mslxdff 0.1.242 → 0.1.244

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.242",
3
+ "version": "0.1.244",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,173 @@
1
+ // mslxdff -multi-ip-pool 的 `fetch`(抓表落盘)与 `probe`(只测节点可达性),加上 `test` 也要用的
2
+ // 「表准备 + ③ 取样预检」——这三件事都不打隧道,故与 test 的六步流水分开放。
3
+ //
4
+ // 为什么独立成一份(2026-10-25 真机第三轮):`test` 已顶到 11.3KB;而「这台机到底能连上表里几个节点」
5
+ // 是判断多出口池值不值得继续的**先行问题**——它不要 root、不要 openvpn、不碰内核,
6
+ // 任何平台都能几十秒给出答案,不该被塞进「要 Linux + root + 隧道」的取证流程里。
7
+ import { readdirSync, statSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { resolveMultiIpConfig } from "../../multi-ip-pool/config.js";
10
+ import { countCandidates, loadNodeCache, probeTargetOf, sampleCandidates } from "../../multi-ip-pool/nodes.js";
11
+ import { fetchNodes, fetchNodesToFile } from "../../multi-ip-pool/nodes-fetch.js";
12
+ import { createTranscript, probeBatch, probeSummaryLines } from "../../multi-ip-pool/diagnose.js";
13
+ import { argValue } from "../policy.js";
14
+
15
+ const FLAG = "-multi-ip-pool";
16
+
17
+ /** 取样条数:够看出「哪一片不通」,又不至于把流程拖成几分钟(限并发 6、单条最多 6s ⇒ 最坏约 15s)。 */
18
+ export const PROBE_DEFAULT = 12;
19
+
20
+ /** `--flag value` 与 `--flag=value` 两种写法都认;认不到返回 null(`-` 开头的不当值)。 */
21
+ export 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
+ /** 目录里已经有什么(auth.txt/nodes.json/node-*.conf)——很多失败就是这里少一个文件。 */
32
+ export function dirNotes(dir) {
33
+ try {
34
+ const names = readdirSync(dir);
35
+ const size = (f) => { try { return `${statSync(join(dir, f)).size}B`; } catch { return "?"; } };
36
+ const files = names.slice(0, 12).map((n) => `${n}(${size(n)})`);
37
+ files.push(names.length > 12 ? `…共 ${names.length} 个` : "");
38
+ files.push(`auth.txt ${names.includes("auth.txt") ? "在" : "**缺失**(拨号必挂,看第 4 步)"}`);
39
+ return files.filter(Boolean);
40
+ } catch (e) {
41
+ return [`目录读不到:${String(e?.code || e?.message || e)}`];
42
+ }
43
+ }
44
+
45
+ /** 取表(缓存优先,取不到就逐源试并把每个源的死因摆出来)。 */
46
+ export async function ensureRows(cfg, t) {
47
+ const cache = loadNodeCache({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
48
+ if (cache?.rows?.length) {
49
+ t.say(`命中本地缓存:${cache.rows.length} 个节点(表源 ${cache.source},缓存龄 ${Math.round((Date.now() - Number(cache.fetchedAt || 0)) / 1000)}s)`);
50
+ return cache.rows;
51
+ }
52
+ t.say("缓存没有或已过期 ⇒ 逐源取表(每源失败原因都会列出来)");
53
+ const got = await fetchNodes({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
54
+ t.say(`取到:${got.source} → ${got.rows.length} 个节点`);
55
+ return got.rows;
56
+ }
57
+
58
+ /** 一条预检结果的判语(UDP 节点必须如实标注「无握手,仅供参考」)。 */
59
+ function nodeLine(r, minScore = 0) {
60
+ const n = r.node || {};
61
+ const proto = String(n.proto || "").toLowerCase();
62
+ const how = r.ok ? `✓ TCP 通(${r.ms}ms)` : `✗ TCP 不通(${String(r.error || "?").replace(/\s+/g, " ").slice(0, 60)},${r.ms}ms)`;
63
+ const low = Number(n.score || 0) < minScore ? "(Score 低于下限,仅作可达性参考)" : "";
64
+ return ` ${r.ok ? "✓" : "✗"} ${n.hostName}(${n.countryShort || "?"})${n.host}:${n.port}|${n.proto || "?"}|Score ${n.score}${low} → ${how}`
65
+ + (proto === "udp" ? " ※ UDP 无握手,此探测仅供参考" : "");
66
+ }
67
+
68
+ /**
69
+ * ③ 取样与 TCP 预检(test 与 probe 共用一份,保证「探的」与「拨的」永远是同一批):
70
+ * 按簇轮转取样 → 限并发探一遍 → 逐条打印 + 小结 + 簇分布。
71
+ * @returns {Promise<{ sample: object[], nodes: object[], results: object[], live: object[] }>}
72
+ */
73
+ export async function surveyCandidates({ cfg, rows = [], t, limit = PROBE_DEFAULT, timeoutMs = 5_000, concurrency = 6 } = {}) {
74
+ const strict = sampleCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore, limit });
75
+ // 达下限的候选不够取样条数时补低分节点——**只作可达性参考**(Score 低不等于连不上,先别急着排除它)
76
+ const skip = new Set(strict.map((r) => r.hostName));
77
+ const sample = strict.length < limit
78
+ ? [...strict, ...sampleCandidates({ rows, countries: cfg.countries, minScore: 0, limit: limit - strict.length, skip })]
79
+ : strict;
80
+ const matched = countCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore });
81
+ t.say(`候选池:表 ${rows.length} 个 → 国家/Score 达标 ${matched} 个 → 取样 ${sample.length} 个(按簇轮转:同一机房不占满取样,免得一片不通就判整表不通)`);
82
+ const nodes = sample
83
+ .map((row) => { const target = probeTargetOf(row); return target ? { ...row, host: target.host, port: target.port, proto: target.proto } : null; })
84
+ .filter(Boolean);
85
+ if (!nodes.length) return { sample, nodes, results: [], live: [] };
86
+ const low = nodes.filter((n) => Number(n.score || 0) < cfg.minScore).length;
87
+ t.say(`预检:限并发 ${Math.min(concurrency, nodes.length)}、单条 ${timeoutMs}ms${low ? `(其中 ${low} 条 Score 低于下限,仅作参考)` : ""}`);
88
+ const results = await probeBatch({ nodes, timeoutMs, concurrency });
89
+ for (const r of results) t.say(nodeLine(r, cfg.minScore));
90
+ for (const l of probeSummaryLines({ results, minScore: cfg.minScore })) t.say(l);
91
+ return { sample, nodes, results, live: results.filter((r) => r.ok).map((r) => r.node) };
92
+ }
93
+
94
+ /**
95
+ * `-multi-ip-pool probe [--probe N]`:只回答「这台机能连上表里几个节点」。
96
+ * 不建隧道、不要 root、不碰 openvpn/内核、不看角色门 ⇒ 任何平台都能跑:这是「池值不值得继续」的先行问题。
97
+ */
98
+ export async function runProbe(args = []) {
99
+ const cfg = resolveMultiIpConfig(process.env);
100
+ const limit = (() => { const v = flagValue(args, "--probe", "-p"); return v === null ? PROBE_DEFAULT : Math.max(1, Math.min(64, Number(v) || PROBE_DEFAULT)); })();
101
+ const t = createTranscript({ dir: cfg.dir, fileName: `probe-${new Date().toISOString().replace(/[:.]/g, "-")}.log` });
102
+ console.log("=".repeat(70));
103
+ console.log(" 多出口池 · 节点可达性(只测能不能连上:不拨号、不要 root、不动内核、不看角色门)");
104
+ console.log("=".repeat(70));
105
+
106
+ t.step("① 环境快照");
107
+ t.say(`node ${process.version}|平台 ${process.platform}`);
108
+ t.say(`配置:取样 ${limit} 条|Score 下限 ${cfg.minScore}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|目录 ${cfg.dir}`);
109
+ t.say(`节点源(按序):${cfg.sources.join(" → ")}`);
110
+
111
+ t.step("② 节点表");
112
+ let rows = [];
113
+ try {
114
+ rows = await ensureRows(cfg, t);
115
+ } catch (e) {
116
+ t.say(String(e?.message || e));
117
+ console.log("\n❌ 取不到节点表(原因见上一行)");
118
+ console.log(` 完整 transcript:${t.file}`);
119
+ process.exit(1);
120
+ }
121
+
122
+ t.step("③ 取样与 TCP 预检");
123
+ const survey = await surveyCandidates({ cfg, rows, t, limit, timeoutMs: Math.min(6_000, cfg.probeTimeoutMs), concurrency: 6 });
124
+ if (!survey.sample.length) {
125
+ t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
126
+ console.log(`\n❌ 没有可用候选(放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑)`);
127
+ console.log(` 完整 transcript:${t.file}`);
128
+ process.exit(1);
129
+ }
130
+ const tcpResults = survey.results.filter((r) => String(r.node?.proto || "").toLowerCase() !== "udp");
131
+ const tcpLive = tcpResults.filter((r) => r.ok);
132
+
133
+ t.step("④ 结论");
134
+ if (tcpLive.length) {
135
+ t.say(`✅ 取样里 ${tcpLive.length}/${tcpResults.length} 条 TCP 节点可连 ⇒ 这台机能用多出口池(能不能换来新额度另说:跑 test)`);
136
+ } else {
137
+ t.say(`⛔ 取样里 ${tcpResults.length} 条 TCP 节点一条都连不上 ⇒ 这台机到这些免费节点整体不通(网络层,不是本模块配错)`);
138
+ t.say("下一步:① 换网络/换出口(云厂商安全组、运营商对境外 VPN 端口的限制都在这一层);② 加大取样 --probe 40 或去掉国家白名单再确认一次");
139
+ }
140
+ console.log("");
141
+ console.log(tcpLive.length ? ` ✅ 取样 ${tcpResults.length} 条 TCP 节点里有 ${tcpLive.length} 条可连` : ` ⛔ 一条 TCP 节点都连不上(探了 ${tcpResults.length} 条)`);
142
+ console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
143
+ console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
144
+ process.exit(tcpLive.length ? 0 : 1);
145
+ }
146
+
147
+ /**
148
+ * `-multi-ip-pool fetch [路径]`:在**能连节点源**的机器上抓一份表落成文件,scp 到受限网络的组长机,
149
+ * 那边设 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可完全跳过取表网络请求(2026-10-25 真机:华为云 ECS 到 vpngate.net 不通)。
150
+ */
151
+ export async function runFetch(args = []) {
152
+ const cfg = resolveMultiIpConfig(process.env);
153
+ const out = flagValue(args, "--out", "-o") || join(cfg.dir, "vpngate.csv");
154
+ const t = createTranscript({ dir: cfg.dir, fileName: "fetch.log" });
155
+ t.step("逐源取表(失败原因逐条列出)");
156
+ t.say(`源列表:${cfg.sources.join(" → ")}`);
157
+ try {
158
+ const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
159
+ const usable = got.rows.filter((r) => r.config).length;
160
+ t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}`);
161
+ console.log("");
162
+ console.log(" 带到受限网络的机器上:");
163
+ console.log(` scp ${out} root@<组长机>:/root/.config/mslxdff/multi-ip-pool/vpngate.csv`);
164
+ console.log(" export MSLXDFF_MULTI_IP_NODES_FILE=/root/.config/mslxdff/multi-ip-pool/vpngate.csv");
165
+ console.log(" (之后取表零网络请求;表会旧,免费节点掉得快,建议隔几天重抓一次)");
166
+ console.log(` 先在那台机上跑 mslxdff ${FLAG} probe:能连上几个节点,比真拨一遍快得多`);
167
+ process.exit(0);
168
+ } catch (e) {
169
+ t.say(`❌ ${String(e?.message || e)}`);
170
+ console.log(" 下一步:换网络重试,或 MSLXDFF_MULTI_IP_SOURCES=<你自己能连的镜像/本地路径> 再跑一次");
171
+ process.exit(1);
172
+ }
173
+ }
@@ -0,0 +1,136 @@
1
+ // mslxdff -multi-ip-pool 的 test 流程:全程 transcript(屏幕 + 文件),每步编号带耗时。
2
+ //
3
+ // 为什么要写成流水账(用户 2026-10-25 明确要求):真机两轮排障都卡在「只看到最终判语、看不到环节」——
4
+ // openvpn 的 argv、它的 stderr 原文、节点端口通不通、tun 地址有没有拿到、路由装没装上,
5
+ // 每一项都必须是**一行一条**摆在屏幕上,并且整份落盘,贴一个文件就能定位。
6
+ // fetch / probe 两条「不打隧道」的流程、以及本文件也要用的「表准备 + ③ 取样预检」在 ./multi-ip-pool-fetch.js。
7
+ import { runMultiIpCompare, VERDICT_ZH, MITEST_DEFAULT, MITEST_MAX } from "../../multi-ip-pool/selftest.js";
8
+ import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
9
+ import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
10
+ import { createTranscript, envSnapshotLines, residualHints, slotPlanLines } from "../../multi-ip-pool/diagnose.js";
11
+ import { loadGroupsJoined } from "../../state.js";
12
+ import { PROBE_DEFAULT, dirNotes, ensureRows, flagValue, surveyCandidates } from "./multi-ip-pool-fetch.js";
13
+
14
+ const FLAG = "-multi-ip-pool";
15
+
16
+ const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
17
+
18
+ /** 探活结果 → 一行判语(基线与逐槽结果共用同一份口径)。 */
19
+ const line = (r) => {
20
+ if (r.error) return `✗ 不可达(${String(r.error).replace(/\s+/g, " ").slice(0, 300)})`;
21
+ if (r.ok) return `✅ HTTP ${r.status} 有额度`;
22
+ return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
23
+ };
24
+
25
+ /**
26
+ * `-multi-ip-pool test`:五道门 → 取表 → **按簇取样 + 批量 TCP 预检(只拨探通的)** → 基线 → 逐槽起隧道取证 → 判语 → 善后。
27
+ * 任何一步失败都会停在那一步并说清「下一步做什么」,不会攒到最后给你一个 unreachable。
28
+ * @param {string[]} args 认 `--count N`(拨几条)、`--probe N`(取样几条)、`--force`(一条都不通也真拨留证据)、`--model <id>`
29
+ */
30
+ export async function runTest(args = []) {
31
+ const cfg = resolveMultiIpConfig(process.env);
32
+ const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? MITEST_DEFAULT : Math.max(1, Math.min(MITEST_MAX, Number(v) || MITEST_DEFAULT)); })();
33
+ const probeN = (() => { const v = flagValue(args, "--probe", "-p"); return v === null ? PROBE_DEFAULT : Math.max(1, Math.min(64, Number(v) || PROBE_DEFAULT)); })();
34
+ const force = args.includes("--force");
35
+ const model = flagValue(args, "--model", "-m") || "";
36
+ const t = createTranscript({ dir: cfg.dir });
37
+ console.log("=".repeat(70));
38
+ console.log(" 多出口池取证 —— 全程 transcript(每一步都印,失败即停在原地)");
39
+ console.log("=".repeat(70));
40
+
41
+ t.step("① 环境快照");
42
+ const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
43
+ const root = isRoot();
44
+ const supported = isSupportedPlatform();
45
+ const openvpn = await checkOpenvpn(cfg.openvpnBin);
46
+ for (const l of envSnapshotLines({ cfg, platform: process.platform, root, openvpn, files: dirNotes(cfg.dir) })) t.say(l);
47
+ t.say(`角色门 role=${role.role} ⇒ ${role.active ? "通过" : "不建池"}(${role.why})`);
48
+ if (!cfg.enabled || !role.active || !supported || !root || !openvpn.ok) {
49
+ t.say("五道门有不过的 ⇒ 后面不再动作(否则只会被 openvpn/内核报一堆看不懂的错)");
50
+ console.log(`\n❌ 前置不足:开关=${cfg.enabled} 角色=${role.active} Linux=${supported} root=${root} openvpn=${openvpn.ok}`);
51
+ console.log(` 下一步:mslxdff ${FLAG} status(逐门给人话结论)|只想测网络可达性用 ${FLAG} probe(不要 root)`);
52
+ console.log(` 完整 transcript:${t.file}`);
53
+ process.exit(1);
54
+ }
55
+
56
+ t.step("② 节点表");
57
+ let rows = [];
58
+ try {
59
+ rows = await ensureRows(cfg, t);
60
+ } catch (e) {
61
+ t.say(String(e?.message || e));
62
+ console.log(`\n❌ 取不到节点表(原因见上一行)`);
63
+ console.log(" 下一步:在能连的机器上 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,");
64
+ console.log(" 再 export MSLXDFF_MULTI_IP_NODES_FILE=<路径> 重跑 test");
65
+ console.log(` 完整 transcript:${t.file}`);
66
+ process.exit(1);
67
+ }
68
+
69
+ t.step("③ 取样与 TCP 预检(按簇摊开取样;探不通的一律不拨,省掉几分钟白等)");
70
+ const survey = await surveyCandidates({ cfg, rows, t, limit: probeN, timeoutMs: Math.min(6_000, cfg.probeTimeoutMs), concurrency: 6 });
71
+ if (!survey.sample.length) {
72
+ t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
73
+ console.log(`\n❌ 没有可用候选`);
74
+ console.log(" 下一步:放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑");
75
+ console.log(` 完整 transcript:${t.file}`);
76
+ process.exit(1);
77
+ }
78
+ const tcpResults = survey.results.filter((r) => String(r.node?.proto || "").toLowerCase() !== "udp");
79
+ if (!survey.live.length && tcpResults.length && !force) {
80
+ t.say(`⛔ 取样里 ${tcpResults.length} 条 TCP 节点**一条都连不上** ⇒ 这台机到这一片节点整体不通(网络层,不是本模块配错)`);
81
+ t.say("下一步:① 换网络/换出口——云厂商安全组、运营商对境外 VPN 端口的限制都在这一层");
82
+ t.say(` ② 加大取样再看一次:mslxdff ${FLAG} test --probe 40(或去掉国家白名单 MSLXDFF_MULTI_IP_COUNTRY=)`);
83
+ t.say(` ③ 只测可达性、不要 root:mslxdff ${FLAG} probe --probe 40`);
84
+ t.say(` ④ 一定要看真拨的证据:mslxdff ${FLAG} test --force`);
85
+ console.log(`\n❌ 取样里 ${tcpResults.length} 条 TCP 节点一条都连不上`);
86
+ console.log(" 这不是本模块的配置错,是这台机到这些免费节点(这一片机房)的网络不通");
87
+ console.log(` 完整 transcript:${t.file}`);
88
+ process.exit(1);
89
+ }
90
+ const dialRows = (survey.live.length ? survey.live : survey.nodes).slice(0, count);
91
+ for (const l of slotPlanLines({ cfg, nodes: dialRows })) t.say(l);
92
+ t.say(`拨号计划:${dialRows.length} 条${survey.live.length ? "(取自上面探通的)" : "(没有探通的 ⇒ 按取样顺序强拨,只为留证据)"}`);
93
+
94
+ t.step("④ 基线 + 逐槽起隧道取证");
95
+ const onProgress = (ev) => {
96
+ if (ev.phase === "nodes") t.say(`(selftest 用的表:${ev.source},${ev.total} 个节点)`);
97
+ else if (ev.phase === "baseline-start") t.say("基线:直连(系统默认出口)打一条免费层请求…");
98
+ else if (ev.phase === "baseline-done") t.say(`基线:${line(ev)}${ev.body ? ` ${String(ev.body).slice(0, 90).replace(/\s+/g, " ")}` : ""}`);
99
+ else if (ev.phase === "exit-start") t.say(`槽 ${ev.i}/${ev.n}:${ev.row.hostName}(${ev.row.countryShort || "?"})开始`);
100
+ else if (ev.phase === "exit-done") t.say(`槽 ${ev.i}/${ev.n} 结果:${line(ev)}${ev.exitIp ? `|出口 IP ${ev.exitIp}` : ""}`);
101
+ };
102
+ let r;
103
+ try {
104
+ r = await runMultiIpCompare({ count: dialRows.length || count, slots: dialRows, ...(model ? { model } : {}), onProgress, deps: { log: (m) => t.say(m) } });
105
+ } catch (e) {
106
+ t.say(`测试执行抛错:${String(e?.stack || e?.message || e)}`);
107
+ console.log(`\n❌ 测试执行出错:${e?.message ?? e}`);
108
+ console.log(` 完整 transcript:${t.file}`);
109
+ process.exit(1);
110
+ }
111
+ if (r.status === "skip") {
112
+ t.say(`跳过:${r.reason}`);
113
+ console.log(`\n❌ 测不了:${r.reason}`);
114
+ console.log(` 下一步:${r.hint}`);
115
+ console.log(` 完整 transcript:${t.file}`);
116
+ process.exit(1);
117
+ }
118
+
119
+ t.step("⑤ 判语");
120
+ console.log("-".repeat(70));
121
+ console.log(` 基线(直连):${line(r.baseline)}`);
122
+ console.log(` VPN 出口:有额度 ${r.okCount}|受限 ${r.limitedCount}|不可达 ${r.netCount}(共 ${r.requested})`);
123
+ const v = VERDICT_ZH[r.verdict] || { text: "未判定", next: "" };
124
+ const mark = r.verdict === "multi-ip-works" ? "✅" : r.verdict === "mixed" ? "△" : "⛔";
125
+ console.log(` ${mark} ${v.text}:${r.okCount}/${r.requested} 条隧道拿到 200`);
126
+ if (v.next) console.log(` ${v.next}`);
127
+ for (const e of r.exits || []) console.log(` · ${e.hostName}(${e.country || "?"})${e.exitIp ? `出口 ${e.exitIp}|` : ""}${line(e)}`);
128
+
129
+ t.step("⑥ 善后与存档");
130
+ t.say(`隧道由本命令自造自拆;若中途 Ctrl+C,按下面几条查残留:`);
131
+ for (const h of residualHints()) t.say(` ${h}`);
132
+ console.log("");
133
+ console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
134
+ console.log(` 重试:mslxdff ${FLAG} test --count ${r.requested}${model ? ` --model ${model}` : ""}`);
135
+ process.exit(r.verdict === "multi-ip-works" || r.verdict === "mixed" ? 0 : 1);
136
+ }
@@ -1,125 +1,29 @@
1
- // mslxdff -multi-ip-pool [test|status|help]
2
- // 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长/单机**专属,只服务 opencode 免费层。
1
+ // mslxdff -multi-ip-pool [test|probe|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, runProbe } from "./multi-ip-pool-fetch.js";
10
+ import { runTest } from "./multi-ip-pool-test.js";
13
11
 
14
12
  const FLAG = "-multi-ip-pool";
15
13
 
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
14
  export async function handleMultiIpPool(args = []) {
34
15
  const at = args.findIndex((x) => x === FLAG || x.startsWith(`${FLAG}=`));
35
16
  if (at < 0) return false;
36
17
  const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
37
18
  if (["-h", "--help", "help"].includes(sub)) return printUsage();
38
19
  if (sub === "status") return await printStatus(args);
20
+ if (sub === "probe" || sub === "ping") return await runProbe(args);
39
21
  if (sub === "fetch" || sub === "grab") return await runFetch(args);
40
22
  return await runTest(args);
41
23
  }
42
24
 
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) {
25
+ /** status:只回答「开没开、为什么」。**不建池不拨号**,所以只能报告配置与门,报不了 daemon 内存里的池。 */
26
+ async function printStatus(args = []) {
123
27
  const cfg = resolveMultiIpConfig(process.env);
124
28
  const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
125
29
  const platform = process.platform;
@@ -141,17 +45,22 @@ async function printStatus(args) {
141
45
  console.log(` 节点源 ${cfg.nodesFile ? `本地表优先:${cfg.nodesFile}(其后 ${cfg.sources.length - 1} 个网络源)` : cfg.sources.join(",")}`);
142
46
  console.log("");
143
47
  console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
48
+ console.log(` 测网络可达性(不要 root):mslxdff ${FLAG} probe [--probe N]`);
144
49
  process.exit(0);
145
50
  return true;
146
51
  }
147
52
 
148
53
  function printUsage() {
149
- console.log(`用法: mslxdff ${FLAG} [test|status|fetch|help] [--count N] [--model <id>] [--out <路径>]`);
54
+ console.log(`用法: mslxdff ${FLAG} [test|probe|status|fetch|help] [--count N] [--probe N] [--force] [--model <id>] [--out <路径>]`);
150
55
  console.log("");
151
- console.log(" 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
56
+ console.log(" test 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
152
57
  console.log(" 对照判语:");
153
58
  console.log(" 基线 429 + VPN 出口有 200 ⇒ 池有用(换 IP 换到新额度)");
154
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/内核、不看角色门,任何平台都能跑 —— 池值不值得继续用的先行问题。");
155
64
  console.log("");
156
65
  console.log(" 前置:Linux + root + openvpn(tun 设备与策略路由必需)。非 Linux 如实跳过。");
157
66
  console.log("");
@@ -171,4 +80,4 @@ function printUsage() {
171
80
  console.log(" MSLXDFF_MULTI_IP_PROBE_MODEL=<id> 打 hi 探额度用的免费模型(空=同款缺省)");
172
81
  process.exit(0);
173
82
  return true;
174
- }
83
+ }
@@ -0,0 +1,159 @@
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
+ }
@@ -8,7 +8,7 @@
8
8
  // 拆成独立模块的原因:装配(index)、拨号(本文件)、备选池(standby)三件事各自都要单测,混一起必超 10KB 门。
9
9
  import { getUndici } from "../compat.js";
10
10
  import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
11
- import { candidateNodes, loadNodeCache, materializeNode } from "./nodes.js";
11
+ import { loadNodeCache, materializeNode, sampleCandidates } from "./nodes.js";
12
12
  import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
13
13
  import { bringUpTunnel, tearDownTunnel } from "./tunnel.js";
14
14
  import { AGENT_OPTIONS } from "./dispatch.js";
@@ -74,7 +74,7 @@ export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () =>
74
74
  return { ok: Boolean(r.ok), status: Number(r.status) || 0, kind: r.kind || "", ms: Number(r.ms) || 0, ip, error: r.error || "" };
75
75
  }
76
76
 
77
- /** 取节点表 → 候选队列(按 Score 降序,剔除已用过的);`force` = 绕过缓存重取(每小时刷新备选池用)。 */
77
+ /** 取节点表 → 候选队列(Score 降序 + **按簇轮转**,剔除已用过的);`force` = 绕过缓存重取(每小时刷新备选池用)。 */
78
78
  async function ensureCandidates({ force = false } = {}) {
79
79
  if (!force && pending.length) return pending.length;
80
80
  let rows = [];
@@ -90,7 +90,9 @@ export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () =>
90
90
  rows = got.rows;
91
91
  source = got.source;
92
92
  }
93
- pending = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, skip: used, scan: cfg.scan * (cfg.count + cfg.standby + 1) });
93
+ // 按簇轮转(见 nodes.js sampleCandidates):免费表前列常被同一个机房霸榜,纯 Score 序会让「那一片连不上」
94
+ // 变成「池永远起不来」(真机 2026-10-25:三个候选同属 219.100.37.x)。轮转不丢节点,只是把各机房摊开。
95
+ pending = sampleCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore, skip: used, limit: cfg.scan * (cfg.count + cfg.standby + 1) });
94
96
  log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
95
97
  return pending.length;
96
98
  }
@@ -105,6 +105,60 @@ export function candidateNodes({ rows = [], countries = [], minScore = 0, scan =
105
105
  .slice(0, scan);
106
106
  }
107
107
 
108
+ /**
109
+ * 节点「簇」:同一批免费节点往往同机房同网段(hostName 同前缀 + 同 /24),**一条连不上时整片都连不上**。
110
+ * 真机 2026-10-25 实证:按 Score 取 3 个候选全落在 `public-vpn-*` / `219.100.37.x`,
111
+ * 于是「一个机房的墙」被误读成「全部节点不通」。取样与候选排序都必须按簇摊开。
112
+ */
113
+ export function clusterOf(row) {
114
+ const host = String(row?.hostName || "").replace(/[-.\d]+$/, "");
115
+ const seg = String(row?.ip || "").split(".").slice(0, 3).join(".");
116
+ return `${host}|${seg}`;
117
+ }
118
+
119
+ /** 达标候选条数(只数不取:给「取样里有几个是达标的」这类结论用)。 */
120
+ export function countCandidates({ rows = [], countries = [], minScore = 0 } = {}) {
121
+ return rows.filter((r) => r.config && r.score >= minScore && matchCountry(r, countries)).length;
122
+ }
123
+
124
+ /**
125
+ * 取样候选:Score 降序,但**按簇轮转**(先每个机房各出一条,再轮到各家第二条…)。
126
+ * 为什么不沿用纯 Score 序:免费表前列常被同一个机房霸榜,纯排序取样=拿一个机房的运气赌整台机器能不能用。
127
+ * 轮转不丢节点——簇内仍是 Score 降序,只是把同簇的连续几条摊到后面几轮去。
128
+ * @param {{ rows?: object[], countries?: string[], minScore?: number, limit?: number,
129
+ * perCluster?: number, skip?: Set<string> }} opts perCluster=0 表示不设每簇上限
130
+ */
131
+ export function sampleCandidates({ rows = [], countries = [], minScore = 0, limit = 12, perCluster = 0, skip = new Set() } = {}) {
132
+ const pool = candidateNodes({ rows, countries, minScore, scan: Math.max(1, rows.length), skip });
133
+ const groups = new Map();
134
+ for (const r of pool) {
135
+ const key = clusterOf(r);
136
+ if (!groups.has(key)) groups.set(key, []);
137
+ groups.get(key).push(r);
138
+ }
139
+ const out = [];
140
+ for (let round = 0; out.length < limit && groups.size; round++) {
141
+ let added = 0;
142
+ for (const list of groups.values()) {
143
+ if (out.length >= limit) break;
144
+ if (round >= list.length || (perCluster > 0 && round >= perCluster)) continue;
145
+ out.push(list[round]);
146
+ added += 1;
147
+ }
148
+ if (!added) break; // 各簇都取空了
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /** 只读地把一行解成「探什么/拨什么」(不落文件):TCP 预检与选槽共用同一份解析。 */
154
+ export function probeTargetOf(row) {
155
+ const conf = decodeConfig(row?.config);
156
+ if (!conf) return null;
157
+ const remote = parseRemote(conf);
158
+ if (!remote) return null;
159
+ return { decoded: conf, host: remote.host, port: remote.port, proto: (/^\s*proto\s+(\S+)/m.exec(conf) || [])[1] || "" };
160
+ }
161
+
108
162
  /** 节点表缓存(TTL 内直接用,省一次外网请求;VPNGate 免费节点掉得快,默认 30min)。 */
109
163
  export function loadNodeCache({ dir, ttlMs = 0, now = () => Date.now() } = {}) {
110
164
  try {
@@ -133,14 +187,15 @@ export function writeNodeConf({ dir, row, name = "" } = {}) {
133
187
  return { path: writePrivate(join(dir, `node-${alias}.conf`), row.config), hostName: row.hostName };
134
188
  }
135
189
 
136
- /** writeNodeConf 落的是**解码后**的配置;这里显式解码一次,把「解不开」的节点挡在门外(绝不猜)。 */
190
+ /** writeNodeConf 落的是**解码后**的配置;这里复用 `probeTargetOf` 解码:
191
+ * 解不开或缺 remote 的节点一律返 null(绝不猜),绝不把坏配置交给 openvpn。 */
137
192
  export function materializeNode({ dir, row, name = "" } = {}) {
138
- const conf = decodeConfig(row.config);
139
- if (!conf) return null;
140
- const remote = parseRemote(conf);
141
- if (!remote) return null;
142
- const written = writeNodeConf({ dir, row: { ...row, config: conf }, name });
143
- return { ...row, conf: written.path, host: remote.host, port: remote.port };
193
+ const target = probeTargetOf(row);
194
+ if (!target) return null;
195
+ // 落盘的是解码后的配置文本;别名照旧按 hostName/ip 生成(与旧行为一致)
196
+ const written = writeNodeConf({ dir, row: { ...row, config: target.decoded }, name });
197
+ // host/port/proto 与 TCP 预检同源(都出自 `probeTargetOf`):预检探的与这里拨的必须是同一份解释
198
+ return { ...row, conf: written.path, host: target.host, port: target.port, proto: target.proto };
144
199
  }
145
200
 
146
201
  /** 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
  }
@@ -27,9 +27,9 @@ export const MITEST_DEFAULT = 3;
27
27
  * onProgress?: (ev: object) => void,
28
28
  * deps?: { joined?, platform?, supported?, isRoot?, checkOpenvpn?, loadNodeCache?, fetchNodes?,
29
29
  * materializeNode?, bringUpTunnel?, tearDownTunnel?, agentFactory?, probeExitIp?,
30
- * probeUpstream?, fetchImpl?, session?, log? } }} opts
30
+ * probeUpstream?, fetchImpl?, session?, log? }, slots?: object[] }} opts
31
31
  */
32
- export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFAULT, model = DEFAULT_PROBE_MODEL, baseUrl = "https://opencode.ai", onProgress, deps = {} } = {}) {
32
+ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFAULT, model = DEFAULT_PROBE_MODEL, baseUrl = "https://opencode.ai", onProgress, deps = {}, slots = null } = {}) {
33
33
  const cfg = resolveMultiIpConfig(env);
34
34
  const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
35
35
  const log = deps.log || (() => {});
@@ -76,8 +76,10 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
76
76
  tick({ phase: "baseline-start", model });
77
77
  const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
78
78
  tick({ phase: "baseline-done", ...baseline });
79
+ // 候选:调用方给了 `slots` 就用它——CLI 侧已经「按簇取样 + TCP 预检」过,拨的必须就是探通的那些;
80
+ // 没给才自己按 Score 取(单测与旧路径)。两边各挑一次会让「探的」与「拨的」不是同一批,预检就白做了。
79
81
 
80
- const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
82
+ const picked = (Array.isArray(slots) && slots.length) ? slots.slice(0, MITEST_MAX) : candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
81
83
  if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
82
84
  // auth 文件必须先落到磁盘再把路径交给 openvpn。取证路径以前把 `deps.authFile`(undefined)直接传下去
83
85
  // ⇒ `--auth-user-pass` 拿到空参数 ⇒ openvpn 判「选项缺参数」,只留一句「Use --help」——2026-10-25 真机三条槽全挂在这。
@@ -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」(隧道没起来):${lastTail()}`));
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}):${lastTail()}`));
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() }));