mslxdff 0.1.173 → 0.1.175
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/bin/mslxdff.js +3 -3
- package/package.json +1 -1
- package/src/auto.js +254 -254
- package/src/bench/cline-bench.js +42 -42
- package/src/bench/probe.js +70 -70
- package/src/bench/report.js +162 -162
- package/src/bench/runner.js +77 -77
- package/src/bench/via-probe.js +124 -124
- package/src/bench/via-routes.js +87 -87
- package/src/bench/workbuddy-bench.js +54 -54
- package/src/chat/engine.js +160 -160
- package/src/chat/gateway.js +163 -163
- package/src/chat/orchestrator.js +234 -234
- package/src/chat/prompt.js +70 -70
- package/src/chat/repl.js +88 -88
- package/src/chat/terminal.js +135 -135
- package/src/chat/tools.js +306 -306
- package/src/chat-pipeline/auto-race.js +26 -1
- package/src/chat-pipeline/index.js +131 -131
- package/src/chat-pipeline/policy.js +84 -76
- package/src/chat-pipeline/serial-trial.js +35 -8
- package/src/chat-pipeline/terminal-verdict.js +109 -0
- package/src/claude-desktop/apply.js +42 -5
- package/src/claude-desktop/context1m.js +38 -0
- package/src/claude-desktop/slots.js +66 -0
- package/src/cli/commands/claude-desktop.js +27 -7
- package/src/cli/commands/group.js +249 -249
- package/src/cli/commands/model/picks.js +50 -50
- package/src/cli/commands/provider/bench-via.js +247 -247
- package/src/cli/commands/provider/bench.js +141 -141
- package/src/cli/commands/provider/index.js +136 -133
- package/src/cli/commands/provider/keys-guard.js +50 -0
- package/src/cli/commands/provider/keys.js +7 -0
- package/src/cli/commands/provider/models.js +145 -145
- package/src/cli/commands/provider/qwenwork-login.js +119 -119
- package/src/cli/commands/provider/traework-checkin.js +144 -0
- package/src/cli/commands/provider/traework-login.js +7 -45
- package/src/cli/commands/sync.js +310 -310
- package/src/cli/help.js +4 -1
- package/src/cli/status.js +279 -279
- package/src/daemon.js +112 -112
- package/src/model-capabilities/enrich.js +86 -86
- package/src/model-capabilities/index.js +183 -183
- package/src/model-capabilities/parse.js +70 -70
- package/src/model-trace.js +2 -0
- package/src/models.js +225 -225
- package/src/opencode-identity.js +39 -19
- package/src/providers/cline/auth.js +228 -228
- package/src/providers/cline/chat.js +307 -307
- package/src/providers/cline.js +2 -2
- package/src/providers/codearts/chat.js +7 -1
- package/src/providers/codearts/const.js +6 -1
- package/src/providers/codearts/index.js +18 -11
- package/src/providers/globalqwenwork/cosy.js +123 -123
- package/src/providers/globalqwenwork/crypto.js +218 -218
- package/src/providers/globalqwenwork/http.js +20 -20
- package/src/providers/globalqwenwork/rsa.js +54 -54
- package/src/providers/globalqwenwork/sse.js +268 -268
- package/src/providers/globalqwenwork/stream.js +130 -130
- package/src/providers/globalqwenwork/upstream.js +122 -122
- package/src/providers/keyring.js +60 -60
- package/src/providers/qoder/chat.js +203 -203
- package/src/providers/qoder/index.js +249 -249
- package/src/providers/qoder/sse.js +145 -145
- package/src/providers/qwenwork/account-store.js +133 -133
- package/src/providers/qwenwork/constants.js +67 -67
- package/src/providers/qwenwork/cosy.js +120 -120
- package/src/providers/qwenwork/crypto.js +218 -218
- package/src/providers/qwenwork/http.js +20 -20
- package/src/providers/qwenwork/index.js +327 -327
- package/src/providers/qwenwork/payload.js +144 -144
- package/src/providers/qwenwork/rsa.js +54 -54
- package/src/providers/qwenwork/sse.js +268 -268
- package/src/providers/qwenwork/stream.js +130 -130
- package/src/providers/qwenwork/upstream.js +120 -120
- package/src/providers/qwenwork.js +1 -1
- package/src/providers/registry.js +79 -79
- package/src/providers/traework/account-store.js +87 -2
- package/src/providers/traework/checkin.js +51 -9
- package/src/providers/traework/constants.js +20 -0
- package/src/providers/traework/headers.js +42 -10
- package/src/providers/traework/index.js +14 -4
- package/src/providers/traework/market-identity.js +116 -0
- package/src/providers/workbuddy/chat.js +248 -248
- package/src/providers/workbuddy/reshape.js +152 -152
- package/src/providers/workbuddy.js +2 -2
- package/src/reasoning.js +32 -32
- package/src/routes/chat/gateway.js +46 -46
- package/src/routes/chat/relay-pipeline.js +294 -294
- package/src/routes/chat/via-route-handler.js +146 -146
- package/src/routes/hedge.js +255 -255
- package/src/routes/models-route.js +167 -167
- package/src/routes/peers.js +273 -273
- package/src/routes/stream.js +385 -385
- package/src/runtime/bootstrap.js +50 -50
- package/src/runtime/provider-gate.js +36 -36
- package/src/runtime/providers-setup.js +168 -165
- package/src/runtime/traework-checkin.js +26 -11
- package/src/server.js +69 -69
- package/src/state/schemas/allowlist.js +92 -92
- package/src/sync-opencode.js +280 -280
- package/src/transport/index.js +244 -244
- package/src/transport/pool.js +56 -56
- package/src/transport/retry.js +24 -24
- package/src/transport/sse.js +93 -93
- package/src/upstream-probe/display.js +52 -52
- package/src/upstream-probe/probe.js +49 -49
- package/src/upstream-probe/rotate.js +110 -110
- package/src/upstream-probe/start.js +45 -45
- package/src/upstream.js +291 -289
- package/src/warp-pool/config.js +71 -0
- package/src/warp-pool/health.js +65 -0
- package/src/warp-pool/index.js +252 -0
- package/src/warp-pool/keys.js +29 -0
- package/src/warp-pool/nodes.js +94 -0
- package/src/warp-pool/register.js +113 -0
- package/src/warp-pool/rotate.js +150 -0
- package/src/warp-pool/strategy.js +43 -0
- package/src/warp-pool/wireproxy.js +153 -0
- package/docs/cli_help_mini.md +0 -143
- package/src/providers/AGENTS.md +0 -49
- package/src/routes/AGENTS.md +0 -37
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// warp-pool · 配置解析:env 优先,缺省值集中在一处;纯函数,便于单测。
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logDir } from "../logs.js";
|
|
4
|
+
|
|
5
|
+
/** 缺省即开:组长升级/重启后无需改任何环境变量即可生效;不想用的节点设 MSLXDFF_WARP_POOL=0。 */
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
enabled: true,
|
|
8
|
+
mode: "leader",
|
|
9
|
+
count: 4,
|
|
10
|
+
basePort: 10800,
|
|
11
|
+
strategy: "session",
|
|
12
|
+
cooldownMs: 600_000,
|
|
13
|
+
autodownload: true,
|
|
14
|
+
registerMinGapMs: 6_000,
|
|
15
|
+
registerMaxGapMs: 15_000,
|
|
16
|
+
probePath: "/zen/v1/models",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const OFF = ["0", "off", "false", "no", "disable", "disabled"];
|
|
20
|
+
|
|
21
|
+
function envBool(env, name, fallback) {
|
|
22
|
+
const raw = env[name];
|
|
23
|
+
if (raw === undefined || raw === null || raw === "") return fallback;
|
|
24
|
+
return !OFF.includes(String(raw).trim().toLowerCase());
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function envInt(env, name, fallback) {
|
|
28
|
+
const v = Number(env[name]);
|
|
29
|
+
return Number.isInteger(v) && v > 0 ? v : fallback;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function envStr(env, name, fallback) {
|
|
33
|
+
const raw = env[name];
|
|
34
|
+
return typeof raw === "string" && raw.trim() ? raw.trim() : fallback;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 解析 warp-pool 运行时配置。
|
|
39
|
+
* @param {Record<string,string|undefined>} env
|
|
40
|
+
* @param {{ logDirFn?: () => string }} [deps] 测试可注入 logDir
|
|
41
|
+
*/
|
|
42
|
+
export function resolveWarpConfig(env = process.env, { logDirFn = logDir } = {}) {
|
|
43
|
+
const enabled = envBool(env, "MSLXDFF_WARP_POOL", DEFAULTS.enabled);
|
|
44
|
+
const mode = envStr(env, "MSLXDFF_WARP_MODE", DEFAULTS.mode);
|
|
45
|
+
const count = envInt(env, "MSLXDFF_WARP_COUNT", DEFAULTS.count);
|
|
46
|
+
const basePort = envInt(env, "MSLXDFF_WARP_BASE_PORT", DEFAULTS.basePort);
|
|
47
|
+
const strategy = envStr(env, "MSLXDFF_WARP_STRATEGY", DEFAULTS.strategy) === "request" ? "request" : "session";
|
|
48
|
+
const cooldownMs = envInt(env, "MSLXDFF_WARP_COOLDOWN_MS", DEFAULTS.cooldownMs);
|
|
49
|
+
const dir = envStr(env, "MSLXDFF_WARP_DIR", join(logDirFn(), "warp-pool"));
|
|
50
|
+
const bin = envStr(env, "MSLXDFF_WARP_BIN", "");
|
|
51
|
+
const autodownload = envBool(env, "MSLXDFF_WARP_AUTODOWNLOAD", DEFAULTS.autodownload);
|
|
52
|
+
const probePath = envStr(env, "MSLXDFF_WARP_PROBE_PATH", DEFAULTS.probePath);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
enabled,
|
|
56
|
+
active: enabled && mode === "leader",
|
|
57
|
+
mode,
|
|
58
|
+
count,
|
|
59
|
+
basePort,
|
|
60
|
+
strategy,
|
|
61
|
+
cooldownMs,
|
|
62
|
+
dir,
|
|
63
|
+
bin,
|
|
64
|
+
autodownload,
|
|
65
|
+
probePath,
|
|
66
|
+
registerMinGapMs: DEFAULTS.registerMinGapMs,
|
|
67
|
+
registerMaxGapMs: DEFAULTS.registerMaxGapMs,
|
|
68
|
+
/** 节点端口列表:basePort .. basePort+count-1 */
|
|
69
|
+
ports: Array.from({ length: count }, (_, i) => basePort + i),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// warp-pool · 节点健康:主动探活(经隧道打上游 models,不烧 token)+ 失败冷却 + 连续失败触发重拨换 IP。
|
|
2
|
+
import { probeTunnel } from "./wireproxy.js";
|
|
3
|
+
|
|
4
|
+
export function createHealthMonitor({
|
|
5
|
+
dispatcher,
|
|
6
|
+
baseUrl = "https://opencode.ai",
|
|
7
|
+
probePath = "/zen/v1/models",
|
|
8
|
+
unhealthyAfter = 3,
|
|
9
|
+
intervalMs = 60_000,
|
|
10
|
+
onExhausted = null,
|
|
11
|
+
probe = probeTunnel,
|
|
12
|
+
headers = {},
|
|
13
|
+
log = () => {},
|
|
14
|
+
} = {}) {
|
|
15
|
+
let timer = null;
|
|
16
|
+
let rounds = 0;
|
|
17
|
+
|
|
18
|
+
/** 跑一轮全池探活。返回 { checked, healthy }。 */
|
|
19
|
+
async function tick() {
|
|
20
|
+
if (!dispatcher) return { checked: 0, healthy: 0 };
|
|
21
|
+
const list = dispatcher.entries();
|
|
22
|
+
let healthy = 0;
|
|
23
|
+
for (const e of list) {
|
|
24
|
+
const r = await probe({ port: e.port, baseUrl, path: probePath, headers }).catch((err) => ({
|
|
25
|
+
ok: false,
|
|
26
|
+
error: String(err?.message || err),
|
|
27
|
+
}));
|
|
28
|
+
if (r.ok) {
|
|
29
|
+
dispatcher.markOk(e.index);
|
|
30
|
+
healthy += 1;
|
|
31
|
+
} else {
|
|
32
|
+
dispatcher.markFailure(e.index);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (list.length > 0 && healthy === 0) {
|
|
36
|
+
rounds += 1;
|
|
37
|
+
log(`[warp-pool] 全池 ${list.length} 个节点均不可用(连续第 ${rounds} 轮)`);
|
|
38
|
+
if (rounds >= 2 && typeof onExhausted === "function") {
|
|
39
|
+
rounds = 0;
|
|
40
|
+
await onExhausted().catch((e) => log(`[warp-pool] 重拨失败:${String(e?.message || e)}`));
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
rounds = 0;
|
|
44
|
+
}
|
|
45
|
+
return { checked: list.length, healthy };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
tick,
|
|
50
|
+
start() {
|
|
51
|
+
if (timer || !(intervalMs > 0)) return;
|
|
52
|
+
timer = setInterval(() => {
|
|
53
|
+
tick().catch(() => {});
|
|
54
|
+
}, intervalMs);
|
|
55
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
56
|
+
},
|
|
57
|
+
stop() {
|
|
58
|
+
if (timer) clearInterval(timer);
|
|
59
|
+
timer = null;
|
|
60
|
+
},
|
|
61
|
+
get running() {
|
|
62
|
+
return !!timer;
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// warp-pool · 装配入口。
|
|
2
|
+
//
|
|
3
|
+
// 定位(见 MODULES.md §0):WARP 池是**增量**出口,不是替代成员自身出口;
|
|
4
|
+
// 开关缺省即开(MSLXDFF_WARP_POOL 缺省 1;设 0 则零副作用);只有组长(MSLXDFF_WARP_MODE=leader)才真建池。
|
|
5
|
+
//
|
|
6
|
+
// 关键约束:bootstrap(注册设备 + 起隧道)很慢(注册有 6–15s 间隔、可能退避 60–120s),
|
|
7
|
+
// 因此**绝不阻塞 daemon 启动**——createWarpPool 同步返回,dispatcher 先是空池(直连兜底),
|
|
8
|
+
// bootstrap 完成后 setNodes() 换上真实出口。
|
|
9
|
+
import { resolveWarpConfig } from "./config.js";
|
|
10
|
+
import { createRotatingDispatcher } from "./rotate.js";
|
|
11
|
+
import { createHealthMonitor } from "./health.js";
|
|
12
|
+
import { ensureDir, loadNodes, removeNode, saveNode } from "./nodes.js";
|
|
13
|
+
import { registerWarpDevice } from "./register.js";
|
|
14
|
+
import { ensureBinary, resolveAsset, startTunnel, writeTunnelConf } from "./wireproxy.js";
|
|
15
|
+
/** 重拨节流:连续失败指数退避。缺省开之后,网络不通的机器绝不能让重注册把 CF 刷到 429。 */
|
|
16
|
+
const REDIAL_MIN_GAP_MS = 5 * 60_000;
|
|
17
|
+
const REDIAL_MAX_GAP_MS = 30 * 60_000;
|
|
18
|
+
|
|
19
|
+
/** 注册间隔(同类项目实测:密集注册会让设备落同一网段,且触发 CF 429)。 */
|
|
20
|
+
function gapMs(cfg, rnd = Math.random) {
|
|
21
|
+
const span = Math.max(0, cfg.registerMaxGapMs - cfg.registerMinGapMs);
|
|
22
|
+
return Math.round(cfg.registerMinGapMs + rnd() * span);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 建池。开关缺省即开,故「未启用」= 显式 MSLXDFF_WARP_POOL=0 或 非 leader;那时返回 null,调用方据此不注入。
|
|
28
|
+
* @returns {null | { dispatcher: object, ready: Promise<void>, close: () => Promise<void>, stats: () => object }}
|
|
29
|
+
*/
|
|
30
|
+
export function createWarpPool({
|
|
31
|
+
env = process.env,
|
|
32
|
+
baseUrl = "https://opencode.ai",
|
|
33
|
+
log = (m) => console.error(m),
|
|
34
|
+
register = registerWarpDevice,
|
|
35
|
+
ensureBin = ensureBinary,
|
|
36
|
+
start = startTunnel,
|
|
37
|
+
now = Date.now,
|
|
38
|
+
rnd = Math.random,
|
|
39
|
+
wait = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
40
|
+
} = {}) {
|
|
41
|
+
const cfg = resolveWarpConfig(env);
|
|
42
|
+
if (!cfg.active) return null;
|
|
43
|
+
|
|
44
|
+
const dispatcher = createRotatingDispatcher({ nodes: [], strategy: cfg.strategy, cooldownMs: cfg.cooldownMs, log });
|
|
45
|
+
/** @type {Map<number, {stop: () => void}>} */
|
|
46
|
+
const tunnels = new Map();
|
|
47
|
+
let binPath = "";
|
|
48
|
+
let closed = false;
|
|
49
|
+
let lastRedialAt = 0;
|
|
50
|
+
let redialGapMs = REDIAL_MIN_GAP_MS;
|
|
51
|
+
|
|
52
|
+
// 兜底清理:Node 退出不会回收 spawn 的子进程,daemon 突然退出时同步杀掉隧道。
|
|
53
|
+
process.once("exit", () => {
|
|
54
|
+
for (const t of tunnels.values()) {
|
|
55
|
+
try {
|
|
56
|
+
t.stop();
|
|
57
|
+
} catch {}
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function bringUp(node) {
|
|
62
|
+
writeTunnelConf(cfg.dir, node);
|
|
63
|
+
if (tunnels.has(node.port)) return;
|
|
64
|
+
const t = start({
|
|
65
|
+
binPath,
|
|
66
|
+
dir: cfg.dir,
|
|
67
|
+
port: node.port,
|
|
68
|
+
log,
|
|
69
|
+
onExit: ({ port, code }) => {
|
|
70
|
+
tunnels.delete(port);
|
|
71
|
+
log(`[warp-pool] 隧道 :${port} 退出(code=${code})`);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
tunnels.set(node.port, t);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function bootstrap() {
|
|
78
|
+
if (closed) return;
|
|
79
|
+
ensureDir(cfg.dir);
|
|
80
|
+
try {
|
|
81
|
+
binPath = await ensureBin({ dir: cfg.dir, bin: cfg.bin, autodownload: cfg.autodownload, log });
|
|
82
|
+
} catch (e) {
|
|
83
|
+
const why = !cfg.bin && !resolveAsset()
|
|
84
|
+
? `本平台(${process.platform}-${process.arch})无预编译 wireproxy,如需启用请设 MSLXDFF_WARP_BIN`
|
|
85
|
+
: String(e?.message || e);
|
|
86
|
+
log(`[warp-pool] 出口池跳过:${why}`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (closed) return;
|
|
90
|
+
|
|
91
|
+
// 1. 复用磁盘上已注册的节点
|
|
92
|
+
let nodes = loadNodes(cfg.dir, cfg.ports);
|
|
93
|
+
log(`[warp-pool] 已有节点 ${nodes.length}/${cfg.count}`);
|
|
94
|
+
|
|
95
|
+
// 2. 补足:逐个注册(拉长间隔,避免 CF 注册限流与同网段撞车)
|
|
96
|
+
const used = new Set(nodes.map((n) => n.port));
|
|
97
|
+
const free = cfg.ports.filter((p) => !used.has(p));
|
|
98
|
+
for (const port of free) {
|
|
99
|
+
if (closed) return;
|
|
100
|
+
if (nodes.length >= cfg.count) break;
|
|
101
|
+
try {
|
|
102
|
+
if (nodes.length > 0) await wait(gapMs(cfg, rnd));
|
|
103
|
+
const reg = await register({ log });
|
|
104
|
+
const node = {
|
|
105
|
+
port,
|
|
106
|
+
id: reg.id,
|
|
107
|
+
token: reg.token,
|
|
108
|
+
v4: reg.v4,
|
|
109
|
+
v6: reg.v6,
|
|
110
|
+
endpoint: reg.endpoint,
|
|
111
|
+
peerKey: reg.peerKey,
|
|
112
|
+
privateKey: reg.privateKey,
|
|
113
|
+
registeredAt: new Date(now()).toISOString(),
|
|
114
|
+
};
|
|
115
|
+
saveNode(cfg.dir, node);
|
|
116
|
+
nodes.push(node);
|
|
117
|
+
log(`[warp-pool] 注册节点 :${port}(v6=${reg.v6})`);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
log(`[warp-pool] 注册节点 :${port} 失败:${String(e?.message || e)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 3. 起隧道
|
|
124
|
+
for (const n of nodes) {
|
|
125
|
+
try {
|
|
126
|
+
await bringUp(n);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
log(`[warp-pool] 起隧道 :${n.port} 失败:${String(e?.message || e)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 4. 换上真实出口(失败/退出中的节点仍会被 health 摘除)
|
|
133
|
+
const live = nodes.filter((n) => tunnels.has(n.port));
|
|
134
|
+
if (live.length) {
|
|
135
|
+
await dispatcher.setNodes(live.map((n) => ({ port: n.port, uri: `http://127.0.0.1:${n.port}` })));
|
|
136
|
+
log(`[warp-pool] 出口池就绪:${live.length} 个节点`);
|
|
137
|
+
} else {
|
|
138
|
+
log("[warp-pool] 无可用节点,保持直连");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const monitor = createHealthMonitor({
|
|
143
|
+
dispatcher,
|
|
144
|
+
baseUrl,
|
|
145
|
+
probePath: cfg.probePath,
|
|
146
|
+
log,
|
|
147
|
+
onExhausted: async () => {
|
|
148
|
+
// 重拨 = 重新注册 = 换新出口 IP(代价与限流见 MODULES.md)。
|
|
149
|
+
// 隧道全活却探活全挂 ⇒ 网络路径问题(实测墙内到 CF 的 UDP 被选择性屏蔽),
|
|
150
|
+
// 重注册换不来出路,只会把 CF 注册接口刷到 429 拉黑——故必须过 redialPlan 这道门。
|
|
151
|
+
const plan = redialPlan({ ports: cfg.ports, alivePorts: [...tunnels.keys()], lastRedialAt, redialGapMs, now });
|
|
152
|
+
if (plan.reason === "all-alive") {
|
|
153
|
+
log("[warp-pool] 隧道均存活但全池不通,判定为网络路径问题,跳过重拨");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (plan.reason === "throttled") {
|
|
157
|
+
log(`[warp-pool] 距上次重拨不足 ${Math.round(redialGapMs / 1000)}s,跳过本轮`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const dead = plan.dead;
|
|
161
|
+
lastRedialAt = now();
|
|
162
|
+
redialGapMs = Math.min(redialGapMs * 2, REDIAL_MAX_GAP_MS);
|
|
163
|
+
log(`[warp-pool] ${dead.length} 条隧道已死,重拨(下次最短间隔 ${Math.round(redialGapMs / 1000)}s)`);
|
|
164
|
+
for (const port of dead) {
|
|
165
|
+
if (closed) return;
|
|
166
|
+
try {
|
|
167
|
+
tunnels.get(port)?.stop();
|
|
168
|
+
tunnels.delete(port);
|
|
169
|
+
removeNode(cfg.dir, port);
|
|
170
|
+
const reg = await register({ log });
|
|
171
|
+
saveNode(cfg.dir, {
|
|
172
|
+
port,
|
|
173
|
+
id: reg.id,
|
|
174
|
+
token: reg.token,
|
|
175
|
+
v4: reg.v4,
|
|
176
|
+
v6: reg.v6,
|
|
177
|
+
endpoint: reg.endpoint,
|
|
178
|
+
peerKey: reg.peerKey,
|
|
179
|
+
privateKey: reg.privateKey,
|
|
180
|
+
registeredAt: new Date(now()).toISOString(),
|
|
181
|
+
});
|
|
182
|
+
await bringUp({ port, ...reg });
|
|
183
|
+
} catch (e) {
|
|
184
|
+
log(`[warp-pool] 重拨 :${port} 失败:${String(e?.message || e)}`);
|
|
185
|
+
}
|
|
186
|
+
await wait(gapMs(cfg, rnd));
|
|
187
|
+
}
|
|
188
|
+
if (!closed && [...tunnels.keys()].length) {
|
|
189
|
+
// 重拨确实把隧道拉起来了 ⇒ 退避复位,别让一次网络抖动永久拖慢恢复。
|
|
190
|
+
redialGapMs = REDIAL_MIN_GAP_MS;
|
|
191
|
+
await dispatcher.setNodes([...tunnels.keys()].map((port) => ({ port, uri: `http://127.0.0.1:${port}` })));
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const ready = bootstrap()
|
|
197
|
+
.then(() => {
|
|
198
|
+
if (!closed) monitor.start();
|
|
199
|
+
})
|
|
200
|
+
.catch((e) => log(`[warp-pool] bootstrap 失败:${String(e?.message || e)}`));
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
dispatcher,
|
|
204
|
+
ready,
|
|
205
|
+
config: cfg,
|
|
206
|
+
async close() {
|
|
207
|
+
if (closed) return;
|
|
208
|
+
closed = true;
|
|
209
|
+
monitor.stop();
|
|
210
|
+
for (const t of tunnels.values()) {
|
|
211
|
+
try {
|
|
212
|
+
t.stop();
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
tunnels.clear();
|
|
216
|
+
await dispatcher.close();
|
|
217
|
+
},
|
|
218
|
+
stats() {
|
|
219
|
+
return {
|
|
220
|
+
enabled: true,
|
|
221
|
+
mode: cfg.mode,
|
|
222
|
+
strategy: cfg.strategy,
|
|
223
|
+
dir: cfg.dir,
|
|
224
|
+
tunnels: tunnels.size,
|
|
225
|
+
nodes: dispatcher.stats(),
|
|
226
|
+
};
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 只取轮换 dispatcher 的便捷入口(隧道进程由模块内的 process exit 钩子兜底清理)。
|
|
234
|
+
* 未启用(开关关 / 非 leader)时返回 undefined —— 调用方直接透传即是"关闭态"。
|
|
235
|
+
*/
|
|
236
|
+
export function createWarpDispatcher(opts) {
|
|
237
|
+
return createWarpPool(opts)?.dispatcher;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* 重拨门(纯函数,便于单测):只有「有隧道进程已死」且「距上次重拨超过当前退避间隔」才放行。
|
|
242
|
+
* 隧道全活却探活全挂 ⇒ 网络路径问题(实测:墙内到 CF 的 UDP 被选择性屏蔽),
|
|
243
|
+
* 此时重拨换不来出路,只会把 CF 注册接口刷到 429 拉黑。
|
|
244
|
+
* @returns {{ dead: number[], reason: "ok" | "all-alive" | "throttled" }}
|
|
245
|
+
*/
|
|
246
|
+
export function redialPlan({ ports = [], alivePorts = [], lastRedialAt = 0, redialGapMs = REDIAL_MIN_GAP_MS, now = Date.now } = {}) {
|
|
247
|
+
const alive = new Set(alivePorts);
|
|
248
|
+
const dead = ports.filter((p) => !alive.has(p));
|
|
249
|
+
if (!dead.length) return { dead, reason: "all-alive" };
|
|
250
|
+
if (now() - lastRedialAt < redialGapMs) return { dead, reason: "throttled" };
|
|
251
|
+
return { dead, reason: "ok" };
|
|
252
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
registeredAt: String(j.registeredAt || ""),
|
|
44
|
+
...(j.cooldownUntil ? { cooldownUntil: Number(j.cooldownUntil) } : {}),
|
|
45
|
+
};
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function saveNode(dir, node) {
|
|
52
|
+
const { meta } = nodePaths(dir, node.port);
|
|
53
|
+
return writeSecret(meta, `${JSON.stringify(node, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removeNode(dir, port) {
|
|
57
|
+
const { conf, meta } = nodePaths(dir, port);
|
|
58
|
+
for (const f of [conf, meta]) {
|
|
59
|
+
try { if (existsSync(f)) unlinkSync(f); } catch {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 读回配置目录里已注册的节点(按端口升序)。 */
|
|
64
|
+
export function loadNodes(dir, ports) {
|
|
65
|
+
const out = [];
|
|
66
|
+
for (const p of ports) {
|
|
67
|
+
const n = loadNode(dir, p);
|
|
68
|
+
if (n && n.id && n.token) out.push(n);
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** WireGuard + wireproxy INI([http] 段把隧道暴露成本地 HTTP 代理出口;undici v5 的 ProxyAgent 只吃 http/https)。 */
|
|
74
|
+
export function renderWireproxyConf({ privateKey, v4, v6, endpoint, peerKey, port }) {
|
|
75
|
+
const addresses = [
|
|
76
|
+
v4 ? `Address = ${v4}/32` : "",
|
|
77
|
+
v6 ? `Address = ${v6}/128` : "",
|
|
78
|
+
].filter(Boolean).join("\n");
|
|
79
|
+
return `[Interface]
|
|
80
|
+
PrivateKey = ${privateKey}
|
|
81
|
+
${addresses}
|
|
82
|
+
DNS = 1.1.1.1
|
|
83
|
+
MTU = 1280
|
|
84
|
+
|
|
85
|
+
[Peer]
|
|
86
|
+
PublicKey = ${peerKey}
|
|
87
|
+
AllowedIPs = 0.0.0.0/0, ::/0
|
|
88
|
+
Endpoint = ${endpoint}
|
|
89
|
+
PersistentKeepalive = 25
|
|
90
|
+
|
|
91
|
+
[http]
|
|
92
|
+
BindAddress = 127.0.0.1:${port}
|
|
93
|
+
`;
|
|
94
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
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
|
+
}
|