Files
dsh_shenxian/dsh-server-docs/04-调整方案/136-控制面按两台中继取并集-立项交接单.md
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +08:00

13 KiB
Raw Blame 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. 如实留档:本轮未解决项、未测项、以及任何与上游文档冲突的取证结论。