mslxdff 0.1.239 → 0.1.241
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 +174 -0
- package/src/cli/index.js +3 -0
- package/src/multi-ip-pool/config.js +155 -0
- package/src/multi-ip-pool/dial.js +177 -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-fetch.js +110 -0
- package/src/multi-ip-pool/nodes.js +150 -0
- package/src/multi-ip-pool/report.js +157 -0
- package/src/multi-ip-pool/selftest.js +163 -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
package/package.json
CHANGED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// mslxdff -multi-ip-pool [test|status|help]
|
|
2
|
+
// 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长/单机**专属,只服务 opencode 免费层。
|
|
3
|
+
//
|
|
4
|
+
// 为什么需要这条命令(用户要的"取证"):换出口到底有没有换来新额度,只有实测能回答。
|
|
5
|
+
// 本命令做 A/B:直连打一条免费层请求取基线 → 逐条 VPN 隧道打**同一条**请求 → 对照判语。
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { runMultiIpCompare, VERDICT_ZH, MITEST_DEFAULT, MITEST_MAX } from "../../multi-ip-pool/selftest.js";
|
|
8
|
+
import { fetchNodesToFile } from "../../multi-ip-pool/nodes-fetch.js";
|
|
9
|
+
import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
|
|
10
|
+
import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
|
|
11
|
+
import { loadGroupsJoined } from "../../state.js";
|
|
12
|
+
import { argValue } from "../policy.js";
|
|
13
|
+
|
|
14
|
+
const FLAG = "-multi-ip-pool";
|
|
15
|
+
|
|
16
|
+
function flagValue(args, ...names) {
|
|
17
|
+
for (const name of names) {
|
|
18
|
+
const v = argValue(args, name);
|
|
19
|
+
if (v !== null && v !== undefined && !String(v).startsWith("-")) return v;
|
|
20
|
+
const eq = args.find((a) => a.startsWith(`${name}=`));
|
|
21
|
+
if (eq) return eq.slice(eq.indexOf("=") + 1);
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
|
|
27
|
+
const line = (r) => {
|
|
28
|
+
if (r.error) return `✗ 不可达(${String(r.error).slice(0, 60)})`;
|
|
29
|
+
if (r.ok) return `✅ HTTP ${r.status} 有额度`;
|
|
30
|
+
return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function handleMultiIpPool(args = []) {
|
|
34
|
+
const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
|
|
35
|
+
if (at < 0) return false;
|
|
36
|
+
const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
|
|
37
|
+
if (["-h", "--help", "help"].includes(sub)) return printUsage();
|
|
38
|
+
if (sub === "status") return await printStatus(args);
|
|
39
|
+
if (sub === "fetch" || sub === "grab") return await runFetch(args);
|
|
40
|
+
return await runTest(args);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function runTest(args) {
|
|
44
|
+
const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? MITEST_DEFAULT : Math.max(1, Math.min(MITEST_MAX, Number(v) || MITEST_DEFAULT)); })();
|
|
45
|
+
const model = flagValue(args, "--model", "-m") || "";
|
|
46
|
+
console.log("=".repeat(66));
|
|
47
|
+
console.log(" 多出口池取证 —— 「直连 429 时,换成 VPN 出口还有额度吗」");
|
|
48
|
+
console.log("=".repeat(66));
|
|
49
|
+
console.log("");
|
|
50
|
+
console.log(` 探测模型 ${model || "(默认免费模型)"}|采样 ${count} 条隧道|只换出口,其余请求完全相同`);
|
|
51
|
+
console.log("");
|
|
52
|
+
|
|
53
|
+
const onProgress = (ev) => {
|
|
54
|
+
if (ev.phase === "nodes") console.log(` ▸ 节点表:${ev.source}(${ev.total} 个节点)`);
|
|
55
|
+
else if (ev.phase === "baseline-start") console.log(" ▸ 基线:直连(系统默认出口)打一条免费层请求…");
|
|
56
|
+
else if (ev.phase === "baseline-done") {
|
|
57
|
+
console.log(` 基线结果:${line(ev)}${ev.body ? `(${String(ev.body).slice(0, 80).replace(/\s+/g, " ")})` : ""}`);
|
|
58
|
+
console.log("");
|
|
59
|
+
} else if (ev.phase === "exit-start") console.log(` [${String(ev.i).padStart(2)}/${ev.n}] ${ev.row.hostName}(${ev.row.countryShort || "?"})…`);
|
|
60
|
+
else if (ev.phase === "exit-done") console.log(` ${line(ev)}${ev.exitIp ? `|出口 IP ${ev.exitIp}` : ""}`);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let r;
|
|
64
|
+
try {
|
|
65
|
+
r = await runMultiIpCompare({ count, ...(model ? { model } : {}), onProgress });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
console.log("");
|
|
68
|
+
console.log(`❌ 测试执行出错:${e?.message ?? e}`);
|
|
69
|
+
console.log(` 重试:mslxdff ${FLAG}`);
|
|
70
|
+
process.exit(1);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
if (r.status === "skip") {
|
|
74
|
+
console.log(`❌ 测不了:${r.reason}`);
|
|
75
|
+
console.log(` 下一步:${r.hint}`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log("-".repeat(66));
|
|
82
|
+
console.log(` 基线(直连):${line(r.baseline)}`);
|
|
83
|
+
console.log(` VPN 出口:有额度 ${r.okCount}|受限 ${r.limitedCount}|不可达 ${r.netCount}(共 ${r.requested})`);
|
|
84
|
+
console.log("");
|
|
85
|
+
const v = VERDICT_ZH[r.verdict] || { text: "未判定", next: "" };
|
|
86
|
+
const mark = r.verdict === "multi-ip-works" ? "✅" : r.verdict === "mixed" ? "△" : "⛔";
|
|
87
|
+
console.log(` ${mark} ${v.text}:${r.okCount}/${r.requested} 条隧道拿到 200`);
|
|
88
|
+
if (v.next) console.log(` ${v.next}`);
|
|
89
|
+
console.log("");
|
|
90
|
+
console.log(` 重试:mslxdff ${FLAG} --count ${r.requested}${model ? ` --model ${model}` : ""}`);
|
|
91
|
+
process.exit(r.verdict === "multi-ip-works" || r.verdict === "mixed" ? 0 : 1);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `-multi-ip-pool fetch [路径]`:在**能连节点源**的机器上抓一份 VPNGate 表落成文件,scp 到受限网络的组长机,
|
|
96
|
+
* 那边设 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可完全跳过取表网络请求(2026-10-25 真机:华为云 ECS 上 vpngate.net 不可达)。
|
|
97
|
+
*/
|
|
98
|
+
async function runFetch(args) {
|
|
99
|
+
const cfg = resolveMultiIpConfig(process.env);
|
|
100
|
+
const out = flagValue(args, "--out", "-o") || join(cfg.dir, "vpngate.csv");
|
|
101
|
+
console.log(` 节点源(按序试):${cfg.sources.join("\n ")}`);
|
|
102
|
+
console.log("");
|
|
103
|
+
try {
|
|
104
|
+
const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs });
|
|
105
|
+
const usable = got.rows.filter((r) => r.config).length;
|
|
106
|
+
console.log(` ✅ ${got.rows.length} 个节点(${usable} 个配置可解码)已写入:${out}`);
|
|
107
|
+
console.log(` 来源:${got.source}`);
|
|
108
|
+
console.log("");
|
|
109
|
+
console.log(" 带到受限网络的机器上:");
|
|
110
|
+
console.log(` scp ${out} root@<组长机>:/root/.config/mslxdff/multi-ip-pool/vpngate.csv`);
|
|
111
|
+
console.log(" export MSLXDFF_MULTI_IP_NODES_FILE=/root/.config/mslxdff/multi-ip-pool/vpngate.csv");
|
|
112
|
+
console.log(" (之后取表零网络请求;表会旧,免费节点掉得快,建议隔几天重抓一次)");
|
|
113
|
+
process.exit(0);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.log(` ❌ 一个源都没取到:${String(e?.message || e)}`);
|
|
116
|
+
console.log(" 下一步:换网络重试,或 MSLXDFF_MULTI_IP_SOURCES=<你自己能连的镜像/本地路径> 再跑一次");
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function printStatus(args) {
|
|
123
|
+
const cfg = resolveMultiIpConfig(process.env);
|
|
124
|
+
const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
|
|
125
|
+
const platform = process.platform;
|
|
126
|
+
const root = isRoot();
|
|
127
|
+
const openvpn = await checkOpenvpn(cfg.openvpnBin);
|
|
128
|
+
console.log("=".repeat(66));
|
|
129
|
+
console.log(" 多出口池状态(不做任何隧道动作,只回答「开没开、为什么」)");
|
|
130
|
+
console.log("=".repeat(66));
|
|
131
|
+
console.log("");
|
|
132
|
+
console.log(` 开关 ${cfg.enabled ? "开(MSLXDFF_MULTI_IP_POOL 缺省开)" : "关(MSLXDFF_MULTI_IP_POOL=0)"}`);
|
|
133
|
+
console.log(` 角色门 role=${role.role} ⇒ ${role.active ? "通过" : "不建池"}(${role.why})`);
|
|
134
|
+
console.log(` 平台门 ${isSupportedPlatform(platform) ? "通过(Linux)" : `不通过(${platform}:没有 tun + ip rule)`}`);
|
|
135
|
+
console.log(` 权限门 ${root ? "通过(root)" : "不通过(需要 root 才能建 tun 与策略路由)"}`);
|
|
136
|
+
console.log(` openvpn ${openvpn.ok ? `可用(${openvpn.version})` : `不可用(${openvpn.why})`}`);
|
|
137
|
+
console.log(` 隧道数 ${cfg.count}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|Score ≥ ${cfg.minScore}|表号 ${cfg.baseTable}+`);
|
|
138
|
+
console.log(` 备选池 配 ${cfg.standby} 条(每 ${Math.round((cfg.standbyEveryMs || 0) / 60_000)} 分钟重拨一批;入池标准是 hi 回 200,不是"隧道起来了")`);
|
|
139
|
+
console.log(` 实际就绪几条**只有 daemon 知道**(本命令不建池、不拨号)→ 看 mslxdff -log 里的 [multi-ip-pool] 备选池 行`);
|
|
140
|
+
console.log(` 目录 ${cfg.dir}`);
|
|
141
|
+
console.log(` 节点源 ${cfg.nodesFile ? `本地表优先:${cfg.nodesFile}(其后 ${cfg.sources.length - 1} 个网络源)` : cfg.sources.join(",")}`);
|
|
142
|
+
console.log("");
|
|
143
|
+
console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
|
|
144
|
+
process.exit(0);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function printUsage() {
|
|
149
|
+
console.log(`用法: mslxdff ${FLAG} [test|status|fetch|help] [--count N] [--model <id>] [--out <路径>]`);
|
|
150
|
+
console.log("");
|
|
151
|
+
console.log(" 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
|
|
152
|
+
console.log(" 对照判语:");
|
|
153
|
+
console.log(" 基线 429 + VPN 出口有 200 ⇒ 池有用(换 IP 换到新额度)");
|
|
154
|
+
console.log(" 基线 429 + VPN 出口也全 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额)");
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log(" 前置:Linux + root + openvpn(tun 设备与策略路由必需)。非 Linux 如实跳过。");
|
|
157
|
+
console.log("");
|
|
158
|
+
console.log("相关 env(与 daemon 建池同源):");
|
|
159
|
+
console.log(" MSLXDFF_MULTI_IP_POOL=0|1 关 / 开(缺省开)");
|
|
160
|
+
console.log(" MSLXDFF_MULTI_IP_MODE=auto|leader|member 角色门(缺省 auto:**只有组长**建池;组员与单机都不建,强开=leader)");
|
|
161
|
+
console.log(" MSLXDFF_MULTI_IP_COUNT=N 并行隧道数(缺省 2)");
|
|
162
|
+
console.log(" MSLXDFF_MULTI_IP_COUNTRY=JP,KR 国家白名单(缺省不限)");
|
|
163
|
+
console.log(` MSLXDFF_MULTI_IP_MIN_SCORE=N 节点 Score 下限(缺省 1000000)`);
|
|
164
|
+
console.log(" MSLXDFF_MULTI_IP_SOURCES=url,url 节点表来源(缺省 VPNGate 官方 https+http,末位挂 aimili 仓库内镜像兜墙)");
|
|
165
|
+
console.log(" MSLXDFF_MULTI_IP_NODES_FILE=<路径> 本地表文件(排在最前,设了就不打网;配合 -multi-ip-pool fetch 生成)");
|
|
166
|
+
console.log(" MSLXDFF_MULTI_IP_BASE_TABLE=N 策略路由表起始号(缺省 210)");
|
|
167
|
+
console.log(" MSLXDFF_MULTI_IP_SYSCTL=0|1 是否放宽 rp_filter(缺省 1)");
|
|
168
|
+
console.log(" MSLXDFF_MULTI_IP_STANDBY=N 备选出口条数(缺省 5,**上限 5**,0=关)");
|
|
169
|
+
console.log(" MSLXDFF_MULTI_IP_STANDBY_EVERY_MS=N 备选池刷新周期(缺省 3600000=每小时重拨一批)");
|
|
170
|
+
console.log(" MSLXDFF_MULTI_IP_STRATEGY=session 会话粘出口(request=每请求换出口,会撞冷启动)");
|
|
171
|
+
console.log(" MSLXDFF_MULTI_IP_PROBE_MODEL=<id> 打 hi 探额度用的免费模型(空=同款缺省)");
|
|
172
|
+
process.exit(0);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
package/src/cli/index.js
CHANGED
|
@@ -44,6 +44,9 @@ export async function run(args = process.argv.slice(2)) {
|
|
|
44
44
|
const { handleV6Pool } = await import("./commands/v6-pool.js");
|
|
45
45
|
if (await handleV6Pool(args)) return;
|
|
46
46
|
|
|
47
|
+
const { handleMultiIpPool } = await import("./commands/multi-ip-pool.js");
|
|
48
|
+
if (await handleMultiIpPool(args)) return;
|
|
49
|
+
|
|
47
50
|
const { handleProviders, handleProvider } = await import("./commands/provider.js");
|
|
48
51
|
if (await handleProviders(args)) return;
|
|
49
52
|
if (await handleProvider(args)) return;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// multi-ip-pool · 配置解析:env 优先,缺省值集中一处;纯函数,便于单测。
|
|
2
|
+
//
|
|
3
|
+
// 定位:第三.种出口源(与 v6pool 换本机源地址、warp-pool 换 WARP 出口同层)。
|
|
4
|
+
// 思路参照 aimili-vpngate(VPNGate 免费 OpenVPN 节点当出口),但本模块是:
|
|
5
|
+
// ① Node 实现(不再需要 Python 侧那套进程/代理);② **N 槽并行**(参照实现一次只挂 1 条隧道);
|
|
6
|
+
// ③ 只服务 opencode 免费层(免费层按**出口 IP** 计额,见 docs/domain-notes.md §5)。
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { logDir } from "../logs.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 缺省**开** + **只有组长建**(用户 2026-10-25 补裁):`joined` 里存在"自己当组长"的组才建池。
|
|
12
|
+
* 组员、以及**没带任何组的单机都不建**(这种机器上要用,显式 `MSLXDFF_MULTI_IP_MODE=leader`)。
|
|
13
|
+
* 另三条硬门:只有 Linux(tun + ip rule)、要 root、要有 openvpn 可执行文件。
|
|
14
|
+
*/
|
|
15
|
+
export const DEFAULTS = {
|
|
16
|
+
enabled: true,
|
|
17
|
+
mode: "auto", // auto | leader | member
|
|
18
|
+
count: 2, // 并行隧道数:每槽一条 openvpn + 一条策略路由表
|
|
19
|
+
country: "", // 国家白名单(ISO 两字母,逗号分隔;空 = 不限)
|
|
20
|
+
minScore: 1_000_000, // VPNGate Score 下限(低于它的节点基本连不动)
|
|
21
|
+
scan: 8, // 起隧道时最多按序尝试多少个候选
|
|
22
|
+
baseTable: 210, // 策略路由表起始号(避开通信/策略常用表)
|
|
23
|
+
devPrefix: "mltun", // tun 设备名前缀(留足内核 15 字符上限)
|
|
24
|
+
probeModel: "", // 「打 hi 探额度」用的免费模型(空 = 与 v6 池对照测试同款)
|
|
25
|
+
strategy: "session", // 会话粘出口:上游按 session 做粘性路由,每请求换出口会撞冷启动
|
|
26
|
+
standby: 5, // 备选出口上限(用户定:**不多于 5**;0 = 关备选池)
|
|
27
|
+
standbyEveryMs: 60 * 60_000, // 备选池刷新周期(每小时重拨一批新 IP)
|
|
28
|
+
connectTimeoutMs: 25_000, // 等 "Initialization Sequence Completed" 的上限
|
|
29
|
+
probeTimeoutMs: 20_000, // 探活/出口取证单发超时
|
|
30
|
+
cooldownMs: 600_000, // 槽失败冷却
|
|
31
|
+
rotate: true, // 撞额度信号时换槽
|
|
32
|
+
nodesTtlMs: 30 * 60_000, // 节点表缓存有效期(VPNGate 免费节点掉得快)
|
|
33
|
+
openvpnBin: "openvpn",
|
|
34
|
+
authUser: "vpn", // VPNGate 公共账号(非私人凭据)
|
|
35
|
+
authPass: "vpn",
|
|
36
|
+
debug: false,
|
|
37
|
+
sysctl: true, // 放宽 rp_filter(严格反向路径过滤会让隧道"活着但请求挂死")
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* VPNGate 节点源:**官方永远在前**,第三方镜像只作兜墙的最后一条。
|
|
42
|
+
* 镜像 `aimili-vpngate` 仓库内 `mirror/vpngate.csv` 由它的 GitHub Actions **每 15 分钟**从官方表刷新
|
|
43
|
+
* (cron `7,22,37,52 * * * *`),实测 2026-10-25 与官方**同 15 列同 schema**、99 节点全部可解码 ⇒ 解析器零改动。
|
|
44
|
+
* 注意:它自己配的那条 GitHub Pages 镜像 `baoweise-bot.github.io/...` 已 404 死(实测),故不采用。
|
|
45
|
+
* 表内容只是**公开节点的连接配置 + VPNGate 公共账号(vpn/vpn)**,不含任何私人凭据;仍不信任镜像就用
|
|
46
|
+
* `MSLXDFF_MULTI_IP_SOURCES` 只留官方,或 `MSLXDFF_MULTI_IP_NODES_FILE` 自带一份本地表。
|
|
47
|
+
*/
|
|
48
|
+
export const NODE_SOURCES = [
|
|
49
|
+
"https://www.vpngate.net/api/iphone/",
|
|
50
|
+
"http://www.vpngate.net/api/iphone/",
|
|
51
|
+
"https://raw.githubusercontent.com/OpenMili/aimili-vpngate/main/mirror/vpngate.csv",
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const OFF = ["0", "off", "false", "no", "disable", "disabled"];
|
|
55
|
+
const MODES = ["auto", "leader", "member"];
|
|
56
|
+
|
|
57
|
+
function envBool(env, name, fallback) {
|
|
58
|
+
const raw = env[name];
|
|
59
|
+
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
60
|
+
return !OFF.includes(String(raw).trim().toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function envInt(env, name, fallback) {
|
|
64
|
+
const v = Number(env[name]);
|
|
65
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function envStr(env, name, fallback) {
|
|
69
|
+
const raw = env[name];
|
|
70
|
+
return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function envList(env, name, fallback = "") {
|
|
74
|
+
return String(env[name] ?? fallback)
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((s) => s.trim())
|
|
77
|
+
.filter(Boolean);
|
|
78
|
+
}
|
|
79
|
+
/** 备选出口上限:0 = 关;**硬上限 5**(用户 2026-10-25:不多于 5 个备选 IP)。 */
|
|
80
|
+
function standbyCount(env) {
|
|
81
|
+
const raw = Number(env.MSLXDFF_MULTI_IP_STANDBY);
|
|
82
|
+
if (!Number.isFinite(raw) || raw < 0) return DEFAULTS.standby;
|
|
83
|
+
return Math.min(5, Math.floor(raw));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 槽 i 的 tun 设备名(内核上限 15 字符:前缀默认 6 + 序号 ≤2 位,安全)。 */
|
|
87
|
+
export function devNameFor(prefix, i) {
|
|
88
|
+
return `${prefix}${i}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 槽 i 的策略路由表号(每槽独立表:`ip rule from <tun 地址> lookup <table>`)。 */
|
|
92
|
+
export function tableFor(baseTable, i) {
|
|
93
|
+
return baseTable + i;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 解析 multi-ip 出口池配置。
|
|
98
|
+
* @param {Record<string, string|undefined>} env
|
|
99
|
+
* @param {{ logDirFn?: () => string }} [deps] 测试可注入 logDir
|
|
100
|
+
*/
|
|
101
|
+
export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } = {}) {
|
|
102
|
+
const modeRaw = envStr(env, "MSLXDFF_MULTI_IP_MODE", DEFAULTS.mode).toLowerCase();
|
|
103
|
+
// 受限网络兜墙:`MSLXDFF_MULTI_IP_NODES_FILE` 指的本地表**排在最前**(设了就一次网络都不打)。
|
|
104
|
+
const nodesFile = envStr(env, "MSLXDFF_MULTI_IP_NODES_FILE", "");
|
|
105
|
+
const baseSources = envList(env, "MSLXDFF_MULTI_IP_SOURCES").length
|
|
106
|
+
? envList(env, "MSLXDFF_MULTI_IP_SOURCES")
|
|
107
|
+
: NODE_SOURCES;
|
|
108
|
+
const sources = nodesFile ? [nodesFile, ...baseSources.filter((s) => s !== nodesFile)] : baseSources;
|
|
109
|
+
return {
|
|
110
|
+
enabled: envBool(env, "MSLXDFF_MULTI_IP_POOL", DEFAULTS.enabled),
|
|
111
|
+
mode: MODES.includes(modeRaw) ? modeRaw : DEFAULTS.mode,
|
|
112
|
+
count: Math.min(8, envInt(env, "MSLXDFF_MULTI_IP_COUNT", DEFAULTS.count)),
|
|
113
|
+
countries: envList(env, "MSLXDFF_MULTI_IP_COUNTRY").map((c) => c.toUpperCase()),
|
|
114
|
+
minScore: envInt(env, "MSLXDFF_MULTI_IP_MIN_SCORE", DEFAULTS.minScore),
|
|
115
|
+
scan: Math.min(32, envInt(env, "MSLXDFF_MULTI_IP_SCAN", DEFAULTS.scan)),
|
|
116
|
+
baseTable: envInt(env, "MSLXDFF_MULTI_IP_BASE_TABLE", DEFAULTS.baseTable),
|
|
117
|
+
devPrefix: envStr(env, "MSLXDFF_MULTI_IP_DEV_PREFIX", DEFAULTS.devPrefix),
|
|
118
|
+
probeModel: envStr(env, "MSLXDFF_MULTI_IP_PROBE_MODEL", DEFAULTS.probeModel),
|
|
119
|
+
strategy: envStr(env, "MSLXDFF_MULTI_IP_STRATEGY", DEFAULTS.strategy) === "request" ? "request" : "session",
|
|
120
|
+
standby: standbyCount(env),
|
|
121
|
+
standbyEveryMs: envInt(env, "MSLXDFF_MULTI_IP_STANDBY_EVERY_MS", DEFAULTS.standbyEveryMs),
|
|
122
|
+
connectTimeoutMs: envInt(env, "MSLXDFF_MULTI_IP_CONNECT_TIMEOUT_MS", DEFAULTS.connectTimeoutMs),
|
|
123
|
+
probeTimeoutMs: envInt(env, "MSLXDFF_MULTI_IP_PROBE_TIMEOUT_MS", DEFAULTS.probeTimeoutMs),
|
|
124
|
+
cooldownMs: envInt(env, "MSLXDFF_MULTI_IP_COOLDOWN_MS", DEFAULTS.cooldownMs),
|
|
125
|
+
rotate: envBool(env, "MSLXDFF_MULTI_IP_ROTATE", DEFAULTS.rotate),
|
|
126
|
+
nodesTtlMs: envInt(env, "MSLXDFF_MULTI_IP_NODES_TTL_MS", DEFAULTS.nodesTtlMs),
|
|
127
|
+
openvpnBin: envStr(env, "MSLXDFF_MULTI_IP_OPENVPN", DEFAULTS.openvpnBin),
|
|
128
|
+
authUser: envStr(env, "MSLXDFF_MULTI_IP_AUTH_USER", DEFAULTS.authUser),
|
|
129
|
+
authPass: envStr(env, "MSLXDFF_MULTI_IP_AUTH_PASS", DEFAULTS.authPass),
|
|
130
|
+
dir: envStr(env, "MSLXDFF_MULTI_IP_DIR", join(logDirFn(), "multi-ip-pool")),
|
|
131
|
+
sources,
|
|
132
|
+
nodesFile,
|
|
133
|
+
debug: envBool(env, "MSLXDFF_MULTI_IP_DEBUG", DEFAULTS.debug),
|
|
134
|
+
sysctl: envBool(env, "MSLXDFF_MULTI_IP_SYSCTL", DEFAULTS.sysctl),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 角色门(纯函数):**只有组长建池**(用户 2026-10-25 补裁);判据与 `-group` 同口径 —— `joined` 里 `leaderUrl` 为空即本节点是组长。
|
|
140
|
+
* 纯组员与"没带任何组的单机"都不建;确需强开设 MSLXDFF_MULTI_IP_MODE=leader。
|
|
141
|
+
* @param {{ mode?: string, joined?: Array<{leaderUrl?: string}> }} opts
|
|
142
|
+
* @returns {{ active: boolean, role: "leader" | "member" | "solo", why: string }}
|
|
143
|
+
*/
|
|
144
|
+
export function resolveRole({ mode = "auto", joined = [] } = {}) {
|
|
145
|
+
const list = Array.isArray(joined) ? joined : [];
|
|
146
|
+
const leads = list.some((g) => g && !g.leaderUrl);
|
|
147
|
+
const derived = list.length === 0 ? "solo" : leads ? "leader" : "member";
|
|
148
|
+
// 显式覆盖优先于推导(escape hatch:单机/组员机器上 MSLXDFF_MULTI_IP_MODE=leader 强开)
|
|
149
|
+
if (mode === "leader") return { active: true, role: "leader", why: "MSLXDFF_MULTI_IP_MODE=leader(显式强开)" };
|
|
150
|
+
if (mode === "member") return { active: false, role: "member", why: "MSLXDFF_MULTI_IP_MODE=member(显式)" };
|
|
151
|
+
const why = derived === "leader" ? "本节点是组长(自己带的组)" : derived === "solo"
|
|
152
|
+
? "本节点没带任何组(单机):按「只有组长启用」不建池,要启用请设 MSLXDFF_MULTI_IP_MODE=leader"
|
|
153
|
+
: "本节点是组员,出口池只由组长建";
|
|
154
|
+
return { active: derived === "leader", role: derived, why };
|
|
155
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// multi-ip-pool · 拨号与体检:候选队列 → 起一条隧道 → **打一个 hi 到上游要它 200** → 才算可用出口。
|
|
2
|
+
//
|
|
3
|
+
// 为什么"要 200"而不是"能连上就行"(用户 2026-10-25 定):
|
|
4
|
+
// 这条池的唯一目的是**换新额度**。隧道起来了但那个 IP 已经没额度(429/403),拿它换出口等于白换——
|
|
5
|
+
// 所以判据必须是"这个出口现在真能出活",即免费层 chat 打 `hi` 回 **200**。
|
|
6
|
+
// 请求形状复用 `v6pool/selftest.js`(`stream:true` + 五核心工具名 + 身份四件套):缺任何一项上游回
|
|
7
|
+
// **403 FreeTierError 而不是 429**,那测的是形状、不是额度(真机 2026-10-19 踩过)。
|
|
8
|
+
// 拆成独立模块的原因:装配(index)、拨号(本文件)、备选池(standby)三件事各自都要单测,混一起必超 10KB 门。
|
|
9
|
+
import { getUndici } from "../compat.js";
|
|
10
|
+
import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
|
|
11
|
+
import { candidateNodes, loadNodeCache, materializeNode } from "./nodes.js";
|
|
12
|
+
import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
|
|
13
|
+
import { bringUpTunnel, tearDownTunnel } from "./tunnel.js";
|
|
14
|
+
import { AGENT_OPTIONS } from "./dispatch.js";
|
|
15
|
+
import { fmtExitIpFail, fmtHi, fmtNodePick, fmtNodes } from "./report.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {{ cfg: object, baseUrl?: string, log?: Function, deps?: {
|
|
19
|
+
* bringUpTunnel?, tearDownTunnel?, materializeNode?, fetchNodes?, loadNodeCache?, probeExitIp?,
|
|
20
|
+
* probeHi?, agentFactory?, fetchImpl?, runCmdFn?, spawnFn?, wait?, authFile?, probeSession? } }} opts
|
|
21
|
+
*/
|
|
22
|
+
export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () => {}, deps = {} } = {}) {
|
|
23
|
+
const startSlot = deps.bringUpTunnel || bringUpTunnel;
|
|
24
|
+
const stopSlot = deps.tearDownTunnel || tearDownTunnel;
|
|
25
|
+
const materialize = deps.materializeNode || materializeNode;
|
|
26
|
+
const probeExit = deps.probeExitIp || probeExitIp;
|
|
27
|
+
const probeHi = deps.probeHi || probeUpstreamFrom;
|
|
28
|
+
const { Agent } = getUndici();
|
|
29
|
+
const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, ...AGENT_OPTIONS }));
|
|
30
|
+
|
|
31
|
+
/** 本拨号器起过的所有隧道(含备用的):退出钩子与 close 都从这儿收,一条都不许漏。 */
|
|
32
|
+
const tunnels = new Set();
|
|
33
|
+
const used = new Set(); // 已试过的节点(含正在用的):绝不重复拨同一台
|
|
34
|
+
let pending = [];
|
|
35
|
+
let source = "(未取表)";
|
|
36
|
+
let authFile = deps.authFile || "";
|
|
37
|
+
|
|
38
|
+
function setAuthFile(f) {
|
|
39
|
+
authFile = f || "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 探额度用的免费模型(上游不同模型额度池可能独立,需要时可换)。 */
|
|
43
|
+
function probeModel() {
|
|
44
|
+
return cfg.probeModel || DEFAULT_PROBE_MODEL;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 「打一个 hi,要 200」:一发请求同时拿两个读数——**有没有额度**(status)与**真实出口 IP**(取证)。
|
|
49
|
+
* @returns {Promise<{ok: boolean, status: number, kind: string, ms: number, ip: string, error: string}>}
|
|
50
|
+
*/
|
|
51
|
+
async function hiProbe(entry) {
|
|
52
|
+
const r = await probeHi({
|
|
53
|
+
addr: entry.tunIp,
|
|
54
|
+
model: probeModel(),
|
|
55
|
+
baseUrl,
|
|
56
|
+
agentFactory,
|
|
57
|
+
fetchImpl: deps.fetchImpl,
|
|
58
|
+
session: deps.probeSession,
|
|
59
|
+
});
|
|
60
|
+
let ip = entry.exitIp || "";
|
|
61
|
+
if (!ip) {
|
|
62
|
+
const agent = agentFactory(entry.tunIp);
|
|
63
|
+
try {
|
|
64
|
+
const e = await probeExit({ agent, timeoutMs: cfg.probeTimeoutMs, fetchImpl: deps.fetchImpl });
|
|
65
|
+
ip = e.ip || "";
|
|
66
|
+
} finally {
|
|
67
|
+
try {
|
|
68
|
+
await agent.close?.();
|
|
69
|
+
} catch {
|
|
70
|
+
/* 一次性 Agent */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { ok: Boolean(r.ok), status: Number(r.status) || 0, kind: r.kind || "", ms: Number(r.ms) || 0, ip, error: r.error || "" };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 取节点表 → 候选队列(按 Score 降序,剔除已用过的);`force` = 绕过缓存重取(每小时刷新备选池用)。 */
|
|
78
|
+
async function ensureCandidates({ force = false } = {}) {
|
|
79
|
+
if (!force && pending.length) return pending.length;
|
|
80
|
+
let rows = [];
|
|
81
|
+
if (!force) {
|
|
82
|
+
const cache = (deps.loadNodeCache || loadNodeCache)({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
|
|
83
|
+
if (cache?.rows?.length) {
|
|
84
|
+
rows = cache.rows;
|
|
85
|
+
source = cache.source || "(本地缓存)";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (!rows.length) {
|
|
89
|
+
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
90
|
+
rows = got.rows;
|
|
91
|
+
source = got.source;
|
|
92
|
+
}
|
|
93
|
+
pending = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, skip: used, scan: cfg.scan * (cfg.count + cfg.standby + 1) });
|
|
94
|
+
log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
|
|
95
|
+
return pending.length;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 拆一条隧道并忘掉它(先撤策略路由再收子进程,见 tunnel.js)。 */
|
|
99
|
+
async function drop(entry, why = "") {
|
|
100
|
+
if (!entry) return;
|
|
101
|
+
tunnels.delete(entry);
|
|
102
|
+
try {
|
|
103
|
+
await stopSlot({ slot: entry, runCmdFn: deps.runCmdFn, log });
|
|
104
|
+
} catch (e) {
|
|
105
|
+
log(`[multi-ip-pool] ${entry.dev} 拆隧道没拆干净:${String(e?.message || e)}${why ? `(${why})` : ""}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 开一个出口:按序试候选,每个都走「起隧道 → hi 要 200」,**不 200 当场拆掉换下一个**。
|
|
111
|
+
* 为什么这道闸门必须有:隧道进程活着但转不动流量、或那个 IP 早已没额度时,把它塞进池等于
|
|
112
|
+
* 把请求挂到超时或继续 429——比直连更糟(与 WARP 池 probeGate 同一纪律)。
|
|
113
|
+
* @returns {Promise<null | { dev, table, priority, tunIp, pid, stop, node, exitIp }>}
|
|
114
|
+
*/
|
|
115
|
+
async function openSlot(slotIndex, { dev = "", gate = true } = {}) {
|
|
116
|
+
for (let attempt = 1; attempt <= cfg.scan; attempt++) {
|
|
117
|
+
if (!pending.length) {
|
|
118
|
+
try {
|
|
119
|
+
await ensureCandidates();
|
|
120
|
+
} catch (e) {
|
|
121
|
+
log(`[multi-ip-pool] 取节点表失败:${String(e?.message || e)}`);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
if (!pending.length) return null;
|
|
125
|
+
}
|
|
126
|
+
const row = pending.shift();
|
|
127
|
+
if (!row || used.has(row.hostName)) continue;
|
|
128
|
+
used.add(row.hostName);
|
|
129
|
+
log(fmtNodePick({ i: attempt, n: cfg.scan, row, dev }));
|
|
130
|
+
let slot = null;
|
|
131
|
+
try {
|
|
132
|
+
let node = row;
|
|
133
|
+
if (!node.conf) {
|
|
134
|
+
node = materialize({ dir: cfg.dir, row });
|
|
135
|
+
if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
|
|
136
|
+
}
|
|
137
|
+
slot = await startSlot({
|
|
138
|
+
cfg,
|
|
139
|
+
slotIndex,
|
|
140
|
+
node,
|
|
141
|
+
deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log, onExit: () => tunnels.delete(slot) },
|
|
142
|
+
});
|
|
143
|
+
tunnels.add(slot);
|
|
144
|
+
const p = await hiProbe(slot);
|
|
145
|
+
log(fmtHi({ dev: slot.dev, model: probeModel(), ip: p.ip, ...p }));
|
|
146
|
+
if (!p.ip) log(fmtExitIpFail({ dev: slot.dev, error: p.error || "出口取证无响应" }));
|
|
147
|
+
if (gate && p.status !== 200) {
|
|
148
|
+
await drop(slot, "hi 未回 200");
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
return { ...slot, node, exitIp: p.ip || "" };
|
|
152
|
+
} catch (e) {
|
|
153
|
+
await drop(slot);
|
|
154
|
+
log(`[multi-ip-pool] ${slot?.dev || `${cfg.devPrefix}${slotIndex}`} 起隧道失败:${String(e?.message || e)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
tunnels,
|
|
162
|
+
agentFactory,
|
|
163
|
+
used,
|
|
164
|
+
setAuthFile,
|
|
165
|
+
probeModel,
|
|
166
|
+
hiProbe,
|
|
167
|
+
ensureCandidates,
|
|
168
|
+
openSlot,
|
|
169
|
+
drop,
|
|
170
|
+
get sourceLabel() {
|
|
171
|
+
return source;
|
|
172
|
+
},
|
|
173
|
+
get pendingCount() {
|
|
174
|
+
return pending.length;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|