mslxdff 0.1.243 → 0.1.245

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.243",
3
+ "version": "0.1.245",
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, maxAgeMs: cfg.maxTableAgeMs, log: (m) => t.say(m) });
54
+ t.say(`取到:${got.source} → ${got.rows.length} 个节点${got.stale ? "(⚠ 这张表已过期:节点多半早下线,别拿它当唯一来源)" : ""}`);
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, maxAgeMs: cfg.maxTableAgeMs, log: (m) => t.say(m) });
159
+ const usable = got.rows.filter((r) => r.config).length;
160
+ t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}${got.stale ? " ⚠ 这份表已过期,只能应急" : ""}`);
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
+ }
@@ -1,73 +1,37 @@
1
- // mslxdff -multi-ip-pool 的 test / fetch 两条流程:全程 transcript(屏幕 + 文件),每步编号带耗时。
1
+ // mslxdff -multi-ip-pool 的 test 流程:全程 transcript(屏幕 + 文件),每步编号带耗时。
2
2
  //
3
3
  // 为什么要写成流水账(用户 2026-10-25 明确要求):真机两轮排障都卡在「只看到最终判语、看不到环节」——
4
4
  // openvpn 的 argv、它的 stderr 原文、节点端口通不通、tun 地址有没有拿到、路由装没装上,
5
5
  // 每一项都必须是**一行一条**摆在屏幕上,并且整份落盘,贴一个文件就能定位。
6
- import { readdirSync, statSync } from "node:fs";
7
- import { join } from "node:path";
6
+ // fetch / probe 两条「不打隧道」的流程、以及本文件也要用的「表准备 + ③ 取样预检」在 ./multi-ip-pool-fetch.js。
8
7
  import { runMultiIpCompare, VERDICT_ZH, MITEST_DEFAULT, MITEST_MAX } from "../../multi-ip-pool/selftest.js";
9
8
  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
9
  import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
13
- import { createTranscript, envSnapshotLines, probeTcp, residualHints, slotPlanLines } from "../../multi-ip-pool/diagnose.js";
10
+ import { createTranscript, envSnapshotLines, residualHints, slotPlanLines } from "../../multi-ip-pool/diagnose.js";
14
11
  import { loadGroupsJoined } from "../../state.js";
15
- import { argValue } from "../policy.js";
12
+ import { PROBE_DEFAULT, dirNotes, ensureRows, flagValue, surveyCandidates } from "./multi-ip-pool-fetch.js";
16
13
 
17
14
  const FLAG = "-multi-ip-pool";
18
15
 
19
16
  const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
20
17
 
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
-
18
+ /** 探活结果 → 一行判语(基线与逐槽结果共用同一份口径)。 */
31
19
  const line = (r) => {
32
20
  if (r.error) return `✗ 不可达(${String(r.error).replace(/\s+/g, " ").slice(0, 300)})`;
33
21
  if (r.ok) return `✅ HTTP ${r.status} 有额度`;
34
22
  return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
35
23
  };
36
24
 
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
25
  /**
65
- * `-multi-ip-pool test`:五道门 → 取表 → 每槽 TCP 预检 → 基线 → 逐槽起隧道取证 → 判语 → 善后。
26
+ * `-multi-ip-pool test`:五道门 → 取表 → **按簇取样 + 批量 TCP 预检(只拨探通的)** → 基线 → 逐槽起隧道取证 → 判语 → 善后。
66
27
  * 任何一步失败都会停在那一步并说清「下一步做什么」,不会攒到最后给你一个 unreachable。
28
+ * @param {string[]} args 认 `--count N`(拨几条)、`--probe N`(取样几条)、`--force`(一条都不通也真拨留证据)、`--model <id>`
67
29
  */
68
30
  export async function runTest(args = []) {
69
31
  const cfg = resolveMultiIpConfig(process.env);
70
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");
71
35
  const model = flagValue(args, "--model", "-m") || "";
72
36
  const t = createTranscript({ dir: cfg.dir });
73
37
  console.log("=".repeat(70));
@@ -84,7 +48,8 @@ export async function runTest(args = []) {
84
48
  if (!cfg.enabled || !role.active || !supported || !root || !openvpn.ok) {
85
49
  t.say("五道门有不过的 ⇒ 后面不再动作(否则只会被 openvpn/内核报一堆看不懂的错)");
86
50
  console.log(`\n❌ 前置不足:开关=${cfg.enabled} 角色=${role.active} Linux=${supported} root=${root} openvpn=${openvpn.ok}`);
87
- console.log(` 下一步:mslxdff ${FLAG} status(逐门给人话结论)|完整 transcript:${t.file}`);
51
+ console.log(` 下一步:mslxdff ${FLAG} status(逐门给人话结论)|只想测网络可达性用 ${FLAG} probe(不要 root)`);
52
+ console.log(` 完整 transcript:${t.file}`);
88
53
  process.exit(1);
89
54
  }
90
55
 
@@ -99,29 +64,32 @@ export async function runTest(args = []) {
99
64
  console.log(" 再 export MSLXDFF_MULTI_IP_NODES_FILE=<路径> 重跑 test");
100
65
  console.log(` 完整 transcript:${t.file}`);
101
66
  process.exit(1);
102
- return true;
103
67
  }
104
68
 
105
- t.step(`③ 选槽与 TCP 预检(拨号前先确认端口通不通,省掉 3×20s 的瞎等)`);
106
- const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: count });
107
- if (!picked.length) {
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) {
108
72
  t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
109
73
  console.log(`\n❌ 没有可用候选`);
110
74
  console.log(" 下一步:放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑");
111
75
  console.log(` 完整 transcript:${t.file}`);
112
76
  process.exit(1);
113
- return true;
114
77
  }
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++;
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);
123
89
  }
124
- if (!reachable) t.say("全部候选 TCP 都不通 ⇒ 多半是本机到公网节点被限制(不是本模块的配置问题),下面仍会真拨一次以留证据");
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 ? "(取自上面探通的)" : "(没有探通的 ⇒ 按取样顺序强拨,只为留证据)"}`);
125
93
 
126
94
  t.step("④ 基线 + 逐槽起隧道取证");
127
95
  const onProgress = (ev) => {
@@ -133,13 +101,12 @@ export async function runTest(args = []) {
133
101
  };
134
102
  let r;
135
103
  try {
136
- r = await runMultiIpCompare({ count, ...(model ? { model } : {}), onProgress, deps: { log: (m) => t.say(m) } });
104
+ r = await runMultiIpCompare({ count: dialRows.length || count, slots: dialRows, ...(model ? { model } : {}), onProgress, deps: { log: (m) => t.say(m) } });
137
105
  } catch (e) {
138
106
  t.say(`测试执行抛错:${String(e?.stack || e?.message || e)}`);
139
107
  console.log(`\n❌ 测试执行出错:${e?.message ?? e}`);
140
108
  console.log(` 完整 transcript:${t.file}`);
141
109
  process.exit(1);
142
- return true;
143
110
  }
144
111
  if (r.status === "skip") {
145
112
  t.say(`跳过:${r.reason}`);
@@ -147,7 +114,6 @@ export async function runTest(args = []) {
147
114
  console.log(` 下一步:${r.hint}`);
148
115
  console.log(` 完整 transcript:${t.file}`);
149
116
  process.exit(1);
150
- return true;
151
117
  }
152
118
 
153
119
  t.step("⑤ 判语");
@@ -167,33 +133,4 @@ export async function runTest(args = []) {
167
133
  console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
168
134
  console.log(` 重试:mslxdff ${FLAG} test --count ${r.requested}${model ? ` --model ${model}` : ""}`);
169
135
  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
- }
136
+ }
@@ -1,4 +1,4 @@
1
- // mslxdff -multi-ip-pool [test|status|fetch|help]
1
+ // mslxdff -multi-ip-pool [test|probe|status|fetch|help]
2
2
  // 多出口池(VPNGate 免费 OpenVPN 节点当出口):**组长**专属,只服务 opencode 免费层。
3
3
  //
4
4
  // 本文件只做「分发 + status + 用法」;test / fetch 两条取证流程在 ./multi-ip-pool-test.js
@@ -6,7 +6,8 @@
6
6
  import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
7
7
  import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
8
8
  import { loadGroupsJoined } from "../../state.js";
9
- import { runFetch, runTest } from "./multi-ip-pool-test.js";
9
+ import { runFetch, runProbe } from "./multi-ip-pool-fetch.js";
10
+ import { runTest } from "./multi-ip-pool-test.js";
10
11
 
11
12
  const FLAG = "-multi-ip-pool";
12
13
 
@@ -16,6 +17,7 @@ export async function handleMultiIpPool(args = []) {
16
17
  const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
17
18
  if (["-h", "--help", "help"].includes(sub)) return printUsage();
18
19
  if (sub === "status") return await printStatus(args);
20
+ if (sub === "probe" || sub === "ping") return await runProbe(args);
19
21
  if (sub === "fetch" || sub === "grab") return await runFetch(args);
20
22
  return await runTest(args);
21
23
  }
@@ -43,12 +45,13 @@ async function printStatus(args = []) {
43
45
  console.log(` 节点源 ${cfg.nodesFile ? `本地表优先:${cfg.nodesFile}(其后 ${cfg.sources.length - 1} 个网络源)` : cfg.sources.join(",")}`);
44
46
  console.log("");
45
47
  console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
48
+ console.log(` 测网络可达性(不要 root):mslxdff ${FLAG} probe [--probe N]`);
46
49
  process.exit(0);
47
50
  return true;
48
51
  }
49
52
 
50
53
  function printUsage() {
51
- 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 <路径>]`);
52
55
  console.log("");
53
56
  console.log(" test 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
54
57
  console.log(" 对照判语:");
@@ -56,6 +59,9 @@ function printUsage() {
56
59
  console.log(" 基线 429 + VPN 出口也全 429 ⇒ 换 IP 抬不动(上游不按出口 IP 计额)");
57
60
  console.log(" test 每一步都编号打印并整份落盘(结尾给出 transcript 路径),失败即停在原地。");
58
61
  console.log("");
62
+ console.log(" probe 只回答「这台机能连上表里几个节点」(按簇轮转取样 + 限并发 TCP 预检):不拨号、不要 root、");
63
+ console.log(" 不碰 openvpn/内核、不看角色门,任何平台都能跑 —— 池值不值得继续用的先行问题。");
64
+ console.log("");
59
65
  console.log(" 前置:Linux + root + openvpn(tun 设备与策略路由必需)。非 Linux 如实跳过。");
60
66
  console.log("");
61
67
  console.log("相关 env(与 daemon 建池同源):");
@@ -30,6 +30,7 @@ export const DEFAULTS = {
30
30
  cooldownMs: 600_000, // 槽失败冷却
31
31
  rotate: true, // 撞额度信号时换槽
32
32
  nodesTtlMs: 30 * 60_000, // 节点表缓存有效期(VPNGate 免费节点掉得快)
33
+ maxTableAgeMs: 24 * 60 * 60_000, // 表龄告警阈值:镜像表超过它就 ⚠(旧表 = 一堆早下线的 IP,白拨几分钟)
33
34
  openvpnBin: "openvpn",
34
35
  authUser: "vpn", // VPNGate 公共账号(非私人凭据)
35
36
  authPass: "vpn",
@@ -38,16 +39,16 @@ export const DEFAULTS = {
38
39
  };
39
40
 
40
41
  /**
41
- * VPNGate 节点源:**官方永远在前**,第三方镜像只作兜墙的最后一条。
42
- * 镜像 `aimili-vpngate` 仓库内 `mirror/vpngate.csv` 由它的 GitHub Actions **每 15 分钟**从官方表刷新
43
- * (cron `7,22,37,52 * * * *`),实测 2026-10-25 与官方**同 15 列同 schema**、99 节点全部可解码 ⇒ 解析器零改动。
44
- * 注意:它自己配的那条 GitHub Pages 镜像 `baoweise-bot.github.io/...` 已 404 死(实测),故不采用。
45
- * 表内容只是**公开节点的连接配置 + VPNGate 公共账号(vpn/vpn)**,不含任何私人凭据;仍不信任镜像就用
46
- * `MSLXDFF_MULTI_IP_SOURCES` 只留官方,或 `MSLXDFF_MULTI_IP_NODES_FILE` 自带一份本地表。
42
+ * VPNGate 节点源:**官方永远在前**,第三方镜像只作兜墙。
43
+ * 纠错(2026-10-26 实测):`aimili-vpngate` 的 Actions 只把新鲜快照发到 **GitHub Pages**(`openmili.github.io/aimili-vpngate/`),
44
+ * **不往仓库里写表**;仓库内 `mirror/vpngate.csv` 是手工提交的死快照(2026-08-26 后未变)——此前当「每 15 分钟刷新」用 =
45
+ * 拿两个月前的死 IP 白拨,真机症状与被墙一模一样。
46
+ * 表只是公开节点配置 + VPNGate 公共账号(vpn/vpn);不信任镜像就 `_SOURCES` 只留官方或 `_NODES_FILE` 自带。
47
47
  */
48
48
  export const NODE_SOURCES = [
49
49
  "https://www.vpngate.net/api/iphone/",
50
50
  "http://www.vpngate.net/api/iphone/",
51
+ "https://openmili.github.io/aimili-vpngate/vpngate.csv",
51
52
  "https://raw.githubusercontent.com/OpenMili/aimili-vpngate/main/mirror/vpngate.csv",
52
53
  ];
53
54
 
@@ -124,6 +125,7 @@ export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } =
124
125
  cooldownMs: envInt(env, "MSLXDFF_MULTI_IP_COOLDOWN_MS", DEFAULTS.cooldownMs),
125
126
  rotate: envBool(env, "MSLXDFF_MULTI_IP_ROTATE", DEFAULTS.rotate),
126
127
  nodesTtlMs: envInt(env, "MSLXDFF_MULTI_IP_NODES_TTL_MS", DEFAULTS.nodesTtlMs),
128
+ maxTableAgeMs: envInt(env, "MSLXDFF_MULTI_IP_MAX_TABLE_AGE_MS", DEFAULTS.maxTableAgeMs),
127
129
  openvpnBin: envStr(env, "MSLXDFF_MULTI_IP_OPENVPN", DEFAULTS.openvpnBin),
128
130
  authUser: envStr(env, "MSLXDFF_MULTI_IP_AUTH_USER", DEFAULTS.authUser),
129
131
  authPass: envStr(env, "MSLXDFF_MULTI_IP_AUTH_PASS", DEFAULTS.authPass),
@@ -7,6 +7,7 @@ import { mkdirSync, appendFileSync } from "node:fs";
7
7
  import { dirname, join } from "node:path";
8
8
  import net from "node:net";
9
9
  import { devNameFor, tableFor } from "./config.js";
10
+ import { clusterOf } from "./nodes.js";
10
11
 
11
12
  /** 值得单独成行的报错关键词(openvpn 的用法/握手/认证/网络失败都在这里)。 */
12
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;
@@ -56,6 +57,52 @@ export function probeTcp({ host, port, timeoutMs = 6_000, connectFn } = {}) {
56
57
  });
57
58
  }
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
+
59
106
  /**
60
107
  * 取证全程 transcript:每行带 `[步骤号]` 与相对耗时,同时进屏幕与文件(0600)。
61
108
  * 落盘是为了「一次跑完贴一个文件给我」,不必复现;写文件失败绝不影响测试本体。
@@ -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 = [];
@@ -86,11 +86,13 @@ export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () =>
86
86
  }
87
87
  }
88
88
  if (!rows.length) {
89
- const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
89
+ const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, 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
  }
@@ -48,11 +48,11 @@ export function sourceToLocalPath(url) {
48
48
  }
49
49
 
50
50
  /**
51
- * 逐源尝试取表;任一源返回可解析的 CSV 即成功(并顺手写缓存)。
51
+ * 逐源尝试取表;任一源返回可解析的 CSV 即成功(顺手写缓存 + 问一句表龄:`maxAgeMs`)。
52
52
  * 全失败时**逐源原因都进异常**(受限网络下「为什么取不到」就是用户唯一的线索)。
53
- * @returns {Promise<{ source: string, rows: object[], text: string }>} text 是原始 CSV,供 `fetchNodesToFile` 落盘
53
+ * @returns {Promise<{ source: string, rows: object[], text: string, meta: object|null, stale: boolean }>} text 供 `fetchNodesToFile` 落盘
54
54
  */
55
- export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), log = () => {} } = {}) {
55
+ export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), maxAgeMs = 0, log = () => {} } = {}) {
56
56
  const f = fetchImpl || getUndici().fetch || compatFetch;
57
57
  const fails = [];
58
58
  for (const url of sources) {
@@ -68,9 +68,9 @@ export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs
68
68
  }
69
69
  const rows = parseVpnGateCsv(text);
70
70
  if (!rows.length) throw new Error("返回体不是可解析的 VPNGate CSV");
71
- log(`[multi-ip-pool] 节点表取自 ${url}(${rows.length} 个节点)`);
72
- if (dir) saveNodeCache({ dir, source: url, rows, now });
73
- return { source: url, rows, text };
71
+ const meta = await fetchTableMeta({ source: url, fetchImpl: f, timeoutMs: Math.min(5_000, timeoutMs), now });
72
+ const stale = noteTable({ url, rows, meta, maxAgeMs, dir, now, log });
73
+ return { source: url, rows, text, meta, stale };
74
74
  } catch (e) {
75
75
  const why = errorWhy(e);
76
76
  fails.push(`${url}:${why}`);
@@ -84,8 +84,8 @@ export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs
84
84
  * 取表并原样落一份 CSV(`-multi-ip-pool fetch` 的实现):在能连的机器上抓,scp 进受限网络的组长机,
85
85
  * 那边 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可零网络取表。0600:表里是节点凭据,不该给同机用户读。
86
86
  */
87
- export async function fetchNodesToFile({ sources = [], out, fetchImpl, timeoutMs = 20_000, log } = {}) {
88
- const got = await fetchNodes({ sources, fetchImpl, timeoutMs, log });
87
+ export async function fetchNodesToFile({ sources = [], out, fetchImpl, timeoutMs = 20_000, maxAgeMs = 0, log } = {}) {
88
+ const got = await fetchNodes({ sources, fetchImpl, timeoutMs, maxAgeMs, log });
89
89
  mkdirSync(dirname(out), { recursive: true });
90
90
  writeFileSync(out, got.text, { mode: 0o600 });
91
91
  return { ...got, out, file: out };
@@ -108,3 +108,53 @@ export async function probeExitIp({ agent, fetchImpl, timeoutMs = 12_000 } = {})
108
108
  return { ok: false, ip: "", ms: Date.now() - t0, error: errorWhy(e) };
109
109
  }
110
110
  }
111
+
112
+ /** 镜像表的同源元数据文件名(上游 `build_vpngate_mirror.py` 固定产出,含 `generated_at_iso`)。 */
113
+ const TABLE_META_NAME = "vpngate.meta.json";
114
+
115
+ /** 表 URL → 同目录元数据 URL:只认 `.../vpngate.csv` 这类镜像表(官方 `api/iphone/`、本地表文件都没有 meta)。 */
116
+ export function metaUrlFor(url) {
117
+ const m = /^(https?:\/\/\S*\/)vpngate\.csv$/i.exec(String(url || "").trim());
118
+ return m ? `${m[1]}${TABLE_META_NAME}` : "";
119
+ }
120
+
121
+ /** 表龄的人话(分钟/小时/天)。 */
122
+ export function humanAge(ms) {
123
+ const mins = Math.round(Number(ms) / 60_000);
124
+ if (!Number.isFinite(mins) || mins < 0) return "未知";
125
+ if (mins < 60) return `${mins} 分钟`;
126
+ const hours = mins / 60;
127
+ return hours < 48 ? `${hours.toFixed(1)} 小时` : `${Math.round(hours / 24)} 天`;
128
+ }
129
+
130
+ /**
131
+ * 问一句「这份表是什么时候生成的」——镜像源长得一模一样(同样 15 列、同样能解析),
132
+ * 但可能是**几个月前的手工快照**:真机 2026-10-26 实测,参照实现仓库内那份 2026-08-26 生成、
133
+ * 而它的活表在 GitHub Pages(2026-10-24 生成)⇒ 拿旧表就是拿一堆早已下线的 IP 白拨几分钟,
134
+ * 症状与被墙完全一样。拿不到 meta(官方源/本地表/网络失败)就返回 null,绝不猜。
135
+ * @returns {Promise<{ ageMs: number, generatedAtIso: string } | null>}
136
+ */
137
+ export async function fetchTableMeta({ source = "", fetchImpl, timeoutMs = 5_000, now = () => Date.now() } = {}) {
138
+ const url = metaUrlFor(source);
139
+ if (!url) return null;
140
+ try {
141
+ const f = fetchImpl || getUndici().fetch || compatFetch;
142
+ const res = await f(url, { signal: timeoutSignal(timeoutMs) });
143
+ if (!res.ok) return null;
144
+ const meta = JSON.parse(await res.text());
145
+ const at = Number(meta?.generated_at) * 1000 || Date.parse(String(meta?.generated_at_iso || ""));
146
+ if (!Number.isFinite(at) || at <= 0) return null;
147
+ return { ageMs: Math.max(0, now() - at), generatedAtIso: String(meta.generated_at_iso || new Date(at).toISOString()) };
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ /** 取到表后的一次性记账:打「表龄」行、过期就 ⚠、按需写缓存,返回是否过期。 */
154
+ function noteTable({ url = "", rows = [], meta = null, maxAgeMs = 0, dir = "", now = () => Date.now(), log = () => {} } = {}) {
155
+ const stale = Boolean(meta && maxAgeMs > 0 && meta.ageMs > maxAgeMs);
156
+ log(`[multi-ip-pool] 节点表取自 ${url}(${rows.length} 个节点${meta ? `,${humanAge(meta.ageMs)}前生成` : ""})${stale ? " ⚠ 表已过期" : ""}`);
157
+ if (stale) log(`[multi-ip-pool] ⚠ 这张表已 ${humanAge(meta.ageMs)}没更新 ⇒ 节点多半早已下线(免费节点掉得快),拿它拨号就是白等几分钟:换源 MSLXDFF_MULTI_IP_SOURCES,或自带表 MSLXDFF_MULTI_IP_NODES_FILE`);
158
+ if (dir) saveNodeCache({ dir, source: url, rows, now });
159
+ return stale;
160
+ }
@@ -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,15 +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
- // proto 一并带出:预检与判语要分清「TCP 节点连不上」与「UDP 节点无握手可探」两回事
144
- return { ...row, conf: written.path, host: remote.host, port: remote.port, proto: (/^\s*proto\s+(\S+)/m.exec(conf) || [])[1] || "" };
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 };
145
199
  }
146
200
 
147
201
  /** VPNGate 公共账号(vpn/vpn);落在 0600 文件里,绝不入库。 */
@@ -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 || (() => {});
@@ -62,7 +62,7 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
62
62
  let source = cache?.source || "(本地缓存)";
63
63
  if (!rows.length) {
64
64
  try {
65
- const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
65
+ const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log });
66
66
  rows = got.rows;
67
67
  source = got.source;
68
68
  } catch (e) {
@@ -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 真机三条槽全挂在这。