mslxdff 0.1.240 → 0.1.242
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli/commands/multi-ip-pool.js +38 -5
- package/src/multi-ip-pool/config.js +14 -2
- package/src/multi-ip-pool/dial.js +2 -1
- package/src/multi-ip-pool/nodes-fetch.js +110 -0
- package/src/multi-ip-pool/nodes.js +3 -44
- package/src/multi-ip-pool/selftest.js +7 -3
- package/src/multi-ip-pool/tunnel.js +19 -2
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";
|
|
@@ -23,7 +25,7 @@ function flagValue(args, ...names) {
|
|
|
23
25
|
|
|
24
26
|
const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
|
|
25
27
|
const line = (r) => {
|
|
26
|
-
if (r.error) return `✗ 不可达(${String(r.error).slice(0,
|
|
28
|
+
if (r.error) return `✗ 不可达(${String(r.error).replace(/\s+/g, " ").slice(0, 220)})`;
|
|
27
29
|
if (r.ok) return `✅ HTTP ${r.status} 有额度`;
|
|
28
30
|
return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
|
|
29
31
|
};
|
|
@@ -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, writeAuthFile } 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 });
|
|
@@ -78,6 +79,9 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
|
|
|
78
79
|
|
|
79
80
|
const picked = candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
|
|
80
81
|
if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
|
|
82
|
+
// auth 文件必须先落到磁盘再把路径交给 openvpn。取证路径以前把 `deps.authFile`(undefined)直接传下去
|
|
83
|
+
// ⇒ `--auth-user-pass` 拿到空参数 ⇒ openvpn 判「选项缺参数」,只留一句「Use --help」——2026-10-25 真机三条槽全挂在这。
|
|
84
|
+
const authFile = deps.authFile || (deps.writeAuthFile || writeAuthFile)({ dir: cfg.dir, user: cfg.authUser, pass: cfg.authPass });
|
|
81
85
|
|
|
82
86
|
const exits = [];
|
|
83
87
|
try {
|
|
@@ -90,7 +94,7 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
|
|
|
90
94
|
try {
|
|
91
95
|
const node = materialize({ dir: cfg.dir, row });
|
|
92
96
|
if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
|
|
93
|
-
slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile
|
|
97
|
+
slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
|
|
94
98
|
const exit = await exitProbe({ agent: agentFactory(slot.tunIp), timeoutMs: cfg.probeTimeoutMs });
|
|
95
99
|
if (exit.ok) log(fmtExitIp({ dev: slot.dev, ip: exit.ip, ms: exit.ms }));
|
|
96
100
|
// 从这条隧道的出口打**同一条**免费层请求
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// 平台纪律:只有 Linux 能干(tun + `ip rule`);其它平台由调用方**诚实跳过一行**,不做半吊子。
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
11
|
import { devNameFor, tableFor } from "./config.js";
|
|
12
|
-
import { tailReason } from "../warp-pool/revive.js";
|
|
13
12
|
import { fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.js";
|
|
14
13
|
|
|
15
14
|
/** openvpn 就绪标志(配置文件里可能出现别的 "Sequence",必须整串匹配)。 */
|
|
@@ -39,11 +38,18 @@ export function isRoot(getuid = process.getuid?.bind(process)) {
|
|
|
39
38
|
* openvpn 命令行。**dev 与路由一律走命令行覆盖**(命令行优先于 config 文件):
|
|
40
39
|
* `--route-nopull` + `--pull-filter ignore redirect-gateway` 保证它绝不碰主路由表——
|
|
41
40
|
* 主路由表被改 = 整台机器的流量(含 daemon 自己打上游)都被拽进隧道,那是灾难。
|
|
41
|
+
* `--dev-type tun` 不可省:槽设备名(`mltun1`…)**不以 tun/tap 开头**,openvpn 从名字推不出类型
|
|
42
|
+
* (其 usage 原文:dev-type「仅当 --dev 的设备名不以 tun 或 tap 开头时使用」);实测 97 个 VPNGate 节点全是 `dev tun`。
|
|
43
|
+
* 三个必填项缺一即抛:空串传下去会被 openvpn 判成「选项缺参数」,只留下一句没头没尾的 usage 错误(2026-10-25 真机踩过)。
|
|
42
44
|
*/
|
|
43
45
|
export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }) {
|
|
46
|
+
for (const [name, v] of [["conf", conf], ["dev", dev], ["authFile", authFile]]) {
|
|
47
|
+
if (!v) throw new Error(`buildOpenvpnArgs 缺 ${name}:拒绝起隧道(空参数会让 openvpn 只报一句 usage 错误,看不出病因)`);
|
|
48
|
+
}
|
|
44
49
|
return [
|
|
45
50
|
"--config", conf,
|
|
46
51
|
"--dev", dev,
|
|
52
|
+
"--dev-type", "tun",
|
|
47
53
|
"--auth-user-pass", authFile,
|
|
48
54
|
"--route-nopull",
|
|
49
55
|
"--pull-filter", "ignore", "redirect-gateway",
|
|
@@ -52,6 +58,17 @@ export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }
|
|
|
52
58
|
];
|
|
53
59
|
}
|
|
54
60
|
|
|
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
|
+
|
|
55
72
|
/** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
|
|
56
73
|
export function parseTunAddr(text) {
|
|
57
74
|
const m = /inet\s+(\d+\.\d+\.\d+\.\d+)/.exec(String(text || ""));
|
|
@@ -139,7 +156,7 @@ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, lo
|
|
|
139
156
|
if (tail.length > 20) tail.shift();
|
|
140
157
|
}
|
|
141
158
|
};
|
|
142
|
-
const lastTail = () =>
|
|
159
|
+
const lastTail = () => openvpnErrorLine(tail.join("\n"));
|
|
143
160
|
const ready = new Promise((resolve, reject) => {
|
|
144
161
|
const timer = setTimeout(() => {
|
|
145
162
|
if (settled) return;
|