ghprobe 0.1.2 → 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/src/prober.js CHANGED
@@ -4,6 +4,11 @@
4
4
  // 证书校验拦的是 DNS 污染,应用层终审拦的是共享 CDN 上未部署该域名的"假活"IP
5
5
  import tls from "node:tls";
6
6
  import https from "node:https";
7
+ import net from "node:net";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { execFile } from "node:child_process";
7
12
 
8
13
  // 目标域名的应用层指纹:真实业务路径、必须包含的响应特征、允许状态码、拒绝特征、允许跳转的 host
9
14
  // 路径必须能代表真实业务;codeload 用归档下载路径,避免根路径 301 到 github.com 主站骗过校验
@@ -19,6 +24,15 @@ import https from "node:https";
19
24
  // → 规则:**路径 `/` 只接受 200/3xx**。只有那些「根路径本来就不存在合法内容」的域名
20
25
  // (objects 根路径是 Varnish 的 404、gist 是 301)才保留 4xx,且靠 reject 词表兜底。
21
26
  export const DOMAIN_SIG = {
27
+ // api.github.com:gh CLI / REST API 走它,DNS 同样被污染。判据用 /rate_limit:
28
+ // 未认证也返回 200,响应体是 JSON 且必含 "rate" 字段 —— 不依赖 GitHub 网页特征
29
+ "api.github.com": {
30
+ path: "/rate_limit",
31
+ mustContain: ["rate"],
32
+ allow: [200, 204],
33
+ reject: ["unknown domain", "Fastly error", "bad request"],
34
+ redirectHosts: [],
35
+ },
22
36
  "github.com": {
23
37
  path: "/",
24
38
  mustContain: ["github"],
@@ -27,6 +41,21 @@ export const DOMAIN_SIG = {
27
41
  reject: ["unknown domain", "Fastly error", "bad request"],
28
42
  redirectHosts: ["github.com"],
29
43
  },
44
+ // github.githubassets.com:github.com 网页的全部 JS/CSS/字体都从这里加载(0.3.0 新增)。
45
+ // 它不写进 hosts 时「页面能打开但样式全裂」,是直连浏览最常见的体验杀手。
46
+ // 判据用 favicon(2026-10-31 实测 4 个 Fastly .215 IP 全部 200 且以 `<svg` 开头);
47
+ // 注意它的 Fastly 段是 185.199.108-111.**215**,与 githubusercontent 系的 .133 不同段
48
+ // (.133 对该 SNI 直接连接失败),所以 domainPools 走独立的 ghassets 池。
49
+ "github.githubassets.com": {
50
+ path: "/favicons/favicon.svg",
51
+ mustContain: ["<svg"],
52
+ allow: [200, 204],
53
+ reject: ["unknown domain", "Fastly error", "bad request"],
54
+ redirectHosts: [],
55
+ },
56
+ // ssh.github.com:SSH-over-443 端点(git clone git@ssh.github.com:... 用,0.3.0 新增)。
57
+ // type:"ssh" —— 不走 TLS/HTTP 三关,改探 SSH banner(见 sshProbe)。
58
+ "ssh.github.com": { type: "ssh" },
30
59
  "codeload.github.com": {
31
60
  path: "/torvalds/linux/tar.gz/refs/heads/master",
32
61
  mustContain: [],
@@ -45,7 +74,11 @@ export const DOMAIN_SIG = {
45
74
  redirectHosts: ["objects.githubusercontent.com"],
46
75
  },
47
76
  "raw.githubusercontent.com": {
48
- path: "/torvalds/linux/README.md",
77
+ // 🔴 路径必须带分支段(2026-10-31 实测修正):raw.githubusercontent.com 的第 2 段是
78
+ // 分支名,`/torvalds/linux/README.md` 会被当作「README.md 分支」返回 404 ——
79
+ // 就算网络全通也会被判死(假阴性)。正确形态 `/torvalds/linux/master/README`
80
+ // 实测 200 且响应体含 "Linux kernel"(linux 仓库根文件本来就叫 README,无 .md)。
81
+ path: "/torvalds/linux/master/README",
49
82
  mustContain: ["Linux kernel"],
50
83
  allow: [200, 204, 302, 303, 304, 307, 308],
51
84
  reject: ["unknown domain", "Fastly error", "bad request"],
@@ -65,6 +98,9 @@ export const DOMAIN_SIG = {
65
98
  reject: ["unknown domain", "Fastly error", "bad request"],
66
99
  redirectHosts: ["avatars.githubusercontent.com"],
67
100
  },
101
+ // cdn.jsdelivr.net:0.4.0 起已从默认兜底链移除(2026-10-31 实测 SNI 级硬封:
102
+ // node + 双 curl 共 45 次全部在 ClientHello 后被 RST,换 IP/换 TLS 栈均无效)。
103
+ // 判据保留在这里:老用户配置里若仍引用它,探测依然有正确的业务路径可用。
68
104
  "cdn.jsdelivr.net": {
69
105
  path: "/gh/torvalds/linux@master/README",
70
106
  mustContain: ["Linux kernel"],
@@ -90,12 +126,12 @@ function badRedirect(statusCode, location, sig) {
90
126
  // 十几条死候选就是几十秒 —— 池子因此不敢放宽,进而出现「明明有活 IP 却报 stuck」。
91
127
  // 这里先按 tcpMs 计,握手成功(connect 事件)后再把超时切到 tlsMs:
92
128
  // 快速淘汰死 IP,同时不误杀「连得上但 TLS 慢」的活 IP。
93
- function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
129
+ function tcpTlsProbe(ip, domain, tcpMs, tlsMs, port = 443) {
94
130
  return new Promise((resolve) => {
95
131
  const t0 = Date.now();
96
132
  let tcpCost = -1;
97
133
  let stage = "tcp";
98
- const sock = tls.connect({ host: ip, port: 443, servername: domain, rejectUnauthorized: true });
134
+ const sock = tls.connect({ host: ip, port, servername: domain, rejectUnauthorized: true });
99
135
  const done = (r) => { try { sock.destroy(); } catch { /* 已销毁 */ } resolve(r); };
100
136
  sock.setTimeout(tcpMs, () => done({ ok: false, stage, err: "timeout" }));
101
137
  sock.on("connect", () => { tcpCost = Date.now() - t0; stage = "tls"; sock.setTimeout(tlsMs); });
@@ -104,6 +140,102 @@ function tcpTlsProbe(ip, domain, tcpMs, tlsMs) {
104
140
  });
105
141
  }
106
142
 
143
+ // SSH banner 探测(type:"ssh" 的域名走这条,如 ssh.github.com 的 SSH-over-443):
144
+ // 服务端**先**发 banner(SSH-2.0-...),客户端 TLS/HTTP 三关都不适用 —— 读到合法 banner 即活。
145
+ // 超时复用 tcpMs:SSH banner 在 TCP 建连后立刻到,不需要单独一档。
146
+ export function parseSshBanner(buf) {
147
+ const s = String(buf.slice(0, 255));
148
+ const nl = s.indexOf("\r\n");
149
+ if (nl === -1) { return null; }
150
+ const line = s.slice(0, nl);
151
+ return /^SSH-\d\.\d-.+\r?$/.test(line) ? line : "";
152
+ }
153
+
154
+ export function sshProbe(ip, tcpMs, port = 443) {
155
+ return new Promise((resolve) => {
156
+ const t0 = Date.now();
157
+ let tcpCost = -1;
158
+ let settled = false;
159
+ let acc = Buffer.alloc(0);
160
+ const sock = net.connect({ host: ip, port });
161
+ const done = (r) => {
162
+ if (settled) { return; }
163
+ settled = true;
164
+ try { sock.destroy(); } catch { /* 已销毁 */ }
165
+ resolve(r);
166
+ };
167
+ sock.setTimeout(tcpMs, () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "timeout" }));
168
+ sock.on("connect", () => { tcpCost = Date.now() - t0; });
169
+ sock.on("data", (c) => {
170
+ acc = Buffer.concat([acc, c]);
171
+ const banner = parseSshBanner(acc);
172
+ if (banner) { done({ ok: true, tcpMs: tcpCost, banner: banner.slice(0, 48), err: "" }); }
173
+ else if (banner === "") { done({ ok: false, stage: "banner", err: "not ssh" }); }
174
+ else if (acc.length >= 255) { done({ ok: false, stage: "banner", err: "not ssh" }); }
175
+ });
176
+ sock.on("timeout", () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "timeout" }));
177
+ sock.on("error", (e) => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: String(e.code || e.message).slice(0, 60) }));
178
+ sock.on("close", () => done({ ok: false, stage: tcpCost < 0 ? "tcp" : "banner", err: "closed" }));
179
+ });
180
+ }
181
+
182
+ // 🔴 schannel 二次探测:Node 的 TLS ClientHello 形态会被中间设备识别并 RST(README §3 实测:
183
+ // 同一 IP 同一时刻 openssl 20/20、Node 5/20、浏览器正常)。Windows 10+ **自带**的
184
+ // curl.exe 是 Schannel 构建 —— 与 Edge 同一 TLS 栈,指纹与浏览器一致。
185
+ // 被节点侧 TLS 阻断的候选用它重探一次:能给出「与浏览器一致」的真实判定,
186
+ // 而不是把「浏览器明明能用」的域名写成 # blocked。
187
+ // 🔴 必须显式定位 System32 的 curl.exe:PATH 里排在前面的可能是 Git 自带的 OpenSSL 构建
188
+ // (实测 mingw64 curl 走 OpenSSL,指纹又变回去了)。找不到就返回 null,退回旧行为。
189
+ export function findSystemCurl() {
190
+ if (process.platform !== "win32") { return ""; }
191
+ const p = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "curl.exe");
192
+ return fs.existsSync(p) ? p : "";
193
+ }
194
+
195
+ // 一次 schannel 探测:curl --resolve 把域名钉到候选 IP,-m 限时。
196
+ // 返回 { ok, status, tlsMs, ttfb, err };连接层失败(含 TLS 被 reset)status=-1。
197
+ // opts = { exec, curl, port } 可注入(单测不打真网络)。
198
+ export function curlSchannelProbe(ip, domain, cfg, opts = {}) {
199
+ const curl = opts.curl ?? findSystemCurl();
200
+ if (!curl) { return Promise.resolve(null); }
201
+ const exec = opts.exec || execFile;
202
+ const sig = sigFor(domain, cfg);
203
+ const httpMs = Math.max(2000, cfg.timeouts.httpMs);
204
+ const port = opts.port ?? 443;
205
+ return new Promise((resolve) => {
206
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ghprobe-curl-"));
207
+ const body = path.join(dir, "body");
208
+ exec(curl, [
209
+ "-sS", "-o", body, "--resolve", `${domain}:${port}:${ip}`,
210
+ "-m", String(Math.round(httpMs / 1000)),
211
+ "-w", "%{http_code} %{time_appconnect} %{time_starttransfer}",
212
+ `https://${domain}:${port}${sig.path}`,
213
+ ], { timeout: httpMs + 4000, windowsHide: true }, (e, stdout, stderr) => {
214
+ let out = { ok: false, status: -1, tlsMs: -1, ttfb: -1, err: "" };
215
+ if (e) {
216
+ const msg = String(stderr || e.message).trim().split(/\r?\n/)[0].slice(0, 60);
217
+ out = { ok: false, status: -1, tlsMs: -1, ttfb: -1, err: `schannel:${msg || "failed"}` };
218
+ } else {
219
+ const parts = String(stdout).trim().split(/\s+/);
220
+ const statusCode = Number(parts[0]) || -1;
221
+ let bodyText = "";
222
+ try { bodyText = fs.readFileSync(body, "utf8").slice(0, 4096); } catch { /* 空响应体 */ }
223
+ const checked = checkHttpResponse(statusCode, bodyText, "", sig);
224
+ // 跳转校验弱化:curl 未跟随跳转、也没取回 Location 头,3xx 交给 allow 列表判。
225
+ // 只在 TLS 已经通过 schannel 之后才会走到这,风险可接受(README §3 的假活是 HTTP 层的)。
226
+ out = {
227
+ ok: checked.ok, status: statusCode,
228
+ tlsMs: Math.round(Number(parts[1]) * 1000) || -1,
229
+ ttfb: Math.round(Number(parts[2]) * 1000) || -1,
230
+ err: checked.ok ? "" : `http:${checked.err}`,
231
+ };
232
+ }
233
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 清理失败不管 */ }
234
+ resolve(out);
235
+ });
236
+ });
237
+ }
238
+
107
239
  // 读取响应体前 n 字节,用于特征校验;读够后立即断开,不下载完整大页面
108
240
  function readPrefix(res, maxBytes) { return new Promise((resolve) => {
109
241
  const chunks = [];
@@ -161,12 +293,12 @@ export function sigFor(domain, cfg) {
161
293
  }
162
294
 
163
295
  // 第三关:HTTP GET(host=IP、SNI=域名、Host头=域名),读少量响应体做终审
164
- function httpProbe(ip, domain, httpMs, cfg) {
296
+ function httpProbe(ip, domain, httpMs, cfg, port = 443) {
165
297
  return new Promise((resolve) => {
166
298
  const sig = sigFor(domain, cfg);
167
299
  const t0 = Date.now();
168
300
  const req = https.request({
169
- host: ip, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
301
+ host: ip, port, servername: domain, method: "GET", path: sig.path, timeout: httpMs,
170
302
  headers: { Host: domain, "user-agent": "ghprobe/0.1.0", accept: "*/*" },
171
303
  }, async (res) => {
172
304
  const ttfb = Date.now() - t0;
@@ -198,15 +330,49 @@ export function looksLikeTlsBlocked(failure) {
198
330
  return RESET_RE.test(err) || err === "timeout";
199
331
  }
200
332
 
201
- // 探测单个 (域名, IP):三关全过才算活;分数越小越优
202
- export async function probeIp(domain, ip, cfg) {
333
+ // 探测单个 (域名, IP):三关全过才算活;分数越小越优。
334
+ // deps = { curlProbe, port } 可注入(单测把 port 指到本地服务,不打真网络),
335
+ // 缺省 curlProbe 用 curlSchannelProbe、端口 443。
336
+ // 两条扩展路径:
337
+ // ① type:"ssh" 的域名(ssh.github.com)不跑 TLS/HTTP 三关,读 SSH banner 判活;
338
+ // ② TLS 阶段被指纹阻断时(TCP 通、TLS 被 reset),用 Windows 自带 curl(schannel,
339
+ // 与浏览器同一 TLS 栈)重探一次 —— 0.3.0 前这种情况只能写 # blocked 放弃,
340
+ // 而「浏览器明明能用」的域名就这样被误判成不可达。重探也失败时:
341
+ // HTTP 层有响应 = TLS 其实通了(假活,不是指纹问题,不标 blocked);
342
+ // 连接层仍失败 = 连浏览器同款栈都过不去,才是真被阻断。
343
+ export async function probeIp(domain, ip, cfg, deps = {}) {
203
344
  const { tlsMs, httpMs } = cfg.timeouts;
204
345
  const tcpMs = cfg.timeouts.tcpMs ?? tlsMs; // 老配置没这一项时退回 tlsMs,行为不变
205
- const g = await tcpTlsProbe(ip, domain, tcpMs, tlsMs);
346
+ const port = deps.port ?? 443;
347
+ const sig = sigFor(domain, cfg);
348
+
349
+ if (sig.type === "ssh") {
350
+ const g = await sshProbe(ip, tcpMs, port);
351
+ return g.ok
352
+ ? { domain, ip, ok: true, tcpMs: g.tcpMs, tlsMs: g.tcpMs, ttfb: g.tcpMs, status: 0, score: g.tcpMs, banner: g.banner, err: "", via: "ssh" }
353
+ : { domain, ip, ok: false, stage: g.stage, err: g.err };
354
+ }
355
+
356
+ const g = await tcpTlsProbe(ip, domain, tcpMs, tlsMs, port);
206
357
  if (!g.ok) {
358
+ if (looksLikeTlsBlocked(g) && cfg.probe?.schannelRetry !== false) {
359
+ const s = await (deps.curlProbe || curlSchannelProbe)(ip, domain, cfg, { port });
360
+ if (s) {
361
+ if (s.ok) {
362
+ const tcp = g.tcpMs >= 0 ? g.tcpMs : s.tlsMs;
363
+ const tlsCost = s.tlsMs > 0 ? s.tlsMs : 0;
364
+ const score = Math.round(tcp * 0.4 + tlsCost * 0.4 + (s.ttfb > 0 ? s.ttfb : httpMs) * 0.2);
365
+ return {
366
+ domain, ip, ok: true, tcpMs: tcp, tlsMs: tcp + tlsCost,
367
+ ttfb: s.ttfb, status: s.status, score, via: "schannel", err: "",
368
+ };
369
+ }
370
+ return { domain, ip, ok: false, stage: s.status > 0 ? "http" : "tls", err: s.err, tlsBlocked: !(s.status > 0), via: "schannel" };
371
+ }
372
+ }
207
373
  return { domain, ip, ok: false, stage: g.stage, err: g.err, tlsBlocked: looksLikeTlsBlocked(g) };
208
374
  }
209
- const h = await httpProbe(ip, domain, httpMs, cfg);
375
+ const h = await httpProbe(ip, domain, httpMs, cfg, port);
210
376
  const tlsCost = g.tlsMs - g.tcpMs;
211
377
  const score = Math.round(g.tcpMs * 0.4 + tlsCost * 0.4 + (h.ok ? h.ttfb : httpMs) * 0.2);
212
378
  return {
package/src/ranker.js CHANGED
@@ -28,3 +28,29 @@ export function neededFallbacks(ranked, cfg) {
28
28
  }
29
29
  return [...new Set(list)];
30
30
  }
31
+
32
+ // 多次采样合并(纯函数):把首轮结果与复测样本按 (domain, ip) 分组,
33
+ // · 有活样本 -> score = 活样本均值 + 每个失败样本罚 penaltyMs(抖动 IP 别排第一)
34
+ // · 全部失败 -> 保留失败结果(字段取首个,ok=false,供 blockedDomainsFromRaw 判读)
35
+ // 为什么需要:单次采样抖动大时排名近乎随机 —— 而每次换 IP 都要刷 DNS 缓存、断连接池,
36
+ // 排名稳比单次「最优」更重要。0.3.0 前工具排名单采样,实测同一 IP 相邻两轮分差可达数倍。
37
+ export function mergeSamples(first, extra, penaltyMs = 500) {
38
+ const by = new Map();
39
+ const push = (r) => {
40
+ if (!r || !r.domain || !r.ip) { return; }
41
+ const k = `${r.domain}|${r.ip}`;
42
+ if (!by.has(k)) { by.set(k, []); }
43
+ by.get(k).push(r);
44
+ };
45
+ for (const r of first) { push(r); }
46
+ for (const r of extra) { push(r); }
47
+ const out = [];
48
+ for (const rs of by.values()) {
49
+ const ok = rs.filter((r) => r.ok);
50
+ if (!ok.length) { out.push({ ...rs[0], ok: false }); continue; }
51
+ const failed = rs.length - ok.length;
52
+ const avg = Math.round(ok.reduce((a, r) => a + r.score, 0) / ok.length);
53
+ out.push({ ...ok[0], ok: true, score: avg + failed * penaltyMs, samples: rs.length });
54
+ }
55
+ return out;
56
+ }
package/src/status.js ADDED
@@ -0,0 +1,88 @@
1
+ // SPDX-License-Identifier: GPL-3.0-only
2
+ // Copyright (C) 2026 zmw
3
+ // status:秒级速查。与 doctor 的区别:doctor 跑一轮完整探测(~1 分钟)给诊断,
4
+ // status **不打任何网络请求**,只把「hosts 里现在写了什么 / 自愈日志最近发生了什么 /
5
+ // 计划任务在不在」摆出来 —— 日常「现在什么情况」用这个,坏了再上 doctor。
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { parseBlock, targetDomains } from "./health.js";
9
+ import { userDataDir, WIN_HOSTS } from "./hosts.js";
10
+ import { readHeartbeat, heartbeatFresh, processAlive } from "./daemon.js";
11
+
12
+ export function healthLogPath() {
13
+ return path.join(userDataDir(), "ghprobe.log");
14
+ }
15
+
16
+ // 读健康检查日志最后 n 行(文件不存在/读不了返回 [])
17
+ export function readLogTail(n = 8, logPath = healthLogPath()) {
18
+ try {
19
+ const lines = fs.readFileSync(logPath, "utf8").trimEnd().split(/\r?\n/);
20
+ return lines.slice(-n);
21
+ } catch { return []; }
22
+ }
23
+
24
+ // 从 hosts 内容取块生成时间(# generated <ts> by ghprobe),没有则 null
25
+ function generatedAt(content) {
26
+ const m = String(content || "").match(/^# generated (\S+) by ghprobe$/m);
27
+ return m ? m[1] : null;
28
+ }
29
+
30
+ // 纯函数:由既有状态组装速查报告。全离线 —— 不探测、不解析、不查任务网络。
31
+ export function buildStatusReport({ hostsContent = "", cfg, logTail = [], task = null, daemon = undefined }) {
32
+ const current = parseBlock(hostsContent);
33
+ const targets = targetDomains(cfg);
34
+ const lines = [];
35
+
36
+ // ① hosts 块现状
37
+ const gen = generatedAt(hostsContent);
38
+ if (!current.entries.size) {
39
+ lines.push("hosts:还没有 ghprobe 块 —— 先跑 ghprobe install");
40
+ } else {
41
+ lines.push(`hosts:${current.entries.size} 个域名(块生成于 ${gen ?? "?"})`);
42
+ for (const [d, ip] of current.entries) {
43
+ const alts = (current.extras.get(d) || []).join(", ");
44
+ lines.push(` ${d.padEnd(32)} ${ip}${alts ? ` 备选: ${alts}` : ""}`);
45
+ }
46
+ }
47
+ const cooling = new Set([...current.absent.keys(), ...(current.blocked || new Map()).keys()]);
48
+ const missing = targets.filter((d) => !current.entries.has(d) && !cooling.has(d));
49
+ for (const [d, ts] of current.absent) { lines.push(` [缺失] ${d}(${ts} 起冷却)`); }
50
+ for (const [d, ts] of (current.blocked || new Map())) { lines.push(` [阻断] ${d}(${ts} 起冷却;浏览器栈也验证过被拦,详见 README §3)`); }
51
+ if (missing.length) { lines.push(` [未写入] ${missing.join(", ")}(下轮健康检查自动补)`); }
52
+
53
+ // ② 常驻守护 + 计划任务
54
+ if (daemon === undefined) { daemon = readHeartbeat(); }
55
+ if (daemon && heartbeatFresh(daemon) && processAlive(daemon.pid)) {
56
+ const age = Math.round((Date.now() - new Date(daemon.at).getTime()) / 1000);
57
+ lines.push(`常驻守护:运行中(pid ${daemon.pid},心跳 ${age}s 前,15~45s 自适应自愈)`);
58
+ } else if (daemon) {
59
+ lines.push("常驻守护:未运行(心跳陈旧)—— 监督者会在下一分钟自动拉起");
60
+ } else {
61
+ lines.push("常驻守护:未启动过 —— 下一轮 health(≤1 分钟)自动拉起");
62
+ }
63
+ if (task === null || task === undefined) {
64
+ lines.push("计划任务:查询跳过(仅 Windows)");
65
+ } else if (task.output && /ghprobe/i.test(task.output)) {
66
+ lines.push("计划任务:已注册,每分钟自愈中");
67
+ } else {
68
+ lines.push(`计划任务:${task.error ? `查询失败 ${task.error}` : "未注册 —— 跑 ghprobe install"}`);
69
+ }
70
+
71
+ // ③ 自愈日志尾部
72
+ if (logTail.length) {
73
+ lines.push(`自愈日志(最近 ${logTail.length} 条,完整见 ${healthLogPath()}):`);
74
+ for (const l of logTail) { lines.push(` ${l}`); }
75
+ } else {
76
+ lines.push("自愈日志:暂无(还没跑过 health,或日志不可读)");
77
+ }
78
+ return lines;
79
+ }
80
+
81
+ // 一轮速查。deps 全可注入(单测不打文件系统/任务)。
82
+ export async function runStatus(cfg, deps = {}) {
83
+ const hostsContent = deps.hostsContent ?? (() => { try { return fs.readFileSync(WIN_HOSTS, "utf8"); } catch { return ""; } })();
84
+ const logTail = deps.logTail ?? readLogTail();
85
+ const task = deps.taskQuery ? await deps.taskQuery() : null;
86
+ const lines = buildStatusReport({ hostsContent, cfg, logTail, task });
87
+ return { lines, entries: parseBlock(hostsContent).entries.size };
88
+ }