三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。 ⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。 【档案 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。
13 KiB
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"当成缺口不存在。
补测步骤(只读 + 一次性制造现场):
- 记录基线归属:
curl -s http://127.0.0.1:20080/status(47)与ssh test106 'curl -s http://127.0.0.1:20080/status'(106)。 - 制造现场:停 47 的
dshs-relay45 s 后起(序㊾ 已实测此法能逼 worker 回落/漂移;⚠️ 首轮"停 3 s"不足够 —— 106 是该 worker 候选链第一条,它会回连),或按候选链顺序停"当前那条"。 - 现场下取三组读数:① 两台 relay
/status的sessions归属 ② 探针输出(应仍全绿)③curl -sk --http1.1 -o /dev/null -w '%{http_code}' https://guest.<domain>/(期望复现 500)。 - 先红:把第 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 回报(执行棒收口必写,逐条给原文级证据):
- 修前补测结果:现场归属(两机
sessions)+ 500 原文 + 那行 journal。 - 我选了什么(可推翻):候选 A / B 的取舍与理由。
- 改动清单:文件 × 行 × 改前改后(+ 等价性证明,若适用)。
- 先红后绿:红 / 绿两条原文并列 diff 只差这几行。
- 零回归三件套:E4 / E5 / E6 的逐字数值。
- 部署与部署面:文件 list + md5 对账 + 回滚点路径。
- 边界自证:⛔ 未 commit / 未 push(写 HEAD)|⛔ 未改
REACHABILITY/P0-3 语义 |⛔ 未改探针阈值与参数表值格 |⛔ 未新增监听口 |🔴COOLDOWN_MS=0计数 = 0 |⛔ 未调RELAY_FAILOVER_DEADLINE_MS/HB_SEC/ burst。 - 如实留档:本轮未解决项、未测项、以及任何与上游文档冲突的取证结论。