# 交接单 · relay 落地 R2–R5(**执行会话专用** · 2026-09-16 20:5x 开,23:0x R5 收口) > **口径指纹(必须校验)**:`tail -n +4 交接单_relay落地R2-R4_20260916.md | md5sum` = `b110b5c4e3eb8afcac08af537d333c99` > 不符 ⇒ **停下,只报告「口径已更新,需重新接续」**。 > 📌 **状态:R2 → §9 | R3 Step 0 → §10.4 | R3 本体 + R4 → §10.5 | R5 → §11**(全部已执行完毕)。 > > **怎么用**:先跑工作区根 `state.py` ⇒ 按「覆盖网络线」那一行定位;**本单是唯一执行依据**, > ⛔ 不读其它会话上下文、⛔ 不 Glob/Grep 全库探索。 > **一轮只做 §3「下一步」那一件事**,做完即停并回报;⛔ 不许顺手做 §9 或别的线。 --- ## 1. 已完成(**不要再做**,只作背景) | 步 | 内容 | 证据 | |---|---|---| | R0 | 判据打分表 + 链路画像 + relay 定案 | 方案文档 §9 / §10 | | R1 | 自研 relay 最小闭环(`src/net/relay/`) | 方案 §11;47⇄本机跑通 | | R1.5 | 韧性(启停/网络变化/中断/异常/时钟)+ 准入与选点 | 方案 **§12 / §13**;单测 **15/15**、全量 verify **80/79**、47 真机全绿 | | **R2** | **relay 常驻 47 + nginx 443 暴露 wss** | ✅ **2026-09-16 21:0x 已执行完毕** ⇒ 见本单 **§9**;命令原文级证据 = 方案 **§14.2** | - 代码仓 `D:/github/dsh_shenxian`(**未 commit**):`src/net/relay/`(`wire/server/client/keys/placement/rendezvous/index/main`)+ `test/relay.test.mjs`; 改动仅 `src/net/reachability.ts` 加 `VIA_RELAY`、`package.json` 测试列表加一项。 - 分层检查:`node scripts/check-layering.mjs` **无新增违规**(relay 属③能力层)。 - **四条真机硬结论**(改代码前必读,方案 §12.5):① 重试定时器**不能 `unref`** ② 停机必须 `closeAllConnections` + 硬兜底 ③ 优雅重连用**时窗**不用次数 ④ 满载是唯一硬门、已在册 host 重连优先。 --- ## 2. R2 内容(✅ **已执行完毕,⛔ 不要再做** —— 结果见 §9,下一棒直接看 §10)
展开 R2 原指令(历史记录) R2 = 让 relay 在 47 上**常驻**并**经 nginx 443 暴露 wss** 两个小步,**各自可独立验收、独立回滚**;R2-a 失败不影响 R2-b,反之亦然。 ⚠️ 全程**不动 SSH 路径**(`manager-ssh` 照旧在跑),所以 **data plane 零风险**。 ### R2-a 常驻(relay 服务端) 1. 只读前置:`command -v node`(取**绝对路径**,systemd 不吃 PATH)、`ss -lntp | grep 20080`(应为空)。 2. 部署产物:`D:/github/dsh_shenxian/lib/net/relay/` ⇒ 47 的 **`/opt/dsh-relay/lib/net/relay/`** (⛔ **不碰 `/opt/dshs/**`**;在 `/opt/dsh-relay/` 下放 `{"type":"module"}` 的 `package.json`) 3. 密钥:生成**每 worker 一密钥**(`openssl rand -hex 32`),落 **`/etc/dshs/relay-keys.json`**(`chmod 600`),格式: `{"w-47":"<64hex>","w-106":"<64hex>"}` —— ⛔ 不用共享 token。 4. 单元 `/etc/systemd/system/dshs-relay.service`(全文如下,**`ExecStart` 里的 node 换成第 1 步取到的绝对路径**): ```ini [Unit] Description=dshs relay (loopback-only WebSocket relay for the DSH overlay network) After=network-online.target Wants=network-online.target [Service] Type=simple User=root WorkingDirectory=/opt/dsh-relay ExecStart=/usr/bin/node /opt/dsh-relay/lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 --max-hosts 0 Restart=always RestartSec=1 TimeoutStopSec=5 StandardOutput=journal StandardError=journal SyslogIdentifier=dshs-relay [Install] WantedBy=multi-user.target ``` 5. `daemon-reload` + `enable --now dshs-relay`。 ### R2-b 入口(唯一动门户的一步) 6. 只读前置:`nginx -T | grep -n 'dshs-relay'`(应为空)、定位承载 dsh 门户的 **443 server 块**所在 conf (候选:`/www/server/panel/vhost/nginx/dsh.alotbuy.com.conf`;`0.catchall-443.conf` 是 default_server,**别改它**)。 7. **备份**:`cp .bak--pre-relay`(照该目录既有 `.bak-*` 命名惯例)。 8. 在该 server 块内加**一个** location 并 reload(`nginx -t` 必须先过): ```nginx # ── DSH 覆盖网络中继(自研 relay)──────────────────────────────── # 只在**既有 443 server 块**里加一个 location:不新增监听口、不新增证书、不动门户其它路径。 # `/status` 不在此前缀下 ⇒ 不会被代理出去(relay 端也只认 RELAY_PATH 前缀)。 location /dshs-relay { proxy_pass http://127.0.0.1:20080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } ``` > `map $http_upgrade $connection_upgrade` **已在 `nginx.conf:321`**(R0 已取证)⇒ 直接复用,不要新写 map。
--- ## 3. R2 原验收表(历史记录;实测结果见 §9)
展开 | 步 | 命令(原文) | 期望 | |---|---|---| | R2-a | `systemctl is-active dshs-relay` | `active` | | R2-a | `ss -lntp \| grep 20080` | 只有 `127.0.0.1:20080`(**不是** `0.0.0.0`) | | R2-a | `curl -s 127.0.0.1:20080/status \| head -c 200` | 含 `"capacity"` | | R2-a | 本机 `curl -s -m 5 http://47.77.182.89:20080/status` | **不可达**(`rc≠0`)⇒ 零新增公网口 | | R2-a | `journalctl -u dshs-relay -n 5 --no-pager` | `listening ws://127.0.0.1:20080/dshs-relay (loopback only)` | | R2-b | `nginx -t` | `syntax is ok` | | R2-b | `curl -i -s -N --max-time 6 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" https://dsh.alotbuy.com/dshs-relay \| head -3` | `HTTP/1.1 101 Switching Protocols` | | R2-b | `ss -lntp \| awk '{print $4}' \| sort -u` 前后对比 | **无新增 `0.0.0.0` / `*` 监听** | | **端到端** | 本机跑 client 经 `wss://dsh.alotbuy.com/dshs-relay` 拨出(用 R1 的方式:`node lib/net/relay/main.js --client --url wss://… --host local-r2 --keys-file <本地 keys> --ports 20099`) | 日志出现 `registered host=local-r2`;47 上 `curl 127.0.0.1:20080/status` 的 `online` 含 `local-r2` | ---
## 4. 回滚(每步秒级) - **R2-b**:`cp .bak-… ` → `nginx -t` → `nginx -s reload`。 - **R2-a**:`systemctl disable --now dshs-relay` + `rm -f /etc/systemd/system/dshs-relay.service` + `systemctl daemon-reload`; 产物目录 `/opt/dsh-relay` 可留可删(不占端口即无害)。 - 两步都回滚后 ⇒ 与本单之前状态**完全一致**(SSH 路径从头到尾没动过)。 --- ## 5. 边界与红线(越界即停手) - ⛔ **不 commit / 不 push / 不 git add**。 - ⛔ 不碰 `/opt/dshs/**`、不动 `dshs.service`(Manager 主服务)、不动 443 server 块的 `server_name` / 证书 / 其它 location。 - ⛔ 不新增任何 `0.0.0.0` 监听;不安装任何第三方软件(relay 是自研、零新增依赖)。 - ⛔ 不改 `dsh_hosts.via`(那是 R3)⇒ 本轮**必须**让 `manager-ssh` 原样在跑。 - ✅ 允许:写 `/opt/dsh-relay/**`、`/etc/dshs/relay-keys.json`、`/etc/systemd/system/dshs-relay.service`、备份并改**一个**站点 conf、`nginx -t` + reload、 `systemctl` 操作 `dshs-relay`。 - **成本纪律**:工具调用 **≤ 25**;批量活写脚本一次跑完;大输出先 `> /tmp/x.txt` 再 `sed -n` 读关键行。 - 🔴 临时进程/目录**必须回收**(R1.5 教训:残留一个 relay 占 20080 ⇒ 后续 `EADDRINUSE`);收尾用 pidfile 或 `systemctl`,⛔ **不要 `pkill -f`**(pattern 会命中 ssh 自身命令行 ⇒ 自杀)。 --- ## 6. 回报格式 `判定(1–3 行)` → `实测证据(命令原文 + 输出摘录)` → `未解决/阻塞(诚实标注,不写"已处理")` → `我接着做什么(陈述句)`。 ⛔ 结尾**不许**用征询句("要不要我继续")。 --- ## 7. R3 / R4 预告(**本单不做**,只为让下一棒知道路线) - **R3**:worker 侧常驻(47 的 `w-47` 用 `local` 不动;106 的 `w-106` 经**宝塔 MCP** 部署 client 常驻)+ `dsh_hosts.via='relay'`(env 级、可秒回滚)。 - **R4**:观察一轮 ⇒ 下线 sshd 反向隧道、收回 47 上那条 `authorized_keys`、**回收 `32022`**(净减一个公网暴露口)。 --- ## 8. 关键事实速查(免得重新探索) - 47 = `47.77.182.89`(Manager,控制面 `dshs.service`);106 = `106.54.21.172`(Worker,宝塔 MCP `mcp__baota-mcp-106.54.21.172`)。 - 47 **无本机防火墙**(`nft INPUT policy accept`)⇒ 任何 `0.0.0.0` 监听立刻公网可达 ⇒ **零新增公网口是硬约束**。 - relay 产物在代码仓 `lib/net/relay/`(`npm run build` 产出);`src/net/relay/` 是源码,同层还有 `reachability.ts`(`VIA_RELAY='relay'`)/ `rendezvous.ts`。 - 47 上 `/opt/dshs/lib/`(勿动);106 上 `/opt/dshs-cluster/lib/`。47 部署惯例 = 本机 build 后 scp `lib/`。 - 全局执行锁:`bash D:\github\dsh_shenxian\dsh-server-docs\scripts/handoff-guard.sh --claim-exec "<会话名>"`;**做前抢、做完放**。 --- ## 9. ✅ R2 实测结果(2026-09-16 21:0x · **已执行完毕**) **判定**:R2-a + R2-b **全部通过** —— relay 已在 47 常驻,且经 `https://alotbuy.com/dshs-relay` 可达(**origin 直连与经 Cloudflare 两路均 `HTTP/1.1 101`**);端到端 `registered host=w-47`;监听面与基线**逐字一致**(零新增公网口)。 **落在 47 上的物(可核)** - `/opt/dsh-relay/lib/net/relay/*` + `/opt/dsh-relay/lib/net/reachability.js` + `/opt/dsh-relay/package.json` - `/etc/dshs/relay-keys.json`(`chmod 600`;`w-47` / `w-106` 各一 64-hex 密钥) - `/etc/systemd/system/dshs-relay.service`(`enabled` + `active`) - `/www/server/panel/vhost/nginx/alotbuy.com.conf` 新增 `location /dshs-relay`;备份 = `alotbuy.com.conf.bak-20260916-2059-pre-relay` 🔴 **两处必须继承的勘误(本单 §2 里的"候选"写错了)** 1. 门户 443 块 = **`alotbuy.com.conf`**(`server_name alotbuy.com www.alotbuy.com *.alotbuy.com`,443 块在第 85–123 行);**`dsh.alotbuy.com.conf` 是遗留 301 跳转域名** ⇒ ⛔ 不能往它里面加 location。 2. `dsh.alotbuy.com` 在 **Cloudflare** 后面 ⇒ 验收 URL 一律用 **`https://alotbuy.com/dshs-relay`**;且 443 块有 `http2 on` ⇒ **curl 判据必须带 `--http1.1`**(否则必得 `HTTP/2 404` 假失败)。 **完整证据** = 方案文档 **§14.2**(13 条判据的命令原文与实测值);**三条硬结论** = 方案 **§14.3**(`base/span` 语义勘误 / `/proc/` 判属主 / `http2` 与 WS 握手)。 --- ## 10. 下一棒 = R3(⏳ **本节已被 §10.5 取代:R3 与 R4 均已落地并端到端验收**) ### 10.1 R3 内容(原样保留) - **R3-a**:worker 侧 client 常驻(106 经宝塔 MCP;`w-47` 用 `local` 不动)+ 密钥下发(`w-106` 密钥已在 `/etc/dshs/relay-keys.json` 内)。 - **R3-b**:`dsh_hosts.via='relay'`(env 级、可秒回滚)。 - ⚠️ **R3 还有一个没做的前置**:`src/web/server.ts` 的 `RendezvousRegistry([...])` 目前**只注册了** `LocalRendezvous` + `ManagerSshRendezvous`,**`RelayRendezvous` 尚未接入** ⇒ **不接线就改 `via='relay'` 不会有任何效果**(会静默无效)。 ### 10.2 ✅ 阻塞已降级(2026-09-16 21:1x 复核,**推翻本单初稿**):不是"未评审改动",而是"一次 hash 对账" **复核实测** - `git diff --stat` = **15 files, +287 / −23**;逐条看**全部属于 S2 会合中继拆分这条线**(`db/repo.ts`、`db/types.ts`、`net/reachability.ts`、`supervisor/{orchestrator,spawn}.ts`、`web/server.ts`、`web/routes/admin.ts`、`test/reachability.test.mjs` …)—— **不是**与 relay 无关的杂项改动。 - 47 上**正在跑的** `/opt/dshs/lib/web/server.js`(mtime 09-16 16:10)关键字计数:`RendezvousRegistry` **2**、`ManagerSshRendezvous` **2**、`LocalRendezvous` **2**、**`RelayRendezvous` 0**、**`VIA_RELAY` 0**。 ⇒ **本机工作区 ≡ 47 已上线的代码**:那 15 处未提交改动**已经在生产跑着、且已过 P1/P2 验收**,只是没 commit。 **因此 R3 的 Step 0(纯技术对账,⛔ 不需要 git 授权)** 1. 本机 `npm run build`(**Node 22**)。 2. **对账**:`lib/` 逐文件比 47 `/opt/dshs/lib/` —— **净差异必须只有 relay 相关**(`lib/net/relay/**`、`lib/net/reachability.js`,以及接入 `RelayRendezvous` 后的 `lib/web/server.js`)。**出现其它任何差异 ⇒ 停下报告,不要铺**。 3. 对账过了才铺 Manager,并 `systemctl restart dshs`(开发环境服务器 ⇒ 直接做,动手前一句话说明即可)。 ⛔ **仍不允许**:把本机未提交改动 **commit / push**(未获授权)—— R3 全程**不需要** commit。 ⚠️ 风险提示保留:本机**不是沙箱** ⇒ 铺之前必须完成第 2 步对账,**⛔ 不许"先 scp 再看"**。 ### 10.3 因此 R3 另起一轮,唯一入口仍是本单 §10 ### 10.4 ✅ Step 0 实测结果(2026-09-16 21:1x · **已执行完毕**) **判定**:Step 0 三步全过 —— 构建干净;`lib/` 对账**净差异只有 relay 相关**(符合 §10.2 的许可集);Manager 已铺并重启,`dshs` / `dshs-relay` / `dshs-pg` 全 `active`、门户 200。 **实测证据(命令原文级)** 1. 构建:`node node_modules/typescript/bin/tsc -p tsconfig.json`(Node 22.22.2)⇒ 退出码 **0**,零报错。 2. 对账(本机 `lib/` **134** 文件 vs 47 `/opt/dshs/lib/` **118** 文件,均排除 `*.map`): - **只在 47 = 0**|**只在本机 = 16** ⇒ 全是 `lib/net/relay/**`(R2 的 relay 产物在 47 是部署到 `/opt/dsh-relay`,故 `/opt/dshs/lib` 里本就没有) - **同路径内容不同 = 2** ⇒ `lib/net/reachability.js` + `.d.ts`,差异 = 本机多 `export const VIA_RELAY = 'relay';` 及其注释块(5 行) - ⇒ 恰好落在 §10.2 的许可集(`lib/net/relay/**` + `lib/net/reachability.js`),**无任何无关差异** 2b. 附带核对:本机 `lib/net/relay/*.js`(8 个)vs 47 `/opt/dsh-relay/lib/net/relay/*.js` ⇒ **8/8 全同**(relay 服务端代码两端一致,R2 之后无漂移) 3. 铺设与重启:备份 47 `/opt/dshs/lib/net` → `/opt/dshs-lib-net.bak-r3pre-20260916-2118`;scp `lib/net/relay/`(新增)+ `lib/net/reachability.{js,d.ts}`; **铺后 lib 全量再对账 = 134 / 134,三个集合全空**;`systemctl restart dshs` ⇒ 三单元 `active`;`127.0.0.1:3080` 与 `127.0.0.1:20080` 在听;`curl -H "Host: alotbuy.com" 127.0.0.1:3080/` = **200**。 **仍未做(= R3 本体,下一件事,本轮⛔ 未做)** - 🔴 `src/web/server.ts` 的 `RendezvousRegistry([...])` **仍未注册 `RelayRendezvous`** ⇒ 此刻改 `via='relay'` 依旧**静默无效**(§10.1 的前置,**必须先接线**)。 - R3-a(106 client 常驻,经宝塔 MCP)|R3-b(`dsh_hosts.via='relay'`,env 级)。 - ⛔ 未 `commit` / 未 `push`(本机工作区仍是 15 处未提交改动 + 新增 relay 产物)。 **回滚**:`cp -a /opt/dshs-lib-net.bak-r3pre-20260916-2118/* /opt/dshs/lib/net/` → `systemctl restart dshs`(本步只动了 `lib/net`,其余 `lib/**` 逐文件 hash 相同)。 --- ## 10.5 ✅ R3 本体 + R4 全链落地(2026-09-16 21:37–22:2x · **已执行完毕**) **判定**:**R3 与 R4 全部完成并端到端验收**。SSH 反向隧道**已不可能再建立**(凭据与端口都已收回),47 的公网暴露面**净减 1 口**(`32022`)。本单执行完毕。 ### R3(worker 侧常驻 + `via='relay'`) 1. **接线**(§10.1 的前置,必须先做):`src/config.ts` 新增 `relayUrl` / `relayStatusUrl`(**默认空 ⇒ 行为同 R2 之前**);`src/web/server.ts` 把 `RelayRendezvous` 注册进 `RendezvousRegistry`,配 relay `/status` 实时快照(`online` 判定 + **15 s 陈旧即失效**);`src/net/reachability.ts` 新增 `addressPort()`(IPv6 安全取端口)。 2. **47** drop-in 加 `DSHS_RELAY_URL` / `DSHS_RELAY_STATUS_URL`;relay 以 `--base 19000 --span 3000` 起(准入窗口覆盖 agent `19000` 与 w-106 实例段 `21000+`)。 3. **106** 常驻 + 密钥:`/opt/dshs-cluster/lib/net/relay/*`、`/etc/dshs/relay-keys.json`(`600`,**只含 `w-106` 一把**)。 4. **`dsh_hosts.via='relay'`**(仅 w-106;`w-47` 保持 `local` —— 同机直连,无需中继)。 5. **证据**:relay `/status` 出 `w-106 … ports=19000` +动态回环口;经 Manager 打 `/api/admin/users//dsh/status` = **200**,且 relay `streamsOpened` 同步增长 ⇒ **Manager→agent 确实经 relay**。 ### R4(实例面经 relay + 隧道下线) 1. **mux/relay**:新增 `PORT_ADD(0x0b)` / `PORT_DEL(0x0c)` / `PORT_ACK(0x0d)`;server 侧 `ensureEndpoint` 暴露 `ready`、新增 `closeEndpoint` / `onPortChange`(口径与 `HELLO` 同源);client 侧 `addPort/removePort/replayDynamicPorts`,**`addPort` 成功必须同时写 `allow`**(否则"端口开着、流全被拒"的最难看半通;T16 抓的就是它)。 2. **传输抽象**:新增 `WorkerTunnel` 接口 + `RelayTunnel`;worker agent 按 scheme 选传输,**缺密钥抛错、不静默降级**。 3. **传输切换**:106 设 `DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay` + `DSHS_RELAY_SECRET`;停用早期那个临时 `dshs-relay-client` 单元。 4. 🔴 **R4 关键缺陷(本次实测发现并修掉)**:`RemoteSpawner` 构造函数**漏了 `this.translateEndpoint = options.translateEndpoint`** ⇒ 实例面翻译**静默失效** ⇒ Manager 拿 **Worker 侧口号**(`127.0.0.1:21000`)往**自己本机**拨 ⇒ 连接被拒两次 ⇒ 代理 `reply.raw.destroy()` ⇒ **浏览器只见「空响应」、平台一行日志都没有**。 - **定位手段(可复跑,别靠读代码猜)**=**判别器**:在 47 上临时监听 `21000` 再发门户请求 ⇒ 请求被探针接走(`PROBE21000 hit GET /?token=… host=127.0.0.1:21000`)⇒ 一口定死"拨的是未翻译的口号"。(探针用完即停,已确认 `21000` 监听数归 0。) - **同处还修了一个"失败开放"**:原判据取 `reachability.via`,而 host 离线 / relay 快照陈旧时它是 `undefined` ⇒ 落到"非 relay ⇒ 原样透传"分支。改成读 **`dsh_hosts.via` 原文**(server 侧新增 `hostVia` 表)⇒ relay host **失败关闭**(查不到就回 `undefined`),未知 host 行为不变。 - **回归测试**:新增 `test/remote-spawner.test.mjs`(T1–T4)并登记进 `npm test` / `npm run verify`;**先红后绿已实证** —— 摘掉修复那行 ⇒ T1/T3 红(`# pass 2 / # fail 2`);恢复重构建 ⇒ `remote-spawner + relay + reachability + instance-port` = **36/36 全绿**。 5. **R4 收尾(净减暴露面)** - **47**:`/root/.ssh/authorized_keys` 收回 `dshs-tunnel-106to47`(**3 → 2 条**;备份 `authorized_keys.bak-r4-20260916`);`/etc/ssh/sshd_config` 注释掉 `Port 32022`(`sshd -t` OK;备份 `/etc/ssh/sshd_config.bak-r4-20260916`)⇒ **32022 监听 = 0**,`22` 正常。 - **106**:`/etc/dshs-worker.env` 注释掉 `DSHS_TUNNEL_TARGET` / `DSHS_TUNNEL_IDENTITY`(备份 `.bak-r4-20260916`);`/root/.ssh/tunnel_ed25519*` **移至** `/root/_tunnel-keys-bak-r4-20260916/`(移动而非删除 ⇒ 回滚不必重生成密钥)。 6. **终验(在隧道下线之后跑,最强证据)** ``` 47: sshd 22=2 32022=0 19000(隧道落点)=0 | relay 仅绑 127.0.0.1:20080 | nginx 443=1 门户:内部 http=200 / 公网 https://alotbuy.com/ = 200 relay session:w-106 session=6b18bfba1249f111 ports=[19000] launch ⇒ 实例 port=21000 status=running relay 端点:19000 -> 36097 | 21000 -> 38991 ← PORT_ADD 门户带 token:http=303 + Set-Cookie: dsh-auth-… 再取 /(带实例 cookie):http=200 bytes=62451 ct=text/html;DeepSeek Harness relay streamsOpened 4 -> 6 ← 字节真的过了 relay stop ⇒ 21000 端点消失 ← PORT_DEL ``` ⚠️ **口径坑(上一轮误判的真因)**:`/dsh/launch` 的 `folder` 必须是 ws 下**已存在**的目录,且 `fs.isDirectory()` 对不存在的路径是**抛 `UserFsError('not_found')` → 404**(**不是**返回 `false`)⇒ `{"error":"not_found"}` 是"**文件夹不存在**",**不是**"用户不存在"。别再把用户 id 当成嫌疑(`targetOr404` 用的是 `users.id`,那个 id 一直是对的)。 ### 回滚(分层,均秒级) | 回滚哪一层 | 动作 | |---|---| | 实例面翻译 | 还原 `translateEndpoint` 那一行 → `npm run build` → scp 两个文件 → `systemctl restart dshs` | | 传输回退到 ssh 隧道 | 106 恢复 `DSHS_TUNNEL_*`(`/etc/dshs-worker.env.bak-r4-20260916`)+ 私钥从 `/root/_tunnel-keys-bak-r4-20260916/` 搬回 + 47 恢复 `authorized_keys` 那一行与 `Port 32022` → `systemctl restart sshd dshs-worker` | | 会合回退(整体) | 47 drop-in 删 `DSHS_RELAY_URL` / `DSHS_RELAY_STATUS_URL` → `daemon-reload` → `restart dshs`(`via='relay'` 自动回退 `manager-ssh`) | ### 残留(不影响功能,需控制台凭据) - 腾讯云安全组里 **32022 的放行规则仍在**(本机已无监听 ⇒ 实际打不通)。改安全组需要控制台凭据 ⇒ **待用户处理**,不阻塞任何功能。 - 106 的 **provisioner 仍未铺**(与本单无关,属集群化 D 阶段遗留)。 **纪律**:本轮**未 `commit`、未 `push`**(本机工作区仍是未提交状态)。 --- ## 11. ✅ R5 = 会合可换机(2026-09-16 22:3x–23:0x · **已执行完毕**) **判定**:**R5 完成并端到端验收**。R1–R4 隐含的「**Manager 必须与 relay 同机**」这条前提**已经不存在** —— Manager 改为**只拨出**一条 wss,落点搬到**自己本机**的回环池。**relay 放哪台机器都不再影响 Manager。** 本单至此执行完毕。 ### 11.1 它解决的确切问题(方案缺口 = 会合中继拆分 §9.4) R1–R4 的落点是「relay 在**自己主机**回环上开监听,Manager 去连那个回环口」(R4 终验原文:`19000 -> 36097 | 21000 -> 38991` —— `36097/38991` 都是 **relay 主机**上的口号)。⇒ 中继换机后 Manager 根本够不到那些 `127.0.0.1`。 ### 11.2 做法(关键的一步**换位**) | 项 | 变化 | |---|---| | 落点在哪 | **relay 主机** → **Manager 本机**(`127.0.0.1:25000..26099` 预绑池,`DSHS_RELAY_DIAL_POOL=64`) | | Manager 身份 | 纯客户端 → **拨号方**(`RelayClient` 的 `dialer: true`,`HELLO` 时 `ports=[]`) | | 新帧 | `DIAL: 0x0e`(`{target, port}`,`streamId` 由拨号方自分配)/`DIAL_ACK: 0x0f`(`{ok, error?, workerStreamId?}`) | | 命名空间 | 拨号方 `streamId` 与 worker 会话 `streamId` **不重叠**;relay 用 `session.dialRoutes: Map<dialStreamId, {session, st}>` 配对 | | 数据面同形 | 新增 `StreamPeer` 结构化接口 ⇒「注册端口的 `net.Socket`」与「拨号流的 `WsStreamPeer`」在 `onData/flushStream/closeStream` **一行都不分叉** | | 权限门(R5 只准收窄) | relay 侧 `DSHS_RELAY_DIALERS=manager` **白名单**(空集 ⇒ `DIAL` 一律拒)+ `keys.ts` **每机独立密钥** ⇒ 爆炸半径 = 那一台 | | 双身份校验 | `ports=[] 且非拨号方` ⇒ 拒(`no-ports` 姿态**未退化**);`ports≠[] 且是拨号方` ⇒ 拒(`dialer-must-not-declare-ports`) | | 背压 | 真判据 = `conn.bufferedAmount < highWater`(WS `send()` 返回值恒真,不可作判据) | | 纯流转发模式 | 新增 `exposeLoopback: false` ⇒ relay 一个本地口都不开(本机暴露面 0);**本次未启用**,留作"relay 真换机"时的可选项(见 §11.5) | **代码改动**(本机工作区,⛔ 未 commit):`src/net/relay/{wire,duplex(新),server,client,dialer(新),main,index}.ts`+`src/config.ts`(5 项)+`src/web/server.ts`(接线)+`test/relay.test.mjs`(+T18/T19)。 ### 11.3 命令级证据(三组,均为原文) **① 单元 / 集成**:`tsc` 构建 `rc=0`;`relay + remote-spawner + reachability + instance-port` = **38/38 全过**;`check-layering` **无新增违规**(扫描 71 个 `.ts`,5 条全在基线内)。 **② 端到端(`accept_r5.sh`,47 上跑)** ``` ① [relay-dialer] 本机落点池就绪:64 个口(25000..26000) [relay-client] registered host=manager session=87dc3ebc78f37306 池监听数(25000..26000)= 64 ③ [relay] DIAL manager -> w-106:19000 ok (workerStream=1 dialStream=1) ← 控制面 ④ [relay-dialer] 落点 127.0.0.1:25000 -> w-106:19000 ⑤ endpoints: [(19000, 41233, 0)] ← 🔴 relay 回环口 41233 streams=**0**:Manager 完全没碰 ⑥ 带 token: http=303 → 带实例 cookie: http=200 bytes=62451 ct=text/html 页面标题:<title>DeepSeek Harness ``` **实例面也在拨号池里**(`diag2_r5.sh` / `diag3_r5.sh`): ``` [relay] DIAL manager -> w-106:21000 ok (workerStream=12/13/15 ...) ← 实例面(21000) [relay-dialer] 落点 127.0.0.1:25001 -> w-106:21000 [relay-client] dial w-106:21000 up (stream=12) launch ⇒ http=200 {"instance":{...,"port":21000,"status":"running"}} ``` **③ 🔴「换机」等价实验(`accept_r5_swap.sh` · 最强证据)** —— 语义 = 清掉 `DSHS_RELAY_STATUS_URL` ⇒ **Manager 对 relay 回环口一无所知**(等价于"relay 在别的机器上"): ``` 改后 drop-in 中 RELAY_STATUS 行数 = 0 / 生效值 0 条 relay DIAL 计数:0 -> 3 ← 控制面全经拨号 19000 次数=20 / 21xxx 次数=4 ← 实例面也经拨号 落点 127.0.0.1:25000 -> w-106:19000 endpoints: [(19000, 41233, 0), (21000, 44559, 0)] ← 🔴 两个 relay 回环端点 streams **全为 0** 带 token: http=303 / 带实例 cookie: http=200 bytes=62451 / <title>DeepSeek Harness → 已还原(RELAY_STATUS 1 条,dialers=["manager"]) ``` **④ 暴露面(`finalize_r5.sh`)**:R5 新增的 `127.0.0.1:25000..25063` **全部仅回环**;非回环监听与基线逐字一致 ⇒ **公网零新增口**。实例已停回 `running:false`,relay 端点收敛为 `[(19000, 41233, 1)]`。 **⑤ 配置与备份(47)** ``` relay drop-in: /etc/systemd/system/dshs-relay.service.d/dialers.conf Environment="DSHS_RELAY_DIALERS=manager" manager drop-in 追加 5 行: DIAL_HOST=manager / DIAL_SECRET=<64hex> DIAL_PORT_BASE=25000 / DIAL_PORT_SPAN=1000 / DIAL_POOL=64 备份: /etc/dshs/relay-keys.json.bak-r5-20260916-224838 /etc/systemd/system/dshs.service.d/cluster.conf.bak-r5-20260916-224838 /etc/systemd/system/dshs.service.d/cluster.conf.bak-r5swap-20260916-225546 产物对账 29/29 hash 全同(Manager lib/config.* + lib/web/server.* + lib/net/relay/*;relay 运行时 /opt/dsh-relay/lib/net/relay/*) ``` ### 11.4 🔴 勘误 §10.5 第 4 条关于 `{"error":"not_found"}` 的解释 §10.5 原文说它"**是「文件夹不存在」,不是「用户不存在」**" —— **这个解释不完整,实测可复现另一种更隐蔽的来源**(本次冷启动实验:重启 Manager 后连续 3 次 launch **全 404**,且**零 relay DIAL、零落点分配** ⇒ 请求**根本没到 w-106**): `src/fs/remote-user-fs.ts:70-73` —— 归属 `hostId` 解析出来了,但 `agentFor(hostId)` 返回 `undefined` 时,**静默回退**到 `options.agentUrl`(= `DSHS_CLUSTER_AGENT_URL = http://127.0.0.1:19100`,**w-47 自己的 agent**)⇒ w-106 的用户在 w-47 上不存在 ⇒ agent 回 `{error:'not_found'}` ⇒ 404 **`{"error":"not_found"}`**,与"文件夹不存在"**完全同形**。 ⇒ **判别器**(照 R4 那条同族教训的法子):看 **relay 有没有 DIAL** —— 有 = 真到了 w-106(文件层面问题);**没有 = 根本没出去**(地址未解析)。别只凭错误体下结论。 ⚠️ **这是既有缺陷(T08 集群化遗留),非 R5 引入**:R5 只在解析链上**加了一跳**(dialer 优先,再回退快照),不会让原路径变差。**按红线"额外问题先报告、后动手",本轮只记录未修** ⇒ 见 §11.6 待修。 ### 11.5 一处有意识的不改动(留证据,免下一棒误判为"没做完") relay 仍为端口绑回环口(`41233`/`44559`…)⇒ 47 上仍有这类**动态回环监听**。**本次有意不启用 `exposeLoopback: false`**,理由:① R5 的达成判据是"Manager 不再依赖它"(§11.3 ③ 已证 `streams=0`),**不是**"relay 不能绑";② 同机部署下 `localPort` 字段提供**诊断可读性**,关掉会让 `/status` 变"瞎" = 可观测性**净变差**(违反 R11);③ 真正换机时按需开即可。 ⇒ 下一棒若要做"relay 真挪到 106",**只需设 `exposeLoopback: false`(或另找一台时保持默认)**,Manager 侧**一行配置都不用改**。 ### 11.6 新发现待办(R5 当时**未修**,按"先报告后动手")—— ✅ **现已修完,见 §12** > ✅ **勘误(2026-09-16 23:4x)**:本节两条**均已修完并端到端验收** ⇒ 见 **§12**(先红后绿 + 冷启动 `launch 200` + relay 有 `DIAL` + `not_found=0`)。下方保留为 **R5 当时**的记录,仅存档。 **假 404 / 静默回退到错 agent**(§11.4)。影响面:**任何一次 `systemctl restart dshs` 之后的一小段窗口内,w-106 用户的文件面 / launch 会 404,且错误码误导为"文件夹不存在"**(用户视图 = "实例启动失败/找不到工作区")。 - 修法(3 行,`resolve()` 内):归属解析出来但 `agentFor` 解析不出时 **⛔ 不许静默用默认 agent**,改为**失败关闭**并回一个可区分的码(新增 `host_unreachable`,或直接复用现成码)。 - 验收:新增回归用例 —— 让 `agentFor` 返回 `undefined`,断言**不会**打到 `agentUrl`;`npm test` + 先红后绿。 - ⚠️ 它属**文件面**,动工前按 R7 出受影响清单。 ### 11.7 回滚(R5 专属,均秒级) | 回滚哪一层 | 动作 | |---|---| | Manager 拨号通道 | 删 manager drop-in 里 `DSHS_RELAY_DIAL_*` 5 行 → `daemon-reload` → `restart dshs`(`addressOf` 自动回退到 R3 的 `/status` 快照路径) | | relay 侧白名单 | **不删也行**(无拨号方接入 = 行为同 R3);彻底回退则删 `dshs-relay.service.d/dialers.conf` → `daemon-reload` → `restart dshs-relay` | | 代码级回退 | `cp -a` 上面三个 `.bak-r5*` 备份;代码则 revert `src/net/relay/*`(`wire/duplex/dialer` 新增项 + `server/client` 改动)→ `npm run build` → scp `lib/` → `restart dshs` | **残留**:`_tmp_r5/`(本机临时脚本,已清);R5 新增的 64 个**仅回环**口(有意保留,池大小可配)。 --- ## 12. ✅ 缺陷 A1 / A2 修复(2026-09-16 23:2x–23:3x · **已执行完毕**) > 起因:§11.6 只把这两条**记下来没动手**(R7"先报告后动手")。本轮用户授权「按建议执行」⇒ 修完。 > **A1 = §11.4 那条假 404 的真因**;**A2 = `state.py` 恒报「锁空闲」**。 ### 12.1 A1 根因(比 §11.4 更准,**勘误**) §11.4 写的是"地址解析不出时静默回退本地 agent",方向对但**没说清是哪一个"解析不出"**。本轮定死: - `server.ts:262` 的 `hostDirectory` 是**惰性 Map** —— 唯一的写入者是 `hostsProvider()`,而此前**只有** `RemoteSpawner.ensureHosts()`(TTL 30 s,`remote-spawner.ts:140`)会调它 ⇒ **文件面的路由表正确性,隐式依赖"spawner 恰好先刷过一次"**。 - 启动时该 Map **只有 `config.clusterHostId`(本机)这一项**(`server.ts:263`)。 - ⇒ Manager 重启后若用户**先碰文件面**("我的文件" / launch 的 folder 检查), `hostIdForFile` 正常解析出 `w-106`,但 `agentFor('w-106')` = `hostDirectory.get('w-106')` = `undefined` ⇒ 旧 `target()` **静默回退到 `DSHS_CLUSTER_AGENT_URL`(= `http://127.0.0.1:19100`,w-47 自己的 agent)** ⇒ 那台上没有这个用户 ⇒ **假 `404 {"error":"not_found"}`**,与"文件夹不存在"**完全同形**,平台**零日志**。 - **判别器 = relay 有没有 `DIAL`**:没有 = 请求根本没出这台机。 ### 12.2 A1 修法(**两条一起做才算解决**) | 层 | 做法 | 为什么缺一不可 | |---|---|---| | **治本** | `ClusterFsRouting` 新增可选 `ensureHost(hostId)`;`RemoteUserFs.target()` **未命中时先补齐目录再判** | 只"失败关闭"= 把"假 404"换成"真 503",**用户还是用不了**(属降级,不算解决) | | **治安全** | 补齐后仍取不到 ⇒ `UserFsError('host_unresolved')` → **503**,且**一个字节都不发往默认机** | 消掉"跑到错机读写":读 = 伪装成"文件丢了",写 = **静默写坏**(更糟) | | **不退化** | "确实还没有归属"(`hostIdFor` 正常返回 `undefined`)与单机形态**仍是默认机** | 这是设计内契约(`hostIdForFile` 的粘性首触达),别一刀切成 503 | | **可观测** | 补齐时打 `[cluster] host 目录未命中 <hostId> ⇒ 按需补齐(重启窗口期常见)` | 没有它,"这次为什么没 404"只能靠推断 | 改动文件(8 个源 + 12 个产物):`src/fs/user-fs.ts`(新增码 `host_unresolved: 503`)、`src/fs/remote-user-fs.ts`(`target()` 重写 + `ensureHost`)、 `src/fs/provider.ts`(透传)、`src/web/server.ts`(`ensureHostDirectory`:只在**未命中**时查库 + 并发去重 + 5 s 冷却)、 新增 `test/remote-user-fs.test.mjs`(U1–U8,已登记进 `npm test` / `npm run verify`)。 ### 12.3 A1 证据(**先红后绿 + 端到端直接证据**) **① 先红后绿**(把 `target()` 逐字换回 git HEAD 的旧实现,同输入对比): ``` 【红】旧实现下 fetch 实际打到: ["http://127.0.0.1:19100/fs/list"] ⇒ 打到了默认机 ⇒ 缺陷成立 【绿】新实现:抛出 code=host_unresolved status=503,fetch 调用数=0 ``` (⚠️ 运行时替换而非 git stash:旧 `RemoteUserFsOptions` 没有 `ensureHost` 字段而 `provider.ts` 已在传 ⇒ 回退单文件会 **TS2353 编译不过**,取不到"红"。) **② 端到端:冷启动窗口**(`_tmp_fix/accept_a1.sh`,**逐字复刻 §11.4 那条红的时序** —— 它当时跑出 `launch#1/#2/#3` **全 404**、relay **零 DIAL**) ``` ⓿ 归属:4092b965-… → host_id = w-106 默认 agent = http://127.0.0.1:19100 ① systemctl restart dshs(3080 用 4×0.5s 恢复) 此刻 relay 累计 DIAL = 1 ② launch#1 http=200 body={"instance":{"port":21000,"status":"running",…}} ← 旧版这一发 = 404 ③ launch#2 http=500 "instance … is held by w-106 until …"(租约互斥 ⇒ 第一次真的占了 w-106 的租约) ④ relay:DIAL manager -> w-106:19000 ok ×3(旧版 = 0) ⑤ 落点 127.0.0.1:25000 -> w-106:19000 ⑥ dshs 日志 'not_found' 条数 = 0(旧版 = 3) ``` **③ 直接证据(证明真走进了"未命中 ⇒ 补齐"这一步,而不是侥幸)** —— 补上日志行后复跑: ``` 23:31:55 [cluster] host 目录未命中 w-106 ⇒ 按需补齐(重启窗口期常见) 23:31:55 launch http=200 23:31:55 [relay] DIAL manager -> w-106:19000 ok not_found 条数 = 0 ``` ⇒ 重启**同一秒内**:目录未命中 → 补齐 → DIAL 出本机 → 200。**窗口真实存在,且现在被透明恢复**。 **④ 回归与部署**:`node --test`(8 个文件)= **82 tests / 81 pass / 0 fail / 1 skipped**(skip 为既有); `check-layering` = **无新增违规**(基线 5 条,扫描 71 个 .ts);47 产物对账 **12/12 hash 全同**; 备份 `/opt/dshs/lib-bak-a1-20260916-232914`(滚动两次部署,后一次覆盖前者同名文件,均在)。 ### 12.4 A2 修法(`state.py`) `state.py:39` 把 `.exec-lock` 当**文件**读,而 `--claim-exec` 建的是**目录**(`OWNER` 在里,3 行:OWNER / `开始:` / `在做:`) ⇒ 读空 ⇒ **恒报「空闲」** ⇒ 每个新会话读到的**第一个信号是错的**。改为:目录读 `OWNER`(兼容历史遗留的普通文件)。 **复验**(本会话正持锁时运行): ``` [锁] 🔴 被占用 —— 缺陷修复-A1A2-20260916 / 开始:09-16 23:24 / 在做:(未声明单号) ⇒ ⛔ 停手,别碰任何文件(R9) ``` ### 12.5 分层回滚(A1 / A2) | 层 | 动作 | 影响 | |---|---|---| | 代码 | `cp -a /opt/dshs/lib-bak-a1-20260916-232914/lib/. /opt/dshs/lib/` → `systemctl restart dshs` | 回到 A1 修前(假 404 复现) | | 单测 | 用 `git` 取回旧 `src/fs/remote-user-fs.ts` + 删 `test/remote-user-fs.test.mjs` + 撤 `package.json` 两处登记 | 无运行时影响 | | A2 | 还原 `state.py` 第 1 节为"按文件读" | 回到恒报空闲(**不建议**) | **残留**:`_tmp_fix/`(本机临时脚本,收尾时移入 `_中间产物_待清理/`)。 ### 12.6 勘误 R4/R5 的 `not_found` 单因结论 `§10.5` 把 `not_found` 归为"**文件夹确实不存在**"。本轮证明它**至少有两个来源**: ① 文件夹不存在(原判) ② **地址未解析 ⇒ 静默打到错机**(本轮修掉的那条)。 ⇒ **看到一个 `404 not_found` 时,先看 relay 有没有 `DIAL` 再下结论。**(已同步进 §11.4。) ### 12.7 ⛔ A1 遗留的一个**相邻**缺陷(按 R7 只记不动) `src/supervisor/leased-spawner.ts:149` 是**同族写法**: `this.options.agentFor?.(inst.hostId) ?? { agentUrl: this.options.agentUrl, token: this.options.agentToken }` ⇒ 实例面(launch / status / stop)**在 `agentFor` 未命中时同样静默回退默认机**。 本轮**只修了文件面**(因为那是用户可见的 404),**没碰**这一处 —— 需不需要按同一口径收, **是下一棒要决定的事**(判据:它是否也会产生"看起来像别的错"的假象)。