ghprobe 0.1.2 → 0.1.5

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/bin/ghprobe.js CHANGED
@@ -11,9 +11,13 @@ import { loadConfig, ensureUserConfig, userConfigPath, userConfigSnapshotPath, c
11
11
  import { updatePool, poolNeedsUpdate, poolCachePath } from "../src/pool.js";
12
12
  import { hasLocalNetwork } from "../src/net.js";
13
13
  import { probeNow, startServer } from "../src/server.js";
14
- import { runHealth, blockFromProbeResult, shouldKeepOldBlock } from "../src/health.js";
14
+ import { runHealth, blockFromProbeResult, shouldKeepOldBlock, parseBlock } from "../src/health.js";
15
+ import { runDoctor, formatReport, queryTask } from "../src/doctor.js";
16
+ import { runStatus } from "../src/status.js";
17
+ import { runDaemon, readHeartbeat, heartbeatFresh, daemonRunning } from "../src/daemon.js";
15
18
  import { mergeHosts, applyHosts, restoreHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock, runElevated, buildUninstallTaskScript, buildInstallScript, buildHiddenRunnerVbs, userDataDir } from "../src/hosts.js";
16
19
  import { readFileSync } from "node:fs";
20
+ import { spawn } from "node:child_process";
17
21
 
18
22
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
19
23
 
@@ -35,16 +39,31 @@ function writeHealthLog(action, reason, extra = {}) {
35
39
  const entry = `${new Date().toISOString()} health action=${action} reason=${reason}`
36
40
  + `${extra.changed && extra.changed.length ? ` changed=${extra.changed.join(",")}` : ""}`
37
41
  + `${extra.stuck && extra.stuck.length ? ` stuck=${extra.stuck.join(",")}` : ""}\n`;
42
+ appendRawLog(entry);
43
+ }
44
+
45
+ // 通用日志追加(daemon / 监督者用普通行,health 用上面的带格式行)
46
+ function appendRawLog(line) {
38
47
  try {
39
48
  const f = healthLogPath();
40
49
  fs.mkdirSync(path.dirname(f), { recursive: true });
41
- // 新文件写 UTF-8 BOM:否则 PowerShell 5.1 的 Get-Content 会按系统 ANSI 解码,
42
- // 中文日志在用户查看时变乱码(文件本身没错,是读取端猜错编码)。
43
50
  const prefix = fs.existsSync(f) ? "" : "\ufeff";
44
- fs.appendFileSync(f, prefix + entry);
51
+ fs.appendFileSync(f, prefix + line);
45
52
  } catch { /* 日志写失败不影响主流程 */ }
46
53
  }
47
54
 
55
+ // 监督者:daemon 心跳陈旧时拉起新实例(分离进程,继承当前权限,随调用方退出继续存活)。
56
+ // 成功与否都不阻断本轮 —— 本轮 health 照常执行,作为 daemon 就位前的兜底。
57
+ function spawnDaemonDetached() {
58
+ try {
59
+ const child = spawn(process.execPath, [path.join(ROOT, "bin", "ghprobe.js"), "daemon"], {
60
+ detached: true, stdio: "ignore", windowsHide: true,
61
+ });
62
+ child.unref();
63
+ return true;
64
+ } catch { return false; }
65
+ }
66
+
48
67
  // 打印 hosts 块里的 IP 条目(install 完成后回显写入结果)
49
68
  function printHostsEntries() {
50
69
  try {
@@ -76,6 +95,35 @@ function printSummary(r) {
76
95
  }
77
96
  }
78
97
 
98
+ program.command("doctor")
99
+ .description("一键自检:网络 / DoH / 探测 / hosts / 计划任务,人话结论 + 下一步建议")
100
+ .option("-c, --config <path>", "用户配置覆盖")
101
+ .action(async (opts) => {
102
+ const cfg = loadConfig(opts.config);
103
+ console.error("ghprobe 自检中(跑一轮完整探测,约 1 分钟)…");
104
+ const r = await runDoctor(cfg, (m) => console.error(m));
105
+ console.log(formatReport(r.checks));
106
+ if (!r.ok) { process.exitCode = 1; }
107
+ });
108
+
109
+ program.command("daemon")
110
+ .description("常驻自愈循环(由监督者自动拉起,一般不需要手动跑):15~45s 自适应健康检查")
111
+ .option("-c, --config <path>", "用户配置覆盖")
112
+ .action(async (opts) => {
113
+ const cfg = loadConfig(opts.config);
114
+ await runDaemon(cfg, (m) => appendRawLog(`${new Date().toISOString()} ${m}\n`));
115
+ // 正常不返回;返回即让位/已有实例,静默退出
116
+ });
117
+
118
+ program.command("status")
119
+ .description("秒级速查:hosts 现状 + 自愈日志尾部 + 计划任务(不打任何网络请求)")
120
+ .option("-c, --config <path>", "用户配置覆盖")
121
+ .action(async (opts) => {
122
+ const cfg = loadConfig(opts.config);
123
+ const r = await runStatus(cfg, { taskQuery: () => queryTask() });
124
+ console.log(r.lines.join("\n"));
125
+ });
126
+
79
127
  program.command("probe")
80
128
  .description("跑一轮探测并输出结果(不写 hosts)")
81
129
  .option("-c, --config <path>", "用户配置覆盖")
@@ -138,8 +186,22 @@ program.command("health")
138
186
  .description("健康检查:只测已写入 IP,异常才全量重排换 IP(加 --apply 才写 hosts)")
139
187
  .option("-c, --config <path>", "用户配置覆盖")
140
188
  .option("-a, --apply", "检测到异常时写入系统 hosts(会弹 UAC)")
189
+ .option("-f, --force", "跳过 daemon 监督逻辑,本轮亲自跑(诊断用)")
141
190
  .action(async (opts) => {
142
191
  let cfg = loadConfig(opts.config);
192
+ // 0.5.0 监督者逻辑:daemon 心跳新鲜 -> 秒退(它在干活,重复探测纯属浪费);
193
+ // 心跳陈旧/没有 -> 拉起新 daemon(分离进程),本轮仍照常执行,作为 daemon 就位前的兜底。
194
+ // daemon 崩溃 -> 下一分钟这里自动复活它。手动诊断加 --force 绕过。
195
+ if (!opts.force) {
196
+ const hb = readHeartbeat();
197
+ if (daemonRunning(hb)) {
198
+ writeHealthLog("skip", "daemon 心跳新鲜,本轮交给 daemon");
199
+ return;
200
+ }
201
+ if (spawnDaemonDetached()) {
202
+ appendRawLog(`${new Date().toISOString()} supervisor action=spawn-daemon reason=${hb ? "心跳陈旧" : "无心跳"}\n`);
203
+ }
204
+ }
143
205
  // 本地断网 -> 直接跳过:所有候选必然全灭,跑一轮只会刷屏并把真故障淹掉。
144
206
  if (!(await hasLocalNetwork(cfg))) {
145
207
  console.error("本地无网络连接,跳过本轮(不探测、不更新池、不写 hosts)");
@@ -148,9 +210,13 @@ program.command("health")
148
210
  }
149
211
  // 到点先更新候选池(默认每天 1 次;成功失败同一档冷却,不会反复重试)。
150
212
  // 放最前:池子新了,后面的重排才有更宽的候选可用;更新成功后重新加载配置以吃进新池。
213
+ // 0.4.0 自救:把 hosts 里**当前已写入的条目**一并当种子 —— 它们刚通过三关终审,
214
+ // 是「当下确实能直连 GitHub」的 IP;seed 池被 SNI 整段封锁时这批 IP 往往是
215
+ // 唯一还拉得到 meta 的入口,池子不至于永久冻结。
151
216
  if (poolNeedsUpdate(cfg)) {
152
217
  try {
153
- await updatePool(cfg, (m) => console.error(m));
218
+ const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
219
+ await updatePool(cfg, (m) => console.error(m), [...cur.entries.values()]);
154
220
  cfg = loadConfig(opts.config);
155
221
  } catch (e) {
156
222
  console.error(`候选池更新失败(忽略,用现有池继续): ${e.message}`);
@@ -262,7 +328,9 @@ program.command("update-pool")
262
328
  .action(async (opts) => {
263
329
  const cfg = loadConfig(opts.config);
264
330
  try {
265
- const r = await updatePool(cfg, (m) => console.error(m));
331
+ // 手动更新同样带上健康 hosts 条目当备用种子(见 health 命令里的说明)
332
+ const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
333
+ const r = await updatePool(cfg, (m) => console.error(m), [...cur.entries.values()]);
266
334
  console.log(`候选池已更新:${r.count} 个 IP`);
267
335
  console.log(` 经 ${r.via} 拉取 -> ${r.path}`);
268
336
  console.log("下次 probe / health 会自动使用 auto 池");
@@ -1,166 +1,215 @@
1
- # ghprobe 默认配置(用户可用 --config 覆盖,键名一致即可)
2
- #
3
- # ⚠️ 关于升级:`ghprobe install` 会把本文件**整份**复制成用户配置
4
- # (%LOCALAPPDATA%\ghprobe\config.yaml),并同时存一份**快照**
5
- # (config.default.snapshot.yaml,用来判断你到底改过哪些键)。
6
- # 加载时做**三方合并**:
7
- # · 你**没改过**的键(值仍等于快照) -> 跟随本文件的最新值 ← 默认值的改进自动到达
8
- # · 你**改过**的键 -> 保留你的值
9
- # · 你自己加的键 / 本文件新增的键 -> 都保留
10
- # 所以升级后不需要手动同步配置。想看有哪些差异:`ghprobe config-diff`。
11
- # (本文件里的注释只是说明,不参与合并 —— 合并看的是键值。)
12
- # 目标域名族:每域名独立探测、独立写 hosts
13
- domains:
14
- - github.com
15
- - codeload.github.com
16
- - objects.githubusercontent.com
17
- - raw.githubusercontent.com
18
- - gist.githubusercontent.com
19
- - avatars.githubusercontent.com
20
-
21
- # DoH 端点(全部 IP 直连,绕开解析 DoH 域名的死锁;默认只放国内实测可达端点)
22
- # sni 填服务域名:TLS 握手按它验证书(RFC 6066 不允许拿 IP 当 SNI)
23
- doh:
24
- - { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
25
- - { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
26
- - { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
27
-
28
- # 内置候选 IP 池:补足 DoH 拿不到的候选。
29
- # 🔴 池子必须够宽,否则会出现「明明有活 IP,工具却报 stuck」——见下面 azure 段的说明。
30
- ipPools:
31
- # 种子池:**最稳定的入口,建议不要删**。属于 GitHub 自租段 140.82.112.0/20,多年未变。
32
- # 两个用途:① github.com / codeload 的兜底候选;
33
- # ② 拉 api.github.com/meta 更新 auto 池时的**直连入口**(该域名 DNS 在国内被污染,
34
- # 只能靠这些 IP + SNI 直连;而且这一步恰恰发生在「还没有可用 IP」的时候)。
35
- seed:
36
- - 140.82.112.3
37
- - 140.82.112.4
38
- - 140.82.113.3
39
- - 140.82.113.4
40
- - 140.82.114.3
41
- - 140.82.114.4
42
- - 140.82.116.3
43
- - 140.82.116.4
44
- - 140.82.121.3
45
- # Fastly 段:服务 githubusercontent 系(raw / objects / gist / avatars),多年不变
46
- fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
47
- # GitHub 自租段:服务 github.com / codeload.github.com 主站。
48
- # 🔴 2026-10 实测:这些 IP **在分钟级抖动** —— 同一批 IP 几分钟前全灭、现在 9/20 可用,
49
- # 且「活的是哪几个」每次都不一样。所以池子必须宽(靠 tcpMs 短超时兜住成本),
50
- # 只留少量 IP 必然出现「明明有活 IP 却报 stuck」。
51
- # ⚠️ 这一池**会过期**(2026-10-11 实测:24 个里有 4 个已不在官方 web 段)——
52
- # 它是「离线兜底」,真正跟得上变化的是下面 poolUpdate 自动维护的 `auto` 池。
53
- azure:
54
- # Azure 段(亚太/欧洲)
55
- - 20.200.245.247
56
- - 20.201.28.151
57
- - 20.207.73.82
58
- - 20.217.135.5
59
- - 20.233.83.145
60
- - 20.248.137.48
61
- - 20.26.156.215
62
- - 20.29.134.23
63
- - 20.87.245.0
64
- - 20.205.243.166
65
- - 20.205.243.167
66
- - 20.205.243.168
67
- - 4.237.22.34
68
- - 20.27.177.113
69
- - 4.208.26.197
70
- # 140.82.112.0/20(美国,web/api 走 .3 / .4)
71
- - 140.82.112.3
72
- - 140.82.112.4
73
- - 140.82.113.3
74
- - 140.82.113.4
75
- - 140.82.114.3
76
- - 140.82.114.4
77
- - 140.82.116.3
78
- - 140.82.116.4
79
- - 140.82.121.3
80
-
81
- # 域名 -> 只探哪些池(不配 = 用全部池)。
82
- # 两个池互不相干:185.199.x.x 永远不给 github.com 服务,20.x 也不给 raw.githubusercontent.com
83
- # 服务。分开能省掉近一半候选(冷启动最明显)。池名写错时自动退回「用全部池」。
84
- # auto = poolUpdate 自动从 api.github.com/meta 拉下来的最新网段(缓存不存在时该池为空,自动跳过)。
85
- # 顺序有意义:靠前的池先占 maxPoolIps 名额。
86
- domainPools:
87
- github.com: [auto, seed, azure]
88
- codeload.github.com: [auto, seed, azure]
89
- objects.githubusercontent.com: [fastly]
90
- raw.githubusercontent.com: [fastly]
91
- gist.githubusercontent.com: [fastly]
92
- avatars.githubusercontent.com: [fastly]
93
-
94
- # 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段,缓存到用户目录,供 `auto` 池使用。
95
- # 为什么需要:GitHub 的 Azure 段在变,内置 azure 池会过期(实测 24 个里 4 个已失效)。
96
- # 🔴 api.github.com/meta 未认证限 **60 次/小时** —— 所以 intervalHours 只能大(默认每天 1 次),
97
- # 绝不要调到 1 以下。
98
- # ⚠️ 若 api.github.com 被 SNI 层封锁(换 IP 也救不回),**失败后同样等满一个周期再试**,
99
- # 不会反复重试 —— 那种情况下继续重试没有意义,用现有池 + DoH 继续工作即可。
100
- # 手动触发:`ghprobe update-pool`(不受周期限制);自动触发:每次 health(到点才真去拉)。
101
- # 缓存文件:%LOCALAPPDATA%\ghprobe\pool.cache.json(自动生成,不用手改)
102
- poolUpdate:
103
- enabled: true
104
- intervalHours: 24 # 更新间隔(小时);成功与失败同一档
105
- source: "https://api.github.com/meta"
106
- host: api.github.com # TLS SNI(实际连的是 ipPools.seed 里的 IP)
107
- sections: [web, api] # 取 meta 里的哪些段
108
- maxIps: 48 # 缓存里最多保留几个 IP
109
- perBigNet: 4 # 大网段(/20、/22)每个采样几个 IP(不能全展开,/20 = 4096 个)
110
- timeoutMs: 8000
111
-
112
- # 本地网络检查:本机断网时直接跳过本轮(不探测、不更新池、不写 hosts)。
113
- # 为什么需要:断网时所有候选必然全灭,跑一轮纯属浪费,日志还会被「stuck / 全灭」刷屏,
114
- # 把「本机没网」和「GitHub 被墙」两种情况混在一起,反而看不出真故障。
115
- # 两步:① 有没有「已启用且带非回环 IPv4」的网卡(零成本);② TCP 连一下基准目标。
116
- # 基准目标必须是**国内任何正常网络都该能连上**的(默认阿里公共 DNS 的 DoH 端点),
117
- # 它只用来判断「本机能不能出去」,与 GitHub 是否可达无关。
118
- networkCheck:
119
- enabled: true
120
- probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
121
-
122
- # 被主机名黑名单掐掉的域名走兜底链:主候选全灭时改探测这些替身域名,把能通的那个写进 hosts。
123
- #
124
- # 两种写法都支持:
125
- # 简写 raw.githubusercontent.com: [cdn.jsdelivr.net]
126
- # 完整 raw.githubusercontent.com:
127
- # - { domain: my.mirror, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
128
- # 完整写法**自带探测判据** —— 第三关用它验证「这个 IP 真的在提供这份内容」。
129
- # 为什么判据必须跟着配置走:不同替身服务的 URL 形态不同,CDN 映射是
130
- # `/gh/<user>/<repo>@<ref>/<path>`,代理是 `/<完整原始 URL>`,写死在代码里就没法让用户自己加替身。
131
- #
132
- # 🔴 兜底域名本身也可能被封:实测 2026-10-11,`cdn.jsdelivr.net` 在当前网络下 TCP 连得上但 TLS 被掐
133
- # (被识别的是 TLS ClientHello 的形态,不是 IP)。所以这里给**多个候选**,探测时挑当下能通的那个。
134
- # ⚠️ `ghproxy.net` / `gh-proxy.com` 是**第三方 GitHub 代理**:请求会经过它们的服务器。
135
- # 介意的话把这两条删掉,只留 `cdn.jsdelivr.net` 即可。
136
- fallbacks:
137
- raw.githubusercontent.com:
138
- - cdn.jsdelivr.net
139
- - { domain: ghproxy.net, path: "/https://raw.githubusercontent.com/torvalds/linux/master/README", mustContain: ["Linux kernel"] }
140
- - { domain: gh-proxy.com, path: "/https://raw.githubusercontent.com/torvalds/linux/master/README", mustContain: ["Linux kernel"] }
141
-
142
- # 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
143
- # 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
144
- timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
145
- probe:
146
- maxCandidates: 8 # DoH 每域名最多取几个候选
147
- maxPoolIps: 32 # 内置池每域名最多补几个。**与 DoH 各自限额,不共享总上限**(见 probe.js)
148
- maxPerDomain: 4 # 每域名最多保留几个可用 IP(前 maxEntriesPerDomain 个会写进 hosts)
149
- concurrency: 10 # 全局同时握手的 socket 上限(跨域名共享)
150
- # hosts 写入:每域名写几条候选。
151
- # 🔴 2026-10-11 实测推翻旧假设「Windows 同一域名只取第一条」—— hosts 里同名多条会被
152
- # 解析器**全部返回**(保持文件顺序),且 curl / 浏览器会**逐个尝试**:黑洞 IP 排在前
153
- # 也能 fallback 到后面的活 IP(实测 http=200 / 0.22s)。所以多写 1 条备选 = 抖动时
154
- # 客户端自动兜底,把「断最长 1 分钟」变成「多等一个连接超时」。
155
- # 不要贪多:每多一个死 IP 就多叠一个连接超时;2 条是收益/代价的平衡点。1 = 退回旧行为。
156
- hosts:
157
- maxEntriesPerDomain: 2
158
- serve: { host: 127.0.0.1, port: 8930 }
159
- # 调度:固定频率健康检查,不做指数退避(哨兵一轮 <2s,退避省不下开销反而拉长最坏发现延迟)
160
- schedule:
161
- # 哨兵间隔(分钟):只测已写入 IP(3 条并发,健康时 ~1s),异常才触发全量重排。
162
- # 🔴 取 1 是刻意的:这是「IP 死了到自动换掉」的**最坏等待**。5 分钟时代码本身只要 20s 就能修好,
163
- # 但用户平均要多等 2.5 分钟 —— 卡在调度间隔上而不是卡在探测上。1 分钟是 Task Scheduler
164
- # 重复间隔的下限(New-ScheduledTaskTrigger 最小 1 分钟),已经到底了。
165
- healthMin: 1
166
- absentCooldownHours: 1 # 缺失域名冷却(小时):SNI 层封锁的域名全量也救不回,冷却内不重试
1
+ # ghprobe 默认配置(用户可用 --config 覆盖,键名一致即可)
2
+ #
3
+ # ⚠️ 关于升级:`ghprobe install` 会把本文件**整份**复制成用户配置
4
+ # (%LOCALAPPDATA%\ghprobe\config.yaml),并同时存一份**快照**
5
+ # (config.default.snapshot.yaml,用来判断你到底改过哪些键)。
6
+ # 加载时做**三方合并**:
7
+ # · 你**没改过**的键(值仍等于快照) -> 跟随本文件的最新值 ← 默认值的改进自动到达
8
+ # · 你**改过**的键 -> 保留你的值
9
+ # · 你自己加的键 / 本文件新增的键 -> 都保留
10
+ # 所以升级后不需要手动同步配置。想看有哪些差异:`ghprobe config-diff`。
11
+ # (本文件里的注释只是说明,不参与合并 —— 合并看的是键值。)
12
+ # 目标域名族:每域名独立探测、独立写 hosts
13
+ # api.github.com(0.2.0):gh CLI / REST API 走它,DNS 同样被污染 —— 写进 hosts 后
14
+ # `gh api` / `curl https://api.github.com` 直连可用;候选走 seed/auto 池(GitHub 自租段)
15
+ # github.githubassets.com(0.3.0):github.com 网页的全部 JS/CSS/字体都从这里加载 ——
16
+ # 不写它「页面能开但样式全裂」。注意其 Fastly 段是 .215,与 githubusercontent 的 .133 不同
17
+ # ssh.github.com(0.3.0):SSH-over-443(git clone git@ssh.github.com:... 用),
18
+ # 探测方式是读 SSH banner,不走 TLS/HTTP 三关
19
+ domains:
20
+ - github.com
21
+ - api.github.com
22
+ - ssh.github.com
23
+ - codeload.github.com
24
+ - objects.githubusercontent.com
25
+ - raw.githubusercontent.com
26
+ - gist.githubusercontent.com
27
+ - avatars.githubusercontent.com
28
+ - github.githubassets.com
29
+
30
+ # DoH 端点(全部 IP 直连,绕开解析 DoH 域名的死锁;默认只放国内实测可达端点)
31
+ # sni 填服务域名:TLS 握手按它验证书(RFC 6066 不允许拿 IP 当 SNI)
32
+ doh:
33
+ - { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
34
+ - { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
35
+ - { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
36
+ - { name: dnspod2, host: 120.53.53.53, sni: doh.pub, path: /dns-query }
37
+
38
+ # 内置候选 IP 池:补足 DoH 拿不到的候选。
39
+ # 🔴 池子必须够宽,否则会出现「明明有活 IP,工具却报 stuck」——见下面 azure 段的说明。
40
+ ipPools:
41
+ # 种子池:**最稳定的入口,建议不要删**。属于 GitHub 自租段 140.82.112.0/20,多年未变。
42
+ # 两个用途:① github.com / codeload 的兜底候选;
43
+ # ② 拉 api.github.com/meta 更新 auto 池时的**直连入口**(该域名 DNS 在国内被污染,
44
+ # 只能靠这些 IP + SNI 直连;而且这一步恰恰发生在「还没有可用 IP」的时候)。
45
+ seed:
46
+ - 140.82.112.3
47
+ - 140.82.112.4
48
+ - 140.82.113.3
49
+ - 140.82.113.4
50
+ - 140.82.114.3
51
+ - 140.82.114.4
52
+ - 140.82.116.3
53
+ - 140.82.116.4
54
+ - 140.82.121.3
55
+ # Fastly 段:服务 githubusercontent 系(raw / objects / gist / avatars),多年不变
56
+ fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
57
+ # Fastly ghassets 段(0.3.0 新增):github.githubassets.com 专用。
58
+ # 🔴 与上面 .133 不是同一段:实测 .133 对 github.githubassets.com 的 SNI 连不上,
59
+ # DoH 对该域名返回的也是 .215 —— 两段不能混用,所以单独建池。
60
+ ghassets: [185.199.108.215, 185.199.109.215, 185.199.110.215, 185.199.111.215]
61
+ # GitHub 自租段:服务 github.com / codeload.github.com 主站。
62
+ # 🔴 2026-10 实测:这些 IP **在分钟级抖动** —— 同一批 IP 几分钟前全灭、现在 9/20 可用,
63
+ # 且「活的是哪几个」每次都不一样。所以池子必须宽(靠 tcpMs 短超时兜住成本),
64
+ # 只留少量 IP 必然出现「明明有活 IP 却报 stuck」。
65
+ # ⚠️ 这一池**会过期**(2026-10-11 实测:24 个里有 4 个已不在官方 web 段)——
66
+ # 它是「离线兜底」,真正跟得上变化的是下面 poolUpdate 自动维护的 `auto` 池。
67
+ azure:
68
+ # Azure 段(亚太/欧洲)
69
+ - 20.200.245.247
70
+ - 20.201.28.151
71
+ - 20.207.73.82
72
+ - 20.217.135.5
73
+ - 20.233.83.145
74
+ - 20.248.137.48
75
+ - 20.26.156.215
76
+ - 20.29.134.23
77
+ - 20.87.245.0
78
+ - 20.205.243.166
79
+ - 20.205.243.167
80
+ - 20.205.243.168
81
+ - 4.237.22.34
82
+ - 20.27.177.113
83
+ - 4.208.26.197
84
+ # 140.82.112.0/20(美国,web/api 走 .3 / .4)
85
+ - 140.82.112.3
86
+ - 140.82.112.4
87
+ - 140.82.113.3
88
+ - 140.82.113.4
89
+ - 140.82.114.3
90
+ - 140.82.114.4
91
+ - 140.82.116.3
92
+ - 140.82.116.4
93
+ - 140.82.121.3
94
+
95
+ # 域名 -> 只探哪些池(不配 = 用全部池)。
96
+ # 两个池互不相干:185.199.x.x 永远不给 github.com 服务,20.x 也不给 raw.githubusercontent.com
97
+ # 服务。分开能省掉近一半候选(冷启动最明显)。池名写错时自动退回「用全部池」。
98
+ # auto = poolUpdate 自动从 api.github.com/meta 拉下来的最新网段(缓存不存在时该池为空,自动跳过)。
99
+ # 顺序有意义:靠前的池先占 maxPoolIps 名额。
100
+ domainPools:
101
+ github.com: [auto, seed, azure]
102
+ api.github.com: [auto, seed]
103
+ ssh.github.com: [auto, seed]
104
+ codeload.github.com: [auto, seed, azure]
105
+ objects.githubusercontent.com: [fastly]
106
+ raw.githubusercontent.com: [fastly]
107
+ gist.githubusercontent.com: [fastly]
108
+ avatars.githubusercontent.com: [fastly]
109
+ github.githubassets.com: [ghassets]
110
+
111
+ # 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段,缓存到用户目录,供 `auto` 池使用。
112
+ # 为什么需要:GitHub 的 Azure 段在变,内置 azure 池会过期(实测 24 个里 4 个已失效)。
113
+ # 🔴 api.github.com/meta 未认证限 **60 次/小时** —— 所以 intervalHours 只能大(默认每天 1 次),
114
+ # 绝不要调到 1 以下。
115
+ # ⚠️ 若 api.github.com 被 SNI 层封锁(换 IP 也救不回),**失败后同样等满一个周期再试**,
116
+ # 不会反复重试 —— 那种情况下继续重试没有意义,用现有池 + DoH 继续工作即可。
117
+ # 手动触发:`ghprobe update-pool`(不受周期限制);自动触发:每次 health(到点才真去拉)。
118
+ # 缓存文件:%LOCALAPPDATA%\ghprobe\pool.cache.json(自动生成,不用手改)
119
+ poolUpdate:
120
+ enabled: true
121
+ intervalHours: 24 # 更新间隔(小时);成功与失败同一档
122
+ source: "https://api.github.com/meta"
123
+ host: api.github.com # TLS SNI(实际连的是 ipPools.seed 里的 IP)
124
+ sections: [web, api] # 取 meta 里的哪些段
125
+ maxIps: 48 # 缓存里最多保留几个 IP
126
+ perBigNet: 4 # 大网段(/20、/22)每个采样几个 IP(不能全展开,/20 = 4096 个)
127
+ timeoutMs: 8000
128
+
129
+ # 本地网络检查:本机断网时直接跳过本轮(不探测、不更新池、不写 hosts)。
130
+ # 为什么需要:断网时所有候选必然全灭,跑一轮纯属浪费,日志还会被「stuck / 全灭」刷屏,
131
+ # 把「本机没网」和「GitHub 被墙」两种情况混在一起,反而看不出真故障。
132
+ # 两步:① 有没有「已启用且带非回环 IPv4」的网卡(零成本);② TCP 连一下基准目标。
133
+ # 基准目标必须是**国内任何正常网络都该能连上**的(默认阿里公共 DNS 的 DoH 端点),
134
+ # 它只用来判断「本机能不能出去」,与 GitHub 是否可达无关。
135
+ networkCheck:
136
+ enabled: true
137
+ probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
138
+
139
+ # 被主机名黑名单掐掉的域名走兜底链:主候选全灭时改探测这些替身域名,把能通的那个写进 hosts。
140
+ #
141
+ # 两种写法都支持:
142
+ # 简写 raw.githubusercontent.com: [cdn.jsdelivr.net]
143
+ # 完整 raw.githubusercontent.com:
144
+ # - { domain: my.mirror, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
145
+ # 完整写法**自带探测判据** —— 第三关用它验证「这个 IP 真的在提供这份内容」。
146
+ #
147
+ # 0.4.0 默认链(2026-10-31 实测数据驱动,详见 CHANGELOG):
148
+ # - cdn.jsdelivr.net 移除:node + 双 curl 共 45 次全部在 ClientHello 后被 RST(SNI 级硬封),
149
+ # 留着只会白耗每轮 6s 超时;
150
+ # - fastly.jsdelivr.net 首选:Fastly anycast(151.101.x.229),全栈全 IP 100%、最快(TTFB ~125ms);
151
+ # - testingcf.jsdelivr.net 次选:Cloudflare 段,与 Fastly 真正异构;
152
+ # - gcore.jsdelivr.net 末位:当前 DoH 解析与 testingcf 落在同一对 CF IP(名不副实),纯语义冗余。
153
+ # 🔴 三个替身都不在代码 DOMAIN_SIG 里 —— 必须用完整写法自带判据,否则会退回「探根路径」的弱判据。
154
+ fallbacks:
155
+ raw.githubusercontent.com:
156
+ - { domain: fastly.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
157
+ - { domain: testingcf.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
158
+ - { domain: gcore.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
159
+
160
+ # 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
161
+ # 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
162
+ timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
163
+ probe:
164
+ maxCandidates: 8 # DoH 每域名最多取几个候选
165
+ maxPoolIps: 32 # 内置池每域名最多补几个。**与 DoH 各自限额,不共享总上限**(见 probe.js)
166
+ maxPerDomain: 4 # 每域名最多保留几个可用 IP(前 maxEntriesPerDomain 个会写进 hosts)
167
+ concurrency: 10 # 全局同时握手的 socket 上限(跨域名共享)
168
+ # schannel 二次重探(0.3.0):Node 的 TLS ClientHello 被中间设备识别并 reset 时
169
+ # (README §3 实测:同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常),
170
+ # 改用 Windows 自带的 curl.exe(Schannel 构建,与 Edge 同一 TLS 栈)重探一次。
171
+ # 0.3.0 前这种情况只能写 # blocked 放弃,而浏览器明明能用 —— 这是假阴性的根治。
172
+ schannelRetry: true
173
+ # 复测抗抖动(0.3.0):对每域名前 3 名再采一枪,均值 + 失败罚分后重排名。
174
+ # 单采样时同一 IP 相邻两轮分差可达数倍、排名近乎随机,而无谓的换 IP 要刷 DNS 缓存。
175
+ # 2 = 总采样两次(默认,代价 ~1-2s);1 或删掉 = 退回单采样旧行为。
176
+ stableSamples: 2
177
+ # hosts 写入:每域名写几条候选。
178
+ # 🔴 2026-10-11 实测推翻旧假设「Windows 同一域名只取第一条」—— hosts 里同名多条会被
179
+ # 解析器**全部返回**(保持文件顺序),且 curl / 浏览器会**逐个尝试**:黑洞 IP 排在前
180
+ # 也能 fallback 到后面的活 IP(实测 http=200 / 0.22s)。所以多写 1 条备选 = 抖动时
181
+ # 客户端自动兜底,把「断最长 1 分钟」变成「多等一个连接超时」。
182
+ # 不要贪多:每多一个死 IP 就多叠一个连接超时;2 条是收益/代价的平衡点。1 = 退回旧行为。
183
+ hosts:
184
+ maxEntriesPerDomain: 2
185
+ serve: { host: 127.0.0.1, port: 8930 }
186
+ # 用户行为感知的「切换时机」算法(0.6.0,daemon 专用):
187
+ # 换 IP 有打扰成本(flushdns + 连接池重建,正在看的页面会卡一下)。成本按权重打分:
188
+ # 前台是浏览器 100 > 浏览器在后台跑 30 > 前台是其他软件 10 > 空闲 0
189
+ # 非紧急优化类写入(提上备选/重排优化/补缺失/兜底)在「前台是浏览器」时**推迟**,攒到空闲落地,
190
+ # 上限 deferMaxMs 防饿死;**当前已断的域名(主备全死)不吃权重,立即切** —— 正在用 ≠ 该断网。
191
+ # 检测免管理员:tasklist 扫浏览器进程 + PowerShell GetForegroundWindow 查前台。
192
+ algorithm:
193
+ enabled: true
194
+ deferMaxMs: 600000 # 推迟上限(毫秒):到点强制切,宁可打扰也不能饿死修复
195
+ deferThreshold: 100 # 成本 >= 此值才推迟(默认只有「前台是浏览器」会到 100)
196
+ detectCacheMs: 20000 # 活动检测结果缓存(daemon 一轮内不重复打系统)
197
+ browsers: # 视作「浏览器」的进程名(小写,不带 .exe)
198
+ - chrome
199
+ - msedge
200
+ - firefox
201
+ - brave
202
+ - opera
203
+ - vivaldi
204
+ - 360se
205
+ - 360chrome
206
+ - qqbrowser
207
+ - sogouexplorer
208
+ # 调度:固定频率健康检查,不做指数退避(哨兵一轮 <2s,退避省不下开销反而拉长最坏发现延迟)
209
+ schedule:
210
+ # 哨兵间隔(分钟):只测已写入 IP(3 条并发,健康时 ~1s),异常才触发全量重排。
211
+ # 🔴 取 1 是刻意的:这是「IP 死了到自动换掉」的**最坏等待**。5 分钟时代码本身只要 20s 就能修好,
212
+ # 但用户平均要多等 2.5 分钟 —— 卡在调度间隔上而不是卡在探测上。1 分钟是 Task Scheduler
213
+ # 重复间隔的下限(New-ScheduledTaskTrigger 最小 1 分钟),已经到底了。
214
+ healthMin: 1
215
+ absentCooldownHours: 1 # 缺失域名冷却(小时):SNI 层封锁的域名全量也救不回,冷却内不重试
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghprobe",
3
- "version": "0.1.2",
3
+ "version": "0.1.5",
4
4
  "description": "GitHub direct-connect optimizer: local DoH resolution + TLS-verified probing + hosts generation (no proxy)",
5
5
  "keywords": [
6
6
  "github",