ghprobe 0.1.1 → 0.1.3

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";
@@ -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,4 +1,14 @@
1
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
+ # (本文件里的注释只是说明,不参与合并 —— 合并看的是键值。)
2
12
  # 目标域名族:每域名独立探测、独立写 hosts
3
13
  domains:
4
14
  - github.com
@@ -109,9 +119,23 @@ networkCheck:
109
119
  enabled: true
110
120
  probe: { host: 223.5.5.5, port: 443, timeoutMs: 2000 }
111
121
 
112
- # 被主机名黑名单掐掉的域名走兜底链:主候选全灭时改探测这些替身域名并写入
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
+ # 🔴 默认只保留公共 CDN:**不预置任何第三方代理 / 镜像域名**——本工具定位「无代理」,
135
+ # 预置代理域名既与该定位矛盾,也有法律风险。需要更多兜底请自行添加(示例见 README 第 6 节)。
113
136
  fallbacks:
114
- raw.githubusercontent.com: [cdn.jsdelivr.net]
137
+ raw.githubusercontent.com:
138
+ - cdn.jsdelivr.net
115
139
 
116
140
  # 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
117
141
  # 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ghprobe",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
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 {