mslxdff 0.1.244 → 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.
- package/package.json +1 -1
- package/src/chat-pipeline/terminal-verdict.js +4 -4
- package/src/cli/commands/daemon.js +1 -1
- package/src/cli/index.js +0 -5
- package/src/daemon.js +3 -3
- package/src/runtime/bootstrap.js +1 -1
- package/src/runtime/proc-info.js +2 -2
- package/src/runtime/providers-setup.js +2 -4
- package/src/server.js +3 -3
- package/src/upstream-engine/index.js +2 -17
- package/src/upstream.js +1 -61
- package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
- package/src/cli/commands/multi-ip-pool-test.js +0 -136
- package/src/cli/commands/multi-ip-pool.js +0 -83
- package/src/cli/commands/v6-pool.js +0 -123
- package/src/multi-ip-pool/config.js +0 -155
- package/src/multi-ip-pool/diagnose.js +0 -159
- package/src/multi-ip-pool/dial.js +0 -179
- package/src/multi-ip-pool/dispatch.js +0 -244
- package/src/multi-ip-pool/index.js +0 -174
- package/src/multi-ip-pool/nodes-fetch.js +0 -110
- package/src/multi-ip-pool/nodes.js +0 -205
- package/src/multi-ip-pool/report.js +0 -162
- package/src/multi-ip-pool/selftest.js +0 -168
- package/src/multi-ip-pool/standby.js +0 -157
- package/src/multi-ip-pool/tunnel.js +0 -291
- package/src/v6pool/claim.js +0 -157
- package/src/v6pool/config.js +0 -65
- package/src/v6pool/detect.js +0 -130
- package/src/v6pool/index.js +0 -157
- package/src/v6pool/prefix.js +0 -140
- package/src/v6pool/probe.js +0 -40
- package/src/v6pool/report.js +0 -104
- package/src/v6pool/rotate.js +0 -263
- package/src/v6pool/selftest.js +0 -178
- package/src/warp-pool/config.js +0 -74
- package/src/warp-pool/health.js +0 -106
- package/src/warp-pool/identity.js +0 -141
- package/src/warp-pool/index.js +0 -358
- package/src/warp-pool/keys.js +0 -29
- package/src/warp-pool/nodes.js +0 -95
- package/src/warp-pool/ports.js +0 -160
- package/src/warp-pool/register.js +0 -113
- package/src/warp-pool/report.js +0 -232
- package/src/warp-pool/revive.js +0 -168
- package/src/warp-pool/rotate.js +0 -214
- package/src/warp-pool/strategy.js +0 -43
- package/src/warp-pool/verdict.js +0 -69
- package/src/warp-pool/wireproxy.js +0 -162
|
@@ -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
|
-
}
|
package/src/v6pool/claim.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// v6pool · 让内核接受池里的源地址(bind 直用 / ip addr add 认领)+ 认领账 + 释放。
|
|
2
|
-
//
|
|
3
|
-
// 两条路(Linux 上 bind() 一个没分配给本机的源地址会直接 EADDRNOTAVAIL):
|
|
4
|
-
// bind:net.ipv6.ip_nonlocal_bind=1 ⇒ 直接 bind 任意地址,**不碰网卡**(首选,零副作用);
|
|
5
|
-
// add :ip -6 addr add <addr>/128 dev <iface> nodad ⇒ 加进网卡(需 root)。
|
|
6
|
-
//
|
|
7
|
-
// 为什么要记账(本轮手工实测的教训):加进网卡的地址会改变内核的**默认源地址选择**——
|
|
8
|
-
// 我在 A 机上留了两个临时地址,结果不带 --interface 的请求也走了新地址(生产机上这是事故)。
|
|
9
|
-
// 所以:每次 add/del 都落 `<dir>/claimed.json`(0600、非工作树);close 时全部释放;
|
|
10
|
-
// 启动时按这份账清上一代残留(崩溃 / 被 SIGKILL 时 close 跑不到)。
|
|
11
|
-
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
|
-
import { join } from "node:path";
|
|
13
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
14
|
-
import { fmtClaim, fmtClaimFail, fmtSweep } from "./report.js";
|
|
15
|
-
|
|
16
|
-
export const CLAIM_FILE = "claimed.json";
|
|
17
|
-
|
|
18
|
-
/** 默认执行器:失败时把 stderr 原文带进 Error(不许把 stderr 吞掉——否则只剩「失败了」)。 */
|
|
19
|
-
export function defaultExec(file, args, { timeoutMs = 10_000 } = {}) {
|
|
20
|
-
return new Promise((resolve, reject) => {
|
|
21
|
-
execFile(file, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
|
|
22
|
-
if (err) {
|
|
23
|
-
const e = new Error(`${file} ${args.join(" ")} 失败:${String(stderr || err.message || "").trim()}`);
|
|
24
|
-
e.code = err.code;
|
|
25
|
-
e.stderr = String(stderr || "");
|
|
26
|
-
reject(e);
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
resolve({ stdout: String(stdout || ""), stderr: String(stderr || "") });
|
|
30
|
-
});
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 退出钩子专用(同步):`process.on("exit")` 里跑不了 async,但地址必须清。 */
|
|
35
|
-
export function defaultExecSync(file, args, { timeoutMs = 10_000 } = {}) {
|
|
36
|
-
execFileSync(file, args, { timeout: timeoutMs, stdio: "ignore" });
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** 幂等判据:`ip addr add` 报 File exists = 已经在网卡上了,不算失败。 */
|
|
40
|
-
export function isAlreadyThere(e) {
|
|
41
|
-
return /File exists|already assigned/i.test(String(e?.stderr || e?.message || ""));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** 释放时「它已经不在了」也不算失败(上一代残留已被别处清掉 / 手工删掉)。 */
|
|
45
|
-
export function isAlreadyGone(e) {
|
|
46
|
-
return /Cannot assign requested address|Cannot find device|does not exist/i.test(String(e?.stderr || e?.message || ""));
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* @param {{ iface?: string, mode?: "bind"|"add", dir?: string, log?: (s:string)=>void, exec?: Function, fs?: object }} opts
|
|
51
|
-
*/
|
|
52
|
-
export function createClaimer({ iface = "", mode = "bind", dir = "", log = () => {}, exec = defaultExec, fs = { mkdirSync, writeFileSync, chmodSync, readFileSync } } = {}) {
|
|
53
|
-
/** 只有 add 模式需要记账(bind 模式没改网卡,无残留可言)。 */
|
|
54
|
-
const claimed = new Set();
|
|
55
|
-
|
|
56
|
-
function persist() {
|
|
57
|
-
try {
|
|
58
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
-
const file = join(dir, CLAIM_FILE);
|
|
60
|
-
fs.writeFileSync(file, `${JSON.stringify({ iface, mode, addrs: [...claimed], updatedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
61
|
-
fs.chmodSync(file, 0o600); // mode 只在新建时生效,存量必须补
|
|
62
|
-
} catch (e) {
|
|
63
|
-
log(`[v6pool] ⚠ 认领账写盘失败(本次照跑,但崩溃后无法自动清残留):${String(e?.message || e)}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
async function ensure(addr) {
|
|
68
|
-
if (mode !== "add") return addr;
|
|
69
|
-
try {
|
|
70
|
-
await exec("ip", ["-6", "addr", "add", `${addr}/128`, "dev", iface, "nodad"]);
|
|
71
|
-
log(fmtClaim({ addr, iface }));
|
|
72
|
-
} catch (e) {
|
|
73
|
-
if (!isAlreadyThere(e)) {
|
|
74
|
-
log(fmtClaimFail({ addr, iface, error: String(e?.message || e) }));
|
|
75
|
-
throw e;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
claimed.add(addr);
|
|
79
|
-
persist();
|
|
80
|
-
return addr;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
async function release(addr) {
|
|
84
|
-
if (mode !== "add" || !claimed.has(addr)) return false;
|
|
85
|
-
claimed.delete(addr);
|
|
86
|
-
persist();
|
|
87
|
-
try {
|
|
88
|
-
await exec("ip", ["-6", "addr", "del", `${addr}/128`, "dev", iface]);
|
|
89
|
-
return true;
|
|
90
|
-
} catch (e) {
|
|
91
|
-
if (isAlreadyGone(e)) return false;
|
|
92
|
-
log(`[v6pool] ⚠ 释放源地址失败(重启时由启动清残留兜底):${addr} — ${String(e?.stderr || e?.message || "").trim()}`);
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
async function releaseAll() {
|
|
98
|
-
const done = [];
|
|
99
|
-
for (const a of [...claimed]) if (await release(a)) done.push(a);
|
|
100
|
-
return done;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** 退出钩子专用:进程正在退出,只能跑同步命令;清不掉的交给下次启动的 sweepStale。 */
|
|
104
|
-
function releaseAllSync({ execSync = defaultExecSync } = {}) {
|
|
105
|
-
if (mode !== "add" || !claimed.size) return [];
|
|
106
|
-
const done = [];
|
|
107
|
-
for (const a of [...claimed]) {
|
|
108
|
-
try {
|
|
109
|
-
execSync("ip", ["-6", "addr", "del", `${a}/128`, "dev", iface]);
|
|
110
|
-
done.push(a);
|
|
111
|
-
} catch {
|
|
112
|
-
/* 删不掉不抛:进程正在退出,抛了也没人接 */
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
claimed.clear();
|
|
116
|
-
return done;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** 启动清残留:只删**上一代自己记在账上的**地址,绝不猜(不扫全表,不碰别人的地址)。 */
|
|
120
|
-
async function sweepStale() {
|
|
121
|
-
if (mode !== "add") return { removed: [], failed: [] };
|
|
122
|
-
let prev = [];
|
|
123
|
-
try {
|
|
124
|
-
prev = JSON.parse(fs.readFileSync(join(dir, CLAIM_FILE), "utf8"))?.addrs || [];
|
|
125
|
-
} catch {
|
|
126
|
-
return { removed: [], failed: [] };
|
|
127
|
-
}
|
|
128
|
-
const removed = [];
|
|
129
|
-
const failed = [];
|
|
130
|
-
for (const a of prev) {
|
|
131
|
-
if (typeof a !== "string" || !a) continue;
|
|
132
|
-
try {
|
|
133
|
-
await exec("ip", ["-6", "addr", "del", `${a}/128`, "dev", iface]);
|
|
134
|
-
removed.push(a);
|
|
135
|
-
} catch (e) {
|
|
136
|
-
if (!isAlreadyGone(e)) failed.push({ addr: a, error: String(e?.stderr || e?.message || "").trim() });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
claimed.clear();
|
|
140
|
-
persist();
|
|
141
|
-
const r = { removed, failed };
|
|
142
|
-
if (removed.length || failed.length) log(fmtSweep({ iface, ...r }));
|
|
143
|
-
return r;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return {
|
|
147
|
-
ensure,
|
|
148
|
-
release,
|
|
149
|
-
releaseAll,
|
|
150
|
-
releaseAllSync,
|
|
151
|
-
sweepStale,
|
|
152
|
-
claimed: () => [...claimed],
|
|
153
|
-
get mode() {
|
|
154
|
-
return mode;
|
|
155
|
-
},
|
|
156
|
-
};
|
|
157
|
-
}
|
package/src/v6pool/config.js
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
// v6pool · 配置解析:env 优先,缺省值集中一处;纯函数,便于单测。
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { logDir } from "../logs.js";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* 缺省**开** + **自动判断**:本机有整段路由的 IPv6 前缀就启用(判据见 detect.js),
|
|
7
|
-
* 没有就诚实跳过(行为与今天完全一致)。判据不靠 env,靠事实。
|
|
8
|
-
*/
|
|
9
|
-
export const DEFAULTS = {
|
|
10
|
-
enabled: true,
|
|
11
|
-
count: 4,
|
|
12
|
-
strategy: "session",
|
|
13
|
-
cooldownMs: 600_000,
|
|
14
|
-
rotate: true,
|
|
15
|
-
minHostBits: 8,
|
|
16
|
-
claim: "auto",
|
|
17
|
-
probePath: "/zen/v1/models",
|
|
18
|
-
retireMs: 60_000,
|
|
19
|
-
debug: false,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const OFF = ["0", "off", "false", "no", "disable", "disabled"];
|
|
23
|
-
const CLAIM_MODES = ["auto", "bind", "add"];
|
|
24
|
-
|
|
25
|
-
function envBool(env, name, fallback) {
|
|
26
|
-
const raw = env[name];
|
|
27
|
-
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
28
|
-
return !OFF.includes(String(raw).trim().toLowerCase());
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function envInt(env, name, fallback) {
|
|
32
|
-
const v = Number(env[name]);
|
|
33
|
-
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function envStr(env, name, fallback) {
|
|
37
|
-
const raw = env[name];
|
|
38
|
-
return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 解析 v6 出口池配置。
|
|
43
|
-
* @param {Record<string,string|undefined>} env
|
|
44
|
-
* @param {{ logDirFn?: () => string }} [deps] 测试可注入 logDir
|
|
45
|
-
*/
|
|
46
|
-
export function resolveV6Config(env = process.env, { logDirFn = logDir } = {}) {
|
|
47
|
-
const strategy = envStr(env, "MSLXDFF_V6_STRATEGY", DEFAULTS.strategy) === "request" ? "request" : "session";
|
|
48
|
-
const claimRaw = envStr(env, "MSLXDFF_V6_CLAIM", DEFAULTS.claim).toLowerCase();
|
|
49
|
-
return {
|
|
50
|
-
enabled: envBool(env, "MSLXDFF_V6_POOL", DEFAULTS.enabled),
|
|
51
|
-
/** 显式池前缀(`2001:db8::/64`);空 = 取本机全局地址的前缀 */
|
|
52
|
-
prefix: envStr(env, "MSLXDFF_V6_PREFIX", ""),
|
|
53
|
-
iface: envStr(env, "MSLXDFF_V6_IFACE", ""),
|
|
54
|
-
count: envInt(env, "MSLXDFF_V6_COUNT", DEFAULTS.count),
|
|
55
|
-
strategy,
|
|
56
|
-
cooldownMs: envInt(env, "MSLXDFF_V6_COOLDOWN_MS", DEFAULTS.cooldownMs),
|
|
57
|
-
rotate: envBool(env, "MSLXDFF_V6_ROTATE", DEFAULTS.rotate),
|
|
58
|
-
minHostBits: envInt(env, "MSLXDFF_V6_MIN_HOST_BITS", DEFAULTS.minHostBits),
|
|
59
|
-
claim: CLAIM_MODES.includes(claimRaw) ? claimRaw : DEFAULTS.claim,
|
|
60
|
-
dir: envStr(env, "MSLXDFF_V6_DIR", join(logDirFn(), "v6pool")),
|
|
61
|
-
probePath: envStr(env, "MSLXDFF_V6_PROBE_PATH", DEFAULTS.probePath),
|
|
62
|
-
retireMs: envInt(env, "MSLXDFF_V6_RETIRE_MS", DEFAULTS.retireMs),
|
|
63
|
-
debug: envBool(env, "MSLXDFF_V6_DEBUG", DEFAULTS.debug),
|
|
64
|
-
};
|
|
65
|
-
}
|