mslxdff 0.1.245 → 0.1.246

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/chat-pipeline/terminal-verdict.js +4 -4
  3. package/src/cli/commands/daemon.js +1 -1
  4. package/src/cli/index.js +0 -5
  5. package/src/daemon.js +3 -3
  6. package/src/runtime/bootstrap.js +1 -1
  7. package/src/runtime/proc-info.js +2 -2
  8. package/src/runtime/providers-setup.js +2 -4
  9. package/src/server.js +3 -3
  10. package/src/upstream-engine/index.js +2 -17
  11. package/src/upstream.js +1 -61
  12. package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
  13. package/src/cli/commands/multi-ip-pool-test.js +0 -136
  14. package/src/cli/commands/multi-ip-pool.js +0 -83
  15. package/src/cli/commands/v6-pool.js +0 -123
  16. package/src/multi-ip-pool/config.js +0 -157
  17. package/src/multi-ip-pool/diagnose.js +0 -159
  18. package/src/multi-ip-pool/dial.js +0 -179
  19. package/src/multi-ip-pool/dispatch.js +0 -244
  20. package/src/multi-ip-pool/index.js +0 -174
  21. package/src/multi-ip-pool/nodes-fetch.js +0 -160
  22. package/src/multi-ip-pool/nodes.js +0 -205
  23. package/src/multi-ip-pool/report.js +0 -162
  24. package/src/multi-ip-pool/selftest.js +0 -168
  25. package/src/multi-ip-pool/standby.js +0 -157
  26. package/src/multi-ip-pool/tunnel.js +0 -291
  27. package/src/v6pool/claim.js +0 -157
  28. package/src/v6pool/config.js +0 -65
  29. package/src/v6pool/detect.js +0 -130
  30. package/src/v6pool/index.js +0 -157
  31. package/src/v6pool/prefix.js +0 -140
  32. package/src/v6pool/probe.js +0 -40
  33. package/src/v6pool/report.js +0 -104
  34. package/src/v6pool/rotate.js +0 -263
  35. package/src/v6pool/selftest.js +0 -178
  36. package/src/warp-pool/config.js +0 -74
  37. package/src/warp-pool/health.js +0 -106
  38. package/src/warp-pool/identity.js +0 -141
  39. package/src/warp-pool/index.js +0 -358
  40. package/src/warp-pool/keys.js +0 -29
  41. package/src/warp-pool/nodes.js +0 -95
  42. package/src/warp-pool/ports.js +0 -160
  43. package/src/warp-pool/register.js +0 -113
  44. package/src/warp-pool/report.js +0 -232
  45. package/src/warp-pool/revive.js +0 -168
  46. package/src/warp-pool/rotate.js +0 -214
  47. package/src/warp-pool/strategy.js +0 -43
  48. package/src/warp-pool/verdict.js +0 -69
  49. package/src/warp-pool/wireproxy.js +0 -162
@@ -1,83 +0,0 @@
1
- // mslxdff -multi-ip-pool [test|probe|status|fetch|help]
2
- // 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长**专属,只服务 opencode 免费层。
3
- //
4
- // 本文件只做「分发 + status + 用法」;test / fetch 两条取证流程在 ./multi-ip-pool-test.js
5
- // (2026-10-25 起拆分:那边是全程 transcript 流水账,混在一起两边都顶过体积线)。
6
- import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
7
- import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
8
- import { loadGroupsJoined } from "../../state.js";
9
- import { runFetch, runProbe } from "./multi-ip-pool-fetch.js";
10
- import { runTest } from "./multi-ip-pool-test.js";
11
-
12
- const FLAG = "-multi-ip-pool";
13
-
14
- export async function handleMultiIpPool(args = []) {
15
- const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
16
- if (at < 0) return false;
17
- const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
18
- if (["-h", "--help", "help"].includes(sub)) return printUsage();
19
- if (sub === "status") return await printStatus(args);
20
- if (sub === "probe" || sub === "ping") return await runProbe(args);
21
- if (sub === "fetch" || sub === "grab") return await runFetch(args);
22
- return await runTest(args);
23
- }
24
-
25
- /** status:只回答「开没开、为什么」。**不建池不拨号**,所以只能报告配置与门,报不了 daemon 内存里的池。 */
26
- async function printStatus(args = []) {
27
- const cfg = resolveMultiIpConfig(process.env);
28
- const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
29
- const platform = process.platform;
30
- const root = isRoot();
31
- const openvpn = await checkOpenvpn(cfg.openvpnBin);
32
- console.log("=".repeat(66));
33
- console.log(" 多出口池状态(不做任何隧道动作,只回答「开没开、为什么」)");
34
- console.log("=".repeat(66));
35
- console.log("");
36
- console.log(` 开关 ${cfg.enabled ? "开(MSLXDFF_MULTI_IP_POOL 缺省开)" : "关(MSLXDFF_MULTI_IP_POOL=0)"}`);
37
- console.log(` 角色门 role=${role.role} ⇒ ${role.active ? "通过" : "不建池"}(${role.why})`);
38
- console.log(` 平台门 ${isSupportedPlatform(platform) ? "通过(Linux)" : `不通过(${platform}:没有 tun + ip rule)`}`);
39
- console.log(` 权限门 ${root ? "通过(root)" : "不通过(需要 root 才能建 tun 与策略路由)"}`);
40
- console.log(` openvpn ${openvpn.ok ? `可用(${openvpn.version})` : `不可用(${openvpn.why})`}`);
41
- console.log(` 隧道数 ${cfg.count}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|Score ≥ ${cfg.minScore}|表号 ${cfg.baseTable}+`);
42
- console.log(` 备选池 配 ${cfg.standby} 条(每 ${Math.round((cfg.standbyEveryMs || 0) / 60_000)} 分钟重拨一批;入池标准是 hi 回 200,不是"隧道起来了")`);
43
- console.log(` 实际就绪几条**只有 daemon 知道**(本命令不建池、不拨号)→ 看 mslxdff -log 里的 [multi-ip-pool] 备选池 行`);
44
- console.log(` 目录 ${cfg.dir}`);
45
- console.log(` 节点源 ${cfg.nodesFile ? `本地表优先:${cfg.nodesFile}(其后 ${cfg.sources.length - 1} 个网络源)` : cfg.sources.join(",")}`);
46
- console.log("");
47
- console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
48
- console.log(` 测网络可达性(不要 root):mslxdff ${FLAG} probe [--probe N]`);
49
- process.exit(0);
50
- return true;
51
- }
52
-
53
- function printUsage() {
54
- console.log(`用法: mslxdff ${FLAG} [test|probe|status|fetch|help] [--count N] [--probe N] [--force] [--model <id>] [--out <路径>]`);
55
- console.log("");
56
- console.log(" test 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
57
- console.log(" 对照判语:");
58
- console.log(" 基线 429 + VPN 出口有 200 ⇒ 池有用(换 IP 换到新额度)");
59
- console.log(" 基线 429 + VPN 出口也全 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额)");
60
- console.log(" test 每一步都编号打印并整份落盘(结尾给出 transcript 路径),失败即停在原地。");
61
- console.log("");
62
- console.log(" probe 只回答「这台机能连上表里几个节点」(按簇轮转取样 + 限并发 TCP 预检):不拨号、不要 root、");
63
- console.log(" 不碰 openvpn/内核、不看角色门,任何平台都能跑 —— 池值不值得继续用的先行问题。");
64
- console.log("");
65
- console.log(" 前置:Linux + root + openvpn(tun 设备与策略路由必需)。非 Linux 如实跳过。");
66
- console.log("");
67
- console.log("相关 env(与 daemon 建池同源):");
68
- console.log(" MSLXDFF_MULTI_IP_POOL=0|1 关 / 开(缺省开)");
69
- console.log(" MSLXDFF_MULTI_IP_MODE=auto|leader|member 角色门(缺省 auto:**只有组长**建池;组员与单机都不建,强开=leader)");
70
- console.log(" MSLXDFF_MULTI_IP_COUNT=N 并行隧道数(缺省 2)");
71
- console.log(" MSLXDFF_MULTI_IP_COUNTRY=JP,KR 国家白名单(缺省不限)");
72
- console.log(` MSLXDFF_MULTI_IP_MIN_SCORE=N 节点 Score 下限(缺省 1000000)`);
73
- console.log(" MSLXDFF_MULTI_IP_SOURCES=url,url 节点表来源(缺省 VPNGate 官方 https+http,末位挂 aimili 仓库内镜像兜墙)");
74
- console.log(" MSLXDFF_MULTI_IP_NODES_FILE=<路径> 本地表文件(排在最前,设了就不打网;配合 -multi-ip-pool fetch 生成)");
75
- console.log(" MSLXDFF_MULTI_IP_BASE_TABLE=N 策略路由表起始号(缺省 210)");
76
- console.log(" MSLXDFF_MULTI_IP_SYSCTL=0|1 是否放宽 rp_filter(缺省 1)");
77
- console.log(" MSLXDFF_MULTI_IP_STANDBY=N 备选出口条数(缺省 5,**上限 5**,0=关)");
78
- console.log(" MSLXDFF_MULTI_IP_STANDBY_EVERY_MS=N 备选池刷新周期(缺省 3600000=每小时重拨一批)");
79
- console.log(" MSLXDFF_MULTI_IP_STRATEGY=session 会话粘出口(request=每请求换出口,会撞冷启动)");
80
- console.log(" MSLXDFF_MULTI_IP_PROBE_MODEL=<id> 打 hi 探额度用的免费模型(空=同款缺省)");
81
- process.exit(0);
82
- return true;
83
- }
@@ -1,123 +0,0 @@
1
- // mslxdff -v6-pool [test|help] —— IPv6 出口额度对照测试(在 VPS 上验证 v6 池值不值得用)。
2
- //
3
- // 目的(用户 2026-10-18 明确):**当这台 VPS 的 IPv4 出口撞 429 时,对比测一下 IPv6 出口还有没有额度**。
4
- // 所以本命令做 A/B:
5
- // ① 直连(IPv4/系统默认出口)打一条免费层请求,记下额度状态作基线;
6
- // ② 从 v6 段里随机采 N 个源地址,各打**同一条**请求(同 UA/模型/stream/tools,只换源地址);
7
- // ③ 对照两者:v6 有 200 ⇒ 池有用(换 IP 换到新额度);v6 全 429 ⇒ 换 IP 抬不动。
8
- //
9
- // 纯可逆:自造的 /128 用完即撤(bind 模式不碰网卡)。非 Linux 如实跳过。
10
- import { runV6PoolCompare, V6TEST_DEFAULT, DEFAULT_PROBE_MODEL } from "../../v6pool/selftest.js";
11
- import { argValue } from "../policy.js";
12
-
13
- const FLAG = "-v6-pool";
14
-
15
- function flagValue(args, ...names) {
16
- for (const name of names) {
17
- const v = argValue(args, name);
18
- if (v !== null && v !== undefined && !String(v).startsWith("-")) return v;
19
- const eq = args.find((a) => a.startsWith(`${name}=`));
20
- if (eq) return eq.slice(eq.indexOf("=") + 1);
21
- }
22
- return null;
23
- }
24
-
25
- const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
26
- const line = (r) => {
27
- if (r.error) return `✗ 不可达(${String(r.error).slice(0, 50)})`;
28
- if (r.ok) return `✅ HTTP ${r.status} 有额度`;
29
- return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
30
- };
31
-
32
- export async function handleV6Pool(args = []) {
33
- const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
34
- if (at < 0) return false;
35
- const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
36
- if (["-h", "--help", "help"].includes(sub)) return printUsage();
37
-
38
- const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? V6TEST_DEFAULT : Math.max(1, Math.min(64, Number(v) || V6TEST_DEFAULT)); })();
39
- const model = flagValue(args, "--model", "-m") || DEFAULT_PROBE_MODEL;
40
-
41
- console.log("=".repeat(66));
42
- console.log(" IPv6 出口额度对照 —— 「IPv4 撞 429 时,IPv6 还有额度吗」");
43
- console.log("=".repeat(66));
44
- console.log("");
45
- console.log(` 探测模型 ${model}|采样 ${count} 个 v6 源地址|只换源地址,其余请求完全相同`);
46
- console.log("");
47
-
48
- const onProgress = (ev) => {
49
- if (ev.phase === "baseline-start") console.log(` ▸ 基线:直连(IPv4 / 系统默认出口)打一条免费层请求…`);
50
- else if (ev.phase === "baseline-done") {
51
- console.log(` 基线结果:${line(ev)}${ev.body ? `(${String(ev.body).slice(0, 80).replace(/\s+/g, " ")})` : ""}`);
52
- console.log("");
53
- } else if (ev.phase === "v6-start") process.stdout.write(` [${String(ev.i).padStart(2)}/${ev.n}] ${ev.addr} … `);
54
- else if (ev.phase === "v6-done") process.stdout.write(`${line(ev)}\n`);
55
- };
56
-
57
- let r;
58
- try {
59
- r = await runV6PoolCompare({ count, model, onProgress });
60
- } catch (e) {
61
- console.log("");
62
- console.log(`❌ 测试执行出错:${e?.message ?? e}`);
63
- console.log(` 重试:mslxdff ${FLAG}`);
64
- process.exit(1);
65
- return true;
66
- }
67
-
68
- if (r.status === "skip") {
69
- console.log(`❌ 无法测 v6 出口:${r.reason}`);
70
- console.log(` 下一步:${r.hint}`);
71
- process.exit(1);
72
- return true;
73
- }
74
-
75
- console.log("");
76
- console.log("-".repeat(66));
77
- console.log(` 基线(IPv4/直连):${line(r.baseline)}`);
78
- console.log(` v6 采样:有额度 ${r.v6OkCount}|受限 ${r.v6LimitedCount}|不可达 ${r.v6NetCount}(共 ${r.requested})`);
79
- console.log("");
80
- const verdicts = {
81
- "v6-works": {
82
- mark: "✅",
83
- text: `IPv6 **能换到新额度**:${r.v6OkCount}/${r.requested} 个 v6 出口拿到 200`,
84
- next: ` 结论:v6 池对这台机器**有用** —— IPv4 耗尽时换 v6 出口能续。daemon 建池时会自动启用。`,
85
- },
86
- "v6-also-limited": {
87
- mark: "⛔",
88
- text: `IPv6 **也全受限**(换 IP 没换到新额度)`,
89
- next: ` 结论:v6 池对这台机器**无意义**(要么上游不按出口 IP 计额,要么该段没真路由出去)。建议 MSLXDFF_V6_POOL=0。`,
90
- },
91
- "v6-unreachable": {
92
- mark: "❌",
93
- text: `IPv6 源地址**全都够不着上游**(段没路由到本机)`,
94
- next: ` 结论:本机这段 v6 不能用作出口。查机房是否把该段 routed 过来,或用 MSLXDFF_V6_PREFIX 指更准的段。`,
95
- },
96
- mixed: {
97
- mark: "△",
98
- text: `部分可用:${r.v6OkCount}/${r.requested} 个 v6 出口有额度`,
99
- next: ` 结论:v6 池**部分可用**(可能有些 /128 被沿途丢弃)。够用即可开着。`,
100
- },
101
- }[r.verdict] || { mark: "?", text: "未判定", next: "" };
102
- console.log(` ${verdicts.mark} ${verdicts.text}`);
103
- if (verdicts.next) console.log(verdicts.next);
104
- console.log("");
105
- console.log(` 重试:mslxdff ${FLAG} --count ${r.requested}${model !== DEFAULT_PROBE_MODEL ? ` --model ${model}` : ""}`);
106
- process.exit(r.verdict === "v6-works" || r.verdict === "mixed" ? 0 : 1);
107
- }
108
-
109
- function printUsage() {
110
- console.log(`用法: mslxdff ${FLAG} [test] [--count N] [--model <id>]`);
111
- console.log("");
112
- console.log(" 在 Linux VPS 上做 A/B:直连(IPv4)打一条 free 层请求取基线,");
113
- console.log(" 再从 v6 段随机采 N 个源地址各打同一条,对照 IPv6 出口还有没有额度。");
114
- console.log(" 基线 429 + v6 有 200 ⇒ 池有用(换 IP 换到新额度)");
115
- console.log(" 基线 429 + v6 也 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额 / 段没路由过来)");
116
- console.log("");
117
- console.log("相关 env(与 daemon 建池同源):");
118
- console.log(" MSLXDFF_V6_PREFIX=<段>/<位数> 显式指定池前缀(缺省取本机全局地址前缀)");
119
- console.log(" MSLXDFF_V6_IFACE=<网卡> 指定网卡");
120
- console.log(" MSLXDFF_V6_CLAIM=auto|bind|add 认领方式(bind 需 ip_nonlocal_bind=1;add 需 root)");
121
- process.exit(0);
122
- return true;
123
- }
@@ -1,157 +0,0 @@
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
- maxTableAgeMs: 24 * 60 * 60_000, // 表龄告警阈值:镜像表超过它就 ⚠(旧表 = 一堆早下线的 IP,白拨几分钟)
34
- openvpnBin: "openvpn",
35
- authUser: "vpn", // VPNGate 公共账号(非私人凭据)
36
- authPass: "vpn",
37
- debug: false,
38
- sysctl: true, // 放宽 rp_filter(严格反向路径过滤会让隧道"活着但请求挂死")
39
- };
40
-
41
- /**
42
- * VPNGate 节点源:**官方永远在前**,第三方镜像只作兜墙。
43
- * 纠错(2026-10-26 实测):`aimili-vpngate` 的 Actions 只把新鲜快照发到 **GitHub Pages**(`openmili.github.io/aimili-vpngate/`),
44
- * **不往仓库里写表**;仓库内 `mirror/vpngate.csv` 是手工提交的死快照(2026-08-26 后未变)——此前当「每 15 分钟刷新」用 =
45
- * 拿两个月前的死 IP 白拨,真机症状与被墙一模一样。
46
- * 表只是公开节点配置 + VPNGate 公共账号(vpn/vpn);不信任镜像就 `_SOURCES` 只留官方或 `_NODES_FILE` 自带。
47
- */
48
- export const NODE_SOURCES = [
49
- "https://www.vpngate.net/api/iphone/",
50
- "http://www.vpngate.net/api/iphone/",
51
- "https://openmili.github.io/aimili-vpngate/vpngate.csv",
52
- "https://raw.githubusercontent.com/OpenMili/aimili-vpngate/main/mirror/vpngate.csv",
53
- ];
54
-
55
- const OFF = ["0", "off", "false", "no", "disable", "disabled"];
56
- const MODES = ["auto", "leader", "member"];
57
-
58
- function envBool(env, name, fallback) {
59
- const raw = env[name];
60
- if (raw === undefined || raw === null || raw === "") return fallback;
61
- return !OFF.includes(String(raw).trim().toLowerCase());
62
- }
63
-
64
- function envInt(env, name, fallback) {
65
- const v = Number(env[name]);
66
- return Number.isInteger(v) && v > 0 ? v : fallback;
67
- }
68
-
69
- function envStr(env, name, fallback) {
70
- const raw = env[name];
71
- return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
72
- }
73
-
74
- function envList(env, name, fallback = "") {
75
- return String(env[name] ?? fallback)
76
- .split(",")
77
- .map((s) => s.trim())
78
- .filter(Boolean);
79
- }
80
- /** 备选出口上限:0 = 关;**硬上限 5**(用户 2026-10-25:不多于 5 个备选 IP)。 */
81
- function standbyCount(env) {
82
- const raw = Number(env.MSLXDFF_MULTI_IP_STANDBY);
83
- if (!Number.isFinite(raw) || raw < 0) return DEFAULTS.standby;
84
- return Math.min(5, Math.floor(raw));
85
- }
86
-
87
- /** 槽 i 的 tun 设备名(内核上限 15 字符:前缀默认 6 + 序号 ≤2 位,安全)。 */
88
- export function devNameFor(prefix, i) {
89
- return `${prefix}${i}`;
90
- }
91
-
92
- /** 槽 i 的策略路由表号(每槽独立表:`ip rule from <tun 地址> lookup <table>`)。 */
93
- export function tableFor(baseTable, i) {
94
- return baseTable + i;
95
- }
96
-
97
- /**
98
- * 解析 multi-ip 出口池配置。
99
- * @param {Record<string, string|undefined>} env
100
- * @param {{ logDirFn?: () => string }} [deps] 测试可注入 logDir
101
- */
102
- export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } = {}) {
103
- const modeRaw = envStr(env, "MSLXDFF_MULTI_IP_MODE", DEFAULTS.mode).toLowerCase();
104
- // 受限网络兜墙:`MSLXDFF_MULTI_IP_NODES_FILE` 指的本地表**排在最前**(设了就一次网络都不打)。
105
- const nodesFile = envStr(env, "MSLXDFF_MULTI_IP_NODES_FILE", "");
106
- const baseSources = envList(env, "MSLXDFF_MULTI_IP_SOURCES").length
107
- ? envList(env, "MSLXDFF_MULTI_IP_SOURCES")
108
- : NODE_SOURCES;
109
- const sources = nodesFile ? [nodesFile, ...baseSources.filter((s) => s !== nodesFile)] : baseSources;
110
- return {
111
- enabled: envBool(env, "MSLXDFF_MULTI_IP_POOL", DEFAULTS.enabled),
112
- mode: MODES.includes(modeRaw) ? modeRaw : DEFAULTS.mode,
113
- count: Math.min(8, envInt(env, "MSLXDFF_MULTI_IP_COUNT", DEFAULTS.count)),
114
- countries: envList(env, "MSLXDFF_MULTI_IP_COUNTRY").map((c) => c.toUpperCase()),
115
- minScore: envInt(env, "MSLXDFF_MULTI_IP_MIN_SCORE", DEFAULTS.minScore),
116
- scan: Math.min(32, envInt(env, "MSLXDFF_MULTI_IP_SCAN", DEFAULTS.scan)),
117
- baseTable: envInt(env, "MSLXDFF_MULTI_IP_BASE_TABLE", DEFAULTS.baseTable),
118
- devPrefix: envStr(env, "MSLXDFF_MULTI_IP_DEV_PREFIX", DEFAULTS.devPrefix),
119
- probeModel: envStr(env, "MSLXDFF_MULTI_IP_PROBE_MODEL", DEFAULTS.probeModel),
120
- strategy: envStr(env, "MSLXDFF_MULTI_IP_STRATEGY", DEFAULTS.strategy) === "request" ? "request" : "session",
121
- standby: standbyCount(env),
122
- standbyEveryMs: envInt(env, "MSLXDFF_MULTI_IP_STANDBY_EVERY_MS", DEFAULTS.standbyEveryMs),
123
- connectTimeoutMs: envInt(env, "MSLXDFF_MULTI_IP_CONNECT_TIMEOUT_MS", DEFAULTS.connectTimeoutMs),
124
- probeTimeoutMs: envInt(env, "MSLXDFF_MULTI_IP_PROBE_TIMEOUT_MS", DEFAULTS.probeTimeoutMs),
125
- cooldownMs: envInt(env, "MSLXDFF_MULTI_IP_COOLDOWN_MS", DEFAULTS.cooldownMs),
126
- rotate: envBool(env, "MSLXDFF_MULTI_IP_ROTATE", DEFAULTS.rotate),
127
- nodesTtlMs: envInt(env, "MSLXDFF_MULTI_IP_NODES_TTL_MS", DEFAULTS.nodesTtlMs),
128
- maxTableAgeMs: envInt(env, "MSLXDFF_MULTI_IP_MAX_TABLE_AGE_MS", DEFAULTS.maxTableAgeMs),
129
- openvpnBin: envStr(env, "MSLXDFF_MULTI_IP_OPENVPN", DEFAULTS.openvpnBin),
130
- authUser: envStr(env, "MSLXDFF_MULTI_IP_AUTH_USER", DEFAULTS.authUser),
131
- authPass: envStr(env, "MSLXDFF_MULTI_IP_AUTH_PASS", DEFAULTS.authPass),
132
- dir: envStr(env, "MSLXDFF_MULTI_IP_DIR", join(logDirFn(), "multi-ip-pool")),
133
- sources,
134
- nodesFile,
135
- debug: envBool(env, "MSLXDFF_MULTI_IP_DEBUG", DEFAULTS.debug),
136
- sysctl: envBool(env, "MSLXDFF_MULTI_IP_SYSCTL", DEFAULTS.sysctl),
137
- };
138
- }
139
-
140
- /**
141
- * 角色门(纯函数):**只有组长建池**(用户 2026-10-25 补裁);判据与 `-group` 同口径 —— `joined` 里 `leaderUrl` 为空即本节点是组长。
142
- * 纯组员与"没带任何组的单机"都不建;确需强开设 MSLXDFF_MULTI_IP_MODE=leader。
143
- * @param {{ mode?: string, joined?: Array<{leaderUrl?: string}> }} opts
144
- * @returns {{ active: boolean, role: "leader" | "member" | "solo", why: string }}
145
- */
146
- export function resolveRole({ mode = "auto", joined = [] } = {}) {
147
- const list = Array.isArray(joined) ? joined : [];
148
- const leads = list.some((g) => g && !g.leaderUrl);
149
- const derived = list.length === 0 ? "solo" : leads ? "leader" : "member";
150
- // 显式覆盖优先于推导(escape hatch:单机/组员机器上 MSLXDFF_MULTI_IP_MODE=leader 强开)
151
- if (mode === "leader") return { active: true, role: "leader", why: "MSLXDFF_MULTI_IP_MODE=leader(显式强开)" };
152
- if (mode === "member") return { active: false, role: "member", why: "MSLXDFF_MULTI_IP_MODE=member(显式)" };
153
- const why = derived === "leader" ? "本节点是组长(自己带的组)" : derived === "solo"
154
- ? "本节点没带任何组(单机):按「只有组长启用」不建池,要启用请设 MSLXDFF_MULTI_IP_MODE=leader"
155
- : "本节点是组员,出口池只由组长建";
156
- return { active: derived === "leader", role: derived, why };
157
- }
@@ -1,159 +0,0 @@
1
- // multi-ip-pool · 诊断层:openvpn 输出归因 / 节点 TCP 预检 / 取证全程 transcript(argv 原文由 report.js:fmtArgv 出)。
2
- //
3
- // 为什么单独一份:2026-10-25 真机两轮都栽在「看得见结果、看不见环节」——openvpn 用法错误的末行恒是
4
- // `Use --help`,真凶在它上面;而 selftest 的内部步骤日志在 CLI 里被接到了空函数上。
5
- // 本文件把「哪一步出问题」变成一等信息:每一步都有编号、耗时、原文,并整份落盘可粘贴。
6
- import { mkdirSync, appendFileSync } from "node:fs";
7
- import { dirname, join } from "node:path";
8
- import net from "node:net";
9
- import { devNameFor, tableFor } from "./config.js";
10
- import { clusterOf } from "./nodes.js";
11
-
12
- /** 值得单独成行的报错关键词(openvpn 的用法/握手/认证/网络失败都在这里)。 */
13
- const ERRORISH = /options error|unrecognized|missing or extra|fatal|error:|can ?not|cannot|denied|failed|refused|reset|auth failed|tls|write udp|bind|permission/i;
14
-
15
- /**
16
- * openvpn 输出里挑「最能说明问题」的一行:用法错误的**末行恒是** `Use --help for more information.`,
17
- * 真凶(`Options error: …`)在它上面。只报末行等于把病因盖住。
18
- */
19
- export function openvpnErrorLine(text, max = 240) {
20
- const lines = String(text || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
21
- if (!lines.length) return "";
22
- const pick = lines.find((l) => ERRORISH.test(l));
23
- return (pick || lines[lines.length - 1]).slice(0, max);
24
- }
25
-
26
- /**
27
- * 一行内的**多行摘要**:真凶行 + 其余最多 3 行(用 ` ‖ ` 分隔)。
28
- * 单行是为了日志文件仍可 grep;多行是为了人眼一次看完,不必再翻 openvpn 自己的输出。
29
- */
30
- export function openvpnErrorBrief(text, max = 620) {
31
- const lines = String(text || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
32
- if (!lines.length) return "";
33
- const first = lines.find((l) => ERRORISH.test(l)) || "";
34
- const rest = lines.filter((l) => l !== first && !/^Use --help/i.test(l)).slice(-3);
35
- return [first || lines[lines.length - 1], ...rest].join(" ‖ ").slice(0, max);
36
- }
37
-
38
- /**
39
- * 节点 TCP 预检:拨号前先确认「这台机能不能连到 host:port」。
40
- * 判语口径:VPNGate 实测 91/97 是 `proto tcp`,TCP 连不上基本就是节点/防火墙问题;
41
- * `proto udp` 的节点这条只作参考(UDP 无握手,连不上不代表隧道必挂),**必须如实分开说**。
42
- */
43
- export function probeTcp({ host, port, timeoutMs = 6_000, connectFn } = {}) {
44
- const t0 = Date.now();
45
- return new Promise((resolve) => {
46
- const done = (ok, extra) => resolve({ ok, ms: Date.now() - t0, host, port, ...extra });
47
- if (!host || !port) return done(false, { error: "缺 host/port" });
48
- if (connectFn) {
49
- return connectFn(host, port, timeoutMs)
50
- .then((r) => done(Boolean(r?.ok), { ...(r?.error ? { error: r.error } : {}) }))
51
- .catch((e) => done(false, { error: String(e?.message || e) }));
52
- }
53
- const s = net.connect({ host, port });
54
- const timer = setTimeout(() => { s.destroy(); done(false, { error: `连接超时(${timeoutMs}ms)` }); }, timeoutMs);
55
- s.once("connect", () => { clearTimeout(timer); s.destroy(); done(true, {}); });
56
- s.once("error", (e) => { clearTimeout(timer); done(false, { error: String(e?.code || e?.message || e) }); });
57
- });
58
- }
59
-
60
- /**
61
- * 批量 TCP 预检:**限并发**探一组节点,按输入顺序返回结果(`{ node, ok, ms, error }`)。
62
- * 为什么必须成批:真机实证「同一机房整片连不上」远多于「个别节点不通」——只探 3 条会给出
63
- * 一句误导的「全部不通」,探一批才看得出**是哪一片不通、还有哪片是通的**。
64
- */
65
- export async function probeBatch({ nodes = [], timeoutMs = 6_000, concurrency = 6, probe = probeTcp } = {}) {
66
- const out = new Array(nodes.length);
67
- let next = 0;
68
- const worker = async () => {
69
- for (;;) {
70
- const i = next++;
71
- if (i >= nodes.length) return;
72
- const node = nodes[i];
73
- out[i] = { node, ...(await probe({ host: node?.host, port: node?.port, timeoutMs })) };
74
- }
75
- };
76
- const width = Math.max(1, Math.min(Math.max(1, concurrency), nodes.length || 1));
77
- await Promise.all(Array.from({ length: width }, worker));
78
- return out;
79
- }
80
-
81
- /**
82
- * 取样小结:一句「探了几条、通几条」+ **簇分布**(哪个机房整片不通,一眼看出)。
83
- * 判死口径必须分开写:`proto udp` 的节点无握手,TCP 探不通**不能**当成「这条隧道必挂」。
84
- */
85
- export function probeSummaryLines({ results = [], minScore = 0, clustersShown = 6 } = {}) {
86
- const udp = results.filter((r) => String(r.node?.proto || "").toLowerCase() === "udp");
87
- const tcp = results.filter((r) => !udp.includes(r));
88
- const tcpLive = tcp.filter((r) => r.ok);
89
- const strict = results.filter((r) => Number(r.node?.score || 0) >= minScore).length;
90
- const groups = new Map();
91
- for (const r of tcp) {
92
- const key = clusterOf(r.node);
93
- const cur = groups.get(key) || { total: 0, ok: 0 };
94
- cur.total += 1;
95
- cur.ok += r.ok ? 1 : 0;
96
- groups.set(key, cur);
97
- }
98
- const tail = udp.length ? `(另有 ${udp.length} 条 UDP 节点:无握手,不计入判死)` : "";
99
- const lines = [`取样 ${results.length} 个(Score 达下限 ${strict} 个)→ TCP 通 ${tcpLive.length}|不通 ${tcp.length - tcpLive.length}${tail}`];
100
- const shown = [...groups.entries()].slice(0, clustersShown).map(([key, v]) => `${key} ${v.ok}/${v.total} 通`);
101
- if (shown.length) lines.push(`簇分布(机房/IP 段):${shown.join("|")}${groups.size > clustersShown ? `|…共 ${groups.size} 簇` : ""}`);
102
- if (!tcpLive.length && udp.some((r) => r.ok)) lines.push("探通的都是 UDP 节点 ⇒ TCP 探不出结论,下面仍按取样顺序真拨");
103
- return lines;
104
- }
105
-
106
- /**
107
- * 取证全程 transcript:每行带 `[步骤号]` 与相对耗时,同时进屏幕与文件(0600)。
108
- * 落盘是为了「一次跑完贴一个文件给我」,不必复现;写文件失败绝不影响测试本体。
109
- */
110
- export function createTranscript({ dir, out = (s) => console.log(s), now = () => Date.now(), fs = { mkdirSync, appendFileSync }, fileName = "" } = {}) {
111
- const t0 = now();
112
- let step = 0;
113
- const file = dir ? join(dir, fileName || `test-${new Date(t0).toISOString().replace(/[:.]/g, "-")}.log`) : "";
114
- const emit = (raw) => {
115
- const line = `[${String(step).padStart(2, "0")} +${((now() - t0) / 1000).toFixed(1)}s] ${raw}`;
116
- out(line);
117
- if (file) {
118
- try { fs.appendFileSync(file, line + "\n"); } catch { /* 落盘失败只影响存档,不影响取证 */ }
119
- }
120
- };
121
- try {
122
- if (file) { fs.mkdirSync(dirname(file), { recursive: true }); fs.appendFileSync(file, `# multi-ip-pool 取证 transcript(本文件可整份粘贴)\n`); }
123
- } catch { /* 同上 */ }
124
- return {
125
- file,
126
- /** 一条步骤行(自动编号)。 */
127
- step: (text) => { step += 1; emit(text); return step; },
128
- /** 归属当前步骤的明细行(不占编号)。 */
129
- say: (text) => emit(` ${String(text).replace(/^\s+/, "")}`),
130
- /** 小节标题。 */
131
- section: (text) => { step += 1; emit(`—— ${text} ——`); return step; },
132
- };
133
- }
134
-
135
- /** 环境快照:一眼看清「机器/权限/二进制/配置/磁盘上有什么」,省掉一轮来回。 */
136
- export function envSnapshotLines({ cfg, platform, root, openvpn, files = [], argvNode = "" } = {}) {
137
- return [
138
- `node ${argvNode || process.version}|平台 ${platform}|root ${root ? "是" : "否"}`,
139
- `openvpn ${openvpn?.ok ? `可用:${openvpn.version}` : `不可用:${openvpn?.why || "?"}`}`,
140
- `配置:隧道 ${cfg?.count} 条|备胎 ${cfg?.standby} 条/每 ${Math.round((cfg?.standbyEveryMs || 0) / 60_000)} 分钟|Score 下限 ${cfg?.minScore}|国家 ${cfg?.countries?.length ? cfg.countries.join("/") : "不限"}|策略 ${cfg?.strategy}`,
141
- `目录 ${cfg?.dir}:${files.length ? files.join(" ") : "(空,本轮第一次跑)"}`,
142
- `节点源(按序):${(cfg?.sources || []).join(" → ")}`,
143
- ];
144
- }
145
-
146
- /** 每槽的「设备名/路由表/优先级」对照表:内核侧出问题时按这张表查。 */
147
- export function slotPlanLines({ cfg, nodes = [] } = {}) {
148
- return nodes.map((n, i) => ` 槽 ${i}:${n.hostName}(${n.countryShort || "?"})→ ${n.host}:${n.port}|proto ${n.proto || "?"}|设备 ${devNameFor(cfg.devPrefix, i)}|表 ${tableFor(cfg.baseTable, i)}`);
149
- }
150
-
151
- /** 跑完(尤其是中途失败)后给全善后命令:设备与路由残留必须能一眼查清。 */
152
- export function residualHints() {
153
- return [
154
- "ip -brief addr show type tun",
155
- "ip link show | grep -E 'mltun|tun'",
156
- "ip rule show | grep -E 'from (10\\.|192\\.|172\\.)' | head",
157
- "ip route show table all | grep -E 'mltun' | head",
158
- ];
159
- }