# dsh-github-accel 稳定性改造方案(v0.2) > 撰写时间:2026-09-25。所有结论都来自**本机实测**(`recon/probe.mjs`、`recon/probe2.mjs`、 > `netstat`、`Get-NetTCPConnection`、实际 curl 经隧道/直连、证书 SAN 实测)。凡是没有实测支撑的 > 推断都明确标注。 --- ## 0. 摘要(先看这段) 现在的插件**能用,但有三类会让人以为「时好时坏」的硬伤**: | 编号 | 问题 | 性质 | 实测证据 | | --- | --- | --- | --- | | **B1** | 客户端断开后**不关上游 socket**,连接永久泄漏 | 资源泄漏,越用越坏 | `status.connections=7` 一直不归零;`trace=[]`(因为记 trace 的 `close` 回调从来没跑过) | | **B2** | CONNECT 代理路径里 `clientClosed` **未定义**(`accel.js:851` 引用了 `listenOn` 闭包里的变量) | **进程级未捕获异常 → DSH 直接死** | 静态确认:`clientClosed` 只在 688 行声明,851 行在另一个函数作用域 | | **B3** | 拿不到 SNI 之前的每一个浏览器连接都要**现连上游**,且是**串行试错**(单址 6 s) | 首包慢、偶发 30 s 挂死 | 经隧道 `tls=0.67–3.38 s`,而直连 TLS 只要 `0.42–0.63 s` | | **B4** | `raw/objects/avatars/user-images/pkg-containers/github.io` **没进 hosts**,而其中一部分有 AAAA、本机 IPv6 到 GitHub 又是**黑洞** | 浏览器卡住(图片、raw 文件) | 4 个 AAAA 全部 `tcp FAIL(timeout)`;本机确有全局 IPv6 | | **B5** | 候选地址只按「TCP 通不通」记好,**不校验它是否真服务这个域名** | 偶尔整段不可用 | 实测 `20.205.243.168` 对 SNI=`github.com` 握手成功,但 HTTP 回 400 | | **B6** | DSH 被强杀(或 B2 触发崩溃)后 hosts 留在接管态 | 全系统 GitHub 变砖 | 第 3 节有历史记录:Steam++ 留下的正是这个残局 | 外加两条**结构性**问题: - **B7** 没有自愈:网络切换 / 睡眠唤醒 / DNS 换答案之后,`good`、`badIps` 冷却表都不会自动失效。 - **B8** `COALESCING_UNSAFE`(禁止把 githubusercontent 写进 hosts)这条守卫,**在「一域名一地址」的前提下已经过时**——它拦掉的那批域名恰恰是 B4 的受害者。 改造目标两条,缺一不可: - **G1 工具链可用**:`git` / `npm` / `ghcr.io` / `go get`,**不要求管理员权限也能用**。 - **G2 浏览器顺畅**:打开 github.com、看 README、看头像、看 raw 文件都**又快又不挂**。 --- ## 1. 本机实测事实(改造的事实基础) ### 1.1 网络质量:其实不差 直连 GitHub 各个 IP 的 TCP 连接延迟 **210–305 ms**,TLS 握手 **420–500 ms**,TTFB **620–900 ms**。 所以「慢」不是距离问题,**是我们自己加的延迟**。 ### 1.2 证书分组(决定回环地址怎么分) ``` github.com SAN: github.com, www.github.com api / codeload / uploads / SAN: *.github.com, github.com collector / alive.github.com github.githubassets.com SAN: *.githubassets.com npm.pkg.github.com SAN: *.pkg.github.com ghcr.io SAN: *.ghcr.io raw / objects / release-assets / SAN: *.github.com, *.github.io, *.githubusercontent.com, avatars / user-images / github.com, github.io, githubusercontent.com pkg-containers / gist.*.ghu / camo.ghu / github.io / pages.github.com ``` **关键结论**:`*.githubusercontent.com` 那组的证书 **SAN 里含 `github.com`**(README 里的老结论是对的), 所以它**绝对不能和 github.com 共用回环地址**。但**一域名一地址**之后,这个危险自动消失—— 反过来说,**「一域名一地址」已经足够保证安全,`COALESCING_UNSAFE` 这道守卫不再需要**。 最简且严格安全的规则:**一个域名 = 一个回环地址**(现状保留)。127.0.0.0/8 有 1600 万个地址,不心疼。 ### 1.3 IPv6 黑洞(B4 的证据,**已修订**) 初次只测了 `raw.githubusercontent.com`。逐域名实测 (`Resolve-DnsName -Type AAAA -DnsOnly`,必须带 `-DnsOnly`,否则会先读 hosts 拿到回环地址) 之后的**准确**结论是: **有 AAAA,且 4 条全部 timeout**(真正的黑洞受害者,共 5 个): ``` github.githubassets.com 2606:50c0:8000-8003::215 avatars.githubusercontent.com 2606:50c0:8000-8003::154 raw.githubusercontent.com 2606:50c0:8000-8003::154 user-images.githubusercontent.com 2606:50c0:8000-8003::154 pkg-containers.githubusercontent.com 2606:50c0:8000-8003::154 ``` **没有 AAAA**(接管理由是「统一走多候选选路 + 免 DNS 污染」,不是黑洞): `objects` / `release-assets` / `camo` / `gist.githubusercontent.com`、 `github.com`、`api` / `codeload` / `uploads` / `collector` / `alive.github.com`、 `npm.pkg.github.com`、`ghcr.io`。 另外 `gist.github.com` 的 AAAA 是 **`2001::1`** —— 明显的污染标记。 而 **hosts 里的域名不会返回 AAAA**(实测): ``` github.com A=[127.0.0.2] AAAA=[] <- 被 hosts 接管,AAAA 被抑制 raw.githubusercontent.com A=[185.199.108.133,...] AAAA=[2606:50c0:8000::154,...] <- 没接管,AAAA 照常返回 ``` Windows 默认前缀策略 `::/0`(40) 优先于 `::ffff:0:0/96`(35),也就是浏览器**优先试 IPv6**。 所以把上面那 5 个域名(连同整族 `githubusercontent`)接管进来,就从根上消掉这个问题。 这是「页面资源慢」的一条实打实的原因,但它**解释不了 github.com 网页本身打不开**(那个域名没有 AAAA)。 ### 1.4 DNS - 系统 DNS 对 `github.com` 只回 **1 条 A 记录**(`20.205.243.166`),且这是**地理优选**结果,不是污染。 - `gist.github.com` → `37.61.54.158`(**这是污染**,超时);备用池里 4 个地址也全部 `ECONNRESET/timeout`。 - DoH:Cloudflare / Google / Quad9 **全部 timeout**;`doh.pub`(DNSPod)**可用,202 ms**,但 对 `github.com` 给出**与系统 DNS 完全相同**的答案(`20.205.243.166`)。 **结论(重要,且与 Watt Toolkit 的做法不同)**:这台机器上 **DoH 不能提供「更多候选」**, 拿它当命脉是错的。**不要在 DoH 上押注**;正确做法是**本地自己测速 + 自建候选池**。 ### 1.5 「TLS 通 ≠ 服务这个 Host」(B5 的证据) | 域名 | 地址 | TLS | HTTP | | --- | --- | --- | --- | | github.com | 20.205.243.166 | OK | **200** | | github.com | 140.82.112.4 | OK | **timeout** | | github.com | 140.82.113.4 | OK | 200 | | github.com | 140.82.114.4 | OK | **timeout** | | github.com | 140.82.121.4 | OK | **timeout** | | api.github.com(旧注释说的) | 20.205.243.168 | OK | 对 `Host: github.com` 回 **400** | 5 个候选里 3 个「能握手但不服务内容」。现在的代码只按 TCP connect 判好,**选错的概率不低**。 ### 1.6 现在跑着的实例(改造前的基线) ``` hosts 块 : 7 条(github.com .. ghcr.io),指向 127.0.0.2 .. 127.0.0.8 443 监听 : 127.0.0.2..8 共 7 个(都是 DSH 进程,高完整性级别 = **有管理员权**) 80 监听 : 同 7 个地址 18999 监听 : 127.0.0.1 connections : 7 <- 泄漏,见 B1 trace : [] <- 因为 close 回调没跑过,诊断能力等于零 ``` 经隧道实测(curl): ``` github.com 200 tls=1.758s total=3.074s api.github.com 301 tls=0.822s total=1.028s codeload.github.com 301 tls=0.947s total=1.353s github.githubassets.com 404 tls=3.379s total=4.861s <- 页面资源主站,最慢 ghcr.io 301 tls=0.668s total=1.004s npm.pkg.github.com 301 tls=1.897s total=2.351s uploads.github.com 302 tls=1.984s total=2.327s ``` --- ## 2. 与 Watt Toolkit(Steam++)的对比与取舍 —— **读源码后的版本** > 这一节在初稿是「凭印象的对比」。后来把 SteamTools 源码 > (`develop` @ `d0421314`,反向代理部分整体 fork 自 **FastGithub 2.1.4**)读了一遍, > 结论有几处要改。完整报告见 [`docs/research-watt-toolkit.md`](research-watt-toolkit.md)。 | 维度 | Watt Toolkit 实际做法(源码级) | 本项目的判断 | | --- | --- | --- | | 重定向 | **四选一互斥模式**:Hosts / DNSIntercept(WinDivert) / PAC / System,默认 **Hosts**;hosts 写**单个 `127.0.0.1`**(全仓 `127.0.0.[2-9]` 零命中);PAC 与系统代理**与 hosts 不并行,是替代关系** | 保留 hosts,但**一域名一地址**。它的 PAC/System 思路我们采纳成第三条腿,但改成「垂直叠加」而不是互斥 | | 本地服务 | 443 上 Kestrel + **TLS MITM**(`TlsInvadeMiddleware` → `UseHttps` 逐 SNI 现签叶子证书),根 CA 装进 `LocalMachine\Root`;另有 `:26561` HTTP 正向代理 | **拒绝 MITM**(已实测 schannel `0x80092012`、Node `UNABLE_TO_VERIFY_LEAF_SIGNATURE`)。继续 SNI 直通 | | **阻断连接复用** | **逐域名自签叶子证书**(SAN 只含该域名)→ 按 RFC 9113 §9.1.1,证书不覆盖就不许复用。**这是它比「一域名一地址」更强的一招** | 我们没有证书手段,用一域名一地址达成同样效果(前提①不成立)。**这一点必须承认它更干净** | | 上游 IP | **不测速、不下发最优 IP**。服务端只下发「域名 → 转发目标」表(可以是固定 IP);本地 `TestSpeedAsync()` 直接 `throw new NotImplementedException()`;按序逐 IP 试连,**单 IP 超时 10 s** | 初稿说「抄它的测速优选」——**它根本没有**。改成**本地自己测速**:后台端到端校验 + EWMA 打分 + 冷却。比它强 | | DNS | DoH **默认开**,10 个候选(dns.pub / 1.12.12.12 / 120.53.53.53 / dns.alidns / 223.5.5.5 / 223.6.6.6 / dns.google / cloudflare / doh.360.cn / 101.6.6.6:8443),启动前并发测所有候选取最快;缓存 TTL **9.9 min**;hosts 模式强制改用 Dnspod | **采纳「启动时挑一个能用的 DoH」这个思路**,但**不押注**:本机 Cloudflare/Google/Quad9 全 timeout,只有 `doh.pub` 通且答案与系统 DNS 相同。默认关闭,只作为「系统 DNS 明显被污染」时的补充(`DSH_GITHUB_ACCEL_DOH`) | | SNI 解析 | **自研解析器一个都没有**,全交给 Kestrel/`SslStream`;唯一的裸字节嗅探只 `ReadAtLeastAsync(2)` 看 `0x16 0x03` 就原样退回 | 我们的累积到完整 TLS record 再解析**是必要的**(跨段 ClientHello 实测存在),保留 | | 连接池 | 每 `(域名, 域名配置)` 一个 `SocketsHttpHandler`(跨域名不复用);handler **首次 10 s / 之后 100 s 轮换**,旧 handler 用弱引用延迟 Dispose | 我们只剩裸 TCP 要管:**预热池** 2 条/热域名、TTL 5 s,互补 | | 首包提速 | **无 pre-connect / 无预热**(grep 零命中) | 我们做预热池,实测隧道 p50 **比直连还快**(见第 11 节) | | 失败与回滚 | hosts 用「双标记区 + 按原行号回放的备份区」精确还原;退出时**先清 hosts 再停反代**;**崩溃不自动还 hosts**;有 `FileSystemWatcher` 看门狗(2.65 s 节流)防别人改 hosts | 它的**精确回放备份**比我们的「整段删除」更鲁棒(多软件共存时);**看门狗我们采纳了**(`verifyHosts()`),但更保守:见到别人的块直接认输。**「崩溃不还 hosts」我们明确做得比它好**(哨兵 + 启动自修 + 一键 recover) | | 其它 | 无 Alt-Svc/HTTP3 处理(**公认短板**);`SOCKS5` 只有出站客户端、本地无服务端;Git(9418)/SSH(22) 监听代码已注释掉 | 我们同样不处理 Alt-Svc,但在文档里写清楚,并给出 PAC / `QuicAllowed=0` 两条缓解 | **一句话**:抄它的「多通路兜底 + 退出恢复 + hosts 看门狗 + 启动时挑可用 DoH」, **不抄**它的 MITM 和自有 API;**它没有的东西**(测速优选、预热池、多候选竞速、 崩溃自愈)我们自己做。 --- ## 3. 历史教训(为什么「自愈」必须做) `~/.dsh/gh-proxy/README.md` 记录的现场:Steam++ 在 hosts 里留了 31 条 `127.0.0.1`, 但它自己没在 443 上监听 → **所有 GitHub 请求变成 `ECONNREFUSED 127.0.0.1:443`**, GitHub 从「有点慢」变成「完全打不开」,而用户完全不知道是谁干的。 本插件只要出现同样的情况(DSH 被强杀 / B2 崩溃 / 443 被别的程序抢走),就会复现同一场事故。 所以**「写 hosts 的人必须负责撤销」**要升级成三条: 1. 写之前留**哨兵文件**(记录 pid + 域名 + 时间 + 备份路径); 2. 进程**正常退出 / 被强杀后下次启动**都能发现「上次的接管没撤」并自动修复; 3. 提供**一条命令**的应急还原(不需要 DSH 在跑)。 --- ## 4. 目标架构 ``` ┌─ 浏览器 / git / npm / ghcr ──────────────────────────────────────────────┐ │ │ │ P1 hosts + 一域名一地址 P2 CONNECT 代理 P3 PAC/系统代理 │ │ (系统级,需管理员) 127.0.0.1:18999 (无需管理员, │ │ 顺带解决 IPv6 黑洞 (工具链/HTTPS_PROXY) 抗浏览器 Secure DNS)│ │ │ │ │ │ └─────────┴────────────────────────────┴──────────────────────────┴─────────┘ │ ┌─────────────▼─────────────┐ │ L3 隧道层 tunnel.js │ SNI/Host 嗅探(累积到完整 TLS record) │ 双向管道 + 强制同生共死 │ 绝不泄漏 socket └─────────────┬─────────────┘ │ ┌─────────────▼─────────────┐ │ L2 连接层 net.js │ ① 预热池(pop 即用) │ race() happy-eyeballs │ ② 并行竞速(首 2 个候选抢) │ 打分 / 冷却 / 校验 │ ③ 端到端校验(TLS+HTTP) └─────────────┬─────────────┘ │ ┌─────────────▼─────────────┐ │ L1 地址层 net.js │ pinned → 本机实测最优 → DNS A │ 候选池 + TTL + 健康循环 │ → 静态备用池 →(可选) DoH └─────────────┬─────────────┘ │ ┌─────────────▼─────────────┐ │ L0 自愈层 hosts.js + 哨兵 │ 原子写 / 部分接管保护 / 启动自修 └───────────────────────────┘ ``` --- ## 5. 关键设计决策(每条都对应一个实测问题) ### D1 — 双向管道「同生共死」,彻底消灭泄漏(修 B1) 任何一侧 `close` / `error` 都必须 `destroy()` 另一侧,且**每一条事件处理器都要能抛不出异常**。 ``` a.on('close', kill); b.on('close', kill); a.on('error', swallow); b.on('error', swallow) ``` 并在 `kill()` 里对两侧各调用一次 `destroy()`。**禁止**只 `pipe` 不接管生命周期。 验收:`status.connections` 在一批请求结束后**归零**;`trace` 里能看到完整的 `end` 记录。 ### D2 — 所有事件处理器 **绝对不能抛**(修 B2) `net` / `tls` 的 `error` / `data` 回调里的任何 `ReferenceError` 都是**进程级崩溃**—— 已确认 DSH 的 `bin.js` 里**没有** `uncaughtException` 处理器,抛出去就是 DSH 死。 规约:所有回调体包在 `safe()` 里;`clientClosed` 这类状态改为**每次连接独立对象** (`const conn = { clientClosed: false, ... }`),不再依赖闭包变量。 验收:新增用例——**代理路径制造上游错误**,进程必须存活且连接归零。 ### D3 — 端到端校验 + 打分,而不是「TCP 通就算好」(修 B5) - `validate(ip, host)` = TLS 握手(**`rejectUnauthorized: true`**)→ 真发 `HEAD /` → 看响应。 握手成功即证明**该 IP 确实持有该域名的证书**;HTTP 有响应即证明**该边缘确实服务这个 Host**。 - 每个域名维护 `ip -> { ewmaMs, okN, failN, validatedAt }`,选路排序: `pinned > 已验证且延迟最低 > 已验证 > 未验证 > 静态池`。 - 校验在**后台定时跑**(热域名 90 s 一轮),不在连接路径上跑 → 不拖慢首包。 ### D4 — 预热池 + 并行竞速(修 B3) - **预热池**:热域名(github.com / githubassets / api / codeload / raw)各保持 1–2 条**已验证的 空闲上游 socket**(TTL 8 s),浏览器一连上就 `pop` 直接用 → 省掉整个上游 TCP 往返。 - **竞速**:池空时同时向**前 2 个候选**发起连接(第二个延迟 250 ms 起步),**先连上的赢**, 输的那个 `destroy()`。整体预算 4 s,单址 1.5 s → 最坏情况从 **30 s 降到 4 s**。 - 目标:经隧道 TLS 从 `0.67–3.38 s` 压到 **≤ 直连 + 60 ms**。 ### D5 — 接管 githubusercontent 家族,根除 IPv6 黑洞(修 B4) 新增接管:`avatars / camo / raw / objects / release-assets / user-images / pkg-containers / gist.githubusercontent.com`,以及 `collector.github.com`、`alive.github.com`。 每个域名**独立回环地址**,所以连接复用的前提依然不成立(见 1.2)。 **不接管** `github.io` / `pages.github.com`(那是用户自己的站点,风险不对等),仅保留 opt-in。 ### D6 — 只接管「监听确实起来了」的域名(修部分接管风险) 现在只要**任一** 443 监听成功就写全部域名的 hosts。改成 **按域名逐个判断**: 谁的监听起来了才写谁。一个域名一个地址,互不影响。 ### D7 — 自愈与哨兵(修 B6 / B7) - 写 hosts 前落盘 `~/.dsh/dsh-github-accel/active.json`:`{ pid, startedAt, domains, backup }`。 - **插件装载时**自检:哨兵存在但 pid 已死 → 说明上次是异常退出 → **先撤 hosts 再重新起**。 - **网络变化 / 睡眠唤醒**(`os.networkInterfaces()` 指纹变化)→ 清空缓存与冷却表,重新测速。 - `tools/recover.mjs`:不依赖 DSH,一条命令撤 hosts + 还原系统代理 + 删哨兵。 - 写 hosts 用**临时文件 + rename**,避免崩溃时写出半个文件;读回来校验再算成功。 ### D8 — 第三条腿:PAC / 系统代理(可选,无需管理员) hosts 拿不到管理员权限时(`elevation-required`),浏览器**完全没有覆盖**。 补一条:本地起一个极小的 PAC/HTTP 服务,把 `HKCU\...\Internet Settings\AutoConfigURL` 指过去(**用户级,不需要管理员**),只把 GitHub 域名 `PROXY 127.0.0.1:18999`,其余 `DIRECT`。 > **⚠️ 勘误(2026-09-25,调研后修正)**:本方案初稿给 PAC 的第二个理由是 > 「抗浏览器 Secure DNS(DoH)」——**这是错的**,已从代码注释与本文件中删除。 > Chromium 的 `HostResolverManager` 在 `ResolveLocally()` 里**同步**处理 HOSTS 条目 > (`out_tasks->push_back(TaskType::HOSTS)` 无条件执行且先于 DNS), > Secure DNS 只在真要查 DNS 时才介入,**不会绕过 hosts**。所以「关掉浏览器的安全 DNS」 > 是没必要的。 > > PAC 现在真正的两条理由是: > ① **不需要管理员**(`HKCU`),hosts 写不进去时浏览器仍有覆盖; > ② **按 RFC 7838 §2.4**,「配置了代理的客户端 SHOULD NOT 为请求直连 alternative service」 > —— 也就是说走 PAC 时浏览器不会绕开代理去试 HTTP/3。而 hosts 那条路上, > 浏览器仍会往 `127.0.0.x:443/udp` 试一次 QUIC(缓存 24 h、会反复重试)。 > 实测 `github.githubassets.com` 当前确实回 `alt-svc: h3=":443";ma=86400`。 > > 默认策略:`pac: 'auto'` —— 只在 hosts 写入失败时启用。 > 关闭开关 / 卸载 / 崩溃自愈时必须**逐字段还原**原来的注册表值。 ### D9 — 诊断可用(修「trace 永远是空的」) `status` 增加: - `paths`: 每条通路(hosts / proxy / pac)的 `ok / reason`; - `pool`: 每个热域名的池状态与命中率; - `stats`: 每域名 `{ conns, failovers, poolHits, p50UpstreamMs, p95UpstreamMs }`; - `trace`: 真正的环形缓冲(含 `end` 原因)。 - 新增 `GET /dsh-github-accel/diagnose`:现场跑一次全链路自检,直接给结论。 - 新增 `tools/doctor.mjs`:命令行一屏体检。 --- ## 6. 最终域名表与地址分配(稳定序号,永不平移) | # | 地址 | 域名 | 理由 | | --- | --- | --- | --- | | 0 | 127.0.0.2 | github.com | 交互式应用;`*.github.com` 证书不含它,必须独享 | | 1 | 127.0.0.3 | api.github.com | REST / 插件 | | 2 | 127.0.0.4 | codeload.github.com | clone / tarball | | 3 | 127.0.0.5 | uploads.github.com | LFS / release 上传 | | 4 | 127.0.0.6 | collector.github.com | 页面遥测(不接管会拖慢加载) | | 5 | 127.0.0.7 | alive.github.com | 页面心跳 | | 6 | 127.0.0.8 | github.githubassets.com | **页面 JS/CSS 主站**(最慢的那个) | | 7 | 127.0.0.9 | avatars.githubusercontent.com | 头像 | | 8 | 127.0.0.10 | camo.githubusercontent.com | 外链图片 | | 9 | 127.0.0.11 | raw.githubusercontent.com | README 里的图片/文件 | | 10 | 127.0.0.12 | objects.githubusercontent.com | 附件 | | 11 | 127.0.0.13 | release-assets.githubusercontent.com | release 下载 | | 12 | 127.0.0.14 | user-images.githubusercontent.com | issue 图片 | | 13 | 127.0.0.15 | pkg-containers.githubusercontent.com | 容器包 | | 14 | 127.0.0.16 | gist.githubusercontent.com | gist 内容(网页版 gist.github.com 单独处理) | | 15 | 127.0.0.17 | npm.pkg.github.com | npm registry | | 16 | 127.0.0.18 | ghcr.io | 容器 registry | | — | (运行时决定) | gist.github.com | **仅当校验找到可用地址时**才接管;否则放出 hosts 让它快速失败 | 序号固定在 `server/domains.js` 里,**增删域名不会让其它域名的地址平移**(老实现靠 `DEFAULT_DOMAINS` 的下标,改表就会全量抖一次)。 --- ## 7. 实施计划(分 4 步,每步都有可验证的验收) ### 第 1 步:止血(改 3 个 bug,风险最低、收益最大) - D1 双向管道同生共死(B1) - D2 事件处理器零异常 + 连接状态对象化(B2) - D6 只接管监听真的起来的域名 - 验收:`node test/accel.test.mjs` 新增「泄漏」与「代理路径上游错误不崩进程」两用例通过; 实际跑 20 次并发请求后 `status.connections` 必须回到 0。 ### 第 2 步:提速(改选路与连接) - D3 端到端校验 + 打分 - D4 预热池 + happy-eyeballs 竞速 - D7 健康循环(后台重测 + 网络变化重置) - 验收:`tools/bench.mjs` 输出——经隧道 TLS `p95 ≤ 直连 TLS p95 + 100 ms`; 连续 30 次请求无一次超过 2 s。 ### 第 3 步:扩面(根除 IPv6 黑洞) - D5 新域名表 + 独立回环地址 - D9 诊断(status/diagnose/doctor) - 验收:`raw.githubusercontent.com` / `avatars.githubusercontent.com` 在浏览器里 不再走 AAAA(`netstat` 上看不到对外 IPv6 连接);页面首屏明显变快。 ### 第 4 步:兜底与自愈 - D7 哨兵 + 启动自修 + `tools/recover.mjs` - D8 PAC / 系统代理(`auto` 策略) - 验收:强杀 DSH 后重启,hosts 自动恢复正确;拿掉管理员权限时浏览器仍能走 PAC 通路。 --- ## 8. 回滚与应急 | 场景 | 动作 | | --- | --- | | 想立刻停 | 点顶部栏开关(撤 hosts + 撤系统代理 + 关监听) | | DSH 死了 / 打不开 | `node tools/recover.mjs`(**管理员**运行;撤 hosts、还原注册表、删哨兵) | | 想完全卸载 | 关开关 → `dsh plugin --profile web remove dsh-github-accel` → 删 `~/.dsh/local-plugins/dsh-github-accel` 软链 | | hosts 被别的工具(Steam++)接管 | 插件**检测到外来块就拒绝启动并明确报错**,绝不和它抢 | 原始 hosts 备份:`C:\Windows\System32\drivers\etc\hosts.dsh-github-accel.bak`(首次接管时留存)。 --- ## 9. 明确不做的(以及为什么) 1. **不做 TLS 中间人**——会让 curl / git / Node 全线证书报错(有实测)。 2. **不装根证书、不装驱动、不装系统服务**——不可回滚的风险不对等。 3. **不把第三方测速 API 当命脉**——引入可用性和隐私依赖;本机自测足够准。 4. **不押注 DoH 取更多候选**——本机实测 DoH 要么不通、要么答案相同;只做可选补充。 5. **不接管 `github.io` / `pages.github.com`**——那是用户自己的站点。 6. **不动系统代理里的非 GitHub 规则**——PAC 只写这一条,其余 `DIRECT`。 --- ## 10. 仍未验证、需要在实施中测的点 - ~~`127.0.0.9` 之后的高位回环地址在 Windows 上是否同样可 bind/connect~~ —— 已在本机 `netstat` 上确认到 `127.0.0.8`,测试里实测 `127.0.0.2`–`127.0.0.18` 全部可 bind + connect。 - 某些安全软件是否会拦截非 `.1` 的回环地址(若遇到,`DSH_GITHUB_ACCEL_LOOPBACK_PREFIX` 可改基址)。 - PAC + `AutoConfigURL` 在 Edge/Chrome 上的生效延迟(需要重启浏览器还是即时)—— 我们已加 `notifySettingsChanged()` 广播 `WM_SETTINGCHANGE`,但还没在真机上验过。 - 预热池在**上游主动断开空闲连接**时的表现 —— 已加「pop 到死连接就透明重连一次」的兜底, 并把 TTL 压到 5 s;bench 里表现为偶发的单次 1.5 s 尖峰,需要长期观察。 --- # 第二部分:实施结果与勘误(2026-09-25 当日完成) ## 11. 落地了什么 ``` server/domains.js 域名表 + 回环地址分配(纯数据/纯函数) 新增 server/hosts.js hosts 块:原子写 / 读回校验 / 外来块检测 / CRLF 保留 重写 server/net.js 候选地址 / 端到端校验 / 打分与冷却 / 竞速 / 预热池 新增 server/tunnel.js SNI·Host 嗅探 / 无泄漏双向管道 / CONNECT 代理 新增 server/sysproxy.js PAC + 系统代理 + DNS 缓存刷新 + 注册表还原 新增 server/accel.js 编排(start/stop/status/diagnose/自愈/健康循环) 重写 lib/index.js 三条 GET 路由 + 装载自检 + 自动恢复 重写 client/client.js 状态提示增加「哪几条通路活着」+ 新失败原因 增量改 test/accel.test.mjs 52 项断言,含 B1/B2/黑洞/stall 四项回归 重写 tools/bench.mjs 隧道 vs 直连的 p50/p95 验收 新增 tools/doctor.mjs 一屏只读体检 新增 tools/recover.mjs 应急还原(不需要 DSH 在跑) 新增 tools/rewrite-hosts.mjs 改成「只写真的有监听的域名」 重写 recon/probe*.mjs 三组只读勘察脚本(留下了原始数据) 新增 ``` ## 12. 实测结果 ### 12.1 测试 `node test/accel.test.mjs` —— 全部通过。四项回归直接对应真实事故: ``` PASS 客户端 destroy 之后上游 socket 全部被销毁(5/5) ← B1 PASS 对端也观察到了连接被关闭(残留 0 条) ← B1 PASS trace 里能看到完整的隧道记录(老代码里永远是空的) ← B1 的连带伤害 PASS 代理路径上游出错时进程必须活着 / 未捕获异常 0 次 ← B2 PASS 黑洞上游被记为 stall(这样坏地址才会被换下去) ← 新发现 B9 PASS 没有出现「管道回调里引用了够不着的变量」这类内部错误 ← 新发现 B10 PASS 非 TLS / 拒绝连接的上游被快速判否 ← 校验层 PASS 整个测试过程没有未捕获异常 / 未处理的 Promise 拒绝 ``` ### 12.2 提速与「救回来」(`node tools/bench.mjs --rounds 10`) 对比口径:**先在本地把候选地址与预热池建好**,然后交替测量「直连最优地址」与 「经本机隧道的 TLS 握手」。直连用一个 IP(模拟浏览器的处境),隧道走完整候选池。 ``` host best-ip direct p50 direct p95 tunnel p50 tunnel p95 tun max tunnel ok dir ok github.com 140.82.113.4 467ms 2015ms 243ms 506ms 506ms 10/10 10/10 api.github.com 20.205.243.168 424ms 454ms 219ms 434ms 434ms 10/10 10/10 codeload.github.com 20.205.243.165 421ms 464ms 217ms 419ms 419ms 10/10 10/10 池命中:28 次 / 未命中 2 次 隧道 p95 相对直连 p95 的最差落后:-20 ms(目标 ≤ 100 ms) PASS 隧道单次最大耗时:506 ms(目标 ≤ 2000 ms) PASS ``` 两件事同时成立: 1. **中位数更快 ~200 ms** —— 预热池把上游连接提前建好了,浏览器一连上就能立刻转发 ClientHello,省掉整整一个上游 RTT。 2. **长尾被砍掉**:`github.com` 直连 p95 是 **2015 ms**(那个唯一 A 记录时好时坏), 隧道 p95 只有 **506 ms** —— 因为它在候选池里换到了活着的那一个。 另一次运行的对照更能说明问题(`--rounds 8`,同一台机器): ``` github.com 直连 0/8(DNS 给的 140.82.113.4 也死了) 隧道 4/8 ✔ ``` **这就是这套东西存在的理由**:浏览器只有一个地址、没有多候选切换;隧道有。 ### 12.3 一处必须记住的实测结论 调整竞速参数(`width` 2→3、单址超时 1800→1200 ms)之前,同一台机器同一时刻: ``` api.github.com direct p95 445ms tunnel p95 2566ms ← FAIL githubassets direct p95 412ms tunnel p95 1570ms ← FAIL ``` 原因是「前两个候选都坏时,第三个要等到前两个超时才被发起」。这说明 **竞速的宽度和单址超时是这套方案的性能命门**,不是调优项。 ## 13. 勘误:初稿里写错的三件事 | # | 初稿的说法 | 实际 | 影响 | | --- | --- | --- | --- | | 1 | 「把 githubusercontent 那批接管进来能治 IPv6 黑洞」 | 只有 **5 个**域名真有 AAAA(`githubassets`、`avatars`、`raw`、`user-images`、`pkg-containers`);`objects`/`release-assets`/`camo`/`gist.githubusercontent` 没有 | 结论仍成立(那 5 个正是页面最重的资源),但范围缩小了;已按域名逐个标注 | | 2 | 「PAC 的意义之一是抗浏览器 Secure DNS」 | **错**。Chromium 在 `ResolveLocally()` 里同步处理 HOSTS,Secure DNS 不会介入。PAC 的真正理由是「不需要管理员」+「按 RFC 7838 §2.4 避免浏览器绕去 QUIC」 | 已从代码注释和本文档删除;用户**不需要**去关浏览器的安全 DNS | | 3 | 「抄 Watt Toolkit 的测速优选」 | **它没有测速优选**。服务端只下发「域名 → 转发目标」表,本地 `TestSpeedAsync()` 直接 `NotImplementedException`,逐 IP 试连、单 IP 超时 10 s | 我们的本地校验+打分+冷却反而是**比它多的东西**,不是抄的 | 另外 `recon/probe3-sni.mjs` 第一版的自动结论也判错过一次:它看到「同一批 IP 上有的 SNI 通、有的不通」就报「SNI 维度干扰」。真实情况是 **IP 维度**(`20.205.243.166` 对所有 SNI 全部 timeout = 该 IP 整体被黑洞)+ **边缘服务范围不同**(`20.205.243.168` 对 `Host: github.com` 回 403 = 那个边缘本来就不服务 github.com)。脚本的判断逻辑已按 IP 为单位重写。 ## 14. 实施中发现的两个新问题(初稿完全没提到) ### B9 —— 坏地址**永远不会被换下去**(这是「时好时坏」最直接的机制) 老代码把坏地址的判据写成 `!clientClosed && up.bytesWritten === 0`。但我们在收到 ClientHello 之后**总是要**先把它转发给上游,`bytesWritten` 必然大于 0 —— 也就是说**那个条件永远不成立,候选表永远学不到东西**,一个黑洞地址会一直坐在第一位, 每次请求都先撞它一遍。 **修法**:换成看**上游回了几个字节**。 | 情况 | 判定 | 后果 | | --- | --- | --- | | 上游一个字节都没回就 RST | 硬失败 | 连续 2 次进冷却(30 s 起,指数退避到 5 min) | | 上游一个字节都没回、拖了 ≥1.5 s | **stall(卡死)** | **一次就降权**(15 s) | | 已经传过字节才断 | 软失败 | 不惩罚(浏览器关页面不能被当成地址坏) | 这条对**黑洞型**地址是唯一有效的判据:它不报错、只是不出声,靠 RST 永远抓不到它。 ### B10 —— 同一个「闭包里够不着的变量」把 trace 也一起废掉了 `connectUpstream` 是 `TunnelServer` 的**另一个方法**,而记 trace 的 `finish()` 定义在 `handleClient` 的闭包里。`onEnd: () => { ...; finish() }` 里的 `finish` 是个 `ReferenceError`,被 `try { hooks.onEnd?.() } catch {}` **静默吞掉** —— 于是隧道全程正常,但 `trace` 永远是空数组,诊断能力为零。 这和 B2 是**同一类**错误。修法有三层: 1. `finish` 挂到 `conn` 上(`conn.finish`),不再依赖闭包; 2. `relay()` 把 `onEnd` 的异常通过 `onError('hook', e)` **报出来**,不再静默; 3. `Accelerator` 接上 `onHandlerError`,写进 `lastError` 与 `trace`(`handler-error` 事件), 并加了一条断言:整个测试过程不许出现任何 `handler-error`。 **教训**:`catch {}` 是这套代码里最危险的一行。凡是吞掉异常的地方,都必须有一条 能把它暴露出来的路径。 ## 15. 还没做 / 需要用户决定的事 1. **需要重启 DSH 才会生效**(插件是 `link:` 到源码目录的,宿主半边已经加载进内存了)。 2. **PAC 通路还没在真机上端到端验证**(当前 DSH 是管理员权限,hosts 通路可用, `pac: auto` 策略下 PAC 是关闭的)。 3. **Alt-Svc / HTTP3**:本方案不屏蔽(Watt Toolkit 也不屏蔽)。如果实测发现浏览器 确实在反复试 QUIC,可选缓解是切 `pac=on`,或给 Edge 设 `QuicAllowed=0`。 4. **`gist.github.com`**:这台网络上没有找到任何可用上游,默认不接管(浏览器会快速失败)。