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/src/health.js CHANGED
@@ -10,13 +10,17 @@ import { applyHosts, mergeHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock }
10
10
  const BEGIN = "# === ghprobe begin ===";
11
11
  const END = "# === ghprobe end ===";
12
12
 
13
- // 从 hosts 内容解析 ghprobe 块:有效 IP 映射、同名备选 IP、缺失域名冷却表
14
- // 块内行格式:`IP 域名`(同域名可多行:第一条 = 主 IP,其余 = 备选)或 `# absent 域名 时间戳`
13
+ // 从 hosts 内容解析 ghprobe 块:有效 IP 映射、同名备选 IP、缺失域名冷却表、指纹阻断表
14
+ // 块内行格式:
15
+ // `IP 域名`(同域名可多行:第一条 = 主 IP,其余 = 备选)
16
+ // `# absent 域名 时间戳` —— 探测过、确实没有可用 IP
17
+ // `# blocked 域名 时间戳` —— 探测不可判:TCP 连上了但 TLS 被本机指纹阻断(详见 prober.js)
15
18
  // 主 IP 进 entries(决策/探活只看它),备选进 extras(原样写回,供客户端 fallback)
16
19
  export function parseBlock(content) {
17
20
  const entries = new Map();
18
21
  const extras = new Map();
19
22
  const absent = new Map();
23
+ const blocked = new Map();
20
24
  let inBlock = false;
21
25
  for (const raw of String(content || "").split(/\r?\n/)) {
22
26
  const line = raw.trim();
@@ -24,8 +28,10 @@ export function parseBlock(content) {
24
28
  if (line.startsWith(END)) { inBlock = false; continue; }
25
29
  if (!inBlock) { continue; }
26
30
  if (line.startsWith("#")) {
27
- const m = line.match(/^#\s+absent\s+(\S+)\s+(\S+)/);
28
- if (m) { absent.set(m[1].toLowerCase(), m[2]); }
31
+ const ma = line.match(/^#\s+absent\s+(\S+)\s+(\S+)/);
32
+ if (ma) { absent.set(ma[1].toLowerCase(), ma[2]); continue; }
33
+ const mb = line.match(/^#\s+blocked\s+(\S+)\s+(\S+)/);
34
+ if (mb) { blocked.set(mb[1].toLowerCase(), mb[2]); }
29
35
  continue;
30
36
  }
31
37
  const parts = line.split(/\s+/);
@@ -38,12 +44,12 @@ export function parseBlock(content) {
38
44
  if (!list.includes(ip)) { list.push(ip); extras.set(domain, list); }
39
45
  }
40
46
  }
41
- return { entries, extras, absent };
47
+ return { entries, extras, absent, blocked };
42
48
  }
43
49
 
44
- // 生成健康检查用的 hosts 块:有效条目(每域名 主 IP + 备选)+ 缺失域名冷却注释
50
+ // 生成健康检查用的 hosts 块:有效条目(每域名 主 IP + 备选)+ 缺失域名冷却注释 + 指纹阻断注释
45
51
  // extras:domain -> [备选 IP...]。同域名多行让客户端在首个 IP 抖动时可自动 fallback。
46
- export function buildHealthBlock(entries, absent, extras = new Map()) {
52
+ export function buildHealthBlock(entries, absent, extras = new Map(), blocked = new Map()) {
47
53
  const lines = [BEGIN, `# generated ${new Date().toISOString()} by ghprobe`];
48
54
  for (const [domain, ip] of entries) {
49
55
  lines.push(`${ip} ${domain}`);
@@ -52,6 +58,7 @@ export function buildHealthBlock(entries, absent, extras = new Map()) {
52
58
  }
53
59
  }
54
60
  for (const [domain, ts] of absent) { lines.push(`# absent ${domain} ${ts}`); }
61
+ for (const [domain, ts] of blocked) { lines.push(`# blocked ${domain} ${ts}`); }
55
62
  lines.push(END);
56
63
  return lines.join("\n");
57
64
  }
@@ -78,9 +85,15 @@ export function blockFromProbeResult(cfg, r) {
78
85
  for (const [d, rs] of r.ranked || []) { put(d, rs); }
79
86
  for (const [d, rs] of r.extra || []) { put(d, rs); }
80
87
  const absent = new Map();
88
+ const blocked = new Map();
89
+ const tlsBlocked = blockedDomainsFromRaw(r.raw);
81
90
  const now = new Date().toISOString();
82
- for (const d of targetDomains(cfg)) { if (!entries.has(d)) { absent.set(d, now); } }
83
- return buildHealthBlock(entries, absent, extras);
91
+ for (const d of targetDomains(cfg)) {
92
+ if (entries.has(d)) { continue; }
93
+ // 与 health 路径保持同构:指纹阻断写 `# blocked`,确实无可用 IP 写 `# absent`
94
+ if (tlsBlocked.has(d)) { blocked.set(d, now); } else { absent.set(d, now); }
95
+ }
96
+ return buildHealthBlock(entries, absent, extras, blocked);
84
97
  }
85
98
 
86
99
  // 全灭护栏(写入前判定):新块一条有效 IP 都没有、而 hosts 里还有旧条目时,必须拒绝写入 ——
@@ -89,11 +102,33 @@ export function shouldKeepOldBlock(oldContent, newBlock) {
89
102
  return parseBlock(newBlock).entries.size === 0 && parseBlock(oldContent).entries.size > 0;
90
103
  }
91
104
 
92
- // 决策(纯函数):输入已写入 IP 的探活结果 + 当前块状态,输出动作
105
+ // 决策(纯函数):输入「已写入条目的探活结果」+ 当前块状态,输出动作
93
106
  // 横向判据:严格多数条目同时失败视为网络故障(本轮不动,避开黑洞窗口);
94
107
  // 单条目失败或缺失域名(非冷却)才触发全量重排
108
+ //
109
+ // 🔴 2026-10-11 两个关键修正:
110
+ // ① 探活结果现在**同时包含主条目与备选条目**(每条带 role: "main" / "alt")。
111
+ // 一个域名只有「它所有已写入条目全部失败」才算挂 —— 备选还活着时不该重排。
112
+ // ② 主挂了、备选活着 → **把备选提上来(promote)**,不重排。
113
+ // 这条是针对实测故障的:主 IP 变成假活(返回 400)而备选是好的,旧逻辑不知道备选存在,
114
+ // 直接去重排 → 撞上 IP 抖动窗口 → 换了个更差的,用户反而断网。
115
+ // 备选本来就是为了这一刻存在的,不该只写不读。
95
116
  export function decide(probeResults, current, cfg) {
96
- const failed = new Set(probeResults.filter((r) => !r.ok).map((r) => r.domain));
117
+ // 按域名聚合:只有全部条目都失败才算这个域名挂了
118
+ const byDomain = new Map();
119
+ for (const r of probeResults) {
120
+ if (!byDomain.has(r.domain)) { byDomain.set(r.domain, []); }
121
+ byDomain.get(r.domain).push(r);
122
+ }
123
+ const failed = new Set();
124
+ const promote = new Map();
125
+ for (const [d, rs] of byDomain) {
126
+ const alive = rs.filter((r) => r.ok);
127
+ if (alive.length === 0) { failed.add(d); continue; }
128
+ // 主条目失败但有备选活着 -> 提升备选(不重排)
129
+ const main = rs.find((r) => r.role !== "alt");
130
+ if (main && !main.ok) { promote.set(d, alive[0].ip); }
131
+ }
97
132
  const total = current.entries.size;
98
133
 
99
134
  // 只有「已写入条目全部失败」才可能是网络级故障(黑洞窗口),此时本轮不动、避开窗口。
@@ -101,7 +136,7 @@ export function decide(probeResults, current, cfg) {
101
136
  // 🔴 旧判据 `failed.size * 2 > total`(多数失败即放弃)已证实会误触发:分母只数已写入
102
137
  // 条目、常低到 3~4,实测「3 条陈旧 + 1 条健康」被误判成断网 → 该修的时候不修。
103
138
  if (total >= 2 && failed.size === total) {
104
- return { action: "none", reason: "全部已写入 IP 同时失败,疑似网络故障" };
139
+ return { action: "none", reason: "全部已写入 IP 同时失败,疑似网络故障", promote: new Map() };
105
140
  }
106
141
 
107
142
  const cooldownMs = (cfg.schedule?.absentCooldownHours ?? 1) * 3600_000;
@@ -110,7 +145,9 @@ export function decide(probeResults, current, cfg) {
110
145
  const missing = new Set();
111
146
  for (const d of targets) {
112
147
  if (current.entries.has(d)) { continue; }
113
- const ts = current.absent.get(d);
148
+ // `# absent`(确实无可用 IP)与 `# blocked`(探测不可判:本机 TLS 指纹被阻断)
149
+ // 共用同一档冷却 —— 两者都不该每分钟重探一遍
150
+ const ts = current.absent.get(d) || current.blocked?.get(d);
114
151
  if (ts && now - new Date(ts).getTime() < cooldownMs) { continue; }
115
152
  missing.add(d);
116
153
  }
@@ -118,33 +155,80 @@ export function decide(probeResults, current, cfg) {
118
155
  const orphan = [...current.entries.keys()].filter((d) => !targets.includes(d));
119
156
 
120
157
  const diagnose = [...new Set([...failed, ...missing, ...orphan])];
121
- if (diagnose.length === 0) { return { action: "none", reason: "全部健康" }; }
122
- return { action: "full", domains: diagnose, reason: `需重排: ${diagnose.join(", ")}` };
158
+ if (diagnose.length === 0 && promote.size === 0) {
159
+ return { action: "none", reason: "全部健康", promote: new Map() };
160
+ }
161
+ if (diagnose.length === 0) {
162
+ return { action: "full", domains: [], promote, reason: `主 IP 失效,提上备选: ${[...promote.keys()].join(", ")}` };
163
+ }
164
+ return { action: "full", domains: diagnose, promote, reason: `需重排: ${diagnose.join(", ")}` };
123
165
  }
124
166
 
125
167
  // 部分更新(纯函数):只改 diagnose 域名;全灭护栏保证不把好 IP 写成空
126
168
  // 返回的新 entries/absent 用于与当前对比,changed 记录真正换掉 IP 的域名
127
169
  // stuck:被诊断了、但本轮一个活 IP 都没找到、只能保留旧值的域名(黑洞窗口里必然出现,
128
170
  // 显式报出来,否则日志只写「无变化」会让人以为工具没干活)
129
- export function applyDelta(current, ranked, extra, diagnose, cfg) {
171
+ // 从一轮原始探测结果里挑出「指纹被阻断」的域名:
172
+ // 该域名所有候选都失败了,且**失败全部发生在 TLS 阶段(TCP 已连上)** —— 见 prober.js 的说明。
173
+ // 只有全部失败都是这一类才判定,避免把「一半 IP 黑洞 + 一半被 reset」误当成指纹阻断。
174
+ export function blockedDomainsFromRaw(raw) {
175
+ const by = new Map();
176
+ for (const r of raw || []) {
177
+ if (!by.has(r.domain)) { by.set(r.domain, { total: 0, alive: 0, blocked: 0 }); }
178
+ const e = by.get(r.domain);
179
+ e.total++;
180
+ if (r.ok) { e.alive++; } else if (r.tlsBlocked) { e.blocked++; }
181
+ }
182
+ const out = new Set();
183
+ for (const [d, e] of by) {
184
+ if (e.alive === 0 && e.blocked > 0 && e.blocked === e.total) { out.add(d); }
185
+ }
186
+ return out;
187
+ }
188
+
189
+ export function applyDelta(current, ranked, extra, diagnose, cfg, promote = new Map(), tlsBlocked = new Set()) {
130
190
  const entries = new Map(current.entries);
131
191
  const extras = new Map(current.extras || []);
132
192
  const absent = new Map(current.absent);
193
+ const blocked = new Map(current.blocked || []);
133
194
  const changed = [];
134
195
  const now = new Date().toISOString();
135
196
  const allResult = new Map([...ranked, ...extra]);
136
197
  const maxPer = Math.max(1, cfg.hosts?.maxEntriesPerDomain ?? 2);
137
198
 
199
+ // 0) 提升:主 IP 失效、备选还活着 -> 备选上位,旧主降为备选。
200
+ // 旧主留着当兜底 —— GitHub 的 IP 是分钟级抖动,它可能下一分钟就活了。
201
+ for (const [d, ip] of promote) {
202
+ const oldMain = entries.get(d);
203
+ const alts = (extras.get(d) || []).filter((x) => x !== ip);
204
+ if (oldMain && oldMain !== ip && !alts.includes(oldMain)) { alts.unshift(oldMain); }
205
+ entries.set(d, ip);
206
+ const kept = alts.slice(0, maxPer - 1);
207
+ if (kept.length) { extras.set(d, kept); } else { extras.delete(d); }
208
+ absent.delete(d);
209
+ changed.push(d);
210
+ }
211
+
138
212
  for (const d of diagnose) {
139
213
  const list = allResult.get(d) || [];
140
214
  const best = list[0];
141
215
  if (best) {
142
216
  if (entries.get(d) !== best.ip) { entries.set(d, best.ip); changed.push(d); }
143
- const alt = list.slice(1, maxPer).map((x) => x.ip);
144
- if (alt.length) { extras.set(d, alt); } else { extras.delete(d); }
217
+ // 🔴 备选是「合并」而不是「覆盖」:本轮只探到 1 个活 IP 时,把原有备选保留下来。
218
+ // 旧写法 `alt.length ? set : delete` 会在抖动窗口里(活 IP 少)顺手删掉备选 ——
219
+ // 恰恰在最需要 fallback 的时候把兜底丢了。新的排前、旧的排后,超出 maxPer 截断。
220
+ const freshAlt = list.slice(1, maxPer).map((x) => x.ip);
221
+ const oldAlt = (extras.get(d) || []).filter((x) => x !== best.ip);
222
+ const merged = [...new Set([...freshAlt, ...oldAlt])].slice(0, maxPer - 1);
223
+ if (merged.length) { extras.set(d, merged); } else { extras.delete(d); }
145
224
  absent.delete(d);
225
+ blocked.delete(d);
146
226
  } else if (!entries.has(d)) {
147
- absent.set(d, now);
227
+ // 🔴 指纹被阻断 ≠ 不可达:不写 absent,写 blocked。
228
+ // 两者共用同一档冷却(decide 里查),但语义不同 —— blocked 表示
229
+ // 「本机探测客户端(Node)的 TLS 指纹被识别,用户的浏览器很可能照样能访问」,
230
+ // 写 absent 会让工具主动放弃一个其实可用的域名,日志也会误导。
231
+ if (tlsBlocked.has(d)) { blocked.set(d, now); } else { absent.set(d, now); }
148
232
  }
149
233
  }
150
234
 
@@ -158,7 +242,9 @@ export function applyDelta(current, ranked, extra, diagnose, cfg) {
158
242
  }
159
243
  // orphan 已在上一步删掉,这里只剩「配置内、诊断过、却没探到活 IP、于是保留旧值」的域名
160
244
  const stuck = diagnose.filter((d) => allow.has(d) && entries.has(d) && !(allResult.get(d) || []).length);
161
- return { entries, extras, absent, changed, stuck };
245
+ // 指纹被阻断的域名(没有写进 entries,也不是 absent)
246
+ const sniBlocked = diagnose.filter((d) => allow.has(d) && !entries.has(d) && tlsBlocked.has(d));
247
+ return { entries, extras, absent, blocked, changed, stuck, sniBlocked };
162
248
  }
163
249
 
164
250
  function sameMap(a, b) {
@@ -190,10 +276,18 @@ export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS)
190
276
  return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath);
191
277
  }
192
278
 
193
- const jobs = [...current.entries].map(([d, ip]) => () => probeIp(d, ip, cfg));
194
- const probeResults = await pool(jobs, cfg.probe.concurrency);
279
+ // 🔴 主条目 + 备选条目一起探活。旧版只探主条目 —— 于是「主 IP 挂了、备选还是好的」
280
+ // 这种最常见的情况工具根本不知道,直接去重排(撞抖动窗口),而它本来只需把备选提上来。
281
+ const pairs = [];
282
+ for (const [d, ip] of current.entries) {
283
+ pairs.push([d, ip, "main"]);
284
+ for (const alt of current.extras.get(d) || []) { pairs.push([d, alt, "alt"]); }
285
+ }
286
+ const jobs = pairs.map(([d, ip]) => () => probeIp(d, ip, cfg));
287
+ const probed = await pool(jobs, cfg.probe.concurrency);
288
+ const probeResults = probed.map((r, i) => ({ ...r, role: pairs[i][2] }));
195
289
  for (const r of probeResults) {
196
- say(`${r.ok ? "[OK ]" : "[FAIL]"} ${r.domain} @ ${r.ip} err=${r.err}`);
290
+ say(`${r.ok ? "[OK ]" : "[FAIL]"}${r.role === "alt" ? " ALT" : " "} ${r.domain} @ ${r.ip} err=${r.err}`);
197
291
  }
198
292
 
199
293
  const decision = decide(probeResults, current, cfg);
@@ -203,28 +297,40 @@ export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS)
203
297
  }
204
298
 
205
299
  say(`异常:${decision.reason}`);
206
- return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath);
300
+ return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote);
207
301
  }
208
302
 
209
303
  // 全量探测 + 部分更新 + 写回;护栏与粘性都在 applyDelta 里体现。
210
304
  // 只探 diagnose 里的域名:典型场景是「github.com 的 IP 死了,codeload / jsdelivr 还活着」,
211
305
  // 此时探 1 个域名(~8 个候选)而不是 6 个(~48 个),失败发现到修好的时间从 ~90s 压到 ~20s。
212
- async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS) {
306
+ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map()) {
213
307
  const say = log || (() => {});
214
- const r = await runProbe(cfg, log, diagnose);
215
- const d = applyDelta(current, r.ranked, r.extra, diagnose, cfg);
308
+ // diagnose 为空 = 只有「提上备选」要做,没有需要重排的域名 -> 跳过探测,直接写提升结果
309
+ const r = diagnose.length ? await runProbe(cfg, log, diagnose) : { ranked: new Map(), extra: new Map(), raw: [] };
310
+ const tlsBlocked = blockedDomainsFromRaw(r.raw);
311
+ const d = applyDelta(current, r.ranked, r.extra, diagnose, cfg, promote, tlsBlocked);
216
312
 
217
313
  const entriesChanged = !sameMap(d.entries, current.entries);
218
314
  const absentChanged = !sameMap(d.absent, current.absent);
315
+ const blockedChanged = !sameMap(d.blocked, current.blocked || new Map());
219
316
  const extrasChanged = !sameExtras(d.extras, current.extras || new Map());
220
317
  const stuckNote = d.stuck.length ? `;无可用 IP 保留原值: ${d.stuck.join(", ")}` : "";
221
- if (!entriesChanged && !absentChanged && !extrasChanged) {
222
- say(`探测无变化,保持现状(护栏/粘性生效)${stuckNote}`);
223
- return { action: "none", reason: `无变化${stuckNote}`, block: null, stuck: d.stuck };
318
+ const blockedNote = d.sniBlocked.length
319
+ ? `;探测不可判(本机 TLS 指纹被阻断,浏览器可能仍可用): ${d.sniBlocked.join(", ")}` : "";
320
+ if (!entriesChanged && !absentChanged && !blockedChanged && !extrasChanged) {
321
+ say(`探测无变化,保持现状(护栏/粘性生效)${stuckNote}${blockedNote}`);
322
+ return { action: "none", reason: `无变化${stuckNote}${blockedNote}`, block: null, stuck: d.stuck };
224
323
  }
225
324
 
226
- const block = buildHealthBlock(d.entries, d.absent, d.extras);
227
- const msg = d.changed.length ? `更新域名: ${d.changed.join(", ")}` : "更新缺失域名冷却记录";
325
+ const block = buildHealthBlock(d.entries, d.absent, d.extras, d.blocked);
326
+ // 日志里把「提上备选」和「重排换 IP」分开写,否则看日志分不清是哪种动作
327
+ const promoted = [...promote.keys()];
328
+ const reranked = d.changed.filter((x) => !promote.has(x));
329
+ const parts = [];
330
+ if (promoted.length) { parts.push(`提上备选: ${promoted.join(", ")}`); }
331
+ if (reranked.length) { parts.push(`重排换 IP: ${reranked.join(", ")}`); }
332
+ if (d.sniBlocked.length) { parts.push(`指纹阻断: ${d.sniBlocked.join(", ")}`); }
333
+ const msg = parts.length ? parts.join(";") : "更新缺失域名冷却记录";
228
334
  if (write) {
229
335
  const lock = acquireHostsLock();
230
336
  if (!lock) {
package/src/prober.js CHANGED
@@ -7,54 +7,69 @@ import https from "node:https";
7
7
 
8
8
  // 目标域名的应用层指纹:真实业务路径、必须包含的响应特征、允许状态码、拒绝特征、允许跳转的 host
9
9
  // 路径必须能代表真实业务;codeload 用归档下载路径,避免根路径 301 到 github.com 主站骗过校验
10
+ //
11
+ // 🔴 状态码白名单的取舍(2026-10-11 实测踩坑):
12
+ // 白名单里放 4xx 等于给「不服务该域名的假活 IP」发通行证 —— 它们 TCP/TLS 都通、
13
+ // 证书也合法(同属 GitHub 的 CDN),但 HTTP 层对所有路径回 4xx。实测
14
+ // `20.205.243.161` 对 github.com 的 `/`、`/login` 全回 **400**,响应体还是
15
+ // **GitHub 自己的错误页**(`<title>Bad request &middot; GitHub</title>`)——
16
+ // 于是 `mustContain:["github"]` 也命中,三道判据全部放行,该 IP 被排到第 1 名写进 hosts,
17
+ // 用户拿到 400。更糟的是「同名多条目 fallback」兜不住它:**400 是合法 HTTP 响应,
18
+ // 不是连接失败,客户端不会回退到备选**。
19
+ // → 规则:**路径 `/` 只接受 200/3xx**。只有那些「根路径本来就不存在合法内容」的域名
20
+ // (objects 根路径是 Varnish 的 404、gist 是 301)才保留 4xx,且靠 reject 词表兜底。
10
21
  export const DOMAIN_SIG = {
11
22
  "github.com": {
12
23
  path: "/",
13
24
  mustContain: ["github"],
14
- allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404],
15
- reject: ["unknown domain", "Fastly error"],
25
+ // 只留 2xx/3xx:根路径必须真的返回 GitHub 首页(或跳转到 github.com)
26
+ allow: [200, 204, 301, 302, 303, 304, 307, 308],
27
+ reject: ["unknown domain", "Fastly error", "bad request"],
16
28
  redirectHosts: ["github.com"],
17
29
  },
18
30
  "codeload.github.com": {
19
31
  path: "/torvalds/linux/tar.gz/refs/heads/master",
20
32
  mustContain: [],
21
33
  allow: [200, 204, 302, 303, 304, 307, 308],
22
- reject: ["unknown domain", "Fastly error"],
34
+ reject: ["unknown domain", "Fastly error", "bad request"],
23
35
  redirectHosts: ["codeload.github.com", "objects.githubusercontent.com"],
24
36
  },
25
37
  "objects.githubusercontent.com": {
38
+ // ⚠️ 根路径必然 404(实测 Varnish 返回纯 "404 Not Found",响应体里没有 "github"),
39
+ // 所以这里无法用 mustContain 兜底,4xx 必须留在白名单里 —— 是已知的判据弱点,
40
+ // 实际靠 reject 词表(unknown domain / Fastly error)拦假活节点。
26
41
  path: "/",
27
42
  mustContain: [],
28
43
  allow: [200, 204, 302, 303, 304, 307, 308, 400, 401, 403, 404],
29
- reject: ["unknown domain", "Fastly error"],
44
+ reject: ["unknown domain", "Fastly error", "bad request"],
30
45
  redirectHosts: ["objects.githubusercontent.com"],
31
46
  },
32
47
  "raw.githubusercontent.com": {
33
48
  path: "/torvalds/linux/README.md",
34
49
  mustContain: ["Linux kernel"],
35
50
  allow: [200, 204, 302, 303, 304, 307, 308],
36
- reject: ["unknown domain", "Fastly error"],
51
+ reject: ["unknown domain", "Fastly error", "bad request"],
37
52
  redirectHosts: ["raw.githubusercontent.com", "objects.githubusercontent.com"],
38
53
  },
39
54
  "gist.githubusercontent.com": {
40
55
  path: "/",
41
56
  mustContain: [],
42
57
  allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404],
43
- reject: ["unknown domain", "Fastly error"],
58
+ reject: ["unknown domain", "Fastly error", "bad request"],
44
59
  redirectHosts: ["gist.githubusercontent.com", "gist.github.com"],
45
60
  },
46
61
  "avatars.githubusercontent.com": {
47
62
  path: "/u/1?v=4",
48
63
  mustContain: [],
49
64
  allow: [200, 204, 302, 303, 304, 307, 308],
50
- reject: ["unknown domain", "Fastly error"],
65
+ reject: ["unknown domain", "Fastly error", "bad request"],
51
66
  redirectHosts: ["avatars.githubusercontent.com"],
52
67
  },
53
68
  "cdn.jsdelivr.net": {
54
69
  path: "/gh/torvalds/linux@master/README",
55
70
  mustContain: ["Linux kernel"],
56
71
  allow: [200, 204, 301, 302, 303, 304, 307, 308],
57
- reject: ["unknown domain", "Fastly error"],
72
+ reject: ["unknown domain", "Fastly error", "bad request"],
58
73
  redirectHosts: ["cdn.jsdelivr.net", "www.jsdelivr.com", "jsdelivr.net"],
59
74
  },
60
75
  };
@@ -90,8 +105,7 @@ function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
90
105
  }
91
106
 
92
107
  // 读取响应体前 n 字节,用于特征校验;读够后立即断开,不下载完整大页面
93
- function readPrefix(res, maxBytes) {
94
- return new Promise((resolve) => {
108
+ function readPrefix(res, maxBytes) { return new Promise((resolve) => {
95
109
  const chunks = [];
96
110
  let got = 0;
97
111
  let settled = false;
@@ -126,10 +140,30 @@ export function checkHttpResponse(statusCode, body, location, sig) {
126
140
  return { ok: false, err: `status:${statusCode}` };
127
141
  }
128
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
+
129
163
  // 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
130
- function httpProbe(ip, domain, httpMs) {
164
+ function httpProbe(ip, domain, httpMs, cfg) {
131
165
  return new Promise((resolve) => {
132
- const sig = DOMAIN_SIG[domain] || { path: "/", mustContain: [], allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404], reject: ["unknown domain", "Fastly error"], redirectHosts: [] };
166
+ const sig = sigFor(domain, cfg);
133
167
  const t0 = Date.now();
134
168
  const req = https.request({
135
169
  host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
@@ -146,13 +180,33 @@ function httpProbe(ip, domain, httpMs) {
146
180
  });
147
181
  }
148
182
 
183
+ // 🔴 区分两类失败(2026-10-11 实测):
184
+ // ① stage=tcp —— TCP 握手就没成功(超时 / 拒绝)-> 这个 IP 确实不可用
185
+ // ② stage=tls —— **TCP 连上了,但 TLS 阶段被重置或永远不完成** -> 很可能不是 IP 的问题,
186
+ // 而是本机 TLS 指纹被中间设备识别后掐断(DPI 按 ClientHello 的形态放行/拦截)。
187
+ //
188
+ // 为什么必须区分:实测同一 IP、同一 SNI、同一时刻 ——
189
+ // openssl s_client 20/20 握手成功,node tls.connect 5/20,curl 3/20;
190
+ // 而浏览器(Edge)能正常打开同一个 URL。
191
+ // ghprobe 自己就是 Node,**它的探测客户端指纹和用户的浏览器不同** ——
192
+ // 把 ② 判成「IP 不可用」会把「浏览器其实能用」的域名误记成不可达(假阴性),
193
+ // 还会写进 `# absent` 冷却让工具主动放弃它。
194
+ const RESET_RE = /ECONNRESET|EPIPE|ECONNABORTED|socket hang up|Client network socket disconnected/i;
195
+ export function looksLikeTlsBlocked(failure) {
196
+ if (!failure || failure.ok || failure.stage !== "tls") { return false; }
197
+ const err = String(failure.err || "");
198
+ return RESET_RE.test(err) || err === "timeout";
199
+ }
200
+
149
201
  // 探测单个 (域名, IP):三关全过才算活;分数越小越优
150
202
  export async function probeIp(domain, ip, cfg) {
151
203
  const { tlsMs, httpMs } = cfg.timeouts;
152
204
  const tcpMs = cfg.timeouts.tcpMs ?? tlsMs; // 老配置没这一项时退回 tlsMs,行为不变
153
205
  const g = await tcpTlsProbe(ip, domain, tcpMs, tlsMs);
154
- if (!g.ok) { return { domain, ip, ok: false, stage: g.stage, err: g.err }; }
155
- const h = await httpProbe(ip, domain, httpMs);
206
+ if (!g.ok) {
207
+ return { domain, ip, ok: false, stage: g.stage, err: g.err, tlsBlocked: looksLikeTlsBlocked(g) };
208
+ }
209
+ const h = await httpProbe(ip, domain, httpMs, cfg);
156
210
  const tlsCost = g.tlsMs - g.tcpMs;
157
211
  const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
158
212
  return {