mslxdff 0.1.244 → 0.1.245
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
CHANGED
|
@@ -50,8 +50,8 @@ export async function ensureRows(cfg, t) {
|
|
|
50
50
|
return cache.rows;
|
|
51
51
|
}
|
|
52
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}
|
|
53
|
+
const got = await fetchNodes({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log: (m) => t.say(m) });
|
|
54
|
+
t.say(`取到:${got.source} → ${got.rows.length} 个节点${got.stale ? "(⚠ 这张表已过期:节点多半早下线,别拿它当唯一来源)" : ""}`);
|
|
55
55
|
return got.rows;
|
|
56
56
|
}
|
|
57
57
|
|
|
@@ -155,9 +155,9 @@ export async function runFetch(args = []) {
|
|
|
155
155
|
t.step("逐源取表(失败原因逐条列出)");
|
|
156
156
|
t.say(`源列表:${cfg.sources.join(" → ")}`);
|
|
157
157
|
try {
|
|
158
|
-
const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs, log: (m) => t.say(m) });
|
|
158
|
+
const got = await fetchNodesToFile({ sources: cfg.sources, out, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log: (m) => t.say(m) });
|
|
159
159
|
const usable = got.rows.filter((r) => r.config).length;
|
|
160
|
-
t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}`);
|
|
160
|
+
t.say(`✅ ${got.rows.length} 个节点(${usable} 个配置可解码)→ ${out}${got.stale ? " ⚠ 这份表已过期,只能应急" : ""}`);
|
|
161
161
|
console.log("");
|
|
162
162
|
console.log(" 带到受限网络的机器上:");
|
|
163
163
|
console.log(` scp ${out} root@<组长机>:/root/.config/mslxdff/multi-ip-pool/vpngate.csv`);
|
|
@@ -30,6 +30,7 @@ export const DEFAULTS = {
|
|
|
30
30
|
cooldownMs: 600_000, // 槽失败冷却
|
|
31
31
|
rotate: true, // 撞额度信号时换槽
|
|
32
32
|
nodesTtlMs: 30 * 60_000, // 节点表缓存有效期(VPNGate 免费节点掉得快)
|
|
33
|
+
maxTableAgeMs: 24 * 60 * 60_000, // 表龄告警阈值:镜像表超过它就 ⚠(旧表 = 一堆早下线的 IP,白拨几分钟)
|
|
33
34
|
openvpnBin: "openvpn",
|
|
34
35
|
authUser: "vpn", // VPNGate 公共账号(非私人凭据)
|
|
35
36
|
authPass: "vpn",
|
|
@@ -38,16 +39,16 @@ export const DEFAULTS = {
|
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
/**
|
|
41
|
-
* VPNGate
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `MSLXDFF_MULTI_IP_SOURCES` 只留官方,或 `MSLXDFF_MULTI_IP_NODES_FILE` 自带一份本地表。
|
|
42
|
+
* VPNGate 节点源:**官方永远在前**,第三方镜像只作兜墙。
|
|
43
|
+
* 纠错(2026-10-26 实测):`aimili-vpngate` 的 Actions 只把新鲜快照发到 **GitHub Pages**(`openmili.github.io/aimili-vpngate/`),
|
|
44
|
+
* **不往仓库里写表**;仓库内 `mirror/vpngate.csv` 是手工提交的死快照(2026-08-26 后未变)——此前当「每 15 分钟刷新」用 =
|
|
45
|
+
* 拿两个月前的死 IP 白拨,真机症状与被墙一模一样。
|
|
46
|
+
* 表只是公开节点配置 + VPNGate 公共账号(vpn/vpn);不信任镜像就 `_SOURCES` 只留官方或 `_NODES_FILE` 自带。
|
|
47
47
|
*/
|
|
48
48
|
export const NODE_SOURCES = [
|
|
49
49
|
"https://www.vpngate.net/api/iphone/",
|
|
50
50
|
"http://www.vpngate.net/api/iphone/",
|
|
51
|
+
"https://openmili.github.io/aimili-vpngate/vpngate.csv",
|
|
51
52
|
"https://raw.githubusercontent.com/OpenMili/aimili-vpngate/main/mirror/vpngate.csv",
|
|
52
53
|
];
|
|
53
54
|
|
|
@@ -124,6 +125,7 @@ export function resolveMultiIpConfig(env = process.env, { logDirFn = logDir } =
|
|
|
124
125
|
cooldownMs: envInt(env, "MSLXDFF_MULTI_IP_COOLDOWN_MS", DEFAULTS.cooldownMs),
|
|
125
126
|
rotate: envBool(env, "MSLXDFF_MULTI_IP_ROTATE", DEFAULTS.rotate),
|
|
126
127
|
nodesTtlMs: envInt(env, "MSLXDFF_MULTI_IP_NODES_TTL_MS", DEFAULTS.nodesTtlMs),
|
|
128
|
+
maxTableAgeMs: envInt(env, "MSLXDFF_MULTI_IP_MAX_TABLE_AGE_MS", DEFAULTS.maxTableAgeMs),
|
|
127
129
|
openvpnBin: envStr(env, "MSLXDFF_MULTI_IP_OPENVPN", DEFAULTS.openvpnBin),
|
|
128
130
|
authUser: envStr(env, "MSLXDFF_MULTI_IP_AUTH_USER", DEFAULTS.authUser),
|
|
129
131
|
authPass: envStr(env, "MSLXDFF_MULTI_IP_AUTH_PASS", DEFAULTS.authPass),
|
|
@@ -86,7 +86,7 @@ export function createDialer({ cfg, baseUrl = "https://opencode.ai", log = () =>
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
if (!rows.length) {
|
|
89
|
-
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
89
|
+
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log });
|
|
90
90
|
rows = got.rows;
|
|
91
91
|
source = got.source;
|
|
92
92
|
}
|
|
@@ -48,11 +48,11 @@ export function sourceToLocalPath(url) {
|
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
/**
|
|
51
|
-
* 逐源尝试取表;任一源返回可解析的 CSV
|
|
51
|
+
* 逐源尝试取表;任一源返回可解析的 CSV 即成功(顺手写缓存 + 问一句表龄:`maxAgeMs`)。
|
|
52
52
|
* 全失败时**逐源原因都进异常**(受限网络下「为什么取不到」就是用户唯一的线索)。
|
|
53
|
-
* @returns {Promise<{ source: string, rows: object[], text: string }>} text
|
|
53
|
+
* @returns {Promise<{ source: string, rows: object[], text: string, meta: object|null, stale: boolean }>} text 供 `fetchNodesToFile` 落盘
|
|
54
54
|
*/
|
|
55
|
-
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), log = () => {} } = {}) {
|
|
55
|
+
export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs = 20_000, now = () => Date.now(), maxAgeMs = 0, log = () => {} } = {}) {
|
|
56
56
|
const f = fetchImpl || getUndici().fetch || compatFetch;
|
|
57
57
|
const fails = [];
|
|
58
58
|
for (const url of sources) {
|
|
@@ -68,9 +68,9 @@ export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs
|
|
|
68
68
|
}
|
|
69
69
|
const rows = parseVpnGateCsv(text);
|
|
70
70
|
if (!rows.length) throw new Error("返回体不是可解析的 VPNGate CSV");
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return { source: url, rows, text };
|
|
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
74
|
} catch (e) {
|
|
75
75
|
const why = errorWhy(e);
|
|
76
76
|
fails.push(`${url}:${why}`);
|
|
@@ -84,8 +84,8 @@ export async function fetchNodes({ sources = [], dir = "", fetchImpl, timeoutMs
|
|
|
84
84
|
* 取表并原样落一份 CSV(`-multi-ip-pool fetch` 的实现):在能连的机器上抓,scp 进受限网络的组长机,
|
|
85
85
|
* 那边 `MSLXDFF_MULTI_IP_NODES_FILE=<路径>` 即可零网络取表。0600:表里是节点凭据,不该给同机用户读。
|
|
86
86
|
*/
|
|
87
|
-
export async function fetchNodesToFile({ sources = [], out, fetchImpl, timeoutMs = 20_000, log } = {}) {
|
|
88
|
-
const got = await fetchNodes({ sources, fetchImpl, timeoutMs, log });
|
|
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
89
|
mkdirSync(dirname(out), { recursive: true });
|
|
90
90
|
writeFileSync(out, got.text, { mode: 0o600 });
|
|
91
91
|
return { ...got, out, file: out };
|
|
@@ -108,3 +108,53 @@ export async function probeExitIp({ agent, fetchImpl, timeoutMs = 12_000 } = {})
|
|
|
108
108
|
return { ok: false, ip: "", ms: Date.now() - t0, error: errorWhy(e) };
|
|
109
109
|
}
|
|
110
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
|
+
}
|
|
@@ -62,7 +62,7 @@ export async function runMultiIpCompare({ env = process.env, count = MITEST_DEFA
|
|
|
62
62
|
let source = cache?.source || "(本地缓存)";
|
|
63
63
|
if (!rows.length) {
|
|
64
64
|
try {
|
|
65
|
-
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, log });
|
|
65
|
+
const got = await (deps.fetchNodes || fetchNodes)({ sources: cfg.sources, dir: cfg.dir, timeoutMs: cfg.probeTimeoutMs, maxAgeMs: cfg.maxTableAgeMs, log });
|
|
66
66
|
rows = got.rows;
|
|
67
67
|
source = got.source;
|
|
68
68
|
} catch (e) {
|