# 会合 / 中继从 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 -i -R :127.0.0.1:` —— **两端同端口号** | | 隧道加/减 | `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` | `DSHS_TUNNEL_TARGET=root@47.77.182.89: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:` —— 代理**写死 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** | `DSHS_TUNNEL_TARGET=root@47.77.182.89:32022` 写死在 `switch-C-worker.sh` + `/etc/dshs-worker.env` | 会合点换机 / 加第二台 ⇒ **每台 Worker 都要改 env 重启**;会合点不能独立部署 | | **C2** | **中继落点命名空间 = Manager 的 loopback(且两端同端口号)** | `tunnel.ts` 用 `-R :127.0.0.1:`;`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 }` - 新增 `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:`。 - **怎么验**:`npm run build` 通过 + `node scripts/verify-cluster-cross.mjs` 全绿(**行为零变化**是唯一验收标准)。 - **怎么回滚**:纯新增文件 + 可选字段,`git revert ` 即可。 ### S1 — 会合地址出 env(打掉 **C1**) - **改哪些文件**:`src/worker/agent.ts:154-172`、`src/worker/tunnel.ts:26-65`、`src/config.ts:350-354` - **接口怎么切**:新增 `DSHS_RENDEZVOUS_URL`(`ssh://root@47.77.182.89:32022` 或未来的 `https://...`);`DSHS_TUNNEL_TARGET` **保留为兜底**(未设 `DSHS_RENDEZVOUS_URL` 时走它)⇒ 两台机器可分先后改。 - **怎么验**:改一台 Worker 的 env → `systemctl restart dshs-worker` → `curl -H 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 回填脚本的取值 | 需先取真实值再写迁移 | **下令复核的命令(只读,未执行)** ```bash # 远程服务器 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`),未改服务器任何状态。 > 执行环境:本机 → `root@47.77.182.89`(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 的 HTTP `Host` 头**,源码注释已写明原因: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 root@47.77.182.89: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:` 拨 ⇒ **打到另一个用户的实例**。 `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** ✅ 确认。