Files
dsh_ai1net_server/归档/交接单-20260924-归档/交接单_覆盖网络落地执行_20260916.md
T

255 lines
23 KiB
Markdown
Raw Normal View History

# 交接单 · 覆盖网络落地执行(**v2** · 2026-09-16 15:2x 复核后重写)
> **给执行会话**:按本单开工,**不读规划会话的上下文**。
> **v2 改了什么**:新增 **§0 复核结论 + 6 条勘误**(其中 **A/B 为实测**,**改变了 S3 的做法**);更正部署面(v1 只写 106,**漏了 47**);步骤改为 **P1 对齐 47 → P2=S2 → P3=S3(改写) → P4=S4**。
> **方案来源** = 工作区根 `会合中继拆分_取证与改造方案_20260916.md` —— 必读 **§4 + §8 + §9**。
> **用户的验收要求(原话口径)**:改造后 **47 + 106 + 本机都能跑起来;现有功能与多用户机制正常**。
> 分层规则见代码仓 `docs/architecture.md`(新模块必须守;`npm run check:layering` 会在 `verify` 里拦你)。
---
## 0. 本轮复核结论(2026-09-16 15:2x · 全部只读实测)
### 0.1 已核实基线(部署前先照这个对一遍)
| 项 | 实测值 |
|---|---|
| 本机 | HEAD `640813e`,工作区干净;`lib/net/{reachability,rendezvous}.js` 已 build(09-16 14:34) |
| 47(Manager) | `dshs` / `dshs-worker` / `dshs-pg` **全 active**;Manager 跑 `/opt/dshs/lib/cli.js`(`EnvironmentFile=/etc/dshs.env` + drop-in `dshs.service.d/cluster.conf`) |
| **47 代码版本** | 🔴 **落后于 S0** —— `/opt/dshs/lib/` **无 `net/`**;`config.js` 指纹 `91fc3756` ≠ 本机 `1c3601f7`(lib mtime 09-16 00:39) |
| 106(Worker) | `dshs-worker` **active**;`/healthz` ⇒ `{"ok":true,"hostId":"w-106","instances":0,"tunnel":{"ready":true,"ports":[19000]}}` |
| 106 代码版本 | ✅ S1 三件已上:`config.js` `1c3601f7`、`worker/tunnel.js` `5f2649b4` == 本机;`lib/net/` 无(**无需**,那是 Manager 侧文件) |
| `dsh_hosts` | `w-106` → `http://127.0.0.1:19000`(cap 2048)|`w-47` → `http://127.0.0.1:19100`(cap 1024);**共 7 列,无 `via`** |
| 用户 / 实例 | `admin@w-47`、`dbg2mx897@w-106`、`guest@w-106`、`pocuimwkrr@w-106`;⚠️ **四条实例全 `stopped`** ⇒ 验收要**主动 launch** |
| 47 sshd | `gatewayports no`|`allowtcpforwarding yes`|`permitopen any`|OpenSSH **8.0p1**(106 = 9.3p2);`lo` = `127.0.0.1/8` |
| 47 端口面 | `0.0.0.0:32022` sshd(会合)|`127.0.0.1:19000` sshd(106 隧道落点)|`127.0.0.1:19100` node(w-47 agent)|`127.0.0.1:15432` postgres |
### 0.2 勘误(6 条 · A/B 为实测,🔴 照原方案字面做会白做或出事)
**A 🔴 `DSHS_RELAY_LOCAL_NAMESPACE`(回环别名)实测无效 ⇒ S3 换做法。**
实测:自 106 发起 `ssh -R 127.0.0.2:19999:127.0.0.1:19000 -o ExitOnForwardFailure=yes` ⇒ 47 上落点实际是 **`127.0.0.1:19999`**(另含 `[::1]:19999`),**`127.0.0.2` 被 sshd 静默改写**,ssh 侧**零报错**(`ExitOnForwardFailure` 未触发、日志为空;测试后已清理)。
根因:47 `sshd -T` ⇒ **`gatewayports no`**(默认)⇒ sshd 强制把远端转发绑到回环,**客户端指定的绑定地址被丢弃**。
⇒ **S3 改为「实例端口区间隔离」**(§3 P3):同样打掉根因,且**不改 sshd、不改协议、不需要 `portMap`**。
将来若确需回环别名,只能开 `GatewayPorts clientspecified` —— 且**只能开在新建的 relay sshd 上**,⛔ **绝不动主 sshd 32022**(那等于让持隧道密钥者可绑 `0.0.0.0`,命中 **R5**)。
**B 🔴 端口撞号是**真 bug**,不是"管道美化" —— 这才是 S3 的真实必要性。**
`findFreePort()`(`src/supervisor/spawn.ts:53`,唯一调用点 `src/supervisor/orchestrator.ts:595`)在 **worker 自己那台机器**上随机取端口 ⇒ 两台 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` 端口空间** ⇒ **只用两台机器就能触发**。
**C 🔴 部署面:v1 只写了 106,漏了 47。**
S0 的 `src/web/server.ts`、`src/supervisor/remote-spawner.ts`、`src/net/*` 跑在 **Manager(47)**;S2 的 `hostsProvider` 也在那儿。
⇒ **v1 说的"S1 已上线"只覆盖 Worker 侧;47 至今是 S0 之前的版本**。执行时必须**双侧对齐**(§3 P1)。
**D ⚠️ `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<ts>` 用户并审批**、**在 106 上拉真实例**,可选**注册 `w-106b` 并迁移实例** ⇒ **跑完必须清理**(§5.2)。
③ 脚本头部注释"控制面 PG 也在 106"**已过时** —— PG 现在在 47(`127.0.0.1:15432`)。
**E ⚠️ S4 有方案缺口:中继一旦不在 Manager 主机上,"Manager 拨落点"就断。**
隧道落点在**接收方那台机器的 `127.0.0.1`**。中继换到别的机器 ⇒ Manager 拨不到 ⇒ 需要**额外一跳**(Manager 侧向中继建出向隧道 `ssh -L`,或中继侧把落点暴露到非回环地址 + nft 收窄)。
⇒ 本轮 S4 **只做同机 relay**(47,独立单元 / 端口 / 密钥 / 账号);异地 relay 留待定这一跳。
⛔ **不要用方案 §4 S4 的"会合指向第二个中继实例"当通过判据** —— 那要先解决本缺口;本轮改判据(见 §3 P4)。
**F ⚠️ S2 减负:不要加 `address` 列。**
`endpoint` 本身就是"要拨的地址",再加 `address` = **同义双真相**(回填后必然逐字相等)。⇒ **只加 `via`**(`NOT NULL DEFAULT 'manager-ssh'`),回填用一条显式 `UPDATE`。
> 方案 §8 的 ①②③ **仍然有效**,其中 ① 本轮**再次确认**:`proxy.ts:109` 是**发给上游 dsh 的 `Host` 头**(dsh 的 `/api` 信任栅栏要 loopback),⛔ **绝不能改**;真正的连接目标在 **`proxy.ts:136-137`**(`host/port` 取自 `endpointFor`)。
---
## 1. 只读前置(必做,全部只读)
1. 抢锁:`bash dsh-server-docs/scripts/handoff-guard.sh --claim-exec "<会话名>"`
2. 读方案 **§4 + §8 + §9**;读 `docs/architecture.md §5`(现存 5 条违规基线,**别新增**)
3. 照 **§0.1 表**逐项复核基线(本机 HEAD|47 的 `/etc/dshs.env` + `dshs.service.d/cluster.conf`|106 的 `/etc/dshs-worker.env`|`dsh_hosts` 现值|47 端口面)
4. 远程入口:`ssh [email protected]`(22)|`ssh test106`(**必须 `-i ~/.ssh/id_ed25519_test106`**)
---
## 2. 范围
**做**:P1 → P2 → P3(P4 视 P1–P3 结果决定是否本轮)
**不做**:⛔ 不换传输协议(WireGuard / TURN 属远期)|⛔ 不动 `proxy.ts:109`|⛔ 不动权威状态(归属 / 租约 / 骨干资格**仍只在 Manager 写**)|⛔ 不做 `DSHS_RELAY_LOCAL_NAMESPACE`(勘误 A)|⛔ 不加 `portMap`(不再改"不同号")
---
## 3. 步骤(每步独立可验、独立可回滚)
### P1 · 对齐 47 到 HEAD `640813e`(把 S0+S1 真正补上 Manager 侧)
**为什么先做这一步**:47 现在落后一代,直接上 S2 就是"跳版部署",出问题无从二分。这一步本身**零行为变化**,风险最低。
1. **本机门禁**(Node **22**,24 会因 better-sqlite3 ABI 全红):
`npm run build` → `npm test`(须 **44 项 / 0 失败**)→ `npm run verify` → `npm run check:layering`(现存违规 **5 条**,**不得新增**)
2. **备份 47**(回滚点必须存在):
`ssh [email protected] 'tar czf /opt/dsh/backups/lib-pre-S0-$(date +%Y%m%d-%H%M).tgz -C /opt/dshs lib'`
3. **只传这几件**(= `git show --name-only 640813e` 的 `src/` 清单 → `lib/` 产物;同名 `.d.ts` 一起传;`lib/net/` 需先 `mkdir -p`):
```
lib/config.js lib/net/reachability.js lib/net/rendezvous.js
lib/supervisor/remote-spawner.js lib/web/server.js
lib/worker/agent.js lib/worker/tunnel.js
```
⛔ **别整包覆盖 `lib/`** —— 会把别人直接改在服务器上的内容回退。
4. **逐文件 md5 对账**(47 上算完与本机比,必须逐条相等;`.d.ts` 也核)
5. `systemctl restart dshs`(**会短暂中断门户**;本项目为开发环境服务器 ⇒ 直接做,动手前一句话说明在动什么)
- **验收**:① 47 三单元 active ② 门户 `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:3080/` = 200 ③ 106 `/healthz` `tunnel.ready=true` ④ **`admin`(w-47)与 `guest`(w-106)各自的实例页都能打开 + 工作区文件能读能写** ⑤ `ls /opt/dshs/lib/net/` 有两个文件且 md5 == 本机
- **回滚**:`systemctl stop dshs` → 用步骤 2 的 tgz 还原 `lib` → `systemctl start dshs`
### P2 · S2:`dsh_hosts` 增 `via` 列(打掉 C3)
- **顺序(不能颠倒)**:**先加列(带默认值)→ 再改代码 → 最后回填** —— 任一步中断都不崩
- **加列**:`src/db/schema.ts` 的 `MIGRATIONS` 追加 **v8**,**双方言都要写**:
- sqlite:`ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';`
- pg:同款 SQL(语法一致)
- **改代码(6 处)**:
| 文件 | 改什么 |
|---|---|
| `src/db/pg.ts:56`、`src/db/repo.ts:598` | `HOST_COLS` 末尾加 `, via` |
| `src/db/pg.ts:605-646`、`src/db/repo.ts:603-625` | upsert 带上 `via` |
| `src/db/types.ts` | `DshHost` / `UpsertDshHostInput` 加 `via?: string`(缺省语义 = `manager-ssh`) |
| `src/web/routes/admin.ts:181-203` | 注册口接受 `via`,缺省 `manager-ssh` |
| `src/web/server.ts:256-266` | `hostsProvider`:**读 `via` → `RendezvousRegistry.get(via)` → `resolve(id)` → 塞 `ClusterHost.reachability`**;`via` 未知/未设 ⇒ 回退 `parseReachability(id, endpoint)` |
| `src/net/rendezvous.ts` | `LocalRendezvous` / `ManagerSshRendezvous` 的 `addressOf` 由调用方从 `db.listDshHosts()` 建 map 传入(`resolve` 是 async,`hostsProvider` 本来就是 async ⇒ 无需改签名) |
- **回填(一条显式 SQL,幂等)**:
`UPDATE dsh_hosts SET via='local' WHERE id='w-47';` —— 判据:**Manager 与 worker 同机 ⇒ 直连 ⇒ `local`**;`w-106` 保持默认 `manager-ssh`(47 上 sshd 的隧道落点)。⛔ **别一刀切**(方案 §8 勘误 ②)。
- **部署**:**只改 47**(S2 全是 Manager 侧代码,106 本轮无需改):传 `lib/{config,net/*,db/*,web/*}` 对账 → `systemctl restart dshs`
- **验收**:
① 单测扩展 `test/reachability.test.mjs` ⇒ 断言「迁移前 `agentUrl`」== 「迁移后 `resolve()` 结果」**逐条相等**,两条真实行**写死**(`w-47`→`local`、`w-106`→`manager-ssh`)
② 真机 `/api/admin/hosts` 返回带 `via`,且**不含 `agentToken`**
③ P1 的四条业务验收重跑一遍(多用户不受影响)
- **回滚**:列有默认值 ⇒ 旧代码读 `endpoint` 完全不受影响,**列可留着不删**
### P3 · S3(**改写**):实例端口区间隔离
- **目标**:消除勘误 B 那条真 bug —— 跨 worker 同号 ⇒ 47 的 `127.0.0.1` 端口空间撞号 ⇒ 静默不转发 ⇒ 拨到**别人的实例**
- **改哪些文件**:
| 文件 | 改什么 |
|---|---|
| `src/supervisor/spawn.ts:53` | `findFreePort()` → 增可选取址(区间内挑端口;区间为空 ⇒ 保持旧 `listen(0)` 行为) |
| `src/supervisor/orchestrator.ts:595` | 取端口改走区间(读 config) |
| `src/config.ts` | 新增 `DSHS_INSTANCE_PORT_BASE`(默认 `0` = 旧行为)/ `DSHS_INSTANCE_PORT_SPAN`(默认 `1000`) |
- ⚠️ **`src/worker/agent.ts:352` 不用改**(`instanceHost` 仍 `127.0.0.1`)—— 这就是本方案相对原设计的简化点;也**因此不需要 `portMap`**
- **env 分配(必须互不重叠)**:`w-47` → base **`42000`**|`w-106` → base **`43000`**|span 各 `1000`
- **部署**:47 —— `lib/{config,supervisor/*,worker/agent.js}` + drop-in 加两行 env → `systemctl restart dshs dshs-worker`;106 —— `lib/{config,supervisor/*}` + `/etc/dshs-worker.env` 加两行 env → `systemctl restart dshs-worker`
- **验收(三条端到端,缺一不可)**:
① 跨机实例页能打开 ② 跨机工作区文件能读能写 ③ **同一时刻 47 与 106 各起一个实例** ⇒ `ss -lntp` 上 47 的 `127.0.0.1`:`w-47` 实例端口 ∈ `42000+`、`w-106` 实例端口 ∈ `43000+`,且**无重叠**
- **回滚**:删两个 env ⇒ 回 `listen(0)` 随机(**零代码回滚**)
- **不做**:`DSHS_RELAY_LOCAL_NAMESPACE`(勘误 A)|`portMap`(不再改"不同号")
### P4 · S4(本轮收尾,可延后):中继独立成单元
- **做什么**:47 上新建 **`dshs-relay.service`** = **独立 sshd 实例**(独立端口如 `32023` / 独立 host key / 独立 `authorized_keys` / 独立系统账号 `dshsrelay`;**`gatewayports no` 保持不动**)
- **worker 侧只改一行 env**:`DSHS_RENDEZVOUS_URL=ssh://root@47.77.182.89:32023`
- **`via` 补一个实现**:新增 `RelayRendezvous`(id 如 `relay:47-32023`),`hostsProvider` 经注册表选到它;`w-106` 的 `via` 由 `manager-ssh` 改指它
- **验收(三条,**已按勘误 E 改判据**)**:
① 单中继下全链路通(实例面 + 文件面)
② **停掉中继** ⇒ 同机形态(`w-47`)实例仍可用、跨机代理失败但**不崩**
③ ⛔ 不判"第二个中继实例";改判:**把会合地址换回 `32022` 后,106 能重新注册并被正确解析**(本轮能验的"会合可换"就到这里)
- **回滚**:`DSHS_RENDEZVOUS_URL` 指回 `32022`(或删掉它,走 `DSHS_TUNNEL_TARGET` 兜底)+ `systemctl disable --now dshs-relay`
---
## 4. 本机(**客户端类型**节点,不是 worker)
要验的是可行性评估**缺口 1**(全案**唯一无证据**的技术点):
1. 本机起实例(**`soft` 模式** —— Windows 无 bwrap/systemd)⇒ 实例页 200
2. 实例内跑一次 bash 工具 ⇒ 验 dsh 在 Windows 的**沙箱后端行为**
3. 本机 ↔ 47 / 106 的**真实网络画像**(NAT 类型 / 打洞可行性 / RTT + **jitter**)⇒ 直接填"最该先测三项"里的两项
⚠️ ⛔ **不得把本机接进现有生产链路**(要独立形态、独立开关);本机是**唯一开发机** ⇒ 用最小配额。
---
## 5. 全局验收(缺一 = 没做完)
### 5.1 通过项
1. **多用户机制**:`admin`(锚 `w-47`)、`guest`(锚 `w-106`)**各自实例页 + 工作区文件都正常**(用户硬要求)—— ⚠️ 当前四条实例**全 `stopped`**,要主动 launch
2. `verify-cluster-cross.mjs` 全绿 —— **必须按勘误 D 的参数跑**:
`MANAGER=http://127.0.0.1:3080 AGENT_TOKEN=dshs-worker-7f3a91c05e ADMIN_PW=<真实管理员口令> node scripts/verify-cluster-cross.mjs`(`AGENT2` 留空 ⇒ 跳过第二台 worker 段)
3. `npm test` + `npm run verify` 全绿,且 `npm run check:layering` **无新增违规**
4. 47:`dshs` / `dshs-worker` / `dshs-pg` active;106:`dshs-worker` active
5. 每步的**回滚点**写清并**实测过**(至少回滚命令能跑)
### 5.2 跑完必须清理(勘误 D 的副作用)
- 删掉 `crossuser*` 用户;把 `dsh_hosts` 里 `w-106` 的 `capacity_mb` 改回 **2048**
- 若跑过 `AGENT2` 段:删掉 `w-106b` 登记,并把被迁移的实例改回 `w-106`
- 自建的测试实例:`stop` 掉,别留在生产
## 6. 回报格式
**判定 → 改了什么(文件 + 部署到哪台哪个路径 + md5 对账结果)→ 验收实测输出 → 未做 / 风险 → 锁状态**
(截图或命令原文均可;⛔ 不许写"应该没问题")
---
## 7. 开工前已知的坑(省你踩一遍)
| 坑 | 事实 |
|---|---|
| 106 连不上 | 必须 `-i ~/.ssh/id_ed25519_test106`;其它 key 一律 `Permission denied` |
| 47 的 cluster 配置 | 在 **drop-in** `dshs.service.d/cluster.conf`,**不在** `/etc/dshs.env` |
| 47 的 `/opt/dshs` 是 git 仓库 | ⚠️ **但不是部署来源** —— 部署 = 本机 build 后 scp `lib/` 文件;47 上 `git log` 只有初始提交 + 一堆 `M` ⇒ ⛔ 别在 47 上 `git pull`。`git -C /opt/dshs` 会报 `dubious ownership`,要加 `-c safe.directory=/opt/dshs` |
| 47 的 PG 查询 | **只能 TCP**:`PGPASSWORD=dshs_cluster_2026 psql -h 127.0.0.1 -p 15432 -U dshs -d dshs`;`sudo -u postgres psql` **不通**(unix socket 未启用) |
| `C4` 其实不成立 | 106/47 的 worker env **都没有** `DSHS_TUNNEL_STATIC_PORTS` ⇒ 隧道**不含控制面 PG** ⇒ 回滚面比方案预估小 |
| 本机 git | `refs/remotes/**` 写不进去 ⇒ `git fetch` **静默失败** ⇒ 分叉判定必须 `git ls-remote` 取裸 sha |
| 本机 bash | PATH 被 shim 重置 ⇒ 先 `export PATH=<PortableGit>/usr/bin:<PortableGit>/bin:/c/Windows/System32:/c/Windows:$PATH`;⛔ 进程替换 `diff <(a) <(b)` **不可用** |
| 本机跑测试 | 必须 **Node 22**(Node 24 会因 better-sqlite3 ABI 全红) |
---
# 8. 执行记录(2026-09-16 15:5x–16:2x · **P1/P2/P3 已完成并验收**)
> 执行会话:`覆盖网络-落地执行`。**P4 未做** —— 理由见 §8.4(命中 **R5**,需授权)。
## 8.1 ✅ P1 · 47 对齐到 `640813e`(Manager 侧补上 S0+S1)
- **前置取证**:全量产物指纹对账 ⇒ 47 与本地 HEAD 的差异**恰好只有 S0+S1 那 7 个文件**,无其他漂移。
⚠️ 踩坑记录:本机 `md5sum` 输出 `hash *path`(二进制模式)、远端是 `hash path`(文本模式)⇒ 直接 `diff` 会得到"**59 个文件全不同**"的**假象**。判据必须先归一化路径字段。
- **备份**:`/opt/dsh/backups/lib-pre-S0-20260916-1555.tgz`
- **部署**:7 个 `.js` + 同名 `.d.ts`/`.js.map`(共 21 件)⇒ 47 `/opt/dshs/lib/`,属主/权限 `197108:197121 644`(与现存一致)
- **验收实测**:7 件 md5 **逐条相等**|`dshs/dshs-worker/dshs-pg` active|门户 200|`w-47` 与 `w-106` agent 均可达(106 经隧道,`tunnel.ready=true`)|日志 0 错误
- **业务验收(多用户,R4 临时会话)**:
· `admin`(w-47)实例 `running` + 实例页 **200**
· `guest`(w-106)launch 成功 + **跨机实例页 200** + `/api/desktop/tree` 通 + `mkdir` 落盘已核实**写在 106 的盘上**
- **痕迹清理**:临时会话 `DELETE 2`⇒余 0|`/tmp` 临时件已删|guest 实例还原为 `stopped`
## 8.2 ✅ P2 · `dsh_hosts` 增 `via`(打掉 C3)
- **改 7 处**:`db/schema.ts`(**v8 迁移**,双方言同写)|`db/types.ts`(`DshHost.via` + `UpsertDshHostInput.via?` + `toDshHost`)|`db/pg.ts` / `db/repo.ts`(`HOST_COLS` + upsert)|`web/routes/admin.ts`(注册口接受 `via`)|`web/server.ts`(`hostsProvider`:**读 `via` → `RendezvousRegistry` → `resolve()` → `ClusterHost.reachability`**)|`test/reachability.test.mjs`(+2 条判据)
- **两处按勘误 F 落地**:**不加 `address` 列**(`endpoint` 本身就是要拨的地址,加列=同义双真相);**`via` 省略时不覆盖已有值**(`COALESCE(?, dsh_hosts.via)`)—— 否则一次不带 `via` 的 join 会把回填好的 `local` 冲回默认
- **回填(显式、幂等)**:`UPDATE dsh_hosts SET via='local' WHERE id='w-47';` ⇒ 结果 `w-47=local` / `w-106=manager-ssh` ✅
- **门禁**:`build` rc=0|单测 **46 项 / 0 失败**|`check:layering` **无新增违规**|迁移后 `schema_migrations` = 1–8,`dsh_hosts` 列含 `via`(默认 `manager-ssh`)
- **验收实测**:`GET /api/admin/hosts` ⇒ `"via":"manager-ssh"` / `"via":"local"`,**且不含 `agentToken`**(只有 `hasToken`)|两条路径实例页均 200(w-47 同机 / w-106 跨机)|日志 0 错误
- **补做的一处**:v1 交接单只要求"API 返回带 via",实测发现 **GET 列表原本不下发 `via`** ⇒ 已补(否则运维看不见 = 加列白加)
## 8.3 ✅ P3(改写版)· 实例端口区间隔离
- **按勘误 A 执行**:⛔ 未做 `DSHS_RELAY_LOCAL_NAMESPACE`(实测被 sshd 静默改写)|⛔ 未加 `portMap`
- **区间选定**(避开 OS 临时端口段 `32768-60999`):`w-47` = **20000**, span 1000|`w-106` = **21000**, span 1000
- **改 4 处**:`config.ts`(`instancePortBase/Span` + `toPortNumber` 防 NaN)|`supervisor/spawn.ts`(`findFreePortInRange` + `findInstancePort`)|`supervisor/orchestrator.ts`(取端口改走区间)|新增 `test/instance-port.test.mjs`(5 条,已挂进 `npm test`/`verify`)
- **部署**:**两台**(47 `/opt/dshs`、106 `/opt/dshs-cluster`)各 3 件,md5 **逐条相等**;env 加在各自 `/etc/dshs-worker.env`;备份 `lib-pre-S3-*.tgz` + `dshs-worker.env.bak-*`
- **门禁**:`build` rc=0|单测 **51 项 / 0 失败**|`check:layering` 无新增违规
- **验收实测(缺一不可的三条全过)**:
① **两台各起一个实例** ⇒ `w-47` 落 **20000**、`w-106` 落 **21000**
② 47 上 `127.0.0.1:20000`(node 直连) 与 `127.0.0.1:21000`(sshd 隧道落点) **互不重叠** ⇒ 原先的撞号根因消除
③ 实例页 `admin`(同机) **200** + `guest`(跨机) **200**,跨机文件面通
- **状态还原**:`guest` 回 `stopped`(与开工前一致);**`admin` 实例保持运行**(与开工前一致)|临时会话已删|40 端口重启后旧端口(42185/37523/35765)**全部释放**
## 8.4 ⏳ P4 未做 —— 命中 **R5**,需要一句授权
**做什么**:47 上新建独立 `dshs-relay.service`(独立 sshd、独立端口 **32023**、独立 host key、独立 `authorized_keys`、非 root 账号 `dshsrelay`),106 的 `DSHS_RENDEZVOUS_URL` 指过去。
**为什么停在这里**:它要在**公网面新开一个 SSH 监听口(32023)** —— 属 **R5「权限只准收窄」**的反面(新暴露面),必须先知会。其余三项(P1–P3)都是"收窄/修复"性质,故直接做了。
**P4 的收益与代价**(供判断):
- 优点:与宝塔面板共用的主 sshd `32022` 解耦(面板改配置不再波及覆盖网络)|隧道密钥从 `root` 的 `authorized_keys` 挪到独立文件与非 root 账号|是"中继可多实例/可换机"的前置
- 缺点:**新增一个公网 SSH 入口**(尽管只允许密钥 + `restrict,port-forwarding`)|受**勘误 E** 限制,异地中继仍差"一跳"未设计 ⇒ 本轮只能验同机形态
**回滚**:`DSHS_RENDEZVOUS_URL` 指回 `ssh://[email protected]:32022` + `systemctl disable --now dshs-relay`。
## 8.5 本轮顺带确认的两件事(未改,仅记录)
1. **控制面已在 PG 上**:`DSHS_DEPLOY_MODE=cluster` + `DSHS_DB_URL=postgres://…:15432/dshs`;dshs 进程**未打开任何 `dshs.db`**(该文件 mtime 停在 09-15 17:51)= 纯回滚副本。⇒ **不存在"需要改造为 PG"这件事**。
2. **隧道密钥已受约束**:47 的 `authorized_keys` 里 `dshs-tunnel-106to47` 那行带 **`restrict,port-forwarding`** ⇒ 该密钥**只能做端口转发、拿不到 shell**(另两把是部署键与个人键,无约束属预期)。
⚠️ **`dsh_instances.status` 不反映实时状态**:实测 admin 的实例在跑,DB 里却是 `stopped`。运行态以 **worker agent 的 `/status/:userId`** 为准(Worker 按设计不写控制面数据)—— 排查时别拿这张表的 `status` 当事实。