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 代码面)。
This commit is contained in:
admin committed 2026-09-17 18:24:19 +08:00
1 parent bc0dd2c96d
commit 04776af4b1
36 files changed
+11840 -43

No files matched your search

@@ -0,0 +1,284 @@
# 会合 / 中继从 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 回填脚本的取值 | 需先取真实值再写迁移 |
**下令复核的命令(只读,未执行)**
```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`),未改服务器任何状态。
> 执行环境:本机 → `[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 的 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:<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** ✅ 确认。