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.
- package/package.json +1 -1
- package/src/chat-pipeline/terminal-verdict.js +4 -4
- package/src/cli/commands/daemon.js +1 -1
- package/src/cli/index.js +0 -5
- package/src/daemon.js +3 -3
- package/src/runtime/bootstrap.js +1 -1
- package/src/runtime/proc-info.js +2 -2
- package/src/runtime/providers-setup.js +2 -4
- package/src/server.js +3 -3
- package/src/upstream-engine/index.js +2 -17
- package/src/upstream.js +1 -61
- package/src/cli/commands/multi-ip-pool-fetch.js +0 -173
- package/src/cli/commands/multi-ip-pool-test.js +0 -136
- package/src/cli/commands/multi-ip-pool.js +0 -83
- package/src/cli/commands/v6-pool.js +0 -123
- package/src/multi-ip-pool/config.js +0 -155
- package/src/multi-ip-pool/diagnose.js +0 -159
- package/src/multi-ip-pool/dial.js +0 -179
- package/src/multi-ip-pool/dispatch.js +0 -244
- package/src/multi-ip-pool/index.js +0 -174
- package/src/multi-ip-pool/nodes-fetch.js +0 -110
- package/src/multi-ip-pool/nodes.js +0 -205
- package/src/multi-ip-pool/report.js +0 -162
- package/src/multi-ip-pool/selftest.js +0 -168
- package/src/multi-ip-pool/standby.js +0 -157
- package/src/multi-ip-pool/tunnel.js +0 -291
- package/src/v6pool/claim.js +0 -157
- package/src/v6pool/config.js +0 -65
- package/src/v6pool/detect.js +0 -130
- package/src/v6pool/index.js +0 -157
- package/src/v6pool/prefix.js +0 -140
- package/src/v6pool/probe.js +0 -40
- package/src/v6pool/report.js +0 -104
- package/src/v6pool/rotate.js +0 -263
- package/src/v6pool/selftest.js +0 -178
- package/src/warp-pool/config.js +0 -74
- package/src/warp-pool/health.js +0 -106
- package/src/warp-pool/identity.js +0 -141
- package/src/warp-pool/index.js +0 -358
- package/src/warp-pool/keys.js +0 -29
- package/src/warp-pool/nodes.js +0 -95
- package/src/warp-pool/ports.js +0 -160
- package/src/warp-pool/register.js +0 -113
- package/src/warp-pool/report.js +0 -232
- package/src/warp-pool/revive.js +0 -168
- package/src/warp-pool/rotate.js +0 -214
- package/src/warp-pool/strategy.js +0 -43
- package/src/warp-pool/verdict.js +0 -69
- package/src/warp-pool/wireproxy.js +0 -162
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
// multi-ip-pool · 拨号与体检:候选队列 → 起一条隧道 → **打一个 hi 到上游要它 200** → 才算可用出口。
|
|
2
|
-
//
|
|
3
|
-
// 为什么"要 200"而不是"能连上就行"(用户 2026-10-25 定):
|
|
4
|
-
// 这条池的唯一目的是**换新额度**。隧道起来了但那个 IP 已经没额度(429/403),拿它换出口等于白换——
|
|
5
|
-
// 所以判据必须是"这个出口现在真能出活",即免费层 chat 打 `hi` 回 **200**。
|
|
6
|
-
// 请求形状复用 `v6pool/selftest.js`(`stream:true` + 五核心工具名 + 身份四件套):缺任何一项上游回
|
|
7
|
-
// **403 FreeTierError 而不是 429**,那测的是形状、不是额度(真机 2026-10-19 踩过)。
|
|
8
|
-
// 拆成独立模块的原因:装配(index)、拨号(本文件)、备选池(standby)三件事各自都要单测,混一起必超 10KB 门。
|
|
9
|
-
import { getUndici } from "../compat.js";
|
|
10
|
-
import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
|
|
11
|
-
import { loadNodeCache, materializeNode, sampleCandidates } from "./nodes.js";
|
|
12
|
-
import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
|
|
13
|
-
import { bringUpTunnel, tearDownTunnel } from "./tunnel.js";
|
|
14
|
-
import { AGENT_OPTIONS } from "./dispatch.js";
|
|
15
|
-
import { fmtExitIpFail, fmtHi, fmtNodePick, fmtNodes } from "./report.js";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {{ cfg: object, baseUrl?: string, log?: Function, deps?: {
|
|
19
|
-
* bringUpTunnel?, tearDownTunnel?, materializeNode?, fetchNodes?, loadNodeCache?, probeExitIp?,
|
|
20
|
-
* probeHi?, agentFactory?, fetchImpl?, runCmdFn?, spawnFn?, wait?, authFile?, probeSession? } }} opts
|
|
21
|
-
*/
|
|
22
|
-
export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () => {}, deps = {} } = {}) {
|
|
23
|
-
const startSlot = deps.bringUpTunnel || bringUpTunnel;
|
|
24
|
-
const stopSlot = deps.tearDownTunnel || tearDownTunnel;
|
|
25
|
-
const materialize = deps.materializeNode || materializeNode;
|
|
26
|
-
const probeExit = deps.probeExitIp || probeExitIp;
|
|
27
|
-
const probeHi = deps.probeHi || probeUpstreamFrom;
|
|
28
|
-
const { Agent } = getUndici();
|
|
29
|
-
const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, ...AGENT_OPTIONS }));
|
|
30
|
-
|
|
31
|
-
/** 本拨号器起过的所有隧道(含备用的):退出钩子与 close 都从这儿收,一条都不许漏。 */
|
|
32
|
-
const tunnels = new Set();
|
|
33
|
-
const used = new Set(); // 已试过的节点(含正在用的):绝不重复拨同一台
|
|
34
|
-
let pending = [];
|
|
35
|
-
let source = "(未取表)";
|
|
36
|
-
let authFile = deps.authFile || "";
|
|
37
|
-
|
|
38
|
-
function setAuthFile(f) {
|
|
39
|
-
authFile = f || "";
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** 探额度用的免费模型(上游不同模型额度池可能独立,需要时可换)。 */
|
|
43
|
-
function probeModel() {
|
|
44
|
-
return cfg.probeModel || DEFAULT_PROBE_MODEL;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* 「打一个 hi,要 200」:一发请求同时拿两个读数——**有没有额度**(status)与**真实出口 IP**(取证)。
|
|
49
|
-
* @returns {Promise<{ok: boolean, status: number, kind: string, ms: number, ip: string, error: string}>}
|
|
50
|
-
*/
|
|
51
|
-
async function hiProbe(entry) {
|
|
52
|
-
const r = await probeHi({
|
|
53
|
-
addr: entry.tunIp,
|
|
54
|
-
model: probeModel(),
|
|
55
|
-
baseUrl,
|
|
56
|
-
agentFactory,
|
|
57
|
-
fetchImpl: deps.fetchImpl,
|
|
58
|
-
session: deps.probeSession,
|
|
59
|
-
});
|
|
60
|
-
let ip = entry.exitIp || "";
|
|
61
|
-
if (!ip) {
|
|
62
|
-
const agent = agentFactory(entry.tunIp);
|
|
63
|
-
try {
|
|
64
|
-
const e = await probeExit({ agent, timeoutMs: cfg.probeTimeoutMs, fetchImpl: deps.fetchImpl });
|
|
65
|
-
ip = e.ip || "";
|
|
66
|
-
} finally {
|
|
67
|
-
try {
|
|
68
|
-
await agent.close?.();
|
|
69
|
-
} catch {
|
|
70
|
-
/* 一次性 Agent */
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return { ok: Boolean(r.ok), status: Number(r.status) || 0, kind: r.kind || "", ms: Number(r.ms) || 0, ip, error: r.error || "" };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** 取节点表 → 候选队列(Score 降序 + **按簇轮转**,剔除已用过的);`force` = 绕过缓存重取(每小时刷新备选池用)。 */
|
|
78
|
-
async function ensureCandidates({ force = false } = {}) {
|
|
79
|
-
if (!force && pending.length) return pending.length;
|
|
80
|
-
let rows = [];
|
|
81
|
-
if (!force) {
|
|
82
|
-
const cache = (deps.loadNodeCache || loadNodeCache)({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
|
|
83
|
-
if (cache?.rows?.length) {
|
|
84
|
-
rows = cache.rows;
|
|
85
|
-
source = cache.source || "(本地缓存)";
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
if (!rows.length) {
|
|
89
|
-
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
90
|
-
rows = got.rows;
|
|
91
|
-
source = got.source;
|
|
92
|
-
}
|
|
93
|
-
// 按簇轮转(见 nodes.js sampleCandidates):免费表前列常被同一个机房霸榜,纯 Score 序会让「那一片连不上」
|
|
94
|
-
// 变成「池永远起不来」(真机 2026-10-25:三个候选同属 219.100.37.x)。轮转不丢节点,只是把各机房摊开。
|
|
95
|
-
pending = sampleCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore, skip: used, limit: cfg.scan * (cfg.count + cfg.standby + 1) });
|
|
96
|
-
log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
|
|
97
|
-
return pending.length;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
/** 拆一条隧道并忘掉它(先撤策略路由再收子进程,见 tunnel.js)。 */
|
|
101
|
-
async function drop(entry, why = "") {
|
|
102
|
-
if (!entry) return;
|
|
103
|
-
tunnels.delete(entry);
|
|
104
|
-
try {
|
|
105
|
-
await stopSlot({ slot: entry, runCmdFn: deps.runCmdFn, log });
|
|
106
|
-
} catch (e) {
|
|
107
|
-
log(`[multi-ip-pool] ${entry.dev} 拆隧道没拆干净:${String(e?.message || e)}${why ? `(${why})` : ""}`);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* 开一个出口:按序试候选,每个都走「起隧道 → hi 要 200」,**不 200 当场拆掉换下一个**。
|
|
113
|
-
* 为什么这道闸门必须有:隧道进程活着但转不动流量、或那个 IP 早已没额度时,把它塞进池等于
|
|
114
|
-
* 把请求挂到超时或继续 429——比直连更糟(与 WARP 池 probeGate 同一纪律)。
|
|
115
|
-
* @returns {Promise<null | { dev, table, priority, tunIp, pid, stop, node, exitIp }>}
|
|
116
|
-
*/
|
|
117
|
-
async function openSlot(slotIndex, { dev = "", gate = true } = {}) {
|
|
118
|
-
for (let attempt = 1; attempt <= cfg.scan; attempt++) {
|
|
119
|
-
if (!pending.length) {
|
|
120
|
-
try {
|
|
121
|
-
await ensureCandidates();
|
|
122
|
-
} catch (e) {
|
|
123
|
-
log(`[multi-ip-pool] 取节点表失败:${String(e?.message || e)}`);
|
|
124
|
-
return null;
|
|
125
|
-
}
|
|
126
|
-
if (!pending.length) return null;
|
|
127
|
-
}
|
|
128
|
-
const row = pending.shift();
|
|
129
|
-
if (!row || used.has(row.hostName)) continue;
|
|
130
|
-
used.add(row.hostName);
|
|
131
|
-
log(fmtNodePick({ i: attempt, n: cfg.scan, row, dev }));
|
|
132
|
-
let slot = null;
|
|
133
|
-
try {
|
|
134
|
-
let node = row;
|
|
135
|
-
if (!node.conf) {
|
|
136
|
-
node = materialize({ dir: cfg.dir, row });
|
|
137
|
-
if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
|
|
138
|
-
}
|
|
139
|
-
slot = await startSlot({
|
|
140
|
-
cfg,
|
|
141
|
-
slotIndex,
|
|
142
|
-
node,
|
|
143
|
-
deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log, onExit: () => tunnels.delete(slot) },
|
|
144
|
-
});
|
|
145
|
-
tunnels.add(slot);
|
|
146
|
-
const p = await hiProbe(slot);
|
|
147
|
-
log(fmtHi({ dev: slot.dev, model: probeModel(), ip: p.ip, ...p }));
|
|
148
|
-
if (!p.ip) log(fmtExitIpFail({ dev: slot.dev, error: p.error || "出口取证无响应" }));
|
|
149
|
-
if (gate && p.status !== 200) {
|
|
150
|
-
await drop(slot, "hi 未回 200");
|
|
151
|
-
continue;
|
|
152
|
-
}
|
|
153
|
-
return { ...slot, node, exitIp: p.ip || "" };
|
|
154
|
-
} catch (e) {
|
|
155
|
-
await drop(slot);
|
|
156
|
-
log(`[multi-ip-pool] ${slot?.dev || `${cfg.devPrefix}${slotIndex}`} 起隧道失败:${String(e?.message || e)}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
return null;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
return {
|
|
163
|
-
tunnels,
|
|
164
|
-
agentFactory,
|
|
165
|
-
used,
|
|
166
|
-
setAuthFile,
|
|
167
|
-
probeModel,
|
|
168
|
-
hiProbe,
|
|
169
|
-
ensureCandidates,
|
|
170
|
-
openSlot,
|
|
171
|
-
drop,
|
|
172
|
-
get sourceLabel() {
|
|
173
|
-
return source;
|
|
174
|
-
},
|
|
175
|
-
get pendingCount() {
|
|
176
|
-
return pending.length;
|
|
177
|
-
},
|
|
178
|
-
};
|
|
179
|
-
}
|
|
@@ -1,244 +0,0 @@
|
|
|
1
|
-
// multi-ip-pool · 槽位 dispatcher:每槽一条隧道 + 一个把源地址绑到 tun 地址的 undici Agent。
|
|
2
|
-
//
|
|
3
|
-
// 契约与 v6pool/warp-pool 的 dispatcher 完全一致:`dispatch(opts, handler)` + `close()`,
|
|
4
|
-
// 注入点同为 transport 的 extDispatcher(legacy 与 SDK 两条路共用同一个对象)。
|
|
5
|
-
// 与 v6 池的差别只在「换出口」这一步里干了什么:
|
|
6
|
-
// v6 :换个源地址(≈0 成本)
|
|
7
|
-
// 这里:拆掉旧隧道 + 用下一个 VPNGate 节点起新隧道(秒级;所以**绝不用于请求内换址**)
|
|
8
|
-
//
|
|
9
|
-
// 判决表复用 warp-pool/verdict.js(403 FreeTierError 与 429 FreeUsageLimitError 同判、强制换,
|
|
10
|
-
// BlackUsageLimitError 绝不换)——与生产同源,不自造一套。
|
|
11
|
-
import { getUndici } from "../compat.js";
|
|
12
|
-
import { headerValue, tapHandler } from "../warp-pool/rotate.js";
|
|
13
|
-
import { createSelector } from "../warp-pool/strategy.js";
|
|
14
|
-
import { SIGNAL_STATUSES, classifyWarpSignal } from "../warp-pool/verdict.js";
|
|
15
|
-
import { fmtDirect, fmtExitIp, fmtExitIpFail, fmtFirstOk, fmtNoRotate, fmtSwap, fmtSwapFail, fmtVerdictNo } from "./report.js";
|
|
16
|
-
|
|
17
|
-
const SESSION_HEADER = "x-opencode-session";
|
|
18
|
-
|
|
19
|
-
/** 每槽 Agent 的连接池参数(与 v6pool 同口径:注入 dispatcher 后 transport 不自带池)。 */
|
|
20
|
-
export const AGENT_OPTIONS = { connections: 4, pipelining: 1, keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000 };
|
|
21
|
-
|
|
22
|
-
/** 误关旧 Agent 不影响新出口,但也不该静默吞掉(只在 debug 里报)。 */
|
|
23
|
-
function closeQuietly(agent, log) {
|
|
24
|
-
try {
|
|
25
|
-
Promise.resolve(agent?.close?.()).catch((e) => log(`[multi-ip-pool] 关旧出口 Agent 失败:${String(e?.message || e)}`));
|
|
26
|
-
} catch (e) {
|
|
27
|
-
log(`[multi-ip-pool] 关旧出口 Agent 抛错:${String(e?.message || e)}`);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @param {{ slots?: object[], strategy?: "session"|"request", cooldownMs?: number, rotate?: boolean,
|
|
33
|
-
* now?: () => number, log?: (s: string) => void,
|
|
34
|
-
* agentFactory?: (addr: string) => object, fallbackAgent?: object, agentOptions?: object,
|
|
35
|
-
* selector?: object, rebuild?: (index: number, prev: object) => Promise<object>,
|
|
36
|
-
* verifyExit?: (t: object) => Promise<{ ok: boolean, ip?: string, ms?: number, error?: string }> }} [opts]
|
|
37
|
-
*/
|
|
38
|
-
export function createMultiIpDispatcher({
|
|
39
|
-
slots = [],
|
|
40
|
-
strategy = "session",
|
|
41
|
-
cooldownMs = 600_000,
|
|
42
|
-
rotate = true,
|
|
43
|
-
now = Date.now,
|
|
44
|
-
log = () => {},
|
|
45
|
-
agentFactory,
|
|
46
|
-
fallbackAgent,
|
|
47
|
-
agentOptions = AGENT_OPTIONS,
|
|
48
|
-
selector = createSelector({ strategy }),
|
|
49
|
-
rebuild = null,
|
|
50
|
-
verifyExit = null,
|
|
51
|
-
} = {}) {
|
|
52
|
-
const { Agent } = getUndici();
|
|
53
|
-
if (typeof agentFactory !== "function") {
|
|
54
|
-
if (typeof Agent !== "function") throw new Error("undici Agent 不可用:无法建立多出口池");
|
|
55
|
-
agentFactory = (addr) => new Agent({ connect: { localAddress: addr }, ...agentOptions });
|
|
56
|
-
}
|
|
57
|
-
// 空池兜底 = 直连:池还没建好(或全槽不可用)时行为与未启用完全一致。
|
|
58
|
-
const fallback = fallbackAgent !== undefined ? fallbackAgent : typeof Agent === "function" ? new Agent(agentOptions) : null;
|
|
59
|
-
|
|
60
|
-
let list = [];
|
|
61
|
-
const history = [];
|
|
62
|
-
let directWarned = false;
|
|
63
|
-
|
|
64
|
-
function buildSlot(index, t) {
|
|
65
|
-
return {
|
|
66
|
-
index,
|
|
67
|
-
node: t.node || null,
|
|
68
|
-
dev: t.dev,
|
|
69
|
-
tunIp: t.tunIp || "",
|
|
70
|
-
table: t.table,
|
|
71
|
-
priority: t.priority,
|
|
72
|
-
stop: t.stop,
|
|
73
|
-
exitIp: t.exitIp || "",
|
|
74
|
-
agent: t.tunIp ? agentFactory(t.tunIp) : null,
|
|
75
|
-
cooldownUntil: 0,
|
|
76
|
-
swapping: false,
|
|
77
|
-
swaps: 0,
|
|
78
|
-
counts: {},
|
|
79
|
-
firstOkAt: 0,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** 装上初始槽(bootstrap 探活通过后调用;空数组 = 池为空 ⇒ 直连兜底)。 */
|
|
84
|
-
async function setSlots(next = []) {
|
|
85
|
-
const old = list;
|
|
86
|
-
list = (next || []).map((t, i) => buildSlot(i, t));
|
|
87
|
-
for (const e of old) closeQuietly(e.agent, log);
|
|
88
|
-
return list.length;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function availableIndexes() {
|
|
92
|
-
const t = now();
|
|
93
|
-
return list.filter((e) => e.agent && e.cooldownUntil <= t && !e.swapping).map((e) => e.index);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
function markFailure(index) {
|
|
97
|
-
const e = list[index];
|
|
98
|
-
if (!e) return;
|
|
99
|
-
e.cooldownUntil = now() + cooldownMs;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function markOk(index) {
|
|
103
|
-
const e = list[index];
|
|
104
|
-
if (e) e.cooldownUntil = 0;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* 换该槽的出口:挑下一个候选节点起新隧道(rebuild 由 index.js 提供,含拆旧/起新/探活)。
|
|
109
|
-
* 失败时**绝不假装成功**:把该槽标记为无出口(tunIp 置空 ⇒ 不再被选中),并如实报错。
|
|
110
|
-
* @returns {Promise<{ok?: boolean, skipped?: boolean, from?: string, to?: string, reason?: string}>}
|
|
111
|
-
*/
|
|
112
|
-
async function swap(index, verdict = {}) {
|
|
113
|
-
const e = list[index];
|
|
114
|
-
if (!e) return { skipped: true, reason: "槽不存在" };
|
|
115
|
-
if (e.swapping) return { skipped: true, reason: "该槽已在换出口中" };
|
|
116
|
-
if (!rebuild) return { skipped: true, reason: "没有配置换出口实现(rebuild)" };
|
|
117
|
-
e.swapping = true;
|
|
118
|
-
const prev = { dev: e.dev, tunIp: e.tunIp, node: e.node, agent: e.agent, stop: e.stop, exitIp: e.exitIp, counts: { ...e.counts }, swaps: e.swaps };
|
|
119
|
-
try {
|
|
120
|
-
const t = await rebuild(index, prev);
|
|
121
|
-
if (!t || !t.tunIp) throw new Error("换出口没拿到可用的新隧道");
|
|
122
|
-
const exit = verifyExit ? await verifyExit(t) : null;
|
|
123
|
-
e.node = t.node || null;
|
|
124
|
-
e.dev = t.dev || e.dev;
|
|
125
|
-
e.tunIp = t.tunIp;
|
|
126
|
-
e.table = t.table ?? e.table;
|
|
127
|
-
e.priority = t.priority ?? e.priority;
|
|
128
|
-
e.stop = t.stop || null;
|
|
129
|
-
e.exitIp = exit?.ip || t.exitIp || e.exitIp;
|
|
130
|
-
e.agent = agentFactory(t.tunIp);
|
|
131
|
-
e.cooldownUntil = 0;
|
|
132
|
-
e.swaps = prev.swaps + 1;
|
|
133
|
-
e.counts = {};
|
|
134
|
-
e.firstOkAt = 0;
|
|
135
|
-
closeQuietly(prev.agent, log);
|
|
136
|
-
log(fmtSwap({ dev: e.dev, from: prev.tunIp, to: e.tunIp, status: verdict.status, kind: verdict.kind, forced: verdict.forced, swaps: e.swaps }));
|
|
137
|
-
if (exit && !exit.ok) log(fmtExitIpFail({ dev: e.dev, error: exit.error }));
|
|
138
|
-
else if (exit) log(fmtExitIp({ dev: e.dev, ip: exit.ip, ms: exit.ms, prev: prev.exitIp }));
|
|
139
|
-
history.push({ dev: e.dev, tunIp: e.tunIp, swaps: e.swaps, counts: { ...prev.counts }, exitIp: e.exitIp });
|
|
140
|
-
if (history.length > 32) history.shift();
|
|
141
|
-
return { ok: true, from: prev.tunIp, to: e.tunIp, node: e.node };
|
|
142
|
-
} catch (err) {
|
|
143
|
-
// 旧隧道可能已经被拆掉了 ⇒ 这槽当前没有出口,必须置空(否则会被选中并挂到超时)
|
|
144
|
-
e.agent = null;
|
|
145
|
-
e.tunIp = "";
|
|
146
|
-
e.cooldownUntil = now() + cooldownMs;
|
|
147
|
-
log(fmtSwapFail({ dev: prev.dev, error: String(err?.message || err) }));
|
|
148
|
-
return { skipped: false, error: String(err?.message || err) };
|
|
149
|
-
} finally {
|
|
150
|
-
e.swapping = false;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** 上游响应旁听:记账 → 换出口后首个 200 的取证行 → 配额信号才换出口。 */
|
|
155
|
-
function onTap(signal, e) {
|
|
156
|
-
const status = Number(signal?.status);
|
|
157
|
-
e.counts[status] = (e.counts[status] || 0) + 1;
|
|
158
|
-
if (status === 200 && !e.firstOkAt) {
|
|
159
|
-
e.firstOkAt = now();
|
|
160
|
-
log(fmtFirstOk({ dev: e.dev, ip: e.exitIp || e.tunIp, swaps: e.swaps, counts: e.counts }));
|
|
161
|
-
}
|
|
162
|
-
if (!SIGNAL_STATUSES.includes(status)) return;
|
|
163
|
-
const v = classifyWarpSignal(signal);
|
|
164
|
-
if (!rotate) {
|
|
165
|
-
log(fmtNoRotate({ dev: e.dev, status, kind: v.kind }));
|
|
166
|
-
return;
|
|
167
|
-
}
|
|
168
|
-
if (!v.rotate) {
|
|
169
|
-
log(fmtVerdictNo({ dev: e.dev, status, kind: v.kind, why: v.why }));
|
|
170
|
-
return;
|
|
171
|
-
}
|
|
172
|
-
const dev = e.dev;
|
|
173
|
-
void swap(e.index, { ...v, status }).catch((err) => log(fmtSwapFail({ dev, error: String(err?.message || err) })));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
return {
|
|
177
|
-
/** 每次请求调一次 = 选槽粒度(session 策略下同会话粘同一出口)。 */
|
|
178
|
-
dispatch(opts, handler) {
|
|
179
|
-
const avail = availableIndexes();
|
|
180
|
-
if (!avail.length) {
|
|
181
|
-
// 空池/全槽不可用 ⇒ 直连兜底(行为与未启用一致),绝不硬派给已知不可用的槽。
|
|
182
|
-
if (!list.length || !list.some((e) => e.agent)) {
|
|
183
|
-
if (fallback) return fallback.dispatch(opts, handler);
|
|
184
|
-
throw new Error("multi-ip-pool:出口池为空且无直连兜底");
|
|
185
|
-
}
|
|
186
|
-
if (!directWarned) {
|
|
187
|
-
directWarned = true;
|
|
188
|
-
log(fmtDirect({ reason: `全部 ${list.length} 条隧道都在冷却/换出口中(下一条恢复后自动回到池内)` }));
|
|
189
|
-
}
|
|
190
|
-
return (fallback || list[0].agent).dispatch(opts, handler);
|
|
191
|
-
}
|
|
192
|
-
directWarned = false;
|
|
193
|
-
const sessionKey = strategy === "session" ? headerValue(opts?.headers, SESSION_HEADER) : "";
|
|
194
|
-
const e = list[selector.pick(avail, sessionKey)];
|
|
195
|
-
return e.agent.dispatch(opts, tapHandler(handler, (sig) => onTap(sig, e)));
|
|
196
|
-
},
|
|
197
|
-
|
|
198
|
-
setSlots,
|
|
199
|
-
|
|
200
|
-
/** 手动换某槽出口(供运维/测试;正常路径由配额信号触发)。 */
|
|
201
|
-
swap,
|
|
202
|
-
|
|
203
|
-
/** 只读快照(换出口后首个 200 是否出现 = 「换 IP 有没有换来新额度」的证据)。 */
|
|
204
|
-
stats() {
|
|
205
|
-
const t = now();
|
|
206
|
-
return {
|
|
207
|
-
slots: list.map((e) => ({
|
|
208
|
-
dev: e.dev,
|
|
209
|
-
tunIp: e.tunIp,
|
|
210
|
-
exitIp: e.exitIp,
|
|
211
|
-
node: e.node?.hostName || "",
|
|
212
|
-
cooling: e.cooldownUntil > t,
|
|
213
|
-
swapping: e.swapping,
|
|
214
|
-
swaps: e.swaps,
|
|
215
|
-
counts: { ...e.counts },
|
|
216
|
-
firstOk: !!e.firstOkAt,
|
|
217
|
-
})),
|
|
218
|
-
history: history.map((h) => ({ ...h, counts: { ...h.counts } })),
|
|
219
|
-
};
|
|
220
|
-
},
|
|
221
|
-
|
|
222
|
-
entries() {
|
|
223
|
-
return list.map((e) => ({ index: e.index, dev: e.dev, tunIp: e.tunIp, cooldownUntil: e.cooldownUntil, swaps: e.swaps }));
|
|
224
|
-
},
|
|
225
|
-
|
|
226
|
-
async close() {
|
|
227
|
-
const old = list;
|
|
228
|
-
list = [];
|
|
229
|
-
for (const e of old) closeQuietly(e.agent, log);
|
|
230
|
-
if (fallback) closeQuietly(fallback, log);
|
|
231
|
-
},
|
|
232
|
-
|
|
233
|
-
async destroy() {
|
|
234
|
-
return this.close();
|
|
235
|
-
},
|
|
236
|
-
|
|
237
|
-
markFailure,
|
|
238
|
-
markOk,
|
|
239
|
-
|
|
240
|
-
get size() {
|
|
241
|
-
return list.length;
|
|
242
|
-
},
|
|
243
|
-
};
|
|
244
|
-
}
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
// multi-ip-pool · 装配入口(MODULES.md §0/§2):只做「门 + 接线 + 生命周期」,
|
|
2
|
-
// 拨号与体检在 dial.js、备选池在 standby.js、选槽与换槽在 dispatch.js(每件事各自可单测)。
|
|
3
|
-
//
|
|
4
|
-
// 定位:**只有组长启用的第三.种出口源**——用 VPNGate 免费 OpenVPN 节点换出口 IP,专供 opencode 免费层。
|
|
5
|
-
// 用户 2026-10-25 的四条裁决(本文件的形状就是这四条):
|
|
6
|
-
// ① 只有组长建池(组员与没带组的单机都不建,强开需显式 MODE=leader);
|
|
7
|
-
// ② 只有 Linux(tun 设备 + ip rule);
|
|
8
|
-
// ③ 定时预拨**不多于 5** 条备选 IP(缺省每小时重拨一批),429 一到立即启用备胎,不现场拨号;
|
|
9
|
-
// ④ 启用备用 IP 时**打一个 hi 到 opencode.ai,要它 200** —— 没 200 的出口不算可用,也不配当备用。
|
|
10
|
-
//
|
|
11
|
-
// 门(任一不过 ⇒ 返回 null,调用方据此不注入,行为与未启用完全一致):
|
|
12
|
-
// 开关 → 角色 → 平台 → root → openvpn 可执行(最后一条在 bootstrap 里判,绝不阻塞启动)。
|
|
13
|
-
import { resolveMultiIpConfig, resolveRole } from "./config.js";
|
|
14
|
-
import { createDialer } from "./dial.js";
|
|
15
|
-
import { createStandby } from "./standby.js";
|
|
16
|
-
import { createMultiIpDispatcher as createDispatcher } from "./dispatch.js";
|
|
17
|
-
import { writeAuthFile } from "./nodes.js";
|
|
18
|
-
import { checkOpenvpn, isRoot, isSupportedPlatform } from "./tunnel.js";
|
|
19
|
-
import { fmtClose, fmtConfig, fmtDisabled, fmtNoOpenvpn, fmtNoRoot, fmtPlatformSkip, fmtPool, fmtPoolEmpty, fmtRoleOk, fmtRoleSkip } from "./report.js";
|
|
20
|
-
import { loadGroupsJoined } from "../state.js";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* @param {{ env?: object, baseUrl?: string, log?: (s: string) => void,
|
|
24
|
-
* deps?: { joined?, platform?, supported?, isRoot?, checkOpenvpn?, authFile?, schedule?, cancel?,
|
|
25
|
-
* bringUpTunnel?, tearDownTunnel?, materializeNode?, fetchNodes?, loadNodeCache?,
|
|
26
|
-
* probeExitIp?, probeHi?, agentFactory?, fetchImpl?, runCmdFn?, spawnFn?, wait?, exitHook? } }} [opts]
|
|
27
|
-
* @returns {null | { dispatcher, standby, ready, config, role, close, stats }}
|
|
28
|
-
*/
|
|
29
|
-
export function createMultiIpPool({ env = process.env, baseUrl = "https://opencode.ai", log = (m) => console.error(m), deps = {} } = {}) {
|
|
30
|
-
const cfg = resolveMultiIpConfig(env);
|
|
31
|
-
if (!cfg.enabled) {
|
|
32
|
-
log(fmtDisabled());
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
// ① 角色门:只有组长(裁决 2026-10-25,从"组长或单机"收紧为"只有组长")
|
|
36
|
-
const role = resolveRole({ mode: cfg.mode, joined: deps.joined ?? loadGroupsJoined() });
|
|
37
|
-
if (!role.active) {
|
|
38
|
-
log(fmtRoleSkip(role));
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
// ② 平台门:只有 Linux
|
|
42
|
-
const platform = deps.platform || process.platform;
|
|
43
|
-
if (!(deps.supported ?? isSupportedPlatform(platform))) {
|
|
44
|
-
log(fmtPlatformSkip({ platform, why: "本功能只在 Linux 上可用" }));
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
// ③ 权限门
|
|
48
|
-
if (!(deps.isRoot ?? isRoot)()) {
|
|
49
|
-
log(fmtNoRoot());
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
log(fmtConfig(cfg));
|
|
53
|
-
log(fmtRoleOk(role));
|
|
54
|
-
|
|
55
|
-
const dialer = createDialer({ cfg, baseUrl, log, deps });
|
|
56
|
-
const standby = createStandby({ cfg, log, dialer, schedule: deps.schedule, cancel: deps.cancel, now: deps.now });
|
|
57
|
-
let closed = false;
|
|
58
|
-
|
|
59
|
-
const dispatcher = createDispatcher({
|
|
60
|
-
slots: [],
|
|
61
|
-
strategy: cfg.strategy,
|
|
62
|
-
cooldownMs: cfg.cooldownMs,
|
|
63
|
-
rotate: cfg.rotate,
|
|
64
|
-
log,
|
|
65
|
-
agentFactory: dialer.agentFactory,
|
|
66
|
-
/**
|
|
67
|
-
* 换出口(429 路径):**先用备选池里已验通的备胎**(裁决③的"立即启用"),
|
|
68
|
-
* 备胎为空才现场拨一条新的(拨号内部同样要过 hi 200 闸门)。
|
|
69
|
-
* 旧隧道在拿到新出口**之后**才拆:换出口全程都有一条能用的出口,不把池打空。
|
|
70
|
-
*/
|
|
71
|
-
rebuild: async (index, prev) => {
|
|
72
|
-
const spare = cfg.standby ? await standby.take() : null;
|
|
73
|
-
const opened = spare || (await dialer.openSlot(index, { dev: prev?.dev || "" }));
|
|
74
|
-
if (!opened) throw new Error("备选池为空且候选节点都没通过 hi 验,本槽暂时没有出口");
|
|
75
|
-
if (prev) await dialer.drop(prev, "换出口后拆旧隧道");
|
|
76
|
-
return opened;
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
async function bootstrap() {
|
|
81
|
-
if (closed) return;
|
|
82
|
-
const openvpn = await (deps.checkOpenvpn || checkOpenvpn)(cfg.openvpnBin, { runCmdFn: deps.runCmdFn });
|
|
83
|
-
if (!openvpn.ok) {
|
|
84
|
-
log(fmtNoOpenvpn({ bin: cfg.openvpnBin, why: openvpn.why }));
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
dialer.setAuthFile(deps.authFile || writeAuthFile({ dir: cfg.dir, user: cfg.authUser, pass: cfg.authPass }));
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
await dialer.ensureCandidates();
|
|
91
|
-
} catch (e) {
|
|
92
|
-
log(fmtPoolEmpty({ tried: 0, why: `节点表取不到:${String(e?.message || e)}` }));
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const slots = [];
|
|
97
|
-
for (let i = 0; i < cfg.count; i++) {
|
|
98
|
-
if (closed) return;
|
|
99
|
-
const opened = await dialer.openSlot(i);
|
|
100
|
-
if (!opened) break;
|
|
101
|
-
slots.push(opened);
|
|
102
|
-
}
|
|
103
|
-
if (slots.length) {
|
|
104
|
-
await dispatcher.setSlots(slots);
|
|
105
|
-
log(fmtPool({ pooled: slots.length, tried: dialer.used.size, exits: slots.map((s) => s.exitIp || "(未取证)") }));
|
|
106
|
-
} else {
|
|
107
|
-
log(fmtPoolEmpty({ tried: dialer.used.size, why: "候选节点都没起通、或起来后 hi 拿不到 200(可能被墙 / 节点已下线 / 额度被同出口的其他人吃光)" }));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
// ③ 备选池:正式槽成没成都要起(备胎本身就是"下一次 429 的出口"),并按小时刷新
|
|
111
|
-
if (cfg.standby) {
|
|
112
|
-
standby.start();
|
|
113
|
-
void standby.refill({ force: true }).catch((e) => log(`[multi-ip-pool] 备选池首轮补位失败:${String(e?.message || e)}`));
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// 退出兜底:daemon 被强杀时 `exit` 钩子仍会跑;这里只能做同步事——把每条隧道的子进程都收掉
|
|
118
|
-
// (openvpn 子进程留着 = 白占 tun 设备与策略路由表,下次启动还得人工清)。
|
|
119
|
-
if (deps.exitHook !== false) {
|
|
120
|
-
process.once("exit", () => {
|
|
121
|
-
closed = true;
|
|
122
|
-
for (const t of dialer.tunnels) {
|
|
123
|
-
try {
|
|
124
|
-
t.stop?.();
|
|
125
|
-
} catch {
|
|
126
|
-
/* 已经退了 */
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
const ready = bootstrap().catch((e) => log(`[multi-ip-pool] bootstrap 失败:${String(e?.message || e)}`));
|
|
133
|
-
|
|
134
|
-
return {
|
|
135
|
-
dispatcher,
|
|
136
|
-
standby,
|
|
137
|
-
ready,
|
|
138
|
-
config: cfg,
|
|
139
|
-
role,
|
|
140
|
-
async close() {
|
|
141
|
-
if (closed) return;
|
|
142
|
-
closed = true;
|
|
143
|
-
await standby.stop().catch(() => {}); // 先停定时器,免得收摊路上又补出一批
|
|
144
|
-
let failed = 0;
|
|
145
|
-
const all = [...dialer.tunnels];
|
|
146
|
-
for (const t of all) {
|
|
147
|
-
try {
|
|
148
|
-
await dialer.drop(t, "收摊");
|
|
149
|
-
} catch {
|
|
150
|
-
failed += 1;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
await dispatcher.close();
|
|
154
|
-
log(fmtClose({ closed: all.length - failed, failed }));
|
|
155
|
-
},
|
|
156
|
-
stats() {
|
|
157
|
-
return {
|
|
158
|
-
enabled: true,
|
|
159
|
-
role: role.role,
|
|
160
|
-
count: cfg.count,
|
|
161
|
-
standbyWant: cfg.standby,
|
|
162
|
-
tunnels: dialer.tunnels.size,
|
|
163
|
-
dir: cfg.dir,
|
|
164
|
-
standby: standby.stats(),
|
|
165
|
-
...dispatcher.stats(),
|
|
166
|
-
};
|
|
167
|
-
},
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/** 只取 dispatcher 的便捷入口;未启用时返回 undefined(调用方直接透传即"关闭态")。 */
|
|
172
|
-
export function createMultiIpDispatcher(opts) {
|
|
173
|
-
return createMultiIpPool(opts)?.dispatcher;
|
|
174
|
-
}
|