docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)

起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

入仓 33 份(一律复制,工作区根原件保留不动,避免引用断链):
- 04-调整方案/113–128(16 份 · 原子占号后落盘):覆盖网络 传输方案取舍 / 应用场景与待完善清单 /
  插件化vs改内核 / 问题逐条推演 / 参数表与观测口径;会合中继拆分取证与改造方案;
  集群化改造方案 Manager-Worker;跨节点迁移与节点自举;项目代码分层范式与迭代风险评估;
  搬运与共享重建方案 guest w47→w106;方案规划方法提炼;文档无效信息审计报告;
  会话接续机制复盘与修复;会话接续规范;dsh 客户端化部署方案;dsh 桌面客户端开发方案
- 交接单/archive/交接单-已完成/T09–T21(13 份 · 覆盖网络线已完成单归档)
- ops/(2 份运行态指针:接续入口 / 接续包 · 覆盖网络线)
- archive/(2 份临时与内部简报)

已排除(无需重复入仓):覆盖网络线 10 份方案正文已入档案 103–112(文件名不同)。

登记:INDEX.md §二 新增 04-113–128 共 16 行 + §四 追加 T09–T21 说明 + 机器摘要行刷新
(⛔ 未跑 docs-index-stats.py --write:该脚本会按 \r\n 归一化全文件行尾,故改为字节级单行替换);
README.md 追加 1 条入仓指针;docs-manifest.json 复跑 scripts/docs-manifest.py 刷新。

验收:工作区根 47 份 .md —— 同名已入仓 8 / 本次内容一致 29 / 已知改名映射 10 / 未入仓 0;
git status 待提交清单只含本次新增与登记 3 件(未涉 src/ 与 relay 代码面)。
This commit is contained in:
admin committed 2026-09-17 18:24:19 +08:00
1 parent bc0dd2c96d
commit 04776af4b1
36 files changed
+11840 -43

No files matched your search

@@ -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:<port>` 拨 ⇒ **打到另一个用户的实例**。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<ts>` 用户并审批**、**在 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 [email protected]`(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 [email protected] '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=<PortableGit>/usr/bin:<PortableGit>/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://[email protected]: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` 当事实。
@@ -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) | 逻辑名 `<network>/<hostId>` 成为 `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 地址规划与名字解析**:逻辑名 `<network>/<host>` + 按网络授权。
⛔ **本单明确不做**:应用层 / 房间层 / 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 [email protected] '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:<userId>`**(该用户全部设备)。
- 节点逻辑名 = **`<network_id>/<hostId>`**;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<string>` 升级为
`dialers: Map<networkId, Set<hostId>>` 语义(**权限只收窄**:默认拒绝,命中才放行)。
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. 节点逻辑名统一为 `<network_id>/<hostId>`(`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 · <P0-x 名称>
- 改动:<文件:行 级清单>(新增/修改分开写)
- 判据:<命令> ⇒ <实测输出原文>
- 未过 / 存疑:<逐条,没有就写"无">
- 回滚:<本步的退法,一句话>
- 纪律:未 commit / 未 push;锁 <持/放>;工具调用 N 次
```
---
## 9. 纪律(沿用本线惯例)
- ⛔ **不 commit、不 push**(未获授权);改完只铺 `47` 的 `/opt/dshs/lib/` + 重启 `dshs`。
- 动手前抢全局执行锁,**做完立刻释放**(带锁结束回合 = 把所有人挡在门外)。
- 每步 **只做一件事**;发现的额外问题**先报告后动手**(R7)。
- ⛔ **不要回头问"要不要部署"** —— 部署是本 lane 内的执行细节,直接做,动手前一句话说明。
- ⛔ 完成任一段后**不要**把"下一棒"写成应用层 / presence(`清单 §五` 排在第 7 步)。
@@ -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)
<details><summary>展开 R2 原指令(历史记录)</summary>
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 <conf> <conf>.bak-<YYYYMMDD-HHMM>-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。
</details>
---
## 3. R2 原验收表(历史记录;实测结果见 §9)
<details><summary>展开</summary>
| 步 | 命令(原文) | 期望 |
|---|---|---|
| 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` |
---
</details>
## 4. 回滚(每步秒级)
- **R2-b**:`cp <conf>.bak-… <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` 可留可删(不占端口即无害)。
- 两步都回滚后 ⇒ 与本单之前状态**完全一致**(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/<pid>` 判属主 / `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/<w-106 用户>/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;<title>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),**没碰**这一处 —— 需不需要按同一口径收,
**是下一棒要决定的事**(判据:它是否也会产生"看起来像别的错"的假象)。
@@ -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` 现取 —— ⛔ 此处**故意不内嵌数值**:
它包含本节自身,写进去即刻失效(自指)。
@@ -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 口径均不变**(执行棒可照常核指纹)。
@@ -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 [email protected] '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**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。
@@ -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`)。
@@ -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 任何一行。
@@ -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://[email protected]: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 行逐条复核**与预期一致**,正文无残留。⇒ 记录在此,供下一棒对账时知情。
@@ -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**:请现取(⛔ 本行故意不内嵌数值 —— 包含本节自身,写进去即刻失效)。
@@ -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 口径均不变**。
@@ -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。
@@ -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` 已同时存在于本机 / 文档库 / 镜像 ⇒ 上述门禁在**三处都可读取**。