ghprobe 0.1.3 → 0.1.6

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/daemon.js ADDED
@@ -0,0 +1,153 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // daemon:常驻自愈循环(0.5.0,无缝切换的核心)。
4
+ //
5
+ // 为什么需要:0.4.0 及以前的自愈靠计划任务**每分钟起一个新进程**,最坏检测延迟 60s +
6
+ // 全量重排 ~30s,实测一次抖动窗口从断到修 ~6 分钟。常驻进程把检测压到 15~45s 自适应循环,
7
+ // 故障发现→修复的全程都不再有「等下一分钟」的固定开销。
8
+ //
9
+ // 与监督者的契约(监督者 = 现有的每分钟 `health --apply` 任务,不需要重新注册):
10
+ // · daemon 每轮循环写心跳文件(daemon.heartbeat)
11
+ // · 监督者看到心跳新鲜 -> 秒退(daemon 在干活);陈旧/没有 -> 拉起新 daemon + 本轮照常兜底
12
+ // · daemon 崩溃 -> 下一分钟监督者自动复活它;旧版本 npm 用户没有心跳文件,行为完全不变
13
+ import fs from "node:fs";
14
+ import path from "node:path";
15
+ import { userDataDir, applyHosts, mergeHosts, WIN_HOSTS, isPaused } from "./hosts.js";
16
+ import { hasLocalNetwork } from "./net.js";
17
+ import { loadConfig } from "./config.js";
18
+ import { parseBlock, runHealth, shouldKeepOldBlock } from "./health.js";
19
+ import { poolNeedsUpdate, updatePool } from "./pool.js";
20
+ import { createSwitchGate } from "./algorithm.js";
21
+
22
+ const HEARTBEAT_MAX_AGE_MS = 150_000; // 心跳超过这个年龄视为陈旧(单轮最坏 ~90s,留余量)
23
+
24
+ export function heartbeatPath() {
25
+ return path.join(userDataDir(), "daemon.heartbeat");
26
+ }
27
+
28
+ export function writeHeartbeat(file = heartbeatPath()) {
29
+ try {
30
+ fs.mkdirSync(path.dirname(file), { recursive: true });
31
+ fs.writeFileSync(file, JSON.stringify({ at: new Date().toISOString(), pid: process.pid }));
32
+ } catch { /* 写不了就算了,监督者会按陈旧处理并兜底 */ }
33
+ }
34
+
35
+ export function readHeartbeat(file = heartbeatPath()) {
36
+ try {
37
+ const o = JSON.parse(fs.readFileSync(file, "utf8"));
38
+ return o && typeof o === "object" && o.pid ? o : null;
39
+ } catch { return null; }
40
+ }
41
+
42
+ // 心跳是否新鲜(纯函数,now 可注入)
43
+ export function heartbeatFresh(hb, now = Date.now(), maxAgeMs = HEARTBEAT_MAX_AGE_MS) {
44
+ if (!hb || !hb.at) { return false; }
45
+ const t = new Date(hb.at).getTime();
46
+ return Number.isFinite(t) && now - t >= 0 && now - t < maxAgeMs;
47
+ }
48
+
49
+ // 进程是否还活着:process.kill(pid, 0) 只查存在性;EPERM = 存在但无权限(也算活着)
50
+ export function processAlive(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) { return false; }
52
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === "EPERM"; }
53
+ }
54
+
55
+ // 监督者决策(纯函数):心跳新鲜且持有者活着 -> 秒退;否则需要拉起/兜底
56
+ export function daemonRunning(hb, now = Date.now()) {
57
+ return heartbeatFresh(hb, now) && processAlive(hb.pid) && hb.pid !== process.pid;
58
+ }
59
+
60
+ // 自适应间隔(纯函数):
61
+ // 断网 -> 120s(断网时探测无意义,省唤醒;恢复由下一轮本地检查发现)
62
+ // 有异常/有变更 -> 15s(刚经历故障,密集盯防确认稳定)
63
+ // 全部健康 -> 45s(空闲基线;比 1 分钟任务更密,且没有进程启动开销)
64
+ export function nextIntervalMs(result, intervals = {}) {
65
+ const { healthy = 45_000, fast = 15_000, offline = 120_000 } = intervals;
66
+ if (!result || result.offline) { return offline; }
67
+ const troubled = result.action !== "none" || (Array.isArray(result.stuck) && result.stuck.length > 0);
68
+ return troubled ? fast : healthy;
69
+ }
70
+
71
+ // 单轮自愈:断网跳过 -> 到点刷池(带 hosts 健康条目当种子)-> runHealth(写 hosts)。
72
+ // deps.gate(0.6.0):用户行为门控 —— 非紧急写入在「用户正用浏览器」时被推迟,
73
+ // 攒进门控的 pendingBlock;健康轮结束后由 flushPending 空闲落地。
74
+ export async function daemonCycle(cfg, log, deps = {}) {
75
+ // ghprobe off 后:不探测、不写 hosts。暂停检查必须放在最前 —— 连「断网跳过」都
76
+ // 不能先判,off 语义是「先别动我的网络」,探测请求本身也不该发。
77
+ // deps.isPaused 可注入(单测不碰真实用户目录)。
78
+ if ((deps.isPaused || isPaused)()) {
79
+ return { action: "skip", reason: "已暂停(ghprobe off 恢复)", offline: false, block: null, stuck: [] };
80
+ }
81
+ if (!(await (deps.hasNetwork || hasLocalNetwork)(cfg))) {
82
+ return { action: "skip", reason: "本地无网络连接", offline: true, block: null, stuck: [] };
83
+ }
84
+ if (poolNeedsUpdate(cfg)) {
85
+ try {
86
+ const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
87
+ await updatePool(cfg, log, [...cur.entries.values()]);
88
+ } catch (e) {
89
+ log(`候选池更新失败(忽略,用现有池继续): ${e.message}`);
90
+ }
91
+ }
92
+ const hooks = deps.gate ? { beforeApply: deps.gate.beforeApply } : {};
93
+ const result = await runHealth(cfg, log, true, WIN_HOSTS, undefined, hooks);
94
+ // 空闲落地:上轮被推迟的块,此刻成本低就写掉(含全灭护栏)
95
+ if (deps.gate && deps.gate.pending && (!result || result.action === "none" || result.action === "deferred")) {
96
+ try {
97
+ await flushPending(cfg, log, deps.gate, deps.hostsPath || WIN_HOSTS, deps.applyHosts);
98
+ } catch (e) {
99
+ log(`推迟块落地失败(下轮重试): ${e.message}`);
100
+ }
101
+ }
102
+ return result;
103
+ }
104
+
105
+ // 把门控攒下的块落地:重读最新 hosts + 全灭护栏 + 合并写入。
106
+ // deps.applyHosts 可注入(单测不写真 hosts)。
107
+ export async function flushPending(cfg, log, gate, hostsPath = WIN_HOSTS, applyHostsImpl = applyHosts) {
108
+ return gate.maybeFlush(cfg, log, {
109
+ hostsPath,
110
+ applyHosts: (merged, p) => applyHostsImpl(merged, p),
111
+ mergeLatest: (block) => {
112
+ const latest = fs.readFileSync(hostsPath, "utf8");
113
+ if (shouldKeepOldBlock(latest, block)) {
114
+ throw new Error("全灭护栏:推迟块无有效 IP 而现有条目可用,放弃落地");
115
+ }
116
+ return mergeHosts(latest, block);
117
+ },
118
+ });
119
+ }
120
+
121
+ // 常驻主循环。返回条件:单实例让位(更年轻的 daemon 在跑)或 deps.maxCycles 用完(测试)。
122
+ export async function runDaemon(cfg, log = () => {}, deps = {}) {
123
+ const gate = deps.gate !== undefined ? deps.gate : createSwitchGate();
124
+ const cycle = deps.cycle || ((c, l) => daemonCycle(c, l, { gate, applyHosts: deps.applyHosts, hostsPath: deps.hostsPath }));
125
+ const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
126
+ const intervals = deps.intervals || {};
127
+ const maxCycles = deps.maxCycles ?? Infinity;
128
+ const hb = readHeartbeat();
129
+ if (daemonRunning(hb)) { return { started: false, reason: "已有 daemon 在跑(心跳新鲜)" }; }
130
+ writeHeartbeat();
131
+ log(`daemon 已启动 (pid ${process.pid})`);
132
+ let cycles = 0;
133
+ for (;;) {
134
+ let result = null;
135
+ let cfgNow = cfg;
136
+ try {
137
+ cfgNow = loadConfig(); // 每轮重载:配置编辑与池缓存更新即时生效
138
+ result = await cycle(cfgNow, log);
139
+ if (result && result.action) { log(`daemon cycle action=${result.action} reason=${result.reason || ""}`); }
140
+ } catch (e) {
141
+ log(`daemon 循环异常(不退出,继续下一轮): ${e.message}`);
142
+ result = { action: "error" }; // 异常按「有事」处理 -> fast 档,别在故障后睡满 2 分钟
143
+ }
144
+ writeHeartbeat();
145
+ cycles += 1;
146
+ if (cycles >= maxCycles) { return { started: true, cycles }; }
147
+ const newer = readHeartbeat();
148
+ if (newer && newer.pid !== process.pid && heartbeatFresh(newer) && processAlive(newer.pid)) {
149
+ return { started: true, cycles, yielded: true }; // 让位给更年轻的实例
150
+ }
151
+ await sleep(nextIntervalMs(result, intervals));
152
+ }
153
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,174 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // doctor:一键自检。把「网络 / DoH / 探测 / hosts / 计划任务」五层各查一遍,
4
+ // 输出人话结论 + 下一步该做什么 —— 面向不想读日志的普通用户。
5
+ // 评估逻辑全部拆成纯函数(输入状态、输出结论),编排函数 runDoctor 的依赖可注入,
6
+ // 测试不打真网络。
7
+ import { execFile } from "node:child_process";
8
+ import { readFileSync } from "node:fs";
9
+ import { resolveAll } from "./doh.js";
10
+ import { runProbe } from "./probe.js";
11
+ import { hasLocalNetwork } from "./net.js";
12
+ import { parseBlock, targetDomains, blockedDomainsFromRaw } from "./health.js";
13
+ import { WIN_HOSTS } from "./hosts.js";
14
+
15
+ // 单条结论:status = ok(正常)/ warn(能用但要注意)/ fail(有问题)/ info(仅供参考)
16
+ // hint 只在非 ok 时给「下一步做什么」,正常项不啰嗦
17
+ export function check(id, title, status, detail, hint = "") {
18
+ return { id, title, status, detail, hint };
19
+ }
20
+
21
+ // 从一轮探测结果逐域名给结论。
22
+ // 三类情形要分开说:有活 IP(ok)/候选全灭但疑似 TLS 指纹被阻断(warn:浏览器可能仍能用,
23
+ // 换 IP 修不了)/候选全灭(fail:等自愈或查池)。
24
+ // tlsBlockedDomains 由 blockedDomainsFromRaw() 算出,这里不重复判 —— 单一事实来源。
25
+ export function evaluateProbe(ranked, tlsBlockedDomains, cfg) {
26
+ const out = [];
27
+ for (const domain of cfg.domains) {
28
+ const rs = ranked.get(domain) || [];
29
+ if (rs.length) {
30
+ const best = rs[0];
31
+ out.push(check(`probe:${domain}`, `探测 ${domain}`,
32
+ "ok", `最优 ${best.ip}(tcp ${best.tcpMs}ms / tls ${best.tlsMs}ms / 首字节 ${best.ttfb}ms),共 ${rs.length} 个可用`));
33
+ } else if (tlsBlockedDomains.has(domain)) {
34
+ out.push(check(`probe:${domain}`, `探测 ${domain}`, "warn",
35
+ "候选全部在 TLS 阶段被重置 —— 本机探测客户端(Node)的 TLS 指纹被识别,换 IP 修不了",
36
+ "你的浏览器很可能仍能正常访问该域名;若浏览器也不行,只能换域名入口(兜底链,见 README 第 6 节)"));
37
+ } else {
38
+ out.push(check(`probe:${domain}`, `探测 ${domain}`, "fail",
39
+ "所有候选 IP 都不可用",
40
+ "稍等几分钟让计划任务自动重排;持续如此先跑 `ghprobe update-pool` 刷新候选池,再不行就是该网络到 GitHub 的链路整体不可达"));
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+
46
+ // hosts 块状态:有没有写、写的够不够。探测结果(ranked)与 hosts 是两回事 ——
47
+ // 「本轮测出了活 IP」不等于「已经写进 hosts」,doctor 要把两者对上。
48
+ export function evaluateHosts(content, cfg, probed = true) {
49
+ const current = parseBlock(content);
50
+ const n = current.entries.size;
51
+ if (n === 0) {
52
+ return { state: current, checks: [check("hosts", "hosts 写入状态", probed ? "fail" : "info",
53
+ "hosts 里还没有 ghprobe 块",
54
+ probed ? "运行 `ghprobe install`:写入 hosts 并注册每分钟自愈的计划任务(一次 UAC)" : "")] };
55
+ }
56
+ const targets = targetDomains(cfg);
57
+ const absent = [...current.absent.keys()];
58
+ const blocked = [...(current.blocked || new Map()).keys()];
59
+ // 「未写入」只算真正没写入的:absent/blocked 域名有专门的状态注释,不算缺失,
60
+ // 否则同一个域名会在 detail 里出现两遍,读起来像出了两个问题。
61
+ const cooling = new Set([...absent, ...blocked]);
62
+ const missing = targets.filter((d) => !current.entries.has(d) && !cooling.has(d));
63
+ const detail = `已写入 ${n} 个域名`
64
+ + `${absent.length ? `;缺(冷却中): ${absent.join(", ")}` : ""}`
65
+ + `${blocked.length ? `;探测不可判: ${blocked.join(", ")}` : ""}`;
66
+ if (missing.length) {
67
+ return { state: current, checks: [check("hosts", "hosts 写入状态", "warn", detail + `;配置里有但未写入: ${missing.join(", ")}`,
68
+ "下一轮健康检查会自动补写;急用可手动跑 `ghprobe health --apply`")] };
69
+ }
70
+ return { state: current, checks: [check("hosts", "hosts 写入状态", "ok", detail)] };
71
+ }
72
+
73
+ // 计划任务在不在。用 schtasks 查(只读、不弹 UAC);queryResult = { output, error } 或 null(非 Windows)。
74
+ // 查询本身失败不等于没注册,单独标 warn 而不是 fail,避免把「权限/语法问题」夸大成「自愈失效」。
75
+ export function evaluateTask(queryResult) {
76
+ if (queryResult === null || queryResult === undefined) {
77
+ return check("task", "计划任务(每分钟自愈)", "info", "跳过(仅 Windows 支持查询)");
78
+ }
79
+ if (queryResult.output && /ghprobe/i.test(queryResult.output)) {
80
+ return check("task", "计划任务(每分钟自愈)", "ok", "已注册(ghprobe)");
81
+ }
82
+ if (queryResult.error) {
83
+ return check("task", "计划任务(每分钟自愈)", "warn", `查询失败:${queryResult.error}`,
84
+ "无法确认自愈是否在跑;可重跑 `ghprobe install` 修复");
85
+ }
86
+ return check("task", "计划任务(每分钟自愈)", "fail", "未注册 —— IP 失效后不会自动换",
87
+ "运行 `ghprobe install` 注册(需要一次 UAC)");
88
+ }
89
+
90
+ // DoH 端点连通性:只要有一个端点解析成功就算过;全挂意味着候选只能靠内置池,
91
+ // 池子一过期就彻底失明,属于要提醒的问题。
92
+ export function evaluateDoh(sources, ips) {
93
+ const ok = (sources || []).filter((s) => s.ok);
94
+ const names = ok.map((s) => s.ep.name).join(", ");
95
+ if (ok.length) {
96
+ return check("doh", "DoH 解析", "ok", `${ok.length}/${sources.length} 个端点可用(${names}),解析出 ${ips.length} 个候选`);
97
+ }
98
+ return check("doh", "DoH 解析", "warn", "所有 DoH 端点都不可达,候选只能靠内置 IP 池",
99
+ "内置池会过期;检查防火墙是否拦了 443 出站,或在配置里更换 doh 端点");
100
+ }
101
+
102
+ // 汇总:有无 fail。warn 不算不健康 —— blocked 域名(浏览器可能仍可用)不该让 doctor 退出码非零。
103
+ export function overall(checks) {
104
+ const failed = checks.filter((c) => c.status === "fail");
105
+ return { ok: failed.length === 0, failed: failed.map((c) => c.id) };
106
+ }
107
+
108
+ const MARK = { ok: "[ OK ]", warn: "[WARN]", fail: "[FAIL]", info: "[INFO]" };
109
+
110
+ export function formatReport(checks) {
111
+ const lines = [];
112
+ for (const c of checks) {
113
+ lines.push(`${MARK[c.status]} ${c.title} —— ${c.detail}`);
114
+ if (c.hint) { lines.push(` ↳ ${c.hint}`); }
115
+ }
116
+ const o = overall(checks);
117
+ lines.push(o.ok ? "结论:一切正常。" : `结论:${o.failed.length} 项需要处理(见上)。`);
118
+ return lines.join("\n");
119
+ }
120
+
121
+ // 查计划任务(Windows):schtasks 只读查询,不需要管理员。返回 { output, error };
122
+ // 非 Windows 返回 null(evaluateTask 据此跳过)。deps.taskQuery 可注入以便测试。
123
+ export function queryTask(taskName = "ghprobe") {
124
+ if (process.platform !== "win32") { return Promise.resolve(null); }
125
+ return new Promise((resolve) => {
126
+ execFile("schtasks", ["/query", "/tn", taskName], { windowsHide: true }, (e, stdout, stderr) => {
127
+ resolve(e
128
+ ? { output: "", error: stderr ? String(stderr).trim().split(/\r?\n/)[0] : String(e.message).slice(0, 80) }
129
+ : { output: String(stdout), error: "" });
130
+ });
131
+ });
132
+ }
133
+
134
+ // 完整一轮自检。deps 全部可注入(测试用);缺省用真实实现。
135
+ // 返回 { ok, checks },CLI 层负责打印与退出码。
136
+ export async function runDoctor(cfg, log = () => {}, deps = {}) {
137
+ const d = {
138
+ hasNetwork: deps.hasNetwork || hasLocalNetwork,
139
+ dohResolve: deps.dohResolve || resolveAll,
140
+ probe: deps.probe || runProbe,
141
+ readHosts: deps.readHosts || (() => { try { return readFileSync(WIN_HOSTS, "utf8"); } catch { return ""; } }),
142
+ taskQuery: deps.taskQuery || queryTask,
143
+ blockedFromRaw: deps.blockedFromRaw || null,
144
+ };
145
+ const checks = [];
146
+
147
+ // ① 本地网络:断网时后面的探测全无意义,直接给结论收场
148
+ const online = await d.hasNetwork(cfg);
149
+ checks.push(online
150
+ ? check("network", "本地网络", "ok", "可上网")
151
+ : check("network", "本地网络", "fail", "本机无网络连接",
152
+ "ghprobe 修不了断网:先恢复联网,之后计划任务会自动继续"));
153
+ if (!online) { return { ok: false, checks }; }
154
+
155
+ // ② DoH 连通性:deps.probe 被注入(测试替身)时跳过,避免测试打真网络
156
+ if (!deps.probe) {
157
+ const { ips, sources } = await d.dohResolve(cfg.doh, cfg.domains[0], cfg.timeouts.dohMs);
158
+ checks.push(evaluateDoh(sources, ips));
159
+ }
160
+
161
+ // ③ 一轮完整探测
162
+ const r = await d.probe(cfg, log);
163
+ const blocked = (d.blockedFromRaw || blockedDomainsFromRaw)(r.raw || []);
164
+ checks.push(...evaluateProbe(r.ranked || new Map(), blocked, cfg));
165
+
166
+ // ④ hosts 块状态
167
+ const hosts = evaluateHosts(d.readHosts(), cfg, Boolean(deps.probe));
168
+ checks.push(...hosts.checks);
169
+
170
+ // ⑤ 计划任务
171
+ checks.push(evaluateTask(await d.taskQuery()));
172
+
173
+ return { ...overall(checks), checks };
174
+ }
package/src/health.js CHANGED
@@ -265,15 +265,17 @@ function sameExtras(a, b) {
265
265
  }
266
266
 
267
267
  // 完整一轮健康检查:读 -> 测已写入 -> 决策 -> (异常时)全量探测 + 部分更新
268
- // write=true 时才真正 applyHosts(会弹 UAC),否则仅预览
269
- export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS) {
268
+ // write=true 时才真正 applyHosts(会弹 UAC),否则仅预览。
269
+ // hooks.beforeApply(0.6.0,daemon 传):写前门控 —— meta = { deadDomains, initial, changed,
270
+ // reason, block, cfg },返回 false 表示「按用户行为算法推迟」,本轮不落盘、块由门控方保存。
271
+ export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS, promote = new Map(), hooks = {}) {
270
272
  const say = log || (() => {});
271
273
  const content = fs.readFileSync(hostsPath, "utf8");
272
274
  const current = parseBlock(content);
273
275
 
274
276
  if (current.entries.size === 0) {
275
277
  say("hosts 无 ghprobe 记录,触发首次全量");
276
- return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath);
278
+ return fullUpdate(cfg, log, content, current, targetDomains(cfg), write, hostsPath, promote, hooks);
277
279
  }
278
280
 
279
281
  // 🔴 主条目 + 备选条目一起探活。旧版只探主条目 —— 于是「主 IP 挂了、备选还是好的」
@@ -296,14 +298,22 @@ export async function runHealth(cfg, log, write = false, hostsPath = WIN_HOSTS)
296
298
  return { action: "none", reason: decision.reason, block: null, stuck: [] };
297
299
  }
298
300
 
301
+ // 主备全死的域名数(0.6.0):algorithm 门控的 P0 紧急度 —— 用户此刻真的断着
302
+ const byDomain = new Map();
303
+ for (const r of probeResults) {
304
+ if (!byDomain.has(r.domain)) { byDomain.set(r.domain, []); }
305
+ byDomain.get(r.domain).push(r);
306
+ }
307
+ const deadAll = [...byDomain.entries()].filter(([, rs]) => rs.every((r) => !r.ok)).map(([d]) => d);
308
+
299
309
  say(`异常:${decision.reason}`);
300
- return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote);
310
+ return fullUpdate(cfg, log, content, current, decision.domains, write, hostsPath, decision.promote, hooks, deadAll);
301
311
  }
302
312
 
303
313
  // 全量探测 + 部分更新 + 写回;护栏与粘性都在 applyDelta 里体现。
304
314
  // 只探 diagnose 里的域名:典型场景是「github.com 的 IP 死了,codeload / jsdelivr 还活着」,
305
315
  // 此时探 1 个域名(~8 个候选)而不是 6 个(~48 个),失败发现到修好的时间从 ~90s 压到 ~20s。
306
- async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map()) {
316
+ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath = WIN_HOSTS, promote = new Map(), hooks = {}, deadAll = []) {
307
317
  const say = log || (() => {});
308
318
  // diagnose 为空 = 只有「提上备选」要做,没有需要重排的域名 -> 跳过探测,直接写提升结果
309
319
  const r = diagnose.length ? await runProbe(cfg, log, diagnose) : { ranked: new Map(), extra: new Map(), raw: [] };
@@ -332,6 +342,18 @@ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath
332
342
  if (d.sniBlocked.length) { parts.push(`指纹阻断: ${d.sniBlocked.join(", ")}`); }
333
343
  const msg = parts.length ? parts.join(";") : "更新缺失域名冷却记录";
334
344
  if (write) {
345
+ // 0.6.0 用户行为门控:P0(有主备全死的域名)/ 首次写入 直接过;
346
+ // 非紧急优化类在「用户正用浏览器」时被拒 —— 块由门控方保存,空闲后落地。
347
+ if (hooks.beforeApply) {
348
+ const ok = await hooks.beforeApply({
349
+ deadDomains: deadAll, initial: current.entries.size === 0,
350
+ changed: d.changed, reason: msg, block, cfg,
351
+ });
352
+ if (!ok) {
353
+ say(`按用户行为算法推迟写入(用户正在用浏览器;修复类不受限,上限 10 分钟)`);
354
+ return { action: "deferred", reason: "按 algorithm 推迟写入(浏览器使用中)", block, stuck: d.stuck };
355
+ }
356
+ }
335
357
  const lock = acquireHostsLock();
336
358
  if (!lock) {
337
359
  say("另一进程正在写 hosts,本轮跳过(下轮健康检查会自动补写)");
@@ -340,7 +362,7 @@ async function fullUpdate(cfg, log, content, current, diagnose, write, hostsPath
340
362
  try {
341
363
  // 锁内重读最新 hosts:探测耗时期间他人可能已改过,避免覆盖别人刚写的新值
342
364
  const latest = fs.readFileSync(hostsPath, "utf8");
343
- await applyHosts(mergeHosts(latest, block));
365
+ await applyHosts(mergeHosts(latest, block), hostsPath);
344
366
  say(`已写入 hosts:${msg}${stuckNote}`);
345
367
  } finally { releaseHostsLock(lock); }
346
368
  } else {
package/src/hosts.js CHANGED
@@ -17,6 +17,63 @@ export function userDataDir() {
17
17
  return path.join(base, "ghprobe");
18
18
  }
19
19
 
20
+ // ---------- 暂停(ghprobe off / on,0.1.6) ----------
21
+ // off 的语义是「先别动我的网络」,不是卸载:剥掉 hosts 块 + 暂停计划任务 + 落暂停标记。
22
+ // daemon / health 见到标记直接跳过,不再探测也不写 hosts;on 反之并当场触发一轮。
23
+ // 标记文件必须是第一动作 —— off 和计划任务正在跑的一轮是并发的,先落标记才能保证
24
+ // 「正在跑的那轮也看到暂停」,不会出现 off 刚剥完块又被那一轮写回去。
25
+
26
+ // 暂停标记路径(dir 可注入供单测,不碰真实用户目录)
27
+ export function pausedFlagPath(dir = userDataDir()) {
28
+ return path.join(dir, "paused");
29
+ }
30
+
31
+ export function isPaused(file = pausedFlagPath()) {
32
+ try { return fs.existsSync(file); } catch { return false; }
33
+ }
34
+
35
+ export function setPaused(paused, file = pausedFlagPath()) {
36
+ try {
37
+ if (paused) {
38
+ fs.mkdirSync(path.dirname(file), { recursive: true });
39
+ fs.writeFileSync(file, `${new Date().toISOString()}\n`);
40
+ } else {
41
+ fs.rmSync(file, { force: true });
42
+ }
43
+ return true;
44
+ } catch { return false; }
45
+ }
46
+
47
+ // ---------- 日志滚动(0.1.6) ----------
48
+ // daemon/监督者每 15~60s 追加一行日志,此前只增不减 —— 长跑机器会无限膨胀
49
+ // (2026-11-13 实测一台装了几天没动的机器已经 33MB)。滚动策略:超过 max 字节就把
50
+ // 文件截成「保留最后 keep 字节」,控制权交还给追加方,不需要额外的滚动进程。
51
+ export const LOG_MAX_BYTES = 2 * 1024 * 1024; // 超过就滚
52
+ export const LOG_KEEP_BYTES = 256 * 1024; // 滚动时保留的尾部
53
+
54
+ export function rotateLogIfNeeded(file, opts = {}) {
55
+ const max = opts.max ?? LOG_MAX_BYTES;
56
+ const keep = opts.keep ?? LOG_KEEP_BYTES;
57
+ let st;
58
+ try { st = fs.statSync(file); } catch { return false; }
59
+ if (st.size <= max) { return false; }
60
+ try {
61
+ const start = Math.max(0, st.size - keep);
62
+ const fh = fs.openSync(file, "r");
63
+ let buf;
64
+ try {
65
+ buf = Buffer.alloc(st.size - start);
66
+ fs.readSync(fh, buf, 0, buf.length, start);
67
+ } finally { fs.closeSync(fh); }
68
+ // 起点大概率落在半行上,掐掉第一行残片,避免留下一条读不懂的脏日志
69
+ const text = buf.toString("utf8").replace(/^[^\r\n]*\r?\n/, "");
70
+ const tmp = `${file}.rotating`;
71
+ fs.writeFileSync(tmp, "\ufeff" + text, "utf8");
72
+ fs.renameSync(tmp, file);
73
+ return true;
74
+ } catch { return false; } // 滚动失败不影响主流程:下一轮追加时再试
75
+ }
76
+
20
77
  // 写 hosts 跨进程锁:probe --apply 与计划任务(health --apply)可能并发,避免读改写互踩
21
78
  export const HOSTS_LOCK = path.join(os.tmpdir(), "ghprobe-hosts.lock");
22
79
  const LOCK_STALE_MS = 60000;
@@ -118,6 +175,32 @@ export function buildUninstallTaskScript(taskName = "ghprobe") {
118
175
  + ` exit 0`;
119
176
  }
120
177
 
178
+ // 构造「暂停/恢复计划任务」的提权脚本(ghprobe off / on 用)。任务不存在直接 exit 0(幂等):
179
+ // off 在没安装过的机器上跑不应报错;重复 off/on 同样幂等。
180
+ // 与 buildUninstallTaskScript 同一条纪律:动作必须 -ErrorAction Stop,否则「拒绝访问」
181
+ // 这类非终止错误会把失败伪装成 exit 0。
182
+ export function buildPauseTaskScript(paused, taskName = "ghprobe") {
183
+ const verb = paused ? "Disable" : "Enable";
184
+ const cn = paused ? "暂停" : "恢复";
185
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
186
+ + ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
187
+ + ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
188
+ + ` try { ${verb}-ScheduledTask -TaskName '${taskName}' -ErrorAction Stop | Out-Null }`
189
+ + ` catch { [Console]::Error.WriteLine('${cn}计划任务失败: ' + $_.Exception.Message); exit 1 };`
190
+ + ` exit 0`;
191
+ }
192
+
193
+ // 构造「立即触发一轮健康检查」的提权脚本(ghprobe on 用):恢复后不等下一个间隔,
194
+ // 当场跑一轮 —— on 之后用户应该在一分钟内看到 hosts 重新写好,而不是干等。
195
+ export function buildStartTaskScript(taskName = "ghprobe") {
196
+ return `try { [Console]::OutputEncoding = [Text.Encoding]::UTF8 } catch {};`
197
+ + ` if (-not (Get-ScheduledTask -TaskName '${taskName}' -ErrorAction SilentlyContinue))`
198
+ + ` { [Console]::Error.WriteLine('计划任务 ${taskName} 不存在,跳过'); exit 0 };`
199
+ + ` try { Start-ScheduledTask -TaskName '${taskName}' -ErrorAction Stop }`
200
+ + ` catch { [Console]::Error.WriteLine('触发计划任务失败: ' + $_.Exception.Message); exit 1 };`
201
+ + ` exit 0`;
202
+ }
203
+
121
204
  // 构造「写入 hosts + 注册计划任务」的提权脚本(install 用):一次 UAC 完成两件事。
122
205
  // 与 buildApplyScript 的区别:中途不 exit,两件事都做完后再按综合结果返回退出码,
123
206
  // 这样 install 只需弹一次 UAC,且能拿到「两件事各自成功与否」的确定结果。
package/src/pool.js CHANGED
@@ -118,13 +118,16 @@ export function fetchFirstMeta(seeds, host, pathName, timeoutMs) {
118
118
  }
119
119
 
120
120
  // 种子 IP:拉 meta 用的直连入口。优先用配置里的 seed 池,再退回 azure / fastly。
121
- export function seedIps(cfg) {
121
+ // extra:调用方传入的**健康 hosts 条目**(0.4.0 自救)——它们刚通过三关 + HTTP 终审,
122
+ // 是「当下确实能直连 GitHub」的 IP;当 seed 池被 SNI 整段封锁、静态种子全灭时,
123
+ // 这批 IP 往往是唯一还拉得到 meta 的入口,池子不至于永久冻结。
124
+ export function seedIps(cfg, extra = []) {
122
125
  const p = cfg.ipPools || {};
123
- return [...new Set([...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
126
+ return [...new Set([...extra, ...(p.seed || []), ...(p.azure || []), ...(p.fastly || [])])];
124
127
  }
125
128
 
126
129
  // 一轮更新:拉 meta -> 展开 -> 写缓存。失败也会写 lastAttempt(冷却),避免每分钟重试撞 60 次/小时限制。
127
- export async function updatePool(cfg, log = () => {}) {
130
+ export async function updatePool(cfg, log = () => {}, extraSeeds = []) {
128
131
  const u = cfg.poolUpdate || {};
129
132
  const url = new URL(u.source || "https://api.github.com/meta");
130
133
  const host = u.host || url.hostname;
@@ -133,8 +136,8 @@ export async function updatePool(cfg, log = () => {}) {
133
136
  const attemptedAt = new Date().toISOString();
134
137
  const prev = readPoolCache();
135
138
  try {
136
- const seeds = seedIps(cfg);
137
- if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly 全为空)"); }
139
+ const seeds = seedIps(cfg, extraSeeds);
140
+ if (!seeds.length) { throw new Error("配置里没有种子 IP(ipPools.seed / azure / fastly 全为空,也没有健康 hosts 条目)"); }
138
141
  // 并行试所有种子,第一个成功的立刻返回(实测 8.2s -> 亚秒级;串行时是 2m13s)。
139
142
  const { json, via } = await fetchFirstMeta(seeds, host, pathName, timeoutMs);
140
143
 
package/src/probe.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // 一轮完整探测:DoH 候选 + 内置池 -> 三关并发探测 -> 排名 -> 兜底轮
4
4
  import { resolveAll } from "./doh.js";
5
5
  import { probeIp, pool } from "./prober.js";
6
- import { rank, neededFallbacks } from "./ranker.js";
6
+ import { rank, neededFallbacks, mergeSamples } from "./ranker.js";
7
7
 
8
8
  // 该域名该用哪些内置池。
9
9
  // 默认(未配 domainPools 或配了不存在的池名)用全部池 —— 保持「与域名无关地全量进候选」
@@ -59,8 +59,21 @@ export async function runProbe(cfg, log, onlyDomains = null) {
59
59
  const directFb = want ? [...want].filter((d) => fbDomains.includes(d) && !cfg.domains.includes(d)) : [];
60
60
 
61
61
  const resolved = await resolveCandidates(cfg, mainDomains, say);
62
- const all = await pool(jobsOf(cfg, resolved), cfg.probe.concurrency);
63
- const ranked = rank(all, cfg);
62
+ let all = await pool(jobsOf(cfg, resolved), cfg.probe.concurrency);
63
+ let ranked = rank(all, cfg);
64
+
65
+ // 复测抗抖动(0.3.0):对每域名当前前 3 名各再采一枪,均值 + 失败罚分后重排名。
66
+ // 代价极小(≤24 个快候选、约 1~2s),换来「写进 hosts 的第一名是真的稳」——
67
+ // 单采样时同一 IP 相邻两轮分差可达数倍,排名近乎随机,而无谓的换 IP 要刷 DNS 缓存。
68
+ if ((cfg.probe.stableSamples ?? 2) >= 2) {
69
+ const jobs = [];
70
+ for (const [, rs] of ranked) {
71
+ for (const r of rs.slice(0, 3)) { jobs.push(() => probeIp(r.domain, r.ip, cfg)); }
72
+ }
73
+ const second = await pool(jobs, cfg.probe.concurrency);
74
+ all = mergeSamples(all, second);
75
+ ranked = rank(all, cfg);
76
+ }
64
77
 
65
78
  // 兜底轮:主域名全灭且配了替身 -> 探测替身域名
66
79
  const extra = new Map();