mslxdff 0.1.239 → 0.1.241
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/cli/commands/multi-ip-pool.js +174 -0
- package/src/cli/index.js +3 -0
- package/src/multi-ip-pool/config.js +155 -0
- package/src/multi-ip-pool/dial.js +177 -0
- package/src/multi-ip-pool/dispatch.js +244 -0
- package/src/multi-ip-pool/index.js +174 -0
- package/src/multi-ip-pool/nodes-fetch.js +110 -0
- package/src/multi-ip-pool/nodes.js +150 -0
- package/src/multi-ip-pool/report.js +157 -0
- package/src/multi-ip-pool/selftest.js +163 -0
- package/src/multi-ip-pool/standby.js +157 -0
- package/src/multi-ip-pool/tunnel.js +280 -0
- package/src/v6pool/index.js +7 -2
|
@@ -0,0 +1,244 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// multi-ip-pool · 节点表与出口的**网络侧**:取表(多源回退 + 本地表文件)/ 出口 IP 取证 / fetch 失败归因。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一份:出口池的成败在受限网络里完全取决于「表取不取得到」,这一层的诊断信息
|
|
4
|
+
// (DNS 污染 / 连接被掐 / 超时)必须与解析层分开演进。纯解析与落盘在 ./nodes.js,本文件只碰网络。
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { compatFetch, getUndici, timeoutSignal } from "../compat.js";
|
|
9
|
+
import { EXIT_PROBE_URL, parseExitIp } from "../warp-pool/revive.js";
|
|
10
|
+
import { parseVpnGateCsv, saveNodeCache } from "./nodes.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* fetch 失败的人话原因:undici 一律只回 `fetch failed`,真凶在 `e.cause`。
|
|
14
|
+
* 这个区分是**功能性的**:受限网络下 ENOTFOUND(DNS 污染)与 ECONNRESET(连接被掐)
|
|
15
|
+
* 与超时(IP 被墙)意味着完全不同的对策,糊成一句「fetch failed」等于没诊断。
|
|
16
|
+
*/
|
|
17
|
+
export function describeFetchError(e) {
|
|
18
|
+
const c = e?.cause || {};
|
|
19
|
+
const code = String(c.code || c.errno || "");
|
|
20
|
+
const msg = String(c.message || e?.message || "");
|
|
21
|
+
if (code === "ENOTFOUND") return "DNS 解析失败 ENOTFOUND(域名可能被污染或不存在)";
|
|
22
|
+
if (code === "EAI_AGAIN") return "DNS 暂时解析不了 EAI_AGAIN(当前网络解析不了这个域)";
|
|
23
|
+
if (code === "ECONNREFUSED") return "连接被拒 ECONNREFUSED(端口没开或服务已下线)";
|
|
24
|
+
if (code === "ECONNRESET" || code === "EPIPE") return "连接被重置 ECONNRESET(中途被掐断,常见于墙/防火墙)";
|
|
25
|
+
if (code === "ETIMEDOUT" || /timeout|timed out|deadline|abort/i.test(msg)) return "连接超时(IP 不可达或被静默丢包)";
|
|
26
|
+
if (/cert|self-signed|SSL|TLS|handshake/i.test(msg + code)) return `TLS 握手失败 ${code || msg}`.trim();
|
|
27
|
+
if (code) return `网络失败 ${code}`;
|
|
28
|
+
return (msg || String(e || "未知原因")).slice(0, 70);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 把任何异常归成人话(有 cause 走 fetch 归因,否则取 message)。 */
|
|
32
|
+
export function errorWhy(e) {
|
|
33
|
+
return e && e.cause ? describeFetchError(e) : String(e?.message || e);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 节点源 scheme 判定:`http(s)://` 走 fetch;`file://` 与裸路径都当**本地表文件**(受限网络「抓一份带过去」)。 */
|
|
37
|
+
export function sourceToLocalPath(url) {
|
|
38
|
+
const s = String(url || "").trim();
|
|
39
|
+
if (!s || /^https?:\/\//i.test(s)) return null;
|
|
40
|
+
if (/^file:\/\/\//i.test(s) || /^file:\/\//i.test(s)) {
|
|
41
|
+
try {
|
|
42
|
+
return fileURLToPath(s);
|
|
43
|
+
} catch {
|
|
44
|
+
return s.replace(/^file:\/\/\/?/i, "");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 逐源尝试取表;任一源返回可解析的 CSV 即成功(并顺手写缓存)。
|
|
52
|
+
* 全失败时**逐源原因都进异常**(受限网络下「为什么取不到」就是用户唯一的线索)。
|
|
53
|
+
* @returns {Promise<{ source: string, rows: object[], text: string }>} text 是原始 CSV,供 `fetchNodesToFile` 落盘
|
|
54
|
+
*/
|
|
55
|
+
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), log = () => {} } = {}) {
|
|
56
|
+
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
57
|
+
const fails = [];
|
|
58
|
+
for (const url of sources) {
|
|
59
|
+
try {
|
|
60
|
+
const local = sourceToLocalPath(url);
|
|
61
|
+
let text = "";
|
|
62
|
+
if (local !== null) {
|
|
63
|
+
text = readFileSync(local, "utf8");
|
|
64
|
+
} else {
|
|
65
|
+
const res = await f(url, { signal: timeoutSignal(timeoutMs) });
|
|
66
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
67
|
+
text = await res.text();
|
|
68
|
+
}
|
|
69
|
+
const rows = parseVpnGateCsv(text);
|
|
70
|
+
if (!rows.length) throw new Error("返回体不是可解析的 VPNGate CSV");
|
|
71
|
+
log(`[multi-ip-pool] 节点表取自 ${url}(${rows.length} 个节点)`);
|
|
72
|
+
if (dir) saveNodeCache({ dir, source: url, rows, now });
|
|
73
|
+
return { source: url, rows, text };
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const why = errorWhy(e);
|
|
76
|
+
fails.push(`${url}:${why}`);
|
|
77
|
+
log(`[multi-ip-pool] 节点源失败 ${url}:${why}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
throw new Error(fails.length ? `全部 ${fails.length} 个节点源都失败 → ${fails.join(" | ")}` : "没配任何节点源(MSLXDFF_MULTI_IP_SOURCES 为空)");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 取表并原样落一份 CSV(`-multi-ip-pool fetch` 的实现):在能连的机器上抓,scp 进受限网络的组长机,
|
|
85
|
+
* 那边 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可零网络取表。0600:表里是节点凭据,不该给同机用户读。
|
|
86
|
+
*/
|
|
87
|
+
export async function fetchNodesToFile({ sources = [], out, fetchImpl, timeoutMs = 20_000, log } = {}) {
|
|
88
|
+
const got = await fetchNodes({ sources, fetchImpl, timeoutMs, log });
|
|
89
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
90
|
+
writeFileSync(out, got.text, { mode: 0o600 });
|
|
91
|
+
return { ...got, out, file: out };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 出口取证:从某槽的 tun 地址出网,读回真实出口 IP(照 warp-pool/revive.js 的同源判据)。
|
|
96
|
+
* 这是「换 IP 到底换没换动」的直接证据——没有它,日志只能说"起了隧道",说不了"出口变了"。
|
|
97
|
+
* @returns {Promise<{ ok: boolean, ip: string, ms: number, error?: string }>}
|
|
98
|
+
*/
|
|
99
|
+
export async function probeExitIp({ agent, fetchImpl, timeoutMs = 12_000 } = {}) {
|
|
100
|
+
const t0 = Date.now();
|
|
101
|
+
try {
|
|
102
|
+
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
103
|
+
const res = await f(EXIT_PROBE_URL, { ...(agent ? { dispatcher: agent } : {}), signal: timeoutSignal(timeoutMs) });
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
const ip = parseExitIp(text);
|
|
106
|
+
return { ok: Boolean(ip), ip, ms: Date.now() - t0, ...(ip ? {} : { error: `取证响应认不出出口 IP:${text.slice(0, 80)}` }) };
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return { ok: false, ip: "", ms: Date.now() - t0, error: errorWhy(e) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// multi-ip-pool · VPNGate 节点表(**纯解析与落盘**侧):解 CSV → 解 base64 配置 → 筛候选 → 落 conf/缓存。
|
|
2
|
+
//
|
|
3
|
+
// 为什么要拆开:出口 IP 的**来源**就是这些免费志愿者节点(参照实现 aimili-vpngate 用同一份 CSV),
|
|
4
|
+
// 但「取表」在受限网络里是独立的成败关口,它的诊断逻辑单独住在 ./nodes-fetch.js(fetch/归因/出口取证)。
|
|
5
|
+
// 纪律:候选的 conf / auth 一律 0600 落在**非工作树**目录(默认 logDir()/multi-ip-pool)。
|
|
6
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** CSV 列序(VPNGate `/api/iphone/`):末列是 base64 配置,其余为节点元数据。 */
|
|
10
|
+
export const VPNGATE_FIELDS = [
|
|
11
|
+
"hostName", "ip", "score", "ping", "speed", "countryLong", "countryShort",
|
|
12
|
+
"sessions", "uptime", "totalUsers", "totalTraffic", "logType", "operator", "message", "config",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const CACHE_FILE = "nodes.json";
|
|
16
|
+
|
|
17
|
+
/** 0755 目录 + 0600 文件:conf 里有 VPNGate 的公证书与账号行,不该给同机其它用户读。 */
|
|
18
|
+
function ensureDir(dir) {
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writePrivate(file, text) {
|
|
24
|
+
writeFileSync(file, text, { mode: 0o600 });
|
|
25
|
+
// mode 只在新建时生效:存量文件补 chmod(照 AGENTS.md 的凭据落地纪律)
|
|
26
|
+
try {
|
|
27
|
+
chmodSync(file, 0o600);
|
|
28
|
+
} catch {
|
|
29
|
+
/* Windows / 不支持 chmod 的文件系统:忽略 */
|
|
30
|
+
}
|
|
31
|
+
return file;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 解析 VPNGate CSV。只认「首列是 hostName 的数据行」:
|
|
36
|
+
* 以 `*` 开头的是注释行、以 `#` 开头的是表头。
|
|
37
|
+
* Message 列理论上可能含逗号 ⇒ **末列永远是 config**(base64 不含逗号),中间多余的列并回 message。
|
|
38
|
+
*/
|
|
39
|
+
export function parseVpnGateCsv(text) {
|
|
40
|
+
const rows = [];
|
|
41
|
+
for (const rawLine of String(text || "").split(/\r?\n/)) {
|
|
42
|
+
const line = rawLine.trim();
|
|
43
|
+
if (!line || line.startsWith("*") || line.startsWith("#")) continue;
|
|
44
|
+
const parts = line.split(",");
|
|
45
|
+
if (parts.length < VPNGATE_FIELDS.length) continue;
|
|
46
|
+
const n = parts.length;
|
|
47
|
+
const row = {
|
|
48
|
+
hostName: parts[0],
|
|
49
|
+
ip: parts[1],
|
|
50
|
+
score: Number(parts[2]) || 0,
|
|
51
|
+
ping: Number(parts[3]) || 0,
|
|
52
|
+
speed: Number(parts[4]) || 0,
|
|
53
|
+
countryLong: parts[5],
|
|
54
|
+
countryShort: parts[6],
|
|
55
|
+
sessions: Number(parts[7]) || 0,
|
|
56
|
+
uptime: Number(parts[8]) || 0,
|
|
57
|
+
totalUsers: Number(parts[9]) || 0,
|
|
58
|
+
totalTraffic: Number(parts[10]) || 0,
|
|
59
|
+
logType: parts[11],
|
|
60
|
+
operator: parts[12],
|
|
61
|
+
message: parts.slice(13, n - 1).join(","),
|
|
62
|
+
config: parts[n - 1],
|
|
63
|
+
};
|
|
64
|
+
if (!row.hostName || !row.config) continue;
|
|
65
|
+
rows.push(row);
|
|
66
|
+
}
|
|
67
|
+
return rows;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** base64 → OpenVPN 配置文本;不是一份能用的 client 配置就返回空串(绝不猜)。 */
|
|
71
|
+
export function decodeConfig(b64) {
|
|
72
|
+
let text = "";
|
|
73
|
+
try {
|
|
74
|
+
text = Buffer.from(String(b64 || ""), "base64").toString("utf8");
|
|
75
|
+
} catch {
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
if (!/\bclient\b/.test(text) || !/^\s*remote\s+\S+/m.test(text)) return "";
|
|
79
|
+
return text;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 从配置文本里读出 `remote <host> <port>`(缺端口按 OpenVPN 默认 1194)。 */
|
|
83
|
+
export function parseRemote(conf) {
|
|
84
|
+
const m = /^\s*remote\s+(\S+)(?:\s+(\d+))?/m.exec(String(conf || ""));
|
|
85
|
+
if (!m) return null;
|
|
86
|
+
return { host: m[1], port: m[2] ? Number(m[2]) : 1194 };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 国家白名单判据:空 = 不限;否则比对 ISO 两字母或全称(大小写无关)。 */
|
|
90
|
+
export function matchCountry(row, countries = []) {
|
|
91
|
+
if (!countries.length) return true;
|
|
92
|
+
const short = String(row?.countryShort || "").toUpperCase();
|
|
93
|
+
const long = String(row?.countryLong || "").toUpperCase();
|
|
94
|
+
return countries.some((c) => c === short || c === long);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 候选队列:按 Score 降序(Score 是 VPNGate 官方"还活着且跑得动"的综合分)。
|
|
99
|
+
* @param {{ rows?: object[], countries?: string[], minScore?: number, scan?: number, skip?: Set<string> }} opts
|
|
100
|
+
*/
|
|
101
|
+
export function candidateNodes({ rows = [], countries = [], minScore = 0, scan = 8, skip = new Set() } = {}) {
|
|
102
|
+
return rows
|
|
103
|
+
.filter((r) => r.config && r.score >= minScore && matchCountry(r, countries) && !skip.has(r.hostName))
|
|
104
|
+
.sort((a, b) => b.score - a.score || a.ping - b.ping)
|
|
105
|
+
.slice(0, scan);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 节点表缓存(TTL 内直接用,省一次外网请求;VPNGate 免费节点掉得快,默认 30min)。 */
|
|
109
|
+
export function loadNodeCache({ dir, ttlMs = 0, now = () => Date.now() } = {}) {
|
|
110
|
+
try {
|
|
111
|
+
const raw = JSON.parse(readFileSync(join(dir, CACHE_FILE), "utf8"));
|
|
112
|
+
if (!Array.isArray(raw?.rows) || !raw.rows.length) return null;
|
|
113
|
+
if (ttlMs > 0 && now() - Number(raw.fetchedAt || 0) > ttlMs) return null;
|
|
114
|
+
return raw;
|
|
115
|
+
} catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function saveNodeCache({ dir, source = "", rows = [], now = () => Date.now() } = {}) {
|
|
121
|
+
ensureDir(dir);
|
|
122
|
+
const payload = { fetchedAt: now(), source, rows };
|
|
123
|
+
writePrivate(join(dir, CACHE_FILE), JSON.stringify(payload));
|
|
124
|
+
return payload;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
/** 落一份 node conf(设备名/路由由 CLI 覆盖,不改原配置文本);返回文件路径。 */
|
|
129
|
+
export function writeNodeConf({ dir, row, name = "" } = {}) {
|
|
130
|
+
ensureDir(dir);
|
|
131
|
+
const safe = String(row.hostName || "node").replace(/[^\w.-]/g, "_").slice(0, 48);
|
|
132
|
+
const alias = name || `${safe}-${row.ip}`;
|
|
133
|
+
return { path: writePrivate(join(dir, `node-${alias}.conf`), row.config), hostName: row.hostName };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** writeNodeConf 落的是**解码后**的配置;这里显式解码一次,把「解不开」的节点挡在门外(绝不猜)。 */
|
|
137
|
+
export function materializeNode({ dir, row, name = "" } = {}) {
|
|
138
|
+
const conf = decodeConfig(row.config);
|
|
139
|
+
if (!conf) return null;
|
|
140
|
+
const remote = parseRemote(conf);
|
|
141
|
+
if (!remote) return null;
|
|
142
|
+
const written = writeNodeConf({ dir, row: { ...row, config: conf }, name });
|
|
143
|
+
return { ...row, conf: written.path, host: remote.host, port: remote.port };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** VPNGate 公共账号(vpn/vpn);落在 0600 文件里,绝不入库。 */
|
|
147
|
+
export function writeAuthFile({ dir, user = "vpn", pass = "vpn" } = {}) {
|
|
148
|
+
ensureDir(dir);
|
|
149
|
+
return writePrivate(join(dir, "auth.txt"), `${user}\n${pass}\n`);
|
|
150
|
+
}
|