ghprobe 0.1.0 → 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/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";
@@ -60,7 +60,9 @@ if (process.platform !== "win32") {
60
60
  process.exit(1);
61
61
  }
62
62
  const program = new Command();
63
- program.name("ghprobe").description("GitHub 直连优选:本地 DoH + TLS 证书终审探测 + hosts 生成(无代理)").version("0.1.0");
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);
64
66
 
65
67
  // 打印一轮结果的可读摘要
66
68
  function printSummary(r) {
@@ -271,10 +273,33 @@ program.command("update-pool")
271
273
  });
272
274
 
273
275
  program.command("config-path")
274
- .description("打印用户配置文件与池缓存的路径(配置文件可直接编辑)")
276
+ .description("打印用户配置文件、默认配置快照与池缓存的路径(配置文件可直接编辑)")
275
277
  .action(() => {
276
278
  console.log(`配置:${userConfigPath()}`);
279
+ console.log(`快照:${userConfigSnapshotPath()}(安装时的默认配置,用来判断你改过哪些键)`);
277
280
  console.log(`池缓存:${poolCachePath()}`);
278
281
  });
279
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
+
280
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.0",
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",
@@ -25,9 +25,13 @@
25
25
  },
26
26
  "author": "zmw",
27
27
  "type": "module",
28
- "bin": { "ghprobe": "bin/ghprobe.js" },
28
+ "bin": {
29
+ "ghprobe": "bin/ghprobe.js"
30
+ },
29
31
  "main": "bin/ghprobe.js",
30
- "engines": { "node": ">=18" },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
31
35
  "license": "GPL-3.0-only",
32
36
  "files": [
33
37
  "bin/",
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
  }