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,168 +0,0 @@
1
- // warp-pool · 复活与取证(真机事故后新增,2026-10-16)。
2
- //
3
- // 三件事,都来自组长 VPS 上暴露的缺陷:
4
- //
5
- // 1. 隧道退出后必须自愈:原先只有「全池不通」才补拉,一条死隧道会让池静默从 4 降到 3,直到 daemon 重启。
6
- // 2. 入池后必须实测各节点真实出口:WARP 的 IPv4 出口是共享 NAT,「N 设备」很可能折叠成一个出口
7
- // (2026-10-17 组长 VPS 实测:4 条隧道只拿到 2 个出口地址 ⇒ 隧道数 ≠ 上游名额数)。
8
- // 3. 复活必须**真的活下来**(2026-10-16 组长 VPS 事故):`tunnels.has(port)` 只证明「spawn 被调用了」,
9
- // wireproxy 起一条死一条(端口被上一代孤儿进程占着 ⇒ bind 失败 ⇒ 退出码 1),旧逻辑次次判「已复活」
10
- // ⇒ 下一次退出立刻再复活 ⇒ 无限重启刷屏,该端口永远起不来。
11
- //
12
- // 复活只走**磁盘上已有的 conf**:进程挂了 ≠ 设备失效,重注册只会白烧 CF 配额。
13
- import { probeTunnel } from "./wireproxy.js";
14
- import { fmtExits } from "./report.js";
15
-
16
- /** 出口取证目标:Cloudflare 自己的 trace 端点,回 `ip=` 一行(不烧上游 token)。 */
17
- export const EXIT_PROBE_URL = "https://cloudflare.com/cdn-cgi/trace";
18
-
19
- /** 从 cdn-cgi/trace 正文里取出口地址;取不到返回 ""。 */
20
- export function parseExitIp(text) {
21
- const m = String(text || "").match(/^\s*ip=(\S+)\s*$/m);
22
- return m ? m[1] : "";
23
- }
24
-
25
- /** 子进程末行输出当退出原因(wireproxy 的真实报错都在 stderr);没有输出返回 ""。 */
26
- export function tailReason(tail, max = 300) {
27
- const lines = String(tail || "").split("\n").map((s) => s.trim()).filter(Boolean);
28
- return lines.length ? lines[lines.length - 1].slice(0, max) : "";
29
- }
30
-
31
- /**
32
- * 按端口独立的退避复活器。
33
- * @param {{ revive: (port:number)=>Promise<boolean>, isAlive?: (port:number)=>boolean, minGapMs?: number,
34
- * maxGapMs?: number, settleMs?: number, now?: ()=>number, sleep?: Function,
35
- * schedule?: Function, cancel?: Function, log?: (s:string)=>void }} opts
36
- * 复活是否算数由 `isAlive` 在 `settleMs` 后复查;失败按指数退避并**自己用定时器安排下一次重试**。
37
- */
38
- 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 = () => {} } = {}) {
39
- /** @type {Map<number, {attempts:number, nextAt:number, busy:boolean}>} */
40
- const state = new Map();
41
-
42
- function entry(port) {
43
- let s = state.get(port);
44
- if (!s) {
45
- s = { attempts: 0, nextAt: 0, busy: false };
46
- state.set(port, s);
47
- }
48
- return s;
49
- }
50
-
51
- /** @type {Map<number, object>} 每端口最多一个待触发的重试定时器 */
52
- const timers = new Map();
53
-
54
- /** 安排「到点自动重试」;没有它,日志里那句「N s 后自动再试」就是空话。 */
55
- function armRetry(port, ms) {
56
- if (timers.has(port)) return;
57
- const h = schedule(() => {
58
- timers.delete(port);
59
- void attempt(port);
60
- }, Math.max(0, ms));
61
- if (h && typeof h.unref === "function") h.unref();
62
- timers.set(port, h);
63
- }
64
-
65
- /**
66
- * 复活一次:首次立即复活,失败按 30s→10min 指数退避并自己安排下一次重试。
67
- * 复活算不算数由 `isAlive` 在 `settleMs` 后复查——不许拿「spawn 被调用了」冒充「活下来了」。
68
- */
69
- async function attempt(port) {
70
- const s = entry(port);
71
- if (s.busy) return false;
72
- const wait = Math.max(0, s.nextAt - now());
73
- if (wait > 0) {
74
- log(`[warp-pool] :${port} 距上次复活不足退避间隔,${Math.ceil(wait / 1000)}s 后再试(防抖动刷屏,不是故障)`);
75
- armRetry(port, wait);
76
- return false;
77
- }
78
- s.busy = true;
79
- s.attempts += 1;
80
- const gap = Math.min(minGapMs * 2 ** (s.attempts - 1), maxGapMs);
81
- let ok = false;
82
- try {
83
- ok = await revive(port);
84
- } catch (e) {
85
- log(`[warp-pool] :${port} 复活抛出异常:${String(e?.message || e)}`);
86
- }
87
- if (ok && isAlive) {
88
- if (settleMs > 0) await sleep(settleMs);
89
- ok = isAlive(port);
90
- if (!ok) log(`[warp-pool] :${port} 复活后进程立刻又退出 —— 不认这次复活(原因见上面那条「意外退出」)`);
91
- }
92
- s.busy = false;
93
- if (ok) {
94
- const n = s.attempts;
95
- s.attempts = 0;
96
- s.nextAt = now() + minGapMs; // 稳定期:期间再退出先等一轮,防「起一条死一条」把日志刷爆
97
- log(`[warp-pool] :${port} 已复活(第 ${n} 次尝试成功);${Math.round(minGapMs / 1000)}s 内若又退出会先等一轮再重启`);
98
- return true;
99
- }
100
- s.nextAt = now() + gap;
101
- const hint = s.attempts >= 3 ? ` 排查:ss -ltnp | grep :${port};或前台跑隧道二进制 -c node-${port}.conf 看真实报错` : "";
102
- log(`[warp-pool] :${port} 复活未成功(第 ${s.attempts} 次),${Math.round(gap / 1000)}s 后自动再试。${hint}`);
103
- armRetry(port, gap);
104
- return false;
105
- }
106
-
107
- return {
108
- onExit: (port) => attempt(port),
109
- /** 探活成功时调用:清掉退避账与待触发定时器,恢复正常节奏。 */
110
- noteHealthy(port) {
111
- const h = timers.get(port);
112
- if (h) {
113
- try { cancel(h); } catch {}
114
- timers.delete(port);
115
- }
116
- state.delete(port);
117
- },
118
- stats() {
119
- return [...state.entries()].map(([port, s]) => ({
120
- port,
121
- attempts: s.attempts,
122
- nextAt: s.nextAt,
123
- cooldownMsLeft: Math.max(0, s.nextAt - now()),
124
- }));
125
- },
126
-
127
- /** 池关闭时调用:别再有定时器去打复活。 */
128
- stop() {
129
- for (const h of timers.values()) {
130
- try { cancel(h); } catch {}
131
- }
132
- timers.clear();
133
- state.clear();
134
- },
135
- };
136
- }
137
-
138
- /**
139
- * 入池后实测各节点真实出口地址并打日志(含撞车警告)。
140
- * 只读探测:打 Cloudflare 的 trace 端点,不碰上游配额。
141
- * @param {{ ports?: number[], dir?: string, fetchImpl?: Function, timeoutMs?: number, log?: Function }} [opts]
142
- * @returns {Promise<{ ip: string, nodes: number[] }[]>} 按出口地址分组的节点清单
143
- */
144
- export async function labelExits({ ports = [], dir = "", fetchImpl, timeoutMs = 12_000, log = () => {} } = {}) {
145
- const byIp = new Map();
146
- let failed = 0;
147
- for (const port of ports) {
148
- let ip = "";
149
- try {
150
- const res = await probeTunnel({ port, baseUrl: EXIT_PROBE_URL, path: "", fetchImpl, timeoutMs, wantText: true });
151
- ip = typeof res?.text === "string" ? parseExitIp(res.text) : "";
152
- } catch {
153
- ip = "";
154
- }
155
- if (!ip) {
156
- failed += 1;
157
- continue;
158
- }
159
- if (!byIp.has(ip)) byIp.set(ip, []);
160
- byIp.get(ip).push(port);
161
- }
162
-
163
- const groups = [...byIp.entries()].map(([ip, nodes]) => ({ ip, nodes }));
164
- const line = fmtExits({ groups, dir });
165
- if (line) log(line);
166
- if (failed) log(`[warp-pool] ${failed} 个节点出口取证失败(不影响进池,只影响轮换时「出口到底变没变」的比对)`);
167
- return groups;
168
- }
@@ -1,214 +0,0 @@
1
- // warp-pool · 轮换 dispatcher:把每个请求委派给不同出口节点的 ProxyAgent。
2
- // 契约(MODULES.md §1):只需 dispatch(opts, handler) + close()/destroy()。
3
- // 注入点:src/transport/index.js 的 extDispatcher —— legacy(transport.request)与 SDK(sdk/attempt.js
4
- // 的 dispatcherFetch)两条路共用同一个 dispatcher 对象,故一处实现、两条路生效。
5
- //
6
- // 空池语义:bootstrap(注册设备/起隧道)完成前 list 为空 → 走直连兜底,行为与未启用本功能时一致;
7
- // bootstrap 完成后 setNodes() 换上真实出口。这样 daemon 启动不会因注册/下载而被阻塞。
8
- import { getUndici } from "../compat.js";
9
- import { createSelector } from "./strategy.js";
10
- import { looksLikeQuotaSignal, SIGNAL_MAX_BYTES, SIGNAL_STATUSES } from "./verdict.js";
11
-
12
- const SESSION_HEADER = "x-opencode-session";
13
-
14
- /** undici 的 dispatch opts.headers 是扁平数组 [k, v, k, v, ...]。 */
15
- export function headerValue(headers, name) {
16
- if (!Array.isArray(headers)) return "";
17
- const target = String(name).toLowerCase();
18
- for (let i = 0; i + 1 < headers.length; i += 2) {
19
- if (String(headers[i]).toLowerCase() === target) return String(headers[i + 1] ?? "");
20
- }
21
- return "";
22
- }
23
-
24
- /**
25
- * 让 dispatcher 看见上游的失败信号:复制一层 handler,其余方法原样转发(全部调用都落在副本上,this 一致)。
26
- *
27
- * 403/429 要**连正文一起**交出去才能判决(`403 FreeTierError` 与 `429 FreeUsageLimitError` 是同一条件,
28
- * 而 `429 BlackUsageLimitError` 是账号配额、绝不能换出口)——参照实现 zen-router 就是按正文里的具名判决
29
- * 分类的(internal/zen/errors.go)。故这两类状态码延迟到「正文到手」再回调:凑够具名判决即提前回调,
30
- * 否则等 onComplete/onError/触顶。其余状态码照旧在 onHeaders 立即回调(正文为空)。
31
- * 正文只**旁听**不消费:onData 原样转发给下游 handler,捕获 ≤ SIGNAL_MAX_BYTES 字节。
32
- */
33
- export function tapHandler(handler, tap) {
34
- if (!tap || !handler || typeof handler !== "object") return handler;
35
- const out = Object.assign(Object.create(Object.getPrototypeOf(handler)), handler);
36
- const origHeaders = handler.onHeaders;
37
- const origData = handler.onData;
38
- const origComplete = handler.onComplete;
39
- const origError = handler.onError;
40
- /** @type {null | { status: number, buf: Buffer[], size: number, done: boolean }} */
41
- let capture = null;
42
- const fire = () => {
43
- if (!capture || capture.done) return;
44
- capture.done = true;
45
- try { tap({ status: capture.status, text: Buffer.concat(capture.buf).toString("utf8") }); } catch {}
46
- };
47
-
48
- out.onHeaders = function (statusCode, headers, resume, statusText) {
49
- const code = Number(statusCode);
50
- // 只有 403/429 值得等正文;且必须下游真能收数据,否则退回「只按状态码判决」。
51
- if (SIGNAL_STATUSES.includes(code) && typeof origData === "function") {
52
- capture = { status: code, buf: [], size: 0, done: false };
53
- } else {
54
- try { tap({ status: code, text: "" }); } catch {}
55
- }
56
- return typeof origHeaders === "function" ? origHeaders.call(this, statusCode, headers, resume, statusText) : undefined;
57
- };
58
- out.onData = function (chunk) {
59
- if (capture && !capture.done) {
60
- try {
61
- const b = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
62
- const room = SIGNAL_MAX_BYTES - capture.size;
63
- if (room > 0) {
64
- const slice = b.length > room ? b.subarray(0, room) : b;
65
- capture.buf.push(slice);
66
- capture.size += slice.length;
67
- }
68
- } catch {}
69
- // 具名判决一到手就判决,不必等整条 body;触顶也判(大 body 不许把判决拖成慢扫描)
70
- const text = capture.buf.length ? Buffer.concat(capture.buf).toString("utf8") : "";
71
- if (capture.size >= SIGNAL_MAX_BYTES || looksLikeQuotaSignal(text)) fire();
72
- }
73
- return typeof origData === "function" ? origData.call(this, chunk) : undefined;
74
- };
75
- out.onComplete = function (trailers) {
76
- fire();
77
- return typeof origComplete === "function" ? origComplete.call(this, trailers) : undefined;
78
- };
79
- out.onError = function (err) {
80
- fire();
81
- return typeof origError === "function" ? origError.call(this, err) : undefined;
82
- };
83
- return out;
84
- }
85
-
86
- /**
87
- * @param {{ nodes?: Array<{port:number, uri:string}>, strategy?: "session"|"request",
88
- * cooldownMs?: number, now?: () => number, log?: (s:string)=>void, onStatus?: (status:number, port:number)=>void,
89
- * agentOptions?: object, selector?: object }} [opts]
90
- */
91
- export function createRotatingDispatcher({
92
- nodes = [],
93
- strategy = "session",
94
- cooldownMs = 600_000,
95
- now = Date.now,
96
- log = () => {},
97
- onStatus = null,
98
- agentFactory,
99
- fallbackAgent,
100
- agentOptions = { connections: 8, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000, pipelining: 1 },
101
- selector = createSelector({ strategy }),
102
- } = {}) {
103
- const { ProxyAgent, Agent } = getUndici();
104
- if (typeof ProxyAgent !== "function") throw new Error("undici ProxyAgent 不可用:无法建立出口池");
105
- // 空池兜底:直连(等价 R3 里被顶掉的 keepAlive 池)。
106
- const fallback = fallbackAgent !== undefined ? fallbackAgent : typeof Agent === "function" ? new Agent(agentOptions) : null;
107
- let list = build(nodes);
108
-
109
- function build(nodesIn) {
110
- return (nodesIn || []).map((n, i) => ({
111
- index: i,
112
- port: Number(n.port),
113
- uri: String(n.uri),
114
- agent: agentFactory ? agentFactory(n.uri) : new ProxyAgent({ uri: n.uri, ...agentOptions }),
115
- cooldownUntil: 0,
116
- fails: 0,
117
- }));
118
- }
119
-
120
- function availableIndexes() {
121
- const t = now();
122
- const out = [];
123
- for (const e of list) if (e.cooldownUntil <= t) out.push(e.index);
124
- return out;
125
- }
126
-
127
- function markFailure(index) {
128
- const e = list[index];
129
- if (!e) return;
130
- e.fails += 1;
131
- e.cooldownUntil = now() + cooldownMs;
132
- if (e.fails === 1 || e.fails % 10 === 0) log(`[warp-pool] 节点 :${e.port} 连续失败 ${e.fails} 次,冷却 ${Math.round(cooldownMs / 1000)}s(中间次数静默:健康探活每分钟一轮,一个不通的节点曾把日志刷成 12 行/分钟)`);
133
- }
134
-
135
- function markOk(index) {
136
- const e = list[index];
137
- if (e) {
138
- e.fails = 0;
139
- e.cooldownUntil = 0;
140
- }
141
- }
142
-
143
- async function closePool(entries) {
144
- await Promise.all(
145
- entries.map((e) => {
146
- try {
147
- return Promise.resolve(e.agent.close?.()).catch(() => {});
148
- } catch {
149
- return Promise.resolve();
150
- }
151
- }),
152
- );
153
- }
154
-
155
- return {
156
- /** 每次请求调一次 = 轮换粒度。 */
157
- dispatch(opts, handler) {
158
- if (!list.length) {
159
- if (fallback) return fallback.dispatch(opts, handler);
160
- throw new Error("warp-pool:节点池为空且无直连兜底");
161
- }
162
- const avail = availableIndexes();
163
- // 全池冷却 ⇒ 直连兜底优先:硬派给已知不可用的节点 = 把请求挂到超时。
164
- if (!avail.length) return (fallback || list[0].agent).dispatch(opts, handler);
165
- const sessionKey = strategy === "session" ? headerValue(opts?.headers, SESSION_HEADER) : "";
166
- const e = list[selector.pick(avail, sessionKey)];
167
- return e.agent.dispatch(opts, tapHandler(handler, onStatus ? (sc) => onStatus(sc, e.port) : null));
168
- },
169
-
170
- /** 运行期换上一批出口(bootstrap 完成后调用);旧 Agent 池关闭。 */
171
- async setNodes(nodesIn) {
172
- const old = list;
173
- list = build(nodesIn);
174
- await closePool(old);
175
- return list.length;
176
- },
177
-
178
- /** 只读快照,供 health 探活用。 */
179
- entries() {
180
- return list.map((e) => ({ index: e.index, port: e.port, uri: e.uri, cooldownUntil: e.cooldownUntil, fails: e.fails }));
181
- },
182
-
183
- async close() {
184
- await closePool(list);
185
- list = [];
186
- if (fallback) {
187
- try {
188
- await fallback.close?.();
189
- } catch {}
190
- }
191
- },
192
-
193
- async destroy() {
194
- return this.close();
195
- },
196
-
197
- stats() {
198
- const t = now();
199
- return list.map((e) => ({
200
- port: e.port,
201
- uri: e.uri,
202
- cooling: e.cooldownUntil > t,
203
- cooldownMsLeft: Math.max(0, e.cooldownUntil - t),
204
- fails: e.fails,
205
- }));
206
- },
207
-
208
- markFailure,
209
- markOk,
210
- get size() {
211
- return list.length;
212
- },
213
- };
214
- }
@@ -1,43 +0,0 @@
1
- // warp-pool · 选节点策略。
2
- //
3
- // session(默认):同一 `x-opencode-session` 恒粘同一出口。
4
- // 依据 src/upstream.js L14-16 的实测注释——上游按 session 做粘性路由(固定 session 两次请求 ~1.2s;
5
- // 每次随机时可能撞冷机器 26s+)。故"每请求换出口"会把请求打成冷启动。
6
- // request:每请求轮转,仅供实验(延迟代价见上)。
7
- const STICKY_MAX = 2000;
8
-
9
- export function createSelector({ strategy = "session" } = {}) {
10
- const sticky = new Map();
11
- let cursor = 0;
12
-
13
- /** @param {number[]} available 可用节点下标 @param {string} sessionKey */
14
- function pick(available, sessionKey) {
15
- if (!Array.isArray(available) || available.length === 0) return null;
16
- if (strategy === "session" && sessionKey) {
17
- const hit = sticky.get(sessionKey);
18
- if (hit !== undefined && available.includes(hit)) return hit;
19
- const chosen = available[cursor++ % available.length];
20
- sticky.set(sessionKey, chosen);
21
- if (sticky.size > STICKY_MAX) {
22
- const oldest = sticky.keys().next().value;
23
- sticky.delete(oldest);
24
- }
25
- return chosen;
26
- }
27
- return available[cursor++ % available.length];
28
- }
29
-
30
- return {
31
- pick,
32
- forget(sessionKey) {
33
- if (sessionKey === undefined) sticky.clear();
34
- else sticky.delete(sessionKey);
35
- },
36
- get tracked() {
37
- return sticky.size;
38
- },
39
- get strategy() {
40
- return strategy;
41
- },
42
- };
43
- }
@@ -1,69 +0,0 @@
1
- // warp-pool · 上游失败信号的「换不换出口」判决(纯函数,零 IO)。
2
- //
3
- // 判据逐字对齐参照实现 kyomufs/zen-router:
4
- // - internal/zen/errors.go 的 Kind 分类表(哪些错误算「按出口 IP 计的限额」);
5
- // - internal/router/stage.go 的 NextAttempt 决策表(每种 Kind 该换什么、要不要绕过冷却)。
6
- //
7
- // 立此模块的原因(2026-10-17 十次修订):我原先只在 rotate.js 的 onStatus 里认 `status === 429`,
8
- // 漏掉了参照实现表里**最重要的一格**——`403 FreeTierError` 与 `429 FreeUsageLimitError` 是
9
- // **同一个条件的两种状态码**(上游按自己的窗口状态二选一,文案完全相同)。参照实现实测:
10
- // 同一身份连发 3 次 429,**身份一换同一个请求立刻 200**(errors.go:32-42);
11
- // 而它最初把 403 当通用 403 不重试,代价是整轮请求直接死掉(errors.go:50-61 记录得很清楚)。
12
- // 我这边更糟:`terminal-verdict.js` 把 FreeTierError 判成**终局**(跳过接力),
13
- // warp-pool 又只认 429 ⇒ **403 FreeTierError 从来没有任何换出口动作**。
14
- //
15
- // 反向护栏同样照抄:`BlackUsageLimitError` 是真正的**账号日配额**,换出口抬不动,
16
- // 参照实现明确「绝不旋转身份」(stage.go:125-128、errors.go:28-31);换 key 才是它的正解。
17
- // 裸 403(正文没有 FreeTier 判决)也不旋转:参照实现的 kindByStatus 把 403 归为
18
- // KindClient(不可重试),换出口对形状/区域/凭据类 403 毫无用处。
19
- //
20
- // 读不到正文时的退路也照抄 kindByStatus:429 → 当出口 IP 限流(换);403 → 不换。
21
-
22
- /** 上游失败正文里判定免费层资格的具名判决(与 terminal-verdict.js 的 FREE_TIER_PATTERNS 同源口径)。 */
23
- const FREE_TIER_RE = /FreeTierError|free tier can only be used/i;
24
- /** 匿名免费层 429(参照实现实测:按出口 IP 计,换身份即换桶)。 */
25
- const FREE_QUOTA_RE = /FreeUsageLimitError|GoUsageLimitError/i;
26
- /** 黑名单层:真账号日配额,换出口抬不动。 */
27
- const BLACK_QUOTA_RE = /BlackUsageLimitError/i;
28
-
29
- /** 只这两类状态码参与判决;其余(含 200)一律不参与、也不许刷日志。 */
30
- export const SIGNAL_STATUSES = [403, 429];
31
-
32
- /** 正文捕获上限(判据只需要文案里的类名,2KB 足够,且防大 body 拖成慢扫描)。 */
33
- export const SIGNAL_MAX_BYTES = 2048;
34
-
35
- /** 正文里出现了具名判决(供 tapHandler 一凑够就提前判决,不必等整条 body 读完)。 */
36
- export function looksLikeQuotaSignal(text) {
37
- const t = typeof text === "string" ? text : "";
38
- return FREE_TIER_RE.test(t) || FREE_QUOTA_RE.test(t) || BLACK_QUOTA_RE.test(t);
39
- }
40
-
41
- /**
42
- * 一次上游失败该不该换出口。
43
- * @param {{ status?: number, text?: string }} signal 上游状态码 + 正文(≤ SIGNAL_MAX_BYTES;读不到给空串)
44
- * @returns {{ kind: "free-tier"|"free-quota"|"black-quota"|"ip-limit"|"none", rotate: boolean,
45
- * forced: boolean, why: string }}
46
- * forced=true 表示必须**跳过**冷却(参照实现的 RotateForced:冷却曾把第一次真实反应吞掉,
47
- * 于是重试复用了同一个已限额的出口 —— stage.go:315-324)。
48
- */
49
- export function classifyWarpSignal({ status, text = "" } = {}) {
50
- const code = Number(status);
51
- const body = typeof text === "string" ? text : "";
52
- if (!SIGNAL_STATUSES.includes(code)) {
53
- return { kind: "none", rotate: false, forced: false, why: `上游 ${code || "?"} 不是 403/429,与出口限额无关` };
54
- }
55
- // 顺序即优先级,照抄 errors.go 的 classify():先认正文里的具名判决,再退回状态码。
56
- if (BLACK_QUOTA_RE.test(body)) {
57
- return { kind: "black-quota", rotate: false, forced: false, why: "BlackUsageLimitError 是真正的账号日配额,换出口抬不动(参照实现同样绝不旋转)——该换 key,不是换 IP" };
58
- }
59
- if (FREE_TIER_RE.test(body)) {
60
- return { kind: "free-tier", rotate: true, forced: true, why: "403 FreeTierError 与 429 FreeUsageLimitError 是同一条件的两种状态码;参照实现实测换身份后同一个请求立刻 200 ⇒ 强制换出口,跳过冷却" };
61
- }
62
- if (FREE_QUOTA_RE.test(body)) {
63
- return { kind: "free-quota", rotate: true, forced: true, why: "匿名免费层 429 实测是**按出口 IP** 计的限额(同一身份连发 3 次全 429,换身份立刻 200)⇒ 强制换出口,跳过冷却" };
64
- }
65
- if (code === 429) {
66
- return { kind: "ip-limit", rotate: true, forced: false, why: "裸 429(正文无配额判决)= 出口 IP 限流信号 ⇒ 换出口;保留冷却,防 429 风暴把 CF 注册刷爆" };
67
- }
68
- return { kind: "none", rotate: false, forced: false, why: "403 但正文没有 FreeTier 判决 ⇒ 不是配额信号(形状/区域/凭据类),换出口无用" };
69
- }
@@ -1,162 +0,0 @@
1
- // warp-pool · wireproxy 进程管理:取二进制 → 生成配置 → 启动 → 守护/销毁。
2
- // wireproxy(octeep/wireproxy,Go,用户态 WireGuard,无需 root/TUN)把 WARP 隧道暴露为本地 HTTP 代理。
3
- // 为什么是 [http] 而非 [Socks5]:项目 undici 为 v5(实测 5.29.0),其 ProxyAgent 只接受 http/https,
4
- // 「SOCKS5 支持」是 undici 后续版本才加的(实测 socks5:// 直接抛 Invalid URL protocol)。
5
- import { spawn } from "node:child_process";
6
- import { chmodSync, existsSync, createWriteStream, readdirSync, rmSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { pipeline } from "node:stream/promises";
9
- import { compatFetch, getUndici } from "../compat.js";
10
- import { ensureDir, nodePaths, renderWireproxyConf, writeSecret } from "./nodes.js";
11
-
12
- export const WIREPROXY_VERSION = "v1.1.3";
13
- const RELEASE_BASE = "https://github.com/octeep/wireproxy/releases/download";
14
-
15
- const ASSETS = {
16
- "linux-x64": "wireproxy_linux_amd64.tar.gz",
17
- "linux-arm64": "wireproxy_linux_arm64.tar.gz",
18
- "darwin-x64": "wireproxy_darwin_amd64.tar.gz",
19
- "darwin-arm64": "wireproxy_darwin_arm64.tar.gz",
20
- };
21
-
22
- /** 依平台选发布包;不支持的平台返回 null(改用 MSLXDFF_WARP_BIN 自备二进制)。 */
23
- export function resolveAsset(platform = process.platform, arch = process.arch) {
24
- const key = `${platform}-${arch}`;
25
- return ASSETS[key] || null;
26
- }
27
-
28
- /** 本地二进制路径(bin/ 下按平台固定名)。 */
29
- export function binaryPath(dir) {
30
- return join(dir, "bin", process.platform === "win32" ? "wireproxy.exe" : "wireproxy");
31
- }
32
-
33
- /**
34
- * 确保 wireproxy 可用:优先 env 指定 → 本地已下载 → 自动下载解包。
35
- * @returns {Promise<string>} 可执行文件绝对路径
36
- */
37
- export async function ensureBinary({ dir, bin = "", autodownload = true, fetchImpl = compatFetch, log = () => {}, platform = process.platform, arch = process.arch } = {}) {
38
- if (bin) {
39
- if (!existsSync(bin)) throw new Error(`MSLXDFF_WARP_BIN 指向的文件不存在:${bin}`);
40
- return bin;
41
- }
42
- const local = binaryPath(dir);
43
- if (existsSync(local)) return local;
44
-
45
- if (!autodownload) throw new Error(`未找到 wireproxy(${local}),且已关闭自动下载:请设 MSLXDFF_WARP_BIN 或开启 MSLXDFF_WARP_AUTODOWNLOAD`);
46
- const asset = resolveAsset(platform, arch);
47
- if (!asset) throw new Error(`暂不支持自动下载 wireproxy(平台 ${platform}-${arch}):请自备二进制并设 MSLXDFF_WARP_BIN`);
48
-
49
- ensureDir(join(dir, "bin"));
50
- const url = `${RELEASE_BASE}/${WIREPROXY_VERSION}/${asset}`;
51
- const tarball = join(dir, "bin", asset);
52
- log(`[warp-pool] 下载 wireproxy ${WIREPROXY_VERSION}(${asset})`);
53
- const res = await fetchImpl(url, { signal: AbortSignal.timeout(120_000) });
54
- if (!res.ok) throw new Error(`下载 wireproxy 失败:HTTP ${res.status}`);
55
- await pipeline(res.body, createWriteStream(tarball));
56
-
57
- // 解包用系统 tar(Linux/macOS 必带;不引入 npm 依赖去解析 tar 格式)。
58
- await runCommand("tar", ["-xzf", tarball, "-C", join(dir, "bin")], { log });
59
- try { rmSync(tarball, { force: true }); } catch {}
60
-
61
- const extracted = findWireproxy(join(dir, "bin"));
62
- if (!extracted) throw new Error(`解包后未找到 wireproxy 可执行文件(目录:${join(dir, "bin")})`);
63
- if (extracted !== local) {
64
- try { chmodSync(extracted, 0o755); } catch {}
65
- }
66
- try { chmodSync(local, 0o755); } catch {}
67
- return existsSync(local) ? local : extracted;
68
- }
69
-
70
- function findWireproxy(binDir) {
71
- try {
72
- const hit = readdirSync(binDir).find((f) => f === "wireproxy" || f === "wireproxy.exe");
73
- return hit ? join(binDir, hit) : null;
74
- } catch {
75
- return null;
76
- }
77
- }
78
-
79
- function runCommand(cmd, args, { log = () => {} } = {}) {
80
- return new Promise((resolve, reject) => {
81
- let err = "";
82
- const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
83
- p.stderr.on("data", (d) => { err += String(d); });
84
- p.on("error", reject);
85
- p.on("close", (code) => {
86
- if (code === 0) resolve();
87
- else reject(new Error(`${cmd} 退出码 ${code}${err ? `:${err.slice(0, 200)}` : ""}`));
88
- });
89
- });
90
- }
91
-
92
- /** 写出某节点的 wireproxy 配置(含私钥 ⇒ 0600)。 */
93
- export function writeTunnelConf(dir, node) {
94
- if (!node?.privateKey) throw new Error(`节点 :${node?.port} 缺 WireGuard 私钥,拒绝写出一份跑不起来的配置`);
95
- const { conf } = nodePaths(dir, node.port);
96
- const text = renderWireproxyConf({ ...node, port: node.port });
97
- writeSecret(conf, text);
98
- return { conf, bytes: Buffer.byteLength(text) };
99
- }
100
-
101
- /**
102
- * 启动一条隧道。返回 { port, conf, stop() }。
103
- * 进程退出即视为隧道失效(不自动重启:由 health 层决策重拨换 IP)。
104
- */
105
- export function startTunnel({ binPath: exe, dir, port, log = () => {}, onExit = () => {}, now = Date.now } = {}) {
106
- const { conf } = nodePaths(dir, port);
107
- const child = spawn(exe, ["-c", conf], { stdio: ["ignore", "pipe", "pipe"] });
108
- const startedAt = now();
109
- let tail = "";
110
- const keepTail = (d) => {
111
- tail = (tail + String(d)).slice(-800);
112
- };
113
- child.stdout.on("data", keepTail);
114
- child.stderr.on("data", keepTail);
115
-
116
- let stopped = false;
117
- child.on("exit", (code, signal) => {
118
- if (!stopped) onExit({ port, code, signal, tail, pid: child.pid, aliveMs: now() - startedAt });
119
- });
120
-
121
- return {
122
- port,
123
- conf,
124
- get pid() {
125
- return child.pid;
126
- },
127
- get alive() {
128
- return !stopped && child.exitCode === null;
129
- },
130
- get tail() {
131
- return tail;
132
- },
133
- stop() {
134
- if (stopped) return;
135
- stopped = true;
136
- try { child.kill("SIGTERM"); } catch {}
137
- },
138
- };
139
- }
140
-
141
- /** 探活一个已起的隧道:经该端口访问上游 models 路径(不烧 token)。 */
142
- export async function probeTunnel({ port, baseUrl = "https://opencode.ai", path = "/zen/v1/models", fetchImpl = compatFetch, timeoutMs = 8000, headers = {}, wantText = false } = {}) {
143
- const { ProxyAgent } = getUndici();
144
- const agent = new ProxyAgent({ uri: `http://127.0.0.1:${port}`, connections: 2, keepAliveTimeout: 10_000 });
145
- const url = `${String(baseUrl).replace(/\/+$/, "")}${path}`;
146
- const t0 = Date.now();
147
- try {
148
- const res = await fetchImpl(url, {
149
- headers,
150
- signal: AbortSignal.timeout(timeoutMs),
151
- dispatcher: agent,
152
- });
153
- const base = { ok: res.ok, status: res.status, ms: Date.now() - t0, url };
154
- if (!wantText) return base;
155
- const text = await res.text().catch(() => "");
156
- return { ...base, text: String(text).slice(0, 2000) };
157
- } catch (e) {
158
- return { ok: false, status: 0, error: String(e?.message || e), ms: Date.now() - t0, url };
159
- } finally {
160
- try { await agent.close(); } catch {}
161
- }
162
- }