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,110 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,205 +0,0 @@
|
|
|
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
|
-
/**
|
|
109
|
-
* 节点「簇」:同一批免费节点往往同机房同网段(hostName 同前缀 + 同 /24),**一条连不上时整片都连不上**。
|
|
110
|
-
* 真机 2026-10-25 实证:按 Score 取 3 个候选全落在 `public-vpn-*` / `219.100.37.x`,
|
|
111
|
-
* 于是「一个机房的墙」被误读成「全部节点不通」。取样与候选排序都必须按簇摊开。
|
|
112
|
-
*/
|
|
113
|
-
export function clusterOf(row) {
|
|
114
|
-
const host = String(row?.hostName || "").replace(/[-.\d]+$/, "");
|
|
115
|
-
const seg = String(row?.ip || "").split(".").slice(0, 3).join(".");
|
|
116
|
-
return `${host}|${seg}`;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** 达标候选条数(只数不取:给「取样里有几个是达标的」这类结论用)。 */
|
|
120
|
-
export function countCandidates({ rows = [], countries = [], minScore = 0 } = {}) {
|
|
121
|
-
return rows.filter((r) => r.config && r.score >= minScore && matchCountry(r, countries)).length;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* 取样候选:Score 降序,但**按簇轮转**(先每个机房各出一条,再轮到各家第二条…)。
|
|
126
|
-
* 为什么不沿用纯 Score 序:免费表前列常被同一个机房霸榜,纯排序取样=拿一个机房的运气赌整台机器能不能用。
|
|
127
|
-
* 轮转不丢节点——簇内仍是 Score 降序,只是把同簇的连续几条摊到后面几轮去。
|
|
128
|
-
* @param {{ rows?: object[], countries?: string[], minScore?: number, limit?: number,
|
|
129
|
-
* perCluster?: number, skip?: Set<string> }} opts perCluster=0 表示不设每簇上限
|
|
130
|
-
*/
|
|
131
|
-
export function sampleCandidates({ rows = [], countries = [], minScore = 0, limit = 12, perCluster = 0, skip = new Set() } = {}) {
|
|
132
|
-
const pool = candidateNodes({ rows, countries, minScore, scan: Math.max(1, rows.length), skip });
|
|
133
|
-
const groups = new Map();
|
|
134
|
-
for (const r of pool) {
|
|
135
|
-
const key = clusterOf(r);
|
|
136
|
-
if (!groups.has(key)) groups.set(key, []);
|
|
137
|
-
groups.get(key).push(r);
|
|
138
|
-
}
|
|
139
|
-
const out = [];
|
|
140
|
-
for (let round = 0; out.length < limit && groups.size; round++) {
|
|
141
|
-
let added = 0;
|
|
142
|
-
for (const list of groups.values()) {
|
|
143
|
-
if (out.length >= limit) break;
|
|
144
|
-
if (round >= list.length || (perCluster > 0 && round >= perCluster)) continue;
|
|
145
|
-
out.push(list[round]);
|
|
146
|
-
added += 1;
|
|
147
|
-
}
|
|
148
|
-
if (!added) break; // 各簇都取空了
|
|
149
|
-
}
|
|
150
|
-
return out;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** 只读地把一行解成「探什么/拨什么」(不落文件):TCP 预检与选槽共用同一份解析。 */
|
|
154
|
-
export function probeTargetOf(row) {
|
|
155
|
-
const conf = decodeConfig(row?.config);
|
|
156
|
-
if (!conf) return null;
|
|
157
|
-
const remote = parseRemote(conf);
|
|
158
|
-
if (!remote) return null;
|
|
159
|
-
return { decoded: conf, host: remote.host, port: remote.port, proto: (/^\s*proto\s+(\S+)/m.exec(conf) || [])[1] || "" };
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
/** 节点表缓存(TTL 内直接用,省一次外网请求;VPNGate 免费节点掉得快,默认 30min)。 */
|
|
163
|
-
export function loadNodeCache({ dir, ttlMs = 0, now = () => Date.now() } = {}) {
|
|
164
|
-
try {
|
|
165
|
-
const raw = JSON.parse(readFileSync(join(dir, CACHE_FILE), "utf8"));
|
|
166
|
-
if (!Array.isArray(raw?.rows) || !raw.rows.length) return null;
|
|
167
|
-
if (ttlMs > 0 && now() - Number(raw.fetchedAt || 0) > ttlMs) return null;
|
|
168
|
-
return raw;
|
|
169
|
-
} catch {
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
export function saveNodeCache({ dir, source = "", rows = [], now = () => Date.now() } = {}) {
|
|
175
|
-
ensureDir(dir);
|
|
176
|
-
const payload = { fetchedAt: now(), source, rows };
|
|
177
|
-
writePrivate(join(dir, CACHE_FILE), JSON.stringify(payload));
|
|
178
|
-
return payload;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
/** 落一份 node conf(设备名/路由由 CLI 覆盖,不改原配置文本);返回文件路径。 */
|
|
183
|
-
export function writeNodeConf({ dir, row, name = "" } = {}) {
|
|
184
|
-
ensureDir(dir);
|
|
185
|
-
const safe = String(row.hostName || "node").replace(/[^\w.-]/g, "_").slice(0, 48);
|
|
186
|
-
const alias = name || `${safe}-${row.ip}`;
|
|
187
|
-
return { path: writePrivate(join(dir, `node-${alias}.conf`), row.config), hostName: row.hostName };
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
/** writeNodeConf 落的是**解码后**的配置;这里复用 `probeTargetOf` 解码:
|
|
191
|
-
* 解不开或缺 remote 的节点一律返 null(绝不猜),绝不把坏配置交给 openvpn。 */
|
|
192
|
-
export function materializeNode({ dir, row, name = "" } = {}) {
|
|
193
|
-
const target = probeTargetOf(row);
|
|
194
|
-
if (!target) return null;
|
|
195
|
-
// 落盘的是解码后的配置文本;别名照旧按 hostName/ip 生成(与旧行为一致)
|
|
196
|
-
const written = writeNodeConf({ dir, row: { ...row, config: target.decoded }, name });
|
|
197
|
-
// host/port/proto 与 TCP 预检同源(都出自 `probeTargetOf`):预检探的与这里拨的必须是同一份解释
|
|
198
|
-
return { ...row, conf: written.path, host: target.host, port: target.port, proto: target.proto };
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/** VPNGate 公共账号(vpn/vpn);落在 0600 文件里,绝不入库。 */
|
|
202
|
-
export function writeAuthFile({ dir, user = "vpn", pass = "vpn" } = {}) {
|
|
203
|
-
ensureDir(dir);
|
|
204
|
-
return writePrivate(join(dir, "auth.txt"), `${user}\n${pass}\n`);
|
|
205
|
-
}
|
|
@@ -1,162 +0,0 @@
|
|
|
1
|
-
// multi-ip-pool · 日志文案(判据来自 warp-pool/report.js 的同一套纪律):
|
|
2
|
-
// **「看完日志还需要的下一个命令,就是日志没写好的地方」**。
|
|
3
|
-
// 行首符号:▸步骤 / ✓正常 / ✗失败 / ⚠警告;每条失败行都要带「为什么 + 下一步」。
|
|
4
|
-
const SYM = { step: "▸", ok: "✓", bad: "✗", warn: "⚠" };
|
|
5
|
-
const TAG = "[multi-ip-pool]";
|
|
6
|
-
|
|
7
|
-
export function fmtConfig(cfg) {
|
|
8
|
-
return `${TAG} ${SYM.step} 多出口池配置:隧道 ${cfg.count} 条|备选 ${cfg.standby} 条(每 ${Math.round((cfg.standbyEveryMs || 0) / 60_000)} 分钟重拨一批)|打 hi 用 ${cfg.probeModel || "缺省免费模型"}|国家 ${cfg.countries.length ? cfg.countries.join("/") : "不限"}|Score ≥ ${cfg.minScore}|表号 ${cfg.baseTable}+|目录 ${cfg.dir}|openvpn ${cfg.openvpnBin}|轮换 ${cfg.rotate ? "开" : "关"}`;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function fmtDisabled() {
|
|
12
|
-
return `${TAG} 出口池已关闭(MSLXDFF_MULTI_IP_POOL=0),请求走直连`;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function fmtRoleSkip({ role, why }) {
|
|
16
|
-
return `${TAG} ${SYM.step} 跳过:本节点角色=${role}(${why})—— 出口池只由组长建,组员与单机都不建`;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function fmtRoleOk({ role, why }) {
|
|
20
|
-
return `${TAG} ${SYM.ok} 角色门通过:role=${role}(${why})`;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function fmtPlatformSkip({ platform, why }) {
|
|
24
|
-
return `${TAG} ${SYM.step} 跳过:${why}(当前平台 ${platform})—— 本功能要 Linux 的 tun 设备与 ip rule,其它平台请用 v6 池或 WARP 池`;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function fmtNoRoot() {
|
|
28
|
-
return `${TAG} ${SYM.step} 跳过:需要 root(要建 tun 设备 + 改策略路由 + 写 rp_filter)。下一步:sudo mslxdff -multi-ip-pool test(或让 root 起 daemon)`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function fmtNoOpenvpn({ bin, why }) {
|
|
32
|
-
return `${TAG} ${SYM.step} 跳过:没找到可用的 ${bin}(${why})。下一步:apt install openvpn(或 MSLXDFF_MULTI_IP_OPENVPN 指到全路径)`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function fmtNodes({ source, total, countries, minScore }) {
|
|
36
|
-
return `${TAG} ${SYM.ok} 节点表来自 ${source}:共 ${total} 个节点(筛选:国家 ${countries.length ? countries.join("/") : "不限"}、Score ≥ ${minScore})`;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function fmtNodePick({ i, n, row, dev }) {
|
|
40
|
-
return `${TAG} ${SYM.step} [${i}/${n}] 尝试 ${row.hostName}(${row.countryShort || "?"} ${row.ip} Score=${row.score} ping=${row.ping}ms)→ ${dev}`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function fmtSpawn({ dev, pid, host, port, country, table }) {
|
|
44
|
-
return `${TAG} ${SYM.ok} ${dev} 隧道就绪(pid=${pid},出口节点 ${host}:${port} ${country},策略表 ${table})`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** 实际执行的 openvpn 命令行(argv 原文):真机排障第一问就是「你到底跑了什么」。 */
|
|
48
|
-
export function fmtArgv({ bin, args }) {
|
|
49
|
-
return `${TAG} ${SYM.step} 执行:${bin} ${(args || []).join(" ")}`;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function fmtTunAddrFail({ dev, out }) {
|
|
53
|
-
return `${TAG} ${SYM.bad} ${dev} 起来了但读不到 tun 地址(ip -4 addr show dev ${dev} 的输出:${(out || "(空)").slice(0, 200)})—— 隧道建不起来,这槽换下一个节点`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export function fmtRuleFail({ dev, cmd, error }) {
|
|
57
|
-
return `${TAG} ${SYM.bad} ${dev} 装策略路由失败:${cmd} → ${error}。下一步:ip rule show 看优先级、ip route show table all 看表号,是否被别人的规则/表号占了(可改 MSLXDFF_MULTI_IP_BASE_TABLE)`;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function fmtRpf({ ok, skipped, changed = [], error = "" } = {}) {
|
|
61
|
-
if (skipped) return `${TAG} ${SYM.warn} rp_filter 未改(MSLXDFF_MULTI_IP_SYSCTL=0):若隧道通了但请求全挂住,先查这里`;
|
|
62
|
-
if (ok && !changed.length) return `${TAG} ${SYM.ok} rp_filter 已是宽松模式(2),无需改动`;
|
|
63
|
-
if (ok) return `${TAG} ${SYM.warn} 已把 rp_filter 改为 2(宽松):${changed.map((c) => `${c.path}: ${c.from}→2`).join(",")}|还原命令:sysctl -w net.ipv4.conf.all.rp_filter=1`;
|
|
64
|
-
return `${TAG} ${SYM.warn} rp_filter 改不动(${error}):可能隧道活着但请求挂住(不对称路由被内核丢了)。下一步:sysctl -w net.ipv4.conf.all.rp_filter=2`;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function fmtProbe({ dev, url, ok, status, error, ms }) {
|
|
68
|
-
if (ok) return `${TAG} ${SYM.ok} ${dev} 探活通过:${url} → HTTP ${status}(${ms}ms)`;
|
|
69
|
-
return `${TAG} ${SYM.bad} ${dev} 探活不通:${url} → ${status ? `HTTP ${status}` : String(error || "无响应").slice(0, 120)}(${ms}ms)`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function fmtProbeGiveUp({ dev, attempts, url }) {
|
|
73
|
-
return `${TAG} ${SYM.bad} ${dev} 连续 ${attempts} 次探活都不过,本槽不进池(${url})—— 隧道进程活着但转不动流量时进池会把请求挂到超时,比直连更糟`;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function fmtExitIp({ dev, ip, ms, prev }) {
|
|
77
|
-
const what = prev ? (prev === ip ? `出口没变(仍是 ${ip})` : `出口已换:${prev} → ${ip}`) : `出口 ${ip}`;
|
|
78
|
-
return `${TAG} ${SYM.ok} ${dev} 出口取证:${what}(${ms}ms)`;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
export function fmtExitIpFail({ dev, error }) {
|
|
82
|
-
return `${TAG} ${SYM.bad} ${dev} 出口取证失败:${String(error || "").slice(0, 160)}—— 这条隧道的出口未知,不视为可用`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function fmtPool({ pooled, tried, exits }) {
|
|
86
|
-
return `${TAG} ${SYM.ok} 出口池就绪:${pooled}/${tried} 槽可用|出口 IP:${exits.length ? exits.join(",") : "(待取证)"}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function fmtPoolEmpty({ tried, why }) {
|
|
90
|
-
return `${TAG} ${SYM.bad} 出口池为空(试了 ${tried} 个节点都没起通):${why}—— 请求继续走直连,不影响其它出口源`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function fmtSwap({ dev, from, to, status, kind, forced, swaps }) {
|
|
94
|
-
return `${TAG} ${SYM.step} ${dev} 上游 ${status} 判为「${kind}」⇒ 换出口${forced ? "(强制,跳过冷却)" : ""}:${from} → ${to}(第 ${swaps} 次换)`;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function fmtSwapFail({ dev, error }) {
|
|
98
|
-
return `${TAG} ${SYM.bad} ${dev} 换出口失败:${String(error || "").slice(0, 200)}—— 原槽保持原状,不把池打空`;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function fmtFirstOk({ dev, ip, swaps, counts }) {
|
|
102
|
-
return `${TAG} ${SYM.ok} ${dev} 换出口后首个 200(出口 ${ip},换过 ${swaps} 次,本轮状态分布 ${JSON.stringify(counts)})—— 这就是「换 IP 有没有换来新额度」的直接证据`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function fmtNoRotate({ dev, status, kind }) {
|
|
106
|
-
return `${TAG} ${SYM.warn} ${dev} 上游 ${status}(${kind})—— 轮换开关关着(MSLXDFF_MULTI_IP_ROTATE=0),不换出口`;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function fmtVerdictNo({ dev, status, kind, why }) {
|
|
110
|
-
return `${TAG} ${SYM.step} ${dev} 上游 ${status} 判为「${kind}」⇒ 不换出口:${why}`;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function fmtDirect({ reason }) {
|
|
114
|
-
return `${TAG} ${SYM.warn} 全部槽都在冷却/换出口中,本发走直连兜底:${reason}`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function fmtExit({ dev, code, signal, pid, aliveMs, tail }) {
|
|
118
|
-
return `${TAG} ${SYM.warn} ${dev} 隧道进程退出(code=${code} signal=${signal} pid=${pid}${aliveMs ? ` 存活 ${Math.round(aliveMs / 1000)}s` : ""}):${tail || "(无输出)"}`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
export function fmtClose({ closed, failed = 0 }) {
|
|
122
|
-
return `${TAG} ${SYM.step} 收摊:已关 ${closed} 条隧道${failed ? `,${failed} 条没关干净(下次启动查残留:ip link show | grep ${""}mltun)` : ""}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function fmtResidual({ dev }) {
|
|
126
|
-
return `${TAG} ${SYM.warn} 发现上一代留下的设备 ${dev}(上次 daemon 被强杀时子进程不会自己退):下一步 ip link del ${dev}`;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/** 免费层判决的人话名(与 `warp-pool/verdict.js` 的 kind 一一对应,不自造一套)。 */
|
|
130
|
-
const KIND_ZH = { "free-quota": "429 额度耗尽", "free-tier": "403 免费层资格", "ip-limit": "429 限流", "black-quota": "账号日配额", network: "网络不可达", none: "非额度信号" };
|
|
131
|
-
|
|
132
|
-
/** 「打一个 hi 到上游、要它 200」的读数行:只有 200 才算"这个出口此刻真有额度"。 */
|
|
133
|
-
export function fmtHi({ dev, model, status, kind, ip, ms, error } = {}) {
|
|
134
|
-
const who = `${dev}${ip ? `(出口 ${ip})` : ""}`;
|
|
135
|
-
if (status === 200) return `${TAG} ${SYM.ok} ${who} 打 hi 通过:${model} → HTTP 200(${ms}ms)—— 这个出口有额度`;
|
|
136
|
-
return `${TAG} ${SYM.bad} ${who} 打 hi 未通:${model} → ${status ? `HTTP ${status}(${KIND_ZH[kind] || kind || "?"})` : String(error || "无响应").slice(0, 120)}(${ms}ms)—— 没额度的出口不当备用,换下一个`;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** 备用出口收下:一条已验通(hi 200)的隧道躺在备选池里等 429。 */
|
|
140
|
-
export function fmtStandbyAdd({ dev, node, exitIp, tunIp, have, want } = {}) {
|
|
141
|
-
return `${TAG} ${SYM.ok} 备选池 +1:${dev} ${node || "?"}(tun ${tunIp},出口 ${exitIp || "未取证"})→ 现有 ${have}/${want} 条待命`;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** 补位结论行(每小时一次,也是"这轮找到几个新 IP"的读数)。 */
|
|
145
|
-
export function fmtStandbyRefill({ added, have, want, dialed } = {}) {
|
|
146
|
-
return `${TAG} ${SYM.step} 备选池补位:本轮拨 ${dialed} 条、收下 ${added} 条 → ${have}/${want} 待命(标准是 hi 回 200,不是"隧道起来了")`;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** 取用备用出口那一刻的读数(429 后"立即启用"的证据)。 */
|
|
150
|
-
export function fmtStandbyUse({ dev, tunIp, exitIp, node, left } = {}) {
|
|
151
|
-
return `${TAG} ${SYM.ok} 启用备用出口:${dev} ${node || "?"}(tun ${tunIp},出口 ${exitIp || "未取证"})→ 剩余备用 ${left} 条`;
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** 备用出口被丢弃的原因(复验没过 200 = 这一小时里那个 IP 的额度被别人吃光了)。 */
|
|
155
|
-
export function fmtStandbyDrop({ dev, node, status, kind, error } = {}) {
|
|
156
|
-
return `${TAG} ${SYM.warn} 丢弃备用出口 ${dev} ${node || "?"}:复验 hi → ${status ? `HTTP ${status}(${KIND_ZH[kind] || kind || "?"})` : String(error || "无响应").slice(0, 120)}—— 宁缺毋滥,不把没额度的 IP 交给请求`;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
/** 备选池这轮没干成事(取不到候选 / 拨号异常):必须留一行,否则"为什么没有备胎"无从判断。 */
|
|
160
|
-
export function fmtStandbySkip({ why } = {}) {
|
|
161
|
-
return `${TAG} ${SYM.warn} 备选池本轮跳过:${why}—— 429 时若备胎为空只能现场拨号(慢),原因就是这条`;
|
|
162
|
-
}
|
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// multi-ip-pool · 出口额度对照测试:回答「这台机器的直连出口撞 429 时,换成 VPNGate 出口还有没有额度」。
|
|
2
|
-
//
|
|
3
|
-
// 这才是多出口池的**唯一价值判据**(opencode 免费层按**出口 IP** 计额,见 docs/domain-notes.md §5):
|
|
4
|
-
// 基线(直连)429 而某个 VPN 出口 200 ⇒ 池有用;VPN 出口也全 429 ⇒ 换 IP 抬不动(上游按别的东西计额)。
|
|
5
|
-
// 对照纪律:**除"从哪里出网"外一切相同** —— 同 URL / 同 UA / 同身份四件套 header / 同模型 /
|
|
6
|
-
// 同 `stream:true` + 五核心工具名;整轮共用同一个 session(免费层按出口 IP 计额,共用 session 才测得干净)。
|
|
7
|
-
// 请求形状**直接复用** `v6pool/selftest.js`(同一份免费层契约,绝不在两处各写一遍)。
|
|
8
|
-
import { getUndici } from "../compat.js";
|
|
9
|
-
import { genId } from "../opencode-identity.js";
|
|
10
|
-
import { DEFAULT_PROBE_MODEL, probeUpstreamFrom } from "../v6pool/selftest.js";
|
|
11
|
-
import { classifyWarpSignal } from "../warp-pool/verdict.js";
|
|
12
|
-
import { resolveMultiIpConfig, resolveRole } from "./config.js";
|
|
13
|
-
import { candidateNodes, loadNodeCache, materializeNode, writeAuthFile } from "./nodes.js";
|
|
14
|
-
import { fetchNodes, probeExitIp } from "./nodes-fetch.js";
|
|
15
|
-
import { bringUpTunnel, checkOpenvpn, isRoot, isSupportedPlatform, tearDownTunnel } from "./tunnel.js";
|
|
16
|
-
import { fmtExitIp, fmtNodePick, fmtNodes, fmtProbe } from "./report.js";
|
|
17
|
-
import { loadGroupsJoined } from "../state.js";
|
|
18
|
-
|
|
19
|
-
/** 采样上限:VPNGate 免费节点起隧道是秒级操作,别把测试拖成十几分钟。 */
|
|
20
|
-
export const MITEST_MAX = 8;
|
|
21
|
-
export const MITEST_DEFAULT = 3;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* 跑一次出口对照测试(本模块不打印,结构化返回给 CLI 渲染)。
|
|
25
|
-
*
|
|
26
|
-
* @param {{ env?: object, count?: number, model?: string, baseUrl?: string,
|
|
27
|
-
* onProgress?: (ev: object) => void,
|
|
28
|
-
* deps?: { joined?, platform?, supported?, isRoot?, checkOpenvpn?, loadNodeCache?, fetchNodes?,
|
|
29
|
-
* materializeNode?, bringUpTunnel?, tearDownTunnel?, agentFactory?, probeExitIp?,
|
|
30
|
-
* probeUpstream?, fetchImpl?, session?, log? }, slots?: object[] }} opts
|
|
31
|
-
*/
|
|
32
|
-
export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFAULT, model = DEFAULT_PROBE_MODEL, baseUrl = "https://opencode.ai", onProgress, deps = {}, slots = null } = {}) {
|
|
33
|
-
const cfg = resolveMultiIpConfig(env);
|
|
34
|
-
const tick = (ev) => { try { onProgress?.(ev); } catch { /* 渲染出错不打断测试 */ } };
|
|
35
|
-
const log = deps.log || (() => {});
|
|
36
|
-
|
|
37
|
-
if (!cfg.enabled) return { status: "skip", reason: "多出口池已关闭(MSLXDFF_MULTI_IP_POOL=0)", hint: "去掉该 env 或设为 1 再跑" };
|
|
38
|
-
const role = resolveRole({ mode: cfg.mode, joined: deps.joined ?? loadGroupsJoined() });
|
|
39
|
-
if (!role.active) return { status: "skip", reason: `本节点角色是 ${role.role}(${role.why})`, hint: "本功能只给组长做取证;组员请用组长那条出口" };
|
|
40
|
-
const platform = deps.platform || process.platform;
|
|
41
|
-
if (!(deps.supported ?? isSupportedPlatform(platform))) {
|
|
42
|
-
return { status: "skip", reason: `本功能需要 Linux 的 tun 设备与策略路由(当前平台 ${platform})`, hint: "在 Linux VPS 上跑;其它平台用 mslxdff -v6-pool 或 WARP 池" };
|
|
43
|
-
}
|
|
44
|
-
if (!(deps.isRoot ?? isRoot)()) {
|
|
45
|
-
return { status: "skip", reason: "需要 root(建 tun 设备 + 装策略路由 + 写 rp_filter)", hint: "sudo mslxdff -multi-ip-pool test" };
|
|
46
|
-
}
|
|
47
|
-
const openvpn = await (deps.checkOpenvpn || checkOpenvpn)(cfg.openvpnBin);
|
|
48
|
-
if (!openvpn.ok) return { status: "skip", reason: `${cfg.openvpnBin} 不可用(${openvpn.why})`, hint: "apt install openvpn" };
|
|
49
|
-
|
|
50
|
-
const { Agent } = getUndici();
|
|
51
|
-
const agentFactory = deps.agentFactory || ((addr) => new Agent({ connect: { localAddress: addr }, connections: 1, keepAliveTimeout: 5_000 }));
|
|
52
|
-
const probe = deps.probeUpstream || probeUpstreamFrom;
|
|
53
|
-
const session = deps.session || genId("ses_");
|
|
54
|
-
const materialize = deps.materializeNode || materializeNode;
|
|
55
|
-
const startSlot = deps.bringUpTunnel || bringUpTunnel;
|
|
56
|
-
const stopSlot = deps.tearDownTunnel || tearDownTunnel;
|
|
57
|
-
const exitProbe = deps.probeExitIp || probeExitIp;
|
|
58
|
-
|
|
59
|
-
// 节点表:优先用缓存(不重复打 VPNGate),取不到再拉。
|
|
60
|
-
const cache = (deps.loadNodeCache || loadNodeCache)({ dir: cfg.dir, ttlMs: cfg.nodesTtlMs });
|
|
61
|
-
let rows = cache?.rows || [];
|
|
62
|
-
let source = cache?.source || "(本地缓存)";
|
|
63
|
-
if (!rows.length) {
|
|
64
|
-
try {
|
|
65
|
-
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
66
|
-
rows = got.rows;
|
|
67
|
-
source = got.source;
|
|
68
|
-
} catch (e) {
|
|
69
|
-
return { status: "skip", reason: `节点表取不到:${String(e?.message || e)}`, hint: "在能连的机器上跑 `mslxdff -multi-ip-pool fetch` 抓一份表带过来,或设 MSLXDFF_MULTI_IP_NODES_FILE / _SOURCES" };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
tick({ phase: "nodes", source, total: rows.length });
|
|
73
|
-
log(fmtNodes({ source, total: rows.length, countries: cfg.countries, minScore: cfg.minScore }));
|
|
74
|
-
|
|
75
|
-
// ① 基线:直连(系统默认出口)打一条免费层请求
|
|
76
|
-
tick({ phase: "baseline-start", model });
|
|
77
|
-
const baseline = await probe({ addr: "", model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
|
|
78
|
-
tick({ phase: "baseline-done", ...baseline });
|
|
79
|
-
// 候选:调用方给了 `slots` 就用它——CLI 侧已经「按簇取样 + TCP 预检」过,拨的必须就是探通的那些;
|
|
80
|
-
// 没给才自己按 Score 取(单测与旧路径)。两边各挑一次会让「探的」与「拨的」不是同一批,预检就白做了。
|
|
81
|
-
|
|
82
|
-
const picked = (Array.isArray(slots) && slots.length) ? slots.slice(0, MITEST_MAX) : candidateNodes({ rows, countries: cfg.countries, minScore: cfg.minScore, scan: Math.max(1, Math.min(MITEST_MAX, Number(count) || MITEST_DEFAULT)) });
|
|
83
|
-
if (!picked.length) return { status: "skip", reason: "没有满足条件的候选节点(国家/Score 过滤后为空)", hint: "放宽 MSLXDFF_MULTI_IP_MIN_SCORE 或去掉 MSLXDFF_MULTI_IP_COUNTRY" };
|
|
84
|
-
// auth 文件必须先落到磁盘再把路径交给 openvpn。取证路径以前把 `deps.authFile`(undefined)直接传下去
|
|
85
|
-
// ⇒ `--auth-user-pass` 拿到空参数 ⇒ openvpn 判「选项缺参数」,只留一句「Use --help」——2026-10-25 真机三条槽全挂在这。
|
|
86
|
-
const authFile = deps.authFile || (deps.writeAuthFile || writeAuthFile)({ dir: cfg.dir, user: cfg.authUser, pass: cfg.authPass });
|
|
87
|
-
|
|
88
|
-
const exits = [];
|
|
89
|
-
try {
|
|
90
|
-
for (let i = 0; i < picked.length; i++) {
|
|
91
|
-
const row = picked[i];
|
|
92
|
-
const dev = `${cfg.devPrefix}${i}`;
|
|
93
|
-
tick({ phase: "exit-start", i: i + 1, n: picked.length, row });
|
|
94
|
-
log(fmtNodePick({ i: i + 1, n: picked.length, row, dev }));
|
|
95
|
-
let slot = null;
|
|
96
|
-
try {
|
|
97
|
-
const node = materialize({ dir: cfg.dir, row });
|
|
98
|
-
if (!node) throw new Error("节点配置解不开(base64 或 remote 缺失)");
|
|
99
|
-
slot = await startSlot({ cfg, slotIndex: i, node, deps: { authFile, runCmdFn: deps.runCmdFn, spawnFn: deps.spawnFn, wait: deps.wait, log } });
|
|
100
|
-
const exit = await exitProbe({ agent: agentFactory(slot.tunIp), timeoutMs: cfg.probeTimeoutMs });
|
|
101
|
-
if (exit.ok) log(fmtExitIp({ dev: slot.dev, ip: exit.ip, ms: exit.ms }));
|
|
102
|
-
// 从这条隧道的出口打**同一条**免费层请求
|
|
103
|
-
const r = await probe({ addr: slot.tunIp, model, baseUrl, agentFactory, fetchImpl: deps.fetchImpl, session });
|
|
104
|
-
log(fmtProbe({ dev: slot.dev, url: `${baseUrl.replace(/\/+$/, "")}/zen/v1/chat/completions`, ok: r.ok, status: r.status, error: r.error, ms: r.ms }));
|
|
105
|
-
const entry = { ...r, hostName: row.hostName, country: row.countryShort, dev: slot.dev, exitIp: exit.ip || "" };
|
|
106
|
-
exits.push(entry);
|
|
107
|
-
tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
|
|
108
|
-
} catch (e) {
|
|
109
|
-
const entry = { hostName: row.hostName, country: row.countryShort, dev, ok: false, status: 0, kind: "network", ms: 0, exitIp: "", error: String(e?.message || e) };
|
|
110
|
-
exits.push(entry);
|
|
111
|
-
tick({ phase: "exit-done", i: i + 1, n: picked.length, ...entry });
|
|
112
|
-
} finally {
|
|
113
|
-
if (slot) await stopSlot({ slot, runCmdFn: deps.runCmdFn, log }).catch(() => {});
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} catch (e) {
|
|
117
|
-
return { status: "skip", reason: `测试中断:${String(e?.message || e)}`, hint: "看 daemon.log 里的 [multi-ip-pool] 行" };
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// ③ 判语:对照基线(受限 = 有 HTTP 响应但拿不到额度;只有 status=0 才算"够不着")
|
|
121
|
-
const baseLimited = baseline.status === 429 || ["free-quota", "free-tier", "ip-limit"].includes(baseline.kind);
|
|
122
|
-
const ok = exits.filter((r) => r.ok);
|
|
123
|
-
const limited = exits.filter((r) => !r.ok && r.status);
|
|
124
|
-
const net = exits.filter((r) => !r.status);
|
|
125
|
-
let verdict;
|
|
126
|
-
if (ok.length && ok.length === exits.length) verdict = "multi-ip-works";
|
|
127
|
-
else if (limited.length === exits.length) verdict = "multi-ip-also-limited";
|
|
128
|
-
else if (net.length === exits.length) verdict = "multi-ip-unreachable";
|
|
129
|
-
else verdict = "mixed";
|
|
130
|
-
|
|
131
|
-
return {
|
|
132
|
-
status: "ok",
|
|
133
|
-
source, model, baseUrl, session,
|
|
134
|
-
baseLimited,
|
|
135
|
-
baseline,
|
|
136
|
-
exits,
|
|
137
|
-
requested: picked.length,
|
|
138
|
-
okCount: ok.length,
|
|
139
|
-
limitedCount: limited.length,
|
|
140
|
-
netCount: net.length,
|
|
141
|
-
verdict,
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/** 判语 → 人话(CLI 与测试共用一份,避免两处口径漂移)。 */
|
|
146
|
-
export const VERDICT_ZH = {
|
|
147
|
-
"multi-ip-works": {
|
|
148
|
-
text: "VPN 出口**能换到新额度**",
|
|
149
|
-
next: "结论:多出口池对这台机器**有用**——直连出口耗尽时换隧道就能续。daemon 侧缺省已开。",
|
|
150
|
-
},
|
|
151
|
-
"multi-ip-also-limited": {
|
|
152
|
-
text: "VPN 出口**也全受限**(换 IP 没换来新额度)",
|
|
153
|
-
next: "结论:换出口治不了(要么上游不按出口 IP 计额,要么节点出口本身已被大量共用到撞额)。建议 MSLXDFF_MULTI_IP_POOL=0。",
|
|
154
|
-
},
|
|
155
|
-
"multi-ip-unreachable": {
|
|
156
|
-
text: "VPN 出口**全都够不着上游**(隧道没转起来)",
|
|
157
|
-
next: "结论:这些节点起不通或上游被墙。查 rp_filter / 节点可用性,或换 MSLXDFF_MULTI_IP_SOURCES / 加国家白名单。",
|
|
158
|
-
},
|
|
159
|
-
mixed: {
|
|
160
|
-
text: "部分可用(有的出口有额度、有的没有)",
|
|
161
|
-
next: "结论:池**部分可用**(免费节点质量参差,属正常)。够用即可开着。",
|
|
162
|
-
},
|
|
163
|
-
};
|
|
164
|
-
|
|
165
|
-
/** 判语里的免费层分类(与生产判决同源:warp-pool/verdict.js)。 */
|
|
166
|
-
export function classifyOf(status, text = "") {
|
|
167
|
-
return classifyWarpSignal({ status, text });
|
|
168
|
-
}
|