mslxdff 0.1.245 → 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 -157
- 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 -160
- 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,160 +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 即成功(顺手写缓存 + 问一句表龄:`maxAgeMs`)。
|
|
52
|
-
* 全失败时**逐源原因都进异常**(受限网络下「为什么取不到」就是用户唯一的线索)。
|
|
53
|
-
* @returns {Promise<{ source: string, rows: object[], text: string, meta: object|null, stale: boolean }>} text 供 `fetchNodesToFile` 落盘
|
|
54
|
-
*/
|
|
55
|
-
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), maxAgeMs = 0, 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
|
-
const meta = await fetchTableMeta({ source: url, fetchImpl: f, timeoutMs: Math.min(5_000, timeoutMs), now });
|
|
72
|
-
const stale = noteTable({ url, rows, meta, maxAgeMs, dir, now, log });
|
|
73
|
-
return { source: url, rows, text, meta, stale };
|
|
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, maxAgeMs = 0, log } = {}) {
|
|
88
|
-
const got = await fetchNodes({ sources, fetchImpl, timeoutMs, maxAgeMs, 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
|
-
}
|
|
111
|
-
|
|
112
|
-
/** 镜像表的同源元数据文件名(上游 `build_vpngate_mirror.py` 固定产出,含 `generated_at_iso`)。 */
|
|
113
|
-
const TABLE_META_NAME = "vpngate.meta.json";
|
|
114
|
-
|
|
115
|
-
/** 表 URL → 同目录元数据 URL:只认 `.../vpngate.csv` 这类镜像表(官方 `api/iphone/`、本地表文件都没有 meta)。 */
|
|
116
|
-
export function metaUrlFor(url) {
|
|
117
|
-
const m = /^(https?:\/\/\S*\/)vpngate\.csv$/i.exec(String(url || "").trim());
|
|
118
|
-
return m ? `${m[1]}${TABLE_META_NAME}` : "";
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/** 表龄的人话(分钟/小时/天)。 */
|
|
122
|
-
export function humanAge(ms) {
|
|
123
|
-
const mins = Math.round(Number(ms) / 60_000);
|
|
124
|
-
if (!Number.isFinite(mins) || mins < 0) return "未知";
|
|
125
|
-
if (mins < 60) return `${mins} 分钟`;
|
|
126
|
-
const hours = mins / 60;
|
|
127
|
-
return hours < 48 ? `${hours.toFixed(1)} 小时` : `${Math.round(hours / 24)} 天`;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* 问一句「这份表是什么时候生成的」——镜像源长得一模一样(同样 15 列、同样能解析),
|
|
132
|
-
* 但可能是**几个月前的手工快照**:真机 2026-10-26 实测,参照实现仓库内那份 2026-08-26 生成、
|
|
133
|
-
* 而它的活表在 GitHub Pages(2026-10-24 生成)⇒ 拿旧表就是拿一堆早已下线的 IP 白拨几分钟,
|
|
134
|
-
* 症状与被墙完全一样。拿不到 meta(官方源/本地表/网络失败)就返回 null,绝不猜。
|
|
135
|
-
* @returns {Promise<{ ageMs: number, generatedAtIso: string } | null>}
|
|
136
|
-
*/
|
|
137
|
-
export async function fetchTableMeta({ source = "", fetchImpl, timeoutMs = 5_000, now = () => Date.now() } = {}) {
|
|
138
|
-
const url = metaUrlFor(source);
|
|
139
|
-
if (!url) return null;
|
|
140
|
-
try {
|
|
141
|
-
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
142
|
-
const res = await f(url, { signal: timeoutSignal(timeoutMs) });
|
|
143
|
-
if (!res.ok) return null;
|
|
144
|
-
const meta = JSON.parse(await res.text());
|
|
145
|
-
const at = Number(meta?.generated_at) * 1000 || Date.parse(String(meta?.generated_at_iso || ""));
|
|
146
|
-
if (!Number.isFinite(at) || at <= 0) return null;
|
|
147
|
-
return { ageMs: Math.max(0, now() - at), generatedAtIso: String(meta.generated_at_iso || new Date(at).toISOString()) };
|
|
148
|
-
} catch {
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** 取到表后的一次性记账:打「表龄」行、过期就 ⚠、按需写缓存,返回是否过期。 */
|
|
154
|
-
function noteTable({ url = "", rows = [], meta = null, maxAgeMs = 0, dir = "", now = () => Date.now(), log = () => {} } = {}) {
|
|
155
|
-
const stale = Boolean(meta && maxAgeMs > 0 && meta.ageMs > maxAgeMs);
|
|
156
|
-
log(`[multi-ip-pool] 节点表取自 ${url}(${rows.length} 个节点${meta ? `,${humanAge(meta.ageMs)}前生成` : ""})${stale ? " ⚠ 表已过期" : ""}`);
|
|
157
|
-
if (stale) log(`[multi-ip-pool] ⚠ 这张表已 ${humanAge(meta.ageMs)}没更新 ⇒ 节点多半早已下线(免费节点掉得快),拿它拨号就是白等几分钟:换源 MSLXDFF_MULTI_IP_SOURCES,或自带表 MSLXDFF_MULTI_IP_NODES_FILE`);
|
|
158
|
-
if (dir) saveNodeCache({ dir, source: url, rows, now });
|
|
159
|
-
return stale;
|
|
160
|
-
}
|
|
@@ -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
|
-
}
|