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/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,244 @@
|
|
|
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
|
+
// 块内行格式:`IP 域名`(同域名可多行:第一条 = 主 IP,其余 = 备选)或 `# absent 域名 时间戳`
|
|
15
|
+
// 主 IP 进 entries(决策/探活只看它),备选进 extras(原样写回,供客户端 fallback)
|
|
16
|
+
export function parseBlock(content) {
|
|
17
|
+
const entries = new Map();
|
|
18
|
+
const extras = new Map();
|
|
19
|
+
const absent = new Map();
|
|
20
|
+
let inBlock = false;
|
|
21
|
+
for (const raw of String(content || "").split(/\r?\n/)) {
|
|
22
|
+
const line = raw.trim();
|
|
23
|
+
if (line.startsWith(BEGIN)) { inBlock = true; continue; }
|
|
24
|
+
if (line.startsWith(END)) { inBlock = false; continue; }
|
|
25
|
+
if (!inBlock) { continue; }
|
|
26
|
+
if (line.startsWith("#")) {
|
|
27
|
+
const m = line.match(/^#\s+absent\s+(\S+)\s+(\S+)/);
|
|
28
|
+
if (m) { absent.set(m[1].toLowerCase(), m[2]); }
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const parts = line.split(/\s+/);
|
|
32
|
+
if (parts.length >= 2 && parts[0] && parts[1]) {
|
|
33
|
+
const domain = parts[1].toLowerCase();
|
|
34
|
+
const ip = parts[0];
|
|
35
|
+
if (!entries.has(domain)) { entries.set(domain, ip); continue; }
|
|
36
|
+
if (entries.get(domain) === ip) { continue; }
|
|
37
|
+
const list = extras.get(domain) || [];
|
|
38
|
+
if (!list.includes(ip)) { list.push(ip); extras.set(domain, list); }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { entries, extras, absent };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 生成健康检查用的 hosts 块:有效条目(每域名 主 IP + 备选)+ 缺失域名冷却注释
|
|
45
|
+
// extras:domain -> [备选 IP...]。同域名多行让客户端在首个 IP 抖动时可自动 fallback。
|
|
46
|
+
export function buildHealthBlock(entries, absent, extras = new Map()) {
|
|
47
|
+
const lines = [BEGIN, `# generated ${new Date().toISOString()} by ghprobe`];
|
|
48
|
+
for (const [domain, ip] of entries) {
|
|
49
|
+
lines.push(`${ip} ${domain}`);
|
|
50
|
+
for (const alt of extras.get(domain) || []) {
|
|
51
|
+
if (alt && alt !== ip) { lines.push(`${alt} ${domain}`); }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
for (const [domain, ts] of absent) { lines.push(`# absent ${domain} ${ts}`); }
|
|
55
|
+
lines.push(END);
|
|
56
|
+
return lines.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 合法域名全集 = 主域名 + 兜底域名;缺失检测与 orphan 清理都以它为准
|
|
60
|
+
export function targetDomains(cfg) {
|
|
61
|
+
const fb = Object.values(cfg.fallbacks || {}).flat();
|
|
62
|
+
return [...new Set([...(cfg.domains || []), ...fb])];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// 由一轮探测结果生成待写入的块。
|
|
66
|
+
// 与 health 写出的块保持同构:没有活 IP 的目标域名记入 `# absent` 冷却注释,
|
|
67
|
+
// 否则下次 health 会把这些域名当「缺失」而白跑一次全量。
|
|
68
|
+
export function blockFromProbeResult(cfg, r) {
|
|
69
|
+
const maxPer = Math.max(1, cfg.hosts?.maxEntriesPerDomain ?? 2);
|
|
70
|
+
const entries = new Map();
|
|
71
|
+
const extras = new Map();
|
|
72
|
+
const put = (d, list) => {
|
|
73
|
+
if (!list || !list.length) { return; }
|
|
74
|
+
entries.set(d, list[0].ip);
|
|
75
|
+
const alt = list.slice(1, maxPer).map((x) => x.ip);
|
|
76
|
+
if (alt.length) { extras.set(d, alt); }
|
|
77
|
+
};
|
|
78
|
+
for (const [d, rs] of r.ranked || []) { put(d, rs); }
|
|
79
|
+
for (const [d, rs] of r.extra || []) { put(d, rs); }
|
|
80
|
+
const absent = new Map();
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
for (const d of targetDomains(cfg)) { if (!entries.has(d)) { absent.set(d, now); } }
|
|
83
|
+
return buildHealthBlock(entries, absent, extras);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 全灭护栏(写入前判定):新块一条有效 IP 都没有、而 hosts 里还有旧条目时,必须拒绝写入 ——
|
|
87
|
+
// 否则会把原本可用的条目一起抹掉(`probe --apply` 此前缺这道护栏,只有 health 有)。
|
|
88
|
+
export function shouldKeepOldBlock(oldContent, newBlock) {
|
|
89
|
+
return parseBlock(newBlock).entries.size === 0 && parseBlock(oldContent).entries.size > 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 决策(纯函数):输入已写入 IP 的探活结果 + 当前块状态,输出动作
|
|
93
|
+
// 横向判据:严格多数条目同时失败视为网络故障(本轮不动,避开黑洞窗口);
|
|
94
|
+
// 单条目失败或缺失域名(非冷却)才触发全量重排
|
|
95
|
+
export function decide(probeResults, current, cfg) {
|
|
96
|
+
const failed = new Set(probeResults.filter((r) => !r.ok).map((r) => r.domain));
|
|
97
|
+
const total = current.entries.size;
|
|
98
|
+
|
|
99
|
+
// 只有「已写入条目全部失败」才可能是网络级故障(黑洞窗口),此时本轮不动、避开窗口。
|
|
100
|
+
// 只要还有任意一条健康,就说明链路是通的,失败的那些是 IP 陈旧/真死,应当重排换 IP。
|
|
101
|
+
// 🔴 旧判据 `failed.size * 2 > total`(多数失败即放弃)已证实会误触发:分母只数已写入
|
|
102
|
+
// 条目、常低到 3~4,实测「3 条陈旧 + 1 条健康」被误判成断网 → 该修的时候不修。
|
|
103
|
+
if (total >= 2 && failed.size === total) {
|
|
104
|
+
return { action: "none", reason: "全部已写入 IP 同时失败,疑似网络故障" };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const cooldownMs = (cfg.schedule?.absentCooldownHours ?? 1) * 3600_000;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const targets = targetDomains(cfg);
|
|
110
|
+
const missing = new Set();
|
|
111
|
+
for (const d of targets) {
|
|
112
|
+
if (current.entries.has(d)) { continue; }
|
|
113
|
+
const ts = current.absent.get(d);
|
|
114
|
+
if (ts && now - new Date(ts).getTime() < cooldownMs) { continue; }
|
|
115
|
+
missing.add(d);
|
|
116
|
+
}
|
|
117
|
+
// orphan:hosts 块里已不在配置(含兜底)中的域名,需清理
|
|
118
|
+
const orphan = [...current.entries.keys()].filter((d) => !targets.includes(d));
|
|
119
|
+
|
|
120
|
+
const diagnose = [...new Set([...failed, ...missing, ...orphan])];
|
|
121
|
+
if (diagnose.length === 0) { return { action: "none", reason: "全部健康" }; }
|
|
122
|
+
return { action: "full", domains: diagnose, reason: `需重排: ${diagnose.join(", ")}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 部分更新(纯函数):只改 diagnose 域名;全灭护栏保证不把好 IP 写成空
|
|
126
|
+
// 返回的新 entries/absent 用于与当前对比,changed 记录真正换掉 IP 的域名
|
|
127
|
+
// stuck:被诊断了、但本轮一个活 IP 都没找到、只能保留旧值的域名(黑洞窗口里必然出现,
|
|
128
|
+
// 显式报出来,否则日志只写「无变化」会让人以为工具没干活)
|
|
129
|
+
export function applyDelta(current, ranked, extra, diagnose, cfg) {
|
|
130
|
+
const entries = new Map(current.entries);
|
|
131
|
+
const extras = new Map(current.extras || []);
|
|
132
|
+
const absent = new Map(current.absent);
|
|
133
|
+
const changed = [];
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const allResult = new Map([...ranked, ...extra]);
|
|
136
|
+
const maxPer = Math.max(1, cfg.hosts?.maxEntriesPerDomain ?? 2);
|
|
137
|
+
|
|
138
|
+
for (const d of diagnose) {
|
|
139
|
+
const list = allResult.get(d) || [];
|
|
140
|
+
const best = list[0];
|
|
141
|
+
if (best) {
|
|
142
|
+
if (entries.get(d) !== best.ip) { entries.set(d, best.ip); changed.push(d); }
|
|
143
|
+
const alt = list.slice(1, maxPer).map((x) => x.ip);
|
|
144
|
+
if (alt.length) { extras.set(d, alt); } else { extras.delete(d); }
|
|
145
|
+
absent.delete(d);
|
|
146
|
+
} else if (!entries.has(d)) {
|
|
147
|
+
absent.set(d, now);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// orphan 清理:hosts 块里已不在配置(含兜底)中的域名,无条件删除(主 IP 与备选一起删)
|
|
152
|
+
const allow = new Set(targetDomains(cfg));
|
|
153
|
+
for (const d of [...entries.keys()]) {
|
|
154
|
+
if (!allow.has(d)) { entries.delete(d); changed.push(d); }
|
|
155
|
+
}
|
|
156
|
+
for (const d of [...extras.keys()]) {
|
|
157
|
+
if (!allow.has(d) || !entries.has(d)) { extras.delete(d); }
|
|
158
|
+
}
|
|
159
|
+
// orphan 已在上一步删掉,这里只剩「配置内、诊断过、却没探到活 IP、于是保留旧值」的域名
|
|
160
|
+
const stuck = diagnose.filter((d) => allow.has(d) && entries.has(d) && !(allResult.get(d) || []).length);
|
|
161
|
+
return { entries, extras, absent, changed, stuck };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function sameMap(a, b) {
|
|
165
|
+
if (a.size !== b.size) { return false; }
|
|
166
|
+
for (const [k, v] of a) { if (b.get(k) !== v) { return false; } }
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 比较「域名 -> 备选 IP 数组」是否一致(值是有序数组,不能直接套 sameMap)
|
|
171
|
+
function sameExtras(a, b) {
|
|
172
|
+
if (a.size !== b.size) { return false; }
|
|
173
|
+
for (const [k, v] of a) {
|
|
174
|
+
const w = b.get(k);
|
|
175
|
+
if (!w || w.length !== v.length) { return false; }
|
|
176
|
+
for (let i = 0; i < v.length; i++) { if (v[i] !== w[i]) { return false; } }
|
|
177
|
+
}
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 完整一轮健康检查:读 -> 测已写入 -> 决策 -> (异常时)全量探测 + 部分更新
|
|
182
|
+
// write=true 时才真正 applyHosts(会弹 UAC),否则仅预览
|
|
183
|
+
export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS) {
|
|
184
|
+
const say = log || (() => {});
|
|
185
|
+
const content = fs.readFileSync(hostsPath, "utf8");
|
|
186
|
+
const current = parseBlock(content);
|
|
187
|
+
|
|
188
|
+
if (current.entries.size === 0) {
|
|
189
|
+
say("hosts 无 ghprobe 记录,触发首次全量");
|
|
190
|
+
return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const jobs = [...current.entries].map(([d, ip]) => () => probeIp(d, ip, cfg));
|
|
194
|
+
const probeResults = await pool(jobs, cfg.probe.concurrency);
|
|
195
|
+
for (const r of probeResults) {
|
|
196
|
+
say(`${r.ok ? "[OK ]" : "[FAIL]"} ${r.domain} @ ${r.ip} err=${r.err}`);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const decision = decide(probeResults, current, cfg);
|
|
200
|
+
if (decision.action === "none") {
|
|
201
|
+
say(`健康:${decision.reason}`);
|
|
202
|
+
return { action: "none", reason: decision.reason, block: null, stuck: [] };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
say(`异常:${decision.reason}`);
|
|
206
|
+
return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// 全量探测 + 部分更新 + 写回;护栏与粘性都在 applyDelta 里体现。
|
|
210
|
+
// 只探 diagnose 里的域名:典型场景是「github.com 的 IP 死了,codeload / jsdelivr 还活着」,
|
|
211
|
+
// 此时探 1 个域名(~8 个候选)而不是 6 个(~48 个),失败发现到修好的时间从 ~90s 压到 ~20s。
|
|
212
|
+
async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS) {
|
|
213
|
+
const say = log || (() => {});
|
|
214
|
+
const r = await runProbe(cfg, log, diagnose);
|
|
215
|
+
const d = applyDelta(current, r.ranked, r.extra, diagnose, cfg);
|
|
216
|
+
|
|
217
|
+
const entriesChanged = !sameMap(d.entries, current.entries);
|
|
218
|
+
const absentChanged = !sameMap(d.absent, current.absent);
|
|
219
|
+
const extrasChanged = !sameExtras(d.extras, current.extras || new Map());
|
|
220
|
+
const stuckNote = d.stuck.length ? `;无可用 IP 保留原值: ${d.stuck.join(", ")}` : "";
|
|
221
|
+
if (!entriesChanged && !absentChanged && !extrasChanged) {
|
|
222
|
+
say(`探测无变化,保持现状(护栏/粘性生效)${stuckNote}`);
|
|
223
|
+
return { action: "none", reason: `无变化${stuckNote}`, block: null, stuck: d.stuck };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const block = buildHealthBlock(d.entries, d.absent, d.extras);
|
|
227
|
+
const msg = d.changed.length ? `更新域名: ${d.changed.join(", ")}` : "更新缺失域名冷却记录";
|
|
228
|
+
if (write) {
|
|
229
|
+
const lock = acquireHostsLock();
|
|
230
|
+
if (!lock) {
|
|
231
|
+
say("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
|
|
232
|
+
return { action: "none", reason: "写锁冲突,本轮跳过", changed: d.changed, block, stuck: d.stuck };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
// 锁内重读最新 hosts:探测耗时期间他人可能已改过,避免覆盖别人刚写的新值
|
|
236
|
+
const latest = fs.readFileSync(hostsPath, "utf8");
|
|
237
|
+
await applyHosts(mergeHosts(latest, block));
|
|
238
|
+
say(`已写入 hosts:${msg}${stuckNote}`);
|
|
239
|
+
} finally { releaseHostsLock(lock); }
|
|
240
|
+
} else {
|
|
241
|
+
say(`预览块(加 --apply 才写入):\n${block}`);
|
|
242
|
+
}
|
|
243
|
+
return { action: "full", reason: msg, changed: d.changed, block, stuck: d.stuck };
|
|
244
|
+
}
|
package/src/hosts.js
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// hosts 片段生成 + 事务化提权写入 + 还原 + DNS 缓存刷新
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
export const WIN_HOSTS = "C:\\Windows\\System32\\drivers\\etc\\hosts";
|
|
10
|
+
const BEGIN = "# === ghprobe begin ===";
|
|
11
|
+
const END = "# === ghprobe end ===";
|
|
12
|
+
|
|
13
|
+
// 用户级数据目录(日志、无窗口包装器)。放 %LOCALAPPDATA% 而不是包目录:
|
|
14
|
+
// npm 全局安装时包目录在 node_modules 下,npm 升级/重装会整个换掉。
|
|
15
|
+
export function userDataDir() {
|
|
16
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), ".ghprobe");
|
|
17
|
+
return path.join(base, "ghprobe");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// 写 hosts 跨进程锁:probe --apply 与计划任务(health --apply)可能并发,避免读改写互踩
|
|
21
|
+
export const HOSTS_LOCK = path.join(os.tmpdir(), "ghprobe-hosts.lock");
|
|
22
|
+
const LOCK_STALE_MS = 60000;
|
|
23
|
+
|
|
24
|
+
function sleepSync(ms) {
|
|
25
|
+
const sab = new SharedArrayBuffer(4);
|
|
26
|
+
Atomics.wait(new Int32Array(sab), 0, 0, ms);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 抢写锁:wx 独占创建,冲突每 retryMs 重试,最多 timeoutMs;超时返回 null 表示本轮放弃。
|
|
30
|
+
// 锁文件带随机 PID+时间戳;超出 LOCK_STALE_MS 视为持有进程崩溃残留,强清后重试。
|
|
31
|
+
export function acquireHostsLock(timeoutMs = 5000, retryMs = 200) {
|
|
32
|
+
const deadline = Date.now() + timeoutMs;
|
|
33
|
+
for (;;) {
|
|
34
|
+
try {
|
|
35
|
+
const fd = fs.openSync(HOSTS_LOCK, "wx");
|
|
36
|
+
fs.writeSync(fd, `${process.pid} ${Date.now()}\n`);
|
|
37
|
+
return fd;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if (e.code !== "EEXIST") { throw e; }
|
|
40
|
+
try {
|
|
41
|
+
const st = fs.statSync(HOSTS_LOCK);
|
|
42
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { fs.unlinkSync(HOSTS_LOCK); continue; }
|
|
43
|
+
} catch { continue; } // 锁刚被持有者释放
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() >= deadline) { return null; }
|
|
46
|
+
sleepSync(retryMs);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function releaseHostsLock(fd) {
|
|
51
|
+
try { if (fd != null) { fs.closeSync(fd); } } catch { /* 忽略 */ }
|
|
52
|
+
try { fs.unlinkSync(HOSTS_LOCK); } catch { /* 忽略 */ }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 从 ranked(域名->有序IP列表) 生成 hosts 块。
|
|
56
|
+
// 🔴 每域名写 maxPer 条(默认 2):**Windows 会把同名多条全部返回**(2026-10-11 实测,
|
|
57
|
+
// dns.lookup/all 与 curl 双证),且 curl / 浏览器会**逐个尝试** —— 黑洞 IP 排在前也能
|
|
58
|
+
// fallback 到后面的活 IP(实测 http=200 / 0.22s)。旧注释「只取第一条生效」是错的。
|
|
59
|
+
// 所以多写 1 条备选 = 抖动时客户端自动兜底,不必干等下一轮健康检查(最长 1 分钟)。
|
|
60
|
+
export function buildBlock(ranked, extraDomains, maxPer = 2) {
|
|
61
|
+
const lines = [BEGIN, `# generated ${new Date().toISOString()} by ghprobe`];
|
|
62
|
+
const put = (domain, rs) => {
|
|
63
|
+
for (const r of (rs || []).slice(0, Math.max(1, maxPer))) { lines.push(`${r.ip} ${domain}`); }
|
|
64
|
+
};
|
|
65
|
+
for (const [domain, rs] of ranked) {
|
|
66
|
+
if (rs.length) { put(domain, rs); }
|
|
67
|
+
}
|
|
68
|
+
for (const [domain, rs] of extraDomains || []) {
|
|
69
|
+
if (rs.length) { put(domain, rs); }
|
|
70
|
+
}
|
|
71
|
+
lines.push(END);
|
|
72
|
+
return lines.join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 把新块合并进已有 hosts 内容:替换旧 ghprobe 块,其余行原样保留
|
|
76
|
+
export function mergeHosts(current, block) {
|
|
77
|
+
const lines = String(current).split(/\r?\n/);
|
|
78
|
+
const out = [];
|
|
79
|
+
let inBlock = false;
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
if (line.startsWith(BEGIN)) { inBlock = true; continue; }
|
|
82
|
+
if (line.startsWith(END)) { inBlock = false; continue; }
|
|
83
|
+
if (!inBlock) { out.push(line); }
|
|
84
|
+
}
|
|
85
|
+
while (out.length && out[out.length - 1].trim() === "") { out.pop(); }
|
|
86
|
+
if (!block) { return out.join("\n") + (out.length ? "\n" : ""); }
|
|
87
|
+
const merged = [...out, "", block].join("\n");
|
|
88
|
+
return merged.endsWith("\n") ? merged : merged + "\n";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 构造提权包装命令:Start-Process -Verb RunAs 拉起目标,并把被提升进程的退出码带回来。
|
|
92
|
+
// 两个关键点(都踩过):
|
|
93
|
+
// ① 先把控制台输出编码设为 UTF-8 —— 否则 PowerShell 以 GBK 写 stderr,Node 按 UTF-8 解码会乱码,
|
|
94
|
+
// 调用方 throw 出来的「提权写入失败: …」就变成一串问号。
|
|
95
|
+
// ② 必须在 `exit $p.ExitCode` 之前判空:Start-Process 自身失败(典型是用户取消 UAC)时
|
|
96
|
+
// 抛的是「非终止错误」,脚本会继续往下执行,$p 仍为 $null,而 `exit $null` 等价于
|
|
97
|
+
// 退出码 0 —— 会把「提权失败」伪装成「成功」。-ErrorAction Stop 让它变成可捕获的终止错误。
|
|
98
|
+
export function buildElevateCommand(file, args = []) {
|
|
99
|
+
const list = args.map((a) => `'${a}'`).join(",");
|
|
100
|
+
return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
|
|
101
|
+
+ ` try { $p = Start-Process -FilePath '${file}' -Verb RunAs -Wait -PassThru -ArgumentList ${list} -ErrorAction Stop }`
|
|
102
|
+
+ ` catch { [Console]::Error.WriteLine('提权失败: ' + $_.Exception.Message); exit 1 };`
|
|
103
|
+
+ ` if ($null -eq $p) { exit 1 };`
|
|
104
|
+
+ ` exit $p.ExitCode`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// 构造「删除计划任务」的提权脚本。单独导出是为了可单测、可独立执行验证。
|
|
108
|
+
// 两个关键点:
|
|
109
|
+
// ① 任务不存在时直接 exit 0(幂等)—— uninstall 重复跑不应报错。
|
|
110
|
+
// ② 删除动作必须 -ErrorAction Stop:否则「拒绝访问」这类错误是非终止的,脚本会继续走到
|
|
111
|
+
// 末尾的 exit 0,把「删除失败」伪装成「成功」。
|
|
112
|
+
export function buildUninstallTaskScript(taskName = "ghprobe") {
|
|
113
|
+
return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
|
|
114
|
+
+ ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
|
|
115
|
+
+ ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
|
|
116
|
+
+ ` try { Unregister-ScheduledTask -TaskName '${taskName}' -Confirm:$false -ErrorAction Stop }`
|
|
117
|
+
+ ` catch { [Console]::Error.WriteLine('删除计划任务失败: ' + $_.Exception.Message); exit 1 };`
|
|
118
|
+
+ ` exit 0`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 构造「写入 hosts + 注册计划任务」的提权脚本(install 用):一次 UAC 完成两件事。
|
|
122
|
+
// 与 buildApplyScript 的区别:中途不 exit,两件事都做完后再按综合结果返回退出码,
|
|
123
|
+
// 这样 install 只需弹一次 UAC,且能拿到「两件事各自成功与否」的确定结果。
|
|
124
|
+
export function buildInstallScript({ hostsPath, tmpPath, nodePath, binPath, cfgArg = "", minutes = 1, writeHosts = true, vbsPath = "" }) {
|
|
125
|
+
const bak = `${hostsPath}.ghprobe-bak`;
|
|
126
|
+
const hostsPart = writeHosts ? `
|
|
127
|
+
try {
|
|
128
|
+
Copy-Item -Force $src $bak
|
|
129
|
+
Copy-Item -Force $tmp $src
|
|
130
|
+
$now = [IO.File]::ReadAllText($src, $enc)
|
|
131
|
+
if ($now -match [regex]::Escape($marker)) { $hostsOk = $true }
|
|
132
|
+
else { Copy-Item -Force $bak $src; [Console]::Error.WriteLine('hosts 校验失败:未找到 ghprobe 标记,已回滚') }
|
|
133
|
+
} catch {
|
|
134
|
+
if (Test-Path $bak) { Copy-Item -Force $bak $src }
|
|
135
|
+
[Console]::Error.WriteLine('hosts 写入失败: ' + $_.Exception.Message)
|
|
136
|
+
}
|
|
137
|
+
if ($hostsOk) { ipconfig /flushdns | Out-Null }` : "";
|
|
138
|
+
// 任务动作二选一:
|
|
139
|
+
// ① 有 VBS 包装器(默认)→ 用 wscript.exe 跑它,VBS 内以 SW_HIDE 启动 node —— 全程无窗口;
|
|
140
|
+
// ② 没有(老调用方 / 包装器写失败)→ 直接跑 node,功能不受影响,但会闪一个控制台窗口。
|
|
141
|
+
const actionLine = vbsPath
|
|
142
|
+
? `$a = New-ScheduledTaskAction -Execute "$env:SystemRoot\\System32\\wscript.exe" -Argument ('"' + '${vbsPath}' + '"')`
|
|
143
|
+
: `$a = New-ScheduledTaskAction -Execute '${nodePath}' -Argument '"${binPath}" health --apply${cfgArg}'`;
|
|
144
|
+
return `
|
|
145
|
+
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
|
|
146
|
+
$src = '${hostsPath}'
|
|
147
|
+
$tmp = '${tmpPath}'
|
|
148
|
+
$bak = '${bak}'
|
|
149
|
+
$marker = '${BEGIN}'
|
|
150
|
+
$enc = [Text.Encoding]::Default
|
|
151
|
+
$hostsOk = $${writeHosts ? "false" : "true"}
|
|
152
|
+
${hostsPart}
|
|
153
|
+
$taskOk = $false
|
|
154
|
+
try {
|
|
155
|
+
${actionLine}
|
|
156
|
+
$t = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes ${minutes})
|
|
157
|
+
$p = New-ScheduledTaskPrincipal -UserId "$env:COMPUTERNAME\\$env:USERNAME" -LogonType Interactive -RunLevel Highest
|
|
158
|
+
Register-ScheduledTask -TaskName ghprobe -Action $a -Trigger $t -Principal $p -Force | Out-Null
|
|
159
|
+
$taskOk = $true
|
|
160
|
+
} catch {
|
|
161
|
+
[Console]::Error.WriteLine('注册计划任务失败: ' + $_.Exception.Message)
|
|
162
|
+
}
|
|
163
|
+
if ($hostsOk -and $taskOk) { exit 0 }
|
|
164
|
+
exit 1
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// 构造「无窗口运行」包装器(VBScript)。
|
|
169
|
+
// 为什么需要它:Task Scheduler 在交互会话里启动控制台程序(node.exe)会**必然**新建一个
|
|
170
|
+
// 控制台窗口,而 node.exe 没有任何「隐藏窗口」开关(不像 powershell 有 -WindowStyle Hidden)。
|
|
171
|
+
// 唯一可靠的做法是让 wscript.exe 起一层:WScript.Shell.Run 的第二个参数是 ShowWindow 风格,
|
|
172
|
+
// 传 0 = SW_HIDE,且该方式自 Win7 起一直有效。第三个参数 True = 等子进程结束再退出。
|
|
173
|
+
// 🔴 注释必须**纯 ASCII**:这个文件以 "ascii" 编码落盘(wscript 对无 BOM 的 .vbs 按 ANSI 解析,
|
|
174
|
+
// 写 UTF-8 中文会变乱码),中文字符会被掩掉高位变成一堆问号/方块。
|
|
175
|
+
// VBS 字符串里 `"` 要写成 `""`(成对双写),所以路径里的引号要整体转义一遍。
|
|
176
|
+
export function buildHiddenRunnerVbs(nodePath, binPath, args = "") {
|
|
177
|
+
const cmd = `"${nodePath}" "${binPath}"${args ? " " + args : ""}`;
|
|
178
|
+
return [
|
|
179
|
+
"' ghprobe hidden-runner wrapper - generated by \"ghprobe install\", removed by \"ghprobe uninstall\".",
|
|
180
|
+
"' Do not edit. ASCII-only on purpose (wscript reads BOM-less .vbs as ANSI).",
|
|
181
|
+
'Set sh = CreateObject("WScript.Shell")',
|
|
182
|
+
`sh.Run "${cmd.replace(/"/g, '""')}", 0, True`,
|
|
183
|
+
"",
|
|
184
|
+
].join("\r\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 判断当前进程是否已经有管理员权限:hosts 是只有管理员能写的系统文件,
|
|
188
|
+
// 用「以读写方式打开」做探测,成功即已提权。比 spawn 一个 PowerShell 去查 token 便宜得多,
|
|
189
|
+
// 而且不产生任何窗口。已提权时 applyHosts 直接跑脚本,不再走 Start-Process -Verb RunAs
|
|
190
|
+
// (那条路会再拉一个新控制台窗口,无窗口运行就破了)。
|
|
191
|
+
export function isElevated(hostsPath = WIN_HOSTS) {
|
|
192
|
+
try { fs.closeSync(fs.openSync(hostsPath, "r+")); return true; } catch { return false; }
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 跑一段 PowerShell 命令,回传 { code, out, err }
|
|
196
|
+
function runPowerShellCommand(cmd) {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const child = execFile("powershell", ["-NoProfile", "-Command", cmd], { windowsVerbatimArguments: true, windowsHide: true });
|
|
199
|
+
let out = "";
|
|
200
|
+
let err = "";
|
|
201
|
+
child.stdout?.on("data", (d) => { out += d; });
|
|
202
|
+
child.stderr?.on("data", (d) => { err += d; });
|
|
203
|
+
child.on("close", (code) => resolve({ code, out, err }));
|
|
204
|
+
child.on("error", reject);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 直接执行一个 .ps1(调用方保证已经提权)。windowsHide 让 powershell 以 CREATE_NO_WINDOW
|
|
209
|
+
// 启动,不闪窗;被提升的父进程若本身就在隐藏控制台里,子进程也是继承而非新建。
|
|
210
|
+
function runPowerShellFile(file) {
|
|
211
|
+
return new Promise((resolve, reject) => {
|
|
212
|
+
const child = execFile("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", file], { windowsHide: true });
|
|
213
|
+
let out = "";
|
|
214
|
+
let err = "";
|
|
215
|
+
child.stdout?.on("data", (d) => { out += d; });
|
|
216
|
+
child.stderr?.on("data", (d) => { err += d; });
|
|
217
|
+
child.on("close", (code) => resolve({ code, out, err }));
|
|
218
|
+
child.on("error", reject);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 跑一次提权包装。单独导出是为了让回归测试能注入一个必然失败的 Start-Process 目标,
|
|
223
|
+
// 从而验证「提权失败不会退化成退出码 0」。
|
|
224
|
+
export function runElevationWrapper(file, args = []) {
|
|
225
|
+
return runPowerShellCommand(buildElevateCommand(file, args));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// 写提权脚本文件。🔴 必须带 UTF-8 BOM:PowerShell 5.1 对「无 BOM 的 .ps1」按系统 ANSI
|
|
229
|
+
// 代码页(中文系统 = GBK)解析,脚本里的中文字面量(错误提示)会整段变乱码。
|
|
230
|
+
// 加 BOM 后 PS 才按 UTF-8 解析。注意这条路径与 buildElevateCommand 不同 ——
|
|
231
|
+
// 那条走 `-Command`,参数由 CreateProcessW 以 UTF-16 传递,本来就不受文件编码影响。
|
|
232
|
+
export function writePs1WithBom(file, content) {
|
|
233
|
+
fs.writeFileSync(file, "\ufeff" + content, "utf8");
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// 写临时 ps1 -> 提权执行 -> 取回退出码 -> 清理 ps1(apply/restore/install 复用)。
|
|
237
|
+
// 已经提权时(计划任务 runlevel=highest)直接执行脚本,不走 Start-Process -Verb RunAs ——
|
|
238
|
+
// 后者会再拉一个独立控制台窗口,是「无窗口运行」的第二个破口。
|
|
239
|
+
export async function runElevated(content) {
|
|
240
|
+
const tmp = path.join(os.tmpdir(), `ghprobe-${Date.now()}.ps1`);
|
|
241
|
+
writePs1WithBom(tmp, content);
|
|
242
|
+
try {
|
|
243
|
+
if (isElevated()) { return await runPowerShellFile(tmp); }
|
|
244
|
+
return await runElevationWrapper("powershell", ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", tmp]);
|
|
245
|
+
} finally {
|
|
246
|
+
try { fs.unlinkSync(tmp); } catch { /* 清理失败不管 */ }
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// 构造 hosts 写入脚本(由 runElevated 提权执行)。两个要点:
|
|
251
|
+
// ① 备份**每次刷新**(不是「只建一次」)—— 只建一次时,若 hosts 在安装后被第三方追加了
|
|
252
|
+
// 条目,那份陈旧备份里没有它们,卸载/回滚时会把它们一起抹掉。
|
|
253
|
+
// ② 读写统一用 [Text.Encoding]::Default(系统 ANSI 代码页),保证 hosts 里的非 ASCII
|
|
254
|
+
// 注释(不少工具会写中文标记)编码不变。
|
|
255
|
+
export function buildApplyScript(hostsPath, tmpPath) {
|
|
256
|
+
const bak = `${hostsPath}.ghprobe-bak`;
|
|
257
|
+
return `
|
|
258
|
+
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
|
|
259
|
+
$src = '${hostsPath}'
|
|
260
|
+
$tmp = '${tmpPath}'
|
|
261
|
+
$bak = '${bak}'
|
|
262
|
+
$marker = '${BEGIN}'
|
|
263
|
+
$enc = [Text.Encoding]::Default
|
|
264
|
+
try {
|
|
265
|
+
Copy-Item -Force $src $bak
|
|
266
|
+
Copy-Item -Force $tmp $src
|
|
267
|
+
$now = [IO.File]::ReadAllText($src, $enc)
|
|
268
|
+
if ($now -notmatch [regex]::Escape($marker)) {
|
|
269
|
+
Copy-Item -Force $bak $src
|
|
270
|
+
[Console]::Error.WriteLine('hosts 校验失败:未找到 ghprobe 标记,已回滚')
|
|
271
|
+
exit 1
|
|
272
|
+
}
|
|
273
|
+
ipconfig /flushdns | Out-Null
|
|
274
|
+
exit 0
|
|
275
|
+
} catch {
|
|
276
|
+
if (Test-Path $bak) { Copy-Item -Force $bak $src }
|
|
277
|
+
[Console]::Error.WriteLine('hosts 写入失败: ' + $_.Exception.Message)
|
|
278
|
+
exit 1
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 构造 hosts 还原脚本(由 runElevated 提权执行):剥掉整个 ghprobe 块后写回。
|
|
284
|
+
// 刻意**不**用 *.ghprobe-bak 直接覆盖 —— 备份是「写入前」的快照,可能含旧块,也可能
|
|
285
|
+
// 早于第三方后来追加的条目。直接剥块才能保证「只移除 ghprobe 自己的行」。
|
|
286
|
+
// 🔴 必须用状态机跳过 begin..end 之间的**所有行**:早期写法是 `Where-Object { $_ -notmatch
|
|
287
|
+
// '^# === ghprobe (begin|end) ===' }`,它只删标记行、把块内 IP 条目全留下 ——
|
|
288
|
+
// 以前靠备份分支兜着没暴露,去掉备份分支后就会留下一堆孤儿条目。
|
|
289
|
+
export function buildRestoreScript(hostsPath) {
|
|
290
|
+
return `
|
|
291
|
+
try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {}
|
|
292
|
+
$src = '${hostsPath}'
|
|
293
|
+
$enc = [Text.Encoding]::Default
|
|
294
|
+
try {
|
|
295
|
+
$current = [IO.File]::ReadAllText($src, $enc)
|
|
296
|
+
$keep = @()
|
|
297
|
+
$skip = $false
|
|
298
|
+
foreach ($line in ($current -split '\\r?\\n')) {
|
|
299
|
+
if ($line -match '^# === ghprobe begin ===') { $skip = $true; continue }
|
|
300
|
+
if ($line -match '^# === ghprobe end ===') { $skip = $false; continue }
|
|
301
|
+
if (-not $skip) { $keep += $line }
|
|
302
|
+
}
|
|
303
|
+
$clean = ($keep -join [Environment]::NewLine).TrimEnd() + [Environment]::NewLine
|
|
304
|
+
[IO.File]::WriteAllText($src, $clean, $enc)
|
|
305
|
+
ipconfig /flushdns | Out-Null
|
|
306
|
+
exit 0
|
|
307
|
+
} catch {
|
|
308
|
+
[Console]::Error.WriteLine('hosts 还原失败: ' + $_.Exception.Message)
|
|
309
|
+
exit 1
|
|
310
|
+
}
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 事务化提权写入:刷新备份 -> 覆盖 -> 校验含 BEGIN -> 失败回滚 -> 刷 DNS 缓存
|
|
315
|
+
export async function applyHosts(merged, hostsPath = WIN_HOSTS) {
|
|
316
|
+
const tmp = path.join(os.tmpdir(), `ghprobe-hosts-${Date.now()}.tmp`);
|
|
317
|
+
fs.writeFileSync(tmp, merged, "utf8");
|
|
318
|
+
try {
|
|
319
|
+
const r = await runElevated(buildApplyScript(hostsPath, tmp));
|
|
320
|
+
if (r.code !== 0) { throw new Error(`提权写入失败: ${r.err || r.out}`); }
|
|
321
|
+
return { ok: true, backup: `${hostsPath}.ghprobe-bak` };
|
|
322
|
+
} finally {
|
|
323
|
+
try { fs.unlinkSync(tmp); } catch { /* 清理失败不管 */ }
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 还原 hosts:剥掉 ghprobe 块后写回;最后刷 DNS 缓存
|
|
328
|
+
export async function restoreHosts(hostsPath = WIN_HOSTS) {
|
|
329
|
+
const r = await runElevated(buildRestoreScript(hostsPath));
|
|
330
|
+
if (r.code !== 0) { throw new Error(`还原 hosts 失败: ${r.err || r.out}`); }
|
|
331
|
+
return { ok: true };
|
|
332
|
+
}
|