Files
dsh_ai1net_server/归档/文档整理/交接单-20260924-归档/交接单_relay落地R2-R4_20260916.md
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

37 KiB
Raw Blame History

交接单 · 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 步取到的绝对路径):
[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
  1. daemon-reload + enable --now dshs-relay。

R2-b 入口(唯一动门户的一步)

  1. 只读前置: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,别改它)。
  2. 备份:cp <conf> <conf>.bak-<YYYYMMDD-HHMM>-pre-relay(照该目录既有 .bak-* 命名惯例)。
  3. 在该 server 块内加一个 location 并 reload(nginx -t 必须先过):
    # ── 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 <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),没碰这一处 —— 需不需要按同一口径收, 是下一棒要决定的事(判据:它是否也会产生"看起来像别的错"的假象)。