mslxdff 0.1.241 → 0.1.243

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.241",
3
+ "version": "0.1.243",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,199 @@
1
+ // mslxdff -multi-ip-pool 的 test / fetch 两条流程:全程 transcript(屏幕 + 文件),每步编号带耗时。
2
+ //
3
+ // 为什么要写成流水账(用户 2026-10-25 明确要求):真机两轮排障都卡在「只看到最终判语、看不到环节」——
4
+ // openvpn 的 argv、它的 stderr 原文、节点端口通不通、tun 地址有没有拿到、路由装没装上,
5
+ // 每一项都必须是**一行一条**摆在屏幕上,并且整份落盘,贴一个文件就能定位。
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { runMultiIpCompare, VERDICT_ZH, MITEST_DEFAULT, MITEST_MAX } from "../../multi-ip-pool/selftest.js";
9
+ import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
10
+ import { candidateNodes, loadNodeCache, materializeNode } from "../../multi-ip-pool/nodes.js";
11
+ import { fetchNodes, fetchNodesToFile } from "../../multi-ip-pool/nodes-fetch.js";
12
+ import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
13
+ import { createTranscript, envSnapshotLines, probeTcp, residualHints, slotPlanLines } from "../../multi-ip-pool/diagnose.js";
14
+ import { loadGroupsJoined } from "../../state.js";
15
+ import { argValue } from "../policy.js";
16
+
17
+ const FLAG = "-multi-ip-pool";
18
+
19
+ const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
20
+
21
+ function flagValue(args, ...names) {
22
+ for (const name of names) {
23
+ const v = argValue(args, name);
24
+ if (v !== null && v !== undefined && !String(v).startsWith("-")) return v;
25
+ const eq = args.find((a) => a.startsWith(`${name}=`));
26
+ if (eq) return eq.slice(eq.indexOf("=") + 1);
27
+ }
28
+ return null;
29
+ }
30
+
31
+ const line = (r) => {
32
+ if (r.error) return `✗ 不可达(${String(r.error).replace(/\s+/g, " ").slice(0, 300)})`;
33
+ if (r.ok) return `✅ HTTP ${r.status} 有额度`;
34
+ return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
35
+ };
36
+
37
+ /** 目录里已经有什么(auth.txt/nodes.json/node-*.conf)——很多失败就是这里少一个文件。 */
38
+ function dirNotes(dir) {
39
+ try {
40
+ const names = readdirSync(dir);
41
+ const size = (f) => { try { return `${statSync(join(dir, f)).size}B`; } catch { return "?"; } };
42
+ const files = names.slice(0, 12).map((n) => `${n}(${size(n)})`);
43
+ files.push(names.length > 12 ? `…共 ${names.length} 个` : "");
44
+ files.push(`auth.txt ${names.includes("auth.txt") ? "在" : "**缺失**(拨号必挂,看第 4 步)"}`);
45
+ return files.filter(Boolean);
46
+ } catch (e) {
47
+ return [`目录读不到:${String(e?.code || e?.message || e)}`];
48
+ }
49
+ }
50
+
51
+ /** 取表(缓存优先,取不到就逐源试并把每个源的死因摆出来)。 */
52
+ async function ensureRows(cfg, t) {
53
+ const cache = loadNodeCache({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
54
+ if (cache?.rows?.length) {
55
+ t.say(`命中本地缓存:${cache.rows.length} 个节点(表源 ${cache.source},缓存龄 ${Math.round((Date.now() - Number(cache.fetchedAt || 0)) / 1000)}s)`);
56
+ return cache.rows;
57
+ }
58
+ t.say("缓存没有或已过期 ⇒ 逐源取表(每源失败原因都会列出来)");
59
+ const got = await fetchNodes({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
60
+ t.say(`取到:${got.source} → ${got.rows.length} 个节点`);
61
+ return got.rows;
62
+ }
63
+
64
+ /**
65
+ * `-multi-ip-pool test`:五道门 → 取表 → 每槽 TCP 预检 → 基线 → 逐槽起隧道取证 → 判语 → 善后。
66
+ * 任何一步失败都会停在那一步并说清「下一步做什么」,不会攒到最后给你一个 unreachable。
67
+ */
68
+ export async function runTest(args = []) {
69
+ const cfg = resolveMultiIpConfig(process.env);
70
+ const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? MITEST_DEFAULT : Math.max(1, Math.min(MITEST_MAX, Number(v) || MITEST_DEFAULT)); })();
71
+ const model = flagValue(args, "--model", "-m") || "";
72
+ const t = createTranscript({ dir: cfg.dir });
73
+ console.log("=".repeat(70));
74
+ console.log(" 多出口池取证 —— 全程 transcript(每一步都印,失败即停在原地)");
75
+ console.log("=".repeat(70));
76
+
77
+ t.step("① 环境快照");
78
+ const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
79
+ const root = isRoot();
80
+ const supported = isSupportedPlatform();
81
+ const openvpn = await checkOpenvpn(cfg.openvpnBin);
82
+ for (const l of envSnapshotLines({ cfg, platform: process.platform, root, openvpn, files: dirNotes(cfg.dir) })) t.say(l);
83
+ t.say(`角色门 role=${role.role} ⇒ ${role.active ? "通过" : "不建池"}(${role.why})`);
84
+ if (!cfg.enabled || !role.active || !supported || !root || !openvpn.ok) {
85
+ t.say("五道门有不过的 ⇒ 后面不再动作(否则只会被 openvpn/内核报一堆看不懂的错)");
86
+ console.log(`\n❌ 前置不足:开关=${cfg.enabled} 角色=${role.active} Linux=${supported} root=${root} openvpn=${openvpn.ok}`);
87
+ console.log(` 下一步:mslxdff ${FLAG} status(逐门给人话结论)|完整 transcript:${t.file}`);
88
+ process.exit(1);
89
+ }
90
+
91
+ t.step("② 节点表");
92
+ let rows = [];
93
+ try {
94
+ rows = await ensureRows(cfg, t);
95
+ } catch (e) {
96
+ t.say(String(e?.message || e));
97
+ console.log(`\n❌ 取不到节点表(原因见上一行)`);
98
+ console.log(" 下一步:在能连的机器上 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,");
99
+ console.log(" 再 export MSLXDFF_MULTI_IP_NODES_FILE=<路径> 重跑 test");
100
+ console.log(` 完整 transcript:${t.file}`);
101
+ process.exit(1);
102
+ return true;
103
+ }
104
+
105
+ t.step(`③ 选槽与 TCP 预检(拨号前先确认端口通不通,省掉 3×20s 的瞎等)`);
106
+ const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: count });
107
+ if (!picked.length) {
108
+ t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
109
+ console.log(`\n❌ 没有可用候选`);
110
+ console.log(" 下一步:放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑");
111
+ console.log(` 完整 transcript:${t.file}`);
112
+ process.exit(1);
113
+ return true;
114
+ }
115
+ const nodes = picked.map((row) => materializeNode({ dir: cfg.dir, row, name: `pre${picked.indexOf(row)}` })).filter(Boolean);
116
+ for (const l of slotPlanLines({ cfg, nodes })) t.say(l);
117
+ let reachable = 0;
118
+ for (const n of nodes) {
119
+ const p = await probeTcp({ host: n.host, port: n.port, timeoutMs: Math.min(6_000, cfg.connectTimeoutMs) });
120
+ const verdict = p.ok ? `✓ TCP 通(${p.ms}ms)` : `✗ TCP 不通(${p.error || "?"},${p.ms}ms)`;
121
+ t.say(` ${n.hostName} ${n.host}:${n.port} proto=${n.proto || "?"} → ${verdict}${String(n.proto).toLowerCase() === "udp" ? " ※ UDP 节点无握手,此探测仅供参考" : ""}`);
122
+ if (p.ok) reachable++;
123
+ }
124
+ if (!reachable) t.say("全部候选 TCP 都不通 ⇒ 多半是本机到公网节点被限制(不是本模块的配置问题),下面仍会真拨一次以留证据");
125
+
126
+ t.step("④ 基线 + 逐槽起隧道取证");
127
+ const onProgress = (ev) => {
128
+ if (ev.phase === "nodes") t.say(`(selftest 用的表:${ev.source},${ev.total} 个节点)`);
129
+ else if (ev.phase === "baseline-start") t.say("基线:直连(系统默认出口)打一条免费层请求…");
130
+ else if (ev.phase === "baseline-done") t.say(`基线:${line(ev)}${ev.body ? ` ${String(ev.body).slice(0, 90).replace(/\s+/g, " ")}` : ""}`);
131
+ else if (ev.phase === "exit-start") t.say(`槽 ${ev.i}/${ev.n}:${ev.row.hostName}(${ev.row.countryShort || "?"})开始`);
132
+ else if (ev.phase === "exit-done") t.say(`槽 ${ev.i}/${ev.n} 结果:${line(ev)}${ev.exitIp ? `|出口 IP ${ev.exitIp}` : ""}`);
133
+ };
134
+ let r;
135
+ try {
136
+ r = await runMultiIpCompare({ count, ...(model ? { model } : {}), onProgress, deps: { log: (m) => t.say(m) } });
137
+ } catch (e) {
138
+ t.say(`测试执行抛错:${String(e?.stack || e?.message || e)}`);
139
+ console.log(`\n❌ 测试执行出错:${e?.message ?? e}`);
140
+ console.log(` 完整 transcript:${t.file}`);
141
+ process.exit(1);
142
+ return true;
143
+ }
144
+ if (r.status === "skip") {
145
+ t.say(`跳过:${r.reason}`);
146
+ console.log(`\n❌ 测不了:${r.reason}`);
147
+ console.log(` 下一步:${r.hint}`);
148
+ console.log(` 完整 transcript:${t.file}`);
149
+ process.exit(1);
150
+ return true;
151
+ }
152
+
153
+ t.step("⑤ 判语");
154
+ console.log("-".repeat(70));
155
+ console.log(` 基线(直连):${line(r.baseline)}`);
156
+ console.log(` VPN 出口:有额度 ${r.okCount}|受限 ${r.limitedCount}|不可达 ${r.netCount}(共 ${r.requested})`);
157
+ const v = VERDICT_ZH[r.verdict] || { text: "未判定", next: "" };
158
+ const mark = r.verdict === "multi-ip-works" ? "✅" : r.verdict === "mixed" ? "△" : "⛔";
159
+ console.log(` ${mark} ${v.text}:${r.okCount}/${r.requested} 条隧道拿到 200`);
160
+ if (v.next) console.log(` ${v.next}`);
161
+ for (const e of r.exits || []) console.log(` · ${e.hostName}(${e.country || "?"})${e.exitIp ? `出口 ${e.exitIp}|` : ""}${line(e)}`);
162
+
163
+ t.step("⑥ 善后与存档");
164
+ t.say(`隧道由本命令自造自拆;若中途 Ctrl+C,按下面几条查残留:`);
165
+ for (const h of residualHints()) t.say(` ${h}`);
166
+ console.log("");
167
+ console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
168
+ console.log(` 重试:mslxdff ${FLAG} test --count ${r.requested}${model ? ` --model ${model}` : ""}`);
169
+ process.exit(r.verdict === "multi-ip-works" || r.verdict === "mixed" ? 0 : 1);
170
+ return true;
171
+ }
172
+
173
+ /**
174
+ * `-multi-ip-pool fetch [路径]`:在**能连节点源**的机器上抓一份表落成文件,scp 到受限网络的组长机,
175
+ * 那边设 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可完全跳过取表网络请求(2026-10-25 真机:华为云 ECS 到 vpngate.net 不通)。
176
+ */
177
+ export async function runFetch(args = []) {
178
+ const cfg = resolveMultiIpConfig(process.env);
179
+ const out = flagValue(args, "--out", "-o") || join(cfg.dir, "vpngate.csv");
180
+ const t = createTranscript({ dir: cfg.dir, fileName: "fetch.log" });
181
+ t.step("逐源取表(失败原因逐条列出)");
182
+ t.say(`源列表:${cfg.sources.join(" → ")}`);
183
+ try {
184
+ const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
185
+ const usable = got.rows.filter((r) => r.config).length;
186
+ t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}`);
187
+ console.log("");
188
+ console.log(" 带到受限网络的机器上:");
189
+ console.log(` scp ${out} root@<组长机>:/root/.config/mslxdff/multi-ip-pool/vpngate.csv`);
190
+ console.log(" export MSLXDFF_MULTI_IP_NODES_FILE=/root/.config/mslxdff/multi-ip-pool/vpngate.csv");
191
+ console.log(" (之后取表零网络请求;表会旧,免费节点掉得快,建议隔几天重抓一次)");
192
+ process.exit(0);
193
+ } catch (e) {
194
+ t.say(`❌ ${String(e?.message || e)}`);
195
+ console.log(" 下一步:换网络重试,或 MSLXDFF_MULTI_IP_SOURCES=<你自己能连的镜像/本地路径> 再跑一次");
196
+ process.exit(1);
197
+ }
198
+ return true;
199
+ }
@@ -1,35 +1,15 @@
1
- // mslxdff -multi-ip-pool [test|status|help]
2
- // 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长/单机**专属,只服务 opencode 免费层。
1
+ // mslxdff -multi-ip-pool [test|status|fetch|help]
2
+ // 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长**专属,只服务 opencode 免费层。
3
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";
4
+ // 本文件只做「分发 + status + 用法」;test / fetch 两条取证流程在 ./multi-ip-pool-test.js
5
+ // (2026-10-25 起拆分:那边是全程 transcript 流水账,混在一起两边都顶过体积线)。
9
6
  import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
10
7
  import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
11
8
  import { loadGroupsJoined } from "../../state.js";
12
- import { argValue } from "../policy.js";
9
+ import { runFetch, runTest } from "./multi-ip-pool-test.js";
13
10
 
14
11
  const FLAG = "-multi-ip-pool";
15
12
 
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
13
  export async function handleMultiIpPool(args = []) {
34
14
  const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
35
15
  if (at < 0) return false;
@@ -40,86 +20,8 @@ export async function handleMultiIpPool(args = []) {
40
20
  return await runTest(args);
41
21
  }
42
22
 
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) {
23
+ /** status:只回答「开没开、为什么」。**不建池不拨号**,所以只能报告配置与门,报不了 daemon 内存里的池。 */
24
+ async function printStatus(args = []) {
123
25
  const cfg = resolveMultiIpConfig(process.env);
124
26
  const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
125
27
  const platform = process.platform;
@@ -148,10 +50,11 @@ async function printStatus(args) {
148
50
  function printUsage() {
149
51
  console.log(`用法: mslxdff ${FLAG} [test|status|fetch|help] [--count N] [--model <id>] [--out <路径>]`);
150
52
  console.log("");
151
- console.log(" 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
53
+ console.log(" test 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
152
54
  console.log(" 对照判语:");
153
55
  console.log(" 基线 429 + VPN 出口有 200 ⇒ 池有用(换 IP 换到新额度)");
154
56
  console.log(" 基线 429 + VPN 出口也全 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额)");
57
+ console.log(" test 每一步都编号打印并整份落盘(结尾给出 transcript 路径),失败即停在原地。");
155
58
  console.log("");
156
59
  console.log(" 前置:Linux + root + openvpn(tun 设备与策略路由必需)。非 Linux 如实跳过。");
157
60
  console.log("");
@@ -171,4 +74,4 @@ function printUsage() {
171
74
  console.log(" MSLXDFF_MULTI_IP_PROBE_MODEL=<id> 打 hi 探额度用的免费模型(空=同款缺省)");
172
75
  process.exit(0);
173
76
  return true;
174
- }
77
+ }
@@ -0,0 +1,112 @@
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
+
11
+ /** 值得单独成行的报错关键词(openvpn 的用法/握手/认证/网络失败都在这里)。 */
12
+ 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;
13
+
14
+ /**
15
+ * openvpn 输出里挑「最能说明问题」的一行:用法错误的**末行恒是** `Use --help for more information.`,
16
+ * 真凶(`Options error: …`)在它上面。只报末行等于把病因盖住。
17
+ */
18
+ export function openvpnErrorLine(text, max = 240) {
19
+ const lines = String(text || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
20
+ if (!lines.length) return "";
21
+ const pick = lines.find((l) => ERRORISH.test(l));
22
+ return (pick || lines[lines.length - 1]).slice(0, max);
23
+ }
24
+
25
+ /**
26
+ * 一行内的**多行摘要**:真凶行 + 其余最多 3 行(用 ` ‖ ` 分隔)。
27
+ * 单行是为了日志文件仍可 grep;多行是为了人眼一次看完,不必再翻 openvpn 自己的输出。
28
+ */
29
+ export function openvpnErrorBrief(text, max = 620) {
30
+ const lines = String(text || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
31
+ if (!lines.length) return "";
32
+ const first = lines.find((l) => ERRORISH.test(l)) || "";
33
+ const rest = lines.filter((l) => l !== first && !/^Use --help/i.test(l)).slice(-3);
34
+ return [first || lines[lines.length - 1], ...rest].join(" ‖ ").slice(0, max);
35
+ }
36
+
37
+ /**
38
+ * 节点 TCP 预检:拨号前先确认「这台机能不能连到 host:port」。
39
+ * 判语口径:VPNGate 实测 91/97 是 `proto tcp`,TCP 连不上基本就是节点/防火墙问题;
40
+ * `proto udp` 的节点这条只作参考(UDP 无握手,连不上不代表隧道必挂),**必须如实分开说**。
41
+ */
42
+ export function probeTcp({ host, port, timeoutMs = 6_000, connectFn } = {}) {
43
+ const t0 = Date.now();
44
+ return new Promise((resolve) => {
45
+ const done = (ok, extra) => resolve({ ok, ms: Date.now() - t0, host, port, ...extra });
46
+ if (!host || !port) return done(false, { error: "缺 host/port" });
47
+ if (connectFn) {
48
+ return connectFn(host, port, timeoutMs)
49
+ .then((r) => done(Boolean(r?.ok), { ...(r?.error ? { error: r.error } : {}) }))
50
+ .catch((e) => done(false, { error: String(e?.message || e) }));
51
+ }
52
+ const s = net.connect({ host, port });
53
+ const timer = setTimeout(() => { s.destroy(); done(false, { error: `连接超时(${timeoutMs}ms)` }); }, timeoutMs);
54
+ s.once("connect", () => { clearTimeout(timer); s.destroy(); done(true, {}); });
55
+ s.once("error", (e) => { clearTimeout(timer); done(false, { error: String(e?.code || e?.message || e) }); });
56
+ });
57
+ }
58
+
59
+ /**
60
+ * 取证全程 transcript:每行带 `[步骤号]` 与相对耗时,同时进屏幕与文件(0600)。
61
+ * 落盘是为了「一次跑完贴一个文件给我」,不必复现;写文件失败绝不影响测试本体。
62
+ */
63
+ export function createTranscript({ dir, out = (s) => console.log(s), now = () => Date.now(), fs = { mkdirSync, appendFileSync }, fileName = "" } = {}) {
64
+ const t0 = now();
65
+ let step = 0;
66
+ const file = dir ? join(dir, fileName || `test-${new Date(t0).toISOString().replace(/[:.]/g, "-")}.log`) : "";
67
+ const emit = (raw) => {
68
+ const line = `[${String(step).padStart(2, "0")} +${((now() - t0) / 1000).toFixed(1)}s] ${raw}`;
69
+ out(line);
70
+ if (file) {
71
+ try { fs.appendFileSync(file, line + "\n"); } catch { /* 落盘失败只影响存档,不影响取证 */ }
72
+ }
73
+ };
74
+ try {
75
+ if (file) { fs.mkdirSync(dirname(file), { recursive: true }); fs.appendFileSync(file, `# multi-ip-pool 取证 transcript(本文件可整份粘贴)\n`); }
76
+ } catch { /* 同上 */ }
77
+ return {
78
+ file,
79
+ /** 一条步骤行(自动编号)。 */
80
+ step: (text) => { step += 1; emit(text); return step; },
81
+ /** 归属当前步骤的明细行(不占编号)。 */
82
+ say: (text) => emit(` ${String(text).replace(/^\s+/, "")}`),
83
+ /** 小节标题。 */
84
+ section: (text) => { step += 1; emit(`—— ${text} ——`); return step; },
85
+ };
86
+ }
87
+
88
+ /** 环境快照:一眼看清「机器/权限/二进制/配置/磁盘上有什么」,省掉一轮来回。 */
89
+ export function envSnapshotLines({ cfg, platform, root, openvpn, files = [], argvNode = "" } = {}) {
90
+ return [
91
+ `node ${argvNode || process.version}|平台 ${platform}|root ${root ? "是" : "否"}`,
92
+ `openvpn ${openvpn?.ok ? `可用:${openvpn.version}` : `不可用:${openvpn?.why || "?"}`}`,
93
+ `配置:隧道 ${cfg?.count} 条|备胎 ${cfg?.standby} 条/每 ${Math.round((cfg?.standbyEveryMs || 0) / 60_000)} 分钟|Score 下限 ${cfg?.minScore}|国家 ${cfg?.countries?.length ? cfg.countries.join("/") : "不限"}|策略 ${cfg?.strategy}`,
94
+ `目录 ${cfg?.dir}:${files.length ? files.join(" ") : "(空,本轮第一次跑)"}`,
95
+ `节点源(按序):${(cfg?.sources || []).join(" → ")}`,
96
+ ];
97
+ }
98
+
99
+ /** 每槽的「设备名/路由表/优先级」对照表:内核侧出问题时按这张表查。 */
100
+ export function slotPlanLines({ cfg, nodes = [] } = {}) {
101
+ 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)}`);
102
+ }
103
+
104
+ /** 跑完(尤其是中途失败)后给全善后命令:设备与路由残留必须能一眼查清。 */
105
+ export function residualHints() {
106
+ return [
107
+ "ip -brief addr show type tun",
108
+ "ip link show | grep -E 'mltun|tun'",
109
+ "ip rule show | grep -E 'from (10\\.|192\\.|172\\.)' | head",
110
+ "ip route show table all | grep -E 'mltun' | head",
111
+ ];
112
+ }
@@ -140,7 +140,8 @@ export function materializeNode({ dir, row, name = "" } = {}) {
140
140
  const remote = parseRemote(conf);
141
141
  if (!remote) return null;
142
142
  const written = writeNodeConf({ dir, row: { ...row, config: conf }, name });
143
- return { ...row, conf: written.path, host: remote.host, port: remote.port };
143
+ // proto 一并带出:预检与判语要分清「TCP 节点连不上」与「UDP 节点无握手可探」两回事
144
+ return { ...row, conf: written.path, host: remote.host, port: remote.port, proto: (/^\s*proto\s+(\S+)/m.exec(conf) || [])[1] || "" };
144
145
  }
145
146
 
146
147
  /** VPNGate 公共账号(vpn/vpn);落在 0600 文件里,绝不入库。 */
@@ -44,6 +44,11 @@ export function fmtSpawn({ dev, pid, host, port, country, table }) {
44
44
  return `${TAG} ${SYM.ok} ${dev} 隧道就绪(pid=${pid},出口节点 ${host}:${port} ${country},策略表 ${table})`;
45
45
  }
46
46
 
47
+ /** 实际执行的 openvpn 命令行(argv 原文):真机排障第一问就是「你到底跑了什么」。 */
48
+ export function fmtArgv({ bin, args }) {
49
+ return `${TAG} ${SYM.step} 执行:${bin} ${(args || []).join(" ")}`;
50
+ }
51
+
47
52
  export function fmtTunAddrFail({ dev, out }) {
48
53
  return `${TAG} ${SYM.bad} ${dev} 起来了但读不到 tun 地址(ip -4 addr show dev ${dev} 的输出:${(out || "(空)").slice(0, 200)})—— 隧道建不起来,这槽换下一个节点`;
49
54
  }
@@ -10,7 +10,7 @@ import { genId } from "../opencode-identity.js";
10
10
  import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
11
11
  import { classifyWarpSignal } from "../warp-pool/verdict.js";
12
12
  import { resolveMultiIpConfig, resolveRole } from "./config.js";
13
- import { candidateNodes, loadNodeCache, materializeNode } from "./nodes.js";
13
+ import { candidateNodes, loadNodeCache, materializeNode, writeAuthFile } from "./nodes.js";
14
14
  import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
15
15
  import { bringUpTunnel, checkOpenvpn, isRoot, isSupportedPlatform, tearDownTunnel } from "./tunnel.js";
16
16
  import { fmtExitIp, fmtNodePick, fmtNodes, fmtProbe } from "./report.js";
@@ -79,6 +79,9 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
79
79
 
80
80
  const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
81
81
  if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
82
+ // auth 文件必须先落到磁盘再把路径交给 openvpn。取证路径以前把 `deps.authFile`(undefined)直接传下去
83
+ // ⇒ `--auth-user-pass` 拿到空参数 ⇒ openvpn 判「选项缺参数」,只留一句「Use --help」——2026-10-25 真机三条槽全挂在这。
84
+ const authFile = deps.authFile || (deps.writeAuthFile || writeAuthFile)({ dir: cfg.dir, user: cfg.authUser, pass: cfg.authPass });
82
85
 
83
86
  const exits = [];
84
87
  try {
@@ -91,7 +94,7 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
91
94
  try {
92
95
  const node = materialize({ dir: cfg.dir, row });
93
96
  if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
94
- slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile: deps.authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
97
+ slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
95
98
  const exit = await exitProbe({ agent: agentFactory(slot.tunIp), timeoutMs: cfg.probeTimeoutMs });
96
99
  if (exit.ok) log(fmtExitIp({ dev: slot.dev, ip: exit.ip, ms: exit.ms }));
97
100
  // 从这条隧道的出口打**同一条**免费层请求
@@ -9,8 +9,8 @@
9
9
  // 平台纪律:只有 Linux 能干(tun + `ip rule`);其它平台由调用方**诚实跳过一行**,不做半吊子。
10
10
  import { spawn } from "node:child_process";
11
11
  import { devNameFor, tableFor } from "./config.js";
12
- import { tailReason } from "../warp-pool/revive.js";
13
- import { fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.js";
12
+ import { fmtArgv, fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.js";
13
+ import { openvpnErrorLine, openvpnErrorBrief } from "./diagnose.js";
14
14
 
15
15
  /** openvpn 就绪标志(配置文件里可能出现别的 "Sequence",必须整串匹配)。 */
16
16
  export const READY_PATTERN = /Initialization Sequence Completed/;
@@ -39,11 +39,18 @@ export function isRoot(getuid = process.getuid?.bind(process)) {
39
39
  * openvpn 命令行。**dev 与路由一律走命令行覆盖**(命令行优先于 config 文件):
40
40
  * `--route-nopull` + `--pull-filter ignore redirect-gateway` 保证它绝不碰主路由表——
41
41
  * 主路由表被改 = 整台机器的流量(含 daemon 自己打上游)都被拽进隧道,那是灾难。
42
+ * `--dev-type tun` 不可省:槽设备名(`mltun1`…)**不以 tun/tap 开头**,openvpn 从名字推不出类型
43
+ * (其 usage 原文:dev-type「仅当 --dev 的设备名不以 tun 或 tap 开头时使用」);实测 97 个 VPNGate 节点全是 `dev tun`。
44
+ * 三个必填项缺一即抛:空串传下去会被 openvpn 判成「选项缺参数」,只留下一句没头没尾的 usage 错误(2026-10-25 真机踩过)。
42
45
  */
43
46
  export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }) {
47
+ for (const [name, v] of [["conf", conf], ["dev", dev], ["authFile", authFile]]) {
48
+ if (!v) throw new Error(`buildOpenvpnArgs 缺 ${name}:拒绝起隧道(空参数会让 openvpn 只报一句 usage 错误,看不出病因)`);
49
+ }
44
50
  return [
45
51
  "--config", conf,
46
52
  "--dev", dev,
53
+ "--dev-type", "tun",
47
54
  "--auth-user-pass", authFile,
48
55
  "--route-nopull",
49
56
  "--pull-filter", "ignore", "redirect-gateway",
@@ -52,6 +59,7 @@ export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }
52
59
  ];
53
60
  }
54
61
 
62
+
55
63
  /** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
56
64
  export function parseTunAddr(text) {
57
65
  const m = /inet\s+(\d+\.\d+\.\d+\.\d+)/.exec(String(text || ""));
@@ -121,6 +129,8 @@ export async function checkOpenvpn(bin, { runCmdFn = runCmd } = {}) {
121
129
  * tail 保留末 20 行:真机排障时"openvpn 为什么退"全在这几行里。
122
130
  */
123
131
  export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, log = () => {}, onExit = null, dev = "" } = {}) {
132
+ // 「我到底跑了什么」必须是第一条信息:openvpn 的 argv 报错只有对着命令行原文才看得懂
133
+ log(fmtArgv({ bin, args }));
124
134
  let child;
125
135
  try {
126
136
  child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
@@ -139,13 +149,14 @@ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, lo
139
149
  if (tail.length > 20) tail.shift();
140
150
  }
141
151
  };
142
- const lastTail = () => tailReason(tail.join("\n"));
152
+ const lastTail = () => openvpnErrorLine(tail.join("\n"));
153
+ const briefTail = () => openvpnErrorBrief(tail.join("\n"));
143
154
  const ready = new Promise((resolve, reject) => {
144
155
  const timer = setTimeout(() => {
145
156
  if (settled) return;
146
157
  settled = true;
147
158
  try { child.kill("SIGTERM"); } catch { /* 已退 */ }
148
- reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${lastTail()}`));
159
+ reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${briefTail()}`));
149
160
  }, timeoutMs);
150
161
  const onChunk = (buf) => {
151
162
  push(buf);
@@ -161,7 +172,7 @@ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, lo
161
172
  if (!settled) {
162
173
  settled = true;
163
174
  clearTimeout(timer);
164
- reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${lastTail()}`));
175
+ reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${briefTail()}`));
165
176
  }
166
177
  // 主动关掉的隧道不算"意外退出":收摊日志只该出现在真出事时(否则每次换槽都刷一行假警报)
167
178
  if (!stopping) log(fmtExit({ dev, code, signal, pid: child.pid, aliveMs, tail: lastTail() }));