- 变更规模:新增 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/ 知识文件,按口径入库)
37 KiB
交接单 · 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 服务端)
- 只读前置:
command -v node(取绝对路径,systemd 不吃 PATH)、ss -lntp | grep 20080(应为空)。 - 部署产物:
D:/github/dsh_shenxian/lib/net/relay/⇒ 47 的/opt/dsh-relay/lib/net/relay/(⛔ 不碰/opt/dshs/**;在/opt/dsh-relay/下放{"type":"module"}的package.json) - 密钥:生成每 worker 一密钥(
openssl rand -hex 32),落/etc/dshs/relay-keys.json(chmod 600),格式:{"w-47":"<64hex>","w-106":"<64hex>"}—— ⛔ 不用共享 token。 - 单元
/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
daemon-reload+enable --now dshs-relay。
R2-b 入口(唯一动门户的一步)
- 只读前置:
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,别改它)。 - 备份:
cp <conf> <conf>.bak-<YYYYMMDD-HHMM>-pre-relay(照该目录既有.bak-*命名惯例)。 - 在该 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,宝塔 MCPmcp__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 后 scplib/。 - 全局执行锁:
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 里的"候选"写错了)
- 门户 443 块 =
alotbuy.com.conf(server_name alotbuy.com www.alotbuy.com *.alotbuy.com,443 块在第 85–123 行);dsh.alotbuy.com.conf是遗留 301 跳转域名 ⇒ ⛔ 不能往它里面加 location。 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)关键字计数:RendezvousRegistry2、ManagerSshRendezvous2、LocalRendezvous2、RelayRendezvous0、VIA_RELAY0。 ⇒ 本机工作区 ≡ 47 已上线的代码:那 15 处未提交改动已经在生产跑着、且已过 P1/P2 验收,只是没 commit。
因此 R3 的 Step 0(纯技术对账,⛔ 不需要 git 授权)
- 本机
npm run build(Node 22)。 - 对账:
lib/逐文件比 47/opt/dshs/lib/—— 净差异必须只有 relay 相关(lib/net/relay/**、lib/net/reachability.js,以及接入RelayRendezvous后的lib/web/server.js)。出现其它任何差异 ⇒ 停下报告,不要铺。 - 对账过了才铺 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。
实测证据(命令原文级)
- 构建:
node node_modules/typescript/bin/tsc -p tsconfig.json(Node 22.22.2)⇒ 退出码 0,零报错。 - 对账(本机
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 之后无漂移)
- 只在 47 = 0|只在本机 = 16 ⇒ 全是
- 铺设与重启:备份 47
/opt/dshs/lib/net→/opt/dshs-lib-net.bak-r3pre-20260916-2118;scplib/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')
- 接线(§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 安全取端口)。 - 47 drop-in 加
DSHS_RELAY_URL/DSHS_RELAY_STATUS_URL;relay 以--base 19000 --span 3000起(准入窗口覆盖 agent19000与 w-106 实例段21000+)。 - 106 常驻 + 密钥:
/opt/dshs-cluster/lib/net/relay/*、/etc/dshs/relay-keys.json(600,只含w-106一把)。 dsh_hosts.via='relay'(仅 w-106;w-47保持local—— 同机直连,无需中继)。- 证据:relay
/status出w-106 … ports=19000+动态回环口;经 Manager 打/api/admin/users/<w-106 用户>/dsh/status= 200,且 relaystreamsOpened同步增长 ⇒ Manager→agent 确实经 relay。
R4(实例面经 relay + 隧道下线)
- mux/relay:新增
PORT_ADD(0x0b)/PORT_DEL(0x0c)/PORT_ACK(0x0d);server 侧ensureEndpoint暴露ready、新增closeEndpoint/onPortChange(口径与HELLO同源);client 侧addPort/removePort/replayDynamicPorts,addPort成功必须同时写allow(否则"端口开着、流全被拒"的最难看半通;T16 抓的就是它)。 - 传输抽象:新增
WorkerTunnel接口 +RelayTunnel;worker agent 按 scheme 选传输,缺密钥抛错、不静默降级。 - 传输切换:106 设
DSHS_RENDEZVOUS_URL=wss://alotbuy.com/dshs-relay+DSHS_RELAY_SECRET;停用早期那个临时dshs-relay-client单元。 - 🔴 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 全绿。
- 定位手段(可复跑,别靠读代码猜)=判别器:在 47 上临时监听
- R4 收尾(净减暴露面)
- 47:
/root/.ssh/authorized_keys收回dshs-tunnel-106to47(3 → 2 条;备份authorized_keys.bak-r4-20260916);/etc/ssh/sshd_config注释掉Port 32022(sshd -tOK;备份/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/(移动而非删除 ⇒ 回滚不必重生成密钥)。
- 47:
- 终验(在隧道下线之后跑,最强证据)
⚠️ 口径坑(上一轮误判的真因):
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),没碰这一处 —— 需不需要按同一口径收,
是下一棒要决定的事(判据:它是否也会产生"看起来像别的错"的假象)。