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.
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 -155
  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 -110
  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,141 +0,0 @@
1
- // warp-pool · 身份轮换:换一台 WARP 设备身份来换出口 IP。
2
- //
3
- // 依据(他项目真机实测,非推测):kyomufs/zen-router 的 internal/zen/errors.go:32-42 记录
4
- // 同一身份连发 3 次 → 429 FreeUsageLimitError;身份一换,同一个请求立刻 200
5
- // (104.28.198.244 → 429 ×3,104.28.198.247 → 200)。
6
- // 故「多名额」的正解不是「N 台设备同时在线」(真机实测同时注册会折叠成同一个出口),
7
- // 而是**串行换身份**:换一台设备 ⇒ 换一个出口 ⇒ 换一份按 IP 计的名额。
8
- //
9
- // 与 zen-router 对齐的三条纪律:
10
- // 1. 轮换后**必须实测确认出口真的变了**;没变就整个 cycle 重试(它用 4 次,这里同)。
11
- // 「注册成功」不等于「出口换了」——不校验就会拿同一个桶反复撞 429。
12
- // 2. 同端口并发轮换去重 + 最小间隔,防 429 风暴把 CF 注册刷到限流(同类项目事故)。
13
- // 3. 只轮换、不销毁别的节点:轮换失败保持原节点可用,绝不把池打空。
14
- import { probeTunnel } from "./wireproxy.js";
15
- import { EXIT_PROBE_URL, parseExitIp } from "./revive.js";
16
-
17
- /** 经隧道读该端口真实出口地址(CF trace);取不到返回 "",绝不抛。 */
18
- export async function readExitIp({ port, fetchImpl, timeoutMs = 12_000 } = {}) {
19
- try {
20
- const res = await probeTunnel({ port, baseUrl: EXIT_PROBE_URL, path: "", fetchImpl, timeoutMs, wantText: true });
21
- return typeof res?.text === "string" ? parseExitIp(res.text) : "";
22
- } catch {
23
- return "";
24
- }
25
- }
26
-
27
- /**
28
- * 身份轮换器。
29
- * @param {{ register: Function, apply: (node:object)=>Promise<void>, readIp?: Function,
30
- * maxAttempts?: number, settleMs?: number, sleep?: Function, now?: Function,
31
- * log?: (s:string)=>void }} opts
32
- * apply 由调用方注入:把新身份落盘并起隧道(含停掉旧隧道)。
33
- */
34
- export function createIdentityRotator({
35
- register,
36
- apply,
37
- readIp = readExitIp,
38
- maxAttempts = 4,
39
- settleMs = 3000,
40
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
41
- now = Date.now,
42
- log = () => {},
43
- } = {}) {
44
- /** @type {Map<number, {ip:string, at:number}>} 各端口已知出口 */
45
- const known = new Map();
46
- /** @type {Map<number, Promise<object>>} 在途轮换(同端口去重) */
47
- const inflight = new Map();
48
-
49
- async function cycle(port, oldIp) {
50
- for (let i = 1; i <= maxAttempts; i++) {
51
- let reg;
52
- try {
53
- reg = await register({ log });
54
- } catch (e) {
55
- log(`[warp-pool] :${port} 轮换 ${i}/${maxAttempts}:注册失败(${String(e?.message || e).slice(0, 120)})`);
56
- await sleep(settleMs);
57
- continue;
58
- }
59
- try {
60
- await apply({ port, ...reg });
61
- } catch (e) {
62
- log(`[warp-pool] :${port} 轮换 ${i}/${maxAttempts}:起隧道失败(${String(e?.message || e).slice(0, 120)})`);
63
- }
64
- await sleep(settleMs);
65
- const ip = await readIp({ port });
66
- if (ip && ip !== oldIp) {
67
- known.set(port, { ip, at: now() });
68
- log(`[warp-pool] :${port} 身份已轮换:出口 ${oldIp || "?"} → ${ip}(第 ${i}/${maxAttempts} 次)`);
69
- return { ok: true, ip, attempts: i };
70
- }
71
- log(`[warp-pool] :${port} 轮换 ${i}/${maxAttempts}:出口未变(仍取到 ${ip || "空"}),重试`);
72
- }
73
- return { ok: false, attempts: maxAttempts };
74
- }
75
-
76
- return {
77
- /** 记下入池时的实测出口(bootstrap 取证后调用)。 */
78
- note(port, ip) {
79
- if (ip) known.set(port, { ip, at: now() });
80
- },
81
- /** 该端口当前已知出口("" = 未知)。 */
82
- ipOf(port) {
83
- return known.get(port)?.ip || "";
84
- },
85
- /**
86
- * 轮换某节点身份换出口。同端口在途时返回同一个 Promise;minGapMs 内重复请求被掐
87
- * (429 风暴时每个失败请求都触发一次轮换会把 CF 刷黑)。
88
- * @returns {Promise<{ok:boolean, ip?:string, attempts?:number, skipped?:boolean}>}
89
- */
90
- rotate(port, { minGapMs = 60_000, force = false } = {}) {
91
- const dup = inflight.get(port);
92
- if (dup) return dup;
93
- const prev = known.get(port);
94
- if (!force && prev && now() - prev.at < minGapMs) {
95
- return Promise.resolve({ ok: false, skipped: true });
96
- }
97
- const p = cycle(port, prev?.ip || "")
98
- .catch((e) => {
99
- log(`[warp-pool] :${port} 轮换异常:${String(e?.message || e)}`);
100
- return { ok: false };
101
- })
102
- .finally(() => inflight.delete(port));
103
- inflight.set(port, p);
104
- return p;
105
- },
106
-
107
- /** 入池取证结果(labelExits 的分组)批量记账:每个节点的出口 IP 都要记住,轮换才能比对「是否真变了」。 */
108
- noteExits(groups) {
109
- for (const g of groups || []) for (const p of g.nodes || []) if (g.ip) known.set(p, { ip: g.ip, at: now() });
110
- },
111
- /** 轮换进行中 = 该节点正在换出口(调用方可据此避开它)。 */
112
- busy(port) {
113
- return inflight.has(port);
114
- },
115
- stats() {
116
- return [...known.entries()].map(([port, v]) => ({ port, ip: v.ip, at: v.at }));
117
- },
118
- };
119
- }
120
-
121
- /**
122
- * 把「429 → 换身份换出口」接进一个已建好的池。轮换窗口内**先把该节点冷却**(端口已停,
123
- * 请求过去必失败),换好再放回。依赖全由调用方注入,本模块不反向依赖池。
124
- * @returns {{note:Function, noteExits:Function, ipOf:Function, rotate:Function, busy:Function, stats:Function}}
125
- */
126
- export function wireRotation({ dispatcher, register, tunnels, bringUp, swap = () => {}, log = () => {}, ...rest } = {}) {
127
- return createIdentityRotator({
128
- register,
129
- apply: async (node) => {
130
- const idx = dispatcher.entries().find((e) => e.port === node.port)?.index;
131
- if (idx !== undefined) dispatcher.markFailure(idx); // 轮换窗口内先冷却:端口已停,请求过去必失败
132
- tunnels.get(node.port)?.stop();
133
- tunnels.delete(node.port);
134
- swap(node.port, node);
135
- await bringUp(node);
136
- if (idx !== undefined) dispatcher.markOk(idx);
137
- },
138
- log,
139
- ...rest,
140
- });
141
- }
@@ -1,358 +0,0 @@
1
- // warp-pool · 装配入口。
2
- //
3
- // 定位(MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口;缺省**开**,MSLXDFF_WARP_POOL=0 关。
4
- import { statSync } from "node:fs";
5
- import { resolveWarpConfig } from "./config.js";
6
- import { createRotatingDispatcher } from "./rotate.js";
7
- import { classifyWarpSignal, SIGNAL_STATUSES } from "./verdict.js";
8
- import { createHealthMonitor } from "./health.js";
9
- import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
10
- import { registerWarpDevice } from "./register.js";
11
- import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
12
- import { createReviver, labelExits, tailReason } from "./revive.js";
13
- import { portInUse, portOwner, reclaimPort, waitPortFree } from "./ports.js";
14
- import { fmtConf, fmtExit, fmtIdentityDetail, fmtPool, fmtPoolEmpty, fmtProbe, fmtProbeGiveUp, fmtPortBlocked, fmtSiblings, fmtSpawn, startupReport } from "./report.js";
15
- import { wireRotation } from "./identity.js";
16
- import { findDaemons } from "../runtime/proc-info.js";
17
-
18
- /** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
19
- const REDIAL_MIN_GAP_MS = 5 * 60_000;
20
- const REDIAL_MAX_GAP_MS = 30 * 60_000;
21
-
22
- /** 注册间隔(同类项目实测:密集注册会让设备落同一网段,且触发 CF 429)。 */
23
- function gapMs(cfg, rnd = Math.random) {
24
- const span = Math.max(0, cfg.registerMaxGapMs - cfg.registerMinGapMs);
25
- return Math.round(cfg.registerMinGapMs + rnd() * span);
26
- }
27
-
28
- /** 注册响应 → 节点对象(bootstrap 与重拨共用,避免同一份字段表写两遍)。 */
29
- function nodeFromReg(port, reg, now) {
30
- return { port, id: reg.id, token: reg.token, v4: reg.v4, v6: reg.v6, endpoint: reg.endpoint, peerKey: reg.peerKey, privateKey: reg.privateKey, registeredAt: new Date(now()).toISOString() };
31
- }
32
-
33
- /**
34
- * 建池。开关缺省关,故「未启用」= 未设 MSLXDFF_WARP_POOL=1 或 非 leader;那时返回 null,调用方据此不注入。
35
- * @returns {null | { dispatcher: object, ready: Promise<void>, close: () => Promise<void>, stats: () => object }}
36
- */
37
- export function createWarpPool({
38
- env = process.env,
39
- baseUrl = "https://opencode.ai",
40
- log = (m) => console.error(m),
41
- register = registerWarpDevice,
42
- ensureBin = ensureBinary,
43
- start = startTunnel,
44
- probe = probeTunnel,
45
- reclaim = reclaimPort,
46
- inUse = portInUse,
47
- waitFree = waitPortFree,
48
- now = Date.now,
49
- rnd = Math.random,
50
- wait = (ms) => new Promise((r) => setTimeout(r, ms)),
51
- } = {}) {
52
- const cfg = resolveWarpConfig(env);
53
- if (!cfg.active) return null;
54
-
55
- /** 身份轮换器:上游按出口 IP 计的限额(403 FreeTierError / 429 FreeUsage·GoUsage / 裸 429)时换一台 WARP 设备换出口。 */
56
- let rotator = null;
57
-
58
- const dispatcher = createRotatingDispatcher({
59
- nodes: [],
60
- strategy: cfg.strategy,
61
- cooldownMs: cfg.cooldownMs,
62
- log,
63
- // 判决照抄参照实现 zen-router 的决策表(见 verdict.js 头注):403 FreeTierError 与
64
- // 429 FreeUsage/GoUsage 是同一条件、必须**强制**换出口(跳过冷却);BlackUsageLimitError
65
- // 是账号日配额、绝不换;其余状态码不参与判决、也不许刷日志。
66
- onStatus: (signal, port) => {
67
- const status = Number(signal?.status);
68
- if (!SIGNAL_STATUSES.includes(status)) return;
69
- const v = classifyWarpSignal(signal);
70
- if (!cfg.rotate) {
71
- log(`[warp-pool] :${port} 上游 ${status}(${v.kind})—— 轮换开关关着(MSLXDFF_WARP_ROTATE=0),不换出口`);
72
- return;
73
- }
74
- if (!v.rotate || !rotator) {
75
- log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 不换出口:${v.why}`);
76
- return;
77
- }
78
- log(`[warp-pool] :${port} 上游 ${status} 判为「${v.kind}」⇒ 换身份换出口${v.forced ? "(强制,跳过冷却)" : ""}:${v.why}`);
79
- void rotator.rotate(port, { force: v.forced })
80
- .then((r) => { if (r && r.skipped) log(`[warp-pool] :${port} 换出口被冷却掐掉(距上次不足最小间隔)—— 这一发仍走原出口`); })
81
- .catch(() => {});
82
- },
83
- });
84
- /** @type {Map<number, {stop: () => void}>} */
85
- const tunnels = new Map();
86
-
87
- /** 复活:用磁盘已有 conf 重起,不重注册(进程挂了 ≠ 设备失效,省 CF 配额)。 */
88
- async function reviveTunnel(port) {
89
- if (closed) return false;
90
- const node = loadNodes(cfg.dir, [port])[0];
91
- if (!node) {
92
- log(`[warp-pool] :${port} 放弃复活:磁盘上没有该节点的身份(node-${port}.json 缺失或缺私钥)`);
93
- return false;
94
- }
95
- try {
96
- await bringUp(node);
97
- } catch (e) {
98
- log(`[warp-pool] :${port} 复活起隧道失败:${String(e?.message || e)}`);
99
- return false;
100
- }
101
- return true; // 真活没活由 createReviver 在 settleMs 后用 isAlive 复查,这里不许冒充成功
102
- }
103
- const reviver = createReviver({ revive: reviveTunnel, isAlive: (port) => { const t = tunnels.get(port); return !!t && t.alive !== false; }, log });
104
- let binPath = "";
105
- let closed = false;
106
- let lastRedialAt = 0;
107
- let redialGapMs = REDIAL_MIN_GAP_MS;
108
-
109
- // 兜底清理:Node 退出不会回收 spawn 的子进程,daemon 突然退出时同步杀掉隧道。
110
- // 同时置 closed:否则「杀子进程 → onExit → 立刻复活」会在退出过程中再 spawn 出一批孤儿(真机:每次重启漏一批)。
111
- process.once("exit", () => {
112
- closed = true;
113
- try {
114
- reviver.stop();
115
- } catch {}
116
- for (const t of tunnels.values()) {
117
- try {
118
- t.stop();
119
- } catch {}
120
- }
121
- });
122
-
123
- /** 起一条隧道的完整动作:确认不是重复起 → 清残留 → 写 conf → 等端口真空出来 → spawn → 记表。 */
124
- async function bringUp(node) {
125
- if (tunnels.has(node.port)) return; // 已归我们管:绝不再清残留(reclaim 会把自己这条隧道当残留杀掉)
126
- await reclaim({ dir: cfg.dir, port: node.port, log }); // 清上一代孤儿(daemon 被强杀时子进程不会自己退)
127
- const { conf, bytes } = writeTunnelConf(cfg.dir, node);
128
- log(fmtConf({ port: node.port, conf, bytes }));
129
- // SIGTERM 之后套接字要几百毫秒才释放,这期间连得上 ≠ 别人的进程占着——先等它真空出来再判
130
- if (!(await waitFree(node.port, { inUse }))) {
131
- const owner = await portOwner({ dir: cfg.dir, port: node.port }).catch(() => ({ ours: [], others: [] }));
132
- throw new Error(fmtPortBlocked({ port: node.port, ...owner }));
133
- }
134
- const t = start({
135
- binPath,
136
- dir: cfg.dir,
137
- port: node.port,
138
- log,
139
- onExit: ({ port, code, signal, tail, pid, aliveMs }) => {
140
- tunnels.delete(port);
141
- log(fmtExit({ port, code, signal, pid, aliveMs, tail: tailReason(tail) }));
142
- if (!closed) void reviver.onExit(port); // 收摊中就别复活了,否则退出过程里又 spawn 出一批孤儿
143
- },
144
- });
145
- tunnels.set(node.port, t);
146
- log(fmtSpawn({ port: node.port, pid: t.pid, binPath, conf }));
147
- }
148
-
149
- // 429 = 上游按出口 IP 计的名额耗尽 ⇒ 换一台设备的身份换出口(换身份=换桶)。
150
- rotator = wireRotation({
151
- dispatcher,
152
- register,
153
- tunnels,
154
- bringUp,
155
- swap: (port, node) => { removeNode(cfg.dir, port); saveNode(cfg.dir, node); },
156
- log,
157
- });
158
-
159
- /** 探活闸门参数:刚起的隧道要等 WireGuard 握手完成才通(真机:一起来就探 ⇒ 4 条全没通 ⇒ 池为空、WARP 白开)。 */
160
- const PROBE_SETTLE_MS = 2_000;
161
- const PROBE_ATTEMPTS = 2;
162
-
163
- /**
164
- * 入池前的实测闸门:给握手留时间 → 逐个探活 → 探不通的绝不进池。
165
- * 为什么必须有:wireproxy 进程活着但隧道转不动流量时,请求会挂到超时(比直连更糟)。
166
- * @returns {Promise<Array<object>>} 通过探活的节点
167
- */
168
- async function probeGate(list) {
169
- const live = [];
170
- await wait(PROBE_SETTLE_MS);
171
- for (const n of list) {
172
- if (!tunnels.has(n.port)) continue;
173
- const url = `${String(baseUrl).replace(/\/+$/, "")}${cfg.probePath}`;
174
- let r = { ok: false };
175
- for (let i = 1; i <= PROBE_ATTEMPTS; i++) {
176
- const t0 = now();
177
- r = await probe({ port: n.port, baseUrl, path: cfg.probePath });
178
- const ms = Number.isFinite(r.ms) ? r.ms : now() - t0;
179
- log(fmtProbe({ port: n.port, attempt: i, attempts: PROBE_ATTEMPTS, url, ok: r.ok, status: r.status, error: r.error, ms }));
180
- if (r.ok) break;
181
- if (i < PROBE_ATTEMPTS) await wait(PROBE_SETTLE_MS);
182
- }
183
- if (r.ok) {
184
- live.push(n);
185
- } else {
186
- log(fmtProbeGiveUp({ port: n.port, attempts: PROBE_ATTEMPTS, url }));
187
- }
188
- }
189
- return live;
190
- }
191
- async function bootstrap() {
192
- if (closed) return;
193
- ensureDir(cfg.dir);
194
- try {
195
- binPath = await ensureBin({ dir: cfg.dir, bin: cfg.bin, autodownload: cfg.autodownload, log });
196
- } catch (e) {
197
- const why = !cfg.bin && !resolveAsset()
198
- ? `本平台(${process.platform}-${process.arch})无预编译 wireproxy,如需启用请设 MSLXDFF_WARP_BIN`
199
- : String(e?.message || e);
200
- log(`[warp-pool] 出口池本次跳过(不启用,请求走直连):${why}`);
201
- return;
202
- }
203
- if (closed) return;
204
-
205
- // 1. 复用磁盘上已注册的节点
206
- let nodes = loadNodes(cfg.dir, cfg.ports);
207
- let binSize = 0;
208
- try { binSize = statSync(binPath).size; } catch {}
209
- await startupReport({ cfg, nodes, binPath, binSize, ownerOf: (port) => portOwner({ dir: cfg.dir, port }), log });
210
- // 身份指纹:4 条隧道只拿到 2 个出口时,只有这组读数能区分「设备写重复」与「CF 共享 NAT 折叠」
211
- for (const line of fmtIdentityDetail(nodes)) log(line);
212
- // 同机其它 daemon:两个 daemon 会互相把对方的活隧道当「残留」杀掉(真机事故根因),必须一开池就报出来
213
- const siblings = findDaemons({ self: process.pid });
214
- if (siblings.length) log(fmtSiblings({ list: siblings }));
215
-
216
- // 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
217
- const used = new Set(nodes.map((n) => n.port));
218
- const free = cfg.ports.filter((p) => !used.has(p));
219
- for (const port of free) {
220
- if (closed) return;
221
- if (nodes.length >= cfg.count) break;
222
- try {
223
- if (nodes.length > 0) await wait(gapMs(cfg, rnd));
224
- const reg = await register({ log });
225
- const node = nodeFromReg(port, reg, now);
226
- saveNode(cfg.dir, node);
227
- nodes.push(node);
228
- log(`[warp-pool] 注册新身份 :${port} 成功(设备 v6=${reg.v6},WireGuard 密钥对在本地生成)`);
229
- } catch (e) {
230
- log(`[warp-pool] :${port} 注册身份失败,本轮跳过这个端口:${String(e?.message || e)}`);
231
- }
232
- }
233
-
234
- // 3. 起隧道
235
- for (const n of nodes) {
236
- try {
237
- await bringUp(n);
238
- } catch (e) {
239
- log(`[warp-pool] :${n.port} 起隧道失败,这个端口本轮不可用:${String(e?.message || e)}`);
240
- }
241
- }
242
-
243
- // 入池前实测探活:WARP 握手没完成时 wireproxy 在跑却转不动流量(请求会挂到超时),那种节点进池会黑洞全部请求
244
- const live = await probeGate(nodes);
245
- if (live.length) {
246
- await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
247
- // 出口取证:一次拿到每个端口的真实出口,既喂给轮换器做「换没换动」的比对,也直接写进池结论那一行
248
- const exits = await labelExits({ ports: live.map((n) => n.port), dir: cfg.dir, log });
249
- rotator.noteExits(exits);
250
- log(fmtPool({ pooled: live.length, total: nodes.length, exits }));
251
- } else {
252
- log(fmtPoolEmpty({ total: nodes.length, ports: cfg.ports }));
253
- }
254
- }
255
-
256
- const monitor = createHealthMonitor({
257
- dispatcher,
258
- baseUrl,
259
- probePath: cfg.probePath,
260
- log,
261
- infoOf: () => ({ alive: tunnels.size, pooled: dispatcher.size, exits: rotator ? rotator.stats() : [] }),
262
- cooldownMs: cfg.cooldownMs,
263
- onExhausted: async () => {
264
- // 重拨 = 重新注册换出口。只重拨「进程已死」的隧道:全活却全挂 = 网络路径问题,重注册只会把 CF 刷到 429
265
- const plan = redialPlan({ ports: cfg.ports, alivePorts: [...tunnels.keys()], lastRedialAt, redialGapMs, now });
266
- if (plan.reason === "all-alive") {
267
- log("[warp-pool] 隧道均存活但全池不通,判定为网络路径问题,跳过重拨");
268
- return;
269
- }
270
- if (plan.reason === "throttled") {
271
- log(`[warp-pool] 距上次重拨不足 ${Math.round(redialGapMs / 1000)}s,跳过本轮`);
272
- return;
273
- }
274
- const dead = plan.dead;
275
- lastRedialAt = now();
276
- redialGapMs = Math.min(redialGapMs * 2, REDIAL_MAX_GAP_MS);
277
- log(`[warp-pool] ${dead.length} 条隧道已死,重拨(下次最短间隔 ${Math.round(redialGapMs / 1000)}s)`);
278
- for (const port of dead) {
279
- if (closed) return;
280
- try {
281
- tunnels.get(port)?.stop();
282
- tunnels.delete(port);
283
- removeNode(cfg.dir, port);
284
- const reg = await register({ log });
285
- saveNode(cfg.dir, nodeFromReg(port, reg, now));
286
- await bringUp({ port, ...reg });
287
- } catch (e) {
288
- log(`[warp-pool] 重拨 :${port} 失败:${String(e?.message || e)}`);
289
- }
290
- await wait(gapMs(cfg, rnd));
291
- }
292
- if (!closed && tunnels.size) {
293
- // 重拨起来的隧道同样要过闸门:没过探活的隧道进池 = 把请求挂到超时(原先这里直接 setNodes,等于绕过闸门)
294
- const alive = await probeGate([...tunnels.keys()].map((port) => ({ port })));
295
- if (alive.length) {
296
- redialGapMs = REDIAL_MIN_GAP_MS; // 确实有隧道能通 ⇒ 退避复位
297
- await dispatcher.setNodes(alive.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
298
- } else {
299
- log("[warp-pool] 重拨后仍没有一条隧道探通,保持直连(等下一轮健康检查)");
300
- }
301
- }
302
- },
303
- });
304
-
305
- const ready = bootstrap()
306
- .then(() => {
307
- if (!closed) monitor.start();
308
- })
309
- .catch((e) => log(`[warp-pool] bootstrap 失败:${String(e?.message || e)}`));
310
-
311
- return {
312
- dispatcher,
313
- ready,
314
- config: cfg,
315
- async close() {
316
- if (closed) return;
317
- closed = true;
318
- monitor.stop();
319
- reviver.stop();
320
- for (const t of tunnels.values()) {
321
- try {
322
- t.stop();
323
- } catch {}
324
- }
325
- tunnels.clear();
326
- await dispatcher.close();
327
- },
328
- stats() {
329
- return {
330
- enabled: true,
331
- mode: cfg.mode,
332
- strategy: cfg.strategy,
333
- dir: cfg.dir,
334
- tunnels: tunnels.size,
335
- nodes: dispatcher.stats(),
336
- exits: rotator ? rotator.stats() : [],
337
- };
338
- },
339
- };
340
- }
341
-
342
- /** 只取轮换 dispatcher 的便捷入口;未启用时返回 undefined(调用方直接透传即"关闭态")。 */
343
- /** 隧道进程由模块内的 process exit 钩子兜底清理。 */
344
- export function createWarpDispatcher(opts) {
345
- return createWarpPool(opts)?.dispatcher;
346
- }
347
-
348
- /**
349
- * 重拨门(纯函数,便于单测):只「有隧道进程已死」且「距上次重拨超过退避间隔」才放行——全活却全挂是网络问题。
350
- * @returns {{ dead: number[], reason: "ok" | "all-alive" | "throttled" }}
351
- */
352
- export function redialPlan({ ports = [], alivePorts = [], lastRedialAt = 0, redialGapMs = REDIAL_MIN_GAP_MS, now = Date.now } = {}) {
353
- const alive = new Set(alivePorts);
354
- const dead = ports.filter((p) => !alive.has(p));
355
- if (!dead.length) return { dead, reason: "all-alive" };
356
- if (now() - lastRedialAt < redialGapMs) return { dead, reason: "throttled" };
357
- return { dead, reason: "ok" };
358
- }
@@ -1,29 +0,0 @@
1
- // warp-pool · WireGuard 密钥(`wg genkey` / `wg pubkey` 的 Node 原生等价物)。
2
- // WireGuard 用 Curve25519(x25519):密钥是 32 字节 raw,编码为 44 字符 base64。
3
- // Node 原生 generateKeyPairSync('x25519') 导出的 DER 末 32 字节即 raw key(本机实测通过)。
4
- import crypto from "node:crypto";
5
-
6
- const RAW_BYTES = 32;
7
-
8
- /** DER(SPKI/PKCS8) 的末 32 字节就是 Curve25519 raw key。 */
9
- function rawTail(der) {
10
- const raw = der.subarray(-RAW_BYTES);
11
- if (raw.length !== RAW_BYTES) throw new Error("x25519 密钥导出异常:DER 长度不足 32 字节");
12
- return raw;
13
- }
14
-
15
- /**
16
- * 生成一对 WireGuard 密钥(base64,44 字符)。
17
- * 注册 WARP 设备时把 publicKey 填进请求体的 `key` 字段。
18
- */
19
- export function generateWgKeyPair() {
20
- const { publicKey, privateKey } = crypto.generateKeyPairSync("x25519");
21
- const privateKeyB64 = rawTail(privateKey.export({ type: "pkcs8", format: "der" })).toString("base64");
22
- const publicKeyB64 = rawTail(publicKey.export({ type: "spki", format: "der" })).toString("base64");
23
- return { privateKey: privateKeyB64, publicKey: publicKeyB64 };
24
- }
25
-
26
- /** WireGuard base64 密钥形状自检:恒 44 字符、以 '=' 收尾(Nothing-up-my-sleeve 检查用)。 */
27
- export function isWgKey(v) {
28
- return typeof v === "string" && /^[A-Za-z0-9+/]{43}=$/.test(v);
29
- }
@@ -1,95 +0,0 @@
1
- // warp-pool · 节点元数据持久化。
2
- // 含 WireGuard 私钥文件与设备 token ⇒ 一律 0600,且只落在日志根(MSLXDFF_WARP_DIR),不进工作树。
3
- import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { join } from "node:path";
5
-
6
- const SECRET_MODE = 0o600;
7
-
8
- /** 节点两件套:WireGuard 配置(含私钥)与元数据(含设备 token)。 */
9
- export function nodePaths(dir, port) {
10
- return {
11
- conf: join(dir, `node-${port}.conf`),
12
- meta: join(dir, `node-${port}.json`),
13
- };
14
- }
15
-
16
- /** 写敏感文件:新建用 0600,存量补 chmod(mode 只在新建时生效)。 */
17
- export function writeSecret(file, content) {
18
- const existed = existsSync(file);
19
- writeFileSync(file, content, existed ? undefined : { mode: SECRET_MODE });
20
- try { chmodSync(file, SECRET_MODE); } catch {}
21
- return file;
22
- }
23
-
24
- export function ensureDir(dir) {
25
- mkdirSync(dir, { recursive: true, mode: 0o700 });
26
- return dir;
27
- }
28
-
29
- /** 读单个节点元数据;文件缺失/损坏返回 null,绝不抛。 */
30
- export function loadNode(dir, port) {
31
- const { meta } = nodePaths(dir, port);
32
- try {
33
- const j = JSON.parse(readFileSync(meta, "utf8"));
34
- if (!j || typeof j !== "object") return null;
35
- return {
36
- port,
37
- id: String(j.id || ""),
38
- token: String(j.token || ""),
39
- v4: String(j.v4 || ""),
40
- v6: String(j.v6 || ""),
41
- endpoint: String(j.endpoint || ""),
42
- peerKey: String(j.peerKey || ""),
43
- privateKey: String(j.privateKey || ""), // 必须读回:bringUp 靠它渲染 conf,漏了就是 PrivateKey = undefined(2026-10-16 真机事故:复用节点的隧道全起不来)
44
- registeredAt: String(j.registeredAt || ""),
45
- ...(j.cooldownUntil ? { cooldownUntil: Number(j.cooldownUntil) } : {}),
46
- };
47
- } catch {
48
- return null;
49
- }
50
- }
51
-
52
- export function saveNode(dir, node) {
53
- const { meta } = nodePaths(dir, node.port);
54
- return writeSecret(meta, `${JSON.stringify(node, null, 2)}\n`);
55
- }
56
-
57
- export function removeNode(dir, port) {
58
- const { conf, meta } = nodePaths(dir, port);
59
- for (const f of [conf, meta]) {
60
- try { if (existsSync(f)) unlinkSync(f); } catch {}
61
- }
62
- }
63
-
64
- /** 读回配置目录里已注册的节点(按端口升序)。 */
65
- export function loadNodes(dir, ports) {
66
- const out = [];
67
- for (const p of ports) {
68
- const n = loadNode(dir, p);
69
- if (n && n.id && n.token && n.privateKey) out.push(n);
70
- }
71
- return out;
72
- }
73
-
74
- /** WireGuard + wireproxy INI([http] 段把隧道暴露成本地 HTTP 代理出口;undici v5 的 ProxyAgent 只吃 http/https)。 */
75
- export function renderWireproxyConf({ privateKey, v4, v6, endpoint, peerKey, port }) {
76
- const addresses = [
77
- v4 ? `Address = ${v4}/32` : "",
78
- v6 ? `Address = ${v6}/128` : "",
79
- ].filter(Boolean).join("\n");
80
- return `[Interface]
81
- PrivateKey = ${privateKey}
82
- ${addresses}
83
- DNS = 1.1.1.1
84
- MTU = 1280
85
-
86
- [Peer]
87
- PublicKey = ${peerKey}
88
- AllowedIPs = 0.0.0.0/0, ::/0
89
- Endpoint = ${endpoint}
90
- PersistentKeepalive = 25
91
-
92
- [http]
93
- BindAddress = 127.0.0.1:${port}
94
- `;
95
- }