mslxdff 0.1.245 → 0.1.246

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.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/chat-pipeline/terminal-verdict.js +4 -4
  3. package/src/cli/commands/daemon.js +1 -1
  4. package/src/cli/index.js +0 -5
  5. package/src/daemon.js +3 -3
  6. package/src/runtime/bootstrap.js +1 -1
  7. package/src/runtime/proc-info.js +2 -2
  8. package/src/runtime/providers-setup.js +2 -4
  9. package/src/server.js +3 -3
  10. package/src/upstream-engine/index.js +2 -17
  11. package/src/upstream.js +1 -61
  12. package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
  13. package/src/cli/commands/multi-ip-pool-test.js +0 -136
  14. package/src/cli/commands/multi-ip-pool.js +0 -83
  15. package/src/cli/commands/v6-pool.js +0 -123
  16. package/src/multi-ip-pool/config.js +0 -157
  17. package/src/multi-ip-pool/diagnose.js +0 -159
  18. package/src/multi-ip-pool/dial.js +0 -179
  19. package/src/multi-ip-pool/dispatch.js +0 -244
  20. package/src/multi-ip-pool/index.js +0 -174
  21. package/src/multi-ip-pool/nodes-fetch.js +0 -160
  22. package/src/multi-ip-pool/nodes.js +0 -205
  23. package/src/multi-ip-pool/report.js +0 -162
  24. package/src/multi-ip-pool/selftest.js +0 -168
  25. package/src/multi-ip-pool/standby.js +0 -157
  26. package/src/multi-ip-pool/tunnel.js +0 -291
  27. package/src/v6pool/claim.js +0 -157
  28. package/src/v6pool/config.js +0 -65
  29. package/src/v6pool/detect.js +0 -130
  30. package/src/v6pool/index.js +0 -157
  31. package/src/v6pool/prefix.js +0 -140
  32. package/src/v6pool/probe.js +0 -40
  33. package/src/v6pool/report.js +0 -104
  34. package/src/v6pool/rotate.js +0 -263
  35. package/src/v6pool/selftest.js +0 -178
  36. package/src/warp-pool/config.js +0 -74
  37. package/src/warp-pool/health.js +0 -106
  38. package/src/warp-pool/identity.js +0 -141
  39. package/src/warp-pool/index.js +0 -358
  40. package/src/warp-pool/keys.js +0 -29
  41. package/src/warp-pool/nodes.js +0 -95
  42. package/src/warp-pool/ports.js +0 -160
  43. package/src/warp-pool/register.js +0 -113
  44. package/src/warp-pool/report.js +0 -232
  45. package/src/warp-pool/revive.js +0 -168
  46. package/src/warp-pool/rotate.js +0 -214
  47. package/src/warp-pool/strategy.js +0 -43
  48. package/src/warp-pool/verdict.js +0 -69
  49. package/src/warp-pool/wireproxy.js +0 -162
@@ -1,168 +0,0 @@
1
- // multi-ip-pool · 出口额度对照测试:回答「这台机器的直连出口撞 429 时,换成 VPNGate 出口还有没有额度」。
2
- //
3
- // 这才是多出口池的**唯一价值判据**(opencode 免费层按**出口 IP** 计额,见 docs/domain-notes.md §5):
4
- // 基线(直连)429 而某个 VPN 出口 200 ⇒ 池有用;VPN 出口也全 429 ⇒ 换 IP 抬不动(上游按别的东西计额)。
5
- // 对照纪律:**除"从哪里出网"外一切相同** —— 同 URL / 同 UA / 同身份四件套 header / 同模型 /
6
- // 同 `stream:true` + 五核心工具名;整轮共用同一个 session(免费层按出口 IP 计额,共用 session 才测得干净)。
7
- // 请求形状**直接复用** `v6pool/selftest.js`(同一份免费层契约,绝不在两处各写一遍)。
8
- import { getUndici } from "../compat.js";
9
- import { genId } from "../opencode-identity.js";
10
- import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
11
- import { classifyWarpSignal } from "../warp-pool/verdict.js";
12
- import { resolveMultiIpConfig, resolveRole } from "./config.js";
13
- import { candidateNodes, loadNodeCache, materializeNode, writeAuthFile } from "./nodes.js";
14
- import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
15
- import { bringUpTunnel, checkOpenvpn, isRoot, isSupportedPlatform, tearDownTunnel } from "./tunnel.js";
16
- import { fmtExitIp, fmtNodePick, fmtNodes, fmtProbe } from "./report.js";
17
- import { loadGroupsJoined } from "../state.js";
18
-
19
- /** 采样上限:VPNGate 免费节点起隧道是秒级操作,别把测试拖成十几分钟。 */
20
- export const MITEST_MAX = 8;
21
- export const MITEST_DEFAULT = 3;
22
-
23
- /**
24
- * 跑一次出口对照测试(本模块不打印,结构化返回给 CLI 渲染)。
25
- *
26
- * @param {{ env?: object, count?: number, model?: string, baseUrl?: string,
27
- * onProgress?: (ev: object) => void,
28
- * deps?: { joined?, platform?, supported?, isRoot?, checkOpenvpn?, loadNodeCache?, fetchNodes?,
29
- * materializeNode?, bringUpTunnel?, tearDownTunnel?, agentFactory?, probeExitIp?,
30
- * probeUpstream?, fetchImpl?, session?, log? }, slots?: object[] }} opts
31
- */
32
- export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFAULT, model = DEFAULT_PROBE_MODEL, baseUrl = "https://opencode.ai", onProgress, deps = {}, slots = null } = {}) {
33
- const cfg = resolveMultiIpConfig(env);
34
- const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
35
- const log = deps.log || (() => {});
36
-
37
- if (!cfg.enabled) return { status: "skip", reason: "多出口池已关闭(MSLXDFF_MULTI_IP_POOL=0)", hint: "去掉该 env 或设为 1 再跑" };
38
- const role = resolveRole({ mode: cfg.mode, joined: deps.joined ?? loadGroupsJoined() });
39
- if (!role.active) return { status: "skip", reason: `本节点角色是 ${role.role}(${role.why})`, hint: "本功能只给组长做取证;组员请用组长那条出口" };
40
- const platform = deps.platform || process.platform;
41
- if (!(deps.supported ?? isSupportedPlatform(platform))) {
42
- return { status: "skip", reason: `本功能需要 Linux 的 tun 设备与策略路由(当前平台 ${platform})`, hint: "在 Linux VPS 上跑;其它平台用 mslxdff -v6-pool 或 WARP 池" };
43
- }
44
- if (!(deps.isRoot ?? isRoot)()) {
45
- return { status: "skip", reason: "需要 root(建 tun 设备 + 装策略路由 + 写 rp_filter)", hint: "sudo mslxdff -multi-ip-pool test" };
46
- }
47
- const openvpn = await (deps.checkOpenvpn || checkOpenvpn)(cfg.openvpnBin);
48
- if (!openvpn.ok) return { status: "skip", reason: `${cfg.openvpnBin} 不可用(${openvpn.why})`, hint: "apt install openvpn" };
49
-
50
- const { Agent } = getUndici();
51
- const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, connections: 1, keepAliveTimeout: 5_000 }));
52
- const probe = deps.probeUpstream || probeUpstreamFrom;
53
- const session = deps.session || genId("ses_");
54
- const materialize = deps.materializeNode || materializeNode;
55
- const startSlot = deps.bringUpTunnel || bringUpTunnel;
56
- const stopSlot = deps.tearDownTunnel || tearDownTunnel;
57
- const exitProbe = deps.probeExitIp || probeExitIp;
58
-
59
- // 节点表:优先用缓存(不重复打 VPNGate),取不到再拉。
60
- const cache = (deps.loadNodeCache || loadNodeCache)({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
61
- let rows = cache?.rows || [];
62
- let source = cache?.source || "(本地缓存)";
63
- if (!rows.length) {
64
- try {
65
- const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log });
66
- rows = got.rows;
67
- source = got.source;
68
- } catch (e) {
69
- return { status: "skip", reason: `节点表取不到:${String(e?.message || e)}`, hint: "在能连的机器上跑 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,或设 MSLXDFF_MULTI_IP_NODES_FILE / _SOURCES" };
70
- }
71
- }
72
- tick({ phase: "nodes", source, total: rows.length });
73
- log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
74
-
75
- // ① 基线:直连(系统默认出口)打一条免费层请求
76
- tick({ phase: "baseline-start", model });
77
- const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
78
- tick({ phase: "baseline-done", ...baseline });
79
- // 候选:调用方给了 `slots` 就用它——CLI 侧已经「按簇取样 + TCP 预检」过,拨的必须就是探通的那些;
80
- // 没给才自己按 Score 取(单测与旧路径)。两边各挑一次会让「探的」与「拨的」不是同一批,预检就白做了。
81
-
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)) });
83
- if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
84
- // auth 文件必须先落到磁盘再把路径交给 openvpn。取证路径以前把 `deps.authFile`(undefined)直接传下去
85
- // ⇒ `--auth-user-pass` 拿到空参数 ⇒ openvpn 判「选项缺参数」,只留一句「Use --help」——2026-10-25 真机三条槽全挂在这。
86
- const authFile = deps.authFile || (deps.writeAuthFile || writeAuthFile)({ dir: cfg.dir, user: cfg.authUser, pass: cfg.authPass });
87
-
88
- const exits = [];
89
- try {
90
- for (let i = 0; i < picked.length; i++) {
91
- const row = picked[i];
92
- const dev = `${cfg.devPrefix}${i}`;
93
- tick({ phase: "exit-start", i: i + 1, n: picked.length, row });
94
- log(fmtNodePick({ i: i + 1, n: picked.length, row, dev }));
95
- let slot = null;
96
- try {
97
- const node = materialize({ dir: cfg.dir, row });
98
- if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
99
- slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
100
- const exit = await exitProbe({ agent: agentFactory(slot.tunIp), timeoutMs: cfg.probeTimeoutMs });
101
- if (exit.ok) log(fmtExitIp({ dev: slot.dev, ip: exit.ip, ms: exit.ms }));
102
- // 从这条隧道的出口打**同一条**免费层请求
103
- const r = await probe({ addr: slot.tunIp, model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
104
- log(fmtProbe({ dev: slot.dev, url: `${baseUrl.replace(/\/+$/, "")}/zen/v1/chat/completions`, ok: r.ok, status: r.status, error: r.error, ms: r.ms }));
105
- const entry = { ...r, hostName: row.hostName, country: row.countryShort, dev: slot.dev, exitIp: exit.ip || "" };
106
- exits.push(entry);
107
- tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
108
- } catch (e) {
109
- const entry = { hostName: row.hostName, country: row.countryShort, dev, ok: false, status: 0, kind: "network", ms: 0, exitIp: "", error: String(e?.message || e) };
110
- exits.push(entry);
111
- tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
112
- } finally {
113
- if (slot) await stopSlot({ slot, runCmdFn: deps.runCmdFn, log }).catch(() => {});
114
- }
115
- }
116
- } catch (e) {
117
- return { status: "skip", reason: `测试中断:${String(e?.message || e)}`, hint: "看 daemon.log 里的 [multi-ip-pool] 行" };
118
- }
119
-
120
- // ③ 判语:对照基线(受限 = 有 HTTP 响应但拿不到额度;只有 status=0 才算"够不着")
121
- const baseLimited = baseline.status === 429 || ["free-quota", "free-tier", "ip-limit"].includes(baseline.kind);
122
- const ok = exits.filter((r) => r.ok);
123
- const limited = exits.filter((r) => !r.ok && r.status);
124
- const net = exits.filter((r) => !r.status);
125
- let verdict;
126
- if (ok.length && ok.length === exits.length) verdict = "multi-ip-works";
127
- else if (limited.length === exits.length) verdict = "multi-ip-also-limited";
128
- else if (net.length === exits.length) verdict = "multi-ip-unreachable";
129
- else verdict = "mixed";
130
-
131
- return {
132
- status: "ok",
133
- source, model, baseUrl, session,
134
- baseLimited,
135
- baseline,
136
- exits,
137
- requested: picked.length,
138
- okCount: ok.length,
139
- limitedCount: limited.length,
140
- netCount: net.length,
141
- verdict,
142
- };
143
- }
144
-
145
- /** 判语 → 人话(CLI 与测试共用一份,避免两处口径漂移)。 */
146
- export const VERDICT_ZH = {
147
- "multi-ip-works": {
148
- text: "VPN 出口**能换到新额度**",
149
- next: "结论:多出口池对这台机器**有用**——直连出口耗尽时换隧道就能续。daemon 侧缺省已开。",
150
- },
151
- "multi-ip-also-limited": {
152
- text: "VPN 出口**也全受限**(换 IP 没换来新额度)",
153
- next: "结论:换出口治不了(要么上游不按出口 IP 计额,要么节点出口本身已被大量共用到撞额)。建议 MSLXDFF_MULTI_IP_POOL=0。",
154
- },
155
- "multi-ip-unreachable": {
156
- text: "VPN 出口**全都够不着上游**(隧道没转起来)",
157
- next: "结论:这些节点起不通或上游被墙。查 rp_filter / 节点可用性,或换 MSLXDFF_MULTI_IP_SOURCES / 加国家白名单。",
158
- },
159
- mixed: {
160
- text: "部分可用(有的出口有额度、有的没有)",
161
- next: "结论:池**部分可用**(免费节点质量参差,属正常)。够用即可开着。",
162
- },
163
- };
164
-
165
- /** 判语里的免费层分类(与生产判决同源:warp-pool/verdict.js)。 */
166
- export function classifyOf(status, text = "") {
167
- return classifyWarpSignal({ status, text });
168
- }
@@ -1,157 +0,0 @@
1
- // multi-ip-pool · 备选出口池:**定时预拨** 不多于 N 条已验通的隧道,429 一到立刻有干净 IP 可上。
2
- //
3
- // 用户 2026-10-25 的裁决(本模块存在的唯一理由):
4
- // 「启用时定时准备不多于 5 个备选 IP(例如每小时找 5 个备用),一旦组长触发 429,
5
- // 可以**立即**启用其中的备用 IP;启用备用 IP 时要打一个 hi 到 opencode.ai,要它 200。」
6
- // ⇒ 换出口那一刻绝不能再现场拨号(openvpn 握手秒级 + 候选可能全不通,429 现场等 = 用户在等),
7
- // 而是**平时就把备用隧道起好、并逐条用 hi 验过有 200**,429 时只做"取一条"。
8
- //
9
- // 备用条目两条铁律:
10
- // ① 只有 hi 回 200 的才算"备用"(有额度的 IP),429/403 的当场拆掉重拨下一条;
11
- // ② 交付前**再验一次** hi——免费节点的出口 IP 是别人在用的,一小时里可能已经被别人吃光额度。
12
- // 验不过就丢掉取下一条(宁可少一条出口,也不把一个没额度的 IP 交给请求路径)。
13
- import { fmtStandbyAdd, fmtStandbyDrop, fmtStandbyRefill, fmtStandbySkip, fmtStandbyUse } from "./report.js";
14
-
15
- /** 备用槽的索引偏移:设备名/表号与正式槽彻底错开(正式槽 mltun0..、备用 mltun100..、表 baseTable+100..)。 */
16
- export const STANDBY_INDEX_BASE = 100;
17
- /** 备用槽索引循环上限(设备名 ≤15 字符:mltun + 3 位)。 */
18
- export const STANDBY_INDEX_SPAN = 100;
19
- /** 一轮补位最多拨多少次(防候选全不通时把 VPNGate 与本机刷成灾)。 */
20
- export const REFILL_DIAL_CAP = 12;
21
-
22
- /**
23
- * @param {{ cfg: object, log?: Function, dialer: object,
24
- * schedule?: (fn: Function, ms: number) => any, cancel?: (h: any) => void, now?: () => number }} opts
25
- * dialer 需要提供:openSlot(index) / ensureCandidates({force}) / hiProbe(entry) / drop(entry) / tunnels(Set)
26
- */
27
- export function createStandby({ cfg, log = () => {}, dialer, schedule = (fn, ms) => setInterval(fn, ms), cancel = (h) => clearInterval(h), now = Date.now } = {}) {
28
- /** @type {object[]} 已验通的备用出口(FIFO,最旧的先交出去) */
29
- const ready = [];
30
- let timer = null;
31
- let busy = null;
32
- let counter = 0;
33
-
34
- function want() {
35
- return Math.max(0, Math.min(5, Number(cfg.standby) || 0)); // 用户定的硬上限:不多于 5
36
- }
37
-
38
- function stats() {
39
- return {
40
- want: want(),
41
- have: ready.length,
42
- everyMs: Number(cfg.standbyEveryMs) || 0,
43
- exits: ready.map((e) => ({ dev: e.dev, node: e.node?.hostName || "", tunIp: e.tunIp, exitIp: e.exitIp || "", verifiedAt: e.verifiedAt || 0 })),
44
- };
45
- }
46
-
47
- /** 拨一条新备用隧道并验它(不 200 就地拆掉,调用方继续下一条)。 */
48
- async function dialOne() {
49
- const index = STANDBY_INDEX_BASE + (counter++ % STANDBY_INDEX_SPAN);
50
- const e = await dialer.openSlot(index, { dev: "" });
51
- if (!e) return null;
52
- e.verifiedAt = now();
53
- ready.push(e);
54
- log(fmtStandbyAdd({ dev: e.dev, node: e.node?.hostName || "", exitIp: e.exitIp || "", tunIp: e.tunIp, have: ready.length, want: want() }));
55
- return e;
56
- }
57
-
58
- /**
59
- * 补到 want() 条。同一时刻只跑一轮(定时器与 429 后的补位会撞车);每轮开头强制重取节点表——
60
- * 免费节点掉得快,一小时前的候选表多半已经拨不通了。
61
- * @returns {Promise<{added: number, have: number, want: number}>}
62
- */
63
- function refill({ force = false } = {}) {
64
- if (!want()) return Promise.resolve({ added: 0, have: 0, want: 0 });
65
- if (busy) return busy;
66
- const run = async () => {
67
- if (force || ready.length < want()) {
68
- try {
69
- await dialer.ensureCandidates({ force: force || ready.length === 0 });
70
- } catch (e) {
71
- log(fmtStandbySkip({ why: `取节点表失败:${String(e?.message || e)}` }));
72
- }
73
- }
74
- let dialed = 0;
75
- const before = ready.length;
76
- while (ready.length < want() && dialed < REFILL_DIAL_CAP) {
77
- dialed++;
78
- try {
79
- const got = await dialOne();
80
- if (!got) break; // 候选用尽(openSlot 内部已换过一轮)
81
- } catch (e) {
82
- log(fmtStandbySkip({ why: `拨备用隧道出错:${String(e?.message || e)}` }));
83
- }
84
- }
85
- log(fmtStandbyRefill({ added: ready.length - before, have: ready.length, want: want(), dialed }));
86
- return { added: ready.length - before, have: ready.length, want: want() };
87
- };
88
- busy = run().finally(() => {
89
- busy = null;
90
- });
91
- return busy;
92
- }
93
-
94
- /** 复验一条备用出口:交出去之前必须**这一秒**还是 200。 */
95
- async function verify(entry) {
96
- const p = await dialer.hiProbe(entry);
97
- if (p.status === 200) {
98
- entry.exitIp = p.ip || entry.exitIp || "";
99
- entry.verifiedAt = now();
100
- return entry;
101
- }
102
- log(fmtStandbyDrop({ dev: entry.dev, node: entry.node?.hostName || "", status: p.status, kind: p.kind, error: p.error || "" }));
103
- await dialer.drop(entry, "备用出口复验未过 200");
104
- return null;
105
- }
106
-
107
- /**
108
- * 取一条备用出口(429 路径调这个):逐条复验、验不过就丢;池空了先补一轮再取。
109
- * @returns {Promise<null | object>} 验通的出口条目(可直接当新槽用)
110
- */
111
- async function take() {
112
- for (let guard = 0; guard < 2 && !ready.length; guard++) await refill();
113
- while (ready.length) {
114
- const e = ready.shift();
115
- const okEntry = await verify(e);
116
- if (okEntry) {
117
- log(fmtStandbyUse({ dev: okEntry.dev, tunIp: okEntry.tunIp, exitIp: okEntry.exitIp || "", node: okEntry.node?.hostName || "", left: ready.length }));
118
- void refill().catch(() => {}); // 用完立刻补位,下一发 429 还得有备胎
119
- return okEntry;
120
- }
121
- }
122
- return null;
123
- }
124
-
125
- /** 起定时器(每小时刷新)。`MSLXDFF_MULTI_IP_STANDBY=0` ⇒ 完全不起。 */
126
- function start() {
127
- const w = want();
128
- const every = Number(cfg.standbyEveryMs) || 0;
129
- if (!w || !every || timer) return;
130
- timer = schedule(() => {
131
- void refreshAll().catch((e) => log(fmtStandbySkip({ why: `定时刷新失败:${String(e?.message || e)}` })));
132
- }, every);
133
- }
134
-
135
- /** 周期到点:把旧的备用全部拆掉重拨一批(出口 IP 换新,才叫"每小时找 5 个备用")。 */
136
- async function refreshAll() {
137
- const stale = ready.splice(0, ready.length);
138
- for (const e of stale) await dialer.drop(e, "备选池整批刷新");
139
- return refill({ force: true });
140
- }
141
-
142
- /** 收摊:停定时器 + 拆掉所有备用隧道(一条都不许留给下次启动当残留)。 */
143
- async function stop() {
144
- if (timer) {
145
- try {
146
- cancel(timer);
147
- } catch {
148
- /* 忽略 */
149
- }
150
- timer = null;
151
- }
152
- const all = ready.splice(0, ready.length);
153
- for (const e of all) await dialer.drop(e, "收摊");
154
- }
155
-
156
- return { refill, take, start, stop, refreshAll, stats, get size() { return ready.length; } };
157
- }
@@ -1,291 +0,0 @@
1
- // multi-ip-pool · 一槽一条 openvpn 子进程 + 一条独立策略路由表。
2
- //
3
- // 为什么这么装(照参照实现 aimili-vpngate 的必要动作,但这里每槽各一套):
4
- // openvpn 起来后内核多一个 tunX 设备,VPNGate 推给它的内网地址就是**本槽的源地址**;
5
- // 把「源地址 = <tunX 地址> 的包」整表引到「默认路由 = tunX」的策略表里,
6
- // 该槽的出站(含 undici 的 connect.localAddress 绑定)就只会从这条隧道出去。
7
- // ⇒ 一块网卡上 N 条隧道、N 个真实出口,互不干扰(参照实现一次只挂 1 条)。
8
- //
9
- // 平台纪律:只有 Linux 能干(tun + `ip rule`);其它平台由调用方**诚实跳过一行**,不做半吊子。
10
- import { spawn } from "node:child_process";
11
- import { devNameFor, tableFor } from "./config.js";
12
- import { fmtArgv, fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.js";
13
- import { openvpnErrorLine, openvpnErrorBrief } from "./diagnose.js";
14
-
15
- /** openvpn 就绪标志(配置文件里可能出现别的 "Sequence",必须整串匹配)。 */
16
- export const READY_PATTERN = /Initialization Sequence Completed/;
17
-
18
- /** 策略路由表号 → 规则优先级:落在 main(32766) 之前,且多槽之间固定可复现。 */
19
- export const RULE_PRIORITY_BASE = 20_000;
20
-
21
- export function rulePriorityFor(i) {
22
- return RULE_PRIORITY_BASE + i;
23
- }
24
-
25
- /** 只有 Linux 支持:tun 设备 + `ip rule`/`ip route`。 */
26
- export function isSupportedPlatform(platform = process.platform) {
27
- return platform === "linux";
28
- }
29
-
30
- export function isRoot(getuid = process.getuid?.bind(process)) {
31
- try {
32
- return typeof getuid === "function" ? getuid() === 0 : true; // 取不到 uid 的平台上由平台门先拦
33
- } catch {
34
- return true;
35
- }
36
- }
37
-
38
- /**
39
- * openvpn 命令行。**dev 与路由一律走命令行覆盖**(命令行优先于 config 文件):
40
- * `--route-nopull` + `--pull-filter ignore redirect-gateway` 保证它绝不碰主路由表——
41
- * 主路由表被改 = 整台机器的流量(含 daemon 自己打上游)都被拽进隧道,那是灾难。
42
- * `--dev-type tun` 不可省:槽设备名(`mltun1`…)**不以 tun/tap 开头**,openvpn 从名字推不出类型
43
- * (其 usage 原文:dev-type「仅当 --dev 的设备名不以 tun 或 tap 开头时使用」);实测 97 个 VPNGate 节点全是 `dev tun`。
44
- * 三个必填项缺一即抛:空串传下去会被 openvpn 判成「选项缺参数」,只留下一句没头没尾的 usage 错误(2026-10-25 真机踩过)。
45
- */
46
- export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }) {
47
- for (const [name, v] of [["conf", conf], ["dev", dev], ["authFile", authFile]]) {
48
- if (!v) throw new Error(`buildOpenvpnArgs 缺 ${name}:拒绝起隧道(空参数会让 openvpn 只报一句 usage 错误,看不出病因)`);
49
- }
50
- return [
51
- "--config", conf,
52
- "--dev", dev,
53
- "--dev-type", "tun",
54
- "--auth-user-pass", authFile,
55
- "--route-nopull",
56
- "--pull-filter", "ignore", "redirect-gateway",
57
- "--verb", String(verb),
58
- ...(logFile ? ["--log", logFile] : []),
59
- ];
60
- }
61
-
62
-
63
- /** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
64
- export function parseTunAddr(text) {
65
- const m = /inet\s+(\d+\.\d+\.\d+\.\d+)/.exec(String(text || ""));
66
- return m ? m[1] : "";
67
- }
68
-
69
- /** 该槽要装的策略路由(纯函数):源地址定表 → 表里默认走隧道。 */
70
- export function tunnelUpCommands({ tunIp, table, dev, priority }) {
71
- return [
72
- ["ip", "rule", "add", "from", `${tunIp}/32`, "lookup", String(table), "priority", String(priority)],
73
- ["ip", "route", "replace", "default", "dev", dev, "table", String(table)],
74
- ];
75
- }
76
-
77
- /** 拆槽时按相反顺序撤(先撤表、再撤规则)。 */
78
- export function tunnelDownCommands({ tunIp, table }) {
79
- return [
80
- ["ip", "route", "flush", "table", String(table)],
81
- ["ip", "rule", "del", "from", `${tunIp}/32`, "lookup", String(table)],
82
- ];
83
- }
84
-
85
- /** `ip rule` 的优先级和表号都可能是别人先占的——失败要能一眼看出「占了什么」。 */
86
- export function rpFilterPaths() {
87
- return [
88
- "/proc/sys/net/ipv4/conf/all/rp_filter",
89
- "/proc/sys/net/ipv4/conf/default/rp_filter",
90
- ];
91
- }
92
-
93
- /** 逐行跑命令;超时 SIGKILL(绝不让一个卡住的 ip 调用把启动挂死)。 */
94
- export function runCmd(cmd, args = [], { timeoutMs = 10_000, spawnFn = spawn } = {}) {
95
- return new Promise((resolve) => {
96
- let out = "";
97
- let err = "";
98
- let child;
99
- try {
100
- child = spawnFn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
101
- } catch (e) {
102
- resolve({ code: -1, stdout: "", stderr: String(e?.message || e) });
103
- return;
104
- }
105
- const timer = setTimeout(() => {
106
- try {
107
- child.kill("SIGKILL");
108
- } catch {
109
- /* 已经退了 */
110
- }
111
- }, timeoutMs);
112
- child.stdout?.on("data", (b) => { out += String(b); });
113
- child.stderr?.on("data", (b) => { err += String(b); });
114
- child.once("error", (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: `${err}${e.message}` }); });
115
- child.once("exit", (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stdout: out, stderr: err }); });
116
- });
117
- }
118
-
119
- /** 本机到底有没有 openvpn(没有就别装了,诚实跳过)。 */
120
- export async function checkOpenvpn(bin, { runCmdFn = runCmd } = {}) {
121
- const r = await runCmdFn(bin, ["--version"], { timeoutMs: 8_000 });
122
- const text = `${r.stdout}${r.stderr}`;
123
- if (r.code === 0 && /openvpn/i.test(text)) return { ok: true, version: (text.split(/\r?\n/)[0] || "").trim() };
124
- return { ok: false, why: r.stderr.trim() || `退出码 ${r.code}` };
125
- }
126
-
127
- /**
128
- * 起一条隧道并等它就绪。返回句柄;`ready` reject 表示这槽本轮不可用(调用方换下一个节点)。
129
- * tail 保留末 20 行:真机排障时"openvpn 为什么退"全在这几行里。
130
- */
131
- export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, log = () => {}, onExit = null, dev = "" } = {}) {
132
- // 「我到底跑了什么」必须是第一条信息:openvpn 的 argv 报错只有对着命令行原文才看得懂
133
- log(fmtArgv({ bin, args }));
134
- let child;
135
- try {
136
- child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
137
- } catch (e) {
138
- return { pid: 0, ready: Promise.reject(new Error(`拉不起 ${bin}:${String(e?.message || e)}`)), tail: () => "", stop: () => {} };
139
- }
140
- const tail = [];
141
- const t0 = Date.now();
142
- let settled = false;
143
- let stopping = false;
144
- const push = (buf) => {
145
- for (const line of String(buf).split(/\r?\n/)) {
146
- const s = line.trim();
147
- if (!s) continue;
148
- tail.push(s);
149
- if (tail.length > 20) tail.shift();
150
- }
151
- };
152
- const lastTail = () => openvpnErrorLine(tail.join("\n"));
153
- const briefTail = () => openvpnErrorBrief(tail.join("\n"));
154
- const ready = new Promise((resolve, reject) => {
155
- const timer = setTimeout(() => {
156
- if (settled) return;
157
- settled = true;
158
- try { child.kill("SIGTERM"); } catch { /* 已退 */ }
159
- reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${briefTail()}`));
160
- }, timeoutMs);
161
- const onChunk = (buf) => {
162
- push(buf);
163
- if (settled || !READY_PATTERN.test(tail.join("\n"))) return;
164
- settled = true;
165
- clearTimeout(timer);
166
- resolve({ pid: child.pid });
167
- };
168
- child.stdout?.on("data", onChunk);
169
- child.stderr?.on("data", onChunk);
170
- child.once("exit", (code, signal) => {
171
- const aliveMs = Date.now() - t0;
172
- if (!settled) {
173
- settled = true;
174
- clearTimeout(timer);
175
- reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${briefTail()}`));
176
- }
177
- // 主动关掉的隧道不算"意外退出":收摊日志只该出现在真出事时(否则每次换槽都刷一行假警报)
178
- if (!stopping) log(fmtExit({ dev, code, signal, pid: child.pid, aliveMs, tail: lastTail() }));
179
- if (onExit) { try { onExit({ code, signal, tail: lastTail() }); } catch { /* 回调失败不影响退出 */ } }
180
- });
181
- child.once("error", (err) => {
182
- if (settled) return;
183
- settled = true;
184
- clearTimeout(timer);
185
- reject(new Error(`拉不起 ${bin}:${String(err?.message || err)}`));
186
- });
187
- });
188
- return { child, pid: child.pid, ready, tail: lastTail, stop: () => { stopping = true; try { child.kill("SIGTERM"); } catch { /* 已退 */ } } };
189
- }
190
-
191
- /** 等 tun 地址出现(VPNGate 的 ifconfig 是随隧道建立推送的,通常一两百毫秒内就位)。 */
192
- export async function waitTunAddr({ dev, runCmdFn = runCmd, wait = (ms) => new Promise((r) => setTimeout(r, ms)), tries = 10, gapMs = 300 } = {}) {
193
- let last = "";
194
- for (let i = 0; i < tries; i++) {
195
- const r = await runCmdFn("ip", ["-4", "addr", "show", "dev", dev], { timeoutMs: 5_000 });
196
- last = `${r.stdout}${r.stderr}`.trim();
197
- const ip = parseTunAddr(r.stdout);
198
- if (ip) return { ip, out: last };
199
- if (i < tries - 1) await wait(gapMs);
200
- }
201
- return { ip: "", out: last };
202
- }
203
-
204
- /**
205
- * 放宽 rp_filter(严格反向路径过滤会丢「从 tun 出去、从 eth 回来」的不对称包 ⇒ 隧道看起来活着但请求全挂住)。
206
- * 默认开;MSLXDFF_MULTI_IP_SYSCTL=0 关。改的是内核参数,日志必须写清楚改了什么、怎么还原。
207
- */
208
- export async function relaxRpf({ enabled = true, readFile, writeFile, log = () => {} } = {}) {
209
- if (!enabled) {
210
- log(fmtRpf({ skipped: true }));
211
- return { ok: true, changed: [] };
212
- }
213
- const paths = rpFilterPaths();
214
- const changed = [];
215
- try {
216
- const fs = await import("node:fs");
217
- const read = readFile || fs.readFileSync;
218
- const write = writeFile || fs.writeFileSync;
219
- for (const p of paths) {
220
- const before = String(read(p, "utf8")).trim();
221
- if (before === "2") continue;
222
- write(p, "2");
223
- changed.push({ path: p, from: before, to: "2" });
224
- }
225
- log(fmtRpf({ ok: true, changed }));
226
- return { ok: true, changed };
227
- } catch (e) {
228
- log(fmtRpf({ ok: false, error: String(e?.message || e), changed }));
229
- return { ok: false, changed, error: String(e?.message || e) };
230
- }
231
- }
232
-
233
- /**
234
- * 起槽全流程:spawn openvpn → 等就绪 → 读 tun 地址 → 放宽 rp_filter → 装策略路由。
235
- * @returns {Promise<{dev, table, priority, tunIp, pid, stop, tail}>}
236
- */
237
- export async function bringUpTunnel({ cfg, slotIndex, node, deps = {} } = {}) {
238
- const dev = devNameFor(cfg.devPrefix, slotIndex);
239
- const table = tableFor(cfg.baseTable, slotIndex);
240
- const priority = rulePriorityFor(slotIndex);
241
- const runCmdFn = deps.runCmdFn || runCmd;
242
- const wait = deps.wait || ((ms) => new Promise((r) => setTimeout(r, ms)));
243
- const log = deps.log || (() => {});
244
- const t = startTunnel({
245
- bin: cfg.openvpnBin,
246
- args: buildOpenvpnArgs({ conf: node.conf, dev, authFile: deps.authFile, logFile: deps.openvpnLog || "" }),
247
- timeoutMs: cfg.connectTimeoutMs,
248
- spawnFn: deps.spawnFn,
249
- log,
250
- dev,
251
- onExit: deps.onExit,
252
- });
253
- try {
254
- await t.ready;
255
- } catch (e) {
256
- t.stop();
257
- throw e;
258
- }
259
- log(fmtSpawn({ dev, pid: t.pid, host: node.host, port: node.port, country: node.countryShort || "", table }));
260
- const addr = await waitTunAddr({ dev, runCmdFn, wait, tries: deps.addrTries ?? 10 });
261
- if (!addr.ip) {
262
- t.stop();
263
- throw new Error(fmtTunAddrFail({ dev, out: addr.out }));
264
- }
265
- await relaxRpf({ enabled: cfg.sysctl !== false, log, readFile: deps.readFile, writeFile: deps.writeFile });
266
- for (const cmd of tunnelUpCommands({ tunIp: addr.ip, table, dev, priority })) {
267
- const r = await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 8_000 });
268
- if (r.code !== 0) {
269
- t.stop();
270
- throw new Error(fmtRuleFail({ dev, cmd: cmd.join(" "), error: `${r.stderr || r.stdout}`.trim() }));
271
- }
272
- }
273
- return { dev, table, priority, tunIp: addr.ip, pid: t.pid, stop: t.stop, tail: t.tail };
274
- }
275
-
276
- /** 拆槽:先撤策略路由(否则表里留着已消失设备的默认路由),再关子进程。 */
277
- export async function tearDownTunnel({ slot, runCmdFn = runCmd, log = () => {} } = {}) {
278
- if (!slot) return;
279
- for (const cmd of tunnelDownCommands(slot)) {
280
- try {
281
- await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 5_000 });
282
- } catch (e) {
283
- log(`[multi-ip-pool] 撤策略路由失败(${cmd.join(" ")}):${String(e?.message || e)}`);
284
- }
285
- }
286
- try {
287
- slot.stop?.();
288
- } catch {
289
- /* 已经退了 */
290
- }
291
- }