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/CHANGELOG.md +164 -0
- package/LICENSE +674 -0
- package/README.en.md +582 -0
- package/README.md +575 -3
- package/bin/ghprobe.js +282 -0
- package/config.default.yaml +140 -0
- package/install.cmd +8 -0
- package/install.ps1 +119 -0
- package/package.json +51 -4
- package/src/config.js +64 -0
- package/src/doh.js +89 -0
- package/src/health.js +350 -0
- package/src/hosts.js +332 -0
- package/src/net.js +47 -0
- package/src/pool.js +174 -0
- package/src/probe.js +76 -0
- package/src/prober.js +212 -0
- package/src/ranker.js +30 -0
- package/src/server.js +56 -0
package/bin/ghprobe.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
3
|
+
// Copyright (C) 2026 zmw
|
|
4
|
+
// ghprobe CLI:probe / serve / hosts / install / uninstall
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import { loadConfig, ensureUserConfig, userConfigPath } from "../src/config.js";
|
|
11
|
+
import { updatePool, poolNeedsUpdate, poolCachePath } from "../src/pool.js";
|
|
12
|
+
import { hasLocalNetwork } from "../src/net.js";
|
|
13
|
+
import { probeNow, startServer } from "../src/server.js";
|
|
14
|
+
import { runHealth, blockFromProbeResult, shouldKeepOldBlock } from "../src/health.js";
|
|
15
|
+
import { mergeHosts, applyHosts, restoreHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock, runElevated, buildUninstallTaskScript, buildInstallScript, buildHiddenRunnerVbs, userDataDir } from "../src/hosts.js";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
|
|
18
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
19
|
+
|
|
20
|
+
// 健康检查日志写到用户数据目录,而不是包目录:npm 全局安装时包目录位于 node_modules 下,
|
|
21
|
+
// npm 升级/重装会整个换掉,日志放那儿会被清掉。
|
|
22
|
+
function healthLogPath() {
|
|
23
|
+
return path.join(userDataDir(), "ghprobe.log");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 无窗口运行包装器(VBScript)的落地路径。计划任务跑的是它,不是 node 本体。
|
|
27
|
+
function hiddenRunnerPath() {
|
|
28
|
+
return path.join(userDataDir(), "run-hidden.vbs");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 计划任务静默运行,stdout/stderr 都丢;每轮结果追加日志文件,跑挂了能查到。
|
|
32
|
+
// stuck 单独记:它表示「探测跑了、但这个域名本轮一个活 IP 都没找到,只能保留旧值」——
|
|
33
|
+
// 只看 action=none 会误以为工具没干活,其实可能是链路整体被黑洞了。
|
|
34
|
+
function writeHealthLog(action, reason, extra = {}) {
|
|
35
|
+
const entry = `${new Date().toISOString()} health action=${action} reason=${reason}`
|
|
36
|
+
+ `${extra.changed && extra.changed.length ? ` changed=${extra.changed.join(",")}` : ""}`
|
|
37
|
+
+ `${extra.stuck && extra.stuck.length ? ` stuck=${extra.stuck.join(",")}` : ""}\n`;
|
|
38
|
+
try {
|
|
39
|
+
const f = healthLogPath();
|
|
40
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
41
|
+
// 新文件写 UTF-8 BOM:否则 PowerShell 5.1 的 Get-Content 会按系统 ANSI 解码,
|
|
42
|
+
// 中文日志在用户查看时变乱码(文件本身没错,是读取端猜错编码)。
|
|
43
|
+
const prefix = fs.existsSync(f) ? "" : "\ufeff";
|
|
44
|
+
fs.appendFileSync(f, prefix + entry);
|
|
45
|
+
} catch { /* 日志写失败不影响主流程 */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 打印 hosts 块里的 IP 条目(install 完成后回显写入结果)
|
|
49
|
+
function printHostsEntries() {
|
|
50
|
+
try {
|
|
51
|
+
const m = readFileSync(WIN_HOSTS, "utf8").match(/# === ghprobe begin ===[\s\S]*?# === ghprobe end ===/);
|
|
52
|
+
if (!m) { return; }
|
|
53
|
+
for (const l of m[0].split(/\r?\n/).filter((x) => /^\d+\.\d+\.\d+\.\d+\s/.test(x))) { console.log(` ${l}`); }
|
|
54
|
+
} catch { /* 读不到就算了 */ }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// 严格仅 Windows:hosts 路径硬编码 + Start-Process 提权 + ipconfig,非 Windows 直接给清晰报错
|
|
58
|
+
if (process.platform !== "win32") {
|
|
59
|
+
console.error("ghprobe 当前仅支持 Windows:hosts 路径与提权机制依赖 Windows 系统");
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const program = new Command();
|
|
63
|
+
// 版本号从 package.json 读,不写死 —— 否则 npm version 之后 --version 会漂移
|
|
64
|
+
const PKG = JSON.parse(readFileSync(path.join(ROOT, "package.json"), "utf8"));
|
|
65
|
+
program.name("ghprobe").description("GitHub 直连优选:本地 DoH + TLS 证书终审探测 + hosts 生成(无代理)").version(PKG.version);
|
|
66
|
+
|
|
67
|
+
// 打印一轮结果的可读摘要
|
|
68
|
+
function printSummary(r) {
|
|
69
|
+
for (const [domain, rs] of r.ranked) {
|
|
70
|
+
if (!rs.length) { console.log(`[FAIL] ${domain} 主候选全灭(看兜底链)`); continue; }
|
|
71
|
+
const best = rs[0];
|
|
72
|
+
console.log(`[ OK ] ${domain} best=${best.ip} tcp=${best.tcpMs}ms tls=${best.tlsMs}ms ttfb=${best.ttfb}ms 备选 ${rs.length - 1}`);
|
|
73
|
+
}
|
|
74
|
+
for (const [domain, rs] of r.extra) {
|
|
75
|
+
if (rs.length) { console.log(`[FBKP] ${domain} -> 兜底命中 ${rs[0].ip} (${rs[0].score})`); }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
program.command("probe")
|
|
80
|
+
.description("跑一轮探测并输出结果(不写 hosts)")
|
|
81
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
82
|
+
.option("-j, --json", "输出 JSON")
|
|
83
|
+
.option("-a, --apply", "探测后写入系统 hosts(会弹 UAC)")
|
|
84
|
+
.action(async (opts) => {
|
|
85
|
+
const cfg = loadConfig(opts.config);
|
|
86
|
+
if (!(await hasLocalNetwork(cfg))) {
|
|
87
|
+
console.error("本地无网络连接,跳过探测(不写 hosts)");
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const r = await probeNow(cfg, (m) => { if (!opts.json) { console.error(m); } });
|
|
92
|
+
if (opts.json) {
|
|
93
|
+
const out = { at: r.at, domains: [...r.ranked].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => ({ ip: x.ip, tcpMs: x.tcpMs, tlsMs: x.tlsMs, ttfb: x.ttfb, score: x.score })) })), extra: [...r.extra].map(([d, rs]) => ({ domain: d, alive: rs.map((x) => x.ip) })) };
|
|
94
|
+
console.log(JSON.stringify(out, null, 2));
|
|
95
|
+
} else {
|
|
96
|
+
printSummary(r);
|
|
97
|
+
}
|
|
98
|
+
if (opts.apply) {
|
|
99
|
+
const lock = acquireHostsLock();
|
|
100
|
+
if (!lock) {
|
|
101
|
+
console.error("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
|
|
102
|
+
} else {
|
|
103
|
+
try {
|
|
104
|
+
const current = readFileSync(WIN_HOSTS, "utf8");
|
|
105
|
+
// 块格式与 health 保持一致(含 # absent 冷却),并走同一道全灭护栏
|
|
106
|
+
const block = blockFromProbeResult(cfg, r);
|
|
107
|
+
if (shouldKeepOldBlock(current, block)) {
|
|
108
|
+
console.error("本轮全部域名不可达(疑似网络故障),保留 hosts 中的旧条目不动");
|
|
109
|
+
} else {
|
|
110
|
+
const res = await applyHosts(mergeHosts(current, block));
|
|
111
|
+
console.log(`hosts 已写入,备份在 ${res.backup}`);
|
|
112
|
+
}
|
|
113
|
+
} finally { releaseHostsLock(lock); }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
program.command("serve")
|
|
119
|
+
.description("启动本地 HTTP API(默认 127.0.0.1:8930)")
|
|
120
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
121
|
+
.action(async (opts) => {
|
|
122
|
+
const cfg = loadConfig(opts.config);
|
|
123
|
+
await startServer(cfg, (m) => console.error(`[serve] ${m}`));
|
|
124
|
+
console.log(`ghprobe serve -> http://${cfg.serve.host}:${cfg.serve.port}`);
|
|
125
|
+
console.log("POST /api/probe 触发探测 | GET /api/status /api/hosts /api/config");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
program.command("hosts")
|
|
129
|
+
.description("生成 hosts 块文本(先探测一次)")
|
|
130
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
131
|
+
.action(async (opts) => {
|
|
132
|
+
const cfg = loadConfig(opts.config);
|
|
133
|
+
const r = await probeNow(cfg, (m) => console.error(m));
|
|
134
|
+
console.log(blockFromProbeResult(cfg, r));
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
program.command("health")
|
|
138
|
+
.description("健康检查:只测已写入 IP,异常才全量重排换 IP(加 --apply 才写 hosts)")
|
|
139
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
140
|
+
.option("-a, --apply", "检测到异常时写入系统 hosts(会弹 UAC)")
|
|
141
|
+
.action(async (opts) => {
|
|
142
|
+
let cfg = loadConfig(opts.config);
|
|
143
|
+
// 本地断网 -> 直接跳过:所有候选必然全灭,跑一轮只会刷屏并把真故障淹掉。
|
|
144
|
+
if (!(await hasLocalNetwork(cfg))) {
|
|
145
|
+
console.error("本地无网络连接,跳过本轮(不探测、不更新池、不写 hosts)");
|
|
146
|
+
writeHealthLog("skip", "本地无网络连接");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// 到点先更新候选池(默认每天 1 次;成功失败同一档冷却,不会反复重试)。
|
|
150
|
+
// 放最前:池子新了,后面的重排才有更宽的候选可用;更新成功后重新加载配置以吃进新池。
|
|
151
|
+
if (poolNeedsUpdate(cfg)) {
|
|
152
|
+
try {
|
|
153
|
+
await updatePool(cfg, (m) => console.error(m));
|
|
154
|
+
cfg = loadConfig(opts.config);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.error(`候选池更新失败(忽略,用现有池继续): ${e.message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const r = await runHealth(cfg, (m) => console.error(m), Boolean(opts.apply));
|
|
160
|
+
writeHealthLog(r.action, r.reason, r);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
program.command("install")
|
|
164
|
+
.description("一键开启:写一次 hosts + 注册计划任务(定时健康检查),全程只需一次 UAC")
|
|
165
|
+
.option("-c, --config <path>", "用户配置覆盖(间隔取 schedule.healthMin 分钟)")
|
|
166
|
+
.option("--skip-probe", "只注册计划任务,不立即探测写 hosts(等下一次定时触发)")
|
|
167
|
+
.action(async (opts) => {
|
|
168
|
+
// 生成用户可编辑的配置文件(已存在则不覆盖,保留用户改动)
|
|
169
|
+
const ucfg = ensureUserConfig();
|
|
170
|
+
const cfg = loadConfig(opts.config);
|
|
171
|
+
const minutes = cfg.schedule?.healthMin ?? 1;
|
|
172
|
+
console.log(`用户配置:${ucfg}`);
|
|
173
|
+
console.log("(可直接编辑:候选池 / 超时 / 调度间隔都在里面,优先级高于包内默认)");
|
|
174
|
+
// 断网时不探测(必然全灭),但**仍然注册计划任务** —— 有网后任务会自动跑起来
|
|
175
|
+
const online = await hasLocalNetwork(cfg);
|
|
176
|
+
const skipProbe = Boolean(opts.skipProbe) || !online;
|
|
177
|
+
if (!online) { console.log("本地无网络连接:本次跳过探测写 hosts(计划任务仍会注册,有网后自动生效)"); }
|
|
178
|
+
// 包内 bin 与当前 node 的绝对路径:npm 全局安装后二者都指向真实位置,
|
|
179
|
+
// 不依赖计划任务进程的 PATH(highest runlevel 下 PATH 未必含 %APPDATA%\npm)。
|
|
180
|
+
const bin = path.join(ROOT, "bin", "ghprobe.js");
|
|
181
|
+
const cfgArg = opts.config ? ` -c "${opts.config}"` : "";
|
|
182
|
+
|
|
183
|
+
// 无窗口运行:Task Scheduler 起 node.exe 必弹控制台窗口,而 node 没有隐藏窗口的开关。
|
|
184
|
+
// 写一个 VBS 包装器(wscript + WScript.Shell.Run(..., 0, True) = SW_HIDE),任务改成跑它。
|
|
185
|
+
// 写失败不阻断安装:退回直接跑 node,功能一样,只是会闪一下窗口。
|
|
186
|
+
let vbsPath = "";
|
|
187
|
+
try {
|
|
188
|
+
const v = hiddenRunnerPath();
|
|
189
|
+
fs.mkdirSync(path.dirname(v), { recursive: true });
|
|
190
|
+
fs.writeFileSync(v, buildHiddenRunnerVbs(process.execPath, bin, `health --apply${cfgArg}`), "ascii");
|
|
191
|
+
vbsPath = v;
|
|
192
|
+
} catch (e) {
|
|
193
|
+
console.error(`无窗口包装器写入失败(退回直接运行,可能会闪一下窗口): ${e.message}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 用 runHealth 的保守决策(只预览、不写)来算该写什么:
|
|
197
|
+
// 它会先测 hosts 里已写入的 IP,健康的保留,只有确认失效的才重排。
|
|
198
|
+
// 若直接拿全量探测结果覆盖,会把「本轮没抓到、但现有 IP 仍然健康」的域名误记成 absent。
|
|
199
|
+
let merged = null;
|
|
200
|
+
if (!opts.skipProbe) {
|
|
201
|
+
console.log("正在检查现有条目并探测(约 1 分钟)…");
|
|
202
|
+
const h = await runHealth(cfg, (m) => console.error(m), false);
|
|
203
|
+
if (h.block) { merged = mergeHosts(readFileSync(WIN_HOSTS, "utf8"), h.block); }
|
|
204
|
+
else { console.log(`hosts 无需改动:${h.reason}`); }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const tmp = path.join(os.tmpdir(), `ghprobe-install-${Date.now()}.tmp`);
|
|
208
|
+
fs.writeFileSync(tmp, merged || "", "utf8");
|
|
209
|
+
const writeHosts = merged != null;
|
|
210
|
+
try {
|
|
211
|
+
// 用 Register-ScheduledTask 而非 schtasks:/TR 的嵌套引号在 cmd 里必打架
|
|
212
|
+
// RunLevel Highest:任务以管理员权限静默运行,applyHosts 不再额外弹 UAC
|
|
213
|
+
// UserId 必须用 COMPUTERNAME\USERNAME(裸用户名非合法 UserId,会 E_INVALIDARG)
|
|
214
|
+
const res = await runElevated(buildInstallScript({
|
|
215
|
+
hostsPath: WIN_HOSTS, tmpPath: tmp, nodePath: process.execPath, binPath: bin, cfgArg, minutes, writeHosts, vbsPath,
|
|
216
|
+
}));
|
|
217
|
+
if (res.code !== 0) { console.error(`安装失败: ${res.err || res.out}`); process.exitCode = 1; return; }
|
|
218
|
+
} catch (e) {
|
|
219
|
+
console.error(`安装失败: ${e.message}`);
|
|
220
|
+
process.exitCode = 1;
|
|
221
|
+
return;
|
|
222
|
+
} finally {
|
|
223
|
+
try { fs.unlinkSync(tmp); } catch { /* 清理失败不管 */ }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
console.log(`计划任务 ghprobe 已注册:每 ${minutes} 分钟健康检查(health --apply,runlevel=highest,无窗口)`);
|
|
227
|
+
if (opts.skipProbe) { console.log("未写 hosts(--skip-probe);下一次定时到点会自动处理。"); return; }
|
|
228
|
+
if (merged == null) { console.log("hosts 未改动(现有条目仍然健康)。"); return; }
|
|
229
|
+
console.log("hosts 已写入 ✓");
|
|
230
|
+
printHostsEntries();
|
|
231
|
+
console.log("停止:ghprobe uninstall");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
program.command("uninstall")
|
|
235
|
+
.description("删除 ghprobe 计划任务并还原 hosts")
|
|
236
|
+
.action(async () => {
|
|
237
|
+
// 两步互相独立:任一步失败都不影响另一步,最后统一置 exitCode
|
|
238
|
+
try {
|
|
239
|
+
await restoreHosts();
|
|
240
|
+
console.log("hosts 已还原");
|
|
241
|
+
} catch (e) {
|
|
242
|
+
console.error(`还原 hosts 失败: ${e.message}`);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
}
|
|
245
|
+
// 删任务同样需要管理员权限:任务建在根目录且 RunLevel=Highest,非提权删会「拒绝访问」,
|
|
246
|
+
// 因此这里和 install 一样走 runElevated 自提权(已提权时会话内直接执行,不再弹 UAC)。
|
|
247
|
+
try {
|
|
248
|
+
const r = await runElevated(buildUninstallTaskScript());
|
|
249
|
+
if (r.code === 0) { console.log("计划任务 ghprobe 已删除(若本就不存在则跳过)"); }
|
|
250
|
+
else { console.error(`删除计划任务失败: ${r.err || r.out}`); process.exitCode = 1; }
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(`删除计划任务失败: ${e.message}`);
|
|
253
|
+
process.exitCode = 1;
|
|
254
|
+
}
|
|
255
|
+
// 包装器是 ghprobe 自己生成的临时产物,顺手清掉(删不掉也不影响,下次 install 会覆盖)
|
|
256
|
+
try { fs.unlinkSync(hiddenRunnerPath()); } catch { /* 不存在就算了 */ }
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
program.command("update-pool")
|
|
260
|
+
.description("从 GitHub 官方 api.github.com/meta 更新候选池缓存(每天一次即可,该 API 限 60 次/小时)")
|
|
261
|
+
.option("-c, --config <path>", "用户配置覆盖")
|
|
262
|
+
.action(async (opts) => {
|
|
263
|
+
const cfg = loadConfig(opts.config);
|
|
264
|
+
try {
|
|
265
|
+
const r = await updatePool(cfg, (m) => console.error(m));
|
|
266
|
+
console.log(`候选池已更新:${r.count} 个 IP`);
|
|
267
|
+
console.log(` 经 ${r.via} 拉取 -> ${r.path}`);
|
|
268
|
+
console.log("下次 probe / health 会自动使用 auto 池");
|
|
269
|
+
} catch (e) {
|
|
270
|
+
console.error(`更新失败: ${e.message}`);
|
|
271
|
+
process.exitCode = 1;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
program.command("config-path")
|
|
276
|
+
.description("打印用户配置文件与池缓存的路径(配置文件可直接编辑)")
|
|
277
|
+
.action(() => {
|
|
278
|
+
console.log(`配置:${userConfigPath()}`);
|
|
279
|
+
console.log(`池缓存:${poolCachePath()}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
program.parseAsync();
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# ghprobe 默认配置(用户可用 --config 覆盖,键名一致即可)
|
|
2
|
+
# 目标域名族:每域名独立探测、独立写 hosts
|
|
3
|
+
domains:
|
|
4
|
+
- github.com
|
|
5
|
+
- codeload.github.com
|
|
6
|
+
- objects.githubusercontent.com
|
|
7
|
+
- raw.githubusercontent.com
|
|
8
|
+
- gist.githubusercontent.com
|
|
9
|
+
- avatars.githubusercontent.com
|
|
10
|
+
|
|
11
|
+
# DoH 端点(全部 IP 直连,绕开解析 DoH 域名的死锁;默认只放国内实测可达端点)
|
|
12
|
+
# sni 填服务域名:TLS 握手按它验证书(RFC 6066 不允许拿 IP 当 SNI)
|
|
13
|
+
doh:
|
|
14
|
+
- { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
|
|
15
|
+
- { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
|
|
16
|
+
- { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
|
|
17
|
+
|
|
18
|
+
# 内置候选 IP 池:补足 DoH 拿不到的候选。
|
|
19
|
+
# 🔴 池子必须够宽,否则会出现「明明有活 IP,工具却报 stuck」——见下面 azure 段的说明。
|
|
20
|
+
ipPools:
|
|
21
|
+
# 种子池:**最稳定的入口,建议不要删**。属于 GitHub 自租段 140.82.112.0/20,多年未变。
|
|
22
|
+
# 两个用途:① github.com / codeload 的兜底候选;
|
|
23
|
+
# ② 拉 api.github.com/meta 更新 auto 池时的**直连入口**(该域名 DNS 在国内被污染,
|
|
24
|
+
# 只能靠这些 IP + SNI 直连;而且这一步恰恰发生在「还没有可用 IP」的时候)。
|
|
25
|
+
seed:
|
|
26
|
+
- 140.82.112.3
|
|
27
|
+
- 140.82.112.4
|
|
28
|
+
- 140.82.113.3
|
|
29
|
+
- 140.82.113.4
|
|
30
|
+
- 140.82.114.3
|
|
31
|
+
- 140.82.114.4
|
|
32
|
+
- 140.82.116.3
|
|
33
|
+
- 140.82.116.4
|
|
34
|
+
- 140.82.121.3
|
|
35
|
+
# Fastly 段:服务 githubusercontent 系(raw / objects / gist / avatars),多年不变
|
|
36
|
+
fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
|
|
37
|
+
# GitHub 自租段:服务 github.com / codeload.github.com 主站。
|
|
38
|
+
# 🔴 2026-10 实测:这些 IP **在分钟级抖动** —— 同一批 IP 几分钟前全灭、现在 9/20 可用,
|
|
39
|
+
# 且「活的是哪几个」每次都不一样。所以池子必须宽(靠 tcpMs 短超时兜住成本),
|
|
40
|
+
# 只留少量 IP 必然出现「明明有活 IP 却报 stuck」。
|
|
41
|
+
# ⚠️ 这一池**会过期**(2026-10-11 实测:24 个里有 4 个已不在官方 web 段)——
|
|
42
|
+
# 它是「离线兜底」,真正跟得上变化的是下面 poolUpdate 自动维护的 `auto` 池。
|
|
43
|
+
azure:
|
|
44
|
+
# Azure 段(亚太/欧洲)
|
|
45
|
+
- 20.200.245.247
|
|
46
|
+
- 20.201.28.151
|
|
47
|
+
- 20.207.73.82
|
|
48
|
+
- 20.217.135.5
|
|
49
|
+
- 20.233.83.145
|
|
50
|
+
- 20.248.137.48
|
|
51
|
+
- 20.26.156.215
|
|
52
|
+
- 20.29.134.23
|
|
53
|
+
- 20.87.245.0
|
|
54
|
+
- 20.205.243.166
|
|
55
|
+
- 20.205.243.167
|
|
56
|
+
- 20.205.243.168
|
|
57
|
+
- 4.237.22.34
|
|
58
|
+
- 20.27.177.113
|
|
59
|
+
- 4.208.26.197
|
|
60
|
+
# 140.82.112.0/20(美国,web/api 走 .3 / .4)
|
|
61
|
+
- 140.82.112.3
|
|
62
|
+
- 140.82.112.4
|
|
63
|
+
- 140.82.113.3
|
|
64
|
+
- 140.82.113.4
|
|
65
|
+
- 140.82.114.3
|
|
66
|
+
- 140.82.114.4
|
|
67
|
+
- 140.82.116.3
|
|
68
|
+
- 140.82.116.4
|
|
69
|
+
- 140.82.121.3
|
|
70
|
+
|
|
71
|
+
# 域名 -> 只探哪些池(不配 = 用全部池)。
|
|
72
|
+
# 两个池互不相干:185.199.x.x 永远不给 github.com 服务,20.x 也不给 raw.githubusercontent.com
|
|
73
|
+
# 服务。分开能省掉近一半候选(冷启动最明显)。池名写错时自动退回「用全部池」。
|
|
74
|
+
# auto = poolUpdate 自动从 api.github.com/meta 拉下来的最新网段(缓存不存在时该池为空,自动跳过)。
|
|
75
|
+
# 顺序有意义:靠前的池先占 maxPoolIps 名额。
|
|
76
|
+
domainPools:
|
|
77
|
+
github.com: [auto, seed, azure]
|
|
78
|
+
codeload.github.com: [auto, seed, azure]
|
|
79
|
+
objects.githubusercontent.com: [fastly]
|
|
80
|
+
raw.githubusercontent.com: [fastly]
|
|
81
|
+
gist.githubusercontent.com: [fastly]
|
|
82
|
+
avatars.githubusercontent.com: [fastly]
|
|
83
|
+
|
|
84
|
+
# 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段,缓存到用户目录,供 `auto` 池使用。
|
|
85
|
+
# 为什么需要:GitHub 的 Azure 段在变,内置 azure 池会过期(实测 24 个里 4 个已失效)。
|
|
86
|
+
# 🔴 api.github.com/meta 未认证限 **60 次/小时** —— 所以 intervalHours 只能大(默认每天 1 次),
|
|
87
|
+
# 绝不要调到 1 以下。
|
|
88
|
+
# ⚠️ 若 api.github.com 被 SNI 层封锁(换 IP 也救不回),**失败后同样等满一个周期再试**,
|
|
89
|
+
# 不会反复重试 —— 那种情况下继续重试没有意义,用现有池 + DoH 继续工作即可。
|
|
90
|
+
# 手动触发:`ghprobe update-pool`(不受周期限制);自动触发:每次 health(到点才真去拉)。
|
|
91
|
+
# 缓存文件:%LOCALAPPDATA%\ghprobe\pool.cache.json(自动生成,不用手改)
|
|
92
|
+
poolUpdate:
|
|
93
|
+
enabled: true
|
|
94
|
+
intervalHours: 24 # 更新间隔(小时);成功与失败同一档
|
|
95
|
+
source: "https://api.github.com/meta"
|
|
96
|
+
host: api.github.com # TLS SNI(实际连的是 ipPools.seed 里的 IP)
|
|
97
|
+
sections: [web, api] # 取 meta 里的哪些段
|
|
98
|
+
maxIps: 48 # 缓存里最多保留几个 IP
|
|
99
|
+
perBigNet: 4 # 大网段(/20、/22)每个采样几个 IP(不能全展开,/20 = 4096 个)
|
|
100
|
+
timeoutMs: 8000
|
|
101
|
+
|
|
102
|
+
# 本地网络检查:本机断网时直接跳过本轮(不探测、不更新池、不写 hosts)。
|
|
103
|
+
# 为什么需要:断网时所有候选必然全灭,跑一轮纯属浪费,日志还会被「stuck / 全灭」刷屏,
|
|
104
|
+
# 把「本机没网」和「GitHub 被墙」两种情况混在一起,反而看不出真故障。
|
|
105
|
+
# 两步:① 有没有「已启用且带非回环 IPv4」的网卡(零成本);② TCP 连一下基准目标。
|
|
106
|
+
# 基准目标必须是**国内任何正常网络都该能连上**的(默认阿里公共 DNS 的 DoH 端点),
|
|
107
|
+
# 它只用来判断「本机能不能出去」,与 GitHub 是否可达无关。
|
|
108
|
+
networkCheck:
|
|
109
|
+
enabled: true
|
|
110
|
+
probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
|
|
111
|
+
|
|
112
|
+
# 被主机名黑名单掐掉的域名走兜底链:主候选全灭时改探测这些替身域名并写入
|
|
113
|
+
fallbacks:
|
|
114
|
+
raw.githubusercontent.com: [cdn.jsdelivr.net]
|
|
115
|
+
|
|
116
|
+
# 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
|
|
117
|
+
# 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
|
|
118
|
+
timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
|
|
119
|
+
probe:
|
|
120
|
+
maxCandidates: 8 # DoH 每域名最多取几个候选
|
|
121
|
+
maxPoolIps: 32 # 内置池每域名最多补几个。**与 DoH 各自限额,不共享总上限**(见 probe.js)
|
|
122
|
+
maxPerDomain: 4 # 每域名最多保留几个可用 IP(前 maxEntriesPerDomain 个会写进 hosts)
|
|
123
|
+
concurrency: 10 # 全局同时握手的 socket 上限(跨域名共享)
|
|
124
|
+
# hosts 写入:每域名写几条候选。
|
|
125
|
+
# 🔴 2026-10-11 实测推翻旧假设「Windows 同一域名只取第一条」—— hosts 里同名多条会被
|
|
126
|
+
# 解析器**全部返回**(保持文件顺序),且 curl / 浏览器会**逐个尝试**:黑洞 IP 排在前
|
|
127
|
+
# 也能 fallback 到后面的活 IP(实测 http=200 / 0.22s)。所以多写 1 条备选 = 抖动时
|
|
128
|
+
# 客户端自动兜底,把「断最长 1 分钟」变成「多等一个连接超时」。
|
|
129
|
+
# 不要贪多:每多一个死 IP 就多叠一个连接超时;2 条是收益/代价的平衡点。1 = 退回旧行为。
|
|
130
|
+
hosts:
|
|
131
|
+
maxEntriesPerDomain: 2
|
|
132
|
+
serve: { host: 127.0.0.1, port: 8930 }
|
|
133
|
+
# 调度:固定频率健康检查,不做指数退避(哨兵一轮 <2s,退避省不下开销反而拉长最坏发现延迟)
|
|
134
|
+
schedule:
|
|
135
|
+
# 哨兵间隔(分钟):只测已写入 IP(3 条并发,健康时 ~1s),异常才触发全量重排。
|
|
136
|
+
# 🔴 取 1 是刻意的:这是「IP 死了到自动换掉」的**最坏等待**。5 分钟时代码本身只要 20s 就能修好,
|
|
137
|
+
# 但用户平均要多等 2.5 分钟 —— 卡在调度间隔上而不是卡在探测上。1 分钟是 Task Scheduler
|
|
138
|
+
# 重复间隔的下限(New-ScheduledTaskTrigger 最小 1 分钟),已经到底了。
|
|
139
|
+
healthMin: 1
|
|
140
|
+
absentCooldownHours: 1 # 缺失域名冷却(小时):SNI 层封锁的域名全量也救不回,冷却内不重试
|
package/install.cmd
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
rem ghprobe one-click deploy wrapper (ASCII only: cmd.exe reads .cmd as OEM codepage)
|
|
3
|
+
chcp 65001 >nul
|
|
4
|
+
setlocal
|
|
5
|
+
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install.ps1" %*
|
|
6
|
+
echo.
|
|
7
|
+
echo ---- Done. Press any key to close. ----
|
|
8
|
+
pause >nul
|
package/install.ps1
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
#Requires -Version 5.1
|
|
2
|
+
<#
|
|
3
|
+
ghprobe 一键部署(Windows / PowerShell 5.1+)
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
.\install.ps1 部署:注册计划任务 + 立即写入一次 hosts
|
|
7
|
+
.\install.ps1 -SkipProbe 只注册计划任务,等首轮健康检查自己写 hosts
|
|
8
|
+
.\install.ps1 -Uninstall 卸载:删除计划任务 + 还原 hosts
|
|
9
|
+
|
|
10
|
+
也可以直接双击 install.cmd(等价)。
|
|
11
|
+
全程只弹一次 UAC:脚本先自提权,之后的注册/写 hosts 都在管理员会话内静默完成。
|
|
12
|
+
#>
|
|
13
|
+
[CmdletBinding()]
|
|
14
|
+
param(
|
|
15
|
+
[switch]$Uninstall,
|
|
16
|
+
[switch]$SkipProbe,
|
|
17
|
+
[string]$LogFile,
|
|
18
|
+
[switch]$Inner
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
$ErrorActionPreference = 'Stop'
|
|
22
|
+
$Root = $PSScriptRoot
|
|
23
|
+
if ([string]::IsNullOrWhiteSpace($LogFile)) { $LogFile = Join-Path $env:TEMP 'ghprobe-deploy.log' }
|
|
24
|
+
|
|
25
|
+
function Say([string]$m) {
|
|
26
|
+
$line = "[ghprobe] $m"
|
|
27
|
+
Write-Host $line
|
|
28
|
+
try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# ── 1. 自提权(注册计划任务需要管理员)────────────────────────────────
|
|
32
|
+
# 只弹一次 UAC:子进程在管理员会话里跑完,父进程回显子进程写的日志。
|
|
33
|
+
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
|
34
|
+
if ((-not $isAdmin) -and (-not $Inner)) {
|
|
35
|
+
Say "需要管理员权限,正在申请提权(只会弹一次 UAC)…"
|
|
36
|
+
Remove-Item -LiteralPath $LogFile -ErrorAction SilentlyContinue
|
|
37
|
+
$psArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$PSCommandPath`"", '-Inner', '-LogFile', "`"$LogFile`"")
|
|
38
|
+
if ($Uninstall) { $psArgs += '-Uninstall' }
|
|
39
|
+
if ($SkipProbe) { $psArgs += '-SkipProbe' }
|
|
40
|
+
$proc = Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -PassThru -ArgumentList $psArgs
|
|
41
|
+
# 注意:Start-Process 被取消时 $proc 为 $null。若直接 exit $proc.ExitCode,
|
|
42
|
+
# PowerShell 会退化成 exit $null = 0,把"用户拒绝提权"伪装成成功。
|
|
43
|
+
if ($null -eq $proc) { Write-Host "[ghprobe] 提权被取消,未做任何改动。"; exit 1 }
|
|
44
|
+
if (Test-Path -LiteralPath $LogFile) { Get-Content -LiteralPath $LogFile | ForEach-Object { Write-Host $_ } }
|
|
45
|
+
exit $proc.ExitCode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
Set-Location -LiteralPath $Root
|
|
49
|
+
Say "工作目录:$Root"
|
|
50
|
+
|
|
51
|
+
# ── 2. 环境检查:Node >= 18 ─────────────────────────────────────────
|
|
52
|
+
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
|
|
53
|
+
Say "错误:未找到 Node.js(需要 >= 18)。请先安装:https://nodejs.org/"
|
|
54
|
+
exit 1
|
|
55
|
+
}
|
|
56
|
+
# 用 Out-String 兜底:node 异常时输出可能为 $null,直接 .Trim() 会抛「不能对 Null 值表达式调用方法」
|
|
57
|
+
$nodeVer = (& node -p "process.versions.node" 2>$null | Out-String).Trim()
|
|
58
|
+
if ([string]::IsNullOrWhiteSpace($nodeVer)) { Say "错误:无法执行 node -p,Node 安装可能损坏"; exit 1 }
|
|
59
|
+
$okVer = $false
|
|
60
|
+
try { $okVer = ([version]$nodeVer -ge [version]'18.0.0') } catch { Say "错误:无法识别 Node 版本:$nodeVer"; exit 1 }
|
|
61
|
+
if (-not $okVer) { Say "错误:Node 版本 $nodeVer 过低(需要 >= 18)"; exit 1 }
|
|
62
|
+
Say "Node $nodeVer OK"
|
|
63
|
+
|
|
64
|
+
# ── 3. 依赖 ─────────────────────────────────────────────────────────
|
|
65
|
+
if (-not (Test-Path -LiteralPath (Join-Path $Root 'node_modules'))) {
|
|
66
|
+
Say "安装依赖(npm install --omit=dev)…"
|
|
67
|
+
& npm install --omit=dev --no-audit --no-fund
|
|
68
|
+
if ($LASTEXITCODE -ne 0) { Say "错误:npm install 失败"; exit 1 }
|
|
69
|
+
} else {
|
|
70
|
+
Say "依赖已就绪"
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
$Cli = Join-Path $Root 'bin\ghprobe.js'
|
|
74
|
+
$Hosts = 'C:\Windows\System32\drivers\etc\hosts'
|
|
75
|
+
|
|
76
|
+
# ── 4a. 卸载分支 ────────────────────────────────────────────────────
|
|
77
|
+
if ($Uninstall) {
|
|
78
|
+
Say "删除计划任务并还原 hosts…"
|
|
79
|
+
& node $Cli uninstall
|
|
80
|
+
if ($LASTEXITCODE -ne 0) { Say "错误:卸载失败"; exit 1 }
|
|
81
|
+
if (Get-ScheduledTask -TaskName 'ghprobe' -ErrorAction SilentlyContinue) {
|
|
82
|
+
Say "自检失败:计划任务 ghprobe 仍然存在"; exit 1
|
|
83
|
+
}
|
|
84
|
+
Say "完成:计划任务已删除,hosts 已还原。"
|
|
85
|
+
exit 0
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# ── 4b. 写 hosts + 注册计划任务(当前已是管理员,内部不会再弹 UAC)────
|
|
89
|
+
# `ghprobe install` 先探测、再在一次提权里同时完成「写 hosts」与「注册任务」
|
|
90
|
+
if ($SkipProbe) {
|
|
91
|
+
Say "注册计划任务(每 5 分钟健康检查,异常才全量换 IP)…"
|
|
92
|
+
& node $Cli install --skip-probe
|
|
93
|
+
} else {
|
|
94
|
+
Say "探测并写入 hosts,同时注册计划任务(约 1 分钟)…"
|
|
95
|
+
& node $Cli install
|
|
96
|
+
}
|
|
97
|
+
if ($LASTEXITCODE -ne 0) { Say "错误:安装失败"; exit 1 }
|
|
98
|
+
|
|
99
|
+
# ── 6. 自检:不信任命令行自报的成功 ──────────────────────────────────
|
|
100
|
+
$task = Get-ScheduledTask -TaskName 'ghprobe' -ErrorAction SilentlyContinue
|
|
101
|
+
if (-not $task) { Say "自检失败:未找到计划任务 ghprobe"; exit 1 }
|
|
102
|
+
Say ("自检:计划任务存在(State={0},重复间隔={1},RunLevel={2})" -f $task.State, $task.Triggers[0].Repetition.Interval, $task.Principal.RunLevel)
|
|
103
|
+
|
|
104
|
+
$inBlock = $false
|
|
105
|
+
$block = @()
|
|
106
|
+
foreach ($line in (Get-Content -LiteralPath $Hosts)) {
|
|
107
|
+
if ($line -match '^# === ghprobe begin ===') { $inBlock = $true; continue }
|
|
108
|
+
if ($line -match '^# === ghprobe end ===') { $inBlock = $false; continue }
|
|
109
|
+
if ($inBlock) { $block += $line.Trim() }
|
|
110
|
+
}
|
|
111
|
+
if ($block.Count -gt 0) {
|
|
112
|
+
Say "自检:hosts 中的 ghprobe 块:"
|
|
113
|
+
$block | ForEach-Object { Say " $_" }
|
|
114
|
+
} else {
|
|
115
|
+
Say "自检:hosts 暂无 ghprobe 块(计划任务首轮会自动写入)"
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
Say "全部完成。此后无需敲命令:每 5 分钟自动健康检查,IP 陈旧时自动重排。"
|
|
119
|
+
Say "卸载:.\install.ps1 -Uninstall"
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ghprobe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "GitHub direct-connect optimizer: local DoH resolution + TLS-verified probing + hosts generation (no proxy)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"github",
|
|
7
|
+
"hosts",
|
|
8
|
+
"hosts-file",
|
|
9
|
+
"dns",
|
|
10
|
+
"dns-over-https",
|
|
11
|
+
"doh",
|
|
12
|
+
"sni",
|
|
13
|
+
"acceleration",
|
|
14
|
+
"network",
|
|
15
|
+
"windows",
|
|
16
|
+
"cli"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/zmw-zmw/ghprobe#readme",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/zmw-zmw/ghprobe.git"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/zmw-zmw/ghprobe/issues"
|
|
25
|
+
},
|
|
26
|
+
"author": "zmw",
|
|
27
|
+
"type": "module",
|
|
28
|
+
"bin": {
|
|
29
|
+
"ghprobe": "bin/ghprobe.js"
|
|
30
|
+
},
|
|
31
|
+
"main": "bin/ghprobe.js",
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
},
|
|
35
|
+
"license": "GPL-3.0-only",
|
|
36
|
+
"files": [
|
|
37
|
+
"bin/",
|
|
38
|
+
"src/",
|
|
39
|
+
"config.default.yaml",
|
|
40
|
+
"install.ps1",
|
|
41
|
+
"install.cmd",
|
|
42
|
+
"CHANGELOG.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "node --test",
|
|
46
|
+
"serve": "node bin/ghprobe.js serve",
|
|
47
|
+
"probe": "node bin/ghprobe.js probe"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"commander": "^12.1.0",
|
|
51
|
+
"js-yaml": "^4.1.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// 配置加载:包内默认 YAML + 用户覆盖(对象递归合并:顶层用户键覆盖默认键,对象属性合并而非整体替换)
|
|
4
|
+
// 用户配置放在用户数据目录(%LOCALAPPDATA%\ghprobe\config.yaml),install 时自动生成一份,
|
|
5
|
+
// 用户可自由编辑 —— 放在包目录会被 npm 升级/重装整个换掉。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import yaml from "js-yaml";
|
|
10
|
+
import { userDataDir } from "./hosts.js";
|
|
11
|
+
import { readPoolCache } from "./pool.js";
|
|
12
|
+
|
|
13
|
+
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
14
|
+
|
|
15
|
+
// 用户可编辑的配置文件路径(install 时生成;不存在也不报错)
|
|
16
|
+
export function userConfigPath() {
|
|
17
|
+
return path.join(userDataDir(), "config.yaml");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isPlainObject(v) {
|
|
21
|
+
return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !(v instanceof RegExp);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mergeDefaults(def, user) {
|
|
25
|
+
if (!isPlainObject(def) || !isPlainObject(user)) { return user === undefined ? def : user; }
|
|
26
|
+
const out = { ...def };
|
|
27
|
+
for (const key of Object.keys(user)) {
|
|
28
|
+
out[key] = isPlainObject(def[key]) && isPlainObject(user[key]) ? mergeDefaults(def[key], user[key]) : user[key];
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 把「自动更新的池缓存」并进 ipPools.auto(池名固定为 auto,供 domainPools 引用)。
|
|
34
|
+
// 缓存不存在时什么都不做 —— 探测侧对缺失的池名是宽容的(会退回「用全部池」)。
|
|
35
|
+
function withPoolCache(cfg) {
|
|
36
|
+
const cache = readPoolCache();
|
|
37
|
+
if (cache && Array.isArray(cache.ips) && cache.ips.length) {
|
|
38
|
+
cfg.ipPools = { ...(cfg.ipPools || {}), auto: cache.ips };
|
|
39
|
+
}
|
|
40
|
+
return cfg;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 优先级:--config 显式指定 > 用户目录 config.yaml > 包内 config.default.yaml
|
|
44
|
+
export function loadConfig(userPath) {
|
|
45
|
+
const def = yaml.load(fs.readFileSync(path.join(ROOT, "config.default.yaml"), "utf8"));
|
|
46
|
+
let user = null;
|
|
47
|
+
for (const p of [userPath, userConfigPath()]) {
|
|
48
|
+
if (!p) { continue; }
|
|
49
|
+
try { user = yaml.load(fs.readFileSync(p, "utf8")); break; } catch { /* 不存在 / 读不了 -> 下一个 */ }
|
|
50
|
+
}
|
|
51
|
+
return withPoolCache(user ? mergeDefaults(def, user) : def);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 首次 install 时把包内默认配置复制成用户配置(已存在则不覆盖,保留用户改动)
|
|
55
|
+
export function ensureUserConfig() {
|
|
56
|
+
const p = userConfigPath();
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(p)) {
|
|
59
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
60
|
+
fs.copyFileSync(path.join(ROOT, "config.default.yaml"), p);
|
|
61
|
+
}
|
|
62
|
+
} catch { /* 写不了不阻塞安装 */ }
|
|
63
|
+
return p;
|
|
64
|
+
}
|