ghprobe 0.1.1 → 0.1.2

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,44 @@
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.1.2] - 2026-10-11
9
+
10
+ ### Added
11
+
12
+ - **配置升级机制(三方合并)**:解决「默认值的改进到不了老用户」——
13
+ 用户配置是 `install` 时**整份复制**的、且优先于包内默认,所以默认文件里**已有键**的新值
14
+ 永远不生效(数组是整体替换,不合并)。现在 `install` 会额外存一份**默认配置快照**
15
+ (`%LOCALAPPDATA%\ghprobe\config.default.snapshot.yaml`),`loadConfig` 做**三方合并**:
16
+ 用户值 == 快照值 → 这个键他没动过 → 用**新默认**;否则保留用户值。
17
+ 快照**只在缺失时创建、不随升级刷新**(一刷新基准就漂了,没改过的键会被误判成改过)。
18
+ 新增 `ghprobe config-diff` 查看用户配置与当前默认的差异;`config-path` 一并打印快照路径。
19
+ ⚠️ 0.1.2 之前装的用户没有快照,跑一次 `ghprobe install` 之后才会建立;在那之前用
20
+ `config-diff` 看差异、手动采纳。
21
+ - **兜底链支持多个候选,并挑当下能通的那个**:原来 `fallbacks` 每个主域名只能配一个替身,
22
+ 而替身自己也会被封(实测同一时刻 `cdn.jsdelivr.net` 的 TCP 连得上、TLS 被掐)。
23
+ 现在默认给三个:`cdn.jsdelivr.net`(jsDelivr 公共 CDN)、`ghproxy.net` / `gh-proxy.com`
24
+ (第三方 GitHub 代理,配置里已注明「请求会经过它们的服务器」,介意可删)。
25
+ - **替身可以自带探测判据**:`fallbacks` 新增完整写法
26
+ `- { domain, path, mustContain }`,判据写在配置里而不是代码里 ——
27
+ 因为不同替身的 URL 形态不同(CDN 映射 `/gh/<user>/<repo>@<ref>/<path>` vs 代理 `/<完整原始 URL>`),
28
+ 用户新增替身不需要改源码。归一化在 `loadConfig()` 里做,下游(`targetDomains` /
29
+ `neededFallbacks`)拿到的仍是 `{ 主域名: [替身域名...] }`,无需改动。
30
+
31
+ ### Changed
32
+
33
+ - `config.default.yaml` 头部补了升级说明:`install` 会把默认配置**整份**复制成用户配置,
34
+ 而用户配置优先 —— 所以**默认值里已有的键,升级后新默认不会自动生效**(新增的键不受影响)。
35
+ - README 第 6 节重写(原「关于为什么还会出现 cdn.jsdelivr.net」):说明兜底链要解决的是
36
+ **换 IP 修不了**的那层封锁、为什么需要多个候选、第三方代理的隐私代价、以及怎么自己加替身。
37
+ 并修正了原文「`raw` 被封锁后这类文件没有替代入口」的自相矛盾说法。
38
+
39
+ ### Fixed
40
+
41
+ - README **中文版**修正两条过时 FAQ:还在说「每个域名只写一条 IP」「Windows 对同一域名只认第一条」——
42
+ 这两个说法在 0.1.0 就被实测推翻了(hosts 里同名多条会被解析器**全部返回**、curl / 浏览器
43
+ **逐个尝试**,所以备选是真兜底)。**英文版当时改了、中文版漏了**,两边现在一致。
44
+ - 单测 137 → **153**。
45
+
8
46
  ## [0.1.1] - 2026-10-11
9
47
 
10
48
  线上实测驱动的三个修复。都是「工具看着在干活、实际把用户搞断网」那一类。
@@ -160,5 +198,6 @@
160
198
  - 提权脚本以 UTF-8 BOM 落盘,杜绝编码解析歧义
161
199
  - 写入前校验块标记存在,失败自动回滚;还原时只移除本工具自己的行
162
200
 
201
+ [0.1.2]: https://github.com/zmw-zmw/ghprobe/releases/tag/v0.1.2
163
202
  [0.1.1]: https://github.com/zmw-zmw/ghprobe/releases/tag/v0.1.1
164
203
  [0.1.0]: https://github.com/zmw-zmw/ghprobe/releases/tag/v0.1.0
package/README.en.md CHANGED
@@ -160,7 +160,8 @@ The last layer needs its own explanation, because it is the most easily misunder
160
160
 
161
161
  **Same IP, same hostname, only the client changes — and the success rate drops from 100% to 15%.** So the block keys on the **shape of the TLS ClientHello** (extension list, order, length) — not on the hostname itself, and not on the IP. Two consequences:
162
162
 
163
- 1. **Changing the IP cannot fix it** — only changing the domain can (which is why there is a fallback chain: `raw.githubusercontent.com -> cdn.jsdelivr.net`).
163
+ 1. **Changing the IP cannot fix it** — only changing the domain can. That is why there is a **fallback chain**
164
+ with **several alternative candidates** (the fallbacks get blocked too): see section 6.
164
165
  2. **This tool's probe results do not fully represent your experience** — ghprobe is Node, and its TLS fingerprint differs from your browser's. Measured: `raw.githubusercontent.com` opens fine in a browser, while the Node probe gets `ECONNRESET` on every candidate. In that case the tool writes `# blocked` (not `# absent`), meaning **inconclusive** rather than "unreachable":
165
166
 
166
167
  ```text
@@ -478,26 +479,55 @@ If you deployed via `install.ps1`, you can also use `.\install.ps1 -Uninstall`.
478
479
 
479
480
  ---
480
481
 
481
- ## 6. About "why is cdn.jsdelivr.net in there?"
482
+ ## 6. The fallback chain: why cdn.jsdelivr.net / ghproxy.net show up in hosts
482
483
 
483
- **`cdn.jsdelivr.net` is not a domestic mirror.** It is the jsDelivr public CDN (running on Cloudflare). It appears in hosts for exactly one reason: the **fallback chain**.
484
+ Besides the primary domains like `github.com`, ghprobe maintains a **fallback chain** for
485
+ hostname-blocked domains: when every primary candidate is dead, it probes alternative entry
486
+ domains and writes **whichever works right now** into hosts.
484
487
 
485
- One fault type is the **SNI hostname blacklist** — what is blocked is the hostname itself. The SNI in the TLS handshake carries that domain, so **no IP will help**; the only way out is to **change the domain**. jsDelivr maps GitHub files directly:
488
+ **Why the domain has to change**: one layer of blocking cannot be fixed by changing the IP —
489
+ what is blocked is not the IP but the **shape of the ClientHello** in the TLS handshake
490
+ (measurements in section 3, "Limits"). No IP will help there; only a **different, unblocked
491
+ entry domain** will. GitHub file content has several equivalent entry points:
486
492
 
487
493
  ```text
488
- raw.githubusercontent.com/<user>/<repo>/<ref>/<path>
489
- cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> <- same content
494
+ 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
496
+ ghproxy.net/https://raw.githubusercontent.com/<...> <- third-party GitHub proxy
490
497
  ```
491
498
 
492
- So when `raw.githubusercontent.com` is SNI-blocked and all primary candidates are dead, ghprobe falls back to it.
499
+ **Why several candidates**: the fallback domains get blocked too. Measured at the same moment:
500
+ `cdn.jsdelivr.net` accepted TCP but had its TLS handshake cut. So the config ships a set of
501
+ candidates and the probe **picks whichever works right now** (if several work, all are written
502
+ and the client chooses). Three by default:
493
503
 
494
- Caveats:
504
+ | Fallback | Type | Note |
505
+ | --- | --- | --- |
506
+ | `cdn.jsdelivr.net` | jsDelivr public CDN (Cloudflare) | path mapping `/gh/<user>/<repo>@<ref>/<path>` |
507
+ | `ghproxy.net` | third-party GitHub proxy | your requests go through their server |
508
+ | `gh-proxy.com` | third-party GitHub proxy | same |
509
+
510
+ > **A third-party proxy can see which files you requested.** Delete those two if that bothers
511
+ > you and keep only `cdn.jsdelivr.net`.
512
+
513
+ Behaviour and prerequisites:
495
514
 
496
515
  - By default only `raw.githubusercontent.com` has a fallback; `github.com` / `codeload.github.com` never use one.
497
- - It fires only when the primary candidates are **entirely dead** — any live IP and the fallback domain is never probed.
498
- - What gets written is the `cdn.jsdelivr.net` IP — this rescues "change the domain," not "change the IP."
516
+ - It fires only when the primary candidates are **entirely dead** — any live IP and the fallback is never probed.
517
+ - What gets written is the **fallback domain's** IP: this rescues "change the domain", not "change the IP".
518
+ You access it through the fallback URL form.
519
+ - Fallbacks carry their **own probe signature** (path + required response content), declared in config
520
+ rather than code — so you can add your own without touching the source:
521
+
522
+ ```yaml
523
+ fallbacks:
524
+ raw.githubusercontent.com:
525
+ - cdn.jsdelivr.net
526
+ # Add your own: gate 3 uses path + mustContain to verify this IP really serves this content
527
+ - { domain: my.mirror.example, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
528
+ ```
499
529
 
500
- To remove it, empty `fallbacks` (`--config` merges recursively, so an empty object works):
530
+ To remove the fallback, empty it (`--config` merges recursively, so an empty object works):
501
531
 
502
532
  ```yaml
503
533
  fallbacks: {}
@@ -507,8 +537,6 @@ fallbacks: {}
507
537
  ghprobe install -c my.yaml
508
538
  ```
509
539
 
510
- The cost: once `raw.githubusercontent.com` is SNI-blocked, that class of files has no alternative entry point.
511
-
512
540
  ---
513
541
 
514
542
  ## 7. Requirements and FAQ
@@ -537,6 +565,22 @@ Two by default (`hosts.maxEntriesPerDomain`): the best IP plus one spare. Window
537
565
  **Q: What if an IP dies between two health checks?**
538
566
  The client falls through to the spare entry, so the cost is one extra connect timeout rather than a hard failure. Without a spare, the worst-case interruption would be about `schedule.healthMin` (1 minute by default).
539
567
 
568
+ **Q: How do I upgrade?**
569
+ ```powershell
570
+ npm install -g ghprobe@latest
571
+ ```
572
+ The scheduled task runs a **fixed path** inside the npm global directory, and `npm install -g` replaces it in place — so the **next trigger (within a minute) picks up the new version**. No need to re-run `install`, no second UAC prompt.
573
+
574
+ **Does the config update too?** Yes. At `install` time a **snapshot** of the then-current default config is stored, and loading does a **three-way merge**:
575
+
576
+ - Keys you **never changed** (value still equals the snapshot) → follow the latest default
577
+ - Keys you **did change** → your value is kept
578
+ - Keys you added / keys newly added by a release → both kept
579
+
580
+ So improvements to defaults (say, new fallback candidates) reach you **automatically**, and your customisations are never overwritten. To see what differs: `ghprobe config-diff`.
581
+
582
+ > Note: users who installed before 0.1.2 have no snapshot; it is created on the next `ghprobe install`. Until then, use `ghprobe config-diff` and delete the keys you want to follow.
583
+
540
584
  **Q: Does the scheduled task run while the screen is locked?**
541
585
  The task registered by `install` uses `LogonType = Interactive`, so it won't fire while locked / logged off / not logged in; it resumes on the interval after logon.
542
586
 
package/README.md CHANGED
@@ -160,7 +160,8 @@ ghprobe 的取舍是:**只做「优选 IP + 自动维护」,不碰系统网
160
160
 
161
161
  **同一个 IP、同一个主机名,只换客户端,结果从 100% 掉到 15%。** 说明拦截依据是 **TLS ClientHello 的形态**(扩展列表、顺序、长度),既不是主机名本身,也不是 IP。这带来两个后果:
162
162
 
163
- 1. **换 IP 修不了它** —— 只能换域名(所以本工具有兜底链:`raw.githubusercontent.com → cdn.jsdelivr.net`)。
163
+ 1. **换 IP 修不了它** —— 只能换域名。所以本工具有**兜底链**,而且配了**多个替身候选**
164
+ (替身自己也会被封):见第 6 节。
164
165
  2. **本工具的探测结果不能完全代表你的体验** —— 它自己是 Node,TLS 指纹和你用的浏览器不同。实测:`raw.githubusercontent.com` 在浏览器里能正常打开,而 Node 探测全部 `ECONNRESET`。遇到这种情况工具会写 `# blocked`(而不是 `# absent`),明确表示**探测不可判**,不是「该域名不可达」:
165
166
 
166
167
  ```text
@@ -471,26 +472,50 @@ ghprobe uninstall # 删除计划任务 + 还原 hosts(只删自
471
472
 
472
473
  ---
473
474
 
474
- ## 6. 关于「为什么还会出现 cdn.jsdelivr.net」
475
+ ## 6. 兜底链:为什么 hosts 里会出现 cdn.jsdelivr.net / ghproxy.net
475
476
 
476
- **`cdn.jsdelivr.net` 不是国内镜像**,它是 jsDelivr 的公共 CDN(走 Cloudflare)。它进 hosts 的唯一原因是**兜底链**。
477
+ 除了 `github.com` 这类主域名,ghprobe 还会给**被主机名封锁**的域名配一条**兜底链**:
478
+ 主候选全灭时改探测替身域名,并把**当下能通的那个**写进 hosts。
477
479
 
478
- 有一种故障是 **SNI 主机名黑名单** —— 被封锁的是主机名本身,TLS 握手里的 SNI 带着那个域名,**换任何 IP 都没用**,只能**换域名**。jsDelivr 提供 GitHub 文件的直接映射:
480
+ **为什么必须换域名**:有一层封锁是「换 IP 修不了」的 —— 被拦的不是 IP,而是 TLS 握手里
481
+ ClientHello 的形态(实测数据见 §3「边界」)。这种时候换任何 IP 都没用,只能**换一个不被拦的入口域名**。
482
+ 而 GitHub 的文件内容有几个等价入口:
479
483
 
480
484
  ```text
481
- raw.githubusercontent.com/<user>/<repo>/<ref>/<path>
482
- cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> ← 同一份内容
485
+ raw.githubusercontent.com/<user>/<repo>/<ref>/<path> ← 原始入口(常被拦)
486
+ cdn.jsdelivr.net/gh/<user>/<repo>@<ref>/<path> ← jsDelivr 公共 CDN,同一份内容
487
+ ghproxy.net/https://raw.githubusercontent.com/<...> ← 第三方 GitHub 代理
483
488
  ```
484
489
 
485
- 所以当 `raw.githubusercontent.com` 被 SNI 封锁、主候选全灭时,ghprobe 用它兜底。
490
+ **为什么配多个候选**:替身域名自己也会被封。实测同一时刻 `cdn.jsdelivr.net` 的 TCP 连得上、
491
+ 但 TLS 被掐 —— 所以配置里给一组候选,探测时**挑当下能通的**(能通多个就都写上,客户端自己选)。
492
+ 默认三个:
486
493
 
487
- 前提:
494
+ | 替身 | 类型 | 说明 |
495
+ | --- | --- | --- |
496
+ | `cdn.jsdelivr.net` | jsDelivr 公共 CDN(Cloudflare) | 路径映射 `/gh/<user>/<repo>@<ref>/<path>` |
497
+ | `ghproxy.net` | ⚠️ 第三方 GitHub 代理 | 请求会经过它的服务器 |
498
+ | `gh-proxy.com` | ⚠️ 第三方 GitHub 代理 | 同上 |
499
+
500
+ > ⚠️ **第三方代理能看到你请求了哪些文件。** 介意的话把这两条删掉,只留 `cdn.jsdelivr.net`。
501
+
502
+ 行为与前提:
488
503
 
489
504
  - 默认只有 `raw.githubusercontent.com` 配了兜底;`github.com` / `codeload.github.com` 从不走兜底。
490
- - 只在主候选**全灭**时触发,有任一活 IP 就不探兜底。
491
- - 命中后写入的是 `cdn.jsdelivr.net` 的 IP —— 救的是「换域名」,不是「换 IP」。
505
+ - 只在主候选**全灭**时触发;有任一活 IP 就不探兜底。
506
+ - 写入的是**替身域名**的 IP —— 救的是「换域名」,不是「换 IP」。访问时要换成替身的 URL 形式。
507
+ - 替身**自带探测判据**(路径 + 响应里必须出现的内容),写在配置里而不是代码里 ——
508
+ 所以你可以自己加替身,不需要改源码:
492
509
 
493
- 不想要就清空 `fallbacks`(`--config` 覆盖是递归合并,给空对象即可):
510
+ ```yaml
511
+ fallbacks:
512
+ raw.githubusercontent.com:
513
+ - cdn.jsdelivr.net
514
+ # 自己加一个:第三关用 path + mustContain 验证「这个 IP 真的在提供这份内容」
515
+ - { domain: my.mirror.example, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
516
+ ```
517
+
518
+ 不想要兜底就清空(`--config` 覆盖是递归合并,给空对象即可):
494
519
 
495
520
  ```yaml
496
521
  fallbacks: {}
@@ -500,8 +525,6 @@ fallbacks: {}
500
525
  ghprobe install -c my.yaml
501
526
  ```
502
527
 
503
- 代价:`raw.githubusercontent.com` 被 SNI 封锁后,这类文件没有替代入口。
504
-
505
528
  ---
506
529
 
507
530
  ## 7. 系统要求与常见问题
@@ -524,11 +547,27 @@ ghprobe install -c my.yaml
524
547
  **Q:会不会影响别的软件?**
525
548
  不会。它**只写 hosts 文件**,不装代理、不改系统代理设置、不劫持流量。而且只操作 `# === ghprobe begin/end ===` 之间属于它自己的那一块,**你原有的 hosts 条目和第三方工具写的条目都不会被改动**;卸载时也只剥掉自己的块。
526
549
 
527
- **Q:为什么每个域名只写一条 IP?**
528
- Windows 对同一域名在 hosts 里只认第一条,写多条也不会轮询。所以 ghprobe 每个域名只写最优的一条;排名里的「备选」仅用于展示,不写入。
550
+ **Q:每个域名写几条 IP?**
551
+ 默认 **2 条**(最优 + 1 条备选),由 `hosts.maxEntriesPerDomain` 控制。这里推翻过一个流传很广的说法:Windows **不是**「同名只认第一条」—— hosts 里同名多条会被解析器**全部返回**(保持文件顺序),curl / 浏览器会**逐个尝试**(黑洞 IP 排在前也能 fallback,实测 `curl` 返回 200)。所以备选是真的兜底,不是摆设。设 1 可退回单条。
529
552
 
530
553
  **Q:两次健康检查之间 IP 失效了怎么办?**
531
- 每域名只写一条 IP,若它在两次检查之间失效,最坏中断时长 ≈ `schedule.healthMin`(默认 1 分钟)。这是 hosts 机制本身的限制,不是探测能规避的。
554
+ 每域名写 2 条(主 + 备选)。首条抖动失效时客户端会**自动试备选** —— 代价是多等一个连接超时,而不是中断。最坏情况(两条同时失效)中断时长 ≈ `schedule.healthMin`(默认 1 分钟)。
555
+
556
+ **Q:怎么升级?**
557
+ ```powershell
558
+ npm install -g ghprobe@latest
559
+ ```
560
+ 计划任务跑的是 npm 全局目录里的**固定路径**,而 `npm install -g` 是**就地替换** —— 所以**下次触发(≤ 1 分钟)自动用新版**。不需要重跑 `install`,不需要再点 UAC。
561
+
562
+ **配置也会跟着更新吗?** 会。`install` 时会把当时的默认配置存一份**快照**,加载时做**三方合并**:
563
+
564
+ - 你**没改过**的键(值仍等于快照)→ 跟随最新默认值
565
+ - 你**改过**的键 → 保留你的值
566
+ - 你自己加的键 / 新版新增的键 → 都保留
567
+
568
+ 所以默认值的改进(比如新增的兜底候选)会**自动到达**,而你的自定义不会被覆盖。想看有哪些差异:`ghprobe config-diff`。
569
+
570
+ > ⚠️ 0.1.2 之前装的用户没有快照,跑一次 `ghprobe install` 之后才会建立。在那之前用 `ghprobe config-diff` 看差异,把想采纳的键从用户配置里删掉即可。
532
571
 
533
572
  **Q:计划任务锁屏时会跑吗?**
534
573
  `install` 注册的任务 `LogonType` 为 `Interactive`,锁屏 / 注销 / 未登录状态下不会触发,登录后按间隔继续。
package/bin/ghprobe.js CHANGED
@@ -7,7 +7,7 @@ import fs from "node:fs";
7
7
  import os from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { Command } from "commander";
10
- import { loadConfig, ensureUserConfig, userConfigPath } from "../src/config.js";
10
+ import { loadConfig, ensureUserConfig, userConfigPath, userConfigSnapshotPath, configDiff } from "../src/config.js";
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";
@@ -273,10 +273,33 @@ program.command("update-pool")
273
273
  });
274
274
 
275
275
  program.command("config-path")
276
- .description("打印用户配置文件与池缓存的路径(配置文件可直接编辑)")
276
+ .description("打印用户配置文件、默认配置快照与池缓存的路径(配置文件可直接编辑)")
277
277
  .action(() => {
278
278
  console.log(`配置:${userConfigPath()}`);
279
+ console.log(`快照:${userConfigSnapshotPath()}(安装时的默认配置,用来判断你改过哪些键)`);
279
280
  console.log(`池缓存:${poolCachePath()}`);
280
281
  });
281
282
 
283
+ program.command("config-diff")
284
+ .description("对比用户配置与当前默认配置(升级后看哪些默认改动还没生效)")
285
+ .action(() => {
286
+ const { hasUser, diff } = configDiff();
287
+ if (!hasUser) {
288
+ console.log("还没有用户配置 —— 先跑一次 `ghprobe install` 生成。");
289
+ return;
290
+ }
291
+ if (!diff.length) {
292
+ console.log("用户配置与当前默认一致,没有需要处理的差异。");
293
+ return;
294
+ }
295
+ console.log(`用户配置与当前默认有 ${diff.length} 处差异:\n`);
296
+ for (const d of diff) {
297
+ console.log(` ${d.key}`);
298
+ console.log(` 你的值 : ${JSON.stringify(d.user)}`);
299
+ console.log(` 当前默认 : ${JSON.stringify(d.def)}`);
300
+ }
301
+ console.log("\n想让某处跟随新默认:把用户配置里对应的键**删掉**即可(没被改过的键会自动跟随默认)。");
302
+ console.log(`配置文件:${userConfigPath()}`);
303
+ });
304
+
282
305
  program.parseAsync();
@@ -1,140 +1,166 @@
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 层封锁的域名全量也救不回,冷却内不重试
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 层封锁的域名全量也救不回,冷却内不重试
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghprobe",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "GitHub direct-connect optimizer: local DoH resolution + TLS-verified probing + hosts generation (no proxy)",
5
5
  "keywords": [
6
6
  "github",
package/src/config.js CHANGED
@@ -17,10 +17,39 @@ export function userConfigPath() {
17
17
  return path.join(userDataDir(), "config.yaml");
18
18
  }
19
19
 
20
+ // 「安装时的默认配置」快照路径。它的唯一用途:**判断用户到底改过哪些键**。
21
+ // 为什么需要:用户配置是 install 时整份复制的,而用户配置优先于包内默认 ——
22
+ // 于是**默认文件里已有键的新默认值,永远到不了老用户**(数组是整体替换,不合并)。
23
+ // 有了快照就能做三方合并:用户值 == 快照值 → 他没动过这个键 → 用**新默认**。
24
+ export function userConfigSnapshotPath() {
25
+ return path.join(userDataDir(), "config.default.snapshot.yaml");
26
+ }
27
+
20
28
  function isPlainObject(v) {
21
29
  return v !== null && typeof v === "object" && !Array.isArray(v) && !(v instanceof Date) && !(v instanceof RegExp);
22
30
  }
23
31
 
32
+ // 读 YAML,读不到/解析失败都返回 null(不抛)
33
+ function readYamlSafe(p) {
34
+ try { return yaml.load(fs.readFileSync(p, "utf8")); } catch { return null; }
35
+ }
36
+
37
+ // 深比较(只看结构,不看键顺序)—— 三方合并要靠它判断「用户值是否还等于安装时的默认值」
38
+ export function deepEqual(a, b) {
39
+ if (a === b) { return true; }
40
+ if (Array.isArray(a) || Array.isArray(b)) {
41
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false; }
42
+ return a.every((x, i) => deepEqual(x, b[i]));
43
+ }
44
+ if (isPlainObject(a) || isPlainObject(b)) {
45
+ if (!isPlainObject(a) || !isPlainObject(b)) { return false; }
46
+ const ka = Object.keys(a);
47
+ if (ka.length !== Object.keys(b).length) { return false; }
48
+ return ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]));
49
+ }
50
+ return false;
51
+ }
52
+
24
53
  function mergeDefaults(def, user) {
25
54
  if (!isPlainObject(def) || !isPlainObject(user)) { return user === undefined ? def : user; }
26
55
  const out = { ...def };
@@ -30,6 +59,48 @@ function mergeDefaults(def, user) {
30
59
  return out;
31
60
  }
32
61
 
62
+ // 三方合并:def = 当前包内默认,user = 用户配置,snap = 安装时的默认快照。
63
+ // 用户值 == 快照值 -> 这个键用户没动过 -> 用**当前默认**(默认值的改进能自动到达老用户)
64
+ // 用户值 != 快照值 -> 用户改过 -> 保留用户值
65
+ // 快照里没有这个键 -> 用户自己加的 -> 保留
66
+ // 默认里有、用户没有 -> 新键 -> 直接用默认(同 mergeDefaults)
67
+ // 🔴 没有快照时(0.1.2 之前装的)**不敢猜**,退回「用户值优先」的老行为 —— 判断不了就别动用户的值。
68
+ export function mergeThreeWay(def, user, snap) {
69
+ if (!snap) { return mergeDefaults(def, user); }
70
+ if (!isPlainObject(user)) { return user; }
71
+ const out = { ...(isPlainObject(def) ? def : {}) };
72
+ for (const k of Object.keys(user)) {
73
+ const d = isPlainObject(def) ? def[k] : undefined;
74
+ const u = user[k];
75
+ const hasSnap = isPlainObject(snap) && Object.prototype.hasOwnProperty.call(snap, k);
76
+ if (!hasSnap) { out[k] = u; continue; }
77
+ if (isPlainObject(u) && isPlainObject(snap[k])) { out[k] = mergeThreeWay(d ?? {}, u, snap[k]); continue; }
78
+ out[k] = deepEqual(u, snap[k]) ? (d === undefined ? u : d) : u;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // 用户配置与当前默认的差异(供 `ghprobe config-diff` 用)。
84
+ // 对老用户来说,他配置里的值**全部**来自旧版默认 —— 所以任何差异都是「你可能想采纳的默认改动」。
85
+ export function configDiff() {
86
+ const def = readYamlSafe(path.join(ROOT, "config.default.yaml"));
87
+ const user = readYamlSafe(userConfigPath());
88
+ if (!user) { return { hasUser: false, diff: [] }; }
89
+ const out = [];
90
+ const walk = (d, u, prefix) => {
91
+ const keys = new Set([...Object.keys(d || {}), ...Object.keys(u || {})]);
92
+ for (const k of keys) {
93
+ const path0 = prefix ? `${prefix}.${k}` : k;
94
+ const dv = isPlainObject(d) ? d[k] : undefined;
95
+ const uv = isPlainObject(u) ? u[k] : undefined;
96
+ if (isPlainObject(dv) && isPlainObject(uv)) { walk(dv, uv, path0); continue; }
97
+ if (!deepEqual(dv, uv)) { out.push({ key: path0, user: uv, def: dv }); }
98
+ }
99
+ };
100
+ walk(def, user, "");
101
+ return { hasUser: true, diff: out };
102
+ }
103
+
33
104
  // 把「自动更新的池缓存」并进 ipPools.auto(池名固定为 auto,供 domainPools 引用)。
34
105
  // 缓存不存在时什么都不做 —— 探测侧对缺失的池名是宽容的(会退回「用全部池」)。
35
106
  function withPoolCache(cfg) {
@@ -40,25 +111,65 @@ function withPoolCache(cfg) {
40
111
  return cfg;
41
112
  }
42
113
 
114
+ // fallbacks 归一化:既支持简写 `[域名]`,也支持完整写法 `[{ domain, path, mustContain }]`。
115
+ // 归一化后:
116
+ // cfg.fallbacks = { 主域名: [替身域名...] } —— 下游(targetDomains / neededFallbacks)不用改
117
+ // cfg.fallbackSigs = { 替身域名: { path, mustContain } } —— 供 prober 用该替身自己的探测判据
118
+ //
119
+ // 🔴 为什么需要:兜底域名本身也可能被封(实测 2026-10-11:`cdn.jsdelivr.net` 在当前网络下
120
+ // TCP 连得上但 TLS 被掐),所以配置里要给**多个候选**、探测时挑当下能通的那个。
121
+ // 而不同替身服务的 URL 形态不同 —— CDN 映射是 `/gh/<user>/<repo>@<ref>/<path>`,
122
+ // 代理是 `/<完整原始 URL>` —— 判据没法写死在代码里,必须跟着配置走。
123
+ export function normalizeFallbacks(cfg) {
124
+ const raw = cfg.fallbacks || {};
125
+ const fallbacks = {};
126
+ const fallbackSigs = {};
127
+ for (const [primary, list] of Object.entries(raw)) {
128
+ const domains = [];
129
+ for (const item of (Array.isArray(list) ? list : [])) {
130
+ if (typeof item === "string") { domains.push(item); continue; }
131
+ if (item && typeof item === "object" && item.domain) {
132
+ domains.push(item.domain);
133
+ const sig = {};
134
+ if (item.path) { sig.path = item.path; }
135
+ if (Array.isArray(item.mustContain) && item.mustContain.length) { sig.mustContain = item.mustContain; }
136
+ if (Object.keys(sig).length) { fallbackSigs[item.domain] = sig; }
137
+ }
138
+ }
139
+ fallbacks[primary] = domains;
140
+ }
141
+ cfg.fallbacks = fallbacks;
142
+ cfg.fallbackSigs = fallbackSigs;
143
+ return cfg;
144
+ }
145
+
43
146
  // 优先级:--config 显式指定 > 用户目录 config.yaml > 包内 config.default.yaml
147
+ // 用户目录那份走**三方合并**(见 mergeThreeWay):用户没改过的键会跟随最新默认值。
44
148
  export function loadConfig(userPath) {
45
149
  const def = yaml.load(fs.readFileSync(path.join(ROOT, "config.default.yaml"), "utf8"));
46
150
  let user = null;
47
151
  for (const p of [userPath, userConfigPath()]) {
48
152
  if (!p) { continue; }
49
- try { user = yaml.load(fs.readFileSync(p, "utf8")); break; } catch { /* 不存在 / 读不了 -> 下一个 */ }
153
+ const y = readYamlSafe(p);
154
+ if (y) { user = y; break; }
50
155
  }
51
- return withPoolCache(user ? mergeDefaults(def, user) : def);
156
+ if (!user) { return withPoolCache(normalizeFallbacks(def)); }
157
+ const snap = readYamlSafe(userConfigSnapshotPath());
158
+ return withPoolCache(normalizeFallbacks(mergeThreeWay(def, user, snap)));
52
159
  }
53
160
 
54
- // 首次 install 时把包内默认配置复制成用户配置(已存在则不覆盖,保留用户改动)
161
+ // 首次 install 时把包内默认配置复制成用户配置(已存在则不覆盖,保留用户改动);
162
+ // 快照缺失时补一份(= 当前默认),供三方合并判断「用户改过哪些键」。
163
+ // ⚠️ 快照**只在缺失时创建,不随升级刷新** —— 它代表「用户拿到配置时默认长什么样」这个基准,
164
+ // 一刷新基准就漂了,用户没改过的键也会被误判成改过。
55
165
  export function ensureUserConfig() {
56
166
  const p = userConfigPath();
167
+ const defPath = path.join(ROOT, "config.default.yaml");
57
168
  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
- }
169
+ fs.mkdirSync(path.dirname(p), { recursive: true });
170
+ if (!fs.existsSync(p)) { fs.copyFileSync(defPath, p); }
171
+ const snap = userConfigSnapshotPath();
172
+ if (!fs.existsSync(snap)) { fs.copyFileSync(defPath, snap); }
62
173
  } catch { /* 写不了不阻塞安装 */ }
63
174
  return p;
64
175
  }
package/src/prober.js CHANGED
@@ -140,10 +140,30 @@ export function checkHttpResponse(statusCode, body, location, sig) {
140
140
  return { ok: false, err: `status:${statusCode}` };
141
141
  }
142
142
 
143
+ // 未在 DOMAIN_SIG 里声明、也没有配置判据时的兜底判据
144
+ const DEFAULT_SIG = {
145
+ path: "/",
146
+ mustContain: [],
147
+ allow: [200, 204, 301, 302, 303, 304, 307, 308],
148
+ reject: ["unknown domain", "Fastly error", "bad request"],
149
+ redirectHosts: [],
150
+ };
151
+
152
+ // 取某域名的探测判据:**配置里 fallbackSigs 声明的优先于代码里的 DOMAIN_SIG**。
153
+ // 🔴 为什么替身域名必须能自带判据:不同替身服务的 URL 形态不同 ——
154
+ // CDN 映射是 `/gh/<user>/<repo>@<ref>/<path>`,代理是 `/<完整原始 URL>` ——
155
+ // 判据没法写死在代码里,用户新增一个替身也不该被要求改源码。
156
+ export function sigFor(domain, cfg) {
157
+ const override = cfg?.fallbackSigs?.[domain];
158
+ const base = DOMAIN_SIG[domain];
159
+ if (!base && !override) { return DEFAULT_SIG; }
160
+ return { ...DEFAULT_SIG, ...(base || {}), ...(override || {}) };
161
+ }
162
+
143
163
  // 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
144
- function httpProbe(ip, domain, httpMs) {
164
+ function httpProbe(ip, domain, httpMs, cfg) {
145
165
  return new Promise((resolve) => {
146
- const sig = DOMAIN_SIG[domain] || { path: "/", mustContain: [], allow: [200, 204, 301, 302, 303, 304, 307, 308], reject: ["unknown domain", "Fastly error", "bad request"], redirectHosts: [] };
166
+ const sig = sigFor(domain, cfg);
147
167
  const t0 = Date.now();
148
168
  const req = https.request({
149
169
  host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
@@ -186,7 +206,7 @@ export async function probeIp(domain, ip, cfg) {
186
206
  if (!g.ok) {
187
207
  return { domain, ip, ok: false, stage: g.stage, err: g.err, tlsBlocked: looksLikeTlsBlocked(g) };
188
208
  }
189
- const h = await httpProbe(ip, domain, httpMs);
209
+ const h = await httpProbe(ip, domain, httpMs, cfg);
190
210
  const tlsCost = g.tlsMs - g.tcpMs;
191
211
  const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
192
212
  return {