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/CHANGELOG.md +164 -0
- package/LICENSE +674 -0
- package/README.en.md +582 -0
- package/README.md +575 -3
- package/bin/ghprobe.js +282 -0
- package/config.default.yaml +140 -0
- package/install.cmd +8 -0
- package/install.ps1 +119 -0
- package/package.json +51 -4
- package/src/config.js +64 -0
- package/src/doh.js +89 -0
- package/src/health.js +350 -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 +212 -0
- package/src/ranker.js +30 -0
- package/src/server.js +56 -0
package/src/doh.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// DoH 联邦解析:RFC 8484 wire format,端点 IP 直连,无任何 UDP53 路径
|
|
4
|
+
import https from "node:https";
|
|
5
|
+
|
|
6
|
+
// 构造最小 DNS A 查询报文:header(12) + QNAME 标签串 + 根0 + QTYPE=A + QCLASS=IN
|
|
7
|
+
export function buildQuery(id, name) {
|
|
8
|
+
if (id < 0 || id > 65535) { throw new Error("dns id out of range"); }
|
|
9
|
+
const hdr = Buffer.alloc(12);
|
|
10
|
+
hdr.writeUInt16BE(id, 0);
|
|
11
|
+
hdr.writeUInt16BE(0x0100, 2); // RD=1
|
|
12
|
+
hdr.writeUInt16BE(1, 4); // QDCOUNT=1
|
|
13
|
+
const labels = [];
|
|
14
|
+
for (const label of String(name).split(".")) {
|
|
15
|
+
if (!label.length || label.length > 63) { throw new Error(`bad label: ${label}`); }
|
|
16
|
+
labels.push(Buffer.from([label.length]), Buffer.from(label));
|
|
17
|
+
}
|
|
18
|
+
return Buffer.concat([hdr, ...labels, Buffer.from([0, 0, 1, 0, 1])]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// 解析应答中的 A 记录;名字支持压缩指针,解析失败不抛错只返回已得结果
|
|
22
|
+
export function parseA(buf) {
|
|
23
|
+
const ips = [];
|
|
24
|
+
if (!buf || buf.length < 12) { return ips; }
|
|
25
|
+
const qd = buf.readUInt16BE(4);
|
|
26
|
+
const an = buf.readUInt16BE(6);
|
|
27
|
+
let off = 12;
|
|
28
|
+
const skipName = () => {
|
|
29
|
+
for (;;) {
|
|
30
|
+
if (off >= buf.length) { return; }
|
|
31
|
+
const len = buf[off];
|
|
32
|
+
if (len === 0) { off += 1; return; }
|
|
33
|
+
if ((len & 0xc0) === 0xc0) { off += 2; return; }
|
|
34
|
+
off += len + 1;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
for (let i = 0; i < qd; i++) { skipName(); off += 4; }
|
|
38
|
+
for (let i = 0; i < an && off + 10 <= buf.length; i++) {
|
|
39
|
+
skipName();
|
|
40
|
+
if (off + 10 > buf.length) { break; }
|
|
41
|
+
const type = buf.readUInt16BE(off);
|
|
42
|
+
const rdlen = buf.readUInt16BE(off + 8);
|
|
43
|
+
if (type === 1 && rdlen === 4 && off + 14 <= buf.length) {
|
|
44
|
+
ips.push(`${buf[off + 10]}.${buf[off + 11]}.${buf[off + 12]}.${buf[off + 13]}`);
|
|
45
|
+
}
|
|
46
|
+
off += 10 + rdlen;
|
|
47
|
+
}
|
|
48
|
+
return ips;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 单端点 DoH 查询:POST wire 报文,IP 直连 + 可选 SNI 覆盖(证书仍按 servername 校验)
|
|
52
|
+
// 应答只校验 query ID 是否与请求一致,不单独解析 question 段:端点是我们主动 TLS 直连的已知服务器,
|
|
53
|
+
// 信道经过证书校验,且 ID 已匹配,足以排除响应错位与缓存投毒;question 段校验属防御性冗余,不做
|
|
54
|
+
export function queryDoh(ep, name, timeoutMs) {
|
|
55
|
+
return new Promise((resolve) => {
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
const qid = (Math.random() * 65536) | 0;
|
|
58
|
+
const qbuf = buildQuery(qid, name);
|
|
59
|
+
const req = https.request({
|
|
60
|
+
host: ep.host, path: ep.path || "/dns-query",
|
|
61
|
+
method: "POST", timeout: timeoutMs,
|
|
62
|
+
headers: { "content-type": "application/dns-message" },
|
|
63
|
+
// 只在显式配了 sni 时覆盖;否则交给 node 按 host 自动推导(IP 不允许当 SNI)
|
|
64
|
+
...(ep.sni ? { servername: ep.sni } : {}),
|
|
65
|
+
}, (res) => {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
res.on("data", (c) => chunks.push(c));
|
|
68
|
+
res.on("end", () => {
|
|
69
|
+
const body = Buffer.concat(chunks);
|
|
70
|
+
if (body.length < 12) { resolve({ ep, ok: false, ms: Date.now() - t0, ips: [], err: "short response" }); return; }
|
|
71
|
+
const rid = body.readUInt16BE(0);
|
|
72
|
+
if (rid !== qid) { resolve({ ep, ok: false, ms: Date.now() - t0, ips: [], err: `id mismatch ${rid}!==${qid}` }); return; }
|
|
73
|
+
const rcode = body[3] & 0x0f;
|
|
74
|
+
resolve({ ep, ok: res.statusCode === 200 && rcode === 0, ms: Date.now() - t0, ips: rcode === 0 ? parseA(body) : [], err: res.statusCode !== 200 ? `http ${res.statusCode}` : `rcode ${rcode}` });
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
req.on("timeout", () => { req.destroy(); resolve({ ep, ok: false, ms: Date.now() - t0, ips: [], err: "timeout" }); });
|
|
78
|
+
req.on("error", (e) => resolve({ ep, ok: false, ms: Date.now() - t0, ips: [], err: String(e.code || e.message).slice(0, 60) }));
|
|
79
|
+
req.end(qbuf);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 联邦查询:全部端点并发,成功者取并集去重
|
|
84
|
+
export async function resolveAll(endpoints, name, timeoutMs) {
|
|
85
|
+
const rs = await Promise.all(endpoints.map((ep) => queryDoh(ep, name, timeoutMs)));
|
|
86
|
+
const ips = new Set();
|
|
87
|
+
for (const r of rs) { for (const ip of r.ips) { ips.add(ip); } }
|
|
88
|
+
return { ips: [...ips], sources: rs };
|
|
89
|
+
}
|
package/src/health.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// 调度层:健康检查(哨兵)+ 决策 + 部分更新 + 全灭护栏
|
|
4
|
+
// 无状态文件:缺失域名冷却表直接编码进 hosts 块注释,跨进程、跨重启不丢
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { probeIp, pool } from "./prober.js";
|
|
7
|
+
import { runProbe } from "./probe.js";
|
|
8
|
+
import { applyHosts, mergeHosts, WIN_HOSTS, acquireHostsLock, releaseHostsLock } from "./hosts.js";
|
|
9
|
+
|
|
10
|
+
const BEGIN = "# === ghprobe begin ===";
|
|
11
|
+
const END = "# === ghprobe end ===";
|
|
12
|
+
|
|
13
|
+
// 从 hosts 内容解析 ghprobe 块:有效 IP 映射、同名备选 IP、缺失域名冷却表、指纹阻断表
|
|
14
|
+
// 块内行格式:
|
|
15
|
+
// `IP 域名`(同域名可多行:第一条 = 主 IP,其余 = 备选)
|
|
16
|
+
// `# absent 域名 时间戳` —— 探测过、确实没有可用 IP
|
|
17
|
+
// `# blocked 域名 时间戳` —— 探测不可判:TCP 连上了但 TLS 被本机指纹阻断(详见 prober.js)
|
|
18
|
+
// 主 IP 进 entries(决策/探活只看它),备选进 extras(原样写回,供客户端 fallback)
|
|
19
|
+
export function parseBlock(content) {
|
|
20
|
+
const entries = new Map();
|
|
21
|
+
const extras = new Map();
|
|
22
|
+
const absent = new Map();
|
|
23
|
+
const blocked = new Map();
|
|
24
|
+
let inBlock = false;
|
|
25
|
+
for (const raw of String(content || "").split(/\r?\n/)) {
|
|
26
|
+
const line = raw.trim();
|
|
27
|
+
if (line.startsWith(BEGIN)) { inBlock = true; continue; }
|
|
28
|
+
if (line.startsWith(END)) { inBlock = false; continue; }
|
|
29
|
+
if (!inBlock) { continue; }
|
|
30
|
+
if (line.startsWith("#")) {
|
|
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]); }
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const parts = line.split(/\s+/);
|
|
38
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
39
|
+
const domain = parts[1].toLowerCase();
|
|
40
|
+
const ip = parts[0];
|
|
41
|
+
if (!entries.has(domain)) { entries.set(domain, ip); continue; }
|
|
42
|
+
if (entries.get(domain) === ip) { continue; }
|
|
43
|
+
const list = extras.get(domain) || [];
|
|
44
|
+
if (!list.includes(ip)) { list.push(ip); extras.set(domain, list); }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { entries, extras, absent, blocked };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// 生成健康检查用的 hosts 块:有效条目(每域名 主 IP + 备选)+ 缺失域名冷却注释 + 指纹阻断注释
|
|
51
|
+
// extras:domain -> [备选 IP...]。同域名多行让客户端在首个 IP 抖动时可自动 fallback。
|
|
52
|
+
export function buildHealthBlock(entries, absent, extras = new Map(), blocked = new Map()) {
|
|
53
|
+
const lines = [BEGIN, `# generated ${new Date().toISOString()} by ghprobe`];
|
|
54
|
+
for (const [domain, ip] of entries) {
|
|
55
|
+
lines.push(`${ip} ${domain}`);
|
|
56
|
+
for (const alt of extras.get(domain) || []) {
|
|
57
|
+
if (alt && alt !== ip) { lines.push(`${alt} ${domain}`); }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const [domain, ts] of absent) { lines.push(`# absent ${domain} ${ts}`); }
|
|
61
|
+
for (const [domain, ts] of blocked) { lines.push(`# blocked ${domain} ${ts}`); }
|
|
62
|
+
lines.push(END);
|
|
63
|
+
return lines.join("\n");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 合法域名全集 = 主域名 + 兜底域名;缺失检测与 orphan 清理都以它为准
|
|
67
|
+
export function targetDomains(cfg) {
|
|
68
|
+
const fb = Object.values(cfg.fallbacks || {}).flat();
|
|
69
|
+
return [...new Set([...(cfg.domains || []), ...fb])];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 由一轮探测结果生成待写入的块。
|
|
73
|
+
// 与 health 写出的块保持同构:没有活 IP 的目标域名记入 `# absent` 冷却注释,
|
|
74
|
+
// 否则下次 health 会把这些域名当「缺失」而白跑一次全量。
|
|
75
|
+
export function blockFromProbeResult(cfg, r) {
|
|
76
|
+
const maxPer = Math.max(1, cfg.hosts?.maxEntriesPerDomain ?? 2);
|
|
77
|
+
const entries = new Map();
|
|
78
|
+
const extras = new Map();
|
|
79
|
+
const put = (d, list) => {
|
|
80
|
+
if (!list || !list.length) { return; }
|
|
81
|
+
entries.set(d, list[0].ip);
|
|
82
|
+
const alt = list.slice(1, maxPer).map((x) => x.ip);
|
|
83
|
+
if (alt.length) { extras.set(d, alt); }
|
|
84
|
+
};
|
|
85
|
+
for (const [d, rs] of r.ranked || []) { put(d, rs); }
|
|
86
|
+
for (const [d, rs] of r.extra || []) { put(d, rs); }
|
|
87
|
+
const absent = new Map();
|
|
88
|
+
const blocked = new Map();
|
|
89
|
+
const tlsBlocked = blockedDomainsFromRaw(r.raw);
|
|
90
|
+
const now = new Date().toISOString();
|
|
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);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 全灭护栏(写入前判定):新块一条有效 IP 都没有、而 hosts 里还有旧条目时,必须拒绝写入 ——
|
|
100
|
+
// 否则会把原本可用的条目一起抹掉(`probe --apply` 此前缺这道护栏,只有 health 有)。
|
|
101
|
+
export function shouldKeepOldBlock(oldContent, newBlock) {
|
|
102
|
+
return parseBlock(newBlock).entries.size === 0 && parseBlock(oldContent).entries.size > 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 决策(纯函数):输入「已写入条目的探活结果」+ 当前块状态,输出动作
|
|
106
|
+
// 横向判据:严格多数条目同时失败视为网络故障(本轮不动,避开黑洞窗口);
|
|
107
|
+
// 单条目失败或缺失域名(非冷却)才触发全量重排
|
|
108
|
+
//
|
|
109
|
+
// 🔴 2026-10-11 两个关键修正:
|
|
110
|
+
// ① 探活结果现在**同时包含主条目与备选条目**(每条带 role: "main" / "alt")。
|
|
111
|
+
// 一个域名只有「它所有已写入条目全部失败」才算挂 —— 备选还活着时不该重排。
|
|
112
|
+
// ② 主挂了、备选活着 → **把备选提上来(promote)**,不重排。
|
|
113
|
+
// 这条是针对实测故障的:主 IP 变成假活(返回 400)而备选是好的,旧逻辑不知道备选存在,
|
|
114
|
+
// 直接去重排 → 撞上 IP 抖动窗口 → 换了个更差的,用户反而断网。
|
|
115
|
+
// 备选本来就是为了这一刻存在的,不该只写不读。
|
|
116
|
+
export function decide(probeResults, current, cfg) {
|
|
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
|
+
}
|
|
132
|
+
const total = current.entries.size;
|
|
133
|
+
|
|
134
|
+
// 只有「已写入条目全部失败」才可能是网络级故障(黑洞窗口),此时本轮不动、避开窗口。
|
|
135
|
+
// 只要还有任意一条健康,就说明链路是通的,失败的那些是 IP 陈旧/真死,应当重排换 IP。
|
|
136
|
+
// 🔴 旧判据 `failed.size * 2 > total`(多数失败即放弃)已证实会误触发:分母只数已写入
|
|
137
|
+
// 条目、常低到 3~4,实测「3 条陈旧 + 1 条健康」被误判成断网 → 该修的时候不修。
|
|
138
|
+
if (total >= 2 && failed.size === total) {
|
|
139
|
+
return { action: "none", reason: "全部已写入 IP 同时失败,疑似网络故障", promote: new Map() };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const cooldownMs = (cfg.schedule?.absentCooldownHours ?? 1) * 3600_000;
|
|
143
|
+
const now = Date.now();
|
|
144
|
+
const targets = targetDomains(cfg);
|
|
145
|
+
const missing = new Set();
|
|
146
|
+
for (const d of targets) {
|
|
147
|
+
if (current.entries.has(d)) { continue; }
|
|
148
|
+
// `# absent`(确实无可用 IP)与 `# blocked`(探测不可判:本机 TLS 指纹被阻断)
|
|
149
|
+
// 共用同一档冷却 —— 两者都不该每分钟重探一遍
|
|
150
|
+
const ts = current.absent.get(d) || current.blocked?.get(d);
|
|
151
|
+
if (ts && now - new Date(ts).getTime() < cooldownMs) { continue; }
|
|
152
|
+
missing.add(d);
|
|
153
|
+
}
|
|
154
|
+
// orphan:hosts 块里已不在配置(含兜底)中的域名,需清理
|
|
155
|
+
const orphan = [...current.entries.keys()].filter((d) => !targets.includes(d));
|
|
156
|
+
|
|
157
|
+
const diagnose = [...new Set([...failed, ...missing, ...orphan])];
|
|
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(", ")}` };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// 部分更新(纯函数):只改 diagnose 域名;全灭护栏保证不把好 IP 写成空
|
|
168
|
+
// 返回的新 entries/absent 用于与当前对比,changed 记录真正换掉 IP 的域名
|
|
169
|
+
// stuck:被诊断了、但本轮一个活 IP 都没找到、只能保留旧值的域名(黑洞窗口里必然出现,
|
|
170
|
+
// 显式报出来,否则日志只写「无变化」会让人以为工具没干活)
|
|
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()) {
|
|
190
|
+
const entries = new Map(current.entries);
|
|
191
|
+
const extras = new Map(current.extras || []);
|
|
192
|
+
const absent = new Map(current.absent);
|
|
193
|
+
const blocked = new Map(current.blocked || []);
|
|
194
|
+
const changed = [];
|
|
195
|
+
const now = new Date().toISOString();
|
|
196
|
+
const allResult = new Map([...ranked, ...extra]);
|
|
197
|
+
const maxPer = Math.max(1, cfg.hosts?.maxEntriesPerDomain ?? 2);
|
|
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
|
+
|
|
212
|
+
for (const d of diagnose) {
|
|
213
|
+
const list = allResult.get(d) || [];
|
|
214
|
+
const best = list[0];
|
|
215
|
+
if (best) {
|
|
216
|
+
if (entries.get(d) !== best.ip) { entries.set(d, best.ip); changed.push(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); }
|
|
224
|
+
absent.delete(d);
|
|
225
|
+
blocked.delete(d);
|
|
226
|
+
} else if (!entries.has(d)) {
|
|
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); }
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// orphan 清理:hosts 块里已不在配置(含兜底)中的域名,无条件删除(主 IP 与备选一起删)
|
|
236
|
+
const allow = new Set(targetDomains(cfg));
|
|
237
|
+
for (const d of [...entries.keys()]) {
|
|
238
|
+
if (!allow.has(d)) { entries.delete(d); changed.push(d); }
|
|
239
|
+
}
|
|
240
|
+
for (const d of [...extras.keys()]) {
|
|
241
|
+
if (!allow.has(d) || !entries.has(d)) { extras.delete(d); }
|
|
242
|
+
}
|
|
243
|
+
// orphan 已在上一步删掉,这里只剩「配置内、诊断过、却没探到活 IP、于是保留旧值」的域名
|
|
244
|
+
const stuck = diagnose.filter((d) => allow.has(d) && entries.has(d) && !(allResult.get(d) || []).length);
|
|
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 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sameMap(a, b) {
|
|
251
|
+
if (a.size !== b.size) { return false; }
|
|
252
|
+
for (const [k, v] of a) { if (b.get(k) !== v) { return false; } }
|
|
253
|
+
return true;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// 比较「域名 -> 备选 IP 数组」是否一致(值是有序数组,不能直接套 sameMap)
|
|
257
|
+
function sameExtras(a, b) {
|
|
258
|
+
if (a.size !== b.size) { return false; }
|
|
259
|
+
for (const [k, v] of a) {
|
|
260
|
+
const w = b.get(k);
|
|
261
|
+
if (!w || w.length !== v.length) { return false; }
|
|
262
|
+
for (let i = 0; i < v.length; i++) { if (v[i] !== w[i]) { return false; } }
|
|
263
|
+
}
|
|
264
|
+
return true;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 完整一轮健康检查:读 -> 测已写入 -> 决策 -> (异常时)全量探测 + 部分更新
|
|
268
|
+
// write=true 时才真正 applyHosts(会弹 UAC),否则仅预览
|
|
269
|
+
export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS) {
|
|
270
|
+
const say = log || (() => {});
|
|
271
|
+
const content = fs.readFileSync(hostsPath, "utf8");
|
|
272
|
+
const current = parseBlock(content);
|
|
273
|
+
|
|
274
|
+
if (current.entries.size === 0) {
|
|
275
|
+
say("hosts 无 ghprobe 记录,触发首次全量");
|
|
276
|
+
return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath);
|
|
277
|
+
}
|
|
278
|
+
|
|
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] }));
|
|
289
|
+
for (const r of probeResults) {
|
|
290
|
+
say(`${r.ok ? "[OK ]" : "[FAIL]"}${r.role === "alt" ? " ALT" : " "} ${r.domain} @ ${r.ip} err=${r.err}`);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const decision = decide(probeResults, current, cfg);
|
|
294
|
+
if (decision.action === "none") {
|
|
295
|
+
say(`健康:${decision.reason}`);
|
|
296
|
+
return { action: "none", reason: decision.reason, block: null, stuck: [] };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
say(`异常:${decision.reason}`);
|
|
300
|
+
return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 全量探测 + 部分更新 + 写回;护栏与粘性都在 applyDelta 里体现。
|
|
304
|
+
// 只探 diagnose 里的域名:典型场景是「github.com 的 IP 死了,codeload / jsdelivr 还活着」,
|
|
305
|
+
// 此时探 1 个域名(~8 个候选)而不是 6 个(~48 个),失败发现到修好的时间从 ~90s 压到 ~20s。
|
|
306
|
+
async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map()) {
|
|
307
|
+
const say = log || (() => {});
|
|
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);
|
|
312
|
+
|
|
313
|
+
const entriesChanged = !sameMap(d.entries, current.entries);
|
|
314
|
+
const absentChanged = !sameMap(d.absent, current.absent);
|
|
315
|
+
const blockedChanged = !sameMap(d.blocked, current.blocked || new Map());
|
|
316
|
+
const extrasChanged = !sameExtras(d.extras, current.extras || new Map());
|
|
317
|
+
const stuckNote = d.stuck.length ? `;无可用 IP 保留原值: ${d.stuck.join(", ")}` : "";
|
|
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 };
|
|
323
|
+
}
|
|
324
|
+
|
|
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(";") : "更新缺失域名冷却记录";
|
|
334
|
+
if (write) {
|
|
335
|
+
const lock = acquireHostsLock();
|
|
336
|
+
if (!lock) {
|
|
337
|
+
say("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
|
|
338
|
+
return { action: "none", reason: "写锁冲突,本轮跳过", changed: d.changed, block, stuck: d.stuck };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
// 锁内重读最新 hosts:探测耗时期间他人可能已改过,避免覆盖别人刚写的新值
|
|
342
|
+
const latest = fs.readFileSync(hostsPath, "utf8");
|
|
343
|
+
await applyHosts(mergeHosts(latest, block));
|
|
344
|
+
say(`已写入 hosts:${msg}${stuckNote}`);
|
|
345
|
+
} finally { releaseHostsLock(lock); }
|
|
346
|
+
} else {
|
|
347
|
+
say(`预览块(加 --apply 才写入):\n${block}`);
|
|
348
|
+
}
|
|
349
|
+
return { action: "full", reason: msg, changed: d.changed, block, stuck: d.stuck };
|
|
350
|
+
}
|