mslxdff 0.1.242 → 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 +1 -1
- package/src/cli/commands/multi-ip-pool-test.js +199 -0
- package/src/cli/commands/multi-ip-pool.js +10 -107
- package/src/multi-ip-pool/diagnose.js +112 -0
- package/src/multi-ip-pool/nodes.js +2 -1
- package/src/multi-ip-pool/report.js +5 -0
- package/src/multi-ip-pool/tunnel.js +7 -13
package/package.json
CHANGED
|
@@ -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
|
|
1
|
+
// mslxdff -multi-ip-pool [test|status|fetch|help]
|
|
2
|
+
// 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长**专属,只服务 opencode 免费层。
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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 {
|
|
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).replace(/\s+/g, " ").slice(0, 220)})`;
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -9,7 +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 { 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";
|
|
13
14
|
|
|
14
15
|
/** openvpn 就绪标志(配置文件里可能出现别的 "Sequence",必须整串匹配)。 */
|
|
15
16
|
export const READY_PATTERN = /Initialization Sequence Completed/;
|
|
@@ -58,16 +59,6 @@ export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }
|
|
|
58
59
|
];
|
|
59
60
|
}
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
* openvpn 输出里挑「最能说明问题」的一行:用法错误的**末行恒是** `Use --help for more information.`,
|
|
63
|
-
* 真凶(`Options error: …`)在它上面。只报末行等于把病因盖住——2026-10-25 真机就因为这样绕了一圈。
|
|
64
|
-
*/
|
|
65
|
-
export function openvpnErrorLine(text, max = 240) {
|
|
66
|
-
const lines = String(text || "").split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
|
67
|
-
if (!lines.length) return "";
|
|
68
|
-
const pick = lines.find((l) => /options error|unrecognized|missing or extra|fatal|error:|can ?not|cannot|denied|failed|refused|reset|auth failed/i.test(l));
|
|
69
|
-
return (pick || lines[lines.length - 1]).slice(0, max);
|
|
70
|
-
}
|
|
71
62
|
|
|
72
63
|
/** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
|
|
73
64
|
export function parseTunAddr(text) {
|
|
@@ -138,6 +129,8 @@ export async function checkOpenvpn(bin, { runCmdFn = runCmd } = {}) {
|
|
|
138
129
|
* tail 保留末 20 行:真机排障时"openvpn 为什么退"全在这几行里。
|
|
139
130
|
*/
|
|
140
131
|
export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, log = () => {}, onExit = null, dev = "" } = {}) {
|
|
132
|
+
// 「我到底跑了什么」必须是第一条信息:openvpn 的 argv 报错只有对着命令行原文才看得懂
|
|
133
|
+
log(fmtArgv({ bin, args }));
|
|
141
134
|
let child;
|
|
142
135
|
try {
|
|
143
136
|
child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
@@ -157,12 +150,13 @@ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, lo
|
|
|
157
150
|
}
|
|
158
151
|
};
|
|
159
152
|
const lastTail = () => openvpnErrorLine(tail.join("\n"));
|
|
153
|
+
const briefTail = () => openvpnErrorBrief(tail.join("\n"));
|
|
160
154
|
const ready = new Promise((resolve, reject) => {
|
|
161
155
|
const timer = setTimeout(() => {
|
|
162
156
|
if (settled) return;
|
|
163
157
|
settled = true;
|
|
164
158
|
try { child.kill("SIGTERM"); } catch { /* 已退 */ }
|
|
165
|
-
reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${
|
|
159
|
+
reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${briefTail()}`));
|
|
166
160
|
}, timeoutMs);
|
|
167
161
|
const onChunk = (buf) => {
|
|
168
162
|
push(buf);
|
|
@@ -178,7 +172,7 @@ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, lo
|
|
|
178
172
|
if (!settled) {
|
|
179
173
|
settled = true;
|
|
180
174
|
clearTimeout(timer);
|
|
181
|
-
reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${
|
|
175
|
+
reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${briefTail()}`));
|
|
182
176
|
}
|
|
183
177
|
// 主动关掉的隧道不算"意外退出":收摊日志只该出现在真出事时(否则每次换槽都刷一行假警报)
|
|
184
178
|
if (!stopping) log(fmtExit({ dev, code, signal, pid: child.pid, aliveMs, tail: lastTail() }));
|