ghprobe 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +138 -0
- package/README.en.md +28 -17
- package/README.md +32 -16
- package/bin/ghprobe.js +74 -6
- package/config.default.yaml +58 -7
- package/package.json +1 -1
- package/src/algorithm.js +173 -0
- package/src/daemon.js +147 -0
- package/src/doctor.js +174 -0
- package/src/health.js +28 -6
- package/src/pool.js +8 -5
- package/src/probe.js +16 -3
- package/src/prober.js +175 -9
- package/src/ranker.js +26 -0
- package/src/status.js +88 -0
package/src/health.js
CHANGED
|
@@ -265,15 +265,17 @@ function sameExtras(a, b) {
|
|
|
265
265
|
}
|
|
266
266
|
|
|
267
267
|
// 完整一轮健康检查:读 -> 测已写入 -> 决策 -> (异常时)全量探测 + 部分更新
|
|
268
|
-
// write=true 时才真正 applyHosts(会弹 UAC
|
|
269
|
-
|
|
268
|
+
// write=true 时才真正 applyHosts(会弹 UAC),否则仅预览。
|
|
269
|
+
// hooks.beforeApply(0.6.0,daemon 传):写前门控 —— meta = { deadDomains, initial, changed,
|
|
270
|
+
// reason, block, cfg },返回 false 表示「按用户行为算法推迟」,本轮不落盘、块由门控方保存。
|
|
271
|
+
export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS, promote = new Map(), hooks = {}) {
|
|
270
272
|
const say = log || (() => {});
|
|
271
273
|
const content = fs.readFileSync(hostsPath, "utf8");
|
|
272
274
|
const current = parseBlock(content);
|
|
273
275
|
|
|
274
276
|
if (current.entries.size === 0) {
|
|
275
277
|
say("hosts 无 ghprobe 记录,触发首次全量");
|
|
276
|
-
return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath);
|
|
278
|
+
return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath, promote, hooks);
|
|
277
279
|
}
|
|
278
280
|
|
|
279
281
|
// 🔴 主条目 + 备选条目一起探活。旧版只探主条目 —— 于是「主 IP 挂了、备选还是好的」
|
|
@@ -296,14 +298,22 @@ export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS)
|
|
|
296
298
|
return { action: "none", reason: decision.reason, block: null, stuck: [] };
|
|
297
299
|
}
|
|
298
300
|
|
|
301
|
+
// 主备全死的域名数(0.6.0):algorithm 门控的 P0 紧急度 —— 用户此刻真的断着
|
|
302
|
+
const byDomain = new Map();
|
|
303
|
+
for (const r of probeResults) {
|
|
304
|
+
if (!byDomain.has(r.domain)) { byDomain.set(r.domain, []); }
|
|
305
|
+
byDomain.get(r.domain).push(r);
|
|
306
|
+
}
|
|
307
|
+
const deadAll = [...byDomain.entries()].filter(([, rs]) => rs.every((r) => !r.ok)).map(([d]) => d);
|
|
308
|
+
|
|
299
309
|
say(`异常:${decision.reason}`);
|
|
300
|
-
return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote);
|
|
310
|
+
return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote, hooks, deadAll);
|
|
301
311
|
}
|
|
302
312
|
|
|
303
313
|
// 全量探测 + 部分更新 + 写回;护栏与粘性都在 applyDelta 里体现。
|
|
304
314
|
// 只探 diagnose 里的域名:典型场景是「github.com 的 IP 死了,codeload / jsdelivr 还活着」,
|
|
305
315
|
// 此时探 1 个域名(~8 个候选)而不是 6 个(~48 个),失败发现到修好的时间从 ~90s 压到 ~20s。
|
|
306
|
-
async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map()) {
|
|
316
|
+
async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map(), hooks = {}, deadAll = []) {
|
|
307
317
|
const say = log || (() => {});
|
|
308
318
|
// diagnose 为空 = 只有「提上备选」要做,没有需要重排的域名 -> 跳过探测,直接写提升结果
|
|
309
319
|
const r = diagnose.length ? await runProbe(cfg, log, diagnose) : { ranked: new Map(), extra: new Map(), raw: [] };
|
|
@@ -332,6 +342,18 @@ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath
|
|
|
332
342
|
if (d.sniBlocked.length) { parts.push(`指纹阻断: ${d.sniBlocked.join(", ")}`); }
|
|
333
343
|
const msg = parts.length ? parts.join(";") : "更新缺失域名冷却记录";
|
|
334
344
|
if (write) {
|
|
345
|
+
// 0.6.0 用户行为门控:P0(有主备全死的域名)/ 首次写入 直接过;
|
|
346
|
+
// 非紧急优化类在「用户正用浏览器」时被拒 —— 块由门控方保存,空闲后落地。
|
|
347
|
+
if (hooks.beforeApply) {
|
|
348
|
+
const ok = await hooks.beforeApply({
|
|
349
|
+
deadDomains: deadAll, initial: current.entries.size === 0,
|
|
350
|
+
changed: d.changed, reason: msg, block, cfg,
|
|
351
|
+
});
|
|
352
|
+
if (!ok) {
|
|
353
|
+
say(`按用户行为算法推迟写入(用户正在用浏览器;修复类不受限,上限 10 分钟)`);
|
|
354
|
+
return { action: "deferred", reason: "按 algorithm 推迟写入(浏览器使用中)", block, stuck: d.stuck };
|
|
355
|
+
}
|
|
356
|
+
}
|
|
335
357
|
const lock = acquireHostsLock();
|
|
336
358
|
if (!lock) {
|
|
337
359
|
say("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
|
|
@@ -340,7 +362,7 @@ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath
|
|
|
340
362
|
try {
|
|
341
363
|
// 锁内重读最新 hosts:探测耗时期间他人可能已改过,避免覆盖别人刚写的新值
|
|
342
364
|
const latest = fs.readFileSync(hostsPath, "utf8");
|
|
343
|
-
await applyHosts(mergeHosts(latest, block));
|
|
365
|
+
await applyHosts(mergeHosts(latest, block), hostsPath);
|
|
344
366
|
say(`已写入 hosts:${msg}${stuckNote}`);
|
|
345
367
|
} finally { releaseHostsLock(lock); }
|
|
346
368
|
} else {
|
package/src/pool.js
CHANGED
|
@@ -118,13 +118,16 @@ export function fetchFirstMeta(seeds, host, pathName, timeoutMs) {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
// 种子 IP:拉 meta 用的直连入口。优先用配置里的 seed 池,再退回 azure / fastly。
|
|
121
|
-
|
|
121
|
+
// extra:调用方传入的**健康 hosts 条目**(0.4.0 自救)——它们刚通过三关 + HTTP 终审,
|
|
122
|
+
// 是「当下确实能直连 GitHub」的 IP;当 seed 池被 SNI 整段封锁、静态种子全灭时,
|
|
123
|
+
// 这批 IP 往往是唯一还拉得到 meta 的入口,池子不至于永久冻结。
|
|
124
|
+
export function seedIps(cfg, extra = []) {
|
|
122
125
|
const p = cfg.ipPools || {};
|
|
123
|
-
return [...new Set([...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
|
|
126
|
+
return [...new Set([...extra, ...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
|
|
124
127
|
}
|
|
125
128
|
|
|
126
129
|
// 一轮更新:拉 meta -> 展开 -> 写缓存。失败也会写 lastAttempt(冷却),避免每分钟重试撞 60 次/小时限制。
|
|
127
|
-
export async function updatePool(cfg, log = () => {}) {
|
|
130
|
+
export async function updatePool(cfg, log = () => {}, extraSeeds = []) {
|
|
128
131
|
const u = cfg.poolUpdate || {};
|
|
129
132
|
const url = new URL(u.source || "https://api.github.com/meta");
|
|
130
133
|
const host = u.host || url.hostname;
|
|
@@ -133,8 +136,8 @@ export async function updatePool(cfg, log = () => {}) {
|
|
|
133
136
|
const attemptedAt = new Date().toISOString();
|
|
134
137
|
const prev = readPoolCache();
|
|
135
138
|
try {
|
|
136
|
-
const seeds = seedIps(cfg);
|
|
137
|
-
if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly
|
|
139
|
+
const seeds = seedIps(cfg, extraSeeds);
|
|
140
|
+
if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly 全为空,也没有健康 hosts 条目)"); }
|
|
138
141
|
// 并行试所有种子,第一个成功的立刻返回(实测 8.2s -> 亚秒级;串行时是 2m13s)。
|
|
139
142
|
const { json, via } = await fetchFirstMeta(seeds, host, pathName, timeoutMs);
|
|
140
143
|
|
package/src/probe.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// 一轮完整探测:DoH 候选 + 内置池 -> 三关并发探测 -> 排名 -> 兜底轮
|
|
4
4
|
import { resolveAll } from "./doh.js";
|
|
5
5
|
import { probeIp, pool } from "./prober.js";
|
|
6
|
-
import { rank, neededFallbacks } from "./ranker.js";
|
|
6
|
+
import { rank, neededFallbacks, mergeSamples } from "./ranker.js";
|
|
7
7
|
|
|
8
8
|
// 该域名该用哪些内置池。
|
|
9
9
|
// 默认(未配 domainPools 或配了不存在的池名)用全部池 —— 保持「与域名无关地全量进候选」
|
|
@@ -59,8 +59,21 @@ export async function runProbe(cfg, log, onlyDomains = null) {
|
|
|
59
59
|
const directFb = want ? [...want].filter((d) => fbDomains.includes(d) && !cfg.domains.includes(d)) : [];
|
|
60
60
|
|
|
61
61
|
const resolved = await resolveCandidates(cfg, mainDomains, say);
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
let all = await pool(jobsOf(cfg, resolved), cfg.probe.concurrency);
|
|
63
|
+
let ranked = rank(all, cfg);
|
|
64
|
+
|
|
65
|
+
// 复测抗抖动(0.3.0):对每域名当前前 3 名各再采一枪,均值 + 失败罚分后重排名。
|
|
66
|
+
// 代价极小(≤24 个快候选、约 1~2s),换来「写进 hosts 的第一名是真的稳」——
|
|
67
|
+
// 单采样时同一 IP 相邻两轮分差可达数倍,排名近乎随机,而无谓的换 IP 要刷 DNS 缓存。
|
|
68
|
+
if ((cfg.probe.stableSamples ?? 2) >= 2) {
|
|
69
|
+
const jobs = [];
|
|
70
|
+
for (const [, rs] of ranked) {
|
|
71
|
+
for (const r of rs.slice(0, 3)) { jobs.push(() => probeIp(r.domain, r.ip, cfg)); }
|
|
72
|
+
}
|
|
73
|
+
const second = await pool(jobs, cfg.probe.concurrency);
|
|
74
|
+
all = mergeSamples(all, second);
|
|
75
|
+
ranked = rank(all, cfg);
|
|
76
|
+
}
|
|
64
77
|
|
|
65
78
|
// 兜底轮:主域名全灭且配了替身 -> 探测替身域名
|
|
66
79
|
const extra = new Map();
|
package/src/prober.js
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
// 证书校验拦的是 DNS 污染,应用层终审拦的是共享 CDN 上未部署该域名的"假活"IP
|
|
5
5
|
import tls from "node:tls";
|
|
6
6
|
import https from "node:https";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { execFile } from "node:child_process";
|
|
7
12
|
|
|
8
13
|
// 目标域名的应用层指纹:真实业务路径、必须包含的响应特征、允许状态码、拒绝特征、允许跳转的 host
|
|
9
14
|
// 路径必须能代表真实业务;codeload 用归档下载路径,避免根路径 301 到 github.com 主站骗过校验
|
|
@@ -19,6 +24,15 @@ import https from "node:https";
|
|
|
19
24
|
// → 规则:**路径 `/` 只接受 200/3xx**。只有那些「根路径本来就不存在合法内容」的域名
|
|
20
25
|
// (objects 根路径是 Varnish 的 404、gist 是 301)才保留 4xx,且靠 reject 词表兜底。
|
|
21
26
|
export const DOMAIN_SIG = {
|
|
27
|
+
// api.github.com:gh CLI / REST API 走它,DNS 同样被污染。判据用 /rate_limit:
|
|
28
|
+
// 未认证也返回 200,响应体是 JSON 且必含 "rate" 字段 —— 不依赖 GitHub 网页特征
|
|
29
|
+
"api.github.com": {
|
|
30
|
+
path: "/rate_limit",
|
|
31
|
+
mustContain: ["rate"],
|
|
32
|
+
allow: [200, 204],
|
|
33
|
+
reject: ["unknown domain", "Fastly error", "bad request"],
|
|
34
|
+
redirectHosts: [],
|
|
35
|
+
},
|
|
22
36
|
"github.com": {
|
|
23
37
|
path: "/",
|
|
24
38
|
mustContain: ["github"],
|
|
@@ -27,6 +41,21 @@ export const DOMAIN_SIG = {
|
|
|
27
41
|
reject: ["unknown domain", "Fastly error", "bad request"],
|
|
28
42
|
redirectHosts: ["github.com"],
|
|
29
43
|
},
|
|
44
|
+
// github.githubassets.com:github.com 网页的全部 JS/CSS/字体都从这里加载(0.3.0 新增)。
|
|
45
|
+
// 它不写进 hosts 时「页面能打开但样式全裂」,是直连浏览最常见的体验杀手。
|
|
46
|
+
// 判据用 favicon(2026-10-31 实测 4 个 Fastly .215 IP 全部 200 且以 `<svg` 开头);
|
|
47
|
+
// 注意它的 Fastly 段是 185.199.108-111.**215**,与 githubusercontent 系的 .133 不同段
|
|
48
|
+
// (.133 对该 SNI 直接连接失败),所以 domainPools 走独立的 ghassets 池。
|
|
49
|
+
"github.githubassets.com": {
|
|
50
|
+
path: "/favicons/favicon.svg",
|
|
51
|
+
mustContain: ["<svg"],
|
|
52
|
+
allow: [200, 204],
|
|
53
|
+
reject: ["unknown domain", "Fastly error", "bad request"],
|
|
54
|
+
redirectHosts: [],
|
|
55
|
+
},
|
|
56
|
+
// ssh.github.com:SSH-over-443 端点(git clone git@ssh.github.com:... 用,0.3.0 新增)。
|
|
57
|
+
// type:"ssh" —— 不走 TLS/HTTP 三关,改探 SSH banner(见 sshProbe)。
|
|
58
|
+
"ssh.github.com": { type: "ssh" },
|
|
30
59
|
"codeload.github.com": {
|
|
31
60
|
path: "/torvalds/linux/tar.gz/refs/heads/master",
|
|
32
61
|
mustContain: [],
|
|
@@ -45,7 +74,11 @@ export const DOMAIN_SIG = {
|
|
|
45
74
|
redirectHosts: ["objects.githubusercontent.com"],
|
|
46
75
|
},
|
|
47
76
|
"raw.githubusercontent.com": {
|
|
48
|
-
|
|
77
|
+
// 🔴 路径必须带分支段(2026-10-31 实测修正):raw.githubusercontent.com 的第 2 段是
|
|
78
|
+
// 分支名,`/torvalds/linux/README.md` 会被当作「README.md 分支」返回 404 ——
|
|
79
|
+
// 就算网络全通也会被判死(假阴性)。正确形态 `/torvalds/linux/master/README`
|
|
80
|
+
// 实测 200 且响应体含 "Linux kernel"(linux 仓库根文件本来就叫 README,无 .md)。
|
|
81
|
+
path: "/torvalds/linux/master/README",
|
|
49
82
|
mustContain: ["Linux kernel"],
|
|
50
83
|
allow: [200, 204, 302, 303, 304, 307, 308],
|
|
51
84
|
reject: ["unknown domain", "Fastly error", "bad request"],
|
|
@@ -65,6 +98,9 @@ export const DOMAIN_SIG = {
|
|
|
65
98
|
reject: ["unknown domain", "Fastly error", "bad request"],
|
|
66
99
|
redirectHosts: ["avatars.githubusercontent.com"],
|
|
67
100
|
},
|
|
101
|
+
// cdn.jsdelivr.net:0.4.0 起已从默认兜底链移除(2026-10-31 实测 SNI 级硬封:
|
|
102
|
+
// node + 双 curl 共 45 次全部在 ClientHello 后被 RST,换 IP/换 TLS 栈均无效)。
|
|
103
|
+
// 判据保留在这里:老用户配置里若仍引用它,探测依然有正确的业务路径可用。
|
|
68
104
|
"cdn.jsdelivr.net": {
|
|
69
105
|
path: "/gh/torvalds/linux@master/README",
|
|
70
106
|
mustContain: ["Linux kernel"],
|
|
@@ -90,12 +126,12 @@ function badRedirect(statusCode, location, sig) {
|
|
|
90
126
|
// 十几条死候选就是几十秒 —— 池子因此不敢放宽,进而出现「明明有活 IP 却报 stuck」。
|
|
91
127
|
// 这里先按 tcpMs 计,握手成功(connect 事件)后再把超时切到 tlsMs:
|
|
92
128
|
// 快速淘汰死 IP,同时不误杀「连得上但 TLS 慢」的活 IP。
|
|
93
|
-
function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
|
|
129
|
+
function tcpTlsProbe(ip, domain, tcpMs, tlsMs, port = 443) {
|
|
94
130
|
return new Promise((resolve) => {
|
|
95
131
|
const t0 = Date.now();
|
|
96
132
|
let tcpCost = -1;
|
|
97
133
|
let stage = "tcp";
|
|
98
|
-
const sock = tls.connect({ host: ip, port
|
|
134
|
+
const sock = tls.connect({ host: ip, port, servername: domain, rejectUnauthorized: true });
|
|
99
135
|
const done = (r) => { try { sock.destroy(); } catch { /* 已销毁 */ } resolve(r); };
|
|
100
136
|
sock.setTimeout(tcpMs, () => done({ ok: false, stage, err: "timeout" }));
|
|
101
137
|
sock.on("connect", () => { tcpCost = Date.now() - t0; stage = "tls"; sock.setTimeout(tlsMs); });
|
|
@@ -104,6 +140,102 @@ function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
|
|
|
104
140
|
});
|
|
105
141
|
}
|
|
106
142
|
|
|
143
|
+
// SSH banner 探测(type:"ssh" 的域名走这条,如 ssh.github.com 的 SSH-over-443):
|
|
144
|
+
// 服务端**先**发 banner(SSH-2.0-...),客户端 TLS/HTTP 三关都不适用 —— 读到合法 banner 即活。
|
|
145
|
+
// 超时复用 tcpMs:SSH banner 在 TCP 建连后立刻到,不需要单独一档。
|
|
146
|
+
export function parseSshBanner(buf) {
|
|
147
|
+
const s = String(buf.slice(0, 255));
|
|
148
|
+
const nl = s.indexOf("\r\n");
|
|
149
|
+
if (nl === -1) { return null; }
|
|
150
|
+
const line = s.slice(0, nl);
|
|
151
|
+
return /^SSH-\d\.\d-.+\r?$/.test(line) ? line : "";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function sshProbe(ip, tcpMs, port = 443) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const t0 = Date.now();
|
|
157
|
+
let tcpCost = -1;
|
|
158
|
+
let settled = false;
|
|
159
|
+
let acc = Buffer.alloc(0);
|
|
160
|
+
const sock = net.connect({ host: ip, port });
|
|
161
|
+
const done = (r) => {
|
|
162
|
+
if (settled) { return; }
|
|
163
|
+
settled = true;
|
|
164
|
+
try { sock.destroy(); } catch { /* 已销毁 */ }
|
|
165
|
+
resolve(r);
|
|
166
|
+
};
|
|
167
|
+
sock.setTimeout(tcpMs, () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "timeout" }));
|
|
168
|
+
sock.on("connect", () => { tcpCost = Date.now() - t0; });
|
|
169
|
+
sock.on("data", (c) => {
|
|
170
|
+
acc = Buffer.concat([acc, c]);
|
|
171
|
+
const banner = parseSshBanner(acc);
|
|
172
|
+
if (banner) { done({ ok: true, tcpMs: tcpCost, banner: banner.slice(0, 48), err: "" }); }
|
|
173
|
+
else if (banner === "") { done({ ok: false, stage: "banner", err: "not ssh" }); }
|
|
174
|
+
else if (acc.length >= 255) { done({ ok: false, stage: "banner", err: "not ssh" }); }
|
|
175
|
+
});
|
|
176
|
+
sock.on("timeout", () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "timeout" }));
|
|
177
|
+
sock.on("error", (e) => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: String(e.code || e.message).slice(0, 60) }));
|
|
178
|
+
sock.on("close", () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "closed" }));
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 🔴 schannel 二次探测:Node 的 TLS ClientHello 形态会被中间设备识别并 RST(README §3 实测:
|
|
183
|
+
// 同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常)。Windows 10+ **自带**的
|
|
184
|
+
// curl.exe 是 Schannel 构建 —— 与 Edge 同一 TLS 栈,指纹与浏览器一致。
|
|
185
|
+
// 被节点侧 TLS 阻断的候选用它重探一次:能给出「与浏览器一致」的真实判定,
|
|
186
|
+
// 而不是把「浏览器明明能用」的域名写成 # blocked。
|
|
187
|
+
// 🔴 必须显式定位 System32 的 curl.exe:PATH 里排在前面的可能是 Git 自带的 OpenSSL 构建
|
|
188
|
+
// (实测 mingw64 curl 走 OpenSSL,指纹又变回去了)。找不到就返回 null,退回旧行为。
|
|
189
|
+
export function findSystemCurl() {
|
|
190
|
+
if (process.platform !== "win32") { return ""; }
|
|
191
|
+
const p = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "curl.exe");
|
|
192
|
+
return fs.existsSync(p) ? p : "";
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 一次 schannel 探测:curl --resolve 把域名钉到候选 IP,-m 限时。
|
|
196
|
+
// 返回 { ok, status, tlsMs, ttfb, err };连接层失败(含 TLS 被 reset)status=-1。
|
|
197
|
+
// opts = { exec, curl, port } 可注入(单测不打真网络)。
|
|
198
|
+
export function curlSchannelProbe(ip, domain, cfg, opts = {}) {
|
|
199
|
+
const curl = opts.curl ?? findSystemCurl();
|
|
200
|
+
if (!curl) { return Promise.resolve(null); }
|
|
201
|
+
const exec = opts.exec || execFile;
|
|
202
|
+
const sig = sigFor(domain, cfg);
|
|
203
|
+
const httpMs = Math.max(2000, cfg.timeouts.httpMs);
|
|
204
|
+
const port = opts.port ?? 443;
|
|
205
|
+
return new Promise((resolve) => {
|
|
206
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ghprobe-curl-"));
|
|
207
|
+
const body = path.join(dir, "body");
|
|
208
|
+
exec(curl, [
|
|
209
|
+
"-sS", "-o", body, "--resolve", `${domain}:${port}:${ip}`,
|
|
210
|
+
"-m", String(Math.round(httpMs / 1000)),
|
|
211
|
+
"-w", "%{http_code} %{time_appconnect} %{time_starttransfer}",
|
|
212
|
+
`https://${domain}:${port}${sig.path}`,
|
|
213
|
+
], { timeout: httpMs + 4000, windowsHide: true }, (e, stdout, stderr) => {
|
|
214
|
+
let out = { ok: false, status: -1, tlsMs: -1, ttfb: -1, err: "" };
|
|
215
|
+
if (e) {
|
|
216
|
+
const msg = String(stderr || e.message).trim().split(/\r?\n/)[0].slice(0, 60);
|
|
217
|
+
out = { ok: false, status: -1, tlsMs: -1, ttfb: -1, err: `schannel:${msg || "failed"}` };
|
|
218
|
+
} else {
|
|
219
|
+
const parts = String(stdout).trim().split(/\s+/);
|
|
220
|
+
const statusCode = Number(parts[0]) || -1;
|
|
221
|
+
let bodyText = "";
|
|
222
|
+
try { bodyText = fs.readFileSync(body, "utf8").slice(0, 4096); } catch { /* 空响应体 */ }
|
|
223
|
+
const checked = checkHttpResponse(statusCode, bodyText, "", sig);
|
|
224
|
+
// 跳转校验弱化:curl 未跟随跳转、也没取回 Location 头,3xx 交给 allow 列表判。
|
|
225
|
+
// 只在 TLS 已经通过 schannel 之后才会走到这,风险可接受(README §3 的假活是 HTTP 层的)。
|
|
226
|
+
out = {
|
|
227
|
+
ok: checked.ok, status: statusCode,
|
|
228
|
+
tlsMs: Math.round(Number(parts[1]) * 1000) || -1,
|
|
229
|
+
ttfb: Math.round(Number(parts[2]) * 1000) || -1,
|
|
230
|
+
err: checked.ok ? "" : `http:${checked.err}`,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 清理失败不管 */ }
|
|
234
|
+
resolve(out);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
107
239
|
// 读取响应体前 n 字节,用于特征校验;读够后立即断开,不下载完整大页面
|
|
108
240
|
function readPrefix(res, maxBytes) { return new Promise((resolve) => {
|
|
109
241
|
const chunks = [];
|
|
@@ -161,12 +293,12 @@ export function sigFor(domain, cfg) {
|
|
|
161
293
|
}
|
|
162
294
|
|
|
163
295
|
// 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
|
|
164
|
-
function httpProbe(ip, domain, httpMs, cfg) {
|
|
296
|
+
function httpProbe(ip, domain, httpMs, cfg, port = 443) {
|
|
165
297
|
return new Promise((resolve) => {
|
|
166
298
|
const sig = sigFor(domain, cfg);
|
|
167
299
|
const t0 = Date.now();
|
|
168
300
|
const req = https.request({
|
|
169
|
-
host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
|
|
301
|
+
host: ip, port, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
|
|
170
302
|
headers: { Host: domain, "user-agent": "ghprobe/0.1.0", accept: "*/*" },
|
|
171
303
|
}, async (res) => {
|
|
172
304
|
const ttfb = Date.now() - t0;
|
|
@@ -198,15 +330,49 @@ export function looksLikeTlsBlocked(failure) {
|
|
|
198
330
|
return RESET_RE.test(err) || err === "timeout";
|
|
199
331
|
}
|
|
200
332
|
|
|
201
|
-
// 探测单个 (域名, IP)
|
|
202
|
-
|
|
333
|
+
// 探测单个 (域名, IP):三关全过才算活;分数越小越优。
|
|
334
|
+
// deps = { curlProbe, port } 可注入(单测把 port 指到本地服务,不打真网络),
|
|
335
|
+
// 缺省 curlProbe 用 curlSchannelProbe、端口 443。
|
|
336
|
+
// 两条扩展路径:
|
|
337
|
+
// ① type:"ssh" 的域名(ssh.github.com)不跑 TLS/HTTP 三关,读 SSH banner 判活;
|
|
338
|
+
// ② TLS 阶段被指纹阻断时(TCP 通、TLS 被 reset),用 Windows 自带 curl(schannel,
|
|
339
|
+
// 与浏览器同一 TLS 栈)重探一次 —— 0.3.0 前这种情况只能写 # blocked 放弃,
|
|
340
|
+
// 而「浏览器明明能用」的域名就这样被误判成不可达。重探也失败时:
|
|
341
|
+
// HTTP 层有响应 = TLS 其实通了(假活,不是指纹问题,不标 blocked);
|
|
342
|
+
// 连接层仍失败 = 连浏览器同款栈都过不去,才是真被阻断。
|
|
343
|
+
export async function probeIp(domain, ip, cfg, deps = {}) {
|
|
203
344
|
const { tlsMs, httpMs } = cfg.timeouts;
|
|
204
345
|
const tcpMs = cfg.timeouts.tcpMs ?? tlsMs; // 老配置没这一项时退回 tlsMs,行为不变
|
|
205
|
-
const
|
|
346
|
+
const port = deps.port ?? 443;
|
|
347
|
+
const sig = sigFor(domain, cfg);
|
|
348
|
+
|
|
349
|
+
if (sig.type === "ssh") {
|
|
350
|
+
const g = await sshProbe(ip, tcpMs, port);
|
|
351
|
+
return g.ok
|
|
352
|
+
? { domain, ip, ok: true, tcpMs: g.tcpMs, tlsMs: g.tcpMs, ttfb: g.tcpMs, status: 0, score: g.tcpMs, banner: g.banner, err: "", via: "ssh" }
|
|
353
|
+
: { domain, ip, ok: false, stage: g.stage, err: g.err };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const g = await tcpTlsProbe(ip, domain, tcpMs, tlsMs, port);
|
|
206
357
|
if (!g.ok) {
|
|
358
|
+
if (looksLikeTlsBlocked(g) && cfg.probe?.schannelRetry !== false) {
|
|
359
|
+
const s = await (deps.curlProbe || curlSchannelProbe)(ip, domain, cfg, { port });
|
|
360
|
+
if (s) {
|
|
361
|
+
if (s.ok) {
|
|
362
|
+
const tcp = g.tcpMs >= 0 ? g.tcpMs : s.tlsMs;
|
|
363
|
+
const tlsCost = s.tlsMs > 0 ? s.tlsMs : 0;
|
|
364
|
+
const score = Math.round(tcp * 0.4 + tlsCost * 0.4 + (s.ttfb > 0 ? s.ttfb : httpMs) * 0.2);
|
|
365
|
+
return {
|
|
366
|
+
domain, ip, ok: true, tcpMs: tcp, tlsMs: tcp + tlsCost,
|
|
367
|
+
ttfb: s.ttfb, status: s.status, score, via: "schannel", err: "",
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
return { domain, ip, ok: false, stage: s.status > 0 ? "http" : "tls", err: s.err, tlsBlocked: !(s.status > 0), via: "schannel" };
|
|
371
|
+
}
|
|
372
|
+
}
|
|
207
373
|
return { domain, ip, ok: false, stage: g.stage, err: g.err, tlsBlocked: looksLikeTlsBlocked(g) };
|
|
208
374
|
}
|
|
209
|
-
const h = await httpProbe(ip, domain, httpMs, cfg);
|
|
375
|
+
const h = await httpProbe(ip, domain, httpMs, cfg, port);
|
|
210
376
|
const tlsCost = g.tlsMs - g.tcpMs;
|
|
211
377
|
const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
|
|
212
378
|
return {
|
package/src/ranker.js
CHANGED
|
@@ -28,3 +28,29 @@ export function neededFallbacks(ranked, cfg) {
|
|
|
28
28
|
}
|
|
29
29
|
return [...new Set(list)];
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// 多次采样合并(纯函数):把首轮结果与复测样本按 (domain, ip) 分组,
|
|
33
|
+
// · 有活样本 -> score = 活样本均值 + 每个失败样本罚 penaltyMs(抖动 IP 别排第一)
|
|
34
|
+
// · 全部失败 -> 保留失败结果(字段取首个,ok=false,供 blockedDomainsFromRaw 判读)
|
|
35
|
+
// 为什么需要:单次采样抖动大时排名近乎随机 —— 而每次换 IP 都要刷 DNS 缓存、断连接池,
|
|
36
|
+
// 排名稳比单次「最优」更重要。0.3.0 前工具排名单采样,实测同一 IP 相邻两轮分差可达数倍。
|
|
37
|
+
export function mergeSamples(first, extra, penaltyMs = 500) {
|
|
38
|
+
const by = new Map();
|
|
39
|
+
const push = (r) => {
|
|
40
|
+
if (!r || !r.domain || !r.ip) { return; }
|
|
41
|
+
const k = `${r.domain}|${r.ip}`;
|
|
42
|
+
if (!by.has(k)) { by.set(k, []); }
|
|
43
|
+
by.get(k).push(r);
|
|
44
|
+
};
|
|
45
|
+
for (const r of first) { push(r); }
|
|
46
|
+
for (const r of extra) { push(r); }
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const rs of by.values()) {
|
|
49
|
+
const ok = rs.filter((r) => r.ok);
|
|
50
|
+
if (!ok.length) { out.push({ ...rs[0], ok: false }); continue; }
|
|
51
|
+
const failed = rs.length - ok.length;
|
|
52
|
+
const avg = Math.round(ok.reduce((a, r) => a + r.score, 0) / ok.length);
|
|
53
|
+
out.push({ ...ok[0], ok: true, score: avg + failed * penaltyMs, samples: rs.length });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
package/src/status.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// status:秒级速查。与 doctor 的区别:doctor 跑一轮完整探测(~1 分钟)给诊断,
|
|
4
|
+
// status **不打任何网络请求**,只把「hosts 里现在写了什么 / 自愈日志最近发生了什么 /
|
|
5
|
+
// 计划任务在不在」摆出来 —— 日常「现在什么情况」用这个,坏了再上 doctor。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { parseBlock, targetDomains } from "./health.js";
|
|
9
|
+
import { userDataDir, WIN_HOSTS } from "./hosts.js";
|
|
10
|
+
import { readHeartbeat, heartbeatFresh, processAlive } from "./daemon.js";
|
|
11
|
+
|
|
12
|
+
export function healthLogPath() {
|
|
13
|
+
return path.join(userDataDir(), "ghprobe.log");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 读健康检查日志最后 n 行(文件不存在/读不了返回 [])
|
|
17
|
+
export function readLogTail(n = 8, logPath = healthLogPath()) {
|
|
18
|
+
try {
|
|
19
|
+
const lines = fs.readFileSync(logPath, "utf8").trimEnd().split(/\r?\n/);
|
|
20
|
+
return lines.slice(-n);
|
|
21
|
+
} catch { return []; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 从 hosts 内容取块生成时间(# generated <ts> by ghprobe),没有则 null
|
|
25
|
+
function generatedAt(content) {
|
|
26
|
+
const m = String(content || "").match(/^# generated (\S+) by ghprobe$/m);
|
|
27
|
+
return m ? m[1] : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// 纯函数:由既有状态组装速查报告。全离线 —— 不探测、不解析、不查任务网络。
|
|
31
|
+
export function buildStatusReport({ hostsContent = "", cfg, logTail = [], task = null, daemon = undefined }) {
|
|
32
|
+
const current = parseBlock(hostsContent);
|
|
33
|
+
const targets = targetDomains(cfg);
|
|
34
|
+
const lines = [];
|
|
35
|
+
|
|
36
|
+
// ① hosts 块现状
|
|
37
|
+
const gen = generatedAt(hostsContent);
|
|
38
|
+
if (!current.entries.size) {
|
|
39
|
+
lines.push("hosts:还没有 ghprobe 块 —— 先跑 ghprobe install");
|
|
40
|
+
} else {
|
|
41
|
+
lines.push(`hosts:${current.entries.size} 个域名(块生成于 ${gen ?? "?"})`);
|
|
42
|
+
for (const [d, ip] of current.entries) {
|
|
43
|
+
const alts = (current.extras.get(d) || []).join(", ");
|
|
44
|
+
lines.push(` ${d.padEnd(32)} ${ip}${alts ? ` 备选: ${alts}` : ""}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const cooling = new Set([...current.absent.keys(), ...(current.blocked || new Map()).keys()]);
|
|
48
|
+
const missing = targets.filter((d) => !current.entries.has(d) && !cooling.has(d));
|
|
49
|
+
for (const [d, ts] of current.absent) { lines.push(` [缺失] ${d}(${ts} 起冷却)`); }
|
|
50
|
+
for (const [d, ts] of (current.blocked || new Map())) { lines.push(` [阻断] ${d}(${ts} 起冷却;浏览器栈也验证过被拦,详见 README §3)`); }
|
|
51
|
+
if (missing.length) { lines.push(` [未写入] ${missing.join(", ")}(下轮健康检查自动补)`); }
|
|
52
|
+
|
|
53
|
+
// ② 常驻守护 + 计划任务
|
|
54
|
+
if (daemon === undefined) { daemon = readHeartbeat(); }
|
|
55
|
+
if (daemon && heartbeatFresh(daemon) && processAlive(daemon.pid)) {
|
|
56
|
+
const age = Math.round((Date.now() - new Date(daemon.at).getTime()) / 1000);
|
|
57
|
+
lines.push(`常驻守护:运行中(pid ${daemon.pid},心跳 ${age}s 前,15~45s 自适应自愈)`);
|
|
58
|
+
} else if (daemon) {
|
|
59
|
+
lines.push("常驻守护:未运行(心跳陈旧)—— 监督者会在下一分钟自动拉起");
|
|
60
|
+
} else {
|
|
61
|
+
lines.push("常驻守护:未启动过 —— 下一轮 health(≤1 分钟)自动拉起");
|
|
62
|
+
}
|
|
63
|
+
if (task === null || task === undefined) {
|
|
64
|
+
lines.push("计划任务:查询跳过(仅 Windows)");
|
|
65
|
+
} else if (task.output && /ghprobe/i.test(task.output)) {
|
|
66
|
+
lines.push("计划任务:已注册,每分钟自愈中");
|
|
67
|
+
} else {
|
|
68
|
+
lines.push(`计划任务:${task.error ? `查询失败 ${task.error}` : "未注册 —— 跑 ghprobe install"}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ③ 自愈日志尾部
|
|
72
|
+
if (logTail.length) {
|
|
73
|
+
lines.push(`自愈日志(最近 ${logTail.length} 条,完整见 ${healthLogPath()}):`);
|
|
74
|
+
for (const l of logTail) { lines.push(` ${l}`); }
|
|
75
|
+
} else {
|
|
76
|
+
lines.push("自愈日志:暂无(还没跑过 health,或日志不可读)");
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 一轮速查。deps 全可注入(单测不打文件系统/任务)。
|
|
82
|
+
export async function runStatus(cfg, deps = {}) {
|
|
83
|
+
const hostsContent = deps.hostsContent ?? (() => { try { return fs.readFileSync(WIN_HOSTS, "utf8"); } catch { return ""; } })();
|
|
84
|
+
const logTail = deps.logTail ?? readLogTail();
|
|
85
|
+
const task = deps.taskQuery ? await deps.taskQuery() : null;
|
|
86
|
+
const lines = buildStatusReport({ hostsContent, cfg, logTail, task });
|
|
87
|
+
return { lines, entries: parseBlock(hostsContent).entries.size };
|
|
88
|
+
}
|