mslxdff 0.1.177 → 0.1.179

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.177",
3
+ "version": "0.1.179",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,9 @@
2
2
  import { join } from "node:path";
3
3
  import { logDir } from "../logs.js";
4
4
 
5
- /** 缺省关:2026-10-16 真机实测证伪「N 设备 = N 桶」(免费版 WARP 的 IPv4 出口是共享 NAT,见 ADR-0059 修订)。 */
5
+ /** 缺省开 + 429 时换身份换出口:他项目真机实测「同一身份连发 3 次 429,身份一换同一个请求立刻 200」⇒ 换身份=换桶。 */
6
6
  export const DEFAULTS = {
7
- enabled: false,
7
+ enabled: true,
8
8
  mode: "leader",
9
9
  count: 4,
10
10
  basePort: 10800,
@@ -14,6 +14,7 @@ export const DEFAULTS = {
14
14
  registerMinGapMs: 6_000,
15
15
  registerMaxGapMs: 15_000,
16
16
  probePath: "/zen/v1/models",
17
+ rotate: true,
17
18
  };
18
19
 
19
20
  const OFF = ["0", "off", "false", "no", "disable", "disabled"];
@@ -50,6 +51,7 @@ export function resolveWarpConfig(env = process.env, { logDirFn = logDir } = {})
50
51
  const bin = envStr(env, "MSLXDFF_WARP_BIN", "");
51
52
  const autodownload = envBool(env, "MSLXDFF_WARP_AUTODOWNLOAD", DEFAULTS.autodownload);
52
53
  const probePath = envStr(env, "MSLXDFF_WARP_PROBE_PATH", DEFAULTS.probePath);
54
+ const rotate = envBool(env, "MSLXDFF_WARP_ROTATE", DEFAULTS.rotate);
53
55
 
54
56
  return {
55
57
  enabled,
@@ -63,6 +65,7 @@ export function resolveWarpConfig(env = process.env, { logDirFn = logDir } = {})
63
65
  bin,
64
66
  autodownload,
65
67
  probePath,
68
+ rotate,
66
69
  registerMinGapMs: DEFAULTS.registerMinGapMs,
67
70
  registerMaxGapMs: DEFAULTS.registerMaxGapMs,
68
71
  /** 节点端口列表:basePort .. basePort+count-1 */
@@ -0,0 +1,141 @@
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,14 +1,15 @@
1
1
  // warp-pool · 装配入口。
2
2
  //
3
- // 定位(见 MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口。开关缺省**关**——
4
- // 2026-10-16 真机实测证伪「N 设备 = N 桶」(免费版 WARP 的 IPv4 出口是共享 NAT);设 MSLXDFF_WARP_POOL=1 才建池。
3
+ // 定位(MODULES.md §0/§10):WARP 池是**增量**出口,不是替代成员自身出口;缺省**开**,MSLXDFF_WARP_POOL=0 关。
5
4
  import { resolveWarpConfig } from "./config.js";
6
5
  import { createRotatingDispatcher } from "./rotate.js";
7
6
  import { createHealthMonitor } from "./health.js";
8
7
  import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
9
8
  import { registerWarpDevice } from "./register.js";
10
9
  import { ensureBinary, probeTunnel, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
11
- import { createReviver, labelExits } from "./revive.js";
10
+ import { createReviver, labelExits, tailReason } from "./revive.js";
11
+ import { portInUse, reclaimPort } from "./ports.js";
12
+ import { wireRotation } from "./identity.js";
12
13
 
13
14
  /** 重拨节流:连续失败指数退避。网络不通的机器绝不能让重注册把 CF 刷到 429。 */
14
15
  const REDIAL_MIN_GAP_MS = 5 * 60_000;
@@ -44,7 +45,10 @@ export function createWarpPool({
44
45
  const cfg = resolveWarpConfig(env);
45
46
  if (!cfg.active) return null;
46
47
 
47
- const dispatcher = createRotatingDispatcher({ nodes: [], strategy: cfg.strategy, cooldownMs: cfg.cooldownMs, log });
48
+ /** 身份轮换器:429 时换一台 WARP 设备换出口 IP(串行换身份,见 identity.js 头注)。 */
49
+ let rotator = null;
50
+
51
+ const dispatcher = createRotatingDispatcher({ nodes: [], strategy: cfg.strategy, cooldownMs: cfg.cooldownMs, log, onStatus: (status, port) => { if (status === 429 && cfg.rotate && rotator) void rotator.rotate(port); } });
48
52
  /** @type {Map<number, {stop: () => void}>} */
49
53
  const tunnels = new Map();
50
54
 
@@ -52,11 +56,19 @@ export function createWarpPool({
52
56
  async function reviveTunnel(port) {
53
57
  if (closed) return false;
54
58
  const node = loadNodes(cfg.dir, [port])[0];
55
- if (!node) return false;
56
- await bringUp(node);
57
- return tunnels.has(port);
59
+ if (!node) {
60
+ log(`[warp-pool] :${port} 放弃复活:磁盘上没有该节点的身份(node-${port}.json 缺失或缺私钥)`);
61
+ return false;
62
+ }
63
+ try {
64
+ await bringUp(node);
65
+ } catch (e) {
66
+ log(`[warp-pool] :${port} 复活起隧道失败:${String(e?.message || e)}`);
67
+ return false;
68
+ }
69
+ return true; // 真活没活由 createReviver 在 settleMs 后用 isAlive 复查,这里不许冒充成功
58
70
  }
59
- const reviver = createReviver({ revive: reviveTunnel, log });
71
+ const reviver = createReviver({ revive: reviveTunnel, isAlive: (port) => { const t = tunnels.get(port); return !!t && t.alive !== false; }, log });
60
72
  let binPath = "";
61
73
  let closed = false;
62
74
  let lastRedialAt = 0;
@@ -71,23 +83,40 @@ export function createWarpPool({
71
83
  }
72
84
  });
73
85
 
86
+ /** 起一条隧道的完整动作:清残留 → 写 conf → 确认端口真空着 → spawn → 记表。 */
74
87
  async function bringUp(node) {
88
+ await reclaimPort({ dir: cfg.dir, port: node.port, log }); // 清上一代孤儿(daemon 被强杀时子进程不会自己退)
75
89
  writeTunnelConf(cfg.dir, node);
76
90
  if (tunnels.has(node.port)) return;
91
+ if (await portInUse(node.port)) {
92
+ throw new Error(`端口 :${node.port} 已被非本池进程占用,拒绝再起(硬起只会 bind 失败、无限重启刷屏)`);
93
+ }
77
94
  const t = start({
78
95
  binPath,
79
96
  dir: cfg.dir,
80
97
  port: node.port,
81
98
  log,
82
- onExit: ({ port, code }) => {
99
+ onExit: ({ port, code, signal, tail }) => {
83
100
  tunnels.delete(port);
84
- log(`[warp-pool] 隧道 :${port} 退出(code=${code}),尝试复活`);
101
+ const why = tailReason(tail) || "进程没留下任何输出";
102
+ log(`[warp-pool] :${port} 隧道意外退出(退出码 ${code}${signal ? `,信号 ${signal}` : ""}):${why}`);
85
103
  void reviver.onExit(port);
86
104
  },
87
105
  });
88
106
  tunnels.set(node.port, t);
107
+ log(`[warp-pool] :${node.port} 隧道已起:HTTP 代理 http://127.0.0.1:${node.port} → 经 WARP 出网(pid ${t.pid ?? "?"})`);
89
108
  }
90
109
 
110
+ // 429 = 上游按出口 IP 计的名额耗尽 ⇒ 换一台设备的身份换出口(换身份=换桶)。
111
+ rotator = wireRotation({
112
+ dispatcher,
113
+ register,
114
+ tunnels,
115
+ bringUp,
116
+ swap: (port, node) => { removeNode(cfg.dir, port); saveNode(cfg.dir, node); },
117
+ log,
118
+ });
119
+
91
120
  async function bootstrap() {
92
121
  if (closed) return;
93
122
  ensureDir(cfg.dir);
@@ -97,14 +126,14 @@ export function createWarpPool({
97
126
  const why = !cfg.bin && !resolveAsset()
98
127
  ? `本平台(${process.platform}-${process.arch})无预编译 wireproxy,如需启用请设 MSLXDFF_WARP_BIN`
99
128
  : String(e?.message || e);
100
- log(`[warp-pool] 出口池跳过:${why}`);
129
+ log(`[warp-pool] 出口池本次跳过(不启用,请求走直连):${why}`);
101
130
  return;
102
131
  }
103
132
  if (closed) return;
104
133
 
105
134
  // 1. 复用磁盘上已注册的节点
106
135
  let nodes = loadNodes(cfg.dir, cfg.ports);
107
- log(`[warp-pool] 已有节点 ${nodes.length}/${cfg.count}`);
136
+ log(`[warp-pool] 复用磁盘上已注册的身份 ${nodes.length}/${cfg.count} 个(端口 ${cfg.ports.join(", ")};缺的现场注册)`);
108
137
 
109
138
  // 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
110
139
  const used = new Set(nodes.map((n) => n.port));
@@ -118,9 +147,9 @@ export function createWarpPool({
118
147
  const node = nodeFromReg(port, reg, now);
119
148
  saveNode(cfg.dir, node);
120
149
  nodes.push(node);
121
- log(`[warp-pool] 注册节点 :${port}(v6=${reg.v6})`);
150
+ log(`[warp-pool] 注册新身份 :${port} 成功(设备 v6=${reg.v6},WireGuard 密钥对在本地生成)`);
122
151
  } catch (e) {
123
- log(`[warp-pool] 注册节点 :${port} 失败:${String(e?.message || e)}`);
152
+ log(`[warp-pool] :${port} 注册身份失败,本轮跳过这个端口:${String(e?.message || e)}`);
124
153
  }
125
154
  }
126
155
 
@@ -129,7 +158,7 @@ export function createWarpPool({
129
158
  try {
130
159
  await bringUp(n);
131
160
  } catch (e) {
132
- log(`[warp-pool] 起隧道 :${n.port} 失败:${String(e?.message || e)}`);
161
+ log(`[warp-pool] :${n.port} 起隧道失败,这个端口本轮不可用:${String(e?.message || e)}`);
133
162
  }
134
163
  }
135
164
 
@@ -139,17 +168,17 @@ export function createWarpPool({
139
168
  if (!tunnels.has(n.port)) continue;
140
169
  const r = await probe({ port: n.port, baseUrl, path: cfg.probePath });
141
170
  if (r.ok) live.push(n);
142
- else log(`[warp-pool] 节点 :${n.port} 隧道未通(${r.status || r.error || "?"}),不进池`);
171
+ else log(`[warp-pool] :${n.port} 隧道没探通(${r.status || r.error || "?"}),不进池:进池会把请求挂到超时,比直连更糟`);
143
172
  }
144
173
  if (live.length) {
145
174
  await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
146
- log(`[warp-pool] 出口池就绪:${live.length} 个节点`);
175
+ log(`[warp-pool] 出口池就绪:${live.length} 条隧道可用(选路策略 ${cfg.strategy}${cfg.rotate ? ",撞 429 会自动换身份换出口" : ""})`);
147
176
  } else {
148
- log("[warp-pool] 无可用节点,保持直连");
177
+ log("[warp-pool] 没有一条隧道探通 ⇒ 池为空,全部请求照旧直连(不影响可用性,只是没多出口)");
149
178
  }
150
179
 
151
180
  // 5. 出口取证:把每台设备的真实出口地址打进日志(免费版 WARP 的 IPv4 是共享 NAT,N 台设备很可能折叠成同一个出口)
152
- if (live.length) await labelExits({ ports: live.map((n) => n.port), log });
181
+ if (live.length) rotator.noteExits(await labelExits({ ports: live.map((n) => n.port), log }));
153
182
  }
154
183
 
155
184
  const monitor = createHealthMonitor({
@@ -208,6 +237,7 @@ export function createWarpPool({
208
237
  if (closed) return;
209
238
  closed = true;
210
239
  monitor.stop();
240
+ reviver.stop();
211
241
  for (const t of tunnels.values()) {
212
242
  try {
213
243
  t.stop();
@@ -224,6 +254,7 @@ export function createWarpPool({
224
254
  dir: cfg.dir,
225
255
  tunnels: tunnels.size,
226
256
  nodes: dispatcher.stats(),
257
+ exits: rotator ? rotator.stats() : [],
227
258
  };
228
259
  },
229
260
  };
@@ -0,0 +1,110 @@
1
+ // warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16 组长 VPS 真机事故后新增)。
2
+ //
3
+ // 事故:daemon 被强杀(WMI TerminateProcess / taskkill /F)时 `process.once("exit")` 钩子不执行,
4
+ // wireproxy 子进程变成孤儿,继续占着 127.0.0.1:10800+ 这些端口。新 daemon 再起隧道就 bind 失败
5
+ // (wireproxy 立刻退出 code=1),而旧代码把「spawn 成功」当成「复活成功」⇒ 无限重启、日志刷屏、
6
+ // 且该端口的隧道永远起不来。治本是「起隧道前先把自家残留清掉」,并「端口被别人占着就别硬起」。
7
+ import { readFileSync, readdirSync } from "node:fs";
8
+ import { connect } from "node:net";
9
+ import { nodePaths } from "./nodes.js";
10
+
11
+ /** /proc/<pid>/cmdline 是 NUL 分隔的字节串;切成参数数组(丢掉结尾空串)。 */
12
+ export function parseCmdline(buf) {
13
+ return String(buf == null ? "" : buf).split("\0").filter((s) => s !== "");
14
+ }
15
+
16
+ /**
17
+ * 该进程是不是「正在跑我们这条 conf」的 wireproxy。
18
+ * 判据刻意收紧(argv 里得有 wireproxy 这个可执行文件名,且 conf 路径逐字相等),
19
+ * 免得误杀别人的进程。缺一不可。
20
+ */
21
+ export function isOurWireproxy(argv, conf) {
22
+ const list = Array.isArray(argv) ? argv : [];
23
+ const isBin = list.some((a) => /(^|[\\/])wireproxy(\.exe)?$/i.test(a));
24
+ return isBin && list.includes(conf);
25
+ }
26
+
27
+ function defaultPids() {
28
+ try {
29
+ return readdirSync("/proc").filter((f) => /^\d+$/.test(f)).map(Number);
30
+ } catch {
31
+ return [];
32
+ }
33
+ }
34
+
35
+ function defaultCmdline(pid) {
36
+ try {
37
+ return parseCmdline(readFileSync(`/proc/${pid}/cmdline`));
38
+ } catch {
39
+ return [];
40
+ }
41
+ }
42
+
43
+ /** 进程是否还在(信号 0 = 只做存在性检查,不真发信号)。 */
44
+ export function pidAlive(pid, kill = process.kill) {
45
+ try {
46
+ kill(pid, 0);
47
+ return true;
48
+ } catch {
49
+ return false;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * 清掉「占着该端口那条 conf」的上一代 wireproxy。返回被清理的 pid 列表。
55
+ * 非 Linux 直接返回 [](读不到 /proc,别拿别的平台做危险猜测)。
56
+ * @param {{ dir: string, port: number, log?: Function, platform?: string,
57
+ * pids?: Function, cmdline?: Function, kill?: Function, sleep?: Function, waitRounds?: number }} opts
58
+ */
59
+ export async function reclaimPort({
60
+ dir,
61
+ port,
62
+ log = () => {},
63
+ platform = process.platform,
64
+ pids = defaultPids,
65
+ cmdline = defaultCmdline,
66
+ kill = process.kill,
67
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
68
+ waitRounds = 15,
69
+ } = {}) {
70
+ if (platform !== "linux") return [];
71
+ const conf = nodePaths(dir, port).conf;
72
+ const targets = pids().filter((pid) => isOurWireproxy(cmdline(pid), conf));
73
+ if (!targets.length) return [];
74
+
75
+ log(
76
+ `[warp-pool] 端口 :${port} 发现上一代 wireproxy 残留(pid ${targets.join(", ")}):` +
77
+ `daemon 被强杀时子进程不会自己退出,它会一直占着端口让新隧道 bind 失败。先清掉再起。`,
78
+ );
79
+ for (const pid of targets) {
80
+ try {
81
+ kill(pid, "SIGTERM");
82
+ } catch {}
83
+ }
84
+ for (let i = 0; i < waitRounds && targets.some((p) => pidAlive(p, kill)); i++) await sleep(100);
85
+ for (const pid of targets.filter((p) => pidAlive(p, kill))) {
86
+ try {
87
+ kill(pid, "SIGKILL");
88
+ } catch {}
89
+ }
90
+ return targets;
91
+ }
92
+
93
+ /** 该端口是否已有人在监听(TCP 连得上 = 占用;连不上/被拒 = 空闲)。 */
94
+ export function portInUse(port, { host = "127.0.0.1", timeoutMs = 800, connectFn = connect } = {}) {
95
+ return new Promise((resolve) => {
96
+ let done = false;
97
+ const sock = connectFn({ host, port });
98
+ const finish = (v) => {
99
+ if (done) return;
100
+ done = true;
101
+ try {
102
+ sock.destroy();
103
+ } catch {}
104
+ resolve(v);
105
+ };
106
+ sock.setTimeout(timeoutMs, () => finish(false));
107
+ sock.once("connect", () => finish(true));
108
+ sock.once("error", () => finish(false));
109
+ });
110
+ }
@@ -1,14 +1,14 @@
1
1
  // warp-pool · 复活与取证(真机事故后新增,2026-10-16)。
2
2
  //
3
- // 两件事,都来自组长 VPS 上暴露的缺陷:
3
+ // 三件事,都来自组长 VPS 上暴露的缺陷:
4
4
  //
5
- // 1. `revive`: 隧道进程意外退出后**必须自愈**。原先 `onExit` 只把端口从表里删掉、打一行日志就完事,
6
- // 而唯一的补拉路径 `onExhausted` 要求「全池不通」——3 条活着时探活一直成功,那条死隧道
7
- // 就**再也不会被拉起**,池静默从 4 降到 3,直到 daemon 重启。(真机::10800 死了就一直死。)
8
- // 注意:复活走**磁盘上已有的 conf**(不重新注册)——进程挂了不代表设备失效,重注册只会白烧 CF 配额。
5
+ // 1. 隧道退出后必须自愈:原先只有「全池不通」才补拉,一条死隧道会让池静默从 4 降到 3,直到 daemon 重启。
6
+ // 2. 入池后必须实测各节点真实出口:WARP 的 IPv4 出口是共享 NAT,「N 设备」很可能折叠成一个出口。
7
+ // 3. 复活必须**真的活下来**(2026-10-16 组长 VPS 事故):`tunnels.has(port)` 只证明「spawn 被调用了」,
8
+ // wireproxy 起一条死一条(端口被上一代孤儿进程占着 ⇒ bind 失败 ⇒ 退出码 1),旧逻辑次次判「已复活」
9
+ // ⇒ 下一次退出立刻再复活 ⇒ 无限重启刷屏,该端口永远起不来。
9
10
  //
10
- // 2. `labelExits`: 入池后实测各节点**真实出口地址**并标注撞车。WARP 的 IPv4 出口是共享 NAT,
11
- // 「N 设备」很可能折叠成一个出口;没有这条读数就只能靠人手工 curl 才发现。
11
+ // 复活只走**磁盘上已有的 conf**:进程挂了 ≠ 设备失效,重注册只会白烧 CF 配额。
12
12
  import { probeTunnel } from "./wireproxy.js";
13
13
 
14
14
  /** 出口取证目标:Cloudflare 自己的 trace 端点,回 `ip=` 一行(不烧上游 token)。 */
@@ -20,12 +20,20 @@ export function parseExitIp(text) {
20
20
  return m ? m[1] : "";
21
21
  }
22
22
 
23
+ /** 子进程末行输出当退出原因(wireproxy 的真实报错都在 stderr);没有输出返回 ""。 */
24
+ export function tailReason(tail, max = 300) {
25
+ const lines = String(tail || "").split("\n").map((s) => s.trim()).filter(Boolean);
26
+ return lines.length ? lines[lines.length - 1].slice(0, max) : "";
27
+ }
28
+
23
29
  /**
24
30
  * 按端口独立的退避复活器。
25
- * @param {{ revive: (port:number)=>Promise<boolean>, minGapMs?: number, maxGapMs?: number,
26
- * now?: ()=>number, log?: (s:string)=>void }} opts
31
+ * @param {{ revive: (port:number)=>Promise<boolean>, isAlive?: (port:number)=>boolean, minGapMs?: number,
32
+ * maxGapMs?: number, settleMs?: number, now?: ()=>number, sleep?: Function,
33
+ * schedule?: Function, cancel?: Function, log?: (s:string)=>void }} opts
34
+ * 复活是否算数由 `isAlive` 在 `settleMs` 后复查;失败按指数退避并**自己用定时器安排下一次重试**。
27
35
  */
28
- export function createReviver({ revive, minGapMs = 30_000, maxGapMs = 10 * 60_000, now = Date.now, log = () => {} } = {}) {
36
+ export function createReviver({ revive, isAlive, minGapMs = 30_000, maxGapMs = 10 * 60_000, settleMs = 1_200, now = Date.now, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), schedule = (fn, ms) => setTimeout(fn, ms), cancel = (h) => clearTimeout(h), log = () => {} } = {}) {
29
37
  /** @type {Map<number, {attempts:number, nextAt:number, busy:boolean}>} */
30
38
  const state = new Map();
31
39
 
@@ -38,37 +46,71 @@ export function createReviver({ revive, minGapMs = 30_000, maxGapMs = 10 * 60_00
38
46
  return s;
39
47
  }
40
48
 
49
+ /** @type {Map<number, object>} 每端口最多一个待触发的重试定时器 */
50
+ const timers = new Map();
51
+
52
+ /** 安排「到点自动重试」;没有它,日志里那句「N s 后自动再试」就是空话。 */
53
+ function armRetry(port, ms) {
54
+ if (timers.has(port)) return;
55
+ const h = schedule(() => {
56
+ timers.delete(port);
57
+ void attempt(port);
58
+ }, Math.max(0, ms));
59
+ if (h && typeof h.unref === "function") h.unref();
60
+ timers.set(port, h);
61
+ }
62
+
63
+ /**
64
+ * 复活一次:首次立即复活,失败按 30s→10min 指数退避并自己安排下一次重试。
65
+ * 复活算不算数由 `isAlive` 在 `settleMs` 后复查——不许拿「spawn 被调用了」冒充「活下来了」。
66
+ */
67
+ async function attempt(port) {
68
+ const s = entry(port);
69
+ if (s.busy) return false;
70
+ const wait = Math.max(0, s.nextAt - now());
71
+ if (wait > 0) {
72
+ log(`[warp-pool] :${port} 距上次复活不足退避间隔,${Math.ceil(wait / 1000)}s 后再试(防抖动刷屏,不是故障)`);
73
+ armRetry(port, wait);
74
+ return false;
75
+ }
76
+ s.busy = true;
77
+ s.attempts += 1;
78
+ const gap = Math.min(minGapMs * 2 ** (s.attempts - 1), maxGapMs);
79
+ let ok = false;
80
+ try {
81
+ ok = await revive(port);
82
+ } catch (e) {
83
+ log(`[warp-pool] :${port} 复活抛出异常:${String(e?.message || e)}`);
84
+ }
85
+ if (ok && isAlive) {
86
+ if (settleMs > 0) await sleep(settleMs);
87
+ ok = isAlive(port);
88
+ if (!ok) log(`[warp-pool] :${port} 复活后进程立刻又退出 —— 不认这次复活(原因见上面那条「意外退出」)`);
89
+ }
90
+ s.busy = false;
91
+ if (ok) {
92
+ const n = s.attempts;
93
+ s.attempts = 0;
94
+ s.nextAt = now() + minGapMs; // 稳定期:期间再退出先等一轮,防「起一条死一条」把日志刷爆
95
+ log(`[warp-pool] :${port} 已复活(第 ${n} 次尝试成功);${Math.round(minGapMs / 1000)}s 内若又退出会先等一轮再重启`);
96
+ return true;
97
+ }
98
+ s.nextAt = now() + gap;
99
+ const hint = s.attempts >= 3 ? ` 排查:ss -ltnp | grep :${port};或前台跑隧道二进制 -c node-${port}.conf 看真实报错` : "";
100
+ log(`[warp-pool] :${port} 复活未成功(第 ${s.attempts} 次),${Math.round(gap / 1000)}s 后自动再试。${hint}`);
101
+ armRetry(port, gap);
102
+ return false;
103
+ }
104
+
41
105
  return {
42
- /** 进程退出时调用。首次立即复活,之后按 30s→10min 指数退避。 */
43
- async onExit(port) {
44
- const s = entry(port);
45
- const wait = Math.max(0, s.nextAt - now());
46
- if (wait > 0) {
47
- log(`[warp-pool] :${port} 复活退避中(还剩 ${Math.round(wait / 1000)}s)`);
48
- return false;
49
- }
50
- if (s.busy) return false;
51
- s.busy = true;
52
- s.attempts += 1;
53
- const gap = Math.min(minGapMs * 2 ** (s.attempts - 1), maxGapMs);
54
- s.nextAt = now() + gap;
55
- let ok = false;
56
- try {
57
- ok = await revive(port);
58
- } catch (e) {
59
- log(`[warp-pool] :${port} 复活失败:${String(e?.message || e)}`);
60
- }
61
- s.busy = false;
62
- if (ok) {
63
- state.delete(port);
64
- log(`[warp-pool] :${port} 已复活`);
65
- } else {
66
- log(`[warp-pool] :${port} 复活未成功(第 ${s.attempts} 次,下次最早 ${Math.round(gap / 1000)}s 后)`);
67
- }
68
- return ok;
69
- },
70
- /** 探活成功时调用:清掉退避账,恢复正常节奏。 */
106
+ onExit: (port) => attempt(port),
107
+ /** 探活成功时调用:清掉退避账与待触发定时器,恢复正常节奏。 */
71
108
  noteHealthy(port) {
109
+ const h = timers.get(port);
110
+ if (h) {
111
+ try { cancel(h); } catch {}
112
+ timers.delete(port);
113
+ }
72
114
  state.delete(port);
73
115
  },
74
116
  stats() {
@@ -79,6 +121,15 @@ export function createReviver({ revive, minGapMs = 30_000, maxGapMs = 10 * 60_00
79
121
  cooldownMsLeft: Math.max(0, s.nextAt - now()),
80
122
  }));
81
123
  },
124
+
125
+ /** 池关闭时调用:别再有定时器去打复活。 */
126
+ stop() {
127
+ for (const h of timers.values()) {
128
+ try { cancel(h); } catch {}
129
+ }
130
+ timers.clear();
131
+ state.clear();
132
+ },
82
133
  };
83
134
  }
84
135
 
@@ -108,10 +159,10 @@ export async function labelExits({ ports = [], fetchImpl, timeoutMs = 12_000, lo
108
159
 
109
160
  const groups = [...byIp.entries()].map(([ip, nodes]) => ({ ip, nodes }));
110
161
  if (groups.length === 1) {
111
- log(`[warp-pool] ⚠ 出口不分散:${groups[0].nodes.length} 个节点共用同一个出口 ${groups[0].ip}(免费版 WARP 的 IPv4 是共享 NAT)`);
162
+ log(`[warp-pool] ⚠ 出口不分散:${groups[0].nodes.length} 条隧道同时在线只有 1 个出口地址(${groups[0].ip}):免费版 WARP 的 IPv4 是共享 NAT,多开隧道换不到多份名额;多份名额靠撞 429 时的身份轮换`);
112
163
  } else if (groups.length > 1) {
113
- log(`[warp-pool] 出口分散:${groups.length} 个不同地址 / ${groups.reduce((a, g) => a + g.nodes.length, 0)} 个节点`);
164
+ log(`[warp-pool] 出口分散:${groups.length} 个出口地址 / ${groups.reduce((a, g) => a + g.nodes.length, 0)} 条隧道同时在线(同时在线就有 ${groups.length} 份上游名额)`);
114
165
  }
115
- if (failed) log(`[warp-pool] ${failed} 个节点出口取证失败(不影响其进池)`);
166
+ if (failed) log(`[warp-pool] ${failed} 个节点出口取证失败(不影响进池,只影响轮换时「出口到底变没变」的比对)`);
116
167
  return groups;
117
168
  }
@@ -20,9 +20,21 @@ export function headerValue(headers, name) {
20
20
  return "";
21
21
  }
22
22
 
23
+ /** 让 dispatcher 看见上游状态码:复制一层 handler,只截 onHeaders,其余方法原样转发(全部调用都落在副本上,this 一致)。 */
24
+ export function tapHandler(handler, tap) {
25
+ if (!tap || !handler || typeof handler !== "object") return handler;
26
+ const out = Object.assign(Object.create(Object.getPrototypeOf(handler)), handler);
27
+ const orig = handler.onHeaders;
28
+ out.onHeaders = function (statusCode, headers, resume, statusText) {
29
+ try { tap(statusCode); } catch {}
30
+ return typeof orig === "function" ? orig.call(this, statusCode, headers, resume, statusText) : undefined;
31
+ };
32
+ return out;
33
+ }
34
+
23
35
  /**
24
36
  * @param {{ nodes?: Array<{port:number, uri:string}>, strategy?: "session"|"request",
25
- * cooldownMs?: number, now?: () => number, log?: (s:string)=>void,
37
+ * cooldownMs?: number, now?: () => number, log?: (s:string)=>void, onStatus?: (status:number, port:number)=>void,
26
38
  * agentOptions?: object, selector?: object }} [opts]
27
39
  */
28
40
  export function createRotatingDispatcher({
@@ -31,6 +43,7 @@ export function createRotatingDispatcher({
31
43
  cooldownMs = 600_000,
32
44
  now = Date.now,
33
45
  log = () => {},
46
+ onStatus = null,
34
47
  agentFactory,
35
48
  fallbackAgent,
36
49
  agentOptions = { connections: 8, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000, pipelining: 1 },
@@ -40,7 +53,7 @@ export function createRotatingDispatcher({
40
53
  if (typeof ProxyAgent !== "function") throw new Error("undici ProxyAgent 不可用:无法建立出口池");
41
54
  // 空池兜底:直连(等价 R3 里被顶掉的 keepAlive 池)。
42
55
  const fallback = fallbackAgent !== undefined ? fallbackAgent : typeof Agent === "function" ? new Agent(agentOptions) : null;
43
- let list = [];
56
+ let list = build(nodes);
44
57
 
45
58
  function build(nodesIn) {
46
59
  return (nodesIn || []).map((n, i) => ({
@@ -96,11 +109,11 @@ export function createRotatingDispatcher({
96
109
  throw new Error("warp-pool:节点池为空且无直连兜底");
97
110
  }
98
111
  const avail = availableIndexes();
99
- // 全池冷却时兜底用第一个,避免直接失败(宁可撞限流也别把请求打死)。
100
- if (!avail.length) return list[0].agent.dispatch(opts, handler);
112
+ // 全池冷却 ⇒ 直连兜底优先:硬派给已知不可用的节点 = 把请求挂到超时。
113
+ if (!avail.length) return (fallback || list[0].agent).dispatch(opts, handler);
101
114
  const sessionKey = strategy === "session" ? headerValue(opts?.headers, SESSION_HEADER) : "";
102
- const idx = selector.pick(avail, sessionKey);
103
- return list[idx].agent.dispatch(opts, handler);
115
+ const e = list[selector.pick(avail, sessionKey)];
116
+ return e.agent.dispatch(opts, tapHandler(handler, onStatus ? (sc) => onStatus(sc, e.port) : null));
104
117
  },
105
118
 
106
119
  /** 运行期换上一批出口(bootstrap 完成后调用);旧 Agent 池关闭。 */