mslxdff 0.1.245 → 0.1.246

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/package.json +1 -1
  2. package/src/chat-pipeline/terminal-verdict.js +4 -4
  3. package/src/cli/commands/daemon.js +1 -1
  4. package/src/cli/index.js +0 -5
  5. package/src/daemon.js +3 -3
  6. package/src/runtime/bootstrap.js +1 -1
  7. package/src/runtime/proc-info.js +2 -2
  8. package/src/runtime/providers-setup.js +2 -4
  9. package/src/server.js +3 -3
  10. package/src/upstream-engine/index.js +2 -17
  11. package/src/upstream.js +1 -61
  12. package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
  13. package/src/cli/commands/multi-ip-pool-test.js +0 -136
  14. package/src/cli/commands/multi-ip-pool.js +0 -83
  15. package/src/cli/commands/v6-pool.js +0 -123
  16. package/src/multi-ip-pool/config.js +0 -157
  17. package/src/multi-ip-pool/diagnose.js +0 -159
  18. package/src/multi-ip-pool/dial.js +0 -179
  19. package/src/multi-ip-pool/dispatch.js +0 -244
  20. package/src/multi-ip-pool/index.js +0 -174
  21. package/src/multi-ip-pool/nodes-fetch.js +0 -160
  22. package/src/multi-ip-pool/nodes.js +0 -205
  23. package/src/multi-ip-pool/report.js +0 -162
  24. package/src/multi-ip-pool/selftest.js +0 -168
  25. package/src/multi-ip-pool/standby.js +0 -157
  26. package/src/multi-ip-pool/tunnel.js +0 -291
  27. package/src/v6pool/claim.js +0 -157
  28. package/src/v6pool/config.js +0 -65
  29. package/src/v6pool/detect.js +0 -130
  30. package/src/v6pool/index.js +0 -157
  31. package/src/v6pool/prefix.js +0 -140
  32. package/src/v6pool/probe.js +0 -40
  33. package/src/v6pool/report.js +0 -104
  34. package/src/v6pool/rotate.js +0 -263
  35. package/src/v6pool/selftest.js +0 -178
  36. package/src/warp-pool/config.js +0 -74
  37. package/src/warp-pool/health.js +0 -106
  38. package/src/warp-pool/identity.js +0 -141
  39. package/src/warp-pool/index.js +0 -358
  40. package/src/warp-pool/keys.js +0 -29
  41. package/src/warp-pool/nodes.js +0 -95
  42. package/src/warp-pool/ports.js +0 -160
  43. package/src/warp-pool/register.js +0 -113
  44. package/src/warp-pool/report.js +0 -232
  45. package/src/warp-pool/revive.js +0 -168
  46. package/src/warp-pool/rotate.js +0 -214
  47. package/src/warp-pool/strategy.js +0 -43
  48. package/src/warp-pool/verdict.js +0 -69
  49. package/src/warp-pool/wireproxy.js +0 -162
@@ -1,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
- }
@@ -1,160 +0,0 @@
1
- // warp-pool · 端口占用与「上一代进程残留」清理(2026-10-16 真机事故后新增;2026-10-17 七次修订)。
2
- //
3
- // 事故一(0.1.179):daemon 被强杀时 `process.once("exit")` 钩子不执行,wireproxy 子进程变孤儿继续占着端口。
4
- // 事故二(七次修订,真机铁证):**两个 daemon 同时活着**(一个从昨天起活了一整天),两者都用同一份
5
- // `node-<port>.conf` 判「本池残留」⇒ 互相把对方的**活隧道**当残留杀掉 ⇒ 无限互杀、池永远不稳。
6
- // 两条教训写进判据:
7
- // ① 父进程是**另一个活着的 daemon** ⇒ 那不是残留,是「别人的活隧道」,清了就是互杀 ⇒ 不许清,只报出来;
8
- // ② 「SIGKILL 都清不掉」多半是**僵尸**(state=Z:kill 无效,但 kill(pid,0) 仍返回成功)⇒ 僵尸算已死。
9
- import { connect } from "node:net";
10
- import { defaultCmdline, defaultPids, defaultStat, isZombie, otherDaemonParent, parseCmdline, pidAlive } from "../runtime/proc-info.js";
11
- import { nodePaths } from "./nodes.js";
12
- import { fmtReclaim } from "./report.js";
13
-
14
- // /proc 读取层已上移到 runtime/proc-info.js(daemon 侧也要用同一套判据);这里原样再导出,既有调用方路径不变。
15
- export { parseCmdline, pidAlive };
16
-
17
- /**
18
- * 该进程是不是「正在跑我们这条 conf」的 wireproxy。
19
- * 判据刻意收紧(argv 里得有 wireproxy 这个可执行文件名,且 conf 路径逐字相等),
20
- * 免得误杀别人的进程。缺一不可。
21
- */
22
- export function isOurWireproxy(argv, conf) {
23
- const list = Array.isArray(argv) ? argv : [];
24
- const isBin = list.some((a) => /(^|[\\/])wireproxy(\.exe)?$/i.test(a));
25
- return isBin && list.includes(conf);
26
- }
27
-
28
- /**
29
- * 清掉占着该端口那条 conf 的**真残留**:自家丢引用的子进程、父进程已死的孤儿。
30
- * 不动的三类:僵尸(kill 无效,也不是真占用者)、**父进程是另一个活 daemon 的活隧道**(清了就是互杀)、
31
- * argv 不匹配的别人的进程。
32
- * 非 Linux 直接返回空(读不到 /proc,别拿别的平台做危险猜测)。
33
- * @returns {Promise<{scanned:number, killed:number[], zombies:number[],
34
- * foreign:Array<{pid:number,ppid:number,aliveSec:number|null}>, waitedMs:number, gone:boolean}>}
35
- */
36
- export async function reclaimPort({
37
- dir,
38
- port,
39
- log = () => {},
40
- platform = process.platform,
41
- pids = defaultPids,
42
- cmdline = defaultCmdline,
43
- stat = defaultStat,
44
- kill = process.kill,
45
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
46
- waitRounds = 15,
47
- } = {}) {
48
- const startedAt = Date.now();
49
- const shape = (scanned, killed, zombies, foreign) => ({
50
- scanned,
51
- killed,
52
- zombies,
53
- foreign,
54
- waitedMs: Date.now() - startedAt,
55
- gone: !killed.some((p) => pidAlive(p, kill)),
56
- });
57
- if (platform !== "linux") return shape(0, [], [], []);
58
-
59
- const conf = nodePaths(dir, port).conf;
60
- const all = pids();
61
- const zombies = [];
62
- const foreign = [];
63
- const targets = [];
64
- for (const pid of all) {
65
- if (!isOurWireproxy(cmdline(pid), conf)) continue;
66
- const st = stat(pid);
67
- if (st && isZombie(st.state)) {
68
- zombies.push(pid);
69
- continue;
70
- }
71
- const other = otherDaemonParent(pid, { stat, cmdline, kill });
72
- if (other) {
73
- foreign.push({ pid, ppid: other.pid, aliveSec: other.aliveSec });
74
- continue;
75
- }
76
- targets.push(pid);
77
- }
78
-
79
- if (targets.length) {
80
- for (const pid of targets) {
81
- try {
82
- kill(pid, "SIGTERM");
83
- } catch {}
84
- }
85
- for (let i = 0; i < waitRounds && targets.some((p) => pidAlive(p, kill)); i++) await sleep(100);
86
- for (const pid of targets.filter((p) => pidAlive(p, kill))) {
87
- try {
88
- kill(pid, "SIGKILL");
89
- } catch {}
90
- }
91
- }
92
- const r = shape(all.length, targets, zombies, foreign);
93
- log(fmtReclaim({ port, ...r }));
94
- return r;
95
- }
96
-
97
- /** 该端口是否已有人在监听(TCP 连得上 = 占用;连不上/被拒 = 空闲)。 */
98
- export function portInUse(port, { host = "127.0.0.1", timeoutMs = 800, connectFn = connect } = {}) {
99
- return new Promise((resolve) => {
100
- let done = false;
101
- const sock = connectFn({ host, port });
102
- const finish = (v) => {
103
- if (done) return;
104
- done = true;
105
- try {
106
- sock.destroy();
107
- } catch {}
108
- resolve(v);
109
- };
110
- sock.setTimeout(timeoutMs, () => finish(false));
111
- sock.once("connect", () => finish(true));
112
- sock.once("error", () => finish(false));
113
- });
114
- }
115
-
116
- /**
117
- * 等端口真正空出来(SIGTERM 之后套接字要几百毫秒才释放,这期间连得上 ≠ 别人的进程占着)。
118
- * 返回 true = 空了;false = 等了 rounds 轮还是被占。
119
- */
120
- export async function waitPortFree(port, { inUse = portInUse, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), rounds = 12, stepMs = 250 } = {}) {
121
- for (let i = 0; i < rounds; i++) {
122
- if (!(await inUse(port))) return true;
123
- await sleep(stepMs);
124
- }
125
- return !(await inUse(port));
126
- }
127
-
128
- /**
129
- * 端口占用者取证:谁在监听——本池残留?另一个 daemon 的活隧道?僵尸?别人的进程?还是空闲。
130
- * 「孤儿占端口」「两个 daemon 互杀」这两类事故唯一能一眼看出结论的读数:只报「占用 / 拒绝再起」是查不出问题的。
131
- * @returns {Promise<{busy:boolean, ours:number[], zombies:number[],
132
- * foreign:Array<{pid:number,ppid:number,aliveSec:number|null}>, others:Array<{pid:number,name:string}>}>}
133
- */
134
- export async function portOwner({ dir, port, platform = process.platform, pids = defaultPids, cmdline = defaultCmdline, stat = defaultStat, kill = process.kill, inUse = portInUse } = {}) {
135
- const busy = await inUse(port);
136
- const out = { busy, ours: [], zombies: [], foreign: [], others: [] };
137
- if (!busy || platform !== "linux") return out;
138
- const conf = nodePaths(dir, port).conf;
139
- for (const pid of pids()) {
140
- const argv = cmdline(pid);
141
- if (!argv.length) continue;
142
- if (isOurWireproxy(argv, conf)) {
143
- const st = stat(pid);
144
- if (st && isZombie(st.state)) {
145
- out.zombies.push(pid);
146
- continue;
147
- }
148
- const other = otherDaemonParent(pid, { stat, cmdline, kill });
149
- if (other) {
150
- out.foreign.push({ pid, ppid: other.pid, aliveSec: other.aliveSec });
151
- continue;
152
- }
153
- out.ours.push(pid);
154
- continue;
155
- }
156
- // 非 root 看不到别人的 listen 表,退一步:命令行里带着这个端口号的进程也值得报出来
157
- if (argv.some((a) => String(a).includes(`:${port}`))) out.others.push({ pid, name: String(argv[0]).split("/").pop() });
158
- }
159
- return out;
160
- }
@@ -1,113 +0,0 @@
1
- // warp-pool · 注册 Cloudflare WARP 设备(换回一份 WireGuard 配置 + 设备 token)。
2
- // API 形状与限流纪律来自同类项目实战(catoncat/warp-pool)+ 本机实测(200 响应字段已核对)。
3
- import crypto from "node:crypto";
4
- import { compatFetch } from "../compat.js";
5
- import { generateWgKeyPair } from "./keys.js";
6
-
7
- export const REG_URL = "https://api.cloudflareclient.com/v0a2483/reg";
8
- // 客户端版本头:Cloudflare 换版后旧值会被 403/400 拒(届时换成最新 app 版本号)。
9
- export const CF_CLIENT_VERSION = "a-6.81-2410032352.1";
10
- export const MAX_ATTEMPTS = 4;
11
-
12
- export function buildRegisterBody({ publicKey, uuid = crypto.randomUUID(), now = Date.now } = {}) {
13
- return {
14
- install_id: "",
15
- tos: new Date(now()).toISOString().replace(/\.\d{3}Z$/, ".000Z"),
16
- key: publicKey,
17
- fcm_token: "",
18
- serial_number: uuid,
19
- type: "Android",
20
- model: "PC",
21
- locale: "zh_CN",
22
- };
23
- }
24
-
25
- /** 从注册响应抽建隧道所需字段;缺 config/id/token 视为失败。 */
26
- export function parseRegisterResponse(json = {}) {
27
- const cfg = json?.config;
28
- const peer = Array.isArray(cfg?.peers) ? cfg.peers[0] : null;
29
- const addr = cfg?.interface?.addresses || {};
30
- if (!peer?.public_key || !addr.v4 || !json?.id || !json?.token) {
31
- throw new Error("注册响应缺少必要字段(config.peers/interface.addresses/id/token)");
32
- }
33
- return {
34
- id: String(json.id),
35
- token: String(json.token),
36
- v4: String(addr.v4),
37
- v6: String(addr.v6 || ""),
38
- peerKey: String(peer.public_key),
39
- endpoint: String(peer?.endpoint?.host || "engage.cloudflareclient.com:2408"),
40
- };
41
- }
42
-
43
- /**
44
- * 退避:429/403 走长退避(短退避会被续封,同类项目 2026-08-18 事故记录),
45
- * 其它错误(网络层)走短随机退避。
46
- */
47
- export function backoffMs(status, attempt, rnd = Math.random) {
48
- if (status === 429 || status === 403) return Math.round(60_000 + rnd() * 60_000);
49
- return Math.round(12_000 * attempt + 5_000 + rnd() * 3_000);
50
- }
51
-
52
- /**
53
- * 注册一个 WARP 设备。成功返回 { id, token, v4, v6, peerKey, endpoint, privateKey, publicKey }。
54
- * @param {{ fetchImpl?: Function, keyPair?: {privateKey:string,publicKey:string}, uuid?: string,
55
- * now?: () => number, sleep?: (ms:number)=>Promise<void>, log?: (s:string)=>void,
56
- * maxAttempts?: number, timeoutMs?: number }} [opts]
57
- */
58
- export async function registerWarpDevice({
59
- fetchImpl = compatFetch,
60
- keyPair = generateWgKeyPair(),
61
- uuid = crypto.randomUUID(),
62
- now = Date.now,
63
- sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
64
- log = () => {},
65
- maxAttempts = MAX_ATTEMPTS,
66
- timeoutMs = 30_000,
67
- } = {}) {
68
- const body = buildRegisterBody({ publicKey: keyPair.publicKey, uuid, now });
69
- let lastStatus = 0;
70
- let lastErr = null;
71
-
72
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
73
- if (attempt > 1) {
74
- const wait = backoffMs(lastStatus, attempt);
75
- log(`[warp-pool] 注册重试 ${attempt}/${maxAttempts}(上次 HTTP ${lastStatus || "网络错误"}),退避 ${Math.round(wait / 1000)}s`);
76
- await sleep(wait);
77
- }
78
- let res;
79
- try {
80
- res = await fetchImpl(REG_URL, {
81
- method: "POST",
82
- headers: {
83
- "Content-Type": "application/json",
84
- "CF-Client-Version": CF_CLIENT_VERSION,
85
- Accept: "application/json",
86
- },
87
- body: JSON.stringify(body),
88
- signal: AbortSignal.timeout(timeoutMs),
89
- });
90
- } catch (e) {
91
- lastStatus = 0;
92
- lastErr = e;
93
- continue;
94
- }
95
- lastStatus = res.status;
96
- const text = await res.text().catch(() => "");
97
- if (res.status !== 200) {
98
- lastErr = new Error(`HTTP ${res.status}: ${String(text).slice(0, 200)}`);
99
- continue;
100
- }
101
- let json;
102
- try {
103
- json = JSON.parse(text);
104
- } catch (e) {
105
- lastErr = e;
106
- continue;
107
- }
108
- return { ...parseRegisterResponse(json), privateKey: keyPair.privateKey, publicKey: keyPair.publicKey };
109
- }
110
- throw new Error(
111
- `WARP 注册失败(已试 ${maxAttempts} 次,最后 HTTP ${lastStatus || "网络错误"}):${String(lastErr?.message || lastErr)}`,
112
- );
113
- }
@@ -1,232 +0,0 @@
1
- // warp-pool · 日志与自检报告(2026-10-16 四轮返工后新增;2026-10-17 七次修订补「同机其它 daemon」)。
2
- //
3
- // 为什么单独一个模块(五轮返工的教训):日志若只说「失败了」、不说「谁干的、为什么」,就只能靠猜——
4
- // 第 1 轮猜孤儿占端口、第 2 轮分不清旧 daemon 子进程与刚被杀还没释放的套接字、第 3 轮分不清握手未完成与上游拒绝、
5
- // 第 4~5 轮对着「退出 / 已复活」循环完全看不出真凶是**同机另一个 daemon**(两边都把对方的活隧道当残留杀)。
6
- // 故把可判定信息一次摆全:配置快照、**同机其它 daemon**、端口占用者(pid+ppid+命令行+本池残留/别人的活隧道/僵尸)、
7
- // 身份文件、每条隧道完整生命周期(清理→写配置→spawn pid→探活耗时/状态/错误→退出码/信号/存活时长/末行)、池子结论与「下一步敲什么命令」。
8
- import { fmtAlive } from "../runtime/proc-info.js";
9
- export const MARK = { step: "▸", ok: "✓", fail: "✗", warn: "⚠" };
10
-
11
- const KB = (n) => (n >= 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)}MB` : `${Math.round(n / 1024)}KB`);
12
-
13
- /** 人类可读时长:412ms / 1.8s / 2m3s。 */
14
- export function dur(ms) {
15
- const n = Number(ms);
16
- if (!Number.isFinite(n) || n < 0) return "?";
17
- if (n < 1000) return `${Math.round(n)}ms`;
18
- if (n < 60_000) return `${(n / 1000).toFixed(1)}s`;
19
- return `${Math.floor(n / 60_000)}m${Math.round((n % 60_000) / 1000)}s`;
20
- }
21
-
22
- /** 配置快照:一行说清这次到底按什么参数在跑。 */
23
- export function fmtConfig(cfg) {
24
- return `[warp-pool] ${MARK.step} 启动自检:开关=${cfg.enabled ? "开" : "关"} 模式=${cfg.mode} 端口=${cfg.ports.join(",")} 策略=${cfg.strategy} 轮换=${cfg.rotate ? "开" : "关"} 冷却=${Math.round(cfg.cooldownMs / 1000)}s`;
25
- }
26
-
27
- /** 目录与二进制(带体积,顺手能看出二进制是不是被下坏了)。 */
28
- export function fmtEnv({ dir, binPath, binSize }) {
29
- const bin = binPath ? `${binPath}(${binSize ? KB(binSize) : "体积未知"})` : "未就绪";
30
- return `[warp-pool] ${MARK.step} 目录=${dir} 二进制=${bin}`;
31
- }
32
-
33
- /** 身份文件盘点:哪个端口有 id+token+私钥,哪个缺。 */
34
- export function fmtIdentities(nodes, ports) {
35
- const have = new Set(nodes.map((n) => n.port));
36
- const parts = ports.map((p) => `:${p}${have.has(p) ? " ✓" : " ✗缺"}`);
37
- return `[warp-pool] ${MARK.step} 身份文件 ${nodes.length}/${ports.length}:${parts.join(" ")}(✓=有 id+token+私钥,✗缺=现场注册)`;
38
- }
39
-
40
- /** 「另一个 daemon(pid 1558055,已运行 6h12m) 的活隧道 pid 33」——两个 daemon 互杀这类事故唯一能一眼看出结论的读数。 */
41
- export function fmtForeign(foreign = []) {
42
- return (foreign || []).map((f) => `另一个 daemon(pid ${f.ppid}${f.aliveSec != null ? `,已运行 ${fmtAlive(f.aliveSec)}` : ""}) 的活隧道 pid ${f.pid}`).join(";");
43
- }
44
-
45
- /** 单端口占用者:区分「本池残留」「另一个 daemon 的活隧道」「僵尸」「别人的进程」「空闲」——两轮返工缺的就是这一行。 */
46
- export function fmtPortState({ port, busy, ours = [], zombies = [], foreign = [], others = [] }) {
47
- if (!busy) return `[warp-pool] :${port} 空闲`;
48
- if (foreign.length) return `[warp-pool] :${port} 被 ${fmtForeign(foreign)} 占着:那是**别人的活隧道**,本进程不碰它(清了就是和另一个 daemon 互杀)`;
49
- if (ours.length) return `[warp-pool] :${port} 被 pid ${ours.join(",")} 占:本池 wireproxy 残留(上一代 daemon 留下的孤儿)→ 起隧道前清理`;
50
- if (zombies.length) return `[warp-pool] :${port} 有本池僵尸 pid ${zombies.join(",")}(已死等收尸:kill 无效,也不再占端口)`;
51
- const who = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "未知进程";
52
- return `[warp-pool] :${port} 被 ${who} 占:非本池进程 → 不碰它,这个端口本轮跳过(查明:ss -ltnp | grep :${port})`;
53
- }
54
-
55
- /** 清理残留的结果:扫了多少进程、命中谁、等了多久、清掉没、以及「另一个 daemon 占着」这种不该动的情形。 */
56
- export function fmtReclaim({ port, scanned, killed = [], zombies = [], foreign = [], waitedMs = 0, gone = true }) {
57
- const parts = [`扫 ${scanned} 个进程`];
58
- if (killed.length) parts.push(`命中本池残留 pid ${killed.join(",")} → ${gone ? "已清掉" : "清不掉(SIGTERM/SIGKILL 都试过)"}`);
59
- else parts.push("无本池残留");
60
- if (zombies.length) parts.push(`僵尸 pid ${zombies.join(",")}(已死等收尸,kill 无效)`);
61
- if (foreign.length) parts.push(`${MARK.warn} ${fmtForeign(foreign)}——不碰它(清了就是和另一个 daemon 互杀)`);
62
- const wait = killed.length ? `(等了 ${dur(waitedMs)})` : "";
63
- return `[warp-pool] :${port} ${MARK.step} 清理残留:${parts.join(";")}${wait}`;
64
- }
65
-
66
- /** 写出的 WireGuard 配置(体积能看出是不是缺字段:缺私钥那次就是 338B vs 373B)。 */
67
- export function fmtConf({ port, conf, bytes }) {
68
- return `[warp-pool] :${port} ${MARK.step} 写配置 ${conf}(${bytes}B,0600)`;
69
- }
70
-
71
- /** 起隧道:pid + 完整命令行,事后可 `ps -p <pid>` 对账。 */
72
- export function fmtSpawn({ port, pid, binPath, conf }) {
73
- return `[warp-pool] :${port} ${MARK.step} 起隧道 pid=${pid ?? "?"}:${binPath} -c ${conf}`;
74
- }
75
-
76
- /** 探活单次:URL、状态、耗时、错误原文——第 3 轮返工缺的就是「到底哪一步不通」。 */
77
- export function fmtProbe({ port, attempt, attempts, url, ok, status, error, ms }) {
78
- const head = `[warp-pool] :${port} ${ok ? MARK.ok : MARK.fail} 探活 ${attempt}/${attempts}:GET ${url} 经 127.0.0.1:${port}`;
79
- const tail = ok
80
- ? `HTTP ${status}(${dur(ms)})→ 进池`
81
- : `HTTP ${status || 0}(${dur(ms)})${error ? ` 错误=${String(error).slice(0, 160)}` : ""} → 稍后重试`;
82
- return `${head} → ${tail}`;
83
- }
84
-
85
- /** 探活彻底失败:说清「不进池」的后果与自检命令,而不是只丢一句「没探通」。 */
86
- export function fmtProbeGiveUp({ port, attempts, url }) {
87
- return `[warp-pool] :${port} ${MARK.fail} 探活 ${attempts} 次都不通 ⇒ 不进池(进程可能在跑但隧道转不动流量,进池会把请求挂到超时,比直连更糟)。自检:curl --proxy http://127.0.0.1:${port} ${url}`;
88
- }
89
-
90
- /** 隧道退出:退出码/信号/存活时长/pid/是否外部终止/stderr 末行(含「谁杀的」这一层推断)。 */
91
- export function fmtExit({ port, code, signal, pid, aliveMs, tail }) {
92
- const how = signal ? `信号 ${signal}` : `退出码 ${code}`;
93
- const killed = signal ? "本进程未调用 stop ⇒ 外部终止(别人杀了它 / 系统回收)" : "进程自行退出(多为配置被拒或端口被占)";
94
- return `[warp-pool] :${port} ${MARK.fail} 隧道退出:${how}|存活 ${aliveMs == null ? "?" : dur(aliveMs)}|pid ${pid ?? "?"}|${killed}|末行="${tail || "没有输出"}"`;
95
- }
96
-
97
- /** 端口最终被占着、拒绝硬起时,把「另一个 daemon 的活隧道 / 本池残留 / 僵尸 / 别人的进程 / 套接字还没释放」分开说。 */
98
- export function fmtPortBlocked({ port, ours = [], zombies = [], foreign = [], others = [] }) {
99
- const foreignTxt = foreign.length ? `${fmtForeign(foreign)}(清了就是互杀,故本进程不碰)——先停掉那个 daemon:kill ${[...new Set(foreign.map((f) => f.ppid))].join(" ")}` : "";
100
- const oursTxt = ours.length ? `本池残留 pid ${ours.join(",")}(SIGTERM/SIGKILL 都没清掉)` : "";
101
- const zombieTxt = zombies.length ? `本池僵尸 pid ${zombies.join(",")}(已死等收尸,不占端口)` : "";
102
- const othTxt = others.length ? others.map((o) => `pid ${o.pid} ${o.name}`).join("、") : "";
103
- const who = [foreignTxt, oursTxt, zombieTxt, othTxt].filter(Boolean).join(";") || "未知进程(已等套接字释放仍连得上)";
104
- return `[warp-pool] :${port} ${MARK.fail} 端口被 ${who} 占着,拒绝再起(硬起只会 bind 失败、无限重启刷屏)。查明:ss -ltnp | grep :${port}`;
105
- }
106
-
107
- /** 池子结论:入池几个、各端口出口是什么、几种不同地址——一句话判断「WARP 到底有没有在用」。 */
108
- export function fmtPool({ pooled, total, exits }) {
109
- const head = `[warp-pool] ${MARK.step} 出口池就绪:${pooled}/${total} 条入池`;
110
- const ipOf = new Map();
111
- for (const g of exits || []) for (const p of g.nodes || []) if (g.ip) ipOf.set(p, g.ip);
112
- if (!ipOf.size) return `${head}|出口地址未知(CF trace 取证失败,不影响使用)`;
113
- const table = [...ipOf.entries()].map(([p, ip]) => `:${p}=${ip}`).join(" ");
114
- return `${head}|出口:${table}|不同地址 ${new Set(ipOf.values()).size} 个`;
115
- }
116
-
117
- /**
118
- * 身份指纹:每个端口的短设备 id / WARP 内网 IPv6 尾段 / 对端端点 / 注册时间。
119
- * 为什么必须有:4 条隧道只拿到 2 个出口地址时(真机 2026-10-17),只有这组读数能区分
120
- * 「设备本身被写重复了」和「CF 把不同设备折叠成一个出口」——没有它就只能猜。
121
- *
122
- * ⚠ 判据**不看 IPv4**:`172.16.0.2` 是 WARP 的**固定接口地址**(6 次实测全是它:组长 4 台 +
123
- * 本机 2 台;公开的 wgcf 生成配置里同样是 `Address = 172.16.0.2/32`),按它判「重复」是误报
124
- * (2026-10-17 十一次修订前确实这么误报过)。每台设备真正唯一的是 **IPv6**(`2606:4700:110:…`)
125
- * 与密钥对,所以指纹打 v6 尾段。
126
- */
127
- /** v6 尾段做指纹(`2606:4700:110:81b5:56ec:72b3:9542:e3de` → `…:9542:e3de`);缺了给「无」。 */
128
- export function v6Tail(v6) {
129
- const s = String(v6 || "");
130
- if (!s) return "无";
131
- const parts = s.split(":");
132
- return parts.length > 2 ? `…:${parts.slice(-2).join(":")}` : s;
133
- }
134
- export function fmtIdentityDetail(nodes = []) {
135
- const out = [];
136
- const byId = new Map();
137
- const byEp = new Map();
138
- for (const n of nodes || []) {
139
- if (n?.id) byId.set(n.id, [...(byId.get(n.id) || []), n.port]);
140
- if (n?.endpoint) byEp.set(n.endpoint, [...(byEp.get(n.endpoint) || []), n.port]);
141
- out.push(`[warp-pool] :${n?.port} 设备=${n?.id ? String(n.id).slice(0, 8) : "无id"} 内网v6=${v6Tail(n?.v6)} 端点=${n?.endpoint || "?"} 注册=${String(n?.registeredAt || "?").slice(0, 19)}`);
142
- }
143
- const dupId = [...byId.entries()].filter(([, p]) => p.length > 1).map(([k, p]) => `${String(k).slice(0, 8)} ← :${p.join(",:")}`).join(";");
144
- if (dupId) out.push(`[warp-pool] ${MARK.warn} 设备 id 重复:${dupId} —— 同一个 WARP 设备被写到多个端口,出口必然相同;删掉重复端口的 node-<port>.json 再重启,它会重新注册`);
145
- // 端点全同 = 大家都从同一个 CF 入口出去(CF 对每次注册都回同一个 hostname):出口撞车由此而来
146
- const total = (nodes || []).length;
147
- const sameEp = [...byEp.entries()].find(([, p]) => total > 1 && p.length === total);
148
- if (sameEp) out.push(`[warp-pool] ${MARK.step} ${total} 条隧道都用同一个端点(${sameEp[0]})—— CF 对每次注册都回同一个 hostname,故大家都从同一个 CF 入口出去,出口地址撞车由此而来(想分散:把 node-<port>.json 的 endpoint 改成别的 WARP 入口 IP 后重启)`);
149
- return out;
150
- }
151
-
152
- /**
153
- * 出口分散结论:几个出口地址 / 几条隧道在线、撞车的是谁、以及「想多要一份该怎么办」。
154
- * 措辞必须自洽:免费 WARP 的 IPv4 是共享 NAT,**隧道数 ≠ 上游名额数**——旧措辞
155
- * 「同时在线就有 N 份名额」与紧邻的「出口不分散」分支自相矛盾(真机 4 条隧道只拿到 2 个出口)。
156
- */
157
- export function fmtExits({ groups = [], dir = "" } = {}) {
158
- if (!groups.length) return "";
159
- const online = groups.reduce((a, g) => a + g.nodes.length, 0);
160
- if (groups.length === 1) {
161
- return `[warp-pool] ${MARK.warn} 出口不分散:${online} 条隧道同时在线只有 1 个出口地址(${groups[0].ip}):免费版 WARP 的 IPv4 是共享 NAT,多开隧道换不到多份名额;多份名额靠撞 429 时的身份轮换`;
162
- }
163
- const collide = groups.filter((g) => g.nodes.length > 1);
164
- const drop = collide.flatMap((g) => g.nodes.slice(1));
165
- const base = dir || "$HOME/.config/mslxdff/warp-pool";
166
- const next = drop.length
167
- ? `|${MARK.warn} 撞车:${collide.map((g) => `${g.ip} ← :${g.nodes.join(",:")}`).join(";")}(它们共用同一份上游名额)⇒ 想多要一份:rm -f ${drop.map((p) => `${base}/node-${p}.json`).join(" ")} && mslxdff -restart(重新注册换出口;共享 NAT 下常换不动,要试几次)`
168
- : "";
169
- return `[warp-pool] ${MARK.step} 出口分散:${groups.length} 个出口地址 / ${online} 条隧道在线(上游按出口 IP 计名额 ⇒ 最多 ${groups.length} 份;隧道数 ≠ 名额数:免费版 WARP 的 IPv4 是共享 NAT)${next}`;
170
- }
171
-
172
- /** 池空告警:最容易被误读成「功能在跑」,必须把结论与逐条自检命令一起说。 */
173
- export function fmtPoolEmpty({ total, ports }) {
174
- return `[warp-pool] ${MARK.warn} 没有一条隧道探通 ⇒ 池为空,全部请求照旧直连(不影响可用性,但 WARP 出口等于没启用)。逐条自检:for p in ${ports.join(" ")}; do echo -n ":$p "; curl -s --max-time 8 --proxy http://127.0.0.1:$p https://cloudflare.com/cdn-cgi/trace | grep '^ip='; done`;
175
- }
176
-
177
- /** 健康检查一轮:只在状态变化/心跳时打(每分钟一行会把日志淹掉)。 */
178
- export function fmtHealth({ healthy, total, slowestMs, problems = [] }) {
179
- if (!problems.length) return `[warp-pool] ${MARK.ok} 健康检查:${healthy}/${total} 通(最慢 ${dur(slowestMs)})`;
180
- const detail = problems
181
- .map((p) => `:${p.port} 第 ${p.fails} 次不通(HTTP ${p.status || 0}${p.error ? ` ${String(p.error).slice(0, 80)}` : ""},${dur(p.ms)})冷却 ${Math.round(p.cooldownMs / 1000)}s`)
182
- .join(";");
183
- return `[warp-pool] ${MARK.warn} 健康检查:${healthy}/${total} 通|不通:${detail}`;
184
- }
185
-
186
- /** 心跳:一行给全部关键状态,省得「日志翻半天不知道现在啥情况」。 */
187
- export function fmtHeartbeat({ alive, total, pooled, exits, cooling, lastAllDown }) {
188
- const ips = [...new Set((exits || []).map((e) => e.ip).filter(Boolean))];
189
- return `[warp-pool] ${MARK.step} 心跳:隧道 ${alive}/${total} 活|入池 ${pooled}|出口 ${ips.length ? `${ips.length} 种(${ips.join(",")})` : "未知"}|冷却中 ${cooling}|全池不通 ${lastAllDown || "未发生"}`;
190
- }
191
-
192
- /** 同机其它 daemon:两个 daemon 抢同一批端口会互杀(真机事故根因),必须一次说清并给出精确 kill 命令。 */
193
- export function fmtSiblings({ list = [] } = {}) {
194
- if (!list.length) return "";
195
- const who = list.map((d) => `pid ${d.pid}(父 ${d.ppid}${d.aliveSec != null ? `,已运行 ${fmtAlive(d.aliveSec)}` : ""})`).join(";");
196
- return `[warp-pool] ${MARK.warn} 同机还有 ${list.length} 个别的 mslxdff daemon 在跑:${who} —— 它也在管同一个出口池目录,两边会把对方的隧道当「残留」杀掉(本池每个 conf 只允许一个拥有者)。先停掉它:kill ${list.map((d) => d.pid).join(" ")}`;
197
- }
198
-
199
- /**
200
- * 启动自检块:一次性把「这次按什么参数跑、二进制在哪多大、磁盘上有几个身份、端口分别被谁占着」打全。
201
- * 第 1 轮返工只看到「退出 code=1 / 已复活」循环,就是缺这一块——真因(孤儿占端口)当时只能靠猜。
202
- * @param {{ cfg: object, nodes: Array<object>, binPath?: string, binSize?: number,
203
- * ownerOf: (port:number)=>Promise<{busy:boolean, ours:number[], zombies:number[], foreign:Array<object>, others:Array<{pid:number,name:string}>}>,
204
- * log?: Function }} opts
205
- */
206
- export async function startupReport({ cfg, nodes, binPath = "", binSize = 0, ownerOf, log = () => {} }) {
207
- log(fmtConfig(cfg));
208
- log(fmtEnv({ dir: cfg.dir, binPath, binSize }));
209
- log(fmtIdentities(nodes, cfg.ports));
210
- const states = [];
211
- for (const port of cfg.ports) {
212
- try {
213
- states.push({ port, ...(await ownerOf(port)) });
214
- } catch (e) {
215
- states.push({ port, busy: false, ours: [], zombies: [], foreign: [], others: [], error: String(e?.message || e) });
216
- }
217
- }
218
- const busy = states.filter((s) => s.busy);
219
- log(`[warp-pool] ${MARK.step} 端口体检:${busy.length} 个被占 / ${states.length} 个端口`);
220
- for (const s of states) log(fmtPortState(s));
221
- // 另一个 daemon 占着端口 = 两个 daemon 互杀(真机事故根因):必须单独大声报,并给出精确 kill 命令
222
- const foreign = busy.flatMap((s) => s.foreign || []);
223
- if (foreign.length) {
224
- const pids = [...new Set(foreign.map((f) => f.ppid))];
225
- log(`[warp-pool] ${MARK.warn} ${foreign.length} 个端口被 ${fmtForeign(foreign)} 占着 —— 那不是残留,本进程不碰它(清了就是互杀)。若那是上一代的 daemon:kill ${pids.join(" ")}`);
226
- }
227
- const orphans = busy.filter((s) => (s.ours || []).length).length;
228
- if (orphans) log(`[warp-pool] ${MARK.warn} 有 ${orphans} 个端口被本池残留占着(上一代 daemon 被强杀留下的孤儿)——起隧道前逐个清理,日志会写清 pid`);
229
- const zombies = busy.filter((s) => (s.zombies || []).length).length;
230
- if (zombies) log(`[warp-pool] ${MARK.step} 有 ${zombies} 个端口上还有本池僵尸进程(已死等收尸:kill 无效,也不再占端口,无需处理)`);
231
- return states;
232
- }