ghprobe 0.1.3 → 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/CHANGELOG.md CHANGED
@@ -5,6 +5,144 @@
5
5
  格式参考 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
6
6
  版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
7
7
 
8
+ ## [0.6.0] - 2026-10-31
9
+
10
+ ### Added
11
+
12
+ - **用户行为感知的切换时机算法(`src/algorithm.js`)**:换 IP 不是零成本 —— flushdns +
13
+ 连接池重建,正在浏览的页面会卡一下。daemon 现在按「打扰成本 vs 紧急度」决定何时落盘:
14
+ - **成本权重**(可配 `algorithm.weights`):前台是浏览器 100 > 浏览器在后台跑 30 >
15
+ 前台是其他软件 10 > 空闲 0;
16
+ - **P0 不吃权重**:当前主备全死、用户此刻真的断着的域名 → **立即切**。「正在用」
17
+ 和「不能用」二选一,当然选别断网;首次写入(无从打扰)同此;
18
+ - **非紧急优化类**(提上备选 / 重排优化 / 补缺失 / 兜底写入)在前台是浏览器时**推迟**,
19
+ 攒成待写块由 daemon 每轮检查,用户空闲后落地;推迟上限 `deferMaxMs`(默认 10 分钟)防饿死。
20
+ - 检测免管理员:`tasklist` 扫浏览器进程(名单可配)+ PowerShell `GetForegroundWindow`
21
+ 查前台窗口;结果短缓存(beforeApply 同轮复用,空闲落地走新鲜检测)。
22
+ - 只作用于 daemon 自动写入;手动 `health --apply` / `probe --apply` 是显式意图,不设门。
23
+ - 顺带修正:`fullUpdate` 写入时把 `hostsPath` 传给 `applyHosts`(此前健康检查的写路径
24
+ 测试注入会被忽略,默认恰好等于真实 hosts 才未暴露)。
25
+ - 单元测试 192 → **201**(tasklist 解析 / 成本权重 / 决策矩阵 / 门控与空闲落地,全注入)。
26
+
27
+ ## [0.5.0] - 2026-10-31
28
+
29
+ ### Added
30
+
31
+ - **常驻 daemon(无缝切换的核心)**:`ghprobe daemon` 以 15~45s **自适应循环**持续自愈——
32
+ 全部健康 45s、有异常/stuck/出错 15s、断网 120s。取代「每分钟起一个新进程」的固定节拍:
33
+ 故障发现不再等下一分钟,修复全程无进程启动开销。实测架构目标:抖动窗口结束后的
34
+ 恢复延迟从 ~6 分钟量级压进 ~1 分钟以内。
35
+ - 每轮写心跳文件(`daemon.heartbeat`);单实例:发现更年轻的活实例自动让位;
36
+ - 每轮重载配置:编辑 `config.yaml` 即时生效;
37
+ - 循环体异常不退出(按 15s 快档重试)。
38
+ - **监督者架构(零迁移成本)**:现有的每分钟计划任务**原地降级**为监督者——
39
+ 心跳新鲜 → 秒退(本轮交给 daemon);心跳陈旧/没有 → **自动拉起新 daemon** + 本轮照常兜底。
40
+ daemon 崩溃 → 下一分钟自动复活。旧版本用户升级 npm 包后自动获得,**无需重装、无需新 UAC**。
41
+ 手动诊断用 `ghprobe health --force` 绕过监督逻辑。
42
+ - **status 显示常驻守护状态**:`常驻守护:运行中(pid …,心跳 Ns 前)` 一眼可见。
43
+ - 单元测试 185 → **192**(心跳/单实例/自适应间隔/主循环全注入,不打网络不真睡)。
44
+
45
+ ## [0.4.0] - 2026-10-31
46
+
47
+ ### Changed
48
+
49
+ - **🔴 修正 `raw.githubusercontent.com` 探测判据路径(潜伏 bug)**:
50
+ `DOMAIN_SIG` 里的 `/torvalds/linux/README.md` 在真 GitHub 上是 **404** ——
51
+ raw 的第 2 段是分支名,无分支的路径会被当作「README.md 分支」。就算网络全通,
52
+ 该判据也会把活 IP 判死(假阴性)。修正为 `/torvalds/linux/master/README`
53
+ (实测 200 且含 "Linux kernel";linux 仓库根文件本来就叫 `README`,无 `.md`)。
54
+ - **默认兜底链按实测数据重写**(jsDelivr 官方多 CDN,同一份内容、官方运营、无中间人):
55
+
56
+ | 替身 | 2026-10-31 实测 |
57
+ | --- | --- |
58
+ | `fastly.jsdelivr.net`(新,首选) | Fastly anycast 151.101.x.229,node+双 curl 全栈全 IP **100%**,TTFB ~125ms |
59
+ | `testingcf.jsdelivr.net`(新) | Cloudflare 段,100%,与 Fastly 真正异构 |
60
+ | `gcore.jsdelivr.net`(新) | 100%,但当前与 testingcf 解析到同一对 CF IP,纯语义冗余 |
61
+ | ~~`cdn.jsdelivr.net`~~(移除) | **0/45**:TCP 全通、ClientHello 后即 RST(SNI 硬封),换 IP/换 TLS 栈均无效 |
62
+
63
+ 三个替身都以**完整写法**入配置(自带 `/gh/torvalds/linux@master/README` + mustContain 判据,
64
+ 它们不在代码 DOMAIN_SIG 里,简写会退回探根路径的弱判据)。
65
+ 老用户没改过 `fallbacks` 的自动跟随新默认;改过的用 `ghprobe config-diff` 查看。
66
+
67
+ ### Added
68
+
69
+ - **`ghprobe status` 秒级速查**:不打任何网络请求,汇总「hosts 块现状(每域名主 IP + 备选 +
70
+ 缺失/阻断标记)+ 计划任务状态 + 自愈日志尾部」。与 doctor 的分工:日常「现在什么情况」用
71
+ status(<1s),坏了要诊断再用 doctor(~1 分钟)。
72
+ - **候选池拉取自救**:`updatePool` 支持**健康 hosts 条目当备用种子**(排在静态 seed 之前)——
73
+ 它们刚通过三关 + HTTP 终审,是「当下确实能直连 GitHub」的 IP;seed 池被 SNI 整段封锁时
74
+ 这批 IP 往往是唯一还拉得到 `api.github.com/meta` 的入口,池子不再永久冻结。
75
+ `health` 与 `update-pool` 两条路径都已接入。
76
+ - 单元测试 178 → **185**。
77
+
78
+ ### Investigated(按用户要求实测,结论如实记录)
79
+
80
+ - **Cloudflare 1.1.1.1:当前网络全封死,不可用** —— UDP 53 超时、TCP 53 超时、
81
+ DoH 直连 IP 超时、`cloudflare-dns.com` 秒 RST。另勘测确认本机 System32 curl 与 Git
82
+ mingw64 curl 均为 Schannel 构建(与「mingw=OpenSSL」的既有假设不符,栈对比结论不受影响);
83
+ 系统 DNS(UDP 53 出站)全部不可用,DoH 是唯一解析通道 —— 印证了本工具「DoH 端点 IP 直连」的设计。
84
+
85
+ ## [0.3.0] - 2026-10-31
86
+
87
+ ### Added
88
+
89
+ - **`github.githubassets.com` 加入默认域名族**:github.com 网页的全部 JS/CSS/字体都从这里
90
+ 加载 —— 不写它「页面能打开但样式全裂」,是直连浏览最常见的体验杀手。
91
+ 🔴 它的 Fastly 段是 `185.199.108-111.215`,与 githubusercontent 系的 `.133` **不同段**
92
+ (实测 `.133` 对该 SNI 直接连接失败),所以单独建 `ghassets` 池。
93
+ 判据用 `GET /favicons/favicon.svg`(实测 4 个 .215 IP 全部 200 且以 `<svg` 开头)。
94
+ - **`ssh.github.com` 加入默认域名族(SSH-over-443)**:`git clone git@ssh.github.com:...`
95
+ 的入口,SNI 封锁下最稳的 SSH 通道。探测类型新增 `type:"ssh"`:服务端先发 banner,
96
+ 读到合法 `SSH-2.0-*` 即活,不走 TLS/HTTP 三关(`sshProbe`)。
97
+ - **schannel 二次重探**:Node 的 TLS ClientHello 被中间设备识别并 reset 时(README §3 实测:
98
+ 同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常),改用 **Windows 自带的 curl.exe
99
+ (Schannel 构建,与 Edge 同一 TLS 栈)** 重探一次。为此 probeIp 的端口改为可注入。
100
+ 重探结果的语义随之升级:
101
+ - schannel 通 → 写入 hosts(0.3.0 前这种情况只能写 `# blocked` 放弃,而浏览器明明能用);
102
+ - schannel 后 HTTP 层仍 4xx → 假活,与指纹无关,不标 blocked;
103
+ - schannel 后连接层仍被 reset → **连浏览器同款栈都过不去**,`# blocked` 从此是
104
+ 「浏览器栈验证过确实被拦」的强结论,不再可能是客户端指纹的假阴性。
105
+ 🔴 必须显式定位 System32 的 curl.exe:PATH 前面的可能是 Git 自带的 OpenSSL 构建
106
+ (实测 mingw64 curl 走 OpenSSL,指纹又变回去)。
107
+ - **排名双采样抗抖动**:对每域名前 3 名再采一枪,`mergeSamples` 按均值 + 失败罚分
108
+ (500ms/次)重排名。单采样时同一 IP 相邻两轮分差可达数倍、排名近乎随机,
109
+ 而无谓的换 IP 要刷 DNS 缓存、断连接池 —— 排名稳比单次「最优」更重要。
110
+ `probe.stableSamples: 2`(默认开),设 1 退回旧行为。
111
+ - 单元测试 166 → **178**(SSH banner 解析/探测、schannel 注入路径、双采样合并、
112
+ githubassets 判据;全部本地 socket 与注入替身,不打真网络)。
113
+
114
+ ### Live validation (2026-10-31, 真实网络)
115
+
116
+ - 首轮 probe:`ssh.github.com` 经 banner 判活(20.205.243.160,54ms);
117
+ `github.githubassets.com` 4 个 .215 全活(最优 score 36);`api.github.com` /
118
+ `github.com`(双采样后 4 个可用)正常。
119
+ - `raw/gist/avatars.githubusercontent.com`(.133 段)本轮全灭 —— schannel 重探同样被
120
+ connection reset,证实是网络层对该批 SNI 的真实封锁,**不是工具的指纹假阴性**;
121
+ 工具如实标 blocked/absent 并走冷却,这正是设计行为。
122
+
123
+ ## [0.2.0] - 2026-10-30
124
+
125
+ ### Added
126
+
127
+ - **`ghprobe doctor` 一键自检**:把「本地网络 / DoH 解析 / 逐域名探测 / hosts 写入状态 /
128
+ 计划任务」五层各查一遍,逐项给人话结论 + 下一步建议(如「跑 `ghprobe update-pool`」
129
+ 「重跑 `ghprobe install`」)。定位:GitHub 连不上时,普通人不该被要求去读日志——
130
+ 一条命令看懂哪层坏了、该做什么。有 fail 项时退出码非零,方便脚本消费;
131
+ `# blocked` 类域名只标 warn 不算失败(浏览器可能仍可用,不该吓退用户)。
132
+ 评估逻辑拆成纯函数、编排依赖可注入,单测不打真网络。
133
+ - **`api.github.com` 加入默认域名族**:gh CLI(`gh api`)与 REST API 直连提速。
134
+ DNS 同样被污染,此前 hosts 不管它。判据用 `GET /rate_limit`(未认证也返回 200,
135
+ 响应体必含 `rate` 字段,不依赖网页特征);候选走 seed/auto 池(GitHub 自租段)。
136
+ 老用户升级后经三方合并自动获得该域名(没动过 `domains` 键的话)。
137
+ - **DoH 端点增加 DNSPod 第二入口 `120.53.53.53`**(与 `1.12.12.12` 同证书 `doh.pub`):
138
+ 单端点抖动时解析不再整体失明。真实网络实测 4/4 端点可用。
139
+ - 单元测试 153 → **166**。
140
+
141
+ ### Notes
142
+
143
+ - `ghprobe serve` 的 HTTP API 保持不变(`POST /api/probe`、`GET /api/status`、
144
+ `GET /api/hosts`、`GET /api/config`),供脚本消费。
145
+
8
146
  ## [0.1.3] - 2026-10-12
9
147
 
10
148
  ### Changed
package/README.en.md CHANGED
@@ -43,7 +43,7 @@ It provides **no** proxy, tunnel, traffic forwarding, or protocol evasion, and i
43
43
 
44
44
  **① Local DoH resolution, bypassing system-DNS poisoning**
45
45
 
46
- Candidates do not come from system DNS (which may be poisoned); they come over DoH (RFC 8484 wire format, with no UDP/53 path at all). The defaults are endpoints verified reachable from mainland China: Alibaba DNS (`223.5.5.5` / `223.6.6.6`) and DNSPod (`1.12.12.12`).
46
+ Candidates do not come from system DNS (which may be poisoned); they come over DoH (RFC 8484 wire format, with no UDP/53 path at all). The defaults are endpoints verified reachable from mainland China: Alibaba DNS (`223.5.5.5` / `223.6.6.6`) and DNSPod (`1.12.12.12` / `120.53.53.53`).
47
47
 
48
48
  Key detail: **DoH endpoints are reached by IP directly, with SNI validation.** If reaching a DoH endpoint itself required resolving its hostname first, you would fall into the deadlock of "resolving the DoH hostname requires resolving first." So the config stores the endpoint's IP, and a `sni` field carries the service hostname for certificate validation (RFC 6066 forbids using an IP as SNI), for example:
49
49
 
@@ -170,6 +170,8 @@ The last layer needs its own explanation, because it is the most easily misunder
170
170
 
171
171
  `# absent` = probed, genuinely no usable IP. `# blocked` = the local probe client got fingerprinted; your browser may well work.
172
172
 
173
+ > **Since 0.3.0**: after a Node reset, ghprobe retries once with the built-in Windows `curl.exe` (Schannel build, **the same TLS stack as Edge**) — `probe.schannelRetry`, on by default. Retry succeeds → the entry is written to hosts normally. Retry also reset → only then is `# blocked` written, and it now means "**verified blocked with a browser-equivalent stack**", no longer a possible fingerprint false negative.
174
+
173
175
  **Fixing the fourth layer** requires packet-level techniques (TTL manipulation, decoy packets, TCP-layer desync), which need a kernel driver (tools like GoodbyeDPI). That directly conflicts with this project's "no driver, no system network path changes" trade-off, so it is **deliberately out of scope**. ghprobe's job is: **given the network you already have, pick the right IPs and keep them working.**
174
176
 
175
177
  ---
@@ -357,6 +359,10 @@ ghprobe hosts
357
359
  ghprobe health
358
360
  ghprobe health --apply # write to system hosts when a problem is detected (UAC prompt)
359
361
 
362
+ # One-shot self-check: network / DoH / probing / hosts / scheduled task,
363
+ # with a plain-language verdict and next-step hint for each item
364
+ ghprobe doctor
365
+
360
366
  # Refresh the candidate pool from GitHub's official api.github.com/meta (once a day is enough)
361
367
  # That API is limited to 60 requests/hour unauthenticated, so don't call it often.
362
368
  # health also does this on schedule, so you rarely need it manually.
@@ -431,11 +437,14 @@ ipPools:
431
437
 
432
438
  domainPools:
433
439
  github.com: [auto, seed, azure]
440
+ api.github.com: [auto, seed]
441
+ ssh.github.com: [auto, seed]
434
442
  codeload.github.com: [auto, seed, azure]
435
443
  objects.githubusercontent.com: [fastly]
436
444
  raw.githubusercontent.com: [fastly]
437
445
  gist.githubusercontent.com: [fastly]
438
446
  avatars.githubusercontent.com: [fastly]
447
+ github.githubassets.com: [ghassets]
439
448
 
440
449
  poolUpdate:
441
450
  enabled: true
@@ -452,10 +461,10 @@ networkCheck:
452
461
  probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
453
462
 
454
463
  fallbacks:
455
- raw.githubusercontent.com: [cdn.jsdelivr.net]
464
+ raw.githubusercontent.com: [fastly.jsdelivr.net]
456
465
 
457
466
  timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
458
- probe: { maxCandidates: 8, maxPoolIps: 32, maxPerDomain: 4, concurrency: 10 }
467
+ probe: { maxCandidates: 8, maxPoolIps: 32, maxPerDomain: 4, concurrency: 10, schannelRetry: true, stableSamples: 2 }
459
468
  hosts: { maxEntriesPerDomain: 2 }
460
469
  serve: { host: 127.0.0.1, port: 8930 }
461
470
  schedule:
@@ -479,7 +488,7 @@ If you deployed via `install.ps1`, you can also use `.\install.ps1 -Uninstall`.
479
488
 
480
489
  ---
481
490
 
482
- ## 6. The fallback chain: why cdn.jsdelivr.net shows up in hosts
491
+ ## 6. The fallback chain: why *.jsdelivr.net shows up in hosts
483
492
 
484
493
  Besides the primary domains like `github.com`, ghprobe maintains a **fallback chain** for
485
494
  hostname-blocked domains: when every primary candidate is dead, it probes alternative entry
@@ -492,20 +501,22 @@ entry domain** will. GitHub file content has several equivalent entry points:
492
501
 
493
502
  ```text
494
503
  raw.githubusercontent.com/<user>/<repo>/<ref>/<path> <- the original (often blocked)
495
- cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> <- jsDelivr public CDN, same content
504
+ <mirror>.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> <- jsDelivr's official multi-CDN, same content
496
505
  ```
497
506
 
498
- **Why only one by default**: fallback domains get blocked too (measured at the same moment:
499
- `cdn.jsdelivr.net` accepted TCP but had its TLS handshake cut), so the chain **supports
500
- multiple candidates**; but the default ships only the public CDN `cdn.jsdelivr.net` —
501
- **ghprobe presets no third-party proxy or mirror domains** (e.g. `ghproxy.net` /
502
- `gh-proxy.com`): with those, "your requests go through their server", which contradicts
503
- this tool's "no proxy" positioning and carries legal risk. Add your own if you need more
504
- (how-to below):
507
+ **The default chain since 0.4.0 (driven by 2026-10-31 measurements)**:
505
508
 
506
- | Fallback | Type | Note |
507
- | --- | --- | --- |
508
- | `cdn.jsdelivr.net` | jsDelivr public CDN (Cloudflare) | path mapping `/gh/<user>/<repo>@<ref>/<path>` |
509
+ | Fallback | Type | Measured | Note |
510
+ | --- | --- | --- | --- |
511
+ | `fastly.jsdelivr.net` | jsDelivr's official Fastly segment | **100%** across all stacks/IPs, TTFB ~125ms | first choice; stable resolution (151.101.x.229) |
512
+ | `testingcf.jsdelivr.net` | jsDelivr's official Cloudflare segment | 100% | genuinely heterogeneous from Fastly; second channel |
513
+ | `gcore.jsdelivr.net` | jsDelivr official (nominally GCore) | 100% | currently resolves to the same CF IPs as testingcf; semantic redundancy only |
514
+ | ~~`cdn.jsdelivr.net`~~ | jsDelivr official multi-CDN scheduler | **0/45, hard SNI-blocked** | removed in 0.4.0: no IP or TLS stack helps, it only burns probe timeouts |
515
+
516
+ The official jsDelivr subdomains are not third-party proxies: same content, officially
517
+ operated, no man-in-the-middle. Third-party proxies (`ghproxy.net` / `gh-proxy.com` and
518
+ alike) mean "your requests go through their server" — **ghprobe presets none**; add your
519
+ own if you need more (how-to below):
509
520
 
510
521
  > Any third-party fallback domain you add yourself is your own judgement and risk.
511
522
 
@@ -521,7 +532,7 @@ Behaviour and prerequisites:
521
532
  ```yaml
522
533
  fallbacks:
523
534
  raw.githubusercontent.com:
524
- - cdn.jsdelivr.net
535
+ - fastly.jsdelivr.net # full form (with path + mustContain) shown in config.default.yaml
525
536
  # Add your own: gate 3 uses path + mustContain to verify this IP really serves this content
526
537
  - { domain: my.mirror.example, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
527
538
  ```
@@ -585,7 +596,7 @@ The task registered by `install` uses `LogonType = Interactive`, so it won't fir
585
596
 
586
597
  ### Disclaimer
587
598
 
588
- This tool performs DNS preference selection and connectivity probing only. It implements and runs no proxy, tunnel, or traffic forwarding itself, and presets no third-party proxy or mirror domains in its default config (the fallback chain defaults to the public CDN `cdn.jsdelivr.net`; any other fallback must be added by the user, at their own risk). You are solely responsible for any consequences of using this tool. Please comply with the laws and regulations of your jurisdiction.
599
+ This tool performs DNS preference selection and connectivity probing only. It implements and runs no proxy, tunnel, or traffic forwarding itself, and presets no third-party proxy domains in its default config (the fallback chain defaults to jsDelivr's official subdomains `fastly` / `testingcf` / `gcore.jsdelivr.net` — official multi-CDN entries for the same content; any other fallback must be added by the user, at their own risk). You are solely responsible for any consequences of using this tool. Please comply with the laws and regulations of your jurisdiction.
589
600
 
590
601
  ---
591
602
 
package/README.md CHANGED
@@ -43,7 +43,7 @@ ghprobe uninstall # 停止:删除计划任务并还原 hosts
43
43
 
44
44
  **① 本地 DoH 解析,绕开系统 DNS 污染**
45
45
 
46
- 候选 IP 不来自系统 DNS(可能被污染),而是走 DoH(RFC 8484 wire format,全程无 UDP/53 路径)。默认使用国内实测可达的端点:阿里 DNS(`223.5.5.5` / `223.6.6.6`)和 DNSPod(`1.12.12.12`)。
46
+ 候选 IP 不来自系统 DNS(可能被污染),而是走 DoH(RFC 8484 wire format,全程无 UDP/53 路径)。默认使用国内实测可达的端点:阿里 DNS(`223.5.5.5` / `223.6.6.6`)和 DNSPod(`1.12.12.12` / `120.53.53.53`)。
47
47
 
48
48
  关键细节:**DoH 端点用 IP 直连 + SNI 校验**。如果连 DoH 端点本身都要先解析它的域名,就会掉进「解析 DoH 域名又要先解析」的死锁;所以配置里端点写的是 IP,同时用 `sni` 字段填服务域名做证书校验(RFC 6066 不允许拿 IP 当 SNI),例如:
49
49
 
@@ -52,6 +52,7 @@ doh:
52
52
  - { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
53
53
  - { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
54
54
  - { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
55
+ - { name: dnspod2, host: 120.53.53.53, sni: doh.pub, path: /dns-query }
55
56
  ```
56
57
 
57
58
  多个端点并发查询,成功者取并集去重,再用内置 IP 池补一批候选。
@@ -168,7 +169,12 @@ ghprobe 的取舍是:**只做「优选 IP + 自动维护」,不碰系统网
168
169
  # blocked raw.githubusercontent.com 2026-10-11T10:27:09.367Z
169
170
  ```
170
171
 
171
- 两种标记的区别:`# absent` = 探过、确实没有可用 IP;`# blocked` = 本机探测客户端被识别,你的浏览器很可能照样能用。
172
+ 两种标记的区别:`# absent` = 探过、确实没有可用 IP;`# blocked` = 探测客户端 TLS 被识别,你的浏览器很可能照样能用。
173
+
174
+ > **0.3.0 起**:Node 被 reset 后会用 Windows 自带的 `curl.exe`(Schannel 构建,
175
+ > **与 Edge 同一 TLS 栈**)重探一次(`probe.schannelRetry`,默认开)。重探通过 →
176
+ > 正常写进 hosts;重探仍被 reset → `# blocked` 才落笔,此时它意味着
177
+ > 「**浏览器同款栈验证过确实被拦**」,不再是客户端指纹的假阴性。
172
178
 
173
179
  **要解决第四层**需要包级手法(TTL 操纵、伪包、TCP 层 desync),那必须装内核驱动(类似 GoodbyeDPI 那类工具)。这跟本项目「不装驱动、不改系统网络路径」的取舍直接冲突,所以**刻意不做**。本工具的定位是:**在你现有的网络条件下,把 IP 选对、并持续维护**。
174
180
 
@@ -350,6 +356,10 @@ ghprobe hosts
350
356
  ghprobe health
351
357
  ghprobe health --apply # 检测到异常时写入系统 hosts(会弹 UAC)
352
358
 
359
+ # 一键自检:网络 / DoH / 探测 / hosts / 计划任务,逐项人话结论 + 下一步建议
360
+ # GitHub 连不上不知道哪坏了?先跑它。有 fail 项时退出码非零,方便脚本判断
361
+ ghprobe doctor
362
+
353
363
  # 从 GitHub 官方 api.github.com/meta 更新候选池缓存(每天一次即可)
354
364
  # 该 API 未认证限 60 次/小时,所以别频繁调;health 到点也会自动调,一般不用手动
355
365
  ghprobe update-pool
@@ -423,11 +433,14 @@ ipPools:
423
433
 
424
434
  domainPools:
425
435
  github.com: [auto, seed, azure]
436
+ api.github.com: [auto, seed]
437
+ ssh.github.com: [auto, seed]
426
438
  codeload.github.com: [auto, seed, azure]
427
439
  objects.githubusercontent.com: [fastly]
428
440
  raw.githubusercontent.com: [fastly]
429
441
  gist.githubusercontent.com: [fastly]
430
442
  avatars.githubusercontent.com: [fastly]
443
+ github.githubassets.com: [ghassets]
431
444
 
432
445
  poolUpdate:
433
446
  enabled: true
@@ -444,10 +457,10 @@ networkCheck:
444
457
  probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
445
458
 
446
459
  fallbacks:
447
- raw.githubusercontent.com: [cdn.jsdelivr.net]
460
+ raw.githubusercontent.com: [fastly.jsdelivr.net]
448
461
 
449
462
  timeouts: { dohMs: 5000, tcpMs: 2500, tlsMs: 6000, httpMs: 6000 }
450
- probe: { maxCandidates: 8, maxPoolIps: 32, maxPerDomain: 4, concurrency: 10 }
463
+ probe: { maxCandidates: 8, maxPoolIps: 32, maxPerDomain: 4, concurrency: 10, schannelRetry: true, stableSamples: 2 }
451
464
  hosts: { maxEntriesPerDomain: 2 }
452
465
  serve: { host: 127.0.0.1, port: 8930 }
453
466
  schedule:
@@ -472,7 +485,7 @@ ghprobe uninstall # 删除计划任务 + 还原 hosts(只删自
472
485
 
473
486
  ---
474
487
 
475
- ## 6. 兜底链:为什么 hosts 里会出现 cdn.jsdelivr.net
488
+ ## 6. 兜底链:为什么 hosts 里会出现 *.jsdelivr.net
476
489
 
477
490
  除了 `github.com` 这类主域名,ghprobe 还会给**被主机名封锁**的域名配一条**兜底链**:
478
491
  主候选全灭时改探测替身域名,并把**当下能通的那个**写进 hosts。
@@ -483,18 +496,21 @@ ClientHello 的形态(实测数据见 §3「边界」)。这种时候换任
483
496
 
484
497
  ```text
485
498
  raw.githubusercontent.com/<user>/<repo>/<ref>/<path> ← 原始入口(常被拦)
486
- cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> ← jsDelivr 公共 CDN,同一份内容
499
+ <mirror>.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> ← jsDelivr 官方多 CDN,同一份内容
487
500
  ```
488
501
 
489
- **为什么只默认一个**:替身域名自己也会被封(实测同一时刻 `cdn.jsdelivr.net` 的 TCP 连得上、
490
- 但 TLS 被掐),所以兜底链**支持多候选**;但默认只留 `cdn.jsdelivr.net` 这一条公共 CDN ——
491
- **ghprobe 不预置任何第三方代理 / 镜像域名**(如 `ghproxy.net` / `gh-proxy.com`):
492
- 这类服务「请求会经过它们的服务器」,既违背本工具「无代理」的定位,也有法律风险。
493
- 需要更多兜底,自己加(写法见下文):
502
+ **0.4.0 默认链(2026-10-31 实测数据驱动)**:
503
+
504
+ | 替身 | 类型 | 实测 | 说明 |
505
+ | --- | --- | --- | --- |
506
+ | `fastly.jsdelivr.net` | jsDelivr 官方 Fastly 段 | 全栈全 IP **100%**,TTFB ~125ms | 首选;解析稳定(151.101.x.229) |
507
+ | `testingcf.jsdelivr.net` | jsDelivr 官方 Cloudflare 段 | 100% | 与 Fastly 真正异构,第二通道 |
508
+ | `gcore.jsdelivr.net` | jsDelivr 官方(名义 GCore) | 100% | 当前与 testingcf 同解析到 CF,纯语义冗余 |
509
+ | ~~`cdn.jsdelivr.net`~~ | jsDelivr 官方多 CDN 调度 | **0/45,SNI 硬封** | 0.4.0 移除:换 IP/换 TLS 栈均无效,留着白耗超时 |
494
510
 
495
- | 替身 | 类型 | 说明 |
496
- | --- | --- | --- |
497
- | `cdn.jsdelivr.net` | jsDelivr 公共 CDN(Cloudflare) | 路径映射 `/gh/<user>/<repo>@<ref>/<path>` |
511
+ jsDelivr 官方子域与第三方代理不同:同一份内容、官方运营、无中间人;第三方代理
512
+ (`ghproxy.net` / `gh-proxy.com` 一类)「请求会经过它们的服务器」,**ghprobe 不预置**,
513
+ 需要更多兜底自己加(写法见下文):
498
514
 
499
515
  > 🔴 自己添加的第三方替身域名,其可见性 / 合规性 / 法律风险由添加者自行判断和承担。
500
516
 
@@ -509,7 +525,7 @@ cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> ← jsDelivr 公共 CDN
509
525
  ```yaml
510
526
  fallbacks:
511
527
  raw.githubusercontent.com:
512
- - cdn.jsdelivr.net
528
+ - fastly.jsdelivr.net # 完整写法见 config.default.yaml:替身自带 path + mustContain 判据
513
529
  # 自己加一个:第三关用 path + mustContain 验证「这个 IP 真的在提供这份内容」
514
530
  - { domain: my.mirror.example, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
515
531
  ```
@@ -573,7 +589,7 @@ npm install -g ghprobe@latest
573
589
 
574
590
  ### 免责声明
575
591
 
576
- 本工具仅做 DNS 优选与连通性探测:自身不实现、不运行任何代理、隧道或流量转发,默认配置也不预置任何第三方代理 / 镜像域名(兜底链默认仅含公共 CDN `cdn.jsdelivr.net`,其余替身需用户自行添加并自担风险)。使用本工具产生的一切后果由使用者自行承担。请遵守你所在地区的法律法规。
592
+ 本工具仅做 DNS 优选与连通性探测:自身不实现、不运行任何代理、隧道或流量转发,默认配置也不预置任何第三方代理域名(兜底链默认只含 jsDelivr 官方子域 `fastly` / `testingcf` / `gcore.jsdelivr.net`——同一份内容的官方多 CDN 入口,其余替身需用户自行添加并自担风险)。使用本工具产生的一切后果由使用者自行承担。请遵守你所在地区的法律法规。
577
593
 
578
594
  ---
579
595
 
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 池");