mslxdff 0.1.240 → 0.1.241
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
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
//
|
|
4
4
|
// 为什么需要这条命令(用户要的"取证"):换出口到底有没有换来新额度,只有实测能回答。
|
|
5
5
|
// 本命令做 A/B:直连打一条免费层请求取基线 → 逐条 VPN 隧道打**同一条**请求 → 对照判语。
|
|
6
|
+
import { join } from "node:path";
|
|
6
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";
|
|
7
9
|
import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
|
|
8
10
|
import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
|
|
9
11
|
import { loadGroupsJoined } from "../../state.js";
|
|
@@ -34,6 +36,7 @@ export async function handleMultiIpPool(args = []) {
|
|
|
34
36
|
const sub = (args[at].includes("=") ? args[at].split("=")[1] : args[at + 1] || "test").toLowerCase();
|
|
35
37
|
if (["-h", "--help", "help"].includes(sub)) return printUsage();
|
|
36
38
|
if (sub === "status") return await printStatus(args);
|
|
39
|
+
if (sub === "fetch" || sub === "grab") return await runFetch(args);
|
|
37
40
|
return await runTest(args);
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -88,6 +91,34 @@ async function runTest(args) {
|
|
|
88
91
|
process.exit(r.verdict === "multi-ip-works" || r.verdict === "mixed" ? 0 : 1);
|
|
89
92
|
}
|
|
90
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
|
+
|
|
91
122
|
async function printStatus(args) {
|
|
92
123
|
const cfg = resolveMultiIpConfig(process.env);
|
|
93
124
|
const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
|
|
@@ -104,9 +135,10 @@ async function printStatus(args) {
|
|
|
104
135
|
console.log(` 权限门 ${root ? "通过(root)" : "不通过(需要 root 才能建 tun 与策略路由)"}`);
|
|
105
136
|
console.log(` openvpn ${openvpn.ok ? `可用(${openvpn.version})` : `不可用(${openvpn.why})`}`);
|
|
106
137
|
console.log(` 隧道数 ${cfg.count}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|Score ≥ ${cfg.minScore}|表号 ${cfg.baseTable}+`);
|
|
107
|
-
console.log(` 备选池 ${cfg.standby}
|
|
138
|
+
console.log(` 备选池 配 ${cfg.standby} 条(每 ${Math.round((cfg.standbyEveryMs || 0) / 60_000)} 分钟重拨一批;入池标准是 hi 回 200,不是"隧道起来了")`);
|
|
139
|
+
console.log(` 实际就绪几条**只有 daemon 知道**(本命令不建池、不拨号)→ 看 mslxdff -log 里的 [multi-ip-pool] 备选池 行`);
|
|
108
140
|
console.log(` 目录 ${cfg.dir}`);
|
|
109
|
-
console.log(` 节点源 ${cfg.sources.join(",")}`);
|
|
141
|
+
console.log(` 节点源 ${cfg.nodesFile ? `本地表优先:${cfg.nodesFile}(其后 ${cfg.sources.length - 1} 个网络源)` : cfg.sources.join(",")}`);
|
|
110
142
|
console.log("");
|
|
111
143
|
console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
|
|
112
144
|
process.exit(0);
|
|
@@ -114,7 +146,7 @@ async function printStatus(args) {
|
|
|
114
146
|
}
|
|
115
147
|
|
|
116
148
|
function printUsage() {
|
|
117
|
-
console.log(`用法: mslxdff ${FLAG} [test|status] [--count N] [--model <id>]`);
|
|
149
|
+
console.log(`用法: mslxdff ${FLAG} [test|status|fetch|help] [--count N] [--model <id>] [--out <路径>]`);
|
|
118
150
|
console.log("");
|
|
119
151
|
console.log(" 在多出口池上做 A/B:直连打一条 free 层请求取基线,再逐条 VPN 隧道打同一条,");
|
|
120
152
|
console.log(" 对照判语:");
|
|
@@ -129,7 +161,8 @@ function printUsage() {
|
|
|
129
161
|
console.log(" MSLXDFF_MULTI_IP_COUNT=N 并行隧道数(缺省 2)");
|
|
130
162
|
console.log(" MSLXDFF_MULTI_IP_COUNTRY=JP,KR 国家白名单(缺省不限)");
|
|
131
163
|
console.log(` MSLXDFF_MULTI_IP_MIN_SCORE=N 节点 Score 下限(缺省 1000000)`);
|
|
132
|
-
console.log(" MSLXDFF_MULTI_IP_SOURCES=url,url 节点表来源(缺省 VPNGate 官方 https+http
|
|
164
|
+
console.log(" MSLXDFF_MULTI_IP_SOURCES=url,url 节点表来源(缺省 VPNGate 官方 https+http,末位挂 aimili 仓库内镜像兜墙)");
|
|
165
|
+
console.log(" MSLXDFF_MULTI_IP_NODES_FILE=<路径> 本地表文件(排在最前,设了就不打网;配合 -multi-ip-pool fetch 生成)");
|
|
133
166
|
console.log(" MSLXDFF_MULTI_IP_BASE_TABLE=N 策略路由表起始号(缺省 210)");
|
|
134
167
|
console.log(" MSLXDFF_MULTI_IP_SYSCTL=0|1 是否放宽 rp_filter(缺省 1)");
|
|
135
168
|
console.log(" MSLXDFF_MULTI_IP_STANDBY=N 备选出口条数(缺省 5,**上限 5**,0=关)");
|
|
@@ -37,10 +37,18 @@ export const DEFAULTS = {
|
|
|
37
37
|
sysctl: true, // 放宽 rp_filter(严格反向路径过滤会让隧道"活着但请求挂死")
|
|
38
38
|
};
|
|
39
39
|
|
|
40
|
-
/**
|
|
40
|
+
/**
|
|
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` 自带一份本地表。
|
|
47
|
+
*/
|
|
41
48
|
export const NODE_SOURCES = [
|
|
42
49
|
"https://www.vpngate.net/api/iphone/",
|
|
43
50
|
"http://www.vpngate.net/api/iphone/",
|
|
51
|
+
"https://raw.githubusercontent.com/OpenMili/aimili-vpngate/main/mirror/vpngate.csv",
|
|
44
52
|
];
|
|
45
53
|
|
|
46
54
|
const OFF = ["0", "off", "false", "no", "disable", "disabled"];
|
|
@@ -92,9 +100,12 @@ export function tableFor(baseTable, i) {
|
|
|
92
100
|
*/
|
|
93
101
|
export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } = {}) {
|
|
94
102
|
const modeRaw = envStr(env, "MSLXDFF_MULTI_IP_MODE", DEFAULTS.mode).toLowerCase();
|
|
95
|
-
|
|
103
|
+
// 受限网络兜墙:`MSLXDFF_MULTI_IP_NODES_FILE` 指的本地表**排在最前**(设了就一次网络都不打)。
|
|
104
|
+
const nodesFile = envStr(env, "MSLXDFF_MULTI_IP_NODES_FILE", "");
|
|
105
|
+
const baseSources = envList(env, "MSLXDFF_MULTI_IP_SOURCES").length
|
|
96
106
|
? envList(env, "MSLXDFF_MULTI_IP_SOURCES")
|
|
97
107
|
: NODE_SOURCES;
|
|
108
|
+
const sources = nodesFile ? [nodesFile, ...baseSources.filter((s) => s !== nodesFile)] : baseSources;
|
|
98
109
|
return {
|
|
99
110
|
enabled: envBool(env, "MSLXDFF_MULTI_IP_POOL", DEFAULTS.enabled),
|
|
100
111
|
mode: MODES.includes(modeRaw) ? modeRaw : DEFAULTS.mode,
|
|
@@ -118,6 +129,7 @@ export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } =
|
|
|
118
129
|
authPass: envStr(env, "MSLXDFF_MULTI_IP_AUTH_PASS", DEFAULTS.authPass),
|
|
119
130
|
dir: envStr(env, "MSLXDFF_MULTI_IP_DIR", join(logDirFn(), "multi-ip-pool")),
|
|
120
131
|
sources,
|
|
132
|
+
nodesFile,
|
|
121
133
|
debug: envBool(env, "MSLXDFF_MULTI_IP_DEBUG", DEFAULTS.debug),
|
|
122
134
|
sysctl: envBool(env, "MSLXDFF_MULTI_IP_SYSCTL", DEFAULTS.sysctl),
|
|
123
135
|
};
|
|
@@ -8,7 +8,8 @@
|
|
|
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,
|
|
11
|
+
import { candidateNodes, loadNodeCache, materializeNode } from "./nodes.js";
|
|
12
|
+
import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
|
|
12
13
|
import { bringUpTunnel, tearDownTunnel } from "./tunnel.js";
|
|
13
14
|
import { AGENT_OPTIONS } from "./dispatch.js";
|
|
14
15
|
import { fmtExitIpFail, fmtHi, fmtNodePick, fmtNodes } from "./report.js";
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// multi-ip-pool · 节点表与出口的**网络侧**:取表(多源回退 + 本地表文件)/ 出口 IP 取证 / fetch 失败归因。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一份:出口池的成败在受限网络里完全取决于「表取不取得到」,这一层的诊断信息
|
|
4
|
+
// (DNS 污染 / 连接被掐 / 超时)必须与解析层分开演进。纯解析与落盘在 ./nodes.js,本文件只碰网络。
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { compatFetch, getUndici, timeoutSignal } from "../compat.js";
|
|
9
|
+
import { EXIT_PROBE_URL, parseExitIp } from "../warp-pool/revive.js";
|
|
10
|
+
import { parseVpnGateCsv, saveNodeCache } from "./nodes.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* fetch 失败的人话原因:undici 一律只回 `fetch failed`,真凶在 `e.cause`。
|
|
14
|
+
* 这个区分是**功能性的**:受限网络下 ENOTFOUND(DNS 污染)与 ECONNRESET(连接被掐)
|
|
15
|
+
* 与超时(IP 被墙)意味着完全不同的对策,糊成一句「fetch failed」等于没诊断。
|
|
16
|
+
*/
|
|
17
|
+
export function describeFetchError(e) {
|
|
18
|
+
const c = e?.cause || {};
|
|
19
|
+
const code = String(c.code || c.errno || "");
|
|
20
|
+
const msg = String(c.message || e?.message || "");
|
|
21
|
+
if (code === "ENOTFOUND") return "DNS 解析失败 ENOTFOUND(域名可能被污染或不存在)";
|
|
22
|
+
if (code === "EAI_AGAIN") return "DNS 暂时解析不了 EAI_AGAIN(当前网络解析不了这个域)";
|
|
23
|
+
if (code === "ECONNREFUSED") return "连接被拒 ECONNREFUSED(端口没开或服务已下线)";
|
|
24
|
+
if (code === "ECONNRESET" || code === "EPIPE") return "连接被重置 ECONNRESET(中途被掐断,常见于墙/防火墙)";
|
|
25
|
+
if (code === "ETIMEDOUT" || /timeout|timed out|deadline|abort/i.test(msg)) return "连接超时(IP 不可达或被静默丢包)";
|
|
26
|
+
if (/cert|self-signed|SSL|TLS|handshake/i.test(msg + code)) return `TLS 握手失败 ${code || msg}`.trim();
|
|
27
|
+
if (code) return `网络失败 ${code}`;
|
|
28
|
+
return (msg || String(e || "未知原因")).slice(0, 70);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 把任何异常归成人话(有 cause 走 fetch 归因,否则取 message)。 */
|
|
32
|
+
export function errorWhy(e) {
|
|
33
|
+
return e && e.cause ? describeFetchError(e) : String(e?.message || e);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 节点源 scheme 判定:`http(s)://` 走 fetch;`file://` 与裸路径都当**本地表文件**(受限网络「抓一份带过去」)。 */
|
|
37
|
+
export function sourceToLocalPath(url) {
|
|
38
|
+
const s = String(url || "").trim();
|
|
39
|
+
if (!s || /^https?:\/\//i.test(s)) return null;
|
|
40
|
+
if (/^file:\/\/\//i.test(s) || /^file:\/\//i.test(s)) {
|
|
41
|
+
try {
|
|
42
|
+
return fileURLToPath(s);
|
|
43
|
+
} catch {
|
|
44
|
+
return s.replace(/^file:\/\/\/?/i, "");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 逐源尝试取表;任一源返回可解析的 CSV 即成功(并顺手写缓存)。
|
|
52
|
+
* 全失败时**逐源原因都进异常**(受限网络下「为什么取不到」就是用户唯一的线索)。
|
|
53
|
+
* @returns {Promise<{ source: string, rows: object[], text: string }>} text 是原始 CSV,供 `fetchNodesToFile` 落盘
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), log = () => {} } = {}) {
|
|
56
|
+
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
57
|
+
const fails = [];
|
|
58
|
+
for (const url of sources) {
|
|
59
|
+
try {
|
|
60
|
+
const local = sourceToLocalPath(url);
|
|
61
|
+
let text = "";
|
|
62
|
+
if (local !== null) {
|
|
63
|
+
text = readFileSync(local, "utf8");
|
|
64
|
+
} else {
|
|
65
|
+
const res = await f(url, { signal: timeoutSignal(timeoutMs) });
|
|
66
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
|
+
text = await res.text();
|
|
68
|
+
}
|
|
69
|
+
const rows = parseVpnGateCsv(text);
|
|
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 };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const why = errorWhy(e);
|
|
76
|
+
fails.push(`${url}:${why}`);
|
|
77
|
+
log(`[multi-ip-pool] 节点源失败 ${url}:${why}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new Error(fails.length ? `全部 ${fails.length} 个节点源都失败 → ${fails.join(" | ")}` : "没配任何节点源(MSLXDFF_MULTI_IP_SOURCES 为空)");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 取表并原样落一份 CSV(`-multi-ip-pool fetch` 的实现):在能连的机器上抓,scp 进受限网络的组长机,
|
|
85
|
+
* 那边 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可零网络取表。0600:表里是节点凭据,不该给同机用户读。
|
|
86
|
+
*/
|
|
87
|
+
export async function fetchNodesToFile({ sources = [], out, fetchImpl, timeoutMs = 20_000, log } = {}) {
|
|
88
|
+
const got = await fetchNodes({ sources, fetchImpl, timeoutMs, log });
|
|
89
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
90
|
+
writeFileSync(out, got.text, { mode: 0o600 });
|
|
91
|
+
return { ...got, out, file: out };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 出口取证:从某槽的 tun 地址出网,读回真实出口 IP(照 warp-pool/revive.js 的同源判据)。
|
|
96
|
+
* 这是「换 IP 到底换没换动」的直接证据——没有它,日志只能说"起了隧道",说不了"出口变了"。
|
|
97
|
+
* @returns {Promise<{ ok: boolean, ip: string, ms: number, error?: string }>}
|
|
98
|
+
*/
|
|
99
|
+
export async function probeExitIp({ agent, fetchImpl, timeoutMs = 12_000 } = {}) {
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
try {
|
|
102
|
+
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
103
|
+
const res = await f(EXIT_PROBE_URL, { ...(agent ? { dispatcher: agent } : {}), signal: timeoutSignal(timeoutMs) });
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
const ip = parseExitIp(text);
|
|
106
|
+
return { ok: Boolean(ip), ip, ms: Date.now() - t0, ...(ip ? {} : { error: `取证响应认不出出口 IP:${text.slice(0, 80)}` }) };
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { ok: false, ip: "", ms: Date.now() - t0, error: errorWhy(e) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -1,12 +1,10 @@
|
|
|
1
|
-
// multi-ip-pool · VPNGate
|
|
1
|
+
// multi-ip-pool · VPNGate 节点表(**纯解析与落盘**侧):解 CSV → 解 base64 配置 → 筛候选 → 落 conf/缓存。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// 为什么要拆开:出口 IP 的**来源**就是这些免费志愿者节点(参照实现 aimili-vpngate 用同一份 CSV),
|
|
4
|
+
// 但「取表」在受限网络里是独立的成败关口,它的诊断逻辑单独住在 ./nodes-fetch.js(fetch/归因/出口取证)。
|
|
5
5
|
// 纪律:候选的 conf / auth 一律 0600 落在**非工作树**目录(默认 logDir()/multi-ip-pool)。
|
|
6
6
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { join } from "node:path";
|
|
8
|
-
import { compatFetch, getUndici, timeoutSignal } from "../compat.js";
|
|
9
|
-
import { EXIT_PROBE_URL, parseExitIp } from "../warp-pool/revive.js";
|
|
10
8
|
|
|
11
9
|
/** CSV 列序(VPNGate `/api/iphone/`):末列是 base64 配置,其余为节点元数据。 */
|
|
12
10
|
export const VPNGATE_FIELDS = [
|
|
@@ -126,27 +124,6 @@ export function saveNodeCache({ dir, source = "", rows = [], now = () => Date.no
|
|
|
126
124
|
return payload;
|
|
127
125
|
}
|
|
128
126
|
|
|
129
|
-
/** 逐源尝试取表;任一源返回可解析的 CSV 即成功(并顺手写缓存)。 */
|
|
130
|
-
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), log = () => {} } = {}) {
|
|
131
|
-
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
132
|
-
let lastErr = null;
|
|
133
|
-
for (const url of sources) {
|
|
134
|
-
try {
|
|
135
|
-
const res = await f(url, { signal: timeoutSignal(timeoutMs) });
|
|
136
|
-
const text = await res.text();
|
|
137
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
138
|
-
const rows = parseVpnGateCsv(text);
|
|
139
|
-
if (!rows.length) throw new Error("返回体不是可解析的 VPNGate CSV");
|
|
140
|
-
log(`[multi-ip-pool] 节点表取自 ${url}(${rows.length} 个节点)`);
|
|
141
|
-
if (dir) saveNodeCache({ dir, source: url, rows, now });
|
|
142
|
-
return { source: url, rows };
|
|
143
|
-
} catch (e) {
|
|
144
|
-
lastErr = e;
|
|
145
|
-
log(`[multi-ip-pool] 节点源失败 ${url}:${String(e?.message || e)}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
throw new Error(`全部节点源都失败:${String(lastErr?.message || lastErr || "无源可试")}`);
|
|
149
|
-
}
|
|
150
127
|
|
|
151
128
|
/** 落一份 node conf(设备名/路由由 CLI 覆盖,不改原配置文本);返回文件路径。 */
|
|
152
129
|
export function writeNodeConf({ dir, row, name = "" } = {}) {
|
|
@@ -171,21 +148,3 @@ export function writeAuthFile({ dir, user = "vpn", pass = "vpn" } = {}) {
|
|
|
171
148
|
ensureDir(dir);
|
|
172
149
|
return writePrivate(join(dir, "auth.txt"), `${user}\n${pass}\n`);
|
|
173
150
|
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* 出口取证:从某槽的 tun 地址出网,读回真实出口 IP(照 warp-pool/revive.js 的同源判据)。
|
|
177
|
-
* 这是「换 IP 到底换没换动」的直接证据——没有它,日志只能说"起了隧道",说不了"出口变了"。
|
|
178
|
-
* @returns {Promise<{ ok: boolean, ip: string, ms: number, error?: string }>}
|
|
179
|
-
*/
|
|
180
|
-
export async function probeExitIp({ agent, fetchImpl, timeoutMs = 12_000 } = {}) {
|
|
181
|
-
const t0 = Date.now();
|
|
182
|
-
try {
|
|
183
|
-
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
184
|
-
const res = await f(EXIT_PROBE_URL, { ...(agent ? { dispatcher: agent } : {}), signal: timeoutSignal(timeoutMs) });
|
|
185
|
-
const text = await res.text();
|
|
186
|
-
const ip = parseExitIp(text);
|
|
187
|
-
return { ok: Boolean(ip), ip, ms: Date.now() - t0, ...(ip ? {} : { error: `取证响应认不出出口 IP:${text.slice(0, 80)}` }) };
|
|
188
|
-
} catch (e) {
|
|
189
|
-
return { ok: false, ip: "", ms: Date.now() - t0, error: String(e?.message || e) };
|
|
190
|
-
}
|
|
191
|
-
}
|
|
@@ -10,7 +10,8 @@ import { genId } from "../opencode-identity.js";
|
|
|
10
10
|
import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
|
|
11
11
|
import { classifyWarpSignal } from "../warp-pool/verdict.js";
|
|
12
12
|
import { resolveMultiIpConfig, resolveRole } from "./config.js";
|
|
13
|
-
import { candidateNodes,
|
|
13
|
+
import { candidateNodes, loadNodeCache, materializeNode } from "./nodes.js";
|
|
14
|
+
import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
|
|
14
15
|
import { bringUpTunnel, checkOpenvpn, isRoot, isSupportedPlatform, tearDownTunnel } from "./tunnel.js";
|
|
15
16
|
import { fmtExitIp, fmtNodePick, fmtNodes, fmtProbe } from "./report.js";
|
|
16
17
|
import { loadGroupsJoined } from "../state.js";
|
|
@@ -65,7 +66,7 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
|
|
|
65
66
|
rows = got.rows;
|
|
66
67
|
source = got.source;
|
|
67
68
|
} catch (e) {
|
|
68
|
-
return { status: "skip", reason: `节点表取不到:${String(e?.message || e)}`, hint: "
|
|
69
|
+
return { status: "skip", reason: `节点表取不到:${String(e?.message || e)}`, hint: "在能连的机器上跑 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,或设 MSLXDFF_MULTI_IP_NODES_FILE / _SOURCES" };
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
tick({ phase: "nodes", source, total: rows.length });
|