Files
dsh_shenxian/dsh-server-docs/04-调整方案/136-控制面按两台中继取并集-立项交接单.md
T

145 lines
13 KiB
Markdown
Raw Normal View History

# 136 · 控制面(平台 web 层)按两台中继取并集 —— 立项交接单
> **归档号**:136(占号 = `mkdir 04-调整方案/.lock-136`,2026-09-19 现核成功)
> **日期**:2026-09-19
> **状态**:🔄 待执行(**已立项,未开工** —— 本单只做立项,零代码 / 零服务器改动)
> **触发**:用户 2026-09-19 原话 —— 「**A 立项修**」(对「控制面也按两台中继取并集」的答复)
> **上游依据**:本工作区 `接续入口_覆盖网络线_20260916.md §0` 最新行的 **⑤ 遗留**(序㊾ 只修了观测面,控制面未跟)
> **单一来源**:根因链 = 本单 §2|观测面口径 = `scripts/overlay-probe.cjs`(序㊾ 已并列集)|拓扑 = `会合中继拆分_取证与改造方案_20260916.md`
---
## 1. 目标与不在范围
**目标(一句话)**:让**控制面**(`src/web/` 这一层的可达性解析)与**观测面**(`overlay-probe.cjs`)**用同一口径**看两台自研中继 —— 即「按 `hostId`(含 `network`)合并两台中继的 `/status` 视图」,从而消除「worker 漂到另一台中继时,探针全绿而**实例子域 500**」这一**假绿**。
**判据(可验证终态)**:在「`w-106` 归属在 **106 自家中继**」这一现场下,`https://guest.<domain>` 与 `https://admin.<domain>` **均 302**(⛔ 不是 500),且探针仍 **29 PASS / 0 FAIL / 0 SKIP(rc=0)**。
**在范围内**:`src/web/server.ts`(`RelayRendezvous` 接线 + `/status` 快照取数点 + `presenceOnline`)、`src/net/relay/rendezvous.ts`(`resolve()` 三级链)、以及为对齐口径可能需要的 `src/net/relay/presence*` 一侧改动。
**⛔ 不在范围内**:
- ⛔ 不改 `hostsProvider` / `dsh_hosts` 表结构(那是拆分方案 S2/C3,**已落地**,本项不再动)。
- ⛔ 不改 `src/net/reachability.ts` 的 **P0-3「`via='relay'` 拒绝回落 `agentUrl`」** —— 那是**有意的失败关闭**,本项要修的是「解析不出落点」,⛔ 不是「让它回落」。
- ⛔ 不改探针阈值 / 参数表值格 / `CAND_MIN`(序㊾ 已定,⛔ 本项只让控制面**追平**它)。
- ⛔ 不动 sshd 反向隧道相关残留、⛔ 不新增监听口、⛔ 不改 nft / nginx。
---
## 2. 根因链(**已取证,2026-09-19,⛔ 别重做**)
行号 = 本次阅读的 **HEAD `d2ef362`**(装置已部署到 47 `/opt/dshs/lib`)。
| # | 位置 | 事实 |
|---|---|---|
| 1 | `src/config.ts:592` | 状态源**只有一个** URL:`relayStatusUrl: overrides.relayStatusUrl ?? process.env.DSHS_RELAY_STATUS_URL ?? ''` |
| 2 | `src/web/server.ts:361` | `currentClient()` = **当前唯一在持有的那条**拨号通道的 client(`currentClientRef` 由 `:639` 赋值) |
| 3 | `src/web/server.ts:398-413` | `/status` 快照 → `relayEndpoints`(`{localPort, online}`),**只读 `currentClient()` 那一台** |
| 4 | `src/web/server.ts:647-655` | `presenceOnline(name)` 与订阅里的落点,**同样只取 `currentClient()`** |
| 5 | `src/web/server.ts:727 / 746 / 767` | `new RelayRendezvous({ …, online: (name)=>…, presence: presenceOnline })` —— 两个数据源**同源于一台中继** |
| 6 | `src/net/relay/rendezvous.ts:62-68` | `const pushed = this.opts.presence?.(name)`;`const online = pushed !== undefined ? pushed : this.opts.online?.(name)` ⇒ **两者都拿不到 ⇒ `resolve()` 返 `undefined`** |
| 7 | `src/net/reachability.ts:101-108` | `host.via === VIA_RELAY` 且解析不出落点 ⇒ **按设计抛错**:`host "…" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint`(P0-3) |
**⇒ 现象**:`w-106` 一旦按**抖动选路**(`switcher.ts`,属既有设计)漂到 **106 自家中继**,Manager 侧 `currentClient()` 仍是 47 那条 ⇒ `online` / `presence` 都查不到 `w-106` ⇒ 第 7 步抛错 ⇒ **实例子域 500**;而观测面(序㊾ 已并列集)**全绿** ⇒ **假绿**。
**本轮实测旁证(2026-09-19 07:3x)**:
```
47 relay /status ⇒ sessions ["manager","w-106"] dialers ["manager"]
106 relay /status ⇒ sessions [] dialers ["manager"]
```
⇒ 两台**都**被 Manager 拨入,但 `w-106` 只出现在 47 的会话表里 ⇒ 缺口**当下被"worker 恰好挂在 47"掩盖**,尚未显形 —— 这正是它危险的地方(**平时不红,漂到 106 才红**)。
---
## 3. 🔴 修前补测(**必做,第一件事**)
⚠️ 序㊾ 收口行里的结论「S5 让 106 worker 主入口落回 47 中继后修复(500 → 302)」**只验了"落回 47 即 302",未验"漂到 106 自家中继是否仍 500"**。⛔ 不许把"已恢复 302"当成缺口不存在。
**补测步骤(只读 + 一次性制造现场)**:
1. 记录基线归属:`curl -s http://127.0.0.1:20080/status`(47)与 `ssh test106 'curl -s http://127.0.0.1:20080/status'`(106)。
2. 制造现场:**停 47 的 `dshs-relay` 45 s 后起**(序㊾ 已实测此法能逼 worker 回落/漂移;⚠️ 首轮"停 3 s"不足够 —— 106 是该 worker 候选链**第一条**,它会回连),或按候选链顺序停"当前那条"。
3. 现场下取三组读数:① 两台 relay `/status` 的 `sessions` 归属 ② 探针输出(应**仍全绿**)③ `curl -sk --http1.1 -o /dev/null -w '%{http_code}' https://guest.<domain>/`(**期望复现 500**)。
4. **先红**:把第 3 步 ③ 的 500 原文(含 `journalctl -u dshs` 里那条 `声明 via=relay 但解析不出落点`)贴进本单 §8。
⚠️ 若**复现不出 500** ⇒ ⛔ **停手报告**(缺口可能已被别处改动掩盖),**不许**在没有红的情况下开工 —— 没有红就没有可验证的绿。
---
## 4. 修法与决策点
**主修方向(已定)**:把 `online` / `presence` **合并两台中继**的在线态(并集),与序㊾ 探针**同口径**。
| 难点 | 说明 |
|---|---|
| `online`(`/status` 快照) | ✅ **容易**:对**每台**中继各取一份 `/status`,按 `network:hostId:port` 合并(`online` 取**或**、`localPort` 取**在线那一侧**)—— 与 `overlay-probe.cjs#mergeEndpoints()` **逐字同口径** |
| `presence`(订阅推送) | 🔴 **真难点**:presence 是**订阅**,Manager 连哪台就只能订阅哪台。两条候选见下 |
| 🔴 口径一致性 | 第二台若走 `/status` 轮询,其**新鲜度**语义必须与订阅一致,⛔ 防「**假在线**」(订阅侧是推、轮询侧是拉,陈旧窗口不同) |
**决策点(两条候选 —— 各有优有劣,需在执行棒开工前定)**:
**候选 A:Manager 同时对两台中继各建一条 presence 订阅**
- 优点:口径**完全一致**(都是推、都是同一份 presence 语义);无陈旧窗口差异;与「并集」设计最贴合。
- 缺点:Manager 需**同时持有两条拨号通道**(当前是"单通道 + 切流"),与 `switcher.ts` 的**单通道/冷却**模型冲突 ⇒ 改动面最大,可能牵动切流与冷却语义(🔴 **动冷却 = 高风险区**,参数表已把冷却值钉死)。
**候选 B:第一条(当前通道)走订阅,第二条降级为 `/status` 轮询,两边并集**
- 优点:改动面小、⛔ 不碰切流与冷却;复用 `:398` 已有的 `/status` 快照代码路径(只是从"一台"变"两台")。
- 缺点:第二台是**拉**、有陈旧窗口 ⇒ 存在「**假在线**」窗口(对端刚离线、轮询未刷);需额外定义并**断言**陈旧阈值,否则把真 FAIL 糊成 PASS(违反序㊾ 已立的「并集只消除'看不见'、⛔ 不把真 FAIL 糊成 PASS」原则)。
**倾向(可推翻)**:**先 B 后 A** —— B 能**立即消除 500**(目标达成),且不碰冷却(风险最低);A 作为架构目标登记。⚠️ 若执行棒取证发现「B 的陈旧窗口会让实例子域**偶尔 500**」⇒ 升级为 A。
🔴 **需要用户拍板的只有边界外项**;以上属**技术实现取舍**,执行棒按 `dsh-decision-method §4.4` 自决并在 §8 写「我选了什么(可推翻)」,⛔ **不上抛**。
---
## 5. 避让 / 重叠检查(**开工前必核**)
| 对象 | 关系 | 处置 |
|---|---|---|
| `会合中继拆分_取证与改造方案_20260916.md` **§2 C3**(`dsh_hosts.endpoint` 磨掉"经谁中转") | ⚠️ **最易混淆**:C3 说的是**表形状**(补 `via` 列),⇒ **已由该方案 S2 落地**(`dsh_hosts.via` 已存在、`Reachability.via` 已在用) | ⛔ **本项不再动表**;本项修的是**解析数据源数量**,不是表形状 |
| 同方案 **§9.4**「S4 有方案缺口:中继一旦不在 Manager 主机上,'Manager 拨落点'就断」 | 🔴 **同源问题、不同切面** —— §9.4 讲**拨号侧**落点,本项讲**读状态侧**并集 | 执行棒须引用 §9.4 并写明**本项不解决 §9.4**(避免重复设计 / 互相以为对方会修) |
| `ts` 侧 `presence` 既有实现(`src/web/server.ts:363-389`,序⑲ D5「订阅新鲜 ⇒ 挂起 `/status` 轮询」) | 🔴 **强耦合**:该开关决定 `/status` 轮询**是否在跑** | 本项改动**必须**说明它与 D5 开关的相互作用(第二台轮询是否受 D5 门控?) |
| 探针 `overlay-probe.cjs`(序㊾ 已并列集) | **同口径来源** | 本项**必须复用其合并语义**(键、`online` 取或、`localPort` 取在线侧),⛔ 不许另造一套口径(避免"同一事实两处写") |
---
## 6. 步骤(S0–S4)
| 步 | 动作 | 生效 | 验收 |
|---|---|---|---|
| **S0** | **修前补测**(§3):制造"worker 在 106"现场 ⇒ 取证 **500 原文** | — | §8 里贴出 500 原文 + `在解析不出落点` 日志行 |
| **S1** | 抽"多中继状态视图"取数入口(**先不动行为**):把"取一台 `/status`"泛化为"取 N 台 + 合并",**默认 N=1 ⇒ 零行为变化**(等价性证明写进注释) | 本机 | `npm test` **与基线逐字相同**(≥217 pass / 0 fail / 1 skipped) |
| **S2** | 按 §4 选定候选(倾向 **B**)接线 `online` / `presence` 并集;🔴 同时定义**陈旧阈值**并写进参数表 | build + 部署 | §7 逐条 |
| **S3** | 部署 + 重启 47 `dshs`(控制面断数十秒,R8 允许,动手前一句话说明) | **立即** | 探针 29 PASS + 目标现场 302 |
| **S4** | 回填:本单 §8 + 当日日志 + `接续入口 §0` | — | — |
⚠️ `npm test` 必须用 **Node 22**(`E:\ProgramData\.workbuddy\binaries\node\versions\22.22.2-3\node.exe`)。
⚠️ 部署面:47 `/opt/dshs/lib`(`src/web/server.js`、`src/net/relay/*.js` 及其 `.d.ts`);**md5 逐文件对账**。
---
## 7. 逐条验收(**先红后绿**)
| # | 判据 | 红(开工前必须拿到) | 绿(修后) |
|---|---|---|---|
| E1 | **修复本体** | 现场(worker 在 106 自家中继)⇒ `guest.<domain>` **500** | 同态 ⇒ **302** |
| E2 | **解析链** | `journalctl -u dshs` 有 `声明 via=relay 但解析不出落点` | 该行**消失**,且出现"并集命中"的可诊断痕迹 |
| E3 | **并集真并集(⛔ 防假绿)** | — | 模拟"对端不可达"腿 ⇒ 判据**按单台视角回退**(⛔ 不许因为"并集了"就把红的糊成绿) |
| E4 | 零回归 · 探针 | 基线 **29 PASS / 0 FAIL / 0 SKIP(rc=0)** | **逐字相同** |
| E5 | 零回归 · 单测 | 基线 **217 pass / 0 fail / 1 skipped**(`# tests 218`) | **逐字相同** |
| E6 | 零回归 · 演练 | 基线 `--scene all` **12 PASS / 0 SKIP / 0 FAIL** | 逐字相同;⚠️ 含 120 s + 90 s 观察窗 ⇒ **外层超时 ≥ 8 min**,否则 `restore()` 不跑 ⇒ **106 `dshs-relay` 留在停机态** |
| E7 | 端到端 | — | 门户 `https://<domain>/portal.html` = **200**;`admin.<domain>` / `guest.<domain>` 在**有 session** 时 302 带 `?token=`(⚠️ 无 cookie 直连返回 **401 属已知正常**) |
| E8 | `bootstrap` 面 | — | `relays[]` / `bootstrap[]` 仍 **3 条、零旧域** |
---
## 8. 回滚 / 回报格式
**回滚**:`git checkout -- src/` → 重新 build → scp 回 47 `/opt/dshs/lib` → `systemctl restart dshs`。
⚠️ 回滚点须在执行棒开工时**先备份** 47 `/opt/dshs/lib`(`cp -a` 到 `/opt/dsh/backups/<seq>-<ts>/`)。
**§8 回报(执行棒收口必写,逐条给原文级证据)**:
1. **修前补测结果**:现场归属(两机 `sessions`)+ 500 原文 + 那行 journal。
2. **我选了什么(可推翻)**:候选 A / B 的取舍与理由。
3. **改动清单**:文件 × 行 × 改前改后(+ 等价性证明,若适用)。
4. **先红后绿**:红 / 绿两条原文并列 **diff 只差这几行**。
5. **零回归三件套**:E4 / E5 / E6 的逐字数值。
6. **部署与部署面**:文件 list + **md5 对账** + 回滚点路径。
7. **边界自证**:⛔ 未 commit / 未 push(写 HEAD)|⛔ 未改 `REACHABILITY`/P0-3 语义 |⛔ 未改探针阈值与参数表值格 |⛔ 未新增监听口 |🔴 `COOLDOWN_MS=0` 计数 = 0 |⛔ 未调 `RELAY_FAILOVER_DEADLINE_MS` / `HB_SEC` / burst。
8. **如实留档**:本轮未解决项、未测项、以及任何与上游文档冲突的取证结论。