ghprobe 0.0.0-stage → 0.1.0
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 +123 -0
- package/LICENSE +674 -0
- package/README.en.md +525 -0
- package/README.md +517 -2
- package/bin/ghprobe.js +280 -0
- package/config.default.yaml +140 -0
- package/install.cmd +8 -0
- package/install.ps1 +119 -0
- package/package.json +47 -4
- package/src/config.js +64 -0
- package/src/doh.js +89 -0
- package/src/health.js +244 -0
- package/src/hosts.js +332 -0
- package/src/net.js +47 -0
- package/src/pool.js +174 -0
- package/src/probe.js +76 -0
- package/src/prober.js +178 -0
- package/src/ranker.js +30 -0
- package/src/server.js +56 -0
package/src/net.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// 本地网络可用性检查:本机断网时直接跳过本轮 —— 不探测、不更新池、不写 hosts。
|
|
4
|
+
// 为什么需要:断网时所有候选必然全灭,跑一轮纯属浪费,而且日志会被「stuck / 全灭」刷屏,
|
|
5
|
+
// 掩盖真正的故障;更糟的是会让「网络故障」和「GitHub 被墙」两种情况混在一起无法区分。
|
|
6
|
+
import os from "node:os";
|
|
7
|
+
import net from "node:net";
|
|
8
|
+
|
|
9
|
+
// 第一步(零成本):有没有「已启用且带非回环 IPv4」的网卡。
|
|
10
|
+
// 只能排除「网卡全禁用 / 只连了回环」这种最明显的情况,不能证明真能出去。
|
|
11
|
+
export function hasInterface() {
|
|
12
|
+
const ifs = os.networkInterfaces();
|
|
13
|
+
for (const list of Object.values(ifs)) {
|
|
14
|
+
for (const a of list || []) {
|
|
15
|
+
if (!a.internal && (a.family === "IPv4" || a.family === 4)) { return true; }
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 第二步:TCP 连一下基准目标。只判断「本机能不能出去」,与 GitHub 是否可达无关 ——
|
|
22
|
+
// 后者交给正常探测(换 IP 能救的属于 GitHub 侧,不在这里判)。
|
|
23
|
+
export function canReach(host, port, timeoutMs) {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
const sock = net.connect({ host, port });
|
|
26
|
+
let done = false;
|
|
27
|
+
const fin = (ok) => {
|
|
28
|
+
if (done) { return; }
|
|
29
|
+
done = true;
|
|
30
|
+
try { sock.destroy(); } catch { /* 忽略 */ }
|
|
31
|
+
resolve(ok);
|
|
32
|
+
};
|
|
33
|
+
sock.setTimeout(timeoutMs, () => fin(false));
|
|
34
|
+
sock.on("connect", () => fin(true));
|
|
35
|
+
sock.on("error", () => fin(false));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 基准目标默认用阿里公共 DNS 的 DoH 端点(223.5.5.5:443):
|
|
40
|
+
// 国内任何正常网络都该能连上,且它不是 GitHub —— 不会被 SNI 封锁影响判断。
|
|
41
|
+
export async function hasLocalNetwork(cfg) {
|
|
42
|
+
const c = cfg.networkCheck || {};
|
|
43
|
+
if (c.enabled === false) { return true; }
|
|
44
|
+
if (!hasInterface()) { return false; }
|
|
45
|
+
const p = c.probe || {};
|
|
46
|
+
return canReach(p.host || "223.5.5.5", p.port ?? 443, p.timeoutMs ?? 2000);
|
|
47
|
+
}
|
package/src/pool.js
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段 → 展开成候选 IP → 缓存到用户目录。
|
|
4
|
+
// 为什么需要它:GitHub 的 Azure 段(20.x / 4.x)在变,内置静态池会过期
|
|
5
|
+
// (2026-10-11 实测:内置 24 个里有 4 个已不在官方 web 段)。
|
|
6
|
+
// 🔴 api.github.com/meta 未认证限 60 次/小时 —— 所以只能低频(默认每天 1 次),绝不能轮询。
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import https from "node:https";
|
|
10
|
+
import { userDataDir } from "./hosts.js";
|
|
11
|
+
|
|
12
|
+
export function poolCachePath() {
|
|
13
|
+
return path.join(userDataDir(), "pool.cache.json");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readPoolCache() {
|
|
17
|
+
try {
|
|
18
|
+
const o = JSON.parse(fs.readFileSync(poolCachePath(), "utf8"));
|
|
19
|
+
return o && typeof o === "object" ? o : null;
|
|
20
|
+
} catch { return null; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function writePoolCache(obj) {
|
|
24
|
+
const p = poolCachePath();
|
|
25
|
+
try {
|
|
26
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2), "utf8");
|
|
28
|
+
} catch { /* 写不了就算了,探测侧对缺缓存是宽容的 */ }
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const toInt = (s) => s.split(".").reduce((a, o) => a * 256 + Number(o), 0) >>> 0;
|
|
32
|
+
const toIp = (v) => [(v >>> 24) & 255, (v >>> 16) & 255, (v >>> 8) & 255, v & 255].join(".");
|
|
33
|
+
|
|
34
|
+
// CIDR -> 候选 IP。
|
|
35
|
+
// 🔴 不能全展开:meta 的 web 段里有 /20、/22,一个 /20 就是 4096 个地址,全展开会让探测成本爆炸。
|
|
36
|
+
// 实际在用的是 /32 单播(GitHub 把它们逐个列出来就是给你白名单用的)与大段里的固定偏移
|
|
37
|
+
// (GitHub 习惯用段内 .3 / .4 做 web/api),所以大段只采样 perBigNet 个。
|
|
38
|
+
export function expandCidr(cidr, perBigNet = 4) {
|
|
39
|
+
const raw = String(cidr || "").trim();
|
|
40
|
+
if (!raw || raw.includes(":")) { return []; } // 跳过 IPv6(本项目只探 v4)
|
|
41
|
+
const slash = raw.indexOf("/");
|
|
42
|
+
const net = slash === -1 ? raw : raw.slice(0, slash);
|
|
43
|
+
const bits = slash === -1 ? 32 : Number(raw.slice(slash + 1));
|
|
44
|
+
if (!Number.isFinite(bits) || bits < 0 || bits > 32) { return []; }
|
|
45
|
+
const parts = net.split(".");
|
|
46
|
+
if (parts.length !== 4 || parts.some((x) => !/^\d{1,3}$/.test(x) || Number(x) > 255)) { return []; }
|
|
47
|
+
if (bits === 32) { return [net]; }
|
|
48
|
+
const base = toInt(net);
|
|
49
|
+
const size = 2 ** (32 - bits);
|
|
50
|
+
const out = [];
|
|
51
|
+
for (let i = 0; i < perBigNet; i++) {
|
|
52
|
+
const v = (base + 3 + i) >>> 0; // 从段内 .3 起
|
|
53
|
+
if (v >= base + size) { break; }
|
|
54
|
+
out.push(toIp(v));
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 用指定 IP + SNI 直连拉 meta。
|
|
60
|
+
// 🔴 必须用 IP 直连:api.github.com 的 DNS 在国内被污染(实测直连拿不到响应),
|
|
61
|
+
// 而这里恰恰是「还没有可用 IP 时」才需要它 —— 所以只能用配置里的种子池。
|
|
62
|
+
// onReq:把底层 request 暴露给调用方,便于「某个种子先成功」时掐掉其余在飞的请求。
|
|
63
|
+
function fetchMeta(ip, host, pathName, timeoutMs, onReq) {
|
|
64
|
+
return new Promise((resolve, reject) => {
|
|
65
|
+
const req = https.get({
|
|
66
|
+
host: ip,
|
|
67
|
+
port: 443,
|
|
68
|
+
servername: host,
|
|
69
|
+
path: pathName,
|
|
70
|
+
headers: { Host: host, "User-Agent": "ghprobe", Accept: "application/json" },
|
|
71
|
+
rejectUnauthorized: true,
|
|
72
|
+
timeout: timeoutMs,
|
|
73
|
+
}, (res) => {
|
|
74
|
+
if (res.statusCode !== 200) {
|
|
75
|
+
res.resume();
|
|
76
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
let b = "";
|
|
80
|
+
res.setEncoding("utf8");
|
|
81
|
+
res.on("data", (d) => { b += d; });
|
|
82
|
+
res.on("end", () => { try { resolve(JSON.parse(b)); } catch (e) { reject(new Error("meta 不是合法 JSON")); } });
|
|
83
|
+
});
|
|
84
|
+
if (onReq) { onReq(req); }
|
|
85
|
+
req.on("timeout", () => req.destroy(new Error("timeout")));
|
|
86
|
+
req.on("error", reject);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 并行试所有种子,**第一个成功的立刻返回**并掐掉其余在飞请求。
|
|
91
|
+
// 为什么不用 Promise.allSettled:它要等**最慢**那个(黑洞种子会一直挂到 timeoutMs),
|
|
92
|
+
// 实测总耗时 = 8s(正好一个 timeout),等于没并行。
|
|
93
|
+
// 为什么不用 Promise.any:它只给第一个成功的值,拿不到「是哪个 IP 拉到的」,日志里就没法记 via。
|
|
94
|
+
export function fetchFirstMeta(seeds, host, pathName, timeoutMs) {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
if (!seeds.length) { reject(new Error("没有可用种子 IP")); return; }
|
|
97
|
+
const reqs = [];
|
|
98
|
+
const errs = [];
|
|
99
|
+
let left = seeds.length;
|
|
100
|
+
let done = false;
|
|
101
|
+
const finish = (fn, arg) => {
|
|
102
|
+
if (done) { return; }
|
|
103
|
+
done = true;
|
|
104
|
+
for (const r of reqs) { try { r.destroy(); } catch { /* 忽略 */ } }
|
|
105
|
+
fn(arg);
|
|
106
|
+
};
|
|
107
|
+
for (const ip of seeds) {
|
|
108
|
+
fetchMeta(ip, host, pathName, timeoutMs, (r) => reqs.push(r)).then(
|
|
109
|
+
(json) => finish(resolve, { json, via: ip }),
|
|
110
|
+
(e) => {
|
|
111
|
+
errs.push(`${ip}:${e?.message || e}`);
|
|
112
|
+
left -= 1;
|
|
113
|
+
if (left === 0) { finish(reject, new Error(`所有种子 IP 都拉不到 ${host}(${errs.slice(0, 3).join(", ")})`)); }
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 种子 IP:拉 meta 用的直连入口。优先用配置里的 seed 池,再退回 azure / fastly。
|
|
121
|
+
export function seedIps(cfg) {
|
|
122
|
+
const p = cfg.ipPools || {};
|
|
123
|
+
return [...new Set([...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 一轮更新:拉 meta -> 展开 -> 写缓存。失败也会写 lastAttempt(冷却),避免每分钟重试撞 60 次/小时限制。
|
|
127
|
+
export async function updatePool(cfg, log = () => {}) {
|
|
128
|
+
const u = cfg.poolUpdate || {};
|
|
129
|
+
const url = new URL(u.source || "https://api.github.com/meta");
|
|
130
|
+
const host = u.host || url.hostname;
|
|
131
|
+
const pathName = url.pathname || "/meta";
|
|
132
|
+
const timeoutMs = u.timeoutMs ?? 8000;
|
|
133
|
+
const attemptedAt = new Date().toISOString();
|
|
134
|
+
const prev = readPoolCache();
|
|
135
|
+
try {
|
|
136
|
+
const seeds = seedIps(cfg);
|
|
137
|
+
if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly 全为空)"); }
|
|
138
|
+
// 并行试所有种子,第一个成功的立刻返回(实测 8.2s -> 亚秒级;串行时是 2m13s)。
|
|
139
|
+
const { json, via } = await fetchFirstMeta(seeds, host, pathName, timeoutMs);
|
|
140
|
+
|
|
141
|
+
const sections = u.sections || ["web", "api"];
|
|
142
|
+
const maxIps = u.maxIps ?? 48;
|
|
143
|
+
const perBigNet = u.perBigNet ?? 4;
|
|
144
|
+
const cidrs = [];
|
|
145
|
+
for (const s of sections) { if (Array.isArray(json[s])) { cidrs.push(...json[s]); } }
|
|
146
|
+
// /32 优先:那些才是 GitHub 实际在用的入口;大段采样只是补充
|
|
147
|
+
const singles = cidrs.filter((c) => String(c).endsWith("/32")).flatMap((c) => expandCidr(c));
|
|
148
|
+
const bigs = cidrs.filter((c) => !String(c).endsWith("/32")).flatMap((c) => expandCidr(c, perBigNet));
|
|
149
|
+
const ips = [...new Set([...singles, ...bigs])].slice(0, maxIps);
|
|
150
|
+
|
|
151
|
+
const cache = { at: new Date().toISOString(), lastAttempt: attemptedAt, via, sections, count: ips.length, ips };
|
|
152
|
+
writePoolCache(cache);
|
|
153
|
+
log(`池已更新:${ips.length} 个 IP(via ${via},段 ${sections.join("+")})`);
|
|
154
|
+
return { ok: true, count: ips.length, via, path: poolCachePath() };
|
|
155
|
+
} catch (e) {
|
|
156
|
+
writePoolCache({ ...(prev || {}), lastAttempt: attemptedAt, lastError: String(e.message || e) });
|
|
157
|
+
throw e;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 是否该更新。🔴 **成功与失败用同一档冷却**(intervalHours,默认 24h):
|
|
162
|
+
// 拉 meta 走的是 api.github.com —— 如果它被 SNI 层封锁,换任何 IP 都救不回,
|
|
163
|
+
// 那种情况下反复重试毫无意义(还白撞 60 次/小时的 API 限制),所以失败后也等满一个周期再说。
|
|
164
|
+
// 手动更新不受此限制:`ghprobe update-pool` 永远立即执行。
|
|
165
|
+
export function poolNeedsUpdate(cfg) {
|
|
166
|
+
if (cfg.poolUpdate?.enabled === false) { return false; }
|
|
167
|
+
const intervalH = cfg.poolUpdate?.intervalHours ?? 24;
|
|
168
|
+
const c = readPoolCache();
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const fresh = (ts) => ts && now - new Date(ts).getTime() < intervalH * 3600_000;
|
|
171
|
+
if (fresh(c?.at)) { return false; } // 成功过,没到点
|
|
172
|
+
if (fresh(c?.lastAttempt)) { return false; } // 尝试过(成功或失败),没到点
|
|
173
|
+
return true;
|
|
174
|
+
}
|
package/src/probe.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// 一轮完整探测:DoH 候选 + 内置池 -> 三关并发探测 -> 排名 -> 兜底轮
|
|
4
|
+
import { resolveAll } from "./doh.js";
|
|
5
|
+
import { probeIp, pool } from "./prober.js";
|
|
6
|
+
import { rank, neededFallbacks } from "./ranker.js";
|
|
7
|
+
|
|
8
|
+
// 该域名该用哪些内置池。
|
|
9
|
+
// 默认(未配 domainPools 或配了不存在的池名)用全部池 —— 保持「与域名无关地全量进候选」
|
|
10
|
+
// 的老行为,由 TLS 证书终审 + 应用层终审判归属。
|
|
11
|
+
// 配了 domainPools 就只取指定的池:185.199.x.x(Fastly)永远不给 github.com 服务,
|
|
12
|
+
// 20.x 也不给 raw.githubusercontent.com 服务,互探纯属浪费——冷启动能省掉近一半候选。
|
|
13
|
+
export function poolsForDomain(cfg, domain) {
|
|
14
|
+
const pools = cfg.ipPools || {};
|
|
15
|
+
const names = cfg.domainPools?.[domain];
|
|
16
|
+
if (!Array.isArray(names) || names.length === 0) { return Object.values(pools).flat(); }
|
|
17
|
+
const picked = names.flatMap((n) => pools[n] || []);
|
|
18
|
+
return picked.length ? picked : Object.values(pools).flat();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 解析一批域名的候选 IP。域名之间并行 —— DoH 是纯网络等待,串行会把最坏耗时
|
|
22
|
+
// 乘上域名数(6 个域名 × dohMs 5s = 30s),并行后压回一个 dohMs。
|
|
23
|
+
async function resolveCandidates(cfg, domains, say, { usePool = true, prefix = "" } = {}) {
|
|
24
|
+
return Promise.all(domains.map(async (domain) => {
|
|
25
|
+
say(prefix ? `${prefix}探测 ${domain}` : `解析 ${domain}`);
|
|
26
|
+
const { ips } = await resolveAll(cfg.doh, domain, cfg.timeouts.dohMs);
|
|
27
|
+
// 🔴 两个来源**各自限额后再合并**,绝不能对合并结果做总限额。
|
|
28
|
+
// 实测(2026-10):DoH 对 github.com 返回的 8 条里有一半是 Fastly 的 185.199.x.x
|
|
29
|
+
// ——那是 githubusercontent 的节点,根本不给 github.com 服务。按总限额截断时,
|
|
30
|
+
// 内置池会被这堆垃圾整个挤掉,于是出现「20.29.134.23 明明是活的,工具却报
|
|
31
|
+
// stuck=github.com」。分开限额后内置池一定拿得到名额。
|
|
32
|
+
const fromDoh = (ips || []).slice(0, cfg.probe.maxCandidates);
|
|
33
|
+
const fromPool = usePool ? poolsForDomain(cfg, domain).slice(0, cfg.probe.maxPoolIps ?? 12) : [];
|
|
34
|
+
const candidates = [...new Set([...fromDoh, ...fromPool])];
|
|
35
|
+
say(` 候选 ${candidates.length} 个: ${candidates.join(", ")}`);
|
|
36
|
+
return { domain, candidates };
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 把所有 (域名, IP) 拍平成任务队列,交给一个**全局**并发池跑。
|
|
41
|
+
// 每个域名各自开一个池的话,实际并发 = 域名数 × concurrency(6×6=36 条 socket),
|
|
42
|
+
// 会把最坏耗时和瞬时连接数同时放大;全局单池才是真正的「同时最多 N 条」。
|
|
43
|
+
function jobsOf(cfg, resolved) {
|
|
44
|
+
const jobs = [];
|
|
45
|
+
for (const { domain, candidates } of resolved) {
|
|
46
|
+
for (const ip of candidates) { jobs.push(() => probeIp(domain, ip, cfg)); }
|
|
47
|
+
}
|
|
48
|
+
return jobs;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// onlyDomains:只探这些域名。部分更新(health 只发现某 1 个域名坏了)时用它,
|
|
52
|
+
// 避免「1 个域名失效却把 6 个域名、近 50 个候选全探一遍」——那是几十秒的纯浪费。
|
|
53
|
+
// 传 null / 空数组 = 全量。注意兜底域名不在 cfg.domains 里,若被点名必须单独直探。
|
|
54
|
+
export async function runProbe(cfg, log, onlyDomains = null) {
|
|
55
|
+
const say = log || (() => {});
|
|
56
|
+
const fbDomains = Object.values(cfg.fallbacks || {}).flat();
|
|
57
|
+
const want = onlyDomains && onlyDomains.length ? new Set(onlyDomains) : null;
|
|
58
|
+
const mainDomains = cfg.domains.filter((d) => !want || want.has(d));
|
|
59
|
+
const directFb = want ? [...want].filter((d) => fbDomains.includes(d) && !cfg.domains.includes(d)) : [];
|
|
60
|
+
|
|
61
|
+
const resolved = await resolveCandidates(cfg, mainDomains, say);
|
|
62
|
+
const all = await pool(jobsOf(cfg, resolved), cfg.probe.concurrency);
|
|
63
|
+
const ranked = rank(all, cfg);
|
|
64
|
+
|
|
65
|
+
// 兜底轮:主域名全灭且配了替身 -> 探测替身域名
|
|
66
|
+
const extra = new Map();
|
|
67
|
+
const alts = [...new Set([...neededFallbacks(ranked, cfg), ...directFb])];
|
|
68
|
+
if (alts.length) {
|
|
69
|
+
const altResolved = await resolveCandidates(cfg, alts, say, { usePool: false, prefix: "兜底" });
|
|
70
|
+
const rs = await pool(jobsOf(cfg, altResolved), cfg.probe.concurrency);
|
|
71
|
+
for (const [d, list] of rank(rs, cfg)) {
|
|
72
|
+
if (list.length) { extra.set(d, list); }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { ranked, extra, raw: all, at: new Date().toISOString() };
|
|
76
|
+
}
|
package/src/prober.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
export const DOMAIN_SIG = {
|
|
11
|
+
"github.com": {
|
|
12
|
+
path: "/",
|
|
13
|
+
mustContain: ["github"],
|
|
14
|
+
allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404],
|
|
15
|
+
reject: ["unknown domain", "Fastly error"],
|
|
16
|
+
redirectHosts: ["github.com"],
|
|
17
|
+
},
|
|
18
|
+
"codeload.github.com": {
|
|
19
|
+
path: "/torvalds/linux/tar.gz/refs/heads/master",
|
|
20
|
+
mustContain: [],
|
|
21
|
+
allow: [200, 204, 302, 303, 304, 307, 308],
|
|
22
|
+
reject: ["unknown domain", "Fastly error"],
|
|
23
|
+
redirectHosts: ["codeload.github.com", "objects.githubusercontent.com"],
|
|
24
|
+
},
|
|
25
|
+
"objects.githubusercontent.com": {
|
|
26
|
+
path: "/",
|
|
27
|
+
mustContain: [],
|
|
28
|
+
allow: [200, 204, 302, 303, 304, 307, 308, 400, 401, 403, 404],
|
|
29
|
+
reject: ["unknown domain", "Fastly error"],
|
|
30
|
+
redirectHosts: ["objects.githubusercontent.com"],
|
|
31
|
+
},
|
|
32
|
+
"raw.githubusercontent.com": {
|
|
33
|
+
path: "/torvalds/linux/README.md",
|
|
34
|
+
mustContain: ["Linux kernel"],
|
|
35
|
+
allow: [200, 204, 302, 303, 304, 307, 308],
|
|
36
|
+
reject: ["unknown domain", "Fastly error"],
|
|
37
|
+
redirectHosts: ["raw.githubusercontent.com", "objects.githubusercontent.com"],
|
|
38
|
+
},
|
|
39
|
+
"gist.githubusercontent.com": {
|
|
40
|
+
path: "/",
|
|
41
|
+
mustContain: [],
|
|
42
|
+
allow: [200, 204, 301, 302, 303, 304, 307, 308, 400, 401, 403, 404],
|
|
43
|
+
reject: ["unknown domain", "Fastly error"],
|
|
44
|
+
redirectHosts: ["gist.githubusercontent.com", "gist.github.com"],
|
|
45
|
+
},
|
|
46
|
+
"avatars.githubusercontent.com": {
|
|
47
|
+
path: "/u/1?v=4",
|
|
48
|
+
mustContain: [],
|
|
49
|
+
allow: [200, 204, 302, 303, 304, 307, 308],
|
|
50
|
+
reject: ["unknown domain", "Fastly error"],
|
|
51
|
+
redirectHosts: ["avatars.githubusercontent.com"],
|
|
52
|
+
},
|
|
53
|
+
"cdn.jsdelivr.net": {
|
|
54
|
+
path: "/gh/torvalds/linux@master/README",
|
|
55
|
+
mustContain: ["Linux kernel"],
|
|
56
|
+
allow: [200, 204, 301, 302, 303, 304, 307, 308],
|
|
57
|
+
reject: ["unknown domain", "Fastly error"],
|
|
58
|
+
redirectHosts: ["cdn.jsdelivr.net", "www.jsdelivr.com", "jsdelivr.net"],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// 3xx Location 跳到该域名不允许的 host(如 codeload 坏节点跳回 github.com 主站)则判死
|
|
63
|
+
// redirectHosts 未配置或为空数组时表示"不检查跳转",避免兜底域名被误杀
|
|
64
|
+
function badRedirect(statusCode, location, sig) {
|
|
65
|
+
if (!location || statusCode < 300 || statusCode >= 400) { return false; }
|
|
66
|
+
const hosts = sig.redirectHosts;
|
|
67
|
+
if (!hosts || hosts.length === 0) { return false; }
|
|
68
|
+
let h;
|
|
69
|
+
try { h = new URL(location).host; } catch { return false; }
|
|
70
|
+
return !new Set(hosts).has(h);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 一条 TLS 连接同时量出 TCP 与 TLS 两段耗时。
|
|
74
|
+
// 🔴 两段必须用**各自的超时**:黑洞 IP 的 TCP 握手会一直挂到超时,若统一用 tlsMs(6s),
|
|
75
|
+
// 十几条死候选就是几十秒 —— 池子因此不敢放宽,进而出现「明明有活 IP 却报 stuck」。
|
|
76
|
+
// 这里先按 tcpMs 计,握手成功(connect 事件)后再把超时切到 tlsMs:
|
|
77
|
+
// 快速淘汰死 IP,同时不误杀「连得上但 TLS 慢」的活 IP。
|
|
78
|
+
function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
const t0 = Date.now();
|
|
81
|
+
let tcpCost = -1;
|
|
82
|
+
let stage = "tcp";
|
|
83
|
+
const sock = tls.connect({ host: ip, port: 443, servername: domain, rejectUnauthorized: true });
|
|
84
|
+
const done = (r) => { try { sock.destroy(); } catch { /* 已销毁 */ } resolve(r); };
|
|
85
|
+
sock.setTimeout(tcpMs, () => done({ ok: false, stage, err: "timeout" }));
|
|
86
|
+
sock.on("connect", () => { tcpCost = Date.now() - t0; stage = "tls"; sock.setTimeout(tlsMs); });
|
|
87
|
+
sock.on("secureConnect", () => done({ ok: true, tcpMs: tcpCost, tlsMs: Date.now() - t0 }));
|
|
88
|
+
sock.on("error", (e) => done({ ok: false, stage, err: String(e.code || e.message).slice(0, 60) }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 读取响应体前 n 字节,用于特征校验;读够后立即断开,不下载完整大页面
|
|
93
|
+
function readPrefix(res, maxBytes) {
|
|
94
|
+
return new Promise((resolve) => {
|
|
95
|
+
const chunks = [];
|
|
96
|
+
let got = 0;
|
|
97
|
+
let settled = false;
|
|
98
|
+
const finish = () => {
|
|
99
|
+
if (settled) { return; }
|
|
100
|
+
settled = true;
|
|
101
|
+
resolve(Buffer.concat(chunks).toString("utf8", 0, maxBytes));
|
|
102
|
+
};
|
|
103
|
+
res.on("data", (c) => {
|
|
104
|
+
if (got < maxBytes) { chunks.push(c); }
|
|
105
|
+
got += c.length;
|
|
106
|
+
if (got >= maxBytes) { res.destroy(); finish(); }
|
|
107
|
+
});
|
|
108
|
+
res.on("end", finish);
|
|
109
|
+
res.on("close", finish);
|
|
110
|
+
res.on("error", finish);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 纯函数:给定状态码、响应体、Location,按域名指纹判定是否"真活"
|
|
115
|
+
export function checkHttpResponse(statusCode, body, location, sig) {
|
|
116
|
+
const lower = String(body).toLowerCase();
|
|
117
|
+
for (const bad of sig.reject) {
|
|
118
|
+
if (lower.includes(bad.toLowerCase())) { return { ok: false, err: `reject:${bad}` }; }
|
|
119
|
+
}
|
|
120
|
+
if (badRedirect(statusCode, location, sig)) { return { ok: false, err: `redirect:${location}` }; }
|
|
121
|
+
if (statusCode >= 500) { return { ok: false, err: `status:${statusCode}` }; }
|
|
122
|
+
for (const need of sig.mustContain || []) {
|
|
123
|
+
if (!lower.includes(need.toLowerCase())) { return { ok: false, err: `missing:${need}` }; }
|
|
124
|
+
}
|
|
125
|
+
if (sig.allow.includes(statusCode)) { return { ok: true, err: "" }; }
|
|
126
|
+
return { ok: false, err: `status:${statusCode}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
|
|
130
|
+
function httpProbe(ip, domain, httpMs) {
|
|
131
|
+
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: [] };
|
|
133
|
+
const t0 = Date.now();
|
|
134
|
+
const req = https.request({
|
|
135
|
+
host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
|
|
136
|
+
headers: { Host: domain, "user-agent": "ghprobe/0.1.0", accept: "*/*" },
|
|
137
|
+
}, async (res) => {
|
|
138
|
+
const ttfb = Date.now() - t0;
|
|
139
|
+
const body = await readPrefix(res, 4096);
|
|
140
|
+
const checked = checkHttpResponse(res.statusCode, body, res.headers.location, sig);
|
|
141
|
+
resolve({ ok: checked.ok, ttfb, status: res.statusCode, err: checked.err });
|
|
142
|
+
});
|
|
143
|
+
req.on("timeout", () => { req.destroy(); resolve({ ok: false, err: "timeout" }); });
|
|
144
|
+
req.on("error", (e) => resolve({ ok: false, err: String(e.code || e.message).slice(0, 60) }));
|
|
145
|
+
req.end();
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 探测单个 (域名, IP):三关全过才算活;分数越小越优
|
|
150
|
+
export async function probeIp(domain, ip, cfg) {
|
|
151
|
+
const { tlsMs, httpMs } = cfg.timeouts;
|
|
152
|
+
const tcpMs = cfg.timeouts.tcpMs ?? tlsMs; // 老配置没这一项时退回 tlsMs,行为不变
|
|
153
|
+
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);
|
|
156
|
+
const tlsCost = g.tlsMs - g.tcpMs;
|
|
157
|
+
const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
|
|
158
|
+
return {
|
|
159
|
+
domain, ip, ok: h.ok,
|
|
160
|
+
tcpMs: g.tcpMs, tlsMs: g.tlsMs, ttfb: h.ok ? h.ttfb : -1, status: h.ok ? h.status : -1,
|
|
161
|
+
score, err: h.ok ? "" : `http:${h.err}`,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 简单并发池:限制同时握手的 socket 数量
|
|
166
|
+
export async function pool(jobs, concurrency) {
|
|
167
|
+
const out = new Array(jobs.length);
|
|
168
|
+
let next = 0;
|
|
169
|
+
const worker = async () => {
|
|
170
|
+
for (;;) {
|
|
171
|
+
const i = next++;
|
|
172
|
+
if (i >= jobs.length) { return; }
|
|
173
|
+
out[i] = await jobs[i]();
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
|
|
177
|
+
return out;
|
|
178
|
+
}
|
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
|
+
}
|