ghprobe 0.0.0-stage → 0.1.1

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/src/hosts.js ADDED
@@ -0,0 +1,332 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // hosts 片段生成 + 事务化提权写入 + 还原 + DNS 缓存刷新
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { execFile } from "node:child_process";
8
+
9
+ export const WIN_HOSTS = "C:\\Windows\\System32\\drivers\\etc\\hosts";
10
+ const BEGIN = "# === ghprobe begin ===";
11
+ const END = "# === ghprobe end ===";
12
+
13
+ // 用户级数据目录(日志、无窗口包装器)。放 %LOCALAPPDATA% 而不是包目录:
14
+ // npm 全局安装时包目录在 node_modules 下,npm 升级/重装会整个换掉。
15
+ export function userDataDir() {
16
+ const base = process.env.LOCALAPPDATA || path.join(os.homedir(), ".ghprobe");
17
+ return path.join(base, "ghprobe");
18
+ }
19
+
20
+ // 写 hosts 跨进程锁:probe --apply 与计划任务(health --apply)可能并发,避免读改写互踩
21
+ export const HOSTS_LOCK = path.join(os.tmpdir(), "ghprobe-hosts.lock");
22
+ const LOCK_STALE_MS = 60000;
23
+
24
+ function sleepSync(ms) {
25
+ const sab = new SharedArrayBuffer(4);
26
+ Atomics.wait(new Int32Array(sab), 0, 0, ms);
27
+ }
28
+
29
+ // 抢写锁:wx 独占创建,冲突每 retryMs 重试,最多 timeoutMs;超时返回 null 表示本轮放弃。
30
+ // 锁文件带随机 PID+时间戳;超出 LOCK_STALE_MS 视为持有进程崩溃残留,强清后重试。
31
+ export function acquireHostsLock(timeoutMs = 5000, retryMs = 200) {
32
+ const deadline = Date.now() + timeoutMs;
33
+ for (;;) {
34
+ try {
35
+ const fd = fs.openSync(HOSTS_LOCK, "wx");
36
+ fs.writeSync(fd, `${process.pid} ${Date.now()}\n`);
37
+ return fd;
38
+ } catch (e) {
39
+ if (e.code !== "EEXIST") { throw e; }
40
+ try {
41
+ const st = fs.statSync(HOSTS_LOCK);
42
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { fs.unlinkSync(HOSTS_LOCK); continue; }
43
+ } catch { continue; } // 锁刚被持有者释放
44
+ }
45
+ if (Date.now() >= deadline) { return null; }
46
+ sleepSync(retryMs);
47
+ }
48
+ }
49
+
50
+ export function releaseHostsLock(fd) {
51
+ try { if (fd != null) { fs.closeSync(fd); } } catch { /* 忽略 */ }
52
+ try { fs.unlinkSync(HOSTS_LOCK); } catch { /* 忽略 */ }
53
+ }
54
+
55
+ // 从 ranked(域名->有序IP列表) 生成 hosts 块。
56
+ // 🔴 每域名写 maxPer 条(默认 2):**Windows 会把同名多条全部返回**(2026-10-11 实测,
57
+ // dns.lookup/all 与 curl 双证),且 curl / 浏览器会**逐个尝试** —— 黑洞 IP 排在前也能
58
+ // fallback 到后面的活 IP(实测 http=200 / 0.22s)。旧注释「只取第一条生效」是错的。
59
+ // 所以多写 1 条备选 = 抖动时客户端自动兜底,不必干等下一轮健康检查(最长 1 分钟)。
60
+ export function buildBlock(ranked, extraDomains, maxPer = 2) {
61
+ const lines = [BEGIN, `# generated ${new Date().toISOString()} by ghprobe`];
62
+ const put = (domain, rs) => {
63
+ for (const r of (rs || []).slice(0, Math.max(1, maxPer))) { lines.push(`${r.ip} ${domain}`); }
64
+ };
65
+ for (const [domain, rs] of ranked) {
66
+ if (rs.length) { put(domain, rs); }
67
+ }
68
+ for (const [domain, rs] of extraDomains || []) {
69
+ if (rs.length) { put(domain, rs); }
70
+ }
71
+ lines.push(END);
72
+ return lines.join("\n");
73
+ }
74
+
75
+ // 把新块合并进已有 hosts 内容:替换旧 ghprobe 块,其余行原样保留
76
+ export function mergeHosts(current, block) {
77
+ const lines = String(current).split(/\r?\n/);
78
+ const out = [];
79
+ let inBlock = false;
80
+ for (const line of lines) {
81
+ if (line.startsWith(BEGIN)) { inBlock = true; continue; }
82
+ if (line.startsWith(END)) { inBlock = false; continue; }
83
+ if (!inBlock) { out.push(line); }
84
+ }
85
+ while (out.length && out[out.length - 1].trim() === "") { out.pop(); }
86
+ if (!block) { return out.join("\n") + (out.length ? "\n" : ""); }
87
+ const merged = [...out, "", block].join("\n");
88
+ return merged.endsWith("\n") ? merged : merged + "\n";
89
+ }
90
+
91
+ // 构造提权包装命令:Start-Process -Verb RunAs 拉起目标,并把被提升进程的退出码带回来。
92
+ // 两个关键点(都踩过):
93
+ // ① 先把控制台输出编码设为 UTF-8 —— 否则 PowerShell 以 GBK 写 stderr,Node 按 UTF-8 解码会乱码,
94
+ // 调用方 throw 出来的「提权写入失败: …」就变成一串问号。
95
+ // ② 必须在 `exit $p.ExitCode` 之前判空:Start-Process 自身失败(典型是用户取消 UAC)时
96
+ // 抛的是「非终止错误」,脚本会继续往下执行,$p 仍为 $null,而 `exit $null` 等价于
97
+ // 退出码 0 —— 会把「提权失败」伪装成「成功」。-ErrorAction Stop 让它变成可捕获的终止错误。
98
+ export function buildElevateCommand(file, args = []) {
99
+ const list = args.map((a) => `'${a}'`).join(",");
100
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
101
+ + ` try { $p = Start-Process -FilePath '${file}' -Verb RunAs -Wait -PassThru -ArgumentList ${list} -ErrorAction Stop }`
102
+ + ` catch { [Console]::Error.WriteLine('提权失败: ' + $_.Exception.Message); exit 1 };`
103
+ + ` if ($null -eq $p) { exit 1 };`
104
+ + ` exit $p.ExitCode`;
105
+ }
106
+
107
+ // 构造「删除计划任务」的提权脚本。单独导出是为了可单测、可独立执行验证。
108
+ // 两个关键点:
109
+ // ① 任务不存在时直接 exit 0(幂等)—— uninstall 重复跑不应报错。
110
+ // ② 删除动作必须 -ErrorAction Stop:否则「拒绝访问」这类错误是非终止的,脚本会继续走到
111
+ // 末尾的 exit 0,把「删除失败」伪装成「成功」。
112
+ export function buildUninstallTaskScript(taskName = "ghprobe") {
113
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
114
+ + ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
115
+ + ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
116
+ + ` try { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction Stop }`
117
+ + ` catch { [Console]::Error.WriteLine('删除计划任务失败: ' + $_.Exception.Message); exit 1 };`
118
+ + ` exit 0`;
119
+ }
120
+
121
+ // 构造「写入 hosts + 注册计划任务」的提权脚本(install 用):一次 UAC 完成两件事。
122
+ // 与 buildApplyScript 的区别:中途不 exit,两件事都做完后再按综合结果返回退出码,
123
+ // 这样 install 只需弹一次 UAC,且能拿到「两件事各自成功与否」的确定结果。
124
+ export function buildInstallScript({ hostsPath, tmpPath, nodePath, binPath, cfgArg = "", minutes = 1, writeHosts = true, vbsPath = "" }) {
125
+ const bak = `${hostsPath}.ghprobe-bak`;
126
+ const hostsPart = writeHosts ? `
127
+ try {
128
+ Copy-Item -Force $src $bak
129
+ Copy-Item -Force $tmp $src
130
+ $now = [IO.File]::ReadAllText($src, $enc)
131
+ if ($now -match [regex]::Escape($marker)) { $hostsOk = $true }
132
+ else { Copy-Item -Force $bak $src; [Console]::Error.WriteLine('hosts 校验失败:未找到 ghprobe 标记,已回滚') }
133
+ } catch {
134
+ if (Test-Path $bak) { Copy-Item -Force $bak $src }
135
+ [Console]::Error.WriteLine('hosts 写入失败: ' + $_.Exception.Message)
136
+ }
137
+ if ($hostsOk) { ipconfig /flushdns | Out-Null }` : "";
138
+ // 任务动作二选一:
139
+ // ① 有 VBS 包装器(默认)→ 用 wscript.exe 跑它,VBS 内以 SW_HIDE 启动 node —— 全程无窗口;
140
+ // ② 没有(老调用方 / 包装器写失败)→ 直接跑 node,功能不受影响,但会闪一个控制台窗口。
141
+ const actionLine = vbsPath
142
+ ? `$a = New-ScheduledTaskAction -Execute "$env:SystemRoot\\System32\\wscript.exe" -Argument ('"' + '${vbsPath}' + '"')`
143
+ : `$a = New-ScheduledTaskAction -Execute '${nodePath}' -Argument '"${binPath}" health --apply${cfgArg}'`;
144
+ return `
145
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
146
+ $src = '${hostsPath}'
147
+ $tmp = '${tmpPath}'
148
+ $bak = '${bak}'
149
+ $marker = '${BEGIN}'
150
+ $enc = [Text.Encoding]::Default
151
+ $hostsOk = $${writeHosts ? "false" : "true"}
152
+ ${hostsPart}
153
+ $taskOk = $false
154
+ try {
155
+ ${actionLine}
156
+ $t = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes ${minutes})
157
+ $p = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\\$env:USERNAME" -LogonType Interactive -RunLevel Highest
158
+ Register-ScheduledTask -TaskName ghprobe -Action $a -Trigger $t -Principal $p -Force | Out-Null
159
+ $taskOk = $true
160
+ } catch {
161
+ [Console]::Error.WriteLine('注册计划任务失败: ' + $_.Exception.Message)
162
+ }
163
+ if ($hostsOk -and $taskOk) { exit 0 }
164
+ exit 1
165
+ `;
166
+ }
167
+
168
+ // 构造「无窗口运行」包装器(VBScript)。
169
+ // 为什么需要它:Task Scheduler 在交互会话里启动控制台程序(node.exe)会**必然**新建一个
170
+ // 控制台窗口,而 node.exe 没有任何「隐藏窗口」开关(不像 powershell 有 -WindowStyle Hidden)。
171
+ // 唯一可靠的做法是让 wscript.exe 起一层:WScript.Shell.Run 的第二个参数是 ShowWindow 风格,
172
+ // 传 0 = SW_HIDE,且该方式自 Win7 起一直有效。第三个参数 True = 等子进程结束再退出。
173
+ // 🔴 注释必须**纯 ASCII**:这个文件以 "ascii" 编码落盘(wscript 对无 BOM 的 .vbs 按 ANSI 解析,
174
+ // 写 UTF-8 中文会变乱码),中文字符会被掩掉高位变成一堆问号/方块。
175
+ // VBS 字符串里 `"` 要写成 `""`(成对双写),所以路径里的引号要整体转义一遍。
176
+ export function buildHiddenRunnerVbs(nodePath, binPath, args = "") {
177
+ const cmd = `"${nodePath}" "${binPath}"${args ? " " + args : ""}`;
178
+ return [
179
+ "' ghprobe hidden-runner wrapper - generated by \"ghprobe install\", removed by \"ghprobe uninstall\".",
180
+ "' Do not edit. ASCII-only on purpose (wscript reads BOM-less .vbs as ANSI).",
181
+ 'Set sh = CreateObject("WScript.Shell")',
182
+ `sh.Run "${cmd.replace(/"/g, '""')}", 0, True`,
183
+ "",
184
+ ].join("\r\n");
185
+ }
186
+
187
+ // 判断当前进程是否已经有管理员权限:hosts 是只有管理员能写的系统文件,
188
+ // 用「以读写方式打开」做探测,成功即已提权。比 spawn 一个 PowerShell 去查 token 便宜得多,
189
+ // 而且不产生任何窗口。已提权时 applyHosts 直接跑脚本,不再走 Start-Process -Verb RunAs
190
+ // (那条路会再拉一个新控制台窗口,无窗口运行就破了)。
191
+ export function isElevated(hostsPath = WIN_HOSTS) {
192
+ try { fs.closeSync(fs.openSync(hostsPath, "r+")); return true; } catch { return false; }
193
+ }
194
+
195
+ // 跑一段 PowerShell 命令,回传 { code, out, err }
196
+ function runPowerShellCommand(cmd) {
197
+ return new Promise((resolve, reject) => {
198
+ const child = execFile("powershell", ["-NoProfile", "-Command", cmd], { windowsVerbatimArguments: true, windowsHide: true });
199
+ let out = "";
200
+ let err = "";
201
+ child.stdout?.on("data", (d) => { out += d; });
202
+ child.stderr?.on("data", (d) => { err += d; });
203
+ child.on("close", (code) => resolve({ code, out, err }));
204
+ child.on("error", reject);
205
+ });
206
+ }
207
+
208
+ // 直接执行一个 .ps1(调用方保证已经提权)。windowsHide 让 powershell 以 CREATE_NO_WINDOW
209
+ // 启动,不闪窗;被提升的父进程若本身就在隐藏控制台里,子进程也是继承而非新建。
210
+ function runPowerShellFile(file) {
211
+ return new Promise((resolve, reject) => {
212
+ const child = execFile("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", file], { windowsHide: true });
213
+ let out = "";
214
+ let err = "";
215
+ child.stdout?.on("data", (d) => { out += d; });
216
+ child.stderr?.on("data", (d) => { err += d; });
217
+ child.on("close", (code) => resolve({ code, out, err }));
218
+ child.on("error", reject);
219
+ });
220
+ }
221
+
222
+ // 跑一次提权包装。单独导出是为了让回归测试能注入一个必然失败的 Start-Process 目标,
223
+ // 从而验证「提权失败不会退化成退出码 0」。
224
+ export function runElevationWrapper(file, args = []) {
225
+ return runPowerShellCommand(buildElevateCommand(file, args));
226
+ }
227
+
228
+ // 写提权脚本文件。🔴 必须带 UTF-8 BOM:PowerShell 5.1 对「无 BOM 的 .ps1」按系统 ANSI
229
+ // 代码页(中文系统 = GBK)解析,脚本里的中文字面量(错误提示)会整段变乱码。
230
+ // 加 BOM 后 PS 才按 UTF-8 解析。注意这条路径与 buildElevateCommand 不同 ——
231
+ // 那条走 `-Command`,参数由 CreateProcessW 以 UTF-16 传递,本来就不受文件编码影响。
232
+ export function writePs1WithBom(file, content) {
233
+ fs.writeFileSync(file, "\ufeff" + content, "utf8");
234
+ }
235
+
236
+ // 写临时 ps1 -> 提权执行 -> 取回退出码 -> 清理 ps1(apply/restore/install 复用)。
237
+ // 已经提权时(计划任务 runlevel=highest)直接执行脚本,不走 Start-Process -Verb RunAs ——
238
+ // 后者会再拉一个独立控制台窗口,是「无窗口运行」的第二个破口。
239
+ export async function runElevated(content) {
240
+ const tmp = path.join(os.tmpdir(), `ghprobe-${Date.now()}.ps1`);
241
+ writePs1WithBom(tmp, content);
242
+ try {
243
+ if (isElevated()) { return await runPowerShellFile(tmp); }
244
+ return await runElevationWrapper("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp]);
245
+ } finally {
246
+ try { fs.unlinkSync(tmp); } catch { /* 清理失败不管 */ }
247
+ }
248
+ }
249
+
250
+ // 构造 hosts 写入脚本(由 runElevated 提权执行)。两个要点:
251
+ // ① 备份**每次刷新**(不是「只建一次」)—— 只建一次时,若 hosts 在安装后被第三方追加了
252
+ // 条目,那份陈旧备份里没有它们,卸载/回滚时会把它们一起抹掉。
253
+ // ② 读写统一用 [Text.Encoding]::Default(系统 ANSI 代码页),保证 hosts 里的非 ASCII
254
+ // 注释(不少工具会写中文标记)编码不变。
255
+ export function buildApplyScript(hostsPath, tmpPath) {
256
+ const bak = `${hostsPath}.ghprobe-bak`;
257
+ return `
258
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
259
+ $src = '${hostsPath}'
260
+ $tmp = '${tmpPath}'
261
+ $bak = '${bak}'
262
+ $marker = '${BEGIN}'
263
+ $enc = [Text.Encoding]::Default
264
+ try {
265
+ Copy-Item -Force $src $bak
266
+ Copy-Item -Force $tmp $src
267
+ $now = [IO.File]::ReadAllText($src, $enc)
268
+ if ($now -notmatch [regex]::Escape($marker)) {
269
+ Copy-Item -Force $bak $src
270
+ [Console]::Error.WriteLine('hosts 校验失败:未找到 ghprobe 标记,已回滚')
271
+ exit 1
272
+ }
273
+ ipconfig /flushdns | Out-Null
274
+ exit 0
275
+ } catch {
276
+ if (Test-Path $bak) { Copy-Item -Force $bak $src }
277
+ [Console]::Error.WriteLine('hosts 写入失败: ' + $_.Exception.Message)
278
+ exit 1
279
+ }
280
+ `;
281
+ }
282
+
283
+ // 构造 hosts 还原脚本(由 runElevated 提权执行):剥掉整个 ghprobe 块后写回。
284
+ // 刻意**不**用 *.ghprobe-bak 直接覆盖 —— 备份是「写入前」的快照,可能含旧块,也可能
285
+ // 早于第三方后来追加的条目。直接剥块才能保证「只移除 ghprobe 自己的行」。
286
+ // 🔴 必须用状态机跳过 begin..end 之间的**所有行**:早期写法是 `Where-Object { $_ -notmatch
287
+ // '^# === ghprobe (begin|end) ===' }`,它只删标记行、把块内 IP 条目全留下 ——
288
+ // 以前靠备份分支兜着没暴露,去掉备份分支后就会留下一堆孤儿条目。
289
+ export function buildRestoreScript(hostsPath) {
290
+ return `
291
+ try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
292
+ $src = '${hostsPath}'
293
+ $enc = [Text.Encoding]::Default
294
+ try {
295
+ $current = [IO.File]::ReadAllText($src, $enc)
296
+ $keep = @()
297
+ $skip = $false
298
+ foreach ($line in ($current -split '\\r?\\n')) {
299
+ if ($line -match '^# === ghprobe begin ===') { $skip = $true; continue }
300
+ if ($line -match '^# === ghprobe end ===') { $skip = $false; continue }
301
+ if (-not $skip) { $keep += $line }
302
+ }
303
+ $clean = ($keep -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine
304
+ [IO.File]::WriteAllText($src, $clean, $enc)
305
+ ipconfig /flushdns | Out-Null
306
+ exit 0
307
+ } catch {
308
+ [Console]::Error.WriteLine('hosts 还原失败: ' + $_.Exception.Message)
309
+ exit 1
310
+ }
311
+ `;
312
+ }
313
+
314
+ // 事务化提权写入:刷新备份 -> 覆盖 -> 校验含 BEGIN -> 失败回滚 -> 刷 DNS 缓存
315
+ export async function applyHosts(merged, hostsPath = WIN_HOSTS) {
316
+ const tmp = path.join(os.tmpdir(), `ghprobe-hosts-${Date.now()}.tmp`);
317
+ fs.writeFileSync(tmp, merged, "utf8");
318
+ try {
319
+ const r = await runElevated(buildApplyScript(hostsPath, tmp));
320
+ if (r.code !== 0) { throw new Error(`提权写入失败: ${r.err || r.out}`); }
321
+ return { ok: true, backup: `${hostsPath}.ghprobe-bak` };
322
+ } finally {
323
+ try { fs.unlinkSync(tmp); } catch { /* 清理失败不管 */ }
324
+ }
325
+ }
326
+
327
+ // 还原 hosts:剥掉 ghprobe 块后写回;最后刷 DNS 缓存
328
+ export async function restoreHosts(hostsPath = WIN_HOSTS) {
329
+ const r = await runElevated(buildRestoreScript(hostsPath));
330
+ if (r.code !== 0) { throw new Error(`还原 hosts 失败: ${r.err || r.out}`); }
331
+ return { ok: true };
332
+ }
package/src/net.js ADDED
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 本地网络可用性检查:本机断网时直接跳过本轮 —— 不探测、不更新池、不写 hosts。
4
+ // 为什么需要:断网时所有候选必然全灭,跑一轮纯属浪费,而且日志会被「stuck / 全灭」刷屏,
5
+ // 掩盖真正的故障;更糟的是会让「网络故障」和「GitHub 被墙」两种情况混在一起无法区分。
6
+ import os from "node:os";
7
+ import net from "node:net";
8
+
9
+ // 第一步(零成本):有没有「已启用且带非回环 IPv4」的网卡。
10
+ // 只能排除「网卡全禁用 / 只连了回环」这种最明显的情况,不能证明真能出去。
11
+ export function hasInterface() {
12
+ const ifs = os.networkInterfaces();
13
+ for (const list of Object.values(ifs)) {
14
+ for (const a of list || []) {
15
+ if (!a.internal && (a.family === "IPv4" || a.family === 4)) { return true; }
16
+ }
17
+ }
18
+ return false;
19
+ }
20
+
21
+ // 第二步:TCP 连一下基准目标。只判断「本机能不能出去」,与 GitHub 是否可达无关 ——
22
+ // 后者交给正常探测(换 IP 能救的属于 GitHub 侧,不在这里判)。
23
+ export function canReach(host, port, timeoutMs) {
24
+ return new Promise((resolve) => {
25
+ const sock = net.connect({ host, port });
26
+ let done = false;
27
+ const fin = (ok) => {
28
+ if (done) { return; }
29
+ done = true;
30
+ try { sock.destroy(); } catch { /* 忽略 */ }
31
+ resolve(ok);
32
+ };
33
+ sock.setTimeout(timeoutMs, () => fin(false));
34
+ sock.on("connect", () => fin(true));
35
+ sock.on("error", () => fin(false));
36
+ });
37
+ }
38
+
39
+ // 基准目标默认用阿里公共 DNS 的 DoH 端点(223.5.5.5:443):
40
+ // 国内任何正常网络都该能连上,且它不是 GitHub —— 不会被 SNI 封锁影响判断。
41
+ export async function hasLocalNetwork(cfg) {
42
+ const c = cfg.networkCheck || {};
43
+ if (c.enabled === false) { return true; }
44
+ if (!hasInterface()) { return false; }
45
+ const p = c.probe || {};
46
+ return canReach(p.host || "223.5.5.5", p.port ?? 443, p.timeoutMs ?? 2000);
47
+ }
package/src/pool.js ADDED
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段 → 展开成候选 IP → 缓存到用户目录。
4
+ // 为什么需要它:GitHub 的 Azure 段(20.x / 4.x)在变,内置静态池会过期
5
+ // (2026-10-11 实测:内置 24 个里有 4 个已不在官方 web 段)。
6
+ // 🔴 api.github.com/meta 未认证限 60 次/小时 —— 所以只能低频(默认每天 1 次),绝不能轮询。
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+ import https from "node:https";
10
+ import { userDataDir } from "./hosts.js";
11
+
12
+ export function poolCachePath() {
13
+ return path.join(userDataDir(), "pool.cache.json");
14
+ }
15
+
16
+ export function readPoolCache() {
17
+ try {
18
+ const o = JSON.parse(fs.readFileSync(poolCachePath(), "utf8"));
19
+ return o && typeof o === "object" ? o : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ function writePoolCache(obj) {
24
+ const p = poolCachePath();
25
+ try {
26
+ fs.mkdirSync(path.dirname(p), { recursive: true });
27
+ fs.writeFileSync(p, JSON.stringify(obj, null, 2), "utf8");
28
+ } catch { /* 写不了就算了,探测侧对缺缓存是宽容的 */ }
29
+ }
30
+
31
+ const toInt = (s) => s.split(".").reduce((a, o) => a * 256 + Number(o), 0) >>> 0;
32
+ const toIp = (v) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255].join(".");
33
+
34
+ // CIDR -> 候选 IP。
35
+ // 🔴 不能全展开:meta 的 web 段里有 /20、/22,一个 /20 就是 4096 个地址,全展开会让探测成本爆炸。
36
+ // 实际在用的是 /32 单播(GitHub 把它们逐个列出来就是给你白名单用的)与大段里的固定偏移
37
+ // (GitHub 习惯用段内 .3 / .4 做 web/api),所以大段只采样 perBigNet 个。
38
+ export function expandCidr(cidr, perBigNet = 4) {
39
+ const raw = String(cidr || "").trim();
40
+ if (!raw || raw.includes(":")) { return []; } // 跳过 IPv6(本项目只探 v4)
41
+ const slash = raw.indexOf("/");
42
+ const net = slash === -1 ? raw : raw.slice(0, slash);
43
+ const bits = slash === -1 ? 32 : Number(raw.slice(slash + 1));
44
+ if (!Number.isFinite(bits) || bits < 0 || bits > 32) { return []; }
45
+ const parts = net.split(".");
46
+ if (parts.length !== 4 || parts.some((x) => !/^\d{1,3}$/.test(x) || Number(x) > 255)) { return []; }
47
+ if (bits === 32) { return [net]; }
48
+ const base = toInt(net);
49
+ const size = 2 ** (32 - bits);
50
+ const out = [];
51
+ for (let i = 0; i < perBigNet; i++) {
52
+ const v = (base + 3 + i) >>> 0; // 从段内 .3 起
53
+ if (v >= base + size) { break; }
54
+ out.push(toIp(v));
55
+ }
56
+ return out;
57
+ }
58
+
59
+ // 用指定 IP + SNI 直连拉 meta。
60
+ // 🔴 必须用 IP 直连:api.github.com 的 DNS 在国内被污染(实测直连拿不到响应),
61
+ // 而这里恰恰是「还没有可用 IP 时」才需要它 —— 所以只能用配置里的种子池。
62
+ // onReq:把底层 request 暴露给调用方,便于「某个种子先成功」时掐掉其余在飞的请求。
63
+ function fetchMeta(ip, host, pathName, timeoutMs, onReq) {
64
+ return new Promise((resolve, reject) => {
65
+ const req = https.get({
66
+ host: ip,
67
+ port: 443,
68
+ servername: host,
69
+ path: pathName,
70
+ headers: { Host: host, "User-Agent": "ghprobe", Accept: "application/json" },
71
+ rejectUnauthorized: true,
72
+ timeout: timeoutMs,
73
+ }, (res) => {
74
+ if (res.statusCode !== 200) {
75
+ res.resume();
76
+ reject(new Error(`HTTP ${res.statusCode}`));
77
+ return;
78
+ }
79
+ let b = "";
80
+ res.setEncoding("utf8");
81
+ res.on("data", (d) => { b += d; });
82
+ res.on("end", () => { try { resolve(JSON.parse(b)); } catch (e) { reject(new Error("meta 不是合法 JSON")); } });
83
+ });
84
+ if (onReq) { onReq(req); }
85
+ req.on("timeout", () => req.destroy(new Error("timeout")));
86
+ req.on("error", reject);
87
+ });
88
+ }
89
+
90
+ // 并行试所有种子,**第一个成功的立刻返回**并掐掉其余在飞请求。
91
+ // 为什么不用 Promise.allSettled:它要等**最慢**那个(黑洞种子会一直挂到 timeoutMs),
92
+ // 实测总耗时 = 8s(正好一个 timeout),等于没并行。
93
+ // 为什么不用 Promise.any:它只给第一个成功的值,拿不到「是哪个 IP 拉到的」,日志里就没法记 via。
94
+ export function fetchFirstMeta(seeds, host, pathName, timeoutMs) {
95
+ return new Promise((resolve, reject) => {
96
+ if (!seeds.length) { reject(new Error("没有可用种子 IP")); return; }
97
+ const reqs = [];
98
+ const errs = [];
99
+ let left = seeds.length;
100
+ let done = false;
101
+ const finish = (fn, arg) => {
102
+ if (done) { return; }
103
+ done = true;
104
+ for (const r of reqs) { try { r.destroy(); } catch { /* 忽略 */ } }
105
+ fn(arg);
106
+ };
107
+ for (const ip of seeds) {
108
+ fetchMeta(ip, host, pathName, timeoutMs, (r) => reqs.push(r)).then(
109
+ (json) => finish(resolve, { json, via: ip }),
110
+ (e) => {
111
+ errs.push(`${ip}:${e?.message || e}`);
112
+ left -= 1;
113
+ if (left === 0) { finish(reject, new Error(`所有种子 IP 都拉不到 ${host}(${errs.slice(0, 3).join(", ")})`)); }
114
+ },
115
+ );
116
+ }
117
+ });
118
+ }
119
+
120
+ // 种子 IP:拉 meta 用的直连入口。优先用配置里的 seed 池,再退回 azure / fastly。
121
+ export function seedIps(cfg) {
122
+ const p = cfg.ipPools || {};
123
+ return [...new Set([...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
124
+ }
125
+
126
+ // 一轮更新:拉 meta -> 展开 -> 写缓存。失败也会写 lastAttempt(冷却),避免每分钟重试撞 60 次/小时限制。
127
+ export async function updatePool(cfg, log = () => {}) {
128
+ const u = cfg.poolUpdate || {};
129
+ const url = new URL(u.source || "https://api.github.com/meta");
130
+ const host = u.host || url.hostname;
131
+ const pathName = url.pathname || "/meta";
132
+ const timeoutMs = u.timeoutMs ?? 8000;
133
+ const attemptedAt = new Date().toISOString();
134
+ const prev = readPoolCache();
135
+ try {
136
+ const seeds = seedIps(cfg);
137
+ if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly 全为空)"); }
138
+ // 并行试所有种子,第一个成功的立刻返回(实测 8.2s -> 亚秒级;串行时是 2m13s)。
139
+ const { json, via } = await fetchFirstMeta(seeds, host, pathName, timeoutMs);
140
+
141
+ const sections = u.sections || ["web", "api"];
142
+ const maxIps = u.maxIps ?? 48;
143
+ const perBigNet = u.perBigNet ?? 4;
144
+ const cidrs = [];
145
+ for (const s of sections) { if (Array.isArray(json[s])) { cidrs.push(...json[s]); } }
146
+ // /32 优先:那些才是 GitHub 实际在用的入口;大段采样只是补充
147
+ const singles = cidrs.filter((c) => String(c).endsWith("/32")).flatMap((c) => expandCidr(c));
148
+ const bigs = cidrs.filter((c) => !String(c).endsWith("/32")).flatMap((c) => expandCidr(c, perBigNet));
149
+ const ips = [...new Set([...singles, ...bigs])].slice(0, maxIps);
150
+
151
+ const cache = { at: new Date().toISOString(), lastAttempt: attemptedAt, via, sections, count: ips.length, ips };
152
+ writePoolCache(cache);
153
+ log(`池已更新:${ips.length} 个 IP(via ${via},段 ${sections.join("+")})`);
154
+ return { ok: true, count: ips.length, via, path: poolCachePath() };
155
+ } catch (e) {
156
+ writePoolCache({ ...(prev || {}), lastAttempt: attemptedAt, lastError: String(e.message || e) });
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ // 是否该更新。🔴 **成功与失败用同一档冷却**(intervalHours,默认 24h):
162
+ // 拉 meta 走的是 api.github.com —— 如果它被 SNI 层封锁,换任何 IP 都救不回,
163
+ // 那种情况下反复重试毫无意义(还白撞 60 次/小时的 API 限制),所以失败后也等满一个周期再说。
164
+ // 手动更新不受此限制:`ghprobe update-pool` 永远立即执行。
165
+ export function poolNeedsUpdate(cfg) {
166
+ if (cfg.poolUpdate?.enabled === false) { return false; }
167
+ const intervalH = cfg.poolUpdate?.intervalHours ?? 24;
168
+ const c = readPoolCache();
169
+ const now = Date.now();
170
+ const fresh = (ts) => ts && now - new Date(ts).getTime() < intervalH * 3600_000;
171
+ if (fresh(c?.at)) { return false; } // 成功过,没到点
172
+ if (fresh(c?.lastAttempt)) { return false; } // 尝试过(成功或失败),没到点
173
+ return true;
174
+ }
package/src/probe.js ADDED
@@ -0,0 +1,76 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 一轮完整探测:DoH 候选 + 内置池 -> 三关并发探测 -> 排名 -> 兜底轮
4
+ import { resolveAll } from "./doh.js";
5
+ import { probeIp, pool } from "./prober.js";
6
+ import { rank, neededFallbacks } from "./ranker.js";
7
+
8
+ // 该域名该用哪些内置池。
9
+ // 默认(未配 domainPools 或配了不存在的池名)用全部池 —— 保持「与域名无关地全量进候选」
10
+ // 的老行为,由 TLS 证书终审 + 应用层终审判归属。
11
+ // 配了 domainPools 就只取指定的池:185.199.x.x(Fastly)永远不给 github.com 服务,
12
+ // 20.x 也不给 raw.githubusercontent.com 服务,互探纯属浪费——冷启动能省掉近一半候选。
13
+ export function poolsForDomain(cfg, domain) {
14
+ const pools = cfg.ipPools || {};
15
+ const names = cfg.domainPools?.[domain];
16
+ if (!Array.isArray(names) || names.length === 0) { return Object.values(pools).flat(); }
17
+ const picked = names.flatMap((n) => pools[n] || []);
18
+ return picked.length ? picked : Object.values(pools).flat();
19
+ }
20
+
21
+ // 解析一批域名的候选 IP。域名之间并行 —— DoH 是纯网络等待,串行会把最坏耗时
22
+ // 乘上域名数(6 个域名 × dohMs 5s = 30s),并行后压回一个 dohMs。
23
+ async function resolveCandidates(cfg, domains, say, { usePool = true, prefix = "" } = {}) {
24
+ return Promise.all(domains.map(async (domain) => {
25
+ say(prefix ? `${prefix}探测 ${domain}` : `解析 ${domain}`);
26
+ const { ips } = await resolveAll(cfg.doh, domain, cfg.timeouts.dohMs);
27
+ // 🔴 两个来源**各自限额后再合并**,绝不能对合并结果做总限额。
28
+ // 实测(2026-10):DoH 对 github.com 返回的 8 条里有一半是 Fastly 的 185.199.x.x
29
+ // ——那是 githubusercontent 的节点,根本不给 github.com 服务。按总限额截断时,
30
+ // 内置池会被这堆垃圾整个挤掉,于是出现「20.29.134.23 明明是活的,工具却报
31
+ // stuck=github.com」。分开限额后内置池一定拿得到名额。
32
+ const fromDoh = (ips || []).slice(0, cfg.probe.maxCandidates);
33
+ const fromPool = usePool ? poolsForDomain(cfg, domain).slice(0, cfg.probe.maxPoolIps ?? 12) : [];
34
+ const candidates = [...new Set([...fromDoh, ...fromPool])];
35
+ say(` 候选 ${candidates.length} 个: ${candidates.join(", ")}`);
36
+ return { domain, candidates };
37
+ }));
38
+ }
39
+
40
+ // 把所有 (域名, IP) 拍平成任务队列,交给一个**全局**并发池跑。
41
+ // 每个域名各自开一个池的话,实际并发 = 域名数 × concurrency(6×6=36 条 socket),
42
+ // 会把最坏耗时和瞬时连接数同时放大;全局单池才是真正的「同时最多 N 条」。
43
+ function jobsOf(cfg, resolved) {
44
+ const jobs = [];
45
+ for (const { domain, candidates } of resolved) {
46
+ for (const ip of candidates) { jobs.push(() => probeIp(domain, ip, cfg)); }
47
+ }
48
+ return jobs;
49
+ }
50
+
51
+ // onlyDomains:只探这些域名。部分更新(health 只发现某 1 个域名坏了)时用它,
52
+ // 避免「1 个域名失效却把 6 个域名、近 50 个候选全探一遍」——那是几十秒的纯浪费。
53
+ // 传 null / 空数组 = 全量。注意兜底域名不在 cfg.domains 里,若被点名必须单独直探。
54
+ export async function runProbe(cfg, log, onlyDomains = null) {
55
+ const say = log || (() => {});
56
+ const fbDomains = Object.values(cfg.fallbacks || {}).flat();
57
+ const want = onlyDomains && onlyDomains.length ? new Set(onlyDomains) : null;
58
+ const mainDomains = cfg.domains.filter((d) => !want || want.has(d));
59
+ const directFb = want ? [...want].filter((d) => fbDomains.includes(d) && !cfg.domains.includes(d)) : [];
60
+
61
+ const resolved = await resolveCandidates(cfg, mainDomains, say);
62
+ const all = await pool(jobsOf(cfg, resolved), cfg.probe.concurrency);
63
+ const ranked = rank(all, cfg);
64
+
65
+ // 兜底轮:主域名全灭且配了替身 -> 探测替身域名
66
+ const extra = new Map();
67
+ const alts = [...new Set([...neededFallbacks(ranked, cfg), ...directFb])];
68
+ if (alts.length) {
69
+ const altResolved = await resolveCandidates(cfg, alts, say, { usePool: false, prefix: "兜底" });
70
+ const rs = await pool(jobsOf(cfg, altResolved), cfg.probe.concurrency);
71
+ for (const [d, list] of rank(rs, cfg)) {
72
+ if (list.length) { extra.set(d, list); }
73
+ }
74
+ }
75
+ return { ranked, extra, raw: all, at: new Date().toISOString() };
76
+ }