Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_relay落地R2-R4_20260916.md
T
admin 7bd1151c67 chore(工作区): 归档交接单 27 件 + 新增 CODEBUDDY §9 工作区卫生
交接单按归属约定(正文落文档库、工作区只放指针)归档至
归档/交接单-20260924-归档/,含逐件判定 README:
- 16 件已被文档库正式版取代(T09–T21 + 覆盖网络-24/25/26)
- 4 件主题已被覆盖网络线入口汇总
- 7 件历史接续包/规划件

CODEBUDDY §9:收口清本棒 tmp、tmp 保留期 7 天、禁「待清理」中间态、
工作区入库只放文档与文件、不保留脚本副本、>60 KB 单文件须逐个判。
2026-09-24 07:58:57 +08:00

489 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 交接单 · 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),**没碰**这一处 —— 需不需要按同一口径收,
**是下一棒要决定的事**(判据:它是否也会产生"看起来像别的错"的假象)。