mslxdff 0.1.239 → 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.
@@ -0,0 +1,280 @@
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 { tailReason } from "../warp-pool/revive.js";
13
+ import { fmtExit, fmtRpf, fmtRuleFail, fmtSpawn, fmtTunAddrFail } from "./report.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
+ */
43
+ export function buildOpenvpnArgs({ conf, dev, authFile, logFile = "", verb = 3 }) {
44
+ return [
45
+ "--config", conf,
46
+ "--dev", dev,
47
+ "--auth-user-pass", authFile,
48
+ "--route-nopull",
49
+ "--pull-filter", "ignore", "redirect-gateway",
50
+ "--verb", String(verb),
51
+ ...(logFile ? ["--log", logFile] : []),
52
+ ];
53
+ }
54
+
55
+ /** 从 `ip -4 addr show dev <dev>` 输出里取 tun 地址(VPNGate 推的是 10.x 内网地址)。 */
56
+ export function parseTunAddr(text) {
57
+ const m = /inet\s+(\d+\.\d+\.\d+\.\d+)/.exec(String(text || ""));
58
+ return m ? m[1] : "";
59
+ }
60
+
61
+ /** 该槽要装的策略路由(纯函数):源地址定表 → 表里默认走隧道。 */
62
+ export function tunnelUpCommands({ tunIp, table, dev, priority }) {
63
+ return [
64
+ ["ip", "rule", "add", "from", `${tunIp}/32`, "lookup", String(table), "priority", String(priority)],
65
+ ["ip", "route", "replace", "default", "dev", dev, "table", String(table)],
66
+ ];
67
+ }
68
+
69
+ /** 拆槽时按相反顺序撤(先撤表、再撤规则)。 */
70
+ export function tunnelDownCommands({ tunIp, table }) {
71
+ return [
72
+ ["ip", "route", "flush", "table", String(table)],
73
+ ["ip", "rule", "del", "from", `${tunIp}/32`, "lookup", String(table)],
74
+ ];
75
+ }
76
+
77
+ /** `ip rule` 的优先级和表号都可能是别人先占的——失败要能一眼看出「占了什么」。 */
78
+ export function rpFilterPaths() {
79
+ return [
80
+ "/proc/sys/net/ipv4/conf/all/rp_filter",
81
+ "/proc/sys/net/ipv4/conf/default/rp_filter",
82
+ ];
83
+ }
84
+
85
+ /** 逐行跑命令;超时 SIGKILL(绝不让一个卡住的 ip 调用把启动挂死)。 */
86
+ export function runCmd(cmd, args = [], { timeoutMs = 10_000, spawnFn = spawn } = {}) {
87
+ return new Promise((resolve) => {
88
+ let out = "";
89
+ let err = "";
90
+ let child;
91
+ try {
92
+ child = spawnFn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
93
+ } catch (e) {
94
+ resolve({ code: -1, stdout: "", stderr: String(e?.message || e) });
95
+ return;
96
+ }
97
+ const timer = setTimeout(() => {
98
+ try {
99
+ child.kill("SIGKILL");
100
+ } catch {
101
+ /* 已经退了 */
102
+ }
103
+ }, timeoutMs);
104
+ child.stdout?.on("data", (b) => { out += String(b); });
105
+ child.stderr?.on("data", (b) => { err += String(b); });
106
+ child.once("error", (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: `${err}${e.message}` }); });
107
+ child.once("exit", (code) => { clearTimeout(timer); resolve({ code: code ?? -1, stdout: out, stderr: err }); });
108
+ });
109
+ }
110
+
111
+ /** 本机到底有没有 openvpn(没有就别装了,诚实跳过)。 */
112
+ export async function checkOpenvpn(bin, { runCmdFn = runCmd } = {}) {
113
+ const r = await runCmdFn(bin, ["--version"], { timeoutMs: 8_000 });
114
+ const text = `${r.stdout}${r.stderr}`;
115
+ if (r.code === 0 && /openvpn/i.test(text)) return { ok: true, version: (text.split(/\r?\n/)[0] || "").trim() };
116
+ return { ok: false, why: r.stderr.trim() || `退出码 ${r.code}` };
117
+ }
118
+
119
+ /**
120
+ * 起一条隧道并等它就绪。返回句柄;`ready` reject 表示这槽本轮不可用(调用方换下一个节点)。
121
+ * tail 保留末 20 行:真机排障时"openvpn 为什么退"全在这几行里。
122
+ */
123
+ export function startTunnel({ bin, args, timeoutMs = 25_000, spawnFn = spawn, log = () => {}, onExit = null, dev = "" } = {}) {
124
+ let child;
125
+ try {
126
+ child = spawnFn(bin, args, { stdio: ["ignore", "pipe", "pipe"] });
127
+ } catch (e) {
128
+ return { pid: 0, ready: Promise.reject(new Error(`拉不起 ${bin}:${String(e?.message || e)}`)), tail: () => "", stop: () => {} };
129
+ }
130
+ const tail = [];
131
+ const t0 = Date.now();
132
+ let settled = false;
133
+ let stopping = false;
134
+ const push = (buf) => {
135
+ for (const line of String(buf).split(/\r?\n/)) {
136
+ const s = line.trim();
137
+ if (!s) continue;
138
+ tail.push(s);
139
+ if (tail.length > 20) tail.shift();
140
+ }
141
+ };
142
+ const lastTail = () => tailReason(tail.join("\n"));
143
+ const ready = new Promise((resolve, reject) => {
144
+ const timer = setTimeout(() => {
145
+ if (settled) return;
146
+ settled = true;
147
+ try { child.kill("SIGTERM"); } catch { /* 已退 */ }
148
+ reject(new Error(`等 ${Math.round(timeoutMs / 1000)}s 没等到「Initialization Sequence Completed」(隧道没起来):${lastTail()}`));
149
+ }, timeoutMs);
150
+ const onChunk = (buf) => {
151
+ push(buf);
152
+ if (settled || !READY_PATTERN.test(tail.join("\n"))) return;
153
+ settled = true;
154
+ clearTimeout(timer);
155
+ resolve({ pid: child.pid });
156
+ };
157
+ child.stdout?.on("data", onChunk);
158
+ child.stderr?.on("data", onChunk);
159
+ child.once("exit", (code, signal) => {
160
+ const aliveMs = Date.now() - t0;
161
+ if (!settled) {
162
+ settled = true;
163
+ clearTimeout(timer);
164
+ reject(new Error(`openvpn 提前退出(code=${code} signal=${signal}):${lastTail()}`));
165
+ }
166
+ // 主动关掉的隧道不算"意外退出":收摊日志只该出现在真出事时(否则每次换槽都刷一行假警报)
167
+ if (!stopping) log(fmtExit({ dev, code, signal, pid: child.pid, aliveMs, tail: lastTail() }));
168
+ if (onExit) { try { onExit({ code, signal, tail: lastTail() }); } catch { /* 回调失败不影响退出 */ } }
169
+ });
170
+ child.once("error", (err) => {
171
+ if (settled) return;
172
+ settled = true;
173
+ clearTimeout(timer);
174
+ reject(new Error(`拉不起 ${bin}:${String(err?.message || err)}`));
175
+ });
176
+ });
177
+ return { child, pid: child.pid, ready, tail: lastTail, stop: () => { stopping = true; try { child.kill("SIGTERM"); } catch { /* 已退 */ } } };
178
+ }
179
+
180
+ /** 等 tun 地址出现(VPNGate 的 ifconfig 是随隧道建立推送的,通常一两百毫秒内就位)。 */
181
+ export async function waitTunAddr({ dev, runCmdFn = runCmd, wait = (ms) => new Promise((r) => setTimeout(r, ms)), tries = 10, gapMs = 300 } = {}) {
182
+ let last = "";
183
+ for (let i = 0; i < tries; i++) {
184
+ const r = await runCmdFn("ip", ["-4", "addr", "show", "dev", dev], { timeoutMs: 5_000 });
185
+ last = `${r.stdout}${r.stderr}`.trim();
186
+ const ip = parseTunAddr(r.stdout);
187
+ if (ip) return { ip, out: last };
188
+ if (i < tries - 1) await wait(gapMs);
189
+ }
190
+ return { ip: "", out: last };
191
+ }
192
+
193
+ /**
194
+ * 放宽 rp_filter(严格反向路径过滤会丢「从 tun 出去、从 eth 回来」的不对称包 ⇒ 隧道看起来活着但请求全挂住)。
195
+ * 默认开;MSLXDFF_MULTI_IP_SYSCTL=0 关。改的是内核参数,日志必须写清楚改了什么、怎么还原。
196
+ */
197
+ export async function relaxRpf({ enabled = true, readFile, writeFile, log = () => {} } = {}) {
198
+ if (!enabled) {
199
+ log(fmtRpf({ skipped: true }));
200
+ return { ok: true, changed: [] };
201
+ }
202
+ const paths = rpFilterPaths();
203
+ const changed = [];
204
+ try {
205
+ const fs = await import("node:fs");
206
+ const read = readFile || fs.readFileSync;
207
+ const write = writeFile || fs.writeFileSync;
208
+ for (const p of paths) {
209
+ const before = String(read(p, "utf8")).trim();
210
+ if (before === "2") continue;
211
+ write(p, "2");
212
+ changed.push({ path: p, from: before, to: "2" });
213
+ }
214
+ log(fmtRpf({ ok: true, changed }));
215
+ return { ok: true, changed };
216
+ } catch (e) {
217
+ log(fmtRpf({ ok: false, error: String(e?.message || e), changed }));
218
+ return { ok: false, changed, error: String(e?.message || e) };
219
+ }
220
+ }
221
+
222
+ /**
223
+ * 起槽全流程:spawn openvpn → 等就绪 → 读 tun 地址 → 放宽 rp_filter → 装策略路由。
224
+ * @returns {Promise<{dev, table, priority, tunIp, pid, stop, tail}>}
225
+ */
226
+ export async function bringUpTunnel({ cfg, slotIndex, node, deps = {} } = {}) {
227
+ const dev = devNameFor(cfg.devPrefix, slotIndex);
228
+ const table = tableFor(cfg.baseTable, slotIndex);
229
+ const priority = rulePriorityFor(slotIndex);
230
+ const runCmdFn = deps.runCmdFn || runCmd;
231
+ const wait = deps.wait || ((ms) => new Promise((r) => setTimeout(r, ms)));
232
+ const log = deps.log || (() => {});
233
+ const t = startTunnel({
234
+ bin: cfg.openvpnBin,
235
+ args: buildOpenvpnArgs({ conf: node.conf, dev, authFile: deps.authFile, logFile: deps.openvpnLog || "" }),
236
+ timeoutMs: cfg.connectTimeoutMs,
237
+ spawnFn: deps.spawnFn,
238
+ log,
239
+ dev,
240
+ onExit: deps.onExit,
241
+ });
242
+ try {
243
+ await t.ready;
244
+ } catch (e) {
245
+ t.stop();
246
+ throw e;
247
+ }
248
+ log(fmtSpawn({ dev, pid: t.pid, host: node.host, port: node.port, country: node.countryShort || "", table }));
249
+ const addr = await waitTunAddr({ dev, runCmdFn, wait, tries: deps.addrTries ?? 10 });
250
+ if (!addr.ip) {
251
+ t.stop();
252
+ throw new Error(fmtTunAddrFail({ dev, out: addr.out }));
253
+ }
254
+ await relaxRpf({ enabled: cfg.sysctl !== false, log, readFile: deps.readFile, writeFile: deps.writeFile });
255
+ for (const cmd of tunnelUpCommands({ tunIp: addr.ip, table, dev, priority })) {
256
+ const r = await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 8_000 });
257
+ if (r.code !== 0) {
258
+ t.stop();
259
+ throw new Error(fmtRuleFail({ dev, cmd: cmd.join(" "), error: `${r.stderr || r.stdout}`.trim() }));
260
+ }
261
+ }
262
+ return { dev, table, priority, tunIp: addr.ip, pid: t.pid, stop: t.stop, tail: t.tail };
263
+ }
264
+
265
+ /** 拆槽:先撤策略路由(否则表里留着已消失设备的默认路由),再关子进程。 */
266
+ export async function tearDownTunnel({ slot, runCmdFn = runCmd, log = () => {} } = {}) {
267
+ if (!slot) return;
268
+ for (const cmd of tunnelDownCommands(slot)) {
269
+ try {
270
+ await runCmdFn(cmd[0], cmd.slice(1), { timeoutMs: 5_000 });
271
+ } catch (e) {
272
+ log(`[multi-ip-pool] 撤策略路由失败(${cmd.join(" ")}):${String(e?.message || e)}`);
273
+ }
274
+ }
275
+ try {
276
+ slot.stop?.();
277
+ } catch {
278
+ /* 已经退了 */
279
+ }
280
+ }
@@ -14,6 +14,7 @@ import { probeFrom } from "./probe.js";
14
14
  import { randomAddress } from "./prefix.js";
15
15
  import { fmtClose, fmtConfig, fmtDetect, fmtDisabled, fmtPool, fmtPoolEmpty, fmtProbe, fmtReadyFail, fmtSkip } from "./report.js";
16
16
  import { createWarpDispatcher } from "../warp-pool/index.js";
17
+ import { createMultiIpDispatcher } from "../multi-ip-pool/index.js";
17
18
 
18
19
  /** 每槽 Agent 的连接池参数。注入 dispatcher 后 transport 不建自带池(transport/index.js L28),所以必须自带。 */
19
20
  export const AGENT_OPTIONS = { connections: 4, pipelining: 1, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000 };
@@ -139,13 +140,17 @@ export function createV6Pool({ env = process.env, baseUrl = "https://opencode.ai
139
140
  }
140
141
 
141
142
  /**
142
- * 出口池二选一(唯一接线点用):v6 池可用就用它,否则退回 WARP 池。
143
- * 两者都只产出一个 dispatcher 对象,注入点与契约完全一致(见 MODULES.md §1)。
143
+ * 出口池三选一(唯一接线点用):v6 池 → 多出口池 → WARP 池。
144
+ * 三者都只产出一个 dispatcher 对象,注入点与契约完全一致(见 MODULES.md §1)。
144
145
  * 放在这里而不是 providers-setup:那里是 10KB 硬门下的文件,多一行都是风险。
145
146
  */
146
147
  export function createPreferredDispatcher({ baseUrl = "https://opencode.ai", env = process.env, log, deps = {} } = {}) {
147
148
  const pool = createV6Pool({ env, baseUrl, log, deps });
148
149
  if (pool) return pool.dispatcher;
150
+ // 次选:多出口池(VPNGate 免费 OpenVPN 节点当出口,只组长/单机建)——它给的是**另一批真实出口 IP**,
151
+ // 而 v6 只是本机自带的那段地址,所以 v6 优先、它次之。任一不可用都安静跳过(各自打自己的日志)。
152
+ const multi = createMultiIpDispatcher({ env, baseUrl, log, deps: deps.multiIp });
153
+ if (multi) return multi;
149
154
  // 单向依赖:warp-pool 不 import v6pool(见 MODULES.md §2「已知耦合」)。
150
155
  return createWarpDispatcher({ baseUrl, env, log });
151
156
  }