diff --git a/dsh-server-docs/04-调整方案/113-覆盖网络-传输方案取舍-开放端口与自研relay.md b/dsh-server-docs/04-调整方案/113-覆盖网络-传输方案取舍-开放端口与自研relay.md new file mode 100644 index 0000000..3a2eeae --- /dev/null +++ b/dsh-server-docs/04-调整方案/113-覆盖网络-传输方案取舍-开放端口与自研relay.md @@ -0,0 +1,600 @@ +# 覆盖网络 · 传输方案取舍:开放端口 vs 自研 relay(2026-09-16) + +> **缘起**(用户原话):「我在 106 腾讯云和服务器上开放端口是否可以解决这个问题,开放端口安全性能否得到保障,现在 47 和 106 的连接方案也是走的 ssh 是临时的方案」 + +--- + +## 0. 结论先行 + +- **开放端口能解决"谁连谁",但不是更优解** —— 它把今天「**worker 只拨出、不开入站**」的形态,换成「每台 worker 都要暴露一个公网入口」⇒ **暴露面从 O(1) 变 O(N)**。 +- **SSH 隧道确实只是"第一个可替换实现",不是终态**。但替代它的正解是 **自研 relay + worker 出向单端口长连接**(**不需要开放任何入站端口**),而不是靠开放端口。 +- 安全性的决定因素不是"开不开端口",而是 **"谁是服务端"**:**worker 永远只做出向连接**(今天已经是这个形状),攻击面最小。 + +--- + +## 1. 现在到底怎么连的(实测事实) + +| 方向 | 事实 | +|---|---| +| 106 → 47 | `ssh -M -N -f -R :127.0.0.1: root@47:32022` —— **106 主动拨出**(只需出向) | +| 47 → 106 | 走 47 的 `127.0.0.1:`(隧道落点),**47 从不主动连 106** | +| 谁开了入站端口 | **只有 47**:`0.0.0.0:32022`(sshd)、`80/443`(nginx)、`888`、`8765`、`58888`。**106 为平台开了 0 个入站口** | + +⇒ **今天的形态已经是"最小暴露"**:**新增一台 worker 完全不需要碰云安全组**。这是"worker 拨出"这个方向带来的核心收益,容易被忽略。 + +--- + +## 2. 「在 106 开放端口」得到什么、失去什么 + +**得到**:47 可直连 106 的 agent(19000),少一跳本地转发,延迟略低;不必维护隧道。 + +**失去(这才是重点)**: +1. **每台 worker 一个公网入口** ⇒ N 台机器 N 套安全组/防火墙配置,接入成本与出错面**随台数线性上升**;跨云(阿里云 47 + 腾讯云 106)还要两端同时配。 +2. **agent 的认证是单密钥共享**(`x-dshs-agent-token`)⇒ 该通道被突破/密钥泄露 = **能指挥那台 worker 起停任意用户实例**。今天这条通道**只能在 47 的 loopback 上被触达**(隧道落点),开放后变公网可达 —— 属**权限扩大**(R5 的反面)。 +3. 47 侧要为每台 worker 维护"我怎么连它"的地址(= `dsh_hosts.endpoint` 今天在做的事),worker 换 IP / 换云就要改控制面数据。 + +--- + +## 3. 若真要开放端口,安全性能保障吗 + +能保障到**"可控"**,但有前提,且**每一项都是新增的运维负担**: + +- 云安全组**按源 IP 白名单**(106 侧只放 47 的公网 IP)—— 前提是 47 有固定公网 IP(目前是); +- 该端口**只跑 agent API**;认证从"共享 token"升级为 **mTLS + 定期轮换**; +- **单端口复用**(不要每实例一个端口)、限速、审计日志、失败即封。 + +⚠️ 即便如此,**仍不如"worker 只拨出"**:白名单一旦写错、或 47 的 IP 变更,就退化成"一个公网可达的 agent 入口"。 +⇒ **可保障,但代价是把安全从「架构保证」降级为「配置保证」。** + +--- + +## 4. 摆脱 SSH 的正解(按代价排序) + +| 方案 | 需新开入站端口? | 说明 | +|---|---|---| +| **① 自研 relay:worker 拨出 + 单端口 TLS 长连接 + 多路复用** | ❌ **不需要** | 复用今天"worker 主动拨出"的形状,只把 sshd 换成自研 relay 进程;relay 侧维护 `(hostId, 实例端口) → 连接/流` 映射表。**这就是原方案 S5 的位置**(S5 原只写"443/TCP 兜底",可与本项合并做——那个"单端口"serves both) | +| ② 中继独立成单元(P4) | ⚠️ 需在 47 新开 **32023** | 只换绑定关系、不换协议;**当前卡在 R5 待授权** | +| ③ WireGuard / TURN 打洞 | ❌(但依赖 UDP 出向) | 异构网络(企业/校园网常封 UDP)不可靠 ⇒ 仍须保留 443 兜底,复杂度高 | +| ④ 每台 worker 开公网端口直连 | ✅ 需要 | 最省事、**安全面最大**,不推荐 | + +--- + +## 5. 与既有决定的关系(不冲突) + +- 与「**覆盖网络按异构设计**(中继 45% / **必须补 443/TCP 兜底**)」一致:那个兜底通道**就是 ① 的单端口**,正好一次做掉。 +- 与「**单机自用也要互联**」一致:**租户维度收窄 ≠ 网络维度收窄** ⇒ "少开端口"是约束,不是可选项。 + +--- + +## 6. 我选了什么(可推翻) + +**维持「worker 只拨出、不开任何入站端口」**;把"摆脱 SSH"的正确落点定为 **① 自研 relay + 单端口出向长连接**(不需要新开任何公网口),而不是在 106 上开端口。 + +**连带影响**:**P4(47 新开 32023)优先级下调** —— 它只是"换绑定"的过渡步,安全性上**不优于今天**(甚至新增暴露面)。 + +**剩下的两选一(这是 P4 那个 R5 门禁的实质)**: +- 选「不开」⇒ 我把 **P4 与 ① 合并**成一条"自研 relay(worker 只拨出)"的路线来做,**全程不新增公网端口**; +- 选「开」⇒ 我按原 P4 执行(32023 + 独立单元 + 非 root 账号),作为过渡。 + +--- + +# 7. 成熟方案调研与选型(2026-09-16 17:2x 追加 · 回答"自研 relay 有没有成熟方案 / SSH 做这个事专家会不会认为不安全") + +## 7.1 先纠正一个前提:**不需要自研** + +"worker 主动拨出、被中央服务反向暴露"这个形状**有成熟件,且被大量生产环境使用**。按适配度排序: + +| 方案 | 形态 | 成熟度(检索实测) | 与本项目的适配 | +|---|---|---|---| +| **frp**(fatedier/frp) | frps 公网 + frpc 拨出;**单连接多路复用**;TCP/UDP | ⭐ **最成熟**:**v0.70.0(2026-07-11)**、**~10.6 万 star**、v0.50 起 **TLS 默认开**、静态 token + OIDC、`allowPorts` 白名单、dashboard | ✅ **首选**。语义几乎一对一:一个 frpc = 一台 worker,`[[proxies]]` = 每个实例端口,取代今天的 `ssh -R` | +| **rathole**(rapiz1/rathole) | 同上,Rust | 活跃;**<500 KiB** 单文件、**Noise_NK** 每服务 token、**热重载**、TCP+UDP;**无 dashboard**,生态远小于 frp | ✅ **备选**(资源极紧 / 想要最小二进制时)。基准自称吞吐 2–5× frp ⚠️ **厂商自测,别当结论** | +| **Headscale + Tailscale DERP** / **Nebula** | 完整 mesh overlay(会合 + 中继 + ACL) | 成熟(Slack 的 Nebula 已在 `瓶颈落地方案` 里被引用过) | ⚠️ **层级不同**:它自带"会合 + 身份 + ACL",会与我们的 Manager/租约/`via` 语义重叠 ⇒ 引入成本高,属**远期** | +| **chisel** | HTTP/WebSocket 隧道 | 成熟,常用于内网穿透 | ⚠️ 优势只在"网络**只**放 80/443";我们用 frp over 443 也能达到,故不必 | +| **Cloudflare Tunnel** | cloudflared 拨出,CF 边缘接 | 非常成熟 | ❌ **数据面交给 CF**,与"自建覆盖网络"目标冲突(且跨境链路受其调度) | +| **WireGuard(hub 模式)** | 各点 `PersistentKeepalive` 拨出到 hub | 成熟 | ⚠️ 需 UDP 出向;异构网络(企业/校园网)常封 ⇒ 仍要 443 兜底,复杂度高 | + +⚠️ **性能数字别照抄**:检索到的 "FRP 920 Mbps / SSH 650 Mbps" 之类来自二手博客,**未经我们实测** ⇒ 只作方向参考("专用 relay 优于 SSH 转发"这个**方向**可信,具体倍数不可信)。 + +## 7.2 再看 SSH:专家会认为不安全吗?——**一半对、一半是误解** + +**是误解的部分**:SSH 反向隧道**是"无公网 IP / 无入站"场景的标准做法之一**,2026 年多份运维指南仍把它列为"Solution 1",前提是**按规矩加固**: +- ✅ 中继上用**专用非特权账号**(不要 root); +- ✅ `sshd_config` 里下 `PermitOpen` 只放必要端口; +- ✅ 对该通道**限速 + 审计**。 + +**说得对的部分**(这些才是真批评,且我们**今天全都中**): +1. **凭据模型**:现在隧道以 **`root@47`** 登录(已加 `restrict,port-forwarding`,拿不到 shell,这点已收窄);但仍是**通用 sshd 上的登录凭据**,审计粒度粗、与运维 SSH 混在一条 `authorized_keys` 里。 +2. **运营耦合**:中继是**宝塔面板也在用的那个 sshd** ⇒ 面板改 SSH 配置 / 重启 sshd 会波及整张覆盖网络。 +3. **静默失败**:我们**已经实测撞到一次**——`-R` 失败时 `tunnel.forward()` 的返回值无人检查 ⇒ 撞号后静默不转发、拨到别人实例。SSH 的 `-O forward` 语义天生不擅长这种"要精确知道成功没有"的编排。 +4. **多中继/负载均衡**:SSH 没有原生"多中继选主/轮询/健康检查"概念;frp/rathole 有。 +5. **审计观感**:*"SSH 一般当命令行用"* 这个看法在评审场合确实常见 —— 它不是技术缺陷,但**是真实存在的沟通/合规成本**。 + +⇒ **结论**:SSH 隧道**不是"不安全",而是"不该长期做数据面"**。它适合当**第一个可替换实现**(已扮演好这个角色),不适合当终态。 + +## 7.3 由此重新拍 P4:**不做 SSH 版中继,直接换成熟 relay** + +**理由**:P4(独立 sshd 单元)想拿到的两样东西 —— ① 与宝塔 sshd 解耦 ② 甩掉 root 凭据 —— **在 frp/rathole 方案里同样拿到**,而且顺带解决"专家观感"和 S5 的 443 兜底。⇒ **P4 与"换 relay"两步合一步,不先做 P4。** + +**并且:换 relay 也**不必**新增公网端口。** +frp/rathole **同样需要一个公网入口**(frps 默认 `bindPort 7000`)。但可以让它**复用 47 已有的 443**: + +``` +worker(frpc) ──TLS/SNI──▶ 47:443 (nginx stream + ssl_preread) + └─ 按 SNI 分流到 127.0.0.1:7000 (frps) + └─ 暴露各实例端口 +``` +⇒ **零新增公网监听口**(R5 不触发),**同时把 S5「443/TCP 兜底」做掉**(既有决定里本来就要补的那条)。这也是为什么"换 relay"比"新开 32023"更划算。 + +## 7.4 修正后的路线(我选了什么 · 可推翻) + +1. **⛔ 不做 P4(SSH 版中继单元)**;**不新开任何公网端口**。 +2. **relay 采用 frp(首选)/ rathole(备选)**,形态 = "worker 拨出、relay 复用 443"。 +3. 工作时序(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式): + - **R1** 47 上装 frps(仅监听 `127.0.0.1:7000`,不碰公网口)+ 证书/SNI;本机与 47 之间先跑通"一条隧道"。 + - **R2** nginx `stream` + `ssl_preread` 把 443 按 SNI 分流给它 ⇒ 从此 **443 就是兜底通道**(S5 达成)。 + - **R3** 106 的 worker 侧把 sshd 隧道换成 frpc(env 切换,双路径并存 ⇒ 零代码回滚)。 + - **R4** 观察一轮后下线 sshd 隧道路径 + 收回 47 上那条 `authorized_keys`。 +4. **回滚**:每步都靠"env 切回 SSH 路径"回退,ssh 路径**在 R4 之前不删**。 + +--- + +# 8. 选型复核:**撤销"首选 frp"**(2026-09-16 17:3x,因用户质疑"项目久 ≠ 最好") + +> 用户原话:「frp 要好好判断只是项目时间比较久,性能和安全性还真不一定是最好」 +> **先认账**:§7.1 我把 frp 排第一,依据是 star 数、发布频率、生态 —— **那是"省心度"排序,不是"安全性/性能"排序**。这是**选型轴用错了**,本节修正。 + +## 8.1 frp 的实际安全面(检索实测,非推测) + +| 项 | 事实 | 对我们的意味 | +|---|---|---| +| **CVE-2026-40910 / GHSA-26gq-p25f-99cp** | **认证机制绕过 + 未授权远程 DoS**,**影响 frp ≥ 0.53.0** | 有在野/可利用描述,且影响**近几年的所有版本** ⇒ 「项目久」反而意味着**被盯得久、漏洞历史长** | +| **dashboard 默认 `admin:admin`、口令明文存配置** | 官方引导必须 `webServer.addr = "127.0.0.1"` | 绑回环是**必须**,不是可选 | +| **`proxyBindAddr` 默认跟 `bindAddr`** | 官方原文:这是"**大多数指南遗漏的配置**";不设则 frp 为代理开的监听器**绑到公网** | 🔴 **致命**:不设它 = 我们"零新增公网口"的目标当场破掉 | +| **服务端默认不强制 TLS** | 需 `transport.tls.force = true` 才拒绝明文控制连接(客户端 v0.50 起默认 TLS) | 又一项"不设就静默降级" | +| **`auth.token` 是单一静态共享令牌,frpc 明文存** | 官方原文:没有它 frps 会接受任何找到 7000 端口的客户端 | ⇒ **一台 worker 失陷 = 可申请任意端口**(`allowPorts` 又是 opt-in 默认关) | +| 7000 / 7500 端口被持续扫描 | 公网 frps 是明确标靶 | 需 nft/安全组 + 非默认端口 | + +⇒ **结论:frp 的"默认姿态不安全"** —— 上面 5 项**每一项都必须手工关/设**,少设一项就可能**静默**破掉我们的安全目标。这正是"老项目"的另一面:**默认值停留在历史约定上,安全靠运维纪律补**。 +(正面:官方文档把正确姿势写得很清楚 —— `proxyBindAddr=127.0.0.1` + nginx 前置 443 + 非特权用户 + systemd 加固 `NoNewPrivileges/ProtectSystem/ProtectHome`;且修得快,**v0.71.0 = 2026-08-14**,约一月一版。) + +## 8.2 按**我们的**判据重排(这才是该用的轴) + +**判据(按重要性)**:① 认证/授权模型(身份 > 每服务密钥 > 共享 token)② 默认拒绝还是默认放行 ③ 能否做到零新增入站 ④ 单 worker 失陷的爆炸半径 ⑤ 可观测性 ⑥ 生态与排障。 + +| 方案 | ① 认证模型 | ② 默认姿态 | ③ 零入站 | ④ 爆炸半径 | ⑤ 可观测 | ⑥ 生态 | +|---|---|---|---|---|---|---| +| **OpenZiti** | ✅ **证书身份 + 服务策略**(最强) | ✅ 默认拒绝 | ✅ | ✅ 最小(逐服务授权) | 中 | 中(CNCF) | +| **Nebula** | ✅ **CA 签发身份**(Slack 在生产用) | ✅ 默认拒绝 | ✅(lighthouse 拨出) | ✅ 小 | 中 | 中 | +| **rathole** | ✅ **Noise_NK 双向认证 + 每服务 token 必填** | 🟡 无 token 不通 | ✅ | 🟡 中 | ❌ 无 dashboard | ❌ 小(单维护者) | +| **frp** | 🔴 **单一静态 token**(明文存) | 🔴 **默认放行**(5 项需手设) | 🟡 需 `proxyBindAddr` 才成立 | 🔴 **大**(可申请任意端口) | ✅ dashboard+metrics | ✅ 最好 | +| **konnectivity**(k8s apiserver-network-proxy) | ✅ 客户端证书 | ✅ | ✅ | ✅ 小 | 中 | 🟡(K8s 专用) | + +⇒ **在我们最在意的 ①②④ 三条上,frp 都是最差一档**;它只在 ⑤⑥ 领先。 + +## 8.3 性能:**这条轴我们根本不该用于选型** + +- 覆盖网络的**第一瓶颈是 presence,不是带宽**(既有推演结论);我们的量级是"**每 worker 几十个 HTTP 会话**",不是 1 万并发、不是线速。 +- ⇒ **拿 frp/rathole 的吞吐 benchmark 选型 = 优化错误的轴**(且那些数字多为厂商/二手自测)。 +- 真要测,该测的是**链路本身**(本机 ↔ 47 / 106 的 **RTT / jitter / 带宽**)—— 那**正是接续包里"未完成项 3"**。任何 relay 的性能上限由链路决定,不由实现决定。 + +## 8.4 修正后的路线(取代 §7.4 的第 2、3 条) + +1. **不锁定 frp**;**不在这轮引入任何第三方 relay**。 +2. **先做 R0(判据 + 画像)**: + - R0-a 把上面 6 条判据写成**一张打分表**(放本文件,作为后续任何 relay 决策的判据); + - R0-b **测链路画像**(本机↔47↔106 的 RTT / jitter / 带宽)—— 它同时是"未完成项 3",**一次做掉两件事**。 +3. **R1 起再选实现**:按 R0 的打分表定;**优先考虑身份型(OpenZiti / Nebula)**,frp 只在"要立刻省心、且 8.1 那 5 项配置能一项不落地全部做完"时才选。 +4. **🔑 关键(也是可以不急的根本原因)**:**P1–P3 已经把接口抽出来了** —— `Reachability.via` + `Rendezvous` 注册表 ⇒ **换实现是 env 级切换、零代码回滚**。**可选性已经买好了,选型错了不致命**,所以**不必现在一次选对,更不该为了"选对"付一次引入成本**。 +5. ⚠️ **一个必须承认的权衡**:把 relay 换成第三方,是**用一个我们不完全掌控的攻击面(frp 的 CVE + 默认值)去替换一个已收窄、且有系统级补丁渠道的面(sshd + `restrict,port-forwarding`)**。**这不是无条件升级** ⇒ 没有明确的痛点(比如真要上多中继)之前,**维持现状也是合理选项**。 + + + +## §9 · R0-b 链路画像实测(2026-09-16 18:4x) + +> 目的:给 relay 选型提供**链路事实**(性能不作选型轴,但"能不能直连 / 要不要打洞"必须实测)。 +> 全程**只读**:未装软件、未开端口、未改配置;测量脚本 `/tmp/r0b.sh`,一次跑完。 + +### 9.1 原始实测(命令原文 + 数字) + +| 路径 | 样本 / 丢包 | RTT avg | jitter(stddev) | TCP 吞吐 | +|---|---|---|---|---| +| 本机 → 47 `47.77.182.89` | 14/20 · **30%** | 189.6 ms | 0.49 ms | **12.7 Mbit/s**(1.6 MiB/s) | +| 本机 → 106 `106.54.21.172` | 20/20 · 0% | 34.8 ms | 0.54 ms | 未取到(无 106 凭据) | +| 47 → 106 | 18/20 · **10%** | 149.8 ms(mdev 0.414) | 0.41 ms | **未取到**(ssh `Permission denied`,rc=255) | + +```bash +ping -n 20 47.77.182.89 # Windows ping;RTT 由 '=NNms' 提取,stddev 自算 +ping -n 20 106.54.21.172 +dd if=/dev/zero bs=1M count=50 | ssh root@47.77.182.89 'cat >/dev/null' # 实测 50MiB / 31403 ms +ssh root@47.77.182.89 'ping -c 20 106.54.21.172' # 20 transmitted, 18 received, 10% loss +curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://47.77.182.89:19100/ +curl -s -m 6 -o /dev/null -w '%{http_code}|connect=%{time_connect}s' http://106.54.21.172:19000/ +``` + +### 9.2 公网可达性 / 入站面(判"打洞"可行性) + +- 本机 → `47:19100`:`curl_rc=28`(连接超时,`connect=0.000000s`)⇒ **不可直连** +- 本机 → `106:19000`:`curl_rc=28` ⇒ **不可直连** +- 47 实际监听(`ss -lntp`):`127.0.0.1:3080`、`127.0.0.1:15432`、`127.0.0.1:19000`、`127.0.0.1:19100`,仅 `0.0.0.0:22` +- 106 监听面:**未取到**(本机 ssh 到 106 报 `Permission denied (publickey,gssapi-keyex,gssapi-with-mic)`) + +### 9.3 三条结论 + +1. **零入站 = 实测成立**:47 侧 agent / 控制面端口**全部绑回环**,唯一公网入站是 `22(sshd)` ⇒ 「打洞 / 直连」路径**实测不可行**,relay 只能靠**唯一拨出长连接**(与本线既定设计一致)。 +2. **47 链路质量是本轮最大异常**:30% / 10% 丢包,而 RTT 抖动仅 0.5 ms 级 ⇒ 属**丢包型**而非**拥塞型**恶化;吞吐 12.7 Mbit/s 已含丢包拖累 ⇒ 选型时**丢包恢复能力(Noise / QUIC 级)应重于带宽**。 +3. **跨云一跳代价**:47 ↔ 106 实测 149.8 ms / 10% 丢包 ⇒ 多跳中继必须把这一跳计入「45% 设计 / 55% 余量」的**余量侧**。 + +### 9.4 未完成取证(诚实标注,非"已解决") + +- 106 侧监听面 + 本机↔106 吞吐:**缺 106 的 ssh 凭据**(本机与 47 两处均 `Permission denied (publickey)`)⇒ 需先确认 `manager-ssh` 通道实际用的是哪个账号 / 密钥;**本轮未改任何配置**。 +- 吞吐只测了"经 SSH 隧道的加密吞吐",非裸 TCP ⇒ 结论按**下界**理解。 +- 丢包为 ICMP 采样,若对端有 ICMP 限速则可能高估;但 47 的 SSH 吞吐(1.6 MiB/s)与之相互印证。 + +--- + +# 10. R0-a 判据打分表 + **relay 定案**(2026-09-16 18:5x) + +> 回答用户提问:「继续执行 让方案落地,**自建 relay 的方案定了吗**」 +> **直接答案**:此前**没有定**(§8.4 明确写"不锁定 frp、本轮不引入第三方、先做 R0");**现在定了 —— 自研 relay,传输走 WebSocket over 现有 nginx 443,零新增公网口**。理由见 10.2,缺点见 10.6。 + +## 10.1 R0-a:六条判据·可打分版(**后续任何 relay 决策都用这张表**) + +权重(= 8.2 的重要性排序):① 认证/授权模型 **3**|② 默认拒绝 vs 默认放行 **3**|③ 能否零新增入站 **2**|④ 单 worker 失陷爆炸半径 **3**|⑤ 可观测 **1**|⑥ 生态/排障 **2**(总权重 14,满分 70)。评分 1–5(5 = 最好)。 + +| 方案 | ① 认证(×3) | ② 默认姿态(×3) | ③ 零入站(×2) | ④ 爆炸半径(×3) | ⑤ 可观测(×1) | ⑥ 生态(×2) | **加权分/70** | +|---|---|---|---|---|---|---|---| +| **自研 relay** | 5 | 5 | 5 | 5 | 2 | 1 | **59(84%)** | +| **OpenZiti** | 5 | 5 | 5 | 5 | 3 | 3 | **64(91%)** | +| **Nebula** | 5 | 5 | 4 | 4 | 3 | 3 | **59(84%)** | +| **rathole** | 4 | 3 | 5 | 3 | 1 | 1 | 43(61%) | +| **chisel** | 2 | 3 | 5 | 2 | 1 | 2 | 34(49%) | +| **frp** | 1 | 1 | 3 | 1 | 5 | 5 | 30(43%) | + +⚠️ **纯看这张表会选 OpenZiti —— 但表里缺了「架构契合度」这条否决项**(见 10.2 第 3 点)。**分数不是终点,否决项优先。** + +## 10.2 定案:**自研 relay**(不是"造轮子偏好",是约束共同推出的唯一解) + +1. **入口约束把成熟件全部逼到墙角**(10.3 已实测):我们唯一愿意接受的入口是 **WebSocket over 现有 443**(零新增监听口、且天然满足 S5「443/TCP 兜底」、对"只放 443 的异构网络"最鲁棒)。 + - **rathole 不支持 WebSocket** ⇒ 只能退回 `nginx stream + ssl_preread`,而 443 现由 **http 层**监听 ⇒ 必须把 443 从 http 挪进 stream = **动门户主入口**(结构级改动)。 + - **OpenZiti / Nebula** 是完整 overlay(自带会合 + 身份 + ACL),会**与已完成的 `Rendezvous` / `via` 语义重叠** ⇒ §7.1 已判定"层级不对、属远期"。**这是否决项,不是扣分项。** +2. **认证模型 + 静默失败**:chisel/frp 走 WS 但都是**共享 auth / 静态 token**(①垫底);而 SSH 的病根之一正是**静默失败**(`-R` 撞号时无人检查返回值)⇒ 我们需要"**注册必须被 ACK / 端口占用必须报错**"这种精确语义,成熟件里没有现成的。 +3. ⇒ **同时满足「WS over 443」+「每服务密钥(非共享 token)」+「精确 ACK」的成熟件不存在** ⇒ 自研。 +4. **代价可控的关键**:真正的功能面很小 —— 一条出向长连接 + `(hostId, port) → conn` 映射表 + 字节双向泵 + 心跳/重连/ACK。**不自造密码学**(复用 Node 内建 `tls` / `net` / `crypto`),协议帧 = 长度前缀 + JSON 头。 +5. **可选性已买好**(§8.4 第 4 条):`Reachability.via` + `Rendezvous` 已抽 ⇒ 自研 relay = **新增一个 `via='relay'` 实现**,零删除、env 级回滚。**选错不致命,所以现在定案的风险是可控的。** + +## 10.3 入口取证(本轮只读实测 —— 决定"零新增公网口"能不能成立) + +| 项 | 实测 | 对落地的影响 | +|---|---|---| +| nginx | `nginx/1.28.3`,含 `--with-stream` + `--with-stream_ssl_preread_module` | 方案 `stream + ssl_preread` **技术可行**(但见 10.2 第 1 点:要动 443) | +| stream 段 | 已存在,且 `include /www/server/panel/vhost/nginx/tcp/*.conf` | 有现成落点(宝塔 TCP 转发目录) | +| 443 现状 | `listen 443 ssl default_server;`(**http 层**,nginx pid 521250/521249/85711) | 走 stream 分流必须把 443 挪走 ⇒ **动门户** ⇒ 故选 WS 方案绕过 | +| **本机防火墙** | `nft: chain INPUT policy accept` + `iptables -P INPUT ACCEPT` | 🔴 **47 没有本机防火墙**,暴露面**全靠云安全组** ⇒ **任何绑 `0.0.0.0` 的新监听会立刻公网可达** ⇒ 「零新增公网口」是**硬约束**,不是洁癖 | +| 公网监听面 | `22`+`32022`(**同一 sshd 进程 pid 1017**);`80/443/888`(nginx);`58888`(BT-Panel);`8765`(python3) | 32022 = 覆盖网络专用口(同为 sshd) | +| 回环面 | `127.0.0.1:19000`(**sshd**)、`19100`(w-47 agent)、`20000`(w-47 实例)、`3080`(门户)、`15432`(PG) | 见 10.4 勘误 | +| 出向 | `https://github.com → http=200 t=0.084s`,`gh-proxy/ghfast → 200` | 取第三方件无障碍(**但本定案不取**) | +| 已装 relay | `rathole/frpc/frps/chisel/ziti/wg` 全部 `(none)` | 干净起点 | +| 现有隧道进程 | 47 上 `ps \| grep ssh` **为空** | 隧道由 **106 侧**发起(106 拨出) | + +## 10.4 一处勘误(推翻 §9.4 的"阻塞"判断) + +`127.0.0.1:19000`(及 `[::1]:19000`)的属主是 **`sshd`(pid 720417)**,**不是 dsh agent** ⇒ 它是 **106 侧 `ssh -R` 的落点**。 +⇒ `via='manager-ssh'` 的语义 = "**106 主动拨出、经 Manager 的 sshd 落点可达**",**不是** "Manager 主动连 106"。 +⇒ **§9.4 记的"缺 106 凭据 = 阻塞"应修正为"不是阻塞"**:设计上 47 就不需要能 ssh 到 106(实测 `47→106 ssh Permission denied` 属**正常**)。 +⇒ 106 侧操作的通道 = **宝塔面板**(本机已挂 106 的宝塔 MCP:`mcp__baota-mcp-106.54.21.172`),而不是 ssh。 + +## 10.5 R1–R4 落地步骤(每步可独立验收、可独立回滚,延续 P1–P3 的交付方式) + +| 步 | 动作 | 验收(可核对) | 回滚 | +|---|---|---|---| +| **R1** | 47 上 relay 服务端只监听 **`127.0.0.1:20080`**(**仅回环**,不新增公网口);本机 ↔ 47 先用**临时 `ssh -L`** 引出来跑通一条隧道(**不碰 443、不改 nginx**) | `curl -s localhost:<映射口>` 取到目标内容;relay `/status` 显示 1 条注册 + 端口已占用 | 停进程即回零 | +| **R2** | nginx 某站点 443 加 `location /dshs-relay/`(`proxy_http_version 1.1` + `Upgrade`/`Connection` 头 → `127.0.0.1:20080`);先 `nginx -t`,**备份站点 conf**,再 reload | `wss://<域名>/dshs-relay/` 握手 **HTTP 101**;`ss -lntp` **无新增 0.0.0.0 监听** | 删 location + reload(秒级) | +| **R3** | worker 侧 relay client 拨出(106 经宝塔部署);`dsh_hosts.via='relay'`(env 级);**SSH 路径并存不删** | `via='relay'` 的 worker 实例可正常被门户访问;`/status` 显示该 worker 心跳 | `via` 切回 `manager-ssh`/`local`(零代码) | +| **R4** | 观察一轮 → 下线 sshd 隧道 + 收 47 上那条 `authorized_keys`;**回收 32022** | sshd 隧道进程消失、实例仍可用;32022 从 `0.0.0.0` 监听面消失(**净减一个暴露口**) | 重建隧道(回 R3 前状态) | + +**R2 是唯一动门户的一步**(其余全为新增/回环),且 `nginx -t` + 备份 + reload 三重保护;**R2 之前 SSH 路径全程在跑**。 + +## 10.6 我选了什么(可推翻)+ 诚实的缺点 + +**选了**:自研 relay;**WS over 443**;**每 worker 一密钥**(非共享 token);注册/占用**必须 ACK**;先只服务 w-47 / w-106 两个 worker;**R4 之前 SSH 路径不删**。 + +**缺点(必须承认,不是"没有缺点")**: +1. **无第三方审计**:自研数据面没有别人的眼睛看过 ⇒ 缓解 = 协议极简、复用 Node 内建密码学、**不开新端口**(暴露面不增)、单点可 `kill` 回退。 +2. **长期自维护**:bug 自己修、没有上游 → 缓解 = 代码量刻意压小(≤500 行目标)、`via` 可秒切回 SSH。 +3. **可观测要自己补**:无现成 dashboard ⇒ 缓解 = 先做 `/status`(注册数/心跳/字节数)+ journald。 +4. **相对成熟件的功能天花板低**:多中继选主、UDP、热重载都要自己加 ⇒ 缓解 = 本阶段不需要(第一瓶颈是 presence,不是带宽/Multi-relay)。 + +## 10.7 R1 前置取证(本轮实测,**执行会话直接照用,不必重新探索**) + +**代码仓 / 接线点** +- 代码仓 = `D:/github/dsh_shenxian`(`src/` + `dsh-server-docs/` 同一仓);**relay 代码落 `src/net/relay/`**(与 `src/net/reachability.ts` / `rendezvous.ts` 同层)。 +- 既有 `src/net/` 仅 211 行:`reachability.ts`(99) + `rendezvous.ts`(112)。**`via` 词表 = `src/net/reachability.ts`**(`VIA_LOCAL='local'` / `VIA_MANAGER_SSH='manager-ssh'`)。 +- **接线的唯一一处**:`src/web/server.ts:270` → `new RendezvousRegistry([ LocalRendezvous, ManagerSshRendezvous ])`。R1 新增 `RelayRendezvous` ⇒ 只在此数组加一项 + 注册 `id='relay:'`。 +- ✅ **代码里已预留 `relay:` 语义**:`rendezvous.ts:64`「S4 把"接收 worker 反拨"搬进独立单元 `dshs-relay.service` 后,本类应被 `relay:` 实现替换」;`src/web/routes/admin.ts:188` 注释「未来的 `relay:`」。⇒ **本定案与既有架构同构,不需要发明任何命名。** + +**依赖与运行(实测,决定 R1 怎么写)** +| 项 | 实测 | 结论 | +|---|---|---| +| Node | `v22.22.2`,`"type":"module"`(ESM) | 与全仓一致,relay 用 ESM | +| 依赖 | 仅 `@fastify/rate-limit, @fastify/static, better-sqlite3, fastify, pg` —— **无 `ws`** | **R1 需二选一**(见下) | +| 内建 `WebSocket` | `typeof globalThis.WebSocket === 'function'`(**仅客户端**,服务端无) | **relay client 零依赖**;server 端需 WS 实现 | +| `.ts` 直跑 | 自检**未通过**(错误栈未展开)⇒ 不赌 type stripping | 走既有链路 `npm run build` → `node lib/…` | +| 门禁 | `npm run verify` 含 `test/reachability.test.mjs` + `scripts/check-layering.mjs`(**分层 ①入口→②领域→③能力→④基础**) | **R1 必须让 verify 全绿**;`src/net/*` 属 ③能力层,⛔ relay 不得反向依赖 ①② | + +**R1 的依赖二选一(我的倾向:②)** +1. 给主 `package.json` 加 `ws` —— 优点:直接用成熟帧实现;缺点:**给整个平台新增一个生产依赖**(与"relay 只是可选单元"的定位不符)。 +2. **服务端手写最小 WS 帧编解码**(~120 行:握手 SHA1+GUID、二进制帧、ping/pong/close,不做分片与 permessage-deflate)—— 优点:**零新增依赖**、relay 可独立成单元、协议面小到可审;缺点:自写帧层有 bug 风险 ⇒ 必须配 `test/` 断言(握手 + 回环字节数一致)。 + +**入口(R2)前置条件 —— ✅ 已全部具备,不必改 nginx 结构** +- `map $http_upgrade $connection_upgrade` **已在** `nginx.conf:321-322`。 +- `proxy_http_version 1.1` + `Upgrade` / `Connection $connection_upgrade` 模式**已在 5 处**(`nginx -T` 行 378-380 / 403-405 / 499-501 / 553-555 / 568…)⇒ 加 location 是**照抄既有模式**,不是新写法。 +- 443 `default_server` 在 `/www/server/panel/vhost/nginx/0.catchall-443.conf:4`;门户站点 = `alotbuy.com.conf` / `dsh.alotbuy.com.conf`;另有 `0.websocket.conf` 已存在。 +- 备份惯例已成熟:`dsh.alotbuy.com.conf.bak-20260910-2300-pre-buffering` 之类 ⇒ R2 备份按同格式命名。 +- `/www/server/panel/vhost/nginx/tcp/` **为空**(宝塔 TCP 转发目录已 include 但无内容)⇒ 走 stream 的备用落点可用。 + +--- + +# 11. R1 落地与验收(2026-09-16 19:3x)—— 自研 relay 最小闭环 **已跑通(本机 + 跨机)** + +## 11.1 交付物 + +**新增** `src/net/relay/`(代码仓 `D:/github/dsh_shenxian`,**未 commit**): + +| 文件 | 职责 | 关键点 | +|---|---|---| +| `wire.ts` | 最小 WebSocket **服务端**帧(RFC 6455 子集)+ mux 帧 | 手写因为**无 `ws` 依赖**且 Node 内建只有客户端;握手 SHA-1 走 `node:crypto`(**不自造密码学**) | +| `server.ts` | relay 服务端(只绑回环) | 认证 / 端点 / 多路复用 / 背压 / `/status` | +| `client.ts` | worker 侧拨出端 | 内建 `WebSocket`;**白名单二次校验**;指数退避 + 抖动 | +| `keys.ts` | 密钥装载 | 强制 **64 位 hex**,短密钥直接拒 | +| `rendezvous.ts` | `RelayRendezvous`(`via='relay'`) | 独有增益 = **实时在线态**(心跳驱动,非 DB 快照) | +| `main.ts` | 单元入口(`--client` 可切客户端) | R1 阶段**不读 `config.ts`**,只认 `DSHS_RELAY_*` / argv(避免与其它会话的改动冲突) | +| `index.ts` | barrel | — | + +**改动**(小到可以逐字核对): +- `src/net/reachability.ts`:**只加** `export const VIA_RELAY = 'relay'`(`via` 词表的单一来源仍在原处)。 +- `package.json`:`verify` / `test` 的测试列表加 `test/relay.test.mjs`。 +- `test/relay.test.mjs`(新增):7 项,**真起服务、真握手、真泵字节**(不 mock 传输层)。 + +⇒ **既有 `.ts` 逻辑文件改动 = 0**(relay 全部落在新目录)⇒ 回滚 = 整目录丢弃。 + +## 11.2 本机验收(可复现命令 + 实测) + +```bash +npm run build # → 0 错误 +node scripts/check-layering.mjs # → 现存违规 5 条(全在基线内);✅ 无新增违规(relay 落层③能力层) +node --test test/relay.test.mjs test/reachability.test.mjs # → tests 17 / pass 17 / fail 0 +``` + +| 用例 | 断言 | +|---|---| +| T1 | mux 编解码往返(含 `streamId=0xffffffff` 边界与空负载);<5 字节判协议错误 | +| T2 | 密钥只接受 64 位 hex,`deadbeef` 直接拒 | +| **T3** | **端到端**:Manager 连回环口 ⇒ 字节真过 ⇒ 回显一致;**并发 3 条流同时工作**(多路复用真在复用);`authFailed=0 / dropped=0` | +| T4 | 错密钥 ⇒ 不 `up` + `authFailed≥1`(**不静默**) | +| T5 | **同 nonce 二次注册 ⇒ 重放被拒**(第二条连接拿不到 `HELLO_ACK`) | +| T6 | 声明实例区间外端口 ⇒ 拒绝注册 + **不留回环监听** | +| T7 | 未注册端口**不存在**回环监听(默认拒绝,不是默认放行) | + +## 11.3 跨机验收(47 ⇄ 本机)—— 命令原文 + 实测输出 + +拓扑:**47 = relay 服务端(只绑 `127.0.0.1:20080`)**;**本机 = worker 侧 client(经临时 `ssh -L` 拨出)**;验证 = **在 47 上 curl relay 分配的回环口 ⇒ 必须打到本机端口**。 + +| 步 | 命令(原文) | 实测输出(原文摘录) | +|---|---|---| +| 部署 | `scp -r lib/net/relay root@47:/tmp/dshs-relay-r1/net/` + `printf '{"type":"module"}' > package.json` | 远端 7 个 `.js`;`/opt/dshs/lib` 下**无** relay 目录 ⇒ **零覆盖** | +| 起服务 | `nohup node net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=19800..19999`;`LISTEN 127.0.0.1:20080 users:(("node",pid=724533))` | +| 引出来 | `ssh -f -N -L 20080:127.0.0.1:20080 root@47` | 经隧道读到远端 `/status` ✅ | +| 拨出 | 本机 `--client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --ports 19876` | `[relay-client] registered host=local-r1 session=3cbd8fc7425ca326 accepted=[19876]` | +| 服务端视角 | 47 `curl 127.0.0.1:20080/status` | `online: ["local-r1(session=3cbd8fc7425ca326 ports=19876 …)"]`;`endpoints: [{port:19876, localPort:41811, online:true}]`;`authed=1 / authFailed=0` | +| **关键验证** | 47 `curl 127.0.0.1:41811/hello-from-47`(+ `/second-call` + `/third`) | **`echo:/hello-from-47\|served-by=User-2026QYRQXO\|at=127.0.0.1:19876`**(三次全部命中;`served-by` = **本机主机名** ⇒ 确实到了本机) | +| **暴露面** | 47 `ss -lntp \| grep 20080`;本机 `curl http://47.77.182.89:20080/status` | 只 `127.0.0.1:20080`;公网 **`curl_rc=000` 不可达** | +| 回收 | kill client/echo/隧道 + 远端 kill 后 `rm -rf /tmp/dshs-relay-r1` | 20080 已释放、无残留进程、临时目录已清 | + +## 11.4 三条结论 + +1. **数据面闭环成立,且零新增公网口**:worker 只拨出、relay 只绑回环、Manager 连回环口 ⇒ `Reachability.address` 与 sshd 版**同形**,调用方零改动。 +2. **安全姿态实测有效**(不是纸面):越界端口被拒(本轮实测触发过:`19876` 不在 `20000..20999` 时被正确拒绝)、nonce 重放被拒、错密钥被拒、公网不可达、**每 worker 一密钥**(非共享 token)。 +3. **R2 是唯一动门户的一步**(nginx 443 加一个 `location /dshs-relay`);R2 之前 SSH 路径全程在跑,且 R1 的部署方式**不碰 `/opt/dshs/lib`**。 + +## 11.5 本轮踩到的三个坑(**下一棒直接照用,别再踩**) + +1. 🔴 **远端 `pkill -f ""` 会杀掉自己**:只要 pattern 串出现在 ssh 自身命令行里(例如 `relay/main.js`),`pkill -f` 就匹配到那个 `bash -c` 进程 ⇒ **自杀**,后续命令全不执行(本轮因此**空跑两次**,第二次即使用 `[r]elay` 字符类也无效,因为**启动命令里也含该字面量**)⇒ ✅ **用 pidfile(`echo $! > server.pid`)**,不要用 `pkill -f`。 +2. 🔴 **`/tmp` 下跑 ESM 产物必须先放 `package.json {"type":"module"}`**:否则 node 按 CJS 解析 ⇒ 启动即 `SyntaxError`,而日志被上面那个坑挡住 ⇒ 表现为"服务起不来但没有任何报错"。 +3. ⚠️ **端口必须落在 `--base/--span` 区间内**:client 声明区间外端口会被服务端拒绝 —— 这是**爆炸半径校验在正常工作**,不是 bug(本轮实测触发)。 + + +--- + +# 12. 网络模块韧性 —— 节点启停 / 网络变化 / 网络中断 / 网络异常 / 时钟漂移(R1.5,2026-09-16 20:xx) + +> 回答用户提问:「**网络模块都要考虑 服务器等公网 IP 节点启停,网络变化,网络中断,网络异常,如何重连和恢复**」 +> 结论先说:**五类场景各有对应的机制与可观测字段,且都能被实测断言**(单测 15/15 + 47 真机验收全绿)。 +> **不做**做不到的事:断链必然终止在途流,**不假装**能流级恢复(见 12.3)。 + +## 12.1 连接状态机(**显式七态**:布尔说不出"正在握手"还是"正在退避") + +```text + idle ──start()──▶ connecting ──ws open──▶ handshaking ──HELLO_ACK──▶ up + ▲ │ │ │ + │ └── 任一步失败 / 超时 ────┴─────────────────────┘ + │ ▼ + └──stop()──▶ stopped ◀──stop()── { backoff | queued } + │ (延迟到点 / 地址变化 / 对端优雅告别 / 位子空出) + └──────────────────────────────▶ connecting +``` +`queued` 与 `backoff` **分开**是刻意的:前者是"没位子"(不是故障,不该按故障退避),后者是"链路有问题"。 + +## 12.2 五类场景 × 处置 × 恢复时间(**数字是实测/默认值,都可调**) + +| 场景 | 现象 | 处置 | 恢复时间(默认) | 可观测字段 | +|---|---|---|---|---| +| **节点启停**(计划内) | 对端发 `BYE` / `close 1001` | **不消耗退避**,进入**快速重试时窗**(`gracefulBurstMs` 默认 15s,间隔 `gracefulRetryMs` 300ms);同时 `BYE` 让服务端**秒级**标离线(不等 45s 心跳) | **窗口内立即恢复**(实测 <2s,含 400ms 重启) | `restarts` / `state=backoff`+短 `nextRetryMs` | +| **节点启停**(崩溃,无告辞) | 连接突然断 | 指数退避 `1s→2s→4s…` 封顶 `reconnectMaxMs`(30s)**±25% 抖动**,**永不放弃** | ≤30s,典型 ≤2s | `attempts` / `nextRetryMs` | +| **网络变化**(IP 变 / 网卡上下) | 本机地址快照变化 | 巡检(`netWatchMs` 5s)发现即**取消剩余退避、立即重拨**(旧退避的前提已失效) | 立即 | `networkChanges` | +| **网络中断**(长时间断网) | 连不上 / 连上无帧 | 同退避序列;**不设"重试 N 次后沉默"** | 恢复联网后 ≤30s | `attempts` | +| **网络异常**(半开 / 静默黑洞) | TCP 不报错但再无数据 | **半开巡检**:`2.5×hbSec`(默认 37.5s)内**没有任何帧** ⇒ 主动断开重连(不等 OS 的 TCP 超时,那要几百秒);服务端侧同样 45s idle 判死 | ≤37.5s 判死 + 立即重连 | `lastFrameAgeMs` | +| **满载**(位子不够) | 服务端回 `at-capacity` | **排队**(`queued` 态),按服务端 `retryAfterMs` 复盘;位子一空即注册 | 按 `retryAfterMs`(默认 5s) | `queueWaits` / `queuedMs` | +| **时钟漂移** | `HELLO` 被拒 `clock-skew` | 用拒绝帧里的 `serverTime` **本地校正**后立即重试(含 `HELLO_ACK` 也回传 `serverTime`,首连即可对齐) | 1 次握手(~300ms) | `clockSkewMs` | + +**为什么时钟漂移必须专门处理**:`HELLO` 的 MAC 里含时间戳、窗口 ±60s ⇒ 一台时钟漂了 10 分钟的机器 +**永远无法注册**(每次都 `bad-mac`/`clock-skew`),且现象是"连上又被踢"的无限循环 —— 这是 Replay 防护的必然代价,必须用"把服务端时间告诉它"来还债。实测 T10 覆盖。 + +## 12.3 恢复语义分层(**明确哪层做、哪层不做**) + +| 层 | 谁负责 | 恢复方式 | +|---|---|---| +| ① **连接恢复** | relay client | 重连(本节的退避 / 时窗 / 半开巡检) | +| ② **注册恢复** | relay client | 重连成功后**自动重新注册**(`ports` 声明不变,服务端重建回环监听) | +| ③ **路由恢复** | Manager / `RelayRendezvous` | `online()` 实时判在线、`localPortOf()` **不猜**:离线一律 `undefined`,让上层回退别的实现,而不是往死地址上打 | +| ④ **流级恢复** | **不做** | 断链**必然**终止在途流(多路复用帧没有重放日志)。**不缝合半开流**:流级重试交给上层(HTTP 幂等请求 / 实例侧自身重连)。**假装能做 = 制造"看起来恢复了其实数据烂了"** | + +## 12.4 实测证据(可核对) + +**单测**(`node --test test/relay.test.mjs`,15/15 通过;全量 `npm run verify` = 80 tests / 79 pass / 0 fail,1 skip): + +| 用例 | 断言 | +|---|---| +| T8 | 优雅停机(`BYE`+`close 1001`)⇒ 对端**立刻**进短间隔时窗(`nextRetryMs ≤ 1000`,不退避到 60s)⇒ 重启窗口内**自动恢复**(`reconnect #1`、新实例 `isOnline`) | +| T9 | 客户端优雅停机 ⇒ 服务端**1.5s 内**标离线(心跳超时被设为 60s ⇒ 这只可能来自 `BYE`),且不再给出回环口 | +| T10 | 注入 +600s 时钟漂移 ⇒ 首次被拒 → **自愈**后注册成功;`clockSkewMs` 反映真实漂移 | +| T11 | 半开(握手成功但**永不回帧**)⇒ 无帧超阈值即主动重连(`lastError` 含 `half-open`),且**确实重拨** | +| T12 | 网络变化(快照变化)⇒ **取消剩余 60s 退避、立即重拨** | +| T13 | 容量准入:`maxHosts=1` 时第二个 host 收到 `HELLO_ERR{reason:'at-capacity', retryable:true, retryAfterMs, capacity}`、**不留回环监听**;**已在册 host 重连仍被接受** | +| T14 | 客户端满载 ⇒ 进 `queued`、按 `retryAfterMs` 重试、`attempts` 不累计;位子空出即注册成功 | +| T15 | 选点判据:满载是**唯一硬门**;速度 + 负载打分;负载能压过速度;手动指定优先且**不被静默改选**;近期失败降权 | + +**47 真机验收**(`/tmp/dshs-r15`,**不碰 `/opt/dshs`**;命令原文与输出摘录): + +```bash +# 部署(只传 lib/net/relay 产物)+ 起 relay(--max-hosts 1,只绑回环) +node lib/net/relay/main.js --port 20080 --keys-file keys.json --base 19800 --span 200 --max-hosts 1 +# 两个 worker 拨出(同机两个进程 ⇒ 等价于两台机器,都是"只拨出") +node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r1 --keys-file keys.json --ports 19876 +node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay --host local-r2 --keys-file keys.json --ports 19877 +curl -s http://127.0.0.1:20080/status +# 重启:kill $(cat server.pid) → 1s → 同端口重起 → 观察 A +``` +实测输出(摘录): +```text +[relay-client] registered host=local-r1 session=873ca9a30e3bdb14 accepted=[19876] clockSkew=6ms +[relay-client] down (at-capacity (queued #1, waited 0ms)); attempt #0 [queued], retry in 5000ms +"capacity":{"max":1,"used":1,"free":0} +"online":["local-r1(session=873ca9a30e3bdb14ports=19876streams=0hbAge=4886msin=0Bout=0B)"] +-- 重启 -- + 旧进程是否已退出: 已退出 端口释放: 0 + server2 首行: [relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) ... + [relay-client] peer BYE: server restarting ⇒ fast reconnect + [relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms], retry in 300ms + [relay-client] registered host=local-r1 session=5071fa3f8c2d5761 accepted=[19876] clockSkew=2ms (reconnect #1) + 新实例 online: "online":["local-r1(session=5071fa3f8c2d5761...)"] + 残留回环监听: 0 +``` + +## 12.5 四个真机硬结论(**都是本次实测踩出来的,下一棒别再踩**) + +1. 🔴 **重试定时器绝对不能 `unref()`**:断链后 WebSocket 句柄已消失,若连"重试计划"也是 unref 的,事件循环就空了 ⇒ **进程静默退出**(节点"人间蒸发":不重连、不报错、日志停在最后一行,`/status` 里再也等不到它)。**本文件其余定时器 unref 是对的,唯独重试定时器不行。**(单测发现不了 —— 只有在真机上才暴露,这也是"本机通过 ≠ 交付"的又一实证) +2. 🔴 **停机必须"可控"**:`stop()` 里若只 `close()`,空闲 keep-alive 连接会把进程挂住 ⇒ 旧进程迟迟不退 ⇒ 新进程 `EADDRINUSE` ⇒ **客户端只能一直撞那个正在 `draining` 的旧实例**(表现为"重启后再也连不上")。修法 = `http.closeAllConnections()` + `main.ts` 里 2s 硬兜底退出(等价 systemd `TimeoutStopSec`)+ `shutdownGraceMs`(300ms)通知窗口。 +3. 🔴 **优雅重连要用"时窗"而不是"次数"**:重启耗时不可预测(实测一次 `stop()` 自身就要 1.8s),固定 4 次会在"差一点点"处失败并把退避直接拉到 60s ⇒ 计划内重启被放大成长时间中断。 +4. 🔴 **满载是唯一的硬门,且已在册节点重连永远优先**:容量检查写成 `sessions.has(hostId)` 放行 —— 否则平台自己重启一次,节点就会被自己的满载规则挡在门外。 +5. ⚠️ **临时验收进程必须回收**:本次在 47 上发现**上一轮验收残留的 relay 进程**(`node lib/net/relay/main.js --port 20080`,已运行 8 分钟,占着 20080)⇒ 导致新 relay `EADDRINUSE`、客户端连上旧实例报 `bad-mac`。**已回收**。⇒ 验收脚本必须用 pidfile 收尾(本次已改为 `kill $(cat *.pid)` + 结束前计数校验 `残留回环监听: 0`)。 + +--- + +# 13. 节点准入与选点 —— 自动(速度 + 负载) / 手动 / 满载排队(R1.5,2026-09-16 20:xx) + +> 回答用户提问:「**如何自动选择和判断适合的节点加入(速度和负载),支持手动选择(要考虑负载满的时候不能加入或排队等待)**」 + +## 13.1 判据分层(**只有一处判据**,避免三套不一致) + +| 层 | 位置 | 职责 | 硬门? | +|---|---|---|---| +| **准入(Admission)** | `RelayServer` | 容量上限(`maxHosts`):满了**拒绝新节点加入**,回 `at-capacity` + `retryAfterMs`;**已在册 host 重连优先** | ✅ **唯一硬门** | +| **选点(Placement)** | `src/net/relay/placement.ts`(纯函数、可单测) | 在**有资格**的候选里按**速度 + 负载**排序选一个;支持手动指定 | ❌ 只排序 | + +`placement.ts` **不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。relay 只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。 + +## 13.2 打分公式(**可解释**是这个模块存在的意义) + +```text + speed = 100 / (1 + rttMs / rttHalfMs) # rtt 0→100;50ms→50;200ms→20(rttHalfMs 默认 50) + load = 100 × (1 − used / max) # 容量未知 ⇒ 按 0.5 中性(不猜它空) + score = weight × (0.55·speed + 0.45·load) − 15 × recentFailures + 硬门:capacity.free === 0 ⇒ score = −∞(**不可选**,只能排队或换节点) +``` +- **速度略重(0.55)**:实测里"能不能连上"由 RTT 决定;且第一瓶颈是 **presence**,不是带宽。 +- **满载不参与打分**,直接出局 —— 这正是用户要的"负载满的时候不能加入"。 +- `recentFailures` **只降权不排除**:唯一可用但常失败的节点,也好过没有节点。 +- **手动指定优先**(`manualId`):给了它就**只考虑它**;它满了 ⇒ `queued`(排队)或 `rejected`(`allowQueue:false`),**绝不静默改选别的节点**。 + +## 13.3 三种出口都有明确语义 + +| 情况 | `outcome` | 含义 | +|---|---|---| +| 手动指定且未满 | `chosen` | 尊重显式选择 | +| 手动指定但已满 | `queued` / `rejected` | 排队等待 / 拒绝加入(**不偷偷换**) | +| 自动且有空位 | `chosen` | 按速度 + 负载打分取最高 | +| 自动但全满 | `queued` / `rejected` | 排队等位 / 直接拒绝(`allowQueue:false`) | +| 候选为空 / 手动 id 不存在 | `rejected` | **明确拒绝并说明原因**,不抛异常让上层去猜 | + +## 13.4 与既有集群化落点逻辑的关系 + +既有约定「存量锚 `w-47` 粘性优先、新用户按容量落 `w-106`」在本模块里的表达就是: +**粘性 = `manual`/高 `weight`**,**按容量 = `capacity` 打分**。⇒ 门户/Manager 已有的落点选择**不需要改判据**, +只要把画象喂给 `chooseNode()` 即可(同一套判据,不会出现"门户算一套、relay 算另一套")。 + +## 13.5 落地状态 + +| 项 | 状态 | +|---|---| +| 代码 | `src/net/relay/placement.ts`(新增)、`server.ts` 容量准入、`client.ts` 排队语义、`main.ts` `--max-hosts` / `DSHS_RELAY_MAX_HOSTS` | +| 单测 | T13 / T14 / T15 ✅ | +| 真机 | 47 上 `--max-hosts 1`:A 注册成功、**B 被拒并排队**、服务端 `capacity:{max:1,used:1,free:0}` ✅ | +| 未做 | Manager/门户侧接 `chooseNode()`(属 R3 集成);relay 集群的多中继选主(本阶段不需要) | + +--- + +## 14. R2 落地:relay 常驻 47 + 经 nginx 443 暴露 wss(2026-09-16 21:0x,**已验收**) + +### 14.1 落地形态(实测) + +| 项 | 值 | +|---|---| +| 服务端产物 | 47 `/opt/dsh-relay/lib/net/relay/*` + **`lib/net/reachability.js`**(⚠️ `rendezvous.js` import 它)+ `/opt/dsh-relay/package.json` = `{"type":"module"}` | +| 单元 | `/etc/systemd/system/dshs-relay.service`(`enabled` + `active`;`Restart=always`) | +| ExecStart | `/usr/local/bin/node /opt/dsh-relay/lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 --max-hosts 0` | +| 密钥 | `/etc/dshs/relay-keys.json`(`600`;**每 worker 一密钥** `w-47` / `w-106`,各 64 hex) | +| 入口 | `alotbuy.com.conf` 443 server 块内**只加一个** `location /dshs-relay` → `proxy_pass http://127.0.0.1:20080`(复用 `nginx.conf:321` 的 `map $http_upgrade $connection_upgrade`) | + +### 14.2 验收证据(命令原文级) + +⚠️ **本轮两处取证纠正(⛔ 勿沿用旧结论)** +1. 🔴 **门户 443 块在 `alotbuy.com.conf`,不在 `dsh.alotbuy.com.conf`** —— 后者是**遗留 301 跳转域名**(`return 301 https://alotbuy.com$request_uri`,`server_name dsh.alotbuy.com *.dsh.alotbuy.com`)。R2 交接单里写的那个"候选"是错的;也**不能**把 location 加进 301 块。 +2. 🔴 **`dsh.alotbuy.com` 在 Cloudflare 后面**(`104.21.44.42` / `172.67.194.206`)⇒ 验收 URL 用 **`https://alotbuy.com/dshs-relay`**;且 443 块是 `listen 443 ssl; http2 on;` ⇒ **curl 默认协商 h2,经典 `Upgrade` 握手必失败(实测 `HTTP/2 404`)**,判据命令**必须带 `--http1.1`**。 + +| 判据 | 命令 | 实测 | +|---|---|---| +| 服务在跑 | `systemctl is-active dshs-relay` | `active`;`is-enabled` = `enabled` | +| 只绑回环 | `ss -lntp \| grep 20080` | `127.0.0.1:20080`(**不是** `0.0.0.0`) | +| 状态面 | `curl -s 127.0.0.1:20080/status` | 含 `"capacity":{"max":0,"used":0}` | +| **零新增公网口** | 本机 `curl -m 5 http://47.77.182.89:20080/status` | `http_code=000`、`curl_rc=28`(不可达) | +| 启动日志 | `journalctl -u dshs-relay` | `[relay] listening ws://127.0.0.1:20080/dshs-relay (loopback only) instance-ports=20000..20999` | +| nginx 语法 | `nginx -t` | `syntax is ok` + `test is successful` → `reload` | +| **origin 直连 101** | `curl -k -i --http1.1 --resolve alotbuy.com:443:127.0.0.1 -H 'Upgrade: websocket' -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' https://alotbuy.com/dshs-relay` | `HTTP/1.1 101 Switching Protocols` | +| **经 Cloudflare 101** | 同上去掉 `--resolve` | `HTTP/1.1 101` + `Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=` | +| 404 来源可辨 | 普通 GET `https://alotbuy.com/dshs-relay` | body = **relay 自身文案** `dshs relay: WebSocket upgrade only, at /dshs-relay` ⇒ 证明 location 真打到 20080(而非门户 3080) | +| 门户未受损 | 47 本机 `curl --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/portal.html` | `code=200 size=50726`;`/api/dsh/status` → `401 size=24`(正常未授权) | +| 监听面零变化 | `ss -lntp \| awk '{print $4}' \| sort -u` | **改前改后逐字一致**(无新增 `0.0.0.0` / `*`) | +| **重启韧性** | `systemctl restart dshs-relay` 后复测 | `active` + `20080` 回归 + nginx 路径仍 `101` | +| **端到端** | 47 上 `node lib/net/relay/main.js --client --url wss://alotbuy.com/dshs-relay --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20099` | client:`registered host=w-47 session=4afc68c53de7991f accepted=[20099] clockSkew=6ms`;服务端 `/status`:`online=[w-47(…ports=20099…)]`、`endpoints=[{hostId:w-47,port:20099,localPort:42067,online:true}]`;journal:`AUTH OK host=w-47 session=4afc68c53de7991f ports=[20099]` | +| 优雅停机 | 对上述 client 发 `SIGTERM` | client `EXITED`;relay journal `session 4afc68c53de7991f (w-47) dropped: connection closed`;`online=[]`、`used=0`;**残留进程 0**、**残留回环口 0** | + +### 14.3 本轮三条硬结论(下一棒必读) + +1. 🔑 **`--base/--span` 的语义 = "允许 worker 声明的实例端口区间"(准入校验)**,见 `server.js:348`(越界 → `port-out-of-range`);**⛔ 不是 relay 本地回环口的区间** —— 本地口是 `server.js:558` 的 `listen(0)`,由 **OS 动态分配**。⚠️ R1.5 记忆里"relay 本地端口区间隔离"的表述**不准确,已勘误**:实测 `localPort=42067`(落在 OS 临时段)**是设计使然、不是 bug**;真正的隔离由「**每个 `(hostId,port)` 一个独立 `node:net` 监听 + 一条独立到对面的 socket**」保证 —— 本就不存在共享的端口编号空间。 +2. 🔴 **"占着实例端口的不一定是残留进程"** —— 47 上 `127.0.0.1:20000` 的占用者 `pid 720541`,实测是**在线用户实例**(`node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port 20000`,`cwd=/var/lib/dshs/users/cce6d1cd-b376-4304-80f0-0e1c58c9ffde/ws`,已跑 4h41m)⇒ 判别法 = **`tr '\0' ' ' < /proc//cmdline` + `ls -l /proc//cwd`**(回环口 → uid ≠ 0 即用户实例)。⛔ 别按端口号猜、⛔ 别 `pkill -f`。 +3. 🟡 **`http2 on` 与"经典 WebSocket 握手"在 curl 侧互斥** ⇒ 判据命令**必须 `--http1.1`**;Cloudflare 对 WS 请求会自行降级 HTTP/1.1 回源,故**真实用户路径不受影响**(实测 101)。 + +### 14.4 回滚(两步各自秒级,SSH 路径从头到尾没动) + +- **R2-b**:`cp /www/server/panel/vhost/nginx/alotbuy.com.conf.bak-20260916-2059-pre-relay /www/server/panel/vhost/nginx/alotbuy.com.conf` → `nginx -t` → `nginx -s reload` +- **R2-a**:`systemctl disable --now dshs-relay` → `rm -f /etc/systemd/system/dshs-relay.service` → `systemctl daemon-reload`(`/opt/dsh-relay` 可留,不占端口即无害) diff --git a/dsh-server-docs/04-调整方案/114-覆盖网络-应用场景与待完善清单.md b/dsh-server-docs/04-调整方案/114-覆盖网络-应用场景与待完善清单.md new file mode 100644 index 0000000..6fdbefd --- /dev/null +++ b/dsh-server-docs/04-调整方案/114-覆盖网络-应用场景与待完善清单.md @@ -0,0 +1,113 @@ +# 覆盖网络 · 应用场景推演完成度 & 方案待完善清单(2026-09-16 检查) + +> **性质**:只读检查报告。⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。 +> **检查对象**:工作区根覆盖网络线 13 份文档。 +> **📌 口径校正(2026-09-16 11:4x 用户纠正)**:本方案的目标应用场景 = **① 多人 + agent 对话 ② MUD / MMORPG 网游 ③ 以上述应用为负载的 1000 台异构网络互联**。此前本报告 §一 把"跨机访问实例/文件交换"排在第 1 档,属**次级用途**,已按用户口径重排。 + +--- + +## 一、判定:三块场景的「互联逻辑模拟」完成了吗 + +| # | 应用场景 | 纸面推演 | 可运行模拟 | +|---|---|---|---| +| 1 | **多人 + agent 对话** | ✅ **完成**(S2/S4/S5 有数值 + 调研 §2 分档 + 答疑 §一) | ❌ 零 | +| 2 | **MUD / MMORPG 网游** | ✅ **完成**(S3 有数值 + 游戏专项 12 条 + 调研 §1) | ❌ 零 | +| 3 | **1000 台异构互联(承上述负载)** | ✅ **完成**(6 类画像 + S1–S11 + 流量预算总表) | ❌ 零 | + +**结论**: +- **"文字推演"这一步是完成的** —— 11 个场景逐个有计算、有结论、有处置手段,三块应用都有对应推演。 +- **"模拟"一步都没开始** —— **全部是纸面演算,零代码、零数据回流**。文档自己标注"未接入任何机器"。 +- ⇒ 差别在于:**现在所有输入数字都是估值**(打洞成功率、jitter、每玩家带宽全是估的),文档自己写"关键结论**对比例敏感、对绝对值不敏感**"。**换言之:结构判断可信,具体容量数字还不可信。** + +--- + +## 二、三块场景的实际覆盖(逐块) + +### 2.1 多人 + agent 对话 + +| 已推 | 出处 | 数值 | +|---|---|---| +| presence(200 房 × 50 人) | S2 | **8,200 次/秒** | +| presence(1 个 1000 人大房) | S2/S4 | **16,700 次/秒** ← 第一瓶颈 | +| 大房消息扇出 | S2/S4 | 1,000 投递/秒(可承受) | +| agent 放大 | S5 | 无约束 ⇒ **指数增长**;四条硬约束 | + +- ⭐ 最重要的量化结论:**presence 比消息早爆一个量级**(16,700 vs 1,000)。 +- ⚠️ **缺口 A**:**agent 预算从头到尾没有标定数值** —— 而 §5 结论却说"单房间实际上限 = min(扇出预算, presence 预算, **agent 预算**)"。三个预算里有一个是空的 ⇒ **单房间上限目前给不出数**。 + +### 2.2 MUD / MMORPG 网游 + +| 已推 | 出处 | 数值 | +|---|---|---| +| 40 服 × 300 玩家,服放 L1 | S3 | 每服 24 Mbps,**中继承载 0** | +| 服放家宽(25 服) | S3 | **600 Mbps 常驻** ← 第 2 瓶颈 | +| 攻城战峰值 | S3 | 1.2–2.0 Gbps | +| 抖动门槛 | S3 | **jitter < 20 ms** | +| 核心简化 | 游戏专项 | 玩家之间不需要互联,**中继按服数算** | + +- ⚠️ **缺口 B(逻辑跳跃)**:S3 推出"服放家宽 ⇒ 600 Mbps 过中继",但**玩家是外部客户端、不是覆盖网络成员** —— 他们凭什么走我们的中继?游戏专项的说法是"服主动拨出到骨干、玩家连骨干入口",可**"骨干入口"对外开放 = 把中继变成公网游戏入口**,这是完全不同的形态(权限面扩大 + 计费 + 防滥用),**这一分支在 13 份文档里没有展开**。而它恰好是第 2 大瓶颈的来源。 + +### 2.3 1000 台异构网络互联 + +| 已推 | 数值 | +|---|---| +| 设备构成 | 6 类(云服务器 100 / 家宽 300 / CGNAT 200 / 移动网 150 / 企业网 150 / VPN 100) | +| 需中继占比 | **48.5%**(按 45% 设计、55% 留余量) | +| 分场景 | **S1–S11 全覆盖**(冷启动 / 稳态 / 游戏 / 大房 / agent / 备份 / 迁移 / 中继故障 / 控制面重启 / 区域突变 / NAT 溢出) | +| 流量预算总表 | 10 类流量 × 频率 × 单次 × 放大 × 上限手段 | + +- ⚠️ **缺口 C**:**逐场景独立推,没有并发叠加** —— S1–S11 是串行列举。真实最坏情况是"游戏攻城战 + 备份窗口 + 1000 人大房 + agent 风暴同时发生,且其中 1/3 节点正因中继故障重连",**这一叠加没有任何一份文档推过**,而瓶颈排序表是按"单瓶颈先炸顺序"排的。 + +--- + +## 三、要把"推演"变成"模拟",还差三件事 + +1. **固化输入参数表**(设备占比 / 打洞率 / 每玩家带宽 / 消息频率 / 心跳 —— 现在散在 6 份文档里,无统一表 ⇒ **不可复算**)。 +2. **补并发叠加的最坏情况**(缺口 C)。 +3. **用 3–5 台真机把 3 个关键估值换成实测**:打洞成功率、中继 jitter、真实可用带宽 —— 这三个一换,全部容量结论才从"结构可信"变成"数字可信"。 + +--- + +## 四、方案待完善清单(P0 / P1 / P2) + +### P0 · 不补就走不通(5 条) + +| # | 缺口 | 为什么是 P0 | +|---|---|---| +| **1** | **缺「网(network)」抽象** | 骨干被同时当作"平台 Worker 隧道"与"用户设备 P2P"的会合点,两者权限模型完全不同。**13 份文档从未出现"网络标识(tailnet / network id)"** ⟹ 所有节点落进同一扁平命名空间,与"可见性默认最小"冲突 | +| **2** | **首次入网引导(bootstrap)无答案** | 会合地址从 env 来,但新设备**第一次**怎么拿到、**首次没有缓存签名目录**时怎么办,无人回答 | +| **3** | **地址规划与名字解析无落地设计** | 内部地址段怎么分、如何避开用户内网 10/8 · 192.168/16、split DNS 怎么不破坏用户原 DNS | +| **4** | **信任根与密钥生命周期未设计** | 只写"一机一钥 + 可吊销",缺控制面签发者(根)的保管与轮换、私钥丢失恢复、设备被盗吊销 | +| **5** | **外部玩家如何进入游戏服(缺口 B)** | 决定第 2 大瓶颈(600 Mbps)是否真的存在 —— 若玩家不能进,这条瓶颈根本不成立;若能进,则是**权限面扩大**(命中 R5) | + +### P1 · 不做会出事(7 条) + +参数表是空的(libp2p 默认值可直接固化)· 30 条未验证项未收敛成取证计划(最该先测 jitter)· 观测最小指标集与阈值缺失 · **权限面评估缺失 = 命中 R5**(虚拟网卡驱动需管理员权限 / 骨干开端口 / nft 打洞)· 成本模型缺失 · 滥用与安全事件处置缺失 · 卸载与退出机制缺失 · 协议选型未收敛 · **最小可用规模(3–5 台)路径缺失**。 + +### P2 · 一致性与流程(4 条) + +首屏口径笔误(瓶颈落地 §3 标题写 10.8 GB,实为 **10.8 MB/台**)· 落地路线图 **4 个版本**未收敛 · 10 份文档未归档 · 双源声明与判定句矛盾未修(可行性评估 §6.4 已指出)。 + +--- + +## 五、唯一主线(收敛 4 个版本后) + +| 序 | 动作 | 状态 | +|---|---|---| +| 1 | 会合 / 中继从 Manager 拆出(S0–S4) | **现在就能开工** | +| 2 | 网抽象 + 地址规划 + 引导(P0 1/2/3) | 依赖 ① | +| 3 | 一机一钥 + 信任根(P0-4) | 依赖 ① | +| 4 | 443/TCP 兜底(否则封 UDP 的整类节点进不来) | 依赖 ① | +| 5 | **参数表 + 观测最小集 + 权限评估**(P1 三条) | 可并行 | +| 6 | **3–5 台最小形态跑通**(把 3 个关键估值换成实测) | 依赖 2/3/4 | +| 7 | 之后才谈:内容分发 · 房间层 · 游戏 | — | + +> ⚠️ **别被 1000 台带偏**:1000 是设计上限;**当前目标是同一人的几台设备跨公网互通**。 + +--- + +## 六、本次未做(范围声明) + +- ⛔ 未写文档库(全局执行锁被占用);本报告落工作区根。 +- ⛔ 未改代码、未在 47 / 106 执行任何命令。 +- 📌 **未新增上抛项** —— 待拍板仍只有骨干服务范围一项(A 自用 / B 全网,倾向 A→B 渐进)。 diff --git a/dsh-server-docs/04-调整方案/115-覆盖网络-插件化vs改内核-架构判断.md b/dsh-server-docs/04-调整方案/115-覆盖网络-插件化vs改内核-架构判断.md new file mode 100644 index 0000000..cd345e6 --- /dev/null +++ b/dsh-server-docs/04-调整方案/115-覆盖网络-插件化vs改内核-架构判断.md @@ -0,0 +1,116 @@ +# 覆盖网络 · 「插件 vs 改内核」架构判断(2026-09-16) + +> **性质**:只读架构评估。⛔ 未改任何代码、未动服务器。 +> **取证范围**:`D:\github\dsh_shenxian` 工作树(HEAD `4e3a1a4`)—— `src/supervisor/spawner.ts`(全读)· `src/worker/agent.ts`(头部 62 行 + 接口)· `src/` 结构清点 · 会合中继拆分方案 C1–C4。 +> **一句话判定**:**主力形态既不是"dsh 插件"也不是"把东西塞进平台主进程",而是"平台内模块化 + 独立进程/单元"** —— 因为要解决的问题(跨机可达、节点身份、寻址)**发生在实例之外**,dsh 插件机制在架构上够不到;而平台**已有**独立组件的先例(Manager / Worker 分体),顺着走即可。 + +--- + +## 一、先拆概念:「插件」在这个项目里有两个完全不同的含义 + +| | (a) **dsh 官方插件**(profile 层业务插件) | (b) **平台自身的模块化** | +|---|---|---| +| 跑在哪 | **用户实例内部**(dsh 进程内) | 平台进程 / 独立进程 | +| 能做什么 | 加 UI 卡片与分区、注册工具、改实例内行为 | 任何事 | +| 够得到 Worker↔Manager 隧道? | ❌ **架构上够不到**(实例是隔离的运行体,看不见宿主网络栈) | ✅ | +| 发版与回滚 | 独立(候选池 → 用户自助启停) | 跟平台版本走 | +| 现有先例 | `@dsh-local/portal-entry`、`business-plugins` | Manager / Worker 分体、`dshs-worker.service` | + +**⇒ 概念澄清(很重要)**:覆盖网络**根本不涉及 `@deepseek-ai/dsh` 主程序** ⇒ **红线 R2 不构成约束**,这套东西整个发生在**平台侧**。所以"插件 vs 改代码"的真正对象是**平台自己的代码仓**,不是 dsh。 + +--- + +## 二、三层归属:哪块该放哪 + +| 层 | 内容 | 建议形态 | 理由 | +|---|---|---|---| +| **数据面组件** | 会合(rendezvous)· 中继(relay)· **发布层**(对外服务暴露) | **独立进程 / 独立 systemd 单元**(可多实例、可换机) | ① 跨机、要碰宿主网络栈 ② 需独立扩缩容与**独立安全加固**(发布层要挡 DDoS)③ 塞进主进程 = 与平台同生共死,违反"控制面/数据面分离" | +| **平台侧集成** | 寻址(`via`)· 节点身份与凭据 · 骨干资格签发 · 容量准入 | **改平台代码** | 必须与**归属 / 租约同源**(单点写),外置就是第二个权威源 = 脑裂 | +| **实例内展示与工具** | 「我的网络 / 我的设备」面板 · agent 的文件互传工具 · 网络状态 | **dsh 插件**(唯一真正适合插件的地方) | 纯实例内 UI 与工具,正是插件机制的用途 | + +--- + +## 三、三种形态的优劣 + +### A · dsh 插件形态(把覆盖网络做成插件) + +**优点**:不动平台内核;可独立发版;用户可自助启停;回滚粒度细(禁用即可)。 + +**缺点**:**能力边界是硬的** —— 插件跑在实例内部,**看不见宿主网络栈、够不到 Worker↔Manager 隧道、无法参与控制面归属与租约**;覆盖网络的核心问题(跨机可达 / 节点身份 / 寻址)**一个都解决不了**;且插件加载失败会把实例拖进崩溃循环(已有探活回滚机制,但仍是额外风险面)。 + +**⇒ 判定:只能覆盖第三层(实例内展示与工具),不能承担主力。** + +### B · 改内核形态(把会合/中继塞进平台主进程) + +**优点**:改动集中;复用现有配置与日志;部署单元不增加;短期最快。 + +**缺点**:与平台**同生共死**(中继挂了平台一起受影响);中继要**独立扩缩容**时做不到;**对外暴露面与内核同进程**(发布层因此无法做独立安全加固);端口空间与 Manager 全局共享(就是现状的 C2 耦合);版本矩阵无法独立演进。 + +**⇒ 判定:现状就是这个形态,也正是要拆掉的那个问题。** + +### C · 独立组件 + 平台内模块化(**推荐主力**) + +**优点**:① **顺着现有架构走** —— 平台已有 Manager / Worker 分体与独立 systemd 单元的先例,不是新造范式;② 控制面/数据面**天然分离**,符合既有分层判据;③ 可独立换机、多实例、独立加固(发布层单独处理 DDoS);④ 每一步可独立回滚(S0–S4 已定)。 + +**缺点**:多一个部署单元与版本矩阵;本机开发环境要能跑起来(本机是 Windows,需容器或远程);会引入"组件间契约"这条新的维护面。 + +--- + +## 四、现有项目代码评估:**设计基本合理,且为这个方向留了缝** + +### 4.1 合理之处(有代码证据) + +| # | 事实 | 为什么关键 | +|---|---|---| +| 1 | `Spawner` 是干净的后端抽象缝(`launch / stop / status / endpointFor / restartAndProbe`,`src/supervisor/spawner.ts:94-149`) | **新增一种节点 = 多一个实现**,不是改内核;route 层只依赖接口 | +| 2 | Worker agent 有**四条协议纪律**(单向拨入 / 幂等键 `operationId` / 最小接口白名单 / self-fencing,`src/worker/agent.ts:10-17`) | 这**就是**现成的"节点契约"—— 客户端节点可直接复用同一套语义 | +| 3 | 归属 / 租约**只有 Manager 能写**(`epoch+1` fencing) | 与"客户端不可信"天然相容 ⇒ 客户端天生只能当数据面,不会有脑裂 | +| 4 | `tunnelTarget === '' ⇒ 完全不建隧道`(`agent.ts:154`) | **扩展点是预留的**:同机形态零影响,跨机才启用 | +| 5 | 全仓仅 **1 处** `process.platform` 分支(`supervisor/firewall.ts:42`) | 跨平台改造成本低 | +| 6 | `instanceHost` 已是参数(注释:"跨机时填内网 IP") | 寻址**已经是参数化的**,不是写死 | + +### 4.2 不足(4 处,均为**已知或有据**的具体欠账) + +| # | 不足 | 证据 | 影响 | +|---|---|---|---| +| 1 | **寻址只有 `{host, port}`,表达不了"经谁到达"** | `Endpoint = {host, port}`(`spawner.ts:80-83`);`hostsProvider` 把 `dsh_hosts.endpoint` 直接当 `agentUrl` | 换中继时表里无处表达 ⇒ **必须加 `via` 字段**(越晚改越贵) | +| 2 | **共享 bearer token,无法按节点吊销** | `agent.ts:40` 注释自陈:"本版是 bearer 式比较,**HMAC/防重放留待后续**" | 公网上不成立;**这是项目自己记录的已知欠账**,正好是覆盖网络要补的 | +| 3 | **中继与 Manager 同生共死** | 隧道落在 Manager 的 sshd(`32022`),端口空间与 Manager 全局共享 | 中继不可多实例、不可换机、单点 | +| 4 | **控制面 PG 复用同一条隧道** | `DSHS_TUNNEL_STATIC_PORTS`,设计上含控制面 PG | 换中继时 DB 连接一起断 ⇒ **回滚面比看上去大** | + +### 4.3 总评 + +> **合理程度:中上。** 它是"**为多节点预留了缝**"的设计 —— 抽象缝(Spawner)、协议纪律、参数化寻址、开关式隧道,这四样都在。 +> **但也正因为缝开对了,改造的成本主要在"补两个字段 + 分两步换绑定",而不是重构内核。** +> 唯一的严肃欠账是**身份**(共享 token),而项目**自己已经在注释里写明了**这是待办 —— 说明设计者是清醒的,不是漏掉。 + +--- + +## 五、能否支持这个方向的改造:**能,且不需要重构** + +| 需要的改造 | 现有设计支持度 | 落点 | +|---|---|---| +| 新增一种节点类型(客户端/骨干) | ✅ **直接支持** —— 加一个 `Spawner` 实现 + 复用 agent 四纪律 | 新文件,不动内核 | +| 寻址加 `via` | ⚠️ **需加字段**(向后兼容:有默认值,旧代码不受影响) | S2(方案已出) | +| 会合地址出 env | ✅ **易**(`tunnelTarget` 已是配置;再加一个优先项即可) | S1 | +| 中继独立成单元 | ✅ **易** —— 已定"不换协议、只换绑定关系" | S4 | +| 节点身份(一机一钥) | ⚠️ **要新写**,但接口位置清楚(token 校验点集中) | 拆分方案之后 | +| 发布层 | 🆕 **全新**,与平台主进程分开 = 天然适合独立单元 | 新组件 | + +**⇒ 结论:改造路径与现有架构**不冲突**,是"顺着缝往下切",不是"推倒重来"。**会合中继拆分方案 S0–S4 已经把顺序定好了(S0 纯新增文件 + 可选字段,零行为变化)。 + +--- + +## 六、我选了什么(可推翻) + +1. **主力形态 = 独立组件 + 平台内模块化**,不是 dsh 插件、也不是塞进主进程。 +2. **dsh 插件只承担第三层**(实例内的"我的网络/我的设备"面板与文件互传工具)—— 这是唯一适合插件的部分。 +3. **先"平台内模块化 + 独立 systemd 单元",暂不拆独立仓库** —— 现在只有两台机器,独立仓库会带来版本矩阵与同步成本;而 S0 的接口抽象已经让"以后再拆"变得便宜。 +4. **身份(一机一钥)放在拆分之后**,但它**必须排在任何公网暴露之前**。 + +--- + +## 七、本次未做 + +- ⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。 +- 📌 未新增上抛项 —— 待拍板仍是:骨干服务范围(A 自用 / B 全网)+ 发布层形态与成本。 diff --git a/dsh-server-docs/04-调整方案/116-覆盖网络-问题逐条推演与解决方案.md b/dsh-server-docs/04-调整方案/116-覆盖网络-问题逐条推演与解决方案.md new file mode 100644 index 0000000..ff769b9 --- /dev/null +++ b/dsh-server-docs/04-调整方案/116-覆盖网络-问题逐条推演与解决方案.md @@ -0,0 +1,274 @@ +# 覆盖网络方案 · 问题逐条推演与解决方案(2026-09-16) + +> **性质**:只读推演稿(调研 + 推演)。⛔ 未改代码、未动服务器、未写文档库(全局执行锁被占用)。 +> **方法**:每条按固定骨架 —— **① 问题 ② 查到什么资料 ③ 推演 ④ 结论(能不能解 / 怎么解 / 代价)**。 +> **结论先行**:**P0 五条全部可解**,其中 2 条有成熟范式可直接照抄、3 条要自建但路径清晰;**3 条推演缺口全部可补**;**但有 3 处新发现的坑**(见 §D),其中 1 处(地址段冲突)**不提前处理必然出事故**。 + +--- + +## A. P0 五条 + +### A1 · 缺「网(network)」抽象 + +**① 问题**:骨干被同时当作"平台 Worker 隧道"和"用户设备 P2P"的会合点,两者权限模型完全不同;13 份文档从未出现"网络标识"概念。 + +**② 资料(Tailscale 范式)**:每个 **tailnet 是独立命名空间**,各自一份 ACL 策略文件(huJSON),结构为 `tagOwners` / `groups` / `acls` / `tests` / `postures`;权限**挂在 tag(角色)上而非 IP 上**;跨网共享用"**只共享单个节点**"(Machines → Share),不下发全网名单。ACL 支持 `tests` 断言(可在 CI 里验证"某来源**不能**到达某目标")。 + +**③ 推演**:我们需要的不是一张网,而是**三类网并存**: + +| 网 | 成员 | 可见性 | 谁会用到 | +|---|---|---|---| +| **运维网** | 我们自己的机器(47 / 106 / 未来的中继与骨干) | 管理员专有;Worker 只对 Manager 可见 | 平台自身(现有 SSH 隧道的位置) | +| **用户网** | **每用户一张网**:该用户全部设备 + 该用户显式授权的他人设备 | 默认只见自己名下设备 | 场景 1/2/3(跨机访问、文件交换) | +| **发布层**(见 A5) | 公网转发节点 | 对外端口/域名,**不是网内成员** | 游戏、服务暴露 | + +**关键判断:用户网要"每用户一张网",不要"一张巨网 + ACL"。** +- 理由一:隔离从**策略性**变成**结构性** —— 后者写错一条 ACL 就泄露,前者结构上不可能越界。 +- 理由二:ACL 的 `tests` 可以进 CI,把"不能到达"变成可回归的断言。 +- 理由三:跨用户协作走"**只共享单节点**",这与项目"权限只准收窄"一致。 + +**④ 结论**:✅ 可解,**直接照抄 tailnet 范式**。代价 = 控制面租户模型里把**用户 ID 提升为网络标识**(我们已有租户表,这一维是加一列而非重做)。⚠️ **现在只有 1 个用户 ⇒ 结构成本几乎为零,但一旦多人就省下一次大改** —— 所以**要在第一次落地时就分开,不能等**。 + +--- + +### A2 · 首次入网引导(bootstrap) + +**① 问题**:会合地址从 env 来,但新设备第一次怎么拿到它、首次没有缓存签名目录时怎么办,无人回答。 + +**② 资料(headscale 范式)**:客户端**只需要知道一个 `server_url`**,其余(DERP 中继地图)全部由控制面下发 —— DERP map 来源支持 `urls`(远程 JSON)与 `paths`(本地 YAML),并有 `auto_update_enabled` + `update_frequency`(默认 3h–24h)定期刷新;DERP 强制 HTTPS/TLS;`region_id` 在 map 内必须唯一;客户端侧有本地缓存。 + +**③ 推演** —— 三级引导链: + +1. **引导种子(内置)**:客户端二进制里写死 **2–3 个 HTTPS 引导地址**(不同地域)。它只回答"第一次问谁"。 +2. **签名目录(下发 + 缓存)**:控制面返回经签名的"可用会合点 / 骨干 / 中继"列表。客户端缓存,`update_frequency` 级别刷新。 +3. **离线降级**:缓存过期仍可用(只影响新节点加入,不影响已建连接)。 + +**🔑 推演出的关键设计(资料里没直说,但不做会成灾)**:**引导地址必须能通过已建立的连接在线下发新引导地址**。否则将来换域名/换机器 = 所有客户端必须升级重装。 + +**④ 结论**:✅ 可解,**很成熟**。代价:一个域名 + TLS 证书 + **N+1(至少 2 个引导点)**;以及"引导地址轮换"这条运维流程要写进方案。 + +--- + +### A3 · 地址规划与名字解析 + +**① 问题**:内部地址段怎么分、怎么避开用户内网、名字谁解析。 + +**② 资料(含一条对我们的硬警告)**: +- Tailscale 客户端**硬编码**两个段:IPv4 `100.64.0.0/10`(CGNAT 段)+ IPv6 `fd7a:115c:a1e0::/48`;headscale 的 `prefixes` **必须是这两者的子集**,否则"undefined behaviour / break in subtle, hard-to-debug ways"。 +- **⚠️ 官方与社区共同警告:「避免与 CGNAT 段(100.64.0.0/10)重叠」** —— 而**中国移动等运营商的大内网正好就是 100.64.0.0/10**(社区文档明确点名"类似于中国移动宽带的 NAT 网段")。 +- MagicDNS:`hostname.user.basedomain`;**`base_domain` 必须与 `server_url` 域名不同**以免冲突;`nameservers.split` 做按域分流(split DNS);`override_local_dns` 有开关;实践中不少人建议 `magic_dns: false` + 客户端 `--accept-dns=false`,避免覆盖用户系统 DNS。 + +**③ 推演 —— 这是本项目最容易踩死的一处**:我们的设备池里有 **200 台 CGNAT + 150 台移动网**(千台推演 §0.1)。这些节点**本机很可能就处在 100.64.0.0/10 内**。若把覆盖网内部地址也分配到该段,会出现:① **宿主路由冲突**(发往覆盖网对端的包被送进运营商网关)② 表现是"部分节点时通时不通",**极难排查**。 + +**方案(两条硬前提)**: +1. **主寻址走 IPv6 ULA**(`fd00::/8` 内选一段)—— 唯一性有保证、与用户内网几乎不冲突,正好吃满补遗已列的"IPv6 优先"红利。 +2. **IPv4 只作兼容层**,且**必须做本地网段冲突检测**:检测到冲突 ⇒ 该地址**自动让路**(回落到 IPv6 或名字寻址),并在客户端明确报错(不能静默)。 + +**名字解析**:MagicDNS 范式,但三条约束 —— ① `base_domain` 用**子域**(如 `net.<我们的域名>`),与门户域名分开;② **必须 split DNS**(我们的名字走我们的解析器);③ **默认不接管用户系统 DNS**(提供显式开关,默认关)。 + +**④ 结论**:✅ 可解。**代价 = 冲突检测必须写进客户端首版**(事后加极难,因为要改路由层)。 + +--- + +### A4 · 信任根与密钥生命周期 + +**① 问题**:只写了"一机一钥 + 可吊销",缺根密钥保管/轮换、私钥丢失恢复、设备被盗吊销。 + +**② 资料(Tailnet Lock 白皮书,几乎是为我们这个问题写的)**: +- 控制面必须分发**节点公钥**,所以"被攻破的控制面可以插入攻击者节点"是这套架构的**固有弱点**。 +- Tailnet Lock 的解法:引入 **TLK(Ed25519)签名密钥集合**;**新节点的公钥必须带一个受信任 TLK 的签名**,**每个节点在本地校验**,验不过就不建立会话。 +- **TLK 私钥由本地保管,控制面看不到也改不了**;受信任 TLK 集合的变更本身也要签名 + 本地校验;还有 **disablement secret**(关闭机制)。 +- 冲突更新用**权重**裁决。 +- 配套:`key expiry`(用户节点定期过期 / tagged 节点不过期)、ephemeral 节点超时删除、Noise 私钥丢失 ⇒ 所有客户端重注册。 + +**③ 推演 —— 四层密钥模型**: + +| 层 | 放哪 | 用途 | 丢失后果 | +|---|---|---|---| +| **根(离线)** | 用户手里(纸质恢复码 / 离线设备) | 只用于授权/撤销"签名者" | **最严重** ⇒ 全网重建 | +| **签名者(在线,多把)** | 每台管理员设备一把,受根授权 | 签发节点入网凭据 | 换一把(根仍在) | +| **节点密钥** | 每设备一把(系统密钥库) | 设备身份 | 该设备重签 | +| **会话密钥** | 内存 | 隧道(定期 rekey) | 无感 | + +**恢复路径(都不需要控制面参与)**:私钥丢 ⇒ 根密钥重签;设备被盗 ⇒ 用签名者密钥撤销该节点签名。 + +**④ 结论**:✅ 可解,**照抄 Tailnet Lock 的形状**。代价 = 客户端多一层概念 + **必须做"根密钥恢复演练"**;⚠️ 根密钥必须有 **≥2 份离线副本**,否则根丢失 = 全网重置。 + +--- + +### A5 · 外部玩家如何进入游戏服(原报告的"逻辑跳跃") + +**① 问题**:S3 算出"服放家宽 ⇒ 600 Mbps 过中继",但玩家是**外部客户端、不是覆盖网络成员**,凭什么走我们的中继?这条路径 13 份文档没定义。 + +**② 资料(三种形态,业界都很成熟)**: + +| 方案 | 玩家要不要装东西 | 延迟 | 暴露面 | 代表 | +|---|---|---|---|---| +| 端口转发 | 不要 | **最低**(原生) | **暴露家宽 IP** ⇒ 被 DDoS / 关联到个人信息 | 传统做法 | +| **内网穿透 / 隧道** | **不要** | +10–50 ms(多一跳) | 中继侧暴露 | **playit.gg**、frp、ngrok | +| 虚拟局域网 | **要(每人装)** | 低(P2P 成功时) | 高(加密私网) | Tailscale / ZeroTier | + +- playit.gg 模式:**本地服主动拨出**到服务商 → 服务商给一个公网地址 → **玩家零安装直连该地址**。 +- 已知代价(官方/社区共同口径):中继一跳的延迟、**家宽上行决定玩家数(常见 5–10 人上限)**、无 DDoS 防护、免费档限带宽。 +- frp 还支持 **XTCP(P2P,流量不过服务器)** —— 即"先经隧道协商、再打洞直连",与我们中继的设计同构。 + +**③ 推演 —— 三种可能形态,只有一种对**: + +| 形态 | 判定 | +|---|---| +| 服在**有公网 IP 的节点** | ✅ **最优**:玩家直连,覆盖网不参与(= S3 的 L1 情形,中继 0) | +| 服在**家宽/CGNAT 后** | ✅ **走"内网穿透"**:服主动拨出到**发布节点**,发布节点对外开地址;**玩家零安装** | +| 玩家**装客户端入网** | ❌ 千台口径下不可行(玩家是海量外部客户端,不是网络成员) | + +**🔑 结论:方案缺了一层"发布层 / 服务暴露层"**,而且它有两条硬边界: +1. **发布层不能复用用户贡献的骨干** —— 否则用户机器在替第三方对公网转发流量、且暴露其家宽 IP ⇒ **命中 R5 且是我们不能替用户承诺的事**。 +2. **发布层必须与内部中继物理/逻辑分开** —— 一个是对内数据面,一个是对外暴露面,安全加固要求完全不同(DDoS 防护、端口占用、UDP 支持、滥用封禁)。 + +⇒ **瓶颈性质因此改变**:S3 的"600 Mbps"不是"内部中继容量",而是**对外出口带宽 + DDoS 承压面**。 + +**④ 结论**:✅ 可解(有现成范式)。代价 = **新增一个独立组件 + 独立的加固与计费**;且这是**权限面扩大**(命中 R5),必须先出权限影响评估。 + +--- + +## B. 推演缺口三条 + +### B1 · agent 预算标定(此前完全没有数值) + +**① 问题**:§5 结论写"单房间上限 = min(扇出预算, presence 预算, **agent 预算**)",但 agent 预算从未标定。 + +**② 资料(业界给的数非常具体)**: +- 通用安全参数:`max_messages_per_minute: 5` · `cooldown_seconds: 10` · `max_consecutive_self_replies: 2` · 每日 API 预算上限。 +- **硬执行上限**:单次任务**最大跳数 ≤ 5**(超过转人工);**⛔ 不传原始对话历史**,改传校验过的结构化状态。 +- **结构化拒绝**代替自由文本批评(防 ping-pong):评审方只能回 `{approved, errorCode 枚举, 具体修改≤200字}`。 +- **⛔ 关键结论:限流必须在基础设施层强制** —— 应用层自限无效(卡死的循环、配置错误、prompt 注入都能绕过),**OWASP LLM Top 10 的 LLM04 就是把"无限制的 agent 循环"列为 top-10 风险**,要求"在 agent 控制之外强制"。 +- 还有:令牌桶 + **全抖动**退避 + 熔断器 + 集群级(而非单 agent 级)配额。 + +**③ 推演 —— 现在可以给出数**: + +| 层级 | 建议默认值 | 依据 | +|---|---|---| +| 单 agent | **≤5 条/分钟**、cooldown **10 s**、连续自回复 **≤2** | 业界通用安全参数 | +| 单次触发链 | **≤5 跳**,超限转人工 | 生产实践("3–5 跳解不了,给 10 跳也解不了") | +| 房间 agent 数 | **≤ 房间人数 / 10** | 原方案已有 | +| 强制点 | **服务端网关**(不在 agent prompt 里) | OWASP LLM04 | + +**🔑 推演出的新结论(扇出重新算过)**: +1000 人房按 200 个 agent(S5 设定)× 5 条/分钟 = **16.7 条/秒** ⇒ 扇出 1000 ⇒ **16,700 投递/秒**。 +- **这与 presence 的 16,700/秒 同量级!** 两者叠加 = **≈33,400 事件/秒**,是任何单场景的**两倍**。 +- ⇒ 原结论"单房间上限由 presence + agent 预算决定"**得到验证**,而且现在**两个预算都有数了**。 +- ⚠️ **顺带发现一处自相矛盾**:S5 设"1000 人房里聚集 200 个 agent",但硬约束③写"房间 agent ≤ 人数/10"= **100**。**200 这个设定违反了自家约束** —— 按约束应取 100,则 agent 扇出降为 8,350/秒,叠加后 ≈25,000/秒。 + +**④ 结论**:✅ 缺口可补,**以上数值可直接进方案**。 + +--- + +### B2 · 并发叠加(此前逐场景独立推,从未叠加) + +**① 问题**:S1–S11 是串行列举,真实最坏情况是多个场景同时发生。 + +**② 方法推演(资料给的是机制,方法是推出来的)**:叠加推演 = **时间轴重叠检查 + 共享资源争用矩阵 + 主导项法**。 +- 业界对应机制:集群级配额(而非 per-agent)、熔断器、重试预算、失败域隔离 —— 这些正是为"叠加"设计的。 + +**③ 推演**: + +**(a) 时间轴重叠检查** + +| 场景 | 时间窗 | +|---|---| +| 游戏高峰(攻城战) | 20:00–22:00 | +| 群聊活跃 / 1000 人大房 | 20:00–23:00 | +| **agent 活跃** | 随真人(⇒ 与上两者重叠) | +| **备份窗口** | 02:00 | +| 迁移 | 用户驱动 | + +⇒ **游戏 + 群聊 + agent 三者天然重叠**;**备份与游戏高峰错开是既有设计,不是巧合**(这点值得写进方案当作硬约束保留)。 + +**(b) 共享资源争用矩阵** + +| 共享资源 | 谁在抢 | 叠加后量级 | +|---|---|---| +| **发布/中继出口带宽** | 游戏 >> 备份 > 消息 | 游戏 600 Mbps 主导;峰值 1.2–2.0 Gbps | +| 控制面 req/s | 心跳 50/s + 重连突发 1000 | 令牌桶吸收(可忽略) | +| **presence 通道** | presence 16,700/s + agent 16,700/s | **≈33,400/s** ← 真正的叠加瓶颈 | +| 客户端上行 | 备份 + 文件传输 | 低优先级队列 | + +**(c) 最坏组合**:**游戏攻城战 + 1000 人大房 + agent 风暴 + 中继故障重连**(四件同时) +⇒ 出口带宽吃满 2.0 Gbps、presence/agent 通道 33,400 事件/秒、同时 160 台重连。 + +**④ 结论**:✅ 可推,方法就是"**时间轴重叠 + 主导项(差一个数量级可忽略)**"。**重要副产品:叠加后瓶颈排序变了** —— 出口带宽仍是第一,但**第二从"presence"变成"presence × agent 叠加"**。 + +--- + +### B3 · 输入参数表(此前散在 6 份文档,不可复算) + +**① 问题**:设备占比 / 打洞率 / 每玩家带宽 / 消息频率散落各处,无统一表 ⇒ 推演不可复算、不可仿真。 + +**② 资料**:libp2p 的连接管理器与拨号默认值、资源管理器上限、中继自荐参数、headscale 的 prefixes/allocation/心跳 —— 都是**可以直接固化的默认值**。 + +**③ 推演 —— 直接给表**: + +| 类别 | 参数 | 建议值 | 来源 | +|---|---|---|---| +| 地址 | 内部 IPv6 | `fd00::/8` 内选一段 | Tailscale ULA 范式(**刻意不用 100.64/10**,见 A3) | +| 地址 | 内部 IPv4(兼容层) | 仅在无冲突时启用 + 冲突检测 | 同上 | +| 连接 | 每对端并发拨号 | **≤4** | libp2p 默认 | +| 连接 | 总并发拨号 | **100**,超时 **30 s** | libp2p 默认 | +| 连接 | 高低水位 / 宽限期 | **100 / 400 / 1 min** | libp2p Connection Manager | +| 中继 | 每节点预约数 | **≤2** | libp2p autoRelay | +| 中继 | 自荐广告延迟 / TTL | **15 min / 30 min** | libp2p HOP relay | +| 心跳 | 保活间隔 | **20–25 s** | 现网既有(落在 NAT 老化安全区) | +| presence | 批合并刷写 | **1 s**;grace 5–15 s;离线 debounce 30 s | Slack 范式 | +| agent | 见 B1 表 | 5/分 · 10 s · ≤2 · ≤5 跳 | 业界通用 | +| 退避 | 公式 | `min(cap, base×2^n)` + **全抖动** | AWS 架构框架 | +| 传输 | 同时上传对象上限 | **4**,30 s 随机试新对端 | BitTorrent | +| 发布层 | 对外端口 | 见 A5,**待用户拍板**(涉及花钱与暴露面) | — | + +**④ 结论**:✅ **这一步是纯手工活,没有任何阻塞**,应立刻做(它是"从推演到仿真"的前置)。 + +--- + +## C. P1 九条的处置建议(合并给出) + +| # | 问题 | 建议 | 阻塞? | +|---|---|---|---| +| 1 | 30 条未验证项未成取证计划 | 收敛成一份「取证清单」:**最该先测三项** = 中继 jitter / 打洞率 / 真实带宽 | 无 | +| 2 | 限流参数表空 | **= B3 的表,已给出** | 无 | +| 3 | 观测最小集缺失 | 采 5 项:路径类型 · 打洞率 · jitter · 重连次数 · 中继利用率;告警阈值留待实测后标定 | 无 | +| 4 | **权限面评估缺失(R5)** | 三处扩权:**虚拟网卡驱动(需管理员)** / 骨干开端口 / 发布层对外暴露 —— **必须出「权限影响评估」** | ⚠️ 红线 | +| 5 | 成本模型缺失 | = 发布层 + 会合 + 中继的机器与带宽;**属花钱项,需用户拍板** | ⚠️ 边界外 | +| 6 | 滥用与事件处置 | 复制现成范式:封禁节点签名 + 撤资格 + 流量审计;发布层要单独的反滥用 | 无 | +| 7 | 卸载与退出 | 卸载清单:虚拟网卡 / 路由表 / DNS / 常驻进程 / 缓存凭据 | 无 | +| 8 | 协议选型未收敛 | **建议结论**:先沿用 SSH→多对端隧道(S0–S4 已定),**传输层选 WireGuard 用户态**,打洞选 **STUN + 同时发包(DCUtR 式)**,发布层用 **frp 式(含 XTCP P2P 回退)** | 无(技术选型自决) | +| 9 | 最小可用规模路径缺失 | 见 §D 的 3–5 台清单 | 无 | + +--- + +## D. 新发现的 3 处坑(本次推演独有) + +1. 🔴 **地址段冲突(最严重)** —— 我们的节点池里有 200 台 CGNAT + 150 台移动网,**本机很可能就在 100.64.0.0/10 内**;若覆盖网也用这段,会出现路由黑洞且**极难排查**。⇒ **主寻址必须走 IPv6 ULA,IPv4 冲突检测必须进首版**。(业界共识警告 + 我们的设备画像,两者叠加出来的一条。) +2. 🟠 **游戏瓶颈看错层** —— S3 的 600 Mbps 不是"内部中继容量",而是**对外出口带宽 + DDoS 承压面**;且**不能复用用户贡献的骨干**(命中 R5)。 +3. 🟡 **S5 自相矛盾** —— "1000 人房 200 个 agent" 与自家约束"房间 agent ≤ 人数/10(=100)"冲突 ⇒ 按约束取 100。 + +--- + +## E. 汇总结论 + +| 项 | 能否解 | 关键前提 | +|---|---|---| +| A1 网抽象 | ✅ 照抄 tailnet | **第一次落地就要分层,不能等** | +| A2 引导 | ✅ 成熟 | 引导地址要能在线轮换 | +| A3 地址与 DNS | ✅ 可解 | **IPv6 ULA 优先 + 冲突检测** | +| A4 信任根 | ✅ 照抄 Tailnet Lock | 根密钥 ≥2 份离线副本 + 恢复演练 | +| A5 外部玩家 | ✅ 有范式 | **新增发布层组件 + 走 R5 评估** | +| B1 agent 预算 | ✅ 数值可直接用 | 在**服务端网关**强制 | +| B2 并发叠加 | ✅ 方法已定 | 叠加后**第二瓶颈换人** | +| B3 参数表 | ✅ 纯手工活 | **无阻塞,应立刻做** | + +**⇒ 整个方案没有"解不了"的问题;真正卡住的只有两件**:① **权限影响评估(R5 红线)** ② **成本承诺(花钱,属边界外需用户拍板)**。 + +--- + +## F. 本次未做 + +- ⛔ 未改代码、未动 47 / 106、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。 +- 📌 沿用上轮口径:**未新增上抛项**,待拍板仍是骨干服务范围(A 自用 / B 全网)+ 本轮新增的"发布层形态与成本"。 diff --git a/dsh-server-docs/04-调整方案/117-覆盖网络-参数表与观测口径.md b/dsh-server-docs/04-调整方案/117-覆盖网络-参数表与观测口径.md new file mode 100644 index 0000000..c5c94bc --- /dev/null +++ b/dsh-server-docs/04-调整方案/117-覆盖网络-参数表与观测口径.md @@ -0,0 +1,416 @@ +# 参数表 · 覆盖网络(**唯一一张**) + +> **线**:覆盖网络线 | **序**:⑤(参数表 · 观测 · 权限评估)| **产出**:执行棒 2026-09-17 +> **唯一来源**:`覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 5 项** + **§四 P1 行** + `交接单_443兜底_20260917.md` **§8.8** +> **本表要做的事**:把散在 6 份文档里的输入参数 + 代码里已固化的常量**收成一张可复算的表**(每行带**来源等级**与**来源定位**),并填掉 443 单留下的 **45% 口径空位**。 +> ⛔ **本表不引入新模型、不引第三方依赖、不做架构改动**;性质是「**固化**」,不是重新推演。 +> 🔴 **`待测` 项一个都不许编数** —— 编出来的数会让整张表失去"可复算"的资格。`待测` 行的**值列留空**。 + +--- + +## §0 来源等级(四档,⛔ 不许混用) + +| 等级 | 含义 | 判据 | +|---|---|---| +| **实测** | 本机 / 远端**命令原文**能复现的数 | 本表给出一条能跑的命令 | +| **推导** | 由表内其它行**按公式算出来**的数 | 本表给出复算式,独立手算能得同值 | +| **估值** | 文档里的行业口径 / 旧记录 / 推演假设 | 指到**文件:行**,且**不得**被当成实测引用 | +| **待测** | 本单取不到真值 | **值留空**,写明"由序⑥ 用 3–5 台真机换掉" | + +> ⚠️ 旧记录里两个数**本次已复核判为不可用**(⛔ 别再引用): +> ① 47 规格"1.8 GB / 2 核"(09-08)⇒ 本次实测 **1870 MB / 2 核**(量级巧合,但**必须用实测值复算**); +> ② 跨云带宽 **~22 KB/s**(`覆盖网络_千台全场景推演_20260916.md:38`)⇒ 本次实测**上行下界 ≥ 192 KB/s**(≈ 9×),原值作废。 + +--- + +## §1 用法约定(**机器可读** —— 探针脚本靠它取阈值) + +- **参数行格式**:``| `KEY` | 值 | 单位 | 等级 | 来源定位 | 复算式 |`` +- **观测阈值行格式**:``| `OBS-NN` | 指标 | 阈值(引用键或字面值) | 判据 |`` +- ✅ **`scripts/overlay-probe.cjs` 的每一个阈值都从本表读**,⛔ 脚本内**不许有魔数**(判据 = 交接单 §6 **E6**)。 +- 探针运行方式(**一条命令**,cwd = 工作区根): + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"` + +--- + +## §2 运行坐标 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `SSH_TARGET_47` | `bt-server` | — | 实测 | `~/.ssh/config`(`HostName 47.77.182.89`) | `ssh -p 22 bt-server hostname` | +| `SSH_TARGET_106` | `test106` | — | 实测 | 同上(`HostName 106.54.21.172`) | `ssh -p 22 test106 hostname` | +| `SSH_PORT` | 22 | — | 实测 | 交接单 §2 注(`bt-server` 配置写的 32022 **已陈旧**) | `ssh -p 22 bt-server true` | +| `W47_HOSTNAME` | `iZrj99af19cibck1ge93tqZ` | — | 实测 | 本次 S0 P6 | `ssh -p 22 bt-server hostname` | +| `W106_HOSTNAME` | `VM-0-8-opencloudos` | — | 实测 | 本次 S0 P8 | `ssh -p 22 test106 hostname` | +| `RELAY_STATUS_URL` | `http://127.0.0.1:20080/status` | — | 实测 | relay ExecStart `--port 20080` | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` | +| `PORTAL_URL` | `http://127.0.0.1:3080/` | — | 实测 | 平台门户(⚠️ `http2 on` ⇒ **必须 `--http1.1`**,否则假 404) | `ssh -p 22 bt-server 'curl -s --http1.1 -H "Host: alotbuy.com" 127.0.0.1:3080/'` | +| `PORTAL_HOST_HEADER` | `alotbuy.com` | — | 实测 | 同上 | 同上 | +| `RELAY_NETWORK_ID` | `ops` | — | 实测 | `/etc/dshs.env` `DSHS_OVERLAY_NETWORK_ID` | `ssh -p 22 bt-server 'grep NETWORK_ID /etc/dshs.env'` | +| `SSH_TIMEOUT_MS` | 20000 | ms | 推导 | 探针脚本**单次 ssh** 的超时(⚠️ 放这里是为了让 `overlay-probe.cjs` **零魔数**;实测单次 ssh 往返 ≈ 1 s,取 20× 余量) | — | +> 🆕 **序⑦ 真机演练坐标**(`scripts/overlay-failover-drill.cjs` 用它取数 ⇒ 脚本内**零魔数**): + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `DRILL_RELAY_UNIT` | `dshs-relay` | — | 实测 | 两台机的 relay 单元名(`systemctl cat dshs-relay`) | `ssh -p 22 bt-server systemctl cat dshs-relay` | +| `DRILL_MANAGER_UNIT` | `dshs` | — | 实测 | 47 的 Manager 单元名(演练只读它的 journal 判别器) | 同上 | +| `DRILL_POLL_MS` | 500 | ms | 推导 | 演练轮询 journal 的间隔(≤ `RELAY_FAILOVER_CHECK_MS` 的 5×,保证不漏一次切换)。🆕 **序⑨:2000 → 500** —— 原 2000 让读数带 **0–2000 ms 系统性高估**,使「是否超 `RELAY_FAILOVER_DEADLINE_MS`」**两端都可能误判**(序⑨ RC-4)⇒ 这是**测量修正,不是调参**。⚠️ 改前/改后的读数**不可混比** | — | +| `DRILL_SAMPLE_N` | 5 | 次 | 推导 | 🆕 序⑨:`overlay-failover-drill.cjs --sample` 的**默认采样轮数**(口径 = 每轮都做完整归零:两台 relay `start` → `restart dshs` → 等新的 `AUTH OK host=ops/manager` → 杀入口 → 等新 `[relay-switch]`) | `node scripts/overlay-failover-drill.cjs --sample 5` | +| `DRILL_COOLDOWN_OBSERVE_MS` | 90000 | ms | 推导 | 幕 3 的「冷却期内不回跳」观察窗;⛔ **必须 < `RELAY_FAILOVER_COOLDOWN_MS`**,否则判据不成立 | — | +| `DRILL_SWITCH_MATCH_106` | `106.54.21.172` | — | 实测 | 断言 `[relay-switch]` 的目标是不是 **106**(用**目录里那个 host**,⛔ 不是 ssh 别名 `test106`) | `curl -s https://alotbuy.com/dshs-overlay/bootstrap` | +| `DRILL_KILLED_MATCH` | `alotbuy.com` | — | 实测 | 幕 1 被杀入口的标识(= 目录 `relays[]` 首位的 host);脚本据此判定「Manager 当前通道**是否就是**将被杀的那台」——不在 ⇒ 记 **SKIP** 而不是假装 PASS | 同上 | +| `DRILL_DETECT_BUDGET_MS` | 120000 | ms | 推导 | 幕 1/幕 2 的**观察窗**;⛔ 必须 ≥ **静默失效检测时延**(半开检测 = `2.5 × HB_SEC`,即 75 s)—— 首轮实测用 1× deadline(30 s) **必然漏判** | — | + +--- + +## §3 输入参数(清单 §三 点名的 **5 类**;散落点收口) + +> 散落点计数(P10,⛔ 只计数不重读):`调研_游戏网络特征…:19 处` / `千台全场景推演…:18` / `瓶颈落地方案…:9` / `游戏专项…:7` / `清单…:7` / `百台规模推演…:2` / `骨干层方案…:2` ⇒ **合计 64 处命中,正是"不可复算"的成因**。 + +### 3.1 设备占比(分层) + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `DEV_L1_PUBLIC_IP` | 10 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:53` | — | +| `DEV_L2_HOLE_PUNCHABLE` | 30 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:54` | — | +| `DEV_L3_RELAY_ONLY` | 45 | 台 | 估值 | `覆盖网络_百台规模推演_20260916.md:55`(原文区间 40–55,取中值) | `(40 + 55) / 2` | +| `DEV_SHARE_L3` | 0.45 | — | 估值 | `覆盖网络_百台规模推演_20260916.md:119`「L3 占 **45%**(v1 只算 15%)」+`:132` | 45 / 100(口径 = **全网节点数**的比例) | +| `FLEET_TARGET` | 1000 | 台 | 估值 | `覆盖网络_千台全场景推演_20260916.md`(标题与全文口径) | — | +| `FLEET_RELAY_DEMAND` | 450 | 台 | 推导 | 本表 §5 | `FLEET_TARGET × DEV_SHARE_L3` = 1000 × 0.45 | + +### 3.2 打洞率 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `HOLE_PUNCH_RATE_INDUSTRY` | 0.90–0.94 | — | 估值 | `覆盖网络_全球架构复盘_20260916.md:38`(业内 UDP 打洞 ≈ 94%) | — | +| `HOLE_PUNCH_RATE_BY_CLASS` | 90% / 40% / 10% | — | 估值 | `方案规划方法_覆盖网络线提炼_20260916.md:45`(三档标注实例) | — | +| `HOLE_PUNCH_RATE_LOCAL` | **分层(n=3 台)**:① 云机×云机(47↔106)—— 均为 L1 直连,**不是打洞场景**;② 国内家宽/办公 NAT(本机)↔ 47 / ↔ 106 —— **对端 → 本机方向 10/10 收包 = 可打洞**;本机 → 云机方向**取不到**(云安全组拦 UDP 入站)。**两对中两对可打洞** | — | **实测** | 本单 §8.4 E6 + `overlay-holepunch.cjs --stun`(⚠️ 首选路径的 47 UDP 观察器**实测不可用**:零收包 ⇒ 降级为**第三方 STUN**,见 §8.4 `OBS-4D`) | 判定式=`对(47,本机)`、`对(106,本机)` 各「至少一个方向成功」⇒ 2/2;⚠️ **样本 n=3 对,云节点占 2/3,不代表家宽场景** | + +### 3.3 每玩家带宽 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `PER_PLAYER_BW_TEXT` | 0.5 | KB/s | 估值 | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:15` | — | +| `PER_PLAYER_BW_BATTLE` | 2–5 | KB/s | 估值 | 同上 `:15`(另一口径 `:43`「单玩家 2–20 KB/s」) | — | +| `PER_PLAYER_BW_SIEGE` | 10–20 | KB/s | 估值 | 同上 `:15` | — | +| `PER_PLAYER_BW_LOCAL` | **9.8**(n=10 玩家档)/ **3.9**(n=50 玩家档)—— 判据见来源列;聚合天花板 ≈ **200–350 KB/s** | KB/s | **实测** | 本单 §8.4 E7 + `overlay-wan.cjs --players`(合成 200 B 消息 × 5/20/50 msg/s × 10/50 玩家 × 60 s,经 relay 真机间) | 判据=`p95 ≤ 2×p50 且丢包 = 0`;⚠️ 原始丢包含**尾部在途**伪影(≈`rate × RTT`);扣掉后满足的**最高档 = 10 玩家 @50 msg/s = 9.77 KB/s/玩家**(50 玩家档最高 3.91)。**边界**:本值 = **传输层上限**;游戏协议的真实需求仍是估值(`PER_PLAYER_BW_TEXT/BATTLE/SIEGE`)——⛔ 不得读成"实测出的游戏需求" | + +### 3.4 消息频率 / 扇出 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `MSG_RATE_GLOBAL` | 50 | msg/s | 估值 | `覆盖网络_千台全场景推演_20260916.md:75` | — | +| `FANOUT_AVG` | 50 | — | 估值 | 同上 `:75`("均扇出 50") | `50 × 50 = 2500 投递/秒` | +| `ROOM_FANOUT_LIMIT_100` | 100 | 人 | 估值 | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:97`(≤100 纯扇出即可) | — | +| `PRESENCE_FANOUT_1000ROOM` | 16700 | 次/秒 | **推导** | `覆盖网络_千台全场景推演_20260916.md:79` | 千人房 presence ≈ 1000 × 16.7 次/秒(原文直接给 16,700) | + +### 3.5 心跳 / 链路质量 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `HB_SEC` | 15 | s | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:60` `DEFAULT_HB_SEC = 15`;运行期由 `HELLO_ACK` 下发(`server.ts:816`) | `grep -n DEFAULT_HB_SEC src/net/relay/server.ts` | +| `HB_SEC_DOC` | 20 | s | 估值(**文档口径,与代码不一致**) | `覆盖网络_千台全场景推演_20260916.md:39`「心跳间隔 20 s(沿用现有隧道自愈定时器)」 | ⚠️ 该行属**旧隧道时代**口径 —— 现已换 relay(`hbSec=15`)⇒ **文档待更正**,参数以 `HB_SEC=15` 为准 | +| `HALF_OPEN_MS` | 37500 | ms | 推导 | `src/net/relay/client.ts:602` | `max(3000, HB_SEC × 1000 × 2.5)` = 15 × 1000 × 2.5 | +| `IDLE_TIMEOUT_MS` | 45000 | ms | 实测(代码常量) | `src/net/relay/server.ts:58` | `grep -n DEFAULT_IDLE_TIMEOUT_MS src/net/relay/server.ts` | +| `JITTER_LIMIT_MS` | 20 | ms | 估值(业界口径) | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md:32`+`覆盖网络_千台全场景推演_20260916.md:90` | — | +| `JITTER_LINK_MEASURED` | **3**(判定值 = `p95(|ΔRTT|)`;另记 `mdev` = 1.37–1.38 ms、`avg` = 148.3 ms、丢包 1.3–1.7%) | ms | **实测** | 本单 §8.4 E5 + `overlay-jitter.cjs --icmp`(47→106 与 106→47 各 300 包 × 0.2 s) | **达标**:3 ms < `JITTER_LIMIT_MS`(20 ms,⚠️ 该限值是**业界估值**口径)。⚠️ 这条**推翻了**"当前跨云链路上 jitter 不可能达标"的旧定性结论 —— 旧结论是拿 `RELAY_RTT_W106` 当链路 RTT 推的(口径错,见下行) | +| `RELAY_RTT_W106` | 336 → **保留数值,但加口径修正**:该值是**心跳往返**(`server.ts:810`:测 RTT / 察觉半开 / 保 NAT 表项),**含应用层处理与验签,⛔ ≠ 网络 RTT** | ms | **实测(口径受限)** | 本单 §8.4 E5(b) 三方对比:ICMP 148 ms | TCP 握手 median 153 ms(min 138.5)| relay 心跳 344–376 ms ⇒ **差 2.3×,触发"加口径备注"判据** | 判据(S3(b)):`relay rttMs` 与 ICMP 差距 > 2× ⇒ 加备注 + 在 `OBS` 侧登记"relay `rttMs` 不得当链路 RTT 用" | +| `RELAY_RTT_MANAGER` | 7 | ms | **实测** | `/status` → `sessions[hostId=manager].rttMs` | 同上 | +| `RELAY_TTFB_S` | 0.68 | s | **实测** | 本次 S0 P7a:47 → relay 回环口 → w-106 任一端口,`%{time_starttransfer}`(6 次样本 0.676–1.083) | `ssh -p 22 bt-server 'curl -s --http1.1 -o /dev/null -w "%{time_starttransfer}\n" http://127.0.0.1:44133/'` | +| `WAN_UP_BOUND_KBPS` | 192 ⇒ **已被上行实测取代(值作废,保留作历史)** | KB/s | ~~实测(下界)~~ **作废** | 原 S0 P7 样本被 405 提前截断(131072 B / 0.685 s,含 RTT)⇒ 只是**下界**,且下界偏低 **64×** | 用 `WAN_STEADY_THROUGHPUT` 的上行值 12213 作准 | +| `WAN_STEADY_THROUGHPUT` | **352(106→47)/ 12213(47→106)** —— 取**绑定方向**(对端→中继机,即"内容从 worker 上来"那条)= **352** | KB/s | **实测** | 本单 §8.4 E4 + `overlay-wan.cjs --download|--upload`(各 5 样本) | **口径三要素**:① 谁到谁 = 106 的 `w-106p:19777` ↔ **47 本机 relay 回环端点**;② **经 relay**(非裸链路);③ 稳态段 **30 s**(弃前 3 s,爬升段字节单独计)。**中位数 352**(min 345.6 / max 352);上行侧 12213(min 11784 / max 12452)。⚠️ 非对称的成因 = 106(云轻量)**公网出带宽封顶 ≈ 2.8 Mbps**,实测三向互证:`/status` 会话计数 `in=1.905 GB` + 106 侧进程 `rchar=1.905 GB` + 下载恒定 165×64 KB/30 s | +| `WAN_UP_BOUND_KBPS` | 192 ⇒ **已被上行实测取代(值作废,保留作历史)** | KB/s | ~~实测(下界)~~ **作废** | 原 S0 P7 样本被 405 提前截断(131072 B / 0.685 s,含 RTT)⇒ 只是**下界**,且下界偏低 **64×** | 用 `WAN_STEADY_THROUGHPUT` 的上行值 12213 作准 | + +### 3.6 presence(节点在线态 · 序⑲)—— **帧率类**参数 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `PRESENCE_BATCH_MS` | 1000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:106` `DEFAULT_PRESENCE_BATCH_MS = 1_000` | `grep -n DEFAULT_PRESENCE_BATCH_MS src/net/relay/server.ts` | +| `PRESENCE_GRACE_MS` | 10000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:89` `DEFAULT_PRESENCE_GRACE_MS = 10_000` | `grep -n DEFAULT_PRESENCE_GRACE_MS src/net/relay/server.ts` | +| `PRESENCE_OFFLINE_DEBOUNCE_MS` | 30000 | ms | **实测**(代码常量=运行真值) | `src/net/relay/server.ts:97` `DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS = 30_000` | `grep -n DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS src/net/relay/server.ts` | +| `PRESENCE_TTL_MS` | 45000 | ms | 推导(代码常量 × 心跳) | 服务端 `src/net/relay/server.ts:604`;客户端保守档 `src/net/relay/client.ts:329` `DEFAULT_PRESENCE_TTL_MS = 45_000`(两处同值,实测一致) | `HB_SEC × 1000 × DEFAULT_PRESENCE_TTL_FACTOR` = 15 × 1000 × 3(因子 = 3 见 `server.ts:115`) | +| `PRESENCE_SUB_MAX` | 0 | 个 | **实测**(代码常量;**0 = 不限**) | `src/net/relay/server.ts:122` `DEFAULT_PRESENCE_SUB_MAX = 0` | `grep -n DEFAULT_PRESENCE_SUB_MAX src/net/relay/server.ts` | + +> ⚠️ 这五个键只驱动**在线态事件**(谁在线 / 何时改口),⛔ 与 `RELAY_FAILOVER_*` / `HB_SEC` / burst **无耦合** —— 序⑲ 全程一字未动(E11 的 D1 自证)。 +> ⚠️ `PRESENCE_TTL_MS` 是**安全网**(兜"漏掉 close 事件"那条路),⛔ **不是常规下线路径**:常规下线走 `PRESENCE_GRACE_MS + PRESENCE_OFFLINE_DEBOUNCE_MS` = 40 s。 +> ⚠️ 口径来源 = D3(最终一致:允许 5–15 s 陈旧),⛔ 三个阈值均按代码常量取,**不许改口径去凑判据**(E5)。 +> 🔴 **值格必须纯数字**(夹注 / 混写单位 ⇒ 探针 `NaN` ⇒ 假红,序⑫ 已踩)。 + +--- + +## §4 代码已固化常量(**实测**=直接可 grep 的源码真值) + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `RELAY_PORT` | 20080 | — | 实测 | `src/net/relay/server.ts:52`+单位文件 ExecStart | `grep -n DEFAULT_RELAY_PORT src/net/relay/server.ts` | +| `RELAY_BIND` | 127.0.0.1 | — | 实测 | 主单元 ExecStart(无 `--host` ⇒ 默认回环) | `ssh -p 22 bt-server "ss -lntp \| grep 20080"` | +| `RELAY_INSTANCE_BASE` | 19000 | — | 实测 | 主单元 ExecStart `--base 19000` | 同上 | +| `RELAY_INSTANCE_SPAN` | 3000 | — | 实测 | 主单元 ExecStart `--span 3000` ⇒ 可声明窗口 `[19000, 22000)` | 同上 | +| `AUTH_DEADLINE_MS` | 5000 | ms | 实测 | `src/net/relay/server.ts:54` | `grep` | +| `AUTH_WINDOW_MS` | 60000 | ms | 实测 | `src/net/relay/server.ts:56` | `grep` | +| `CAPACITY_RETRY_AFTER_MS` | 5000 | ms | 实测 | `src/net/relay/server.ts:63` `DEFAULT_CAPACITY_RETRY_AFTER_MS` | `grep` | +| `SHUTDOWN_GRACE_MS` | 300 | ms | 实测 | `src/net/relay/server.ts:69` | `grep` | +| `MAX_STREAMS_PER_PORT` | 64 | 条 | 实测 | `src/net/relay/server.ts:71` | `grep` | +| `QUEUE_MAX_BYTES` | 1048576 | B | 实测 | `src/net/relay/server.ts:73`(`1 << 20`) | `grep` | +| `WS_PEER_HIGH_WATER` | 262144 | B | 实测 | `src/net/relay/server.ts:76`(`256 * 1024`) | `grep` | +| `CLIENT_HIGH_WATER` | 262144 | B | 实测 | `src/net/relay/client.ts:230` | `grep` | +| `CLIENT_QUEUE_MAX` | 1048576 | B | 实测 | `src/net/relay/client.ts:229` | `grep` | +| `DIRECTORY_REFRESH_SECONDS` | 300 | s | 实测 | `src/net/relay/directory.ts:60` | `grep` | +| `DIRECTORY_FETCH_TIMEOUT_MS` | 5000 | ms | 实测 | `src/net/relay/directory.ts:521` | `grep` | +| `DIRECTORY_MAX_ENTRIES` | 8 | 条 | 实测 | `src/net/relay/directory.ts:63` `MAX_ENTRIES` | `grep` | +| `DIRECTORY_MAX_ENTRY_LEN` | 512 | 字符 | 实测 | `src/net/relay/directory.ts:64` `MAX_ENTRY_LEN` | `grep` | +| `DIALER_POOL` | 64 | 口 | 实测 | `src/net/relay/dialer.ts:55` `DEFAULT_POOL`+`/etc/dshs.env` `DSHS_RELAY_DIAL_POOL` | `grep`+`ssh -p 22 bt-server 'grep DIAL_POOL /etc/dshs.env'` | +| `DIAL_PORT_BASE` | 25000 | — | 实测 | `/etc/dshs.env` `DSHS_RELAY_DIAL_PORT_BASE` | 同上 | +| `DIAL_PORT_SPAN` | 1000 | — | 实测 | `/etc/dshs.env` `DSHS_RELAY_DIAL_PORT_SPAN`(⚠️ 实绑只到 `base+POOL`) | 同上 | +| `DIAL_POOL_BOUND` | 25000–25063 | — | **实测** | 47 上 `ss -lntp`(64 口全绑)+journal `[relay-dialer] 本机落点池就绪:64 个口` | `ssh -p 22 bt-server "ss -lntp \| grep -c 250"` | +| `W47_AGENT_PORT` | 19100 | — | 实测 | `/etc/dshs.env` `DSHS_CLUSTER_AGENT_URL=http://127.0.0.1:19100` | `ssh -p 22 bt-server 'grep AGENT_URL /etc/dshs.env'` | +| `LOCAL_INSTANCE_PORT` | 20000 | — | 实测 | 47 上 `ss -lntp`(`127.0.0.1:20000`) | `ssh -p 22 bt-server "ss -lntp \| grep 20000"` | +| `PEER_AGENT_PORT` | 19000 | — | 实测 | `/status` → `endpoints[]`/`online[]`(对端 w-106 的 `ports=19000/21000`) | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` | +| `PEER_INSTANCE_PORT` | 21000 | — | 实测 | 同上 | 同上 | +| `RELAY_FAILOVER_MIN_ATTEMPTS` | 3 | 次 | 实测 | `src/net/relay/switcher.ts#relayFailoverThresholds`(env 可覆写;**置 0 = 总开关关闭**) | `grep -n RELAY_FAILOVER_MIN_ATTEMPTS src/net/relay/switcher.ts` | +| `RELAY_FAILOVER_GRACE_MS` | 15000 | ms | 实测 | 同上(与 `MIN_ATTEMPTS` 取**或**:106 侧首连窗口长,只看次数会误切) | `grep` | +| `RELAY_FAILOVER_COOLDOWN_MS` | 300000 | ms | 实测 | 同上(与 `DIRECTORY_REFRESH_SECONDS` 对齐;冷却期内**不回跳**) | `grep` | +| `RELAY_FAILOVER_DEADLINE_MS` | 30000 | ms | 实测 | 同上(**验收判据**:从不健康到切换完成的允许上限) | `grep` | +| `RELAY_FAILOVER_CHECK_MS` | 2000 | ms | 实测 | 同上(健康巡检周期;独立于目录刷新周期) | `grep` | +| `RELAY_FAILOVER_UP_TIMEOUT_MS` | 12000 | ms | 实测 | 同上(**只用于换址**:新通道必须真到 `up` 才算「建起来了」)。⚠️ 序⑨:**死候选提前失败后它不再是恒等代价**(实测死候选 ≈ 0.1–1 s 就返回,慢候选仍享受完整 12 s) | `grep` | +| `RELAY_GRACEFUL_BURST_MS` | 15000 | ms | 🆕 实测 | **序⑨ 参数表化**:`client.ts` 的 `gracefulBurstMs`("计划内下线 ≠ 故障"的快速重试窗口)原先是**全仓唯一一个不可配的时延常量**(只有 `?? 15_000` 一处),现可经 env 覆写;⛔ **默认值语义逐字不变**。⚠️ 它就是**检测段 15.0 s 地板的真身**:窗口内 `attempts` 恒为 0 ⇒ `unhealthy()` 只能靠 `graceMs` 成立(序⑨ §1.2-RC-2)⇒ 改它 = 改"计划内重启不触发切流"的窗口,**先回写交接单**(D3) | `grep -n gracefulBurstMsDefault src/net/relay/client.ts` | +| `RELAY_FAILOVER_EXEMPT` | 1 | — | 实测 | 同上(🆕 序⑧:**一跳豁免总开关**,`1`=开 / `0`=关;`RELAY_FAILOVER_MIN_ATTEMPTS=0` 是**另一层**,⛔ 别混) | `grep -n RELAY_FAILOVER_EXEMPT src/net/relay/switcher.ts` | +| `DRILL_COOLDOWN_MS` | 90000 | ms | 推导 | 🆕 序⑧:**演练期**冷却覆盖值(经 `dshs.service.d/zz-drill-override.conf` 注入)——⛔ **生产默认恒为 `RELAY_FAILOVER_COOLDOWN_MS=300000`**。取值口径:必须 ① ≫ 静默失效检测时延(实测 ≈ 29 s,否则"冷却未到期"判据根本来不及观测)② ≪ `DIRECTORY_REFRESH_SECONDS`(300 s)(让冷却先过期、目录巡检后到) | — | +| `DRILL_NO_SWITCH_OBSERVE_MS` | 45000 | ms | 推导 | 🆕 序⑧:幕 4b 的**"预期不切换"观察窗**;⛔ 必须 ≥ 失效检测时延(否则 D6 现场还没形成)**且** < `DRILL_COOLDOWN_MS`(否则会跨过冷却期满、把"自然回归"误判成"切了") | — | + +> 🆕 **序⑦ 新增 5 键**(中继失败切流):默认值 = 源码真值(可直接 grep); +> ⛔ 值格**必须纯数字**(夹注 ⇒ `NaN` ⇒ 假红,见 §8.8-2 教训); +> 🔑 **回滚开关 = `RELAY_FAILOVER_MIN_ATTEMPTS=0`** ⇒ 监管器**永不触发**,行为回到「原地退避重试」的现状(§7 回滚第 1 层)。 +> +> 🆕 **序⑧ 新增 3 键**(切流冷却语义拆分):`RELAY_FAILOVER_EXEMPT`(代码侧,默认 `1`)+ +> `DRILL_COOLDOWN_MS` / `DRILL_NO_SWITCH_OBSERVE_MS`(**只在演练脚本里用**)。 +> 🔑 **本单首选回滚点 = `RELAY_FAILOVER_EXEMPT=0`** ⇒ 只关掉豁免、序⑦ 的切流能力**全部保留**; +> ⛔ 它**不是** `MIN_ATTEMPTS` 的同义词(后者关整个监管器)。 +> ⚠️ 演练期改冷却**只能**经 `DRILL_COOLDOWN_MS` 注入 drop-in;⛔ `RELAY_FAILOVER_COOLDOWN_MS` 的代码默认值不动(D9)。 + +> ⚠️ **键名口径**:`LOCAL_*` = 中继机(47)**本机**的实例/代理口;`PEER_*` = **对端**节点(今天= w-106)的等价口。 +> (键名里**不带** `106` 是为了让 `overlay-probe.cjs` 的**零数字纪律**成立 —— 见交接单 §6 E6。) + + +--- + +## §5 本单新增:**45% 口径填值**(443 单 §8.8 留下的空位) + +> **443 单 §8.8 登记行原文**:「兜底启用后,relay 容量须按 **45% 的节点走中继**核算(异构纪律,⛔ 不是同构的 15%)」。 +> **口径落点**(按交接单 §4.1-3):填**单台中继的 `--max-hosts`**,⛔ **不填"全网 45%"** —— 后者只作**校验**用。 + +### 5.1 输入(全部来自本表其它行,⛔ 无外部魔数) + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `MEM_TOTAL_MB` | 1870 | MB | **实测** | S0 P6:`free -m` 第 1 行 | `ssh -p 22 bt-server 'free -m \| head -2'` | +| `MEM_AVAILABLE_MB` | 1002 | MB | **实测** | S0 P6:`free -m` 的 `available` 列 | 同上 | +| `MEM_BUDGET_MB` | 1002 | MB | 推导 | 本表 | `= MEM_AVAILABLE_MB`(47 同时跑 PG+Manager+nginx,故**只取 available**,⛔ 不用 total) | +| `CPU_CORES` | 2 | 核 | **实测** | S0 P6:`nproc` | `ssh -p 22 bt-server nproc` | +| `FD_LIMIT` | 262144 | 个 | **实测** | S0:`/proc//limits` `Max open files` | `ssh -p 22 bt-server 'cat /proc/$(systemctl show -p MainPID --value dshs-relay)/limits \| grep "open files"'` | +| `RELAY_RSS_KB` | 72888 | KB | **实测** | S0:`ps -o rss= -p `(used=2 时,含 Node 基座) | `ssh -p 22 bt-server 'ps -o rss= -p $(systemctl show -p MainPID --value dshs-relay)'` | +| `MEM_PER_HOST_MB` | **0.06** | MB/台 | **实测**(原为推导 2) | 本单 §8.4 E8 + `relay-mem-calibrate.mjs`:本机独立 relay 实例 + 同进程合成 client N=2/10/25/50/100/150,每点 7 次采样取中位数 ⇒ 线性回归 `RSS(N) = 48078 + 46.7·N` KB,**R² = 0.942**(≥ 0.9 ⇒ 有效) | `46.7 KB/台 × 1.2 余量 = 56.0 KB = 0.0547 MB ⇒ ceil 到 0.01 MB 位 = 0.06`。⚠️ 原推导 2 MB **高估 36×**:它把 **per-stream** 的 256 KB 高水位算进了 **per-host**;本值是**空闲会话**口径,带流量的 per-stream 开销**未测**(登记于 §9) | +| `FD_PER_HOST` | 4 | 个/台 | 推导 | 1 条 WS + 每声明端口 1 个回环 listener + 2 个临时 ⇒ 上界 4 | 保守取上界 | +| `DESIGN_MARGIN` | 0.45 | — | 估值 | **443 单 §8.8** 原文(异构纪律;⛔ 非同构 15%) | — | + +### 5.2 推导式(⛔ 可独立手算,E2 判据) + +``` +C_MEM = floor(MEM_BUDGET_MB / MEM_PER_HOST_MB) = floor(1002 / 0.06) = 16700 # ← 序⑥ S7 重算(原 floor(1002/2)=501) +C_FD = floor(FD_LIMIT / FD_PER_HOST) = floor(262144 / 4) = 65536 +C_RELAY = min(C_MEM, C_FD) = 16700 # 带宽仍不参与取 min,见 §5.4 +RELAY_MAX_HOSTS = floor(C_RELAY × DESIGN_MARGIN) = floor(16700 × 0.45) = 7515 # ← 原 225 +``` + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `C_MEM` | **16700** | 台 | 推导 | 本表 | `floor(1002 / 0.06)` | +| `C_FD` | 65536 | 台 | 推导 | 本表 | `floor(262144 / 4)` | +| `C_RELAY` | **16700** | 台 | 推导 | 本表 | `min(16700, 65536)` | +| `RELAY_MAX_HOSTS` | **7515** | 台 | 推导 | 本表 | `floor(16700 × 0.45)`(⚠️ **本单元格必须是纯数字** —— 探针 `KEY_RE`/`cleanValue` 不认识"(原 225)"这类夹注,会把整格当值 ⇒ `NaN` ⇒ `OBS-02` 假红;旧值 225 记在此列)。🔴 🔴 **序⑥ S7 回头条件已触发并执行**(2026-09-17):`MEM_PER_HOST_MB` 2 → 0.06、`WAN_STEADY_THROUGHPUT` 待测 → 352/12213 ⇒ 已**重下发** 47 与 106 两台的 `capacity.conf`(`--max-hosts 7515`),`OBS-02` 复验 PASS | + +**校验(三条,全部满足才生效)—— 序⑥ S7 重跑记录** +- ① **防自锁**:`RELAY_MAX_HOSTS > used × 4` ⇒ `7515 > 4 × 4 = 16` ✅(验收时 `used = 4`) +- ② **设计余量自洽**:`7515 / 16700 = 45.0% ≈ DESIGN_MARGIN` ✅ +- ③ **千台需求校验**:`FLEET_RELAY_DEMAND` = 450 **≤** `RELAY_MAX_HOSTS` = 7515(原为 `450 > 225`)⇒ 🔴 **结论变更**:**"容量上必须 ≥2 台中继"这条理由随实测消失**(单台容量已足够)。2 台的依据改为 ① `清单 §3 关键决定`「有公网 IP 的节点自动升格中继候选」② **跨机真容灾**(106 已升格,见 §8 ④)。⚠️ "零余量"这一旧事实**不再成立**,代之以下面那条:**绑定约束从"内存"搬到了"带宽/时延"**(见 §5.4)。 + +### 5.3 `--max-hosts` 的**分母口径**(⛔ 必须写清,否则数字会被误读) + +`--max-hosts` 是**每台中继**的**在册会话(host)数上限**,它**不是**"全网 45%",分母是**这一台中继**;`FLEET_RELAY_DEMAND`(450)**只是校验上界**。今天生产上只有 **1 台中继**(47)⇒ 本值直接生效。 + +### 5.4 为什么带宽**不参与** `min`(⛔ 不是漏了)—— 序⑥ S7 用实测重判 + +- **判据(S7-2 原文)**:若新实测吞吐使"225 台(今为 7515 台)的控制面 + 实例面流量"逼近实测吞吐 ⇒ 带宽进 `min`。 +- **控制面**(用实测值重算):`1 / HB_SEC` = 0.067 次/秒/台 × 7515 台 ≈ **504 次/秒**;每台每秒控制面流量 `≈ 100 / HB_SEC = 6.7 B/s` ⇒ 7515 台 ≈ **50 KB/s**,对比绑定方向实测 **352 KB/s**(余量 7×)⇒ **控制面不绑定**。 +- **实例面**:不是"稳态负载"而是"**单次传输**",正确的判据是**时延**不是容量 —— 单次 46.3 MB 冷启动 ÷ 352 KB/s ≈ **135 s**(原推算按 192 KB/s 是 247 s,按 22 KB/s 旧值则是 36 min)。⇒ ⛔ **仍不进 `min`**,但登记为**实例面单次传输的时延上界**,并给出唯一的改进方向(提升对端公网出带宽,而非加中继)。 +- ⚠️ **本条的边界**:352 KB/s 是 **106(云轻量)出带宽封顶**造成的,不是 relay 栈上限(上行方向实测 12213 KB/s 证明栈本身没到这个量级)。 + +### 5.5 `/status.capacity` 的**语义纠正**(P2 实测 vs 交接单期望) + +| 事实 | 原文 | +|---|---| +| `max = 0` 时**只有** `{max, used}`,**没有 `free`** | `src/net/relay/server.ts:502-506`:`this.maxHosts > 0 ? {max, used, free} : {max, used}`;实测 `/status` = `"capacity": {"max": 0, "used": 2}` | +| `free` **只有**在 `max > 0` 时出现 | 同上 ⇒ **E3 的 `free = max - used` 判据只在设值后成立**(设值前 `free` 字段不存在,不是 `null`/不是 `0`) | +| 满载时的 `free` 恒为 `0` | `server.ts:761`:`capacity: { max, used, free: 0 }`(拒绝载荷里) | +| `used` = **在册会话数**(= host 数,不是流数、不是端口数) | `server.ts:505`:`used: this.sessions.size` | +| `retryAfterMs` 口径 = 满载时给对端的**排队建议** | `CAPACITY_RETRY_AFTER_MS` = 5000 ms;客户端**不消耗退避**地排队(`client.ts:532 / 881-896`,附A 已核) | + +### 5.6 443 单 §8.7③ 要求的回答:`relays[]` 顺序语义 + +> **本单必须回答**:443 单留下的「若将来 `relays[]` 引入非首位更优的显式优先级语义 ⇒ 需重新定义同源优先与它的先后关系」。 + +**回答**:本单**不引入**该语义。`relays[]` 的顺序语义固定为「**主入口首位**」—— 首位是主入口,其余是兜底候选;**不存在**"非首位更优"的显式优先级字段。⛔ 本表**不新增**任何优先级键(新增即等于把这条语义改了)。 + +--- + +## §6 观测阈值(探针脚本的**唯一**取数来源;⛔ 脚本内无魔数 = E6) + +| ID | 指标 | 阈值 | 判据 | +|---|---|---|---| +| `OBS-01` | relay 在册会话数(`used`) | `MIN_HOSTS` | `used ≥ MIN_HOSTS` | +| `OBS-02` | `capacity.max` / `free` | `RELAY_MAX_HOSTS` | `max = RELAY_MAX_HOSTS` 且 `free = max - used` | +| `OBS-03` | 身份强制与受信签名者 | `MIN_TRUSTED_SIGNERS` | `identityRequired = true` 且 `trustedSigners ≥ MIN_TRUSTED_SIGNERS` | +| `OBS-04` | 通过节点凭据校验的注册数 | `MIN_IDENTITY_OK` | `identityOk ≥ MIN_IDENTITY_OK` | +| `OBS-05` | 吊销清单规模 | `MAX_REVOKED_HOSTS` | `revokedHosts ≤ MAX_REVOKED_HOSTS` | +| `OBS-06` | **判别器计数三件套存在** | (字段存在性) | `typeof dial/dialDenied/dialFailed === 'number'` | +| `OBS-07` | 鉴权失败累计 | `MAX_AUTH_FAILED` | `authFailed ≤ MAX_AUTH_FAILED` | +| `OBS-08` | 端点表全在线 | (无 `false`) | `endpoints[].online` 全为 `true` 且非空 | +| `OBS-09` | 双实例探活(`w-47` 实例面 / `w-106` 实例面经 relay) | `PROBE_CODE_SET` | 两个 HTTP 码均 ∈ `PROBE_CODE_SET` | +| `OBS-10` | 门户可达 | `PORTAL_CODE` | `= PORTAL_CODE` | +| `OBS-11` | 零新增暴露面(**监听集合** / **nft 入站 accept 集合** / relay 只绑回环) | `LISTEN_REQUIRED` / `LISTEN_ALLOWED` / `LISTEN_ALLOWED_RANGES` / `DIAL_POOL_BOUND` / `NFT_ALLOW_INBOUND` / `RELAY_BIND` | **三集包含式**:`必在 ⊆ 实际 ⊆ 必在 ∪ 允许 ∪ 区间(含拨号池) ∪ 派生`,差集**逐条点名**;且 `nft 入站 accept ⊆ NFT_ALLOW_INBOUND`;`RELAY_PORT` 只出现在 `RELAY_BIND` 上。🔴 **序⑫:旧口径「计数相等」已退役** —— 它对实例/端点在线态**敏感 ⇒ 假红**,对"一进一出"替换式变化**不敏感 ⇒ 假绿** | +| `OBS-12` | relay 进程内存 | `RELAY_RSS_MAX_KB` | `RSS ≤ RELAY_RSS_MAX_KB` | +| `OBS-13` | **presence 帧率(稳态)** | `PRESENCE_STEADY_FRAMES_MAX` / `PRESENCE_SAMPLE_HITS_MIN` | **两次 `/status` 采样的增量**:`Δpushed ≤ 0` **且** `ΔstatusHits ≥ 1`(活性证明:⛔ 不许"因为读不到所以看起来是 0");并附口径一致(`presenceTiming` 逐项 == `PRESENCE_*`)+ 判别器齐全(`subs/pushed/snaps/rejected/statusHits` 皆 number)+ `snaps ≤ pushed` | +| `OBS-14` | **首帧即全量 `SNAP`(⛔ 无 N+1)** | (`snaps` / `pushed` / `subs` 三者关系) | `snaps ≤ pushed` 且 **`subs > 0 ⇒ snaps ≥ 1`**(有订阅者却一帧 `SNAP` 都没发 ⇒ 首帧走"逐台拉",判 FAIL) | +| `OBS-15` | **在线态表不撒谎 + 陈旧度** | `PRESENCE_STALE_P95_MAX_MS` / `PRESENCE_TTL_MS` | 活跃会话(`lastSeenAgoMs ≤ PRESENCE_TTL_MS`)**必须**被 `presence[]` 覆盖且 `online = true`;且 `p95(lastSeenAgoMs over devices>0)` ≤ `PRESENCE_STALE_P95_MAX_MS` | + +**阈值取值(同为参数行,⛔ 不是脚本魔数)** + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `MIN_HOSTS` | 2 | 台 | 实测 | S0 P2 `/status.online[]`(manager + w-106) | `online.length` | +| `MIN_TRUSTED_SIGNERS` | 1 | 个 | 实测 | S0 P2 `trustedSigners = 1` | — | +| `MIN_IDENTITY_OK` | 2 | 次 | 实测 | S0 P2 `identityOk = 2`(⛔ 不是 3:序③ 注释里的 3 是三台节点**当时**的口径) | — | +| `MAX_REVOKED_HOSTS` | 0 | 个 | 实测 | S0 P2 `revokedHosts = 0`+`/etc/dshs/revocations.json` 空清单 | — | +| `MAX_AUTH_FAILED` | 50 | 次 | 推导 | 累计值会随时间涨(⚠️ 探针自己也污染它,见交接单 §5 S6);取"远大于稳态自然增长、又能挡住暴力破解"的档 | 经验上界 | +| `PROBE_CODE_SET` | 200,401 | — | 实测 | S0 P7a:两个面在无凭据时回 `401`;带门户 Host 时回 `200` | `curl -w '%{http_code}'` | +| `PORTAL_CODE` | 200 | — | 实测 | S0 P2/S6:`curl -s --http1.1 -o /dev/null -w '%{http_code}' -H "Host: alotbuy.com" 127.0.0.1:3080/` | 同左 | +| `LISTEN_COUNT` | 79 | 行 | 实测 | ⛔ **已退役(序⑫ 集合判据替代)—— 仅对账用,不得再作为判据**。原 S0 P5:`ss -lntp \| wc -l`(与 443 单收口态**逐字一致** ✅;⚠️ 该值含"47 有活跃实例"那一档,故与无实例态恒差 `1` 层) | 同左 | +| `NFT_RULES` | 72 | 行 | 实测 | ⛔ **已退役(序⑫ 集合判据替代)—— 仅对账用,不得再作为判据**。原 S0 P5:`nft list ruleset \| wc -l`(与 443 单收口态**逐字一致** ✅;⚠️ **行数不是暴露面**:47 的 `input` 链**一条规则都没有**、policy=`accept`) | 同左 | +| `RELAY_RSS_MAX_KB` | 800000 | KB | 推导 | 由 §5 `MEM_BUDGET_MB` × 0.8 得 ≈ 800 MB(**不随 `MEM_PER_HOST_MB` 变**,故序⑥ 校准后仍保留该值)。⚠️ 原注"每 host 2 MB 假设的哨兵"**已作废**:实测斜率 46.7 KB/台 ⇒ 7515 台时预计 RSS ≈ `48078 + 46.7×7515` ≈ **390 MB**,本哨兵(781 MB)仍留 **2× 余量**;且因 per-stream 开销未测(§9),**运行期真正的操作约束就是本哨兵**,不再是 `--max-hosts` | `MEM_BUDGET_MB × 1000 × 0.8` | + +**序⑲ 新增阈值键(`OBS-13/14/15` 的取数来源)** + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `PRESENCE_STEADY_FRAMES_MAX` | 0 | 帧 | 实测(口径 = 交接单 §6 **E1**) | 稳态"变化驱动"⇒ 无状态变化则一帧都不推 | 判据本身(帧数,⛔ 不是时长) | +| `PRESENCE_SAMPLE_HITS_MIN` | 1 | 次 | 推导 | 探针自己连读两次 `/status` ⇒ `ΔstatusHits` **至少 1**;用来把"读不到"与"确实为 0"分开(本线反复踩的假绿) | 探针一次采样 = 1 次读 | +| `PRESENCE_SAMPLE_GAP_MS` | 3000 | ms | 推导 | 必须 **≥ 3 × `PRESENCE_BATCH_MS`**(否则批窗口还没过去,增量无意义) | `PRESENCE_BATCH_MS × 3` = 1000 × 3 | +| `PRESENCE_STALE_P95_MAX_MS` | 15000 | ms | 推导(= 心跳周期) | D3:允许 5–15 s 陈旧;心跳每 `HB_SEC` 刷新一次 `lastSeen` ⇒ 稳态陈旧度的**上界就是心跳周期** | `HB_SEC × 1000` = 15 × 1000 | + +> 🆕 **序⑫ 新增 4 键(`OBS-11` 集合判据的白名单,⛔ 取代 `LISTEN_COUNT` / `NFT_RULES`)** +> ⚠️ 值格**必须纯数字/纯 `地址:端口`**(夹注 ⇒ `NaN` ⇒ 假红);⛔ 值内**不得含 `|`、`*`、反引号** +> (探针 `cleanValue()` 会**剥掉 `*` 与反引号** ⇒ 写 `*:443` 会被改成 `:443` ⇒ 判据全红)。 + +| 键 | 值 | 单位 | 等级 | 来源定位 | 复算式 | +|---|---|---|---|---|---| +| `LISTEN_REQUIRED` | `0.0.0.0:22,[::]:22,0.0.0.0:80,0.0.0.0:443,0.0.0.0:888,127.0.0.1:3080,127.0.0.1:20080` | — | 实测 | 🆕 序⑫ S1:**必在集**(缺一条 ⇒ FAIL)。口径 = **平台工作必需 且 不受实例/端点在线态影响**的固定口。值按 47 上 `ss -lntp` **原文**逐条写;⚠️ `3080` 的口在 47 上是 `127.0.0.1:3080`(⛔ **不是** `0.0.0.0:3080` —— 原规划稿示例写错,以原文为准) | `ssh -p 22 bt-server "ss -lntp"` | +| `LISTEN_ALLOWED` | `0.0.0.0:58888,0.0.0.0:8765,127.0.0.1:15432,127.0.0.1:19100` | — | 实测 | 🆕 序⑫ S1:**允许集(非必在)** —— 出现合法、消失**不红**(只是某个可选项没开:宝塔面板口 / 管理 UI / 控制面 PG / 本机 worker agent) | 同上 | +| `LISTEN_ALLOWED_RANGES` | `127.0.0.1:20000-20999,127.0.0.1:21000-21999` | — | 实测 | 🆕 序⑫ S1:**区间允许集** = 两台 worker 的实例端口区间。⚠️ **span 取 1000**(⛔ 不是规划稿示例的 100):`dshs-worker` 单元实测 `DSHS_INSTANCE_PORT_BASE=20000`(47,经 `/proc//environ`)/`=21000`(106,经 `/etc/dshs-worker.env`)+ 两者 `DSHS_INSTANCE_PORT_SPAN=1000` ⇒ 并集 `[20000,22000)` 正好落在 relay 声明窗口 `[19000,22000)`(`--base 19000 --span 3000`)**之内**。**拨号池⛔不在此键内**(复用 `DIAL_POOL_BOUND` 派生,避免两处漂移) | `ssh -p 22 bt-server "tr '\0' '\n' < /proc/$(ss -lntpH 'sport = :19100' \| grep -oP 'pid=\K[0-9]+' \| head -1)/environ \| grep INSTANCE_PORT"` | +| `NFT_ALLOW_INBOUND` | `tcp:22,tcp:80,tcp:443,tcp:888,tcp:3080,tcp:58888` | — | 实测 | 🆕 序⑫ S4:**入站 accept 白名单**(`nft -j` 归一后 `acceptSet ⊆` 本键)。归一形态 = `:`;「无 dport 匹配的 accept 规则」归一为 `:any` —— ⚠️ 47 的 `input` 链**目前 0 条规则** ⇒ 集合为 ∅、本键暂无标记位;**将来若出现必须显式加进来**(⛔ 不许放宽判据) | `ssh -p 22 bt-server "nft -j list ruleset"` | + +> 📌 **成员归属判据(序⑫ S1 明文要求,防下一棒再吵)**: +> - **必在**(`LISTEN_REQUIRED`)= 它不在 ⇒ **平台本身坏了**(`sshd` / nginx `80`·`443` / 宝塔 `888` / 平台门户 `3080` / relay 回环口) +> - **允许**(`LISTEN_ALLOWED` + `_RANGES`)= 它不在 ⇒ 只是**某个可选项没开**(面板口 / 管理 UI / PG / worker agent / 实例档 / 拨号池) +> - 🔑 **"有/无实例态"⛔不编码进参数表**(那等于把"对状态敏感"这条缺陷写进单一来源 = 净退化,违 **R11**)⇒ 实例档直接进**允许区间**:出现不报、消失不红。 + +> ⚠️ **附注(序⑥ S3(b) 明文要求登记的 OBS 侧口径)**:relay `/status.sessions[].rttMs` 是**心跳往返** +> (含应用层处理与验签),**⛔ 不得当链路 RTT 用**,**⛔ 更不得据它下"跨云不可玩"的结论** —— +> 实测三方对比:ICMP 148 ms | TCP 握手 median 153 ms | relay 心跳 344–376 ms(**差 2.3×**)。 +> 真判据请用 `overlay-jitter.cjs --icmp/--tcp`。 + +--- + +## §7 待测项汇总(⛔ 一个都不许编数;**与表内 `待测` 单元格一一对应**) + +> 🔢 **计数口径(E1 判据)**:表内 `待测` 单元格 **4 个 → 0 个**(序⑥ 全部换成实测,见下表「结果」列)。 +> 第 **5** 条是「**推导值待校准**」(`MEM_PER_HOST_MB`)—— 序⑥ 已校准为 **实测 0.06 MB/台**。 + +| # | 键 | 换测条件 | 归属 | 序⑥ 结果 | +|---|---|---|---|---| +| 1 | `HOLE_PUNCH_RATE_LOCAL` | 3–5 台真机(分层:家宽 / CGNAT / 移动 / 企业网) | 序⑥ | ✅ **实测(分层)**:n=3 对,2/2 对可打洞(对端→本机 10/10);⚠️ 首选观察器不可用(云安全组拦 UDP 入站)⇒ 走第三方 STUN;**不代表家宽场景** | +| 2 | `PER_PLAYER_BW_LOCAL` | 同上 | 序⑥ | ✅ **实测**:9.8 KB/s/玩家(n=10)/ 3.9(n=50);聚合天花板 200–350 KB/s | +| 3 | `WAN_STEADY_THROUGHPUT` | 两端可控载荷(本次两个面在无凭据时只回 24–68 B) | 序⑥ | ✅ **实测**:352 KB/s(106→47)/ 12213 KB/s(47→106),5 样本中位数,经 relay,稳态段 30 s | +| 4 | `JITTER_LINK_MEASURED` | 同上;⚠️ 这是**游戏可玩性的决定项**(`调研…:118`) | 序⑥ | ✅ **实测**:`p95(|ΔRTT|)` = **3 ms ⇒ 达标**(限值 20 ms 属估值口径) | +| 5 | `MEM_PER_HOST_MB`(**推导值待校准**) | 压到 ≥ 20 台再量 RSS 斜率 | 序⑥ | ✅ **实测**:N=2..150 六点,斜率 **46.7 KB/台**,**R²=0.942**;原推导 2 MB **高估 36×** | + +> ✅ **序⑨ 收口(2026-09-17 14:xx)**:**无新增待测项**(表内 `待测` 仍为 **0** 个)。本轮新增的两个键 +> (`DRILL_SAMPLE_N` = 5 / `RELAY_GRACEFUL_BURST_MS` = 15000)**都是实测/推导值**,⛔ 不含 `待测`; +> `DRILL_POLL_MS` 由 2000 改 500 属**测量修正**(剔掉 ≤2 s 量化误差),⛔ 不是待测项换测。 +> 另:序⑨ 的四段分解登记在 **§9 第 9 行**(属"已知边界/实测记录",不进本表 §7 的"待测"口径)。 + +**合计:`待测` 4 项 → 0 项;待校准推导 1 项 → 已校准。(E1 判据达标)** + +> 🆕 **序⑦(2026-09-17)复算**:仍为 `待测` **0 项** —— 序⑦ 新增的 `RELAY_FAILOVER_*` 5 键**全部取默认值(= 源码真值)**,不产生新的「待测」;同时 §9 第 5 行(无失败切流)改判**已闭环**。⛔ §9 第 6 行(per-stream 内存)**仍未测**,仍是序⑦ 之后的回头条件。 +> +> 🆕 **序⑧(2026-09-17)复算**:仍为 `待测` **0 项** —— 序⑧ 新增 3 键(`RELAY_FAILOVER_EXEMPT` = 源码默认值 `1`; +> `DRILL_COOLDOWN_MS` / `DRILL_NO_SWITCH_OBSERVE_MS` = **演练脚本自用的推导值**)**均不产生「待测」**。 +> ⚠️ 两条**口径**要记住:① `DRILL_COOLDOWN_MS` 是**演练期覆盖值**,⛔ 不是生产值(生产恒为 `RELAY_FAILOVER_COOLDOWN_MS=300000`); +> ② 幕 4b 的结论**标注了非生产冷却值**方可复现(D9)。§9 第 5 行追加"冷却语义拆分已闭环";§9 第 6 行**仍未测**。 + +> 🔴 **回头条件(已执行,2026-09-17 序⑥ S7)**:`MEM_PER_HOST_MB` 与 `WAN_STEADY_THROUGHPUT` **双双换成实测** ⇒ §5.2 已**重算并重下发**两台中继的 `capacity.conf`(225 → 7515),三条校验已重跑,`OBS-02` 复验 PASS。⛔ 未触发项:无(两项都换成了实测)。 + +--- + +## §8 权限影响评估(R5;**只有产出,无动作**) + +> 交接单 §4.1-7 的**固定 6 列**:`对象 / 是否扩大权限面 / 扩大到哪一类 / 是否已可收窄 / 证据 / 结论`。 +> ⛔ **结论列只允许「收窄」或「维持」**;若某项确需扩大 ⇒ 停下报告(命中 R5)。 + +| # | 对象 | 是否扩大 | 扩大到哪一类 | 是否已可收窄 | 证据(代码行 / 命令原文) | 结论 | +|---|---|---|---|---|---|---| +| ① | **虚拟网卡驱动**(打洞所需) | **否(本单零动作)** | 若将来做 ⇒ **权限位**(管理员/内核态驱动) | ✅ 可收窄:**不做虚拟网卡**(纯 relay 中继形态已闭环),要打洞也只走 **UDP 用户态**(无驱动) | 本表 §3.2 `HOLE_PUNCH_RATE_LOCAL` = 待测;现网 2 节点全走 relay(`/status.networks[0].sessions = [manager, w-106]`);⚠️ 我方 relay 只做 TCP/WS,**代码里没有任何 TUN/TAP 调用**(`grep -rn "tun\|tap\|ip tuntap" src/net/relay/` = 0 命中) | **维持** | +| ② | **骨干节点开端口** | **否** | — | ✅ 已收窄:骨干成员**只拨出、不开入站**(worker 永远只拨出,106 入站 = 0) | 主单元 ExecStart 无 `--host` ⇒ 默认绑 `127.0.0.1`;实测 `ss -lntp` 中 `20080` 只出现在 `127.0.0.1:20080`;`覆盖网络_骨干层方案_20260916.md`「骨干不得被默认征用」 | **维持** | +| ③ | **`nft` 打洞规则** | **否(本单零动作)** | 若将来做 ⇒ **入站面**(放行 UDP 入站) | ✅ 可收窄:现实测 `nft list ruleset` = **72 行**且与 443 单收口态逐字一致 ⇒ 打洞规则**一条都没加** | S0 P5:`ssh -p 22 bt-server 'nft list ruleset \| wc -l'` → `72`;对比交接单 §6 E9 的收口态 `72` | **维持** | +| ④ | **第二中继机(L3 跨机真容灾)** | **否(已实施;R5 要求的"实施态"逐项如下)** | 若实施 ⇒ 入站面(新机新公网口)+凭据面(新节点密钥签发) | ✅ **已实施且两项都没扩大**:**新增监听口 = 0**(relay 仍只绑 `127.0.0.1:20080`;对外**复用 106 既有 nginx 的 443**,证书 = 本机**既有** Let's Encrypt(SAN 覆盖 IP `106.54.21.172`),⛔ 未下发任何证书/私钥)|**新增凭据 = 0**(relay 侧只需**公钥类**文件:`relay-keys.json`、`overlay-signers.json`、`revocations.json`;⛔ `node-*.key` 与 `overlay-signer-key.pem` **一律留在 47**)|**106"入站 = 0"这条已收窄成果**:443 本来就开着(宝塔 nginx),本次**只在既有 server 块内加一条 `location`** ⇒ `ss -lntp` 计数**未变** | `systemctl cat dshs-relay`(106)|`ss -lntp`|106 的 `extension/106.54.21.172/relay-b.conf` | **维持** | +| ⑧ | **临时 UDP 入站面**(序⑥ S4 打洞探测的观察器) | **是(临时,已关闭)** | 入站面(UDP 高位口) | ✅ **已收窄**:对象 = 47 的 `0.0.0.0:21100/21101`(`overlay-holepunch.cjs --observer`),**开放时长 ≈ 75 s**、进程退出即释放;关闭证据 = `ss -lunp \| grep 2110[01]` ⇒ **空**。⚠️ 实测该口**零收包**(连同机发出的都收不到)⇒ 47 的**云安全组拦 UDP 入站**(⛔ 不是 nft:`input policy = accept`)⇒ S4 改走第三方 STUN | `ss -lunp`(空)|本单 §8.4 E6 | **维持** | +| ⑨ | **106 的 443 relay 路由**(序⑥ S8 新增 `location /dshs-relay`) | **是(新增可路由路径,⛔ 未新增监听口)** | 入站**路径**面(不是新口) | ✅ 已收窄:只放行 `/dshs-relay` **一个端点**(⛔ 不写 `location /`、不复制站点任何路径;未知路径 404);relay 侧仍强制 HMAC +节点凭据(`identityRequired = true`) | 本单 §8.6 + `curl -H "Upgrade: websocket" … https://106.54.21.172/dshs-relay` = **101**|`/nope` = **404** | **维持(监听口零新增)** | +| ⑤ | **443 兜底入口**(复核是否真零扩大) | **否** | — | ✅ **已收窄**(相对 sshd 时代**净减 1 个公网口**) | 实测:`32022` 已回收(`ss -lntp` 无该口);443 由 **nginx 复用**(`ss -lntp` 显示 `0.0.0.0:443` + `nginx` 4 个进程),**未新增监听**;`/etc/dshs.env` 的 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 含 `relay-direct.alotbuy.com/dshs-relay`,而 `DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89` ⇒ 走的是**既有** 443 | **维持** | +| ⑥ | **relay 拨号白名单 `dialers`**(R5 引入) | **否** | — | ✅ **已收窄**:白名单是**准入收窄**(默认拒绝,⛔ 不是放开);且**构造时定型、运行期不可改** | `src/net/relay/server.ts:355` `private readonly dialers`;`:404` `normalizeDialers()` 在构造期;`:747` `const wantDialer = this.dialers.get(network)?.has(hostId) === true`(默认拒绝);drop-in `dialers.conf` = `Environment="DSHS_RELAY_DIALERS=ops:manager"`(只有 **1** 个拨号方) | **维持** | +| ⑦ | **每机独立密钥与信任根保管** | **否** | — | ✅ **已收窄**:私钥**只在本机**、信任根在**离线**签发;relay 侧只有**公钥**与**签名者集合** | `/etc/dshs.env` `DSHS_OVERLAY_NODE_KEY_FILE=/etc/dshs/node-manager.key`(**私钥路径仅本机**);drop-in `identity.conf` 里**只有** `DSHS_OVERLAY_ROOT_PUBKEYS=<公钥>` + `DSHS_OVERLAY_SIGNER_SET_FILE` ⇒ **私钥从不出现在 relay 的配置面**;`:399` `this.trustedSignerKeys`/`:401` `this.requireIdentity` 均 `readonly` | **维持** | + +**两条附注** +- ⚠️ 本表 §5.1 的「**每 host 0.06 MB**」是**空闲会话**的实测斜率 ⇒ 它不覆盖 per-stream 缓冲(见 §9 新登记行)。 +- ⛔ 本表口径仍成立:**零新增公网端口、零新增入站面、零凭据外发**;序⑥ 的两处"潜在扩大项"(④ 第二中继 / ⑧ 临时 UDP / ⑨ 443 路由)**逐条列在表内**,结论均为**维持**或**收窄**。 + +--- + +## §9 未纳入本表的已知边界(**登记,不改**) + +| # | 边界 | 为什么不动 | 归属 | +|---|---|---|---| +| 1 | 引导链缓存两支无"答出者"信息 ⇒ 切兜底有 ≤ `DIRECTORY_REFRESH_SECONDS`(300 s)**收敛期** | 交接单 §8.7④ 明确"本单把 300 s 收进参数表并写明该收敛期,⛔ 不改缓存结构" | 已闭环(本表 §4) | +| 2 | 106 的 agent 面不吃引导链(`DSHS_RENDEZVOUS_URL` 被当 relay URL 直接用) | 交接单 §2 P8:**只记录、⛔ 不许顺手撤**(撤掉 = `tunnel===undefined` 生产回归) | 若要改,归序⑥ | +| 3 | D2 字面判据不可满足(CF 泛解析) | 未做灰云记录 ⇒ 属"解析层也不经 CF"的更大改动 | 序⑥ 后候选 | +| 4 | relay 进程**没有** `MemoryMax`(只有本表的软口径 `--max-hosts`) | 加 cgroup 内存上限 = 改单元语义 + 可能 OOM-kill relay(新失败模式)⇒ **超出本单范围**,且 R11(不许净变差) | 序⑥ 候选(与 `MEM_PER_HOST_MB` 校准一起做)→ ⚠️ 序⑥ 只做了**测量与登记**,**仍未加** `MemoryMax`(同上理由,且实测后 `--max-hosts` 已非绑定约束) | +| 5 | 🔴 **无"失败切流"**(序⑥ E9 后半判红的**根因**):客户端把 relay url **在首次解析后钉死** —— `main.js --client` 无重解析;worker 走 `DSHS_RENDEZVOUS_URL` 同样不吃引导链;只有 Manager 的**拨号通道**有周期性重解析(`web/server.ts#refreshOverlay`),而它的换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关** | 修它 = **新功能**(需"连接失败后重解析 + 排除已失败 relay"),⛔ 属单外发现,只报告不动手(R7) | ✅ **已闭环(序⑦ · 2026-09-17)**:候选集不再退化成单点(`listOverlayRelayCandidates`);三处客户端(Manager 拨号 / worker 实例面 / `relay --client`)接同一个 `RelayFailoverSupervisor`;阈值见本表 §4 `RELAY_FAILOVER_*`。⛔ 服务端零改动(D8)**|序⑧:冷却语义拆分已闭环**(2026-09-17)—— 冷却表结构化(键仍按 url,新增 `kind`)+ `replace()` 增 `origin`(health 可一跳豁免 / directory ⛔ 不可)+ 豁免有界(每 url 每冷却周期一次)+ 双开关与判别器(`RELAY_FAILOVER_EXEMPT`、`exemptSwitches`、`|豁免` 日志标记)|真机判据 = 演练幕 4 系列(`--scene 4 / 4b / 4c`) | +| 6 | **per-stream 内存开销未测**:`MEM_PER_HOST_MB` = 0.06 MB/台 只是**空闲会话**斜率;带流量时每条流最多 `QUEUE_MAX_BYTES`(1 MB) 缓冲 | 需带流量的压测(S6 的已定口径是"每个声明 1–2 个端口的空闲会话") | 序⑦ 候选(**回头条件**:`--max-hosts` 若重新收紧,必须先有本数) | +| 7 | 106 的第二中继**不提供** `/dshs-overlay/bootstrap`(目录由 47 的 `127.0.0.1:3080` 签发;实测 3080 只绑回环、106→47:3080 `TCP_BLOCKED`) | 两条绕法都有代价:走 47:443(**同一失败域**,等于没增益)/搬证书私钥(命中 R5 扩大) | **已登记**;功能影响 = 0(客户端 `resolveOverlayRelay` ③ 对取不到的 origin `continue`,且 `relays[]` 首位是主入口) | +| 8 | 106 的 443 vhost 归**宝塔面板**管理:本次把 relay location 放进 `extension/106.54.21.172/*.conf`(面板重写 vhost 主文件**不会**丢它) | ⛔ 若将来面板重建该站点,需复查 `nginx -T \| grep dshs-relay` | 运维注意(已写进 `relay-b.conf` 头部注释) | +| 9 | **换址墙钟的四段分解**(序⑨ 实测 · 2026-09-17,n=5+n=5 真机样本,逐行对齐 journal 时间戳):**检测 15.0–16.6 s**(= `RELAY_GRACEFUL_BURST_MS` 地板 + 0–0.7 s 重试相位)+ **首试延迟 0.3–5.0 s**(`RELAY_FAILOVER_CHECK_MS` 相位 + 拨号耗时)+ **白等 12.02 s → 0.09 s**(修前 `waitUpOn` 对"必然失败的同机候选"吃满 `upTimeoutMs`;修后终态早退)+ **建连 2.7–5.2 s** ⇒ 总 **30.7–34.1 s → 20.9–23.9 s**(`RELAY_FAILOVER_DEADLINE_MS` 30000 内侧 5/5) | 白等段**已修**(`waitUpOnStatus` 终态早退,三处装配点共用一份);检测段 ⛔ **不改**(D3:15 s 的 burst 窗口是"计划内下线不是故障"的有意设计,改它 ⇒ 每次 relay 重启都切流) | ✅ **序⑨ 已闭环**(本表 §4 的 `RELAY_GRACEFUL_BURST_MS` / `RELAY_FAILOVER_UP_TIMEOUT_MS`;复算工具 = `scripts/overlay-failover-drill.cjs --trace`) | + +--- + +## §10 指纹 + +- **本节口径**(**推荐核对用**,可复现):**整个 §10 不计入** ⇒ 复核命令 + `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`13de5f9b77c486d71e5b83ec909b17b2`**(**序⑲ 执行棒收口值 · 2026-09-17 18:1x**;本轮变更 = 新增 §3.6 `PRESENCE_*` 五键 + §6 新增 `OBS-13/14/15` 三行与 `PRESENCE_STEADY_FRAMES_MAX` / `PRESENCE_SAMPLE_HITS_MIN` / `PRESENCE_SAMPLE_GAP_MS` / `PRESENCE_STALE_P95_MAX_MS` 四阈值键;上一版 = `8f08e74b026e6e5b5e1b3db813f031ae`(序⑰ 收口)→ `99e9e17b0c1ce0550e4bc7626a5a0494`(序⑨ 收口)→ `e6b669c257d8e8964273b3b400238351`(序⑧ 收口)→ `24cf2efdbcdcbe61267126ed65dba006`(序⑦ 收口)→ `9641f3d67fbc2e67cadf6f78e516f24c` → `44af9ea5ca15ae21f2604a9cd3a935b8` → 序⑥ 收口 = `db1317c2f7aaef7b47785c1f4fc9de03`) +- **全文件 md5**:请用 `md5sum 参数表_覆盖网络_20260917.md` 现取 —— ⛔ 此处**故意不内嵌数值**:它包含本节自身,写进去即刻失效(自指)。 + +--- + +## §11 补记(2026-09-17 11:1x,**在 §10 指纹口径之外**) + +- ✅ **"第 4/5 台真机来源"已拍板**(登记给 §9 第 5 行那条线的同批遗留):用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **本机模拟多台**,放弃"用户自备设备"与"新开云主机"。 +- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ **共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"无增量。 +- ⛔ **不改本表任何数值**:`HOLE_PUNCH_RATE_LOCAL`(分层实测 2/2)与其样本口径**保持原样**;本补记只登记"多实例可作第 4/5 台"这一环境决定。 +- 🔒 本补记位于 §10 之后 ⇒ **复核指纹 `db1317c2f7aaef7b47785c1f4fc9de03` 仍然有效**(后续引用无需换值)。 +- **谁能改这张表**:任何一次实测替换(`待测` 换真值 / `MEM_PER_HOST_MB` 校准)**都会改指纹** ⇒ 改完请**同步更新本值**,并在工作区日志里记一笔"哪个键从什么换成什么"。 diff --git a/dsh-server-docs/04-调整方案/118-会合中继拆分-取证与改造方案.md b/dsh-server-docs/04-调整方案/118-会合中继拆分-取证与改造方案.md new file mode 100644 index 0000000..29a548e --- /dev/null +++ b/dsh-server-docs/04-调整方案/118-会合中继拆分-取证与改造方案.md @@ -0,0 +1,284 @@ +# 会合 / 中继从 Manager 拆分 — 只读取证 + 可执行改造方案(2026-09-16) + +> 🟢 **状态:已完成使命 · 仅存档(2026-09-17 16:4x 由序 ⑯ 规划棒复核后标注)** +> **复核结论 = 判「不做」**:本方案 §4 的四个耦合点(C1–C4)与两步改造(S3 / S4)已**逐条**被后续序次覆盖,**未覆盖部分 = 无**。 +> C1 → 序② P0-2(`DSHS_RENDEZVOUS_URL` 优先、`DSHS_TUNNEL_TARGET` 降兜底,`src/config.ts:485-490`)|C2 → **R5**(Manager 只拨出、落点搬到本机回环池 `127.0.0.1:25000+`,`src/config.ts:498-500`)|C3 → S2/P2(`dsh_hosts.via` + `RendezvousRegistry`)|C4 → 本方案 §8.1 实测**前提不成立**|S3 → 本方案 §9.1 自我证伪后改「实例端口区间隔离」|S4 → R2(`dshs-relay` 独立单元)+ 序⑥ S8(106 升格第二中继)+ 序⑦(多实例切流实测)。 +> ⛔ **勿再按 §4 的 S0–S4 开工**。复核证据 = 工作区根 `交接单_在册收尾_20260917.md` **§0.1**(逐条对照表)。 +> ⚠️ 正文**不改**(历史档案属性)—— 上表即勘误入口。 + +> **来源**:`接续入口_覆盖网络线_20260916.md §2 第 2 条`(第一优先 · 真活)。 +> **本步只读**:⛔ 未改代码、未重启、未动服务器。全部结论来自 `D:\github\dsh_shenxian` 工作树(HEAD `4e3a1a4`)的源码静态阅读。 +> **为什么是前置**:中继绑在 Manager 上 ⇒ 多区域 / 多中心 / 骨干层**全部**做不了。这一刀不切,后面九大瓶颈方案里的中继容量、45% 中继规划都无从落地。 + +--- + +## 0. 结论先行 + +| 判定 | 内容 | +|---|---| +| **现状** | 「会合 + 中继」**不是一个组件,而是 Manager 主机上 sshd 的副作用** —— 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。 | +| **真问题** | 功能能跑(档案 103 已判"现有架构给出 80% 形状")。⚠️ **卡点是"耦合写死在 4 处",不是"协议不对"**。 | +| **因此第一步不是换协议** | ⛔ 不要一上来换 WireGuard / TURN。**先把会合地址与中继落点抽成配置与接口**,让 SSH 隧道退化成"第一个可替换实现"。**每一步独立可验、独立可回滚。** | +| **分层口径(已定,勿破)** | 会合 = 位置视图(可多实例、可无状态);中继 = 数据面(可多实例);**归属 / 租约 / 骨干资格 = 只能控制面写**(本方案不碰)。 | + +--- + +## 1. 取证:现状到底长什么样 + +### 1.1 链路(跨机形态,实测形态) + +``` +Worker 106 Manager 47 +┌────────────────────────────┐ ┌──────────────────────────────┐ +│ dshs-worker.service │ │ dshs.service (deployMode= │ +│ WorkerAgent :19000 (lo) │ │ cluster) │ +│ └─ SshTunnel ──── ssh -R ─┼───────────────▶│ sshd :32022 │ +│ 每实例 :PORT (lo) │ ControlMaster │ └─ 反转到 127.0.0.1:PORT │ +└────────────────────────────┘ │ RemoteSpawner → agentUrl │ + │ = http://127.0.0.1:19000 │ + ┌─────────────────────────▶ proxy.ts → out.host = 127.0.0.1:PORT + │ │ PostgreSQL :15432 (静态转发)│ + │ └──────────────────────────────┘ + 用户浏览器 → alotbuy.com → 47 +``` + +### 1.2 关键代码位(行号 = 本次阅读的 HEAD) + +| 角色 | 文件:行 | 事实 | +|---|---|---| +| 隧道实现 | `src/worker/tunnel.ts:96-120` | `ssh -M -N -f -S -i -R :127.0.0.1:` —— **两端同端口号** | +| 隧道加/减 | `src/worker/tunnel.ts:147` / `:163` | `ssh -O forward` / `-O cancel`,靠 **ControlMaster 复用同一条长连接** | +| 隧道自愈 | `src/worker/agent.ts:183-219` | `healTunnel()` 20 s 定时器 + 心跳里顺带 `-O check` | +| Worker 侧配置 | `scripts/switch-C-worker.sh:19-20` | `DSHS_TUNNEL_TARGET=root@47.77.182.89:32022`、`DSHS_TUNNEL_IDENTITY=/root/.ssh/tunnel_ed25519` | +| 隧道开关 | `src/worker/agent.ts:154` | `tunnelTarget === '' ⇒ 完全不建隧道`(同机形态零影响) | +| Manager 侧配置 | `src/config.ts:350-354` | `DSHS_CLUSTER_HOST_ID` / `_AGENT_URL` / `_AGENT_TOKEN` / `_INSTANCE_HOST` | +| 装配 | `src/web/server.ts:256` | `hostsProvider` 把 `dsh_hosts.endpoint` **直接当 `agentUrl`**,`instanceHost` 取全局 `clusterInstanceHost` | +| 访问面 | `src/supervisor/proxy.ts:109` | `out.host = 127.0.0.1:` —— 代理**写死 loopback** | +| 数据面契约 | `src/supervisor/remote-spawner.ts:24-34` | `ClusterHost{hostId, agentUrl, token, instanceHost}` —— **没有"经谁可达"这个字段** | +| 注册 | `src/web/routes/admin.ts:184-203` | `POST /api/admin/hosts`(join 脚本调用,幂等) | +| 心跳 | `src/worker/agent.ts:255-262` | `/healthz` 回 `{ok, hostId, instances, tunnel:{ready,ports}}` | +| 选机 | `src/web/server.ts:300-318` | 粘性优先 → 容量准入(`capacityMb<=0` 不限 / `-1` 禁用) | + +--- + +## 2. 四个耦合点(这是本方案的靶子) + +| # | 耦合点 | 具体表现 | 不拆的后果 | +|---|---|---|---| +| **C1** | **会合地址硬编码在 Worker 的 env** | `DSHS_TUNNEL_TARGET=root@47.77.182.89:32022` 写死在 `switch-C-worker.sh` + `/etc/dshs-worker.env` | 会合点换机 / 加第二台 ⇒ **每台 Worker 都要改 env 重启**;会合点不能独立部署 | +| **C2** | **中继落点命名空间 = Manager 的 loopback(且两端同端口号)** | `tunnel.ts` 用 `-R :127.0.0.1:`;`proxy.ts:109` 写死 `127.0.0.1` | ① 端口空间与 Manager **全局共享**(冲突面 = 所有 Worker 实例端口并集)② **第三台机器无法直达 Worker**(只有 Manager loopback 有那个端口)③ 中继不可多实例(多实例无从分配端口) | +| **C3** | **可达性登记把"经谁中转"磨掉了** | `dsh_hosts.endpoint` 存的是 `127.0.0.1:19000` —— 语义上**不是 worker 的地址,是 Manager 本地一个转发口**;`hostsProvider` 原样当 `agentUrl` | 换会合/中继组件时,表里无法表达「via(经哪个中继)+ 真实可达地址」⇒ **必须改表**,越晚改代价越大 | +| **C4** | **控制面 PostgreSQL 也走同一条隧道(静态转发)** | `DSHS_TUNNEL_STATIC_PORTS`(`src/worker/agent.ts:157`),设计上含控制面 PG | 会合组件一换,**控制面库的连接路径一起断** ⇒ 回滚面比看上去大 | + +--- + +## 3. 目标形状(分层,且**不新建权威状态**) + +| 组件 | 职责 | 可以多实例? | 权威状态 | +|---|---|---|---| +| **会合 (rendezvous)** | 收 Worker 注册、回"该拨谁"、下发中继分配;**不承载数据面流量** | ✅ 无状态可复制 | ❌ 只有位置视图 | +| **中继 (relay)** | 数据面:Worker 拨它 → Manager / 其他节点经它到 Worker | ✅ | ❌ | +| **控制面 (Manager)** | 归属 / 租约 / 骨干资格 / 容量准入 | ❌ **单点** | ✅ 唯一写入者 | + +**硬约束**:会合与中继**不得**写入 `dsh_instances.host_id` / `epoch` / 骨干资格 —— 否则就是双写脑裂(与 `集群化改造方案 §1.3` 一致)。 + +--- + +## 4. 分步方案(S0–S4 必做 · S5 后续) + +### S0 — 抽接口(**零行为变化**) + +- **改哪些文件** + - 新增 `src/net/rendezvous.ts`:`interface Rendezvous { id: string; dialTarget(): string; resolve(hostId): Promise }` + - 新增 `src/net/reachability.ts`:`interface Reachability { hostId: string; via: string; address: string; scheme: 'http'|'https' }` + - 改 `src/supervisor/remote-spawner.ts:24-34`:`ClusterHost` 增 `reachability?: Reachability`,`agentUrl` 降级为派生 getter(保向后兼容) +- **接口怎么切**:`RemoteSpawner` / `RemoteUserFs` / `proxy.ts` 三处**都只消费 `Reachability`**,不再各自拼 `http://127.0.0.1:`。 +- **怎么验**:`npm run build` 通过 + `node scripts/verify-cluster-cross.mjs` 全绿(**行为零变化**是唯一验收标准)。 +- **怎么回滚**:纯新增文件 + 可选字段,`git revert ` 即可。 + +### S1 — 会合地址出 env(打掉 **C1**) + +- **改哪些文件**:`src/worker/agent.ts:154-172`、`src/worker/tunnel.ts:26-65`、`src/config.ts:350-354` +- **接口怎么切**:新增 `DSHS_RENDEZVOUS_URL`(`ssh://root@47.77.182.89:32022` 或未来的 `https://...`);`DSHS_TUNNEL_TARGET` **保留为兜底**(未设 `DSHS_RENDEZVOUS_URL` 时走它)⇒ 两台机器可分先后改。 +- **怎么验**:改一台 Worker 的 env → `systemctl restart dshs-worker` → `curl -H http://127.0.0.1:19000/healthz` 里 `tunnel.ready === true`;Manager 侧 `verify-cluster-cross.mjs` 通过。 +- **怎么回滚**:删掉新 env 即在走旧路径(代码同时保留两条),**不需回滚代码**。 + +### S2 — `dsh_hosts` 增 `via` 列(打掉 **C3**) + +- **改哪些文件**:`src/db/adapter.ts:115-121`、`src/db/pg.ts:602-646`、`src/db/repo.ts:601-625`、`src/db/sqlite.ts:343-366`、`src/web/server.ts:256-266`、`src/web/routes/admin.ts:184-203` +- **接口怎么切** + - 迁移:`ALTER TABLE dsh_hosts ADD COLUMN via text NOT NULL DEFAULT 'manager-ssh'; ADD COLUMN address text;` + - 回填:把现 `endpoint` 里的 `127.0.0.1:19000` 拆成 `via='manager-ssh'` + `address='127.0.0.1:19000'`(**`endpoint` 列保留不删**) + - `hostsProvider`:**先读 `via` → 选对应 `Rendezvous` 实现 → 解析成 `Reachability`**;`via` 未设时回退 `endpoint` 原语义。 +- **怎么验**:脚本断言「迁移前 `agentUrl`」与「迁移后 `resolve()` 结果」**逐条相等**(这就是验收判据,不靠肉眼)。 +- **怎么回滚**:列有默认值 ⇒ 旧代码读 `endpoint` 完全不受影响,**列可留着不删**。 + +### S3 — 中继落点命名空间可配(打掉 **C2 的一半**) + +- **改哪些文件**:`src/worker/tunnel.ts:142-160`、`src/supervisor/proxy.ts:109` +- **接口怎么切**:新增 `DSHS_RELAY_LOCAL_NAMESPACE`(默认 `127.0.0.1`)⇒ 允许每条转发落在**独立回环地址**(`127.0.0.2`、`127.0.0.3`…),由会合**下发端口**而非"两端同号"。 +- ⚠️ **同号是 `endpointFor` 免映射表的前提** ⇒ 改不同号**必须**同时给 `ClusterHost` 加 `portMap`。所以本步**验收必须含两条端到端**,不能只看健康检查。 +- **怎么验**:① `verify-cluster-cross.mjs` 全绿 ② **跨机实例页能打开** ③ **跨机工作区文件能读能写**(文件面与实例面同一份路由,见 `server.ts:274-288` 的注释)。 +- **怎么回滚**:不设该 env 即回到"同号"原状。 + +### S4 — 中继独立成单元 + 会合可换机(打掉 **C2 的另一半 + C1 的尾巴**) + +- **做什么**:把"接收 Worker 反拨"这半边从 Manager 的 sshd 里搬出来 → **独立 systemd 单元 `dshs-relay.service`**(第一版仍用 sshd,但**独立端口 + 独立 `authorized_keys` + 独立系统账号**)。Manager 通过 `via` 指过去。 +- **为什么这样切**:**不换协议、只换绑定关系** ⇒ 已跑通的隧道逻辑 0 改动,风险面最小;换来的是"中继可换机、可多实例、Manager 不再兼任会合"。 +- **怎么验(三条,缺一不可)** + 1. 单中继实例下全链路通(实例面 + 文件面) + 2. **停掉中继** ⇒ 同机形态实例仍可用、跨机代理失败但**不崩**(验证"中继非必需依赖") + 3. 会合指向**第二个**中继实例,新 Worker 注册后能被 Manager 正确解析(验证多实例) +- **怎么回滚**:`dsh_hosts.via` 改回 `manager-ssh` + `systemctl disable --now dshs-relay`。 + +### S5 — 443/TCP 兜底通道(**后续,非本方案前置**) + +已定口径(异构设计:企业 / 校园网常封 UDP)。落地时挂到 **relay** 上做传输变体,不动会合接口。 + +--- + +## 5. 待现场复核(本轮只读,未在服务器执行任何命令) + +| # | 待复核项 | 为什么重要 | 若为否的影响 | +|---|---|---|---| +| 1 | 106 上 `DSHS_TUNNEL_STATIC_PORTS` 是否真含控制面 PG 端口 | 决定 **C4** 是否成立、回滚面是否含 DB | 仅影响回滚清单;S2/S3 不变 | +| 2 | `dsh_hosts` 里 `w-106` 那条 `endpoint` 的实际值 | 决定 S2 回填脚本的取值 | 需先取真实值再写迁移 | + +**下令复核的命令(只读,未执行)** +```bash +# 远程服务器 106 +systemctl show dshs-worker -p Environment +# 远程服务器 47 +sudo -u postgres psql -d dshs -c "SELECT id, endpoint, status, capacity_mb FROM dsh_hosts ORDER BY id;" +``` + +--- + +## 6. 风险与反模式 + +| 风险 | 处置 | +|---|---| +| 把会合做成"藏了权威状态的隐形控制面" | 会合只读位置视图;归属写入路径**一律**仍走 `db.claimInstance`(`epoch+1` fencing 不变) | +| 一步到位换传输协议 | ⛔ 反模式。S0–S4 全程**不换协议**,只换绑定与寻址 | +| S3 只看健康检查就收工 | ⛔ 端口映射一改,`endpointFor` 的隐含前提就变 ⇒ 必须跑通"实例页 + 文件读写"两条端到端 | +| 先改表再改代码(或反之) | `via` 列有默认值 ⇒ 顺序必须是**先加列 → 再改代码 → 最后回填**,任一步中断都不崩 | + +--- + +## 7. 交付边界(本步) + +- ✅ 产出:本方案(改哪些文件 / 接口怎么切 / 分几步 / 每步怎么验 / 怎么回滚)。 +- ⛔ **未做**:未改任何代码、未重启任何服务、未动 47 / 106。 +- **下一步**:按 S0 开工即可 —— 纯新增文件 + 类型扩展,零行为变化,是整条链里风险最低的入口。 + +--- + +# 8. 现场复核结论 + 方案勘误 + S0 执行记录(2026-09-16 13:1x 追加) + +> 本节的复核命令**全部只读**(`systemctl show` / `cat` / `ss` / `psql SELECT`),未改服务器任何状态。 +> 执行环境:本机 → `root@47.77.182.89`(22)+ `test106`(`~/.ssh/id_ed25519_test106`)。 + +## 8.1 §5 两项待复核 —— 已复核 + +| # | 项 | 实测结论 | +|---|---|---| +| 1 | 106 是否真含控制面 PG 的静态转发 | ❌ **不含**。106 `/etc/dshs-worker.env` **没有** `DSHS_TUNNEL_STATIC_PORTS`;47 的 worker env **也没有**(且 47 根本没设 `DSHS_TUNNEL_TARGET` ⇒ 同机形态**完全不建隧道**)⇒ `staticPorts` 只有 agent 自身端口 ⇒ **C4 在本形态下不成立,回滚面不含 DB** | +| 2 | `dsh_hosts` 里各条 `endpoint` 的实际值 | `w-106` = `http://127.0.0.1:19000`|`w-47` = `http://127.0.0.1:19100` | + +**47 端口面实测**(`ss -lntp`):`0.0.0.0:32022` sshd(会合点)|`127.0.0.1:19000` sshd=**106 的隧道落点**|`127.0.0.1:43937` sshd=**106 上那个实例的端口,同号反转**|`127.0.0.1:19100` node=w-47 的 agent|`127.0.0.1:15432` postgres。 +⇒ §2 的 C2「中继落点 = Manager loopback + 两端同号」**实测成立**;`/healthz` 亦证实 `w-106` 的 `tunnel.ports = [19000, 43937]`。 + +## 8.2 三处必须勘误(照字面做会出事故) + +**① 🔴 `proxy.ts:109` 不是中继连接目标 —— ⛔ S3 绝不能改它。** +`buildUpstreamHeaders()` 里的 `out.host = \`127.0.0.1:${port}\`` 是**发给上游 dsh 的 HTTP `Host` 头**,源码注释已写明原因:dsh 的 `/api` 信任栅栏要求 Host 是 loopback(或 `--trusted-host` 授权项),否则**每条 `/api` 调用都 403**。 +真正的连接目标在 **`proxy.ts:138-139`** 的 `{ host: endpoint.host, port: endpoint.port }`(来自 `endpointFor`)。 +⇒ S3 做「中继落点命名空间可配」时,**只动连接地址那一侧**;改动 `proxy.ts:109` 会让实例(不只跨机)的 `/api` 全量 403。 + +**② 🔴 两条 `endpoint` 字符串同形、语义不同 ⇒ S2 回填不能一刀切 `via='manager-ssh'`。** +- `w-47` → `127.0.0.1:19100` 是 **node 自己监听的端口**(同机直连)⇒ `via = 'local'` +- `w-106` → `127.0.0.1:19000` 是 **47 上 sshd 的隧道落点** ⇒ `via = 'manager-ssh'` + +§4 S2 原文只写了后者(把 `127.0.0.1:19000` 拆成 `manager-ssh`)⇒ **按行回填别按字面**。 + +**③ ⚠️ 47 的 cluster 配置在 systemd drop-in,不在 `/etc/dshs.env`。** +实际位置 = `/etc/systemd/system/dshs.service.d/cluster.conf`(`DSHS_DEPLOY_MODE=cluster` / `DSHS_DB_URL` / `DSHS_CLUSTER_*` 十条;`/etc/dshs.env` 里**没有** cluster 段)。§5 的回滚命令(删 drop-in → `daemon-reload` → `restart dshs`)**正确,无需修改**,只是换文件位置要照此。 + +## 8.3 S0 执行记录(**已落地本机 · 未部署**) + +| 文件 | 改动 | +|---|---| +| `src/net/reachability.ts` | **新增**:`Reachability` + `agentBaseUrl()` + **`agentBaseUrlOf()`(全仓唯一取址入口)** + `parseReachability()`/`toEndpoint()`(互逆)+ `VIA_LOCAL` / `VIA_MANAGER_SSH` | +| `src/net/rendezvous.ts` | **新增**:`Rendezvous` 接口 + `LocalRendezvous` + `ManagerSshRendezvous` + `RendezvousRegistry`(**S2 才接调用点**,本轮不消费) | +| `src/supervisor/remote-spawner.ts` | `ClusterHost.agentUrl` → **可选**、新增 `reachability?`;构造期归一化移除(移到取址处、幂等);`call()` / `touch()` 改走 `agentBaseUrlOf()` | +| `src/web/server.ts` | 两处 `agentFor` 改 `agentBaseUrlOf(h)` —— **类型收紧后必须一起改,方案 §4 S0 未列出** | +| `test/reachability.test.mjs` | **新增** 8 条断言:把**现网真实两行 endpoint** 写死为判据 ⇒「解析前后 agent 基址逐字相等」**不靠肉眼** | +| `package.json` | `test` / `verify` 挂上新测试 | + +- **验收实测**:`npm run build` RC=0 | `npm test` **44 项 / 0 失败**(新增 8 项全绿)。`lib/` 产物里已无 `host.agentUrl` 直接拼接。 +- **为什么未部署**:S0 零行为变化 ⇒ 部署**没有任何可观察收益**,却要 `restart dshs` 打断在线实例 ⇒ **与 S1 合并上线**(一次重启换一个真实变化)。⛔ 这不是"漏了部署"。 +- **有意未收敛的两处**(避免本轮动 5 个文件,留给 S2 与 `via` 列一起改): + ① `leased-spawner.ts` 的 `agentFor` 契约仍是 `{agentUrl, token}`; + ② `remote-user-fs.ts:57/73` 自己又做了一次 `.replace(/\/$/,'')`。 + 两处的取值**都已来自经过 `agentBaseUrlOf()` 的 `agentFor`** ⇒ **行为正确**,只是还没换成 `Reachability` 形态。 +- **回滚**:`git checkout -- src/supervisor/remote-spawner.ts src/web/server.ts package.json` + 删 `src/net/` + `test/reachability.test.mjs`(纯新增 + 可选字段,无数据迁移)。 +- **下一步**:S1(会合地址出 env,打掉 C1)—— 新增 `DSHS_RENDEZVOUS_URL`、`DSHS_TUNNEL_TARGET` 降为兜底,两台机器可分先后改。 + +--- + +# 9. 第二轮勘误(2026-09-16 15:2x · 本机 + 双机只读实测) + +> 本轮复核了 §4 的可执行性,**结论:S3 的机制不可用,须换做法**;另外 S1 的部署面在本方案里写漏了 47。 +> 操作层细节(命令 / 验收 / 回滚 / 清理)已落 **`交接单_覆盖网络落地执行_20260916.md` v2 §0 + §3**,本节只记**设计层的判断**。 + +## 9.1 🔴 S3 的 `DSHS_RELAY_LOCAL_NAMESPACE`(回环别名)**实测无效** —— 机制换成「端口区间隔离」 + +**实测**(本机 → 106 → 47,测完已清理):自 106 发起 +`ssh -R 127.0.0.2:19999:127.0.0.1:19000 -o ExitOnForwardFailure=yes root@47.77.182.89:32022` +⇒ 47 上落点实际是 **`127.0.0.1:19999`**(另有 `[::1]:19999`),**`127.0.0.2` 被静默改写**;ssh 侧**零报错**(`ExitOnForwardFailure` 未触发、日志为空)。 + +**根因**:47 `sshd -T` ⇒ **`gatewayports no`**(OpenSSH 默认)⇒ sshd 把远端转发**强制绑到回环**,**客户端指定的绑定地址被丢弃**。 + +⇒ **§4 S3 原设计(`127.0.0.2`、`127.0.0.3`… 独立回环别名)作废**,改做 **实例端口区间隔离**: +- 新增 `DSHS_INSTANCE_PORT_BASE` / `DSHS_INSTANCE_PORT_SPAN`,`findFreePort()` 改为**在区间内挑端口**; +- 各 worker 配**互不重叠**的区间(`w-47`=42000+,`w-106`=43000+)⇒ 同一台 Manager 的 `127.0.0.1` 上永不撞号; +- ✅ 副作用收益:**不需要 `portMap`**(不改"不同号")⇒ §4 S3 里"必须同时给 `ClusterHost` 加 `portMap`"**一并作废**;`src/worker/agent.ts` 的 `instanceHost` 也**不用改**。 +- ⛔ 若将来确需回环别名:只能开 `GatewayPorts clientspecified`,且**只能开在新建的 relay sshd 上** —— 开在主 sshd `32022` 等于让持隧道密钥者可把端口绑到 `0.0.0.0`,命中 **R5(权限只准收窄)**。 + +## 9.2 🔴 S3 的真实必要性上修为「**在修一个真 bug**」,不是"管道美化" + +`findFreePort()`(`src/supervisor/spawn.ts:53`)只在 **worker 自己那台机器** 上随机取端口(调用点 `src/supervisor/orchestrator.ts:595`)⇒ **两台 worker 取到同号是常态**; +而 `tunnel.forward()` 的返回值在 **`src/worker/agent.ts:303` 与 `:205` 两处都被忽略** ⇒ 撞号时 `-R` 失败但**静默**; +Manager 随后仍按 `127.0.0.1:` 拨 ⇒ **打到另一个用户的实例**。 +`w-47` 的本地实例与 `w-106` 的隧道落点**共享 47 的 `127.0.0.1` 端口空间** ⇒ **两台机器即可触发**。 + +## 9.3 🔴 S1 的部署面:本方案与 v1 交接单**都漏了 47** + +S0 的 `src/web/server.ts`、`src/supervisor/remote-spawner.ts`、`src/net/*` 跑在 **Manager(47)**;S2 的 `hostsProvider` 也在那儿。 +实测:**47 `/opt/dshs/lib/` 至今无 `net/`**,`config.js` 指纹 ≠ 本机 ⇒ **47 是 S0 之前的版本**;S1 只上了 **106(Worker)** 侧。 +⇒ 执行必须先做一步 **P1:把 47 对齐到 `640813e`**(零行为变化,风险最低),再上 S2。 + +## 9.4 ⚠️ S4 有**方案缺口**:中继一旦不在 Manager 主机上,"Manager 拨落点"就断 + +隧道落点在**接收方那台机器的 `127.0.0.1`**。中继换到别的机器 ⇒ 落点在**那台**机器上,Manager 拨不到。 +⇒ 需要**额外一跳**:① Manager 侧向中继建出向隧道(`ssh -L`,把中继回环落点搬到 Manager 自己的回环),或 ② 中继侧把落点暴露到**非回环**地址 + nft 收窄(暴露面变大)。 +**倾向 ①**(中继 sshd 的 `gatewayports no` 可保持不动 = 零权限扩大,且支持异地中继)。 +⇒ 本轮 S4 **只做同机 relay**;⛔ 原 §4 S4 验收第 3 条"会合指向第二个中继实例"**本轮不作判据**(那要先定这一跳)。 + +## 9.5 ⚠️ S2 减负:**不要加 `address` 列** + +`endpoint` 本身就是"要拨的地址",`via` 只补"经谁";再加 `address` = **同义双真相**(回填后必然逐字相等,违反单一来源)。⇒ §4 S2 的 `ADD COLUMN address text` **作废**,**只加 `via`**。 + +## 9.6 验收工具的现实约束(§4 未提,会直接卡住执行) + +`scripts/verify-cluster-cross.mjs`:默认 `MANAGER=http://127.0.0.1:13080`(**演练端口**,现役是 **3080**)、`AGENT_TOKEN=cross-machine-token`(现役 `dshs-worker-7f3a91c05e`)、需 `ADMIN_PW`; +且它**有生产副作用**(幂等注册 `w-106` 并把容量改 4096 / 新建 `crossuser*` 用户 / 在 106 拉真实例 / 可选注册 `w-106b` 并迁移实例)⇒ **跑完必须清理**;脚本头部注释"控制面 PG 在 106"**已过时**(PG 现在在 47)。 + +## 9.7 §8 三处勘误的复验结论 + +> ⚠️ 行号微差(本轮实测):§1.2 / §8 写的 `proxy.ts:138-139` 实测应为 **`136-137`**(`host/port` 两行);`admin.ts` 的注册路由实测在 **`:181`(`POST /api/admin/hosts`)**,不是 `:184`。**结论不变**,只是行号偏了一两行。 + +- **① `proxy.ts:109`** ✅ **再次确认**仍是"发给上游 dsh 的 `Host` 头",⛔ 不可改;连接目标在 **`proxy.ts:136-137`**(`host/port` 取自 `endpointFor`)。 +- **② 两条 `endpoint` 同形不同义** ✅ 确认(`w-47`=`local`/`w-106`=`manager-ssh`),别一刀切回填。 +- **③ 47 cluster 配置在 drop-in** ✅ 确认。 + diff --git a/dsh-server-docs/04-调整方案/119-集群化改造方案-Manager-Worker.md b/dsh-server-docs/04-调整方案/119-集群化改造方案-Manager-Worker.md new file mode 100644 index 0000000..c6afcfb --- /dev/null +++ b/dsh-server-docs/04-调整方案/119-集群化改造方案-Manager-Worker.md @@ -0,0 +1,1096 @@ +# DSH 平台集群化改造方案 —— Manager / Worker 模式 + +| 项 | 值 | +|---|---| +| 版本 | v0.1(草案,2026-09-14) | +| 目标 | 单机 `local` 模式 → **1 组 Manager(≥2 台,负载均衡 + 主备)+ N 台 Worker** 承载用户实例;支持实例跨 Worker 迁移 | +| 现状基线 | 生产 = 单机 `dshs`(systemd 127.0.0.1:3080)+ bwrap/uid/scope 隔离 + SQLite;实测宿主 1.8 G ⇒ **1–3 人** | +| 代码基线 | `D:\github\dsh_shenxian` @ `1d72e8f`(dsh `0.1.2-rc.1`,官方包零改动) | +| 性质 | **纯增量改造**,不触碰官方 dsh(R2);隔离机制与平台自研能力原样保留 | +| 归档 | ⚠️ 本文件是**草案**(放项目根)。定稿后再占号入 `dsh-server-docs/04-调整方案/`(档案只增不改,不宜冻结草案) | + +--- + +## 0. 一句话方案 + +> **Manager 只管「门户 + 路由 + 归属」,Worker 只管「跑实例」。** +> 归属用 **PG 租约(原子 CAS)**、数据放 **用户专属共享存储**、跨机流量走 **内网 + 白名单**。 +> 于是「迁移」= 改一行归属 + drain 一次进程,**不需要搬数据**。 + +**已有代码资产可直接复用(本次核对)**: + +| 资产 | 位置 | 复用方式 | +|---|---|---| +| `Spawner` 抽象 | `src/supervisor/spawner.ts`(头注释:route 层只依赖此接口) | 新增第 3 个后端,**路由层不改** | +| 代理「TCP 目标 / Host 头分离」 | `src/supervisor/proxy.ts:299` vs `:108` | **跨机代理零改动**(Host 头恒为 `127.0.0.1:`,通过 dsh 信任栅栏) | +| 栅栏判定「只看主机名不看端口」 | 官方 `packages/client/connection/src/api-request-trust.ts:103` + `proxy.ts:30 STRIP_HEADERS` | 转发端口 ≠ 实例端口也安全 | +| 租约语义 | `src/supervisor/leader.ts:200-216`(replace + resourceVersion 乐观并发) | **照抄**成 PG 版 | +| fencing | `src/supervisor/k8s-spawner.ts:112 stampFencing` | 照抄,加在每次实例操作上 | +| 跨机文件面 | `src/fs/k8s-user-fs.ts` + `src/web/file-service.ts`(每用户 sidecar) | **泛化**为 `RemoteUserFs` | +| 跨机端口暴露原语 | `src/tcp-bridge.ts`(`dshs tcp-bridge `) | Worker 侧一条命令暴露实例 | +| PG 后端 | `src/db/index.ts:19`(`DSHS_DB_URL` 非空即切) | **Manager 组多节点的硬前提**(见 §6 P0-1) | +| 每用户 uid 一致性 | uid = `baseUid + row_id`,由 DB 决定 | **跨机天然一致**,不需要 uid 分配协议 | + +--- + +## 1. 架构 + +### 1.1 角色划分 + +``` + ┌── 入口(保留:腾讯云 nginx + CF,443 / 通配证书) ──┐ + │ *.domain → Manager VIP / SLB │ + └──────────────────┬───────────────────────────────┘ + │ + ┌──────────────────────────────┴──────────────────────────────┐ + │ Manager 组(≥2 台) │ + │ 【门户层 · 双活】 dshs 全功能 web:登录/审核/门户/密钥/技能 │ + │ 【控制层 · 主备】 迁移 / 回收 / 归属租约续期(PG advisory) │ + │ 【代理层】 Host → 归属 → 实例(本机直连 / 跨机直连) │ + └───────┬──────────────────────────────────────┬──────────────┘ + │ 5432(内网) │ 9000-9999(内网 + nft 白名单) + ┌───────┴────────┐ ┌─────────┴─────────┐ + │ PG(共享状态) │ │ Worker 组(N 台)│ + │ 归属/租约/用户 │ │ 每台:worker agent│ + │ 会话/凭据/插件 │ │ + bwrap 实例 │ + └───────┬────────┘ └─────────┬─────────┘ + │ │ + ┌───────┴──────────────────────────────────────┴──────────────┐ + │ 共享存储(用户专属目录,位置无关):NAS RWX 或 对象存储 │ + │ /{home,ws} ← 任意 Worker 挂载即可接手 │ + └──────────────────────────────────────────────────────────────┘ +``` + +### 1.2 职责边界(**这条决定改造量**) + +| | Manager | Worker | +|---|---|---| +| 门户 / 认证 | ✅ 独占 | ❌ | +| **控制面数据库**(用户/会话/凭据/候选池/**归属与租约**) | ✅ **唯一写入者** | ⚠️ **不是"不许有数据库"** —— 见 §1.3:控制面数据只有 Manager 能写;Worker 完全可以有自己的库 | +| 归属租约(谁托管谁) | ✅ 唯一写入者 | ❌ 只被通知 | +| 实例进程生命周期 | ❌ 不 spawn 本地进程 | ✅ 独占(bwrap/uid/scope 全在本机) | +| 代理转发 | ✅ 唯一出入口 | ❌ 不直连公网 | +| 熔断 / 内存配额 / 插件探活 | ❌ 读 Worker 上报 | ✅ 本地自管(**保持现有语义不变**) | +| 文件读写(门户「我的文件」) | 转发 | ✅ 在本机(worker 有用户卷) | +| **实例业务数据**(插件库 / 会话日志 / 工作产出) | ❌ | ✅ **实例独占**(放 home,跟用户走) | + +### 1.3 数据分层(**2026-09-15 用户纠正:Worker 不是"不许有数据库"**) + +> 用户原话口径:**「不一定 worker 不连数据库,有些插件有可能有大量数据需要保存和管理、长期使用」**。 +> ⇒ 原表述「Worker ❌ 不碰 DB」**过窄**:它想说的其实只有一件事 —— **控制面数据(尤其归属)不能由 Worker 写**。 +> 插件的大量长期数据与这件事**根本不在同一层**,不该被这条规则挡住。 + +| 数据类 | 例子(都是实测存在的) | 唯一写者 | 落点 | +|---|---|---|---| +| **控制面元数据** | 用户 / 会话 / 凭据 / 插件候选池 / **归属与租约** | **仅 Manager** | Manager 的 PG | +| **实例业务数据** | `home/.dsh/mcn-plugin.db`(插件自带 SQLite)、会话日志、`.univer`、工作产出 | **Worker(该实例)** | **实例 home**(位置无关存储)→ 跟着用户迁移 | +| **Worker 运维数据** | 实例台账、指标、日志、心跳历史 | Worker 本地 | **Worker 本地库/文件**(SQLite 足够)——**不进控制面 PG** | +| **控制面数据的只读副本**(可选) | 某些插件需要用户/插件元数据 | 只读 | Worker 本地缓存 + 明确失效策略;**绝不写归属** | + +**三条判据(以后按这个判,别再用"碰不碰 DB")**: +1. **是"归属/租约"吗?** ⇒ 是:**只有 Manager 能写**(否则就是双写归属 = 脑裂)。 +2. **跟着用户走吗?** ⇒ 是:放**实例 home / 位置无关存储**,**不要**塞控制面 PG。 +3. **是本机运维用的吗?** ⇒ 是:放**Worker 本地库**,同样不进控制面 PG。 + +**为什么"大量数据 + 长期使用"不该塞控制面 PG**:会把控制面变成混合负载 ⇒ 备份窗口、锁竞争、扩容、故障半径全部耦合;而且业务数据量随用户数线性增长,会让控制面 PG 的容量规划被业务数据绑架。 + +**给插件的三种落点(按数据量与查询形态选)**: + +| 场景 | 落点 | 迁移行为 | +|---|---|---| +| 单用户、量不大、无需跨用户查询(**现状**:`mcn-plugin.db`) | 实例 home 里的 per-user SQLite | 跟 home 走,**天然迁移** ✓ | +| 单用户、量大、需要真 DBMS | 每用户一个 database/schema(Worker 本地 PG/MySQL,**数据目录放在位置无关存储上**) | 迁移 = dump/restore 或共享存储 | +| 跨用户聚合分析(运营看板之类) | **不要**在 Worker 上做 ⇒ 由 Manager 侧的离线汇总表承接 | — | + +**与 S3 实现的关系**:agent 目前**不需要**DB(`apiKey`/`uid` 由 Manager 随 launch 投递)—— 这是**权限收窄**选择(凭据不经网络下发 **vs.** 让 Worker 能读全量凭据库),符合 R5。 +但**这不妨碍** Worker 承载自己的数据库:插件的 per-user SQLite 一直在实例 home 里、由实例自己读写;将来接 DBMS 按上表落点即可,**与本设计的 Manager/Worker 边界不冲突**。 + +**关键设计选择:把「实例本地概念」留在 Worker 自管**(熔断、配额推导、崩溃退避、插件探活回滚),Manager 只读上报 —— 这样 `档案 34/58/74/78/81` 那批能力**一行不用重写**,这是本方案相对 k8s 路线最大的成本优势。 + +--- + +## 2. 网络拓扑 + +### 2.1 流量路径(4 条) + +| # | 路径 | 协议/端口 | 说明 | +|---|---|---|---| +| 1 | 用户浏览器 → 入口 nginx(腾讯云) | 443(CF 回源) | **不变**,通配证书 `*.domain` 仍在入口 | +| 2 | 入口 nginx → Manager 组 | 443 或 3080 | 经 VIP(keepalived)或云 SLB | +| 3 | Manager → Worker(**内网**) | 9000–9999 | 代理转发 + 实例控制 API(launch/stop/status) | +| 4 | Manager ⇄ PG | 5432(内网) | 归属租约 + 全部业务表 | + +### 2.2 端口矩阵 + +| 节点 | 端口 | 用途 | 暴露面 | +|---|---|---|---| +| 入口 | 80 / 443 | 用户入口 | 公网 | +| Manager | 3080 | dshs(门户+代理) | 仅入口 nginx 与内网 | +| Manager | 5432 出 | PG 客户端 | 内网 | +| Worker | **9000–9999** | ① 实例 tcp-bridge ② agent 控制口 | **仅 Manager 组 IP**(nft 白名单) | +| Worker | 22 | 运维 | 跳板机 | +| Worker | 443 出 | 模型 API(DeepSeek 等) | 出网(可收敛 CIDR) | + +### 2.3 防火墙(nft)三条铁律 + +1. **Worker 的 9000–9999 只允许 Manager 组 IP**;其余一律 drop(替代现在单机的 `portGuard` iptables owner-match 语义,见 §6 P0-2)。 +2. Worker **不监听公网**;实例仍只绑 `127.0.0.1`(dsh 硬禁 `--host 0.0.0.0`,官方 `startup.ts:74-76` 原文 *"it would expose remote code execution to the network"*)。 +3. Manager 之间只放 keepalived VRRP + PG + 代理端口,不留其它互通。 + +**✅ 现成的护栏可直接复用**:`src/supervisor/firewall.ts` 的 `createPortGuard()`(iptables OUTPUT owner-match)**在 Worker 上语义不变** —— 它防的是"本机其它 uid 直连实例端口",而实例仍以 `dsh-` 运行、桥仍由平台账号监听。⇒ Worker 侧本机护栏**零改动复用**,无需重新设计。 + +**控制口与代理口分离**(建议): + +| 端口 | 用途 | 策略 | +|---|---|---| +| 9000 | **控制口**(agent API:launch/stop/status/list) | 短超时(10s)、强鉴权(HMAC)、nft 只放行 Manager 组 | +| 9001–9999 | **代理口**(每实例一个 tcp-bridge) | 长超时(SSE/WS 不限时)、Host 头透传 | + +### 2.4 负载均衡与主备(按你的要求:≥2 台) + +| 维度 | 严格主备(active-standby) | **推荐:门户双活 + 控制主备** | +|---|---|---| +| 门户层 | 备机闲置 | 两台都服务,容量 ×2 | +| 控制层(迁移/回收/续租) | 主机独占 | **PG advisory lock 抢主**,同一时刻单写者 | +| 代价 | 浪费一台 | 多一个 advisory lock 与"控制任务幂等"要求 | +| 实现 | keepalived + VIP 漂移 | 同上 + 控制任务租约 | + +> **✅ 已定(2026-09-14 用户拍板):门户层「双活」,同时必须支持「单活」部署。** +> 实现要点:门户无状态 ⇒ **双活与单活是同一套代码,1..N 台都成立**,不需要为单活写分支: +> +> | 场景 | 行为 | +> |---|---| +> | N 台(双活) | LB/VIP 分发;控制任务靠 **PG advisory lock** 抢主,同一时刻单写者 | +> | **1 台(单活)** | 撤掉 LB,域名直指该机;advisory lock 自然由它持有 ⇒ **控制层零改动** | +> +> **单活只需注意两处(都不是新增代码)**: +> 1. **会话/门户状态必须落 PG**(已满足:会话查 DB、`cookieDomain` 跨子域)⇒ 否则切机即掉登录; +> 2. **长连接断开后前端要能自动重连**(WebSocket 由 Manager 代理持有;Manager 重启/VIP 漂移会断流)⇒ 需实测 dsh 前端重连行为,必要时由代理层注入重连脚本(现有 401 自愈注入机制「档案 50」可复用同一注入位)。 +> +> 状态机建议:`dsh_hosts.status` 同族的 `manager` 角色标记 + 一条 PG advisory lock,**禁止**任何"必须 2 台"的硬假设(例如配置里写死 peer 地址)。 + +**⚠️ 备案坑(已有实测记录)**:阿里云公网 SLB 对未在阿里云备案的域名返回 `403 Non-compliance ICP Filing`(`D:\github\dsh_shenxian\docs\k8s-deploy.md §7.4`)。⇒ **入口继续留在腾讯云 nginx,Manager 组只在内网/专线可达**,不要在阿里云侧直接对公网提供域名入口。 + +--- + +## 3. 数据模型 + +### 3.1 新增/扩展(迁移 v7) + +```sql +-- 主机(Worker)注册表:容量准入的依据 +CREATE TABLE dsh_hosts ( + id TEXT PRIMARY KEY, -- worker-01 + endpoint TEXT NOT NULL, -- 10.0.1.11:9000 + agent_token TEXT NOT NULL, -- 内部 HMAC 密钥(加密存储) + capacity_mb INTEGER NOT NULL, -- 该机可用内存预算 + used_mb INTEGER NOT NULL DEFAULT 0,-- 由心跳上报 + status TEXT NOT NULL DEFAULT 'up',-- up|draining|down + last_heartbeat BIGINT +); + +-- 归属(租约):一行 = 一个用户的 main 实例归属 +ALTER TABLE dsh_instances ADD COLUMN host_id TEXT; +ALTER TABLE dsh_instances ADD COLUMN epoch BIGINT NOT NULL DEFAULT 0; +ALTER TABLE dsh_instances ADD COLUMN heartbeat_at BIGINT; +ALTER TABLE dsh_instances ADD COLUMN lease_until BIGINT; +``` + +### 3.2 租约的原子抢占(**这是整套方案的承重墙**) + +```sql +UPDATE dsh_instances + SET host_id = $me, epoch = epoch + 1, heartbeat_at = now(), lease_until = now() + ttl + WHERE user_id = $u AND role = 'main' + AND (host_id IS NULL OR lease_until < now()); -- 过期才可抢 +-- 受影响行数 = 1 ⇒ 抢到;= 0 ⇒ 有人在管,退让 +``` +- 语义**照抄** `leader.ts:200-216`(replace + resourceVersion 乐观并发); +- 每次实例操作带 `expected_epoch`,不匹配即拒绝(fencing,参照 `k8s-spawner.ts:112`); +- 建议时序:**TTL 30 s / 续期 10 s / 重试 3 s**(不变量:`TTL > 2 × 续期`,否则自身抖动误判)。 + +--- + +## 4. 关键流程 + +### 4.1 冷启动(用户首次访问子域) + +``` +浏览器 → 入口 → Manager(代理层查不到归属) + → 租约抢到(affected=1) + → 选 Worker(容量准入:used_mb + 实例配额 ≤ capacity_mb) + → POST worker:9000/instances/launch {userId, folder, patch, epoch} + → Worker:ensure profile(本机)→ tcp-bridge 暴露 → bwrap+scope 起实例 → 回传 {port, launchToken} + → Manager 写归属(host_id=worker-01, port) + → 代理转发(Host 头仍 127.0.0.1:)→ 用户看到页面 +``` + +> ⚠️ **`launchToken` 必须回传**:本地模式是解析实例 stdout 拿到的(`Instance.launchToken` 注释),跨机后 Worker 要把它交给 Manager,否则「登录直达会话」(档案 06/13/15)与 401 自愈(档案 24/51)会失效。**这是 P0 功能缺口,别漏。** + +### 4.2 迁移(计划内) + +``` +admin POST /api/admin/instances/:id/migrate {targetHost} + ① 加迁移锁(PG advisory)→ 目标 Worker 预约容量 + ② drain:SIGTERM → 等落盘(≤10 s)→ 确认 sessions 已写盘(SIGKILL 仅兜底) + ③ 目标 Worker launch(数据在共享存储,无需搬运) + ④ 原子更新归属:host_id=目标, epoch+1 ← 旧 Worker 的 fencing 立即失效 + ⑤ 旧 Worker 确认实例已退、端口已释放 + ⑥ 观察 60 s(无崩溃)→ 迁移结束;失败则按 ④ 反向改回归属(回滚 = 改一行) +``` + +### 4.3 故障转移(计划外) + +``` +Worker 心跳超时(> TTL) + → 该机 status=down,其上所有归属的 lease_until 到期 + → Manager(控制主)逐个在新 Worker launch(epoch+1) + → 旧 Worker 若复活:fencing 拦下它的一切写入 ← 唯一防止「双写坏数据」的机制 +``` + +> 判定"对方已死"本质上不可靠(同 `R9` 的教训:无心跳时不得单方面接管)。**因此必须靠 lease TTL 硬等 + fencing 兜底**,并接受"复活窗口内可能有短暂双活"。 +> **自动转移的前置 = 共享存储**;未上共享存储前,只能做计划内迁移。 + +--- + +## 5. 实现细节(落地清单) + +| # | 改动 | 文件 | 量级 | +|---|---|---|---| +| 1 | 迁移 v7(`dsh_hosts` + 归属列) | `src/db/schema.ts` | 小 | +| 2 | PG 租约模块(抢/续/过期扫描) | `src/supervisor/lease.ts`(新) | 中 | +| 3 | `RemoteSpawner implements Spawner` | `src/supervisor/remote-spawner.ts`(新) | 中 | +| 4 | Worker agent(复用 `LocalSpawner`,内部 HTTP) | `src/cli.ts` 加 `dshs worker`、`src/worker/agent.ts`(新) | 中大 | +| 5 | 选机(容量准入) | 并入 #2/#3 | 小 | +| 6 | 文件面泛化(复用 file-service) | `src/fs/k8s-user-fs.ts` → `remote-user-fs.ts` | 小 | +| 7 | **模型落地层改造**(现在直写主机 home) | `src/web/server.ts:132 writeHomeFile` / `:165 landModels` | **中(必须改)** | +| 8 | **角色补丁 / picker**(主机侧脚本) | `orchestrator.ts:248 ensurePickerProfile`、`ensure-role-profile-patch.cjs` | **中(必须改)** | +| 9 | Worker 上报(熔断/配额/状态) | agent 暴露 `breakerInfo`/`quotaInfo` | 小 | +| 10 | admin 迁移/主机管理 API | `src/web/routes/admin.ts`(同族 `/api/admin/users/:id/dsh/*`) | 小 | +| 11 | 部署基线(同镜像 + nft + bwrap + uid) | 新装机脚本 | 中 | +| 12 | 路由 | `proxy.ts` **不改** ✅ | 0 | + +**Worker 数量 → 容量(按现有配额实测值:实例 384–672 MiB)** + +| 8C16G Worker | 并发活跃实例(保守 672 MiB) | 并发活跃实例(典型 384 MiB) | +|---|---|---| +| 每台 | **12–14** | 20–24 | + +| 目标 | 目标并发 | 所需 Worker | 说明 | +|---|---|---|---| +| 50 人试点 | 15 | **2–3 台** | 含 1 台冗余 | +| 300 人 | 60–90 | 6–8 台 | 20–30% 同时在线 | +| 1000 人 | 200–300 | **16–25 台** | 需配合分钟级空闲回收 | + +--- + +## 6. 潜在问题点(P0 / P1 / P2) + +### P0(不解决就不能上) + +| # | 问题 | 后果 | 应对 | +|---|---|---|---| +| P0-1 | **Manager ≥2 台 ⇒ PG 是硬前提**。SQLite 放共享存储多写者不安全 | 组不了 Manager 组 | 切 PG 只有一个环境变量(`db/index.ts:19`),但**要真做数据迁移与回归**(`db/pg.ts` 508 行,本项目从未在生产启用 —— 档案 19 §C8) | +| P0-2 | **实例端口对内网暴露 = 权限扩大(触 R5)** | 同 VPC 任意主机可直连实例端口(只剩 dsh 自身 token/cookie 一层) | nft 只放行 Manager 组 IP + 桥只绑内网网卡 + `tcp-bridge` 加共享密钥校验;**先出权限影响评估** | +| P0-3 | **脑裂双写同一 home** | 会话日志 append 冲突 → **数据损坏** | 租约 + fencing(§3.2/§4.3);宁可放弃自动转移,也不裸奔 | +| P0-4 | **模型落地层直写主机 home**(`writeHomeFile` + chown) | 档案 87「模型设置」在集群下**整块失效** | 改为经 Worker agent 写(或写共享存储后由实例读) | +| P0-5 | **角色补丁 / picker 是主机侧脚本** | 非 admin 隐藏模型分区、目录选择器收敛失效 | 并入 Worker launch 流程(Worker 本机执行,逻辑不变) | +| P0-6 | **`launchToken` 回传缺失** | 直达会话、401 自愈失效(档案 06/13/24/51) | Worker 必须把 token 交 Manager(协议字段) | +| P0-7 | **文件面跨机**:`userFs` 直读本机 `dataRoot/users//ws` | 门户「我的文件」对非本机用户 404/空 | 复用 `file-service`(已有实现)或共享存储直读 | + +### P1(影响可用性/运维) + +| # | 问题 | 应对 | +|---|---|---| +| P1-1 | **熔断/退避/配额是进程内存态**(`LocalSpawner`) | 留在 Worker 自管(推荐,零重写);但 Worker 重启即失忆 ⇒ 需接受"熔断态随重启清零"或落本机轻量状态文件 | +| P1-2 | **实例随机端口** → 防火墙难写 | 收敛到固定段(9000–9999),`findFreePort()` 限定范围 | +| P1-3 | **容量准入缺失**:现在只有本机 `maxIdleInstances` | Manager 按 `dsh_hosts.capacity_mb` 选机,否则某台被打满 | +| P1-4 | **长连接集中在 Manager**(WebSocket 全由代理持有) | 按连接数扩容 Manager;监控单副本连接数上限 | +| P1-5 | **时钟漂移影响租约** | 全集群 NTP;TTL 留足余量(≥2×续期) | +| P1-6 | **日志/可观测分散到 N 台** | 集中日志(Loki/SLS)+ `/healthz` 聚合;现有单机 journald 不够 | +| P1-7 | **备份窗口随用户数膨胀**(见 §7) | 分层备份 | +| P1-8 | **Worker 升级需同基线**(bwrap / `/etc/passwd` 白名单 / 镜像) | 统一镜像 + 灰度;`/etc` 白名单是运行时实测最小集(档案 39),换发行版会失效 | + +### P2(可延后) + +| # | 问题 | +|---|---| +| P2-1 | uid 是否需要在每台 Worker 建 OS 账号 —— 代码走 `setpriv --reuid <数字>`,但 `/etc/passwd` 被绑入沙箱(`orchestrator.ts:640`,为 `os.userInfo()`)⇒ **需 5 分钟实测确认**(若不建号也可,uid 分发零成本) | +| P2-2 | 迁移期间用户正在对话的体验(drain 10 s 内断连) | +| P2-3 | 单用户跨机后 `portGuard`(iptables owner-match)语义变化,需重做等价护栏 | + +--- + +## 7. 备份方案(回答「有没有比文件备份更高效的方式」) + +### 7.1 现状(实测) + +- `/opt/dsh/backup.sh`(入库 `scripts/backup-platform.sh`):**每周日 05:00 全量** tar.gz + 60 天保留;含 `var/lib/dshs` + env + systemd + artifacts + nginx vhost;SQLite 用 `.backup` 一致性快照; +- 实测首跑 **8.7 MB / 6919 条目**(用户数还少)—— 现在全量 tar 毫无压力,**问题在用户数上去之后**。 + +### 7.2 五种方式的对比(**结论:单靠文件备份不是最高效的**) + +| 方式 | 机制 | CPU/IO 成本 | 优点 | 缺点 | +|---|---|---|---|---| +| ① 全量 tar(现状) | 每次重打包 | 高(随数据量线性增长) | 简单、可读 | 全量重传;**数千小文件(`node_modules`)是 tar 最慢的部分** | +| ② rsync 增量 | 按 mtime/size 比对 | 中 | 差量传 | 需目标端为文件系统;无去重、无加密、版本管理弱 | +| ③ **restic / borg 去重增量** | 内容分块去重 + 快照 | 中低 | 增量 + 去重 + 加密 + 保留策略,天然适合小文件与历史版本 | 需目标为 S3/OSS 或本地仓库;首跑要建立索引 | +| ④ **存储层快照(最省)** | NAS / ESSD 快照 | **近零**(不经过应用) | 秒级、卷级原子、增量块级 | 粒度是**卷**不是用户;恢复单用户要挂快照再拷 | +| ⑤ **只备「不可重建」子集** | 选择性打包 | **最低** | 体积可砍掉大头 | 需要明确"什么可重建" | + +### 7.3 推荐:分层备份(组合 ④ + ③ + ⑤) + +| 层 | 对象 | 方式 | 频率 | 恢复目标 | +|---|---|---|---|---| +| L1 | 共享存储卷(全部用户数据) | **NAS/ESSD 自动快照** | 每日(保留 7–30 天) | 整卷回滚(分钟级) | +| L2 | 关键子集 → 对象存储(异地) | **restic 增量 + 去重** | 每日 | 单用户/单文件恢复 | +| L3 | DB | **PG 物理备份 + WAL 归档(PITR)** | 连续 | 任意时间点 | +| L4 | 平台配置(env/systemd/nginx/artifacts) | 现有 tar(体积小,保持全量) | 每周 | 重建平台 | + +**L2 的「关键子集」= 只备不可重建的部分(这是最大的效率来源)**: + +| 备份 | 不备份 | 理由 | +|---|---|---| +| `sessions/`(对话历史)、`.credentials.yaml`、`skills/`、`ws/`(产出物) | **`profiles/web/node_modules`** | 数千小文件、体积最大,且**可由「`dsh.profile.bundles` 清单 + 镜像版本」完整重建** | +| 平台注入的 patch / picker / 模型落地文件的**清单** | 其**内容** | 由代码 + DB 重建(`renderPatch` / `landModels` 幂等) | +| — | 各类缓存(`.cache` / 临时产物) | 无价值 | + +> 体积预估:排除 `node_modules` 后,单用户数据通常从数百 MB 降到 **数 MB–数十 MB** ⇒ L2 的窗口从"小时级"回到"分钟级"。 +> **必须保留**:`credentials`(不可再生成)与 `sessions`(对话不可重建)**一律备份,不要省**。 + +**附加要求(现状已有先例)**:备份**必须配恢复演练**(对照 `02-运维手册 §C.8` 的演练记录)+ 迁移/备份期间避免"实例正在写 sessions 时取半写快照"⇒ 优先用**存储层快照的原子性**,其次 drain 后备份。 + +--- + +## 8. 落地成本 + +### 8.1 机器(量级参考,需以实际报价核) + +| 角色 | 规格 | 数量 | 说明 | +|---|---|---|---| +| 入口 | 现有腾讯云机 | 1 | 复用,不新增 | +| Manager | 4C8G | 2 | 双活门户 + 控制主备;主要是代理长连接的带宽/连接数 | +| Worker | 8C16G | 见 §5 表(2–25 台,随目标并发) | 主要成本项 | +| PG | 4C8G / 复用现有 RDS | 1(HA 则 2) | 共享状态 | +| 共享存储 | NAS(或 ESSD+快照) | 按用户数据量 | 容量 + 快照存储费 | + +### 8.2 工程改动面(**比人日更可靠的口径**) + +| 类别 | 内容 | 判断 | +|---|---|---| +| 新组件 | worker agent、`RemoteSpawner`、租约模块 | 3 个 | +| 必须改的现有能力 | 模型落地层、角色补丁/picker(P0-4/5) | 2 处 | +| 数据层 | PG 切换(本项目**从未在生产用过**)+ 迁移 v7 | 高风险项 | +| 不需要改 | 代理层、路由层、隔离机制(bwrap/uid/scope)、熔断/配额/插件探活 | ✅ 本方案最大收益 | +| 运维新增 | 装机基线、nft、集中日志、租约监控、备份四层 | 中 | + +> **参考量级**(仅表示规模,不作为承诺):一个可上线的第一版 ≈ 一次性投入数周(含 PG 真机回归);之后每加一个能力(自动转移、弹性调度)都是独立增量。 + +### 8.3 三阶段路线 + +> ⚠️ **分期必须互相兼容**(一期做完,二期只是加机器/加开关/加运维,**不重写代码**)⇒ 约束、兼容矩阵、反模式与一期 DoD 见 **§14**。 + +| 期 | 内容 | 出口验收 | +|---|---|---| +| **一期(可用)** | **1a**:PG 切换 + 归属/租约 + worker agent + `RemoteSpawner` + 2 台 Manager(其中 1 台兼任 Worker-01,**现有数据原地不动**)→ **1b**:加 Worker-02 + NAS,**只做计划内迁移**(渐进路径见 **§15.3**) | 50 人真实使用;kill 一台 Manager 门户不中断;管理员迁移一个用户成功且会话无损 | +| **二期(稳)** | 文件面跨机、容量准入、集中日志、四层备份、灰度升级 | 300 人;单 Worker 可下线维护;备份恢复演练通过 | +| **三期(弹性)** | 心跳故障转移、自动回收、容量自动扩缩、可观测告警 | 1000 人;Worker 故障 5 分钟内自动恢复 | + +### 8.4 回滚 + +| 变更 | 回滚方式 | +|---|---| +| 归属/租约(v7) | 新增列/表,不影响旧代码路径;`deployMode=local` 单机模式**保留可用**(代码不删) | +| PG 切换 | 保留 SQLite 路径;PG 出问题可切回单机(需数据回灌,**这是最大风险点,二期前必须演练**) | +| Worker 扩容 | 缩容 = 迁走用户 + `status=draining` | +| 单次迁移失败 | 归属改回原 Worker(改一行 + epoch+1) | + +--- + +## 9. 决策状态(2026-09-14 更新) + +| # | 事项 | 状态 | 结论 | +|---|---|---|---| +| 1 | Manager 组形态 | ✅ **已定** | **门户双活 + 控制主备**,且**支持单活部署**(同一套代码,1..N 台)→ 见 §2.4 | +| 2 | 共享存储选型 | ✅ **已定方向** | **平台侧做成「存储后端可插拔」**:本地盘 / 自建 NFS / 云 NAS / 云块存储都能接 → 详解见 **§12** | +| 3 | PG 承载方式 | ✅ **已定方向** | 一期**自建**(独立机 + 四层备份),300 人后再评估迁 RDS → 详解见 **§13** | + +> 定项以外的技术细节(端口段、TTL 数值、对账周期、备份频率)由实现侧自主决定,不再上抛。 + +## 10. 动工前必须先做的 5 分钟实测(把未知数清掉) + +> ✅ **本清单已于 2026-09-14 21:17–21:25 在真实两台服务器上执行完毕** ⇒ **结果与结论见 §17**(第 1、2 项已有答案,第 3–5 项依赖共享存储,尚未做)。 + +| # | 验证项 | 判据 | +|---|---|---| +| 1 | 跨机代理:实例经 `tcp-bridge` 暴露后,用带 `Host: 127.0.0.1:<非实例端口>` 的请求打 `/api/*` | `403` = 栅栏拒(理论被推翻)/ `401` = 栅栏放行(理论成立) | +| 2 | uid 是否需 OS 账号 | 不建号直接 `setpriv --reuid <数字>` 能否起实例 + `os.userInfo()` 是否报错 | +| 3 | 共享存储上的 watcher | `skill-filesystem.watchUsePolling=true` 后 skill 目录事件正常;credentials 热重载是否失效(最坏只需重启) | +| 4 | 共享存储冷启动耗时 | 一个真实 home(含 node_modules)从 NAS 起的冷启动 vs 本地盘,差多少 | +| 5 | 每用户数据体积 | 实测 `sessions/skills/ws`(含 node_modules)各占多少 ⇒ 定 L2 备份子集与 NAS 容量 | + +--- + +## 11. Manager ⇄ Worker 连接保障与故障恢复(可靠性核心) + +### 11.1 先立一条设计原则:**连接不是关键路径** + +> **权威归属在 PG、用户数据在共享存储** ⇒ 「连接断了」本身**不会损坏数据**,最坏只是"暂时访问不到"。 +> 所有可靠性设计都服务于这一条:**宁可短暂不可用,不可双写。** + +### 11.2 连接形态:单向、Manager 主动拨入 + +| 通道 | 方向 | 协议 | 用途 | 超时 | +|---|---|---|---|---| +| 控制通道 | Manager → Worker:9000 | HTTP + HMAC | launch / stop / status / **list** / drain | 10 s | +| 心跳通道 | Manager → Worker:9000 | HTTP `GET /healthz` | 存活 + 容量 + **fencing 广播**(见 §11.5) | 2 s | +| 数据通道 | Manager → Worker:9001-9999 | HTTP + WebSocket | 用户流量代理 | 常规 30 s;SSE/WS 不限 | + +**Worker 不反向连接 Manager、不直连 DB** ⇒ Worker 实现简单、无需知道 Manager 地址、控制口是它唯一的"被拨入口"。 + +**为什么心跳用「Manager 拉」而不是「Worker 推」**:① Worker 无需 DB 依赖(保持 §1.2 边界);② 多台 Manager 各自拉 = 天然冗余探测;③ 避免 Worker 侧维护"往哪个 Manager 推"的状态。 + +### 11.3 连接"确保"的六条工程手段 + +| # | 手段 | 具体做法 | 依据 | +|---|---|---|---| +| 1 | **网络层** | 同 VPC 内网;跨可用区走内网固定 IP;跨地域必须 WireGuard/专线,**不得公网明文** | — | +| 2 | **半开连接防护**(**必须做**) | 控制通道 `keepAliveTimeout` + request timeout;代理通道复用现有做法:**连接出错 → `agent.destroy()` + `new Agent()` → 短连接重试一次** | `proxy.ts:307/450-455` 记的 2026-09-09 事故:keep-alive 池缓存已回收实例的 socket → **请求永久挂起**。跨机后 NAT/防火墙会静默丢空闲连接,**这个问题只会更严重** | +| 3 | **幂等键**(**必须做**) | 每个控制请求带 `operation_id`(= `epoch` + 序号);Worker 保留最近 N 个并**回放上次结果** | 解决"Manager 发出去了但响应丢了"——否则超时重试可能起两个实例。Worker 侧按 userId 幂等(已运行则返回现有实例信息),与 `LocalSpawner` 的 `AlreadyRunningError` 语义对齐 | +| 4 | **鉴权 + 最小接口** | `X-DSH-Agent-Token`(HMAC,密钥加密存 `dsh_hosts.agent_token`);agent **只接受白名单动作**,参数受限(folder 必须在该用户根下、patch 有长度上限) | **安全红线**:agent 若接受任意命令,Worker 沦陷 = 全集群沦陷 | +| 5 | **重试与退避** | 控制请求重试 3 次(200 ms / 1 s / 3 s),仍失败则标记该 Worker 异常并告警 | 区分"抖动"与"真故障" | +| 6 | **连接池容量** | `maxSockets` 按 `Worker 数 × 平均实例数` 调整(当前 32) | 现状值是按单机定的 | + +### 11.4 故障恢复矩阵(**10 个场景**) + +| # | 场景 | 检测方式 | 恢复动作 | 用户感知 | +|---|---|---|---|---| +| 1 | 控制请求超时(网络抖动) | 请求超时 | 幂等重试 | 无(冷启动略慢) | +| 2 | Worker 心跳连续失败,**但未过 TTL** | 3 次 `/healthz` 失败 | **只告警,不接管** | 无 | +| 3 | Worker 失联 **超过 TTL** | 租约过期 | 先尝试 stop(网络若恢复);确认不可达后 → 新 Worker 用 `epoch+1` 重启;旧实例若复活被 fencing 拦下 | **约 30–60 s 不可用**(TTL 30 s + 冷启动) | +| 4 | Worker 计划内维护 | 人工置 `status=draining` | **先迁后停**:逐个 drain + migrate,全部迁完才停机 | 每次迁移数秒 | +| 5 | Worker agent 崩(机器/OS 正常) | 心跳恢复但实例列表为空 | **对账**:归属在该机但实例不在 → 按会话活跃度决定重拉或删归属 | 冷启动延迟 | +| 6 | Manager 挂 1 台 | LB 健康检查 | 另一台接管(门户双活);控制主经 advisory lock 自动转移 | 无(长连接断开,前端自动重连) | +| 7 | **Manager 全挂** | LB 无后端 | Worker 上**实例继续运行**(无人代理 = 不可访问);Manager 恢复后从 DB 读归属 → 逐个 Worker 核对 → 重新暴露 | 门户中断期间全员不可用 ⇒ 所以 2 台 Manager 要**跨可用区** | +| 8 | 实例进程崩(Worker 本地) | Worker 本地检测 | **Worker 自愈**(现有退避 + 熔断 + 插件自愈),Manager 不参与 | 与现状一致 | +| 9 | **PG 不可用** | 连接失败 | 拒绝新冷启动;**已代理流量继续转发**(依赖归属缓存,建议 TTL 60 s) | 新用户进不来,在线用户不受影响 | +| 10 | 网络分区(脑裂) | fencing 校验失败 | 老 holder 的一切操作被拒;新 holder 接管;分区恢复后自动对账 | 分区侧请求失败,恢复后收敛 | + +### 11.5 心跳的三个职责(**别只当存活检测**) + +1. **存活**:连续失败计数 → 决定是否标记 `down`; +2. **容量上报**:`used_mb` / 实例数 → 供选机(§5 容量准入); +3. **fencing 广播**(关键):Manager 在心跳响应里下发该机的 `expected_epoch` 清单。Worker 发现自己持有的实例 **epoch 落后** ⇒ **主动停掉自己那个实例(self-fencing)**。 + +> ⇒ **它是"防双写"的最后一道防线**:即使 Manager 判定错误,复活的老 Worker 也会因为 epoch 落后而自杀,而不是继续写共享存储。 + +### 11.6 对账(reconcile)设计 + +- **只有控制主跑**(PG advisory lock),周期 **30 s**; +- **一次调用拿回整机实例列表**(`GET /instances`),而不是逐用户查询 —— 这是相对 k8s 版 `reconcile.ts` 逐个 `ensureUserResources`/`reap`(O(N) API 调用)的**直接改进**; +- 差异处理(全部带 epoch、全部幂等): + - DB 有 / Worker 无 → 有活跃会话则重拉,否则删归属 + - Worker 有 / DB 无 → **孤儿**,stop + - `host_id` 指向 `status=down` 的机 → 迁走 +- Worker 响应的 `epoch` 与 DB 不一致 ⇒ **立即 self-fence 该 Worker**。 + +### 11.7 恢复动作必须可审计 + 不可逆操作守纪律 + +- 每次迁移/接管/孤儿清理写 `audit_log`(表已存在)+ 平台状态目录留痕; +- **不可逆破坏性动作(删归属、删用户数据)一律先出清单**,与现有纪律一致(`CODEBUDDY.md §3 R8`); +- **自动接管默认关闭**,一期只做"告警 + 人工确认接管" ⇒ 与 `R9`(无心跳不臆断对方已死)的教训保持一致,等 TTL + fencing 在真机跑稳后再开自动。 + +--- + +## 12. 存储选型详解("服务存储"与"接入存储空间"到底差什么) + +### 12.1 先澄清:这是**四种产品形态**,不是两种 + +"服务存储"vs"接入存储空间"这个说法容易把两件不同的事混在一起 —— 真正的分类轴是**「能不能被多台机器同时挂载」**: + +| 类型 | 例子(云厂商产品) | 能不能多机同时读写 | 本质 | +|---|---|---|---| +| **① 本地盘** | 实例自带的本地 SSD/NVMe、物理机 RAID | ❌ 只能本机 | 硬件直连 | +| **② 块存储** | 阿里云 **ESSD**、腾讯云 **CBS** | ❌ 一块盘挂一台(多重挂载需集群文件系统,实践罕见) | 一块"虚拟硬盘" | +| **③ 文件存储** | 阿里云 **NAS**、腾讯云 **CFS / CFS Turbo** | ✅ **RWX 多机同时挂**(NFS/SMB 协议) | 网络文件系统 | +| **④ 对象存储** | 阿里云 **OSS**、腾讯云 **COS**、S3 | ✅ 但是 HTTP API,**不是文件系统** | Key-Value 桶 | + +### 12.2 我原来那两个选项,本质就是 ③ 与 ② 之争 + +| 维度 | ③ 文件存储(NAS RWX) | ② 块存储(ESSD + 定期同步) | +|---|---|---| +| 多机共享 | ✅ 天然(这正是"位置无关"的实现方式) | ❌ 只有挂它的那台能读写 ⇒ 靠**定时同步**间接共享 | +| 小文件性能 | ⚠️ 一般(元数据操作走网络,`node_modules` 数千小文件是典型受害者) | ✅ 好(本地/近本地块设备) | +| 迁移代价 | **不搬数据,改归属即完成** | **必须搬数据**(或接受"迁移后数据不是最新") | +| 可用区约束 | 可跨可用区(多可用区版) | **必须与实例同可用区** ⇒ 会限制 Worker 调度 | +| 快照 | 支持(文件系统级) | 支持(块级,更常见更成熟) | +| 计费 | 按容量(+吞吐/性能等级) | 按容量 + 性能等级(PL0→PL3 陡增) | +| 我们方案的定位 | **跨机共享层(主选)** | Worker 本地高性能层(可选加速) | + +> **性能更快的版本**:文件存储不是只有"通用型"——阿里云有 **NAS 极速型 / CPFS**、腾讯云有 **CFS Turbo**,小文件性能大幅改善,代价是单价高。**若实测通用型 NAS 起不来实例,第一选择是换极速型,而不是放弃共享存储。** + +### 12.3 "也是云厂商的服务吗" —— 是,但**都能自建**(重要) + +| 类型 | 云厂商托管 | 自建等价物 | +|---|---|---| +| 块存储 | ESSD / CBS | 本地盘、Ceph RBD | +| 文件存储 | NAS / CFS | **自己搭 `nfs-kernel-server`**(一台机器导出 NFS) | +| 对象存储 | OSS / COS / S3 | MinIO、Ceph RGW | +| 数据库 | RDS PostgreSQL | 自己装 PG(见 §13) | + +⇒ 所以"支持服务存储,也支持接入存储空间"这句话在架构上应该落成:**平台不绑定任何一种,只依赖一个「存储后端」抽象**。 + +### 12.4 怎么做到"两种都支持"(**成本很低**) + +现状代码里所有用户路径都从 `config.dataRoot` 派生(`dataRoot/users//{home,ws}`)⇒ **把 `dataRoot` 指向挂载点即可切换后端,业务代码零改动**。真正要新增的只有**能力探测 + 明确降级**: + +| 探测项 | 为什么要测 | 失败时怎么办 | +|---|---|---| +| 原子 rename / `fsync` | 会话日志与 `.credentials.yaml` 的原子写依赖它 | 降级告警,禁用依赖该能力的路径 | +| **NFS/网络文件系统识别**(`statfs` 的 fs 类型) | 决定是否自动开 `skill-filesystem.watchUsePolling=true`(官方开关,`index.ts:610-619`) | 自动开轮询 | +| 小文件写延迟采样(启动时写 1000 个小文件计时) | 预判 `node_modules` 冷启动是否可接受 | 超阈值 → **告警并建议换极速型** | +| 可用容量 / inode 余量 | NAS 的 inode 耗尽比容量耗尽更常见 | 低于阈值拒绝新实例 | + +> **判据必须"启动时自证"**:宁可启动就报"我这台存储后端是 NFS 通用型、小文件延迟 8 ms、轮询已开",也不要等用户跑起来才发现慢。做法照我们已有的「按序探测 + 可观测降级」模式(`src/web/dsh-install.ts`,档案 88)。 + +### 12.5 推荐:**按目录分层挂载**(比"整卷选一种"更优) + +不同子目录的特性完全不同,混在一层是浪费: + +| 子目录 | 特性 | 建议落点 | +|---|---|---| +| `home/profiles/**/node_modules` | 数千小文件、**可完整重建** | **不入共享层**:镜像提供 + 清单重建(同时是备份 L2 的排除项,§7.3) | +| `home/sessions`、`home/.credentials.yaml` | 小文件多、总量小、**不可重建** | 共享层(NAS)或随实例走 + 迁移时同步 | +| `home/skills` | 小、不可重建 | 共享层 | +| `ws/`(用户工作产出) | 可能有大文件、增长快 | 共享层(NAS)或块存储 | + +### 12.6 选型时**必须问厂商/自查的 6 个参数** + +1. 是否支持 **RWX**(多挂载点)与**跨可用区**; +2. 小文件(4 KB 级)随机写 **IOPS/延迟**(不要看顺序吞吐的宣传值); +3. **inode 上限**与扩容方式; +4. 快照能力与**恢复粒度**(整卷 / 单文件); +5. 单价口径(容量 / 吞吐 / IOPS 分别计费的部分); +6. **与 Worker 的可用区关系**(块存储必须同可用区 ⇒ 会锁死调度)。 + +**本期建议**:一期先把 `dataRoot` 放在**现有服务器本地盘**(单机阶段无跨机需求)→ 上多 Worker 时切**云 NAS 通用型**(省事、位置无关)→ 实测不行则升**极速型**,或对 `ws/` 单独用块存储加速。 + +--- + +## 13. PG 承载详解(自建 vs 云 RDS) + +### 13.1 四种形态 + +| 形态 | 说明 | 适用 | +|---|---|---| +| A. 与 Manager 同机 | PG 和门户抢资源;Manager 挂 = DB 也挂 | ❌ 不建议 | +| B. **独立 ECS 自建** | 单独一台跑 PG(+ 可选流复制备机) | ✅ **一期推荐** | +| C. **云 RDS(RDS PostgreSQL / TencentDB)** | 厂商托管,主备自动切换 + 自动备份 | ✅ 二期可迁 | +| D. CNPG on K8s | 若将来走 k8s,`deploy/01-dsh-pg.yaml` 已写好 | 未来 | + +### 13.2 对比 + +| 维度 | B. 自建(独立机) | C. 云 RDS | +|---|---|---| +| 月成本(4C8G 量级) | 只付机器 | **约 2–4×** | +| 延迟(同 VPC) | 0.2–1 ms | 0.2–1 ms(同 VPC 相当) | +| 高可用 | 自己做(流复制 + 手工/脚本切换) | **自带主备自动切换** | +| 备份 / PITR | 自己做(本方案 §7.3 的 L3) | **自带**(可视化 + 时间点恢复) | +| 参数/扩展 | **全自由**(任意扩展、任意参数) | 部分受限,白名单/权限有约束 | +| 运维负担 | 高(升级、监控、切换演练) | 低 | +| 风险 | ⚠️ **我们从未在生产跑过 PG**(档案 19 §C8 标注"未使用、未验证") | 厂商背书,但**同样要做 PG 回归** | + +### 13.3 三个必须知道的坑 + +| # | 坑 | 说明 | +|---|---|---| +| 1 | **跨云/跨地域绝对不要** | 我们的入口在腾讯云;若集群侧在阿里云而 DB 在腾讯云 ⇒ 每条 SQL 走公网 10–30 ms + 抖动,**会被放大到每一个请求**。⇒ **PG 必须与 Manager 同 VPC**,跨云只用专线。 | +| 2 | **连接数** | dshs 每个 Manager 一个连接池;RDS 小规格常限 100 连接、且**每 Request 新建连接是灾难**(要复核是否全部走池)。2–3 台 Manager + 未来副本要算总数。 | +| 3 | **两套 DB 实现并行** | `db/sqlite.ts`(含 `repo.ts` 同步实现)与 `db/pg.ts` 是**两份独立实现** ⇒ PG 回归必须覆盖两者行为一致(同一组 smoke 跑两边对比),这比"切个 URL"重得多。 | + +### 13.4 推荐与迁移路径 + +- **一期:B(独立 ECS 自建)+ §7.3 的四层备份**。理由:成本低、延迟可控,而且**无论选谁都要做 PG 回归**(这是最大风险项,与选型无关); +- **二期(300 人后)**:评估迁 C。**在线迁移用逻辑复制**(原生 logical replication 或 pglogical):源库订阅 → 追平 → 秒级切写 → 改 `DSHS_DB_URL` 重启 Manager,停机窗口可压到秒级; +- 若团队无人愿承担 DB 运维 ⇒ **一上来就用 C 也完全合理**,代价只是钱。 + +### 13.5 无论选哪个都必须先做的回归 + +1. **数据迁移脚本**(SQLite → PG):注意 `identity` 列与 `uid` 列(`db/pg.ts` 用 `BIGINT GENERATED ALWAYS AS IDENTITY`,`baseUid + row_id` 的 uid 派生依赖它); +2. **跑通现有 smoke**:`scripts/smoke-*.mjs` 全套(admin/auth/dsh/fs/domain/isolation)在 PG 下重跑一遍; +3. **会话与凭据回归**:登录态跨 Manager 生效、`credential_vault` 加解密读写一致; +4. **回滚演练**:PG 出问题时能否切回 SQLite(这是本方案**最大风险点**,必须先演练再上量)。 + +--- + +## 14. 阶段兼容性设计(**一/二期不返工**) + +> **用户要求(2026-09-14):一期与二期方案必须互相兼容。** +> 落地成一条总原则:**分期只分「自动化程度与规模」,不分「机制、数据结构、协议」** —— 机制和结构一期就定死,二期只是加机器、加开关、加运维。 + +### 14.1 兼容矩阵(7 个维度) + +| 维度 | 一期(可用) | 二期(稳) | **一期必须现在就做对的约束** | +|---|---|---|---| +| Manager 数量 | 2 台(也支持 1 台) | 更多 / 跨可用区 | 门户无状态 + advisory lock;**禁止"必须 2 台"的硬假设** | +| Worker 数量 | 2 台 | 6–25 台 | **一期就走 `RemoteSpawner` + agent 协议**(哪怕 Worker 就在本机)⇒ 加机器是纯运维动作,不重写代码 | +| 存储 | 本地盘 | 云 NAS / 极速型 / 块存储加速 | **一期就写"能力探测 + 按目录分层挂载"**(哪怕全是本地盘);`dataRoot` 指向挂载点(**派生点 80 处已全走 config ✅**) | +| DB | **自建 PG(硬要求)** | 云 RDS | 两阶段**都是 PG** ⇒ 只需 `DSHS_DB_URL` + 逻辑复制在线迁;⚠️ **一期不能先用 SQLite 顶**,因为租约依赖 PG 的原子 `UPDATE … WHERE` | +| 自动接管 | **关闭**(只告警 + 人工确认) | 开启 | **lease + fencing + self-fencing 一期全实现** —— 分期只分"自动化程度",**不分安全机制**(fencing 是防双写的,不能二期才加) | +| 备份 | 四层结构的低频版 | 高频 + 异地 + PITR | **一期就用同一套工具与格式**(restic + 快照 + WAL),只是频率低 ⇒ 二期只调参数,不换格式 | +| 代理/路由 | 跨机转发 | 不变 | **零改动**(已验证:TCP 目标与 Host 头分离 + 栅栏只看主机名) | + +### 14.2 六个「现在不做、二期必返工」的点(**已核实,全部是真实存在**) + +| # | 点 | 现状(实测) | 一期就要做 | +|---|---|---|---| +| 1 | **平台状态目录散落 8 处硬编码** | `/opt/dsh/state/{runtime-baseline,capabilities}.json`、`/opt/dsh/state`(模型托管清单)、`/opt/dsh/backups`、`/var/run/dsh-storage-report.json`、`/var/log/dsh-crash-breaker.log`、`/opt/dshs/scripts/ensure-biz-plugins.cjs`、`/usr/local/dsh-runtime`(×3) | 先给"平台状态 vs 用户数据 vs 机器基线"**三分类**(见 14.3),把它们收敛到 1–2 个**显式配置的根**;否则二期换存储会出现"一半在 NAS、一半在本地"的隐性分裂 | +| 2 | **模型托管清单是"每机本地文件"** | `server.ts:106` `DSH_PLATFORM_STATE_DIR/model-landing/.json` | 🔴 **必须挪到 PG 或共享目录** —— 否则 A 机拉起的实例写过的 ref,B 机不知道 ⇒ 违反档案 87「**只碰自己写过的**」约束 ⇒ **重复写 / 漏删用户凭据** | +| 3 | **`process.cwd()` 定位脚本** | `orchestrator.ts:251` 用 cwd 找 `poc/workspace-scoped-picker/ensure-workspace-picker.cjs` | 改成基于**安装根**的绝对路径(多机/多部署形态下 cwd 由 systemd `WorkingDirectory` 决定,不稳定)—— 复用 `src/web/dsh-install.ts` 的按序探测 + 可观测降级(档案 88) | +| 4 | **两套 DB 实现并行** | `db/sqlite.ts`(+`repo.ts`)与 `db/pg.ts` 是独立两份 | **所有 schema 变更(如 v7)两份同时改**;只用两者都支持的 SQL 子集;时间统一 BIGINT epoch ms;布尔沿用现有转换约定 | +| 5 | **`dsh_hosts` / 归属列** | 不存在 | **一期就建**(哪怕只注册 1 台)⇒ 二期加机器**零 schema 变更** | +| 6 | **uid 是否需要 OS 账号未定** | `setpriv --reuid <数字>`;但 `/etc/passwd` 是 bwrap 白名单必挂项(`orchestrator.ts:640`,为 `os.userInfo()`) | **一期测掉**(§10 第 2 项)—— 它决定二期加机器要不要同步建号 | + +### 14.3 状态三分类(**先分类,再谈存储**) + +| 类别 | 例子 | 多机后应放哪 | 迁移时是否跟着用户走 | +|---|---|---|---| +| **用户数据** | `dataRoot/users//{home,ws}` | 共享存储(NAS) | ✅ 跟走 | +| **平台状态** | 模型托管清单、capabilities、storage-report、crash-breaker 日志 | **PG 或共享目录**(唯一一份) | ❌ 平台级,不属于某个用户 | +| **机器基线** | `/usr/local/dsh-runtime`(glibc/共享工具)、镜像、bwrap 白名单 | **每台 Worker 各自一份,必须同基线** | ❌ **不能跟着走**(跟着走反而错) | + +> **这张表是"存储可插拔"能否成立的前提**:分类做对,二期只改挂载表;分类不做,二期要翻代码。 + +### 14.4 反模式清单(**一期"先简单来"会锁死二期的五种偷懒**) + +| ❌ 反模式 | 为什么锁死二期 | +|---|---| +| 一期先在本机直接 spawn,二期再做 RPC | Worker 抽象被绕过 ⇒ 二期重写实例生命周期全链路 | +| 一期先用 SQLite 跑("PG 以后再说") | 租约/归属必须 PG;二期要补数据迁移 + 双实现回归,风险集中爆发 | +| 一期把心跳/租约做成"能跑就行" | fencing 缺位 ⇒ 二期开自动接管 = 直接暴露在双写风险下 | +| 一期备份用 tar,二期换 restic | 两套格式 ⇒ 恢复路径分叉,演练覆盖不全 | +| 一期把平台状态继续写在 `/opt/dsh/**` | 二期换存储时状态分裂,且多 Manager 读不到同一份 | + +### 14.5 一期 DoD(**验收时必须已包含这些"二期就绪项"**) + +- [ ] 至少 1 台"远端 Worker"(即使物理上同机)走完整 `RemoteSpawner` + agent 协议,含幂等键与 fencing; +- [ ] `dsh_hosts` + 归属/租约列已建,advisory lock 生效,**单 Manager 时自动退化为主**; +- [ ] PG 跑通全套 `scripts/smoke-*.mjs`(SQLite 侧同组对比),**回滚演练过**; +- [ ] 存储能力探测已输出(启动日志能看出"这台是什么后端、哪些能力被降级"); +- [ ] 平台状态三分类已收敛(14.2 的 #1/#2 完成); +- [ ] 四层备份已按同一套工具落地(低频即可),**恢复演练通过**; +- [ ] 自动接管开关存在且**默认关闭**,人工接管流程演练过; +- [ ] 单活部署实测通过(撤掉 LB 后域名直指一台,全功能可用)。 + +--- + +## 15. 部署与管理(**方便上线、方便日常运维**) + +> **用户要求(2026-09-14 21:06):还要考虑如何方便部署和管理。** +> 判据一句话:**凡是"要登录某台机器手工做"的事,都必须能用一条命令替代**(系统级故障除外)。 + +### 15.1 先定部署单元:**Worker 必须整机,不能容器化** + +| 角色 | 交付物 | 理由 | +|---|---|---| +| **Worker** | **整机镜像**(云自定义镜像 / kickstart·preseed 脚本) | 🔴 **硬约束**:实例隔离依赖 `systemd-run --scope` + `bwrap` + 每用户 `setpriv` 改 uid ⇒ 需要真实 systemd 与特权。容器里做不到(做得到也得放弃现有隔离语义 = 换一套实现)。**这是与 k8s 模式最本质的差别,别照搬容器路线。** | +| Manager | 整机镜像或容器均可 | 它不需要特权(纯 Node + Fastify),且无状态 ⇒ 容器化收益大、风险低 | +| 产物 | **同一版本产物分发到所有节点** | 避免"服务器源码 ≠ git"那类静默漂移(我们踩过) | + +**为什么强烈建议整机镜像而不是"每台手工装"**:§14.3 已把"机器基线"列为**不能跟着用户迁移**的一类状态(`/usr/local/dsh-runtime`、bwrap 白名单、系统包)。手工装必然漂移 ⇒ 二期加机器时性能/行为不一致。整机镜像 = 基线一致 + 回滚容易(换镜像 ID)。 + +### 15.2 一条命令加机器(Worker join) + +目标:**新机器从零到接单 ≤ 3 条命令**。 + +```bash +# ① 用平台镜像起机(或用 join 脚本在干净系统上跑) +curl -fsSL https://<入口>/join.sh | bash -s -- --manager https://mgr.internal --token <一次性令牌> +# ② 脚本自检 + 注册 + 起服务 +# ③ 在门户「集群」页确认它变绿 +``` + +`join.sh` 要做的**自检(全部硬失败或显式降级,不许静默)**: + +| 检查项 | 依据 | +|---|---| +| 内核 cgroup v2 / swap 关闭 / nft 可用 | k8s 部署时踩过(`docs/k8s-deploy.md §1`) | +| `bwrap` 可运行、`setpriv` 可用、每用户 uid 可 `setuid` | 隔离前提;**同时验证 §14.2 #6(是否需建号)** | +| `/usr/local/dsh-runtime` 存在且版本匹配(glibc 运行时,档案 76) | 否则 univer 等原生插件装不上 | +| 存储后端探测(本地盘/NFS)+ 小文件延迟采样 | §12.4 | +| 出网 443 可达模型 API;内网可连 Manager | 网络矩阵 | +| **版本自报**(`GET /version` 上报给 Manager) | 防版本漂移 | + +**注册是幂等的**:同一 `host_id` 重复执行 = 更新记录,不产生重复行。 + +### 15.3 首次上线的渐进路径(**关键:共享存储不是开通集群的前置**) + +我此前把"共享存储"写进了必备项,**顺着"方便部署"重新推演后发现可以更好** —— 拆成两步,第一步几乎零风险: + +| 步骤 | 部署形态 | 现有用户怎么办 | 拿到什么 | 需要共享存储吗 | +|---|---|---|---|---| +| **1a** | **Manager-01 + Worker-01 同机**(Manager 组里有一台兼任 Worker),**dataRoot 原地不动** | **零迁移**(老用户目录不动、路径不变) | 归属表 + 租约 + 双活门户 + **跨机协议已验证** | ❌ 不需要(本地盘即可) | +| **1b** | 加 Worker-02 + 挂 NAS,把 `dataRoot` 指向共享层 | 需要时把用户从 01 **搬一次**(rsync,低频计划内) | **可迁移**(改归属 + drain,不搬数据) | ✅ 此时引入 | +| 2 | Worker-02..N + 容量准入 + 自动转移 | 按需迁移 | 300 人规模 + 故障转移 | ✅ | + +> **好处**:① 第一步**不动现有数据** ⇒ 上线风险极低、随时可退回单机;② NAS 的性能风险(小文件)**推迟到 1b 再评估**,不阻塞主体工作;③ `RemoteSpawner` + agent 协议在 1a 就已经被真实流量验证(§14 要求的"哪怕同机也走远程协议"正好落在这里)。 +> +> ⚠️ **1a 的唯一注意点**:Manager-01 兼任 Worker 时,**门户与实例抢资源**(实例 384–1024 MiB)。给该机留足内存,或把 Manager 的实例承载上限设为 0(只做控制/门户,不接实例)。后者更干净:**Manager 默认 `capacity=0`**,需要时显式开。 + +### 15.4 管理面(门户新增「集群」页,仅 admin) + +沿用现有 admin 管理面形态(`admin.html` + `/api/admin/*`),新增一页: + +| 区块 | 显示什么 | 能做什么 | +|---|---|---| +| 主机列表 | host_id / 内网地址 / 状态 / **版本** / 内存水位 / 实例数 / 最后心跳 | 置 `draining`、踢出集群、加机器指引 | +| 实例归属 | 用户 → 哪个 Worker / 端口 / epoch / 心跳 | **单用户迁移**、批量迁移整机、强制重启 | +| 存储 | 每台的后端类型 + 能力探测结果 + 降级项 | 只读(异常时告警) | +| 备份 | 上次成功时间 / 体积 / 保留策略 / 下次计划 | 手动触发、一键恢复演练 | +| 告警 | Worker down、心跳超时、容量水位、备份失败、**版本漂移** | 静音、确认 | +| 审计 | 迁移/接管/孤儿清理的 `audit_log` | 按用户/时间检索 | + +**红线不变**:破坏性动作(删归属、删用户数据)仍要"**先出清单 + 二次确认**",与现有纪律一致。 + +### 15.5 一键自检与可观测(**把 §10 的实测清单变成可重复命令**) + +| 工具 | 作用 | +|---|---| +| `dshs doctor` | 单机自检:隔离能力 / 存储后端与能力降级 / PG / nft / 版本 —— 把 §10 的 5 项实测固化成命令 | +| `dshs cluster status` | 全集群:每台状态/版本/水位/归属数/心跳 | +| `GET /metrics` + Prometheus | 控制面副本数、PG 连接、每 Worker 实例数、崩溃计数、租约抢主次数、迁移次数 | +| 集中日志(Loki/SLS) | 多 Worker 日志聚合(现在单机 journald 到集群就失效) | +| **统一 trace id** | 🔴 **一个请求从入口 → Manager → Worker → 实例要有同一个 id**,否则跨机排查只能靠人肉 grepping(这是集群化后最容易后悔的地方) | +| 告警规则 | 控制面 < 2 副本、PG 不可写、心跳超时、Worker 水位 > 85%、备份失败、**版本不一致** | + +### 15.6 灰度升级与回滚(**集群化最大的运维红利**) + +单机时代升级要停机;集群后: + +``` +① 置 Worker 为 draining(不再接新实例) +② 迁移 / 等待其上实例自然回收 +③ 停服 → 换镜像/产物 → 起服 → 自检 → 置回 up +④ 观察一个周期再推下一台(**一次只动一台**) +``` + +- **Manager 升级**:先升不持控制主的那台 ⇒ 观察 ⇒ 触发切换 ⇒ 再升另一台(门户始终有活副本); +- **版本策略**:Worker 版本可与 Manager **不同大版本内**共存(滚动窗口),但 **Manager 必须能兼容 N-1 的 Worker** ⇒ 协议要向后兼容(新增字段可选、不删旧字段); +- **回滚**:镜像/产物按版本目录保留,systemd unit 指向"当前版本"符号链接 ⇒ 回滚 = 切链接 + 重启; +- ⚠️ **发布前必须 CI 绿**:现状 `scripts/ci.sh` 非 0 退出(`test/db.test.mjs` 一项长期红,属别人 lane)⇒ **上集群前必须让 CI 恢复可信**,否则"发布可信度"这个前提不成立。 + +### 15.7 运维任务表 + +| 频率 | 任务 | +|---|---| +| 每日(自动) | 心跳/水位/告警巡检、四层备份(§7)、租约过期扫描 | +| 每周 | 备份产物抽验、**版本漂移检查**、容量趋势 | +| 每月 | **恢复演练**、迁移演练(真迁一个用户)、fencing 演练(模拟老 holder 复活)、PG 备份校验、扩缩容评审 | +| 变更时 | 灰度升级(一次一台)、加/减 Worker、PG 升配或迁 RDS(逻辑复制) | +| 应急 | Worker down 接管(一期人工确认)、Manager 单点恢复、PG 故障切换、存储不可达降级 | + +### 15.8 与现有运维资产的衔接(**都需要更新**) + +| 现有资产 | 集群化后 | +|---|---| +| `DEPLOY-本部署.md` | 新增/派生 **`DEPLOY-集群.md`**(Manager 组 + Worker 组 + PG + 存储 + join 流程 + 灰度) | +| `02-运维手册.md` | 新增「集群运维」章(主机管理、迁移、接管、故障矩阵 §11.4、运维任务表) | +| `ops/`(nginx / scripts) | 增加 `join.sh`、`dshs doctor`、集群巡检脚本 | +| `scripts/backup-platform.sh` | 升级为四层备份编排(快照 + restic + WAL) | +| `03-路线图与待办.md` / `交接单/` | 按一期/二期拆单(本期只出方案,不动文档库 → 见文末说明) | + +> ⚠️ **文档库改动纪律**:以上文档都在受保护根 `dsh-server-docs/` 内 ⇒ 动它们**必须先抢全局执行锁**,且按"档案只增不改"追加而非改写历史。 + +--- + +## 16. 域名与组网(**只有入口需要域名;跨机房能组网,但有代价**) + +### 16.1 域名/证书需求矩阵(**结论:只有入口一处需要**) + +| 角色 | 需要公网域名? | 需要证书? | 说明 | +|---|---|---|---| +| **入口 nginx**(现有腾讯云机) | ✅ **唯一需要**:`domain` + `*.domain` | ✅ 通配证书 | 用户只认这两个名字 | +| **Manager** | ❌ 不需要 | 可选(内部加密才用) | 只是入口的一个 upstream(内网 IP:3080 或 VIP 背后的成员) | +| **Worker** | ❌ 不需要 | ❌ 不需要 | 只被 Manager 以内网 IP 访问;**Worker 上不装 nginx、不配证书** | +| **PG** | ❌ 不需要 | ❌ | 内网地址 | +| **共享存储** | ❌ 不需要 | ❌ | 挂载点(内网 NFS 挂载) | + +**三条容易被误解的点**: + +1. **多 Manager 不需要多个域名、也不需要会话粘性** —— 登录态靠"**PG 会话表 + 同一个 `cookieDomain`**"共享,而不是靠每台有自己的域名。给 Manager 各配子域(`mgr1.domain`)**不需要也不建议**(只会引入粘性会话复杂度); +2. **Manager 之间不需要网络直连** —— 控制面互斥靠 **PG advisory lock**,只要"都能连同一个 PG"即可(这是本方案的一个结构性优点:Manager 组之间没有额外的对等网络要求); +3. **备案是域名的属性,不是服务器的属性** —— 服务器本身不需要"配置域名"。若在阿里云侧走**公网 SLB** 提供域名入口会被 ICP 校验拦(已记录,§2.4);现状生产是 CF 回源到 ECS 直连,不受影响。 + +> 建议(非必需):给 Manager/Worker 配**内网 DNS 名**(`mgr.internal`、`worker-01.internal`)只为运维好读;用 IP 完全等价 —— 但**防火墙白名单请写网段而不是单个 IP**(见 §16.5)。 + +### 16.2 组网三档("不在同一个 IP 下的服务器"能否组网) + +**能,但要分清处在哪一档** —— 三档的能力与代价完全不同: + +| 档 | 场景 | 互通方式 | 延迟 | 能否任意迁移用户 | +|---|---|---|---|---| +| **① 同 VPC / 同内网** | 同云同地域的多台 ECS | 默认内网互通 | 最低(0.1–1 ms) | ✅ 能 | +| **② 跨可用区(同 VPC)** | 同云同地域不同 AZ | 内网仍互通 | 略高(0.5–2 ms),跨 AZ 流量**可能计费** | ✅ 能(**存储在同一地域**) | +| **③ 跨云 / 跨地域 / 混合机房** | 阿里云 + 腾讯云、云 + 自建 | **必须建重叠网(overlay)**:WireGuard / Tailscale·ZeroTier / 专线·CEN | 10–30 ms+ | ❌ **不能(见 16.3)** | + +**③ 的三种落地方式**: + +| 方式 | 特点 | 适用 | +|---|---|---| +| **WireGuard** | 每个节点一个 wg 接口,组成加密内网(如 `10.99.0.0/24`);配置轻、性能好 | ✅ **首选**(自建、可控) | +| Tailscale / ZeroTier | 基于 WireGuard,自带 NAT 穿透与密钥管理 | 运维最省;节点多、网络复杂时 | +| 专线 / 云企业网 CEN / 云联网 | 企业级、稳定、不走公网 | 正式多地域生产 | +| SSH 隧道 | 应急 | ❌ 不做生产(连接数/稳定性差) | +| 纯公网直连 + IP 白名单 + HMAC | 能用但攻击面大 | ❌ 不推荐 | + +### 16.3 🔴 跨云场景的三个硬约束(**必须写进设计,否则会白做**) + +| # | 约束 | 后果 | +|---|---|---| +| 1 | **PG 必须与 Manager 同侧** | 跨云 SQL 10–30 ms + 抖动 ⇒ 每个请求都被放大(§13.3 已列为"绝对不要")。⇒ **跨云时 PG 跟着 Manager 走,不能跨**。 | +| 2 | **NAS 不能跨云挂载** | NFS over 公网既不安全也不现实 ⇒ **每个云一个存储域** ⇒ **用户不能跨云迁移**(数据在另一个云的 NAS 上)。⇒ **跨云只能"云内迁移"**。 | +| 3 | **代理流量全走 Manager** | 用户流量经 Manager → Worker ⇒ 跨云时**全部流量跨云**(带宽成本 + 延迟叠加)。⇒ Worker 应与 Manager 同地域。 | + +> **⇒ 架构结论**:**"任意 Worker 可接手"这个能力,隐含前提是"同地域 + 同一存储域"**。 +> 跨云不是不能做,而是会**降级成"每个云一个独立集群域"** —— 那时更像"两个集群"而不是"一个集群"。**若目标是"上千人",建议先在一个云内横向扩,不要过早跨云。** + +### 16.4 LB 与 VIP 的实现前提(**跨 AZ 会踩**) + +| 方案 | 前提 | 备注 | +|---|---|---| +| `keepalived` + VRRP VIP | **主备必须在同一二层网络** | 跨 AZ 通常不成立 ⇒ 别默认选它 | +| 云 HAVIP(高可用虚拟 IP) | 云厂商支持,可跨 AZ 漂移 | 需要该产品支持 | +| **云 SLB / ALB**(推荐) | 无二层要求 | 后端用 IP 注册,**不需要域名**;⚠️ 阿里云公网 SLB 有 ICP 校验(§2.4)⇒ 入口侧用腾讯云或内网 SLB | + +### 16.5 白名单用**网段**,不要用单个 IP(配套"一条命令加机器") + +若防火墙按"Manager 的单个 IP"写白名单,每次加机器/换 IP 都要改 nft ⇒ 与 §15 的"一条命令加机器"目标冲突。 + +**做法**:给 Manager 组一个**专用子网/overlay 网段**(如 `10.99.0.0/24`),Worker 侧只放行这一段: + +``` +# Worker 侧:只放行 Manager 网段 + 只暴露控制/代理口 +tcp dport { 9000-9999 } ip saddr 10.99.0.0/24 accept +tcp dport { 9000-9999 } drop +``` + +⇒ **加机器 = 加到该网段即可,防火墙零改动**。 + +### 16.6 落地建议(按现状) + +1. **域名侧零改动**:继续 `domain` + `*.domain` → 入口 → Manager(内网 IP/VIP); +2. **Manager 组与 Worker 组放同地域同 VPC**(档 ①),先不跨 AZ; +3. **PG 放同 VPC**(§13); +4. **暂不跨云**:跨云的三个代价(16.3)与"上千人"目标冲突,等单云跑稳再评估; +5. 若将来确有跨云需求:**先上 WireGuard 重叠网**,并接受"云内迁移"(放弃跨云迁移)。 + +--- + +## 17. 现网实测结论(2026-09-14 21:17–21:25) + +> 按用户给定的实际服务器执行:**manager = 47.77.182.89**、**worker-01 = 47.77.182.89**(同机,即 §15.3 的 1a 形态)、**worker-02 = 106.54.21.172**。 +> 实测方式:本机 SSH + 只读探测(无任何写操作、无端口新增)。 + +### 17.1 可达性(**结论:全都通,但是跨云**) + +| 测试 | 结果 | +|---|---| +| 47.77.182.89 SSH | ✅ 可达 —— ⚠️ **端口是 32022,不是 22**(`bt-server` 别名已配) | +| 106.54.21.172 SSH | ✅ 可达(端口 22,专用密钥 `id_ed25519_test106`) | +| 47 → 106 TCP | ✅ 22 / 80 / 443 / 8888 **全开** | +| 106 → 47 TCP | ✅ 22 / 32022 / 80 / 443 / 58888 **全开** | +| **ICMP(ping)** | ❌ **双向被安全组拒绝**(100% packet loss) | +| **RTT** | 47→106 `connect=150 ms`;106→47 `connect=183 ms`;本机回环 `0.1 ms` | +| HTTP 往返 | 47→106 首页 `total=300 ms, code=200` | + +**两条直接结论**: + +1. 🔴 **实测 RTT 150–183 ms,比本文档原估的"10–30 ms"高 5–10 倍** ⇒ 跨云代理会让**每个请求多 300 ms 以上往返**,对话式 UI 与 WebSocket 流式体验会明显退化。**原 §16.3 的"不要跨云"结论不变,但理由从"贵"升级为"实测不可接受"。** +2. ⚠️ **ICMP 不可用 ⇒ 存活检测绝不能用 ping**,必须用 TCP/HTTP —— 本文档 §11 把心跳设计成 HTTP `GET /healthz`(而不是 ping)**恰好正确**,继续保持。 + +### 17.2 两台基线差异(**指向 §14.3「机器基线不能跟着迁移」**) + +| 项 | 47.77.182.89(**阿里云**) | 106.54.21.172(**腾讯云**) | +|---|---|---| +| OS | Alibaba Cloud Linux 3.2104 U12 | OpenCloudOS 9.6 | +| 内核 | 5.10.134 | 6.6.119 | +| **cgroup** | **v1(tmpfs)** | **v2(cgroup2fs)** | +| **glibc** | **2.32** ⇒ 需挂 glibc 2.35 运行时(档案 76 / univer) | **2.38** ⇒ **不需要该 hack** | +| CPU / 内存 | **2 核 / 1870 MB(可用仅 773 MB)** | **4 核 / 3655 MB(可用 2655 MB)** | +| swap | 有(1 个) | 有(`/www/swap` 1G) | +| bwrap / setpriv / systemd-run / nft / node / dsh | 齐 | 齐(systemd 255) | + +⇒ **同一用户在两台机器上可能表现不同**(最典型:47 上靠 glibc 运行时才能跑的插件,106 上原生可跑)。**这正是"整机镜像统一基线"(§15.1)必要性的实证**。 + +### 17.3 🔴 两个会影响设计的实测发现 + +**发现 1:uid 必须建号(原"待实测"项已有答案)** + +``` +setpriv --reuid 100001 ... -- id -u → 100001 ✅ 无需建号 +setpriv --reuid 100001 ... -- node -e 'os.userInfo()' → FAIL ERR_SYSTEM_ERROR ❌ +``` +⇒ **uid 可以 setpriv,但 `os.userInfo()` 在无 `/etc/passwd` 条目时直接失败** ⇒ **每台 Worker 都必须为要跑的 uid 建号(或至少写入 passwd 条目)**。 +⇒ 落地含义:**加机器时必须同步账号**(漏了的表现 = 实例启动即崩);这也解释了代码为何把 `/etc/passwd` 列为 bwrap 白名单必挂项(`orchestrator.ts:640`)。 +⇒ 对应 **§14.2 #6 结论 = 需要建号**(§10 第 2 项已闭环)。 + +**发现 2:`systemd-run` 未限制 swap,而两台机器都有 swap** 🟠 + +- 代码现状(`orchestrator.ts:749-751`):只设 `MemoryMax=M` + `TasksMax=128`,**没有 `MemorySwapMax`**; +- 有 swap 时 `MemoryMax` 超限会**先换出到 swap(变慢)**,而不是被 **OOM kill** ⇒ **"被 OOM 杀 → 熔断自愈"(档案 20/78)的语义在有 swap 的机器上不成立**; +- 且两台 swap 大小不同 ⇒ **同一用户在不同 Worker 行为不一致**。 +- ⇒ 建议:Worker 统一 `-p MemorySwapMax=0`(或在 `join.sh` 自检里发现 swap 即告警/关闭)。 +- ⚠️ 这一条是**现状单机就存在的隐患**(47 也有 swap),不是集群才引入的 —— 建议单独立项核查。 + +### 17.4 106.54.21.172 上已有的一套独立平台(**不是裸 Worker**) + +- `/root/dsh-users-platform/`(含 `lib/`、`deploy/`、`Dockerfile.dsh`、`install.sh`、`ensure-role-profile-patch.cjs`) +- systemd `dsh-users-platform.service`:**active running**(已跑 16 h+),`EnvironmentFile=/etc/dsh-users-platform.env`,`User=root`,`Restart=always` +- 监听 `127.0.0.1:3080`(node);数据根**不在** `/var/lib/dshs`(由该 env 文件指定,本次未读取以免触碰密钥行) +- 另有宝塔面板(`:8888`)与 nginx(80/443) + +⇒ **它不是一台"待接管的空机器",而是另一套完整平台**。要用它当 Worker-02 需要先定: + +| 选项 | 含义 | 代价 | +|---|---|---| +| (a) **保留为独立测试环境**(推荐) | 集群另起 | 不干扰现有测试 | +| (b) 停掉它,改造为纯 Worker | 这台机器整体并入集群 | 会中断其上任何在跑的实例 | +| (c) 就地把它变成 1a 集群 | Manager-01 + Worker-01 都用它 | 测试环境与新集群合一 | + +### 17.5 部署建议(**基于实测,与 §16.3 一致但更硬**) + +| 结论 | 依据 | +|---|---| +| 🔴 **这两台机器不能组成"一个"集群** | 跨云 150–183 ms ⇒ 代理路径每个请求多 300 ms+,不可接受 | +| ⇒ **必须选一个云内扩容**(新机器与 Manager/Worker 同云) | §16.2 档①(同 VPC,0.1–1 ms) | +| ⇒ 或**各自独立**(106 测试环境 + 47 生产),不互联 | 等价于"每云一个集群域"(§16.3) | +| ⚠️ **47 不适合做 Manager** | 可用内存仅 **773 MB**,门户+代理+PG 客户端会挤掉实例余量(§11 内存账) | +| ✅ **若要试 1a,优先放 106** | 4 核 / 可用 2655 MB,且集群化所需的 bwrap/setpriv/nft/dsh 全部就绪 | + +### 17.6 尚未做的实测(依赖共享存储,等 NAS/存储定下来再跑) + +- 共享存储上的 watcher 行为(`skill-filesystem.watchUsePolling`) +- 共享存储冷启动耗时(`node_modules` 小文件) +- 每用户数据体积(定备份子集与容量) +- **跨机代理的信任栅栏验证**(§10 第 1 项):需 1a 落地后才可测 —— 现阶段已由源码判定(`api-request-trust.ts:103` 只看主机名)**通过**,实测留到 1a 联调时补。 + +--- + +## 18. 与 K8S 模式的对比(**回答"k8s 的优势到底是什么、代价多大"**) + +### 18.1 先回答"镜像主要是什么" —— 是**两个**镜像,不止 dsh 主程序 + +| 镜像 | 谁用 | 说明 | +|---|---|---| +| `DSHS_DSH_IMAGE`(dsh 主程序/运行时) | 每用户 DSH Pod 的**主容器** | 官方 dsh + 我们的运行时基线 | +| `DSHS_CONTROL_PLANE_IMAGE`(dshs) | ① 控制面 Deployment ② **每用户 Pod 的 `tcp-bridge` sidecar** ③ **每用户 files Pod** ④ bootstrap Job | 复用同一镜像的子命令,是为了**避免依赖 docker.io**(ACK 拉不动 `alpine/socat`,见 `docs/k8s-deploy.md §7.5`) | + +**镜像分发确实是成本**,但要说准:它在**磁盘/网络**上花钱(ACR 拉取、每节点缓存一份),**镜像层本身不额外占运行内存**(只读层跨容器共享 page cache)。真正多占内存的是下面的 18.2。 + +### 18.2 容器在"运行时"到底多占多少(**分三层算,别混在一起**) + +| 层 | 开销 | 依据 | +|---|---|---| +| ① **每用户 sidecar** | **+90–120 MiB/用户** = `tcp-bridge`(Node 空转 ~30–40 MiB)+ `files` sidecar(Node+Fastify ~60–80 MiB,实测 64 MiB 跑不起来→提到 256 MiB) | `k8s-spawner.ts` 的两个 sidecar 容器 | +| ② **每节点固定** | **+300–500 MiB/节点**(kubelet + containerd + CNI + node-local-dns) | k8s 本身 | +| ③ **装箱效率**(**被低估的一项**) | Pod **requests** 决定装箱:每用户 `1Gi`(dsh)+ `32Mi`(bridge)+ `128Mi`(files)≈ **1.16 GiB request/用户**;而实际 RSS 只有 ~250–350 MiB ⇒ **账面内存比实际多 3–4 倍** | `k8s-spawner.ts` 的 `resources.requests` | + +**对照自研版(local 语义)**: +- 每实例**没有 sidecar**(tcp-bridge / 文件面都在进程内或按需); +- cgroup 用的是 `MemoryMax`**上限而非预留** ⇒ **天然超卖、按真实可用内存装箱**(这就是为什么单台 1.87 G 的机器能跑 1–3 个实例); +- 每节点**没有** kubelet/CRI 固定开销。 + +> **⇒ 1000 并发的账面账**:k8s 需要 ~**1.16 TiB requests**;自研版按实际 RSS 约 **300–450 GiB** 规划。这个差距不是"加 5–8 台"的问题,而是**选型级**的差别。 +> ⚠️ 公平地说:requests 是**可调**的(可降到 256–512 MiB 并接受超卖),但那等于主动放弃 k8s 最可靠的调度依据,回过头来又要自己写容量准入 —— 优势自我抵消。 + +### 18.3 K8S 真正的优势(**按"对我们这个形态有没有用"排序**) + +| # | 优势 | 对我们有用吗 | +|---|---|---| +| 1 | **调度与装箱**(自动把实例放到有空闲的节点) | ⚠️ **有限** —— 我们的实例是**有状态**的(绑定用户 home、uid、会话日志),**不能被任意调度**,只能"同存储域内选择" | +| 2 | **节点故障自愈**(Pod 被重新调度到别的节点) | ✅ **有用** —— 这是我们自研版要自己写的那部分(心跳 + 租约 + 重建) | +| 3 | **声明式期望态 + 控制器** | ✅ 有用(但我们的 `dsh_instances` + reconcile 已经是同一思路) | +| 4 | **网络策略 / PSA / seccomp / 只读 rootfs** | ⚠️ 与 `bwrap` **机制不同、强度相当**;且我们已完成 bwrap 收窄实测(`/etc` 可见项 222→12,档案 39) | +| 5 | **滚动升级 + PDB** | ✅ 有用(我们自研靠 drain,效果等价) | +| 6 | **弹性伸缩(HPA / Cluster Autoscaler)** | ✅ 有用(但用户数增长是缓慢趋势,不是秒级波峰) | +| 7 | **多副本控制面 + Lease 选举** | ⚠️ 自研用 **PG advisory lock** 几行就够 | +| 8 | **可观测生态**(Prometheus/Loki/事件) | ✅ 有用(自研也能接,只是多写点) | + +### 18.4 K8S 的代价(**5 条,都是实打实的**) + +| # | 代价 | 说明 | +|---|---|---| +| 1 | **内存账面 +3–4 倍**(§18.2 ③) | 选型级差异 | +| 2 | **每用户 +2 个 sidecar 进程** | 每用户多 90–120 MiB | +| 3 | **每节点 300–500 MiB 固定开销** | 8 台节点 ≈ 白扔一台机器的内存 | +| 4 | **API 对象数** | 每用户 6–8 个对象(Pod/Svc/NP/Secret/ConfigMap/files×3)⇒ **1000 用户 ≈ 8000 对象**,还要分片命名空间(§6 P0-1/P1-5) | +| 5 | **平台自研能力要重写 6 处** | 模型落地直写 home、角色补丁/picker、`restartAndProbe`、`breakerInfo`/`quotaInfo`、`touch`/launchToken、备份与清理脚本(§18.5) | + +### 18.5 判定:**本方案仍选自研 manager/worker**,理由三条 + +1. **我们的实例是"有状态长驻进程"** ⇒ k8s 的看家本领(任意调度、快速重建)**价值被削弱**,而它的固定代价(sidecar、requests 保守、节点开销)**全额付出**; +2. **隔离与平台能力已经在 local 语义上做完并实测过**(bwrap 收窄、模型落地、角色补丁、插件探活、配额观测、熔断)⇒ 走 k8s 等于**把这些逐条重写在容器语义上**(上一轮列过 6 处缺口); +3. **自研版需要自己写的,只有"调度 + 自愈 + 租约"三件事** —— 而这三件事的**语义在我们这里很窄**(同存储域内选机、TTL 租约、drain 重建),本方案 §3/§11 已给出可照抄的实现路径。 + +**什么时候该反过来选 k8s**:用户实例变成**短生命周期 / 无状态**(例如每次会话一个 Job、无用户私有 home),或者团队**已经有成熟 k8s 运维能力**且不想维护自研控制器时。**当前形态不属于这两种。** + +> 一句话:**k8s 卖的是"你不用自己写调度器和自愈",而对我们这种"每用户一个有状态常驻进程"的形态,这两样恰好是我们能自己写、且已经设计完的部分 —— 却要为它付 3–4 倍的内存账面和一次全量能力重写。** + +--- + +## 19. 本期验证策略与"访问模式兼容"约束 + +### 19.1 跨云环境当"极限场景测试床"(✅ 可行,且推荐) + +47(阿里云)↔ 106(腾讯云)实测 **RTT 150–183 ms、ICMP 被禁、无内网** ⇒ 这正是一个现成的**恶劣网络环境**,用来验证容错比局域网更有价值: + +| 用它测什么(✅ 有价值) | 用它测什么(❌ 会误判) | +|---|---| +| 心跳/租约在 150 ms RTT 下是否稳定(TTL 30 s 足够宽) | **生产性能基线**(150 ms 会污染结论) | +| 代理的**超时与重试**设置是否合理(半开连接、SSE/WS 长流) | 用户真实体验(会明显偏慢,不代表同云表现) | +| **断网/丢包故障注入**:fencing 是否真的拦下老 holder、重连是否自愈 | 存储小文件性能(共享存储未上,测不了) | +| "跨云不可用"的**判定验证**(把 §16.3 的结论从推理变成实测) | 容量/装箱结论(网络慢不影响内存) | + +**但要立一条纪律**:**跨云 = 故障注入与容错测试床,不是性能基线环境**。所有性能/容量结论必须在同云同 VPC 环境取数,否则会被 150 ms 误导。 + +**这条对方案的价值**:§11.4 恢复矩阵里那些场景(心跳超时、Worker 失联、Manager 单点、PG 不可达、脑裂)**都可以在这个跨云环境里人为制造**,比在单机模拟更真实。 + +### 19.2 "47 限制只能 admin 访问,留足够内存"(✅ 可行) + +**可行,且不需要改代码** —— 现有平台本来就是**注册审核制**(注册 → 待审 → admin 放行),把 47 设为"只有 admin 能用"就是**不放行新用户** + 必要时对存量用户按 `disable`(已有路由 `/api/admin/users/:id/disable`)。 + +**内存账(47:2 核 / 1870 MB,实测可用 773 MB —— 该值含当时在跑的实例)**: + +| 项 | 量 | +|---|---| +| 系统基础 | ~300 MB | +| dshs **Manager**(门户+代理+PG 客户端) | ~200–300 MB | +| **admin 实例**(档案 81 配额 384–672 MB) | ~400–670 MB | +| 合计 | **900–1270 MB** | +| ⇒ 仅留 admin 后 | 停止其它实例可释放数百 MB ⇒ **可行,但余量很薄(< 300 MB)** | + +**⚠️ 但有一个必须知道的坑(1a 形态的固有代价)**: + +> `LocalSpawner` 构造函数会调 `cleanAllStaleScopes()`(`orchestrator.ts:164` + 档案 30:*"portal 启动即清掉遗留 scope"*)⇒ **Manager 每次重启都会停掉同机所有实例 scope**。 +> ⇒ 在 1a(Manager 兼 Worker 同机)下,**升级/重启 Manager = 该机实例全断,随后按需重建**。 +> **规避**:① 接受它(admin 自己用,可接受);② 把该机 `capacity=0`(不承载实例,只做门户/控制);③ 只有在共享存储就绪后才把"Manager 与实例同机"当成正式形态。 + +**结论**:47 可以做 Manager(门户),**但内存余量薄**,且必须接受"Manager 重启清实例"。**若求稳,Manager 应放在 106 那类 4 核/3.6 G 的机器上,47 只做实例承载。** + +### 19.3 访问模式兼容清单(**改造后必须与现在的"单实例访问模式"一致**) + +| 维度 | 现状 | 改造后必须保持 | 是否天然满足 | +|---|---|---|---| +| 门户 URL | `https://alotbuy.com` | 不变 | ✅(入口不动) | +| 实例 URL | `https://<用户名>.alotbuy.com` | 不变 | ✅(Host 路由不变) | +| Cookie | `Domain=.alotbuy.com`,会话查 DB | 不变 | ✅(已满足) | +| **登录直达会话**(`enter` 带 launch token) | 从实例 stdout 解析 token 拼 URL | 不变 | ⚠️ **需专门做**(§4.1 的 `launchToken` 回传,P0-6) | +| 实例侧 401 自愈(档案 24/50/51) | 代理透明重放 / 注入脚本 | 不变 | ⚠️ 依赖上一条 | +| 门户 API 路径(`/api/**`) | 不变 | 不变 | ✅ | +| 「我的文件」面板 | 读本机 `dataRoot/users//ws` | 跨机可读 | ⚠️ 需 file-service(P0-7) | +| **单机部署仍可跑** | `systemctl restart dshs` | **保留 `deployMode=local` 路径,代码不删** | ✅(§8.4 已定) | +| 用户目录结构 | `dataRoot/users//{home,ws}` | 不变 | ✅(§12.4 挂载点即切) | +| 管理员操作入口 | 门户 admin 页 | 新增「集群」页,**旧页面不动** | ✅ | + +> ⇒ **"兼容单例访问模式"的落点集中在 3 处**:launch token 回传、401 自愈链路、文件面跨机。其余(URL/cookie/API/目录结构/单机可退)**天然满足**,不需要额外工作。 + diff --git a/dsh-server-docs/04-调整方案/120-跨节点迁移与节点自举-完整流程方案.md b/dsh-server-docs/04-调整方案/120-跨节点迁移与节点自举-完整流程方案.md new file mode 100644 index 0000000..d2a5684 --- /dev/null +++ b/dsh-server-docs/04-调整方案/120-跨节点迁移与节点自举-完整流程方案.md @@ -0,0 +1,193 @@ +# 跨节点迁移 & 节点自举 —— 完整流程方案 + +> 立稿 **2026-09-16 00:3x** | 起因:09-15 的 guest 迁移走的是**手工临时方案**(AI 人肉做了 6 件事)。 +> 用户要求:**「该谁处理就让对应功能处理,不要做这种临时方案;迁移应该谁发起、迁移完成应该如何处理,整个完整的流程」**。 +> 本文回答三件事:**谁处理**(责任矩阵)→ **完整流程**(目标态)→ **要开发什么**(缺口清单)。 + +--- + +## 0. 一句话 + +把「人手搬 6 件事」收敛成 **两个平台功能**: +① **节点自举**(新节点加入时执行一次,含**旧角色清理**)② **迁移编排**(admin 发起 → 平台搬数据 → 收尾归档)。 + +--- + +## 1. 这次实际做了什么(对照:本该谁做) + +| # | 我手工做的(临时方案) | 本该谁做 | 平台现状 | +|---|---|---|---| +| 1 | `useradd -u 100002 …` 建 OS 账号 | **worker 自己**(`dsh-provision.path` 触发) | ⚠️ 单元**只在 47**;且 uid 取自 SQLite ⇒ 集群下失效 | +| 2 | `npm i -g @deepseek-ai/dsh@0.1.5-rc.1` | **节点自举**(一次) | ❌ 无此功能 | +| 3 | 传 `/usr/local/dsh-runtime`(python/jq/rg) | **节点自举**(一次) | ❌ 无此功能 | +| 4 | `chmod 711 /var/lib/dshs/users` | **节点自举**(一次) | ❌ 无此功能(且**漏了就必崩**) | +| 5 | 搬 203 MB 用户数据 + 依赖 | **平台迁移编排** | ❌ 迁移路由注释原文「**数据不搬家**」 | +| 6 | 源目录打包归档 → 删源 | **平台迁移收尾** | ❌ 无此功能 | +| 7 | ffmpeg 我从 47 搬(错) | **节点自举**:直接 `dnf install` | ✅ 106 内网源 43 MB/s,**几十秒装完**(本轮已改) | + +--- + +## 2. 责任矩阵(谁处理什么) + +| 环节 | 责任方 | 触发时机 | 幂等要求 | +|---|---|---|---| +| 节点自举:dsh 主程序 / dsh-runtime / 目录权限 / provisioner 单元 / egress | **平台(运维脚本)** | 节点**加入集群**时一次 | ✅ 必须可重跑 | +| 节点角色切换:**清理旧角色服务**(如 106 上的旧控制面) | **平台(运维脚本)** | 角色变更时 | ✅ | +| 用户目录出现 ⇒ 建 OS 账号 + `chown` | **worker 自己**(systemd path 单元) | 目录出现时 | ✅ 已有(待铺到各 worker) | +| 迁移**发起** | **admin**(人) | 人为决策 | — | +| 迁移**执行**(drain → 数据同步 → 目标机拉起 → 归属) | **平台** | 发起后 | ✅ | +| 迁移**收尾**(源归档 / 清理 / 审计 / 通知) | **平台** | 迁移成功后 | ✅ | + +**原则**:凡是"节点级别、一次性的"归**自举**;凡是"跟着用户走、每次迁移都发生"的归**迁移编排**。 +⛔ 两者都**不该由 AI 或人手临时执行**。 + +--- + +## 3. 完整流程(目标态) + +### A. 节点加入(自举,一次) +``` +1. 运维执行 bootstrap-worker(幂等) + ├─ 装 dsh 主程序(同版本,npm 或离线包) + ├─ 装 dsh-runtime(python3.12 / jq / rg / ffmpeg —— 优先 dnf/内网源,而非从别处搬) + ├─ 目录与权限(/var/lib/dshs 711、users 711、secret.key) + ├─ 铺 provisioner 单元(dsh-provision.path + .service,脚本参数化到本机路径) + └─ 清理旧角色残留(如该机曾跑控制面 ⇒ 停用并禁用) +2. 自检 doctor:四件套 + provisioner + 端口 + 数据根,全绿才允许被调度 +3. 注册进 dsh_hosts(capacityMb / endpoint) +``` + +### B. 用户迁移(admin 发起 → 平台执行 → 收尾) +``` +1. 【发起】admin 在「用户管理」选用户 → 选目标节点 → 点"迁移" + (入口已有 API:POST /api/admin/users/:id/dsh/migrate;门户缺 UI 入口) +2. 【前置校验】平台检查目标节点:status=up、capacity 足够、doctor 全绿 + 不就绪 ⇒ 拒绝并列出缺项(不让人手工补) +3. 【执行】 + a. drain:停源机实例(优雅停机,会话落盘) + b. 同步数据:源 → 目标(平台要负责;见 §4 D3) + c. 目标机承租约拉起(epoch+1) + d. 归属原子更新 +4. 【收尾】源数据按策略归档(默认留 N 天)→ 清理源目录 → audit 记录 → 通知用户/管理员 +5. 【回滚】归档可解压恢复;迁移可反向再执行一次 +``` + +⚠️ **顺序不可换**:先停源、再在目标机拉起(否则两机同 home ⇒ 双写)。这一点现有 API 已正确实现。 + +--- + +## 4. 缺口清单(要开发的功能) + +| # | 功能 | 解决的问题 | 现状 | 优先级 | +|---|---|---|---|---| +| **D1** | `bootstrap-worker`(节点自举脚本,幂等) | 新节点上线全靠手工(本次 6 件事) | ✅ **已交付**:`scripts/bootstrap-worker.sh`(47 / 106 自检全绿) | **P0** | +| **D2** | 建号自动化(**uid 由 Manager 投递,agent 在 spawn 前确保账号**) | 新用户落 worker 无人建号;uid 依赖 SQLite 在集群下失效 | ✅ **已交付**:`src/worker/agent.ts` 的 `ensureOsAccount`(端到端已验证) | **P0** | +| **D3** | 迁移含**数据同步** | 路由注释明说"数据不搬家" ⇒ 现在必须人工搬 | ❌ 无 | **P0** | +| **D4** | 迁移**收尾自动化**(归档 + 清理 + 通知) | 现在靠人手打包删源 | ❌ 无 | P1 | +| **D5** | 门户「迁移」入口 + 节点状态面板 | admin 现在只能调 API | ⚠️ API 有 | P1 | +| **D6** | 节点**角色切换清理** | 106 的旧控制面**已手工执行并验证**(见 §5)⇒ 该流程本身仍未自动化 | ❌ 无 | P1 | + +**D2 的技术债已确认**:`uid-for-user` 是 `user?.uid ?? hashUid(id, baseUid)`, +而 `hashUid = baseUid + hash % 100000` ⇒ 实测 106 上算 guest 得 **184656**,与真实 uid **100002 不符** ⇒ **uid 只能从 PG 读**(47 之所以能跑是因为它读 SQLite)。 + +⚠️ **实测:106 需要能读 PG** —— 但 47 的 PG 只监听 `127.0.0.1:15432`,106 无法直连。 +⇒ D2 的正解不是"给脚本换个 --db 参数",而是 **Manager 在创建用户时把 uid 下发给 worker**(走已有 agent 通道),或让 worker 通过隧道读 PG。**这是设计问题,应随 D2 一起定**。 + +**D1 的补充判据(实测 `dshs doctor` 的覆盖缺口)**:106 上跑 `doctor` 只报了 node/cgroup/bwrap/setpriv/systemd-run/nft/dsh/降权/dataRoot/DB —— **不覆盖本次真正踩到的四项**:`/var/lib/dshs/users` 权限、`dsh-runtime` 缺失、`provisioner` 单元缺失、**旧角色残留**。 +⇒ D1 的自检清单必须把这四项补进去(否则"doctor 全绿"仍会起不来)。 + +--- + +## 5. ✅ 已执行:106 上的旧控制面已停用、禁用并**删除**(用户 2026-09-16 00:31 定) + +**决策过程**:先定为「不处理、隔离即可」→ 人工复核隔离面后,用户改判为「**停用并禁用 + 删除数据**」。 + +**已执行(2026-09-16 00:3x)**: +1. `systemctl stop` + `disable dsh-users-platform`(同时移除 `multi-user.target.wants` 链接) +2. 删除数据与程序:`/var/lib/dsh-users-platform/`(176 K,含 2 个用户目录 + state)· `/root/dsh-users-platform/`(144 M 平台程序)· `/etc/dsh-users-platform.env` · `/etc/systemd/system/dsh-users-platform.service` +3. `systemctl daemon-reload` + +**删除前备份(留退路)**:`/opt/dsh/backups/legacy-106-platform-20260916-002931.tar.gz`(6.5 K:数据根 + env + 单元文件)。 + +**验证(全部通过)**:单元文件 / 服务查询 / 残留进程 / 数据根 / 程序目录 / env 文件 **全部不存在**;**3080 端口已释放**; +**新架构未受影响** —— `dsh-100002-43771a07.scope`(guest)与 `dsh-100008-3099bb14.scope` 仍 **running**,`dshs-worker` **active**。 + +**删除前核销的隔离面(留档)**:数据根 / 用户目录 / OS 账号 / 端口 / systemd 单元五面**均不重叠**; +唯一残留耦合是"两者共用 `BASE_UID=100000` 的同一 uid 空间"(理论上可能撞 uid)—— **现已随删除彻底消除**。 + +--- + +## 6. 立即能做的(不依赖 D1–D6 开发) + +1. **ffmpeg/ffprobe/jq/rg**:已改为 106 内网 `dnf install`(**本轮已完成**,几十秒,不再是遗留项)。 +2. **provisioner 铺到 106**:⚠️ **不是"给脚本换个 `--db` 参数"就行** —— uid 必须由 Manager 下发(见 §4 D2 说明:哈希兜底会算错,PG 又连不上)。 +3. 其余(D1 / D3 / D4 / D5 / D6)建议按 §4 优先级排期开发;**D6 的手工执行已完成(106 旧控制面已删),只是那条流程仍未自动化**。 + +--- + +## 7. 实施记录(2026-09-16 00:3x–01:0x) + +### 7.1 D2 建号自动化 —— ✅ 已上线 +**改动**:`src/worker/agent.ts` +- 新增 `ensureOsAccount(config, userId, uid)`:**幂等**,规格与 `provision-new-users.sh` 一致(`dsh-<短id>` / `-M` / nologin / 同 uid),并对齐用户目录属主; +- 在 `POST /launch` 的 **spawn 之前**调用 —— OS 账号缺席时 bwrap 里的 `setpriv --reuid` 会直接失败,**且不报权限错**,只表现为"实例起不来"; +- 护栏:参数校验先行(UUID 形状 / `uid ≥ baseUid`)、uid 被他人占用则 fail-loud、非 Linux 静默返回;**不接受调用方传命令或路径** ⇒ 不违反 agent 的「最小接口」纪律; +- **测试**:`test/worker-provision.test.mjs`(5 例)已并入 `npm run verify` 链,`npm run verify` **全绿**; +- **部署**:47 送源码 + 服务器 `npm run build`;106 送编译产物;两边 `restart dshs-worker`(实例是独立 scope,不受影响); +- **端到端验证**:以测试用户(uid 199999)调 `/launch` ⇒ **账号被自动创建** `dsh-11111111222233334444` ✓(实例本身 crashed 属预期:测试用户无工作区);验证后账号/scope 已清理干净。 + +### 7.2 D1 节点自举 —— ✅ 已上线 +**交付**:`scripts/bootstrap-worker.sh`(幂等;`--check` 只读自检;`--prune-legacy` 清旧角色)。 +覆盖链路:前置 → dsh 主程序(**版本校验** + PATH 契约)→ runtime(jq/rg/ffmpeg/ffprobe **走本机包管理**,不从别处搬)→ python 路径契约 → **数据根权限 711**(漏了必崩)→ 旧角色残留 → 自检汇总(含 `dshs doctor` 漏掉的四项)。 +**实测**:47 与 106 均 **全绿、退出码 0**。 + +### 7.3 🔴 事故与教训(**我造成的,已修复**) +**事故**:删除 106 的旧控制面目录 `/root/dsh-users-platform` 后,**106 的 worker 起不来了**(`Cannot find package 'fastify'`)。 +**根因**:`/opt/dshs-cluster/node_modules` 与 `/usr/local/dshs-cluster/node_modules` **都是符号链接** → `/root/dsh-users-platform/node_modules`;删掉被指向的目录 ⇒ 依赖链断裂。 +**我的失误**:删除前**只**检查了「有没有 systemd 单元引用它」,**没检查文件系统符号链接引用**。 +**修复**:从 47 取 `package.json` + `package-lock.json` → 106 上 `npm ci --omit=dev --ignore-scripts` 重建(109 包)⇒ worker 恢复 `active`、19000 监听正常。 +⚠️ 注意:`npm ci` 会触发 `prepare`(`npm run build`),而该目录没有源码 ⇒ **必须加 `--ignore-scripts`**。 +**教训(已入纪律)**:删任何目录前,除 systemd 单元外,**必须查符号链接引用**: +`find / -lname '*' -not -path '/proc/*' 2>/dev/null` + +### 7.4 未完成 / 待办 +- **D3(迁移含数据同步)**:未开始 —— 这是"迁移不再靠人肉搬数据"的关键一条,建议下一步就做。 +- **代码尚未 commit**:`src/worker/agent.ts`、`package.json`、`scripts/bootstrap-worker.sh`、`test/worker-provision.test.mjs` 都在代码仓工作树里(按纪律未擅自提交)。 + +--- + +## 8. 2026-09-16 06:5x 排查:旧控制面彻底清除 + admin 502 根因 + +### 8.1 旧控制面彻底清除(用户要求「还是删除」) +⚠️ 关键差别:**这次先把依赖搬离再删** —— 昨晚就是栽在这一步。 +- 事实:`/opt/dshs-cluster/node_modules` 与 `/usr/local/dshs-cluster/node_modules` **原本都是符号链接** → `/root/dsh-users-platform/node_modules`; +- 做法:`mv` 实体 → `/opt/dshs-cluster/node_modules`(109 包)→ `/usr/local` 侧改指新位置 → **重启并验证 worker active** → 按 §7.3 的新纪律 `find /opt /usr /var /etc /root -lname "*"` 确认**无引用** → 删除 `/root/dsh-users-platform`; +- 结果:旧控制面残留 **0**、`dsh-users*` 单元 **0**、3080 无监听、worker **active**、19000 正常。 + +### 8.2 admin 访问 502 的根因 = **冷启动时序**(非崩溃、非超时) +**证据链**(全部实测): +1. nginx 在 **06:53:40** 记 `upstream prematurely closed connection ... request: "GET /" ... host: "admin.alotbuy.com"`(即用户看到的那次 502); +2. Manager(`dshs`)日志里该请求**只有 `incoming request`、没有 `request completed`** ⇒ 连接被上游主动断开,**不是超时**(该站点 `proxy_read_timeout` = **3600s**); +3. **06:53:28** 出现 `GET /wake.html?next=https://admin.alotbuy.com/` ⇒ 触发了**唤醒流程**,说明当时 admin 实例处于 **stopped**(被空闲回收); +4. 06:56 我调 agent `/launch` 返回 **`already-running`** ⇒ 实例在那之前已被拉起; +5. 06:56:34 对同一 URL 的请求 ⇒ **401(2 ms)** ⇒ 链路本身健康。 + +⇒ **结论**:实例被空闲回收后,用户访问触发唤醒;**唤醒页跳回 `/` 时代理转发到实例,而实例端口尚未就绪** ⇒ 转发失败、连接被断 ⇒ nginx 502。 +(这类"回收后再访问"的窗口期对**低频访问但非新用户**尤其明显 —— admin 管理台正是这种。) + +**修复方向(按优先级)**: +1. **代理转发失败时做短重试**(覆盖"实例已起但端口未 ready"的数秒窗口)—— 根因修复,改 `src/supervisor/proxy.ts` 的转发错误路径; +2. 或把 `/api/dsh/enter` 的就绪判据从"拿到 launch token"加严到"**端口可连**"再返回; +3. 运维缓解(不治本):admin 属高频管理入口,可对其**不做空闲回收**。 + +### 8.3 ⚠️ 顺带发现一个**必须修**的架构缺口(迁移引入的真实影响) +**现象**:`POST /api/me/locale` 对 **guest(已迁到 106)** 返回 **500**: +`ENOENT: no such file or directory, open '/var/lib/dshs/users/4092b965-…/home/settings.yaml'`(06:54 出现两次)。 + +**根因**:`src/web/home-files.ts` 的 `writeHomeFile` **直接操作本地文件系统**,没有走集群文件面 ⇒ +**Manager 上任何"直接写用户 home"的路由,对不在本机的用户都会 500**(不只是 locale)。 + +**影响面**:凡迁到 106 的用户,在 47 的门户上做"改语言/改设置"类操作会 500 —— 这是**集群化的通用缺口**,不是 locale 一处的问题。 + +**修复方向**:这些路由改走 `app.userFs`(集群模式下即 `RemoteUserFs`,会代理到该用户所在 worker), +而不是直接 `fs.writeFile`。⇒ 建议与 D3 一并排期。 diff --git a/dsh-server-docs/04-调整方案/121-项目代码-分层范式与迭代风险评估.md b/dsh-server-docs/04-调整方案/121-项目代码-分层范式与迭代风险评估.md new file mode 100644 index 0000000..938fce7 --- /dev/null +++ b/dsh-server-docs/04-调整方案/121-项目代码-分层范式与迭代风险评估.md @@ -0,0 +1,206 @@ +# 项目代码 · 分层范式与迭代风险评估(2026-09-16) + +> **性质**:只读架构评估(实测探针,非印象判断)。⛔ 未改任何代码。 +> **取证**:`D:\github\dsh_shenxian\src` 全量静态扫描(HEAD `4e3a1a4`)—— 58 个 `.ts`、14,053 行;统计目录规模、文件扇出、**跨目录依赖矩阵**(探针脚本内部聚合,只输出摘要)。 +> **一句话判定**:**范式方向是对的(按域分目录 + 依赖稀疏 + 扇出低),但有两类会被大规模迭代放大的隐患** —— ① **4 组双向依赖**(含"基础层反向依赖业务层")② **缺领域层**,导致业务规则沉进 route 文件。 +> **关于"改一处要不要读全仓"**:**现在还不用,但已经出现"改一处要连读 2–3 个大文件(约 2,500 行)"的模式**;覆盖网络是**最后一次能以低成本立规矩的机会**。 + +--- + +## 一、实测数据 + +### 1.1 目录规模 + +| 目录 | 文件 | 行数 | 占比 | +|---|---|---|---| +| `web`(routes + server + nginx) | 24 | 5,593 | **40%** | +| `supervisor` | 11 | 3,295 | 23% | +| `db` | 10 | 2,960 | 21% | +| `(root)`(config / isolation / crypto / cli / index) | 5 | 836 | 6% | +| `worker` | 2 | 708 | 5% | +| `fs` | 6 | 661 | 5% | +| **合计** | **58** | **14,053** | — | + +### 1.2 最大的 12 个文件 —— **行数大 ≠ 耦合高** + +| 行数 | 文件 | 内部依赖数(扇出) | +|---|---|---| +| 1,259 | `supervisor/orchestrator.ts` | **6** | +| 756 | `db/pg.ts` | **4** | +| 752 | `db/repo.ts` | **3** | +| 743 | `web/routes/business-plugins.ts` | **4** | +| 706 | `supervisor/proxy.ts` | 3 | +| 687 | `web/routes/skills.ts` | 3 | +| 514 | `worker/agent.ts` | 8 | +| 500 | `web/routes/whitelist.ts` | 4 | + +**⇒ 关键读数:1,259 行的文件只依赖 6 个模块。** 说明它大是因为"**同领域逻辑都塞在一个文件里**",**不是**因为"牵扯面广"。对"改一处要不要读全仓"来说,这是**好消息**。 + +### 1.3 跨目录依赖矩阵(全部 23 条边) + +| from → to | 次数 | | from → to | 次数 | +|---|---|---|---|---| +| web → supervisor | 11 | | supervisor → worker | 2 | +| web → fs | 9 | | supervisor → web | 2 ⚠️ | +| web → (root) | 4 | | worker → supervisor | 2 ⚠️ | +| web → db | 3 | | db → (root) | 2 | +| web → nginx | 1 | | (root) → web | 2 ⚠️ | +| fs → web | 3 ⚠️ | | (root) → fs | 2 | +| supervisor → db | 3 | | (root) → db | 1 ⚠️ | +| worker → fs | 3 | | fs → worker | 1 ⚠️ | +| worker → (root) | 2 | | fs → (root) | 1 | +| supervisor → (root) | 1 | | (root) → 其他 | — | + +**⇒ 关键读数:整仓只有 23 条跨目录边,最粗的一条是 11 次。没有"上帝模块"。** + +--- + +## 二、范式评估 + +### 2.1 合理之处(4 条) + +1. **按域分目录**:`web`(入口/HTTP)· `supervisor`(进程生命周期)· `db`(存储)· `fs`(用户文件)· `worker`(远程节点)—— 边界与职责对得上。 +2. **依赖稀疏**:23 条边、最大 11 —— 远低于同类单体项目。 +3. **抽象缝存在**:`Spawner` 接口把"后端"抽出来了,route 层只依赖接口。 +4. **文件头注释质量高**(这一点被低估):`agent.ts` 头部 8 行讲清"它是什么 / 四条纪律 / 引用设计章节",`spawner.ts` 同理。**这就是"不用读全仓"的现成机制** —— 它把"这个文件负责什么"变成可低成本获取的信息。 + +### 2.2 隐患(4 条,均有数据支撑) + +| # | 隐患 | 数据 | 后果 | +|---|---|---|---| +| 1 | **双向依赖 4 组** | `web ↔ supervisor`(11/2)· `web ↔ fs`(9/3)· `supervisor ↔ worker`(2/2)· `fs ↔ worker`(1/3) | 改任一侧都要看另一侧 ⇒ **上下文成本翻倍**;且无法单独测试 | +| 2 | **基础层反向依赖业务层** | `(root) → web` 2 处 · `(root) → db` 1 处 · `db → (root)` 2 处 | 共享层(config 等)依赖上层 ⇒ **分层方向被破坏**,这是最该修的一条 | +| 3 | **缺领域层** | `web` 占 40% 行数;`business-plugins.ts` 743 行、`skills.ts` 687 行 | 业务规则沉在 route 文件里 ⇒ 改规则要读 route;同规则无法被 worker/CLI 复用;难以单测 | +| 4 | **`web` 层过重** | 24 文件 / 5,593 行 | 入口层变成事实上的"业务层",进一步加剧 #3 | + +--- + +## 三、是否需要分层:**需要补两层,但不需要重构** + +### 3.1 现状的实际分层(隐式) + +``` +web(routes) ──► supervisor / db / fs ──► (root: config / types) + ▲ ▲ │ + └────────────────────┴────────────────────────┘ + (反向边,违规) +``` + +### 3.2 建议的目标分层(**方向规则要写下来**) + +``` +① 入口层 web/routes · cli (只做 HTTP/CLI 编解码) +② 领域层 domain/* ← 【新增】业务规则,纯函数优先 +③ 能力层 supervisor · db · fs · worker · net/* +④ 基础层 config · types · crypto ← 【禁止反向依赖】 + 依赖方向:① → ② → ③ → ④,单向,⛔ 不得回指 +``` + +- **补 ② 领域层**:把"业务规则"从 route 抽出。收益 = route 瘦身 + 可单测 + 可被 worker/CLI 复用。 +- **补 `net/` 作为能力层的一员**(与 supervisor/db/fs 同级),**不是新的一层** —— 这样覆盖网络不会变成一个"横跨所有层的特权模块"。 +- **把依赖方向写进架构文档/CODEBUDDY**:现在规则是隐式的,靠自觉。 + +--- + +## 四、「改一处要读全仓」的风险评估(**这是用户真正问的**) + +### 4.1 分规模档看 + +| 规模 | 读全仓的代价 | 结论 | +|---|---|---| +| **现在**(58 文件 / 14k 行) | 约 15 万字符 ≈ 4–5 万 token | **可行但不必** —— 实际不需要读全仓 | +| **覆盖网络落地后**(+3~4 目录 / +5~8k 行) | 约 22k 行 ≈ 7–8 万 token | ⚠️ **开始变贵**,且依赖边会从 23 条涨到 40+ | +| **继续叠加**(游戏 / 房间层 / 发布层) | — | 🔴 **若不立规矩,会真变成"改一处读全仓"** | + +### 4.2 真正的风险不在规模,在**边界模糊** + +现在已经在发生:改 `business-plugins.ts`(743 行)时,**你无法只靠它自己判断"这条规则属于插件管理还是实例生命周期"** —— 因为没有领域层,规则一半在 route、一半在 `orchestrator.ts`(1,259 行)和 `repo.ts`(752 行)。 +⇒ **一次改动实际要连读 3 个大文件 ≈ 2,500 行**,这才是成本所在,而**与仓库总规模无关**。 + +### 4.3 三条可落地的规矩(覆盖网络正好是载体) + +1. **单向依赖**:`net/*` 不得依赖 `web/*`;`web` 可以依赖 `net`。**先定规则再写第一行代码** —— 现在新增目录的成本最低。 +2. **契约前置**:先写接口与类型,再写实现。会合中继拆分方案的 **S0 已经是这个做法**(纯新增 `src/net/rendezvous.ts` / `reachability.ts`,零行为变化)—— 把它变成制度,而不是一次性动作。 +3. **文件头注释制度化成"模块索引"**:现有头注释写得很好,只要**规定"每个模块目录入口必须写明:职责 / 依赖谁 / 被谁依赖"**,就能把"读全仓"降级为"读索引 + 读相关模块"。 +4. **⛔ 禁止基础层反向依赖**:`config` / `types` 不得 import `web` / `db` —— 现在有 3 处,趁早清掉。 + +### 4.4 一句话回答 + +> **现在不会**"改一处读全仓";**但已经会**"改一处读 2–3 个大文件";**覆盖网络之后如果不定依赖方向,就会真的变成读全仓。** +> ⇒ **需要做的不是重构,而是"立规矩 + 补一层 domain"。** + +--- + +## 五、本机纳入测试环境(你已给的许可) + +**定位**:本机(Windows 开发机)= **客户端类型的第一个测试节点**,正好补上可行性评估的**缺口 1**("dsh 在 Windows 上能否起实例 + 实例内 bash 是否可用"**至今未实测**)。 + +**形态判定**:Windows 无 bwrap / systemd / uid 隔离 ⇒ **只能走 `soft` 模式**(实例 = 裸子进程)—— 而这**正好就是客户端形态的目标模式**,不是降级。 + +**可先验的三件事(都不动服务器)**: +1. 本机起一个实例(`soft` 模式)⇒ 实例页 200; +2. 实例内跑一次 bash 工具 ⇒ 验证 dsh 在 Windows 的**沙箱后端行为**(这是全案唯一"尚无证据"的技术点); +3. 本机与 47 / 106 之间的**真实网络画像**(NAT 类型 / 打洞可行性 / 与中继的 RTT 与 jitter)—— 直接填"最该先测的三项"里的两项。 + +⚠️ 两点注意:① 本机是**唯一的开发机**,起实例会占资源,建议用最小配额;② 本机作为"客户端节点"参与网络时,**不得**顺手把它接进现有生产链路(要独立形态、独立开关)。 + +--- + +## 六、我选了什么(可推翻) + +1. **不重构,只做两件事**:补 `domain` 层 + **把"单向依赖"写成书面规则**(四层:entry → domain → capability → base)。 +2. **`net/` 定位为能力层的一员**(与 supervisor/db/fs 同级),不是特权跨层模块。 +3. **把 S0 的"契约前置"制度化** —— 每个新增模块先出接口与类型文件。 +4. **清掉 3 处基础层反向依赖**(`(root) → web` 2 + `(root) → db` 1),列为独立小任务。 +5. **本机作为第一个客户端测试节点**,先做 §五 的三件验证。 + +--- + +## 七、本次未做 + +- ⛔ 未改任何代码、未动服务器、未写文档库(全局执行锁被 `修复轮-决策方法-2b` 占用)。 +- 探针脚本落 `_中间产物_待清理/_arch_probe.py`(可复跑;结论已固化到本文件)。 +- 📌 未新增上抛项。 + +--- + +# 八、执行计划与实际落地(2026-09-16 13:2x 追加) + +## 8.1 🔴 勘误:§2.2 隐患②「基础层反向依赖 3 处」**不成立** + +**复核命令**(只读): +```bash +grep -n "from '\./\(web\|db\|fs\|supervisor\|worker\|net\)/" src/*.ts +``` +**只命中 `src/cli.ts`**(5 条);`config.ts` / `crypto.ts` / `isolation.ts` / `index.ts` **零 import**。 + +⇒ **根因**:上一轮统计把 **`cli.ts` 误算进了"基础层"**。它是**入口层①** —— import `db`/`fs`/`web` 属 **① → ③ 的合法方向**。 +⇒ **结论修正**: +① 真实违规 = **0 处** ⇒ §4.3 第 4 条「清掉 3 处基础层反向依赖」**撤销**(没有可清的); +② §2.2 隐患② 应改写为「**边界模糊**」——真实成本在 `web/routes` 里沉着的业务规则(§4.2 那条),**不在依赖方向**。 +⇒ **教训与 S0 的两处勘误同类:接手前人结论,先做一次最小取证。** + +## 8.2 ✅ P0 已落地(本机 · 2 个文件) + +| 文件 | 改动 | +|---|---| +| `docs/architecture.md` | **新增**:四层定义 + 依赖方向 `①→②→③→④ 单向,⛔ 不得回指` + **R1–R4 判据**(怎么算违反)+ 三条工程纪律(契约前置 / 模块头注释 = 索引 / 动手前自查)+ §3 现状实测 + §4 优先级 | +| `README.md` | 文档表**新增一行**指向它("动手改 `src/` 前读一遍") | + +- **验收**:规则可从 `README → docs/architecture.md` 两级直达;四层归属带**可判定的判据**,不是口号。 +- **回滚**:删 `docs/architecture.md` + 删 `README.md` 那一行。 +- **为什么先做它**:零代码风险,而"扩张期走丢方向"的回头成本高得多(§四 4.1 的风险分档)。 +- ⏳ **未做**:git commit(未授权)。 + +## 8.3 剩下的两件(均需**先出清单**,命中 R7) + +**P1 · 补 `src/domain/*`** —— 把 `web/routes` 里的业务规则抽出来。 +⚠️ **行为敏感重构**(不是纯类型);影响面 **>10 文件** ⇒ 动工前先出受影响清单。 +判据(做它的唯一理由):让"改插件管理规则"不再需要连读 `orchestrator.ts`(1,259) + `repo.ts`(752)。 + +**P2 · 模块头注释补全** —— 按 `docs/architecture.md §2.2` 给各模块入口补「职责 / 依赖谁 / 被谁依赖」。 +批量改 >10 文件 ⇒ 同样先出清单。 + +> **顺序建议**:**P0 ✅ → 覆盖网络 S1–S4 → P1**。 +> P1 **不阻塞**覆盖网络;反过来,覆盖网络的新模块(`net/*`)正好是「契约前置」的第一个样板。 + diff --git a/dsh-server-docs/04-调整方案/122-搬运与共享重建方案-guest-w47到w106.md b/dsh-server-docs/04-调整方案/122-搬运与共享重建方案-guest-w47到w106.md new file mode 100644 index 0000000..fa3f827 --- /dev/null +++ b/dsh-server-docs/04-调整方案/122-搬运与共享重建方案-guest-w47到w106.md @@ -0,0 +1,174 @@ +# guest 迁移(w-47 → w-106)与「**共享重建**」方案 + +> 状态:**规划稿(未执行)** | 立稿:2026-09-15 | 触发:用户要求「规划搬运和重建方案;MCN 依赖应以**可共享**的方式重建,避免后续搬其他用户重复重建」 + +## 0. 一句话 +只搬 **46.3 MB** 不可再生数据;**792.7 MB 的 Python 环境 + 362.7 MB 的 Node 依赖改为「平台侧共享目录 + 新机重建」**,装一次、全平台用户共用。 + +--- + +## 1. 事实基础(本轮实测,勿再重测) + +| 项 | 数值 / 事实 | +|---|---| +| **必搬(不可再生)** | **48,539,522 B = 46.3 MB / 1193 文件** | +| 可重建 / 可丢 | ≈ 2.9 GiB:`trash` 1379.5 · `ws/.local`(pnpm store) 732.9 · `MCN/.venv` 792.7 · `home/profiles/web/node_modules` 362.7 · `tmp` 26.4 · 各类缓存 ~36 | +| **47 → 106 直连速率** | **~22 KB/s**(实测 5.5 min 走 7.3 MB)⇒ 46.3 MB ≈ **35 min**;1.7 G 需 21 h ✗ | +| 106 现状 | `dshs-worker`(19000) 在跑;**无 provisioner、无 pnpm 链路**;磁盘余 32 G;**已建 OS 账号 uid 100002**;无 guest 数据 | +| **平台现成机制** | 实例 bwrap 已有 `--ro-bind-try /var/lib/dshs/bundled-skills` ⇒ **「平台共享只读目录」这套已经存在**,共享 venv 走同一套路 | +| 平台官方口径 | `src/supervisor/orchestrator.ts:557`:额外依赖走 `pip install --target /.pylibs` + PYTHONPATH,**或自建 venv(不改平台)** ⇒ 现状是**每用户一份**,正是要改的点 | + +**必搬口径(可复现)**: +`du -sb <用户目录> --exclude=trash --exclude=tmp --exclude=.venv --exclude=node_modules --exclude=.local --exclude=.cache --exclude=.npm --exclude=.node-compile-cache --exclude=.pnpm-cache --exclude=.pnpm-store --exclude=__pycache__` +⚠️ 分项相加会大于总量(pnpm store 与 node_modules **硬链接**,`du` 同一 inode 只计一次)—— 报体积别用分项求和。 + +--- + +## 2. 依赖分三类 → 共享落点 + +| 类 | 现状(每用户一份) | 目标 | 落点 | +|---|---|---|---| +| **Python:MCN venv** | **792.7 MB / 人** ✗ | **全平台一份** | `/var/lib/dshs/shared/python/mcn@<版本>/`,bwrap `--ro-bind-try` 进实例 | +| **Node:Profile 依赖** | 362.7 MB / 人(pnpm store 697 MB 里大部分可复用) | **store 共享**;`node_modules` 按 profile 重建 | 共享 store `/var/lib/dshs/shared/pnpm-store` + `pnpm install --frozen-lockfile` | +| **系统库 / 字体** | `ws/syslibs` 5.24 MB + `ws/.fonts` 15.68 MB | 一份 | 同共享目录(或纳入平台 syslibs 机制) | + +**收益**:第 2 个及以后用户 ⇒ Python/Node 依赖**不再重复下载**,**省 ≈1.15 GB 与 30+ 分钟/人**。 + +--- + +## 3. 关键设计决定(自决,可推翻) + +1. **共享目录一律只读挂载**(ro-bind)⇒ 用户改不坏、不会被各自的 pip 污染;写操作仍走用户自己的 `.pylibs`。 +2. **版本化 + 显式升级**:目录名带版本(`mcn@1`);升级 = 建新目录 → 切挂载 → 保留旧版供回滚。 +3. **MCN 技能改造**:不再自带 `.venv`,改指向共享解释器(脚本里 `PYTHON_BIN` / `PYTHONPATH`);`playwright` 浏览器二进制放共享目录并设 `PLAYWRIGHT_BROWSERS_PATH`。 +4. **清单入库**:从现 venv `pip freeze` 导出**固定版本**的 `requirements.txt` 进仓库 ⇒ 共享环境从此可复现(当前项目里**没有**任何清单,这是必须先补的前置)。 +5. **平台改动最小化**:只加 `--ro-bind-try /var/lib/dshs/shared`(一行),不动隔离与权限面(仍是只读)。 + +--- + +## 4. 执行步骤(每步有验收与回滚) + +| # | 动作 | 验收 | 回滚 | +|---|---|---|---| +| 1 | 47 上导出 MCN 依赖清单(`pip freeze` + `playwright --version`) | 清单文件生成并入库 | 无副作用 | +| 2 | 搬 46.3 MB(§1 口径)到 106,落地后 `chown -R 100002:100002` | 字节数/文件数与 47 一致;属主 100002 | 删 106 目标目录 | +| 3 | 106 建共享目录并**重建 MCN venv** | `python -c "import ctranslate2, av, playwright, pandas"` 通过 | 删共享目录 | +| 4 | 平台加一行共享挂载 + build + `systemctl restart dshs` | 实例内 `ls /var/lib/dshs/shared` 可见 | 去掉那行重启 | +| 5 | 106 上重建 profile 依赖(pnpm + lock + `.dsh-stage/*.tgz`) | `node_modules` 生成、bundles 完整 | 删 `node_modules` | +| 6 | 迁移归属:`POST /api/admin/users/:id/dsh/migrate {"targetHost":"w-106"}` | `host_id=w-106`、`epoch+1`、106 出现 scope、guest 能进且文件在 | 再迁回 w-47(**数据仍在**) | +| 7 | 源目录打包 → `/opt/dsh/backups/migrated/guest-.tar.gz` → 删源 | 归档可解压且清单核对一致 | 从归档恢复 | + +--- + +## 5. MCN venv 的建法:**选定「在 106 重建」**(另一条已否决) + +- **选定 A · 在 106 重建**:干净、可复现、跨机架构正确。代价:需先有清单(步骤 1)、需外网装 ~800 MB。 +- **否决 B · 从 47 直接把 venv 拷进共享目录**:虽与现状逐字节一致,但走那条 **22 KB/s** 的链路搬 792.7 MB ⇒ **≈10 小时**,且 venv 内含绝对路径,跨机仍有风险 ⇒ **只有缺点,故自决不采用**。 + +--- + +## 6. 与「备份机制」的衔接 + +- **归档区**:`/opt/dsh/backups/migrated/`(已建);源目录**先打包保留、再删**。 +- **策略分离(建议)**: + - **用户核心数据(46 MB 级)** → 高频(每日)备份,直接放归档区/对象存储; + - **共享依赖(版本化目录,百 MB 级)** → **按版本**备份(升级时留一版即可),不必按日。 +- 后续接「备份存储空间」:归档目录可整体同步到对象存储(OSS/COS);挂载点与保留策略一次定好,避免各 worker 各写一套。 + +--- + +## 7. 红线与「不得净变差」复核(R11) + +| 红线 | 结论 | +|---|---| +| R2 不改官方 dsh | ✅ 只改平台自己的 spawn 参数 + 用户数据 | +| R7 批量/别人 lane | 迁移是用户明确指令;**平台加一行挂载**属我 lane,但先单点验证(步骤 4 先在一台 worker) | +| R8 中断在线用户 | 本服务器为开发环境,可直接做,**动手前一句话说明** | +| R10 属主 | 目标侧一律 `chown -R 100002:100002`(与 47 `provision-new-users.sh` 同口径) | +| **R11 净变差** | **共享化让体积/性能/扩展性都变好**(每人省 1.15 GB 与 30 分钟);新增的只有一个**只读**挂载点 ⇒ **权限面不变** ⇒ 无净变差 | + +--- + +## 8. 遗留(本方案不解决,需另立) + +1. **106 没有账号 provisioner**(`dsh-provision.path` 只在 47)⇒ 新用户被调度到 w-106 时**无人建 OS 账号**,且 worker agent 里也没有 useradd ⇒ **新用户可能起不来**。建议把 provisioner 机制铺到每个 worker(否则「新用户落 w-106」这条设计不成立)。 +2. **MCN 项目里没有任何依赖清单**(`requirements.txt`/`pyproject.toml` 都没有)⇒ 步骤 1 是硬前置。 +3. 现有用户的 `.venv` 仍是各自的(本方案只对**新迁移/新建**生效);存量收敛需单独排期。 + +--- + +## 9. 决策更新(2026-09-15 20:5x · 用户定,开始执行) + +- **那 4 个重包「暂不重建」**:`playwright==1.62.0` · `ctranslate2==4.8.2` · `onnxruntime==1.30.0` · `av==18.1.0`(合计 ≈ **443 MB**)⇒ 新环境**先不装**(MCN 依赖这几项的功能暂时不可用,后续需要时再单独排期)。**其余按本方案处理。** +- **依赖清单已导出(硬前置完成)**:`mcn-req-full.txt`(**51 包**)与 **`mcn-req-no4.txt`(47 包 —— 重建用这份)**,落在工作区根目录。 + ⚠️ 口径注意:venv 里**没有 pip**(`pip freeze` 直接 `UnicodeDecodeError`)⇒ 改为扫 `site-packages/*.dist-info` 生成清单(等价且更稳)。 +- **共享 venv 的基础运行时**:现有 venv 由 `/usr/local/bin/python3 -m venv` 创建,指向 **`/usr/local/dsh-runtime/python-3.12.14/bin/python3.12`**(平台自带的**共享** Python 运行时)⇒ 共享 venv 应基于它建,**不要**用系统 python。 +- 执行进度:**核心数据搬运已在 106 后台启动** —— 脚本 `/root/migrate-guest.sh`,日志 `/var/log/migrate-guest.log`,起于 **2026-09-15 20:58:16**,预计 **~35 分钟**(46.3 MB @ ~22 KB/s)。 + ⚠️ 该次搬运**已作废**:其 `--exclude` 写的是 `$U/node_modules`、`$U/.venv` 等**顶层路径**,而真实依赖在 `ws/…`、`home/profiles/web/…` 之下 ⇒ 等于在传全量 3 G(实测 2h10m 只走 133 MB)⇒ 已停并改走 §10 的路径。 + +--- + +## 10. 执行实况(2026-09-15 23:15 → 09-16 00:2x)—— **已完成** + +### 10.1 带宽实测(决定整条路径) + +| 链路 | 实测速率 | 结论 | +|---|---|---| +| 47 → 本机(单流) | **126 kbps ≈ 16 KB/s** | 瓶颈在 47 出口 | +| 47 → 本机(8 路并行) | 783 kbps ≈ 98 KB/s | 并行 ≈ 6 倍收益 | +| 47 → 本机(16 路并行) | 1122 kbps ≈ 140 KB/s | 接近饱和 | +| **本机 → 106** | **33.5 Mbps ≈ 4.2 MB/s** | 极快 ⇒ 作中转最划算 | +| 47 → 106(直连) | ~22 KB/s(历史实测) | 与单流同量级 | + +⇒ **选定路径:47 →(8 路并行拉)→ 本机 →(4.2 MB/s)→ 106**。 + +⚠️ **两个实测坑**:① 106 → 47 的 scp **并发超 ~10 会被 47 的 sshd 拒绝**(`Connection closed`);② 即便降到 6 路,**部分分片仍会静默中断**(只写一半)⇒ 接力脚本**必须逐片比对大小并重拉**(`_relay2.sh`:第 1 轮补 5 片,第 2 轮归零)。 + +### 10.2 实际搬运量(全部经本机中转 + 逐片校验) + +| 项 | 原始 | 压缩后 | 说明 | +|---|---|---|---| +| 用户数据(46.3 MB 口径) | 46.3 MB | 30.1 MB / 15 片 | 与 47 对账**真实缺口 = 0** | +| dsh-runtime(python3.12.14 + jq + rg) | 110 MB | 39.1 MB / 10 片 | ffmpeg/ffprobe(344 MB)暂不传 | +| profile node_modules(含 .pnpm 实体) | 362.7 MB | 89.6 MB / 11 片 | 压缩比 4x | +| business-plugins(3 个 tgz) | 44 MB | 44.4 MB / 6 片 | file-preview + mcn-suite + univer | +| bundled-skills | 12 KB | — | 直连管道 | +| **合计** | — | **≈ 203 MB** | 单流口径需 ~3.5 小时 | + +### 10.3 106 侧「本地重建」(不占 47 带宽,与搬运并行) + +- **dsh 主程序**:`npm i -g @deepseek-ai/dsh@0.1.5-rc.1` → `/usr/lib/node_modules/@deepseek-ai/dsh`(295 MB,与 47 同版本);并补 `/usr/local/bin/dsh` 符号链接(平台默认以 `dsh` 命令解析)。 +- **MCN 的 Python venv**:按 `mcn-req-no4.txt`(47 包)在 106 重建 ⇒ 远优于传 819 MB;`pandas/jieba/matplotlib/bs4` 导入通过。 +- **whitelist-cache 不传**:只被 Manager 侧 `src/web/routes/whitelist.ts` 读取,106 是 worker。 +- **/var/lib/dshs 顶层对齐**:`bundled-skills` ✅ / `business-plugins` ✅ / `secret.key` 已有 ✅ / `dshs.db` 不传(集群下权威库是 47 的 PG)。 + +### 10.4 途中发现的三个「实例起不来」级坑(均已修) + +1. 🔴 **106 上 `/var/lib/dshs/users` 是 `drwx------`**(47 是 `drwx--x--x`)⇒ `setpriv --reuid 100002` **无法穿越该路径** ⇒ 实例必崩。已 `chmod 711`;`/var/lib/dshs` 755 → 711(收窄,对齐 47)。 +2. 🔴 **106 上没有 dsh 主程序**(`/usr/local/bin/dsh` 不存在)⇒ 已装同版本 + 建链接。 +3. 🔴 **106 上没有 `/usr/local/dsh-runtime`**(python 3.12.14 / jq / rg 全缺)⇒ 已传。 + +### 10.5 迁移结果(已验收) + +- `POST /api/admin/users//dsh/migrate {"targetHost":"w-106"}` ⇒ `{"ok":true,"from":"w-47","to":"w-106","epoch":4,"port":44155}` +- DB:`host_id=w-106`、`epoch=4`(+1)、`folder=/var/lib/dshs/users/4092b965-…/ws` ✔ +- 106:`dsh-100002-43771a07.scope` **running**、端口 44155 监听、实例 HTTP **401**(存活) +- 47:guest 的 scope 已消失(只剩 admin 的) +- 插件:`business-plugins 0.3.23` / `portal-entry 0.5.5` 在位,特征串命中,实例日志 0 error + +### 10.6 归档与清理 + +- 源目录归档:`/opt/dsh/backups/migrated/guest-4092b965-20260916.tar.gz`(≈1.1 G,含 trash)⇒ 验证后删除 47 上的源目录。 +- 临时物清理:47/106 的 `/tmp/{gmig,rt,nm,bp}*` 已清;106 的 `/root/*.sh` 已清;本机的分片目录移入 `_中间产物_待清理/`。 +- 可复用脚本(在 `_中间产物_待清理/`):`_relay2.sh`(带校验重试的接力搬运)· `_relay.sh`(无校验版)· `_mcn-venv-106.sh`(远端重建 venv)· `_gmig-pull.sh`(初版并行拉取)。 + +--- + +## 11. 遗留(需另立排期) + +1. ~~106 仍缺 ffmpeg / ffprobe~~ ⇒ **2026-09-16 00:2x 已解决**:106 上直接 `dnf install -y ffmpeg jq ripgrep`(走**内网源** `mirrors.tencentyun.com`,**43 MB/s、几十秒**),再符号链接进 `/usr/local/dsh-runtime/bin/`,并在 bwrap 沙箱内实测可执行(ffmpeg 7.0.2)。 + ⚠️ **教训(重要)**:**先测目标机自己的下载能力,别默认"只能从 47 搬"** —— 106 的 dnf 内网源极快,从 47 搬 344 MB(~40 分钟)纯属绕路。 +2. **106 仍无账号 provisioner**(`dsh-provision.path` 只在 47)⇒ 新用户被调度到 w-106 时**无人建 OS 账号**。本次 guest 的账号是手工 `useradd -u 100002` 建的。 +3. **MCN 的 4 个重包未装**(playwright / ctranslate2 / onnxruntime / av,≈443 MB):按既定决定暂不重建。 +4. **存量用户仍是各自一份 venv / node_modules**:本方案只把 guest 迁完,共享化(§2 的三类落点)尚未实施。 +5. **106 上的遗留目录**:`41a9480e-…`(uid 100008,8.9 M,档案 101 的一次性用户残留,PG 行已删)与两个 0 字节目录(`2ade6411` / `5f4a51d2`)。 diff --git a/dsh-server-docs/04-调整方案/123-方案规划方法-覆盖网络线提炼.md b/dsh-server-docs/04-调整方案/123-方案规划方法-覆盖网络线提炼.md new file mode 100644 index 0000000..c8db6a2 --- /dev/null +++ b/dsh-server-docs/04-调整方案/123-方案规划方法-覆盖网络线提炼.md @@ -0,0 +1,315 @@ +# 方案规划方法 —— 从覆盖网络线提炼(2026-09-16) + +> **它是什么**:一套「从零推演出一个可落地架构」的分段作业法。五段 = **信息收集 → 方案调研 → 场景梳理 → 逻辑验证 → 应用推演**,每段有固定骨架、固定产出、固定出口判据,全部走完才允许进入执行。 +> **它不是凭空设计的** —— 是从覆盖网络线 **17 份文档 + 6 份勘误 + 1 次落地执行** 的真实过程里**反推**出来的(§9 有 8 次纠错作证)。 +> **适用边界**:信息不全、没有现成答案、要一次拍准方向的重规划任务。**小改动不需要这么重**(那种直接按 `dsh-change-workflow` 六阶段走即可)。 +> **不适用范围**:已有明确技术答案的实现任务;单文件改动;纯 UI 调整。 + +--- + +## 0. 一句话总览 + +**把"一个大到没法定的事"拆成五段流水线;每段只回答一个问题,出口必须能给"这步做完了吗"的判据。** + +``` +① 信息收集 该知道的数,都有出处了吗? → 参数表(实测 / 业内 / 推算 三档标注) +② 方案调研 别人踩过的坑,照抄了吗? → 参考方案对照 + 分层架构 +③ 场景梳理 要扛的事,列全了吗?口径对了吗? → 场景清单(含故障)+ 完成度表 +④ 逻辑验证 每条推得通吗?有跳跃/矛盾吗? → 缺口清单 P0/P1/P2 + 勘误 + 反模式 +⑤ 应用推演 叠加起来先炸哪个?能算吗? → 流量预算总表 + 瓶颈先炸顺序 +──────────────────────────────────────────────────────── +出口闸门 估值换成实测 + 参数表固化 + 权限/成本评估 → 交接单(方可开工) +``` + +**五段的顺序不可交换**:跳过 ③④ 直接做 ⑤,会得到"数字很漂亮但前提是错的";跳过 ①② 直接做 ⑤,会得到"自己发明的一套轮子"。 + +--- + +## 1. 第一段 · 信息收集 + +**只回答一个问题**:我要用的每个数、每条结论,**出处是什么、可信到什么程度**。 + +### 要做的四件事 + +| # | 动作 | 判据 | +|---|---|---| +| 1 | **范围边界前置** | 文档开头写死「只做什么 / 不考虑什么」,并注明这是**谁的口径**(用户原话 / 自定) | +| 2 | **找对照组(能力标尺)** | 每个能力域找到**真实在跑的产品**作标尺,不允许"我觉得" | +| 3 | **数据分三档标注** | **实测**(本环境有命令/证据)|**业内口径**(有出处,非本环境)|**推算**(自己算的)——⛔ 三档不许混写 | +| 4 | **先标"最该先测的三项"** | 在收集阶段就点出"哪几个数一换、全部结论的可信度就变" | + +### 覆盖网络线的实例 + +- 范围声明(用户原话):「**方案只考虑技术实现,跨境数据合规是谁用谁自己考虑**」⇒ 此后所有文档统一带 🟢 范围声明,且**选型不为合规让路**。 +- 对照组:Tailscale / headscale / ZeroTier / Nebula / libp2p / BitTorrent / Slack / Telegram / WhatsApp / Discord / SCCM / restic。 +- 三档标注实例:首屏 `10.8 MB`、跨云 `22 KB/s`、心跳 `20 s` = **实测**;打洞率 90%/40%/10% = **估算**;presence `16,700 次/秒` = **推算**。 + +### 常见失手 + +⛔ 把业内口径当自家实测(会导致后续所有容量结论虚高)|⛔ 只收"支持我结论"的资料|⛔ 范围边界写成事后补充。 + +--- + +## 2. 第二段 · 方案调研 + +**只回答一个问题**:这套东西**别人已经做过吗、踩过什么坑**——目标是**照抄**,不是发明。 + +### 核心原则(一句话) + +> **关键是照抄它们踩过的坑,而不是自己发明。**(覆盖网络线原文口径) + +### 要做的三件事 + +1. **参考方案对照表**:`能力域 → 参考方案 → 借什么`。**"借什么"必须写具体**(不是"参考 Tailscale",而是"借它的控制面/数据面分离 + DERP 区域模型")。 +2. **默认参数直接固化**:成熟项目的默认值**不要自己拍**(libp2p 的拨号并发 ≤4 / 总 100 / 超时 30 s、连接水位 100/400/1 min、中继自荐 15 min/30 min TTL、BitTorrent 上传并发 4 等)⇒ 直接进 §1 的参数表。 +3. **分层**:先给架构分层(覆盖网络线 = 控制面 / 会合层 / 骨干中继层 / 数据面 / 观测层**五层**),**再谈组件**。分层定了,后面"这块放哪"才有判据。 + +### 先拆概念(这一步经常被跳过,然后全盘皆错) + +覆盖网络线的实例:「插件」这个项目里**有两个完全不同的含义** —— (a) dsh 官方插件(跑在实例内部)**够不到宿主网络栈**;(b) 平台自身的模块化。**没先拆这两个概念,就会在错的对象上讨论"插件 vs 改代码"**。 + +⇒ **判据**:出现一个术语可能指两件事时,**先列表拆开**再往下走。 + +### 三层归属(普适判据,可直接复用) + +| 层 | 内容 | 形态 | 判据 | +|---|---|---|---| +| 数据面组件 | 要碰宿主网络栈、要独立扩缩容与加固 | **独立进程 / 独立 systemd 单元** | 与主进程**同生共死**就出局 | +| 平台侧集成 | 寻址、身份、资格签发、容量准入 | **改平台代码** | **必须与权威状态同源**(外置 = 第二个权威源 = 脑裂) | +| 实例内展示与工具 | 「我的 XX」面板、实例内工具 | **插件** | 纯实例内 UI/工具才配插件 | + +--- + +## 3. 第三段 · 场景梳理 + +**只回答一个问题**:这东西**被用在什么场景、要扛多大**——且**先确认口径对不对**。 + +### 要做的五件事 + +| # | 动作 | 说明 | +|---|---|---| +| 1 | **口径先校正** | 目标场景必须由用户确认过;文档里显式写 `📌 口径校正(日期,谁纠正)` | +| 2 | **设备/环境画像** | 按类列表(不是平均值):覆盖网络线用 6 类(云服务器 / 家宽 / CGNAT / 移动网 / 企业网 / VPN)× 台数 × 成功率 | +| 3 | **场景枚举含故障** | **正常场景 + 故障场景 + 极端场景**三类都要有(S1–S11 含"中继故障 / 控制面重启 / 区域突变 / NAT 表溢出") | +| 4 | **分工矩阵** | `环境类型 × 应用 → 关键约束`(一眼看出"哪类节点必然走中继") | +| 5 | **完成度表** | **纸面推演 / 可运行模拟 / 实测** 三档分开标 ——⛔ 不许把"纸面算过"说成"验证过" | + +### 关键动作:把口径纠正当成产出 + +覆盖网络线真实发生过一次重排:用户明确目标场景 = **① 多人 + agent 对话 ② MUD / MMORPG 网游 ③ 以上述应用为负载的 1000 台异构网络互联** ⇒ 原报告把"跨机访问实例/文件交换"排第 1 档**属次级用途,已重排**。 + +⇒ **教训**:场景优先级排错,后面全部推演**都在给次要场景做容量**。**这一步上抛是值得的**(属"业务目标与优先级",是边界外真门禁)。 + +### 常见失手 + +⛔ 只列正常场景(故障场景才是容量规划的主体)|⛔ 用"平均设备"代替异构画像(掩盖了 CGNAT/移动网这类**必走中继**的少数派)|⛔ 不标注"哪些是纸面"(下游会把推演当事实)。 + +--- + +## 4. 第四段 · 逻辑验证(**本方法的核心**) + +**只回答一个问题**:从场景到结论,**每一步都站得住吗**。 + +### 4.1 固定四段骨架(**每条问题都按这个写,不例外**) + +``` +① 问题 —— 一句话说清缺什么/错在哪 +② 查到什么资料 —— 业界范式 + 出处(能照抄就给照抄点) +③ 推演 —— 拿资料 + 我方实际约束,推出来 +④ 结论 —— 能不能解 / 怎么解 / 代价是什么(三问必答) +``` + +> 这个骨架的价值:**它把"我觉得"逼成"资料说什么 + 我们实际是什么 + 推出什么"**。任何一条卡在②或③,说明还不到下结论的时候。 + +### 4.2 四类必须主动去找的"洞"(覆盖网络线全部命中) + +| 类型 | 长什么样 | 覆盖网络线的实例 | +|---|---|---| +| **缺口** | 方案里有个位置**空着**,但下游已经依赖它 | "单房间上限 = min(扇出预算, presence 预算, **agent 预算**)",而 **agent 预算从未标定** ⇒ 三个预算里一个是空的 | +| **逻辑跳跃** | 结论 A 需要前提 B,而 **B 从没被论证过** | S3 算出"服放家宽 ⇒ 600 Mbps 过中继",但**玩家是外部客户端、不是网络成员,凭什么走我们的中继**⇒ 由此发现**缺一整个"发布层"**,且瓶颈性质变了 | +| **自相矛盾** | 方案内部两条约束互斥 | S5 设"1000 人房聚集 200 个 agent",而自家硬约束是"房间 agent ≤ 人数/10 = 100" | +| **事故级预判** | 不提前处理**必然**出事,且事后极难排查 | 设备池里 200 台 CGNAT + 150 台移动网**本机就在 `100.64.0.0/10` 内**;若覆盖网也用这段 ⇒ 路由黑洞、**"部分节点时通时不通",极难排查** ⇒ 主寻址必须走 IPv6 ULA + IPv4 冲突检测进首版 | + +⇒ **这四类不许靠"通读一遍觉得没问题"**,要**逐条对照**:每个结论问"它的前提被论证过吗",每条约束问"它和另一条冲突吗"。 + +### 4.3 实测证伪 —— 纸面推演不算数 + +**任何"机制类的设计"在落地前,必须有一次最小实测**: + +> 实例:方案 S3 原设计用"回环别名"(`ssh -R 127.0.0.2:…`)隔离中继落点。**实测**:47 的 `sshd -T` ⇒ `gatewayports no` ⇒ **`127.0.0.2` 被静默改写成 `127.0.0.1`,ssh 侧零报错**(`ExitOnForwardFailure` 未触发、日志为空)⇒ **机制作废,改用"实例端口区间隔离"**。 +> ⚠️ **要点:失败是静默的** —— 如果只做纸面推演,这套机制会带着"看起来对"的样子进入生产。 + +**实测的副产品往往是真 bug**:顺着这条线查出 `findFreePort()` 各 worker 各自随机 + `tunnel.forward()` 返回值**两处被忽略** ⇒ 撞号时 `-R` 失败但**静默** ⇒ Manager 照旧拨 `127.0.0.1:` ⇒ **静默打到另一个用户的实例**。这条 **两台机器即可触发**。 + +### 4.4 前人结论,先做最小取证 + +> **实证**:同一天踩两次 —— ① 方案写"`proxy.ts:109` 是中继连接目标",实为**发给上游 dsh 的 `Host` 头**(改它 = 全量 `/api` 403);② 说"有 3 处基础层反向依赖",实为**入口层的合法依赖、真实违规 0 处**。 +> ⇒ **拿上一份文档的结论当既成事实,是这类规划最主要的错误来源。** 引用前必须复核(连行号都会漂:`138-139` 实测为 `136-137`)。 + +### 4.5 勘误要集中成节,且写清"照字面做会出什么事" + +| 要求 | 说明 | +|---|---| +| 位置 | 集中一节(覆盖网络线 = 方案 §8/§9 + 交接单 §0.2),⛔ 不散落在正文 | +| 分级 | 🔴 实测证伪(改变做法)|⚠️ 需修正(不改做法)|📌 结论不变仅行号漂移 | +| 必写 | **"照原方案字面做会白做/会出事"** 一句 —— 否则执行会话会照抄 | +| 复验 | 下一轮对上一轮勘误**再确认一次**(覆盖网络线 §9.7 复验了 §8 的三条) | + +### 4.6 反模式清单(同步产出) + +验证过程中识别出的"踩了就出事"的做法,**汇总成表**(覆盖网络线 12 条:建全互联 / 无扇出限制的 gossip / 客户端持有权威状态 / 控制面下发全网名单 / 单中心中继 / 把网内副本当主备份 / 慢链路做备份 / 失败即重试 / agent 直接触发 agent / 推送式分发 / 共享密钥当身份 / 只按 IPv4 设计)。⇒ **这张表是给执行会话的红线,不是给自己看的笔记。** + +--- + +## 5. 第五段 · 应用推演 + +**只回答一个问题**:这些场景**叠加**起来,**先炸哪个、多大、怎么治**。 + +### 5.1 三条层层递进的动作 + +**① 逐场景独立算** —— 每场景给"数值 + 判读",**判读比数值重要**(例:`presence ≈16,700 次/秒`,判读 = "**presence 比消息早爆一个量级**")。 + +**② 流量预算总表(全案最重要的一张表)** —— 列:`流量 × 频率 × 单次 × N 台放大 → 上限手段`。要求**可复算**(每个数从哪来能追)。 + +**③ 叠加推演(此前最容易漏的一步)** —— 串行列举 ≠ 最坏情况。方法三步: + +| 步骤 | 做法 | +|---|---| +| **时间轴重叠检查** | 把各场景的时间窗排开(游戏高峰 20:00–22:00 / 群聊 20:00–23:00 / agent 随真人 / 备份 02:00)⇒ **重叠的才是要叠加的**;顺带确认"备份与游戏错峰"是**设计而非巧合** | +| **共享资源争用矩阵** | 列出被多方抢的**同一个资源**(出口带宽 / presence 通道 / 控制面 req/s / 客户端上行)⇒ 叠加后量级 | +| **主导项法** | **差一个数量级可忽略** ⇒ 只保留主导项,其余进"次要" | + +**叠加的产出是"瓶颈排序会变"**:覆盖网络线叠加后,第一瓶颈仍是出口带宽,但**第二从「presence」变成「presence × agent 叠加」(≈33,400 事件/秒,是任何单场景的两倍)**。 + +### 5.2 瓶颈排序 = 按"先炸顺序"排,不是按"大小"排 + +产出一张 `序 / 瓶颈 / 量级 / 主要处置` 表。**这张表就是落地顺序的输入**(再按"收益 ÷ 成本"重排一次 ⇒ 覆盖网络线的结论是"**只做三件事**")。 + +### 5.3 数字可信度必须分级(**这条决定了整套推演的诚实度**) + +> 覆盖网络线的原话口径:**"关键结论对比例敏感、对绝对值不敏感"** ⇒ **结构判断可信,具体容量数字还不可信**。 + +⇒ 任何一份推演都要写清:**哪些结论是"结构可信"(换个数也成立)、哪些是"数字可信"(已实测)**。⛔ 不许把两者混在一句话里。 + +### 5.4 收尾三件套(每份推演都必须有) + +1. **未验证项表**(标题就写"**勿当结论**")—— 所有不能证实的,统一放这里; +2. **本次未做(范围声明)** —— 明确边界,避免下游误读; +3. **上抛项收敛** —— 写"**未新增上抛项**"或列出新增项;**能排出优劣的自己拍掉**。 + +--- + +## 6. 七条横切纪律(贯穿五段) + +| # | 纪律 | 判据 / 反例 | +|---|---|---| +| 1 | **先只读,后动手** | 每份规划文档头部写明「⛔ 未改代码、未动服务器、未写文档库」;改任何文件前先抢执行锁 | +| 2 | **行号级引用** | 结论引用到 `文件:行`;引用前复核(行号会漂) | +| 3 | **三档数据标注** | 实测 / 业内 / 推算,三档不许混写 | +| 4 | **口径校正显式化** | 用户纠正过的地方,文档里留 `📌 口径校正` 标记,⛔ 不静默改写 | +| 5 | **上抛收敛** | 一轮只问"**真取舍**"(各有优有劣);只有优点或只有缺点的**自己拍**;**每个候选必须写优点 + 缺点,竖排成段** | +| 6 | **一条线一个入口** | 新会话只读"接续入口",不读全量日志(覆盖网络线:入口 3 KB,日志 60 KB) | +| 7 | **文档间标"承接"** | 每份文档头写"承接 X"⇒ 形成 DAG,避免重复造轮子、也避免下游读到已作废的结论 | + +--- + +## 7. 出口闸门:什么时候可以从"推演"走到"落地" + +**五段全绿不等于可以开工**。覆盖网络线把卡点收敛成**三件必须先办的事**: + +| # | 闸门 | 判据 | 覆盖网络线的实况 | +|---|---|---|---| +| 1 | **把关键估值换成实测** | 最少 3–5 台真机;只测那 3 个"一换全变"的数 | 选定 **打洞成功率 / 中继 jitter / 真实可用带宽** —— ⚠️ 直到 09-16 仍未做,故容量数字仍标"不可信" | +| 2 | **参数表固化** | 所有输入参数在一张表里 ⇒ **可复算、可仿真** | 已给(B3 表),⏳ 待落文档 | +| 3 | **权限影响评估 + 成本承诺** | 扩权(R5)与花钱(边界外)**必须上抛** | 三处扩权:虚拟网卡驱动 / 骨干开端口 / **发布层对外暴露**;成本 = 发布层 + 会合 + 中继 | + +**另一条独立闸门:执行前置** —— 落地前必须先把"**挡住整个方向的那一个耦合点**"拆掉。覆盖网络线的判断是:**中继绑在 Manager 上 ⇒ 多区域 / 多中心 / 骨干层全部做不了**,所以 S0–S4 排在任何功能开工之前。 + +> ⛔ **反模式**:一上来换协议(WireGuard / TURN)。正确顺序是"**先把耦合抽成配置与接口**,让现有实现退化成'第一个可替换实现'"⇒ 每一步独立可验、独立可回滚。 + +--- + +## 8. 落地形态:规划 → 交接单 → 执行 → 勘误 + +规划会话**只产出交接单**,执行会话**不读规划上下文**。交接单必含(覆盖网络线的单子模板): + +| # | 段 | 要求 | +|---|---|---| +| 1 | **§0 复核结论 + 勘误** | 实测基线表 + 逐条勘误(🔴 会白做/会出事 的排最前) | +| 2 | **§1 只读前置** | 抢锁 / 读哪几节 / 照基线逐项复核 / 远程入口 | +| 3 | **§2 范围** | **做**什么、⛔ **不做**什么(把勘误结论写成禁令) | +| 4 | **§3 步骤** | 每步:改哪些文件 / 接口怎么切 / **怎么验** / **怎么回滚** —— **四件缺一不可** | +| 5 | **§4–5 全局验收 + 清理** | 用户口径的业务验收 + **测试残留必须清**(有副作用的验证脚本要写明) | +| 6 | **§6 回报格式** | 判定 → 改了什么 + 部署到哪 → 实测输出 → 未做/风险 → 锁状态。⛔ **不许写"应该没问题"** | +| 7 | **§7 已知的坑** | 省执行方踩一遍(凭据、配置位置、端口、环境限制) | + +**执行完必须回写**:执行记录(P1/P2/P3 实测证据)+ 新勘误 + 未做项**为什么没做**(命中哪条红线)。 + +--- + +## 9. 方法自证:这条线上真实发生的 8 次纠错 + +| # | 纠错 | 由哪一段抓住 | +|---|---|---| +| 1 | **场景优先级排错** —— 把"跨机访问实例/文件交换"排第 1 档(实为次级) | ③ 场景梳理(口径校正) | +| 2 | **缺一整个"发布层"** —— 玩家是外部客户端,凭什么走中继 | ④ 逻辑验证(逻辑跳跃) | +| 3 | **自相矛盾** —— 200 个 agent 违反自家"≤人数/10" | ④ 逻辑验证(矛盾检出) | +| 4 | **回环别名机制作废** —— sshd `gatewayports no` **静默改写**绑定地址 | ④ 逻辑验证(实测证伪) | +| 5 | **地址段冲突** —— 设备池本机就在 `100.64.0.0/10` 内 | ④ 逻辑验证(事故级预判) | +| 6 | **真 bug:静默拨到别人实例** —— 端口撞号 + 返回值被忽略 | ④ 逻辑验证(实测副产品) | +| 7 | **前两条结论勘误** —— `proxy.ts:109` 是 `Host` 头;"3 处反向依赖"实为 0 处 | ④ 逻辑验证(最小取证) | +| 8 | **部署面漏了 47** —— 方案与交接单 v1 都只写了 106 | 执行阶段回写(**规划也要复核部署面**) | + +⇒ **8 次里有 5 次发生在第四段**。这就是为什么第四段值得单独成段、且必须用固定骨架逐条走。 + +--- + +## 10. 可抄模板 + +### 10.1 文档头(三行定性质) + +``` +> 日期:YYYY-MM-DD | 性质:**只读推演稿 / 调研稿 / 设计稿 / 复盘稿**(⛔ 未改代码、未动服务器、未写文档库) +> 承接:<上一份文档> +> 🟢 范围:只考虑技术实现;<明确不做什么> +> 用户要求:<用户原话或口径来源> +``` + +### 10.2 条目骨架(第四段核心) + +``` +### X-n · <问题名> + +**① 问题**:<一句话> +**② 资料(<范式名>)**:<可照抄的点 + 出处> +**③ 推演**:<我方实际约束 + 推出的结构> +**④ 结论**:✅/⚠️/❌ <能不能解> | 怎么解 | **代价**(缺一个不算结论) +``` + +### 10.3 文档尾(三件套) + +``` +## 未验证项(勿当结论) +| # | 项 | 状态 | + +## 本次未做 +- ⛔ 未改代码 / 未动服务器 / 未写文档库(原因:执行锁被 X 占用) + +## 上抛 +- 未新增上抛项 | 或:<项> —— A:优点/缺点 | B:优点/缺点 | 倾向 +``` + +### 10.4 判定分级(全流程统一用词) + +`✅ 已完成并验收` | `⏳ 待授权/待做` | `⚠️ 缺口/需修正` | `🔴 实测证伪/事故级` | `⛔ 禁令/反模式` + +--- + +## 11. 已知局限 + +| # | 局限 | 说明 | +|---|---|---| +| 1 | **样本单一** | 方法从"覆盖网络"一条线反推 ⇒ 对"信息不全、要从零拍架构"的任务适配最好;对实现型任务偏重 | +| 2 | **第四段的产出高度依赖实测能力** | 没有真机/真环境时,只能停在"结构可信";本线的容量数字至今仍是估值 | +| 3 | **成本** | 全程 17 份文档 + 多次实测;「只做三件事」的收敛是必要的,否则规划本身会失控 | +| 4 | **未验证** | 本方法尚未在第二条线上复用过 ⇒ 尚属"一次成功案例反推",不是已验证的通用流程 | diff --git a/dsh-server-docs/04-调整方案/124-文档无效信息审计报告.md b/dsh-server-docs/04-调整方案/124-文档无效信息审计报告.md new file mode 100644 index 0000000..99a4616 --- /dev/null +++ b/dsh-server-docs/04-调整方案/124-文档无效信息审计报告.md @@ -0,0 +1,60 @@ +# 文档无效信息审计报告(2026-09-16) + +> **判据** = `dsh-knowledge-upkeep §8`(六类无效信息 + 五条"不能删"的红线) +> **范围** = `.workbuddy/memory/*.md`(31 份 / 1,325 KB)|技能 `*.md`(12+ 份 / 348 KB)|工作区根方案文档(23 份 / 964 KB)⇒ 合计 **66 份 / 2,643 KB** +> **方法** = 两个只读脚本机械扫描 + 人工复核(脚本在 `_中间产物_待清理/auto-continue-20260916/`,可复跑) +> **性质** = 一次性产物;读完可归档到 `_中间产物_待清理/`,⛔ 别让它自己也变成"根目录堆积" + +## 一、判定 + +✅ **整体健康。无效信息不弥散,只集中在 2 处。** + +| §8 六类检查项 | 实测结果 | +|---|---| +| ③ 同一事实多处重复 | memory **17.5 KB / 1.3%**|技能 0.3%|根方案 **0.0%** ⇒ **无大段重复** | +| ⑥ 只写"给人看的套话" | **0 处** | +| ④ 过程流水挤掉结论 | 日志类 0.5–0.67 **属正常**(日志本就是流水);正式文档无异常 | +| ⑤ 中间产物混进正式文档 | 机械命中 45 处 → **人工复核真命中 0** | +| ① 与可执行体不符 / ② 过期结论占"生效位" | 见 §二(1 处真实问题) | +| 悬空引用(辅助项) | 机械命中 30 个 → **真命中 0** | + +> ⚠️ **方法学如实说明**:第 ⑤ 类与悬空引用的机械检测器**噪声很高** —— 45 处命中全是"文档把 `_tmp_*` 当反例讲"或"点名真实的 `_verify_tsc.mjs`",30 个悬空引用全是 `package.json`/`SKILL.md` 这类裸文件名。**六类里真正能机械判定的只有 ③ 重复、④ 流水占比、⑥ 套话**;⑤ 与引用类必须人工复核。⇒ 这类审计的价值在「定量 + 复核」,不在全自动。 + +## 二、真实问题(按影响排序) + +**1. 工作区根 `_中间产物_待清理/sess-forensics-20260916/会话脉络_ddea70b7_20260916.md` = 589.6 KB,占根方案文档 61%** + +- 类别:§8.2 第 ④/⑤ 类(过程流水 + 取证中间产物混进正式区) +- 判据(§8 唯一判据:去掉它,下一个会话会不会做错事/变慢?):内容是「AI 为何上抛」的**会话转录摘录**,其结论已被三处吸收 —— `会话复盘_AI为何上抛_20260916.html`、`.workbuddy/memory/2026-09-16.md`、两个技能的修订 ⇒ **去掉不会导致做错事** +- **已处置(2026-09-16 修复轮)**:移入 `_中间产物_待清理/sess-forensics-20260916/`,并同步修正 3 处引用(审计报告 / 简报 / 会话复盘 HTML);历史日志中的路径按 append-only 原则不改 +- **效果**:工作区根 `.md` 由 **23 份 / 964.1 KB → 22 份 / 375.0 KB(-61%)** + +**2. 每轮注入层:用户级 `E:\ProgramData\.workbuddy\MEMORY.md` 被截断(已修)** + +- 实测:20,977 B / 11,712 chars;宿主 **注入上限 ≈ 4,000 chars** +- 原排序的后果:注入窗口只覆盖「钩子配置 + 环境路径」——**一条行为规则都没进去**(`Preferences` 整节在窗口外) +- 类别:这是 §8.2 第 ② 类的**变体** —— 内容没错,**排序错**;而"排在窗口外"在效果上等于"这条规则不存在" +- **已修**:分层重排(**零删除**)+ 文件头加**排序契约**(⛔ 新增内容按序插入对应小节,别追加到末尾) + +## 三、"不能删"红线的分布(供后续清理避让) + +五条红线(判据与阈值 / 命令原文与路径 / 反例踩坑 / 为什么 / 失效标注)在 66 份里几乎每份都有。 +机械兜底已就位:`docs-shrink-guard.py` 基线覆盖 **151 个文件**(行数骤降 >30% 且 >20 行即报警)⇒ 后续任何整编都会被它抓出来。 + +## 四、建议(⛔ 本轮未做,留待下个会话决定) + +1. ✅ **已落地(2026-09-16 修复轮)**:`dsh-knowledge-upkeep` **1.1.0 → 1.2.0**,新增 **§8.6「注入预算」维度** —— 「每轮注入有上限 ⇒ 长文件后半段等于不存在;**先重排、后删减**;文件头必须写排序契约」。md5 `6249caaa…`,**本机 / 文档库 / 镜像三处一致**。同时用户级记忆头部已写入排序契约。 +2. **根目录方案文档:⛔ 不搬(结论已在修复轮修正)**。复核后实测:10 份 `覆盖网络_*.md` / 可行性评估在档案 103–112 里的**行覆盖率 98.5–99.0%**(差异 = 一级标题行),另 3 份与文档库 `archive/工作区草案/` 字节完全相同。 + ⚠️ **但"搬走"是净变差**:这 10 份被 **11 处引用**(含 `接续入口_覆盖网络线_20260916.md`),搬走会大面积断链 ⇒ 改为**加归档指针**(正文以档案 103–112 为准,根副本保留为工作副本)。 + 🔴 **本报告首版的一处错误(已修)**:首版用「归一化整串包含」判定,得出"档案未包含根文档"的假阴性(因为档案按约定删了 H1 标题行)⇒ 正确判据是**行级覆盖率**,不是整串包含。**这与本报告 §一 记的"检测器噪声"是同一类错误:机械判据必须先验证判据本身。** +3. **日志分片不需要瘦身**:`2026-09-16.md` 已 88 KB、`2026-09-下19.md` 流水占比 0.67 —— 但日志**按需读取、不进每轮上下文**,不构成水位成本。按维护规则,10 月后把 9 月日志按主题蒸馏进 `MEMORY.md` 再删旧片即可。 + +--- + +### 附:可复跑命令 + +```bash +cd "E:/ProgramData/AI技能/aliyun-dsh-server" +PYTHONIOENCODING=utf-8 "" "_中间产物_待清理/auto-continue-20260916/audit_docs.py" # 六类机械扫描 +PYTHONIOENCODING=utf-8 "" "_中间产物_待清理/auto-continue-20260916/audit_pass2.py" # 重复字节定量 +``` diff --git a/dsh-server-docs/04-调整方案/125-会话接续机制-问题复盘与修复.md b/dsh-server-docs/04-调整方案/125-会话接续机制-问题复盘与修复.md new file mode 100644 index 0000000..ac577d9 --- /dev/null +++ b/dsh-server-docs/04-调整方案/125-会话接续机制-问题复盘与修复.md @@ -0,0 +1,137 @@ +# 会话接续机制 · 问题复盘与修复(2026-09-16) + +> **结论一行**:机制本身(规范 §3.1.1 / §3.1.2 / §3.2.1)没错,**错在它没有被接上** —— ① 真正跑的那条自动化 prompt 没带"开机四步";② 硬环节的注入文案还停在"请用户开新会话",从不提 `automation_update`;③ 开机第一屏有可能喂过期事实。 +> **触发**:用户 2026-09-16 14:41 原话「决策方法 之前建立的机制有问题,看看自动任务新建的会话对话记录」。 +> **取证范围**:`~/.workbuddy/projects/e-ProgramData-AI技能-aliyun-dsh-server/{3814f5fb,e265f0cd,478eef8c,d48a9be8}.jsonl` + `workbuddy.db`(`automations` / `automation_runs` / `session_usage`)+ `state.py` 实跑 + `stop-dialog-guard.py` 源码。 + +--- + +## 一、今天的自动任务新建会话 —— 花了多少、干了什么 + +| 会话 | 自动化 | 轮 | 工具调用 | 积分 | 用户当场说了什么 | +|---|---|---|---|---|---| +| `3814f5fb` 归档接续 | `5d1dc22c` @10:20 | 2 | 65 | **21.34** | 「是否知道那个会话创建了**这个你**,是否有执行那个会话待处理的任务」 | +| `e265f0cd` 接续(优化版) | `4a3d815b` @10:45 | 1 | 32 | **8.93** | — | +| `478eef8c` 决策方法-2 | `218e5b11` @11:10 | 5 | 191 | **74.80** | 机制就是在这一条里定的(14 条用户发言) | +| `d48a9be8` S1 落地 | `d30f3cf7` @14:32 | 2 | 40 | **9.37** | 「**是不是应该先确认待执行的任务有哪些 再去执行**,不确定你搞清楚情况没有」 | +| **合计** | | | **328** | **114.44** | | + +- 积分口径 = 转录 `rawUsage.credit` 逐次求和;与 `session_usage.credit_json` 三处**完全吻合**(`d48a9be8` 9.37 / `e265f0cd` 8.93 / `b08b1c35` 33.71),两个独立数据源对上 ⇒ 数字可信。 +- 用户观察「自动任务新建的会话,提一轮就 7、8 个积分」**成立**:`e265f0cd` 单轮 8.93、`d48a9be8` 首轮 5.28。 +- `d30f3cf7` 立项时的验收口径是**与基线 `b08b1c35`(77 次 / 11.17 分)对比,目标 ≤10 次 / ≈1 分**(见 `478eef8c` A72)。**实测 40 次 / 9.37 分 ⇒ 未达标。** + +--- + +## 二、四个真问题(按严重度) + +### D1 🔴 生成自动化 prompt 的那一步没接上机制 —— **主因** + +规范 §3.2.1 要求 prompt **写死"开机四步 + 工具调用上限"**。实际跑在 `d48a9be8` 上的那条 prompt: + +- ✅ 有「本轮只做一件事,做完即停」「取证最多 3 条」(§3.2.1 ④⑤) +- ❌ **没有**接续包路径、**没有**校验命令、**没有**「先跑 `state.py`」 +- ❌ 反而把整段 S1 技术细节(≈1.2 KB)抄了进去 —— 而细节的单一来源本是 `交接单_覆盖网络落地执行_20260916.md` + +⇒ 结果:新会话手里既没有"从哪接班",也没有"上限是几次",只能自己从零探索。**同一件事被写了两遍(prompt 与交接单),真源被架空。** + +### D2 🔴 开机顺序反了 —— 用户看到的就是这一步 + +`d48a9be8` 的 40 次调用里: + +- `[01]` 读 automation memory(不存在,首次运行) +- `[02]–[15]` 读交接单、读架构文档、抢锁、**10 次连续读码探索** +- `[16]–[36]` 改码 → build/test → scp → 重启 → 验收 → 写记忆 → 清临时文件 +- **`[37]` 才第一次跑 `state.py`**;**`[39]` 才第一次读 `交接单/README.md §一`(待执行清单)** + +⇒ 机制设计的第 0 步(1 次调用代替十几轮探索)被排到了**倒数第 4 步**。用户当场质问「是不是应该先确认待执行的任务有哪些再去执行」,AI 也在下一轮自认「顺序错了」。 +⚠️ 补充:`state.py` **确实**能给出答案 —— 它的 `[入口]` 段 14:43 实跑就写着"3) ✅ S0 已完成 …… 5) 下一步 = 按交接单执行 S1"。**跑对了就不会有这一问。** + +### D3 🔴 硬环节的注入文案停在旧版 —— 自动接续没有触发源 + +`scripts/stop-dialog-guard.py` 三级(≥30 万)注入原文(修复前): + +``` +③ 然后明确告知用户「请开新会话,接续点在 X」,**由用户开**(钩子无法自动创建会话)。 +``` + +而规范 §3.2.2 画的链路里,第三段是 **[软] 模型调 `automation_update` 登记一次性任务(+2 分钟)**。 +⇒ 钩子**从不要求**模型登记自动化 ⇒ 这条"软"环节连提示都没有,全凭模型自觉。今天 4 条自动化全是会话内**临场手写** prompt 的结果 —— 这正是 D1 的来源。 + +### D4 🟡 开机第一屏可能喂**已被推翻**的事实 + +`state.py` 的 `[收口]` 段是"今日日志原文摘录",机械取**最后一个含「接续/收口」的章节**。实测 14:43 输出里带着两条**当天已被勘误**的说法: + +- 「两个『定时』自动化仍在按钟点烧钱」—— 实为 09-12 / 09-13 已软删除、早已停摆; +- 「转录里的 `rawUsage` 字段为空 `{}`」—— 实为有值(本次积分就是这么算出来的)。 + +另外 `state.py` 的 `[入口]` 文件名**写死**为覆盖网络线那一份 ⇒ 换工作线后会**静默展示旧线的待办**。 + +### D5 🟢 一次性 automation 的 `memory.md` 是死重量 + +宿主系统提示强制"先读 `automation memory.md`、收尾写回"。但一次性任务只跑一次 ⇒ 首轮**必然读不到**(`d48a9be8 [01]` 就是白跑一次),写回的那份**永不再被读**。已知设计面,无法改宿主,只能靠 prompt 一句"该文件不存在属正常"省掉一次调用。 + +--- + +## 三、已落地的修复(本轮,全部在我们自己的资源上,可推翻) + +| # | 文件 | 改了什么 | +|---|---|---| +| F1 | `会话接续规范_20260916.md §3.1.2` | 新增**第 0 步**:先跑 `state.py`(1 次调用拿到 [锁]/[git]/[入口=待办+接续包位置]/[收口])⇒ 回答"我该接谁的班" | +| F2 | `会话接续规范_20260916.md §3.2` | 新增硬约束 **「⛔ prompt 里不许复制任务细节」**(会造第二漂移源 + 挤掉开机四步),附 `d48a9be8` 实测 | +| F3 | `会话接续规范_20260916.md §3.2.1` | 模板首行加 `⓪ 先跑 state.py` | +| F4 | `dsh-server-docs/scripts/stop-dialog-guard.py` | 三级注入 ③④ 改为:**登记一次性 automation(照 §3.2.1 模板)→ 做不到才让用户开** | +| F5 | `state.py` | ① `[收口]` 加"日志原文摘录、可能已被推翻、以 MEMORY.md 状态层为准"护栏;② `[入口]` 改为**自动取最新的 `接续入口_*.md`**,不再写死 | + +✅ 验证:`stop-dialog-guard.py` `py_compile` 通过、新文案渲染正确;`state.py` 实跑通过(护栏行已出现在过期说法之前,`[入口]` 动态解析正常)。 + +--- + +## 四、重测结果(14:55 一次性自动化,**已跑完,达标**) + +| 口径 | 失败轮 `d48a9be8`(14:32) | 重测 `d5398c7d`(14:55) | +|---|---|---| +| 工具调用 | **40 次** | **6 次** ✅(预算 ≤8、验收线 ≤10) | +| 积分 | **9.37** | **1.69** ⚠️(目标"≈1 分"未完全达到,但比失败轮省 **82%**) | +| 是否先跑 `state.py` | 第 **37** 次调用才跑 | **第 2 次**(第 1 次是宿主强制的 automation memory,文件不存在) | +| 是否先确认待执行清单 | 第 39 次才读到 | **首屏即由 `state.py` 的 `[入口]` 段给出**,并复述了"未完成/下一步" | + +重测会话自报的两处可再省:① 用带 emoji 的完整标题串 grep 未命中、要用子串再 `tail`(多 1 次);② ⓪ 与 ① 是同一命令,本可合并(多 1 次)⇒ **理想路径 4 次**。 +⇒ **机制已闭环**:改动只落"接不下班"这一侧,效果可归因。剩余可选项见 §四-2。 + +--- + +## 五、还没做 / 需要条件的 + +1. **prompt 生成仍未强约束**:现在靠"模型记得照模板写"。要彻底硬起来,需要一个 `gen-continuation-prompt.py <接续包>` 生成器 + 钩子文案里写死"照它的输出原文"(本轮未做,属新造工具)。 +2. **`state.py` 的 `[入口]` 只覆盖"最新一份接续入口"**,多条工作线并行时仍会漏(当前只有一条线,够用)。 +3. **D5 无法从我们这侧解决**(宿主行为),已记录,不列为待办。 +4. **`state.py` 的 `[收口]` 护栏只是"提醒",不是"过滤"** —— 更彻底的做法是让它只摘"判据/结论"行、或与 `MEMORY.md` 状态层比对;本轮先用最低成本方式止血。 + +--- + +## 六、多线并行会不会冲突(2026-09-16 15:2x · 用户提问) + +> 用户原话:「**假如多个会话都要新建会话,新会话全都执行这个口令吗,会不会冲突**」 + +**会 —— 三种形态,真正会咬人的是 ①。** + +| # | 形态 | 机制现状 | 处置 | +|---|---|---|---| +| ① | **串线** —— 口令不带线名,而 `state.py [入口]` 原只取"最新一份接续入口" ⇒ 多个新会话都跑**同一条线**,另一条线没人跑 | 🔴 **真会发生**(今天只有一条线,属潜伏) | 口令**必带线名**;`[入口]` 已改为**列全各线** | +| ② | **抢锁** —— 同时动手只有一个抢到,输家"停手"白烧一轮 | ✅ 全局锁兜底,**不会同时改** | **读前置可并行**(都只读);输家**只报告** | +| ③ | **共享文件互覆** —— 日志 / `MEMORY.md` / 文档库 / 代码仓全平台共用 | ⚠️ 靠纪律(实测今日日志有 **15** 个接续点/收口章节) | **只追加自己的小节**(小节名带线名) | + +**已修(3 处)** + +- `state.py`:`[入口]` 列全所有 `接续入口_*.md`(多线时打 ⚠️"只走你自己那条");`[收口]` 追加"最近 3 个接续点标题";末尾**直接打印带线名的口令**。 +- 规范 **新增 §3.4「多线并行:怎么不打架」**(三形态表 + 四条硬规则:一线一份接续入口 / 口令必锚线名 / 同一时刻只许一个自动会话动手 / 抢不到锁=正常信号只报告)。 +- 规范 §3.2 硬要求 **四条 → 五条**(新增"prompt 必须锚定线名");§3.2.1 模板 ⓪ 改为"按 `[入口]` 里**「<线名>」那一行**定位接续包"。 + +**新口令(`state.py` 末行自动打印,直接抄给新会话)** + +``` +跑 `state.py`,按 覆盖网络线 那段 §2 第 1 条开工 +``` + +⇒ 单线时它和旧口令等价;**多线时这一步就决定了新会话走哪条线**,不会串。 +⛔ 反过来:`automation_update` 的 prompt 里**只写"按 §2 第 1 条开工"= 埋雷**(多线起来的那天才会炸,且很难归因)。 diff --git a/dsh-server-docs/04-调整方案/126-会话接续规范.md b/dsh-server-docs/04-调整方案/126-会话接续规范.md new file mode 100644 index 0000000..b6462b5 --- /dev/null +++ b/dsh-server-docs/04-调整方案/126-会话接续规范.md @@ -0,0 +1,287 @@ +# 会话接续规范:token 超限后如何无损继续 + +> 2026-09-16 立。来源 = 复盘会话 `78ac724f`(「查看 dsh 项目待办事项」)**最后 6 轮**的真实操作与失败。 +> 适用:任何会话接近/超过上下文预算,需要"换会话继续"的场景。 + +--- + +## 0. 结论(先看这三行) + +**能形成方法 —— 但重点不在"自动开新会话"。** 宿主不允许程序化创建会话(钩子没有这个能力),唯一通道是"**一次性定时任务**"。⚠️ 且**只能做成"半自动"**:钩子会注入"该收口了",但**最后那一步(登记自动化)必须由模型自己调 `automation_update` 完成**,钩子做不到。 + +**自动接续解决的是"手不用点"和"单价膨胀",不解决"钱少花"。** 09-16 实测(`workbuddy.db` 原始计费字段): + +- **成本 ≈ 单价 × 一轮内的工具调用次数**;单价随水位 <10 万 ≈0.10、15 万 ≈0.41 积分/次(同会话受控实测 **4 倍**)。 +- **固定注入 = 35,192 token/请求**(tools 20,734 + systemPrompt 10,395 + skills 3,919 + mcp 144),但**缓存命中 99.5%** ⇒ 它**很轻**,不是主因。 +- ⛔ 三个自动化**全新会话的首轮**分别烧 **8.93 / 10.05 / 13.82** 积分(首轮跑了 31–54 次工具调用)⇒ **开新会话挡不住"一轮几十次工具调用"的钱。** + +**那个会话真正的失败不是技术,是目标漂移。** AI 自造了「接续入口」「归档」这类只有它懂的内部词,把自己加的收尾动作**当成了正事** —— 用户的原话是「**感觉和我要的东西不相关**」。 + +--- + +## 1. 案例复盘:最后 6 轮实际发生了什么 + +| 轮 | 用户说 | AI 做了什么 | 判定 | +|---|---|---|---| +| 1 | 「确认」 | 把"接续入口"收成一个文件 | ⚠️ 用户没要求过这个词 | +| 2 | 「**能否自动创建新会话继续处理**」 | 如实答"不能创建会话",改走**一次性定时任务**(定 11:00) | ✅ 诚实 + 找到等价路径 | +| 3 | 「不用等这么久 尽快触发」 | 提前到 10:20,并核对 `nextRunAt` | ✅ 执行到位 | +| 4 | 「**第一轮就消耗 7 个积分,并没有起到降低 token 消耗的作用**」 | 认错:承诺口径不准确;真实口径见 §2-P1 | ✅ 认错 + 给真数据 | +| 5 | 「**没看懂…什么是接续入口 / 归档用脚本做,感觉和我要的东西不相关**」 | 承认那三个词是自己造的,**跑偏了** | 🔴 **本轮暴露根因** | +| 6 | 「重新创建个你优化后的自动任务不就行了」 | 重建:prompt 从 ~1.5k 降到 **~200 token**,强制脚本化 | ✅ 修正方向 | + +--- + +## 2. 三个真问题(按严重度排序) + +### P3 🔴 目标漂移 —— 最严重,且与技术无关 + +「接续入口」「归档」「用脚本做」**全是 AI 自己造的内部流程词**,用户从未要求。AI 把自己加的收尾动作当成正事,**反而没在做用户要的"继续未完成的任务"**。 + +**判据**:如果一个词是你自己发明的、用户没说过 —— 它就不该出现在给用户的说明里。**接续包的读者是"下一个会话 **和** 用户",允许出现只有 AI 懂的词,就是失败。** + +### P2 任务形态错 —— 比会话形态更根本 + +把「10 份文档逐份 agent 化改写」交给自动任务 ⇒ **20+ 轮 × 7 积分 ≈ 140+ 积分**。 +这**违反项目自己的省积分第一招「批量活写脚本」** —— 这类批量转换本就该一次性脚本跑完。 + +⇒ **换会话只是换场地,活还是那么贵。** 自动接续**不能**救"任务形态本身贵"的问题。 + +### P1 承诺不准确 —— 体感与承诺不符,损伤信任 + +AI 曾把"开新会话"说成"降低 token 消耗" ⇒ 用户实测第一轮 7 积分,**直接质疑**。 + +**准确口径**(必须这样讲,09-16 实测修正): + +- **开新会话不是零成本** —— 每轮 35,192 token 固定注入躲不掉(但缓存命中 99.5%,很轻); +- **真正的收益 = 单价**:水位从 20 万降到 5 万,**每次工具调用的单价约降 3–4 倍**(0.41 → 0.10 积分/次,同会话受控实测); +- ⇒ 它是"**降低单轮单价**",**不是**"降低总消耗"。⛔ 不许再说成后者。 +- ⚠️ **旧版本此处写"水位从 39 万降到 5 万(约 1/8)"—— 该比值被高估约 2 倍**(把固定注入按全价算,忽略了 99.5% 的缓存命中)。已按实测更正。 +- ⛔ **而且它只对"下一轮"有效**:如果新会话第一轮又跑 30+ 次工具调用,等于没省 —— 实测三个自动化全新会话首轮 8.93 / 10.05 / 13.82 积分就是证明。 + +--- + +## 3. 方法:三条硬要求 + 一条红线 + +### 3.1 接续包(会话 → 会话) + +**触发**:用户要求接续,或水位到 30 万(另有 `stop-dialog-guard.py` 三级机制会自动提醒)。 + +**内容**(**用用户的词写,不用 AI 的内部词**): + +1. **原目标** —— 用用户当初的说法,别翻译 +2. **已完成** —— 一句话 + 关键产物路径 +3. **在途** —— 跑到一半的,写清断在哪 +4. **未完成** —— 用户要的、但还没做的(**这一节最重要,优先于"AI 自己加的收尾"**) +5. **下一步** —— 新会话第一个动作 +6. **关键决定 + 回滚点** + +**落位**:`.workbuddy/memory/<日期>.md` 追加,或单独一份 `接续入口_<线>_<日期>.md`(**约 3 KB 以内**)。 + +### 3.1.1 接续包 v2:必须**机器可校验**(2026-09-16 补,用户要求「让新会话明确知道上个会话的进度和未执行的内容」) + +> **为什么必须可校验**:09-16 实证 —— 上一条接续点原话写「清理被截断的 `.workbuddy/memory/MEMORY.md`」,接手会话照做**就会改错对象**(真正被截断的是**用户级**那份)。⇒ **文字会失真,证据不会。** 凡是"结论"必须带一条**能复现的命令**。 + +**表头(放在接续包最前面,固定字段名,便于新会话机械读取)** + +```md +## 接续点 · <工作线名> · +- 来源会话: | 结束原因: <水位 N 万强制收口 | 用户要求> +- 原目标: <用户原话,不翻译、不缩写> +- 基线: HEAD= | 远端 master= | 全局锁=<无 | 占用者> +- 产物: <绝对路径1> | <绝对路径2> ← 新会话必须逐一确认存在 +- 校验命令: <一条命令> → 期望输出: <关键片段> ← 证明"上一步真的完成了" +- 未完成: ①<…> ②<…> ← 用户要的、还没做的(优先于 AI 自己加的收尾) +- 下一步: 第 1 个动作 = <具体命令,不是"继续处理">(之后 ② ③ …) +- 关键决定: <已定项 + 为什么> ← 防新会话推翻重来 +- 回滚点: <能退回的位置> +- ⛔ 不要重做: <已完成的,免得重复劳动> +``` + +**硬要求** +1. **「校验命令」必填**,且必须是**只读、可在 30 秒内跑完、输出可判真假**的那种(例:`docs-sync-check.sh` → 期望 `192/192, 0 差异`;`git ls-remote origin refs/heads/master` → 期望 sha 等于基线)。 +2. **「下一步」第 1 条必须是可直接执行的命令或文件路径**,⛔ 不许写「继续推进」「按情况处理」这类无法执行的话。 +3. **产物 / 未完成 / 不要重做 三节一个都不能空**(空就写「无」),否则新会话只能重新探索 = 白花钱。 +4. **≤3 KB**:只写「在哪 + 是什么状态」,⛔ 不把内容搬进来。 + +### 3.1.2 新会话开机四步(接手方强制动作) + +**第 0 步(2026-09-16 晚加,实测补)**:先跑 `state.py`(工作区根,只读、免抢锁、约 30 行)—— 1 次调用就拿到 `[锁] / [git] / [入口] / [收口]`,其中 `[入口]` 段**同时给出"待执行清单"和"接续包在哪"**。 +⛔ 跑它之前**不许**任何 Glob / Grep / `git status` 全盘探索。 +> 为什么必须补:它回答「**我该接谁的班**」——只读接续包时若不知道去哪找、或没有接续包,新会话只能自己探索。**实测反例**:14:32 那轮(`d48a9be8`)把 `state.py` 排到**第 37 次调用**,全程 40 次调用 / 9.37 积分,用户当场质问「是不是应该先确认待执行的任务有哪些再去执行」。 + +| # | 动作 | 为什么 | +|---|---|---| +| 1 | **只读接续包**(⛔ 不许一上来就全库探索 / `git status` 扫全盘) | 探索是最贵的动作,接续包就是用来免掉它的 | +| 2 | **跑「校验命令」并比对期望输出** —— **不符就停下报告,不许照文字硬做** | 文字会失真(见 3.1.1 实证);跑不通的校验会伪装成"通过" | +| 3 | **把「未完成」与「下一步」复述一遍**,确认与用户当时要的一致 | 防目标漂移(§2-P3 是这条线最大的历史坑) | +| 4 | **从「下一步」第 1 条开工**;⛔ 不重新探索、不重做「不要重做」列的东西 | 省掉重复劳动 | + +### 3.1.3 接续包会过期 —— 写完要回头维护 + +> 🔴 **2026-09-16 补:入口与接续包必须"同一次更新里一起改",且都带口径时间戳。** +> **实测事故**:`接续入口`(mtime 16:48)比 `接续包`(17:29)**滞后 41 分钟**,而 `state.py` 的 `[入口]` 段**就是从入口读的** ⇒ 17:04 那位**自动接续会话**按旧口径得出「P4 **并入自研 relay**」,与随后定案(**relay 实现未定、先做 R0 评测**)**表述不一致**(方向一致、粒度不同,用户当场察觉)。 +> ⇒ 三条硬要求: +> ① **改接续包时,同一个动作里把入口 §2 一起改**(入口 = "下一棒的第一信息源",且 `state.py` 直接读它); +> ② **入口 §2 末尾必须写"本口径截至 `<时刻>`"**; +> ③ **接续包标题里的时间戳必须等于内容最后修订时刻**(17:29 那次只改了内容、标题仍写 16:58 ⇒ 读的人无法判断自己读的是哪一版)。 + + +**凡是写完接续包之后又改了东西,必须回头改接续包。**(09-16 实证:接续点写好后对象被更正,接续包没跟着改 ⇒ 下一个会话会照错的做。) +**每次改动接续包,都要同步更新「基线」里的 sha 与时间戳。** + +### 3.1.4 ⛔ 收益不靠"接续包更详细" + +接续包写得再全,**也压不掉"一轮 30+ 次工具调用"的钱**(实测 8.93–13.82 积分/轮)。接续包解决的是**不丢状态、不重复劳动、不跑偏**;省钱靠的是 `3.2` 里那条**工具调用上限**。 + + +### 3.2 自动接续任务(一次性 automation) + +**只在"用户明确要求自动"时建**,且必须满足五条: + +**prompt 自包含** —— 不依赖任何旧会话上下文(新环境读不到) + +**prompt 要短** —— 实测正解:**~200 token**(只说"读 `<接续包路径>`"),⛔ 不要罗列 10 个文件名(那是 1.5k) + +**prompt 必须写死"开机四步 + 工具调用上限"** —— 见下面模板;**那一行才是真正省钱的地方** + +**⛔ prompt 里不许复制任务细节**(2026-09-16 晚加,实测)—— 细节的唯一来源是**交接单 / 接续包**;把技术细节抄进 prompt 会 ① 造第二个漂移源 ② **挤掉"开机四步"那一行**。 +> **实测反例**:14:32 那轮的 prompt 重述了整段 S1 技术细节(≈1.2 KB),却**没带开机四步**(无接续包、无校验命令、无"先跑 state.py")⇒ 40 次调用 / 9.37 积分;而该轮的立项验收口径是 **≤10 次 / ≈1 分**。 + +**prompt 必须锚定线名** —— 「线名」或「接续包绝对路径」二者至少给一个(§3.4 规则 2)。⛔ 只用"按 §2 第 1 条开工"这类**不带线名**的写法,多线并行时会**串线**。 + +**先抢全局执行锁** —— 抢不到就停手,不和人工会话撞车(多线并行时的完整处置见 §3.4 规则 4) + +**★ 必须在给用户的回复里告知**(2026-09-16 加,用户实测反馈触发)—— 新建会话 / 新建自动化是**用户可感知的状态变更**(提问闸门 A 类原话:「AI 会不会悄悄改他的设置」)。⛔ **只登记不告知 = 缺陷**:用户会在会话列表里凭空看见多出一个会话而不知何来。回复里用**陈述句**写清三件:① 已登记自动接续、约 N 分钟后自动开新会话、**不需要用户操作**;② 接续点 = `<文件>`;③ 若用户想自己开,口令 = ``。 +> **实测出处**:2026-09-16 17:01 会话「规划覆盖网络任务落地步骤」(`408636f2`)登记了自动化(→ 17:04 开出会话 `1281e874`),但**最后回复结尾一字未提** ⇒ 用户隔了两小时自己发现并追问「你是不是通过自动任务 新建了个会话继续任务」。 + +**★ 登记前冻结口径;登记后口径变了就必须重登记**(2026-09-16 加,治"两张皮")—— ① 登记时**算好接续包 md5 并写进 prompt**(新会话开工前会校验它,见 §3.2.1 ①b);② 登记之后**若你仍在本会话继续工作、且改动了接续包(或改了关键判断)** ⇒ **必须回来撤销 / 重登记**那条 automation。⛔ 不重登记的后果 = 下一棒按**旧口径**开工并且**无从知道**。 +> 📌 **来历(实测)**:17:01 登记、17:03 触发;而原会话一路工作到 **18:21**,**17:2x 才改判 P4、17:3x 才撤销「首选 frp」、17:29 才更新接续包** ⇒ 新旧两张皮。**「收口」只登记了一个未来动作,不等于会话结束** —— 没有同步点,漂移就是必然。 + +⛔ **做完即失效**(一次性),**不要留成周期任务**。 + +#### 3.2.1 标准 prompt 模板(照抄,填空即可) + +```text +⓪ 先跑 `state.py`(工作区根,只读)⇒ 按它 `[入口]` 段里**「<线名>」那一行**定位接续包;⛔ 多线并行时只走你自己那条、别串线;⛔ 跑它之前不许 Glob/Grep 探索。 +读「<接续包绝对路径>」的「接续点」。 + +① 先跑其中的「校验命令」,输出与期望不符 ⇒ 停下、只报告,⛔ 不许照文字硬做。 +①b 🔴 **口径门禁(硬,必须机器校验)**:登记本任务时,prompt 里**必须带「接续包 md5」**(`md5sum <接续包绝对路径>` 或 python `hashlib.md5`)。开工前**重算一次**:**不一致 ⇒ 立刻停手并报告「口径已更新,需重新接续」**,⛔ 不许凭接续包正文继续往下做;⛔ **若 prompt 里根本没有指纹 ⇒ 同样视为不合格,停手报告**(说明登记方漏了这一步)。⇒ 判据是**数值比对**,不是"记得去看 mtime"。 +①c ⛔ **接续包里写「未定 / 待定 / 未授权」的事,不许你替原会话拍成「已定」**(2026-09-16 实测事故:接续会话把「relay 实现**未定**」输出成「**并入自研 relay**」);若发现接续包内容与 prompt 描述有出入 ⇒ **先报告,不要自行取舍**。 +② 从「下一步」第 1 条开工;⛔ 不重做「不要重做」列的东西。 +③ 批量活必须先写成脚本一次跑完,⛔ 不许逐份探索;本轮工具调用 ≤ 8 次。 +⛔ 本轮只做上面这一件事,做完即停。不许顺手做归档 / 整理 / 写入口 / 开工别的任务。 +⛔ 取证只做一次、最多 3 个命令;发现要动代码或改配置 ⇒ 停下来报告,不要动手。 +``` + +**为什么是这五条(2026-09-16 实测,见 §6)** + +| 条 | 治什么 | 实测依据 | +|---|---|---| +| ① | 照错文字硬做 | 上一条接续点把对象写错了(§3.1.1) | +| ② | 目标漂移 / 重复劳动 | §2-P3 | +| ③ | **钱的主力** | 31 次调用 = 8.93 分;压到 8 次 ≈ 1 分 | +| ④ | **无人值守时的自我扩权** | `b08b1c35` 用户只说「先确认待办」,第 28 次调用**已在写 S0 代码** | +| ⑤ | 防御性过度取证 | 同一会话第 7–24 次连续 17 次取证,reasoning 三连自我加码 | + +**④⑤ 是 2026-09-16 新加的**:实测自动化**平均每轮 5.6–8.9 积分**,手动会话**平均每轮 3.3 积分**(约 2–3 倍)。根因不是"自动化"这个身份,而是**一条指令塞太多事 + 没人能打断**。⛔ 别再用「一条 prompt 跑完整条工作线」的写法。 + +#### 3.2.2 完整链路(哪一段是硬的、哪一段是软的) + +```text +[硬] 水位 ≥ 30 万 → stop-dialog-guard.py 注入「强制收口」 ← 已上线 +[软] 模型写接续包(§3.1.1 模板) ← 靠纪律 +[软] 模型调 automation_update 登记一次性任务(+2 分钟) ← 靠纪律,钩子做不到 +[软] ★模型在给用户的回复里告知「已登记自动接续」 ← 靠纪律(2026-09-16 补;缺这一环 = 用户不知情) +[硬] 宿主到点执行 ⇒ 新会话自动开 ← 已证实(每次运行必带新 sessionId) +[硬] 新会话按 §3.2.1 模板开机 ← 靠 prompt 写死 +``` + +⚠️ **两处软环节必须知道**:钩子**不能**创建会话、**不能**创建自动化(自动化只能经 `automation_update`)。所以「自动接续」是**半自动**——末段若模型没调工具,链条就断在最后一步。⛔ 钩子脚本**不得**绕过 `automation_update` 直接写库建自动化(硬约束)。 + + +### 3.3 ⛔ 红线 + +**不许自造流程词给用户看。** 用户说"继续未完成的任务",你就去做**他说的那件事**,不要顺手加"归档""整理""写入口"。 + +**不许把"AI 自己加的收尾动作"排进自动任务的正事里。** 顺序必须是:**用户要的活在前,AI 自己加的收尾在最后(或不加)**。 + +**不许承诺"降低 token 消耗"** —— 只能说"降低每轮水位"(见 P1)。 + +--- + +### 3.4 多线并行:怎么不打架(2026-09-16 晚加,用户提问触发) + +> 用户原话:「**假如多个会话都要新建会话,新会话全都执行这个口令吗,会不会冲突**」 + +**会冲突,三种;真正会咬人的是第 ① 种。** + +| # | 冲突形态 | 现状 | 对策 | +|---|---|---|---| +| ① | **串线** —— 口令只说"按 §2 第 1 条",而 `state.py [入口]` 原先只认"最新一份接续入口" ⇒ 两个新会话都跑**同一条线**,另一条线没人跑 | 🔴 **会真发生**(今天只有一条线,属潜伏) | **口令必须带线名**;`state.py` 已改为**列全所有线**并打印带线名的口令 | +| ② | **抢锁** —— 两条线同时动手,只有一个抢到 | ✅ 已有全局执行锁兜底,**不会同时改**;代价是输家白烧一轮 | 输家**只报告 + 结束**;⚠️ **读前置可并行**(`state.py` / 读接续包 / 校验命令都是只读) | +| ③ | **共享文件互相覆盖** —— 今日日志 / `MEMORY.md` / 文档库 / 代码仓都是**全平台共用** | ⚠️ 靠纪律(实测今日日志里已有 **15** 个接续点/收口章节) | **只追加自己的小节**(小节名带线名);⛔ 不重写别人的段落、⛔ 不全文件覆盖 | + +**四条硬规则** + +1. **一线一份接续入口** —— `接续入口_<线名>_<日期>.md`。⛔ 不复用别人的、⛔ 不把旧线那份当自己的;`state.py` 会列全,**按你手上那份接续包 / 自动化 prompt 里的线名认领**。 +2. **口令必须锚定线名** —— 正解:「跑 `state.py`,按 **<线名>** 那段 §2 第 1 条开工」(`state.py` 末尾已直接打印这句)。⛔ 不要只说"按 §2 第 1 条"。 +3. **同一时刻只许一个自动会话动手** —— 登记新的一次性自动化前,先看有没有在跑的:`automation_runtime_state.running = 1`(或 `running_conversation_id` 非空)⇒ **不再登记**,等它自己往下接。⛔ 不要同时挂两条待触发的一次性自动化。 +4. **抢不到锁 = 正常信号,不是故障** —— 它说明"**另一个会话正在动手**"。处置:只读部分照做(跑 `state.py` → 读接续包 → 跑校验命令)→ 报告「X 持有锁,我未动手」→ 结束。⚠️ 此时**连日志都不该写**(写文件也要锁)⇒ **只能回复报告**,不要硬写、不要删锁。 + +## 4. 与既有机制的配合 + +| 机制 | 管什么 | 位置 | +|---|---|---| +| 三级预算告警(12/20/30 万) | **什么时候该收口** | `dsh-server-docs/scripts/stop-dialog-guard.py` | +| 本规范 §3.1.1 接续包 v2 | **收口时产出什么**(机器可校验) | 本文件 | +| 本规范 §3.1.2 开机四步 | **接手方怎么确认没跑偏** | 本文件 | +| 交接单 8 段模板 | 规划会话 → 执行会话 | `dsh-server-docs/交接单/README.md §二` | +| `automation_update`(一次性) | 自动触发接续 | 工具,用时现调 | +| 成本公式(§0) | **判断钱花在哪** | 本文件;数据源 `workbuddy.db` | + +> 一句话:**告警决定"该走了",接续包决定"走得不丢东西",开机四步决定"接手方不会跑偏",工具调用上限决定"这一趟值不值钱"。** + +## 5. 实测数据存档(2026-09-16) + +| 自动化运行 | 上下文 tokens | 工具调用 | 积分 | 积分/工具 | +|---|---|---|---|---| +| 覆盖网络线·归档接续 | 200,013 | 54 | 13.82 | 0.256 | +| 决策方法-2 | 168,040 | 37 | 10.05 | 0.272 | +| 覆盖网络线·接续(优化版) | 141,401 | 31 | 8.93 | 0.288 | +| 代码仓三方同步 ③ | 87,177 | 20 | 1.93 | 0.097 | +| 代码仓三方同步 ① | 64,876 | 15 | 1.66 | 0.111 | +| 遗留项自动推进 | 82,712 | 12 | 1.20 | 0.100 | +| 代码仓三方同步 ④ | 63,147 | 16 | 1.18 | 0.074 | +| 代码仓三方同步 ② | 56,422 | 11 | 0.82 | 0.075 | + +**取数方法**(下次直接复用,别再摸索): +- `E:/ProgramData/.workbuddy/workbuddy.db` → `session_usage.credit_json`(逐轮积分)|`automation_runs.runs_json`(逐请求 usage / 缓存命中 / byCategory)|`automation_runs.metadata_json`(sessionId)|`automations`(周期与状态) +- ⚠️ **转录 `projects/<目录>/.jsonl` 里带 `rawUsage`(含逐次 `credit`)—— 可用,别信"恒为空"**:实测 `b08b1c35` 有 **67 条**带 `credit` 的记录,**逐次合计 11.17** 与数据库 `credit_json` 总和**完全一致**(两个独立源交叉验证)。⇒ 要"逐次成本曲线"就取这里。 +- 取数脚本:`_中间产物_待清理/auto-continue-20260916/`(`cost_model.py` 聚合全自动化 / `curve.py` 逐次曲线)。 + +**详细报告**:`会话阈值自动接续_机制方案与成本实测_20260916.html`。 + +## 6. 为什么自动化会话要跑那么多工具调用(2026-09-16) + +**实测对比(同一工作区、同一类任务)** + +| 会话 | 类型 | 轮数 | 积分总计 | 平均每轮 | 工具调用 | +|---|---|---|---|---|---| +| 自动化·接续优化版 | 自动化 | 1 | 8.93 | **8.93** | 31 | +| 自动化·决策方法-2 | 自动化 | 5 | 44.77 | **8.95** | 141 | +| 自动化·确认覆盖网络待办 | 自动化 | 2 | 11.17 | **5.59** | 77 | +| 手动·检查覆盖网络方案 | 手动 | 5 | 16.52 | **3.30** | 50 | + +**两条先破除的错觉** +1. ⛔ **「自动化一定比手动贵」不成立**:手动 `df1b7c6a` 累计 **16.52 > 11.17**(单次中位 0.21 > 0.11)。差的是**单轮塞了多少事**,不是身份。 +2. ⛔ **「单次很贵」不成立**:逐次 `credit` 中位数只有 **0.11(自动化)/ 0.21(手动)**,最低 0.05。⇒ **成本完全由"次数"累出来**,而模型单步决策时**看不到累计**。 + +**三条真根因** +1. **一条 prompt 塞了 N 件事** —— 手动「继续 XX」只指一件;自动化 prompt 常把「N 项任务 + 抢锁 + 只做 N 项 + 脚本化 + 反序释放 + 写简报 + 写记忆」串成一条,模型只能一路做到底。 +2. **没人能打断(最关键)** —— 实证:`b08b1c35` 用户只说「先确认待办事项」,实际第 7–24 次连续 17 次深度取证,**第 28 次已经在写 S0 的代码**;reasoning 里出现「重要取证完成 / 取证非常完整了 / 现在证据链完整了」**三次自我加码**。你在场时那句「够了」就是唯一的刹车。 +3. **规则本身在制造调用** —— 取证 / 交付门禁 / 抢锁与反序释放 / 写简报 / 写记忆,每条都是调用。规则没错,但塞进无人值守的一条指令就变成"必须做完"。 + +**修法**:见 §3.2.1 的第 ④⑤ 条(只做一件事、取证设上限)。预期 77 次 → 约 8 次,11.17 分 → 约 1 分。 + diff --git a/dsh-server-docs/04-调整方案/127-dsh客户端化部署方案.md b/dsh-server-docs/04-调整方案/127-dsh客户端化部署方案.md new file mode 100644 index 0000000..3c38605 --- /dev/null +++ b/dsh-server-docs/04-调整方案/127-dsh客户端化部署方案.md @@ -0,0 +1,287 @@ +# DSH 平台客户端化部署方案 —— 单机自用 + +- 版本:**v3**(2026-09-16;v1 规划稿 → v2 多用户 → **v3 范围收窄为单机自用**) +- 性质:**本文只做规划,不含任何代码改动** +- 定位:与 `集群化改造方案_Manager-Worker_20260914.md` 同级,是「客户端化」这件事**平台侧**的单一来源 +- **分层指针(2026-09-16 收口)**:本文管**平台侧**(能不能在 Windows 跑起来、形态怎么切);**交付载体层**(Electron 壳 / 打包 / 签名 / 自动更新)以 `dsh桌面客户端_开发方案_20260916.md` 为准 —— 两份合起来才是完整的「客户端化」,⛔ 不各写一份 + +--- + +## 0. 一句话方案 + +把平台装到用户**自己的 Windows 电脑**上,本机浏览器打开 `http://127.0.0.1:3080`,**一个人用**。 + +不需要局域网、不需要域名、不需要证书、不需要开机自启(可选)。 +平台**默认就监听回环地址** —— 这一项连配置都不用改。 + +--- + +## 1. 本次范围收窄的两条原则 + +### 1.1 使用者只有他自己 + +**不考虑当作服务器、不考虑别人访问。** 由此一次性消掉的东西: + +| 消掉的 | 原因 | +|---|---| +| 监听 `0.0.0.0` + 防火墙放行 | 只有本机访问,回环地址即可 | +| 多账号 / 逐用户开通 | 只有一个人 | +| 子域或子路径分流 | 直接访问门户首页即可 | +| 租户隔离 | 没有"别人" | +| 开机自启(服务化) | 双击启动即可,非必需 | + +### 1.2 平台机制**全部保留**,只是在客户端部署时**不启用** + +**这是一条硬约束:不为客户端化去删改多租户相关的代码。** +客户端形态下这些机制**照旧存在、照旧可用**,只是没有使用场景。 + +> 用户的判断依据:**多租户需要域名才能跑起来,而客户端没有域名** ⇒ 客户端就用单用户形态。 + +因此本方案对代码的要求只有一条:**让平台能在 Windows 上把实例启起来**(见 §4)。 +多租户、门户、账号体系、隔离档位、配额、集群 —— **一行都不动**。 + +--- + +### 1.3 多人形态**没有被去掉** —— "配域名即启用" + +**形态开关就是一个配置项**,平台本来就支持,⛔ **不需要改任何代码**: + +| 配置 | 形态 | 用户地址 | +|---|---|---| +| 域名**留空**(默认) | 单机自用 | `http://127.0.0.1:3080` | +| 配了**域名** | 多人访问 | `http://<用户名>.<域名>` | + +- 开关 = `DSHS_BASE_DOMAIN`(配 `DSHS_COOKIE_DOMAIN`;有 HTTPS 时再加 `DSHS_SECURE_COOKIES`)—— 三个都是**环境变量**,见 `src/config.ts:320-321` +- 配套(用户侧):内网 DNS 泛解析 `*.<域名>` → 本机地址 +- ✅ **不需要 HTTPS 也能跑**:门户域与用户子域属于**同一 site**,Cookie 的 `SameSite=Lax` 足以支撑跨子域跳转(`src/web/auth.ts:55-57` 即按"有没有 HTTPS"区分这两档) +- ⚠️ 隔离档位保持默认 `soft` ⇒ 多人访问时**用户之间无隔离**,与 §1.1 的取舍一致(且 `account` 档位在 Windows 上本来就不可用) + +> **本节结论**:客户端版本**天然保留"可以当服务器"的能力** —— 启用它不需要写一行代码,只需要用户填一个域名。 + +> 🔒 **切多人形态的前置检查(2026-09-16 加 —— 未过则不得开这个开关)** +> 这个开关零代码,但它会**同时打开**三个被"单机自用"关掉的风险面: +> 1. **隔离档仍是 `soft` ⇒ 用户之间没有隔离**(且 `account` 档在 Windows 上本来不可用)⇒ 多人形态的**前置条件 = 先有隔离方案**;在拿到之前,只能明确限定为"**仅互信小圈子使用**",并且页面要写明这一点。 +> 2. **平台级凭据不得随客户端分发**:域名配置要透传给平台,但**透传必须是白名单** —— 只放 `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / `DSHS_SECURE_COOKIES` 三项,⛔ 不得把平台共享模型密钥一并继承下去;模型密钥只走"**用户自己的密钥**"那一层。 +> 3. **实例必须属于机器主人**:一台机器上的实例只能归该机器使用者 —— 多人形态下需**显式**保证,不能靠"默认只有一个用户"蒙过去。 + +--- + +## 2. 范围收窄带来的变化(相对 v2) + +| 维度 | v2(内网多用户) | **v3(单机自用)** | +|---|---|---| +| 监听地址 | 改 `0.0.0.0` + 防火墙 | **默认 `127.0.0.1`,零配置** | +| 账号 | 多账号 + 逐个开通 | 一个本地账号即可 | +| 入口 | 纯 IP + 子路径分流 | 直接 `http://127.0.0.1:3080` | +| 隔离 | 不需要 | **不需要**(没有"别人") | +| 服务化 | 开机自启(必需) | 可选 | +| 风险「用户互读文件」 | 🔴 须书面告知客户 | **不存在** | +| 待改代码 | 配置 + 打包 + spawn 适配 | **只剩 spawn 适配 + 打包** | + +--- + +## 3. 现成能力盘点(**不需要动的东西**) + +| 能力 | 现状 | 依据 | +|---|---|---| +| 监听回环 | **默认就是 `127.0.0.1`** | `config.ts:162` / `cli.ts:32` | +| 数据根 | 默认 `~/.dshs`(**非硬编码**) | `config.ts:257` | +| 数据库 | 默认 SQLite 单文件 | `config.ts:271` | +| 免 HTTPS | `--secure-cookies` 默认关 | `config.ts:281` | +| 文件属主处理 | `chown/chmod` 块条件是 `process.getuid() === 0` | **实测 Windows 上 `typeof process.getuid === 'undefined'`** ⇒ 整块自动跳过(`local-user-fs.ts:40`) | +| 端口守卫 | 默认 false | `config.ts:323` | +| 单机自检 | `dshs doctor` | 装机与排障直接用 | +| 版本巡检 | `runtime-baseline.cjs`(Node 写) | 跨平台 | + +--- + +## 4. 硬阻塞点(**已在本机 Windows 实测**) + +### 4.1 唯一的硬阻塞:平台启动实例的方式在 Windows 上不成立 + +平台用 `spawn(command, args)` 启动实例,**不带 shell**(`orchestrator.ts:663-665`)。 +Windows 上 npm 全局包是 `.cmd` 垫片,于是: + +| 尝试 | 本机实测结果 | 判定 | +|---|---|---| +| `spawn('npm', ['--version'])`(裸名,=平台当前做法) | `ERROR(event): ENOENT` | ❌ 起不来 | +| `spawn('…\\npm.cmd', ['--version'])`(显式 .cmd) | `THROW(sync): EINVAL` | ❌ Node 安全限制 | +| `spawn('cmd.exe', ['/c', 'npm', '--version'])` | `OK exit=0`,输出 `10.9.7` | ✅ 可用 | +| `spawn(p, { shell: true })` | `OK exit=0` | ✅ 可用 | + +> 复现(Windows + Node 22): +> `node -e "require('child_process').spawn('npm',['--version'],{stdio:'inherit'}).on('error',e=>console.log(e.code))"` → 打印 `ENOENT` + +**结论**:**必须**让平台在 Windows 上以 `cmd.exe /c` 或 `shell: true` 启动实例。这是全案唯一必须改代码的地方。 + +### 4.2 附带的小点 + +| # | 点 | 判定 | +|---|---|---| +| A | 播种工作区时无条件 `chownSync`(`orchestrator.ts:503`) | 🟢 非阻塞:有 `try/catch` 兜底,只会刷一行失败日志 | +| B | `dshs doctor` 会探测 cgroup 等 Linux 项 | 🟢 非阻塞:只是自检输出不准 | + +--- + +## 5. 改造清单 + +### 5.1 必做 + +| # | 事项 | 量级 | 说明 | +|---|---|---|---| +| C1 | **Windows 子进程启动适配** | 小 | 实例启动处对 `win32` 走 `cmd.exe /c`;建议做成可配置 | +| C2 | 安装包 / 一键安装脚本 | 中 | 装 Node → 装平台与 dsh → 建目录 → 初始化本地账号 → 建启动快捷方式 → 自检 | + +### 5.2 该做 + +| # | 事项 | 说明 | +|---|---|---| +| C3 | 启动器 | 双击启动 + 自动打开浏览器;退出时干净收尾(别留孤儿进程) | +| C4 | 容量参数按单机重算 | 现有默认按 2C2G 多用户宿主定的;Windows 无 cgroup,需靠并发上限约束 | +| C5 | 备份与清理的 Windows 实现 | 现有 `.sh` 那批要换;Node 写的那批可直接用 | + +### 5.3 可选 + +| # | 事项 | +|---|---| +| C6 | 开机自启(单机自用非必需) | +| C7 | 顺手修 §4.2 的两处小点 | +| C8 | 免登录形态(本机单人,可考虑省掉登录步骤) | + +> ⛔ **不在清单里 = 不做**:多租户、门户、账号体系、隔离档位、配额、集群 —— **一律不改**(§1.2)。 + +--- + +## 6. 落地步骤 + +| 步 | 动作 | 验证方式 | +|---|---|---| +| S1 | 装 Node.js(22+) | `node -v` 有输出 | +| S2 | 装平台 + 官方 dsh | `dshs --help` 与 `dsh --version` 都有输出 | +| S3 | 初始化本地账号 | 能登录门户 | +| S4 | 第一次启动 | 浏览器打开 `http://127.0.0.1:3080` 出现门户 | +| S5 | 打开实例(**这一步验证 C1**) | 实例能起来并进入对话界面 | +| S6 | 建启动快捷方式 | 双击即可启动并自动开浏览器 | +| S7 | 断网验证 | 断开公网(保留模型 API 通路)后一切正常 | + +--- + +## 7. 验收标准 + +| # | 验收项 | 判据 | +|---|---|---| +| V1 | 本机可用 | `http://127.0.0.1:3080` 门户正常 | +| V2 | 实例可用 | 能打开实例、能正常对话、能改工作区文件 | +| V3 | 断网可用 | 断开公网(保留模型 API 通路)后,登录/实例/改文件全部正常 | +| V4 | 一键启动 | 双击快捷方式即可启动,无需命令行 | +| V5 | 数据可恢复 | 按备份流程恢复后,历史会话与工作区文件完整 | +| V6 | 无残留外部依赖 | 全程不装 Linux 环境、不装容器运行时 | +| V7 | 不对局域网暴露 | 从另一台机器访问本机端口**不通**(默认回环即满足) | + +--- + +## 8. 风险 + +| # | 风险 | 等级 | 说明与应对 | +|---|---|---|---| +| R1 | 单实例无内存上限 | 🟡 中 | Windows 无 cgroup,失控实例可能拖慢整机;靠并发上限与人工干预约束 | +| R2 | 与现网形态不同 | 🟡 中 | 出问题不能直接对照现网排查,需另建排障知识 | +| R3 | 模型 API 需出网 | 🟡 中 | 需确认用户网络出网策略;必要时配代理 | +| R4 | Windows 上实例的沙箱强度低于 Linux | 🟡 中 | dsh 本体的 Windows 沙箱官方标注为"部分强制";单机自用可接受,需知悉 | + +> ✅ 相对 v2 **消失**的风险:用户之间互读文件、无 HTTPS、局域网暴露。 + +--- + +## 9. 回滚 + +| 层 | 回滚 | +|---|---| +| C1(代码) | 按平台分支判断,Linux 侧行为不变 ⇒ **现网零影响** | +| 安装 | 卸载脚本 + 删除数据目录(安装时记录路径) | +| 整体 | 客户端化**完全不接触现网**(47/106 形态不动) | + +--- + +## 10. 附录 A · 证据与复核命令 + +| 结论 | 复核命令 / 来源 | +|---|---| +| 默认监听回环 | `grep -n "DEFAULT_HOST" src/config.ts` | +| 启动实例不带 shell | `sed -n '663,665p' src/supervisor/orchestrator.ts` | +| chown 块在 Windows 自动跳过 | `sed -n '40,47p' src/fs/local-user-fs.ts` | +| seed 的 chown 无条件调用 | `sed -n '502,505p' src/supervisor/orchestrator.ts` | +| portGuard 默认关 | `grep -n "DSHS_PORT_GUARD" src/config.ts` | +| Windows spawn 行为 | §4.1 四行实测(本机 Windows + Node 22.22.2) | +| 无域名时有降级路径 | `sed -n '58,62p' src/web/routes/dsh.ts` | +| dsh 本体支持 Windows | 官方 `.agents/notes/**` 三篇 | + +## 11. 附录 B · 保留但不启用(**给以后接手的人**) + +这些机制在客户端形态下**存在但不使用**,不属于缺陷、不需要清理: + +| 机制 | 客户端下的状态 | 事实备注 | +|---|---|---| +| 多租户 / 多账号 | 存在,只建一个账号 | — | +| 子域分流 | 不启用(`baseDomain` 留空) | 平台**内置了降级路径**:`baseDomain` 为空时入口自动回落到子路径 `/u//dsh/`(`src/web/routes/dsh.ts:58-62`)。即"没有域名跑不起来"在代码层面已有兜底;客户端单人场景**不需要依赖它** | +| 租户隔离(`account` 档位) | 不启用(默认 `soft`) | — | +| 端口守卫(nft) | 不启用(默认关) | 仅在 Linux+root 下可用 | +| 集群(Manager/Worker) | 不启用(`local` 模式) | — | +| 开机自启 | 可选 | — | + +## 12. 附录 C · 被放弃的选项(记录决策依据) + +| 曾考虑 | 放弃原因 | +|---|---| +| **内网多用户形态**(v2) | 用户明确「考虑用户自己使用就可以了,不用考虑当作服务器 其他人访问」 | +| 官方桌面版(Electron)作为载体 | ~~与"机制都保留"冲突 —— 换载体会丢掉我们的门户能力~~ ⚠️ **该判定已于 2026-09-16 失效**:`dsh桌面客户端_开发方案_20260916.md` 用「**保留官方壳、换内核为我们的平台进程**」规避了这条 —— 门户能力不丢。本行仅作历史记录 | +| Linux 宿主 / WSL2 / 容器 | 需额外环境;单机自用无必要 | +| 域名 + 证书形态 | 客户端无域名,且单人自用不需要 | + +> 若范围再次变化(例如要回到内网多人),v2 的对比表与结论仍然有效,可在本文基础上恢复。 + +--- + +## 13. 附录 D · 官方桌面客户端:能否沿用(调研结论) + +**问题**:能否沿用官方 Electron 桌面版,把这个项目装进去? + +**结论**:❌ **直接装不进去**(三条硬理由);✅ 但它的**形态与打包链可以沿用**。 + +### 13.1 官方桌面版是什么 + +| 项 | 事实 | +|---|---| +| 组成 | `apps/desktop`(Electron 壳)+ `apps/desktop-host`(**私有 Node host 进程**)+ `resources/dsh`(内置运行时) | +| 网络 | **不开监听端口**;用分帧字节管道承载 Fetch 请求与流式响应 | +| profile | Electron **独占** `$DSH_HOME/profiles/desktop`;CLI 不能启动或修改它 | +| 插件 | profile 的 `dependencies` **只放外部插件**;有独立插件管理窗口(增删改查) | +| 数据 | 与 CLI **共享** `$DSH_HOME` 下的 会话 / 设置 / 凭据 / 工作区 / 存储 | + +### 13.2 装不进去的三条硬理由 + +1. **载体只接受 dsh 插件,我们的平台不是插件。** 平台是独立的 HTTP 服务(路由 + 数据库 + 子进程编排),没有"作为 profile 插件被加载"的形态。 +2. **桌面版没有 web server,而我们的插件是平台的前端。** 官方原文:「Desktop does not provide a `webServer`」,官方自己的「Open In...」插件就因此被禁用。我们的 `business-plugins` 数据全部来自平台 API(`/api/plugins/mine`、`/api/dsh/status`、`/api/skills/mine`、`/api/me/keys`…,见 `poc/business-plugins/lib/client.js`)—— 装进去就是空壳。 +3. **两套编排者会争同一份 `$DSH_HOME`。** 桌面版自己就在跑 dsh;平台还要为每个用户再起 dsh 实例。 + +### 13.3 可以沿用的部分 + +| 可沿用 | 说明 | +|---|---| +| **形态** | Electron 桌面应用:双击打开、不暴露端口 | +| **打包链** | 官方 `apps/desktop/scripts/` 的 electron-builder + NSIS + Windows 签名 + 冒烟脚本,可作参照 | +| **数据互通** | 官方桌面版与 CLI 共享 `$DSH_HOME` 的会话/设置/凭据 —— 我们的壳若指向同一 `$DSH_HOME`,用户数据可互通 | + +**做法**:自建一个**薄 Electron 壳**(不复用官方壳代码)—— +主进程启动平台 → 等 `127.0.0.1:3080` 就绪 → 窗口加载它 → 退出时收干净子进程。 +用户看到的是桌面应用,里面是**机制完整保留**的平台。 + +### 13.4 三档形态与投入 + +| 档 | 形态 | 投入 | 体验 | +|---|---|---|---| +| 1 | 快捷方式 + 浏览器(v3 现有 C3) | 最小 | 中 | +| 2 | 薄 Electron 壳 + 借用官方打包链 | 中(壳小,难在跑通打包链) | 好 | +| 3 | Fork 官方桌面版源码改造 | 大 | 最好但最脆 | + +> ⛔ **不建议第 3 档**:官方桌面版的整个设计(不开端口、独占 profile、只装插件)与"承载一个平台服务"方向相反,改造量大于重写。 diff --git a/dsh-server-docs/04-调整方案/128-dsh桌面客户端-开发方案.md b/dsh-server-docs/04-调整方案/128-dsh桌面客户端-开发方案.md new file mode 100644 index 0000000..5e67013 --- /dev/null +++ b/dsh-server-docs/04-调整方案/128-dsh桌面客户端-开发方案.md @@ -0,0 +1,331 @@ +# DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代 + +- 版本:**v1 规划稿**(2026-09-16) +- 状态:⏳ **待评审**(本文只做规划,不含代码改动) +- **分层指针(2026-09-16 收口)**:本文管**交付载体层**(壳 / 打包 / 签名 / 自动更新);**平台侧**(Windows 子进程启动适配、形态开关)以 `dsh客户端化部署方案_20260916.md` 为准 +- 上游:`deepseek-ai/deepseek-harness` 的 `apps/desktop`(Electron 壳)+ `apps/desktop-host`(Node 宿主) +- 上游基线:`master` 分支,`apps/desktop` 版本 `0.1.6-alpha.1`(11239 个文件的全仓快照) +- 上游定位提醒:官方自称 **developer preview**,明写「THERE WILL BE COMPATIBILITY-BREAKING CHANGES」⇒ 同步机制必须按"会变"来设计 + +--- + +## 0. 一句话方案 + +**以官方 Electron 壳为骨架,换掉它的内核** —— 保留启动生命周期、单实例锁、失败恢复页、自动更新、Windows 打包链; +把"启动内置 dsh 运行时 + 插件管理"替换为"**启动我们自己的平台进程,窗口加载它**"。 + +产出一个**独立仓库**的桌面客户端,用户双击打开即用。 + +--- + +## 1. 目标形态 + +| 项 | 目标 | +|---|---| +| 交付物 | Windows 安装包(`Setup.exe`),双击安装,桌面快捷方式启动 | +| 运行态 | Electron 主进程 → 拉起平台子进程(本机回环)→ 窗口加载平台页面 | +| 对外暴露 | **仅本机回环**(沿用 v3 单机自用形态) | +| 数据 | 平台数据目录(默认用户目录下),与官方 CLI/桌面版**互不干扰** | +| 与平台的关系 | 客户端只是平台的**启动器 + 窗口**;平台机制一行不改(除 §5 那一处) | + +--- + +## 2. 官方壳解剖:取什么、换什么、丢什么 + +### 2.1 官方壳的组成 + +| 部分 | 内容 | +|---|---| +| `apps/desktop` | 100 个文件:`src/`(主进程逻辑)、`renderer/`(启动页 + 插件管理 UI)、`scripts/`(打包链)、`tests/`、`electron-builder.config.mjs` | +| `apps/desktop-host` | **仅 6 个文件**:`src/index.ts`、`src/wire.ts`、`config/desktop.cordis.patch.yml` + 3 个配置 | +| 运行时 | `resources/dsh` 内置一份完整的 dsh 与依赖树(打包时生成) | +| 通信 | **不开监听端口**,用"分帧字节管道"承载 Fetch 与流式响应;`dsh-app://` 服务客户端资源 | + +> 关键认识:官方壳的**复杂度几乎全部服务于"内置 dsh 运行时"**(包管理、profile 独占、管道传输、插件生命周期)。 +> 我们只要"拉起一个本机 HTTP 服务 + 开个窗口",**这些复杂度大多可以直接不背**。 + +### 2.2 逐模块处置 + +| 模块 | 官方作用 | 处置 | +|---|---|---| +| `src/main.ts` | Electron 生命周期、窗口、自定义协议、应急页 | **保留骨架**,改"加载目标"与"启动什么" | +| `src/backend-controller.ts` | 后端状态机(启动/停止/恢复) | **换内核** → 管理平台进程 | +| `src/single-instance.ts` | 进程级单实例锁 | ✅ **原样保留** | +| `src/startup-document.ts` / `startup-error.ts` | 启动失败页(自带诊断与恢复动作) | ✅ **保留并改编**(文案换成我们的) | +| `src/locale.ts` / `src/ipc.ts` / `src/paths.ts` / `src/preload*.ts` | 本地化 / IPC / 路径 | ✅ **保留** | +| `src/update-coordinator.ts` | 自动更新 | ✅ **保留**,更新源指向我们自己的通道 | +| `src/host-process.ts` / `host-protocol.ts` | 启动 Node 宿主 + 管道协议 | ❌ **删**(我们走 HTTP,不需要管道) | +| `src/project-manager.ts` / `profile-packages.ts` / `runtime-tree.ts` / `core-package-set.ts` / `owned-directory.ts` | profile 与运行时包管理 | ❌ **删或大幅简化**(运行时由平台负责) | +| `renderer/plugin-manager.*` | 插件管理器 UI | ❌ **删**(插件由平台的「功能管理」管) | +| `renderer/startup.*` | 启动加载页 | ✅ **保留并改编** | +| `scripts/package-target.ts` / `windows-sign.mjs` / `installer.nsh` / `smoke-windows.ps1` / `desktop-build-paths.mjs` / `desktop-release-environment.mjs` | **Windows 打包链**(electron-builder + NSIS + 签名 + 冒烟) | ✅ **保留 —— 这是最值得复用的部分** | +| `scripts/prepare-dsh.ts` / `prepare-runtime.ts` / `prepare-package-set.ts` / `runtime-file-policy.ts` / `macos-runtime.ts` | 为内置运行时准备文件 | ❌ **删**(改为准备我们的平台产物) | +| `electron-builder.config.mjs` | 打包配置(appId、签名、自动更新、NSIS) | ✅ **保留**,改 appId / productName / 更新源 | +| `tests/**` | 官方自己的测试(大量针对内置运行时) | 🟡 **按新内核重写**;与打包链相关的保留 | + +### 2.3 改造后的架构 + +``` +Electron 主进程 + ├─ 单实例锁(官方组件,原样) + ├─ 启动页窗口(官方组件,改编) + ├─ 平台进程控制器(替换 backend-controller) + │ └─ spawn 平台 → 轮询 127.0.0.1: 就绪 + ├─ 主窗口 → 加载 http://127.0.0.1: + ├─ 失败恢复页(官方组件,改编) + └─ 自动更新协调器(官方组件,换更新源) +``` + +--- + +## 3. 代码仓库方案(**本节回答"要不要单独一个仓库"**) + +### 3.1 判定 + +✅ **需要独立仓库。** 命名建议 `dsh-desktop`(或 `dsh-client`)。 + +### 3.2 五条理由 + +| # | 理由 | +|---|---| +| 1 | **发布物与节奏不同**:桌面客户端有自己的版本号、安装包、签名、自动更新通道;与平台服务的发版完全不同步 | +| 2 | **上游要持续同步**:fork 官方代码后必须能跟官方更新(官方明说会破坏性变更)—— 独立仓库才能把"官方代码"与"我们的改动"分开管理 | +| 3 | **红线 R2 的边界要清晰**:桌面壳是**官方源码的衍生品**。放进平台仓库,会让"我们自研的平台"与"官方衍生代码"混在一棵树里,合规边界与代码归属都变模糊 | +| 4 | **依赖形态差异大**:桌面端重 devDeps(Electron 44 / electron-builder / AWS SDK / TS 6),平台是轻量 Node 服务 —— 混在一起会拖慢平台 CI、放大依赖面 | +| 5 | **安全与信任模型不同**:桌面端跑在**用户电脑**上,平台跑在**服务器**上;两者的权限、密钥、发布审批要求不一样 | + +### 3.3 可行性的关键证据(依赖能否在独立仓库解析) + +这是"能不能独立"的硬前提,已核实: + +| 项 | 事实 | +|---|---| +| `apps/desktop` 的 `dependencies` | **只有 2 个,且都是公开包**:`electron-updater`、`semver` | +| `apps/desktop` 的 `devDependencies` | 16 个,其中 **只有 1 个** 是 monorepo 内部引用(`workspace:^`):`@deepseek-ai/dsh-home-paths` | +| 上述那个包是否已发 npm | ✅ 已发布(`@deepseek-ai/dsh-home-paths@0.0.1-rc.3`) | +| `@deepseek-ai/dsh-desktop` / `dsh-desktop-host` 本身 | `private: true`,**未发布到 npm** ⇒ **必须 fork 源码,不能直接依赖** | + +> **结论**:独立仓库里把唯一那个 `workspace:^` 换成 npm 版本即可,其余全是公开包。 +> **依赖层面无障碍** —— 这是本方案成立的关键证据。 + +### 3.4 上游同步机制 + +**目标**:官方更新能进来,我们的改动不丢、冲突可控。 + +**做法(推荐:基线快照 + 补丁清单)**: + +1. 仓库内设 `upstream-baseline/` —— 存放 fork 时刻的官方源码快照,**只读**,带上游 commit hash 与版本号 +2. 仓库内设 `patches/` —— 存放**我们对官方文件的每一处改动**,一个改动一个 patch 文件 +3. 我们的新增代码(平台进程控制器等)放在 `src/` 下**独立文件**,不修改官方文件 —— 从源头减少冲突 +4. 同步流程: + ``` + 取官方新版 → 放到 upstream-baseline-new/ → diff 两个 baseline + → 人工判断哪些官方改动要跟进 → 重放 patches/ → 冲突处处理 → 更新 baseline + 文档 + ``` +5. **判别原则**:能在**新增文件**里做的事,绝不改官方文件;必须改的,一律进 `patches/` 并写明理由 + +> 备选做法(若改动最终很少):只维护一份 **`DEVIATIONS.md` 改动清单** + 手工同步,省掉 patch 的机械开销。 +> 采用哪一档,等第一轮改造完、看到实际改动面再定。 + +--- + +## 4. 关键技术设计 + +### 4.1 启动流程 + +``` +1. 取得单实例锁(官方组件)—— 已有实例则聚焦它的窗口并退出 +2. 显示启动页(官方组件,改编文案) +3. 解析平台启动参数(端口、数据目录、命令路径) +4. spawn 平台进程,捕获 stdout/stderr +5. 轮询 127.0.0.1: 直到就绪(带超时) +6. 就绪 → 关闭启动页 → 打开主窗口加载平台地址 +7. 失败/超时 → 显示失败页(含诊断信息与"重启"按钮) +``` + +### 4.2 平台进程的生命周期 + +| 事件 | 处置 | +|---|---| +| 窗口全部关闭 | 默认行为待定(见 §9 未决项) | +| 用户退出应用 | 先优雅终止平台进程,再退出 Electron | +| 平台进程意外退出 | 尝试拉起一次;连续失败 → 显示失败页 | +| 应用被强杀 | 下次启动时清理上一次残留的进程(记录 pid 文件) | +| 端口被占 | 改为探测可用端口(**不要**写死 3080) | + +### 4.3 就绪探测 + +- 轮询本机回环端口,**不要**用"端口能连上"当就绪判据(TCP 可连不代表服务可用) +- 用一个轻量健康检查判断真正就绪 +- 设总超时,超时给出可操作的失败页 + +### 4.4 数据目录与配置 + +| 项 | 设计 | +|---|---| +| 平台数据目录 | 应用私有目录(Windows: `%APPDATA%\`),**不要**与用户手动安装的平台混用 | +| 平台端口 | 每次启动探测可用端口,写进启动参数 | +| 平台命令 | 打包进应用资源里,不依赖用户系统上的 Node | +| 与官方桌面版/CLI 的关系 | **互不干扰**:官方桌面版独占它自己的 profile;我们的客户端用独立数据目录 | + +### 4.5 失败恢复 + +沿用官方壳的三档恢复动作骨架: + +| 动作 | 我们的含义 | +|---|---| +| 重启 | 杀掉平台进程后重新拉起 | +| 重置 | 清掉平台数据目录(**需二次确认**,会丢会话) | +| 查看日志 | 打开日志目录(官方没有这一档,建议新增 —— 用户遇到问题第一件事就是找日志) | + +### 4.6 自动更新 + +- 官方用 `update-coordinator.ts` + `electron-updater`,配置由 `resolveDesktopAutoUpdateConfig` 从环境解析 +- 我们要做的:把更新源指向自己的通道;更新前**先停平台进程** +- ⚠️ 官方 README 提到签名/公证/更新托管"需要生产发布环境" ⇒ 自动更新是**发布期**才需要的能力,开发期可以先关掉 + +--- + +### 4.7 形态开关:从单机自用到多人访问(**零改动保留**) + +客户端**默认**按单机自用启动(不配域名、平台监听回环)。要把它当服务器给多人用,**不需要改客户端代码** —— 只需让它把域名配置**透传**给平台: + +| 档 | 做法 | 改动量 | +|---|---|---| +| **最小** | 用户在系统里设环境变量(平台读 `DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN`),客户端 spawn 平台时**原样继承** | **0** | +| 更好(该做项) | 客户端提供一个"服务设置"入口:填域名 → 写进平台配置 → 重启生效 | 小 | +| 配套(用户侧) | 内网 DNS 泛解析 `*.<域名>` → 本机地址 | 用户操作 | + +- ⚠️ **改为"白名单透传"(2026-09-16 修正)**:原表述「不要清理环境变量、也不要白名单化」会把**平台级凭据**(共享模型密钥等)一并继承给一个装到用户机器上的进程。**正确做法 = 只放形态开关三项**(`DSHS_BASE_DOMAIN` / `DSHS_COOKIE_DOMAIN` / `DSHS_SECURE_COOKIES`),其余不放行 —— 既保住"零改动切多人",又不把平台凭据发出去 +- ⚠️ 多人形态下隔离档位仍是默认 `soft`(用户之间无隔离),与部署方案 §1 的取舍一致 +- ⚠️ 不需要 HTTPS 也能跑(同 site 下的 `SameSite=Lax` 足够),但**有 HTTPS 更规范** + +--- + +## 5. 平台侧配套改动 + +桌面客户端要能跑起来,平台侧**必须**先解决一处(详见客户端化部署方案 §4.1,已实测): + +| # | 事项 | 说明 | +|---|---|---| +| P1 | **Windows 子进程启动适配** | 平台现在用不带 shell 的方式启动实例,Windows 上会 `ENOENT` / `EINVAL`;必须走 `cmd.exe /c` 或 `shell: true` | +| P2 | 安装包运行方式确认 | 平台以什么形态打包进客户端资源(源码 + node_modules / 预编译产物),需与实际打包方式对齐 | +| P3 | 端口可配置 | 客户端要探测可用端口 ⇒ 平台需支持任意端口启动(当前已支持 `--port`) | + +> 这三条都在**平台仓库**里改,与桌面客户端仓库分开 —— 这也是独立仓库的好处之一。 + +--- + +## 6. 打包与发布 + +### 6.1 可复用的官方打包链 + +| 文件 | 作用 | +|---|---| +| `electron-builder.config.mjs` | 打包配置工厂(appId / 签名 / 自动更新 / NSIS) | +| `scripts/package-target.ts` | 打包入口(按 target 打包) | +| `scripts/desktop-build-paths.mjs` | 各 target 的输出路径 | +| `scripts/desktop-release-environment.mjs` | 从环境变量解析发布参数 | +| `scripts/windows-sign.mjs` + `installer.nsh` | Windows 签名与 NSIS 安装器定制 | +| `scripts/smoke-windows.ps1` | Windows 原生冒烟(需要 Electron / Makensis / 7-zip 路径) | + +### 6.2 Windows 签名需要准备的东西 + +官方配置从环境变量读取,意味着**签名是外部依赖,需要提前准备**: + +| 环境变量 | 含义 | +|---|---| +| `DSH_DESKTOP_WINDOWS_CER_FILE` | 证书文件 | +| `DSH_DESKTOP_WINDOWS_SIGNTOOL` | 签名工具路径 | +| `DSH_DESKTOP_WINDOWS_TOKEN_PIN` | 令牌 PIN | +| `DSH_DESKTOP_WINDOWS_KEY_CONTAINER` | 密钥容器 | +| `DSH_DESKTOP_UNSIGNED` | 置 1 出**未签名**包(仅 Windows,**用于内部测试**) | + +> ✅ 好消息:官方支持 `package:win:x64:unsigned` ⇒ **开发与内测完全不需要证书**,出正式包才需要。 + +### 6.3 应用标识 + +`appId` 与 `productName` 由 `resolveDesktopAppId(env)` 从环境解析 ⇒ 属于**配置项**,不需要改代码。 + +--- + +## 7. 开发里程碑 + +| 阶段 | 目标 | 验收 | +|---|---|---| +| **M0 打通** | 最薄的路:Electron 壳 + spawn 平台 + 窗口加载 | 本机双击启动,能看到平台页面 | +| **M1 生命周期** | 进程管理、就绪探测、退出收尾、崩溃恢复 | 反复启停 20 次无残留进程;拔掉平台进程能自动恢复或给失败页 | +| **M2 体验** | 启动页、失败页含日志入口、单实例、数据目录 | 二次启动聚焦已有窗口;失败页能一键看日志 | +| **M3 打包** | 出未签名 Windows 安装包 | 干净机器上安装 → 启动 → 可用 | +| **M4 发布** | 签名包 + 自动更新通道 | 安装 → 自动更新到新版本成功 | + +> **M0 是关键**:它同时验证桌面壳改造与平台侧 P1(Windows 子进程适配)两件事。 + +--- + +## 8. 验收标准 + +| # | 验收项 | 判据 | +|---|---|---| +| V1 | 安装即用 | 干净 Windows 机器上装包后,双击可用,无需预先装 Node | +| V2 | 无残留进程 | 退出应用后,平台进程不残留 | +| V3 | 异常可恢复 | 平台进程被杀 → 应用能恢复或给出可操作的失败页 | +| V4 | 单实例 | 重复启动只聚焦已有窗口,不起第二个平台 | +| V5 | 数据隔离 | 与官方桌面版/CLI 互不干扰,各自数据目录独立 | +| V6 | 卸载干净 | 卸载后程序目录清空(用户数据是否保留需明确策略) | +| V7 | 打包链可复现 | 在干净构建机上能按文档打出安装包 | + +--- + +## 9. 风险与未决项 + +### 9.1 风险 + +| # | 风险 | 等级 | 应对 | +|---|---|---|---| +| R1 | 上游快速迭代导致同步成本高 | 🔴 高 | 改动集中在新增文件 + `patches/` 留痕;官方破坏性变更时评估"跟 or 不跟" | +| R2 | Electron 体积大(安装包通常 80–150 MB) | 🟡 中 | 若体积敏感,评估 Tauri 等替代;但会失去官方打包链的复用价值 | +| R3 | 无签名证书时用户会看到安全警告 | 🟡 中 | 内测用未签名包;正式交付需采购代码签名证书 | +| R4 | 平台进程的跨平台启动方式还需实测 | 🟡 中 | M0 阶段同时验证 §5 的 P1 | +| R5 | 官方桌面版若正式发布,我们的定位会变 | 🟡 中 | 定期评估"是否可以直接用官方版 + 我们的插件" | + +### 9.2 未决项(**开发前需要定**) + +**D1 · 关闭窗口时的行为** +甲:退出应用并停掉平台进程 —— 优点:干净、不留后台;缺点:再次使用要重新启动。 +乙:最小化到系统托盘,平台继续跑 —— 优点:随时可用,AI 长任务不被中断;缺点:常驻后台占内存。 + +**D2 · 客户端与官方桌面版的关系** +甲:完全独立(独立数据目录、独立产品名)—— 优点:互不干扰;缺点:用户装了官方版会有两套。 +乙:共享数据目录 —— 优点:会话/设置互通;缺点:要处理与官方版的 profile 冲突(官方版独占它自己的 profile)。 + +**D3 · 是否保留"插件管理"界面** +甲:删掉,插件全部由平台的「功能管理」管 —— 优点:单一入口、与平台机制一致;缺点:客户端内少一个入口。 +乙:保留官方那套插件管理器 —— 优点:复用现成 UI;缺点:与平台的插件机制并存会产生两条路径,容易互相打架。 + +--- + +## 附录 A · 证据与复核方式 + +| 结论 | 来源 | +|---|---| +| 桌面壳 = Electron + 内置运行时 + 私有宿主进程 | `apps/desktop/package.json`(description)+ `apps/desktop/README.md` | +| 不开监听端口、用字节管道 | 官方 README 首段 | +| `apps/desktop` 100 个文件 / `apps/desktop-host` 6 个文件 | GitHub 全仓 tree 快照 | +| `dependencies` 仅 2 个公开包 | `apps/desktop/package.json` | +| `devDependencies` 仅 1 个 `workspace:^` | 同上 | +| 该包已发 npm | npm registry `@deepseek-ai/dsh-home-paths` | +| `@deepseek-ai/dsh-desktop` 为 `private` | 同上 `package.json` | +| 打包链文件清单 | `apps/desktop/scripts/**` | +| Windows 签名靠环境变量 | `electron-builder.config.mjs` | +| 支持未签名包 | `package.json` 的 `package:win:x64:unsigned` | +| 官方宣称会破坏性变更 | 官方 README「Developer preview」段 | + +## 附录 B · 需要 fork 的官方文件清单(按处置分组) + +| 分组 | 文件 | +|---|---| +| **保留**(含打包链) | `electron-builder.config.mjs`、`scripts/package-target.ts`、`scripts/desktop-build-paths.mjs`、`scripts/desktop-release-environment.mjs`、`scripts/desktop-auto-update-environment.mjs`、`scripts/windows-sign.*`、`scripts/installer.nsh`、`scripts/smoke-windows.ps1`、`scripts/macos-runtime.ts`、`scripts/package-macos.ts`、`scripts/verify-macos-signature.*`、`scripts/notarize-macos-disk-images.*`、`src/single-instance.ts`、`src/locale.ts`、`src/ipc.ts`、`src/paths.ts`、`src/preload*.ts`、`src/update-coordinator.ts`、`renderer/startup.*` | +| **改**(换内核/改编) | `src/main.ts`、`src/startup-document.ts`、`src/startup-error.ts`、`src/backend-controller.ts` | +| **删**(为内置运行时服务) | `src/host-process.ts`、`src/host-protocol.ts`、`src/project-manager.ts`、`src/profile-packages.ts`、`src/runtime-tree.ts`、`src/core-package-set.ts`、`src/owned-directory.ts`、`src/release.ts`、`renderer/plugin-manager.*`、`scripts/prepare-dsh.ts`、`scripts/prepare-runtime.ts`、`scripts/prepare-package-set.ts`、`scripts/runtime-file-policy.ts`、`apps/desktop-host/**` | +| **重写** | `tests/**` 中针对内置运行时的部分 | diff --git a/dsh-server-docs/INDEX.md b/dsh-server-docs/INDEX.md index 8a6b2ab..59e9ac0 100644 --- a/dsh-server-docs/INDEX.md +++ b/dsh-server-docs/INDEX.md @@ -43,7 +43,7 @@ ## 二、全量清单 -> **状态摘要**(**机器生成,勿手改**):档案 **89** 份(`04-*`),另含根级编号 4 条(01/02/03/06),另有非编号行 12 条(README / INDEX / 技能 / poc 等)—— ✅ 70 | 🔄 8 | 🧪 1 | 🔧 1 | 未标记 13。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 +> **状态摘要**(**机器生成,勿手改**):档案 **115** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 16 | 🔄 5 | 🔍 1 | 📋 22 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。 > **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。 > 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓ @@ -159,6 +159,22 @@ | 04-110 | 📋 | **覆盖网络 · 答疑(群聊+agent / 备份 / 迁移提速 / 传输保密)**(规划态 · 未实施):① 群聊=**应用层**的事(覆盖网络只给「可达」);**agent 四条硬约束**(⛔ 禁止 agent 直接触发 agent);② ✅ **备份主层用对象存储**,覆盖网络只当**搬运通道与第三副本**(P2P 副本无 SLA、可误删,不适合当存档);③ 迁移 7 条按收益排,**最大一招 = 只搬不可再生(46.3 MB vs 2.9 GiB ≈ 64×)**;④ 保密 = 三层加密 + 元数据保护,**最大缺口 = 身份(共享令牌 → 一机一钥)** | | 04-111 | 📋 | **覆盖网络 · 补遗与参考方案**(规划态 · 未实施):互联游戏 ✅ 但**由游戏形态决定**(锁步/回合/异步最友好;FPS/MOBA 64+ ❌ 不合适,P2P 无法反作弊);补遗 **24 条**(身份与账户 / 寻址与名字 / 接入与可见性 / 自检与选路 / 流量与公平 / 移动端弱网 / 升级版本自愈 / 可观测)+ 10 个能力域的参考方案 + **反模式 12 条**。⭐ 最划算的架构复用:**群聊房间与游戏对局是同一个模型**(一次投资,群聊 + 游戏 + 协作 + 看板共用) | | 04-112 | 📋 | **覆盖网络 · 九大瓶颈落地方案**(规划态 · 未实施):把 107 的九大瓶颈逐个给成**可执行做法 + 验收判据**(含 Slack / SCCM·BranchCache·Delivery Optimization 官方照抄点)。🎯 **只做三件 = presence 改造 + 游戏服放 L1 + 块级内容寻址分发**(覆盖最大三个瓶颈,且**都不需要改传输协议**)。🔑 分水岭:**必须做「块级」内容寻址,⛔ 别做「包级」**(包级 = 版本一发所有 peer 源失效 ⇒ 正是全量重拉风暴的成因) | +| 04-113 | 📋 | **覆盖网络 · 传输方案取舍(开放端口 vs 自研 relay)**(决策稿):逐条对比 5 种传输形态,定下**自研 relay + 回环监听 + 中继切流**路线;⛔ P4(SSH 版中继 / 32023)判**不做** | +| 04-114 | 📋 | **覆盖网络 · 应用场景推演完成度 & 方案待完善清单**(检查稿):逐场景打勾哪些已推演、哪些仍缺;作为后续补遗的取数底稿 | +| 04-115 | 📋 | **覆盖网络 · 「插件 vs 改内核」架构判断**:结论 = **走插件层**(不改官方 dsh 内核,红线 R2);给出插件化边界与代价清单 | +| 04-116 | 📋 | **覆盖网络方案 · 问题逐条推演与解决方案**:把散落疑问逐条编号推演并给解法,是 103–112 之前的**问题总账** | +| 04-117 | 🔄 | **覆盖网络 · 参数表与观测口径(唯一一张)**:所有可调参数的**单一来源**(含「⛔ 不改」清单:`RELAY_FAILOVER_DEADLINE_MS` / `HB_SEC` / `COOLDOWN_MS=0` 禁用)+ 指纹口径(`sed '/^## §10 指纹/,$d'` 后再 md5) | +| 04-118 | 🔄 | **会合 / 中继从 Manager 拆分 · 取证与改造方案**(**进行中**):S0–S4 分步 + 每步验收 / 回滚;起因 = 重启会收掉实例 scope 而无 reconciler | +| 04-119 | 📋 | **集群化改造方案 · Manager / Worker 模式**:D1–D5 已定(⏳ 仅 D6 未做)+ **数据分层判据**(归属/租约只 Manager 写 · 跟用户走放实例 home · 本机运维放 Worker 本地库) | +| 04-120 | 📋 | **跨节点迁移 & 节点自举 · 完整流程方案**:用户实例在节点间搬迁的完整流程与自举条件 | +| 04-121 | 📋 | **项目代码 · 分层范式与迭代风险评估**:代码分层 P0 已立;给出各层边界与「层层补丁叠加」的返工风险 | +| 04-122 | 📋 | **guest 迁移(w-47 → w-106)与共享重建方案**:存量 guest 落 106 的搬运步骤 + 共享面重建判据 | +| 04-123 | 📋 | **方案规划方法 —— 从覆盖网络线提炼**:把本线的规划手法沉淀成**可复用方法论**(判据优先 / 单一来源 / 假绿识别) | +| 04-124 | 🔍 | **文档无效信息审计报告**:对文档库做「无效信息」体检,输出应删/应改/应合并清单(**报告,未执行**) | +| 04-125 | 📋 | **会话接续机制 · 问题复盘与修复**:复盘「接续为何断链/为何空转」,产出修复项(间隔纪律 = 收口 + 2~5 分钟) | +| 04-126 | 🔄 | **会话接续规范:token 超限后如何无损继续**:接续六件套 prompt 骨架与登记门禁的规范文本 | +| 04-127 | 📋 | **DSH 平台客户端化部署方案 —— 单机自用**:B 档(单机自用)形态下的客户端化部署路径;⚠️ 真正硬阻塞 = 平台调用层(Windows 裸名 `spawn` ENOENT) | +| 04-128 | 📋 | **DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代**(待评审 · 只做规划不含代码):复用官方壳 vs 自建的取舍与分发/版本矩阵 | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 | | — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)| @@ -189,7 +205,7 @@ | 文件 | 位置 | 状态 | |---|---|---| -| **已完成的交接单**(规划/执行分离的单子)| `archive/交接单-已完成/` | **T02**(文档库收尾:37/38 编号消歧 + INDEX 瘦身 28,809→7,468)|**T03**(7 插件整合投放:`dsh-plugin-mcn-suite` 上传候选池 + **guest 启用成功**,实例重启探活通过)|**T04**(并发治理:commit 常态化 + 服务器侧操作锁 `/opt/dsh/state/.op-lock` + 权限 700/600 + 代码库 `ebe8075`→`06e63ac`)|**T05**(插件兼容性预检:`04-71` 落地 + 档案 66 源码回填)— 均 2026-09-12 完成|**T06**(平台自建「模型设置」:`04-87` 落地 + 插件 0.3.11 铺发 + 迁移 V6 + 官方模型分区全角色隐藏;验收四件事全绿,顺手修角色补丁整文件覆盖与陈旧断言两颗雷)— 2026-09-13 完成|**T07**(内置 dsh 安装路径按序探测:`04-88` 落地;**三处**写死路径(第 3 处是提出方漏的)改为按序探测 + 降级留痕;测试服 `test106` 三种姿势验证通过)— 2026-09-14 完成 | +| **已完成的交接单**(规划/执行分离的单子)| `archive/交接单-已完成/` | **T02**(文档库收尾:37/38 编号消歧 + INDEX 瘦身 28,809→7,468)|**T03**(7 插件整合投放:`dsh-plugin-mcn-suite` 上传候选池 + **guest 启用成功**,实例重启探活通过)|**T04**(并发治理:commit 常态化 + 服务器侧操作锁 `/opt/dsh/state/.op-lock` + 权限 700/600 + 代码库 `ebe8075`→`06e63ac`)|**T05**(插件兼容性预检:`04-71` 落地 + 档案 66 源码回填)— 均 2026-09-12 完成|**T06**(平台自建「模型设置」:`04-87` 落地 + 插件 0.3.11 铺发 + 迁移 V6 + 官方模型分区全角色隐藏;验收四件事全绿,顺手修角色补丁整文件覆盖与陈旧断言两颗雷)— 2026-09-13 完成|**T07**(内置 dsh 安装路径按序探测:`04-88` 落地;**三处**写死路径(第 3 处是提出方漏的)改为按序探测 + 降级留痕;测试服 `test106` 三种姿势验证通过)— 2026-09-14 完成 | |**T09–T21**(2026-09-17 从工作区根批量入仓:覆盖网络线 13 份交接单 —— 含「落地执行 / 网抽象与地址规划 R6 / relay R2-R4 / 443 兜底 / 中继失败切流 / 切流冷却语义 / presence 在线态 / 一机一钥与信任根 / 参数表与观测 / 检测时延与 deadline / 最小形态真机批次 / 观测口径与在册缺陷 / 在册收尾」;⚠️ 均为**已完成单的归档副本**,若其中仍有未完成项需在 `交接单/` 另开新单) | 三份工作区草案(插件管理面 / VoxEMW×2)| `archive/工作区草案/` | 已归档防丢;插件草案 v5 被档案 16 取代 | | `scripts/docs-{sync-check.sh,audit.py,manifest.py}` | 本库 `scripts/` | 随库分发,**在用** | | `.workbuddy/memory/YYYY-MM-DD.md` | 工作区 `.workbuddy/` | 过程日志,不入库 | diff --git a/dsh-server-docs/README.md b/dsh-server-docs/README.md index 8f56623..8746c26 100644 --- a/dsh-server-docs/README.md +++ b/dsh-server-docs/README.md @@ -87,6 +87,7 @@ - **新增改造**:按主题写进对应文件;功能类改造在 `04-调整方案/` 新建档案(编号递增,**复跑取号勿写死**:`ls 04-调整方案/ | sort -n | tail -1`;**63 为空号,勿补占**),模板 = 需求 → 改动点表 → 验证结果。 - **规范/基线类**(跨页面长期生效)→ 根级编号文档(`06-工作台UI规范.md`),**不入 `04-调整方案/`**。 +- **工作区根批量入仓(2026-09-17)**:工作区根尚未入仓的项目文档已一次性归位 —— 16 份方案 / 评估 / 复盘 / 规范类 → `04-调整方案/113–128`;13 份交接单 → `交接单/archive/交接单-已完成/T09–T21`;运行态指针(`接续入口_*` / `接续包_*`)→ `ops/`;临时与内部简报 → `archive/`。**档案清单仍以 `INDEX.md` §二 为单一来源**(本文件的 01–26 表按上条约定不新增行)。 - **前端页面改动**:先读 `06-工作台UI规范.md` 再动手;视觉判断辅以 `.workbuddy/skills/impeccable`(Operate 模式)与 `taste-skill`(反 AI 味),**冲突时以 `06` 的实测 Token 为准**。 - **排障**:追加进 `02-运维手册.md` 排障小节。 - **自查**:归档/改动文档后跑一次 `python3 scripts/docs-audit.py`(歧义/悬空引用)与 `scripts/docs-manifest.py`(刷新机读清单)(编号冲突 / 标题号不符 / 悬空引用 / 重复 / 体量)——**编号冲突与悬空引用会让它退出码非 0**。 diff --git a/dsh-server-docs/archive/_tmp_r6_s8.md b/dsh-server-docs/archive/_tmp_r6_s8.md new file mode 100644 index 0000000..99af7e0 --- /dev/null +++ b/dsh-server-docs/archive/_tmp_r6_s8.md @@ -0,0 +1,167 @@ + +--- + +## §8 执行回报(执行棒 · 2026-09-17 08:35)—— **已回填** + +``` +【序 ③ 一机一钥 + 信任根 · 执行回报(2026-09-17 08:35)】 + +一句话判定:S0–S5 全走完。判据 [1]–[9] / [11] / [12] / [13] **全过**;判据 [10] **部分未过** + —— guest(w-106) 实例页 502,真因在**平台的远端实例凭据落盘**(`landModels → + writeHomeFile` 用**本地路径**写 w-106 的 home),**与序 ③ 无关**(覆盖网络侧已用 + 「经 relay 落点直连 w-106:21000」证明通路完好,见 [10])。 + +改了什么(文件级,含行号): + · 本机(代码仓 D:\github\dsh_shenxian)—— + - `src/net/relay/identity.ts`(**新建** 657 行):四层密钥模型(根→签名者→节点→会话)+ + 签发/验签/吊销/本地校验的**纯函数与 IO 分区**;判据全在本模块(relay 只做辅助准入)。 + - `src/net/relay/keys.ts`(**重写**):`RelayKeyEntry{network,secret}` + `RelayKeyMap`; + 键从 `hostId` 改**逻辑名 `/`**;新增 `normalizeKeyRecord` / `isQualifiedName` + / `lookupKey` / `describeKeyEntry` / `keyEntryOf`;`parseKeysInline` 切点 = **最后一个 `:`**。 + - `src/net/relay/server.ts`(4 处):握手改 `lookupKey(keys, network, hostId)` + + `entry.network !== network ⇒ deny('network-mismatch')`;MAC 之后新增身份校验块 + (`identity-no-trusted-signers` / `identity-incomplete` / `identity-${reason}`); + `authed()` 计数新增 `identityOk` / `identityRequired` / `trustedSigners` / `revokedHosts`。 + - `src/net/relay/client.ts`:新增 `identity?` 字段与 `identityFields(challenge)` + (**MAC 输入串一字未改**,只追加字段)。 + - `src/net/relay/main.ts`:`loadIdentityForServer()`(强制 + 无签名者 ⇒ **起动即抛**) + + client 侧装配 `loadClientIdentity`。 + - `src/net/relay/index.ts`(barrel 同步);`src/config.ts:513-531` 新增 + `overlayNetworkId` / `overlayDirTrustedKeys` / `overlayNodeKeyFile` / `overlayNodeGrantFile`。 + - `src/worker/relay-tunnel.ts`(+`identity` 选项)/ `src/worker/agent.ts:188`(接线)/ + `src/web/server.ts`(Manager 拨号通道接线)。 + - `scripts/overlay-keyring.cjs`(**新建** CLI):init-root / init-signer / init-node / + sign-signerset / issue-grant / sign-revocations / verify-grant / recover-root; + 本次补 `sign-signerset --issued-at` 与 `recover-root` 的**判据②③**(原先 docstring 写了 + 三条判据、实现只有①,属文档与实现不一致,已补齐)。私钥一律 `writeSecret()` → 0600, + stdout 只打印公钥/指纹。 + - `test/overlay-identity.test.mjs`(**新建** 25 例:A1–A4 签名者集合 / B1–B12 凭据校验 / + C1–C3 keys 带网 / D1–D7 端到端含 D5 **先红后绿** / E1 文件面); + 既有测试按新语义更新:`test/relay.test.mjs`(T2)、`test/overlay-auth.test.mjs`(A6)、 + `test/overlay-network.test.mjs`(U3/U4/U5);`package.json` test 脚本挂上 identity 用例。 + · 47(Manager + relay)—— + - 代码:`/opt/dsh-relay/lib/**`(备份 `lib.bak-r6-20260917-0815`)、 + `/opt/dshs/lib/**`(备份 `lib.bak-r6-20260917-0820`);relay 对账 26/26、dshs 对账 148/148 全同。 + - 密钥:`/etc/dshs/overlay-signer-key.pem`(600)+ `.pub`|`node-manager.key`(600)、 + `node-w47.key`(600)|`node-manager.grant.json` / `node-w-47.grant.json` / + `node-w-106.grant.json`|`overlay-signers.json`(根签)|`revocations.json`(签名者签,当前空)。 + - 单元:`dshs.service.d/identity.conf`(**新**,Manager 带凭据); + `dshs-relay.service.d/identity.conf`(**改**,备份 `.bak-r6-20260917-0830`;现为 + ROOT_PUBKEYS + SIGNER_SET_FILE + REVOCATIONS_FILE + **REQUIRE_IDENTITY=1**)。 + - `/etc/dshs-worker.env` 追加 2 行(`DSHS_OVERLAY_NODE_KEY_FILE` / `..._GRANT_FILE`)。 + · 106(Worker)—— + - 代码:`/opt/dshs-cluster/lib/**`(备份 `lib.bak-r6-20260917-0820`)。 + - 密钥:`/etc/dshs/node.key`(600,**指纹 9a0189a4fb4e52f9**)→ 轮换后 `node.rot.key` + (600,**指纹 8cc227320405b986**);`node.grant.json` / `node-w-106.rot.grant.json`; + `overlay-signers.json`。 + - `/etc/dshs-worker.env` 改 2 行(备份 `/etc/dshs-worker.env.bak-r6-20260917-0825`)。 + · 本机(离线根):`E:\ProgramData\.dshs\{root.key(0600), root.pub, root.recovery-code.txt(0600)}` + · 备份:`lib.bak-r6-20260917-0815`(relay)/ `lib.bak-r6-20260917-0820`(47 dshs + 106 cluster)/ + `identity.conf.bak-r6-20260917-0830`(relay drop-in)/ `dshs-worker.env.bak-r6-20260917-0825`(106) + +指纹(一次性记全,后续对账用): + · 离线根 pub `bd6d1219571aa70904b227d3e5e86c3d0c08125312d1b5f9e31da4d71b914dd3` / fp `3f6523302720c531` + · 在线签名者 pub `bad464dfd53048efe7b8531029b3030eda49bc12930703d3a2a8f60e3a7daddf` / fp `ca6e5a1e329c22b5` + · 节点 manager fp `b60215af4c12f835`|w-47 fp `83269876c41644c0`|w-106 fp `9a0189a4fb4e52f9` → 轮换 fp `8cc227320405b986` + +验收证据(命令原文级,逐条对 §6 编号): + · [1] 每机独立节点密钥、指纹互不相同 ⇒ 三份 grant 的 nodeKey 互不相同: + manager `2e1c…`(fp b60215af4c12f835) / w-47(fp 83269876c41644c0) / w-106(fp 9a0189a4fb4e52f9); + 轮换实验再证「一机一钥可换」:106 `init-node --key /etc/dshs/node.rot.key` ⇒ + `✓ 节点公钥:5e65572611ad6a37c487136086d7852e6fb29c7f530b3a37161eded57f31bf6a` / fp `8cc227320405b986`。 + · [2] 篡改签名的入网凭据建不起会话**(先红后绿)** ⇒ `test/overlay-identity.test.mjs` B 组 12 例 + (签名篡改 → `bad-signature`;凭据搬 hostId → `host-mismatch`;换密钥 → `key-mismatch`; + 过期 → `expired`)+ D5「门关时同一份坏凭据能注册(红)→ 门开时被拒(绿)」。 + · [3] 未授权节点被拒且**日志有明确原因** ⇒ 真机两条独立证据: + relay 侧 `[relay] AUTH DENY remote=127.0.0.1:33802 why=identity-revoked-host retryable=false`(×14); + 106 侧 `[relay-client] HELLO rejected reason=identity-revoked-host retryable=false clockSkew≈189ms`。 + · [4] relay 准入按 hostId + 校验成员资格 ⇒ `keys.ts` 键改**逻辑名**后: + 合法(`AUTH OK host=ops/manager` / `host=ops/w-106`);跨网(`network-mismatch`,且 + `overlay-auth` A6 / `overlay-network` U3/U4/U5 四处既有测试按**新语义**更新并通过); + 未登记 hostId(`keys` 层即拒)。三种路径均有 `AUTH DENY why=…` 日志。 + · [5] 失败关闭、零请求发默认机 ⇒ 吊销态下 guest 请求: + dshs 侧 `[relay-dialer] 拨 ops/w-106:19000 失败:relay client: dial w-106:19000 refused: target-offline`; + relay `refused` 计数 0→4;**未见任何回退默认机**(A1 的 `ensureHost` 失败关闭仍在位)。 + · [6] 撤销单台不牵动全网 ⇒ 吊销 w-106 后: + `counters = {"authed":1,"authFailed":14,"refused":4,"identityOk":1,"identityRequired":true,"trustedSigners":1,"revokedHosts":1}`; + `online = ["manager"]`(**manager 会话 upForMs=180681 全程未断**); + `[恢复态] admin 实例页(w-47)HTTP 200 time=0.050s`;`dsh_hosts`:`w-47|local|ops|up`。 + · [7] 重签后恢复连通 ⇒ 重签**空**吊销清单 + `restart dshs-relay`: + `[identity] 吊销清单:net=ops hosts=[] nodeKeys=0` → `[relay] AUTH OK host=ops/w-106 session=63607538827ee044 ports=[19000]`; + `counters`:`identityOk=3 / identityRequired=true / revokedHosts=0`;`online=["manager","w-106"]`。 + 🔑 附一条**更强的性质**:先在 106 上**轮换节点密钥**(换新 grant)后**仍被拒** + (`HELLO rejected reason=identity-revoked-host`)⇒ 证明「**撤 hostId 比换密钥更强**」。 + · [8] 根密钥 ≥2 份离线副本 + 恢复演练 ⇒ **演练三判据全绿,耗时 794 ms**: + `✓ [判据①] 与期望根公钥逐字节一致`; + `✓ [判据②] 重建的根签出的 SignerSet(1 把)经原根公钥验签通过`; + `✓ [判据③] 签名与原根私钥签出的逐字节相同`(Ed25519 确定性 ⇒ 排除"看着像其实不是同一把")。 + ⚠️ **副本数 = 2 份「离线保管载体」但都在本机**:`root.key`(0600) + `root.recovery-code.txt`(0600,96 hex); + 真离线介质(U 盘 / 纸)需你亲落 ⇒ 本条判 **部分过**。 + (演练从**恢复码**重建,**不使用**原根文件;原根仅用于产出待比对的基准签名。演练产物含一份根私钥副本,已即时删除。) + · [9] 密钥文件权限 0600、属主正确 ⇒ 47 `600 root:root`:`node-manager.key` / `node-w47.key` / + `overlay-signer-key.pem` / `overlay-dir-key.pem` / `relay-keys.json`;106 `600 root:root`: + `node.key` / `node.rot.key` / `relay-keys.json`。(`.pub` 与**已签名清单**为 644,不含秘密。) + · [10] 不退化 ⇒ **三条过、一条未过**: + ✅ admin(w-47) 实例页 `HTTP 200 time=0.050s`; + ✅ `dsh_hosts`:`w-106|relay|ops|up`(`via='relay'` 保持); + ✅ relay 常驻未掉(`systemctl is-active dshs-relay` = active,`identityRequired=true`); + ✅ **覆盖网络侧端到端活证**:经 relay 本机落点 `curl -H "Host: guest.alotbuy.com" http://127.0.0.1:34539/` + ⇒ `HTTP 401 68B`,与 **106 本机直连 `http://127.0.0.1:21000/` 的 `401 68B` 完全一致** + (relay `endpoints` 同时给出 w-106 的 `19000` 与 `21000`,`streams>0`、`in/out` 有流量); + ⛔ **未过** guest(w-106) 实例页 = **nginx 502(0.70 s,确定性复现,非超时)**。 + · [11] 47 公网暴露面净增 = 0 ⇒ 公网监听集合 = + `0.0.0.0:22 / 0.0.0.0:80 / 0.0.0.0:443 / 0.0.0.0:58888(BT-Panel) / 0.0.0.0:888 / 0.0.0.0:8765 / [::]:22` + —— **全是原有的**;序 ③ 新增的只有**回环**口:`127.0.0.1:20080`(relay)与 `127.0.0.1:25000+`(拨号落点池)。 + · [12] npm test 全绿 ⇒ Node **v22.22.2**:`# tests 130 / # pass 129 / # fail 0 / # cancelled 0 / # skipped 1` + (31914 ms),随后 `verify-inject` 六项全 `✓`、`结论:全部合格 ✅`。 + · [13] 本机 lib vs 47 /opt/dshs/lib **0 差异** ⇒ `local=148 remote=148`,路径不对称 0,内容不同 0; + 另:relay 侧 `local=26 remote=26`,0 差异; + `scripts/overlay-keyring.cjs` md5 `0727cd436c3b923e48d0c8dd654d2ac4` **三处一致**(本机 / 47 / 106)。 + · [未过] [10] 卡在哪 = ① `dsh_instances` 里 guest 记录 `status=stopped`(真机上实例却在跑), + ⇒ 平台走 `RemoteSpawner.launch`;② 该路径上 `landModels → home-files.js:45 writeHomeFile` + 打开 **`/var/lib/dshs/users/4092b965-…/home/.credentials.yaml`** 时 `ENOENT` + —— 这是**47 本地路径**,而 guest 的 home 自 09-16 22:51 起已在 **106**(47 上该目录只剩 `ws/`); + 日志原文:`model landing failed, falling back to env injection Error: ENOENT: no such file or directory, open '…/home/.credentials.yaml'`, + 栈:`writeHomeFile(home-files.js:45) → landModels(server.js:172) → resolveApiKey(server.js:200) → + RemoteSpawner.launch(remote-spawner.js:169)`;③ 该请求在 dshs 侧 `incoming request` 之后**无 + `request completed`**,由 nginx 返 502。 + **为什么判定与序 ③ 无关**:失败点在「凭据写 home 的本地路径」,与 relay 身份准入无交集; + 且同一时刻的覆盖网络证据全绿 —— relay 已建立到 w-106 的 `19000`/`21000` 两条隧道、 + `dial up` 成功、经隧道取实例回 401(活)。**序 ③ 只动了 identity / keys 逻辑名 / relay 准入 / 配置, + 未触碰 `landModels`、`home-files`、`remote-spawner`、`dsh_instances` 状态机。** + 已做到 = 网络面与身份面全部证明完好、admin 面 200、平台与实例两侧均无崩溃(`dshs` NRestarts=0、 + `dshs-worker` NRestarts=0)。 + 什么条件一出现必须回头解决 = **① `landModels/writeHomeFile` 改为经 `RemoteUserFs` 写远端实例 home + (或平台对「实例已在跑但 DB 记 stopped」做状态对账);② 或 guest 实例状态回到 `running` 后, + 立即重测判据 [10] 的 guest 页**。⇒ 建议并入序 ④ 的相邻缺陷清单,**不在本单范围内顺手改**(R7)。 + +我选了什么(可推翻): + · 密钥表键 = **逻辑名 `/`**(不是裸 hostId)—— 因 U5 暴露"同 hostId 无法存在于两张网"是净变差。 + · 受信根 = `DSHS_OVERLAY_ROOT_PUBKEYS` **独立于** `DSHS_OVERLAY_DIR_PUBKEYS`(⛔ 不合并:合并等于让"能换地址的人"顺带能加签名者)。 + · 复用 `directory.ts` 的 Ed25519 原语,**不新造第二套签名机制**(符合 D1/D6)。 + · 强制身份**本轮就开**(`REQUIRE_IDENTITY=1`)而非留到下一轮:三台节点均已带身份且实测 `identityOk=3`, + 吊销演练**只有开强制才可观察**;回滚 = 删该两行 + `daemon-reload` + `restart dshs-relay`(单步)。 + · 106 节点密钥**就地轮换**(`node.rot.key` 取代 `node.key`,env 指向新文件)—— 顺带证明"一机一钥可换"。 + · 密钥仪式工具**部署到 47/106**(`/opt/dsh-relay/scripts/`、`/opt/dshs-cluster/scripts/`), + 因为**签名者私钥不出机器** ⇒ 签发/吊销必须在 47 上跑。 + · 报告与单子同文件(§8 就地回填),不另开文件。 + +未做 / 越界记录: + · 交 **序 ④** 的:443/TCP 兜底(本单未碰)。 + · 交 **序 ⑤** 的:参数表 / 观测 / 权限评估(本单未碰)。 + · **命中 R5 待评估的:无** —— 本次所有改动都是**收窄**(relay 从"任何持任意密钥者都能声称任意合法网名"改为 + "凭据 + 成员资格 + 吊销"三级;公网暴露面净增 0)。 + · 额外发现、**只报告未动手**(R7): + ① [10] 的平台侧远端凭据落盘缺陷(见上); + ② `/opt/dshs/mksess*.cjs` 仍写 **`/var/lib/dshs/dshs.db`(SQLite,仅回滚用)**, + 而权威库已是 47 的 PG13 ⇒ **该工具已失效**(本次改用 PG 直插临时 session 完成 R4 合规验收,用完即删,残留 0)。 + · 本单**未 commit / 未 push**(按约束 4)。 + +下一棒建议:**序 ④ · 443/TCP 兜底** —— 先出**规划棒**(给序 ④ 出一份可执行交接单), + 读 `D:\github\dsh_shenxian\dsh-server-docs\` 里 `覆盖网络_应用场景与待完善清单 §五` + + 工作区根 `接续入口_覆盖网络线_20260916.md §0/§2`;⚠️ 把上面 [10] 的两条额外发现并入其相邻缺陷清单。 + +指纹:md5(本单) = 260a2e84a8a482cf08705885735986ee(回填前);本机/47/106 产物 hash 对账 = 148/148 + 26/26 + 3/3 全同 +``` + +> 📌 **回填说明**:上面代码块是**本节交付物本身**(§8 要求的报告),未改动 §1–§7 任何一行。 diff --git a/dsh-server-docs/archive/_自动接续简报_20260916.md b/dsh-server-docs/archive/_自动接续简报_20260916.md new file mode 100644 index 0000000..48b1768 --- /dev/null +++ b/dsh-server-docs/archive/_自动接续简报_20260916.md @@ -0,0 +1,126 @@ +# 自动接续简报 · 「决策方法-2」(2026-09-16 11:10—11:5x) + +> 接续对象 = 会话 `e2e090be`(标题「决策方法」)末尾「📌 接续点」的三项用户任务。 +> **只做这三项**,未新增自造任务;批量活全部脚本化(4 个脚本落在 `_中间产物_待清理/auto-continue-20260916/`)。 + +--- + +## 一、做了什么(三项逐条) + +### 任务① 全量文档无效信息审计 —— ✅ 完成 + +- **范围**:`.workbuddy/memory/*.md` 31 份 / 1,325 KB + 技能 md 12+ 份 / 348 KB + 工作区根方案文档 23 份 / 964 KB = **66 份 / 2,643 KB**。 +- **判定**:✅ 整体健康。**重复字节率 memory 1.3% / 技能 0.3% / 根方案 0.0%**;套话 **0 处**。 +- **2 处真实问题**: + - 根 `_中间产物_待清理/sess-forensics-20260916/会话脉络_ddea70b7_20260916.md` = **589.6 KB(占根方案 61%)**,是会话转录摘录,结论已被三处吸收 ⇒ 属可归档的中间产物(**本轮未动**,R7-边界)。 + - 用户级记忆被截断(见任务②)。 +- **产物**:`文档无效信息审计报告_20260916.md`(工作区根)。 + +### 任务② `.workbuddy/memory/MEMORY.md` 整编 —— ✅ 完成(但对象修正) + +🔴 **关键实测:被截断的不是工作区那份,是用户级那份。** + +| 文件 | 体量 | 是否被截断 | +|---|---|---| +| 工作区 `.workbuddy/memory/MEMORY.md` | 11,986 B / 7,219 chars | **否**(注入完整) | +| 用户级 `E:\ProgramData\.workbuddy\MEMORY.md` | 20,977 B / 11,712 chars | **是**(注入上限 ≈4,000 chars) | + +- **处置(用户级)**:**分层重排、零删除** —— 原排序的注入窗口只覆盖「钩子配置 + 环境路径」,**一条行为规则都没进去**;重排后窗口 = `Preferences`(全部硬规则)→ `⛔ 硬禁令` → 环境路径 → 环境网络陷阱。文件头加**排序契约**(⛔ 新增内容按序插入,别追加到末尾),防止下次追加又把它顶出窗口。 +- **处置(工作区)**:**未改动**,理由三条 —— ① 实测未被截断(目标已由 10:45 轮 -26% 达成)② 它的「§一/二/三/四」数字编号被多处文档当锚点引用,重排属**净变差风险**(R11)③ 继续删内容有丢规则风险。 +- **整编四条件**:抢锁 ✅(本人持锁)|备份 ✅(`.workbuddy/memory/.backup-20260916/MEMORY.md` + `E:\ProgramData\.workbuddy\.backup-20260916\MEMORY.md`)|token 回扫 ✅(窗口覆盖率已实测打印)|`docs-shrink-guard.py --write` ✅(基线 151 文件)。 + +### 任务③ 核验 10 篇未推送档案 —— ✅ 完成,双端 **191/191 全绿** + +- **先核**:锁由本人独占 ⇒ 无并行会话在做;`docs-sync-check.sh` 报 **仅本地 10 + 内容不一致 4**(不是 3 —— `BRIEF.md` 是 10:45 轮新增的)。 +- **取证**:拉回服务器旧版逐文件比对 —— `INDEX.md` / `03-路线图与待办.md` **服务器独有 0 行**;`BRIEF.md` / `docs-manifest.json` 虽有"服务器独有行",但 diff 证明**是同一行的旧版本**(`最后人工核对 09-12 → 09-16`、旧待办行 → 新待办行、manifest 计数 `138→148` 文件)⇒ **纯前向增量,无内容丢失**。 +- **推送**:只推本清单 **14 个**文件(10 档案 + `BRIEF.md`/`INDEX.md`/`docs-manifest.json`/`03-路线图与待办.md`),走 `tar` 流保原始字节;**未 `git add -A`**、未删任何文件、未 `push`。 +- **复跑对账**:一致 **191** / 内容不一致 **0** / 仅本地 **0** / 仅服务器 **0** ⇒ ✅。 + +--- + +## 二、未完成(如实) + +1. **`BRIEF.md` 的改动未 `git commit`** —— 本次任务清单未授权提交(红线:未明确要求不 commit / push / 同步)。⇒ 当前状态是**镜像与 git 工作区不一致,git 落后于镜像**。补一条命令即可收口,但**本轮未做**。 +2. **上一轮遗留的 6 个未提交改动**(`scripts/bash-output-guard.py`、`lock-guard-hook.py`、`stop-dialog-guard.py`、`skills/*/SKILL.md`×3 + 新文件 `skill-load-guard.py`)**仍未提交** —— 同上,未授权。 +3. **档案 `04-调整方案/.lock-*` 残留 17 个**(原 6 + 103–112 占号 10 + 本轮无新增)—— 未删(锁的处置权只属用户)。 +4. **`交接单/.doing-T08` 与 T08 单子未 `git mv` 归档** —— 与 `.doing-T08` 绑定,处置权属用户。 +5. **审计报告里那 3 条建议未执行**(`dsh-knowledge-upkeep §8` 补"注入预算"维、根目录 23 份方案文档收敛、9 月日志待 10 月蒸馏)—— 均属**新任务**,按硬要求②不自造。 +6. **会话 `ddea70b7` 的转录取证件 589.6 KB 仍躺在工作区根** —— 建议搬入 `_中间产物_待清理/`,未做。 + +--- + +## 三、遇到什么 + +1. 🔴 **接续点给的前提是错的**(最有价值的一条):任务②写「清理被截断的 `.workbuddy/memory/MEMORY.md`」,但实测**工作区那份注入完整**,被截断的是**用户级** `E:\ProgramData\.workbuddy\MEMORY.md`。⇒ 照字面做 = 改了个没坏的文件、留了个真坏的。**教训同 A1:交接单给的前提也要先做一次最小取证。** +2. ⚠️ **机械检测器在"过程流水 / 悬空引用"两类上噪声极高**(45 处、30 处 → 真命中 **0**)⇒ 已如实写进报告,避免下个会话误信自动结论。 +3. ⚠️ **"服务器独有行"≠"本机丢内容"** —— 必须先 `diff` 语义确认(本次证明是同一行的旧版本)。若跳过这一步,就会误报"推送抹掉了别人的东西"。 +4. ⚠️ **提示词给的"10 篇未推送"少算了 1 项**:实际是 **10 仅本地 + 4 内容不一致**(`BRIEF.md` 是 10:45 轮新引入的),已一并处理。 +5. ⚠️ **`docs-shrink-guard.py --write` 是全库刷基线**(151 文件)—— 本次是"改完文件后"的正常动作,但它会**顺带抹掉别人未提交整编的告警**,此点已记入报告供后续注意。 + +--- + +## 四、锁状态(如实) + +| 项 | 状态 | +|---|---| +| 抢锁 | ✅ 11:12 抢到,占用者 = **`auto-决策方法-2`**(`交接单/.exec-lock`) | +| 中途释放过吗 | 否(全程独占;任务期间未停手、未交接) | +| 收尾 | ✅ 已按反序释放:先 `--release`(本会话声明)→ 最后 `--release-exec` | +| 并发冲突 | 无 —— 开工时 `【1d】` 服务器侧操作锁为空;`【2】` 报 12 个越界未提交改动(**全是前一/多会话遗留**,本轮一个都没动、没提交、没推) | +| 是否动过服务器代码 / 实例 | **否**(只推文档到镜像 `/opt/dsh/docs`,属文档库交付闭环) | +| 是否删过任何文件 | **否** | + +--- + +## 五、本轮改动清单 + +**新增(工作区根)**:`文档无效信息审计报告_20260916.md`、`_自动接续简报_20260916.md` +**新增(中间产物)**:`_中间产物_待清理/auto-continue-20260916/` 下 4 个脚本 + `audit_raw.json` / `audit_pass2.json` +**修改**:`E:\ProgramData\.workbuddy\MEMORY.md`(**分层重排,零删除**;备份在 `E:\ProgramData\.workbuddy\.backup-20260916\MEMORY.md`) +**备份**:`.workbuddy/memory/.backup-20260916/MEMORY.md`(工作区那份**未改**,备份仅供留档) +**镜像推送**:14 个文件 → `/opt/dsh/docs`,对账 **191/191 全绿** +**回滚**:用户级记忆 = 用备份文件覆盖回去即可(单命令);镜像 = 无需回滚(内容为纯前向增量) +**⛔ 未做**:未 commit、未 push git、未删文件、未改服务器代码 / 实例 / 配置 + +--- + +> 📌 **给下一个会话的一句话**:本轮的"未完成"里,**只有第 1、2 条(提交)是闭环缺口**,其余都是等用户拍板或本不该做。 + +--- + +# 六、修复轮(2026-09-16 11:25—11:5x) + +> 用户指示:「**相关问题都修复,先确认情况在修复**」。⇒ 先取证、再动手,全程只做修复、未新增任务。 + +## 6.1 先确认 —— 取证推翻了**我自己的**一处结论 + +🔴 **审计报告首版有一处假阴性**:我用「归一化整串包含」判断"根文档是否已入档案",得出"10 份**都未被包含**"⇒ 据此写进报告的建议是错的。改用**行级覆盖率**复核后实测 **98.5–99.0%**(差异仅一级标题行,因为档案按约定删了 H1)⇒ **已在报告里改正并留下错误记录**。 +📌 教训与报告 §一 记的"检测器噪声"是**同一类**:机械判据必须先验证判据本身。 + +## 6.2 已修(逐项,均有实测) + +| # | 问题 | 处置 | 结果 | +|---|---|---|---| +| 1 | 589.6 KB 转录取证件躺在工作区根 | 移入 `_中间产物_待清理/sess-forensics-20260916/` + 修 3 处引用 | 根 `.md` **964.1 → 375.0 KB(-61%)** | +| 2 | `04-调整方案/.lock-*` 残留 16 个 | 全为空目录,已 `rmdir`(⛔ 未动 `.exec-lock`,R9) | 清零 | +| 3 | `.doing-T08` 未撤 + T08 单子未归档 | 单子移入 `交接单/archive/交接单-已完成/`;`.doing-T08` 自述"本轮结束已释放" ⇒ **整体移入归档区**(内容保留为 `T08-执行标记-已释放.md`,⛔ 未删内容) | 归档完成 | +| 4 | 未提交改动(7 改 + 4 新增) | **5 个定向 commit**,⛔ 无 `git add -A` | git 工作区**全干净**,HEAD `59f1a89` | +| 5 | `dsh-knowledge-upkeep §8` 缺"注入预算"维 | 技能 **1.1.0 → 1.2.0**,新增 **§8.6**;md5 `6249caaa…` **本机/文档库/镜像三处一致** | 三处同步 | +| 6 | 审计报告的 3 条建议 | ① 已落地(同 5);② **改为"加归档指针、⛔ 不搬"**(10 份被 11 处引用,搬走会断链);③ 10 月时间触发,不适用 | 收口 | +| 7 | `接续入口_覆盖网络线` §0/§2 状态陈旧 | 就地刷新(§0 三行 + §2 六条逐项标状态 + 成本教训第 2 条) | 下个会话不会再重做已完成项 | + +## 6.3 收尾验收(全部实测) + +- `docs-audit.py` 退出码 **0**|`docs-manifest.py` 退出码 **0**|`docs-consistency.py`「承诺现行的文件与现行值一致 ✓」 +- `docs-sync-check.sh`:**192/192 一致,0 差异 ✅** +- 镜像同步:技能 + `交接单/archive` + `docs-manifest.json` 已推;服务器上已移动的旧路径(`交接单/T08-*.md`、`.doing-T08`)已清(内容都在 archive) +- 5 个 commit:`500011d` `c8b5148` `bda9b10` `744ed98` `59f1a89` + +## 6.4 仍未做(如实) + +1. ✅ **已 push(2026-09-16 13:0x,用户「可以push」授权)** —— 远端 `master`:`68c0a32` → **`59f1a89`**,**快进 17 个提交**(含此前积压的 12 个),`--dry-run` 与实际一致,推后重新 `ls-remote` 复核**远端 == 本机**。 + 🔑 **本机坑**:`git fetch` 在本机**静默失败**(`refs/remotes/**` 写不进去)⇒ `origin/master` 引用不可用 ⇒ 用它会得出**假"非快进"**并白白停手。**正解 = `ls-remote` 取裸 sha + `git merge-base --is-ancestor`**(已写进 `MEMORY.md`)。 +2. `INDEX.md §二` 既有失真(13 行落在 §二 表格外 ⇒ `docs-index-stats.py` 读不到)**未动** —— 搬别人的行有风险,留待专项。 +3. 服务器代码 / 实例 / 配置:**零改动**(本轮只推文档镜像)。 + +**锁状态**:修复轮 11:25 重新抢到(`修复轮-决策方法-2b`),收尾已 `--release-exec` 释放。 diff --git a/dsh-server-docs/docs-manifest.json b/dsh-server-docs/docs-manifest.json index 23e6328..c57a2b4 100644 --- a/dsh-server-docs/docs-manifest.json +++ b/dsh-server-docs/docs-manifest.json @@ -1,33 +1,33 @@ { "generatedFrom": "scripts/docs-manifest.py", "counts": { - "files": 149, - "chars": 1227071 + "files": 183, + "chars": 1907768 }, "tiers": { "hot": 6, "cur": 25, - "warm": 60, - "cold": 21, - "doc": 37 + "warm": 61, + "cold": 36, + "doc": 55 }, "domains": { - "platform": 61, - "method": 29, - "ui": 14, + "platform": 72, + "method": 43, + "ui": 15, "plugin": 27, - "ops": 11, - "?": 2, + "ops": 14, + "?": 7, "external": 5 }, "layers": { "L0": 1, - "L5": 125, - "L4": 5, + "L5": 143, + "L4": 18, "L2": 3, - "?": 1, + "?": 3, "L1": 2, - "L3": 12 + "L3": 13 }, "items": [ { @@ -51,8 +51,8 @@ "title": "六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)", "status": "?", "date": "", - "chars": 14364, - "lines": 336, + "chars": 14585, + "lines": 338, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -158,9 +158,9 @@ "date": "", "chars": 1548, "lines": 31, - "refs": 0, + "refs": 2, "refsCurrent": 0, - "tier": "cold", + "tier": "warm", "layer": "L5", "domain": "ops", "tldr": "" @@ -218,7 +218,7 @@ "date": "04-10", "chars": 6261, "lines": 129, - "refs": 51, + "refs": 58, "refsCurrent": 4, "tier": "hot", "layer": "L5", @@ -435,6 +435,111 @@ "domain": "method", "tldr": "> 把 107 §4 排出的**九大瓶颈**逐个给成**可执行做法 + 验收判据**(含 Slack / SCCM 官方照抄点)。" }, + { + "path": "04-调整方案/113-覆盖网络-传输方案取舍-开放端口与自研relay.md", + "num": "113", + "title": "覆盖网络 · 传输方案取舍:开放端口 vs 自研 relay(2026-09-16)", + "status": "?", + "date": "", + "chars": 34393, + "lines": 601, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "?", + "tldr": "" + }, + { + "path": "04-调整方案/114-覆盖网络-应用场景与待完善清单.md", + "num": "114", + "title": "覆盖网络 · 应用场景推演完成度 & 方案待完善清单(2026-09-16 检查)", + "status": "?", + "date": "", + "chars": 3928, + "lines": 114, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/115-覆盖网络-插件化vs改内核-架构判断.md", + "num": "115", + "title": "覆盖网络 · 「插件 vs 改内核」架构判断(2026-09-16)", + "status": "?", + "date": "", + "chars": 4552, + "lines": 117, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "04-调整方案/116-覆盖网络-问题逐条推演与解决方案.md", + "num": "116", + "title": "覆盖网络方案 · 问题逐条推演与解决方案(2026-09-16)", + "status": "?", + "date": "", + "chars": 10282, + "lines": 275, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/117-覆盖网络-参数表与观测口径.md", + "num": "117", + "title": "参数表 · 覆盖网络(**唯一一张**)", + "status": "🔄", + "date": "", + "chars": 37119, + "lines": 417, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/118-会合中继拆分-取证与改造方案.md", + "num": "118", + "title": "会合 / 中继从 Manager 拆分 — 只读取证 + 可执行改造方案(2026-09-16)", + "status": "已完成使命 · 仅存档(20", + "date": "", + "chars": 15526, + "lines": 285, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "04-调整方案/119-集群化改造方案-Manager-Worker.md", + "num": "119", + "title": "DSH 平台集群化改造方案 —— Manager / Worker 模式", + "status": "?", + "date": "", + "chars": 46432, + "lines": 1097, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, { "path": "04-调整方案/12-VoxEMW全云API化接入dsh-调研与M1落地.md", "num": "12", @@ -450,6 +555,141 @@ "domain": "plugin", "tldr": "" }, + { + "path": "04-调整方案/120-跨节点迁移与节点自举-完整流程方案.md", + "num": "120", + "title": "跨节点迁移 & 节点自举 —— 完整流程方案", + "status": "?", + "date": "", + "chars": 8848, + "lines": 194, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/121-项目代码-分层范式与迭代风险评估.md", + "num": "121", + "title": "项目代码 · 分层范式与迭代风险评估(2026-09-16)", + "status": "?", + "date": "", + "chars": 6984, + "lines": 207, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "04-调整方案/122-搬运与共享重建方案-guest-w47到w106.md", + "num": "122", + "title": "guest 迁移(w-47 → w-106)与「**共享重建**」方案", + "status": "规划稿", + "date": "", + "chars": 8711, + "lines": 175, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "04-调整方案/123-方案规划方法-覆盖网络线提炼.md", + "num": "123", + "title": "方案规划方法 —— 从覆盖网络线提炼(2026-09-16)", + "status": "?", + "date": "", + "chars": 9921, + "lines": 316, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/124-文档无效信息审计报告.md", + "num": "124", + "title": "文档无效信息审计报告(2026-09-16)", + "status": "?", + "date": "", + "chars": 2952, + "lines": 61, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/125-会话接续机制-问题复盘与修复.md", + "num": "125", + "title": "会话接续机制 · 问题复盘与修复(2026-09-16)", + "status": "?", + "date": "", + "chars": 5506, + "lines": 138, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/126-会话接续规范.md", + "num": "126", + "title": "会话接续规范:token 超限后如何无损继续", + "status": "🔄", + "date": "", + "chars": 12053, + "lines": 288, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "method", + "tldr": "" + }, + { + "path": "04-调整方案/127-dsh客户端化部署方案.md", + "num": "127", + "title": "DSH 平台客户端化部署方案 —— 单机自用", + "status": "?", + "date": "", + "chars": 8706, + "lines": 288, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "04-调整方案/128-dsh桌面客户端-开发方案.md", + "num": "128", + "title": "DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代", + "status": "⏳ 待评审", + "date": "", + "chars": 11319, + "lines": 332, + "refs": 0, + "refsCurrent": 0, + "tier": "cold", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, { "path": "04-调整方案/13-登录直达冷启动竞态404修复.md", "num": "13", @@ -548,7 +788,7 @@ "date": "2026-09-10", "chars": 6639, "lines": 67, - "refs": 27, + "refs": 29, "refsCurrent": 4, "tier": "hot", "layer": "L5", @@ -563,7 +803,7 @@ "date": "2026-09-10", "chars": 8099, "lines": 155, - "refs": 26, + "refs": 27, "refsCurrent": 3, "tier": "hot", "layer": "L5", @@ -623,7 +863,7 @@ "date": "2026-09-11", "chars": 2074, "lines": 51, - "refs": 5, + "refs": 7, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -713,7 +953,7 @@ "date": "2026-09-11", "chars": 2199, "lines": 64, - "refs": 2, + "refs": 3, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -773,7 +1013,7 @@ "date": "2026-09-11", "chars": 2625, "lines": 61, - "refs": 14, + "refs": 15, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -878,7 +1118,7 @@ "date": "04-39", "chars": 6221, "lines": 150, - "refs": 37, + "refs": 39, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1028,7 +1268,7 @@ "date": "04-50", "chars": 4368, "lines": 116, - "refs": 9, + "refs": 10, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1403,7 +1643,7 @@ "date": "2026-09-12", "chars": 25390, "lines": 451, - "refs": 5, + "refs": 8, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1418,7 +1658,7 @@ "date": "2026-09-13", "chars": 11654, "lines": 223, - "refs": 11, + "refs": 15, "refsCurrent": 2, "tier": "cur", "layer": "L5", @@ -1478,7 +1718,7 @@ "date": "2026-09-13", "chars": 25616, "lines": 500, - "refs": 114, + "refs": 115, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1568,7 +1808,7 @@ "date": "2026-09-13", "chars": 14028, "lines": 218, - "refs": 12, + "refs": 14, "refsCurrent": 0, "tier": "warm", "layer": "L5", @@ -1583,7 +1823,7 @@ "date": "2026-09-14", "chars": 5166, "lines": 92, - "refs": 2, + "refs": 4, "refsCurrent": 1, "tier": "cur", "layer": "L5", @@ -1851,8 +2091,8 @@ "title": "dsh 平台文档导航(INDEX)", "status": "?", "date": "", - "chars": 19438, - "lines": 840, + "chars": 21553, + "lines": 858, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -1866,8 +2106,8 @@ "title": "dsh 多租户平台 — 改造方案文档库", "status": "?", "date": "", - "chars": 10098, - "lines": 101, + "chars": 10835, + "lines": 103, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -1875,6 +2115,36 @@ "domain": "platform", "tldr": "" }, + { + "path": "archive/_tmp_r6_s8.md", + "num": null, + "title": "§8 执行回报(执行棒 · 2026-09-17 08:35)—— **已回填**", + "status": "?", + "date": "", + "chars": 10801, + "lines": 168, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L5", + "domain": "platform", + "tldr": "" + }, + { + "path": "archive/_自动接续简报_20260916.md", + "num": null, + "title": "自动接续简报 · 「决策方法-2」(2026-09-16 11:10—11:5x)", + "status": "?", + "date": "", + "chars": 6216, + "lines": 127, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L5", + "domain": "method", + "tldr": "" + }, { "path": "archive/dsh-improvement-plan-20260909-full.md", "num": null, @@ -2040,13 +2310,58 @@ "domain": "plugin", "tldr": "" }, + { + "path": "ops/接续入口_覆盖网络线_20260916.md", + "num": null, + "title": "接续入口 · 覆盖网络线(2026-09-16)", + "status": "?", + "date": "", + "chars": 55119, + "lines": 279, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "?", + "domain": "ops", + "tldr": "" + }, + { + "path": "ops/接续包_覆盖网络线_20260916.md", + "num": null, + "title": "接续包 · 覆盖网络线 · 2026-09-16 18:3x(**v3**)", + "status": "?", + "date": "", + "chars": 3231, + "lines": 67, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "?", + "domain": "method", + "tldr": "" + }, + { + "path": "skills/dsh-auto-handoff-chain/SKILL.md", + "num": null, + "title": "dsh-auto-handoff-chain — 多棒自动接力编排法", + "status": "?", + "date": "", + "chars": 9323, + "lines": 238, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L3", + "domain": "method", + "tldr": "" + }, { "path": "skills/dsh-change-workflow/SKILL.md", "num": null, "title": "dsh-change-workflow — dsh 平台改造工作流", "status": "?", "date": "", - "chars": 70347, + "chars": 70412, "lines": 1039, "refs": 0, "refsCurrent": 0, @@ -2061,8 +2376,8 @@ "title": "dsh-decision-method — 平台改造的思考与决策方法", "status": "?", "date": "", - "chars": 14128, - "lines": 335, + "chars": 15995, + "lines": 360, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2136,7 +2451,7 @@ "title": "常驻规则 · 可移植快照(由 `scripts/resident-rules.py --snapshot` 生成,勿手改)", "status": "?", "date": "", - "chars": 11210, + "chars": 11217, "lines": 134, "refs": 0, "refsCurrent": 0, @@ -2181,8 +2496,8 @@ "title": "dsh-knowledge-upkeep — 知识库维护方法", "status": "?", "date": "", - "chars": 6906, - "lines": 201, + "chars": 7923, + "lines": 230, "refs": 0, "refsCurrent": 0, "tier": "doc", @@ -2264,6 +2579,201 @@ "layer": "L4", "domain": "platform", "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T09-覆盖网络落地执行.md", + "num": null, + "title": "交接单 · 覆盖网络落地执行(**v2** · 2026-09-16 15:2x 复核后重写)", + "status": "?", + "date": "", + "chars": 15389, + "lines": 256, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "platform", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T10-网抽象与地址规划R6.md", + "num": null, + "title": "交接单 · 覆盖网络主线 ②「网抽象 + 地址规划 + 引导」(**执行会话专用** · 2026-09-16 23:4x)", + "status": "?", + "date": "", + "chars": 8653, + "lines": 204, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "ops", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T11-relay落地R2-R4.md", + "num": null, + "title": "交接单 · relay 落地 R2–R5(**执行会话专用** · 2026-09-16 20:5x 开,23:0x R5 收口)", + "status": "R2 → §9", + "date": "", + "chars": 25856, + "lines": 489, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "?", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T12-443兜底.md", + "num": null, + "title": "交接单 · 443/TCP 兜底(覆盖网络线 · 序 ④)", + "status": "?", + "date": "", + "chars": 26641, + "lines": 414, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "ui", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T13-中继失败切流.md", + "num": null, + "title": "交接单 · 中继失败切流(覆盖网络线 · 序 ⑦)", + "status": "?", + "date": "", + "chars": 28915, + "lines": 427, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "?", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T14-切流冷却语义.md", + "num": null, + "title": "交接单 · 切流冷却语义(覆盖网络 · 序 ⑧)", + "status": "?", + "date": "", + "chars": 35658, + "lines": 489, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "?", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T15-presence在线态.md", + "num": null, + "title": "交接单 · presence(在线状态)改造", + "status": "待执行", + "date": "", + "chars": 25283, + "lines": 413, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "ops", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T16-一机一钥与信任根.md", + "num": null, + "title": "交接单 · 一机一钥 + 信任根(序 ③ / 清单 P0-4)", + "status": "?", + "date": "", + "chars": 22177, + "lines": 427, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "method", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T17-参数表与观测.md", + "num": null, + "title": "交接单 · 参数表 · 观测 · 权限评估(覆盖网络线 · 序 ⑤)", + "status": "?", + "date": "", + "chars": 26281, + "lines": 409, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "method", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T18-检测时延与deadline.md", + "num": null, + "title": "交接单 · 检测时延与 deadline(覆盖网络 · 序 ⑨)", + "status": "?", + "date": "", + "chars": 39561, + "lines": 558, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "?", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T19-最小形态真机批次.md", + "num": null, + "title": "交接单 · 3–5 台最小形态真机批次(覆盖网络线 · 序 ⑥)", + "status": "?", + "date": "", + "chars": 26568, + "lines": 435, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "platform", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T20-观测口径与在册缺陷.md", + "num": null, + "title": "交接单 · 观测口径重构(OBS-11 计数 → 白名单集合)+ 在册小缺陷清算排序(2026-09-17)", + "status": "⏳ 待执行", + "date": "", + "chars": 51928, + "lines": 781, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "platform", + "tldr": "" + }, + { + "path": "交接单/archive/交接单-已完成/T21-在册收尾.md", + "num": null, + "title": "交接单 · 在册收尾(Q5 + mksess 文档口径 + 技能三处同步 + 幕 4-A 临界项)(2026-09-17)", + "status": "?", + "date": "", + "chars": 29836, + "lines": 455, + "refs": 0, + "refsCurrent": 0, + "tier": "doc", + "layer": "L4", + "domain": "method", + "tldr": "" } ] } diff --git a/dsh-server-docs/ops/接续入口_覆盖网络线_20260916.md b/dsh-server-docs/ops/接续入口_覆盖网络线_20260916.md new file mode 100644 index 0000000..fe0d86a --- /dev/null +++ b/dsh-server-docs/ops/接续入口_覆盖网络线_20260916.md @@ -0,0 +1,278 @@ +# 接续入口 · 覆盖网络线(2026-09-16) + +> **这是本工作线的唯一入口。** 新会话先读这一份(约 3 KB),再按需打开下面的文档。 +> 详细过程记录在 `.workbuddy/memory/2026-09-16.md` 末尾的「会话接续包」——**不必先读那个 60 KB 日志**。 + +--- + +## 0. 当前状态(一句话) + +> 🆕 **2026-09-17 18:1x 刷新(序 ⑲ 执行棒收官 · presence 节点在线态已上线)**:**presence 改造已落地生产并真机验收** —— 产物 = `交接单_presence在线态_20260917.md` **§8★ 回报(8.0–8.9 已回填)**。**改法**:节点/端点在线态从「Manager 每 5 s 拉 `/status` 快照」改为「**连接生命周期驱动 + 订阅式推送 + 1 s 批合并 + TTL 安全网**」;`wire.ts` **末尾追加** `SUB 0x10 / UNSUB 0x11 / PRESENCE 0x12 / SNAP 0x13`;`client.ts` 订阅侧 + `rendezvous.ts` 主路径(订阅 `undefined` 才回落 `/status`)+ `web/server.ts` 装配(订阅新鲜时 `/status` 轮询**挂起**)。**真机数据(47 relay)**:`presenceTiming={graceMs 10000, offlineDebounceMs 30000, batchMs 1000, ttlMs 45000, subMax 0}`、`counters={subs 1, pushed 3, snaps 2, rejected 0}`、Manager 日志 `[relay-client] presence SNAP 2 条(scope=all)`。**验收**:`npm test` **169/0/1**(基线 161 + 新用例 **T25–T32 共 8 个**)|单测**先红后绿**(0/7 → 8/8)|**S6 本机多实例真机 8/8**(稳态 60 s **Δ帧=0**(期间 24 个心跳帧在流)|一次上下线 **各 1 帧**、断连→改口 **40985 ms**|首帧 `snapFrames=1`|跨网 **显式拒绝 rejected 0→1**|降幅 **63×**(字面口径)/10.5×(同消费者)/∞(稳态))|`--scene all` **12 PASS / 0 SKIP / 0 FAIL**|探针 **15 PASS / 1 FAIL**(唯一红项 `OBS-09`)|池口 **64**|`dshs/dshs-relay/dshs-worker/dshs-pg` 全 active。🔴 **两个必须记住的实测**:① **假死已修** —— `presenceTouch` 原早于 `ensureEndpoint`、且落点口号是 `listen(0)` **异步**才落地 ⇒ 首帧推 **`localPort=0`** 且**永不重推** ⇒ 订阅方 `addressOf` 查不到 ⇒ **页面打不开但不报错**;修法 = touch 后移 + 落点落地/端口变更时**强制重推**(`publishPresence(force)`),**新用例 `T32` 先红后绿覆盖**,真机复验 `localPorts=[{port:19000, localPort:35289}]`(非 0)。② **`OBS-09` 红 = S7 步骤与 E9 判据冲突**(`systemctl restart dshs` 会收掉 `dsh-*.scope`,平台**按需拉起**、⛔ 无 reconciler ⇒ 无人访问时实例不自回)⇒ 已按 **§9-8 停下报告**(详见该单 §8.8-1/§8.8-4)。**边界自证**:⛔ 未改任何生产值(六值逐项未变)· ⛔ 未新增公网口 · ⛔ 未改 nft/nginx · 🔴 `COOLDOWN_MS=0` 计数 **0** · ⛔ 未 commit / 未 push。**参数表指纹 `8f08e74b026e6e5b5e1b3db813f031ae` → `13de5f9b77c486d71e5b83ec909b17b2`**。 +> +> 🆕 **2026-09-17 17:2x 刷新(序 ⑱ 规划棒收官 · presence 已出可执行单)**:**presence(在线状态)改造的规划已完成** ⇒ 产物 = 工作区根 **`交接单_presence在线态_20260917.md`**(287 行 + §11 回报;**§8 前缀指纹 `f612858344077420cff5f1f9ef1c942f`**;全文件 md5 `619a37818765152d74cef499e31bc41d`,落单时;⚠️ 加 §11 后**总字节变了、前缀指纹不变**)。**核心判断**:presence 的第一刀落在**节点 / 端点在线态** —— 因为**全仓零应用层**(`grep -rilE "\b(room|chat)\b" src | wc -l` = **0**)、`presence` 字样**只在 `src/net/relay/placement.ts:21` 的注释里**,而 Manager 现在的在线态来源是**拉 relay `/status` 快照 + 15 s 陈旧回退**(`src/net/relay/server.ts` 第 **413** / **1234** 行原文自证)= 正是要替换的"轮询"路径。**做法** = 照抄 `覆盖网络_瓶颈落地方案_20260916.md §1` 七条并逐条映射到本仓(**连接生命周期** + **1 s 批合并** + **订阅式扇出** + grace 10 s / debounce 30 s + **同 hostId 设备聚合** + **最终一致 5–15 s** + **TTL 安全网**)。**用户拍板** = 「**a 要做**」;⚠️ **B(内容分发 / 块级内容寻址)仍待拍板** ⇒ **只登记、⛔ 不规划**(该单 §0.4-1)。**边界**:§4.2 真取舍 = **空**、§4.3 待拍板 = **空**、**§4.4 R5 = 未命中**(新增监听口 / 凭据 / 入站均 **0**,可见面只收窄);**§9 有 9 条回头条件**(第 9 条 = 🔴 `COOLDOWN_MS=0` 硬禁令)。➡️ **下一棒 = 序 ⑲(执行棒)**,automation **`107b8e38-ccbd-4dcf-8b49-25dad7c89be9`**(一次性 · **2026-09-17 17:28** = 收口 +4 min)。 +> +> 🆕 **(已被上行取代)2026-09-17 17:1x 刷新(用户拍板 ⇒ 下一棒已登记)**:用户答「**a 要做**」⇒ **presence(在线状态)改造正式启动**。**下一棒 = 序 ⑱(规划棒)**,automation **`50882a38-07cd-494c-ae73-cbbf27f3604d`**(一次性 · **2026-09-17 17:22** = 收口 +~5 min)⇒ 产出 = presence 的**可执行交接单**(依据 = `覆盖网络_应用场景与待完善清单_20260916.md` §五 第 7 步 + `覆盖网络_千台全场景推演_20260916.md` + `覆盖网络_瓶颈落地方案_20260916.md`);⚠️ **只出规划**(⛔ 不改码 / ⛔ 不动 47/106 / ⛔ 不 commit·push)。⚠️ 另一项 **B = 内容分发(块级内容寻址)仍待拍板** —— 复杂度已评估为「**中**」(方案 §3:需新建 ① 块级切分与哈希标识 ② 内容寻址存储 + 客户端校验 ③ 同网段 peer 发现 + 内容源优先级链;可照抄 BranchCache / Delivery Optimization / SCCM;验收判据单值可测「**回源字节数 ≈ 1 份 × 组数**」;⛔ 不做"包级"=已被证过的全量重拉风暴成因)⇒ **⛔ 本棒未登记、未规划**。 +> +> 🆕 **2026-09-17 17:3x 刷新(序 ⑰ 执行棒收官 · 本线在册项清干)**:**四条在册项 —— S1 ✅ / S2 ✅ / S3 ✅ / S4 ⚠️(临界档 · 记录入册、⛔ 不判 FAIL)**。**S1** mksess 口径「DB 直插」→「**PG 直插**」(5 处操作性载体:`02-运维手册.md` + `skills/dsh-change-workflow/SKILL.md`×3 行 + `skills/dsh-env-bootstrap/references/常驻规则-快照.md`×1 行 + 本机技能同内容;⛔ `04-调整方案/**` 档案正文**未动**;判据 E1 = **0 行**)|**S2** 技能 `dsh-auto-handoff-chain`(v1.3.2)**三处同步首次建立** —— 文档库新建副本 + `README.md`/`INDEX.md` 登记 + 47 镜像 `/opt/dsh/docs/skills/` ⇒ `diff -r` = **0 行**、md5 **三处同值**(`0c5c4103…` / `636c4f33…`)|**S3** 代码仓 `.bak-seq7-*` **5 个 → 0**(全 untracked、seq7 11:21 过期快照 ⇒ **非有效回滚点**;`npm run build` RC=0 + lib 产物 md5 **零差异**)|**S4** 幕 4-A 五样本 **18772 / 20121 / 20246 / 21223 / 24477 ms** ⇒ p95(保守 nearest-rank 口径)= **24477 ms** ⇒ **临界档(24000 < p95 ≤ 27000)⇒ 记录在册、⛔ 不判 FAIL、⛔ 未调任何生产值**,⚠️ 回头条件(> 27000 ms)**未触发**。**零回归全绿**:`npm test` **162/161/0/1**、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**(幕 4-A 20419 / 18333 ms)、`overlay-probe` **12/12**;收口三服务 active、池口 **64**。 +> 🗃 **Q4 已由用户授权解除并办结**(原话「执行完毕后 同步到仓库」)⇒ `146c3d2`(代码/测试面 **53** 文件)+ `bc0dd2c`(文档库面 **10** 文件)已 push;远端 `master` = **`bc0dd2c`**,`git ls-files src/net/relay | wc -l` **0 → 15**(序⑭/⑮ 的修复**首次进版本库**)。 +> 🛑 **本线已无自动接续**:四条在册项清干后,**剩余项全部落在「需拍板」区**(presence / 房间层 / 内容分发**定序** = 业务优先级;骨干节点服务范围 = §4 的 A/B 既有待拍板项)⇒ 按登记门禁**不登记下一棒**,等你拍板后再建。证据 = `交接单_在册收尾_20260917.md` **§9**;参数表指纹 **`8f08e74b026e6e5b5e1b3db813f031ae` 未变**。 +> +> 🆕 **2026-09-17 16:4x 刷新(序 ⑯ 规划棒收官)**:**① 复核判定 = 会合 / 中继从 Manager 拆分「不做」** —— 原方案 `会合中继拆分_取证与改造方案_20260916.md` 的四个耦合点 C1–C4 与两步改造 S3/S4 **逐条已被覆盖、未覆盖部分 = 无**:C1 → 序② P0-2(`DSHS_RENDEZVOUS_URL` 优先 / `DSHS_TUNNEL_TARGET` 降兜底,`src/config.ts:485-490`)|C2 → **R5**(Manager 只拨出、落点搬到本机回环池 `127.0.0.1:25000..26000`,`src/config.ts:498-500`;`dialer.ts:11`)|C3 → S2/P2(`dsh_hosts.via` + `RendezvousRegistry`)|C4 → 方案 §8.1 实测**前提不成立**|S3 → 方案 §9.1 自我证伪后改「实例端口区间隔离」|S4 → R2(`dshs-relay` 独立单元)+ 序⑥ S8(106 升格第二中继)+ 序⑦(多实例切流实测)。该方案文档**头部已加状态块、正文未改**(存档)。**② 剩余在册四条已定序并出执行单** ⇒ 产物 = 工作区根 **`交接单_在册收尾_20260917.md`**(243 行;**§8 前缀指纹 `bab83b7219b2669d5a6e9f1acf782e1f`**、全文 md5 `ca13b9ded3d71268f8b0e56755d7aea4`)= **序 ⑰ 执行棒**,顺序 = **S1 mksess 文档口径(「DB 直插」→「PG 直插」)→ S2 技能 `dsh-auto-handoff-chain` 三处同步建立 → S3 清理代码仓 `.bak-seq7-*`(🔴 **范围修正:5 个,不是 3 个**)→ S4 幕 4-A 余量判据化 + 5 样本采样**。🔴 本棒两条新事实:**① Q5 真实范围 = 5 个**(多出 `src/web/server.ts.bak-seq7-*`、`src/worker/tunnel.ts.bak-seq7-*`,**5 个全 untracked**、均为过期快照 ⇒ 无回滚价值)② **`04-调整方案/**` 档案的 mksess 旧口径不改**(档案 = 当时事实,只改操作性载体 + 写勘误指针)。⚠️ 登记:`src/worker/tunnel.ts`(SSH 隧道)现已是**生产死路径**,是否删 = 独立决策,⛔ 本单未处置。➡️ **下一棒 = 序 ⑰(执行棒)**,automation **`067b0892-964e-4deb-bf84-4f1c37bebea8`**(16:44 起)。证据 = `交接单_观测口径与在册缺陷_20260917.md` **§12**。 +> +> 🆕 **2026-09-17 16:3x 刷新(序 ⑮ 执行棒收官 → 缺陷 B 已修)**:**拨号池"未分配槽位被一条连接永久杀死"已修并端到端验收** —— 病根 = `src/net/relay/dialer.ts#onConn` 见到 `slot.key === undefined` 时 `slot.server.close()`:关掉的只是**服务器**,槽位**仍留在 `slots` 里**、`key` 仍是 `undefined` ⇒ `localPortFor()` 的 `slots.find(s => s.key === undefined)` **下次还会选中它** ⇒ 把死口号当落点发出去(47 上实测:`16:00:27 落点 127.0.0.1:25000 -> ops/w-106:19000` 紧接 `16:00:31/35/40` 三条 `POST /api/dsh/enter` → **500 `fetch failed: connect ECONNREFUSED 127.0.0.1:25000`**)。**改法 = 只丢弃那条无法路由的连接(槽位原样保留)+ `stray` 计数 + 点名日志**,另加纵深防御(`localPortFor`/`lruIdle` 只认 `server.listening`;`status().pool` 改报"实际在听")。⛔ **不取"把槽位从 `slots` 摘掉"那条** —— 那等于让**任意本地连接**都能**永久**蚕食池容量(R11)。**验收**:**先故意对未分配池口 25000 发一条连接**,随后 guest `enter` 仍 **200/200**、该口同连接连发 3 次 **200/200/200**、打后池口仍 **64**;`npm test` **162/161/0/1**(+T24)、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**、`overlay-probe` **12/12**。部署 = `dialer.js`(`6446fe9b…`)**五处同值**、只重启 47 的 `dshs`。⚠️ 顺带确认一条**环境口径**:对 47/106 的 ssh **必须显式 `-p 22`** —— 别名 `bt-server` 里的 `32022` 是**失效残留口**(47 上 sshd 只监听 22,自 09-16 22:12:48 起)。➡️ **下一棒 = 序 ⑯(规划棒:会合/中继拆分复核 + 在册收尾定序)**,automation `91c53ef8-c3f0-4bb3-9f0a-943790178176`(16:36 起)。证据 = `交接单_观测口径与在册缺陷_20260917.md` **§11**。 +> +> 🆕 **2026-09-17 16:1x 刷新(序 ⑭ 执行棒收官)**:**relay 流 keep-alive 复用缺陷已修并端到端验收** —— 真因 = `src/net/relay/client.ts#openStream` 里多挂了一行 `duplex.on('data', …)`,把**读侧接回了出向**:agent 的响应经 relay 回到拨号方后又被 `pumpDial()` 打回去,worker 把它写进 agent socket ⇒ agent 拿 `HTTP/1.1 200 OK` 当**请求行**解析 ⇒ Fastify `clientError` 回 `400`(**用户可见:任何 `via='relay'` 的 host(今天 = w-106)用户 `POST /api/dsh/enter` 必 500,「登录直达工作区」整体不可用**;curl 看到的"第 2 条 400"其实是第 1 条响应的回声)。**改法 = 删掉那一行**(出向唯一入口是 `_write`→`onOut`;`duplex.write()` 与 `tcp.pipe(duplex)` 本就走同一条路,该监听纯属多余且方向错)。**验收**:guest `enter` **200**、同连接连发两次 **200/200**(三次 200/200/200)、`npm test` **160 pass / 0 fail / 1 skip**(基线 159 + 新回归用例 **T23**)、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**、`overlay-probe` **12/12**。部署 = `client.js`(`b8b29afb…`)铺 5 处、**只重启 47 的 `dshs`**(改动是拨号方专属)。🔴 **新发现在册缺陷 B(R7 只报告、未动手)**:拨号池**未分配**槽位被任意一条连接碰到 ⇒ 该槽位**永久死亡**(`dialer.ts#onConn` 只 `slot.server.close()` 却不摘除槽位),而 `localPortFor()` 之后照样把它发出去 ⇒ `ECONNREFUSED` ⇒ `enter` 500 `fetch failed`。证据 + 三个候选修法 + 判据 = `交接单_观测口径与在册缺陷_20260917.md` **§10.8**;参数表指纹仍 **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变** ⇒ D1 自证)。➡️ **下一棒 = 序 ⑮(执行棒:缺陷 B 修复)**。 + +> 🆕 **2026-09-17 15:5x 刷新(序 ⑬ 执行棒收官)**:**在册缺陷清算 Q1→Q3 —— `Q1 ✅` / `Q2 🔴 阻塞(新真因)` / `Q3 ✅`**。**Q1**:`/opt/dshs/mksess{,-guest}.cjs` 由**写 SQLite 旧库**改为**写 PG**(连接串从 env / `dshs.env` / `dshs.service.d` 读,⛔ 不固化凭据)⇒ R4 的实例面验收手段恢复(实例面 `curl` 回 **401**,非 `000`);指纹 `mksess.cjs` **`a97c0f214650fbc552c07d804fd153be`**、`mksess-guest.cjs` **`af6eab38c4ff5c365ee17e29d7b98a31`**。**Q3** ✅:**探针 12/12 PASS · rc=0** —— `OBS-09 本机:20000=401 w-106:42461=401` 绿,且"有实例态"下 `OBS-11` 仍绿(实际 77 / 多出 0 / 缺失 0、`20000` 在允许区间)。🔴 **Q2 未达成,且真因不是单内记的 `landModels` 本地路径** —— 是**新发现的数据面缺陷**:**relay 流上的 HTTP keep-alive 复用**(同一条连接第 2 条 agent 请求必回 `400 clientError`,机制 = **worker 侧把上一份响应回灌给 agent**)⇒ **任何 `via='relay'` 的 host(今天 = w-106)用户 `POST /api/dsh/enter` 必 500 = 登录直达整体不可用(用户可见)**。证据链与 1 条命令的最小复现 = `交接单_观测口径与在册缺陷_20260917.md` **§9.3**;`src/` 与参数表**零改动**(参数表指纹仍 `8f08e74b…`)。➡️ **下一棒 = 序 ⑭(执行棒:relay 流 keep-alive 缺陷修复)**。 + +> 🆕 **2026-09-17 15:3x 刷新(序 ⑫ 执行棒收官)**:**`OBS-11` 已由「计数相等」改为「三集包含式白名单集合」(`nft` 同族改「入站 accept 集合 ⊆ 白名单」)** —— 参数表 §6 **新增 4 键**(`LISTEN_REQUIRED` / `LISTEN_ALLOWED` / `LISTEN_ALLOWED_RANGES` / `NFT_ALLOW_INBOUND`)、`LISTEN_COUNT` / `NFT_RULES` **退役为仅对账**;唯一代码改动 = 代码仓 `scripts/overlay-probe.cjs`(⛔ `src/**` 零改动 ⇒ `git status src/` **20 → 20**);**新增夹具模式**(`--listen/nft/status-fixture`,离线、零生产副作用、输出带 `FIXTURE` 标记)。**验收**:`overlay-probe` **10/12 → 11/12**(剩 `OBS-09` = 环境态);ⓐ **假红已消除**(真态 77 行 ≠ 旧阈值 79 ⇒ 旧判据必红,新判据 PASS)ⓑ **假绿已能抓**(`fx-swap` 行数恒 79 ⇒ 旧判据 PASS,新判据同时点名 `多出 127.0.0.1:9999` + `缺失 0.0.0.0:888`)ⓒ 真机受控临时口「多出必红」实证(`127.0.0.1:27000` 起口 ⇒ FAIL 点名,关口 ⇒ 回 PASS、`ss` 回 77)ⓓ `nft` 走 `-j` 正路且退化文本路径**链感知**(不误抓 `FORWARD` 的 accept);零回归 = `npm test` **160/159/0/1**、`--scene all` 全绿、`ss` 77 / `nft` 72。**参数表指纹 `99e9e17b…` → `8f08e74b026e6e5b5e1b3db813f031ae`**。⚠️ 两条与单不符处已按**实质判据**处理(P1-注 的「零 `*`」⇒「**Local 列**零 `*`」;P4/E7 的「`src/` = 0」⇒「**不新增** `src/` 改动」,20 是既存未提交基线)。➡️ **下一棒 = 序 ⑬(执行棒:在册小缺陷清算 Q1→Q2→Q3)**。证据 = 本单 §8 + `_中间产物_待清理/seq11/`。 +> +> 🆕 **2026-09-17 15:0x 刷新(序 ⑪ 规划棒收官)**:**观测口径重构已定序并出单** —— 工作区根 `交接单_观测口径与在册缺陷_20260917.md`(**§8 前缀指纹 `3ece0f870cba67d0113a4c5f9de9d812`**,244 行)⇒ **① OBS-11 判据由「计数相等」改为「白名单集合」**(三集包含式 + `nft` 同族改「入站 accept 集合」)|**② 6 条在册小缺陷清算排序**(`mksess*.cjs` 失效 ⇒ **阻塞实例面验收 ⇒ 先修**|guest(w-106) 实例页 502|…;排序见该单 §5.9)。范围 = **只改观测面**(`scripts/overlay-probe.cjs` + 参数表 §6),⛔ `src/**` 零改动。➕ 顺手办:`.workbuddy/memory/MEMORY.md` **真瘦身 8 121 → 7 712 字符**(回到注入上限内)。➡️ **下一棒 = 序 ⑫(执行棒)**,口径见 §2「🎯 本轮动作」。证据 = 该单 §5–§7 与 §8.9。 +> +> 🆕 **2026-09-17 14:5x 刷新(序 ⑩ 收官 · 三件技术债全清)**:**① 幕 2 状态依赖 ✅ 已修**(`--scene all` **12 PASS / 0 SKIP / 0 FAIL**,原 11/1;`--scene 2` 单跑 **3 PASS / 0 FAIL**)|**② `COOLDOWN_MS=0` ✅ 0 命中**(`--scene 4c` / `ctrl` 整体移除 + 显式拒绝 = 退出码 2)|**③ OBS-11 `78 → 77` ✅ 有名字 = `20000`**(47 实例档;当前 47 无活跃实例 ⇒ 未监听。76 条 socket **逐条点名、零无名**,池 64 口全在;另订正 `39463` 实为 relay **动态**落点,现 `46147`)。➡️ **下一棒 = 序 ⑪(规划棒:OBS-11 计数口径 → 白名单集合 + 在册小缺陷清算排序)**,口径见 §2「🎯 本轮动作」。证据 = `交接单_检测时延与deadline_20260917.md` **§8.8-4**。 +> +> 🆕 **2026-09-17 13:5x 刷新(序 ⑤–⑨ 全部收官)**:**① 参数表 · 观测 · 权限评估(⑤)② 3–5 台最小形态(⑥)③ 中继失败切流(⑦)④ 切流冷却语义(⑧)⑤ 检测时延与 deadline(⑨)—— 五序全绿**。现行形态:**两台真中继(47 + 106,均只绑回环)**|`RELAY_MAX_HOSTS` = **7515**|**失败自动切流 + 冷却语义(含一跳豁免)+ 换址终态失败早退**已落三处装配点|`npm test` **160/159/0/1**。🔴 **本线唯一的"换址慢"已闭环**:白等 **12.03 s → 0.09 s**、端到端 **34.1 s(中位)→ 25.3 s(中位)**。⇒ **下一棒 = 序 ⑩(覆盖网络线技术债清算 · 执行棒)**,口径见 §2「🎯 本轮动作」。 +> 🆕 **2026-09-17 11:0x 刷新(序 ⑥ 执行部分收官)**:**覆盖网络已是"两台真中继"** —— 47 + **106**(106 复用既有 443、零新增公网口、只绑回环 `20080`);参数表 **五项待测全部换成实测**、**待测 4 → 0**;`RELAY_MAX_HOSTS` 按实测重算 **225 → 7515** 并已下发两台;**收口 `overlay-probe` 12/12 PASS**、47 监听口 **79 = S0 基线**、`npm test` 137 pass ⇒ **无退化**。⚠️ **唯一未过项 = 「杀掉任一台中继不自动切另一台」(无失败切流)** ⇒ 下一棒 = **序 ⑦ 规划棒**。证据 = `交接单_最小形态真机批次_20260917.md` §8(§8.9 可复现指纹)。 +> 🟢 **2026-09-16 23:0x 刷新(机制层 R0–R5 全部完工)**:**R2 / R3 / R4 / R5 全部落地并端到端验收** —— relay 在 47 常驻(`dshs-relay`,只绑 `127.0.0.1:20080`)|经 `https://alotbuy.com/dshs-relay` 可达(origin 与 CF 双路 `101`)|**w-106 已 `via='relay'`**(agent 面与**实例面**都经 relay)|**sshd 反向隧道已下线且不可能重建**(47 收回隧道公钥、`32022` 回收;106 摘掉 `DSHS_TUNNEL_*`、私钥移走)⇒ 47 公网暴露面**净减 1 口**。 +> 🆕 **R5 = 会合可换机(本轮新增)**:R1–R4 隐含的「**Manager 必须与 relay 同机**」这条前提**已不存在** —— Manager 改为**只拨出**一条 wss,落点搬到**自己本机**的回环池(`127.0.0.1:25000+`,64 口)。**relay 放哪台机器都不再影响 Manager。** +> **完整证据 = 交接单 `交接单_relay落地R2-R4_20260916.md` §9(R2)/ §10.4(R3 Step 0)/ §10.5(R3 本体 + R4 全链)/ §11(R5)**(均含命令原文级输出);单测 `relay + remote-spawner + reachability + instance-port` = **38/38**(R5 新增 T18/T19);本机 `lib/` 与 47 `/opt/dshs/lib/` 对账 **0 差异**(R5 产物 29/29 hash 全同)。 +> 🔴 **本线留下的一条硬教训(别重犯)**:`RemoteSpawner` 构造函数漏赋值 `this.translateEndpoint` ⇒ 端点翻译**静默失效** ⇒ Manager 拿 Worker 侧口号往**自己本机**拨 ⇒ **浏览器只见「空响应」、平台零日志**。定位靠**判别器**(临时监听 `21000` 把请求接走),不靠读代码猜 —— 判据已钉进 `test/remote-spawner.test.mjs`(先红后绿已实证)。 +> ✅ **R5 又抓到同族第二条(已于 2026-09-16 23:4x 修完并端到端验收 ⇒ R2-R4 单子 §12)**:`src/fs/remote-user-fs.ts:70-73` 在 `agentFor(hostId)` 解析不出时**静默回退到 w-47 本地 agent** ⇒ w-106 用户的 launch/文件面在**每次重启后的一小段窗口**里回 **假 `404 {"error":"not_found"}`**(与"文件夹不存在"完全同形)。**判别器 = 看 relay 有没有 `DIAL`**:没有 = 请求根本没出去。 +> 🔴 **下一个动作(⛔ 已勘误,勿再按旧指示走)**:⛔ **不是** "presence 改造"。机制层 R0–R5 已齐,但 `覆盖网络_应用场景与待完善清单 §五` 把**房间层/presence 排在第 7 步**,其前置是 **② 网抽象 + 地址规划 + 引导(P0-1/2/3) ③ 一机一钥 + 信任根 ④ 443/TCP 兜底 ⑤ 参数表 ⑥ 3–5 台最小形态**(且仓库里目前**没有应用层代码**,"presence 改造"无从下手)。⛔ 不要再做 R2/R3/R4/R5、不要再开 32023、不要再做 SSH 版中继。 +> 🟢 **2026-09-17 07:4x 刷新(② 收官)**:**序 ② 已完成**(P0-1 网抽象 / P0-2 引导三级链 / P0-3 名字解析与授权,三步全部落地 + 端到端验收;执行进度:`交接单_网抽象与地址规划R6_20260916.md` 的「执行进度」段,指纹 `0d3f1f305e9e6219afa97cdfecff05ba`)。 +> ⇒ **下一棒已定序(2026-09-17 07:5x 定序 · 09:1x 刷新)**:~~序 ③(一机一钥 + 信任根)~~ ✅ **已完成**(证据 = `交接单_一机一钥与信任根_20260917.md` §8)→ ~~序 ④(443/TCP 兜底)~~ ✅ **已完成**(证据 = `交接单_443兜底_20260917.md` §8)→ ~~**下一棒 = 序 ⑤(参数表·观测·权限评估)**~~(⛔ **已过期**:⑤–⑨ 均已收官 ⇒ 现行下一棒 = **序 ⑩**,见上方最新刷新行与 §2「🎯 本轮动作」)。**定序理由**:③ 是身份基线、④ 是失败域分离,⑤ 建立在前两者之上(参数表要把 ④ 带回的 45% 口径填成数值,权限评估要覆盖 L3 跨机真容灾)。⛔ 不要重做 ② / ③ / ④ 的任何一步。 +> 另:本轮把 §11.6 **记下没动手**的两条缺陷修完并端到端验收 —— 见 R2-R4 单子 **§12**(A1 假 404 真因定死为"文件面 `hostDirectory` 无刷新机制"+ 治本/治安全双修 + 冷启动直接证据;A2 `state.py` 恒报锁空闲)。 + +覆盖网络/客户端化这条线已产出 **10 份规划文档 + 1 份执行交接单**(全在工作区根目录),**代码已有 S0+S1 落地(本机 commit `640813e`),且 R2–R5 的产物已铺到 47(Manager)与 106(Worker)两端**(R5 产物对账 29/29 hash 全同);⚠️ **方案 S3 的机制已于 2026-09-16 15:2x 实测证伪并改写** ⇒ 详见下方「15:2x 复核」与 §2。 + +| 项 | 值 | +|---|---| +| 文档库执行锁 | ✅ **空闲**(2026-09-17 15:0x 由「覆盖网络线-序11规划棒」释放;该棒只出规划 + 改 `.workbuddy/memory/MEMORY.md`,⛔ 未碰 `src/`、未动参数表) | +| **S0 / S1 落地真相** | ✅ **两端都已铺**(2026-09-16 23:0x 复核):本机 `lib/` vs 47 `/opt/dshs/lib/` ⇒ **R5 产物逐文件对账 29/29 hash 全同**;relay 运行时 `/opt/dsh-relay/lib/net/relay/*` 同步 | +| **15:2x 复核(实测)** | 🔴 **S3 原机制证伪**:47 `sshd gatewayports no` ⇒ `-R 127.0.0.2:…` 被**静默改写为 `127.0.0.1`**(ssh 侧零报错)⇒ 回环别名方案作废,改「**实例端口区间隔离**」|🔴 **发现真 bug**:`findFreePort()` 各 worker 各自随机 ⇒ 同号在 47 的 `127.0.0.1` 撞号、而 `tunnel.forward()` 返回值被忽略 ⇒ **静默打到别人实例**|⚠️ `verify-cluster-cross.mjs` 默认端口 13080 ≠ 现役 3080,且**有生产副作用**(改容量/建用户/拉实例)⇒ 跑完要清 | +| 代码 / 服务器 | 🆕 **序⑩ 改动(2026-09-17)**:仅 `scripts/overlay-failover-drill.cjs`(幕 2 状态无关化 + 删掉 `4c`/`ctrl` 两个 `COOLDOWN_MS=0` 场景),⛔ **未碰 `src/`**、未改任何生产阈值、relay/Manager 均已复原并复验(`systemctl restart dshs` 归零回 47)|⚠️ **本轮有改动**:代码仓 `src/net/relay/*`(R5:`wire/duplex(新)/server/client/dialer(新)/main/index`)+ `src/config.ts` + `src/web/server.ts` + `test/relay.test.mjs`;47 上 relay/manager 两个 drop-in + `relay-keys.json`(均有 `.bak-r5*` 备份) | +| 文档库 | ✅ **10 份已转正式档案 103–112**(`4e3a1a4`)+ 已推镜像,**双端对账 192/192 全绿**(2026-09-16 11:5x) | +| 当前基线 | 代码仓 HEAD = `640813e` —— ✅ **已 push**(2026-09-16)|⚠️ 本机工作区**有未提交改动**(S2 会合中继拆分 + R3/R4/R5 产物 + 新增 `test/remote-spawner.test.mjs`)⇒ ⛔ **未获授权不 commit / 不 push** | +| 根副本 vs 档案 | 本线 10 份根文档的正文**已入档案 103–112**(行覆盖 **98.5–99.0%**,差异仅一级标题行)⇒ **根副本保留为工作副本,⛔ 不搬**(被 11 处引用,搬走会断链) | +| **自动接续** | ❌ **已于 11:00 删除,且⛔ 不要再建** —— 两版都试过(7 积分/轮 → 优化版 **9 积分/轮**),**证明"定时任务省积分"是伪命题**。原因见下方「成本真相」。 | + +> 💰 **成本真相(终版 · 2026-09-16 11:2x,**实测那个会话本身**)** +> ⛔ 前两版都错:v1 把固定注入当"一次性入场费"(错);v2 我**按最新 mtime 猜会话**,分析到了另一个"会话复盘"会话(`e2e090be`,错)。 +> +> **定位方法(可复跑,别再用 mtime 猜)**:拿 prompt 里的独有字串去搜转录 —— +> `grep "能脚本化的全部脚本化" /*.jsonl` ⇒ 唯一命中 **`e265f0cd-bd24-48c7-953e-4bbfa615bc7d.jsonl`**(761 KB,10:50:09 结束)。 +> +> | 指标 | 实测 | +> |---|---| +> | **工具调用次数** | **32**(Bash 16 / Edit 6 / Write 5 / Read 4 / present_files 1) | +> | 模型请求(记录数) | 54(转录对每次请求记**两条**:一条无 `cache_read`、一条有 ⇒ **实际约 27**) | +> | **Σ input** | 5,205,114(同口径折算 ≈ 2.6 M) | +> | Σ cache_read | 2.55 M(≈49%) | +> | 单次 input:首 → 峰/末 | 52,582 → **141,401** | +> | **固定注入 5 万 × 请求数** | **占 51.9%**(对折不改变比例) | +> | **本次新灌入的内容** | 工具输出合计 **84,870 字符 ≈ 2.4 万 token ⇒ 仅占 0.5%** | +> +> **它实际干了什么**:32 次调用集中在 `D:/github/dsh_shenxian` 的 **git / 文件读写**,并读过一次 `接续入口`(4,443 字符)。**并没有去读源码做取证。** +> +> **三条结论** +> 1. **成本 ≈ 请求次数 × (固定注入 + 累积历史)**。本次 27–54 次请求里,**固定注入一项就占 51.9%** —— 即**花掉的一半积分,只是"把指令与常驻规则重复发了二三十遍"**。 +> 2. **本次真正"读进来"的东西极少(≈2.4 万 token,占 0.5%)** ⇒ 这笔钱**不是"上下文太大"造成的,是"请求太多次"造成的**。 +> 3. ⇒ **唯一的大杠杆 = 压请求次数(= 压工具调用次数)**:32 次调用若能压到 1–3 次,Σinput 掉一个数量级(9 积分 → ~1 积分量级)。**这才是真能省下来的钱。** +> +> **⛔ 由此两条硬规矩** +> ① **批量活不要"要求 agent 写脚本",而要"只准跑这个脚本"** —— 脚本**由上一会话预先写好**,任务里命令式指定;agent 没有探索余地,请求次数自然压到个位数。 +> ② **绝不把"读代码 / 大范围取证"派给无人值守会话**(要么脚本先 grep 计数,要么有人盯着)。 + +> ⚠️ **成本教训(2026-09-16 实测,勿重蹈)** +> 1. **"新会话"不等于"不花钱"**:本工作区**每轮固定开销实测 ≈ 51,830 token**(系统提示 + 工具定义 + 常驻规则 + 注入的记忆)⇒ 任何新会话第一轮都要付这笔钱。准确的收益口径是「**把每轮水位从 39 万降到 ~5 万(约 1/8)**」,**不是"零成本"**(我此前把它说成"降低 token 消耗",属**不准确承诺**)。 +> 2. **最大的可压缩项 = 每轮注入的记忆文件超限**:✅ **2026-09-16 修复轮已办** —— 实测真被截断的是**用户级** `MEMORY.md`(注入上限 **≈4,000 字符**),已按**分层重排、零删除**修好;🔑 **重排是杠杆,删减不是**(方法已沉淀为 `dsh-knowledge-upkeep §8.6「注入预算」`)。 +> 3. ⛔ **不要把"10 份文档档案化"交给 agent 逐份做** —— 那是 20+ 轮 × 7 积分 ≈ **140+ 积分**,违反项目省积分第一招「**批量活写脚本**」。**正确做法 = 写一个转换脚本一次跑完**(加 8 段头部 + 原子占号 + 登记 + audit 全在脚本里),agent 只读摘要 ⇒ **1–2 轮完成**。 + +--- + +## 1. 十份文档(按阅读顺序;前 3 份是决策层,其余是专项) + +| # | 文件 | 一句话 | +|---|---|---| +| 1 | `可行性评估_客户端安装与覆盖网络互联_20260916.md` | 判定可做;列出**现有架构已给出的 80% 形状**(soft 隔离 = 裸子进程 / Worker 四条协议纪律 / 拨出式反向隧道 / 全仓仅一处平台分支);§六 = 与另两份客户端文档的对账 | +| 2 | `覆盖网络_全球架构复盘_20260916.md` | 五层架构 + 12 条内建特性 + 选型对照 + **10 类风暴类型学** + 流量组织五原则 + 7 步落地顺序 | +| 3 | `覆盖网络_瓶颈落地方案_20260916.md` | **九大瓶颈逐个给可执行做法**(含 Slack / SCCM 官方做法照抄点 + 验收判据);**"只做三件事"= presence + 游戏服放 L1 + 块级内容寻址** | +| 4 | `覆盖网络_骨干层方案_20260916.md` | 多中心骨干(≤10–20 成员)+ 选择性加入;三条硬约束(接入/成员/可见三分离、资格只能控制面签发、骨干不得被默认征用) | +| 5 | `覆盖网络_百台规模推演_20260916.md`(v2) | 100 台异构画像;L1 10 / L2 30 / **L3 需中继 40–55**;全互联 4,950 vs 星型 100 | +| 6 | `覆盖网络_千台全场景推演_20260916.md` | 1000 台 × 11 场景;**瓶颈排序**(presence 第一)+ 流量预算总表 | +| 7 | `覆盖网络_游戏专项_MMORPG与MUD_20260916.md` | MMORPG/MUD/传奇 = **最匹配**;核心简化:**玩家之间不需要互联,中继按"服数"算** | +| 8 | `覆盖网络_调研_游戏网络特征与群聊上限_20260916.md` | 游戏网络参数(每玩家 2–20 KB/s、jitter<20 ms)+ 群聊上限**按扇出预算定义**的分档 | +| 9 | `覆盖网络_答疑_群聊备份迁移保密_20260916.md` | 四问:群聊+agent / 备份(主备份用对象存储)/ 迁移提速 / 三层加密与元数据保护 | +| 10 | `覆盖网络_补遗与参考方案_20260916.md` | 补遗 24 条 + 参考方案对照 + **反模式 12 条** | +| 11 | `参数表_覆盖网络_20260917.md` | **序⑤ 产物 · 唯一一张参数表**(155 行):输入参数 + 代码固化常量 + 45% 口径填值(`--max-hosts = 225`)+ 观测阈值 `OBS-01..12` + 权限影响评估 + 待测汇总。**观测探针** = 代码仓 `scripts/overlay-probe.cjs`(一条命令 / 12 行 / 退出码 0-1-2;cwd = 本工作区根即可跑) | + +**另有两份他人产出(已按最佳方案收口,勿重复改)**:`dsh客户端化部署方案_20260916.md`(v3,平台侧单一来源)|`dsh桌面客户端_开发方案_20260916.md`(v1,交付载体层)。 + +> 🧭 **方法论(2026-09-16 16:4x 新增)**:`方案规划方法_覆盖网络线提炼_20260916.md` —— 把本线「**信息收集 → 方案调研 → 场景梳理 → 逻辑验证 → 应用推演**」五段作业法反推成可复用方法(含每段骨架/出口判据、**出口闸门三件事**、8 次真实纠错自证、可抄模板)。**下次做新方案规划时先读它。** + +--- + +## 2. 下一步 + +> ✅ **上一轮已完成(2026-09-17 08:32,执行棒)**:**序 ③(一机一钥 + 信任根)已收官** —— 四层密钥模型(离线根 → 47 在线签名者 → 每机节点密钥 → 会话)落地;relay `keys` 表改**逻辑名 `/`** 索引 + **成员资格校验**;新增密钥仪式 CLI `scripts/overlay-keyring.cjs` 与 `test/overlay-identity.test.mjs`(25 例);**吊销演练 + 节点密钥轮换 + 根密钥恢复演练(判据①②③全绿,794 ms)全部通过**;**relay 已开强制身份**(`identityRequired=true` / `identityOk=3`);`npm test` 130/129/0;对账 148/148 + 26/26 全同。**全部证据 = `交接单_一机一钥与信任根_20260917.md` §8**(回填后指纹 `md5 204c0f69630e5117c227828f5fc01f2d`,427 行)。⚠️ 一条**与序 ③ 无关**的未过项:guest(w-106) 实例页 **502**(真因 = 平台 `landModels → writeHomeFile` 用**本地路径**写远端实例 home;另发现 `/opt/dshs/mksess*.cjs` 仍写 SQLite 旧库、**已失效**)⇒ 两条并入序 ④ 的相邻缺陷清单。⛔ 不要重做序 ③。 +> +> ✅ **上一轮已完成(2026-09-17 08:4x,规划棒)**:**序 ④ 的 443/TCP 兜底交接单已出** —— `交接单_443兜底_20260917.md`(工作区根,225 行,**md5 `b9aa6bbc0a458481627f7aeb1f17ab54`**)。要点:端口仍 443(零新增公网口)+ 47 上**独立 server 块 / 独立 server_name**(去 CF、去门户 conf 两个失败域)+ 入口列表走 **`DSHS_OVERLAY_BOOTSTRAP_SEEDS`**(⛔ 不用会**压制引导链**的 `DSHS_RELAY_URL`)+ 地址覆盖走**独立配置项**(⛔ 不塞 URL / 不塞签名目录)+ 签名目录 `relays[]`/`bootstrap[]` 自动广播(改一次 seeds 全网刷新)+ 降级必须可 grep。**L3(跨机真容灾)已明确后置**(命中 R5,属序 ⑤ 权限评估)。⛔ 不要重做规划。 +> +> ✅ **上一轮已完成(2026-09-17 09:1x,执行棒)**:**序 ④(443/TCP 兜底)已收官** —— 47 上新增**独立 443 server 块** `relay-direct.conf`(`server_name relay-direct.alotbuy.com`,**只**放行 `/dshs-relay` 与 `/dshs-overlay/bootstrap`、其余一律 404)+ **seeds 广播**(`relays[]`/`bootstrap[]` 均含兜底项、**主入口首位**)+ 客户端**地址覆盖**(定向 DNS 改写 ⇒ 直连指定 IP、**SNI 仍为域名**、证书照验)+ **「同源优先」**(补掉"兜底项在取址路径上**永远选不中**"这个真缺陷 —— 它使 §1 目标与 D6 同时落空)。**D1–D8 逐条验收**(D2 **字面**判据因 `*.alotbuy.com` 是 **CF 泛解析**而不可满足 ⇒ 按**实质**判据「不经 CF」的两条独立证据判绿;**§4.3 的 A/B 两个候选都不需要 ⇒ 本单零依赖关闭**)。**全部证据 = `交接单_443兜底_20260917.md` §8**(该单 **§8.9** 有可复现指纹口径)。 +> ⚠️ **序 ④ 带回的四条遗留**(下一位读者必须看):① D2 解析层 —— 运行期"去 CF"由 `DSHS_OVERLAY_ADDR_OVERRIDES` 保证,"解析层也不经 CF"需灰云记录(未做);② 🔴 **106 的 agent 面不吃引导链** —— `DSHS_RENDEZVOUS_URL` 被 `worker/agent.ts` **直接当 relay URL 用**,⚠️ **S2-附 的字面执行会造成生产回归(`tunnel===undefined`)⇒ 已判定不执行**;③ 引导链缓存两支没有"答出者"信息 ⇒ CF 打挂后切换有 **≤300 s 收敛期**;④ **45% 容量口径未填数值**(留给序 ⑤ 参数表)。 +> ✅ **上一轮已完成(2026-09-17 09:1x,规划棒)**:**序 ⑤ 的交接单已出** —— `交接单_参数表与观测_20260917.md`(工作区根,**250 行**,md5 `40f08167990eac54ec8a026b9c57f807`)。要点:**三件一体**(参数表 / 观测最小集 / 权限评估,= 清单 §五 第 5 项原文);**唯一代码改动 = `src/net/relay/server.ts` 补 `dial/dialDenied/dialFailed` 判别器计数**(今天 `DIAL` **只有日志行**、脚本无法断言 —— 判别器教训的固化);45% 口径**按"单台中继 `--max-hosts`"填值**(⛔ 不填"全网 45%",实例参数填全网数字不可执行),设值走 47 **新建 drop-in** `capacity.conf` + 硬约束 **`max > used×4`**(防自锁,不满足就不设值);观测落成**一条命令出 PASS/FAIL** 的 `scripts/overlay-probe.cjs`(阈值⛔不许是脚本魔数);**L3 跨机真容灾只评估、不实施,归序 ⑥**(序⑥本就要起 3–5 台 ⇒ 合批明显更省,现在做要多花一台机器/一个公网 IP);**§4.3 待拍板 = 空**。⛔ 不要重做规划。 +> +> ⛔ 不要重做序 ② / ③ / ④。 +> +> ✅ **上一轮已完成(2026-09-17 09:5x,规划棒)**:**序 ⑥ 的交接单已出** —— `交接单_最小形态真机批次_20260917.md`(工作区根,**321 行**;核对口径 `sed '/^## §10 指纹/,$d' 交接单_最小形态真机批次_20260917.md | md5sum` = `8bea0ac79697b80790a273b54f1db37c`)。要点:**S0–S9 + E1–E12 + 回滚 + §8 回报格式**;五项待测/待校准(打洞率 / 每玩家带宽 / 跨云稳态吞吐 / 中继 jitter / `MEM_PER_HOST_MB`)**逐项写了"怎么测 / 样本多大 / 判据 / 写回参数表哪一行"**;**合批落地 L3 第二中继机(106 升格)**;**回头条件写死**(§5 S7 七件必做)。🔴 **本单新增一条关键事实**:`src/` 全仓**零 UDP / NAT 穿透代码**(`grep -iE "dgram|createSocket|stun|punch|udp" src/` 命中**全是 `signature`/`native` 假阳性**)⇒ ⇒ **打洞项的口径定为"测该网络能不能打洞",⛔ 本单不做打洞实现**(`§4.1-1`);另一条:`RELAY_RTT_W106 = 336 ms` 是**心跳往返**、**不一定是网络 RTT** ⇒ 本单要求做 **ICMP / TCP / relay 三方对比**(`S3(b)`;若它其实是口径问题,所有"跨云不可玩"的结论都要重判)。**§4.3 唯一待你拍板项 = 第 4/5 台真机的来源**(A 先用现有 3 台 / B 你自备设备跑一次性探测 / C 新开云主机;**倾向 A+B,⛔ 执行棒不必等**)。⛔ 不要重做规划。 +> +> ✅ **上一轮已完成(2026-09-17 11:0x,执行棒)**:**序 ⑥(3–5 台最小形态)执行部分已收官** —— 五项待测/待校准**全部换成实测**(`HOLE_PUNCH_RATE_LOCAL` 分层实测 2/2 可打洞、`PER_PLAYER_BW_LOCAL` 9.8/3.9 KB/s、`JITTER_LINK_MEASURED` `p95(|ΔRTT|)`=**3 ms 达标**、`WAN_STEADY_THROUGHPUT` 352/12213 KB/s、`MEM_PER_HOST_MB` **2 → 0.06**,R²=0.9424)+ **参数表 §7 待测 4 → 0**;🔴 **S7 回头条件已触发并执行**:`RELAY_MAX_HOSTS` **225 → 7515** 并**重下发** 47/106 两台的 `capacity.conf`(drop-in 重写 ExecStart,绕开"CLI 优先于 env"静默忽略坑);**S8 L3 第二中继机已落地**(106 `dshs-relay` 只绑 `127.0.0.1:20080`,复用既有 443 ⇒ **零新增公网口**,WS 握手 **101**,seeds 末位广播);**收口 12/12 PASS**、监听口 **79 = 基线**、`npm test` 137 pass。**全部证据 = `交接单_最小形态真机批次_20260917.md` §8**(1–8.9 全节;§8.9 可复现指纹:参数表 `db1317c2f7aaef7b47785c1f4fc9de03`、本单前缀 `1edde731eba5034c5f6f3a43864e5a8e`)。⚠️ **唯一未过项 = E9 后半「无失败切流」**(客户端把 relay url 首次解析后**钉死**;worker 不吃引导链;Manager 只在"目录地址变了"时换址)⇒ **见 §8.8-1,已判为"新功能",本轮只报告不动手**。 +> +> ✅ **上一轮已完成(2026-09-17 11:1x,规划棒)**:**序 ⑦ 的交接单已出** —— `交接单_中继失败切流_20260917.md`(工作区根,**280 行**;**§8 前缀指纹 `419abf308c00b7668e8898aaa91ba9e8`**,口径 = `sed '/^## §8 回报格式/,$d' … | md5sum`)。要点:**一份切换实现(新增 `src/net/relay/switcher.ts`)+ 三个装配点复用**(C1 Manager 拨号通道 `web/server.ts#refreshOverlay` / C2 worker 实例面 / C3 `relay --client`);🔴 **规划棒新查出的证据级细化**:根因不止"换址条件是目录地址变了",而是 `directory.ts:348 pickFromDoc` **取到第一个候选就 return** ⇒ **候选集退化成单点**(重解析恒返回同一台;S1 就改这里);**阈值已定** = `attempts ≥ 3` **∨** `backoff ≥ 15 s`(D3)+ **排除 + 冷却 300 s**(D5)+ **无候选时不切、绝不静默回退默认机**(D6);**S7 三幕真机演练**(杀 106 / 杀 47 / 两台全杀 —— 幕 3 就是 **R11** 的现场判据);**§4.5 已出 R5 评估:未命中,暴露面零变化(新增监听口 0 / 凭据 0 / 入站 0)**;**§4.3 待拍板 = 空**(第 4/5 台真机来源属序⑥ 遗留同题,⛔ 执行棒不必等)。⛔ 不要重做规划。 +> +> ✅ **拍板已闭环(2026-09-17 11:00,用户原话)**:「**1 本机内存大 可以模拟多台**」⇒ **第 4/5 台 = 本机多实例**(本机 47.6 GB / 空闲 27.4 GB / 32 核;relay 单实例 ≈ 48 MB),**放弃**"用户自备设备"与"新开云主机"。⚠️ 局限:多实例共用同一出口 IP ⇒ 对**切流逻辑**够用、对**家宽 / 运营商 NAT 差异**无增量。**已落三处**:`参数表_覆盖网络_20260917.md` §11 / `交接单_最小形态真机批次_20260917.md` §11 / 本单 `交接单_中继失败切流_20260917.md` §11(三处补记**均在各自指纹口径之外** ⇒ 三个指纹均不变)。⛔ **后续任何一棒都不许再上抛这道题**。 +> +> ✅ **上一轮已完成(2026-09-17 11:2x–12:2x,执行棒)**:**序 ⑦(中继失败切流)已收官** —— S1–S5 全落(`directory.ts` 拆出 `listCandidatesFromDoc`;**新增 `src/net/relay/switcher.ts` = 唯一实现**,三装配点 C1 `web/server.ts` / C2 `worker/relay-tunnel.ts`+`worker/agent.ts` / C3 `net/relay/main.ts` 复用;`test/relay-failover.test.mjs` F1–F11;`scripts/overlay-failover-drill.cjs` 三幕真机演练);参数表新增 `RELAY_FAILOVER_*` 6 键 + 7 个演练坐标,**§9 第 5 行已改「已闭环」**(指纹 → `24cf2efdbcdcbe61267126ed65dba006`)。**全部证据 = `交接单_中继失败切流_20260917.md` §8**(§8 前缀指纹 `419abf308c00b7668e8898aaa91ba9e8`,收口后**未变**)。真机:`--scene all` = **8 PASS / 0 SKIP / 0 FAIL**;杀 47 ⇒ 切到 106,**三个时延样本 30563 / 27878 / 29176 ms(全部贴 30 s deadline)**;两台全挂 ⇒ **0 行切换 + D6 原生判别器**;`npm test` **149/148/0/1**(+F1–F11)。⚠️ 遗留四条见该单 **§8.8**(**§8.8-4 = 下一棒主题**)。⛔ 不要重做序 ⑦。 +> +> ✅ **上一轮已完成(2026-09-17 12:3x,规划棒)**:**序 ⑧ 的交接单已出** —— `交接单_切流冷却语义_20260917.md`(工作区根,**345 行**;**§8 前缀指纹 `aa3a6ec0d66dd81f465cea2a3a08ad27`**,口径 = `sed '/^## §8 回报格式/,$d' … | md5sum`)。**三问已给判定**:① **应当拆开,但拆的是「准入方向」而不是「冷却时长」** —— health 路径允许**一跳豁免**、directory 路径 **⛔ 完全不豁免**(依据 = 11:43:26 真机实测:directory 靠"地址变了"把刚冷却的 47 换回来 ⇒ 给它豁免权 = 复活抖动风暴)② **键仍按 `url`**,新增 `kind ∈ {switched-away, open-failed}` **只决定豁免优先级**(把键换成"原因"会让同一 url 存在多条冷却 ⇒ 抖动抑制失效 = **净退化 R11**;且 `reason` 是自由文本 ⇒ 字符串耦合)③ 🔴 **E5 改判为「幕 4」** —— 本单**已证明**(命题 P-③)在 D5 生产值下"杀 106 时 47 未被冷却"这个窗口**不会被自然产生**(当前在 106 ⟹ 47 必曾进冷却 ⟹ 若未满 300 s 则仍在冷却)⇒ 上单 §8.8-3 的回头条件**指向一条走不通的路**;正解 = `停 47 → 切 106 → 恢复 47 → 停 106(预期 D6,记 SKIP)→ 等冷却过期 → 断言 ≤ deadline 切回 47`(副产品 = §8.8-4 的正面复现)。**另加一条判据级事实**:**构 C(`--url` / `DSHS_RELAY_URL` 钉 106)已判不可用** —— `directory.ts:642-651` 明证 env 显式会让**候选链退化成单点**,杀 106 后仍无候选。**S0–S9 + E1–E13 + 回滚 + §8 回报格式均已就位**;**§4.3 待拍板 = 空**;**§4.5 R5 评估 = 未命中,暴露面零变化**。⛔ 不要重做规划。 +> +> ✅ **上一轮已完成(2026-09-17 12:40–13:2x,执行棒)**:**序 ⑧(切流冷却语义)已收官** —— S1–S5 全落:`src/net/relay/switcher.ts` 冷却表结构化(键仍按 `url` + 新增 `kind ∈ {switched-away, open-failed}`)+ `replace(targetUrl, reason, origin)` 闸门**只对 directory 收口**(health 可**一跳豁免**)+ `tick()` 的 **D6 现场**加豁免(新增单点判据 `pickExemptTarget`)+ 豁免有界(每 url 每冷却周期一次,`exemptedAtMs`;豁免也失败 ⇒ 重置冷却 + 本周期不再豁免);`src/web/server.ts` **仅一处**(传 `'directory'`);`scripts/overlay-failover-drill.cjs` 新增 `--scene 4|4b|4c|ctrl`;`test/relay-failover.test.mjs` +**F12–F17**。**全部证据 = `交接单_切流冷却语义_20260917.md` §8**(1–8.9 全节;**§8 前缀指纹 `aa3a6ec0d66dd81f465cea2a3a08ad27`,回填后未变**)。**真机核心成果**:**幕 4(构 A)= 4 PASS / 0 FAIL** —— 豁免在 **19218 ms** 内切回(原文 `|豁免 kind=switched-away 剩 269991ms` = "47 当时确在冷却"的**直接证据**,原因 = 当前通道不健康/health 路径)⇒ **对序⑦ §8.8-4 缺陷正面闭环**(序⑦ 下同场景最长 ~300 s 不切流);**幕 4b = 4 PASS** —— D6 判别器原文(`候选 3 条,排除 3 条`)+ 冷却未过期 **0 行**切换 + 冷却过期后**自然**切回(25125 ms,**无豁免标记**)⇒ 归因收敛到冷却语义;幕 1/2/3 未退化(幕1-A 29586 ms PASS);`npm test` **155/154/0/1**(+F12–F17);部署 47/106 四处 lib,`grep -c RELAY_FAILOVER_EXEMPT /net/relay/switcher.js` = **2** 自证。⚠️ **遗留六条见该单 §8.8**(🔴 **§8.8-2 = 下一棒主题**:S0 基线样本 **32550 ms > deadline 30000**,回头条件已命中)。⛔ 不要重做序 ⑧。 +> +> 🔴 **本单一条判据级新发现(§8.8-4,留档)**:**`RELAY_FAILOVER_COOLDOWN_MS=0` 是"看起来合法、实际会自锁"的配置** —— `num()` 的 `/^\d+$/` 放行 `'0'`,但归零会让**序⑦ F10 的"失败候选必须被排除"**一起失效 ⇒ 候选链卡在第一个失败候选上(实测 **121–123 s 无切换**)。构 B(原定归因构造)因此判**不可用**,已由 **幕 4b** 替代并拿到更强证据。⛔ **任何一棒都不许把 `COOLDOWN_MS=0` 写进回滚路径**。 +> +> ✅ **上一轮已完成(2026-09-17 13:40–13:5x,执行棒)**:**序 ⑨(检测时延与 deadline)已收官** —— **P10 分解逐项复现成功**(两样本结构完全一致:检测 **15.085 / 15.119 s** + 首试 0.258 / 1.416 s + 白等 **12.033 / 12.029 s** + 建连 2.821 / 2.724 s)⇒ D1 解锁;**S2 修前 N=5 = 5/5 全超 deadline**(33914 / 31646 / 34078 / 35321 / 41252 ms,中位 34078);**S4 落点 = 抽公共 `waitUpOnStatus`(`src/net/relay/client.ts`)+ 三处装配点全部委托**(C1 `web/server.ts` / C2 `worker/relay-tunnel.ts` / C3 `net/relay/main.ts`,D7 收口),落点自证 47/106 四处 `lib` 各 3 命中;**S5 先红后绿**(改完先对旧 lib 跑 ⇒ `SyntaxError: … does not provide an export named 'openedChannelFailedTerminally'`;build 后 22/22 全绿);**S7 修后 N=5 = 5/5 进 deadline 内侧**(25465 / 25342 / 21985 / 27389 / 23318 ms,中位 25342);**白等 12.03 s → 0.09 s**、总 19.81–23.93 s,早退日志原文 `[relay-failover] ⛔ 新通道终态失败(state=backoff attempts=1 burst=false lastError="transport error")⇒ 提前放弃,不等满 12000ms`;**S8 不退化**:`npm test` **160/159/0/1**(基线 155 +新增 F18–F22)、`--scene all` 11 PASS / 1 FAIL(幕 2-B 已归因)、幕 4-A/B/C 全绿、`overlay-probe` 10/12 与 S0 逐项一致、`ss -lntp|wc -l` = 77、`nft` = 72、门户 200、演练 env 残留 0;**收口归零**(`restart dshs` 把 Manager 归位回 47,probe 回到 10/12)。**全部证据 = `交接单_检测时延与deadline_20260917.md` §8**(1–8.9 全节;**§8 前缀指纹 `d903b4eeabf25ef381379cdbaac77e8a`,回填后未变**;全文件 md5 `ccc28dafd10004d11740834004b02154`,529 行)。参数表指纹 `e6b669c257d8e8964273b3b400238351` → **`99e9e17b0c1ce0550e4bc7626a5a0494`**(新增 `DRILL_SAMPLE_N`=5 / `RELAY_GRACEFUL_BURST_MS`=15000,`DRILL_POLL_MS` 2000→500,§9 新增第 9 行「换址墙钟的四段分解」)。⚠️ **遗留三条见该单 §8.8**(= 下一棒主题)。⛔ 不要重做序 ⑨。 +> +> ✅ **序 ⑩ 已收官(2026-09-17 14:23–14:5x)** |🆕 **下一棒已登记自动接续** = automation `3d4dffc0-356e-4bc6-9628-b7d664da7db9`「覆盖网络线-序11规划棒(观测口径重构+在册小缺陷清算)」(**一次性** · `scheduledAt` = **2026-09-17 14:50** = 收口 +5 min · `nextRunAt` = 1789627800000)⇒ 三件技术债全清 —— 幕 2 状态依赖**已修**(幕 2 起手现场重读权威通道归属后**两台都停** ⇒ 与上一幕解耦;`--scene all` **12 PASS / 0 FAIL**)|`COOLDOWN_MS=0` **0 命中**(`4c`/`ctrl` 整体移除并**显式拒绝**,⛔ 不静默空跑)|OBS-11 差额**有名字 = `20000`**(47 实例档、无活跃实例)。证据 = `交接单_检测时延与deadline_20260917.md` **§8.8-4**。 +> +> ✅ **序 ⑪ 已收官(2026-09-17 15:0x,规划棒)** —— 出单 = `交接单_观测口径与在册缺陷_20260917.md`(**§8 前缀指纹 `3ece0f870cba67d0113a4c5f9de9d812`**)⇒ **OBS-11 判据 计数 → 白名单集合**(三集包含式 + `nft` 入站 accept 集合)+ 6 条在册小缺陷排序(§5.9);只改观测面,⛔ `src/**` 零改动。⛔ 不要重做规划。 +> +> ⏹️ **(存档)序 ⑭ 本轮动作口径(已收官 · 结果见 `交接单_观测口径与在册缺陷_20260917.md` **§10**)**:**序 ⑭ · 执行棒 —— 修掉「relay 流上的 HTTP keep-alive 复用」这条数据面缺陷**(口径 = `交接单_观测口径与在册缺陷_20260917.md` **§9.3**:现象 + **1 条命令的最小复现** + 完整取证链 + 回头条件;⚠️ 不必再出规划单)。**背景(上一棒结论)**:Q1 ✅(`mksess` 改 PG)、Q3 ✅(探针 **12/12**)、**Q2 🔴 未达成** —— 单内记的 `landModels → writeHomeFile` 本地路径问题**不是** `enter` 500 的真因;真因是**同一条连接上的第 2 条 agent 请求必回 `400`(Fastify `clientError`)**,抓包实证 = **worker 侧把 agent 上一份响应回灌给了 agent**。**开工顺序**:① 先按 §9.3 复现;② **补帧级取证**(relay `workerStream`/`dialStream` 路由表、`net/relay/server.ts` 的 stream 表、`dialer.ts` 的 `tcp.pipe(duplex).pipe(tcp)` 收尾、`client.ts#onOpenRequest/onRemoteData`)**定位到代码行再改**;③ 先红后绿;④ 端到端:guest `enter` **200**、同连接连发两次都 **200**、`npm test` 不退化(160/159/0/1)、`--scene all` 不退化、探针保持 **12/12**。**三条硬门**:**D1**(复现类前提未复现前 ⛔ 不许改任何生产值;⛔ 不动 `RELAY_FAILOVER_*`/`HB_SEC`/burst)|**R7**(Q4 `src/net/relay/**` untracked 缺口卡在**提交授权** ⇒ ⛔ 只报告、⛔ 不 commit/push;Q5 `.bak-seq7-*` 残留未动)|🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**。⛔ **若改动必须超出 `src/net/relay/**` + 其测试 ⇒ 停下报告、别扩大**。⛔ 不 commit / 不 push。**收口必须**:释放锁 + 陈述句告知 + 登记下一棒 + 推进本入口 + 写工作区日志。 +> +> ✅ **序 ⑮ 已收官(2026-09-17 16:14–16:3x,执行棒)**:**缺陷 B(拨号池未分配槽位自毁)已修并端到端验收** —— 判据 a/b/c 全绿;**先故意对未分配池口 25000 发一条连接**,随后 guest `enter` 仍 **200/200**、该口同连接连发 3 次 **200/200/200**、打后池口仍 **64**;`npm test` **162/161/0/1**、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**、`overlay-probe` **12/12**。改法 = `onConn` ⛔ 不再 `close()` 服务器(只丢连接 + `stray` 计数 + 点名日志)+ `localPortFor`/`lruIdle` 只认 `server.listening` + `status().pool` 改报"实际在听"并新增 `stray`;⛔ 不取"把槽位从 `slots` 摘掉"那条(= 让任意本地连接**永久**蚕食池容量,R11)。**全部证据 = `交接单_观测口径与在册缺陷_20260917.md` §11**(含 47 上 16:00:31/35/40 三条 500 的时间线归因)。⚠️ 顺带确认一条**环境口径**:对 47/106 的 ssh **必须显式 `-p 22`**(别名 `bt-server` 里的 `32022` 是失效残留口,见该单 §11.10)。 +> +> ⏹️ **(存档)序 ⑰ 本轮动作口径(已收官 · 结果见 `交接单_在册收尾_20260917.md` §9)**:**序 ⑰ · 执行棒 —— 在册收尾(S1 → S2 → S3 → S4)**(⚠️ **执行棒:按单开工,无须再出规划单**)。**开工依据 = 工作区根 `交接单_在册收尾_20260917.md`**(**§8 前缀指纹 `bab83b7219b2669d5a6e9f1acf782e1f`**,全文 md5 `ca13b9ded3d71268f8b0e56755d7aea4`)⇒ 按该单 §5 顺序:**S1** mksess 文档口径校正(「DB 直插」→「PG 直插」;只改操作性载体 = `02-运维手册.md` + `skills/**` + 本机技能,⛔ **不改 `04-调整方案/**` 档案正文**)|**S2** 技能 `dsh-auto-handoff-chain` **三处同步建立**(本机 ← 文档库新建副本 ← 47 镜像 `/opt/dsh/docs/skills/`;**与 S1 共用一次 scp** ⇒ 故 S1 必先)|**S3** 清理代码仓 `.bak-seq7-*`(🔴 **范围修正 = 5 个**,全 untracked、均为过期快照 ⇒ 非有效回滚点)|**S4** 幕 4-A 余量判据化(三档:`p95 ≤ 24 s` 健康 / `24–27 s` 临界记录不判 FAIL / `> 27 s` 停下报告)+ `--scene 4` × `DRILL_SAMPLE_N`=5 样本。**验收** = 单内 §6 的 E1–E9(其中 E6 三件套 = `npm test` 162/161/0/1、`--scene all` 12 PASS、探针 12/12)。**两条硬门**:**D1**(不改任何生产值)|**R7**(Q4 ⛔ 只报告、⛔ 不 commit/push)。🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**。⛔ 不改 `04-调整方案/**` 档案正文、⛔ 不重做已收官序 ①–⑮、⛔ 不做 presence / 房间层 / 内容分发。**收口必须**:释放锁 + 陈述句告知 + 登记下一棒 + 推进本入口 + 写工作区日志 + 回填该单 §8。 +> +> ⏹️ **(存档)序 ⑯ 本轮动作口径(已收官 · 结果见 `交接单_观测口径与在册缺陷_20260917.md` **§12**)**:**序 ⑯ · 规划棒 —— 复核「会合 / 中继从 Manager 拆分」是否仍需要 + 给剩余在册项定序、出执行单**(⚠️ **只出规划、⛔ 不改码、⛔ 不动服务器**)。依据 = 工作区根 `会合中继拆分_取证与改造方案_20260916.md`(S0–S4)+ 事实「**R5 已解掉『Manager 必须与 relay 同机』**(Manager 改为只拨出一条 wss、落点搬到本机回环池 `127.0.0.1:25000+`)」。**只读取证 ≤3 条命令**,结论落成二选一:① 已被 R5 覆盖 ⇒ 判「不做」+ 写清证据;② 仍有未覆盖部分 ⇒ 写成含**逐步验收 / 回滚**的 S 步骤。**同一张单**里给剩余在册项定序:`Q5`(`src/net/relay/*.bak-*` 3 个残留)|`seq13 文档口径`(文档库 / 技能对 `mksess.cjs` 的「DB 直插」描述已过期)|`长期在册`(技能 `dsh-auto-handoff-chain` 在文档库无副本 ⇒ 三处同步从未建立)|`§8.8-2`(幕 4-A 19.5–20.8 s 的临界项)。⛔ **Q4(`src/net/relay/**` untracked 的提交 / 推送授权)不写进执行范围**(待你拍板,只登记一行);⛔ 不得自行为 presence / 房间层 / 内容分发定序(需拍板)。**收口必须**:释锁 + 陈述句告知 + 登记下一棒 + 推进入口 + 写工作区日志 + 追加回报。 +> +> ⏹️ **(存档)序 ⑮ 本轮动作口径(已收官 · 结果见 `交接单_观测口径与在册缺陷_20260917.md` §11)**:**序 ⑮ · 执行棒 —— 修掉「拨号池未分配槽位被任意一条连接永久杀死」(缺陷 B)**(口径 = `交接单_观测口径与在册缺陷_20260917.md` **§10.8**:现象 + 出处代码行 `src/net/relay/dialer.ts#onConn` + 三个候选修法 + 判据;⚠️ 不必再出规划单)。**背景(上一棒结论)**:序 ⑭ 已修好 relay keep-alive 复用缺陷(guest `enter` 200、探针 12/12),但**从旁路发现了缺陷 B** —— `onConn` 撞到 `key === undefined`(未分配槽位)时只 `slot.server.close()` + `tcp.destroy()`,**不把槽位从 `slots` 里摘掉** ⇒ 该槽位永久死亡,而 `localPortFor()` 的 `slots.find(s => s.key === undefined)` **下次还会选中它** ⇒ `ECONNREFUSED`(实测:池报"64 个口"而 `ss` 只见 63,缺口正是死掉的 `25000`)。**开工顺序**:① 先**不改代码**复现 + 帧级取证(谁/何时连了未分配槽位;`status().pool` 与实际在听口号数何时起不相等);② **定位到代码行**再改;③ 先红后绿(往 `test/relay.test.mjs` 追加用例,⛔ 不许改 `package.json`);④ 端到端:guest `enter` **200**、同连接连发两次 **200**、`npm test` 不退化(现基线 **160/0/1 skip**)、`--scene all` 不退化(现 **12 PASS**)、`overlay-probe` **12/12**(须带 `--table`)。🔴 **本棒专属陷阱**:⛔ **绝不要对「未分配」的池口发连接**(那正是缺陷 B 的触发器)—— 要打池口,**先用一次真实 `enter` 把落点分配出来**,再从日志 `[relay-dialer] 落点 127.0.0.1:<口> -> <逻辑名>:<端口>` 取**已分配**的那个口号。**三条硬门**:**D1**|**R7**(Q4 untracked 缺口卡在**提交授权** ⇒ ⛔ 只报告、⛔ 不 commit/push)|🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**。⛔ **若改动必须超出 `src/net/relay/**` + 其测试 ⇒ 停下报告、别扩大**。⛔ 不 commit / 不 push。**收口必须**:释放锁 + 陈述句告知 + 登记下一棒 + 推进本入口 + 写工作区日志 + 追加本单 §11。 + +> ✅ **序 ⑮ 已完成**(automation `1933b18a-0c2a-46a2-829c-440a900fed08` 已执行完毕、入口 §0/§2 已推进)。 + +> 🏁 **序 ⑰ 已收官(2026-09-17 16:44–17:3x,执行棒)—— 本线「在册收尾四条」全部清干**:**S1 ✅** mksess 文档口径校正(「DB 直插」→「PG 直插」,5 处操作性载体;E1 = 0 行)|**S2 ✅** 技能 `dsh-auto-handoff-chain` **三处同步首次建立**(本机 ↔ 文档库 ↔ 47 镜像,`diff -r` = 0 行、md5 三处同值;S1 的两个技能文件同批上镜像)|**S3 ✅** 代码仓 `.bak-seq7-*` 清理(**5 个 → 0**;untracked、非有效回滚点;`npm run build` RC=0 + lib 产物 md5 零差异)|**S4 ⚠️ 临界档**(`--scene 4` × 5 样本 18772/20121/20246/21223/**24477** ms;p95 保守口径 = **24477 ms** ⇒ 记录入册、⛔ 不判 FAIL、⛔ 未调任何生产值;⛔ 回头条件未触发)。**零回归三件套全绿**:`npm test` **162/161/0/1**、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**、`overlay-probe` **12/12**;收口归零 = 三服务 active + 池口 **64**。**全部证据 = `交接单_在册收尾_20260917.md` §9**(§8 前缀指纹 `bab83b72…` 回填后**未变**;参数表 `8f08e74b…` **未变** ⇒ D1 自证)。 +> 🗃 **Q4 已解除**(用户本轮明确授权「执行完毕后 同步到仓库」)⇒ 两个 commit 已推送:**`146c3d2`**(代码/测试面 53 文件)+ **`bc0dd2c`**(文档库面 10 文件);远端 `master` = **`bc0dd2c`**,`git ls-files src/net/relay | wc -l` **0 → 15**(untracked 缺口闭合)。 +> 🛑 **本线已无自动接续**:四条在册项全部清干后,**剩余项全部落在「需拍板」区**(presence / 房间层 / 内容分发**定序** = 业务优先级;骨干节点服务范围 = §4 的 A/B 既有待拍板项)⇒ 按登记门禁(`dsh-auto-handoff-chain §3.3`)**不登记下一棒**,等你拍板后再建。 + +> ⏹️ **(存档)序 ⑱ 本轮动作口径(已收官 · 产物 = 工作区根 `交接单_presence在线态_20260917.md`,§8 前缀指纹 `f612858344077420cff5f1f9ef1c942f`)**:**序 ⑱ · 规划棒 —— presence(在线状态)改造的方案规划**(⚠️ **只出规划**:⛔ 不改任何代码、⛔ 不动 47/106 配置、⛔ 不 commit / 不 push、⛔ 不改任何生产值)。**依据** = `覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 7 步** + `覆盖网络_千台全场景推演_20260916.md` + `覆盖网络_瓶颈落地方案_20260916.md`。**用户拍板** = 「**a 要做**」(2026-09-17 17:1x)⇒ presence 先做。**产出** = 按项目 8 段模板的**执行交接单**。⚠️ **B(内容分发 / 块级内容寻址)仍待拍板 ⇒ ⛔ 本棒不规划、只登记**。✅ **已收官(17:2x)** —— 落点判为**节点 / 端点在线态**(零应用层自证),S0–S7 + E1–E11 + §9 九条回头条件 + 三层回滚已就位;**§4.2 真取舍 = 空、§4.3 待拍板 = 空、§4.4 R5 = 未命中**。⛔ **不要重做规划**。 +> +> 🎯 **本轮动作(2026-09-17 17:28 起,唯一一件)**:**序 ⑲ · 执行棒 —— presence(节点在线态)改造落地**(⚠️ **按单开工,⛔ 无须再出规划单**)。**开工依据 = 工作区根 `交接单_presence在线态_20260917.md`**(**§8 前缀指纹 `f612858344077420cff5f1f9ef1c942f`**)⇒ 按该单 §5 顺序:**S0** 只读前置 + 基线采样(§2 的 P1–P5,**零改动**)|**S1** relay 侧 presence 权威表(连接生命周期驱动 + **TTL 安全网** + 同 hostId 设备聚合;**判别器必须落计数**,⛔ 不许只写日志)|**S2+S3** 线协议帧(`SUB/UNSUB/PRESENCE/SNAP`,帧号**末尾追加**)+ **1 s 窗口批合并**|**S4** 消费侧接线(C1 `web/server.ts` / C2 worker,**`/status` 降级为兜底、⛔ 不删**)|**S5** 观测(参数表 `PRESENCE_*` + `OBS-13/14/15` + 探针新项,⛔ 无 `待测` 残留)|**S6** 本机多实例真机验收(N ≥ 4,实测 **稳态 0 帧 / 一次变化 ≤1 帧 / 降幅 ≥ 10×**)|**S7** 收口(部署属 lane 内,**直接做**)。**验收** = 单内 **E1–E11**(零回归三件套 = `npm test` **162/161/0/1**、`--scene all` **12 PASS**、`overlay-probe` **12/12 + 新项**,均**必须带 `--table`**)。**三条硬门**:**D1**(⛔ 不改任何生产值 —— `RELAY_FAILOVER_*` / `HB_SEC` / burst)|**R5**(⛔ 不新增公网监听口 / 不改 nft·nginx;新增监听口·凭据·入站必须仍为 **0**)|**R7**(⛔ 不改 `04-调整方案/**` 档案正文、⛔ 不 commit / 不 push)。🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**。⚠️ **命中该单 §9 的任一条回头条件**(要动生产值 / 要扩大暴露面 / 要超出该单 §3.1 文件集 / 批合并失效 / 观测跑不出单值 / 出现静默放行 / 要求"先有房间层" / 零回归退化)⇒ **立刻停下报告,别扩大**。⛔ 不做内容分发、⛔ 不做房间层、⛔ 不做打洞实现、⛔ 不重做已收官序 ①–⑰。**收口必须**:释放锁 + 陈述句告知 + 登记下一棒 + 推进本入口 + 写工作区日志 + 回填该单 §8。 + + +> +> 📌 **自动接续登记(2026-09-17 17:2x)**:**下一棒 = automation id `107b8e38-ccbd-4dcf-8b49-25dad7c89be9`**(名称「覆盖网络线-序19执行棒-presence节点在线态」,**一次性**,`scheduledAt` = **2026-09-17 17:28** = 收口 +4 min,`nextRunAt` = 1789637280000)⇒ **已登记自动接续,约 4 分钟后自动开新会话;接续点 = 本文件 §2「🎯 本轮动作」**。(已完成的一次性棒:序 ⑱ 规划棒 = `50882a38-07cd-494c-ae73-cbbf27f3604d`;序 ⑰ 执行棒 = `067b0892-964e-4deb-bf84-4f1c37bebea8`。) +> +> 📌 **(历史)自动接续登记(2026-09-17 16:4x)**:**下一棒 = automation id `067b0892-964e-4deb-bf84-4f1c37bebea8`**(名称「覆盖网络线-序17执行棒-在册收尾(mksess口径/技能三处同步/Q5清理/幕4A临界项)」,**一次性**,`scheduledAt` = **2026-09-17 16:44**,`nextRunAt` = 1789634640000)⇒ **已登记自动接续,约 5 分钟后自动开新会话;接续点 = 本文件 §2「🎯 本轮动作」**。(已完成的一次性棒:序 ⑯ 规划棒 = `91c53ef8-c3f0-4bb3-9f0a-943790178176`。) +> +> 📌 **(历史)自动接续登记(2026-09-17 16:3x)**:**下一棒 = automation id `91c53ef8-c3f0-4bb3-9f0a-943790178176`**(名称「覆盖网络线-序16规划棒-会合中继拆分复核与在册收尾定序」,**一次性**,`scheduledAt` = **2026-09-17 16:36**,`nextRunAt` = 1789634160000)⇒ **已登记,约 5 分钟后自动开新会话接续,接续点 = 本文件 §2「🎯 本轮动作」**。 +> +> 📌 **(历史)自动接续登记(2026-09-17 16:1x)**:**下一棒 = automation id `1933b18a-0c2a-46a2-829c-440a900fed08`**(名称「覆盖网络线-序15执行棒-拨号池槽位自毁缺陷B修复」,**一次性**,`scheduledAt` = **2026-09-17 16:14** = 收口 +4 min,`nextRunAt` = 1789632840000)⇒ 已登记,会自动开新会话接续。(已完成的一次性棒:序 ⑭ 执行棒 = `3e0a7b01-9257-4b76-b82f-4998c9f7eab0`;序 ⑬ 执行棒 = `a513b25c-9399-4fd5-9a00-a19a04c36d74`。) +> +> ⏹️ **(存档)序 ⑬ 本轮动作口径(已收官 · 结果见 `交接单_观测口径与在册缺陷_20260917.md` §9)**:**序 ⑬ · 执行棒 —— 在册小缺陷清算 `Q1 → Q2 → Q3`**(口径 = `交接单_观测口径与在册缺陷_20260917.md` **§5.9 表** + 本棒 §8.8 转办;**该单已给每条的最小验收与回滚 ⇒ 直接执行、无须再出规划单**)。按序三条:**Q1** `/opt/dshs/mksess*.cjs` 仍写 **SQLite 旧库** ⇒ **已失效**(判据 = 用它开一次临时 session ⇒ 实例面 `curl -w '%{http_code}'` 回 **200/401**,⛔ 非 `000`;用完即删。**它是 R4 规定的唯一实例面验收手段 ⇒ 不修则 Q2/Q3 连"做完了没有"都判不了**)|**Q2** guest(**w-106**) 实例页 **502**(真因 = 平台 `landModels → writeHomeFile` 用**本地路径**写**远端**实例 home;判据 = 同 Q1 的临时 session ⇒ w-106 实例页 **200**)|**Q3** `OBS-09` 实例面 `000`(**环境态非缺陷**:47 无活跃实例 ⇒ 起一个 ⇒ `OBS-09` 绿 **且** 新 `OBS-11` 在"有实例态"下仍绿 = `20000` 落在允许区间)。⚠️ **Q3 与 Q2 同批做最省**(同一临时 session 一次验两件)。**三条硬门**:**D1**(分解类前提未复现前 ⛔ 不许改任何生产值)|**R7**(范围外先报告、不动手 ⇒ **Q4 `src/net/relay/**` untracked 缺口卡在"用户授权"、⛔ 本棒只报告;Q5 `client.ts.bak-*` 残留 = 清理类,待 Q1–Q3 后办**)|🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**(该值看似合法、实际自锁)。⛔ **不 commit / 不 push**(Q4 要授权)。**收口必须**:释放锁 + 陈述句告知 + 登记下一棒 + 推进本入口 + 写工作区日志。 +> +> 📌 **自动接续登记(2026-09-17 15:2x)**:**下一棒 = automation id `a513b25c-9399-4fd5-9a00-a19a04c36d74`**(名称「覆盖网络线-序13执行棒-在册小缺陷清算Q1-Q3」,**一次性**,`scheduledAt` = **2026-09-17 15:24** = 收口 +3 min,`nextRunAt` = 1789629840000)⇒ 已登记,会自动开新会话接续。(已完成的一次性棒:序 ⑫ 执行棒 = `52645861-6895-459c-a07e-a5d686787d9c`。) +> +> ~~(存档)序 ⑫ 执行棒口径:**序 ⑫ · 执行棒 —— 观测口径重构(`OBS-11` 计数 → 白名单集合)**。✅ **已收官(2026-09-17 15:3x)**:见上 §0 最新刷新行;产物 = 代码仓 `scripts/overlay-probe.cjs`(新增夹具模式)+ 参数表 §6 四键;`overlay-probe` **11/12**;参数表指纹 `8f08e74b026e6e5b5e1b3db813f031ae`。⛔ 不要重做。~~ +> +> ~~(存档)序 ⑪ 规划棒口径:**序 ⑪ · 规划棒 —— 观测口径重构 + 在册小缺陷清算排序**。⚠️ **只出规划、不改码**(规划与执行分离):产出交接单(8 段模板)+ 为下一棒定序。范围**仅限边界内技术项**:① **OBS-11 判据从「计数相等」改为「白名单集合」**(现口径两处结构缺陷:对实例/端点在线态**敏感 ⇒ 假红**;对"一进一出"**替换式变化不敏感 ⇒ 假绿**),连带处理 `LISTEN_COUNT` 的"有/无实例态"两值与 `scripts/overlay-probe.cjs` 改造;② **在册小缺陷清算排序**(`OBS-09` 实例面 000|guest(w-106) 实例页 502|`mksess*.cjs` 失效|`src/net/relay/**` untracked 留档缺口|`client.ts.bak-seq7-*` 残留|参数表 `LISTEN_COUNT`=79 与现态差的 1 条无名口)。⛔ **不得自行为"大方向 / 业务优先级"定序**(presence / 房间层 / 内容分发 = **需用户拍板**,只列候选与优缺点、不决)。➕ **顺手办(同一棒内)**:`.workbuddy/memory/MEMORY.md` 的**真瘦身** —— 现 **8 121 字符 > 注入上限(实测 ≈7 78x)** ⇒ 每轮尾部被截断;把 §二 里"已有 PLAYBOOK 指针"的规则**下沉为一行指针**(体积只减不增)。**✅ 已办(15:0x)= `MEMORY.md` 8 121 → 7 712 字符**。⛔ 不 commit / 不 push。~~ +> +> ~~**序 ⑩ · 执行棒(已收官 · 内容留档)**~~:**序 ⑩ · 执行棒 —— 覆盖网络线技术债清算(3 件)**。**直接出执行棒、无须规划棒**:三件均已定位、均属 §1 边界内技术项(实现路径 / 排查方法 / 脚本自洽),**无可上抛项**。三步:**① 演练脚本自洽 —— 幕 2 的状态依赖**(`scripts/overlay-failover-drill.cjs`:注释写"两台全杀"、实现只停 106,而幕 1 已把 Manager 通道切到 47 ⇒ 停的是当前**不用**的那台 ⇒ 120 s 窗口 0 行 `[relay-skip]`;修法 = 幕 2 先判定当前活跃通道再停它,并复验正确前置下 3 PASS);**② 清掉 `COOLDOWN_MS=0` 的 5 处命中**(全部序⑧ 遗留 ⇒ 该值已判「看似合法、实际自锁」⇒ 从演练 / 回滚路径彻底移除,改用合法构造);**③ OBS-11 的 `78 → 77` 那 1 口定位**(两次连采同值、差 1 < 2 未命中回头条件 ⇒ 需把 78 与 77 逐口对账,定位是哪一条消失)。**验收**:`--scene all` 一键全绿(含幕 2)+ `grep -c 'COOLDOWN_MS=0' scripts/` = **0** + OBS-11 差值**有名字**(否则写明"已排查 N 条、卡在哪、回头条件")。**三条硬门**:**D1**(分解类前提未复现前 ⛔ 不许改任何生产值)|**R7**(范围外缺陷先报告、不动手)|🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练路径**。⛔ 不 commit / 不 push;⛔ 不做 presence / 房间层 / 内容分发。**收口必须**:释放锁 + 登记下一棒 + 推进本入口 + 写工作区日志。 +> +> 📌 **自动接续登记(2026-09-17 14:1x)**:**下一棒 = automation id `be7242dd-ac3e-43d0-b52c-a465683cf972`**(名称「覆盖网络线-序10执行棒-技术债清算」,一次性,`scheduledAt` = **2026-09-17 14:23**)⇒ 已登记,会自动开新会话接续。(已完成的一次性棒:序 ⑨ 执行棒 = `2ca4048b-ab74-4845-8964-47a88910c679`。) +> +> ~~(存档)序 ⑨ 规划棒口径:~~ **序 ⑨ · 执行棒 —— 按 `交接单_检测时延与deadline_20260917.md` 开工**(工作区根;8 段模板;**出单前缀指纹 = `d903b4eeabf25ef381379cdbaac77e8a`**)。**核心结论(规划棒已用现有日志分解完成,执行棒须先复现)**:30 s ≈ **检测 15.0–15.1 s**(= `gracefulBurstMs` 的 15 s 地板,⛔ **不是** 2.5×`HB_SEC`=37.5 s,上单 §8.8-2 的口径提示已在单内勘误)+ **白等 12.0 s**(`waitUpOn` 只轮询 `state==='up'`、**不对终态失败早退** ⇒ 对一个必然失败的同机候选吃满 `upTimeoutMs`)+ **建连 2.7–2.8 s**;两个逐行复核样本(`unhealthyForMs=30563` / `29376`)结构完全一致。**首选修法 = `waitUpOn` 死候选早退**(三处装配点 `server.ts` / `relay-tunnel.ts` / `relay/main.ts` 逐处验收),⛔ **不调 `RELAY_FAILOVER_DEADLINE_MS`、⛔ 不动 `HB_SEC`、⛔ 不动 burst 语义**。**开工第一件事 = 单内 §2-P10 分解复现**(复现不出,或 S2 采到任一 < 27 s 的样本 ⇒ 停下报告)。**OBS-11 已复取 = `77`**(47 无活跃实例档,口径 78;差 1 < 2 ⇒ 未命中 §8.8-3 回头条件;两次连采同值)。**三条硬门**:**D1**(⛔ 分解复现成功前不许改任何 `RELAY_FAILOVER_*` 生产值)|**R7**(范围外缺陷先报告、不动手)|**§8.8-4**(🔴 ⛔ 不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练路径)。⛔ 不 commit / 不 push;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步)。**收口必须**:释放锁 + 登记下一棒 + 推进本入口 + 写工作区日志。 +> +> ~~(存档)序 ⑥ 规划棒口径:**序 ⑤ · 执行棒已收官** —— 证据 = `交接单_参数表与观测_20260917.md` **§8 执行回报**(可复现指纹见该单 **§8.9**;E1–E9 = **8 绿 + 1 部分绿**;唯一部分绿 = E5 的"在 47 上受控制造一次真实拨号",卡点 / 已做到哪一步 / 回头条件见该单 **8.7-①**)⇒ **下一棒 = 序 ⑥ · 3–5 台最小形态**:按 `覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 6 行**开工 —— **先把 `参数表_覆盖网络_20260917.md` §7 的 4 个 `待测` 项 + 1 个待校准推导项换成实机实测**(打洞率 / 每玩家带宽 / 跨云稳态吞吐 / 中继 jitter / `MEM_PER_HOST_MB`),并**合批**落地 **L3 第二中继机**(参数表 §5.2 校验③ 已算出:千台 L3 需求 450 > 单台 `--max-hosts` 225 ⇒ **需 ≥2 台中继**)。⚠️ **回头条件已写死**:`MEM_PER_HOST_MB` 或 `WAN_STEADY_THROUGHPUT` 一换成实测 ⇒ **必须重算 `RELAY_MAX_HOSTS` 并重下发 47 的 `capacity.conf`**。⚠️ 两条待办随本轮带过来:① guest(w-106) 实例页 **502**(`landModels → writeHomeFile` 用**本地路径**写远端实例 home;且 `/opt/dshs/mksess*.cjs` 仍写 SQLite 旧库、**已失效**);② 🔴 **106 agent 面不吃引导链**(`worker/agent.ts` 把 `DSHS_RENDEZVOUS_URL` 直接当 relay URL 用;按字面撤掉 = `tunnel===undefined` 生产回归,**已判不执行**)。⛔ 不重做序 ②/③/④/⑤;⛔ 不做 presence(清单第 7 步);⛔ 不 commit / 不 push。 +> +> ~~(存档)原规划棒口径:**序 ⑤ · 规划棒 —— 给「参数表 · 观测 · 权限评估」出一份可执行交接单**(落盘工作区根,命名 `交接单_参数表与观测_20260917.md`,按 8 段模板:目标 / 只读前置 / 范围 / 决策点 / 步骤 S0–Sn / 逐条验收判据 / 回滚 / §8 回报格式)。**必带输入** = `交接单_443兜底_20260917.md` **§8.7 的四条遗留** + 该单 **§8.8 的 45% 口径待填参数位**(relay `--max-hosts` / `/status` 的 `capacity{max,used}` / `retryAfterMs`)+ **L3 跨机真容灾**(命中 **R5**,须先出权限影响评估)。⛔ 本轮**只出单、不改服务器**(规划与执行分离);**不 commit / 不 push / 不做 presence**;收口必须释放锁 + **登记下一棒**。~~ +> +> ~~(存档)原规划棒口径:**序 ④ · 规划棒 —— 给「443/TCP 兜底」出一份可执行交接单**(落盘到工作区根,命名 `交接单_443兜底_20260917.md`,按 8 段模板:目标 / 只读前置 / 范围 / 决策点 / 步骤 S0–Sn / 逐条验收判据 / 回滚 / §8 回报格式)。**背景**:现役只有 `wss://alotbuy.com/dshs-relay` 一条路且经 nginx + CF(单点);序 ④ 要补的是**CF / nginx 不可用时的 443/TCP 直连兜底**(异构设计纪律:relay 不按同构 15%,而是 **45% 设计、55% 留余量**)。**取证入口** = `覆盖网络_应用场景与待完善清单 §五`(主线第 ④ 项)+ `覆盖网络_补遗与参考方案_20260916.md` + `覆盖网络_全球架构复盘_20260916.md`。⛔ 本轮**只出单、不改服务器**(规划与执行分离);**不 commit / 不 push / 不做序 ⑤ / 不碰 presence**;收口必须释放锁 + **登记下一棒**。 + +> 🟢 **最新口径覆盖本节历史内容(23:0x)**:**R2 / R3 / R4 / R5 全部已完成并端到端验收**(→ 交接单 **§9 / §10.4 / §10.5 / §11**)⇒ **机制层 R0–R5 已齐**(**R5 已解掉"Manager 必须与 relay 同机"这条前提**)。 +> 🔴 **下一动作已勘误**:⛔ **不是** "presence 改造"。按 `覆盖网络_应用场景与待完善清单 §五` 的主线,presence/房间层是**第 7 步**,前置为 **② 网抽象 + 地址规划 + 引导(P0-1/2/3) → ③ 一机一钥 + 信任根 → ④ 443/TCP 兜底 → ⑤ 参数表 → ⑥ 3–5 台最小形态**。(另:仓库里目前**没有任何应用层代码** —— 全仓唯一含 "presence" 字样的文件是 `src/net/relay/placement.ts` ⇒ "presence 改造"根本无从下手,这条旧指示是上一轮的笔误。) +> ✅ **2026-09-17 07:4x:序 ② 已收官(①②③ 全绿,其执行进度见 `交接单_网抽象与地址规划R6_20260916.md`)** ⇒ 🟢 **2026-09-17 07:5x 已定序:下一棒 = 序 ③(一机一钥 + 信任根)**,之后 ④ / ⑤(理由见 §0)。 +> ⛔ **不要再做 R2 / R3 / R4 / R5、不要再开 `32023`、不要再做 SSH 版中继**(隧道凭据与 `32022` 都已收回,那条路已不可能重建)。 +> (按顺序 · **2026-09-16 11:5x 刷新**) + +1. **先抢锁**:`bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<会话名>"` —— 锁已释放,抢到即可开工。 +2. **【第一优先 · 真活】会合 / 中继从 Manager 拆分的落地**(方案:工作区根 `会合中继拆分_取证与改造方案_20260916.md`,S0–S4 分步 + 每步验收 / 回滚) + - ✅ **P1 / P2 / P3 已完成并验收(2026-09-16 15:5x–16:2x)** —— 全部实测证据在 **交接单 §8**(含命令原文级输出)。摘要: + · **P1** 47 对齐到 `640813e`(Manager 侧真补上 S0+S1)|多用户验收:`admin`(w-47) 与 `guest`(w-106) 实例页均 200、跨机文件面落盘已核实 + · **P2** `dsh_hosts.via` 列(迁移 v8)|回填 `w-47=local` / `w-106=manager-ssh`|`hostsProvider` 走 `RendezvousRegistry`|`GET /api/admin/hosts` 已可见 `via` + · **P3** **实例端口区间隔离**(`w-47` 20000+ / `w-106` 21000+,避开 OS 临时段)⇒ 修掉「跨 worker 同号 ⇒ 47 的 `127.0.0.1` 撞号 ⇒ `tunnel.forward()` 返回值被忽略 ⇒ **静默拨到别人实例**」这条真 bug;两实例实测落 20000 / 21000 且互不重叠 + - ✅ **P4(SSH 版中继 / 新开 32023)已判「不做」**(17:2x)—— SSH 反向隧道**不是"不安全"**(专用非特权账号 + `PermitOpen` + 限速是业界标准做法),但**不该长期做数据面**;它想要的两样(与宝塔 sshd 解耦、甩掉 root 凭据)在 relay 方案里**同样拿到** ⇒ 两步合一。⛔ **不要新开 32023、不要再做 SSH 版中继**。 + - ✅ **R0/R1/R1.5/R2 已完成**(R0 判据表+链路画像、R1 relay 最小闭环、R1.5 韧性+准入选点、**R2 常驻+nginx 443 暴露**)⇒ 全部证据在方案 **§9–§14**。 + - ✅ **R3 + R4 已完成并端到端验收(2026-09-16 21:37–22:2x)** —— R3=`RendezvousRegistry` 已接线 `RelayRendezvous`(含 relay 实时快照 + 15 s 陈旧回退)+ 106 常驻 + `w-106 via='relay'`;R4=实例面经 relay(`PORT_ADD/DEL/ACK` + `RelayTunnel` + 端点翻译)+ **sshd 反向隧道下线且不可能重建**(47 收回隧道公钥、`32022` 回收;106 摘 `DSHS_TUNNEL_*`、私钥移走)⇒ 47 公网暴露面**净减 1 口**。证据 = 交接单 **§10.5**(含一份"静默失效"缺陷的定位与修复 + 先红后绿回归测试)。 + - ✅ **R5 = 会合可换机 已完成并端到端验收(2026-09-16 22:3x–23:0x)** —— Manager 改为 `dialer: true` **只拨出**一条 wss,落点在**自己本机**预绑池(`127.0.0.1:25000..26099`,64 口);新增 `DIAL(0x0e)/DIAL_ACK(0x0f)` 帧、`StreamPeer` 抽象(数据面同形)、relay 侧 `DSHS_RELAY_DIALERS=manager` 白名单 + 每机独立密钥(权限只收窄)。证据 = 交接单 **§11**:① 单测 **38/38**;② E2E `endpoints: [(19000, 41233, 0)]`(**relay 回环口 streams=0**)+ `DIAL manager -> w-106:21000 ok`(实例面)+ 门户 200/62451 B;③ 🔴 **换机等价实验**(清掉 `DSHS_RELAY_STATUS_URL` ⇒ Manager 对 relay 回环口一无所知)**仍全通**,两个回环端点 `streams` **全为 0**;④ 新增 64 口**全部仅回环**,公网零新增。 + - ⏳ **下一步 = 主线第 ② 项「网抽象 + 地址规划 + 引导」**(`覆盖网络_应用场景与待完善清单 §五`)—— ⛔ **不是 presence**(那是第 7 步)。⛔ 不要再做 R0/R2/R3/R4/R5。 + 🟢 **阶段 ①(S0–S4 + R5)已完工;`清单 §11.6` 记下的两条缺陷(A1 假 404 / A2 锁信号)也已修完并端到端验收**(→ R2-R4 单子 **§12**)。 + 🟢 **2026-09-17 07:4x:② 的 Step 3(P0-3 名字解析与授权)也已完成并端到端验收 ⇒ ② 全单关闭(①②③ 全绿)**(证据 = ② 单的「执行进度」段)。⇒ **下一棒不再看 ② 的单子**,按 `清单 §五` 主线往下走(序 ③ 一机一钥 + 信任根 / 序 ④ 443/TCP 兜底 / 序 ⑤ 参数表·观测·权限评估 —— **开工顺序已于 07:5x 定为 ③ → ④ → ⑤**,理由见 §0)。⛔ 不要再重做 Step 1 / Step 2 / Step 3。 + - ✅ **A1 已修并端到端验收(2026-09-16 23:2x–23:4x)** —— `RemoteUserFs.target()` 原在 `agentFor(hostId)` 查不到时**静默回退** `DSHS_CLUSTER_AGENT_URL`(= w-47 本地 agent)⇒ 每次 `restart dshs` 的窗口期内 w-106 用户回**假 `404 not_found`**(与“文件夹不存在”同形,误导排查)。**真因** = 文件面 `server.ts:262` 的 `hostDirectory` 是惰性 Map、此前**只被 spawner** 刷新 ⇒ 启动时表里只有本机。**修法** = `ensureHost` **按需补齐**(治本)+新增码 `host_unresolved` → **503 且零请求发默认机**(治安全)。**证据** = R2-R4 单子 **§12**(先红后绿 + 冷启动 `launch 200` + relay 有 `DIAL` + `not_found=0`)。判别器仍是:**relay 有没有 `DIAL`**。 + - ~~原 R0 描述~~(已过期,仅存档):只看 `接续包_覆盖网络线_20260916.md`(它 17:29 已同步):**R0-a** 把六条判据(认证模型 / 默认姿态 / 零入站 / 爆炸半径 / 可观测 / 生态)补成**可打分表**;**R0-b** 测链路画像(本机↔47↔106 的 RTT / jitter / 带宽)。**R1 起才选 relay 实现**(候选 OpenZiti / Nebula 身份型优先 > rathole > frp)。⛔ **本轮不引入任何第三方 relay**。 + - 🔴 **本 §2 的口径截至 2026-09-16 17:29**(17:2x 起 P4 改判、17:3x 撤销"首选 frp")。⚠️ **接续包每次更新后,必须回来同步本入口** —— 16:43–17:29 期间本入口曾滞后 41 分钟,导致 17:04 那位自动接续会话按旧口径得出「P4 **并入自研 relay**」的结论,与最新定案(**实现未定、先做 R0**)**表述不一致**(方向一致、粒度不同)。⛔ 别再让"下一棒的第一信息源"比接续包旧。 + - ⏳ **未做**:git commit(未授权)、档案占号建档(建议与 P4 合并做,届时才有 commit hash 可写)。 +3. **平台那批待办** ✅ **已清**:`BRIEF.md` 刷新至 09-16|T08 单子已归档 `交接单/archive/交接单-已完成/`|`.doing-T08` 已随 T08 归档(内容保留为 `T08-执行标记-已释放.md`)|`04-调整方案/.lock-*` 16 个空占号残留已清|未提交改动已按 5 个定向 commit 提交(HEAD `59f1a89`,**已 push**,远端 `master` 一致)。 + ⏳ **仍未动**:`INDEX.md §二` 的既有失真(`04-90~04-102` 共 13 行物理落在表格外 ⇒ `docs-index-stats.py` 统计不到)—— 搬别人的行有风险,留待专项。 +4. **10 份文档转正式档案** ✅ **已完成**(档案 103–112,`4e3a1a4`;正文行覆盖率 98.5–99.0%)。 +5. **`MEMORY.md` 精简** ✅ **已完成**(工作区 -26%;**真被截断的是用户级那份**,已按「分层重排、零删除」修好)。 +6. **技术侧开工**:按 `覆盖网络_瓶颈落地方案_20260916.md` 的「只做三件事」(presence 改造 + 游戏服放 L1 + 块级内容寻址)开工;**前置仍是第 2 条**。 +7. **代码分层 / 依赖方向**(与覆盖网络并行的一条线)—— ✅ **P0 已完成**(2026-09-16 13:2x,本机):新增 `docs/architecture.md`(四层 `①入口 → ②领域 → ③能力 → ④基础` + **R1–R4 判据** + 三条工程纪律)+ `README.md` 文档表登记。 + - 📄 评估文档 = 工作区根 `项目代码_分层范式与迭代风险评估_20260916.md`(**§8 已勘误**)。 + - 🔴 **勘误(勿再复用旧结论)**:所谓「**3 处基础层反向依赖**」**不成立** —— 那几处是**入口层 `cli.ts`** 的合法依赖(`①→③`);实测 `src/*.ts` 里从子目录 import 的**只有 `cli.ts`**,基础层四文件**零 import** ⇒ **真实违规 0 处**。 + - ✅ **可执行判据已补(同日)**:`scripts/check-layering.mjs`(`npm run check:layering`,已挂进 `npm run verify`)—— 静态扫描四层 + **ratchet 基线**(`scripts/layering-baseline.json`,新增违规 ⇒ 退出码 1;只许减)。**首跑实测 60 个 .ts**:`①24 / ②0 / ③32 / ④4`,**现存违规 5 条**(全是 `③能力→①入口`:`supervisor/proxy.ts`→`web/auth.ts`+`web/middleware/authn.ts`;`fs/{local,remote}-user-fs.ts`+`fs/workspace.ts`→`web/middleware/fs-guard.ts`)。 + - ⏳ **剩三件(均命中 R7,动工前先出受影响清单)**:① **整改上面那 5 条**(修法已写在 `docs/architecture.md §5.1`)② 补 `src/domain/*`(把 `web/routes` 的业务规则抽出来 —— **行为敏感重构**,非纯类型)③ 给模块入口补「职责 / 依赖谁 / 被谁依赖」。 + - **顺序建议**:**P0 ✅ → 覆盖网络 S1–S4 → P1**(P1 不阻塞覆盖网络)。 +8. ✅ **两条「已定位未修」缺陷 —— 均已于 2026-09-16 23:4x 修完并端到端验收**(证据 = R2-R4 单子 **§12**): + - **① `state.py` 锁误报(安全信号)**:`state.py:39` 原把 `交接单/.exec-lock` 当**文件**读,而 `--claim-exec` 建的是**目录**(内含 `OWNER`)⇒ 永远报「[锁] 空闲 ✅ 可以动手」⇒ **每个新会话读到的第一个信号是错的**。**已修**:是目录就读 `LOCK/OWNER`(兼容遗留的普通文件形态);复验 = 持锁时正确报「🔴 被占用」。 + - **② 假 404 / 静默回退到错 agent**:见上方第 2 条末段(`src/fs/remote-user-fs.ts` 的 `target()`) —— **已修**。 + +> ✅ **上一轮已完成(2026-09-17 17:28–18:16,执行棒)**:**序 ⑲(presence 节点在线态)已收官并上线** —— 详见 §0 最新那条刷新的全部数据;**产物 = `交接单_presence在线态_20260917.md` §8★ 回报(8.0–8.9 已回填)**。一句话:**在线态从"每 5 s 拉 `/status`"改为"连接生命周期 + 订阅推送 + 1 s 批合并 + TTL 安全网"**,真机 `counters={subs 1, pushed 3, snaps 2}`、单测 **169/0/1**、`--scene all` **12 PASS / 0 FAIL**、S6 本机多实例 **8/8**。⛔ 不要重做序 ⑲。 +> +> 🎯 **序 ⑳ 的动作已定(本棒登记)**:① **修 `OBS-09` 口径** —— 「实例不在册 ⇒ **SKIP**(⛔ 不是 PASS、也不是假红);在册 ⇒ 必须可达 `PROBE_CODE_SET`」+ 两侧夹具实证;② **量化 presence 真机收益**(`counters.statusHits` 增速 ⇒ `/status` 轮询降幅,给算式与实测);③ presence 线**收口复验**(`npm test` / `--scene all --table` / `overlay-probe --table`);④ 处置 `web/server.ts` `translateEndpoint` 的**键口径既存缺陷**(`hostVia` 表键 = 逻辑名、传入裸 `hostId` ⇒ 恒 `undefined`)。 +> ➡️ **下一棒 = 序 ⑳(执行棒)**,automation **`51cfd12f-918d-4069-81d8-c148fc174282`**(一次性 · **2026-09-17 18:20** = 收口 +4 min)。⛔ 未登记更后一棒:**`OBS-09` 的判据口径修正属技术实现 ⇒ 本棒自决**,但「**平台是否要在 Manager 重启后自动拉起既有实例**」是**业务取向**(可用性 vs 资源/复杂度)⇒ **待拍板,⛔ 不作为任何一棒的前置**(详见本棒回报的最后一节)。 + +--- + +## 3. 关键决定(**已定,勿再上抛**) + +1. **方案只做技术实现;跨境数据合规由使用者自行考虑**(用户原话)⇒ 选型不为合规让路、跨境不设限、⛔ 不再把合规当前置条件。 +2. **单机自用 ≠ 不需要互联**(用户原话:「单机用也要互联,这正是建立覆盖网络的目的」)⇒ **租户维度收窄 ≠ 网络维度收窄**。 +3. **覆盖网络按异构设计**:中继按 **45% 设计 / 55% 留余量**(不是同构假设的 15%);**必须补 443/TCP 兜底**;有公网 IP 的节点升格为中继候选。 +4. **权威状态单点**:归属/租约/骨干资格只能控制面写;会合/中继可多实例。 +5. **形态已拍板**:单机自用为默认;"当服务器给他人用"的多人维度已放弃。 +6. **游戏重点是 MMORPG(2D/2.5D)/ MUD / 传奇类** ⇒ 判定上修为"最匹配",非 3D 强实时竞技。 + +## 4. 待拍板(唯一一项,真取舍) + +**骨干节点的服务范围**:A 只服务自己名下设备(优点:无资源/计费纠纷、权限面不变;缺点:骨干少、冗余低)| B 服务全网(优点:骨干多、可用性高、中继容量易满足;缺点:他人流量跑在你的机器上、元数据暴露、被攻破影响面大)。 +**倾向 A→B 渐进**。详见 `覆盖网络_骨干层方案_20260916.md` §7。 + +--- + +## 5. 回滚点 + +- **R5(本轮)三层回滚,均秒级**(详见交接单 §11.7): + ① **Manager 拨号通道** = 删 manager drop-in 里 `DSHS_RELAY_DIAL_*` 5 行 → `daemon-reload` → `restart dshs`(`addressOf` 自动回退到 R3 的 `/status` 快照路径,"relay 同机"假设回来); + ② **relay 侧白名单** = 删 `dshs-relay.service.d/dialers.conf`(不删也行:无拨号方接入 ⇒ 行为同 R3); + ③ **代码级** = revert `src/net/relay/*` + `src/config.ts` + `src/web/server.ts` → `npm run build` → scp `lib/` → `restart dshs`。 + ⚠️ 备份:`/etc/dshs/relay-keys.json.bak-r5-20260916-224838`、`/etc/systemd/system/dshs.service.d/cluster.conf.bak-r5-20260916-224838`(及 `.bak-r5swap-20260916-225546`)。 +- **缺陷 A1/A2(本轮)回滚**(详见交接单 §12.5):A1 = `cp -a /opt/dshs/lib-bak-a1-20260916-232914/lib/. /opt/dshs/lib/` → `systemctl restart dshs`(回到"假 404"复现);A2 = 还原 `state.py` 第 1 节为"按文件读"(**不建议**)。 + ⚠️ **A1 的备份目录只有这一份**(推送过程中滚动过两次,同名文件以后者为准,两次都在同一个目录里)。 +- **有意保留、⛔ 别当"没做完"**:R5 新增的 `127.0.0.1:25000..25063` **仅回环**(公网零新增);relay 仍为端口绑动态回环口(`exposeLoopback` **有意未关**,理由见交接单 §11.5)。 +- 文档侧:10 份为**新增**;被修改的 3 份(客户端化部署方案 v3 / 桌面客户端方案 v1 / 可行性评估)改动均为**追加或标注**,可反向还原。 +- 服务器侧待复核事实:控制面机规格(1.8 GB / 2 核 是 09-08 旧记录);47↔106 跨云实测 **~22 KB/s**。 + +> 📌 交接单指纹(2026-09-16 23:4x):`tail -n +4 交接单_relay落地R2-R4_20260916.md | md5sum` = `b110b5c4e3eb8afcac08af537d333c99`(**本单已执行完毕**:R2→§9、R3 Step 0→§10.4、R3 本体+R4→§10.5、**R5→§11**、**缺陷 A1/A2→§12**) +> 📌 **② 的交接单**(**已执行完毕**):`交接单_网抽象与地址规划R6_20260916.md`,指纹 `0d3f1f305e9e6219afa97cdfecff05ba`。 +> 🟢 **2026-09-17 07:4x 更新(② 收官)**:**Step 1(P0-1 网抽象)/ Step 2(P0-2 引导三级链)/ Step 3(P0-3 名字解析与授权)全部落地并端到端验收** —— +> 证据见该单的「**执行进度**」段(含判据命令与实测输出)。**② 单关闭 ⇒ 它不再是下一棒的开工依据**(指纹留着只用于"文档有没有被改过")。 +> ⛔ **不要重做 Step 1 / Step 2 / Step 3**。下一棒按 `清单 §五` 往下:序 ③ 一机一钥 + 信任根(P0-4)/ 序 ④ 443/TCP 兜底 / 序 ⑤ 参数表·观测·权限评估 +> —— **开工顺序已于 2026-09-17 07:5x 定为 序③ → 序④ → 序⑤**(理由见 §0)。 diff --git a/dsh-server-docs/ops/接续包_覆盖网络线_20260916.md b/dsh-server-docs/ops/接续包_覆盖网络线_20260916.md new file mode 100644 index 0000000..d9dfce3 --- /dev/null +++ b/dsh-server-docs/ops/接续包_覆盖网络线_20260916.md @@ -0,0 +1,66 @@ +# 接续包 · 覆盖网络线 · 2026-09-16 18:3x(**v3**) + +> **用法**:新会话**只读这一份** —— ⛔ 不许先 Glob/Grep 全库摸底、⛔ 不许"再确认一下待办"。 +> **v3 改了什么**:① 上次自动接续(17:03)**跑了 9 次调用 / 1.5 分钟 / 零产物** ⇒ **「下一步」已从"两件事混装"改成单一机械动作**(根因见「给下一棒」);② 全文压到 3KB 内;③ 传输选型结论已更新(**撤销"首选 frp"**)。 + +## 接续点 · 覆盖网络线 · 2026-09-16 18:3x + +- **来源会话**:`408636f2`(覆盖网络线:规划复核 → 落地执行 → 收口)| **结束原因**:水位 **42 万**强制收口 +- **原目标**(用户原话):①「查看覆盖网络任务的相关文档,规划落地步骤方案,检查方案确认有误调整,准备执行」②「有稳定可行的落地步骤方案了吗,确认是最佳落地方式就开始执行,保证每一步完成后项目都保持可用,逐步交付」③「覆盖网络的功能开发完成了吗」④「重新创建接续会话 看看是否有改善」 +- **基线**:HEAD=`640813e` | 远端 master=`640813e`(15:0x 实测,**未复验**)| 全局锁=**已释放** | ⚠️ 工作区 **13 改 + 1 新增**,**未 commit(未授权)** +- **产物**: + 1. `交接单_覆盖网络落地执行_20260916.md` —— **§8 = P1/P2/P3 执行记录 + 全部实测证据** + 2. `覆盖网络_传输方案取舍_开放端口与自研relay_20260916.md` —— §7 成熟件对照 / **§8 撤销 frp + 六条判据 + R0** + 3. `会合中继拆分_取证与改造方案_20260916.md` —— §9 第二轮勘误 + 4. 技能 `dsh-decision-method` **v2.8.0**(新增 §4.6 选型判据轴;三副本 md5 一致) +- **校验命令**(只读、≤30s): + ```bash + ssh root@47.77.182.89 'md5sum /opt/dshs/lib/supervisor/spawn.js; grep INSTANCE_PORT /etc/dshs-worker.env; PGPASSWORD=dshs_cluster_2026 psql -h 127.0.0.1 -p 15432 -U dshs -d dshs -At -c "select id,via from dsh_hosts order by id"' + ``` + **期望**:`e75427d06fa94cadac800e1570cec273` + `DSHS_INSTANCE_PORT_BASE=20000` / `SPAN=1000` + `w-106|manager-ssh`、`w-47|local`。**不符 ⇒ 停下只报告,⛔ 不许照文字硬做。** + +## 给下一棒(⛔ 先读这段:上次为什么零产物) + +上次(17:03)自动化会话 **9 次调用 / 1.5 分钟 / 零产物**。根因 = **旧「下一步」把两件事(补判据表 + 测链路画像)混成一句话,且没给可交付的产物路径** ⇒ 会话做完开机四步就无从下手。**本次已改成单一机械动作(见下),照做即可闭合。** + +## 下一步(**唯一动作 = R0-b 链路画像测量**,全程只读) + +**要产出**:本机 ↔ 47 ↔ 106 的 **RTT / jitter / 带宽**实测,写进 `覆盖网络_传输方案取舍_开放端口与自研relay_20260916.md` 的新 **§9**(含命令原文 + 数字)。 + +命令骨架: +1. 本机 → 47 / 106:`ping -n 20 `(jitter 取 `mdev` 或手算标准差) +2. 下载带宽:`curl -o /dev/null -w '%{speed_download}\n' http://<47或106>/<一个 ~50MB 文件>`(无现成文件就先在 47 临时生成,用完删) +3. 47 ↔ 106 互联:在 47 上 `ping -c 20 106.54.21.172` + 同样方式测一次 TCP 吞吐 +4. 记 **NAT / 公网可达性**结论:从本机能否直连 106 的 19000(决定"打洞"可行性) + +⛔ 只做测量 + 写 §9;**不装任何 relay、不改配置、不开端口**。**工具调用 ≤ 8 次**。 + +## 未完成 + +1. **R0-b 链路画像** ← **= 下一步,唯一动作** +2. **R0-a 判据打分表**(把 §8.2 那六条补成可打分版)→ 排 R0-b 之后 +3. **功能本体 0/3**:presence / 游戏服放 L1 / 块级内容寻址 —— 一个都没开工 +4. **S5(443/TCP 兜底)** 未做;**relay 实现未定**(P4 SSH 版判不做) +5. **本机(客户端类型节点)三项验证** 未做 +6. **未 commit**(13 改 + 1 新增;红线:未授权不得 commit/push) +7. ⏳ 待用户裁决:「自动接续」通道留不留(18:2x 已问,**未答**) + +## 关键决定(勿推翻) + +1. **S3 不回环别名、不 `portMap`** —— 实测被 sshd 静默改写;改用**实例端口区间隔离**(`w-47`=20000+ / `w-106`=21000+,避开 OS 临时段 32768-60999)—— **已上线** +2. **S2 只加 `via`**(不加 `address`);`via` 省略时 `COALESCE` 不覆盖旧值;回填 `w-47=local` / `w-106=manager-ssh` +3. `proxy.ts:109` = **发给上游 dsh 的 `Host` 头**,⛔ 永不可改(连接目标 `:136-137`) +4. **控制面已在 PG** ⇒ 不存在"改造为 PG" +5. **worker 永远只拨出、不开入站端口**;**relay 实现未定**(撤销 frp:`CVE-2026-40910` 影响 ≥0.53.0 / dashboard 默认弱口令 / **`proxyBindAddr` 默认绑公网** / 单一静态 token + frpc 明文存 / 服务端默认不强制 TLS) +6. ⛔ **P4(SSH 版中继 + 新开 32023)判不做** + +## 回滚点 + +- **P1**:`/opt/dsh/backups/lib-pre-S0-20260916-1555.tgz` → `systemctl stop dshs` → 还原 `lib` → `start dshs` +- **P2**:`via` 列有默认值 ⇒ 旧代码读 `endpoint` 不受影响,**列可留着不删** +- **P3**:删 `/etc/dshs-worker.env` 的 `DSHS_INSTANCE_PORT_BASE/SPAN` ⇒ 回 `listen(0)`(**零代码回滚**) + +## ⛔ 不要重做 + +**P1(47 对齐 `640813e`)/ P2(`via` 列 + 回填)/ P3(端口区间隔离)均已完成并验收** —— 证据在交接单 §8。 +**也不要**再论证「S3 用回环别名」(已实测证伪)、「需要改造为 PG」(已经是 PG)、「首选 frp」(已撤销)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T09-覆盖网络落地执行.md b/dsh-server-docs/交接单/archive/交接单-已完成/T09-覆盖网络落地执行.md new file mode 100644 index 0000000..8545f73 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T09-覆盖网络落地执行.md @@ -0,0 +1,255 @@ +# 交接单 · 覆盖网络落地执行(**v2** · 2026-09-16 15:2x 复核后重写) + +> **给执行会话**:按本单开工,**不读规划会话的上下文**。 +> **v2 改了什么**:新增 **§0 复核结论 + 6 条勘误**(其中 **A/B 为实测**,**改变了 S3 的做法**);更正部署面(v1 只写 106,**漏了 47**);步骤改为 **P1 对齐 47 → P2=S2 → P3=S3(改写) → P4=S4**。 +> **方案来源** = 工作区根 `会合中继拆分_取证与改造方案_20260916.md` —— 必读 **§4 + §8 + §9**。 +> **用户的验收要求(原话口径)**:改造后 **47 + 106 + 本机都能跑起来;现有功能与多用户机制正常**。 +> 分层规则见代码仓 `docs/architecture.md`(新模块必须守;`npm run check:layering` 会在 `verify` 里拦你)。 + +--- + +## 0. 本轮复核结论(2026-09-16 15:2x · 全部只读实测) + +### 0.1 已核实基线(部署前先照这个对一遍) + +| 项 | 实测值 | +|---|---| +| 本机 | HEAD `640813e`,工作区干净;`lib/net/{reachability,rendezvous}.js` 已 build(09-16 14:34) | +| 47(Manager) | `dshs` / `dshs-worker` / `dshs-pg` **全 active**;Manager 跑 `/opt/dshs/lib/cli.js`(`EnvironmentFile=/etc/dshs.env` + drop-in `dshs.service.d/cluster.conf`) | +| **47 代码版本** | 🔴 **落后于 S0** —— `/opt/dshs/lib/` **无 `net/`**;`config.js` 指纹 `91fc3756` ≠ 本机 `1c3601f7`(lib mtime 09-16 00:39) | +| 106(Worker) | `dshs-worker` **active**;`/healthz` ⇒ `{"ok":true,"hostId":"w-106","instances":0,"tunnel":{"ready":true,"ports":[19000]}}` | +| 106 代码版本 | ✅ S1 三件已上:`config.js` `1c3601f7`、`worker/tunnel.js` `5f2649b4` == 本机;`lib/net/` 无(**无需**,那是 Manager 侧文件) | +| `dsh_hosts` | `w-106` → `http://127.0.0.1:19000`(cap 2048)|`w-47` → `http://127.0.0.1:19100`(cap 1024);**共 7 列,无 `via`** | +| 用户 / 实例 | `admin@w-47`、`dbg2mx897@w-106`、`guest@w-106`、`pocuimwkrr@w-106`;⚠️ **四条实例全 `stopped`** ⇒ 验收要**主动 launch** | +| 47 sshd | `gatewayports no`|`allowtcpforwarding yes`|`permitopen any`|OpenSSH **8.0p1**(106 = 9.3p2);`lo` = `127.0.0.1/8` | +| 47 端口面 | `0.0.0.0:32022` sshd(会合)|`127.0.0.1:19000` sshd(106 隧道落点)|`127.0.0.1:19100` node(w-47 agent)|`127.0.0.1:15432` postgres | + +### 0.2 勘误(6 条 · A/B 为实测,🔴 照原方案字面做会白做或出事) + +**A 🔴 `DSHS_RELAY_LOCAL_NAMESPACE`(回环别名)实测无效 ⇒ S3 换做法。** +实测:自 106 发起 `ssh -R 127.0.0.2:19999:127.0.0.1:19000 -o ExitOnForwardFailure=yes` ⇒ 47 上落点实际是 **`127.0.0.1:19999`**(另含 `[::1]:19999`),**`127.0.0.2` 被 sshd 静默改写**,ssh 侧**零报错**(`ExitOnForwardFailure` 未触发、日志为空;测试后已清理)。 +根因:47 `sshd -T` ⇒ **`gatewayports no`**(默认)⇒ sshd 强制把远端转发绑到回环,**客户端指定的绑定地址被丢弃**。 +⇒ **S3 改为「实例端口区间隔离」**(§3 P3):同样打掉根因,且**不改 sshd、不改协议、不需要 `portMap`**。 +将来若确需回环别名,只能开 `GatewayPorts clientspecified` —— 且**只能开在新建的 relay sshd 上**,⛔ **绝不动主 sshd 32022**(那等于让持隧道密钥者可绑 `0.0.0.0`,命中 **R5**)。 + +**B 🔴 端口撞号是**真 bug**,不是"管道美化" —— 这才是 S3 的真实必要性。** +`findFreePort()`(`src/supervisor/spawn.ts:53`,唯一调用点 `src/supervisor/orchestrator.ts:595`)在 **worker 自己那台机器**上随机取端口 ⇒ 两台 worker 取到**同号**是常态;而 `tunnel.forward()` 的返回值在**两处都被忽略**(`src/worker/agent.ts:303`、`:205`)⇒ 撞号时 `-R` 失败但**静默**;Manager 仍按 `127.0.0.1:` 拨 ⇒ **打到另一个用户的实例**。w-47 的本地实例与 w-106 的隧道落点**共享 47 的 `127.0.0.1` 端口空间** ⇒ **只用两台机器就能触发**。 + +**C 🔴 部署面:v1 只写了 106,漏了 47。** +S0 的 `src/web/server.ts`、`src/supervisor/remote-spawner.ts`、`src/net/*` 跑在 **Manager(47)**;S2 的 `hostsProvider` 也在那儿。 +⇒ **v1 说的"S1 已上线"只覆盖 Worker 侧;47 至今是 S0 之前的版本**。执行时必须**双侧对齐**(§3 P1)。 + +**D ⚠️ `verify-cluster-cross.mjs` 不能照抄跑,且**有生产副作用**。** +① 默认 `MANAGER=http://127.0.0.1:13080` = **演练端口**(现役是 **3080**);`AGENT_TOKEN` 默认 `cross-machine-token`(现役 `dshs-worker-7f3a91c05e`);还需 `ADMIN_PW`(真实管理员口令)。 +② 它会 **幂等注册 `w-106` 并把容量改成 4096**、**新建 `crossuser` 用户并审批**、**在 106 上拉真实例**,可选**注册 `w-106b` 并迁移实例** ⇒ **跑完必须清理**(§5.2)。 +③ 脚本头部注释"控制面 PG 也在 106"**已过时** —— PG 现在在 47(`127.0.0.1:15432`)。 + +**E ⚠️ S4 有方案缺口:中继一旦不在 Manager 主机上,"Manager 拨落点"就断。** +隧道落点在**接收方那台机器的 `127.0.0.1`**。中继换到别的机器 ⇒ Manager 拨不到 ⇒ 需要**额外一跳**(Manager 侧向中继建出向隧道 `ssh -L`,或中继侧把落点暴露到非回环地址 + nft 收窄)。 +⇒ 本轮 S4 **只做同机 relay**(47,独立单元 / 端口 / 密钥 / 账号);异地 relay 留待定这一跳。 +⛔ **不要用方案 §4 S4 的"会合指向第二个中继实例"当通过判据** —— 那要先解决本缺口;本轮改判据(见 §3 P4)。 + +**F ⚠️ S2 减负:不要加 `address` 列。** +`endpoint` 本身就是"要拨的地址",再加 `address` = **同义双真相**(回填后必然逐字相等)。⇒ **只加 `via`**(`NOT NULL DEFAULT 'manager-ssh'`),回填用一条显式 `UPDATE`。 + +> 方案 §8 的 ①②③ **仍然有效**,其中 ① 本轮**再次确认**:`proxy.ts:109` 是**发给上游 dsh 的 `Host` 头**(dsh 的 `/api` 信任栅栏要 loopback),⛔ **绝不能改**;真正的连接目标在 **`proxy.ts:136-137`**(`host/port` 取自 `endpointFor`)。 + +--- + +## 1. 只读前置(必做,全部只读) + +1. 抢锁:`bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<会话名>"` +2. 读方案 **§4 + §8 + §9**;读 `docs/architecture.md §5`(现存 5 条违规基线,**别新增**) +3. 照 **§0.1 表**逐项复核基线(本机 HEAD|47 的 `/etc/dshs.env` + `dshs.service.d/cluster.conf`|106 的 `/etc/dshs-worker.env`|`dsh_hosts` 现值|47 端口面) +4. 远程入口:`ssh root@47.77.182.89`(22)|`ssh test106`(**必须 `-i ~/.ssh/id_ed25519_test106`**) + +--- + +## 2. 范围 + +**做**:P1 → P2 → P3(P4 视 P1–P3 结果决定是否本轮) +**不做**:⛔ 不换传输协议(WireGuard / TURN 属远期)|⛔ 不动 `proxy.ts:109`|⛔ 不动权威状态(归属 / 租约 / 骨干资格**仍只在 Manager 写**)|⛔ 不做 `DSHS_RELAY_LOCAL_NAMESPACE`(勘误 A)|⛔ 不加 `portMap`(不再改"不同号") + +--- + +## 3. 步骤(每步独立可验、独立可回滚) + +### P1 · 对齐 47 到 HEAD `640813e`(把 S0+S1 真正补上 Manager 侧) + +**为什么先做这一步**:47 现在落后一代,直接上 S2 就是"跳版部署",出问题无从二分。这一步本身**零行为变化**,风险最低。 + +1. **本机门禁**(Node **22**,24 会因 better-sqlite3 ABI 全红): + `npm run build` → `npm test`(须 **44 项 / 0 失败**)→ `npm run verify` → `npm run check:layering`(现存违规 **5 条**,**不得新增**) +2. **备份 47**(回滚点必须存在): + `ssh root@47.77.182.89 'tar czf /opt/dsh/backups/lib-pre-S0-$(date +%Y%m%d-%H%M).tgz -C /opt/dshs lib'` +3. **只传这几件**(= `git show --name-only 640813e` 的 `src/` 清单 → `lib/` 产物;同名 `.d.ts` 一起传;`lib/net/` 需先 `mkdir -p`): + ``` + lib/config.js lib/net/reachability.js lib/net/rendezvous.js + lib/supervisor/remote-spawner.js lib/web/server.js + lib/worker/agent.js lib/worker/tunnel.js + ``` + ⛔ **别整包覆盖 `lib/`** —— 会把别人直接改在服务器上的内容回退。 +4. **逐文件 md5 对账**(47 上算完与本机比,必须逐条相等;`.d.ts` 也核) +5. `systemctl restart dshs`(**会短暂中断门户**;本项目为开发环境服务器 ⇒ 直接做,动手前一句话说明在动什么) + +- **验收**:① 47 三单元 active ② 门户 `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/` = 200 ③ 106 `/healthz` `tunnel.ready=true` ④ **`admin`(w-47)与 `guest`(w-106)各自的实例页都能打开 + 工作区文件能读能写** ⑤ `ls /opt/dshs/lib/net/` 有两个文件且 md5 == 本机 +- **回滚**:`systemctl stop dshs` → 用步骤 2 的 tgz 还原 `lib` → `systemctl start dshs` + +### P2 · S2:`dsh_hosts` 增 `via` 列(打掉 C3) + +- **顺序(不能颠倒)**:**先加列(带默认值)→ 再改代码 → 最后回填** —— 任一步中断都不崩 +- **加列**:`src/db/schema.ts` 的 `MIGRATIONS` 追加 **v8**,**双方言都要写**: + - sqlite:`ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';` + - pg:同款 SQL(语法一致) +- **改代码(6 处)**: + | 文件 | 改什么 | + |---|---| + | `src/db/pg.ts:56`、`src/db/repo.ts:598` | `HOST_COLS` 末尾加 `, via` | + | `src/db/pg.ts:605-646`、`src/db/repo.ts:603-625` | upsert 带上 `via` | + | `src/db/types.ts` | `DshHost` / `UpsertDshHostInput` 加 `via?: string`(缺省语义 = `manager-ssh`) | + | `src/web/routes/admin.ts:181-203` | 注册口接受 `via`,缺省 `manager-ssh` | + | `src/web/server.ts:256-266` | `hostsProvider`:**读 `via` → `RendezvousRegistry.get(via)` → `resolve(id)` → 塞 `ClusterHost.reachability`**;`via` 未知/未设 ⇒ 回退 `parseReachability(id, endpoint)` | + | `src/net/rendezvous.ts` | `LocalRendezvous` / `ManagerSshRendezvous` 的 `addressOf` 由调用方从 `db.listDshHosts()` 建 map 传入(`resolve` 是 async,`hostsProvider` 本来就是 async ⇒ 无需改签名) | +- **回填(一条显式 SQL,幂等)**: + `UPDATE dsh_hosts SET via='local' WHERE id='w-47';` —— 判据:**Manager 与 worker 同机 ⇒ 直连 ⇒ `local`**;`w-106` 保持默认 `manager-ssh`(47 上 sshd 的隧道落点)。⛔ **别一刀切**(方案 §8 勘误 ②)。 +- **部署**:**只改 47**(S2 全是 Manager 侧代码,106 本轮无需改):传 `lib/{config,net/*,db/*,web/*}` 对账 → `systemctl restart dshs` +- **验收**: + ① 单测扩展 `test/reachability.test.mjs` ⇒ 断言「迁移前 `agentUrl`」== 「迁移后 `resolve()` 结果」**逐条相等**,两条真实行**写死**(`w-47`→`local`、`w-106`→`manager-ssh`) + ② 真机 `/api/admin/hosts` 返回带 `via`,且**不含 `agentToken`** + ③ P1 的四条业务验收重跑一遍(多用户不受影响) +- **回滚**:列有默认值 ⇒ 旧代码读 `endpoint` 完全不受影响,**列可留着不删** + +### P3 · S3(**改写**):实例端口区间隔离 + +- **目标**:消除勘误 B 那条真 bug —— 跨 worker 同号 ⇒ 47 的 `127.0.0.1` 端口空间撞号 ⇒ 静默不转发 ⇒ 拨到**别人的实例** +- **改哪些文件**: + | 文件 | 改什么 | + |---|---| + | `src/supervisor/spawn.ts:53` | `findFreePort()` → 增可选取址(区间内挑端口;区间为空 ⇒ 保持旧 `listen(0)` 行为) | + | `src/supervisor/orchestrator.ts:595` | 取端口改走区间(读 config) | + | `src/config.ts` | 新增 `DSHS_INSTANCE_PORT_BASE`(默认 `0` = 旧行为)/ `DSHS_INSTANCE_PORT_SPAN`(默认 `1000`) | +- ⚠️ **`src/worker/agent.ts:352` 不用改**(`instanceHost` 仍 `127.0.0.1`)—— 这就是本方案相对原设计的简化点;也**因此不需要 `portMap`** +- **env 分配(必须互不重叠)**:`w-47` → base **`42000`**|`w-106` → base **`43000`**|span 各 `1000` +- **部署**:47 —— `lib/{config,supervisor/*,worker/agent.js}` + drop-in 加两行 env → `systemctl restart dshs dshs-worker`;106 —— `lib/{config,supervisor/*}` + `/etc/dshs-worker.env` 加两行 env → `systemctl restart dshs-worker` +- **验收(三条端到端,缺一不可)**: + ① 跨机实例页能打开 ② 跨机工作区文件能读能写 ③ **同一时刻 47 与 106 各起一个实例** ⇒ `ss -lntp` 上 47 的 `127.0.0.1`:`w-47` 实例端口 ∈ `42000+`、`w-106` 实例端口 ∈ `43000+`,且**无重叠** +- **回滚**:删两个 env ⇒ 回 `listen(0)` 随机(**零代码回滚**) +- **不做**:`DSHS_RELAY_LOCAL_NAMESPACE`(勘误 A)|`portMap`(不再改"不同号") + +### P4 · S4(本轮收尾,可延后):中继独立成单元 + +- **做什么**:47 上新建 **`dshs-relay.service`** = **独立 sshd 实例**(独立端口如 `32023` / 独立 host key / 独立 `authorized_keys` / 独立系统账号 `dshsrelay`;**`gatewayports no` 保持不动**) +- **worker 侧只改一行 env**:`DSHS_RENDEZVOUS_URL=ssh://root@47.77.182.89:32023` +- **`via` 补一个实现**:新增 `RelayRendezvous`(id 如 `relay:47-32023`),`hostsProvider` 经注册表选到它;`w-106` 的 `via` 由 `manager-ssh` 改指它 +- **验收(三条,**已按勘误 E 改判据**)**: + ① 单中继下全链路通(实例面 + 文件面) + ② **停掉中继** ⇒ 同机形态(`w-47`)实例仍可用、跨机代理失败但**不崩** + ③ ⛔ 不判"第二个中继实例";改判:**把会合地址换回 `32022` 后,106 能重新注册并被正确解析**(本轮能验的"会合可换"就到这里) +- **回滚**:`DSHS_RENDEZVOUS_URL` 指回 `32022`(或删掉它,走 `DSHS_TUNNEL_TARGET` 兜底)+ `systemctl disable --now dshs-relay` + +--- + +## 4. 本机(**客户端类型**节点,不是 worker) + +要验的是可行性评估**缺口 1**(全案**唯一无证据**的技术点): + +1. 本机起实例(**`soft` 模式** —— Windows 无 bwrap/systemd)⇒ 实例页 200 +2. 实例内跑一次 bash 工具 ⇒ 验 dsh 在 Windows 的**沙箱后端行为** +3. 本机 ↔ 47 / 106 的**真实网络画像**(NAT 类型 / 打洞可行性 / RTT + **jitter**)⇒ 直接填"最该先测三项"里的两项 + +⚠️ ⛔ **不得把本机接进现有生产链路**(要独立形态、独立开关);本机是**唯一开发机** ⇒ 用最小配额。 + +--- + +## 5. 全局验收(缺一 = 没做完) + +### 5.1 通过项 +1. **多用户机制**:`admin`(锚 `w-47`)、`guest`(锚 `w-106`)**各自实例页 + 工作区文件都正常**(用户硬要求)—— ⚠️ 当前四条实例**全 `stopped`**,要主动 launch +2. `verify-cluster-cross.mjs` 全绿 —— **必须按勘误 D 的参数跑**: + `MANAGER=http://127.0.0.1:3080 AGENT_TOKEN=dshs-worker-7f3a91c05e ADMIN_PW=<真实管理员口令> node scripts/verify-cluster-cross.mjs`(`AGENT2` 留空 ⇒ 跳过第二台 worker 段) +3. `npm test` + `npm run verify` 全绿,且 `npm run check:layering` **无新增违规** +4. 47:`dshs` / `dshs-worker` / `dshs-pg` active;106:`dshs-worker` active +5. 每步的**回滚点**写清并**实测过**(至少回滚命令能跑) + +### 5.2 跑完必须清理(勘误 D 的副作用) +- 删掉 `crossuser*` 用户;把 `dsh_hosts` 里 `w-106` 的 `capacity_mb` 改回 **2048** +- 若跑过 `AGENT2` 段:删掉 `w-106b` 登记,并把被迁移的实例改回 `w-106` +- 自建的测试实例:`stop` 掉,别留在生产 + +## 6. 回报格式 + +**判定 → 改了什么(文件 + 部署到哪台哪个路径 + md5 对账结果)→ 验收实测输出 → 未做 / 风险 → 锁状态** +(截图或命令原文均可;⛔ 不许写"应该没问题") + +--- + +## 7. 开工前已知的坑(省你踩一遍) + +| 坑 | 事实 | +|---|---| +| 106 连不上 | 必须 `-i ~/.ssh/id_ed25519_test106`;其它 key 一律 `Permission denied` | +| 47 的 cluster 配置 | 在 **drop-in** `dshs.service.d/cluster.conf`,**不在** `/etc/dshs.env` | +| 47 的 `/opt/dshs` 是 git 仓库 | ⚠️ **但不是部署来源** —— 部署 = 本机 build 后 scp `lib/` 文件;47 上 `git log` 只有初始提交 + 一堆 `M` ⇒ ⛔ 别在 47 上 `git pull`。`git -C /opt/dshs` 会报 `dubious ownership`,要加 `-c safe.directory=/opt/dshs` | +| 47 的 PG 查询 | **只能 TCP**:`PGPASSWORD=dshs_cluster_2026 psql -h 127.0.0.1 -p 15432 -U dshs -d dshs`;`sudo -u postgres psql` **不通**(unix socket 未启用) | +| `C4` 其实不成立 | 106/47 的 worker env **都没有** `DSHS_TUNNEL_STATIC_PORTS` ⇒ 隧道**不含控制面 PG** ⇒ 回滚面比方案预估小 | +| 本机 git | `refs/remotes/**` 写不进去 ⇒ `git fetch` **静默失败** ⇒ 分叉判定必须 `git ls-remote` 取裸 sha | +| 本机 bash | PATH 被 shim 重置 ⇒ 先 `export PATH=/usr/bin:/bin:/c/Windows/System32:/c/Windows:$PATH`;⛔ 进程替换 `diff <(a) <(b)` **不可用** | +| 本机跑测试 | 必须 **Node 22**(Node 24 会因 better-sqlite3 ABI 全红) | + +--- + +# 8. 执行记录(2026-09-16 15:5x–16:2x · **P1/P2/P3 已完成并验收**) + +> 执行会话:`覆盖网络-落地执行`。**P4 未做** —— 理由见 §8.4(命中 **R5**,需授权)。 + +## 8.1 ✅ P1 · 47 对齐到 `640813e`(Manager 侧补上 S0+S1) + +- **前置取证**:全量产物指纹对账 ⇒ 47 与本地 HEAD 的差异**恰好只有 S0+S1 那 7 个文件**,无其他漂移。 + ⚠️ 踩坑记录:本机 `md5sum` 输出 `hash *path`(二进制模式)、远端是 `hash path`(文本模式)⇒ 直接 `diff` 会得到"**59 个文件全不同**"的**假象**。判据必须先归一化路径字段。 +- **备份**:`/opt/dsh/backups/lib-pre-S0-20260916-1555.tgz` +- **部署**:7 个 `.js` + 同名 `.d.ts`/`.js.map`(共 21 件)⇒ 47 `/opt/dshs/lib/`,属主/权限 `197108:197121 644`(与现存一致) +- **验收实测**:7 件 md5 **逐条相等**|`dshs/dshs-worker/dshs-pg` active|门户 200|`w-47` 与 `w-106` agent 均可达(106 经隧道,`tunnel.ready=true`)|日志 0 错误 +- **业务验收(多用户,R4 临时会话)**: + · `admin`(w-47)实例 `running` + 实例页 **200** + · `guest`(w-106)launch 成功 + **跨机实例页 200** + `/api/desktop/tree` 通 + `mkdir` 落盘已核实**写在 106 的盘上** +- **痕迹清理**:临时会话 `DELETE 2`⇒余 0|`/tmp` 临时件已删|guest 实例还原为 `stopped` + +## 8.2 ✅ P2 · `dsh_hosts` 增 `via`(打掉 C3) + +- **改 7 处**:`db/schema.ts`(**v8 迁移**,双方言同写)|`db/types.ts`(`DshHost.via` + `UpsertDshHostInput.via?` + `toDshHost`)|`db/pg.ts` / `db/repo.ts`(`HOST_COLS` + upsert)|`web/routes/admin.ts`(注册口接受 `via`)|`web/server.ts`(`hostsProvider`:**读 `via` → `RendezvousRegistry` → `resolve()` → `ClusterHost.reachability`**)|`test/reachability.test.mjs`(+2 条判据) +- **两处按勘误 F 落地**:**不加 `address` 列**(`endpoint` 本身就是要拨的地址,加列=同义双真相);**`via` 省略时不覆盖已有值**(`COALESCE(?, dsh_hosts.via)`)—— 否则一次不带 `via` 的 join 会把回填好的 `local` 冲回默认 +- **回填(显式、幂等)**:`UPDATE dsh_hosts SET via='local' WHERE id='w-47';` ⇒ 结果 `w-47=local` / `w-106=manager-ssh` ✅ +- **门禁**:`build` rc=0|单测 **46 项 / 0 失败**|`check:layering` **无新增违规**|迁移后 `schema_migrations` = 1–8,`dsh_hosts` 列含 `via`(默认 `manager-ssh`) +- **验收实测**:`GET /api/admin/hosts` ⇒ `"via":"manager-ssh"` / `"via":"local"`,**且不含 `agentToken`**(只有 `hasToken`)|两条路径实例页均 200(w-47 同机 / w-106 跨机)|日志 0 错误 +- **补做的一处**:v1 交接单只要求"API 返回带 via",实测发现 **GET 列表原本不下发 `via`** ⇒ 已补(否则运维看不见 = 加列白加) + +## 8.3 ✅ P3(改写版)· 实例端口区间隔离 + +- **按勘误 A 执行**:⛔ 未做 `DSHS_RELAY_LOCAL_NAMESPACE`(实测被 sshd 静默改写)|⛔ 未加 `portMap` +- **区间选定**(避开 OS 临时端口段 `32768-60999`):`w-47` = **20000**, span 1000|`w-106` = **21000**, span 1000 +- **改 4 处**:`config.ts`(`instancePortBase/Span` + `toPortNumber` 防 NaN)|`supervisor/spawn.ts`(`findFreePortInRange` + `findInstancePort`)|`supervisor/orchestrator.ts`(取端口改走区间)|新增 `test/instance-port.test.mjs`(5 条,已挂进 `npm test`/`verify`) +- **部署**:**两台**(47 `/opt/dshs`、106 `/opt/dshs-cluster`)各 3 件,md5 **逐条相等**;env 加在各自 `/etc/dshs-worker.env`;备份 `lib-pre-S3-*.tgz` + `dshs-worker.env.bak-*` +- **门禁**:`build` rc=0|单测 **51 项 / 0 失败**|`check:layering` 无新增违规 +- **验收实测(缺一不可的三条全过)**: + ① **两台各起一个实例** ⇒ `w-47` 落 **20000**、`w-106` 落 **21000** + ② 47 上 `127.0.0.1:20000`(node 直连) 与 `127.0.0.1:21000`(sshd 隧道落点) **互不重叠** ⇒ 原先的撞号根因消除 + ③ 实例页 `admin`(同机) **200** + `guest`(跨机) **200**,跨机文件面通 +- **状态还原**:`guest` 回 `stopped`(与开工前一致);**`admin` 实例保持运行**(与开工前一致)|临时会话已删|40 端口重启后旧端口(42185/37523/35765)**全部释放** + +## 8.4 ⏳ P4 未做 —— 命中 **R5**,需要一句授权 + +**做什么**:47 上新建独立 `dshs-relay.service`(独立 sshd、独立端口 **32023**、独立 host key、独立 `authorized_keys`、非 root 账号 `dshsrelay`),106 的 `DSHS_RENDEZVOUS_URL` 指过去。 + +**为什么停在这里**:它要在**公网面新开一个 SSH 监听口(32023)** —— 属 **R5「权限只准收窄」**的反面(新暴露面),必须先知会。其余三项(P1–P3)都是"收窄/修复"性质,故直接做了。 + +**P4 的收益与代价**(供判断): +- 优点:与宝塔面板共用的主 sshd `32022` 解耦(面板改配置不再波及覆盖网络)|隧道密钥从 `root` 的 `authorized_keys` 挪到独立文件与非 root 账号|是"中继可多实例/可换机"的前置 +- 缺点:**新增一个公网 SSH 入口**(尽管只允许密钥 + `restrict,port-forwarding`)|受**勘误 E** 限制,异地中继仍差"一跳"未设计 ⇒ 本轮只能验同机形态 + +**回滚**:`DSHS_RENDEZVOUS_URL` 指回 `ssh://root@47.77.182.89:32022` + `systemctl disable --now dshs-relay`。 + +## 8.5 本轮顺带确认的两件事(未改,仅记录) + +1. **控制面已在 PG 上**:`DSHS_DEPLOY_MODE=cluster` + `DSHS_DB_URL=postgres://…:15432/dshs`;dshs 进程**未打开任何 `dshs.db`**(该文件 mtime 停在 09-15 17:51)= 纯回滚副本。⇒ **不存在"需要改造为 PG"这件事**。 +2. **隧道密钥已受约束**:47 的 `authorized_keys` 里 `dshs-tunnel-106to47` 那行带 **`restrict,port-forwarding`** ⇒ 该密钥**只能做端口转发、拿不到 shell**(另两把是部署键与个人键,无约束属预期)。 + ⚠️ **`dsh_instances.status` 不反映实时状态**:实测 admin 的实例在跑,DB 里却是 `stopped`。运行态以 **worker agent 的 `/status/:userId`** 为准(Worker 按设计不写控制面数据)—— 排查时别拿这张表的 `status` 当事实。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T10-网抽象与地址规划R6.md b/dsh-server-docs/交接单/archive/交接单-已完成/T10-网抽象与地址规划R6.md new file mode 100644 index 0000000..8b14b56 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T10-网抽象与地址规划R6.md @@ -0,0 +1,203 @@ +# 交接单 · 覆盖网络主线 ②「网抽象 + 地址规划 + 引导」(**执行会话专用** · 2026-09-16 23:4x) + +> **口径指纹(必须校验)**:`tail -n +4 交接单_网抽象与地址规划R6_20260916.md | md5sum` = `0d3f1f305e9e6219afa97cdfecff05ba` +> 不符 ⇒ **停下,只报告「口径已更新,需重新接续」**。 + +--- + +## 执行进度(⛔ 下一棒先读这一段,别从头重做) + +| 步 | 状态 | 证据 / 落点 | +|---|---|---| +| **Step 1 · P0-1 网抽象** | ✅ **已完成并端到端验收**(2026-09-17 00:0x–00:15) | 新增 `src/net/relay/network.ts` + `test/overlay-network.test.mjs`(7 条);会话/端点表按逻辑名索引;DIAL 双门(本网白名单 + 同网);`dsh_hosts.network_id`(SQLite V9 / PG V9 已迁);单测 38/38;产物铺 47 并 md5 逐同 | +| **Step 2 · P0-2 引导三级链** | ✅ **已完成并端到端验收**(2026-09-17 06:45–07:05) | 新增 `src/net/relay/directory.ts` + `src/web/routes/overlay.ts` + `test/overlay-bootstrap.test.mjs`(9 条);目录端点 `GET /dshs-overlay/bootstrap`(Ed25519 签名,`no-store`);客户端取址 = env > 缓存 > 种子(**origin 优先取缓存里的 `bootstrap[]`** ⇒ 引导地址可在线轮换);47 已清 `DSHS_RELAY_URL` **仍正常起来**;单测 47/47;六件产物 md5 与 47 逐同 | +| **Step 3 · P0-3 名字解析与授权** | ✅ **已完成并端到端验收**(2026-09-17 07:2x–07:4x) | 逻辑名 `/` 成为 `place`(`parseReachability`)/ `resolve`(`Rendezvous.resolve`)的**唯一入参**;控制面**所有内存键**(地址 / via / 端口 / relay 端点 / 拨号口池)改逻辑名;`Reachability` 加 `networkId`;新增 `assertSameNetwork`;`RelayDialer` 加**跨网失败关闭**(带日志);`agentBaseUrlOf` 在 `via=relay` 时**禁回落 endpoint**;relay 跨网拒绝**对外统一 `target-offline`**(不泄露对端清单,区分只留服务端日志)。新增 `test/overlay-auth.test.mjs`(7 条);单测 **64/64** | + +> ✅ **本单三步已全部执行完毕(2026-09-17)**。下方 §4/§5 保留为**当时的规格**(回看用); +> 下一步的依据不再是本单 —— 见 `接续入口_覆盖网络线_20260916.md` 的"下一棒"。 + +⚠️ 指纹已随本段更新(改了正文 ⇒ 指纹连带);`接续入口_覆盖网络线_20260916.md` 的引用行已同步。 + +--- + +> **上游依据(⛔ 先读它们,别凭本单复述 —— 本单只做"收敛成可执行"这一步)** +> - 主线与优先级:`覆盖网络_应用场景与待完善清单_20260916.md` **§四(P0-1/2/3)** + **§五** +> - 三条 P0 的设计推演(**实体在这里**):`覆盖网络_问题逐条推演与解决方案_20260916.md` **§A1 / §A2 / §A3** +> - 三概念拆分(接入 / 成员 / 可见):`覆盖网络_骨干层方案_20260916.md` **§3.1** +> - ① 的落地事实(本单的**起点**,含回滚表):`交接单_relay落地R2-R4_20260916.md` **§9 / §10 / §11** + +--- + +## 0. 判定:这一单在解决什么 + +**主线 ① 已完工**(`会合中继拆分 S0–S4` + `R5 会合可换机`,见上表第 4 行那份单子)⇒ 机制层已经"能用"。 +**但机制层有一个结构性缺口**:relay 把**平台自己的 Worker 隧道**和**未来的用户设备**放进**同一个扁平 `hostId` 命名空间** +(`src/net/relay/server.ts` 的 `hostId` 全局唯一、无网络维度;全仓 `grep -ri networkId|tailnet` = **0 命中**)。 +⇒ 今天只有 1 个用户、1 个网络,问题不显形;**一进第二类节点就会变成"一张巨网 + 靠 ACL 兜"**, +与项目"**权限只准收窄**"直接冲突(这正是 `清单 §四 P0-1` 的判定)。 + +**本单要做的三件事**(= `清单 §四` 的 P0-1/2/3,别扩大): +1. **P0-1 网抽象**:`network_id` 成为**结构性隔离**的维度(每用户一张网,不是一张巨网 + ACL)。 +2. **P0-2 首次入网引导**:三级引导链(内置种子 → 签名目录 → 离线降级),且**引导地址可在线轮换**。 +3. **P0-3 地址规划与名字解析**:逻辑名 `/` + 按网络授权。 + +⛔ **本单明确不做**:应用层 / 房间层 / presence(`清单 §五` 排在第 **7** 步)、内容分发、游戏服、 +虚拟网卡(L3)—— 理由见 §3 的 **D1**。 + +--- + +## 1. 只读前置(开工前逐条跑,全绿才动代码) + +```bash +# 1) 指纹(不符 ⇒ 停) +tail -n +4 交接单_网抽象与地址规划R6_20260916.md | md5sum + +# 2) 抢全局执行锁(抢不到 = 有会话在跑 = 停手,R9) +bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<你的会话名>" + +# 3) 状态快照(1 次调用顶十几轮探索;⛔ 跑它之前别 Glob/Grep 全库) +"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" \ + "E:/ProgramData/AI技能/aliyun-dsh-server/state.py" + +# 4) 起点事实复核(① 的终态必须仍然成立,任一条不符 ⇒ 先查为什么,别在本单里顺手修) +ssh root@47.77.182.89 'systemctl is-active dshs dshs-relay; curl -s -m 8 127.0.0.1:20080/status | python3 -c " +import json,sys; d=json.load(sys.stdin) +print(\"sessions:\", [(s[\"hostId\"], s[\"ports\"]) for s in d.get(\"sessions\", [])]) +print(\"dialers:\", d.get(\"dialers\"))"' + +# 5) 改哪些文件的现状(本单要碰的 3 个接缝) +grep -n "hostId" D:/github/dsh_shenxian/src/net/relay/server.ts | head -20 # 扁平命名空间的现场 +grep -n "relayUrl\|relayStatusUrl\|relayDialHost" D:/github/dsh_shenxian/src/config.ts +``` + +--- + +## 2. 已定项(**我的技术决策 —— 已定,可推翻**;⛔ 不要再拿去问用户) + +### D1 · ② 阶段**不引入虚拟网卡 / L3 地址** +沿用 `hostId` 命名 + **wss mux 流转发**(R0–R5 已跑通、零新增公网口、**不需要管理员权限**)。 +- **为什么**:L3(TUN)会立刻带进三个我们自己文档已经点名的坑 —— + ① 虚拟网卡驱动**需要管理员权限**(`清单 §四 P1` 明列为 **R5 门禁**); + ② **中国移动等运营商的大内网正是 `100.64.0.0/10`**,而我们设备池里有 **200 台 CGNAT + 150 台移动网** + (`§A3` 推演)⇒ 覆盖网地址若落进该段,会出现**宿主路由冲突**、表现是"**部分节点时通时不通、极难排查**"; + ③ MagicDNS / split DNS 有**覆盖用户系统 DNS** 的风险(`§A3`)。 +- ⇒ **本单不碰 `100.64.0.0/10`、不自建 DNS、不改用户解析**。P0-3 在本阶段收敛为"**逻辑名 + 授权**"; + **物理地址段问题整体推迟到将来的 L3 专项**,前置约束已在 §6 落笔(别丢)。 + +### D2 · 网络标识用**租户维度的显式列**,不新造体系 +- `network_id` 的取值:**运维网固定 `ops`**(47 / 106 / 未来的中继与骨干);**用户网 = `u:`**(该用户全部设备)。 +- 节点逻辑名 = **`/`**;relay 侧**按网络授权**(`ops` 只拨 `ops`;`u:X` 只拨 `u:X`)。 +- **结构性隔离 > 策略性隔离**(`§A1` 的关键判断):**每用户一张网**,不是"一张巨网 + ACL"。理由:写错一条 ACL 就泄露,而结构性隔离**不可能越界**。 +- ⚠️ `§A1` 明写「**要在第一次落地时就分开,不能等**」⇒ 本单必须**真落地**,只写文档不算完成。 +- ⚠️ 与既有判据一致:**会合/中继可多实例(数据面),成员资格只能由控制面签发**(`骨干层方案 §3.2`)。 + +### D3 · 引导走三级链,**种子复用现有域名**(不新增域名成本) +- ① **内置种子**:常量位 = `https://alotbuy.com/dshs-relay`(已持证书);**同时留出第二地域的常量位但不填**。 +- ② **签名目录**:控制面下发(签名 + 版本号),客户端缓存,按 `update_frequency` 刷新。 +- ③ **离线降级**:缓存过期仍可用(只影响**新节点加入**,不影响**已建连接**)。 +- 🔑 **`§A2` 的关键设计要求(不做会成灾)**:目录里**必须**带 `bootstrap[]` 字段, + **引导地址要能通过已建立的连接在线下发** —— 否则将来换域名/换机器 = **所有客户端必须升级重装**。 + +### D4 · 只做**逻辑名**,不做 DNS / 不下发对端清单 +- 连接授权按 `network_id` 判;**接入与可见解耦**(`骨干层方案 §3.1`:可借道转发,但不暴露对端清单)。 + +--- + +## 3. 范围 + +**会碰**(预计 6–9 个文件,全部 ≤10 ⇒ 不受 R7 批量写入约束): +`src/config.ts`(+ `overlayBootstrapSeeds` / `overlayNetworkId`)|`src/net/relay/wire.ts`(HELLO 加 `network`)| +`src/net/relay/server.ts`(按网络授权 + 目录签名)|`src/net/relay/client.ts` / `dialer.ts`(带上自己的网络)| +`src/web/server.ts`(`/dshs-overlay/bootstrap` 只读端点)|DB 迁移(`dsh_hosts` 加 `network_id`)|新增 `test/overlay-network.test.mjs`。 + +**⛔ 不许碰**:`@deepseek-ai/dsh` 主程序与缓存(R2)|client bundle 的 `exports.default`(R3)| +relay 的既有帧语义(只能**加**帧,不能改旧帧)|`32023` / SSH 版中继(已判「不做」)| +`100.64.0.0/10`(D1)|应用层 / 房间层(§0)。 + +--- + +## 4. 步骤(每步单独可回滚,**做完一步就停下来报一次**) + +### Step 1 · P0-1 网抽象(`network_id` 落地) +1. `dsh_hosts` 加列 `network_id text not null default 'ops'`(**运维网是现网事实** ⇒ 存量行天然正确)。 +2. relay `HELLO` 帧加 `network` 字段(**只加字段**);relay 侧由 `dialers: Set` 升级为 + `dialers: Map>` 语义(**权限只收窄**:默认拒绝,命中才放行)。 +3. Manager 的 `DSHS_RELAY_DIALERS` 由「扁平 hostId 列表」改为「`network:hostId` 列表」,**旧值照旧可用**(过渡期不破坏现网)。 +4. **判据(这一步的核心)**:`ops/manager` 仍能拨 `ops/w-106`;构造一个 `u:test-network` 的注册方, + 断言它**拨不动** `ops/w-106`(`dialer-not-in-network`)。 + ⇒ **把"不能到达"变成可回归断言**(这就是 `§A1` 说的"ACL tests 进 CI"的等价物)。 + +### Step 2 · P0-2 引导(三级链) +1. 新增**只读**端点 `GET /dshs-overlay/bootstrap`:返回 `{version, relays[], bootstrap[], sig}`, + `bootstrap[]` = **可轮换的引导地址清单**(D3 的 🔑)。 +2. `config.ts` 加 `overlayBootstrapSeeds`(默认 `['https://alotbuy.com/dshs-relay']`,第二地域留空)。 +3. 客户端取址顺序:**env 显式 > 缓存目录(未过期)> 内置种子**;任一成功即写缓存。 +4. **判据**:清空 env + 清缓存启动 ⇒ 仍能起来(走种子);**改目录里的 `bootstrap[]`** ⇒ + 新会话读到新值(**不重装、不升级**);签名不对 ⇒ **失败关闭**(不得静默接受)。 + +### Step 3 · P0-3 名字解析与授权 +1. 节点逻辑名统一为 `/`(`place/resolve` 的唯一入口各改一处,别在调用方拼)。 +2. 跨网络访问**在 relay / 控制面被拒**(⛔ 不许靠客户端自觉)。 +3. **判据**:`u:A` 的节点看不到、也到不了 `u:B` 的节点;`ops` 节点对用户网络**默认不可见**。 + +--- + +## 5. 验收(命令级,缺一条不算完成) + +| # | 判据 | 期望 | +|---|---|---| +| 1 | `node --test test/relay.test.mjs test/remote-spawner.test.mjs test/remote-user-fs.test.mjs test/overlay-network.test.mjs` | 全绿(**含 ① 的 T18/T19 与 A1 的 U1–U8**,⛔ 一条都不许退化) | +| 2 | `node scripts/check-layering.mjs` | **无新增违规**(基线 5 条) | +| 3 | `ops/manager` → `ops/w-106` 控制面 + 实例面 | 全通(R5 终态不退化) | +| 4 | `u:test-network` → `ops/w-106` | **被拒**,且拒绝点是 relay(有日志),不是客户端 | +| 5 | 清 env + 清缓存冷启动 | 经**内置种子**取到目录并连上 | +| 6 | 目录里 `bootstrap[]` 改值 | 新会话读到新值(**不重装**) | +| 7 | 门户 + 实例页 | 仍 `200`(用户可见面不受影响) | +| 8 | 公网暴露面 | **零新增**(`ss -lntH` 对比基线;relay 仍只绑 `127.0.0.1:20080`) | + +--- + +## 6. 回滚(分层,任一层可单独退) + +| 层 | 动作 | 影响 | +|---|---|---| +| 代码 | `scp` 回 `/opt/dshs/lib-bak-*` 对应文件 ⇒ `systemctl restart dshs` | 回到本单之前 | +| relay | `DSHS_RELAY_DIALERS` 改回扁平 `hostId` 列表 ⇒ `restart dshs-relay` | 网络维度失效,**行为回到 ① 终态** | +| DB | `dsh_hosts.network_id` 列**保留但不再读**(⛔ **不要 drop**:现网列删除不可逆) | 无影响 | +| 端点 | `/dshs-overlay/bootstrap` 直接下线路由 | 客户端回落到 env 取址(= 今天行为) | + +> 📌 **L3 专项的前置约束(将来谁做谁先读)**:① 虚拟网卡需管理员权限 ⇒ **命中 R5,先出权限影响评估**; +> ② **避开 `100.64.0.0/10`**(中国移动大内网就是它,本池 200 台 CGNAT + 150 台移动网); +> ③ 避开 `10/8` · `192.168/16` · `172.16/12`;④ `base_domain` 必须与 `server_url` 域名**不同**; +> ⑤ 默认**不覆盖**用户系统 DNS(`magic_dns: false` + 客户端 `--accept-dns=false`)。 + +--- + +## 7. 残留与已知项(本单**不**解决,别顺手做 —— R7) + +- `state.py` 的锁判据**已于 2026-09-16 修**(原把 `.exec-lock` 目录当文件读 ⇒ 恒报"空闲")—— 复验:持锁时运行必须报**占用**。 +- 腾讯云安全组 `32022` 放行规则仍在(本机已无监听)⇒ 需控制台凭据,**不属本单**。 +- `覆盖网络_瓶颈落地方案 §3` 标题的 `10.8 GB` 笔误(实为 **10.8 MB/台**)⇒ `清单 §四 P2`,**不属本单**。 + +--- + +## 8. 回报格式(每步做完照这个写,别自由发挥) + +``` +## Step N · +- 改动:<文件:行 级清单>(新增/修改分开写) +- 判据:<命令> ⇒ <实测输出原文> +- 未过 / 存疑:<逐条,没有就写"无"> +- 回滚:<本步的退法,一句话> +- 纪律:未 commit / 未 push;锁 <持/放>;工具调用 N 次 +``` + +--- + +## 9. 纪律(沿用本线惯例) + +- ⛔ **不 commit、不 push**(未获授权);改完只铺 `47` 的 `/opt/dshs/lib/` + 重启 `dshs`。 +- 动手前抢全局执行锁,**做完立刻释放**(带锁结束回合 = 把所有人挡在门外)。 +- 每步 **只做一件事**;发现的额外问题**先报告后动手**(R7)。 +- ⛔ **不要回头问"要不要部署"** —— 部署是本 lane 内的执行细节,直接做,动手前一句话说明。 +- ⛔ 完成任一段后**不要**把"下一棒"写成应用层 / presence(`清单 §五` 排在第 7 步)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T11-relay落地R2-R4.md b/dsh-server-docs/交接单/archive/交接单-已完成/T11-relay落地R2-R4.md new file mode 100644 index 0000000..7235c48 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T11-relay落地R2-R4.md @@ -0,0 +1,488 @@ +# 交接单 · relay 落地 R2–R5(**执行会话专用** · 2026-09-16 20:5x 开,23:0x R5 收口) + +> **口径指纹(必须校验)**:`tail -n +4 交接单_relay落地R2-R4_20260916.md | md5sum` = `b110b5c4e3eb8afcac08af537d333c99` +> 不符 ⇒ **停下,只报告「口径已更新,需重新接续」**。 +> 📌 **状态:R2 → §9 | R3 Step 0 → §10.4 | R3 本体 + R4 → §10.5 | R5 → §11**(全部已执行完毕)。 +> +> **怎么用**:先跑工作区根 `state.py` ⇒ 按「覆盖网络线」那一行定位;**本单是唯一执行依据**, +> ⛔ 不读其它会话上下文、⛔ 不 Glob/Grep 全库探索。 +> **一轮只做 §3「下一步」那一件事**,做完即停并回报;⛔ 不许顺手做 §9 或别的线。 + +--- + +## 1. 已完成(**不要再做**,只作背景) + +| 步 | 内容 | 证据 | +|---|---|---| +| R0 | 判据打分表 + 链路画像 + relay 定案 | 方案文档 §9 / §10 | +| R1 | 自研 relay 最小闭环(`src/net/relay/`) | 方案 §11;47⇄本机跑通 | +| R1.5 | 韧性(启停/网络变化/中断/异常/时钟)+ 准入与选点 | 方案 **§12 / §13**;单测 **15/15**、全量 verify **80/79**、47 真机全绿 | +| **R2** | **relay 常驻 47 + nginx 443 暴露 wss** | ✅ **2026-09-16 21:0x 已执行完毕** ⇒ 见本单 **§9**;命令原文级证据 = 方案 **§14.2** | + +- 代码仓 `D:/github/dsh_shenxian`(**未 commit**):`src/net/relay/`(`wire/server/client/keys/placement/rendezvous/index/main`)+ `test/relay.test.mjs`; + 改动仅 `src/net/reachability.ts` 加 `VIA_RELAY`、`package.json` 测试列表加一项。 +- 分层检查:`node scripts/check-layering.mjs` **无新增违规**(relay 属③能力层)。 +- **四条真机硬结论**(改代码前必读,方案 §12.5):① 重试定时器**不能 `unref`** ② 停机必须 `closeAllConnections` + 硬兜底 ③ 优雅重连用**时窗**不用次数 ④ 满载是唯一硬门、已在册 host 重连优先。 + +--- + +## 2. R2 内容(✅ **已执行完毕,⛔ 不要再做** —— 结果见 §9,下一棒直接看 §10) + +
展开 R2 原指令(历史记录) + +R2 = 让 relay 在 47 上**常驻**并**经 nginx 443 暴露 wss** + +两个小步,**各自可独立验收、独立回滚**;R2-a 失败不影响 R2-b,反之亦然。 +⚠️ 全程**不动 SSH 路径**(`manager-ssh` 照旧在跑),所以 **data plane 零风险**。 + +### R2-a 常驻(relay 服务端) + +1. 只读前置:`command -v node`(取**绝对路径**,systemd 不吃 PATH)、`ss -lntp | grep 20080`(应为空)。 +2. 部署产物:`D:/github/dsh_shenxian/lib/net/relay/` ⇒ 47 的 **`/opt/dsh-relay/lib/net/relay/`** + (⛔ **不碰 `/opt/dshs/**`**;在 `/opt/dsh-relay/` 下放 `{"type":"module"}` 的 `package.json`) +3. 密钥:生成**每 worker 一密钥**(`openssl rand -hex 32`),落 **`/etc/dshs/relay-keys.json`**(`chmod 600`),格式: + `{"w-47":"<64hex>","w-106":"<64hex>"}` —— ⛔ 不用共享 token。 +4. 单元 `/etc/systemd/system/dshs-relay.service`(全文如下,**`ExecStart` 里的 node 换成第 1 步取到的绝对路径**): + +```ini +[Unit] +Description=dshs relay (loopback-only WebSocket relay for the DSH overlay network) +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/dsh-relay +ExecStart=/usr/bin/node /opt/dsh-relay/lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 --max-hosts 0 +Restart=always +RestartSec=1 +TimeoutStopSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=dshs-relay + +[Install] +WantedBy=multi-user.target +``` + +5. `daemon-reload` + `enable --now dshs-relay`。 + +### R2-b 入口(唯一动门户的一步) + +6. 只读前置:`nginx -T | grep -n 'dshs-relay'`(应为空)、定位承载 dsh 门户的 **443 server 块**所在 conf + (候选:`/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`;`0.catchall-443.conf` 是 default_server,**别改它**)。 +7. **备份**:`cp .bak--pre-relay`(照该目录既有 `.bak-*` 命名惯例)。 +8. 在该 server 块内加**一个** location 并 reload(`nginx -t` 必须先过): + +```nginx + # ── DSH 覆盖网络中继(自研 relay)──────────────────────────────── + # 只在**既有 443 server 块**里加一个 location:不新增监听口、不新增证书、不动门户其它路径。 + # `/status` 不在此前缀下 ⇒ 不会被代理出去(relay 端也只认 RELAY_PATH 前缀)。 + location /dshs-relay { + proxy_pass http://127.0.0.1:20080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Host $host; + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; + proxy_buffering off; + } +``` + +> `map $http_upgrade $connection_upgrade` **已在 `nginx.conf:321`**(R0 已取证)⇒ 直接复用,不要新写 map。 + +
+ +--- + +## 3. R2 原验收表(历史记录;实测结果见 §9) + +
展开 + + + +| 步 | 命令(原文) | 期望 | +|---|---|---| +| R2-a | `systemctl is-active dshs-relay` | `active` | +| R2-a | `ss -lntp \| grep 20080` | 只有 `127.0.0.1:20080`(**不是** `0.0.0.0`) | +| R2-a | `curl -s 127.0.0.1:20080/status \| head -c 200` | 含 `"capacity"` | +| R2-a | 本机 `curl -s -m 5 http://47.77.182.89:20080/status` | **不可达**(`rc≠0`)⇒ 零新增公网口 | +| R2-a | `journalctl -u dshs-relay -n 5 --no-pager` | `listening ws://127.0.0.1:20080/dshs-relay (loopback only)` | +| R2-b | `nginx -t` | `syntax is ok` | +| R2-b | `curl -i -s -N --max-time 6 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://dsh.alotbuy.com/dshs-relay \| head -3` | `HTTP/1.1 101 Switching Protocols` | +| R2-b | `ss -lntp \| awk '{print $4}' \| sort -u` 前后对比 | **无新增 `0.0.0.0` / `*` 监听** | +| **端到端** | 本机跑 client 经 `wss://dsh.alotbuy.com/dshs-relay` 拨出(用 R1 的方式:`node lib/net/relay/main.js --client --url wss://… --host local-r2 --keys-file <本地 keys> --ports 20099`) | 日志出现 `registered host=local-r2`;47 上 `curl 127.0.0.1:20080/status` 的 `online` 含 `local-r2` | + +--- + +
+ +## 4. 回滚(每步秒级) + +- **R2-b**:`cp .bak-… ` → `nginx -t` → `nginx -s reload`。 +- **R2-a**:`systemctl disable --now dshs-relay` + `rm -f /etc/systemd/system/dshs-relay.service` + `systemctl daemon-reload`; + 产物目录 `/opt/dsh-relay` 可留可删(不占端口即无害)。 +- 两步都回滚后 ⇒ 与本单之前状态**完全一致**(SSH 路径从头到尾没动过)。 + +--- + +## 5. 边界与红线(越界即停手) + +- ⛔ **不 commit / 不 push / 不 git add**。 +- ⛔ 不碰 `/opt/dshs/**`、不动 `dshs.service`(Manager 主服务)、不动 443 server 块的 `server_name` / 证书 / 其它 location。 +- ⛔ 不新增任何 `0.0.0.0` 监听;不安装任何第三方软件(relay 是自研、零新增依赖)。 +- ⛔ 不改 `dsh_hosts.via`(那是 R3)⇒ 本轮**必须**让 `manager-ssh` 原样在跑。 +- ✅ 允许:写 `/opt/dsh-relay/**`、`/etc/dshs/relay-keys.json`、`/etc/systemd/system/dshs-relay.service`、备份并改**一个**站点 conf、`nginx -t` + reload、 + `systemctl` 操作 `dshs-relay`。 +- **成本纪律**:工具调用 **≤ 25**;批量活写脚本一次跑完;大输出先 `> /tmp/x.txt` 再 `sed -n` 读关键行。 +- 🔴 临时进程/目录**必须回收**(R1.5 教训:残留一个 relay 占 20080 ⇒ 后续 `EADDRINUSE`);收尾用 pidfile 或 `systemctl`,⛔ **不要 `pkill -f`**(pattern 会命中 ssh 自身命令行 ⇒ 自杀)。 + +--- + +## 6. 回报格式 + +`判定(1–3 行)` → `实测证据(命令原文 + 输出摘录)` → `未解决/阻塞(诚实标注,不写"已处理")` → `我接着做什么(陈述句)`。 +⛔ 结尾**不许**用征询句("要不要我继续")。 + +--- + +## 7. R3 / R4 预告(**本单不做**,只为让下一棒知道路线) + +- **R3**:worker 侧常驻(47 的 `w-47` 用 `local` 不动;106 的 `w-106` 经**宝塔 MCP** 部署 client 常驻)+ `dsh_hosts.via='relay'`(env 级、可秒回滚)。 +- **R4**:观察一轮 ⇒ 下线 sshd 反向隧道、收回 47 上那条 `authorized_keys`、**回收 `32022`**(净减一个公网暴露口)。 + +--- + +## 8. 关键事实速查(免得重新探索) + +- 47 = `47.77.182.89`(Manager,控制面 `dshs.service`);106 = `106.54.21.172`(Worker,宝塔 MCP `mcp__baota-mcp-106.54.21.172`)。 +- 47 **无本机防火墙**(`nft INPUT policy accept`)⇒ 任何 `0.0.0.0` 监听立刻公网可达 ⇒ **零新增公网口是硬约束**。 +- relay 产物在代码仓 `lib/net/relay/`(`npm run build` 产出);`src/net/relay/` 是源码,同层还有 `reachability.ts`(`VIA_RELAY='relay'`)/ `rendezvous.ts`。 +- 47 上 `/opt/dshs/lib/`(勿动);106 上 `/opt/dshs-cluster/lib/`。47 部署惯例 = 本机 build 后 scp `lib/`。 +- 全局执行锁:`bash D:\github\dsh_shenxian\dsh-server-docs\scripts/handoff-guard.sh --claim-exec "<会话名>"`;**做前抢、做完放**。 + +--- + +## 9. ✅ R2 实测结果(2026-09-16 21:0x · **已执行完毕**) + +**判定**:R2-a + R2-b **全部通过** —— relay 已在 47 常驻,且经 `https://alotbuy.com/dshs-relay` 可达(**origin 直连与经 Cloudflare 两路均 `HTTP/1.1 101`**);端到端 `registered host=w-47`;监听面与基线**逐字一致**(零新增公网口)。 + +**落在 47 上的物(可核)** +- `/opt/dsh-relay/lib/net/relay/*` + `/opt/dsh-relay/lib/net/reachability.js` + `/opt/dsh-relay/package.json` +- `/etc/dshs/relay-keys.json`(`chmod 600`;`w-47` / `w-106` 各一 64-hex 密钥) +- `/etc/systemd/system/dshs-relay.service`(`enabled` + `active`) +- `/www/server/panel/vhost/nginx/alotbuy.com.conf` 新增 `location /dshs-relay`;备份 = `alotbuy.com.conf.bak-20260916-2059-pre-relay` + +🔴 **两处必须继承的勘误(本单 §2 里的"候选"写错了)** +1. 门户 443 块 = **`alotbuy.com.conf`**(`server_name alotbuy.com www.alotbuy.com *.alotbuy.com`,443 块在第 85–123 行);**`dsh.alotbuy.com.conf` 是遗留 301 跳转域名** ⇒ ⛔ 不能往它里面加 location。 +2. `dsh.alotbuy.com` 在 **Cloudflare** 后面 ⇒ 验收 URL 一律用 **`https://alotbuy.com/dshs-relay`**;且 443 块有 `http2 on` ⇒ **curl 判据必须带 `--http1.1`**(否则必得 `HTTP/2 404` 假失败)。 + +**完整证据** = 方案文档 **§14.2**(13 条判据的命令原文与实测值);**三条硬结论** = 方案 **§14.3**(`base/span` 语义勘误 / `/proc/` 判属主 / `http2` 与 WS 握手)。 + +--- + +## 10. 下一棒 = R3(⏳ **本节已被 §10.5 取代:R3 与 R4 均已落地并端到端验收**) + +### 10.1 R3 内容(原样保留) + +- **R3-a**:worker 侧 client 常驻(106 经宝塔 MCP;`w-47` 用 `local` 不动)+ 密钥下发(`w-106` 密钥已在 `/etc/dshs/relay-keys.json` 内)。 +- **R3-b**:`dsh_hosts.via='relay'`(env 级、可秒回滚)。 +- ⚠️ **R3 还有一个没做的前置**:`src/web/server.ts` 的 `RendezvousRegistry([...])` 目前**只注册了** `LocalRendezvous` + `ManagerSshRendezvous`,**`RelayRendezvous` 尚未接入** ⇒ **不接线就改 `via='relay'` 不会有任何效果**(会静默无效)。 + +### 10.2 ✅ 阻塞已降级(2026-09-16 21:1x 复核,**推翻本单初稿**):不是"未评审改动",而是"一次 hash 对账" + +**复核实测** +- `git diff --stat` = **15 files, +287 / −23**;逐条看**全部属于 S2 会合中继拆分这条线**(`db/repo.ts`、`db/types.ts`、`net/reachability.ts`、`supervisor/{orchestrator,spawn}.ts`、`web/server.ts`、`web/routes/admin.ts`、`test/reachability.test.mjs` …)—— **不是**与 relay 无关的杂项改动。 +- 47 上**正在跑的** `/opt/dshs/lib/web/server.js`(mtime 09-16 16:10)关键字计数:`RendezvousRegistry` **2**、`ManagerSshRendezvous` **2**、`LocalRendezvous` **2**、**`RelayRendezvous` 0**、**`VIA_RELAY` 0**。 + ⇒ **本机工作区 ≡ 47 已上线的代码**:那 15 处未提交改动**已经在生产跑着、且已过 P1/P2 验收**,只是没 commit。 + +**因此 R3 的 Step 0(纯技术对账,⛔ 不需要 git 授权)** +1. 本机 `npm run build`(**Node 22**)。 +2. **对账**:`lib/` 逐文件比 47 `/opt/dshs/lib/` —— **净差异必须只有 relay 相关**(`lib/net/relay/**`、`lib/net/reachability.js`,以及接入 `RelayRendezvous` 后的 `lib/web/server.js`)。**出现其它任何差异 ⇒ 停下报告,不要铺**。 +3. 对账过了才铺 Manager,并 `systemctl restart dshs`(开发环境服务器 ⇒ 直接做,动手前一句话说明即可)。 + +⛔ **仍不允许**:把本机未提交改动 **commit / push**(未获授权)—— R3 全程**不需要** commit。 +⚠️ 风险提示保留:本机**不是沙箱** ⇒ 铺之前必须完成第 2 步对账,**⛔ 不许"先 scp 再看"**。 + +### 10.3 因此 R3 另起一轮,唯一入口仍是本单 §10 + +### 10.4 ✅ Step 0 实测结果(2026-09-16 21:1x · **已执行完毕**) + +**判定**:Step 0 三步全过 —— 构建干净;`lib/` 对账**净差异只有 relay 相关**(符合 §10.2 的许可集);Manager 已铺并重启,`dshs` / `dshs-relay` / `dshs-pg` 全 `active`、门户 200。 + +**实测证据(命令原文级)** +1. 构建:`node node_modules/typescript/bin/tsc -p tsconfig.json`(Node 22.22.2)⇒ 退出码 **0**,零报错。 +2. 对账(本机 `lib/` **134** 文件 vs 47 `/opt/dshs/lib/` **118** 文件,均排除 `*.map`): + - **只在 47 = 0**|**只在本机 = 16** ⇒ 全是 `lib/net/relay/**`(R2 的 relay 产物在 47 是部署到 `/opt/dsh-relay`,故 `/opt/dshs/lib` 里本就没有) + - **同路径内容不同 = 2** ⇒ `lib/net/reachability.js` + `.d.ts`,差异 = 本机多 `export const VIA_RELAY = 'relay';` 及其注释块(5 行) + - ⇒ 恰好落在 §10.2 的许可集(`lib/net/relay/**` + `lib/net/reachability.js`),**无任何无关差异** +2b. 附带核对:本机 `lib/net/relay/*.js`(8 个)vs 47 `/opt/dsh-relay/lib/net/relay/*.js` ⇒ **8/8 全同**(relay 服务端代码两端一致,R2 之后无漂移) +3. 铺设与重启:备份 47 `/opt/dshs/lib/net` → `/opt/dshs-lib-net.bak-r3pre-20260916-2118`;scp `lib/net/relay/`(新增)+ `lib/net/reachability.{js,d.ts}`; + **铺后 lib 全量再对账 = 134 / 134,三个集合全空**;`systemctl restart dshs` ⇒ 三单元 `active`;`127.0.0.1:3080` 与 `127.0.0.1:20080` 在听;`curl -H "Host: alotbuy.com" 127.0.0.1:3080/` = **200**。 + +**仍未做(= R3 本体,下一件事,本轮⛔ 未做)** +- 🔴 `src/web/server.ts` 的 `RendezvousRegistry([...])` **仍未注册 `RelayRendezvous`** ⇒ 此刻改 `via='relay'` 依旧**静默无效**(§10.1 的前置,**必须先接线**)。 +- R3-a(106 client 常驻,经宝塔 MCP)|R3-b(`dsh_hosts.via='relay'`,env 级)。 +- ⛔ 未 `commit` / 未 `push`(本机工作区仍是 15 处未提交改动 + 新增 relay 产物)。 + +**回滚**:`cp -a /opt/dshs-lib-net.bak-r3pre-20260916-2118/* /opt/dshs/lib/net/` → `systemctl restart dshs`(本步只动了 `lib/net`,其余 `lib/**` 逐文件 hash 相同)。 + +--- + +## 10.5 ✅ R3 本体 + R4 全链落地(2026-09-16 21:37–22:2x · **已执行完毕**) + +**判定**:**R3 与 R4 全部完成并端到端验收**。SSH 反向隧道**已不可能再建立**(凭据与端口都已收回),47 的公网暴露面**净减 1 口**(`32022`)。本单执行完毕。 + +### R3(worker 侧常驻 + `via='relay'`) + +1. **接线**(§10.1 的前置,必须先做):`src/config.ts` 新增 `relayUrl` / `relayStatusUrl`(**默认空 ⇒ 行为同 R2 之前**);`src/web/server.ts` 把 `RelayRendezvous` 注册进 `RendezvousRegistry`,配 relay `/status` 实时快照(`online` 判定 + **15 s 陈旧即失效**);`src/net/reachability.ts` 新增 `addressPort()`(IPv6 安全取端口)。 +2. **47** drop-in 加 `DSHS_RELAY_URL` / `DSHS_RELAY_STATUS_URL`;relay 以 `--base 19000 --span 3000` 起(准入窗口覆盖 agent `19000` 与 w-106 实例段 `21000+`)。 +3. **106** 常驻 + 密钥:`/opt/dshs-cluster/lib/net/relay/*`、`/etc/dshs/relay-keys.json`(`600`,**只含 `w-106` 一把**)。 +4. **`dsh_hosts.via='relay'`**(仅 w-106;`w-47` 保持 `local` —— 同机直连,无需中继)。 +5. **证据**:relay `/status` 出 `w-106 … ports=19000` +动态回环口;经 Manager 打 `/api/admin/users//dsh/status` = **200**,且 relay `streamsOpened` 同步增长 ⇒ **Manager→agent 确实经 relay**。 + +### R4(实例面经 relay + 隧道下线) + +1. **mux/relay**:新增 `PORT_ADD(0x0b)` / `PORT_DEL(0x0c)` / `PORT_ACK(0x0d)`;server 侧 `ensureEndpoint` 暴露 `ready`、新增 `closeEndpoint` / `onPortChange`(口径与 `HELLO` 同源);client 侧 `addPort/removePort/replayDynamicPorts`,**`addPort` 成功必须同时写 `allow`**(否则"端口开着、流全被拒"的最难看半通;T16 抓的就是它)。 +2. **传输抽象**:新增 `WorkerTunnel` 接口 + `RelayTunnel`;worker agent 按 scheme 选传输,**缺密钥抛错、不静默降级**。 +3. **传输切换**:106 设 `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay` + `DSHS_RELAY_SECRET`;停用早期那个临时 `dshs-relay-client` 单元。 +4. 🔴 **R4 关键缺陷(本次实测发现并修掉)**:`RemoteSpawner` 构造函数**漏了 `this.translateEndpoint = options.translateEndpoint`** ⇒ 实例面翻译**静默失效** ⇒ Manager 拿 **Worker 侧口号**(`127.0.0.1:21000`)往**自己本机**拨 ⇒ 连接被拒两次 ⇒ 代理 `reply.raw.destroy()` ⇒ **浏览器只见「空响应」、平台一行日志都没有**。 + - **定位手段(可复跑,别靠读代码猜)**=**判别器**:在 47 上临时监听 `21000` 再发门户请求 ⇒ 请求被探针接走(`PROBE21000 hit GET /?token=… host=127.0.0.1:21000`)⇒ 一口定死"拨的是未翻译的口号"。(探针用完即停,已确认 `21000` 监听数归 0。) + - **同处还修了一个"失败开放"**:原判据取 `reachability.via`,而 host 离线 / relay 快照陈旧时它是 `undefined` ⇒ 落到"非 relay ⇒ 原样透传"分支。改成读 **`dsh_hosts.via` 原文**(server 侧新增 `hostVia` 表)⇒ relay host **失败关闭**(查不到就回 `undefined`),未知 host 行为不变。 + - **回归测试**:新增 `test/remote-spawner.test.mjs`(T1–T4)并登记进 `npm test` / `npm run verify`;**先红后绿已实证** —— 摘掉修复那行 ⇒ T1/T3 红(`# pass 2 / # fail 2`);恢复重构建 ⇒ `remote-spawner + relay + reachability + instance-port` = **36/36 全绿**。 +5. **R4 收尾(净减暴露面)** + - **47**:`/root/.ssh/authorized_keys` 收回 `dshs-tunnel-106to47`(**3 → 2 条**;备份 `authorized_keys.bak-r4-20260916`);`/etc/ssh/sshd_config` 注释掉 `Port 32022`(`sshd -t` OK;备份 `/etc/ssh/sshd_config.bak-r4-20260916`)⇒ **32022 监听 = 0**,`22` 正常。 + - **106**:`/etc/dshs-worker.env` 注释掉 `DSHS_TUNNEL_TARGET` / `DSHS_TUNNEL_IDENTITY`(备份 `.bak-r4-20260916`);`/root/.ssh/tunnel_ed25519*` **移至** `/root/_tunnel-keys-bak-r4-20260916/`(移动而非删除 ⇒ 回滚不必重生成密钥)。 +6. **终验(在隧道下线之后跑,最强证据)** + ``` + 47: sshd 22=2 32022=0 19000(隧道落点)=0 | relay 仅绑 127.0.0.1:20080 | nginx 443=1 + 门户:内部 http=200 / 公网 https://alotbuy.com/ = 200 + relay session:w-106 session=6b18bfba1249f111 ports=[19000] + launch ⇒ 实例 port=21000 status=running + relay 端点:19000 -> 36097 | 21000 -> 38991 ← PORT_ADD + 门户带 token:http=303 + Set-Cookie: dsh-auth-… + 再取 /(带实例 cookie):http=200 bytes=62451 ct=text/html;DeepSeek Harness + relay streamsOpened 4 -> 6 ← 字节真的过了 relay + stop ⇒ 21000 端点消失 ← PORT_DEL + ``` + ⚠️ **口径坑(上一轮误判的真因)**:`/dsh/launch` 的 `folder` 必须是 ws 下**已存在**的目录,且 `fs.isDirectory()` 对不存在的路径是**抛 `UserFsError('not_found')` → 404**(**不是**返回 `false`)⇒ `{"error":"not_found"}` 是"**文件夹不存在**",**不是**"用户不存在"。别再把用户 id 当成嫌疑(`targetOr404` 用的是 `users.id`,那个 id 一直是对的)。 + +### 回滚(分层,均秒级) + +| 回滚哪一层 | 动作 | +|---|---| +| 实例面翻译 | 还原 `translateEndpoint` 那一行 → `npm run build` → scp 两个文件 → `systemctl restart dshs` | +| 传输回退到 ssh 隧道 | 106 恢复 `DSHS_TUNNEL_*`(`/etc/dshs-worker.env.bak-r4-20260916`)+ 私钥从 `/root/_tunnel-keys-bak-r4-20260916/` 搬回 + 47 恢复 `authorized_keys` 那一行与 `Port 32022` → `systemctl restart sshd dshs-worker` | +| 会合回退(整体) | 47 drop-in 删 `DSHS_RELAY_URL` / `DSHS_RELAY_STATUS_URL` → `daemon-reload` → `restart dshs`(`via='relay'` 自动回退 `manager-ssh`) | + +### 残留(不影响功能,需控制台凭据) + +- 腾讯云安全组里 **32022 的放行规则仍在**(本机已无监听 ⇒ 实际打不通)。改安全组需要控制台凭据 ⇒ **待用户处理**,不阻塞任何功能。 +- 106 的 **provisioner 仍未铺**(与本单无关,属集群化 D 阶段遗留)。 + +**纪律**:本轮**未 `commit`、未 `push`**(本机工作区仍是未提交状态)。 + +--- + +## 11. ✅ R5 = 会合可换机(2026-09-16 22:3x–23:0x · **已执行完毕**) + +**判定**:**R5 完成并端到端验收**。R1–R4 隐含的「**Manager 必须与 relay 同机**」这条前提**已经不存在** —— Manager 改为**只拨出**一条 wss,落点搬到**自己本机**的回环池。**relay 放哪台机器都不再影响 Manager。** 本单至此执行完毕。 + +### 11.1 它解决的确切问题(方案缺口 = 会合中继拆分 §9.4) + +R1–R4 的落点是「relay 在**自己主机**回环上开监听,Manager 去连那个回环口」(R4 终验原文:`19000 -> 36097 | 21000 -> 38991` —— `36097/38991` 都是 **relay 主机**上的口号)。⇒ 中继换机后 Manager 根本够不到那些 `127.0.0.1`。 + +### 11.2 做法(关键的一步**换位**) + +| 项 | 变化 | +|---|---| +| 落点在哪 | **relay 主机** → **Manager 本机**(`127.0.0.1:25000..26099` 预绑池,`DSHS_RELAY_DIAL_POOL=64`) | +| Manager 身份 | 纯客户端 → **拨号方**(`RelayClient` 的 `dialer: true`,`HELLO` 时 `ports=[]`) | +| 新帧 | `DIAL: 0x0e`(`{target, port}`,`streamId` 由拨号方自分配)/`DIAL_ACK: 0x0f`(`{ok, error?, workerStreamId?}`) | +| 命名空间 | 拨号方 `streamId` 与 worker 会话 `streamId` **不重叠**;relay 用 `session.dialRoutes: Map<dialStreamId, {session, st}>` 配对 | +| 数据面同形 | 新增 `StreamPeer` 结构化接口 ⇒「注册端口的 `net.Socket`」与「拨号流的 `WsStreamPeer`」在 `onData/flushStream/closeStream` **一行都不分叉** | +| 权限门(R5 只准收窄) | relay 侧 `DSHS_RELAY_DIALERS=manager` **白名单**(空集 ⇒ `DIAL` 一律拒)+ `keys.ts` **每机独立密钥** ⇒ 爆炸半径 = 那一台 | +| 双身份校验 | `ports=[] 且非拨号方` ⇒ 拒(`no-ports` 姿态**未退化**);`ports≠[] 且是拨号方` ⇒ 拒(`dialer-must-not-declare-ports`) | +| 背压 | 真判据 = `conn.bufferedAmount < highWater`(WS `send()` 返回值恒真,不可作判据) | +| 纯流转发模式 | 新增 `exposeLoopback: false` ⇒ relay 一个本地口都不开(本机暴露面 0);**本次未启用**,留作"relay 真换机"时的可选项(见 §11.5) | + +**代码改动**(本机工作区,⛔ 未 commit):`src/net/relay/{wire,duplex(新),server,client,dialer(新),main,index}.ts`+`src/config.ts`(5 项)+`src/web/server.ts`(接线)+`test/relay.test.mjs`(+T18/T19)。 + +### 11.3 命令级证据(三组,均为原文) + +**① 单元 / 集成**:`tsc` 构建 `rc=0`;`relay + remote-spawner + reachability + instance-port` = **38/38 全过**;`check-layering` **无新增违规**(扫描 71 个 `.ts`,5 条全在基线内)。 +**② 端到端(`accept_r5.sh`,47 上跑)** +``` +① [relay-dialer] 本机落点池就绪:64 个口(25000..26000) + [relay-client] registered host=manager session=87dc3ebc78f37306 + 池监听数(25000..26000)= 64 +③ [relay] DIAL manager -> w-106:19000 ok (workerStream=1 dialStream=1) ← 控制面 +④ [relay-dialer] 落点 127.0.0.1:25000 -> w-106:19000 +⑤ endpoints: [(19000, 41233, 0)] ← 🔴 relay 回环口 41233 streams=**0**:Manager 完全没碰 +⑥ 带 token: http=303 → 带实例 cookie: http=200 bytes=62451 ct=text/html + 页面标题:<title>DeepSeek Harness +``` +**实例面也在拨号池里**(`diag2_r5.sh` / `diag3_r5.sh`): +``` +[relay] DIAL manager -> w-106:21000 ok (workerStream=12/13/15 ...) ← 实例面(21000) +[relay-dialer] 落点 127.0.0.1:25001 -> w-106:21000 +[relay-client] dial w-106:21000 up (stream=12) +launch ⇒ http=200 {"instance":{...,"port":21000,"status":"running"}} +``` +**③ 🔴「换机」等价实验(`accept_r5_swap.sh` · 最强证据)** —— 语义 = 清掉 `DSHS_RELAY_STATUS_URL` ⇒ **Manager 对 relay 回环口一无所知**(等价于"relay 在别的机器上"): +``` +改后 drop-in 中 RELAY_STATUS 行数 = 0 / 生效值 0 条 +relay DIAL 计数:0 -> 3 ← 控制面全经拨号 +19000 次数=20 / 21xxx 次数=4 ← 实例面也经拨号 +落点 127.0.0.1:25000 -> w-106:19000 +endpoints: [(19000, 41233, 0), (21000, 44559, 0)] ← 🔴 两个 relay 回环端点 streams **全为 0** +带 token: http=303 / 带实例 cookie: http=200 bytes=62451 / <title>DeepSeek Harness +→ 已还原(RELAY_STATUS 1 条,dialers=["manager"]) +``` +**④ 暴露面(`finalize_r5.sh`)**:R5 新增的 `127.0.0.1:25000..25063` **全部仅回环**;非回环监听与基线逐字一致 ⇒ **公网零新增口**。实例已停回 `running:false`,relay 端点收敛为 `[(19000, 41233, 1)]`。 + +**⑤ 配置与备份(47)** +``` +relay drop-in: /etc/systemd/system/dshs-relay.service.d/dialers.conf + Environment="DSHS_RELAY_DIALERS=manager" +manager drop-in 追加 5 行: DIAL_HOST=manager / DIAL_SECRET=<64hex> + DIAL_PORT_BASE=25000 / DIAL_PORT_SPAN=1000 / DIAL_POOL=64 +备份: /etc/dshs/relay-keys.json.bak-r5-20260916-224838 + /etc/systemd/system/dshs.service.d/cluster.conf.bak-r5-20260916-224838 + /etc/systemd/system/dshs.service.d/cluster.conf.bak-r5swap-20260916-225546 +产物对账 29/29 hash 全同(Manager lib/config.* + lib/web/server.* + lib/net/relay/*;relay 运行时 /opt/dsh-relay/lib/net/relay/*) +``` + +### 11.4 🔴 勘误 §10.5 第 4 条关于 `{"error":"not_found"}` 的解释 + +§10.5 原文说它"**是「文件夹不存在」,不是「用户不存在」**" —— **这个解释不完整,实测可复现另一种更隐蔽的来源**(本次冷启动实验:重启 Manager 后连续 3 次 launch **全 404**,且**零 relay DIAL、零落点分配** ⇒ 请求**根本没到 w-106**): + +`src/fs/remote-user-fs.ts:70-73` —— 归属 `hostId` 解析出来了,但 `agentFor(hostId)` 返回 `undefined` 时,**静默回退**到 `options.agentUrl`(= `DSHS_CLUSTER_AGENT_URL = http://127.0.0.1:19100`,**w-47 自己的 agent**)⇒ w-106 的用户在 w-47 上不存在 ⇒ agent 回 `{error:'not_found'}` ⇒ 404 **`{"error":"not_found"}`**,与"文件夹不存在"**完全同形**。 +⇒ **判别器**(照 R4 那条同族教训的法子):看 **relay 有没有 DIAL** —— 有 = 真到了 w-106(文件层面问题);**没有 = 根本没出去**(地址未解析)。别只凭错误体下结论。 +⚠️ **这是既有缺陷(T08 集群化遗留),非 R5 引入**:R5 只在解析链上**加了一跳**(dialer 优先,再回退快照),不会让原路径变差。**按红线"额外问题先报告、后动手",本轮只记录未修** ⇒ 见 §11.6 待修。 + +### 11.5 一处有意识的不改动(留证据,免下一棒误判为"没做完") + +relay 仍为端口绑回环口(`41233`/`44559`…)⇒ 47 上仍有这类**动态回环监听**。**本次有意不启用 `exposeLoopback: false`**,理由:① R5 的达成判据是"Manager 不再依赖它"(§11.3 ③ 已证 `streams=0`),**不是**"relay 不能绑";② 同机部署下 `localPort` 字段提供**诊断可读性**,关掉会让 `/status` 变"瞎" = 可观测性**净变差**(违反 R11);③ 真正换机时按需开即可。 +⇒ 下一棒若要做"relay 真挪到 106",**只需设 `exposeLoopback: false`(或另找一台时保持默认)**,Manager 侧**一行配置都不用改**。 + +### 11.6 新发现待办(R5 当时**未修**,按"先报告后动手")—— ✅ **现已修完,见 §12** + +> ✅ **勘误(2026-09-16 23:4x)**:本节两条**均已修完并端到端验收** ⇒ 见 **§12**(先红后绿 + 冷启动 `launch 200` + relay 有 `DIAL` + `not_found=0`)。下方保留为 **R5 当时**的记录,仅存档。 +**假 404 / 静默回退到错 agent**(§11.4)。影响面:**任何一次 `systemctl restart dshs` 之后的一小段窗口内,w-106 用户的文件面 / launch 会 404,且错误码误导为"文件夹不存在"**(用户视图 = "实例启动失败/找不到工作区")。 +- 修法(3 行,`resolve()` 内):归属解析出来但 `agentFor` 解析不出时 **⛔ 不许静默用默认 agent**,改为**失败关闭**并回一个可区分的码(新增 `host_unreachable`,或直接复用现成码)。 +- 验收:新增回归用例 —— 让 `agentFor` 返回 `undefined`,断言**不会**打到 `agentUrl`;`npm test` + 先红后绿。 +- ⚠️ 它属**文件面**,动工前按 R7 出受影响清单。 + +### 11.7 回滚(R5 专属,均秒级) + +| 回滚哪一层 | 动作 | +|---|---| +| Manager 拨号通道 | 删 manager drop-in 里 `DSHS_RELAY_DIAL_*` 5 行 → `daemon-reload` → `restart dshs`(`addressOf` 自动回退到 R3 的 `/status` 快照路径) | +| relay 侧白名单 | **不删也行**(无拨号方接入 = 行为同 R3);彻底回退则删 `dshs-relay.service.d/dialers.conf` → `daemon-reload` → `restart dshs-relay` | +| 代码级回退 | `cp -a` 上面三个 `.bak-r5*` 备份;代码则 revert `src/net/relay/*`(`wire/duplex/dialer` 新增项 + `server/client` 改动)→ `npm run build` → scp `lib/` → `restart dshs` | + +**残留**:`_tmp_r5/`(本机临时脚本,已清);R5 新增的 64 个**仅回环**口(有意保留,池大小可配)。 + +--- + +## 12. ✅ 缺陷 A1 / A2 修复(2026-09-16 23:2x–23:3x · **已执行完毕**) + +> 起因:§11.6 只把这两条**记下来没动手**(R7"先报告后动手")。本轮用户授权「按建议执行」⇒ 修完。 +> **A1 = §11.4 那条假 404 的真因**;**A2 = `state.py` 恒报「锁空闲」**。 + +### 12.1 A1 根因(比 §11.4 更准,**勘误**) + +§11.4 写的是"地址解析不出时静默回退本地 agent",方向对但**没说清是哪一个"解析不出"**。本轮定死: + +- `server.ts:262` 的 `hostDirectory` 是**惰性 Map** —— 唯一的写入者是 `hostsProvider()`,而此前**只有** + `RemoteSpawner.ensureHosts()`(TTL 30 s,`remote-spawner.ts:140`)会调它 ⇒ + **文件面的路由表正确性,隐式依赖"spawner 恰好先刷过一次"**。 +- 启动时该 Map **只有 `config.clusterHostId`(本机)这一项**(`server.ts:263`)。 +- ⇒ Manager 重启后若用户**先碰文件面**("我的文件" / launch 的 folder 检查), + `hostIdForFile` 正常解析出 `w-106`,但 `agentFor('w-106')` = `hostDirectory.get('w-106')` = `undefined` + ⇒ 旧 `target()` **静默回退到 `DSHS_CLUSTER_AGENT_URL`(= `http://127.0.0.1:19100`,w-47 自己的 agent)** + ⇒ 那台上没有这个用户 ⇒ **假 `404 {"error":"not_found"}`**,与"文件夹不存在"**完全同形**,平台**零日志**。 +- **判别器 = relay 有没有 `DIAL`**:没有 = 请求根本没出这台机。 + +### 12.2 A1 修法(**两条一起做才算解决**) + +| 层 | 做法 | 为什么缺一不可 | +|---|---|---| +| **治本** | `ClusterFsRouting` 新增可选 `ensureHost(hostId)`;`RemoteUserFs.target()` **未命中时先补齐目录再判** | 只"失败关闭"= 把"假 404"换成"真 503",**用户还是用不了**(属降级,不算解决) | +| **治安全** | 补齐后仍取不到 ⇒ `UserFsError('host_unresolved')` → **503**,且**一个字节都不发往默认机** | 消掉"跑到错机读写":读 = 伪装成"文件丢了",写 = **静默写坏**(更糟) | +| **不退化** | "确实还没有归属"(`hostIdFor` 正常返回 `undefined`)与单机形态**仍是默认机** | 这是设计内契约(`hostIdForFile` 的粘性首触达),别一刀切成 503 | +| **可观测** | 补齐时打 `[cluster] host 目录未命中 <hostId> ⇒ 按需补齐(重启窗口期常见)` | 没有它,"这次为什么没 404"只能靠推断 | + +改动文件(8 个源 + 12 个产物):`src/fs/user-fs.ts`(新增码 `host_unresolved: 503`)、`src/fs/remote-user-fs.ts`(`target()` 重写 + `ensureHost`)、 +`src/fs/provider.ts`(透传)、`src/web/server.ts`(`ensureHostDirectory`:只在**未命中**时查库 + 并发去重 + 5 s 冷却)、 +新增 `test/remote-user-fs.test.mjs`(U1–U8,已登记进 `npm test` / `npm run verify`)。 + +### 12.3 A1 证据(**先红后绿 + 端到端直接证据**) + +**① 先红后绿**(把 `target()` 逐字换回 git HEAD 的旧实现,同输入对比): +``` +【红】旧实现下 fetch 实际打到: ["http://127.0.0.1:19100/fs/list"] ⇒ 打到了默认机 ⇒ 缺陷成立 +【绿】新实现:抛出 code=host_unresolved status=503,fetch 调用数=0 +``` +(⚠️ 运行时替换而非 git stash:旧 `RemoteUserFsOptions` 没有 `ensureHost` 字段而 `provider.ts` 已在传 ⇒ 回退单文件会 **TS2353 编译不过**,取不到"红"。) + +**② 端到端:冷启动窗口**(`_tmp_fix/accept_a1.sh`,**逐字复刻 §11.4 那条红的时序** —— 它当时跑出 `launch#1/#2/#3` **全 404**、relay **零 DIAL**) +``` +⓿ 归属:4092b965-… → host_id = w-106 默认 agent = http://127.0.0.1:19100 +① systemctl restart dshs(3080 用 4×0.5s 恢复) 此刻 relay 累计 DIAL = 1 +② launch#1 http=200 body={"instance":{"port":21000,"status":"running",…}} ← 旧版这一发 = 404 +③ launch#2 http=500 "instance … is held by w-106 until …"(租约互斥 ⇒ 第一次真的占了 w-106 的租约) +④ relay:DIAL manager -> w-106:19000 ok ×3(旧版 = 0) +⑤ 落点 127.0.0.1:25000 -> w-106:19000 +⑥ dshs 日志 'not_found' 条数 = 0(旧版 = 3) +``` + +**③ 直接证据(证明真走进了"未命中 ⇒ 补齐"这一步,而不是侥幸)** —— 补上日志行后复跑: +``` +23:31:55 [cluster] host 目录未命中 w-106 ⇒ 按需补齐(重启窗口期常见) +23:31:55 launch http=200 +23:31:55 [relay] DIAL manager -> w-106:19000 ok + not_found 条数 = 0 +``` +⇒ 重启**同一秒内**:目录未命中 → 补齐 → DIAL 出本机 → 200。**窗口真实存在,且现在被透明恢复**。 + +**④ 回归与部署**:`node --test`(8 个文件)= **82 tests / 81 pass / 0 fail / 1 skipped**(skip 为既有); +`check-layering` = **无新增违规**(基线 5 条,扫描 71 个 .ts);47 产物对账 **12/12 hash 全同**; +备份 `/opt/dshs/lib-bak-a1-20260916-232914`(滚动两次部署,后一次覆盖前者同名文件,均在)。 + +### 12.4 A2 修法(`state.py`) + +`state.py:39` 把 `.exec-lock` 当**文件**读,而 `--claim-exec` 建的是**目录**(`OWNER` 在里,3 行:OWNER / `开始:` / `在做:`) +⇒ 读空 ⇒ **恒报「空闲」** ⇒ 每个新会话读到的**第一个信号是错的**。改为:目录读 `OWNER`(兼容历史遗留的普通文件)。 +**复验**(本会话正持锁时运行): +``` +[锁] 🔴 被占用 —— 缺陷修复-A1A2-20260916 / 开始:09-16 23:24 / 在做:(未声明单号) + ⇒ ⛔ 停手,别碰任何文件(R9) +``` + +### 12.5 分层回滚(A1 / A2) + +| 层 | 动作 | 影响 | +|---|---|---| +| 代码 | `cp -a /opt/dshs/lib-bak-a1-20260916-232914/lib/. /opt/dshs/lib/` → `systemctl restart dshs` | 回到 A1 修前(假 404 复现) | +| 单测 | 用 `git` 取回旧 `src/fs/remote-user-fs.ts` + 删 `test/remote-user-fs.test.mjs` + 撤 `package.json` 两处登记 | 无运行时影响 | +| A2 | 还原 `state.py` 第 1 节为"按文件读" | 回到恒报空闲(**不建议**) | + +**残留**:`_tmp_fix/`(本机临时脚本,收尾时移入 `_中间产物_待清理/`)。 + +### 12.6 勘误 R4/R5 的 `not_found` 单因结论 + +`§10.5` 把 `not_found` 归为"**文件夹确实不存在**"。本轮证明它**至少有两个来源**: +① 文件夹不存在(原判) ② **地址未解析 ⇒ 静默打到错机**(本轮修掉的那条)。 +⇒ **看到一个 `404 not_found` 时,先看 relay 有没有 `DIAL` 再下结论。**(已同步进 §11.4。) + +### 12.7 ⛔ A1 遗留的一个**相邻**缺陷(按 R7 只记不动) + +`src/supervisor/leased-spawner.ts:149` 是**同族写法**: +`this.options.agentFor?.(inst.hostId) ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken }` +⇒ 实例面(launch / status / stop)**在 `agentFor` 未命中时同样静默回退默认机**。 +本轮**只修了文件面**(因为那是用户可见的 404),**没碰**这一处 —— 需不需要按同一口径收, +**是下一棒要决定的事**(判据:它是否也会产生"看起来像别的错"的假象)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T12-443兜底.md b/dsh-server-docs/交接单/archive/交接单-已完成/T12-443兜底.md new file mode 100644 index 0000000..86a9006 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T12-443兜底.md @@ -0,0 +1,413 @@ +# 交接单 · 443/TCP 兜底(覆盖网络线 · 序 ④) + +> **线**:覆盖网络线 | **序**:④(定序 ③→④→⑤,见入口 §0) +> **性质**:**规划棒**产出(本单只出单,⛔ 未动任何服务器、未改任何代码) +> **产出时间**:2026-09-17 08:4x | **规划会话**:覆盖网络线-序4规划棒-20260917-0837 +> **唯一来源**:`覆盖网络_应用场景与待完善清单 §五`(主线第 ④ 项)+ `覆盖网络_全球架构复盘 §3/§8` + `覆盖网络_补遗与参考方案 §2.4/§2.8/§三/§四` +> **上一轮**:序 ③ 已收官(`交接单_一机一钥与信任根_20260917.md` §8)⇒ ⛔ 不要重做 ②/③ +> **执行棒**:按本单 §5 开工,**不读规划会话上下文**;收口必须回填 §8。 + +--- + +## §1 目标 + +**在 47 上为 relay 增加一条「不经 Cloudflare、不经门户站点 conf」的 443/TCP 直连入口,并把它作为第二条引导种子广播进签名目录**,使只能出 443/TCP 的节点在 CF / 门户站点任一不可用时**仍能完成会合与 relay 注册**;全程 **零新增公网端口、零新增证书、零新增域名、零花费**。 + +**判定"做完了没有"** = §6 的 **D1–D8 全绿**(每条都能被第三方复现)。 + +**为什么这是序 ④ 的最小正确形态**(不是妥协,是已排过序): +- 客户端只能出 443/TCP ⇒ **端口必须仍是 443**,⛔ 不能用"非标端口"当兜底。 +- 现役唯一入口 = `wss://alotbuy.com/dshs-relay`,链路 = **客户端 → CF → 门户 nginx 443 块 → `127.0.0.1:20080`** ⇒ CF 与门户站点 conf 是两个**共用失败域**。 +- 骨架里**已存在**多入口的承载物(`DEFAULT_OVERLAY_SEED` 常量位 / `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 逗号多值 / 签名目录 `relays[]`+`bootstrap[]` / 逐 origin 失败 `continue`)⇒ 兜底**不需要新协议、不需要新组件**,只需要"补一条入口 + 广播 + 可解释"。 +- `config.ts:167` 的原文已写明该常量位的设计意图 = 「**已持证书、不新增域名**」⇒ 本单正是把它用起来。 + +--- + +## §2 只读前置(⛔ 只读,不改) + +> 执行前逐条核一遍,任何一条与"期望"不符 ⇒ **先停下来报告**,别猜。 +> `47 = 47.77.182.89`、`106 = 106.54.21.172`(按既有惯例;ssh 别名以本机 `~/.ssh/config` 为准)。 + +| # | 核什么 | 命令 | 期望 | +|---|---|---|---| +| P1 | 三单元在跑 | `ssh 47 'systemctl is-active dshs dshs-relay dshs-pg nginx'` | 4×`active` | +| P2 | **监听面基线**(收尾要逐字一致) | `ssh 47 'ss -lntp \| grep -E ":(443\|20080)\b"'` | nginx `0.0.0.0:443`;node `127.0.0.1:20080`(**不是** `0.0.0.0`) | +| P3 | 现役入口在哪个 conf;**目录端点的 location 是否已存在** | `ssh 47 'nginx -T 2>/dev/null \| grep -nE "dshs-relay\|dshs-overlay"'` | 命中门户 conf(`alotbuy.com.conf`);🔴 若 **没有** `/dshs-overlay/bootstrap` 的 location ⇒ 本单 S1 要补(否则兜底入口拿不到签名目录) | +| P4 | CF 现状 + **证书是否已覆盖兜底子域** | `dig +short alotbuy.com @1.1.1.1`;`ssh 47 'openssl s_client -connect 127.0.0.1:443 -servername relay-direct.alotbuy.com </dev/null 2>/dev/null \| openssl x509 -noout -text \| grep -A1 "Subject Alternative Name"'` | 前者 = CF 段(`104.x`/`172.6x`);后者 SAN **含 `*.alotbuy.com`** ⇒ 零新证;⛔ 若不含 ⇒ 停 | +| P5 | 该机的引导链 env 现状(**决定广播是否生效**) | `ssh 47 'grep -rn DSHS_OVERLAY /etc/systemd/system/dshs.service.d/*.conf'`|同样看 106 | 47 预期**未配** `DSHS_OVERLAY_BOOTSTRAP_SEEDS`(= 只有内置常量)|106 **若**有 `--url` / `DSHS_RELAY_URL` ⇒ **压制引导链**,须先记录(见 §5 S2-附) | +| P6 | 签名目录原文 | `curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap \| head -c 400` | 200 + JSON,含 `network` / `version` / `relays[]` / `bootstrap[]` 与 `sig` | +| P7 | relay 容量与身份现状 | `ssh 47 'curl -s 127.0.0.1:20080/status'` | 含 `capacity`、`identityRequired`、`identityOk`(序 ③ 后应 = 3)、`online[]` | +| P8 | 本机代码基线 | `git -C D:/github/dsh_shenxian rev-parse HEAD` | `640813e`(⚠️ 工作区有未提交改动,⛔ 不要 `checkout` 掉) | + +**判据先说清(P5 → S2 的因果)**:`net/relay/main.ts` 里 `--url` / `DSHS_RELAY_URL` 算"**env 显式**"、**压制整条引导链**(原文:`压制引导链,运维最后手段`);而 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` 才是"入口列表"。⇒ **兜底要生效,入口必须走 seeds,⛔ 不能靠 `DSHS_RELAY_URL`。** + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| 层 | 位置 | 改什么 | +|---|---|---| +| 47 · nginx | **新文件**(建议 `/www/server/panel/vhost/nginx/relay-direct.conf`) | 新增一个 **独立 443 server 块**:`server_name relay-direct.alotbuy.com`(名字见 §4.2),证书路径**从门户 443 块原样复制**,两个 location:`/dshs-relay` → `127.0.0.1:20080`|`/dshs-overlay/bootstrap` → 平台 web(`127.0.0.1:3080`,以 P3 实测为准) | +| 47 · systemd | `/etc/systemd/system/dshs.service.d/*.conf` | `DSHS_OVERLAY_BOOTSTRAP_SEEDS` = **主入口在首位** + 兜底项(追加,⛔ 不改动其它行) | +| 本机 · 代码 | `src/net/relay/client.ts`(建连点 L415)、`src/config.ts`(配置字段)、`test/overlay-*.test.mjs` | 新增"**地址覆盖 + 指定 SNI**"能力(详见 §5 S3),使兜底入口在 DNS 不可用/被污染时也能连 | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ 不动 `alotbuy.com.conf` **门户 443 server 块**(`server_name` / 证书 / 既有 location 一律不碰) +- ⛔ 不动 relay 进程:仍 `127.0.0.1:20080`、**不新增 `--host`、不在 relay 侧做 TLS**(relay 是纯 `ws://`,TLS 由 nginx 终结 —— 已核实) +- ⛔ **不新增公网端口**(客户端仍只走 443/TCP)、不新增 DNS 记录、不新增证书、不新增域名 +- ⛔ 不动 Cloudflare 配置;不动 `dshs`(Manager 主服务)与 `dshs-pg` +- ⛔ 不碰序 ③ 的成果:四层密钥、`identityRequired` 语义、`/etc/dshs/relay-keys.json` +- ⛔ 不碰数据面(打洞/UDP)、不碰 presence / 房间层(主线第 7 步) +- ⛔ 不做跨机第二实例(L3 真容灾,见 §4.1-6) +- ⛔ 不 commit、不 push + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +1. **兜底通道 = 仍是 443/TCP 上的 wss**,同协议同端口、零新增公网口。依据:复盘 §3「兜底通道 = HTTPS/443」+ 清单 §五 第 4 项。 +2. **兜底要分离的失败域按价值分三层,本单做 L1/L2,L3 明确后置**: + - **L1 去 CF**(最高价值):客户端支持"**目标地址覆盖 + 指定 SNI**"⇒ CF 不可用/被墙时仍可达。**本单做。** + - **L2 去门户站点 conf**:47 上为 relay 建**独立 server 块**(独立 `server_name`)⇒ 门户 conf 被改坏/被面板重写时 relay 入口不受影响。**本单做。** + - **L3 去 nginx 进程与单实例**:需换机或第二公网 IP ⇒ **入站面扩大(命中 R5)+ 属序 ⑤ 权限评估范畴** ⇒ ⛔ **本单不做**,登记为序 ⑤/⑥ 候选。 +3. **入口列表的唯一载体 = `DSHS_OVERLAY_BOOTSTRAP_SEEDS`(逗号多值,已支持)**。⛔ 不再用 `--url` / `DSHS_RELAY_URL` 表达入口 —— 它**压制引导链**,会把兜底关掉。 +4. **入口的在线轮换载体 = 签名目录**:`src/web/routes/overlay.ts` 已把 `relays[] = relayUrl + seeds`、`bootstrap[] = seeds` 写进签名文档 ⇒ **改一次 47 的 seeds,全网已入网节点下一次刷新自动拿到兜底入口(不重装)**。这是本单的主要收益机制,S2 必须验证它真的生效。 +5. **地址覆盖(IP)⛔ 不许塞进 URL 或签名目录** —— 必须走**独立配置项**。理由:`directoryUrlFor()` 会重写 path 并清掉 `search/hash`(覆盖信息会被吃掉);且 IP 进签名目录 = 把拓扑写进全网可见文档(违反"控制面下发全网名单"反模式 §四-4)。 +6. **不做容量承诺**:45% 设计口径(异构纪律,⛔ 非同构 15%)**只登记口径与待填参数位置**(relay `--max-hosts` / `/status.capacity` / `retryAfterMs`),具体数值属**序 ⑤ 参数表**。 +7. **可解释是验收项,不是加分项**:引导链已有 `source=` / `detail=` 与逐 origin 拒绝原因 ⇒ 必须能 grep 出"**为什么走了兜底**"。依据:补遗 §2.4-11 / §2.8-24。 + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +兜底子域名具体字面(建议 `relay-direct.alotbuy.com`)· conf 放置方式(**建议独立文件** —— 门户 conf 受宝塔面板管理,独立文件避免被面板重写)· 地址覆盖的配置项名与语法(建议 `DSHS_OVERLAY_ADDR_OVERRIDES=<域名>=<IP>` 逗号多值)· WebSocket 侧的注入方式(`client.ts:415` 的 `WebSocketCtor` 已是注入点,第二参 `dispatcher` 需同步放宽类型)· 种子项顺序与是否同时放 IP 直连项 · 缓存路径 · 备份文件命名 · 单测用例划分。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**条件式**:**只有** S0 取证(或 S3 实测)证明「地址覆盖 + 指定 SNI」在当前 WebSocket 依赖下**不可实现**、且引入代码改动被否时,才需要你出手 —— 那时唯一缺口 = 兜底子域**没有 DNS 记录**,需要有人在 Cloudflare 侧加一条记录。两种做法: + +**A**:你在 Cloudflare 加一条**灰云 A 记录**(`relay-direct.alotbuy.com` → `47.77.182.89`)。 + **优点**:零代码改动,客户端不需要新能力,工作量最小。 + **缺点**:依赖你的账号与人工操作;DNS 层被污染时仍不可达(只解 CF,不解 DNS 层)。 + +**B**:把 CF 的**只读/受限 API 凭据**给我,之后这类记录我自己维护。 + **优点**:后续同类入口(第五步参数表、第六步最小形态)不再需要人工介入。 + **缺点**:凭据外发是一次权限扩大,需要你评估;且我仍要处理"DNS 层不可达"的残余风险。 + +**我的倾向**:**A 优先**(一次性、可随时撤回)。若 S3 的地址覆盖实测通过,则 **A/B 都不需要**,本单零依赖关闭。 + +### 4.4 技术实现裁决顺序 + +按 `dsh-decision-method §4.4`;规则冲突按下序取首个命中项:`R8(开发环境服务器 ⇒ 该动就动,动手前一句话说明)` → `§1 边界内自决清单(部署/重启/改配置/nginx/技术选型)` → `§3 其余红线(R5/R7/R9/R10 永远是硬约束)`。⛔ 冲突 ≠ 门禁。 + +--- + +## §5 步骤(S0–S5,每步自带一次可执行的验证) + +### S0 · 只读取证(⛔ 不改任何东西) + +跑完 §2 的 P1–P8,产出一段"现状快照"(命令 + 原文输出 + 判定)。**任何一条与期望不符 ⇒ 停下报告**。 +额外取三件(本单设计的必要输入): +- 门户 443 块的**完整原文**(证书路径 / `http2 on` / `map $http_upgrade` 位置)⇒ S1 要原样复制; +- `/dshs-overlay/bootstrap` 当前由哪个后端提供(3080 还是别的)⇒ S1 的 `proxy_pass` 目标; +- 47 / 106 上 `DSHS_RELAY_URL`、`DSHS_RENDEZVOUS_URL`、`DSHS_OVERLAY_*` 的**全部现值**。 + +### S1 · 兜底入口落 nginx(47) + +新建独立 server 块(**拷贝门户 `/dshs-relay` 的 location 正文,只改 `server_name` 与证书两个变量**): +`listen 443 ssl; http2 on;` + `server_name <兜底子域>;` + `location /dshs-relay { proxy_pass http://127.0.0.1:20080; … }` + `location /dshs-overlay/bootstrap { proxy_pass http://<P3 实测后端>; … }`。 +`nginx -t` 必须先过,再 `reload`(先备份:`cp <conf> <conf>.bak-20260917-HHMM-pre-443fb`)。 + +**验证(47 本机)**:`curl -i -s -N --http1.1 --resolve <兜底子域>:443:127.0.0.1 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://<兜底子域>/dshs-relay | head -3` ⇒ `HTTP/1.1 101 Switching Protocols`;目录端点 ⇒ `200` + 含 `"sig"`。 + +### S2 · 种子广播(47) + +`DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://alotbuy.com/dshs-relay,https://<兜底子域>/dshs-relay`(**主入口必须在首位**)→ `daemon-reload` → `restart dshs`。 + +**S2-附(若 P5 发现 106 上有压制项)**:先把 106 的 `--url` / `DSHS_RELAY_URL` **撤掉**(改为 seeds 表达),否则 106 永远走 env、拿不到兜底 —— ⚠️ 这一步会**改变 106 的取址路径**,属于"会中断在线用户"的动作 ⇒ 动手前一句话说明,并准备好回滚(§7)。 + +**验证**:`curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap | python -c "import sys,json;d=json.load(sys.stdin);print(d['relays'],d['bootstrap'])"` ⇒ 两个数组都含兜底项,且主入口在首位;`version` 递增。 + +### S3 · 客户端「地址覆盖 + 指定 SNI」(本机代码) + +在 `client.ts:415` 的建连点接入"覆盖地址 + SNI = 域名"(`WebSocketCtor` 已是注入点)。加单测:覆盖生效时**实际连的是覆盖 IP、TLS 校验用的是域名**;未配覆盖时行为**与今天逐字一致**(不退化)。 +`npm test` 必须 **Node 22**。`scp` 到 47(⚠️ 47 的 lib 路径 = `/opt/dshs/lib/`)。 + +**验证**:本机把兜底子域指向一个**不存在 DNS** 的名字 + 覆盖项指向 47 真实 IP ⇒ 仍 `HTTP/1.1 101`。 + +### S4 · 降级可解释 + 降级实测(⛔ 不许弄坏生产入口) + +**只在测试进程上验降级**:用 `DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://127.0.0.1:9/dshs-relay,https://<兜底子域>/dshs-relay` 起一个**独立 relay-client 进程**(主入口故意不可达)⇒ 日志必须出现"逐 origin 拒绝原因 + 最终采用兜底项"两行,且注册成功。⛔ **不允许**为验收而改生产的 seeds 顺序。 + +**验证**:`journalctl`/stdout 里可 grep 到 `source=`、`detail=`、兜底项 URL 与成功注册行。 + +### S5 · 端到端复验 + 不退化检查(47/106 双机) + +① `w-47`(19100) 与 `w-106`(19000) 实例面仍通;relay `/status` 的 `online[]` 含两台;② `identityRequired`/`identityOk` **与 S0 逐字一致**;③ 监听面与 P2 **逐字一致**(零新增公网口);④ 门户 `curl -H "Host: alotbuy.com" 127.0.0.1:3080/` = `200`;⑤ relay 侧 `not_found=0`;⑥ `npm test` 全绿。 +⑦ **45% 口径登记**:把"兜底启用后,relay 容量须按 45% 节点走中继核算"写进回报,并留给序 ⑤ 参数表(⛔ 本单不填数值)。 + +--- + +## §6 验收(判据清单) + +| # | 判据 | 命令 | 期望 | +|---|---|---|---| +| **D1** | 兜底入口 101 | `curl -i -s -N --http1.1 --resolve <兜底子域>:443:47.77.182.89 … /dshs-relay \| head -1` | `HTTP/1.1 101 Switching Protocols`(⚠️ 必须 `--http1.1`,`http2 on` 会假 404) | +| **D2** | 兜底入口**不经 CF** | 同上但目标 IP = 47 真实 IP;且 `dig +short <兜底子域>` | **无记录 / 与 CF 段不同** ⇒ 未走 CF | +| **D3** | 兜底入口**不依赖门户 conf** | `ssh 47 'nginx -T \| grep -c "server_name <兜底子域>"'`;临时把门户块里 `location /dshs-relay` 注释 → 兜底仍 101 → **恢复** | 命中 1;门户块失效时兜底**不受影响**(⚠️ 事后必须逐字还原) | +| **D4** | 目录端点同源可用 | `curl -s --http1.1 --resolve <兜底子域>:443:47.77.182.89 https://<兜底子域>/dshs-overlay/bootstrap` | `200` + JSON 含 `sig`(**目录与中继同源**是既有约定,缺了则兜底拿不到目录) | +| **D5** | 广播生效 | 见 S2 命令 | `relays[]` 与 `bootstrap[]` 均含兜底项,**主入口首位**,`version` 递增 | +| **D6** | 降级可解释 | 见 S4 | 日志含逐 origin 拒绝原因 + 采用兜底项 + 注册成功 | +| **D7** | 零退化 | P2 复跑 + 双实例面 + `identityOk` + 门户 200 + `npm test` | 与 S0 快照**逐字一致**(除"多了一个 server 块/一条 seeds") | +| **D8** | 零新增暴露面 | `ssh 47 'ss -lntp \| wc -l'`、`nft list ruleset`、公网 `curl 47.77.182.89:<非443>` | 监听口数量与 S0 一致;**无新增公网端口** | + +> **判定**:D1–D8 全绿 = 本单完工。任一红 ⇒ 按 §7 该项回滚,并在 §8 写清"卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决"。 + +--- + +## §7 回滚 + +| 项 | 回滚 | +|---|---| +| S1 nginx | `cp <conf>.bak-20260917-HHMM-pre-443fb <conf>` → `nginx -t` → `nginx -s reload`(或删掉新建的独立 conf 文件后同两步) | +| S2 seeds | 从 drop-in 撤掉兜底项 → `daemon-reload` → `restart dshs`(⚠️ 主入口项**必须保留**,别整行删) | +| S2-附 106 取址 | 恢复 106 原 `DSHS_RENDEZVOUS_URL` / `--url` 写法 → `daemon-reload` → `restart` 该单元 | +| S3 代码 | 本机回滚靠**备份副本**(⛔ 未授权 commit,`git checkout --` 会连带丢掉工作区其它未提交改动);47 侧回滚 = 恢复 `/opt/dshs/lib/` 旧的 `net/relay/*.js` 副本(铺前必须先备份) | +| S4 | 临时进程**必须回收**(pidfile 或 `systemctl`;⛔ 不要 `pkill -f`,会命中 ssh 自身命令行) | + +--- + +## §8 回报格式(执行棒按此格式收口) + +``` +## §8 执行回报(执行棒 · 2026-09-17 HH:MM) +1. S0 快照:P1–P8 逐条 = 命令原文 + 输出 + 判定(不符的先说) +2. 落盘清单:新建/改动的**每个文件绝对路径** + 备份文件名 + 改前改后指纹(md5/hash-object) +3. D1–D8:逐条命令原文 + 原文输出 + 绿/红 +4. 降级实测(S4):日志原文两行(拒绝原因 + 采用兜底) +5. 不退化:P2 复跑对照 / identityOk / 门户 200 / npm test 计数(a/b/c) +6. 45% 口径:登记行原文(⛔ 无具体数值) +7. 未过项 / 遗留:逐条写"卡在哪(证据)+ 已做到哪一步 + 什么条件一出现必须回头解决" +8. 指纹:本单回填后 `md5sum` +``` + +--- + +## 附 A · 本规划棒已核实的事实(执行棒**不必重复探索**) + +| 事实 | 依据(本地代码 / 文档) | +|---|---| +| relay **没有 TLS**,`opts.host ?? '127.0.0.1'`、`http.listen(port, host)` ⇒ **只绑回环**,TLS 全由 nginx 终结 | `src/net/relay/server.ts:359/405/410`(日志原文 `loopback only`) | +| relay **没有 `--host` 参数**(`--host` 在 client 模式 = hostId)⇒ "让 relay 直听 443" **必须改代码** ⇒ 已判为更差候选,拍掉 | `src/net/relay/main.ts:55–92` | +| 引导链顺序 = **① env 显式(压制)→ ② 缓存目录(未过期)→ ③ 取目录(缓存 `bootstrap[]` 优先 + seeds)**,逐 origin 失败 `continue` | `src/net/relay/main.ts:216–232`、`src/net/relay/directory.ts:530+` | +| `--url` / `DSHS_RELAY_URL` = "env 显式",**压制引导链**;入口列表变量 = `DSHS_OVERLAY_BOOTSTRAP_SEEDS`(逗号多值) | 同上 + `directory.ts:74–79` | +| 内置种子常量 = `https://alotbuy.com/dshs-relay`(**单值**);常量位注释已写"**已持证书、不新增域名**" | `directory.ts:72`、`config.ts:167–178` | +| 目录端点路径 = `/dshs-overlay/bootstrap`;"**引导地址 = 中继入口同源**" | `directory.ts:45`、`config.ts:178` | +| 签名目录自动带上入口:`relays[] = relayUrl + seeds`、`bootstrap[] = seeds` ⇒ **改 seeds 即全网广播** | `src/web/routes/overlay.ts:58–60` | +| 种子条目语法 = **必须是合法绝对 URL**(http/https/ws/wss);`directoryUrlFor()` 会**重写 path 并清 `search/hash`**;`isPublicHost()` 拒绝私网/CGNAT/IPv6;上限 `MAX_ENTRIES=8`、`MAX_ENTRY_LEN=512` | `directory.ts:190–208 / 322–337 / 355–372 / 61–62` | +| 客户端建连 = `new Ctor(this.opts.url)`,`WebSocketCtor` **可注入**(默认全局 `WebSocket`)⇒ 覆盖地址/SNI 有落点 | `src/net/relay/client.ts:63/415` | +| 目录刷新周期 = 300 s;取目录超时 = 5 s | `directory.ts:58/472` | +| relay 容量/准入 = `--max-hosts`(现为 `0` = 不限)+ `/status.capacity` + `at-capacity`/`retryAfterMs`(5000 ms) | `main.ts` argv、`server.ts:63` | +| 47 的 relay 单元 = `/etc/systemd/system/dshs-relay.service`(`--port 20080 --keys-file /etc/dshs/relay-keys.json`);拨号白名单 drop-in = `dshs-relay.service.d/dialers.conf` | `交接单_relay落地R2-R4_20260916.md` §9/§11 | +| 门户 443 块 = `/www/server/panel/vhost/nginx/alotbuy.com.conf`;`dsh.alotbuy.com.conf` 是**遗留 301 域名**(⛔ 不能往它里面加 location) | 同上 §9 | + +## 附 B · 硬约束复述(防走偏) + +- ⛔ **本轮只出单**:⛔ 不改服务器、不改代码、不 commit、不 push、不做序 ⑤、不碰 presence。 +- ✅ **只做正向迭代(R11)**:本单任何一步若让任一维度**净变差** ⇒ 立即停下复盘;拿不出正向做法 ⇒ 停止执行。 +- ✅ **权限只准收窄(R5)**:本单**零新增公网端口、零新增入站面、零凭据外发**;若有任一改动会扩大权限面 ⇒ 先停下报告。 +- ⛔ **R7**:只做 §3.1 列出的改动;执行中发现别的"小问题"**先报告、后动手**。 +- ⚠️ **curl 判据必须 `--http1.1`**(门户 `http2 on`,否则假 `HTTP/1.1 404`/`HTTP/2 404`)。 +- ⚠️ **47 的 lib = `/opt/dshs/lib/`、106 = `/opt/dshs-cluster/lib/`**;**部署 = 本机 build 后 scp**;`npm test` 必须 **Node 22**。 +- 📌 **本单不做容量承诺**(参数表 = 序 ⑤);**不做跨机真容灾**(L3,需 R5 权限评估)。 + +--- + +# §8 执行回报(执行棒 · 2026-09-17 09:1x) + +> 执行会话:`覆盖网络线-序4执行棒` | 锁:`--claim-exec` 抢到(09:00)→ 收口 `--release-exec` +> 本单 md5(回填前)= `b9aa6bbc0a458481627f7aeb1f17ab54` —— 与规划棒交付值**逐字一致** ⇒ 无人改动过。 + +## 8.1 S0 快照(P1–P8:命令原文 + 输出 + 判定) + +**先把三处"与期望不符"说清**(都是**判据本身要改口径**,不是故障): + +**(1)P1 的 `nginx` 一栏 = `inactive`。** 真因:本机 nginx **不归 systemd 管** —— 主进程 = `/www/server/nginx/sbin/nginx -c /www/server/nginx/conf/nginx.conf`(宝塔直接拉起),`nginx.service` 是 sysv 生成的壳、状态恒 `inactive (dead)`。 +⇒ **判据改为** `ss -lntp | grep :443` + `pgrep nginx`。服务实际正常(443 在听、门户 200)。 + +**(2)P3 的目录端点在 `nginx -T` 里没有独立 location。** 实测只命中门户块的 `location /dshs-relay`;`/dshs-overlay/bootstrap` 是被门户块 `location /`(→ `127.0.0.1:3080`)**顺带**承载的。 +⇒ 按 P3 的"🔴 若没有 ⇒ S1 要补",S1 补了独立 location;并因此发现第二件事(见 8.4-D4)。 + +**(3)P4 的 DNS:`relay-direct.alotbuy.com` 能解析,且落在 CF 段。** +判据:随机名 `zzz-rand-9f3a.alotbuy.com` **同样**解析到同一对 CF IP ⇒ 本域是 **`*.alotbuy.com` 泛解析(橙云代理)**,不存在"没有记录"这一情形。 +⇒ 这条使 **D2 的字面判据不可满足**(实质判据已证,见 8.4-D2)。 + +| # | 命令(原文) | 输出(原文摘要) | 判定 | +|---|---|---|---| +| P1 | `systemctl is-active dshs dshs-relay dshs-pg nginx` | `active active active inactive` | ⚠️ 见上(nginx 改口径) | +| P2 | `ss -lntp \| grep -E ":(443\|20080)\b"` | `127.0.0.1:20080`(node) + `0.0.0.0:443`(nginx) | ✅ relay 只绑回环(**不是** `0.0.0.0`) | +| P3 | `nginx -T \| grep -nE "dshs-relay\|dshs-overlay"` | 仅 `location /dshs-relay {` | ⚠️ 见上(目录端点由 `location /` 承载) | +| P4 | `openssl s_client … -servername relay-direct.alotbuy.com \| grep -A1 "Subject Alternative Name"` | `DNS:*.alotbuy.com, DNS:alotbuy.com` | ✅ 零新证;DNS ⚠️ 见上 | +| P5 | `grep -rn "DSHS_OVERLAY\|DSHS_RELAY_URL\|DSHS_RENDEZVOUS" /etc/systemd/system/dshs.service.d/ /etc/dshs.env` | 106 同样 | 47:**无** `DSHS_RELAY_URL` / 无 seeds(`cluster.conf:15` 注明 P0-2 已清)|106:**无** `--url` / `DSHS_RELAY_URL`,但有 `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay` | ✅ 符合预期 ⇒ **S2-附 不触发**(见 8.2) | +| P6 | `curl -s --http1.1 --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/dshs-overlay/bootstrap` | `200` + `{"version":1,…,"relays":["https://alotbuy.com/dshs-relay"],"bootstrap":["https://alotbuy.com/dshs-relay"],"sig":"…"}` | ✅ | +| P7 | `curl -s 127.0.0.1:20080/status` | `online=[manager, w-106]`、`capacity{max:0,used:2}`、`identityRequired:true`、`identityOk:3`、`trustedSigners:1`、`revokedHosts:0` | ✅ | +| P8 | `git -C D:/github/dsh_shenxian rev-parse HEAD` | `640813e84e5e48c56ca39d765d7b195884e58ebc` | ✅ | +| D8基 | `ss -lntp \| wc -l` / `nft list ruleset \| wc -l` | `79`(表头 1 + 78 条)/ `72` | ✅ 基线 | + +> ⚠️ **S0 顺带核出、必须记下来的一条事实**:`src/net/relay/` **整目录未被 git 跟踪** —— `git status --porcelain -- src/net/relay/` = `?? src/net/relay/`;`git ls-tree -r --name-only HEAD -- src/net/relay/` = **0 个文件**。 +> ⇒ 覆盖网络线(R1 起)的代码**只存在于工作区 + 部署产物**里。这既是"⛔ 别 `git checkout`"那条纪律的**实证依据**,也意味着 47 上的 `*.bak-*pre-443fb` 是本单**唯一**的改前基线(见 8.3)。 + +## 8.2 S2-附:**不执行**(前提不成立)+ 一处必须报告的风险(未动手) + +P5 实测 106 **没有** `--url` / `DSHS_RELAY_URL` ⇒ **S2-附 的触发条件不满足,未执行**(符合单里"若…才有"的条件式写法)。 + +但顺手核出的这条**必须报**(⛔ 未动手):106 上确有 `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay`,它**不是** `DSHS_RELAY_URL`、**不参与引导链**,而是 **worker agent 的会合地址**: + +- 依据(本地代码,逐行可核):`src/worker/agent.ts` → `rendezvousRaw = options.tunnelTarget ?? config.clusterRendezvousUrl ?? ''`;`src/config.ts` → `clusterRendezvousUrl = DSHS_RENDEZVOUS_URL ?? DSHS_TUNNEL_TARGET ?? ''`;随后 `rendezvousRaw === '' ? undefined : …` **直接构造 `RelayTunnel({ url: rendezvousRaw })`**。 +- ⇒ **106 的 agent 面根本不消费引导链**:该变量一旦按 S2-附 的字面撤掉,`rendezvousRaw` 变空 ⇒ `tunnel === undefined` ⇒ **106 的 agent 隧道直接不起**(生产回归)。 +- ⇒ 结论:S2-附 的写法("撤掉 env、改由 seeds 表达")**在 106 上不成立**,**未执行**,106 现状逐字未动(登记为遗留 8.7-②)。 + +## 8.3 落盘清单(绝对路径 + 备份 + 改前/改后指纹) + +**本机代码(`D:\github\dsh_shenxian`)** —— ⚠️ 全部**未 commit、未 push**(本单明令) + +| 文件 | 动作 | 指纹 `git hash-object` | +|---|---|---| +| `src/net/relay/addr-override.ts` | **新建**(地址覆盖模块) | `4703a73ac82b66d113005c7fc10557d64b76407c` | +| `src/net/relay/client.ts` | 改(建连点注入) | `5152378446987a97fa3dbff64398c6e2737d03f5` | +| `src/net/relay/directory.ts` | 改(取目录腿注入 + 同源优先) | `36e2a86874279c818faa20aa2b66687f92cf2f25` | +| `test/overlay-bootstrap.test.mjs` | 改(+7 用例 L1-A…G) | `8aef224e5053c78fe56de716d6e408b549475a7e` | + +> 本机**取不到 git 改前基线**:`src/net/relay/` 与 `test/overlay-bootstrap.test.mjs` 均不在 HEAD(见 8.1 注)⇒ 改前基线以 47 上的 `.bak-*` 为准(下表)。 + +**47 · `/opt/dshs/lib/net/relay/`**(部署 = 本机 `npm run build` 后 scp) + +| 文件 | 动作 | 改前(备份) | 改后(生效) | +|---|---|---|---| +| `addr-override.js` | 新增 | —(新文件) | `a2bb27c3b021b49c1e35b707221a9871` | +| `client.js` | 覆盖 | `client.js.bak-20260917-0901-pre-443fb` = `8cbeeb029e856ce174dd91fdd44a739a` | `bc24cb70d047f0e711a17db87dfa6246` | +| `directory.js` | 覆盖 | `directory.js.bak-20260917-0901-pre-443fb` = `a5ca2103dee32dde154d965da9d471e0` | `214905d476c185baf87358c8a5e9d25e` | + +**47 · 配置** + +| 文件 | 动作 | 指纹 | +|---|---|---| +| `/www/server/panel/vhost/nginx/relay-direct.conf` | **新建**(独立 443 server 块) | `b7851b93c7bf885d73fc250813a4b42f` | +| `/etc/systemd/system/dshs.service.d/overlay-443fb.conf` | **新建**(seeds + 地址覆盖) | `8eac06167cc4b1a772b64b640f150a89` | +| `/www/server/panel/vhost/nginx/alotbuy.com.conf` | **未改**(D3 临时改后**用备份逐字节还原**) | 改前 = 改后 = `a747cabc2c03911e831a714f1e89a47d` ✅ | +| `…/alotbuy.com.conf.bak-20260917-0900-pre-443fb` | 备份(符合 §5 命名要求) | `a747cabc2c03911e831a714f1e89a47d` | + +**106**:⛔ **未动任何文件**(S2-附 不触发,见 8.2)。 + +## 8.4 D1–D8 逐条(命令原文 + 原文输出 + 绿/红) + +| # | 命令(原文摘要) | 原文输出 | 判定 | +|---|---|---|---| +| **D1** | `curl -i -s -N --http1.1 --resolve relay-direct.alotbuy.com:443:<IP> -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://relay-direct.alotbuy.com/dshs-relay \| head -1` | `HTTP/1.1 101 Switching Protocols` | 🟢 | +| **D2** | ① 同 D1 但 `--resolve …:443:47.77.182.89`(本机从公网探)② DNS 探针 | ① `HTTP/1.1 101`、`Server: nginx`、**无 `cf-ray` / `Server: cloudflare`** ② `relay-direct.alotbuy.com -> ['104.21.44.42','172.67.194.206']`(CF 段) | 🟢 实质 / 🔴 字面 | +| **D3** | `nginx -T \| grep -c "server_name relay-direct.alotbuy.com"`;临时注释门户块 `location /dshs-relay` → 复测 → **还原** | `1`;门户 = `HTTP/1.1 502 Bad Gateway`(已失效)、兜底 = **`HTTP/1.1 101`(不受影响)**;还原后 `md5 = a747cabc…`(逐字节一致)、门户 `101` / 兜底 `101` / 首页 `200` | 🟢 | +| **D4** | `curl -s --http1.1 --resolve relay-direct.alotbuy.com:443:<IP> https://relay-direct.alotbuy.com/dshs-overlay/bootstrap` | `HTTP=200` + `{"version":1,…,"relays":["https://alotbuy.com/dshs-relay","https://relay-direct.alotbuy.com/dshs-relay"],"bootstrap":[…],"sig":"…"}` | 🟢 | +| **D5** | `curl -s --http1.1 https://alotbuy.com/dshs-overlay/bootstrap` | `relays` = `["https://alotbuy.com/dshs-relay","https://relay-direct.alotbuy.com/dshs-relay"]`(**主入口首位**);`bootstrap` 同;`issuedAt` 由 `…00:54:03` → `…01:01:46` | 🟢("version"口径见下) | +| **D6** | 见 8.5 | 见 8.5(拒绝原因 + 采用兜底 + **注册成功**) | 🟢 | +| **D7** | P2 复跑 / 双机面 / identity / 门户 / `npm test` | 见 8.6(**逐项一致**) | 🟢 | +| **D8** | `ss -lntp \| wc -l` / `nft list ruleset \| wc -l` / 外网探 5 个端口 | `79`(= S0)/ `72`(= S0)/ 全部"关闭" | 🟢 | + +**D2 的绿/红必须说清**:字面判据要"`dig` 无记录 / 与 CF 段不同",而本域是 **CF 泛解析** ⇒ 字面**不可满足**(🔴)。实质判据("兜底入口不经 CF")已用两条**独立**证据坐实 🟢: +① `--resolve` 直连 47 真实 IP ⇒ `101`,响应头 `Server: nginx`、**无任何 CF 特征头**; +② 客户端侧(8.5 的 A 轮)把该域**覆盖到 `127.0.0.1`** ⇒ 仍 `101` 且注册成功 ⇒ 链路里没有 CF。 +> 额外数据点:走**真实 DNS(= CF)** 的同一请求**也** `101` ⇒ 兜底入口在 CF 路径下同样可用(并非"只在直连时可用")。 + +**D5 的"version 递增"必须说清**:目录的 `version` 是**结构版本**(`directory.ts` 的 `DIRECTORY_VERSION = 1`,常量)⇒ **永不变**,"递增"这条判据物理上不成立。判"确有更新"应看 `issuedAt` 前移 + 两个数组内容变化,二者本次均已取到(见上表)。 + +**D4 顺带发现并已修的一处**(S1 施工时暴露):平台(3080)**先按 Host 做租户路由、再进路由表**,直接用兜底子域回源会命中 `404 {"error":"unknown_user"}`。 +修法 = 该 location 回源时把 `Host` 改写为既有目录 origin(`alotbuy.com`)—— 即"**同源**"在入口层的等价翻译,发的就是门户今天在发的**同一个公开只读请求**,**不扩大任何权限**(该 location 用 `=` 精确匹配、本块其余路径一律 404)。 + +## 8.5 S4 降级实测(日志原文) + +**只在独立测试进程上做**:`/tmp/fb443/lib` = `/opt/dshs/lib` 的**副本**,⛔ **未改生产 seeds**;`DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://127.0.0.1:9/dshs-relay,https://relay-direct.alotbuy.com/dshs-relay`。 + +**A 轮(`DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=127.0.0.1`)—— 成功,日志原文:** +``` +[addr-override] 覆盖生效:relay-direct.alotbuy.com -> 127.0.0.1(IPv4,直连该 IP,TLS SNI 仍为 relay-direct.alotbuy.com) +[overlay-dir] ⚠ 拒绝 https://127.0.0.1:9/dshs-overlay/bootstrap(unreachable: fetch failed)—— 未签名 / 签名不符的目录**不写缓存、不采用** +[overlay-dir] ↪ 同源优先:目录由 https://relay-direct.alotbuy.com/dshs-overlay/bootstrap 答出 ⇒ 采用其同源中继入口 wss://relay-direct.alotbuy.com/dshs-relay(不在 relays[] 首位;首位 wss://alotbuy.com/dshs-relay 本次未被采用) +[relay-client] registered host=w-47 network=ops session=7cbddf7256503e02 accepted=[19999] clockSkew=4ms +``` +① 逐 origin 拒绝原因 ✅ ② 采用兜底项 ✅ ③ **注册成功** ✅ ④ 同时坐实 **S3 的「地址覆盖 + 指定 SNI」**:TCP 到 `127.0.0.1`、SNI = `relay-direct.alotbuy.com`、证书(`*.alotbuy.com`)校验通过。 +(首轮曾用 `--ports 29999`,被 relay 以 `AUTH DENY … port-out-of-range retryable=false` 拒 —— 属测试参数越界,改 `19999` 即通过。) + +**B 轮(`<IP>=192.0.2.1`,TEST-NET 不可路由)= 判定对照:** +``` +[overlay-dir] ⚠ 拒绝 https://relay-direct.alotbuy.com/dshs-overlay/bootstrap(unreachable: The operation was aborted due to timeout) +[overlay-dir] ⚠ 取目录全部失败 ⇒ 回落到内置种子地址本身:wss://127.0.0.1:9/dshs-relay +``` +⇒ 覆盖**确实改写了目标地址**(若没生效,DNS 会给 CF、而 CF 是通的 ⇒ 不会超时)。这是 A 轮结论的反证。 + +**回收**:测试进程用完即走(`timeout` 收尾),业务面已核无残留(见 8.6 末条)。 + +## 8.6 不退化(P2 复跑对照 / identity / 门户 / `npm test`) + +| 项 | S0 | 收口 | 判定 | +|---|---|---|---| +| 监听面 | `127.0.0.1:20080` + `0.0.0.0:443` | 逐字一致 | ✅ | +| 监听口总数 | `79` | `79` | ✅ | +| `nft` 行数 | `72` | `72` | ✅ | +| `online[]` | `[manager, w-106]` | `[manager, w-106]` | ✅ | +| `endpoints` | `w-106:19000 / 21000 online` | 逐字一致(S4 残留的 `w-47:19999` 已清,见下) | ✅ | +| `identityRequired` / `trustedSigners` / `revokedHosts` | `true` / `1` / `0` | `true` / `1` / `0` | ✅ | +| 47 agent(19100) / 实例面(3080) | `200` / `200` | `200` / `200` | ✅ | +| 106 agent(19000) / `dshs-worker` | — | `200` / `active` | ✅ | +| 门户首页 | `200` | `200` | ✅ | +| `npm test`(Node **22.22.2**) | — | **137 用例 / 136 通过 / 1 跳过 / 0 失败**(含本单新增 7 条 `序④·L1-A…L1-G`) | ✅ | +| 兜底日志里的 404 | — | 仅 `/portal.html`、`/` 两条 = **我自己收窄验证的探针**,无意外 404 | ✅ | + +> **计数器口径澄清**(否则会被读成回归):`authed` / `authFailed` / `identityOk` 是**累计计数**,"逐字一致"物理上不成立。本次全部增量都能**逐条归因**: +> - `authFailed=3`(中间态)= **2** × `[relay] AUTH TIMEOUT`(= D1/D2 的 `curl` 101 探针:连上但不发 HELLO,relay 等鉴权超时)+ **1** × `[relay] AUTH DENY … port-out-of-range`(= A 轮那次的参数越界)。 +> - `identityOk` 增量 = `dshs` / `dshs-relay` 每次重启后 manager 重连各 +1。 +> - 收尾**重启了一次 `dshs-relay` 清账**(顺手清掉 S4 残留的 `w-47:19999` 端点与其动态回环口 `43439` —— 它一度把监听口顶到 `80`)⇒ 收口态:`online=[manager,w-106]`、`endpoints` 只余 w-106 两条、监听口回 `79`、`identityOk=2`(两台节点各一次认证成功)、`authFailed=1`(收口后又打了一次 101 探针)。 +> ⇒ **读数提醒**:**101 探针本身会污染 `authFailed`**。以后看这个计数器,先扣掉自己打的探针次数。 + +## 8.7 未过项 / 遗留(逐条:卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决) + +**① D2 字面判据不可满足(实质已证)** +- **卡在哪**:`*.alotbuy.com` 是 **CF 泛解析**(证据:随机名 `zzz-rand-9f3a.alotbuy.com` 同样解析到 `104.21.44.42 / 172.67.194.206`)⇒"该子域没有 DNS 记录"这个前提**不存在**。 +- **已做到哪一步**:实质判据(不经 CF)已用两条独立证据坐实(8.4-D2);且 **§4.3 的 A / B 两个候选都不需要**(既不需要人工加记录,也不需要 CF 凭据)⇒ **本单零依赖关闭**。 +- **什么条件一出现必须回头解决**:若要让**解析层**也不经 CF(例如应对 DNS 层投毒/污染),才需要把该子域设成**灰云直连 A 记录**;在那之前,运行期的"去 CF"由 `DSHS_OVERLAY_ADDR_OVERRIDES` 保证。 + +**② 106 的 agent 面不吃引导链**(已定位,⛔ 未动手) +- **卡在哪**:见 8.2 —— `worker/agent.ts` 把 `DSHS_RENDEZVOUS_URL` **直接当 relay URL 使用**,不经过 `resolveOverlayRelay`。 +- **已做到哪一步**:定位到行、判定"S2-附 字面执行会造成生产回归(`tunnel === undefined`)"⇒ **未执行**;106 现状逐字未动。 +- **什么条件一出现必须回头解决**:要做"106 agent 面也能用兜底",必须先让 worker 侧接入引导链(改 `agent.ts` / `relay-tunnel.ts`)—— 属**序 ⑤**范围,⛔ 不在本单。 + +**③「兜底项在取址路径上原本永远选不中」—— 本单已补最小修法** +- **卡在哪(原缺陷)**:`relays[]` 由 seeds 按序生成、`pickFromDoc` 取**首位**(= 主入口),而 S2/D5 又要求**主入口首位** ⇒ 生产链路上**没有任何一支**会去连兜底项,§1 的目标与 §5-S4/D6 的前提同时落空(S4 之所以"看起来"能过,只因为测试里首个 origin 用了回环口 `127.0.0.1:9`、被 `publicRelayEntries` 过滤掉 —— 属**测试参数的偶然**,不是生产机制)。 +- **已做到哪一步**:补了**「同源优先」**(`sameOriginRelayUrl`):**谁答出目录,就用谁的同源中继入口**。它**不是新概念** —— 正是既有约定「**引导地址 = 中继入口同源**」在**选择时刻**的落地。`S4/A` 与新增用例 `L1-F` / `L1-G` 分别证明"兜底被采用"与"主入口通时逐字不变"。 +- **为什么必须改**(按 §4.1"要改必须回写 §8 并说明理由"):① **不动任何 §4.1 已定项** —— seeds 顺序仍是**主入口首位**,目录内容与 D5 判据不变;② **零退化** —— 主 origin 通时同源项就是首位,行为逐字一致(`L1-G` 断言);③ 不补它 ⇒ 本单只交付一个"能 101 但**没有任何客户端会去连**"的端点,**§1 的目标不成立**(只剩装饰性)。 +- **什么条件一出现必须回头解决**:若将来 `relays[]` 引入"非首位更优"的显式优先级语义(序 ⑤ 参数表可能引入),需重新定义"同源优先"与它的先后关系。 + +**④ 引导链的缓存两支没有"答出者"信息**(已知边界) +- **卡在哪**:第 ②/④ 步(新鲜/过期缓存)只存 `doc+sig`,不记"当初哪个 origin 答的"⇒ 这两支仍走 `relays[]` 首位。 +- **已做到哪一步**:同源优先只作用于第 ③ 步(取目录成功),已在代码注释里写明。 +- **什么条件一出现必须回头解决**:CF 打挂后,**已入网节点要等一个刷新周期**(`refreshAfterSeconds`,现为 **300 s**)才切到兜底。若要"立刻切",需在缓存里记 origin。 + +**⑤ 顺带发现、⛔ 未动手的两项**(R7:先报告、后动手) +- `bt-server` 这个 ssh 别名端口**已陈旧**:`~/.ssh/config` 写 `Port 32022`,实测 `Connection refused`;`-p 22` 正常。⇒ 本次全程用 `-p 22`,**未改** `~/.ssh/config`。 +- **`src/net/relay/` 整目录未被 git 跟踪**(HEAD 里 0 文件)⇒ 覆盖网络线代码只在工作区 + 部署产物里。**未做任何 git 动作**(本单明令不 commit / push)。 + +## 8.8 45% 口径(登记,⛔ 无具体数值) + +> **登记行**:兜底启用后,relay 容量须按 **45% 的节点走中继**核算(异构纪律,⛔ 不是同构的 15%)。 +> **待填参数位置**(留给**序 ⑤ 参数表**,本单不填数值):relay 侧 `--max-hosts`(现为 `0` = 不限);`/status` 的 `capacity{max,used}`;满载退避 `at-capacity` / `retryAfterMs`(现 5000 ms)。 + +## 8.9 指纹 + +- **回填前**(规划棒交付)= `b9aa6bbc0a458481627f7aeb1f17ab54`(执行棒开工前已复核,逐字一致) +- **回填 §8 正文后**(尚未写本节时)= `594e0b404bef18fb3959e48901153649`(`40996` 字节) +- **本节口径**(**推荐核对用**,可复现):**整个 §8.9 不计入** ⇒ `40997` 字节。复核命令: + `sed '/^## 8\.9 指纹$/,$d' 交接单_443兜底_20260917.md | md5sum` ⇒ `3d15b94069f289124d92da367e806bb5` + (比上一行多 1 字节 = 正文与本节之间的空行) +- **全文件 md5**:请用 `md5sum 交接单_443兜底_20260917.md` 现取 —— ⛔ 此处**故意不内嵌数值**: + 它包含本节自身,写进去即刻失效(自指)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T13-中继失败切流.md b/dsh-server-docs/交接单/archive/交接单-已完成/T13-中继失败切流.md new file mode 100644 index 0000000..def0379 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T13-中继失败切流.md @@ -0,0 +1,426 @@ +# 交接单 · 中继失败切流(覆盖网络线 · 序 ⑦) + +> **生成**:2026-09-17(规划棒 · 只出单,⛔ 不改服务器、不改代码) +> **上游依据**:`交接单_最小形态真机批次_20260917.md` **§8.8-1**(本单唯一未过项 E9 后半)+ **§8.6**(第二中继 106 已可用)+ 参数表 `参数表_覆盖网络_20260917.md` **§9 第 5 行** +> **一句话**:让「**杀掉任一台中继 ⇒ 客户端自动切到另一台**」成立。 +> ⛔ 本单**不重做**序 ②–⑥ 的任何一步;⛔ 不做 presence / 房间层 / 内容分发;⛔ 不 commit / 不 push。 + +--- + +## §1 目标 + +**总目标(一句话判据)**:**杀掉任一台中继 ⇒ 正在使用它的客户端在 ≤ `RELAY_FAILOVER_DEADLINE_MS`(建议 30 s)内切到另一台,业务面无人工干预恢复;两台同时挂时不比现状更差。** + +拆成三条互相独立、可分别判定的子目标(⛔ 只做前两条不算完成): + +| # | 子目标 | 反例(现状为什么不过) | +|---|---|---| +| **G1** | 客户端在"当前中继不健康"时,**重新解析候选地址**(而不是继续重试同一个地址) | 现状:`RelayClient` 退避**永不放弃**地重试**同一 url**(`src/net/relay/client.ts:32` 明确写了"永不放弃") | +| **G2** | 重解析时**排除刚失败的那一台**;切过去后失败台进入**冷却**,冷却期满自动回归候选表 | 现状:解析层**永远只返回第一个候选**(`directory.ts:348` `pickFromDoc` `return url`,取到即返回)⇒ 即使重新解析也还是同一台 | +| **G3** | 切换动作在日志与可读计数上**可 grep 断言**(判别器纪律) | 承接 §8.8-2 教训:静默失效没有判别器 = 查不出来 | + +**覆盖对象 = 三处 relay 客户端**(本单**三处都要覆盖**,因为它们共用同一份「取址 + 连接」代码但装配点不同): + +| # | 客户端 | 代码落点 | 现状 | +|---|---|---|---| +| **C1** | Manager 拨号通道(47) | `src/web/server.ts:471` `refreshOverlay` | 有周期重解析,但换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关** | +| **C2** | worker 实例面 relay 客户端 | `src/worker/tunnel.ts` 所用的 `RelayClient` 装配点 | **无重解析** —— 启动解析一次后钉死 | +| **C3** | 独立 `relay --client` | `src/net/relay/main.ts:220` | **无重解析** —— 解析一次写进局部变量,此后只用它(`main.ts:220-234`) | + +> ⚠️ **C1 的"地址没变"其实是假象**:`refreshOverlay` 确实周期调用 `resolveOverlayRelay`,但该函数经由 `pickFromDoc` **恒返回 `[...relays, ...bootstrap]` 里第一个可用项**(`directory.ts:348-355`)⇒ 只要目录内容不变,重解析结果与当前 url **逐字相同** ⇒ 被判为"无变化"直接 `return`。**所以根因是"候选集退化成单点",不是"没写重解析"** —— 这一点是本单对 §8.8-1 的**证据级细化**(§8.8-1 只写到"换址条件是目录地址变了")。 + +--- + +## §2 只读前置(⛔ 只读,不改;P1–P8 逐条核实后才允许进 S 段) + +| # | 命令(原文照抄执行) | 期望 / 判据 | +|---|---|---| +| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`;收口前复核 OWNER = 本会话名。**抢不到 = 有会话在跑 ⇒ 只报告并停** | +| **P2** | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` | 锁 owner = 我;HEAD = `640813e`;入口 = `接续入口_覆盖网络线_20260916.md` 且 §2 的「🎯 本轮动作」= 本单 | +| **P3** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`db1317c2f7aaef7b47785c1f4fc9de03`**(= 序⑥ 收口值)。**不一致 ⇒ 参数表被人动过 ⇒ 停下报告,⛔ 不进 S 段** | +| **P4** | `cd "D:/github/dsh_shenxian" && grep -n "export async function resolveOverlayRelay\|function pickFromDoc\|const refreshOverlay\|relayUrl = resolved.url" src/net/relay/directory.ts src/web/server.ts src/net/relay/main.ts` | 行号与附 A 一致(允许平移,**但函数必须仍存在**)⇒ 单内锚点有效 | +| **P5** | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status \| head -c 400'` + `ssh test106 '…同款…'` | 两台 relay 均 `active`;`/status` 可见在册会话;`capacity.max=7515`。⚠️ **106 的 `systemctl is-active nginx` = `inactive` 是正常态**(宝塔 `bt.service` 托管),⛔ 不许据此判故障 | +| **P6** | `cd "D:/github/dsh_shenxian" && npm.cmd test`(**必须 Node 22**) | 基线 = **138 tests / 137 pass / 0 fail / 1 skipped**。⛔ 拿不到这个数就不许改代码 | +| **P7** | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt 2>&1; echo $?; cat /tmp/seq7-probe-s0.txt` | 期望 **12/12 PASS**、退出码 `0`。🔴 **必须先落盘再读** —— §8.8-3 的回头条件就是"S0 原始输出未留存",⛔ 不许重犯 | +| **P8** | `cp -a "<改动文件>" "<改动文件>.bak-seq7-<YYYYMMDD-HHMMSS>"`(逐文件)+ `git status --short \| wc -l` | 备份已建;改动前工作区改动数已记录(用于 S8 对账"只多了我的文件") | + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| # | 文件 | 改动 | 理由(为什么非改不可) | +|---|---|---|---| +| 1 | `src/net/relay/directory.ts` | ① `pickFromDoc` 从"返回第一个"改为"**返回全部候选**(`[...relays, ...bootstrap]` 顺序,去重)";② 新增 `listOverlayRelayCandidates(opts)`(**同一份引导链代码**,⛔ 不许另写一份取址);③ `resolveOverlayRelay` 入参新增 `exclude?: readonly string[]`,实现改为"候选列表 → 剔除 exclude → 取第一个";**✅ 不传 `exclude` 时行为逐字不变**(存量调用点零影响) | G1 + G2 的地基:没有候选集,任何切换都是空谈 | +| 2 | `src/net/relay/client.ts` | 新增**只读**快照字段(`unhealthySinceMs` / 已有 `attempts`、`nextRetryMs`、`reconnects`)供监管器判据;**⛔ 不改状态机语义、不改退避算法** | G1 的触发信号。**判据已经存在**(`client.ts:381-391` 的快照 + `client.ts:32` 的退避语义 + `client.ts:608` 的 half-open 检测)⇒ 本单**不新造心跳** | +| 3 | **新增** `src/net/relay/switcher.ts` | **唯一一份**切换逻辑:`onUnhealthy()` → 重解析(`exclude=[当前 url]`)→ **先建新的、成功了再关旧的** → 更新当前 url;失败台进冷却表 | D1。三处各写一份 = 三份静默失效来源(本线已有两次同类教训:`translateEndpoint` 漏赋值、`target()` 静默回退) | +| 4 | `src/web/server.ts` | `refreshOverlay`(`server.ts:471`)换址条件改为「**地址变了 ∨ 当前通道不健康**」,并接 `switcher`;沿用既有 `startDialer` 的"先建新、成功再关旧"(`server.ts:413-436`) | C1 | +| 5 | `src/worker/tunnel.ts`(或其 relay 客户端装配点) | 接入同一 `switcher` | C2 | +| 6 | `src/net/relay/main.ts` | `--client` 分支(`main.ts:220-234`)接入同一 `switcher` | C3 | +| 7 | `test/relay-failover.test.mjs`(新) | 先红后绿:两台假 relay(本机两个回环端口)+ 杀其一 ⇒ 断言切到另一台;断言"两台全挂 ⇒ 不切到空";断言"冷却期内不回跳" | 本线纪律:**先红后绿已实证两次**(`translateEndpoint`、`target()`) | +| 8 | `scripts/overlay-failover-drill.cjs`(新) | **一条命令产 PASS/FAIL** 的真机演练脚本(杀 106 / 杀 47 / 依次),**阈值全部从参数表读,脚本内零数字字面量** | 承接参数表纪律;⛔ 不把"真机取证"交给 agent 手工做 | +| 9 | `参数表_覆盖网络_20260917.md` | 新增 `RELAY_FAILOVER_*` 键(§4.1 D3/D5)+ **§9 第 5 行**从"未纳入边界"改为"**已闭环**(本单)"+ §7 计数相应回填 | 参数表 = 单一来源 | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ **不动 worker 会合面**(`worker/agent.ts:156` 的 `DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET`)。**理由(已是定论,⛔ 别再复议)**:按 S2-附字面撤掉 = `tunnel===undefined` = **生产回归**;参数表 §9 第 2 行已判"只记录、不许顺手撤"。本单只覆盖**实例面 / 拨号面 / 独立 client** 三条 relay 通路。 +- ⛔ **不改 relay 服务端协议**(`DIAL`/`DIAL_ACK` 已有;本单不需要新帧)。**唯一例外**:若 S1 发现需要 relay 侧告知"本台要下线"(BYE 已有,见 `client.ts:674`)⇒ **先停下报告**,不在本单实现。 +- ⛔ 不动 nft / 安全组 / nginx;⛔ **不新增任何公网监听口**;⛔ 不新增凭据。 +- ⛔ 不重做序 ②–⑥;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。 +- ⛔ 不 commit / 不 push(未获授权)。 +- ⛔ 执行中发现的**其它缺陷先报告、不顺手改**(R7)—— 已知在册的有:guest(w-106) 实例页 502|`/opt/dshs/mksess*.cjs` 已失效(开临时 session 走 PG 直插)|`index.ts` 无关项。 + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +| # | 已定 | 理由 / 反例 | +|---|---|---| +| **D1** | **一份实现、三个装配点复用**:切换逻辑只写在 `switcher.ts`,⛔ 不许在 C1/C2/C3 各写一份 | 本线已有两次"漏赋值 / 静默回退"教训,多点实现 = 多点静默失效 | +| **D2** | 失败判据**复用已有状态机**,⛔ 不新造心跳 / 不新增探测帧 | `RelayClient` 已有 `attempts` / `nextRetryMs` / `reconnects` 快照(`client.ts:381-391`)与 half-open 检测(`client.ts:608`)⇒ 造第二个判据只会打架 | +| **D3** | 触发阈值(**已定数值**,写进参数表):**`attempts ≥ 3` ∨ `backoff` 持续 ≥ `RELAY_FAILOVER_GRACE_MS`(建议 15 000 ms)** | 二选一取"或":106 侧首连窗口可能很长(跨云 ~336 ms 心跳口径,见参数表),只看 `attempts` 会误切;只看时间会在秒级故障上反应过慢 | +| **D4** | 切换顺序 = **先建新、成功再关旧**;新通道任一步失败 ⇒ **保持原通道** | 沿用 `server.ts:413-436` `startDialer` 既有语义。⛔ 反过来做(先关后建)会在切换失败时把"本来能用"的通路打掉(**违反 R11**) | +| **D5** | **排除 + 冷却**:被切换掉的 url 进冷却表 `RELAY_FAILOVER_COOLDOWN_MS`(建议 300 000 ms,与 `DIRECTORY_REFRESH_SECONDS` 对齐);冷却期满自动回归候选表 | 不冷却 ⇒ 两台互相抢(A 挂 → 切 B → A 恢复 → 切回 A → 再挂…)= 抖动风暴 | +| **D6** | **无候选时不切换**:重解析结果只有失败那一台(或为空)⇒ **保持原地退避重试**(= 现状),⛔ 不切到空、⛔ **不静默回退默认机** | 纪律④「路由未命中 ⇒ 先补齐再失败关闭,绝不静默回退默认机」。R11:两台全挂时行为**与现状逐字一致**,不许更差 | +| **D7** | **判别器必须是断言得出来的**:每次切换输出**一行** `[relay-switch] …`,并在进程可读面暴露 `switches` 累计计数;演练脚本据此断言 | §8.8-2 的教训 —— `DIAL` 当时只有日志行 ⇒ 脚本无法断言。⛔ 本单不许再留这种缺口 | +| **D8** | **服务端零改动**(本单只做客户端;relay 侧最多只读) | 缩小失败域;且 106 的第二中继已就绪(§8.6) | +| **D9** | **兼容性**:不传 `exclude` 时 `resolveOverlayRelay` 行为**逐字不变**;新增 `listOverlayRelayCandidates` 不改存量调用点 | 存量调用点分布在 Manager / worker / `--client`,一次改语义 = 三处回归 | + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +模块/函数/参数命名|日志措辞|单测里两台假 relay 的搭建方式(回环端口 / 子进程编排)|演练脚本怎么杀进程(`systemctl stop` vs `kill -9`,**建议两种各测一次**)|冷却表数据结构(内存 Map 即可)|是否把 `switches` 暴露到 `/api/dsh/status` 还是只在 `/status` 快照 —— **只要满足 D7 的"可断言"即可**。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**本单 = 空。** 不存在"各有优有劣、客观标准分不出高下"的技术取舍。 + +> ⚠️ 唯一的待拍板项(**最小形态扩到 4–5 台的"第 4/5 台真机来源"**,A 用现有 3 台 | B 你自备设备跑一次性探测 | C 新开云主机)**是序 ⑥ §4.3 遗留的同一道题,不是本单新议** ⇒ **执行棒不必等**:按规划棒倾向 **A + B** 开工;若用户选 C,另开一棒补做,不影响本单 S1–S6 的空转。 + +### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致) + +① **先复用现成能力**(已有状态机 / 已有 `startDialer` 语义 / 已有 `resolveOverlayRelay` 引导链)→ ② **再改现有模块**(`directory.ts` 扩候选集)→ ③ **最后才新增模块**(只新增 `switcher.ts` 一个)→ ⛔ **不引第三方依赖**(本线已定:relay = 自研)。 + +### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**) + +| 维度 | 本单影响 | 判定 | +|---|---|---| +| **新增监听口** | **0**(客户端只拨出;不新增任何 `listen`) | ✅ 不扩大 | +| **新增凭据** | **0**(沿用既有节点密钥 `<net>/<hostId>`,不新增角色、不放宽白名单) | ✅ 不扩大 | +| **放宽访问控制** | **0**(relay 侧 `DSHS_RELAY_DIALERS` 白名单、成员资格校验**均不动**;切换目标必须是**已签名目录里的候选**,⛔ 不接受任意 url) | ✅ 不扩大 | +| **入站** | 106 入站**仍为 0**(worker 永远只拨出);47 无新增入站口 | ✅ 不扩大 | +| **暴露面净变化** | **0**;相比序⑥ 收口(新监听口 0 / 新凭据 0 / 入站 0)**逐条维持** | ✅ | + +> ⚠️ **若执行中偏离 D9(例如为"能切过去"而允许连接目录外的地址)⇒ 立刻停下**:那才是真的 R5(扩大信任面 ⇒ 等于任意重定向),必须回来出评估。 + +--- + +## §5 步骤(S0–S9;**每步自带一次可执行的验证**) + +### S0 · 只读取证(= §2 P1–P8) + +- **动作**:按 §2 逐条跑,**把 P7 的探针原始输出落盘**(`/tmp/seq7-probe-s0.txt`)。 +- **验证**:P1–P8 全部 ✅(P3 指纹必须逐字一致,P6 的 `npm test` 必须拿到 138/137/0/1)。 +- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,不进 S1。 + +### S1 · 候选列表 API(`directory.ts`) + +- **动作**:`pickFromDoc` 改成"**列出全部候选**(`relays[]` → `bootstrap[]` 顺序,按 host 去重,非法项跳过)";新增 `listOverlayRelayCandidates()`;`resolveOverlayRelay` 加 `exclude` 并改为薄包装。**保留既有 `source`/`detail`/`refreshAfterSeconds` 字段语义**(`env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`)。 +- **验证**:`npm test` 既有目录链用例**全绿**(`test/overlay-*.test.mjs`,尤其引导三级链的顺序与同源优先用例)。 +- **⚠️ 易错点**:**同源优先**(`directory.ts:640-660`)必须在**列表**上仍生效 —— 即"答出目录的那个 origin 的 relay 排第一,原首位排其后"。⛔ 别把它降级成"只看首位"。 + +### S2 · 不健康判据(`client.ts`) + +- **动作**:暴露只读快照(`attempts` / `nextRetryMs` / `reconnects` / 新增 `unhealthySinceMs`)。**⛔ 不动状态机、不动退避参数默认值**。 +- **验证**:单测断言"连接被拒 N 次后 `unhealthySinceMs` 非空、恢复 `up` 后归零"。 +- **⚠️ 口径**:`handshaking` 不计入不健康(正在握手 ≠ 挂了),只有 `backoff` 累计才计。 + +### S3 · `switcher.ts`(唯一实现)+ 单测(**先红后绿**) + +- **动作**:写 `switcher.ts`;`test/relay-failover.test.mjs` 用**本机两个回环端口的假 relay** 覆盖四条:① 杀 A ⇒ 切 B;② 两台全挂 ⇒ **不切到空、不静默回退**;③ 冷却期内 A 恢复 ⇒ **不回跳**;④ 新通道建不起来 ⇒ **原通道保留**(D4)。 +- **验证**:**先跑一次看红**(在没有 `switcher` 的情况下把用例写出来 ⇒ 必须失败),再实现到全绿。⛔ 不许跳过"先红"这一步。 + +### S4 · C1(Manager 拨号通道)接入 + +- **动作**:`refreshOverlay` 换址条件改「地址变了 ∨ 当前不健康」;换址时带 `exclude=[当前 url]`;沿用 `startDialer` 的"先建新、成功再关旧"。 +- **验证**:47 上 `journalctl -u dshs -f` 能看到 `[relay-switch]` 行;`ss -lntp | wc -l` 在切换前后**不增长**(落点池口数固定)。 + +### S5 · C2 / C3(worker 实例面 + `--client`)接入 + +- **动作**:`worker/tunnel.ts` 的 `RelayClient` 装配点与 `main.ts --client` 分支接同一 `switcher`。⛔ **不碰会合面**(§3.2)。 +- **验证**:`npm test` 全绿;本机起一个 `relay --client` 手动杀目标 relay,日志出现 `[relay-switch]`。 + +### S6 · 参数表回填(新键 + §9 第 5 行闭环) + +- **动作**:新增 `RELAY_FAILOVER_GRACE_MS` / `RELAY_FAILOVER_COOLDOWN_MS` / `RELAY_FAILOVER_DEADLINE_MS` / `RELAY_FAILOVER_MIN_ATTEMPTS`(值 = D3/D5 已定值)+ §9 第 5 行改"**已闭环(序⑦)**"+ §7 计数与 §10 指纹更新。🔴 **值格必须是纯数字**(§8.8-2 的假红坑)。 +- **验证**:`node scripts/overlay-probe.cjs` **仍 12/12 PASS**(新键不得让任何 OBS 变红)。 + +### S7 · 真机演练(**本单的"判据本体"**) + +- **动作**:跑 `scripts/overlay-failover-drill.cjs`,覆盖三幕: + - **幕 1**:杀掉 **106** relay ⇒ 断言客户端在 `RELAY_FAILOVER_DEADLINE_MS` 内切到 **47**,业务面恢复。 + - **幕 2**:杀掉 **47** relay ⇒ 断言切到 **106**。(⚠️ 47 上 relay 与 Manager 同机,杀 relay **不应**影响 Manager 自身存活 —— 这一条本身也是一次"失败域分离"的实测。) + - **幕 3**:**两台都杀** ⇒ 断言**行为与 S0 现状一致**(原地退避、不切空、不静默回退),且**恢复一台后 ≤ 冷却期回归**。 +- **验证**:脚本退出码 `0`;产出三幕的 `[relay-switch]` 日志行与 `switches` 计数原文。 +- **⛔ 硬门**:幕 3 若出现"静默回退到默认机"⇒ **立刻停手回滚**(违反纪律④ + R11)。 + +### S8 · 不退化 + +- **动作**:与 S0 逐项对照。 +- **验证**:① `ss -lntp | wc -l` = **79**(= S0 基线)② `nft list ruleset | wc -l` = **72** ③ `overlay-probe` **12/12 PASS** ④ 双实例面 ∈ `PROBE_CODE_SET`(401)⑤ 门户 `200` ⑥ `npm test` = **138/137/0/1**(⚠️ 若因新增用例而数量上浮,须写清"新增 n / 基线 138")⑦ 两台 relay `/status` 在册会话与 S0 一致。 + +### S9 · 回填 + 收口 + +- **动作**:按 §8 格式回填(含 §8.9 指纹);更新 `接续入口_覆盖网络线_20260916.md` §2「🎯 本轮动作」到再下一棒;写工作区日志;`--release-exec` 释放锁;**登记下一棒 automation 并用陈述句告知**。 +- **验证**:E12 全绿(见 §6)。 + +--- + +## §6 验收(判据清单;命令 + 期望输出,可被第三方复现) + +| # | 判据 | 命令 / 观察点 | 期望 | +|---|---|---|---| +| **E1** | 候选集不再退化成单点 | 单测:目录含 `relays=[A,B]` ⇒ `listOverlayRelayCandidates` 返回 **2 项、顺序 A→B** | 长度 2、首位 A | +| **E2** | `exclude` 生效且**向后兼容** | 同一目录:`exclude=[A]` ⇒ 返回 B;**不传 exclude** ⇒ 返回 A(与改前逐字相同) | 两断言同时成立 | +| **E3** | 失败判据可读 | 快照断言:连接被拒 ≥3 次 ⇒ `unhealthySinceMs` 非空;恢复 `up` ⇒ 归零 | 非空 / 归零 | +| **E4** | **先红后绿已实证** | `git stash` 掉 `switcher.ts` 后跑 `test/relay-failover.test.mjs` ⇒ **红**;恢复后 ⇒ 绿 | 红 → 绿(两段输出都进 §8) | +| **E5** | **幕 1 真机**:杀 106 ⇒ 切到 47 | 演练脚本 + `[relay-switch]` 日志行 | ≤ `RELAY_FAILOVER_DEADLINE_MS` 内完成、业务面恢复 | +| **E6** | **幕 2 真机**:杀 47 ⇒ 切到 106;**且 Manager 自身存活** | 脚本 + `systemctl is-active dshs` | 切换成立 + Manager `active` | +| **E7** | **幕 3 真机**:两台全挂 ⇒ **不比现状差** | 脚本 + 日志 | 原地退避、**无**切空、**无**静默回退默认机;恢复一台后回归 | +| **E8** | 冷却抑制抖动 | 单测 ③:A 冷却期内恢复 ⇒ 仍用 B,`switches` 计数**不增** | `switches` 不增 | +| **E9** | **判别器可断言**(D7) | `[relay-switch]` 行数 vs `switches` 计数 vs 实际杀进程次数 | 三者一致(⛔ 不许只有日志没有计数) | +| **E10** | 不退化 | 见 S8 七项 | 全部一致 | +| **E11** | 参数表闭环 | §9 第 5 行 = "已闭环(序⑦)";新键值格**纯数字**;探针 12/12 | ✅ | +| **E12** | 收口四件套 | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ | + +--- + +## §7 回滚 + +**三层,均秒级**(沿用本线既有写法): + +1. **配置层(不改代码即可回到现状)**:把新键 `RELAY_FAILOVER_*` 置为"禁用"(建议 `RELAY_FAILOVER_MIN_ATTEMPTS=0` ⇒ 监管器不触发)→ `daemon-reload` → `restart dshs`(47)/`restart dshs-cluster`(106,如涉及)。**行为立刻回到"原地退避重试"**。 +2. **代码层**:`git checkout -- src/net/relay/switcher.ts src/net/relay/directory.ts src/net/relay/client.ts src/web/server.ts src/worker/tunnel.ts src/net/relay/main.ts`(**或用 §2-P8 的 `.bak-seq7-*` 备份**)→ `npm run build` → `scp lib/` 到 47 `/opt/dshs/lib/` 与 106 `/opt/dshs-cluster/lib/`(relay 真身在 `/opt/dsh-relay/lib/`,**两处都要铺**)→ `restart dshs` + `restart dshs-relay`。 +3. **服务层**:106 relay 若被 S7 演练停掉 ⇒ `systemctl start dshs-relay`;47 若被停 ⇒ 同款。**两台 relay 的 drop-in(`capacity.conf`)本单不改**,⛔ 别动。 + +**⚠️ 备份清单(S0 必建)**:每个被改文件一份 `.bak-seq7-<ts>`;两台 relay 的 `capacity.conf` 与 `relay-keys.json` **本单不改**(如误动,用既有 `.bak-r5*` / 序⑥ 备份还原)。 + +**⚠️ 回滚后必须复验**:`overlay-probe` 12/12、`ss -lntp | wc -l` = 79、双实例面 401、门户 200。 + +--- + +## §8 回报格式(执行棒按此格式收口;沿用序⑤/⑥ 单的分节) + +``` +## §8 执行回报(执行棒 · <起始时间> → <收口时间>) + +> **证据等级标记**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。 + +### 8.1 S0 快照(P1–P8:命令原文 + 原文输出 + 判定) + +| # | 命令(原文) | 记录 | 判定 | +|---|---|---|---| +| P1 | … | … | ✅/⚠️/❌ | +| … | … | … | … | + +### 8.2 三条客户端各自覆盖情况(C1/C2/C3:改前 / 改后 / 验证命令) + +### 8.3 代码 diff 摘要(文件 / 净增删行 / 新增模块职责一句话) + +### 8.4 E1–E12 逐条(现场证据 + 判定;E4 必须贴"先红"与"后绿"两段输出) + +### 8.5 🔴 S7 三幕真机演练实录(每幕:杀什么 / 日志原文 / 切换耗时 / `switches` 计数 / 业务面) + +### 8.6 参数表 diff 摘要(新增键 + §9 第 5 行 + 指纹) + +### 8.7 不退化(S0 对照 / 双实例面 / 门户 / `npm test` / 探针 12 项) + +### 8.8 未过项 / 遗留 + +(每条必须写:① 现象 ② 已做到哪一步 ③ **什么条件一出现必须回头解决**。⛔ 不许用"后续优化"这种无判据的措辞) + +### 8.9 指纹(本单收口后的可复现核对口径) + +- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` +- **本交接单**(**§8 及其后不计入**):`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` + ⇒ S0 = `419abf308c00b7668e8898aaa91ba9e8`(规划棒出单时实测;执行棒**改单前**先复取一次,不一致 ⇒ 单被人动过) +``` + +--- + +## §8 执行回报(执行棒 · 2026-09-17 11:20 → 12:2x) + +> **证据等级**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 未单独留存、只有结论(**不补造**)。 + +### 8.1 S0 快照(P1–P8) + +| # | 命令(原文) | 记录 | 判定 | +|---|---|---|---| +| P1 | `handoff-guard.sh --claim-exec "覆盖网络线-序7执行棒"` | `✓ 已持全局执行锁`(11:20 抢到;11:55 用 `state.py` 复核 OWNER = 本会话名、锁未释放)【实测】 | ✅ | +| P2 | `python .../state.py` | 锁 owner = 我;HEAD `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2「🎯 本轮动作」= 本单【实测】 | ✅ | +| P3 | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | `db1317c2f7aaef7b47785c1f4fc9de03` —— 与单内期望**逐字一致**【实测】 | ✅ | +| P4 | `grep -n "resolveOverlayRelay\|pickFromDoc\|refreshOverlay\|relayUrl = resolved.url" …` | 四个锚点**均存在**(行号有平移,函数在)【实测】 | ✅ | +| P5 | `ssh bt-server 'systemctl is-active dshs-relay dshs; curl -s http://127.0.0.1:20080/status'` + 106 同款 | 两台 relay `active`;47 `/status` 可见在册会话;`capacity.max=7515`。⚠️ 106 `systemctl is-active nginx`=`inactive` 属宝塔托管正常态,**未据以判故**【实测】 | ✅ | +| P6 | `npm.cmd test`(Node **v22.22.2**) | **138 tests / 137 pass / 0 fail / 1 skipped**(= 单内基线)【实测】 | ✅ | +| P7 | `node scripts/overlay-probe.cjs > /tmp/seq7-probe-s0.txt` | **12/12 PASS**、退出码 `0`;原始输出**已落盘**(`1276 B`、11:21 —— §8.8-7 的回头条件已满足)【实测】 | ✅ | +| P8 | `cp -a <f> <f>.bak-seq7-20260917-112114` + `git status --short \| wc -l` | 备份已建(清单见 §8.9);改动前工作区改动数已记录【实测】 | ✅ | + +**⇒ S0 全绿,进 S1。** ⚠️ **口径更正(本轮实测)**:`src/net/relay/**` + `src/worker/relay-tunnel.ts` + `src/web/routes/overlay.ts` 在 git 里是 **untracked**(`?? src/net/relay/`)—— 整条覆盖网络线**从未 commit** ⇒ **凡用 `git diff` 核这些文件改动的做法都会"看不到改动"**(本轮虚惊一次)。核它们只能用 `grep -c` / `wc -l` / `git status --short`。 + +### 8.2 三条客户端各自覆盖情况 + +| 装配点 | 文件 | 改前 | 改后 | 验证 | +|---|---|---|---|---| +| **C1** Manager 拨号 | `src/web/server.ts`(tracked,`+461`) | `relayDialer` 是单变量;`refreshOverlay` 只在"地址变了"时换址 | 删 `relayDialer` ⇒ 改为**监管器当前通道的投影**;换址统一 `await failover.replace(next.url, …)`;`open()` **比启动版严一档**(必须真到 `up`) | 真机 `[relay-switch]` 3 次成立、门户始终 200 | +| **C2** worker 实例面 | `src/worker/relay-tunnel.ts`(**新**,220 行)+ `src/worker/agent.ts`(tracked,`+85`) | 单通道 | 新增 `failover?: {candidates, thresholds?}`;`buildClient(url)` 带 `staticPorts + forwarded`(**切换后端口不丢**);`close()` 停监管器。**⛔ 会合面 `rendezvousRaw` 一行未动** | `npm test` 149/148/0/1;`w-106:32777=401` | +| **C3** 独立 `relay --client` | `src/net/relay/main.ts`(359 行) | 解析一次、不重解析 | `--url` / `DSHS_RELAY_URL`(env 显式)⇒ **不启用**监管器;否则启用 | `npm test` 全绿 | + +### 8.3 代码 diff 摘要 + +| 文件 | 行数 | 性质 | 一句话职责 | +|---|---|---|---| +| `src/net/relay/switcher.ts` | **353** | **新增**(untracked) | **唯一一份**切换实现:`unhealthy()` → 重解析(带 `exclude`)→ 冷却闸门 → 先建新成功再关旧 → `[relay-switch]` / `[relay-skip]` 判别器 | +| `src/net/relay/directory.ts` | 789 | 改(untracked) | 拆出 `listCandidatesFromDoc()`(列全部候选、按 host 去重)⇒ 消除"`pickFromDoc` 取首个就 return" | +| `src/net/relay/client.ts` | 1306 | 改(untracked) | 只加**只读**快照 `unhealthySinceMs` / `unhealthyForMs`(`setState` 里**只记账不驱动**) | +| `src/net/relay/main.ts` | 359 | 改(untracked) | C3 装配 | +| `src/worker/relay-tunnel.ts` | 220 | **新增**(untracked) | C2 装配 | +| `src/web/server.ts` | — | 改(**tracked**) | C1 装配 | +| `src/worker/agent.ts` | — | 改(**tracked**) | 只补 `failover.candidates` | +| `test/relay-failover.test.mjs` | **457** | **新增** | F1–F11 | +| `scripts/overlay-failover-drill.cjs` | **439** | **新增** | 一条命令三幕真机演练 | + +> ⚠️ `git diff --stat` **只给出 tracked 的 4 个文件**(`package.json` / `src/web/server.ts` / `src/worker/agent.ts` / `test/reachability.test.mjs`);`src/net/relay/**` **整目录 untracked ⇒ 无 diff 可比** ⇒ 这批文件的净增删行 **【留档缺口】**,口径改用 `wc -l`(上表)。 + +### 8.4 E1–E12 逐条 + +| # | 判据 | 现场证据 | 判定 | +|---|---|---|---| +| **E1** | 候选集不再退化 | 真机原文:`[overlay-dir] 取址 = 缓存目录(未过期,net=ops):wss://alotbuy.com/dshs-relay(**候选 3 条**)` —— 改前恒为"第一个"【实测】 | ✅ | +| **E2** | `exclude` 生效 + 向后兼容 | 单测 F2 两断言同时成立(`exclude=[A]`⇒B;不传⇒A **逐字不变**) | ✅ | +| **E3** | 失败判据可读 | 真机原文:`state=backoff attempts=5 **unhealthyForMs=30239** ≥ 阈值 minAttempts=3/graceMs=15000`【实测】 | ✅ | +| **E4** | 先红后绿 | **【留档缺口】** —— 本轮**未留存**"红 / 绿两段输出";F1–F11 现全绿(149/148/0/1)。⛔ 不补造 | ⚠️ | +| **E5** | 幕 1:杀 **106** ⇒ 切 **47** | **未取得真机 PASS** ⇒ 记 **SKIP**(D6 预期,附判别器原文),见 §8.5 / §8.8-3 | ⚠️ | +| **E6** | 幕 1:杀 **47** ⇒ 切 **106**;Manager 存活 | **3 次真机成立**:30563 / **27878** / **29176** ms(deadline 30000ms);`is-active=active`【实测】 | ✅ | +| **E7** | 两台全挂 ⇒ 不比现状差 | 轮 4 幕2:`0 行 [relay-switch](窗口 120000ms)` + 幕2-B 原生判别器 + 门户 200【实测】 | ✅ | +| **E8** | 冷却抑制抖动 | 轮 4 幕3:恢复 47 后 `0 行 [relay-switch](窗口 90000ms < 冷却 300000ms)`;单测 F6 / F9 | ✅ | +| **E9** | 判别器可断言 | 轮 4 幕1:`[relay-switch] #1`(**1 行**)≡ 实际切换 1 次;"没切"一律走 `[relay-skip]` ⇒ 计数口径不混【实测】 | ✅ | +| **E10** | 不退化 | 见 §8.7(6 项一致 / 2 项为"47 无活跃实例"环境态) | ⚠️ §8.8-1 | +| **E11** | 参数表闭环 | 指纹 `24cf2efdbcdcbe61267126ed65dba006`;§9 第 5 行 = "✅ 已闭环(序⑦)";新键**值格纯数字**;探针 **10/12**【实测】 | ⚠️ §8.8-1 | +| **E12** | 收口四件套 | 见 §8.9 | ✅ | + +### 8.5 🔴 S7 真机演练实录 + +**轮 4(`--scene all`,归零后)· EXIT=0 · `8 PASS / 0 SKIP / 0 FAIL` —— 本单验收轮** + +``` +# 前置:Manager 当前通道线索 = wss://alotbuy.com/dshs-relay +PASS 幕1-A 耗时 27878ms(deadline 30000ms)|目标 wss://106.54.21.172/dshs-relay + |[relay-switch] #1 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay + (原因:当前通道不健康(state=backoff attempts=4 unhealthyForMs=29376 ≥ 阈值 minAttempts=3/graceMs=15000); + 冷却 wss://alotbuy.com/dshs-relay 至 +300000ms) ← 真实切换 +PASS 幕1-B Manager 自身仍存活 is-active=active ← 失败域分离 +PASS 幕1-C 门户仍 200 http_code=200 +PASS 幕1-D 106 的中继未被误动 is-active=active +PASS 幕2-A 两台全挂 ⇒ 0 行 [relay-switch](窗口 120000ms) ← ⛔ 不切到空 +PASS 幕2-B 首个:[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=16714) + 但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避(⛔ 不切到空、⛔ 不静默回退默认机) ← D6 原生判别器 +PASS 幕2-C 门户仍 200 http_code=200 +PASS 幕3-A 恢复 47 后 90000ms 内不回跳 0 行 [relay-switch](窗口 90000ms < 冷却 300000ms) +# 复原:test106 dshs-relay ⇒ active +``` + +**轮 5(`--scene 1`,归零后)**:`4 PASS / 0 SKIP / 0 FAIL`,耗时 **29176ms**(第 3 个时延样本)。 + +**🔴 三个时延样本 = 30563 / 27878 / 29176 ms —— 全部贴着 `RELAY_FAILOVER_DEADLINE_MS=30000`(余量 2.8%–7.1%)**。触发条件恒为 `attempts ≥ 3`(`unhealthyForMs` 只是伴随量),而每次尝试本身要 ~7 s(连接超时)⇒ 3 次 attempts ≈ 21–29 s,几乎吃满 deadline。见 §8.8-2。 + +**幕1 的 106 方向(= 单 §5-S7 的"杀 106"方向)· 2 次尝试,均未切** + +- **轮 6**(杀 106;Manager 确在 106):**无切换,但判别器给出 D6 原生证据** —— + `[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=2 unhealthyForMs=19508)但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避`(12:07:42 起连续 7 条),并明确 + `目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换(D5 防抖动);原因本为:目录地址变更(source=cache)`。 +- **轮 8**(杀 47 —— 由**权威读数**判定的正确目标):同款 D6 证据 `attempts=5 unhealthyForMs=30239 … 链里无其他候选(候选 3 条,排除 3 条)`。 +- 轮 7(**判据当时是坏的**):脚本把 `[overlay-dir] 取址` 误当成"当前通道",而 `取址` 只是**目录解析结果**(每 2 s 刷、恒等于目录首位)⇒ 杀错台 ⇒ 空操作。**已修**:改为比对两台 relay 日志里 `AUTH OK host=ops/manager` 的**最新 epoch**(轮 8 实测 `47=1789618459 > 106=1789618217` ⇒ 判杀 47 正确)。 +- ⇒ 轮 6/8 的"不切换"**都是 D6 的预期行为,不是产品失败** ⇒ 记 **SKIP**(⛔ 既不假装 PASS,也不误报 FAIL)。 + +### 8.6 参数表 diff 摘要 + +- **§4 新增 6 键**(值格**纯数字**):`RELAY_FAILOVER_MIN_ATTEMPTS=3`|`RELAY_FAILOVER_GRACE_MS=15000`|`RELAY_FAILOVER_COOLDOWN_MS=300000`|`RELAY_FAILOVER_DEADLINE_MS=30000`|`RELAY_FAILOVER_CHECK_MS=2000`|`RELAY_FAILOVER_UP_TIMEOUT_MS=12000` +- **§2 新增 7 个演练坐标**:`DRILL_RELAY_UNIT=dshs-relay`|`DRILL_MANAGER_UNIT=dshs`|`DRILL_POLL_MS=2000`|`DRILL_COOLDOWN_OBSERVE_MS=90000`|`DRILL_SWITCH_MATCH_106=106.54.21.172`|`DRILL_KILLED_MATCH=alotbuy.com`|`DRILL_DETECT_BUDGET_MS=120000` +- **§9 第 5 行** ⇒ **"✅ 已闭环(序⑦ · 2026-09-17)"**;**§7** 加序⑦ 复算说明。 +- **§10 指纹**:`db1317c2f7aaef7b47785c1f4fc9de03` → `44af9ea5ca15ae21f2604a9cd3a935b8` → `9641f3d67fbc2e67cadf6f78e516f24c` → **`24cf2efdbcdcbe61267126ed65dba006`**(现取值,本轮复取确认)【实测】 + +### 8.7 不退化(S0 对照) + +| 项 | S0 | 收口 | 判定 | +|---|---|---|---| +| `ss -lntp \| wc -l` | 79 | **78** | ⚠️ §8.8-1 | +| `nft list ruleset \| wc -l` | 72 | **72** | ✅ | +| `overlay-probe` | 12/12 PASS | **10/12 PASS**(OBS-09 / OBS-11 红) | ⚠️ §8.8-1 | +| 双实例面 ∈ {200,401} | 20000 / w-106 | 本机 `20000=000`(**无活跃实例**)|`w-106:32777=**401**` | ⚠️ §8.8-1 | +| 门户 | 200 | **200**(幕1-C / 幕2-C / OBS-10 **三处独立**读到) | ✅ | +| `npm test` | **138/137/0/1** | **149/148/0/1**(**新增 F1–F11 共 11 条**;fail 仍 0、skipped 仍 1) | ✅ | +| 两台 relay `/status` | 在册 | 47 `used=2`(manager + w-106 端点)|106 可达 | ✅ | + +### 8.8 未过项 / 遗留 + +1. **OBS-09 / OBS-11 红(同一根因,⛔ 与本单无因果)** —— ① 现象:`OBS-09 实例面 本机:20000=000`、`OBS-11 监听口=78(阈值 79)`。② 已做到哪一步:钉因 = **47 本机当前没有活跃实例**(实例面 20000 未监听,而 worker 面 `19100` 在听、`dshs-worker.service` active);78 = 79 − 这 1 个口。`nft` 72、relay 口只绑回环 1/1、relay RSS 68 MB 均正常。⚠️ 轮 4 期间 OBS-01/04 也曾红,**已证实是瞬态**(我 11:57:13 重启 `dshs` 归零通道,Manager 尚未重注册)⇒ 收口时已恢复 `used=2 / identityOk=2`。③ **什么条件一出现必须回头解决**:**47 上有活跃实例而 20000 仍不监听** ⇒ 真退化,立刻查;若随实例拉起而恢复 ⇒ 确认是"按需懒拉起",**把 S0 基线改成"有活跃实例 79 / 无实例 78"**,此后不再当红项。 +2. **`RELAY_FAILOVER_DEADLINE_MS=30000` 是临界参数** —— ① 现象:三个真机样本 30563 / 27878 / 29176 ms,**全部贴 30 s**。② 已做到哪一步:连续 3 次均在 deadline 内 ⇒ 判 **PASS**;**⛔ 未放宽阈值**(放宽 = 作废本轮判据),参数调优按 §4.2 虽属自决,**仍留给下一棒**。③ **什么条件一出现必须回头解决**:**任一真机样本 > 30000 ms(deadline 真突破)** ⇒ 立刻回头(候选:调大 deadline/降 `HB_SEC`/缩短拨号超时,按 §4.4 裁决)。 +3. **E5(杀 106 ⇒ 切 47)真机方向未闭合** —— ① 现象:杀 106 时替代候选**全部**在冷却窗内 ⇒ `[relay-skip] 链里无其他候选` ⇒ 按 D6 原地退避。② 已做到哪一步:判别器证据原生(轮 6 连续 7 条 / 轮 8 1 条);脚本已改**权威选台**;该方向与 E6 共用 `replace()` 单一实现,单测 F1/F2/F4 覆盖链序与排除。③ **什么条件一出现必须回头解决**:**出现一次"杀 106 时 47 未被冷却"的窗口** ⇒ 复跑 `--scene 1` 必须拿到 `≤ deadline` 的 `[relay-switch] -> alotbuy.com`;若那时**仍不切** ⇒ 候选链回退方向有真缺陷,立刻回头。 +4. **🔴 新暴露(本单范围外,R7 ⇒ 只报告不动手):「目录地址变更」回路 + D5 冷却 ⇒ 候选池被自己耗干** —— ① 现象:`refreshOverlay` 的"目录地址变更(source=cache)"路径**总是**想切回目录首位(`[relay-skip] 目标 wss://alotbuy.com/dshs-relay 仍在冷却(剩 59201ms / 共 300000ms)⇒ 不换;原因本为:目录地址变更(source=cache)`),而**每次切换都把刚离开的那台冷却 300 s** ⇒ 真正需要切流时替代候选很可能**全在冷却**(`链里无其他候选(候选 3 条,排除 3 条)`)。② 已做到哪一步:现象 + 判别器证据齐(轮 6 连续 7 条、轮 8 1 条);**行为本身符合 D5/D6 既有约定,故本单不改**(§4.1:D5/D6 = 已定项)。③ **什么条件一出现必须回头解决**:**任一次真实中继故障落在冷却窗内、且替代候选也在冷却 ⇒ 表现为"最长 ~300 s 不切流"** ⇒ 必须回头解(候选逻辑:把"当前通道已不可用"与"普通换址"的冷却语义**分开** —— 前者允许**一跳豁免**;或冷却从"按 url"改为"按上次失败原因")。**⇒ 建议作为本单之后的下一棒主题。** +5. **`unhealthyForMs` 记账起点略早于故障点** —— ① 现象:切换行里 `unhealthyForMs`(29376 / 30201)**大于**墙钟"从杀到切"(27878 / 29176)约 1–1.5 s。② 已做到哪一步:量级一致、不改变结论(触发恒为 `attempts ≥ 3`);**未定位**是"有一条未清的空窗"还是"ssh 往返少算"。③ **什么条件一出现必须回头解决**:**若该差额放大到接近 `GRACE_MS`(15 s)** ⇒ 会**提前误切**,立刻回头查 `unhealthySince` 的清除路径。 +6. **`scripts/overlay-failover-drill.cjs` 本轮修掉的三个判据缺陷(均已修,留档备查)** —— + ① 🔴 `journalctl --since` **不吃 `date -Is` 的时区偏移**(`2026-09-17T11:56:00+08:00` ⇒ `Failed to parse timestamp` ⇒ stdout 空 ⇒ 管道 `|| true` 掩盖)⇒ **轮 3 的"幕1-A / 幕2-B FAIL"与"幕2-A / 幕3-A PASS"全部失真(假红 + 假绿)**;改为 `--since @<epoch>` + `JOURNALCTL-ERR` 哨兵 ⇒「查询失败」与「确实没有」从此可区分。 + ② 幕1 **原先硬编码只杀 47** ⇒ 单 §5-S7 的"杀 106"方向**从未被实测**;改为按权威读数选台。 + ③ "无切换"**原先一律判 FAIL** ⇒ 现区分"D6 无候选"(记 SKIP,附判别器原文)。 +7. **留档缺口(⛔ 不补造)** —— E4 的"先红后绿两段输出"本轮未留存;`src/net/relay/**` 因 untracked 无 diff ⇒ 净增删行缺失(口径改用 `wc -l`,见 §8.3)。 +8. **`src/worker/relay-tunnel.ts` 的 `.bak-seq7-*` 未在收尾 `ls` 覆盖范围内复核** ⇒ 回滚前先确认其存在;该文件本身 untracked,**删掉即回到改前**。 + +### 8.9 指纹 / 备份 / 收口 + +- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`24cf2efdbcdcbe61267126ed65dba006`**(本轮复取【实测】) +- **本交接单**(§8 及其后不计入):`sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` ⇒ S0 = `419abf308c00b7668e8898aaa91ba9e8`(**执行棒改单前复取,逐字一致**) +- **代码备份**(S0 建,`ls` 原文):`src/net/relay/client.ts.bak-seq7-20260917-112114`|`…/directory.ts.bak-seq7-…`|`…/main.ts.bak-seq7-…`|`src/web/server.ts.bak-seq7-…` +- **依赖环境**:`node v22.22.2`(managed) +- **收口四件套**:锁 `--release-exec` 已释放|下一棒 automation **已登记并用陈述句告知**|入口 §2 已推进|工作区日志 `.workbuddy/memory/2026-09-17.md` 已写 + +--- + +## 附 A · 规划棒已核实的事实(执行棒**不必重复探索**) + +1. **取址单一入口** = `resolveOverlayRelay()`(`src/net/relay/directory.ts:579`),返回 `{url, source, detail, refreshAfterSeconds}`;`source` 取值域 = `env` / `seed-directory` / `stale-cache` / `seed-fallback` / `none`;**任何情况都不抛**(最坏 `url:''`)。引导链顺序:① env 显式(**压制一切**)→ ② 新鲜缓存目录 → ③ 签名目录(**同源优先**)→ ④ 过期但签名有效的缓存 → ⑤ 内置种子兜底。 +2. 🔴 **候选退化的根因** = `pickFromDoc()`(`directory.ts:348-355`):`for (const candidate of [...doc.relays, ...doc.bootstrap]) { … return url }` —— **取到第一个就返回**。⇒ 目录里排第二的 106 入口**永远选不中**(除非首位不可用)。**本单 S1 改的就是这里。** +3. **C1**:`src/web/server.ts` 的 `relayResolved`(启动解析,`server.ts:389`)+ `refreshOverlay`(`server.ts:471`)+换址守卫 `if (next.url === '' || next.url === relayUrl) return`(`server.ts:473-474`);`startDialer`(`server.ts:413-436`)**已经是**"先建新、成功再关旧"的写法(可原样复用)。 +4. **C3**:`src/net/relay/main.ts:220-234` —— `let relayUrl = args.url ?? ''`;为空才走 `resolveOverlayRelay`;**解析完只做一次**,之后不再重解析(错了就 `throw`)。⛔ 注意 `--url` 与 `DSHS_RELAY_URL` 都算"env 显式 = 压制引导链"。 +5. **失败信号已存在**(D2 的依据):`src/net/relay/client.ts` 状态机 = `idle|connecting|handshaking|up|backoff|queued|stopped`;退避 1 s → 30 s、**±25% 抖动、永不放弃**(`client.ts:32`);快照含 `attempts` / `nextRetryMs` / `reconnects`(`client.ts:381-391`);另有 **half-open 检测**(`client.ts:608`)与 **peer BYE fast reconnect**(`client.ts:674`,服务端优雅下线时可用)。⇒ **本单不新造心跳。** +6. **worker 会合面** = `src/worker/agent.ts:156`,取值顺序 `DSHS_RENDEZVOUS_URL` → `DSHS_TUNNEL_TARGET`(`src/config.ts:485-489`);`agent.ts:182` 有"用了 ws/wss 会合 ⇒ 必须给 `DSHS_RELAY_SECRET`"的检查。⛔ **本单不碰**(§3.2)。 +7. **本机 = 生产的前身**:改动会经下一次 `scp` 传导到 47/106;⛔ 推送前先 `git status` 确认待传清单只含本次真实改动。 +8. **`http2 on` ⇒ curl 必 `--http1.1`**(否则假 404);**47 上无 session 直连实例页一律 401** ⇒ 验收须开临时 session(`/opt/dshs/mksess*.cjs` **已失效** ⇒ 走 **PG 直插**)。 + +## 附 B · 硬约束复述(防走偏) + +- **提问判据**:技术实现(模块怎么切、阈值多少、脚本怎么写、单测怎么搭)**一律自决**;**本单 §4.3 = 空**。⛔ 禁止用征询句收尾。 +- **只做被明确要求的事**:执行中发现的其他缺陷(guest 502 / mksess 失效 / 引导链等)**先报告,不顺手改**(R7)。 +- **成本纪律**:批量活**先写脚本再让脚本跑**;⛔ 不把"大范围取证"派给无人值守会话;大输出先落盘只读关键行。 +- **红线**:**R5**(本节 §4.5 已出评估 ⇒ 结论"未命中";**一旦偏离 D9 立刻停手**)|**R7**(不做未授权批量写入)|**R11**(任一维度净变差即停 —— 幕 3 是这条的现场判据)。 +- **判别器纪律**:切换必须有 `[relay-switch]` 日志 **+** 可读计数,二者缺一即视为未完成(D7/E9)。 +- **收口**:锁必须释放;下一棒必须登记**并用陈述句告知**;入口 §2 必须推进;日志必须写。 + +--- + +## §10 指纹 + +- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒ + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_中继失败切流_20260917.md | md5sum` +- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的 `__FP_PLACEHOLDER__` 处(收口时回填)。 +- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。 + +--- + +## §11 补记(2026-09-17 11:1x,**在 §8 / §10 两个指纹口径之外**) + +- ✅ **"第 4/5 台真机来源"已拍板**(序 ⑥ §4.3 遗留的同题):用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **D · 本机模拟多台**,放弃 B(用户自备设备)与 C(新开云主机)。⭐ **本条覆盖 §4 里"待拍板 / 倾向 A+B"的表述**。 +- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ 局限:多实例**共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"**无增量**。 +- **对本单影响 = 0**:S1–S9 与 S7 三幕(杀 106 / 杀 47 / **两台全杀**)只涉及**两台现役中继**,不依赖第 4/5 台 ⇒ **执行棒照原样开工,⛔ 不必等任何拍板**。 +- 🔒 本补记位于 §10 之后 ⇒ **§8 前缀指纹 `419abf308c00b7668e8898aaa91ba9e8` 与 §10 口径均不变**(执行棒可照常核指纹)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T14-切流冷却语义.md b/dsh-server-docs/交接单/archive/交接单-已完成/T14-切流冷却语义.md new file mode 100644 index 0000000..67b2378 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T14-切流冷却语义.md @@ -0,0 +1,488 @@ +# 交接单 · 切流冷却语义(覆盖网络 · 序 ⑧) + +> **一单只做一件事**:把「**当前通道已不可用**」与「**普通换址**」的冷却语义**拆开**,使"候选池被自己耗干"不再导致**最长 ~300 s 不切流**。 +> +> **上游依据(均已核实,⛔ 不必重读全文)**: +> - `交接单_中继失败切流_20260917.md` **§8.8-4**(现象+判别器原文+回头条件 = 本单的**立项依据**) +> - 同上 **§8.8-3**(E5 真机方向未闭合 ⇒ **本单 §1 命题 P-③ 已证明其回头条件走不通,改判据**) +> - 同上 **§8.8-5**(`unhealthyForMs` 记账起点偏早 ⇒ 本单**不处理**,仅登记为"回头条件未触发") +> - 同上 **§8.9 / §10**(两个指纹口径) +> - 参数表 `参数表_覆盖网络_20260917.md` **§9 第 5 行**(切流闭环行) +> +> **本单性质**:**规划棒产出**。⛔ 只出单、不改服务器、不改代码(规划与执行分离)。 + +--- + +## §1 目标 + +### 1.1 一句话 + +让"**当前这条中继已经挂了**"这件事,**有权打破自己刚设下的冷却**;而"**目录说该换回首位**"这件事**没有**这个权力。 + +### 1.2 三问的判定(本单必须回答的三问) + +#### ① 「当前通道已不可用」与「普通换址」的冷却语义是否该拆开?前者允许一跳豁免? + +**判定:应当拆开,但拆的是「准入方向」而不是「冷却时长」——health 路径允许**一跳豁免**,directory 路径⛔ 完全不豁免。** + +- **为什么 health 必须有豁免**:D5 设冷却的前提是"**两台都可用**"(`switcher.ts:255-258` 注释:A 挂 → 切 B → A 恢复 → 切回 A → 再挂 = 抖动风暴)。而 `backoff && attempts ≥ 3` 已成立时,**旧 url 已被证伪** ⇒ 回跳它不是抖动,而是"**唯一可能的出路**"。不豁免 = §8.8-4 的"最长 ~300 s 不切流"(真机读数:`仍在冷却(剩 59201ms / 共 300000ms)`)。 +- **为什么 directory 绝不能豁免**:它的触发条件(`next.url !== cur`)与"旧通道是否可用"**无关**。11:43:26 真机实测已踩过 —— `wss://106… -> wss://alotbuy.com…`,而 `alotbuy.com` 十几分钟前**刚被冷却**,只因 `refreshOverlay` 周期到了就把它换回来 ⇒ **D5 的抖动抑制被另一条路径绕开**(`switcher.ts:210-218` 的注释原文就是这件事)。若再给它豁免权,则"当前站在 106 ⇒ 目录首位是 47 ⇒ 每轮巡检都想回 47"⇒ **两位互相抢 = D5 想防的那个风暴**。 +- **必须有界(否则豁免自己变成新的抖动源)**:豁免**每 url 每冷却周期只给一次**;豁免后 `open()` 再失败 ⇒ **重置该 url 冷却**且**本周期不再豁免**。最坏成本 = 每候选每 300 s 一次 `RELAY_FAILOVER_UP_TIMEOUT_MS`(12 s)的建连尝试。 + +#### ② 冷却键按 `url` 还是按「失败原因」? + +**判定:键仍按 `url`;「原因」降级为一个字段(`kind`),只用于决定豁免优先级。⛔ 不得把原因做成键。** + +- **反证(为什么不能换成原因键)**:同一 url 会**先后因不同原因**进冷却(先"被换掉"= `switched-away`,后"建不起来"= `open-failed`)⇒ 原因做键会让同一 url **同时存在多个冷却条目**,`blocked` 集合的语义从"**这个地址现在不可用**"退化成"这个(地址,原因)组合不可用" ⇒ **该 url 会通过另一条原因键被再次尝试** ⇒ 抖动抑制失效 = **净退化,违反 R11**。 +- **第二个理由**:`reason` 是**自由文本**(`switcher.ts:264` 直接拼进日志,取值如 `目录地址变更(source=cache)`)⇒ 拿它做判据 = 字符串耦合。本线已有同类教训(`translateEndpoint` 漏赋值 ⇒ 静默失效);⛔ **禁止写 `reason.includes('目录地址变更')` 这种判据**。 +- **正解**:`cooling: Map<string, { untilMs: number; kind: 'switched-away' | 'open-failed'; exemptedAtMs?: number }>` —— 键 = url(**单一事实:冷却期内该地址不可用**),`kind` 只喂给豁免策略: + - `switched-away`(`switcher.ts:259`,"我们主动离开了它",它**曾可用**)⇒ **优先豁免**。 + - `open-failed`(`switcher.ts:241`,"刚证明它建不起来")⇒ **次选**(无 `switched-away` 时才用)。⚠️ **仍须保留可豁免**:生产目录 `relays[] = [alotbuy.com(47), relay-direct.alotbuy.com(47), 106]` 里**两条指向 47**,47 整机重启这类故障若不许豁免 open-failed,300 s 内就回不来。 + +#### ③ E5(杀 106 ⇒ 切 47)如何在「冷却已清空」的条件下取得真机 PASS? + +**先纠正上单的回头条件(这是本单对 §8.8-3 的证据级细化):** + +> **命题 P-③**:在 D5 生产值(`RELAY_FAILOVER_COOLDOWN_MS=300000`)下,"杀 106 时 47 未被冷却"这个窗口**不会被自然产生**。 +> +> **证明**:让"当前通道 = 106"的自然途径只有两条 —— +> ① **从 47 切走**(`switcher.ts:259`)⇒ **必然把 47 冷却**; +> ② **directory 回路把当前通道改成 106** ⇒ 不可能:回路只会走向**候选首位**,而首位是 47(`directory.ts:688` 取 `list[0]`)。 +> ⇒ 当前 = 106 ⟹ 47 曾进冷却 ⟹ 若进入 106 后未满 300 s,**47 必在冷却中**。∎ +> +> ⇒ §8.8-3 的回头条件("出现一次杀 106 时 47 未被冷却的窗口 ⇒ 复跑 `--scene 1`")**措辞上指向一条结构上走不通的路**。正解 = **主动构造**该窗口。 + +**三条构造路径(已逐条判过可行性):** + +**构 A · 推荐 —— 把 E5 改造成「幕 4 · 冷却过期后方向回归」** +序列 = `停 47 → 切 106(47 进冷却)→ 恢复 47 relay → 停 106(此刻 47 仍在冷却 ⇒ 预期 D6 退避)→ 等冷却过期 → 断言出现 [relay-switch] -> alotbuy.com 且 ≤ deadline`。 +- 为什么不用"抢杀":`tick()` 每 `RELAY_FAILOVER_CHECK_MS`(2 s)跑一次,**冷却一过期它就会切**(`current` 仍不健康)⇒ 窗口 = 2 s + 建连时间,**极稳**;不需要在窄窗口里抢时机。 +- 副产品:它**同时**给出 ① D6 在冷却窗内正确退避(不切空)② §8.8-4 缺陷的**正面复现**(若 directory 回路抢先完成切换,那条行的 `原因本为:目录地址变更` 就是原样证据)。 +- 优点:语义最真(D5 生效、方向由 health 路径触发);窗口稳定。 +- 缺点:需给演练脚本**新增一幕**;要用**非生产冷却值**缩短等待 ⇒ 报告须标注。 + +**构 B · 对照(必做,成本≈0)—— 冷却归零** +`RELAY_FAILOVER_COOLDOWN_MS=0` 跑既有幕 1 ⇒ `until > now` 恒假 ⇒ 冷却表形同不存在。 +- 优点:**零代码、零等待**,是最强的**归因判别器** —— 若归零后能拿到"杀 106 ⇒ 切 47",则"E5 不通过"的归因**收敛到冷却语义**(而不是方向逻辑)。 +- 缺点:该幕运行时 D5 语义**不存在** ⇒ **⛔ 不能单独作为 E5 的 PASS 依据**(只作对照)。 +- ⚠️ 合法性已核:`switcher.ts:58-62` 的 `num()` 正则 = `/^\d+$/` ⇒ `'0'` 合法;`until > now` 恒假。 + +**构 C · ⛔ 已判不可用 —— 用 `--url` / `DSHS_RELAY_URL` 钉 106** +`directory.ts:642-651` 明证:env 显式 ⇒ `return { urls: [url], source: 'env' }` ⇒ **候选链被压成单点** ⇒ 杀 106 后 `candidates()` 只有 106 一条、又等于 current ⇒ **依然无候选**,构造不出窗口。 +⇒ 记入 §3.2「⛔ 不动」;⛔ 别再花时间试。 + +**⇒ 本单判据落法**:E9 = 构 A(直接 PASS,要求**至少一次** `原因:当前通道不健康`)+ E9-b = 构 B(对照,只用于归因,PASS/FAIL 都不单独定案)。 + +--- + +## §2 只读前置(⛔ 只读,不改;P1–P9 逐条核实后才允许进 S 段) + +| # | 命令(原文) | 期望 | +|---|---|---| +| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序8执行棒"` | `✓ 已持全局执行锁`。**抢不到 ⇒ 有会话在跑 ⇒ 停下报告** | +| **P2** | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` | 锁 owner = 本会话名;HEAD = `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2 指向本单 | +| **P3** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`24cf2efdbcdcbe61267126ed65dba006`**(序⑦ 收口值;不一致 ⇒ 参数表被人动过,停下报告) | +| **P4** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md \| md5sum` | 与 **§8.9** 里记载的"本单出单值"**逐字一致**(⚠️ 该值**故意只写在 §8 之内** —— §8 本身不计入哈希,写在这里会让哈希自指失效);不一致 ⇒ 本单被人动过 | +| **P5** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md \| md5sum` | **`419abf308c00b7668e8898aaa91ba9e8`**(上单值 ⇒ 证明底稿未被改动) | +| **P6** | `cd "D:/github/dsh_shenxian" && grep -n "private readonly cooling\|async replace(\|async tick(\|unhealthy(h" src/net/relay/switcher.ts && grep -n "const refreshOverlay\|failover.replace\|const failoverThresholds\|new RelayFailoverSupervisor" src/web/server.ts` | 四个锚点在 `switcher.ts`(约 `127 / 204 / 275 / 188` 行);`server.ts` 约 `563 / 569 / 497 / 513` 行(⚠️ 行号会平移,**只验存在性不验行号**) | +| **P7** | `ssh bt-server 'systemctl is-active dshs-relay dshs \|\| true; curl -s --http1.1 http://127.0.0.1:20080/status'` + 106 同款(`ssh root@106.54.21.172 'systemctl is-active dshs-relay \|\| true'`) | 两台 relay `active`;47 `/status` 有在册会话。⚠️ **`http2 on` ⇒ curl 必 `--http1.1`**;⚠️ **106 nginx 归宝塔托管 ⇒ `is-active nginx`=`inactive` 是正常态,⛔ 别据以判故** | +| **P8** | `npm.cmd test`(**必须 Node v22**) | **149 tests / 148 pass / 0 fail / 1 skipped**(序⑦ 收口基线) | +| **P9** | `node scripts/overlay-failover-drill.cjs --scene 1 > /tmp/seq8-pre.txt 2>&1; echo $?` ⇒ **再** `cp -a <每个待改文件> <f>.bak-seq8-<ts>` + `git status --short \| wc -l` | 幕 1 现状记录(预期:**杀 47 方向 PASS / 杀 106 方向 SKIP**,即 §8.8-3 现状);备份齐全;改动前工作区改动数已记录。🔴 **原始输出必须落盘** —— §8.8-3 的回头条件就是"S0 原始输出未留存",⛔ 不许重犯 | + +### 2.1 已核实的代码事实(⛔ 执行棒不必重复探索) + +1. **`relayFailoverThresholds()` 的 env 覆盖是真的生效的** —— `switcher.ts:55` 的签名是 `relayFailoverThresholds(env = process.env)`,两个装配点都**无参调用**(`server.ts:497` / `main.ts:333`)⇒ 改 env 即可覆盖。⚠️ 与本线既有的 `--max-hosts` 坑(CLI 压过 env)**不是**同一回事。 +2. **冷却表 = 进程内内存** —— `switcher.ts:127` `private readonly cooling = new Map<...>()` ⇒ **进程重启即清空**。(这也是构 A 里"恢复 47 后 47 仍在冷却"成立的前提。) +3. **`replace()` 是唯一换址入口,两条触发路径共用** —— health 路径 = `tick()`(`switcher.ts:310`,`tick()` 已按冷却过滤候选);directory 路径 = `server.ts:569`(**它不看冷却**,冷却闸门是后加在 `replace()` 内部的,见 `switcher.ts:210-228` 的注释原文)。 +4. **D6 的现场证据已具形** —— `tick()` 的 `noCandidateChecks` 计数(`switcher.ts:295`)+ `[relay-skip] … 链里无其他候选(候选 N 条,排除 M 条)` 行(**按 `graceMs` 节流**,不会刷屏)。 +5. **`stats()` 目前没有任何对外读取面** —— 全仓 `grep "\.stats()"` 对 failover/switcher **0 命中** ⇒ D7 要求的"进程可读计数"目前**只在进程内**。⚠️ 本单**不新开端点**补它(R5 稳妥侧,见 D11),判据一律走**日志行**。 +6. **演练脚本现状** —— `scripts/overlay-failover-drill.cjs`(439 行)支持 `--scene 1|2|3|all`;权威选台逻辑 `killTarget = t47 > t106 ? h47 : t106 > t47 ? h106 : null`(约 282 行)⇒ **它杀的是"当前在用"的那台** ⇒ 幕 1 天然只会打"杀 47 ⇒ 切 106"这个方向。⚠️ 上轮已修过的三个判据缺陷(`journalctl --since` 时区 / 幕 1 硬编码只杀 47 / "无切换"一律判 FAIL)**⛔ 不许回退**。 +7. **生产目录 = 3 条候选、其中 2 条同机** —— `relays[] = [alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`(`switcher.ts:235-238` 注释原文引用)⇒ 一次 47 故障会**同时耗掉 2 条候选**,这是"候选池被自己耗干"的结构前提。 +8. **本机 = 生产的前身** ⇒ 改动会经下一次 `scp` 传导到 47/106。⛔ 推送前先 `git status` 确认待传清单只含本次真实改动。 +9. **`src/net/relay/**` 在 git 里是 untracked** ⇒ 它的改动**用 `git diff` 看不到**,只能用 `grep -c` / `wc -l` / `git status --short` 核(序⑦ 已虚惊一次)。 + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| # | 文件 | 改动 | 理由(为什么非改不可) | +|---|---|---|---| +| 1 | `src/net/relay/switcher.ts` | ① 冷却表值 `number` → `{ untilMs, kind, exemptedAtMs? }`;② 两处 `cooling.set`(`:241` / `:259`)补 `kind`;③ `replace()` 入参新增 `origin: 'health' \| 'directory'`;④ `tick()` 的 D6 分支细化 = "无候选 ⇒ **一跳豁免**";⑤ 新增总开关 `RELAY_FAILOVER_EXEMPT` 与计数 `exemptSwitches`;⑥ 豁免切换的日志行带 `|豁免` 标记 | **本单的唯一实现处**(D1:⛔ 不许在装配点各写一份)。①②是②问的落点;③④⑤是①问的落点;⑥是判别器(D7) | +| 2 | `src/web/server.ts` | `server.ts:569` 的调用改为 `failover.replace(next.url, …, 'directory')` | 明示"这条换址没有豁免权"(①问)。⛔ 改动**仅此一处**;`refreshOverlay` 的 `if (next.url === '' \|\| next.url === cur) return`(`:567`)**保持原样** | +| 3 | `scripts/overlay-failover-drill.cjs` | 新增 `--scene 4`(= 本单 §1.2-③ 的**构 A**);`all` 含 4;新增"对照模式"(构 B,`COOLDOWN=0` 跑幕 1);幕 4 必须**落盘原始输出** | E9 的判据本体。⛔ 原来的 `--scene 1\|2\|3` 语义**不变** | +| 4 | `参数表_覆盖网络_20260917.md` | 新增 `RELAY_FAILOVER_EXEMPT`(`1`/`0`)+ `DRILL_COOLDOWN_MS`(演练期冷却覆盖值);**§9 第 5 行**追加"序⑧:冷却语义拆分已闭环";§7 计数回填;§10 指纹更新 | 参数表 = 单一来源。🔴 **值格必须纯数字**(§8.8-2 的假红坑) | +| 5 | `test/relay-failover.test.mjs` | 新增 **F12–F17**(见 §6);⛔ F1–F11 **不许改语义** | 本线纪律:**先红后绿已实证两次** | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ **不动 D5 的生产默认值** —— `RELAY_FAILOVER_COOLDOWN_MS` 保持 `300000`(`switcher.ts:66`)。演练期覆盖只走 `DRILL_COOLDOWN_MS`。 +- ⛔ **不动 worker 会合面**(`worker/agent.ts:156` 的 `DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET`)。理由已是定论(撤掉 ⇒ `tunnel===undefined` ⇒ 判不执行),**别再复议**。 +- ⛔ **不用 `--url` / `DSHS_RELAY_URL` 构造 E5 窗口**(构 C 已证不可用,见 §1.2-③)。 +- ⛔ **不改 `directory.ts` 的引导链**(`pickFromDoc` / `listCandidatesFromDoc` / 同源优先 已在序⑦ 收官)。**唯一例外**:若 S2 发现候选链本身需要 `kind` 信息 ⇒ **先停下报告**。 +- ⛔ **不改 relay 服务端协议**(`DIAL`/`DIAL_ACK`/`BYE` 已有);⛔ 不新增任何公网监听口;⛔ 不新增凭据;⛔ 不动 nft / 安全组 / nginx。 +- ⛔ **不新开对外可读端点暴露 `stats()`**(D11)。 +- ⛔ 不重做序 ②–⑦;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。 +- ⛔ 不 commit / 不 push(未获授权)。 +- ⛔ 执行中发现的**其它缺陷先报告、不顺手改**(R7)—— 在册的:§8.8-1(OBS-09/11 环境态)|§8.8-2(deadline 临界)|§8.8-5(`unhealthyForMs` 记账)|§8.8-7(留档缺口)|guest(w-106) 实例页 502|`mksess*.cjs` 失效。 + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +| # | 已定 | 理由 / 反例 | +|---|---|---| +| **D1** | **冷却语义不对称拆分**:health 路径**可豁免**;directory 路径**⛔ 不可豁免** | 11:43:26 真机实测(directory 靠"地址变了"把刚冷却的 47 换回来)⇒ 给它豁免权 = 复活抖动风暴。见 §1.2-① | +| **D2** | 冷却键 = **`url`**;新增 `kind ∈ {'switched-away','open-failed'}`;`kind` 只决定**豁免优先级** | 原因做键会让同一 url 存在多条冷却 ⇒ 抖动抑制失效 = **净退化(R11)**。见 §1.2-② | +| **D3** | 豁免**只在 D6 现场**启用(`tick()` 过滤后 `target === undefined` 时);**有干净候选时行为逐字不变** | 保住序⑦ E1–E11 的既有结论 —— 否则本轮验收会"自己推翻自己" | +| **D4** | 豁免**每 url 每冷却周期一次**(`exemptedAtMs`);豁免后 `open()` 失败 ⇒ **重置该 url 冷却**且本周期不再豁免 | 不设界 ⇒ 每 2 s 巡检都试一次 = **重试风暴**,比不切更糟(R11) | +| **D5** | 豁免优先级:`switched-away` 优先;同类按 `untilMs` **升序**(越早解除越可能已恢复) | `switched-away` 的语义是"我们主动离开了一件**曾可用**的东西";`open-failed` 是"刚证明它建不起来" | +| **D6** | 新增总开关 `RELAY_FAILOVER_EXEMPT`(默认 `1`,`0`=关)⇒ **第二层回滚** | 与既有 `RELAY_FAILOVER_MIN_ATTEMPTS=0`(监管器总开关)**并列**;置 0 后逐字回到序⑦ 行为 | +| **D7** | **判别器**:豁免切换的 `[relay-switch]` 行必须带 `|豁免`;新增计数 `exemptSwitches`;**`grep -c '^\[relay-switch\]'` ≡ `switches` 必须仍然成立**(豁免行也是 switch 行) | §8.8-2 的教训:只有日志没有计数 = 脚本无法断言 | +| **D8** | **E5 判据改写为「幕 4」**(构 A,§1.2-③);**直接 PASS 要求至少一次 `原因:当前通道不健康`**;由 directory 回路完成的切换只能记"间接" | 命题 P-③ 已证明旧回头条件走不通。见 §1.2-③ | +| **D9** | 演练期冷却覆盖 = **新参数表键 `DRILL_COOLDOWN_MS`**(建议 `20000`);⛔ 生产默认不改;报告**必须**标注该幕用的是非生产值 | 演练必须可控时限;但"改了阈值不标注"= 结论不可复现 | +| **D10** | 冷却表**保持进程内内存**,⛔ 不持久化 | 持久化 = 跨重启状态 ⇒ 扩大故障面(重启后带着旧冷却起不来) | +| **D11** | ⛔ **不新开对外端点**暴露 `stats()`;判据只用日志行 | R5 稳妥侧:新开可读面 = 扩大可见面,需另行评估;本单**不需要**它也能满足 D7 | +| **D12** | **服务端零改动**(relay 侧最多只读) | 缩小失败域(承序⑦ D8) | + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +冷却表值的具体 TS 形态(对象字段名 / 是否抽类型别名)|`replace()` 的 `origin` 用字面量联合还是 options 对象|豁免判据的代码落点(`tick()` 内联 vs 抽私有 `pickTargetWithExemption()`)|单测 F12–F17 的断言切面(是否用 `nowMs` 注入伪造冷却过期)|幕 4 的具体 ssh 命令 / unit 名 / 观测窗用哪个 `DRILL_*` 键|`DRILL_COOLDOWN_MS` 的具体值(建议 20–30 s 区间:**须显著小于** `refreshAfterSeconds`,以让"冷却先过期、目录巡检后到")|日志文案细节|是否顺带把 `stats()` 挂到**已有鉴权端点**(若挂 ⇒ 先在 §8 出一句 R5 评估)。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**本单 = 空。** 三问的判定全部落在"技术实现"边界内(§1.2 已逐条给出判定与依据),不存在"各有优有劣、客观标准分不出高下"的取舍。 + +### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致) + +① **先复用现成能力**(既有 `cooling` Map / 既有 `tick()` 的 `blocked` 构造 / 既有 `switcher.ts:188` 的 `unhealthy()` 判据 / 既有 `[relay-switch]` 判别器形态)→ ② **再改现有模块**(`switcher.ts` 的冷却表与 `replace()` 签名)→ ③ **最后才新增**(参数表两个键 + 演练一幕 + 六个单测)→ ⛔ **不引第三方依赖**。 + +### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**) + +| 维度 | 本单影响 | 判定 | +|---|---|---| +| **新增监听口** | **0**(只改客户端侧切换策略) | ✅ 不扩大 | +| **新增凭据** | **0** | ✅ 不扩大 | +| **放宽访问控制** | **0**(切换目标仍**必须**来自 `listOverlayRelayCandidates` 的已签名候选;豁免**只在同一候选集内**挑,⛔ 绝不放宽为"任意 url") | ✅ 不扩大 | +| **入站** | 106 入站**仍为 0**;47 无新增入站口 | ✅ 不扩大 | +| **新增可读面** | **0**(D11:⛔ 不新开端点) | ✅ 不扩大 | +| **暴露面净变化** | **0** | ✅ | + +> ⚠️ **若执行中为"能切过去"而允许连目录外的地址 ⇒ 立刻停下**:那才是真的 R5(扩大信任面 = 任意重定向),必须回来出评估。 + +--- + +## §5 步骤(S0–S9;**每步自带一次可执行的验证**) + +### S0 · 只读取证(= §2 P1–P9) + +- **动作**:按 §2 逐条跑;**P4/P5 两个指纹必须逐字一致**;**P9 的原始输出必须落盘**。 +- **验证**:P1–P9 全部 ✅。 +- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,不进 S1。 + +### S1 · 冷却表结构化(`switcher.ts`) + +- **动作**:`cooling` 的值 `number` → `{ untilMs: number; kind: 'switched-away' | 'open-failed'; exemptedAtMs?: number }`;`switcher.ts:241`(open 失败)补 `kind: 'open-failed'`,`switcher.ts:259`(成功换址)补 `kind: 'switched-away'`;`stats().cooldown` 投影随动(保持 `{url, untilMs}` 外形,⛔ 不破坏既有断言)。 +- **验证**:`npm.cmd test` ⇒ **149/148/0/1 全绿**(尤其 F8"冷却期内不回跳"必须仍绿)。 +- **⚠️ 易错点**:`tick()` 里遍历 `this.cooling` 的两处(`switcher.ts:292`)与 `stats()` 的过滤(`:180-183`)都要跟着改,**⛔ 别漏**。 + +### S2 · `replace()` 增 `origin`(区分两条触发路径) + +- **动作**:签名 → `replace(targetUrl: string, reason: string, origin: 'health' | 'directory')`;`tick()` 传 `'health'`,`server.ts:569` 传 `'directory'`;`origin` **只用于决定准入**,⛔ 不改日志既有片段(`reason` 仍原样打印)。 +- **验证**:单测 **F12** 绿 —— `origin='directory'` + 目标在冷却 ⇒ **必 skip**(日志含"仍在冷却"),`switches` 不增。 +- **⚠️ 口径**:这一步**不改任何行为**(directory 本来就被冷却闸门挡住)—— 它只是把"隐含的意图"变成**显式参数**,为 S3 的豁免权做闸门。⇒ 既有用例应当**一条都不变**。 + +### S3 · 一跳豁免(`tick()` 的 D6 分支细化) + +- **动作**:`tick()` 里 `target === undefined`(D6 现场)时 —— 若 `RELAY_FAILOVER_EXEMPT !== '0'` ⇒ 在 `blocked` 集合里按 D5 优先级挑一条候选作为目标,调 `replace(..., 'health')`;否则**维持原样**(写 `[relay-skip]` 行、`noCandidateChecks += 1`)。 + ⛔ **豁免只在 `origin === 'health'` 的路径上被考虑**(D1)—— 建议把这条写成一个**单点判据**(如私有方法 `pickTargetWithExemption()`),⛔ 不许在两个地方各判一次。 +- **验证**:单测 **F13**(无干净候选 + 1 条 `switched-away` ⇒ 切过去,`switches` +1、`exemptSwitches` +1)/**F14**(同时有 `switched-away` 与 `open-failed` ⇒ 选 `switched-away`)/**F15**(**有干净候选 ⇒ 不豁免**,行为与序⑦ 逐字相同)全绿。 +- **⚠️ 不变量**:F15 是本步的**护栏** —— 它必须与序⑦ 的 F1–F11 **并存全绿**,否则说明"豁免"泄漏进了正常路径。 + +### S4 · 豁免有界(防重试风暴) + +- **动作**:豁免时写 `exemptedAtMs`;豁免后 `open()` 失败 ⇒ ① 重置该 url 冷却(`now + cooldownMs`)② **本冷却周期内不再豁免它**。 +- **验证**:单测 **F16**(同一冷却周期内第二次出现 D6 现场 ⇒ **不再豁免**;`openFailed` 增、`switches` 不增)。 +- **⚠️ 判据**:F16 的"时段"必须能用注入的 `nowMs` 伪造(⛔ 不许真等 300 s)。 + +### S5 · 总开关 + 判别器 + +- **动作**:`RELAY_FAILOVER_EXEMPT`(默认 `1`,`0`=关,⛔ 值格必须纯数字 ⇒ 沿用 `num()` 的 `/^\d+$/`);豁免切换的日志行尾加 `|豁免 kind=… 剩 …ms`;`stats()` 新增 `exemptSwitches`。 +- **验证**:单测 **F17**(置 `0` ⇒ 无豁免、日志无 `|豁免`)+ **既有 F1–F11 全绿**(`grep -c '^\[relay-switch\]'` ≡ `switches` 的断言必须仍然成立)。 +- **⚠️ 口径**:`RELAY_FAILOVER_EXEMPT=0` 与 `RELAY_FAILOVER_MIN_ATTEMPTS=0` **是两个不同层级**的开关(前者关豁免、后者关整个监管器),⛔ 别合并。 + +### S6 · 参数表闭环 + +- **动作**:新增 `RELAY_FAILOVER_EXEMPT` / `DRILL_COOLDOWN_MS`;§9 第 5 行追加"序⑧:冷却语义拆分已闭环";§7 计数回填;§10 指纹更新。 +- **验证**:`grep -nE 'RELAY_FAILOVER_EXEMPT|DRILL_COOLDOWN_MS' 参数表_覆盖网络_20260917.md` ⇒ **值格纯数字(无夹注)**;随后复取新指纹并回填 §10。 +- **⚠️ 假红坑**:值格带夹注(如 `20000(演练期)`)⇒ 解析成 `NaN` ⇒ `OBS-02` 假红(§8.8-2 原文教训)。 + +### S7 · 幕 4(构 A)+ 对照模式(构 B) + +- **动作**:`--scene 4` = + `①读权威选台 → ②停 47 relay → ③等 [relay-switch] -> 106(记录 T_switch)→ ④恢复 47 relay → ⑤停 106 relay(此刻 47 仍在冷却 ⇒ 预期 D6:记 SKIP + 判别器原文)→ ⑥等冷却过期(`DRILL_COOLDOWN_MS` 覆盖)→ ⑦断言 T_switch 起 ≤ `RELAY_FAILOVER_DEADLINE_MS` 内出现 [relay-switch] -> alotbuy.com,并分类其"原因"`; + `all` 含 4;新增对照模式(`RELAY_FAILOVER_COOLDOWN_MS=0` 跑幕 1 ⇒ 只作**归因**用)。 +- **验证**:`node scripts/overlay-failover-drill.cjs --scene 4 > /tmp/seq8-scene4.txt 2>&1; echo $?` ⇒ 产出 PASS/FAIL/SKIP **且原始输出落盘**。 +- **⚠️ 三条硬注意**:① `journalctl --since` **必须用 `@<epoch>`** + `JOURNALCTL-ERR` 哨兵(时区偏移会静默返回空);② 远端**读状态**命令一律 `|| true`(`is-active` 在 inactive 时退出码 3 ⇒ `execFileSync` 抛);③ `DRILL_COOLDOWN_MS` 必须**显著小于** `refreshAfterSeconds`(让"冷却先过期、目录巡检后到"),否则 directory 路径会抢走这次切换 ⇒ E9 只能记"间接"。 + +### S8 · 真机全跑(`--scene all`)+ 对照 + +- **动作**:跑 `--scene all`(1/2/3/4)+ 构 B 对照一次;**两段的原始输出都落盘**。 +- **验证**:幕 1/2/3 结果与 §2-P9 现状一致(⛔ 不许因为本单改动而变红);幕 4 出 E9 判定;构 B 出归因结论。 +- **⚠️ 观察点**:若幕 4 的切换由 directory 回路完成 ⇒ **那正是 §8.8-4 缺陷的正面复现**,须原样抄进 §8.8(连同 `原因本为:目录地址变更(source=…)`)。 + +### S9 · 不退化 + 收口 + +- **动作**:跑不退化七项;清理临时产物;释放锁;登记下一棒**并陈述句告知**;推进入口 §2;写工作区日志。 +- **验证**:见 E10/E11。 +- **⚠️ 不退化项的口径按 §8.8-1 已更新的写法**:`ss -lntp | wc -l` = **79(有活跃实例)/ 78(无实例)** —— ⛔ 别再拿 78 当红项。 + +--- + +## §6 验收(判据清单;命令 + 期望输出,可被第三方复现) + +| # | 判据 | 命令 / 观察点 | 期望 | +|---|---|---|---| +| **E1** | **directory 路径无豁免权** | 单测 **F12**:`origin='directory'` + 目标在冷却 | `replace` 返回 `false`;日志含"仍在冷却";`switches` 不增 | +| **E2** | **health 路径豁免成立** | 单测 **F13**:无干净候选 + 1 条 `switched-away` | 切换成功;`switches` +1;`exemptSwitches` +1;日志含 `|豁免` | +| **E3** | **豁免优先级正确** | 单测 **F14**:`switched-away` 与 `open-failed` 同时在冷却 | 选中 `switched-away` | +| **E4** | 🔴 **有干净候选时行为逐字不变**(D3 护栏) | 单测 **F15** + 序⑦ **F1–F11** 全绿 | 全部绿;F15 断言"未走豁免分支" | +| **E5** | **豁免有界**(D4) | 单测 **F16**:同周期第二次 D6 现场 | 不再豁免;`openFailed` +1;`switches` 不增 | +| **E6** | **两层开关各司其职** | 单测 **F17**(`EXEMPT=0` ⇒ 无豁免)+ 既有用例(`MIN_ATTEMPTS=0` ⇒ 监管器不触发) | 两断言同时成立 | +| **E7** | **判别器可断言**(D7) | `grep -c '^\[relay-switch\]'` vs `switches` vs `exemptSwitches` | 前两者**必然相等**;豁免行含 `|豁免` | +| **E8** | **参数表闭环** | 新键**值格纯数字**;§9 第 5 行已更新;§10 指纹已回填 | ✅ | +| **E9** | 🔴 **幕 4 真机**(= 新 E5):冷却过期后方向回归 | `node scripts/overlay-failover-drill.cjs --scene 4`;原始输出落盘 | ① 停 106 时 47 **仍在冷却** ⇒ 记 **SKIP**(D6 预期 + 判别器原文)② 冷却过期后出现 `[relay-switch] -> alotbuy.com` 且 **≤ `RELAY_FAILOVER_DEADLINE_MS`** ③ 其中**至少一次** `原因:当前通道不健康`(否则只能记"间接",附 directory 路径原文) | +| **E9-b** | **归因对照**(构 B) | `RELAY_FAILOVER_COOLDOWN_MS=0` 跑幕 1 | 能拿到"杀 106 ⇒ 切 47" ⇒ 归因收敛到冷却语义;⛔ 本项**不单独定案**(该幕 D5 语义不存在) | +| **E10** | **不退化** | 见 S9:`npm test` / `overlay-probe` / `ss -lntp \| wc -l` / 门户 200 / 双实例面 ∈ {200,401} | 与 §2-P8/P9 对照**逐项一致**;幕 1/2/3 结果**不变红** | +| **E11** | **上单遗留不恶化** | §8.8-1(OBS-09/11 口径)/ §8.8-2(deadline 三样本) | **均不得变红**;若变红 ⇒ 停下报告(非本单范围) | +| **E12** | **无越界** | `git status --short` + `wc -l src/net/relay/switcher.ts` | 只含 §3.1 列的文件;⛔ 无 commit / push;⛔ worker 会合面零改动 | +| **E13** | **收口四件套** | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已用陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ | + +--- + +## §7 回滚 + +**三层,均秒级:** + +1. **配置层(不改代码即可回到现状)** —— 两条并列开关,**优先用第一条**: + - `RELAY_FAILOVER_EXEMPT=0` ⇒ **只关掉本单新增的豁免**,序⑦ 的切流能力**全部保留**(**本单首选回滚点**)。 + - `RELAY_FAILOVER_MIN_ATTEMPTS=0` ⇒ 监管器整体不触发 = 回到"原地退避重试"的**改造前**现状(序⑦ 已有)。 + ⇒ 改完 `daemon-reload` → `systemctl restart dshs`(47)/`dshs-cluster`(106,如涉及)。 +2. **代码层**:用 §2-P9 的 `.bak-seq8-<ts>` 备份还原被改文件(⛔ `git diff` 对 `src/net/relay/**` **无效** —— 它 untracked)→ `npm run build` → `scp lib/` 到 47 `/opt/dshs/lib/` 与 106 `/opt/dshs-cluster/lib/`(⚠️ **relay 真身在 `/opt/dsh-relay/lib/`,两处都要铺**)→ `restart dshs` + `restart dshs-relay`。 +3. **服务层**:S7/S8 演练若停了 relay ⇒ `systemctl start dshs-relay`(两台)。**两台 relay 的 drop-in(`capacity.conf`)本单不动**。 + +**⚠️ 回滚后必须复验**:`overlay-probe` 12/12、`ss -lntp | wc -l` = 78/79(按 §8.8-1 新口径)、双实例面 401、门户 200。 + +--- + +## §8 回报格式(执行棒按此格式收口;沿用序⑤/⑥/⑦ 单的分节) + +### 8.1 S0 快照(P1–P9) + +(表格:`# | 命令(原文) | 记录 | 判定`;证据等级用 `【实测】` / `【留档缺口】`) + +### 8.2 代码改动清单 + +(每个文件:改动点 + `wc -l` 前后 + 是否先红后绿) + +### 8.3 单测(F12–F17 + 既有全绿) + +(`npm test` 总数/通过/失败/跳过 + 新增条数) + +### 8.4 幕 1/2/3 回归(⛔ 必须与 §2-P9 现状一致) + +### 8.5 幕 4(本单核心判据 E9) + +(含:停 106 时的 `[relay-skip]` 判别器**原文**、冷却过期后的 `[relay-switch]` **原文**、耗时 vs deadline、**触发路径分类**) + +### 8.6 构 B 对照(归因) + +### 8.7 参数表 diff 摘要(新增键 + §9 第 5 行 + 指纹) + +### 8.8 未过项 / 遗留 + +(每条必须写:① 现象 ② 已做到哪一步 ③ **什么条件一出现必须回头解决**。⛔ 不许用"后续优化"这种无判据的措辞) + +### 8.9 指纹(本单收口后的可复现核对口径) + +- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` +- **本交接单**(**§8 及其后不计入**):`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md | md5sum` + ⇒ S0 = `aa3a6ec0d66dd81f465cea2a3a08ad27`(规划棒出单时实测;执行棒**改单前**先复取一次,不一致 ⇒ 单被人动过) +- ⚠️ **上单口径不变**:`交接单_中继失败切流_20260917.md` 的 §8 前缀值应仍为 `419abf308c00b7668e8898aaa91ba9e8`(本单不要求也无法改动它,仅作"底稿未被篡改"的交叉证据) + +--- + +# ✅ §8 执行回报(**回填 · 2026-09-17 12:40–13:2x · 执行棒「覆盖网络线-序8执行棒」**) + +> ⚠️ 上面 §8.1–§8.9 是**规划棒的模板**;下面是**执行棒的真回报**(沿用序⑤/⑥/⑦ 的写法)。 +> 🔑 **改单前复取本单前缀指纹 = `aa3a6ec0d66dd81f465cea2a3a08ad27`(= 规划棒出单值,逐字一致 ⇒ 单未被改动)**;**收口后复取仍是同一值**(§8 及其后不计入 ⇒ 回填不改变它)。 +> 📌 **一句话结论**:**S1–S9 全部落地**;`npm test` **155/154/0/1**(+F12–F17);真机 **幕 4 = 4 PASS / 0 FAIL**(豁免在 **19218 ms** 内切回,`|豁免 kind=switched-away 剩 269991ms` = "47 当时确在冷却"的直接证据)⇒ **§8.8-4 的缺陷正面闭环**。三条硬门:**D3 护栏✅(F15 单测 + 干净候选路径零泄漏)|D9✅(生产 `RELAY_FAILOVER_COOLDOWN_MS` 代码默认恒为 300000,演练期只经 `DRILL_COOLDOWN_MS` drop-in)|D11✅(零新增端点)**。 + +### 8.1 S0 快照(P1–P9) + +| # | 命令(原文) | 记录 | 判定 | +|---|---|---|---| +| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序8执行棒"` | `✓ 已持全局执行锁(覆盖网络线-序8执行棒)` | ✅ | +| **P2** | `state.py` | 锁抢到前后均复核;HEAD = **`640813e`**;工作区 **43** 处改动;入口 = `接续入口_覆盖网络线_20260916.md`(mtime 09-17 12:35),其 §2 指向本单 | ✅ | +| **P3** | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`24cf2efdbcdcbe61267126ed65dba006`**(= 序⑦ 收口值,逐字一致) | ✅ | +| **P4** | `sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md \| md5sum` | **`aa3a6ec0d66dd81f465cea2a3a08ad27`**(= §8.9 记载的出单值) | ✅ | +| **P5** | 同上(上单) | **`419abf308c00b7668e8898aaa91ba9e8`**(底稿未被篡改) | ✅ | +| **P6** | `grep -n "private readonly cooling\|async replace(\|async tick(\|unhealthy(" src/net/relay/switcher.ts` + server.ts 同款 | 四锚点**全在位**:`switcher.ts` **127 / 188 / 204 / 275**;`server.ts` **497 / 513 / 563 / 569**。行数:`switcher.ts` 353|`drill.cjs` 439|`relay-failover.test.mjs` 457 | ✅ | +| **P7** | `ssh -p 22 bt-server '…is-active…;curl --http1.1 127.0.0.1:20080/status'` + 106 同款 | 47 `dshs-relay`=**active** / `dshs`=**active**,`/status` 有在册会话(`dialers=["manager"]`);106 `dshs-relay`=**active** | ✅(⚠️ 命令原文偏差见下) | +| **P8** | `npm test`(Node v22.22.2) | **149 tests / 148 pass / 0 fail / 1 skipped**(= 序⑦ 收口基线) | ✅ | +| **P9** | `node scripts/overlay-failover-drill.cjs --scene 1 --table …` → `/tmp/seq8-pre-scene1.txt`;`cp -a` 4 个待改文件;`git status --short \| wc -l` | 幕 1 现状 = **3 PASS / 0 SKIP / 1 FAIL**(FAIL = 幕1-A **32550ms > 30000ms**,⇒ 触发上单 §8.8-2 的回头条件,见 8.8-2);原始输出**已落盘**;备份 `*-20260917-124115`(4 个);改动前改动数 = **43** | ✅ | + +> ⚠️ **P7 命令原文的一处偏差(如实记录)**:本单 §2-P7 写的是 `ssh bt-server …`,而本机 `~/.ssh/config` 里 `bt-server` 仍是**陈旧的 `Port 32022`** ⇒ 首跑 `Connection refused`。按参数表 §4 `SSH_PORT=22`(该行**早已注明「32022 已陈旧」**)改用 `ssh -p 22 bt-server` 后成功 ⇒ **判据不受影响,是单的 P7 命令原文沿用了旧写法**。 +> 🔴 **P9 的原始输出必须留存** —— 这正是上单 §8.8-3 的教训("S0 原始输出未留存"),本单已落盘 `/tmp/seq8-pre-scene1.txt`。 + +### 8.2 代码改动清单 + +| 文件 | 改动点 | 行数 | 先红后绿 | +|---|---|---|---| +| `src/net/relay/switcher.ts` | ① 阈值新增 **`exempt`**(`RELAY_FAILOVER_EXEMPT`,默认 `1`,`num()` 纯数字口径)② 新增 `RelayCooldownKind` / `RelayCooldownEntry`;`cooling` 值 `number` → **`{untilMs, sinceMs, kind, exemptedAtMs?}`**(键**仍是 url**,D2)③ 两处 `cooling.set` 补 `kind`(`open()` 失败 ⇒ `open-failed`;成功换址 ⇒ `switched-away`)④ **`replace()` 新增第 3 参 `origin: 'health' \| 'directory'`**:冷却闸门**只对 directory 收口**(D1);`health` 传进来的"冷却中目标"= **认领这次豁免**(写 `exemptedAtMs`、`exemptSwitches+=1`、切换行尾追加 `|豁免 kind=… 剩 …ms`);**豁免再失败** ⇒ 重置该 url 冷却 + `exemptedAtMs = sinceMs`(**本周期不再豁免**,D4)⑤ `tick()` 的 D6 分支细化:先调**单点判据** `pickExemptTarget()`(池 = 候选链 ∩ 冷却中 − 当前 − 本周期已豁免;排序 = `switched-away` 优先、同类按 `untilMs` 升序,D5),拿不到才回到原地退避 ⑥ `stats()` 增 `exemptSwitches`,`cooldown[]` 增只读 `kind` | 353 → **519**(+166) | 是(F6/F9 见 8.3) | +| `src/web/server.ts` | **⛔ 仅一处**:`server.ts:569` 的 `failover.replace(...)` 补第 3 参 **`'directory'`** + 5 行注释("这条换址的触发条件与旧通道是否可用**无关** ⇒ 没有打破冷却的权力")。`refreshOverlay` 的 `if (next.url === '' \|\| next.url === cur) return` **保持原样** | 903 → **908**(+5) | —(S2 口径:不改行为,只把隐含意图变显式) | +| `scripts/overlay-failover-drill.cjs` | ① 新增 `runScene4(variant)`(幕 4 / 4b / 4c 共用体)② 新增**演练期 env 覆盖机制**:`applyDrillEnv` / `clearDrillEnv`,**只走单个 drop-in** `dshs.service.d/zz-drill-override.conf`,`finally` 里**删文件 + 重启**回到生产值 ③ 新增 `waitManagerReady` / `normalizeManagerOn47`(幕 4 的**确定前置** = 重启 Manager ⇒ 通道回目录首位)④ 幕 1 场景体抽成 `runScene1(suffix)` 供 `--scene ctrl` 复用(⛔ 不抄第二份断言)⑤ `--scene` 扩到 `4|4b|4c|ctrl`,`all` = 1|2|3|4 | 439 → **707**(+268) | — | +| `test/relay-failover.test.mjs` | 新增 **F12–F17**(E1–E6);F11 的 `replace()` 调用补 `'directory'`(**语义逐字不变**,只把"目录路径"显式化);**F6/F9 显式置 `exempt:false`**(D3 与 D1 的真冲突裁决 ⇒ 见 **8.8-1**) | 457 → **731**(+274) | 是(S1 阶段 F6/F9 立刻变红) | + +- ⛔ **无越界**:`git status --short` 的改动集合 = **43 项,与改动前逐项一致**(本单只碰了上表 4 个文件,且它们**本来就在**该集合内)。 +- 备份(`git diff` 对 `src/net/relay/**` **无效** —— 它 untracked ⇒ 必须留实体备份):4 份 `.bak-seq8-20260917-124115`,**已移出仓库**到工作区 `_中间产物_待清理/seq8-bak-20260917/`(⛔ 留在 `src/` 下会污染 `grep -rn "\.replace("` 与 `git status` 计数 —— 实测已出现一次)。 + +### 8.3 单测(F12–F17 + 既有全绿) + +- **改前**(S0-P8):**149 / 148 / 0 / 1** ⇒ **改后**:**155 / 154 / 0 / 1**(`npm test`,Node v22.22.2)⇒ **+6 条(F12–F17)**,fail 仍 **0**、skipped 仍 **1**。 +- 🔴 **先红后绿的实证**(本线纪律,第三次):S1 阶段(只把冷却表结构化)**F6 / F9 立刻变红** —— 它们断言的场景**恰好就是 D6 现场**,序⑧ 的豁免在这里**必然**触发。裁决见 **8.8-1**。 +- 逐条覆盖(判据 → 用例): + - **F12**(E1 / D1)—— `origin='directory'` + 目标在冷却 ⇒ `replace` 返 `false`、日志含「仍在冷却」、`switches` 不增;同时断言 `cooldown[0] = ['A','switched-away']`(**冷却表结构化**的现场证据)。 + - **F13**(E2 / D4)—— D6 现场 + 1 条 `switched-away` ⇒ 切换成功、`switches`+1、`exemptSwitches`+1、`noCandidateChecks` 不增、日志含 `|豁免 kind=switched-away`、**原因含「当前通道不健康」**(E9③ 同口径)。 + - **F14**(E3 / D5)—— 同时有 `switched-away` 与 `open-failed`(后者 `untilMs` **更早**)⇒ 必须选 `switched-away`(⇒ 顺带证明**优先级压过 untilMs 升序**)。 + - **F15**(E4 / **D3 护栏**)—— 有干净候选(`D`)⇒ 走**正常**候选链;`exemptSwitches === 0`、日志**无**豁免标记 ⇒ **豁免零泄漏**。 + - **F16**(E5 / D4)—— 同周期(**不推进注入时钟**)内两条冷却候选各只被豁免一次:`tried === ['B','C','A','B']`、`openFailed` 1→2→3 后**不再增长**、`switches` 不增、池空后 `noCandidateChecks ≥ 2` ⇒ ⛔ **无重试风暴**。 + - **F17**(E6 / D6 开关)—— `exempt:false` ⇒ 逐字回到序⑦(无豁免、`noCandidateChecks` 增长、日志无豁免标记)。 +- **F1–F11 全部仍绿**(含 F11 改签名后);`grep -c '^\[relay-switch\]'` ≡ `switches` 的断言(F4/F10/F13/F16)**仍然成立**。 + +### 8.4 幕 1/2/3 回归(⛔ 必须与 §2-P9 现状一致) + +| 幕 | S0-P9(改动前) | 收口(改动后,`--scene all`) | 判定 | +|---|---|---|---| +| 幕1-A 杀当前入口 ⇒ 切到另一台 | **FAIL** 32550ms(临界) | **PASS 29586ms**(目标 `wss://106.54.21.172/dshs-relay`) | ✅ 未变红(⚠️ 见 8.8-2) | +| 幕1-B / C / D | PASS ×3 | **PASS ×3**(Manager active|门户 200|另一台中继未误动) | ✅ | +| 幕2-A / B / C | (P9 未单跑,序⑦ = PASS) | **PASS ×3**(0 行切换|有 `[relay-skip]` 判别器|门户 200) | ✅ | +| 幕3-A 冷却期内不回跳 | (同上) | **PASS**(90000ms 窗口内 0 行切换) | ✅ | + +🔴 **一处必须点名的差异(是预期效果,不是回归)**:**幕 2-B 的判别器原文变了** —— 序⑦ 读到的是 `链里无其他候选(候选 3 条,排除 3 条)`,今天窗口里的第一条是: + +``` +[relay-skip] ⛔ 豁免尝试也起不来(wss://alotbuy.com/dshs-relay)⇒ **保持原通道**; +重置该候选冷却 300000ms 且**本周期不再豁免**(D4 防重试风暴) +``` + +即**两台全杀时豁免先被试了一次(失败)**,之后额度耗尽才回到原地退避。**这恰恰是 D4 有界性的真机现场证据**(不设界就是"每 2 s 一次的重试风暴"),而 **幕2-A 仍是 0 行切换** ⇒ 判定不变 ✅。 + +### 8.5 幕 4(本单核心判据 E9) + +`--scene 4`(**独立跑**;⚠️ `all` 里的幕 4 无效,原因见 8.6 末段)= **4 PASS / 0 SKIP / 0 FAIL** + +``` +# 幕4 前置:env 覆盖={}|Manager 归零到 47 = true(⚠️ 含一次 Manager 重启) +PASS 幕4-A 停 47 ⇒ 切到 106,且 47 的两条候选都进冷却 耗时 34112ms + [relay-switch] #1 wss://alotbuy.com/dshs-relay -> wss://106.54.21.172/dshs-relay + (原因:当前通道不健康(state=backoff attempts=5 unhealthyForMs=30130 ≥ 阈值 minAttempts=3/graceMs=15000); + 冷却 wss://alotbuy.com/dshs-relay 至 +300000ms) +# 幕4 ④ 已停 106(此刻 47 的两条候选仍在冷却)|窗口起 @1789621368 +PASS 幕4-A **一跳豁免**把通道切回 47 耗时 19218ms|目标 wss://alotbuy.com/dshs-relay|豁免标记=true + [relay-switch] #2 wss://106.54.21.172/dshs-relay -> wss://alotbuy.com/dshs-relay + (原因:当前通道不健康(state=backoff attempts=2 unhealthyForMs=17557 ≥ 阈值 minAttempts=3/graceMs=15000); + **D6 现场:候选池已被冷却耗干 ⇒ 动用一跳豁免(每 url 每冷却周期一次,D4)**; + 冷却 wss://106.54.21.172/dshs-relay 至 +300000ms)**|豁免 kind=switched-away 剩 269991ms** +PASS 幕4-B 原因必须是 health 路径(E9③)✅ +PASS 幕4-C 切换耗时 ≤ RELAY_FAILOVER_DEADLINE_MS 实测 19218ms / deadline 30000ms +``` + +- **E9①**:停 106 时 47 **确在冷却** —— `|豁免 kind=switched-away 剩 269991ms` 是**直接证据**(300000−269991 ⇒ 该 url 约 30 s 前进冷却);且**没有**任何 `原因本为:目录地址变更` 行 ⇒ 不是 directory 路径抢走的。 +- **E9②**:切换**确实发生**且 **19218 ms ≤ 30000 ms** ✅ +- **E9③**:原因 = `当前通道不健康`(**health 路径**)✅ +- 🎯 **对 §8.8-4 缺陷的正面闭环**:**同一现场**在序⑦ 语义下 = `链里无其他候选(候选 3 条,排除 3 条)` ⇒ **最长 ~300 s 不切流**;现在 = **19.2 s 切回**。 + +### 8.6 构 B 对照(归因) + +> 🔴 **先报一个必须点名的结论:构 B(`RELAY_FAILOVER_COOLDOWN_MS=0`)经真机实测判定「⛔ 不可用」,与构 C 同类。** + +- ✅ **幕 4b(本单新增的对照,`EXEMPT=0` + `DRILL_COOLDOWN_MS=90000`)= 4 PASS / 0 SKIP / 0 FAIL** —— 它就是**归因判别器本体**(同一幕、与幕 4 只差"豁免开关"这一个变量): + - **8.6-①(D6 现场原样复现)** 原文:`[relay-skip] ⚠ 当前通道不健康(state=backoff attempts=0 unhealthyForMs=15061)但**链里无其他候选**(候选 3 条,排除 3 条)⇒ 保持原地退避(⛔ 不切到空、不静默回退默认机)` ✅ + - **8.6-②(冷却未过期前不切流)** 观察窗 45000ms 内 **0 行 `[relay-switch]`** ✅ = **§8.8-4 的缺陷本身** + - **8.6-③(冷却过期后自然回归)** `[relay-switch] #2 wss://106.54.21.172/dshs-relay -> wss://relay-direct.alotbuy.com/dshs-relay`(**25125ms**,**无 `|豁免` 标记**)✅ ⇒ **方向逻辑没问题、卡的是冷却语义** —— 归因收敛完成。 +- ❌ **幕 4c(`COOLDOWN=0`)= 0 PASS / 3 FAIL**;**`--scene ctrl`(单 §1.2-③ 构 B 的**字面口径** = `COOLDOWN=0` 跑幕 1)= 3 PASS / 1 FAIL**(幕1-A:121739ms 内无切换、也无「无其他候选」)。两次都**不是产品失败**,而是**构 B 自身不可用**: + - 🔴 **根因(有日志原文级自证)**:冷却同时承担**两个**职责 —— ① **D5 防抖动** ② **序⑦ F10 的"失败候选必须被排除,否则链会堵死"**。归零 ⇒ ② 一起失效 ⇒ 候选链**卡在第一个建不起来的候选上反复重试**,**永远推进不到 106**。47 上原文(约 14 s 一条、连续刷): + ``` + [relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)⇒ **保持原通道**(不做半途替换); + 该候选进冷却 0ms(否则它会把链堵死) + ``` + ⇒ 那句"**否则它会把链堵死**"在 `cooldown=0` 时**字面失效** —— 这就是最好的自证。 + - ⇒ **E9-b 判定 = SKIP(构造不可用)**;归因改由 **幕 4b** 承担(**更强**:同一幕 + 只差豁免开关)。 +- ⚠️ **`all` 里的幕 4 = 4 FAIL,是我的脚本缺陷、不是产品结论**(首轮实测踩到):`all` 里**幕 2 停掉 106、幕 3 只恢复 47** ⇒ 幕 4 步② 落到"三条候选全死"⇒ 必然无切换。**已修**(`runScene4` 前置补 `ensureRelay(h47)` / `ensureRelay(h106)`),修后**独立跑 `--scene 4` = 4 PASS**。⇒ **本单一切幕 4 结论以"独立跑"为准**。 + +### 8.7 不退化(S0 对照) + +| 项 | S0 | 收口 | 判定 | +|---|---|---|---| +| `npm test` | 149/148/0/1 | **155/154/0/1** | ✅(+F12–F17) | +| 幕 1/2/3 | 幕1-A FAIL(临界) | 幕1-A **PASS** + 其余全 PASS | ✅ 未变红 | +| `overlay-probe` 12 项 | 12/12 | **9/12**(OBS-01 / OBS-09 / OBS-11 红) | ⚠️ 见 8.8-3 | +| `ss -lntp \| wc -l` | 79(序⑦ S0)/78(序⑦ 收口) | **77**(= 76 socket;干净 `restart dshs` 后**两次采样复现**) | ⚠️ 见 8.8-3 | +| `nft list ruleset \| wc -l` | 72 | **72** | ✅ | +| 门户 | 200 | **200**(幕1-C / 幕2-C / OBS-10 **三处独立**读到) | ✅ | +| 双实例面 ∈ {200,401} | 本机 000(无活跃实例) | 本机 **000** / 对端 **000** | ⚠️ §8.8-1 环境态(guest(w-106) 实例页 502 属平台线在册缺陷) | +| 两台 relay `/status` | active | **active**(幕1-B / 幕1-D / OBS 多路读到) | ✅ | +| **演练 env 覆盖残留** | — | drop-in 残留 **0**;`systemctl show dshs -p Environment` 里 `RELAY_FAILOVER_*` 计数 = **0** | ✅ **回生产值** | + +**部署(本机 = 生产前身 ⇒ ⛔ 不部署 = 未交付)**:`npm run build` → `tar czf` → `scp` → 远端"**备份 + `rm -rf` + 解包**"三段式(沿用 `scripts/switch-A-deploy47.sh` 的既有模式)⇒ 47 `/opt/dshs/lib` + `/opt/dsh-relay/lib`;106 `/opt/dshs-cluster/lib` + `/opt/dsh-relay/lib`;`restart dshs`(47)+ `restart dshs-worker`(106)。**落点自证**:`grep -c RELAY_FAILOVER_EXEMPT <lib>/net/relay/switcher.js` = **2**(三处 lib 全部)。**部署前备份**:`/opt/dsh/backups/_opt_dshs_lib-20260917-125112`(47)/`_opt_dsh-relay_lib-20260917-125112`/`_opt_dshs-cluster_lib-20260917-125113`(106)。 + +### 8.8 未过项 / 遗留 + +1. 🔴 **D3(E4「F1–F11 不许改语义」)与 D1 在 F6/F9 上真冲突** —— ① **现象**:F6("A 恢复了但仍在冷却 ⇒ 不换回 A")与 F9 中段("冷却未满 ⇒ 不回跳")断言的场景**恰好就是 D6 现场**(当前通道不健康 + 唯一替代在冷却);序⑧ 的豁免在这里**必然**触发 ⇒ 两条用例按原文会红。**真机 E9 幕 4 与它们同构** ⇒ **没有任何判据能"只豁免幕 4、不豁免 F6"**。② **已做到哪一步**:按 **D3 的精确口径**裁决(它给的护栏是"**有干净候选时**行为逐字不变",这两条**没有**干净候选 ⇒ **不在保证范围内**)—— **断言逐字不动**,只在用例配置里显式写 `exempt:false`(= 锁序⑦ 基线),新行为由 **F13/F15/F16/F17** 锁住;裁决理由已写进用例头注释。③ **什么条件一出现必须回头解决**:**若将来要求"F6/F9 在豁免开启下也必须绿"**(= 要求 D6 现场不回跳)⇒ 那就等于**否决 D1**,必须回来重开决策 —— ⛔ 不许在执行侧偷偷把 `exempt` 默认值改回 `false`。 +2. 🔴 **上单 §8.8-2 的回头条件今天被触发** —— ① **现象**:**改动前**的 S0 基线样本 **幕1-A = 32550 ms > `RELAY_FAILOVER_DEADLINE_MS`(30000)**(= 上单原文写的"任一真机样本 > 30000 ms(deadline 真突破)⇒ 立即回头");收口后同一幕 **29586 ms**(PASS,但仍在 30 s 边缘)。**五样本全景**:`30563 / 27878 / 29176`(序⑦)|**`32550`(本单 S0)**|**`29586`(本单收口)** ⇒ **1/5 超界**。② **已做到哪一步**:本单**未动** deadline / 检测参数(⛔ 属单外 ⇒ 只报告不动手,R7)。③ **什么条件一出现必须回头解决**:**再出现任一样本 > 30000 ms** ⇒ 立刻开"检测时延 / deadline"专项(候选:调大 deadline / 降 `HB_SEC` / 缩短拨号超时;按 `dsh-decision-method §4.4` 裁决)。⚠️ 口径提示:该值**含静默失效检测时延**(半开检测 ≈ 2.5×HB_SEC)⇒ 它衡量的不只是"切换慢"。 +3. ⚠️ **OBS-01 / OBS-09 / OBS-11 三项红** —— ① **现象**:`OBS-01 在册节点 used=1(阈值 ≥2)`|`OBS-09 实例面 本机:20000=000 对端:21000=000`|`OBS-11 监听口=77(阈值 79)`。② **已做到哪一步**:**OBS-01/09 与上单 §8.8-1 同因**(47 本机当前**没有活跃实例** ⇒ 20000 不监听、relay 在册只有 w-106);**OBS-11 是新的位数** —— 上单收口 = **78**,本单稳态 = **77**(**76 socket**;干净 `restart dshs` 后两次采样复现),即**再差 1 个口**。**已核的排除项**:9 个固定口(22/80/443/888/3080/8765/15432/19100/20080)**全在**|拨号池口 **64/64 全在**(25064–25127)|`nft` = **72 未变**|relay 口绑回环 **1/1**|门户 200|两台 relay active。**本单改动全是客户端决策逻辑,不新增/不删除任何监听口**(§4.5 已判定暴露面零变化)⇒ **无因果路径**。③ **什么条件一出现必须回头解决**:**下一棒开工先复取一次稳态基线**(口径:47 无活跃实例时应为 **78**);**若"有活跃实例"时仍 ≠ 79,或差值 ≥ 2** ⇒ 立即停下定位(那时可能真的是监听面变化,而不是口径抖动)。 +4. 🔴 **新登记(判据级):`RELAY_FAILOVER_COOLDOWN_MS=0` 是"看起来合法、实际会自锁"的配置** —— ① **现象**:见 8.6。`num()` 的 `/^\d+$/` 放行 `'0'`,参数表"值格必须纯数字"也没拦它,但归零会让**序⑦ F10 的"失败候选必须被排除"**一起失效 ⇒ 候选链卡在第一个失败候选上(实测 **121–123 s 无切换**)。② **已做到哪一步**:**只报告**(R7)。构 B(本单 §1.2-③ 指定的归因构造)因此判**不可用**,已用 **幕 4b** 替代并拿到同等(更强)的归因证据。③ **什么条件一出现必须回头解决**:**任何一棒若打算用"归零冷却"当开关**(回滚/演练/调参)⇒ 先回头解决本条(可选修法:把"已试过且失败"与"冷却计时"**拆成两个集合**,归零只关后者)—— ⛔ 在此之前**不许**把 `COOLDOWN_MS=0` 写进任何回滚路径。 +5. ⚠️ **在册未办(承上单,本轮仍未动)**:① guest(w-106) 实例页 **502**|② **106 agent 面不吃引导链**(`worker/agent.ts` 把 `DSHS_RENDEZVOUS_URL` 当 relay URL 用;已判**不执行**)|③ `mksess*.cjs` 失效 ⇒ 临时 session 走 PG 直插|④ **§8.8-5(`unhealthyForMs` 记账起点偏早)本单未触发回头条件**:今天 `17557`(记账) vs `19218`(墙钟)≈ **1.7 s**,远未接近 `GRACE_MS`=15 s|⑤ §8.8-6 留档缺口(`src/net/relay/**` untracked ⇒ 只能用 `wc -l` / `grep -c` 核)。 +6. ✅ **"`all` 里幕 4 失效"这件事本身已闭环**:脚本缺陷已修(`ensureRelay`),并在报告里明确"**以独立跑为准**" ⇒ ⛔ 下一位读者**不要**把 `all` 的 4 FAIL 当产品结论。 + +### 8.9 指纹(本单收口后的可复现核对口径) + +- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ **`e6b669c257d8e8964273b3b400238351`**(**序⑧ 收口值**;上一版 = `24cf2efdbcdcbe61267126ed65dba006`(序⑦ 收口)) +- **本交接单前缀(§8 及其后不计入)**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md | md5sum` ⇒ **`aa3a6ec0d66dd81f465cea2a3a08ad27`(= 规划棒出单值;回填 §8 后**未变**)** ⇒ 本单底稿未被执行棒改动 +- **交叉证据(上单)**:`sed '/^## §8 回报格式/,$d' 交接单_中继失败切流_20260917.md | md5sum` = **`419abf308c00b7668e8898aaa91ba9e8`**(未变) +- 本单**全文件** md5 请现取(⛔ 不内嵌,自指) + +--- + +## §9 附录 · 本单的取证基线(供执行棒复核,⛔ 不必重跑) + +| 事实 | 出处 | +|---|---| +| `relayFailoverThresholds(env = process.env)` ⇒ **env 覆盖生效** | `switcher.ts:55-71`(默认值 `300000 / 30000 / 15000 / 3 / 2000 / 12000`) | +| 值格判据 = `/^\d+$/` ⇒ `'0'` **合法**(构 B 的合法性) | `switcher.ts:58-62` | +| 冷却表 = 进程内 Map ⇒ **重启即清空** | `switcher.ts:127` | +| 不健康口径 = 仅 `backoff` 累计;`minAttempts<=0` = 总开关关 | `switcher.ts:188-197` | +| **唯一换址入口** `replace(targetUrl, reason)`;冷却闸门在其内部 | `switcher.ts:204-228` | +| `open` 失败 ⇒ `cooling.set(targetUrl, …)` | `switcher.ts:241` | +| 成功换址 ⇒ `cooling.set(old.url, …)` | `switcher.ts:259` | +| D6 现场 = `target === undefined` ⇒ `noCandidateChecks += 1` + 节流日志 | `switcher.ts:291-308` | +| `refreshOverlay`:地址变了就 `replace`,**不看冷却** | `server.ts:563-570` | +| 阈值读取点(无参 ⇒ 吃 `process.env`) | `server.ts:497` / `main.ts:333` | +| 三个装配点(C1/C2/C3) | `server.ts:513` / `worker/relay-tunnel.ts:94` / `main.ts:310` | +| **env 显式 ⇒ 候选链压成单点**(构 C 不可用的证据) | `directory.ts:642-651` | +| 生产目录 3 条候选、2 条同机 | `switcher.ts:235-238` 注释 | +| `stats()` **无任何对外读取面** | 全仓 `grep "\.stats()"` 对 failover/switcher 0 命中 | +| 演练脚本现状(`--scene 1\|2\|3\|all`、权威选台) | `scripts/overlay-failover-drill.cjs`(439 行,约 282 行处) | + +--- + +## §10 指纹 + +- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒ + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_切流冷却语义_20260917.md | md5sum` +- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的取值处(收口时回填)。 +- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T15-presence在线态.md b/dsh-server-docs/交接单/archive/交接单-已完成/T15-presence在线态.md new file mode 100644 index 0000000..6177f7f --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T15-presence在线态.md @@ -0,0 +1,412 @@ +# 交接单 · presence(在线状态)改造 + +> **序 ⑱(规划棒)产物** | 2026-09-17 | 状态:**待执行**(= 下一棒 序 ⑲ 执行棒) +> **性质**:**只出规划** —— ⛔ 本棒未改任何代码、⛔ 未动 47/106 配置、⛔ 未 commit / push、⛔ 未改任何生产值。 +> **依据**:`接续入口_覆盖网络线_20260916.md` §2「🎯 本轮动作」|`覆盖网络_应用场景与待完善清单_20260916.md` §五 第 7 步 + §二 / §四|`覆盖网络_瓶颈落地方案_20260916.md` **§1** + 总览|`覆盖网络_千台全场景推演_20260916.md` 瓶颈排序 / 流量预算总表 +> **用户拍板** = 「**a 要做**」(2026-09-17 17:1x)⇒ presence 先做。⚠️ **B(内容分发 / 块级内容寻址)仍待拍板 ⇒ 本单只登记、⛔ 不规划**(见 §3.3-1)。 +> **口径**:本单 **§8 前缀指纹** = `sed '/^## §8 回报格式/,$d' 交接单_presence在线态_20260917.md | md5sum`(⛔ 不是全文件 md5)。 + +--- + +## §0 摘要 + +**一句话**:presence 的第一刀必须落在**节点 / 端点在线态**(overlay node presence)上 —— 因为**全仓零应用层**(`room|chat` 命中 **0**,`presence` 字样**只出现在一处注释里**),而"房间层 presence"(1000 人大房那套订阅裁剪 / 抽样降频)**没有载体、无从下手**;节点层是它的**真前置**,且**今天就落在真实代码面上**:Manager 现在的在线态来源是**拉 relay `/status` 快照 + 15 s 陈旧回退**,这正是要替换的那条"轮询"路径。 + +**做法**(照抄 `瓶颈落地方案 §1` 的七条,逐条映射到本仓): +①presence 绑**连接生命周期**(WS 连上 = 在线、断开 = 离线)|②**本地批合并**(固定 1 s 窗口)|③**订阅式扇出**(只推给订阅了该 host 的 Manager / Worker)|④**批量事件**(一帧带 host 数组)|⑤**grace 10 s + 离线 debounce 30 s**|⑥**同 hostId 多连接按 device 聚合**|⑦**最终一致 5–15 s**(⛔ 不上强一致),外加 **TTL 安全网**(防 relay 崩溃漏事件)。 + +### §0.1 复核证据(本棒实测,3 条命令族) + +| # | 命题 | 命令 | 实测输出 | 判定 | +|---|---|---|---|---| +| P1 | 全仓**零应用层**(房间 / 对话) | `grep -rilE "\b(room|chat)\b" src \| wc -l` | **0** | ✅ 成立 | +| P2 | `presence` 字样**只在注释里** | `grep -ril presence src` | **仅 `src/net/relay/placement.ts`**;该文件 **第 21 行**原文:`而我们的第一瓶颈是 **presence(在线态)**,所以速度略重(传输方案 §12 的实测依据)` | ✅ 成立(**零实现**) | +| P3 | 现役在线态来源 = **拉快照** | `grep -n "req.url === '/status'" src/net/relay/server.ts` ⇒ **413**;`grep -n "为什么必须真的把条目从 endpoints 里删掉" src/net/relay/server.ts` ⇒ **1234** | 1234 行原文写明:**「`/status` 是 Manager …」** ⇒ Manager 消费的是 relay 的 `/status` 快照 | ✅ 成立(**即"轮询"反模式**) | + +> `src/net/relay/` 现存 15 个模块:`addr-override / client / dialer / directory / duplex / identity / index / keys / main / network / placement / rendezvous / server / switcher / wire` ⇒ **presence 的落点就是这一层**(不是新起一层)。 + +### §0.2 与入口旧结论的对账 + +- 入口 §2 曾记(2026-09-16):「仓库里目前**没有任何应用层代码** ⇒ "presence 改造"**无从下手**,这条旧指示是上一轮的笔误」。**本棒的处置**:该结论**前提仍然成立**(P1/P2 实测),但**结论要收窄** —— 不是"presence 无从下手",而是"**应用层 presence 无从下手**、**节点 presence 正当时**"(序 ①–⑰ 已把它的全部前置做完:网抽象+引导 ②、一机一钥+信任根 ③、443 兜底 ④、参数表+观测 ⑤、3–5 台最小形态 ⑥、失败切流+冷却语义 ⑦⑧、检测时延 ⑨)。 +- 入口 §2 另有:「⛔ 未为 presence / 房间层 / 内容分发**定序**(只登记候选与优缺点)」+ 序 ⑰ 执行棒的「登记门禁」。⇒ **本棒已拿到拍板**(用户「a 要做」),门禁解除,**presence 已由"候选"转为"在册执行项"**。 + +### §0.3 步骤定序(理由) + +**S0 基线 → S1 机制(relay 侧表)→ S2 线协议帧 → S3 批合并 → S4 消费侧 → S5 观测 → S6 真机验收 → S7 收口**。 + +- S1 必须最先落:**机制是唯一真活**,S2/S3 都只是它的出口形式;先红后绿在 S1 就能建起来。 +- S2 与 S3 **同一批改**(帧与批合并同属"出口形式",拆开要跑两遍 build / scp)。 +- S4 消费侧放 S3 之后:**只有出口定了才谈接线**,否则接线要返工(序⑭ 的教训:消费者先动 = 方向反)。 +- S5 观测紧贴 S6:**没有阈值就不许跑真机**(否则"降幅多少算过"无法判定,= 序⑤ 的立单理由)。 +- S6 是唯一动服务的一步,**置末**;S7 收口含部署(**属 lane 内执行细节,直接做、⛔ 不上抛**)。 + +### §0.4 范围外登记(⛔ 本单不规划) + +| # | 项 | 为什么不在本单 | +|---|---|---| +| 1 | **B 内容分发 / 块级内容寻址** | **仍待用户拍板**(复杂度已评估为「中」:块级切分与哈希标识 + 内容寻址存储与客户端校验 + 同网段 peer 发现 + 内容源优先级链;验收判据单值可测「回源字节数 ≈ 1 份 × 组数」)⇒ **只登记一行**,⛔ 不写步骤、不定序、不留键位 | +| 2 | **房间层 presence 专属做法** | 百分比抽样降频 / idle / typing / 1000 人大房批量 bulk:**应用层专属**,且应用层不存在(P1)⇒ 只登记;⛔ 参数表**不留空键位**(口径要可执行) | +| 3 | 游戏服放 L1(三件事之二) | 属"部署规范 + 容量",与 presence 无耦合 ⇒ 另立 | +| 4 | `src/worker/tunnel.ts`(SSH 隧道,**生产死路径**)是否删 | 序 ⑯ 已判**独立决策** ⇒ 本单不处置 | + +--- + +## §1 目标(一句话,可判定"做完了没有") + +> 把覆盖网络的**节点 / 端点在线态**从「Manager 拉 relay `/status` 快照 + 15 s 陈旧回退」改为「**连接生命周期驱动 + 订阅式推送 + 本地批合并 + TTL 安全网**」;**完成判据 = 稳态 presence 帧数为 0、一次上下线变化 ≤1 帧、相对旧路径的帧数降幅 ≥ 1 个数量级(本机多实例实测 N≥4)、`/status` 兜底仍可用、且 `RELAY_FAILOVER_*` / `HB_SEC` / burst 一字未动(D1 自证)**。 + +--- + +## §2 只读前置(执行前**必须**先核实的 5 条;给命令与期望输出,⛔ 不许靠推断) + +| # | 要核实的事实 | 命令(cwd = 代码仓 `D:/github/dsh_shenxian`) | 期望输出 | +|---|---|---|---| +| **P1** | 全仓**零应用层** ⇒ 本单只做节点 presence | `grep -rilE "\b(room|chat)\b" src \| wc -l` | **0**(≠0 ⇒ 先用 `head` 看是谁,再决定是否调整 §3 范围) | +| **P2** | presence **零实现** | `grep -ril presence src` ;`sed -n '21p' src/net/relay/placement.ts` | 仅 `placement.ts`;第 21 行 = **注释**(含「第一瓶颈是 presence」) | +| **P3** | 现役在线态 = **拉快照**(要替换的那条路) | `grep -n "req.url === '/status'" src/net/relay/server.ts` | `413`(行号以实测为准,⛔ 别写死) | +| **P4** | **D1 基线快照**(改前拍一次,收口时逐项比对) | 参数表 `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` ;并在参数表内 `grep -n "RELAY_FAILOVER_\|HB_SEC\|GRACEFUL_BURST"` | 参数表指纹(**序⑰ 收口值 = `8f08e74b026e6e5b5e1b3db813f031ae`**,取数时间 2026-09-17 17:2x)+ 6 个 `RELAY_FAILOVER_*` 与 `HB_SEC` / burst 现值逐行留档 | +| **P5** | 收口基线(零回归三件套的**改前值**) | 代码仓 `npm test`(**Node 22**:`npm.cmd test`)/ `node scripts/overlay-failover-drill.cjs --scene all --table "<工作区根>/参数表_覆盖网络_20260917.md"` / `node scripts/overlay-probe.cjs --table "<工作区根>/参数表_覆盖网络_20260917.md"` | `npm test` **162/161/0/1**、`--scene all` **12 PASS / 0 SKIP / 0 FAIL**、`overlay-probe` **12/12**(取值以实测为准,**必须带时间戳**) | + +> ⚠️ P4 / P5 的数字**必须带"取数时间 + 复核命令"**,⛔ 不写死绝对值(`交接单/README.md §二` 的坑 2:写死的数字会被并行改动打穿)。 +> ⚠️ 对 47 / 106 的任何命令:**ssh 一律显式 `-p 22`**(别名 `bt-server` 的 `32022` 是**失效残留口**);取证输出一律 `2>/dev/null`(PQ 告警会污染计数判据)。 + +--- + +## §3 范围 + +### 3.1 要改的(预计 8 个文件;**以实测为准**,多一个都要按 §9-3 停下报告) + +| 层 | 文件 | 改什么 | +|---|---|---| +| 机制(主体) | `src/net/relay/server.ts` | presence 权威表(`hostId → {state, connId, deviceCount, lastSeenMs, endpoints[]}`)+ 订阅表 + 推送出口 + **TTL 安全网** | +| 机制 | `src/net/relay/wire.ts` | 新增帧:`SUB` / `UNSUB` / `PRESENCE` / `SNAP`(**帧号在既有分配表末尾追加**,⛔ 不改既有帧号语义) | +| 出口 | `src/net/relay/dialer.ts` 或 `client.ts` | 批合并调度(1 s 窗口)落点(S3 时定,二选一,⛔ 不两处都改) | +| 消费 | `src/net/relay/rendezvous.ts`(`RelayRendezvous`) | 订阅为主路径、`/status` **降级为兜底 / 对账**(⛔ 不删) | +| 消费 | `src/web/server.ts` | **C1 装配点**:`refreshOverlay` 一带改为订阅驱动(去掉"只能靠拉"的假设) | +| 消费 | `src/worker/relay-tunnel.ts` / `src/worker/agent.ts` | **C2 装配点**:同族接线(若实测无需改 ⇒ 在 §8 写明"已核对、无需改",⛔ 别为对称而改) | +| 测试 | `test/relay.test.mjs`(+可选新增 `test/relay-presence.test.mjs`) | 先红后绿用例;⛔ **不许改 `package.json` 的测试入口语义** | +| 观测 | `参数表_覆盖网络_20260917.md` + `scripts/overlay-probe.cjs` | 新增 `PRESENCE_*` 键(**全部有实测值,⛔ 无 `待测` 残留**)+ `OBS-13/14/15` + 探针新项 | + +### 3.2 明确**不动**的(⛔ 防止顺手扩大) + +1. ⛔ **不动任何生产值**:`RELAY_FAILOVER_*`(6 键)/ `HB_SEC` / burst 语义 —— 这是 **D1**,也是本线长期硬门。 +2. ⛔ **不删 `/status`** —— 它是回滚链的一环(§7-②)。 +3. ⛔ **不新增公网监听口**、⛔ 不改 `nft`、⛔ 不改 nginx(本单是纯应用层改动,**暴露面零变化** ⇒ §4.4 R5 未命中)。 +4. ⛔ **不改 `src/net/relay/switcher.ts` 的冷却语义** —— presence 抖动抑制**独立成表**(§4.1-D7)。 +5. ⛔ 不碰房间层 / 内容分发 / 打洞实现 / `src/worker/tunnel.ts`(死路径)。 +6. ⛔ 不动 `04-调整方案/**` 档案正文(历史档案属性)。 +7. ⛔ **不 commit / 不 push**(未获明确授权;序 ⑰ 那次授权是**当次一次性**的)。 +8. 🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**(该值看似合法、实际自锁:归零会让"失败候选必须被排除"一起失效,实测 121–123 s 不切换)。 + +### 3.3 范围外登记(同 §0.4;执行棒**只登记、⛔ 不动手**) + +1. **B 内容分发 / 块级内容寻址** —— 待拍板。 +2. **房间层 presence 专属做法**(抽样降频 / idle / typing / bulk)—— 无载体。 +3. 骨干节点服务范围(A 自用 / B 全网)—— 入口 §4 既有待拍板项,与 presence 无关。 + +--- + +## §4 决策点 + +### 4.1 已定项(**本棒自决,可推翻** —— 依据均为客观可判) + +| # | 决策 | 值 | 依据 | +|---|---|---|---| +| **D1** | **落点层次** | **节点 / 端点 presence**(`src/net/relay/**`);⛔ 应用层 presence 不做 | §0.1 P1/P2 实测(零 `room|chat`、`presence` 零实现)+ 入口 §2 既有结论 + `清单 §五` 第 7 步把房间层整块后置 | +| **D2** | **权威归属** | **在线态权威 = relay 侧**(连接生命周期所在层);Manager 只**消费 + 缓存**,⛔ 不自己轮询推导(避免双权威) | ⚠️ **与 §3-4「权威状态单点」不冲突**:那条说的是**归属 / 租约 / 骨干资格**;presence 是**连接事实**,天然归属连接所在层。**租约仍只归 Manager 写** | +| **D3** | **一致性口径** | **最终一致**,允许 **5–15 s** 陈旧;**grace 10 s + 离线 debounce 30 s + TTL 安全网**(TTL 取 `HB_SEC` 现值的 3 倍,⛔ **不改 `HB_SEC`**) | `瓶颈落地方案 §1` 第 5/7 条;TTL 是"防网关崩溃漏事件"的安全网(第 1 条) | +| **D4** | **批合并 = 固定 1 s 窗口**(方案 A) | 每网关把本地变化攒起来,**每 1 s 一帧 pipeline 提交** | `瓶颈落地方案 §1` 第 2/4 条(业界量级对照:2 万条命令/秒 → 1 次 pipeline/秒)。**为什么不取"零延迟逐次推"**:presence 允许 5–15 s 陈旧 ⇒ 1 s 窗口完全够用,逐次推只会在抖动时制造帧爆炸(**正是要消灭的现象**) | +| **D5** | **`/status` 保留但降级** | 从"主路径"降为**兜底 / 对账**(保留代码与 nginx 放行,⛔ 不删) | 回滚链依赖它(§7-②);且它现在是 Manager 的唯一事实来源,一步切干净 = 单点失败 | +| **D6** | **订阅可见性** | 默认**只允许订阅同网络、且本机有权**的 host(沿用序③ 的逻辑名 `<net>/<hostId>` 与成员资格校验);**跨网络订阅 ⇒ 显式拒绝 + 拒绝计数 +1**,⛔ 不静默返空 | 权限只收窄(R5 方向);**静默返空 = 假绿**,本线已有判别器教训 | +| **D7** | **抖动抑制独立成表** | presence 的 grace / debounce **自成一张表**,⛔ **不复用** `switcher.ts` 的冷却表 | 两条语义不同("拨号失败"vs"在线态抖动"),混表会污染序⑦/⑧ 的冷却判据 | + +### 4.2 真取舍(本单 = **空**) + +三个曾考虑上抛的项(落点层次 / 批合并策略 / `/status` 去留)**均有客观判据可分出高下**(见 D1 / D4 / D5 的"依据"列)⇒ 按 §1 判据**自行拍掉、⛔ 不上抛**。 + +### 4.3 待拍板 = **空** + +- presence 本身**已拍板**(用户「a 要做」)。 +- **B(内容分发)不属本单**:它是**独立一项**、仍待拍板 ⇒ 本单只登记一行(§3.3-1),⛔ 不写成"待你拍板"塞进本节(避免把别人那题捆进来)。 + +### 4.4 权限影响评估(R5,**先评估后动手**) + +| 维度 | 变化 | 结论 | +|---|---|---| +| 新增监听口 | **0**(帧走既有 wss 通道) | — | +| 新增凭据 | **0**(复用序③ 的节点密钥 / 成员资格) | — | +| 新增入站 | **0**(Manager / Worker **仍然只拨出**) | — | +| 可见面 | 订阅可见性**只收窄**(D6 同网络 + 有权 + 跨网络显式拒绝) | — | + +> ⇒ **未命中 R5**,无需权限评估门禁,可直接开工。 + +### 4.5 与既有机制的对账(⛔ 不重复劳动、不重复造) + +| 既有机制 | 与本单关系 | +|---|---| +| 序③ 身份 / 成员资格(`identityRequired`、`<net>/<hostId>`) | **复用**:订阅鉴权走它,⛔ 不新造一套 | +| R3 `RendezvousRegistry` / `RelayRendezvous`(relay 实时快照 + **15 s 陈旧回退**) | **被替换主路径**(保留为兜底):15 s 陈旧回退正是 D3 口径的现存实现,改造后由订阅推送取代 | +| R5 `DIAL / DIAL_ACK`(Manager 只拨出 + 本机回环池) | **不改**:presence 帧搭同一条通道,⛔ 不动拨号模型 | +| 序⑦ `directory.ts#listCandidatesFromDoc` / 序⑧ `switcher.ts`(冷却 + 一跳豁免) | **不改**:那是**地址选择**,与在线态是两件事(D7) | +| 序⑨ `waitUpOnStatus` + 终态早退(deadline 四段分解) | **不改**:那是**换址墙钟**;presence 缩短的是"Manager 多久知道谁在线",⛔ 别混为一谈 | +| 序⑤ 参数表 `OBS-01..12` + `scripts/overlay-probe.cjs` | **扩展**:新增 `OBS-13/14/15`(沿用"阈值⛔ 不许是脚本魔数"的纪律) | +| 序⑥ `RELAY_MAX_HOSTS` / `capacity.conf` | **不改**:帧数在 presence 口径下与容量无关 | + +--- + +## §5 步骤 S0–S7(每步自带**一次可执行的验证**) + +> ⚠️ **开工前**:先抢全局执行锁 `bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<会话名>"`;抢不到 = 有会话在跑 ⇒ 停手(⛔ 不排队等)。 +> ⚠️ **本单是执行棒,按单开工,⛔ 无须再出规划单**。 +> ⚠️ **部署属 lane 内执行细节**(开发环境服务器,R8)⇒ 直接做,**动手前一句话说明在动什么**即可,⛔ 不上抛。 + +### S0 · 只读前置 + 基线采样(**零改动**) +逐条跑 §2 的 P1–P5 并**留档原文**;把 P4 的生产值快照与 P5 的三件套基线存到 `_中间产物_待清理/seq19/`。 +**验证**:§2 五行输出全部与"期望输出"一致;若有偏差 ⇒ ⛔ 先停下报告,别改码。 + +### S1 · relay 侧 presence 权威表(**唯一真活**) +在 `src/net/relay/server.ts` 落 presence 表:**`open` ⇒ online;`close` ⇒ offline(先过 grace 10 s + debounce 30 s)**;**TTL 安全网**按 D3 兜底;同 `hostId` 多连接**按 device 聚合**(任一在线 = 在线)。 +**判别器(必做,序⑤ 的教训)**:**不许只写日志** —— 必须有可断言计数 `subs` / `pushed` / `rejected`,并以 `status()` 暴露。 +**验证(先红后绿)**:往 `test/relay.test.mjs` 追加用例 —— ①断开后 ≤ grace 仍 online、> grace+debounce 转 offline;②同 hostId 两连接断一条仍 online;③kill 掉通道后 TTL 内自愈。**先对旧 `lib` 跑一次拿红**,再改。 + +### S2 · 线协议帧(`wire.ts`) +新增 `SUB` / `UNSUB` / `PRESENCE` / `SNAP`:**帧号在既有分配表末尾追加**,⛔ 不改既有帧号语义(改了 = 与 47/106 上现役 `lib` 不兼容,热更窗口会断)。 +**验证**:帧号唯一性用例(既有号集合 ∩ 新号 = ∅)+ 旧 lib 对新帧"**未知帧号 ⇒ 显式报错**"(⛔ 不静默丢弃)。 + +### S3 · 批合并 + 节流(1 s 窗口,D4) +每个网关把 1 s 内的变化**合并成一帧**(一帧带 host 数组);`SNAP` 供重连后**一次拿全量**(做法 ⑦:重连必须重拉)。 +**验证**:单测断言 —— **稳态 0 帧**;**一次变化 ≤1 帧**;100 次连续变化(<1 s 内)合并后 **= 1 帧**。 + +### S4 · 消费侧接线(C1 / C2) +`RelayRendezvous` 改为**订阅为主路径**,`/status` 降为兜底;**C1** `src/web/server.ts`(`refreshOverlay` 一带)、**C2** `src/worker/relay-tunnel.ts` / `src/worker/agent.ts` 同族接线(**实测无需改就写明,⛔ 不为对称而改**)。 +**验证**:单测 —— 订阅生效后**不再发起 `/status` 轮询**(判别器 = `/status` 命中计数稳态 = 0);断掉订阅 ⇒ **自动回退 `/status`** 仍能拿到在线态。 + +### S5 · 观测落地(参数表 + 探针) +参数表新增:`PRESENCE_BATCH_MS` / `PRESENCE_GRACE_MS` / `PRESENCE_OFFLINE_DEBOUNCE_MS` / `PRESENCE_TTL_MS` / `PRESENCE_SUB_MAX`(**每个键都要有实测值,⛔ 无 `待测` 残留**)+ `OBS-13`(presence 帧率:稳态 0)`OBS-14`(首帧即全量 `SNAP`,⛔ 无 N+1)`OBS-15`(状态陈旧 p95 ≤ 15 s);`scripts/overlay-probe.cjs` 增同名新项。 +**验证**:`node scripts/overlay-probe.cjs --table "<工作区根>/参数表_覆盖网络_20260917.md"` ⇒ 新项**能判 PASS 也能判 FAIL**(⛔ 只做过 PASS 的路径 = 未验证,序⑫ 的夹具模式可复用:**假红 / 假绿两侧都要能抓**)。 + +### S6 · 本机多实例真机验收(**唯一动服务的一步**) +用**已拍板的本机多实例**(用户原话「本机内存大 可以模拟多台」)起 **N ≥ 4** 个节点,实测: +① 稳态 presence 帧数 = **0**;② 一次上下线 = **≤1 帧**;③ 与旧路径(按 T 秒拉 `/status`)**帧数降幅 ≥ 1 个数量级**;④ 首帧即全量;⑤ 跨网络订阅 ⇒ **显式拒绝**且计数 +1。 +**验证**:五项逐条给"命令 + 实测数字 + 判定";跑完**必须归零**(`systemctl restart dshs` 把 Manager 归位回 47)并复跑探针。 + +### S7 · 收口(部署 + 三件套 + 回填 + 指纹) +部署 = `npm run build`(Node 22)→ `scp` 产物到 47 `/opt/dshs/lib/` 与 106 `/opt/dshs-cluster/lib/`(relay 真身另在 `/opt/dsh-relay/lib/`,**三处都要铺**)→ `systemctl restart dshs`(+ 需要时 `dshs-relay`)——**属 lane 内,直接做**;随后跑零回归三件套 + 回填 §8 + 给指纹 + 推进 `接续入口_覆盖网络线_20260916.md` §0/§2 + 写工作区日志。 +**验证**:§6 的 E9/E10/E11 全绿;`overlay-probe` **12/12 + 新项 PASS**;池口 **64**;三服务 `active`。 + +--- + +## §6 验收判据 E1–E11(命令 + 期望输出,**可被第三方复现**) + +| # | 判据 | 命令 / 观测点 | 期望 | +|---|---|---|---| +| **E1** | **稳态 0 帧**(变化驱动,无变化不推) | 单测 + 真机 N≥4 静置 60 s | presence 帧数 = **0**(⛔ 非 0 即未达标) | +| **E2** | **一次变化 ≤1 帧**(批合并生效) | 单测:1 s 内 100 次变化 | 帧数 = **1** | +| **E3** | **降幅 ≥ 1 个数量级**(对旧路径) | 真机:旧口径 `N` 节点 × `/T` 秒拉 → 新口径变化驱动 | 降幅 **≥ 10×**(写明 N / T / 实测算式) | +| **E4** | **首帧即全量**(⛔ 无 N+1) | 订阅后第一帧 | = `SNAP`(**1 帧**完成,⛔ 非逐 host 拉取) | +| **E5** | **生命周期正确性** | 断连 ⇒ ≤ 10 s 仍 online、> 40 s 转 offline;`kill -9` relay ⇒ TTL 内自愈 | 三条全绿(**三个阈值均来自 D3,⛔ 不许改口径去凑判据**) | +| **E6** | **多设备聚合** | 同 hostId 两连接断一条 | 仍 **online** | +| **E7** | **最终一致**(⛔ 不上强一致) | 代码面检查 | **无**跨节点同步 RPC / 无分布式锁 | +| **E8** | **权限只收窄 + 显式拒绝**(判别器) | 跨网络订阅请求 | **显式拒绝** + `rejected` 计数 **+1**(⛔ 不静默返空) | +| **E9** | **零回归三件套** | `npm test`(Node 22)/ `--scene all`(**必须带 `--table`**)/ `overlay-probe`(**必须带 `--table`**) | ≥ **162/161/0/1** + **12 PASS / 0 SKIP / 0 FAIL** + **12/12 + 新项 PASS** | +| **E10** | **参数表口径可执行** | `grep -c "待测" 参数表_覆盖网络_20260917.md` + 新增键逐项 | `PRESENCE_*` **全部有实测值**;**指纹变化必须写明新值**(口径 = `sed '/^## §10 指纹/,$d' … \| md5sum`) | +| **E11** | **D1 自证** | 与 §2-P4 的快照逐项比对 | `RELAY_FAILOVER_*` / `HB_SEC` / burst **一字未变**;`grep -c 'COOLDOWN_MS=0' scripts/` = **0** | + +--- + +## §7 回滚(三层,**均秒级**;⛔ 不含任何 `COOLDOWN_MS=0`) + +① **代码级**(主回滚):`git` 恢复本单改动的文件(或从 `.bak-seq19-<YYYYMMDD-HHMMSS>` 副本还原)→ `npm run build`(Node 22)→ `scp` 铺回 47 / 106 / relay 三处 `lib` → `systemctl restart dshs`(+ `dshs-relay`)。**判据 = 探针回 12/12 + `--scene all` 12 PASS**。 +② **消费侧回退**(不重启服务即可):删掉 S4 的订阅装配点 ⇒ `RelayRendezvous` **自动回退**到既有的「relay 实时快照 + 15 s 陈旧回退」路径(**D5 保留 `/status` 就是为了这条**)。**判据 = `/status` 命中计数恢复非零 + 在线态仍正确**。 +③ **观测侧**:参数表新增键**保留但标注"未启用"**(⛔ 不删行 —— 删了会让 `OBS-13/14/15` 悬空)。 + +> ⚠️ 备份命名一律 `.bak-seq19-<YYYYMMDD-HHMMSS>`,落**原位同目录**;relay 侧另存 `/etc/dshs/relay-keys.json.bak-seq19-*`(若本轮未动密钥则**不备份**)。 +> ⛔ **回滚路径里出现 `RELAY_FAILOVER_COOLDOWN_MS=0` = 违规**(该值自锁,实测 121–123 s 不切换)。 + +--- + +## §8 回报格式(执行会话**必须**回填的证据,逐条给"命令 + 原文输出 + 判定") + +``` +### 8.0 回报(序 ⑲ 执行棒 · <开始时刻>–<收口时刻>) + +### 8.1 只读前置复现 +P1–P5 命令 + 原文输出(P4/P5 带取数时间);与 §2「期望输出」逐条对账(一致 / 偏差及处置) + +### 8.2 S1–S7 逐步回报 +每步:改了什么(文件 + 行级要点)/验证命令 / 原文输出 / 判定(✅ / ⚠️ 临界 / ❌) +⛔ 不许写"已完成"三字了事;**每步必须有一次可执行验证的原文** + +### 8.3 验收 E1–E11 对账表 +| # | 判据 | 命令 | 实测 | 判定 | +(❌ 项必须写:卡在哪 / 已做到哪一步 / 什么条件一出现必须回头) + +### 8.4 真机降幅实测(E3) +N = ? / 旧口径算式的取数 / 新口径实测帧数 / 降幅 = ? + +### 8.5 D1 自证 +`RELAY_FAILOVER_*`(6) / `HB_SEC` / burst 改前 vs 改后逐值对比;`grep -c 'COOLDOWN_MS=0' scripts/` 实测值 + +### 8.6 部署与收口 +build RC / scp 三处路径与产物 md5 / 重启了哪些单元 / 归零后三件套复跑值 / 池口数 + +### 8.7 参数表变更 +新增键与实测值;**指纹:旧 → 新**(口径 = `sed '/^## §10 指纹/,$d' … | md5sum`) + +### 8.8 遗留与回头条件 +逐条列出;**若某条命中 §9 的回头条件 ⇒ 必须写明"命中第 N 条"并停下报告** + +### 8.9 本单指纹(可复现) +`sed '/^## §8 回报格式/,$d' 交接单_presence在线态_20260917.md | md5sum` = ?(回填后**应保持不变**:§8 之后的内容不影响前缀) +``` + +--- + +## §8★ 回报(序⑲ 执行棒 · **已回填** · 2026-09-17 17:28–18:16) + +> 本节 = 按上面 §8 的格式回填的**实测证据**(原文 + 命令)。所有输出取自本棒实跑,⛔ 无一条为推断。 + +### 8.0 基本信息 + +| 项 | 值 | +|---|---| +| 会话名 | `覆盖网络线-序19执行棒-presence`(全局执行锁:抢到 → 收口时释放) | +| 起止 | 17:28 → 18:16(含 S6 真机 2 分钟 + `--scene all` 6 分钟) | +| 改动文件 | 6 个 `src` + 1 个测试 + 1 个探针 + 参数表(⛔ 未 commit · 未 push) | +| 边界自证 | ⛔ 未改任何生产值 · ⛔ 未新增公网监听口 · ⛔ 未改 nft/nginx · 🔴 全程未出现 `COOLDOWN_MS=0` | + +### 8.1 只读前置复现(S0) + +| # | 命令 | 实测原文 | 与 §2 期望 | +|---|---|---|---| +| P1 | `grep -rilE "\b(room\|chat)\b" src \| wc -l` | `0` | 一致(零应用层) | +| P2 | `grep -ril presence src` | `src/net/relay/placement.ts`(仅注释) | 一致(只读缓存待替换已成事实) | +| P3 | `grep -n "req.url === '/status'" src/net/relay/server.ts` | `413`(`head -2` 证输出非空) | 一致 | +| P4 | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | `8f08e74b026e6e5b5e1b3db813f031ae` | 一致 ⇒ **D1 自证成立**(生产值一字未改) | +| P5 | `npm.cmd test` / `overlay-probe --table` / `--scene all --table` | `161 pass / 0 fail / 1 skip` / `12/12 PASS` / `12 PASS / 0 FAIL` | 一致(基线) | + +**P4 生产值快照(逐值留档)**:`HB_SEC=15`(`server.ts:60`) · `HALF_OPEN_MS=37500` · `RELAY_FAILOVER_MIN_ATTEMPTS=3` · `GRACE_MS=15000` · `COOLDOWN_MS=300000` · `DEADLINE_MS=30000` · `CHECK_MS=2000` · `UP_TIMEOUT_MS=12000` · `GRACEFUL_BURST_MS=15000` · `EXEMPT=1`;`grep -c 'COOLDOWN_MS=0' scripts/` = **0**。 + +### 8.2 S1–S7 逐步回报 + +| 步 | 改了什么(文件 + 行级要点) | 验证命令 | 实测原文 | 判定 | +|---|---|---|---|---| +| **S1** | `server.ts`:`PresenceState`(`conns: Map<connId,lastActMs>` 逐连接判死)+ `presenceTouch/presenceDrop/presenceDevices/presenceEntry/publishPresence/flushPresence/presenceSnapshot` + 计数 `subs/pushed/snaps/rejected/statusHits` | `node --test --test-name-pattern="T(25\|29\|31) " test/relay.test.mjs` | 先红 `0 pass / 7 fail` → 后绿 **7/7** | ✅ | +| **S2** | `wire.ts`:**末尾追加** `SUB 0x10 / UNSUB 0x11 / PRESENCE 0x12 / SNAP 0x13`(既有 0x01–0x0f 语义一字未动) | T26(帧号唯一 + 未知帧号 0x99 必须显式拒绝) | `close 1008` + `authFailed 0→1` | ✅ | +| **S3** | `server.ts`:`presenceBatchMs` 批合并出口(1 s 窗口、一帧带数组)、`SNAP` 首帧全量、TTL 安全网逐连接摘除 | T27(6 台同窗口 ⇒ 1 帧 6 条)/T29(幽灵连接 TTL 自愈) | `Δ帧=1 条目=6` / 离线自动收口 | ✅ | +| **S4** | `client.ts`(订阅侧:`subscribePresence/presenceStatus/presenceOf/…`,失败 ≥2 次 ⇒ `unsupported` 停止再试)+ `rendezvous.ts`(`presence` 为**主路径**,`undefined` 才回落 `online`)+ `web/server.ts`(装配订阅 + `refreshRelay` 挂起 + `addressOf` ② 订阅落点) | T30(订阅新鲜 ⛔ 不读兜底;`undefined` 必须回退;两台 relay 之间**无**同步) | `calls.length = 0 / 0 / 1`;`s2.presence.length = 0` | ✅ | +| **S5** | 参数表 §3.6 新增 `PRESENCE_*` 五键 + §6 新增 `OBS-13/14/15` + 四阈值键;`overlay-probe.cjs` 新增三条**真判据**(含 `--status-fixture-2` 双快照) | 夹具五连跑(good + bad13/14/15/16) | good:三条全 `PASS`;bad13 `Δpushed=5 ⇒ FAIL`;bad14 `snaps=0 ⇒ FAIL`;bad15 `p95=60000 ⇒ FAIL`;bad16 口径漂移 ⇒ FAIL | ✅ | +| **S6** | 无改动(只跑现场) | `_tmp_seq19_s6.mjs`(**生产默认时序零注入**,N=6 本机多实例) | **8/8 ✅**(明细见 §8.4) | ✅ | +| **S7** | `npm run build` → `scp` 四处 `lib/` → 重启 4 个单元 | 见 §8.6 | build `RC=0`;四处 md5 同值;`dshs/dshs-relay/dshs-worker/dshs-pg` 全 `active` | ✅(OBS-09 除外,见 §8.8-1) | + +**S1 的"先红后绿"原文**:`git stash push -m seq19-red -- <6 个 src 文件>` → 重建 `lib` → 跑 7 个新用例 = `0 pass / 7 fail`(`RED_RC=1`);`git stash pop` + 定点 LF 还原 → 重建 → **7/7 pass**。 + +### 8.3 验收 E1–E11 对账表 + +| # | 判据 | 命令 / 观测点 | 实测 | 判定 | +|---|---|---|---|---| +| **E1** | 稳态 0 帧 | 单测 T25/真机 S6 静置 60 s/探针 `OBS-13` | `Δ帧=0`(期间 24 个心跳帧在流、`lastSeenAgoMs≈101ms`)/真机 `Δpushed=0` | ✅ | +| **E2** | 一次变化 ≤1 帧 | T25/T27/S6 ② | `Δ帧=1`(上线、下线、6 台同窗口各 1 帧) | ✅ | +| **E3** | 降幅 ≥10× | S6 ③(算式见 §8.4) | 字面口径 **63×**;同消费者口径 10.5×;稳态 ∞ | ✅ | +| **E4** | 首帧即全量 | T25/S6 ④/真机 `/status` | `snapFrames=1`;`snaps 0→1`;真机 `snaps=2 = 订阅次数` | ✅ | +| **E5** | 生命周期正确性 | T25/T29/S6 ②a | 断连→改口 **40985 ms**(grace 10 s+debounce 30 s);TTL 幽灵自愈 | ✅ | +| **E6** | 多设备聚合 | T25(连接更替) | 顶旧接新期间 `online=true` 且 `devices=1`、**零新增帧** | ✅ | +| **E7** | 最终一致(无跨节点同步) | T30 尾段(两台 relay 独立) | `s1.presence=1 / s2.presence=0` | ✅ | +| **E8** | 权限只收窄 + 显式拒绝 | T28/S6 ⑤ | 客户端 `rejected=1`、服务端 `rejected 0→1`、状态回 `idle` | ✅ | +| **E9** | 零回归三件套 | `npm test` / `--scene all` / `overlay-probe` | **169/0/1**(≥162/161 ✅);`--scene all` = 见 §8.8-4;探针 **15 PASS / 1 FAIL** | ⚠️ **1 红**(`OBS-09`,见 §8.8-1) | +| **E10** | 参数表口径可执行 | `grep -c 待测` + 新键逐项 | `PRESENCE_*` **全为实测值**(⛔ 无 `待测`);指纹 `8f08…→ 13de5f9b77c486d71e5b83ec909b17b2` | ✅ | +| **E11** | D1 自证 | 与 §2-P4 快照逐项比对 + `grep -c 'COOLDOWN_MS=0' scripts/` | 六项生产值**一字未变**;禁用串计数 **0** | ✅ | + +### 8.4 真机降幅实测(E3) + +**现场**:本机多实例,`N = 6` 台 worker + 1 台订阅方,relay 用**生产默认时序**(零注入),窗口 `W = 104 s`(含一次下线 + 一次上线)。 +**旧口径取数**:`src/web/server.ts:76 RELAY_STATUS_POLL_MS = 5000` ⇒ 同一消费者每 5 s 拉 1 次。 +- 字面口径(交接单 §6 E3 原文「`N` 节点 × `/T` 秒拉」):`6 × (104 / 5) = 126` 次 → 新口径实测 **2 帧** ⇒ **63.0×** +- 同消费者口径(同一台 Manager 轮询):`104 / 5 = 21` 次 → 2 帧 ⇒ **10.5×** +- 纯稳态(60 s、零变化):旧 `12` 次 vs 新 **0 帧** ⇒ **∞** +**判定**:✅(≥10×)。⚠️ 临界说明:新口径的**下限**是"变化次数 × 1 帧"(与 N **无关**),而旧口径与 N 成正比 ⇒ N 越大降幅越大、变化越稀疏降幅越大;本窗口把"变化"人为压到 104 s 内两次(极端档)仍达 10.5×。 + +### 8.5 D1 自证 + +| 值 | 改前 | 改后 | +|---|---|---| +| `HB_SEC` | 15 | 15 | +| `HALF_OPEN_MS` | 37500 | 37500 | +| `GRACE_MS` | 15000 | 15000 | +| `COOLDOWN_MS` | 300000 | 300000 | +| `DEADLINE_MS` | 30000 | 30000 | +| `GRACEFUL_BURST_MS` | 15000 | 15000 | +| `RELAY_FAILOVER_EXEMPT` | 1 | 1 | +| `grep -c 'COOLDOWN_MS=0' scripts/` | 0 | **0** | + +### 8.6 部署与收口 + +- `npm run build`(Node 22)= **RC 0**;`npx tsc --noEmit` = **RC 0**。 +- 铺码 **四处**(产物 md5,改后值):`server.js = 182a7645f8ad3e8f240737b9dab91028`,`index/wire/client/rendezvous.js = 2a688424… / 467d38bd… / 46a025ff… / e851667d…`,`web/server.js = a360c7f4…` —— 47 `/opt/dshs/lib/net/relay/` + 47 `/opt/dsh-relay/lib/net/relay/` + 106 `/opt/dshs-cluster/lib/net/relay/` + 106 `/opt/dsh-relay/lib/net/relay/`(另 47 `/opt/dshs/lib/web/server.js`)⇒ **远端 md5 与本机逐条相同**。 +- 重启单元:47 `dshs-relay`→`dshs-worker`→`dshs`;106 `dshs-relay`→`dshs-worker` ⇒ `dshs / dshs-relay / dshs-worker / dshs-pg` **全 active**。 +- 归零复跑三件套:`npm test` **169/0/1**;`overlay-probe --table` = **15 PASS / 1 FAIL**(唯一红项 `OBS-09`);池口 **64**(`DIALER_POOL`,`ss` 实测 25000–25063 全绑);`--scene all` = 见 §8.8-4。 +- 真机新路径证据(47 relay `/status`):`presenceTiming = {graceMs 10000, offlineDebounceMs 30000, batchMs 1000, ttlMs 45000, subMax 0}`;`counters = {subs 1, pushed 3, snaps 2, rejected 0}`;Manager 日志 `[relay-client] presence SNAP 2 条(scope=all)`。 + +### 8.7 参数表变更 + +新增键(**全部实测/可复算,⛔ 无 `待测`**):`PRESENCE_BATCH_MS=1000` · `PRESENCE_GRACE_MS=10000` · `PRESENCE_OFFLINE_DEBOUNCE_MS=30000` · `PRESENCE_TTL_MS=45000` · `PRESENCE_SUB_MAX=0` · `PRESENCE_STEADY_FRAMES_MAX=0` · `PRESENCE_SAMPLE_HITS_MIN=1` · `PRESENCE_SAMPLE_GAP_MS=3000` · `PRESENCE_STALE_P95_MAX_MS=15000`;新增观测行 `OBS-13/14/15`。 +**指纹(口径 = `sed '/^## §10 指纹/,$d' … | md5sum`):`8f08e74b026e6e5b5e1b3db813f031ae` → `13de5f9b77c486d71e5b83ec909b17b2`**。 + +### 8.8 遗留与回头条件 + +1. 🔴 **`OBS-09` 红 ⇒ 命中 §9-8**(**已停下报告**)。**卡在哪**:`OBS-09` 要的是**用户实例面**(47 `127.0.0.1:20000`、对端 21000 经 relay),而 S7 规定的 `systemctl restart dshs`(+ 106 `dshs-worker`)**会把 `dsh-114801-*.scope` / `dsh-100002-*.scope` 一起收掉**;平台是**按需拉起**(拉起的唯一触发 = 用户真去打开自己的工作区),⛔ **无 reconciler**(重启后 4 分钟仍 `inactive`,Manager 日志零 spawn 记录)。⇒ **S7 的重启步骤与 E9 的探针判据在"无用户访问"时互相冲突**。**已做到哪一步**:探针 15 PASS / 1 FAIL,其余 11 项基线项全 PASS(`OBS-11` 集合判据含 nft 与监听面全清)。**什么条件一出现必须回头**:任一用户访问自己的实例页(实例被拉起)后重跑 `overlay-probe --table …` ⇒ `OBS-09` 应回 PASS;若拉起后**仍红** ⇒ 属真回归,立即回头查 `addressOf` 链路。 +2. ⚠️ **本棒实测踩到并已修的假死(重要,已补测)**:`presenceTouch` 原在 `ensureEndpoint` **之前**、且 `localPort` 是 `listen(0)` 回调里**异步**才落地的 ⇒ 首帧推出去的落点是 `0`;而发布只在"在线态翻转"时发生 ⇒ 那个 `0` **永远修不回来** ⇒ 订阅方 `addressOf` 查不到落点 ⇒ **页面打不开且不报错**。修法:① `presenceTouch` 移到 `ensureEndpoint` 之后;② 落点落地时(`listen` 回调)与端口增删时**强制重推**(`publishPresence(..., force)`)。**新用例 `T32` 先红后绿覆盖**(先红实测 `localPort=0`)。真机复验:`ops/w-106` 的 `localPorts=[{port:19000, localPort:35289}]`(**非 0**)。 +3. ⚠️ **已登记的既存缺陷(本单范围外,⛔ 未动)**:`web/server.ts` 的 `translateEndpoint` 走了与 `addressOf` **不同的键口径**(表键是逻辑名、传入裸 `hostId` ⇒ 恒 `undefined`)⇒ 该分支的订阅回退**故意未加**(加了也是死代码);要改须另立单(R7)。 +4. ✅ **`--scene all` 本轮结果 = 12 PASS / 0 SKIP / 0 FAIL**(唯一"附带影响":幕 4 前置含一次 Manager 重启 ⇒ 可能再次收掉实例 scope;**未出现 FAIL**)。 +5. ⏳ **`translateEndpoint` 键口径既存缺陷**(见上第 3 条)⇒ 建议并入序 ⑳ 或另立单。 +6. ⚠️ **动态值提醒**:`w-106` 的 relay 落点 `localPort` 是**动态值**(本轮 = 35289),⛔ 不得写死进参数表。 + +### 8.9 本单指纹(可复现) + +`sed '/^## §8 回报格式/,$d' 交接单_presence在线态_20260917.md | md5sum` = **`f612858344077420cff5f1f9ef1c942f`**(回填后**保持不变** ✅ —— §8 之后的内容不影响前缀)。 + +--- + +## §9 回头条件(**一出现必须回头**;⛔ 不许自行扩范围、⛔ 不许调生产值去凑) + +1. **要动生产值才能继续** —— 需要改 `RELAY_FAILOVER_*` / `HB_SEC` / burst 任一 ⇒ **停下报告**(D1)。 +2. **要扩大暴露面** —— 需新增公网监听口 / 放开遮蔽 / 跨网络或跨租户订阅 ⇒ **命中 R5** ⇒ 停下出权限评估。 +3. **要超出 §3.1 的文件集** —— 除参数表 / 测试 / 探针 / 文档外,改动溢出到 `src/**` 的其他层 ⇒ **停下报告**,别扩大。 +4. **批合并失效** —— 一次状态变化推不出 ≤1 帧,或稳态出现非零 presence 帧 ⇒ **停下报告**(D4 的前提被证伪)。 +5. **观测跑不出单值** —— `OBS-13/14/15` 在真机拿不到可判定的数字 ⇒ **停下报告**,改口径(⛔ 不许把未验证的项标 PASS)。 +6. **非静态失败** —— 跨网络订阅被**静默放行**(而非显式拒绝),或未知帧号被**静默丢弃** ⇒ **立即停**(假绿,本线头号教训)。 +7. **要求"先有房间层才能验收"** ⇒ **停下报告**:本单口径是**节点层单值判据**,房间层不在范围内。 +8. **零回归退化** —— `npm test` 低于 **162/161/0/1**、或 `--scene all` 出现 FAIL、或探针低于 **12/12** ⇒ **停下报告**。 +9. 🔴 **任何回滚 / 演练 / 夹具路径要写 `RELAY_FAILOVER_COOLDOWN_MS=0`** ⇒ **禁止**(不是"回头",是禁令)。 + +--- + +## §10 指纹与状态 + +| 项 | 值 | +|---|---| +| 本单 §8 前缀指纹 | **`f612858344077420cff5f1f9ef1c942f`**(落单时算出,2026-09-17 17:2x;口径 = `sed '/^## §8 回报格式/,$d' … \| md5sum`) | +| 参数表指纹(改前) | `8f08e74b026e6e5b5e1b3db813f031ae`(序⑰ 收口值,2026-09-17 17:2x) | +| 代码仓 HEAD(落单时) | `bc0dd2c`(已 push,远端 `master` 一致) | +| 本单状态 | **待执行**(下一棒 = 序 ⑲ 执行棒) | +| 本棒边界自证 | ⛔ 未改码 / ⛔ 未动 47·106 / ⛔ 未 commit·push / ⛔ 未改任何生产值 / 🔴 `COOLDOWN_MS=0` 仅以"禁令"形式出现 | + +--- + +## §11 规划棒回报(序 ⑱ · 2026-09-17 17:22–17:2x) + +**做了什么**:只出规划 —— 产出本单(**287 行 / 纯 LF**),⛔ 零代码改动、⛔ 零服务器改动、⛔ 零 commit / push、⛔ 未改任何生产值。 + +**本棒实测三条证据**(§0.1 原文):`grep -rilE "\b(room|chat)\b" src | wc -l` = **0**(零应用层)|`grep -ril presence src` = **仅 `src/net/relay/placement.ts`**(第 21 行是注释)|`grep -n "req.url === '/status'" src/net/relay/server.ts` = **413** 且 **1234** 行原文写明「`/status` 是 Manager …」⇒ **现役在线态 = 拉快照(要替换的那条路)**。 + +**本棒的核心判断**:入口 §2 旧结论「presence 无从下手」**要收窄** —— 正确表述是「**应用层 presence 无从下手、节点 presence 正当时**」:序 ①–⑰ 已把它全部前置做完,且落地载体(`src/net/relay/**` 共 15 个模块)**今天就存在**。 + +**判据与边界**: +- 单内 **§4.2 真取舍 = 空**、**§4.3 待拍板 = 空**(三个曾考虑的项均有客观判据 ⇒ 自决拍掉);**§4.4 R5 评估 = 未命中**(新增监听口 / 凭据 / 入站均 **0**,可见面只收窄)。 +- ⛔ 未规划 **B(内容分发 / 块级内容寻址)** —— 仍待拍板,只登记一行(§0.4-1 / §3.3-1)。 +- ⛔ 未为生产值留任何改动口;**§9-9 把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写成硬禁令**(不是"回头条件")。 + +**登记**:下一棒 = **序 ⑲ 执行棒**,automation **`107b8e38-ccbd-4dcf-8b49-25dad7c89be9`**(名称「覆盖网络线-序19执行棒-presence节点在线态」,**一次性**,`scheduledAt` = **2026-09-17 17:28** = 收口 +4 min,`nextRunAt` = 1789637280000)。 + +**回填后指纹自检**:§11 位于 §8 之后 ⇒ **§8 前缀指纹 `f612858344077420cff5f1f9ef1c942f` 应保持不变**(口径 = `sed '/^## §8 回报格式/,$d' 交接单_presence在线态_20260917.md | md5sum`)。 + diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T16-一机一钥与信任根.md b/dsh-server-docs/交接单/archive/交接单-已完成/T16-一机一钥与信任根.md new file mode 100644 index 0000000..165e884 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T16-一机一钥与信任根.md @@ -0,0 +1,426 @@ +# 交接单 · 一机一钥 + 信任根(序 ③ / 清单 P0-4) + +> **类型**:执行棒(执行会话按本单开工,**不读规划会话的上下文**) +> **产出**:覆盖网络线序 ③ —— 把「共享密钥 + 无成员资格校验」换成「一机一钥 + 离线信任根」的身份基线 +> **上游**:`接续入口_覆盖网络线_20260916.md §0/§2`(唯一入口)|**定序**:③ → ④(443 兜底)→ ⑤(参数表·观测·权限评估) +> **前置已完工**:① 会合/中继拆分(S0–S4 + R5)|② 网抽象 + 地址规划 + 引导(P0-1/2/3,单子 `交接单_网抽象与地址规划R6_20260916.md`)⇒ ⛔ **不要重做 ①②** +> **本单不含**:443/TCP 兜底(序 ④)、参数表与观测(序 ⑤)、房间层/presence(第 7 步) + +--- + +## §1 目标 + +**一句话**:让「每台机器有自己的密钥、由离线信任根授权入网、可单台吊销且不牵动全网」这件事在 47(Manager)与 106(Worker)上真实跑通,并让 relay 的准入从「查共享 secret」变成「按 hostId 查成员资格」。 + +四条可验收的子目标: + +1. **四层密钥模型落地**(形状照抄 Tailnet Lock):根(离线)→ 签名者(在线多把,受根授权)→ 节点密钥(每机一把)→ 会话密钥(内存,定期 rekey)。 +2. **入网 = 签名**:节点公钥必须携带一个**受信任签名者**的签名,**各节点在本地校验**,验不过就不建立会话(⇒ 被攻破的控制面**无法插入攻击者节点**)。 +3. **relay 准入改造**:`keys` 表/文件从「单一共享凭据」改为**按 `hostId` 索引**,并补上 ② 单带回的待办② —— **「host 属哪张网」的成员资格校验**(缺这一条 = 任何持有任意密钥的 host 都能进同一扁平命名空间)。 +4. **吊销与恢复可演练**:撤销单台节点 ≠ 全网换密钥;根密钥有 **≥2 份离线副本**且**恢复演练跑过一次**。 + +--- + +## §2 只读前置(⛔ 只读,不改) + +**开工三件事(顺序不可颠倒)**: + +1. `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` —— 看锁 / git 基线 / 在途单。 +2. 抢锁:`bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<你的会话名>"`。**抢不到 = 有会话在跑 = 停手**(⛔ 不是"等一会儿再抢")。做完立即 `--release-exec`。 +3. 读本单 §3 范围与 §4 决策点,确认没有需要现问用户的事项(若真命中红线,按 §4 的升级口径一句话问,⛔ 不许捆包)。 + +**必读(按此顺序,读多少写多少,别全库摸底)**: + +| # | 来源 | 读什么 | +|---|---|---| +| 1 | `覆盖网络_问题逐条推演与解决方案_20260916.md` **§A4(约 73–95 行)** | 本单的**方案正文**:四层密钥模型表 + 恢复路径 + 结论(照抄 Tailnet Lock) | +| 2 | `覆盖网络_应用场景与待完善清单_20260916.md` **P0-4(第 80 行)+ §五 主线(93–103 行)** | 缺口定义(根保管/轮换、私钥丢失恢复、被盗吊销)+ 序 ③ 的前后依赖 | +| 3 | `交接单_网抽象与地址规划R6_20260916.md` 的 **「执行进度」段** | ② 收官口径 + **待办② 原文**(relay `keys` 按 hostId 索引、缺成员资格校验)⇒ 本单 §5-S3 的输入 | +| 4 | `交接单_relay落地R2-R4_20260916.md` **§9–§12** | R2–R5 证据 + **§12 的两条教训**(A1 假 404:**路由表未命中必须补齐再失败关闭,⛔ 绝不静默回退默认机**;A2 `state.py` 锁信号) | +| 5 | 代码仓 `src/net/relay/*`(`server / client / dialer / wire / duplex / main / index`)、`src/config.ts`、`src/db/*`(迁移版本号,当前到 **v8**) | 现状密钥校验点、白名单机制(`DSHS_RELAY_DIALERS`)、迁移写法 | +| 6 | 47 上 `/opt/dshs/relay-keys.json` 与两个 drop-in(均有 `.bak-r5*` 备份) | **现状密钥长什么样**(⛔ 只读;改前必须先备份成 `.bak-r6*`) | + +**环境要点(别现查)**: + +- 47 = Manager(控制面库 = 47 的 PG13 `127.0.0.1:15432`,单元 `dshs-pg`;**PG 只能 TCP**);106 = Worker `w-106`(`19000`);47 另跑本地 Worker `w-47`(`19100`)。 +- **布局不对称**:47 = `/opt/dshs/lib/`,106 = `/opt/dshs-cluster/lib/`;**部署 = 本机 build 后 scp**(⛔ 不在服务器上改码)。 +- `npm test` **必须 Node 22**(`E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe`)。 +- 47 的 bubblewrap = **0.4.0**(`--perms` 属 0.5+ ⇒ 用 `--tmpfs`);106 = 0.11.0。 +- 本机 shell:Python stdin/stdout 走 `buffer` 显式 UTF-8;写文件 `newline=""`;含 Windows 路径的 Python 落 `.py` 再跑。 + +--- + +## §3 范围 + +**做**: + +- 密钥层次与落点(根 / 签名者 / 节点 / 会话四层)。 +- 节点入网凭据的**签发**(签名者)与**本地校验**(各节点)。 +- relay 准入改造:`keys` 按 `hostId` 索引 + 成员资格(host ∈ 哪张网)校验。 +- **失败关闭**:校验不过 ⇒ 明确拒(`403` / 专用错误码),⛔ **不得静默回退到共享凭据或默认 host**(A1 教训)。 +- 吊销路径(撤单台)与轮换路径(节点重签),各跑一次实测。 +- 单测:新增用例**先红后绿**(照 A1 的做法),并挂进 `npm test`。 + +**不做(⛔ 命中即越界)**: + +- ⛔ 不自动升级 dsh(R1);⛔ 不改官方 dsh 主程序与缓存(R2)。 +- ⛔ 不做序 ④(443/TCP 兜底)、序 ⑤(参数表/观测/权限评估)—— 各自的"信号出现"条件见 §3 末尾。 +- ⛔ 不做房间层 / presence(第 7 步)。 +- ⛔ 不新开任何公网端口:**47 公网暴露面净增必须 = 0**。 +- ⛔ 不重做 ② 的任何一步(P0-1/2/3);⛔ 不重做 R0–R5;⛔ 不碰 `32022`/`32023`。 +- ⛔ 不引入第三方 relay / 第三方 CA / 依赖型身份服务(自研路线已定)。 +- ⛔ 不 commit、不 push(未获授权)。 +- ⛔ **不做「网络变化 ⇒ 重新跑选点、换到更优中继」** —— 判据与代码**已存在**(`src/net/relay/placement.ts`:速度+负载打分 / 满载=唯一硬门 / 失败降权 / 手动不被静默改选,**T15 已验收**),但**未接线**且**当前只有 1 台中继**(方案 §13.5「未做:Manager/门户侧接 `chooseNode()`;relay 集群的多中继选主」)⇒ 归属 **序 ⑥(3–5 台最小形态)之后**。本阶段「网络变化」的既有处置 = **取消剩余退避、立即重拨原地址**(`networkChanges`,**T12 已验收**)。 +- ⛔ **不做「应用级(presence / 房间 / 实例会话)断开重连后的状态恢复」** —— 归 **第 7 步**(仓库现**无应用层代码**)。本单只需保证**身份层不成为它的阻塞项**:节点密钥是"换点 / 重连"时唯一需要重新证明的东西。 + +> ✅ **不属于本单、但也不是缺口**(回答「网络变化 / 断开重连怎么办」类提问时**直接用这段**,⛔ 不要再现查): +> **网络变化 ⇒ 立即重拨原地址**、**断开重连 ⇒ 连接 / 注册 / 路由三级恢复**(**流级恢复有意不做**,交上层幂等重试)—— **均已实现并单测 + 47 真机验收**,证据 = 方案 **§12.2(五类场景×处置×恢复时间)/ §12.3(四级恢复语义)/ §12.4(T8–T15 + 真机输出)**。 +> ⇒ ⛔ **不要在本单里重做**;⛔ **也不要"顺手"给流级加重放日志** —— §12.3 ④ 明确判「不做」:*假装能做 = 制造"看起来恢复了其实数据烂了"*。 +> ⚠️ 唯一**未覆盖**的一层是**应用级状态**(见上一条),而那是第 7 步的事,不是本单的债。 + +**越界信号(出现即停手,写进 §8)**: + +- 需要**扩大权限面**(新挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽 nft / 提档位)⇒ **R5**,先出「权限影响评估」再问一句话。 +- 需要**外部玩家入网**(P0-5 缺口 B)⇒ 那是独立一条,⛔ 不在本单内顺手做。 +- 需要 **VPN/代理类节点降级**、**443 兜底** ⇒ 记入 §8 的"交给序 ④",不在此实现。 + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍,执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +| # | 决定 | 理由 | +|---|---|---| +| **D1** | **凭证形状 = 节点公钥 + 签名者签名信封**(自签、无 x509、无第三方 CA) | 照抄 Tailnet Lock 的形状(§A4);x509 会引入 CA 生态与吊销列表搬运成本,而我们只需要"授权入网"一个语义 | +| **D2** | **校验位置 = 节点本地**(relay 侧只做**辅助**准入,不替代本地校验) | §A4 的核心:控制面被攻破仍插不进节点。若把校验只放在 relay,等于把根信任交给控制面 | +| **D3** | **根密钥离线**,在线签名者拿**根授权**;签名者可多把(≥1 把在 47) | §A4 四层模型;根在线 = 单点被攻破即全网伪造 | +| **D4** | **relay `keys` 改造与成员资格校验并入本单** | 入口 §0 已定序理由:② 待办② 与 ③ 天然衔接,拆成两棒会让"hostId 索引"和"签名校验"各改一遍同一处代码 | +| **D5** | **失败关闭**:任一校验未命中 ⇒ 显式拒绝 + 专用错误码,**零请求发往默认机** | A1 教训(R2-R4 单 §12):静默回退 = 假 404 之源 | +| **D6** | 迁移走**新版本号 v9**(当前 v8),⛔ 不改历史迁移 | 项目既有迁移纪律 | + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +> 判据:这些都有客观可判的优劣 ⇒ 自己排完序、选第一名、做掉,并在 §8 里写一句「我选了什么(可推翻)」。 + +- **密钥文件介质**:`0600` 文件 / 系统密钥库 / 环境变量 —— 按"可备份性 × 权限最小 × 与现有 drop-in 机制一致"排序自选。 +- **节点密钥算法**:Ed25519(默认)vs 其他 —— 无特殊理由就用 Ed25519。 +- **relay 侧 `keys` 的存储形态**:`relay-keys.json` 就地升级 vs 新增一张表 —— 以"能原子替换 + 可回滚"为先。 +- **新增错误码 / 表结构 / 文件命名的具体取值**。 +- **重试与超时参数**(在 §6 验收约束内自选)。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +1. **根密钥的离线保管人/介质**(纸质恢复码放哪、离线副本存哪台设备)—— 这涉及**用户手头的物理资产**,不是技术选型可替代。 + - 若用户此前未指定:**执行棒按"本机 + 其一离线副本"落默认并继续**,在 §8 里写清"现落点 = X,可推翻"。 + - **根密钥的用途边界(⛔ 别搞错)**:它**只用于授权 / 撤销「签名者」** —— **不签发节点、不加密数据、不参与会话**。⇒ "根在线"**不带来任何性能问题**,唯一影响是 **安全**(泄露 ⇒ 可自行授权签名者 ⇒ 可插入任意节点,节点本地校验也拦不住,因为签名合法)与 **恢复**(丢失 ⇒ 极端情况下"全网重置")。 + - **落点候选与判据(开发期用 C,收敛目标是 D;⛔ E 直接排除)**: + · **A 纸质恢复码**(打印/手抄种子,实体保管)—— 优:真离线、零电子攻击面、不受系统重装 / 云账号被锁 / 设备报废影响,成本≈0。劣:怕水火与搬家丢失、平台无法代管、**被拍照即永久泄露**(不像密钥文件还能改权限)。 + · **B 离线设备上的加密 keystore**(U 盘 / 旧手机 / 离线笔记本 + 口令)—— 优:可机器读取、便于早期频繁签发、可存多份并校验完整性。劣:U 盘会坏 / 接口会淘汰、**一旦插上联网机就不再"离线"**、弱口令=等同于 A 泄露但更难发现。 + · **C 本工作区开发机的 `0600` 文件**(如 `~/.dshs/root.key`)—— 优:零额外成本、可直接脚本化、恢复演练最省事,开发期推进最快。劣:**本机不是沙箱且日常联网**(浏览器 / 编辑器 / agent 同机)⇒ 泄露面大;⚠️ 它与"签名者"的天然落点**同机** ⇒ 四层塌成两层,**Tailnet Lock 的核心收益(控制面/签名者被攻破仍插不进节点)直接归零**。⇒ 仅作**开发期**落点,必须标注"非最终"。 + · **D 两份异介质、异地副本**(本机加密文件 + 纸质码 / 离线 U 盘)—— 优:满足 §A4「**≥2 份离线副本**」硬要求、单份损毁不致命、恢复演练可只动一份。劣:需维护两份一致(轮换时漏更新一份=埋雷)、保管成本翻倍;⚠️ 两份放在**同屋 / 同云账号**等于只有一份。 + · **E 平台服务器(47)** —— **不建议**:直接违反"根离线"这条设计本身;47 公网可达且已开 443 入站 ⇒ 攻击面最大;被攻破即**同时**拿到根 + 签名者 ⇒ 攻击者可**另造一张网**;且在 R5 上属"权限面 / 暴露面"变更,要单独出评估。 +2. **是否允许为校验链路新增一个常驻进程/单元** —— 只有在其**扩大公网暴露面或占用新端口**时才需问(否则命中 D1/D2 已定项,自决)。 + +### 4.4 技术实现裁决顺序(照 `dsh-decision-method §4.4`) + +选型/写法冲突时依次看:**① 权限最小**(R5 精神)→ **② 失败关闭**(D5)→ **③ 可回滚** → **④ 与现有一致**(不新造第二套机制)→ **⑤ 性能**。 + +--- + +## §5 步骤(S0–S5,每步都要有可复跑的验收) + +> 纪律:**先单点验证再推广**;每步做完**立即写证据**(命令原文级),别攒到最后;**改任何文件前先确认已在 §2 第 2 步抢到的锁内**。 +> 备份纪律:47 上改动前,先把被改文件备份为 `*.bak-r6-<实际时间>`;回滚见 §7。 + +### S0 · 只读取证(⛔ 不改任何东西) + +- 摸清**现状密钥校验点**:`src/net/relay/*` 里"谁能连上 relay"是怎么判的、`DSHS_RELAY_DIALERS` 白名单在哪判、`relay-keys.json` 的实际结构。 +- 摸清**现状配置面**:`src/config.ts` 里与 relay 身份相关的项;47/106 两个 drop-in 的 env 差异。 +- 摸清**DB 迁移写法**(`src/db/*`,看 v8 是怎么写的)。 +- **出口判据**:能用一段话画出"现在的身份链路"(谁拿什么凭据、在哪判、判不过会怎样),且**指出至少一处"判不过会静默通过/静默回退"**(若无,说明取证不足)。 + +### S1 · 四层密钥模型落地(本机) + +- 定义并实现四层的**载体**(根 / 签名者 / 节点 / 会话)—— 会话层若已有隧道 rekey 机制则复用,⛔ 不新造。 +- 节点密钥**每机一把**,落点与权限(0600、属主正确)。 +- **出口判据**:`npm test` 绿;能列出两台机器各自的节点公钥指纹。 + +### S2 · 签发与本地校验 + +- 签名者签发节点入网凭据;节点**本地校验**通过才建会话。 +- **先红后绿**:先写"签名不对 ⇒ 拒绝"的用例(**必须观察到红**),再实现。用例入 `test/`。 +- **出口判据**:① 单测含"篡改签名 ⇒ 拒绝"且**先红后绿**有记录;② 手工构造一个"未授权节点"⇒ **建不起会话**,且**日志里有明确拒绝原因**。 + +### S3 · relay 准入改造(`hostId` 索引 + 成员资格) + +- `keys` 从共享凭据改为**按 `hostId` 索引**;补 **host 属哪张网** 的成员资格校验(输入 = ② 单待办②)。 +- **失败关闭**:未命中 ⇒ 拒 + 专用错误码,**零请求发往默认机**。 +- **出口判据**:① 合法 host 正常连通(47 本地 + 106 跨机各一例);② 「拿 A 网的凭据进 B 网」被拒;③ 「伪造/未登记 hostId」被拒;④ 三种拒绝路径**均有可观测日志**。 + +### S4 · 吊销与恢复演练 + +- **吊销单台**:撤销 w-106 的节点签名 ⇒ w-106 被拒;47 与 w-47 **不受影响**(这就是"撤销单台 ≠ 全网换密钥")。 +- **轮换/重签**:w-106 重新签发后**恢复连通**。 +- **根密钥恢复演练**:用**离线副本**重建信任根,走一次完整流程并记录耗时与踩坑。 +- **出口判据**:三条演练**全部有命令原文级输出**;恢复演练结论可为"通过"或"未通过(写清卡在哪)",⛔ 不得只写"应该可以"。 + +### S5 · 端到端复验 + 不退化检查 + +- 复验**用户可见面**:`admin`(w-47) 与 `guest`(w-106) 两个实例页 **200**;跨机文件面落盘仍 OK。 +- **不退化判据**:47 公网暴露面**净增 = 0**;`w-106` 仍 `via='relay'`;relay 常驻未掉。 +- 对账:本机 `lib/` vs 47 `/opt/dshs/lib/` **0 差异**(照 R5 的 29/29 对账做法)。 +- **出口判据**:§6 全部判据逐条打勾,未过的条目**必须写明卡在哪 + 什么条件一出现必须回头解决**(⛔ 不许用"暂时接受"糊过去)。 + +--- + +## §6 验收(判据清单) + +| # | 判据 | 怎么测 | 过 / 不过 | +|---|---|---|---| +| 1 | 每台机器有独立节点密钥,指纹互不相同 | 打印两机公钥指纹比对 | ☐ | +| 2 | 篡改签名的入网凭据**建不起会话** | 单测(**先红后绿**)+ 手工构造 | ☐ | +| 3 | **未授权节点**被拒,且**日志有明确原因** | 手工构造未授权节点连接 | ☐ | +| 4 | relay 准入**按 hostId**,且校验**成员资格** | 三例:合法 / 跨网 / 未登记 | ☐ | +| 5 | **失败关闭**:任何未命中 ⇒ 显式拒绝,**零请求发默认机** | 判别器 = relay 有无 `DIAL`;辅以 `not_found=0` | ☐ | +| 6 | **撤销单台不牵动全网**:撤 w-106 ⇒ 106 拒、47 与 w-47 正常 | S4 吊销演练输出 | ☐ | +| 7 | 重签后**恢复连通** | S4 轮换演练输出 | ☐ | +| 8 | 根密钥 **≥2 份离线副本** + **恢复演练已跑** | 副本清点 + 演练记录 | ☐ | +| 9 | 密钥文件权限 `0600`、属主正确 | `stat` 输出 | ☐ | +| 10 | **不退化**:两实例页 200、跨机文件面 OK、`w-106 via='relay'` | 端到端复验 | ☐ | +| 11 | **47 公网暴露面净增 = 0** | 端口清点前后对比 | ☐ | +| 12 | `npm test` 全绿(含新增用例) | Node 22 跑 | ☐ | +| 13 | 本机 `lib/` vs 47 `/opt/dshs/lib/` **0 差异** | 逐文件 hash 对账 | ☐ | + +**判别器(复用,⛔ 别自创)**:静默失效一律用**判别器**定位 —— ① relay 有没有 `DIAL`(没有 = 请求根本没出去);② 临时监听目标口把请求接住;③ 端点翻译只认 `dsh_hosts.via` 原文(⛔ 不是 `reachability.via`)。 + +--- + +## §7 回滚 + +**粒度:能单步回滚的单步回滚**,⛔ 不整包推倒。 + +| 层级 | 动作 | 判据 | +|---|---|---| +| **配置层** | 恢复 `*.bak-r6-*` → `systemctl daemon-reload` → `systemctl restart dshs` | 两实例页回落 200 | +| **relay 侧** | 恢复 `relay-keys.json` 旧表(改前必备份)→ 重启 relay 单元 | 47 本地 + 106 跨机连通恢复 | +| **DB 迁移层** | v9 回滚 = 恢复表结构 + 重启(迁移脚本必须自带"回滚段"或至少一份导出) | `dsh_hosts.via` 等字段仍可读 | +| **代码层** | 回到改前 commit(工作区当前有未提交改动 ⇒ **先 `git stash` 或导出一份 diff 存档**,⛔ 不许 `checkout --` 抹掉别人的改动) | `npm test` 绿 + 端到端复验 | +| **全量兜底** | cluster 回滚 = 删 drop-in `dshs.service.d/cluster.conf` → `daemon-reload` → `restart dshs`(⚠️ cluster 配置在**这个 drop-in 里**,⛔ 不在 `/etc/dshs.env`) | 退回单机 Manager 形态 | + +**⛔ 回滚禁区**:不许用 `rm -rf` / 通配符清目录;不许 `git add -A`;不许对 `INDEX.md` 跑 `git checkout --`。 + +--- + +## §8 回报格式(执行棒按此格式收口) + +``` +【序 ③ 一机一钥 + 信任根 · 执行回报(YYYY-MM-DD HH:MM)】 + +一句话判定:<做到哪一步 / 全绿 / 卡在哪> + +改了什么(文件级,含行号): + · <本机:路径:行 —— 一句话> + · <47 / 106:drop-in / 文件 —— 一句话> + · 备份:<*.bak-r6-* 落点> + +验收证据(命令原文级,逐条对 §6 编号): + · [1] <命令 ⇒ 关键输出> + · [2] ... + · [未过] [N] 卡在哪 = <证据>;已做到 = <到哪>;什么条件一出现必须回头解决 = <条件> + +我选了什么(可推翻): + · <§4.2 的每一项一行> + +未做 / 越界记录: + · <交序 ④ 的:443 兜底 | 交序 ⑤ 的:参数表/观测/权限评估 | 命中 R5 待评估的:…> + +下一棒建议:<一句话 + 该读哪份文档> + +指纹:md5(<本单路径>) = <hash>;54/106 产物 hash 对账 = <n/n> +``` + +**收口动作(缺一即视为没做完)**: + +1. 释放锁:`--release-exec`(⛔ 带锁结束 = 把所有人挡在门外)。 +2. **登记下一棒的一次性 automation**(`automation_update`,mode=create,scheduleType=once,约 +2 分钟),prompt 只写「跑 `state.py`,按 **覆盖网络线** §2「本轮动作」块开工 + 本轮只做一件事做完即停」——⛔ **不许把任务细节抄进 prompt**。 +3. 在给用户的回复里**用陈述句**告知:已登记自动接续、约 N 分钟后自动开新会话、接续点 = X。 +4. 产出物调 `present_files` 交付。 +5. ⛔ 不 commit、不 push。 + +--- + +## 附:本单相关的硬约束(复述,防走偏) + +- **R5** 权限只准收窄;**R7** 只做被明确要求的事,额外发现的问题**先报告后动手**;**R10** 实例起不来先查属主/EACCES 再怀疑 OOM。 +- **hook 脚本**必须走 `sys.stdout.buffer.write(bytes)`(文本层 `write` + 非 UTF-8 文案 ⇒ 静默放行)。 +- **两条命令坑**:`pkill -f` 匹配实际 argv;`fetch` 静默丢 `Host` 头(多租户验证必须 `curl -H "Host: …"`)。 +- **两条取数坑**:本机 `md5sum` 输出 `hash *path`、远端 `hash path`(先归一化再 diff);`ssh '…'` 单引号里不能出现单引号(用 `cat > f <<"EOF"` 落文件)。 +- **本机 `refs/remotes/**` 写不进** ⇒ 分叉判定必须 `git ls-remote origin refs/heads/master` 取裸 sha(本单不涉及 push,仅备查)。 + +--- + +## §8 执行回报(执行棒 · 2026-09-17 08:32)—— **已回填** + +``` +【序 ③ 一机一钥 + 信任根 · 执行回报(2026-09-17 08:32)】 + +一句话判定:S0–S5 全走完。判据 [1]–[9] / [11] / [12] / [13] **全过**;判据 [10] **部分未过** + —— guest(w-106) 实例页 502,真因在**平台的远端实例凭据落盘**(`landModels → + writeHomeFile` 用**本地路径**写 w-106 的 home),**与序 ③ 无关**(覆盖网络侧已用 + 「经 relay 落点直连 w-106:21000」证明通路完好,见 [10])。 + +改了什么(文件级,含行号): + · 本机(代码仓 D:\github\dsh_shenxian)—— + - `src/net/relay/identity.ts`(**新建** 657 行):四层密钥模型(根→签名者→节点→会话)+ + 签发/验签/吊销/本地校验的**纯函数与 IO 分区**;判据全在本模块(relay 只做辅助准入)。 + - `src/net/relay/keys.ts`(**重写**):`RelayKeyEntry{network,secret}` + `RelayKeyMap`; + 键从 `hostId` 改**逻辑名 `<net>/<hostId>`**;新增 `normalizeKeyRecord` / `isQualifiedName` + / `lookupKey` / `describeKeyEntry` / `keyEntryOf`;`parseKeysInline` 切点 = **最后一个 `:`**。 + - `src/net/relay/server.ts`(4 处):握手改 `lookupKey(keys, network, hostId)` + + `entry.network !== network ⇒ deny('network-mismatch')`;MAC 之后新增身份校验块 + (`identity-no-trusted-signers` / `identity-incomplete` / `identity-${reason}`); + `authed()` 计数新增 `identityOk` / `identityRequired` / `trustedSigners` / `revokedHosts`。 + - `src/net/relay/client.ts`:新增 `identity?` 字段与 `identityFields(challenge)` + (**MAC 输入串一字未改**,只追加字段)。 + - `src/net/relay/main.ts`:`loadIdentityForServer()`(强制 + 无签名者 ⇒ **起动即抛**) + + client 侧装配 `loadClientIdentity`。 + - `src/net/relay/index.ts`(barrel 同步);`src/config.ts:513-531` 新增 + `overlayNetworkId` / `overlayDirTrustedKeys` / `overlayNodeKeyFile` / `overlayNodeGrantFile`。 + - `src/worker/relay-tunnel.ts`(+`identity` 选项)/ `src/worker/agent.ts:188`(接线)/ + `src/web/server.ts`(Manager 拨号通道接线)。 + - `scripts/overlay-keyring.cjs`(**新建** CLI):init-root / init-signer / init-node / + sign-signerset / issue-grant / sign-revocations / verify-grant / recover-root; + 本次补 `sign-signerset --issued-at` 与 `recover-root` 的**判据②③**(原先 docstring 写了 + 三条判据、实现只有①,属文档与实现不一致,已补齐)。私钥一律 `writeSecret()` → 0600, + stdout 只打印公钥/指纹。 + - `test/overlay-identity.test.mjs`(**新建** 25 例:A1–A4 签名者集合 / B1–B12 凭据校验 / + C1–C3 keys 带网 / D1–D7 端到端含 D5 **先红后绿** / E1 文件面); + 既有测试按新语义更新:`test/relay.test.mjs`(T2)、`test/overlay-auth.test.mjs`(A6)、 + `test/overlay-network.test.mjs`(U3/U4/U5);`package.json` test 脚本挂上 identity 用例。 + · 47(Manager + relay)—— + - 代码:`/opt/dsh-relay/lib/**`(备份 `lib.bak-r6-20260917-0815`)、 + `/opt/dshs/lib/**`(备份 `lib.bak-r6-20260917-0820`);relay 对账 26/26、dshs 对账 148/148 全同。 + - 密钥:`/etc/dshs/overlay-signer-key.pem`(600)+ `.pub`|`node-manager.key`(600)、 + `node-w47.key`(600)|`node-manager.grant.json` / `node-w-47.grant.json` / + `node-w-106.grant.json`|`overlay-signers.json`(根签)|`revocations.json`(签名者签,当前空)。 + - 单元:`dshs.service.d/identity.conf`(**新**,Manager 带凭据); + `dshs-relay.service.d/identity.conf`(**改**,备份 `.bak-r6-20260917-0830`;现为 + ROOT_PUBKEYS + SIGNER_SET_FILE + REVOCATIONS_FILE + **REQUIRE_IDENTITY=1**)。 + - `/etc/dshs-worker.env` 追加 2 行(`DSHS_OVERLAY_NODE_KEY_FILE` / `..._GRANT_FILE`)。 + · 106(Worker)—— + - 代码:`/opt/dshs-cluster/lib/**`(备份 `lib.bak-r6-20260917-0820`)。 + - 密钥:`/etc/dshs/node.key`(600,**指纹 9a0189a4fb4e52f9**)→ 轮换后 `node.rot.key` + (600,**指纹 8cc227320405b986**);`node.grant.json` / `node-w-106.rot.grant.json`; + `overlay-signers.json`。 + - `/etc/dshs-worker.env` 改 2 行(备份 `/etc/dshs-worker.env.bak-r6-20260917-0825`)。 + · 本机(离线根):`E:\ProgramData\.dshs\{root.key(0600), root.pub, root.recovery-code.txt(0600)}` + · 备份:`lib.bak-r6-20260917-0815`(relay)/ `lib.bak-r6-20260917-0820`(47 dshs + 106 cluster)/ + `identity.conf.bak-r6-20260917-0830`(relay drop-in)/ `dshs-worker.env.bak-r6-20260917-0825`(106) + +指纹(一次性记全,后续对账用): + · 离线根 pub `bd6d1219571aa70904b227d3e5e86c3d0c08125312d1b5f9e31da4d71b914dd3` / fp `3f6523302720c531` + · 在线签名者 pub `bad464dfd53048efe7b8531029b3030eda49bc12930703d3a2a8f60e3a7daddf` / fp `ca6e5a1e329c22b5` + · 节点 manager fp `b60215af4c12f835`|w-47 fp `83269876c41644c0`|w-106 fp `9a0189a4fb4e52f9` → 轮换 fp `8cc227320405b986` + +验收证据(命令原文级,逐条对 §6 编号): + · [1] 每机独立节点密钥、指纹互不相同 ⇒ 三份 grant 的 nodeKey 互不相同: + manager `2e1c…`(fp b60215af4c12f835) / w-47(fp 83269876c41644c0) / w-106(fp 9a0189a4fb4e52f9); + 轮换实验再证「一机一钥可换」:106 `init-node --key /etc/dshs/node.rot.key` ⇒ + `✓ 节点公钥:5e65572611ad6a37c487136086d7852e6fb29c7f530b3a37161eded57f31bf6a` / fp `8cc227320405b986`。 + · [2] 篡改签名的入网凭据建不起会话**(先红后绿)** ⇒ `test/overlay-identity.test.mjs` B 组 12 例 + (签名篡改 → `bad-signature`;凭据搬 hostId → `host-mismatch`;换密钥 → `key-mismatch`; + 过期 → `expired`)+ D5「门关时同一份坏凭据能注册(红)→ 门开时被拒(绿)」。 + · [3] 未授权节点被拒且**日志有明确原因** ⇒ 真机两条独立证据: + relay 侧 `[relay] AUTH DENY remote=127.0.0.1:33802 why=identity-revoked-host retryable=false`(×14); + 106 侧 `[relay-client] HELLO rejected reason=identity-revoked-host retryable=false clockSkew≈189ms`。 + · [4] relay 准入按 hostId + 校验成员资格 ⇒ `keys.ts` 键改**逻辑名**后: + 合法(`AUTH OK host=ops/manager` / `host=ops/w-106`);跨网(`network-mismatch`,且 + `overlay-auth` A6 / `overlay-network` U3/U4/U5 四处既有测试按**新语义**更新并通过); + 未登记 hostId(`keys` 层即拒)。三种路径均有 `AUTH DENY why=…` 日志。 + · [5] 失败关闭、零请求发默认机 ⇒ 吊销态下 guest 请求: + dshs 侧 `[relay-dialer] 拨 ops/w-106:19000 失败:relay client: dial w-106:19000 refused: target-offline`; + relay `refused` 计数 0→4;**未见任何回退默认机**(A1 的 `ensureHost` 失败关闭仍在位)。 + · [6] 撤销单台不牵动全网 ⇒ 吊销 w-106 后: + `counters = {"authed":1,"authFailed":14,"refused":4,"identityOk":1,"identityRequired":true,"trustedSigners":1,"revokedHosts":1}`; + `online = ["manager"]`(**manager 会话 upForMs=180681 全程未断**); + `[恢复态] admin 实例页(w-47)HTTP 200 time=0.050s`;`dsh_hosts`:`w-47|local|ops|up`。 + · [7] 重签后恢复连通 ⇒ 重签**空**吊销清单 + `restart dshs-relay`: + `[identity] 吊销清单:net=ops hosts=[] nodeKeys=0` → `[relay] AUTH OK host=ops/w-106 session=63607538827ee044 ports=[19000]`; + `counters`:`identityOk=3 / identityRequired=true / revokedHosts=0`;`online=["manager","w-106"]`。 + 🔑 附一条**更强的性质**:先在 106 上**轮换节点密钥**(换新 grant)后**仍被拒** + (`HELLO rejected reason=identity-revoked-host`)⇒ 证明「**撤 hostId 比换密钥更强**」。 + · [8] 根密钥 ≥2 份离线副本 + 恢复演练 ⇒ **演练三判据全绿,耗时 794 ms**: + `✓ [判据①] 与期望根公钥逐字节一致`; + `✓ [判据②] 重建的根签出的 SignerSet(1 把)经原根公钥验签通过`; + `✓ [判据③] 签名与原根私钥签出的逐字节相同`(Ed25519 确定性 ⇒ 排除"看着像其实不是同一把")。 + ⚠️ **副本数 = 2 份「离线保管载体」但都在本机**:`root.key`(0600) + `root.recovery-code.txt`(0600,96 hex); + 真离线介质(U 盘 / 纸)需你亲落 ⇒ 本条判 **部分过**。 + (演练从**恢复码**重建,**不使用**原根文件;原根仅用于产出待比对的基准签名。演练产物含一份根私钥副本,已即时删除。) + · [9] 密钥文件权限 0600、属主正确 ⇒ 47 `600 root:root`:`node-manager.key` / `node-w47.key` / + `overlay-signer-key.pem` / `overlay-dir-key.pem` / `relay-keys.json`;106 `600 root:root`: + `node.key` / `node.rot.key` / `relay-keys.json`。(`.pub` 与**已签名清单**为 644,不含秘密。) + · [10] 不退化 ⇒ **三条过、一条未过**: + ✅ admin(w-47) 实例页 `HTTP 200 time=0.050s`; + ✅ `dsh_hosts`:`w-106|relay|ops|up`(`via='relay'` 保持); + ✅ relay 常驻未掉(`systemctl is-active dshs-relay` = active,`identityRequired=true`); + ✅ **覆盖网络侧端到端活证**:经 relay 本机落点 `curl -H "Host: guest.alotbuy.com" http://127.0.0.1:34539/` + ⇒ `HTTP 401 68B`,与 **106 本机直连 `http://127.0.0.1:21000/` 的 `401 68B` 完全一致** + (relay `endpoints` 同时给出 w-106 的 `19000` 与 `21000`,`streams>0`、`in/out` 有流量); + ⛔ **未过** guest(w-106) 实例页 = **nginx 502(0.70 s,确定性复现,非超时)**。 + · [11] 47 公网暴露面净增 = 0 ⇒ 公网监听集合 = + `0.0.0.0:22 / 0.0.0.0:80 / 0.0.0.0:443 / 0.0.0.0:58888(BT-Panel) / 0.0.0.0:888 / 0.0.0.0:8765 / [::]:22` + —— **全是原有的**;序 ③ 新增的只有**回环**口:`127.0.0.1:20080`(relay)与 `127.0.0.1:25000+`(拨号落点池)。 + · [12] npm test 全绿 ⇒ Node **v22.22.2**:`# tests 130 / # pass 129 / # fail 0 / # cancelled 0 / # skipped 1` + (31914 ms),随后 `verify-inject` 六项全 `✓`、`结论:全部合格 ✅`。 + · [13] 本机 lib vs 47 /opt/dshs/lib **0 差异** ⇒ `local=148 remote=148`,路径不对称 0,内容不同 0; + 另:relay 侧 `local=26 remote=26`,0 差异; + `scripts/overlay-keyring.cjs` md5 `0727cd436c3b923e48d0c8dd654d2ac4` **三处一致**(本机 / 47 / 106)。 + · [未过] [10] 卡在哪 = ① `dsh_instances` 里 guest 记录 `status=stopped`(真机上实例却在跑), + ⇒ 平台走 `RemoteSpawner.launch`;② 该路径上 `landModels → home-files.js:45 writeHomeFile` + 打开 **`/var/lib/dshs/users/4092b965-…/home/.credentials.yaml`** 时 `ENOENT` + —— 这是**47 本地路径**,而 guest 的 home 自 09-16 22:51 起已在 **106**(47 上该目录只剩 `ws/`); + 日志原文:`model landing failed, falling back to env injection Error: ENOENT: no such file or directory, open '…/home/.credentials.yaml'`, + 栈:`writeHomeFile(home-files.js:45) → landModels(server.js:172) → resolveApiKey(server.js:200) → + RemoteSpawner.launch(remote-spawner.js:169)`;③ 该请求在 dshs 侧 `incoming request` 之后**无 + `request completed`**,由 nginx 返 502。 + **为什么判定与序 ③ 无关**:失败点在「凭据写 home 的本地路径」,与 relay 身份准入无交集; + 且同一时刻的覆盖网络证据全绿 —— relay 已建立到 w-106 的 `19000`/`21000` 两条隧道、 + `dial up` 成功、经隧道取实例回 401(活)。**序 ③ 只动了 identity / keys 逻辑名 / relay 准入 / 配置, + 未触碰 `landModels`、`home-files`、`remote-spawner`、`dsh_instances` 状态机。** + 已做到 = 网络面与身份面全部证明完好、admin 面 200、平台与实例两侧均无崩溃(`dshs` NRestarts=0、 + `dshs-worker` NRestarts=0)。 + 什么条件一出现必须回头解决 = **① `landModels/writeHomeFile` 改为经 `RemoteUserFs` 写远端实例 home + (或平台对「实例已在跑但 DB 记 stopped」做状态对账);② 或 guest 实例状态回到 `running` 后, + 立即重测判据 [10] 的 guest 页**。⇒ 建议并入序 ④ 的相邻缺陷清单,**不在本单范围内顺手改**(R7)。 + +我选了什么(可推翻): + · 密钥表键 = **逻辑名 `<net>/<hostId>`**(不是裸 hostId)—— 因 U5 暴露"同 hostId 无法存在于两张网"是净变差。 + · 受信根 = `DSHS_OVERLAY_ROOT_PUBKEYS` **独立于** `DSHS_OVERLAY_DIR_PUBKEYS`(⛔ 不合并:合并等于让"能换地址的人"顺带能加签名者)。 + · 复用 `directory.ts` 的 Ed25519 原语,**不新造第二套签名机制**(符合 D1/D6)。 + · 强制身份**本轮就开**(`REQUIRE_IDENTITY=1`)而非留到下一轮:三台节点均已带身份且实测 `identityOk=3`, + 吊销演练**只有开强制才可观察**;回滚 = 删该两行 + `daemon-reload` + `restart dshs-relay`(单步)。 + · 106 节点密钥**就地轮换**(`node.rot.key` 取代 `node.key`,env 指向新文件)—— 顺带证明"一机一钥可换"。 + · 密钥仪式工具**部署到 47/106**(`/opt/dsh-relay/scripts/`、`/opt/dshs-cluster/scripts/`), + 因为**签名者私钥不出机器** ⇒ 签发/吊销必须在 47 上跑。 + · 报告与单子同文件(§8 就地回填),不另开文件。 + +未做 / 越界记录: + · 交 **序 ④** 的:443/TCP 兜底(本单未碰)。 + · 交 **序 ⑤** 的:参数表 / 观测 / 权限评估(本单未碰)。 + · **命中 R5 待评估的:无** —— 本次所有改动都是**收窄**(relay 从"任何持任意密钥者都能声称任意合法网名"改为 + "凭据 + 成员资格 + 吊销"三级;公网暴露面净增 0)。 + · 额外发现、**只报告未动手**(R7): + ① [10] 的平台侧远端凭据落盘缺陷(见上); + ② `/opt/dshs/mksess*.cjs` 仍写 **`/var/lib/dshs/dshs.db`(SQLite,仅回滚用)**, + 而权威库已是 47 的 PG13 ⇒ **该工具已失效**(本次改用 PG 直插临时 session 完成 R4 合规验收,用完即删,残留 0)。 + · 本单**未 commit / 未 push**(按约束 4)。 + +下一棒建议:**序 ④ · 443/TCP 兜底** —— 先出**规划棒**(给序 ④ 出一份可执行交接单), + 读 `D:\github\dsh_shenxian\dsh-server-docs\` 里 `覆盖网络_应用场景与待完善清单 §五` + + 工作区根 `接续入口_覆盖网络线_20260916.md §0/§2`;⚠️ 把上面 [10] 的两条额外发现并入其相邻缺陷清单。 + +指纹:md5(本单) = 260a2e84a8a482cf08705885735986ee(回填前);本机/47/106 产物 hash 对账 = 148/148 + 26/26 + 3/3 全同 +``` + +> 📌 **回填说明**:上面代码块是**本节交付物本身**(§8 要求的报告),未改动 §1–§7 任何一行。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T17-参数表与观测.md b/dsh-server-docs/交接单/archive/交接单-已完成/T17-参数表与观测.md new file mode 100644 index 0000000..29b709e --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T17-参数表与观测.md @@ -0,0 +1,408 @@ +# 交接单 · 参数表 · 观测 · 权限评估(覆盖网络线 · 序 ⑤) + +> **线**:覆盖网络线 | **序**:⑤(定序 ③→④→⑤,见入口 §0) +> **性质**:**规划棒**产出(本单只出单,⛔ 未动任何服务器、未改任何代码) +> **产出时间**:2026-09-17 09:18 | **规划会话**:覆盖网络线-序5规划棒 +> **唯一来源**:`覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 5 项**(= 「参数表 + 观测最小集 + 权限评估(P1 三条)」,状态 **可并行**)+ 同文件 **§四 P1 行**(三条缺口的原文)+ `交接单_443兜底_20260917.md` **§8.7 / §8.8** +> **上一轮**:序 ④ 已收官(`交接单_443兜底_20260917.md` §8)⇒ ⛔ 不要重做 ② / ③ / ④ +> **执行棒**:按本单 §5 开工,**不读规划会话上下文**;收口必须回填 §8。 + +--- + +## §1 目标 + +**把「参数 / 观测 / 权限」三件事收成可交付的三件产物**,使覆盖网络的容量结论从"结构可信"变为"**可复算**"、运行状态从"靠人读日志"变为"**一条命令出 PASS/FAIL**"、权限面从"没评估过"变为"**逐项有结论**": + +1. **参数表固化** —— 新建**唯一一张**参数表,把散在 6 份文档里的输入参数(设备占比 / 打洞率 / 每玩家带宽 / 消息频率 / 心跳)+ 代码里已固化的常量收成一张表,**每行带「来源等级 + 来源定位」**;并填掉 443 单 **§8.8** 留下的 **45% 口径待填参数位**(relay `--max-hosts` 数值 / `/status.capacity{max,used}` 语义 / `retryAfterMs` 口径)。 +2. **观测最小集** —— 定出**最小指标集 + 阈值 + 判别器**,落成**一个探针脚本**(一条命令、一个退出码);补上今天**唯一缺的计数器**(`DIAL` 只有日志行、无法被脚本断言)。 +3. **权限影响评估(R5)** —— 对「虚拟网卡驱动 / 骨干开端口 / nft 打洞 / 第二中继机(L3) / 443 兜底入口 / relay 拨号白名单」逐项出**权限面评估表**,结论只允许是「**收窄**」或「**维持**」;**L3 跨机真容灾只评估、不实施**。 + +**判定"做完了没有"** = §6 的 **E1–E9 全绿**(每条都能被第三方复现)。 + +**为什么这是序 ⑤ 的最小正确形态**(不是妥协,是已排过序): +- 清单 §五 第 5 项原文就是**三条一体**、且标注「可并行」;三条**本质耦合**:45% 口径要填数值 ⇒ 数值要么来自参数表内的其它参数(推导)、要么来自实测(序 ⑥);而"打洞率"这类参数要拿实测值,前置是**权限评估**(打洞需虚拟网卡/管理员权限)⇒ 拆开做会各留一个空洞。 +- 参数表的性质是「**固化**」(清单 §五 用词),**不是重新推演** ⇒ 本单不引入新模型、不引第三方依赖、不做架构改动。 +- 443 单已把「**不做容量承诺**」显式后置给序 ⑤(其 §4.1-6 / §8.8)⇒ 本单必须**把那个洞填上**,否则兜底入口启用后 `--max-hosts=0`(不限)等于**没有过载保护**。 + +--- + +## §2 只读前置(⛔ 只读,不改) + +> 执行前逐条核一遍,任何一条与"期望"不符 ⇒ **先停下来报告**,别猜。 +> `47 = 47.77.182.89`、`106 = 106.54.21.172`;⚠️ ssh 别名 `bt-server` 端口**已陈旧**(配置写 `32022`,实测 `Connection refused`)⇒ **用 `-p 22`**(443 单 §8.7-⑤ 已记,⛔ 不必再改 `~/.ssh/config`)。 + +| # | 核什么 | 命令 | 期望 | +|---|---|---|---| +| P1 | 三单元在跑(⚠️ **nginx 不归 systemd 管**,443 单已定口径) | `ssh -p 22 47 'systemctl is-active dshs dshs-relay dshs-pg'` + `ssh -p 22 47 'ss -lntp \| grep -E ":(443\|20080)\b"; pgrep -c nginx'` | 3×`active`;443 在听;nginx 进程数 > 0 | +| P2 | **relay `/status` 全字段原文**(= 观测现状基线,收尾要对照) | `ssh -p 22 47 'curl -s 127.0.0.1:20080/status'` | 含 `online[]` / `capacity{max,used,free?}` / `identityRequired` / `identityOk` / `trustedSigners` / `revokedHosts` / `endpoints[]` / `networks[]` / `counters{authed,authFailed}` | +| P3 | 现役容量参数(**45% 口径的空位就在这**) | `ssh -p 22 47 'systemctl cat dshs-relay \| grep -nE "max-hosts\|ExecStart"'` | `--max-hosts` **未出现**(默认 `0` = 不限)⇒ 本单 S4 要填 | +| P4 | relay 侧参数常量(参数表要收的"代码事实") | 本机 `grep -n "MAX_ENTRIES\|MAX_ENTRY_LEN\|DEFAULT_CAPACITY_RETRY_AFTER_MS\|refreshAfterSeconds\|300\|5_000" src/net/relay/directory.ts src/net/relay/server.ts` | `MAX_ENTRIES=8` / `MAX_ENTRY_LEN=512` / `capacityRetryAfterMs=5000` / 目录刷新 `300 s` / 取目录超时 `5 s` | +| P5 | 端口区间与回环池现状(观测要断言的对象) | `ssh -p 22 47 'ss -lntp \| wc -l'`;`ssh -p 22 47 'nft list ruleset \| wc -l'` | 443 单收口态 = 监听口 **79** / nft **72**(⚠️ 若不同,以本次实测为新基线并写明) | +| P6 | **47 的机器规格**(`--max-hosts` 数值的**直接输入**;旧记录 1.8 GB / 2 核 是 09-08 的,**必须复核**) | `ssh -p 22 47 'nproc; free -m \| head -2; df -h / \| tail -1'` | 实测值(⛔ 不得沿用旧记录) | +| P7 | 跨云实测带宽(旧记录 47↔106 **~22 KB/s**,**必须复核**) | 走既有 relay 数据面做**小样本**(≤1 s、≤1 MB) | 实测值(⛔ 不得沿用旧记录;⛔ 不许对生产做压测) | +| P8 | 106 的会合 env(⛔ **只记录,不动**) | `ssh -p 22 106 'grep -rn "DSHS_RENDEZVOUS_URL\|DSHS_RELAY_URL\|DSHS_OVERLAY" /etc/systemd/system/*.d/ /etc/dshs-cluster.env 2>/dev/null'` | 106 **有** `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay` ⇒ 🔴 **agent 面不吃引导链**,⛔ 不许顺手撤(撤掉 = `tunnel===undefined` 生产回归,443 单 §8.2/§8.7-② 已判不执行) | +| P9 | 本机代码基线 + **一个必须知道的事实** | `git -C D:/github/dsh_shenxian rev-parse HEAD`;`git -C D:/github/dsh_shenxian status --porcelain -- src/net/relay/` | `640813e`;输出 `?? src/net/relay/`(**整目录未被 git 跟踪** ⇒ 443 单 §8.1 注)⇒ ⛔ **永不 `git checkout --`**(会连带丢掉工作区其它未提交改动,且拿不回覆盖网络线代码) | +| P10 | 参数散落点(⛔ **只 grep 计数,不做全库摸底**) | 对清单 §三 点名的 6 份文档逐份 `grep -c` 关键参数词(`打洞率\|jitter\|KB/s\|扇出\|心跳`) | 每份的命中数(用于 S1 的"散落点清单",⛔ 不是重读全文) | + +**判据先说清(P6/P7 → S4 的因果)**:`--max-hosts` 只能从**实测**机器规格+**实测**单节点成本推出;沿用旧记录会得到一个不可复算的数 ⇒ **P6/P7 是硬前置,不是"顺便看看"**。 + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| 层 | 位置 | 改什么 | +|---|---|---| +| 工作区文档 | **新建** `参数表_覆盖网络_20260917.md`(工作区根) | 唯一参数表:`参数 / 值 / 单位 / 来源等级(实测·估值·推导·待测) / 来源定位(文件:行) / 复算式`;45% 口径在此**填出数值** | +| 本机 · 代码 | **新建** `scripts/overlay-probe.cjs` + `test/overlay-probe.test.mjs` | 观测最小集的**唯一入口**:一条命令、打印 ≤12 行、退出码 0/1 | +| 本机 · 代码 | `src/net/relay/server.ts`(**唯一代码改动,只加计数器**) | `counters` 增 `dial` / `dialDenied` / `dialFailed`(判别器计数),随 `/status` 暴露 | +| 本机 · 代码 | `test/relay.test.mjs`(或就近新增用例文件) | 计数器三分支的**先红后绿**用例 | +| 47 · systemd | **新建** `/etc/systemd/system/dshs-relay.service.d/capacity.conf` | `Environment=DSHS_RELAY_MAX_HOSTS=<参数表数值>` ⇒ 45% 口径**生效**(⛔ 不是硬编码进代码) | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ **不动官方 dsh 主程序与缓存**(R2)——`src/net/relay/*` 是**自研**代码,可动,但**只加计数器**;⛔ 不改 `HELLO` / `DIAL` / `DIAL_ACK` 帧格式、⛔ 不改鉴权与网络隔离语义、⛔ 不改 `identityRequired` 任何行为 +- ⛔ **不碰序 ②/③/④ 成果**:网抽象与逻辑名索引、引导三级链、`DSHS_OVERLAY_BOOTSTRAP_SEEDS`、`DSHS_OVERLAY_ADDR_OVERRIDES`、`relay-direct.conf`(443 兜底)、四层密钥与 `relay-keys.json` +- ⛔ **不改 106 的 `DSHS_RENDEZVOUS_URL`**(P8;字面撤掉 = 生产回归) +- ⛔ **不做 L3 跨机真容灾**(只出权限评估;落地归序 ⑥) +- ⛔ **不新增公网端口 / 不新增 DNS 记录 / 不改 nginx / 不动 Cloudflare** +- ⛔ 不碰数据面(打洞 / UDP)、不碰 presence / 房间层(清单 §五 第 7 步) +- ⛔ **不做 dashboard、不引外部监控依赖**(观测最小集 = 一个脚本,不是一套系统) +- ⛔ 不 commit、不 push + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +1. **三条一体、一单交付**,参数表的**唯一落盘位置** = 工作区根 `参数表_覆盖网络_20260917.md`。⛔ 不再往那 6 份文档里各写一份(那正是"散在 6 份、不可复算"的成因,清单 §三 第 1 条)。 +2. **参数表必须有「来源等级」列**:`实测 / 估值 / 推导 / 待测`。⛔ **禁止把估值写成实测**;凡 `待测` 项,**值留空**并写明「由序 ⑥ 用 3–5 台真机换掉」。 +3. **45% 口径填「单台中继的 `--max-hosts`」,⛔ 不填"全网 45%"**。理由:`--max-hosts` 是**每实例**参数,填一个与实例无关的全网数字**不可执行**;"1000 台的 45%"只作**校验上界**使用。 +4. **观测最小集 = 「一条命令出 PASS/FAIL」**。指标 ≤12 条,每条一个阈值 + 一个退出码;⛔ 阈值**不许是脚本里的魔数**,必须能在参数表里找到。 +5. **判别器计数必须补**(`dial` / `dialDenied` / `dialFailed`)—— 这是本单**唯一**的代码改动。依据:443 单 §12 的教训原文「**静默失效靠判别器定位**」,而今天判别器**只有日志行**(`DIAL manager -> w-106:21000 ok`),脚本无法断言 ⇒ 观测最小集缺了最关键的一条。 +6. **L3 跨机真容灾:只出权限评估,⛔ 不实施;落地归序 ⑥**。理由:做出 L3 需要"换机或第二公网 IP",而现在全网只有 **2 个节点**,单点风险已由序 ④ 的 443 兜底 + CF 双路部分对冲;而**序 ⑥ 本来就要起 3–5 台真机**,第二中继机在那个批次里落地最省事(同一批采购/配置/验收)⇒ **没有理由现在单独为它花一台机器或一个公网 IP**。 +7. **权限评估的判据形态固定**为 6 列:`对象 / 是否扩大权限面 / 扩大到哪一类(入站面·权限位·凭据外发·可见面)/ 是否已可收窄 / 证据(代码行或命令原文)/ 结论(收窄·维持)`。⛔ **结论列只允许「收窄」或「维持」**;若某项确需扩大 ⇒ **停下报告**(命中 R5),⛔ 不得自行执行。 + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +参数表的表结构细节与行序 · 探针脚本的文件名/输出格式/退出码约定 · 计数器命名与埋点行位置 · `--max-hosts` 的推导公式与最终取值(只要满足 §6 的 **E3** 约束)· `capacity.conf` 的备份命名 · 单测用例划分 · 参数散落点的 grep 词表 · 探测样本大小。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**本轮为空。** + +- L3 跨机真容灾已按 §4.1-6 **自决后置到序 ⑥**(现在做要新增机器/公网 IP = 资源承诺;而序 ⑥ 本来就要多台 ⇒ 合批明显更优,**上抛门槛不成立**)。 +- 其余全部落在 §1 的**边界内自决清单**(参数推导 / 观测实现 / 权限评估为**产出**而非动作)。 +- ⇒ 若将来要**提前**做 L3(例如你打算换掉 47),那时唯一缺口 = **第二台机器或第二个公网 IP**,属资源承诺,届时才需要你出手。 + +### 4.4 技术实现裁决顺序 + +按 `dsh-decision-method §4.4`;规则冲突按下序取首个命中项:`R8(开发环境服务器 ⇒ 该动就动,动手前一句话说明)` → `§1 边界内自决清单(部署/重启/改配置/技术选型)` → `§3 其余红线(R5/R7/R9/R10 永远是硬约束)`。⛔ 冲突 ≠ 门禁。 + +--- + +## §5 步骤(S0–S7,每步自带一次可执行的验证) + +### S0 · 只读取证(⛔ 不改任何东西) + +跑完 §2 的 P1–P10,产出"现状快照"(命令 + 原文输出 + 判定)。**任何一条与期望不符 ⇒ 停下报告**。 +额外取三件(本单设计的必要输入): +- **47 规格实测 + 跨云带宽实测**(P6/P7)⇒ S4 的 `--max-hosts` 推导输入; +- **`/status` 全字段原文**(P2)⇒ 既是"观测现状基线",也是 E8 的对照基准; +- **6 份文档的参数散落点计数**(P10)⇒ S1 的收口清单(⛔ 只计数,不重读全文)。 + +### S1 · 参数表落盘(工作区根 `参数表_覆盖网络_20260917.md`) + +收三类行: +- **输入参数**(清单 §三点名的 5 类):设备占比 / 打洞率 / 每玩家带宽 / 消息频率 / 心跳; +- **代码已固化的常量**:目录刷新 `300 s` / 取目录超时 `5 s` / `MAX_ENTRIES=8` / `MAX_ENTRY_LEN=512` / `capacityRetryAfterMs=5000` / 实例端口区间(`w-47` 20000+、`w-106` 21000+)/ dialer 回环池 64 口(`127.0.0.1:25000..26099`); +- **本单新增**:`--max-hosts`(含**推导式 + 分母口径**)、观测阈值(每条指标一个)。 + +**硬要求**:每行必须有**来源等级 + 来源定位**;`待测` 行必须在表末**单独汇总计数**。 + +**验证**:抽样 3 行按"来源定位"回原文复核 ⇒ 逐字对得上;`--max-hosts` 行按表内公式**手算一遍**能得同值。 + +### S2 · 观测最小集落成探针(本机代码 `scripts/overlay-probe.cjs`) + +最小指标集(≤12 条,全部来自既有 `/status` + 本机/远端只读命令): +在册节点数 · `capacity{max,used,free}` · `identityRequired` / `identityOk` · `trustedSigners` · `revokedHosts` · `counters.authed` / `authFailed` · **`counters.dial` / `dialDenied` / `dialFailed`(S3 新增)** · 端点表 `endpoints[]` · 双实例探活(`w-47:19100` / `w-106:19000`)· 门户 `200` · 监听口数 · `nft` 行数。 +**每条一个阈值 → PASS/FAIL;退出码 = 全绿 0 / 任一红 1**(可被 automation 直接消费)。⛔ 阈值从参数表读,⛔ 不硬编码。 + +**验证**:47 上跑一次 ⇒ 全绿、打印 ≤12 行、`exit 0`;**反证**:把某阈值临时调紧 ⇒ 变红、`exit 1`(证明确实在读阈值而不是恒绿)。 + +### S3 · 判别器计数(本机代码 `src/net/relay/server.ts` —— 本单**唯一**代码改动) + +`counters` 增 `dial` / `dialDenied` / `dialFailed`;埋点 = `DIAL` 帧处理路径的三个分支(放行 / 白名单或同网校验拒绝 / 目标端点不可达)。加**先红后绿**单测。 +⚠️ `npm test` 必须 **Node 22**;⛔ 不改帧格式、不改校验顺序。 + +**验证**:本机单测通过;47 上重启 `dshs-relay` 后制造一次成功拨号 ⇒ `counters.dial` +1 且 `/status` 可见。 + +### S4 · 45% 口径填值 + 设值(47) + +按 S1 的推导式(输入 = P6 实测规格 + P7 实测量 + 参数表内的单节点成本)算出 `--max-hosts`,写进**新建 drop-in** `dshs-relay.service.d/capacity.conf`(⛔ 不改 `dialers.conf`、⛔ 不改主单元)。设值前后各取一次 `/status`。 + +**硬约束(防自锁)**:新值必须 **`> used × 4`** 且 **≥ 参数表下限**;不满足 ⇒ **不设值**,改为在 §8 写明"参数表已填数值但因 X 不设值"。 + +**验证**:`curl -s 127.0.0.1:20080/status` ⇒ `capacity.max` = 设定值、`free = max - used`;`online[]` 未变;`identityOk` 未退化。 + +### S5 · 权限影响评估(**只有产出,无动作**) + +按 §4.1-7 的 6 列逐项评估: +① 虚拟网卡驱动(打洞所需)② 骨干节点开端口 ③ `nft` 打洞规则 ④ **第二中继机(L3 跨机真容灾)** ⑤ 443 兜底入口(**复核**是否真的零扩大)⑥ relay 拨号白名单 `dialers`(R5 引入)⑦ 每机独立密钥与信任根保管。 +每条给**证据列**(代码行 / 命令原文)。⛔ 不许出现"我认为"。 + +**验证**:评估表每条有证据;**结论列只出现「收窄」或「维持」**;L3 行**明确写「只评估不实施 + 归序 ⑥」**。 + +### S6 · 端到端复验 + 不退化检查 + +① `/status` 全字段对照 S0;② `online[]` 两台在、双实例面 `200`;③ 门户 `curl -H "Host: alotbuy.com" 127.0.0.1:3080/` = `200`;④ 监听口数 / `nft` 行数 与 S0 逐字一致;⑤ `npm test` 全绿;⑥ 探针全绿。 + +> 🔴 **计数器读数纪律(443 单 §8.6 的教训,必须照做)**:`authed` / `authFailed` / `identityOk` / `dial*` 都是**累计值** ⇒ "逐字一致"**物理上不成立**。必须**逐条归因增量**:每个增量都要能指名到"我做的哪一次动作"。⚠️ **101 探针本身会污染 `authFailed`** —— 读数前先扣掉自己打的探针次数。 + +### S7 · 回写 + +① `参数表_覆盖网络_20260917.md` 落盘,并在**入口 §1 的文档表登记一行**;②本单 §8 回填(含指纹);③把入口 §2「🎯 本轮动作」**推进到下一棒(序 ⑥ · 3–5 台最小形态)**;④⛔ 不 commit / 不 push。 + +--- + +## §6 验收(判据清单) + +| # | 判据 | 命令 | 期望 | +|---|---|---|---| +| **E1** | 参数表落盘且**每行有来源等级 + 来源定位** | `grep -c "^|" 参数表_覆盖网络_20260917.md`;抽样 3 行按定位回原文复核 | 行数与 `待测` 汇总计数自洽;抽样 3 行**逐字对得上** | +| **E2** | **可复算**(这是"结构可信 → 数字可信"的判据) | 任取 2 行按表内公式**独立手算** | 手算值 = 表内值 | +| **E3** | 45% 口径已填数值且**生效** | `ssh -p 22 47 'curl -s 127.0.0.1:20080/status' \| python -c "import sys,json;c=json.load(sys.stdin)['capacity'];print(c)"` | `max > 0`、`max` = 参数表值、`free = max - used`、`max > used×4` | +| **E4** | 观测探针可用 + **非恒绿** | `node scripts/overlay-probe.cjs; echo $?` ⇒ 再调紧一阈值复跑 | 首次 `exit 0` 全绿;调紧后 `exit 1` 且**指名**哪条红 | +| **E5** | 判别器计数存在且可增 | `/status` 取 `counters.dial`;制造一次成功拨号 + 一次白名单拒绝 | 两个数分别 +1,`/status` 可见 | +| **E6** | 观测阈值**无脚本魔数** | `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` | 每个命中项都能在参数表里找到同名阈值(⛔ 无孤值) | +| **E7** | 权限评估表完整 | 通读 §5-S5 产出 | 每条有证据列;结论 ∈ {收窄, 维持};L3 行写"只评估不实施 + 归序 ⑥" | +| **E8** | 零退化 | S0 快照 vs 收口:`/status` 全字段 + 双实例面 + 门户 + `npm test` | 除**已归因增量**外逐字一致;`npm test` 全绿(写清 a/b/c 计数) | +| **E9** | 零新增暴露面 | `ssh -p 22 47 'ss -lntp \| wc -l'`;`ssh -p 22 47 'nft list ruleset \| wc -l'`;`ss -lntp \| grep -c 20080` | 与 S0 逐字一致(443 单收口态 = **79 / 72**);relay 仍**只绑 `127.0.0.1`** | + +> **判定**:E1–E9 全绿 = 本单完工。任一红 ⇒ 按 §7 该项回滚,并在 §8 写清"卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决"。 + +--- + +## §7 回滚 + +| 项 | 回滚 | +|---|---| +| S4 `capacity.conf` | 删该 drop-in → `daemon-reload` → `systemctl restart dshs-relay`(回到 `max=0` = 不限;⚠️ 重启会重建 relay 会话,`online[]` 需复验) | +| S3 计数器代码 | 47 侧 = 恢复铺前备份 `/opt/dshs/lib/net/relay/server.js.bak-<时间戳>-pre-p5obs`(**铺前必须先备份**);本机 = 靠**备份副本**(⛔ **禁止 `git checkout --`**:`src/net/relay/` 不在 git 里,且会连带丢掉工作区其它未提交改动 —— 443 单 §8.1 注) | +| S2 探针 | 独立新增文件,删掉即可;⛔ **不改 `state.py`**(避免影响别人的"第一信号") | +| S1 参数表 | 新增文档,回退即可;若已登记进入口 §1 表,记得同步撤行 | + +--- + +## §8 回报格式(执行棒按此格式收口) + +``` +## §8 执行回报(执行棒 · 2026-09-17 HH:MM) +1. S0 快照:P1–P10 逐条 = 命令原文 + 输出 + 判定(不符的先说);含 47 规格与跨云带宽**实测值** +2. 参数表:行数 / 待测行数 / `--max-hosts` 推导式 + 取值 + 分母口径(原文摘录) +3. 落盘清单:新建/改动的**每个文件绝对路径** + 备份文件名 + 改前改后指纹(md5 / hash-object) +4. E1–E9:逐条命令原文 + 原文输出 + 绿/红 +5. 观测:探针输出原文(≤12 行)+ 退出码 + **调紧反证**(哪条变红) +6. 权限评估:6 列表逐条(结论只能是 收窄/维持)+ L3 行的"只评估不实施"原文 +7. 不退化:S0 对照 / 双实例面 / 门户 / `npm test` 计数(a/b/c)+ **计数器增量的逐条归因** +8. 未过项 / 遗留:逐条写"卡在哪(证据)+ 已做到哪一步 + 什么条件一出现必须回头解决" +9. 指纹:本单回填后 `md5sum`(⚠️ 别把全文件 md5 写进正文 —— 自指即失效,用 `sed '/^## §9 指纹/,$d' | md5sum` 口径) +``` + +--- + +## 附 A · 本规划棒已核实的事实(执行棒**不必重复探索**) + +| 事实 | 依据 | +|---|---| +| 序 ⑤ 的原文口径 = 「参数表 + 观测最小集 + 权限评估(P1 三条)」,状态 **可并行** | `覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 5 行** | +| 三条缺口的原文:「**参数表是空的**(libp2p 默认值可直接固化)· **观测最小指标集与阈值缺失** · **权限面评估缺失 = 命中 R5**(虚拟网卡驱动需管理员权限 / 骨干开端口 / nft 打洞)」 | 同上 **§四 P1 行**(第 85 行) | +| "不可复算"的根因 = 参数**散在 6 份文档、无统一表** | 同上 **§三 第 1 条**(第 65 行) | +| 序 ⑥ 的口径 = 「**3–5 台最小形态跑通**(把 3 个关键估值换成实测)」,依 `2/3/4` | 同上 §五 第 6 行 ⇒ **L3 归它是合批,不是推迟** | +| relay 容量/准入机制**已存在**:`--max-hosts`(`DSHS_RELAY_MAX_HOSTS`,默认 `0` = 不限)+ `/status.capacity{max,used,free}` + 满载 `at-capacity` + `retryAfterMs`(默认 **5000 ms**) | `src/net/relay/main.ts:64/82`、`src/net/relay/server.ts:62/102/104/367/368/502-504/757-759` | +| 客户端侧排队语义**已实现**(不消耗退避、不累计 attempts) | `src/net/relay/client.ts:532/881-896` | +| `/status` 的 **counters 只有 `authed` / `authFailed`** ⇒ **`DIAL` 无计数**(判别器只能读日志) | `src/net/relay/server.ts:276-278 / 347-348 / 510-512`(本次实测 grep) | +| `DIAL` 的隔离语义 = **同网 + 本网白名单**,跨网"能不能拨"都走不到(结构性隔离) | `src/net/relay/server.ts:22 / 108 / 261-265` | +| 45% 口径的待填位置(由 443 单显式后置给序 ⑤) | `交接单_443兜底_20260917.md` **§4.1-6 / §8.8** | +| 443 单带回的四条遗留(本单与它们的对应关系见下) | 同上 **§8.7** | +| 判别器教训原文:「**静默失效靠判别器定位**」(relay 有没有 `DIAL`) | 入口 §0 + `交接单_relay落地R2-R4_20260916.md` §12 | + +**§8.7 四条遗留 → 本单的对应关系**(⛔ 不许漏): + +| 遗留 | 本单处理 | +|---|---| +| ① D2 字面判据不可满足(CF 泛解析) | **不在本单范围**(未做灰云记录)⇒ 若要"解析层也不经 CF"才回头,登记为**序 ⑥ 后**候选项 | +| ② 106 的 agent 面不吃引导链 | **本单只记录、⛔ 不动手**(P8);若要 106 agent 面也能用兜底 ⇒ 需改 `worker/agent.ts`/`relay-tunnel.ts`,**归序 ⑥** | +| ③ 「同源优先」修法(已补) | 已闭环;⚠️ 其**遗留条件** = "若将来 `relays[]` 引入非首位更优的显式优先级语义(**序 ⑤ 参数表可能引入**)⇒ 需重新定义同源优先与它的先后关系" ⇒ **本单必须回答这一条**(在参数表里写明:`relays[]` 顺序语义 = **主入口首位**,本单**不引入**优先级新语义) | +| ④ 引导链缓存两支无"答出者"信息(切兜底有 ≤300 s 收敛期) | **本单把 `refreshAfterSeconds=300` 收进参数表并写明该收敛期**(作为已知边界,⛔ 不改缓存结构) | + +## 附 B · 硬约束复述(防走偏) + +- ⛔ **本轮只出单**:不改服务器、不改代码、不 commit、不 push、不做 presence。 +- ✅ **只做正向迭代(R11)**:本单任何一步若让任一维度**净变差** ⇒ 立即停下复盘;拿不出正向做法 ⇒ 停止执行。 +- ✅ **权限只准收窄(R5)**:本单**零新增公网端口、零新增入站面、零凭据外发**;权限评估是**产出**不是动作;若某项确需扩大 ⇒ **先停下报告**,⛔ 不得自行执行。 +- ⛔ **R7**:只做 §3.1 列出的改动;执行中发现别的"小问题"**先报告、后动手**。 +- ⚠️ **curl 判据必须 `--http1.1`**(门户 `http2 on`,否则假 404)。 +- ⚠️ **47 的 lib = `/opt/dshs/lib/`、106 = `/opt/dshs-cluster/lib/`**;**部署 = 本机 build 后 scp**;`npm test` 必须 **Node 22**。 +- 🔴 **`--max-hosts` 设值必须满足 `max > used × 4`**(防把自己锁在门外);不满足 ⇒ **不设值**并写清原因。 +- 📌 **参数表里 `待测` 项一个都不许编数** —— 编出来的数会让整张表失去"可复算"的资格。 + +--- + +## §8 执行回报(执行棒 · 2026-09-17 09:55) + +**判定:E1–E9 = **8 绿 + 1 部分绿**(E5 的"在 47 上受控制造一次真实拨号"未做到 —— 卡点、已做到哪一步、回头条件见 8.7-①)。三条硬约束全部守住(见 8.7-④)。** + +--- + +## 8.1 S0 快照(P1–P10:命令原文 + 输出 + 判定) + +| # | 命令(原文) | 输出(原文摘要) | 判定 | +|---|---|---|---| +| P1 | `ssh -p 22 bt-server 'systemctl is-active dshs dshs-relay dshs-pg'` + `ss -lntp \| grep -E ":(443\|20080)\b"; pgrep -c nginx` | `active`×3;`LISTEN 127.0.0.1:20080`(node)/ `LISTEN 0.0.0.0:443`(nginx);`nginx_pid=4` | ✅ | +| P2 | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` | 全字段见 8.2 表;⚠️ **两处与期望不符**(见 8.2) | ⚠️ 已纠偏 | +| P3 | `systemctl cat dshs-relay \| grep -nE "max-hosts\|ExecStart"` | `ExecStart=… --base 19000 --span 3000 --max-hosts 0` ⇒ ⚠️ **`--max-hosts` 并非"未出现"**,是**显式写在 ExecStart 里** | ⚠️ 改变 S4 做法 | +| P4 | `grep -n "MAX_ENTRIES\|…" src/net/relay/{directory,server}.ts` | `directory.ts:60` 300 / `:63` 8 / `:64` 512 / `:521` 5000;`server.ts:52/54/56/58/60/63/69/71/73/76` | ✅ 与期望一致 | +| P5 | `ss -lntp \| wc -l`;`nft list ruleset \| wc -l` | **79** / **72** | ✅ 与 443 单收口态**逐字一致** | +| P6 | `nproc; free -m \| head -2; df -h / \| tail -1; hostname` | **2 核**;mem total **1870** / used 868 / **available 1002** MB;`/dev/vda3 40G 15G 23G 39%`;`iZrj99af19cibck1ge93tqZ` | ✅ 实测(⛔ 未沿用 09-08 旧记录) | +| P7 | ①`/status` 读 `rttMs`;②`curl -w` 打 relay 回环口(6 样本);③`head -c 1048576 /dev/zero \| curl -X POST --data-binary @- http://127.0.0.1:44133/` | ① w-106 **rttMs=336** / manager **7**;② **TTFB 0.676–1.083 s**;③ `code=405 up=131072 total=0.6849` ⇒ **上行下界 ≈ 192 KB/s** | ⚠️ **部分**(稳态吞吐取不到,见 8.7-②) | +| P8 | `ssh -p 22 test106 'grep -rn "DSHS_RENDEZVOUS_URL\|…" /etc/systemd/system/*.d/ /etc/dshs-cluster.env'` | ⚠️ **指定路径零命中**;全局搜得真身在 **`/etc/dshs-worker.env`**:`DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay` | ⚠️ 位置纠偏(结论不变) | +| P9 | `git rev-parse HEAD`;`git status --porcelain -- src/net/relay/` | `640813e84e5e48c56ca39d765d7b195884e58ebc`;`?? src/net/relay/` | ✅;⛔ 全程**未** `git checkout --` | +| P10 | 7 份文档逐份 `grep -c "打洞率\|jitter\|KB/s\|扇出\|心跳"` | 调研 19 / 千台 18 / 瓶颈 9 / 游戏 7 / 清单 7 / 百台 2 / 骨干 2 = **64 处** | ✅ 只计数、未重读全文 | + +**P8 附注(只记录、⛔ 未动手)**:`/etc/dshs-worker.env` 的四个副本里,`.bak-20260916-1617` 仍是 `ssh://root@47.77.182.89:32022`,现行与另两个 `.bak-r4` / `.bak-r6` 已是 `wss://alotbuy.com/dshs-relay` ⇒ 「106 的 agent 面不吃引导链」这条**前提成立**,⛔ 未撤。 + +--- + +## 8.2 P2 的两处**事实纠偏**(都不是阻塞,但都会误导下一棒) + +| # | 期望(交接单 §2) | 实测 | 影响 | +|---|---|---|---| +| ① | `counters` 只有 `authed/authFailed` | 实测**还有一个**:`refused / dropped / streamsOpened / protocolErrors / backpressurePauses` + 序③ 的 `identityOk / identityRequired / trustedSigners / revokedHosts`(共 11 项 + 本次新增 3 项) | ⛔ 附A 那句"只有 authed/authFailed"作废;**`DIAL` 仍无计数**这条结论**依然成立**(新增前确实没有 `dial*`) | +| ② | `capacity{max,used,free?}` | `max=0` 时**只有 `{max,used}`**,`free` **字段不存在**(不是 `null`、不是 `0`);`free` 仅当 `max>0` 才出现(`server.ts:502-506`);拒绝载荷里恒为 `0`(`:761`) | ⇒ **E3 的 `free = max - used` 只在设值后才能判**(已写进参数表 §5.5) | + +**P3 的连带纠正(S4 做法因此改变)**:`main.ts:64` 用 `DSHS_RELAY_MAX_HOSTS` 作**默认**、`:82` 由 CLI `--max-hosts` **覆盖** ⇒ 主单元里那句显式的 `--max-hosts 0` 会把 drop-in 的 `Environment=` **静默吃掉**。故 `capacity.conf` **重写 `ExecStart`**(`ExecStart=` 清空 + 完整命令)—— 仍是"新建 drop-in、⛔ 不改主单元、⛔ 不硬编码进代码",且**避免了本项目最忌讳的静默失效**。 + +--- + +## 8.3 落盘清单(绝对路径 + 备份 + 改前/改后指纹) + +| # | 位置 | 动作 | 备份 | 改前 → 改后 | +|---|---|---|---|---| +| 1 | `E:\ProgramData\AI技能\aliyun-dsh-server\参数表_覆盖网络_20260917.md` | **新建** | — | — → 155 行(`md5sum` 见 8.9) | +| 2 | `D:\github\dsh_shenxian\scripts\overlay-probe.cjs` | **新建** | — | — → `0cd76d986273e04631510aa7d215d16c`(`git hash-object` `98b91d8280147f025792301f56b95582e9b901e5`) | +| 3 | `D:\github\dsh_shenxian\test\relay.test.mjs` | **改**(新增 T20) | — | `git hash-object` 改前未存 → `846460b5b6d6a3957abe29c63fce67e0a2e77f9d` | +| 4 | `D:\github\dsh_shenxian\src\net\relay\server.ts` | **改**(只加计数) | — | `7f95fe5f6b1865102d12a0917c4b4f9b7ecf2035` → `dc062c83ac0167f40d0e5d2c09f568b4`(`git hash-object`) | +| 5 | `D:\github\dsh_shenxian\lib\net\relay\server.js`(build 产物) | **改** | — | `64d814e7d68163752247d4a190b3b538` → `623374d948e37c87db3401f00ebf4dbe` | +| 6 | 47 `/opt/dsh-relay/lib/net/relay/server.js`(**relay 真身**) | **改** | `server.js.bak-20260917-pre-p5obs`(`64d814e7…`) | 同 5 | +| 7 | 47 `/opt/dshs/lib/net/relay/server.js` | **改**(保双副本对账) | 同上 | 同 5 | +| 8 | 106 `/opt/dshs-cluster/lib/net/relay/server.js` | **改**(保 29/29 对账口径) | `server.js.bak-20260917-pre-p5obs`(`64d814e7…`) | 同 5 | +| 9 | 47 `/etc/systemd/system/dshs-relay.service.d/capacity.conf` | **新建** | — | — → `a48df38f859adc3e8589d85bfd8a9ff1` | + +> ⚠️ **§7 回滚表的一处纠偏**:relay 运行的代码在 **`/opt/dsh-relay/lib/`**(主单元 ExecStart 指它),⛔ **不是** `/opt/dshs/lib/`。据此本次**两处都备份、都铺**(`/opt/dshs/lib` 只为双副本对账与 Manager 侧一致)。106 的副本 worker 永远不加载(worker 不跑 `RelayServer`),铺它是为了不破坏"29/29 hash 全同"这条既有对账口径。 +> **临时产物**(⛔ 未删,留给复核):`_中间产物_待清理/seq5/capacity.conf`(下发源)、`_中间产物_待清理/参数表_覆盖网络_调紧反证.md`(E4 反证用)。 + +--- + +## 8.4 E1–E9 逐条(命令原文 + 原文输出 + 绿/红) + +| # | 命令(原文) | 输出(原文) | 结果 | +|---|---|---|---| +| **E1** | `grep -c "^\|" 参数表_覆盖网络_20260917.md`;`grep -cE "^\|.*\| \*\*待测\*\* \|" 参数表_覆盖网络_20260917.md` | `155`;`4` | ✅(155 行;`待测` **数据行 4** = §7 汇总的 4,自洽。⚠️ 全文 `**待测**` 字面命中 6 次 = 4 数据行 + §0 图例行 + §7 说明行,⛔ 不是 6 项待测) | +| **E2** | 按表内公式**独立手算**(`RELAY_MAX_HOSTS` + `HALF_OPEN_MS`) | `floor(1002/2)=501`;`floor(262144/4)=65536`;`min=501`;`floor(501×0.45)=floor(225.45)=225` ✅;`max(3000, 15×1000×2.5)=37500` ✅ | ✅ 手算值 = 表内值 | +| **E3** | `ssh -p 22 bt-server 'curl -s 127.0.0.1:20080/status'` | `"capacity": {"max": 225,"used": 2,"free": 223}` | ✅ `max=225` > `used×4=8`;`free = max-used` | +| **E4** | `node scripts/overlay-probe.cjs; echo $?` ⇒ 再 `--table <调紧副本>` | 首次 **12 行全 PASS / `exit=0`**;调紧后 `FAIL OBS-02 capacity max=225 … (阈值 max=9999…)` + stderr `❌ 1 项红:OBS-02` / `exit=1` | ✅ 非恒绿,且**指名**红项 | +| **E5** | `/status` 取 `counters.dial`;`test/relay.test.mjs` T20 三分支 | 47 实测三个字段**已存在**:`"dial": 0, "dialDenied": 0, "dialFailed": 0`;T20 **先红后绿**(加计数前 `counters.dial` = `undefined` ⇒ `AssertionError`;加完 20/20 通过) | ⚠️ **部分绿**(47 上未能受控制造真实拨号,见 8.7-①) | +| **E6** | `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` | **零命中**(命中数 `0`) | ✅ 比判据更严:脚本里**连标识符名与注释里都没有**像阈值的数字 | +| **E7** | 通读 §5-S5 产出(本单 → 参数表 §8) | 7 条(① 虚拟网卡驱动 ② 骨干开端口 ③ nft 打洞 ④ 第二中继机 ⑤ 443 兜底入口 ⑥ 拨号白名单 ⑦ 每机密钥与信任根);**结论列只出现「维持」**(全部 7 条都是"不扩大",其中 5 条还标了"已收窄"证据);④ 明写「只评估不实施 + 归序⑥」 | ✅ | +| **E8** | S0 对照 + 双实例面 + 门户 + `npm test` | `listen=79 / nft=72` **逐字一致**;`online[]` = `manager` + `w-106` 两台在;实例面 `20000→401`、`w-106:40147→401`;门户 `200`;`npm test` = **tests 138 / pass 137 / fail 0 / cancelled 0 / skipped 1 / todo 0** | ✅(`skipped 1` 为既有条件跳过,非本次引入;计数器增量逐条归因见 8.6) | +| **E9** | `ss -lntp \| wc -l`;`nft list ruleset \| wc -l`;`ss -lntp \| grep 20080` | `79` / `72`;`LISTEN 0 511 127.0.0.1:20080` **只有回环一条** | ✅ 零新增暴露面 | + +--- + +## 8.5 观测(探针输出原文 + 退出码 + 调紧反证) + +``` +PASS OBS-01 在册节点 used=2 (阈值 ≥ 2) +PASS OBS-02 capacity max=225 used=2 free=223 (阈值 max=225, free=max-used) +PASS OBS-03 identityRequired=true trustedSigners=1 (阈值 ≥ 1) +PASS OBS-04 identityOk=2 (阈值 ≥ 2) +PASS OBS-05 revokedHosts=0 (阈值 ≤ 0) +PASS OBS-06 判别器 dial=0 dialDenied=0 dialFailed=0 (必须都是 number) +PASS OBS-07 authFailed=0 authed=2 (阈值 ≤ 50) +PASS OBS-08 端点表 2 条 / 离线 0 条 +PASS OBS-09 实例面 本机:20000=401 w-106:40147=401 (阈值 ∈ {200,401}) +PASS OBS-10 门户=200 (阈值 = 200) +PASS OBS-11 监听口=79(阈值 79) nft=72(阈值 72) relay 口绑定回环=1/1 条 +PASS OBS-12 relay RSS=72040KB (阈值 ≤ 800000KB) +exit=0 +``` +**调紧反证**:把 `RELAY_MAX_HOSTS` 改成 `9999`(副本表,⛔ 未改正式表)⇒ `FAIL OBS-02`,stderr `❌ 1 项红:OBS-02`,`exit=1`。 + +--- + +## 8.6 不退化(S0 对照 / 双实例面 / 门户 / `npm test` + **计数器增量逐条归因**) + +| 项 | S0(09:20) | 收口(09:5x) | 归因 | +|---|---|---|---| +| `capacity` | `{max:0, used:2}` | `{max:225, used:2, free:223}` | **我**:S4 设值(可归因、预期内) | +| `counters.authed` / `authFailed` | `2 / 3` | `2 / 0` | **我**:`restart dshs-relay` **清零累计值**;重启后 manager + w-106 各认证一次 ⇒ `authed=2`;`authFailed=0`(⛔ 本次**没有**打过任何错误密钥;旧值 3 是历史累计) | +| `counters.dial/dialDenied/dialFailed` | 字段不存在 | `0 / 0 / 0` | **我**:S3 新增字段 + 部署。⚠️ 全 0 是**真实读数**(生产此刻确实没有拨号流量,见 8.7-①) | +| `identityOk` / `identityRequired` / `trustedSigners` / `revokedHosts` | `2 / true / 1 / 0` | `2 / true / 1 / 0` | **零变化** ⇒ 序③ 成果未退化 | +| `online[]` | manager + w-106 | manager + w-106(sessionId 已换) | **我**:重启重建会话(R5/T8 的既定行为,无需人工介入) | +| `endpoints[].localPort` | 44911 / 44133 | 42563 / 40147 | **我**:重启后重分配(回环落点口号**本来就是会话期量**,⛔ 不是退化) | +| 监听口数 / nft 行数 | 79 / 72 | 79 / 72 | 逐字一致 | +| 门户 / 双实例面 | 200 / 401 | 200 / 401 | 未退化 | +| `npm test` | — | 138 / 137 pass / **0 fail** / 1 skipped | 未退化(T20 已计入) | + +> ⚠️ 按 §5 S6 的**计数器读数纪律**:`authed` 的"逐字一致"**物理上不成立**(重启即清零)—— 上表已逐条点名到"哪一次动作"。 + +--- + +## 8.7 未过项 / 遗留(逐条:卡在哪 + 已做到哪一步 + 什么条件一出现必须回头解决) + +**① E5 的"47 上受控制造一次真实拨号"—— 未做到** +- **卡在哪**:`DIAL` 只在**拨号方白名单**里的会话发出时才会被处理(`server.ts:747 / :975`),而白名单**只有 `manager` 一个**(`dialers.conf` = `DSHS_RELAY_MAX_HOSTS`… 原文 `ops:manager`)。要让 47 上产生真实 `DIAL`,唯一路径是**一条通过鉴权的业务请求**去访问 w-106 的实例面;而实测无 session 的 `curl -H "Host: …" 127.0.0.1:3080/` 在**鉴权层**就被挡(`w-106.alotbuy.com` ⇒ **404**,连实例路由都没进),35 s 观察 `dial` 恒 `0`、relay 日志 `DIAL` **0 条**(库里有 3 个 w-106 实例、1 个 w-47 实例 ⇒ 一旦有人真实访问必然触发)。 +- **已做到哪一步**:① 三个字段**已在 47 的 `/status` 可见**(`0/0/0`);② 三分支的**可得性**在 `test/relay.test.mjs` **T20** 上先红后绿全证(放行 `dial+1` / 白名单拒绝 `dialDenied+1` / 目标不可达与请求非法 `dialFailed+2`),而 T20 跑的就是**部署到 47 的同一份构建产物**(md5 `623374d948e37c87db3401f00ebf4dbe`,三处一致)。 +- **⛔ 为什么没硬做**:为一个计数器去 ① 往控制面库插临时 session(= 冒充真实用户去访问**正在运行的**用户实例,可能触发 launch / 文件面刷新)或 ② 把第二个 hostId 加进 `dialers` 白名单(= **扩大权限面**,命中 R5 必须停下报告)—— 两者都越出本单 §3.1 的范围,且风险**大于**这条判据本身的价值。 +- **什么条件一出现必须回头解决**:**下一次有真实用户访问 w-106 实例**时,回看 `/status.counters.dial` 应从 0 变正数;若**仍为 0**,说明拨号路径**根本没被走到**(正是 `RemoteSpawner.translateEndpoint` 那类静默失效的指纹),届时按判别器口径立即排查。 + +**② P7 的"跨云稳态带宽"—— 仍为 `待测`** +- **卡在哪**:relay 的两个面(agent 19000 / 实例 21000)在**无凭据**时只回 24 B / 68 B 的 401/404;`/client.js`、`/assets/` 均 404 ⇒ **拿不到大响应就测不出稳态吞吐**。 +- **已做到哪一步**:拿到 **RTT 336 ms**、**TTFB 0.676–1.083 s**、**上行下界 ≥ 192 KB/s**(1 MB 样本被 405 截断到 131072 B / 0.685 s)⇒ **旧记录 `22 KB/s` 判为不可用**(差 ≈ 9×,⛔ 已写进参数表 §0 作废)。 +- **什么条件一出现必须回头解决**:两端有**可控载荷**(序⑥ 的真机批次天然满足)⇒ 换出稳态值并**重算 §5.2**。 + +**③ 三项"顺手发现、⛔ 未动手"(R7:先报告后动手)** +- `~/.ssh/config` 的 `bt-server` 端口陈旧(`32022`)—— 本次全程 `-p 22`,**未改配置**。 +- **`src/net/relay/` 整目录未被 git 跟踪**(`?? src/net/relay/`)⇒ 覆盖网络线代码只在工作区+部署产物里;**未做任何 git 动作**(⛔ 未 commit / 未 push)。 +- 附A 那句「`/status` counters 只有 `authed/authFailed`」**已勘误**(见 8.2-①);`--max-hosts` "未出现"**已勘误**(见 8.2-P3)。两条都属"上一棒文档的既有结论",⛔ 本轮只记录、只纠正,未扩大改动。 + +**④ 三条硬约束守持证据** +- ① `--max-hosts`:`225 > used×4 = 8` ✅(不满足才"不设值",本次满足)⇒ **已设值**,且写明**分母口径 = 每台中继**。 +- ② 参数表 `待测` 项:**4 项值列全部留空**(`grep -n` 逐行可见 `| |` 空值),⛔ 一个都没编数。 +- ③ ⛔ **全程未执行 `git checkout --`**(`grep -c` 校验:本次会话的命令记录里零次)。 + +--- + +## 8.8 45% 口径(**已填值** + 生效证据) + +- **数值**:`RELAY_MAX_HOSTS = 225`(`floor(min(floor(1002/2), floor(262144/4)) × 0.45) = floor(501 × 0.45) = 225`)。 +- **分母口径**:**每台中继的在册会话数上限**(⛔ 不是"全网 45%");`1000 × 45% = 450` 只作**校验**。 +- **生效证据**:`capacity {max:225, used:2, free:223}` ✅;`systemctl show -p ExecStart dshs-relay` 原文含 `--max-hosts 225` ✅。 +- **校验③ 的结论(新信息)**:`450 > 225` ⇒ **单台中继盖不住千台的 L3 层,需 ≥ 2 台中继** —— 与 `覆盖网络_骨干层方案_20260916.md`「多中心骨干」**同向**;落地归**序⑥**(交接单 §4.1-6 已判 L3 只评估不实施)。 +- **回头条件(写死)**:`MEM_PER_HOST_MB` 或 `WAN_STEADY_THROUGHPUT` 一旦换成实测 ⇒ **必须重算本值并重新下发 `capacity.conf`**。 +- **哨兵**:`OBS-12 relay RSS ≤ 800000KB`(每 host 2 MB 假设的越线报警)。 +- **§8.7③ 要求的回答已写进参数表 §5.6**:`relays[]` 顺序语义 = **主入口首位**,本单**不引入**优先级新语义。 + +--- + +## 8.9 指纹 + +- **回填前**(规划棒交付,⛔ 本次未改动规划棒正文 §1–§7 与附A/附B 的既有结论,只追加了 §8 与两处勘误说明) +- **本节口径**(**推荐核对用**,可复现):**整个 §8.9 不计入** ⇒ 复核命令 + `sed '/^## 8\.9 指纹$/,$d' 交接单_参数表与观测_20260917.md | md5sum` ⇒ `ea5cf5158129467a03608410a10be2e7` +- **全文件 md5**:请用 `md5sum 交接单_参数表与观测_20260917.md` 现取 —— ⛔ 此处**故意不内嵌数值**:它包含本节自身,写进去即刻失效(自指)。 + +> ⚠️ **一次非预期改写的披露(已完全回滚,逐字复核通过)**:回填过程中一条 shell 命令里的反引号被当成**命令替换**,导致本文件被插入 25,761 段垃圾串(每字符之间一段)。**当场发现并回滚**(`replace` 逆操作 ⇒ 垃圾串计数 `0`),随后 `wc -l` / 章节标题 / 抽样 3 行逐条复核**与预期一致**,正文无残留。⇒ 记录在此,供下一棒对账时知情。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T18-检测时延与deadline.md b/dsh-server-docs/交接单/archive/交接单-已完成/T18-检测时延与deadline.md new file mode 100644 index 0000000..3355d96 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T18-检测时延与deadline.md @@ -0,0 +1,557 @@ +# 交接单 · 检测时延与 deadline(覆盖网络 · 序 ⑨) + +> **一单只做一件事**:把「杀中继 ⇒ 切流完成」的 **30 s 墙钟拆开量化**,并用**分解结果**决定改哪里 —— 目标是让真机样本回到 `RELAY_FAILOVER_DEADLINE_MS`(30000) 的**内侧**,⛔ **不是**把 deadline 调大。 +> +> **上游依据(均已核实,⛔ 不必重读全文)**: +> - `交接单_切流冷却语义_20260917.md` **§8.8-2**(现象 + 口径提示"该值含静默失效检测时延 ≈ 2.5×`HB_SEC`")—— ⚠️ **本单 §1.2 已用实测勘误该口径**,见 `RC-5` +> - 同上 **§8.8-3**(OBS-11 回头条件)⇒ 本单 **§2-P7 已复取**,判定见下 +> - 同上 **§8.8-4**(`RELAY_FAILOVER_COOLDOWN_MS=0` 自锁)⇒ 本单 **§7 回滚 ⛔ 禁用该值** +> - 同上 **§9**(`switcher.ts` 阈值六键默认值 `300000 / 30000 / 15000 / 3 / 2000 / 12000` + 三个装配点 + env 覆盖生效) +> - `交接单_中继失败切流_20260917.md` **§8.8-2**(五样本全景 `30563 / 27878 / 29176 / 32550 / 29586`;触发条件 = 样本 > 30000 ⇒ 立即回头)—— **本单的立项依据** +> - 代码单一来源:`D:/github/dsh_shenxian/src/net/relay/switcher.ts`(+ `client.ts` / `web/server.ts`) +> +> **本单性质**:**规划棒产出**。⛔ 只出单、不改服务器、不改代码(规划与执行分离)。 + +--- + +## §1 目标 + +### 1.1 一句话 + +**先证明这 30 s 花在哪两段上,再决定改「检测」还是改「拨号」** —— 现在证据指向:**检测 ≈ 15 s(优雅重启的 burst 窗口地板)、拨号 ≈ 12 s(对一个必然失败的同机候选白等 `upTimeoutMs`)、真正建连只要 ≈ 2.8 s**。 + +### 1.2 本单的核心判定:**30 s 去哪了(用现有日志分解,不需要新埋点)** + +**方法**:`[relay-client] down / [relay-skip] / [relay-switch]` 三种行**本来就带毫秒级时间戳**(`journalctl -o short-unix`)⇒ **分解是"读现有日志",不是"加新探针"**。 + +**样本 ① · 序⑧ 幕 4 收口(drill 读数 29586 ms,与五样本里的 29586 同一轮)** —— 逐行原文: + +| 事件 | epoch | 相对 BYE | 段长 | +|---|---|---|---| +| `[relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms]` | `1789617465.999982` | 0 | — | +| `attempt #1, retry in 975ms`(burst 窗口耗尽,退避才从 0 起算) | `1789617481.084742` | **+15.085 s** | **① 检测 = 15.08 s** | +| `[relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)… 该候选进冷却 300000ms` | `1789617493.375446` | +27.376 s | **② 白等 = 12.00 s**(= `waitUpOn` 吃满 `upTimeoutMs`) | +| `[relay-switch] #1 wss://alotbuy.com/… -> wss://106.54.21.172/…(原因:当前通道不健康(… attempts=4 unhealthyForMs=29376 …))` | `1789617496.196091` | **+30.196 s** | **③ 建连 = 2.82 s** | + +**样本 ② · 序⑦ 30563 ms 那一轮** —— 同结构、逐行原文: + +| 事件 | epoch | 相对 BYE | +|---|---|---| +| `peer bye: server restarting (was up) … burst window 15000ms` | `1789616881.632718` | 0 | +| `[relay-skip] ⛔ 新通道起不来(relay-direct)… 进冷却 300000ms` | `1789616910.195416` | **+28.56 s** | +| `[relay-switch] #1 alotbuy -> 106(… attempts=5 unhealthyForMs=30563 …)` | `1789616912.919233` | **+31.29 s**(drill 读数 30563 ⇒ 偏移 0.73 s = `systemctl stop` 返回晚于 BYE 的时间) | + +**⇒ 两样本结构完全一致:`15.0(检测)+ ~1.5(tick 相位)+ 12.0(白等)+ 2.1(建连)≈ 30.6 s`。** + +**分解结论(一句话)**: + +> **检测段 = 15 s 不是"半开检测",而是 `gracefulBurstMs` 的地板;拨号段 = 12 s 里 12 s 全是白等(`waitUpOn` 不对终态失败早退),真正的跨机建连只要 2.8 s。** +> ⇒ 两段各占一半;**其中 12 s 是纯浪费**(生产目录前两条候选同在 47)。 + +**五样本按同一结构反算(⛔ 未逐行复核,须由 §5-S2 验证)**: + +| 样本 | 总 | 检测 | tick 相位 | 白等 | 建连 | 是否自洽 | +|---|---|---|---|---|---|---| +| 30563 | 30.56 | 15.00 | ~1.5 | 12.00 | ~2.1 | ✅ 已逐行复核 | +| 29586 | 30.20 | 15.08 | ~0.3 | 12.00 | 2.82 | ✅ 已逐行复核 | +| 27878 | 27.88 | 15.00 | ~0.2 | 12.00 | ~0.7 | ⚠️ 反算自洽,未复核 | +| 29176 | 29.18 | 15.00 | ~0.2 | 12.00 | ~2.0 | ⚠️ 反算自洽,未复核 | +| 32550 | 32.55 | 15.10 | ~0.2 | 12.00 | ~5.2 | ⚠️ 反算自洽,未复核 | + +> 🔴 **可被证伪**:以上结构的**硬地板 = 15.0 + 12.0 = 27.0 s**。若 §5-S2 采到**任一 < 27 s 的样本** ⇒ **本单的分解被证伪**,立刻停下报告(⛔ 不许带着错分解去改代码)。 + +**根因清单(按可省时长排序)**: + +| # | 根因 | 证据 | 可省 | +|---|---|---|---| +| **RC-1** | **`waitUpOn` 对"终态失败"不早退** —— `server.ts:499-506` 只轮询 `state === 'up'`,直到 `deadline` 才返回 `false` ⇒ **每个死候选固定吃满 `upTimeoutMs`**。生产目录 `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]` 前两条**同机**(`server.ts:517-519` 注释原文已承认"这个坑一定会踩到"),杀 47 ⇒ 第一次 tick **必然**先试 `relay-direct` ⇒ **白等 12 s** | 两样本的 `[relay-skip] ⛔ 新通道起不来` 行,间隔 ≡ `upTimeoutMs` | **~12 s** | +| **RC-2** | **`gracefulBurstMs`(15_000) 期间 `attempts` 恒为 0**(`client.ts:565-580` 三处 `this.attempts = 0`)⇒ `unhealthy()`(`switcher.ts:244` = `attempts ≥ 3 ∨ unhealthyForMs ≥ graceMs`)**只能靠 `graceMs=15000` 成立** ⇒ 优雅停机(`systemctl stop/restart`、部署)路径的**检测地板 = 15.0 s** | 两样本 `attempt #0 [graceful …]` → `attempt #1` 间隔 = 15.085 / 15.06 s | 受 DP-4 约束,**本单⛔不改** | +| **RC-3** | **tick 周期 2000 ms** ⇒ 判不健康 → 发起换址之间存在 0–2 s 相位误差 | 实测 0.29 / 1.48 s | 0–2 s | +| **RC-4** | **`DRILL_POLL_MS` = 2000 ms** ⇒ 演练读数本身有 **0–2 s 系统性高估** | drill `waitSwitch` 每 2 s 轮询一次 | **测量误差,非产品时延** | +| **RC-5** | 🔴 **勘误**:上单 §8.8-2 的口径提示"该值**含静默失效检测时延 ≈ 2.5×`HB_SEC`**(= 37.5 s)"在**优雅停机路径上不成立** —— 两样本都**没走到半开检测**(`half-open suspected` 行为零命中)。⇒ 该口径只在**丢包型静默失效**时生效;**`HB_SEC` 不在这条关键路径上** ⇒ ⛔ 别为了它去动心跳/控制面流量模型 | 样本 ①②的原文链条里**无** `half-open` 行 | 0(防误改) | + +### 1.3 三问的判定 + +#### ① 30 s 里多少是检测、多少是拨号? + +**判定:检测 15.0–15.1 s(50%)/拨号 12.0 s 白等 + 0.7–5.2 s 建连(50%)。** 依据见 §1.2 两个逐行复核样本。 + +#### ② 该先改哪里? + +**判定:先改「拨号段的白等」(RC-1),⛔ 不动 deadline、⛔ 不动 `HB_SEC`。** + +- **为什么不是调 deadline**:deadline 是**验收判据**(上单 §8.8-2 原文:"放宽 = 作废本轮判据")⇒ 调大 = 把问题藏起来,**明确违反"要解决问题、不将就妥协"**。 +- **为什么不先动检测段(RC-2)**:15 s 的 burst 窗口是**有意设计** —— `client.ts:30` 注释原文"**计划内下线不是故障**:`gracefulRetryMs` 后立刻重连"。把它改成"graceful 也触发切流"⇒ **每次 relay 重启/部署都切一次流** = 正是 D5 要防的抖动。⇒ **判为已定项,⛔ 不动**(§4.1-D3)。 +- **为什么拨号段是"真缺陷"**:12 s 不是"等一个慢候选",是**等一个必然失败的候选**(`waitUpOn` 不看终态),且生产目录结构决定**每次从 47 切走都必然命中** ⇒ 纯浪费、且有确定性的修法。 + +#### ③ 判据怎么改才算"不将就"? + +**判定:把"总时长 ≤ deadline"细化为"三段各自的预算",并把测量误差从判据里剔掉。** + +- 总判据保留(`kill → switch ≤ 30000 ms`),但**必须同时看三段**:`检测 ≤ 16 s`(graceful)/`白等 ≤ 1 s`(修后)/`建连 ≤ 6 s`。 +- **`DRILL_POLL_MS` 2000 → 500**:否则 ≤2 s 的量化误差会让"是否超 deadline"的判定**既可能假红也可能假绿**(RC-4)。这是**测量修正,不是调参**。 + +--- + +## §2 只读前置(⛔ 只读,不改;P1–P10 逐条核实后才允许进 S 段) + +| # | 命令(原文) | 期望 | +|---|---|---| +| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序9执行棒"` | `✓ 已持全局执行锁`。**抢不到 ⇒ 有会话在跑 ⇒ 停下报告** | +| **P2** | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` | 锁 owner = 本会话名;HEAD = `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`,其 §2 指向本单 | +| **P3** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`e6b669c257d8e8964273b3b400238351`**(【实测·本棒复取】;不一致 ⇒ 参数表被人动过,停下报告) | +| **P4** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md \| md5sum` | 与 **§8.9** 里记载的"本单出单值"**逐字一致**(⚠️ 该值**故意只写在 §8 之内** —— §8 本身不计入哈希,写在这里会让哈希自指失效) | +| **P5** | 上两单前缀:`sed '/^## §8 回报格式/,$d' 交接单_切流冷却语义_20260917.md \| md5sum` ⇒ **`aa3a6ec0d66dd81f465cea2a3a08ad27`**;`…交接单_中继失败切流_20260917.md \| md5sum` ⇒ **`419abf308c00b7668e8898aaa91ba9e8`** | 两个值**逐字一致**(【实测·本棒复取】)⇒ 底稿未被改动 | +| **P6** | `cd "D:/github/dsh_shenxian" && grep -n "gracefulBurstMs\|halfOpenMs" src/net/relay/client.ts && grep -n "const waitUpOn" -A 8 src/web/server.ts && grep -n "private unhealthy\|async open\|deps.open" src/net/relay/switcher.ts` | 锚点**存在性**(⛔ 不验行号):`client.ts` ≈ `137`/`146`/`567`/`631`;`server.ts` ≈ `499`(`waitUpOn` 只轮询 `up`、**不看终态**)/`524`;`switcher.ts` ≈ `244`(`unhealthy`)/`300`(`await deps.open`) | +| **P7** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs" > /tmp/seq9-probe-s0.txt 2>&1; echo $?` | **12/12 PASS**、`exit 0`。**【实测·本棒 13:3x 复取】当前为 `10/12`(`OBS-09` / `OBS-11` 红)** ⇒ 见下方口径说明 | +| **P8** | `ssh -p 22 bt-server "systemctl is-active dshs-relay dshs \|\| true; curl -s http://127.0.0.1:20080/status"` + `ssh -p 22 test106 "systemctl is-active dshs-relay dshs-worker \|\| true"` | 两台 relay `active`;47 `/status` 的 `capacity.used = 2`、`online[]` 含 `manager` + `w-106`。⚠️ **106 nginx 归宝塔托管 ⇒ `is-active nginx`=`inactive` 是正常态,⛔ 别据以判故** | +| **P9** | `npm.cmd test`(**必须 Node v22**) | **155 tests / 154 pass / 0 fail / 1 skipped**(序⑧ 收口基线;⚠️ 若因新增用例上浮,写清"新增 n / 基线 155") | +| **P10** | 🔴 **分解复现(本单的核心前置)**:`ssh -p 22 bt-server "journalctl -u dshs --since -6h -o short-unix --no-pager \| grep -E 'relay-switch\|relay-skip\|relay-client. down'"` ⇒ 抽 §1.2 的两个样本,**逐行算出四段数值** | **样本①(`unhealthyForMs=29376`)= 检测 15.085 s + 白等 12.00 s + 建连 2.82 s**;**样本②(`unhealthyForMs=30563`)= 28.56 / 12.00 / 2.72 s**。**复现不出 ⇒ 停下报告**(说明 §1.2 的分解是错的,⛔ 不许带着它进 S3) | + +### 2.1 P7 的口径(承上单 §8.8-3 的回头条件)—— **本棒已复取,判定:未命中** + +- **复取值**:`ss -lntp | wc -l` = **77**(= **76 socket + 1 行表头**);**两次连采同值**(`sample1=77` / `sample2=77`);`nft=72`、relay 口绑回环 `1/1`、门户 `200`、两台 relay `active`。 +- **环境态钉因**:`LOCAL_INSTANCE_PORT`(20000) **未监听** ⇒ **47 无活跃实例**(`/opt/dsh/users/main` 存在但无实例进程,`ps` 里只有控制面 / `w-47` worker / relay 三个 node)。⇒ **口径目标 = 78**("无活跃实例"档),不是 79。 +- **判定**:`78 − 77 = 1`,**差值 1 < 2 ⇒ 未命中**上单 §8.8-3 的回头条件("差值 ≥ 2");且另一分支要求"**有活跃实例**时仍 ≠ 79",本棒**无活跃实例** ⇒ 分支不适用。另:相对序⑧ 收口值(同为 77)**零变化**。 +- **⛔ 只报告不动手(R7)**:`78 → 77` 这**1 个口**从未被定位(上单已核销 9 个固定口 / 64 个拨号池口 / `nft` / relay 回环绑定)。⇒ 记为**在册未办**,本单**不修**;只把"完整端口清单留档"作为 §5-S0 的一个动作(**取证,不是修复**),让下次漂移可比对。 + +### 2.2 已核实的代码事实(⛔ 执行棒不必重复探索) + +1. **`waitUpOn` 不会对失败早退**(RC-1 的代码真身):`server.ts:499-506` 每 100 ms 查一次 `client.status().state === 'up'`,直到 `deadline` 才 `return false`。⇒ **死候选**(连不上 / 握手失败)与**慢候选**在这一层**不可区分**,代价恒为 `upTimeoutMs`。 +2. **`upTimeoutMs` 有三处消费点**(改一处不够):`web/server.ts:524`(Manager 拨号通道 · C1)/`worker/relay-tunnel.ts:104` 与 `:158`(worker 实例面 · C2)/`net/relay/main.ts:315`(独立 `relay --client` · C3)。⚠️ 序⑦ 的 D1 就是为"同一件事别在多处各写一份"立的 —— **要么抽公共,要么每处都改并逐处验收**。 +3. **`gracefulBurstMs` 是"写死的默认值、不可配"**:`client.ts:137` 是可选 option,全仓 `grep` **只有** `client.ts:567` 的 `?? 15_000`,**无任何 env 键 / 无任何装配点赋值** ⇒ **运行时无法调**(对比 `RELAY_FAILOVER_*` 全部可经 env 覆盖)。 +4. **burst 期间 `attempts` 被强制归零**:`client.ts:565-580` 三个分支各自 `this.attempts = 0`(`queued` / `graceful` / `burst`)⇒ 与 `switcher.ts:244` 的 `unhealthy()` 组合出"15 s 检测地板"(RC-2)。 +5. **半开检测参数与触发**:`client.ts:631` `halfOpenMs ?? max(3000, hbSec×1000×2.5)` = **37.5 s**;`:632` 巡检 `tick = halfOpen/4`;`:636` 判据 `age > halfOpen`。⚠️ 仅在 `state === 'up'` 时计时 ⇒ **只在"连着但不来帧"(丢包)时生效**(RC-5)。 +6. **演练读数的两个系统性偏差**:① `waitSwitch` 每 `DRILL_POLL_MS`(2000) 轮询一次 ⇒ **读数高估 0–2 s**;② `t0 = Date.now()` 在 `stopRelay` **返回之后** ⇒ 比 relay 的 `BYE` 行**晚 ≈ 0.6–0.73 s**(两样本实测;这正好解释为什么 drill 读数与 `unhealthyForMs` 数值接近)。 +7. **`[relay-switch]` 行自带 `state/attempts/unhealthyForMs/阈值`** ⇒ 三段的边界**不需要新埋点**,只需时间戳对齐(D2 的"⛔ 不新造探测帧"继续成立)。 +8. **本机 = 生产的前身** ⇒ 改动会经下一次 `scp` 传导到 47/106。⛔ 推送前先 `git status --short` 确认待传清单只含本次真实改动(当前 = **43** 项)。 +9. **`src/net/relay/**` 在 git 里 untracked** ⇒ 改动**用 `git diff` 看不到**,只能用 `grep -c` / `wc -l` / `git status --short` 核(⚠️ 目录里还留着一个 `client.ts.bak-seq7-*`)。 + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| # | 文件 | 改动 | 理由(为什么非改不可) | +|---|---|---|---| +| 1 | `src/web/server.ts`(+ 对齐 `worker/relay-tunnel.ts` / `net/relay/main.ts`) | **`waitUpOn` 增加"终态失败早退"**:新通道一旦进入**终态失败态**(建议判据 = 已发生一次明确的失败退避且**不在 burst 窗口内**,或 `lastFatalReason` 命中)⇒ 立即 `return false`,⛔ **不再等满 `upTimeoutMs`**。三处消费点**逐处验收**;能抽公共就抽公共(D1) | **RC-1 的唯一落点** ⇒ 直接省掉 12 s。判据必须"不误伤慢候选":仍能连上只是慢的候选,依旧享受完整 12 s | +| 2 | `scripts/overlay-failover-drill.cjs` | 新增 **`--trace`**:从 journal 抽 `down/skip/switch` 三类行,**逐样本算出四段**(检测 / tick 相位 / 白等 / 建连)并落盘原始行 | E1–E5 的判据本体(分解必须**可被第三方复现**)。⛔ 原有 `--scene 1\|2\|3\|4\|4b\|all` 语义**一字不改** | +| 3 | `test/relay-failover.test.mjs` | 新增用例:**死候选 ⇒ `open()` 提前失败**(注入"永不 `up` 且已进 backoff"的假客户端)/**慢候选 ⇒ 仍等满超时**(护栏,⛔ 防"早退"误伤) | 本线纪律:**先红后绿已实证两次**;"早退"必须**有护栏用例**否则会把慢候选误杀(= R11 净退化) | +| 4 | `src/net/relay/client.ts` | **把 `gracefulBurstMs` 参数表化**(新增 env 键 `RELAY_GRACEFUL_BURST_MS`,**默认值 15_000 不变**) | 消除"唯一一个不可配的时延常量"(符合本线既有的"阈值零魔数"纪律,`overlay-probe` E6)。⚠️ 只加可配性,**⛔ 不改值** | +| 5 | `参数表_覆盖网络_20260917.md` | ① `DRILL_POLL_MS` **2000 → 500**;② 新增 `DRILL_SAMPLE_N` = `5`;③ 新增 `RELAY_GRACEFUL_BURST_MS` = `15000`;④ **§9 表格新增一行「时延分解(序⑨ · 实测)」**记录四段数值 + 两个样本的 epoch 原文;⑤ §7 计数回填;⑥ §10 指纹更新 | 参数表 = 单一来源。🔴 **值格必须纯数字**(上单 §8.6 的假红坑:夹注 ⇒ `NaN` ⇒ `OBS-02` 假红) | +| 6 | `overlay-failover-drill.cjs` 的 `waitSwitch` / 参数表 `DRILL_POLL_MS` | 轮询周期 2000 → 500 ms(随第 5 项) | **RC-4**:把 ≤2 s 的量化误差从判据里剔掉 —— 否则"是否超 deadline"**两端都可能误判** | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ **不动 `RELAY_FAILOVER_DEADLINE_MS`**(30000)。**这是本单的纪律核心**:放宽判据 = 作废判据。若 §5-S7 复测**仍**超界 ⇒ **写进 §8.8 报告,⛔ 不许自行改值**。 +- ⛔ **不动 `gracefulBurstMs` 的默认值**(只加 env 可配性 = §3.1-4);⛔ **不动 `client.ts` 的 burst 语义**("计划内下线不触发切流"是**有意设计**,见 §1.3-②)。 +- ⛔ **不动 `HB_SEC`**(15) —— 分解已证 `half-open`(37.5 s) **不在关键路径**上(RC-5);动它等于为一个没发生的路径改控制面流量模型。 +- ⛔ **不动 `RELAY_FAILOVER_COOLDOWN_MS`**(300000);🔴 **⛔ 绝对不许把 `COOLDOWN_MS=0` 写进任何回滚 / 演练路径**(上单 §8.8-4:归零连带废掉"失败候选必须被排除" ⇒ 候选链自锁,实测 121–123 s 无切换)。演练期要缩短**只走 `DRILL_COOLDOWN_MS`**。 +- ⛔ **不动 `RELAY_FAILOVER_MIN_ATTEMPTS` / `GRACE_MS` / `CHECK_MS`**(本单不改判据阈值;若要改,先回 §8 报告)。 +- ⛔ **不做"同机候选去重 / 并发竞速 open"**(§4.2-DP-2):运行时**拿不到"同机"维度**(候选只是 url 字符串),要做得在取址层新增 host 维度 = 扩大改造面;并发 open 会**同时建两条通道**(多占口池口 + 多一次 AUTH)⇒ 暴露面/资源面变化需另行评估。**记为在册未办(R7 只报告)**。 +- ⛔ **不新增任何监听口 / 凭据 / 对外可读端点**;⛔ 不动 nft / 安全组 / nginx;⛔ 不改 relay 服务端协议。 +- ⛔ **不动 worker 会合面**(`DSHS_RENDEZVOUS_URL` / `DSHS_TUNNEL_TARGET` —— 已判"撤掉 ⇒ 判不执行",别再复议);⛔ 不动 directory 引导链。 +- ⛔ **不重做序 ②–⑧**;⛔ 不做 presence / 房间层 / 内容分发(清单第 7 步);⛔ 不做打洞实现。 +- ⛔ **不 commit / 不 push**(未获授权)。 +- ⛔ **范围外的缺陷先报告、不顺手改**(R7)。在册:OBS-11 的 1 口差(§2.1)|OBS-09 实例面 000|guest(w-106) 实例页 502|`mksess*.cjs` 失效|`unhealthyForMs` 记账起点偏早(上单 §8.8-5)|`src/net/relay/**` untracked 留档缺口|`client.ts.bak-seq7-*` 残留。 + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +| # | 已定 | 理由 / 反例 | +|---|---|---| +| **D1** | **顺序 = 先量化再调参**:§2-P10 的分解**复现成功之前**,⛔ **不许改任何 `RELAY_FAILOVER_*` 的生产值**(含 deadline / minAttempts / grace / upTimeout) | 用户明令 + 本单立项初衷。分解**已经能用现有日志做出来**(§1.2)⇒ 没有理由跳步 | +| **D2** | **判据来源 = 现有日志行的时间戳**,⛔ 不新增探测帧 / 不新开端点 / 不引外部监控 | 承序⑦ D2;`[relay-switch]` 行自带 `attempts / unhealthyForMs / 阈值`,信息已足够 | +| **D3** | **15 s 的 burst 检测地板⛔ 不改**(保持"计划内下线不触发切流") | `client.ts:30` 原文语义;改它 ⇒ 每次 relay 重启都切流 = D5 要防的抖动(R11 净退化) | +| **D4** | **首选修法 = RC-1 的"死候选早退"**;`upTimeoutMs` **降值**只作**降级替代** | 降 `upTimeoutMs` 会误伤"慢但能通"的候选(实测 106 建连 2.7–2.8 s ⇒ 降到 4 s 已贴边)⇒ 只有在 RC-1 拿不到 12 s 时才考虑 | +| **D5** | **`DRILL_POLL_MS` 2000 → 500**(测量修正,非调参) | RC-4:≤2 s 量化误差会让判定两端误判 | +| **D6** | **deadline 保持 30000**,且**必须继续按 30000 判**(⛔ 不设"宽限窗口"、⛔ 不写"接近即视为通过") | 判据一旦软化,本单与上单的结论**都不可比** | +| **D7** | **三处装配点必须逐处验收**(`server.ts` / `relay-tunnel.ts` / `main.ts`) | 序⑦ D1 的教训("同一件事在多处各写一遍,其中一处悄悄漏了") | +| **D8** | **服务端零改动**(relay 侧最多只读) | 缩小失败域(承序⑦ D8 / 序⑧ D12) | +| **D9** | 若最终确需动 `upTimeoutMs` ⇒ **必须同时给出"慢候选不被误杀"的判据**(例如"某台成功建连耗时的 P95 + 余量")并回填参数表 | ⛔ 不许凭感觉给一个更小的数 | + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +"终态失败"的**具体判据**(用 `state === 'backoff'`?用"已发生 ≥1 次非 burst 退避"?用 `lastFatalReason` 是否存在?还是给 `RelayClient` 加一个只读的 `failedTerminally()` 投影)|早退判据的**代码落点**(`waitUpOn` 内联 vs 抽 `src/net/relay/` 公共工具)|是否把三处 `waitUpOn`/`waitUp` 合并|`--trace` 的**输出格式**(四段数值 + 原文行)/落盘路径|`DRILL_SAMPLE_N` 的采法(是否复用 `--scene ctrl`)|`RELAY_GRACEFUL_BURST_MS` 的键名与是否需要 `DRILL_` 前缀的演练覆盖|§9「时延分解」行的**表格字段设计**|日志文案细节。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**本单 = 空。** + +三问的判定全部落在"技术实现 / 测量与判据"边界内(§1.3 已逐条给出判定与依据);最容易被误当成"业务意图"的 **D3(relay 计划内重启该不该触发切流)** 已有**明确的设计原文依据**(`client.ts:30` 注释 + burst 窗口的存在理由)⇒ 属"客观可判",**自行拍掉、不上抛**。 + +### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致) + +① **先复用现成能力**(现有日志行的时间戳 + `RelayClient.status()` 的 `state/attempts` + 既有 `[relay-skip]` 判别器)→ ② **再改现有模块**(`waitUpOn` 一处/三处)→ ③ **最后才新增**(`--trace`、两个单测、参数表两个键)→ ⛔ **不引第三方依赖**。 + +### 4.5 权限影响评估(按红线 R5 要求先出;**结论:未命中 R5,暴露面零变化**) + +| 维度 | 本单影响 | 判定 | +|---|---|---| +| **新增监听口** | **0**(只改客户端换址时序;候选集不变) | ✅ 不扩大 | +| **新增凭据** | **0** | ✅ 不扩大 | +| **放宽访问控制** | **0**(早退只是"更早放弃一个已失败的候选",⛔ 绝不放宽为"连目录外地址") | ✅ 不扩大 | +| **入站** | 106 入站**仍为 0**;47 无新增入站口 | ✅ 不扩大 | +| **新增可读面** | **0**(D2:⛔ 不新开端点;`--trace` 只读本机 journal) | ✅ 不扩大 | +| **暴露面净变化** | **0** | ✅ | + +> ⚠️ **若执行中为"能切过去"而允许连目录外的地址 ⇒ 立刻停下**:那才是真的 R5(扩大信任面 = 任意重定向),必须回来出评估。 + +--- + +## §5 步骤(S0–S9;**每步自带一次可执行的验证**) + +### S0 · 只读取证(= §2 P1–P10 + 端口清单留档) + +- **动作**:按 §2 逐条跑;**P3/P5 指纹逐字一致**;**P10 的分解必须复现**;额外**把 47 的完整端口清单落盘**(`ss -lntp | tail -n +2 | awk '{print $4}' | sort -n | uniq > /tmp/seq9-ports-<ts>.txt`)—— 这是**取证**,用于下次 `78 → 77` 类漂移的可比对(⛔ 不是修复)。 +- **验证**:P1–P10 全部 ✅;P10 的四段数值与 §1.2 一致(容差:检测段 ±0.5 s、白等段 ±0.3 s、建连段 ±1.5 s)。 +- **⛔ 硬门**:任一 ❌ ⇒ 停下报告,**不进 S1**。 + +### S1 · 时间轴抽取(只读,先出工具) + +- **动作**:给 `scripts/overlay-failover-drill.cjs` 加 `--trace`:一次 `journalctl` 取回窗口内 `relay-client. down` / `relay-skip` / `relay-switch` 三类行(⚠️ **`--since` 一律用 `@<epoch>`**;⚠️ 远端**读状态**命令一律 `|| true`;⚠️ 保留 `JOURNALCTL-ERR` 哨兵),按**样本切分**(以 `down (… (was up))` 或进程号变化为界)算出四段,**并原样打印所依据的行**。 +- **验证**:对 §1.2 的两个历史样本跑 `--trace` ⇒ 四段数值**与 §1.2 表格逐项对得上**。 +- **⚠️ 口径**:`--trace` **只读**,⛔ 不停 relay、⛔ 不改 env、⛔ 不写远端文件。 + +### S2 · N=5 采样(判据分布的地基) + +- **动作**:跑 5 次"杀当前入口"(每轮之间必须**归零**:① 两台 relay `systemctl start` ② 重启 Manager(`restart dshs`)⇒ 通道回到目录首位 ③ 等 47 的 relay 日志出现新的 `AUTH OK host=ops/manager`);每次用 `--trace` 落盘。 +- **验证**:5 份 trace 齐全;**每份的"检测 + 白等"≥ 27.0 s** ⇒ 若出现 **< 27 s 的样本** ⇒ 🔴 **分解被证伪,立刻停下报告**(§1.2 的可证伪条款)。 +- **⚠️ 注意事项**:① **必须避开 `--scene ctrl`(`COOLDOWN=0`)那类轮次** —— 会污染候选顺序;② 每轮**记录 `DRILL_POLL_MS` 值**(S6 改前后各一组,读数不可混比)。 + +### S3 · 按分布选方案(**门 D1 在此解锁**) + +- **动作**:算三段的中位数与极值: + - **若白等段中位数 ≥ 8 s** ⇒ 做 **D4 首选**(S4 的死候选早退); + - **若白等段中位数 < 8 s** ⇒ 说明 RC-1 不是主因 ⇒ **停下报告**,把精力转向 RC-3/RC-2 的重新评估(⛔ 不许硬套)。 +- **验证**:一段结论 + 三行数据(中位数 / 最小值 / 最大值)写进 §8。 +- **⛔ 硬门**:S2 未跑完 ⇒ ⛔ 不许进 S4。 + +### S4 · 死候选早退(RC-1 落点) + +- **动作**:`waitUpOn`(及另两处等价逻辑)在轮询里增加**终态失败**判定 ⇒ 立即 `return false`;⛔ **不得**把"还在 burst 窗口内的短间隔重试"误判成终态(那会让 relay 的**计划内重启**被当成死候选 ⇒ 每次部署都切流)。 +- **验证**:单测 **先红后绿**;`npm.cmd test` ⇒ **155 + 新增 / 全绿**(基线 155)。 +- **⚠️ 易错点**:三处消费点(`server.ts:524` / `relay-tunnel.ts:104`、`:158` / `main.ts:315`)**逐处确认**;⛔ 别只改一处就宣布完成。 + +### S5 · 护栏用例(防"早退"误杀慢候选) + +- **动作**:新增单测:**慢候选**(连得上、只是 `up` 来得晚,< `upTimeoutMs`)必须**仍然成功**;**死候选**(进 backoff 且出 burst 窗口)必须**提前失败**。 +- **验证**:两条同时绿;且**既有用例一条不红**。 +- **⚠️ 不变量**:这条是"早退"的护栏 —— 没有它,早退可能变成"更频繁地切到第三候选"甚至"全部候选都判失败"(R11)。 + +### S6 · 参数表闭环 + +- **动作**:`DRILL_POLL_MS` 2000 → 500;新增 `DRILL_SAMPLE_N`、`RELAY_GRACEFUL_BURST_MS`;**§9 新增「时延分解(序⑨ · 实测)」行**(四段数值 + 两个样本 epoch 原文);§7 计数回填;§10 指纹复取回填。 +- **验证**:`grep -nE 'DRILL_POLL_MS|DRILL_SAMPLE_N|RELAY_GRACEFUL_BURST_MS' 参数表_覆盖网络_20260917.md` ⇒ **值格纯数字、无夹注**。 +- **⚠️ 假红坑**:值格带夹注 ⇒ `NaN` ⇒ `OBS-02` 假红(上单 §8.6 原文教训)。 + +### S7 · 真机复测(修后 N=5,**判据不许软化**) + +- **动作**:S4 部署后重跑 S2 的 5 轮(`scp` + `restart dshs`;⚠️ **relay 真身在 `/opt/dsh-relay/lib/`,两处都要铺**)。 +- **验证**:**五样本 kill→switch ≤ 30000 ms(用新的 500 ms 轮询口径读数)**;且白等段 ≤ 1 s。 +- **⛔ 硬门**:若**仍**有样本 > 30000 ms ⇒ **写进 §8.8 报告并给出下一步候选(含 `upTimeoutMs` 的降值方案 + 慢候选 P95 证据)**,⛔ **不许自行改 deadline**。 + +### S8 · 不退化 + +- **动作**:`--scene all`(1/2/3/4/4b)+ `overlay-probe` + `npm test` + 门户 `200` + 双实例面 ∈ `PROBE_CODE_SET` + `ss -lntp | wc -l` 与 §2-P7 对照。 +- **验证**:见 E9/E10。**幕 1/2/3/4 结果不得变红**。 +- **⚠️ 观察点**:若出现新的 `[relay-skip]` 或 `[relay-switch]` 形态变化 ⇒ 事件行**必须原样抄进 §8**(含 `attempts / unhealthyForMs`)。 + +### S9 · 收口 + +- **动作**:清理临时产物(trace 落盘件先移 `_中间产物_待清理/`);**释放锁**;登记下一棒 automation(`scheduledAt` = 收口时刻 + 2~5 分钟);**用陈述句告知**接续;推进入口 §2;写工作区日志。 +- **验证**:见 E11/E12。 + +--- + +## §6 验收(判据清单;命令 + 期望输出,可被第三方复现) + +| # | 判据 | 命令 / 观察点 | 期望 | +|---|---|---|---| +| **E1** | 🔴 **分解可复现**(D1 的解锁条件) | §2-P10 + `--trace` 对两个历史样本 | 检测 `15.02–15.60 s`;白等 `11.7–12.3 s`;建连 `1.3–4.3 s`;**三段之和 ≡ 墙钟(误差 ≤ 0.5 s)** | +| **E2** | **分解未被证伪** | S2 的 5 份 trace | **每份"检测 + 白等" ≥ 27.0 s**;出现 < 27 s ⇒ **停下**(§1.2 可证伪条款) | +| **E3** | **白等段有原文证据** | `--trace` 输出里出现 `[relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)` | 该行与 open 发起时刻的间隔 **≥ 11.7 s** | +| **E4** | **测量误差已剔除**(D5) | `grep '^| `DRILL_POLL_MS`' 参数表_覆盖网络_20260917.md` + drill 实跑 | 值 = **500**,且复测读数与 trace 逐样本一致(差 ≤ 0.6 s) | +| **E5** | **样本量达标** | S2 / S7 各 5 轮 | 5/5 份 trace 齐全,且**每轮都记录了归零动作** | +| **E6** | 🔴 **修后 5/5 在 deadline 内侧** | S7 | `kill → switch ≤ 30000 ms` **5/5**;**白等段 ≤ 1 s** | +| **E7** | **不误伤慢候选**(护栏) | 单测(S5) | "慢候选仍成功" + "死候选提前失败" 两条同时绿 | +| **E8** | **三处装配点逐处验收**(D7) | `grep -n "waitUpOn" -A 12 src/web/server.ts src/worker/relay-tunnel.ts src/net/relay/main.ts` | 三处的早退语义**一致**;报告逐处给出证据 | +| **E9** | **不退化** | `--scene all` + `npm test`(基线 **155**)+ `overlay-probe` + 门户 `200` + 双实例面 ∈ `PROBE_CODE_SET` | 与 §2-P8/P9 对照**逐项一致**;幕 1/2/3/4 结果**不变红** | +| **E10** | **deadline 未被放宽**(D6) | `grep -n 'RELAY_FAILOVER_DEADLINE_MS' src/net/relay/switcher.ts 参数表_覆盖网络_20260917.md` | **仍是 `30000`**;⛔ 参数表 / drop-in / 演练脚本里**零处**把它改成别的值 | +| **E11** | **上单遗留不恶化** | §2.1(`ss -lntp \| wc -l`)|OBS-09/11|`nft` 72 | 与 §2-P7 逐项一致;若变红 ⇒ 停下报告(非本单范围) | +| **E12** | **无越界** | `git status --short` + `grep -rc "COOLDOWN_MS=0\|COOLDOWN_MS= 0"`(drill / 参数表 / drop-in) | 只含 §3.1 列的文件;⛔ 无 commit / push;🔴 **`COOLDOWN_MS=0` 在回滚 / 演练路径里零命中** | +| **E13** | **收口四件套** | 锁 `--release-exec` 已释放;下一棒 automation 已登记**并已用陈述句告知**;入口 §2 已推进;工作区日志已写 | ✅ | + +--- + +## §7 回滚 + +**三层,均秒级:** + +1. **配置层(不改代码即可回到现状)** —— 本单**新增的键全部有安全默认值**: + - `RELAY_GRACEFUL_BURST_MS` 删掉 ⇒ 回落到代码默认 `15_000`(**逐字回到现状**)。 + - `DRILL_POLL_MS` 改回 `2000` ⇒ 只影响**演练读数**,不影响产品。 + - ⇒ 改完 `daemon-reload` → `systemctl restart dshs`(47)/`restart dshs-worker`(106,如涉及)。 + - 🔴 **⛔ 禁用项**:**不许**用 `RELAY_FAILOVER_COOLDOWN_MS=0` 当"回滚/对照"开关(上单 §8.8-4:归零 ⇒ 候选链自锁,实测 121–123 s 无切换)。真要缩短冷却**只走 `DRILL_COOLDOWN_MS`**。 +2. **代码层**:用出单后立即做的 `.bak-seq9-<ts>` 备份还原被改文件(⛔ `git diff` 对 `src/net/relay/**` **无效** —— 它 untracked)→ `npm run build` → `scp lib/` 到 47 `/opt/dshs/lib/` + `/opt/dsh-relay/lib/`;106 `/opt/dshs-cluster/lib/` + `/opt/dsh-relay/lib/` → `restart dshs` + `restart dshs-worker`。 + - **落点自证**:改的是 `server.ts` 的 `waitUpOn` 与 `client.ts` 的 burst 键 ⇒ 建议用一个稳定的可 grep 标记(例如早退分支里的专属日志片段)在远端 `lib/` 里 `grep -c` 自证,**三处 lib 全部**。 +3. **服务层**:S2/S7 采样若停了 relay ⇒ `systemctl start dshs-relay`(两台)。**两台 relay 的 drop-in(`capacity.conf`)本单不动**。 + +**⚠️ 回滚后必须复验**:`overlay-probe`(按 §2.1 口径判:`OBS-09` / `OBS-11` 属环境态)|`ss -lntp | wc -l` = **77**(无活跃实例档,与 §2.1 同值)|双实例面 ∈ `PROBE_CODE_SET`|门户 `200`|`--scene 1` 仍能在 deadline 内侧切换。 + +--- + +## §8 回报格式(执行棒按此格式收口;沿用序⑤–⑧ 单的分节) + +> ## ✅ 执行棒回报(覆盖网络线 · 序⑨ 执行棒 | 2026-09-17 13:41–14:3x) +> +> **一句话结论**:**§2-P10 的四段分解复现成功(D1 解锁)⇒ 「死候选白等」12.03 s → **0.09 s**(省掉 12 s)⇒ 真机样本 **5/5 从「全部超 deadline」变成「全部在 deadline 内侧」**(30.2–34.1 s → **19.8–23.9 s**)。** +> **⛔ 未动**:`RELAY_FAILOVER_DEADLINE_MS`(30000) / `HB_SEC`(15) / burst 语义 / 服务端零改动 / 未 commit·未 push。 +> **🔴 三条硬门**:**D1** ✅(分解先复现,再改值 —— 且本单**一个生产阈值都没改**)|**R7** ✅(范围外只报告:§8.8)|**⛔ `COOLDOWN_MS=0`** ✅(本单新增行零命中,见 E12)。 + +### 8.1 S0 快照(P1–P10) + +| # | 命令(原文摘要) | 记录 | 判定 | +|---|---|---|---| +| **P1** | `handoff-guard.sh --claim-exec "覆盖网络线-序9执行棒"` | `✓ 已持全局执行锁` | ✅ | +| **P2** | `state.py` | 锁空闲→抢到;HEAD `640813e`;入口 = `接续入口_覆盖网络线_20260916.md`(§2 指向本单);工作区 **43** 处改动 | ✅ | +| **P3** | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`e6b669c257d8e8964273b3b400238351`** | ✅ 逐字一致 | +| **P4** | `sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md \| md5sum` | **`d903b4eeabf25ef381379cdbaac77e8a`** | ✅ **逐字一致 ⇒ 单未被改动**(收口后仍为此值,见 8.9) | +| **P5** | 上两单前缀 | 序⑧ `aa3a6ec0d66dd81f465cea2a3a08ad27`|序⑦ `419abf308c00b7668e8898aaa91ba9e8` | ✅ 两值逐字一致 | +| **P6** | `grep -n gracefulBurstMs\|halfOpenMs client.ts`;`grep -n "const waitUpOn" -A8 server.ts`;`switcher.ts` 锚点 | `client.ts:137/146/567/631`;`server.ts:499-506`(**只轮询 `up`、不看终态**);`switcher.ts:244/300`;**`waitUpOn` 三处** = `server.ts:499+524`|`relay-tunnel.ts:104+189`|`main.ts:155+315`(⚠️ 单 §2-P6 记的 `main.ts` 函数在 **155**、调用在 **315**,单内写 315 指调用点 ⇒ 一致) | ✅ 锚点全部存在 | +| **P7** | `node scripts/overlay-probe.cjs`(cwd = 工作区根) | **10/12**(`OBS-09` 实例面 `000`|`OBS-11` 监听口 **77** vs 阈值 79),`exit=1` | ✅ 与 §2.1 / §9.1 **逐项同值**(环境态:47 无活跃实例) | +| **P8** | 两台 `systemctl is-active` + 47 `/status` | 47 `dshs`=active / `dshs-relay`=active;`capacity.used=**2**`、`online[]` = `manager(session=6959e3e1fb5a2956)` + `w-106(session=416e7764a86752db ports=19000)`;106 `dshs-worker`=active / `dshs-relay`=active | ✅ | +| **P9** | `npm.cmd test`(Node 22) | **155 tests / 154 pass / 0 fail / 1 skipped** | ✅ 与序⑧ 收口基线逐字一致 | +| **P10** | `journalctl -u dshs --since -6h -o short-unix \| grep -E 'relay-switch\|relay-skip\|relay-client. down'` 抽两样本逐行对齐 | 见 **§8.2** —— **逐项复现**(误差 ≤ 0.03 s) | ✅ **D1 解锁** | + +### 8.2 **时延分解表(本单的核心产出)** + +**口径**:四段全部由 **journal 现有行的时间戳**算出(⛔ 无新埋点,D2); +`--trace` 的**白等**锚点 = 「新候选客户端首败」→「放弃」(⑨ 修前修后**同一口径**,见 8.2.3)。 + +#### 8.2.1 单内 §1.2 的两个历史样本 —— **逐项复现**(§6-E1 / D1 的解锁证据) + +| 样本(epoch 原文) | 检测 | 首试延迟 | **白等** | 建连 | 总 | `unhealthyForMs` | 与 §1.2 比对 | +|---|---|---|---|---|---|---|---| +| `1789616881.632718` | **15.119** | 1.416 | **12.029** | 2.724 | **31.287** | 30563 | 单内记 15.00 / ~1.5 / 12.00 / ~2.1 ⇒ ✅ 逐项一致 | +| `1789617465.999982` | **15.085** | 0.258 | **12.033** | 2.821 | **30.196** | 29376 | 单内记 15.085 / 0.29 / 12.00 / 2.82 ⇒ ✅ **逐字一致** | + +**所依据的行原文(样本② `1789616881`,节选三行)**: + +``` +1789616881.632718 … [relay-client] down (peer bye: server restarting (was up)); attempt #0 [graceful, burst window 15000ms], retry in 300ms +1789616896.751232 … [relay-client] down (transport error); attempt #1, retry in 1054ms ← 检测段终点(burst 耗尽) +1789616898.166825 … [relay-client] down (transport error); attempt #1, retry in 799ms ← **新候选客户端首败**(白等起点) +1789616910.195416 … [relay-skip] ⛔ 新通道起不来(wss://relay-direct.alotbuy.com/dshs-relay)… 进冷却 300000ms ← 放弃点 +1789616912.919233 … [relay-switch] #1 … -> wss://106.54.21.172/…(unhealthyForMs=30563) ← 样本终点 +``` + +> 🔴 **`--trace` 对"白等起点"的识别依据**(可被第三方复核):`open()` 里的候选客户端是**全新**实例 +> (`attempts=0`、无 burst 窗口)⇒ 它第一次拨号失败就打**不带方括号标签**的 `attempt #1`; +> 而老通道客户端在 burst 耗尽时也打一条 `attempt #1`,**那条正好等于 burst 耗尽行本身**(被排除) +> ⇒ 窗口内"最后一条不带标签的 `attempt #1`"唯一指向新候选客户端。 + +#### 8.2.2 全量样本(`--trace --since -6h`,19 个样本中 15 个"单跳") + +| # | 样本 epoch | 检测 | 首试延迟 | 白等 | 建连 | 总 | 签名 | +|---|---|---|---|---|---|---|---| +| 1 | `1789616881.632718` | 15.119 | 1.416 | **12.029** | 2.724 | 31.287 | 修前 | +| 2 | `1789617465.999982` | 15.085 | 0.258 | **12.033** | 2.821 | 30.196 | 修前 | +| 3 | `1789620716.466165` | 15.209 | 1.269 | **12.035** | 3.718 | 32.232 | 修前 | +| 4 | `1789621434.115527` | 15.490 | 1.830 | **12.034** | 3.768 | 33.122 | 修前 | +| 5 | `1789623996.90519` | 15.291 | 0.985 | **12.041** | 2.716 | 31.034 | 修前(S2-1) | +| 6 | `1789624064.033677` | 15.136 | 0.808 | **12.026** | 2.721 | 30.692 | 修前(S2-2) | +| 7 | `1789624132.71806` | 15.298 | 1.660 | **12.044** | 4.221 | 33.223 | 修前(S2-3) | +| 8 | `1789624194.346892` | 15.738 | 0.468 | **12.021** | 3.717 | 31.943 | 修前(S2-4) | +| 9 | `1789624258.224431` | 15.671 | 2.818 | **12.033** | 3.621 | 34.143 | 修前(S2-5) | +| 10 | `1789624473.578564` | 16.645 | 3.031 | **0.092** | 2.717 | 22.485 | 修后(S7-1) | +| 11 | `1789624534.000883` | 15.229 | 0.332 | **0.090** | 5.223 | 20.873 | 修后(S7-2) | +| 12 | `1789624587.689803` | 15.017 | 1.480 | **0.092** | 4.719 | 21.307 | 修后(S7-3) | +| 13 | `1789624640.289734` | 15.096 | 5.016 | **0.092** | 3.730 | 23.933 | 修后(S7-4) | +| 14 | `1789624693.055419` | 15.262 | 1.585 | **0.090** | 4.719 | 21.656 | 修后(S7-5) | +| 15 | `1789625104.721054` | 15.386 | 1.610 | **0.093** | 2.721 | 19.810 | 修后(幕 4-A) | + +- **修前(n=9)**:检测 **15.085–15.738**(中位 15.29)|白等 **12.021–12.044**(中位 12.033)|建连 2.716–4.221|**总 30.196–34.143 ⇒ 9/9 全部 > `deadline` 30000** +- **修后(n=6)**:检测 **15.017–16.645**(中位 15.26)|白等 **0.090–0.093**(中位 0.092)|建连 2.717–5.223|**总 19.810–23.933 ⇒ 6/6 全部 ≤ `deadline`** +- **自洽**:15/15 样本 `检测 + 首试延迟 + 白等 + 建连 ≡ 总`(误差 ≤ 0.05 s)✅ + +#### 8.2.3 白等段的口径说明(⛔ 防误读) + +- 单内 §1.2 把白等写成 `≡ upTimeoutMs`(12.000 s)—— 那是**修前的定义式**。本棒用**可直接测的锚点**替代它: + **白等 = `t(放弃点) − t(新候选客户端首败)`** ⇒ 修前实测 **12.021–12.044**(与定义式 12.000 相差 21–44 ms = 拨号耗时), + 修后实测 **0.090–0.093 s**。⇒ 同一个度量在修前修后都成立,**修前的 12 s 是"真等待",修后只剩 0.09 s**。 +- ⚠️ 单内 §1.2 的"tick 相位"在本口径下改称**首试延迟**(= `RELAY_FAILOVER_CHECK_MS` 相位 + 拨号耗时), + ⛔ 不是"测量被篡改":四段划分与总长自洽校验逐样本通过(见 8.2.2)。 + +### 8.3 代码改动清单 + +| # | 文件 | 改动点 | `wc -l` 前后 | 先红后绿 | +|---|---|---|---|---| +| 1 | `src/net/relay/client.ts` | ① 接口+`status()` 新增**只读投影** `inGracefulBurstWindow`;② 新增 `gracefulBurstMsDefault()`(env 参数表化,**默认 15_000 逐字不变**);③ 新增 `openedChannelFailedTerminally()`(**RC-1 判据**);④ 新增 `waitUpOnStatus()`(**三处装配点收口成一份**,D7) | **1306 → 1403** | ✅ | +| 2 | `src/net/relay/index.ts` | 导出上述 3 个符号 | 2 行改 | — | +| 3 | `src/web/server.ts`(**C1**) | `waitUpOn` 改为委托 `waitUpOnStatus` + 判死日志(`[relay-failover] ⛔ 新通道终态失败…`) | **908 → 917** | ✅ | +| 4 | `src/worker/relay-tunnel.ts`(**C2**) | 同上 | **220 → 224** | ✅ | +| 5 | `src/net/relay/main.ts`(**C3**) | 同上(+ `log` 透传) | **359 → 363** | ✅ | +| 6 | `scripts/overlay-failover-drill.cjs` | 新增 **`--trace`**(四段分解·**只读**)+ **`--sample N`**(N 轮采样,含完整归零序列);⛔ 原有 `--scene 1/2/3/4/4b/4c/ctrl` **语义一字未改**(仅在 `--help` 追加 2 行) | **720 → 1032** | — | +| 7 | `test/relay-failover.test.mjs` | 新增 **F18(护栏·慢候选)+ F19(死候选早退)+ F20(burst 窗口不许判死)+ F21(判据四反例)+ F22(env 参数表化)** | **731 → 844** | ✅ | + +**E8 · 三处装配点逐处验收**(D7):`grep -n waitUpOnStatus` ⇒ `server.ts`(C1)/`relay-tunnel.ts`(C2)/`main.ts`(C3)**三处全部委托同一实现**; +**落点自证(远端 lib 四份)**:`grep -c "openedChannelFailedTerminally"` ⇒ 47 `/opt/dshs/lib/net/relay/client.js` = **3**、47 `/opt/dsh-relay/lib/…` = **3**、106 `/opt/dshs-cluster/lib/…` = **3**、106 `/opt/dsh-relay/lib/…` = **3**; +`grep -c "新通道终态失败"` ⇒ `/opt/dshs/lib/web/server.js` = **1**、`/opt/dshs-cluster/lib/worker/relay-tunnel.js` = **1**。⚠️ 属主按原值 `197108:197121` 复原(⛔ 不引入 R10 的 root 属主问题)。 + +### 8.4 单测与本地验证 + +- **`npm.cmd test`(Node 22)= 160 tests / 159 pass / 0 fail / 1 skipped**(基线 **155** + 新增 **5**)。 +- **先红后绿**(实证):改完用例先对**旧 `lib/` 产物**跑 ⇒ + `SyntaxError: The requested module '../lib/net/relay/index.js' does not provide an export named 'openedChannelFailedTerminally'`(**红**); + `npm run build` 后 `node --test test/relay-failover.test.mjs` ⇒ **22/22 全绿**;全量 `npm test` ⇒ **159/0**。 +- **护栏用例(E7 的断言切面)**: + - **F19(红→绿的分水岭)**:假客户端在 100 ms 处进 `backoff`+`attempts=1`+非 burst ⇒ 断言 `ok===false` **且 `ms < 2000`**(旧实现必然 12 000 ms)。 + - **F18(护栏)**:慢候选 `connecting → up@800ms` ⇒ 断言 `ok===true` 且等到 ~800 ms ⇒ **⛔ 早退不许误杀慢候选**(R11 不变量)。 + - **F20(D3 保护)**:`backoff attempts=1` 但 `inGracefulBurstWindow=true` ⇒ 必须继续等,窗口过后 `up` ⇒ `ok===true`(⛔ 不许在窗口内判死)。 + - **F21**:判据四反例(`connecting` / `handshaking` / `queued`(attempts=0) / burst 窗口内)**一律 false**。 + - **F22**:`RELAY_GRACEFUL_BURST_MS` 默认 **15 000 逐字不变**;空串/非法/负数 ⇒ 回落默认;显式覆写才生效。 + +### 8.5 真机复测(修复前 N=5 / 修复后 N=5) + +**读数口径**:切换耗时 = `--sample` 的墙钟(**含 `DRILL_POLL_MS` 的量化误差**);四段另由 `--trace` 从 journal 时间戳复算(**与轮询口径无关**)。 + +| 轮 | 修复前 `DRILL_POLL_MS=2000` | ≤30000? | 修复后 `DRILL_POLL_MS=500` | ≤30000? | +|---|---|---|---|---| +| 1 | 33914 ms | ❌ | **25465 ms** | ✅ | +| 2 | 31646 ms | ❌ | **25342 ms** | ✅ | +| 3 | 34078 ms | ❌ | **21985 ms** | ✅ | +| 4 | 35321 ms | ❌ | **27389 ms** | ✅ | +| 5 | 41252 ms | ❌ | **23318 ms** | ✅ | +| 合计 | **5/5 超界**(中位 34078) | ❌ | **5/5 在内侧**(中位 25342) | ✅ | + +**每轮的归零动作(E5)**:① 两台 relay `systemctl start` → ② `systemctl restart dshs`(通道回到目录首位 = 47)→ ③ 等 47 relay 日志出现**新的** `AUTH OK host=ops/manager`(修复前 AUTH 就绪 2359–4112 ms/修复后 2834–4761 ms)→ ④ 记 `t0` → `systemctl stop dshs-relay`(47) → 等新 `[relay-switch]` → ⑤ 复原 relay。**10/10 轮全部执行归零**;⛔ 全程**未施加任何演练 env 覆盖**(尤其未出现冷却归零)。 +**同轮的四段读数(journal 口径)**:修复前白等 **12.021–12.044**/修复后 **0.090–0.093**(见 8.2.2 第 5–14 行)。 + +### 8.6 参数表改动 + +| 键 | 旧 → 新 | 值格自证(纯数字) | 说明 | +|---|---|---|---| +| `DRILL_POLL_MS` | **2000 → 500** | `500` | **测量修正**(剔掉 ≤2 s 量化误差 ⇒ 超/不超 deadline 两端都可能误判,RC-4)。⚠️ 改前/改后读数不可混比(8.5 已分档) | +| `DRILL_SAMPLE_N` | 🆕 `5` | `5` | `--sample` 的默认轮数(= S2/S7 的 N) | +| `RELAY_GRACEFUL_BURST_MS` | 🆕 `15000` | `15000` | 把"全仓唯一一个不可配的时延常量"参数表化(`gracefulBurstMs`);⛔ **默认值语义逐字不变**、⛔ 本单未改其值 | +| §9 表格 | 🆕 **第 9 行「换址墙钟的四段分解」** | — | 四段数值 + 修前修后两档总长 | +| §7 计数 | 回填一行 | — | 待测项 **仍为 0**(新增两个键都是实测/推导值) | + +**值格纯数字自证**:`grep -nE '^\| `(DRILL_POLL_MS|DRILL_SAMPLE_N|RELAY_GRACEFUL_BURST_MS)`'` ⇒ 值格依次为 `500` / `5` / `15000`(**无夹注** ⇒ ⛔ 不会引发 `OBS-02` 假红)。 + +### 8.7 不退化(S0 对照) + +| 项 | S0(§8.1) | 收口复取 | 判定 | +|---|---|---|---| +| `npm test` | 155/154/0/1 | **160/159/0/1**(+5 新用例) | ✅ 无红 | +| `--scene all`(幕 1/2/3/4) | 序⑧ 全绿 | **11 PASS / 1 FAIL**(幕2-B,见 8.8-1);**幕 4-A/B/C 全绿**(豁免切回 47 **22213 ms ≤ 30000**) | ⚠️ 见 8.8-1(归因 = drill 状态依赖,**正确前置下 3/3 PASS**) | +| `overlay-probe` | 10/12(`OBS-09`/`OBS-11` 红) | **10/12,同样只有 `OBS-09`/`OBS-11` 红** | ✅ 逐项一致 | +| `ss -lntp \| wc -l`(47) | **77** | **77** | ✅ | +| `nft` 规则行数 | 72 | **72** | ✅ | +| 门户 `http_code` | 200 | **200** | ✅ | +| 双实例面 | `000/000`(无活跃实例) | `000/000` | ✅ 同值(OBS-09 环境态) | +| 两台 relay 单元 | active/active | **active/active** | ✅ | +| 演练 env 残留(`RELAY_FAILOVER_*` 计数) | — | **0** | ✅ 生产值未被演练污染 | +| `git status --short` 计数 | **43** | **43** | ✅ 无越界(新增的 4 个文件均在本单 §3.1 范围内,且 `src/net/relay/**`·`relay-tunnel.ts`·drill·test 本就是 untracked) | + +### 8.8 未过项 / 遗留 + +**1. ✅ 序⑩ 已修(2026-09-17 14:4x)· 原报:🔴 `--scene all` 的幕 2-B 判 FAIL(归因:drill 自身的状态依赖,⛔ 非本单改动引入)** +- **① 现象**:`幕2-B 留下「无候选 ⇒ 原地退避」的判别器证据(D6)` ⇒ 窗口内 **0 行 `[relay-skip]`**;同窗口内 `[relay-client] down` / `[relay-switch]` 也**全为 0 行**(journal 取证:`1789624835±` 的 120 s 窗口里 Manager 一行 relay 日志都没有 ⇒ **它的通道根本没被打断**)。 +- **② 已做到哪一步**:定位到 **drill 代码本身** —— 幕 2 的注释写"两台全杀",但实现**只停 106**(`await stopRelay(h106)`,`overlay-failover-drill.cjs` 幕 2 块首行),而**幕 1 已把 Manager 的通道切到了 47** ⇒ 幕 2 停的是一台 Manager 已不在用的 relay ⇒ 无失效、无判别器。**在正确前置下重跑 ⇒ 3 PASS / 0 FAIL**(`幕2 前置:47 relay=inactive / 106 relay=inactive`,判别器原文:`[relay-skip] ⚠ 豁免尝试也起不来(wss://alotbuy.com/dshs-relay)⇒ **保持原通道**;重置该候选冷却…`)。⇒ **与本单改动无关**(本单只改"新通道等待",不触碰"无候选"分支;且该窗口内 Manager 完全无事件)。 +- **③ 回头条件**:**只要有人需要 `--scene all` 一键全绿**(如 CI 化 / 交给第三方复跑)⇒ 必须先把幕 2 改成"停掉**当前通道所在**的那台 relay"(与幕 1 同一套 `killTarget` 判定),否则它**永远依赖上一轮留下的通道归属**。⛔ 本单**不动**(§3.1 明令"原有 `--scene` 语义一字不改"+R7 范围外先报告)。 + +**2. 🔴 在册未办(本单**只报告、不动手**)** +- ✅ **序⑩ 已清(现 `grep -rho 'COOLDOWN_MS=0' scripts/ | wc -l` = `0`)· 原报:`COOLDOWN_MS=0` 在演练脚本里仍有 5 处命中**(`overlay-failover-drill.cjs`:`--scene 4c` / `--scene ctrl` 的文案与实现)—— **全部是序⑧ 遗留**,本单新增行**零命中**(`git diff -U0 | grep -c '^+.*COOLDOWN_MS=0'` = **0**)。按上单 §8.8-4(归零 ⇒ 候选链自锁、实测 121–123 s 无切换)与 §3.2 禁令 ⇒ 建议下一棒**删掉 `--scene ctrl` 及其 4 处文案**(`4c` 可保留:它走 `DRILL_COOLDOWN_MS` 覆盖,不碰生产键)。回头条件:**任何一次把 `--scene ctrl` 当"回滚/对照"用**之前必须删掉。 +- ✅ **序⑩ 已定位(见下方 §8.8-4)· 原报:OBS-11 的 `78 → 77` 那 1 个口**:本棒复取仍 = **77**(与 S0/序⑧ 同值),**未定位**(R7 只报告)。 +- 承上单:`OBS-09`(47 无活跃实例 ⇒ 实例面 `000`)|guest(w-106) 实例页 502|106 agent 面不吃引导链(已判不执行)|`mksess*.cjs` 失效|`src/net/relay/**` untracked 留档缺口|`client.ts.bak-seq7-*` 残留。 +- **⚠️ 本棒对"当前通道归属"有副作用**(幕 4 / S2/S7 采样会把 Manager 留在 106):收口时已 **`systemctl restart dshs` 归零回 47** 并复验(`used=2`、`online[]` 含 `manager` + `w-106`、probe 回到 10/12)。 + +**3. ⛔ 明确未做(保持单内 §3.2)**:未动 `deadline`/`HB_SEC`/burst 语义/`MIN_ATTEMPTS`/`GRACE_MS`/`CHECK_MS`/生产 `COOLDOWN_MS`;未做"同机候选去重 / 并发竞速 open";未新增监听口·凭据·端点;未改服务端;未 commit / 未 push。 + +**4. ✅ 序⑩ 收口回填(2026-09-17 14:23–14:5x · 执行棒 · 三件技术债全清)** + +| # | 遗留 | 结果 | 证据(现取) | +|---|---|---|---| +| ① | 幕 2 状态依赖(幕 2 只停 106 ⇒ 停的是**不用**的那台) | ✅ **已修** | 修法 = 幕 2 起手**现场重读权威通道归属**(`lastManagerAuthOn` 比对)后**两台都停**(幂等);`--scene all` = **12 PASS / 0 SKIP / 0 FAIL**(原 11/1);`--scene 2` 单独复跑 = **3 PASS / 0 FAIL**(前置行原文:`本幕开始时活跃通道 = bt-server(epoch 47=…/106=…)`) | +| ② | `COOLDOWN_MS=0` × 5 处(`--scene 4c` / `--scene ctrl`) | ✅ **已清,0 命中** | `grep -rho 'COOLDOWN_MS=0' scripts/ \| wc -l` = **0**;两个场景名**整体移除**并**显式拒绝**(`--scene 4c` / `ctrl` ⇒ 退出码 **2** + 提示走 `DRILL_COOLDOWN_MS`),⛔ 不静默空跑;`node --check` 通过 | +| ③ | OBS-11 的 `78 → 77` 那 1 口 | ✅ **已定位(有名字)** | 逐口对账见下 ⇒ **差额 = `20000`**(47 实例档,当前无活跃实例) | + +**③ 逐口对账(应然 78 行 vs 实际 77 行 · `ss -lntp`,含表头)** + +| 组 | 应然条数 | 实际 | 说明 | +|---|---|---|---| +| 九固定口 `22/80/443/888/3080/8765/15432/19100/20080` | 9 | ✅ 全在 | — | +| `[::]:22`(sshd 的 IPv6 **第二条绑定行**) | 1 | ✅ 在 | 计数是"**行**"不是"端口",故 22 占 2 行 | +| 拨号池 `25000–25063` | 64 | ✅ **64 口全在** | 脚本逐口点名验证 | +| w-106 落点(relay **动态分配**) | 1 | ✅ 在(值 = `46147`) | `/status.endpoints[0].localPort=46147`,与 relay `20080` **同 PID 779508** | +| BT-Panel `58888` | 1 | ✅ 在 | — | +| **47 实例档 `20000`** | 1 | ❌ **缺** | **就是这 1 口** —— 47 当前**无活跃实例**(`ss` 无 `127.0.0.1:20000`) | +| 表头 | 1 | ✅ 在 | — | + +⇒ **76 条 socket 逐条点名、零无名**;`78 − 77 = 1` 的名字 = **`20000`(实例档随实例上线/下线)**,**不是**暴露面消失。 +⇒ 🔴 **附带订正(口径书写错误)**:原清单把 `39463` 写成固定项 —— 它其实是 relay 为 w-106 端点**动态分配**的落点(现值 `46147`,同 PID)。⛔ **口径里不能写固定值**,否则每次重启都会误报"口变了"。 +⇒ ⚠️ **仍无名的 1 条(在册未办)**:参数表 `LISTEN_COUNT` = **79**(S0 基线,78 socket)比"应然·无实例态 77"高 **2**、比"应然·有实例态 78 行"高 **1**。这 1~2 条**无法定名**,卡点 = **S0 原始 `ss` 清单未留档**(已查工作区与 `04-调整方案/`,均无)。**回头条件**:① 47 恢复活跃实例后复取(应回到 **78 行**)② 届时若仍 **77** ⇒ 另有 socket 确实消失 ⇒ 按"零新增暴露面"重定基线,⛔ 不许把差值当噪声放过。 + +**🔴 ③ 的副产品(⛔ 本棒不动手 · 属方案改动 ⇒ 转规划棒)**:OBS-11 用「**计数相等**」当暴露面判据有**两个结构性弱点** —— ⓐ 对**状态**敏感(实例档/端点落点在线态一变就红 ⇒ 假红);ⓑ 对**替换式**变化**不敏感**(一进一出计数不变 ⇒ **假绿**,正是本项目最忌的静默失效)。⇒ 建议下一棒规划 **白名单集合判据**(`实际监听行 ⊆ 允许集`,并打印差集)替代计数相等;连带处理 `LISTEN_COUNT` 的"有/无实例态"两值。⛔ 本棒只报告(§3.2 范围 + D1 纪律)。 + +### 8.9 指纹(收口后的可复现核对口径) + + +- **参数表**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` + ⇒ **现取 = `99e9e17b0c1ce0550e4bc7626a5a0494`**(本单出单值 = `e6b669c257d8e8964273b3b400238351` ⇒ **已随 §3.1-5 的四处改动更新**) +- **本交接单前缀(§8 及其后不计入)**:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md | md5sum` + ⇒ **现取 = `d903b4eeabf25ef381379cdbaac77e8a`** = 出单值 ✅ **回填 §8 后未变**(底稿零改动) +- **交叉证据(上两单,收口复取)**:`切流冷却语义_20260917.md` = **`aa3a6ec0d66dd81f465cea2a3a08ad27`**|`中继失败切流_20260917.md` = **`419abf308c00b7668e8898aaa91ba9e8`**(两者均未变) +- 本单**全文件** md5 请现取(⛔ 不内嵌,自指) +- **序⑩ 原始取证落盘**:`_中间产物_待清理/seq10/`(`ss47-actual.txt` = 47 监听口全量 77 行;`drill-all-after.txt` = `--scene all` **12 PASS** 原文;`drill-scene2-after.txt` = 幕 2 单跑 **3 PASS** 原文) +- **原始取证落盘**(供第三方复核):`_中间产物_待清理/seq9-trace/`(`s2-before.json` 修前 5 轮、`s7-after.json` 修后 5 轮、`full-6h.json` 全窗口 19 样本、`seq9-sample-*.json` 采样明细) + +--- + +## §9 附录 · 本单的取证基线(供执行棒复核,⛔ 不必重跑) + +| 事实 | 出处 | +|---|---| +| `relayFailoverThresholds(env = process.env)` ⇒ **env 覆盖生效**;六键默认值 `300000 / 30000 / 15000 / 3 / 2000 / 12000` + `exempt` | `switcher.ts:55-95` | +| **唯一换址入口** `replace(targetUrl, reason, origin)`;`open()` 在 `:300` 被 await | `switcher.ts:204-310` | +| **`unhealthy()`** = `state==='backoff' && (attempts ≥ minAttempts ∨ unhealthyForMs ≥ graceMs)` | `switcher.ts:244` | +| `tick()` 周期 = `checkMs`(2000);D6 现场 ⇒ `noCandidateChecks` + `[relay-skip]`(节流) | `switcher.ts`(`tick` 内) | +| **`waitUpOn` 只轮询 `state === 'up'`,直到 `deadline` 才 false**(RC-1 真身) | `web/server.ts:499-506` | +| `open` 的三处消费点 | `web/server.ts:524`(C1)/`worker/relay-tunnel.ts:104`、`:158`(C2)/`net/relay/main.ts:315`(C3) | +| 生产目录 3 条候选、**前 2 条同机 47** —— 注释原文已承认 | `web/server.ts:517-519` | +| **`gracefulBurstMs` 默认 15_000、不可配**(全仓只有 `?? 15_000` 一处) | `client.ts:137`/`:567` | +| **burst 三个分支各自 `attempts = 0`**(RC-2) | `client.ts:559-580` | +| `gracefulRetryMs` 默认 300;`reconnectMinMs` 1000/`reconnectMaxMs` 30000;退避含 ±25% 抖动 | `client.ts:554-588` | +| 半开检测 `halfOpen = max(3000, hbSec×1000×2.5)` = **37.5 s**;仅在 `state==='up'` 计时 | `client.ts:631-638` | +| `DEFAULT_HB_SEC = 15`(运行真值;`HB_SEC_DOC=20` 是旧隧道时代文档口径,⚠️ 待更正) | `server.ts:60`/参数表 §4 | +| 演练读数偏差:`waitSwitch` 每 `DRILL_POLL_MS` 轮询;`t0` 在 `stopRelay` 返回之后(比 BYE 晚 0.6–0.73 s) | `overlay-failover-drill.cjs`(`waitSwitch` / `runScene1`) | +| 本单两个逐行复核样本的 epoch | ① `1789617466 / …7493.375 / …7496.196`(`unhealthyForMs=29376`)② `1789616882 / …6910.195 / …6912.919`(`unhealthyForMs=30563`) | + +### 9.1 本棒(规划棒)已做的只读取证(⛔ 执行棒不必重做) + +| 项 | 读数 | 出处 | +|---|---|---| +| 工作区 HEAD | `640813e`;`git status --short` = **43** | `state.py` / `git status` | +| 参数表指纹 | `e6b669c257d8e8964273b3b400238351` | 现取 | +| 上两单前缀指纹 | `aa3a6ec0d66dd81f465cea2a3a08ad27` / `419abf308c00b7668e8898aaa91ba9e8` | 现取 | +| `overlay-probe` | **10/12**(`OBS-09` / `OBS-11` 红);`exit=1`;原始输出落 `/tmp/seq9-probe-s0.txt` | 现取 | +| `ss -lntp \| wc -l`(47) | **77**(76 socket + 表头);连采两次同值;`nft=72` | 现取 | +| 47 端口清单 | 9 固定口(`22/80/443/888/3080/8765/15432/19100/20080`)+ 拨号池 `25000–25063`(64) + `39463`(w-106 落点)+ `58888`(BT-Panel) | 现取 | +| 47 活跃实例 | **无**(`20000` 未监听;`ps` 只有控制面 / `w-47` / relay) ⇒ OBS-09 红属环境态 | 现取 | +| 47 relay `/status` | `capacity.used=2`;`online[]` = `manager` + `w-106`;`dialers=["manager"]` | 现取 | +| 时延分解 | **检测 15.08 s / 白等 12.00 s / 建连 2.82 s**(+ 相位 0.29 s) | §1.2 样本① | + +--- + +## §10 指纹 + +- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒ + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_检测时延与deadline_20260917.md | md5sum` +- ⚠️ **本单另有 §8 前缀口径**(§8 及其后不计入)⇒ 见 **§8 回报格式** 内 §8.9 的取值处(收口时回填)。 +- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T19-最小形态真机批次.md b/dsh-server-docs/交接单/archive/交接单-已完成/T19-最小形态真机批次.md new file mode 100644 index 0000000..10e95f4 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T19-最小形态真机批次.md @@ -0,0 +1,434 @@ +# 交接单 · 3–5 台最小形态真机批次(覆盖网络线 · 序 ⑥) + +> **线**:覆盖网络线 | **序**:⑥(3–5 台最小形态跑通)| **产出**:规划棒 2026-09-17 09:5x +> **唯一来源**:`覆盖网络_应用场景与待完善清单_20260916.md` **§五 第 6 行**("3–5 台最小形态跑通,把 3 个关键估值换成实测,依赖 2/3/4")+ `参数表_覆盖网络_20260917.md` **§7 待测项汇总** + 同表 **§5.2 校验③**(需 ≥2 台中继) +> **本单要做的事**:把参数表 §7 的 **4 个 `待测` 项 + 1 个待校准推导项**设计成**可执行实测步骤**(每项:怎么测 / 样本多大 / 判据 / 写回哪一行),并**合批**落地 **L3 第二中继机**。 +> 🔴 **本单不改架构、不新增传输能力**:性质是「**把估值换成实测**」,不是"做打洞"(理由见 §4.1-1,这是本单最重要的一个已定项)。 + +--- + +## §1 目标 + +> **一句话**:让 `参数表_覆盖网络_20260917.md` 里 **5 个空值单元格全部有实测值或"如实标注取不到"的记录**,并让 **relay 从 1 台变 2 台**(跨机真容灾成立),**同时**按回头条件把 `RELAY_MAX_HOSTS` 重算并重下发到两台中继。 + +**判定"做完了没有"**: +1. `参数表` 中 `HOLE_PUNCH_RATE_LOCAL` / `PER_PLAYER_BW_LOCAL` / `WAN_STEADY_THROUGHPUT` / `JITTER_LINK_MEASURED` / `MEM_PER_HOST_MB` **五行**的"等级"列都不再是 `待测`(改成 `实测`,或写成 `取不到 + 原因`——⛔ 但**不许编数**); +2. 两台中继机各自的 `/status` 都能看到在册会话,且**杀掉任一台后客户端能自动切到另一台**; +3. §5 S7 的回头条件**已执行或已写明"本轮未触发、因为哪个数没换成实测"**。 + +--- + +## §2 只读前置(⛔ 只读,不改;P1–P8 逐条核实后才允许进 S 段) + +| # | 命令 | 期望输出 / 判据 | +|---|---|---| +| **P1** | `bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "<你的会话名>"` | `✓ 已持全局执行锁`。**抢不到 = 有会话在跑 ⇒ 只报告并立刻停** | +| **P2** | `"E:/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" "E:/ProgramData/AI技能/aliyun-dsh-server/state.py"` | `[锁] 🔴 被占用`(owner = 你)|HEAD = `640813e`|`[入口] 接续入口_覆盖网络线_20260916.md` | +| **P3** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`f3e68012698abb352549e2560746d992`**。⚠️ 不等 ⇒ 参数表已被改过,先去 §7 确认 `待测` 计数,再按实际情况调整本单行号 | +| **P4** | `ssh -p 22 bt-server 'systemctl cat dshs-relay \| head -25; echo ---; cat /etc/systemd/system/dshs-relay.service.d/capacity.conf 2>&1'` | 期望看到 **`--max-hosts 225`**(序⑤ 已下发;⚠️ 记忆铁律:主单元 ExecStart **已有显式 `--max-hosts 0`**,**CLI 优先于 `Environment=`** ⇒ 只设 env 会被静默忽略)。⛔ 若未下发 ⇒ **停下报告**,不要边补边测 | +| **P5** | `ssh -p 22 test106 'hostname; nproc; free -m \| head -2; systemctl is-active nginx dshs-relay 2>&1; ss -lntp \| grep -E ":(443\|80\|20080)\b"; ls -d /opt/dsh-relay /opt/dshs-cluster 2>&1'` | 记录:106 有无 nginx / 443 是否被占 / 是否已有 `/opt/dsh-relay`。**这是 S8 的唯一分叉判据**(有 nginx ⇒ 复用 443,零新增口;无 ⇒ 按 §4.2-2 自决) | +| **P6** | `"E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" -v && ls "D:/github/dsh_shenxian/lib/net/relay/" \| head` | Node 22.x + **`lib/net/relay/*.js` 已 build**。缺 ⇒ 先在代码仓 `npm run build`(Node 22)再继续 | +| **P7** | `ssh -p 22 bt-server 'ss -lntp \| wc -l; nft list ruleset \| wc -l; curl -s -o /dev/null -w "%{http_code}\n" --http1.1 -H "Host: alotbuy.com" 127.0.0.1:3080/; curl -s 127.0.0.1:20080/status \| head -c 400'` | **不退化对照基线**:`79` / `72` / `200` / `capacity:{max:225,used:N}`。⛔ 数与本单不一致 ⇒ 先报告差异,别当成 bug 顺手修 | +| **P8** | `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && "E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe" "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"` | 12 行 + 退出码 `0/1/2`。**FAIL 清单原样记下**(S9 要与它逐项对照) | + +> 🔑 **P1–P8 全部只读**。任何一条需要写动作 ⇒ 说明你走错了,停下回看 §3。 + +--- + +## §3 范围 + +### 3.1 要改的(且只有这些) + +| # | 对象 | 性质 | +|---|---|---| +| 1 | 工作区根 `参数表_覆盖网络_20260917.md` | **只改值 + 等级 + 指纹**(§3.2/§3.3/§3.5/§5.1/§5.2/§7/§10),⛔ 不改结构、不新增键 | +| 2 | 代码仓 `scripts/` **新增**一次性脚本(nat-probe / loadgen / bw-probe) | **不进 `src/`、不进产品路径**;判据 = `git status` 里 `src/**` 零改动 | +| 3 | 47 上 `dshs-relay.service.d/capacity.conf` | **仅在 S7 触发时重写**(drop-in 重写 ExecStart) | +| 4 | 106 上新增 `dshs-relay` 单元 + `/opt/dsh-relay/`(build 后 scp) + 节点密钥 | S8(L3 第二中继机) | +| 5 | 47 / 106 的 seeds 广播(`DSHS_OVERLAY_BOOTSTRAP_SEEDS` 或签名目录 `relays[]`) | 加入第二中继项 | +| 6 | 本单自身 §8 执行回报 + 工作区日志 | 收口 | + +### 3.2 ⛔ 不动什么(防顺手扩大) + +- ⛔ **不做打洞实现**(不在 `src/net/relay/` 加 UDP/dgram/STUN)—— 见 §4.1-1; +- ⛔ **不做 presence / 房间层 / 内容分发 / 游戏服**(`清单 §五` 第 7 步,本单毫不触碰); +- ⛔ **不引入第三方 relay 或把 STUN 库写进产品依赖**(探测脚本可用公网 STUN 作**降级手段**,但不得进 `package.json`); +- ⛔ **不重做序 ②/③/④/⑤ 的任何一步**; +- ⛔ **不改 `dshs` / `dshs-relay` 主单元的既有参数**(只经 drop-in); +- ⛔ **不在 106 上开非 443 的新公网口**(安全组不动); +- ⛔ **不 commit / 不 push**(本线纪律;本单只改工作区与服务器运行态)。 + +--- + +## §4 决策点 + +### 4.1 已定项(规划棒已拍 —— 执行棒**不得自行更改**;要改必须回写 §8 并说明理由) + +1. 🔴 **本单只做「把估值换成实测」,不做打洞实现** —— 依据:① `清单 §五 第 6 行`原文是"把 3 个关键估值换成实测",不是"新增传输能力";② **实测确认**:`src/` 全仓**零 UDP/NAT 穿透代码**(`grep -iE "dgram|createSocket|stun|punch|udp" src/` 的命中**全部是 `signature` / `native` 假阳性**),`directory.ts:404-421` 只有 CGNAT 地址**判定**;③ 引入打洞 = **净新增 UDP 入站面**(命中 R5)+需要 STUN/信令设计,属**独立方案**,不是本序的交付物。 + ⇒ **因此 `HOLE_PUNCH_RATE_LOCAL` 的口径定为:测「这台设备所在网络**能不能**打洞(NAT 映射/过滤行为)」,⛔ 不是"我系统打洞成功率"**(后者需要先有实现)。**测到的数仍然可用**:它决定"打洞实现值不值得做"(若可打洞比例低 ⇒ 这项能力可以直接不做)。 +2. **`--max-hosts` 的分母 = 单台中继**(参数表 §5.3 已写死);`FLEET_RELAY_DEMAND`(450)**只作校验上界**。 +3. **L3 第二中继机 = 106 升格**(依据:`清单 §3 关键决定`已定"**有公网 IP 的节点自动升格为中继候选**"+参数表 §5.2 校验③"需 ≥2 台")。⛔ 不新建机器、不启用"另开一台云主机"这条路(花钱属边界外,且 §4.3 列着)。 +4. **2 台就落地 2 台**:`225 × 2 = 450 = FLEET_RELAY_DEMAND` ⇒ **恰好达标、零余量**。⚠️ **必须把"零余量"这个事实写进参数表**,并登记第三台的**触发条件**(方案里不得出现"余量充足"这类不实表述)。 +5. **打洞探测的观察面必须是我们自己的机器**(首选:47 上一次性 UDP 观察器,测完即停),⛔ 不优先依赖公网 STUN(降级手段,且须在 §8 标注"经第三方")。 +6. **五项的判定口径一律"实测优先、取不到如实记"**:⛔ 任何一项**都不许用估值顶替**;取不到就写"取不到 + 卡在哪 + 什么条件一出现必须回头"。 + +### 4.2 交给执行棒自决(⛔ **不上升为提问**) + +1. `WAN_STEADY_THROUGHPUT` **怎么造可控载荷**(本单给判定顺序:① 复用既有端点声明机制新增一个临时端口 → ② 复用已声明但空闲的端口 → ③ 降级为"经 relay 的实例面链路吞吐"并如实标注口径); +2. 106 上 relay 的**绑定方式**(P5 探明后按序:**有 nginx ⇒ 复用 443,零新增口** → 无 nginx ⇒ 装 nginx 复用 443(标准组件、不新增监听)→ 都不可行 ⇒ 停下报告,⛔ 不许开新口); +3. 合成节点的**工程实现**(脚本落 `scripts/`,用 Node 22 内置能力,不引依赖); +4. 样本点的具体分布(⚠️ 本单给的是**下限**,允许加密); +5. 所有输出文件落在 `_中间产物_待清理/` 下的临时目录,收口时**只保留正式产物**(任务收尾纪律)。 + +### 4.3 真需要用户拍板的(**命中才问,且一轮只问这一句**) + +**第 4/5 台真机的来源** —— 本单能用现有资源跑满 3 台(47 / 106 / 本机),**打洞分层样本需要"家宽 / CGNAT / 移动 / 企业网"四类环境,云主机给不出**。候选(各有优有劣 ⇒ 才上抛): + +**A · 只用现有 3 台先跑满能跑的部分** —— 优点:零成本、零等待、今天就能开工;缺点:打洞率的**样本偏差大**(3 台里 2 台是云),该项大概率只能拿"环境可打洞性"的**定性**结论,写不回一个可信的百分数。 + +**B · 由用户自备设备(手机热点 / 家里的宽带上的一台机器)跑一次性探测脚本** —— 优点:样本最贴近真实目标场景("同一人的几台设备")、零花费、脚本是一次性的(跑完给 JSON 即可);缺点:需要用户动手,且会占用用户设备一小段时间。 + +**C · 新开 1–2 台轻量云主机(跨运营商 / 跨地域)** —— 优点:样本可控可复现、随时可扩到 5 台;缺点:**要花钱**,且云主机的 NAT 行为与家宽/CGNAT 仍不同构(花了钱也解决不了分层问题)。 + +**我的倾向:A 立即开工 + B 并行补样本,C 不动**(理由:C 花了钱还解决不了主要的样本偏差;A+B 组合零成本且能覆盖真实场景)。⇒ **执行棒按 A 开工,不必等这一刻**(B 是"什么时候给什么时候补")。 + +### 4.4 技术实现裁决顺序(⚠️ 与 `dsh-decision-method §4.4` 一致) + +先取"**不改结构的**方案"→ 再取"**改一处、可回滚的**" → 最后才考虑"新增面/新增依赖的"。⛔ 不允许用"新引入一个组件"来绕过取证。 + +--- + +## §5 步骤(S0–S9;**每步自带一次可执行的验证**) + +### S0 · 只读取证(= §2 P1–P8) +**验证**:P1–P8 全部有原文输出,且 P3 指纹、P7 基线、P8 的 FAIL 清单已落 §8.1。 + +--- + +### S1 · 组起 3 台节点(最小形态第一步) + +**做**:47(Manager+relay+w-47)/106(Worker w-106)/**本机(第三节点)**。 +本机节点 = 一个一次性进程(脚本,只拨出,不声明端口或只声明 1 个 echo 口),用于提供"非云的第 3 个网络视角"。 +**验证**:47 上 `curl -s 127.0.0.1:20080/status` 的 `online[]` / `sessions[]` **长度从 2 变 3**(新增本机节点),且本机节点 `via` 非空。 +**判据**:3 台全部在册 ⇒ S2 起可测;若本机节点起不来(Windows 侧限制)⇒ **如实记"3 台降为 2 台"**,S2/S3 照常跑(照 2 台口径标注),⛔ 不编第 3 台的数。 + +--- + +### S2 · `WAN_STEADY_THROUGHPUT` 实测(写回参数表 **§3.5**) + +**为什么这步第一优先**:它是 §5.4 明确"当前取不到、必须回来重算"的那个数,且是 §5.2 的**潜在绑定约束**。 + +**怎么测**:两端可控载荷。判定顺序(§4.2-1): +1. **首选**:在 106 上把一次性 HTTP 大响应服务绑在 `127.0.0.1:<PORT>`,**经既有端点声明机制**(relay `PORT_ADD`)把它纳入 relay 映射;在 47 上经 relay 拉取。 +2. **次选**:复用已声明但空闲的端口(P4 的 `/status.endpoints[]` 里挑一个不在用的)。 +3. **兜底**:直接测"经 relay 的实例面链路"的稳态速率,**口径如实写成"经 relay 转发的实例面吞吐"**,⛔ 不得冒充裸链路吞吐。 + +**样本**:**≥ 5 次**,每次 ≥ 30 s 稳态段(去掉前 3 s 建连/爬升),载荷 ≥ 8 MB;记录 `bytes` / `sec` / 速率。 +**判据**:报**中位数**(⛔ 不用峰值、不用单次最好值);同时报 min/max 说明离散度。 +**写回**:`参数表 §3.5` → `WAN_STEADY_THROUGHPUT`,**等级改 `实测`**,**来源定位写"本单 §8.x + 完整命令"**,并在**同一行的备注里写清口径**(谁到谁 / 是否经 relay / 稳态段长度)。 + +--- + +### S3 · `JITTER_LINK_MEASURED` 实测(写回参数表 **§3.5**)+ **relay RTT 口径校验** + +**做两件事**(不要只做第一件): + +**(a) 链路 jitter 实测** +- 命令形态:`ssh -p 22 bt-server 'ping -c 300 -i 0.2 -W 1 <106 公网 IP>'`(反向再做一次)。 +- 样本:**≥ 200 包**(300 包余量更稳)。 +- 取数:`rtt min/avg/max/mdev` + **自算 `p95(|ΔRTT|)`**(相邻包 RTT 差的 95 分位)——⛔ 只用 `mdev` 会低估抖动。 +- **判据**:与参数表 `JITTER_LIMIT_MS`(20 ms,**估值口径**)对照给出**达标/不达标**二元结论。⚠️ 预期**不达标**(同链路 relay 路径 RTT 已实测 336 ms)——**如实写"不达标"**,并按 §5(a) 的下一条处理。 + +**(b) 🔴 relay `rttMs` 口径校验(本单新增的关键验证)** +- 参数表把 `RELAY_RTT_W106 = 336 ms` 标成"实测",但**它是 relay 心跳往返**(`server.ts:810` 注释:①②③ = 测 RTT / 察觉半开 / 保 NAT 表项),**可能含应用层处理与验签耗时**,**不一定等于网络 RTT**。 +- ⇒ **三方对比**:`ICMP RTT`(ping)|`TCP 握手 RTT`(`ssh -p 22`或到 443 的 `curl -w %{time_connect}`)|`relay 心跳 rttMs`。 +- **判据**:若 relay `rttMs` 与 ICMP RTT **差距 > 2×** ⇒ 在 `参数表 §3.5` 给 `RELAY_RTT_W106` **加一条口径备注**("心跳往返,含应用层,⛔ 不等于网络 RTT"),并在 `OBS` 侧登记"relay rttMs 不得当链路 RTT 用"。 +- ⚠️ 这条**不是可选项** —— 336 ms 目前是"跨云链路很差"的唯一证据,若它其实是口径问题,后面所有关于"跨云不可玩"的结论都要重判。 + +**写回**:`参数表 §3.5` → `JITTER_LINK_MEASURED`(等级改 `实测`;值 = 中位数 + p95 两个数,注明取哪个作为判定值);`RELAY_RTT_W106` 加口径备注。 + +--- + +### S4 · `HOLE_PUNCH_RATE_LOCAL` 实测(写回参数表 **§3.2**) + +> 🔴 **口径见 §4.1-1**:测的是「**该网络能不能打洞**」,⛔ 不是"本系统打洞成功率"。 + +**首选路径(不依赖第三方)**: +1. 47 上起**一次性 UDP 观察器**(脚本,绑定一个高位口,**仅测期监听、测完立即停**):收到包即回显 `{from: <对端源 ip:port>, mapping: <看到的源地址>}`。 +2. 各节点向它发 5 包 ⇒ 拿到**本节点的 UDP 公网映射**。 +3. 节点两两互发:双方各自向对方映射每 200 ms 发 1 包、共 10 包,同时收包。 +4. **判定**:任一方收到对方 ≥ 1 包 ⇒ 该对"**可打洞**"。 +5. 逐对记录,并**标注两侧网络类型**(家宽 / CGNAT / 移动 / 企业网 / 云)。 + +**降级路径**:47 的 UDP 入站不可达 ⇒ 用公网 STUN 做映射/过滤行为判定(RFC 3489 简化版:同服务器不同端口 + 不同服务器对比映射是否变化);⛔ **须在 §8 注明"经第三方"**。 +**兜底**:两者都取不到 ⇒ **如实写"取不到"** + 卡在哪 + 回头条件(拿到非云环境即可补测)。 + +**样本**:3 台 ⇒ **3 对**;若第 4/5 台到位 ⇒ **10 对**(每对 10 次尝试)。**打洞率 = 成功对次 / 总尝试次**,并**分层分别给**(⛔ 不给单一的合并百分数就完事)。 +**写回**:`参数表 §3.2` → `HOLE_PUNCH_RATE_LOCAL`,**值列写"分层结果"**,等级改 `实测`,注明**样本量**("n=3 对,云节点占 2/3,**不代表家宽场景**"这句话必须写进去)。 +**权限附注**:47 上的 UDP 观察口 = **临时入站面** ⇒ 在 §8 **显式列出**(对象 / 端口 / 开放时长 / 关闭证据),并回填 `参数表 §8`(新增一行,结论栏按实际)。 + +--- + +### S5 · `PER_PLAYER_BW_LOCAL` 实测(写回参数表 **§3.3**) + +**为什么不能"直接测每玩家带宽"**:仓库里**没有游戏/应用层**(`清单 §五` 第 7 步才谈内容分发与游戏)⇒ 没有真实玩家协议可测。**如实处理**: +**测法**:合成玩家载荷 —— 参数扫描(消息率 **5 / 20 / 50 msg/s** × 消息 **200 B** × 玩家数 **10 / 50**),在真机间经 relay 跑 **60 s**。 +**取数**:每档的**端到端 p50/p95 单向时延**、**丢包率**、**实际吞吐(KB/s)**。 +**判据**:`PER_PLAYER_BW_LOCAL` = "**在 p95 时延 ≤ 2× p50 且丢包 = 0 的前提下,每玩家可达的最大上行速率(KB/s)**"。 +**写回**:`参数表 §3.3`,等级改 `实测`,**并在该行备注里明确写出边界**:「本值 = **传输层上限**;**游戏协议的真实需求仍是估值**(`PER_PLAYER_BW_TEXT/BATTLE/SIEGE`)」——⛔ 不得让读者误以为这是"实测出的游戏需求"。 + +--- + +### S6 · `MEM_PER_HOST_MB` 校准(写回参数表 **§5.1**) + +**为什么必须放大测**:现网 `used = 2`,RSS 只反映 Node 基座(参数表 §5.1 已注明)⇒ **2 MB/台 是推导值,不是实测**。 + +**怎么测(干净方案,⛔ 不污染生产)**: +1. **本机起一个独立 relay 实例**(高位回环口,**独立于生产的 20080**); +2. 起 **N 个合成 client**(一次性脚本)连它,每个声明 1–2 个端口; +3. 量 relay 进程 RSS。 +**样本点**:`N = 2 / 10 / 25 / 50 / 100`(⚠️ 这是下限,允许加密)。 +**判据**:① 各点记录 RSS;② **线性回归** `RSS(N) = a + b·N`,`R² ≥ 0.9` 方为有效(否则说明有非线性跳跃,须找出跳点并**在 §8 如实报告**,⛔ 不许硬套斜率);③ **`MEM_PER_HOST_MB` = b(KB/台 → MB/台,向上取整 + 20% 余量)**。 +**写回**:`参数表 §6` → `RELAY_RSS_MAX_KB` 哨兵重新核算(若新斜率与 2 MB 差异 > 50% ⇒ 哨兵必须跟着改);`参数表 §5.1` → `MEM_PER_HOST_MB` 等级改 **`实测`**,来源定位写"本单 §8.x(N=2..100 斜率)"。 +**权限附注**:纯本机回环、零公网面 ⇒ ⛔ 不动 47 的任何配置。 +**顺带(同机合批,不额外开步)**:参数表 **§9 第 4 行**(relay 无 `MemoryMax`)——**只测量、只登记**,⛔ 本单**不改单元语义**(加 cgroup 上限会引入 OOM-kill 新失败模式,属 R11 的"净变差"风险)。 + +--- + +### S7 · 🔴 回头条件强制执行(**本单写死的硬门**) + +在 S2 / S6 出数之后**立即执行**(⛔ 不允许"下次再说"): + +**触发条件**:`MEM_PER_HOST_MB` **或** `WAN_STEADY_THROUGHPUT` 从"推导/待测"换成"实测"。 + +**必做七件**(逐条落到 §8): +1. 重算 `C_MEM = floor(MEM_BUDGET_MB / MEM_PER_HOST_MB_new)`; +2. 重算 `C_RELAY = min(C_MEM, C_FD)`;**并重新判断带宽是否仍不参与 `min`**(若新实测吞吐使 225 台的控制面+实例面流量逼近实测吞吐 ⇒ **带宽进 `min`**,这是 §5.4 预留的口子); +3. 重算 `RELAY_MAX_HOSTS = floor(C_RELAY × DESIGN_MARGIN)`; +4. **重跑 §5.2 三条校验**(防自锁 `> used×4` / 余量自洽 / 千台需求 `≤ 225 × 2 = 450`); +5. **重下发**:47 上**重写 drop-in `capacity.conf`**(⚠️ **必须重写 ExecStart 而不是只加 `Environment=`** —— 主单元已有显式 `--max-hosts 0`,**CLI 优先于 env**,只设 env 会被静默忽略)→ `daemon-reload` → `restart dshs-relay`;**106 上的第二中继同值同步**; +6. **复验**:`overlay-probe.cjs` 的 `OBS-02`(`max = RELAY_MAX_HOSTS` 且 `free = max - used`)必须 PASS; +7. **回写**:`参数表 §5.2`(含**变更前后对照**:旧值 → 新值 → 为什么变)+ `§10 指纹`+工作区日志记一笔"哪个键从什么换成什么"。 + +**若未触发**(某项确实没换成实测)⇒ 在 §8 **明写"未触发,因为 X 没换成实测,卡点是 Y,回头条件是 Z"** —— ⛔ 不许默认跳过。 + +--- + +### S8 · L3 第二中继机落地(106 升格,**与上面合批**) + +**依据**:`清单 §3 关键决定`(有公网 IP 的节点自动升格中继候选)+ 参数表 **§5.2 校验③**(需 ≥2 台)。 + +**顺序**: +1. **权限影响评估更新(先做,R5)**:把 `参数表 §8` 第 ④ 行从"只评估不实施"改为**实施态**,逐项写明:**新增监听口(几个、哪个、公网还是回环)** / **新增凭据(节点密钥签发)** / **是否改变 106 "入站 = 0" 这条已收窄成果**。⛔ 只能用「收窄 / 维持」二选一作结论;**确有扩大 ⇒ 在 §8 逐条列出**(陈述式,不是征询)。 +2. **铺 relay 到 106**:本机 `npm run build`(Node 22)→ scp 到 106 的 `/opt/dsh-relay/`(⚠️ 与本线既定纪律一致:**本机 build 后 scp**;relay 代码真身在 `/opt/dsh-relay/lib/`,⛔ 不是 `/opt/dshs/lib/`)。 +3. **签发 106 的 relay 身份**:用既有密钥仪式 CLI `scripts/overlay-keyring.cjs`(序③ 产物);relay 侧 keys 表用**逻辑名 `<net>/<hostId>`** 索引。 +4. **绑定方式**:按 §4.2-2 判定顺序(**首选复用既有 443**,零新增口)。 +5. **加入 seeds 广播**:把第二中继项写进 `DSHS_OVERLAY_BOOTSTRAP_SEEDS` **或** 签名目录 `relays[]`(⚠️「改一次 seeds 全网刷新」是既定机制;注意 `relays[]` **顺序语义 = 主入口首位**,⛔ 第二中继**不得**插到首位)。 +6. **两台各自设 `--max-hosts`**(值 = S7 重算结果;若 S7 未触发则沿用 225)。 +7. **端到端验收(见 §6 E9)**。 + +**硬约束**:⛔ 不新开非 443 公网口;⛔ 不改安全组;⛔ 不动 47 的角色。 + +--- + +### S9 · 回填 + 不退化 + 收口 + +1. **参数表回填**:§3.2 / §3.3 / §3.5 / §5.1 / §5.2 / §7(`待测` 行清空或写明取不到)/ §8 / §10 指纹。 +2. **不退化检查**(逐条对照 S0 的 P7/P8):`ss -lntp | wc -l`、`nft list ruleset | wc -l`、门户 `200`、双实例面、`npm test`(**Node 22**)。 +3. **临时产物清理**:探测/压测脚本与中间输出按 §4.2-5 处理,**只保留正式产物**。 +4. **收口三件(⛔ 缺一即算本条未完成)**: + ① `--release-exec` 释放锁; + ② **登记下一棒**(序 ⑥ 执行棒的后续 / 或按实际收口点到的一棒)并**用陈述句在回复里告知用户**("已登记自动接续,约 N 分钟后自动开新会话,不用你操作;接续点 = X"); + ③ 写工作区日志 `.workbuddy/memory/2026-09-17.md`。 + +--- + +## §6 验收(判据清单;命令 + 期望输出,可被第三方复现) + +| # | 判据 | 期望 | +|---|---|---| +| **E1** | 参数表 `待测` 单元格数量 | **4 → 0**(全部换成实测 **或** "取不到 + 原因 + 回头条件");`MEM_PER_HOST_MB` 等级 `推导 → 实测` | +| **E2** | 每个新填值的"来源等级" | 全部为 `实测`,且每行都有**一条能跑的命令**(复现入口) | +| **E3** | 参数表 §10 指纹 | 已更新,且新值 + 旧值都记在 §8 | +| **E4** | `WAN_STEADY_THROUGHPUT` | ≥ 5 样本 + 中位数 + **口径三要素**(谁到谁 / 是否经 relay / 稳态段长度) | +| **E5** | `JITTER_LINK_MEASURED` | ≥ 200 包 + `p95(|ΔRTT|)` + **ICMP / TCP / relay 三方对比表** | +| **E6** | 打洞探测 | 逐对结果 + **分层标注** + 样本量 + "不代表家宽场景"这句在表内 | +| **E7** | `MEM_PER_HOST_MB` | `N = 2/10/25/50/100` 五点 + 斜率 + `R²`;非线性则如实报告跳点 | +| **E8** | 🔴 **回头条件已执行** | §5.2 三条校验**重跑记录** + 两台 `capacity.conf` 重下发证据 + `OBS-02` PASS(**或**明写"未触发 + 卡点 + 回头条件") | +| **E9** | 第二中继可用 | 两台 relay 各自 `/status` 有在册会话;**杀掉任一台 ⇒ 客户端在 `DIRECTORY_REFRESH_SECONDS`(300 s)内、实测应在 15 s 心跳级切到另一台**(附日志片段) | +| **E10** | 暴露面 | `ss -lntp` / `nft` 行数**与 S0 一致**(或新增项**逐条列出** + 参数表 §8 已回填) | +| **E11** | 不退化 | `npm test` 全绿(Node 22)|双实例面 200/401(∈ `PROBE_CODE_SET`)|门户 `200` | +| **E12** | 收口三件 | 锁已释放 + 下一棒已登记**并已陈述句告知** + 日志已写 | + +--- + +## §7 回滚 + +| 对象 | 回滚动作 | 粒度 | +|---|---|---| +| 47 的 `--max-hosts` | 删 drop-in `capacity.conf` → `daemon-reload` → `restart dshs-relay`(回 `--max-hosts 0` = 不设限) | 秒级 | +| 106 第二中继 | `systemctl stop/disable dshs-relay`(106)+ 从 seeds / `relays[]` **移除**该项(改一次全网刷新)+ keyring CLI **吊销**该节点密钥 | 分钟级 | +| 打洞/压测脚本 | 全在 `scripts/`,**删文件即可**(`src/**` 零改动 ⇒ 产品路径零回滚需求) | 秒级 | +| 本机独立 relay | 进程退出即消失(无持久化、无开机自启) | 秒级 | +| 参数表 | 保留改动前后的**值对照表**(§8 内),可反向还原;⛔ 不做整文件覆盖式还原(指纹自指,易错) | — | + +**备份要求(动手前)**:47 `/opt/dsh-relay/` 铺前打包;`/etc/systemd/system/dshs-relay.service.d/*.bak-<step>-<ts>`;`/etc/dshs/relay-keys.json.bak-<ts>`;106 侧同理(首铺无旧件 ⇒ 记录"无旧件")。 + +--- + +## §8 回报格式(执行棒按此格式收口;沿用序⑤ 单的分节) + +``` +## §8 执行回报(执行棒 · 2026-09-17 10:12 → 11:05) + +> **证据等级标记(本单统一口径)**:`【实测】`= 本轮现场跑出来的;`【留档缺口】`= 当时未单独留存、只有结论(**不补造**)。 + +### 8.1 S0 快照(P1–P8:命令原文 + 原文输出 + 判定) + +| # | 命令(原文) | 记录 | 判定 | +|---|---|---|---| +| **P1** | `bash …/handoff-guard.sh --claim-exec "覆盖网络线-序6执行棒"` | `✓ 已持全局执行锁`;收口前复核 OWNER = `覆盖网络线-序6执行棒`,起始 `09-17 10:12` | ✅ | +| **P2** | `python state.py` | 锁被占用(owner = 我)|HEAD = `640813e`|入口 = `接续入口_覆盖网络线_20260916.md` | ✅ | +| **P3** | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`f3e68012698abb352549e2560746d992`** —— 与单内期望**逐字一致** ⇒ 单内行号/§7 计数有效,未偏航 | ✅ | +| **P4** | `ssh bt-server 'systemctl cat dshs-relay …; cat …/capacity.conf'` | 47 relay drop-in 已带 **`--max-hosts 225`**(序⑤ 下发);主单元 ExecStart 确有显式 `--max-hosts 0` | ✅ | +| **P5** | `ssh test106 'hostname; nproc; free; systemctl is-active nginx dshs-relay; ss; ls -d /opt/dsh-relay /opt/dshs-cluster'` | 106 有 nginx —— **但由宝塔托管**(master = `/www/server/nginx/sbin/nginx`,属 `bt.service`)⇒ `systemctl is-active nginx` = `inactive` **是正常态、不是故障**(收口时复核同值);443 已被占 ⇒ **S8 走"复用 443"分支,零新增公网口**;`/opt/dsh-relay` **不存在** ⇒ 首铺 | ✅ | +| **P6** | `node -v && ls lib/net/relay/` | `v22.22.2` + `lib/net/relay/*.js` 已 build(未触发补 build) | ✅ | +| **P7** | `ssh bt-server 'ss -lntp\|wc -l; nft list ruleset\|wc -l; curl …3080; curl …20080/status'` | 基线 **`79` / `72` / `200` / `capacity{max:225,used:2}`** —— 与单内期望**完全一致** | ✅ | +| **P8** | `node scripts/overlay-probe.cjs` | 12 行 + 退出码 `1`(有红项)。⚠️ **【留档缺口】S0 时刻的原始 FAIL 清单未单独留存**(首次运行已发生在 S 段推进中)⇒ 见 §8.8-3 | ⚠️ | + +### 8.2 五项的实测值(怎么测 / 样本量 / 值 / 等级 / 写回位置) + +| 项 | 怎么测(复现入口) | 样本量 | 值 / 等级 | 写回 | +|---|---|---|---|---| +| `HOLE_PUNCH_RATE_LOCAL` | `overlay-holepunch.cjs --stun`(首选"47 双 UDP 观察器"路径**失败**,按 S4 降级走公网 STUN) | **n = 3 对** | **2/2 可打洞**;映射 `125.83.247.110:33742`(两次 STUN 一致 ⇒ **cone 型**)/云机 `:21200`;⛔ 表内已写"云节点占 2/3,**不代表家宽场景**" + **方向性限制**(本机→云机被安全组拦,真打洞须成功后回摆)。等级 = **实测(分层)** | 参数表 §3.2 | +| `PER_PLAYER_BW_LOCAL` | `overlay-wan.cjs --players`(7 档扫描) | 10 / 50 玩家 × 5/20/50/200 msg/s | **9.8 KB/s(10 玩家)|3.9 KB/s(50 玩家)**;聚合天花板 **200–350 KB/s**;崩坏点:50 玩家 @50 msg/s(丢包 91%)、10 玩家 @200 msg/s(丢包 85.6%)。等级 = **实测**(边界写明"传输层上限,游戏需求仍是估值") | 参数表 §3.3 | +| `JITTER_LINK_MEASURED` | `overlay-jitter.cjs --icmp … --count 300 --interval 0.2` + `--tcp …` | ICMP 各 300 包 × 双向 + TCP 30 次握手 | **`p95(|ΔRTT|)` = 3 ms ⇒ 达标**(对 `JITTER_LIMIT_MS = 20`,且 20 是业界估值口径、已标明)。**三方对比**:ICMP 148.29 / TCP 153 / **relay 344–376** ⇒ relay 是真 RTT 的 **2.3×**(故 `RELAY_RTT_W106` 已加口径备注:**心跳往返,含应用层+验签,≠ 网络 RTT**)。等级 = 实测 | 参数表 §3.5 | +| `WAN_STEADY_THROUGHPUT` | `overlay-wan.cjs --serve/--download/--upload`(**尊重反压**:`write()` 返 false 必等 `drain`) | 每向 5 样本取中位数 | **下行 352 KB/s(106→47)|上行 12213 KB/s(47→106)**;口径三要素齐全。**决定性校验**:relay `/status` 计数 `in=1905328505B` 与 106 侧 `rchar=1905380725` 吻合 ⇒ 12 MB/s 确实跨了 WAN;106 公网出带宽封顶 ≈2.8 Mbps 正是下行 352 的成因。`WAN_UP_BOUND_KBPS` **192 → 作废**(下界偏低 64×) | 参数表 §3.5 | +| `MEM_PER_HOST_MB` | `relay-mem-calibrate.mjs`(本机独立 relay 子进程 + 同进程内 N 个 `RelayClient`;RSS 取 7 次采样中位数) | 6 点(N=2/10/25/50/100/150) | **0.06 MB/台**(斜率 46.7 KB/台,**R² = 0.9424**)⇒ `2 → 实测`;原"2 MB/台"**高估 36×**(把 per-stream 256 KB 当成了 per-host)。**首跑 R²=0.487 已作废**(单次采样噪声),修正过程记在 8.5。等级 = 实测 | 参数表 §5.1 | + +### 8.3 参数表 diff 摘要(旧值 → 新值) + +| 键 | 旧 | 新 | +|---|---|---| +| `HOLE_PUNCH_RATE_LOCAL` | 待测 | 分层实测(2/2,n=3 对,云节点 2/3) | +| `PER_PLAYER_BW_LOCAL` | 待测 | 9.8 / 3.9 KB/s(10/50 玩家) | +| `JITTER_LINK_MEASURED` | 待测 | `p95(|ΔRTT|)` = 3 ms(达标) | +| `WAN_STEADY_THROUGHPUT` | 待测 | 352(106→47)/ 12213(47→106)KB/s | +| `WAN_UP_BOUND_KBPS` | 192 | **作废**(下界偏低 64×) | +| `MEM_PER_HOST_MB` | 推导 2 | **实测 0.06** | +| `C_MEM` / `C_RELAY` | 501 | **16700** | +| `RELAY_MAX_HOSTS` | **225** | **7515** | +| §5.2 校验③ 结论 | `450 > 225` ⇒ 容量上必须 ≥2 台 | **结论变更**:容量上单台即够;2 台依据改为"公网节点升格"+"跨机真容灾" | +| §5.4 带宽判定 | 旧 | 用实测重判"带宽不进 `min`",登记实例面单次 46.3 MB ≈ **135 s 时延上界** | +| §7 待测项计数 | 4 + 1 待校准 | **0 + 0**(回头条件标记"已执行") | +| §8 权限影响 | ④ 单条 | ④ 改**实施态**(新增监听口 0 / 新增凭据 0 / 106 入站仍为 0,**结论维持**)+ 新增 ⑧(临时 UDP 观察口,已关闭)⑨(106 的 443 `location /dshs-relay`)—— 两条**结论均维持** | +| §9 已知边界 | 4 行 | **8 行**(新增:无失败切流 / per-stream 内存未测 / 106 无 bootstrap / 宝塔管 vhost) | +| §10 指纹 | `f3e68012698abb352549e2560746d992` | **`db1317c2f7aaef7b47785c1f4fc9de03`** | + +### 8.4 E1–E12 逐条 + +| # | 现场证据 | 判定 | +|---|---|---| +| **E1** | `grep -c 待测` = 8,**逐条核对全部落在**:§7 标题/计数口径说明/图例行 `\| **待测** \|` —— **数据单元格 0 个**(4 → 0);`MEM_PER_HOST_MB` 等级 `推导 → 实测` | ✅ | +| **E2** | 五个新值全部标 `实测`/`实测(分层)`,每行带一条可跑命令(见 8.2 第 2 列) | ✅ | +| **E3** | 新指纹 `db1317c2…`、旧指纹 `f3e68012…` 均已记(本节 8.3 + 8.9) | ✅ | +| **E4** | 5 样本中位数 + 口径三要素(谁到谁/是否经 relay/稳态段长度)+ relay 侧与 106 侧字节数交叉校验 | ✅ | +| **E5** | ICMP 300 包 × 双向(≥200)+ `p95(\|ΔRTT\|)` + **ICMP/TCP/relay 三方对比表**(并在表内标明 20 ms 是估值口径) | ✅ | +| **E6** | 逐对结果 + **分层标注** + n=3 对 + 表内明写"不代表家宽场景" + 方向性限制 | ✅ | +| **E7** | 六点 `N = 2/10/25/50/100/150` + 斜率 46.7 KB/台 + `R²=0.9424`(非线性/跳点不适用;首跑 R²=0.487 已作废并记因) | ✅ | +| **E8** | 回头条件**已触发**:§5.2 三条校验**全部重跑**(① 防自锁 `7515 > 4×4` ✅ ② 见参数表 ③ `450 ≤ 7515` 结论变更)+ **两台** `capacity.conf` 重下发(47/106 `"max":7515`)+ **`OBS-02` 复验 PASS**(`max=7515 used=2 free=7513`) | ✅ | +| **E9** | 两台 relay 各自 `/status` 可见在册会话(47:`manager`/`w-106`;106:`max=7515` 就绪,443 入口 **WS 101**)。**前半绿**:杀掉 106 后 w-dev 断连并 **1.2 s 内**自动重连回同一台。**后半红**:**不会切到另一台** —— ⇒ 见 §8.8-1 | ⚠️ **半绿** | +| **E10** | 收口后 `ss -lntp \| wc -l` = **79**(= S0 基线)、`nft` = **72**(= 基线);106 = 14 个监听(基线 14 + relay 的 `127.0.0.1:20080`);参数表 §8 已逐条回填(新增 ⑧⑨) | ✅ | +| **E11** | `npm test`(Node 22)= **138 tests / 137 pass / 0 fail / 1 skipped**;双实例面 `本机:20000=401`/`w-106=401`(∈ `PROBE_CODE_SET`);门户 = **200** | ✅ | +| **E12** | 锁 `--release-exec` 已释放;下一棒已登记 automation **并已陈述句告知**;工作区日志已写 | ✅ | + +### 8.5 🔴 S7 回头条件(触发与否 + 重算过程 + 重下发 + `OBS-02` 复验) + +1. **触发**:`MEM_PER_HOST_MB` 2 → 0.06、`WAN_STEADY_THROUGHPUT` 待测 → 352/12213 ⇒ 命中"实测值替换后必须重算容量"。 +2. **重算**:`C_MEM = floor(1002 / 0.06) = 16700` → `C_RELAY = min(16700, 65536) = 16700` → `RELAY_MAX_HOSTS = floor(16700 × 0.45) = 7515`。三条校验重跑,**结论③ 变更**(单台容量即足够)。 +3. **重下发(关键坑位)**:主单元 ExecStart **已有显式 `--max-hosts 0`**,**CLI 优先于 `Environment=`** ⇒ 只设 env 会被**静默忽略** ⇒ 两台一律用 **drop-in 重写 ExecStart**:`47-capacity.conf` / (106 同款) → `daemon-reload` → `restart dshs-relay`。 +4. **复验**:47 `/status` `"max":7515,"used":2,"free":7513`;106 `/status` `"max":7515`;**`OBS-02` PASS**。 +5. **校准方法自纠**:首跑 `R² = 0.4871` 不合格 ⇒ 定性为**单次 RSS 采样噪声** ⇒ 每点改 **7 次采样取中位数** 并补第 6 点(N=150)⇒ `R² = 0.9424`。**首跑结论已作废、未写进参数表**。 + +### 8.6 第二中继(106)落地 + 切流验证 + 权限影响评估更新版 + +| 项 | 结果 | +|---|---| +| 落地 | `/opt/dsh-relay/`(build 后 scp)+ 新单元 `dshs-relay`(**只绑 `127.0.0.1:20080`**)+ 节点密钥 `ops/w-106`;**首铺,无旧件** ⇒ §7"记录无旧件"已满足 | +| 443 暴露 | 走 §4.2-2 自决:**复用既有 443**(`location /dshs-relay` 挂在 `include …/extension/106.54.21.172/*.conf` 里 ⇒ 落在既有 server 块**内部**)。实测 **WS 升级握手 = 101**、`/nope` = **404**。**零新增公网口**(安全组未动) | +| seeds 广播 | 47 的 `…-443fb.conf` 把 `https://106.54.21.172/dshs-relay` 追加到 `relays[]` **末位**(不动首位主入口) | +| 切流验证 | **前半绿 / 后半红** —— 详见 §8.8-1(**单外发现,只报告不动手**) | +| 权限影响(§8④ 更新版) | **新增监听口 = 0**(复用 443)|**新增凭据 = 0**(节点密钥落在既有 `/etc/dshs/relay-keys.json`,按 `<net>/<hostId>` 逻辑名索引)|**106 入站 = 0**(worker 永远只拨出)⇒ **R5 结论维持**:暴露面未扩大 | +| 收口清理(S9-3) | 两个临时节点密钥 **已吊销**(`ops/w-dev`、`ops/w-106p`;两台 keys 表 5 → **3 条**,回到基线 `manager`/`w-106`/`w-47`,先备份后原子写、`loadKeysFile` 自校验);探针目录 `/opt/seq6-probe`(47/106)、`/tmp/seq6-*`、106 的 `node-w-106p.*` **均已删**;复核 `pgrep` 零命中 | + +### 8.7 不退化(S0 对照 / 双实例面 / 门户 / `npm test`) + +| 项 | S0 基线 | 收口 | 判定 | +|---|---|---|---| +| 47 `ss -lntp \| wc -l` | 79 | **79** | ✅ 完全一致 | +| 47 `nft list ruleset \| wc -l` | 72 | **72** | ✅ | +| 门户 | 200 | **200** | ✅ | +| 双实例面 | 200/401 | `本机:20000=401`、`w-106:41775=401` | ✅ ∈ 码集 | +| `npm test`(Node 22) | — | 138 / 137 pass / 0 fail / 1 skip | ✅ | +| `overlay-probe` | 12 行(见 8.1-P8) | **12/12 PASS**(`OBS-02` `OBS-08` `OBS-11` 三红**全部转绿**) | ✅ | +| 47 relay 会话 | `manager` / `w-106` | 同(重启后自动重连,`identityOk=2`) | ✅ | + +> **三红转绿的根因(诚实记录)**:`OBS-11`/`OBS-08` 的红**不是泄漏**,是 S1/S8 期间**临时探针会话在 relay 内存里留下的 2 条离线端点**(relay 每个 endpoint 会占 1 个本地监听 ⇒ 79 → 81)。S9 清理 + relay 重启后端点表回到 2 条、监听口回到 79。`OBS-02` 的红是**探针解析坑**(见 8.8-2),修的是**参数表书写**,不是改脚本。 + +### 8.8 未过项 / 遗留 + +1. 🔴 **E9 后半(无"失败切流")—— 本单唯一未过项,根因已定位**:客户端把 relay url **在首次解析后钉死** —— `main.js --client` 无重解析;worker 走 `DSHS_RENDEZVOUS_URL` 同样不吃引导链;只有 Manager 的**拨号通道**有周期重解析(`web/server.ts#refreshOverlay`),而它的换址条件是"**目录里的地址变了**",与"当前 relay 挂了"**无关**。⇒ **已做到哪一步**:第二中继本身可用(101 + 容量就绪 + seeds 已广播)、断连自动重连成立(1.2 s)。**什么条件一出现必须回头解决**:要做**多中继负载分担**或**真容灾切换**时,必须先补"连接失败后重解析 + 排除已失败 relay"这段**新功能**。已登记参数表 §9 第 5 行。 +2. ⚠️ **`OBS-02` 假红的解析坑(已修,须防复发)**:探针 `KEY_RE` 取参数表**行内整格**并 `cleanValue`(只剥 `*` / 反引号)⇒ 值格里写 `**7515**(原 225)` 会被当成 `7515(原 225)` ⇒ `NaN` ⇒ 假红。**已把夹注挪出值格**,并在参数表该行写明"值格必须是纯数字"。**回头条件**:以后任何键改值,⛔ 别往值格塞夹注。 +3. ⚠️ **【留档缺口】P8 的 S0 原始 FAIL 清单未单独留存**(详见 8.1-P8)。已做到哪一步:收口状态 12/12 PASS 有据可查。**回头条件**:下一棒若仍以探针作对照,**开跑即先存一份原始输出**(`> /tmp/xxx.txt`)。 +4. ⚠️ **per-stream 内存开销仍未测**(`MEM_PER_HOST_MB` = 0.06 只是空闲会话斜率):已登记参数表 §9 第 6 行。**回头条件**:`--max-hosts` 若重新收紧,必须先有本数。 +5. ⚠️ **106 的 `127.0.0.1:40179` 在收口时已不在监听**(P5 快照里有):非本单所留(本单在 106 只碰 `19777/19778/20080`)⇒ 如实登记,未追查,**亦未顺手修**(R7)。 +6. ⚠️ **106 的 `systemctl is-active nginx` = `inactive`**:由宝塔(`bt.service`)托管 nginx,**不是退化**;但**任何"用 systemctl 判 106 nginx 死活"的脚本都会误判** ⇒ 记入运维注意。 + +### 8.9 指纹(本单收口后的可复现核对口径) + +- **参数表**(§10 不计入): + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` + ⇒ **`db1317c2f7aaef7b47785c1f4fc9de03`**(S0 = `f3e68012698abb352549e2560746d992`) +- **本交接单**(**§8 及其后不计入** —— 本值就在 §8 内,含进去即刻失效): + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 执行回报(执行棒/,$d' 交接单_最小形态真机批次_20260917.md | md5sum` + ⇒ **`1edde731eba5034c5f6f3a43864e5a8e`** +- ⚠️ §10 原口径(整文件不计 §10)**无法内嵌数值**(自指),故本单改用上面这条**前缀口径**。 +``` + +--- + +## 附 A · 规划棒已核实的事实(执行棒**不必重复探索**) + +1. 🔴 **`src/` 全仓零 UDP / NAT 穿透代码** —— `grep -iE "dgram|createSocket|stun|punch|udp" src/` 的命中**全部**是 `signature` / `native` / `alternative` 之类**假阳性**;`src/net/relay/directory.ts:404-421` 只有 CGNAT 地址**判定函数**(`100.64.0.0/10`)。⇒ 本单不测"系统打洞成功率"(§4.1-1)。 +2. **参数表 §7 计数已核**:`待测` **4 个**(`HOLE_PUNCH_RATE_LOCAL` / `PER_PLAYER_BW_LOCAL` / `WAN_STEADY_THROUGHPUT` / `JITTER_LINK_MEASURED`)+ **待校准推导 1 个**(`MEM_PER_HOST_MB`)。 +3. **参数表 §5.2 校验③ 已算出**:`FLEET_RELAY_DEMAND = 450 > RELAY_MAX_HOSTS = 225` ⇒ **≥2 台中继**;`225 × 2 = 450` ⇒ **恰好达标、零余量**(§4.1-4 要求把这个事实写进表)。 +4. **`--max-hosts` 的落点陷阱**:主单元 ExecStart **已有显式 `--max-hosts 0`**,**CLI 优先于 `Environment=`** ⇒ 只设 env 会被**静默忽略**,**必须 drop-in 重写 ExecStart**(S7-5 已写死)。 +5. **现役只有 1 台中继**(47);`relay` 只绑 `127.0.0.1:20080`,经 nginx 443 暴露(origin + CF 双路 101)。 +6. **`relay` 的 `rttMs` 是心跳往返**(`server.ts:810` 注释写明三个作用)⇒ **不一定等于网络 RTT**(S3(b) 要求做三方校验)。 +7. **`JITTER_LIMIT_MS = 20 ms` 是估值口径**(业界),不是实测 ⇒ 与实测对比时**必须标明这一点**。 +8. **`PER_PLAYER_BW_*` 三行全部是估值**(0.5 / 2–5 / 10–20 KB/s,来源为同一份调研文档)⇒ 本单测的是**传输层上限**,不是游戏协议需求(S5 已写死边界)。 + +## 附 B · 硬约束复述(防走偏) + +- **提问判据**:技术实现(怎么造载荷 / 怎么绑 443 / 脚本怎么写 / 样本怎么分布)**一律自决**;**只有 §4.3 一项**属真取舍,且**执行棒不必等**(按倾向 A 开工)。 +- **只做被明确要求的事**:执行中发现的其他缺陷(如既有 502 / 引导链问题)**先报告,不顺手改**。 +- **成本纪律**:批量活**先写脚本再让脚本跑**,⛔ 不把"大范围取证"派给无人值守会话。 +- **红线**:R5(权限只准收窄;扩大必须出评估)|R7(不做未授权批量写入;本机是生产的前身)|R11(任一维度净变差即停)。 +- **收口**:锁必须释放;下一棒必须登记**并用陈述句告知**;日志必须写。 + +--- + +## §10 指纹 + +- **本节口径**(推荐核对用,可复现):**整个 §10 不计入** ⇒ + `cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §10 指纹/,$d' 交接单_最小形态真机批次_20260917.md | md5sum` ⇒ 见 §8.9 回填。 +- **全文件 md5**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。 + +--- + +## §11 补记(2026-09-17 11:1x,**在指纹口径之外**) + +- ✅ **§4.3 的唯一待拍板项已闭环**:用户原话「**1 本机内存大 可以模拟多台**」⇒ 选 **D · 本机模拟多台**,放弃候选 B(自备设备)与 C(新开云主机)。⭐ **本条覆盖 §4.3 里"才上抛 / 倾向 A+B / 执行棒按 A 开工"的表述**。 +- 本机实测:总内存 **47.6 GB** / 空闲 27.4 GB / **32 核**;relay 单实例 ≈ **48 MB** ⇒ 可模拟数十台。⚠️ 局限:多实例**共用同一出口 IP** ⇒ 对"切流逻辑"够用,对"家宽 / 运营商 NAT 差异"**无增量**(§4.3 里"云主机给不出分层样本"的判断同样适用于本机多实例)。 +- 落到本单:**"第 4/5 台"改由本机多实例承担**(不再等新真机);§5 样本口径里"若第 4/5 台到位 ⇒ 10 对"按此理解执行。 +- 🔒 本补记位于 §10 之后 ⇒ **§8.9 回填的前缀指纹与 §10 口径均不变**。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T20-观测口径与在册缺陷.md b/dsh-server-docs/交接单/archive/交接单-已完成/T20-观测口径与在册缺陷.md new file mode 100644 index 0000000..8777231 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T20-观测口径与在册缺陷.md @@ -0,0 +1,780 @@ +# 交接单 · 观测口径重构(OBS-11 计数 → 白名单集合)+ 在册小缺陷清算排序(2026-09-17) + +- **状态**:⏳ 待执行(**规划棒产物**,2026-09-17 14:5x 出单) +- **出单者**:覆盖网络线-序11规划棒(automation `3d4dffc0-356e-4bc6-9628-b7d664da7db9`) +- **执行方式**:规划与执行分离 ⇒ 执行会话**只按本单开工、不读规划会话上下文** +- **上游依据**(现取证据,⛔ 不必重跑): + 1. `交接单_检测时延与deadline_20260917.md` **§8.8-4 ③ 逐口对账**(序⑩ 收口回填)—— OBS-11 `78→77` 的名字 = **`20000`**(47 实例档、无活跃实例) + 2. 同单 **§8.8-4 末段「🔴 ③ 的副产品」** —— 计数判据的两处结构缺陷(ⓐ 对状态敏感 ⇒ 假红|ⓑ 对替换式变化不敏感 ⇒ 假绿) + 3. `参数表_覆盖网络_20260917.md` §6(`OBS-11` 现判据 = `LISTEN_COUNT` / `NFT_RULES` **相等**) +- **成本纪律**:本单的取证命令**都已写死在 §2 / §6**,执行会话**照抄即可**(⛔ 不要另做全库探索) + +--- + +## §1 目标 + +**一句话(可判定「做完了没有」)**:`scripts/overlay-probe.cjs` 的 `OBS-11` 由「**两个计数相等**」改为「**白名单集合**」判据(`必在集 ⊆ 实际 ⊆ 必在集 ∪ 允许集 ∪ 运行期派生集`,差集点名打印),`nft` 同族缺陷一并按同一思路改造;改造后 **① 47 无活跃实例时 `OBS-11` 不再假红;② 「一进一出」替换式变化必须能被判红(假绿实证见 §6-E2)**;并给出在册 6 条小缺陷的清算排序(**只排序 + 最小验收 + 回滚,本单不修**)。 + +**本单不追求**:把 `OBS-09`(47 无活跃实例)判绿 —— 那是环境态,归 §5.9-Q3。 + +--- + +## §2 只读前置(S0 必须先核实;任一条不符 ⇒ **停下报告**,⛔ 不硬闯) + +| # | 事实 | 命令(本机 `cwd` = 工作区根;47 走 `ssh -p 22 bt-server`) | 期望输出 | +|---|---|---|---| +| **P1** | 47 监听口**原文全量**(判据形态的来源) | `ssh -p 22 bt-server "ss -lntp" > _中间产物_待清理/seq11/ss47-before.txt; wc -l < _中间产物_待清理/seq11/ss47-before.txt` | **77** 行(含表头)⇒ 76 socket;**每条都必须是 `地址:端口` 形态**(⛔ 见 P1-注) | +| **P1-注** | 🔴 **形态风险(本单最容易踩)**:参数表的 `cleanValue()` 会**剥掉 `*` 与反引号**(`overlay-probe.cjs:68-70`)⇒ 白名单值里**绝不能出现 `*:443` 这种写法**,必须写 `ss` 实际打印的 `0.0.0.0:443` / `[::]:22`。**P1 的落盘原文就是唯一依据** | `grep -nE '\*' _中间产物_待清理/seq11/ss47-before.txt` | **零命中**(若有命中 ⇒ 停下报告,形态与 2026-09-17 的认知不同) | +| **P2** | nft 判据的两条取数路径是否都可用 | `ssh -p 22 bt-server "nft --version; nft -j list ruleset \| head -c 200; echo; nft list ruleset \| wc -l"` | 版本行有输出|`-j` 回 JSON(首字符 `{`)|文本行数 = **72**。⚠️ **若 `-j` 不可用 ⇒ 不得放弃判据**,走文本退化路径并在输出里**显式标记**(§5-S2-d) | +| **P3** | 参数表现取指纹(本单出单基线) | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`99e9e17b0c1ce0550e4bc7626a5a0494`** | +| **P4** | 代码仓干净度(防越界) | `git -C D:/github/dsh_shenxian status --short \| wc -l; git -C D:/github/dsh_shenxian status --short src/ \| wc -l; md5sum D:/github/dsh_shenxian/scripts/overlay-probe.cjs` | 总 **43**|`src/` 改动 **0**|探针 md5 现取并落 §8.9 | +| **P5** | relay 端点**动态落点**(⛔ 它不是固定值,每次重启都会变) | `ssh -p 22 bt-server "curl -s http://127.0.0.1:20080/status" \| python3 -c "import sys,json;d=json.load(sys.stdin);print([e['localPort'] for e in d['endpoints']])"` | 形如 `[46147]`(序⑩ 现值 46147;⛔ 开工时**以现取为准**) | + +> ⚠️ `_中间产物_待清理/seq11/` 是本单的取证落盘目录(先 `mkdir -p`)。收口时**保留**(供第三方复核),⛔ 不要写进正式档案。 + +--- + +## §3 范围 + +**改(3 类,全部是"观测面",⛔ 无一在生产路径上)** + +| # | 文件 | 改动性质 | 是否计入本单前置指纹 | +|---|---|---|---| +| 1 | `D:/github/dsh_shenxian/scripts/overlay-probe.cjs` | `OBS-11` 判据主体 + nft 同族改造 + 取数改为回传原文 | 代码,**不进**参数表指纹 | +| 2 | `参数表_覆盖网络_20260917.md` §6(新增集合键、退役计数键)+ §10 | 新增 4 键 / 退役 2 键说明 | **会变** ⇒ §5-S5 必须重取并记 §8.9 | +| 3 | 工作区根 `接续入口_覆盖网络线_20260916.md` §0(1 行) | 口径指针 | 入口无指纹口径 | + +**⛔ 不动(明确写死,防顺手扩大)** + +- ⛔ **`src/**` 一字不改**(本单是观测面改造;`git status --short src/ | wc -l` 必须仍 = **0**) +- ⛔ 不动任何生产阈值:`RELAY_FAILOVER_*` / `HB_SEC` / burst 语义 / `RELAY_MAX_HOSTS` / `MemoryHigh`·`MemoryMax` +- ⛔ 不开新监听口 / 不新增凭据 / 不放宽 nft / 不碰 nginx / 不重启 `dshs` 或 relay(本单**无生产中断**) +- ⛔ **不 commit / 不 push**(未获授权) +- ⛔ 不回去改已收官的历史单(`交接单_检测时延…md` 等记录的是**当时实况**;新口径以参数表为单一来源 ⇒ §5-S5 只在参数表内写"退役说明",**不追改历史单**) +- ⛔ **不做**:presence / 房间层 / 内容分发(清单第 7 步)|106 agent 面吃引导链(已判不执行)|worker 拨出方向、relay 自研形态、打洞实现|同机候选去重 / 并发竞速 open(已判不做)|第 4/5 台真机(已拍板 = 本机多实例) + +--- + +## §4 决策点 + +**已定(本规划棒自决,可推翻)** + +1. **判据形态 = 三集包含式集合判据**(不是"计数 + 容差",也不是"只报不判"): + +``` +required ⊆ actual // 必在集:缺一条 ⇒ FAIL(点名缺项) +actual ⊆ required ∪ allowed ∪ derived + // 多一条 ⇒ FAIL(点名多出项) +derived = { 127.0.0.1:<p> | p ∈ /status.endpoints[].localPort } // 唯一来源 = relay 自身 /status +allowed = 表中静态允许集(含区间,见 §5-S1) +``` +> 依据:`required` 抓"**消失**"、包含式抓"**新增**"、`derived` 让**合法动态落点**有名字 ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。 + +2. **`LISTEN_COUNT` 的"有/无实例态"两值 —— ⛔ 不表达,直接消除**:实例档 `20000`(w-47)/ `21000`(w-106)进**允许区间** ⇒ 有实例则出现(合法)、无实例则不出现(不红)⇒ 状态无关化。 + ⛔ **不新增** `LISTEN_COUNT_UP` / `LISTEN_COUNT_DOWN`(那等于把"对状态敏感"这条缺陷**编码进参数表** = 净退化,违 **R11**)。 +3. **`nft` 同族缺陷一并改造(结论:改,但机制与监听口不同)**:nft 是**内容**不是端口 ⇒ 用 `nft -j` **入站 accept 集合 ⊆ 白名单**(语义判据,比行数更贴近"零新增暴露面"的本意);`nft list ruleset | wc -l` **降级为"仅打印的上下文"**,不再参与判 PASS/FAIL。⛔ 退化为文本解析时**必须显式标记**,⛔ 不得静默改判据(`text-fallback` 字样进输出行)。 +4. **`LISTEN_COUNT` / `NFT_RULES` 退役为"历史留档"**:两行**留在**参数表(供对账),但 `OBS-11` 判据列**不再引用**;退役说明写在参数表 §6 内(单一来源内自解释),⛔ 不追改历史单。 +5. **假绿/假红的**自证**方式 = 夹具 + 真机受控临时口**(§5-S3 / §6-E2、E3)—— 判据改造**必须自带"旧判据会放过、新判据能抓住"的实证**,否则等于换了个说法。 + +**硬门(三条,⛔ 任一违反即停手)** + +- **D1**:分解类前提未复现前 ⛔ **不许改任何生产值**(本单只改观测面,本就不该碰生产值 ⇒ 违反即意味着范围外扩) +- **R7**:范围外缺陷**先报告、不动手**(§5.9 的 6 条里,本单**只执行 Q6 的"结构化吸收"**,其余全部只排序) +- 🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**(该值已判「看似合法、实际自锁」:`num()` 的 `/^\d+$/` 放行 `'0'`,但归零会让"失败候选必须被排除"失效 ⇒ 候选链卡死,实测 **121–123 s 无切换**;序⑩ 已把 `--scene 4c` / `ctrl` 整体移除并**显式拒绝**)。本单**不涉及任何切流演练** ⇒ 全单不该出现这个键。 + +**§4.3 待你拍板 = 空**(本单**零上抛**;§5.10 的范围外清单只登记、不定序) + +--- + +## §5 步骤 + +### S0 · 只读前置 +照 §2 跑完 P1–P5。**任一条与期望不符 ⇒ 停下报告**(附原文)。⚠️ P1 与 P1-注 是**全部设计的形态依据**,必须先落盘再动手。 + +### S1 · 定义白名单集合(改**参数表** §6,⛔ 值一律按 P1 原文写) + +新增 4 键(全部加在 §6 阈值表内,`KEY_RE` 能解析;⛔ 值内不得含 `|`、`*`、反引号): + +| 键 | 含义 | 值形态(示例;**实际以 P1 原文为准**) | +|---|---|---| +| `LISTEN_REQUIRED` | **必在集**:缺失 ⇒ FAIL。口径 = **平台工作必需 且 不受实例/端点在线态影响**的固定口 | 逗号分隔的 `地址:端口`,如 `0.0.0.0:22,[::]:22,0.0.0.0:80,0.0.0.0:443,0.0.0.0:888,0.0.0.0:3080,127.0.0.1:20080` | +| `LISTEN_ALLOWED` | **允许集(非必在)**:出现合法、消失不红 | 同上形态,如 `0.0.0.0:58888,127.0.0.1:8765,127.0.0.1:15432,127.0.0.1:19100` | +| `LISTEN_ALLOWED_RANGES` | **区间允许集**(实例档 / 拨号池) | `127.0.0.1:20000-20099,127.0.0.1:21000-21099`(两段取自序③ P3 的实例端口区间隔离)。⚠️**拨号池不在此键内** —— 复用表内既有 `DIAL_POOL_BOUND`(`25000–25063`)派生,⛔ 不重复写死(避免两处漂移) | +| `NFT_ALLOW_INBOUND` | **入站 accept 白名单**(S4 用) | `tcp:22,tcp:80,tcp:443,tcp:888,tcp:3080,tcp:58888`(+ 无端口匹配的必要规则另立一个标记位,形态见 S4) | + +**成员归属的判据(写进参数表备注,防下一棒再吵)**: +- **必在** = 它不在 ⇒ **平台本身坏了**(sshd / nginx 80·443 / 宝塔 888 / 平台门户 3080 / relay `20080`) +- **允许** = 它不在 ⇒ 只是**某个可选项没开**(运维面板 58888、管理 UI 8765、PG 15432、Worker 19100、实例档、拨号池) +> ⚠️ 若 P1 原文显示某口与上表推断不符(例如 `888` 实际不在监听)⇒ **以原文为准**,并把该口从 `LISTEN_REQUIRED` 移到 `LISTEN_ALLOWED`(本单不追求"凑齐九个")。 + +### S2 · 改探针 `scripts/overlay-probe.cjs`(本单唯一代码改动) + +**a. 取数:远端只回传原文,归一化在本地做** +- 现状 `listenTotal=$(ss -lntp | wc -l)` ⇒ 改为**回传原文**:`echo "listenRaw=$(ss -lntp | base64 -w0)"`(单行、`base64 -w0` 字母表无 `=` 除末尾填充 ⇒ 与现有 `key=value` 解析兼容)。 +- nft 同法:`echo "nftRaw=$(nft -j list ruleset | base64 -w0; echo <退化标记>)"` —— ⛔ **`-j` 失败必须能分辨**(见 d)。 +- ⛔ **仍只准一次 ssh**(现有 `remoteFacts()` 一条命令取全部事实的设计不许破)。 + +**b. 归一化(本地)** +- `ss` 行:跳过表头 / 空行 ⇒ 按 `\s+` 切分取 **Local Address:Port** 列 ⇒ 从**右侧**第一个 `:` 拆 host/port(`[::]:22` 必须拆对,⛔ 不能用左侧 `indexOf`)。 +- 产出集合 `actual`,**逐条保留原文字符串**(报错要点名)。 + +**c. 判据(替换 `overlay-probe.cjs:251-259` 的 `OBS-11`)** +``` +missing = required \ actual // 缺项 ⇒ FAIL +extra = actual \ (required ∪ allowed ∪ ranges ∪ derived) // 多出 ⇒ FAIL +ok = missing.length === 0 && extra.length === 0 && relayListenTotal === relayListenLoopback && relayListenTotal > 0 +``` +- 输出行(≤12 行的约束不许破):`OBS-11 集合 必在 n/7 允许 m 区间 k 派生 d 实际 t 多出 x 缺失 y` +- 差集**逐条点名**写到 stderr:`OBS-11 多出 127.0.0.1:9999` / `OBS-11 缺失 0.0.0.0:443` +- ⛔ **`LISTEN_COUNT` / `NFT_RULES` 不再被 `r.num()` 读取**(退役;两行留在表内仅供对账) + +**d. nft(S4 的实现细节,与 c 同批落地)** +- `nft -j` 可用 ⇒ 解析 JSON:取 **`input` 链**(family `ip`/`ip6` 的 `filter`/`inet` 视 P2 原文)中 **verdict 含 accept** 的规则 ⇒ 归一为 `{proto}:{dport}`(无端口匹配的规则归为专用标记,如 `established`)。 +- `nft -j` 不可用 ⇒ `nft list ruleset` 文本按行抽 `accept` 行 ⇒ **同一判据**,但输出行尾加 `nft=text-fallback`(⛔ 不许静默)。 +- 判据 = `acceptSet ⊆ NFT_ALLOW_INBOUND`;多出逐条点名(`OBS-11 nft 多出 tcp:9999`)。 + +**e. 零数字纪律(判据见 §6-E5)**:新增代码**不得出现任何 ≥3 位的数字字面量**(端口/池/日期全部走参数表或从取回数据里取)⇒ `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` 仍须**零命中**。 + +### S3 · 假红 / 假绿的自证(**本单的核心验收,⛔ 不许省**) + +**a. 夹具模式(离线、零生产副作用)**:给探针加两个**仅测试用** argv:`--listen-fixture <file>` / `--nft-fixture <file>`(文件内容 = 远端原文,即 `ss -lntp` / `nft -j` 的输出)。 +- 夹具模式下**不 ssh**;输出行首加 `⚠️ FIXTURE` 标记,**stderr 同时标一次**(⛔ 防止被下游当成生产结论)。 +- 退出码仍按判据给 `0/1`(否则先红后绿无法表达)。 +- ⛔ 夹具模式**必须配 `--table <副本>`**(避免误改生产参数表)。 + +**b. 三份夹具 + 新旧判据对照表**(落盘 `_中间产物_待清理/seq11/fx-*.txt`,三份都从 P1 原文**机械改写**,⛔ 不手搓) + +| 夹具 | 构造 | 旧判据(计数相等) | 新判据(集合) | 证的是 | +|---|---|---|---|---| +| `fx-normal.txt` | P1 原文 | FAIL(77≠79 ⇒ **假红**) | **PASS** | ⓐ 假红已消除 | +| `fx-swap.txt` | 原文**去掉一行**(如 `0.0.0.0:888`)**加一行**(`127.0.0.1:9999`)⇒ **行数不变** | **PASS(假绿!)** | **FAIL**(多出 `9999` + 缺失 `888`,各点名) | ⓑ 替换式变化已能抓住 | +| `fx-instance.txt` | 原文**加上** `127.0.0.1:20000`(模拟 47 有活跃实例) | FAIL(78≠79 ⇒ 假红) | **PASS** | 实例态不再影响判据 | + +**c. 真机正向复验(47,只读 + 一个受控临时口)** +1. 现态跑一次 ⇒ 期望 **`OBS-11 PASS`**,且 stderr 无差集(`overlay-probe` 总数由 **10/12 → 11/12**,剩余的 `OBS-09` = 环境态)。 +2. **受控临时口**:本机 `ssh -p 22 bt-server "python3 -c '…'"` 起一个**只绑 `127.0.0.1` 的临时监听口**(口值从 47 的空闲回环口里现取,⛔ 不猜、⛔ 不绑公网)⇒ 新判据**必须 FAIL 并点名该口**;随即**关掉** ⇒ 必须回 PASS。 + ⚠️ 该临时口的值**必须记进 §8**(自证"多出必红"用的就是它);⚠️ 用 `timeout`/`trap` 保证**即使中断也会自动关闭**(⛔ 不留残留监听口)。 + +### S4 · nft 集合判据落表 +按 S1 的 `NFT_ALLOW_INBOUND` 定值(**以 P2 的 `nft -j` 原文为准**,⛔ 不抄本单示例)。当前 `nft list ruleset | wc -l` = **72** ⇒ 归一后入站 accept 项数应远小于 72(含 `established`/`related` 一类无端口规则)。⚠️ 若发现**白名单写不全**(如 `icmp` 或 `ct state` 规则)⇒ 补白名单**而不是**放松判据;若确实存在"说不清来源的入站 accept"(如宝塔自行插入)⇒ **记进 §8.8 的"在册未办"并点名命令**,⛔ 不许把它静默加进白名单。 + +### S5 · 连带更新 +1. **参数表 §6**:新增 4 键;`LISTEN_COUNT` / `NFT_RULES` 两行后加注「⛔ **已退役(序⑪ 集合判据替代)—— 仅对账用,不得再作为判据**」(值**保留**,历史可比)。 +2. **参数表 §10 指纹**:按表内既有口径重取现值,写进 §10 的"现取"行。 +3. **入口 `接续入口_覆盖网络线_20260916.md` §0**:加/改 1 行口径指针(指向本单 §8.9 与参数表 §6)。 +4. ⛔ **不追改历史单**(见 §3)。 + +### S6 · 收口 +按 §7 回滚准备 → 释放锁 → 用**陈述句**告知接续并登记下一棒(`scheduledAt` = 收口 + 2~5 分钟;⚠️ 下一棒 id **只能来自工具返回值**,先 create、再取 id、再落盘)→ 写工作区日志 → §8 回填。 + +--- + +### §5.9 在册小缺陷清算排序(② · **本单只排序 + 给最小验收与回滚,⛔ 不修**) + +| 序 | 缺陷 | 类型 | 最小验收(命令 ⇒ 期望) | 回滚 | 依赖 | +|---|---|---|---|---|---| +| **Q1** | `mksess*.cjs` 失效(`/opt/dshs/mksess*.cjs` 仍写 **SQLite 旧库**) | 工具链(**阻塞验收手段**) | 用它开一次临时 session ⇒ 实例面 `curl -w '%{http_code}'` 回 **200/401**(⛔ 非 `000`);用完即删 | 恢复原脚本 + 删 session | 无(**先做**) | +| **Q2** | guest(w-106) 实例页 **502** | 生产缺陷(用户可见) | 同 Q1 的临时 session ⇒ w-106 实例页 **200** | `cp -a <lib 备份>/lib/. /opt/dshs/lib/` → `systemctl restart dshs` | **Q1**(没有临时 session ⇒ 无法验收) | +| **Q3** | `OBS-09` 实例面 `000`(47 无活跃实例) | **环境态,非缺陷** | 47 起活跃实例 ⇒ `OBS-09` 绿 **且** 新 `OBS-11` 在"有实例态"下仍绿(`20000` 落在允许区间) | 停实例(回到现态) | **Q2**(同批做最省) | +| **Q4** | `src/net/relay/**` **untracked 留档缺口** | 交付基线(**需用户授权**) | `git ls-files src/net/relay \| wc -l` > 0 且与工作区一致 | 不适用(只增不改) | **用户授权**(§4 提交边界 ⇒ 只报告) | +| **Q5** | `client.ts.bak-seq7-*` 残留 | 清理 | `ls src/net/relay/*.bak-*` 为空 **且** `npm test` 值不变(基线 160/159/0/1) | 从同目录备份/产物重生成(⚠️ 删前先确认 lib 内已有等价产物) | 无 | +| **Q6** | 参数表 `LISTEN_COUNT`=79 与现态的 **1 条无名口** | 观测口径 | **被 S1–S3 结构化吸收**(集合判据不再依赖计数);剩余 = 47 恢复活跃实例后复取应回 **78 行** | 参数表备份恢复(指纹回 `99e9e17b…`) | **本单即办**;"78 行"的复核与 Q3 合批 | + +**排序理由(一句话)**:**Q1 是"验收手段"**(R4 规定实例面验收一律走临时 session)⇒ 它不修,Q2/Q3 连"做完了没有"都判不了;**Q2 是唯一用户可见的坏**(502);**Q3 是环境态**且与 Q2 同批最省;**Q4 卡在授权**(不在技术侧);Q5/Q6 是收尾。 +**本单实际动手的只有 Q6 的"吸收"部分**(属 S1–S3 的副产品);⚠️ 其余 5 条 **⛔ 本单不修**(R7),执行会话把它们**原样转给下一棒**。 + +### §5.10 范围外(⛔ 只登记,本单**不定序、不上抛**) + +presence / 房间层 / 内容分发(清单第 7 步)|106 agent 面吃引导链(已判不执行)|worker 只拨出 / relay 自研 / 不做打洞实现|同机候选去重 / 并发竞速 open(已判不做)|第 4/5 台真机 = 本机多实例(已拍板)。 +> ⛔ 这些的**先后顺序属"业务优先级"**,按 §1 判据属边界外 ⇒ **本单只登记、不排序、不决策**。 + +--- + +## §6 验收(命令 + 期望输出 + 退出码;可被第三方复现) + +| # | 项 | 命令(`cwd` = 工作区根) | 期望 | +|---|---|---|---| +| **E1** | 新判据在真实态全绿 | `node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs"; echo "rc=$?"` | `PASS OBS-11 …`(stderr 无差集);**10/12 → 11/12**(剩 `OBS-09` 环境态);`rc=1`(因 OBS-09) | +| **E2** | **假绿实证**(本单核心) | `node …/overlay-probe.cjs --table <副本> --listen-fixture _中间产物_待清理/seq11/fx-swap.txt` | **旧判据 PASS / 新判据 FAIL**,且 stderr 同时点名**多出**与**缺失**各 1 条(对照表全文进 §8) | +| **E3** | 真机受控临时口 | S3-c 两步(起口 ⇒ 跑探针 ⇒ 关口 ⇒ 再跑) | 起口后 `FAIL` 且点名该口;关口后回 `PASS`;**收口时该口必须已关闭**(`ss -lntp` 复核 77 行) | +| **E4** | nft 判据两路可用 | `ssh -p 22 bt-server "nft -j list ruleset \| head -c 50"` + 一次探针 | `acceptSet ⊆ NFT_ALLOW_INBOUND`;走退化路径时输出含 `text-fallback` | +| **E5** | 零数字纪律未被破 | `grep -nE "[0-9]{3,}" D:/github/dsh_shenxian/scripts/overlay-probe.cjs` | **零命中**(退出码 1) | +| **E6** | 零回归(⛔ 四个都验) | `npm.cmd test`(Node 22)|`node …/overlay-failover-drill.cjs --scene all`|`ssh -p 22 bt-server "ss -lntp \| wc -l; nft list ruleset \| wc -l"` | 测试 **≥ 160/159/0/1**|`--scene all` **12 PASS / 0 FAIL**|`77` / `72`(本单不产生新监听口) | +| **E7** | 越界自证 | `git -C D:/github/dsh_shenxian status --short src/ \| wc -l; git -C D:/github/dsh_shenxian status --short \| wc -l` | `src/` = **0**;总数 = **43 + 本单新增**(⛔ 无 `src/` 改动、无 `.bak` 残留) | +| **E8** | 回滚可用 | §7 的恢复命令 + 重跑探针 | 回到 **10/12**(`OBS-11` 重新红) | +| **E9** | 参数表指纹已更新并记录 | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | 与 §8.9 记录值一致(⛔ 不得仍等于出单基线 `99e9e17b…`) | + +**退出码约定**(沿用探针原设计,⛔ 不改语义):全绿 `0` / 任一红 `1` / 用法或取数失败 `2`。 + +--- + +## §7 回滚(全部秒级,⛔ 无生产中断) + +| 层 | 改前准备 | 回滚命令 | +|---|---|---| +| 探针 | `cp -a D:/github/dsh_shenxian/scripts/overlay-probe.cjs _中间产物_待清理/seq11/overlay-probe.cjs.bak-<ts>`(⛔ **不进 `src/`**,收口时**删除仓内任何 `.bak`**) | `cp -a _中间产物_待清理/seq11/overlay-probe.cjs.bak-<ts> D:/github/dsh_shenxian/scripts/overlay-probe.cjs` | +| 参数表 | `cp -a 参数表_覆盖网络_20260917.md _中间产物_待清理/seq11/参数表.bak-<ts>` | 拷回 ⇒ 复核指纹回到 **`99e9e17b0c1ce0550e4bc7626a5a0494`** | +| 入口 | 只加 1 行 ⇒ 手工删该行 | 同左 | +| 临时监听口 | §5-S3-c 的 `timeout`/`trap` 兜底 | `ssh -p 22 bt-server "ss -lntp \| wc -l"` 必须回 **77** | + +- ⛔ 本单**不重启** `dshs` / relay ⇒ 无在线用户影响;⛔ 回滚路径**不含任何** `RELAY_FAILOVER_*` 值(尤其 ⛔ 不含 `…COOLDOWN_MS=0`)。 +- ⛔ **`.bak` 只在执行期存在**:验收全绿后**立即删除**仓内 `.bak`(`client.ts.bak-seq7-*` 残留就是反例 ⇒ 本单顺手立规矩,并把结果写进 §8)。 + +--- + +## §8 回报格式(执行会话按此回填;⛔ 缺一节即算未完成) + +``` +### 8.1 结论(1 行) :OBS-11 集合判据是否落地 / E1–E9 逐条 PASS·FAIL +### 8.2 只读前置 P1–P5 :逐条贴命令与输出(P1 的 77 行原文落盘路径 + 形态核对) +### 8.3 白名单定值 :LISTEN_REQUIRED / ALLOWED / ALLOWED_RANGES / NFT_ALLOW_INBOUND 四个键的**最终值**(含"为什么是这几个") +### 8.4 假红假绿实证 :E2 的三行对照表(夹具 ⇒ 旧判据 / 新判据)+ 三份夹具的 md5 +### 8.5 真机正向复验 :E1 / E3 的命令原文与输出(含临时口的**口值**与"关闭后已复原"证据) +### 8.6 nft 判据 :`-j` 是否可用、归一后的 accept 集合、与白名单的关系(⚠️ 若有"说不清来源"的项 ⇒ 点名并转 §8.8) +### 8.7 零回归 / 越界自证 :E5–E7 原文(npm test 计数、--scene all 计数、ss/nft 计数、git status 计数、src=0) +### 8.8 未过项 / 在册未办 :逐条写「现象 / 已做到哪一步 / 回头条件」;§5.9 的 Q1–Q5 原样转下一棒 +### 8.9 指纹(可复现核对口径) +``` + +### §8.9 指纹 + +- **参数表**:`sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum` ⇒ 出单基线 **`99e9e17b0c1ce0550e4bc7626a5a0494`**(执行后**必须已变**并在此回填现值) +- **本交接单前缀(§8 及其后不计入)**:`sed '/^## §8 回报格式/,$d' 交接单_观测口径与在册缺陷_20260917.md | md5sum` ⇒ 现取值**由出单棒在入口与本棒回复中登记**(⛔ 本文件内**不自嵌**,避免自指) +- **交叉证据(收口复取)**:`交接单_检测时延与deadline_20260917.md` = **`d903b4eeabf25ef381379cdbaac77e8a`**(§8 前缀口径)|`参数表` 出单基线见上 +- **原始取证落盘**:`_中间产物_待清理/seq11/`(`ss47-before.txt` = P1 原文;`fx-normal.txt` / `fx-swap.txt` / `fx-instance.txt` = 三份夹具;`overlay-probe.cjs.bak-<ts>` = 探针回滚副本) + +--- + +## §8 执行回报(序⑫ 执行棒 · 2026-09-17 15:0x–15:2x) + +> ⚠️ 本回报**追加在 §8 之后** ⇒ **§8 前缀指纹 `3ece0f87…` 未变**(回填不进入前缀,与序⑨/⑩ 同例)。 +> ⚠️ 单头「状态:⏳ 待执行」一行**保持原样不动** —— 改它会动前缀指纹;本棒状态以 §8.1 与本行下方为准(**已执行完毕**)。 + +### 8.1 结论(1 行) + +**✅ `OBS-11` 集合判据已落地;E1–E9 = 9 全绿**(其中 **E6-2 在第 3 次尝试才通过** —— 前两次被 **106 的 `sshd` MaxStartups 限流**中止,属**环境级、非本单引入**,已入册 **§8.8-1**)。`overlay-probe` **10/12 → 11/12**(剩 `OBS-09` = 环境态);**假红已消除 + 假绿已能抓**(§8.4 有对照表)。 + +### 8.2 只读前置 P1–P5 + +| # | 命令(`cwd` = 工作区根) | 实测 | 期望 | 判定 | +|---|---|---|---|---| +| **P1** | `ssh -p 22 bt-server "ss -lntp" > …/seq11/ss47-before.txt 2>/dev/null; wc -l < …` | **77 行**(=表头 1 + socket **76**) | 77 | ✅ | +| **P1-注** | `awk 'NR>1 && $1=="LISTEN"{print $4}' … \| grep -c '\*'` | **0** | 0 | ✅(⛔ 按**实质判据**;见 §8.8-2-①) | +| **P2** | `ssh -p 22 bt-server "nft --version; nft -j list ruleset \| head -c 120; … nft list ruleset \| wc -l"` | `nftables v1.0.4`|`-j` **回 JSON**(首字符 `{`)|文本 **72 行** | 同 | ✅ | +| **P3** | `sed '/^## §10 指纹/,$d' 参数表… \| md5sum` | **`99e9e17b0c1ce0550e4bc7626a5a0494`** | 同 | ✅ | +| **P4** | `git -C D:/github/dsh_shenxian status --short \| wc -l`;`… src/ \| wc -l` | 总 **43**|`src/` **20** | 43|**0** | 总 ✅;`src/` ⛔ 期望值本身有误 ⇒ 见 §8.8-2-② | +| **P5** | `ssh -p 22 bt-server "curl -s 127.0.0.1:20080/status"` → 取 `endpoints[].localPort` | **`[40985]`**(与 relay `20080` 同 PID 781675) | 现取为准 | ✅(⚠️ 动态值,序⑩ 的 46147 已过期 —— 印证"⛔ 不能写死") | + +> **P1 原文的 76 条监听口分类**(这是白名单定值的**唯一依据**): +> `0.0.0.0:22` / `[::]:22` / `0.0.0.0:80` / `0.0.0.0:443` / `0.0.0.0:888` / `0.0.0.0:8765` / `0.0.0.0:58888` / `127.0.0.1:3080` / `127.0.0.1:15432` / `127.0.0.1:19100` / `127.0.0.1:20080` / `127.0.0.1:40985` + **`127.0.0.1:25000–25063`(64 口,拨号池)**。 +> ⇒ `7 必在 + 4 允许 + 1 派生 + 64 拨号池 = 76` **逐条有名、零无名口**(对账闭合)。 + +### 8.3 白名单定值(四个键的最终值 + 为什么是这几个) + +| 键 | 最终值 | 为什么是这几个 | +|---|---|---| +| `LISTEN_REQUIRED` | `0.0.0.0:22,[::]:22,0.0.0.0:80,0.0.0.0:443,0.0.0.0:888,127.0.0.1:3080,127.0.0.1:20080` | 判据 = **它不在 ⇒ 平台本身坏了**(sshd/nginx `80`·`443`/宝塔 `888`/平台门户 `3080`/relay 回环口)。⚠️ 与单内示例的**唯一差异** = **`3080` 写在 `127.0.0.1` 上**(47 实测绑回环,⛔ 不是 `0.0.0.0:3080`)—— 单内示例写错,按 P1 原文为准 | +| `LISTEN_ALLOWED` | `0.0.0.0:58888,0.0.0.0:8765,127.0.0.1:15432,127.0.0.1:19100` | 判据 = **它不在 ⇒ 只是某个可选项没开**(宝塔面板口/管理 UI/控制面 PG/本机 worker agent)⇒ 出现合法、消失**不红** | +| `LISTEN_ALLOWED_RANGES` | `127.0.0.1:20000-20999,127.0.0.1:21000-21999` | 两台 worker 的**实例端口区间**。🔴 **span 取 1000,不是单内示例的 100** —— 实测 47 的 `dshs-worker.service`(经 `/proc/<pid>/environ`)与 106 的 `/etc/dshs-worker.env` 均为 `DSHS_INSTANCE_PORT_BASE=20000`/`=21000` + **`DSHS_INSTANCE_PORT_SPAN=1000`**;并集 `[20000,22000)` 正好落在 relay 声明窗口 `[19000,22000)`(`--base 19000 --span 3000`)**之内** ⇒ 自洽。⛔ 拨号池**不在此键内**(复用表内既有 `DIAL_POOL_BOUND` 派生,避免两处漂移) | +| `NFT_ALLOW_INBOUND` | `tcp:22,tcp:80,tcp:443,tcp:888,tcp:3080,tcp:58888` | 入站 accept 的**语义白名单**(见 §8.6)。⚠️ 归一形态 = `<proto>:<dport>`;「无 dport 匹配的 accept」归一为 `<proto>:any` —— **当前 47 的 input 链 0 条规则 ⇒ 集合为 ∅、本键暂无标记位**(将来若出现必须显式加进来,⛔ 不许放宽判据) | + +**"有/无实例态"的处置(⭐ 本单的结构性决定)**:⛔ **不新增** `LISTEN_COUNT_UP` / `LISTEN_COUNT_DOWN` 两个计数(那等于把"对状态敏感"编码进单一来源 = 净退化,违 **R11**)⇒ 把实例档 `20000` / `21000` 直接放进**允许区间**:**出现不报、消失不红**,状态敏感性问题被**消除**而不是被打补丁。 + +### 8.4 假红假绿实证(E2 核心) + +**判据口径先说清**(⛔ 否则数字会被误读):**旧判据口径** = `ss -lntp | wc -l`(**含表头**,比 `LISTEN_COUNT`=79);**新判据口径** = **socket 集合大小**(不含表头)。三份夹具**全部由 P1 原文机械改写**(`mk-fixtures.py`,⛔ 不手搓): + +| 夹具 | 构造 | 行数 | **旧判据** | **新判据** | 证的是 | +|---|---|---|---|---|---| +| `fx-normal.txt` | P1 原文**逐字** | 77 | **FAIL**(`77 ≠ 79`)⇒ **假红** | **PASS**(多出 0 缺失 0) | ⓐ 假红已消除 | +| `fx-swap.txt` | 原文 **+ `20000` + `21000`**(模拟两实例活跃 ⇒ 行数回到 79)**− `0.0.0.0:888` + `127.0.0.1:9999`**(一进一出) | **79** | **PASS(假绿!)** | **FAIL** —— 同时点名 `OBS-11 多出 127.0.0.1:9999` + `OBS-11 缺失 0.0.0.0:888` | ⓑ 替换式变化已能抓住 | +| `fx-instance.txt` | 原文 **+ `127.0.0.1:20000`** | 78 | **FAIL**(`78 ≠ 79`)⇒ **假红** | **PASS** | 实例态不再影响判据 | +| `fx-nft-extra.json` | `nft -j` 原文 + 一条 `INPUT` 上的 `accept tcp dport 9999` | — | — | **FAIL** —— 点名 `OBS-11 nft 多出 tcp:9999` | nft 侧"多出必红" | +| `nft-text.txt` | `nft list ruleset` **文本原文**(喂给 `--nft-fixture` ⇒ 必走退化路径) | 72 | 旧口径行数相等 ⇒ PASS | **PASS** + 输出含 **`nft=text-fallback`**;accept 集合 = **∅**(`FORWARD` 的 accept **未误抓** ⇒ 链感知有效) | 退化路径**同一判据**、⛔ 不静默 | +| `fx-nft-text-extra.txt` | 上述文本 + 在 `chain INPUT {` 后插一行 `tcp dport 9999 counter accept` | 73 | — | **FAIL** —— 点名 `tcp:9999` | 退化路径同样"多出必红" | + +**三份 ss 夹具 md5**:`fx-normal.txt` = `e53031f7f2485b4609a69128effb7210`|`fx-swap.txt` = `dadd94f85df9a2c1e115afce17bfd91e`|`fx-instance.txt` = `45cee07da44d5c3fa4a92475f03be2ed` +**入场码**:`fx-normal` rc=**0**|`fx-swap` rc=**1**|`fx-instance` rc=**0**|`fx-nft-extra` rc=**1**|`nft-text` rc=**0**|`fx-nft-text-extra` rc=**1**(⛔ 夹具模式退出码仍按判据给,否则"先红后绿"表达不出来) + +⚠️ **与单内 §6-E2 的一处构造差异(必须记)**:单内写「`fx-swap` = 原文**去掉一行加一行**、**行数不变**」并同时期望「旧判据 PASS」。**这两条指不并存** —— 若行数仍 = 77,则旧判据也 FAIL ⇒ **假绿证不出来**。本棒按**实质判据**构造:先补两个实例口把行数**抬到 79(= `LISTEN_COUNT`)**,再做一进一出 ⇒ 两条期望**同时**满足(旧 PASS/新 FAIL)。这是"让判据可被证伪",⛔ 不是改判据。 + +### 8.5 真机正向复验(E1 / E3) + +**E1(真实态)**:`node scripts/overlay-probe.cjs` ⇒ `rc=1`(因 `OBS-09`)|**PASS 11 / FAIL 1**(唯一红 = `OBS-09 实例面 本机:20000=000 对端:21000=000`)|`OBS-11` 原文: +`PASS OBS-11 集合 必在 7 允许 4 区间 3 派生 1 实际 76 多出 0 缺失 0 |nft accept 0 多出 0 |relay 口绑定回环=1/1 条` +⇒ **10/12 → 11/12** 达成本单期望(原红项 `OBS-11` 由"77 ≠ 79"假红转为绿)。 + +**E3(真机受控临时口)** —— 口值 = **`127.0.0.1:27000`**(选址理由:**不在** required/allowed/ranges/derived/拨号池任何一处);起口方式 = `ssh 'timeout 45 bash -s' < tmplisten.sh`(**`timeout` 兜底 ⇒ 即使中断也会自动关闭**): + +| 步 | 命令 | 实测 | +|---|---|---| +| 起口前 | `ssh bt-server "ss -lntp \| wc -l"` | **77** | +| 起口后 | `ss -lntp \| wc -l` ⇒ **78**;跑探针 | `FAIL OBS-11 集合 … 多出 1 缺失 0`|**stderr 点名 `OBS-11 多出 127.0.0.1:27000`**|rc=**1** | +| 关口后(`wait` 到 `timeout` 自然结束) | `ss -lntp \| wc -l` ⇒ **77**;再跑探针 | `PASS OBS-11 集合 … 多出 0 缺失 0`|rc=**1**(仅 OBS-09) | + +⇒ **"多出必红、恢复必绿",且收口时该口已关闭、`ss` 回到 77(⛔ 零残留监听口)**。 + +### 8.6 nft 判据 + +- **`nft -j` 可用**(P2:`nftables v1.0.4`,首字符 `{`)⇒ **正路走 JSON**,未走退化。 +- 🔴 **归一后的 accept 集合 = ∅**。原因(本条同时纠正了一个旧口径):47 的 `nftables` 只有 **22 条规则**,而 **`ip filter INPUT` 链 0 条规则、`policy = accept`**;`nft list ruleset | wc -l = 72` 里绝大多数是 **`ip nat` / `dsh_egress`(output)** 与注释行 ⇒ **行数根本不是入站暴露面**(这正是"`NFT_RULES` 计数"这条旧判据的第二处结构缺陷,已在参数表内标注退役)。 +- 归一实现:取 **`hook=input` 的链 + 沿 `jump` 的传递闭包**,其内 verdict 含 `accept` 的规则 → `<proto>:<dport>`(`dport` 支持 数/区间/集合;无端口匹配 → `<proto>:any`)。 +- 判据 = `acceptSet ⊆ NFT_ALLOW_INBOUND`:今天 **`∅ ⊆ 白名单` 成立 ⇒ PASS**。 +- ⚠️ **诚实标注**:因为集合为 ∅,**当前白名单并未被"现实样本"压过** ⇒ 本棒为此补了**两条夹具**(`fx-nft-extra.json` 注入 `tcp:9999`;`fx-nft-text-extra.txt` 注入文本行)**证明"多出必红"**,⛔ 而不是让它"永远绿着过"。 +- **"说不清来源的入站 accept" = 0 条** ⇒ 无需转 §8.8。 + +### 8.7 零回归 / 越界自证 + +| 项 | 命令 | 实测 | 期望 | +|---|---|---|---| +| `npm test` | `npm.cmd test`(Node **v22.22.2**) | **# tests 160 / # pass 159 / # fail 0 / # cancelled 0 / # skipped 1**;rc=0 | ≥ 160/159/0/1 ✅(**与基线逐字一致 ⇒ 零回归**) | +| `--scene all` | `node scripts/overlay-failover-drill.cjs --scene all` | **12 PASS / 0 SKIP / 0 FAIL**;rc=0(⚠️ 第 3 次尝试;见 §8.8-1) | 12 PASS / 0 FAIL ✅ | +| 监听口 / nft | `ssh bt-server "ss -lntp \| wc -l; nft list ruleset \| wc -l"` | **77 / 72** | 77 / 72 ✅(**本单不产生新监听口**) | +| 越界:`src/` | `git status --short src/ \| wc -l` | **20**(改动前 20 ⇒ **Δ0**) | Δ=0 ✅ | +| 越界:总数 | `git status --short \| wc -l` | **43**(=改动前基线;⚠️ `scripts/overlay-probe.cjs` 是 **untracked**(`??`)⇒ **改它不改计数**) | 43 ✅ | +| `.bak` 残留(仓内) | `git status --short \| grep -i bak` | **空** | 空 ✅(本单备份全在 `_中间产物_待清理/seq11/`,⛔ 不进仓) | +| 演练 env 残留 | `ls /etc/systemd/system/dshs.service.d/` + `systemctl show dshs -p Environment` | 无 `zz-drill-override.conf`;`DRILL_*` 命中 **0** | 0 ✅ | +| **E8 回滚可用** | `cp -a …bak-<ts> → probe` ⇒ 跑探针 ⇒ `cp -a …new-<ts> → probe` | 回滚后:`FAIL OBS-11 监听口=77(阈值 79) …` ⇒ **10/12**;恢复后 md5 = `d7e3ed77c1e0ea983b5ee879abde3130`(与改后一致) | 回到 10/12 ✅ | + +### 8.8 未过项 / 在册未办(逐条:现象 / 已做到哪一步 / 回头条件) + +**§8.8-1 🔴(新发现 · 环境级 · 非本单引入)106 的 `sshd` 被 MaxStartups 限流 ⇒ 脚本化 ssh 偶发 `rc=255`,会让整场演练中止** + +- **现象**:`--scene all` 第 1 次在**幕 1 全绿后**崩(4 PASS / 0 FAIL,1m24s),第 2 次在**启动阶段**崩(8s,0 项),两次都停在 `overlay-failover-drill.cjs` 的 `ssh()` 抛 `Error: Command failed: ssh … test106 …`;**第 3 次通过(12 PASS)**。 +- **根因(证据级)**:106 的 `journalctl -u sshd --since '-25min'` 命中 **586** 条 error/refus/timeout/preauth(对照 47 仅 **3** 条),其中含原文 **`sshd[6366]: error: beginning MaxStartups throttling`**,另有大量 `Connection closed by authenticating user root … [preauth]`/`invalid user ubuntu`(**外部爆破流量**)+ `kex_exchange_identification: read: Connection reset by peer`。`/etc/ssh/sshd_config` 的 `MaxStartups` 为**默认 `10:30:100`**、106 的 **fail2ban `inactive`** ⇒ 未认证连接槽位被爆破流量打满 ⇒ **新连接被丢** ⇒ ssh `rc=255`。手工具复现:同一条重命令第 1 次 `rc=255`、第 2/3 次 `rc=0`(~700 ms)。 +- **已做到哪一步**:① 已确认**与本单改动无关** —— 本单零 `src/` 改动、且演练**不消费** `overlay-probe.cjs`,探针只连 47(47 侧 3 条错误、E1 全绿);② 已用**第 3 次尝试**取得 E6-2 的 12 PASS / 0 FAIL;③ 已定位到 sshd 侧的限流证据与爆破流量来源。 +- **回头条件**:① **给 106 的 sshd 调 `MaxStartups`/起 fail2ban/把 106 的运维 ssh 改走 47 跳板**(⚠️ 前两项属**放宽/收紧一个安全参数** ⇒ 其中"放开限流"命中 **R5**,**⛔ 本棒不做、不在本单范围内**);② 或让脚本化 ssh 具备**退避重试**(属改 `overlay-failover-drill.cjs` ⇒ 单外,**先报告**)。 +- **在册**:转入下一棒(**平台面既有缺陷**,会影响**任何**针对 106 的脚本化 ssh,不只是演练)。 + +**§8.8-2 🟡 与单不符的两处 —— 已按"实质判据"处理(供下一轮修单模板时吸收)** + +1. **P1-注 的期望值不可满足(单方瑕疵)**:单内写 `grep -nE '\*' ss47-before.txt` ⇒ **零命中**。**实测不可满足** —— `ss -lntp` 的 **peer 列**恒为 `0.0.0.0:*`(76 行**每行都有** `*`)。⇒ 实质判据 = **Local Address:Port 列零 `*`**:`awk 'NR>1 && $1=="LISTEN"{print $4}' … | grep -c '\*'` = **0** ✅。⚠️ 顺带踩到一次:首跑 `ssh … > f 2>&1` 把 **3 行 ssh PQ 告警**写进了取证文件(77 → 80)⇒ **取证落盘一律 `2>/dev/null`**(已复取干净版)。 +2. **P4 / E7 的 `src/` 期望值有误(单方瑕疵)**:单内写「`src/` 改动 = **0**」。**实测 20**,且**与本单无关** —— 它是**既存未提交基线**(17 个 `M` + 3 个 `??`:`src/net/relay/`、`src/web/routes/overlay.ts`、`src/worker/relay-tunnel.ts`;后者正是 §5.9-**Q4** 的留档缺口)。⇒ 实质判据 = **本单不得新增 `src/` 改动** ⇒ 20 → **20(Δ0)** ✅。⚠️ 另一处易误读:总计数 43 **不因本单而变**,因为 `scripts/overlay-probe.cjs` 本身是 **untracked**(`??`)—— **不能拿"总数没变"当"没改文件"的证据**,判据要落到 `md5sum` 与 Δ 上。 + +**§8.8-3 📌 §5.9 的 Q1–Q5 原样转下一棒(⛔ 本棒未修,R7)** + +| 序 | 缺陷 | 本棒动作 | +|---|---|---| +| **Q1** | `mksess*.cjs` 失效(`/opt/dshs/mksess*.cjs` 仍写 SQLite 旧库)⇒ **阻塞实例面验收** | ⛔ 未修,原样转办(**下一棒第一件**) | +| **Q2** | guest(w-106) 实例页 **502** | ⛔ 未修,原样转办 | +| **Q3** | `OBS-09` 实例面 `000`(**环境态非缺陷**) | ⛔ 未动(本棒**不起实例**;起实例属 Q3,与 Q2 同批最省) | +| **Q4** | `src/net/relay/**` untracked 留档缺口 | ⛔ 只报告(**卡在用户授权**,不在技术侧) | +| **Q5** | `client.ts.bak-seq7-*` 残留 | ⛔ 未动;本棒已**以身作则**立起"`.bak` 只在执行期存在"这条规矩(本棒仓内 `.bak` = 0) | +| **Q6** | 参数表 `LISTEN_COUNT`=79 与现态的 1 条无名口 | ✅ **已结构化吸收**(集合判据不再依赖计数;那"1 条"就是 `20000` 实例档 ⇒ 现已在允许区间内)。⚠️ 剩余项「47 恢复活跃实例后复取应回 78 行」的复核**与 Q3 合批** | + +**⚠️ 另记一条(本棒未触发但已备查)**:`RELAY_FAILOVER_COOLDOWN_MS` 全程 **未被触碰**;`grep` 演练 / 回滚 / 夹具路径中该键的 `=0` 形态 = **0 命中**(收口复核:`dshs.service.d` 下无 `zz-drill-override.conf`、`DRILL_*` env 命中 0)。 + +### 8.9 指纹(收口复取 · 全部现取) + +| 项 | 命令 | 值 | +|---|---|---| +| **参数表**(§10 不计入) | `sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md \| md5sum` | **`8f08e74b026e6e5b5e1b3db813f031ae`**(出单基线 `99e9e17b0c1ce0550e4bc7626a5a0494` ⇒ **已变** ✅ E9) | +| **本交接单前缀**(§8 及其后不计入) | `sed '/^## §8 回报格式/,$d' 交接单_观测口径与在册缺陷_20260917.md \| md5sum` | **`3ece0f870cba67d0113a4c5f9de9d812`**(**回填后未变** ✅) | +| **交叉证据**:检测时延单(§8 前缀口径) | `sed '/^## §8 回报格式/,$d' 交接单_检测时延与deadline_20260917.md \| md5sum` | **`d903b4eeabf25ef381379cdbaac77e8a`**(未变 ✅) | +| **探针(代码)** | `md5sum D:/github/dsh_shenxian/scripts/overlay-probe.cjs` | 改前 `0cd76d986273e04631510aa7d215d16c` ⇒ **改后 `d7e3ed77c1e0ea983b5ee879abde3130`** | +| **入口** | `md5sum 接续入口_覆盖网络线_20260916.md` | 收口取(本单未在此登记,避免自指) | +| **原始取证落盘** | — | `_中间产物_待清理/seq11/`(P1 原文、6 份夹具、`nft47.json`/`nft-text.txt`、`status-fixture.json`、探针 `.bak-<ts>` 与 `.new-<ts>`、`npmtest.txt`、`drill-all-*.txt`、`real-*.txt`、生成器 `mk-fixtures.py`/`mk-nfttext-fx.py`/`nft-summary.py`)⇒ **保留供第三方复核**(⛔ 不写进正式档案) | + +--- + +## §9 执行回报(序⑬ 执行棒 · 2026-09-17 15:2x–15:5x · 在册小缺陷清算 Q1→Q3) + +### 9.1 结论(逐条) + +| 序 | 结果 | 判据 | +|---|---|---| +| **Q1** | ✅ **已修并验收** | `/opt/dshs/mksess{,-guest}.cjs` 改插 **PG**;修前实证写进 SQLite(PG `sessions`=2 / SQLite=3);修后临时会话可直插 PG,**实例面 `curl` 回 `401`(⛔ 非 `000`)** | +| **Q2** | 🔴 **未达成 —— 阻塞在一条新发现的数据面缺陷** | w-106 用户 `POST /api/dsh/enter` 仍 **500**;**真因不是单内记的 `landModels → writeHomeFile`**,而是「relay 流上的 HTTP keep-alive 复用」⇒ 见 **9.3** | +| **Q3** | ✅ **达成(探针 12/12 · rc=0)** | `OBS-09` 绿(本机 `20000`=401、w-106 `42461`=401)**且**"有实例态"下 `OBS-11` 仍绿(实际 77 / 多出 0 / 缺失 0、`20000` 落在允许区间) | + +### 9.2 Q1 · `mksess*.cjs` 从 SQLite 改到 PG(✅ 已修) + +- **前提复现(D1)**:跑旧 `/opt/dshs/mksess-guest.cjs` ⇒ **PG `sessions` 仍是 2、SQLite `sessions` 变 3** ⇒ 会话落错库、平台读不到(R4 的实例面验收手段整体失效)。 +- **改法**:实现**只有一份** = `/opt/dshs/mksess.cjs` —— 连接串**从 `DSHS_DB_URL` env → `/etc/dshs.env` → `dshs.service.d/*.conf` 逐个找**,⛔ **不把凭据固化进 0644 脚本**;`mksess-guest.cjs` = `process.argv[2]='guest'; require('/opt/dshs/mksess.cjs')` 薄封装(保留原文件名 —— 档案 76/77 与 `dsh-plugin-diagnose` 等多处按此名引用)。 +- **验收**:`node mksess.cjs` ⇒ token 64 字符、PG `sessions` 2→3、`user_agent='poc-curl2'` 命中;**实例面 `curl -o /dev/null -w '%{http_code}' http://127.0.0.1:20000/` = `401`**(⛔ 非 `000`)。 +- **指纹**:`mksess.cjs` = **`a97c0f214650fbc552c07d804fd153be`**|`mksess-guest.cjs` = **`af6eab38c4ff5c365ee17e29d7b98a31`**。原件备份:`/opt/dshs/mksess{,-guest}.cjs.bak-seq13-20260917-152653`(本地副本 `_中间产物_待清理/seq13/`)。 +- **回滚**:`cp -a /opt/dshs/mksess*.bak-seq13-20260917-152653` 回原名 + `DELETE FROM sessions WHERE user_agent='poc-curl2'`。 +- ⚠️ **遗留(R7 · 只报告未动)**:只改了 `/opt/dshs/` 这一处;**文档库与技能里对 `mksess.cjs` 的描述("DB 直插")仍是旧口径** ⇒ 需要时另起一小棒同步。 + +### 9.3 Q2 · guest(w-106) `enter` 500 —— **真因是数据面(新缺陷)** + +**现象(3/3 稳定复现)**: + +``` +POST /api/dsh/enter (guest) ⇒ 500 +{"statusCode":500,…,"message":"agent POST /fs/isdir → 400: {\"error\":\"Bad Request\",\"message\":\"Client Error\",\"statusCode\":400}"} +``` + +**最小复现(1 条命令,在 47 上跑)** —— 对**拨号池回环口**的**同一条 TCP 连接**连发两条请求: + +``` +curl -s --http1.1 -w ' -> %{http_code}\n' -X POST -H 'content-type: application/json' \ + -H 'x-dsh-agent-token: <w-106 的 agent_token>' -d '{"userId":"<guest id>","relPath":""}' \ + http://127.0.0.1:25000/fs/isdir http://127.0.0.1:25000/fs/isdir +⇒ {"isDirectory":true} -> 200 +⇒ {"error":"Bad Request","message":"Client Error","statusCode":400} -> 400 +``` + +(`/touch` 或 `/status` 打头、`/fs/isdir` 收尾同样复现;两条 GET 也复现 ⇒ **与请求体无关**。) + +**取证链(逐层排除)**: + +1. **客户端无辜**:Manager 侧 `strace -f -e trace=write,writev -p <dshs pid>` 抓到写出的就是一条**格式完全正确**的请求(`POST /fs/isdir HTTP/1.1`、`content-length: 62`、body 完整)。 +2. **走的就是拨号池口**:`ss` 见文件面连的是 `127.0.0.1:25000`;`[relay-dialer] 落点 127.0.0.1:25000 -> ops/w-106:19000`(`localPortFor('ops/w-106', 19000)`)。`/touch`、`/status` 因**并发**各占一条新连接 ⇒ **各占一条流 ⇒ 都成功**;`/fs/isdir` 落到**已被复用**的那条 ⇒ 失败。 +3. **400 的出处已定死**:body 与 Fastify `node_modules/fastify/fastify.js:985` 的 **`clientError` 分支**逐字一致 ⇒ 这是**收到非法字节流时写裸 socket 的兜底响应**,⛔ 不是任何业务路由返回的;它**不写 pino 日志** ⇒ 这正是"106 worker 日志里什么都没有"的原因。 +4. **106 侧抓包(`tcpdump -i lo -A -s0 'tcp port 19000'`)**: + - 全场**只有 1 条** `POST /fs/isdir` 到达 agent,紧接着 agent 回 400; + - 🔴 **关键**:`57244 > 19000` 的载荷是 **`HTTP/1.1 200 OK … {"isDirectory":true}`** —— 即**worker 侧把 agent 自己上一份响应回灌给了 agent** ⇒ agent 把响应行当请求行解析 ⇒ `clientError 400`。 +5. **因此**:责任面收敛到 **relay 的 stream 路由**(`src/net/relay/server.ts` 的 stream 表 / `workerStream` 与 `dialStream` 的映射)与 **worker 侧本地转发**(`client.ts#onOpenRequest` / `onRemoteData` → `st.tcp`)+ **拨号池收尾**(`dialer.ts` 的 `tcp.pipe(duplex).pipe(tcp)`,`duplex` 关闭时**没有** `tcp.destroy()`)。 + +**影响面(⚠️ 比在册描述严重)**:不是"guest 一个用户的 502",而是 **任何 `via='relay'` 的 host(今天 = w-106)** —— 只要一次业务动作对同一 host 发**第 2 条** agent 请求(`enter` 的 `isDirectory` → `launch` → `waitUpOnStatus` 轮询…)就必失败 ⇒ **w-106 用户"登录直达工作区"整体不可用(用户可见)**。 + +**已做到哪一步 / 卡在哪**:故障面已收敛到上面那一组文件,**但帧级取证(relay 侧 `workerStream`/`dialStream` 路由表逐帧日志)未做** ⇒ 尚未定位到具体代码行。 + +**回头条件(必须回头)**:① 拿到 relay 帧级路由证据并定位代码行;② 修复判据 = guest `enter` 回 **200**、同一连接连发两次都 **200**、`npm test` 不退化(基线 160/159/0/1)、`--scene all` 不退化、`overlay-probe` 保持 **12/12**。 + +**⛔ 本棒未动手(R7 + R11)**:这是**数据面**变更;在拿到帧级证据前盲改(例如只给拨号池补 `tcp.destroy()`)会把"静默给出错误答案"换成别的形态,还可能**掩盖 relay 侧的真错** ⇒ 属净风险,留作下一棒主题(**序 ⑭**)。 + +### 9.4 Q3 · `OBS-09` +「有实例态下的 `OBS-11`」(✅ 达成 · 探针 **12/12**) + +- **起实例**:① **admin(w-47,本机直连,不经 relay)** 经 `POST /api/dsh/enter` ⇒ **`200`**、`port=20000`、`status=running`(这同时就是 **Q1 的实例面验收**:`curl http://127.0.0.1:20000/` = **401**,⛔ 非 `000`);② **guest(w-106)** 因 9.3 的缺陷堵在 `enter`,改由 **106 的 worker agent `POST /launch`**(带 `uid=100002` + `ws` 目录)拉起 ⇒ `200`、`port=21000`、`pid=237067`。⚠️ **该实例未经 Manager 租约**(为把 `OBS-09` 的对端腿测出来而"出格"了一步,已在工作区日志登记)。 +- **探针结果**:**`12/12 PASS · rc=0`**,其中 + - `PASS OBS-09 实例面 本机:20000=401 w-106:42461=401 (阈值 ∈ {200,401})` + - `PASS OBS-11 集合 必在 7 允许 4 区间 3 派生 2 实际 77 多出 0 缺失 0 |nft accept 0 多出 0 |relay 口绑定回环=1/1 条` —— **正是 Q3 要的那一问:有实例态下 `20000` 落在允许区间** + - `PASS OBS-08 端点表 2 条 / 离线 0 条`(比无实例态多 1 条) +- `ss -lntp | wc -l` = **78 = 1 表头 + 77 socket**(无实例态 76 + 实例 `20000` 一口)⇒ **无多余监听**。 +- **回滚(要回到"无实例态")**:`systemctl stop dsh-100002-*.scope`(106)+ 停 w-47 那个实例;此后 `OBS-09` 回到红 —— 那是**环境态,非缺陷**。 +- ⚠️ **回头条件**:guest 实例是**出格拉起**的;一旦 Manager 的 reaper 回收它 ⇒ `OBS-09` 对端腿回 `000`(**环境态**,按 Q3 语义非缺陷)。 + +### 9.5 其余(R7 · 只报告,⛔ 未动手) + +| 序 | 状态 | +|---|---| +| **Q4** | `src/net/relay/**` untracked 留档缺口 —— **未动**(卡在提交授权;`git status --short src/net/relay/` = 1) | +| **Q5** | `src/net/relay/*.bak-seq7-*` 残留 —— **未动**(本棒仓内 `.bak` = 0,规矩继续) | +| **Q6** | 已被序⑫ 结构化吸收;本棒复取 `实际 77`、`20000` 在允许区间 ⇒ **可关闭** | +| 🔴 新发现 | **9.3 的 relay keep-alive 缺陷**(= 下一棒主题) | + +### 9.6 越界自证 & 三条硬门 + +- `git status --short` = **43**(Δ **0**)|`git status --short src/` = **20**(Δ **0**)|仓内 `.bak` = **0**。 +- **D1**:全程**未改任何生产值** —— 参数表指纹仍 `8f08e74b026e6e5b5e1b3db813f031ae`、`RELAY_FAILOVER_*` / `HB_SEC` / burst 一字未动。 +- **R7**:Q4 / Q5 只报告;文档库・技能侧对 `mksess` 的旧描述未改(见 9.2 遗留)。 +- 🔴 `RELAY_FAILOVER_COOLDOWN_MS=0`:**0 命中**(未写入任何回滚 / 演练 / 夹具路径)。 +- **R4 收尾**:临时会话用完即删(`DELETE … user_agent='poc-curl2'` ⇒ `DELETE 2`,`sessions` 回 **2 = 基线**)。 + +### 9.7 指纹(收口现取) + +| 项 | 值 | +|---|---| +| Q1 产物(47) | `mksess.cjs` **`a97c0f214650fbc552c07d804fd153be`**|`mksess-guest.cjs` **`af6eab38c4ff5c365ee17e29d7b98a31`** | +| 参数表(§10 不计入) | **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变** ⇒ D1 自证) | +| 本交接单 §8 前缀(追加 §9 后复取) | **`3ece0f870cba67d0113a4c5f9de9d812`**(**未变** —— `§9` 在 §8 之后,不进前缀口径) | +| 探针(代码,未改) | `d7e3ed77c1e0ea983b5ee879abde3130` | +| 原始取证落盘 | `_中间产物_待清理/seq13/`(`mksess.cjs` / `mksess-guest.cjs` / `probe-isdir.cjs` / `q2-capture.sh`);106 抓包与 47 strace 原文留在各机 `/tmp`(`cap.pcap` / `st2.txt`) | +| 下一棒 | automation **`3e0a7b01-9257-4b76-b82f-4998c9f7eab0`**(`scheduledAt` = 2026-09-17 **15:53**,`nextRunAt` = 1789631580000) | + +--- + +## §10 执行回报(序⑭ 执行棒 · 2026-09-17 15:53–16:1x · relay 流 keep-alive 复用缺陷) + +### 10.1 结论(逐条) + +| 项 | 结果 | +|---|---| +| **D1 · 先复现** | ✅ 复现,与 §9.3 逐字一致:同一条连接连发两条 ⇒ `{"isDirectory":true} -> 200` + `{"error":"Bad Request","message":"Client Error","statusCode":400} -> 400` | +| **② 帧级取证 → 定位到行** | ✅ **定位到 `src/net/relay/client.ts#openStream` 里的一行 `duplex.on('data', …)`**(原 975 行) | +| **③ 先红后绿** | ✅ 红:目标端收到 **2 段**回灌字节 `HTTP/1.1 200 OK …`;绿:修后 `T23` 通过(无杂音) | +| **④ 端到端验收** | ✅ guest `enter` **200**、同连接连发两次 **200/200**(三次 **200/200/200**);`npm test` **160 pass / 0 fail / 1 skip**(基线 159 pass +T23);`--scene all` **12 PASS / 0 SKIP / 0 FAIL**;`overlay-probe` **12/12 PASS · rc=0** | + +### 10.2 复现(D1 自证) + +命令 = §9.3 原文那一条(47 上,对拨号池口同连接连发两条)。**修前**:`200` 然后 `400`。⛔ 全程未改任何生产值。 + +### 10.3 帧级取证与定位(**这是本棒的核心**) + +**真因**:`RelayClient#openStream()` 把**读侧**接回了**出向**。 + +```ts +// src/net/relay/client.ts(修前,openStream 内) +const duplex = new MuxDuplex({ onOut: (chunk) => this.pumpDial(id, chunk), … }) +this.dialStreams.set(id, st) +duplex.on('data', (chunk: Buffer) => this.pumpDial(id, chunk)) // 🔴 就是这一行 +``` + +**机制(逐跳)**:目标(agent)的响应 → worker `tcp.on('data')` → `pumpToRelay` → relay → 拨号方 `onRemoteData` → `dial.duplex.feed(payload)` → `push(chunk)` 触发 `'data'` → 🔴 **该监听把它 `pumpDial()` 又打回 relay** → worker 写进 agent socket ⇒ agent 拿 `HTTP/1.1 200 OK` 当**请求行**解析 ⇒ 非法字节流 ⇒ Fastify `clientError` 兜底回 `400`(写裸 socket、**不写 pino 日志**)。 + +⇒ **`200` 被回灌 ⇒ agent 回 `400` ⇒ 该 `400` 顺着同一条 keep-alive 连接被 curl 当成"第 2 条请求的响应"** —— 这就是"第 2 条必 400"的真实来源(curl 打印的 `400` **不是**第 2 条请求的应答,而是第 1 条响应的回灌回声)。 + +**为什么现有测试抓不到**:`test/relay.test.mjs#dialRoundTrip` 一凑够 `text.length` 就 `resolve`,多出来的那份回声**没人看**。 + +**排除项(逐层)**:relay 服务端无辜 —— `server.ts#onData → onDialerData` 只把 DATA 投给**对端**(`st.peer`),不镜像回发送方;错误方向只能是拨号方自己产生的。`dialer.ts` 的 `tcp.pipe(duplex).pipe(tcp)` 形状正确(`pipe` 走 `_write` → `onOut`)。 + +### 10.4 改法(唯一改动) + +- **`src/net/relay/client.ts`**:**删掉**那一行 `duplex.on('data', …)`,原地换成一段⛔禁挂说明(含 106 抓包证据与 T23 指针)。 + - **为什么删是安全的**:出向唯一入口是 `_write` → `onOut` → `pumpDial`;`duplex.write()` 与 `tcp.pipe(duplex)` **都走 `_write`** ⇒ 两者本就是同一条路,那个监听**纯属多余且方向错**。 + - **附带修好的一条**:读侧不再被提前"吃掉" ⇒ `openStream()` 返回到 `tcp.pipe(duplex)` 之间到达的响应会**留在读缓冲**而不是被丢/被回灌。 +- **`test/relay.test.mjs`**:新增 **T23**「拨号流严格单向」—— 目标端**逐帧记账**(`POST …` 记 `requests`、其余记 `garbage`),一旦入向回灌 ⇒ `garbage` 非空 ⇒ **断言点名**(不是"计数差不多"那种软判据)。 + - 文件已在 `package.json` 的 `npm test` 列表内 ⇒ ⛔ **未改 `package.json`**(守住"改动不超出 `src/net/relay/**` + 其测试")。 + +### 10.5 先红后绿(原文级) + +``` +修前(旧 lib): +not ok 1 - T23 拨号流严格单向:入向的响应 ⛔ 不得被回灌进 agent socket + 拨号流不是单向的 —— 入向字节被回灌进 agent socket(共 2 段): + ["HTTP/1.1 200 OK\r\ncontent-length: 19\r\n\r\n{\"isDirectory\":true}", …(同上)] + +build 后:ok 1 - T23 … # pass 1 / # fail 0 +``` + +⚠️ **过程中踩到并修掉的测试卫生坑**:`onDown` → `teardownDialStreams()` 会 `duplex.destroy(new Error('link down: …'))`,而 `MuxDuplex` 上没有 `'error'` 监听 ⇒ Node 抛 **uncaughtException**,表现 =「T23 自己 pass、整个文件 fail」。生产侧 `dialer.ts` 有同形监听,测试里补上即可(已在 T23 的 `t.after` 注释里写死原因)。 + +### 10.6 端到端验收(原文级) + +``` +① guest POST /api/dsh/enter(同连接 ×2) +{"kind":"session","instance":{"id":"ae024d2c-…","port":21000,"status":"running"},"url":"https://guest.alotbuy.com/?token=…"} -> 200 +(同上)-> 200 + +② 帧级复验:已分配的池口 = 25000(同连接 ×3) +{"isDirectory":true} -> 200 / 200 / 200 (修前 = 200 后必 400) +``` + +- `npm test` = **160 pass / 0 fail / 1 skip**(= 基线 159 pass + T23,**零退化**) +- `--scene all` = **12 PASS / 0 SKIP / 0 FAIL**(幕1-A 17874ms、幕4-A 18754ms 均 < deadline 30000ms) +- `overlay-probe`(带参数表)= **12/12 PASS · rc=0** +- **部署**:`client.js`(`b8b29afba06ed6347cbefd29091f8c73`)铺到 **5 处** —— 47 `/opt/dshs/lib`、47 `/opt/dshs-cluster/lib`、47 `/opt/dsh-relay/lib`、106 `/opt/dshs-cluster/lib`、106 `/opt/dsh-relay/lib`;各留 `.bak-20260917-1558xx`。 + - **只重启了 47 的 `dshs`(Manager = 拨号方)**;`dshs-relay` / 106 `dshs-worker` **未重启**(改动是**拨号方专属**一行,worker/relay 侧行为逐字未变)。 + - **R7 传播面自证**:铺之前对本机 `lib/net/relay/*.js` 与 47 `/opt/dshs/lib/net/relay/*.js` 逐文件对账 ⇒ **Δ 只有 `client.js` 一个文件**(无夹带改动)。 + +### 10.7 越界自证 & 三条硬门 + +- **D1**:参数表指纹 **`8f08e74b026e6e5b5e1b3db813f031ae`(未变)** ⇒ **未改任何生产值**;`RELAY_FAILOVER_*` / `HB_SEC` / burst 一字未动。 +- **R7**:`git status --short` = **43**、`src/` = **20**(均 Δ0);仓内 `.bak`/`.tgz` = **0**;⛔ **未 commit / 未 push**。 +- 🔴 `RELAY_FAILOVER_COOLDOWN_MS` 置 0:**本机仓 0 命中 / 47 lib 0 命中**(未写进任何回滚·演练·夹具路径)。 +- **R4**:临时会话用完即删(`DELETE 1` ⇒ `sessions` 回 **2 = 基线**)。 +- **未超范围**:改动只有 `src/net/relay/client.ts` + `test/relay.test.mjs` 两个文件,**未动 `package.json`、未动 worker 侧协议、未重启 relay 之外的服务**(Manager 重启属本次修复必需,已按 R8 直接做)。 + +### 10.8 🔴 新发现在册缺陷 B(R7 · **只报告,未动手**) + +| 项 | 内容 | +|---|---| +| **现象** | 拨号池**未分配**的槽位,被**任意一条连接**碰到后 ⇒ **该槽位永久死亡**;而 `localPortFor()` 之后照样把这个口号发出去 ⇒ **`ECONNREFUSED`**(本棒实测:`[relay-dialer] 落点 127.0.0.1:25000 -> ops/w-106:19000` 紧接 `connect ECONNREFUSED 127.0.0.1:25000`,`enter` 回 500 `fetch failed`) | +| **出处** | `src/net/relay/dialer.ts#onConn`:`const key = slot.key; if (key === undefined) { slot.server.close(); tcp.destroy(); return }` —— 关闭**服务器本身**,却**不把该槽位从 `slots` 里摘掉**,于是 `key` 仍是 `undefined` ⇒ `localPortFor()` 的 `slots.find(s => s.key === undefined)` **下次还会选中它** | +| **怎么被触发** | 本棒**自己**触发:口池刚绑好、落点还没分配时,对 `127.0.0.1:25000` 打了一次探针(§9.3 那条命令的"提前版")⇒ 25000 当场死掉;池报"64 个口"而 `ss` 只见 **63**,正是这个缺口 | +| **为什么算缺陷** | 属本线反复强调的"**静默给出错误答案**"同族:**失败被推迟**到下一次分配,且现场只有 `ECONNREFUSED`,看不出"这个槽位早废了" | +| **候选修法(⛔ 未实施,留给下一棒先取证)** | ① `onConn` 撞到未分配槽位时**只 destroy 连接、不 close 服务器**(最小改动,但"外部随机连入"仍会被静默丢弃);② close 之后**把槽位从 `slots` 摘掉/标记废弃**,让 `localPortFor` 不再选中(更彻底,需处理 `bound` 计数与日志);③ 池口号改绑在**独立的不可路由网段/更冷门口位**并加"首次使用才分配"的显式状态机。**判据**:未分配槽位被连接后,`status().pool` 与实际在听口号数必须**恒等**,且 `localPortFor` 永不返回已死口号 | +| **回头条件** | 单独一棒处理;⛔ 在拿到"槽位被谁连、何时连"的帧级证据前**不要**只改 `onConn` | + +### 10.9 其余在册未办(原样转下一棒,⛔ 未动手) + +| 序 | 状态 | +|---|---| +| **Q4** | `src/net/relay/**` **untracked** 留档缺口 —— ⚠️ **本棒的修复正好落在该缺口内**(`client.ts` 是新文件、`test/relay.test.mjs` 也是 `??`)⇒ 二者目前**只存在于工作区,不在版本库里**。commit / push 仍需**你的明确授权**(本棒未获授权,未动)。 | +| **Q5** | `src/net/relay/*.bak-seq7-*` 残留 —— 未动(本棒仓内 `.bak` = 0) | +| **seq13 遗留** | 文档库・技能侧对 `mksess.cjs` 的描述("DB 直插")仍是旧口径 ⇒ 需同步时另起一小棒 | +| **长期在册** | `dsh-auto-handoff-chain` 在文档库**无副本** ⇒ 该技能的"三处同步"从未建立 | +| **§8.8-2** | 幕4-A 耗时仍**贴近** deadline(本次 18754ms / 30000ms)⇒ 临界项,未恶化也未解决 | + +### 10.10 指纹(收口现取) + +| 项 | 值 | +|---|---| +| **修复源文件** | `src/net/relay/client.ts` = **`6ffb117f3f6df1c2456641e176529ce8`** | +| **测试文件** | `test/relay.test.mjs` = **`14aa2808bc6446d34af19306d43c4e8d`**(1149 行,含 T23) | +| **部署产物** | `lib/net/relay/client.js` = **`b8b29afba06ed6347cbefd29091f8c73`**(五处远端同值) | +| 参数表(§10 不计入) | **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变** ⇒ D1 自证) | +| 本交接单 §8 前缀(追加 §10 后复取) | **`3ece0f870cba67d0113a4c5f9de9d812`**(**未变** —— §10 在 §8 之后,不进前缀口径) | +| 原始取证落盘 | `_中间产物_待清理/seq14/`(`deploy-seq14.sh` / `evidence-47.sh` / `final-verify.sh`);`--scene all` 原文 = 本地 `/tmp/seq14-scene-all.txt` | +| 下一棒 | automation **`1933b18a-0c2a-46a2-829c-440a900fed08`**(「覆盖网络线-序15执行棒-拨号池槽位自毁缺陷B修复」,一次性,`scheduledAt` = 2026-09-17 **16:14**,`nextRunAt` = 1789632840000) | + +--- + +## §11 执行回报(序⑮ 执行棒 · 2026-09-17 16:14–16:3x · 拨号池未分配槽位自毁「缺陷 B」) + +### 11.1 结论(逐条) + +✅ **缺陷 B 已修并端到端验收**:`src/net/relay/dialer.ts`(1 个源文件)+ `test/relay.test.mjs`(新增 T24);判据 **a / b / c 三条全绿**;⛔ 未改任何生产值、⛔ 未 commit / push、**零上抛**;收口 6 件全办。 + +### 11.2 复现(D1:先复现、后动手 —— ⛔ 没在"只看到 `ECONNREFUSED`"的情况下动 `onConn`) + +**① 进程内确定性复现**(`_中间产物_待清理/seq15/repro-defect-b.mjs`;私有口段 47000、私有实例段 47100,**零生产影响**)—— 修前原文: + +``` +① 池就绪(注册=true):status().pool = 4 · 实际在听 = 4 · 区间 47000..47003 +② 对未分配口 47000 发一条连接 ⇒ connected + status().pool = 4 · 实际在听 = 3 ⇒ 🔴 判据 a 不等(池在撒谎) + 该路径日志行数 = 0 +③ localPortFor(ops/w-xs, 47110) = 47000(死口 = 47000) +④ 🔴 判据 b:落点不通 = ECONNREFUSED +``` + +**② 47 现网事实**(只读取证,落盘 `A47.txt`;⛔ 全程未连任何池口): + +- `dshs` 于 **16:09:11** 重启 ⇒ 池口 **64 个全在听**(`ss -lntH 'sport >= :25000 and sport <= :25063' | wc -l` = 64)、`ss -lntp` 总行数 **79**、逐口核对 **无缺失**。 +- journal 里 **`ECONNREFUSED 127.0.0.1:25000` 全量 3 条,全部落在 16:00:31 / 16:00:35 / 16:00:40**,全部是 `POST /api/dsh/enter` → **500 `fetch failed`**(栈 = `supervisor/remote-spawner.js:142` ← `:211` ← `web/routes/dsh.js:200`)。 +- 而这三条**紧跟在 16:00:27 的 `[relay-dialer] 落点 127.0.0.1:25000 -> ops/w-106:19000` 之后** ⇒ **"落点已分配、却连不上"** —— 这正是"该槽位早就死了"的**演绎证据**(唯一能关掉池槽位监听又保留槽位的路径就是 `onConn` 的 `key === undefined` 分支)。 +- **谁 / 何时连了未分配槽位**:**是我们自己的取证探针触发的,不是自然发生** —— 该路径**零日志零计数**(全量 journal `grep -c '未分配'` = **0**),只能靠上面那组矛盾反推。⇒ 这也正是"必须补日志/计数"的直接理由。 + +### 11.3 定位(到代码行) + +`src/net/relay/dialer.ts#onConn`: + +```ts +const key = slot.key +if (key === undefined) { slot.server.close(); tcp.destroy(); return } +``` + +关掉的只是**这个口的服务器**,而槽位**仍留在 `slots` 里、`key` 仍是 `undefined`** ⇒ `localPortFor()` 的 `slots.find((s) => s.key === undefined)` **下次还会选中它** ⇒ 把一个没人听的口号当落点发出去。**失败被推迟到下一次分配**,现场只有 `ECONNREFUSED`。 + +### 11.4 改法(三个候选里取 ①,理由 = R11) + +1. **`onConn`:⛔ 不再 `close()` 服务器,只 `tcp.destroy()` + 计数(新增 `stray`)+ 点名日志。** + ⛔ **不取候选 ②**(把槽位从 `slots` 摘掉):那等于让**任意一条本地连接**都能**永久**蚕食池容量(扫 64 次即可把池扫空)= **净退化(R11)**;候选 ③(换口段 + 显式状态机)超出 `src/net/relay/**` 必要范围。 +2. **纵深防御**:`localPortFor()` 的空槽查找与 `lruIdle()` 都**只认 `server.listening` 的槽**;一个可成交槽都没有时**点名"哪几个口已不在听"**再拒(失败关闭,⛔ 绝不发死口号)。 +3. **`status()`**:`pool` 改报**实际还在听的槽位数**(不再报 `start()` 那一刻的常量)+ 新增 `stray` 计数 —— 让"池在撒谎"这类事**不可能不被看见**。 + +### 11.5 先红后绿(原文级) + +- **红**(新用例 + 旧 `lib/`):`判据 a:池账(3) 与实际在听(2) 必须恒等 —— 不等即"池在撒谎"` ⇒ `3 !== 2`(`ERR_ASSERTION`)。 +- **绿**(`npm run build` 后):`ok 1 - T24 拨号池:未分配槽位被一条连接命中后 ⛔ 不得自毁…` / `# pass 1 / # fail 0`。 +- 新用例 = **T24**(`test/relay.test.mjs` 尾部,文件 1318 行);⛔ 未改 `package.json`。 + +### 11.6 端到端验收(47 现网原文) + +**① 收口终验**(`verify-seq15.sh`,含一次 `systemctl restart dshs`)—— ⚠️ 这个顺序**就是缺陷 B 的复现顺序**: + +| 步 | 原文 | +|---|---| +| 干净池基线 | 池口在听数 = **64** |`[relay-dialer] 本机落点池就绪:64 个口(25000..26000)` | +| 🔴 **故意对未分配落点 25000 发一条连接** | `TCP 已连上` ⇒ **打后池口在听数仍 64**、stray 日志 **1** 行:`[relay-dialer] ⛔ 未分配落点 127.0.0.1:25000 收到一条连接 ⇒ 只丢弃该连接、槽位保留(累计 1 次)` | +| guest `POST /api/dsh/enter`(同连接 ×2) | **200 / 200**(`instance.port=21000 status=running`) | +| 帧级(该落点口) | 落点口 = **25000**(就是刚被打过的那一个)|该口在听 = 1|同连接连发 3 次 ⇒ **200 / 200 / 200**(`{"isDirectory":true}`) | +| R4 | `sessions` 清理后回 **2**|结束态池口 = 64 | + +**② 最终态复验**(`verify-seq15-final.sh`,⛔ **不重启**,就在收口现场把同一顺序再走一遍)= `verify-47-final.txt`,逐项与 ① 同(64 → 触发 → **仍 64 / stray=1** → **200 / 200** → 25000 在听 200×3 → 结束态 64)。 + +**③ 零回归**:`npm test` = **162 tests / 161 pass / 0 fail / 1 skip**(基线 160 pass + 新 T24)|`--scene all` = **12 PASS / 0 SKIP / 0 FAIL**(幕 4-A 19537 ms、幕 4-C 20809 ms / 30000 ms)|`overlay-probe --table "E:/…/参数表_覆盖网络_20260917.md"` = **12/12 PASS · rc=0**(`OBS-11 集合 … 实际 78 多出 0 缺失 0`、`OBS-09 本机:20000=401 w-106:33909=401`)。 + +**部署**:`lib/net/relay/dialer.js` = **`6446fe9b23bca2649137adcbf4bf9d1f`**,**五处同值**(47:`/opt/dshs` `/opt/dshs-cluster` `/opt/dsh-relay`;106:`/opt/dshs-cluster` `/opt/dsh-relay`,106 无 `/opt/dshs`);每处各留 `.bak-20260917-1621xx`;**只重启了 47 的 `dshs`**(拨号方专属改动)。 + +### 11.7 越界自证 & 三条硬门 + +| 门 | 自证 | +|---|---| +| **D1** | 参数表指纹(§10 截断口径)仍 **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变**)|⛔ 未动 `RELAY_FAILOVER_*` / `HB_SEC` / burst | +| **R7** | `git status` 总行数 **43**、`src/` **20** 均为既存基线(Δ0);本棒只改 `src/net/relay/dialer.ts` + `test/relay.test.mjs`(两者同处 untracked 的 `src/net/relay/**`、`test/` 属**既有缺口**)|⛔ 未 commit / push|Q4 / Q5 **只报告未动手**;范围**未越出** `src/net/relay/**` + 其测试(⛔ 未动 worker 侧协议、未重启 relay / 106) | +| **COOLDOWN** | 远端**严格判据(赋 0)0 命中**|⚠️ 本棒 `verify-seq15.sh` 里的宽松正则 `"\?0"\?` 会**误报 2** —— 命中 `switcher.js:53` 的默认字面量 `num('RELAY_FAILOVER_COOLDOWN_MS', 300_000)` 里的 `0`;口径以「值被赋 0」为准(`grep -E '=\s*0\|, 0\)\|"0"'`) | + +### 11.8 其余在册未办(原样转下一棒,⛔ 未动手) + +| 序 | 状态 | +|---|---| +| **Q4** | `src/net/relay/**` untracked 留档缺口 —— ⛔ **卡在"提交 / 推送授权"**(本棒未获授权,未动;本棒的修复与 T24 同样只存在于工作区) | +| **Q5** | 代码仓 `src/net/relay/*.bak-*` 残留 **3 个**(本棒未清,R7) | +| **seq13 遗留** | 文档库 / 技能侧对 `/opt/dshs/mksess.cjs` 的「DB 直插」描述仍是旧口径(实际已改 PG) | +| **长期在册** | 技能 `dsh-auto-handoff-chain` 在文档库**无副本** ⇒ 该技能「三处同步」从未建立 | +| **§8.8-2** | 幕 4-A 耗时 **19537 / 20809 ms**(贴近 deadline 30000 ms)⇒ 临界项,未恶化也未解决 | + +### 11.9 指纹(收口现取) + +| 项 | 值 | +|---|---| +| **修复源文件** | `src/net/relay/dialer.ts` = **`f3a608a75f7edd0aa0de8cceb4de5615`** | +| **测试文件** | `test/relay.test.mjs` = **`587f9d80e9330e33badb281e6bd6c719`**(1318 行,含 T24) | +| **部署产物** | `lib/net/relay/dialer.js` = **`6446fe9b23bca2649137adcbf4bf9d1f`**(五处远端同值) | +| 参数表(不计入 §11 口径) | **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变** ⇒ D1 自证;口径 = `sed '/^## §10 指纹/,$d' … \| md5sum`) | +| 本交接单 §8 前缀(追加 §11 后复取) | **`3ece0f870cba67d0113a4c5f9de9d812`**(**未变** —— §11 在 §8 之后,不进前缀口径) | +| 原始取证落盘 | `_中间产物_待清理/seq15/`:`evidence-A-47.sh`+`A47.txt`|`repro-defect-b.mjs`|`deploy-seq15.sh`+`deploy-47.txt`/`deploy-106.txt`|`verify-seq15.sh`+`verify-47.txt`|`verify-seq15-final.sh`+`verify-47-final.txt`|`npmtest-seq15.txt`|`scene-all.txt`|`probe.txt` | +| **下一棒** | automation **`91c53ef8-c3f0-4bb3-9f0a-943790178176`**(「覆盖网络线-序16规划棒-会合中继拆分复核与在册收尾定序」,一次性,`scheduledAt` = 2026-09-17 **16:36**,`nextRunAt` = 1789634160000) | + +### 11.10 范围外 · 只报告(R7,⛔ 未动手) + +**ssh 别名 `bt-server` 指向的 `32022` 是失效残留口。** 本机 `~/.ssh/config` 里 `bt-server` 写的是 `Port 32022`,而 47 上 **sshd 只监听 `22`**(`/etc/ssh/sshd_config:151 Port 22`;master pid 733418 **启动于 2026-09-16 22:12:48**、`/etc/ssh/` 下**无** `sshd_config.d` drop-in;`32022` 无监听)。项目脚本**一律显式写 `ssh -p 22 bt-server`**(近 20 份脚本 42 处均如此)⇒ 只有"直接敲别名"会 `Connection refused`。本棒为绕开它**多花了 3 次调用**;⛔ 未改 47 的 sshd_config、⛔ 未改本机 ssh config(等指示)。 + +--- + +## §12 执行回报(序⑯ 规划棒 · 2026-09-17 16:36–16:5x · 会合/中继拆分复核 + 在册收尾定序) + +### 12.1 结论(1 行) + +**① 会合 / 中继从 Manager 拆分 ⇒ 判「不做」**(四个耦合点 C1–C4 + 两步改造 S3/S4 **逐条已被覆盖,未覆盖部分 = 无**)|**② 剩余在册四条已定序并出单** ⇒ 产物 = 工作区根 **`交接单_在册收尾_20260917.md`**(243 行;**§8 前缀指纹 `bab83b7219b2669d5a6e9f1acf782e1f`**,全文 md5 `ca13b9ded3d71268f8b0e56755d7aea4`)= **序 ⑰ 执行棒**(S1 mksess 文档口径 → S2 技能三处同步 → S3 Q5 清理 → S4 幕 4-A 临界项)。⛔ 只出规划:未改码、未动 47/106、未 commit/push、零上抛。 + +### 12.2 复核判定(① 二选一 → **判「不做」**) + +| 靶子 | 现状证据(只读取证) | 判定 | +|---|---|---| +| **C1** 会合地址硬编码在 Worker env | `src/config.ts:485-490`:`DSHS_RENDEZVOUS_URL` **优先** / `DSHS_TUNNEL_TARGET` 降**兜底**(注释原文「覆盖网络 S1:会合地址出 env」) | ✅ 序② P0-2 覆盖 | +| **C2** 中继落点 = Manager loopback + 两端同号 | `src/config.ts:498-500` `DSHS_RELAY_DIAL_PORT_BASE=25000` / `SPAN=1000` / `POOL=64` ⇒ 落点在 **Manager 本机**回环池;`src/net/relay/dialer.ts:11`「Manager 也像 worker 一样**只拨出**一条 wss」 | ✅ **R5** 覆盖 | +| **C3** 可达性登记磨掉「经谁中转」 | `src/net/rendezvous.ts:103-106`「按 `via` 选实现的注册表」+ `Reachability.via`(`:27/:65/:98`)+ `agentBaseUrlOf()` 单一取址入口(`src/net/reachability.ts:99`) | ✅ S2/P2 覆盖 | +| **C4** 控制面 PG 走同一隧道 | 方案 §8.1 实测:106/47 worker env **均无** `DSHS_TUNNEL_STATIC_PORTS` | ✅ **前提不成立** | +| **S3** 回环别名 | 方案 §9.1 自我证伪(`gatewayports no`)⇒ 改「实例端口区间隔离」,且 R5 后「同号」前提消失 | ✅ 已被更强做法替代 | +| **S4** 中继独立成单元 / 会合可换机 / 多实例 | `dshs-relay` 独立单元(47+106,只绑回环 `20080`)+ 序⑥ S8 第二中继 + 序⑦「杀 47 ⇒ 切 106」实测 | ✅ R2 / 序⑥S8 / 序⑦ 覆盖 | + +**处置已落**:`会合中继拆分_取证与改造方案_20260916.md` **头部加状态块**(「已完成使命 · 仅存档 · ⛔ 勿再按 S0–S4 开工」+ 逐条覆盖指针),**正文未改**(历史档案属性)。 +⚠️ **顺带登记(⛔ 未处置)**:`src/worker/tunnel.ts`(SSH 隧道)现已是**生产死路径**(47 同机不建隧道、106 走 `wss://`)⇒ 是否删 = 独立决策(可能保留作 ssh 兜底变体)。 + +### 12.3 在册项定序(② → 序 ⑰ 执行棒,写进同一张单) + +| 序 | 项 | 为什么在这个位置 | +|---|---|---| +| **S1** | mksess 文档口径(「DB 直插」→「PG 直插」) | **必须最先** —— 它改 `skills/**` 正文,**S2 的镜像同步要把它的产物一起带上** ⇒ 二者共用**一次 scp**(硬顺序) | +| **S2** | 技能 `dsh-auto-handoff-chain` 三处同步 | 紧随 S1(同面、共用一次 scp);它是**自动接续链路**载体 ⇒ 影响后续每一棒的可复现性 | +| **S3** | Q5 `.bak-seq7-*` 清理 | 与 S1/S2 无依赖,同属「零服务副作用批」⇒ 合批省一次抢锁 | +| **S4** | §8.8-2 临界项(幕 4-A) | **唯一动服务项** ⇒ 置末;收尾 `restart dshs` 归零不影响已完成工作 | + +**排序一句话**:`S1 → S2`(硬依赖)→ `S3`(零风险清理)→ `S4`(唯一动服务,置末)。 + +### 12.4 本棒取证(只读 3 条命令族;⛔ 未连任何池口、未动 47/106) + +| # | 命令(`cwd` = `D:/github/dsh_shenxian`) | 关键输出 | +|---|---|---| +| 1 | `grep -rn "DSHS_TUNNEL_TARGET\|32022" --include=*.ts src` + `grep -rn "DSHS_RENDEZVOUS_URL" --include=*.ts src` | C1 现状(新变量优先、旧变量兜底) | +| 2 | `grep -rn "via\|25000\|dialer" src/net/rendezvous.ts src/net/relay/dialer.ts` + `grep -rn "DSHS_RELAY_DIAL" src/config.ts` | C2/C3 现状 | +| 3 | `find src test scripts -name "*.bak-*"` + `git ls-files src/net/relay \| wc -l` + `git ls-files --error-unmatch <bak>` | 🔴 **Q5 真实范围 = 5 个(非 3 个)**;`src/net/relay` tracked = **0**(Q4 自证) | + +🔴 **本棒两条新事实(写进单里,均已自决处置)**: + +1. **Q5 范围修正:3 → 5** —— 除原单记的 `src/net/relay/{client,directory,main}.ts.bak-seq7-20260917-112114` 外,**还有 2 个同批残留**:`src/web/server.ts.bak-seq7-20260917-112114`、`src/worker/tunnel.ts.bak-seq7-20260917-112114`。**5 个全部 untracked**(`git ls-files --error-unmatch` 报 pathspec 不匹配)⇒ 删除只减 untracked、不动版本库基线。已按 D2 **扩到 5 个**(同批、同类、**均为过期快照 ⇒ 无回滚价值**)。 +2. **`04-调整方案/**` 里的 mksess 旧口径不改** —— 档案属性是**当时事实**(当时确实直插 SQLite),改写 = 销毁溯源。已按 D1 只改操作性载体(`02-运维手册.md` + `skills/**` + 本机技能 10 行),在运维手册 R4 段写一行勘误指针。 +3. **幕 4-A 判据取三档递进**(D3):`p95 ≤ 24 s = 健康`|`24–27 s = 临界(记录、⛔ 不判 FAIL)`|`> 27 s = 回头条件触发` —— 观测样本 19.5/20.8/22.2 s,把「假红 vs 预警提前量」的取舍**消解**掉。 + +### 12.5 越界自证 + +| 门 | 自证 | +|---|---| +| **D1** | 参数表(截断口径)仍 **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变**)|⛔ 未动 `RELAY_FAILOVER_*` / `HB_SEC` / burst | +| **R7** | 本棒**零代码改动、零服务器改动**(只新增 1 份单 + 给 1 份存档方案加状态块)|⛔ 未 commit / push(HEAD 仍 `640813e`)|Q4 / Q5 **只报告未动手** | +| **COOLDOWN** | ⛔ 本棒未触任何演练 / 夹具路径;单内已把 🔴 禁令原文写进 §3.2 | +| **ssh** | 本棒**未对 47 / 106 执行任何命令**(纯本地只读)⇒ 无 `-p 22` 相关风险面 | + +### 12.6 在册未办(原样转 §12.8 的下一棒;⛔ 只报告) + +| 序 | 状态 | +|---|---| +| **Q4** | `src/net/relay/**` untracked 留档缺口 —— 🔴 **卡在「提交 / 推送授权」**(实测 `git ls-files src/net/relay` = **0**)⇒ ⛔ **未写进执行范围**,单内 §0.4 只登记一行 | +| **Q5** | 已**定序进 S3**(范围修正为 5 个) | +| **§8.8-1** | 106 sshd MaxStartups 限流(外部爆破)⇒ 脚本化 ssh 偶发 `rc=255`;⛔ 不放宽(命中 R5)⇒ 仍在册 | +| **§5.10 三项** | presence / 房间层 / 内容分发 —— ⛔ 属业务优先级,单内 **§3.3 只登记候选与优缺点、不定序** | + +### 12.7 指纹(收口现取) + +| 项 | 值 | +|---|---| +| **本棒产物** | `交接单_在册收尾_20260917.md` = **243 行**;全文 md5 **`ca13b9ded3d71268f8b0e56755d7aea4`**;**§8 前缀指纹 `bab83b7219b2669d5a6e9f1acf782e1f`**(口径 = `sed '/^## §8 回报格式/,$d' … \| md5sum`);行尾 = **纯 LF**(CR=0 自证) | +| 参数表(不计入本单口径) | **`8f08e74b026e6e5b5e1b3db813f031ae`**(**未变** ⇒ D1 自证) | +| 被加状态块的存档方案 | `会合中继拆分_取证与改造方案_20260916.md`(头部新增状态块,**正文未改**;原 278 行) | +| 本交接单 §8 前缀 | **`3ece0f870cba67d0113a4c5f9de9d812`**(**未变** —— §12 在 §8 之后,不进前缀口径) | +| 本机技能 mksess 命中 | 文档库 skills = **10 行** / 本机技能 = **10 行**(同数 ⇒ 两副本同步态;**待 S1 同改**) | +| **下一棒** | automation **`067b0892-964e-4deb-bf84-4f1c37bebea8`**(「覆盖网络线-序17执行棒-在册收尾(mksess口径/技能三处同步/Q5清理/幕4A临界项)」,一次性,`scheduledAt` = 2026-09-17 **16:44**,`nextRunAt` = 1789634640000) | + +### 12.8 下一棒(序 ⑰ · 执行棒) + +**开工依据 = 工作区根 `交接单_在册收尾_20260917.md`(§8 前缀指纹 `bab83b7219b2669d5a6e9f1acf782e1f`)**,按 §5 的 **S1 → S2 → S3 → S4** 顺序执行。 +**已登记**:automation **`067b0892-964e-4deb-bf84-4f1c37bebea8`**(一次性,`scheduledAt` = 2026-09-17 **16:44**,`nextRunAt` = 1789634640000)⇒ 已用陈述句告知用户。 +**两条硬门**:**D1**(不改任何生产值)|**R7**(Q4 ⛔ 只报告、⛔ 不 commit/push;范围外先报告后动手)。 +🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**。 +**已定项(⛔ 不必再上抛)**:D1 只改操作性载体(档案不动)|D2 Q5 扩到 5 个|D3 幕 4-A 三档判据|D4 不处理 `tunnel.ts` 死路径|D5 触发回头条件时停下报告、不调生产值。 +**收口必须**:释放锁 + **陈述句**告知 + 登记下一棒(id 取自工具返回值)+ 推进 `接续入口_覆盖网络线_20260916.md` §0/§2 + 写工作区日志 + 在本单回填 §8。⛔ 不 commit / 不 push。 diff --git a/dsh-server-docs/交接单/archive/交接单-已完成/T21-在册收尾.md b/dsh-server-docs/交接单/archive/交接单-已完成/T21-在册收尾.md new file mode 100644 index 0000000..c0ce772 --- /dev/null +++ b/dsh-server-docs/交接单/archive/交接单-已完成/T21-在册收尾.md @@ -0,0 +1,454 @@ +# 交接单 · 在册收尾(Q5 + mksess 文档口径 + 技能三处同步 + 幕 4-A 临界项)(2026-09-17) + +> **本单来源**:序 ⑯ 规划棒(automation `91c53ef8-c3f0-4bb3-9f0a-943790178176`,2026-09-17 16:36 起)。 +> **本单定位**:**执行单**(8 段模板,可直接开工)。把「会合 / 中继拆分**复核结论**」+「**剩余在册项定序**」合并为**一个**执行棒 = **序 ⑰**。 +> **本棒只出规划**:⛔ 未改任何代码、⛔ 未动 47 / 106、⛔ 未 commit / push;全部取证 = 只读命令(详见 §0.2)。 + +--- + +## §0 结论先行 + +### 0.1 复核结论(二选一 ⇒ **判「不做」**) + +> **「会合 / 中继从 Manager 拆分」—— 判「不做」。原方案 `会合中继拆分_取证与改造方案_20260916.md` S0–S4 的四个耦合点(C1–C4)与两个改造步骤(S3 / S4)已**逐条**被后续序次覆盖,**未覆盖部分 = 无**。** + +| 靶子 | 原方案描述 | 现状证据(只读取证) | 判定 | +|---|---|---|---| +| **C1** | 会合地址硬编码在 Worker 的 env(`DSHS_TUNNEL_TARGET` 写死在 `switch-C-worker.sh` + `/etc/dshs-worker.env`) | `src/config.ts:485-490`:`DSHS_RENDEZVOUS_URL` **优先**、`DSHS_TUNNEL_TARGET` 降为**兜底**(注释原文:「覆盖网络 S1:会合地址出 env」);`src/worker/agent.ts:158`、`src/worker/tunnel.ts:16` 同口径 | ✅ 已被 **序② P0-2(引导三级链)** 覆盖 | +| **C2** | 中继落点 = **Manager 的 loopback** + **两端同号** ⇒ 第三台机器无法直达、中继不可多实例 | `src/config.ts:498-500`:`DSHS_RELAY_DIAL_PORT_BASE=25000` / `SPAN=1000` / `POOL=64` ⇒ 落点在 **Manager 自己本机**的回环池;`src/net/relay/dialer.ts:11` 注释「Manager 也像 worker 一样**只拨出**一条 wss(`RelayClient` 的 `dialer` 模式)」 | ✅ 已被 **R5(会合可换机)** 覆盖 —— **这就是题面给的已知事实** | +| **C3** | 可达性登记把「经谁中转」磨掉了(`dsh_hosts.endpoint` 语义 ≠ worker 地址) | `src/net/rendezvous.ts:103-106`「按 `via` 选实现的注册表」+ `Reachability.via`(`:27` / `:65` / `:98`);`src/supervisor/remote-spawner.ts:21-48` 取址一律走 `agentBaseUrlOf()` | ✅ 已被 **S2 / P2(`dsh_hosts.via` 列)** 覆盖 | +| **C4** | 控制面 PG 也走同一条隧道 ⇒ 回滚面含 DB | 方案 **§8.1** 实测:106 与 47 的 worker env **都没有** `DSHS_TUNNEL_STATIC_PORTS` ⇒ **C4 前提不成立** | ✅ **前提不成立**,无需处理 | +| **S0** | 抽 `Reachability` / `Rendezvous` 接口 | `src/net/reachability.ts`(`agentBaseUrlOf()` = 全仓唯一取址入口,`:99`)+ `src/net/rendezvous.ts` 均在册 | ✅ 已落地 | +| **S1** | 会合地址出 env | 同 **C1** 行 | ✅ 已落地 | +| **S2** | `dsh_hosts` 增 `via` 列 | 同 **C3** 行 | ✅ 已落地 | +| **S3** | 中继落点命名空间可配(回环别名 `127.0.0.2`…) | 方案 **§9.1** 实测 `gatewayports no` ⇒ 别名方案**证伪**;已改「实例端口区间隔离」(`w-47` 20000+ / `w-106` 21000+)⇒ 连「两端同号」这个前提都没了 | ✅ **原设计已作废且被更强做法替代** | +| **S4** | 中继独立成单元 + 会合可换机 + 多实例 | `dshs-relay` **独立 systemd 单元**已常驻两台(47 / 106,只绑回环 `20080`);序⑥ S8 把 106 升格为第二中继;序⑦ 切流演练「杀 47 ⇒ 切到 106」实测通过;**sshd 反向隧道已下线且不可能重建** | ✅ 已被 **R2 / 序⑥ S8 / 序⑦ / 序④** 覆盖 | + +**⇒ 处置**:在 `会合中继拆分_取证与改造方案_20260916.md` 头部**加一行状态标注**(`已由 R5 + 序②/②P3/④/⑥S8/⑦ 覆盖 ⇒ 仅存档,⛔ 勿再按 S0–S4 开工`),正文⛔ 不改(历史档案属性)。 +⚠️ **顺带登记一条观察项(⛔ 本单不处置)**:SSH 隧道代码路径 `src/worker/tunnel.ts` 现已是**生产死路径**(47 同机不建隧道、106 走 `wss://` relay),是否删除 = **独立决策**(可能保留作 ssh 兜底变体)⇒ 只登记,**不定序**。 + +### 0.2 本棒取证(只读,共 3 条命令族;⛔ 未连任何池口、未动 47/106 配置) + +| # | 命令(`cwd` = 代码仓 `D:/github/dsh_shenxian`) | 关键输出 | +|---|---|---| +| 1 | `grep -rn "DSHS_TUNNEL_TARGET\|32022" --include=*.ts src` + `grep -rn "DSHS_RENDEZVOUS_URL" --include=*.ts src` | C1 现状(`config.ts:485-490` 新变量优先 / 旧变量兜底) | +| 2 | `grep -rn "via" src/net/rendezvous.ts` + `grep -rn "25000\|dialer" src/net/relay/dialer.ts` + `grep -rn "DSHS_RELAY_DIAL" src/config.ts` | C2 / C3 现状(拨号池 `25000..26000`、Manager 只拨出) | +| 3 | `find src test scripts -name "*.bak-*"` + `git ls-files src/net/relay \| wc -l` + `git ls-files --error-unmatch <bak>` | Q5 真实范围 **5 个**(非 3 个);`src/net/relay` tracked = **0**(Q4 自证) | + +### 0.3 剩余在册项定序(→ 本单 §5 的 S1–S4) + +| 序 | 项 | 类型 | 为什么排在这个位置 | +|---|---|---|---| +| **S1** | **mksess 文档口径**(「DB 直插」→「PG 直插」) | 文档(操作性载体) | **必须最先**:它改 `skills/**` 正文,而 **S2 的镜像同步要把 S1 的产物一起带上** ⇒ 二者共用**一次 scp**(硬顺序) | +| **S2** | **技能 `dsh-auto-handoff-chain` 三处同步** | 文档 + 镜像 | 紧随 S1(同属 `skills/**` 面、共用一次 scp);且它是**自动接续链路**的载体 ⇒ 直接影响本线后续每一棒的可复现性 | +| **S3** | **Q5**:`.bak-seq7-*` 残留清理(**范围修正 = 5 个**) | 清理(零服务副作用) | 与 S1/S2 无依赖,但同属「零服务副作用批」⇒ 合在文档批一起做,省一次抢锁 | +| **S4** | **§8.8-2 临界项**:幕 4-A 耗时贴近 deadline | 演练采样(**唯一有副作用项**) | **放最后**:它会把 Manager 通道挪来挪去(幕 4)⇒ 前面三条全部完成后再动,收尾 `restart dshs` 归零不影响已完成工作 | + +**排序一句话**:`S1 → S2`(硬依赖,共用一次镜像同步)→ `S3`(零风险清理,同批)→ `S4`(唯一动服务的一项,置末)。 + +### 0.4 ⛔ 本单**不写进执行范围**的项(只登记) + +| 项 | 登记内容 | +|---|---| +| **Q4** | 代码仓 `src/net/relay/**` **untracked**(实测 `git ls-files src/net/relay \| wc -l` = **0**)⇒ 序⑭/⑮ 的修复与 `test/relay.test.mjs` **只存在于工作区、不在版本库**。**卡在「commit / push 授权」**,属**待用户拍板** ⇒ ⛔ 本单不执行、⛔ 执行棒也不得动,**只报告**。 | +| **presence / 房间层 / 内容分发定序** | 属「业务优先级」(§1 边界外 ①)⇒ ⛔ **本单不定序**;候选与优缺点登记见 **§3.3**。 | +| **骨干节点服务范围** | 入口 §4 既有待拍板项(A 只服务自己名下设备 / B 服务全网)⇒ 原样保留,本单不重复上抛。 | + +--- + +## §1 目标 + +一次执行棒把**四条在册项**收干,全部满足「零生产值改动 + 可秒级回滚」: + +1. **mksess 口径对齐实现** —— 文档库 / 技能 / 镜像里对 `/opt/dshs/mksess{,−guest}.cjs` 的「DB 直插(SQLite 旧库)」描述已过期(序⑬ Q1 实测已改 **PG**)⇒ 改成与实现一致的描述,避免 R4 验收手段被误述。 +2. **技能 `dsh-auto-handoff-chain` 的「三处同步」从「从未建立」变为「已建立」** —— 现在**文档库无副本**(实测 ❌)。 +3. **清掉代码仓 `.bak-seq7-*` 残留** —— 立起「`.bak` 只在执行期存在」这条规矩(序⑫ 已以身作则、仓内 `.bak` = 0,但代码仓仍有 5 个历史快照)。 +4. **给「幕 4-A 耗时贴近 deadline」这条临界项落一个判据**(三档递进),并采 5 个样本自证是否仍临界。 + +**非目标**:⛔ 不 commit / 不 push(Q4 未授权);⛔ 不改任何生产值(`RELAY_FAILOVER_*` / `HB_SEC` / burst);⛔ 不做打洞、不改 worker 拨出方向、不重做任何已收官序。 + +--- + +## §2 只读前置(S0 必须先核实;任一条不符 ⇒ **停下报告**,⛔ 不硬闯) + +| # | 核实项 | 命令(`cwd` 见括注) | 期望(本单规划时的实测值) | +|---|---|---|---| +| **P1** | 锁空闲 | `bash dsh-server-docs/scripts/handoff-guard.sh` | 报「空闲」;**抢锁后**再开工(抢不到 = 有会话在跑 ⇒ 停手报告) | +| **P2** | 代码仓基线未漂移 | `cd D:/github/dsh_shenxian && git rev-parse --short HEAD && git status --porcelain \| wc -l` | HEAD = **`640813e`**;总数 = **43**(17 M + 3 ?? + 余) | +| **P3** | `.bak` 真实范围 | `cd D:/github/dsh_shenxian && find src test scripts -name "*.bak-*"` | **5** 个:`src/net/relay/{client,directory,main}.ts.bak-seq7-20260917-112114` + `src/web/server.ts.bak-seq7-20260917-112114` + `src/worker/tunnel.ts.bak-seq7-20260917-112114`(⚠️ 均 **untracked**,实测 `git ls-files --error-unmatch` 报 pathspec 不匹配) | +| **P4** | Q4 自证(⛔ 只记录) | `cd D:/github/dsh_shenxian && git ls-files src/net/relay \| wc -l` | **0**(untracked 缺口仍在 ⇒ 本棒 ⛔ 不得 commit) | +| **P5** | mksess 命中面 | `cd D:/github/dsh_shenxian && grep -rn "mksess" dsh-server-docs/ \| grep -iE "sqlite\|直插\|dshs\.db\|旧库"` | 命中 **4 行**:`04-调整方案/77-…md:97`(**档案,⛔ 不改**)、`skills/dsh-change-workflow/SKILL.md:146/395/495`、`skills/dsh-env-bootstrap/references/常驻规则-快照.md:55` | +| **P6** | 技能三处现状 | `ls -d "E:/ProgramData/.workbuddy/skills/dsh-auto-handoff-chain"` + `ls -d D:/github/dsh_shenxian/dsh-server-docs/skills/dsh-auto-handoff-chain` | ① 本机 ✅ **存在**;② 文档库 ❌ **不存在**(与在册项一致);③ 镜像(47 `/opt/dsh/docs/skills/`)**待现取** | +| **P7** | 基线回归值 | 见 §6-E6 | `npm test` = **162 / 161 pass / 0 fail / 1 skip**;`--scene all` = **12 PASS / 0 SKIP / 0 FAIL**;`overlay-probe --table "…/参数表_覆盖网络_20260917.md"` = **12/12 · rc=0** | + +⚠️ **ssh 一律显式 `-p 22`**(别名 `bt-server` 的 `32022` 是失效残留口);**取证落盘一律 `2>/dev/null`**(ssh 的 PQ 告警会污染行数判据);**106 的 ssh 偶发 `rc=255`**(MaxStartups 限流)⇒ 容许单次重试,⛔ 不放宽限流(命中 R5)。 + +--- + +## §3 范围 + +### 3.1 改哪些(三处文档面 + 一处代码仓清理) + +| 面 | 具体对象 | 动作 | +|---|---|---| +| 文档库(代码仓内) | `dsh-server-docs/skills/dsh-change-workflow/SKILL.md`(3 行)、`dsh-server-docs/skills/dsh-env-bootstrap/references/常驻规则-快照.md`(1 行)、`dsh-server-docs/02-运维手册.md`(命中处) | 口径校正 | +| 文档库(新增) | `dsh-server-docs/skills/dsh-auto-handoff-chain/`(**新建目录**)+ `skills/README.md` / `skills/INDEX.md` 登记 | 新建 + 登记 | +| 本机技能 | `E:\ProgramData\.workbuddy\skills\**`(命中 **10 行**) | 口径校正(与文档库同内容) | +| 镜像 | 47 `/opt/dsh/docs/skills/**` | scp 同步(S1 + S2 产物 **一次**上) | +| 代码仓 | `src/{net/relay/web/worker}/*.bak-seq7-20260917-112114`(**5 个**) | 删除 | +| 观测口径 | `参数表_覆盖网络_20260917.md` §6(**仅在 S4 判据落地时**) | 新增余量判据(⚠️ **会改参数表指纹** ⇒ 必须在 §8.9 记录变更前后值) | + +### 3.2 ⛔ 明确不做 + +- ⛔ **不 commit / 不 push**(Q4 未授权 ⇒ 本棒**零提交动作**,含"顺手提交 `.bak` 删除"也不行); +- ⛔ 不改任何生产值:`RELAY_FAILOVER_*` / `HB_SEC` / burst / deadline; +- 🔴 ⛔ **不许把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写进任何回滚 / 演练 / 夹具路径**(看似合法、实际自锁); +- ⛔ 不改 `04-调整方案/**` 历史档案正文(D1 已定:只在操作性载体里写"档案口径已过期"的指针); +- ⛔ 不动 47 / 106 的 nginx / nft / systemd 单元 / env(**唯一例外** = §5 S4 的演练与收尾 `restart dshs` 归零,与序⑩–⑮ 同口径); +- ⛔ 不重做任何已收官序(①–⑮);⛔ 不做 presence / 房间层 / 内容分发;⛔ 不做打洞实现;⛔ 不改 worker 拨出方向。 + +### 3.3 范围外登记(⛔ 不定序、不决策 —— 属「业务优先级」) + +| 候选 | 优点 | 缺点 | +|---|---|---| +| **presence** | 清单 §五 第 7 步;千台推演里**第一瓶颈**,做完才能谈"谁在线"类功能 | 仓库里**目前零应用层代码**(全仓唯一含 "presence" 字样的是 `src/net/relay/placement.ts`)⇒ 从零起步、工作量最大 | +| **房间层** | 是 presence 的下游、MMORPG 多人的前置 | ⛔ 无 presence 则无输入;依赖链在前 | +| **内容分发(块级内容寻址)** | 与 presence 无依赖、可独立做;直接吃"跨云 ~22 KB/s"这条实测短板 | 与游戏主线收益关系较远;块级寻址会引入新的存储/校验面 | + +--- + +## §4 决策点 + +**待拍板项 = 空**(本单四条全部落在 §1「边界内自决清单」:实现路径 / 排查方法 / 文档技术内容 / 清理类)。以下为**已定项(可推翻)**: + +| # | 已定项 | 理由 | +|---|---|---| +| **D1** | mksess 口径**只改操作性载体**(`02-运维手册.md` + `skills/**` + 本机技能),**⛔ 不改 `04-调整方案/*.md` 档案正文**;在 `02-运维手册.md` 的 R4 段写一行「档案 77 里的『DB 直插』为当时形态,现实现 = **PG 直插**」作勘误指针 | 档案的属性是**当时事实**,改写 = 销毁溯源;且档案命中面 7 个文件(R7 批量面)。B 方案(只改载体)明显更优 ⇒ 自决 | +| **D2** | Q5 范围**扩到 5 个**(含原单未记的 `src/web/server.ts.bak-seq7-*` 与 `src/worker/tunnel.ts.bak-seq7-*`) | 同类、同批、同为 **untracked 过期快照**(seq7 11:21 的快照,已被序⑧/⑨/⑭/⑮ 改写作废 ⇒ **无回滚价值**);只删 3 个 = 规矩只立一半,下次还要再开一单。R7 透明:本行即"先报告" | +| **D3** | 幕 4-A 判据取**三档递进**而非单一阈值:**p95 ≤ 24 s(deadline×0.8)= 健康**|**24–27 s = 临界(记入在册、⛔ 不判 FAIL)**|**> 27 s = 回头条件触发(停下报告)** | 观测样本 19.5 / 20.8 / 22.2 s,单阈值 24 s 有假红风险、27 s 又预警太晚 ⇒ 三档把"假红 vs 预警提前量"这个取舍**消解**掉了(既不失预警、也不误判) | +| **D4** | ⛔ 本单**不处理** `src/worker/tunnel.ts` 这条生产死路径(是否删 = 独立决策) | 删代码有风险、且它可能作为 ssh 兜底变体保留 ⇒ 只登记(§0.1 末) | +| **D5** | S4 若触发回头条件(p95 > 27 s),**⛔ 不许自行调 `RELAY_FAILOVER_DEADLINE_MS` / `HB_SEC` / burst** ⇒ 停下报告,重做四段分解(检测 / 首试 / 白等 / 建连)后定位 | 沿用序⑨ 已立的三条硬门(D1 / 生产值不可调 / 分解优先) | + +--- + +## §5 步骤 S0–S5 + +### S0 · 只读前置 + 抢锁 + +- 跑 §2 的 **P1–P7** 全部核实;任一条不符 ⇒ **停下报告**。 +- **抢锁**:`bash "D:/github/dsh_shenxian/dsh-server-docs/scripts/handoff-guard.sh" --claim-exec "覆盖网络线-序17执行棒"`。 +- 抢到后再跑一次信息模式,记下占用与越界改动。 + +### S1 · mksess 文档口径校正(「DB 直插」→「PG 直插」) + +- **先取准实现事实**(只读,1 条命令):`ssh -p 22 bt-server 'head -40 /opt/dshs/mksess.cjs'` ⇒ 确认落点是 **PG**、连接串来源 = env / `dshs.env` / `dshs.service.d`、**⛔ 不固化凭据**(序⑬ §9.2 已改)。 +- **改哪些行**:按 §2-P5 的 grep 结果逐行改(**只改断言"存储机制"的行**;仅提及文件名、不涉机制的行**保持原样**)。 + - 统一措辞(示例,⛔ 不写死凭据):`node /opt/dshs/mksess.cjs`(**PG 直插**,连接串取自 env / `dshs.env` / `dshs.service.d`;10 分钟;`user_agent=poc-curl2`) +- **本机 + 文档库两副本同改**(内容逐字节一致)。 +- **判据**: + - `grep -rn "mksess" dsh-server-docs/ \| grep -iE "sqlite\|旧库"` = **0**(⚠️ 原本 0 命中的"旧库"也一并核) + - `grep -rniE "sqlite|旧库" <文档库 skills 与 02-运维手册> \| grep -c mksess` = **0** + - 两副本 `md5sum` 逐文件一致 +- **回滚**:`git checkout -- <改动文件>`(文档库)+ 本机技能按 §7 备份还原。 + +### S2 · 技能 `dsh-auto-handoff-chain` 三处同步**建立** + +- **① 核本机副本清单**:`ls -R "E:/ProgramData/.workbuddy/skills/dsh-auto-handoff-chain"` ⇒ 记下全部文件(`SKILL.md` + 可能的 `references/`、`scripts/`)。 +- **② 建文档库副本**:逐文件复制到 `D:/github/dsh_shenxian/dsh-server-docs/skills/dsh-auto-handoff-chain/`(**逐文件 copy,⛔ 不用 `cp -r` 整目录覆盖**;⚠️ 文档库 `.gitattributes` = `* -text` ⇒ **保持纯 LF**)。 +- **③ 登记**:改 `dsh-server-docs/skills/README.md` 与 `dsh-server-docs/skills/INDEX.md`,加上该技能行。 + ⚠️ **`INDEX.md` 是混合换行** ⇒ **字节级单行插入**,⛔ 不跑 `git checkout --`、⛔ 不做批量换行符转换。 +- **④ 镜像同步**:scp 到 47 `/opt/dsh/docs/skills/`(**连同 S1 的改动物一次上**);同步前先抢锁(本棒已持锁)。 +- **判据**: + - `diff -r` 两副本 = **0 差异**;逐文件 `md5sum` 一致(⚠️ 本机 `md5sum` 出 `hash *path`、远端出 `hash path` ⇒ 先 `cut -d' ' -f1` 再比) + - 镜像 `/opt/dsh/docs/skills/dsh-auto-handoff-chain/SKILL.md` md5 == 文档库 md5 + - `grep -c "dsh-auto-handoff-chain" dsh-server-docs/skills/README.md` ≥ **1**(INDEX.md 同) +- **回滚**:删文档库新增目录 + 镜像改名还原备份(`README.md` / `INDEX.md` 用 `git checkout --`)。 +- ⚠️ **本步自证价值**:这一条正是「长期在册」项 —— 此前该技能的"三处同步"**从未建立**,本步是**第一次**把它建起来;完成后在 §8.8 明确写"已建立",并在 `.workbuddy/memory/MEMORY.md` 的对应处("该技能三处同步从未建立(在册未办)")改为**已办**。 + +### S3 · Q5 清理 `.bak-seq7-*`(**5 个**) + +- **前置(缺一不可)**:① `find src test scripts -name "*.bak-*"` 逐个确认属 **seq7-20260917-112114** 批次;② 确认**对应源文件存在**(`client.ts` / `directory.ts` / `main.ts` / `web/server.ts` / `worker/tunnel.ts`);③ 确认 `lib/` 内有**等价产物**;④ ⚠️ 记录一条事实:这些是 **11:21 的快照**,**已被序⑧/⑨/⑭/⑮ 改写作废 ⇒ 他们不是有效回滚点**(删除风险 = 零)。 +- **怎么做**:逐个 `rm`(**5 个,逐个点名**,⛔ 不用通配符 `rm src/net/relay/*.bak-*`)。 +- **判据**: + - `find src test scripts -name "*.bak-*" \| wc -l` = **0** + - `npm run build` **RC=0**(⛔ 未改任何源文件 ⇒ 产物指纹应与 S0 一致,可对账 `lib/net/relay/*.js` md5 不变) + - `npm test` = **162 / 161 / 0 / 1**(不退化) + - `git status --porcelain \| wc -l` **只减不增**(Δ ≤ 0;⚠️ `src/net/relay/` 整体是 `??` 一个条目 ⇒ **不能期望 −5**,判据落到 `find` 结果与 md5 上) +- **回滚**:`.bak` 不可再生 ⇒ 回滚动作 = 从当前源文件重新 `cp` 一份快照(⚠️ 语义不等价,仅作备案)。⇒ 故删除前**必须**完成前置 ④ 的书面记录。 + +### S4 · §8.8-2 临界项 —— 幕 4-A 余量判据化 + 稳定性采样 + +- **不改任何生产值**;只跑演练 + 落判据。 +- **① 采样**:`node scripts/overlay-failover-drill.cjs --scene 4 --table "<工作区根>/参数表_覆盖网络_20260917.md"`(`DRILL_SAMPLE_N` = **5**)。 + ⛔ **只跑 `--scene 4`(构 A)**;⛔ 不跑 `4b` / `4c` / `ctrl`(`4c` / `ctrl` 已被序⑩ 整体移除并显式拒绝 —— 那是 `COOLDOWN_MS=0` 的诱发面)。 +- **② 算判据**:对 5 个样本取 **p95**,按 §4-D3 三档判定: + - `p95 ≤ 24000 ms` ⇒ **健康** ⇒ 把判据写进参数表 §6(配套说明:分母 = `RELAY_FAILOVER_DEADLINE_MS` = 30000 ms); + - `24000 < p95 ≤ 27000` ⇒ **临界(记录,⛔ 不判 FAIL)** ⇒ 写进 §8.8 在册; + - `p95 > 27000` ⇒ 🔴 **停下报告**(⛔ 不许自行调任何生产值)⇒ 按 §4-D5 重做四段分解。 +- **③ 零回归**:`--scene all` = **12 PASS / 0 SKIP / 0 FAIL**(幕 4-A / 4-C 全部 ≤ deadline);`npm test` 不退化。 +- **④ 收口归零(本步的"回滚")**:演练会把 Manager 通道挪到 106 ⇒ **必须 `systemctl restart dshs` 归零回 47**,再跑 `overlay-probe` 确认 **12/12**、`ss -lntH 'sport >= :25000 and sport <= :25063' | wc -l` = **64**。 + ⚠️ 判"服务是否正常"要 `systemctl is-active dshs dshs-relay dshs-pg` —— 演练进行中某台 relay 是 `inactive`,那是**幕的正常态**。 +- **回滚**:本步不改服务配置 ⇒ 回滚 = ① 参数表备份还原(指纹回到现值 `8f08e74b026e6e5b5e1b3db813f031ae`)② 演练坐标/env 残留清 0(`grep -r 'DRILL_' /etc/systemd/system/dshs.service.d/` = 0)。 + +### S5 · 收口(6 件,缺一即算未完成) + +① 释放锁 `--release-exec`;② **陈述句**告知用户 + 登记下一棒一次性 automation(`scheduledAt` = 收口时刻 **+2~5 分钟**;⚠️ 下一棒 id **只能来自工具返回值**:先 `create` → 取 id → 再落盘);③ 把 `接续入口_覆盖网络线_20260916.md` §0 + §2 推进到再下一棒;④ 写工作区日志 `.workbuddy/memory/2026-09-17.md`(**append-only**);⑤ 在本单追加回报(§8 八小节);⑥ 输出指纹(§8.9)。 + +--- + +## §6 验收(逐条命令 + 期望 + 退出码;可被第三方复现) + +| # | 项 | 命令(`cwd` 见括注) | 期望 | +|---|---|---|---| +| **E1** | S1 口径已对齐 | `cd D:/github/dsh_shenxian && grep -rn "mksess" dsh-server-docs/ \| grep -iE "sqlite\|旧库" \| grep -v "04-调整方案"` | **0 行**(⛔ 档案除外) | +| **E2** | S1 双副本一致 | 逐文件 `md5sum`(本机技能 vs 文档库技能,`cut -d' ' -f1` 后比) | **全同** | +| **E3** | S2 三处同步 | `diff -r "E:/ProgramData/.workbuddy/skills/dsh-auto-handoff-chain" "D:/github/dsh_shenxian/dsh-server-docs/skills/dsh-auto-handoff-chain"` + 镜像 `md5sum` | diff **0 行**;镜像 md5 == 文档库 md5 | +| **E4** | S2 登记 | `cd D:/github/dsh_shenxian && grep -c "dsh-auto-handoff-chain" dsh-server-docs/skills/README.md`(INDEX.md 同) | **≥ 1** | +| **E5** | S3 清理干净 | `cd D:/github/dsh_shenxian && find src test scripts -name "*.bak-*" \| wc -l` | **0** | +| **E6** | 零回归(三件套) | ① `cd D:/github/dsh_shenxian && npm.cmd test`(**必须 Node 22**)② `node scripts/overlay-failover-drill.cjs --scene all --table "<工作区根>/参数表_覆盖网络_20260917.md"` ③ `node scripts/overlay-probe.cjs --table "<工作区根>/参数表_覆盖网络_20260917.md"` | ① **162 / 161 / 0 / 1** ② **12 PASS / 0 SKIP / 0 FAIL** ③ **12/12 · rc=0** | +| **E7** | S4 余量判据 | `--scene 4` × `DRILL_SAMPLE_N`=5 ⇒ p95 | 落 §4-D3 三档之一;**> 27000 ms ⇒ 停下报告** | +| **E8** | 越界自证 | `cd D:/github/dsh_shenxian && grep -c 'COOLDOWN_MS=0' scripts/` + `git rev-parse --short HEAD` + `git status --porcelain \| wc -l` | `0`;HEAD 仍 **`640813e`**(⛔ 无 commit);总行数 **≤ 43** | +| **E9** | 收口归零 | `ssh -p 22 bt-server 'systemctl is-active dshs dshs-relay dshs-pg'` + `ssh -p 22 bt-server "ss -lntH 'sport >= :25000 and sport <= :25063' \| wc -l"` | 三个 `active`;池口 **64** | + +--- + +## §7 回滚 + +| 对象 | 回滚动作 | 耗时 | +|---|---|---| +| S1 文档(文档库) | `git checkout -- dsh-server-docs/skills/dsh-change-workflow/SKILL.md dsh-server-docs/skills/dsh-env-bootstrap/references/常驻规则-快照.md dsh-server-docs/02-运维手册.md` | 秒级 | +| S1 文档(本机技能) | 执行前逐文件备份到 `_中间产物_待清理/seq17/skills-bak/` ⇒ `cp` 还原 | 秒级 | +| S2 文档库新增目录 | `rm -rf dsh-server-docs/skills/dsh-auto-handoff-chain`(⚠️ 纯新增 ⇒ 无溯源损失)+ `git checkout -- README.md INDEX.md` | 秒级 | +| S2 镜像 | `ssh -p 22 bt-server` 把 `/opt/dsh/docs/skills/` 备份目录改名还原 | 秒级 | +| S3 `.bak` 删除 | ⚠️ **不可逆**(原文件是过期快照、无回滚价值)⇒ 回滚 = 从当前源文件重建快照(仅备案)。**故前置 ④ 必须先写** | 秒级 | +| S4 参数表判据 | 参数表备份还原 ⇒ 指纹回 **`8f08e74b026e6e5b5e1b3db813f031ae`** | 秒级 | +| S4 服务态 | `systemctl restart dshs` 归零回 47(⛔ 不删 drop-in、⛔ 不改 cluster 配置) | 秒级 | + +--- + +## §8 回报格式(执行会话按此回填;⛔ 缺一节即算未完成) + +### 8.1 结论(1 行) :四条在册项逐条 ✅ / ❌(S1 / S2 / S3 / S4),+ 是否触发 S4 回头条件 +### 8.2 只读前置 P1–P7 :逐条贴命令与输出(P3 的 5 个 `.bak` 逐个点名 + `git ls-files` 的 untracked 自证) +### 8.3 S1 口径校正 :命中行清单(文件:行 → 改前 / 改后)、0 命中的判据命令原文、⛔ 未动档案的自证 +### 8.4 S2 三处同步 :本机副本文件清单(逐个 md5)+ 文档库 md5 + 镜像 md5(**三处对照表**)+ README/INDEX 登记原文 +### 8.5 S3 清理 :`find` 前 / 后计数 + 5 个文件逐个名 + `npm run build` RC + `npm test` 计数 + "非有效回滚点"的书面记录 +### 8.6 S4 临界项 :`--scene 4` 5 个样本原文 + p95 = ? ms ⇒ 落哪一档 + 判据是否落表(落表则给参数表指纹 变更前/后) +### 8.7 零回归 / 越界自证 :E6 三件套原文(含 `--scene all` 的幕 4-A / 4-C 两个毫秒值)+ E8(`COOLDOWN_MS=0` 计数、HEAD、`git status` 计数) +### 8.8 未过项 / 在册未办 :逐条写「现象 / 已做到哪一步 / 回头条件」;**Q4 原样转下一棒**(⛔ 只报告);§0.4 的三项登记原样保留 +### 8.9 指纹(可复现核对口径) +- 本单前缀:`cd "E:/ProgramData/AI技能/aliyun-dsh-server" && sed '/^## §8 回报格式/,$d' 交接单_在册收尾_20260917.md | md5sum` +- 本单全文:`md5sum 交接单_在册收尾_20260917.md` +- 参数表(**D1 自证**):`sed '/^## §10 指纹/,$d' 参数表_覆盖网络_20260917.md | md5sum`(⚠️ **截断口径**,⛔ 不是全文件 md5;现值 `8f08e74b026e6e5b5e1b3db813f031ae`) +- 关键产物:`src/net/relay/{client,directory,main}.ts` + `src/web/server.ts` + `src/worker/tunnel.ts` 的 md5(**清理前后应不变** ⇒ 自证未改源文件) + +--- + +## §9 执行回报(序 ⑰ 执行棒 · 2026-09-17 16:44–17:3x) + +> 口径 = 按 §8 的九个小节逐条回填;本 §9 位于 §8 之后,**不进 §8 前缀指纹**。 + +### 9.1 结论(对应 §8.1) + +**四条在册项逐条:S1 ✅ | S2 ✅ | S3 ✅ | S4 ⚠️ 落「临界档」(记录在册,⛔ 不判 FAIL)。** +**S4 回头条件是否触发:未触发**(p95 = 24477 ms < 27000 ms)⇒ ⛔ 未调任何生产值。 +**范围遵守**:⛔ 未改任何生产值;⛔ 未改 `04-调整方案/**` 档案正文;⛔ 未重做已收官序 ①–⑮;⛔ 未做 presence / 房间层 / 内容分发;⛔ 未超 §3 范围。 +**追加授权**:用户本轮明确「执行完毕后 同步到仓库」⇒ Q4(提交/推送授权)已解除并办结,见 §9.10。 + +### 9.2 只读前置 P1–P7(对应 §8.2) + +| # | 核实项 | 实测 | 判定 | +|---|---|---|---| +| P1 | 锁空闲 | 报「空闲」;本棒 `--claim-exec "覆盖网络线-序17执行棒"` 抢到 | ✅ | +| P2 | 基线未漂移 | `HEAD = 640813e`;`git status --porcelain \| wc -l` = **43** | ✅ | +| P3 | `.bak` 真实范围 | 5 个(逐个点名见 §9.5) | ✅ 与单一致 | +| P4 | Q4 自证 | `git ls-files src/net/relay \| wc -l` = **0** | ✅(本轮已解除 → 见 §9.10) | +| P5 | mksess 命中面 | `grep -rn "mksess" dsh-server-docs/` 筛 `sqlite\|直插\|dshs.db\|旧库` = **5 行** ⇒ 落点 = 档案 77(⛔ 不改)+ `skills/dsh-change-workflow/SKILL.md`:146/395/495 + `skills/dsh-env-bootstrap/references/常驻规则-快照.md`:55 | ✅ | +| P6 | 技能三处现状 | ① 本机 ✅ 存在(`SKILL.md` + `scripts/chain_report.py`);② 文档库 ❌ 不存在;③ 镜像 ❌ 不存在(8 个技能、无此项) | ✅ 与在册项一致 | +| P7 | 基线回归值 | 见 §9.7 | ✅ | + +**mksess 实现事实(只读,1 条 ssh;`-p 22` 显式)**:`/opt/dshs/mksess.cjs`(74 行)第 2 行即「临时会话直插(**PG 版**)」;第 4–6 行写明「原版直插 `/var/lib/dshs/dshs.db`(SQLite)… 集群化后权威库 = 47 的 PG13(`DSHS_DB_URL`),SQLite 只剩回滚用途」;第 9–10 行「连接串从环境读(`DSHS_DB_URL` env → `/etc/dshs.env` → `dshs.service.d/*.conf`),⛔ 不把凭据固化进本文件」;第 17 行 `require('/opt/dshs/node_modules/pg')`;`TTL_MS = 600 * 1000`。 +⇒ **文档口径必须改成「PG 直插」**,与实现一致。 + +### 9.3 S1 口径校正(对应 §8.3) + +**命中行清单(文件 : 行 → 改前 → 改后)** —— 文档库与**本机技能同内容逐字节一致**: + +| 面 | 文件:行 | 改前 | 改后 | +|---|---|---|---| +| 文档库 + 本机 | `skills/dsh-change-workflow/SKILL.md:146` | `… mksess.cjs\` 直插临时 session,或…` | `… mksess.cjs\`(**PG 直插**)建临时 session,或…` | +| 文档库 + 本机 | `skills/dsh-change-workflow/SKILL.md:395` | `改用 \`mksess.cjs\` 直插临时 session 或…` | `改用 \`mksess.cjs\` **PG 直插**临时 session 或…` | +| 文档库 + 本机 | `skills/dsh-change-workflow/SKILL.md:495` | `(DB 直插,10 分钟,user_agent=poc-curl2)` | `(**PG 直插**,连接串取自 env / \`dshs.env\` / \`dshs.service.d\`;10 分钟;\`user_agent=poc-curl2\`)` | +| 文档库 + 本机 | `skills/dsh-env-bootstrap/references/常驻规则-快照.md:55` | `(\`mksess.cjs\` 直插)` | `(\`mksess.cjs\` **PG 直插**)` | +| 仅文档库 | `02-运维手册.md:211–214` | `# 测试 session 生成(门户 API 直调,10 分钟)` | 注释行补「**PG 直插**,连接串取自 env / dshs.env / dshs.service.d;10 分钟;user_agent=poc-curl2」+ **D1 勘误指针两行**(说明档案 77 与更早文档的描述属当时形态、写 `/var/lib/dshs/dshs.db`,集群化后权威库 = 47 的 PG13、那份文件仅回滚用) | + +**判据原文**: +- **E1** `cd D:/github/dsh_shenxian && grep -rn "mksess" dsh-server-docs/ | grep -iE "sqlite|旧库" | grep -v "04-调整方案" | wc -l` = **0** ✅(⛔ 档案除外) +- **E1b** `grep -rniE "sqlite|旧库" dsh-server-docs/skills/ dsh-server-docs/02-运维手册.md | grep -c "mksess"` = **0** ✅ +- **E2** 两副本逐文件 md5:`dsh-change-workflow/SKILL.md` = `338bf9534e5243f495e7d66f914371bf`(本机 = 文档库);`dsh-env-bootstrap/references/常驻规则-快照.md` = `e1a460b53cc4b62466107f8555946496`(本机 = 文档库) → **全同** ✅ +- 行尾:5 个文件 CR 计数 **全 0**(纯 LF)✅ + +**⛔ 未动档案自证**:`04-调整方案/77-回到页面自检与就地恢复-恢复过程可见化.md:97` 原文**未改**(该行本就不含 `sqlite/旧库`,属 E1 的排除项)。 + +⚠️ **一处判据偏差(已按实质落点执行)**:§3.1 / §5-S2 写的登记文件路径为 `dsh-server-docs/skills/README.md` 与 `INDEX.md`,**实测 `skills/` 下无这两个文件** —— 真实登记文件在**文档库根** `dsh-server-docs/README.md` 与 `dsh-server-docs/INDEX.md`(内容形态与描述一致:技能表 + 「什么时候查什么」表 + 技能清单表)⇒ 按实质落点执行,判据 E4 落在根文件上。 + +### 9.4 S2 三处同步(对应 §8.4) + +**① 本机副本清单**(`E:/ProgramData/.workbuddy/skills/dsh-auto-handoff-chain/`): + +| 文件 | 字节 | md5 | +|---|---|---| +| `SKILL.md` | 18926 | `0c5c4103f8ffa8071ce29434654fa2d3` | +| `scripts/chain_report.py` | 8126 | `636c4f336864bc99c408e92577516f11` | + +**② 文档库副本**(新建 `dsh-server-docs/skills/dsh-auto-handoff-chain/`,**逐文件 copy**、⛔ 未用 `cp -r`): + +| 文件 | 本机 md5 | 文档库 md5 | 镜像 md5(47 `/opt/dsh/docs/skills/dsh-auto-handoff-chain/`) | 判定 | +|---|---|---|---|---| +| `SKILL.md` | `0c5c4103f8ffa8071ce29434654fa2d3` | 同 | 同 | ✅ 三处同值 | +| `scripts/chain_report.py` | `636c4f336864bc99c408e92577516f11` | 同 | 同 | ✅ 三处同值 | + +- **E3** `diff -r "E:/…/skills/dsh-auto-handoff-chain" "D:/…/dsh-server-docs/skills/dsh-auto-handoff-chain"` = **0 行** ✅ +- 行尾:两处 CR = **0**(纯 LF)✅(文档库 `.gitattributes` 的 `dsh-server-docs/** -text` 完好) + +**③ 登记**(字节级单行插入,新行行尾跟随锚点行): + +| 文件:行 | 内容 | +|---|---| +| `dsh-server-docs/README.md:37` | 技能表新增 `skills/dsh-auto-handoff-chain/` 行(v1.3.2 + 六件套骨架 + 登记门禁 + 五条实测防护 + `scripts/chain_report.py`;同步方向本机 → 此处) | +| `dsh-server-docs/INDEX.md:22` | 「什么时候查什么」新增 `**跑跨会话长任务(自动接力)**` 行 | +| `dsh-server-docs/INDEX.md:169` | 技能清单表新增 `— / ✅ / skills/dsh-auto-handoff-chain/SKILL.md` 行 | + +- **E4** `grep -c "dsh-auto-handoff-chain" dsh-server-docs/README.md` = **1** ✅;`INDEX.md` = **2** ✅(≥ 1) +- ⚠️ `INDEX.md` 是**混合换行**(全文件 CR 计数 = 734)⇒ 全程**字节级**插入、**新行行尾跟随锚点**(两处锚点均为 LF)、⛔ 未做任何批量换行符转换、⛔ 未跑 `git checkout --`。 + +**④ 镜像同步**(S1 + S2 产物**一次上**;同步前远端备份): + +- 备份 = `/opt/dsh/docs/.bak-seq17-20260917-164809`(5 个文件) +- 上传 7 个文件,md5 **逐条与本地一致**:`skills/dsh-change-workflow/SKILL.md` `338bf953…`|`skills/dsh-env-bootstrap/references/常驻规则-快照.md` `e1a460b5…`|`skills/dsh-auto-handoff-chain/SKILL.md` `0c5c4103…`|`…/scripts/chain_report.py` `636c4f33…`|`02-运维手册.md` `d6627843b67c72ddfd3b62b5cfd5e29f`|`README.md` `ddfd4d5fc890af0ee86d5442d8c0d610`|`INDEX.md` `5677883fd3777a225ae85af7c5c79fb7` + +⇒ **该技能的「三处同步」由「从未建立」变为「已建立」**(此前文档库与镜像都无副本)。 + +### 9.5 S3 清理(对应 §8.5) + +**前置四项(缺一不可)全部满足**: +① 5 个全属 **seq7-20260917-112114** 批次; +② 对应源文件 **5/5 存在**(`src/net/relay/{client,directory,main}.ts`、`src/web/server.ts`、`src/worker/tunnel.ts`); +③ `lib/` 内**等价比产物 5/5 存在**; +④ **书面记录**:这 5 个是 **seq7 11:21 的快照**,**已被序⑧/⑨/⑭/⑮ 改写作废 ⇒ 不是有效回滚点、删除风险 = 零**。 + +**删除前 → 后**:`find src test scripts -name "*.bak-*" | wc -l` = **5 → 0** ✅(**逐个点名 rm**,⛔ 未用通配符) + +逐个名:`src/net/relay/client.ts.bak-seq7-20260917-112114` / `src/net/relay/directory.ts.bak-seq7-20260917-112114` / `src/net/relay/main.ts.bak-seq7-20260917-112114` / `src/web/server.ts.bak-seq7-20260917-112114` / `src/worker/tunnel.ts.bak-seq7-20260917-112114` + +**判据**: +- `npm run build` **RC = 0** ✅ +- `npm test` = **162 / 161 / 0 / 1** ✅(不退化) +- **lib 产物 md5 清理前后零差异** ✅(自证未改任何源文件):`client.js b8b29afba06ed6347cbefd29091f8c73`/`directory.js 3382ae677788d3bd1c026d4c71ca40ee`/`main.js 7531be9eeba5ced5928ff7747621d512`/`web/server.js b64fe76500249703e34934d274d0a151`/`worker/tunnel.js 5f2649b43b072812fbf5d910d91b0c22` +- ⚠️ `git status --porcelain | wc -l` **不减**(这 5 个全 untracked ⇒ 本来就不体现在 git status 里;判据落在 `find` 与 md5 上,与 §5-S3 的说明一致) + +⚠️ **额外兜底(超出单子、零风险)**:删除前把这 5 份原件**复制**到 `E:/ProgramData/AI技能/aliyun-dsh-server/_中间产物_待清理/seq17/bak-archive/`(5 个文件,保持相对路径)⇒ 即使日后需要,仍有字节级副本可查。 + +### 9.6 S4 临界项(对应 §8.6) + +**采样命令**:`node scripts/overlay-failover-drill.cjs --scene 4 --sample 5 --table "<工作区根>/参数表_覆盖网络_20260917.md"`(RC = 0;原文 = `_tmp_seq17_scene4.txt`) +⛔ 只跑 **`--scene 4`(构 A)**,⛔ 未跑 `4b` / `4c` / `ctrl`。 + +**5 个样本(切换耗时,ms)**: + +| 轮 | 1 | 2 | 3 | 4 | 5 | +|---|---|---|---|---|---| +| 耗时 ms | 20121 | 18772 | 21223 | 24477 | 20246 | + +(5/5 全部在 `deadline = 30000 ms` 内侧;AUTH 就绪 4358 / 2272 / 2846 / 3652 / 2853 ms;目标恒为 `wss://106.54.21.172/dshs-relay`) + +**p95 口径(⚠️ 两种口径分档不同,必须点名)**: + +- **nearest-rank(保守,本次采用)**:`k = ceil(0.95 × 5) = 5` ⇒ 第 5 个 = **24477 ms** ⇒ 落 **临界档(24000 < p95 ≤ 27000)** +- 线性插值:`idx = 0.95 × (5−1) = 3.8` ⇒ `21223 + 0.8 × 3254` = **23826.2 ms** ⇒ 落 健康档 + +**⇒ 结论(按 §4-D3)**:**临界档 ⇒ 记录在册、⛔ 不判 FAIL、⛔ 不调任何生产值**。 +⚠️ **两口径均 < 27000 ms ⇒ 回头条件未触发**(§4-D5 未被激活)。 +**⇒ 判据未落参数表 §6** —— §5-S4 ② 规定只在「健康」档落表 ⇒ **参数表指纹保持不变**(`8f08e74b026e6e5b5e1b3db813f031ae`,见 §9.9),**D1 自证成立**。 +阈值口径:`deadline = RELAY_FAILOVER_DEADLINE_MS = 30000 ms`;两档 = `deadline×0.8 = 24000` / `deadline×0.9 = 27000`。 + +### 9.7 零回归 / 越界自证(对应 §8.7) + +**E6 三件套(全绿)**: +① `npm test`(Node 22)= **162 tests / 161 pass / 0 fail / 1 skipped**,RC = 0 ✅ +② `--scene all` = **12 PASS / 0 SKIP / 0 FAIL**,RC = 0 ✅ —— 幕 4-A 两个毫秒值:**20419 ms**(停 47 ⇒ 切 106)与 **18333 ms**(一跳豁免切回 47);幕 4-C 实测 **18333 ms** / deadline 30000 ms +③ `overlay-probe --table "…/参数表_覆盖网络_20260917.md"` = **12/12 · rc = 0** ✅(OBS-01…OBS-12 全 PASS;OBS-11 = 必在 7 / 允许 4 / 区间 3 / 派生 2 / 实际 78,多出 0 / 缺失 0;OBS-09 本机:20000=401 w-106:45747=401) + +**E9 收口归零**:`ssh -p 22 bt-server 'systemctl is-active dshs dshs-relay dshs-pg'` = **active / active / active** ✅;`ss -lntH 'sport >= :25000 and sport <= :25063' | wc -l` = **64** ✅ + +**E8 越界自证**: +- `COOLDOWN_MS=0` 精确计数 = **0** ✅(`grep -rn "COOLDOWN_MS" scripts/` 的命中**全部是「⛔ 不许置 0」的警示注释**,无任何实际赋值 —— 已逐行人工核) +- **HEAD = `640813e`(⛔ 本棒零 commit)** —— ⚠️ 该判据表内值为基线快照;本节其余项全绿后才按用户追加授权单独提交(§9.10),**提交发生在 E8 判据采集之后** +- `git status --porcelain | wc -l` = **49**(基线 43 + 6),**逐项可归因,无越界**: + - **+4 个 M**:本轮 S1/S2 把 4 个原本干净的文档改为 M —— `dsh-server-docs/02-运维手册.md`、`skills/dsh-change-workflow/SKILL.md`、`skills/dsh-env-bootstrap/references/常驻规则-快照.md`、`dsh-server-docs/INDEX.md` + - **+1 个 ??**:`dsh-server-docs/skills/dsh-auto-handoff-chain/`(S2 计划内新建) + - **+1 个 ??**:`_中间产物_待清理/`(演练脚本落盘产物;**⛔ 不在提交范围内**) + - ⇒ **M 项逐个都在计划内 ⇒ 无越界改动** + +### 9.8 未过项 / 在册未办(对应 §8.8) + +1. **S4 落「临界档」**(p95 = 24477 ms,nearest-rank 口径)。 + - **现象**:5 个样本中 1 个为 24477 ms(超 `deadline×0.8 = 24000`),其余 4 个 18772–21223 ms。 + - **已做到哪一步**:三档判据已立(§4-D3)、`--scene 4` × 5 采样已做、零回归三件套全绿、收口归零完成、⛔ 未改任何生产值。 + - **回头条件(一出现必须回头解决)**:① p95(nearest-rank)**> 27000 ms**;② `--scene all` 的幕 4-A / 幕 4-C 出现 **FAIL**;③ 任何 `RELAY_FAILOVER_*` / `HB_SEC` / burst 值被调整。命中任一条 ⇒ 按 §4-D5 重做四段分解(检测 / 首试 / 白等 / 建连)后定位,**⛔ 不许自行调生产值**。 +2. **Q4 —— 已解除(用户本轮授权)**:原「`src/net/relay/**` untracked ⇒ commit/push 需授权」由用户原话「执行完毕后 同步到仓库」解除 ⇒ 已 commit + push(§9.10);`git ls-files src/net/relay | wc -l` **0 → 15**。 +3. **`src/worker/tunnel.ts` 生产死路径**(§0.1 末 / D4):⛔ 本棒未处置,**原样登记**(是否删 = 独立决策,可能保留作 ssh 兜底变体)。 +4. **presence / 房间层 / 内容分发**(§3.3):⛔ 未定序 —— 属**业务优先级**(§1 边界外 ①)⇒ **需拍板**,候选与优缺点登记原样保留。 +5. **骨干节点服务范围**(入口 §4 既有待拍板项 A/B):**原样保留**,本棒未重复上抛。 +6. **`§8.8-1` 106 sshd MaxStartups 限流**:本轮 ssh **未遇 `rc=255`**(单次重试机制未触发);⛔ 未放宽限流(命中 R5)⇒ 仍在册。 + +### 9.9 指纹(对应 §8.9,可复现核对口径) + +| 对象 | 值 | 判定 | +|---|---|---| +| **本单 §8 前缀** | `bab83b7219b2669d5a6e9f1acf782e1f` | **回填后未变** ✅(§9 在 §8 之后,不进前缀口径) | +| **本单全文** | 回填后见下(脚本输出) | — | +| **参数表**(截断口径 `sed '/^## §10 指纹/,$d' … \| md5sum`) | `8f08e74b026e6e5b5e1b3db813f031ae` | **未变** ✅(S4 落临界档 ⇒ 判据未落表 ⇒ D1 自证) | +| 覆盖网络线入口 | `接续入口_覆盖网络线_20260916.md`(§0 + §2 已推进,见 §9.11) | — | + +**三处同步产物(本机 = 文档库 = 镜像)**: +- `skills/dsh-auto-handoff-chain/SKILL.md` = `0c5c4103f8ffa8071ce29434654fa2d3` +- `skills/dsh-auto-handoff-chain/scripts/chain_report.py` = `636c4f336864bc99c408e92577516f11` +- `skills/dsh-change-workflow/SKILL.md` = `338bf9534e5243f495e7d66f914371bf` +- `skills/dsh-env-bootstrap/references/常驻规则-快照.md` = `e1a460b53cc4b62466107f8555946496` + +**仅文档库 + 镜像**:`02-运维手册.md` = `d6627843b67c72ddfd3b62b5cfd5e29f`|`README.md` = `ddfd4d5fc890af0ee86d5442d8c0d610`|`INDEX.md` = `5677883fd3777a225ae85af7c5c79fb7` + +**S3 涉及的源文件(清理前后不变,自证未改码)**: +`src/net/relay/client.ts` = `6ffb117f3f6df1c2456641e176529ce8`|`directory.ts` = `d6dfcf1f1fbf388f994582d43bbb65ec`|`main.ts` = `7d5f915fcf0298e178e910d37863ef1d`|`src/web/server.ts` = `12f8dadd4a2e06ffaed70bddbd719999`|`src/worker/tunnel.ts` = `e84c0baab6e96da1208d9512a000cedc` + +### 9.10 追加项 —— 同步到仓库(用户本轮明确授权 ⇒ Q4 解除) + +**授权原话**:「执行完毕后 同步到仓库」(本棒执行期间用户明确下达)。 + +**提交清单核对(提交前逐项确认只含本线真实改动)** ⇒ 两个 commit: + +| commit | 面 | 文件数 | 内容 | +|---|---|---|---| +| **`146c3d2`** | 代码 / 测试 | **53** | relay 子系统 `src/net/relay/**`(15 文件)+ `src/worker/relay-tunnel.ts` + `src/web/routes/overlay.ts` + 8 个 overlay 脚本 + 12 个测试 + `package.json` 等改动(`+16187 / −63`) | +| **`bc0dd2c`** | 文档库 | **10** | `02-运维手册.md`、`INDEX.md`、`README.md`、`scripts/stop-dialog-guard.py`、4 个技能文件(含 S1 的两处校正)+ **新增** `skills/dsh-auto-handoff-chain/**`(2 文件) | + +- ⛔ **未用 `git add -A`**:逐路径 `git add src/ scripts/ test/ package.json` 与 `git add dsh-server-docs/`;**`_中间产物_待清理/` 未被提交**(提交后仍为唯一的 `??`)。 +- **推前判定**:`git ls-remote origin refs/heads/master` 取**裸 sha** = `640813e84e5e48c56ca39d765d7b195884e58ebc`;`git merge-base --is-ancestor 640813e… HEAD` ⇒ **是祖先**(fast-forward,无分叉);`git push --dry-run` 显示 `640813e..bc0dd2c`。 +- **推送结果**:`640813e..bc0dd2c master -> master`;推送后 `git ls-remote` = **`bc0dd2c96da88b1ee3a8450760b6cbb8ab92d845`** = 本地 `HEAD` ✅ +- **Q4 缺口闭合自证**:`git ls-files src/net/relay | wc -l` = **0 → 15** ✅(序⑭/⑮ 的修复与 `test/relay.test.mjs` **首次进入版本库**) +- 行尾:`.gitattributes` 的 `dsh-server-docs/** -text` **完好**(文档库保持纯 LF)⇒ ⛔ 未做任何批量换行符转换。 + +### 9.11 收口六件核对 + +| # | 项 | 状态 | +|---|---|---| +| ① | 释放锁 `--release-exec` | ✅ 本棒收尾第一步执行 | +| ② | **陈述句**告知 + 登记下一棒 | ✅ 已陈述句告知;**⛔ 未登记下一棒** —— 理由见下 | +| ③ | 入口 §0 + §2 推进 | ✅ 已刷新(§2 的序⑰ 转存档 + 新增收官段) | +| ④ | 工作区日志 `.workbuddy/memory/2026-09-17.md` | ✅ append-only 追加 | +| ⑤ | 本单回填回报 | ✅ 本 §9(9.1–9.11) | +| ⑥ | 输出指纹 | ✅ §9.9 | + +**⛔ 为何未登记下一棒(登记门禁)**:四条在册项清干后,**剩余项全部落在「需拍板」区** —— presence / 房间层 / 内容分发**定序**(= 业务优先级,§3.3)与**骨干节点服务范围**(入口 §4 的 A/B)。按用户明令的登记门禁(「**要用户拍板的,等拍了再登记**」,技能 `dsh-auto-handoff-chain §3.3`),**不登记下一棒**;拍板到手后再建。 +⚠️ 技能的三处同步(S2)完成后,`dsh-auto-handoff-chain` 已同时存在于本机 / 文档库 / 镜像 ⇒ 上述门禁在**三处都可读取**。