ghprobe 0.1.3 → 0.1.5
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 +138 -0
- package/README.en.md +28 -17
- package/README.md +32 -16
- package/bin/ghprobe.js +74 -6
- package/config.default.yaml +58 -7
- package/package.json +1 -1
- package/src/algorithm.js +173 -0
- package/src/daemon.js +147 -0
- package/src/doctor.js +174 -0
- package/src/health.js +28 -6
- package/src/pool.js +8 -5
- package/src/probe.js +16 -3
- package/src/prober.js +175 -9
- package/src/ranker.js +26 -0
- package/src/status.js +88 -0
package/config.default.yaml
CHANGED
|
@@ -10,13 +10,22 @@
|
|
|
10
10
|
# 所以升级后不需要手动同步配置。想看有哪些差异:`ghprobe config-diff`。
|
|
11
11
|
# (本文件里的注释只是说明,不参与合并 —— 合并看的是键值。)
|
|
12
12
|
# 目标域名族:每域名独立探测、独立写 hosts
|
|
13
|
+
# api.github.com(0.2.0):gh CLI / REST API 走它,DNS 同样被污染 —— 写进 hosts 后
|
|
14
|
+
# `gh api` / `curl https://api.github.com` 直连可用;候选走 seed/auto 池(GitHub 自租段)
|
|
15
|
+
# github.githubassets.com(0.3.0):github.com 网页的全部 JS/CSS/字体都从这里加载 ——
|
|
16
|
+
# 不写它「页面能开但样式全裂」。注意其 Fastly 段是 .215,与 githubusercontent 的 .133 不同
|
|
17
|
+
# ssh.github.com(0.3.0):SSH-over-443(git clone git@ssh.github.com:... 用),
|
|
18
|
+
# 探测方式是读 SSH banner,不走 TLS/HTTP 三关
|
|
13
19
|
domains:
|
|
14
20
|
- github.com
|
|
21
|
+
- api.github.com
|
|
22
|
+
- ssh.github.com
|
|
15
23
|
- codeload.github.com
|
|
16
24
|
- objects.githubusercontent.com
|
|
17
25
|
- raw.githubusercontent.com
|
|
18
26
|
- gist.githubusercontent.com
|
|
19
27
|
- avatars.githubusercontent.com
|
|
28
|
+
- github.githubassets.com
|
|
20
29
|
|
|
21
30
|
# DoH 端点(全部 IP 直连,绕开解析 DoH 域名的死锁;默认只放国内实测可达端点)
|
|
22
31
|
# sni 填服务域名:TLS 握手按它验证书(RFC 6066 不允许拿 IP 当 SNI)
|
|
@@ -24,6 +33,7 @@ doh:
|
|
|
24
33
|
- { name: alidns1, host: 223.5.5.5, sni: dns.alidns.com, path: /dns-query }
|
|
25
34
|
- { name: alidns2, host: 223.6.6.6, sni: dns.alidns.com, path: /dns-query }
|
|
26
35
|
- { name: dnspod, host: 1.12.12.12, sni: doh.pub, path: /dns-query }
|
|
36
|
+
- { name: dnspod2, host: 120.53.53.53, sni: doh.pub, path: /dns-query }
|
|
27
37
|
|
|
28
38
|
# 内置候选 IP 池:补足 DoH 拿不到的候选。
|
|
29
39
|
# 🔴 池子必须够宽,否则会出现「明明有活 IP,工具却报 stuck」——见下面 azure 段的说明。
|
|
@@ -44,6 +54,10 @@ ipPools:
|
|
|
44
54
|
- 140.82.121.3
|
|
45
55
|
# Fastly 段:服务 githubusercontent 系(raw / objects / gist / avatars),多年不变
|
|
46
56
|
fastly: [185.199.108.133, 185.199.109.133, 185.199.110.133, 185.199.111.133]
|
|
57
|
+
# Fastly ghassets 段(0.3.0 新增):github.githubassets.com 专用。
|
|
58
|
+
# 🔴 与上面 .133 不是同一段:实测 .133 对 github.githubassets.com 的 SNI 连不上,
|
|
59
|
+
# DoH 对该域名返回的也是 .215 —— 两段不能混用,所以单独建池。
|
|
60
|
+
ghassets: [185.199.108.215, 185.199.109.215, 185.199.110.215, 185.199.111.215]
|
|
47
61
|
# GitHub 自租段:服务 github.com / codeload.github.com 主站。
|
|
48
62
|
# 🔴 2026-10 实测:这些 IP **在分钟级抖动** —— 同一批 IP 几分钟前全灭、现在 9/20 可用,
|
|
49
63
|
# 且「活的是哪几个」每次都不一样。所以池子必须宽(靠 tcpMs 短超时兜住成本),
|
|
@@ -85,11 +99,14 @@ ipPools:
|
|
|
85
99
|
# 顺序有意义:靠前的池先占 maxPoolIps 名额。
|
|
86
100
|
domainPools:
|
|
87
101
|
github.com: [auto, seed, azure]
|
|
102
|
+
api.github.com: [auto, seed]
|
|
103
|
+
ssh.github.com: [auto, seed]
|
|
88
104
|
codeload.github.com: [auto, seed, azure]
|
|
89
105
|
objects.githubusercontent.com: [fastly]
|
|
90
106
|
raw.githubusercontent.com: [fastly]
|
|
91
107
|
gist.githubusercontent.com: [fastly]
|
|
92
108
|
avatars.githubusercontent.com: [fastly]
|
|
109
|
+
github.githubassets.com: [ghassets]
|
|
93
110
|
|
|
94
111
|
# 候选池自动更新:从 GitHub 官方 api.github.com/meta 拉最新网段,缓存到用户目录,供 `auto` 池使用。
|
|
95
112
|
# 为什么需要:GitHub 的 Azure 段在变,内置 azure 池会过期(实测 24 个里 4 个已失效)。
|
|
@@ -126,16 +143,19 @@ networkCheck:
|
|
|
126
143
|
# 完整 raw.githubusercontent.com:
|
|
127
144
|
# - { domain: my.mirror, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
128
145
|
# 完整写法**自带探测判据** —— 第三关用它验证「这个 IP 真的在提供这份内容」。
|
|
129
|
-
# 为什么判据必须跟着配置走:不同替身服务的 URL 形态不同,CDN 映射是
|
|
130
|
-
# `/gh/<user>/<repo>@<ref>/<path>`,代理是 `/<完整原始 URL>`,写死在代码里就没法让用户自己加替身。
|
|
131
146
|
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
135
|
-
#
|
|
147
|
+
# 0.4.0 默认链(2026-10-31 实测数据驱动,详见 CHANGELOG):
|
|
148
|
+
# - cdn.jsdelivr.net 移除:node + 双 curl 共 45 次全部在 ClientHello 后被 RST(SNI 级硬封),
|
|
149
|
+
# 留着只会白耗每轮 6s 超时;
|
|
150
|
+
# - fastly.jsdelivr.net 首选:Fastly anycast(151.101.x.229),全栈全 IP 100%、最快(TTFB ~125ms);
|
|
151
|
+
# - testingcf.jsdelivr.net 次选:Cloudflare 段,与 Fastly 真正异构;
|
|
152
|
+
# - gcore.jsdelivr.net 末位:当前 DoH 解析与 testingcf 落在同一对 CF IP(名不副实),纯语义冗余。
|
|
153
|
+
# 🔴 三个替身都不在代码 DOMAIN_SIG 里 —— 必须用完整写法自带判据,否则会退回「探根路径」的弱判据。
|
|
136
154
|
fallbacks:
|
|
137
155
|
raw.githubusercontent.com:
|
|
138
|
-
-
|
|
156
|
+
- { domain: fastly.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
157
|
+
- { domain: testingcf.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
158
|
+
- { domain: gcore.jsdelivr.net, path: "/gh/torvalds/linux@master/README", mustContain: ["Linux kernel"] }
|
|
139
159
|
|
|
140
160
|
# 超时:tcpMs 只管 TCP 握手阶段(死 IP 快速淘汰),握手成功后自动切到 tlsMs。
|
|
141
161
|
# 黑洞 IP 会一直挂到超时,所以这两段必须分开 —— 否则每加一个候选都要多等 tlsMs。
|
|
@@ -145,6 +165,15 @@ probe:
|
|
|
145
165
|
maxPoolIps: 32 # 内置池每域名最多补几个。**与 DoH 各自限额,不共享总上限**(见 probe.js)
|
|
146
166
|
maxPerDomain: 4 # 每域名最多保留几个可用 IP(前 maxEntriesPerDomain 个会写进 hosts)
|
|
147
167
|
concurrency: 10 # 全局同时握手的 socket 上限(跨域名共享)
|
|
168
|
+
# schannel 二次重探(0.3.0):Node 的 TLS ClientHello 被中间设备识别并 reset 时
|
|
169
|
+
# (README §3 实测:同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常),
|
|
170
|
+
# 改用 Windows 自带的 curl.exe(Schannel 构建,与 Edge 同一 TLS 栈)重探一次。
|
|
171
|
+
# 0.3.0 前这种情况只能写 # blocked 放弃,而浏览器明明能用 —— 这是假阴性的根治。
|
|
172
|
+
schannelRetry: true
|
|
173
|
+
# 复测抗抖动(0.3.0):对每域名前 3 名再采一枪,均值 + 失败罚分后重排名。
|
|
174
|
+
# 单采样时同一 IP 相邻两轮分差可达数倍、排名近乎随机,而无谓的换 IP 要刷 DNS 缓存。
|
|
175
|
+
# 2 = 总采样两次(默认,代价 ~1-2s);1 或删掉 = 退回单采样旧行为。
|
|
176
|
+
stableSamples: 2
|
|
148
177
|
# hosts 写入:每域名写几条候选。
|
|
149
178
|
# 🔴 2026-10-11 实测推翻旧假设「Windows 同一域名只取第一条」—— hosts 里同名多条会被
|
|
150
179
|
# 解析器**全部返回**(保持文件顺序),且 curl / 浏览器会**逐个尝试**:黑洞 IP 排在前
|
|
@@ -154,6 +183,28 @@ probe:
|
|
|
154
183
|
hosts:
|
|
155
184
|
maxEntriesPerDomain: 2
|
|
156
185
|
serve: { host: 127.0.0.1, port: 8930 }
|
|
186
|
+
# 用户行为感知的「切换时机」算法(0.6.0,daemon 专用):
|
|
187
|
+
# 换 IP 有打扰成本(flushdns + 连接池重建,正在看的页面会卡一下)。成本按权重打分:
|
|
188
|
+
# 前台是浏览器 100 > 浏览器在后台跑 30 > 前台是其他软件 10 > 空闲 0
|
|
189
|
+
# 非紧急优化类写入(提上备选/重排优化/补缺失/兜底)在「前台是浏览器」时**推迟**,攒到空闲落地,
|
|
190
|
+
# 上限 deferMaxMs 防饿死;**当前已断的域名(主备全死)不吃权重,立即切** —— 正在用 ≠ 该断网。
|
|
191
|
+
# 检测免管理员:tasklist 扫浏览器进程 + PowerShell GetForegroundWindow 查前台。
|
|
192
|
+
algorithm:
|
|
193
|
+
enabled: true
|
|
194
|
+
deferMaxMs: 600000 # 推迟上限(毫秒):到点强制切,宁可打扰也不能饿死修复
|
|
195
|
+
deferThreshold: 100 # 成本 >= 此值才推迟(默认只有「前台是浏览器」会到 100)
|
|
196
|
+
detectCacheMs: 20000 # 活动检测结果缓存(daemon 一轮内不重复打系统)
|
|
197
|
+
browsers: # 视作「浏览器」的进程名(小写,不带 .exe)
|
|
198
|
+
- chrome
|
|
199
|
+
- msedge
|
|
200
|
+
- firefox
|
|
201
|
+
- brave
|
|
202
|
+
- opera
|
|
203
|
+
- vivaldi
|
|
204
|
+
- 360se
|
|
205
|
+
- 360chrome
|
|
206
|
+
- qqbrowser
|
|
207
|
+
- sogouexplorer
|
|
157
208
|
# 调度:固定频率健康检查,不做指数退避(哨兵一轮 <2s,退避省不下开销反而拉长最坏发现延迟)
|
|
158
209
|
schedule:
|
|
159
210
|
# 哨兵间隔(分钟):只测已写入 IP(3 条并发,健康时 ~1s),异常才触发全量重排。
|
package/package.json
CHANGED
package/src/algorithm.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// SPDX-License-Identifier: GPL-3.0-only
|
|
2
|
+
// Copyright (C) 2026 zmw
|
|
3
|
+
// algorithm:用户行为感知的「切换时机」决策(0.6.0)。
|
|
4
|
+
//
|
|
5
|
+
// 为什么需要:换 IP 不是零成本的 —— flushdns + 连接池重建,正在浏览的页面会卡一下,
|
|
6
|
+
// 进行中的下载/克隆可能断。0.5.0 及以前 daemon 只看网络健康,不看用户在干什么:
|
|
7
|
+
// 半夜优化切没事,用户正看 GitHub 文档时切就是「打扰」。
|
|
8
|
+
//
|
|
9
|
+
// 决策模型(成本 vs 紧急度):
|
|
10
|
+
// 成本 = 前台是浏览器(100) > 浏览器在后台跑(30) > 前台是其他软件(10) > 空闲(0),权重可配
|
|
11
|
+
// 紧急度 = 当前**已经不可用**的域名(主备全死)——这类修复**不吃权重,立即切**:
|
|
12
|
+
// 「正在用」和「不能用」二选一,当然选别断网。
|
|
13
|
+
// 非紧急(提上备选/重排优化/补写缺失/兜底写入)在前台浏览器时推迟,攒成待写块,
|
|
14
|
+
// 空闲时落地;推迟上限 deferMaxMs(默认 10 分钟)防饿死。
|
|
15
|
+
//
|
|
16
|
+
// 检测全部**免管理员**:tasklist 扫浏览器进程 + PowerShell GetForegroundWindow 查前台。
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
|
|
19
|
+
// 常见浏览器进程名(小写、不带 .exe)。匹配 tasklist 镜像名与前台的 ProcessName。
|
|
20
|
+
export const DEFAULT_BROWSERS = [
|
|
21
|
+
"chrome", "msedge", "firefox", "brave", "opera", "vivaldi", "360se", "360chrome",
|
|
22
|
+
"qqbrowser", "sogouexplorer", "maxthon", "twinkstar", "hxbcrowser", "baidubrowser",
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// 解析 tasklist /FO CSV /NH 输出 -> 小写进程名 Set("chrome.exe","123",...)
|
|
26
|
+
export function parseTasklist(out) {
|
|
27
|
+
const names = new Set();
|
|
28
|
+
for (const line of String(out || "").split(/\r?\n/)) {
|
|
29
|
+
const m = line.match(/^"([^"]+)"/);
|
|
30
|
+
if (m) { names.add(m[1].toLowerCase().replace(/\.exe$/, "")); }
|
|
31
|
+
}
|
|
32
|
+
return names;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 纯函数:进程名集合里有没有浏览器
|
|
36
|
+
export function browserRunningFrom(processNames, browsers) {
|
|
37
|
+
return (browsers || []).some((b) => processNames.has(String(b).toLowerCase()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// 活动检测(可注入 exec,测试不打真系统):
|
|
41
|
+
// running = 浏览器进程存在(含后台标签页/更新进程 —— 它们同样会被 flushdns 影响)
|
|
42
|
+
// foreground = 前台窗口是浏览器(用户正在看网页)
|
|
43
|
+
// PowerShell 查询失败按 foreground=false 处理(保守偏向「可以切」:打扰是体验问题,
|
|
44
|
+
// 断网是可用性问题,检测手段失败时不应把可用性押在一个尽力而为的信号上)。
|
|
45
|
+
export async function detectActivity(cfg, deps = {}) {
|
|
46
|
+
const exec = deps.exec || execFile;
|
|
47
|
+
const browsers = cfg.algorithm?.browsers || DEFAULT_BROWSERS;
|
|
48
|
+
const platform = deps.platform || process.platform;
|
|
49
|
+
if (platform !== "win32") { return { running: false, foreground: false, source: "non-win32" }; }
|
|
50
|
+
|
|
51
|
+
const running = await new Promise((resolve) => {
|
|
52
|
+
exec("tasklist", ["/FO", "CSV", "/NH"], { windowsHide: true, timeout: 5000 }, (e, stdout) => {
|
|
53
|
+
if (e) { resolve(false); return; }
|
|
54
|
+
resolve(browserRunningFrom(parseTasklist(stdout), browsers));
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const fgName = await new Promise((resolve) => {
|
|
59
|
+
const ps = `Add-Type -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint p);' -Name FG -Namespace W; $h=[W.FG]::GetForegroundWindow(); $p=0; [W.FG]::GetWindowThreadProcessId($h, [ref]$p) | Out-Null; if ($p) { $pr = Get-Process -Id $p -ErrorAction SilentlyContinue; if ($pr) { Write-Output $pr.ProcessName.ToLower() } }`;
|
|
60
|
+
exec("powershell", ["-NoProfile", "-Command", ps], { windowsHide: true, timeout: 6000 }, (e, stdout) => {
|
|
61
|
+
resolve(e ? "" : String(stdout || "").trim().toLowerCase());
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
running,
|
|
67
|
+
foreground: running && browsers.includes(fgName),
|
|
68
|
+
fgName,
|
|
69
|
+
source: fgName ? "foreground-ok" : "foreground-unknown",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 成本权重(纯函数)
|
|
74
|
+
export function switchCost(activity, weights = {}) {
|
|
75
|
+
const w = { browserForeground: 100, browserRunning: 30, otherForeground: 10, idle: 0, ...weights };
|
|
76
|
+
if (activity.foreground) { return w.browserForeground; }
|
|
77
|
+
if (activity.running) { return w.browserRunning; }
|
|
78
|
+
if (activity.otherForeground) { return w.otherForeground; }
|
|
79
|
+
return w.idle;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 核心决策(纯函数)。
|
|
83
|
+
// deadCount = 当前主备全死的域名数 —— P0,不吃权重
|
|
84
|
+
// initial = 首次写入(hosts 里还没有块)—— 无从打扰,直接切
|
|
85
|
+
// cost = switchCost 结果
|
|
86
|
+
// deferSinceMs/nowMs = 本次推迟的起点,超 deferMaxMs 强制切(防饿死)
|
|
87
|
+
export function decideSwitch({ cost = 0, deadCount = 0, initial = false, deferSinceMs = null, nowMs = Date.now(), cfg = {} }) {
|
|
88
|
+
const a = cfg.algorithm || {};
|
|
89
|
+
if (a.enabled === false) { return { switch: true, reason: "algorithm 已禁用" }; }
|
|
90
|
+
if (deadCount > 0) { return { switch: true, reason: `紧急修复(当前不可用域名 ${deadCount} 个,用户正在受影响)` }; }
|
|
91
|
+
if (initial) { return { switch: true, reason: "首次写入(无从打扰)" }; }
|
|
92
|
+
if (deferSinceMs != null && nowMs - deferSinceMs >= (a.deferMaxMs ?? 600_000)) {
|
|
93
|
+
return { switch: true, reason: "推迟已达上限,强制切换" };
|
|
94
|
+
}
|
|
95
|
+
const threshold = a.deferThreshold ?? 100;
|
|
96
|
+
if (cost >= threshold) {
|
|
97
|
+
return { switch: false, reason: `打扰成本 ${cost} >= 阈值 ${threshold}(用户正在用浏览器),攒到空闲再切` };
|
|
98
|
+
}
|
|
99
|
+
return { switch: true, reason: `打扰成本 ${cost} < 阈值 ${threshold}` };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 门控:交给 runHealth 的 beforeApply 钩子 + daemon 的空闲落地(flush)。
|
|
103
|
+
// 内部状态跨轮存活:deferred 的块存 pendingBlock,用户空闲后由 maybeFlush 落地。
|
|
104
|
+
export function createSwitchGate(deps = {}) {
|
|
105
|
+
const state = { pendingBlock: null, deferSinceMs: null };
|
|
106
|
+
const detect = deps.detect || detectActivity;
|
|
107
|
+
|
|
108
|
+
async function activityNow(cfg, { fresh = false } = {}) {
|
|
109
|
+
// 检测结果短缓存:daemon 15~45s 一轮,PS + tasklist 各 ~0.3s,同轮内不重复打系统。
|
|
110
|
+
// fresh=true 跳过缓存 —— 空闲落地是最终决策点,必须用此刻的真实状态
|
|
111
|
+
// (用户可能刚离开浏览器;用几秒前的缓存会白白推迟一次)。
|
|
112
|
+
const ttl = cfg.algorithm?.detectCacheMs ?? 20_000;
|
|
113
|
+
const now = Date.now();
|
|
114
|
+
if (!fresh && state.cache && now - state.cache.at < ttl) { return state.cache.activity; }
|
|
115
|
+
const activity = await detect(cfg, deps);
|
|
116
|
+
state.cache = { at: now, activity };
|
|
117
|
+
return activity;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
// runHealth 的 beforeApply 钩子:meta = { deadDomains, initial, changed, reason, block, cfg }
|
|
122
|
+
async beforeApply(meta) {
|
|
123
|
+
// P0 短路:用户此刻已经断着 / 首次写入 —— 不检测、不推迟,立即切。
|
|
124
|
+
// 必须在活动检测之前 return,否则活动检测失败等噪声会把救命修复也拦下来。
|
|
125
|
+
if ((meta.deadDomains || []).length > 0 || meta.initial) {
|
|
126
|
+
state.lastReason = meta.initial ? "首次写入" : `紧急修复(不可用域名 ${meta.deadDomains.length} 个)`;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
const activity = await activityNow(meta.cfg || {});
|
|
130
|
+
const cost = switchCost(activity, meta.cfg?.algorithm?.weights);
|
|
131
|
+
const d2 = decideSwitch({
|
|
132
|
+
cost,
|
|
133
|
+
deadCount: 0,
|
|
134
|
+
initial: false,
|
|
135
|
+
deferSinceMs: state.deferSinceMs,
|
|
136
|
+
nowMs: Date.now(),
|
|
137
|
+
cfg: meta.cfg || {},
|
|
138
|
+
});
|
|
139
|
+
if (d2.switch) {
|
|
140
|
+
state.pendingBlock = null;
|
|
141
|
+
state.deferSinceMs = null;
|
|
142
|
+
state.lastReason = d2.reason;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
state.pendingBlock = meta.block; // 攒着,空闲时落地
|
|
146
|
+
state.deferSinceMs = state.deferSinceMs ?? Date.now();
|
|
147
|
+
state.lastReason = d2.reason;
|
|
148
|
+
return false;
|
|
149
|
+
},
|
|
150
|
+
// daemon 每轮调用:有待写块且此刻成本低 -> 落地
|
|
151
|
+
async maybeFlush(cfg, log, deps2 = {}) {
|
|
152
|
+
if (!state.pendingBlock) { return null; }
|
|
153
|
+
const activity = await activityNow(cfg, { fresh: true });
|
|
154
|
+
const cost = switchCost(activity, cfg?.algorithm?.weights);
|
|
155
|
+
const d = decideSwitch({
|
|
156
|
+
cost, deadCount: 0, initial: false,
|
|
157
|
+
deferSinceMs: state.deferSinceMs, nowMs: Date.now(), cfg,
|
|
158
|
+
});
|
|
159
|
+
if (!d.switch) { return { flushed: false, reason: d.reason }; }
|
|
160
|
+
const applyHosts = deps.applyHosts || deps2.applyHosts;
|
|
161
|
+
const hostsPath = deps2?.hostsPath;
|
|
162
|
+
const merged = deps2.mergeLatest(state.pendingBlock); // 调用方负责读最新 hosts 并合并(护栏在其中)
|
|
163
|
+
await applyHosts(merged, hostsPath);
|
|
164
|
+
const block = state.pendingBlock;
|
|
165
|
+
state.pendingBlock = null;
|
|
166
|
+
state.deferSinceMs = null;
|
|
167
|
+
log(`空闲落地:打扰成本 ${cost},已写入推迟的 hosts 块(${d.reason})`);
|
|
168
|
+
return { flushed: true, block };
|
|
169
|
+
},
|
|
170
|
+
get pending() { return Boolean(state.pendingBlock); },
|
|
171
|
+
get lastReason() { return state.lastReason || ""; },
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/daemon.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
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 } 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
|
+
if (!(await (deps.hasNetwork || hasLocalNetwork)(cfg))) {
|
|
76
|
+
return { action: "skip", reason: "本地无网络连接", offline: true, block: null, stuck: [] };
|
|
77
|
+
}
|
|
78
|
+
if (poolNeedsUpdate(cfg)) {
|
|
79
|
+
try {
|
|
80
|
+
const cur = parseBlock(fs.readFileSync(WIN_HOSTS, "utf8"));
|
|
81
|
+
await updatePool(cfg, log, [...cur.entries.values()]);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
log(`候选池更新失败(忽略,用现有池继续): ${e.message}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const hooks = deps.gate ? { beforeApply: deps.gate.beforeApply } : {};
|
|
87
|
+
const result = await runHealth(cfg, log, true, WIN_HOSTS, undefined, hooks);
|
|
88
|
+
// 空闲落地:上轮被推迟的块,此刻成本低就写掉(含全灭护栏)
|
|
89
|
+
if (deps.gate && deps.gate.pending && (!result || result.action === "none" || result.action === "deferred")) {
|
|
90
|
+
try {
|
|
91
|
+
await flushPending(cfg, log, deps.gate, deps.hostsPath || WIN_HOSTS, deps.applyHosts);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
log(`推迟块落地失败(下轮重试): ${e.message}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 把门控攒下的块落地:重读最新 hosts + 全灭护栏 + 合并写入。
|
|
100
|
+
// deps.applyHosts 可注入(单测不写真 hosts)。
|
|
101
|
+
export async function flushPending(cfg, log, gate, hostsPath = WIN_HOSTS, applyHostsImpl = applyHosts) {
|
|
102
|
+
return gate.maybeFlush(cfg, log, {
|
|
103
|
+
hostsPath,
|
|
104
|
+
applyHosts: (merged, p) => applyHostsImpl(merged, p),
|
|
105
|
+
mergeLatest: (block) => {
|
|
106
|
+
const latest = fs.readFileSync(hostsPath, "utf8");
|
|
107
|
+
if (shouldKeepOldBlock(latest, block)) {
|
|
108
|
+
throw new Error("全灭护栏:推迟块无有效 IP 而现有条目可用,放弃落地");
|
|
109
|
+
}
|
|
110
|
+
return mergeHosts(latest, block);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 常驻主循环。返回条件:单实例让位(更年轻的 daemon 在跑)或 deps.maxCycles 用完(测试)。
|
|
116
|
+
export async function runDaemon(cfg, log = () => {}, deps = {}) {
|
|
117
|
+
const gate = deps.gate !== undefined ? deps.gate : createSwitchGate();
|
|
118
|
+
const cycle = deps.cycle || ((c, l) => daemonCycle(c, l, { gate, applyHosts: deps.applyHosts, hostsPath: deps.hostsPath }));
|
|
119
|
+
const sleep = deps.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
120
|
+
const intervals = deps.intervals || {};
|
|
121
|
+
const maxCycles = deps.maxCycles ?? Infinity;
|
|
122
|
+
const hb = readHeartbeat();
|
|
123
|
+
if (daemonRunning(hb)) { return { started: false, reason: "已有 daemon 在跑(心跳新鲜)" }; }
|
|
124
|
+
writeHeartbeat();
|
|
125
|
+
log(`daemon 已启动 (pid ${process.pid})`);
|
|
126
|
+
let cycles = 0;
|
|
127
|
+
for (;;) {
|
|
128
|
+
let result = null;
|
|
129
|
+
let cfgNow = cfg;
|
|
130
|
+
try {
|
|
131
|
+
cfgNow = loadConfig(); // 每轮重载:配置编辑与池缓存更新即时生效
|
|
132
|
+
result = await cycle(cfgNow, log);
|
|
133
|
+
if (result && result.action) { log(`daemon cycle action=${result.action} reason=${result.reason || ""}`); }
|
|
134
|
+
} catch (e) {
|
|
135
|
+
log(`daemon 循环异常(不退出,继续下一轮): ${e.message}`);
|
|
136
|
+
result = { action: "error" }; // 异常按「有事」处理 -> fast 档,别在故障后睡满 2 分钟
|
|
137
|
+
}
|
|
138
|
+
writeHeartbeat();
|
|
139
|
+
cycles += 1;
|
|
140
|
+
if (cycles >= maxCycles) { return { started: true, cycles }; }
|
|
141
|
+
const newer = readHeartbeat();
|
|
142
|
+
if (newer && newer.pid !== process.pid && heartbeatFresh(newer) && processAlive(newer.pid)) {
|
|
143
|
+
return { started: true, cycles, yielded: true }; // 让位给更年轻的实例
|
|
144
|
+
}
|
|
145
|
+
await sleep(nextIntervalMs(result, intervals));
|
|
146
|
+
}
|
|
147
|
+
}
|
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
|
+
}
|