ghprobe 0.0.0-stage → 0.1.1

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/prober.js ADDED
@@ -0,0 +1,212 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 三关探测:TCP RTT -> TLS 握手 + 证书域名校验 -> HTTP 应用层终审
4
+ // 证书校验拦的是 DNS 污染,应用层终审拦的是共享 CDN 上未部署该域名的"假活"IP
5
+ import tls from "node:tls";
6
+ import https from "node:https";
7
+
8
+ // 目标域名的应用层指纹:真实业务路径、必须包含的响应特征、允许状态码、拒绝特征、允许跳转的 host
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 词表兜底。
21
+ export const DOMAIN_SIG = {
22
+ "github.com": {
23
+ path: "/",
24
+ mustContain: ["github"],
25
+ // 只留 2xx/3xx:根路径必须真的返回 GitHub 首页(或跳转到 github.com)
26
+ allow: [200, 204, 301, 302, 303, 304, 307, 308],
27
+ reject: ["unknown domain", "Fastly error", "bad request"],
28
+ redirectHosts: ["github.com"],
29
+ },
30
+ "codeload.github.com": {
31
+ path: "/torvalds/linux/tar.gz/refs/heads/master",
32
+ mustContain: [],
33
+ allow: [200, 204, 302, 303, 304, 307, 308],
34
+ reject: ["unknown domain", "Fastly error", "bad request"],
35
+ redirectHosts: ["codeload.github.com", "objects.githubusercontent.com"],
36
+ },
37
+ "objects.githubusercontent.com": {
38
+ // ⚠️ 根路径必然 404(实测 Varnish 返回纯 "404 Not Found",响应体里没有 "github"),
39
+ // 所以这里无法用 mustContain 兜底,4xx 必须留在白名单里 —— 是已知的判据弱点,
40
+ // 实际靠 reject 词表(unknown domain / Fastly error)拦假活节点。
41
+ path: "/",
42
+ mustContain: [],
43
+ allow: [200, 204, 302, 303, 304, 307, 308, 400, 401, 403, 404],
44
+ reject: ["unknown domain", "Fastly error", "bad request"],
45
+ redirectHosts: ["objects.githubusercontent.com"],
46
+ },
47
+ "raw.githubusercontent.com": {
48
+ path: "/torvalds/linux/README.md",
49
+ mustContain: ["Linux kernel"],
50
+ allow: [200, 204, 302, 303, 304, 307, 308],
51
+ reject: ["unknown domain", "Fastly error", "bad request"],
52
+ redirectHosts: ["raw.githubusercontent.com", "objects.githubusercontent.com"],
53
+ },
54
+ "gist.githubusercontent.com": {
55
+ path: "/",
56
+ mustContain: [],
57
+ allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404],
58
+ reject: ["unknown domain", "Fastly error", "bad request"],
59
+ redirectHosts: ["gist.githubusercontent.com", "gist.github.com"],
60
+ },
61
+ "avatars.githubusercontent.com": {
62
+ path: "/u/1?v=4",
63
+ mustContain: [],
64
+ allow: [200, 204, 302, 303, 304, 307, 308],
65
+ reject: ["unknown domain", "Fastly error", "bad request"],
66
+ redirectHosts: ["avatars.githubusercontent.com"],
67
+ },
68
+ "cdn.jsdelivr.net": {
69
+ path: "/gh/torvalds/linux@master/README",
70
+ mustContain: ["Linux kernel"],
71
+ allow: [200, 204, 301, 302, 303, 304, 307, 308],
72
+ reject: ["unknown domain", "Fastly error", "bad request"],
73
+ redirectHosts: ["cdn.jsdelivr.net", "www.jsdelivr.com", "jsdelivr.net"],
74
+ },
75
+ };
76
+
77
+ // 3xx Location 跳到该域名不允许的 host(如 codeload 坏节点跳回 github.com 主站)则判死
78
+ // redirectHosts 未配置或为空数组时表示"不检查跳转",避免兜底域名被误杀
79
+ function badRedirect(statusCode, location, sig) {
80
+ if (!location || statusCode < 300 || statusCode >= 400) { return false; }
81
+ const hosts = sig.redirectHosts;
82
+ if (!hosts || hosts.length === 0) { return false; }
83
+ let h;
84
+ try { h = new URL(location).host; } catch { return false; }
85
+ return !new Set(hosts).has(h);
86
+ }
87
+
88
+ // 一条 TLS 连接同时量出 TCP 与 TLS 两段耗时。
89
+ // 🔴 两段必须用**各自的超时**:黑洞 IP 的 TCP 握手会一直挂到超时,若统一用 tlsMs(6s),
90
+ // 十几条死候选就是几十秒 —— 池子因此不敢放宽,进而出现「明明有活 IP 却报 stuck」。
91
+ // 这里先按 tcpMs 计,握手成功(connect 事件)后再把超时切到 tlsMs:
92
+ // 快速淘汰死 IP,同时不误杀「连得上但 TLS 慢」的活 IP。
93
+ function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
94
+ return new Promise((resolve) => {
95
+ const t0 = Date.now();
96
+ let tcpCost = -1;
97
+ let stage = "tcp";
98
+ const sock = tls.connect({ host: ip, port: 443, servername: domain, rejectUnauthorized: true });
99
+ const done = (r) => { try { sock.destroy(); } catch { /* 已销毁 */ } resolve(r); };
100
+ sock.setTimeout(tcpMs, () => done({ ok: false, stage, err: "timeout" }));
101
+ sock.on("connect", () => { tcpCost = Date.now() - t0; stage = "tls"; sock.setTimeout(tlsMs); });
102
+ sock.on("secureConnect", () => done({ ok: true, tcpMs: tcpCost, tlsMs: Date.now() - t0 }));
103
+ sock.on("error", (e) => done({ ok: false, stage, err: String(e.code || e.message).slice(0, 60) }));
104
+ });
105
+ }
106
+
107
+ // 读取响应体前 n 字节,用于特征校验;读够后立即断开,不下载完整大页面
108
+ function readPrefix(res, maxBytes) { return new Promise((resolve) => {
109
+ const chunks = [];
110
+ let got = 0;
111
+ let settled = false;
112
+ const finish = () => {
113
+ if (settled) { return; }
114
+ settled = true;
115
+ resolve(Buffer.concat(chunks).toString("utf8", 0, maxBytes));
116
+ };
117
+ res.on("data", (c) => {
118
+ if (got < maxBytes) { chunks.push(c); }
119
+ got += c.length;
120
+ if (got >= maxBytes) { res.destroy(); finish(); }
121
+ });
122
+ res.on("end", finish);
123
+ res.on("close", finish);
124
+ res.on("error", finish);
125
+ });
126
+ }
127
+
128
+ // 纯函数:给定状态码、响应体、Location,按域名指纹判定是否"真活"
129
+ export function checkHttpResponse(statusCode, body, location, sig) {
130
+ const lower = String(body).toLowerCase();
131
+ for (const bad of sig.reject) {
132
+ if (lower.includes(bad.toLowerCase())) { return { ok: false, err: `reject:${bad}` }; }
133
+ }
134
+ if (badRedirect(statusCode, location, sig)) { return { ok: false, err: `redirect:${location}` }; }
135
+ if (statusCode >= 500) { return { ok: false, err: `status:${statusCode}` }; }
136
+ for (const need of sig.mustContain || []) {
137
+ if (!lower.includes(need.toLowerCase())) { return { ok: false, err: `missing:${need}` }; }
138
+ }
139
+ if (sig.allow.includes(statusCode)) { return { ok: true, err: "" }; }
140
+ return { ok: false, err: `status:${statusCode}` };
141
+ }
142
+
143
+ // 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
144
+ function httpProbe(ip, domain, httpMs) {
145
+ 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: [] };
147
+ const t0 = Date.now();
148
+ const req = https.request({
149
+ host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
150
+ headers: { Host: domain, "user-agent": "ghprobe/0.1.0", accept: "*/*" },
151
+ }, async (res) => {
152
+ const ttfb = Date.now() - t0;
153
+ const body = await readPrefix(res, 4096);
154
+ const checked = checkHttpResponse(res.statusCode, body, res.headers.location, sig);
155
+ resolve({ ok: checked.ok, ttfb, status: res.statusCode, err: checked.err });
156
+ });
157
+ req.on("timeout", () => { req.destroy(); resolve({ ok: false, err: "timeout" }); });
158
+ req.on("error", (e) => resolve({ ok: false, err: String(e.code || e.message).slice(0, 60) }));
159
+ req.end();
160
+ });
161
+ }
162
+
163
+ // 🔴 区分两类失败(2026-10-11 实测):
164
+ // ① stage=tcp —— TCP 握手就没成功(超时 / 拒绝)-> 这个 IP 确实不可用
165
+ // ② stage=tls —— **TCP 连上了,但 TLS 阶段被重置或永远不完成** -> 很可能不是 IP 的问题,
166
+ // 而是本机 TLS 指纹被中间设备识别后掐断(DPI 按 ClientHello 的形态放行/拦截)。
167
+ //
168
+ // 为什么必须区分:实测同一 IP、同一 SNI、同一时刻 ——
169
+ // openssl s_client 20/20 握手成功,node tls.connect 5/20,curl 3/20;
170
+ // 而浏览器(Edge)能正常打开同一个 URL。
171
+ // ghprobe 自己就是 Node,**它的探测客户端指纹和用户的浏览器不同** ——
172
+ // 把 ② 判成「IP 不可用」会把「浏览器其实能用」的域名误记成不可达(假阴性),
173
+ // 还会写进 `# absent` 冷却让工具主动放弃它。
174
+ const RESET_RE = /ECONNRESET|EPIPE|ECONNABORTED|socket hang up|Client network socket disconnected/i;
175
+ export function looksLikeTlsBlocked(failure) {
176
+ if (!failure || failure.ok || failure.stage !== "tls") { return false; }
177
+ const err = String(failure.err || "");
178
+ return RESET_RE.test(err) || err === "timeout";
179
+ }
180
+
181
+ // 探测单个 (域名, IP):三关全过才算活;分数越小越优
182
+ export async function probeIp(domain, ip, cfg) {
183
+ const { tlsMs, httpMs } = cfg.timeouts;
184
+ const tcpMs = cfg.timeouts.tcpMs ?? tlsMs; // 老配置没这一项时退回 tlsMs,行为不变
185
+ const g = await tcpTlsProbe(ip, domain, tcpMs, tlsMs);
186
+ if (!g.ok) {
187
+ return { domain, ip, ok: false, stage: g.stage, err: g.err, tlsBlocked: looksLikeTlsBlocked(g) };
188
+ }
189
+ const h = await httpProbe(ip, domain, httpMs);
190
+ const tlsCost = g.tlsMs - g.tcpMs;
191
+ const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
192
+ return {
193
+ domain, ip, ok: h.ok,
194
+ tcpMs: g.tcpMs, tlsMs: g.tlsMs, ttfb: h.ok ? h.ttfb : -1, status: h.ok ? h.status : -1,
195
+ score, err: h.ok ? "" : `http:${h.err}`,
196
+ };
197
+ }
198
+
199
+ // 简单并发池:限制同时握手的 socket 数量
200
+ export async function pool(jobs, concurrency) {
201
+ const out = new Array(jobs.length);
202
+ let next = 0;
203
+ const worker = async () => {
204
+ for (;;) {
205
+ const i = next++;
206
+ if (i >= jobs.length) { return; }
207
+ out[i] = await jobs[i]();
208
+ }
209
+ };
210
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
211
+ return out;
212
+ }
package/src/ranker.js ADDED
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 排名选优:按域名分组,只留三关全过的,score 升序,每域名限量
4
+ export function rank(results, cfg) {
5
+ const byDomain = new Map();
6
+ for (const r of results) {
7
+ if (!byDomain.has(r.domain)) { byDomain.set(r.domain, []); }
8
+ byDomain.get(r.domain).push(r);
9
+ }
10
+ const out = new Map();
11
+ for (const [domain, rs] of byDomain) {
12
+ const alive = rs.filter((r) => r.ok).sort((a, b) => a.score - b.score);
13
+ out.set(domain, alive.slice(0, cfg.probe.maxPerDomain));
14
+ }
15
+ return out;
16
+ }
17
+
18
+ // 兜底链:主域名候选全灭且配置了替身时,返回替身域名清单。
19
+ // 🔴 必须要求该域名**本轮确实探过**(ranked 里存在这个键)。rank 对「探过但全灭」的域名
20
+ // 会写入一个空数组,对「本轮压根没探」的域名则完全不建键。部分更新只探被点名的域名,
21
+ // 若把「没探」误当「全灭」,就会白跑一轮兜底 DoH + 探测(实测每次多花 2~5s)。
22
+ export function neededFallbacks(ranked, cfg) {
23
+ const list = [];
24
+ for (const [domain, alternatives] of Object.entries(cfg.fallbacks || {})) {
25
+ if (!ranked.has(domain)) { continue; }
26
+ const rs = ranked.get(domain) || [];
27
+ if (!rs.length && alternatives.length) { list.push(...alternatives); }
28
+ }
29
+ return [...new Set(list)];
30
+ }
package/src/server.js ADDED
@@ -0,0 +1,56 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // 内置 HTTP API:既是测试钩子也是状态面板数据源
4
+ // GET /api/status 最近一轮结果
5
+ // POST /api/probe 立即触发一轮探测(跑完返回摘要)
6
+ // GET /api/hosts 当前生成的 hosts 块
7
+ // GET /api/config 当前生效配置
8
+ import http from "node:http";
9
+ import { runProbe } from "./probe.js";
10
+ import { blockFromProbeResult } from "./health.js";
11
+
12
+ const state = { results: null, block: "", at: null, probing: false };
13
+
14
+ export function getState() { return state; }
15
+
16
+ // run 可注入(默认真实 runProbe),供接口测试替身使用
17
+ export async function probeNow(cfg, log, run = runProbe) {
18
+ if (state.probing) { throw new Error("已有探测在跑"); }
19
+ state.probing = true;
20
+ try {
21
+ const r = await run(cfg, log);
22
+ state.results = r;
23
+ state.at = r.at;
24
+ state.block = blockFromProbeResult(cfg, r);
25
+ return r;
26
+ } finally {
27
+ state.probing = false;
28
+ }
29
+ }
30
+
31
+ export function startServer(cfg, log, run = runProbe) {
32
+ const server = http.createServer((req, res) => {
33
+ const json = (code, obj) => { res.writeHead(code, { "content-type": "application/json; charset=utf-8" }); res.end(JSON.stringify(obj, null, 2)); };
34
+ if (req.url === "/api/status") {
35
+ if (!state.results) { return json(200, { ready: false, probing: state.probing }); }
36
+ const summary = [...state.results.ranked].map(([domain, rs]) => ({ domain, alive: rs.map((r) => ({ ip: r.ip, tcpMs: r.tcpMs, tlsMs: r.tlsMs, ttfb: r.ttfb, score: r.score })) }));
37
+ return json(200, { ready: true, at: state.at, probing: state.probing, domains: summary });
38
+ }
39
+ if (req.url === "/api/probe" && req.method === "POST") {
40
+ return probeNow(cfg, log, run).then((r) => {
41
+ const summary = [...r.ranked].map(([domain, rs]) => ({ domain, alive: rs.map((x) => ({ ip: x.ip, score: x.score })) }));
42
+ json(200, { at: r.at, domains: summary });
43
+ }).catch((e) => json(500, { error: String(e.message) }));
44
+ }
45
+ if (req.url === "/api/hosts") {
46
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
47
+ return res.end(state.block || "# 尚无结果,先 POST /api/probe\n");
48
+ }
49
+ if (req.url === "/api/config") { return json(200, cfg); }
50
+ res.writeHead(404, { "content-type": "application/json; charset=utf-8" });
51
+ res.end(JSON.stringify({ error: "not found" }));
52
+ });
53
+ return new Promise((resolve) => {
54
+ server.listen(cfg.serve.port, cfg.serve.host, () => resolve(server));
55
+ });
56
+ }