Files
dsh_shenxian/dsh-server-docs/04-调整方案/118-会合中继拆分-取证与改造方案.md
T
admin 04776af4b1 docs: 工作区根项目文档批量入仓(04-调整方案 113–128 / 交接单 T09–T21 / ops / archive)
起因:用户 2026-09-17 明确「所有文档都要同步,都放在开发仓库 docs 对应文件夹下」。
判据:工作区根 *.md 中在仓库(git ls-files --quotepath=false)搜不到的那些。

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

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

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

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

24 KiB
Raw Blame History

会合 / 中继从 Manager 拆分 — 只读取证 + 可执行改造方案(2026-09-16)

🟢 状态:已完成使命 · 仅存档(2026-09-17 16:4x 由序 ⑯ 规划棒复核后标注) 复核结论 = 判「不做」:本方案 §4 的四个耦合点(C1–C4)与两步改造(S3 / S4)已逐条被后续序次覆盖,未覆盖部分 = 无。 C1 → 序② P0-2(DSHS_RENDEZVOUS_URL 优先、DSHS_TUNNEL_TARGET 降兜底,src/config.ts:485-490)|C2 → R5(Manager 只拨出、落点搬到本机回环池 127.0.0.1:25000+,src/config.ts:498-500)|C3 → S2/P2(dsh_hosts.via + RendezvousRegistry)|C4 → 本方案 §8.1 实测前提不成立|S3 → 本方案 §9.1 自我证伪后改「实例端口区间隔离」|S4 → R2(dshs-relay 独立单元)+ 序⑥ S8(106 升格第二中继)+ 序⑦(多实例切流实测)。 ⛔ 勿再按 §4 的 S0–S4 开工。复核证据 = 工作区根 交接单_在册收尾_20260917.md §0.1(逐条对照表)。 ⚠️ 正文不改(历史档案属性)—— 上表即勘误入口。

来源:接续入口_覆盖网络线_20260916.md §2 第 2 条(第一优先 · 真活)。 本步只读:⛔ 未改代码、未重启、未动服务器。全部结论来自 D:\github\dsh_shenxian 工作树(HEAD 4e3a1a4)的源码静态阅读。 为什么是前置:中继绑在 Manager 上 ⇒ 多区域 / 多中心 / 骨干层全部做不了。这一刀不切,后面九大瓶颈方案里的中继容量、45% 中继规划都无从落地。


0. 结论先行

判定 内容
现状 「会合 + 中继」不是一个组件,而是 Manager 主机上 sshd 的副作用 —— 会合点 = 47.77.182.89:32022,中继落点 = Manager 的 127.0.0.1。
真问题 功能能跑(档案 103 已判"现有架构给出 80% 形状")。⚠️ 卡点是"耦合写死在 4 处",不是"协议不对"。
因此第一步不是换协议 ⛔ 不要一上来换 WireGuard / TURN。先把会合地址与中继落点抽成配置与接口,让 SSH 隧道退化成"第一个可替换实现"。每一步独立可验、独立可回滚。
分层口径(已定,勿破) 会合 = 位置视图(可多实例、可无状态);中继 = 数据面(可多实例);归属 / 租约 / 骨干资格 = 只能控制面写(本方案不碰)。

1. 取证:现状到底长什么样

1.1 链路(跨机形态,实测形态)

Worker 106                                     Manager 47
┌────────────────────────────┐                ┌──────────────────────────────┐
│ dshs-worker.service        │                │ dshs.service (deployMode=    │
│  WorkerAgent  :19000 (lo)  │                │              cluster)        │
│   └─ SshTunnel ──── ssh -R ─┼───────────────▶│  sshd :32022                 │
│      每实例 :PORT (lo)      │  ControlMaster │   └─ 反转到 127.0.0.1:PORT   │
└────────────────────────────┘                │  RemoteSpawner → agentUrl    │
                                              │   = http://127.0.0.1:19000   │
                    ┌─────────────────────────▶ proxy.ts → out.host = 127.0.0.1:PORT
                    │                         │  PostgreSQL :15432 (静态转发)│
                    │                         └──────────────────────────────┘
              用户浏览器 → alotbuy.com → 47

1.2 关键代码位(行号 = 本次阅读的 HEAD)

角色 文件:行 事实
隧道实现 src/worker/tunnel.ts:96-120 ssh -M -N -f -S <ctl> -i <key> -R <port>:127.0.0.1:<port> —— 两端同端口号
隧道加/减 src/worker/tunnel.ts:147 / :163 ssh -O forward / -O cancel,靠 ControlMaster 复用同一条长连接
隧道自愈 src/worker/agent.ts:183-219 healTunnel() 20 s 定时器 + 心跳里顺带 -O check
Worker 侧配置 scripts/switch-C-worker.sh:19-20 [email protected]:32022、DSHS_TUNNEL_IDENTITY=/root/.ssh/tunnel_ed25519
隧道开关 src/worker/agent.ts:154 tunnelTarget === '' ⇒ 完全不建隧道(同机形态零影响)
Manager 侧配置 src/config.ts:350-354 DSHS_CLUSTER_HOST_ID / _AGENT_URL / _AGENT_TOKEN / _INSTANCE_HOST
装配 src/web/server.ts:256 hostsProvider 把 dsh_hosts.endpoint 直接当 agentUrl,instanceHost 取全局 clusterInstanceHost
访问面 src/supervisor/proxy.ts:109 out.host = 127.0.0.1:<port> —— 代理写死 loopback
数据面契约 src/supervisor/remote-spawner.ts:24-34 ClusterHost{hostId, agentUrl, token, instanceHost} —— 没有"经谁可达"这个字段
注册 src/web/routes/admin.ts:184-203 POST /api/admin/hosts(join 脚本调用,幂等)
心跳 src/worker/agent.ts:255-262 /healthz 回 {ok, hostId, instances, tunnel:{ready,ports}}
选机 src/web/server.ts:300-318 粘性优先 → 容量准入(capacityMb<=0 不限 / -1 禁用)

2. 四个耦合点(这是本方案的靶子)

# 耦合点 具体表现 不拆的后果
C1 会合地址硬编码在 Worker 的 env [email protected]:32022 写死在 switch-C-worker.sh + /etc/dshs-worker.env 会合点换机 / 加第二台 ⇒ 每台 Worker 都要改 env 重启;会合点不能独立部署
C2 中继落点命名空间 = Manager 的 loopback(且两端同端口号) tunnel.ts 用 -R <port>:127.0.0.1:<port>;proxy.ts:109 写死 127.0.0.1 ① 端口空间与 Manager 全局共享(冲突面 = 所有 Worker 实例端口并集)② 第三台机器无法直达 Worker(只有 Manager loopback 有那个端口)③ 中继不可多实例(多实例无从分配端口)
C3 可达性登记把"经谁中转"磨掉了 dsh_hosts.endpoint 存的是 127.0.0.1:19000 —— 语义上不是 worker 的地址,是 Manager 本地一个转发口;hostsProvider 原样当 agentUrl 换会合/中继组件时,表里无法表达「via(经哪个中继)+ 真实可达地址」⇒ 必须改表,越晚改代价越大
C4 控制面 PostgreSQL 也走同一条隧道(静态转发) DSHS_TUNNEL_STATIC_PORTS(src/worker/agent.ts:157),设计上含控制面 PG 会合组件一换,控制面库的连接路径一起断 ⇒ 回滚面比看上去大

3. 目标形状(分层,且不新建权威状态)

组件 职责 可以多实例? 权威状态
会合 (rendezvous) 收 Worker 注册、回"该拨谁"、下发中继分配;不承载数据面流量 ✅ 无状态可复制 ❌ 只有位置视图
中继 (relay) 数据面:Worker 拨它 → Manager / 其他节点经它到 Worker ✅ ❌
控制面 (Manager) 归属 / 租约 / 骨干资格 / 容量准入 ❌ 单点 ✅ 唯一写入者

硬约束:会合与中继不得写入 dsh_instances.host_id / epoch / 骨干资格 —— 否则就是双写脑裂(与 集群化改造方案 §1.3 一致)。


4. 分步方案(S0–S4 必做 · S5 后续)

S0 — 抽接口(零行为变化)

  • 改哪些文件
    • 新增 src/net/rendezvous.ts:interface Rendezvous { id: string; dialTarget(): string; resolve(hostId): Promise<Reachability|undefined> }
    • 新增 src/net/reachability.ts:interface Reachability { hostId: string; via: string; address: string; scheme: 'http'|'https' }
    • 改 src/supervisor/remote-spawner.ts:24-34:ClusterHost 增 reachability?: Reachability,agentUrl 降级为派生 getter(保向后兼容)
  • 接口怎么切:RemoteSpawner / RemoteUserFs / proxy.ts 三处都只消费 Reachability,不再各自拼 http://127.0.0.1:<port>。
  • 怎么验:npm run build 通过 + node scripts/verify-cluster-cross.mjs 全绿(行为零变化是唯一验收标准)。
  • 怎么回滚:纯新增文件 + 可选字段,git revert <commit> 即可。

S1 — 会合地址出 env(打掉 C1)

  • 改哪些文件:src/worker/agent.ts:154-172、src/worker/tunnel.ts:26-65、src/config.ts:350-354
  • 接口怎么切:新增 DSHS_RENDEZVOUS_URL(ssh://[email protected]:32022 或未来的 https://...);DSHS_TUNNEL_TARGET 保留为兜底(未设 DSHS_RENDEZVOUS_URL 时走它)⇒ 两台机器可分先后改。
  • 怎么验:改一台 Worker 的 env → systemctl restart dshs-worker → curl -H <token> http://127.0.0.1:19000/healthz 里 tunnel.ready === true;Manager 侧 verify-cluster-cross.mjs 通过。
  • 怎么回滚:删掉新 env 即在走旧路径(代码同时保留两条),不需回滚代码。

S2 — dsh_hosts 增 via 列(打掉 C3)

  • 改哪些文件:src/db/adapter.ts:115-121、src/db/pg.ts:602-646、src/db/repo.ts:601-625、src/db/sqlite.ts:343-366、src/web/server.ts:256-266、src/web/routes/admin.ts:184-203
  • 接口怎么切
    • 迁移:ALTER TABLE dsh_hosts ADD COLUMN via text NOT NULL DEFAULT 'manager-ssh'; ADD COLUMN address text;
    • 回填:把现 endpoint 里的 127.0.0.1:19000 拆成 via='manager-ssh' + address='127.0.0.1:19000'(endpoint 列保留不删)
    • hostsProvider:先读 via → 选对应 Rendezvous 实现 → 解析成 Reachability;via 未设时回退 endpoint 原语义。
  • 怎么验:脚本断言「迁移前 agentUrl」与「迁移后 resolve() 结果」逐条相等(这就是验收判据,不靠肉眼)。
  • 怎么回滚:列有默认值 ⇒ 旧代码读 endpoint 完全不受影响,列可留着不删。

S3 — 中继落点命名空间可配(打掉 C2 的一半)

  • 改哪些文件:src/worker/tunnel.ts:142-160、src/supervisor/proxy.ts:109
  • 接口怎么切:新增 DSHS_RELAY_LOCAL_NAMESPACE(默认 127.0.0.1)⇒ 允许每条转发落在独立回环地址(127.0.0.2、127.0.0.3…),由会合下发端口而非"两端同号"。
  • ⚠️ 同号是 endpointFor 免映射表的前提 ⇒ 改不同号必须同时给 ClusterHost 加 portMap。所以本步验收必须含两条端到端,不能只看健康检查。
  • 怎么验:① verify-cluster-cross.mjs 全绿 ② 跨机实例页能打开 ③ 跨机工作区文件能读能写(文件面与实例面同一份路由,见 server.ts:274-288 的注释)。
  • 怎么回滚:不设该 env 即回到"同号"原状。

S4 — 中继独立成单元 + 会合可换机(打掉 C2 的另一半 + C1 的尾巴)

  • 做什么:把"接收 Worker 反拨"这半边从 Manager 的 sshd 里搬出来 → 独立 systemd 单元 dshs-relay.service(第一版仍用 sshd,但独立端口 + 独立 authorized_keys + 独立系统账号)。Manager 通过 via 指过去。
  • 为什么这样切:不换协议、只换绑定关系 ⇒ 已跑通的隧道逻辑 0 改动,风险面最小;换来的是"中继可换机、可多实例、Manager 不再兼任会合"。
  • 怎么验(三条,缺一不可)
    1. 单中继实例下全链路通(实例面 + 文件面)
    2. 停掉中继 ⇒ 同机形态实例仍可用、跨机代理失败但不崩(验证"中继非必需依赖")
    3. 会合指向第二个中继实例,新 Worker 注册后能被 Manager 正确解析(验证多实例)
  • 怎么回滚:dsh_hosts.via 改回 manager-ssh + systemctl disable --now dshs-relay。

S5 — 443/TCP 兜底通道(后续,非本方案前置)

已定口径(异构设计:企业 / 校园网常封 UDP)。落地时挂到 relay 上做传输变体,不动会合接口。


5. 待现场复核(本轮只读,未在服务器执行任何命令)

# 待复核项 为什么重要 若为否的影响
1 106 上 DSHS_TUNNEL_STATIC_PORTS 是否真含控制面 PG 端口 决定 C4 是否成立、回滚面是否含 DB 仅影响回滚清单;S2/S3 不变
2 dsh_hosts 里 w-106 那条 endpoint 的实际值 决定 S2 回填脚本的取值 需先取真实值再写迁移

下令复核的命令(只读,未执行)

# 远程服务器 106
systemctl show dshs-worker -p Environment
# 远程服务器 47
sudo -u postgres psql -d dshs -c "SELECT id, endpoint, status, capacity_mb FROM dsh_hosts ORDER BY id;"

6. 风险与反模式

风险 处置
把会合做成"藏了权威状态的隐形控制面" 会合只读位置视图;归属写入路径一律仍走 db.claimInstance(epoch+1 fencing 不变)
一步到位换传输协议 ⛔ 反模式。S0–S4 全程不换协议,只换绑定与寻址
S3 只看健康检查就收工 ⛔ 端口映射一改,endpointFor 的隐含前提就变 ⇒ 必须跑通"实例页 + 文件读写"两条端到端
先改表再改代码(或反之) via 列有默认值 ⇒ 顺序必须是先加列 → 再改代码 → 最后回填,任一步中断都不崩

7. 交付边界(本步)

  • ✅ 产出:本方案(改哪些文件 / 接口怎么切 / 分几步 / 每步怎么验 / 怎么回滚)。
  • ⛔ 未做:未改任何代码、未重启任何服务、未动 47 / 106。
  • 下一步:按 S0 开工即可 —— 纯新增文件 + 类型扩展,零行为变化,是整条链里风险最低的入口。

8. 现场复核结论 + 方案勘误 + S0 执行记录(2026-09-16 13:1x 追加)

本节的复核命令全部只读(systemctl show / cat / ss / psql SELECT),未改服务器任何状态。 执行环境:本机 → [email protected](22)+ test106(~/.ssh/id_ed25519_test106)。

8.1 §5 两项待复核 —— 已复核

# 项 实测结论
1 106 是否真含控制面 PG 的静态转发 ❌ 不含。106 /etc/dshs-worker.env 没有 DSHS_TUNNEL_STATIC_PORTS;47 的 worker env 也没有(且 47 根本没设 DSHS_TUNNEL_TARGET ⇒ 同机形态完全不建隧道)⇒ staticPorts 只有 agent 自身端口 ⇒ C4 在本形态下不成立,回滚面不含 DB
2 dsh_hosts 里各条 endpoint 的实际值 w-106 = http://127.0.0.1:19000|w-47 = http://127.0.0.1:19100

47 端口面实测(ss -lntp):0.0.0.0:32022 sshd(会合点)|127.0.0.1:19000 sshd=106 的隧道落点|127.0.0.1:43937 sshd=106 上那个实例的端口,同号反转|127.0.0.1:19100 node=w-47 的 agent|127.0.0.1:15432 postgres。 ⇒ §2 的 C2「中继落点 = Manager loopback + 两端同号」实测成立;/healthz 亦证实 w-106 的 tunnel.ports = [19000, 43937]。

8.2 三处必须勘误(照字面做会出事故)

① 🔴 proxy.ts:109 不是中继连接目标 —— ⛔ S3 绝不能改它。 buildUpstreamHeaders() 里的 out.host = \127.0.0.1:${port}`是**发给上游 dsh 的 HTTPHost头**,源码注释已写明原因:dsh 的/api信任栅栏要求 Host 是 loopback(或--trusted-host授权项),否则**每条/api 调用都 403**。 真正的连接目标在 **proxy.ts:138-139** 的 { host: endpoint.host, port: endpoint.port }(来自 endpointFor)。 ⇒ S3 做「中继落点命名空间可配」时,**只动连接地址那一侧**;改动 proxy.ts:109会让实例(不只跨机)的/api` 全量 403。

② 🔴 两条 endpoint 字符串同形、语义不同 ⇒ S2 回填不能一刀切 via='manager-ssh'。

  • w-47 → 127.0.0.1:19100 是 node 自己监听的端口(同机直连)⇒ via = 'local'
  • w-106 → 127.0.0.1:19000 是 47 上 sshd 的隧道落点 ⇒ via = 'manager-ssh'

§4 S2 原文只写了后者(把 127.0.0.1:19000 拆成 manager-ssh)⇒ 按行回填别按字面。

③ ⚠️ 47 的 cluster 配置在 systemd drop-in,不在 /etc/dshs.env。 实际位置 = /etc/systemd/system/dshs.service.d/cluster.conf(DSHS_DEPLOY_MODE=cluster / DSHS_DB_URL / DSHS_CLUSTER_* 十条;/etc/dshs.env 里没有 cluster 段)。§5 的回滚命令(删 drop-in → daemon-reload → restart dshs)正确,无需修改,只是换文件位置要照此。

8.3 S0 执行记录(已落地本机 · 未部署)

文件 改动
src/net/reachability.ts 新增:Reachability + agentBaseUrl() + agentBaseUrlOf()(全仓唯一取址入口) + parseReachability()/toEndpoint()(互逆)+ VIA_LOCAL / VIA_MANAGER_SSH
src/net/rendezvous.ts 新增:Rendezvous 接口 + LocalRendezvous + ManagerSshRendezvous + RendezvousRegistry(S2 才接调用点,本轮不消费)
src/supervisor/remote-spawner.ts ClusterHost.agentUrl → 可选、新增 reachability?;构造期归一化移除(移到取址处、幂等);call() / touch() 改走 agentBaseUrlOf()
src/web/server.ts 两处 agentFor 改 agentBaseUrlOf(h) —— 类型收紧后必须一起改,方案 §4 S0 未列出
test/reachability.test.mjs 新增 8 条断言:把现网真实两行 endpoint 写死为判据 ⇒「解析前后 agent 基址逐字相等」不靠肉眼
package.json test / verify 挂上新测试
  • 验收实测:npm run build RC=0 | npm test 44 项 / 0 失败(新增 8 项全绿)。lib/ 产物里已无 host.agentUrl 直接拼接。
  • 为什么未部署:S0 零行为变化 ⇒ 部署没有任何可观察收益,却要 restart dshs 打断在线实例 ⇒ 与 S1 合并上线(一次重启换一个真实变化)。⛔ 这不是"漏了部署"。
  • 有意未收敛的两处(避免本轮动 5 个文件,留给 S2 与 via 列一起改): ① leased-spawner.ts 的 agentFor 契约仍是 {agentUrl, token}; ② remote-user-fs.ts:57/73 自己又做了一次 .replace(/\/$/,'')。 两处的取值都已来自经过 agentBaseUrlOf() 的 agentFor ⇒ 行为正确,只是还没换成 Reachability 形态。
  • 回滚:git checkout -- src/supervisor/remote-spawner.ts src/web/server.ts package.json + 删 src/net/ + test/reachability.test.mjs(纯新增 + 可选字段,无数据迁移)。
  • 下一步:S1(会合地址出 env,打掉 C1)—— 新增 DSHS_RENDEZVOUS_URL、DSHS_TUNNEL_TARGET 降为兜底,两台机器可分先后改。

9. 第二轮勘误(2026-09-16 15:2x · 本机 + 双机只读实测)

本轮复核了 §4 的可执行性,结论:S3 的机制不可用,须换做法;另外 S1 的部署面在本方案里写漏了 47。 操作层细节(命令 / 验收 / 回滚 / 清理)已落 交接单_覆盖网络落地执行_20260916.md v2 §0 + §3,本节只记设计层的判断。

9.1 🔴 S3 的 DSHS_RELAY_LOCAL_NAMESPACE(回环别名)实测无效 —— 机制换成「端口区间隔离」

实测(本机 → 106 → 47,测完已清理):自 106 发起 ssh -R 127.0.0.2:19999:127.0.0.1:19000 -o ExitOnForwardFailure=yes [email protected]:32022 ⇒ 47 上落点实际是 127.0.0.1:19999(另有 [::1]:19999),127.0.0.2 被静默改写;ssh 侧零报错(ExitOnForwardFailure 未触发、日志为空)。

根因:47 sshd -T ⇒ gatewayports no(OpenSSH 默认)⇒ sshd 把远端转发强制绑到回环,客户端指定的绑定地址被丢弃。

⇒ §4 S3 原设计(127.0.0.2、127.0.0.3… 独立回环别名)作废,改做 实例端口区间隔离:

  • 新增 DSHS_INSTANCE_PORT_BASE / DSHS_INSTANCE_PORT_SPAN,findFreePort() 改为在区间内挑端口;
  • 各 worker 配互不重叠的区间(w-47=42000+,w-106=43000+)⇒ 同一台 Manager 的 127.0.0.1 上永不撞号;
  • ✅ 副作用收益:不需要 portMap(不改"不同号")⇒ §4 S3 里"必须同时给 ClusterHost 加 portMap"一并作废;src/worker/agent.ts 的 instanceHost 也不用改。
  • ⛔ 若将来确需回环别名:只能开 GatewayPorts clientspecified,且只能开在新建的 relay sshd 上 —— 开在主 sshd 32022 等于让持隧道密钥者可把端口绑到 0.0.0.0,命中 R5(权限只准收窄)。

9.2 🔴 S3 的真实必要性上修为「在修一个真 bug」,不是"管道美化"

findFreePort()(src/supervisor/spawn.ts:53)只在 worker 自己那台机器 上随机取端口(调用点 src/supervisor/orchestrator.ts:595)⇒ 两台 worker 取到同号是常态; 而 tunnel.forward() 的返回值在 src/worker/agent.ts:303 与 :205 两处都被忽略 ⇒ 撞号时 -R 失败但静默; Manager 随后仍按 127.0.0.1:<port> 拨 ⇒ 打到另一个用户的实例。 w-47 的本地实例与 w-106 的隧道落点共享 47 的 127.0.0.1 端口空间 ⇒ 两台机器即可触发。

9.3 🔴 S1 的部署面:本方案与 v1 交接单都漏了 47

S0 的 src/web/server.ts、src/supervisor/remote-spawner.ts、src/net/* 跑在 Manager(47);S2 的 hostsProvider 也在那儿。 实测:47 /opt/dshs/lib/ 至今无 net/,config.js 指纹 ≠ 本机 ⇒ 47 是 S0 之前的版本;S1 只上了 106(Worker) 侧。 ⇒ 执行必须先做一步 P1:把 47 对齐到 640813e(零行为变化,风险最低),再上 S2。

9.4 ⚠️ S4 有方案缺口:中继一旦不在 Manager 主机上,"Manager 拨落点"就断

隧道落点在接收方那台机器的 127.0.0.1。中继换到别的机器 ⇒ 落点在那台机器上,Manager 拨不到。 ⇒ 需要额外一跳:① Manager 侧向中继建出向隧道(ssh -L,把中继回环落点搬到 Manager 自己的回环),或 ② 中继侧把落点暴露到非回环地址 + nft 收窄(暴露面变大)。 倾向 ①(中继 sshd 的 gatewayports no 可保持不动 = 零权限扩大,且支持异地中继)。 ⇒ 本轮 S4 只做同机 relay;⛔ 原 §4 S4 验收第 3 条"会合指向第二个中继实例"本轮不作判据(那要先定这一跳)。

9.5 ⚠️ S2 减负:不要加 address 列

endpoint 本身就是"要拨的地址",via 只补"经谁";再加 address = 同义双真相(回填后必然逐字相等,违反单一来源)。⇒ §4 S2 的 ADD COLUMN address text 作废,只加 via。

9.6 验收工具的现实约束(§4 未提,会直接卡住执行)

scripts/verify-cluster-cross.mjs:默认 MANAGER=http://127.0.0.1:13080(演练端口,现役是 3080)、AGENT_TOKEN=cross-machine-token(现役 dshs-worker-7f3a91c05e)、需 ADMIN_PW; 且它有生产副作用(幂等注册 w-106 并把容量改 4096 / 新建 crossuser* 用户 / 在 106 拉真实例 / 可选注册 w-106b 并迁移实例)⇒ 跑完必须清理;脚本头部注释"控制面 PG 在 106"已过时(PG 现在在 47)。

9.7 §8 三处勘误的复验结论

⚠️ 行号微差(本轮实测):§1.2 / §8 写的 proxy.ts:138-139 实测应为 136-137(host/port 两行);admin.ts 的注册路由实测在 :181(POST /api/admin/hosts),不是 :184。结论不变,只是行号偏了一两行。

  • ① proxy.ts:109 ✅ 再次确认仍是"发给上游 dsh 的 Host 头",⛔ 不可改;连接目标在 proxy.ts:136-137(host/port 取自 endpointFor)。
  • ② 两条 endpoint 同形不同义 ✅ 确认(w-47=local/w-106=manager-ssh),别一刀切回填。
  • ③ 47 cluster 配置在 drop-in ✅ 确认。