mslxdff 0.1.239 → 0.1.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/multi-ip-pool.js +141 -0
- package/src/cli/index.js +3 -0
- package/src/multi-ip-pool/config.js +143 -0
- package/src/multi-ip-pool/dial.js +176 -0
- package/src/multi-ip-pool/dispatch.js +244 -0
- package/src/multi-ip-pool/index.js +174 -0
- package/src/multi-ip-pool/nodes.js +191 -0
- package/src/multi-ip-pool/report.js +157 -0
- package/src/multi-ip-pool/selftest.js +162 -0
- package/src/multi-ip-pool/standby.js +157 -0
- package/src/multi-ip-pool/tunnel.js +280 -0
- package/src/v6pool/index.js +7 -2
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// multi-ip-pool · 日志文案(判据来自 warp-pool/report.js 的同一套纪律):
|
|
2
|
+
// **「看完日志还需要的下一个命令,就是日志没写好的地方」**。
|
|
3
|
+
// 行首符号:▸步骤 / ✓正常 / ✗失败 / ⚠警告;每条失败行都要带「为什么 + 下一步」。
|
|
4
|
+
const SYM = { step: "▸", ok: "✓", bad: "✗", warn: "⚠" };
|
|
5
|
+
const TAG = "[multi-ip-pool]";
|
|
6
|
+
|
|
7
|
+
export function fmtConfig(cfg) {
|
|
8
|
+
return `${TAG} ${SYM.step} 多出口池配置:隧道 ${cfg.count} 条|备选 ${cfg.standby} 条(每 ${Math.round((cfg.standbyEveryMs || 0) / 60_000)} 分钟重拨一批)|打 hi 用 ${cfg.probeModel || "缺省免费模型"}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|Score ≥ ${cfg.minScore}|表号 ${cfg.baseTable}+|目录 ${cfg.dir}|openvpn ${cfg.openvpnBin}|轮换 ${cfg.rotate ? "开" : "关"}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function fmtDisabled() {
|
|
12
|
+
return `${TAG} 出口池已关闭(MSLXDFF_MULTI_IP_POOL=0),请求走直连`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function fmtRoleSkip({ role, why }) {
|
|
16
|
+
return `${TAG} ${SYM.step} 跳过:本节点角色=${role}(${why})—— 出口池只由组长建,组员与单机都不建`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function fmtRoleOk({ role, why }) {
|
|
20
|
+
return `${TAG} ${SYM.ok} 角色门通过:role=${role}(${why})`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function fmtPlatformSkip({ platform, why }) {
|
|
24
|
+
return `${TAG} ${SYM.step} 跳过:${why}(当前平台 ${platform})—— 本功能要 Linux 的 tun 设备与 ip rule,其它平台请用 v6 池或 WARP 池`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fmtNoRoot() {
|
|
28
|
+
return `${TAG} ${SYM.step} 跳过:需要 root(要建 tun 设备 + 改策略路由 + 写 rp_filter)。下一步:sudo mslxdff -multi-ip-pool test(或让 root 起 daemon)`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function fmtNoOpenvpn({ bin, why }) {
|
|
32
|
+
return `${TAG} ${SYM.step} 跳过:没找到可用的 ${bin}(${why})。下一步:apt install openvpn(或 MSLXDFF_MULTI_IP_OPENVPN 指到全路径)`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function fmtNodes({ source, total, countries, minScore }) {
|
|
36
|
+
return `${TAG} ${SYM.ok} 节点表来自 ${source}:共 ${total} 个节点(筛选:国家 ${countries.length ? countries.join("/") : "不限"}、Score ≥ ${minScore})`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fmtNodePick({ i, n, row, dev }) {
|
|
40
|
+
return `${TAG} ${SYM.step} [${i}/${n}] 尝试 ${row.hostName}(${row.countryShort || "?"} ${row.ip} Score=${row.score} ping=${row.ping}ms)→ ${dev}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function fmtSpawn({ dev, pid, host, port, country, table }) {
|
|
44
|
+
return `${TAG} ${SYM.ok} ${dev} 隧道就绪(pid=${pid},出口节点 ${host}:${port} ${country},策略表 ${table})`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function fmtTunAddrFail({ dev, out }) {
|
|
48
|
+
return `${TAG} ${SYM.bad} ${dev} 起来了但读不到 tun 地址(ip -4 addr show dev ${dev} 的输出:${(out || "(空)").slice(0, 200)})—— 隧道建不起来,这槽换下一个节点`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function fmtRuleFail({ dev, cmd, error }) {
|
|
52
|
+
return `${TAG} ${SYM.bad} ${dev} 装策略路由失败:${cmd} → ${error}。下一步:ip rule show 看优先级、ip route show table all 看表号,是否被别人的规则/表号占了(可改 MSLXDFF_MULTI_IP_BASE_TABLE)`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function fmtRpf({ ok, skipped, changed = [], error = "" } = {}) {
|
|
56
|
+
if (skipped) return `${TAG} ${SYM.warn} rp_filter 未改(MSLXDFF_MULTI_IP_SYSCTL=0):若隧道通了但请求全挂住,先查这里`;
|
|
57
|
+
if (ok && !changed.length) return `${TAG} ${SYM.ok} rp_filter 已是宽松模式(2),无需改动`;
|
|
58
|
+
if (ok) return `${TAG} ${SYM.warn} 已把 rp_filter 改为 2(宽松):${changed.map((c) => `${c.path}: ${c.from}→2`).join(",")}|还原命令:sysctl -w net.ipv4.conf.all.rp_filter=1`;
|
|
59
|
+
return `${TAG} ${SYM.warn} rp_filter 改不动(${error}):可能隧道活着但请求挂住(不对称路由被内核丢了)。下一步:sysctl -w net.ipv4.conf.all.rp_filter=2`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function fmtProbe({ dev, url, ok, status, error, ms }) {
|
|
63
|
+
if (ok) return `${TAG} ${SYM.ok} ${dev} 探活通过:${url} → HTTP ${status}(${ms}ms)`;
|
|
64
|
+
return `${TAG} ${SYM.bad} ${dev} 探活不通:${url} → ${status ? `HTTP ${status}` : String(error || "无响应").slice(0, 120)}(${ms}ms)`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function fmtProbeGiveUp({ dev, attempts, url }) {
|
|
68
|
+
return `${TAG} ${SYM.bad} ${dev} 连续 ${attempts} 次探活都不过,本槽不进池(${url})—— 隧道进程活着但转不动流量时进池会把请求挂到超时,比直连更糟`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function fmtExitIp({ dev, ip, ms, prev }) {
|
|
72
|
+
const what = prev ? (prev === ip ? `出口没变(仍是 ${ip})` : `出口已换:${prev} → ${ip}`) : `出口 ${ip}`;
|
|
73
|
+
return `${TAG} ${SYM.ok} ${dev} 出口取证:${what}(${ms}ms)`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function fmtExitIpFail({ dev, error }) {
|
|
77
|
+
return `${TAG} ${SYM.bad} ${dev} 出口取证失败:${String(error || "").slice(0, 160)}—— 这条隧道的出口未知,不视为可用`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fmtPool({ pooled, tried, exits }) {
|
|
81
|
+
return `${TAG} ${SYM.ok} 出口池就绪:${pooled}/${tried} 槽可用|出口 IP:${exits.length ? exits.join(",") : "(待取证)"}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fmtPoolEmpty({ tried, why }) {
|
|
85
|
+
return `${TAG} ${SYM.bad} 出口池为空(试了 ${tried} 个节点都没起通):${why}—— 请求继续走直连,不影响其它出口源`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function fmtSwap({ dev, from, to, status, kind, forced, swaps }) {
|
|
89
|
+
return `${TAG} ${SYM.step} ${dev} 上游 ${status} 判为「${kind}」⇒ 换出口${forced ? "(强制,跳过冷却)" : ""}:${from} → ${to}(第 ${swaps} 次换)`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function fmtSwapFail({ dev, error }) {
|
|
93
|
+
return `${TAG} ${SYM.bad} ${dev} 换出口失败:${String(error || "").slice(0, 200)}—— 原槽保持原状,不把池打空`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function fmtFirstOk({ dev, ip, swaps, counts }) {
|
|
97
|
+
return `${TAG} ${SYM.ok} ${dev} 换出口后首个 200(出口 ${ip},换过 ${swaps} 次,本轮状态分布 ${JSON.stringify(counts)})—— 这就是「换 IP 有没有换来新额度」的直接证据`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function fmtNoRotate({ dev, status, kind }) {
|
|
101
|
+
return `${TAG} ${SYM.warn} ${dev} 上游 ${status}(${kind})—— 轮换开关关着(MSLXDFF_MULTI_IP_ROTATE=0),不换出口`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function fmtVerdictNo({ dev, status, kind, why }) {
|
|
105
|
+
return `${TAG} ${SYM.step} ${dev} 上游 ${status} 判为「${kind}」⇒ 不换出口:${why}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function fmtDirect({ reason }) {
|
|
109
|
+
return `${TAG} ${SYM.warn} 全部槽都在冷却/换出口中,本发走直连兜底:${reason}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function fmtExit({ dev, code, signal, pid, aliveMs, tail }) {
|
|
113
|
+
return `${TAG} ${SYM.warn} ${dev} 隧道进程退出(code=${code} signal=${signal} pid=${pid}${aliveMs ? ` 存活 ${Math.round(aliveMs / 1000)}s` : ""}):${tail || "(无输出)"}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function fmtClose({ closed, failed = 0 }) {
|
|
117
|
+
return `${TAG} ${SYM.step} 收摊:已关 ${closed} 条隧道${failed ? `,${failed} 条没关干净(下次启动查残留:ip link show | grep ${""}mltun)` : ""}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function fmtResidual({ dev }) {
|
|
121
|
+
return `${TAG} ${SYM.warn} 发现上一代留下的设备 ${dev}(上次 daemon 被强杀时子进程不会自己退):下一步 ip link del ${dev}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 免费层判决的人话名(与 `warp-pool/verdict.js` 的 kind 一一对应,不自造一套)。 */
|
|
125
|
+
const KIND_ZH = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
|
|
126
|
+
|
|
127
|
+
/** 「打一个 hi 到上游、要它 200」的读数行:只有 200 才算"这个出口此刻真有额度"。 */
|
|
128
|
+
export function fmtHi({ dev, model, status, kind, ip, ms, error } = {}) {
|
|
129
|
+
const who = `${dev}${ip ? `(出口 ${ip})` : ""}`;
|
|
130
|
+
if (status === 200) return `${TAG} ${SYM.ok} ${who} 打 hi 通过:${model} → HTTP 200(${ms}ms)—— 这个出口有额度`;
|
|
131
|
+
return `${TAG} ${SYM.bad} ${who} 打 hi 未通:${model} → ${status ? `HTTP ${status}(${KIND_ZH[kind] || kind || "?"})` : String(error || "无响应").slice(0, 120)}(${ms}ms)—— 没额度的出口不当备用,换下一个`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 备用出口收下:一条已验通(hi 200)的隧道躺在备选池里等 429。 */
|
|
135
|
+
export function fmtStandbyAdd({ dev, node, exitIp, tunIp, have, want } = {}) {
|
|
136
|
+
return `${TAG} ${SYM.ok} 备选池 +1:${dev} ${node || "?"}(tun ${tunIp},出口 ${exitIp || "未取证"})→ 现有 ${have}/${want} 条待命`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 补位结论行(每小时一次,也是"这轮找到几个新 IP"的读数)。 */
|
|
140
|
+
export function fmtStandbyRefill({ added, have, want, dialed } = {}) {
|
|
141
|
+
return `${TAG} ${SYM.step} 备选池补位:本轮拨 ${dialed} 条、收下 ${added} 条 → ${have}/${want} 待命(标准是 hi 回 200,不是"隧道起来了")`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 取用备用出口那一刻的读数(429 后"立即启用"的证据)。 */
|
|
145
|
+
export function fmtStandbyUse({ dev, tunIp, exitIp, node, left } = {}) {
|
|
146
|
+
return `${TAG} ${SYM.ok} 启用备用出口:${dev} ${node || "?"}(tun ${tunIp},出口 ${exitIp || "未取证"})→ 剩余备用 ${left} 条`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 备用出口被丢弃的原因(复验没过 200 = 这一小时里那个 IP 的额度被别人吃光了)。 */
|
|
150
|
+
export function fmtStandbyDrop({ dev, node, status, kind, error } = {}) {
|
|
151
|
+
return `${TAG} ${SYM.warn} 丢弃备用出口 ${dev} ${node || "?"}:复验 hi → ${status ? `HTTP ${status}(${KIND_ZH[kind] || kind || "?"})` : String(error || "无响应").slice(0, 120)}—— 宁缺毋滥,不把没额度的 IP 交给请求`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 备选池这轮没干成事(取不到候选 / 拨号异常):必须留一行,否则"为什么没有备胎"无从判断。 */
|
|
155
|
+
export function fmtStandbySkip({ why } = {}) {
|
|
156
|
+
return `${TAG} ${SYM.warn} 备选池本轮跳过:${why}—— 429 时若备胎为空只能现场拨号(慢),原因就是这条`;
|
|
157
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// multi-ip-pool · 出口额度对照测试:回答「这台机器的直连出口撞 429 时,换成 VPNGate 出口还有没有额度」。
|
|
2
|
+
//
|
|
3
|
+
// 这才是多出口池的**唯一价值判据**(opencode 免费层按**出口 IP** 计额,见 docs/domain-notes.md §5):
|
|
4
|
+
// 基线(直连)429 而某个 VPN 出口 200 ⇒ 池有用;VPN 出口也全 429 ⇒ 换 IP 抬不动(上游按别的东西计额)。
|
|
5
|
+
// 对照纪律:**除"从哪里出网"外一切相同** —— 同 URL / 同 UA / 同身份四件套 header / 同模型 /
|
|
6
|
+
// 同 `stream:true` + 五核心工具名;整轮共用同一个 session(免费层按出口 IP 计额,共用 session 才测得干净)。
|
|
7
|
+
// 请求形状**直接复用** `v6pool/selftest.js`(同一份免费层契约,绝不在两处各写一遍)。
|
|
8
|
+
import { getUndici } from "../compat.js";
|
|
9
|
+
import { genId } from "../opencode-identity.js";
|
|
10
|
+
import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
|
|
11
|
+
import { classifyWarpSignal } from "../warp-pool/verdict.js";
|
|
12
|
+
import { resolveMultiIpConfig, resolveRole } from "./config.js";
|
|
13
|
+
import { candidateNodes, fetchNodes, loadNodeCache, materializeNode, probeExitIp } from "./nodes.js";
|
|
14
|
+
import { bringUpTunnel, checkOpenvpn, isRoot, isSupportedPlatform, tearDownTunnel } from "./tunnel.js";
|
|
15
|
+
import { fmtExitIp, fmtNodePick, fmtNodes, fmtProbe } from "./report.js";
|
|
16
|
+
import { loadGroupsJoined } from "../state.js";
|
|
17
|
+
|
|
18
|
+
/** 采样上限:VPNGate 免费节点起隧道是秒级操作,别把测试拖成十几分钟。 */
|
|
19
|
+
export const MITEST_MAX = 8;
|
|
20
|
+
export const MITEST_DEFAULT = 3;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 跑一次出口对照测试(本模块不打印,结构化返回给 CLI 渲染)。
|
|
24
|
+
*
|
|
25
|
+
* @param {{ env?: object, count?: number, model?: string, baseUrl?: string,
|
|
26
|
+
* onProgress?: (ev: object) => void,
|
|
27
|
+
* deps?: { joined?, platform?, supported?, isRoot?, checkOpenvpn?, loadNodeCache?, fetchNodes?,
|
|
28
|
+
* materializeNode?, bringUpTunnel?, tearDownTunnel?, agentFactory?, probeExitIp?,
|
|
29
|
+
* probeUpstream?, fetchImpl?, session?, log? } }} opts
|
|
30
|
+
*/
|
|
31
|
+
export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFAULT, model = DEFAULT_PROBE_MODEL, baseUrl = "https://opencode.ai", onProgress, deps = {} } = {}) {
|
|
32
|
+
const cfg = resolveMultiIpConfig(env);
|
|
33
|
+
const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
|
|
34
|
+
const log = deps.log || (() => {});
|
|
35
|
+
|
|
36
|
+
if (!cfg.enabled) return { status: "skip", reason: "多出口池已关闭(MSLXDFF_MULTI_IP_POOL=0)", hint: "去掉该 env 或设为 1 再跑" };
|
|
37
|
+
const role = resolveRole({ mode: cfg.mode, joined: deps.joined ?? loadGroupsJoined() });
|
|
38
|
+
if (!role.active) return { status: "skip", reason: `本节点角色是 ${role.role}(${role.why})`, hint: "本功能只给组长做取证;组员请用组长那条出口" };
|
|
39
|
+
const platform = deps.platform || process.platform;
|
|
40
|
+
if (!(deps.supported ?? isSupportedPlatform(platform))) {
|
|
41
|
+
return { status: "skip", reason: `本功能需要 Linux 的 tun 设备与策略路由(当前平台 ${platform})`, hint: "在 Linux VPS 上跑;其它平台用 mslxdff -v6-pool 或 WARP 池" };
|
|
42
|
+
}
|
|
43
|
+
if (!(deps.isRoot ?? isRoot)()) {
|
|
44
|
+
return { status: "skip", reason: "需要 root(建 tun 设备 + 装策略路由 + 写 rp_filter)", hint: "sudo mslxdff -multi-ip-pool test" };
|
|
45
|
+
}
|
|
46
|
+
const openvpn = await (deps.checkOpenvpn || checkOpenvpn)(cfg.openvpnBin);
|
|
47
|
+
if (!openvpn.ok) return { status: "skip", reason: `${cfg.openvpnBin} 不可用(${openvpn.why})`, hint: "apt install openvpn" };
|
|
48
|
+
|
|
49
|
+
const { Agent } = getUndici();
|
|
50
|
+
const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, connections: 1, keepAliveTimeout: 5_000 }));
|
|
51
|
+
const probe = deps.probeUpstream || probeUpstreamFrom;
|
|
52
|
+
const session = deps.session || genId("ses_");
|
|
53
|
+
const materialize = deps.materializeNode || materializeNode;
|
|
54
|
+
const startSlot = deps.bringUpTunnel || bringUpTunnel;
|
|
55
|
+
const stopSlot = deps.tearDownTunnel || tearDownTunnel;
|
|
56
|
+
const exitProbe = deps.probeExitIp || probeExitIp;
|
|
57
|
+
|
|
58
|
+
// 节点表:优先用缓存(不重复打 VPNGate),取不到再拉。
|
|
59
|
+
const cache = (deps.loadNodeCache || loadNodeCache)({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
|
|
60
|
+
let rows = cache?.rows || [];
|
|
61
|
+
let source = cache?.source || "(本地缓存)";
|
|
62
|
+
if (!rows.length) {
|
|
63
|
+
try {
|
|
64
|
+
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
65
|
+
rows = got.rows;
|
|
66
|
+
source = got.source;
|
|
67
|
+
} catch (e) {
|
|
68
|
+
return { status: "skip", reason: `节点表取不到:${String(e?.message || e)}`, hint: "先在能连 vpngate.net 的网络里跑一次,或设 MSLXDFF_MULTI_IP_SOURCES" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
tick({ phase: "nodes", source, total: rows.length });
|
|
72
|
+
log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
|
|
73
|
+
|
|
74
|
+
// ① 基线:直连(系统默认出口)打一条免费层请求
|
|
75
|
+
tick({ phase: "baseline-start", model });
|
|
76
|
+
const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
|
|
77
|
+
tick({ phase: "baseline-done", ...baseline });
|
|
78
|
+
|
|
79
|
+
const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
|
|
80
|
+
if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
|
|
81
|
+
|
|
82
|
+
const exits = [];
|
|
83
|
+
try {
|
|
84
|
+
for (let i = 0; i < picked.length; i++) {
|
|
85
|
+
const row = picked[i];
|
|
86
|
+
const dev = `${cfg.devPrefix}${i}`;
|
|
87
|
+
tick({ phase: "exit-start", i: i + 1, n: picked.length, row });
|
|
88
|
+
log(fmtNodePick({ i: i + 1, n: picked.length, row, dev }));
|
|
89
|
+
let slot = null;
|
|
90
|
+
try {
|
|
91
|
+
const node = materialize({ dir: cfg.dir, row });
|
|
92
|
+
if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
|
|
93
|
+
slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile: deps.authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
|
|
94
|
+
const exit = await exitProbe({ agent: agentFactory(slot.tunIp), timeoutMs: cfg.probeTimeoutMs });
|
|
95
|
+
if (exit.ok) log(fmtExitIp({ dev: slot.dev, ip: exit.ip, ms: exit.ms }));
|
|
96
|
+
// 从这条隧道的出口打**同一条**免费层请求
|
|
97
|
+
const r = await probe({ addr: slot.tunIp, model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
|
|
98
|
+
log(fmtProbe({ dev: slot.dev, url: `${baseUrl.replace(/\/+$/, "")}/zen/v1/chat/completions`, ok: r.ok, status: r.status, error: r.error, ms: r.ms }));
|
|
99
|
+
const entry = { ...r, hostName: row.hostName, country: row.countryShort, dev: slot.dev, exitIp: exit.ip || "" };
|
|
100
|
+
exits.push(entry);
|
|
101
|
+
tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
|
|
102
|
+
} catch (e) {
|
|
103
|
+
const entry = { hostName: row.hostName, country: row.countryShort, dev, ok: false, status: 0, kind: "network", ms: 0, exitIp: "", error: String(e?.message || e) };
|
|
104
|
+
exits.push(entry);
|
|
105
|
+
tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
|
|
106
|
+
} finally {
|
|
107
|
+
if (slot) await stopSlot({ slot, runCmdFn: deps.runCmdFn, log }).catch(() => {});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} catch (e) {
|
|
111
|
+
return { status: "skip", reason: `测试中断:${String(e?.message || e)}`, hint: "看 daemon.log 里的 [multi-ip-pool] 行" };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ③ 判语:对照基线(受限 = 有 HTTP 响应但拿不到额度;只有 status=0 才算"够不着")
|
|
115
|
+
const baseLimited = baseline.status === 429 || ["free-quota", "free-tier", "ip-limit"].includes(baseline.kind);
|
|
116
|
+
const ok = exits.filter((r) => r.ok);
|
|
117
|
+
const limited = exits.filter((r) => !r.ok && r.status);
|
|
118
|
+
const net = exits.filter((r) => !r.status);
|
|
119
|
+
let verdict;
|
|
120
|
+
if (ok.length && ok.length === exits.length) verdict = "multi-ip-works";
|
|
121
|
+
else if (limited.length === exits.length) verdict = "multi-ip-also-limited";
|
|
122
|
+
else if (net.length === exits.length) verdict = "multi-ip-unreachable";
|
|
123
|
+
else verdict = "mixed";
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
status: "ok",
|
|
127
|
+
source, model, baseUrl, session,
|
|
128
|
+
baseLimited,
|
|
129
|
+
baseline,
|
|
130
|
+
exits,
|
|
131
|
+
requested: picked.length,
|
|
132
|
+
okCount: ok.length,
|
|
133
|
+
limitedCount: limited.length,
|
|
134
|
+
netCount: net.length,
|
|
135
|
+
verdict,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 判语 → 人话(CLI 与测试共用一份,避免两处口径漂移)。 */
|
|
140
|
+
export const VERDICT_ZH = {
|
|
141
|
+
"multi-ip-works": {
|
|
142
|
+
text: "VPN 出口**能换到新额度**",
|
|
143
|
+
next: "结论:多出口池对这台机器**有用**——直连出口耗尽时换隧道就能续。daemon 侧缺省已开。",
|
|
144
|
+
},
|
|
145
|
+
"multi-ip-also-limited": {
|
|
146
|
+
text: "VPN 出口**也全受限**(换 IP 没换来新额度)",
|
|
147
|
+
next: "结论:换出口治不了(要么上游不按出口 IP 计额,要么节点出口本身已被大量共用到撞额)。建议 MSLXDFF_MULTI_IP_POOL=0。",
|
|
148
|
+
},
|
|
149
|
+
"multi-ip-unreachable": {
|
|
150
|
+
text: "VPN 出口**全都够不着上游**(隧道没转起来)",
|
|
151
|
+
next: "结论:这些节点起不通或上游被墙。查 rp_filter / 节点可用性,或换 MSLXDFF_MULTI_IP_SOURCES / 加国家白名单。",
|
|
152
|
+
},
|
|
153
|
+
mixed: {
|
|
154
|
+
text: "部分可用(有的出口有额度、有的没有)",
|
|
155
|
+
next: "结论:池**部分可用**(免费节点质量参差,属正常)。够用即可开着。",
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** 判语里的免费层分类(与生产判决同源:warp-pool/verdict.js)。 */
|
|
160
|
+
export function classifyOf(status, text = "") {
|
|
161
|
+
return classifyWarpSignal({ status, text });
|
|
162
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// multi-ip-pool · 备选出口池:**定时预拨** 不多于 N 条已验通的隧道,429 一到立刻有干净 IP 可上。
|
|
2
|
+
//
|
|
3
|
+
// 用户 2026-10-25 的裁决(本模块存在的唯一理由):
|
|
4
|
+
// 「启用时定时准备不多于 5 个备选 IP(例如每小时找 5 个备用),一旦组长触发 429,
|
|
5
|
+
// 可以**立即**启用其中的备用 IP;启用备用 IP 时要打一个 hi 到 opencode.ai,要它 200。」
|
|
6
|
+
// ⇒ 换出口那一刻绝不能再现场拨号(openvpn 握手秒级 + 候选可能全不通,429 现场等 = 用户在等),
|
|
7
|
+
// 而是**平时就把备用隧道起好、并逐条用 hi 验过有 200**,429 时只做"取一条"。
|
|
8
|
+
//
|
|
9
|
+
// 备用条目两条铁律:
|
|
10
|
+
// ① 只有 hi 回 200 的才算"备用"(有额度的 IP),429/403 的当场拆掉重拨下一条;
|
|
11
|
+
// ② 交付前**再验一次** hi——免费节点的出口 IP 是别人在用的,一小时里可能已经被别人吃光额度。
|
|
12
|
+
// 验不过就丢掉取下一条(宁可少一条出口,也不把一个没额度的 IP 交给请求路径)。
|
|
13
|
+
import { fmtStandbyAdd, fmtStandbyDrop, fmtStandbyRefill, fmtStandbySkip, fmtStandbyUse } from "./report.js";
|
|
14
|
+
|
|
15
|
+
/** 备用槽的索引偏移:设备名/表号与正式槽彻底错开(正式槽 mltun0..、备用 mltun100..、表 baseTable+100..)。 */
|
|
16
|
+
export const STANDBY_INDEX_BASE = 100;
|
|
17
|
+
/** 备用槽索引循环上限(设备名 ≤15 字符:mltun + 3 位)。 */
|
|
18
|
+
export const STANDBY_INDEX_SPAN = 100;
|
|
19
|
+
/** 一轮补位最多拨多少次(防候选全不通时把 VPNGate 与本机刷成灾)。 */
|
|
20
|
+
export const REFILL_DIAL_CAP = 12;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ cfg: object, log?: Function, dialer: object,
|
|
24
|
+
* schedule?: (fn: Function, ms: number) => any, cancel?: (h: any) => void, now?: () => number }} opts
|
|
25
|
+
* dialer 需要提供:openSlot(index) / ensureCandidates({force}) / hiProbe(entry) / drop(entry) / tunnels(Set)
|
|
26
|
+
*/
|
|
27
|
+
export function createStandby({ cfg, log = () => {}, dialer, schedule = (fn, ms) => setInterval(fn, ms), cancel = (h) => clearInterval(h), now = Date.now } = {}) {
|
|
28
|
+
/** @type {object[]} 已验通的备用出口(FIFO,最旧的先交出去) */
|
|
29
|
+
const ready = [];
|
|
30
|
+
let timer = null;
|
|
31
|
+
let busy = null;
|
|
32
|
+
let counter = 0;
|
|
33
|
+
|
|
34
|
+
function want() {
|
|
35
|
+
return Math.max(0, Math.min(5, Number(cfg.standby) || 0)); // 用户定的硬上限:不多于 5
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stats() {
|
|
39
|
+
return {
|
|
40
|
+
want: want(),
|
|
41
|
+
have: ready.length,
|
|
42
|
+
everyMs: Number(cfg.standbyEveryMs) || 0,
|
|
43
|
+
exits: ready.map((e) => ({ dev: e.dev, node: e.node?.hostName || "", tunIp: e.tunIp, exitIp: e.exitIp || "", verifiedAt: e.verifiedAt || 0 })),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 拨一条新备用隧道并验它(不 200 就地拆掉,调用方继续下一条)。 */
|
|
48
|
+
async function dialOne() {
|
|
49
|
+
const index = STANDBY_INDEX_BASE + (counter++ % STANDBY_INDEX_SPAN);
|
|
50
|
+
const e = await dialer.openSlot(index, { dev: "" });
|
|
51
|
+
if (!e) return null;
|
|
52
|
+
e.verifiedAt = now();
|
|
53
|
+
ready.push(e);
|
|
54
|
+
log(fmtStandbyAdd({ dev: e.dev, node: e.node?.hostName || "", exitIp: e.exitIp || "", tunIp: e.tunIp, have: ready.length, want: want() }));
|
|
55
|
+
return e;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 补到 want() 条。同一时刻只跑一轮(定时器与 429 后的补位会撞车);每轮开头强制重取节点表——
|
|
60
|
+
* 免费节点掉得快,一小时前的候选表多半已经拨不通了。
|
|
61
|
+
* @returns {Promise<{added: number, have: number, want: number}>}
|
|
62
|
+
*/
|
|
63
|
+
function refill({ force = false } = {}) {
|
|
64
|
+
if (!want()) return Promise.resolve({ added: 0, have: 0, want: 0 });
|
|
65
|
+
if (busy) return busy;
|
|
66
|
+
const run = async () => {
|
|
67
|
+
if (force || ready.length < want()) {
|
|
68
|
+
try {
|
|
69
|
+
await dialer.ensureCandidates({ force: force || ready.length === 0 });
|
|
70
|
+
} catch (e) {
|
|
71
|
+
log(fmtStandbySkip({ why: `取节点表失败:${String(e?.message || e)}` }));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
let dialed = 0;
|
|
75
|
+
const before = ready.length;
|
|
76
|
+
while (ready.length < want() && dialed < REFILL_DIAL_CAP) {
|
|
77
|
+
dialed++;
|
|
78
|
+
try {
|
|
79
|
+
const got = await dialOne();
|
|
80
|
+
if (!got) break; // 候选用尽(openSlot 内部已换过一轮)
|
|
81
|
+
} catch (e) {
|
|
82
|
+
log(fmtStandbySkip({ why: `拨备用隧道出错:${String(e?.message || e)}` }));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
log(fmtStandbyRefill({ added: ready.length - before, have: ready.length, want: want(), dialed }));
|
|
86
|
+
return { added: ready.length - before, have: ready.length, want: want() };
|
|
87
|
+
};
|
|
88
|
+
busy = run().finally(() => {
|
|
89
|
+
busy = null;
|
|
90
|
+
});
|
|
91
|
+
return busy;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 复验一条备用出口:交出去之前必须**这一秒**还是 200。 */
|
|
95
|
+
async function verify(entry) {
|
|
96
|
+
const p = await dialer.hiProbe(entry);
|
|
97
|
+
if (p.status === 200) {
|
|
98
|
+
entry.exitIp = p.ip || entry.exitIp || "";
|
|
99
|
+
entry.verifiedAt = now();
|
|
100
|
+
return entry;
|
|
101
|
+
}
|
|
102
|
+
log(fmtStandbyDrop({ dev: entry.dev, node: entry.node?.hostName || "", status: p.status, kind: p.kind, error: p.error || "" }));
|
|
103
|
+
await dialer.drop(entry, "备用出口复验未过 200");
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 取一条备用出口(429 路径调这个):逐条复验、验不过就丢;池空了先补一轮再取。
|
|
109
|
+
* @returns {Promise<null | object>} 验通的出口条目(可直接当新槽用)
|
|
110
|
+
*/
|
|
111
|
+
async function take() {
|
|
112
|
+
for (let guard = 0; guard < 2 && !ready.length; guard++) await refill();
|
|
113
|
+
while (ready.length) {
|
|
114
|
+
const e = ready.shift();
|
|
115
|
+
const okEntry = await verify(e);
|
|
116
|
+
if (okEntry) {
|
|
117
|
+
log(fmtStandbyUse({ dev: okEntry.dev, tunIp: okEntry.tunIp, exitIp: okEntry.exitIp || "", node: okEntry.node?.hostName || "", left: ready.length }));
|
|
118
|
+
void refill().catch(() => {}); // 用完立刻补位,下一发 429 还得有备胎
|
|
119
|
+
return okEntry;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 起定时器(每小时刷新)。`MSLXDFF_MULTI_IP_STANDBY=0` ⇒ 完全不起。 */
|
|
126
|
+
function start() {
|
|
127
|
+
const w = want();
|
|
128
|
+
const every = Number(cfg.standbyEveryMs) || 0;
|
|
129
|
+
if (!w || !every || timer) return;
|
|
130
|
+
timer = schedule(() => {
|
|
131
|
+
void refreshAll().catch((e) => log(fmtStandbySkip({ why: `定时刷新失败:${String(e?.message || e)}` })));
|
|
132
|
+
}, every);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 周期到点:把旧的备用全部拆掉重拨一批(出口 IP 换新,才叫"每小时找 5 个备用")。 */
|
|
136
|
+
async function refreshAll() {
|
|
137
|
+
const stale = ready.splice(0, ready.length);
|
|
138
|
+
for (const e of stale) await dialer.drop(e, "备选池整批刷新");
|
|
139
|
+
return refill({ force: true });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 收摊:停定时器 + 拆掉所有备用隧道(一条都不许留给下次启动当残留)。 */
|
|
143
|
+
async function stop() {
|
|
144
|
+
if (timer) {
|
|
145
|
+
try {
|
|
146
|
+
cancel(timer);
|
|
147
|
+
} catch {
|
|
148
|
+
/* 忽略 */
|
|
149
|
+
}
|
|
150
|
+
timer = null;
|
|
151
|
+
}
|
|
152
|
+
const all = ready.splice(0, ready.length);
|
|
153
|
+
for (const e of all) await dialer.drop(e, "收摊");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { refill, take, start, stop, refreshAll, stats, get size() { return ready.length; } };
|
|
157
|
+
}
|