From 146c3d25eff98da88c67ae146346847976c769e0 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Thu, 17 Sep 2026 17:03:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(overlay):=20=E8=A6=86=E7=9B=96=E7=BD=91?= =?UTF-8?q?=E7=BB=9C=E7=BA=BF=E5=BA=8F=E2=91=A0=E2=80=93=E2=91=AE=20?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E4=B8=8E=E6=B5=8B=E8=AF=95=E4=BA=A7=E7=89=A9?= =?UTF-8?q?=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 覆盖网络线累积产物(此前只在工作区、未入版本库): - 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index) - 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts - 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate - 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等) 验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12 --- package.json | 4 +- scripts/overlay-failover-drill.cjs | 1050 ++++++++++++++++++++ scripts/overlay-holepunch.cjs | 302 ++++++ scripts/overlay-jitter.cjs | 125 +++ scripts/overlay-keyring.cjs | 270 ++++++ scripts/overlay-probe.cjs | 617 ++++++++++++ scripts/overlay-relaykey-add.cjs | 71 ++ scripts/overlay-wan.cjs | 357 +++++++ scripts/relay-mem-calibrate.mjs | 178 ++++ src/config.ts | 162 ++++ src/db/pg.ts | 20 +- src/db/repo.ts | 27 +- src/db/schema.ts | 39 + src/db/types.ts | 45 + src/fs/provider.ts | 6 + src/fs/remote-user-fs.ts | 54 +- src/fs/user-fs.ts | 5 + src/net/reachability.ts | 75 +- src/net/relay/addr-override.ts | 234 +++++ src/net/relay/client.ts | 1416 +++++++++++++++++++++++++++ src/net/relay/dialer.ts | 312 ++++++ src/net/relay/directory.ts | 789 +++++++++++++++ src/net/relay/duplex.ts | 74 ++ src/net/relay/identity.ts | 656 +++++++++++++ src/net/relay/index.ts | 88 ++ src/net/relay/keys.ts | 193 ++++ src/net/relay/main.ts | 363 +++++++ src/net/relay/network.ts | 222 +++++ src/net/relay/placement.ts | 209 ++++ src/net/relay/rendezvous.ts | 57 ++ src/net/relay/server.ts | 1445 ++++++++++++++++++++++++++++ src/net/relay/switcher.ts | 519 ++++++++++ src/net/relay/wire.ts | 453 +++++++++ src/net/rendezvous.ts | 32 +- src/supervisor/orchestrator.ts | 7 +- src/supervisor/remote-spawner.ts | 42 +- src/supervisor/spawn.ts | 45 + src/web/routes/admin.ts | 20 +- src/web/routes/overlay.ts | 83 ++ src/web/server.ts | 475 ++++++++- src/worker/agent.ts | 85 +- src/worker/relay-tunnel.ts | 224 +++++ src/worker/tunnel.ts | 24 +- test/instance-port.test.mjs | 72 ++ test/overlay-auth.test.mjs | 406 ++++++++ test/overlay-bootstrap.test.mjs | 747 ++++++++++++++ test/overlay-identity.test.mjs | 526 ++++++++++ test/overlay-network.test.mjs | 527 ++++++++++ test/reachability.test.mjs | 54 +- test/relay-failover.test.mjs | 844 ++++++++++++++++ test/relay.test.mjs | 1318 +++++++++++++++++++++++++ test/remote-spawner.test.mjs | 94 ++ test/remote-user-fs.test.mjs | 188 ++++ 53 files changed, 16187 insertions(+), 63 deletions(-) create mode 100644 scripts/overlay-failover-drill.cjs create mode 100644 scripts/overlay-holepunch.cjs create mode 100644 scripts/overlay-jitter.cjs create mode 100644 scripts/overlay-keyring.cjs create mode 100644 scripts/overlay-probe.cjs create mode 100644 scripts/overlay-relaykey-add.cjs create mode 100644 scripts/overlay-wan.cjs create mode 100644 scripts/relay-mem-calibrate.mjs create mode 100644 src/net/relay/addr-override.ts create mode 100644 src/net/relay/client.ts create mode 100644 src/net/relay/dialer.ts create mode 100644 src/net/relay/directory.ts create mode 100644 src/net/relay/duplex.ts create mode 100644 src/net/relay/identity.ts create mode 100644 src/net/relay/index.ts create mode 100644 src/net/relay/keys.ts create mode 100644 src/net/relay/main.ts create mode 100644 src/net/relay/network.ts create mode 100644 src/net/relay/placement.ts create mode 100644 src/net/relay/rendezvous.ts create mode 100644 src/net/relay/server.ts create mode 100644 src/net/relay/switcher.ts create mode 100644 src/net/relay/wire.ts create mode 100644 src/web/routes/overlay.ts create mode 100644 src/worker/relay-tunnel.ts create mode 100644 test/instance-port.test.mjs create mode 100644 test/overlay-auth.test.mjs create mode 100644 test/overlay-bootstrap.test.mjs create mode 100644 test/overlay-identity.test.mjs create mode 100644 test/overlay-network.test.mjs create mode 100644 test/relay-failover.test.mjs create mode 100644 test/relay.test.mjs create mode 100644 test/remote-spawner.test.mjs create mode 100644 test/remote-user-fs.test.mjs diff --git a/package.json b/package.json index f78463c..8235b69 100644 --- a/package.json +++ b/package.json @@ -23,8 +23,8 @@ "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", "check:layering": "node scripts/check-layering.mjs", - "verify": "npm run build && node scripts/check-layering.mjs && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs test/worker-provision.test.mjs test/reachability.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs", - "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs test/reachability.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", + "verify": "npm run build && node scripts/check-layering.mjs && node --test test/db.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs test/worker-provision.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs", + "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs test/overlay-identity.test.mjs test/relay-failover.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", "smoke:auth": "node scripts/smoke-auth.mjs", diff --git a/scripts/overlay-failover-drill.cjs b/scripts/overlay-failover-drill.cjs new file mode 100644 index 0000000..2e7d849 --- /dev/null +++ b/scripts/overlay-failover-drill.cjs @@ -0,0 +1,1050 @@ +/** + * 覆盖网络 · **序⑦ 中继失败切流 · 真机演练**(交接单 §5 S7 / §6 E5–E7 的判据本体) + * + * ## 这个脚本要回答的唯一问题 + * 「**杀掉任一台中继 ⇒ 正在使用它的客户端在 `RELAY_FAILOVER_DEADLINE_MS` 内切到另一台**」 + * —— 在**真机**上、由**一条命令**产出 PASS/FAIL/SKIP(⛔ 不把真机取证交给 agent 手工做)。 + * + * ## 三幕(序⑦) + * - **幕 1**:杀掉 Manager **当前所在**那台中继(`DRILL_KILLED_MATCH`)⇒ 断言切换在 deadline 内发生, + * 且**目标 ≠ 被杀入口**、47 的 Manager 自身仍存活(失败域分离)、门户仍 200。 + * - **幕 2**:两台全杀 ⇒ 断言**无新切换**、有 `[relay-skip]` 的"无候选"证据、⛔ 无静默回退(D6 / R11)。 + * 🔴 **序⑩ 修(2026-09-17)**:原实现**只停 106** —— 若此刻活跃通道正好在 47(幕 1 的余波 / 单跑本幕), + * 停的就是**当前不用**的那台 ⇒ 通道根本没被打断 ⇒ 窗口内 0 行 `[relay-skip]` / 0 行 `[relay-switch]` + * ⇒ **幕2-B 假红**(序⑨ 实测:该 120 s 窗口里 Manager 一行 relay 日志都没有)。现在本幕**现场重读一次 + * 权威通道归属**,再把**两台都停掉**(幂等)⇒ 断言不再依赖上一幕留下的通道归属。 + * - **幕 3**:只恢复 47 ⇒ 断言**冷却期内不回跳**;随后复原两台。 + * + * ## 序⑧ 新增(2026-09-17):**幕 4 系列** —— "切流冷却语义"的真机判据 + * - **幕 4**(构 A):停 47 ⇒ 47 的**两条候选**各按原因进冷却 ⇒ 恢复 47、停 106 ⇒ **D6 现场** + * ⇒ 断言"一跳豁免"把通道切回 47(日志带 `|豁免 … 剩 Nms` ⇒ **直接证明 47 当时确在冷却**)。 + * - **幕 4b**(对照):同上但 `RELAY_FAILOVER_EXEMPT=0` + `RELAY_FAILOVER_COOLDOWN_MS` 缩到 + * `DRILL_COOLDOWN_MS` ⇒ 断言 ① D6 现场原样复现 ② 冷却未过期前**不切流** ③ 冷却过期后**自然**回归。 + * ⚠️ 该幕**用的是非生产冷却值**,报告必须标注(D9)。 + * - 🔴 **序⑩ 删(2026-09-17)**:原 `--scene 4c` 与 `--scene ctrl`(**两者都靠** `RELAY_FAILOVER_COOLDOWN_MS` **置 0**) + * **已整体移除** —— 该值已判「**看似合法**(`num()` 的 `/^\d+$/` 放行 `'0'`)、**实际自锁**」:归零会让 + * "失败候选必须被排除"一并失效 ⇒ 候选链卡在第一个失败候选(实测 **121–123 s 无切换**)。 + * ⇒ ⛔ **演练 / 回滚路径一律不许再出现该值**;要"强制冷却过期"只走 `DRILL_COOLDOWN_MS`(幕 4b 已覆盖)。 + * ⚠️ 这两个场景名**现在被显式拒绝**(退出码 2)—— ⛔ 不许静默空跑("0 项判定"会被误读成通过)。 + * - ⚠️ 幕 4 系列**会重启 Manager 单元**(为了确定的前置 + 施加 env 覆盖);覆盖一律走 + * `dshs.service.d/zz-drill-override.conf` 这一个 drop-in,`finally` 里**删除 + 重启**回到生产值。 + * 🔴 **`RELAY_FAILOVER_COOLDOWN_MS` 的代码默认值恒为 `300000`**(D9)—— 演练期缩短只经 + * **新键** `DRILL_COOLDOWN_MS`,⛔ 不改生产默认。 + * + * ## ✳️ 首轮实测逼出来的三条设计修正(**诚实记录**) + * 1. **前置状态必须归零**:首轮 幕1 判 FAIL,真因是 Manager 的当前通道**遗留在 106**(上一轮演练的余波) + * ⇒ 杀 47 对**它**毫无影响,"不切换"其实是**正确行为**。现在脚本先读"当前通道线索", + * 若它不在被杀入口上 ⇒ 记 **SKIP**(⛔ 不把"状态没归零"算成产品失败,也不假装 PASS)。 + * 2. **观察窗必须 ≥ 失效检测时延**:经 CF 的**静默失效**下,客户端要等**半开检测**(`2.5 × HB_SEC`) + * 才进 `backoff`,首轮实测 `unhealthyForMs` 已到 47–63 s ⇒ 幕2 的 30 s 窗口**必然漏判**。 + * 改用 `DRILL_DETECT_BUDGET_MS`(⛔ 仍把"是否 ≤ `RELAY_FAILOVER_DEADLINE_MS`"如实报出来)。 + * 3. **`systemctl is-active` 在 inactive 时退出码 = 3** ⇒ `execFileSync` 会抛 ⇒ 首轮**中断在幕1 半路** + * 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。 + * + * ## 运行(cwd = 工作区根;⛔ 阈值零硬编码) + * `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]` + * + * ⚠️ **本脚本会真停生产单元**(`dshs-relay`);`finally` 里**一律复原**(失败也复原)。 + */ + +'use strict' + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +/** 参数表文件名(⛔ 不写死日期数字)。 */ +const TABLE_RE = /^参数表_覆盖网络_.+\.md$/ +const KEY_RE = /^\|\s*`([A-Z0-9_]+)`\s*\|\s*([^|]*)\|/ +const OBS_RE = /^\|\s*`(OBS-[0-9]+)`\s*\|\s*([^|]*)\|/ +const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/ + +/* ─────────── 参数表装载(与 overlay-probe.cjs 同款;⛔ 脚本内无魔数) ─────────── */ + +function resolveTablePath(argv) { + const argOf = (name) => { + const i = argv.indexOf(name) + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined + } + const explicit = argOf('--table') + if (explicit !== undefined) return explicit + const dir = argOf('--dir') ?? process.env.DSHS_OVERLAY_TABLE_DIR ?? process.cwd() + const hits = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n)) + if (hits.length !== 1) { + throw new Error(`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${hits.length} 个参数表(要求恰好 1 个)`) + } + return path.join(dir, hits[0]) +} + +const cleanValue = (raw) => String(raw).replace(/[*`]/g, '').trim() + +function loadTable(file) { + const text = fs.readFileSync(file, 'utf8') + const params = new Map() + for (const line of text.split(/\r?\n/)) { + if (OBS_RE.test(line)) continue + const k = KEY_RE.exec(line) + if (k !== null && !params.has(k[1])) params.set(k[1], cleanValue(k[2])) + } + return { file, params } +} + +function makeReaders(table) { + const bad = [] + const need = (key) => { + if (!table.params.has(key) || table.params.get(key) === '') { + bad.push(key) + return '' + } + return table.params.get(key) + } + const num = (keyOrRef) => { + const m = KEY_ONLY_RE.exec(keyOrRef) + let v + if (m !== null) v = need(m[1]) + else if (table.params.has(keyOrRef)) v = need(keyOrRef) + else v = String(keyOrRef).trim() + const n = Number(v) + if (v === '' || !Number.isFinite(n)) { + bad.push(`${keyOrRef}=${JSON.stringify(v)} 不是数`) + return Number.NaN + } + return n + } + return { need, num, bad } +} + +/* ─────────── 远端动作 ─────────── */ + +function ssh(port, target, command, timeoutMs) { + return execFileSync('ssh', ['-p', String(port), '-q', '-o', 'LogLevel=ERROR', '-o', 'BatchMode=yes', target, command], { + encoding: 'utf8', + timeout: timeoutMs, + }).trim() +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +const EXIT_OK = 0 +const EXIT_FAIL = 1 +const EXIT_USAGE = 2 + +async function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help')) { + process.stdout.write( + '用法:node scripts/overlay-failover-drill.cjs [--scene 1|2|3|4|4b|all] [--table <参数表.md>] [--dir <目录>]\n' + + ' node scripts/overlay-failover-drill.cjs --trace [--since -6h|@] [--out ]\n' + + ' --trace = 序⑨:**只读**,从 journal 复现「杀中继 ⇒ 切流完成」的四段分解(检测/tick相位/白等/建连),\n' + + ' ⛔ 不停 relay、不改 env;检测+白等 < 27.0 s ⇒ 分解被证伪(退出码 1)\n' + + ' all = 1|2|3|4(不含 4b —— 它会重启 Manager,须单独跑)\n' + + ' 4 = 序⑧ 构 A:D6 现场 + 一跳豁免(2 秒内切回 47)\n' + + ' 4b = 序⑧ 对照:豁免关 ⇒ D6 现场原样复现 + 冷却过期后**自然**回归(缺陷时限)\n' + + ' ⛔ 序⑩ 起 `4c` / `ctrl` 已移除(两者都依赖 `RELAY_FAILOVER_COOLDOWN_MS` 置 0 = 自锁值,见文件头)\n', + ) + return EXIT_USAGE + } + const sceneArg = (() => { + const i = argv.indexOf('--scene') + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : 'all' + })() + /** + * ⛔ **序⑩:已移除的场景显式报错** —— 靠 `RELAY_FAILOVER_COOLDOWN_MS` 置 0 的两个对照场景(`4c` / `ctrl`) + * 已整体删除。⛔ **不许静默空跑**(那会产出"0 项判定",被人误读成通过)。 + */ + if (['4c', 'ctrl'].includes(sceneArg)) { + process.stderr.write( + `❌ --scene ${sceneArg} 已于序⑩ 移除:它依赖 RELAY_FAILOVER_COOLDOWN_MS 置 0(已判"看似合法、实际自锁")——\n` + + ` 归零会让「失败候选必须被排除」失效 ⇒ 候选链卡死(实测 121–123 s 无切换)。\n` + + ` 要缩短冷却请用 --scene 4b(走 DRILL_COOLDOWN_MS,属非生产值)。\n`, + ) + return EXIT_USAGE + } + + let r + try { + r = makeReaders(loadTable(resolveTablePath(argv))) + } catch (err) { + process.stderr.write(`❌ 参数表装载失败:${err.message}\n`) + return EXIT_USAGE + } + + const sshPort = r.num('SSH_PORT') + const sshTo = r.num('SSH_TIMEOUT_MS') + const h47 = r.need('SSH_TARGET_47') + const h106 = r.need('SSH_TARGET_106') + const relayUnit = r.need('DRILL_RELAY_UNIT') + const managerUnit = r.need('DRILL_MANAGER_UNIT') + const deadlineMs = r.num('RELAY_FAILOVER_DEADLINE_MS') + const pollMs = r.num('DRILL_POLL_MS') + const observeMs = r.num('DRILL_COOLDOWN_OBSERVE_MS') + const detectBudgetMs = r.num('DRILL_DETECT_BUDGET_MS') + const killedMatch = r.need('DRILL_KILLED_MATCH') + const drillCooldownMs = r.num('DRILL_COOLDOWN_MS') + const noSwitchObserveMs = r.num('DRILL_NO_SWITCH_OBSERVE_MS') + const portalUrl = r.need('PORTAL_URL') + const portalHost = r.need('PORTAL_HOST_HEADER') + if (r.bad.length > 0) { + process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`) + return EXIT_USAGE + } + + const results = [] + const record = (name, verdict, detail) => { + results.push({ name, verdict }) + process.stdout.write(`${verdict} ${name} ${detail}\n`) + } + + /** + * 窗口起点 = **`@`**(journalctl 原生、**与时区无关**)。 + * + * 🔴 **实测教训(序⑦ 第三轮,一条命令定案)**:`date -Is` 产出的 `2026-09-17T11:56:00+08:00` + * 喂给 `journalctl --since` 会被判 **`Failed to parse timestamp`** ⇒ stdout 空 ⇒ `grep` 无命中 + * ⇒ 尾部 `|| true` 掩盖 ⇒ **所有窗口判定失真**:幕2-A / 幕3-A 成了**假绿**(空窗口被当"没切换")、 + * 幕1-A / 幕2-B 成了**假红**(日志里明明有 `[relay-switch]` / `[relay-skip]`)。 + * 对照取证:`--since "2026-09-17T11:56:00+08:00"` → `lines=0 err=Failed to parse timestamp`; + * `--since @1789617360` → `lines=2`;`--since -6h` → `lines=6170`。 + */ + const sinceNow = async () => `@${await ssh(sshPort, h47, 'date +%s', sshTo)}` + + /** + * 自某时刻起的某类日志行(原文)。 + * - 尾部 `|| true`:`grep` 无命中退出码 1 不许把流程打断。 + * - 🔴 **`JOURNALCTL-ERR` 哨兵**:查询本身失败(时间戳解析不了 / 单元不存在)时**不再静默返回空** + * —— 「查询失败」与「确实没有该行」必须可区分,否则又是一次假绿。 + */ + const logSince = (since, kind) => + ssh( + sshPort, + h47, + `o=$(journalctl -u ${managerUnit} --since ${since} --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` + + `printf '%s\\n' "$o" | grep -F '${kind}' || true`, + sshTo, + ) + + /** + * 把 `JOURNALCTL-ERR` 哨兵剥出来 ⇒ `{ err, lines }`。 + * ⛔ `err !== ''` 时**一律不许**给出 PASS/FAIL —— 判据不可信就如实报"查询失败"。 + */ + const splitErr = (raw) => { + const i = raw.indexOf('JOURNALCTL-ERR') + if (i < 0) return { err: '', lines: raw } + return { err: raw.slice(i).split('\n')[0].replace('JOURNALCTL-ERR', '').trim(), lines: '' } + } + + /** ⚠️ `|| true` 是**必须的**:`is-active` 在 inactive 时退出码 3。 */ + const relayActive = (target) => + ssh(sshPort, target, `systemctl is-active ${relayUnit} || true`, sshTo) + + const stopRelay = (target) => ssh(sshPort, target, `systemctl stop ${relayUnit}`, sshTo) + const startRelay = (target) => ssh(sshPort, target, `systemctl start ${relayUnit}`, sshTo) + const portalCode = () => + ssh( + sshPort, + h47, + `curl -s -o /dev/null -w '%{http_code}' --http1.1 -H ${JSON.stringify(`Host: ${portalHost}`)} ${portalUrl}`, + sshTo, + ) + + /** + * **当前通道线索** = 最近一条 `[relay-switch]` 的 `->` 目标;没有切换过 ⇒ 回落到最近一条 + * `[overlay-dir] 取址 = …:` 的 url。⚠️ 它只是"线索"(用于**判定前置状态是否归零**), + * 不是权威读数 —— 权威读数在进程内存里,本脚本不碰。 + */ + /** + * **当前通道线索**(**仅供人读**,不参与判定)= 最近一条 `[relay-switch] -> X`; + * 无切换过 ⇒ 回落最近一条 `[overlay-dir] 取址 = …:X`。 + * + * 🔴 **两次踩坑记录(序⑦ 第四/五轮)**: + * ① 改前实现"switch 与 取址 取较新者"是**语义错误** —— `取址` 是**目录解析结果**(每 2 s 刷一条、 + * 永远等于目录首位),**不是已建立的通道**;一旦发生切换,取址 就把线索带偏。 + * ② 只信 switch 也不够:**进程重启后首连不打 switch 日志**(通道 = 取址)⇒ 单独用 switch 会读到上一轮的旧值。 + * ⇒ **判定不再依赖日志线索**,改用 `lastManagerAuthOn()` 的权威读数;本函数只打印给人看。 + */ + const currentChannelHint = () => { + const sw = splitErr(logSince('-6h', '[relay-switch]')).lines + if (sw !== '') { + const last = sw.split('\n').slice(-1)[0] + const m = /->\s*(\S+?)(/.exec(last) ?? /->\s*(\S+)\s*$/.exec(last) + if (m !== null) return m[1].trim() + } + const od = splitErr(logSince('-6h', '[overlay-dir] 取址')).lines + const m2 = od === '' ? null : (/:(\S+?)(/.exec(od.split('\n').slice(-1)[0]) ?? /:(\S+)\s*$/.exec(od.split('\n').slice(-1)[0])) + return m2 !== null && m2 !== undefined ? m2[1].trim() : '' + } + + /** + * **权威读数:Manager 最近一次在**某台**relay 上注册成功**的 epoch 秒(`-1` = 读不到/从未)。 + * + * 🔑 **为什么必须问 relay 的日志**:Manager 是**拨出方**,它的当前通道在进程内存里,日志无法直接读。 + * 而 relay 侧每接受一次拨号就写一行 `[relay] AUTH OK host=ops/manager …`(带时间戳)⇒ + * **两台 relay 谁的时间戳更新,Manager 就在谁那儿**。 + * + * ⛔ **不要用 relay `/status` 的 `dialers`**:实测它**会留陈旧条目**(序⑦ 第五轮:Manager 12:05:18 + * 已切走,47 的 `/status` 仍列 `dialers:["manager"]`,而 47 日志里最后一次 manager AUTH 停在 12:04:19)。 + */ + const lastManagerAuthOn = (target) => { + const raw = splitErr( + ssh( + sshPort, + target, + `o=$(journalctl -u ${relayUnit} --since -6h -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` + + `printf '%s\\n' "$o" | grep -E 'AUTH OK host=ops/manager' | tail -1 || true`, + sshTo, + ), + ).lines + if (raw === '') return -1 + const t = Number.parseFloat(raw.split(/\s+/)[0]) + return Number.isFinite(t) ? t : -1 + } + + /** 等一条**新的** `[relay-switch]` 行(≤ budget),返回 { ok, ms, lines, err }。 */ + const waitSwitch = async (since, budgetMs) => { + const t0 = Date.now() + for (;;) { + const { err, lines } = splitErr(logSince(since, '[relay-switch]')) + if (err !== '') return { ok: false, ms: Date.now() - t0, lines: '', err } + if (lines !== '') return { ok: true, ms: Date.now() - t0, lines, err: '' } + if (Date.now() - t0 >= budgetMs) return { ok: false, ms: Date.now() - t0, lines: '', err: '' } + await sleep(pollMs) + } + } + + /* ═══════════════════════════════════════════════════════════════════════════ + * 序⑨ · `--trace`:把「杀中继 ⇒ 切流完成」的墙钟**拆成四段**(**只读**) + * + * 判据来源 = **现有日志行的时间戳**(D2):⛔ 不停 relay / ⛔ 不改 env / ⛔ 不写远端文件。 + * 三行锚点切样本: + * ① `[relay-client] down (…(was up)); attempt #0 [graceful, burst window Nms]` = 样本起点 t_bye + * ② 其后第一条**不带方括号标签**的 `attempt #M, retry in …`(M ≥ 1)= burst 窗口耗尽 ⇒ 检测段结束 + * ③ 其后**最后一条不带标签的 `attempt #1`** = **新候选客户端的第一条失败**(窗口内唯一能独立量出的 + * "白等起点":新客户端是全新实例 ⇒ 首败必为 `attempt #1` 且无标签;老通道那条 `attempt #1` + * 正好等于 ② 本身,被排除) + * ④ 其后第一条 `[relay-skip] ⛔ 新通道起不来` = 放弃点 t_skip + * ⑤ 其后第一条 `[relay-switch]` = 样本终点 t_switch + * + * 四段: + * **检测** = ② − ①(graceful burst 窗口地板,实测 15.0–16.6 s) + * **首试延迟** = ③ − ②(tick 相位 + 拨号耗时,实测 0.4–3.0 s) + * **白等** = ④ − ③(**实测**;作为对照也已知修前它 ≡ `upTimeoutMs`(12 s)) + * **建连** = ⑤ − ④ + * 自洽校验:检测 + 首试延迟 + 白等 + 建连 ≡ ⑤ − ①(≤ 0.05 s)。 + * 🔴 **两套签名两种判据**(同一个脚本同时认): + * · **修前签名**(白等 ≥ 0.9 × `upTimeoutMs`):`检测 + 白等 < 27.0 s` ⇒ 分解被证伪(§1.2 可证伪条款); + * · **修后签名**(白等 只剩"首败 → 放弃"):`白等 ≤ 1.0 s` 且 `总长 ≤ RELAY_FAILOVER_DEADLINE_MS`。 + * ═══════════════════════════════════════════════════════════════════════════ */ + if (argv.includes('--trace')) { + const argOf = (name) => { + const i = argv.indexOf(name) + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined + } + const since = argOf('--since') ?? '-6h' + const upTimeoutMs = r.num('RELAY_FAILOVER_UP_TIMEOUT_MS') + const raw = ssh( + sshPort, + h47, + `o=$(journalctl -u ${managerUnit} --since ${since} -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` + + `printf '%s\\n' "$o" | grep -E 'relay-client. down|relay-skip|relay-switch|relay-failover' || true`, + sshTo, + ) + const { err, lines } = splitErr(raw) + if (err !== '') { + process.stderr.write(`❌ 窗口查询失败(判据不可信,⛔ 不给出 PASS/FAIL):${err}\n`) + return EXIT_FAIL + } + const rows = lines + .split('\n') + .map((l) => l.trim()) + .filter((l) => l !== '') + .map((l) => { + const i = l.indexOf(']: ') + return { t: Number.parseFloat(l.split(/\s+/)[0]), body: i < 0 ? l : l.slice(i + 3) } + }) + .filter((x) => Number.isFinite(x.t)) + const isBye = (b) => b.includes('[relay-client] down') && b.includes('(was up)') && b.includes('burst window') + const isBurstEnd = (b) => b.includes('[relay-client] down') && /attempt #\d+, retry in/.test(b) + const isSkip = (b) => b.includes('[relay-skip]') && b.includes('新通道起不来') + const isSwitch = (b) => b.includes('[relay-switch]') + /** + * **新候选客户端的第一条失败行**。 + * + * 为什么它是"白等"的可靠起点:`open()` 里的新客户端是**全新**实例(`attempts=0`、无 burst 窗口) + * ⇒ 它第一次拨号失败就 `attempts=1` 且**不带方括号标签** ⇒ 窗口内"最后一条不带标签的 + * `attempt #1`"就是它。⚠️ 老通道客户端在 burst 窗口耗尽时**也**会打一条 `attempt #1` —— + * 但那条正好**等于 burst 耗尽行**(在窗口之外,被排除),所以不会混淆。 + * ⇒ 白等 = `t(放弃/早退) − t(新候选首败)`,**修前修后同一口径**(⛔ 不依赖"白等 ≡ upTimeoutMs"这条 + * 只在修前成立的定义式 —— 序⑨ S7 之后它已被修掉)。 + */ + const isFirstTry = (b) => b.includes('[relay-client] down') && /\battempt #1, retry in/.test(b) + + const samples = [] + for (let i = 0; i < rows.length; i += 1) { + if (!isBye(rows[i].body)) continue + const tBye = rows[i].t + const idxOf = (from, pred) => { + for (let k = from; k < rows.length; k += 1) if (pred(rows[k].body)) return k + return -1 + } + const iBurst = idxOf(i + 1, isBurstEnd) + const iSkip = idxOf(i + 1, isSkip) + const iSw = idxOf(i + 1, isSwitch) + if (iBurst < 0 || iSkip < 0 || iSw < 0 || !(iBurst < iSkip && iSkip < iSw)) continue + let iFirst = -1 + for (let k = iBurst + 1; k < iSkip; k += 1) if (isFirstTry(rows[k].body)) iFirst = k + const tBurst = rows[iBurst].t + const tSkip = rows[iSkip].t + const tSw = rows[iSw].t + const detect = tBurst - tBye + const firstTry = iFirst < 0 ? null : rows[iFirst].t - tBurst + /** 白等:有"新候选首败"行 ⇒ **实测**;没有 ⇒ 回落修前的定义式 `upTimeoutMs`(标注 `waitSrc`)。 */ + const wait = iFirst < 0 ? upTimeoutMs / 1000 : tSkip - rows[iFirst].t + const waitSrc = iFirst < 0 ? '定义式' : '实测' + const connect = tSw - tSkip + const total = tSw - tBye + const mUn = /unhealthyForMs=(\d+)/.exec(rows[iSw].body) + const mAt = /attempts=(\d+)/.exec(rows[iSw].body) + /** 🔴 修前的签名 = 死候选**白等吃满** `upTimeoutMs`(0.9× 余量),此时"检测+白等"的可证伪地板才成立。 */ + const prefixed = waitSrc === '定义式' || wait >= 0.9 * (upTimeoutMs / 1000) + samples.push({ + tBye, + tBurstEnd: tBurst, + tFirstTry: iFirst < 0 ? null : rows[iFirst].t, + tSkip, + tSwitch: tSw, + detect, + firstTry, + wait, + waitSrc, + connect, + total, + identityErr: (iFirst < 0 ? NaN : 0) + detect + (firstTry ?? 0) + wait + connect - total, + unhealthyForMs: mUn === null ? null : Number.parseInt(mUn[1], 10), + switchAttempts: mAt === null ? null : Number.parseInt(mAt[1], 10), + prefixed, + falsified: prefixed && detect + wait < 27.0, + raw: [rows[i], rows[iBurst], ...(iFirst < 0 ? [] : [rows[iFirst]]), rows[iSkip], rows[iSw]].map( + (x) => x.body, + ), + }) + i = iSw + } + + const f = (x) => x.toFixed(3) + const checkMs = r.num('RELAY_FAILOVER_CHECK_MS') + /** + * **只判"单跳样本"**:四段分解描述的是「杀入口 ⇒ 首个候选死 ⇒ 切到第二台」这一跳。 + * 三条同时成立才算: + * ① **首试延迟** ∈ [0, `checkMs` + 3.2 s](= tick 相位 + 拨号耗时;实测修前 0.3–1.4 s、 + * 修后 0.3–5.0 s —— 修后多出来的就是"等到新客户端真拨一次失败"的拨号+重试耗时); + * ② 建连 ∈ [0, 10 s](真跨机建连实测 2.7–5.2 s ⇒ >10 s 一定是"后续候选链推进"); + * ③ `unhealthyForMs`(切流行自带)与四段总长之差 ≤ 3 s(同一起点的两个独立读数; + * 实测系统性偏移 0.7–2.2 s = 「BYE 行落盘」到「状态机记账」的差)。 + * ⛔ 不满足 = 判 `N/A`,**不许**把"候选链推进/回跳"错算成"白等/建连"。 + */ + const applicable = (s) => + s.firstTry !== null && + s.firstTry >= -0.05 && + s.firstTry <= checkMs / 1000 + 3.2 && + s.connect >= -0.05 && + s.connect <= 10 && + (s.unhealthyForMs === null || Math.abs(s.unhealthyForMs / 1000 - s.total) <= 3.0) + const single = samples.filter(applicable) + process.stdout.write( + `\n# 序⑨ --trace|目标 ${h47} · 单元 ${managerUnit}|窗口 --since ${since}|` + + `样本数 ${samples.length}(单跳 ${single.length})|白等 = 新候选首败 → 放弃(实测)` + + `|修前签名判定阈值 = 白等 ≥ ${(0.9 * (upTimeoutMs / 1000)).toFixed(1)}s\n`, + ) + process.stdout.write( + `# 样本 | 检测 | 首试延迟 | 白等[口径] | 建连 | 总 | 自洽 | unhealthyForMs | 检测+白等\n`, + ) + for (const s of samples) { + process.stdout.write( + `# ${s.tBye} | ${f(s.detect)} | ${f(s.firstTry ?? Number.NaN)} | ${f(s.wait)}[${s.waitSrc}] | ` + + `${f(s.connect)} | ${f(s.total)} | ` + + `${Math.abs(s.identityErr) <= 0.05 ? '✅' : `❌${f(s.identityErr)}`} | ${s.unhealthyForMs} | ` + + `${f(s.detect + s.wait)}${applicable(s) ? (s.falsified ? ' 🔴证伪' : '') : ' ⚪N/A'}\n`, + ) + } + for (const s of samples) { + const bad = [] + if (!applicable(s)) { + process.stdout.write( + `# ${s.tBye} N/A ⚪ 非单跳样本(首试延迟 ${f(s.firstTry ?? Number.NaN)} / 建连 ${f(s.connect)} ⇒ ` + + `归因到后续候选链,⛔ 不计入四段分解)\n`, + ) + continue + } + if (s.detect < 14.5) bad.push(`检测 ${f(s.detect)} < 15.0(burst 地板)`) + if (s.detect > 17.0) bad.push(`检测 ${f(s.detect)} > 17.0(burst 地板 + 容差)`) + if (Math.abs(s.identityErr) > 0.05) bad.push(`四段之和 ≠ 墙钟(差 ${f(s.identityErr)})`) + if (s.prefixed) { + /* 修前签名:白等被 `upTimeoutMs` 吃满 ⇒ 用 §1.2 的可证伪地板 */ + if (s.falsified) bad.push(`检测+白等 ${f(s.detect + s.wait)} < 27.0 ⇒ 🔴 分解被证伪`) + } else { + /* 修后签名:白等应当只剩"首败 → 放弃"这一小段 ⇒ 判据换成"白等 ≤ 1 s 且总长 ≤ deadline" */ + if (s.wait > 1.0) bad.push(`白等 ${f(s.wait)} > 1.0 s(死候选应提前失败)`) + if (s.total > deadlineMs / 1000) bad.push(`总长 ${f(s.total)} > deadline ${deadlineMs}ms`) + } + process.stdout.write( + `# ${s.tBye} ${bad.length === 0 ? `PASS ✅ ${s.prefixed ? '修前签名(白等吃满 upTimeoutMs)' : '修后签名(死候选提前失败)'}` : `FAIL ❌ ${bad.join(';')}`}\n` + + s.raw.map((l) => `# · ${l}\n`).join(''), + ) + } + if (samples.length === 0) { + process.stderr.write('❌ 窗口内无可切分的完整样本(⛔ 不给出 PASS/FAIL —— 先确认窗口里真有"杀中继 ⇒ 切流"两轮)\n') + return EXIT_FAIL + } + const outDir = path.join(process.cwd(), '_中间产物_待清理', 'seq9-trace') + fs.mkdirSync(outDir, { recursive: true }) + const outFile = argOf('--out') ?? path.join(outDir, `seq9-trace-${Math.floor(Date.now() / 1000)}.json`) + fs.writeFileSync(outFile, `${JSON.stringify({ since, h47, upTimeoutMs, samples }, null, 2)}\n`, 'utf8') + const det = single.map((s) => s.detect).sort((a, b) => a - b) + const wht = single.map((s) => s.wait).sort((a, b) => a - b) + const tot = single.map((s) => s.total).sort((a, b) => a - b) + const med = (xs) => (xs.length === 0 ? Number.NaN : xs[Math.floor(xs.length / 2)]) + const pre = single.filter((s) => s.prefixed).length + process.stdout.write( + `# 单跳样本统计(n=${single.length},其中修前签名 ${pre} / 修后签名 ${single.length - pre}):\n` + + `# 检测 中位 ${f(med(det))} / 最小 ${f(det[0] ?? Number.NaN)} / 最大 ${f(det[det.length - 1] ?? Number.NaN)}\n` + + `# 白等 中位 ${f(med(wht))} / 最小 ${f(wht[0] ?? Number.NaN)} / 最大 ${f(wht[wht.length - 1] ?? Number.NaN)}\n` + + `# 总长 中位 ${f(med(tot))} / 最小 ${f(tot[0] ?? Number.NaN)} / 最大 ${f(tot[tot.length - 1] ?? Number.NaN)}\n` + + `# 原始行已落盘:${outFile}\n`, + ) + const bad = single.filter( + (s) => Math.abs(s.identityErr) > 0.05 || s.falsified || (!s.prefixed && s.wait > 1.0), + ).length + return bad === 0 ? EXIT_OK : EXIT_FAIL + } + + /* ═══════════════════════════════════════════════════════════════════════════ + * 序⑨ · `--sample N`:**N 次"杀入口 ⇒ 切流"采样**(S2 基线 / S7 复测共用一套动作) + * + * 每轮的**归零序列**(缺一即读数不可比): + * ① 两台 relay `systemctl start` ② `systemctl restart dshs`(⇒ 通道回到目录首位 = 47) + * ③ 等 47 的 **relay 日志**出现新的 `AUTH OK host=ops/manager`(权威就绪读数) + * ④ 记 `t0` → `systemctl stop dshs-relay`(47)→ 等新的 `[relay-switch]`(≤ `DRILL_DETECT_BUDGET_MS`) + * 读数口径:**切换耗时 = `waitSwitch` 的墙钟**(含 `DRILL_POLL_MS` 的 0–`pollMs` 量化误差, + * 所以每轮**必须记录当时 `DRILL_POLL_MS`**;⚠️ 改过 `DRILL_POLL_MS` 前后的读数**不可混比**)。 + * 四段分解另由 `--trace` 从 journal 时间戳复算(⛔ 与这里的读数不互相替代)。 + * ⛔ 不施加任何演练 env 覆盖(尤其**不许把冷却归零** —— 归零连带废掉"失败候选必须被排除", + * 上单 §8.8-4 已实测其自锁后果;演练期要缩短冷却**只走 `DRILL_COOLDOWN_MS`**)。 + * ═══════════════════════════════════════════════════════════════════════════ */ + if (argv.includes('--sample')) { + const argOf = (name) => { + const i = argv.indexOf(name) + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined + } + const n = Number.parseInt(argOf('--sample') ?? String(r.num('DRILL_SAMPLE_N')), 10) + if (!Number.isFinite(n) || n < 1) { + process.stderr.write('❌ --sample 需要一个正整数(或用参数表 DRILL_SAMPLE_N)\n') + return EXIT_USAGE + } + const authCountSince = (target, epoch) => + splitErr( + ssh( + sshPort, + target, + `o=$(journalctl -u ${relayUnit} --since @${epoch} -o short-unix --no-pager 2>&1) || { printf 'JOURNALCTL-ERR %s\\n' "$o"; exit 0; }; ` + + `printf '%s\\n' "$o" | grep -c 'AUTH OK host=ops/manager' || true`, + sshTo, + ), + ).lines.trim() + + process.stdout.write( + `\n# 序⑨ --sample ${n}|DRILL_POLL_MS=${pollMs}ms(⚠️ 读数含 0–${pollMs}ms 量化误差)|` + + `deadline=${deadlineMs}ms|upTimeoutMs=${r.num('RELAY_FAILOVER_UP_TIMEOUT_MS')}ms\n` + + `# 轮 | AUTH就绪ms | 切换ms | ≤deadline | 目标 | 行原文\n`, + ) + const rounds = [] + try { + for (let i = 1; i <= n; i += 1) { + /* ① 归零:两台 relay 都起来 */ + await startRelay(h47) + await startRelay(h106) + /* ② 归零:重启 Manager ⇒ 通道回到目录首位(47) */ + const tRestart = Number.parseInt(await ssh(sshPort, h47, 'date +%s', sshTo), 10) + await ssh(sshPort, h47, `systemctl restart ${managerUnit}`, sshTo) + /* ③ 等"新的" AUTH OK(权威就绪读数) */ + const tAuth0 = Date.now() + let authed = '0' + for (;;) { + authed = await authCountSince(h47, tRestart) + if (authed !== '0' || Date.now() - tAuth0 >= detectBudgetMs) break + await sleep(1000) + } + const authMs = Date.now() - tAuth0 + /* ④ 杀入口 ⇒ 等切换 */ + const t0 = Number.parseInt(await ssh(sshPort, h47, 'date +%s', sshTo), 10) + await stopRelay(h47) + const sw = await waitSwitch(`@${t0}`, detectBudgetMs) + const last = sw.ok ? sw.lines.split('\n').slice(-1)[0] : '' + const tgt = sw.ok ? ((/->\s*(\S+?)((|$)/.exec(last) ?? [])[1] ?? '') : '' + const row = { + round: i, + authed, + authMs, + switchMs: sw.ok ? sw.ms : null, + withinDeadline: sw.ok ? sw.ms <= deadlineMs : false, + target: tgt, + err: sw.err, + line: last, + } + rounds.push(row) + process.stdout.write( + `# ${i}/${n} | ${authMs} | ${sw.ok ? sw.ms : 'N/A'} | ${sw.ok ? (row.withinDeadline ? '✅' : '❌') : '❌'} | ` + + `${tgt} | ${sw.err !== '' ? `❌窗口查询失败:${sw.err}` : last}\n`, + ) + /* 复原(下一轮的 ① 会再 start 一次,幂等) */ + await startRelay(h47) + await sleep(2_000) + } + } finally { + try { + await startRelay(h47) + await startRelay(h106) + } catch (err) { + process.stderr.write(`❌ 复原 relay 失败:${err.message}\n`) + } + } + const ok = rounds.filter((x) => x.withinDeadline && x.err === '') + process.stdout.write( + `\n# 采样结果:${ok.length}/${rounds.length} 在 deadline(${deadlineMs}ms) 内侧|` + + `切换耗时 min/中位/max = ${rounds.map((x) => x.switchMs).filter((x) => x !== null).sort((a, b) => a - b).join(' / ')}\n`, + ) + const outDir = path.join(process.cwd(), '_中间产物_待清理', 'seq9-trace') + fs.mkdirSync(outDir, { recursive: true }) + const outFile = argOf('--out') ?? path.join(outDir, `seq9-sample-${Math.floor(Date.now() / 1000)}.json`) + fs.writeFileSync(outFile, `${JSON.stringify({ pollMs, deadlineMs, rounds }, null, 2)}\n`, 'utf8') + process.stdout.write(`# 采样明细已落盘:${outFile}\n`) + return ok.length === rounds.length && rounds.length === n ? EXIT_OK : EXIT_FAIL + } + + /** + * ⚠️ **序⑧ 起本脚本会重启 Manager 单元**(1 次/幕 4 变体)—— 目的是给幕 4 一个**确定的前置** + * (重启 ⇒ 通道回到目录首位 = 47)并施加演练期 env 覆盖(D9)。`finally` 里**一律删覆盖**。 + */ + const DRILL_DROPIN = `/etc/systemd/system/${managerUnit}.service.d/zz-drill-override.conf` + /** 是否留下过演练 env 覆盖 ⇒ `finally` 据此决定要不要"删文件 + 重启"回到生产值。 */ + let drillEnvApplied = false + + /** + * 施加演练期 env 覆盖(D9):**只走 drop-in**。 + * + * 🔴 **为什么不改 `/etc/dshs.env`**:那份文件是**平台自己的配置**(600/root,09-13 起的既有内容), + * 演练去改它 = 把"演练"与"生产配置"耦合;drop-in 是**可整份删除**的独立单元 ⇒ 回滚 = `rm` 一行。 + * (本线既有规矩:env 改动一律走 drop-in —— cluster 配置就在 `dshs.service.d/cluster.conf`。) + * + * ⛔ **生产默认值一个都不动**:`RELAY_FAILOVER_COOLDOWN_MS` 在代码里的默认仍是 `300000`; + * 演练期要缩短只能通过**新键** `DRILL_COOLDOWN_MS`(它在参数表里,本函数只负责把它塞进 drop-in)。 + */ + const applyDrillEnv = (vars) => { + const keys = Object.keys(vars) + const body = + '# 序⑧ 演练期 env 覆盖 —— 由 scripts/overlay-failover-drill.cjs 自动生成\n' + + '# ⛔ 临时产物:删除本文件 + daemon-reload + restart 即回到生产值\n' + + '[Service]\n' + + keys.map((k) => `Environment="${k}=${vars[k]}"\n`).join('') + /** + * ⚠️ **heredoc 必须独占一行收尾**(`bodyEOF && cmd` 会让终止符变成 `bodyEOF && cmd` ⇒ 语法错误) + * ⇒ 这里用**换行分隔**而不是 `&&` 串。 + */ + const remote = + `mkdir -p "$(dirname ${DRILL_DROPIN})"\n` + + (keys.length === 0 ? `rm -f ${DRILL_DROPIN}\n` : `cat > ${DRILL_DROPIN} <<"EOF"\n${body}EOF\n`) + + `systemctl daemon-reload && systemctl restart ${managerUnit}\n` + ssh(sshPort, h47, remote, sshTo) + drillEnvApplied = keys.length > 0 + } + + /** + * 删掉演练覆盖并**重启** ⇒ **回到生产值**(运行中的进程也一起回)。 + * ⚠️ 只删文件不重启 = 进程里仍是旧 env ⇒ "回滚"没生效。 + */ + const clearDrillEnv = () => { + ssh( + sshPort, + h47, + `rm -f ${DRILL_DROPIN}\nsystemctl daemon-reload && systemctl restart ${managerUnit}\n`, + sshTo, + ) + drillEnvApplied = false + } + + /** 等门户 200(= Manager 起完了)。⚠️ 重启后前几秒必然拿不到 200,不是失败。 */ + const waitManagerReady = async (budgetMs) => { + const t0 = Date.now() + for (;;) { + let pc = '' + try { + pc = await portalCode() + } catch { + pc = '' + } + if (pc === '200') return true + if (Date.now() - t0 >= budgetMs) return false + await sleep(pollMs) + } + } + + /** 归零:重启 Manager 并等它**确实注册在 47**(重启 ⇒ 通道回到目录首位)。 */ + const normalizeManagerOn47 = async (budgetMs) => { + if ((await waitManagerReady(budgetMs)) === false) return false + const t0 = Date.now() + for (;;) { + const a47 = await lastManagerAuthOn(h47) + const a106 = await lastManagerAuthOn(h106) + if (a47 > a106) return true + if (Date.now() - t0 >= budgetMs) return false + await sleep(pollMs) + } + } + + process.stdout.write(`# 参数表=${resolveTablePath(argv)}\n`) + const hint = currentChannelHint() + /** + * **杀哪台 = 权威读数比对**(⛔ 原先硬编码只杀 47 ⇒ 单 §5-S7 的"杀 106"方向**从未被实测**; + * 也⛔ 不再用日志线索猜 —— 见 `currentChannelHint` 的两次踩坑记录)。 + * 两台时间戳相等或都读不到 ⇒ **无法唯一确定** ⇒ SKIP(⛔ 不把"杀错了台"算成产品失败)。 + */ + const match106 = r.need('DRILL_SWITCH_MATCH_106') + const t47 = await lastManagerAuthOn(h47) + const t106 = await lastManagerAuthOn(h106) + const killTarget = t47 > t106 ? h47 : t106 > t47 ? h106 : null + const killMatch = killTarget === h106 ? match106 : killedMatch + const otherTarget = killTarget === h47 ? h106 : h47 + process.stdout.write( + `# 前置:Manager 最近注册 epoch 47=${t47} / 106=${t106} ⇒ 实际被杀 = ` + + `${killTarget === null ? '(无法唯一确定 ⇒ SKIP)' : killTarget}` + + `|线索(仅人读)= ${hint === '' ? '(读不到)' : hint}\n`, + ) + + /** + * 幕 1 的**场景体**(序⑧ 抽成函数):断言只写这一份(⛔ 不许再抄第二份)。 + * ⚠️ 序⑩ 起 `--scene ctrl` 对照已被移除 ⇒ 本函数只有**一个**调用点(`all` / `1`); + * `suffix` 参数保留仅为结果行可读性(默认空串,不影响任何断言语义)。 + */ + const runScene1 = async (suffix = '') => { + { + if (killTarget === null) { + record( + `幕1-A 杀当前入口 ⇒ 在 deadline 内切到另一台${suffix}`, + 'SKIP', + `⚠️ 无法唯一确定 Manager 当前在哪台(47 最近注册=${t47} / 106=${t106})⇒ 杀任一台都可能是空操作,` + + `本轮不作判定(归零办法:重启 ${managerUnit} ⇒ 通道回到目录首位,其 relay 会留下新的 AUTH 行)`, + ) + } else { + const since = await sinceNow() + const before = await relayActive(killTarget) + await stopRelay(killTarget) + const after = await relayActive(killTarget) + process.stdout.write( + `# 幕1 前置:被杀 = ${killTarget}|relay ${before} ⇒ ${after}|窗口起 ${since}\n`, + ) + const hit = await waitSwitch(since, detectBudgetMs) + const last = hit.ok ? hit.lines.split('\n').slice(-1)[0] : '' + const to = hit.ok ? (/(->\s*)(\S+?)((|$)/.exec(last) ?? [])[2] : undefined + const sameTarget = to !== undefined && to.includes(killMatch) + const withinDeadline = hit.ok && hit.ms <= deadlineMs + /** + * 🔴 **无切换 ≠ 产品失败**:若同一窗口里判别器给出了"链里无其他候选"(D6 的原生证据), + * 那"不切换"就是**设计预期行为** ⇒ 记 **SKIP**(⛔ 不假装 PASS,也不误报 FAIL)。 + * 实测来源(序⑦ 第五轮):Manager 刚切到 106 后杀 106,唯一替代 47 **仍在 300 s 冷却窗内** + * ⇒ `[relay-skip] … 链里无其他候选(候选 3 条,排除 3 条)` 刷了 120 s。 + */ + const noCand = + !hit.ok && hit.err === '' + ? (splitErr(logSince(since, '[relay-skip]')) + .lines.split('\n') + .find((l) => l.includes('无其他候选')) ?? '') + : '' + record( + `幕1-A 杀当前入口 ⇒ 切到另一台(≠ 被杀入口)${suffix}`, + hit.err !== '' + ? 'FAIL' + : noCand !== '' + ? 'SKIP' + : hit.ok && !sameTarget + ? withinDeadline + ? 'PASS' + : 'FAIL' + : 'FAIL', + hit.err !== '' + ? `❌ 窗口查询失败(判据不可信,⛔ 非产品结论):${hit.err}` + : noCand !== '' + ? `⚠️ 窗口内无切换,但判别器给出「链里无其他候选」⇒ **D6 预期行为,⛔ 不算产品失败**(归零办法:等冷却期满再复跑):${noCand}` + : hit.ok + ? `被杀 ${killTarget}|耗时 ${hit.ms}ms(deadline ${deadlineMs}ms)|目标 ${to}` + + (sameTarget ? ' ❌ 目标仍是被杀入口' : '') + + (withinDeadline ? '' : ' ⚠️ **超出 deadline**') + + `|${last}` + : `被杀 ${killTarget}|❌ ${hit.ms}ms 内无 [relay-switch]、也无「无其他候选」证据`, + ) + const mgr = await ssh(sshPort, h47, `systemctl is-active ${managerUnit} || true`, sshTo) + record(`幕1-B Manager 自身仍存活(失败域分离)${suffix}`, mgr === 'active' ? 'PASS' : 'FAIL', `is-active=${mgr}`) + const pc = await portalCode() + record(`幕1-C 门户仍 200(业务面无人工干预恢复)${suffix}`, pc === '200' ? 'PASS' : 'FAIL', `http_code=${pc}`) + } + const rother = await relayActive(otherTarget) + record(`幕1-D 另一台(${otherTarget})的中继未被误动${suffix}`, rother === 'active' ? 'PASS' : 'FAIL', `is-active=${rother}`) + } + } + + try { + /* ═══ 幕 1:杀"当前所在那台"⇒ 必须在 deadline 内切到**另一台** ═══ */ + if (sceneArg === 'all' || sceneArg === '1') await runScene1() + + /* ═══ 幕 2:两台全杀 ⇒ 无切换、无静默回退(D6 / R11) ═══ */ + if (sceneArg === 'all' || sceneArg === '2') { + /** + * 🔴 **序⑩:幕 2 不再依赖上一幕的余波** —— 本幕语义是"两台全杀",但原实现**只停 106**: + * 幕 1 已把 Manager 的通道切到 47(或单跑本幕时活跃的本就是 47)⇒ 停的是**当前不用**的那台 + * ⇒ 通道没被打断 ⇒ 窗口内 0 行判别器 ⇒ **幕2-B 假红**(序⑨ 实测:Manager 120 s 零 relay 日志)。 + * ⇒ 修法 = **现场重读一次权威通道归属**(与幕 1 同一套 `lastManagerAuthOn` 比对),再把**两台都停掉** + * (幂等;已 inactive 的 `systemctl stop` 是空操作)⇒ 前置与"上一轮留下谁"彻底解耦。 + */ + const t47b = await lastManagerAuthOn(h47) + const t106b = await lastManagerAuthOn(h106) + const activeAtStart = t47b > t106b ? h47 : t106b > t47b ? h106 : null + const since = await sinceNow() + await stopRelay(h47) + await stopRelay(h106) + const a47 = await relayActive(h47) + const a106 = await relayActive(h106) + process.stdout.write( + `# 幕2 前置:47 relay=${a47} / 106 relay=${a106}(两台全杀)|` + + `本幕开始时活跃通道 = ${activeAtStart === null ? '(两读数相同 ⇒ 无法唯一确定)' : activeAtStart}` + + `(epoch 47=${t47b} / 106=${t106b})|窗口起 ${since}\n`, + ) + // ⚠️ 观察窗必须 ≥ **半开检测**时延(`2.5 × HB_SEC`),否则必然漏判(首轮实测踩到) + await sleep(detectBudgetMs) + const sw = splitErr(logSince(since, '[relay-switch]')) + const sk = splitErr(logSince(since, '[relay-skip]')) + const qErr = sw.err !== '' ? sw.err : sk.err + record( + '幕2-A 两台全挂 ⇒ 无新切换(⛔ 不切到空)', + qErr !== '' ? 'FAIL' : sw.lines === '' ? 'PASS' : 'FAIL', + qErr !== '' + ? `❌ 窗口查询失败(⛔ 空窗口不可当 PASS):${qErr}` + : sw.lines === '' + ? `0 行 [relay-switch](窗口 ${detectBudgetMs}ms)` + : `❌ 出现:${sw.lines.slice(0, 300)}`, + ) + record( + '幕2-B 留下「无候选 ⇒ 原地退避」的判别器证据(D6)', + qErr !== '' ? 'FAIL' : sk.lines !== '' ? 'PASS' : 'FAIL', + qErr !== '' + ? `❌ 窗口查询失败:${qErr}` + : sk.lines === '' + ? '❌ 无 [relay-skip] 行(静默失效没有判别器)' + : `首个:${sk.lines.split('\n')[0]}`, + ) + const pc = await portalCode() + record('幕2-C 门户仍 200(⛔ 不比现状更差 = R11 现场判据)', pc === '200' ? 'PASS' : 'FAIL', `http_code=${pc}`) + } + + /* ═══ 幕 3:只恢复 47 ⇒ 冷却期内不回跳;随后复原 ═══ */ + if (sceneArg === 'all' || sceneArg === '3') { + const since = await sinceNow() + await startRelay(h47) + await sleep(observeMs) + const s3 = splitErr(logSince(since, '[relay-switch]')) + record( + `幕3-A 恢复 47 后 ${observeMs}ms 内不回跳(冷却期内)`, + s3.err !== '' ? 'FAIL' : s3.lines === '' ? 'PASS' : 'FAIL', + s3.err !== '' + ? `❌ 窗口查询失败(⛔ 空窗口不可当 PASS):${s3.err}` + : s3.lines === '' + ? `0 行 [relay-switch](窗口 ${observeMs}ms < 冷却 ${r.num('RELAY_FAILOVER_COOLDOWN_MS')}ms)` + : `❌ 出现:${s3.lines.slice(0, 300)}`, + ) + } + + /* ═══ 幕 4(序⑧):**冷却语义拆分**的真机判据(E9 / 构 A) ═══ + * + * 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`alotbuy.com` + `relay-direct.alotbuy.com` + * 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 + * ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。 + * + * 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106 + * (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`alotbuy` = switched-away) + * → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。 + * + * ⚠️ 本幕**会重启 Manager**(为了①的确定性 + 施加演练期覆盖);`finally` 一律清覆盖并重启回生产值。 + */ + const runScene4 = async (variant) => { + const isB = variant === '4b' + const tag = isB ? '幕4b(豁免关+短冷却)' : '幕4' + /** ⛔ 序⑩ 起只剩两个变体:构 A(**零 env 覆盖**)与 4b(豁免关 + **非生产**短冷却 `DRILL_COOLDOWN_MS`)。 */ + const overlay = isB + ? { RELAY_FAILOVER_EXEMPT: '0', RELAY_FAILOVER_COOLDOWN_MS: String(drillCooldownMs) } + : {} + await applyDrillEnv(overlay) + /** + * ⚠️ **必须先确保两台 relay 都 active** —— 幕 4 的 ② 步要求"停 47 ⇒ 切到 **106**", + * 而 `--scene all` 里前面的**幕 2 已经把 106 停掉**、幕 3 只恢复 47 + * ⇒ 不补这一步,幕 4 会在"三条候选全死"下走成假失败(首轮实测踩到)。 + */ + const ensureRelay = async (target) => { + if ((await relayActive(target)) !== 'active') { + await startRelay(target) + process.stdout.write(`# ${tag} 前置:${target} ${relayUnit} 原为 inactive ⇒ 已拉起(${await relayActive(target)})\n`) + } + } + await ensureRelay(h47) + await ensureRelay(h106) + const on47 = await normalizeManagerOn47(detectBudgetMs) + process.stdout.write( + `# ${tag} 前置:env 覆盖=${JSON.stringify(overlay)}|Manager 归零到 47 = ${on47}` + + `(⚠️ 含一次 Manager 重启)\n`, + ) + if (!on47) { + record(`${tag}-0 Manager 未在窗口内归零到 47`, 'SKIP', '⛔ 状态未归零 ⇒ 不作产品判定') + return + } + + // ② 停 47 ⇒ 期待切到 106;**47 的两条候选分别按 open-failed / switched-away 进冷却** + const sinceA = await sinceNow() + await stopRelay(h47) + const hit1 = await waitSwitch(sinceA, detectBudgetMs) + record( + `${tag}-A 停 47 ⇒ 切到 106,且 47 的两条候选都进冷却(造出"候选池被耗干"的结构前提)`, + hit1.err !== '' ? 'FAIL' : hit1.ok ? 'PASS' : 'FAIL', + hit1.err !== '' + ? `❌ 窗口查询失败(判据不可信):${hit1.err}` + : hit1.ok + ? `耗时 ${hit1.ms}ms|${hit1.lines.split('\n').slice(-1)[0]}` + : `❌ ${hit1.ms}ms 内无 [relay-switch](窗口 ${detectBudgetMs}ms)`, + ) + + // ③ 恢复 47(**必须在 ④ 之前**:否则"回跳"会撞上一条真的不可用通道,判据失去意义) + await startRelay(h47) + + // ④ 停 106 ⇒ D6 现场 + const sinceB = await sinceNow() + await stopRelay(h106) + process.stdout.write(`# ${tag} ④ 已停 106(此刻 47 的两条候选仍在冷却)|窗口起 ${sinceB}\n`) + + if (isB) { + /* 幕 4b:**缺陷正面复现 + 时限**(豁免关、冷却被缩到 DRILL_COOLDOWN_MS) */ + await sleep(noSwitchObserveMs) + const sw = splitErr(logSince(sinceB, '[relay-switch]')) + const sk = splitErr(logSince(sinceB, '[relay-skip]')) + const qErr = sw.err !== '' ? sw.err : sk.err + const noCand = sk.lines.split('\n').find((l) => l.includes('无其他候选')) ?? '' + record( + `${tag}-A 关掉豁免 ⇒ **D6 现场**原样复现(判别器原文)`, + qErr !== '' ? 'FAIL' : noCand !== '' ? 'PASS' : 'SKIP', + qErr !== '' + ? `❌ 窗口查询失败:${qErr}` + : noCand !== '' + ? `判别器原文:${noCand}` + : '⚠️ 窗口内没拿到「链里无其他候选」行(可能是失效检测还没走完)⇒ 本项不作判定', + ) + record( + `${tag}-B 冷却未过期前**不切流**(= §8.8-4 缺陷本身;⛔ 这不是本单的产品失败)`, + qErr !== '' ? 'FAIL' : sw.lines === '' ? 'PASS' : 'FAIL', + qErr !== '' + ? `❌ 窗口查询失败:${qErr}` + : sw.lines === '' + ? `0 行 [relay-switch](观察窗 ${noSwitchObserveMs}ms < 演练冷却 ${drillCooldownMs}ms)` + : `❌ 出现:${sw.lines.slice(0, 300)}`, + ) + const hit3 = await waitSwitch(sinceB, drillCooldownMs + detectBudgetMs) + const last3 = hit3.ok ? hit3.lines.split('\n').slice(-1)[0] : '' + const m3 = hit3.ok ? (/->\s*(\S+?)((|$)/.exec(last3) ?? [])[1] : undefined + record( + `${tag}-C 冷却过期后**自然**切回 47 ⇒ 归因收敛到"冷却语义"而非方向逻辑(E9-b 归因)`, + hit3.err !== '' + ? 'FAIL' + : hit3.ok && m3 !== undefined && m3.includes('alotbuy.com') + ? 'PASS' + : 'FAIL', + hit3.err !== '' + ? `❌ 窗口查询失败:${hit3.err}` + : hit3.ok + ? `耗时 ${hit3.ms}ms(演练冷却 ${drillCooldownMs}ms)|目标 ${m3}|${last3}` + : `❌ ${hit3.ms}ms 内仍无切换(预算 ${drillCooldownMs + detectBudgetMs}ms)`, + ) + } else { + /* 幕 4(构 A):**必须在 deadline 内切回 47**,且带 `|豁免` 标记(= 47 当时确在冷却的直接证据) */ + const hit2 = await waitSwitch(sinceB, detectBudgetMs) + const last2 = hit2.ok ? hit2.lines.split('\n').slice(-1)[0] : '' + const m2 = hit2.ok ? (/->\s*(\S+?)((|$)/.exec(last2) ?? [])[1] : undefined + const to47 = m2 !== undefined && m2.includes('alotbuy.com') + const exemptMark = /|豁免 kind=(switched-away|open-failed) 剩 \d+ms/.test(last2) + const healthReason = last2.includes('原因:当前通道不健康') + if (hit2.ok) process.stdout.write(`# ${tag} ④ 后的切换原文:${last2}\n`) + record( + `${tag}-A **一跳豁免**把通道切回 47(|豁免 标记 = "47 当时确在冷却"的直接证据)`, + hit2.err !== '' ? 'FAIL' : hit2.ok && to47 && exemptMark ? 'PASS' : 'FAIL', + hit2.err !== '' + ? `❌ 窗口查询失败(判据不可信):${hit2.err}` + : hit2.ok + ? `耗时 ${hit2.ms}ms|目标 ${m2}|豁免标记=${exemptMark}` + + (to47 ? '' : ' ❌ 目标不是 47 那台') + + (exemptMark ? '' : ' ❌ 缺 `|豁免` 标记') + : `❌ ${hit2.ms}ms 内无 [relay-switch]`, + ) + record( + `${tag}-B 原因必须是 health 路径(E9③):原文含「原因:当前通道不健康」`, + hit2.err !== '' ? 'FAIL' : healthReason ? 'PASS' : 'FAIL', + healthReason ? '✅' : `❌ 原文:${last2.slice(0, 260)}`, + ) + record( + `${tag}-C 切换耗时 ≤ RELAY_FAILOVER_DEADLINE_MS`, + hit2.err !== '' ? 'FAIL' : hit2.ok && hit2.ms <= deadlineMs ? 'PASS' : 'FAIL', + `实测 ${hit2.ms}ms / deadline ${deadlineMs}ms` + + `(⚠️ 该口径**含失效检测时延**;§8.8-2 已登记其为临界项)`, + ) + } + await startRelay(h106) + } + + if (sceneArg === 'all' || sceneArg === '4') await runScene4('4') + if (sceneArg === '4b') await runScene4('4b') + } finally { + /* ⛔ 一律复原(失败也复原)—— 首轮踩过"停半路把 relay 留在停用态"。 */ + /** + * 序⑧:演练期的 env 覆盖**必须一并清掉**(否则 Manager 带着"豁免关 / 短冷却"继续跑 = + * 静默的配置漂移)。⚠️ 只删文件不够:**进程里仍是旧 env** ⇒ 必须跟着重启一次。 + */ + if (drillEnvApplied) { + try { + await clearDrillEnv() + const envv = await ssh(sshPort, h47, `systemctl show ${managerUnit} -p Environment | tr ' ' '\\n' | grep -c RELAY_FAILOVER || true`, sshTo) + process.stdout.write(`# 复原:演练 env 覆盖已删 + 已重启;残留 RELAY_FAILOVER_* 计数 = ${envv}(⚙️ 若为 0 说明回到生产值)\n`) + } catch (err) { + process.stderr.write(`❌ 清除演练 env 覆盖失败(🔴 请手工检查 ${DRILL_DROPIN}):${err.message}\n`) + } + } + const restore = async (target) => { + try { + if ((await relayActive(target)) !== 'active') { + await startRelay(target) + process.stdout.write(`# 复原:${target} ${relayUnit} ⇒ ${await relayActive(target)}\n`) + } + } catch (err) { + process.stderr.write(`❌ 复原 ${target} 失败:${err.message}\n`) + } + } + await restore(h47) + await restore(h106) + } + + const fails = results.filter((x) => x.verdict === 'FAIL') + const skips = results.filter((x) => x.verdict === 'SKIP') + process.stdout.write( + `\n# 结果:${results.length - fails.length - skips.length} PASS / ${skips.length} SKIP / ${fails.length} FAIL` + + (skips.length > 0 ? `;SKIP:${skips.map((f) => f.name).join(' / ')}` : '') + + (fails.length > 0 ? `;FAIL:${fails.map((f) => f.name).join(' / ')}` : '') + + '\n', + ) + return fails.length === 0 ? EXIT_OK : EXIT_FAIL +} + +main() + .then((code) => process.exit(code)) + .catch((err) => { + process.stderr.write(`❌ 演练异常:${err.stack ?? err.message}\n`) + process.exit(EXIT_FAIL) + }) diff --git a/scripts/overlay-holepunch.cjs b/scripts/overlay-holepunch.cjs new file mode 100644 index 0000000..1f1e32e --- /dev/null +++ b/scripts/overlay-holepunch.cjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序⑥ · S4 打洞可行性探测(⛔ 一次性脚本、不进产品路径)。 + * + * ## 口径(🔴 本单最重要的一条已定项) + * 本脚本测的是**「该网络能不能打洞」**(NAT 映射/过滤行为), + * ⛔ **不是**"本系统打洞成功率" —— 仓库全仓零 UDP/穿透代码(`src/` 无 dgram/STUN), + * 后者需要先有实现(见交接单_最小形态真机批次_20260917 §4.1-1)。 + * + * ## 两个角色 + * - `--observer`(**跑在 47**,唯一具备公网直连观察面的一端):绑两个 UDP 口 + * (两个口是**故意的** —— 同一个 socket 发向两个不同目的口,若两次看到的源口相同 ⇒ + * 该 NAT 是**端点无关映射**(cone 型),这正是可打洞的主判据),登记 `name → ip:port`; + * - `--probe`(跑在每个节点):同一个 socket 依次 `REG` 到两个观察口 → 拿到**自己的两次映射** + * → 向观察口要 `PEERS` 表 → 向每个对端的映射每 `--gap` ms 发 1 包共 `--rounds` 包, + * 全程收包 ⇒ 记录"收到了谁"。 + * + * ## 判据 + * - 任一方收到对方 ≥ 1 包 ⇒ 该**方向**可打洞;逐对两方向分别记; + * - `mappingX === mappingY` ⇒ 端点无关映射(cone);不等 ⇒ 对称型(打洞概率低); + * - **包到达但被本机防火墙拦掉**与"对端没发出来"在数据上同形 ⇒ 因此每次实验都带 + * **同机回环控制对**(47 的观察面自身既是 sender 也是 receiver,见 §8 的对照说明)。 + * + * 用法: + * node overlay-holepunch.cjs --observer --port-x 21100 --port-y 21101 --token --secs 90 + * node overlay-holepunch.cjs --probe --obs 47.77.182.89 --port-x 21100 --port-y 21101 \ + * --token --name w-dev --peers w-47u,w-106u + * + * @module scripts/overlay-holepunch + */ + +'use strict' + +const dgram = require('node:dgram') + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) continue + const k = a.slice(2) + const v = argv[i + 1] + if (v === undefined || v.startsWith('--')) out[k] = true + else { + out[k] = isNaN(Number(v)) || v === '' ? v : Number(v) + i++ + } + } + return out +} + +const args = parseArgs(process.argv.slice(2)) +const log = (s) => process.stderr.write(`[hp] ${s}\n`) +const out = (o) => require('node:fs').writeSync(1, `### RESULT ### ${JSON.stringify(o)}\n`) +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +// ─────────────────────────── observer ─────────────────────────── +function observer(portX, portY, token, secs) { + /** name → { X: 'ip:port', Y: 'ip:port' } */ + const reg = {} + const seen = [] + const socks = [] + const mk = (tag, port) => { + const s = dgram.createSocket('udp4') + s.on('error', (e) => log(`obs ${tag} error ${e.message}`)) + s.on('message', (msg, rinfo) => { + const src = `${rinfo.address}:${rinfo.port}` + const text = msg.toString('utf8') + if (!text.startsWith(token)) { + seen.push({ tag, src, text: text.slice(0, 40), at: Date.now() }) + return + } + const parts = text.split(' ') + const cmd = parts[1] + if (cmd === 'REG') { + const name = parts[2] + reg[name] = reg[name] || {} + reg[name][tag] = src + s.send(`${token} ACK ${tag} ${src}`, rinfo.port, rinfo.address) + } else if (cmd === 'PEERS') { + s.send(`${token} PEERS ${JSON.stringify(reg)}`, rinfo.port, rinfo.address) + } + }) + s.bind(port, '0.0.0.0', () => log(`observer ${tag} 绑 0.0.0.0:${port}`)) + socks.push(s) + } + mk('X', portX) + mk('Y', portY) + setTimeout(() => { + out({ role: 'observer', reg, unauthenticated: seen.slice(0, 20), unauthenticatedCount: seen.length }) + process.exit(0) + }, secs * 1000) +} + +// ─────────────────────────── probe ─────────────────────────── +async function probe(o) { + const token = o.token + const name = o.name + const peers = String(o.peers || '').split(',').filter((s) => s !== '') + const sock = dgram.createSocket('udp4') + const receipts = {} + const acks = {} + let peersTable = null + sock.on('error', (e) => log(`probe error ${e.message}`)) + sock.on('message', (msg, rinfo) => { + const text = msg.toString('utf8') + if (!text.startsWith(token)) return + const parts = text.split(' ') + const src = `${rinfo.address}:${rinfo.port}` + if (parts[1] === 'ACK') acks[parts[2]] = parts[3] + else if (parts[1] === 'PEERS') { + try { + peersTable = JSON.parse(text.slice(token.length + 7)) + } catch (e) { + log(`PEERS 解析失败:${e.message}`) + } + } else if (parts[1] === 'PUNCH') { + receipts[src] = (receipts[src] || 0) + 1 + } + }) + await new Promise((r) => sock.bind(0, '0.0.0.0', r)) + const localPort = sock.address().port + const to = (port) => new Promise((r) => sock.send(`${token} REG ${name}`, port, o.obs, r)) + + for (let i = 0; i < 5; i++) { + await to(o['port-x']) + await sleep(200) + } + for (let i = 0; i < 5; i++) { + await to(o['port-y']) + await sleep(200) + } + for (let i = 0; i < 10 && peersTable === null; i++) { + sock.send(`${token} PEERS ${name}`, o['port-x'], o.obs) + await sleep(300) + } + if (peersTable === null) { + out({ role: 'probe', name, localPort, error: 'no-peers-table', acks }) + process.exit(0) + } + const myX = (peersTable[name] || {}).X + const myY = (peersTable[name] || {}).Y + const targets = {} + for (const p of peers) { + const m = (peersTable[p] || {}).X + if (m === undefined) continue + targets[p] = m + } + const rounds = o.rounds || 10 + const gap = o.gap || 200 + const sent = {} + for (const [p, addr] of Object.entries(targets)) { + sent[p] = 0 + const [ip, port] = addr.split(':') + for (let i = 0; i < rounds; i++) { + sock.send(`${token} PUNCH ${name}`, Number(port), ip) + sent[p]++ + await sleep(gap) + } + } + await sleep(1500) + const byPeer = {} + for (const [p, addr] of Object.entries(targets)) { + byPeer[p] = { target: addr, sent: sent[p], received: receipts[addr] || 0 } + } + const other = Object.entries(receipts).filter(([a]) => !Object.values(targets).includes(a)) + out({ + role: 'probe', + name, + localPort, + mappingX: myX, + mappingY: myY, + mappingEndpointIndependent: myX !== undefined && myX === myY, + peers: byPeer, + receiptsFromUnexpected: other, + acks, + }) + process.exit(0) +} + +// ─────────────────────────── STUN(降级路径) ─────────────────────────── +/** + * 🔴 **为什么需要降级**:首选路径(47 上一次性 UDP 观察器)**实测不可用** —— + * 观察器零收包(连同机发出的包都收不到)⇒ 47 的**云安全组拦掉了 UDP 入站** + * (⛔ 不是 nft:`nft` 的 `input policy` = `accept`)。改走公网 STUN 做映射/过滤判定。 + * ⛔ 按 S4 要求,用本模式得到的一切结论**必须在 §8 标注"经第三方"**。 + */ +const MAGIC = 0x2112a442 +function stunRequest(sock, host, port, cb) { + const buf = Buffer.alloc(20) + buf.writeUInt16BE(0x0001, 0) + buf.writeUInt16BE(0, 2) + buf.writeUInt32BE(MAGIC, 4) + require('node:crypto').randomBytes(12).copy(buf, 8) + let done = false + const onMsg = (msg) => { + if (msg.length < 20 || msg.readUInt16BE(0) !== 0x0101) return + let off = 20 + const end = 20 + msg.readUInt16BE(2) + while (off + 4 <= end && off + 4 <= msg.length) { + const type = msg.readUInt16BE(off) + const len = msg.readUInt16BE(off + 2) + const val = msg.subarray(off + 4, off + 4 + len) + if ((type === 0x0020 || type === 0x0001) && val.length >= 8) { + const family = val[1] + const rawPort = val.readUInt16BE(2) + const p = type === 0x0020 ? rawPort ^ (MAGIC >>> 16) : rawPort + let ip + if (family === 1) { + ip = [...val.subarray(4, 8)].join('.') + if (type === 0x0020) { + const b = val.subarray(4, 8) + const m = Buffer.alloc(4) + m.writeUInt32BE(MAGIC, 0) + ip = [...b].map((x, i) => x ^ m[i]).join('.') + } + } + if (!done && ip !== undefined) { + done = true + sock.off('message', onMsg) + cb({ server: `${host}:${port}`, mapping: `${ip}:${p}`, attr: type === 0x0020 ? 'XOR-MAPPED' : 'MAPPED' }) + } + return + } + off += 4 + len + ((4 - (len % 4)) % 4) + } + } + sock.on('message', onMsg) + sock.send(buf, port, host) + setTimeout(() => { + if (!done) { + done = true + sock.off('message', onMsg) + cb({ server: `${host}:${port}`, error: 'no-response' }) + } + }, 4000) +} + +const writeLine = (s) => require('node:fs').writeSync(1, `${s}\n`) + +async function stun(o) { + const sock = dgram.createSocket('udp4') + await new Promise((r) => sock.bind(Number(o.bind || 0), '0.0.0.0', r)) + const localPort = sock.address().port + const receipts = {} + sock.on('message', (msg, rinfo) => { + const key = `${rinfo.address}:${rinfo.port}` + receipts[key] = (receipts[key] || 0) + 1 + writeLine(`RECV ${key} ${msg.length}B`) + }) + const servers = String(o.stun || '') + .split(',') + .filter((s) => s !== '') + .map((s) => { + const [h, p] = s.split(':') + return { h, p: Number(p) } + }) + const mappings = [] + for (const s of servers) { + // eslint-disable-next-line no-await-in-loop + const r = await new Promise((res) => stunRequest(sock, s.h, s.p, res)) + mappings.push(r) + writeLine(`MAPPING ${JSON.stringify(r)}`) + } + const nodes = [...new Set(mappings.filter((m) => m.mapping).map((m) => m.mapping))] + writeLine(`### MAPPING ### ${JSON.stringify({ name: o.name, localPort, mappings, distinctMappings: nodes })}`) + + const to = typeof o.to === 'string' ? o.to.split(',').filter((s) => s !== '') : [] + let sent = 0 + for (const target of to) { + const [ip, port] = target.split(':') + for (let i = 0; i < (o.rounds || 10); i++) { + sock.send(`${o.name || 'probe'} PUNCH`, Number(port), ip) + sent++ + // eslint-disable-next-line no-await-in-loop + await sleep(o.gap || 200) + } + writeLine(`SENT ${(o.rounds || 10)} -> ${target}`) + } + await sleep((o.listen || 20) * 1000) + out({ + role: 'stun', + name: o.name, + localPort, + mappings, + endpointIndependentMapping: nodes.length === 1, + distinctMappings: nodes, + punchedTo: to, + punchesSent: sent, + receipts, + via: 'third-party STUN(降级路径)', + }) + process.exit(0) +} + +if (args.observer) observer(args['port-x'], args['port-y'], args.token, args.secs || 90) +else if (args.probe) probe(args) +else if (args.stun) stun(args) +else { + process.stderr.write('usage: --observer --port-x N --port-y N --token T --secs S | --probe --obs H --port-x N --port-y N --token T --name N --peers a,b | --stun --name N [--bind P] --stun s1:p1,s2:p2 [--to ip:port] [--listen S]\n') + process.exit(2) +} diff --git a/scripts/overlay-jitter.cjs b/scripts/overlay-jitter.cjs new file mode 100644 index 0000000..218a7ec --- /dev/null +++ b/scripts/overlay-jitter.cjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序⑥ · S3 链路抖动与 RTT 口径校验(⛔ 一次性脚本、不进产品路径)。 + * + * ## 为什么需要它 + * 参数表把 `RELAY_RTT_W106 = 336 ms` 标成"实测",但它其实来自 `server.ts:810` 的 + * **心跳往返**(一次 WS 往返 + 应用层处理 + 验签)⇒ **不一定等于网络 RTT**。 + * 而 336 ms 目前是"跨云链路很差"的**唯一证据** ⇒ 若它是口径问题,后面所有 + * "跨云不可玩"的结论都要重判。本脚本做**三方对比**:ICMP / TCP 握手 / relay 心跳。 + * + * 用法: + * node overlay-jitter.cjs --icmp 106.54.21.172 --count 300 --interval 0.2 + * node overlay-jitter.cjs --tcp 106.54.21.172:22 --tcp-n 30 + * node overlay-jitter.cjs --icmp H --count 300 --tcp H:22 --tcp-n 30 # 两者一起跑 + * + * @module scripts/overlay-jitter + */ + +'use strict' + +const net = require('node:net') +const { spawnSync } = require('node:child_process') + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) continue + const k = a.slice(2) + const v = argv[i + 1] + if (v === undefined || v.startsWith('--')) out[k] = true + else { + out[k] = isNaN(Number(v)) || v === '' ? v : Number(v) + i++ + } + } + return out +} + +const args = parseArgs(process.argv.slice(2)) +const log = (s) => process.stderr.write(`[jit] ${s}\n`) +const out = (o) => process.stdout.write(`### RESULT ### ${JSON.stringify(o)}\n`) + +function pct(arr, p) { + if (arr.length === 0) return 0 + const s = [...arr].sort((a, b) => a - b) + return Math.round(s[Math.min(s.length - 1, Math.floor(s.length * p))] * 100) / 100 +} + +function icmp(host, count, interval) { + const win = process.platform === 'win32' + const argv = win + ? ['-n', String(count), '-w', '1000', host] + : ['-c', String(count), '-i', String(interval || 0.2), '-W', '1', host] + const r = spawnSync(win ? 'ping' : 'ping', argv, { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }) + const text = `${r.stdout || ''}` + const rtts = [] + const re = win ? /time[=<]([\d.]+)\s*ms/gi : /time=([\d.]+)\s*ms/gi + let m + while ((m = re.exec(text)) !== null) rtts.push(Number(m[1])) + if (rtts.length < 2) return { host, packets: rtts.length, error: 'no-rtt-samples', raw: text.slice(0, 300) } + const deltas = [] + for (let i = 1; i < rtts.length; i++) deltas.push(Math.abs(rtts[i] - rtts[i - 1])) + const avg = rtts.reduce((a, b) => a + b, 0) / rtts.length + const mdev = Math.sqrt(rtts.reduce((a, b) => a + (b - avg) ** 2, 0) / rtts.length) + return { + host, + packets: rtts.length, + sentExpected: count, + lossPct: Math.round(((count - rtts.length) / count) * 10000) / 100, + min: pct(rtts, 0), + avg: Math.round(avg * 100) / 100, + p50: pct(rtts, 0.5), + max: pct(rtts, 1), + mdev: Math.round(mdev * 100) / 100, + p95AbsDelta: pct(deltas, 0.95), + p50AbsDelta: pct(deltas, 0.5), + note_win: win ? 'windows ping:无 -i 间隔参数,实际约 1 包/秒' : undefined, + } +} + +function tcpHandshake(host, port, n) { + const rtts = [] + let left = n + return new Promise((resolve) => { + const one = () => { + const t = process.hrtime.bigint() + const s = net.connect(port, host) + const done = (ok) => { + if (ok) rtts.push(Number(process.hrtime.bigint() - t) / 1e6) + s.destroy() + if (--left <= 0) { + resolve({ + target: `${host}:${port}`, + samples: rtts.length, + min: pct(rtts, 0), + median: pct(rtts, 0.5), + p95: pct(rtts, 0.95), + max: pct(rtts, 1), + }) + return + } + setTimeout(one, 100) + } + s.setTimeout(5000) + s.on('connect', () => done(true)) + s.on('error', () => done(false)) + s.on('timeout', () => done(false)) + } + one() + }) +} + +async function main() { + const res = { host: require('node:os').hostname(), platform: process.platform } + if (args.icmp) res.icmp = icmp(String(args.icmp), args.count || 300, args.interval) + if (args.tcp) { + const [h, p] = String(args.tcp).split(':') + res.tcpHandshake = await tcpHandshake(h, Number(p || 22), args['tcp-n'] || 30) + } + out(res) + process.exit(0) +} + +main() diff --git a/scripts/overlay-keyring.cjs b/scripts/overlay-keyring.cjs new file mode 100644 index 0000000..56ac34a --- /dev/null +++ b/scripts/overlay-keyring.cjs @@ -0,0 +1,270 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序③:**密钥仪式工具**(一机一钥 + 信任根)。 + * + * 把 `lib/net/relay/identity.js` 的纯函数包成命令行,做四件事: + * 建根 / 建签名者 / 签发与吊销 / **恢复演练**。⛔ 它**自己不做任何校验决策** —— + * 判据全在模块里(`verify*`),本工具只是"拿私钥签个名、把结果落盘"的搬运工。 + * + * ## 四层与它们各自该在哪台机器上跑(⛔ 别搞混) + * | 命令 | 该在哪跑 | 为什么 | + * |---|---|---| + * | `init-root` | **离线**(本工作区开发机 / 离线介质) | 根只授权签名者;在线 = 单点被攻破即全网伪造 | + * | `init-signer` | **在线签名者**(现网 = 47) | 日常签发都在这台,频繁但爆炸半径小(根还能撤它) | + * | `init-node` | **每台节点** | 一机一钥;私钥永不出机器 | + * | `issue-grant` | 在线签名者 | 签发"某 hostId + 某节点公钥"的入网凭据 | + * | `sign-revocations` | 在线签名者 | 撤单台(⛔ 撤**签名者**是"根重签一份 SignerSet"的事) | + * | `verify-grant` | 任何地方 | 自检:凭据是不是受信签名者签的 | + * | `recover-root` | 离线 | **恢复演练**:从纸质恢复码重建根私钥,再签一份 SignerSet 验通 | + * + * ## 用法 + * ```bash + * node scripts/overlay-keyring.cjs init-root --dir /sec/dshs-root + * node scripts/overlay-keyring.cjs init-signer --key /etc/dshs/overlay-signer-key.pem + * node scripts/overlay-keyring.cjs sign-signerset --root-key --signers --network ops --out + * node scripts/overlay-keyring.cjs init-node --key /etc/dshs/node.key + * node scripts/overlay-keyring.cjs issue-grant --signer-key --network ops --host w-106 --node-key --out + * node scripts/overlay-keyring.cjs sign-revocations --signer-key --network ops --hosts a,b --out + * node scripts/overlay-keyring.cjs verify-grant --file --signer-pub [--host w-106 --network ops] + * node scripts/overlay-keyring.cjs recover-root --code --out [--expect-pub ] + * # 演练三判据:--expect-pub 比公钥; + * # 再给 --signers --network --issued-at ⇒ 用重建的根签 SignerSet 验通(判据②); + * # 再给 --expect-sig (原根对同一 doc 签出的)⇒ 逐字节比对(判据③) + * ``` + * + * 🔴 **所有落盘的私钥一律 `0600`**(`writeSecret`),且**stdout 只打印公钥 / 指纹** —— + * 私钥进 stdout 就会进终端历史、`journalctl`、CI 日志。 + * + * @module scripts/overlay-keyring + */ + +'use strict' + +const { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } = require('node:fs') +const { dirname } = require('node:path') + +const id = require('../lib/net/relay/identity.js') + +const [, , cmd, ...rest] = process.argv + +/** 解析 `--k v` 与开关 `--flag`。 */ +function parseArgs(argv) { + const out = { _: [] } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) { + out._.push(a) + continue + } + const key = a.slice(2) + const next = argv[i + 1] + if (next === undefined || next.startsWith('--')) { + out[key] = true + } else { + out[key] = next + i++ + } + } + return out +} + +const args = parseArgs(rest) + +function need(name) { + const v = args[name] + if (typeof v !== 'string' || v.trim() === '') { + throw new Error(`missing required --${name}(用法见本文件头部的表格)`) + } + return v.trim() +} + +/** 写**私钥**类文件:`0600` + 同目录临时文件 + `rename`(不留半成品,也不留宽权限)。 */ +function writeSecret(file, text, logAction) { + mkdirSync(dirname(file), { recursive: true }) + const tmp = `${file}.tmp` + writeFileSync(tmp, text, { mode: 0o600 }) + renameSync(tmp, file) + chmodSync(file, 0o600) + process.stdout.write(`✓ ${logAction}:${file}(0600)\n`) +} + +/** 写**公开**物(签名文档 / 公钥):`0644` —— 它们本来就要被分发到每台机器。 */ +function writePublic(file, obj) { + mkdirSync(dirname(file), { recursive: true }) + const tmp = `${file}.tmp` + writeFileSync(tmp, `${JSON.stringify(obj, null, 2)}\n`, { mode: 0o644 }) + renameSync(tmp, file) + process.stdout.write(`✓ 已写出:${file}\n`) +} + +function nowIso() { + return new Date().toISOString() +} + +/** `--node-key` 既接受**文件**(推荐:公钥从私钥推,不另存一份)也接受裸 hex。 */ +function nodePublicKey(spec) { + const raw = spec.trim() + if (/^[0-9a-fA-F]{64}$/.test(raw)) return raw.toLowerCase() + const pem = readFileSync(raw, 'utf8') + return id.publicKeyOfPrivate(pem) +} + +const COMMANDS = { + /** 建**离线根**:私钥 + 公钥 + **纸质恢复码**(PKCS#8 DER 的 hex,可抄写)。 */ + 'init-root': () => { + const dir = need('dir') + const key = id.generateAuthorityKey() + writeSecret(`${dir}/root.key`, key.privateKeyPem, '根私钥') + writeFileSync(`${dir}/root.pub`, `${key.publicKey}\n`, { mode: 0o644 }) + // 恢复码 = PKCS#8 DER 的 hex(由 `recover-root` 反解回 PEM)⇒ 可手抄、可打印、可存密码管理器。 + const der = require('node:crypto').createPrivateKey(key.privateKeyPem).export({ type: 'pkcs8', format: 'der' }) + const groups = (der.toString('hex').match(/.{1,8}/g) ?? []).join(' ') + writeFileSync(`${dir}/root.recovery-code.txt`, `DSHS 覆盖网络 · 根密钥恢复码(第 1 份,纸质/离线保管)\n\n${groups}\n`, { + mode: 0o600, + }) + process.stdout.write(`✓ 根公钥(可公开,要下发到每台节点 / relay):${key.publicKey}\n`) + process.stdout.write(`✓ 根指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`) + process.stdout.write(`✓ 恢复码:${dir}/root.recovery-code.txt(0600,**请抄到纸或另存离线介质**)\n`) + }, + + /** 建**在线签名者**(每台管理员设备一把)。 */ + 'init-signer': () => { + const keyFile = need('key') + const key = id.generateAuthorityKey() + writeSecret(keyFile, key.privateKeyPem, '签名者私钥') + writeFileSync(`${keyFile}.pub`, `${key.publicKey}\n`, { mode: 0o644 }) + process.stdout.write(`✓ 签名者公钥(要写进 SignerSet 并由**根**签发):${key.publicKey}\n`) + process.stdout.write(`✓ 签名者指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`) + }, + + /** 建**节点密钥**(每机一把,一机一钥)。 */ + 'init-node': () => { + const keyFile = need('key') + const key = id.generateNodeKey() + writeSecret(keyFile, key.privateKeyPem, '节点私钥') + process.stdout.write(`✓ 节点公钥:${key.publicKey}\n`) + process.stdout.write(`✓ 节点指纹:${id.nodeKeyFingerprint(key.publicKey)}\n`) + }, + + /** **根**签一份签名者集合(授权签名者)—— ⛔ 只在离线跑。 */ + 'sign-signerset': () => { + const doc = { + version: 1, + network: need('network'), + // `--issued-at` 可选:**恢复演练**要靠它把时间钉死,才谈得上"签名逐字节相同"。 + issuedAt: typeof args['issued-at'] === 'string' && args['issued-at'] !== '' ? args['issued-at'] : nowIso(), + signers: need('signers').split(',').map((s) => s.trim()).filter((s) => s !== ''), + } + const sig = id.signSignerSet(doc, readFileSync(need('root-key'), 'utf8')) + writePublic(need('out'), { doc, sig }) + // **自检**:签完立刻用根公钥验一遍 —— 签错比不签更危险(下游会以为"已经授权了")。 + const pub = args['root-pub'] ?? readFileSync(`${dirname(need('root-key'))}/root.pub`, 'utf8').trim() + const verdict = id.verifySignerSet(doc, sig, [pub]) + if (!verdict.ok) throw new Error(`自检失败:刚签的 SignerSet 验不过(${verdict.reason})`) + process.stdout.write(`✓ 自检通过(受信根验签 ok),签名者 ${doc.signers.length} 把\n`) + }, + + /** **签名者**签一份节点入网凭据。 */ + 'issue-grant': () => { + const doc = { + version: 1, + network: need('network'), + hostId: need('host'), + nodeKey: nodePublicKey(need('node-key')), + issuedAt: nowIso(), + expiresAt: args['expires-at'] ?? '', + } + const sig = id.signNodeGrant(doc, readFileSync(need('signer-key'), 'utf8')) + writePublic(need('out'), { doc, sig }) + process.stdout.write(`✓ 已签发:${doc.network}/${doc.hostId} 节点指纹=${id.nodeKeyFingerprint(doc.nodeKey)}\n`) + }, + + /** **签名者**签一份吊销清单(撤单台)。 */ + 'sign-revocations': () => { + const doc = { + version: 1, + network: need('network'), + issuedAt: nowIso(), + hosts: (args.hosts ?? '').split(',').map((s) => s.trim()).filter((s) => s !== ''), + nodeKeys: (args['node-keys'] ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((s) => nodePublicKey(s)), + } + const sig = id.signRevocations(doc, readFileSync(need('signer-key'), 'utf8')) + writePublic(need('out'), { doc, sig }) + process.stdout.write(`✓ 已签发吊销清单:hosts=[${doc.hosts.join(',')}] nodeKeys=${doc.nodeKeys.length}\n`) + }, + + /** 自检:某份凭据是不是受信签名者签的(签发后立刻跑一次)。 */ + 'verify-grant': () => { + const raw = JSON.parse(readFileSync(need('file'), 'utf8')) + const verdict = id.verifyPeerGrant(raw.doc, raw.sig, { + trustedSignerKeys: need('signer-pub').split(',').map((s) => s.trim()), + network: args.network, + hostId: args.host, + }) + if (!verdict.ok) { + process.stderr.write(`✗ 验签失败:${verdict.reason}\n`) + process.exit(1) + } + process.stdout.write(`✓ 验签通过:${verdict.doc.network}/${verdict.doc.hostId} 指纹=${id.nodeKeyFingerprint(verdict.doc.nodeKey)}\n`) + }, + + /** + * **根密钥恢复演练**:从纸质恢复码重建根私钥(⛔ 不用原文件),再签一份 SignerSet 并验通。 + * + * 判据 = **三件都成立**才算过:① 恢复码能重建出**同一把**公钥 ② 用它签出的 SignerSet + * 被原根公钥验通 ③ 与原私钥签出的签名**逐字节相同**(Ed25519 是确定性的 —— 这条让 + * "看起来恢复了其实不是同一把钥匙"无处藏身)。 + */ + 'recover-root': () => { + const hex = need('code').replace(/\s+/g, '') + const der = Buffer.from(hex, 'hex') + const pem = require('node:crypto') + .createPrivateKey({ key: der, format: 'der', type: 'pkcs8' }) + .export({ type: 'pkcs8', format: 'pem' }) + writeSecret(need('out'), pem, '重建出的根私钥') + const pub = id.publicKeyOfPrivate(pem) + process.stdout.write(`✓ 重建出的根公钥:${pub}\n`) + process.stdout.write(`✓ 根指纹:${id.nodeKeyFingerprint(pub)}\n`) + const expect = typeof args['expect-pub'] === 'string' ? args['expect-pub'].trim() : '' + if (expect !== '') { + if (pub !== expect) throw new Error('⛔ 重建出的公钥与期望不符 ⇒ 恢复码不是这把根的') + process.stdout.write('✓ [判据①] 与期望根公钥逐字节一致\n') + } + // 判据② ③:只比公钥不够("看起来一致"),要比**签名逐字节相同**(Ed25519 是确定性的) + // 才能证明"重建出来的就是同一把钥匙"。传入 `--signers` 时用重建的私钥签一份 SignerSet: + if (typeof args.signers === 'string' && args.signers.trim() !== '') { + const signers = args.signers.split(',').map((s) => s.trim()).filter((s) => s !== '') + const doc = { version: 1, network: need('network'), issuedAt: need('issued-at'), signers } + const sig = id.signSignerSet(doc, pem) + const anchor = expect !== '' ? expect : pub + const verdict = id.verifySignerSet(doc, sig, [anchor]) + if (!verdict.ok) throw new Error(`⛔ [判据②] 重建的根签出的 SignerSet 验不过(${verdict.reason})`) + process.stdout.write(`✓ [判据②] 重建的根签出的 SignerSet(${signers.length} 把)经原根公钥验签通过\n`) + const wantSig = typeof args['expect-sig'] === 'string' ? args['expect-sig'].trim() : '' + if (wantSig !== '') { + if (sig !== wantSig) throw new Error('⛔ [判据③] 签名与原根私钥签出的不一致 ⇒ 重建出的不是同一把钥匙') + process.stdout.write('✓ [判据③] 签名与原根私钥签出的逐字节相同\n') + } + } + }, +} + +function main() { + const fn = COMMANDS[cmd] + if (fn === undefined) { + process.stderr.write(`unknown command: ${String(cmd)}\n可用:${Object.keys(COMMANDS).join(' | ')}\n`) + process.exit(2) + } + fn() +} + +try { + main() +} catch (err) { + process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +} diff --git a/scripts/overlay-probe.cjs b/scripts/overlay-probe.cjs new file mode 100644 index 0000000..6c89449 --- /dev/null +++ b/scripts/overlay-probe.cjs @@ -0,0 +1,617 @@ +#!/usr/bin/env node +/** + * 覆盖网络 · **观测最小集**探针 + * + * ## 为什么是"一条命令 + 一个退出码" + * `交接单_relay落地R2-R4` 的教训原文是「**静默失效靠判别器定位**」。要让判别器能被**脚本** + * (而不是人读日志)用,就必须有「一条命令出 PASS/FAIL」的入口 —— 本文件就是那个入口: + * + * cd "E:/ProgramData/AI技能/aliyun-dsh-server" && node "D:/github/dsh_shenxian/scripts/overlay-probe.cjs" + * + * - **退出码**:全绿 `0` / 任一红 `1` / 用法或取数失败 `2`(可直接被 automation 消费) + * - **输出**:≤ 12 行(每行 = 一条指标);红的条目**另外**写到 stderr,并**指名**是哪个 ID + * - **阈值**:⛔ **一个都不许硬编码** —— 全部从 `参数表_覆盖网络` 的 `` | `KEY` | 值 | `` 行读出来。 + * 缺键 ⇒ **报错退出**(⛔ 绝不用默认值静默兜底 —— 那正是"观测形同虚设"的成因)。 + * + * ## 🔴 本文件的**零数字纪律**(判据 = 交接单 §6 E5) + * `grep -nE "[0-9]{3,}" scripts/overlay-probe.cjs` 必须**零命中** —— 连标识符名与注释里 + * 都不许出现"看起来像阈值"的数字(端口、节点名、日期一律走参数表或从取回的数据里取)。 + * 这样就不存在"脚本里藏着一个没人知道出处的常量"的可能。 + * + * ## 🆕 `OBS-11` 的**集合判据**(序⑪ 规划 / 序⑫ 执行) + * 旧口径 = 「**两个计数相等**」,有两处**结构性**缺陷: + * ⓐ 对实例/端点落点**在线态敏感** ⇒ 合法态被判红(**假红**); + * ⓑ 对「**一进一出**」替换式变化**不敏感** ⇒ 真变化被放过(**假绿**)。 + * 新口径 = **三集包含式**(差集逐条点名): + * + * required ⊆ actual // 缺 ⇒ FAIL(点名缺项) + * actual ⊆ required ∪ allowed ∪ ranges ∪ derived // 多 ⇒ FAIL(点名多出项) + * derived = { :

| p ∈ /status.endpoints[].localPort } // 唯一来源 = relay 自身 /status + * + * ⇒ `required` 抓"**消失**"、包含式抓"**新增**"、`derived` 让**合法动态落点**有名字 + * ⇒ 替换式变化(一进一出)**必然**被两条断言之一命中。 + * `LISTEN_COUNT` / `NFT_RULES` **已退役**(仅在参数表里留作对账),⛔ 本文件不得再引用它们。 + * + * ## 🧪 夹具模式(`--listen-fixture` / `--nft-fixture` / `--status-fixture`) + * 判据改造**必须自带"旧判据会放过、新判据能抓住"的实证**。夹具就是那个实证手段: + * 把远端原文喂进来 ⇒ **不 ssh**、零生产副作用;输出行首加 `⚠️ FIXTURE`(stderr 另标一次, + * ⛔ 防止被下游当成生产结论);此时非集合类指标记 `SKIP`(不参与退出码)。 + * ⛔ 夹具模式**必须**配 `--table <副本>`(避免误改生产参数表)。 + * + * ## 运行前置 + * - `cwd` = 工作区根(按 `TABLE_RE` 找参数表;也可 `--dir` / `--table`) + * - 本机能 `ssh` 到中继机(走 `~/.ssh/config` 别名;⚠️ 别名里配的端口可能陈旧 ⇒ 一律用表里的 `SSH_PORT`) + * + * ## ⛔ 它不做什么 + * 不做 dashboard、不引外部监控依赖、不开新端口、不写任何远端文件。**纯只读**。 + * + * @module scripts/overlay-probe + */ + +'use strict' + +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') + +/** 参数表文件名(⛔ 不写死日期数字:用正则匹配,避免脚本里出现像阈值的常量)。 */ +const TABLE_RE = /^参数表_覆盖网络_.+\.md$/ +/** 参数行:`` | `KEY` | 值 | … ``(只取前两列)。 */ +const KEY_RE = /^\|\s*`([A-Z0-9_]+)`\s*\|\s*([^|]*)\|/ +/** 观测阈值行:`` | `OBS-NN` | 指标 | 阈值 | 判据 | ``(阈值一律按 KEY 解析)。 */ +const OBS_RE = /^\|\s*`(OBS-[0-9]+)`\s*\|\s*([^|]*)\|/ +/** 阈值里允许出现的"键引用"形态(`` `KEY` ``)。 */ +const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/ +/** 区间形态:`host:lo-hi`(也容错只给端口段的形态,以及 `~` / en-dash 作分隔符)。 */ +const RANGE_HOST_RE = /^(.+):(\d+)\s*[-–~]\s*(\d+)$/ +/** 区间形态(只给端口段,主机由 `RELAY_BIND` 补)。 */ +const RANGE_BARE_RE = /^(\d+)\s*[-–~]\s*(\d+)$/ +/** `ss` 数据行的首列(`-l` ⇒ 全部是它;据此跳过表头与噪声行)。 */ +const SS_STATE = 'LISTEN' + +const EXIT_OK = 0 +const EXIT_FAIL = 1 +const EXIT_USAGE = 2 + +/* ─────────── 参数表装载 ─────────── */ + +function argOf(argv, name) { + const i = argv.indexOf(name) + return i >= 0 && argv[i + 1] !== undefined ? argv[i + 1] : undefined +} + +function resolveTablePath(argv) { + const explicit = argOf(argv, '--table') + if (explicit !== undefined) return explicit + const dir = argOf(argv, '--dir') ?? process.env.DSHS_OVERLAY_TABLE_DIR ?? process.cwd() + const hits = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n)) + if (hits.length !== 1) { + throw new Error(`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${hits.length} 个参数表(要求恰好 1 个)`) + } + return path.join(dir, hits[0]) +} + +/** 值 → 去掉 markdown 的 `**` 与反引号。 */ +function cleanValue(raw) { + return String(raw).replace(/[*`]/g, '').trim() +} + +function loadTable(file) { + const text = fs.readFileSync(file, 'utf8') + const params = new Map() + const obs = new Map() + for (const line of text.split(/\r?\n/)) { + const o = OBS_RE.exec(line) + if (o !== null) { + obs.set(o[1], cleanValue(o[2])) + continue + } + const k = KEY_RE.exec(line) + if (k !== null && !params.has(k[1])) { + // ⚠️ 先到先得:同一键在多处出现时以**第一处**(正表)为准,避免被"示例行"覆盖。 + params.set(k[1], cleanValue(k[2])) + } + } + return { file, params, obs } +} + +function makeReaders(table) { + const bad = [] + /** 取**字符串**参数;缺失/空 ⇒ 记账(最后统一报错退出,⛔ 不静默兜底)。 */ + const need = (key) => { + if (!table.params.has(key) || table.params.get(key) === '') { + bad.push(key) + return '' + } + return table.params.get(key) + } + /** 取**数值**参数:参数表里有的键名优先;否则按**字面量**(阈值表里可以直接写数)。 */ + const num = (keyOrRef) => { + const m = KEY_ONLY_RE.exec(keyOrRef) + let v + if (m !== null) v = need(m[1]) + else if (table.params.has(keyOrRef)) v = need(keyOrRef) + else v = String(keyOrRef).trim() + const n = Number(v) + if (v === '' || !Number.isFinite(n)) { + bad.push(`${keyOrRef}=${JSON.stringify(v)} 不是数`) + return Number.NaN + } + return n + } + return { need, num, bad } +} + +/* ─────────── 集合判据的取值/解析 ─────────── */ + +/** 逗号分隔 → Set(去空、去首尾空白;⛔ 保持原文,报错要点名)。 */ +function parseList(value) { + return new Set( + String(value) + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + ) +} + +/** + * 解析区间集合。`fallbackHost` 用于**只给端口段**的形态(如拨号池那个 `DIAL_POOL_BOUND`)。 + * 非法项**记账**而不是静默丢弃(丢项 = 白名单悄悄变窄 = 假红)。 + */ +function parseRanges(value, fallbackHost, bad) { + const out = [] + for (const item of String(value).split(',')) { + const t = item.trim() + if (t === '') continue + const withHost = RANGE_HOST_RE.exec(t) + if (withHost !== null) { + out.push({ host: withHost[1], lo: Number(withHost[2]), hi: Number(withHost[3]) }) + continue + } + const bare = RANGE_BARE_RE.exec(t) + if (bare !== null && fallbackHost !== undefined) { + out.push({ host: fallbackHost, lo: Number(bare[1]), hi: Number(bare[2]) }) + continue + } + bad.push(`区间 ${JSON.stringify(t)} 形态不合法`) + } + return out +} + +/** 单个 `host:port` 是否落在某个区间内。 */ +function inRanges(addr, ranges) { + const i = addr.lastIndexOf(':') + if (i <= 0) return false + const host = addr.slice(0, i) + const port = Number(addr.slice(i + 1)) + if (!Number.isFinite(port)) return false + return ranges.some((r) => r.host === host && port >= r.lo && port <= r.hi) +} + +function decodeB64(raw) { + if (raw === undefined || raw === '') return '' + return Buffer.from(String(raw), 'base64').toString('utf8') +} + +/** + * `ss -lntp` 原文 → 监听集合(`host:port` 字符串,**逐条保留原文** ⇒ 报错能点名)。 + * ⚠️ 从**右侧**第一个 `:` 拆 host/port —— `[::]:22` 必须拆对(左侧 `indexOf` 会拆成 `[`)。 + */ +function parseListenRaw(text) { + const set = new Set() + for (const line of String(text).split(/\r?\n/)) { + const f = line.trim().split(/\s+/) + if (f.length < 4 || f[0] !== SS_STATE) continue + const local = f[3] + const i = local.lastIndexOf(':') + if (i <= 0) continue + set.add(`${local.slice(0, i)}:${local.slice(i + 1)}`) + } + return set +} + +/** 从 `input` hook 可达的链(`family/table/chain`,含 `jump` 传递闭包)。 */ +function inputReachableChains(items) { + const keyOf = (o) => `${o.family}/${o.table}/${o.chain ?? o.name}` + const reached = new Set() + for (const it of items) { + if (it.chain !== undefined && it.chain.hook === 'input') reached.add(keyOf(it.chain)) + } + const rules = items.filter((it) => it.rule !== undefined).map((it) => it.rule) + let grew = true + while (grew) { + grew = false + for (const r of rules) { + if (!reached.has(keyOf(r))) continue + for (const e of r.expr ?? []) { + const target = e.jump !== undefined ? e.jump.target : undefined + if (typeof target !== 'string') continue + const k = `${r.family}/${r.table}/${target}` + if (!reached.has(k)) { + reached.add(k) + grew = true + } + } + } + } + return reached +} + +/** 把一条规则的表达式归一成 `:`(无端口匹配 ⇒ `:any`)。 */ +function ruleToToken(expr) { + let proto + let port + for (const e of expr ?? []) { + const m = e.match + if (m === undefined) continue + const pl = m.left !== undefined ? m.left.payload : undefined + if (pl === undefined || pl.field !== 'dport' || m.op !== '==') continue + if (typeof pl.protocol === 'string') proto = pl.protocol + const right = m.right + if (typeof right === 'number') port = String(right) + else if (right !== null && typeof right === 'object') { + if (Array.isArray(right.range)) port = `${right.range[0]}-${right.range[1]}` + else if (Array.isArray(right.set)) port = right.set.map((x) => `${x}`).join('+') + else if (typeof right.prefix === 'object' && right.prefix !== null) port = String(right.prefix.addr) + } + } + return `${proto ?? 'any'}:${port ?? 'any'}` +} + +/** `nft -j` 原文 → 入站 accept 集合。失败返回 `null`(⛔ 由调用方显式标 `text-fallback`,不许静默改判据)。 */ +function parseNftJsonRaw(text) { + let doc + try { + doc = JSON.parse(text) + } catch { + return null + } + const items = Array.isArray(doc.nftables) ? doc.nftables : null + if (items === null) return null + const reached = inputReachableChains(items) + const out = new Set() + for (const it of items) { + const r = it.rule + if (r === undefined) continue + if (!reached.has(`${r.family}/${r.table}/${r.chain}`)) continue + if (!(r.expr ?? []).some((e) => e.accept !== undefined)) continue + out.add(ruleToToken(r.expr)) + } + return out +} + +/** + * 退化路径:`nft list ruleset` 文本里抽 accept 行。 + * ⚠️ 必须**链感知** —— 否则 `FORWARD` 链上的 `accept`(docker 那几条)会被当成入站规则 ⇒ **假红**。 + * 做法:先按 `chain X {` / `}` 切出每链的规则行与是否 `hook input`,再沿 `jump` 取传递闭包 + * (⛔ 与 `-j` 路径同一判据,只是取数方式不同)。 + */ +function parseNftTextRaw(text) { + const chains = new Map() + let cur + for (const line of String(text).split(/\r?\n/)) { + const t = line.trim() + if (t === '' || t.startsWith('#')) continue + const open = /^chain\s+(\S+)\s*\{$/.exec(t) + if (open !== null) { + cur = open[1] + if (!chains.has(cur)) chains.set(cur, { hooked: false, rules: [] }) + continue + } + if (t === '}' || t === '};') { + cur = undefined + continue + } + if (cur === undefined) continue + const c = chains.get(cur) + if (/\bhook\s+input\b/.test(t)) c.hooked = true + // ⚠️ `policy accept;` **不算规则**(`accept` 后紧跟 `;` ⇒ 不构成"独立 token")。 + if (/(^|\s)accept(\s|$)/.test(t)) c.rules.push(t) + } + const reached = new Set() + for (const [name, c] of chains) if (c.hooked) reached.add(name) + let grew = true + while (grew) { + grew = false + for (const name of [...reached]) { + for (const rule of chains.get(name).rules) { + const j = /\bjump\s+(\S+)/.exec(rule) + if (j !== null && !reached.has(j[1])) { + reached.add(j[1]) + grew = true + } + } + } + } + const protoRe = /\b(tcp|udp|icmp|icmpv6|ip|ip6)\b/ + const dportRe = /dport\s+(\d+)/ + const out = new Set() + for (const name of reached) { + for (const rule of chains.get(name).rules) { + const head = rule.slice(0, rule.search(/(^|\s)accept(\s|$)/)) + const proto = protoRe.exec(head) + const dport = dportRe.exec(head) + out.add(`${proto === null ? 'any' : proto[1]}:${dport === null ? 'any' : dport[1]}`) + } + } + return out +} + +/* ─────────── 只读取数 ─────────── */ + +/** 一次 ssh:命令**作为单个 argv 元素**下发 ⇒ 远端 shell 解析引号,本地不过 shell。 */ +function ssh(port, target, command, timeoutMs) { + return execFileSync('ssh', ['-p', String(port), '-o', 'BatchMode=yes', target, command], { + encoding: 'utf8', + timeout: timeoutMs, + }).trim() +} + +/** + * 一次 ssh 取回全部远端只读事实(⛔ 压 ssh 次数 = 压成本)。 + * 🆕 序⑫:`ss` / `nft` **回传原文**(`base64 -w0`),归一化在本地做 —— + * 旧版只回 `wc -l` 的计数 ⇒ 判据只能比数字(假红/假绿的根源)。 + * ⚠️ `base64` 字母表无 `=`(除末尾填充)⇒ 与下面 `key=value` 的行解析兼容。 + */ +function remoteFacts(r, sshPort, target, peerLocalPort) { + const host = r.need('RELAY_BIND') + const cmd = [ + `echo "listenRaw=$(ss -lntp 2>/dev/null | base64 -w0)"`, + `echo "nftJsonRaw=$(nft -j list ruleset 2>/dev/null | base64 -w0)"`, + `echo "nftTextRaw=$(nft list ruleset 2>/dev/null | base64 -w0)"`, + `echo "instanceA=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${r.need('LOCAL_INSTANCE_PORT')}/)"`, + `echo "instanceB=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 http://${host}:${peerLocalPort}/)"`, + `echo "portal=$(curl -s -o /dev/null -w '%{http_code}' --http1.1 -H 'Host: ${r.need('PORTAL_HOST_HEADER')}' ${r.need('PORTAL_URL')})"`, + `echo "relayRss=$(ps -o rss= -p $(systemctl show -p MainPID --value dshs-relay))"`, + ].join('; ') + const out = new Map() + for (const line of ssh(sshPort, target, cmd, r.num('SSH_TIMEOUT_MS')).split(/\r?\n/)) { + const i = line.indexOf('=') + if (i > 0) out.set(line.slice(0, i), line.slice(i + 1).trim()) + } + return out +} + +function readFixture(file) { + if (!fs.existsSync(file)) throw new Error(`夹具不存在:${file}`) + return fs.readFileSync(file, 'utf8') +} + +/* ─────────── 主流程 ─────────── */ + +function usage() { + return ( + '用法:node scripts/overlay-probe.cjs [--table <参数表.md>] [--dir <目录>]\n' + + ' 🧪 夹具模式(⛔ 必须同时给 --table;不 ssh):\n' + + ' --listen-fixture --nft-fixture --status-fixture \n' + ) +} + +function main() { + const argv = process.argv.slice(2) + if (argv.includes('--help') || argv.includes('-h')) { + process.stdout.write(usage()) + return EXIT_OK + } + + const listenFx = argOf(argv, '--listen-fixture') + const nftFx = argOf(argv, '--nft-fixture') + const statusFx = argOf(argv, '--status-fixture') + const fixture = listenFx !== undefined || nftFx !== undefined || statusFx !== undefined + if (fixture && argOf(argv, '--table') === undefined) { + process.stderr.write('❌ 夹具模式必须配 --table <参数表副本>(避免误改生产参数表)\n') + return EXIT_USAGE + } + + let table + try { + table = loadTable(resolveTablePath(argv)) + } catch (err) { + process.stderr.write(`❌ 参数表装载失败:${err.message}\n`) + return EXIT_USAGE + } + const r = makeReaders(table) + + let status + let facts + if (fixture) { + process.stderr.write('⚠️ FIXTURE 本次为**夹具模式**:未连接任何远端,结论不得当生产判据\n') + try { + status = statusFx === undefined ? { endpoints: [] } : JSON.parse(readFixture(statusFx)) + } catch (err) { + process.stderr.write(`❌ 夹具装载失败(/status):${err.message}\n`) + return EXIT_USAGE + } + facts = new Map() + if (listenFx !== undefined) facts.set('listenRaw', Buffer.from(readFixture(listenFx), 'utf8').toString('base64')) + if (nftFx !== undefined) facts.set('nftJsonRaw', Buffer.from(readFixture(nftFx), 'utf8').toString('base64')) + } else { + const sshPort = r.num('SSH_PORT') + const target = r.need('SSH_TARGET_47') + // 取 `/status`(观测的**主数据源**)。独立一次 ssh:对端实例面在中继机上的回环落点口号**要靠它**。 + try { + status = JSON.parse(ssh(sshPort, target, `curl -s ${r.need('RELAY_STATUS_URL')}`, r.num('SSH_TIMEOUT_MS'))) + } catch (err) { + process.stderr.write(`❌ 取 /status 失败:${err.message}\n`) + return EXIT_USAGE + } + const preEps = Array.isArray(status.endpoints) ? status.endpoints : [] + const prePeer = preEps.find((e) => e.port === r.num('PEER_INSTANCE_PORT')) + const peerLocalPort0 = prePeer === undefined ? r.num('PEER_INSTANCE_PORT') : prePeer.localPort + try { + facts = remoteFacts(r, sshPort, target, peerLocalPort0) + } catch (err) { + process.stderr.write(`❌ 远端只读取数失败:${err.message}\n`) + return EXIT_USAGE + } + } + + const counters = status.counters ?? {} + const cap = status.capacity ?? {} + const eps = Array.isArray(status.endpoints) ? status.endpoints : [] + const peerEp = eps.find((e) => e.port === r.num('PEER_INSTANCE_PORT')) + const peerLabel = peerEp === undefined ? '对端' : peerEp.hostId + const peerLocalPort = peerEp === undefined ? r.num('PEER_INSTANCE_PORT') : peerEp.localPort + + const relayBind = r.need('RELAY_BIND') + const required = parseList(r.need('LISTEN_REQUIRED')) + const allowed = parseList(r.need('LISTEN_ALLOWED')) + const ranges = parseRanges(r.need('LISTEN_ALLOWED_RANGES'), relayBind, r.bad) + // 拨号池**不另立键**(⛔ 避免两处漂移):复用既有 `DIAL_POOL_BOUND`,主机取 `RELAY_BIND`。 + ranges.push(...parseRanges(r.need('DIAL_POOL_BOUND'), relayBind, r.bad)) + const nftAllowed = parseList(r.need('NFT_ALLOW_INBOUND')) + // `derived` 的**唯一来源** = relay 自身 `/status` 的端点回环落点(动态值 ⇒ ⛔ 不许写死进参数表)。 + const derived = new Set(eps.map((e) => `${relayBind}:${e.localPort}`)) + + if (r.bad.length > 0) { + process.stderr.write(`❌ 参数表缺键/坏值:${r.bad.join(' , ')}\n`) + return EXIT_USAGE + } + + const codeSet = r.need('PROBE_CODE_SET').split(',').map((s) => Number(s.trim())) + const rows = [] + /** + * 夹具模式下非集合类指标无法取证 ⇒ 记 SKIP(⛔ 不参与退出码,否则"先红后绿"表达不出来)。 + * ⚠️ `judged = true` 的行**仍按真实判据出 PASS/FAIL** —— `OBS-11` 就是它, + * 否则夹具模式恒绿 ⇒ 整个"假绿实证"就假了。 + */ + const add = (id, ok, text, judged = false) => + rows.push(fixture && judged !== true ? { id, ok: true, skip: true, text } : { id, ok, text }) + + if (!fixture) { + add('OBS-01', Number(cap.used) >= r.num('MIN_HOSTS'), `在册节点 used=${cap.used} (阈值 ≥ ${r.num('MIN_HOSTS')})`) + add( + 'OBS-02', + Number(cap.max) === r.num('RELAY_MAX_HOSTS') && Number(cap.free) === Number(cap.max) - Number(cap.used), + `capacity max=${cap.max} used=${cap.used} free=${cap.free} (阈值 max=${r.num('RELAY_MAX_HOSTS')}, free=max-used)`, + ) + add( + 'OBS-03', + counters.identityRequired === true && Number(counters.trustedSigners) >= r.num('MIN_TRUSTED_SIGNERS'), + `identityRequired=${counters.identityRequired} trustedSigners=${counters.trustedSigners} (阈值 ≥ ${r.num('MIN_TRUSTED_SIGNERS')})`, + ) + add( + 'OBS-04', + Number(counters.identityOk) >= r.num('MIN_IDENTITY_OK'), + `identityOk=${counters.identityOk} (阈值 ≥ ${r.num('MIN_IDENTITY_OK')})`, + ) + add( + 'OBS-05', + Number(counters.revokedHosts) <= r.num('MAX_REVOKED_HOSTS'), + `revokedHosts=${counters.revokedHosts} (阈值 ≤ ${r.num('MAX_REVOKED_HOSTS')})`, + ) + const dialKeys = ['dial', 'dialDenied', 'dialFailed'] + add( + 'OBS-06', + dialKeys.every((k) => typeof counters[k] === 'number'), + `判别器 ${dialKeys.map((k) => `${k}=${counters[k]}`).join(' ')} (必须都是 number)`, + ) + add( + 'OBS-07', + Number(counters.authFailed) <= r.num('MAX_AUTH_FAILED'), + `authFailed=${counters.authFailed} authed=${counters.authed} (阈值 ≤ ${r.num('MAX_AUTH_FAILED')})`, + ) + add( + 'OBS-08', + eps.length > 0 && eps.every((e) => e.online === true), + `端点表 ${eps.length} 条 / 离线 ${eps.filter((e) => e.online !== true).length} 条`, + ) + const instanceA = Number(facts.get('instanceA')) + const instanceB = Number(facts.get('instanceB')) + add( + 'OBS-09', + codeSet.includes(instanceA) && codeSet.includes(instanceB), + `实例面 本机:${r.need('LOCAL_INSTANCE_PORT')}=${facts.get('instanceA')} ${peerLabel}:${peerLocalPort}=${facts.get('instanceB')} (阈值 ∈ {${codeSet.join(',')}})`, + ) + add('OBS-10', Number(facts.get('portal')) === r.num('PORTAL_CODE'), `门户=${facts.get('portal')} (阈值 = ${r.num('PORTAL_CODE')})`) + } else { + for (const id of [ + 'OBS-01', + 'OBS-02', + 'OBS-03', + 'OBS-04', + 'OBS-05', + 'OBS-06', + 'OBS-07', + 'OBS-08', + 'OBS-09', + 'OBS-10', + ]) { + add(id, true, '夹具模式未取证') + } + } + + /* ── OBS-11:**集合判据**(三集包含式 + nft 入站 accept 白名单) ── */ + // 夹具模式下 `facts.listenRaw` 亦由夹具文件编码而来 ⇒ 两条路径同一份解析逻辑(⛔ 不写两套)。 + const actual = parseListenRaw(decodeB64(facts.get('listenRaw'))) + const missing = [...required].filter((a) => !actual.has(a)).sort() + // ⛔ 顺序:required → allowed → ranges → derived;只在**全不命中**时才进 `extra`。 + const extra = [...actual] + .filter((a) => !required.has(a) && !allowed.has(a) && !inRanges(a, ranges) && !derived.has(a)) + .sort() + + let acceptSet = new Set() + let nftNote = ' nft=none' + const nftJsonText = nftFx !== undefined ? readFixture(nftFx) : decodeB64(facts.get('nftJsonRaw')) + if (nftJsonText !== '') { + acceptSet = parseNftJsonRaw(nftJsonText) + nftNote = '' + if (acceptSet === null) { + // ⛔ 退化路径**必须显式标记**(不许静默改判据):`-j` 不可用时才走文本解析。 + acceptSet = parseNftTextRaw(nftFx !== undefined ? nftJsonText : decodeB64(facts.get('nftTextRaw'))) + nftNote = ' nft=text-fallback' + } + } else if (!fixture) { + process.stderr.write('❌ OBS-11 取不到 nft 规则集(`-j` 与文本两路都为空)\n') + return EXIT_USAGE + } + const nftExtra = [...acceptSet].filter((t) => !nftAllowed.has(t)).sort() + + // relay 的"只绑回环"不变量 —— 🆕 序⑫ 改为**本地从监听集合算**(旧版是远端 `grep -c`: + // ① 它按"整行含该口号"计数 ⇒ 连 peer 列都算进去,会**高估**;② 夹具模式下取不到 ⇒ NaN ⇒ 假红。 + // 现在:按 Local 列**精确**取端口 ⇒ 顺带把"`RELAY_PORT` 只出现在 `RELAY_BIND` 上"这条判据变成严格版。 + const relayPort = r.need('RELAY_PORT') + const portOf = (a) => a.slice(a.lastIndexOf(':') + 1) + const relayListenTotal = [...actual].filter((a) => portOf(a) === relayPort).length + const relayListenLoopback = [...actual].filter((a) => a === `${relayBind}:${relayPort}`).length + const ok11 = + missing.length === 0 && + extra.length === 0 && + nftExtra.length === 0 && + relayListenTotal === relayListenLoopback && + relayListenTotal > 0 + add( + 'OBS-11', + ok11, + `集合 必在 ${required.size} 允许 ${allowed.size} 区间 ${ranges.length} 派生 ${derived.size} 实际 ${actual.size} ` + + `多出 ${extra.length} 缺失 ${missing.length} |nft accept ${acceptSet.size} 多出 ${nftExtra.length}` + + `${nftNote} |relay 口绑定回环=${relayListenLoopback}/${relayListenTotal} 条`, + true, + ) + for (const a of missing) process.stderr.write(`OBS-11 缺失 ${a}\n`) + for (const a of extra) process.stderr.write(`OBS-11 多出 ${a}\n`) + for (const t of nftExtra) process.stderr.write(`OBS-11 nft 多出 ${t}\n`) + + if (!fixture) { + add( + 'OBS-12', + Number(facts.get('relayRss')) <= r.num('RELAY_RSS_MAX_KB'), + `relay RSS=${facts.get('relayRss')}KB (阈值 ≤ ${r.num('RELAY_RSS_MAX_KB')}KB)`, + ) + } else { + add('OBS-12', true, '夹具模式未取证') + } + + const prefix = fixture ? '⚠️ FIXTURE ' : '' + for (const row of rows) { + process.stdout.write(`${prefix}${row.skip === true ? 'SKIP' : row.ok ? 'PASS' : 'FAIL'} ${row.id} ${row.text}\n`) + } + const red = rows.filter((x) => x.skip !== true && !x.ok) + if (red.length > 0) { + process.stderr.write(`❌ ${red.length} 项红:${red.map((x) => x.id).join(' , ')}\n`) + return EXIT_FAIL + } + return EXIT_OK +} + +process.exit(main()) diff --git a/scripts/overlay-relaykey-add.cjs b/scripts/overlay-relaykey-add.cjs new file mode 100644 index 0000000..3c3949d --- /dev/null +++ b/scripts/overlay-relaykey-add.cjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序⑥ · relay HMAC 密钥表增删(**运维小工具**,⛔ 非产品路径)。 + * + * 为什么要有它:`/etc/dshs/relay-keys.json` 是**直接编辑会毁掉整张表**的那类文件 + * (键 = 逻辑名 `/`,值 = 64 hex;写坏一个字符 ⇒ relay 起动即抛、 + * 全部节点同时被拒)。所以增删都走这个工具:**先备份、再原子写、写完自校验**。 + * + * 用法: + * node scripts/overlay-relaykey-add.cjs --file /etc/dshs/relay-keys.json --name ops/w-dev + * node scripts/overlay-relaykey-add.cjs --file … --name ops/w-dev --secret <64hex> + * node scripts/overlay-relaykey-add.cjs --file … --name ops/w-dev --remove + * + * @module scripts/overlay-relaykey-add + */ + +'use strict' + +const { chmodSync, copyFileSync, readFileSync, renameSync, writeFileSync } = require('node:fs') +const { randomBytes } = require('node:crypto') + +const args = {} +const argv = process.argv.slice(2) +for (let i = 0; i < argv.length; i++) { + if (!argv[i].startsWith('--')) continue + const k = argv[i].slice(2) + const v = argv[i + 1] + if (v === undefined || v.startsWith('--')) args[k] = true + else { + args[k] = v + i++ + } +} +if (typeof args.file !== 'string' || typeof args.name !== 'string') { + process.stderr.write('usage: --file --name [--secret <64hex>] [--remove]\n') + process.exit(2) +} + +const table = JSON.parse(readFileSync(args.file, 'utf8')) +if (table === null || typeof table !== 'object' || Array.isArray(table)) { + throw new Error(`${args.file} 不是对象`) +} +const before = Object.keys(table) +copyFileSync(args.file, `${args.file}.bak-seq6-${Date.now()}`) + +if (args.remove === true) { + if (!(args.name in table)) { + process.stderr.write(`⚠ ${args.name} 不在表里(无需删)\n`) + } else { + delete table[args.name] + } +} else { + const secret = + typeof args.secret === 'string' ? args.secret.trim().toLowerCase() : randomBytes(32).toString('hex') + if (!/^[0-9a-f]{64}$/.test(secret)) throw new Error('secret 必须是 64 位 hex') + table[args.name] = secret +} + +const tmp = `${args.file}.tmp` +writeFileSync(tmp, `${JSON.stringify(table, null, 2)}\n`, { mode: 0o600 }) +renameSync(tmp, args.file) +chmodSync(args.file, 0o600) + +// 自校验:用产品代码自己的装载器读一遍(键名/密钥形状非法会在这里炸) +const { loadKeysFile } = require('../lib/net/relay/keys.js') +const parsed = loadKeysFile(args.file) +process.stdout.write( + `✓ ${args.file}:${before.length} → ${parsed.size} 条\n` + + ` 键:${[...parsed.keys()].join(', ')}\n` + + (args.remove === true ? '' : ` ${args.name} 的 secret(仅本次打印,⛔ 别进日志):${table[args.name]}\n`), +) diff --git a/scripts/overlay-wan.cjs b/scripts/overlay-wan.cjs new file mode 100644 index 0000000..6d69491 --- /dev/null +++ b/scripts/overlay-wan.cjs @@ -0,0 +1,357 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序⑥ · S2/S5 一次性载荷与探针(⛔ 不进产品路径、⛔ 不进 package.json 依赖)。 + * + * 为什么要有它:参数表 §3.5 的 `WAN_STEADY_THROUGHPUT` 与 §3.3 的 `PER_PLAYER_BW_LOCAL` + * 都是 `待测` —— 两端都拿不到"可读大响应"(无凭据时只回 24–68 B 的 401/404)。 + * 本脚本只做一件事:**造可控载荷 + 量稳态速率**,供人工写回参数表。 + * + * ## 四种角色 + * | 角色 | 跑在哪 | 干什么 | + * |---|---|---| + * | `--serve` | 被压的一端(106) | HTTP:`/blob?sec=N`(连续吐 N 秒)/`/blob?mb=N`/`POST /sink`(吞体) | + * | `--download` | 挤压的一端(47) | 连 relay 回环端点 → 丢前 `--drop` 秒 → 量 `--secs` 秒 ⇒ **被压端 → 挤压端** | + * | `--upload` | 挤压的一端(47) | 反向 POST 大流量(**尊重反压**)⇒ **挤压端 → 被压端** | + * | `--echo-serve` / `--players` | 106 / 47 | S5 合成玩家:长度前缀回显 + 多连接消息率扫描 | + * + * ## 口径(⛔ 别丢,写回参数表时要抄) + * - 速率 = **稳态段字节数 ÷ 稳态段秒数**(前 `--drop` 秒的字节**单独计数、不进分子**); + * - `--upload` 的速率**必须**在 `write()` 返回 false 时停下等 `drain` —— 否则量的是 Node 的 + * 内存缓冲,不是链路(这正是"看起来很快、其实全在本地队列里"这个假象的成因)。 + * + * 用法示例(远端): + * node overlay-wan.cjs --serve --port 19777 --secs 600 + * node overlay-wan.cjs --download --port --secs 30 --drop 3 + * node overlay-wan.cjs --upload --port --secs 30 --drop 3 --mb 0 + * + * @module scripts/overlay-wan + */ + +'use strict' + +const http = require('node:http') +const net = require('node:net') + +function parseArgs(argv) { + const out = {} + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + if (!a.startsWith('--')) continue + const k = a.slice(2) + const v = argv[i + 1] + if (v === undefined || v.startsWith('--')) out[k] = true + else { + out[k] = isNaN(Number(v)) ? v : Number(v) + i++ + } + } + return out +} + +const args = parseArgs(process.argv.slice(2)) +const log = (s) => process.stderr.write(`[wan] ${s}\n`) +const out = (obj) => process.stdout.write(`### RESULT ### ${JSON.stringify(obj)}\n`) + +const CHUNK = Buffer.alloc(64 * 1024, 0x41) + +// ─────────────────────────── serve ─────────────────────────── +function serve(port, secs) { + const server = http.createServer((req, res) => { + const u = new URL(req.url, 'http://x') + if (req.method === 'POST') { + // 吞体:只数不清 + let n = 0 + req.on('data', (c) => { + n += c.length + }) + req.on('end', () => { + res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' }) + res.end(`sink ${n}\n`) + log(`POST /sink ${n} B in ${Date.now() - t0} ms`) + }) + var t0 = Date.now() + return + } + if (u.pathname === '/small') { + res.writeHead(200, { 'content-type': 'text/plain', connection: 'close' }) + res.end('ok-0123456789-0123456789\n') + return + } + const sec = Number(u.searchParams.get('sec') || 0) + const mb = Number(u.searchParams.get('mb') || 0) + if (sec > 0) { + res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' }) + const start = Date.now() + let sent = 0 + const pump = () => { + while (Date.now() - start < sec * 1000) { + if (!res.write(CHUNK)) { + sent += CHUNK.length + res.once('drain', pump) + return + } + sent += CHUNK.length + } + res.end() + log(`GET /blob?sec=${sec} 送出 ${sent} B`) + } + pump() + return + } + res.writeHead(200, { 'content-type': 'application/octet-stream', connection: 'close' }) + const total = mb * 1024 * 1024 + let sent = 0 + const pump = () => { + while (sent < total) { + if (!res.write(CHUNK)) { + sent += CHUNK.length + res.once('drain', pump) + return + } + sent += CHUNK.length + } + res.end() + } + pump() + }) + server.listen(port, '127.0.0.1', () => log(`serve 127.0.0.1:${port} secs=${secs}`)) + if (secs > 0) setTimeout(() => process.exit(0), secs * 1000) +} + +// ─────────────────────── download / upload ─────────────────────── +/** 从 `buf` 里切掉 HTTP 头,返回剩下的体;找不到头返回 null。 */ +function splitHeader(buf) { + const i = buf.indexOf('\r\n\r\n') + if (i < 0) return null + return buf.subarray(i + 4) +} + +function download(port, secs, drop) { + const t0 = Date.now() + const sock = net.connect(port, '127.0.0.1') + let hdrDone = false + let tail = Buffer.alloc(0) + let ramp = 0 + let steady = 0 + sock.on('connect', () => { + sock.write( + `GET /blob?sec=${Math.ceil(secs + drop + 6)} HTTP/1.1\r\nHost: wan\r\nConnection: close\r\n\r\n`, + ) + }) + const finish = () => { + try { + sock.destroy() + } catch { + /* noop */ + } + out({ + mode: 'download', + port, + dropS: drop, + steadyS: secs, + rampBytes: ramp, + steadyBytes: steady, + steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10, + ms: Date.now() - t0, + }) + process.exit(0) + } + sock.on('data', (b) => { + if (!hdrDone) { + tail = Buffer.concat([tail, b]) + const body = splitHeader(tail) + if (body === null) return + hdrDone = true + b = body + } + const el = Date.now() - t0 + if (el < drop * 1000) ramp += b.length + else if (el < (drop + secs) * 1000) steady += b.length + else finish() + }) + sock.on('error', (e) => { + log(`sock error ${e.message}`) + finish() + }) + setTimeout(finish, (drop + secs + 20) * 1000) +} + +function upload(port, secs, drop) { + const t0 = Date.now() + const sock = net.connect(port, '127.0.0.1') + let ramp = 0 + let steady = 0 + let acc = 0 + let started = false + const head = + 'POST /sink HTTP/1.1\r\nHost: wan\r\nContent-Type: application/octet-stream\r\n' + + 'Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n' + const frame = Buffer.concat([ + Buffer.from((CHUNK.length).toString(16) + '\r\n'), + CHUNK, + Buffer.from('\r\n'), + ]) + const finish = () => { + try { + sock.destroy() + } catch { + /* noop */ + } + out({ + mode: 'upload', + port, + dropS: drop, + steadyS: secs, + rampBytes: ramp, + steadyBytes: steady, + steadyKBps: Math.round((steady / 1024 / secs) * 10) / 10, + ms: Date.now() - t0, + }) + process.exit(0) + } + const pump = () => { + const el = Date.now() - t0 + if (el >= (drop + secs) * 1000) return finish() + let ok = true + while (ok) { + ok = sock.write(frame) + if (el < drop * 1000) ramp += CHUNK.length + else steady += CHUNK.length + if (Date.now() - t0 >= (drop + secs) * 1000) break + } + if (!ok) sock.once('drain', pump) + else setImmediate(pump) + } + sock.on('connect', () => { + sock.write(head) + started = true + pump() + }) + sock.on('error', (e) => { + log(`sock error ${e.message}`) + finish() + }) + setTimeout(finish, (drop + secs + 30) * 1000) +} + +// ─────────────────────── S5:合成玩家 ─────────────────────── +function echoServe(port) { + const server = net.createServer((c) => { + c.setNoDelay(true) + let buf = Buffer.alloc(0) + c.on('data', (d) => { + buf = Buffer.concat([buf, d]) + for (;;) { + if (buf.length < 4) return + const n = buf.readUInt32BE(0) + if (buf.length < 4 + n) return + const payload = buf.subarray(4, 4 + n) + buf = buf.subarray(4 + n) + c.write(payload) + } + }) + c.on('error', () => c.destroy()) + }) + server.listen(port, '127.0.0.1', () => log(`echo-serve 127.0.0.1:${port}`)) +} + +function p95(arr) { + if (arr.length === 0) return 0 + const s = [...arr].sort((a, b) => a - b) + return s[Math.min(s.length - 1, Math.floor(s.length * 0.95))] +} + +function playOne(port, rate, secs, msgBytes, done) { + const sentAt = [] + const rtts = [] + let sent = 0 + let recv = 0 + const sock = net.connect(port, '127.0.0.1') + const frame = Buffer.alloc(4 + msgBytes, 0x42) + frame.writeUInt32BE(msgBytes, 0) + let buf = Buffer.alloc(0) + let stopped = false + const t0 = Date.now() + const report = () => { + if (stopped) return + stopped = true + const s = [...rtts].sort((a, b) => a - b) + const half = rtts.map((x) => x / 2).sort((a, b) => a - b) + done({ + sent, + recv, + lossPct: sent === 0 ? 0 : Math.round(((sent - recv) / sent) * 10000) / 100, + rttP50: Math.round((s[Math.floor(s.length * 0.5)] || 0) * 10) / 10, + rttP95: Math.round(p95(rtts) * 10) / 10, + oneWayP50: Math.round((half[Math.floor(half.length * 0.5)] || 0) * 10) / 10, + oneWayP95: Math.round(p95(half) * 10) / 10, + }) + } + sock.on('connect', () => { + sock.setNoDelay(true) + const timer = setInterval(() => { + if (Date.now() - t0 > secs * 1000) { + clearInterval(timer) + sock.end() + report() + return + } + sentAt.push(Date.now()) + sock.write(frame) + sent++ + }, Math.max(1, Math.round(1000 / rate))) + }) + sock.on('data', (d) => { + buf = Buffer.concat([buf, d]) + while (buf.length >= msgBytes) { + buf = buf.subarray(msgBytes) + const t = sentAt[recv] + if (t !== undefined) rtts.push(Date.now() - t) + recv++ + } + }) + sock.on('error', (e) => log(`player err ${e.message}`)) + sock.on('close', report) +} + +function players(port, n, rate, secs, msgBytes) { + const results = [] + let left = n + for (let i = 0; i < n; i++) { + setTimeout(() => { + playOne(port, rate, secs, msgBytes, (r) => { + results.push(r) + if (--left === 0) { + const agg = (k) => { + const a = results.map((x) => x[k]).sort((x, y) => x - y) + return a[Math.floor(a.length / 2)] + } + out({ + mode: 'players', + players: n, + rate, + msgBytes, + secs, + perPlayerKBps: Math.round(((rate * msgBytes) / 1024) * 100) / 100, + aggThroughputKBps: Math.round(((rate * msgBytes * n) / 1024) * 10) / 10, + lossPctMax: Math.max(...results.map((r) => r.lossPct)), + oneWayP50: agg('oneWayP50'), + oneWayP95: agg('oneWayP95'), + }) + process.exit(0) + } + }) + }, i * 60) + } +} + +// ─────────────────────────── main ─────────────────────────── +if (args.serve) serve(args.port, args.secs || 0) +else if (args['echo-serve']) echoServe(args.port) +else if (args.download) download(args.port, args.secs, args.drop) +else if (args.upload) upload(args.port, args.secs, args.drop) +else if (args.players) players(args.port, args.players, args.rate, args.secs, args['msg-bytes'] || 200) +else { + process.stderr.write('usage: --serve --port P [--secs N] | --download|--upload --port P --secs S --drop D | --echo-serve --port P | --players --port P --players N --rate R --secs S\n') + process.exit(2) +} diff --git a/scripts/relay-mem-calibrate.mjs b/scripts/relay-mem-calibrate.mjs new file mode 100644 index 0000000..8ce71ba --- /dev/null +++ b/scripts/relay-mem-calibrate.mjs @@ -0,0 +1,178 @@ +#!/usr/bin/env node +/** + * 覆盖网络 序⑥ · S6 `MEM_PER_HOST_MB` 校准(⛔ 一次性脚本、不进产品路径)。 + * + * ## 为什么必须放大测 + * 现网 `used = 2` ⇒ relay 的 RSS(72888 KB)**只反映 Node 基座**,参数表 §5.1 的 + * "2 MB/台" 是**推导值不是实测**。本脚本把 N 拉到 2/10/25/50/100,量 RSS 斜率。 + * + * ## 干净方案(⛔ 不污染生产) + * - **relay 起在本机回环**(独立进程、独立高层口号,与生产的 20080 无关); + * - **N 个合成 client 跑在"本脚本进程内"** —— 这是刻意的:只要被测量的对象是 **relay 子进程** + * 的 RSS,client 的内存长在本进程里 ⇒ **不进被测量**。(若让 client 也各自起进程, + * 机器上会多出 100 个 Node 基座 ≈ 4 GB,纯浪费。) + * - 全程 **127.0.0.1**,零公网面 ⇒ ⛔ 不动 47 的任何配置(S6 权限附注)。 + * + * ## 判据 + * 线性回归 `RSS(N) = a + b·N`,`R² ≥ 0.9` 方为有效;否则**如实报告跳点**,⛔ 不许硬套斜率。 + * + * 用法:node scripts/relay-mem-calibrate.mjs [--base 19000] [--port 23456] + * + * @module scripts/relay-mem-calibrate + */ + +import { spawn } from 'node:child_process' +import { randomBytes } from 'node:crypto' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, dirname } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const REPO = join(HERE, '..') + +function arg(name, dflt) { + const i = process.argv.indexOf(`--${name}`) + return i >= 0 ? process.argv[i + 1] : dflt +} + +const PORT = Number(arg('port', 23456)) +const BASE = Number(arg('base', 19000)) +const SPAN = Number(arg('span', 3000)) +const POINTS = (arg('points', '2,10,25,50,100')).split(',').map(Number) +const logs = [] + +const { RelayClient } = await import(pathToFileURL(join(REPO, 'lib/net/relay/client.js')).href) + +const tmp = mkdtempSync(join(tmpdir(), 'relay-mem-')) +const keysFile = join(tmp, 'keys.json') +const keys = {} +const secrets = {} +for (let i = 1; i <= Math.max(...POINTS); i++) { + const sec = randomBytes(32).toString('hex') + secrets[i] = sec + keys[`ops/w-${i}`] = sec +} +writeFileSync(keysFile, JSON.stringify(keys, null, 2)) + +const relay = spawn( + process.execPath, + [ + join(REPO, 'lib/net/relay/main.js'), + '--port', + String(PORT), + '--keys-file', + keysFile, + '--base', + String(BASE), + '--span', + String(SPAN), + '--max-hosts', + '0', + ], + { cwd: REPO, stdio: ['ignore', 'pipe', 'pipe'] }, +) +relay.stdout.on('data', (d) => logs.push(String(d))) +relay.stderr.on('data', (d) => logs.push(String(d))) + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function status() { + const r = await fetch(`http://127.0.0.1:${PORT}/status`) + return r.json() +} + +async function used() { + try { + return (await status()).capacity.used + } catch { + return -1 + } +} + +async function rssKbMedian(pid, times = 7, gapMs = 500) { + const arr = [] + for (let i = 0; i < times; i++) { + arr.push(await rssKbOnce(pid)) + await sleep(gapMs) + } + const s = arr.filter((x) => Number.isFinite(x)).sort((a, b) => a - b) + return { median: s[Math.floor(s.length / 2)], min: s[0], max: s[s.length - 1], n: s.length } +} + +async function rssKbOnce(pid) { + if (process.platform === 'win32') { + const r = await new Promise((res) => { + const p = spawn('powershell', ['-NoProfile', '-Command', `(Get-Process -Id ${pid}).WorkingSet64`]) + let o = '' + p.stdout.on('data', (d) => (o += d)) + p.on('close', () => res(o.trim())) + }) + return Math.round(Number(r) / 1024) + } + const r = await new Promise((res) => { + const p = spawn('sh', ['-c', `ps -o rss= -p ${pid}`]) + let o = '' + p.stdout.on('data', (d) => (o += d)) + p.on('close', () => res(o.trim())) + }) + return Number(r) +} + +const clients = [] +const samples = [] +const results = { platform: process.platform, node: process.version, points: [], raw: [] } + +for (const target of POINTS) { + while (clients.length < target) { + const i = clients.length + 1 + const c = new RelayClient({ + url: `ws://127.0.0.1:${PORT}/dshs-relay`, + hostId: `w-${i}`, + networkId: 'ops', + secret: secrets[i], + ports: [BASE + i], + }) + c.start() + clients.push(c) + } + let u = -1 + for (let i = 0; i < 60; i++) { + u = await used() + if (u >= target) break + await sleep(500) + } + await sleep(1000) + const rss = await rssKbMedian(relay.pid) + samples.push({ n: clients.length, used: u, rssKb: rss.median, rssMin: rss.min, rssMax: rss.max, nSamples: rss.n }) + process.stderr.write(`[mem] N=${clients.length} used=${u} RSS=${rss.median} KB (min ${rss.min} / max ${rss.max})\n`) +} + +// 线性回归 +const n = samples.length +const sx = samples.reduce((a, s) => a + s.n, 0) +const sy = samples.reduce((a, s) => a + s.rssKb, 0) +const sxx = samples.reduce((a, s) => a + s.n * s.n, 0) +const sxy = samples.reduce((a, s) => a + s.n * s.rssKb, 0) +const b = (n * sxy - sx * sy) / (n * sxx - sx * sx) +const a = (sy - b * sx) / n +const my = sy / n +const ssTot = samples.reduce((acc, s) => acc + (s.rssKb - my) ** 2, 0) +const ssRes = samples.reduce((acc, s) => acc + (s.rssKb - (a + b * s.n)) ** 2, 0) +results.points = samples +results.fit = { + interceptKb: Math.round(a), + slopeKbPerHost: Math.round(b * 10) / 10, + r2: Math.round((1 - ssRes / ssTot) * 10000) / 10000, +} +results.projected = { + memPerHostMbCeil20: Math.ceil((b / 1024) * 1.2 * 10) / 10, + at225HostsMb: Math.round((a + b * 225) / 1024), +} +const last = samples[samples.length - 1] +process.stdout.write(`### RESULT ### ${JSON.stringify({ ...results, lastUsed: last.used })}\n`) +for (const c of clients) c.stop() +relay.kill() +await sleep(500) +rmSync(tmp, { recursive: true, force: true }) +process.exit(0) diff --git a/src/config.ts b/src/config.ts index a0e570c..6f6aee6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -118,6 +118,83 @@ export interface ServerConfig { * 删掉新 env 即回到旧路径,**零代码回滚**。 */ clusterRendezvousUrl: string + /** + * **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。 + * 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。 + */ + relayUrl: string + /** + * 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:20080/status`。 + * + * 为什么必须查它:relay 为每个注册端口在**它自己的回环**上开一条监听,端口号是 + * `listen(0)` 动态分配的(实测 42067)⇒ **Manager 无法从 `dsh_hosts.endpoint` 推出来**, + * 只能问 relay「hostId + 端口 → 回环口 + 在线态」。**空 = 不注册 `relay` 实现** + * (于是 `via='relay'` 会回退到 `manager-ssh`,行为与今天一致)。 + */ + relayStatusUrl: string + /** + * **拨号通道**(覆盖网络 R5):Manager 用哪个 hostId 向 relay 注册为「拨号方」。 + * 默认 `manager`;空 ⇒ **不启用拨号通道**(一切照旧)。 + * + * 为什么需要它:R1–R4 的落点在 **relay 主机的回环**上 ⇒ Manager 必须与 relay 同机, + * 「中继可换机 / 多实例」就做不到。拨号通道把落点搬到 **Manager 自己本机** + * (`src/net/relay/dialer.ts` 的口池)⇒ relay 放哪台机器都行。 + * ⚠️ 该 hostId 必须同时出现在 relay 的密钥文件里,且列在 relay 的 `DSHS_RELAY_DIALERS` 白名单里。 + */ + relayDialHost: string + /** 拨号方的 64 位 hex 密钥(**与 relay 密钥文件里 `relayDialHost` 那一项逐字节相同**)。空 ⇒ 不启用。 */ + relayDialSecret: string + /** 拨号落点口池起点。**必须避开 OS 临时端口段(32768–60999)与实例端口段**。默认 25000。 */ + relayDialPortBase: number + /** 拨号落点口池扫描宽度。默认 1000。 */ + relayDialPortSpan: number + /** 拨号落点口池大小(= 最多同时挂多少个 `(hostId, port)` 落点)。默认 64。 */ + relayDialPool: number + /** + * **实例端口区间起点**(覆盖网络 S3)。`0` = 保持旧行为(`listen(0)` 随机取端口)。 + * + * 为什么必须能配:跨机实例的**隧道落点全部挤在 Manager 的 `127.0.0.1`**,而 + * `findFreePort()` 是**每台 worker 各自**用 `listen(0)` 随机取的 ⇒ **两台 worker 取到同号 + * 就会撞号**:`-R` 失败被静默忽略(`tunnel.forward()` 的返回值无人看),Manager 仍按该 + * 端口拨 ⇒ **打到别人的实例**(2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间 + * 即可根治,且**不需要改 sshd、不需要端口映射表**。 + * + * ⚠️ 选区间时应**避开 OS 临时端口段**(本项目两台机器均为 `32768-60999`)。 + */ + instancePortBase: number + /** 实例端口区间长度(条数)。仅当 `instancePortBase > 0` 时生效。 */ + instancePortSpan: number + // ── 覆盖网络 P0-2 引导三级链(`net/relay/directory.ts`)───────────────── + /** + * **本机所属的网**(P0-1 的 `network_id`)。运维网 = `ops`(47 / 106 / 未来的中继); + * 用户设备用 `u:`。它进目录文档的 `network` 字段(**被签名覆盖**)。 + */ + overlayNetworkId: string + /** + * **内置种子**(引导链第 ①级之后的兜底入口)。常量位 —— 已持证书、**不新增域名**。 + * + * ⚠️ 约定:"**引导地址 = 中继入口同源**":目录端点 = 种子 origin + 固定路径 + * (`net/relay/directory.ts` 的 `DIRECTORY_PATH`)。本文件是**基础层**、 + * 不许 import 能力层 ⇒ 路径字面量两处各写一次,**改动必须两处同改**。 + */ + overlayBootstrapSeeds: string[] + /** + * **受信目录签名公钥**(PEM 或裸 32 字节 hex/base64,逗号分隔)。 + * 空 ⇒ 目录一律不接受(**不可验 = 不接受**)⇒ 只剩 env 与种子兜底两级。 + */ + overlayDirTrustedKeys: string[] + /** 目录**签名私钥**(Ed25519 PEM)路径。空 ⇒ 目录端点 `503`(**绝不发未签名目录**)。 */ + overlayDirKeyFile: string + /** 目录缓存文件(客户端侧)。空 ⇒ 不缓存(每次取目录,多一次往返)。 */ + overlayDirectoryCacheFile: string + // ── 覆盖网络 序③ 一机一钥 + 信任根(`net/relay/identity.ts`)──────────── + /** + * **本机节点私钥**文件路径(Ed25519 PEM,**0600**、属主必须是跑 dsh 的那个用户)。 + * 空 ⇒ 本机不发起身份(只做 HMAC,过渡期形态)。 + */ + overlayNodeKeyFile: string + /** **本机入网凭据**文件路径(`{"doc":{…},"sig":""}`,由离线信任根授权的签名者签发)。 */ + overlayNodeGrantFile: string } /** Untyped overrides collected from argv / env. */ @@ -165,9 +242,29 @@ export interface ConfigOverrides { clusterInstanceHost?: string clusterWorkerDataRoot?: string clusterRendezvousUrl?: string + relayUrl?: string + relayStatusUrl?: string + relayDialHost?: string + relayDialSecret?: string + relayDialPortBase?: number | string + relayDialPortSpan?: number | string + relayDialPool?: number | string + instancePortBase?: number | string + instancePortSpan?: number | string + overlayNetworkId?: string + overlayNodeKeyFile?: string + overlayNodeGrantFile?: string + overlayBootstrapSeeds?: string[] + overlayDirTrustedKeys?: string[] + overlayDirKeyFile?: string + overlayDirectoryCacheFile?: string } const DEFAULT_HOST = '127.0.0.1' + +// 覆盖网络 S3:实例端口区间(`0` = 保持旧行为 `listen(0)` 随机)。 +const DEFAULT_INSTANCE_PORT_BASE = 0 +const DEFAULT_INSTANCE_PORT_SPAN = 1000 const DEFAULT_PORT = 3080 const DEFAULT_DSH_COMMAND = ['dsh'] const DEFAULT_LOG_LEVEL = 'info' @@ -207,6 +304,12 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local' const DEFAULT_K8S_NAMESPACE = 'dsh' const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator' const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull' +/** + * 覆盖网络 P0-2:**内置种子**(引导链的常量位)。 + * 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。 + * ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。 + */ +const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay'] /** Load the encryption secret from env, or persist a generated one at * `/secret.key` (0600) so it survives restarts without setup. */ @@ -231,12 +334,31 @@ function toBool(value: string | undefined, fallback: boolean): boolean { return value === 'true' || value === '1' } +/** + * 解析端口号(覆盖网络 S3)。空 / 非法 / 越界 ⇒ 回落到 `fallback`(`0` = 旧行为 `listen(0)`)。 + * 不接受 NaN:`listen(NaN)` 会以"启动正常但实例起不来"的形式烂在运行时。 + */ +function toPortNumber(value: string | number | undefined, fallback: number): number { + if (value === undefined || value === '') return fallback + const n = Number(value) + return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback +} + /** Split a comma-separated CIDR list into a trimmed, de-duplicated array. */ function parseCidrs(value: string | undefined): string[] { if (value === undefined || value === '') return [] return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))] } +/** + * 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回 + * `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。 + */ +function splitList(value: string | undefined): string[] | undefined { + if (value === undefined) return undefined + return [...new Set(value.split(',').map((s) => s.trim()).filter((s) => s !== ''))] +} + /** Parse an isolation-mode value, rejecting anything outside `soft`/`account` * so a typo in the env var fails loudly at startup instead of silently * falling back to `soft` isolation. */ @@ -367,5 +489,45 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig { process.env.DSHS_RENDEZVOUS_URL ?? process.env.DSHS_TUNNEL_TARGET ?? '', + // 覆盖网络 R3:Manager 侧的 relay 接入(**空 = 完全不启用**,与 R2 之前行为一致)。 + relayUrl: overrides.relayUrl ?? process.env.DSHS_RELAY_URL ?? '', + relayStatusUrl: overrides.relayStatusUrl ?? process.env.DSHS_RELAY_STATUS_URL ?? '', + // 覆盖网络 R5:Manager 侧拨号通道(**空密钥 = 完全不启用**,落回 `/status` 快照那条老路)。 + relayDialHost: (overrides.relayDialHost ?? process.env.DSHS_RELAY_DIAL_HOST ?? 'manager').trim(), + relayDialSecret: (overrides.relayDialSecret ?? process.env.DSHS_RELAY_DIAL_SECRET ?? '').trim(), + relayDialPortBase: toPortNumber(overrides.relayDialPortBase ?? process.env.DSHS_RELAY_DIAL_PORT_BASE, 25000), + relayDialPortSpan: toPortNumber(overrides.relayDialPortSpan ?? process.env.DSHS_RELAY_DIAL_PORT_SPAN, 1000), + relayDialPool: Number(overrides.relayDialPool ?? process.env.DSHS_RELAY_DIAL_POOL ?? 64) || 64, + // 覆盖网络 S3:实例端口区间隔离(`0` = 旧行为)。非法值**回落**而不是变 NaN —— + // `listen(NaN)` 是那种"启动看起来正常、实例起不来"的坑。 + instancePortBase: toPortNumber( + overrides.instancePortBase ?? process.env.DSHS_INSTANCE_PORT_BASE, + DEFAULT_INSTANCE_PORT_BASE, + ), + instancePortSpan: toPortNumber( + overrides.instancePortSpan ?? process.env.DSHS_INSTANCE_PORT_SPAN, + DEFAULT_INSTANCE_PORT_SPAN, + ), + // 覆盖网络 P0-2:引导三级链(env 显式 > 缓存目录 > 内置种子)。 + // 这一组**全部有安全默认**:不配任何东西 ⇒ 与今天行为一致(只认 env;种子地址就是现网地址)。 + overlayNetworkId: (overrides.overlayNetworkId ?? process.env.DSHS_OVERLAY_NETWORK_ID ?? 'ops').trim() || 'ops', + overlayBootstrapSeeds: + overrides.overlayBootstrapSeeds ?? + splitList(process.env.DSHS_OVERLAY_BOOTSTRAP_SEEDS) ?? + DEFAULT_OVERLAY_BOOTSTRAP_SEEDS, + overlayDirTrustedKeys: + overrides.overlayDirTrustedKeys ?? splitList(process.env.DSHS_OVERLAY_DIR_PUBKEYS) ?? [], + overlayDirKeyFile: (overrides.overlayDirKeyFile ?? process.env.DSHS_OVERLAY_DIR_KEY ?? '').trim(), + overlayDirectoryCacheFile: ( + overrides.overlayDirectoryCacheFile ?? + process.env.DSHS_OVERLAY_DIR_CACHE ?? + join(dataRoot, 'overlay', 'directory.json') + ).trim(), + // 覆盖网络 序③:本机节点身份(一机一钥 + 入网凭据)。 + // **两个都配了**才发起身份 ⇒ 不配 = 与今天行为一致(只做 HMAC);配了却读不出来 ⇒ 起动即抛 + //(见 `net/relay/identity.ts#loadClientIdentity` —— 静默退化成"没身份"会让"凭据坏了" + // 表现成"一切正常",等 relay 一开强制就整台失联)。 + overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(), + overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(), } } diff --git a/src/db/pg.ts b/src/db/pg.ts index af45ca1..17e74f6 100644 --- a/src/db/pg.ts +++ b/src/db/pg.ts @@ -53,7 +53,7 @@ types.setTypeParser(20, (value: string) => Number(value)) const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid' const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at' const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at' -const HOST_COLS = 'id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat' +const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat' const INSTANCE_COLS = 'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch, ' + 'host_id, epoch, heartbeat_at, lease_until' // v7 集群化归属/租约(T08 S2)—— 漏了它们会让 hostId 恒为 null @@ -602,15 +602,25 @@ export class PgAdapter implements DbAdapter { async upsertDshHost(input: UpsertDshHostInput): Promise { try { const { rows } = await this.pool.query( - `INSERT INTO dsh_hosts (id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat) - VALUES ($1, $2, $3, $4, 0, $5, NULL) + `INSERT INTO dsh_hosts (id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat) + VALUES ($1, $2, COALESCE($3::text, 'manager-ssh'), COALESCE($4::text, 'ops'), $5, $6, 0, $7, NULL) ON CONFLICT(id) DO UPDATE SET endpoint = excluded.endpoint, + via = COALESCE($3::text, dsh_hosts.via), + network_id = COALESCE($4::text, dsh_hosts.network_id), agent_token = excluded.agent_token, capacity_mb = excluded.capacity_mb, status = excluded.status - RETURNING id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat`, - [input.id, input.endpoint, input.agentToken, input.capacityMb, input.status ?? 'up'], + RETURNING id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat`, + [ + input.id, + input.endpoint, + input.via ?? null, + input.networkId ?? null, + input.agentToken, + input.capacityMb, + input.status ?? 'up', + ], ) return toDshHost(rows[0] as Record) } catch (e) { diff --git a/src/db/repo.ts b/src/db/repo.ts index 022d82b..290aa44 100644 --- a/src/db/repo.ts +++ b/src/db/repo.ts @@ -595,19 +595,36 @@ export function deleteBusinessPlugin(db: Database, id: string): boolean { // ⚠️ local 模式**不调用**这些函数(`LocalSpawner` 靠进程内 Map + 单机互斥), // 所以它们的存在不会改变现有单机行为。 -const HOST_COLS = 'id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat' +const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat' -/** 注册/更新一台 worker。join 幂等:同 id 重复执行 = 更新(并把它标回 `up`)。 */ +/** 注册/更新一台 worker。join 幂等:同 id 重复执行 = 更新(并把它标回 `up`)。 + * + * ⚠️ `via` 与 `networkId` **省略时不覆盖已有值**(`COALESCE` 到旧值):否则一次不带它们的 + * join 会把回填好的 `local` / 已划定的网络归属冲回列默认值(那种漂移在换中继、建第二张网之前 + * **看不出症状**)。 + */ export function upsertDshHost(db: Database, input: UpsertDshHostInput): DshHost { prepare(db, ` - INSERT INTO dsh_hosts (id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat) - VALUES (?, ?, ?, ?, 0, ?, NULL) + INSERT INTO dsh_hosts (id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat) + VALUES (?, ?, COALESCE(?, 'manager-ssh'), COALESCE(?, 'ops'), ?, ?, 0, ?, NULL) ON CONFLICT(id) DO UPDATE SET endpoint = excluded.endpoint, + via = COALESCE(?, dsh_hosts.via), + network_id = COALESCE(?, dsh_hosts.network_id), agent_token = excluded.agent_token, capacity_mb = excluded.capacity_mb, status = excluded.status - `).run(input.id, input.endpoint, input.agentToken, input.capacityMb, input.status ?? 'up') + `).run( + input.id, + input.endpoint, + input.via ?? null, + input.networkId ?? null, + input.agentToken, + input.capacityMb, + input.status ?? 'up', + input.via ?? null, + input.networkId ?? null, + ) const row = prepare(db, `SELECT ${HOST_COLS} FROM dsh_hosts WHERE id = ?`).get(input.id) return toDshHost(row as Record) } diff --git a/src/db/schema.ts b/src/db/schema.ts index 39d1d37..97b5098 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -345,6 +345,43 @@ CREATE INDEX IF NOT EXISTS idx_dsh_instances_host ON dsh_instances (host_id); CREATE INDEX IF NOT EXISTS idx_dsh_instances_lease ON dsh_instances (lease_until); ` +// v8(覆盖网络 S2):`dsh_hosts.via` = **"这台 worker 经谁可达"**(`Reachability.via` 词表)。 +// +// 为什么必须加列:现网两条 `endpoint` 字符串**同形、语义不同**,表里无法表达"经谁中转" +// (会合中继拆分方案 §2 C3)⇒ 换会合 / 中继组件时无处安放,越晚改代价越大。 +// +// 加列**带默认值** `manager-ssh` ⇒ **先加列 → 再改代码 → 最后回填**,任一步中断都不崩; +// 旧代码只读 `endpoint`,完全不受影响(列可留着不删 ⇒ 零风险回滚)。 +// ⚠️ 默认值对 `w-106`(隧道落点)正确、对 `w-47`(同机直连)**不正确** ⇒ 回填由部署步骤 +// 显式 `UPDATE ... WHERE id='w-47'` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。 +const SQLITE_V8 = ` +ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh'; +` + +const PG_V8 = ` +ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh'; +` + +// v9(覆盖网络 ②·P0-1):`dsh_hosts.network_id` = **这台 worker 属于哪张网**。 +// +// 为什么必须加列:relay 的会话表 / 端点表原先只按 `hostId` 索引 —— 即平台自己的 Worker 隧道与 +// 未来的用户设备挤进**同一个扁平命名空间**。今天只有 1 张网,问题不显形;一进第二类节点就会变成 +// 「一张巨网 + 靠 ACL 兜」,而写错一条 ACL 就泄露 ⇒ 与「**权限只准收窄**」直接冲突。 +// ⇒ 网维度必须做成**结构性**的:控制面这一侧落成显式列(本迁移),数据面落成 relay 的 +// 「按网络分桶白名单 + 同网校验」(`src/net/relay/server.ts`)。 +// +// 加列**带默认值** `ops`(运维网)⇒ **存量行天然正确**(现网 47 / 106 / manager 全在运维网), +// 且顺序是「**先加列 → 再改代码 → 最后才谈回填**」,任一步中断都不崩;旧代码只读旧列,零影响。 +// ⚠️ 与 v8 的 `via` 同一条纪律:**迁移里不写 `UPDATE ... WHERE id='...'`**(迁移是静态 SQL, +// 写死 hostId 在别的部署上会错)—— 真要改某台机的网络归属,由部署步骤显式 `UPDATE` 完成。 +const SQLITE_V9 = ` +ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops'; +` + +const PG_V9 = ` +ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops'; +` + interface Migration { version: number name: string @@ -360,6 +397,8 @@ const MIGRATIONS: readonly Migration[] = [ { version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 }, { version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 }, { version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 }, + { version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 }, + { version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 }, ] /** Apply unapplied SQLite migrations inside a single transaction. */ diff --git a/src/db/types.ts b/src/db/types.ts index e11fad8..5141f52 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -5,6 +5,10 @@ * @module dshs/db/types */ +// 覆盖网络 S2:`via` 列的**词表来源**统一在 `net/reachability`(同属 ③ 能力层 ⇒ 不破分层)。 +// 这里只引一个常量,不引取址逻辑 —— `db` 不做网络判定。 +import { VIA_MANAGER_SSH } from '../net/reachability.js' + export type UserRole = 'admin' | 'pending' | 'active' | 'disabled' /** A full user row, including secrets (never serialized to clients). */ @@ -308,11 +312,38 @@ export function toBusinessPlugin(row: Record): BusinessPlugin { /** Worker 健康状态(`dsh_hosts.status` 的 CHECK 镜像)。 */ export type DshHostStatus = 'up' | 'draining' | 'down' +/** + * 节点默认所属网 = **运维网**(覆盖网络 P0-1)。 + * + * ⚠️ 与 `src/net/relay/network.ts` 的 `OPS_NETWORK` 是**同一个词表的同一个值**,但本层 + * (④基础层)**不 import 能力层**(分层纪律,`npm run check:layering` 会拦)⇒ 两边各写一次 + * 字面量,**改动必须两处同改**。取值口径见 `network.ts`:`ops` | `u:`。 + */ +export const DEFAULT_HOST_NETWORK = 'ops' + /** 一台承载用户实例的 worker(= 设计里的 Worker 节点)。 */ export interface DshHost { id: string /** agent 的内网地址,如 `10.0.1.11:9000`。 */ endpoint: string + /** + * **经谁可达**(覆盖网络 S2):`Reachability.via` 的词表值,指向一个 `Rendezvous` 实现。 + * + * ⚠️ 两条现网记录的 `endpoint` 字符串同形、语义不同 ⇒ **不能靠 endpoint 猜**: + * · `w-47` = `127.0.0.1:19100` = **同机直连**(Manager 与 worker 同机)⇒ `local` + * · `w-106` = `127.0.0.1:19000` = **Manager 主机上 sshd 的反向隧道落点** ⇒ `manager-ssh` + */ + via: string + /** + * **属于哪张网**(覆盖网络 P0-1,`dsh_hosts.network_id`)。 + * + * `ops` = 运维网(平台自己的机器:`manager` / `w-47` / `w-106`,以及未来的中继与骨干); + * `u:` = 该用户名下的全部设备。取值与 `src/net/relay/network.ts` 同一词表。 + * + * 为什么它是**结构性**维度而不是又一个标签:relay 侧按 `/` 建会话、且 + * `DIAL` 必须**同网** ⇒ 不同网络的节点之间连"能不能拨"这一步都走不到(写错配置也不会泄露)。 + */ + networkId: string /** 内部 HMAC 密钥(**只应存在于 DB 与 Manager 内存**,绝不经 API 返回)。 */ agentToken: string /** 该机可用内存预算(MB);0 = 不承载实例(只做门户/控制)。 */ @@ -328,6 +359,13 @@ export interface DshHost { export interface UpsertDshHostInput { id: string endpoint: string + /** 不传 ⇒ 走列默认值 `manager-ssh`(S2 加列时的默认,保证旧 join 脚本不改也能用)。 */ + via?: string + /** + * 所属网(P0-1)。**不传 ⇒ 不覆盖已有值**(`COALESCE` 到旧值,与 `via` 同款), + * 新行走列默认值 `ops`。⇒ 旧 join 脚本一行不改也不会把某台机的网络归属冲掉。 + */ + networkId?: string agentToken: string capacityMb: number status?: DshHostStatus @@ -356,6 +394,13 @@ export function toDshHost(row: Record): DshHost { return { id: row.id as string, endpoint: row.endpoint as string, + // S2:列有默认值 ⇒ 行上必然有值;仍做一次兜底,避免"旧库 + 新代码"组合下出现 undefined。 + via: ((row.via as string | null) ?? '') === '' ? VIA_MANAGER_SSH : (row.via as string), + // P0-1:列有默认值 ⇒ 行上必然有值;仍做一次兜底("旧库 + 新代码"组合下不出现 undefined)。 + networkId: + ((row.network_id as string | null) ?? '') === '' + ? DEFAULT_HOST_NETWORK + : (row.network_id as string), agentToken: row.agent_token as string, capacityMb: (row.capacity_mb as number | null) ?? 0, usedMb: (row.used_mb as number | null) ?? 0, diff --git a/src/fs/provider.ts b/src/fs/provider.ts index 2d561d6..1bb8934 100644 --- a/src/fs/provider.ts +++ b/src/fs/provider.ts @@ -15,6 +15,11 @@ import { userRoot } from './workspace.js' export interface ClusterFsRouting { hostIdFor?: (userId: string) => Promise agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined + /** + * `agentFor` 未命中时的**按需补齐**钩子(2026-09-16 加,覆盖网络线缺陷 A1)。 + * 不传 = 未命中直接失败关闭(503 `host_unresolved`),**不会**再静默回退默认机。 + */ + ensureHost?: (hostId: string) => Promise } /** @@ -31,6 +36,7 @@ export function createUserFs(config: ServerConfig, routing: ClusterFsRouting = { workerDataRoot: config.clusterWorkerDataRoot === '' ? config.dataRoot : config.clusterWorkerDataRoot, hostIdFor: routing.hostIdFor, agentFor: routing.agentFor, + ensureHost: routing.ensureHost, }) } return new LocalUserFs((userId) => userRoot(config.dataRoot, userId)) diff --git a/src/fs/remote-user-fs.ts b/src/fs/remote-user-fs.ts index 81b85c8..1693b9c 100644 --- a/src/fs/remote-user-fs.ts +++ b/src/fs/remote-user-fs.ts @@ -36,6 +36,15 @@ export interface RemoteUserFsOptions { hostIdFor?: (userId: string) => Promise /** hostId → 接入信息(与 RemoteSpawner 用**同一份**目录,避免两套漂移)。 */ agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined + /** + * `agentFor` **未命中**时的按需补齐钩子(2026-09-16 加,覆盖网络线缺陷 A1)。 + * + * 为什么需要:`agentFor` 是同步查表,而那张表(`server.ts` 的 `hostDirectory`)是**惰性**的 + * —— 唯一写入者是 `hostsProvider()`,此前只有 `RemoteSpawner.ensureHosts()` 会调它。 + * ⇒ Manager 重启后若用户先碰**文件面**,表里只有"本机",别的机的用户就会取不到地址。 + * 传了这个钩子,取不到时会先补一次表再判,而不是直接掉进下面的失败关闭。 + */ + ensureHost?: (hostId: string) => Promise /** **worker 上**的 dataRoot(必须与该 worker 一致,用于 `resolvePath` 的路径数学)。 */ workerDataRoot: string /** 单次请求超时(ms)。文件可能较大,默认 30 s。 */ @@ -52,30 +61,59 @@ export class RemoteUserFs implements UserFs { private readonly doFetch: typeof fetch private readonly hostIdFor?: (userId: string) => Promise private readonly agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined + private readonly ensureHost?: (hostId: string) => Promise constructor(options: RemoteUserFsOptions) { this.base = options.agentUrl.replace(/\/$/, '') this.token = options.token this.hostIdFor = options.hostIdFor this.agentFor = options.agentFor + this.ensureHost = options.ensureHost this.workerDataRoot = options.workerDataRoot this.timeoutMs = options.timeoutMs ?? 30_000 this.doFetch = options.fetchImpl ?? fetch } - /** 解析该用户文件操作应打的那台 agent(查不到归属就用默认)。 */ + /** + * 解析该用户文件操作应打的那台 agent。 + * + * ## 归属已知却取不到地址 ⇒ **抛错,绝不回退默认机**(覆盖网络线缺陷 A1,2026-09-16 修) + * + * 旧行为是三种失败(查库抛错 / 没给 `agentFor` / `agentFor` 查不到)**一律静默回退默认机**。 + * 为什么这是缺陷:默认机 = Manager 所在那台,它对**别的机**的用户只会给出两种答案 —— + * ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}`,与"**文件夹不存在**"**完全同形** + * (用户读成"我的文件丢了",而真因是"请求根本没出这台机"); + * ② 若本地恰好有同名目录 ⇒ 直接把文件写进**一份没人在看的副本**(更糟的静默写坏)。 + * 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充,w-106 用户点启动 + * 连发 3 次 **全 404,且 relay 零 `DIAL`、拨号池零落点** ⇒ 请求根本没出去。 + * **判别器 = 看 relay 有没有 `DIAL`**(本机单测打在 `fetch` 上,断言"没打默认机")。 + * + * 只有**"确实还没有归属"**(`hostIdFor` 正常返回 `undefined`)才用默认 agent —— + * 那是设计内的单机 / 首次触达路径(见 `hostIdForFile` 的粘性说明),不是错误。 + */ private async target(userId: string): Promise<{ base: string; token: string }> { if (this.hostIdFor === undefined) return { base: this.base, token: this.token } + let hostId: string | undefined try { - const hostId = await this.hostIdFor(userId) - if (hostId !== undefined && hostId !== null && this.agentFor !== undefined) { - const agent = this.agentFor(hostId) - if (agent !== undefined) return { base: agent.agentUrl.replace(/\/$/, ''), token: agent.token } - } + hostId = (await this.hostIdFor(userId)) ?? undefined } catch { - // 查库失败 ⇒ 落回默认 host(宁可"可能读错机",也不要整个文件面 500) + // 归属都查不出来 ⇒ 无法判断该打哪台 ⇒ 失败关闭(旧行为在这里静默打默认机) + throw new UserFsError('host_unresolved') } - return { base: this.base, token: this.token } + if (hostId === undefined || hostId === '') return { base: this.base, token: this.token } + let hit = this.agentFor?.(hostId) + if (hit === undefined && this.agentFor !== undefined && this.ensureHost !== undefined) { + // 未命中往往只是"表还没被填"(重启窗口期)⇒ 先按需补齐一次再判。 + // 这一步让下面的失败关闭**不波及正常的冷启动请求**(否则只是把"假 404"换成"真 503")。 + try { + await this.ensureHost(hostId) + } catch { + /* 补齐失败 ⇒ 交给下面的失败关闭:带 hostId 的明确错误,好过静默打错机 */ + } + hit = this.agentFor(hostId) + } + if (hit === undefined) throw new UserFsError('host_unresolved') + return { base: hit.agentUrl.replace(/\/$/, ''), token: hit.token } } /** 统一的 POST:把 agent 的 `{error: code}` 还原成 `UserFsError`(路由按 code 回前端)。 */ diff --git a/src/fs/user-fs.ts b/src/fs/user-fs.ts index 1542c5b..dd7390d 100644 --- a/src/fs/user-fs.ts +++ b/src/fs/user-fs.ts @@ -26,6 +26,10 @@ export type UserFsErrorCode = | 'too_large' // 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径) | 'unsupported' + // 覆盖网络线缺陷 A1(2026-09-16):**归属的机器已知,却取不到它的接入信息**(或归属本身 + // 查不出来)⇒ 503。刻意与 `not_found` 分开:`not_found` 会被读成"文件夹不存在"(数据丢了), + // 而这里的事实是"这次连该打哪台都没定下来",属于**可重试**的服务侧状态。 + | 'host_unresolved' /** HTTP status each code maps to (unchanged from the pre-seam routes). */ const STATUS: Record = { @@ -38,6 +42,7 @@ const STATUS: Record = { not_a_file: 400, too_large: 413, unsupported: 501, + host_unresolved: 503, } /** diff --git a/src/net/reachability.ts b/src/net/reachability.ts index c5a3ffe..430d4a9 100644 --- a/src/net/reachability.ts +++ b/src/net/reachability.ts @@ -16,6 +16,12 @@ * * `Reachability` 把这件事显式化:`via` 指向一个 `Rendezvous` 实现,`address` 是真实地址。 * + * ## P0-3:为什么要带 `networkId` + * 地址是**网内**的:`127.0.0.1:19000` 在 `ops` 里指向 `w-106` 的 relay 落点,在 `u:5` 里 + * 可能指另一台。只带 `hostId` 的重达性描述**在多网下是有歧义的**,而歧义会以 + * "打到另一张网的同名节点"这种最贵的形态暴露(静默串网)。⇒ 本类型**必带**网络维度, + * `parseReachability()` 从**逻辑名**(`/`)里取它,调用方不许自己拼。 + * * ## 边界(勿破) * 本模块**只做地址的表征与解析**,不承载任何权威状态 —— 归属 / 租约 / 骨干资格 * 一律仍只由控制面写(与 `集群化改造方案 §1.3` 数据分层一致)。 @@ -23,15 +29,28 @@ * @module dshs/net/reachability */ +import { OPS_NETWORK, parseLogicalName } from './relay/network.js' + /** 同机直连:Manager 与 worker 在同一台机器上,不经任何中转。 */ export const VIA_LOCAL = 'local' /** 今天唯一在跑的中转方式 = **Manager 主机上的 sshd 反向隧道**(S4 之后应被 relay 取代)。 */ export const VIA_MANAGER_SSH = 'manager-ssh' +/** + * 自研中继单元 `dshs-relay`(S4):worker **只拨出**、relay **只绑回环**、 + * Manager 连 relay 分配的回环口 ⇒ 去掉对 sshd 的长期依赖(传输方案 §10 定案)。 + */ +export const VIA_RELAY = 'relay' + export interface Reachability { - /** 哪台 worker。 */ + /** 哪台 worker(**裸 hostId**,不含网络段 —— 网络在下一行,两者分开表达)。 */ hostId: string + /** + * **属于哪张网**(P0-3 的结构性维度):`ops` | `u:<租户>` | 其它显式命名。 + * 地址是**网内**语义,缺了它就等于"一张巨网 + 靠 ACL 兜"。 + */ + networkId: string /** **经谁可达** —— 一个 `Rendezvous` 实现的 id。 */ via: string /** agent 的真实地址 `host:port`(**不含 scheme**)。 */ @@ -45,6 +64,14 @@ export interface HostAddressable { hostId?: string agentUrl?: string reachability?: Reachability + /** + * **表里声明的会合形态**(`dsh_hosts.via` 原文,P0-3 加)。 + * + * 为什么取址需要它:`via='relay'` 时 `agentUrl`(= `endpoint`)是 **relay 落点**而非直连地址, + * 解析不出时必须**失败关闭**而不是回落它(见 `agentBaseUrlOf`)。少了这一位,那种回落 + * 在代码里**完全看不出来**(`agentUrl` 与真地址字符串同形)。 + */ + via?: string } /** @@ -61,39 +88,77 @@ export function agentBaseUrl(reach: Reachability): string { * ⇒ 本函数返回的就是原先直接用的那个字符串,**行为零变化**。 * * ⚠️ 两者皆缺时**抛错**,不返回空串 —— "静默打到空地址"是跨机下最难查的失败。 + * + * ## ⛔ `via='relay'` 时**不许**回落到 `agentUrl`(P0-3) + * `agentUrl` 就是 `dsh_hosts.endpoint`。relay 语义下它存的是**落点**(`127.0.0.1:<动态口>`, + * 旧形态则是 Worker 侧口号),与 `reachability.address` **字符串同形、语义完全不同**。 + * 解析失败(离线 / 跨网 / 快照陈旧)时回落它 ⇒ 请求被打到**控制面本机的同号端口**上, + * 而那个端口可能正被**另一张网的落点**或**某个实例**占着 —— 与 A1「假 404 / 静默写坏」同族。 + * ⇒ 宁可**显式失败**(上层拿得到原因),也不发一个"看起来像地址"的东西出去。 */ export function agentBaseUrlOf(host: HostAddressable): string { if (host.reachability !== undefined) return agentBaseUrl(host.reachability) + if (host.via === VIA_RELAY) { + throw new Error( + `host "${host.hostId ?? '?'}" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint ` + + '(relay 语义下 endpoint 是落点,回落会打到本机同号端口)', + ) + } if (host.agentUrl !== undefined && host.agentUrl !== '') return host.agentUrl.replace(/\/+$/, '') throw new Error(`host "${host.hostId ?? '?'}" 既无 reachability 也无 agentUrl:拒绝静默降级`) } /** - * 从旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用)。 + * 从**逻辑名** + 旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用 · P0-3 收口)。 * - * 兼容面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸 + * ## 为什么第一个参数是"逻辑名"而不是 `hostId`(P0-3 的唯一入口) + * 地址是**网内**语义。若这里只收裸 `hostId`,网络维度就得由**每个调用方**自己补 —— + * 而本线复盘里"散着拼字符串"最后必出三套不一致的口径。⇒ 收 `place` 的唯一入口改成收 + * `/`:网络段由**本函数**切出来(`parseLogicalName`),调用方不碰。 + * ⚠️ **裸 `hostId` 仍兼容**(⇒ 落 `ops`):过渡期不破坏现网调用方与既有单测。 + * + * 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸 * `host:port` —— 没写 scheme 时按 `http` 处理,与 `RemoteSpawner` 原先"直接把它当 * fetch 基址"的行为一致(fetch 会补 `http://`)。 */ export function parseReachability( - hostId: string, + name: string, endpoint: string, via: string = VIA_MANAGER_SSH, ): Reachability { + const { network, hostId } = parseLogicalName(name) const trimmed = endpoint.trim() const matched = /^(https?):\/\/(.*)$/i.exec(trimmed) if (matched !== null) { return { hostId, + networkId: network, via, address: matched[2].replace(/\/+$/, ''), scheme: matched[1].toLowerCase() === 'https' ? 'https' : 'http', } } - return { hostId, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' } + return { hostId, networkId: network, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' } } /** `Reachability` → 旧 `endpoint` 字符串(与 `parseReachability` 互逆,回填/回滚用)。 */ export function toEndpoint(reach: Reachability): string { return agentBaseUrl(reach) } + +/** + * 从 `host:port` 里取端口(覆盖网络 R3)。 + * + * 为什么需要:relay 为每个注册端口在**它自己的回环**上开一条监听,回环口号由 `listen(0)` + * 动态分配 ⇒ Manager 拿不到、也推不出,只能拿「要拨的端口号」去 relay 的 `/status` 里查。 + * 而那个号码就住在 `dsh_hosts.endpoint` 里(`http://127.0.0.1:19000`)⇒ 统一在这里剥出来, + * 别在调用方各写一遍 `split(':')`(IPv6 字面量会切错)。 + */ +export function addressPort(address: string): number | undefined { + const idx = address.lastIndexOf(':') + if (idx < 0) return undefined + const raw = address.slice(idx + 1).trim() + if (!/^\d+$/.test(raw)) return undefined + const port = Number(raw) + return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined +} diff --git a/src/net/relay/addr-override.ts b/src/net/relay/addr-override.ts new file mode 100644 index 0000000..4703a73 --- /dev/null +++ b/src/net/relay/addr-override.ts @@ -0,0 +1,234 @@ +/** + * 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。 + * + * ## 它解决的唯一问题 + * 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**, + * 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于 + * "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果 + * **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。 + * + * ## 为什么落在 DNS 层(而不是 dispatcher / 换 URL) + * 本项目**不引入 `undici` / `ws`**(`client.ts` 用的是 Node 22 内建的全局 `WebSocket`)。 + * WHATWG `WebSocket` 不接受自定义 `dispatcher` ⇒「换地址但保留 SNI」在 URL 层没有落点: + * 把 URL 换成 IP 会**连带**把 SNI 换成 IP ⇒ 证书校验必然失败(除非关校验 = 权限/安全净变差)。 + * 实测(2026-09-17,Node v22.22.2):全局 `WebSocket` 的建连**走 JS 层 `dns.lookup`** + * ⇒ 在解析层做定向覆盖,是**零新依赖、不动默认路径**的最小实现。 + * + * ## 安全边界(为什么它不是"通用 hosts 劫持") + * - **白名单语义**:只覆盖 `DSHS_OVERLAY_ADDR_OVERRIDES` 里**逐字列出**的域名;其余一律走原始 + * `dns.lookup`。**未配 ⇒ 本模块完全不生效**,行为与今天逐字一致(零退化)。 + * - **不写 DNS、不改 hosts、不碰 `/etc`**:只改本进程内的解析结果,进程退出即消失。 + * - **失败关闭**:形状非法的条目**不安装**并写一行日志 —— 不静默忽略、更不回落成"通用覆盖"。 + * - **零新增暴露面**:不监听端口、不新增依赖、不新增凭据。 + * + * ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5) + * ``` + * DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89 + * ``` + * 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。 + * + * @module dshs/net/relay/addr-override + */ + +import { createRequire } from 'node:module' +import { isIPv4, isIPv6 } from 'node:net' + +/** `dns.lookup` 的回调形状(只用到 `(err, address, family)` 与 `all: true` 两种)。 */ +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address?: string | Array<{ address: string; family: number }>, + family?: number, +) => void + +/** `node:dns` 的最小面(用 `createRequire` 取,保证改的是 **net/undici 实际用的那个单例**)。 */ +interface DnsModule { + lookup: (hostname: string, options?: unknown, callback?: LookupCallback) => void + promises: { lookup: (hostname: string, options?: unknown) => Promise } +} + +export interface AddrOverride { + /** 小写域名(**逐字匹配**,不做后缀/通配匹配)。 */ + host: string + /** 覆盖后的直连地址。 */ + ip: string + /** 地址族(由 IP 字面量推得)。 */ + family: 4 | 6 +} + +/** 已安装的覆盖表 —— 模块级唯一状态(`ensureOverlayAddrOverrides` 幂等)。 */ +const installed = new Map() +/** 已经打过一次补丁(**只打一次**;反复打会把自己的包装再包一层)。 */ +let patched = false +/** 已经播报过的条目(避免每次建连都刷日志)。 */ +const announced = new Set() +/** 原始实现(仅供测试精确还原)。 */ +let originalLookup: DnsModule['lookup'] | undefined +let originalPromisesLookup: DnsModule['promises']['lookup'] | undefined + +/** 该字符串能不能当"域名"用:非空、含点、无协议/路径/端口/空白、不是 IP 字面量。 */ +function isHostnameLike(host: string): boolean { + if (host === '') return false + if (!host.includes('.')) return false + if (isIPv4(host) || isIPv6(host)) return false + return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(host) +} + +/** IP 字面量 ⇒ 地址族;不是字面量 ⇒ `undefined`(**不接受主机名**:那会把"覆盖"变成"再解析一次")。 */ +function ipFamily(ip: string): 4 | 6 | undefined { + if (isIPv4(ip)) return 4 + if (isIPv6(ip)) return 6 + return undefined +} + +/** + * `host=ip`(逗号多值)⇒ 覆盖表。 + * + * `bad` 装**非法条目的原文** —— 调用方必须把它播出去:静默丢弃会让"我明明配了"看起来像 + * "覆盖没生效",而这正是本项目反复踩过的"静默失败"形态。 + */ +export function parseAddrOverrides(raw: string): { list: AddrOverride[]; bad: string[] } { + const list: AddrOverride[] = [] + const bad: string[] = [] + const seen = new Set() + for (const item of raw.split(',')) { + const spec = item.trim() + if (spec === '') continue + const eq = spec.indexOf('=') + const host = eq < 0 ? '' : spec.slice(0, eq).trim().toLowerCase() + const ip = eq < 0 ? '' : spec.slice(eq + 1).trim() + const family = ipFamily(ip) + if (!isHostnameLike(host) || family === undefined) { + bad.push(spec) + continue + } + if (seen.has(host)) continue + seen.add(host) + list.push({ host, ip, family }) + } + return { list, bad } +} + +/** 按 host 造一个 `lookup` 结果(尊重 `all` / `family` 两个入参形状)。 */ +function overriddenLookup( + hit: AddrOverride, + options: unknown, + callback: LookupCallback, +): void { + const opts = ( + options === null || typeof options !== 'object' ? {} : options + ) as { all?: boolean; family?: number } + const family = opts.family === 4 || opts.family === 6 ? opts.family : hit.family + // 请求的地址族与覆盖项不符 ⇒ 交回调用方语义(给 ENOTFOUND),**不要**回一个错族的地址。 + if (family !== hit.family) { + process.nextTick(() => { + // `node:dns` 的 ENOTFOUND 带 `.hostname`(不在 `ErrnoException` 的公开类型里)⇒ 显式补上, + // 让"配了 IPv4、却按 IPv6 问"这件事在报错里一眼可读。 + const err = new Error( + `getaddrinfo ENOTFOUND ${hit.host} (addr-override 只提供 IPv${hit.family})`, + ) as NodeJS.ErrnoException & { hostname?: string } + err.code = 'ENOTFOUND' + err.errno = -3008 + err.syscall = 'getaddrinfo' + err.hostname = hit.host + callback(err) + }) + return + } + if (opts.all === true) { + process.nextTick(() => callback(null, [{ address: hit.ip, family: hit.family }])) + return + } + process.nextTick(() => callback(null, hit.ip, hit.family)) +} + +/** 把回调版 `lookup` 包一层:**只**拦覆盖表里的域名,其余原样转发。 */ +function wrapLookup(orig: DnsModule['lookup']): DnsModule['lookup'] { + return function patchedLookup(hostname: string, options?: unknown, callback?: LookupCallback): void { + let opts = options + let cb = callback + if (typeof opts === 'function') { + cb = opts as LookupCallback + opts = {} + } + const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined + if (hit === undefined || cb === undefined) { + orig(hostname, opts, cb as LookupCallback) + return + } + overriddenLookup(hit, opts, cb) + } +} + +/** 打补丁(**只打一次**)。用 `createRequire` 取同一单例,确保 `net` / undici 都走新实现。 */ +function installPatch(): void { + const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule + if (originalLookup === undefined) originalLookup = nodeDns.lookup + if (originalPromisesLookup === undefined) originalPromisesLookup = nodeDns.promises.lookup + nodeDns.lookup = wrapLookup(originalLookup) + nodeDns.promises.lookup = async (hostname: string, options?: unknown): Promise => { + const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined + if (hit === undefined) return originalPromisesLookup!(hostname, options) + const opts = (options === null || typeof options !== 'object' ? {} : options) as { all?: boolean } + if (opts.all === true) return [{ address: hit.ip, family: hit.family }] + return { address: hit.ip, family: hit.family } + } +} + +/** + * **幂等安装**:解析 `raw`(缺省读 `DSHS_OVERLAY_ADDR_OVERRIDES`)并装覆盖;返回当前生效的整表。 + * + * ⚠️ 幂等但**累加**:多次调用可把新域名并进来(重连 / 多个建连点共用),不会重复打补丁。 + */ +export function ensureOverlayAddrOverrides( + raw: string = process.env.DSHS_OVERLAY_ADDR_OVERRIDES ?? '', + log: (line: string) => void = (line) => process.stdout.write(`${line}\n`), +): readonly AddrOverride[] { + const { list, bad } = parseAddrOverrides(raw) + for (const spec of bad) { + log(`[addr-override] ⛔ 忽略非法条目 "${spec}"(语法应为 <域名>=,逗号分隔)`) + } + // 未配 ⇒ **完全不生效**(连补丁都不打)⇒ 与今天逐字一致。 + if (installed.size === 0 && list.length === 0) return [] + for (const o of list) { + const prev = installed.get(o.host) + if (prev !== undefined && prev.ip !== o.ip) { + log(`[addr-override] ⚠ ${o.host} 的覆盖地址被改写:${prev.ip} -> ${o.ip}`) + announced.delete(`${o.host}=${o.ip}`) + } + installed.set(o.host, o) + } + if (!patched) { + installPatch() + patched = true + log(`[addr-override] 已启用地址覆盖(白名单 ${installed.size} 条;DNS 层定向改写,SNI 仍为域名)`) + } + for (const o of installed.values()) { + const key = `${o.host}=${o.ip}` + if (announced.has(key)) continue + announced.add(key) + log(`[addr-override] 覆盖生效:${o.host} -> ${o.ip}(IPv${o.family},直连该 IP,TLS SNI 仍为 ${o.host})`) + } + return [...installed.values()] +} + +/** 当前生效的覆盖表(只读快照)。 */ +export function currentAddrOverrides(): readonly AddrOverride[] { + return [...installed.values()] +} + +/** + * **仅供测试**:撤掉补丁并清空覆盖表。 + * ⛔ 生产路径不许调用 —— 它会把"已生效"悄悄退化成"没生效"。 + */ +export function resetOverlayAddrOverridesForTest(): void { + if (originalLookup !== undefined && originalPromisesLookup !== undefined) { + const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule + nodeDns.lookup = originalLookup + nodeDns.promises.lookup = originalPromisesLookup + } + originalLookup = undefined + originalPromisesLookup = undefined + installed.clear() + announced.clear() + patched = false +} diff --git a/src/net/relay/client.ts b/src/net/relay/client.ts new file mode 100644 index 0000000..9d51258 --- /dev/null +++ b/src/net/relay/client.ts @@ -0,0 +1,1416 @@ +/** + * relay 客户端 —— **worker 侧拨出端**(覆盖网络 R1)。 + * + * ## 为什么 worker 只拨出、不开入站 + * 实测(传输方案 §9.2):47 的 agent / 控制面端口全绑回环,公网 `curl` 一律 `rc=28`; + * 而 47 **没有本机防火墙**(`nft INPUT policy accept`)⇒ 任何 `0.0.0.0` 监听都会立刻公网可达。 + * 所以「worker 永不开入站口」是**硬约束**,不是偏好:只有拨出长连接,暴露面才是 O(1)。 + * + * ## 安全(client 侧**二次校验**,不把安全全押在服务端) + * 服务端被攻破时,它唯一能做的事是**给本 client 发 `OPEN`**。本 client 的答复规则: + * 1. 端口必须在构造时给的 `ports` 白名单里(**不看**服务端声明的任何范围); + * 2. 目标**恒为 `127.0.0.1`** —— 永不接受可路由地址,天然断掉"把本机当跳板"; + * 3. 白名单外的 `OPEN` ⇒ 回 `OPEN_ACK{ok:false}` 并**计数 + 打日志**(可观测,不静默)。 + * ⇒ 攻破 relay **不会**让攻击者碰到 22 / 15432 / 3080,最坏只碰到实例端口本身。 + * + * ## 稳定性(R1)与**韧性(R1.5:启停 / 网络变化 / 中断 / 异常)** + * + * ### 状态机(**显式**——布尔说不出"正在握手"还是"正在退避",也就无从观测恢复过程) + * ```text + * idle ──start()──▶ connecting ──ws open──▶ handshaking ──HELLO_ACK──▶ up + * ▲ │ │ │ + * │ └── 任一步失败 / 超时 ─────┴─────────────────────┘ + * │ ▼ + * └──stop()──▶ stopped ◀──stop()── backoff ──(延迟到点 / 地址变化 / 对端优雅告别)──▶ connecting + * ``` + * + * ### 四类场景的处置(每类都有对应字段,**可实测、可断言**) + * | 场景 | 现象 | 处置 | 可观测字段 | + * |---|---|---|---| + * | **节点启停**(计划内重启) | 对端发 `close 1001` / `BYE` | **不消耗退避**:`gracefulRetryMs`(默认 300ms)后立刻重连;同时 `BYE` 让服务端**秒级**标离线,而不是干等 45s 心跳超时 | `restarts` | + * | **网络变化**(IP 变 / 网卡上下) | 本机地址快照变了 | 巡检发现即**取消剩余退避、立即重拨**(旧退避的前提已失效) | `networkChanges` | + * | **网络中断**(长时间断网) | 连不上 / 连上无帧 | 指数退避 **±25% 抖动**,上限 `reconnectMaxMs`(默认 30s),**永不放弃**(不设"重试 N 次后沉默") | `attempts` / `nextRetryMs` | + * | **网络异常**(半开、静默黑洞) | TCP 不报错但再无数据 | **半开巡检**:`2.5 × hbSec` 内没收到**任何**帧 ⇒ 主动断并重连(不等 OS 的 TCP 超时) | `lastFrameAgeMs` | + * | **时钟漂移** | `HELLO` 被拒 `clock-skew` | 用拒绝帧里的 `serverTime` 算出偏移、**本地校正**后立即重试 —— 否则该节点**永久无法重连** | `clockSkewMs` | + * + * ### 诚实边界(不做做不到的事) + * 断链**必然**终止全部在途流(多路复用帧没有重放日志)—— 本实现**不假装**能流级恢复。 + * 恢复语义分三层:**连接恢复 → 注册恢复(重连即自动重注册)→ 路由恢复(Manager 按 `online` 查表拿到新回环口)**; + * **流级重试由上层负责**(HTTP 幂等请求、实例侧自身重连)。**不做半开流的"缝合"**。 + * + * @module dshs/net/relay/client + */ + +import { createHmac, randomBytes } from 'node:crypto' +import { connect } from 'node:net' +import { networkInterfaces } from 'node:os' +import type { Duplex } from 'node:stream' +import { MUX, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js' +import { OPS_NETWORK, assertNetworkId } from './network.js' +import { signProof, publicKeyOfPrivate, type NodeGrant } from './identity.js' +import { MuxDuplex } from './duplex.js' +// 序④(443/TCP 兜底 · L1):地址覆盖(只依赖 node 内建,**无循环依赖**)。 +import { ensureOverlayAddrOverrides } from './addr-override.js' + +/** 内建 `WebSocket` 的最小接口(Node 22 提供客户端实现;**不引 `ws`**)。 */ +export interface WebSocketLike { + binaryType: string + readyState: number + send(data: Uint8Array | ArrayBuffer | string): void + close(code?: number, reason?: string): void + addEventListener(type: 'open' | 'message' | 'close' | 'error', listener: (ev: { data?: unknown; code?: number; reason?: string }) => void): void + bufferedAmount?: number +} + +export type WebSocketCtor = new (url: string) => WebSocketLike + +/** + * 连接状态(**显式状态机**)。 + * `handshaking` 与 `backoff` 是 R1.5 新拆出来的:没有它们,"正在重连"与"正在握手"在日志里长得一样。 + */ +export type RelayClientState = 'idle' | 'connecting' | 'handshaking' | 'up' | 'backoff' | 'queued' | 'stopped' + +export interface RelayClientOptions { + /** relay 的 WebSocket 地址:`ws://127.0.0.1:20080/dshs-relay`(R1)或 `wss://<域名>/dshs-relay`(R2)。 */ + url: string + hostId: string + /** + * **本节点属于哪张网**(覆盖网络 ②·P0-1)。缺省 `ops`(运维网 = 平台自己的机器)。 + * + * 为什么要有它:relay 的会话表原先只按 `hostId` 索引(扁平命名空间)⇒ 平台自己的 Worker隧道 + * 与未来的用户设备会挤进同一个命名空间,一进第二类节点就退化成「一张巨网 + 靠 ACL 兜」。 + * 声明网络后,relay 侧按 **`/`** 建会话,且**只在同网内**允许 `DIAL`。 + * + * ⚠️ 该值**不进 MAC**(`HELLO` 的签名公式一字未改)—— 见 `server.ts` 模块头的理由: + * 安全性由服务端的"按网络分桶白名单 + 同网校验"保证,改 MAC 会让现网旧客户端硬断。 + * 形状非法 ⇒ **构造时抛**(不静默回落 `ops`:那会让"配错网"伪装成"网络不通")。 + */ + networkId?: string + /** 预共享密钥(hex,与 relay 服务端 `keys` 里这一项**逐字节相同**)。 */ + secret: string + /** + * **本机的节点身份**(覆盖网络 序③):私钥 PEM + 入网凭据(`grant` 及其签名)。 + * + * 给了它 ⇒ `HELLO` 会多带四个字段(`nodeKey` / `grant` / `grantSig` / `nodeSig`), + * relay 侧据此做**辅助**准入;**不给** ⇒ 退回纯 HMAC(存量形态,不强制时照旧可用)。 + * + * ⚠️ 与 `networkId` 同一条纪律:**只加字段,MAC 的输入串一字不改** —— + * 改 MAC 会让现网旧客户端硬断,而"多带几个字段"旧服务端会直接忽略(向前兼容)。 + * `nodeSig` 是对**同一个挑战串**(`hostId|ts|nonce|portsCsv`)用节点私钥的签名, + * 目的是证明**握有私钥**(凭据是公开可转发的,光出示凭据 ≠ 有身份)。 + */ + identity?: { + /** 节点私钥 PEM(**0600** 文件里读出来的那一份)。 */ + privateKeyPem: string + /** 本机的入网凭据(由受信签名者签发)。 */ + grant: NodeGrant + /** 凭据的签名(base64)。 */ + grantSig: string + } + /** 允许被中继到本机回环的端口(**白名单,唯一判据**)。 */ + ports: readonly number[] + /** + * **拨号方模式**(覆盖网络 R5):本会话不申明任何端口,改成向 relay 请求"开一条到 + * `(hostId, port)` 的流"(`openStream()`)。 + * + * 为什么它解决的是真问题:另一条路(relay 开回环监听、Manager 连回环)把落点钉在 + * **relay 主机的 `127.0.0.1`** 上 ⇒ Manager 必须与 relay 同机。拨号方模式下 Manager + * 同样"只拨出"一条 wss ⇒ **relay 放哪台机器都行**。 + * + * ⚠️ 服务端必须在 `dialers` 白名单里列出 `hostId`,否则 `HELLO` 会以 `no-ports` 被拒。 + */ + dialer?: boolean + /** 退避下限(首次重试延迟)。默认 1000ms。 */ + reconnectMinMs?: number + /** 退避上限(**永不放弃**,只是拉长间隔)。默认 30000ms。 */ + reconnectMaxMs?: number + /** 被对端**优雅告知**下线(close `1000`/`1001` 或收到 `BYE`)后的重连延迟。默认 300ms。 */ + gracefulRetryMs?: number + /** + * 优雅重连的**快速重试窗口**(ms,默认 15000;`0` = 关闭)。 + * + * 为什么是"窗口"而不是"次数":对端重启耗时**不可预测**(实测一次 `RelayServer.stop()` + * 就被对端收尾拖到 1.8s;systemd 拉起还要更多)。固定次数会"差一点点就恢复不了", + * 之后退避直接跳到上限 ⇒ 「计划内重启」被放大成长时间中断。 + * 窗口内一律用 `gracefulRetryMs` 的短间隔重试,出窗口才回落到指数退避。 + */ + gracefulBurstMs?: number + /** + * 排队等待上限(ms)。服务端**满载**(`at-capacity`)时本客户端排队重试; + * 超过这个时长仍进不去 ⇒ `lastError` 明确报障。`0`(默认)= **无限等**("排队等待"语义)。 + */ + queueMaxWaitMs?: number + /** 心跳间隔(秒)。默认 15;服务端会在 `HELLO_ACK` 里下发实际值并覆盖它。 */ + hbSec?: number + /** 半开判定阈值(ms):连续这么久没收到**任何**帧就主动重连。默认 `2.5 × hbSec`。 */ + halfOpenMs?: number + /** 本机地址巡检间隔(ms);`0` = 关闭。默认 5000。 */ + netWatchMs?: number + queueMaxBytes?: number + /** WS 出向水位(字节);超过则排队而非继续灌。默认 256 KiB。 */ + highWaterBytes?: number + log?: (line: string) => void + webSocketCtor?: WebSocketCtor + /** 测试注入点:换成假连接用。 */ + connectImpl?: (port: number, host: string) => Duplex + /** 测试注入点:时钟基准(用于构造"本机时钟漂移"场景)。默认 `Date.now`。 */ + nowImpl?: () => number + /** 测试注入点:本机地址快照(用于构造"网络变化"场景)。默认取 `os.networkInterfaces()`。 */ + netSnapshot?: () => string +} + +export interface RelayClientStatus { + state: RelayClientState + /** 本节点声明/服务端确认的网络(P0-1)—— 与 `server.ts` 的 `/status` 同一词表。 */ + network: string + /** 进入当前状态的时间戳(诊断"卡在哪个状态多久")。 */ + stateSince: number + sessionId?: string + attempts: number + /** 距下次重试还剩多少毫秒(仅 `backoff` 状态下有值)。 */ + nextRetryMs?: number + /** 最近一次失败原因(**结构化**,不是"出错了")。 */ + lastError?: string + /** 本地时钟相对 relay 的偏移(正 = 本机快)。 */ + clockSkewMs: number + /** 最近一次心跳 RTT。 */ + rttMs?: number + /** 距最后一次收到帧的毫秒数(仅 `up` 状态下有值)—— **半开检测的观测量**。 */ + lastFrameAgeMs?: number + streams: number + /** 当前**拨号**流的条数(`openStream()` 开出、尚未关闭)。拨号方模式下的主要观测量。 */ + dialStreams: number + /** + * **运行期**加进来的端口(`PORT_ADD`,覆盖网络 R4)。 + * 必须可见:这类端口在断链后要靠重放恢复,**看不见就没法判断"到底还在不在"**。 + */ + dynamicPorts: number[] + denied: number + /** 成功进入 `up` 的次数减一(连接恢复计数)。 */ + reconnects: number + /** 被对端优雅告知下线的次数(计划内重启)。 */ + restarts: number + /** 本机地址变化次数(网络变化)。 */ + networkChanges: number + /** 因满载排队过几次。 */ + queueWaits: number + /** 当前这轮排队已等待多久(ms);未排队 ⇒ `0`。 */ + queuedMs: number + bytesIn: number + bytesOut: number + /** + * **本轮"不健康"的起点**(序⑦ · 中继失败切流的触发信号)。 + * + * 定义:**首次进入 `backoff` 的时刻**(epoch ms);恢复 `up` 时**清空**。 + * ⚠️ `handshaking` / `connecting` **不计入**不健康 —— "正在握手" ≠ "挂了", + * 否则每次正常重连都会被误判成故障。只有 `backoff`(= 连不上 / 被拒 / 被踢后重试) + * 开始累计才算。 + * + * ⛔ **只读观测量**:它由既有状态机被动记账产生,**不驱动任何行为**(不改重试 / + * 不改退避 / 不新增定时器)—— 判据已经存在(D2),这里只是把它**读出来**。 + */ + unhealthySinceMs?: number + /** 距 {@link RelayClientStatus.unhealthySinceMs} 已过去多久(ms);健康 ⇒ `0`。 */ + unhealthyForMs: number + /** + * **是否还在"计划内重启"的 burst 窗口内**(序⑨ 加;`gracefulBurstMs` 窗口,默认 15 s)。 + * + * 🔑 存在的唯一理由:**换址等待必须能区分"对端正在重启"与"这台真挂了"**。 + * 两者在 `state` 上都表现为 `backoff`,靠 `state` 一个字分不开;而 burst 窗口是 + * `gracefulBurstMs` 期间**唯一**的区分依据(`scheduleRetry` 在 graceful / burst 两个分支里 + * 把 `attempts` 强制归零)。 + * + * ⛔ **只读观测量**:由既有状态机被动产生,**不驱动任何行为**(不改重试 / 不改退避 / 不新增定时器)。 + */ + inGracefulBurstWindow: boolean +} + +interface LocalStream { + id: number + port: number + tcp: Duplex + closed: boolean + paused: boolean + queued: Buffer[] + queuedBytes: number +} + +/** + * **拨号方**的一条流(R5):`duplex` 直接交给调用方 `pipe()`。 + * + * 与 `LocalStream` 分开一张表,是因为两个方向的语义**刚好相反**: + * `LocalStream.tcp` 是"本机应用"(写进去 = 交给本机应用),`DialStream.duplex` 是"调用方自己" + * (写进去 = 往 relay 送)。混在一起迟早写反。 + */ +interface DialStream { + id: number + duplex: MuxDuplex + closed: boolean + paused: boolean + queued: Buffer[] + queuedBytes: number +} + +const DEFAULT_QUEUE_MAX = 1 << 20 +const DEFAULT_HIGH_WATER = 256 * 1024 +const FLUSH_INTERVAL_MS = 20 + +export class RelayClient { + private readonly opts: RelayClientOptions + private readonly allow: Set + private readonly log: (line: string) => void + private readonly queueMax: number + private readonly highWater: number + /** 本节点所属网(P0-1)。构造时定型:网络的归属不该在运行期漂移。 */ + private readonly network: string + /** 服务端在 `HELLO_ACK` 里回显的网(用于发现"我以为是 A、服务端当成 B"这类偏差)。 */ + private serverNetwork: string | undefined + + /** + * 本客户端声明的网(P0-3)。 + * + * 谁需要它:`RelayDialer` —— 口池里的落点**只对这张网有意义**(relay 只会在本网里找目标), + * 所以申请一个"另一个网的逻辑名"的落点必须**在这里就被拒**,而不是发一个必然失败的假地址。 + */ + get networkId(): string { + return this.network + } + + /** + * 服务端在 `HELLO_ACK` 里回显的网(用于发现"我以为是 A、服务端当成 B"这类偏差)。 + */ + get acknowledgedNetworkId(): string | undefined { + return this.serverNetwork + } + + private ws: WebSocketLike | undefined + /** + * 序④(443/TCP 兜底 · L1):**地址覆盖**是否已在建连点装过。 + * 只装一次(`ensureOverlayAddrOverrides` 幂等),避免每次重连都解析 env。 + */ + private addrOverridesReady = false + private state: RelayClientState = 'idle' + private stateSince = Date.now() + private stopped = true + private attempts = 0 + private sessionId: string | undefined + private lastError: string | undefined + /** 服务端明确告知「重试也解决不了」(密钥/主机名配错)时的原因;用于把重试间隔拉到上限、不刷日志。 */ + private lastFatalReason: string | undefined + private clockSkewMs = 0 + private rttMs: number | undefined + private pingSentAt: number | undefined + private lastFrameAt = 0 + private nextRetryAt: number | undefined + private upCount = 0 + private restarts = 0 + /** + * 序⑦:**本轮"不健康"的起点**(首次进入 `backoff` 的时刻;`up` 时清空)。 + * ⛔ 纯观测量 —— 不参与任何控制流(见 {@link RelayClientStatus.unhealthySinceMs})。 + */ + private unhealthySince: number | undefined + /** 优雅重启的**快速重试窗口**截止时刻(`undefined` = 不在窗口内)。 */ + private burstUntil: number | undefined + /** burst 日志限流(每 1s 最多一行 —— 15s 窗口 × 250ms 重试 = 60 行噪音,观测≠刷屏)。 */ + private burstLoggedAt = 0 + private queueWaits = 0 + private queuedSince: number | undefined + private networkChanges = 0 + private netSignature: string | undefined + private hbSec = 15 + private hbTimer: NodeJS.Timeout | undefined + private retryTimer: NodeJS.Timeout | undefined + private flushTimer: NodeJS.Timeout | undefined + private healthTimer: NodeJS.Timeout | undefined + private netTimer: NodeJS.Timeout | undefined + private readonly streams = new Map() + /** + * **运行期**加进来的端口(覆盖网络 R4):`HELLO` 里声明的是静态表,实例端口走 `PORT_ADD`。 + * + * 为什么必须单独记一份:relay 的 endpoint 表是**随会话**建立的 —— relay 一重启/断链, + * 那些回环监听就全没了,而本地 `allow` 并不知情 ⇒ 不重放就会"本地以为还转着、实际已断"。 + * (与 `SshTunnel` 里 `forwarded` 记账同一类坑,只是那边靠 20s 对账自愈。) + */ + private readonly dynamicPorts = new Set() + private portReqId = 1 + /** 见 `DialStream`:拨号流的 id 由**本侧**分配(relay 只做配对,与 worker 侧 id 不是一个命名空间)。 */ + private readonly dialStreams = new Map() + private nextDialId = 1 + private readonly dialWaiters = new Map void; timer: NodeJS.Timeout }>() + /** 在途的 `PORT_ADD`/`PORT_DEL` 请求(按 `reqId` 关联应答;超时/断链都要收口)。 */ + private readonly portWaiters = new Map< + number, + { resolve: (r: { ok: boolean; localPort?: number; error?: string }) => void; timer: NodeJS.Timeout } + >() + private denied = 0 + private bytesIn = 0 + private bytesOut = 0 + + constructor(opts: RelayClientOptions) { + this.opts = opts + this.allow = new Set(opts.ports) + this.log = opts.log ?? ((s: string) => process.stdout.write(`${s}\n`)) + this.queueMax = opts.queueMaxBytes ?? DEFAULT_QUEUE_MAX + this.highWater = opts.highWaterBytes ?? DEFAULT_HIGH_WATER + this.hbSec = opts.hbSec ?? 15 + // 非法网络 id ⇒ **构造时抛**(配置错别拖到握手被拒才暴露)。 + this.network = assertNetworkId(opts.networkId ?? OPS_NETWORK, 'relay client networkId') + if (opts.dialer === true && opts.ports.length > 0) { + // 与服务端 `dialer-must-not-declare-ports` 同一条口径:配错就在这里炸,别等 `HELLO` 被拒。 + throw new Error('relay client: dialer mode must not declare ports') + } + } + + start(): void { + if (!this.stopped) return + this.stopped = false + this.netSignature = this.snapshotNet() + this.startNetWatch() + this.dial() + } + + /** + * 主动停机 —— **优雅告别**:先发 `BYE` 再关连接。 + * 少了这一帧,服务端要等 45s 心跳超时才认为本节点下线(计划内重启的恢复时间就从毫秒变 45 秒)。 + */ + stop(): void { + this.stopped = true + clearInterval(this.hbTimer) + clearInterval(this.healthTimer) + clearInterval(this.netTimer) + clearTimeout(this.retryTimer) + this.stopFlusher() + this.hbTimer = undefined + this.healthTimer = undefined + this.netTimer = undefined + this.retryTimer = undefined + this.nextRetryAt = undefined + this.teardownStreams() + this.failPortWaiters('client stopping') + const ws = this.ws + this.ws = undefined + this.sessionId = undefined + this.setState('stopped') + if (ws !== undefined) { + try { + if (ws.readyState === 1) this.send(encodeJsonFrame(MUX.BYE, 0, { reason: 'client stopping' })) + ws.close(1000, 'client stopping') + } catch { + /* 已关 */ + } + } + } + + status(): RelayClientStatus { + const now = this.now() + return { + state: this.state, + network: this.network, + stateSince: this.stateSince, + sessionId: this.sessionId, + attempts: this.attempts, + nextRetryMs: this.nextRetryAt === undefined ? undefined : Math.max(0, this.nextRetryAt - now), + lastError: this.lastError, + clockSkewMs: this.clockSkewMs, + rttMs: this.rttMs, + lastFrameAgeMs: this.state === 'up' ? now - this.lastFrameAt : undefined, + streams: this.streams.size, + dialStreams: this.dialStreams.size, + dynamicPorts: [...this.dynamicPorts].sort((a, b) => a - b), + denied: this.denied, + reconnects: this.upCount === 0 ? 0 : this.upCount - 1, + restarts: this.restarts, + networkChanges: this.networkChanges, + queueWaits: this.queueWaits, + queuedMs: this.queuedSince === undefined ? 0 : now - this.queuedSince, + bytesIn: this.bytesIn, + bytesOut: this.bytesOut, + unhealthySinceMs: this.unhealthySince, + unhealthyForMs: this.unhealthySince === undefined ? 0 : Math.max(0, now - this.unhealthySince), + inGracefulBurstWindow: this.state === 'backoff' && this.burstUntil !== undefined && now < this.burstUntil, + } + } + + /* ═══════════ 连接生命周期 ═══════════ */ + + private now(): number { + return (this.opts.nowImpl ?? Date.now)() + } + + private setState(s: RelayClientState): void { + if (this.state === s) return + /** + * 序⑦:**只记账、不驱动** —— 首次进入 `backoff` 记起点,回到 `up` 清空。 + * ⚠️ `connecting` / `handshaking` **不碰**这个字段:于是 + * `backoff → connecting → backoff` 这段**持续累计**("一直连不上"是一个连续事件), + * 而正常重连成功(`… → up`)会把它清掉。 + */ + if (s === 'backoff' && this.unhealthySince === undefined) this.unhealthySince = this.now() + else if (s === 'up') this.unhealthySince = undefined + this.state = s + this.stateSince = this.now() + } + + private dial(): void { + /** + * 序④(443/TCP 兜底)· L1「去 CF」:**建连点是地址覆盖的唯一注入点**。 + * + * 为什么不靠 dispatcher:本项目不引 `undici`/`ws`,用的是内建全局 `WebSocket`, + * 它不接受自定义 dispatcher ⇒「换地址但保留 SNI」只能在**解析层**做(见 `addr-override.ts`)。 + * ⛔ 未配 `DSHS_OVERLAY_ADDR_OVERRIDES` ⇒ 零动作、连补丁都不打 ⇒ 与今天逐字一致。 + */ + if (!this.addrOverridesReady) { + this.addrOverridesReady = true + ensureOverlayAddrOverrides(undefined, this.log) + } + const Ctor = this.opts.webSocketCtor ?? (globalThis as { WebSocket?: WebSocketCtor }).WebSocket + if (Ctor === undefined) { + this.log('[relay-client] fatal: no WebSocket implementation (Node ≥ 22 required)') + return + } + this.setState('connecting') + let ws: WebSocketLike + try { + ws = new Ctor(this.opts.url) + } catch (err) { + this.scheduleRetry(`ctor threw: ${errText(err)}`) + return + } + ws.binaryType = 'arraybuffer' + this.ws = ws + ws.addEventListener('open', () => this.onOpen()) + ws.addEventListener('message', (ev) => this.onMessage(ev.data)) + // 带上 close code / reason:服务端的拒绝理由要能**在 client 日志里看到**,否则又是"静默失败"。 + // `1000 / 1001` = 对端**计划内**下线(正常关闭 / going away)⇒ 走 graceful 快速重连,不消耗退避。 + ws.addEventListener('close', (ev) => { + const code = typeof ev.code === 'number' ? ev.code : 0 + const reason = typeof ev.reason === 'string' && ev.reason !== '' ? ` reason=${ev.reason}` : '' + const graceful = code === 1000 || code === 1001 + if (graceful) this.restarts += 1 + this.onDown(`closed by peer code=${code}${reason}`, graceful) + }) + ws.addEventListener('error', () => this.onDown('transport error')) + } + + private onOpen(): void { + this.setState('handshaking') + // **校正后**的时间戳:`clockSkewMs` 为 0 时即本地时间; + // 被 `clock-skew` 拒过一次之后它已经带上偏移,于是第二次握手能过 —— 这是"时钟漂移节点自愈"的落点。 + const ts = this.now() - this.clockSkewMs + const nonce = randomBytes(16).toString('hex') + // 排序后拼 csv:client 与 server 对同一字符串算 MAC ⇒ 不存在"顺序不同导致验签失败"的坑。 + const portsCsv = [...this.opts.ports].sort((a, b) => a - b).join(',') + const mac = createHmac('sha256', Buffer.from(this.opts.secret, 'hex')).update(`${this.opts.hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex') + /** + * P0-1:**只加 `network` 一个字段**,MAC 的输入串**一字不改**。 + * + * 为什么不动 MAC:现网 47 / 106 上跑的是旧客户端,改公式 = 必须两端同时升级(硬断)。 + * 而网络维度的真判据在**服务端**(按网络分桶的白名单 + 同网校验),这里的 `network` + * 只是"我属于哪张网"的声明 ⇒ 声明错了也换不到任何额外权限(见 `server.ts` 模块头)。 + */ + this.send(encodeJsonFrame(MUX.HELLO, 0, { v: 1, hostId: this.opts.hostId, network: this.network, ts, nonce, portsCsv, mac, ...this.identityFields(`${this.opts.hostId}|${ts}|${nonce}|${portsCsv}`) })) + } + + /** + * 序③:**身份字段**(`nodeKey` / `grant` / `grantSig` / `nodeSig`)。 + * + * ⛔ 不改 MAC 的输入串(见 `onOpen` 理由);这是**追加**字段,旧服务端会直接忽略。 + * ⛔ 身份**没配就不加这些字段**(而不是加空值)—— 空值会让"未配身份"与"配了但坏了" + * 在服务端日志里长得一样,而这两者的处置完全相反(前者是过渡期正常态,后者必须报障)。 + * + * `nodeSig` 覆盖的挑战串**与 MAC 完全同形**(`hostId|ts|nonce|portsCsv`)⇒ 服务端无需 + * 为身份层再造一个挑战格式;两者对"这次握手的是什么"有同一个答案。 + */ + private identityFields(challenge: string): Record { + const id = this.opts.identity + if (id === undefined) return {} + return { + nodeKey: publicKeyOfPrivate(id.privateKeyPem), + grant: id.grant, + grantSig: id.grantSig, + nodeSig: signProof(id.privateKeyPem, challenge), + } + } + + private onDown(why: string, graceful = false, fixedDelayMs?: number): void { + if (this.retryTimer !== undefined) return + const wasUp = this.state === 'up' + clearInterval(this.hbTimer) + clearInterval(this.healthTimer) + this.failPortWaiters(`link down: ${why}`) + this.failDialWaiters(`link down: ${why}`) + this.hbTimer = undefined + this.healthTimer = undefined + this.pingSentAt = undefined + this.stopFlusher() + this.teardownStreams() + this.teardownDialStreams(`link down: ${why}`) + const ws = this.ws + this.ws = undefined + this.sessionId = undefined + if (ws !== undefined) { + try { + ws.close() + } catch { + /* 已关 */ + } + } + this.setState('backoff') + if (this.stopped) return + this.scheduleRetry(wasUp ? `${why} (was up)` : why, graceful, fixedDelayMs) + } + + private scheduleRetry(why: string, graceful = false, fixedDelayMs?: number): void { + if (this.stopped || this.retryTimer !== undefined) return + this.lastError = why + const min = this.opts.reconnectMinMs ?? 1_000 + const max = this.opts.reconnectMaxMs ?? 30_000 + const gracefulMs = Math.max(50, this.opts.gracefulRetryMs ?? 300) + let delay: number + let tag = '' + let quiet = false // burst 窗口内的重复重试按秒限流(否则 15s 窗口刷 60 行) + if (fixedDelayMs !== undefined) { + // **排队等位**:按服务端给的 `retryAfterMs` 回来。不是故障 ⇒ 不消耗退避、不累计 attempts。 + this.attempts = 0 + delay = Math.max(100, Math.round(fixedDelayMs)) + tag = ' [queued]' + } else if (graceful) { + // 计划内下线不是故障 ⇒ **不消耗退避**,并开启一段**快速重试窗口**(对端重启不是瞬间就绪)。 + const winMs = this.opts.gracefulBurstMs ?? gracefulBurstMsDefault() + this.burstUntil = winMs > 0 ? this.now() + winMs : undefined + this.burstLoggedAt = 0 + this.attempts = 0 + delay = gracefulMs + tag = ` [graceful, burst window ${winMs}ms]` + } else if (this.burstUntil !== undefined && this.now() < this.burstUntil) { + // 重启窗口内**持续**短间隔重试(重启耗时不可预测);出窗口才回落到指数退避。 + this.attempts = 0 + delay = gracefulMs + const now = this.now() + if (now - this.burstLoggedAt >= 1_000) { + this.burstLoggedAt = now + tag = ` [burst, ${Math.round(this.burstUntil - now)}ms left]` + } else { + quiet = true + } + } else { + this.attempts += 1 + const exp = Math.min(max, min * 2 ** Math.min(this.attempts - 1, 6)) + const jitter = exp * 0.25 * (Math.random() * 2 - 1) // ±25% 抖动:多 worker 不能在同一秒一起冲 + delay = Math.max(200, Math.round(exp + jitter)) + // 服务端已明确"重试也解决不了"(密钥/主机名错)⇒ 退到上限,避免把日志刷爆;**仍然持续重试**,配置一改好即自动恢复。 + if (this.lastFatalReason !== undefined) delay = max + } + this.nextRetryAt = this.now() + delay + if (!quiet) { + this.log( + `[relay-client] down (${why}); attempt #${this.attempts}${tag}, retry in ${delay}ms` + + (this.lastFatalReason !== undefined ? ` ⛔ fatal=${this.lastFatalReason}(修好配置即自动恢复)` : ''), + ) + } + const timer = setTimeout(() => { + this.retryTimer = undefined + this.nextRetryAt = undefined + if (!this.stopped) this.dial() + }, delay) + // ⚠️ 这里**故意不 unref**(本文件其余内部定时器都 unref,唯独这个不行): + // 断链之后 WebSocket handle 已消失,若连"重试计划"也是 unref 的,事件循环里就没有活跃句柄了 + // ⇒ **进程静默退出**:既不重连、也不报错,日志停在最后一行。 + // 实测(R1.5 跨机):服务端重启后客户端"人间蒸发",`/status` 里再也等不到它上线。 + // 「我正在重连」本身就是必须让进程活下去的理由。 + this.retryTimer = timer + } + + /** 取消剩余退避、**立即**重拨(用于"网络变化"与"时钟校正完成"这两类前提已变的场景)。 */ + private retryNow(why: string): void { + if (this.stopped) return + if (this.retryTimer !== undefined) { + clearTimeout(this.retryTimer) + this.retryTimer = undefined + this.nextRetryAt = undefined + } + this.log(`[relay-client] immediate retry: ${why}`) + this.dial() + } + + /** + * 半开巡检 —— **网络异常的兜底**。 + * 半开(断网 / NAT 表项过期 / 中间设备静默丢包)时 TCP 层**不会报错**,只会永远静默; + * 靠 OS 的 TCP 超时要几百秒。这里用"多久没收到任何帧"这个可观测量主动判死。 + */ + private startHealthWatch(): void { + clearInterval(this.healthTimer) + const halfOpen = this.opts.halfOpenMs ?? Math.max(3_000, Math.round(this.hbSec * 1_000 * 2.5)) + const tick = Math.max(500, Math.round(halfOpen / 4)) + const timer = setInterval(() => { + if (this.stopped || this.state !== 'up') return + const age = this.now() - this.lastFrameAt + if (age > halfOpen) { + this.log(`[relay-client] half-open suspected: no frame for ${age}ms (> ${halfOpen}ms) ⇒ reconnect`) + this.onDown(`half-open: no frame for ${age}ms`) + } + }, tick) + timer.unref() + this.healthTimer = timer + } + + /** + * 本机地址巡检 —— **网络变化的处置**。 + * IP 变了 / 网卡上下,旧的退避前提就失效了:此时干等退避纯属浪费,直接重拨。 + * (`up` 状态下不主动断:地址变化不一定影响已建立的连接,交给半开巡检判定。) + */ + private startNetWatch(): void { + clearInterval(this.netTimer) + const every = this.opts.netWatchMs ?? 5_000 + if (every <= 0) return + const timer = setInterval(() => { + if (this.stopped) return + const sig = this.snapshotNet() + if (sig === this.netSignature) return + this.netSignature = sig + this.networkChanges += 1 + this.log(`[relay-client] local address change #${this.networkChanges} ⇒ ${sig}`) + if (this.state !== 'up') this.retryNow('local address changed') + }, every) + timer.unref() + this.netTimer = timer + } + + private snapshotNet(): string { + if (this.opts.netSnapshot !== undefined) return this.opts.netSnapshot() + const out: string[] = [] + const ifaces = networkInterfaces() + for (const name of Object.keys(ifaces).sort()) { + for (const info of ifaces[name] ?? []) { + if (info.internal) continue + out.push(`${name}:${info.family}:${info.address}`) + } + } + return out.sort().join('|') + } + + /* ═══════════ 帧处理 ═══════════ */ + + private onMessage(data: unknown): void { + const buf = toBuffer(data) + if (buf === null) return + this.lastFrameAt = this.now() // 半开检测的**唯一**依据:任何帧(含心跳)都算"链路还活着" + const frame = decodeMux(buf) + if (frame === null) { + this.log('[relay-client] short frame from relay ⇒ reconnect') + this.onDown('short frame') + return + } + switch (frame.type) { + case MUX.HELLO_ACK: + this.onHelloAck(frame) + return + case MUX.HELLO_ERR: + this.onHelloErr(frame) + return + case MUX.BYE: { + const msg = parseJsonPayload(frame.payload) + const reason = msg !== null && typeof msg.reason === 'string' ? msg.reason : 'peer said bye' + this.restarts += 1 + this.log(`[relay-client] peer BYE: ${reason} ⇒ fast reconnect`) + this.onDown(`peer bye: ${reason}`, true) + return + } + case MUX.OPEN: + this.onOpenRequest(frame) + return + case MUX.PORT_ACK: + this.onPortAck(frame) + return + case MUX.DIAL_ACK: + this.onDialAck(frame) + return + case MUX.DATA: + this.onRemoteData(frame) + return + case MUX.CLOSE: { + const dial = this.dialStreams.get(frame.streamId) + if (dial !== undefined) { + // 对端关流 ⇒ 结束**读侧**(`eof`,不是 `destroy`):写侧允许半开,调用方可能还有最后一点要送。 + dial.duplex.eof() + dial.closed = true + this.dialStreams.delete(frame.streamId) + this.log(`[relay-client] dial stream ${frame.streamId} closed by relay`) + return + } + const known = this.streams.has(frame.streamId) + this.closeLocal(frame.streamId, 'relay closed stream') + if (known) this.log(`[relay-client] stream ${frame.streamId} closed by relay`) + return + } + case MUX.PING: + this.send(encodeMux(MUX.PONG, 0, frame.payload)) + return + case MUX.PONG: + // RTT 采样:`PING` 发出到 `PONG` 回来的往返(含服务端处理时间)。 + if (this.pingSentAt !== undefined) { + this.rttMs = this.now() - this.pingSentAt + this.pingSentAt = undefined + } + return + default: + this.log(`[relay-client] unknown mux type=${frame.type} ⇒ reconnect`) + this.onDown('unknown frame type') + } + } + + private onHelloAck(frame: MuxFrame): void { + const msg = parseJsonPayload(frame.payload) + if (msg === null || typeof msg.sessionId !== 'string') { + this.onDown('malformed hello-ack') + return + } + this.sessionId = msg.sessionId + // P0-1:服务端回显它认定的网。不一致 ⇒ **响亮地说出来**(多数是两边 `DSHS_OVERLAY_NETWORK_ID` + // 配置不同;不打印的话,症状只会是"跨机代理莫名其妙拨不通",而那是七八种原因里最难猜的一种)。 + this.serverNetwork = typeof msg.network === 'string' ? msg.network : undefined + if (this.serverNetwork !== undefined && this.serverNetwork !== this.network) { + this.log( + `[relay-client] ⚠ 网络不一致:本机声明 "${this.network}",服务端认定 "${this.serverNetwork}"` + + '(检查两端的 DSHS_OVERLAY_NETWORK_ID / 白名单归属)', + ) + } + this.attempts = 0 + this.lastFatalReason = undefined + this.burstUntil = undefined + this.burstLoggedAt = 0 + this.queuedSince = undefined + this.nextRetryAt = undefined + // 时钟偏移:用服务端时间戳对齐(不含 RTT/2 修正,量级足够——我们只关心是否越过 ±60s 的窗口)。 + if (typeof msg.serverTime === 'number') this.clockSkewMs = this.now() - msg.serverTime + const hbSec = typeof msg.hbSec === 'number' && msg.hbSec > 0 ? msg.hbSec : this.hbSec + this.hbSec = hbSec + const accepted = Array.isArray(msg.accepted) ? msg.accepted.filter((p): p is number => typeof p === 'number') : [] + const rejected = [...this.opts.ports].filter((p) => !accepted.includes(p)) + this.upCount += 1 + this.lastFrameAt = this.now() + this.setState('up') + this.log( + `[relay-client] registered host=${this.opts.hostId} network=${this.network} session=${this.sessionId} accepted=[${accepted.join(',')}]` + + ` clockSkew=${this.clockSkewMs}ms` + + (rejected.length > 0 ? ` ⚠ rejected=[${rejected.join(',')}]` : '') + + (this.upCount > 1 ? ` (reconnect #${this.upCount - 1})` : ''), + ) + clearInterval(this.hbTimer) + const timer = setInterval(() => { + this.pingSentAt = this.now() + this.send(encodeMux(MUX.PING, 0)) + }, hbSec * 1_000) + timer.unref() + this.hbTimer = timer + this.startHealthWatch() + // ── 注册恢复:**重连后把运行期加的端口重放一遍** ──────────────────────────── + // relay 的 endpoint 表随会话消失;不重放 = 本地以为转着、Manager 侧其实已经没有那个口了 + // (症状是"实例页偶发打不开",且与网络抖动相关 —— 最难查的那种)。 + this.allow.clear() + for (const p of this.opts.ports) this.allow.add(p) + for (const p of this.dynamicPorts) this.allow.add(p) + if (this.dynamicPorts.size > 0) void this.replayDynamicPorts() + } + + /** 把运行期端口重新声明一遍(重连后调用;逐条独立,单条失败不影响其余)。 */ + private async replayDynamicPorts(): Promise { + for (const port of [...this.dynamicPorts]) { + const res = await this.requestPort(port, true, 5_000) + if (!res.ok) this.log(`[relay-client] ⚠ 重连后重放端口 ${port} 未成功:${res.error ?? 'unknown'}`) + } + } + + /** + * 运行期加一个可被中继的端口 —— 对应 ssh 版的 `ssh -O forward`(覆盖网络 R4)。 + * + * **返回 `false` 而不抛**:加不上不该让 agent 崩 —— 实例在本机照样可用,只是"跨机代理" + * 这一跳不可用,这与 `SshTunnel.forward()` 的既有语义一致(跨机代理降级 ≠ 本机功能降级)。 + */ + async addPort(port: number, timeoutMs = 5_000): Promise { + if (this.allow.has(port)) return true + const res = await this.requestPort(port, true, timeoutMs) + if (res.ok) { + this.dynamicPorts.add(port) + // ⚠️ 必须同时进 `allow` —— 否则服务端会开回环口、也把 `OPEN` 发过来,而这里因为白名单 + // 里没有它而回 `OPEN_ACK{ok:false}` ⇒ **口开着、流全被拒**(最难看的一种半通)。 + this.allow.add(port) + } else { + this.log(`[relay-client] addPort ${port} 失败:${res.error ?? 'unknown'}`) + } + return res.ok + } + + /** 撤销一个运行期端口 —— 对应 `ssh -O cancel`;**不再有监听留在 relay 上**。 */ + async removePort(port: number, timeoutMs = 5_000): Promise { + if (!this.dynamicPorts.has(port)) return true + const res = await this.requestPort(port, false, timeoutMs) + if (res.ok) { + this.dynamicPorts.delete(port) + this.allow.delete(port) + } + return res.ok + } + + /** 当前运行期端口(诊断用 —— `describeClientStatus` 会打出来)。 */ + get runtimePorts(): number[] { + return [...this.dynamicPorts].sort((a, b) => a - b) + } + + private requestPort( + port: number, + add: boolean, + timeoutMs: number, + ): Promise<{ ok: boolean; localPort?: number; error?: string }> { + if (!Number.isInteger(port) || port <= 0 || port > 65535) return Promise.resolve({ ok: false, error: 'bad-port' }) + if (this.state !== 'up' || this.ws === undefined) { + // 链路没起来就没法谈"加端口":明确回失败(**不排队**)—— 上层有 20s 对账自愈兜底。 + return Promise.resolve({ ok: false, error: `not up (${this.state})` }) + } + const reqId = this.portReqId++ + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.portWaiters.delete(reqId) + resolve({ ok: false, error: 'timeout' }) + }, Math.max(200, timeoutMs)) + timer.unref() + this.portWaiters.set(reqId, { resolve, timer }) + if (!this.send(encodeJsonFrame(add ? MUX.PORT_ADD : MUX.PORT_DEL, 0, { reqId, port }))) { + clearTimeout(timer) + this.portWaiters.delete(reqId) + resolve({ ok: false, error: 'send failed' }) + } + }) + } + + private onPortAck(frame: MuxFrame): void { + const msg = parseJsonPayload(frame.payload) + const reqId = msg !== null && typeof msg.reqId === 'number' ? msg.reqId : -1 + const waiter = this.portWaiters.get(reqId) + if (waiter === undefined) return // 迟到的 ACK(已超时收口):丢掉,不当错误 + clearTimeout(waiter.timer) + this.portWaiters.delete(reqId) + const ok = msg !== null && msg.ok === true + const localPort = msg !== null && typeof msg.localPort === 'number' ? msg.localPort : undefined + const error = msg !== null && typeof msg.error === 'string' ? msg.error : undefined + waiter.resolve(ok ? { ok: true, localPort } : { ok: false, error: error ?? 'rejected' }) + } + + /** 断链时把所有在途端口请求收口(否则调用方会一直挂在超时上)。 */ + private failPortWaiters(why: string): void { + for (const [reqId, w] of [...this.portWaiters]) { + clearTimeout(w.timer) + this.portWaiters.delete(reqId) + w.resolve({ ok: false, error: why }) + } + } + + /** + * 注册被拒(`HELLO_ERR`)—— **区分"能自救"与"得改配置"**。 + * `clock-skew` 是唯一能自愈的一类:拿服务端时间戳校正本地基准后立即重试。 + * 其余(`unknown-host` / `bad-mac` / `nonce-replay`…)属配置问题,退到上限重试并明确打标。 + */ + private onHelloErr(frame: MuxFrame): void { + const msg = parseJsonPayload(frame.payload) + const reason = msg !== null && typeof msg.reason === 'string' ? msg.reason : 'unknown' + const retryable = msg !== null && msg.retryable === true + const serverTime = msg !== null && typeof msg.serverTime === 'number' ? msg.serverTime : undefined + if (serverTime !== undefined) this.clockSkewMs = this.now() - serverTime + if (!retryable) this.lastFatalReason = reason + + // ── 满载 ⇒ **排队等待**(不是"失败",也不是"换一个节点偷偷连")───────────── + if (reason === 'at-capacity') { + const retryAfterMs = msg !== null && typeof msg.retryAfterMs === 'number' && msg.retryAfterMs > 0 ? msg.retryAfterMs : 5_000 + this.queueWaits += 1 + if (this.queuedSince === undefined) this.queuedSince = this.now() + const waited = this.now() - this.queuedSince + const cap = this.opts.queueMaxWaitMs ?? 0 + if (cap > 0 && waited > cap) { + this.lastError = `at-capacity: queued ${waited}ms > ${cap}ms` + this.log(`[relay-client] ⛔ 满载排队已 ${waited}ms 超过上限 ${cap}ms ⇒ 记故障(上限可调 queueMaxWaitMs)`) + } else { + this.log( + `[relay-client] relay 满载 ⇒ 排队(第 ${this.queueWaits} 次,已等 ${Math.round(waited)}ms,${Math.round(retryAfterMs)}ms 后重试);` + + `容量信息 ${JSON.stringify(msg?.capacity ?? {})}`, + ) + } + this.onDown(`at-capacity (queued #${this.queueWaits}, waited ${Math.round(waited)}ms)`, false, retryAfterMs) + if (!this.stopped) this.setState('queued') // 语义:**在排队**,而不是"退避重试" + return + } + + this.log( + `[relay-client] HELLO rejected reason=${reason} retryable=${retryable} clockSkew≈${this.clockSkewMs}ms` + + (retryable ? ' ⇒ 按提示修正后立即重试' : ' ⛔ 非重试可解(检查密钥 / hostId 配置)'), + ) + // 能自救的那一类:本地校正已在上面的 `clockSkewMs` 里生效,立即重试(`onOpen` 会用校正后的基准重算 MAC)。 + this.onDown(`hello rejected: ${reason}`, reason === 'clock-skew') + } + + /* ═══════════ 拨号方(覆盖网络 R5) ═══════════ */ + + /** + * 请 relay 开一条到 `(target, port)` 的双向流,返回可直接 `pipe()` 的 `Duplex`。 + * + * **与 `addPort()` 的关系是"对称的另一半"**:`addPort` = 让别人能连**我**(inbound), + * `openStream` = 让我能连**别人**(outbound)。relay 侧两条路共用同一套流机制与并发口径。 + * + * **失败一律抛错**(不返回"看起来能用"的对象):拨号失败必须让调用方**立刻**看见 —— + * 否则又退化成"静默失败",那正是 R1 起反复强调要根治的病。 + */ + async openStream(target: string, port: number, timeoutMs = 5_000): Promise { + if (this.opts.dialer !== true) throw new Error('relay client: openStream() requires dialer mode') + if (this.state !== 'up') throw new Error(`relay client: link not up (state=${this.state})`) + if (!Number.isInteger(port) || port <= 0 || port > 65535) throw new Error(`relay client: bad port ${port}`) + if (target === '') throw new Error('relay client: empty dial target') + const id = this.nextDialId++ + if (this.nextDialId > 0xffffffff) this.nextDialId = 1 + const duplex = new MuxDuplex({ + onOut: (chunk) => this.pumpDial(id, chunk), + onClosed: () => this.send(encodeJsonFrame(MUX.CLOSE, id, { reason: 'dialer closed' })), + }) + const st: DialStream = { id, duplex, closed: false, paused: false, queued: [], queuedBytes: 0 } + this.dialStreams.set(id, st) + /** + * ⛔ **这里不许挂 `duplex.on('data', …)` 把读侧接回出向。** + * + * 出向**唯一**入口是 `_write` → `onOut` → `pumpDial`:`duplex.write()` 走 `_write`, + * `tcp.pipe(duplex)` 也走 `_write` ⇒ 两者本来就是同一条路,**不需要**再补一个监听。 + * + * 挂上去等于把**读侧**(`onRemoteData` → `feed()` → `push()` 触发 `'data'`)也接到出向上 + * ⇒ **入向的字节被原样回灌**:worker 把它写进 agent socket,agent 拿 `HTTP/1.1 200 OK` + * 当请求行解析 ⇒ 非法字节流 ⇒ Fastify `clientError` 回 `400`(写裸 socket、**不写 pino + * 日志**)⇒ 用户 `POST /api/dsh/enter` 回 500。 + * + * 2026-09-17 实锤(序 ⑭):106 `tcpdump` 见 ` > 19000` 的载荷是 + * `HTTP/1.1 200 OK … {"isDirectory":true}`(响应方向反了);本仓 + * `test/relay.test.mjs` **T23** 逐帧记账复现同一现象(修前红:目标端收到 2 段回灌字节)。 + */ + const res = await this.requestDial(id, target, port, timeoutMs) + if (!res.ok) { + this.closeDial(id, res.error ?? 'dial refused') + throw new Error(`relay client: dial ${target}:${port} refused: ${res.error ?? 'unknown'}`) + } + this.log(`[relay-client] dial ${target}:${port} up (stream=${id})`) + return duplex + } + + private requestDial(id: number, target: string, port: number, timeoutMs: number): Promise<{ ok: boolean; error?: string }> { + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.dialWaiters.delete(id) + resolve({ ok: false, error: 'dial timeout' }) + }, timeoutMs) + timer.unref() + this.dialWaiters.set(id, { resolve, timer }) + this.send(encodeJsonFrame(MUX.DIAL, id, { target, port })) + }) + } + + private onDialAck(frame: MuxFrame): void { + const w = this.dialWaiters.get(frame.streamId) + if (w === undefined) return + this.dialWaiters.delete(frame.streamId) + clearTimeout(w.timer) + const msg = parseJsonPayload(frame.payload) + if (msg !== null && msg.ok === true) { + w.resolve({ ok: true }) + return + } + const error = msg !== null && typeof msg.error === 'string' ? msg.error : 'refused' + w.resolve({ ok: false, error }) + } + + /** + * 拨号流的出向:调用方写下来的字节 ⇒ 打进 relay(水位满时排队,**不丢**)。 + * + * 返回值 = "是否已直接送出"(`false` ⇒ 已入队,调用方应稍后 `resume()`)—— 与 + * `MuxDuplexOptions.onOut` 的背压语义对齐。 + */ + private pumpDial(id: number, chunk: Buffer): boolean { + const st = this.dialStreams.get(id) + if (st === undefined || st.closed) return false + if (st.paused || this.overWater()) { + const ok = queueOrDropDial(st, chunk, this.queueMax, () => this.closeDial(id, 'egress queue overflow'), this.log) + if (!ok) return false + if (!st.paused) { + st.paused = true + st.duplex.pause() + } + this.ensureFlusher() + return false + } + this.bytesOut += chunk.length + this.send(encodeMux(MUX.DATA, id, chunk)) + return true + } + + private closeDial(id: number, why: string): void { + const st = this.dialStreams.get(id) + if (st === undefined || st.closed) return + st.closed = true + this.dialStreams.delete(id) + this.send(encodeJsonFrame(MUX.CLOSE, id, { reason: why })) + try { + st.duplex.destroy() + } catch { + /* 已断 */ + } + } + + private failDialWaiters(why: string): void { + for (const w of this.dialWaiters.values()) { + clearTimeout(w.timer) + w.resolve({ ok: false, error: why }) + } + this.dialWaiters.clear() + } + + private teardownDialStreams(why: string): void { + for (const st of [...this.dialStreams.values()]) { + st.closed = true + try { + st.duplex.destroy(new Error(why)) + } catch { + /* 已断 */ + } + } + this.dialStreams.clear() + } + + private onOpenRequest(frame: MuxFrame): void { + const msg = parseJsonPayload(frame.payload) + const port = msg !== null && typeof msg.port === 'number' ? msg.port : -1 + const refuse = (why: string): void => { + this.denied += 1 + this.log(`[relay-client] DENY open stream=${frame.streamId} port=${port}: ${why}`) + this.send(encodeJsonFrame(MUX.OPEN_ACK, frame.streamId, { ok: false, error: why })) + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) return refuse('bad port') + /** + * 拨号方**不服务任何端口**:收到 `OPEN` 只可能是服务端把它当 worker 用了(配错)。 + * 必须**响亮地拒**,而不是默默建立一条指向本机随机端口的流(那就是最早的"静默拨错"同族病)。 + */ + if (this.opts.dialer === true) return refuse('dialer serves no ports') + // 🔒 唯一判据是**本机白名单**,不采信服务端声明的任何范围。 + if (!this.allow.has(port)) return refuse('port not in worker allow-list') + const factory = this.opts.connectImpl ?? ((p: number, h: string) => connect(p, h)) + const tcp = factory(port, '127.0.0.1') // 目标恒为回环,永不接受可路由地址 + tuneStream(tcp) + const st: LocalStream = { id: frame.streamId, port, tcp, closed: false, paused: false, queued: [], queuedBytes: 0 } + this.streams.set(frame.streamId, st) + let settled = false + tcp.once('connect', () => { + settled = true + this.send(encodeJsonFrame(MUX.OPEN_ACK, frame.streamId, { ok: true })) + }) + tcp.on('data', (chunk: Buffer) => this.pumpToRelay(st, chunk)) + tcp.once('error', (err: Error) => { + if (settled) { + this.closeLocal(frame.streamId, 'local tcp error') + return + } + settled = true + this.denied += 1 + this.log(`[relay-client] local connect 127.0.0.1:${port} failed: ${err.message}`) + this.send(encodeJsonFrame(MUX.OPEN_ACK, frame.streamId, { ok: false, error: 'local connect failed' })) + }) + tcp.once('close', () => { + if (!st.closed) this.closeLocal(frame.streamId, 'local tcp closed') + }) + } + + private onRemoteData(frame: MuxFrame): void { + // 拨号流先查(它的 id 属于本侧命名空间,与 `streams` 那张表不重叠,但顺序上先判更安全)。 + const dial = this.dialStreams.get(frame.streamId) + if (dial !== undefined) { + this.bytesIn += frame.payload.length + // 读侧水位满时 `feed()` 返回 false:这里**不能**丢字节,交给 relay 侧的队列兜(它有自己的上限)。 + dial.duplex.feed(frame.payload) + return + } + const st = this.streams.get(frame.streamId) + if (st === undefined || st.closed) return + this.bytesIn += frame.payload.length + if (st.paused) { + queueOrDrop(st, frame.payload, this.queueMax, () => this.closeLocal(frame.streamId, 'ingress queue overflow'), this.log) + return + } + if (!st.tcp.write(frame.payload)) { + st.paused = true + st.tcp.once('drain', () => this.flush(st)) + } + } + + private pumpToRelay(st: LocalStream, chunk: Buffer): void { + if (st.closed) return + if (st.paused || this.overWater()) { + const ok = queueOrDrop(st, chunk, this.queueMax, () => this.closeLocal(st.id, 'egress queue overflow'), this.log) + if (!ok) return + if (!st.paused) { + st.paused = true + st.tcp.pause() + } + this.ensureFlusher() + return + } + this.bytesOut += chunk.length + this.send(encodeMux(MUX.DATA, st.id, chunk)) + } + + private overWater(): boolean { + const ws = this.ws + if (ws === undefined) return true + const buffered = ws.bufferedAmount + return typeof buffered === 'number' && buffered > this.highWater + } + + /** WS 缓冲回落后把暂停流的队列放出去;只在**确实有暂停流**时跑定时器。 */ + private ensureFlusher(): void { + if (this.flushTimer !== undefined || this.stopped) return + const timer = setInterval(() => { + if (this.stopped || this.ws === undefined) { + this.stopFlusher() + return + } + if (!this.overWater()) { + for (const st of [...this.streams.values()]) { + if (st.closed || !st.paused) continue + while (st.queued.length > 0 && !this.overWater()) { + const chunk = st.queued.shift() + if (chunk === undefined) break + st.queuedBytes -= chunk.length + this.bytesOut += chunk.length + this.send(encodeMux(MUX.DATA, st.id, chunk)) + } + if (st.queued.length === 0) { + st.paused = false + st.tcp.resume() + } + } + // 拨号流的队列走**同一个** flusher:两套定时器迟早互相踩(`flushTimer` 只有一个)。 + for (const st of [...this.dialStreams.values()]) { + if (st.closed || !st.paused) continue + while (st.queued.length > 0 && !this.overWater()) { + const chunk = st.queued.shift() + if (chunk === undefined) break + st.queuedBytes -= chunk.length + this.bytesOut += chunk.length + this.send(encodeMux(MUX.DATA, st.id, chunk)) + } + if (st.queued.length === 0) { + st.paused = false + st.duplex.resume() + } + } + } + let stillPaused = false + for (const st of this.streams.values()) if (!st.closed && st.paused) stillPaused = true + for (const st of this.dialStreams.values()) if (!st.closed && st.paused) stillPaused = true + if (!stillPaused) this.stopFlusher() + }, FLUSH_INTERVAL_MS) + timer.unref() + this.flushTimer = timer + } + + private stopFlusher(): void { + if (this.flushTimer !== undefined) clearInterval(this.flushTimer) + this.flushTimer = undefined + } + + private flush(st: LocalStream): void { + if (st.closed) return + st.paused = false + while (st.queued.length > 0) { + const chunk = st.queued.shift() + if (chunk === undefined) break + st.queuedBytes -= chunk.length + if (!st.tcp.write(chunk)) { + st.paused = true + st.tcp.once('drain', () => this.flush(st)) + return + } + } + } + + private closeLocal(streamId: number, why: string): void { + const st = this.streams.get(streamId) + if (st === undefined || st.closed) return + st.closed = true + this.streams.delete(streamId) + this.send(encodeJsonFrame(MUX.CLOSE, streamId, { reason: why })) + try { + st.tcp.end() + } catch { + /* 已断 */ + } + const timer = setTimeout(() => st.tcp.destroy(), 2_000) + timer.unref() + } + + private teardownStreams(): void { + for (const st of [...this.streams.values()]) { + st.closed = true + try { + st.tcp.destroy() + } catch { + /* 已断 */ + } + } + this.streams.clear() + } + + private send(buf: Buffer): boolean { + const ws = this.ws + if (ws === undefined || ws.readyState !== 1) return false + try { + ws.send(buf) + return true + } catch (err) { + this.log(`[relay-client] send failed: ${errText(err)}`) + return false + } + } +} + +/** 队列入队;超上限 ⇒ 回调(断流)并返回 `false`。 */ +function queueOrDrop(st: LocalStream, chunk: Buffer, max: number, onOverflow: () => void, log: (s: string) => void): boolean { + st.queued.push(chunk) + st.queuedBytes += chunk.length + if (st.queuedBytes > max) { + log(`[relay-client] stream ${st.id} queue ${st.queuedBytes}B > ${max}B ⇒ drop stream`) + onOverflow() + return false + } + return true +} + +/** 与 `queueOrDrop` 同口径,只是对象换成拨号流(字段刻意同名;断流动作不同,所以没合并)。 */ +function queueOrDropDial(st: DialStream, chunk: Buffer, max: number, onOverflow: () => void, log: (s: string) => void): boolean { + st.queued.push(chunk) + st.queuedBytes += chunk.length + if (st.queuedBytes > max) { + log(`[relay-client] dial stream ${st.id} queue ${st.queuedBytes}B > ${max}B ⇒ drop stream`) + onOverflow() + return false + } + return true +} + +/** `net.Socket` 专有开关(`Duplex` 上没有);测试注入的假连接会走 `?.` 空转。 */ +function tuneStream(tcp: Duplex): void { + const s = tcp as Duplex & { setNoDelay?: (on: boolean) => void } + s.setNoDelay?.(true) +} + +function toBuffer(data: unknown): Buffer | null { + if (data instanceof ArrayBuffer) return Buffer.from(data) + if (ArrayBuffer.isView(data)) return Buffer.from(data.buffer, data.byteOffset, data.byteLength) + if (typeof data === 'string') return Buffer.from(data, 'utf8') + return null +} + +function errText(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +/** 便于诊断:把 client 当前状态压成一行(`systemctl status` / 日志里直接可读)。 */ +export function describeClientStatus(s: RelayClientStatus): string { + const retry = s.nextRetryMs === undefined ? '' : ` nextRetryIn=${Math.round(s.nextRetryMs)}ms` + const frame = s.lastFrameAgeMs === undefined ? '' : ` lastFrameAge=${s.lastFrameAgeMs}ms` + const rtt = s.rttMs === undefined ? '' : ` rtt=${s.rttMs}ms` + const dyn = s.dynamicPorts.length === 0 ? '' : ` dyn=[${s.dynamicPorts.join(',')}]` + return ( + `state=${s.state}(for ${Math.round(Date.now() - s.stateSince)}ms) net=${s.network} session=${s.sessionId ?? '-'} ` + + `attempts=${s.attempts}${retry} streams=${s.streams}${dyn} denied=${s.denied} ` + + `reconnects=${s.reconnects} restarts=${s.restarts} netChanges=${s.networkChanges} ` + + `queue=${s.queueWaits}(waited ${Math.round(s.queuedMs)}ms) ` + + `skew=${s.clockSkewMs}ms${rtt}${frame} in=${s.bytesIn}B out=${s.bytesOut}B` + + (s.lastError === undefined ? '' : ` lastError="${s.lastError}"`) + ) +} + +/** + * **`gracefulBurstMs` 的默认值口径**(序⑨ 参数表化)。 + * + * 改造前它是本仓**唯一一个不可配的时延常量**(全仓只有 `?? 15_000` 一处、无 env 键、无装配点赋值) + * ⇒ 运行时无法调,违反本线"阈值零魔数"纪律(`overlay-probe` 的 E6)。 + * 现在:可用 `RELAY_GRACEFUL_BURST_MS` 覆写,**默认值语义逐字不变**(`15_000`)。 + * + * ⚠️ 改这个值 = **改"计划内重启不触发切流"的窗口长度**({@link RelayClientStatus.inGracefulBurstWindow} + * 就是它)⇒ 动它必须先回写交接单(序⑨ D3:默认值 ⛔ 不许改)。 + */ +export function gracefulBurstMsDefault(env: Record = process.env): number { + const raw = env.RELAY_GRACEFUL_BURST_MS + const n = raw === undefined || raw.trim() === '' ? Number.NaN : Number(raw) + return Number.isFinite(n) && n >= 0 ? Math.round(n) : 15_000 +} + +/** + * **换址等待的"终态失败"判据**(序⑨ · RC-1 的唯一判据来源)。 + * + * ## 它治的是什么 + * `waitUpOn` 原来只轮询 `state === 'up'`,直到 `deadline` 才 `return false` + * ⇒ **连不上的死候选**与**连得上但慢的候选**在这一层**不可区分**,代价**恒为 `upTimeoutMs`**。 + * 实测(序⑨ §2-P10 逐行复核):生产目录前两条候选同在 47(`web/server.ts` 的 `open` 注释已承认 + * "这个坑一定会踩到")⇒ 每次从 47 切走**必然**先试同机的 `relay-direct`(已随 47 一起死) + * ⇒ **固定白等 12 000 ms**:`[relay-skip] ⛔ 新通道起不来` 与 open 发起时刻的间隔 ≡ `upTimeoutMs`。 + * + * ## 三条同时成立才算"终态失败" + * 1. **已在 `backoff`** —— 连不上 / 被拒 / 被踢后重试。 + * ⚠️ `connecting` / `handshaking` **不算**("正在连" ≠ "挂了");`queued` 也不算 + * (满载排队会 `attempts = 0`,下一条判据自然不成立)。 + * 2. **不在 burst 窗口内**({@link RelayClientStatus.inGracefulBurstWindow} 为假)—— + * `gracefulBurstMs`(默认 15 s)窗口内是"对端正在重启",**计划内下线不是故障**(本文件顶部设计) + * ⇒ ⛔ 把它当终态失败就等于"每次 relay 重启/部署都切一次流",正是 D5 要防的抖动。 + * 3. **已发生 ≥ 1 次非 burst 退避**(`attempts ≥ 1`)—— 首次失败即记账 + * (`scheduleRetry` 在非 graceful / 非 burst 分支里 `attempts += 1`), + * 含 `lastFatalReason`(密钥 / 主机名错)分支。 + * + * ⛔ **只读判据**:不驱动重试 / 不改退避 / 不新增定时器(D2 = 复用既有状态机)。 + */ +export function openedChannelFailedTerminally(s: RelayClientStatus): boolean { + return s.state === 'backoff' && !s.inGracefulBurstWindow && s.attempts >= 1 +} + +/** {@link waitUpOnStatus} 的选项。 */ +export interface WaitUpStatusOptions { + /** 轮询间隔(ms)。默认 `100` —— 与改造前那三份内联实现逐字一致。 */ + pollMs?: number + /** + * 判死时回调(各装配点接自己的 logger)。 + * ⛔ **只用于日志**:它不参与判定,也不改变返回值。 + */ + onDead?: (s: RelayClientStatus) => void +} + +/** + * **换址版"等它到 `up`"** —— 序⑦ 起三个装配点各写一份,序⑨ D7 收口成**唯一一份实现**。 + * + * 语义:**非抛**,只回答"通没通"(`open()` 要的是布尔)。 + * - 到 `up` ⇒ `true`; + * - **终态失败**({@link openedChannelFailedTerminally})⇒ **立即 `false`**(序⑨ RC-1:⛔ 不白等满); + * - 到 `timeoutMs` ⇒ `false`(**慢候选照旧享受完整预算** —— 护栏用例 F18)。 + * + * 三处消费点(D7):`web/server.ts`(C1 · Manager 换址)/`worker/relay-tunnel.ts` + * (C2 · worker 实例面)/`net/relay/main.ts`(C3 · 独立 `relay --client`)。 + * ⛔ **启动路径不用它**:启动只要"口池绑好"(见 C1 的 `open` 注释),不依赖网络到达。 + */ +export async function waitUpOnStatus( + client: { status(): RelayClientStatus }, + timeoutMs: number, + opts: WaitUpStatusOptions = {}, +): Promise { + const pollMs = Math.max(10, Math.round(opts.pollMs ?? 100)) + const deadline = Date.now() + timeoutMs + for (;;) { + const st = client.status() + if (st.state === 'up') return true + if (openedChannelFailedTerminally(st)) { + opts.onDead?.(st) + return false + } + if (Date.now() >= deadline) return false + await new Promise((r) => setTimeout(r, pollMs)) + } +} diff --git a/src/net/relay/dialer.ts b/src/net/relay/dialer.ts new file mode 100644 index 0000000..1e9cc54 --- /dev/null +++ b/src/net/relay/dialer.ts @@ -0,0 +1,312 @@ +/** + * `RelayDialer` —— **会合可换机的收口件**(覆盖网络 R5)。 + * + * ## 它解决的确切问题 + * R1–R4 的落点是「relay 在**自己主机**的回环上开监听,Manager 去连那个回环口」。这带来一条 + * 隐式前提:**Manager 必须与 relay 同机**(否则它根本够不到 relay 的 `127.0.0.1`)。 + * 于是"中继可换机 / 可多实例 / Manager 不再兼任会合"这件事**做不到**。 + * + * ## 做法(关键的一步换位) + * 把落点从 **relay 主机** 搬到 **Manager 本机**: + * - Manager 也像 worker 一样**只拨出**一条 wss(`RelayClient` 的 `dialer` 模式); + * - 本类在 Manager 自己的回环上预先绑一**小池**本机口,每个口号"指向"某个 `(hostId, port)`; + * - 有连接进来 ⇒ `client.openStream(hostId, port)` ⇒ 拿到一条 mux 流 ⇒ 与本机 socket 对接。 + * + * ⇒ 对外形态**完全没变**(还是"一个 `127.0.0.1:port`"),所以 `translateEndpoint` 的调用方 + * (`proxy.ts` / `RemoteSpawner` / `RemoteUserFs`)**一行都不用改**;变的是这个口号从哪来 —— + * 从"relay 主机"变成"Manager 自己"。**relay 放哪台机器都不再影响 Manager。** + * + * ## 为什么用「预绑池」而不是「按需 listen」 + * `translateEndpoint` 是**同步**的(在请求路径上),而 `listen()` 是异步的 —— 按需绑会出现 + * "口号已经返回、监听还没起来"的竞态窗口(表现为**首次**请求偶发连接被拒,最难查的那种)。 + * 池在**启动时**一次性绑好 ⇒ 拿口号是纯查表,零竞态;容量上限也变成显式、可观测的。 + * + * ⚠️ 池口号必须**避开 OS 临时端口段(32768–60999)**与实例端口段,否则会偶发撞号。 + * + * @module dshs/net/relay/dialer + */ + +import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net' +import type { RelayClient } from './client.js' +import { parseLogicalName } from './network.js' + +export interface RelayDialerOptions { + /** **拨号方模式**的客户端(`dialer: true`)。 */ + client: RelayClient + /** 本机口池起点。**必须避开** OS 临时端口段与实例端口段。 */ + portBase: number + /** 本机口池扫描宽度。 */ + portSpan: number + /** 池大小 = 最多同时挂多少个 `(hostId, port)` 落点。默认 64。 */ + poolSize?: number + log?: (line: string) => void +} + +interface Slot { + server: TcpServer + localPort: number + /** 当前指向的 `(hostId, port)`;`undefined` = 空闲可分配。 */ + key?: string + /** 在途连接数(决定该槽位能不能被回收重分配)。 */ + active: number + lastUsed: number +} + +const DEFAULT_POOL = 64 + +export class RelayDialer { + private readonly client: RelayClient + private readonly log: (line: string) => void + private readonly portBase: number + private readonly portSpan: number + private readonly poolSize: number + /** + * **本拨号通道声明的那张网**(P0-3)—— 从客户端拿,**不另配一份**。 + * 两处各配一次就会出现"通道在 A 网、判据按 B 网"这种最难查的错配。 + */ + private readonly network: string + private readonly slots: Slot[] = [] + private bound = 0 + private denied = 0 + /** + * **未分配槽位被连接命中的次数**(只丢弃那条连接、槽位原样保留)—— 序⑮ 缺陷 B 的判别器。 + * 这个计数存在的唯一理由:该路径过去**零日志零计数**,缺陷只在下一次分配时才以 + * `ECONNREFUSED` 的样子露面(现场看不出"这个口早就废了")。 + */ + private stray = 0 + private closed = false + + constructor(opts: RelayDialerOptions) { + this.client = opts.client + this.log = opts.log ?? ((s: string) => process.stdout.write(`${s}\n`)) + this.portBase = opts.portBase + this.portSpan = opts.portSpan + this.poolSize = opts.poolSize ?? DEFAULT_POOL + this.network = opts.client.networkId + } + + /** 绑定整个池。**在 Manager 开始服务前 await 它**(否则 `localPortFor()` 恒返回 `undefined`)。 */ + async start(): Promise { + const next = this.portBase + let p = next + const limit = this.portBase + this.portSpan + for (let i = 0; i < this.poolSize; i++) { + let slot: Slot | undefined + while (p < limit) { + const port = p++ + const server = createTcpServer({ pauseOnConnect: true }) + const ok = await new Promise((resolve) => { + const onErr = (): void => { + server.off('error', onErr) + resolve(false) + } + server.once('error', onErr) + server.listen(port, '127.0.0.1', () => { + server.off('error', onErr) + resolve(true) + }) + }) + if (!ok) { + server.close() + continue + } + slot = { server, localPort: port, active: 0, lastUsed: 0 } + break + } + if (slot === undefined) { + this.log(`[relay-dialer] 口池只绑到 ${i}/${this.poolSize}(${this.portBase}..${limit} 已用尽)`) + break + } + slot.server.on('connection', (tcp: Socket) => this.onConn(slot as Slot, tcp)) + this.slots.push(slot) + } + this.bound = this.slots.length + this.log(`[relay-dialer] 本机落点池就绪:${this.bound} 个口(${this.portBase}..${this.portBase + this.portSpan})`) + } + + /** + * 取 `(逻辑名, port)` 在本机的落点口号 —— **同步**返回,可直接用在 `translateEndpoint` 里。 + * + * 返回 `undefined` 的四种情形**都必须让上层失败关闭**(不许回退成"原样透传",那会变成 + * "Manager 拿 worker 侧口号往自己本机拨"的老毛病): + * ① 池未就绪;② 没有空闲槽位且都在途(容量到顶,会在日志里点名);③ 参数非法; + * ④ **跨网**(P0-3,见下)。 + * + * ## 入参是逻辑名(P0-3) + * 键必须是 `/:`:只按裸 `hostId` 建键时,两张网各有一台同名 + * host 会**互相复用同一个落点** —— 也就是"给 A 网的口,发给了 B 网的目标"。 + */ + localPortFor(name: string, port: number): number | undefined { + if (this.closed || this.bound === 0) return undefined + if (name === '' || !Number.isInteger(port) || port <= 0 || port > 65535) return undefined + /** + * ⛔ **跨网 ⇒ 本机口池直接拒**(P0-3 · 控制面侧的那道门)。 + * + * 本通道只声明了一张网(`RelayClient.networkId`),relay 也**只会在这张网里找目标** + * ⇒ 按"另一张网的逻辑名"分配落点 = 把一个**必然失败的落点**当成可用地址发出去 + * (上层会拿 `127.0.0.1:<口>` 去连,被 relay 拒、或撞上别的网的口)。 + * 失败关闭 + **点名两边是哪张网**:不许让它退化成"连不通"。 + */ + const { network } = parseLogicalName(name) + if (network !== this.network) { + this.denied += 1 + this.log( + `[relay-dialer] ⛔ 跨网拒绝 ${name}:${port}(本通道 network=${this.network},目标 network=${network})`, + ) + return undefined + } + const key = `${name}:${port}` + const now = Date.now() + const hit = this.slots.find((s) => s.key === key) + if (hit !== undefined) { + hit.lastUsed = now + return hit.localPort + } + /** + * 先找从未分配过的空槽,再找"空闲且最久没用过"的槽(LRU 回收)—— 两者都不动在途连接。 + * + * 🔴 **两个候选都必须"真的还在听"**(`server.listening`,序⑮ 缺陷 B): + * 一旦某个槽位的监听已经关掉却还留在 `slots` 里,这里就会把一个**死口号**当成可用落点 + * 发出去 ⇒ 调用方 `ECONNREFUSED`(实测 = `/api/dsh/enter` 回 500 `fetch failed`)。 + * `listening` 是"这笔账到底还算不算数"的**唯一权威来源**;宁可失败关闭(下面点名), + * ⛔ 也绝不把一个没人听的口号交出去。 + */ + const free = this.slots.find((s) => s.key === undefined && s.server.listening) ?? this.lruIdle() + if (free === undefined) { + this.denied += 1 + const dead = this.slots.filter((s) => !s.server.listening).map((s) => s.localPort) + this.log( + dead.length === 0 + ? `[relay-dialer] 口池已满(${this.bound} 个全在途)⇒ 拒绝 ${key}(失败关闭,不静默回退)` + : `[relay-dialer] ⛔ 口池无可成交槽位(${this.bound} 个口里 ${dead.length} 个已不在听:${dead.join(',')})⇒ 拒绝 ${key}(失败关闭,不静默回退)`, + ) + return undefined + } + free.key = key + free.lastUsed = now + // **必须可见**:不打印的话,"这个回环口号对应哪台机的哪个端口"在运行期完全不可查, + // 出问题只能靠猜(这正是本线反复踩的"静默"同族病)。重分配(LRU 回收)单独标出来。 + this.log(`[relay-dialer] 落点 127.0.0.1:${free.localPort} -> ${key}`) + return free.localPort + } + + private lruIdle(): Slot | undefined { + let best: Slot | undefined + for (const s of this.slots) { + if (s.active > 0) continue + // 不在听的槽⛔ 不许当"可回收空位"(同上:宁可失败关闭,也不发死口号) + if (!s.server.listening) continue + if (best === undefined || s.lastUsed < best.lastUsed) best = s + } + return best + } + + /** + * 本机连接 ⇒ 拨号流 ⇒ 对接。 + * + * `pauseOnConnect` 让内核替我们挡住"流还没开好时的请求字节"⇒ 不存在丢头几个字节的竞态 + * (与 relay 侧 `onManagerConn` 用的是同一条手法)。拨号失败**必须**把连接拆掉:让调用方 + * 看到连接被重置,而不是一个挂住不动的请求。 + */ + private onConn(slot: Slot, tcp: Socket): void { + tcp.setNoDelay(true) + const key = slot.key + if (key === undefined) { + /** + * 🔴 **未分配槽位被一条连接命中**(序⑮ · 缺陷 B 的修复点)。 + * + * ⛔ 这里**绝不许** `slot.server.close()` —— 关掉的只是"这个口的服务器", + * 而槽位**仍留在 `slots` 里、`key` 仍是 `undefined`** ⇒ `localPortFor()` 的 + * `slots.find((s) => s.key === undefined)` **下次还会选中它**,把一个**已经没人听**的 + * 口号当成可用落点发出去 ⇒ 调用方拿 `ECONNREFUSED`(**用户可见**: + * `POST /api/dsh/enter` 回 500 `fetch failed: connect ECONNREFUSED 127.0.0.1:25000`; + * 47 上实测 3 条,2026-09-17 16:00:31/35/40;现场表现为"池报 64 个口、`ss` 只见 63")。 + * + * 另一种"看似彻底"的修法(顺手把槽位从 `slots` 摘掉)**是净退化**:那等于让**任意一条本地 + * 连接**都能永久蚕食池容量 —— 扫 64 次就把池扫空(R11)。 + * ⇒ 正确做法 = **只丢弃这条无法路由的连接,槽位原样保留**(它本来就没被分配过, + * 监听继续有效、容量不变)。同时**必须留下计数与点名日志**:这个缺陷最难查之处 + * 恰恰是这条路径过去**零日志**,只在下一次分配时以 `ECONNREFUSED` 的样子露面。 + */ + this.stray += 1 + this.log( + `[relay-dialer] ⛔ 未分配落点 127.0.0.1:${slot.localPort} 收到一条连接 ⇒ 只丢弃该连接、槽位保留(累计 ${this.stray} 次)`, + ) + tcp.destroy() + return + } + const idx = key.lastIndexOf(':') + /** + * ⛔ `DIAL.target` 是**裸 hostId**,不含网络段(服务端会显式拼上**拨号方自己**那张网, + * 见 `server.ts#onDial`)。键从 P0-3 起是逻辑名 ⇒ 这里**必须**剥掉网络段再发。 + * 漏剥的表现极具误导性:服务端按 `logicalName('ops', 'ops/w-106')` = `ops/ops/w-106` 找会话, + * 找不到 ⇒ 回 `target-offline`("节点离线"),而节点其实**好好在册** —— + * 实测踩过一次(2026-09-17 07:32 线上,`refused: 8 / streamsOpened: 0`)。 + */ + const { hostId } = parseLogicalName(key.slice(0, idx)) + const port = Number(key.slice(idx + 1)) + void this.client + .openStream(hostId, port, 5_000) + .then((duplex) => { + if (tcp.destroyed) { + duplex.destroy() + return + } + slot.active += 1 + let released = false + const release = (): void => { + if (released) return + released = true + slot.active -= 1 + } + tcp.on('close', release) + duplex.on('close', release) + tcp.on('error', () => tcp.destroy()) + duplex.on('error', () => tcp.destroy()) + tcp.pipe(duplex).pipe(tcp) + tcp.resume() // `pauseOnConnect` 到此结束:流已就绪,可以放字节了 + }) + .catch((err: unknown) => { + this.log(`[relay-dialer] 拨 ${key} 失败:${err instanceof Error ? err.message : String(err)}`) + tcp.destroy() + }) + } + + close(): void { + this.closed = true + for (const s of this.slots) { + try { + s.server.close() + } catch { + /* 已关 */ + } + } + this.slots.length = 0 + this.bound = 0 + } + + /** + * 诊断视图(`/status` 与管理面用):池有多大、几个已分配、被拒过几次、被误连过几次。 + * + * ⚠️ `pool` 报的是**实际还在听的槽位数**(不是 `start()` 那一刻的那个数):它必须与 + * "`ss` 数得出来的池口号数"**恒等**,否则就是"池在撒谎"(序⑮ 判据 a)。恒等不是靠这个写法 + * 保证的(修复已让槽位永不被单方面关掉),而是靠它把任何未来的关闭动作**立刻显形**。 + */ + status(): { + pool: number + assigned: number + denied: number + stray: number + slots: { key: string; localPort: number; active: number }[] + } { + return { + pool: this.slots.filter((s) => s.server.listening).length, + assigned: this.slots.filter((s) => s.key !== undefined).length, + denied: this.denied, + stray: this.stray, + slots: this.slots + .filter((s) => s.key !== undefined) + .map((s) => ({ key: s.key as string, localPort: s.localPort, active: s.active })), + } + } +} diff --git a/src/net/relay/directory.ts b/src/net/relay/directory.ts new file mode 100644 index 0000000..ceef106 --- /dev/null +++ b/src/net/relay/directory.ts @@ -0,0 +1,789 @@ +/** + * 覆盖网络 P0-2:**首次入网引导的三级链**(内置种子 → 签名目录 → 离线降级)。 + * + * ## 为什么要有这个模块 + * + * ① 的终态里,客户端(Manager / worker / 未来的用户设备)**只能**从 env 拿到中继地址。 + * 一旦域名或机器要换,`env` 只存在于**我们自己的部署脚本**里 —— 用户设备上的那个值 + * 改不到 ⇒ `§A2` 点名的灾难:**所有客户端必须升级重装**。 + * 引导链把"地址"从**编译期常量**变成**可在线轮换的下发物**: + * + * | 级 | 来源 | 作用 | + * |---|---|---| + * | ① | **env 显式** | 运维最后手段,**压制一切**(调试 / 应急,不查目录、不联网) | + * | ② | **缓存目录**(未过期) | 日常路径:省一次网络往返,也保证"控制面挂掉不影响已入网节点" | + * | ③ | **内置种子** | 冷启动:拿种子 origin 去取**签名目录**,拿到 `relays[]` 才连 | + * | ④ | 离线降级 | 目录不可达 ⇒ 用**过期但签名有效**的缓存(只影响**新节点加入**) | + * | ⑤ | 种子兜底 | 连目录都取不到 ⇒ 直接用种子地址本身当入口(种子 = 中继入口,**同源**) | + * + * ## 🔴 三条不可动摇的判据 + * + * 1. **签名不对 ⇒ 失败关闭**:目录只有在**受信公钥验签通过**时才被采用, + * **既不写缓存、也不拿它的地址去连**(`§A2`)。受信公钥为空 ⇒ 同样拒绝(**不可验 = 不接受**)。 + * 2. **`bootstrap[]` 是轮换的唯一抓手**:取目录的 origin **优先取缓存里的 `bootstrap[]`**, + * 而不是编译进来的种子 ⇒ 目录里把 `bootstrap[]` 改成新地址,**下一次刷新就跟着走**, + * **不重装、不升级**(`§A2` 的关键设计要求)。 + * 3. **本模块只处理地址,不碰身份**:目录里**没有** hostId / 密钥 / 用户数据 / 内网地址; + * 连上之后能不能拨、能拨谁,仍由 relay 侧「网维度 + 白名单」(P0-1)决定。 + * + * 纯函数(payload / 解析 / 验签 / 取址决策)与 IO(缓存读写、取目录)分开放, + * 前者可单测、后者只做搬运 —— 与 `network.ts` 同一风格。 + * @module dshs/net/relay/directory + */ + +import { + createPrivateKey, + createPublicKey, + sign as cryptoSign, + verify as cryptoVerify, + type KeyObject, +} from 'node:crypto' +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' +// 序④(443/TCP 兜底 · L1):**取目录这一腿也要走地址覆盖**(另一处注入点在 `client.ts` 的建连点)。 +import { ensureOverlayAddrOverrides } from './addr-override.js' + +/** 目录端点的**固定路径**(服务端注册、客户端派生都用它,⛔ 不要在调用方各写一份)。 */ +export const DIRECTORY_PATH = '/dshs-overlay/bootstrap' + +/** 签名载荷的**版本标签**:换载荷格式时改这里 ⇒ 老客户端**验签失败 ⇒ 失败关闭**(不会误读新格式)。 */ +export const DIRECTORY_PAYLOAD_TAG = 'dshs-overlay-directory/v1' + +/** 目录文档结构版本。未知版本 ⇒ 拒绝(而不是"尽力解析")。 */ +export const DIRECTORY_VERSION = 1 + +/** 刷新周期的安全区间(防止目录里写"缓存十年"把轮换能力锁死)。 */ +export const MIN_REFRESH_SECONDS = 30 +export const MAX_REFRESH_SECONDS = 86400 + +/** 默认刷新周期(秒):服务端签发时用、客户端拿不到目录时的兜底节奏也用。 */ +export const DEFAULT_DIRECTORY_REFRESH_SECONDS = 300 + +/** 一份目录里地址条目的上限(防放大 / 防误配)。 */ +const MAX_ENTRIES = 8 +const MAX_ENTRY_LEN = 512 + +/** Ed25519 裸 32 字节公钥的 **SPKI DER 前缀**(`302a300506032b6570032100`)。 */ +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') + +/** + * **内置种子的字面量**(引导链的常量位)。已持证书的门户域名、**不新增域名成本**。 + * ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量, + * **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。 + */ +export const DEFAULT_OVERLAY_SEED = 'https://alotbuy.com/dshs-relay' + +/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */ +export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.DSHS_OVERLAY_BOOTSTRAP_SEEDS + if (raw === undefined) return [DEFAULT_OVERLAY_SEED] + return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))] +} + +/** + * 从环境变量取受信目录公钥。**没配 ⇒ 空数组 ⇒ 目录一律不接受**(不可验 = 不接受), + * 于是引导链退化成"env / 种子兜底"两级 —— 行为等价于 P0-2 之前,不会更危险。 + */ +export function overlayEnvTrustedKeys(env: NodeJS.ProcessEnv = process.env): string[] { + const raw = env.DSHS_OVERLAY_DIR_PUBKEYS + if (raw === undefined) return [] + return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))] +} + +/** 引导目录文档(**签名覆盖全部字段**,顺序由 {@link directoryPayload} 固定)。 */export interface OverlayDirectory { + /** 结构版本,必须 === {@link DIRECTORY_VERSION}。 */ + version: number + /** 签发时间(ISO 8601)。 */ + issuedAt: string + /** 客户端缓存多久后重新取目录(秒)。 */ + refreshAfterSeconds: number + /** 该目录所属的网(P0-1 的 `network_id`,运维网 = `ops`)。 */ + network: string + /** 当前可用的**中继端点**(连接用;`https://` 会被归一化成 `wss://`)。 */ + relays: string[] + /** **可轮换的引导地址清单**(下一轮取目录的 origin;也是最终兜底入口)。 */ + bootstrap: string[] +} + +/** 验签结论。失败一律带**具体原因** —— 静默拒绝会让"目录写错"看起来像"网络不通"。 */ +export type DirectoryVerdict = + | { ok: true; doc: OverlayDirectory; payload: string; keyIndex: number } + | { ok: false; reason: string } + +/** 取址来源(写日志用;也是验收判据要断言的东西)。 */ +export type OverlayAddressSource = + | 'env' + | 'cache' + | 'seed-directory' + | 'stale-cache' + | 'seed-fallback' + | 'none' + +/** 一次取址的结论。 */ +export interface OverlayRelayResolution { + /** 归一化后的中继地址(`ws://` / `wss://`);空 = 取不到(行为等同"未配 relay")。 */ + url: string + source: OverlayAddressSource + /** 供日志 / 取证:具体用了哪个 origin、哪条拒绝原因。 */ + detail: string + /** 本次结论的**保鲜期**(秒):调用方据此决定下一次刷新的节奏。 */ + refreshAfterSeconds: number +} + +// ── 纯函数:载荷 / 解析 / 验签 ───────────────────────────────────────────── + +/** + * **签名载荷的规范形式**。服务端签发与客户端验签都必须走这个函数 —— + * 用 `JSON.stringify` 会踩"键序不同 ⇒ payload 不同"的坑(换一个 TS 版本就可能变)。 + */ +export function directoryPayload(doc: OverlayDirectory): string { + return [ + DIRECTORY_PAYLOAD_TAG, + String(doc.version), + doc.issuedAt, + String(doc.refreshAfterSeconds), + doc.network, + doc.relays.join(','), + doc.bootstrap.join(','), + ].join('\n') +} + +/** 把 hex / base64 / base64url 解成字节;都不是 ⇒ `undefined`。 */ +function decodeRawKey(spec: string): Buffer | undefined { + if (/^[0-9a-fA-F]{64}$/.test(spec)) return Buffer.from(spec, 'hex') + if (/^[A-Za-z0-9+/=_-]+$/.test(spec)) { + const b64 = spec.replace(/-/g, '+').replace(/_/g, '/') + const buf = Buffer.from(b64, 'base64') + if (buf.length === 32) return buf + } + return undefined +} + +/** + * 解析一个受信公钥:接受 **PEM(SPKI)** 或 **裸 32 字节(hex / base64 / base64url)**。 + * 解析不出来 ⇒ `undefined`(调用方**跳过**这一把,而不是让整条链断掉)。 + */ +export function publicKeyFrom(spec: string): KeyObject | undefined { + const s = spec.trim() + if (s === '') return undefined + if (s.includes('BEGIN')) { + try { + const k = createPublicKey(s) + return k.asymmetricKeyType === 'ed25519' ? k : undefined + } catch { + return undefined + } + } + const raw = decodeRawKey(s) + if (raw === undefined) return undefined + try { + const k = createPublicKey({ + key: Buffer.concat([ED25519_SPKI_PREFIX, raw]), + format: 'der', + type: 'spki', + }) + return k.asymmetricKeyType === 'ed25519' ? k : undefined + } catch { + return undefined + } +} + +/** 单个地址条目:必须是非空、≤512 字符、`http(s)` / `ws(s)` 的绝对 URL。 */ +function parseEntry(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined + const s = value.trim() + if (s === '' || s.length > MAX_ENTRY_LEN) return undefined + let u: URL + try { + u = new URL(s) + } catch { + return undefined + } + if (u.protocol !== 'https:' && u.protocol !== 'http:' && u.protocol !== 'wss:' && u.protocol !== 'ws:') { + return undefined + } + return s +} + +/** 地址清单:数组、≤{@link MAX_ENTRIES} 条、条条合法、去重后返回。 */ +function parseEntryList(value: unknown): string[] | undefined { + if (!Array.isArray(value) || value.length > MAX_ENTRIES) return undefined + const out: string[] = [] + for (const item of value) { + const entry = parseEntry(item) + if (entry === undefined) return undefined + if (!out.includes(entry)) out.push(entry) + } + return out +} + +/** + * 严格解析(**只做形状与取值域校验,不验签**)。任何一条不合规 ⇒ `undefined`: + * 对齐"默认拒绝" —— 目录是**下发物**,宁可不接受,也不要"尽力解析出一个半成品"。 + */ +export function parseDirectory(raw: unknown): OverlayDirectory | undefined { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined + const r = raw as Record + if (r.version !== DIRECTORY_VERSION) return undefined + if (typeof r.issuedAt !== 'string' || r.issuedAt.length > 40) return undefined + if (Number.isNaN(Date.parse(r.issuedAt))) return undefined + if (typeof r.refreshAfterSeconds !== 'number' || !Number.isFinite(r.refreshAfterSeconds)) return undefined + if (r.refreshAfterSeconds < MIN_REFRESH_SECONDS || r.refreshAfterSeconds > MAX_REFRESH_SECONDS) { + return undefined + } + if (typeof r.network !== 'string' || r.network === '' || r.network.length > 64) return undefined + const relays = parseEntryList(r.relays) + if (relays === undefined) return undefined + const bootstrap = parseEntryList(r.bootstrap) + if (bootstrap === undefined) return undefined + if (relays.length === 0 && bootstrap.length === 0) return undefined + return { + version: DIRECTORY_VERSION, + issuedAt: r.issuedAt, + refreshAfterSeconds: r.refreshAfterSeconds, + network: r.network, + relays, + bootstrap, + } +} + +/** + * 验签。**受信公钥为空 ⇒ 拒绝**(不可验 = 不接受)—— 这条是 `§A2` 的失败关闭语义, + * ⛔ 不要"配不上密钥就先用着",那等于把引导链的信任根交给 MITM(TLS 之外的那一层防线就没了)。 + */ +export function verifyDirectory( + rawDoc: unknown, + sig: unknown, + keys: readonly string[], +): DirectoryVerdict { + const doc = parseDirectory(rawDoc) + if (doc === undefined) return { ok: false, reason: 'bad-document' } + if (typeof sig !== 'string' || sig.trim() === '') return { ok: false, reason: 'no-signature' } + const sigBuf = Buffer.from(sig.trim(), 'base64') + if (sigBuf.length !== 64) return { ok: false, reason: 'bad-signature-length' } + if (keys.length === 0) return { ok: false, reason: 'no-trusted-keys' } + const payload = directoryPayload(doc) + for (let i = 0; i < keys.length; i += 1) { + const key = publicKeyFrom(keys[i] ?? '') + if (key === undefined) continue + if (cryptoVerify(null, Buffer.from(payload, 'utf8'), key, sigBuf)) { + return { ok: true, doc, payload, keyIndex: i } + } + } + return { ok: false, reason: 'signature-mismatch' } +} + +/** 签发(**只跑在控制面**)。私钥只从内存里的 PEM 字符串进来,⛔ 本模块不读任何密钥文件。 */ +export function signDirectory(doc: OverlayDirectory, privateKeyPem: string): string { + const key = createPrivateKey(privateKeyPem) + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error(`overlay directory key must be ed25519 (got ${String(key.asymmetricKeyType)})`) + } + return cryptoSign(null, Buffer.from(directoryPayload(doc), 'utf8'), key).toString('base64') +} + +/** 组装一份**已经清洗过**的目录文档(服务端与单测共用,保证两边载荷完全一致)。 */ +export function buildDirectoryDocument(input: { + relays: readonly string[] + bootstrap: readonly string[] + network: string + now: number + refreshAfterSeconds?: number +}): OverlayDirectory { + const relays = parseEntryList(input.relays.slice(0, MAX_ENTRIES)) ?? [] + const bootstrap = parseEntryList(input.bootstrap.slice(0, MAX_ENTRIES)) ?? [] + if (relays.length === 0 && bootstrap.length === 0) { + throw new Error('overlay directory needs at least one relay or bootstrap address') + } + const refresh = input.refreshAfterSeconds ?? 300 + if (!Number.isFinite(refresh) || refresh < MIN_REFRESH_SECONDS || refresh > MAX_REFRESH_SECONDS) { + throw new Error(`invalid refreshAfterSeconds ${String(refresh)}`) + } + return { + version: DIRECTORY_VERSION, + issuedAt: new Date(input.now).toISOString(), + refreshAfterSeconds: Math.floor(refresh), + network: input.network, + relays, + bootstrap, + } +} + +// ── 纯函数:地址派生 / 归一化 ─────────────────────────────────────────────── + +/** + * 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。 + * + * 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`, + * 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。 + * 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。 + */ +export function directoryUrlFor(entry: string): string | undefined { + const raw = parseEntry(entry) + if (raw === undefined) return undefined + const u = new URL(raw) + if (u.pathname !== DIRECTORY_PATH) { + u.pathname = DIRECTORY_PATH + u.search = '' + u.hash = '' + } + if (u.protocol === 'wss:') u.protocol = 'https:' + if (u.protocol === 'ws:') u.protocol = 'http:' + return u.toString() +} + +/** + * 中继地址归一化:`https→wss` / `http→ws`(`RelayClient` 只吃 ws 方言)。 + * ⛔ 不改路径 —— `/dshs-relay` 这个 path 是 nginx 的 location 判据(R2 定的)。 + */ +export function toRelayUrl(entry: string): string | undefined { + const raw = parseEntry(entry) + if (raw === undefined) return undefined + const u = new URL(raw) + if (u.protocol === 'https:') u.protocol = 'wss:' + else if (u.protocol === 'http:') u.protocol = 'ws:' + return u.toString() +} + +/** + * 从一份目录里列出**全部候选中继地址**(有序)。 + * + * 顺序 = `relays[]` → `bootstrap[]`(控制面下发的**有序**清单,⛔ 不重排); + * 按 `host` 去重(同一台机器在多条清单里各写一遍只算一个候选);非法项跳过。 + * + * ## 为什么需要"全部"而不是"第一个"(序⑦ · 本函数存在的理由) + * 改造前 `pickFromDoc` **取到第一个可用项就 `return`** ⇒ 候选集退化成**单点**: + * 目录里排第二的那台中继**永远选不中**(除非首位此刻不可用)。后果是"换址"这条路径形同虚设 + * —— 重解析一百次,拿回来的还是同一个字符串 ⇒ 上层按"地址没变"直接 `return`。 + * ⇒ **根因是"候选集退化成单点",不是"没写重解析"**(对序⑥ §8.8-1 的证据级细化)。 + */ +function listCandidatesFromDoc(doc: OverlayDirectory): string[] { + const out: string[] = [] + const seen = new Set() + for (const candidate of [...doc.relays, ...doc.bootstrap]) { + const url = toRelayUrl(candidate) + if (url === undefined) continue + const key = hostKeyOf(url) + if (key === undefined) continue + if (seen.has(key)) continue + seen.add(key) + out.push(url) + } + return out +} + +/** + * 主机键 = `host[:port]`(丢掉 scheme 与 path/query/hash);不是绝对 URL ⇒ `undefined`。 + * + * ⚠️ **不能连 scheme 一起比**:目录 origin 是 `https://…`(HTTP 取目录),而清单里的是 + * `wss://…`(中继入口)—— 同一个 host 在两条链路上 scheme 本来就不同。`URL#host` 会把默认端口 + * (80/443/ws 80/wss 443)一并省掉 ⇒ 既"同 host 即同源",又不会被非默认端口误判成同源。 + */ +function hostKeyOf(url: string): string | undefined { + try { + return new URL(url).host.toLowerCase() + } catch { + return undefined + } +} + +/** + * **同源优先**(序④ · 443/TCP 兜底):目录是**哪个 origin 答出来的**,就优先用它的同源中继入口。 + * + * ## 为什么必须有这一条 + * `relays[]` 是控制面下发的**有序**清单,而 `pickFromDoc` 取的是**首位**(= 主入口)。 + * 于是"主入口不可达、兜底 origin 却能答出目录"这种降级场景里,客户端仍会去连**首位那条** + * ⇒ 兜底入口**永远轮不到** ——「多一条入口」落不成「CF / 门户 conf 不可用时还能连」, + * 整个兜底就只剩装饰性(端点能 101,但没有任何客户端会去连它)。 + * + * ## 它不是新概念 + * 它就是既有约定「**引导地址 = 中继入口同源**」在**选择时刻**的落地 + * (见 `config.ts#overlayBootstrapSeeds` 与 {@link DIRECTORY_PATH} 的注释)。 + * + * ## 什么时候**不动** + * - origin 不在文档的 `relays[]` / `bootstrap[]` 里(那它就不是一个中继入口)⇒ 返回 `undefined`, + * 调用方退回 `pickFromDoc`(**与今天逐字一致**); + * - 命中的就是首位 ⇒ 替换是空操作(调用方按字符串比对后不进日志)。 + * + * ⚠️ 只作用于"**目录被某个 origin 取到手**"这一支;缓存两条路径(② 新鲜缓存 / ④ 过期缓存)没有 + * "答出者"信息 ⇒ 保持原样(代价:CF 打挂后,最多等一个刷新周期 `refreshAfterSeconds` 才切过去)。 + */ +function sameOriginRelayUrl(doc: OverlayDirectory, dirUrl: string): string | undefined { + const target = hostKeyOf(dirUrl) + if (target === undefined) return undefined + for (const candidate of [...doc.relays, ...doc.bootstrap]) { + const url = toRelayUrl(candidate) + if (url === undefined) continue + if (hostKeyOf(url) === target) return url + } + return undefined +} + +/** 单标签主机名 / 回环 / 私网 / 链路本地 / CGNAT / IPv6 ⇒ **不是**可对外公布的地址。 */ +function isPublicHost(host: string): boolean { + const h = host.toLowerCase() + if (h === '') return false + if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) { + return false + } + if (h.includes(':')) return false // IPv6 一律不公布(避免 `::1` / ULA 漏进目录) + if (!h.includes('.')) return false // 单标签 = 内网主机名 + const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h) + if (m === null) return true // 域名 ⇒ 公布 + const a = Number(m[1]) + const b = Number(m[2]) + if (a === 0 || a === 127 || a === 10) return false + if (a === 192 && b === 168) return false + if (a === 172 && b >= 16 && b <= 31) return false + if (a === 169 && b === 254) return false + if (a === 100 && b >= 64 && b <= 127) return false // CGNAT(D1 点名的段) + return true +} + +/** + * 地址的**语义身份**:同一台机器、同一个口、同一个路径 ⇒ 同一个身份。 + * `https`/`wss` 同属 443、`http`/`ws` 同属 80 ⇒ 按"是否 TLS + 主机 + 有效口 + 路径"归一。 + * 为什么需要它:配置里 `wss://host/dshs-relay` 与种子 `https://host/dshs-relay` 是**同一个端点**, + * 不去重就会在目录里出现两条同义项(客户端无碍,但读目录的人会误以为有两个中继)。 + */ +function entryIdentity(u: URL): string { + const secure = u.protocol === 'https:' || u.protocol === 'wss:' + const port = u.port !== '' ? u.port : secure ? '443' : '80' + return `${secure ? 'tls' : 'plain'}|${u.hostname.toLowerCase()}:${port}${u.pathname}` +} + +/** + * 选出**可以对外公布**的中继 / 引导地址。 + * + * ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:20080` 对客户端毫无用处, + * 还白送一份内网拓扑。对齐红线「**权限只准收窄**」(这里收窄的是**暴露面**)。 + * 同一语义身份只保留**首次出现**的那条(⇒ `wss://` 写法优先于同源的 `https://` 写法)。 + */ +export function publicRelayEntries(entries: readonly string[]): string[] { + const out: string[] = [] + const seen = new Set() + for (const entry of entries) { + const raw = parseEntry(entry) + if (raw === undefined) continue + const u = new URL(raw) + if (!isPublicHost(u.hostname)) continue + const id = entryIdentity(u) + if (seen.has(id)) continue + seen.add(id) + out.push(raw) + } + return out +} + +// ── IO:缓存读写 ────────────────────────────────────────────────────────── + +/** 磁盘上的缓存条目。 */ +export interface CachedDirectory { + doc: OverlayDirectory + sig: string + /** 写入时刻(ms)。 */ + fetchedAt: number +} + +/** + * 读缓存。**读也要验签**:缓存文件是本地普通文件,被改坏/被换掉时 + * 必须与"从网上取到脏目录"同等待遇(拒绝),⛔ 不要因为"是本机文件"就免检。 + */ +export function readCachedDirectory( + file: string, + keys: readonly string[], + now: number, +): { entry: CachedDirectory; fresh: boolean } | undefined { + if (file === '') return undefined + let raw: string + try { + raw = readFileSync(file, 'utf8') + } catch { + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return undefined + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + const o = parsed as Record + const fetchedAt = typeof o.fetchedAt === 'number' && Number.isFinite(o.fetchedAt) ? o.fetchedAt : Number.NaN + if (Number.isNaN(fetchedAt)) return undefined + const verdict = verifyDirectory(o.doc, o.sig, keys) + if (!verdict.ok) return undefined + const age = Math.max(0, now - fetchedAt) + return { + entry: { doc: verdict.doc, sig: String(o.sig), fetchedAt }, + fresh: age <= verdict.doc.refreshAfterSeconds * 1000, + } +} + +/** 写缓存(同目录临时文件 + `rename`,避免读到写了一半的 JSON)。 */ +export function writeCachedDirectory( + file: string, + doc: OverlayDirectory, + sig: string, + fetchedAt: number, +): void { + if (file === '') return + mkdirSync(dirname(file), { recursive: true }) + const tmp = `${file}.tmp` + writeFileSync(tmp, `${JSON.stringify({ doc, sig, fetchedAt }, null, 2)}\n`, { mode: 0o600 }) + renameSync(tmp, file) +} + +// ── 取目录 ──────────────────────────────────────────────────────────────── + +const DEFAULT_FETCH_TIMEOUT_MS = 5000 + +type FetchResult = + | { ok: true; doc: OverlayDirectory; sig: string } + | { ok: false; reason: string } + +/** 取一份目录并当场验签。**验签不过的目录绝不外泄给调用方**(返回的 `ok:false` 不带地址)。 */ +async function fetchDirectory( + url: string, + keys: readonly string[], + doFetch: typeof fetch, + timeoutMs: number, +): Promise { + try { + const res = await doFetch(url, { + signal: AbortSignal.timeout(timeoutMs), + headers: { accept: 'application/json' }, + }) + if (!res.ok) return { ok: false, reason: `http-${res.status}` } + const body = (await res.json()) as unknown + if (body === null || typeof body !== 'object' || Array.isArray(body)) { + return { ok: false, reason: 'bad-body' } + } + const o = body as Record + const sig = o.sig + const doc: Record = { ...o } + delete doc.sig + const verdict = verifyDirectory(doc, sig, keys) + if (!verdict.ok) return { ok: false, reason: verdict.reason } + return { ok: true, doc: verdict.doc, sig: String(sig) } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return { ok: false, reason: `unreachable: ${msg.slice(0, 80)}` } + } +} + +/** {@link resolveOverlayRelay} 的入参。 */ +export interface ResolveOverlayRelayOptions { + /** env 显式地址(`DSHS_RELAY_URL`);非空 ⇒ **压制一切**,不查目录。 */ + envUrl?: string + /** 内置种子(D3 的常量位;② 第二地域留空 ⇒ 只填第一条)。 */ + seeds: readonly string[] + /** 受信目录签名公钥(PEM 或裸 32 字节)。**空 ⇒ 目录一律不接受**。 */ + trustedKeys: readonly string[] + /** 缓存文件绝对路径;空 ⇒ 不缓存(每次取目录)。 */ + cacheFile?: string + log?: (line: string) => void + /** 注入点(单测用);默认全局 `fetch`。 */ + fetchImpl?: typeof fetch + /** 注入点(单测用);默认 `Date.now`。 */ + nowMs?: number + timeoutMs?: number + /** + * **要排除的地址**(序⑦ · 中继失败切流):通常 = "刚刚不健康的那一台"。 + * + * 语义 = "在这份候选链里**跳过**这些地址,取第一个没被排除的"。作用范围**只在选择这一步** + * —— ⛔ 它**不会**让任何候选凭空出现:被排除后若没有别的候选,返回 `url: ''` + * (调用方按"无候选可切"处理 ⇒ **保持原地退避**,见 D6)。 + * + * 缺省 / 空数组 ⇒ **行为与改造前逐字一致**(D9:存量调用点零影响)。 + */ + exclude?: readonly string[] +} + +/** + * 引导链算出的**有序候选集**(序⑦ 新增)。 + * + * 与 {@link OverlayRelayResolution} 的区别:后者只有 `url`(首位),这里给**整条链**, + * 供"首位不健康时换下一个"使用。`source` / `detail` / `refreshAfterSeconds` 语义不变。 + */ +export interface OverlayRelayCandidates { + /** 有序候选地址(首位 = 改造前的"本次该连的地址");空数组 = 取不到任何候选。 */ + urls: readonly string[] + source: OverlayAddressSource + detail: string + refreshAfterSeconds: number +} + +/** + * **引导三级链的单一入口**:给出**整条有序候选链**(序⑦ 起)。 + * + * 任何情况下都不抛异常(最坏返回 `urls: []`)—— 调用方按"未配 relay"处理即可。 + * ⛔ **取址代码只有这一份**:{@link resolveOverlayRelay} 与 {@link listOverlayRelayCandidates} + * 都是它的薄包装(另一份取址 = 另一处静默失效,本线已有两次同类教训)。 + */ +async function resolveOverlayRelayChain( + opts: ResolveOverlayRelayOptions, +): Promise { + const log = opts.log ?? ((): void => undefined) + /** + * 序④(443/TCP 兜底 · L1):**取目录这一腿也必须走地址覆盖**。 + * + * 为什么少这一处 L1 就不成立:CF 不可用时,若目录还按 DNS 去取,则第 ③ 步(取目录)会 + * **全 origin 失败**,链只能走到第 ⑤ 步 —— 而 ⑤ 回落的是 `seeds[0]`(主入口 = 同样走 CF) + * ⇒ 兜底入口永远轮不到。装上覆盖后,兜底 origin 的这一腿直连 47,目录取得回来。 + * ⛔ 未配 `DSHS_OVERLAY_ADDR_OVERRIDES` ⇒ 零动作。 + */ + ensureOverlayAddrOverrides(undefined, log) + const now = opts.nowMs ?? Date.now() + const doFetch = opts.fetchImpl ?? fetch + const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS + const seeds = opts.seeds.map((s) => s.trim()).filter((s) => s !== '') + + // ① env 显式:运维最后手段。**支持空串=未配**,但配错了(非 http/ws 地址)要明确报一行。 + const envUrl = (opts.envUrl ?? '').trim() + if (envUrl !== '') { + const url = toRelayUrl(envUrl) + if (url !== undefined) { + log(`[overlay-dir] 取址 = env 显式(压制引导链):${url}`) + return { urls: [url], source: 'env', detail: 'env', refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS } + } + log(`[overlay-dir] ⛔ env 里的地址不是 http(s)/ws(s) 绝对 URL ⇒ 忽略它,继续走引导链`) + } + + const cacheFile = opts.cacheFile ?? '' + const cached = readCachedDirectory(cacheFile, opts.trustedKeys, now) + + // ② 缓存未过期:直接用(省一次往返;控制面抖动不影响已入网节点)。 + if (cached !== undefined && cached.fresh) { + const urls = listCandidatesFromDoc(cached.entry.doc) + if (urls.length > 0) { + log(`[overlay-dir] 取址 = 缓存目录(未过期,net=${cached.entry.doc.network}):${urls[0]}(候选 ${urls.length} 条)`) + return { urls, source: 'cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds } + } + } + + // ③ 取目录。**origin 优先取缓存里的 `bootstrap[]`** —— 这就是"引导地址可在线轮换"的落点: + // 控制面把 bootstrap[] 改成新地址 ⇒ 下一次刷新就去新地址,**不重装**。 + const origins: string[] = [] + for (const candidate of [...(cached?.entry.doc.bootstrap ?? []), ...seeds]) { + if (!origins.includes(candidate)) origins.push(candidate) + } + for (const origin of origins) { + const dirUrl = directoryUrlFor(origin) + if (dirUrl === undefined) continue + const got = await fetchDirectory(dirUrl, opts.trustedKeys, doFetch, timeoutMs) + if (!got.ok) { + log(`[overlay-dir] ⚠ 拒绝 ${dirUrl}(${got.reason})—— 未签名 / 签名不符的目录**不写缓存、不采用**`) + continue + } + writeCachedDirectory(cacheFile, got.doc, got.sig, now) + /** + * **同源优先**(序④):谁答出的目录,就先认它的同源中继入口 —— 否则主入口一挂, + * 客户端会一直去连 `relays[]` 的首位(= 主入口),兜底入口形同不存在。见 {@link sameOriginRelayUrl}。 + * + * ⚠️ 序⑦:同源优先必须作用在**整条链**上("同源那条排第一,原首位排其后"), + * ⛔ 不是"只看首位" —— 否则候选集又会退化成单点。见 {@link listCandidatesFromDoc}。 + */ + const list = listCandidatesFromDoc(got.doc) + const base = list[0] + const sameOrigin = sameOriginRelayUrl(got.doc, dirUrl) + const urls = + sameOrigin === undefined ? list : [sameOrigin, ...list.filter((u) => u !== sameOrigin)] + if (sameOrigin !== undefined && base !== undefined && sameOrigin !== base) { + log( + `[overlay-dir] ↪ 同源优先:目录由 ${dirUrl} 答出 ⇒ 采用其同源中继入口 ${sameOrigin}` + + `(不在 relays[] 首位;首位 ${base} 本次未被采用)`, + ) + } + if (urls.length === 0) { + log(`[overlay-dir] ⚠ ${dirUrl} 的目录里没有可用中继地址 ⇒ 换下一个引导地址`) + continue + } + log( + `[overlay-dir] 取址 = 签名目录(来自 ${dirUrl},version=${got.doc.version},` + + `net=${got.doc.network},relays=${got.doc.relays.length},bootstrap=${got.doc.bootstrap.length}):${urls[0]}(候选 ${urls.length} 条)`, + ) + return { urls, source: 'seed-directory', detail: dirUrl, refreshAfterSeconds: got.doc.refreshAfterSeconds } + } + + // ④ 离线降级:目录全都取不到 / 全被拒 ⇒ 用**过期但签名有效**的缓存(D3 ③)。 + if (cached !== undefined) { + const urls = listCandidatesFromDoc(cached.entry.doc) + if (urls.length > 0) { + log('[overlay-dir] ⚠ 目录不可达 ⇒ 离线降级:用**过期缓存**里的地址(已建连接不受影响)') + return { urls, source: 'stale-cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds } + } + } + + // ⑤ 种子兜底:种子地址本身就是中继入口(同源约定)⇒ 连目录端点挂了也还能起来。 + const first = seeds[0] + if (first !== undefined) { + const url = toRelayUrl(first) + if (url !== undefined) { + log(`[overlay-dir] ⚠ 取目录全部失败 ⇒ 回落到内置种子地址本身:${url}`) + return { urls: [url], source: 'seed-fallback', detail: first, refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS } + } + } + + log('[overlay-dir] ⛔ 引导链全部失败且无内置种子 ⇒ 本次不接中继(等同"未配 relay")') + return { urls: [], source: 'none', detail: 'none', refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS } +} + +/** + * **引导链的有序候选集**(序⑦):把整条链交给调用方,供"当前中继不健康 ⇒ 换下一个"使用。 + * + * ⛔ 只是 {@link resolveOverlayRelayChain} 的转发 —— **不另写一份取址**。 + * 与 {@link resolveOverlayRelay} 的关系:本函数**不套用 `exclude`**(它要的是"候选全集")。 + */ +export async function listOverlayRelayCandidates( + opts: ResolveOverlayRelayOptions, +): Promise { + return resolveOverlayRelayChain(opts) +} + +/** + * **引导三级链的单一入口**:给出"这次该连哪个中继地址"(= 候选链首位)。 + * 任何情况下都不抛异常(最坏返回 `url: ''`)—— 调用方按"未配 relay"处理即可。 + * + * 序⑦ 起它是 {@link resolveOverlayRelayChain} 的**薄包装**:链 → 剔除 `exclude` → 取第一个。 + * **`exclude` 缺省时与改造前逐字一致**(D9)。 + */ +export async function resolveOverlayRelay( + opts: ResolveOverlayRelayOptions, +): Promise { + const chain = await resolveOverlayRelayChain(opts) + const log = opts.log ?? ((): void => undefined) + const excluded = opts.exclude ?? [] + if (excluded.length === 0) { + return { + url: chain.urls[0] ?? '', + source: chain.source, + detail: chain.detail, + refreshAfterSeconds: chain.refreshAfterSeconds, + } + } + const skip = new Set(excluded) + const url = chain.urls.find((u) => !skip.has(u)) + if (url === undefined) { + /** + * D6:被排除后**没有别的候选** ⇒ 返回空串让调用方"保持原地退避"。 + * ⛔ 绝不静默回退默认机、绝不把候选集外的地址当作兜底(那才是真的 R5)。 + */ + log( + `[overlay-dir] ⚠ 候选链里除已排除项外无可用地址(候选 ${chain.urls.length} 条,` + + `排除 ${excluded.length} 条)⇒ 本次不换址(保持原地退避)`, + ) + return { + url: '', + source: chain.source, + detail: `${chain.detail}|exhausted`, + refreshAfterSeconds: chain.refreshAfterSeconds, + } + } + return { + url, + source: chain.source, + detail: chain.detail, + refreshAfterSeconds: chain.refreshAfterSeconds, + } +} diff --git a/src/net/relay/duplex.ts b/src/net/relay/duplex.ts new file mode 100644 index 0000000..d873433 --- /dev/null +++ b/src/net/relay/duplex.ts @@ -0,0 +1,74 @@ +/** + * 一条 mux 流的**应用侧端口**(覆盖网络 R5 —— 拨号方用)。 + * + * ## 语义 + * 与 `net.Socket` 同形,可直接 `a.pipe(b)`: + * - **写** ⇒ `onOut(chunk)`(由调用方决定往哪送,这里就是打进 mux); + * - **读** ⇒ `feed(chunk)`(对端来的字节)、`eof()`(对端关闭)。 + * + * ## 为什么不做成 `net.Socket` + * 拨号方的"落点"不在本机任何 TCP 端口上 —— 它就是一条多路复用的逻辑流。用一个 `Duplex` + * 表示,调用方既能直接 `pipe()`,也不需要为它准备 fd(**零新增监听口**)。 + * + * @module dshs/net/relay/duplex + */ + +import { Duplex, type DuplexOptions } from 'node:stream' + +export interface MuxDuplexOptions extends DuplexOptions { + /** 应用写下来的字节往哪去(返回 `false` = 触发背压,调用方稍后应 `resumeOut()`)。 */ + onOut: (chunk: Buffer) => boolean + /** 流出向关闭 / 被销毁时只调一次(用于给对端补 `CLOSE`)。 */ + onClosed?: () => void +} + +export class MuxDuplex extends Duplex { + private readonly onOut: (chunk: Buffer) => boolean + private readonly onClosed: () => void + private ended = false + + constructor(opts: MuxDuplexOptions) { + // `allowHalfOpen`:对端先关一半不代表本侧不能再写(HTTP/1.1 里很常见),保持半开。 + super({ ...opts, allowHalfOpen: true }) + this.onOut = opts.onOut + this.onClosed = opts.onClosed ?? ((): void => undefined) + } + + /** 读侧由 `feed()` 驱动;这里无事可做(**不能**返回错误,否则会误报"流坏了")。 */ + override _read(): void {} + + override _write(chunk: Buffer | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void { + const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + this.onOut(buf) + cb() + } + + override _final(cb: (err?: Error | null) => void): void { + this.closeOnce() + cb() + } + + override _destroy(err: Error | null, cb: (e?: Error | null) => void): void { + this.closeOnce() + cb(err) + } + + /** 对端来的字节 ⇒ 交给读侧。返回 `false` = 读侧水位满(**不是错误**)。 */ + feed(chunk: Buffer): boolean { + if (this.destroyed || this.ended) return false + return this.push(chunk) + } + + /** 对端关闭 ⇒ 结束读侧(读方会收到 `end`),但**写侧仍可写**(半开)。 */ + eof(): void { + if (this.ended || this.destroyed) return + this.ended = true + this.push(null) + } + + private closeOnce(): void { + const first = !this.ended + this.ended = true + if (first) this.onClosed() + } +} diff --git a/src/net/relay/identity.ts b/src/net/relay/identity.ts new file mode 100644 index 0000000..5fa8774 --- /dev/null +++ b/src/net/relay/identity.ts @@ -0,0 +1,656 @@ +/** + * 覆盖网络 序③:**四层密钥模型 + 离线信任根**(一机一钥 / 入网即签名 / 本地校验 / 可单台吊销)。 + * + * ## 它治的病(一句话) + * `keys.ts` 的 HMAC 预共享密钥解决的是"**爆炸半径 = 那一台**",但**没有解决"控制面自己被攻破"**: + * 密钥表放在控制面(47)上,谁改得动那张表,谁就能塞进一台自己的节点 —— 而所有对端都会照单全收, + * 因为对端**没有任何独立的判据**可以否决它。 + * + * ⇒ 本模块引入一个控制面**看不到也改不了**的信任源(根 + 签名者),让"**能不能入网**"这件事 + * 由**节点自己在本地**判定(D2:校验位置 = 节点本地,relay 侧只做**辅助**准入)。 + * + * ## 四层(形状照抄 Tailnet Lock,见 `覆盖网络_问题逐条推演与解决方案_20260916.md §A4`) + * + * | 层 | 放哪 | 用途 | 丢失后果 | + * |---|---|---|---| + * | **根(离线)** | 用户手里(本工作区 `0600` 文件 / 纸质恢复码 / 离线 U 盘) | **只**授权/撤销"签名者" | 最严重 ⇒ 全网重建 | + * | **签名者(在线,多把)** | 管理员设备(现网 = 47,`/etc/dshs/overlay-signer-key.pem`) | 签发节点入网凭据 | 换一把(根仍在) | + * | **节点密钥(每机一把)** | 每台机器(`0600`,属主正确) | 设备身份;对接时证明"握有私钥" | 该设备重签 | + * | **会话密钥(内存)** | 隧道 | 传输加密 | 无感 | + * + * ⚠️ **根密钥的用途边界(⛔ 别搞错)**:它**只用于授权 / 撤销"签名者"** —— + * **不签发节点、不加密数据、不参与会话**。⇒ "根在线"**不带来任何性能问题**,唯一影响是**安全** + * 与**恢复**(`交接单_一机一钥与信任根_20260917.md §4.3.1`)。 + * + * ## 🔴 四条不可动摇的判据(改动前先读) + * 1. **失败关闭**:任一校验未命中 ⇒ 返回**带具体原因**的 `ok:false`,⛔ 绝不静默回退到共享凭据、 + * ⛔ 绝不回落默认机。原因必须**结构化**(`IdentityReason`),否则"配错了"会伪装成"网络不通" + * (本线反复复发的那类病,见 `交接单_relay落地R2-R4 §12` 的 A1)。 + * 2. **不可验 = 不接受**:受信根 / 受信签名者为空 ⇒ **拒绝**(与 `directory.ts` 的 `no-trusted-keys` 同款)。 + * 3. **签名覆盖全部字段**:载荷用**规范拼接**(`*Payload()`),⛔ 不用 `JSON.stringify` + * (键序变化 ⇒ 载荷变 ⇒ 验签结果随 TS 版本漂移;`directory.ts` 已踩过这条)。 + * 4. **本模块只做身份,不做寻址**:不碰 `network.ts` 的地址规划、不碰 `directory.ts` 的地址下发。 + * 它回答的唯一问题是「**这台机器是不是被授权进入这张网**」。 + * + * 纯函数(载荷 / 解析 / 签发 / 验签 / 指纹)与 IO(密钥文件读写)分开放 —— 与 `network.ts` / + * `directory.ts` 同一风格,前者可单测、后者只做搬运。 + * + * @module dshs/net/relay/identity + */ + +import { + createHash, + createPrivateKey, + createPublicKey as cryptoCreatePublicKey, + generateKeyPairSync, + randomBytes, + sign as cryptoSign, + verify as cryptoVerify, +} from 'node:crypto' +import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname } from 'node:path' + +import { publicKeyFrom } from './directory.js' +import { isHostId, isNetworkId } from './network.js' + +/** 文档结构版本。未知版本 ⇒ 拒绝(而不是"尽力解析")。 */ +export const IDENTITY_VERSION = 1 + +/** 载荷标签:换格式时改这里 ⇒ 老节点**验签失败 ⇒ 失败关闭**(不会误读新格式)。 */ +export const SIGNER_SET_TAG = 'dshs-overlay-signerset/v1' +export const NODE_GRANT_TAG = 'dshs-overlay-nodegrant/v1' +export const REVOCATION_TAG = 'dshs-overlay-revocation/v1' + +/** + * Ed25519 会话证明(node proof)的**域分隔串**。 + * + * 为什么要它:节点私钥**同时**用来签入网凭据的持有证明与握手的挑战签名 —— 两种签名若不带上 + * 各自的域标签,一个签名就可能被搬到另一个场景里重用(跨协议签名混淆)。带上标签后两者互不可用。 + */ +export const PROOF_TAG = 'dshs-overlay-node-proof/v1' + +/** 节点密钥文件的默认落点(**0600**,属主 = 跑 dsh 的那个用户)。 */ +export const DEFAULT_NODE_KEY_FILE = '/etc/dshs/node.key' + +// ── 类型 ──────────────────────────────────────────────────────────────────── + +/** **签名者集合**(由**根**签发)—— 回答"哪些签名者被授权签发节点"。 */ +export interface SignerSet { + version: number + /** 该集合所属的网。**签名者只在它被授权的网里有效**(跨网签发 ⇒ 拒)。 */ + network: string + /** 签发时间(ISO 8601)。 */ + issuedAt: string + /** 受根授权的签名者公钥(PEM 或裸 32 字节 hex/base64)。 */ + signers: string[] +} + +/** **节点入网凭据**(由**签名者**签发)—— 回答"这台机器被授权进入这张网"。 */ +export interface NodeGrant { + version: number + network: string + hostId: string + /** 该节点的公钥(**裸 32 字节 hex 小写**)。 */ + nodeKey: string + issuedAt: string + /** 过期时间(ISO 8601);**空串 = 不过期**(运维网的常驻机器适用,照 Tailnet 的 tagged 语义)。 */ + expiresAt: string +} + +/** + * **吊销清单**(由**签名者**签发)—— 撤销单台 ≈ 只动这一份清单,**不牵动全网换密钥**。 + * + * ⚠️ 撤销**签名者**不走这里(那是"根签一份新的 SignerSet"的职责);本清单只撤**节点**。 + */ +export interface RevocationList { + version: number + network: string + issuedAt: string + /** 被撤销的 hostId(再签一份 grant 也不生效 —— 必须先把它从清单里移除)。 */ + hosts: string[] + /** 被撤销的**节点公钥**(设备被盗场景:hostId 可能被复用,公钥不会)。 */ + nodeKeys: string[] +} + +/** 拒绝原因(**结构化**,直接进日志/HELLO_ERR 的 `why`)。 */ +export type IdentityReason = + | 'bad-document' + | 'no-signature' + | 'bad-signature-length' + | 'no-trusted-keys' + | 'signature-mismatch' + | 'network-mismatch' + | 'host-mismatch' + | 'key-mismatch' + | 'expired' + | 'not-yet-valid' + | 'revoked-host' + | 'revoked-node-key' + +/** 通用验签结论:**失败一律带具体原因**。 */ +export type IdentityVerdict = { ok: true; doc: T; payload: string } | { ok: false; reason: IdentityReason } + +// ── 纯函数:载荷(规范拼接,⛔ 不用 JSON.stringify)──────────────────────────── + +export function signerSetPayload(set: SignerSet): string { + return [SIGNER_SET_TAG, String(set.version), set.network, set.issuedAt, set.signers.join(',')].join('\n') +} + +export function nodeGrantPayload(grant: NodeGrant): string { + return [ + NODE_GRANT_TAG, + String(grant.version), + grant.network, + grant.hostId, + grant.nodeKey, + grant.issuedAt, + grant.expiresAt, + ].join('\n') +} + +export function revocationPayload(list: RevocationList): string { + return [REVOCATION_TAG, String(list.version), list.network, list.issuedAt, list.hosts.join(','), list.nodeKeys.join(',')].join('\n') +} + +/** + * 会话证明的载荷:`\n`。 + * + * `challenge` 由调用方决定(relay 握手用 `hostId|ts|nonce|portsCsv`,与本模块解耦)—— + * 本模块只保证"域分隔"与"签名/验签用的是同一个串"。 + */ +export function proofPayload(challenge: string): string { + return `${PROOF_TAG}\n${challenge}` +} + +// ── 纯函数:公钥 / 指纹 ─────────────────────────────────────────────────────── + +/** 裸 32 字节 Ed25519 公钥 → 小写 hex;解析不出来 ⇒ `undefined`。 */ +export function normalizePublicKey(spec: string): string | undefined { + const key = publicKeyFrom(spec) + if (key === undefined) return undefined + const der = key.export({ type: 'spki', format: 'der' }) as Buffer + // SPKI DER 前 12 字节是固定前缀,其后 32 字节即裸公钥(directory.ts 已用同一常量)。 + return der.subarray(der.length - 32).toString('hex') +} + +/** + * 公钥指纹:`sha256(裸 32 字节)` 的前 16 位 hex。 + * + * **为什么不是直接显示公钥**:指纹是给人看的(日志 / 清点 / 演练报告),16 位 hex 足够唯一, + * 且**不可反推**。判据里"两台机器指纹不同"要的正是这个。 + * ⚠️ 指纹**不是**校验依据(那是验签的职责)⇒ 永不参与任何准入判断。 + */ +export function nodeKeyFingerprint(spec: string): string | undefined { + const raw = normalizePublicKey(spec) + if (raw === undefined) return undefined + return createHash('sha256').update(Buffer.from(raw, 'hex')).digest('hex').slice(0, 16) +} + +/** + * 从**私钥 PEM** 推出对应的裸公钥 hex —— 免去"到处再存一份公钥"的错配风险。 + * + * Node 的 `createPublicKey()` **直接吃私钥 `KeyObject`**(PKCS#8 里本就带着公钥), + * 所以这里一行就够;写成函数是为了让"公钥从哪来"在调用方是**同一个**答案。 + */ +export function publicKeyOfPrivate(privateKeyPem: string): string { + const priv = createPrivateKey(privateKeyPem) + if (priv.asymmetricKeyType !== 'ed25519') { + throw new Error(`identity key must be ed25519 (got ${String(priv.asymmetricKeyType)})`) + } + const der = cryptoCreatePublicKey(priv).export({ type: 'spki', format: 'der' }) as Buffer + return der.subarray(der.length - 32).toString('hex') +} + +// ── 纯函数:解析(只校验形状与取值域,**不验签**)───────────────────────────── + +function asRecord(raw: unknown): Record | undefined { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined + return raw as Record +} + +function isoString(v: unknown, maxLen = 40): string | undefined { + if (typeof v !== 'string' || v.length > maxLen) return undefined + if (Number.isNaN(Date.parse(v))) return undefined + return v +} + +function specList(v: unknown, max = 16): string[] | undefined { + if (!Array.isArray(v) || v.length > max) return undefined + const out: string[] = [] + for (const item of v) { + if (typeof item !== 'string' || item.trim() === '') return undefined + const norm = normalizePublicKey(item) + if (norm === undefined) return undefined + if (!out.includes(norm)) out.push(norm) + } + return out +} + +/** 严格解析签名者集合。任一条不合规 ⇒ `undefined`(宁可不接受,也不要"半成品")。 */ +export function parseSignerSet(raw: unknown): SignerSet | undefined { + const r = asRecord(raw) + if (r === undefined) return undefined + if (r.version !== IDENTITY_VERSION) return undefined + const network = typeof r.network === 'string' ? r.network.trim() : '' + if (!isNetworkId(network)) return undefined + const issuedAt = isoString(r.issuedAt) + if (issuedAt === undefined) return undefined + const signers = specList(r.signers) + // **空集合 = 无授权签名者** ⇒ 不合法:它会让"签名者全部被撤"与"配置忘了填"长得一样。 + if (signers === undefined || signers.length === 0) return undefined + return { version: IDENTITY_VERSION, network, issuedAt, signers } +} + +/** 严格解析节点凭据。 */ +export function parseNodeGrant(raw: unknown): NodeGrant | undefined { + const r = asRecord(raw) + if (r === undefined) return undefined + if (r.version !== IDENTITY_VERSION) return undefined + const network = typeof r.network === 'string' ? r.network.trim() : '' + if (!isNetworkId(network)) return undefined + const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : '' + if (!isHostId(hostId)) return undefined + const nodeKey = typeof r.nodeKey === 'string' ? normalizePublicKey(r.nodeKey) : undefined + if (nodeKey === undefined) return undefined + const issuedAt = isoString(r.issuedAt) + if (issuedAt === undefined) return undefined + // `expiresAt` 允许空串(= 不过期),非空则必须是合法 ISO。 + if (r.expiresAt !== '' && typeof r.expiresAt !== 'string') return undefined + const expiresAt = r.expiresAt === '' ? '' : isoString(r.expiresAt) + if (expiresAt === undefined) return undefined + return { version: IDENTITY_VERSION, network, hostId, nodeKey, issuedAt, expiresAt } +} + +/** 严格解析吊销清单。 */ +export function parseRevocationList(raw: unknown): RevocationList | undefined { + const r = asRecord(raw) + if (r === undefined) return undefined + if (r.version !== IDENTITY_VERSION) return undefined + const network = typeof r.network === 'string' ? r.network.trim() : '' + if (!isNetworkId(network)) return undefined + const issuedAt = isoString(r.issuedAt) + if (issuedAt === undefined) return undefined + const hosts = stringList(r.hosts, 256, (s) => isHostId(s)) + if (hosts === undefined) return undefined + const nodeKeys = stringList(r.nodeKeys, 256, (s) => normalizePublicKey(s) !== undefined) + if (nodeKeys === undefined) return undefined + return { + version: IDENTITY_VERSION, + network, + issuedAt, + hosts, + nodeKeys: nodeKeys.map((s) => normalizePublicKey(s) as string), + } +} + +function stringList(v: unknown, max: number, ok: (s: string) => boolean): string[] | undefined { + if (!Array.isArray(v) || v.length > max) return undefined + const out: string[] = [] + for (const item of v) { + if (typeof item !== 'string') return undefined + const s = item.trim() + if (s === '' || !ok(s)) return undefined + if (!out.includes(s)) out.push(s) + } + return out +} + +// ── 纯函数:签发 / 验签 ─────────────────────────────────────────────────────── + +/** 通用验签:`sig` 为 base64;`keys` 为受信公钥 spec 列表(PEM 或裸 32 字节)。 */ +function verifySigned(payload: string, sig: unknown, keys: readonly string[]): IdentityReason | 'ok' { + if (typeof sig !== 'string' || sig.trim() === '') return 'no-signature' + const sigBuf = Buffer.from(sig.trim(), 'base64') + if (sigBuf.length !== 64) return 'bad-signature-length' + // **受信公钥为空 ⇒ 拒绝**(不可验 = 不接受)——与 directory.ts 同一条判据。 + if (keys.length === 0) return 'no-trusted-keys' + const data = Buffer.from(payload, 'utf8') + for (const spec of keys) { + const key = publicKeyFrom(spec) + if (key === undefined) continue + if (cryptoVerify(null, data, key, sigBuf)) return 'ok' + } + return 'signature-mismatch' +} + +function signPayload(payload: string, privateKeyPem: string): string { + const key = createPrivateKey(privateKeyPem) + if (key.asymmetricKeyType !== 'ed25519') { + throw new Error(`identity key must be ed25519 (got ${String(key.asymmetricKeyType)})`) + } + return cryptoSign(null, Buffer.from(payload, 'utf8'), key).toString('base64') +} + +/** 验一份签名者集合(是否被**根**授权)。 */ +export function verifySignerSet(raw: unknown, sig: unknown, trustedRootKeys: readonly string[]): IdentityVerdict { + const doc = parseSignerSet(raw) + if (doc === undefined) return { ok: false, reason: 'bad-document' } + const payload = signerSetPayload(doc) + const verdict = verifySigned(payload, sig, trustedRootKeys) + if (verdict !== 'ok') return { ok: false, reason: verdict } + return { ok: true, doc, payload } +} + +/** 验一份节点凭据(是否由**受信签名者**签发)。 */ +export function verifyNodeGrant( + raw: unknown, + sig: unknown, + trustedSignerKeys: readonly string[], +): IdentityVerdict { + const doc = parseNodeGrant(raw) + if (doc === undefined) return { ok: false, reason: 'bad-document' } + const payload = nodeGrantPayload(doc) + const verdict = verifySigned(payload, sig, trustedSignerKeys) + if (verdict !== 'ok') return { ok: false, reason: verdict } + return { ok: true, doc, payload } +} + +/** 验一份吊销清单(是否由**受信签名者**签发)。 */ +export function verifyRevocations( + raw: unknown, + sig: unknown, + trustedSignerKeys: readonly string[], +): IdentityVerdict { + const doc = parseRevocationList(raw) + if (doc === undefined) return { ok: false, reason: 'bad-document' } + const payload = revocationPayload(doc) + const verdict = verifySigned(payload, sig, trustedSignerKeys) + if (verdict !== 'ok') return { ok: false, reason: verdict } + return { ok: true, doc, payload } +} + +/** 签发签名者集合(**只跑在离线的根密钥持有者那一侧**)。 */ +export function signSignerSet(set: SignerSet, rootPrivateKeyPem: string): string { + return signPayload(signerSetPayload(set), rootPrivateKeyPem) +} + +/** 签发节点凭据(跑在**在线签名者**那一侧,现网 = 47)。 */ +export function signNodeGrant(grant: NodeGrant, signerPrivateKeyPem: string): string { + return signPayload(nodeGrantPayload(grant), signerPrivateKeyPem) +} + +/** 签发吊销清单(跑在**在线签名者**那一侧)。 */ +export function signRevocations(list: RevocationList, signerPrivateKeyPem: string): string { + return signPayload(revocationPayload(list), signerPrivateKeyPem) +} + +/** 会话证明:用**节点私钥**对挑战签名(证明"握有私钥",光有凭据不够)。 */ +export function signProof(nodePrivateKeyPem: string, challenge: string): string { + return signPayload(proofPayload(challenge), nodePrivateKeyPem) +} + +/** 验会话证明。**公钥解析不出来 ⇒ 拒**(不静默当作"这张网没有身份要求")。 */ +export function verifyProof(nodeKeySpec: string, challenge: string, sig: unknown): boolean { + const key = publicKeyFrom(nodeKeySpec) + if (key === undefined) return false + if (typeof sig !== 'string' || sig.trim() === '') return false + const sigBuf = Buffer.from(sig.trim(), 'base64') + if (sigBuf.length !== 64) return false + return cryptoVerify(null, Buffer.from(proofPayload(challenge), 'utf8'), key, sigBuf) +} + +// ── 本地校验的单一入口(**节点自己判**,D2)────────────────────────────────── + +/** {@link verifyPeerGrant} 的上下文。 */ +export interface PeerVerifyContext { + /** 受根授权的签名者公钥(**直接**受信;要么来自 env,要么来自一份已验签的 SignerSet)。 */ + trustedSignerKeys: readonly string[] + /** 期望的网。给 ⇒ 必须一致(跨网签发 ⇒ `network-mismatch`)。 */ + network?: string + /** 期望的 hostId。给 ⇒ 必须一致(凭据被搬到别的 hostId 上 ⇒ `host-mismatch`)。 */ + hostId?: string + /** 期望的节点公钥(**裸 32 字节 hex**)。给 ⇒ 必须一致(私钥被换过 ⇒ `key-mismatch`)。 */ + nodeKey?: string + /** 当前有效的吊销清单(已验签;未配 ⇒ 只警告不拒 —— 见 `loadRevocations`)。 */ + revocations?: RevocationList + /** 注入点:默认 `Date.now()`。 */ + nowMs?: number +} + +/** + * **本地校验一份对端凭据** —— 本模块唯一的高层入口(relay 与各节点都调它)。 + * + * 校验顺序(**先结构、再签名、再语义、最后吊销**):任何一个不过 ⇒ 立即带原因返回。 + * ⛔ 绝不"跳过某一步继续":那种"尽力而为"的校验正是"看起来验过了"的来源。 + */ +export function verifyPeerGrant( + raw: unknown, + sig: unknown, + ctx: PeerVerifyContext, +): IdentityVerdict { + const verdict = verifyNodeGrant(raw, sig, ctx.trustedSignerKeys) + if (!verdict.ok) return verdict + const g = verdict.doc + if (ctx.network !== undefined && g.network !== ctx.network) return { ok: false, reason: 'network-mismatch' } + if (ctx.hostId !== undefined && g.hostId !== ctx.hostId) return { ok: false, reason: 'host-mismatch' } + if (ctx.nodeKey !== undefined && g.nodeKey !== normalizePublicKey(ctx.nodeKey)) { + return { ok: false, reason: 'key-mismatch' } + } + const now = ctx.nowMs ?? Date.now() + const issued = Date.parse(g.issuedAt) + // 允许 5 分钟的签发时刻容差(签发机与校验机的时钟不可能逐毫秒对齐)。 + if (Number.isFinite(issued) && issued > now + 5 * 60_000) return { ok: false, reason: 'not-yet-valid' } + if (g.expiresAt !== '') { + const exp = Date.parse(g.expiresAt) + if (Number.isFinite(exp) && exp <= now) return { ok: false, reason: 'expired' } + } + const rev = ctx.revocations + if (rev !== undefined) { + // 吊销清单与凭据必须**同网**;不同网 ⇒ 清单不适用(凭据本身已通过 network 校验)。 + if (rev.network === g.network) { + if (rev.hosts.includes(g.hostId)) return { ok: false, reason: 'revoked-host' } + if (rev.nodeKeys.includes(g.nodeKey)) return { ok: false, reason: 'revoked-node-key' } + } + } + return verdict +} + +// ── 环境变量(relay 独立进程与 dsh 进程共用同一套口径)────────────────────── + +function envList(env: NodeJS.ProcessEnv, name: string): string[] { + const raw = env[name] + if (raw === undefined) return [] + return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))] +} + +/** + * 受信**根**公钥(用于验 `SignerSet`)。 + * + * ⚠️ 与 `directory.ts` 的 `DSHS_OVERLAY_DIR_PUBKEYS` 是**两把不同的密钥、两个不同的用途**: + * 那把签"地址目录"(可轮换的下发物),这把签"签名者名单"(身份根)。⛔ 不要合并成一个变量 —— + * 合并等于让"能换地址的人"顺带能加签名者。 + */ +export function identityEnvTrustedRoots(env: NodeJS.ProcessEnv = process.env): string[] { + return envList(env, 'DSHS_OVERLAY_ROOT_PUBKEYS') +} + +/** 受信**签名者**公钥(快速通道:直接信任某把签名者,不必先摆一份 SignerSet)。 */ +export function identityEnvTrustedSigners(env: NodeJS.ProcessEnv = process.env): string[] { + return envList(env, 'DSHS_OVERLAY_SIGNER_PUBKEYS') +} + +/** 是否**强制**要求节点凭据(`1` / `true` / `yes` ⇒ 开)。缺省关(存量节点平滑过渡,见 §8)。 */ +export function identityEnvRequire(env: NodeJS.ProcessEnv = process.env): boolean { + const raw = (env.DSHS_OVERLAY_REQUIRE_IDENTITY ?? '').trim().toLowerCase() + return raw === '1' || raw === 'true' || raw === 'yes' +} + +// ── IO:密钥与凭据文件(只做搬运,判据全在上面)───────────────────────────── + +/** 生成一把 Ed25519 节点密钥。返回**私钥 PEM** 与**裸公钥 hex**。 */ +export function generateNodeKey(): { privateKeyPem: string; publicKey: string } { + const { privateKey, publicKey } = generateKeyPairSync('ed25519') + const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string + const der = publicKey.export({ type: 'spki', format: 'der' }) as Buffer + return { privateKeyPem, publicKey: der.subarray(der.length - 32).toString('hex') } +} + +/** 生成一把 Ed25519 根 / 签名者密钥(同形,分开只为读起来不漏"这把是干什么用的")。 */ +export const generateAuthorityKey = generateNodeKey + +function writeFile0600(file: string, text: string): void { + mkdirSync(dirname(file), { recursive: true }) + const tmp = `${file}.tmp-${randomBytes(4).toString('hex')}` + writeFileSync(tmp, text, { mode: 0o600 }) + renameSync(tmp, file) + // `mode` 在**已存在**的文件上不生效(rename 覆盖时权限随 tmp)⇒ 显式再 chmod 一次, + // 保证"恢复演练 / 重签"之后权限仍然收窄(否则一次 rename 就能把 0600 悄悄放宽)。 + chmodSync(file, 0o600) +} + +/** + * **读或生成**节点密钥(`0600`)。 + * + * ⛔ 不"生成一把临时的顶上去":身份是**长期**的,静默生成新身份 = 让"这台机器被吊销过" + * 这件事在下一次重启后自动失效(吊销形同虚设)。 + */ +export function loadOrCreateNodeKey( + file: string, + log: (line: string) => void = (): void => undefined, +): { privateKeyPem: string; publicKey: string; created: boolean } { + if (file === '') throw new Error('node key file path is empty') + if (existsSync(file)) { + const privateKeyPem = readFileSync(file, 'utf8') + const publicKey = publicKeyOfPrivate(privateKeyPem) + // 权限收窄是**幂等**的:每次装载都确认一遍(被人 `chmod 644` 过也能自愈)。 + chmodSync(file, 0o600) + return { privateKeyPem, publicKey, created: false } + } + const key = generateNodeKey() + writeFile0600(file, key.privateKeyPem) + log(`[identity] 生成新节点密钥:${file}(0600)指纹=${nodeKeyFingerprint(key.publicKey) ?? '-'}`) + return { ...key, created: true } +} + +/** 读一份 `{doc, sig}` 形式的签名文件;文件不存在 ⇒ `undefined`(**不抛**,便于"未配"分支)。 */ +export function readSignedFile(file: string): { doc: unknown; sig: unknown } | undefined { + if (file === '') return undefined + let raw: string + try { + raw = readFileSync(file, 'utf8') + } catch { + return undefined + } + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return undefined + } + const r = asRecord(parsed) + if (r === undefined) return undefined + return { doc: r.doc, sig: r.sig } +} + +/** 写一份 `{doc, sig}` 形式的签名文件(同目录临时文件 + `rename`,**0600**)。 */ +export function writeSignedFile(file: string, doc: unknown, sig: string): void { + if (file === '') throw new Error('signed file path is empty') + writeFile0600(file, `${JSON.stringify({ doc, sig }, null, 2)}\n`) +} + +/** + * 装载**受信签名者**:`SignerSet`(经根验签)+ env 里直接列的签名者。 + * + * ## 失败关闭在这个函数里的确切形态 + * - 没配根、也没配签名者 ⇒ 返回 `{ keys: [] }`(调用方据此判定"不可验 = 不接受"); + * - **配了根、但 SignerSet 读不到 / 验不过** ⇒ 返回 `{ keys: [], error }` —— ⛔ **不回落**到 + * "那先用 env 里那几个吧":那种回落会让"签名者名单被换掉"表现成"一切正常"。 + * + * ⚠️ 返回的签名者**还要过一道网校验**(`set.network` 必须等于本机所在网)—— 由调用方传入 + * `network` 完成(这里不猜本机在哪张网)。 + */ +export function loadTrustedSigners(opts: { + roots: readonly string[] + directSigners: readonly string[] + signerSetFile?: string + network?: string + log?: (line: string) => void +}): { keys: string[]; error?: string; signerSet?: SignerSet } { + const log = opts.log ?? ((): void => undefined) + const keys: string[] = [...opts.directSigners] + let signerSet: SignerSet | undefined + + const file = opts.signerSetFile ?? '' + if (file === '') { + // 无签名者集合文件:只剩"直接受信签名者"这一条路(开发期形态)。 + return { keys } + } + const loaded = readSignedFile(file) + if (loaded === undefined) { + return { keys: [], error: `signer-set-unreadable: ${file}` } + } + const verdict = verifySignerSet(loaded.doc, loaded.sig, opts.roots) + if (!verdict.ok) { + return { keys: [], error: `signer-set-rejected: ${verdict.reason}` } + } + signerSet = verdict.doc + if (opts.network !== undefined && signerSet.network !== opts.network) { + return { keys: [], error: `signer-set-network-mismatch: ${signerSet.network} != ${opts.network}` } + } + for (const k of signerSet.signers) if (!keys.includes(k)) keys.push(k) + log(`[identity] 受信签名者 ${keys.length} 把(来自 ${file},net=${signerSet.network},经根验签通过)`) + return { keys, signerSet } +} + +/** + * 装载**吊销清单**(经签名者验签)。 + * + * ⚠️ 与签名者集合**不同的一条**:清单**没配** ⇒ 返回 `undefined` 但**不报错** + * (`keys.ts` 的 HMAC 已经把"未登记的 hostId"挡住了,吊销清单是"已登记但被撤"的这一层); + * 清单**配了却验不过** ⇒ 返回 `{ error }`,调用方必须**拒**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)。 + */ +export function loadRevocations(opts: { + file?: string + trustedSignerKeys: readonly string[] + log?: (line: string) => void +}): { list?: RevocationList; error?: string } { + const file = opts.file ?? '' + if (file === '') return {} + const loaded = readSignedFile(file) + if (loaded === undefined) return { error: `revocations-unreadable: ${file}` } + const verdict = verifyRevocations(loaded.doc, loaded.sig, opts.trustedSignerKeys) + if (!verdict.ok) return { error: `revocations-rejected: ${verdict.reason}` } + opts.log?.(`[identity] 吊销清单:net=${verdict.doc.network} hosts=[${verdict.doc.hosts.join(',')}] nodeKeys=${verdict.doc.nodeKeys.length}`) + return { list: verdict.doc } +} + +/** 装载本机的 `{grant, sig}`;未配 ⇒ `undefined`(调用方按"本机还没入网凭据"处理)。 */ +export function loadNodeGrant(file: string): { grant: NodeGrant; sig: string } | undefined { + const loaded = readSignedFile(file) + if (loaded === undefined) return undefined + const grant = parseNodeGrant(loaded.doc) + if (grant === undefined || typeof loaded.sig !== 'string') return undefined + return { grant, sig: loaded.sig } +} + +/** + * **本机节点身份**(relay 客户端侧的唯一装配入口)—— `main.ts` / worker / Manager 都走它。 + * + * 返回 `undefined` 的确切语义是「**本机没有身份可用**」,而不是「身份校验通过」: + * - 密钥文件或凭据文件**任一没配** ⇒ `undefined`(过渡期形态:只做 HMAC); + * - 配了却**读不出来 / 凭据不合法** ⇒ **抛**(⛔ 不静默退化成"没身份" —— + * 那会让"凭据坏了"表现成"relay 没要求身份时一切正常",等 relay 一开强制就整台失联, + * 而排障时看到的是"配置明明写了")。 + * + * ⚠️ 这里**不校验凭据签名**(那是 relay 与本机对端的事):本机只是"把我的证书带上"。 + * 本机对**自己**的凭据也不做本地校验 —— 它若无效,relay 会明确拒(`identity-*` 原因码), + * 那条日志才是可诊断的那一条。 + */ +export function loadClientIdentity(opts: { + keyFile: string + grantFile: string + log?: (line: string) => void +}): { privateKeyPem: string; grant: NodeGrant; grantSig: string } | undefined { + const keyFile = (opts.keyFile ?? '').trim() + const grantFile = (opts.grantFile ?? '').trim() + if (keyFile === '' || grantFile === '') return undefined + const node = loadOrCreateNodeKey(keyFile, opts.log) + const loaded = loadNodeGrant(grantFile) + if (loaded === undefined) { + throw new Error(`node grant file ${grantFile} is missing or malformed (need {"doc":{...},"sig":""})`) + } + opts.log?.( + `[identity] 本机节点 ${loaded.grant.network}/${loaded.grant.hostId} 指纹=${nodeKeyFingerprint(node.publicKey) ?? '-'}`, + ) + return { privateKeyPem: node.privateKeyPem, grant: loaded.grant, grantSig: loaded.sig } +} diff --git a/src/net/relay/index.ts b/src/net/relay/index.ts new file mode 100644 index 0000000..ea4a18d --- /dev/null +++ b/src/net/relay/index.ts @@ -0,0 +1,88 @@ +/** + * 覆盖网络中继(relay)—— **自研、零新增依赖、零新增公网口**(传输方案 §10 定案)。 + * + * ## 一段话说完它是什么 + * worker 侧 `RelayClient` **只拨出**一条 wss;`RelayServer` **只绑回环**,为每个注册端口在 + * `127.0.0.1` 上开一条监听;Manager 连那条回环口 ⇒ 字节经多路复用跑回 worker 本地端口。 + * 对 Manager 而言地址形态与 sshd 版**完全同形**(`host:port`),所以换它是 env 级动作。 + * + * ## 三条硬约束(来自实测,改动前先读) + * 1. **零新增公网口**:47 无本机防火墙(`nft INPUT policy accept`)⇒ 绑 `0.0.0.0` 即公网可达。 + * 2. **worker 只拨出**:任何"在 worker 上开监听"的方案都让暴露面从 O(1) 变 O(N)。 + * 3. **精确 ACK**:注册与开流都必须有确认帧,未确认即断并计数 —— 治的是 SSH 版"静默失败"的病根。 + * + * @module dshs/net/relay + */ + +export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_QUEUE_MAX_BYTES } from './server.js' +export type { RelayServerOptions, RelayStatus } from './server.js' +export { RelayClient, describeClientStatus, gracefulBurstMsDefault, openedChannelFailedTerminally, waitUpOnStatus } from './client.js' +export type { RelayClientOptions, RelayClientStatus, RelayClientState, WebSocketLike, WebSocketCtor, WaitUpStatusOptions } from './client.js' +export { RelayRendezvous } from './rendezvous.js' +export type { RelayRendezvousOptions } from './rendezvous.js' +export { RelayDialer } from './dialer.js' +export type { RelayDialerOptions } from './dialer.js' +export { RelayFailoverSupervisor, relayFailoverThresholds } from './switcher.js' +export type { + RelayChannelHandle, + RelayFailoverDeps, + RelayFailoverStats, + RelayFailoverThresholds, +} from './switcher.js' +export { MuxDuplex } from './duplex.js' +export type { MuxDuplexOptions } from './duplex.js' +export { MUX, WS_CLOSE, acceptWebSocket, encodeMux, decodeMux, encodeJsonFrame, parseJsonPayload, WsConnection } from './wire.js' +export type { MuxFrame, MuxType, WsServerOptions } from './wire.js' +export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, isHostId, isNetworkId, logicalName, normalizeDialers, parseLogicalName, sameNetwork } from './network.js' +export { DIRECTORY_PATH, DIRECTORY_PAYLOAD_TAG, DIRECTORY_VERSION, DEFAULT_OVERLAY_SEED, buildDirectoryDocument, directoryPayload, directoryUrlFor, overlayEnvSeeds, overlayEnvTrustedKeys, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, listOverlayRelayCandidates, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory } from './directory.js' +export type { CachedDirectory, DirectoryVerdict, OverlayAddressSource, OverlayDirectory, OverlayRelayCandidates, OverlayRelayResolution, ResolveOverlayRelayOptions } from './directory.js' +export { keyEntryOf, loadKeysFile, parseKeysInline, normalizeKeyRecord, lookupKey, describeKeyEntry, assertKey } from './keys.js' +export type { RelayKeyEntry, RelayKeyMap } from './keys.js' +export { + IDENTITY_VERSION, + NODE_GRANT_TAG, + REVOCATION_TAG, + SIGNER_SET_TAG, + PROOF_TAG, + DEFAULT_NODE_KEY_FILE, + generateAuthorityKey, + generateNodeKey, + identityEnvRequire, + identityEnvTrustedRoots, + identityEnvTrustedSigners, + loadNodeGrant, + loadOrCreateNodeKey, + loadRevocations, + loadTrustedSigners, + nodeGrantPayload, + nodeKeyFingerprint, + normalizePublicKey, + parseNodeGrant, + parseRevocationList, + parseSignerSet, + proofPayload, + publicKeyOfPrivate, + readSignedFile, + revocationPayload, + signNodeGrant, + signProof, + signRevocations, + signSignerSet, + signerSetPayload, + verifyNodeGrant, + verifyPeerGrant, + verifyProof, + verifyRevocations, + verifySignerSet, + writeSignedFile, +} from './identity.js' +export type { + IdentityReason, + IdentityVerdict, + NodeGrant, + PeerVerifyContext, + RevocationList, + SignerSet, +} from './identity.js' +export { chooseNode, describeDecision, rankCandidates, scoreCandidate } from './placement.js' +export type { ChooseOptions, NodeCandidate, PlacementDecision, PlacementScore, PlacementWeights } from './placement.js' diff --git a/src/net/relay/keys.ts b/src/net/relay/keys.ts new file mode 100644 index 0000000..246cec4 --- /dev/null +++ b/src/net/relay/keys.ts @@ -0,0 +1,193 @@ +/** + * relay 密钥装载 —— **每 worker 一密钥**(不是共享 token),**且每条密钥带「属于哪张网」**。 + * + * ## 为什么不是共享 token + * 共享 token 的问题是**爆炸半径 = 全部 worker**:任何一台 worker 被读走配置文件,攻击者就能 + * 冒充**任意** worker 注册(方案 §10.1 判据①,`chisel` / `frp` 正是在这里垫底)。 + * 每 worker 一密钥把爆炸半径收回到那一台。 + * + * ## 为什么带「网」,以及为什么键是**逻辑名** + * 只按 `hostId` 索引时,**这张表回答不了「它属于哪张网」** ⇒ relay 只能相信 HELLO 里的 `network` + * 声明。于是任何持有任意一条有效密钥的节点,**声称任意合法网名就能在那张网里注册** —— + * 而「网内别的节点」与「这张网自己」都无从否决(`交接单_网抽象与地址规划R6 §待办②` 的确切缺口)。 + * + * ⇒ 本模块让**密钥表本身**成为成员资格的唯一判据(配置是既成事实,声明只是待校验的输入)。 + * 表的**键 = 逻辑名 `/`**(`network.ts` 的「唯一入口」口径),因为: + * + * 1. **同一台机器名可以出现在两张网里**(`u:5/pc-1` 与 `u:9/pc-1`)—— 用户设备名是客户端取的, + * 跨用户撞名是常态;拿 `hostId` 当键会让第二张网**注册不进来**(一个维度净变差)。 + * 2. 与 `server.ts` 的会话表 / 端点表**同一个口径**(都是逻辑名)⇒ 不再有「这张表按 hostId、 + * 那张表按逻辑名」两套写法(本线反复复发的那类病)。 + * + * ## 格式(**向后兼容,旧配置一字不改照旧可用**) + * ```json + * { + * "w-47": "fc6c…7d01", // 旧写法:裸 hostId ⇒ 运维网 ops + * "manager": { "secret": "515d…3b6ea" }, // 新写法:显式给出 secret + * "u:5/pc-1": "aa11…77aa", // 带网:与 u:9/pc-1 互不干扰(网络取自**键**) + * "u:9/pc-1": { "secret": "bb22…88bb" } + * } + * ``` + * 内联形式(便于 env 传入,**不建议**用于生产,因为 env 会进 `ps`/journald): + * `w-47:<64hex>,ops/manager:<64hex>,u:5/pc-1:<64hex>` + * + * ⚠️ 名字段一律走 `network.ts#parseLogicalName`(**唯一入口**)—— 它同时接受 + * `网/hostId`、`网:hostId` 与旧形态裸 `hostId`,且**网络 id 非法就抛**。 + * 抛而**不静默回落 `ops`**:静默回落会让「网配错了」表现成「网络不通」,那是最难查的一类。 + * + * @module dshs/net/relay/keys + */ + +import { readFileSync } from 'node:fs' + +import { OPS_NETWORK, assertNetworkId, logicalName, parseLogicalName } from './network.js' + +const HEX64 = /^[0-9a-f]{64}$/ + +/** + * 一条 relay 密钥。 + * + * - `secret`:64 位 hex(= 32 字节,HMAC-SHA256 的推荐长度); + * - `network`:该 hostId **属于哪张网** —— 成员资格的唯一判据(序③); + * ⚠️ 它**是准入判据,不是注释**:relay 拿它与 HELLO 的声明比对,不一致即拒。 + */ +export interface RelayKeyEntry { + network: string + secret: string +} + +/** `逻辑名 / → 密钥条目`。 */ +export type RelayKeyMap = Map + +/** 校验一条密钥:**必须 64 位 hex**。短密钥直接拒,不静默接受。 */ +export function assertKey(hostId: string, secret: string): string { + const s = secret.trim().toLowerCase() + if (!HEX64.test(s)) throw new Error(`relay key for "${hostId}" must be 64 hex chars (got ${s.length} chars)`) + return s +} + +/** 键里**显式写了网络**没有(`u:5/pc-1` / `ops/manager` ⇒ 有;`pc-1` ⇒ 没有)。 */ +function isQualifiedName(raw: string): boolean { + return raw.includes('/') || raw.includes(':') +} + +/** + * 把一条登记项归一化成 `{name, entry}`。 + * + * 网络来源有**两个可能的位置**(键 / 值),规则刻意简单: + * - 键是**限定名**(`u:5/pc-1`)⇒ 网络**只认键**;值里若也写了且不一致 ⇒ **抛** + * (两处写不一致 = 埋雷,与 `normalizeDialers` 的「写了就必须一致」同一条纪律); + * - 键是**裸 hostId** ⇒ 网络取自值里的 `network`,缺省 `ops`(= R5 旧配置的事实)。 + */ +export function normalizeKeyRecord(rawKey: string, rawValue: unknown): { name: string; entry: RelayKeyEntry } { + const parsed = parseLogicalName(rawKey) + const qualified = isQualifiedName(rawKey) + let network = parsed.network + let secretRaw: unknown = rawValue + if (rawValue !== null && typeof rawValue === 'object' && !Array.isArray(rawValue)) { + const o = rawValue as Record + if (typeof o.network === 'string' && o.network.trim() !== '') { + const declared = assertNetworkId(o.network, `relay key "${rawKey}" 的网络段`) + if (qualified && declared !== parsed.network) { + throw new Error(`relay key "${rawKey}" 自相矛盾:键说 "${parsed.network}",值里的 network 说 "${declared}"`) + } + network = declared + } + secretRaw = o.secret + } + if (typeof secretRaw !== 'string') { + throw new Error(`relay key for "${rawKey}" must be a 64-hex string or {secret[, network]}`) + } + const secret = assertKey(parsed.hostId, secretRaw) + return { name: logicalName(network, parsed.hostId), entry: { network, secret } } +} + +/** + * 宽容读取:把「旧调用方手里的 `Map`」与「新的 `Map<逻辑名, RelayKeyEntry>`」 + * 都读成 {@link RelayKeyEntry}。 + * + * 存在的理由:`server.ts` 的 `keys` 选项在若干地方被构造(`main.ts` / `web/server.ts` / 单测), + * 逐个改成新形态会带来「漏改一处 ⇒ 运行期 `entry.secret` 是 `undefined` ⇒ 全部拒绝」的风险 —— + * 那个失效形态**看起来像网络不通**。收在一个函数里,读的地方只有一种写法。 + * + * ⚠️ 本函数**只在没有网络信息可依据时**才把网络当 `ops`。若键本身是限定名(已表达网络), + * 调用方应当用 {@link lookupKey}(它会从键里取网络),否则会把 `u:5/pc-1` 误判成 `ops`。 + */ +export function keyEntryOf(raw: string | RelayKeyEntry | undefined): RelayKeyEntry | undefined { + if (raw === undefined) return undefined + if (typeof raw === 'string') return { network: OPS_NETWORK, secret: raw } + return raw +} + +/** + * 查一条密钥 —— **唯一的查表入口**(`server.ts` / `main.ts` 都走它,⛔ 不各自 `keys.get`)。 + * + * 两步:① 按**逻辑名**查(新形态,能区分同名不同网);② 回落到**裸 hostId**(R5 旧形态: + * `Map` 与「键是裸 hostId 的 JSON 项」)。 + * + * ⚠️ **网络归属从哪来**(这条搞错就会「配对了却仍被拒」): + * - 命中**逻辑名**键 ⇒ 值若是裸 hex 串,网络**取自键**(键是限定名,它已经说了是哪张网); + * 值若是对象,则用对象里那把(`normalizeKeyRecord` 保证两者一致;手写的 `Map` 若不一致, + * 由调用方的 `entry.network !== 声明网` 兜住 ⇒ 仍是失败关闭)。 + * - 命中**裸 hostId** ⇒ 值的网络缺省 `ops`(R5 旧配置的**事实**,不是猜测)。 + * + * ⛔ 第二步**不做网络过滤** —— 网络一致性的判据只有一处(调用方拿 `entry.network !== 声明网` + * 判):这样「裸条目 + 声明别张网」会得到**明确的 `network-mismatch`**,而不是含混的 + * `unknown-host`(后者会让「配错网」看起来像「这台机器从没登记过」)。 + */ +export function lookupKey( + keys: ReadonlyMap, + network: string, + hostId: string, +): RelayKeyEntry | undefined { + const direct = keys.get(logicalName(network, hostId)) + if (direct !== undefined) { + if (typeof direct === 'string') return { network, secret: direct } + return direct + } + return keyEntryOf(keys.get(hostId)) +} + +/** + * 解析 `hostId:secret` / `network/hostId:secret` / `network:hostId:secret`。 + * + * **切点 = 最后一个 `:`**(不是第一个):网络 id 自己就可能含 `:`(`u:5`), + * 而 secret 是纯 hex **不含** `:` ⇒ 最后一个 `:` 必然是分隔符。 + * ⛔ 用第一个 `:` 会把 `u:5/pc-1:` 切成网络段 `u`,于是「配置写错」长得像「这张网不存在」。 + */ +export function parseKeysInline(text: string): RelayKeyMap { + const out: RelayKeyMap = new Map() + for (const pair of text.split(',')) { + const item = pair.trim() + if (item === '') continue + const idx = item.lastIndexOf(':') + if (idx <= 0 || idx === item.length - 1) throw new Error(`malformed relay key entry: ${item.slice(0, 12)}…`) + const name = item.slice(0, idx).trim() + const { name: key, entry } = normalizeKeyRecord(name, item.slice(idx + 1).trim()) + out.set(key, entry) + } + return out +} + +/** 读 JSON 密钥文件(生产用;权限应为 `600`,仅能跑 relay 的用户可读)。 */ +export function loadKeysFile(path: string): RelayKeyMap { + const raw: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error( + `relay keys file ${path} must be a JSON object of { "/": "<64hex>" | {secret[, network]} }`, + ) + } + const out: RelayKeyMap = new Map() + for (const [rawKey, rawValue] of Object.entries(raw as Record)) { + const { name, entry } = normalizeKeyRecord(rawKey, rawValue) + if (out.has(name)) throw new Error(`relay keys file ${path} 里 "${name}" 出现了两次`) + out.set(name, entry) + } + return out +} + +/** 一行摘要(给启动日志 / `/status` 用,**同一条信息只说一次**)。 */ +export function describeKeyEntry(hostId: string, entry: RelayKeyEntry): string { + // `ops` 下的条目**省略网络前缀** —— 与 R5 时代的日志写法一致,读起来不变。 + return entry.network === OPS_NETWORK ? hostId : `${entry.network}/${hostId}` +} diff --git a/src/net/relay/main.ts b/src/net/relay/main.ts new file mode 100644 index 0000000..f1fd79f --- /dev/null +++ b/src/net/relay/main.ts @@ -0,0 +1,363 @@ +/** + * `dshs-relay` 单元入口(R1)—— **一条命令既能起服务端也能起客户端**,便于分机验证。 + * + * ```bash + * # 服务端(**只绑回环**;生产由 systemd 单元拉起) + * node lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000 + * + * # 客户端(worker 侧拨出;R1 用 `ssh -L` 把远端的回环口引到本机来拨) + * node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay \ + * --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20000 + * ``` + * + * ⚠️ **本文件暂不读 `src/config.ts`**:R1 阶段 relay 是**独立可选单元**,且 `src/config.ts` + * 目前有其它会话在改(工作区未提交改动)⇒ 先只认 `DSHS_RELAY_*` / `argv`,避免制造合并冲突。 + * R3 集成进 dsh 进程时再收敛到 `config.ts`(那一步才有必要)。 + * + * @module dshs/net/relay/main + */ + +import { RelayClient, describeClientStatus, waitUpOnStatus } from './client.js' +import { + DEFAULT_OVERLAY_SEED, + overlayEnvSeeds, + overlayEnvTrustedKeys, + resolveOverlayRelay, + listOverlayRelayCandidates, +} from './directory.js' +import { RelayFailoverSupervisor, relayFailoverThresholds } from './switcher.js' +import type { RelayChannelHandle } from './switcher.js' +import { parseKeysInline, loadKeysFile, lookupKey, type RelayKeyMap } from './keys.js' +import { + identityEnvRequire, + identityEnvTrustedRoots, + identityEnvTrustedSigners, + loadClientIdentity, + loadRevocations, + loadTrustedSigners, +} from './identity.js' +import { OPS_NETWORK, describeDialers, normalizeDialers } from './network.js' +import { DEFAULT_RELAY_PORT, RelayServer } from './server.js' + +interface Args { + client: boolean + url?: string + hostId?: string + /** 本节点所属网(P0-1)。缺省 `ops` —— 见 `network.ts`。 */ + network: string + ports: number[] + port: number + keysFile?: string + base: number + span: number + /** 容量准入:允许同时在线的 host 数上限(`0` = 不限)。 */ + maxHosts: number + quiet: boolean +} + +function parseArgs(argv: readonly string[]): Args { + const args: Args = { + client: false, + network: process.env.DSHS_OVERLAY_NETWORK_ID ?? OPS_NETWORK, + ports: [], + port: Number(process.env.DSHS_RELAY_PORT ?? DEFAULT_RELAY_PORT), + keysFile: process.env.DSHS_RELAY_KEYS_FILE, + base: Number(process.env.DSHS_INSTANCE_PORT_BASE ?? 20000), + span: Number(process.env.DSHS_INSTANCE_PORT_SPAN ?? 1000), + maxHosts: Number(process.env.DSHS_RELAY_MAX_HOSTS ?? 0), + quiet: false, + } + for (let i = 0; i < argv.length; i++) { + const a = argv[i] + const next = (): string => { + const v = argv[++i] + if (v === undefined) throw new Error(`${a} requires a value`) + return v + } + if (a === '--client') args.client = true + else if (a === '--quiet') args.quiet = true + else if (a === '--url') args.url = next() + else if (a === '--host') args.hostId = next() + else if (a === '--network') args.network = next() + else if (a === '--port') args.port = Number(next()) + else if (a === '--base') args.base = Number(next()) + else if (a === '--span') args.span = Number(next()) + else if (a === '--max-hosts') args.maxHosts = Number(next()) + else if (a === '--keys-file') args.keysFile = next() + else if (a === '--ports') args.ports = next().split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0) + else if (a === '--help' || a === '-h') { + process.stdout.write( + 'usage: main.js [--client --url --host --ports ] [--network ] [--port n] [--keys-file p] [--base n] [--span n] [--max-hosts n]\n' + + ' 容量准入:--max-hosts(0 = 不限);满载时新节点收到 at-capacity + retryAfterMs 并**排队等待**,已在册节点重连优先。\n' + + ' 网维度(P0-1):--network 缺省 ops;拨号方白名单 DSHS_RELAY_DIALERS 接受 "ops:manager" / "manager"(旧写法)两种。\n' + + ` 引导(P0-2):客户端**不带 --url** 时按「缓存目录 > 内置种子」取址;内置种子 = ${DEFAULT_OVERLAY_SEED}\n` + + ' (env 显式 = --url / DSHS_RELAY_URL,**压制引导链**;受信目录公钥 = DSHS_OVERLAY_DIR_PUBKEYS)。\n', + ) + process.exit(0) + } else throw new Error(`unknown argument: ${a}`) + } + return args +} + +function loadKeys(args: Args): RelayKeyMap { + if (process.env.DSHS_RELAY_KEYS !== undefined && process.env.DSHS_RELAY_KEYS.trim() !== '') { + return parseKeysInline(process.env.DSHS_RELAY_KEYS) + } + if (args.keysFile !== undefined && args.keysFile !== '') return loadKeysFile(args.keysFile) + throw new Error('no relay keys: set --keys-file or DSHS_RELAY_KEYS=":<64hex>,…"') +} + +/** + * 序③:relay 侧的**身份校验配置**(受信签名者 / 吊销清单 / 是否强制)。 + * + * **失败关闭落在这里**:配了 `DSHS_OVERLAY_REQUIRE_IDENTITY=1` 却拿不出任何受信签名者 + * ⇒ **起动即抛**(⛔ 不"先起来,慢慢拒")。理由与 `assertNetworkId` 同款 —— + * 配置不完整就该在**单元状态**上立刻可见(`systemctl status` 一眼看到失败原因), + * 而不是变成"服务 up、日志里全是 AUTH DENY"那种"看起来在跑、其实谁也进不来"的形态。 + */ +function loadIdentityForServer(log: (line: string) => void): { + trustedSignerKeys: string[] + revocations: ReturnType['list'] + requireIdentity: boolean +} { + const requireIdentity = identityEnvRequire() + const loaded = loadTrustedSigners({ + roots: identityEnvTrustedRoots(), + directSigners: identityEnvTrustedSigners(), + signerSetFile: process.env.DSHS_OVERLAY_SIGNER_SET_FILE, + // ⚠️ 这里**不传 network**:relay 服务的网是**每会话**声明的,不是整机一个属性 + //(一台 relay 可以同时承载多张网)。跨网签发由每会话的 `network/hostId` 判据拦。 + log, + }) + if (loaded.error !== undefined) { + if (requireIdentity) throw new Error(`identity config incomplete: ${loaded.error}`) + log(`[relay] ⚠ 签名者集合不可用(${loaded.error})⇒ 本轮**不做身份校验**(仅 HMAC)`) + } + const rev = loadRevocations({ + file: process.env.DSHS_OVERLAY_REVOCATIONS_FILE, + trustedSignerKeys: loaded.keys, + log, + }) + if (rev.error !== undefined) { + if (requireIdentity) throw new Error(`identity config incomplete: ${rev.error}`) + log(`[relay] ⚠ 吊销清单不可用(${rev.error})⇒ 本轮**不做吊销校验**`) + } + if (requireIdentity && loaded.keys.length === 0) { + throw new Error('DSHS_OVERLAY_REQUIRE_IDENTITY=1 但没有任何受信签名者 ⇒ 拒绝启动(配置不完整,不静默放开)') + } + return { trustedSignerKeys: loaded.keys, revocations: rev.list, requireIdentity } +} + +/** + * 换址用的**非抛版**等待(序⑦):`--client` 的 `open()` 要的是"能不能起来"这个布尔, + * 起不来就交回 `undefined`,由监管器决定"保持原通道"(D4)。 + * + * 🔴 **序⑨ · RC-1(C3 装配点)**:实现已抽到 {@link waitUpOnStatus}(三个装配点共用一份,D7); + * 相对改造前的唯一差别 = **终态失败(死候选)立即 `false`**,⛔ 不再白等满 `upTimeoutMs`。 + */ +async function waitUpOn(client: RelayClient, timeoutMs: number, log?: (line: string) => void): Promise { + return waitUpOnStatus(client, timeoutMs, { + onDead: (st) => + log?.( + `[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` + + `burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`, + ), + }) +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)) + const keys = loadKeys(args) + const log = args.quiet ? (): void => undefined : (line: string): void => { + process.stdout.write(`${line}\n`) + } + + if (!args.client) { + /** + * 拨号方白名单(R5 / P0-1):`DSHS_RELAY_DIALERS="ops:manager,u:5:pc-1"` —— **空 = 该能力关闭**(默认)。 + * + * 两种写法都接受(`normalizeDialers`): + * · **旧写法**(R5 时代,扁平 hostId)⇒ 等价于"这些都在 `ops` 网里" ⇒ 现网 drop-in 不必改动; + * · **新写法** `network:hostId`(也接受 `network/hostId`)⇒ 按网络分桶,**没列到的网一个都拨不动**。 + * ⚠️ 网络 id 非法 ⇒ `normalizeDialers` 会**抛**(配置错就炸,不静默变成"谁也没匹配上")。 + */ + const dialers = normalizeDialers( + new Set( + (process.env.DSHS_RELAY_DIALERS ?? '') + .split(',') + .map((s) => s.trim()) + .filter((s) => s !== ''), + ), + ) + const identity = loadIdentityForServer(log) + const server = new RelayServer({ + port: args.port, + keys, + instancePortBase: args.base, + instancePortSpan: args.span, + maxHosts: args.maxHosts, + dialers, + trustedSignerKeys: identity.trustedSignerKeys, + revocations: identity.revocations, + requireIdentity: identity.requireIdentity, + log, + }) + await server.start() + if (dialers.size > 0) log(`[relay] 拨号方白名单:${describeDialers(dialers).join(',')}`) + log( + `[relay] 身份(序③):受信签名者 ${identity.trustedSignerKeys.length} 把,` + + `强制=${identity.requireIdentity ? 'on' : 'off'},吊销 hostId ${identity.revocations?.hosts.length ?? 0} 个`, + ) + const shutdown = (sig: string): void => { + log(`[relay] ${sig} ⇒ stopping`) + // **硬兜底**:`stop()` 若被任何残留连接拖住,也必须按时退出 —— 等价于 systemd 的 + // `TimeoutStopSec`。停机时长本身就是对端的恢复时间,不能被"礼貌"拖长。 + const hard = setTimeout(() => { + log('[relay] stop() 超时 ⇒ 强制退出(停机时长必须可控)') + process.exit(0) + }, 2_000) + void server.stop().then(() => { + clearTimeout(hard) + process.exit(0) + }) + } + process.on('SIGTERM', () => shutdown('SIGTERM')) + process.on('SIGINT', () => shutdown('SIGINT')) + return + } + + if (args.hostId === undefined || args.ports.length === 0) { + throw new Error('client mode needs --host and --ports') + } + /** + * ⚠️ **必须在这里收进常量**:上面那两句校验把 `args.hostId` 收窄成 `string`, + * 但下面 `buildClient` 是**闭包**(序⑦ 换址时要在运行期再建客户端)—— + * TS 的收窄**不进闭包**,直接引用会退回 `string | undefined`。 + */ + const hostId: string = args.hostId + const ports: number[] = args.ports + /** + * 覆盖网络 P0-2:**取址走引导三级链**(env 显式 > 缓存目录 > 内置种子)。 + * + * 本入口是**独立进程**、不经过 `resolveConfig()` ⇒ 种子 / 受信公钥 / 缓存路径从 env 直读。 + * `--url` 与 `DSHS_RELAY_URL` 都算"env 显式"(压制引导链,运维最后手段)。 + */ + let relayUrl = args.url ?? '' + if (relayUrl === '') { + const resolved = await resolveOverlayRelay({ + envUrl: '', + seeds: overlayEnvSeeds(), + trustedKeys: overlayEnvTrustedKeys(), + cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '', + log, + }) + relayUrl = resolved.url + log( + `[relay-client] 引导链取址 source=${resolved.source} ` + + `url=${relayUrl === '' ? '-' : relayUrl} detail=${resolved.detail}`, + ) + } + if (relayUrl === '') { + throw new Error('client mode needs a relay url: --url / DSHS_RELAY_URL / 引导链(种子或签名目录)') + } + const entry = lookupKey(keys, args.network, args.hostId) + if (entry === undefined) throw new Error(`no key for host "${args.hostId}" in the keys file`) + if (entry.network !== args.network) { + // 成员资格在**本机**就先炸:拿着一张属于别的网的密钥去连,relay 必然拒(`network-mismatch`) + // ⇒ 与其等一轮"连上又被踢"的来回,不如在起动时把"密钥与网不匹配"直接讲清楚。 + throw new Error( + `key for "${args.hostId}" is registered in network "${entry.network}", but this node declares "${args.network}"`, + ) + } + /** + * 序③:本机节点身份(与 worker / Manager 走**同一个装配入口**)。 + * + * ⚠️ **缺凭据不抛**(本轮是"先加能力"的过渡期):`identity` 缺省 ⇒ 退回纯 HMAC, + * 与存量行为完全一致。**强制**与否由 relay 侧决定 —— 客户端这边只负责"有就带上"。 + */ + const identity = loadClientIdentity({ + keyFile: process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '', + grantFile: process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '', + log, + }) + /** + * 序⑦ · **C3 装配点:中继失败切流**。 + * + * 一句话:`--url` / `DSHS_RELAY_URL`(env 显式)**压制引导链**,此时**不启用**监管器 + * —— 运维把地址钉死了,就不该由我们背着它换。走引导链来的地址才启用。 + */ + type C3Handle = RelayChannelHandle & { client: RelayClient } + const makeHandle = (url: string, c: RelayClient): C3Handle => ({ + url, + client: c, + health: () => { + const st = c.status() + return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs } + }, + close: () => c.stop(), + }) + const buildClient = (url: string): RelayClient => + new RelayClient({ + url, + hostId, + networkId: args.network, + secret: entry.secret, + ports, + identity, + log, + }) + const client = buildClient(relayUrl) + client.start() + const failover = + args.url !== undefined && args.url !== '' + ? undefined + : new RelayFailoverSupervisor({ + /** **先建新、成功再关旧**:到不了 `up` 就返回 `undefined` ⇒ 监管器保持原通道(D4)。 */ + open: async (target) => { + const next = buildClient(target) + next.start() + if (!(await waitUpOn(next, relayFailoverThresholds().upTimeoutMs, log))) { + next.stop() + return undefined + } + return makeHandle(target, next) + }, + /** **同一份引导链**(⛔ 不另写取址;同源 = 只可能是已签名目录 / 内置种子里的地址)。 */ + candidates: async () => + ( + await listOverlayRelayCandidates({ + envUrl: '', + seeds: overlayEnvSeeds(), + trustedKeys: overlayEnvTrustedKeys(), + cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '', + log, + }) + ).urls, + log, + thresholds: relayFailoverThresholds(), + }) + if (failover !== undefined) { + failover.seed(makeHandle(relayUrl, client)) + failover.start() + } + /** 当前通道(单一权威来源 —— 切换后 reporter / shutdown 都要跟着走)。 */ + const currentClient = (): RelayClient => + (failover?.channel as C3Handle | undefined)?.client ?? client + const reporter = setInterval( + () => log(`[relay-client] ${describeClientStatus(currentClient().status())}`), + 30_000, + ) + reporter.unref() + const shutdown = (): void => { + failover?.stop() + currentClient().stop() + process.exit(0) + } + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) +} + +main().catch((err: unknown) => { + process.stderr.write(`[relay] fatal: ${err instanceof Error ? err.message : String(err)}\n`) + process.exit(1) +}) diff --git a/src/net/relay/network.ts b/src/net/relay/network.ts new file mode 100644 index 0000000..6302c92 --- /dev/null +++ b/src/net/relay/network.ts @@ -0,0 +1,222 @@ +/** + * 网抽象 —— **`network_id` 维度与"节点逻辑名"的唯一入口**(覆盖网络 ②·P0-1)。 + * + * ## 它解决的确切问题 + * R0–R5 跑通之后,relay 把**平台自己的 Worker 隧道**和**未来的用户设备**塞进**同一个扁平 + * `hostId` 命名空间**(`server.ts` 的 `sessions` / `endpoints` 都只按 `hostId` 索引,全仓 + * `grep -ri networkId|tailnet` = 0 命中)。今天只有 1 个用户、1 张网,问题不显形;一进第二类 + * 节点就会变成「**一张巨网 + 靠 ACL 兜**」—— 而写错一条 ACL 就泄露,这与项目 + * 「**权限只准收窄**」直接冲突。 + * + * ⇒ 本模块把「**哪张网**」变成**结构性维度**(不是策略性):判据只有这一处,纯函数、无 IO、可单测。 + * `server.ts` / `client.ts` / 运维配置**一律从这里取**,⛔ 不许在调用方拼字符串 + * (散着拼迟早就出现三套不一致的口径 —— 本线复盘里反复出现的那类病)。 + * + * ## 取值(D2 · 已定项) + * - **运维网固定 `ops`**:47(Manager)/ 106(Worker)/ 未来的中继与骨干 —— 即"平台自己的机器"。 + * - **用户网 = `u:`**:该用户名下的全部设备(含桌面客户端)。 + * ⇒ 存量数据天然落在 `ops`(这正是"加列带默认值"能零风险落地的原因)。 + * + * ## ⛔ 本模块**不做**的事(故意) + * - 不碰虚拟网卡 / L3 地址 / `100.64.0.0/10`(`交接单_网抽象与地址规划R6 §2 D1`); + * - 不解析 DNS、不下发对端清单(D4):本阶段收敛为「**逻辑名 + 授权**」。 + * + * @module dshs/net/relay/network + */ + +/** 运维网(平台自己的机器)。**固定值**,与部署位置无关。 */ +export const OPS_NETWORK = 'ops' + +/** + * 网络 id 的合法形状(三条分支,见 `isNetworkId`)。 + * + * 收在一个入口里,是为了让"配错了"在**装载配置时就炸**,而不是变成 + * 「谁也没匹配上 ⇒ 静默拒绝」(静默拒绝是本线反复要根治的病:它把"配置错"伪装成"网络不通")。 + */ +const TENANT_NET_RE = /^u:[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ +const GENERIC_NET_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/ + +/** hostId 的合法形状(`w-47` / `w-106` / `manager` …)。 */ +const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/ + +/** 逻辑名的**规范分隔符**。`/` —— `network_id` 不含 `/`,故**首个** `/` 即分隔点。 */ +export const NAME_SEP = '/' + +/** + * 网络 id 是否合法。三条分支: + * - `ops` —— 运维网(固定值); + * - `u:<租户 id>` —— 用户网(租户 id 是数字或字符串都行,故后半段放宽); + * - 其它显式命名(小写字母数字与 `_.-`)—— 留给将来的"二级网 / 测试网",**不新增机制**。 + * + * ⛔ **裸 `u` 是保留字**,永远不是一个合法的网名:它是"用户网前缀"。放行它会带来一个极隐蔽的坑 —— + * `DSHS_RELAY_DIALERS="u:5:manager"` 里的网络段 `u:5` 一旦被写错成 `u:5` 之外的形式(或有人直接 + * 写 `u:manager`),左/右切点会把网络段切成 `u`,于是"配置写错"**长得像"这张网不存在"**(静默拒绝)。 + */ +export function isNetworkId(raw: string): boolean { + if (raw === OPS_NETWORK) return true + if (raw === 'u') return false + return TENANT_NET_RE.test(raw) || GENERIC_NET_RE.test(raw) +} + +export function isHostId(raw: string): boolean { + return HOST_RE.test(raw) +} + +/** + * 校验网络 id;非法 ⇒ **抛**(不返回 `undefined`)。 + * + * 为什么抛而不"兜个默认值":`DSHS_OVERLAY_NETWORK_ID` 打错一个字,如果静默回落 `ops`, + * 那台机器会**以正确的样子加入错误的网**(表现是"另一张网里看不见它",且没有任何一行日志说得出原因)。 + */ +export function assertNetworkId(raw: string, what = 'network id'): string { + const v = raw.trim() + if (!isNetworkId(v)) throw new Error(`${what} 非法:${JSON.stringify(raw)}(合法形状:ops | u:<租户> | [a-z0-9][a-z0-9_.-]*)`) + return v +} + +/** 节点逻辑名 = `/`。**唯一拼法**(别在调用方拼)。 */ +export function logicalName(network: string, hostId: string): string { + return `${network}${NAME_SEP}${hostId}` +} + +/** + * 反解一个逻辑名 / 配置项。 + * + * ## 三条分隔规则(顺序即优先级) + * 1. 含 `/` ⇒ 按**首个** `/` 切(`ops/manager` · `u:5/w-106`)—— 规范形态; + * 2. 否则含 `:` ⇒ 按**最后一个** `:` 切(`ops:manager` · `u:5:manager`)—— + * 为的是兼容运维习惯的 `network:hostId` 写法;⚠️ 必须从**右**切:`u:5` 里的 `:` 属于网络 id; + * 3. 都不含 ⇒ **旧形态**(R5 时代的扁平 `hostId`)⇒ 落在 `ops`,**旧配置照旧可用**。 + * (过渡期不破坏现网:`DSHS_RELAY_DIALERS="manager"` 与 `"ops:manager"` 等价。) + * + * ⚠️ 第 2/3 条里"切出来的网络 id 必须合法",否则**抛** —— 见 `assertNetworkId` 的理由。 + */ +export function parseLogicalName(raw: string): { network: string; hostId: string } { + const { network, hostId } = parseEntry(raw) + return { network, hostId } +} + +/** + * 同 `parseLogicalName`,但额外告诉调用方"这条**自己写了网络没有**"(`qualified`)。 + * + * 为什么需要这个位:白名单的**两种入参**对"裸 hostId"的解释**不同** —— + * - 扁平列表(`Set`):裸 hostId = R5 旧写法 ⇒ `ops`; + * - 按网络分桶(`Map`):裸 hostId = **本桶那张网**里的 hostId。 + * 少了这个位,"`Map{'u:x' => {'d-user'}}`" 会被当成"把 ops 的 host 塞进 u:x 桶"⇒ 判据永远不命中 + * ⇒ **静默拒绝**(本线反复要根治的那类病:配置写对了,表现却像网络不通)。 + */ +function parseEntry(raw: string): { network: string; hostId: string; qualified: boolean } { + const v = raw.trim() + if (v === '') throw new Error('逻辑名为空') + const slash = v.indexOf(NAME_SEP) + if (slash > 0 && slash < v.length - 1) { + const network = assertNetworkId(v.slice(0, slash), `逻辑名 ${JSON.stringify(raw)} 里的网络段`) + return { network, hostId: assertHostId(v.slice(slash + 1), raw), qualified: true } + } + const colon = v.lastIndexOf(':') + if (colon > 0 && colon < v.length - 1) { + const network = assertNetworkId(v.slice(0, colon), `逻辑名 ${JSON.stringify(raw)} 里的网络段`) + return { network, hostId: assertHostId(v.slice(colon + 1), raw), qualified: true } + } + // 旧形态:扁平 hostId(**不带网络**,由调用方决定它属于哪张网,默认运维网)。 + return { network: OPS_NETWORK, hostId: assertHostId(v, raw), qualified: false } +} + +function assertHostId(raw: string, whole: string): string { + const v = raw.trim() + if (!isHostId(v)) throw new Error(`逻辑名 ${JSON.stringify(whole)} 里的 hostId 非法:${JSON.stringify(raw)}`) + return v +} + +/** 两个节点是否同网(DIAL 的**结构性**判据:不同网 ⇒ 连"能不能拨"这一步都走不到)。 */ +export function sameNetwork(a: string, b: string): boolean { + return a === b +} + +/** + * 同网**断言**(跨网 ⇒ 抛)—— P0-3 的"控制面侧那道门"。 + * + * 为什么不是让调用方用 `sameNetwork` 自己判:那样的常见写法是 + * `if (!sameNetwork(a, b)) return undefined` ⇒ **跨网拒绝在日志里什么都不留**, + * 表现与"节点离线 / 名字打错"完全同形 —— 正是本线反复要根治的静默失效。 + * 凡"跨网就是错"的地方一律用本函数:错误里**带着两边是哪张网**,排障不必猜。 + */ +export function assertSameNetwork(expected: string, actual: string, what = '节点'): void { + if (expected === actual) return + throw new Error(`${what} 跨网:期望 "${expected}",实际 "${actual}"(结构性隔离 ⇒ 不允许跨网解析)`) +} + +/** + * 拨号方白名单的**归一化** —— 同时接受两种入参,旧形态照旧可用(过渡期不破坏现网)。 + * + * | 入参 | 桶里"裸 hostId"的含义 | + * |---|---| + * | `Set`(R5 形态,`main.ts` 从 env 读出来的就是它) | 该条目**自带**网络(`ops:manager` / `u:5:d1`);裸 hostId ⇒ `ops` | + * | `Map>`(新形态) | 裸 hostId ⇒ **本桶那张网**里的 hostId | + * + * ⚠️ 两种入参对"裸 hostId"的解释不同,是**故意的**:分桶形态下,桶键已经表达了网络, + * 桶里再写一遍网络只会带来"两处写不一致"的新风险(所以**写了就必须一致,否则抛**)。 + * + * ⚠️ 返回的 map **永远是新的**(不被调用方后续 `Set` 改动影响)—— 白名单是安全判据, + * 不能因为某个持有引用的调用方顺手 `add()` 就悄悄放宽。 + */ +export function normalizeDialers( + input: ReadonlySet | ReadonlyMap> | undefined, +): Map> { + const out = new Map>() + if (input === undefined) return out + if (isDialerMap(input)) { + for (const [networkRaw, hosts] of input.entries()) { + const network = assertNetworkId(networkRaw, '拨号方白名单的网络段') + const bucket = new Set(out.get(network) ?? []) + for (const host of hosts ?? []) { + const e = parseEntry(host) + if (e.qualified && e.network !== network) { + throw new Error( + `拨号方白名单自相矛盾:桶 "${network}" 里放了属于 "${e.network}" 的条目 ${JSON.stringify(host)}`, + ) + } + bucket.add(e.hostId) + } + out.set(network, bucket) + } + return out + } + // 扁平列表(R5 形态):逐条按自带网络**分发**到各自的桶;裸 hostId ⇒ 运维网。 + for (const host of input) { + const e = parseEntry(host) + const bucket = new Set(out.get(e.network) ?? []) + bucket.add(e.hostId) + out.set(e.network, bucket) + } + return out +} + +/** + * 类型判别:`Map` 形态 vs 扁平 `Set` 形态。 + * + * 为什么要单独一个函数而不是直接 `input instanceof Map`:`ReadonlyMap` / `ReadonlySet` 都是 + * **接口**,`instanceof` 收窄在联合类型上不可靠(TS 会把 else 分支仍当成联合,于是迭代出来的 + * 元素类型变成 `string | [string, …]`)。写成显式类型谓词,编译器与读代码的人都清楚。 + */ +function isDialerMap( + v: ReadonlySet | ReadonlyMap>, +): v is ReadonlyMap> { + return typeof (v as ReadonlyMap>).get === 'function' +} + +/** + * 一行摘要(**同一条信息只说一次**)—— `main.ts` 启动日志与 `/status` 共用同一格式, + * 免得"日志说的"与"status 说的"长得不一样(那种差异排查时最费时间)。 + * + * `ops` 下的条目**省略网络前缀**(与 R5 的日志/配置写法一致,读起来不变)。 + */ +export function describeDialers(map: ReadonlyMap>): string[] { + const out: string[] = [] + for (const network of [...map.keys()].sort()) { + for (const host of [...(map.get(network) ?? [])].sort()) { + out.push(network === OPS_NETWORK ? host : logicalName(network, host)) + } + } + return out +} diff --git a/src/net/relay/placement.ts b/src/net/relay/placement.ts new file mode 100644 index 0000000..7198bd9 --- /dev/null +++ b/src/net/relay/placement.ts @@ -0,0 +1,209 @@ +/** + * 节点选点(placement)—— **"加入哪个节点"这件事的唯一判据来源**。 + * + * ## 为什么要有独立一层 + * 「自动选择适合的节点」如果散在 client / Manager / 门户里各写一遍,就会出现三套不一致的 + * 判据(这类不一致在复盘里反复出现)。所以:**判据只有这一处,纯函数、无 IO、可单测**。 + * + * ## 两条硬规则(先说结论) + * 1. **手动选择永远优先**(`manual: true`)—— 显式指定就不该被算法偷偷改掉; + * 手动指定的节点**满了** ⇒ 返回 `queued`(排队等待)或 `rejected`,**不会静默换一个**。 + * 2. **满载是唯一的硬门**(`capacity.free === 0` ⇒ 不可选)。其余(RTT 高、近期失败多) + * 只**降权**,不排除 —— 因为"唯一可用但慢"的节点也远好过"没有节点"。 + * + * ## 排序依据(用户要求的两条:速度 + 负载) + * ```text + * speed = 100 / (1 + rttMs / rttHalfMs) // rtt 0→100 分;50ms→50 分;200ms→20 分 + * load = 100 × (1 − used / max) // 容量未知 ⇒ 按 0.5 中性处理(不假装它空) + * score = weight × (wSpeed·speed + wLoad·load) − failurePenalty × recentFailures + * ``` + * 权重默认 `速度 0.55 / 负载 0.45`:实测里"能不能连上"由 RTT 决定,"连上之后卡不卡"由负载决定, + * 而我们的第一瓶颈是 **presence(在线态)**,所以速度略重(传输方案 §12 的实测依据)。 + * + * ## 与 relay 的关系 + * 本模块**不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。 + * relay 侧只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。 + * + * @module dshs/net/relay/placement + */ + +/** 一个候选节点(relay 或 worker,判据同构)的**可观测**画像。 */ +export interface NodeCandidate { + id: string + /** 往返时延(ms)。未知 ⇒ 不猜,按"最差但有值"处理(见 `assumedRttMs`)。 */ + rttMs?: number + /** 容量。`max <= 0` 或缺省 ⇒ **不限**(视为有空位,负载按中性算)。 */ + capacity?: { max: number; used: number } + /** 近期失败次数(连接失败 / 被拒)—— 用于降权,**不用于排除**。 */ + recentFailures?: number + /** 手工指定的节点。**最高优先级**:只要它没满就一定选它。 */ + manual?: boolean + /** 额外权重(例如"存量锚点"、"同机房");`1` = 中性。 */ + weight?: number +} + +export interface PlacementWeights { + /** 速度权重。默认 0.55。 */ + speed?: number + /** 负载权重。默认 0.45。 */ + load?: number + /** 每次近期失败的扣分。默认 15。 */ + failurePenalty?: number + /** 速度评分的半衰 RTT(ms)。默认 50。 */ + rttHalfMs?: number + /** RTT 未知时的代用值(ms)。默认 120(明显偏保守,避免"没测速"被当成"很快")。 */ + assumedRttMs?: number + /** 全部满载时的建议重试间隔(ms)。默认 5000。 */ + queueRetryAfterMs?: number +} + +export interface PlacementScore { + id: string + /** 0–100(负分表示被降权到负);`-Infinity` = 硬门拦住。 */ + score: number + /** 硬门原因。只有 `full` 是硬门。 */ + blocked?: 'full' + detail: { + speed: number + load: number + penalty: number + /** 剩余空位;容量未知时 `undefined`。 */ + freeSlots?: number + /** 负载比例 0–1。 */ + loadRatio?: number + } +} + +export interface PlacementDecision { + /** 选中的节点。全部满载 ⇒ `undefined`。 */ + chosen?: NodeCandidate + /** 结论形态:选中 / 排队等待 / 直接拒绝(无任何可选且不适合排队)。 */ + outcome: 'chosen' | 'queued' | 'rejected' + /** 排队时建议的等待时长(ms)。 */ + retryAfterMs?: number + /** **可解释**的理由(这个字段就是本模块存在的意义:拒绝也要说清为什么)。 */ + reason: string + /** 完整排名(含被拦的),便于前端展示与排障。 */ + ranking: PlacementScore[] +} + +const DEFAULTS = { + speed: 0.55, + load: 0.45, + failurePenalty: 15, + rttHalfMs: 50, + assumedRttMs: 120, + queueRetryAfterMs: 5_000, +} as const + +function clampScore(n: number): number { + return Math.max(-100, Math.min(100, n)) +} + +/** 给一个候选打分(纯函数;导出便于单测与前端复用同一套判据)。 */ +export function scoreCandidate(c: NodeCandidate, w: PlacementWeights = {}): PlacementScore { + const W = { ...DEFAULTS, ...w } + const rtt = typeof c.rttMs === 'number' && c.rttMs >= 0 ? c.rttMs : W.assumedRttMs + const speed = clampScore((100 / (1 + rtt / W.rttHalfMs)) | 0) + + const max = c.capacity !== undefined ? c.capacity.max : 0 + const used = c.capacity !== undefined ? c.capacity.used : 0 + const limited = max > 0 + const freeSlots = limited ? Math.max(0, max - used) : undefined + // 容量未知 ⇒ 按 0.5 中性:既不当成空、也不当成满(不猜)。 + const loadRatio = limited ? Math.min(1, used / max) : 0.5 + const load = clampScore(Math.round(100 * (1 - loadRatio))) + + const penalty = Math.round((c.recentFailures ?? 0) * W.failurePenalty) + const weight = typeof c.weight === 'number' && c.weight > 0 ? c.weight : 1 + const raw = weight * (W.speed * speed + W.load * load) - penalty + + const base = { + id: c.id, + detail: { + speed, + load, + penalty, + ...(freeSlots === undefined ? {} : { freeSlots }), + ...(limited ? { loadRatio } : {}), + }, + } + // **唯一的硬门**:容量已满。满了就是不能加入 —— 只能排队或换节点。 + if (freeSlots === 0) return { ...base, score: Number.NEGATIVE_INFINITY, blocked: 'full' } + return { ...base, score: Math.round(raw * 100) / 100 } +} + +/** 全量排名(降序;`-Infinity` 排在最后)。 */ +export function rankCandidates(candidates: readonly NodeCandidate[], w: PlacementWeights = {}): PlacementScore[] { + return candidates.map((c) => scoreCandidate(c, w)).sort((a, b) => b.score - a.score) +} + +export interface ChooseOptions extends PlacementWeights { + /** + * 手动指定的 id。给了它 ⇒ **只考虑它**(不静默改选别的)。 + * 这也是"手动选择"与"自动推荐"之间唯一的接口。 + */ + manualId?: string + /** 允许排队等待(默认 `true`)。设 `false` ⇒ 满载直接 `rejected`("不能加入")。 */ + allowQueue?: boolean +} + +/** + * 选一个节点 —— **自动 / 手动 / 排队**三种出口都有明确语义: + * | 情况 | outcome | 说明 | + * |---|---|---| + * | 手动指定且未满 | `chosen` | 尊重显式选择 | + * | 手动指定但已满 | `queued` / `rejected` | **不会**偷偷换节点 | + * | 自动且有空位 | `chosen` | 按速度 + 负载打分 | + * | 自动但全满 | `queued` / `rejected` | 排队等位,或(`allowQueue:false`)拒绝加入 | + * | 候选为空 | `rejected` | 明确"没有可选节点",不抛异常给上层去猜 | + */ +export function chooseNode(candidates: readonly NodeCandidate[], opts: ChooseOptions = {}): PlacementDecision { + const allowQueue = opts.allowQueue !== false + const retryAfterMs = opts.queueRetryAfterMs ?? DEFAULTS.queueRetryAfterMs + const ranking = rankCandidates(candidates, opts) + + if (ranking.length === 0) { + return { outcome: 'rejected', reason: '没有可用候选节点(候选列表为空)', ranking } + } + + const manualId = opts.manualId + if (manualId !== undefined && manualId !== '') { + const hit = ranking.find((r) => r.id === manualId) + if (hit === undefined) { + return { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 不在候选列表里(不静默改选别的节点)`, ranking } + } + if (hit.blocked === 'full') { + return allowQueue + ? { outcome: 'queued', retryAfterMs, reason: `手动指定的节点 "${manualId}" 已满载(容量已用尽)⇒ 排队等待`, ranking } + : { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 已满载 ⇒ 拒绝加入(未开启排队)`, ranking } + } + return { chosen: candidates.find((c) => c.id === manualId), outcome: 'chosen', reason: `按手动指定选择 "${manualId}"`, ranking } + } + + const best = ranking[0] + if (best.blocked !== 'full') { + const c = candidates.find((x) => x.id === best.id) + return { + chosen: c, + outcome: 'chosen', + reason: + `自动选择 "${best.id}"(速度 ${best.detail.speed} 分 / 负载 ${best.detail.load} 分` + + `${best.detail.freeSlots === undefined ? '' : ` / 空位 ${best.detail.freeSlots}`}` + + `${best.detail.penalty > 0 ? ` / 近期失败扣 ${best.detail.penalty}` : ''})`, + ranking, + } + } + + const fullCount = ranking.filter((r) => r.blocked === 'full').length + return allowQueue + ? { outcome: 'queued', retryAfterMs, reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 排队等待(${retryAfterMs}ms 后重试)`, ranking } + : { outcome: 'rejected', reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 拒绝加入(未开启排队)`, ranking } +} + +/** 供日志/UI 一行展示(**同一条信息只说一次**,避免各处自定义格式)。 */ +export function describeDecision(d: PlacementDecision): string { + const head = `placement=${d.outcome}${d.chosen === undefined ? '' : ` node=${d.chosen.id}`}` + const retry = d.retryAfterMs === undefined ? '' : ` retryAfter=${d.retryAfterMs}ms` + return `${head}${retry} reason="${d.reason}"` +} diff --git a/src/net/relay/rendezvous.ts b/src/net/relay/rendezvous.ts new file mode 100644 index 0000000..04187d0 --- /dev/null +++ b/src/net/relay/rendezvous.ts @@ -0,0 +1,57 @@ +/** + * `relay:` —— 会合实现的第三个(也是**去掉 sshd 依赖的那一个**)。 + * + * ## 与其他两个实现的关系(同一 `Rendezvous` 接口,可共存、可逐个切换) + * | 实现 | `via` | Manager 侧看到的地址 | 数据面 | + * |---|---|---|---| + * | `LocalRendezvous` | `local` | `127.0.0.1:19100`(同机直连) | 无中转 | + * | `ManagerSshRendezvous` | `manager-ssh` | `127.0.0.1:19000`(**sshd 反向隧道落点**) | Manager 主机上的 sshd | + * | **`RelayRendezvous`** | `relay` | `127.0.0.1:`(**relay 开了回环监听**) | relay 的一条出向 wss | + * + * 三者对 Manager 侧**同形**(都是 `host:port`)⇒ 换实现不动调用方,这是 S0 抽 `Reachability` + * 的全部意义(`src/net/rendezvous.ts:64` 早就写了"本类应被 `relay:` 实现替换")。 + * + * ## 独有增益:**实时在线态** + * 前两个实现只能回答"表里写的地址是什么"——`dsh_instances.status` 是 DB 快照,**不实时**。 + * relay 的注册由心跳维持(45s 无帧即判死)⇒ 本实现可以**先问在线、再给地址**, + * 离线直接回 `undefined`(让上层回退别的实现,而不是往死地址上打、干等到超时)。 + * + * @module dshs/net/relay/rendezvous + */ + +import { VIA_RELAY, type Reachability } from '../reachability.js' +import type { AddressLookup, Rendezvous } from '../rendezvous.js' +import { parseLogicalName } from './network.js' + +export interface RelayRendezvousOptions { + /** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */ + dialTargetUrl: string + /** + * **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。 + * 键是逻辑名(P0-3)⇒ 同名 host 分属两张网时各查各的,不会互相覆盖。 + */ + addressOf: AddressLookup + /** + * **实时**在线判定(relay 心跳驱动)。省略 = 不判在线(退化成与另两个实现相同的纯查表)。 + * 入参同样**逻辑名**(relay 的端点视图按 `network/hostId:port` 建键)。 + */ + online?: (name: string) => boolean +} + +export class RelayRendezvous implements Rendezvous { + readonly id = VIA_RELAY + + constructor(private readonly opts: RelayRendezvousOptions) {} + + dialTarget(): string { + return this.opts.dialTargetUrl + } + + async resolve(name: string): Promise { + if (this.opts.online !== undefined && !this.opts.online(name)) return undefined + const address = this.opts.addressOf(name) + if (address === undefined) return undefined + const { network, hostId } = parseLogicalName(name) + return { hostId, networkId: network, via: this.id, address, scheme: 'http' } + } +} diff --git a/src/net/relay/server.ts b/src/net/relay/server.ts new file mode 100644 index 0000000..7f95fe5 --- /dev/null +++ b/src/net/relay/server.ts @@ -0,0 +1,1445 @@ +/** + * relay 服务端 —— **worker 拨它、Manager 经它到 worker**(覆盖网络 R1)。 + * + * ## 拓扑(与 SSH 版**语义等价**,所以 Manager 侧零改动) + * ```text + * worker 侧 RelayClient ──(唯一一条出向 wss)──▶ 本服务(绑 127.0.0.1) + * │ 为每个注册端口开一条回环监听 + * ▼ + * Manager ──▶ 127.0.0.1:<动态分配端口> ──▶ 多路复用进那条 wss ──▶ worker 本地 127.0.0.1: + * ``` + * `Reachability.address` 仍是 `host:port` ⇒ `agentBaseUrlOf()` 拼出来的基址与 SSH 版 + * **同形**,调用方不需要知道底下换成了 relay。这是"可替换实现"能成立的前提。 + * + * ## 安全姿态(默认拒绝,逐条对应方案 §10.1 的判据) + * | 判据 | 做法 | + * |---|---| + * | ① 认证模型 | **每 worker 一密钥**(非共享 token);`HMAC-SHA256(secret, hostId\|ts\|nonce\|portsCsv)`;`timingSafeEqual` 比较;`ts` 窗口 ±60s;nonce 防重放(有界 LRU) | + * | ② 默认姿态 | 未认证连接 **只允许发 `HELLO`**,5s 未认证即关;未知类型直接关连接 | + * | ③ 零新增入站 | 默认绑 `127.0.0.1`;**只开回环监听**(`localPort` 动态分配,避开既有端口区间) | + * | ④ 爆炸半径 | worker 声明的端口必须落在**实例端口区间**内;每端口并发流上限;单流缓冲上限,超限**断流**而非静默丢弃 | + * | ⑤ 可观测 | `status()`:认证成败计数 / 拒绝计数 / 掉流计数 / 每 host 心跳龄 + 每 endpoint 的 `localPort` 与在线态 | + * | ⑥ **网维度**(P0-1) | 每条会话属于**一张网**(`HELLO.network`,缺省 `ops`);会话与端点表按**逻辑名 `/`** 索引;`DIAL` 必须**同网**且在**本网白名单**里 ⇒ 跨网连"能不能拨"都走不到(结构性隔离,不是策略性) | + * + * ## ⚠️ 为什么 `network` **不进 MAC** + * `HELLO` 的 MAC 输入刻意保持 `${hostId}|${ts}|${nonce}|${portsCsv}` **一字不改**:现网 47 / 106 + * 上跑的是旧客户端,改 MAC 公式 = 硬断(必须两端同时升级)。而网络维度的**真判据在服务端** + * (白名单按网络分桶 + 同网校验),`network` 只是"我属于哪张网"的声明 —— 声明错了也不会多拿到 + * 任何东西:想拨 `ops/w-106` 就得有一个**在 `ops` 桶里的 hostId 密钥**。⇒ 安全性不依赖这个字段, + * 而兼容性(旧客户端不声明 `network` ⇒ 按 `ops` 处理)正好是**存量全部落在运维网**的现网事实。 + * + * ## 稳定性(本轮重点) + * - **精确失败**:`HELLO` 必须被 `HELLO_ACK` 确认、`OPEN` 必须被 `OPEN_ACK` 确认;任何一步没回音都**断开并记数**(SSH 版的病根正是 `-R` 撞号无人检查返回值)。 + * - **不丢字节**:`OPEN` 到 `OPEN_ACK` 之间 Manager 侧数据被 TCP 层 `pause()` 挡住(`pauseOnConnect`),`OPEN_ACK` 后再 `resume()` ⇒ 没有"打开竞态窗口丢头几个字节"。 + * - **有界缓冲**:任一队列超 `queueMaxBytes` 就断**那一条流**,不影响同连接其它流,也不让内存无上限增长。 + * - **心跳**:client 每 `hbSec` 发 `PING`;服务端 45s 内没收到任何帧 ⇒ 判死链并回收该 host 全部流。 + * + * @module dshs/net/relay/server + */ + +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { createServer, type IncomingMessage, type Server as HttpServer } from 'node:http' +import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net' +import type { Duplex } from 'node:stream' +import { MUX, WsConnection, WS_CLOSE, acceptWebSocket, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js' +import { OPS_NETWORK, describeDialers, isNetworkId, logicalName, normalizeDialers } from './network.js' +import { lookupKey, type RelayKeyEntry } from './keys.js' +import { normalizePublicKey, verifyPeerGrant, verifyProof, type RevocationList } from './identity.js' + +/** WebSocket 升级路径 —— 与 nginx `location`(R2)逐字对应,改名要两边同改。 */ +export const RELAY_PATH = '/dshs-relay' +/** 默认回环端口(**只回环**,不是公网口)。 */ +export const DEFAULT_RELAY_PORT = 20080 +/** 默认握手超时:连接建立后多久没收到合法 `HELLO` 就断。 */ +export const DEFAULT_AUTH_DEADLINE_MS = 5_000 +/** `HELLO` 时间戳容忍窗口。 */ +export const DEFAULT_AUTH_WINDOW_MS = 60_000 +/** 死链判定:多久没收到任何帧。 */ +export const DEFAULT_IDLE_TIMEOUT_MS = 45_000 +/** 心跳下发间隔。 */ +export const DEFAULT_HB_SEC = 15 + +/** 满载(`at-capacity`)时给对端的**排队建议时长**(ms)。 */ +export const DEFAULT_CAPACITY_RETRY_AFTER_MS = 5_000 + +/** + * 优雅停机的**通知窗口**(ms):发出 `BYE` + `close 1001` 后等这么久,再强制收尾。 + * 这个值就是"停机耗时"的上界 —— 而停机耗时 = 对端的恢复时间。 + */ +export const DEFAULT_SHUTDOWN_GRACE_MS = 300 +/** 同一 (host, port) 的并发流上限。 */ +export const DEFAULT_MAX_STREAMS_PER_PORT = 64 +/** 单流缓冲上限(入向 open 竞态窗口 + 出向背压队列共用)。 */ +export const DEFAULT_QUEUE_MAX_BYTES = 1 << 20 + +/** 拨号流 peer 的出向水位(WS 缓冲到这个量就请上层转排队)。 */ +export const WS_PEER_HIGH_WATER = 256 * 1024 +/** 每 host 保留的 nonce 数(防重放的有界窗口)。 */ +const NONCE_KEEP = 256 + +export interface RelayServerOptions { + /** 监听地址。**默认且建议保持 `127.0.0.1`** —— 本机无防火墙(实测 `nft INPUT policy accept`),绑 `0.0.0.0` 会立刻公网可达。 */ + host?: string + port?: number + /** hostId → 预共享密钥(**hex**,每 worker 一个)。空 map ⇒ 所有 `HELLO` 被拒(默认拒绝)。 */ + keys: ReadonlyMap + /** 实例端口区间起点(worker 声明的端口必须 ≥ 它)。 */ + instancePortBase: number + /** 实例端口区间宽度。 */ + instancePortSpan: number + maxStreamsPerPort?: number + queueMaxBytes?: number + authDeadlineMs?: number + authWindowMs?: number + idleTimeoutMs?: number + hbSec?: number + /** + * 允许同时在线的主机数上限。`0`(默认)= 不限。 + * + * **满载是唯一的硬门**:满了就拒绝新节点加入(回 `at-capacity` + `retryAfterMs`), + * 对端据此**排队等待**。已在册的 hostId 重连**永远优先**(它占的位子本来就是它的)。 + */ + maxHosts?: number + /** 满载时建议对端多久后再来(ms)。默认 5000。 */ + capacityRetryAfterMs?: number + /** 优雅停机的通知窗口(ms),默认 300 —— 见 `DEFAULT_SHUTDOWN_GRACE_MS`。 */ + shutdownGraceMs?: number + /** + * **允许发起 `DIAL` 的白名单**(覆盖网络 R5 / P0-1),按**网络分桶**: + * `Map>`。没列到的网络 ⇒ 该网络**一个都拨不动**(默认拒绝)。 + * + * 也接受 R5 时代的**扁平 `Set`**(等价于"这些都在 `ops` 网里")⇒ 旧配置照旧可用, + * 过渡期不必改任何 drop-in(见 `network.ts#normalizeDialers`)。 + * + * 为什么用「服务端白名单 + 每主机独立密钥」而不是一个共享的拨号 token: + * - 密钥表本就是**每 worker 一个**(`keys.ts`:爆炸半径 = 那一台)⇒ worker A 无法冒充 + * `manager` 去认证;再叠一层白名单,**即使**某台 worker 的密钥泄露也**拨不动**别人的实例。 + * - 这条门是**收窄**而不是扩大:它只把「Manager 本来就能做的跨机代理」从"必须同机"变成 + * "可以异地",不新增任何主体、不新增任何监听口。 + * + * P0-1 加的**第二道门**不是白名单,而是"**同网**":不同网络的节点之间,连"能不能拨"这一步 + * 都走不到。 + * ⚠️ P0-3 起**对外不再有专属拒绝码**:跨网一律回 `target-offline`(与"本网无此节点"逐字同形), + * 区分只留在服务端日志里 —— 否则"它在别张网"这句话本身就是**可探测的对端清单**(D4)。 + */ + dialers?: ReadonlySet | ReadonlyMap> + /** + * **受信签名者公钥**(覆盖网络 序③)—— 用来验收 `HELLO` 里那条**节点入网凭据**的签名。 + * + * ⚠️ **relay 侧这道门是"辅助"**(D2):身份的真正判据在**节点自己**那里(`identity.ts` 的 + * `verifyPeerGrant`)。relay 也验一遍,是为了让"未授权节点"**在英国人就近被挡住**, + * 而不是等它把端点注册进来、再由 Manager 去拒 —— 但**不能**把它当成唯一防线: + * 控制面(这台 relay)被攻破时,唯一还站得住的正是节点本地的那一道。 + * + * 空 ⇒ **不启用身份校验**(存量形态:只认 HMAC)。是否**强制**由 `requireIdentity` 决定。 + */ + trustedSignerKeys?: readonly string[] + /** 有效的吊销清单(已验签)。给 ⇒ 被撤的 hostId / 节点公钥一律拒。 */ + revocations?: RevocationList + /** + * **强制**要求节点入网凭据(缺 / 无效 ⇒ 拒)。 + * + * 缺省 `false`:现网 47 / 106 上跑的是**还没有身份层**的客户端,一律强制会**当场全断**。 + * ⇒ 采用与 `dsh_hosts.network_id` 同一条纪律:「**先加能力 → 再改代码 → 最后才开强制**」, + * 每一步中断都不崩,且**任一步都能单独回滚**。 + */ + requireIdentity?: boolean + /** + * 是否**参与校验**(不必强制)节点凭据:`true`(默认)⇒ 客户端**带了**凭据就得验, + * 验不过照样拒("带了但无效"绝不放行);`false` ⇒ 完全忽略凭据字段(仅调试用)。 + */ + verifyIdentity?: boolean + /** + * 是否为注册端口在 relay **本机**开回环监听(默认 `true`,即 R1–R4 的行为)。 + * + * 置 `false` ⇒ relay 退化成**纯流转发**:不为任何端口绑本地口(本机暴露面 = 0,`/status` + * 也不再给出 `localPort`)⇒ 「relay 与 Manager 是否同机」**彻底无关**。 + * 这就是「会合可换机」的收口开关,也是它的端到端验收手段(`test/relay.test.mjs` T19)。 + */ + exposeLoopback?: boolean + log?: (line: string) => void +} + +/** + * relay 一侧的「流对端」。 + * + * 有两条完全不同的来路,但对数据面**必须同形**: + * ① **注册端口**(R1–R4):relay 主机上的一个 TCP socket(`net.Socket` 天然满足本接口); + * ② **拨号**(R5):另一条 mux 会话(`WsStreamPeer` 把帧当字节流用)。 + * + * 两者都只需要这 6 个方法 ⇒ 用结构化接口而不是 `Socket`,`onData` / `flushStream` / `closeStream` + * **一行都不用分叉**。 + */ +export interface StreamPeer { + write(chunk: Buffer): boolean + once(event: 'drain', cb: () => void): void + pause(): void + resume(): void + end(): void + destroy(): void +} + +/** 拨号流的回程信息(挂在 worker 侧那条 `MuxStream` 上)。 */ +interface DialInfo { + /** 拨号方会话 —— 回程 `DATA` / `CLOSE` 的收件人。 */ + session: Session + /** 拨号方那侧的 `streamId`(**与 worker 侧的 id 不是一个命名空间**,必须原样带回)。 */ + streamId: number + /** worker 尚未 `OPEN_ACK` ⇒ 拨号方的数据先缓存(等价于 TCP 版的 `pauseOnConnect`)。 */ + ready: boolean + pending: Buffer[] + pendingBytes: number +} + +interface MuxStream { + id: number + port: number + /** 见 `StreamPeer`。 */ + peer: StreamPeer + /** `OPEN` 已发但 `OPEN_ACK` 未回 —— 此刻入向数据只能缓存。 */ + opening: boolean + /** 对端写满,暂停向它写(出向背压)。 */ + paused: boolean + closed: boolean + queued: Buffer[] + queuedBytes: number + /** 仅拨号流有。 */ + dial?: DialInfo +} + +interface Session { + id: string + hostId: string + /** + * 本会话属于哪张网(P0-1)—— 注册时从 `HELLO.network` 取,**缺省 `ops`**(旧客户端兼容)。 + * 会话表按**逻辑名 `/`** 索引 ⇒ 两张网里的同名 hostId **互不干扰**。 + */ + network: string + conn: WsConnection + ports: Set + streams: Map + portStreams: Map + /** + * 仅**拨号方**会话有:`拨号方 streamId → (worker 会话, worker 侧那条流)`。 + * + * 拨号方发来的 `DATA` / `CLOSE` 带的是**它自己的** id,不查这张表就无处可去(worker 会话的 + * `streams` 里装的是 relay 分配的 id,两者不是一个命名空间)。 + */ + dialRoutes: Map + nextStreamId: number + lastSeen: number + heartbeat: NodeJS.Timeout + bytesIn: number + bytesOut: number + /** 注册时刻(诊断"这条会话活了多久")。 */ + since: number + /** 服务端**主动**发 `PING` 的时刻;`PONG` 回来时用它算 RTT。 */ + pingSentAt?: number + /** 最近一次心跳 RTT —— 链路质量的**实测**值,也是判断"是不是半开"的旁证。 */ + rttMs?: number +} + +interface Endpoint { + hostId: string + /** 见 `Session.network`:端点也带网维度(表键 = `/:`)。 */ + network: string + port: number + server?: TcpServer + localPort: number + session?: Session + /** + * `listen()` 落定(或失败)后 resolve —— 只有**运行期动态加端口**那条路要等它: + * `PORT_ADD` 的应答里要回真实口号,而 `listen` 是异步的(注册路径不等,与 R1 行为一致)。 + */ + ready?: Promise +} + +export interface RelayStatus { + listening: string + online: string[] + /** + * 允许发起 `DIAL` 的白名单(R5 / P0-1)。**空数组 = 该能力关闭**(默认)。 + * `ops` 网的条目按 R5 的写法**省略网络前缀**(`manager`);其它网写全逻辑名(`u:5/d1`)。 + * 逐网络视图见 `networks`。 + */ + dialers: string[] + /** + * **网维度视图**(P0-1):每张网各有哪些拨号方、哪些节点在线。 + * + * 为什么单列一个字段而不是只把 `network` 塞进 `sessions`:Step 3 的判据是 + * 「`u:A` 的节点**看不到**也到不了 `u:B` 的节点」—— "看不到"这一半必须**可断言**, + * 而按网络聚合一眼就能看出"这张网里到底有谁",不必让调用方自己去 group。 + */ + networks: { network: string; dialers: string[]; sessions: string[] }[] + /** 容量视图(`max = 0` ⇒ 不限)。`free` 仅在有限容量时有意义。 */ + capacity: { max: number; used: number; free?: number } + counters: { + authed: number + authFailed: number + refused: number + /** 序③:通过节点凭据校验的注册数。 */ + identityOk: number + /** 序③:是否**强制**要求节点凭据。 */ + identityRequired: boolean + /** 序③:受信签名者把数(`0` + `identityRequired` ⇒ 谁也进不来,配置不完整)。 */ + trustedSigners: number + /** 序③:当前吊销清单里的 hostId 数。 */ + revokedHosts: number + dropped: number + streamsOpened: number + protocolErrors: number + backpressurePauses: number + /** + * 序⑤(观测最小集):`DIAL` 的**判别器计数**。 + * + * 为什么需要它:443 单 §12 留下的教训原文是「**静默失效靠判别器定位**」,判别器就是 + * 「relay 到底有没有 `DIAL`」—— 今天它**只存在于日志行**(`DIAL manager -> w-106:21000 ok`), + * 脚本无法断言 ⇒ 观测最小集缺了最关键的一条。 + * + * 为什么不复用 `refused`:`refused` 是**所有**拒绝的合计(`HELLO` 越界、端口越界、`DIAL`…), + * 而判别器要回答的是更窄的问题 —— **拨号这条路本身通不通**。三分支互斥且可加和: + * - `dial` —— 拨号被**放行**(已回 `DIAL_ACK{ok:true}`,与 `streamsOpened` 同点自增); + * - `dialDenied` —— **策略拒绝**(不在拨号方白名单 / 服务停机中 / 每端口并发满); + * - `dialFailed` —— **目标不可达或请求非法**(节点离线或端口未声明 / `target`·`port` 非法)。 + * + * 读法:`dial > 0` ⇒ 拨号路径通;三者恒 `0` 而业务流量存在 ⇒ 请求**根本没走到 `DIAL`** + * (正是 `RemoteSpawner.translateEndpoint` 静默失效那类故障的指纹)。 + */ + dial: number + dialDenied: number + dialFailed: number + } + endpoints: { hostId: string; network: string; port: number; localPort: number; online: boolean; streams: number }[] + /** + * **结构化**会话视图(`online` 那串是给人读的,这里是给程序 / 前端 / 告警用的)。 + * `lastSeenAgoMs` 与 `rttMs` 是判断"对端是否半开"的第一手证据。 + */ + sessions: { + hostId: string + /** 该会话所属网(P0-1)。 */ + network: string + /** 逻辑名 `/` —— 跨网同名时用它区分,`hostId` 单看会歧义。 */ + name: string + sessionId: string + /** 已在线多久(ms)。 */ + upForMs: number + /** 距最后一次收到帧多久(ms)。 */ + lastSeenAgoMs: number + rttMs?: number + ports: number[] + streams: number + }[] +} + +export class RelayServer { + private readonly opts: RelayServerOptions + private readonly host: string + private readonly port: number + private readonly maxStreamsPerPort: number + private readonly queueMaxBytes: number + private readonly authDeadlineMs: number + private readonly authWindowMs: number + private readonly idleTimeoutMs: number + private readonly hbSec: number + private readonly maxHosts: number + private readonly capacityRetryAfterMs: number + private readonly shutdownGraceMs: number + private readonly base: number + private readonly span: number + + private http: HttpServer | undefined + private sweeper: NodeJS.Timeout | undefined + /** 正在优雅下线:此时**拒绝新注册**(回 `retryable=true`,对端会自行重连)。 */ + private draining = false + /** 归一化后的拨号方白名单(`network → hostId 集合`)。**构造时定型**,运行期不可改(安全判据)。 */ + private readonly dialers: ReadonlyMap> + /** 受信签名者(序③)。**构造时定型** —— 与白名单同一条纪律:安全判据不在运行期被改。 */ + private readonly trustedSignerKeys: readonly string[] + private readonly revocations: RevocationList | undefined + private readonly requireIdentity: boolean + private readonly verifyIdentity: boolean + /** 会话表:键 = **逻辑名** `/`(P0-1:两张网里的同名 hostId 互不干扰)。 */ + private readonly sessions = new Map() + /** 端点表:键 = `/:`。 */ + private readonly endpoints = new Map() + private readonly seenNonces = new Map>() + private authed = 0 + private authFailed = 0 + private refused = 0 + private dropped = 0 + private streamsOpened = 0 + private protocolErrors = 0 + /** 通过了**节点入网凭据**校验的注册数(序③ 的可观测判据:`0` 而 `authed > 0` ⇒ 身份层没生效)。 */ + private identityOk = 0 + private backpressurePauses = 0 + /** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(口径见 `RelayStatus.counters`)。 */ + private dial = 0 + private dialDenied = 0 + private dialFailed = 0 + + constructor(opts: RelayServerOptions) { + this.opts = opts + this.host = opts.host ?? '127.0.0.1' + this.port = opts.port ?? DEFAULT_RELAY_PORT + this.maxStreamsPerPort = opts.maxStreamsPerPort ?? DEFAULT_MAX_STREAMS_PER_PORT + this.queueMaxBytes = opts.queueMaxBytes ?? DEFAULT_QUEUE_MAX_BYTES + this.authDeadlineMs = opts.authDeadlineMs ?? DEFAULT_AUTH_DEADLINE_MS + this.authWindowMs = opts.authWindowMs ?? DEFAULT_AUTH_WINDOW_MS + this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS + this.hbSec = opts.hbSec ?? DEFAULT_HB_SEC + this.maxHosts = opts.maxHosts ?? 0 + this.capacityRetryAfterMs = opts.capacityRetryAfterMs ?? DEFAULT_CAPACITY_RETRY_AFTER_MS + this.shutdownGraceMs = opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS + this.base = opts.instancePortBase + this.span = opts.instancePortSpan + this.trustedSignerKeys = opts.trustedSignerKeys ?? [] + this.revocations = opts.revocations + this.requireIdentity = opts.requireIdentity ?? false + this.verifyIdentity = opts.verifyIdentity ?? true + // ⚠️ 「强制身份」但「一把受信签名者都没有」= 谁也进不来(**仍然失败关闭**,不放开)。 + // 这是有意的:那台 relay 的配置**不完整**,此时"少拒一点"比"全拒"危险得多 + // (它会把"身份层根本没生效"伪装成"一切正常")。启动日志会把它喊出来。 + // 白名单**在构造时**归一化(扁平 `Set` 也接受)⇒ 配置错(网络 id 非法)在这里就炸, + // 而不是变成运行期"谁也没匹配上"的静默拒绝。 + this.dialers = normalizeDialers(opts.dialers) + } + + private log(line: string): void { + ;(this.opts.log ?? ((s: string) => process.stdout.write(`${s}\n`)))(`[relay] ${line}`) + } + + async start(): Promise { + const http = createServer((req, res) => { + if (req.url === '/status') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end(JSON.stringify(this.status(), null, 2)) + return + } + res.writeHead(404, { 'content-type': 'text/plain' }) + res.end(`dshs relay: WebSocket upgrade only, at ${RELAY_PATH}\n`) + }) + http.headersTimeout = 10_000 + http.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head)) + this.http = http + this.draining = false + await new Promise((resolve, reject) => { + const onErr = (err: Error): void => reject(err) + http.once('error', onErr) + http.listen(this.port, this.host, () => { + http.off('error', onErr) + resolve() + }) + }) + this.log(`listening ws://${this.host}:${this.port}${RELAY_PATH} (loopback only) instance-ports=${this.base}..${this.base + this.span - 1}`) + this.sweeper = setInterval(() => this.sweep(), 5_000) + this.sweeper.unref() + } + + /** + * 停机 —— **优雅下线**:先给每条会话发 `BYE` + `close 1001`(going away)。 + * + * 为什么不能直接掐 TCP:掐掉之后对端只能靠**心跳超时**(默认 45s)才发现 ⇒ 「计划内重启」 + * 会被放大成 45 秒的服务中断。发 `1001` 后对端**不消耗退避**地立刻重连(实测见 §12 的 T8)。 + */ + async stop(): Promise { + this.draining = true + if (this.sweeper !== undefined) clearInterval(this.sweeper) + const sessions = [...this.sessions.values()] + // 第一步:**告知**,不是掐断 —— 发 `BYE` + `close 1001`,对端据此立刻开始快速重连。 + for (const session of sessions) { + if (!session.conn.isClosed) { + session.conn.sendBinary(encodeJsonFrame(MUX.BYE, 0, { reason: 'server restarting' })) + session.conn.close(WS_CLOSE.GOING_AWAY, 'server restarting') + } + } + // 第二步:给一个**可控**的小窗口让通知真的发出去(默认 300ms),然后**不再等**对端回 close 帧。 + // ⚠️ 这里是实测踩出来的:原来"边发边 drop",`http.close()` 要等 socket 收尾 ⇒ `stop()` 实测 + // 耗时 1.8s+,把对端的快速重连窗口整个耗光(§12 T8 复盘)。停机耗时 = 恢复时间,必须可控。 + if (sessions.length > 0) { + await new Promise((resolve) => { + const t = setTimeout(resolve, this.shutdownGraceMs) + t.unref() + }) + } + // 第三步:**强制**收尾 —— 通知已发出,剩下的不等了。 + for (const session of sessions) { + this.dropSession(session, 'server stopping (graceful)') + session.conn.destroy() + } + for (const ep of this.endpoints.values()) ep.server?.close() + this.endpoints.clear() + const http = this.http + this.http = undefined + if (http !== undefined) { + // 立刻掐掉残留连接(含空闲 keep-alive):`close()` 只等"已建立的请求"收尾, + // 空闲 keep-alive 连接会让它一直挂着 ⇒ **进程迟迟不退、端口不释放**。 + // 实测(R1.5 跨机):旧进程没退 ⇒ 新进程 `EADDRINUSE` ⇒ 客户端只能一直撞那个正在 + // `draining` 的旧实例,表现为"重启后再也连不上"。 + http.closeAllConnections() + await new Promise((resolve) => http.close(() => resolve())) + } + this.log('stopped (graceful: every session was told going-away)') + } + + /** 实际绑定端口(`port: 0` 时由内核分配)—— 测试与诊断用。 */ + get boundPort(): number { + const addr = this.http?.address() + return addr !== null && addr !== undefined && typeof addr === 'object' ? addr.port : this.port + } + + /** + * 某 host 是否**实时**在线(心跳驱动,非 DB 快照 —— 这正是 relay 相对现状的增益)。 + * + * `network` 缺省 `ops`:R5 之前的调用方(含既有单测)只说 hostId,语义不变; + * 一旦涉及第二张网就**必须显式给**(否则问的是"运维网里那个同名节点"—— 这是个正确的默认, + * 因为跨网同名才是歧义源)。 + */ + isOnline(hostId: string, network: string = OPS_NETWORK): boolean { + return this.sessions.has(logicalName(network, hostId)) + } + + /** 某 (host, port) 在 Manager 侧对应的回环端口;离线或未注册 ⇒ `undefined`(**不猜**)。 */ + localPortOf(hostId: string, port: number, network: string = OPS_NETWORK): number | undefined { + const ep = this.endpoints.get(endpointKey(network, hostId, port)) + if (ep === undefined || ep.session === undefined || ep.localPort === 0) return undefined + return ep.localPort + } + + status(): RelayStatus { + const now = Date.now() + const byNetwork = new Map() + for (const session of this.sessions.values()) { + const bucket = byNetwork.get(session.network) ?? [] + bucket.push(session.hostId) + byNetwork.set(session.network, bucket) + } + for (const network of this.dialers.keys()) if (!byNetwork.has(network)) byNetwork.set(network, []) + return { + listening: `${this.host}:${this.port}${RELAY_PATH}`, + dialers: describeDialers(this.dialers), + networks: [...byNetwork.keys()].sort().map((network) => ({ + network, + dialers: [...(this.dialers.get(network) ?? [])].sort(), + sessions: (byNetwork.get(network) ?? []).sort(), + })), + capacity: + this.maxHosts > 0 + ? { max: this.maxHosts, used: this.sessions.size, free: Math.max(0, this.maxHosts - this.sessions.size) } + : { max: 0, used: this.sessions.size }, + online: [...this.sessions.values()].map( + (s) => + `${s.network === OPS_NETWORK ? s.hostId : logicalName(s.network, s.hostId)}(session=${s.id} ports=${[...s.ports].sort((a, b) => a - b).join('/')} streams=${s.streams.size} hbAge=${now - s.lastSeen}ms in=${s.bytesIn}B out=${s.bytesOut}B)`, + ), + counters: { + authed: this.authed, + authFailed: this.authFailed, + refused: this.refused, + dropped: this.dropped, + streamsOpened: this.streamsOpened, + protocolErrors: this.protocolErrors, + backpressurePauses: this.backpressurePauses, + /** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(脚本据此断言"拨号这条路通不通")。 */ + dial: this.dial, + dialDenied: this.dialDenied, + dialFailed: this.dialFailed, + /** 序③:通过节点凭据校验的注册数 / 是否强制身份(`requireIdentity`)与受信签名者把数。 */ + identityOk: this.identityOk, + identityRequired: this.requireIdentity, + trustedSigners: this.trustedSignerKeys.length, + revokedHosts: this.revocations?.hosts.length ?? 0, + }, + endpoints: [...this.endpoints.values()].map((ep) => ({ + hostId: ep.hostId, + network: ep.network, + port: ep.port, + localPort: ep.localPort, + online: ep.session !== undefined, + streams: ep.session?.portStreams.get(ep.port) ?? 0, + })), + sessions: [...this.sessions.values()].map((s) => ({ + hostId: s.hostId, + network: s.network, + name: logicalName(s.network, s.hostId), + sessionId: s.id, + upForMs: now - s.since, + lastSeenAgoMs: now - s.lastSeen, + rttMs: s.rttMs, + ports: [...s.ports].sort((a, b) => a - b), + streams: s.streams.size, + })), + } + } + + /* ═══════════ 连接建立与认证 ═══════════ */ + + private onUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + const url = req.url ?? '' + // 前缀匹配是**故意**收紧的:`/status` 不在此前缀下 ⇒ R2 的 nginx location 无法把它代理出去。 + if (!url.startsWith(RELAY_PATH)) { + socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n') + socket.destroy() + return + } + const conn = acceptWebSocket(req, socket as Socket, head) + if (conn === null) return + + let session: Session | undefined + // 未认证超时:没有它,一个空闲 socket 就能白占服务端资源。 + const deadline = setTimeout(() => { + if (session === undefined && !conn.isClosed) { + this.authFailed += 1 + this.log(`AUTH TIMEOUT remote=${conn.remote}`) + conn.close(WS_CLOSE.POLICY_VIOLATION, 'auth required') + } + }, this.authDeadlineMs) + deadline.unref() + + conn.on('protocolError', (why: string) => { + this.protocolErrors += 1 + this.log(`protocol error remote=${conn.remote}: ${why}`) + }) + conn.on('error', (err: Error) => this.log(`socket error remote=${conn.remote}: ${err.message}`)) + conn.on('close', () => { + clearTimeout(deadline) + if (session !== undefined) this.dropSession(session, 'connection closed') + }) + conn.on('message', (buf: Buffer, isBinary: boolean) => { + if (session === undefined) { + if (!isBinary) { + conn.close(WS_CLOSE.UNSUPPORTED_DATA, 'binary only') + return + } + const frame = decodeMux(buf) + if (frame === null) { + conn.close(WS_CLOSE.PROTOCOL_ERROR, 'short frame') + return + } + if (frame.type !== MUX.HELLO) { + this.authFailed += 1 + this.log(`AUTH DENY remote=${conn.remote} why=hello expected, got type=${frame.type}`) + conn.close(WS_CLOSE.POLICY_VIOLATION, 'hello first') + return + } + const accepted = this.handleHello(conn, frame) + if (accepted !== undefined) { + clearTimeout(deadline) + session = accepted + } + return + } + this.onFrame(session, buf, isBinary) + }) + } + + /** 校验 `HELLO`;通过则建 session 并回 `HELLO_ACK`,否则关连接并返回 `undefined`。 */ + private handleHello(conn: WsConnection, frame: MuxFrame): Session | undefined { + const msg = parseJsonPayload(frame.payload) + /** + * 拒绝注册 —— **必须结构化**(`HELLO_ERR` + 关闭码)。 + * + * `retryable` 是给对端判断"要不要立刻重试"的: + * - `true`(时钟偏移 / 服务端正在下线)⇒ 对端**修正后立刻重连**就能好; + * - `false`(主机名 / 密钥 / 端口配错)⇒ 重试一万次也一样 ⇒ 对端退到上限重试并**明确报障**, + * 而不是刷日志("静默失败"与"刷屏噪音"是同一枚硬币的两面)。 + * `serverTime` 让对端能**自己算出时钟偏移并校正** —— 没有它,一个时钟漂移 > 窗口的节点 + * 会**永久无法重连**(这条曾是设计缺口,见传输方案 §12)。 + */ + const deny = (why: string, retryable = false, extra: Record = {}): undefined => { + this.authFailed += 1 + this.log(`AUTH DENY remote=${conn.remote} why=${why} retryable=${retryable}`) + if (!conn.isClosed) { + conn.sendBinary( + encodeJsonFrame(MUX.HELLO_ERR, 0, { reason: why, retryable, serverTime: Date.now(), windowMs: this.authWindowMs, ...extra }), + ) + } + // 关闭码本身就是给运维看的信号:`retryable` ⇒ 1013(过会儿再来),否则 1008(策略拒绝)。 + conn.close(retryable ? WS_CLOSE.TRY_AGAIN_LATER : WS_CLOSE.POLICY_VIOLATION, why) + return undefined + } + if (msg === null) return deny('malformed-hello') + if (this.draining) return deny('server-draining', true) + const thisNow = Date.now() // 同一次校验内**只取一次**时间,避免"刚过窗/刚进窗"的边界撕裂 + const hostId = typeof msg.hostId === 'string' ? msg.hostId : '' + /** + * P0-1:本节点属于**哪张网**。 + * + * 缺省 `ops`:现网 47 / 106 上跑的是**不带这个字段**的旧客户端,而它们本来就在运维网里 + * ⇒ 这条默认值正好等于事实(不改任何 drop-in 也不会走错网,见模块头的"为什么不进 MAC")。 + * 给了但**形状非法** ⇒ **失败关闭**(`bad-network`),不静默当 `ops` —— + * 静默回落会把"网配错了"伪装成"网络不通",那是最难查的一类。 + */ + const networkClaim = msg.network === undefined ? OPS_NETWORK : typeof msg.network === 'string' ? msg.network.trim() : '' + if (!isNetworkId(networkClaim)) return deny('bad-network') + const network = networkClaim + const ts = typeof msg.ts === 'number' ? msg.ts : 0 + const nonce = typeof msg.nonce === 'string' ? msg.nonce : '' + const portsCsv = typeof msg.portsCsv === 'string' ? msg.portsCsv : '' + const mac = typeof msg.mac === 'string' ? msg.mac.toLowerCase() : '' + /** + * **成员资格(序③)**:密钥表就是权威 —— 它同时给出"这条密钥属于哪个 hostId"与 + * "该 hostId 属于哪张网"。HELLO 里的 `network` 只是**待校验的声明**。 + * + * ⇒ 声明与登记不一致 ⇒ **失败关闭**(`network-mismatch`):⛔ 不回落 `ops`、⛔ 不放行。 + * 这一条补的正是「任何持有任意密钥的 host 都能进同一扁平命名空间」那个缺口 + * (`交接单_网抽象与地址规划R6 §待办②`)。 + */ + const entry = lookupKey(this.opts.keys, network, hostId) + if (hostId === '' || entry === undefined || entry.secret === '') return deny('unknown-host') + if (entry.network !== network) return deny('network-mismatch') + const secret = entry.secret + if (nonce.length < 16 || nonce.length > 64) return deny('bad-nonce') + if (mac.length !== 64) return deny('bad-mac-length') + const skew = thisNow - ts // 有符号:正是这个符号让对端知道该往前还是往后校正 + if (Math.abs(skew) > this.authWindowMs) return deny('clock-skew', true) + const nonceKey = logicalName(network, hostId) + const seen = this.seenNonces.get(nonceKey) + if (seen !== undefined && seen.has(nonce)) return deny('nonce-replay', true) + const want = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex') + if (!safeEqualHex(want, mac)) return deny('bad-mac') + /** + * ── 节点入网凭据(序③)──────────────────────────────────────────────────── + * + * 两道**独立**证明,都要过: + * ① `grant` + `grantSig` —— 这台机器**被授权进入这张网**(由受信签名者签发); + * ② `nodeSig` —— **握有**那把节点私钥(光出示凭据只是"有证书";凭据是公开可转发的, + * 谁抄到都能出示 ⇒ 没有第②条,"入网 = 签名"就退化成了"入网 = 抄一段 JSON")。 + * + * ⛔ **失败一律拒**,且原因**结构化**回给对端(对端据此明确报障,而不是无限重试)。 + * ⛔ 无关"兜底":`grant` 无效时**绝不**"那就只按 HMAC 放行"(那等于身份层形同虚设)。 + */ + const nodeKey = typeof msg.nodeKey === 'string' ? msg.nodeKey.trim() : '' + const identityRequested = this.requireIdentity || msg.grant !== undefined || nodeKey !== '' + if (identityRequested && !this.verifyIdentity) { + // `verifyIdentity=false` 只允许出现在"不强制"的调试场景;**强制**时它是配置矛盾 ⇒ 拒。 + if (this.requireIdentity) return deny('identity-verification-disabled') + } else if (identityRequested) { + /** + * **不可验 = 不接受**(与 `directory.ts` 同条判据):一把受信签名者都没有时, + * "带了凭据"这件事**无法判定** ⇒ 拒。⛔ 不因为"没配"就放行。 + */ + if (this.trustedSignerKeys.length === 0) return deny('identity-no-trusted-signers') + const normNodeKey = normalizePublicKey(nodeKey) + if (normNodeKey === undefined || msg.grant === undefined || typeof msg.grantSig !== 'string') { + return deny('identity-incomplete') + } + const verdict = verifyPeerGrant(msg.grant, msg.grantSig, { + trustedSignerKeys: this.trustedSignerKeys, + network, // 跨网签发 ⇒ network-mismatch + hostId, // 凭据被搬到别的 hostId ⇒ host-mismatch + nodeKey: normNodeKey, // 声明的节点公钥 ≠ 凭据里那把 ⇒ key-mismatch + revocations: this.revocations, + nowMs: thisNow, + }) + if (!verdict.ok) return deny(`identity-${verdict.reason}`) + if (!verifyProof(normNodeKey, `${hostId}|${ts}|${nonce}|${portsCsv}`, msg.nodeSig)) { + return deny('identity-bad-proof') + } + this.identityOk += 1 + } + /** + * **拨号方**(R5)与 **被连方**(worker)是两种身份,各自的门不一样: + * - worker **必须**声明端口(`no-ports` 照旧拒绝),它注册即开回环监听; + * - 拨号方**必须不**声明端口(它是来"开流"的,不是来"被连"的)—— 两条都收紧, + * 免得一个身份同时拿到两种能力(最小权限)。 + * 放行与否只看**服务端白名单在本网那一桶**(`dialers.get(network)`)—— 默认拒绝: + * 没列到的网络里,**一个 hostId 都拨不动**(P0-1 的"结构性隔离"就落在这里)。 + */ + const wantDialer = this.dialers.get(network)?.has(hostId) === true + if (portsCsv === '' && !wantDialer) return deny('no-ports') + if (portsCsv !== '' && wantDialer) return deny('dialer-must-not-declare-ports') + const ports: number[] = [] + // ⚠️ 必须显式分支:`''.split(',')` 是 `['']`,`Number('')` 为 `0` ⇒ 会被下面的 `bad-port` 拒掉, + // 于是"拨号方空端口表"这条合法路径**永远走不到**(T18/T19 首跑就是这么红的两条)。 + // 这里**不**用 `if (part === '') continue` 放宽:非拨号方的空/残缺端口表仍旧照原样拒绝。 + if (portsCsv !== '') { + for (const part of portsCsv.split(',')) { + const p = Number(part) + if (!Number.isInteger(p) || p <= 0 || p > 65535) return deny('bad-port') + if (p < this.base || p >= this.base + this.span) return deny('port-out-of-range') + if (ports.includes(p)) return deny('duplicate-port') + ports.push(p) + } + } + // nonce 窗口(有界,防内存被重放表撑爆) + const bucket = seen ?? new Set() + if (seen === undefined) this.seenNonces.set(nonceKey, bucket) + bucket.add(nonce) + while (bucket.size > NONCE_KEEP) { + const oldest = bucket.values().next().value + if (oldest === undefined) break + bucket.delete(oldest) + } + + const sessionKey = logicalName(network, hostId) + const old = this.sessions.get(sessionKey) + if (old !== undefined) { + this.log(`host ${sessionKey} re-registered ⇒ dropping old session ${old.id}`) + this.dropSession(old, 'superseded by new session') + } + // ── 容量准入(**满载是唯一的硬门**)──────────────────────────────────────── + // `maxHosts = 0` ⇒ 不限(默认,行为与 R1 一致)。满载时: + // ① **不驱逐**任何在线节点(后来者无权踢走先到者); + // ② **已在册**的 hostId 重连**永远优先**(它占的位子本来就是它的)⇒ 只拦"新面孔"; + // ③ 回 `at-capacity` + `retryAfterMs` ⇒ 对端**排队等待**,而不是放弃或死循环硬撞。 + if (this.maxHosts > 0 && !this.sessions.has(sessionKey) && this.sessions.size >= this.maxHosts) { + this.refused += 1 + return deny('at-capacity', true, { + retryAfterMs: this.capacityRetryAfterMs, + capacity: { max: this.maxHosts, used: this.sessions.size, free: 0 }, + }) + } + const sessionId = randomBytes(8).toString('hex') + const session: Session = { + id: sessionId, + hostId, + network, + conn, + ports: new Set(ports), + streams: new Map(), + portStreams: new Map(), + dialRoutes: new Map(), + nextStreamId: 1, + lastSeen: Date.now(), + since: Date.now(), + heartbeat: setInterval(() => undefined), + bytesIn: 0, + bytesOut: 0, + } + clearInterval(session.heartbeat) + // **双向**保活:服务端也主动发 `PING`(不只是等对端的)。 + // 三个作用:① 测 RTT(可观测)② 让"半开"在对端也能被察觉 ③ 出向始终有流量,NAT 表项不过期。 + session.heartbeat = setInterval(() => { + if (Date.now() - session.lastSeen > this.idleTimeoutMs) { + this.log(`session ${session.id} (${hostId}) idle ${this.idleTimeoutMs}ms ⇒ drop`) + this.dropSession(session, 'idle timeout') + return + } + session.pingSentAt = Date.now() + try { + session.conn.sendBinary(encodeMux(MUX.PING, 0)) + } catch { + /* 写失败 ⇒ 交给 idle 超时兜底 */ + } + }, Math.max(1_000, this.hbSec * 1_000)) + session.heartbeat.unref() + + this.sessions.set(sessionKey, session) + // 注册即开回环监听 ⇒ `resolve()` 变成纯查表(Manager 侧零改动的前提)。 + for (const p of ports) this.ensureEndpoint(network, hostId, p).session = session + this.authed += 1 + conn.sendBinary( + encodeJsonFrame(MUX.HELLO_ACK, 0, { + sessionId, + accepted: ports, + /** 本会话是不是"拨号方"(R5)—— 对端据此决定能不能用 `openStream()`。 */ + dialer: wantDialer, + /** + * **服务端认定的网**(P0-1)—— 回显给对端,让"我落在哪张网里"在**客户端日志**上可见。 + * 没有它,一个写错 `network` 的节点只能从"拨不通"倒推,而拨不通的原因有七八种。 + */ + network, + /** 本会话的逻辑名(`/`),与 `/status` 同一口径。 */ + name: sessionKey, + hbSec: this.hbSec, + // 让对端**立刻**得到时钟基准(不必等到被拒一次才知道自己漂了)。 + serverTime: Date.now(), + base: this.base, + span: this.span, + }), + ) + this.log(`AUTH OK host=${sessionKey} session=${sessionId} ports=[${ports.join(',')}] remote=${conn.remote}`) + return session + } + + /* ═══════════ 已认证连接上的帧 ═══════════ */ + + private onFrame(session: Session, buf: Buffer, isBinary: boolean): void { + session.lastSeen = Date.now() + if (!isBinary) { + this.protocolErrors += 1 + session.conn.close(WS_CLOSE.UNSUPPORTED_DATA, 'binary only') + return + } + const frame = decodeMux(buf) + if (frame === null) { + this.protocolErrors += 1 + session.conn.close(WS_CLOSE.PROTOCOL_ERROR, 'short frame') + return + } + switch (frame.type) { + case MUX.OPEN_ACK: + this.onOpenAck(session, frame) + return + case MUX.DATA: + this.onData(session, frame) + return + case MUX.CLOSE: { + // 拨号方的 `CLOSE` 带的是**它自己的** id ⇒ 先查拨号路由表,再查普通流表。 + const route = session.dialRoutes.get(frame.streamId) + if (route !== undefined) { + this.closeStream(route.session, route.st, 'dialer closed') + return + } + const st = session.streams.get(frame.streamId) + if (st !== undefined) this.closeStream(session, st, 'worker closed') + return + } + case MUX.PING: + session.conn.sendBinary(encodeMux(MUX.PONG, 0, frame.payload)) + return + case MUX.PONG: + // RTT 采样(服务端 PING → 对端 PONG 的往返)。 + if (session.pingSentAt !== undefined) { + session.rttMs = Date.now() - session.pingSentAt + session.pingSentAt = undefined + } + return + case MUX.BYE: { + // 对端**主动告别** ⇒ 立刻回收,**不等 45s 心跳超时**(把"计划内重启"的恢复时间压到毫秒级)。 + const msg = parseJsonPayload(frame.payload) + const reason = msg !== null && typeof msg.reason === 'string' ? msg.reason : 'peer bye' + this.log(`BYE from ${session.hostId} (${session.id}): ${reason}`) + this.dropSession(session, `peer bye: ${reason}`) + return + } + case MUX.PORT_ADD: + void this.onPortChange(session, frame, true) + return + case MUX.PORT_DEL: + void this.onPortChange(session, frame, false) + return + case MUX.DIAL: + this.onDial(session, frame) + return + default: { + this.protocolErrors += 1 + this.log(`unknown mux type=${frame.type} from ${session.hostId}`) + session.conn.close(WS_CLOSE.PROTOCOL_ERROR, 'unknown frame type') + } + } + } + + /** + * 运行期端口增删(覆盖网络 R4)—— 对应 ssh 版的 `-O forward` / `-O cancel`。 + * + * 校验口径**与 `HELLO` 逐条同源**(同一个 `[base, base+span)` 窗口 + 去重):两条路径若有 + * 分歧,"注册能过、动态加不能过"会成为极难查的不一致。窗口是**纵深防御**(真正的门是 worker + * 自己的白名单,见 `client.ts` 的 `onOpenRequest`):注意 relay 只把流量导回**worker 自己**的 + * 回环,不会把 relay 主机上的任意端口暴露出去。 + */ + private async onPortChange(session: Session, frame: MuxFrame, add: boolean): Promise { + const msg = parseJsonPayload(frame.payload) + const reqId = msg !== null && typeof msg.reqId === 'number' ? msg.reqId : 0 + const port = msg !== null && typeof msg.port === 'number' ? msg.port : -1 + const reply = (ok: boolean, extra: Record = {}): void => { + session.conn.sendBinary(encodeJsonFrame(MUX.PORT_ACK, 0, { reqId, port, ok, ...extra })) + } + if (!Number.isInteger(port) || port <= 0 || port > 65535) return reply(false, { error: 'bad-port' }) + if (port < this.base || port >= this.base + this.span) return reply(false, { error: 'port-out-of-range' }) + if (!add) { + const known = session.ports.delete(port) + this.closeEndpoint(session.network, session.hostId, port) + return reply(true, { removed: known }) + } + const existing = this.endpoints.get(endpointKey(session.network, session.hostId, port)) + if (session.ports.has(port) && existing !== undefined) { + // **幂等**:已经在册就直接回成功(调用方可能是"重连后补一次对账",报错反而会误导)。 + await existing.ready + return reply(true, { localPort: existing.localPort, existed: true }) + } + const ep = this.ensureEndpoint(session.network, session.hostId, port) + ep.session = session + session.ports.add(port) + await ep.ready + this.log(`host ${logicalName(session.network, session.hostId)} +port ${port} -> 127.0.0.1:${ep.localPort}`) + return reply(true, { localPort: ep.localPort }) + } + + /** + * 拨号方请求开一条到 `(target, port)` 的流(覆盖网络 R5)。 + * + * 与 `onManagerConn` 的差别**只有一处**:对端不是 relay 主机上的 socket,而是另一条会话。 + * 因此除了 `peer` 换成 `WsStreamPeer`、以及多存一块"`OPEN_ACK` 前的水坝"以外,其余逐条同源 + * (流号分配、每端口并发上限、`OPEN` 下发、`OPEN_ACK` 放行)—— 两条路**不能有第二套口径**, + * 否则"注册能过、拨号不能过"会变成最难查的那种不一致。 + * + * 为什么校验 `worker.ports.has(port)` 而不是只查 endpoint 表:端点表**随会话建立**, + * 而"端口已声明"才是 worker 侧白名单的镜像。两者都查 = 纵深防御;真正的门仍在 worker。 + */ + private onDial(session: Session, frame: MuxFrame): void { + const done = (ok: boolean, extra: Record = {}): void => { + if (!session.conn.isClosed) { + session.conn.sendBinary(encodeJsonFrame(MUX.DIAL_ACK, frame.streamId, { ok, ...extra })) + } + } + if (this.dialers.get(session.network)?.has(session.hostId) !== true) { + this.refused += 1 + this.dialDenied += 1 + this.log( + `DIAL refused: ${logicalName(session.network, session.hostId)} 不在网络 "${session.network}" 的拨号方白名单里`, + ) + done(false, { error: 'not-a-dialer' }) + return + } + if (this.draining) { + this.dialDenied += 1 + done(false, { error: 'server-draining', retryable: true }) + return + } + const msg = parseJsonPayload(frame.payload) + const target = msg !== null && typeof msg.target === 'string' ? msg.target : '' + const port = msg !== null && typeof msg.port === 'number' ? msg.port : -1 + if (target === '' || !Number.isInteger(port) || port <= 0 || port > 65535) { + this.dialFailed += 1 + done(false, { error: 'bad-target' }) + return + } + const dialerName = logicalName(session.network, session.hostId) + /** + * 目标**只在同一张网里找**(P0-1):`DIAL.target` 是 hostId、不含网络维度 ⇒ 这里显式拼上 + * **拨号方自己**的网络。⛔ 绝不"在别的网里找到一个同名 host 就连过去" —— + * 那正是「一张巨网 + 靠 ACL 兜」的形态,也是本单要消灭的东西。 + */ + const worker = this.sessions.get(logicalName(session.network, target)) + if (worker === undefined) { + /** + * 本网里没有 ⇒ 再查一遍别的网,**只为日志**。 + * + * ⛔ **对外与 `target-offline` 逐字同形**(P0-3 · D4「不下发对端清单」): + * 回一句 `dialer-not-in-network` + "它在 X 网"等于告诉拨号方「**这个 hostId 存在**、住在哪」 + * —— 那就是一份**可探测的对端清单**,判据「`u:A` 看不到 `u:B` 的节点」当场失效 + * (拿一串猜测的 hostId 打过来,按响应就能把别张网的成员枚举出来)。 + * ⇒ 区分只留**服务端日志**(下一行,含 `foreign`):运维照样分得清"配错网"与"节点离线", + * 而拨号方**无从区分**"不存在"与"在别张网"。 + */ + const foreign = this.findInOtherNetwork(session.network, target) + this.refused += 1 + this.dialFailed += 1 + if (foreign !== undefined) { + this.log( + `DIAL ${dialerName} -> ${target}:${port} 拒绝:该节点在别张网(${foreign})⇒ 跨网隔离 ` + + 'dialer-not-in-network(**仅日志用语**;对外统一回 target-offline)', + ) + done(false, { error: 'target-offline' }) + return + } + this.log(`DIAL ${dialerName} -> ${target}:${port} refused (worker offline / port not declared)`) + done(false, { error: 'target-offline' }) + return + } + if (worker.conn.isClosed || !worker.ports.has(port)) { + this.refused += 1 + this.dialFailed += 1 + this.log(`DIAL ${dialerName} -> ${target}:${port} refused (worker offline / port not declared)`) + done(false, { error: 'target-offline' }) + return + } + const live = worker.portStreams.get(port) ?? 0 + if (live >= this.maxStreamsPerPort) { + this.refused += 1 + this.dialDenied += 1 + done(false, { error: 'busy' }) + return + } + const id = worker.nextStreamId++ + if (worker.nextStreamId > 0xffffffff) worker.nextStreamId = 1 + const st: MuxStream = { + id, + port, + peer: new WsStreamPeer(session.conn, frame.streamId, WS_PEER_HIGH_WATER), + opening: true, + paused: false, + closed: false, + queued: [], + queuedBytes: 0, + dial: { session, streamId: frame.streamId, ready: false, pending: [], pendingBytes: 0 }, + } + worker.streams.set(id, st) + worker.portStreams.set(port, live + 1) + session.dialRoutes.set(frame.streamId, { session: worker, st }) + this.streamsOpened += 1 + // 序⑤ 判别器:**放行**点 —— 与 `streamsOpened` 同点自增,两条计数必须同步增长(口径:已回 ok:true)。 + this.dial += 1 + // 先回拨号方(它的写侧从此刻起可用),再让 worker 开流;两端各自的水坝保证不丢字节。 + done(true, { workerStreamId: id }) + worker.conn.sendBinary(encodeJsonFrame(MUX.OPEN, id, { port })) + this.log( + `DIAL ${dialerName} -> ${logicalName(session.network, target)}:${port} ok (workerStream=${id} dialStream=${frame.streamId})`, + ) + } + + /** + * 同名 hostId 是否存在于**别的网**;返回那张网的 id。 + * + * ⚠️ **只喂日志**(P0-3):调用方拿到非 `undefined` 后写一行"该节点在别张网(X)", + * 但**对外仍回 `target-offline`**。别把它接到响应体上 —— 那等于把"别张网里有哪些 hostId" + * 做成一个可枚举的接口。 + */ + private findInOtherNetwork(network: string, hostId: string): string | undefined { + for (const s of this.sessions.values()) { + if (s.network !== network && s.hostId === hostId) return s.network + } + return undefined + } + + /** + * 拨号方送来的数据 ⇒ 往 worker 走(**方向与 `onData` 相反**,所以必须是独立的一条路)。 + * + * worker 尚未 `OPEN_ACK` 时**只能缓存**:这一刻 worker 还没开始读那个口,直接发过去会让它 + * 按"data before open-ack"判协议错(`client.ts` 那条判据在 worker 侧同样生效)。 + */ + private onDialerData(session: Session, route: { session: Session; st: MuxStream }, frame: MuxFrame): void { + const st = route.st + const info = st.dial + if (info === undefined || st.closed) return + session.bytesIn += frame.payload.length + if (!info.ready) { + info.pending.push(frame.payload) + info.pendingBytes += frame.payload.length + if (info.pendingBytes > this.queueMaxBytes) { + this.dropped += 1 + this.log(`dial stream ${frame.streamId} 起始水坝 ${info.pendingBytes}B > ${this.queueMaxBytes}B ⇒ close`) + this.closeStream(route.session, st, 'dial pre-open queue overflow') + } + return + } + this.pumpToWorker(route.session, st, frame.payload) + } + + private onOpenAck(session: Session, frame: MuxFrame): void { + const st = session.streams.get(frame.streamId) + if (st === undefined || st.closed) return + const msg = parseJsonPayload(frame.payload) + if (msg === null || msg.ok !== true) { + const why = msg !== null && typeof msg.error === 'string' ? msg.error : 'worker refused' + this.dropped += 1 + this.log(`stream ${frame.streamId} (${session.hostId}:${st.port}) refused by worker: ${why}`) + this.closeStream(session, st, `worker refused: ${why}`) + return + } + st.opening = false + st.peer.resume() // 放开 Manager 侧读取(打开竞态窗口到此结束) + /** + * **水坝放水**(拨号流专属):拨号方在 worker `OPEN_ACK` 之前写下的字节此刻才允许进 worker。 + * 不这样做就会出现"头几个字节丢了"—— 与 TCP 版靠 `pauseOnConnect` 挡住的是同一件事。 + */ + const info = st.dial + if (info !== undefined) { + info.ready = true + const pending = info.pending + info.pending = [] + info.pendingBytes = 0 + for (const chunk of pending) this.pumpToWorker(session, st, chunk) + } + const queued = st.queued + st.queued = [] + st.queuedBytes = 0 + for (const chunk of queued) { + session.bytesIn += chunk.length + session.conn.sendBinary(encodeMux(MUX.DATA, st.id, chunk)) + } + } + + private onData(session: Session, frame: MuxFrame): void { + // 拨号方这条路:`frame.streamId` 属于**拨号方的**命名空间 ⇒ 必须先查路由表。 + const route = session.dialRoutes.get(frame.streamId) + if (route !== undefined) { + this.onDialerData(session, route, frame) + return + } + const st = session.streams.get(frame.streamId) + if (st === undefined || st.closed) return + if (st.opening) { + this.dropped += 1 + this.log(`stream ${frame.streamId} got DATA before OPEN_ACK ⇒ drop`) + this.closeStream(session, st, 'protocol: data before open-ack') + return + } + session.bytesOut += frame.payload.length + if (st.paused) { + st.queued.push(frame.payload) + st.queuedBytes += frame.payload.length + if (st.queuedBytes > this.queueMaxBytes) { + this.dropped += 1 + this.log(`stream ${frame.streamId} egress queue ${st.queuedBytes}B > ${this.queueMaxBytes}B ⇒ drop`) + this.closeStream(session, st, 'egress queue overflow') + } + return + } + if (!st.peer.write(frame.payload)) { + st.paused = true + this.backpressurePauses += 1 + st.peer.once('drain', () => this.flushStream(session, st)) + } + } + + private flushStream(session: Session, st: MuxStream): void { + if (st.closed) return + st.paused = false + while (st.queued.length > 0) { + const chunk = st.queued.shift() + if (chunk === undefined) break + st.queuedBytes -= chunk.length + if (!st.peer.write(chunk)) { + st.paused = true + st.peer.once('drain', () => this.flushStream(session, st)) + return + } + } + } + + /* ═══════════ endpoint(Manager 侧入口) ═══════════ */ + + private ensureEndpoint(network: string, hostId: string, port: number): Endpoint { + const key = endpointKey(network, hostId, port) + const found = this.endpoints.get(key) + if (found !== undefined) return found + const ep: Endpoint = { hostId, network, port, localPort: 0 } + if (this.opts.exposeLoopback === false) { + /* + * **纯流转发模式**(R5):不绑任何本地口。 + * 端点条目仍然登记(`online` / 计数照旧),但 `localPort` 恒为 `0` ⇒ 靠 `/status` + * 找落点的那条路会**自然失败**(失败关闭,不是静默透传);而拨号路径完全不受影响。 + */ + this.endpoints.set(key, ep) + return ep + } + ep.server = createTcpServer({ pauseOnConnect: true }, (tcp) => this.onManagerConn(ep, tcp)) + ep.ready = new Promise((resolve) => { + let settled = false + const done = (): void => { + if (settled) return + settled = true + resolve() + } + ep.server?.on('error', (err: Error) => { + this.log(`endpoint ${key} listen error: ${err.message}`) + // ⚠️ 失败也必须 resolve:否则 `PORT_ADD` 的应答永远不来 ⇒ 调用方挂死等一个不存在的 ACK。 + done() + }) + ep.server?.listen(0, '127.0.0.1', () => { + const addr = ep.server?.address() + if (addr !== null && addr !== undefined && typeof addr === 'object') ep.localPort = addr.port + this.log(`endpoint ${key} -> 127.0.0.1:${ep.localPort} (loopback)`) + done() + }) + }) + this.endpoints.set(key, ep) + return ep + } + + /** + * 收掉一个 endpoint(**worker 主动撤销端口**)。 + * + * 为什么必须真的把条目从 `endpoints` 里删掉、而不只是解绑 session:`/status` 是 Manager + * 唯一的地址来源 ⇒ 留一个"已关闭但还带着旧口号"的条目,Manager 会照旧拨它 ⇒ 打到死口上。 + * **查不到 ⇒ 翻译不出来 ⇒ 上层明确失败**,这才是能查的病("静默拨到死地址"是最难查的)。 + */ + private closeEndpoint(network: string, hostId: string, port: number): void { + const key = endpointKey(network, hostId, port) + const ep = this.endpoints.get(key) + if (ep === undefined) return + this.endpoints.delete(key) + // 在途流不在这里掐:它们各自的 Manager 侧 TCP 收尾会走 `forgetStream`。 + try { + ep.server?.close() + } catch { + /* 已关 */ + } + ep.session?.portStreams.delete(port) + ep.session = undefined + this.log(`endpoint ${key} closed (worker 撤销该端口)`) + } + + private onManagerConn(ep: Endpoint, tcp: Socket): void { + tcp.setNoDelay(true) + const session = ep.session + if (session === undefined || session.conn.isClosed) { + this.refused += 1 + this.log(`refuse ${ep.hostId}:${ep.port} (worker offline)`) + tcp.destroy() + return + } + const live = session.portStreams.get(ep.port) ?? 0 + if (live >= this.maxStreamsPerPort) { + this.refused += 1 + this.log(`refuse ${ep.hostId}:${ep.port} (busy, ${live} streams)`) + tcp.destroy() + return + } + const id = session.nextStreamId++ + if (session.nextStreamId > 0xffffffff) session.nextStreamId = 1 + const st: MuxStream = { id, port: ep.port, peer: tcp, opening: true, paused: false, closed: false, queued: [], queuedBytes: 0 } + session.streams.set(id, st) + session.portStreams.set(ep.port, live + 1) + this.streamsOpened += 1 + + // `pauseOnConnect` 让 TCP 层替我们挡住 OPEN_ACK 之前的数据 ⇒ 不存在"丢头几个字节"的竞态。 + tcp.on('data', (chunk: Buffer) => this.pumpToWorker(session, st, chunk)) + tcp.on('end', () => this.closeStream(session, st, 'manager eof')) + tcp.on('error', (err: Error) => { + this.log(`stream ${id} tcp error: ${err.message}`) + this.closeStream(session, st, 'manager tcp error') + }) + tcp.on('close', () => this.forgetStream(session, st)) + + session.conn.sendBinary(encodeJsonFrame(MUX.OPEN, id, { port: ep.port })) + } + + private pumpToWorker(session: Session, st: MuxStream, chunk: Buffer): void { + if (st.closed) return + if (st.opening) { + st.queued.push(chunk) + st.queuedBytes += chunk.length + if (st.queuedBytes > this.queueMaxBytes) { + this.dropped += 1 + this.log(`stream ${st.id} open-queue ${st.queuedBytes}B > ${this.queueMaxBytes}B ⇒ drop`) + this.closeStream(session, st, 'open queue overflow') + return + } + st.peer.pause() + return + } + session.bytesIn += chunk.length + if (!session.conn.sendBinary(encodeMux(MUX.DATA, st.id, chunk))) { + // 连接级背压:一条 wss 承载全部流,暂停所有入向源,`drain` 后恢复。 + // 粗粒度但**有界**——比"无界排队"安全,也比"只暂停一条流"正确。 + this.backpressurePauses += 1 + for (const s of session.streams.values()) s.peer.pause() + session.conn.once('drain', () => { + for (const s of session.streams.values()) if (!s.closed) s.peer.resume() + }) + } + } + + private closeStream(session: Session, st: MuxStream, why: string): void { + if (st.closed) return + st.closed = true + session.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, st.id, { reason: why })) + const left = (session.portStreams.get(st.port) ?? 1) - 1 + if (left <= 0) session.portStreams.delete(st.port) + else session.portStreams.set(st.port, left) + st.peer.end() + const killer = setTimeout(() => st.peer.destroy(), 2_000) + killer.unref() + // 拨号流:把拨号方路由表里的条目一并删掉(否则它会留一条指向已死流的条目)。 + const info = st.dial + if (info !== undefined) info.session.dialRoutes.delete(info.streamId) + this.log(`stream ${st.id} (${session.hostId}:${st.port}) closed: ${why}`) + } + + private forgetStream(session: Session, st: MuxStream): void { + if (st.closed) { + // `closeStream` 已维护计数;这里只删条目(幂等)。 + session.streams.delete(st.id) + return + } + st.closed = true + const left = (session.portStreams.get(st.port) ?? 1) - 1 + if (left <= 0) session.portStreams.delete(st.port) + else session.portStreams.set(st.port, left) + session.streams.delete(st.id) + const info = st.dial + if (info !== undefined) info.session.dialRoutes.delete(info.streamId) + session.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, st.id, { reason: 'manager tcp closed' })) + } + + private dropSession(session: Session, why: string): void { + clearInterval(session.heartbeat) + // ⚠️ 只有"当前 session 还是我"时才解绑 —— 否则新 session 刚注册就被旧 session 的收尾删掉。 + const key = logicalName(session.network, session.hostId) + if (this.sessions.get(key) === session) this.sessions.delete(key) + for (const st of session.streams.values()) { + st.closed = true + st.peer.destroy() + // 这条流的对端若是「拨号方」,把它的路由条目一并清掉(否则拨号方留一条指向死流的记录)。 + if (st.dial !== undefined) st.dial.session.dialRoutes.delete(st.dial.streamId) + } + session.streams.clear() + session.portStreams.clear() + // 本会话若是**拨号方**:它在别人的会话里占着流 ⇒ 一并收掉,否则 worker 侧留悬挂流。 + for (const route of [...session.dialRoutes.values()]) { + this.closeStream(route.session, route.st, 'dialer session dropped') + } + session.dialRoutes.clear() + for (const ep of this.endpoints.values()) { + if (ep.session === session) ep.session = undefined + } + if (!session.conn.isClosed) session.conn.close(WS_CLOSE.NORMAL, why) + this.log(`session ${session.id} (${key}) dropped: ${why}`) + } + + /** 兜底巡检:endpoint 指向的 session 已不在册(只可能走异常路径)⇒ 标离线,避免 Manager 打到死连接。 */ + private sweep(): void { + for (const ep of this.endpoints.values()) { + if (ep.session !== undefined && this.sessions.get(logicalName(ep.network, ep.hostId)) !== ep.session) { + ep.session = undefined + this.log(`endpoint ${endpointKey(ep.network, ep.hostId, ep.port)} marked offline (stale session)`) + } + } + } +} + +/** + * 端点表键 = `/:`。 + * + * `port` 用 `:` 分隔(与 `hostId` 的字符集不冲突),网络维度由 `/` 承担 —— 这样"跨网同名 host" + * 与"同网不同端口"两种情形在键里都天然可分。 + */ +function endpointKey(network: string, hostId: string, port: number): string { + return `${logicalName(network, hostId)}:${port}` +} + +/** 未配置拨号白名单 ⇒ `normalizeDialers` 返回**空 map** ⇒ 任何网络的 `DIAL` 一律被拒(默认拒绝)。 */ + +/** + * 拨号流的 `peer` —— 把「一条 mux 会话 + 一个流号」当字节流用(覆盖网络 R5)。 + * + * ## 背压为什么看 `bufferedAmount` 而不是 `write()` 的返回值 + * WS 的 `send()` 只是入队,**永远"成功"** ⇒ 拿返回值当背压判据等于没有背压(这正是 ssh 版 + * "静默失败"的同族病)。真正的判据是 socket 出向水位,与 `RelayClient.overWater()` 同一口径。 + * + * ## `pause()` / `resume()` 为什么是空实现 + * WS 没有"暂停对端"这种能力。relay 不需要它:`write()` 返回 `false` ⇒ 上层的 + * `paused` / `queued` 机制接手(数据进 relay 自己的队列),对端慢**不会**把 relay 撑爆。 + */ +class WsStreamPeer implements StreamPeer { + constructor( + private readonly conn: WsConnection, + private readonly streamId: number, + private readonly highWater: number, + ) {} + + write(chunk: Buffer): boolean { + if (this.conn.isClosed) return false + this.conn.sendBinary(encodeMux(MUX.DATA, this.streamId, chunk)) + return this.conn.bufferedAmount < this.highWater + } + + once(_event: 'drain', cb: () => void): void { + this.conn.once('drain', cb) + } + + pause(): void { + /* WS 无法暂停对端;背压由 relay 自己的队列兜住(见类注释)。 */ + } + + resume(): void { + /* 同上。 */ + } + + /** 关流:**必须**告诉拨号方(它那边的 `openStream()` 在等这个语义,缺了就是"静默断开")。 */ + end(): void { + if (!this.conn.isClosed) this.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, this.streamId, { reason: 'relay closed' })) + } + + destroy(): void { + this.end() + } +} + +/** 定长 hex 常量时间比较(长度不等直接 false,不泄漏长度以外的信息)。 */ +function safeEqualHex(a: string, b: string): boolean { + if (a.length !== b.length) return false + return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')) +} diff --git a/src/net/relay/switcher.ts b/src/net/relay/switcher.ts new file mode 100644 index 0000000..ea74775 --- /dev/null +++ b/src/net/relay/switcher.ts @@ -0,0 +1,519 @@ +/** + * 中继失败切流 · **唯一一份实现**(覆盖网络 · 序⑦) + * + * ## 为什么只能是"唯一一份" + * 本线已经吃过两次**同类**教训:`translateEndpoint` 漏赋值、`target()` 静默回退 + * —— 都是"同一件事在多处各写一遍,其中一处悄悄漏了"。所以切换逻辑**只写在这里**, + * 三个装配点(Manager 拨号通道 / worker 实例面 / 独立 `relay --client`)复用同一个类。 + * + * ## 它解决的是"候选集退化成单点" + * 改造前 `RelayClient` 退避**永不放弃**地重试**同一个 url**,而取址层 `pickFromDoc` + * **取到第一个候选就 `return`** ⇒ 重解析一百次拿回来的还是同一个字符串。 + * ⇒ 本模块负责"**发现当前这条不健康 ⇒ 换到链里的下一条**"。 + * + * ## 判据全部复用现成的(D2:⛔ 不新造心跳 / 不新增探测帧) + * - 不健康 = `state === 'backoff'` 且(`attempts ≥ MIN_ATTEMPTS` ∨ `unhealthyForMs ≥ GRACE`); + * - 候选链 = {@link listOverlayRelayCandidates}(**同一份引导链**,⛔ 不另写取址); + * - "先建新、成功再关旧" = 由调用方的 `open()` 保证(见 {@link RelayFailoverDeps.open})。 + * + * ## 硬纪律(每条都有对应验收) + * - **D4**:新通道**建不起来 ⇒ 保持原通道**(把"本来能用"的通路打掉才是真事故); + * - **D5**:被换掉的 url 进**冷却表**,冷却期内不回跳(否则两台互相抢 = 抖动风暴); + * - **D6**:链里除已排除项外**没有候选 ⇒ 不切换**(原地退避);⛔ 绝不切到空、**绝不静默回退默认机**; + * - **D7**:**每次切换**输出一行 `[relay-switch] #N …` + 累加计数 `switches` + * ⇒ `grep -c '^\[relay-switch\]'` 与 `switches` **必然相等**(判别器可断言)。 + * ⚠️ 所以"**没切**"的行必须用**另一个前缀** `[relay-skip]` —— 否则计数对不上(E9 会红)。 + * + * ## 序⑧(2026-09-17)新增:**冷却语义的不对称拆分**(解决"候选池被自己耗干") + * - **D1**:**「当前通道已不可用」有权打破自己刚设下的冷却;「目录说该换回首位」没有**。 + * ⇒ `replace()` 新增 `origin: 'health' | 'directory'`,闸门**只对 directory 收口**。 + * - **D2**:冷却键**仍是 url**;新增 `kind`(`switched-away` / `open-failed`)**只**决定豁免优先级。 + * ⛔ 原因做键 ⇒ 同一 url 会同时存在多条 ⇒ 从头抖回来 = 净退化。 + * - **D4**:豁免**有界** —— 每 url **每冷却周期一次**(`exemptedAtMs`);豁免后 `open()` 再失败 + * ⇒ 重置该 url 冷却**且本周期不再豁免**(⛔ 否则每 2 s 试一次 = 重试风暴)。 + * - **D5**:豁免优先级 = `switched-away` 优先,同类按 `untilMs` 升序。 + * - **D3 护栏**:豁免**只在 D6 现场**启用;**有干净候选时行为逐字不变**(单测 F15 锁住)。 + * - **D6 开关**:`RELAY_FAILOVER_EXEMPT`(默认 `1`;置 `0` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。 + */ + +/** 阈值(全部来自参数表 / env;脚本与实现**零数字字面量**)。 */ +export interface RelayFailoverThresholds { + /** 连续失败次数达到这个数即视为不健康。 */ + minAttempts: number + /** 或:持续不健康时间达到这个数(ms)即视为不健康。 */ + graceMs: number + /** 被换掉的 url 的冷却时长(ms);冷却期内不参与候选。 */ + cooldownMs: number + /** 从"判定不健康"到"切换完成"的允许上限(ms)——**验收判据**,实现只用它做日志。 */ + deadlineMs: number + /** 巡检周期(ms)。 */ + checkMs: number + /** + * **换址时"新通道算不算建起来了"的等待上限(ms)**。 + * + * ⚠️ 只作用于**换址**,⛔ **不作用于启动** —— 启动那条通道必须"不依赖网络"就走完 + * (P0-2 的设计前提:控制面自己也是客户端,启动那一刻自己的门户还没 listen)。 + * 不给这个上限,`open()` 就会把"口池绑好了"当成"通了" ⇒ 会切到一条**同样连不上**的中继上。 + */ + upTimeoutMs: number + /** + * **序⑧ 新增:一跳豁免总开关**(`RELAY_FAILOVER_EXEMPT`,默认开)。 + * + * 语义 = "**当前这条已经挂了**"有权打破自己刚设下的冷却(见 {@link RelayFailoverSupervisor.pickExemptTarget})。 + * 置 `0` ⇒ 逐字回到序⑦ 行为(D6 现场只写 `[relay-skip]`、不切换)⇒ **第二层回滚**。 + * ⚠️ 与 `minAttempts <= 0`(把整个监管器关掉)**不是同一层级**,⛔ 不许合并(D6 口径)。 + */ + exempt: boolean +} + +/** + * 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值并在日志里说清)。 + * + * 键名与本线参数表 `参数表_覆盖网络_20260917.md` 的 `RELAY_FAILOVER_*` 一一对应。 + */ +export function relayFailoverThresholds( + env: Record = process.env, +): RelayFailoverThresholds { + const num = (key: string, dflt: number): number => { + const raw = (env[key] ?? '').trim() + if (raw === '') return dflt + return /^\d+$/.test(raw) ? Number(raw) : dflt + } + return { + minAttempts: num('RELAY_FAILOVER_MIN_ATTEMPTS', 3), + graceMs: num('RELAY_FAILOVER_GRACE_MS', 15_000), + cooldownMs: num('RELAY_FAILOVER_COOLDOWN_MS', 300_000), + deadlineMs: num('RELAY_FAILOVER_DEADLINE_MS', 30_000), + checkMs: num('RELAY_FAILOVER_CHECK_MS', 2_000), + upTimeoutMs: num('RELAY_FAILOVER_UP_TIMEOUT_MS', 12_000), + /** + * 值格仍走 `num()`(`/^\d+$/`)⇒ ⛔ 参数表里的值格**必须纯数字**(写 `1` / `0`, + * ⛔ 不许写成 `true` 或带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**)。 + */ + exempt: num('RELAY_FAILOVER_EXEMPT', 1) !== 0, + } +} + +/** 一条 relay 通道(把"通道"和它的健康快照绑在一起,避免调用方各记一份)。 */ +export interface RelayChannelHandle { + /** 这条通道连的地址(`ws://` / `wss://`)。 */ + readonly url: string + /** 该通道自己的健康快照(实现里通常就是 `RelayClient.status()` 的投影)。 */ + health(): { state: string; attempts: number; unhealthyForMs: number } + /** 关掉这条通道。**幂等**、不抛。 */ + close(): void +} + +export interface RelayFailoverDeps { + /** + * 建一条新通道,**成功才返回句柄**(失败返回 `undefined`)。 + * + * ⛔ 顺序由本函数负责:**先把新通道建起来并确认可用,本模块之后才去关旧的**。 + * 反着做(先关后建)会在切换失败时把"本来能用"的通路打掉 ⇒ 违反 R11。 + */ + open: (url: string) => Promise + /** **候选链**(有序)。生产上就是 `listOverlayRelayCandidates()` 的投影。 */ + candidates: () => Promise + log: (line: string) => void + thresholds?: Partial + /** 注入点(单测用);默认 `Date.now`。 */ + nowMs?: () => number + /** 注入点(单测用);默认 `setTimeout` 自链。 */ + setTimerImpl?: (fn: () => void, ms: number) => unknown + clearTimerImpl?: (handle: unknown) => void +} + +/** + * **冷却原因**(序⑧ / D2):⛔ **不作冷却表的键**,只决定**豁免优先级**(D5)。 + * + * - `switched-away` —— "**我们主动离开了它**",它**曾可用**({@link RelayFailoverSupervisor.replace} 换址成功那条)⇒ 优先豁免。 + * - `open-failed` —— "**刚证明它建不起来**"(`open()` 失败那条)⇒ 次选。 + * + * 🔴 **为什么原因不能做键**(本线已有同类教训:`translateEndpoint` 漏赋值):同一 url 会**先后**因不同 + * 原因进冷却 ⇒ 原因做键会让同一 url 同时存在多条条目 ⇒ 它会通过"另一条原因键"被再次尝试 ⇒ + * 抖动抑制失效 = **净退化**。 + */ +export type RelayCooldownKind = 'switched-away' | 'open-failed' + +/** 冷却表条目(序⑧ / D2 + D4)。 */ +export interface RelayCooldownEntry { + /** 解除时刻(epoch ms)。 */ + untilMs: number + /** **本冷却周期的起点**(epoch ms)—— 判定"本周期的豁免是否已用掉"的锚点(D4)。 */ + sinceMs: number + /** 进冷却的原因;只喂给豁免优先级(D5)。 */ + kind: RelayCooldownKind + /** + * **本周期内上一次用掉豁免**的时刻(D4 的有界性)。 + * 判据 = `exemptedAtMs >= sinceMs` ⇒ 本周期不再豁免它(否则每 2 s 巡检都试一次 = **重试风暴**)。 + */ + exemptedAtMs?: number +} + +/** 切换统计(D7:**可读计数**,与日志行数必须一致)。 */ +export interface RelayFailoverStats { + /** 成功切换次数(= `[relay-switch]` 日志行数)。 */ + switches: number + /** 巡检次数。 */ + checks: number + /** "判定不健康但无候选可切"的次数(D6 的现场证据)。 */ + noCandidateChecks: number + /** "新通道建不起来 ⇒ 保持原通道"的次数(D4 的现场证据)。 */ + openFailed: number + /** **序⑧ 新增**:走"一跳豁免"完成的切换次数(⊆ `switches`;这些行都带 `|豁免`)。 */ + exemptSwitches: number + /** 最近一次成功切换的时刻(epoch ms)。 */ + lastSwitchAtMs?: number + /** 冷却表中的地址与解除时刻(⚠️ 保持 `{url, untilMs}` 外形;`kind` 为序⑧ 追加的只读字段)。 */ + cooldown: { url: string; untilMs: number; kind: RelayCooldownKind }[] +} + +export class RelayFailoverSupervisor { + private readonly deps: RelayFailoverDeps + private readonly th: RelayFailoverThresholds + private readonly log: (line: string) => void + private readonly now: () => number + private readonly setTimer: (fn: () => void, ms: number) => unknown + private readonly clearTimer: (handle: unknown) => void + + private current: RelayChannelHandle | undefined + /** 冷却表:**键 = url**(单一事实:冷却期内该地址不可用);值是 {@link RelayCooldownEntry}(序⑧ 结构化)。 */ + private readonly cooling = new Map() + private switches = 0 + private checks = 0 + private noCandidateChecks = 0 + private openFailed = 0 + private exemptSwitches = 0 + private lastSwitchAtMs: number | undefined + private lastSkipLogAtMs: number | undefined + private timer: unknown + private running = false + private ticking = false + + constructor(deps: RelayFailoverDeps) { + this.deps = deps + this.log = deps.log + this.now = deps.nowMs ?? ((): number => Date.now()) + this.setTimer = + deps.setTimerImpl ?? + ((fn, ms): unknown => { + /** + * **默认 unref**:巡检是**后台**活动,⛔ 不许因为它把进程钉在事件循环上 + * (`relay --client` / worker 这类进程本来就可能随时退出;一个 pending timer + * 会让"该退出的进程退不掉"—— 那是净退化)。 + */ + const h = setTimeout(fn, ms) + ;(h as { unref?: () => void }).unref?.() + return h + }) + this.clearTimer = deps.clearTimerImpl ?? ((h): void => clearTimeout(h as ReturnType)) + this.th = { ...relayFailoverThresholds({}), ...(deps.thresholds ?? {}) } + } + + /** 当前通道(启动时由装配点灌入第一条)。 */ + get channel(): RelayChannelHandle | undefined { + return this.current + } + + get thresholds(): RelayFailoverThresholds { + return { ...this.th } + } + + /** 灌入起始通道(启动那条,**不计数、不写 `[relay-switch]`** —— 它不是"切换")。 */ + seed(handle: RelayChannelHandle | undefined): void { + this.current = handle + } + + stats(): RelayFailoverStats { + const now = this.now() + return { + switches: this.switches, + checks: this.checks, + noCandidateChecks: this.noCandidateChecks, + openFailed: this.openFailed, + exemptSwitches: this.exemptSwitches, + lastSwitchAtMs: this.lastSwitchAtMs, + cooldown: [...this.cooling.entries()] + .filter(([, e]) => e.untilMs > now) + .map(([url, e]) => ({ url, untilMs: e.untilMs, kind: e.kind })) + .sort((a, b) => a.untilMs - b.untilMs), + } + } + + /** 是否满足"不健康"判据(**口径**:只有 `backoff` 累计才算;`handshaking` 不算)。 */ + private unhealthy(h: { state: string; attempts: number; unhealthyForMs: number }): boolean { + /** + * 🔑 **`RELAY_FAILOVER_MIN_ATTEMPTS=0` = 总开关关闭**(§7 回滚第 1 层)。 + * 语义必须这样定:置 0 后监管器**永不触发** ⇒ 不改代码就能回到"原地退避重试"的现状。 + * ⛔ 不能理解成"`attempts >= 0` 恒真"—— 那会变成"一进 backoff 就切",是**反向**效果。 + */ + if (this.th.minAttempts <= 0) return false + if (h.state !== 'backoff') return false + return h.attempts >= this.th.minAttempts || h.unhealthyForMs >= this.th.graceMs + } + + /** + * **唯一的"换址"动作**(三处触发路径共用:健康巡检 / 健康巡检的**一跳豁免** / 目录地址变了)。 + * + * 成功 → 关旧、记冷却、`switches += 1`、写一行 `[relay-switch]`;失败 → 原通道**原样保留**。 + * + * ## 序⑧:`origin` = 「这条换址**有没有**打破冷却的权力」(D1) + * - `'directory'` ⇒ ⛔ **没有**。它的触发条件("目录里的地址变了")与"旧通道是否可用"**无关**; + * 给它豁免权 ⇒ "当前站在 106、目录首位是 47"的每一轮巡检都想把刚冷却的 47 换回来 ⇒ + * **两位互相抢 = D5 想防的那个抖动风暴**(真机 11:43:26 已实测踩到)。 + * - `'health'` ⇒ 有。`tick()` 稳态只用它传**已过滤掉冷却**的候选;唯一会传"冷却中目标"的场合是 + * **D6 现场的一跳豁免**(见 {@link tick})—— ⚠️ 豁免**挑谁**由 `tick()` 单点判定(D4/D5), + * 本函数只做一件与豁免有关的事:**认领**它(写 `exemptedAtMs`)+ 按 D4 处理"豁免也失败"。 + */ + async replace( + targetUrl: string, + reason: string, + origin: 'health' | 'directory', + ): Promise { + const old = this.current + if (old !== undefined && old.url === targetUrl) { + this.log(`[relay-skip] 目标地址与当前通道相同(${targetUrl})⇒ 不动`) + return false + } + /** + * 🔴 **冷却闸门放在这里(不是只放在 `tick()` 里)** —— 真机实测逼出来的修正: + * + * 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与 + * **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。 + * 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前 + * **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来 + * ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。 + * ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。 + * + * 序⑧(D1):闸门**只对 `'directory'` 收口** —— `'health'` 传进来的"冷却中目标"就是一跳豁免本身。 + */ + const now = this.now() + const entry = this.cooling.get(targetUrl) + const inCooling = entry !== undefined && entry.untilMs > now + if (inCooling && origin === 'directory') { + this.log( + `[relay-skip] 目标 ${targetUrl} 仍在冷却(剩 ${entry.untilMs - now}ms / 共 ${this.th.cooldownMs}ms)` + + `⇒ 不换(D5 防抖动;目录路径**无豁免权** D1);原因本为:${reason}`, + ) + return false + } + const opened = await this.deps.open(targetUrl) + if (opened === undefined) { + this.openFailed += 1 + const failAt = this.now() + /** + * 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖): + * + * 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]` + * ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选", + * 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒ + * 链虽然"不再退化成单点",却依然**换不过去**。 + * ⇒ 语义 = 「排除 **当前** + 排除 **试过且失败的** ⇒ 取下一个」,失败项冷却 `cooldownMs` 后自动回归。 + */ + if (inCooling) { + /** + * **D4 后半句**:豁免尝试也失败 ⇒ ① **重置该 url 冷却** ② **本周期不再豁免它**。 + * 实现 = `exemptedAtMs = sinceMs = failAt` ⇒ `exemptedAtMs >= sinceMs` 恒真 ⇒ 本周期额度用尽。 + * ⛔ 不这样写就是"每 2 s 豁免一次、每次都失败" = **重试风暴**(比不切更糟,R11)。 + */ + this.cooling.set(targetUrl, { + untilMs: failAt + this.th.cooldownMs, + sinceMs: failAt, + kind: 'open-failed', + exemptedAtMs: failAt, + }) + this.log( + `[relay-skip] ⛔ 豁免尝试也起不来(${targetUrl})⇒ **保持原通道**;` + + `重置该候选冷却 ${this.th.cooldownMs}ms 且**本周期不再豁免**(D4 防重试风暴)`, + ) + return false + } + this.cooling.set(targetUrl, { + untilMs: failAt + this.th.cooldownMs, + sinceMs: failAt, + kind: 'open-failed', + }) + this.log( + `[relay-skip] ⛔ 新通道起不来(${targetUrl})⇒ **保持原通道**(不做半途替换);` + + `该候选进冷却 ${this.th.cooldownMs}ms(否则它会把链堵死)`, + ) + return false + } + this.current = opened + if (old !== undefined) { + try { + old.close() + } catch { + /* 已关 */ + } + /** + * D5:**被换掉的那条进冷却**。不冷却的话,它一恢复就会被立刻换回来, + * 两台互相抢 = 抖动风暴("A 挂 → 切 B → A 恢复 → 切回 A → 再挂…")。 + */ + const awayAt = this.now() + this.cooling.set(old.url, { + untilMs: awayAt + this.th.cooldownMs, + sinceMs: awayAt, + kind: 'switched-away', + }) + } + /** 认领本次豁免(D4):本周期内**不再**把它当豁免对象。 */ + const wasExempt = inCooling && entry !== undefined + if (wasExempt) { + this.cooling.set(targetUrl, { ...entry, exemptedAtMs: this.now() }) + this.exemptSwitches += 1 + } + this.switches += 1 + this.lastSwitchAtMs = this.now() + this.log( + `[relay-switch] #${this.switches} ${old === undefined ? '(无)' : old.url} -> ${opened.url}` + + `(原因:${reason};冷却 ${old === undefined ? '-' : old.url} 至 +${this.th.cooldownMs}ms)` + + (wasExempt ? `|豁免 kind=${entry.kind} 剩 ${entry.untilMs - now}ms` : ''), + ) + return true + } + + /** + * 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。 + * + * ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机); + * 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。 + */ + async tick(): Promise { + this.checks += 1 + const cur = this.current + if (cur === undefined) return + const h = cur.health() + if (!this.unhealthy(h)) return + + const now = this.now() + let urls: readonly string[] + try { + urls = await this.deps.candidates() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.log(`[relay-skip] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(保持原地退避)`) + return + } + const blocked = new Set([cur.url]) + for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url) + /** + * ⚠️ **这段文案是 D3 的护栏本身**:稳态(有干净候选)路径上必须**逐字不变** —— + * 否则序⑦ 的 E1–E11 结论会被本单"自己推翻自己"。 + */ + const reason = + `当前通道不健康(state=${h.state} attempts=${h.attempts} unhealthyForMs=${h.unhealthyForMs}` + + ` ≥ 阈值 minAttempts=${this.th.minAttempts}/graceMs=${this.th.graceMs})` + const target = urls.find((u) => !blocked.has(u)) + if (target !== undefined) { + await this.replace(target, reason, 'health') + return + } + /** + * ═══ **D6 现场**:链里除"当前 + 冷却中的"之外**没有候选** ═══ + * + * 序⑧ 的立项依据正是这里:生产目录 3 条候选里有 **2 条同机**,一次 47 故障就把它们 + * **同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 ⇒ + * 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。 + * + * ⇒ 语义拆分(D1):**"当前这条已经挂了"有权打破自己刚设下的冷却**(一跳豁免), + * 而"目录说该换回首位"没有这个权力(见 {@link replace} 的 `origin`)。 + */ + const exempt = this.pickExemptTarget(urls, cur.url, now) + if (exempt !== undefined) { + await this.replace( + exempt, + `${reason};D6 现场:**候选池已被冷却耗干** ⇒ 动用一跳豁免(每 url 每冷却周期一次,D4)`, + 'health', + ) + return + } + this.noCandidateChecks += 1 + /** + * D6 现场证据。⚠️ 这句话**必然**会被反复写(每次巡检一次)⇒ 按 grace 节流, + * 否则日志被它刷满(本线的日志纪律:`attempts` 那种每秒一行的噪音已经吃过一次)。 + */ + if (this.lastSkipLogAtMs === undefined || now - this.lastSkipLogAtMs >= this.th.graceMs) { + this.lastSkipLogAtMs = now + this.log( + `[relay-skip] ⚠ 当前通道不健康(state=${h.state} attempts=${h.attempts} ` + + `unhealthyForMs=${h.unhealthyForMs})但**链里无其他候选**(候选 ${urls.length} 条,` + + `排除 ${blocked.size} 条)⇒ 保持原地退避(⛔ 不切到空、不静默回退默认机)`, + ) + } + } + + /** + * **一跳豁免的"挑人"单点判据**(D1 / D4 / D5)。 + * + * ⛔ **只有 D6 现场才允许调用它**(D3)—— 有干净候选时**根本不该走到这里**。 + * 这条护栏由单测 **F15** 锁住:豁免一旦泄漏进正常路径,就会变成"每轮巡检都想回跳"(新抖动源)。 + * + * 候选池 = `urls`(**链里真正存在**,即已签名目录给出的候选)∩ 冷却中 - 当前 url - 本周期已豁免过的。 + * ⚠️ 池子**严格限定在候选链内** ⇒ ⛔ 绝不放宽成"任意 url"(那是 R5:扩大信任面 = 任意重定向)。 + * + * 排序(D5):`switched-away` 优先("我们主动离开了一件**曾可用**的东西"), + * 同类按 `untilMs` **升序**(越早解除 ⇒ 越可能已经恢复)。 + */ + private pickExemptTarget( + urls: readonly string[], + curUrl: string, + now: number, + ): string | undefined { + if (!this.th.exempt) return undefined + const pool: { url: string; entry: RelayCooldownEntry }[] = [] + for (const u of urls) { + if (u === curUrl) continue + const entry = this.cooling.get(u) + if (entry === undefined || entry.untilMs <= now) continue + /** D4:本周期已用掉豁免 ⇒ 不再挑它(否则每 2 s 一次 = 重试风暴)。 */ + if (entry.exemptedAtMs !== undefined && entry.exemptedAtMs >= entry.sinceMs) continue + pool.push({ url: u, entry }) + } + if (pool.length === 0) return undefined + pool.sort((a, b) => { + const ka = a.entry.kind === 'switched-away' ? 0 : 1 + const kb = b.entry.kind === 'switched-away' ? 0 : 1 + if (ka !== kb) return ka - kb + return a.entry.untilMs - b.entry.untilMs + }) + return pool[0].url + } + + /** 启动巡检(**幂等**)。周期 = `checkMs`;自链自推,不依赖调用方的定时器。 */ + start(): void { + if (this.running) return + this.running = true + const loop = (): void => { + if (!this.running) return + void this.runOnce().finally(() => { + if (!this.running) return + this.timer = this.setTimer(loop, this.th.checkMs) + }) + } + this.timer = this.setTimer(loop, this.th.checkMs) + } + + /** 停巡检(**幂等**)。⛔ 不关通道 —— 通道归装配点管。 */ + stop(): void { + this.running = false + if (this.timer !== undefined) { + this.clearTimer(this.timer) + this.timer = undefined + } + } + + /** 单次巡检的重入保护(`tick()` 里会 await 网络 ⇒ 两次巡检可能交叠)。 */ + private async runOnce(): Promise { + if (this.ticking) return + this.ticking = true + try { + await this.tick() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + this.log(`[relay-skip] ⚠ 巡检异常(${msg})⇒ 忽略本轮(下轮继续)`) + } finally { + this.ticking = false + } + } +} diff --git a/src/net/relay/wire.ts b/src/net/relay/wire.ts new file mode 100644 index 0000000..f993096 --- /dev/null +++ b/src/net/relay/wire.ts @@ -0,0 +1,453 @@ +/** + * relay 传输层 —— **最小 WebSocket 服务端帧(RFC 6455 子集)** + **多路复用帧**。 + * + * ## 为什么手写而不是引 `ws` + * 全仓依赖表里没有 `ws`(`package.json` 只有 fastify / better-sqlite3 / pg / rate-limit / static); + * Node 22 内建的 `WebSocket` **只有客户端实现**。为此给整个平台加一个生产依赖,与 + * 「relay 只是一个可选单元」(传输方案 §10.2 第 5 点)的定位不符 ⇒ 服务端手写最小帧层。 + * **不自造密码学**:握手 SHA-1 与上层 MAC 全用 `node:crypto`,这里只做字节搬运。 + * + * ## 支持的子集(边界写死,超出一律按协议错误关闭 —— 不留"说不清的行为") + * | 支持 | 不支持 ⇒ 行为 | + * |---|---| + * | binary / text / close / ping / pong | 其它 opcode ⇒ `1003` | + * | 连续分片重组(上限 `maxMessageBytes`,默认 4 MiB) | RSV 置位(未协商扩展)⇒ `1002` | + * | 7 / 16 / 64 位负载长度 | 单帧超上限 ⇒ `1009` | + * | 客户端必须 mask(RFC 6455 §5.1) | 未 mask ⇒ `1002` | + * | 服务端发出的帧**不 mask**(省一次全量拷贝) | permessage-deflate ⇒ 从不协商 | + * + * ## 多路复用帧(relay 内层,跑在 WS 的 binary 帧里) + * ```text + * 0 1 5 N + * +--------+----------------+----------------------+ + * | type | streamId BE32 | payload (N-5 bytes) | + * +--------+----------------+----------------------+ + * ``` + * 固定 5 字节头(而非 JSON 每包)是**性能**上的选择:小包场景下省掉 JSON 解析与字符串分配。 + * + * @module dshs/net/relay/wire + */ + +import { createHash } from 'node:crypto' +import { EventEmitter } from 'node:events' +import type { IncomingMessage } from 'node:http' +import type { Socket } from 'node:net' + +const EMPTY = Buffer.alloc(0) +const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +/* ═══════════════════ 1. mux 帧 ═══════════════════ */ + +/** relay 内层帧类型。**新增类型必须同时改 server 与 client 的 switch,未知类型一律关连接**。 */ +export const MUX = { + /** client → server:带 MAC 的注册请求(**未注册前只允许发这一种**)。 */ + HELLO: 0x01, + /** server → client:注册结果(`sessionId` / `accepted` / 心跳间隔)。 */ + HELLO_ACK: 0x02, + /** server → client:请求为 `streamId` 打开一条到 client 本地 `port` 的连接。 */ + OPEN: 0x03, + /** client → server:`OPEN` 的结果(`ok` / `error`)—— **没有它就不算就绪**。 */ + OPEN_ACK: 0x04, + /** 双向:某个流的原始字节。 */ + DATA: 0x05, + /** 双向:关闭某个流(`reason`)。 */ + CLOSE: 0x06, + /** 双向:应用层心跳(内建 WebSocket 不暴露 WS ping,故放在 mux 层)。 */ + PING: 0x07, + /** 双向:心跳回应。 */ + PONG: 0x08, + /** + * server → client:**注册被拒的结构化原因**(`reason` / `detail` / `serverTime` / `fatal`)。 + * + * 为什么要单独一帧而不是只用 WS close reason:client 必须能区分 + * **「重试就能好」**(`clock-skew` ⇒ 用 `serverTime` 校正后立刻重试)与 + * **「重试一万次也没用」**(`unknown-host` / `bad-mac` ⇒ 密钥配错了,要立刻报障而不是刷日志)。 + * close reason 只有 123 字节且拿不到结构化字段,不够用。**先发此帧,再关连接。** + */ + HELLO_ERR: 0x09, + /** + * 双向:**优雅告别**(`reason`)。 + * 发出方承诺「我按计划下线」⇒ 收到方**立刻回收**(清 endpoints / 标离线), + * 而不是干等心跳超时(默认 45s)。把「计划内重启」的恢复时间从 45s 压到毫秒级。 + */ + BYE: 0x0a, + /** + * client → server:**运行期加一个可被中继的端口**(`{reqId, port}`)—— 覆盖网络 R4。 + * + * 为什么必须有它:实例端口是**实例起来时才**由 `findFreePort()` 分配的,而 `HELLO` 的端口表 + * 在注册那一刻就定死了。没有这一帧,worker 只能"开局声明一整段",relay 就要为**那一段的每一个 + * 口**开一条回环监听(1000 端口段 = 1000 个 fd,实测每台机只跑 1–2 个实例)——纯浪费, + * 而且让"声明即暴露"的安全姿态白白变差。对应 ssh 版的 `ssh -O forward`。 + */ + PORT_ADD: 0x0b, + /** client → server:**撤销**一个端口(`{reqId, port}`)—— 实例停了就该把口收掉,不留悬挂监听。 */ + PORT_DEL: 0x0c, + /** + * server → client:上面两者的结果(`{reqId, port, ok, localPort?, error?}`)。 + * + * `localPort` = relay 为这个端口开在**它自己回环**上的口号(`0` = 尚未绑定完成); + * 用 `reqId` 关联请求(增删可能并发,靠端口号关联会串)。**没有它就等于 ssh 版"静默失败"**。 + */ + PORT_ACK: 0x0d, + /** + * client → server:**拨号方**请 relay 给自己开一条到 `(target, port)` 的双向流(覆盖网络 R5)。 + * + * 为什么需要这一帧:`PORT_ADD` 那条路(relay 开回环监听 → Manager 连回环)把**落点钉在 + * relay 主机的 `127.0.0.1`** 上 ⇒ Manager 必须与 relay **同机**,`会合可换机` 就断在这里。 + * 有了它,Manager 也能像 worker 一样**只拨出**一条 wss,并在自己的会话里请求开流 ⇒ + * **relay 放哪台机器都行**,且 Manager 侧不再需要 `/status` 或任何回环落点。 + * + * `payload = { target: , port: n }`;`streamId` 由**拨号方**自己分配(与 worker 侧 + * 的 id 各属一个命名空间,relay 负责配对)。 + */ + DIAL: 0x0e, + /** server → client:`DIAL` 的结果(`{ok, error?, localPort?}`)。**没有它就等于静默失败**。 */ + DIAL_ACK: 0x0f, +} as const + +export type MuxType = (typeof MUX)[keyof typeof MUX] + +/** 解出来的一个完整 mux 帧。 */ +export interface MuxFrame { + type: number + streamId: number + payload: Buffer +} + +/** 拼一个 mux 帧(一次分配,无中间拷贝)。 */ +export function encodeMux(type: number, streamId: number, payload: Buffer = EMPTY): Buffer { + const out = Buffer.allocUnsafe(5 + payload.length) + out[0] = type & 0xff + out.writeUInt32BE(streamId >>> 0, 1) + if (payload.length > 0) payload.copy(out, 5) + return out +} + +/** 解析一个 mux 帧;长度 < 5 ⇒ `null`(**调用方必须当成协议错误**,不许当空帧吞掉)。 */ +export function decodeMux(frame: Buffer): MuxFrame | null { + if (frame.length < 5) return null + return { type: frame[0], streamId: frame.readUInt32BE(1), payload: frame.subarray(5) } +} + +/** mux 帧 + JSON 负载(控制帧用;数据帧一律走 `encodeMux`,不做 JSON)。 */ +export function encodeJsonFrame(type: number, streamId: number, obj: unknown): Buffer { + return encodeMux(type, streamId, Buffer.from(JSON.stringify(obj), 'utf8')) +} + +/** 解析控制帧负载;**非对象或坏 JSON ⇒ `null`**(不抛,由调用方按协议错误处理)。 */ +export function parseJsonPayload(payload: Buffer): Record | null { + try { + const v: unknown = JSON.parse(payload.toString('utf8')) + return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record) : null + } catch { + return null + } +} + +/* ═══════════════════ 2. WebSocket 服务端(最小子集) ═══════════════════ */ + +/** RFC 6455 关闭码(只列本模块会用到的)。 */ +export const WS_CLOSE = { + NORMAL: 1000, + GOING_AWAY: 1001, + PROTOCOL_ERROR: 1002, + UNSUPPORTED_DATA: 1003, + POLICY_VIOLATION: 1008, + TOO_BIG: 1009, + INTERNAL_ERROR: 1011, + /** 1013 = Try Again Later(RFC 6455):**"现在没位子,过会儿再来"** —— 容量准入用它,语义比 1008 准。 */ + TRY_AGAIN_LATER: 1013, +} as const + +export interface WsServerOptions { + /** 单帧上限(字节),超过 ⇒ `1009` 关闭。默认 1 MiB。 */ + maxFrameBytes?: number + /** 分片重组后的消息上限。默认 4 MiB。 */ + maxMessageBytes?: number +} + +/** + * 完成握手并把 `req` 对应的 socket 提升为 WebSocket 连接。 + * + * 失败(缺 `Upgrade` / `Sec-WebSocket-Key` / 版本非 13)⇒ 回 `400` 并 destroy,返回 `null`。 + * ⚠️ 握手**不做任何认证** —— 认证在 mux `HELLO`(见 `server.ts`),因此上层**必须**设 + * 「未认证超时」,否则任何能连到回环口的进程都能白占一个 socket。 + */ +export function acceptWebSocket( + req: IncomingMessage, + socket: Socket, + head: Buffer, + opts: WsServerOptions = {}, +): WsConnection | null { + const key = req.headers['sec-websocket-key'] + const upgrade = String(req.headers.upgrade ?? '').toLowerCase() + const version = String(req.headers['sec-websocket-version'] ?? '') + if (upgrade !== 'websocket' || typeof key !== 'string' || key === '' || version !== '13') { + socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') + socket.destroy() + return null + } + const accept = createHash('sha1').update(key + WS_GUID).digest('base64') + socket.write( + 'HTTP/1.1 101 Switching Protocols\r\n' + + 'Upgrade: websocket\r\n' + + 'Connection: Upgrade\r\n' + + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`, + ) + // 性能:relay 是「小包、低延迟」形态,Nagle 会平白加一个 RTT 的排队延迟。 + socket.setNoDelay(true) + const conn = new WsConnection(socket, opts) + if (head.length > 0) conn.feed(head) + return conn +} + +/** 服务端侧的 WebSocket 连接。事件:`message(buf, isBinary)` / `drain` / `close` / `error` / `protocolError(why)`。 */ +export class WsConnection extends EventEmitter { + private readonly sock: Socket + private readonly maxFrame: number + private readonly maxMessage: number + + private stash: Buffer = EMPTY + private fragOpcode = 0 + private fragParts: Buffer[] = [] + private fragBytes = 0 + + private dead = false + private closeSent = false + private closeTimer: NodeJS.Timeout | undefined + private code = 1006 + private reason = '' + + constructor(socket: Socket, opts: WsServerOptions = {}) { + super() + this.sock = socket + this.maxFrame = opts.maxFrameBytes ?? 1024 * 1024 + this.maxMessage = opts.maxMessageBytes ?? 4 * 1024 * 1024 + socket.on('data', (chunk: Buffer) => this.feed(chunk)) + socket.on('drain', () => this.emit('drain')) + socket.on('error', (err: Error) => { + this.teardown() + this.emit('error', err) + }) + socket.on('close', () => { + this.teardown() + this.emit('close', this.code, this.reason) + }) + } + + get isClosed(): boolean { + return this.dead || this.closeSent + } + + get remote(): string { + return `${this.sock.remoteAddress ?? '?'}:${this.sock.remotePort ?? 0}` + } + + /** 出向待发字节(背压判据 —— 上层据此暂停读取源,**不得当成"失败"**)。 */ + get bufferedAmount(): number { + return this.sock.writableLength + } + + feed(chunk: Buffer): void { + if (this.dead) return + if (chunk.length > 0) this.stash = this.stash.length === 0 ? chunk : Buffer.concat([this.stash, chunk]) + for (;;) { + const frame = this.parseFrame() + if (frame === null || this.dead || this.closeSent) return + if (!this.handleFrame(frame)) return + } + } + + /** 发一个 binary 消息。返回 `false` = 触发背压(**已入 socket 缓冲,不是失败**)。 */ + sendBinary(payload: Buffer): boolean { + return this.writeFrame(0x2, payload) + } + + /** 发一个 text 消息(只在诊断时用;数据面一律 binary)。 */ + sendText(text: string): boolean { + return this.writeFrame(0x1, Buffer.from(text, 'utf8')) + } + + close(code: number = WS_CLOSE.NORMAL, reason = ''): void { + if (this.closeSent || this.dead) return + this.closeSent = true + this.code = code + this.reason = reason + const r = Buffer.from(reason, 'utf8').subarray(0, 123) + const payload = Buffer.allocUnsafe(2 + r.length) + payload.writeUInt16BE(code, 0) + r.copy(payload, 2) + this.writeFrame(0x8, payload) + // 对端不回 close 也要收尸,否则 fd 泄漏。 + this.closeTimer = setTimeout(() => this.teardown(), 3000) + this.closeTimer.unref() + } + + /** + * **强制**收尾(不等对端回 close 帧)。 + * + * 停机路径专用:实测(传输方案 §12 T8)等对端回帧会把 `stop()` 从 ~300ms 拖到 **1.8s+**, + * 而这 1.8s 正好被对端记成"服务中断"。**停机时长本身就是恢复时间的一部分** ⇒ + * 这里宁可主动销毁,也不"礼貌地等"。 + */ + destroy(): void { + this.teardown() + } + + /* ── 内部 ── */ + + private writeFrame(opcode: number, payload: Buffer): boolean { + if (this.dead) return false + const n = payload.length + let head: Buffer + if (n < 126) { + head = Buffer.allocUnsafe(2) + head[1] = n + } else if (n < 65536) { + head = Buffer.allocUnsafe(4) + head[1] = 126 + head.writeUInt16BE(n, 2) + } else { + head = Buffer.allocUnsafe(10) + head[1] = 127 + head.writeBigUInt64BE(BigInt(n), 2) + } + head[0] = 0x80 | opcode // FIN=1,服务端不 mask ⇒ 不拷负载 + if (n < 4096) return this.sock.write(Buffer.concat([head, payload], head.length + n)) + // 大包不 concat(避免一次全量拷贝),两次 write 由内核合并。 + const a = this.sock.write(head) + const b = this.sock.write(payload) + return a && b + } + + private parseFrame(): { fin: boolean; opcode: number; payload: Buffer } | null { + const b = this.stash + if (b.length < 2) return null + const fin = (b[0] & 0x80) !== 0 + const rsv = b[0] & 0x70 + const opcode = b[0] & 0x0f + const masked = (b[1] & 0x80) !== 0 + let len = b[1] & 0x7f + let off = 2 + if (len === 126) { + if (b.length < 4) return null + len = b.readUInt16BE(2) + off = 4 + } else if (len === 127) { + if (b.length < 10) return null + const big = b.readBigUInt64BE(2) + if (big > BigInt(Number.MAX_SAFE_INTEGER)) { + this.fail(WS_CLOSE.TOO_BIG, 'frame length overflow') + return null + } + len = Number(big) + off = 10 + } + if (rsv !== 0) { + this.fail(WS_CLOSE.PROTOCOL_ERROR, 'rsv set without extension') + return null + } + if (len > this.maxFrame) { + this.fail(WS_CLOSE.TOO_BIG, `frame ${len} > ${this.maxFrame}`) + return null + } + if (!masked) { + this.fail(WS_CLOSE.PROTOCOL_ERROR, 'client frame must be masked') + return null + } + if (b.length < off + 4 + len) return null + const mask = b.subarray(off, off + 4) + const raw = b.subarray(off + 4, off + 4 + len) + const payload = Buffer.allocUnsafe(len) + for (let i = 0; i < len; i++) payload[i] = raw[i] ^ mask[i & 3] + this.stash = b.subarray(off + 4 + len) + return { fin, opcode, payload } + } + + private handleFrame(f: { fin: boolean; opcode: number; payload: Buffer }): boolean { + if (f.opcode === 0x8) { + this.readClose(f.payload) + return false + } + if (f.opcode === 0x9) { + this.writeFrame(0xa, f.payload) + return true + } + if (f.opcode === 0xa) { + this.emit('pong') + return true + } + if (f.opcode === 0x0) { + if (this.fragOpcode === 0) { + this.fail(WS_CLOSE.PROTOCOL_ERROR, 'continuation without start') + return false + } + this.fragBytes += f.payload.length + if (this.fragBytes > this.maxMessage) { + this.fail(WS_CLOSE.TOO_BIG, 'fragmented message too large') + return false + } + this.fragParts.push(f.payload) + if (f.fin) { + const opcode = this.fragOpcode + const message = Buffer.concat(this.fragParts, this.fragBytes) + this.resetFrag() + this.emit('message', message, opcode === 0x2) + } + return true + } + if (f.opcode !== 0x1 && f.opcode !== 0x2) { + this.fail(WS_CLOSE.UNSUPPORTED_DATA, `opcode ${f.opcode}`) + return false + } + if (this.fragOpcode !== 0) { + this.fail(WS_CLOSE.PROTOCOL_ERROR, 'interleaved data frame') + return false + } + if (f.fin) { + this.emit('message', f.payload, f.opcode === 0x2) + return true + } + this.fragOpcode = f.opcode + this.fragParts = [f.payload] + this.fragBytes = f.payload.length + return true + } + + private readClose(payload: Buffer): void { + if (payload.length >= 2) { + this.code = payload.readUInt16BE(0) + this.reason = payload.subarray(2).toString('utf8') + } else { + this.code = WS_CLOSE.NORMAL + } + if (!this.closeSent) { + this.closeSent = true + this.writeFrame(0x8, payload.subarray(0, Math.min(payload.length, 125))) + } + this.teardown() + } + + private resetFrag(): void { + this.fragOpcode = 0 + this.fragParts = [] + this.fragBytes = 0 + } + + private fail(code: number, why: string): void { + this.emit('protocolError', why) + this.close(code, why) + // 协议已错 ⇒ 不等对端回应,立刻收尸。 + const t = setTimeout(() => this.teardown(), 100) + t.unref() + } + + private teardown(): void { + if (this.dead) return + this.dead = true + if (this.closeTimer !== undefined) clearTimeout(this.closeTimer) + this.stash = EMPTY + this.resetFrag() + this.sock.destroy() + } +} diff --git a/src/net/rendezvous.ts b/src/net/rendezvous.ts index bc1f363..b1ef542 100644 --- a/src/net/rendezvous.ts +++ b/src/net/rendezvous.ts @@ -21,6 +21,7 @@ */ import { VIA_LOCAL, VIA_MANAGER_SSH, type Reachability } from './reachability.js' +import { parseLogicalName } from './relay/network.js' export interface Rendezvous { /** 实现 id —— `Reachability.via` 指向它。 */ @@ -29,14 +30,23 @@ export interface Rendezvous { dialTarget(): string /** * 解析某台 worker 的可达性。 + * + * ⚠️ **入参是逻辑名 `/`**(P0-3)—— 不是裸 `hostId`。 + * 地址是**网内**语义:两张网各有一台 `w-1` 时,只给 `hostId` 的解析**必然有歧义**, + * 而歧义会以"打到另一张网的同名节点"这种最贵的形态暴露。裸 `hostId` 仍兼容(⇒ 落 `ops`), + * 但**调用方不许自己拼网络段** —— 名字由 `logicalName()` 产出。 + * * ⚠️ **本实现管不到 ⇒ 回 `undefined`,不抛** —— 由调用方决定回退哪种实现 * (抛错会让"多实现并存"的过渡期没法跑)。 */ - resolve(hostId: string): Promise + resolve(name: string): Promise } -/** 由调用方提供"hostId → `host:port`"的查表函数(会合实现不直接连 DB)。 */ -export type AddressLookup = (hostId: string) => string | undefined +/** + * 由调用方提供"**逻辑名** → `host:port`"的查表函数(会合实现不直接连 DB)。 + * 键是逻辑名(P0-3):查表这一层也必须带网络维度,否则同名 host 两张网互相覆盖。 + */ +export type AddressLookup = (name: string) => string | undefined /** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`w-47` 就是这一类)。 */ export class LocalRendezvous implements Rendezvous { @@ -48,9 +58,11 @@ export class LocalRendezvous implements Rendezvous { return '(direct)' } - async resolve(hostId: string): Promise { - const address = this.addressOf(hostId) - return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' } + async resolve(name: string): Promise { + const address = this.addressOf(name) + if (address === undefined) return undefined + const { network, hostId } = parseLogicalName(name) + return { hostId, networkId: network, via: this.id, address, scheme: 'http' } } } @@ -79,9 +91,11 @@ export class ManagerSshRendezvous implements Rendezvous { return this.opts.target } - async resolve(hostId: string): Promise { - const address = this.opts.addressOf(hostId) - return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' } + async resolve(name: string): Promise { + const address = this.opts.addressOf(name) + if (address === undefined) return undefined + const { network, hostId } = parseLogicalName(name) + return { hostId, networkId: network, via: this.id, address, scheme: 'http' } } } diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts index 84ffbed..bffcf4b 100644 --- a/src/supervisor/orchestrator.ts +++ b/src/supervisor/orchestrator.ts @@ -37,7 +37,7 @@ import { type CrashPolicyConfig, } from './crash-policy.js' import { createPortGuard, type PortGuard } from './firewall.js' -import { findFreePort, scrubEnv } from './spawn.js' +import { findInstancePort, scrubEnv } from './spawn.js' import { AlreadyRunningError, CrashBreakerOpenError, @@ -592,7 +592,10 @@ export class LocalSpawner implements Spawner { private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise { const isMain = role === 'main' - const port = isMain ? await findFreePort() : undefined + // 覆盖网络 S3:端口来自**本机专属区间**(未配 env ⇒ 退回旧的 `listen(0)`)。 + // 为什么不能用 `listen(0)`:跨机实例的隧道落点全挤在 Manager 的 `127.0.0.1`, + // 两台 worker 各自随机取端口就会撞号,而 `-R` 失败是**静默**的 ⇒ 会拨到别人的实例。 + const port = isMain ? await findInstancePort(this.config.instancePortBase, this.config.instancePortSpan) : undefined const instance: Instance = { id: randomUUID(), userId, diff --git a/src/supervisor/remote-spawner.ts b/src/supervisor/remote-spawner.ts index 0568698..460ec72 100644 --- a/src/supervisor/remote-spawner.ts +++ b/src/supervisor/remote-spawner.ts @@ -42,6 +42,14 @@ export interface ClusterHost { token: string /** 代理时使用的主机(同机 1a = `127.0.0.1`;跨机填 Worker 内网 IP)。 */ instanceHost?: string + /** + * **表里声明的会合形态**(`dsh_hosts.via` 原文,P0-3 加)。 + * + * 只有一处用途:`agentBaseUrlOf()` 在 `via='relay'` 且**解析不出落点**时**拒绝回落到 + * `agentUrl`**(relay 语义下 `endpoint` 是落点而非直连地址,回落会打到本机同号端口)。 + * 省略 = 不触发该保护(同机 `clusterHostId` 那条就是这一类)。 + */ + via?: string } export interface RemoteSpawnerOptions { @@ -76,6 +84,18 @@ export interface RemoteSpawnerOptions { hostsProvider?: () => Promise /** 目录缓存时长(ms)。默认 30 s —— 与心跳同量级。 */ directoryTtlMs?: number + /** + * **实例端点翻译器**(覆盖网络 R4):把"Worker 视角的 `{host, port}`"翻成"Manager 能拨的 + * `{host, port}`"。省略 = 不翻译(同号语义,即 ssh 隧道与同机形态的既有行为)。 + * + * 为什么翻译必须发生在**拨号的那一端**:relay 为每个端口在**它自己的回环**上开一条监听, + * 口号是动态分配的 ⇒ 只有 Manager 知道"Worker 的 21000 此刻对应我本机的 42067"。 + * 让 Worker 去上报这个口号,等于把"Manager 侧地址观"塞进 Worker(relay 换机部署时两头都要改)。 + * + * 返回 `undefined` = **翻译不出来**(relay 里查不到该端口 / 该 host 不是 relay 形态)⇒ + * 调用方按"实例不可达"处理,**绝不回退成 Worker 侧原口号**(那会打到死地址上)。 + */ + translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined } const RETRY_DELAYS_MS = [200, 1000, 3000] @@ -90,6 +110,7 @@ export class RemoteSpawner implements Spawner { private readonly hostIdFor?: (userId: string) => Promise private readonly hostsProvider?: () => Promise private readonly directoryTtlMs: number + private readonly translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined private directoryLoadedAt = 0 constructor(options: RemoteSpawnerOptions) { @@ -112,6 +133,12 @@ export class RemoteSpawner implements Spawner { this.hostIdFor = options.hostIdFor this.hostsProvider = options.hostsProvider this.directoryTtlMs = options.directoryTtlMs ?? 30_000 + // ⚠️ 漏掉这一行的后果(2026-09-16 实测踩到):`endpointFor` 会**静默回退成原样透传** ⇒ + // Manager 拿着 Worker 视角的口号(如 `127.0.0.1:21000`)往**自己本机**拨 ⇒ 连接被拒两次 + // ⇒ 代理 `reply.raw.destroy()` ⇒ 浏览器只看到 **空响应**(`curl` 报 `Empty reply from server`), + // 而平台上**一行错误日志都没有**。这正是覆盖网络线要消灭的那类静默失败。 + // 判据:`test/remote-spawner.test.mjs` 第 1 例(构造时就断言 `translateEndpoint` 生效)。 + this.translateEndpoint = options.translateEndpoint } /** @@ -187,11 +214,17 @@ export class RemoteSpawner implements Spawner { operationId?: string, ): Promise { const payload = body === undefined ? undefined : { ...body, ...(operationId === undefined ? {} : { operationId }) } + /** + * 取址**放在重试循环外**(P0-3):`agentBaseUrlOf()` 现在**会抛**(`via=relay` 却解析不出落点 + * ⇒ 拒绝回落到 endpoint)。留在循环里,这条**确定性错误**会被重试 3 次, + * 而且在日志里长得像"网络抖动" —— 排障时最费时间的那类假象。 + */ + const base = agentBaseUrlOf(host) let lastErr: unknown for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) { if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt - 1])) try { - const res = await this.doFetch(`${agentBaseUrlOf(host)}${path}`, { + const res = await this.doFetch(`${base}${path}`, { method, headers: { [AGENT_TOKEN_HEADER]: host.token, @@ -282,9 +315,10 @@ export class RemoteSpawner implements Spawner { 'GET', `/endpoint/${encodeURIComponent(userId)}`, ) - return res.running && res.host !== undefined && res.port !== undefined - ? { host: res.host, port: res.port } - : undefined + if (!res.running || res.host === undefined || res.port === undefined) return undefined + const raw: Endpoint = { host: res.host, port: res.port } + // R4:`relay` 形态下 Manager 侧的口号是 relay 动态分配的 ⇒ 必须在这里翻译(见选项注释)。 + return this.translateEndpoint === undefined ? raw : this.translateEndpoint(host.hostId, raw) } async stop(userId: string, hostId?: string): Promise { diff --git a/src/supervisor/spawn.ts b/src/supervisor/spawn.ts index a0991f9..01015fc 100644 --- a/src/supervisor/spawn.ts +++ b/src/supervisor/spawn.ts @@ -64,3 +64,48 @@ export function findFreePort(): Promise { }) }) } + +/** + * 在 `[base, base + span)` 内取一个空闲回环端口(覆盖网络 S3)。 + * + * ## 为什么不能用 `listen(0)` + * `listen(0)` 让**每台 worker 各自**随机取端口,而所有跨机实例的**隧道落点全挤在 Manager + * 的 `127.0.0.1`** 上 ⇒ **两台 worker 取到同号就撞号**:`ssh -R` 失败被静默忽略 + * (`tunnel.forward()` 的返回值无人检查),Manager 仍按该端口拨 ⇒ **打到别人的实例** + * (2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间即可根治。 + * + * ⛔ 区间用尽 **抛错**,不静默退回 `listen(0)` —— 那等于把撞号风险悄悄放回来。 + */ +export function findFreePortInRange(base: number, span: number): Promise { + return new Promise((resolve, reject) => { + let candidate = base + const end = base + span + const attempt = (): void => { + if (candidate >= end) { + reject(new Error(`实例端口区间已耗尽:${base}-${end - 1}(span=${span})`)) + return + } + const port = candidate + candidate += 1 + const server = createServer() + server.once('error', () => attempt()) + server.listen(port, '127.0.0.1', () => { + const address = server.address() + const got = typeof address === 'object' && address !== null ? address.port : undefined + server.close(() => { + if (got === undefined) attempt() + else resolve(got) + }) + }) + } + attempt() + }) +} + +/** + * 取实例端口的**统一入口**:配了区间(`base>0 && span>0`)就走区间,否则退回旧的 `listen(0)`。 + * 单机 / 未配 env 的部署因此**行为零变化**。 + */ +export function findInstancePort(base: number, span: number): Promise { + return base > 0 && span > 0 ? findFreePortInRange(base, span) : findFreePort() +} diff --git a/src/web/routes/admin.ts b/src/web/routes/admin.ts index c0a858c..49369a3 100644 --- a/src/web/routes/admin.ts +++ b/src/web/routes/admin.ts @@ -168,6 +168,9 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { hosts: hosts.map((h) => ({ id: h.id, endpoint: h.endpoint, + // 覆盖网络 S2:`via` 必须**可见** —— 否则"经谁可达"这件事运维查不出来, + // 加列就等于白加(迁移出问题时第一眼就要能看见)。 + via: h.via, capacityMb: h.capacityMb, usedMb: h.usedMb, status: h.status, @@ -182,6 +185,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { const body = request.body as { id?: string endpoint?: string + /** 覆盖网络 S2:**经谁可达**(`local` / `manager-ssh` / 未来的 `relay:`)。省略 = 列默认。 */ + via?: string token?: string capacityMb?: number } @@ -191,13 +196,24 @@ export const adminRoutes: FastifyPluginAsync = async (app) => { const host = await app.db.upsertDshHost({ id: body.id, endpoint: body.endpoint, + via: body.via, agentToken: body.token, capacityMb: Number(body.capacityMb ?? 0), }) - await app.db.audit(request.user?.id ?? null, 'host.upsert', JSON.stringify({ id: host.id, endpoint: host.endpoint })) + await app.db.audit( + request.user?.id ?? null, + 'host.upsert', + JSON.stringify({ id: host.id, endpoint: host.endpoint, via: host.via }), + ) return { ok: true, - host: { id: host.id, endpoint: host.endpoint, capacityMb: host.capacityMb, status: host.status }, + host: { + id: host.id, + endpoint: host.endpoint, + via: host.via, + capacityMb: host.capacityMb, + status: host.status, + }, } }) diff --git a/src/web/routes/overlay.ts b/src/web/routes/overlay.ts new file mode 100644 index 0000000..9424d49 --- /dev/null +++ b/src/web/routes/overlay.ts @@ -0,0 +1,83 @@ +/** + * 覆盖网络 P0-2:**引导目录端点** `GET /dshs-overlay/bootstrap`(**只读 + 无鉴权**)。 + * + * ## 为什么必须无鉴权 + * + * 它服务的对象正是「**还没有任何凭据**的新节点」(首次入网)⇒ 不能要求登录。 + * 因此它的暴露面必须**小到可以不设防**,这份目录里只允许出现四类东西: + * + * | 字段 | 说明 | + * |---|---| + * | `version` / `issuedAt` / `refreshAfterSeconds` | 结构与缓存策略(**被签名覆盖**) | + * | `network` | 该目录所属的网(`ops`) | + * | `relays[]` | 中继端点(**只放公网 / 域名形态**,过滤回环与私网) | + * | `bootstrap[]` | **可轮换的引导地址清单** | + * + * ⛔ 目录里**不出现**:hostId、任何密钥、用户数据、内网地址、实例端口、版本号之外的内部标识。 + * 连上之后**能拨谁**由 relay 侧的「网维度 + 白名单」(P0-1)决定 —— 目录只管"去哪儿"。 + * + * ## 失败关闭 + * + * 没有配签名私钥 / 私钥读不出来 / 签名抛错 ⇒ **`503 directory-unavailable`**, + * **绝不返回未签名目录**(否则"签名不对就拒绝"这条客户端判据会被一个半成品端点绕过)。 + * + * 缓存策略 = `no-store`:目录**故意允许被轮换**,让 CDN / 浏览器留一份旧副本会把轮换能力拖死 + * (客户端自己按 `refreshAfterSeconds` 缓存,不需要中间层再插一手)。 + * @module dshs/web/routes/overlay + */ + +import type { FastifyPluginAsync } from 'fastify' +import { readFileSync } from 'node:fs' +import type { ServerConfig } from '../../config.js' +import { + DIRECTORY_PATH, + buildDirectoryDocument, + publicRelayEntries, + signDirectory, + type OverlayDirectory, +} from '../../net/relay/directory.js' + +/** 组装并签发目录;任一步不可用 ⇒ `undefined`(调用方回 503,**不发未签名目录**)。 */ +function buildSignedDirectory(config: ServerConfig): { doc: OverlayDirectory; sig: string } | undefined { + const log = (line: string): void => { + process.stdout.write(`${line}\n`) + } + if (config.overlayDirKeyFile === '') { + log('[overlay-dir] ⛔ 未配 DSHS_OVERLAY_DIR_KEY ⇒ 目录端点返回 503(**不发未签名目录**)') + return undefined + } + let pem: string + try { + pem = readFileSync(config.overlayDirKeyFile, 'utf8') + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + log(`[overlay-dir] ⛔ 读不到签名私钥 ${config.overlayDirKeyFile}(${msg})⇒ 503`) + return undefined + } + // `relays[]` 优先用**显式配置的中继入口**,其次用种子(种子本身就是中继入口 ⇒ 语义自洽)。 + const candidates = config.relayUrl !== '' ? [config.relayUrl, ...config.overlayBootstrapSeeds] : [...config.overlayBootstrapSeeds] + const relays = publicRelayEntries(candidates) + const bootstrap = publicRelayEntries(config.overlayBootstrapSeeds) + try { + const doc = buildDirectoryDocument({ + relays, + bootstrap, + network: config.overlayNetworkId, + now: Date.now(), + }) + return { doc, sig: signDirectory(doc, pem) } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + log(`[overlay-dir] ⛔ 目录组装/签发失败(${msg})⇒ 503`) + return undefined + } +} + +export const overlayRoutes: FastifyPluginAsync = async (app) => { + app.get(DIRECTORY_PATH, async (_request, reply) => { + reply.header('cache-control', 'no-store') + const built = buildSignedDirectory(app.config) + if (built === undefined) return reply.code(503).send({ error: 'directory-unavailable' }) + return { ...built.doc, sig: built.sig } + }) +} diff --git a/src/web/server.ts b/src/web/server.ts index 44bde23..df66d05 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -17,7 +17,16 @@ import { RemoteUserFs } from '../fs/remote-user-fs.js' import type { UserFs } from '../fs/user-fs.js' import { decrypt, deriveKey } from '../crypto.js' import { hashUid } from '../isolation.js' -import { agentBaseUrlOf } from '../net/reachability.js' +import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH, VIA_RELAY } from '../net/reachability.js' +import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../net/rendezvous.js' +import { OPS_NETWORK, logicalName } from '../net/relay/network.js' +import { RelayRendezvous } from '../net/relay/rendezvous.js' +import { RelayDialer } from '../net/relay/dialer.js' +import { listOverlayRelayCandidates, resolveOverlayRelay } from '../net/relay/directory.js' +import { RelayClient, waitUpOnStatus } from '../net/relay/client.js' +import { loadClientIdentity } from '../net/relay/identity.js' +import { RelayFailoverSupervisor, relayFailoverThresholds } from '../net/relay/switcher.js' +import type { RelayChannelHandle } from '../net/relay/switcher.js' import { LocalSpawner } from '../supervisor/orchestrator.js' import { LeasedSpawner } from '../supervisor/leased-spawner.js' import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js' @@ -42,6 +51,7 @@ import { businessPluginRoutes } from './routes/business-plugins.js' import { desktopRoutes } from './routes/desktop.js' import { dshRoutes } from './routes/dsh.js' import { domainRoutes } from './routes/domain.js' +import { overlayRoutes } from './routes/overlay.js' import { skillRoutes } from './routes/skills.js' import { whitelistRoutes } from './routes/whitelist.js' @@ -59,6 +69,20 @@ declare module 'fastify' { const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web') +/* ── 覆盖网络 R3:relay 状态视图的三个时间常数(都在 Manager 本地回环上,量级取"秒")── */ +/** 单次 `/status` 查询超时。relay 在回环上 ⇒ 超过这个数就是它卡了,不值得等。 */ +const RELAY_STATUS_TIMEOUT_MS = 2000 +/** 轮询间隔。取 5s:`dsh_hosts` 目录本身 TTL 是 30s,比它更密即可。 */ +const RELAY_STATUS_POLL_MS = 5000 +/** 快照陈旧阈值:超过它一律认为 relay 视图不可信 ⇒ `relay` 实现退回 `manager-ssh`。 */ +const RELAY_SNAPSHOT_TTL_MS = 15000 + +/* ── 覆盖网络 P0-2:引导目录的后台刷新节奏 ── */ +/** 首次刷新延迟。**必须 > 0**:控制面自己既是目录服务端又是客户端,启动瞬间自己的 3080 还没 listen。 */ +const OVERLAY_REFRESH_FIRST_MS = 15000 +/** 刷新周期下限(目录给的 `refreshAfterSeconds` 更小也按它兜底,避免退化成"每秒取一次")。 */ +const OVERLAY_REFRESH_MIN_MS = 60000 + /** Whether an `Origin` header belongs to the platform base domain (or a * per-user subdomain of it). Used to allow cross-subdomain API calls from dsh * instances (功能插件启停). */ @@ -254,13 +278,397 @@ export async function buildServer(config: ServerConfig): Promise/`(P0-3)—— 网络维度是**结构性**的,控制面的每一张 + * 内存表都必须带着它;只按裸 hostId 建键时,两张网的同名 host 会**互相覆盖**(静默串网)。 + */ + const hostAddresses = new Map() + /** + * 每台 host 的 **agent 端口**(从 `dsh_hosts.endpoint` 剥出来的那个号码)。 + * + * 为什么需要(覆盖网络 R3):relay 为**每个注册端口**在它自己的回环上开一条监听,回环口号 + * 由 `listen(0)` **动态分配**(实测 42067)⇒ Manager 既拿不到也推不出,只能拿「要拨的端口号」 + * 去 relay 的 `/status` 里查回环口号。而 agent 端口与 ssh 隧道落点**同号**(隧道就是同号反向 + * 转发,见 `worker/tunnel.ts#forward`)⇒ 这个号码现有表里就有,**不需要新增列**。 + * + * 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。 + */ + const hostAgentPorts = new Map() + /** + * 每台 host **表里声明的会合形态**(`dsh_hosts.via` 原文),与 `reachability` 分开存。 + * + * 为什么不能用 `reachability` 反推(R4 实测教训):`Reachability` 是**实时解析结果**, + * host 离线 / relay 快照陈旧时 `resolve()` 会回 `undefined`。若此时按"不是 relay ⇒ 原样透传" + * 处理,就会把 **Worker 侧口号**打到 Manager 本机(连接被拒、空响应、无日志)。 + * ⇒ 「这台该走哪种会合」必须读**表里的静态声明**;「此刻可拨到什么地址」才读 `reachability`。 + * + * 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。 + */ + const hostVia = new Map() + /** + * relay 的实时视图(覆盖网络 R3):`/:` → `{ localPort, online }`。 + * + * ⚠️ **键是逻辑名**(P0-3):只按裸 hostId 建键时,两张网各有一台同名 host 会**互相覆盖** + * —— 后刷进来的那张网把前一张的落点顶掉 ⇒ 请求被静默路由到**另一张网**的节点上 + * (这正是 Step 1 在 relay 侧修掉的病,控制面这一份当时还是旧的)。 + * + * ⚠️ **只在刷新成功时整表替换**(不做增量合并):relay 重启后回环口号会**全部重分配**, + * 增量会把过期口号永远留在表里 ⇒ Manager 继续往一个已不存在的口上打。 + * 这正是 ssh 版「静默打到别人实例」的同一类病,不能在替代品里复现。 + * 刷新失败时**保留上一份**,靠 `relaySnapshotAt` 判陈旧(陈旧 ⇒ `online` 回 false ⇒ 自动回退 `manager-ssh`)。 + */ + const relayEndpoints = new Map() + let relaySnapshotAt = 0 + const refreshRelay = async (): Promise => { + if (config.relayStatusUrl === '') return + try { + const res = await fetch(config.relayStatusUrl, { signal: AbortSignal.timeout(RELAY_STATUS_TIMEOUT_MS) }) + if (!res.ok) return + const body = (await res.json()) as { endpoints?: unknown } + const rows = Array.isArray(body.endpoints) ? body.endpoints : [] + const next = new Map() + for (const raw of rows) { + const ep = raw as { + hostId?: unknown + network?: unknown + port?: unknown + localPort?: unknown + online?: unknown + } + if (typeof ep.hostId !== 'string' || typeof ep.port !== 'number') continue + if (typeof ep.localPort !== 'number' || ep.localPort <= 0) continue + // `network` 缺失 = 老 relay(P0-1 之前)⇒ 按 `ops` 读(与 DB 的列默认值同口径)。 + const network = typeof ep.network === 'string' && ep.network !== '' ? ep.network : OPS_NETWORK + next.set(`${logicalName(network, ep.hostId)}:${ep.port}`, { + localPort: ep.localPort, + online: ep.online === true, + }) + } + relayEndpoints.clear() + for (const [k, v] of next) relayEndpoints.set(k, v) + relaySnapshotAt = Date.now() + } catch { + // relay 不可达 / 超时 ⇒ **保留上一份快照**(临时抖动不抖路由),由陈旧判定兜底。 + } + } + if (config.relayStatusUrl !== '') { + void refreshRelay() + const relayTimer = setInterval(() => void refreshRelay(), RELAY_STATUS_POLL_MS) + relayTimer.unref() + } + /** + * 覆盖网络 R5:**Manager 侧拨号通道** —— 「会合可换机」的收口件(`会合中继拆分` §9.4 那个断点)。 + * + * 配了 `DSHS_RELAY_DIAL_SECRET` ⇒ Manager 也像 worker 一样**只拨出**一条 wss,并把落点建在 + * **自己的回环**上(`RelayDialer` 的预绑口池)⇒ **不再依赖 relay 主机的 `127.0.0.1`**, + * relay 可以放在任何一台机器上(也可以多实例)。 + * 没配 ⇒ `relayDialer` 为 `undefined` ⇒ 全部回落到 `/status` 快照,**行为与 R3 完全一致**。 + * + * 密钥必须是 64 位 hex(与 relay 密钥表同格式)。格式不对 ⇒ **不启用**并明确报一行 —— + * 而不是带着一个坏密钥去反复握手失败(那会变成刷日志的噪音)。 + */ + /** + * 覆盖网络 P0-2:**relay 地址走引导三级链**(env 显式 > 缓存目录 > 内置种子)。 + * + * 为什么必须在这里改:`config.relayUrl` 只可能来自 env ⇒ 域名 / 机器一换, + * **用户设备上的那个值我们改不到**,只能让所有人重装(`§A2` 点名的灾难)。 + * 引导链把地址变成"可在线轮换的下发物";env 仍然**压制一切**(运维最后手段)。 + * 取不到(返回空串)⇒ 与"未配 relay"**完全同行为**(落回 `/status` 快照 / `manager-ssh`)。 + */ + const overlayLog = (line: string): void => { + process.stdout.write(`${line}\n`) + } + const relayResolved = await resolveOverlayRelay({ + envUrl: config.relayUrl, + seeds: config.overlayBootstrapSeeds, + trustedKeys: config.overlayDirTrustedKeys, + cacheFile: config.overlayDirectoryCacheFile, + log: overlayLog, + }) + const relayUrl = relayResolved.url + const dialLog = (line: string): void => { + process.stdout.write(`${line}\n`) + } + /** 拨号通道能不能起:地址有了 + 密钥是 64 位 hex。**任一条不满足 ⇒ 完全不启用**(与 R5 同语义)。 */ + const secretOk = /^[0-9a-f]{64}$/.test(config.relayDialSecret) + const dialerEnabled = relayUrl !== '' && config.relayDialSecret !== '' && secretOk + if (relayUrl !== '' && config.relayDialSecret !== '' && !secretOk) { + dialLog('[relay-dialer] ⛔ DSHS_RELAY_DIAL_SECRET 不是 64 位 hex ⇒ 拨号通道**不启用**(落回 /status 快照)') + } + /** + * 起一条拨号通道。抽成函数是因为 P0-2 要在**运行期换址**(见下面的后台刷新)。 + * + * **先建新的、成功了再关旧的**:新通道任何一步失败都直接返回 `undefined`、旧通道原样保留 + * —— 换址失败绝不能把"本来能用"的跨机通路打掉。 + */ + const startDialer = async ( + url: string, + ): Promise<{ client: RelayClient; dialer: RelayDialer } | undefined> => { + const client = new RelayClient({ + url, + hostId: config.relayDialHost, + secret: config.relayDialSecret, + ports: [], + dialer: true, + /** + * 序③:Manager 也是**一台机器**,同样要证明"被授权进入这张网"。 + * 与 worker 侧同一装配入口(`loadClientIdentity`)⇒ 两侧只有一种写法。 + */ + identity: loadClientIdentity({ + keyFile: config.overlayNodeKeyFile, + grantFile: config.overlayNodeGrantFile, + log: dialLog, + }), + log: dialLog, + }) + client.start() + const dialer = new RelayDialer({ + client, + portBase: config.relayDialPortBase, + portSpan: config.relayDialPortSpan, + poolSize: config.relayDialPool, + log: dialLog, + }) + // **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)⇒ + // 不 await 就会在启动瞬间把"能给地址"缩成一个窗口期。 + try { + await dialer.start() + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + dialLog(`[relay-dialer] ⛔ 落点池绑定失败(${msg})⇒ 本条通道放弃,已有通道不受影响`) + try { + dialer.close() + } catch { + /* 已关 */ + } + client.stop() + return undefined + } + return { client, dialer } + } + /** + * 序⑦:**通道句柄把 dialer 一起带着走** —— `relayDialer` 不再是一个独立可变量, + * 而是"监管器当前通道"的投影。这样"当前是谁"只有**一个**权威来源, + * ⛔ 不会出现"监管器已切到新通道、别处还拿着旧 dialer"的分叉。 + */ + type C1Handle = RelayChannelHandle & { dialer: RelayDialer; client: RelayClient } + const toHandle = ( + url: string, + started: { client: RelayClient; dialer: RelayDialer }, + ): C1Handle => ({ + url, + dialer: started.dialer, + client: started.client, + health: () => { + const st = started.client.status() + return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs } + }, + close: () => { + try { + started.dialer.close() + } catch { + /* 已关 */ + } + try { + started.client.stop() + } catch { + /* 已关 */ + } + }, + }) + const overlayResolveOpts = (): Parameters[0] => ({ + envUrl: config.relayUrl, + seeds: config.overlayBootstrapSeeds, + trustedKeys: config.overlayDirTrustedKeys, + cacheFile: config.overlayDirectoryCacheFile, + log: overlayLog, + }) + /** 序⑦ 切流阈值(唯一一份默认值在 `switcher.ts`;这里只是取一份实例)。 */ + const failoverThresholds = relayFailoverThresholds() + /** + * 换址版等待:**非抛**,只回答"通没通"(`open` 要的是布尔)。 + * + * 🔴 **序⑨ · RC-1(C1 装配点)**:实现已抽到 `net/relay/client.ts#waitUpOnStatus` + * (三个装配点共用一份,D7)。相对改造前的**唯一**差别 = **终态失败(死候选)立即返回 `false`**, + * ⛔ 不再白等满 `upTimeoutMs`。 + * ⚠️ 为什么这里最痛:生产目录前两条候选**同在 47**(见下面 `open` 的注释)⇒ 每次从 47 切走 + * **必然**先试同机的 `relay-direct`(已随 47 一起死)⇒ 白等 ≡ `upTimeoutMs`(12 000 ms), + * 实测两样本逐行复核(序⑨ §2-P10)。**慢候选(连得上、只是 `up` 来得晚)不受影响**。 + */ + const waitUpOn = (client: RelayClient, timeoutMs: number): Promise => + waitUpOnStatus(client, timeoutMs, { + onDead: (st) => + overlayLog( + `[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` + + `burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`, + ), + }) + /** + * **中继失败切流的唯一实现**(序⑦ · C1 装配点)。 + * + * 触发信号全部复用现成状态机(D2):`RelayClient.status()` 的 `state/attempts/unhealthyForMs` + * ——⛔ 不新造心跳、不新增探测帧。 + */ + const failover = new RelayFailoverSupervisor({ + /** + * ⚠️ **换址版的 `open` 比启动版严一档**:启动只要"口池绑好"(`startDialer` 的语义, + * P0-2 要求启动不依赖网络);**换址**必须等新通道真到 `up` —— + * 否则会把"口池绑好了"误当成"通了",切到一条**同样连不上**的中继上 + * (生产目录前两条候选落在同一台机器,这个坑一定会踩到)。 + * 到不了 `up` ⇒ 关掉它、返回 `undefined` ⇒ 监管器保持原通道并把该候选进冷却(D4 + 链推进)。 + */ + open: async (url) => { + const started = await startDialer(url) + if (started === undefined) return undefined + if (!(await waitUpOn(started.client, failoverThresholds.upTimeoutMs))) { + try { + started.dialer.close() + } catch { + /* 已关 */ + } + started.client.stop() + return undefined + } + return toHandle(url, started) + }, + candidates: async () => (await listOverlayRelayCandidates(overlayResolveOpts())).urls, + log: overlayLog, + thresholds: failoverThresholds, + }) + /** 当前拨号通道(由监管器的"当前通道"派生 —— 单一权威来源)。 */ + const currentDialer = (): RelayDialer | undefined => + (failover.channel as C1Handle | undefined)?.dialer + if (dialerEnabled) { + const started = await startDialer(relayUrl) + if (started !== undefined) failover.seed(toHandle(relayUrl, started)) + /** + * 健康巡检**独立定时器**:目录刷新周期可能长达分钟级(`refreshAfterSeconds`), + * 而故障切换要在 `RELAY_FAILOVER_DEADLINE_MS`(30 s)内完成 ⇒ 两者节奏必须分开。 + */ + failover.start() + } + /** + * 覆盖网络 P0-2:**后台刷新目录**(启动后再取一次,之后按目录给的刷新周期)。 + * + * 为什么不能在启动时"一次算完":**控制面自己也是客户端**(47 同机既是目录签发方、又是 + * Manager)⇒ 启动那一刻自己的 3080 还没 `listen`,取目录必然 `502`;若就此定死, + * Manager 就**永远**拿不到目录里 `relays[]` / `bootstrap[]` 的轮换结果(能力等于没有)。 + * 所以分成两步:**启动只用"缓存 / 种子"起来(启动不依赖网络)**,随后后台再取一次目录; + * 地址**真的变了**才换通道(无变化时零动作、零日志噪音)。 + * + * 序⑦:换址动作统一走 {@link RelayFailoverSupervisor.replace}("先建新、成功再关旧" + + * 冷却表 + `[relay-switch]` 日志 + `switches` 计数),⛔ 不再在本函数里自己关旧通道。 + */ + const refreshOverlay = async (): Promise => { + if (!dialerEnabled) return + const next = await resolveOverlayRelay(overlayResolveOpts()) + const cur = failover.channel?.url ?? relayUrl + if (next.url === '' || next.url === cur) return + overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${next.url}(source=${next.source})⇒ 换拨号通道`) + /** + * 序⑧(D1):显式声明 `'directory'` —— 这条换址的触发条件("目录里的地址变了")与 + * "旧通道是否可用"**无关** ⇒ ⛔ **没有打破冷却的权力**(有的话,"当前站在 106、目录首位是 47" + * 的每一轮巡检都会把刚冷却的 47 换回来 = 两位互相抢 = D5 想防的抖动风暴)。 + */ + await failover.replace(next.url, `目录地址变更(source=${next.source})`, 'directory') + } + if (dialerEnabled) { + const first = setTimeout(() => void refreshOverlay(), OVERLAY_REFRESH_FIRST_MS) + first.unref() + const timer = setInterval( + () => void refreshOverlay(), + Math.max(OVERLAY_REFRESH_MIN_MS, relayResolved.refreshAfterSeconds * 1000), + ) + timer.unref() + } + const rendezvous = new RendezvousRegistry([ + new LocalRendezvous((id) => hostAddresses.get(id)), + new ManagerSshRendezvous({ + target: config.clusterRendezvousUrl, + addressOf: (id) => hostAddresses.get(id), + }), + // `relay`(R3 / R5):**既没配 `/status`、也没启用拨号通道 ⇒ 不注册** ⇒ `via='relay'` + // 回落到 `manager-ssh`(`hostsProvider` 的回退分支),即"没配就完全等同今天"。 + ...(config.relayStatusUrl === '' && currentDialer() === undefined + ? [] + : [ + new RelayRendezvous({ + dialTargetUrl: relayUrl, + // 键一律是**逻辑名** `/`(P0-3):跨网同名 host 不会互相命中。 + addressOf: (name) => { + const port = hostAgentPorts.get(name) + if (port === undefined) return undefined + // ① 首选**拨号通道**:落点在 Manager 自己本机 ⇒ relay 换机器也成立(R5) + // ⚠️ 这里同时是**控制面侧的跨网门**:通道只声明一张网,跨网申请**必然回 undefined** + // (`RelayDialer` 里带日志地拒掉)⇒ 绝不会把别张网的落点发出去。 + const dialed = currentDialer()?.localPortFor(name, port) + if (dialed !== undefined) return `127.0.0.1:${dialed}` + // ② 回退:relay 快照里的动态回环口号(仅在 relay 与 Manager 同机时可用) + const hit = relayEndpoints.get(`${name}:${port}`) + return hit === undefined ? undefined : `127.0.0.1:${hit.localPort}` + }, + online: (name) => { + const port = hostAgentPorts.get(name) + if (port === undefined) return false + /** + * ① **有新鲜快照** ⇒ 沿用 R3 的「先问在线、再给地址」:能明确区分"离线"与"端口没了", + * 失败语义更准(离线走 `undefined`,不必真去拨一次)。 + */ + if (Date.now() - relaySnapshotAt <= RELAY_SNAPSHOT_TTL_MS) { + const hit = relayEndpoints.get(`${name}:${port}`) + if (hit !== undefined) return hit.online === true + } + /** + * ② **没有快照**(relay 在别的机器上 / 未配 `/status`)⇒ 交给**拨号本身**判定: + * 离线会拿到 `target-offline`,是**带原因的显式失败**,不会静默路由到别处。 + */ + return currentDialer() !== undefined + }, + }), + ]), + ]) const hostsProvider = async (): Promise => { - for (const row of await db.listDshHosts()) { + const rows = await db.listDshHosts() + /** + * **逻辑名 = 控制面里"这台 host"的唯一键**(P0-3)。 + * + * 网络维度取自 `dsh_hosts.network_id`(P0-1 迁的列,**本步才真正被消费**)—— + * 在此之前它只是"表里有、没人读",于是控制面所有键(地址表 / via 表 / 端口表 / + * relay 端点表 / 拨号口池)都按**裸 hostId**,两张网各有一台同名 host 时会**互相覆盖**。 + */ + const nameOf = (row: { id: string; networkId: string }): string => + logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id) + // 先同步地址表(会合实现不直接连 DB),再逐行解析 —— 两遍是为了让 `resolve()` 只看纯内存表。 + for (const row of rows) { + const name = nameOf(row) + const address = parseReachability(name, row.endpoint, row.via).address + hostAddresses.set(name, address) + hostVia.set(name, row.via) + const port = addressPort(address) + if (port === undefined) hostAgentPorts.delete(name) + else hostAgentPorts.set(name, port) + } + for (const row of rows) { + // `via` 认不出来(老行 / 手写错值)⇒ 回退到"今天唯一在跑的实现",**不抛**(过渡期要能跑)。 + const impl = rendezvous.get(row.via) ?? rendezvous.get(VIA_MANAGER_SSH) hostDirectory.set(row.id, { hostId: row.id, - agentUrl: row.endpoint, + agentUrl: row.endpoint, // 保留:`agentBaseUrlOf` 的回退路径,S2 阶段与 reachability 等价 + reachability: impl === undefined ? undefined : await impl.resolve(nameOf(row)), token: row.agentToken, instanceHost: config.clusterInstanceHost, + // 表里的声明(P0-3):`agentBaseUrlOf` 靠它区分"解析不出"与"可以回落 endpoint"。 + via: row.via, }) } return [...hostDirectory.values()] @@ -326,6 +734,32 @@ export async function buildServer(config: ServerConfig): Promise`,而 Manager + * 要拨的是 relay 为那个端口开的**动态回环口号** ⇒ 必须按 `(hostId, 实例端口)` 查 + * relay 快照翻译。查不到就回 `undefined`(= 实例不可达)—— **绝不原样透传**, + * 那会拨到一个"在 Manager 上毫无意义"的端口上。 + * + * 非 `relay` 形态(`local` / `manager-ssh`)**原样返回**:隧道是同号反向转发, + * 两边口号相同 ⇒ 不需要也没法翻译(这正是 S3 端口区间隔离能根治撞号的前提)。 + * + * ⚠️ 判据必须取 **`dsh_hosts.via` 原文**(`hostVia`),**不能**取 `reachability.via`: + * 后者在 host 离线 / relay 快照陈旧时为 `undefined`,若据此判"不是 relay ⇒ 原样透传", + * 就会**失败开放** —— 把 Worker 侧口号打到 Manager 本机(2026-09-16 实测:浏览器只见 + * 空响应、平台零日志)。未知 host 仍按老行为原样返回(单机 / 默认 host 不受影响)。 + */ + translateEndpoint: (hostId, ep) => { + if (hostVia.get(hostId) !== VIA_RELAY) return ep + // ① 首选**拨号通道**(R5):落点在 Manager 自己本机 ⇒ relay 换机器也成立 + const dialed = currentDialer()?.localPortFor(hostId, ep.port) + if (dialed !== undefined) return { host: '127.0.0.1', port: dialed } + // ② 回退:relay 快照里的动态回环口号。查不到 ⇒ **失败关闭**(实例此刻不可达), + // 绝不回退成 Worker 侧口号 —— 那正是"浏览器只见空响应、平台零日志"的成因。 + const hit = relayEndpoints.get(`${hostId}:${ep.port}`) + return hit === undefined ? undefined : { host: '127.0.0.1', port: hit.localPort } + }, }), db, { @@ -352,6 +786,39 @@ export async function buildServer(config: ServerConfig): Promise | undefined + let directoryRefreshedAt = 0 + const ensureHostDirectory = async (hostId: string): Promise => { + if (hostDirectory.has(hostId)) return + if (directoryRefreshing !== undefined) return directoryRefreshing + if (Date.now() - directoryRefreshedAt < DIRECTORY_COOLDOWN_MS) return + directoryRefreshedAt = Date.now() + // **可观测性**(别省这一行):本行出现 = 真的走进了"未命中 ⇒ 补齐"这条路径, + // 也就是"旧实现会静默把请求打到本机 agent"的那个窗口。没有它,"这次为什么没 404" + // 只能靠推断;本项目吃过的静默失效亏,根子都在"关键分支不可观测"。 + console.log(`[cluster] host 目录未命中 ${hostId} ⇒ 按需补齐(重启窗口期常见)`) + directoryRefreshing = hostsProvider() + .then(() => undefined) + .finally(() => { + directoryRefreshing = undefined + }) + return directoryRefreshing + } const userFs = createUserFs(config, { hostIdFor: hostIdForFile, agentFor: (hostId: string) => { @@ -359,6 +826,7 @@ export async function buildServer(config: ServerConfig): Promise Number(v.trim())) .filter((v) => Number.isInteger(v) && v > 0), ] - const tunnel = - tunnelTarget === '' + const tunnel: WorkerTunnel | undefined = + rendezvousRaw === '' ? undefined - : new SshTunnel({ - target: tunnelTarget, - identity: - options.tunnelIdentity ?? - process.env.DSHS_TUNNEL_IDENTITY ?? - `${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`, - controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`, - staticPorts, - }) + : isRelayUrl(rendezvousRaw) + ? new RelayTunnel({ + url: rendezvousRaw, + hostId: options.hostId, + // 密钥必须显式给:**缺了就抛** —— 静默降级到别的传输会让"以为切过去了"变成假象。 + secret: (() => { + const secret = (process.env.DSHS_RELAY_SECRET ?? '').trim() + if (!/^[0-9a-fA-F]{64}$/.test(secret)) { + throw new Error('DSHS_RENDEZVOUS_URL 用了 relay(ws/wss)⇒ 必须同时给 DSHS_RELAY_SECRET(64 hex)') + } + return secret + })(), + staticPorts, + /** + * 序⑦ · **C2 装配点:中继失败切流**。 + * + * ⛔ **不改会合面**:起始地址仍然是上面那个 `rendezvousRaw`(= `DSHS_RENDEZVOUS_URL` + * 的既有取值顺序,一行没动)。这里只补一件事 —— 起始那台**不健康**时, + * 从**同一份引导链**(`listOverlayRelayCandidates`)里换到下一条。 + * + * 为什么候选链不走 `rendezvousRaw`:那是"当前连哪台"的答案,不是"还有哪些台"的清单; + * 而链是**已签名目录 / 内置种子**的产物 ⇒ ⛔ 不可能指向任意 url(引不出任意重定向)。 + * 链里取不到候选 ⇒ 什么都不做(D6),**行为与改造前逐字一致**。 + */ + failover: { + candidates: async () => + ( + await listOverlayRelayCandidates({ + envUrl: '', + seeds: overlayEnvSeeds(), + trustedKeys: overlayEnvTrustedKeys(), + cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '', + log: (line: string) => console.error(line), + }) + ).urls, + }, + /** + * 序③:**本机节点身份**。配了 `DSHS_OVERLAY_NODE_KEY_FILE` + `..._NODE_GRANT_FILE` + * 才会带上(缺省 = 只做 HMAC,过渡期形态不变)。 + * ⚠️ **配了却读不出来 ⇒ 起动即抛**(见 `identity.ts#loadClientIdentity`)—— + * 静默退化成"没身份"会让"凭据坏了"表现成"一切正常",等 relay 一开强制就整台失联。 + */ + identity: loadClientIdentity({ + keyFile: config.overlayNodeKeyFile, + grantFile: config.overlayNodeGrantFile, + log: (line: string) => console.error(line), + }), + log: (line: string) => console.error(line), + }) + : new SshTunnel({ + target: normalizeTunnelTarget(rendezvousRaw), + identity: + options.tunnelIdentity ?? + process.env.DSHS_TUNNEL_IDENTITY ?? + `${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`, + controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`, + staticPorts, + }) let tunnelReady = tunnel === undefined /** diff --git a/src/worker/relay-tunnel.ts b/src/worker/relay-tunnel.ts new file mode 100644 index 0000000..c556c90 --- /dev/null +++ b/src/worker/relay-tunnel.ts @@ -0,0 +1,224 @@ +/** + * Worker 侧的**中继隧道**(覆盖网络 R4)—— `SshTunnel` 的同面替代品。 + * + * ## 它替掉的是什么 + * 今天"Manager 连不到 Worker"靠的是 `ssh -R`:Worker 拨 Manager 的 sshd,把两边的 + * `127.0.0.1:` 接起来。代价是**长期依赖 sshd + root 凭据 + 一条公网 SSH 口**。 + * 本类改用自研 relay:Worker 只拨一条 wss,relay 为每个注册端口在**它自己的回环**上开一条 + * 监听 ⇒ 对 Manager 而言"又有一个 `host:port` 可以拨",形态与 sshd 版同形(S0 抽 `Reachability` + * 的全部意义)。**Worker 依旧零入站口**。 + * + * ## 与 `SshTunnel` 的**唯一实质差别**(改代码前务必知道) + * | | 端口号 | 谁来翻译 | + * |---|---|---| + * | `SshTunnel` | **同号**(`-R p:127.0.0.1:p`)⇒ Manager 侧端口 = Worker 侧端口 | 不需要翻译 | + * | `RelayTunnel` | relay **动态分配**(实测 42067)⇒ Manager 侧端口 ≠ Worker 侧端口 | **Manager 侧**按 `(hostId, port)` 查 relay `/status` 翻译 | + * + * ⇒ 所以本类**只负责"把这个口声明给 relay"**,绝不把 relay 的回环口号回传给 agent 去上报 + * (那会把"Manager 侧地址观"塞进 Worker,relay 换机部署时两头都要改)。 + * Manager 侧的翻译在 `web/server.ts` 的 relay 快照 + `RemoteSpawner` 的 endpoint 翻译器里。 + * + * ## 动态端口(实例端口) + * 实例端口是 `findFreePort()` 运行期分配的 ⇒ 用 `PORT_ADD` / `PORT_DEL`(对应 ssh 的 + * `-O forward` / `-O cancel`),**不是**开局声明一整段(那样 relay 要为段内每个口开监听)。 + * 断链后由 `RelayClient` 自动重放(见 `client.ts#replayDynamicPorts`)—— 这是"注册恢复"那一层。 + * + * @module dshs/worker/relay-tunnel + */ + +import { RelayClient, waitUpOnStatus, type RelayClientOptions, type WebSocketCtor } from '../net/relay/client.js' +import { RelayFailoverSupervisor } from '../net/relay/switcher.js' +import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/switcher.js' +import type { WorkerTunnel } from './tunnel.js' + +export interface RelayTunnelOptions { + /** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */ + url: string + /** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */ + hostId: string + /** 与 relay 的预共享密钥(hex)。**缺失必须吵** —— 静默回退到别的传输比报错危险得多。 */ + secret: string + /** + * **本机节点身份**(覆盖网络 序③):私钥 PEM + 入网凭据。 + * + * 缺省 ⇒ `HELLO` 不带身份字段(过渡期形态,relay 未强制时照旧可用); + * 给了 ⇒ relay 侧可按**受信签名者**独立验证"这台机器被授权进入这张网", + * 而不再只依赖那张放在控制面上的密钥表。 + */ + identity?: RelayClientOptions['identity'] + /** 启动即声明的端口(agent 自身;还可带控制面 PG 等)。 */ + staticPorts?: number[] + /** 首连等待上限(ms)。默认 12s:跨云一次 RTT 也就几十 ms,等这么久只可能是真连不上。 */ + upTimeoutMs?: number + log?: (line: string) => void + webSocketCtor?: WebSocketCtor + /** + * **中继失败切流**(序⑦ · C2 装配点)。给了才启用;不给 ⇒ 行为与改造前**逐字一致** + * (启动解析一次、此后钉死 —— 这正是改造前 E9 后半不成立的原因)。 + * + * ⛔ **候选链只来自"同一份引导链"**({@link listOverlayRelayCandidates})⇒ 只可能是 + * **已签名目录 / 内置种子**里的地址,⛔ 不接受任意 url(否则就是任意重定向 = 真 R5)。 + * ⚠️ 它**不改会合面**:起始地址仍由 `DSHS_RENDEZVOUS_URL` 决定(见 §3.2), + * 这里只补上"起始那台不健康时换到链里的下一条"。 + */ + failover?: { + candidates: () => Promise + thresholds?: Partial + } +} + +/** 内部:一条通道 + 它自己的 `RelayClient`。 */ +type TunnelChannel = RelayChannelHandle & { client: RelayClient } + +function healthOf(client: RelayClient): { state: string; attempts: number; unhealthyForMs: number } { + const st = client.status() + return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs } +} + +export class RelayTunnel implements WorkerTunnel { + private readonly opts: RelayTunnelOptions + private readonly forwarded = new Set() + private readonly log: (line: string) => void + private readonly failover: RelayFailoverSupervisor | undefined + /** 起始通道(监管器不在场时它就是唯一通道)。 */ + private readonly initialChannel: TunnelChannel + + constructor(options: RelayTunnelOptions) { + this.opts = options + this.log = options.log ?? ((line: string) => process.stdout.write(`${line}\n`)) + this.initialChannel = this.channelFor(this.buildClient(options.url), options.url) + if (options.failover === undefined) { + this.failover = undefined + return + } + const sup = new RelayFailoverSupervisor({ + /** + * **先建新、成功再关旧**:新客户端必须先真的到 `up`,本函数才返回句柄; + * 到不了 ⇒ 返回 `undefined` ⇒ 监管器**保持原通道**(D4)。 + * 新客户端**一次就把已声明的端口全带上**(`staticPorts` + 运行期 `forwarded`), + * 否则切换完成后"实例面端口没了"—— 那是比不切更糟的结果。 + */ + open: async (url) => { + const next = this.buildClient(url) + next.start() + const ok = await this.waitUpOn(next, this.opts.upTimeoutMs ?? 12_000) + if (!ok) { + next.stop() + return undefined + } + return this.channelFor(next, url) + }, + candidates: options.failover.candidates, + log: this.log, + thresholds: options.failover.thresholds, + }) + sup.seed(this.initialChannel) + sup.start() + this.failover = sup + } + + /** 当前通道(监管器在场时以它为准 —— "当前是谁"只有**一个**权威来源)。 */ + private get channel(): TunnelChannel { + return (this.failover?.channel as TunnelChannel | undefined) ?? this.initialChannel + } + + private channelFor(client: RelayClient, url: string): TunnelChannel { + return { + url, + client, + health: () => healthOf(client), + close: () => client.stop(), + } + } + + private buildClient(url: string): RelayClient { + return new RelayClient({ + url, + hostId: this.opts.hostId, + secret: this.opts.secret, + ports: [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b), + identity: this.opts.identity, + log: this.log, + webSocketCtor: this.opts.webSocketCtor, + }) + } + + private get client(): RelayClient { + return this.channel.client + } + + /** 当前"已声明给 relay"的端口(静态 + 运行期),诊断用。 */ + get ports(): number[] { + return [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b) + } + + /** **幂等**:已启动就只等它到 `up`(断链后 agent 的自愈走的正是这条路径)。 */ + async ensureMaster(): Promise { + this.client.start() + await this.waitUp(this.opts.upTimeoutMs ?? 12_000) + } + + async isMasterAlive(): Promise { + return this.client.status().state === 'up' + } + + /** 加一个转发(实例起来时)。失败**不抛**:实例在本机照样可用,只是跨机代理这一跳不可用。 */ + async forward(port: number): Promise { + if (this.forwarded.has(port)) return true + const ok = await this.client.addPort(port) + if (ok) this.forwarded.add(port) + return ok + } + + /** 撤销一条转发(实例停止时)。 */ + async cancel(port: number): Promise { + if (!this.forwarded.has(port)) return + if (await this.client.removePort(port)) this.forwarded.delete(port) + } + + async close(): Promise { + this.failover?.stop() + this.client.stop() + this.forwarded.clear() + } + + /** + * 换址时用的**非抛版**等待(序⑦):`open()` 要的是"能不能起来"这个布尔, + * 而不是异常 —— 起不来就返回 `false`,由监管器决定"保持原通道"(D4)。 + * + * 🔴 **序⑨ · RC-1(C2 装配点)**:实现已抽到 {@link waitUpOnStatus}(三个装配点共用一份,D7); + * 相对改造前的唯一差别 = **终态失败(死候选)立即 `false`**,⛔ 不再白等满 `upTimeoutMs`。 + */ + private async waitUpOn(client: RelayClient, timeoutMs: number): Promise { + return waitUpOnStatus(client, timeoutMs, { + onDead: (st) => + this.log( + `[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` + + `burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`, + ), + }) + } + + /** 轮询等 `up`(`RelayClient` 没有 up 事件;重连由它自己退避驱动,这里只负责等)。 */ + private async waitUp(timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + for (;;) { + if (this.client.status().state === 'up') return + if (Date.now() >= deadline) { + const st = this.client.status() + throw new Error(`relay 未在 ${timeoutMs}ms 内就绪(state=${st.state} lastError=${st.lastError ?? '-'})`) + } + await new Promise((r) => setTimeout(r, 100)) + } + } +} + +/** + * 这个会合地址是不是"走 relay"(覆盖网络 R4)。 + * + * 判据只看 scheme:`ws://` / `wss://` = relay;`ssh://` / 裸 `user@host:port` = 旧隧道。 + * 于是一个环境变量就能在两种传输之间来回切(**删掉 relay 那行即回滚**,不需要回滚代码)。 + */ +export function isRelayUrl(raw: string): boolean { + return /^wss?:\/\//i.test(raw.trim()) +} diff --git a/src/worker/tunnel.ts b/src/worker/tunnel.ts index aa575c2..4c2adb6 100644 --- a/src/worker/tunnel.ts +++ b/src/worker/tunnel.ts @@ -57,7 +57,29 @@ export interface TunnelOptions { sshBin?: string } -export class SshTunnel { +/** + * Worker 侧隧道的**共同面**(覆盖网络 R4)。 + * + * 为什么要有它:`SshTunnel`(拨 sshd)与 `RelayTunnel`(拨 relay)**必须能被 agent 一视同仁地使唤** —— + * agent 只做三件事:开起来、加/减转发、判活。传输换谁,这三件事的语义都不变 ⇒ 换传输就只是 + * `DSHS_RENDEZVOUS_URL` 里 scheme 的差别(`ssh://…` ↔ `wss://…`),**agent 的代码不需要再动**。 + */ +export interface WorkerTunnel { + /** 建立(或复用)长连接。**幂等**:已就绪时直接返回。 */ + ensureMaster(): Promise + /** 长连接是否还活着(**判据在传输侧**,不能只看本地记账 —— 见 `SshTunnel` 的注释)。 */ + isMasterAlive(): Promise + /** 运行期加一条转发。失败返回 `false`(**不抛**):跨机代理降级 ≠ 本机功能降级。 */ + forward(port: number): Promise + /** 撤销一条转发。 */ + cancel(port: number): Promise + /** 关闭长连接(进程退出时)。 */ + close(): Promise + /** 当前已转发的端口(对账自愈用)。 */ + readonly ports: number[] +} + +export class SshTunnel implements WorkerTunnel { /** 内部一律用**已补默认值**的具体类型(否则 `sshBin` 会是 `string | undefined`)。 */ private readonly opts: { target: string diff --git a/test/instance-port.test.mjs b/test/instance-port.test.mjs new file mode 100644 index 0000000..62ba24c --- /dev/null +++ b/test/instance-port.test.mjs @@ -0,0 +1,72 @@ +/** + * 覆盖网络 S3 · 实例端口区间单测(不连外网、只在本机回环上 bind)。 + * 运行:node --test test/instance-port.test.mjs(已含在 npm test / npm verify 中) + * + * ## 这个测试防的是什么 + * `listen(0)` 让**每台 worker 各自**随机取端口,而跨机实例的隧道落点全挤在 Manager 的 + * `127.0.0.1` 上 ⇒ 两台 worker 取到同号就撞号,`ssh -R` 失败还是**静默**的, + * Manager 会按该端口拨到**别人的实例**(2026-09-16 实测)。 + * + * 所以这里要钉死两件事: + * ① 取到的端口**必须落在配置区间内**(区间隔离的前提) + * ② 起点被占 ⇒ **跳到下一个**,绝不"回落到 `listen(0)`" + */ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { createServer } from 'node:net' + +import { findFreePortInRange, findInstancePort } from '../lib/supervisor/spawn.js' + +/** 占住一个回环端口,返回 `{ port, release }`。 */ +function holdPort() { + return new Promise((resolve, reject) => { + const server = createServer() + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + const port = typeof address === 'object' && address !== null ? address.port : 0 + resolve({ port, release: () => new Promise((r) => server.close(() => r())) }) + }) + }) +} + +test('端口区间:取到的端口必须落在区间内', async () => { + // 用打洞拿到的空闲端口当区间起点 ⇒ 测试不依赖"20000 一定空着"这种环境假设 + const { port, release } = await holdPort() + try { + const got = await findFreePortInRange(port, 200) + assert.ok(got >= port && got < port + 200, `越界:${got} 不在 [${port}, ${port + 200})`) + } finally { + await release() + } +}) + +test('端口区间:起点被占用 ⇒ 跳过它,不回落到 listen(0)', async () => { + const { port, release } = await holdPort() + try { + const got = await findFreePortInRange(port, 200) + assert.notEqual(got, port, '起点被占却仍返回了它 ⇒ 会撞号') + assert.ok(got >= port && got < port + 200) + } finally { + await release() + } +}) + +test('端口区间:区间耗尽 ⇒ 抛错(禁止静默退回 listen(0))', async () => { + await assert.rejects(() => findFreePortInRange(20000, 0), /区间已耗尽/) +}) + +test('findInstancePort:未配区间(base<=0)⇒ 退回旧的 listen(0) 行为', async () => { + const got = await findInstancePort(0, 1000) + assert.ok(got > 0 && got < 65536, `应拿到合法端口,实际 ${got}`) +}) + +test('findInstancePort:配了区间 ⇒ 结果落在区间内', async () => { + const { port, release } = await holdPort() + try { + const got = await findInstancePort(port, 128) + assert.ok(got >= port && got < port + 128, `越界:${got}`) + } finally { + await release() + } +}) diff --git a/test/overlay-auth.test.mjs b/test/overlay-auth.test.mjs new file mode 100644 index 0000000..ee24cbe --- /dev/null +++ b/test/overlay-auth.test.mjs @@ -0,0 +1,406 @@ +/** + * 覆盖网络 · **② P0-3 名字解析与授权** 单测 —— 逻辑名是不是"唯一入口"、跨网是不是**真拒绝**。 + * + * ## 这个文件要回答的两个问题(= 交接单 §4 Step 3 的两条 + §5 判据 3) + * 1. **`place` / `resolve` 的唯一入口收不收逻辑名** `/`? + * —— 只收裸 `hostId` 时,网络维度得由**每个调用方**自己补;两张网各有一台 `w-1` 就会**串网**。 + * ⛔ 判据不是"代码里写了逻辑名",而是**拿两张同名 host 实测:各解析各的,谁也不覆盖谁**。 + * 2. **跨网访问在 relay / 控制面被拒**,且**不泄露**目标在哪张网? + * —— 判据:`u:A` 的节点**看不到、也到不了** `u:B` 的节点;`ops` 对用户网**默认不可见**。 + * "看不到" = 对"在别张网"与"根本不存在"的响应**逐字相同**(拿一串猜测的 hostId 打过来 + * 也枚举不出别张网的成员)。 + * + * 运行:`node --test test/overlay-auth.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。 + * + * @module test/overlay-auth + */ + +import assert from 'node:assert/strict' +import { randomBytes } from 'node:crypto' +import { createServer as createTcpServer, connect } from 'node:net' +import { test } from 'node:test' + +import { RelayClient } from '../lib/net/relay/client.js' +import { RelayDialer } from '../lib/net/relay/dialer.js' +import { RelayServer } from '../lib/net/relay/server.js' +import { OPS_NETWORK, assertSameNetwork, logicalName, parseLogicalName } from '../lib/net/relay/network.js' +import { agentBaseUrl, agentBaseUrlOf, parseReachability, VIA_LOCAL, VIA_RELAY } from '../lib/net/reachability.js' +import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../lib/net/rendezvous.js' + +const BASE = 47000 +const SPAN = 200 +const PATH = '/dshs-relay' +const U_A = 'u:alpha' +const U_B = 'u:beta' + +/* ─────────────────────────── 工具 ─────────────────────────── */ + +/** + * 在 `[lo, hi)` 里挑一个空闲口,**返回端口号**(不是 server 对象 —— 传错了会变成 + * `ports: [object Object]`,服务端只回一个 `bad-port`,看着像"网络不通")。 + */ +async function listenInRange(server, lo, hi) { + for (let p = lo; p < hi; p++) { + const ok = await new Promise((resolve) => { + const onErr = () => { + server.off('error', onErr) + resolve(false) + } + server.once('error', onErr) + server.listen(p, '127.0.0.1', () => { + server.off('error', onErr) + resolve(true) + }) + }) + if (ok) return server.address().port + } + throw new Error(`no free port in ${lo}..${hi}`) +} + +async function waitFor(fn, ms = 3000) { + const until = Date.now() + ms + while (Date.now() < until) { + if (fn()) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false +} + +/** + * 回显服务:收到的每个 chunk 都回 `:<原字节>`。 + * ⚠️ 与 `overlay-network.test.mjs` 同款(两个文件口径一致,免得"同一条判据两份行为")。 + */ +function taggedEcho(tag) { + return createTcpServer((s) => s.on('data', (c) => s.write(`${tag}:${c.toString('utf8')}`))) +} + +/** 拨号流往返:写进去、读回(前缀长度显式传入,避免"读到一半就认为对了")。 */ +function dialRoundTrip(duplex, text, prefixLen = 0, ms = 5000) { + return new Promise((resolve, reject) => { + let got = '' + const timer = setTimeout(() => { + duplex.destroy() + reject(new Error(`dial roundTrip timeout (got ${got.length}/${text.length + prefixLen})`)) + }, ms) + duplex.on('data', (chunk) => { + got += chunk.toString('utf8') + if (got.length >= text.length + prefixLen) { + clearTimeout(timer) + resolve(got) + } + }) + duplex.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + duplex.write(text) + }) +} + +/* ─────────── A1:同网断言(控制面侧那道门的公共件) ─────────── */ + +test('A1 assertSameNetwork:同网静默通过,跨网**抛**且点名两边是哪张网', () => { + assert.doesNotThrow(() => assertSameNetwork(OPS_NETWORK, OPS_NETWORK)) + assert.doesNotThrow(() => assertSameNetwork(U_A, U_A)) + // 抛而不是回 false:回 false 的写法会让"跨网拒绝"在日志里什么都不留(= 静默失效) + assert.throws(() => assertSameNetwork(U_A, U_B), /跨网/) + assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_A)) + assert.throws(() => assertSameNetwork(U_A, U_B), new RegExp(U_B)) +}) + +/* ─────────── A2:place 的唯一入口收逻辑名 ─────────── */ + +test('A2 parseReachability 的第一个参数是**逻辑名**:网络段由它切,调用方不许拼', () => { + const r = parseReachability(logicalName(U_A, 'w-1'), 'http://127.0.0.1:19000', VIA_RELAY) + assert.equal(r.hostId, 'w-1', 'hostId 必须是**裸**的(网络另存一栏,两者分开表达)') + assert.equal(r.networkId, U_A) + assert.equal(agentBaseUrl(r), 'http://127.0.0.1:19000', '取址与 S0/S2 逐字一致(网络维度不进地址)') + // 裸 hostId 仍兼容 ⇒ 落 ops(过渡期:现网所有调用方一个字都不用改) + assert.equal(parseReachability('w-47', 'http://127.0.0.1:19100', VIA_LOCAL).networkId, OPS_NETWORK) + // 非法网络段 ⇒ **抛**(配错别伪装成"这张网里没有它") + assert.throws(() => parseReachability('UPPER/w-1', 'http://x:1', VIA_LOCAL), /网络段/) +}) + +/* ─────────── A3:resolve 的唯一入口收逻辑名,两张同名 host 不互相覆盖 ─────────── */ + +test('A3 两张网各有一台同名 w-1 ⇒ 解析各查各的(键是逻辑名,不是裸 hostId)', async () => { + const table = new Map([ + [logicalName(OPS_NETWORK, 'w-1'), '127.0.0.1:19001'], + [logicalName(U_A, 'w-1'), '127.0.0.1:19002'], + ]) + const reg = new RendezvousRegistry([ + new LocalRendezvous((name) => table.get(name)), + new ManagerSshRendezvous({ target: 't', addressOf: (name) => table.get(name) }), + ]) + for (const impl of [reg.get(VIA_LOCAL), reg.get('manager-ssh')]) { + const ops = await impl.resolve(logicalName(OPS_NETWORK, 'w-1')) + const alpha = await impl.resolve(logicalName(U_A, 'w-1')) + assert.equal(ops.networkId, OPS_NETWORK) + assert.equal(ops.address, '127.0.0.1:19001', 'ops 的 w-1 拿到的是 ops 的落点') + assert.equal(alpha.networkId, U_A) + assert.equal(alpha.address, '127.0.0.1:19002', `${impl.id}:u:alpha 的 w-1 拿到的是 u:alpha 的落点(没被 ops 覆盖)`) + assert.notEqual(ops.address, alpha.address, '同名 host 分属两张网 ⇒ 落点必须不同') + } + // 查表键是逻辑名 —— 这条断言看着废话,但正是"只按裸 hostId 建键"会炸的地方 + assert.equal(await reg.get(VIA_LOCAL).resolve('w-1'), undefined, '裸 hostId 不该命中任何逻辑名条目') +}) + +/* ─────────── A4:via=relay 时禁止回落到 endpoint ─────────── */ + +test('A4 via=relay 且解析不出落点 ⇒ **抛**(不许回落到 endpoint = relay 落点)', () => { + const relayHost = { hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000', via: VIA_RELAY } + assert.throws(() => agentBaseUrlOf(relayHost), /拒绝回落到 endpoint/) + // 一旦解析成功 ⇒ 照常取址,且**优先**于 endpoint + const ok = { + ...relayHost, + reachability: { hostId: 'w-106', networkId: OPS_NETWORK, via: VIA_RELAY, address: '127.0.0.1:42067', scheme: 'http' }, + } + assert.equal(agentBaseUrlOf(ok), 'http://127.0.0.1:42067') + // 非 relay 语义(同机直连 / ssh 隧道)照旧回落 endpoint —— 这条不能被误伤 + assert.equal(agentBaseUrlOf({ hostId: 'w-47', agentUrl: 'http://127.0.0.1:19100', via: VIA_LOCAL }), 'http://127.0.0.1:19100') + assert.equal(agentBaseUrlOf({ hostId: 'w-106', agentUrl: 'http://127.0.0.1:19000' }), 'http://127.0.0.1:19000') +}) + +/* ─────────── A5:控制面侧的跨网门(RelayDialer 口池) ─────────── */ + +test('A5 RelayDialer:落点口**真的能拨通**(DIAL target 必须是裸 hostId)+ 跨网拒 + 不占槽位', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const logs = [] + const echo = taggedEcho('ops') + const echoPort = await listenInRange(echo, BASE + 40, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['wx', wSecret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Map([[OPS_NETWORK, new Set(['manager'])]]), + log: (line) => logs.push(line), + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ + url, + hostId: 'wx', + secret: wSecret, + ports: [echoPort], + networkId: OPS_NETWORK, + log: () => {}, + }) + const mClient = new RelayClient({ + url, + hostId: 'manager', + secret: mSecret, + ports: [], + dialer: true, + networkId: OPS_NETWORK, + log: (line) => logs.push(`[m] ${line}`), + }) + assert.equal(mClient.networkId, OPS_NETWORK, '拨号通道声明的网要从客户端读(不另配一份)') + const dialer = new RelayDialer({ client: mClient, portBase: BASE + 60, portSpan: 8, poolSize: 4, log: (l) => logs.push(l) }) + worker.start() + mClient.start() + // ⚠️ **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)—— + // 少了这一句,本条会以"同网拿不到落点"的样子失败,看着像跨网判据炸了,其实是没绑池。 + await dialer.start() + t.after(async () => { + worker.stop() + mClient.stop() + dialer.close() + await echo.close() + await srv.stop() + }) + assert.ok(await waitFor(() => srv.isOnline('wx', OPS_NETWORK)), `worker 未注册:${logs.slice(-6).join(' | ')}`) + assert.ok(await waitFor(() => srv.isOnline('manager', OPS_NETWORK)), 'manager 未注册') + + const name = logicalName(OPS_NETWORK, 'wx') + const local = dialer.localPortFor(name, echoPort) + assert.equal(typeof local, 'number', '同网必须给落点') + + /** + * 🔴 **本条的承重判据**:真连一次落点口 ⇒ 触发 `onConn` ⇒ 走一次**真 DIAL**。 + * + * 为什么不能只测 `localPortFor` 的返回值:键从 P0-3 起是**逻辑名**,而 `DIAL.target` 要的是 + * **裸 hostId** —— 漏剥网络段时服务端按 `ops/ops/wx` 找会话 ⇒ 回 `target-offline` + * (看着像"节点离线",节点其实好好在册)。这个 bug **只测返回值是抓不到的** + * (2026-09-17 线上实测踩过一次:`refused: 8 / streamsOpened: 0`)。 + */ + const sock = connect(local, '127.0.0.1') + const got = await new Promise((resolve, reject) => { + let acc = '' + const timer = setTimeout(() => { + sock.destroy() + reject(new Error(`落点口未拨通,最近日志:${logs.slice(-6).join(' | ')}`)) + }, 3000) + sock.on('data', (b) => { + acc += b.toString('utf8') + if (acc.length >= 'ops:ping'.length) { + clearTimeout(timer) + resolve(acc) + } + }) + sock.on('error', (e) => { + clearTimeout(timer) + reject(e) + }) + sock.on('connect', () => sock.write('ping')) + }) + assert.equal(got, 'ops:ping', '落点口必须真的把字节送到 worker 的本地端口(⇒ DIAL target 是裸 hostId)') + sock.destroy() + + // 跨网:**回 undefined**(失败关闭),不是"给个口让它去撞" + assert.equal(dialer.localPortFor(logicalName(U_A, 'wx'), echoPort), undefined) + assert.ok( + logs.some((l) => l.includes('跨网拒绝') && l.includes(U_A)), + `跨网拒绝必须留日志(点名两边是哪张网),实测:${logs.join(' | ')}`, + ) + // 不占槽位:同网再取还是**同一个**口(池大小为 4,若被跨网请求污染,这里会变成新口) + assert.equal(dialer.localPortFor(name, echoPort), local) +}) + +/* ─────────── A6:合体判据 —— 看不到 + 到不了(真 server / 真拨号) ─────────── */ + +test('A6 跨网:到不了,且**分辨不出**"在别张网"与"根本不存在"(逐字相同的响应)', async (t) => { + const secrets = { + alpha: randomBytes(32).toString('hex'), + beta: randomBytes(32).toString('hex'), + alphaDialer: randomBytes(32).toString('hex'), + betaDialer: randomBytes(32).toString('hex'), + opsManager: randomBytes(32).toString('hex'), + } + const logs = [] + const echoA = taggedEcho('alpha') + const echoB = taggedEcho('beta') + const portA = await listenInRange(echoA, BASE + 20, BASE + SPAN - 1) + const portB = await listenInRange(echoB, BASE + 21, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + /** + * ⚠️ **序③ 起:密钥表是成员资格的唯一判据** —— 键用**逻辑名** `<网>/`(`keys.ts`), + * 所以"这台 host 属于哪张网"由**配置**决定,而不是由 HELLO 的声明决定。 + * ⇒ 本测试里每台 host 都按**它真正所在的网**登记;声明别的网会被 `network-mismatch` + * 挡在 HELLO 那一步(比 DIAL 那道门更早,也更省一次会话)。 + * 「两张网各有一台同名 `w-1` 且互不覆盖」由 **A3** 在解析入口上判(唯一入口), + * 由 **overlay-network.U5** 在真连接上判(那里两张网各有自己的 `w-1` 登记项)。 + */ + keys: new Map([ + [logicalName(U_A, 'aw1'), secrets.alpha], + [logicalName(U_B, 'bw1'), secrets.beta], + [logicalName(U_A, 'ad'), secrets.alphaDialer], + [logicalName(U_B, 'bd'), secrets.betaDialer], + [logicalName(OPS_NETWORK, 'manager'), secrets.opsManager], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + /** 三张网**各自都有合法拨号方** ⇒ 拒绝只能来自网络维度本身,不是"忘了配白名单"。 */ + dialers: new Map([ + [OPS_NETWORK, new Set(['manager'])], + [U_A, new Set(['ad'])], + [U_B, new Set(['bd'])], + ]), + log: (line) => logs.push(line), + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + + const clients = [] + const mkNode = (hostId, secret, network, ports) => { + const c = new RelayClient({ url, hostId, secret, ports, networkId: network, log: () => {} }) + clients.push(c) + c.start() + return c + } + const mkDialer = (hostId, secret, network) => { + const c = new RelayClient({ + url, + hostId, + secret, + ports: [], + dialer: true, + networkId: network, + log: (line) => logs.push(`[${hostId}] ${line}`), + }) + clients.push(c) + c.start() + return c + } + mkNode('aw1', secrets.alpha, U_A, [portA]) + mkNode('bw1', secrets.beta, U_B, [portB]) + const ad = mkDialer('ad', secrets.alphaDialer, U_A) + mkDialer('bd', secrets.betaDialer, U_B) + mkDialer('manager', secrets.opsManager, OPS_NETWORK) + + t.after(async () => { + for (const c of clients) c.stop() + await echoA.close() + await echoB.close() + await srv.stop() + }) + + assert.ok(await waitFor(() => srv.isOnline('aw1', U_A)), `u:alpha 的节点未注册:${logs.slice(-8).join(' | ')}`) + assert.ok(await waitFor(() => srv.isOnline('bw1', U_B)), `u:beta 的节点未注册:${logs.slice(-8).join(' | ')}`) + assert.ok(await waitFor(() => srv.isOnline('ad', U_A)), 'u:alpha 拨号方未注册') + assert.ok(await waitFor(() => srv.isOnline('bd', U_B)), 'u:beta 拨号方未注册') + + // ── 正证:同网全通(隔离挡的是"跨网",不是"拨号"本身) ── + const stream = await ad.openStream('aw1', portA) + assert.equal( + await dialRoundTrip(stream, 'ping-alpha', 'alpha:'.length), + 'alpha:ping-alpha', + '同网必须真的通(且字节走的是 u:alpha 自己那台,不是别网的同名机)', + ) + + // ── 反证 ①:到不了 —— `u:alpha` 的拨号方够不到只在 `u:beta` 里的节点 ── + let errForeign = '' + await assert.rejects( + () => ad.openStream('bw1', portB), + (e) => { + errForeign = e instanceof Error ? e.message : String(e) + return true + }, + 'u:alpha 拨号方不得够到 u:beta 的节点', + ) + + // ── 反证 ②:**看不到** —— "在别张网"与"根本不存在"的响应**逐字相同** ── + // 拿一个哪儿都没有的 hostId 打一次:两者若不同形,就能拿一串猜测的 hostId 把别张网**枚举**出来。 + let errGhost = '' + await assert.rejects( + () => ad.openStream('definitely-not-a-host', portB), + (e) => { + errGhost = e instanceof Error ? e.message : String(e) + return true + }, + '不存在的 hostId 必须被拒', + ) + const norm = (m) => m.replaceAll('bw1', 'X').replaceAll('definitely-not-a-host', 'X') + assert.equal( + norm(errForeign), + norm(errGhost), + `"在别张网"与"不存在"必须逐字同形(否则可枚举对端清单):\n 在别网=${errForeign}\n 不存在=${errGhost}`, + ) + // 响应里不得出现任何网络名(`u:beta` 一旦出现 ⇒ 对端清单泄露) + assert.equal(errForeign.includes(U_B), false, `响应不得含网络名,实测:${errForeign}`) + assert.equal(errForeign.includes('dialer-not-in-network'), false, `对外不得有跨网专属拒码,实测:${errForeign}`) + + // ── 反证 ③:`ops` 对用户网**默认不可见**(反向也一样) ── + // 日志里必须留得下区分(服务端可取证)—— 这是"看不到"与"查得到"的平衡点 + assert.ok( + logs.some((l) => l.includes('dialer-not-in-network') && l.includes(U_B)), + `relay 日志必须能区分"在别张网",实测:${logs.filter((l) => l.includes('DIAL')).slice(-6).join(' | ')}`, + ) +}) + +/* ─────────── A7:范围声明(Step 3 只做 P0-3) ─────────── */ + +test('A7 范围:本步只做"逻辑名 + 授权",不碰 L3 / DNS / 对端清单(D1 / D4)', async () => { + const net = await import('../lib/net/relay/network.js') + assert.equal(typeof net.logicalName, 'function') + assert.deepEqual(parseLogicalName(`${U_A}/w-1`), { network: U_A, hostId: 'w-1' }) + // ⛔ 本阶段刻意**没有**的东西:地址段分配 / DNS 名 / 对端清单查询 + assert.equal(net.assignAddress, undefined, 'P0-3 不做 L3 地址分配(D1:推迟到 L3 专项)') + assert.equal(net.resolveDns, undefined, 'P0-3 不自建 DNS(D1)') + assert.equal(net.listPeers, undefined, 'P0-3 不下发对端清单(D4)') +}) diff --git a/test/overlay-bootstrap.test.mjs b/test/overlay-bootstrap.test.mjs new file mode 100644 index 0000000..8aef224 --- /dev/null +++ b/test/overlay-bootstrap.test.mjs @@ -0,0 +1,747 @@ +/** + * 覆盖网络 · **② 引导三级链(P0-2)** 单测 —— 地址是"编译期常量"还是"可在线轮换的下发物"。 + * + * ## 这个文件要回答的唯一问题 + * 「换域名 / 换机器」时,**已经装出去的客户端能不能自己跟上来**? + * `§A2` 的判据很硬:**不能**就只能靠"所有客户端升级重装",那是灾难。 + * 所以本文件的验收标准不是"能取到地址",而是四条**行为断言**: + * + * 1. **冷启动只靠内置种子就能起来**(清 env + 清缓存 ⇒ 走种子 → 取签名目录 → 拿到地址); + * 2. **引导地址可在线轮换**:目录里改 `bootstrap[]` ⇒ 下一次刷新**从新地址取**,**不重装、不升级**; + * 3. **签名不对 ⇒ 失败关闭**:既不写缓存、也不拿它的地址去连(受信公钥为空同样拒绝); + * 4. **降级不越权**:取不到目录时可以用**过期但签名有效**的缓存 / 种子地址本身, + * 但**绝不**因此接受一份未签名 / 签名不符的目录。 + * + * 运行:`node --test test/overlay-bootstrap.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。 + * + * @module test/overlay-bootstrap + */ + +import assert from 'node:assert/strict' +import { generateKeyPairSync } from 'node:crypto' +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' +import { + DEFAULT_OVERLAY_SEED, + DIRECTORY_PATH, + DIRECTORY_VERSION, + buildDirectoryDocument, + directoryPayload, + directoryUrlFor, + parseDirectory, + publicKeyFrom, + publicRelayEntries, + readCachedDirectory, + resolveOverlayRelay, + signDirectory, + toRelayUrl, + verifyDirectory, + writeCachedDirectory, +} from '../lib/net/relay/index.js' +// 序④(443/TCP 兜底 · L1):地址覆盖 —— 直接测模块(未从 index 再导出,避免动 index 的公共面)。 +import { + currentAddrOverrides, + ensureOverlayAddrOverrides, + parseAddrOverrides, + resetOverlayAddrOverridesForTest, +} from '../lib/net/relay/addr-override.js' +import { createServer as createNetServer } from 'node:net' +import { createRequire } from 'node:module' + +// ── 夹具 ────────────────────────────────────────────────────────────────── + +/** 生成一对 Ed25519;同时给 PEM 与**裸 32 字节 hex**(两条解析路径都要能验签)。 */ +function makeKeys() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() + const spki = publicKey.export({ type: 'spki', format: 'der' }) + return { + privatePem, + publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + publicRawHex: Buffer.from(spki.subarray(spki.length - 32)).toString('hex'), + } +} + +/** 一份自签目录(默认 relays / bootstrap 都是给定的 origin 形态)。 */ +function signedDoc(origin, keyPem, overrides = {}) { + const doc = buildDirectoryDocument({ + relays: overrides.relays ?? [origin], + bootstrap: overrides.bootstrap ?? [origin], + network: overrides.network ?? 'ops', + now: overrides.now ?? Date.now(), + refreshAfterSeconds: overrides.refreshAfterSeconds ?? 300, + }) + return { doc, sig: signDirectory(doc, keyPem) } +} + +/** + * 起一个**真的**目录端点(`node:http`),请求任意路径都返回当前 `doc+sig`。 + * 用真 HTTP 而不是 mock —— 本文件要证明的是"冷启动 / 轮换"这类**链路事实**。 + */ +function serveDirectory(keys, initial) { + const state = { ...initial } + const server = createServer((req, res) => { + if (!req.url || !req.url.startsWith(DIRECTORY_PATH)) { + res.writeHead(404).end('{}') + return + } + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ ...state.doc, sig: state.sig })) + }) + return { + state, + listen: () => + new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + resolve({ port: addr.port, origin: `http://127.0.0.1:${addr.port}/dshs-relay` }) + }) + }), + /** 换一份目录内容(模拟控制面把 bootstrap[] / relays 轮换走)。 */ + set(next) { + state.doc = next.doc + state.sig = next.sig + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +function tmpCacheFile() { + const dir = mkdtempSync(join(tmpdir(), 'dshs-overlay-')) + return { dir, file: join(dir, 'overlay', 'directory.json') } +} + +/** 记录型 fetch 替身:便于断言"这一步**根本没联网**"。 */ +function refusingFetch(calls) { + return async (url) => { + calls.push(String(url)) + throw new Error('network disabled in this test') + } +} + +// ── B1 · 纯函数:载荷 / 派生 / 清洗 ──────────────────────────────────────── + +test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { + // 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径 + assert.equal(directoryUrlFor('https://alotbuy.com/dshs-relay'), `https://alotbuy.com${DIRECTORY_PATH}`) + assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`) + // 已经是目录地址 ⇒ 原样 + assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`) + // ws 方言 ⇒ 取目录用 http 方言 + assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`) + + // 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据) + assert.equal(toRelayUrl('https://alotbuy.com/dshs-relay'), 'wss://alotbuy.com/dshs-relay') + assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay') + assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x') + assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝') + assert.equal(toRelayUrl('not a url'), undefined) + + // 载荷:同字段 ⇒ 同字符串;`network` 被签名覆盖(改它 ⇒ 载荷变) + const base = buildDirectoryDocument({ + relays: ['https://a.example/dshs-relay'], + bootstrap: ['https://a.example/dshs-relay'], + network: 'ops', + now: 1_700_000_000_000, + }) + const same = buildDirectoryDocument({ + relays: ['https://a.example/dshs-relay'], + bootstrap: ['https://a.example/dshs-relay'], + network: 'ops', + now: 1_700_000_000_000, + }) + assert.equal(directoryPayload(base), directoryPayload(same)) + assert.notEqual(directoryPayload(base), directoryPayload({ ...base, network: 'u:5' })) + assert.notEqual(directoryPayload(base), directoryPayload({ ...base, relays: ['https://b.example/x'] })) + + // 严格解析:坏文档一律 undefined(**默认拒绝**,不"尽力解析") + assert.ok(parseDirectory(base) !== undefined) + assert.equal(parseDirectory({ ...base, version: 2 }), undefined, '未知结构版本必须拒绝') + assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 1 }), undefined, '刷新周期过小必须拒绝') + assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 999_999 }), undefined, '刷新周期过大必须拒绝') + assert.equal(parseDirectory({ ...base, issuedAt: 'not-a-date' }), undefined) + assert.equal(parseDirectory({ ...base, network: '' }), undefined) + assert.equal(parseDirectory({ ...base, relays: ['file:///etc/passwd'] }), undefined, '非 http/ws 条目必须拒绝') + assert.equal(parseDirectory({ ...base, relays: [42] }), undefined) + assert.equal(parseDirectory({ ...base, relays: [], bootstrap: [] }), undefined, '全空的目录没有意义') + + // 对外公布的地址:回环 / 私网 / 单标签 / IPv6 / CGNAT 一律剔除(目录是**公网可读**的) + assert.deepEqual( + publicRelayEntries([ + 'http://127.0.0.1:20080/dshs-relay', + 'http://10.0.0.5/dshs-relay', + 'http://192.168.1.9/dshs-relay', + 'http://172.20.3.4/dshs-relay', + 'http://100.64.7.7/dshs-relay', + 'http://relaybox/dshs-relay', + 'http://[::1]/dshs-relay', + 'https://alotbuy.com/dshs-relay', + 'https://relay.example.com/dshs-relay', + ]), + ['https://alotbuy.com/dshs-relay', 'https://relay.example.com/dshs-relay'], + ) + + // 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本) + assert.equal(DEFAULT_OVERLAY_SEED, 'https://alotbuy.com/dshs-relay') + + // 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443) + // ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继) + assert.deepEqual( + publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']), + ['wss://alotbuy.com/dshs-relay'], + ) + // …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留 + assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [ + 'wss://a.example/x', + 'http://a.example/x', + ]) + // 同源不同路径 = 不同端点 + assert.deepEqual(publicRelayEntries(['https://a.example/dshs-relay', 'https://a.example/dshs-relay2']), [ + 'https://a.example/dshs-relay', + 'https://a.example/dshs-relay2', + ]) +}) + +// ── B2 · 验签(失败关闭) ──────────────────────────────────────────────── + +test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => { + const keys = makeKeys() + const other = makeKeys() + const origin = 'https://alotbuy.com/dshs-relay' + const { doc, sig } = signedDoc(origin, keys.privatePem) + + // 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验 + const okPem = verifyDirectory(doc, sig, [keys.publicPem]) + assert.equal(okPem.ok, true) + assert.equal(okPem.ok === true ? okPem.doc.network : '', 'ops') + assert.equal(verifyDirectory(doc, sig, [keys.publicRawHex]).ok, true) + // 混进一把解不出来的 key 不该让整条链断掉(跳到下一把) + assert.equal(verifyDirectory(doc, sig, ['not-a-key', keys.publicPem]).ok, true) + assert.equal(publicKeyFrom('not-a-key'), undefined) + + // 签名覆盖内容:改 relays(哪怕只多一条)⇒ payload 变 ⇒ 验签失败 + const tampered = { ...doc, relays: [...doc.relays, 'https://evil.example/dshs-relay'] } + const bad1 = verifyDirectory(tampered, sig, [keys.publicPem]) + assert.equal(bad1.ok, false) + assert.equal(bad1.ok === false ? bad1.reason : '', 'signature-mismatch') + + // 换密钥签 ⇒ 拒绝 + const foreign = signDirectory(doc, other.privatePem) + assert.equal(verifyDirectory(doc, foreign, [keys.publicPem]).ok, false) + assert.equal(verifyDirectory(doc, foreign, [other.publicPem]).ok, true) + + // **无受信密钥 ⇒ 拒绝**(不可验 = 不接受 —— 这条就是"失败关闭"本身) + const noKeys = verifyDirectory(doc, sig, []) + assert.equal(noKeys.ok, false) + assert.equal(noKeys.ok === false ? noKeys.reason : '', 'no-trusted-keys') + + // 签名形态坏掉 ⇒ 拒绝 + assert.equal(verifyDirectory(doc, '', [keys.publicPem]).ok, false) + assert.equal(verifyDirectory(doc, 'AAAA', [keys.publicPem]).ok, false) + assert.equal(verifyDirectory({ ...doc, version: 9 }, sig, [keys.publicPem]).ok, false) +}) + +// ── B3 · 三级链的决策(注入 fetch / now,不依赖网络) ───────────────────── + +test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => { + const keys = makeKeys() + const seed = 'https://alotbuy.com/dshs-relay' + + // ① env 显式 ⇒ 压制引导链(**一次网络都不发**) + { + const calls = [] + const r = await resolveOverlayRelay({ + envUrl: 'wss://env.example/dshs-relay', + seeds: [seed], + trustedKeys: [keys.publicPem], + fetchImpl: refusingFetch(calls), + }) + assert.equal(r.source, 'env') + assert.equal(r.url, 'wss://env.example/dshs-relay') + assert.deepEqual(calls, [], 'env 显式时不许联网') + } + + // ② 缓存未过期 ⇒ 直接用(同样一次都不联网) + { + const { dir, file } = tmpCacheFile() + try { + const now = Date.now() + const { doc, sig } = signedDoc(seed, keys.privatePem, { now }) + writeCachedDirectory(file, doc, sig, now) + const calls = [] + const r = await resolveOverlayRelay({ + seeds: [seed], + trustedKeys: [keys.publicPem], + cacheFile: file, + fetchImpl: refusingFetch(calls), + }) + assert.equal(r.source, 'cache') + assert.equal(r.url, 'wss://alotbuy.com/dshs-relay') + assert.deepEqual(calls, [], '新鲜缓存不许联网') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + + // ③ 无缓存 + 取不到 ⇒ 回落到**种子地址本身**(种子就是中继入口,同源约定) + { + const { dir, file } = tmpCacheFile() + try { + const calls = [] + const r = await resolveOverlayRelay({ + seeds: [seed], + trustedKeys: [keys.publicPem], + cacheFile: file, + fetchImpl: refusingFetch(calls), + }) + assert.equal(r.source, 'seed-fallback') + assert.equal(r.url, 'wss://alotbuy.com/dshs-relay') + assert.ok(calls.length >= 1, '应当尝试过取目录') + assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + + // ④ 有**过期但签名有效**的缓存 + 取不到 ⇒ 离线降级用旧值(D3 ③) + { + const { dir, file } = tmpCacheFile() + try { + const stale = Date.now() - 10 * 300_000 + const { doc, sig } = signedDoc(seed, keys.privatePem, { now: stale }) + writeCachedDirectory(file, doc, sig, stale) + const r = await resolveOverlayRelay({ + seeds: [seed], + trustedKeys: [keys.publicPem], + cacheFile: file, + fetchImpl: refusingFetch([]), + }) + assert.equal(r.source, 'stale-cache') + assert.equal(r.url, 'wss://alotbuy.com/dshs-relay') + // 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签) + assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined) + } finally { + rmSync(dir, { recursive: true, force: true }) + } + } + + // ⑤ 连种子都没有 ⇒ 明确返回"取不到"(等价于未配 relay,不抛异常) + { + const r = await resolveOverlayRelay({ seeds: [], trustedKeys: [], fetchImpl: refusingFetch([]) }) + assert.equal(r.source, 'none') + assert.equal(r.url, '') + } +}) + +// ── B4 · 判据 5:清 env + 清缓存冷启动,只靠内置种子起来 ───────────────── + +test('B4 冷启动:无 env、无缓存 ⇒ 走内置种子取到签名目录并留下缓存', async () => { + const keys = makeKeys() + const { dir, file } = tmpCacheFile() + const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) + try { + const { origin } = await srv.listen() + srv.set(signedDoc(origin, keys.privatePem)) + assert.equal(existsSync(file), false) + + // 「清 env」= 不传 envUrl;「清缓存」= 缓存文件不存在 —— 只剩种子这一级 + const first = await resolveOverlayRelay({ + envUrl: '', + seeds: [origin], + trustedKeys: [keys.publicPem], + cacheFile: file, + }) + assert.equal(first.source, 'seed-directory') + assert.equal(first.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`) + assert.equal(existsSync(file), true, '取到目录后必须写缓存') + + // 第二次(同一个"进程"重启)⇒ 读缓存,**不再联网**(把端点关掉也照样起来) + await srv.close() + const second = await resolveOverlayRelay({ + envUrl: '', + seeds: [origin], + trustedKeys: [keys.publicPem], + cacheFile: file, + }) + assert.equal(second.source, 'cache') + assert.equal(second.url, first.url) + } finally { + await srv.close().catch(() => undefined) + rmSync(dir, { recursive: true, force: true }) + } +}) + +// ── B5 · 判据 6:改目录里的 bootstrap[] ⇒ 新会话读到新值(不重装) ──────── + +test('B5 轮换:目录里 bootstrap[] 改值 ⇒ 下一轮从新地址取,客户端不重装', async () => { + const keys = makeKeys() + const { dir, file } = tmpCacheFile() + const a = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) + const b = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) + try { + const A = (await a.listen()).origin + const B = (await b.listen()).origin + a.set(signedDoc(A, keys.privatePem)) // 阶段 1:老地域 A 就是引导地址 + b.set(signedDoc(B, keys.privatePem)) // 阶段 2:新地域 B 上线 + + // 显式时钟(**不要用 Date.now() 加减**:`now - fetchedAt` 会被 `Math.max(0, …)` 夹成 0 + // ⇒ 缓存反而"看起来新鲜",测试会误判成"链路没走")。每次推 10 个刷新周期 ⇒ 缓存必过期。 + const refreshMs = 300_000 + let clock = Date.now() + + // 阶段 1:客户端只认识 A(= 编译进来的种子位) + const p1 = await resolveOverlayRelay({ + seeds: [A], + trustedKeys: [keys.publicPem], + cacheFile: file, + nowMs: clock, + }) + assert.equal(p1.source, 'seed-directory') + assert.equal(p1.url, `ws://127.0.0.1:${new URL(A).port}/dshs-relay`) + + // 控制面把引导地址轮换到 B:A 的目录里 bootstrap[] = [B]、relays[] = [B] + a.set(signedDoc(B, keys.privatePem, { bootstrap: [B], relays: [B] })) + clock += 10 * refreshMs + const p2 = await resolveOverlayRelay({ + seeds: [A], + trustedKeys: [keys.publicPem], + cacheFile: file, + nowMs: clock, + }) + assert.equal(p2.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`, '这一轮就该连到新地址') + const cached = readCachedDirectory(file, [keys.publicPem], clock) + assert.equal(cached?.fresh, true, '刚写完的缓存应当是新鲜的') + assert.deepEqual(cached?.entry.doc.bootstrap, [B], '缓存里的 bootstrap[] 必须已更新为 B') + + // **关键一步**:A 下线(老域名退役)。客户端**没有重装、没有改配置**, + // 只因为"取目录的 origin 优先取缓存里的 bootstrap[]",它自己就找到了 B。 + await a.close() + clock += 10 * refreshMs + const p3 = await resolveOverlayRelay({ + seeds: [A], + trustedKeys: [keys.publicPem], + cacheFile: file, + nowMs: clock, + }) + assert.equal(p3.source, 'seed-directory', 'A 已下线,仍应取到目录(说明 origin 走了 B)') + assert.equal(p3.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`) + assert.equal(p3.detail.includes(`:${new URL(B).port}`), true, 'detail 必须指向 B 的端口') + } finally { + await a.close().catch(() => undefined) + await b.close().catch(() => undefined) + rmSync(dir, { recursive: true, force: true }) + } +}) + +// ── B6 · 失败关闭:签名不对 ⇒ 不写缓存、不采用 ─────────────────────────── + +test('B6 签名不对的目录:既不写缓存也不采用(有旧缓存则用它,没有则回落种子)', async () => { + const keys = makeKeys() + const attacker = makeKeys() + const { dir, file } = tmpCacheFile() + const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) + try { + const origin = (await srv.listen()).origin + // 端点被换成了"另一把密钥签的目录"(或 MITM 自签)⇒ 我们**不认** + srv.set({ + doc: buildDirectoryDocument({ + relays: ['https://evil.example/dshs-relay'], + bootstrap: ['https://evil.example/dshs-relay'], + network: 'ops', + now: Date.now(), + }), + sig: signDirectory( + buildDirectoryDocument({ + relays: ['https://evil.example/dshs-relay'], + bootstrap: ['https://evil.example/dshs-relay'], + network: 'ops', + now: Date.now(), + }), + attacker.privatePem, + ), + }) + const r1 = await resolveOverlayRelay({ + seeds: [origin], + trustedKeys: [keys.publicPem], + cacheFile: file, + }) + assert.equal(r1.source, 'seed-fallback', '坏目录必须被丢掉') + assert.equal(r1.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`) + assert.equal(existsSync(file), false, '坏目录**不许**被写进缓存') + assert.equal(r1.url.includes('evil.example'), false, '⛔ 绝不能采用坏目录里的地址') + + // 有**有效但过期**的缓存 ⇒ 用旧的可信值(而不是接受坏目录) + const good = serveDirectory(keys, signedDoc(origin, keys.privatePem)) + const stale = Date.now() - 10 * 300_000 + const old = signedDoc(origin, keys.privatePem, { now: stale }) + writeCachedDirectory(file, old.doc, old.sig, stale) + const r2 = await resolveOverlayRelay({ + seeds: [origin], + trustedKeys: [keys.publicPem], + cacheFile: file, + }) + assert.equal(r2.source, 'stale-cache') + assert.equal(r2.url.includes('evil.example'), false) + await good.close().catch(() => undefined) + } finally { + await srv.close().catch(() => undefined) + rmSync(dir, { recursive: true, force: true }) + } +}) + +// ── B7 · 端点契约(服务端组装 + 签发 + 验签闭环) ──────────────────────── + +test('B7 端点契约:只公布公网地址、签名可被受信公钥验过、无密钥即不可用', async () => { + const keys = makeKeys() + // 服务端逻辑:候选 = 显式中继入口 + 种子;**过滤回环/私网**后再组装 + const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', DEFAULT_OVERLAY_SEED]) + const bootstrap = publicRelayEntries([DEFAULT_OVERLAY_SEED]) + const doc = buildDirectoryDocument({ relays, bootstrap, network: 'ops', now: Date.now() }) + const sig = signDirectory(doc, keys.privatePem) + + assert.deepEqual(relays, [DEFAULT_OVERLAY_SEED], '回环地址不得出现在目录里') + assert.deepEqual(Object.keys(doc).sort(), [ + 'bootstrap', + 'issuedAt', + 'network', + 'refreshAfterSeconds', + 'relays', + 'version', + ]) + const verdict = verifyDirectory(doc, sig, [keys.publicRawHex]) + assert.equal(verdict.ok, true) + assert.equal(verdict.ok === true ? verdict.doc.version : 0, DIRECTORY_VERSION) + // 目录里**没有**任何身份 / 内网信息(只暴露"去哪儿",不暴露"谁在哪") + const flat = JSON.stringify(doc) + for (const forbidden of ['hostId', 'w-', 'secret', '127.0.0.1', 'token']) { + assert.equal(flat.includes(forbidden), false, `目录里不该出现 ${forbidden}`) + } +}) + +// ── B8 · 范围声明 ──────────────────────────────────────────────────────── + +test('B8 范围:本步只做 P0-2(引导三级链),不做 P0-3 / 应用层', () => { + // Step 2 的边界(R7:别顺手做别的): + // · P0-3 的"名字解析 / 按网络授权"已由 Step 1(P0-1 网抽象)承担,本步不重复实现; + // · 应用层 / 房间层 / presence 排在 `清单 §五` 第 7 步,**本步不碰**; + // · 本步**不新增任何监听口**(目录端点挂在既有门户 3080 上,由既有 nginx location 转发)。 + assert.equal(DIRECTORY_PATH, '/dshs-overlay/bootstrap') + assert.equal(existsSync(join(process.cwd(), 'src', 'net', 'relay', 'directory.ts')), true) +}) + +// ── 夹具自检 ────────────────────────────────────────────────────────────── + +test('S0 夹具自检:缓存读写是字节级可复现的(避免"测试夹具自身有问题")', () => { + const keys = makeKeys() + const { dir, file } = tmpCacheFile() + try { + const now = Date.now() + const { doc, sig } = signedDoc(DEFAULT_OVERLAY_SEED, keys.privatePem, { now }) + writeCachedDirectory(file, doc, sig, now) + const raw = readFileSync(file, 'utf8') + const parsed = JSON.parse(raw) + assert.equal(parsed.doc.network, 'ops') + assert.equal(parsed.fetchedAt, now) + const back = readCachedDirectory(file, [keys.publicPem], now) + assert.deepEqual(back?.entry.doc, doc) + assert.equal(back?.fresh, true) + // 不能留下临时文件(原子写的判据) + assert.equal(existsSync(`${file}.tmp`), false) + // 缓存文件是 JSON 且以换行结尾(便于 diff / 人工读) + assert.equal(raw.endsWith('\n'), true) + } finally { + rmSync(dir, { recursive: true, force: true }) + // 顺手证明夹具没往外写东西(writeFileSync 只用于夹具自检) + assert.equal(typeof writeFileSync, 'function') + } +}) + +// ── 序④(443/TCP 兜底 · L1「去 CF」):地址覆盖 ───────────────────────────── +// +// 全部是**行为断言**(不是"函数返回了什么"): +// A. **未配 ⇒ 零动作** —— 域名照旧不可解析,证明连补丁都没打(默认路径逐字不变); +// B. **配了 ⇒ 内建 WebSocket 的建连真的走到覆盖 IP** —— 这是整件事的机制性前提 +// (内建 `WebSocket` 必须走 JS 层 `dns.lookup`,否则本路线不成立); +// C. **定向**而非全局劫持 —— 白名单之外的域名不受影响; +// D. **失败关闭** —— 形状非法 ⇒ 不安装 + 报出来(不静默忽略); +// E. **可撤销** —— 还原后回到未配状态(既是测试隔离,也是回滚判据)。 + +/** 取「net / undici 实际用的那个」`node:dns` 单例(`addr-override` 改的就是它)。 */ +const nodeDns = createRequire(import.meta.url)('node:dns') + +/** 单地址解析(回调形状)⇒ 地址字符串或 `ERR:`。 */ +function lookupOne(host) { + return new Promise((resolve) => { + nodeDns.lookup(host, (err, address) => resolve(err ? `ERR:${err.code}` : address)) + }) +} + +test('序④·L1-A 未配地址覆盖 ⇒ 零动作(默认解析路径逐字不变)', async () => { + resetOverlayAddrOverridesForTest() + try { + assert.deepEqual(ensureOverlayAddrOverrides('', () => {}), []) + assert.deepEqual(currentAddrOverrides(), []) + assert.equal(await lookupOne('fb-nodns-9f3a.invalid'), 'ERR:ENOTFOUND') + } finally { + resetOverlayAddrOverridesForTest() + } +}) + +test('序④·L1-B 覆盖生效 ⇒ 白名单域名直连覆盖 IP,且内建 WebSocket 真的走到该 IP', async () => { + const HOST = 'fb-direct-9f3a.invalid' + let hits = 0 + const srv = createNetServer((s) => { + hits += 1 + s.destroy() + }) + await new Promise((r) => srv.listen(0, '127.0.0.1', r)) + const port = srv.address().port + resetOverlayAddrOverridesForTest() + try { + // 覆盖前:该名字在公网不存在 ⇒ 不可解析(这就是"兜底子域没有可直连的解析结果"的等价场景) + assert.equal(await lookupOne(HOST), 'ERR:ENOTFOUND') + + const applied = ensureOverlayAddrOverrides(`${HOST}=127.0.0.1`, () => {}) + assert.deepEqual( + applied.map((o) => `${o.host}=${o.ip}/v${o.family}`), + [`${HOST}=127.0.0.1/v4`], + ) + assert.equal(await lookupOne(HOST), '127.0.0.1') + // `all: true` 形状(`net` 在 autoSelectFamily 下会用它)也必须给出覆盖结果 + const all = await new Promise((resolve) => { + nodeDns.lookup(HOST, { all: true }, (err, addrs) => resolve(err ? `ERR:${err.code}` : addrs)) + }) + assert.deepEqual(all, [{ address: '127.0.0.1', family: 4 }]) + + // **机制性前提**:内建 WebSocket 的建连要走 JS 层 `dns.lookup`,覆盖才对它有效。 + const before = hits + await new Promise((resolve) => { + const ws = new WebSocket(`ws://${HOST}:${port}/`) + ws.addEventListener('open', () => resolve()) + ws.addEventListener('error', () => resolve()) + setTimeout(resolve, 3000) + }) + await new Promise((r) => setTimeout(r, 200)) + assert.ok(hits > before, '内建 WebSocket 未走 JS 层 dns.lookup ⇒ 地址覆盖对建连无效(本路线不成立)') + } finally { + srv.close() + resetOverlayAddrOverridesForTest() + } +}) + +test('序④·L1-C 白名单之外不受影响(定向覆盖,不是全局劫持)', async () => { + resetOverlayAddrOverridesForTest() + try { + ensureOverlayAddrOverrides('fb-scoped-9f3a.invalid=127.0.0.1', () => {}) + assert.equal(await lookupOne('fb-scoped-9f3a.invalid'), '127.0.0.1') + assert.equal(await lookupOne('fb-other-9f3a.invalid'), 'ERR:ENOTFOUND') + } finally { + resetOverlayAddrOverridesForTest() + } +}) + +test('序④·L1-D 形状非法 ⇒ 不安装且报出去(失败关闭,不静默忽略)', async () => { + resetOverlayAddrOverridesForTest() + try { + const logs = [] + const applied = ensureOverlayAddrOverrides( + 'no-equals,=1.2.3.4,host.invalid=not-an-ip,ok.invalid=1.2.3.4', + (l) => logs.push(l), + ) + assert.deepEqual(applied.map((o) => o.host), ['ok.invalid']) + assert.equal(logs.filter((l) => l.includes('忽略非法条目')).length, 3) + // 纯函数面:同域名重复 ⇒ **先出现者生效**(小写归一后比对) + assert.deepEqual( + parseAddrOverrides('A.invalid=1.2.3.4,a.INVALID=5.6.7.8').list.map((o) => `${o.host}=${o.ip}`), + ['a.invalid=1.2.3.4'], + ) + // 合法但请求了别的地址族 ⇒ 交回 ENOTFOUND 语义,**不**回一个错族的地址 + assert.equal( + await new Promise((resolve) => { + nodeDns.lookup('ok.invalid', { family: 6 }, (err) => resolve(err ? `ERR:${err.code}` : 'OK')) + }), + 'ERR:ENOTFOUND', + ) + } finally { + resetOverlayAddrOverridesForTest() + } +}) + +test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => { + try { + ensureOverlayAddrOverrides('fb-rollback-9f3a.invalid=127.0.0.1', () => {}) + assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), '127.0.0.1') + resetOverlayAddrOverridesForTest() + assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), 'ERR:ENOTFOUND') + } finally { + resetOverlayAddrOverridesForTest() + } +}) + +/** + * **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」—— + * `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。 + */ +const FB_MAIN = 'https://alotbuy.com/dshs-relay' +const FB_ALT = 'https://relay-direct.alotbuy.com/dshs-relay' + +/** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */ +function twoEntryDoc(keys) { + const { doc, sig } = signedDoc(FB_MAIN, keys.privatePem, { + relays: [FB_MAIN, FB_ALT], + bootstrap: [FB_MAIN, FB_ALT], + }) + return JSON.stringify({ ...doc, sig }) +} + +test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的同源入口,且降级可解释', async () => { + const keys = makeKeys() + const body = twoEntryDoc(keys) + const calls = [] + const logs = [] + const fetchImpl = async (url) => { + calls.push(String(url)) + if (String(url).startsWith('https://alotbuy.com/')) throw new Error('cf unreachable') + return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) + } + const r = await resolveOverlayRelay({ + seeds: [FB_MAIN, FB_ALT], + trustedKeys: [keys.publicPem], + cacheFile: '', + fetchImpl, + log: (l) => logs.push(l), + }) + // ① 逐个 origin 试,主 origin 被拒后才到兜底 + assert.equal(calls.length, 2) + assert.ok( + logs.some((l) => l.includes('拒绝 https://alotbuy.com/dshs-overlay/bootstrap')), + '缺"逐 origin 拒绝原因"这一行', + ) + // ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据) + assert.equal(r.source, 'seed-directory') + assert.equal(r.url, 'wss://relay-direct.alotbuy.com/dshs-relay') + assert.ok( + logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.alotbuy.com/dshs-relay')), + '缺"为什么走了兜底"这一行(可解释性)', + ) +}) + +test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首位,零退化)', async () => { + const keys = makeKeys() + const body = twoEntryDoc(keys) + const logs = [] + const fetchImpl = async () => + new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) + const r = await resolveOverlayRelay({ + seeds: [FB_MAIN, FB_ALT], + trustedKeys: [keys.publicPem], + cacheFile: '', + fetchImpl, + log: (l) => logs.push(l), + }) + assert.equal(r.url, 'wss://alotbuy.com/dshs-relay') + assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志') +}) diff --git a/test/overlay-identity.test.mjs b/test/overlay-identity.test.mjs new file mode 100644 index 0000000..83a5d6a --- /dev/null +++ b/test/overlay-identity.test.mjs @@ -0,0 +1,526 @@ +/** + * 覆盖网络 · **序③ 一机一钥 + 信任根** 单测 —— 身份层是不是"真的挡得住"。 + * + * ## 这个文件要回答的四个问题(= `交接单_一机一钥与信任根_20260917.md §5/§6`) + * 1. **四层模型**:根(离线)→ 签名者(在线)→ 节点(每机一把)→ 会话(复用 TLS) + * 的载体能不能**真签发、真验签**?(A 组) + * 2. **入网 = 签名**:篡改过的入网凭据**建不起会话**,且原因**结构化**?(B 组 + D 组) + * 3. **relay 准入按 `hostId` + 成员资格**:拿 A 网的凭据进 B 网必须被拒?(C 组 + D1) + * 4. **撤销单台 ≠ 全网换密钥**:撤 w106 之后,w47 照旧在线?(D6) + * + * ## 🔴 "先红后绿"在本文件里的落点(⛔ 不是口头声明) + * 身份校验**默认不强制**(`requireIdentity=false`,存量过渡)⇒ 同一份**被搬家的凭据**, + * 在 `verifyIdentity:false` 的服务器上**能注册进去**(= 没有这道门时的攻击面,**红**), + * 在 `verifyIdentity:true` 的服务器上**被拒**(**绿**)。两组用**同一份坏凭据、同一个 hostId**, + * 唯一变量就是那一道门 ⇒ 证明"挡住它的正是这道门",而不是别的巧合(D5)。 + * + * 运行:`node --test test/overlay-identity.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。 + * + * @module test/overlay-identity + */ + +import assert from 'node:assert/strict' +import { createHmac, randomBytes } from 'node:crypto' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { test } from 'node:test' + +import { RelayClient } from '../lib/net/relay/client.js' +import { + generateAuthorityKey, + generateNodeKey, + nodeKeyFingerprint, + publicKeyOfPrivate, + signNodeGrant, + signProof, + signRevocations, + signSignerSet, + verifyNodeGrant, + verifyPeerGrant, + verifyProof, + verifyRevocations, + verifySignerSet, +} from '../lib/net/relay/identity.js' +import { describeKeyEntry, loadKeysFile, parseKeysInline } from '../lib/net/relay/keys.js' +import { OPS_NETWORK } from '../lib/net/relay/network.js' +import { RelayServer } from '../lib/net/relay/server.js' +import { MUX, decodeMux, encodeJsonFrame } from '../lib/net/relay/wire.js' + +const BASE = 47100 +const SPAN = 200 +const PATH = '/dshs-relay' +const U_A = 'u:alpha' +const U_B = 'u:beta' +const NOW = Date.parse('2026-09-17T00:00:00Z') + +/* ─────────────────────────── 工具 ─────────────────────────── */ + +async function waitFor(fn, ms = 4000) { + const until = Date.now() + ms + while (Date.now() < until) { + if (fn()) return true + await new Promise((r) => setTimeout(r, 20)) + } + return false +} + +/** 一套"根 → 签名者 → 节点"的密钥与凭据(**建网的标准动作**:测试与部署脚本同一形状)。 */ +function buildAuthority(network = OPS_NETWORK) { + const root = generateAuthorityKey() + const signer = generateAuthorityKey() + const signerSet = { + version: 1, + network, + issuedAt: new Date(NOW).toISOString(), + signers: [signer.publicKey], + } + const signerSetSig = signSignerSet(signerSet, root.privateKeyPem) + const issue = (hostId, opts = {}) => { + const node = generateNodeKey() + const grant = { + version: 1, + network: opts.network ?? network, + hostId, + nodeKey: node.publicKey, + issuedAt: opts.issuedAt ?? new Date(NOW).toISOString(), + expiresAt: opts.expiresAt ?? '', + } + return { node, grant, sig: signNodeGrant(grant, signer.privateKeyPem) } + } + return { root, signer, signerSet, signerSetSig, issue } +} + +/** 吊销清单(签名者签发)。 */ +function revocationOf(signerPem, hosts, nodeKeys = []) { + const doc = { version: 1, network: OPS_NETWORK, issuedAt: new Date(NOW).toISOString(), hosts, nodeKeys } + return { doc, sig: signRevocations(doc, signerPem) } +} + +/** + * 手工发一个 `HELLO` 并取回**首个回帧**(用于构造"正常 client 不会发"的帧)。 + * + * ⚠️ MAC 输入串与 `client.ts` 逐字同源(**不含身份字段**)—— 身份是**追加**字段、不动 MAC; + * 这一条本身就是被测对象之一(旧客户端算出的 MAC 必须仍被接受)。 + */ +async function rawHello(wsUrl, opts) { + const { hostId, secret, portsCsv = '', network, identity } = opts + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true }) + }) + const ts = Date.now() + const nonce = randomBytes(16).toString('hex') + const challenge = `${hostId}|${ts}|${nonce}|${portsCsv}` + const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(challenge).digest('hex') + const payload = { v: 1, hostId, ts, nonce, portsCsv, mac } + if (network !== undefined) payload.network = network + if (identity !== undefined) { + const { nodePrivateKeyPem, nodeSigOverride, ...rest } = identity + Object.assign(payload, rest) + // 默认按**正确的挑战串**签(走真实路径);给了 override 就按 override(用来构造"签名错")。 + if (nodeSigOverride !== undefined) payload.nodeSig = nodeSigOverride + else if (nodePrivateKeyPem !== undefined) payload.nodeSig = signProof(nodePrivateKeyPem, challenge) + } + ws.send(encodeJsonFrame(MUX.HELLO, 0, payload)) + const frame = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + ws.addEventListener('message', (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }) + ws.addEventListener('close', () => { + clearTimeout(timer) + resolve(undefined) + }) + }) + return { ws, frame } +} + +function reasonOf(frame) { + assert.ok(frame !== undefined, '必须得到结构化回帧') + return JSON.parse(frame.payload.toString('utf8')).reason +} + +/** 建一个 relay:所有 host 都是"拨号方"(端口表为空)⇒ 不需要任何 TCP 回显服务。 */ +async function startRelay(t, opts = {}) { + const hosts = opts.hosts ?? {} + const srv = new RelayServer({ + port: 0, + // ⚠️ 必须是 `Map`(服务端按 `keys.get(hostId)` 取;传普通对象会得到 + // `this.opts.keys.get is not a function` 这种**与判据无关**的崩 —— 会淹掉真正的结论)。 + keys: new Map(Object.entries(hosts)), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Map([[OPS_NETWORK, new Set(Object.keys(hosts))]]), + trustedSignerKeys: opts.trustedSignerKeys ?? [], + revocations: opts.revocations, + requireIdentity: opts.requireIdentity ?? false, + verifyIdentity: opts.verifyIdentity ?? true, + log: opts.log ?? (() => {}), + }) + await srv.start() + t.after(async () => { + await srv.stop() + }) + return { srv, url: `ws://127.0.0.1:${srv.boundPort}${PATH}` } +} + +/* ─────────── A:四层密钥模型(根 → 签名者) ─────────── */ + +test('A1 根签名的签名者集合:受信根验得过', () => { + const { root, signer, signerSet, signerSetSig } = buildAuthority() + const verdict = verifySignerSet(signerSet, signerSetSig, [root.publicKey]) + assert.equal(verdict.ok, true, `根签的集合必须验得过,实测:${JSON.stringify(verdict)}`) + assert.deepEqual(verdict.doc.signers, [signer.publicKey]) +}) + +test('A2 篡改签名者集合(加一把签名者后沿用旧签名)⇒ **signature-mismatch**', () => { + const { root, signerSet, signerSetSig } = buildAuthority() + const evil = generateAuthorityKey() + const tampered = { ...signerSet, signers: [...signerSet.signers, evil.publicKey] } + const verdict = verifySignerSet(tampered, signerSetSig, [root.publicKey]) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'signature-mismatch', '加签名者 = 加攻击面 ⇒ 必须失败关闭') +}) + +test('A3 **不可验 = 不接受**:一把受信根都没有 ⇒ no-trusted-keys(不"先用着")', () => { + const { signerSet, signerSetSig } = buildAuthority() + const verdict = verifySignerSet(signerSet, signerSetSig, []) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'no-trusted-keys') +}) + +test('A4 换一把没被授权的根来签 ⇒ 不认(根是唯一信任源)', () => { + const a = buildAuthority() + const b = buildAuthority() + const crossSig = signSignerSet(a.signerSet, b.root.privateKeyPem) + const verdict = verifySignerSet(a.signerSet, crossSig, [a.root.publicKey]) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'signature-mismatch') +}) + +/* ─────────── B:节点凭据 + 本地校验(D2 的判据落点) ─────────── */ + +test('B1 合法凭据:受信签名者签发 ⇒ 过,且能取出 hostId / 网 / 节点公钥', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106') + const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey]) + assert.equal(verdict.ok, true) + assert.equal(verdict.doc.hostId, 'w106') + assert.equal(verdict.doc.network, OPS_NETWORK) + assert.equal(verdict.doc.nodeKey, grant.nodeKey) +}) + +test('B2 🔴 核心:**篡改 hostId 后沿用原签名** ⇒ signature-mismatch(凭据不可搬家)', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106') + const moved = { ...grant, hostId: 'w47' } + const verdict = verifyNodeGrant(moved, sig, [auth.signer.publicKey]) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'signature-mismatch', '凭据被搬到别的 hostId 上必须拒(签名覆盖 hostId)') +}) + +test('B3 期望网不符 ⇒ network-mismatch(跨网签发进不来)', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106') + const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], network: U_B }) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'network-mismatch') +}) + +test('B4/B5 期望 hostId 不符 ⇒ host-mismatch;期望公钥不符 ⇒ key-mismatch', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106') + const other = generateNodeKey() + assert.equal( + verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], hostId: 'w47' }).reason, + 'host-mismatch', + ) + assert.equal( + verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nodeKey: other.publicKey }).reason, + 'key-mismatch', + '私钥被换过(公钥对不上)⇒ 拒', + ) + // 对得上的那一组必须**过** —— 否则上面两条可能只是"恒拒",测了个寂寞 + assert.equal( + verifyPeerGrant(grant, sig, { + trustedSignerKeys: [auth.signer.publicKey], + network: OPS_NETWORK, + hostId: 'w106', + nodeKey: grant.nodeKey, + }).ok, + true, + ) +}) + +test('B6 过期凭据 ⇒ expired(带过期时间的节点会真的过期)', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106', { + issuedAt: new Date(NOW - 120_000).toISOString(), + expiresAt: new Date(NOW - 60_000).toISOString(), + }) + const verdict = verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], nowMs: NOW }) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'expired') +}) + +test('B7/B8 吊销:撤 hostId ⇒ revoked-host;撤节点公钥 ⇒ revoked-node-key;撤单台不牵连别的', () => { + const auth = buildAuthority() + const { grant, sig } = auth.issue('w106') + const byHost = { ...revocationOf(auth.signer.privateKeyPem, ['w106']).doc } + const byKey = { ...revocationOf(auth.signer.privateKeyPem, [], [grant.nodeKey]).doc } + const other = { ...revocationOf(auth.signer.privateKeyPem, ['manager']).doc } + assert.equal( + verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byHost }).reason, + 'revoked-host', + ) + assert.equal( + verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: byKey }).reason, + 'revoked-node-key', + '设备被盗场景:hostId 可能被复用,公钥不会 ⇒ 两条都要能撤', + ) + assert.equal( + verifyPeerGrant(grant, sig, { trustedSignerKeys: [auth.signer.publicKey], revocations: other }).ok, + true, + '撤销单台不得牵动其它节点', + ) +}) + +test('B9 吊销清单本身也要**验签**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)', () => { + const auth = buildAuthority() + const rogue = generateAuthorityKey() + const good = revocationOf(auth.signer.privateKeyPem, ['w106']) + assert.equal(verifyRevocations(good.doc, good.sig, [auth.signer.publicKey]).ok, true) + const forged = revocationOf(rogue.privateKeyPem, ['w106']) + assert.equal(verifyRevocations(forged.doc, forged.sig, [auth.signer.publicKey]).reason, 'signature-mismatch') + // 篡改内容后沿用原签名 + const tampered = { ...good.doc, hosts: ['w47'] } + assert.equal(verifyRevocations(tampered, good.sig, [auth.signer.publicKey]).reason, 'signature-mismatch') +}) + +test('B10 会话证明(proof):握有私钥才签得出;换公钥 / 改挑战 ⇒ 验不过', () => { + const auth = buildAuthority() + const { node } = auth.issue('w106') + const challenge = 'w106|123|abc|' + const sig = signProof(node.privateKeyPem, challenge) + assert.equal(verifyProof(node.publicKey, challenge, sig), true) + const other = generateNodeKey() + assert.equal(verifyProof(other.publicKey, challenge, sig), false, '别的公钥验不过 ⇒ 证明绑定了具体私钥') + assert.equal(verifyProof(node.publicKey, `${challenge}x`, sig), false, '挑战被改 ⇒ 验不过(防重放 / 防挪用)') +}) + +test('B11 非受信签名者签发的凭据 ⇒ signature-mismatch(名单之外一律不认)', () => { + const auth = buildAuthority() + const rogue = generateAuthorityKey() + const node = generateNodeKey() + const grant = { + version: 1, + network: OPS_NETWORK, + hostId: 'w106', + nodeKey: node.publicKey, + issuedAt: new Date(NOW).toISOString(), + expiresAt: '', + } + const sig = signNodeGrant(grant, rogue.privateKeyPem) + const verdict = verifyNodeGrant(grant, sig, [auth.signer.publicKey]) + assert.equal(verdict.ok, false) + assert.equal(verdict.reason, 'signature-mismatch') +}) + +test('B12 指纹:每机一把 ⇒ 指纹互不相同;解析不出来就 undefined(不猜)', () => { + const a = generateNodeKey() + const b = generateNodeKey() + assert.notEqual(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(b.publicKey), '每机一把 ⇒ 指纹必须不同') + assert.equal(nodeKeyFingerprint(a.publicKey), nodeKeyFingerprint(a.publicKey)) + assert.equal(nodeKeyFingerprint('not-a-key'), undefined) +}) + +/* ─────────── C:keys 表带"网"(成员资格的唯一判据) ─────────── */ + +test('C1 旧写法(裸 hostId + hex 串)⇒ 归入运维网 ops(现网配置一字不改)', () => { + const s = randomBytes(32).toString('hex') + const map = parseKeysInline(`w-47:${s}`) + // 键 = **逻辑名**(序③)⇒ 裸 `w-47` 归一成 `ops/w-47` + assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s }) + assert.equal(describeKeyEntry('w-47', map.get('ops/w-47')), 'w-47', 'ops 下省略网络前缀(日志读法与 R5 一致)') +}) + +test('C2 新写法:`网/hostId:secret` 与 `网:hostId:secret` 都切得出网络(切点是**最后一个冒号**)', () => { + const s = randomBytes(32).toString('hex') + const slash = parseKeysInline(`${U_A}/pc-1:${s}`) + assert.deepEqual(slash.get(U_A + '/pc-1'), { network: U_A, secret: s }) + // ⚠️ 网络 id 自己含 `:`(`u:5`)⇒ 用**第一个**冒号切会切出网络段 `u`, + // 于是"配置写错"就长得像"这张网不存在"(静默拒绝)——这正是本线反复要根治的病。 + const colon = parseKeysInline(`u:5:pc-1:${s}`) + assert.deepEqual(colon.get('u:5/pc-1'), { network: 'u:5', secret: s }) + assert.equal(describeKeyEntry('pc-1', colon.get('u:5/pc-1')), 'u:5/pc-1') +}) + +test('C3 网络段非法 / 密钥长度不对 ⇒ **抛**(不静默回落:那会让"配错"伪装成"网络不通")', () => { + const s = randomBytes(32).toString('hex') + assert.throws(() => parseKeysInline(`u/pc-1:${s}`), /非法/, '裸 `u` 是保留字,不是合法网名') + assert.throws(() => parseKeysInline(`pc-1:deadbeef`), /64 hex/) + assert.throws(() => parseKeysInline('no-colon'), /malformed/) +}) + +/* ─────────── D:端到端(relay 准入 + 身份) ─────────── */ + +test('D1 成员资格:声称别的网 ⇒ **network-mismatch**(失败关闭,不回落 ops)', async (t) => { + const s = randomBytes(32).toString('hex') + const { url } = await startRelay(t, { hosts: { w106: s } }) + const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: U_B }) + t.after(() => ws.close()) + assert.equal(frame.type, MUX.HELLO_ERR) + assert.equal(reasonOf(frame), 'network-mismatch', '密钥表说它在 ops,声称别张网 ⇒ 必须拒') +}) + +test('D2 未登记的 hostId ⇒ unknown-host(默认拒绝)', async (t) => { + const s = randomBytes(32).toString('hex') + const { url } = await startRelay(t, { hosts: { w106: s } }) + const { ws, frame } = await rawHello(url, { hostId: 'nobody', secret: s }) + t.after(() => ws.close()) + assert.equal(reasonOf(frame), 'unknown-host') +}) + +test('D3 强制身份但客户端**不带**凭据 ⇒ identity-incomplete(明确拒,不放行)', async (t) => { + const s = randomBytes(32).toString('hex') + const auth = buildAuthority() + const { url } = await startRelay(t, { + hosts: { w106: s }, + trustedSignerKeys: [auth.signer.publicKey], + requireIdentity: true, + }) + const { ws, frame } = await rawHello(url, { hostId: 'w106', secret: s, network: OPS_NETWORK }) + t.after(() => ws.close()) + assert.equal(reasonOf(frame), 'identity-incomplete') +}) + +test('D4 relay 侧**没有**受信签名者时,带了凭据也拒(不可验 = 不接受)', async (t) => { + const s = randomBytes(32).toString('hex') + const auth = buildAuthority() + const { grant, sig, node } = auth.issue('w106') + const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [] }) + const { ws, frame } = await rawHello(url, { + hostId: 'w106', + secret: s, + network: OPS_NETWORK, + identity: { nodeKey: node.publicKey, grant, grantSig: sig, nodePrivateKeyPem: node.privateKeyPem }, + }) + t.after(() => ws.close()) + assert.equal(reasonOf(frame), 'identity-no-trusted-signers') +}) + +test('D5 🔴 **先红后绿**:同一份"被搬家的凭据" —— 没有这道门时能注册(红),有门时被拒(绿)', async (t) => { + const s = randomBytes(32).toString('hex') + const auth = buildAuthority() + const good = auth.issue('w106') + // 篡改形态:把 w106 的凭据"搬家"到 w47 并沿用原签名 —— 这正是"控制面被攻破后插入节点"的形状。 + const evilGrant = { ...good.grant, hostId: 'w47' } + const identity = { nodeKey: good.node.publicKey, grant: evilGrant, grantSig: good.sig } + const hosts = { w106: s, w47: s } + + /* ① 红:门关着(verifyIdentity:false)⇒ **同一份坏凭据确实注册进去了** */ + const open = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey], verifyIdentity: false }) + const red = await rawHello(open.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity }) + t.after(() => red.ws.close()) + assert.equal(red.frame.type, MUX.HELLO_ACK, '【红】门关着时坏凭据能注册 —— 这就是被治的攻击面,必须有实测') + assert.ok(open.srv.status().counters.authed > 0) + + /* ② 绿:门开着(verifyIdentity:true)⇒ 同一份坏凭据被拒 */ + const closed = await startRelay(t, { hosts, trustedSignerKeys: [auth.signer.publicKey] }) + const green = await rawHello(closed.url, { hostId: 'w47', secret: s, network: OPS_NETWORK, identity }) + t.after(() => green.ws.close()) + assert.equal(reasonOf(green.frame), 'identity-signature-mismatch', '搬家的凭据必须被签名校验挡住') + assert.equal(closed.srv.status().counters.identityOk, 0) + assert.equal(closed.srv.status().sessions.length, 0, '被拒的节点不得留下任何会话') +}) + +test('D6 凭据合法但**节点证明**是错的 ⇒ identity-bad-proof(有证书 ≠ 有私钥)', async (t) => { + const s = randomBytes(32).toString('hex') + const auth = buildAuthority() + const { grant, sig, node } = auth.issue('w106') + const attacker = generateNodeKey() + const { url } = await startRelay(t, { hosts: { w106: s }, trustedSignerKeys: [auth.signer.publicKey] }) + const { ws, frame } = await rawHello(url, { + hostId: 'w106', + secret: s, + network: OPS_NETWORK, + identity: { + nodeKey: node.publicKey, // 声明的是**真**公钥(凭据也对得上) + grant, + grantSig: sig, + nodeSigOverride: signProof(attacker.privateKeyPem, 'anything'), // 但签名不是那把私钥签的 + }, + }) + t.after(() => ws.close()) + assert.equal(reasonOf(frame), 'identity-bad-proof', '凭据是公开可转发的 ⇒ 必须再证"握有私钥"') +}) + +test('D7 合法身份 ⇒ 真 `RelayClient` 连得上;**撤销单台不牵动全网**', async (t) => { + const sA = randomBytes(32).toString('hex') + const sB = randomBytes(32).toString('hex') + const auth = buildAuthority() + const a = auth.issue('w106') + const b = auth.issue('w47') + const rev = revocationOf(auth.signer.privateKeyPem, ['w106']) + assert.equal(verifyRevocations(rev.doc, rev.sig, [auth.signer.publicKey]).ok, true, '清单本身先要验得过') + + const { srv, url } = await startRelay(t, { + hosts: { w106: sA, w47: sB }, + trustedSignerKeys: [auth.signer.publicKey], + revocations: rev.doc, + requireIdentity: true, + }) + + /** 用**真** `RelayClient` 连(证明客户端侧字段拼得对,不是只有 rawHello 能过)。 */ + const connect = (hostId, secret, issued) => { + const c = new RelayClient({ + url, + hostId, + secret, + ports: [], + dialer: true, + identity: { privateKeyPem: issued.node.privateKeyPem, grant: issued.grant, grantSig: issued.sig }, + log: () => {}, + }) + c.start() + return c + } + + const cA = connect('w106', sA, a) + const cB = connect('w47', sB, b) + t.after(() => { + cA.stop() + cB.stop() + }) + assert.ok(await waitFor(() => cB.status().state === 'up'), '未被撤的 w47 必须连上(撤销不得牵动全网)') + assert.equal(await waitFor(() => cA.status().state === 'up', 1500), false, '被撤的 w106 不得连上') + assert.equal(srv.status().counters.identityOk, 1, '只有一台通过身份校验') + assert.equal( + srv.status().sessions.filter((x) => x.hostId === 'w106').length, + 0, + '被撤节点不得在 relay 侧留下会话', + ) + assert.equal(publicKeyOfPrivate(a.node.privateKeyPem), a.grant.nodeKey, '客户端带的 nodeKey 必须与凭据里那把一致') +}) + +/* ─────────── E:文件面(密钥落点与原地升级) ─────────── */ + +test('E1 keys 文件:新旧写法可**共存**(原地升级 ⇒ 可原子替换 + 可回滚)', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'dshs-keys-')) + t.after(() => rmSync(dir, { recursive: true, force: true })) + const s1 = randomBytes(32).toString('hex') + const s2 = randomBytes(32).toString('hex') + const file = join(dir, 'relay-keys.json') + writeFileSync(file, JSON.stringify({ 'w-47': s1, w106: { network: OPS_NETWORK, secret: s2 } })) + const map = loadKeysFile(file) + assert.deepEqual(map.get('ops/w-47'), { network: OPS_NETWORK, secret: s1 }) + assert.deepEqual(map.get('ops/w106'), { network: OPS_NETWORK, secret: s2 }) + + // 非法网络段 ⇒ **装载即抛**("配置错就炸",不变成运行期的静默拒绝) + writeFileSync(file, JSON.stringify({ bad: { network: 'u', secret: s1 } })) + assert.throws(() => loadKeysFile(file), /非法/) +}) diff --git a/test/overlay-network.test.mjs b/test/overlay-network.test.mjs new file mode 100644 index 0000000..1a162a7 --- /dev/null +++ b/test/overlay-network.test.mjs @@ -0,0 +1,527 @@ +/** + * 覆盖网络 · **② 网抽象(P0-1)** 单测 —— `network_id` 是不是**结构性隔离**。 + * + * ## 这个文件要回答的唯一问题 + * 「不同网络的节点之间**不能互相到达**」这件事,是**结构上做不到**,还是"靠某条 ACL 记得拦"? + * —— 所以本文件的验收标准不是"配置写对了",而是: + * 1. **跨网拨号被 relay 拒**,且拒绝点在**服务端**(有日志、有计数); + * ⚠️ P0-3 起**对外没有专属拒码**:跨网一律回 `target-offline`(与"本网无此节点"逐字同形), + * 区分只留在服务端日志 —— 否则"它在别张网"本身就是一份**可探测的对端清单**。 + * 2. **同名 hostId 在两张网里互不干扰**(会话表按 `/` 索引,不是扁平 `hostId`); + * 3. **旧形态照旧可用**(旧客户端不声明 `network`、旧 drop-in 的扁平白名单 ⇒ 全部落在 `ops`, + * 现网 47 / 106 一行配置不改也不受影响)—— "不破坏现网"与"真落地"必须同时成立。 + * + * 运行:`node --test test/overlay-network.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。 + * + * @module test/overlay-network + */ + +import assert from 'node:assert/strict' +import { createHmac, randomBytes } from 'node:crypto' +import { createServer as createTcpServer, connect } from 'node:net' +import { test } from 'node:test' +import { + MUX, + OPS_NETWORK, + RelayClient, + RelayServer, + assertNetworkId, + decodeMux, + describeDialers, + encodeJsonFrame, + logicalName, + normalizeDialers, + parseLogicalName, +} from '../lib/net/relay/index.js' + +const BASE = 46000 +const SPAN = 200 +const PATH = '/dshs-relay' +const U_TEST = 'u:test-network' + +/* ─────────── 小工具(与 relay.test.mjs 同款,本文件自足) ─────────── */ + +async function listenInRange(server, lo, hi) { + for (let p = lo; p < hi; p++) { + const ok = await new Promise((resolve) => { + const onErr = () => { + server.off('error', onErr) + resolve(false) + } + server.once('error', onErr) + server.listen(p, '127.0.0.1', () => { + server.off('error', onErr) + resolve(true) + }) + }) + if (ok) return server.address().port + } + throw new Error(`no free port in ${lo}..${hi}`) +} + +async function waitFor(cond, ms = 5000) { + const t0 = Date.now() + while (Date.now() - t0 < ms) { + if (cond()) return true + await new Promise((r) => setTimeout(r, 20)) + } + return cond() +} + +/** 回显服务,但**带一个前缀** —— 用来分辨"这条流到底落在哪张网的那台机上"。 */ +function taggedEcho(tag) { + const server = createTcpServer((s) => s.on('data', (c) => s.write(`${tag}:${c.toString('utf8')}`))) + return server +} + +/** 拨号流往返:写进去、读回(前缀长度显式传入,避免"读到一半就认为对了")。 */ +function dialRoundTrip(duplex, text, prefixLen = 0, ms = 5000) { + return new Promise((resolve, reject) => { + let got = '' + const timer = setTimeout(() => { + duplex.destroy() + reject(new Error(`dial roundTrip timeout (got ${got.length}/${text.length + prefixLen})`)) + }, ms) + duplex.on('data', (chunk) => { + got += chunk.toString('utf8') + if (got.length >= text.length + prefixLen) { + clearTimeout(timer) + resolve(got) + } + }) + duplex.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + duplex.write(text) + }) +} + +/** + * 手工发一帧 `HELLO`(构造"正常 client 做不到"的输入:**不带** `network` 的旧客户端、非法 `network`)。 + * + * `network === undefined` ⇒ 字段**根本不出现**(= 现网 47/106 上正在跑的旧客户端)。 + */ +async function rawHello(wsUrl, hostId, secret, portsCsv, nonce, network) { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true }) + }) + const ts = Date.now() + // ⚠️ 与 client.ts 逐字同源的 MAC 输入(**不含 network**)—— 这条"一字不改"本身就是被测对象之一: + // 旧客户端算出来的 MAC 必须仍被接受。 + const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex') + const payload = { v: 1, hostId, ts, nonce, portsCsv, mac } + if (network !== undefined) payload.network = network + ws.send(encodeJsonFrame(MUX.HELLO, 0, payload)) + const frame = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + ws.addEventListener('message', (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }) + ws.addEventListener('close', () => { + clearTimeout(timer) + resolve(undefined) + }) + }) + return { ws, frame } +} + +/** 在一个**已建立**的连接上手工发一帧、等回帧(用于构造"正常 client 不会做"的请求)。 */ +async function rawSend(ws, frameBuf) { + const p = new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + ws.addEventListener('message', (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }) + ws.addEventListener('close', () => { + clearTimeout(timer) + resolve(undefined) + }) + }) + ws.send(frameBuf) + return p +} + +/* ─────────── U1:纯函数(逻辑名与白名单解析) ─────────── */ + +test('U1 逻辑名唯一入口:旧形态 / 新形态 / `u:<租户>` 里的冒号 / 非法值一律抛', () => { + // ① 规范形态 + assert.equal(logicalName('u:5', 'w-1'), 'u:5/w-1') + assert.deepEqual(parseLogicalName('ops/manager'), { network: 'ops', hostId: 'manager' }) + assert.deepEqual(parseLogicalName('u:5/w-106'), { network: 'u:5', hostId: 'w-106' }) + + // ② 兼容形态:`network:hostId`(运维习惯写法) + assert.deepEqual(parseLogicalName('ops:manager'), { network: 'ops', hostId: 'manager' }) + // 🔴 必须**从右**切:`u:5` 里的冒号属于网络 id,从左切会得到 network='u'(不存在那张网 ⇒ 静默拒绝) + assert.deepEqual(parseLogicalName('u:5:manager'), { network: 'u:5', hostId: 'manager' }) + assert.deepEqual(parseLogicalName('u:test-network:pc-1'), { network: 'u:test-network', hostId: 'pc-1' }) + + // ③ 旧形态(R5 时代的扁平 hostId)⇒ 落在运维网,**旧配置照旧可用** + assert.deepEqual(parseLogicalName('manager'), { network: OPS_NETWORK, hostId: 'manager' }) + assert.deepEqual(parseLogicalName('w-106'), { network: OPS_NETWORK, hostId: 'w-106' }) + + // ④ 非法 ⇒ **抛**(配置错就炸,不静默变成"谁也没匹配上") + assert.throws(() => parseLogicalName('u:5'), /网络段/) + assert.throws(() => parseLogicalName('bad net:manager'), /网络段/) + assert.throws(() => parseLogicalName('ops/'), /逻辑名/) + assert.throws(() => assertNetworkId('Ops'), /非法/) + assert.throws(() => assertNetworkId(''), /非法/) + + // ⑤ 白名单归一化:扁平 `Set` = 全在 ops;`Map` 按网络分桶 + assert.deepEqual([...normalizeDialers(new Set(['manager'])).get(OPS_NETWORK)], ['manager']) + const m = normalizeDialers(new Map([[U_TEST, new Set(['d1'])]])) + assert.equal(m.has(OPS_NETWORK), false, '没列到的网络不该凭空出现') + assert.deepEqual([...m.get(U_TEST)], ['d1']) + // 混写(桶里再带前缀)不会静默失配;非法网络段在装载时就炸 + const mixed = normalizeDialers(new Set(['ops:manager', 'u:5:d1'])) + assert.deepEqual(describeDialers(mixed), ['manager', 'u:5/d1'], 'ops 省略前缀、其它网写全逻辑名') + assert.throws(() => normalizeDialers(new Set(['BAD net:d1'])), /网络段/) + // 分桶形态:桶内的**裸 hostId 属于本桶那张网**(不是 ops)—— 这一条错了会变成"配对了却静默拒绝" + assert.deepEqual([...normalizeDialers(new Map([['u:5', new Set(['d1'])]])).get('u:5')], ['d1']) + // 桶内带了前缀就必须与本桶一致,否则说明配置自相矛盾 ⇒ 装载时就炸 + assert.throws(() => normalizeDialers(new Map([['u:5', new Set(['ops:d1'])]])), /自相矛盾/) + + // ⑥ 客户端侧同一条口径:非法 networkId ⇒ 构造时抛(不静默回落 ops) + assert.throws( + () => new RelayClient({ url: 'ws://127.0.0.1:1' + PATH, hostId: 'x', secret: randomBytes(32).toString('hex'), ports: [BASE], networkId: 'BAD' }), + /networkId/, + ) +}) + +/* ─────────── U2:现网不退化(旧客户端 + 旧白名单写法) ─────────── */ + +test('U2 旧客户端(不声明 network)+ 旧扁平白名单 ⇒ 一切照旧,全部落在 ops', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const w47Secret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const echo = taggedEcho('ops') + const servePort = await listenInRange(echo, BASE + 10, BASE + SPAN - 1) + const legacyPort = BASE + 12 + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-106', wSecret], + ['w-47', w47Secret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + // 🔴 旧写法:扁平 `Set`(= 现网 `DSHS_RELAY_DIALERS="manager"` 的形态) + dialers: new Set(['manager']), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + t.after(async () => { + echo.close() + await srv.stop() + }) + + // ① **旧客户端握手**:HELLO 里根本没有 network 字段(= 现网 47/106 上正在跑的那一版) + const { ws: rawWs, frame: ack } = await rawHello(url, 'w-106', wSecret, String(legacyPort), randomBytes(16).toString('hex')) + t.after(() => rawWs.close()) + assert.ok(ack !== undefined, '旧客户端必须能注册(否则这次改动就是硬断)') + assert.equal(ack.type, MUX.HELLO_ACK, '旧客户端应拿到 HELLO_ACK') + const parsed = JSON.parse(ack.payload.toString('utf8')) + assert.equal(parsed.network, OPS_NETWORK, 'HELLO_ACK 应回显服务端认定的网 = ops') + assert.equal(parsed.name, logicalName(OPS_NETWORK, 'w-106')) + assert.ok(await waitFor(() => srv.isOnline('w-106')), '默认网络(ops)里应看到它') + assert.ok( + await waitFor(() => srv.localPortOf('w-106', legacyPort) !== undefined), + '旧客户端照样拿到回环落点(R1–R4 的行为不变)', + ) + + // ② 不传 `networkId` 的真 client = ops(默认值即现网事实);旧扁平白名单照旧拨得动 + const worker = new RelayClient({ url, hostId: 'w-47', secret: w47Secret, ports: [servePort], log: () => {} }) + const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + worker.start() + dialer.start() + t.after(() => { + worker.stop() + dialer.stop() + }) + assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册') + assert.equal(worker.status().network, OPS_NETWORK, '不声明网络 ⇒ 默认 ops(不是空、不是 undefined)') + assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') + assert.deepEqual(srv.status().dialers, ['manager'], '/status 的 dialers 仍按旧写法展示(不破坏既有消费者)') + const duplex = await dialer.openStream('w-47', servePort) + assert.equal(await dialRoundTrip(duplex, 'legacy-ok', 'ops:'.length), 'ops:legacy-ok', '字节要真的过去') + duplex.destroy() +}) + +/* ─────────── U3:跨网隔离是**结构性**的(拒绝点在 relay) ─────────── */ + +test('U3 跨网隔离:u:test-network 的合法拨号方拨 ops/w-106 ⇒ relay 拒,且**不泄露**目标在哪张网', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const opsSecret = randomBytes(32).toString('hex') + const foreignSecret = randomBytes(32).toString('hex') + const logs = [] + const echo = taggedEcho('ops') + const workerPort = await listenInRange(echo, BASE + 20, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-106', wSecret], + ['manager', opsSecret], + // 序③:`dt` 真正属于 U_TEST(密钥表说了算)⇒ 它能进自己的网,然后在 **DIAL 那一步** + // 被跨网判据挡住 —— 这才是本用例要测的那道门,而不是『压根没登记』那道。 + [logicalName(U_TEST, 'dt'), foreignSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + /** + * 两张网**各自都有合法的拨号方**(不是"没配白名单"那种低级情形)—— + * 这样才证明隔离来自**网络维度本身**,而不是来自"忘了加白名单"。 + */ + dialers: new Map([ + [OPS_NETWORK, new Set(['manager'])], + [U_TEST, new Set(['dt'])], + ]), + log: (line) => logs.push(line), + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-106', secret: wSecret, ports: [workerPort], log: () => {} }) + const foreign = new RelayClient({ + url, + hostId: 'dt', + secret: foreignSecret, + ports: [], + dialer: true, + networkId: U_TEST, + log: (line) => logs.push(`[client] ${line}`), + }) + worker.start() + foreign.start() + t.after(async () => { + worker.stop() + foreign.stop() + echo.close() + await srv.stop() + }) + const opsUp = await waitFor(() => srv.isOnline('w-106')) + assert.ok(opsUp, 'ops 侧 worker 未注册') + const foreignUp = await waitFor(() => srv.isOnline('dt', U_TEST)) + assert.ok(foreignUp, `别网拨号方未注册;最近日志:${logs.slice(-10).join(' | ')}`) + + // 网维度视图必须可断言(Step 3「看不到」那一半的落地基础) + const nets = srv.status().networks + assert.deepEqual( + nets.find((n) => n.network === OPS_NETWORK)?.sessions, + ['w-106'], + 'ops 网里应只有 w-106', + ) + assert.deepEqual(nets.find((n) => n.network === U_TEST)?.sessions, ['dt'], '别张网里应只有 dt') + + // 🔴 核心判据 ①:**拨不动** + const before = srv.status().counters.refused + let foreignMsg = '' + await assert.rejects( + () => foreign.openStream('w-106', workerPort), + (err) => { + foreignMsg = err instanceof Error ? err.message : String(err) + return true + }, + '跨网必须被拒', + ) + // 🔴 核心判据 ②:**看不到** —— 对外与"本网没有这个节点"逐字同形(P0-3 · D4 不下发对端清单)。 + // 若这里回 `dialer-not-in-network` / `targetNetwork`,拨号方拿一串猜测的 hostId 打过来 + // 就能把别张网的成员**枚举出来**(判据「看不到」当场失效)。 + assert.match(foreignMsg, /target-offline/, `对外必须是 target-offline,实测:${foreignMsg}`) + assert.equal(foreignMsg.includes(U_TEST), false, `响应里不得出现任何网络名,实测:${foreignMsg}`) + assert.equal( + foreignMsg.includes('dialer-not-in-network'), + false, + `对外不得有"跨网"专属拒码,实测:${foreignMsg}`, + ) + // 但**服务端**必须留得下区分(否则运维分不清"配错网"与"节点离线") + assert.ok(srv.status().counters.refused > before, '拒绝必须在 relay 侧计数(本机就得留痕)') + assert.ok( + logs.some((l) => l.includes('dialer-not-in-network')), + `relay 日志必须记下跨网拒绝(实测日志:${logs.filter((l) => l.includes('DIAL')).join(' | ')})`, + ) + // 目标侧没有任何流被建立(不是"建了再断") + assert.equal( + srv.status().endpoints.find((e) => e.hostId === 'w-106' && e.network === OPS_NETWORK && e.port === workerPort)?.streams, + 0, + '被拒的跨网拨号不得在目标侧留下流', + ) + assert.equal(foreign.status().dialStreams, 0, '被拒后拨号方也不该留悬挂流') + + // 反证:**同网**照旧全通 ⇒ 隔离挡的是"跨网",不是"拨号"本身 + const ops = new RelayClient({ url, hostId: 'manager', secret: opsSecret, ports: [], dialer: true, log: () => {} }) + ops.start() + t.after(() => ops.stop()) + assert.ok(await waitFor(() => ops.status().state === 'up'), 'ops 拨号方未注册') + const duplex = await ops.openStream('w-106', workerPort) + assert.equal(await dialRoundTrip(duplex, 'same-net', 'ops:'.length), 'ops:same-net') + duplex.destroy() +}) + +/* ─────────── U4 / U5:未列白名单的网 & 跨网同名 hostId ─────────── */ + +test('U4 没列进白名单的网:连"拨号方"这个身份都拿不到(no-ports,默认拒绝)', async (t) => { + const s1 = randomBytes(32).toString('hex') + const srv = new RelayServer({ + port: 0, + // 序③:`dt` 登记在 U_TEST ⇒ 成员资格这一关**过得去**,于是拒绝来自**白名单**那一道门 + //(`no-ports`)。不这么登记的话,成员资格会先一步拒,本用例就测不到它原本要测的东西。 + keys: new Map([[logicalName(U_TEST, 'dt'), s1]]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Map([[OPS_NETWORK, new Set(['manager'])]]), // 只有 ops 有拨号方 + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + t.after(async () => { + await srv.stop() + }) + + /** + * R1 的**默认拒绝**姿态决定了一件好事:拨号方的形态是"**端口表为空**",而空端口表只在 + * 该 hostId 命中**本网**白名单时才被接受 ⇒ **白名单外的网络连"拨号方身份"都拿不到** + * (比"拿到了身份但拨不动"更早一道门)。这比 U3 那道门更靠前,两条都要能回归。 + */ + const before = srv.status().counters.authFailed + const { ws, frame } = await rawHello(url, 'dt', s1, '', randomBytes(16).toString('hex'), U_TEST) + t.after(() => ws.close()) + assert.ok(frame !== undefined, '必须得到结构化拒绝') + assert.equal(frame.type, MUX.HELLO_ERR) + assert.equal(JSON.parse(frame.payload.toString('utf8')).reason, 'no-ports', '白名单外的网不该拿到拨号方身份') + assert.ok(srv.status().counters.authFailed > before) + assert.equal(srv.status().sessions.length, 0) + + // 对照:同一个 hostId 只要落进 `ops` 桶(且声明 ops),立刻拿到 `dialer: true` + // ⇒ 说明拒绝来自**网络归属**,不是来自"这个 hostId 本身没被信任"。 + const srv2 = new RelayServer({ + port: 0, + keys: new Map([ + ['dt', s1], + ['w2', s1], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Map([[OPS_NETWORK, new Set(['dt'])]]), + log: () => {}, + }) + await srv2.start() + t.after(async () => { + await srv2.stop() + }) + const ok = await rawHello(`ws://127.0.0.1:${srv2.boundPort}${PATH}`, 'dt', s1, '', randomBytes(16).toString('hex'), OPS_NETWORK) + t.after(() => ok.ws.close()) + assert.equal(ok.frame?.type, MUX.HELLO_ACK) + assert.equal(JSON.parse(ok.frame.payload.toString('utf8')).dialer, true) + + /** + * ② `not-a-dialer` 这条门(R5 就有的默认拒绝)不能被这次改动改坏: + * 一个**已注册的普通 worker**(有端口、不在拨号桶里)手工发 `DIAL` ⇒ relay 必须回拒。 + */ + const wPort = BASE + 95 + const w2 = await rawHello(`ws://127.0.0.1:${srv2.boundPort}${PATH}`, 'w2', s1, String(wPort), randomBytes(16).toString('hex'), OPS_NETWORK) + t.after(() => w2.ws.close()) + assert.equal(w2.frame?.type, MUX.HELLO_ACK, 'worker 形态(声明端口)应能注册') + const ack = await rawSend(w2.ws, encodeJsonFrame(MUX.DIAL, 1, { target: 'dt', port: wPort })) + assert.equal(ack?.type, MUX.DIAL_ACK, 'DIAL 必须被应答(不能静默)') + const ackBody = JSON.parse(ack.payload.toString('utf8')) + assert.equal(ackBody.ok, false) + assert.equal(ackBody.error, 'not-a-dialer', '不在本网拨号桶里的会话不得发起 DIAL') +}) + +test('U5 跨网同名 hostId 互不干扰:各拨各的(会话表按逻辑名索引)', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const dOps = randomBytes(32).toString('hex') + const dUser = randomBytes(32).toString('hex') + const opsEcho = taggedEcho('ops') + const userEcho = taggedEcho('user') + const opsPort = await listenInRange(opsEcho, BASE + 40, BASE + 60) + const userPort = await listenInRange(userEcho, BASE + 61, BASE + 80) + assert.notEqual(opsPort, userPort) + const srv = new RelayServer({ + port: 0, + // 序③:键 = **逻辑名** ⇒ 同一台机器名可以**同时**出现在两张网里,各自是一条独立登记项。 + //(用裸 hostId 当键做不到这件事:`w-1` 只能属于一张网 ⇒ 第二张网就注册不进来了。) + keys: new Map([ + [logicalName(OPS_NETWORK, 'w-1'), wSecret], + [logicalName('u:x', 'w-1'), wSecret], + [logicalName(OPS_NETWORK, 'm-ops'), dOps], + [logicalName('u:x', 'd-user'), dUser], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Map([ + [OPS_NETWORK, new Set(['m-ops'])], + ['u:x', new Set(['d-user'])], + ]), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const wOps = new RelayClient({ url, hostId: 'w-1', secret: wSecret, ports: [opsPort], networkId: OPS_NETWORK, log: () => {} }) + const wUser = new RelayClient({ url, hostId: 'w-1', secret: wSecret, ports: [userPort], networkId: 'u:x', log: () => {} }) + const dOpsC = new RelayClient({ url, hostId: 'm-ops', secret: dOps, ports: [], dialer: true, log: () => {} }) + const dUserC = new RelayClient({ url, hostId: 'd-user', secret: dUser, ports: [], dialer: true, networkId: 'u:x', log: () => {} }) + for (const c of [wOps, wUser, dOpsC, dUserC]) c.start() + t.after(async () => { + for (const c of [wOps, wUser, dOpsC, dUserC]) c.stop() + opsEcho.close() + userEcho.close() + await srv.stop() + }) + assert.ok(await waitFor(() => srv.isOnline('w-1', OPS_NETWORK)), 'ops 侧 w-1 未注册') + assert.ok(await waitFor(() => srv.isOnline('w-1', 'u:x')), 'u:x 侧 w-1 未注册') + const w1Names = srv.status() + .sessions.filter((s) => s.hostId === 'w-1') + .map((s) => s.name) + .sort() + assert.deepEqual(w1Names, ['ops/w-1', 'u:x/w-1'], '两个同名节点必须各自在册(扁平命名空间会只剩一个)') + + // 两张网的拨号方各拨**自己网里**的 w-1 ⇒ 各自落到自己那张网的回显服务(前缀不同 ⇒ 不可能看错) + const dOpsStream = await dOpsC.openStream('w-1', opsPort) + assert.equal(await dialRoundTrip(dOpsStream, 'A', 'ops:'.length), 'ops:A') + dOpsStream.destroy() + const dUserStream = await dUserC.openStream('w-1', userPort) + assert.equal(await dialRoundTrip(dUserStream, 'B', 'user:'.length), 'user:B') + dUserStream.destroy() +}) + +/* ─────────── U6:非法 network ⇒ 失败关闭 ─────────── */ + +test('U6 非法 network 声明 ⇒ bad-network 失败关闭(不建会话、不静默当 ops)', async (t) => { + const secret = randomBytes(32).toString('hex') + const srv = new RelayServer({ + port: 0, + keys: new Map([['w-x', secret]]), + instancePortBase: BASE, + instancePortSpan: SPAN, + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + t.after(async () => { + await srv.stop() + }) + const before = srv.status().counters.authFailed + const { ws, frame } = await rawHello(url, 'w-x', secret, String(BASE + 90), randomBytes(16).toString('hex'), 'bad net!') + t.after(() => ws.close()) + assert.ok(frame !== undefined, '非法网必须得到**结构化拒绝**,而不是静默断开') + assert.equal(frame.type, MUX.HELLO_ERR, '应回 HELLO_ERR') + assert.equal(JSON.parse(frame.payload.toString('utf8')).reason, 'bad-network') + assert.ok(srv.status().counters.authFailed > before, '拒绝要计数') + assert.equal(srv.status().sessions.length, 0, '非法网不得建会话(更不得落到 ops)') + assert.equal(srv.localPortOf('w-x', BASE + 90), undefined, '被拒的节点不得留下任何回环监听') +}) + +/* ─────────── 说明:本文件不覆盖的部分 ─────────── */ + +test('U7 本文件的范围声明(Step 1 只做 P0-1,别在这里顺手做后面两步)', () => { + // Step 2(引导三级链 / `/dshs-overlay/bootstrap`)与 Step 3(名字解析对外接口)见后续步骤, + // ⛔ 不要在本步追加 —— 每步单独可回滚(交接单 §4)。 + assert.ok(true) +}) diff --git a/test/reachability.test.mjs b/test/reachability.test.mjs index c40f11b..1c22c8d 100644 --- a/test/reachability.test.mjs +++ b/test/reachability.test.mjs @@ -47,8 +47,9 @@ test('parseReachability → agentBaseUrl 对现网两行是往返恒等的', () assert.equal(toEndpoint(reach), h.endpoint, `${h.hostId} toEndpoint 不一致`) assert.equal(reach.scheme, 'http') assert.deepEqual( - { hostId: reach.hostId, via: reach.via, address: reach.address }, - { hostId: h.hostId, via: h.via, address: h.endpoint.slice('http://'.length) }, + { hostId: reach.hostId, networkId: reach.networkId, via: reach.via, address: reach.address }, + // 裸 hostId ⇒ 落运维网(P0-3 的过渡期兼容:旧调用方一个字都不用改) + { hostId: h.hostId, networkId: 'ops', via: h.via, address: h.endpoint.slice('http://'.length) }, ) } }) @@ -56,6 +57,7 @@ test('parseReachability → agentBaseUrl 对现网两行是往返恒等的', () test('parseReachability:https / 裸 host:port / 尾斜杠 三种兼容面', () => { assert.deepEqual(parseReachability('h', 'https://a.example:8443', 'x'), { hostId: 'h', + networkId: 'ops', via: 'x', address: 'a.example:8443', scheme: 'https', @@ -63,6 +65,7 @@ test('parseReachability:https / 裸 host:port / 尾斜杠 三种兼容面', () // 没写 scheme ⇒ 按 http(与 fetch 的补全行为一致) assert.deepEqual(parseReachability('h', '10.0.0.5:19000', 'x'), { hostId: 'h', + networkId: 'ops', via: 'x', address: '10.0.0.5:19000', scheme: 'http', @@ -91,6 +94,7 @@ test('LocalRendezvous:命中给 local,未命中回 undefined 不抛', async assert.equal(rv.dialTarget(), '(direct)') assert.deepEqual(await rv.resolve('w-47'), { hostId: 'w-47', + networkId: 'ops', via: VIA_LOCAL, address: '127.0.0.1:19100', scheme: 'http', @@ -124,3 +128,49 @@ test('RendezvousRegistry:按 via 取实现', () => { assert.equal(reg.get('relay:backbone-1'), undefined) assert.deepEqual(reg.ids().sort(), [VIA_LOCAL, VIA_MANAGER_SSH].sort()) }) + +// ── S2 验收判据 ──────────────────────────────────────────────────────────── +// 「迁移前 `agentUrl`」必须 **逐条逐字等于** 「迁移后 `resolve()` 的结果」。 +// 这里把 `hostsProvider` 的真实接线原样复刻一遍(先读 via → 选实现 → 解析 → 拼基址), +// 两条数据用现网实测值写死 ⇒ **不靠肉眼、不靠人工比对**。 +test('S2:via → Rendezvous → Reachability 后取址与旧 agentUrl 逐条相等', async () => { + // 现网 `dsh_hosts` 真实两行(2026-09-16 实测;`via` = 回填后的目标值) + const rows = LIVE_HOSTS.map((h) => ({ id: h.hostId, endpoint: h.endpoint, via: h.via })) + // P0-3:控制面的键 = **逻辑名**(现网全在 `ops` ⇒ 与裸 id 等价) + const nameOf = (h) => `ops/${h.id}` + + const hostAddresses = new Map() + const rendezvous = new RendezvousRegistry([ + new LocalRendezvous((id) => hostAddresses.get(id)), + new ManagerSshRendezvous({ target: 'ssh://root@47.77.182.89:32022', addressOf: (id) => hostAddresses.get(id) }), + ]) + // hostsProvider 第一遍:同步地址表 + for (const row of rows) { + hostAddresses.set(nameOf(row), parseReachability(nameOf(row), row.endpoint, row.via).address) + } + + for (const row of rows) { + const impl = rendezvous.get(row.via) ?? rendezvous.get(VIA_MANAGER_SSH) + assert.notEqual(impl, undefined, `${row.id} 的 via=${row.via} 必须能选到实现`) + const reach = await impl.resolve(nameOf(row)) + assert.notEqual(reach, undefined, `${row.id} 必须能解析出可达性`) + assert.equal(reach.via, row.via, `${row.id} 解析后 via 变了`) + assert.equal(reach.hostId, row.id, `${row.id} 解析出的 hostId 必须是**裸** id`) + assert.equal(reach.networkId, 'ops', `${row.id} 解析出的网络段必须是 ops`) + // 判据:新路径拼出来的基址 == 旧路径直接用的 endpoint + assert.equal(agentBaseUrlOf({ hostId: row.id, reachability: reach }), row.endpoint, `${row.id} 取址变了`) + assert.equal(agentBaseUrlOf({ hostId: row.id, agentUrl: row.endpoint }), row.endpoint, `${row.id} 旧路径变了`) + } +}) + +test('S2:via 认不出来 ⇒ 回退 manager-ssh,不抛(过渡期要能跑)', async () => { + const hostAddresses = new Map([['w-x', '10.0.0.9:19000']]) + const rendezvous = new RendezvousRegistry([ + new LocalRendezvous((id) => hostAddresses.get(id)), + new ManagerSshRendezvous({ target: '', addressOf: (id) => hostAddresses.get(id) }), + ]) + const impl = rendezvous.get('relay:not-deployed-yet') ?? rendezvous.get(VIA_MANAGER_SSH) + const reach = await impl.resolve('w-x') + assert.equal(reach.via, VIA_MANAGER_SSH) + assert.equal(agentBaseUrlOf({ hostId: 'w-x', reachability: reach }), 'http://10.0.0.9:19000') +}) diff --git a/test/relay-failover.test.mjs b/test/relay-failover.test.mjs new file mode 100644 index 0000000..16cfc8c --- /dev/null +++ b/test/relay-failover.test.mjs @@ -0,0 +1,844 @@ +/** + * 覆盖网络 · **序⑦ 中继失败切流** 单测 —— "杀掉任一台中继 ⇒ 客户端自动切到另一台"。 + * + * ## 这个文件要回答的两个问题 + * 1. **候选集还会不会退化成单点?**(改造前 `pickFromDoc` 取到第一个就 `return` + * ⇒ 目录里排第二的那台**永远选不中**,重解析一百次拿回来的还是同一个字符串) + * 2. **"当前这条不健康"时到底会不会换到下一条**,且**换不动时会不会把本来能用的通路打掉**? + * + * ## 四条行为断言(对应交接单 §6 E1–E4 / E7–E9) + * - **候选全取出来**(顺序 = `relays[]` → `bootstrap[]`); + * - **`exclude` 生效**,且**不传 `exclude` 时与改造前逐字一致**(D9 向后兼容); + * - **不健康判据可读**(`unhealthySinceMs` 非空 / 健康时归零); + * - **切换的四条纪律**:能切就切 / 无候选不切空 / 冷却期内不回跳 / 新通道建不起来就保留原通道。 + * 且 `[relay-switch]` 日志行数 **必然等于** `switches` 计数(D7 判别器可断言)。 + * + * 运行:`node --test test/relay-failover.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。 + * + * @module test/relay-failover + */ + +import assert from 'node:assert/strict' +import { generateKeyPairSync, randomBytes } from 'node:crypto' +import { createServer } from 'node:http' +import { createServer as createTcpServer } from 'node:net' +import { test } from 'node:test' +import { + DIRECTORY_PATH, + RelayClient, + RelayFailoverSupervisor, + RelayServer, + buildDirectoryDocument, + gracefulBurstMsDefault, + listOverlayRelayCandidates, + openedChannelFailedTerminally, + resolveOverlayRelay, + signDirectory, + waitUpOnStatus, +} from '../lib/net/relay/index.js' + +/* ─────────── 搭台工具 ─────────── */ + +const RELAY_PATH = '/dshs-relay' + +function makeKeys() { + const { publicKey, privateKey } = generateKeyPairSync('ed25519') + return { + privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), + publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), + } +} + +/** 一份自签目录:`relays[]` 按给定顺序(**不过滤私网** —— `buildDirectoryDocument` 只做解析/去重)。 */ +function signedDoc(relays, keyPem, bootstrap = []) { + const doc = buildDirectoryDocument({ + relays, + bootstrap, + network: 'ops', + now: Date.now(), + refreshAfterSeconds: 300, + }) + return { doc, sig: signDirectory(doc, keyPem) } +} + +/** 起一个真的目录端点(`node:http`),请求 `DIRECTORY_PATH` 返回当前 doc+sig。 */ +function serveDirectory(initial) { + const state = { ...initial } + const server = createServer((req, res) => { + if (!req.url || !req.url.startsWith(DIRECTORY_PATH)) { + res.writeHead(404).end('{}') + return + } + res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }) + res.end(JSON.stringify({ ...state.doc, sig: state.sig })) + }) + return { + state, + async listen() { + return await new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const port = server.address().port + resolve({ port, origin: `http://127.0.0.1:${port}/dshs-relay` }) + }) + }) + }, + close: () => new Promise((resolve) => server.close(() => resolve())), + } +} + +/** 假的"通道句柄"(切流单测不碰真 socket:要测的是**决策**,不是传输)。 */ +function fakeChannel(url, health = { state: 'up', attempts: 0, unhealthyForMs: 0 }) { + const ch = { + url, + closed: 0, + _h: { ...health }, + health: () => ({ ...ch._h }), + /** 测试用:改成不健康。 */ + setHealth(next) { + ch._h = { ...next } + }, + close() { + ch.closed += 1 + }, + } + return ch +} + +/** 攒日志行(用于断言"判别器可 grep")。 */ +function collector() { + const lines = [] + return { + lines, + log: (l) => lines.push(l), + /** `[relay-switch]` 开头的行数 —— **必须等于 `switches`**(D7/E9)。 */ + switchLines: () => lines.filter((l) => l.startsWith('[relay-switch]')).length, + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)) + +async function waitFor(fn, timeoutMs = 5000) { + const deadline = Date.now() + timeoutMs + for (;;) { + if (fn()) return true + if (Date.now() >= deadline) return false + await sleep(10) + } +} + +/* ─────────── F1 / F2:候选集不再退化成单点(E1 / E2) ─────────── */ + +test('F1 候选集:目录含两台中继 ⇒ 两条都取出来、顺序 relays[] → bootstrap[](E1)', async (t) => { + const keys = makeKeys() + const dir = serveDirectory({}) + const { port, origin } = await dir.listen() + t.after(() => dir.close()) + // 目录端点自己也算一个 relay 入口(同源约定)⇒ 用它当"回答目录的那个 origin" + const A = origin + const B = `http://127.0.0.1:${port + 1}/dshs-relay` + const C = `http://127.0.0.1:${port + 2}/dshs-relay` + Object.assign(dir.state, signedDoc([A, B], keys.privatePem, [C])) + + const opts = { + seeds: [A], + trustedKeys: [keys.publicPem], + cacheFile: '', + log: () => {}, + } + const cands = await listOverlayRelayCandidates(opts) + assert.equal(cands.source, 'seed-directory') + assert.equal(cands.urls.length, 3, `应列出全部 3 条候选(relays 2 + bootstrap 1),实际 ${JSON.stringify(cands.urls)}`) + assert.equal(cands.urls[0], 'ws://127.0.0.1:' + port + '/dshs-relay', '首位 = relays[] 第一条(同源优先命中它本身 ⇒ 顺序不变)') + assert.ok(cands.urls[1].endsWith(`:${port + 1}/dshs-relay`), '第二位 = relays[] 第二条') + assert.ok(cands.urls[2].endsWith(`:${port + 2}/dshs-relay`), '末位 = bootstrap[]') +}) + +test('F2 exclude 生效且向后兼容:不传 exclude ⇒ 首位与改造前逐字一致(E2 / D9)', async (t) => { + const keys = makeKeys() + const dir = serveDirectory({}) + const { port, origin } = await dir.listen() + t.after(() => dir.close()) + const A = origin + const B = `http://127.0.0.1:${port + 1}/dshs-relay` + Object.assign(dir.state, signedDoc([A, B], keys.privatePem, [])) + + const base = { seeds: [A], trustedKeys: [keys.publicPem], cacheFile: '', log: () => {} } + const plain = await resolveOverlayRelay(base) + const Aws = `ws://127.0.0.1:${port}/dshs-relay` + const Bws = `ws://127.0.0.1:${port + 1}/dshs-relay` + assert.equal(plain.url, Aws, '不传 exclude ⇒ 仍是首位(D9 存量调用点零影响)') + + const excluded = await resolveOverlayRelay({ ...base, exclude: [Aws] }) + assert.equal(excluded.url, Bws, 'exclude 掉当前那台 ⇒ 换到第二台') + + const both = await resolveOverlayRelay({ ...base, exclude: [Aws, Bws] }) + assert.equal(both.url, '', 'D6:候选被排空 ⇒ 返回空串(调用方保持原地退避,⛔ 不切到空)') + assert.ok(both.detail.endsWith('|exhausted'), '排空时 detail 带 |exhausted 便于取证') +}) + +/* ─────────── F3:不健康判据可读(E3 / S2 口径) ─────────── */ + +test('F3 RelayClient 不健康快照:健康 ⇒ 归零;连不上 ⇒ 非空且态为 backoff(E3)', async (t) => { + // ① 健康:连真的 relay(本文件里唯一一处用真 socket 的地方 —— 要证明"up 状态下确实归零") + const secret = randomBytes(32).toString('hex') + // relay 对声明的端口有两条硬校验:**不能空**(`no-ports`)、**必须在实例口区间内**(`port-out-of-range`) + // ⇒ 起一个真在监听的 echo 服务,且端口落在 `instancePortBase..+span` + const BASE = 34400 + const SPAN = 200 + const echo = createTcpServer((sock) => sock.pipe(sock)) + const declared = await new Promise((resolve, reject) => { + let p = BASE + 7 + const tryBind = () => { + if (p >= BASE + SPAN) return reject(new Error('no free port in range')) + echo.once('error', () => { + p += 1 + tryBind() + }) + echo.listen(p, '127.0.0.1', () => resolve(echo.address().port)) + } + tryBind() + }) + const server = new RelayServer({ + port: 0, + keys: new Map([['w-f3', secret]]), + instancePortBase: BASE, + instancePortSpan: SPAN, + log: () => {}, + }) + await server.start() + const ok = new RelayClient({ + url: `ws://127.0.0.1:${server.boundPort}${RELAY_PATH}`, + hostId: 'w-f3', + secret, + ports: [declared], + log: () => {}, + reconnectMinMs: 10, + reconnectMaxMs: 40, + }) + t.after(async () => { + ok.stop() + await server.stop() + echo.close() + }) + ok.start() + assert.ok(await waitFor(() => ok.status().state === 'up'), '客户端未在 5s 内 up') + const healthy = ok.status() + assert.equal(healthy.unhealthySinceMs, undefined, 'up 状态必须归零(unhealthySinceMs = undefined)') + assert.equal(healthy.unhealthyForMs, 0, 'up 状态 unhealthyForMs 必须为 0') + + // ② 不健康:指向一个**没人监听**的回环口 ⇒ 必然进 backoff + const dead = new RelayClient({ + url: 'ws://127.0.0.1:1/dshs-relay', + hostId: 'w-f3b', + secret, + ports: [], + log: () => {}, + reconnectMinMs: 10, + reconnectMaxMs: 40, + }) + t.after(() => dead.stop()) + dead.start() + assert.ok(await waitFor(() => dead.status().state === 'backoff'), '连不上时必须进入 backoff') + const bad = dead.status() + assert.equal(bad.state, 'backoff') + assert.ok(typeof bad.unhealthySinceMs === 'number', 'backoff 后 unhealthySinceMs 必须非空') + assert.ok(bad.unhealthyForMs >= 0, 'unhealthyForMs 必须可读(≥ 0)') +}) + +/* ─────────── F4–F7:切换决策的四条纪律(E4 / E7 / E8 / D4) ─────────── */ + +/** 搭一个"当前连 A、候选 [A,B]"的监管器。 */ +function setup({ candidates = ['A', 'B'], openImpl } = {}) { + const col = collector() + const opened = [] + const sup = new RelayFailoverSupervisor({ + open: async (url) => { + opened.push(url) + const h = openImpl ? await openImpl(url) : fakeChannel(url) + return h === undefined ? undefined : h + }, + candidates: async () => candidates, + log: col.log, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 }, + }) + return { sup, col, opened } +} + +test('F4 能切就切:当前不健康 ⇒ 换到下一条,且 switches 与 [relay-switch] 行数相等(D7/E9)', async () => { + const col = collector() + const opened = [] + const A = fakeChannel('A', { state: 'backoff', attempts: 3, unhealthyForMs: 1200 }) + const B = fakeChannel('B', { state: 'up', attempts: 0, unhealthyForMs: 0 }) + const sup = new RelayFailoverSupervisor({ + open: async (url) => { + opened.push(url) + return url === 'B' ? B : undefined + }, + candidates: async () => ['A', 'B'], + log: col.log, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 }, + }) + sup.seed(A) + await sup.tick() + + const st = sup.stats() + assert.deepEqual(opened, ['B'], '只应去建 B 这一条') + assert.equal(st.switches, 1, '必须切换一次') + assert.equal(sup.channel, B, '当前通道必须已是 B') + assert.equal(A.closed, 1, '旧通道必须被关掉(先建新、成功再关旧)') + assert.equal(B.closed, 0, '新通道不能被关') + assert.equal(col.switchLines(), st.switches, 'D7:日志行数必须等于 switches 计数') + assert.match(col.lines.find((l) => l.startsWith('[relay-switch]')), /#1 A -> B/) + assert.equal(st.cooldown.length, 1, '被换掉的 A 必须进冷却表') + assert.equal(st.cooldown[0].url, 'A') +}) + +test('F5 无候选不切空:链里只剩当前那台 ⇒ 原地退避、switches 不增、不静默回退(D6/E7)', async () => { + const { sup, col } = setup({ candidates: ['A'] }) + const A = fakeChannel('A', { state: 'backoff', attempts: 8, unhealthyForMs: 9000 }) + sup.seed(A) + await sup.tick() + await sup.tick() + + const st = sup.stats() + assert.equal(st.switches, 0, '⛔ 不许切到空') + assert.equal(sup.channel, A, '⛔ 不许静默回退到别的机器') + assert.ok(st.noCandidateChecks >= 1, '必须记下"无候选可切"的次数(D6 现场证据)') + assert.equal(col.switchLines(), 0, '没有切换 ⇒ 不许有 [relay-switch] 行') + assert.ok(col.lines.some((l) => l.startsWith('[relay-skip]')), '必须有一行 [relay-skip] 说明为何不切') +}) + +/** + * 🔴 **序⑧ 的冲突与裁决(F6 / F9 为什么多了 `exempt: false`)**: + * + * 本用例的场景**恰好就是**序⑧ 要改的那个现场 —— 当前通道 `B` 不健康、链里唯一的替代 `A` 正在冷却 + * ⇒ `tick()` 过滤后 `target === undefined` = **D6 现场**。序⑧ 的 D1 在这里**故意**开了"一跳豁免" + * (D1 原文:"旧 url 已被证伪 ⇒ 回跳它不是抖动,而是**唯一可能的出路**")。 + * 而交接单 §3.1-5 / E4 又要求"序⑦ F1–F11 全绿、不许改语义" —— 两者在**这个场景**上真冲突 + * (真机 E9 幕 4 与它同构 ⇒ 没有任何判据能"只豁免幕 4、不豁免 F6")。 + * + * 裁决(依 D3 的**精确口径**):D3 给的护栏是"**有干净候选时**行为逐字不变" —— + * 本场景**没有**干净候选,所以不在 D3 的保证范围内。⇒ + * **断言逐字不变**,只把"关闭序⑧ 豁免"这个开关显式写进用例配置 ⇒ + * 本用例继续锁住 **D5 的基线语义**(= 序⑦ 逐字行为),序⑧ 的新行为由 **F13 / F15 / F16 / F17** 锁住。 + */ +test('F6 冷却期内不回跳:A 恢复了但仍在冷却 ⇒ 不换回 A(D5/E8;豁免关 = 序⑦ 基线)', async () => { + const col = collector() + let A + const sup = new RelayFailoverSupervisor({ + open: async (url) => (url === 'B' ? fakeChannel('B') : fakeChannel(url)), + candidates: async () => ['A', 'B'], + log: col.log, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10, exempt: false }, + }) + A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() + assert.equal(sup.stats().switches, 1, '第一次:A 挂 ⇒ 切 B') + + // B(当前)也变成不健康;此时 A 已"恢复"(健康)但**在冷却期内** + const B = sup.channel + B.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 }) + await sup.tick() + + assert.equal(sup.stats().switches, 1, '⛔ 冷却期内不回跳(否则两台互相抢 = 抖动风暴)') + assert.equal(sup.channel, B, '仍应留在 B') +}) + +test('F7 新通道建不起来 ⇒ 原通道原样保留(D4 / R11)', async () => { + const { sup, col } = setup({ openImpl: async () => undefined }) + const A = fakeChannel('A', { state: 'backoff', attempts: 9, unhealthyForMs: 9000 }) + sup.seed(A) + await sup.tick() + + const st = sup.stats() + assert.equal(st.switches, 0, '建不起来就不算切换') + assert.equal(st.openFailed, 1, '必须记下 openFailed') + assert.equal(sup.channel, A, '⛔ 原通道必须保留(把本来能用的通路打掉才是真事故)') + assert.equal(A.closed, 0, '⛔ 不许把旧通道关掉') + assert.equal(col.switchLines(), 0) +}) + +test('F8 巡检只在"不健康"时动手:健康通道连跑多轮 ⇒ 零切换、零 open', async () => { + const { sup, opened, col } = setup() + const A = fakeChannel('A', { state: 'up', attempts: 0, unhealthyForMs: 0 }) + sup.seed(A) + for (let i = 0; i < 5; i += 1) await sup.tick() + assert.equal(opened.length, 0, '健康时不许调 open(否则每次巡检都白建一条通道)') + assert.equal(sup.stats().switches, 0) + assert.equal(col.switchLines(), 0) +}) + +/* ─────────── F9 / F10:真机拓扑逼出来的两条(注入时钟 / 死候选) ─────────── */ + +/** + * F9 · **冷却期满 ⇒ 自动回归候选表**(D5 后半句)。 + * + * 为什么必须用注入时钟:真机冷却 300 s,等不起。等不起的判据就等于没有 ⇒ 必须可确定性复现。 + * + * ⚠️ 序⑧:本用例中段("冷却未满 ⇒ 不回跳")同样是 **D6 现场** ⇒ 与 F6 同因,显式置 + * `exempt: false`(= 锁序⑦ 基线;序⑧ 的新行为见 F13/F15/F16/F17)。 + */ +test('F9 冷却期满 ⇒ 失败过的那台自动回归候选表(D5;豁免关 = 序⑦ 基线)', async () => { + const col = collector() + let now = 1_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => fakeChannel(url), + candidates: async () => ['A', 'B'], + log: col.log, + nowMs: () => now, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10, exempt: false }, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() + assert.equal(sup.stats().switches, 1, 'A 挂 ⇒ 切 B,A 进冷却') + assert.equal(sup.channel.url, 'B') + + // B 也挂;此刻 A 已"恢复",但**冷却未满** ⇒ 不回跳 + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 }) + now += 59_000 + await sup.tick() + assert.equal(sup.stats().switches, 1, '冷却未满 ⇒ 不回跳') + + // 冷却期满 ⇒ A 回归候选 ⇒ 允许换回 A + now += 2_000 + await sup.tick() + assert.equal(sup.stats().switches, 2, '冷却期满 ⇒ 回归候选并换回') + assert.equal(sup.channel.url, 'A') + assert.equal(col.switchLines(), 2, 'D7:日志行数仍等于 switches') +}) + +/** + * F10 · **试失败的候选不能把链堵死**(真机拓扑逼出来的一条)。 + * + * 生产目录 `relays[]` 前两条**落在同一台机器**上:杀那台时,若失败的候选不进冷却, + * 每次巡检都会卡在同一条上、**永远推进不到第三条** ⇒ 链"不再退化成单点"却依然换不过去。 + */ +test('F10 前两个候选建不起来 ⇒ 自动推进到第三个(失败候选进冷却,⛔ 不堵链)', async () => { + const col = collector() + const alive = fakeChannel('C', { state: 'up', attempts: 0, unhealthyForMs: 0 }) + const tried = [] + const sup = new RelayFailoverSupervisor({ + open: async (url) => { + tried.push(url) + return url === 'C' ? alive : undefined // A / B 同机已死 + }, + candidates: async () => ['A', 'B', 'C'], + log: col.log, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 }, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + + await sup.tick() // 试 B(A 是当前 ⇒ 被排除)⇒ 失败 ⇒ 进冷却 + assert.equal(sup.stats().switches, 0, '第一次不该算切换') + await sup.tick() // B 已在冷却 ⇒ 推进到 C ⇒ 成功 + assert.equal(sup.stats().switches, 1, '第二次必须推进到 C 并切换成功') + assert.equal(sup.channel, alive) + assert.deepEqual(tried, ['B', 'C'], '尝试顺序必须是"下一个候选 → 再下一个"(⛔ 不许卡在 B 上反复试)') + assert.equal(col.switchLines(), sup.stats().switches) +}) + +/** + * F11 · **冷却闸门对"目录地址变更"这条路径同样生效**(D5)。 + * + * 真机实测逼出来的一条:`refreshOverlay`(目录地址变了)直接调 `replace()`, + * 它**不看冷却表** ⇒ 会把刚被冷却的地址立刻换回来 ⇒ 抖动抑制被绕开。 + */ +test('F11 冷却期内的目标:连"目录地址变更"路径也不许换过去(D5)', async () => { + const col = collector() + let now = 5_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => fakeChannel(url), + candidates: async () => ['A', 'B'], + log: col.log, + nowMs: () => now, + thresholds: { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 }, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // A 挂 ⇒ 切 B,A 进冷却 + assert.equal(sup.channel.url, 'B') + assert.equal(sup.stats().switches, 1) + + // 模拟 `refreshOverlay`:目录首位又变回 A(刚被冷却) + // ⚠️ 序⑧ 起 `replace()` 必须显式声明 `origin`('directory' = **无豁免权**,D1)—— + // 本用例的语义与序⑦ 逐字相同(仍是"冷却期内不许换过去"),只是把隐含意图变成显式参数。 + const ok = await sup.replace('A', '目录地址变更(source=cache)', 'directory') + assert.equal(ok, false, '⛔ 冷却期内的目标不许换过去(否则抖动抑制形同不存在)') + assert.equal(sup.stats().switches, 1, 'switches 不许增加') + assert.equal(sup.channel.url, 'B', '仍应留在 B') + + // 冷却期满 ⇒ 允许换回 A + now += 61_000 + assert.equal(await sup.replace('A', '目录地址变更(source=cache)', 'directory'), true, '冷却期满 ⇒ 允许') + assert.equal(sup.channel.url, 'A') +}) + +/* ─────────── F12–F17:序⑧「切流冷却语义」(D1–D6) ─────────── */ + +/** 序⑧ 的公共阈值:与 F9/F11 同口径(可注入时钟 ⇒ 冷却期满可确定性复现)。 */ +const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_000, checkMs: 10 } + +/** + * F12 · **目录路径没有豁免权**(E1 / D1)。 + * + * 立项依据:真机 11:43:26 实测 `wss://106… -> wss://alotbuy.com…` —— 只因"目录里的地址变了" + * 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。 + */ +test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => { + const col = collector() + let now = 1_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => fakeChannel(url), + candidates: async () => ['A', 'B'], + log: col.log, + nowMs: () => now, + thresholds: TH8, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // A 挂 ⇒ 切 B;A 进冷却(kind = switched-away) + assert.equal(sup.channel.url, 'B') + assert.equal(sup.stats().switches, 1) + assert.deepEqual( + sup.stats().cooldown.map((c) => [c.url, c.kind]), + [['A', 'switched-away']], + '冷却表已结构化:键仍按 url,`kind` 记录"我们主动离开了它"', + ) + + now += 5_000 + const ok = await sup.replace('A', '目录地址变更(source=cache)', 'directory') + assert.equal(ok, false, '⛔ 目录路径**不得**打破冷却(D1)') + assert.equal(sup.stats().switches, 1, 'switches 不许增加') + assert.equal(sup.stats().exemptSwitches, 0, '⛔ 这不是豁免') + assert.equal(sup.channel.url, 'B', '仍应留在 B') + assert.ok( + col.lines.some((l) => l.startsWith('[relay-skip]') && l.includes('仍在冷却')), + '必须留下"仍在冷却"的判别器行', + ) + assert.ok( + col.lines.every((l) => !l.includes('|豁免')), + '⛔ 目录路径不许出现豁免标记', + ) +}) + +/** + * F13 · **一跳豁免成立**(E2 / D1 / D4)。 + * + * 现场 = D6:生产目录 3 条候选里 **2 条同机** ⇒ 一次 47 故障把它们**同时**耗进冷却 ⇒ + * "当前这条也挂了"时链里再无干净候选 ⇒ 序⑧ 之前是**最长 `cooldownMs` 不切流**。 + */ +test('F13 一跳豁免:D6 现场 + 1 条 switched-away ⇒ 切过去并计数(E2 / D4)', async () => { + const col = collector() + let now = 2_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => fakeChannel(url), + candidates: async () => ['A', 'B'], + log: col.log, + nowMs: () => now, + thresholds: TH8, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // A 挂 ⇒ 切 B;A 进冷却 + assert.equal(sup.channel.url, 'B') + + // B(当前)也挂;此刻 A **仍在冷却窗内**(60 s)⇒ 候选池被耗干 = D6 现场 + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 }) + now += 5_000 + await sup.tick() + + const st = sup.stats() + assert.equal(st.switches, 2, '豁免必须完成一次真实切换') + assert.equal(st.exemptSwitches, 1, 'D7:豁免切换必须计数(⊆ switches)') + assert.equal(sup.channel.url, 'A', '必须切回 A') + assert.equal(st.noCandidateChecks, 0, '豁免成功 ⇒ 不该再记"无候选"') + const sw = col.lines.filter((l) => l.startsWith('[relay-switch]')) + assert.equal(sw.length, st.switches, 'D7:日志行数必须等于 switches(豁免行也是 switch 行)') + assert.match(sw[1], /|豁免 kind=switched-away /, '豁免切换必须带可断言的判别器标记') + assert.match(sw[1], /原因:当前通道不健康/, 'E9③:原因必须是 health 路径,⛔ 不是"目录地址变更"') +}) + +/** + * F14 · **豁免优先级**(E3 / D5):`switched-away` 优先于 `open-failed`。 + * + * 语义依据:`switched-away` = "我们主动离开了一件**曾可用**的东西";`open-failed` = "刚证明它建不起来"。 + * 本用例里 `open-failed` 的 `untilMs` **更早**(先失败先解除),仍然必须让位于 `switched-away` + * ⇒ 同时验证"优先级**压过** `untilMs` 升序"。 + */ +test('F14 豁免优先级:switched-away 压过 open-failed(E3 / D5)', async () => { + const col = collector() + let now = 3_000_000 + const tried = [] + const sup = new RelayFailoverSupervisor({ + open: async (url) => { + tried.push(url) + return url === 'X' ? undefined : fakeChannel(url) // X 永远建不起来 + }, + candidates: async () => ['C', 'X', 'Y'], + log: col.log, + nowMs: () => now, + thresholds: TH8, + }) + const C = fakeChannel('C', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(C) + await sup.tick() // 试 X ⇒ 失败 ⇒ X 进冷却(open-failed) + assert.equal(sup.stats().openFailed, 1) + now += 1_000 + await sup.tick() // 推进到 Y ⇒ 成功;C 进冷却(switched-away) + assert.equal(sup.channel.url, 'Y') + assert.deepEqual( + sup.stats().cooldown.map((c) => [c.url, c.kind]), + [ + ['X', 'open-failed'], + ['C', 'switched-away'], + ], + '两条冷却条目:X 先建冷却(untilMs 更早),C 后建', + ) + + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + now += 1_000 + await sup.tick() // D6 现场 ⇒ 豁免:必须挑 C(switched-away),⛔ 不是 untilMs 更早的 X + + assert.deepEqual(tried.slice(-1), ['C'], '豁免必须挑 switched-away 那条(压过 untilMs 升序)') + assert.equal(sup.channel.url, 'C') + assert.equal(sup.stats().exemptSwitches, 1) +}) + +/** + * F15 · 🔴 **D3 护栏:有干净候选时行为逐字不变**。 + * + * 这是本单**最重要的一条不变量** —— 豁免一旦泄漏进正常路径,就会变成"每轮巡检都想回跳"(新抖动源), + * 等于把序⑦ 的 E1–E11 结论**自己推翻自己**。 + */ +test('F15 有干净候选 ⇒ 绝不走豁免(D3 护栏)', async () => { + const col = collector() + let now = 4_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => (url === 'B' ? undefined : fakeChannel(url)), // B 永远建不起来 + candidates: async () => ['A', 'B', 'C', 'D'], + log: col.log, + nowMs: () => now, + thresholds: TH8, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // 试 B ⇒ 失败 ⇒ B 进冷却 + now += 1_000 + await sup.tick() // 推进到 C ⇒ 成功;A 进冷却 + assert.equal(sup.channel.url, 'C') + assert.equal(sup.stats().switches, 1) + + // 当前 C 也挂:此时 A(switched-away)/ B(open-failed)都在冷却,但 **D 是干净的** + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 }) + now += 1_000 + await sup.tick() + + const st = sup.stats() + assert.equal(sup.channel.url, 'D', '有干净候选 ⇒ 必须走**正常**候选链(D3)') + assert.equal(st.switches, 2) + assert.equal(st.exemptSwitches, 0, '⛔ 豁免一次都不许发生') + assert.equal(st.noCandidateChecks, 0, '⛔ 也不该记"无候选"') + assert.ok( + col.lines.every((l) => !l.includes('|豁免')), + '⛔ 日志里不许出现豁免标记(逐字回到序⑦)', + ) +}) + +/** + * F16 · **豁免有界**(E5 / D4):每 url **每冷却周期一次**;豁免再失败 ⇒ 重置冷却且本周期不再豁免。 + * + * ⛔ 不设界 = "每 2 s 豁免一次、每次都失败" = **重试风暴**,比不切更糟(R11)。 + * ⚠️ 全程**不推进注入时钟** ⇒ 证明"同一冷却周期内"。两轮豁免各失败一次会让 `openFailed` 递增, + * 但**同一个 url 只会被试一次**。 + */ +test('F16 豁免有界:同周期内每个冷却候选只豁免一次、不再重复试(E5 / D4)', async () => { + const col = collector() + let now = 6_000_000 + const tried = [] + const sup = new RelayFailoverSupervisor({ + open: async (url) => { + tried.push(url) + return url === 'C' ? fakeChannel('C') : undefined // 只有 C 能起 + }, + candidates: async () => ['A', 'B', 'C'], + log: col.log, + nowMs: () => now, + thresholds: TH8, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // 试 B ⇒ 失败 ⇒ B 进冷却(open-failed) + await sup.tick() // 推进到 C ⇒ 成功;A 进冷却(switched-away) + assert.equal(sup.channel.url, 'C') + assert.equal(sup.stats().switches, 1) + assert.equal(sup.stats().openFailed, 1) + + // C 也挂 ⇒ D6 现场(A、B 均冷却)。此后**不推进时钟** ⇒ 全程同一冷却周期。 + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + await sup.tick() // 豁免 A(switched-away 优先)⇒ 失败 ⇒ A 本周期额度用尽 + assert.equal(sup.stats().openFailed, 2, '第一次豁免失败必须计数') + await sup.tick() // A 已被排除 ⇒ 豁免 B ⇒ 也失败 + assert.equal(sup.stats().openFailed, 3, '第二个冷却候选仍可豁免一次') + await sup.tick() // 池子空了 ⇒ 回到原地退避,⛔ 不许再试 A/B + await sup.tick() + + const st = sup.stats() + assert.equal(st.openFailed, 3, '⛔ 重试风暴:同一 url 每周期只许试一次') + assert.equal(st.switches, 1, '豁免全失败 ⇒ 不许增加 switches') + assert.equal(st.exemptSwitches, 0) + assert.ok(st.noCandidateChecks >= 2, '池子空了必须记"无候选"(D6 现场证据)') + assert.deepEqual(tried, ['B', 'C', 'A', 'B'], '尝试序列:B(正常)→ C(正常)→ A(豁免)→ B(豁免)') + assert.ok( + col.lines.some((l) => l.includes('本周期不再豁免')), + '必须留下"本周期不再豁免"的判别器行', + ) + assert.equal(col.switchLines(), st.switches, 'D7:行数仍等于 switches') +}) + +/** + * F17 · **两层开关各司其职**(E6 / D6):`RELAY_FAILOVER_EXEMPT=0` ⇒ 逐字回到序⑦ 行为。 + */ +test('F17 总开关 RELAY_FAILOVER_EXEMPT=0 ⇒ 无豁免(第二层回滚点)', async () => { + const col = collector() + let now = 7_000_000 + const sup = new RelayFailoverSupervisor({ + open: async (url) => fakeChannel(url), + candidates: async () => ['A', 'B'], + log: col.log, + nowMs: () => now, + thresholds: { ...TH8, exempt: false }, + }) + const A = fakeChannel('A', { state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + sup.seed(A) + await sup.tick() // A 挂 ⇒ 切 B;A 进冷却 + assert.equal(sup.channel.url, 'B') + + sup.channel.setHealth({ state: 'backoff', attempts: 5, unhealthyForMs: 5000 }) + A.setHealth({ state: 'up', attempts: 0, unhealthyForMs: 0 }) + now += 5_000 + await sup.tick() + + const st = sup.stats() + assert.equal(st.switches, 1, '⛔ 开关关闭 ⇒ 回到"原地退避"(序⑦ 行为,逐字)') + assert.equal(st.exemptSwitches, 0) + assert.equal(sup.channel.url, 'B') + assert.ok(st.noCandidateChecks >= 1, '必须回到"链里无其他候选"的原地退避路径') + assert.ok(col.lines.every((l) => !l.includes('|豁免')), '⛔ 日志里不许出现豁免标记') +}) + +/* ═══════════════════════════════════════════════════════════════════════════ + * 序⑨ · **换址等待的"终态失败"**(RC-1)—— F18–F21 + * + * 背景(实测,序⑨ §2-P10 逐行复核):`waitUpOn` 只轮询 `state === 'up'` ⇒ **死候选与慢候选 + * 不可区分**,代价恒为 `upTimeoutMs`(12 s)。生产目录前两条候选**同在 47** ⇒ 每次从 47 切走 + * 都先试同机的 `relay-direct`(已随 47 一起死)⇒ **固定白等 12 s**,占 30 s 墙钟的 40%。 + * + * 纪律:① 修(F19)必须有**护栏**(F18:慢候选不许被误杀)② burst 窗口(计划内重启) + * **不许**被当终态失败(F20,否则每次 relay 重启都切流 = D3 要防的抖动)。 + * ═══════════════════════════════════════════════════════════════════════════ */ + +/** + * 假客户端:按脚本在给定毫秒数后切换 `status()`(⛔ 不碰网络 —— 本组只验"等待逻辑"这一层)。 + * `plan` = `[{ at: 毫秒(相对本次调用开始), st: 状态片段 }]`,后者覆盖前者。 + */ +function fakeWaitee(plan, base = {}) { + const t0 = Date.now() + const seen = [] + return { + seen, + status() { + const el = Date.now() - t0 + let st = { state: 'connecting', attempts: 0, inGracefulBurstWindow: false, ...base } + for (const p of plan) if (el >= p.at) st = { ...st, ...p.st } + seen.push(st.state) + return st + }, + } +} + +/** + * F21 · 判据的**切面**(先于行为用例:把"什么算终态失败"钉死在四个反例上)。 + */ +test('F21 终态失败判据的四个反例:connecting / handshaking / queued(attempts=0) / burst 窗口内 ⇒ 都不算', () => { + const dead = { state: 'backoff', attempts: 1, inGracefulBurstWindow: false } + assert.equal(openedChannelFailedTerminally(dead), true, 'backoff + 已记账 + 非 burst ⇒ 终态失败') + + const cases = [ + ['正在连(connecting)', { ...dead, state: 'connecting' }], + ['正在握手(handshaking)', { ...dead, state: 'handshaking' }], + ['满载排队(queued ⇒ attempts 被归零)', { ...dead, attempts: 0 }], + ['计划内重启的 burst 窗口内', { ...dead, inGracefulBurstWindow: true }], + ] + for (const [name, st] of cases) { + assert.equal(openedChannelFailedTerminally(st), false, `${name} ⛔ 不许判成终态失败`) + } +}) + +/** + * F19 · **死候选 ⇒ 提前失败**(本单的核心修法;⛔ 这是判据的"红→绿"分水岭)。 + * 旧实现下本断言必然失败:白等满 12 000 ms。 + */ +test('F19 死候选:`backoff` + 已记账 + 非 burst ⇒ 提前失败(⛔ 不白等满 upTimeoutMs)', async () => { + const dead = fakeWaitee([ + { at: 100, st: { state: 'backoff', attempts: 1, inGracefulBurstWindow: false, lastError: 'transport error' } }, + ]) + const t0 = Date.now() + const ok = await waitUpOnStatus(dead, 12_000) + const ms = Date.now() - t0 + assert.equal(ok, false, '死候选必须返回 false') + assert.ok(ms < 2_000, `必须远早于 upTimeoutMs(12000) 返回;旧实现会白等满,实测 ${ms}ms`) +}) + +/** + * F18 · **护栏:慢候选不许被误杀**(R11 不变量)。 + * + * 慢候选在到达 `up` 之前**一直处于 `connecting`**,从不进 `backoff` ⇒ 判据恒不成立 ⇒ + * 照旧享受完整 `upTimeoutMs`。⛔ 这条是"早退"的安全带:没有它,早退会退化成 + * "更频繁地切到第三候选"甚至"全部候选都判失败"。 + */ +test('F18 护栏:慢候选(连得上、`up` 来得晚)⇒ 仍等满预算且必须成功', async () => { + const slow = fakeWaitee([{ at: 800, st: { state: 'up', attempts: 0 } }]) + const t0 = Date.now() + const ok = await waitUpOnStatus(slow, 12_000) + const ms = Date.now() - t0 + assert.equal(ok, true, '慢候选必须成功 —— ⛔ 早退不许误杀') + assert.ok(ms >= 600 && ms < 5_000, `应等到 ~800ms 它自己 up 为止,实测 ${ms}ms`) +}) + +/** + * F22 · **`gracefulBurstMs` 参数表化**(序⑨ §3.1-4):默认值语义**逐字不变**,只是可配了。 + * ⛔ 只加可配性、⛔ 不改默认值(E10 的同族纪律:判据/阈值不许被悄悄放宽)。 + */ +test('F22 RELAY_GRACEFUL_BURST_MS:默认 15000 逐字不变,显式覆写才生效', () => { + assert.equal(gracefulBurstMsDefault({}), 15_000, '⛔ 默认值必须逐字不变') + assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '' }), 15_000, '空串 ⇒ 回落默认') + assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: 'abc' }), 15_000, '非法值 ⇒ 回落默认(⛔ 不抛)') + assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '-1' }), 15_000, '负数 ⇒ 回落默认') + assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '0' }), 0, '0 = 显式关掉 burst 窗口(合法值)') + assert.equal(gracefulBurstMsDefault({ RELAY_GRACEFUL_BURST_MS: '30000' }), 30_000, '显式覆写生效') +}) + +/** + * F20 · **计划内重启(burst 窗口)⛔ 不算终态失败**(D3 保护)。 + * + * 窗口内 `state=backoff attempts=1` 与"死候选"**字面完全一样**,唯一区分依据就是 + * `inGracefulBurstWindow`。⛔ 若把它当死候选 ⇒ **每次 relay 重启 / 部署都切一次流**。 + */ +test('F20 burst 窗口(计划内重启)内必须继续等,窗口过后才允许判死(D3 保护)', async () => { + const restarting = fakeWaitee([ + { at: 100, st: { state: 'backoff', attempts: 1, inGracefulBurstWindow: true } }, + { at: 1_500, st: { state: 'up', attempts: 0 } }, + ]) + const t0 = Date.now() + const ok = await waitUpOnStatus(restarting, 12_000) + const ms = Date.now() - t0 + assert.equal(ok, true, 'burst 窗口内必须继续等 ⇒ 对端重启完就 up') + assert.ok(ms >= 1_300, `⛔ 不许在窗口内就判死(旧坑:100ms 处就返回 false);实测 ${ms}ms`) +}) diff --git a/test/relay.test.mjs b/test/relay.test.mjs new file mode 100644 index 0000000..3cc0586 --- /dev/null +++ b/test/relay.test.mjs @@ -0,0 +1,1318 @@ +/** + * 覆盖网络 · relay R1 单测(**真起服务、真握手、真泵字节**,不 mock 传输层)。 + * + * ## 为什么必须是"真链路"测试 + * relay 要替掉的是 sshd 反向隧道,而 sshd 那条路的病根正是**静默失败**(`-R` 撞号时没人 + * 检查返回值)。所以本文件的验收标准不是"函数返回了 true",而是: + * 1. **字节真的过去了**(端到端回环泵,T3); + * 2. **失败真的被拒了**(错密钥 / 重放 / 越界端口,T4–T6)—— 且**有计数**可查。 + * + * 运行:`node --test test/relay.test.mjs`(已登记进 `npm run verify`)。 + * + * @module test/relay + */ + +import assert from 'node:assert/strict' +import { createHmac, randomBytes } from 'node:crypto' +import { createServer as createTcpServer, connect } from 'node:net' +import { test } from 'node:test' +import { MUX, OPS_NETWORK, RelayClient, RelayDialer, RelayServer, chooseNode, decodeMux, encodeJsonFrame, encodeMux, logicalName, parseKeysInline } from '../lib/net/relay/index.js' + +const BASE = 45000 +const SPAN = 200 +const PATH = '/dshs-relay' + +/* ─────────── 小工具 ─────────── */ + +async function listenInRange(server, lo, hi) { + for (let p = lo; p < hi; p++) { + const ok = await new Promise((resolve) => { + const onErr = () => { + server.off('error', onErr) + resolve(false) + } + server.once('error', onErr) + server.listen(p, '127.0.0.1', () => { + server.off('error', onErr) + resolve(true) + }) + }) + if (ok) return server.address().port + } + throw new Error(`no free port in ${lo}..${hi}`) +} + +async function waitFor(cond, ms = 5000) { + const t0 = Date.now() + while (Date.now() - t0 < ms) { + if (cond()) return true + await new Promise((r) => setTimeout(r, 20)) + } + return cond() +} + +/** 连一个端口、写一段字、读回同样的字(带超时,避免测试悬挂)。 */ +function roundTrip(port, text, ms = 5000) { + return new Promise((resolve, reject) => { + const sock = connect(port, '127.0.0.1') + let got = '' + const timer = setTimeout(() => { + sock.destroy() + reject(new Error(`roundTrip timeout after ${ms}ms (got ${got.length}/${text.length} bytes)`)) + }, ms) + sock.on('connect', () => sock.write(text)) + sock.on('data', (chunk) => { + got += chunk.toString('utf8') + if (got.length >= text.length) { + clearTimeout(timer) + sock.end() + resolve(got) + } + }) + sock.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} + +/** 用内建 WebSocket 手工发一帧 HELLO(用于构造"正常 client 做不到"的非法输入)。 */ +async function rawHello(wsUrl, hostId, secret, portsCsv, nonce) { + const ws = new WebSocket(wsUrl) + ws.binaryType = 'arraybuffer' + await new Promise((resolve, reject) => { + ws.addEventListener('open', resolve, { once: true }) + ws.addEventListener('error', () => reject(new Error('ws open failed')), { once: true }) + }) + const ts = Date.now() + const mac = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex') + ws.send(encodeJsonFrame(MUX.HELLO, 0, { v: 1, hostId, ts, nonce, portsCsv, mac })) + const frame = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + ws.addEventListener('message', (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }) + ws.addEventListener('close', () => { + clearTimeout(timer) + resolve(undefined) + }) + }) + return { ws, frame } +} + +/* ─────────── T1 / T2:纯函数 ─────────── */ + +test('T1 mux 帧编解码往返(含 streamId 边界与空负载)', () => { + for (const [type, id, payload] of [ + [MUX.HELLO, 0, Buffer.from('x')], + [MUX.DATA, 1, Buffer.alloc(0)], + [MUX.DATA, 0xffffffff, Buffer.from([0, 1, 2, 3])], + [MUX.OPEN, 4294967295 - 1, Buffer.alloc(300)], + ]) { + const raw = encodeMux(type, id, payload) + assert.equal(raw.length, 5 + payload.length) + const back = decodeMux(raw) + assert.equal(back.type, type) + assert.equal(back.streamId, id) + assert.deepEqual(Buffer.from(back.payload), payload) + } + assert.equal(decodeMux(Buffer.alloc(4)), null, '小于 5 字节必须判为协议错误') +}) + +test('T2 密钥装载:只接受 64 位 hex,短密钥直接拒', () => { + const good = randomBytes(32).toString('hex') + const keys = parseKeysInline(`w-a:${good}`) + // 序③ 起:`parseKeysInline` 的返回值带上了"属于哪张网"(**成员资格的唯一判据**)。 + // 裸 `hostId:secret` 是 R5 的旧写法 ⇒ 归入运维网 `ops`(现网 relay-keys.json 一字不改照旧可用)。 + assert.deepEqual(keys.get('ops/w-a'), { network: 'ops', secret: good }) + assert.throws(() => parseKeysInline('w-a:deadbeef'), /64 hex/) + assert.throws(() => parseKeysInline('no-colon'), /malformed/) +}) + +/* ─────────── T3:端到端(核心) ─────────── */ + +test('T3 端到端:Manager ⇒ relay 回环口 ⇒ relay ⇒ worker 本地端口(字节真过)', async (t) => { + const secret = randomBytes(32).toString('hex') + const echo = createTcpServer((sock) => sock.pipe(sock)) + const workerPort = await listenInRange(echo, BASE, BASE + SPAN) + + const server = new RelayServer({ port: 0, keys: new Map([['w-t', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + + let client + t.after(async () => { + client?.stop() + await server.stop() + echo.close() + }) + + client = new RelayClient({ url: `ws://127.0.0.1:${server.boundPort}${PATH}`, hostId: 'w-t', secret, ports: [workerPort], log: () => {} }) + client.start() + + assert.ok(await waitFor(() => client.status().state === 'up'), '客户端未在 5s 内完成注册') + assert.ok(await waitFor(() => server.isOnline('w-t')), '服务端未看到该 host 上线') + const localPort = server.localPortOf('w-t', workerPort) + assert.ok(typeof localPort === 'number' && localPort > 0, '未分配回环端口') + assert.notEqual(localPort, workerPort, '回环口不应与 worker 端口同号(会撞本机实例)') + + const text = 'relay-round-trip-0123456789' + assert.equal(await roundTrip(localPort, text), text) + + // 并发流:同一 (host, port) 上两条连接必须能同时工作(多路复用真的在复用)。 + const [a, b] = await Promise.all([roundTrip(localPort, 'AAAA'), roundTrip(localPort, 'BBBBBBBB')]) + assert.equal(a, 'AAAA') + assert.equal(b, 'BBBBBBBB') + + const st = server.status() + assert.ok(st.counters.streamsOpened >= 3, `期望至少 3 条流,实得 ${st.counters.streamsOpened}`) + assert.equal(st.counters.authFailed, 0) + assert.equal(st.counters.dropped, 0) + assert.equal(client.status().denied, 0) +}) + +/* ─────────── T4:认证失败必须被拒 ─────────── */ + +test('T4 错密钥 ⇒ 拒绝(不 up、有计数、不静默)', async (t) => { + const right = randomBytes(32).toString('hex') + const wrong = randomBytes(32).toString('hex') + const server = new RelayServer({ port: 0, keys: new Map([['w-x', right]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + const client = new RelayClient({ + url: `ws://127.0.0.1:${server.boundPort}${PATH}`, + hostId: 'w-x', + secret: wrong, + ports: [BASE + 1], + reconnectMinMs: 50, + reconnectMaxMs: 100, + log: () => {}, + }) + t.after(async () => { + client.stop() + await server.stop() + }) + client.start() + assert.ok(await waitFor(() => server.status().counters.authFailed > 0, 4000), '服务端没有记录认证失败') + assert.notEqual(client.status().state, 'up', '错密钥不得进入 up') +}) + +/* ─────────── T5:重放必须被拒 ─────────── */ + +test('T5 同 nonce 二次注册 ⇒ 重放被拒(第二条连接拿不到 HELLO_ACK)', async (t) => { + const secret = randomBytes(32).toString('hex') + const server = new RelayServer({ port: 0, keys: new Map([['w-r', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + t.after(() => server.stop()) + + const csv = String(BASE + 2) + const nonce = randomBytes(16).toString('hex') + const first = await rawHello(`ws://127.0.0.1:${server.boundPort}${PATH}`, 'w-r', secret, csv, nonce) + t.after(() => first.ws.close()) + assert.ok(first.frame !== undefined && first.frame.type === MUX.HELLO_ACK, '首次注册应成功') + + const second = await rawHello(`ws://127.0.0.1:${server.boundPort}${PATH}`, 'w-r', secret, csv, nonce) + t.after(() => second.ws.close()) + // 拒绝可以是"关闭"或"结构化拒绝帧",但**绝不能是 HELLO_ACK**(且不得静默)。 + assert.notEqual(second.frame?.type, MUX.HELLO_ACK, '重放必须被拒') + assert.equal(second.frame?.type, MUX.HELLO_ERR, '拒绝必须是结构化的 HELLO_ERR') + assert.equal(JSON.parse(Buffer.from(second.frame.payload).toString('utf8')).reason, 'nonce-replay') + assert.ok(server.status().counters.authFailed >= 1, '重放应计入 authFailed') +}) + +/* ─────────── T6:越界端口必须被拒 ─────────── */ + +test('T6 声明实例区间外的端口 ⇒ 拒绝注册(爆炸半径不外扩)', async (t) => { + const secret = randomBytes(32).toString('hex') + const server = new RelayServer({ port: 0, keys: new Map([['w-b', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + t.after(() => server.stop()) + + const outside = BASE + SPAN + 5 + const probe = await rawHello(`ws://127.0.0.1:${server.boundPort}${PATH}`, 'w-b', secret, String(outside), randomBytes(16).toString('hex')) + t.after(() => probe.ws.close()) + assert.notEqual(probe.frame?.type, MUX.HELLO_ACK, `端口 ${outside} 在区间外,必须拒绝`) + assert.equal(probe.frame?.type, MUX.HELLO_ERR) + assert.equal(JSON.parse(Buffer.from(probe.frame.payload).toString('utf8')).reason, 'port-out-of-range') + assert.ok(server.status().counters.authFailed >= 1) + assert.equal(server.localPortOf('w-b', outside), undefined, '被拒的端口不得留下回环监听') +}) + +/* ─────────── T7:未声明端口没有回环口(默认拒绝) ─────────── */ + +test('T7 未注册的端口不存在回环监听(默认拒绝,不是默认放行)', async (t) => { + const secret = randomBytes(32).toString('hex') + const echo = createTcpServer((sock) => sock.pipe(sock)) + const declared = await listenInRange(echo, BASE, BASE + SPAN) + const server = new RelayServer({ port: 0, keys: new Map([['w-d', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + let client + t.after(async () => { + client?.stop() + await server.stop() + echo.close() + }) + client = new RelayClient({ url: `ws://127.0.0.1:${server.boundPort}${PATH}`, hostId: 'w-d', secret, ports: [declared], log: () => {} }) + client.start() + assert.ok(await waitFor(() => client.status().state === 'up')) + + assert.equal(server.localPortOf('w-d', declared + 1), undefined) + const endpoints = server.status().endpoints + assert.equal(endpoints.length, 1, `只应为声明端口开监听,实得 ${endpoints.length} 个`) + assert.equal(endpoints[0].port, declared) +}) + + +/* ═══════════════════════════════════════════════════════════════════════════ + * T8–T12:韧性 —— 节点启停 / 网络变化 / 网络中断 / 网络异常 / 时钟漂移 + * + * 这五条对应传输方案 §12 的场景矩阵。判据不是"有没有重连",而是: + * **恢复得快不快(计划内 vs 故障)、前提变了会不会立刻纠正(地址/时钟)、静默异常能不能被判死。** + * ═══════════════════════════════════════════════════════════════════════════ */ + +/** 一个"连不上"的假 WebSocket:注册监听后立刻派发 error(模拟断网期间的 dial 失败)。 */ +function makeFailingWs(counter) { + return class FailingWs { + constructor() { + counter.dials += 1 + this.readyState = 0 + this.binaryType = '' + this.bufferedAmount = 0 + this.ls = {} + } + addEventListener(type, fn) { + ;(this.ls[type] ||= []).push(fn) + if (type === 'error') setTimeout(() => this.fire('error', {}), 5) + } + fire(type, ev) { + for (const fn of this.ls[type] ?? []) fn(ev) + } + send() {} + close() { + this.readyState = 3 + } + } +} + +/** 一个"握手成功但随后彻底静默"的假 WebSocket(模拟半开:TCP 没断,但再也不来帧)。 */ +function makeSilentWs(counter, acceptedPort, sessionId = 'silent-sess') { + return class SilentWs { + constructor() { + counter.dials += 1 + this.readyState = 0 + this.binaryType = '' + this.bufferedAmount = 0 + this.ls = {} + setTimeout(() => { + this.readyState = 1 + this.fire('open', {}) + }, 5) + } + addEventListener(type, fn) { + ;(this.ls[type] ||= []).push(fn) + } + fire(type, ev) { + for (const fn of this.ls[type] ?? []) fn(ev) + } + send(buf) { + const frame = decodeMux(Buffer.from(buf)) + if (frame !== null && frame.type === MUX.HELLO) { + // hbSec=1 ⇒ 半开阈值 2.5s ⇒ 静默 3s 左右应被判死 + setTimeout(() => this.fire('message', { data: encodeJsonFrame(MUX.HELLO_ACK, 0, { sessionId, accepted: [acceptedPort], hbSec: 1 }) }), 5) + } + // 之后**永不回帧**:PING 不回 PONG、不主动发任何东西 + } + close(code = 1000) { + this.readyState = 3 + setTimeout(() => this.fire('close', { code }), 1) + } + } +} + +test('T8 服务端优雅停机 ⇒ 重启窗口内自动恢复(close 1001 / BYE 语义,不消耗退避)', async (t) => { + const secret = randomBytes(32).toString('hex') + const instPort = BASE + 10 + // 用一个固定端口:**重启后必须回到同一地址**,才算证明"原地恢复"。 + const probe = createTcpServer() + const fixedPort = await listenInRange(probe, BASE + 100, BASE + SPAN) + await new Promise((r) => probe.close(r)) + + const mk = () => new RelayServer({ port: fixedPort, keys: new Map([['w-r8', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + const s1 = mk() + await s1.start() + const client = new RelayClient({ + url: `ws://127.0.0.1:${fixedPort}${PATH}`, + hostId: 'w-r8', + secret, + ports: [instPort], + // 退避下限故意设成 60s:若还走普通退避,下面的恢复断言**必然失败** ⇒ 断言才有区分度。 + reconnectMinMs: 60_000, + reconnectMaxMs: 60_000, + gracefulRetryMs: 250, + // 用"**窗口**"而不是次数:重启耗时不可预测(实测 stop() 自身就要 1.8s),窗口给足才稳。 + gracefulBurstMs: 60_000, + log: () => {}, + }) + t.after(() => client.stop()) + client.start() + assert.ok(await waitFor(() => client.status().state === 'up'), '首次注册未完成') + + await s1.stop() // 优雅停机:BYE + close 1001 + assert.ok(await waitFor(() => client.status().restarts >= 1, 3000), '未识别出对端的优雅下线信号') + // 关键:#1 必须**立刻**走短间隔重试,而不是把 60s 退避当第一反应。 + assert.ok( + (client.status().nextRetryMs ?? 99999) <= 1000, + `graceful 后应处于短间隔重试窗口,实得 nextRetryMs=${client.status().nextRetryMs}`, + ) + + // systemd Restart= 的典型耗时窗口:400ms 后新进程在同一端口起来了。 + await new Promise((r) => setTimeout(r, 400)) + const s2 = mk() + await s2.start() + t.after(async () => { + await s2.stop() + }) + + assert.ok( + await waitFor(() => client.status().state === 'up', 5000), + `未在重启窗口内恢复(state=${client.status().state} attempts=${client.status().attempts} err=${client.status().lastError})`, + ) + assert.ok(client.status().reconnects >= 1, '应计一次重连') + assert.ok(await waitFor(() => s2.isOnline('w-r8'), 2000), '新实例未看到该 host 上线') +}) + +test('T9 客户端优雅停机 ⇒ 服务端**秒级**标离线(BYE,不等心跳超时)', async (t) => { + const secret = randomBytes(32).toString('hex') + const port = BASE + 11 + const server = new RelayServer({ + port: 0, + keys: new Map([['w-r9', secret]]), + instancePortBase: BASE, + instancePortSpan: SPAN, + idleTimeoutMs: 60_000, // 把心跳超时拉到 60s ⇒ 下面"1.5s 内离线"只可能来自 BYE + log: () => {}, + }) + await server.start() + const client = new RelayClient({ url: `ws://127.0.0.1:${server.boundPort}${PATH}`, hostId: 'w-r9', secret, ports: [port], log: () => {} }) + t.after(async () => { + client.stop() + await server.stop() + }) + client.start() + assert.ok(await waitFor(() => client.status().state === 'up')) + assert.ok(typeof server.localPortOf('w-r9', port) === 'number') + + const t0 = Date.now() + client.stop() + assert.ok(await waitFor(() => !server.isOnline('w-r9'), 1500), 'BYE 未被及时处理(退化成心跳超时了)') + assert.ok(Date.now() - t0 < 1500, `离线耗时 ${Date.now() - t0}ms 过长`) + assert.equal(server.localPortOf('w-r9', port), undefined, '离线后不得再给出回环口(Manager 会打到死地址)') +}) + +test('T10 时钟漂移 > 认证窗口 ⇒ 用服务端时间戳自愈后注册成功(否则该节点永久失联)', async (t) => { + const secret = randomBytes(32).toString('hex') + const port = BASE + 12 + const skew = 600_000 // 本机比 relay 快 10 分钟(远超 ±60s 窗口) + const server = new RelayServer({ port: 0, keys: new Map([['w-r10', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + const client = new RelayClient({ + url: `ws://127.0.0.1:${server.boundPort}${PATH}`, + hostId: 'w-r10', + secret, + ports: [port], + reconnectMinMs: 50, + reconnectMaxMs: 200, + nowImpl: () => Date.now() + skew, // 注入漂移时钟 + log: () => {}, + }) + t.after(async () => { + client.stop() + await server.stop() + }) + client.start() + + assert.ok(await waitFor(() => client.status().state === 'up', 6000), '时钟校正后仍未注册成功 ⇒ 该节点会永久失联') + assert.ok(Math.abs(client.status().clockSkewMs - skew) < 5_000, `clockSkewMs=${client.status().clockSkewMs} 未反映真实漂移`) + assert.ok(server.status().counters.authFailed >= 1, '首次握手应被拒(否则本用例没测到东西)') +}) + +test('T11 网络异常(半开)⇒ 无帧超过阈值即主动重连,不等 OS 的 TCP 超时', async (t) => { + const secret = randomBytes(32).toString('hex') + const counter = { dials: 0 } + const port = BASE + 13 + const client = new RelayClient({ + url: 'ws://127.0.0.1:1/dshs-relay', // 地址不可达无所谓:传输层被下面的假 WS 完全替换 + hostId: 'w-r11', + secret, + ports: [port], + reconnectMinMs: 100, + reconnectMaxMs: 200, + webSocketCtor: makeSilentWs(counter, port), + log: () => {}, + }) + t.after(() => client.stop()) + client.start() + + assert.ok(await waitFor(() => client.status().state === 'up', 3000), '假 WS 未完成注册') + // 服务端静默 ⇒ 应在 ~2.5×hb(1s)=2.5s 后被判死并重连 + assert.ok(await waitFor(() => client.status().state === 'backoff', 8000), '半开未被判死(会一直挂着直到 OS 超时)') + assert.match(String(client.status().lastError), /half-open/, `lastError 应标明半开,实得 ${client.status().lastError}`) + assert.ok(await waitFor(() => counter.dials >= 2, 3000), '判死后没有重拨') +}) + +test('T12 网络变化 ⇒ 取消剩余退避、立即重拨(旧退避的前提已失效)', async (t) => { + const secret = randomBytes(32).toString('hex') + const counter = { dials: 0 } + let snap = 0 + const client = new RelayClient({ + url: 'ws://127.0.0.1:1/dshs-relay', + hostId: 'w-r12', + secret, + ports: [BASE + 14], + // 退避下限 60s:只有"地址变化触发立即重试"这条路才能在几秒内重拨 ⇒ 断言才有区分度 + reconnectMinMs: 60_000, + reconnectMaxMs: 60_000, + netWatchMs: 40, + netSnapshot: () => `snap-${snap++}`, + webSocketCtor: makeFailingWs(counter), + log: () => {}, + }) + t.after(() => client.stop()) + client.start() + + assert.ok(await waitFor(() => client.status().state === 'backoff', 3000), '未进入退避') + const firstRetry = client.status().nextRetryMs ?? 0 + assert.ok(firstRetry > 30_000, `普通退避应很长,实得 ${firstRetry}ms`) + + assert.ok(await waitFor(() => client.status().networkChanges >= 1, 2000), '未检测到本机地址变化') + assert.ok(await waitFor(() => counter.dials >= 2, 2000), '地址变化后未立即重拨') +}) + +/* ═══════════════════════════════════════════════════════════════════════════ + * T13–T15:容量准入与节点选择 —— 自动(速度 + 负载) / 手动 / 满载排队 + * ═══════════════════════════════════════════════════════════════════════════ */ + +/** 一个可控的假 WebSocket:`gate.full=true` 时回 `at-capacity`,否则回 ACK(模拟"等位成功后放行")。 */ +function makeCapacityWs(counter, acceptedPort, gate, sessionId = 'q-1') { + return class CapacityWs { + constructor() { + counter.dials += 1 + this.readyState = 0 + this.binaryType = '' + this.bufferedAmount = 0 + this.ls = {} + setTimeout(() => { + this.readyState = 1 + this.fire('open', {}) + }, 5) + } + addEventListener(type, fn) { + ;(this.ls[type] ||= []).push(fn) + } + fire(type, ev) { + for (const fn of this.ls[type] ?? []) fn(ev) + } + send(buf) { + const frame = decodeMux(Buffer.from(buf)) + if (frame === null || frame.type !== MUX.HELLO) return + const data = gate.full + ? encodeJsonFrame(MUX.HELLO_ERR, 0, { + reason: 'at-capacity', + retryable: true, + retryAfterMs: 60, + serverTime: Date.now(), + capacity: { max: 1, used: 1, free: 0 }, + }) + : encodeJsonFrame(MUX.HELLO_ACK, 0, { sessionId, accepted: [acceptedPort], hbSec: 15, serverTime: Date.now() }) + setTimeout(() => this.fire('message', { data }), 5) + } + close(code = 1000) { + this.readyState = 3 + setTimeout(() => this.fire('close', { code }), 1) + } + } +} + +test('T13 容量准入:满载时新节点被拒(at-capacity + 排队建议),已在册节点重连优先', async (t) => { + const s1 = randomBytes(32).toString('hex') + const s2 = randomBytes(32).toString('hex') + const server = new RelayServer({ + port: 0, + keys: new Map([ + ['w-a', s1], + ['w-b', s2], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + maxHosts: 1, // 只能有一个节点在线 + log: () => {}, + }) + await server.start() + t.after(() => server.stop()) + + const c1 = new RelayClient({ url: `ws://127.0.0.1:${server.boundPort}${PATH}`, hostId: 'w-a', secret: s1, ports: [BASE + 40], log: () => {} }) + c1.start() + t.after(() => c1.stop()) + assert.ok(await waitFor(() => c1.status().state === 'up'), '第一个节点未上线') + + // 第二个新节点:必须被结构性拒绝,并拿到"多久后再来" + const probe = await rawHello(`ws://127.0.0.1:${server.boundPort}${PATH}`, 'w-b', s2, String(BASE + 41), randomBytes(16).toString('hex')) + t.after(() => probe.ws.close()) + assert.equal(probe.frame?.type, MUX.HELLO_ERR, '满载必须回结构化拒绝,而不是静默断开') + const msg = JSON.parse(Buffer.from(probe.frame.payload).toString('utf8')) + assert.equal(msg.reason, 'at-capacity') + assert.equal(msg.retryable, true, '满载属于"过会儿再来",不是永久拒绝') + assert.ok(msg.retryAfterMs > 0, '必须给出排队建议时长') + assert.equal(msg.capacity.free, 0) + assert.ok(!server.isOnline('w-b'), '被拒的节点不得上线') + assert.equal(server.localPortOf('w-b', BASE + 41), undefined, '被拒的节点不得留下回环监听') + assert.equal(server.status().capacity.free, 0) + + // **已在册节点重连优先**:位子本来就是它的,不能被自己的满载规则挡在门外 + const again = await rawHello(`ws://127.0.0.1:${server.boundPort}${PATH}`, 'w-a', s1, String(BASE + 40), randomBytes(16).toString('hex')) + t.after(() => again.ws.close()) + assert.equal(again.frame?.type, MUX.HELLO_ACK, '已在册节点重连被误拦 ⇒ 会造成"重启后再也连不上"') +}) + +test('T14 客户端满载排队:进 queued、不消耗退避、位子一空即注册成功', async (t) => { + const secret = randomBytes(32).toString('hex') + const counter = { dials: 0 } + const gate = { full: true } + const port = BASE + 42 + const client = new RelayClient({ + url: 'ws://127.0.0.1:1/dshs-relay', + hostId: 'w-q', + secret, + ports: [port], + reconnectMinMs: 60_000, + reconnectMaxMs: 60_000, + webSocketCtor: makeCapacityWs(counter, port, gate), + log: () => {}, + }) + t.after(() => client.stop()) + client.start() + + assert.ok(await waitFor(() => client.status().state === 'queued', 3000), `未进入 queued,实得 ${client.status().state}`) + assert.ok(client.status().queueWaits >= 1, '未记录排队次数') + assert.ok((client.status().nextRetryMs ?? 99999) <= 5000, '排队应按服务端给的 retryAfterMs 回来,而不是 60s 退避') + assert.equal(client.status().attempts, 0, '排队不是故障,不得累计退避') + assert.match(String(client.status().lastError), /at-capacity/) + + gate.full = false // 位子空出来了 + assert.ok(await waitFor(() => client.status().state === 'up', 3000), '空位出现后未注册成功') + // 排队→成功是**首次**注册(不是重连),所以 `reconnects` 应为 0;这里正是要确认语义没被搞混。 + assert.equal(client.status().reconnects, 0, '排队后首次成功不该被记成"重连"') +}) + +test('T15 选点判据:速度 + 负载打分、满载硬门、手动优先(纯函数)', () => { + const near = { id: 'near', rttMs: 20, capacity: { max: 10, used: 2 } } + const far = { id: 'far', rttMs: 200, capacity: { max: 10, used: 1 } } + const fullNode = { id: 'full', rttMs: 5, capacity: { max: 4, used: 4 } } + + const d1 = chooseNode([near, far, fullNode]) + assert.equal(d1.outcome, 'chosen') + assert.equal(d1.chosen.id, 'near', `速度占优者应胜出,实得 ${d1.chosen?.id}`) + assert.equal(d1.ranking.find((r) => r.id === 'full').blocked, 'full', '满载必须是硬门') + + // 只有满载候选 ⇒ 排队(而不是"挑个满的凑合") + const d2 = chooseNode([fullNode]) + assert.equal(d2.outcome, 'queued') + assert.equal(d2.chosen, undefined) + assert.ok(d2.retryAfterMs > 0) + // 不允许排队 ⇒ 直接拒绝加入 + assert.equal(chooseNode([fullNode], { allowQueue: false }).outcome, 'rejected') + + // 负载能压过速度:近但 90% 满 vs 远但空 + const busyNear = { id: 'busyNear', rttMs: 20, capacity: { max: 10, used: 9 } } + const idleFar = { id: 'idleFar', rttMs: 60, capacity: { max: 10, used: 0 } } + assert.equal(chooseNode([busyNear, idleFar]).chosen.id, 'idleFar', '负载接近满载时应让位给空闲节点') + + // 手动指定优先,且**不会**被自动算法改掉 + assert.equal(chooseNode([near, far], { manualId: 'far' }).chosen.id, 'far') + // 手动指定但满载 ⇒ 排队,不静默换节点 + const d5 = chooseNode([near, fullNode], { manualId: 'full' }) + assert.equal(d5.outcome, 'queued') + assert.equal(d5.chosen, undefined) + // 手动指定的 id 不存在 ⇒ 明确拒绝(不偷偷选别的) + assert.equal(chooseNode([near], { manualId: 'ghost' }).outcome, 'rejected') + + // 近期失败降权 + const flaky = { id: 'flaky', rttMs: 20, capacity: { max: 10, used: 0 }, recentFailures: 4 } + const steady = { id: 'steady', rttMs: 45, capacity: { max: 10, used: 3 } } + assert.equal(chooseNode([flaky, steady]).chosen.id, 'steady', '近期反复失败应被降权') + + // 空候选 ⇒ 明确拒绝,不抛异常 + assert.equal(chooseNode([]).outcome, 'rejected') +}) + +/* ─────────── T16 / T17:运行期端口增删(R4,替掉 `ssh -O forward/cancel`)─────────── */ + +/** 连一个端口,**期望连不上**(用来证明监听真的被收掉了,而不是"记账删了但口还开着")。 */ +function expectRefused(port, ms = 1500) { + return new Promise((resolve) => { + const sock = connect(port, '127.0.0.1') + let settled = false + const done = (v) => { + if (settled) return + settled = true + try { + sock.destroy() + } catch { + /* 已断 */ + } + resolve(v) + } + sock.on('error', () => done(true)) + sock.on('connect', () => done(false)) + setTimeout(() => done(false), ms) + }) +} + +test('T16 运行期加端口:PORT_ADD ⇒ 真回环口 + 字节真过;越界被拒;PORT_DEL ⇒ 口真的收掉', async (t) => { + const secret = randomBytes(32).toString('hex') + const echo = createTcpServer((s) => s.pipe(s)) + // ⚠️ 故意**不在注册时声明它** —— 这正是实例端口的形态(运行期才知道) + const workerPort = await listenInRange(echo, BASE + 50, BASE + SPAN - 1) + const placeholder = BASE // HELLO 要求端口表非空(`no-ports`),agent 侧对应"agent 自身端口" + const server = new RelayServer({ port: 0, keys: new Map([['w-p', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server.start() + const client = new RelayClient({ + url: `ws://127.0.0.1:${server.boundPort}${PATH}`, + hostId: 'w-p', + secret, + ports: [placeholder], + reconnectMinMs: 50, + reconnectMaxMs: 200, + log: () => {}, + }) + client.start() + t.after(async () => { + client.stop() + await server.stop() + echo.close() + }) + assert.ok(await waitFor(() => client.status().state === 'up'), '客户端未在 5s 内注册') + + // ① 默认拒绝:没声明的端口**不存在**回环监听(与 T7 同一条姿态,只是换个入口) + assert.equal(server.localPortOf('w-p', workerPort), undefined, '未声明的端口不该有回环口') + + // ② 加端口 ⇒ 拿到真口号、字节真过 + assert.equal(await client.addPort(workerPort), true, 'addPort 应成功') + assert.ok(await waitFor(() => (server.localPortOf('w-p', workerPort) ?? 0) > 0), '未分配回环口') + const localPort = server.localPortOf('w-p', workerPort) + assert.ok(typeof localPort === 'number' && localPort > 0, '未分配回环口') + assert.equal(await roundTrip(localPort, 'runtime-port-ok'), 'runtime-port-ok') + assert.deepEqual(client.status().dynamicPorts, [workerPort]) + + // ③ 幂等:重复声明不该报错(重连后重放会重复调它) + assert.equal(await client.addPort(workerPort), true, '重复 addPort 应幂等成功') + + // ④ 越界 / 非法端口 ⇒ **明确被拒**(不静默成功) + assert.equal(await client.addPort(BASE + SPAN + 5), false, '区间外端口必须被拒') + assert.equal(await client.addPort(0), false, '非法端口必须被拒') + assert.equal(server.localPortOf('w-p', BASE + SPAN + 5), undefined) + assert.deepEqual(client.status().dynamicPorts, [workerPort], '被拒的端口不该进记账') + + // ⑤ 撤端口 ⇒ 记账清掉 + **监听真的收掉**(不然就是"以为撤了、口还开着") + assert.equal(await client.removePort(workerPort), true) + assert.ok(await waitFor(() => server.localPortOf('w-p', workerPort) === undefined), '撤销后服务端不该再有该端点') + assert.equal(await expectRefused(localPort), true, '撤销后旧回环口必须连不上') + assert.deepEqual(client.status().dynamicPorts, []) + assert.equal(server.status().counters.protocolErrors, 0) +}) + +test('T17 断链重连后**重放运行期端口**(否则"本地以为转着、Manager 侧其实没有")', async (t) => { + const secret = randomBytes(32).toString('hex') + const echo = createTcpServer((s) => s.pipe(s)) + const workerPort = await listenInRange(echo, BASE + 60, BASE + SPAN - 1) + const PORT = BASE + const server1 = new RelayServer({ port: 0, keys: new Map([['w-r', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server1.start() + const client = new RelayClient({ + url: `ws://127.0.0.1:${server1.boundPort}${PATH}`, + hostId: 'w-r', + secret, + ports: [PORT], + reconnectMinMs: 50, + reconnectMaxMs: 200, + gracefulRetryMs: 50, + log: () => {}, + }) + client.start() + t.after(async () => { + client.stop() + await server1.stop() + echo.close() + }) + assert.ok(await waitFor(() => client.status().state === 'up')) + assert.equal(await client.addPort(workerPort), true) + assert.ok(await waitFor(() => (server1.localPortOf('w-r', workerPort) ?? 0) > 0), '首轮未开回环口') + + // relay 侧重启(**同一端口**复用同一个 server 实例做不到 ⇒ 直接 stop 再来一个同端口的) + const bound = server1.boundPort + await server1.stop() + const server2 = new RelayServer({ port: bound, keys: new Map([['w-r', secret]]), instancePortBase: BASE, instancePortSpan: SPAN, log: () => {} }) + await server2.start() + t.after(async () => { + await server2.stop() + }) + + // 重连 + **重放**:不重放的话,服务端这边永远是空的(本地却仍说 dynamicPorts 有值) + assert.ok( + await waitFor(() => (server2.localPortOf('w-r', workerPort) ?? 0) > 0, 15000), + '重连后未重放运行期端口 ⇒ Manager 侧会静默失去这个口', + ) + const localPort2 = server2.localPortOf('w-r', workerPort) + assert.equal(await roundTrip(localPort2, 'replayed-ok'), 'replayed-ok') + assert.equal(server2.status().counters.authFailed, 0) +}) + +/** 往**拨号流**(`openStream()` 返回的 `Duplex`)里写一段字、读回同样的字。 */ +function dialRoundTrip(duplex, text, ms = 5000) { + return new Promise((resolve, reject) => { + let got = '' + const timer = setTimeout(() => { + duplex.destroy() + reject(new Error(`dial roundTrip timeout after ${ms}ms (got ${got.length} chars)`)) + }, ms) + duplex.on('data', (c) => { + got += c.toString() + if (got.length >= text.length) { + clearTimeout(timer) + resolve(got) + } + }) + duplex.on('error', (e) => { + clearTimeout(timer) + reject(e) + }) + duplex.write(text) + }) +} + +test('T18 拨号方(R5):openStream ⇒ 字节真过;离线 / 越权 / 配错的拒绝**都是显式的**', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const echo = createTcpServer((s) => s.pipe(s)) + const workerPort = await listenInRange(echo, BASE + 70, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-d', wSecret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Set(['manager']), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-d', secret: wSecret, ports: [workerPort], log: () => {} }) + const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + worker.start() + dialer.start() + t.after(async () => { + worker.stop() + dialer.stop() + echo.close() + await srv.stop() + }) + assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册') + assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') + assert.deepEqual(srv.status().dialers, ['manager'], '白名单要能在 /status 里看到') + + // ① 真字节:写进去、读回来 —— `openStream` 返回的就是可 `pipe()` 的 Duplex + const duplex = await dialer.openStream('w-d', workerPort) + assert.equal(await dialRoundTrip(duplex, 'dial-ok'), 'dial-ok') + assert.equal(dialer.status().dialStreams, 1) + + // ② 关流 ⇒ 两端都清干净(worker 侧计数回落,不留悬挂流) + duplex.destroy() + assert.ok(await waitFor(() => dialer.status().dialStreams === 0), '关流后拨号侧应清空') + assert.ok( + await waitFor( + () => (srv.status().endpoints.find((e) => e.hostId === 'w-d' && e.port === workerPort)?.streams ?? -1) === 0, + ), + 'worker 侧该端口的流计数应回落', + ) + + // ③ 离线目标 ⇒ **显式抛错**(不许返回一个"看起来能用"的对象) + await assert.rejects(() => dialer.openStream('w-nope', workerPort), /target-offline/, '离线目标必须显式拒绝') + + // ④ 非拨号方不能拨(客户端侧先拦) + await assert.rejects(() => worker.openStream('w-d', workerPort), /requires dialer mode/) + + // ⑤ 拨号方不得声明端口(与服务端 `dialer-must-not-declare-ports` 同口径) + assert.throws( + () => + new RelayClient({ url: `ws://127.0.0.1:1${PATH}`, hostId: 'manager', secret: mSecret, ports: [1234], dialer: true }), + /must not declare ports/, + ) + + // ⑥ R1 的**默认拒绝**姿态没被改掉:端口表为空的普通客户端照样进不来 + const before = srv.status().counters.authFailed + const bogus = new RelayClient({ + url, + hostId: 'w-d', + secret: wSecret, + ports: [], + reconnectMinMs: 50, + reconnectMaxMs: 100, + log: () => {}, + }) + bogus.start() + t.after(() => bogus.stop()) + assert.ok(await waitFor(() => srv.status().counters.authFailed > before), '空端口表的普通客户端必须被拒(no-ports)') + assert.equal(srv.status().counters.protocolErrors, 0, '拒绝要走 HELLO_ERR,不该变成协议错') +}) + +test('T19 【会合可换机】relay 不开任何回环口(纯流转发)⇒ 业务照样全通', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const echo = createTcpServer((s) => s.pipe(s)) + const workerPort = await listenInRange(echo, BASE + 80, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-d', wSecret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Set(['manager']), + // 🔴 关键:**一个本地口都不绑** —— 等价于"relay 在另一台机器上,Manager 够不到它的回环" + exposeLoopback: false, + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-d', secret: wSecret, ports: [workerPort], log: () => {} }) + const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + worker.start() + dialer.start() + t.after(async () => { + worker.stop() + dialer.stop() + echo.close() + await srv.stop() + }) + assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册') + assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') + + // 🔴 核心判据一:relay **没有任何回环落点**(`localPort` 拿不到) + assert.equal(srv.localPortOf('w-d', workerPort), undefined, '纯流转发模式下不该有回环落点') + + // 🔴 核心判据二:**业务照样通** ⇒ "relay 必须与 Manager 同机"这个前提已经不存在 + const duplex = await dialer.openStream('w-d', workerPort) + assert.equal(await dialRoundTrip(duplex, 'no-loopback-needed'), 'no-loopback-needed') + assert.equal(dialer.status().dialStreams, 1) + + // 端点表仍在册(在线可观测),但 `localPort` 恒 0 ⇒ 靠 `/status` 找落点的那条路**失败关闭** + const ep = srv.status().endpoints.find((e) => e.hostId === 'w-d' && e.port === workerPort) + assert.ok(ep !== undefined, '端点条目仍应在册(在线可观测)') + assert.equal(ep.localPort, 0, '纯流转发模式下 localPort 必须为 0') + assert.equal(ep.online, true) + assert.equal(srv.status().counters.protocolErrors, 0) + duplex.destroy() +}) + +/* ─────────── T20:序⑤ 判别器计数(观测最小集的"最关键一条") ─────────── */ + +/** + * 为什么需要这一组计数:443 单 §12 的教训原文是「**静默失效靠判别器定位**」,判别器就是 + * 「relay 到底有没有 `DIAL`」;而在此之前它**只存在于日志行**,脚本无法断言。 + * + * 三个分支必须**逐条**可断言,且**互斥可加和**: + * | 分支 | 触发方式(本用例用的就是这些) | 期望计数 | + * |---|---|---| + * | 放行 | 拨号方 `openStream` 成功 | `dial` +1 | + * | 策略拒绝 | 普通 worker 手工发 `DIAL`(不在拨号方白名单) | `dialDenied` +1 | + * | 请求非法 | 拨号方发 `port=0`(`bad-target`) | `dialFailed` +1 | + * | 目标不可达 | 拨号方 `openStream('w-nope')` | `dialFailed` +1 | + * + * ⚠️ 为什么"白名单拒绝"必须用**裸 ws 手工发帧**:普通 client 根本发不出这一帧 —— + * ① 客户端侧 `openStream` 有「必须拨号方模式」前置闸门(T18 ④); + * ② 注册侧两道闸门互斥(`no-ports` / `dialer-must-not-declare-ports`)⇒ 不在白名单的 host + * 要么带端口注册(假不了拨号方)、要么空端口被拒。⇒ 只有手工帧能构造这个输入。 + */ +test('T20 序⑤ 判别器计数:DIAL 放行 / 策略拒绝 / 目标不可达三分支都能被 /status 断言', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const xSecret = randomBytes(32).toString('hex') + const m2Secret = randomBytes(32).toString('hex') + const echo = createTcpServer((s) => s.pipe(s)) + const workerPort = await listenInRange(echo, BASE + 90, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-d', wSecret], + ['w-x', xSecret], + ['manager', mSecret], + ['manager2', m2Secret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Set(['manager', 'manager2']), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-d', secret: wSecret, ports: [workerPort], log: () => {} }) + const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + worker.start() + dialer.start() + t.after(async () => { + worker.stop() + dialer.stop() + echo.close() + await srv.stop() + }) + assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册') + assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') + + // ⓪ 三个计数器**必须存在** —— 这一条就是"先红":加计数之前它们是 `undefined`, + // 于是"脚本能不能断言拨号路径"这个问题的答案就是"不能"。 + const c0 = srv.status().counters + assert.equal(typeof c0.dial, 'number', 'counters.dial 必须存在(否则脚本无法断言拨号路径)') + assert.equal(typeof c0.dialDenied, 'number', 'counters.dialDenied 必须存在') + assert.equal(typeof c0.dialFailed, 'number', 'counters.dialFailed 必须存在') + + // ① 放行 ⇒ dial +1(且与 streamsOpened 同点自增,两条必须同步) + const duplex = await dialer.openStream('w-d', workerPort) + assert.equal(await dialRoundTrip(duplex, 'dial-ok'), 'dial-ok') + assert.equal(srv.status().counters.dial, c0.dial + 1, '成功拨号必须计入 dial') + duplex.destroy() + assert.ok(await waitFor(() => dialer.status().dialStreams === 0), '关流后拨号侧应清空') + + // ② 目标不可达 ⇒ dialFailed +1(`w-nope` 不在册) + await assert.rejects(() => dialer.openStream('w-nope', workerPort), /target-offline/, '离线目标必须显式拒绝') + assert.equal(srv.status().counters.dialFailed, c0.dialFailed + 1, '目标不可达必须计入 dialFailed') + + // ③ 白名单拒绝 ⇒ dialDenied +1(普通 worker 手工发 DIAL;它注册得成、但没资格拨) + const raw = await rawHello(url, 'w-x', xSecret, String(workerPort), randomBytes(8).toString('hex')) + t.after(() => raw.ws.close()) + assert.ok(raw.frame !== undefined && raw.frame.type === MUX.HELLO_ACK, 'w-x 应先以普通 worker 身份注册成功') + const ack = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + raw.ws.addEventListener( + 'message', + (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }, + { once: true }, + ) + raw.ws.send(encodeJsonFrame(MUX.DIAL, 7, { target: 'w-d', port: workerPort })) + }) + assert.ok(ack !== undefined, '拒绝也必须回 DIAL_ACK(不许静默)') + assert.equal(ack.type, MUX.DIAL_ACK, '拒绝也要走 DIAL_ACK') + assert.equal(JSON.parse(Buffer.from(ack.payload).toString('utf8')).error, 'not-a-dialer') + assert.equal(srv.status().counters.dialDenied, c0.dialDenied + 1, '白名单拒绝必须计入 dialDenied') + assert.equal(srv.status().counters.dial, c0.dial + 1, '被拒的拨号**不算** dial') + + // ④ 请求非法(`port=0`)⇒ dialFailed 再 +1。 + // ⚠️ 必须用**在白名单里的**拨号方会话来构造:`onDial` 的**第一道门就是白名单**, + // 拿非拨号方去打非法参数只会拿到 `not-a-dialer`(③ 已证)—— 门是**串行**的,不是并行判的。 + const rawDialer = await rawHello(url, 'manager2', m2Secret, '', randomBytes(8).toString('hex')) + t.after(() => rawDialer.ws.close()) + assert.ok( + rawDialer.frame !== undefined && rawDialer.frame.type === MUX.HELLO_ACK, + 'manager2(白名单内的拨号方)应能以空端口表注册', + ) + const bad = await new Promise((resolve) => { + const timer = setTimeout(() => resolve(undefined), 3000) + rawDialer.ws.addEventListener( + 'message', + (ev) => { + clearTimeout(timer) + resolve(decodeMux(Buffer.from(ev.data))) + }, + { once: true }, + ) + rawDialer.ws.send(encodeJsonFrame(MUX.DIAL, 8, { target: 'w-d', port: 0 })) + }) + assert.equal(JSON.parse(Buffer.from(bad.payload).toString('utf8')).error, 'bad-target') + assert.equal(srv.status().counters.dialFailed, c0.dialFailed + 2, '请求非法必须计入 dialFailed') + + // ⑤ 三条计数互斥可加和:本次共 4 次 DIAL(1 放行 + 1 策略拒绝 + 2 失败) + const cf = srv.status().counters + assert.equal(cf.dial + cf.dialDenied + cf.dialFailed, c0.dial + c0.dialDenied + c0.dialFailed + 4) +}) + +/** + * # T23 · 拨号流**严格单向**(序 ⑭ · 数据面缺陷回归) + * + * ## 生产现象(用户可见) + * 任何 `via='relay'` 的 host(今天 = w-106)上,**同一条 keep-alive 连接的第 2 条** agent 请求 + * 必回 `400 clientError`(Fastify `clientError` 兜底)⇒ 用户 `POST /api/dsh/enter` 回 **500** + * ⇒ **"登录直达工作区"整体不可用**。 + * + * ## 机制(106 抓包定死,本测试逐帧复现) + * ` > 19000` 的载荷里出现 **`HTTP/1.1 200 OK …`** —— 拨号方把**入向的响应** + * 当成**出向的字节**又打了回去;worker 把它写进 agent socket,agent 拿响应行当请求行解析 + * ⇒ 非法字节流 ⇒ `clientError 400`。 + * + * ## 判据 + * 目标端**分开记账**:"请求"(`POST …`)与"非请求字节"(garbage)。一旦入向被回灌, + * `garbage` 立即非空 ⇒ 断言点名,**不会静默通过**。 + */ +test('T23 拨号流严格单向:入向的响应 ⛔ 不得被回灌进 agent socket(keep-alive 复用回归)', async (t) => { + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + /** 目标端"响应"—— 故意带响应行:被回灌时它就是那条最刺眼的证据。 */ + const RESP = 'HTTP/1.1 200 OK\r\ncontent-length: 19\r\n\r\n{"isDirectory":true}' + const REQ = 'POST /fs/isdir HTTP/1.1\r\nhost: agent\r\ncontent-length: 0\r\n\r\n' + const requests = [] + const garbage = [] + const target = createTcpServer((s) => { + s.on('data', (c) => { + const text = c.toString() + if (text.startsWith('POST ')) { + requests.push(text) + s.write(RESP) // 一条请求回一份响应 + return + } + // 到这里就是"不该出现的字节"——回灌的响应正是从这里现形 + garbage.push(text) + }) + }) + const workerPort = await listenInRange(target, BASE + 90, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-one', wSecret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Set(['manager']), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-one', secret: wSecret, ports: [workerPort], log: () => {} }) + const dialer = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + /** @type {import('node:stream').Duplex | undefined} */ + let duplex + worker.start() + dialer.start() + t.after(async () => { + // ⚠️ 必须自己收尾:`onDown` 会走 `teardownDialStreams()` → `duplex.destroy(new Error('link down: …'))`, + // 而 `MuxDuplex` 上没有 `'error'` 监听 ⇒ Node 会把它抛成 **uncaughtException**, + // 表现为"测试通过但文件红"(node:test 报 async activity after the test ended)。 + // 生产侧 `dialer.ts` 有 `duplex.on('error', …)`,这里补上同形的监听 + 先手动 destroy。 + try { + duplex.destroy() + } catch { + /* 已断 */ + } + worker.stop() + dialer.stop() + await waitFor(() => worker.status().state !== 'up' && dialer.status().state !== 'up', 2_000) + target.close() + await srv.stop() + await new Promise((r) => setTimeout(r, 50)) + }) + assert.ok(await waitFor(() => worker.status().state === 'up'), 'worker 未注册') + assert.ok(await waitFor(() => dialer.status().state === 'up'), '拨号方未注册') + + // 与生产同形:**一条复用**的流上连发两条请求(对应 `dialer.ts` 的 `tcp.pipe(duplex).pipe(tcp)`, + // 这里用同一对 `write`/`data` 手工表达 —— 同一个 streamId,不重拨)。 + const duplex0 = await dialer.openStream('w-one', workerPort) + duplex = duplex0 + let inBytes = '' + duplex.on('data', (c) => { + inBytes += c.toString() + }) + // 生产侧 `dialer.ts` 同形的收尾监听(见上面 t.after 的说明) + duplex.on('error', () => {}) + + duplex.write(REQ) + assert.ok(await waitFor(() => requests.length >= 1), '第 1 条请求没到目标端') + await new Promise((r) => setTimeout(r, 200)) + duplex.write(REQ) + assert.ok(await waitFor(() => requests.length >= 2), '第 2 条请求没到目标端(复用同一条流)') + await new Promise((r) => setTimeout(r, 300)) + + // ① 出向:两条请求**都必须**原样到达 + assert.equal(requests.length, 2, `目标端应收到 2 条请求,实收 ${requests.length} 条`) + // ② 入向:拨号侧应**恰好**收到两份响应(不重不漏) + assert.equal(inBytes.split(RESP).length - 1, 2, `拨号侧应收到 2 份响应,实见:${JSON.stringify(inBytes)}`) + // ③ 🔴 判据:入向 ⛔ 一个字节都不许回到目标端 + assert.deepEqual( + garbage, + [], + `拨号流不是单向的 —— 入向字节被回灌进 agent socket(共 ${garbage.length} 段):${JSON.stringify(garbage)}`, + ) + // ④ 顺带守一条:拨号流的出向/入向计数都记在 `bytesOut`/`bytesIn` + assert.ok(dialer.status().dialStreams === 1, '复用期间流的条数应恒为 1') +}) + +/* ─────────── T24:序⑮ 缺陷 B 回归 ─────────── */ + +/** + * 该口**是否有人在听**(bind 失败 = 有人在听)。 + * ⚠️ 这才是"实际在听口号数"的正确量法:**零副作用** —— 它不对池口发连接, + * 所以不会触发被测缺陷、也不会污染 `stray` 计数(缺陷 B 的触发器恰恰是"对未分配口发连接")。 + */ +function poolPortBusy(port, ms = 1500) { + return new Promise((resolve) => { + const s = createTcpServer() + const timer = setTimeout(() => { + try { + s.close() + } catch { + /* 已关 */ + } + resolve(false) + }, ms) + s.once('error', () => { + clearTimeout(timer) + resolve(true) + }) + s.listen(port, '127.0.0.1', () => { + clearTimeout(timer) + s.close(() => resolve(false)) + }) + }) +} + +/** 找一段 **n 个连续空闲** 的口,让"池到底绑了哪几个口"可被断言(否则判据 a 无从对账)。 */ +async function findFreeBlock(n, lo) { + for (let b = lo; b < lo + 300; b++) { + let ok = true + for (let i = 0; i < n; i++) { + if (await poolPortBusy(b + i)) { + ok = false + break + } + } + if (ok) return b + } + throw new Error(`range ${lo}..${lo + 300} 里找不到 ${n} 个连续空闲口`) +} + +/** 对某个口发一条**真实连接**(缺陷 B 的触发器)⇒ `'connected'` | `'error:CODE'` | `'timeout'`。 */ +function strayConnect(port, ms = 1500) { + return new Promise((resolve) => { + const s = connect(port, '127.0.0.1') + const timer = setTimeout(() => { + s.destroy() + resolve('timeout') + }, ms) + s.on('connect', () => { + clearTimeout(timer) + s.destroy() + resolve('connected') + }) + s.on('error', (e) => { + clearTimeout(timer) + resolve(`error:${e.code}`) + }) + }) +} + +test('T24 拨号池:未分配槽位被一条连接命中后 ⛔ 不得自毁(池账=实际在听 · localPortFor 不得发死口号)', async (t) => { + /** + * ## 生产现象(用户可见) + * `POST /api/dsh/enter` 回 **500 `fetch failed: connect ECONNREFUSED 127.0.0.1:25000`** + * (47 上实测 3 条:`2026-09-17 16:00:31 / 16:00:35 / 16:00:40`,`RemoteSpawner.status` 抛出); + * 现场形态是 **"池报 64 个口、`ss -lntp` 只见 63"** —— 缺的那个 `25000` 早就是死的了。 + * + * ## 机制 + * `dialer.ts#onConn` 见到 `slot.key === undefined`(未分配槽位)时只 `slot.server.close()`: + * 关掉的是**服务器**,而槽位**仍在 `slots` 里、`key` 仍是 `undefined`** ⇒ 这个口永久没人听, + * 可 `localPortFor()` 的 `slots.find((s) => s.key === undefined)` **下次还会选中它** + * ⇒ 把它当"可用落点"发出去。**失败被推迟到下一次分配**,现场只有 `ECONNREFUSED`。 + * + * ## 判据(缺一不可) + * a. `status().pool` 与实际在听口数**恒等**(⛔ 池不许撒谎); + * b. `localPortFor()` 返回的落点口**真的能拨通**(**真泵一次字节**,⛔ 只看返回值不算过); + * c. 该路径**不许静默**(要有计数 + 点名日志)—— 本缺陷最难查之处正是它**零日志**。 + */ + const wSecret = randomBytes(32).toString('hex') + const mSecret = randomBytes(32).toString('hex') + const logs = [] + const POOL = 3 + const poolBase = await findFreeBlock(POOL, 46_800) + const poolPorts = Array.from({ length: POOL }, (_, i) => poolBase + i) + const echo = createTcpServer((s) => { + s.on('error', () => {}) + s.on('data', (c) => s.write(c)) + }) + const echoPort = await listenInRange(echo, BASE + 30, BASE + SPAN - 1) + const srv = new RelayServer({ + port: 0, + keys: new Map([ + ['w-xb', wSecret], + ['manager', mSecret], + ]), + instancePortBase: BASE, + instancePortSpan: SPAN, + dialers: new Set(['manager']), + log: () => {}, + }) + await srv.start() + const url = `ws://127.0.0.1:${srv.boundPort}${PATH}` + const worker = new RelayClient({ url, hostId: 'w-xb', secret: wSecret, ports: [echoPort], log: () => {} }) + const mClient = new RelayClient({ url, hostId: 'manager', secret: mSecret, ports: [], dialer: true, log: () => {} }) + const dialer = new RelayDialer({ + client: mClient, + portBase: poolBase, + portSpan: POOL + 2, + poolSize: POOL, + log: (l) => logs.push(l), + }) + worker.start() + mClient.start() + // ⚠️ **必须 await**:口池绑完之前 `localPortFor()` 恒返回 `undefined`(失败关闭)。 + await dialer.start() + t.after(async () => { + dialer.close() + mClient.stop() + worker.stop() + await waitFor(() => mClient.status().state !== 'up' && worker.status().state !== 'up', 2_000) + echo.close() + await srv.stop() + await new Promise((r) => setTimeout(r, 50)) + }) + assert.ok(await waitFor(() => srv.isOnline('w-xb', OPS_NETWORK)), `worker 未注册:${logs.slice(-4).join(' | ')}`) + assert.ok(await waitFor(() => srv.isOnline('manager', OPS_NETWORK)), 'manager 未注册') + + const liveCount = async () => { + let n = 0 + for (const p of poolPorts) if (await poolPortBusy(p)) n++ + return n + } + assert.equal(dialer.status().pool, POOL, '池就绪后 status().pool') + assert.equal(await liveCount(), POOL, `池就绪后 ${POOL} 个口都应在听:${logs.join(' | ')}`) + + // ② 触发缺陷 B:对**未分配**槽位发一条连接(生产上 = 取证探针 / 任何本地扫描) + assert.equal(await strayConnect(poolBase), 'connected', '池口在听 ⇒ TCP 连接先成功,随后才被丢弃') + await new Promise((r) => setTimeout(r, 200)) + + // ③ 🔴 判据 a:池账必须诚实(修复前:pool=3 而实际在听=2) + const live = await liveCount() + assert.equal( + dialer.status().pool, + live, + `判据 a:池账(${dialer.status().pool}) 与实际在听(${live}) 必须恒等 —— 不等即"池在撒谎"`, + ) + // ④ 判据 c:该路径不许静默(本缺陷最难查之处) + assert.equal(dialer.status().stray, 1, '未分配槽位被命中必须**有计数**') + assert.ok( + logs.some((l) => l.includes('未分配落点') && l.includes(String(poolBase))), + `必须有一条点名该口的日志:${logs.join(' | ')}`, + ) + + // ⑤ 🔴 判据 b:分配落点 ⇒ 必须**真的能拨通** + //(修复前这里返回的**正是刚被打死的那个口** ⇒ ECONNREFUSED,本断言就是那条生产故障的复现) + const local = dialer.localPortFor(logicalName(OPS_NETWORK, 'w-xb'), echoPort) + assert.equal(typeof local, 'number', '同网必须给落点') + assert.equal(local, poolBase, '落点应命中那个"刚被误连过"的首个槽位 —— 判据 b 才有鉴别力') + assert.equal(await roundTrip(local, 'PING', 3000), 'PING', '判据 b:落点口必须真的能拨通(修复前 = ECONNREFUSED)') + + // ⑥ 分配之后再核一次:池账仍诚实、且该口确实在听 + assert.equal(dialer.status().pool, await liveCount(), '分配后池账仍须与实际在听恒等') + assert.ok(await poolPortBusy(local), `已分配的落点口 ${local} 应在听`) +}) diff --git a/test/remote-spawner.test.mjs b/test/remote-spawner.test.mjs new file mode 100644 index 0000000..e90f589 --- /dev/null +++ b/test/remote-spawner.test.mjs @@ -0,0 +1,94 @@ +/** + * Manager 侧 `RemoteSpawner.endpointFor` 的**端点翻译接线**单测(覆盖网络 R4)。 + * + * ## 为什么单独立一个文件(2026-09-16 实测代价) + * `translateEndpoint` 是 R4 的**唯一**翻译点:`via='relay'` 的 host,其实例在 Worker 上监听 + * `127.0.0.1:<实例端口>`,而 Manager 必须拨 relay 为那个端口开的**动态回环口号**。 + * + * 首版实现里 `RemoteSpawner` 的构造函数**漏了 `this.translateEndpoint = options.translateEndpoint`** + * —— 于是整个翻译**静默失效**:`endpointFor` 原样返回 Worker 侧口号 ⇒ Manager 往**自己本机** + * 拨 `127.0.0.1:21000` ⇒ 连接被拒两次 ⇒ 代理 `reply.raw.destroy()`。 + * 现场表现只有两条:浏览器/curl 看到 **`Empty reply from server`**;平台日志**一行错误都没有**。 + * (定位靠"在 47 上临时监听 21000,请求被这个探针接走"——即**用判别器测,而不是读代码猜**。) + * + * ⇒ 本文件把"翻译必须真的生效"钉成断言:**构造时就验**,不依赖集群环境、不依赖 relay 在跑。 + * + * 运行:`node --test test/remote-spawner.test.mjs`(已登记进 `npm test` / `npm run verify`)。 + * + * @module test/remote-spawner + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { RemoteSpawner } from '../lib/supervisor/remote-spawner.js' + +/* ─────────── 小工具 ─────────── */ + +/** Worker 侧 agent 的回包(`GET /endpoint/:userId` 的形状)。 */ +function fakeFetch(payload) { + const calls = [] + const impl = async (url, init) => { + calls.push(`${init?.method ?? 'GET'} ${url}`) + return { ok: true, status: 200, json: async () => payload, text: async () => JSON.stringify(payload) } + } + impl.calls = calls + return impl +} + +/** `via='relay'` 的一台 worker:agent 口号 19000,relay 已把它映射到本机 38253。 */ +const W106 = { + hostId: 'w-106', + agentUrl: 'http://127.0.0.1:19000', + reachability: { hostId: 'w-106', via: 'relay', address: '127.0.0.1:38253', scheme: 'http' }, + token: 'tok-106', +} + +function make(opts = {}) { + return new RemoteSpawner({ + agentUrl: 'http://127.0.0.1:19100', + token: 'tok-local', + defaultHostId: 'w-47', + hostIdFor: async () => 'w-106', + hostsProvider: async () => [W106], + fetchImpl: fakeFetch({ running: true, host: '127.0.0.1', port: 21000 }), + ...opts, + }) +} + +/* ─────────── T1–T4 ─────────── */ + +test('T1 翻译器被真的接上:传入的 hostId/endpoint 与返回值都要生效', async () => { + const seen = [] + const s = make({ + translateEndpoint: (hostId, ep) => { + seen.push([hostId, ep]) + return { host: '127.0.0.1', port: 34241 } + }, + }) + assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 34241 }) + // ★ 这一条就是首版漏赋值时唯一会红的断言:漏了 ⇒ seen 为空、返回 {21000} + assert.deepEqual(seen, [['w-106', { host: '127.0.0.1', port: 21000 }]]) +}) + +test('T2 未给翻译器 ⇒ 原样透传(local / manager-ssh 的同号语义,行为零变化)', async () => { + const s = make() + assert.deepEqual(await s.endpointFor('u1'), { host: '127.0.0.1', port: 21000 }) +}) + +test('T3 翻译器回 undefined ⇒ endpointFor 也回 undefined(失败关闭,不回退 Worker 口号)', async () => { + const s = make({ translateEndpoint: () => undefined }) + assert.equal(await s.endpointFor('u1'), undefined) +}) + +test('T4 实例未运行 ⇒ undefined,且**不该**调翻译器(没有端口可翻)', async () => { + let called = false + const s = make({ + fetchImpl: fakeFetch({ running: false }), + translateEndpoint: () => { + called = true + return undefined + }, + }) + assert.equal(await s.endpointFor('u1'), undefined) + assert.equal(called, false) +}) diff --git a/test/remote-user-fs.test.mjs b/test/remote-user-fs.test.mjs new file mode 100644 index 0000000..fc4e51c --- /dev/null +++ b/test/remote-user-fs.test.mjs @@ -0,0 +1,188 @@ +/** + * `RemoteUserFs` 的**按用户路由**单测(覆盖网络线缺陷 A1)。 + * + * ## 被钉死的缺陷(2026-09-16 现场实测) + * `RemoteUserFs.target()` 旧实现对三种失败**一律静默回退默认机**:查库抛错 / 没给 `agentFor` / + * `agentFor` 查不到。而默认机 = **Manager 自己那台**,它对*别的机*的用户只有两种回答: + * ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}` —— 与「**文件夹不存在**」**完全同形**, + * 用户读成"我的文件丢了",真因却是"请求根本没出这台机"; + * ② 本地恰好有同名目录 ⇒ 文件被写进一份**没人在看的副本**(更糟的静默写坏)。 + * + * 触发窗口是**真实存在的、且每次重启必现**:`server.ts` 的 `hostDirectory` 是惰性 Map, + * 唯一写入者 `hostsProvider()` 此前只被 `RemoteSpawner.ensureHosts()` 调用 ⇒ Manager 重启后 + * 若用户先碰文件面,表里只有本机。实测:连发 3 次 launch **全 404,relay 零 `DIAL`、 + * 拨号池零落点**(判别器 = relay 有没有 `DIAL`)。 + * + * ## 修法与断言 + * ① **治本**:新增可选 `ensureHost(hostId)` —— 未命中时先**按需补齐**目录再判(U1), + * 所以正常冷启动请求不会被下面的失败关闭波及; + * ② **治安全**:补齐后仍取不到 ⇒ `UserFsError('host_unresolved')` → **503**,且 + * **一个字节都不许发往默认机**(U2/U3/U4/U8 的 `fetch` 断言)。 + * ③ **不退化**:"确实还没有归属"(`hostIdFor` 正常返回 `undefined`)与单机形态仍是默认机(U5/U6)。 + * + * 运行:`node --test test/remote-user-fs.test.mjs`(已登记进 `npm test` / `npm run verify`)。 + * + * @module test/remote-user-fs + */ + +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { RemoteUserFs } from '../lib/fs/remote-user-fs.js' + +/* ─────────── 小工具 ─────────── */ + +/** Manager 自己那台(= 默认 / 回退 agent)—— 任何"打到这里"都是路由失败。 */ +const DEFAULT_AGENT = 'http://127.0.0.1:19100' +/** 归属机:w-106 的 agent。 */ +const W106_AGENT = 'http://127.0.0.1:19000' + +/** 记账用 fetch:记下每一发请求,永不真的出网。 */ +function spyFetch() { + const calls = [] + const impl = async (url) => { + calls.push(String(url)) + return { ok: true, status: 200, text: async () => JSON.stringify([]) } + } + impl.calls = calls + return impl +} + +function mk(opts) { + const fetchImpl = spyFetch() + const fs = new RemoteUserFs({ + agentUrl: DEFAULT_AGENT, + token: 'tok-default', + workerDataRoot: '/var/lib/dsh/data', + hostIdFor: opts.hostIdFor, + agentFor: opts.agentFor, + ensureHost: opts.ensureHost, + fetchImpl, + }) + return { fs, fetchImpl } +} + +/** 断言"抛出的一定是 host_unresolved/503"。 */ +function isHostUnresolved(err) { + assert.equal(err.code, 'host_unresolved') + assert.equal(err.status, 503) + return true +} + +/* ─────────── ① 治本:未命中先补齐 ─────────── */ + +test('U1 目录未命中:ensureHost 补齐后打到**归属机器**(不再回退默认机)', async () => { + const dir = new Map() // 模拟 hostsProvider() 尚未填过的空目录 + let ensured = 0 + const { fs, fetchImpl } = mk({ + hostIdFor: async () => 'w-106', + agentFor: (h) => dir.get(h), + ensureHost: async (h) => { + ensured += 1 + dir.set(h, { agentUrl: W106_AGENT, token: 'tok-106' }) + }, + }) + + await fs.listDir('u1', '') + + assert.equal(ensured, 1, '未命中必须触发一次补齐') + assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`], '必须打到 w-106,不许打默认机') +}) + +test('U7 命中时**不**做补齐(正常路径零开销:不查库)', async () => { + let ensured = 0 + const { fs, fetchImpl } = mk({ + hostIdFor: async () => 'w-106', + agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }), + ensureHost: async () => { + ensured += 1 + }, + }) + + await fs.listDir('u1', '') + + assert.equal(ensured, 0, '命中后再查库 = 纯浪费') + assert.deepEqual(fetchImpl.calls, [`${W106_AGENT}/fs/list`]) +}) + +/* ─────────── ② 治安全:取不到 ⇒ 失败关闭 ─────────── */ + +test('U2 补齐后仍取不到地址:503 host_unresolved,且**零请求发往默认机**(写操作也拦住)', async () => { + let ensured = 0 + const { fs, fetchImpl } = mk({ + hostIdFor: async () => 'w-106', + agentFor: () => undefined, // 目录里始终没有 w-106 + ensureHost: async () => { + ensured += 1 + }, + }) + + // 用 mkdir 而不是读:这正是"把目录建到错机上"的那类操作 + await assert.rejects(() => fs.mkdir('u1', 'MCN短视频创作'), isHostUnresolved) + + assert.equal(ensured, 1, '失败前仍应尝试过补齐') + assert.deepEqual(fetchImpl.calls, [], '⛔ 一个字节都不许发往默认机') +}) + +test('U3 未提供 ensureHost(老调用点):未命中同样失败关闭,**不退化**为静默回退', async () => { + const { fs, fetchImpl } = mk({ + hostIdFor: async () => 'w-106', + agentFor: () => undefined, + // ensureHost 故意不传 + }) + + await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved) + assert.deepEqual(fetchImpl.calls, []) +}) + +test('U8 ensureHost 自己抛错(如查库失败):仍失败关闭,不吞成"默认机"', async () => { + const { fs, fetchImpl } = mk({ + hostIdFor: async () => 'w-106', + agentFor: () => undefined, + ensureHost: async () => { + throw new Error('pg is down') + }, + }) + + await assert.rejects(() => fs.listDir('u1', ''), isHostUnresolved) + assert.deepEqual(fetchImpl.calls, []) +}) + +test('U4 hostIdFor 抛错(查库失败):失败关闭,不静默回退默认机', async () => { + const { fs, fetchImpl } = mk({ + hostIdFor: async () => { + throw new Error('pg is down') + }, + agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }), + }) + + await assert.rejects(() => fs.readFile('u1', 'a.txt'), isHostUnresolved) + assert.deepEqual(fetchImpl.calls, [], '归属都查不出来时,任何一台都不该被打') +}) + +/* ─────────── ③ 不退化:设计内的默认机路径 ─────────── */ + +test('U5 hostIdFor 返回 undefined(确实还没有归属):仍用默认 agent', async () => { + const { fs, fetchImpl } = mk({ + hostIdFor: async () => undefined, + agentFor: () => ({ agentUrl: W106_AGENT, token: 'tok-106' }), + }) + + await fs.initUserRoot('u1', 1001) + + assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/init`], '首触达/无归属走默认机是设计内契约') +}) + +test('U6 未提供 hostIdFor(单机形态):默认 agent,且不触发任何路由表', async () => { + let probed = 0 + const { fs, fetchImpl } = mk({ + agentFor: () => { + probed += 1 + return undefined + }, + }) + + await fs.listDir('u1', '') + + assert.equal(probed, 0) + assert.deepEqual(fetchImpl.calls, [`${DEFAULT_AGENT}/fs/list`]) +})