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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mslxdff",
3
- "version": "0.1.244",
3
+ "version": "0.1.246",
4
4
  "description": "测试项目,请勿使用。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,10 +21,10 @@ const FREE_TIER_PATTERNS = [
21
21
  /FreeTierError/i,
22
22
  // ⚠ `FreeUsageLimitError` 曾列在此(BUG-8 于 2026-10-16 加入)**现已移除**(2026-10-17 纠正):
23
23
  // 它是**限额/限流**判决,不是身份闸门。三条独立实证都证明「换出口/换组员就恢复」⇒ 属可接力:
24
- // ① 本仓自测记录(warp-pool/verdict.js:9-10):同一身份连发 3 次 429 FreeUsageLimitError,
24
+ // ① 本仓自测记录(原 warp-pool/verdict.js:9-10,该模块已随 ADR-0073 移除):同一身份连发 3 次 429 FreeUsageLimitError,
25
25
  // **身份一换、同一个请求立刻 200**(限额按出口 IP 计);
26
- // ② warp-pool 的决策表(verdict.js:62)对 429 FreeUsage 明写「强制换出口、跳过冷却」;
27
- // ③ v6 池(v6pool-claim-rotate.test.js:140)同样据它换源地址。
26
+ // ② 原 warp-pool 的决策表(verdict.js:62)对 429 FreeUsage 明写「强制换出口、跳过冷却」(该模块已随 ADR-0073 移除);
27
+ // ③ 原 v6 池(v6pool-claim-rotate.test.js:140)同样据它换源地址(已随 ADR-0073 移除)。
28
28
  // 更根本:ADR-0058 §2 本就把「401/402/**429**/5xx」明列为**可恢复、接力照常**;本文件头 :9-11 也是
29
29
  // 同一口径。BUG-8 把 429 的具名文案判成终局,偏离了本模块自己写下的边界 —— 现网代价是
30
30
  // 组员额度还在、仍被 `peersSkipped:true` 一刀跳过(2026-10-18 events.log 满屏实证)。
@@ -62,7 +62,7 @@ const TRANSIENT_MARKERS = [
62
62
  // 限额/限流具名判决 ⇒ **可恢复**(换出口换组员就恢复,见 FREE_TIER_PATTERNS 上方三条实证)。
63
63
  // 显式列出而非指望 message 里的 "rate limit" 字样:上游改文案时这层不能跟着失明
64
64
  // (`FreeUsageLimitError` 的类名比它的 message 稳定得多)。
65
- // GoUsageLimitError 与它同族(warp-pool/verdict.js:25 把两者并列),同样按出口计、同样可换。
65
+ // GoUsageLimitError 与它同族(原 warp-pool/verdict.js:25 把两者并列),同族同判。
66
66
  /FreeUsageLimitError|GoUsageLimitError/i,
67
67
  /security_reject/i,
68
68
  /安全校验拒绝/,
@@ -56,7 +56,7 @@ export async function handleRestart(args, VERSION) {
56
56
  console.log("daemon not running — starting...");
57
57
  }
58
58
  // 结构性盲区收口:不在 daemon.pid 里的 daemon 永远杀不到(真机:Oct16 起的旧 daemon 活了一整天,
59
- // 与新版互杀 warp-pool 端口;两边日志格式交错才暴露)。起新的之前先清干净。
59
+ // 与新版互抢本机端口;两边日志格式交错才暴露)。起新的之前先清干净。
60
60
  const ghosts = await reapGhostDaemons({ log: (m) => console.log(m) });
61
61
  if (ghosts.length) console.log(`已清掉 ${ghosts.length} 个不在册的 daemon(pid ${ghosts.map((g) => g.pid).join(", ")})`);
62
62
  const port = effectivePort(args);
package/src/cli/index.js CHANGED
@@ -41,11 +41,6 @@ export async function run(args = process.argv.slice(2)) {
41
41
  if (await handleAutostart(args)) return;
42
42
  if (await handleFree(args)) return;
43
43
 
44
- const { handleV6Pool } = await import("./commands/v6-pool.js");
45
- if (await handleV6Pool(args)) return;
46
-
47
- const { handleMultiIpPool } = await import("./commands/multi-ip-pool.js");
48
- if (await handleMultiIpPool(args)) return;
49
44
 
50
45
  const { handleProviders, handleProvider } = await import("./commands/provider.js");
51
46
  if (await handleProviders(args)) return;
package/src/daemon.js CHANGED
@@ -180,7 +180,7 @@ export async function waitPidGone(pid, { timeoutMs = 5000, killWaitMs = 2000, st
180
180
 
181
181
  /**
182
182
  * 清「不在 daemon.pid 里的」幽灵 daemon —— `-restart` 的结构性盲区
183
- *(真机事故:旧 daemon 活了一整天,与新版互杀 warp-pool 端口,两边日志格式交错才暴露)。
183
+ *(真机事故:旧 daemon 活了一整天,与新版互抢本机端口,两边日志格式交错才暴露)。
184
184
  * 先 SIGTERM 全清 → 等 5s → 仍活着的 SIGKILL。返回**确实清掉**的清单(供调用方诚实汇报)。
185
185
  */
186
186
  export async function reapGhostDaemons({ self = process.pid, list = () => findDaemons({ self }), kill = process.kill, log = () => {}, isAlive = isPidAlive, settleMs = 5000, killWaitMs = 2000, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
@@ -201,7 +201,7 @@ export async function reapGhostDaemons({ self = process.pid, list = () => findDa
201
201
  * 接管:内存里若还有**别的** mslxdff daemon,先把比我们旧的那批请出去。
202
202
  * 为什么必须有(用户点名要求 + 真机 2026-10-17 事故):`-restart` 只认 `daemon.pid`,
203
203
  * 不在册的 daemon(SIGTERM 后没真退出、pid 文件又被删掉的那个)会活一整天,与新版互抢
204
- * warp-pool 的 4 个端口——日志里只能看到「起一条死一条」,看不出是两个 daemon 在互杀。
204
+ * 本机那 4 个长驻端口(当年是 warp-pool 的 wireproxy,该模块已随 ADR-0073 移除)——日志里只能看到「起一条死一条」,看不出是两个 daemon 在互杀。
205
205
  * 判据:比自己早启动 ≥ `minAgeMs` 的一律清(SIGTERM → 等 → SIGKILL);与本次启动几乎同时的不动
206
206
  * (谁该活由 8989 的 bind 决定,`server-lifecycle` 的 EADDRINUSE 自愈会收口),避免两边互杀。
207
207
  * 全程不抛(接管失败绝不能挡住启动)。
@@ -227,7 +227,7 @@ export async function takeoverOlderDaemons({
227
227
  const older = sibs.filter(isOlder);
228
228
  const skipped = sibs.filter((d) => !isOlder(d));
229
229
 
230
- for (const d of older) log(`[daemon] 内存里还有别的 mslxdff daemon:pid ${d.pid}(父 ${d.ppid},已运行 ${fmtAlive(d.aliveSec)},比本进程旧)—— 先请它退出,否则两个 daemon 会抢同一批端口(warp-pool 起一条死一条的真因)`);
230
+ for (const d of older) log(`[daemon] 内存里还有别的 mslxdff daemon:pid ${d.pid}(父 ${d.ppid},已运行 ${fmtAlive(d.aliveSec)},比本进程旧)—— 先请它退出,否则两个 daemon 会抢同一批端口(当年是 warp-pool 起一条死一条的真因,该模块已随 ADR-0073 移除)`);
231
231
  for (const d of older) { try { kill(d.pid, "SIGTERM"); } catch {} }
232
232
  const t0 = Date.now();
233
233
  while (Date.now() - t0 < settleMs && older.some((d) => isAlive(d.pid))) await sleep(100);
@@ -18,7 +18,7 @@ export async function startDaemonMain(VERSION) {
18
18
  const { installLifecycleLog } = await import("./lifecycle-log.js");
19
19
  installLifecycleLog({ version: VERSION, logs });
20
20
  // 接管:内存里若还有旧的 mslxdff daemon,先请它退出。它不在 daemon.pid 里(-restart 结构性碰不到它),
21
- // 两个 daemon 会互抢 warp-pool 的端口(真机 2026-10-17:旧 daemon 活了一整天,日志只显示「起一条死一条」)。
21
+ // 两个 daemon 会互抢本机长驻端口(真机 2026-10-17:旧 daemon 活了一整天,日志只显示「起一条死一条」)。
22
22
  // 必须早于 setupProviders —— 那一步才建池抢端口。debug 前台有自己的收口逻辑,不在这里抢。
23
23
  if (process.env.MSLXDFF_DAEMON === "1" && process.env.MSLXDFF_DEBUG !== "1") {
24
24
  try {
@@ -1,11 +1,11 @@
1
1
  // 进程取证层(Linux /proc 只读)——2026-10-17 组长 VPS「幽灵 daemon 互杀」事故后新增。
2
2
  //
3
- // 事故:两个 daemon 同时活着(一个从昨天起活了一整天),都管同一份 warp-pool 目录,
3
+ // 事故:两个 daemon 同时活着(一个从昨天起活了一整天),都管同一批长驻目录(原 warp-pool,已随 ADR-0073 移除),
4
4
  // 把对方的 wireproxy 当「上一代残留」杀掉 ⇒ 无限互杀、池永远不稳。三处都缺「读 /proc 说事实」的能力:
5
5
  // ① 日志不知道「同机还有几个 daemon」(只能靠人肉 ps 才发现);
6
6
  // ② 清理不知道「这个 wireproxy 的父进程还活着(= 另一个 daemon 的活隧道)」;
7
7
  // ③ 「SIGKILL 都清不掉」其实是**僵尸**(state=Z,kill 对它无效,但 kill(pid,0) 仍返回成功)。
8
- // 放 runtime 而不是 warp-pool:daemon 与 warp-pool 都要用,放 warp-pool 会让 daemon 反向依赖它。
8
+ // 放 runtime 而不是某个子系统目录:daemon 与子系统都要用,放子系统里会让 daemon 反向依赖它。
9
9
  // 非 Linux 一律返回空 —— 读不到 /proc 就别猜(不在别的平台做危险推断)。
10
10
  import { readFileSync, readdirSync } from "node:fs";
11
11
 
@@ -13,7 +13,6 @@ import { createOpenCodeProvider } from "../providers/opencode.js";
13
13
  import { loadProviderKeys, loadProviderAuths, loadProviderConfigs, saveProviderConfig } from "../state.js";
14
14
  import { refreshIntervalMs, modelCooldownMs, slowCooldownMs, peerCooldownMs, peerLimitCooldownMs, peerHeatMs, banWindowMs, banThreshold } from "../cli/policy.js";
15
15
  import { errMsg } from "../cli/util.js";
16
- import { createPreferredDispatcher } from "../v6pool/index.js";
17
16
 
18
17
 
19
18
  import { shouldEnableCustomProvider, loadAuthDocs, seedNoCredentialProviders } from "./provider-gate.js";
@@ -51,7 +50,6 @@ export async function setupProviders() {
51
50
  const baseUrl = process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
52
51
  let providers = [];
53
52
 
54
- const outboundDispatcher = createPreferredDispatcher({ baseUrl });
55
53
  if (providerPlugin) {
56
54
  if (!upstream) {
57
55
  try {
@@ -63,9 +61,9 @@ export async function setupProviders() {
63
61
  appendEvent({ ts: Date.now(), type: "plugin-upstream-error", plugin: providerPlugin.name, error: errMsg(err) });
64
62
  }
65
63
  }
66
- if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks, dispatcher: outboundDispatcher });
64
+ if (!upstream) upstream = createUpstreamClient({ hooks: upstreamHooks });
67
65
  } else {
68
- const opencodeClient = createUpstreamEngine({ hooks: upstreamHooks, dispatcher: outboundDispatcher });
66
+ const opencodeClient = createUpstreamEngine({ hooks: upstreamHooks });
69
67
  const opencodeModels = createModelsService({
70
68
  baseUrl,
71
69
  headers: opencodeClient.headers,
package/src/server.js CHANGED
@@ -50,10 +50,10 @@ export function startServer({ router, signals = true, host, onBeforeClose }, por
50
50
  if (stopping) return;
51
51
  stopping = true;
52
52
  try { console.log(`[lifecycle] ${sig} received — shutting down (pid ${process.pid}, uptime ${Math.round(process.uptime())}s)`); } catch {}
53
- // 关停必须**真的退出**:warp-pool 的 wireproxy 子进程 stdio 是 pipe,活着的子进程会把事件循环钉住;
53
+ // 关停必须**真的退出**:长驻子进程(当年是 warp-pool 的 wireproxy,已随 ADR-0073 移除)的 stdio 是 pipe,活着的子进程会把事件循环钉住;
54
54
  // 只 close() server(放开 8989)会留下「不监听端口却还活着」的幽灵 daemon
55
- //(真机 2026-10-17:Oct16 起的幽灵与新版互杀 warp-pool 端口一整天,且它不在 daemon.pid 里,-restart 永远碰不到)。
56
- // process.exit 会跑 exit 钩子(warp-pool 杀隧道、lifecycle 写遗书),故清理不会被跳过。
55
+ //(真机 2026-10-17:Oct16 起的幽灵与新版互抢本机端口一整天,且它不在 daemon.pid 里,-restart 永远碰不到)。
56
+ // process.exit 会跑 exit 钩子(lifecycle 写遗书等已注册钩子),故清理不会被跳过。
57
57
  const hard = setTimeout(() => { try { console.log(`[lifecycle] ${sig} 优雅关停 8s 未完成 — 强制退出`); } catch {} process.exit(0); }, 8000);
58
58
  hard.unref?.();
59
59
  close().then(() => process.exit(0), (e) => { try { console.log(`[lifecycle] 关停抛错:${String(e?.message || e)}`); } catch {} process.exit(1); });
@@ -21,7 +21,7 @@ export function createUpstreamEngine(opts = {}) {
21
21
  const baseUrl = opts.baseUrl || process.env.UPSTREAM_BASE_URL || "https://opencode.ai";
22
22
  const sdkFactory = opts.sdkFactory ?? createSdkChat;
23
23
  const responsesFactory = opts.responsesFactory ?? createSdkResponses;
24
- const { buildHeaders, anonFirst } = createOpencodeHeaderBuilder({
24
+ const { buildHeaders } = createOpencodeHeaderBuilder({
25
25
  authToken: opts.authToken || process.env.UPSTREAM_AUTH_TOKEN || "public",
26
26
  });
27
27
  const dispatcher = legacy.dispatcher;
@@ -46,22 +46,7 @@ export function createUpstreamEngine(opts = {}) {
46
46
  const via = useResponses ? "@ai-sdk/openai · responses" : "@ai-sdk/openai-compatible · chat";
47
47
  try { console.error(`[upstream-engine] sdk 引擎生效(${via})`); } catch {}
48
48
  }
49
- // 保留 legacy 的匿名重试语义(仅 !anonFirst 配置下存在);free-429 的**请求内 v6 换出口**
50
- // 也走这条委派——重试环在 legacy.chat 里。
51
- // ⚠ 顺序契约(用户 2026-10-19):先借人(组员/组长,用对方 IPv4 出口),全都用完才换出口。
52
- // 故 v6 换出口**默认关**,只有显式 MSLXDFF_V6_INREQUEST_ROTATE=1 才委派;
53
- // 默认把 429 原样返回,交给 chat-pipeline 去试组员/组长。
54
- const v6RotateOn = (() => {
55
- const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
56
- if (raw === undefined || raw === null || raw === "") return false;
57
- const s = String(raw).trim().toLowerCase();
58
- return !(s === "0" || s === "false" || s === "off" || s === "no");
59
- })();
60
- const canRotate = v6RotateOn && typeof dispatcher?.rotateFor === "function";
61
- // 原语义:撞 429 且是 free 模型,且(非匿名首试 或 有 v6 池可换址)⇒ 委派 legacy 重试环。
62
- // 新语义:**只有显式开启 v6 请求内换址时才委派**;默认 429 原样返回,交给上层去借人
63
- // (组员/组长,用对方 IPv4 出口),符合用户 2026-10-19 的「先借人再换出口」顺序。
64
- if (res.status === 429 && isFreeModel(body?.model) && (anonFirst ? canRotate : v6RotateOn)) return legacy.chat(body);
49
+ // 出口池(v6/WARP/多出口)已移除:free 429 原样返回,交给上层去借人(组员/组长,用对方 IPv4 出口)。
65
50
  return res;
66
51
  } catch (e) {
67
52
  if (e && e._sdkLoadFailed) {
package/src/upstream.js CHANGED
@@ -33,11 +33,6 @@ function envInt(name, fallback) {
33
33
  const v = Number(process.env[name]);
34
34
  return Number.isInteger(v) && v > 0 ? v : fallback;
35
35
  }
36
- /** 允许 0 的数值 env(G1a 的逃生阀要认 0=关;envInt 的 `v>0` 会把 0 吞回默认值,那就是个假开关)。 */
37
- function envIntOrZero(name, fallback) {
38
- const v = Number(process.env[name]);
39
- return Number.isInteger(v) && v >= 0 ? v : fallback;
40
- }
41
36
  function isPreheatDisabled() {
42
37
  const raw = process.env.MSLXDFF_PREHEAT;
43
38
  if (raw === undefined || raw === null || raw === "") return false;
@@ -188,62 +183,7 @@ export function createUpstreamClient({
188
183
  try { console.error(`[free-lane] resp model=${body?.model} status=${res.status} ct=${res.headers?.get?.("content-type") || ""}`); } catch {}
189
184
  }
190
185
 
191
- // 请求内换出口(v6 池):撞 429 时在同一请求里换源地址重发。
192
- //
193
- // ⚠ 顺序契约(用户 2026-10-19 明确):**先借人,再换出口**。
194
- // 429 ⇒ ① 先走组员/组长接力(用**对方的 IPv4 出口**);② 全都没得用/都失败,
195
- // 才轮到 WARP / IPv6 换出口拿"新的自然额度"。WARP/IPv6 是另一层兜底,不该抢在借人之前。
196
- // 故本功能**默认关闭**,需要时才显式开:MSLXDFF_V6_INREQUEST_ROTATE=1(原 ADR-0061 行为)。
197
- // 关闭时 429 原样交回上层 → chat-pipeline 去试组员/组长,符合用户要的顺序。
198
- // 边界(探测实证过的硬约束):① 仅头部即 429 才换(不碰半开的 200 流);② 429 错误体已被
199
- // transport 非流分支读尽,无连接泄漏;③ 每轮换的是 session 粘的那个槽(swap 原地换 addr/e.agent,
200
- // 粘性映射不失效);④ 次数上限防 429 风暴空烧认领地址;⑤ 无 rotateFor 的 dispatcher(直连/WARP)
201
- // 条件短路——WARP 换出口=重注册设备 6s~600s,在请求内做只会白等,**设计上排除**。
202
- const inRequestRotateOn = (() => {
203
- const raw = process.env.MSLXDFF_V6_INREQUEST_ROTATE;
204
- if (raw === undefined || raw === null || raw === "") return false; // 缺省关:先借人
205
- const s = String(raw).trim().toLowerCase();
206
- return !(s === "0" || s === "false" || s === "off" || s === "no");
207
- })();
208
- if (inRequestRotateOn && freeLane && res.status === 429 && typeof transport.dispatcher?.rotateFor === "function") {
209
- const maxRot = envIntOrZero("MSLXDFF_V6_INREQUEST_RETRIES", 3); // 0 = 关掉请求内换出口
210
- let rotated = 0;
211
- while (res.status === 429 && rotated < maxRot) {
212
- let next = null;
213
- try {
214
- // 用**线上传的那个** session 头定位粘性槽(无 messages 时 buildHeaders 兜底成进程级
215
- // FALLBACK_SESSION;传 null 会让 selector 轮转到别的槽,换掉一个没准真没耗尽的健康出口)
216
- next = await transport.dispatcher.rotateFor(firstHeaders["x-opencode-session"] || "");
217
- } catch { break; }
218
- if (!next?.agent) break; // 换不到新出口(空池/全在换址/认领失败)⇒ 正常收场,不黑洞
219
- rotated += 1;
220
- if (laneDebug) {
221
- try { console.error(`[free-lane] v6 in-request rotate #${rotated} ${next.from} -> ${next.addr} model=${body?.model}`); } catch {}
222
- }
223
- try {
224
- res = await transport.request({
225
- url,
226
- method: "POST",
227
- headers: buildHeaders(reqBody, { sessionId }),
228
- body: reqBody,
229
- stream: reqBody.stream !== false,
230
- timeoutMs: connectTimeoutMs,
231
- retry,
232
- dispatcher: next.agent, // per-call 出口:绕开 extDispatcher 的旧槽选择
233
- });
234
- } catch (e) {
235
- e._t = e._t || { attempts: [], waitMs: 0, totalMs: Math.round(performance.now() - t0) };
236
- throw e;
237
- }
238
- }
239
- if (rotated) {
240
- res._t = { ...(res._t || {}), egressRotations: rotated, egressRecovered: res.status < 400 };
241
- try {
242
- appendFile(freeAnonLogFile(), `${fmtShanghaiYMDHMS(new Date())} model=${body?.model} v6-in-request-rotations=${rotated} finalStatus=${res.status}\n`).catch(() => {});
243
- } catch {}
244
- }
245
- }
246
-
186
+ // 出口池(v6 换址 / WARP / 多出口)已移除(2026-10-25):free 429 原样交回上层去借人(组员/组长,用对方 IPv4 出口)。
247
187
  // 匿名兜底:仅非 anonFirst 时,public 429 + free 模型才走 hermes 空头重试
248
188
  if (!anonFirst && res.status === 429 && isFreeModel(body?.model) && shouldTryAnonFree()) {
249
189
  const anonRetries = envInt("MSLXDFF_FREE_ANON_RETRIES", 3);
@@ -1,173 +0,0 @@
1
- // mslxdff -multi-ip-pool 的 `fetch`(抓表落盘)与 `probe`(只测节点可达性),加上 `test` 也要用的
2
- // 「表准备 + ③ 取样预检」——这三件事都不打隧道,故与 test 的六步流水分开放。
3
- //
4
- // 为什么独立成一份(2026-10-25 真机第三轮):`test` 已顶到 11.3KB;而「这台机到底能连上表里几个节点」
5
- // 是判断多出口池值不值得继续的**先行问题**——它不要 root、不要 openvpn、不碰内核,
6
- // 任何平台都能几十秒给出答案,不该被塞进「要 Linux + root + 隧道」的取证流程里。
7
- import { readdirSync, statSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { resolveMultiIpConfig } from "../../multi-ip-pool/config.js";
10
- import { countCandidates, loadNodeCache, probeTargetOf, sampleCandidates } from "../../multi-ip-pool/nodes.js";
11
- import { fetchNodes, fetchNodesToFile } from "../../multi-ip-pool/nodes-fetch.js";
12
- import { createTranscript, probeBatch, probeSummaryLines } from "../../multi-ip-pool/diagnose.js";
13
- import { argValue } from "../policy.js";
14
-
15
- const FLAG = "-multi-ip-pool";
16
-
17
- /** 取样条数:够看出「哪一片不通」,又不至于把流程拖成几分钟(限并发 6、单条最多 6s ⇒ 最坏约 15s)。 */
18
- export const PROBE_DEFAULT = 12;
19
-
20
- /** `--flag value` 与 `--flag=value` 两种写法都认;认不到返回 null(`-` 开头的不当值)。 */
21
- export function flagValue(args, ...names) {
22
- for (const name of names) {
23
- const v = argValue(args, name);
24
- if (v !== null && v !== undefined && !String(v).startsWith("-")) return v;
25
- const eq = args.find((a) => a.startsWith(`${name}=`));
26
- if (eq) return eq.slice(eq.indexOf("=") + 1);
27
- }
28
- return null;
29
- }
30
-
31
- /** 目录里已经有什么(auth.txt/nodes.json/node-*.conf)——很多失败就是这里少一个文件。 */
32
- export function dirNotes(dir) {
33
- try {
34
- const names = readdirSync(dir);
35
- const size = (f) => { try { return `${statSync(join(dir, f)).size}B`; } catch { return "?"; } };
36
- const files = names.slice(0, 12).map((n) => `${n}(${size(n)})`);
37
- files.push(names.length > 12 ? `…共 ${names.length} 个` : "");
38
- files.push(`auth.txt ${names.includes("auth.txt") ? "在" : "**缺失**(拨号必挂,看第 4 步)"}`);
39
- return files.filter(Boolean);
40
- } catch (e) {
41
- return [`目录读不到:${String(e?.code || e?.message || e)}`];
42
- }
43
- }
44
-
45
- /** 取表(缓存优先,取不到就逐源试并把每个源的死因摆出来)。 */
46
- export async function ensureRows(cfg, t) {
47
- const cache = loadNodeCache({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
48
- if (cache?.rows?.length) {
49
- t.say(`命中本地缓存:${cache.rows.length} 个节点(表源 ${cache.source},缓存龄 ${Math.round((Date.now() - Number(cache.fetchedAt || 0)) / 1000)}s)`);
50
- return cache.rows;
51
- }
52
- t.say("缓存没有或已过期 ⇒ 逐源取表(每源失败原因都会列出来)");
53
- const got = await fetchNodes({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
54
- t.say(`取到:${got.source} → ${got.rows.length} 个节点`);
55
- return got.rows;
56
- }
57
-
58
- /** 一条预检结果的判语(UDP 节点必须如实标注「无握手,仅供参考」)。 */
59
- function nodeLine(r, minScore = 0) {
60
- const n = r.node || {};
61
- const proto = String(n.proto || "").toLowerCase();
62
- const how = r.ok ? `✓ TCP 通(${r.ms}ms)` : `✗ TCP 不通(${String(r.error || "?").replace(/\s+/g, " ").slice(0, 60)},${r.ms}ms)`;
63
- const low = Number(n.score || 0) < minScore ? "(Score 低于下限,仅作可达性参考)" : "";
64
- return ` ${r.ok ? "✓" : "✗"} ${n.hostName}(${n.countryShort || "?"})${n.host}:${n.port}|${n.proto || "?"}|Score ${n.score}${low} → ${how}`
65
- + (proto === "udp" ? " ※ UDP 无握手,此探测仅供参考" : "");
66
- }
67
-
68
- /**
69
- * ③ 取样与 TCP 预检(test 与 probe 共用一份,保证「探的」与「拨的」永远是同一批):
70
- * 按簇轮转取样 → 限并发探一遍 → 逐条打印 + 小结 + 簇分布。
71
- * @returns {Promise<{ sample: object[], nodes: object[], results: object[], live: object[] }>}
72
- */
73
- export async function surveyCandidates({ cfg, rows = [], t, limit = PROBE_DEFAULT, timeoutMs = 5_000, concurrency = 6 } = {}) {
74
- const strict = sampleCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore, limit });
75
- // 达下限的候选不够取样条数时补低分节点——**只作可达性参考**(Score 低不等于连不上,先别急着排除它)
76
- const skip = new Set(strict.map((r) => r.hostName));
77
- const sample = strict.length < limit
78
- ? [...strict, ...sampleCandidates({ rows, countries: cfg.countries, minScore: 0, limit: limit - strict.length, skip })]
79
- : strict;
80
- const matched = countCandidates({ rows, countries: cfg.countries, minScore: cfg.minScore });
81
- t.say(`候选池:表 ${rows.length} 个 → 国家/Score 达标 ${matched} 个 → 取样 ${sample.length} 个(按簇轮转:同一机房不占满取样,免得一片不通就判整表不通)`);
82
- const nodes = sample
83
- .map((row) => { const target = probeTargetOf(row); return target ? { ...row, host: target.host, port: target.port, proto: target.proto } : null; })
84
- .filter(Boolean);
85
- if (!nodes.length) return { sample, nodes, results: [], live: [] };
86
- const low = nodes.filter((n) => Number(n.score || 0) < cfg.minScore).length;
87
- t.say(`预检:限并发 ${Math.min(concurrency, nodes.length)}、单条 ${timeoutMs}ms${low ? `(其中 ${low} 条 Score 低于下限,仅作参考)` : ""}`);
88
- const results = await probeBatch({ nodes, timeoutMs, concurrency });
89
- for (const r of results) t.say(nodeLine(r, cfg.minScore));
90
- for (const l of probeSummaryLines({ results, minScore: cfg.minScore })) t.say(l);
91
- return { sample, nodes, results, live: results.filter((r) => r.ok).map((r) => r.node) };
92
- }
93
-
94
- /**
95
- * `-multi-ip-pool probe [--probe N]`:只回答「这台机能连上表里几个节点」。
96
- * 不建隧道、不要 root、不碰 openvpn/内核、不看角色门 ⇒ 任何平台都能跑:这是「池值不值得继续」的先行问题。
97
- */
98
- export async function runProbe(args = []) {
99
- const cfg = resolveMultiIpConfig(process.env);
100
- const limit = (() => { const v = flagValue(args, "--probe", "-p"); return v === null ? PROBE_DEFAULT : Math.max(1, Math.min(64, Number(v) || PROBE_DEFAULT)); })();
101
- const t = createTranscript({ dir: cfg.dir, fileName: `probe-${new Date().toISOString().replace(/[:.]/g, "-")}.log` });
102
- console.log("=".repeat(70));
103
- console.log(" 多出口池 · 节点可达性(只测能不能连上:不拨号、不要 root、不动内核、不看角色门)");
104
- console.log("=".repeat(70));
105
-
106
- t.step("① 环境快照");
107
- t.say(`node ${process.version}|平台 ${process.platform}`);
108
- t.say(`配置:取样 ${limit} 条|Score 下限 ${cfg.minScore}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|目录 ${cfg.dir}`);
109
- t.say(`节点源(按序):${cfg.sources.join(" → ")}`);
110
-
111
- t.step("② 节点表");
112
- let rows = [];
113
- try {
114
- rows = await ensureRows(cfg, t);
115
- } catch (e) {
116
- t.say(String(e?.message || e));
117
- console.log("\n❌ 取不到节点表(原因见上一行)");
118
- console.log(` 完整 transcript:${t.file}`);
119
- process.exit(1);
120
- }
121
-
122
- t.step("③ 取样与 TCP 预检");
123
- const survey = await surveyCandidates({ cfg, rows, t, limit, timeoutMs: Math.min(6_000, cfg.probeTimeoutMs), concurrency: 6 });
124
- if (!survey.sample.length) {
125
- t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
126
- console.log(`\n❌ 没有可用候选(放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑)`);
127
- console.log(` 完整 transcript:${t.file}`);
128
- process.exit(1);
129
- }
130
- const tcpResults = survey.results.filter((r) => String(r.node?.proto || "").toLowerCase() !== "udp");
131
- const tcpLive = tcpResults.filter((r) => r.ok);
132
-
133
- t.step("④ 结论");
134
- if (tcpLive.length) {
135
- t.say(`✅ 取样里 ${tcpLive.length}/${tcpResults.length} 条 TCP 节点可连 ⇒ 这台机能用多出口池(能不能换来新额度另说:跑 test)`);
136
- } else {
137
- t.say(`⛔ 取样里 ${tcpResults.length} 条 TCP 节点一条都连不上 ⇒ 这台机到这些免费节点整体不通(网络层,不是本模块配错)`);
138
- t.say("下一步:① 换网络/换出口(云厂商安全组、运营商对境外 VPN 端口的限制都在这一层);② 加大取样 --probe 40 或去掉国家白名单再确认一次");
139
- }
140
- console.log("");
141
- console.log(tcpLive.length ? ` ✅ 取样 ${tcpResults.length} 条 TCP 节点里有 ${tcpLive.length} 条可连` : ` ⛔ 一条 TCP 节点都连不上(探了 ${tcpResults.length} 条)`);
142
- console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
143
- console.log(` 下一步:mslxdff ${FLAG} test(真机取证:换出口到底有没有换来新额度)`);
144
- process.exit(tcpLive.length ? 0 : 1);
145
- }
146
-
147
- /**
148
- * `-multi-ip-pool fetch [路径]`:在**能连节点源**的机器上抓一份表落成文件,scp 到受限网络的组长机,
149
- * 那边设 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可完全跳过取表网络请求(2026-10-25 真机:华为云 ECS 到 vpngate.net 不通)。
150
- */
151
- export async function runFetch(args = []) {
152
- const cfg = resolveMultiIpConfig(process.env);
153
- const out = flagValue(args, "--out", "-o") || join(cfg.dir, "vpngate.csv");
154
- const t = createTranscript({ dir: cfg.dir, fileName: "fetch.log" });
155
- t.step("逐源取表(失败原因逐条列出)");
156
- t.say(`源列表:${cfg.sources.join(" → ")}`);
157
- try {
158
- const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
159
- const usable = got.rows.filter((r) => r.config).length;
160
- t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}`);
161
- console.log("");
162
- console.log(" 带到受限网络的机器上:");
163
- console.log(` scp ${out} root@<组长机>:/root/.config/mslxdff/multi-ip-pool/vpngate.csv`);
164
- console.log(" export MSLXDFF_MULTI_IP_NODES_FILE=/root/.config/mslxdff/multi-ip-pool/vpngate.csv");
165
- console.log(" (之后取表零网络请求;表会旧,免费节点掉得快,建议隔几天重抓一次)");
166
- console.log(` 先在那台机上跑 mslxdff ${FLAG} probe:能连上几个节点,比真拨一遍快得多`);
167
- process.exit(0);
168
- } catch (e) {
169
- t.say(`❌ ${String(e?.message || e)}`);
170
- console.log(" 下一步:换网络重试,或 MSLXDFF_MULTI_IP_SOURCES=<你自己能连的镜像/本地路径> 再跑一次");
171
- process.exit(1);
172
- }
173
- }
@@ -1,136 +0,0 @@
1
- // mslxdff -multi-ip-pool 的 test 流程:全程 transcript(屏幕 + 文件),每步编号带耗时。
2
- //
3
- // 为什么要写成流水账(用户 2026-10-25 明确要求):真机两轮排障都卡在「只看到最终判语、看不到环节」——
4
- // openvpn 的 argv、它的 stderr 原文、节点端口通不通、tun 地址有没有拿到、路由装没装上,
5
- // 每一项都必须是**一行一条**摆在屏幕上,并且整份落盘,贴一个文件就能定位。
6
- // fetch / probe 两条「不打隧道」的流程、以及本文件也要用的「表准备 + ③ 取样预检」在 ./multi-ip-pool-fetch.js。
7
- import { runMultiIpCompare, VERDICT_ZH, MITEST_DEFAULT, MITEST_MAX } from "../../multi-ip-pool/selftest.js";
8
- import { resolveMultiIpConfig, resolveRole } from "../../multi-ip-pool/config.js";
9
- import { checkOpenvpn, isRoot, isSupportedPlatform } from "../../multi-ip-pool/tunnel.js";
10
- import { createTranscript, envSnapshotLines, residualHints, slotPlanLines } from "../../multi-ip-pool/diagnose.js";
11
- import { loadGroupsJoined } from "../../state.js";
12
- import { PROBE_DEFAULT, dirNotes, ensureRows, flagValue, surveyCandidates } from "./multi-ip-pool-fetch.js";
13
-
14
- const FLAG = "-multi-ip-pool";
15
-
16
- const kindZh = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
17
-
18
- /** 探活结果 → 一行判语(基线与逐槽结果共用同一份口径)。 */
19
- const line = (r) => {
20
- if (r.error) return `✗ 不可达(${String(r.error).replace(/\s+/g, " ").slice(0, 300)})`;
21
- if (r.ok) return `✅ HTTP ${r.status} 有额度`;
22
- return `⛔ HTTP ${r.status} ${kindZh[r.kind] || r.kind}`;
23
- };
24
-
25
- /**
26
- * `-multi-ip-pool test`:五道门 → 取表 → **按簇取样 + 批量 TCP 预检(只拨探通的)** → 基线 → 逐槽起隧道取证 → 判语 → 善后。
27
- * 任何一步失败都会停在那一步并说清「下一步做什么」,不会攒到最后给你一个 unreachable。
28
- * @param {string[]} args 认 `--count N`(拨几条)、`--probe N`(取样几条)、`--force`(一条都不通也真拨留证据)、`--model <id>`
29
- */
30
- export async function runTest(args = []) {
31
- const cfg = resolveMultiIpConfig(process.env);
32
- const count = (() => { const v = flagValue(args, "--count", "-n"); return v === null ? MITEST_DEFAULT : Math.max(1, Math.min(MITEST_MAX, Number(v) || MITEST_DEFAULT)); })();
33
- const probeN = (() => { const v = flagValue(args, "--probe", "-p"); return v === null ? PROBE_DEFAULT : Math.max(1, Math.min(64, Number(v) || PROBE_DEFAULT)); })();
34
- const force = args.includes("--force");
35
- const model = flagValue(args, "--model", "-m") || "";
36
- const t = createTranscript({ dir: cfg.dir });
37
- console.log("=".repeat(70));
38
- console.log(" 多出口池取证 —— 全程 transcript(每一步都印,失败即停在原地)");
39
- console.log("=".repeat(70));
40
-
41
- t.step("① 环境快照");
42
- const role = resolveRole({ mode: cfg.mode, joined: loadGroupsJoined() });
43
- const root = isRoot();
44
- const supported = isSupportedPlatform();
45
- const openvpn = await checkOpenvpn(cfg.openvpnBin);
46
- for (const l of envSnapshotLines({ cfg, platform: process.platform, root, openvpn, files: dirNotes(cfg.dir) })) t.say(l);
47
- t.say(`角色门 role=${role.role} ⇒ ${role.active ? "通过" : "不建池"}(${role.why})`);
48
- if (!cfg.enabled || !role.active || !supported || !root || !openvpn.ok) {
49
- t.say("五道门有不过的 ⇒ 后面不再动作(否则只会被 openvpn/内核报一堆看不懂的错)");
50
- console.log(`\n❌ 前置不足:开关=${cfg.enabled} 角色=${role.active} Linux=${supported} root=${root} openvpn=${openvpn.ok}`);
51
- console.log(` 下一步:mslxdff ${FLAG} status(逐门给人话结论)|只想测网络可达性用 ${FLAG} probe(不要 root)`);
52
- console.log(` 完整 transcript:${t.file}`);
53
- process.exit(1);
54
- }
55
-
56
- t.step("② 节点表");
57
- let rows = [];
58
- try {
59
- rows = await ensureRows(cfg, t);
60
- } catch (e) {
61
- t.say(String(e?.message || e));
62
- console.log(`\n❌ 取不到节点表(原因见上一行)`);
63
- console.log(" 下一步:在能连的机器上 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,");
64
- console.log(" 再 export MSLXDFF_MULTI_IP_NODES_FILE=<路径> 重跑 test");
65
- console.log(` 完整 transcript:${t.file}`);
66
- process.exit(1);
67
- }
68
-
69
- t.step("③ 取样与 TCP 预检(按簇摊开取样;探不通的一律不拨,省掉几分钟白等)");
70
- const survey = await surveyCandidates({ cfg, rows, t, limit: probeN, timeoutMs: Math.min(6_000, cfg.probeTimeoutMs), concurrency: 6 });
71
- if (!survey.sample.length) {
72
- t.say(`Score≥${cfg.minScore} 且国家匹配的候选为 0(表里共 ${rows.length} 个节点)`);
73
- console.log(`\n❌ 没有可用候选`);
74
- console.log(" 下一步:放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY 再跑");
75
- console.log(` 完整 transcript:${t.file}`);
76
- process.exit(1);
77
- }
78
- const tcpResults = survey.results.filter((r) => String(r.node?.proto || "").toLowerCase() !== "udp");
79
- if (!survey.live.length && tcpResults.length && !force) {
80
- t.say(`⛔ 取样里 ${tcpResults.length} 条 TCP 节点**一条都连不上** ⇒ 这台机到这一片节点整体不通(网络层,不是本模块配错)`);
81
- t.say("下一步:① 换网络/换出口——云厂商安全组、运营商对境外 VPN 端口的限制都在这一层");
82
- t.say(` ② 加大取样再看一次:mslxdff ${FLAG} test --probe 40(或去掉国家白名单 MSLXDFF_MULTI_IP_COUNTRY=)`);
83
- t.say(` ③ 只测可达性、不要 root:mslxdff ${FLAG} probe --probe 40`);
84
- t.say(` ④ 一定要看真拨的证据:mslxdff ${FLAG} test --force`);
85
- console.log(`\n❌ 取样里 ${tcpResults.length} 条 TCP 节点一条都连不上`);
86
- console.log(" 这不是本模块的配置错,是这台机到这些免费节点(这一片机房)的网络不通");
87
- console.log(` 完整 transcript:${t.file}`);
88
- process.exit(1);
89
- }
90
- const dialRows = (survey.live.length ? survey.live : survey.nodes).slice(0, count);
91
- for (const l of slotPlanLines({ cfg, nodes: dialRows })) t.say(l);
92
- t.say(`拨号计划:${dialRows.length} 条${survey.live.length ? "(取自上面探通的)" : "(没有探通的 ⇒ 按取样顺序强拨,只为留证据)"}`);
93
-
94
- t.step("④ 基线 + 逐槽起隧道取证");
95
- const onProgress = (ev) => {
96
- if (ev.phase === "nodes") t.say(`(selftest 用的表:${ev.source},${ev.total} 个节点)`);
97
- else if (ev.phase === "baseline-start") t.say("基线:直连(系统默认出口)打一条免费层请求…");
98
- else if (ev.phase === "baseline-done") t.say(`基线:${line(ev)}${ev.body ? ` ${String(ev.body).slice(0, 90).replace(/\s+/g, " ")}` : ""}`);
99
- else if (ev.phase === "exit-start") t.say(`槽 ${ev.i}/${ev.n}:${ev.row.hostName}(${ev.row.countryShort || "?"})开始`);
100
- else if (ev.phase === "exit-done") t.say(`槽 ${ev.i}/${ev.n} 结果:${line(ev)}${ev.exitIp ? `|出口 IP ${ev.exitIp}` : ""}`);
101
- };
102
- let r;
103
- try {
104
- r = await runMultiIpCompare({ count: dialRows.length || count, slots: dialRows, ...(model ? { model } : {}), onProgress, deps: { log: (m) => t.say(m) } });
105
- } catch (e) {
106
- t.say(`测试执行抛错:${String(e?.stack || e?.message || e)}`);
107
- console.log(`\n❌ 测试执行出错:${e?.message ?? e}`);
108
- console.log(` 完整 transcript:${t.file}`);
109
- process.exit(1);
110
- }
111
- if (r.status === "skip") {
112
- t.say(`跳过:${r.reason}`);
113
- console.log(`\n❌ 测不了:${r.reason}`);
114
- console.log(` 下一步:${r.hint}`);
115
- console.log(` 完整 transcript:${t.file}`);
116
- process.exit(1);
117
- }
118
-
119
- t.step("⑤ 判语");
120
- console.log("-".repeat(70));
121
- console.log(` 基线(直连):${line(r.baseline)}`);
122
- console.log(` VPN 出口:有额度 ${r.okCount}|受限 ${r.limitedCount}|不可达 ${r.netCount}(共 ${r.requested})`);
123
- const v = VERDICT_ZH[r.verdict] || { text: "未判定", next: "" };
124
- const mark = r.verdict === "multi-ip-works" ? "✅" : r.verdict === "mixed" ? "△" : "⛔";
125
- console.log(` ${mark} ${v.text}:${r.okCount}/${r.requested} 条隧道拿到 200`);
126
- if (v.next) console.log(` ${v.next}`);
127
- for (const e of r.exits || []) console.log(` · ${e.hostName}(${e.country || "?"})${e.exitIp ? `出口 ${e.exitIp}|` : ""}${line(e)}`);
128
-
129
- t.step("⑥ 善后与存档");
130
- t.say(`隧道由本命令自造自拆;若中途 Ctrl+C,按下面几条查残留:`);
131
- for (const h of residualHints()) t.say(` ${h}`);
132
- console.log("");
133
- console.log(` 完整 transcript(贴这一个文件就够):${t.file}`);
134
- console.log(` 重试:mslxdff ${FLAG} test --count ${r.requested}${model ? ` --model ${model}` : ""}`);
135
- process.exit(r.verdict === "multi-ip-works" || r.verdict === "mixed" ? 0 : 1);
136
- }