feat(overlay): 覆盖网络线 序㉔–㊸ 补提交 —— 源码与已部署产物对齐

把这批「已 scp 到 47 / 106 生产并在跑、但一直未入 git」的实现补进版本库
(其中 P2 的全部新源码此前一直是 untracked)。

分棒内容:
- 序㉔ 内容分发块级寻址:src/net/relay/content/{runtime,source}.ts
- 序㉖ 骨干稳定选路(jitter):src/net/relay/{index,network}.ts、src/web/server.ts
- 序㉘ 组密钥加密(GCM 确定性):src/net/relay/content/{runtime,source}.ts
- 序㉛ P1 一键加入 + 分组准入:src/net/relay/{join,registry}.ts、
  src/web/routes/overlay-nodes.ts、scripts/overlay-node-{join,admit}.cjs、
  test/overlay-join.test.mjs
- 序㊵/㊶ 直连打洞 + peer 档:src/net/relay/direct/{candidate,index,punch}.ts、
  scripts/overlay-direct-probe.cjs、test/overlay-direct.test.mjs
- 序㊷/㊸ 观测面:scripts/overlay-probe.cjs、scripts/overlay-failover-drill.cjs

零回归三件套(2026-09-18 16:2x 提交前复跑,全绿):
- npm test                                  201 tests / pass 200 / fail 0 / skipped 1(Node v22.22.2)
- overlay-probe.cjs --table                 28 PASS / 0 SKIP / 0 FAIL
- overlay-failover-drill.cjs --scene all --table  12 PASS / 0 SKIP / 0 FAIL

⛔ 未纳入:_tmp_seq24/、_tmp_seq40/、_中间产物_待清理/(本机临时产物,仍 untracked)
🔴 未实施:D8 云安全组乙-1(待人工在云控制台落地,见
   交接单_覆盖网络直连与P2P_20260918.md §8.11)
This commit is contained in:
admin committed 2026-09-18 16:37:09 +08:00
1 parent 09ce76f3af
commit c452013129
18 files changed
+6605 -79

No files matched your search

File diff suppressed because it is too large. Load diff
+88 -7
View File
@@ -41,8 +41,12 @@
* 3. **`systemctl is-active` 在 inactive 时退出码 = 3** ⇒ `execFileSync` 会抛 ⇒ 首轮**中断在幕1 半路**
* 且把 47 的 relay 留在停用态。凡是**读状态**的远端命令一律 `|| true`。
*
* ## 运行(cwd = 工作区根;⛔ 阈值零硬编码)
* ## 运行(🆕 序㊸:**cwd 不再受限**;⛔ 阈值零硬编码)
* `node "D:/github/dsh_shenxian/scripts/overlay-failover-drill.cjs" [--scene 1|2|3|all]`
* ⚠️ **参数表定位已与 cwd 解耦**(从代码仓根 / 任意目录都可跑):候选目录链 = `--table` > `--dir`
* > `DSHS_OVERLAY_TABLE_DIR` > **注册文件**(缺省 `~/.dshs/overlay-table-dir`)> `cwd` > 脚本目录及上两级。
* 🔴 `--scene trace` / `--scene sample` 的**明细落盘**仍按 `cwd` 写 `_中间产物_待清理/seq9-trace/`
* (那两幕是取证子命令,⛔ 不是本条约的范围)。
*
* ⚠️ **本脚本会真停生产单元**(`dshs-relay`);`finally` 里**一律复原**(失败也复原)。
*/
@@ -51,6 +55,7 @@
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
/** 参数表文件名(⛔ 不写死日期数字)。 */
@@ -61,6 +66,14 @@ const KEY_ONLY_RE = /^`([A-Z0-9_]+)`$/
/* ─────────── 参数表装载(与 overlay-probe.cjs 同款;⛔ 脚本内无魔数) ─────────── */
/**
* 🆕 **序㊸:参数表定位与 cwd 解耦**(与 `overlay-probe.cjs` **同款**,改动理由见该文件同名注释)。
* 候选目录链 = `--table` > `--dir` > `DSHS_OVERLAY_TABLE_DIR` > **注册文件**
* (`DSHS_OVERLAY_TABLE_REGISTRY`,缺省 `~/.dshs/overlay-table-dir`,一行一个目录、`#` 注释)
* > `cwd` > 脚本自身目录及上两级。
* 🔴 **"恰好 1 个才合法"保留且更严**:任一候选目录 ≥2 份 ⇒ 立即报错;跨目录合计 ≥2 份 ⇒ 也报错。
* ⛔ 一份都没找到 ⇒ 报错并列出**搜过的目录**,⛔ **不取缺省**。
*/
function resolveTablePath(argv) {
const argOf = (name) => {
const i = argv.indexOf(name)
@@ -68,12 +81,78 @@ function resolveTablePath(argv) {
}
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 个)`)
const registry = readTableRegistry()
const out = []
const push = (d) => {
if (typeof d === 'string' && d.trim() !== '') out.push(path.resolve(d.trim()))
}
return path.join(dir, hits[0])
push(argOf('--dir'))
push(process.env.DSHS_OVERLAY_TABLE_DIR)
for (const d of registry.dirs) push(d)
push(process.cwd())
push(__dirname)
push(path.join(__dirname, '..'))
push(path.join(__dirname, '..', '..'))
const seen = new Set()
const dirs = []
for (const d of out) {
if (seen.has(d)) continue
seen.add(d)
dirs.push(d)
}
const hits = []
const scanned = []
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue
let names
try {
names = fs.readdirSync(dir).filter((n) => TABLE_RE.test(n))
} catch (err) {
continue // 目录存在但读不了(权限等)⇒ 不算"扫过",也不冒充"没有"
}
scanned.push(dir)
if (names.length > 1) {
throw new Error(
`在 ${dir} 下按 /${TABLE_RE.source}/ 找到 ${names.length} 个参数表(要求恰好 1 个):${names.join(' , ')}`,
)
}
if (names.length === 1) hits.push(path.join(dir, names[0]))
}
if (hits.length === 0) {
throw new Error(
`在 ${scanned.length} 个候选目录下按 /${TABLE_RE.source}/ **一份都没找到**(要求恰好 1 个)\n` +
` 搜过的目录:\n${scanned.map((d) => ` · ${d}`).join('\n')}\n` +
` 注册文件:${registry.file}${registry.readError === null ? `(${registry.dirs.length} 条)` : `(读取失败:${registry.readError})`}\n` +
` ⇒ 处置(三选一):① \`--table <file>\` 直接指定 ② 把参数表所在目录(一行一个)写进上面的注册文件 ③ 在参数表所在目录下运行`,
)
}
if (hits.length > 1) {
throw new Error(
`跨候选目录共找到 ${hits.length} 个参数表(要求恰好 1 个,⛔ 不猜哪一份是对的):\n` +
`${hits.map((h) => ` · ${h}`).join('\n')}`,
)
}
return hits[0]
}
/** 读**注册文件**(一行一个目录)。⛔ 读不到就返回**读取失败原因**(⛔ 不冒充"没有")。 */
function readTableRegistry() {
const envFile = process.env.DSHS_OVERLAY_TABLE_REGISTRY
const file =
typeof envFile === 'string' && envFile.trim() !== ''
? envFile.trim()
: path.join(os.homedir(), '.dshs', 'overlay-table-dir')
let text
try {
text = fs.readFileSync(file, 'utf8')
} catch (err) {
return { file, dirs: [], readError: (err && err.code) || String(err) }
}
const dirs = text
.split(/\r?\n/)
.map((l) => l.replace(/#.*$/, '').trim())
.filter((l) => l !== '')
return { file, dirs, readError: null }
}
const cleanValue = (raw) => String(raw).replace(/[*`]/g, '').trim()
@@ -173,7 +252,9 @@ async function main() {
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')
// ⚠️ 序㊳ 改名:`DRILL_RELAY_UNIT` → `RELAY_UNIT_NAME`(它记的是"两台机的 relay 单元名"这一
// **事实**、并非演练专用坐标 —— 探针 `OBS-08` 也读它 ⇒ 原 `DRILL_` 前缀与用途不相称)。
const relayUnit = r.need('RELAY_UNIT_NAME')
const managerUnit = r.need('DRILL_MANAGER_UNIT')
const deadlineMs = r.num('RELAY_FAILOVER_DEADLINE_MS')
const pollMs = r.num('DRILL_POLL_MS')
+520
View File
@@ -0,0 +1,520 @@
#!/usr/bin/env node
/**
* 覆盖网络 **控制面侧** 命令行:网注册表 / 准入凭据 / 白名单派生(序㊱ · P1 · S1+S2+S4)。
*
* ## 八条命令
* | 命令 | 作用 | 跑在哪 |
* |---|---|---|
* | `init` | 建注册表目录(惰性,⛔ 不改任何既有配置) | **控制面** |
* | `issue-invite` | 签一张**准入邀请**(网络绑定 + 有效期 + 一次性 nonce,⛔ 载荷无密钥) | **控制面**(在线签名者) |
* | `list` | 列出「有哪些网、每张网有哪些节点、谁已批准」 | 任何地方(只读) |
* | `apply` | 收一份节点申请单:验签 → **原子占位 nonce**(一次性)→ 记 `pending` | **控制面** |
* | `approve` / `remove` | 批准 / 移除节点(⇒ 进出派生白名单) | **控制面** |
* | `derive` | 把 approved 集合**投影**成 relay 白名单(缺省**只打印**,`--apply` 才落盘) | **控制面** |
* | `selfcheck` | **端到端自检**(真跑一遍上面全部动作,输出机器可读读数 ⇒ `OBS-25` 的数据源) | 任何地方(临时目录) |
*
* ## 三条纪律
* ① 🔴 **`derive` 缺省不落盘**(`--apply` 才写)—— P1 不动任何既有 drop-in,
* **手写 drop-in 退化为应急通道、⛔ 不删**(`registry.ts#deriveDropIn` 的语义就是"多一条路")。
* ② 🔴 **邀请凭据与申请单里 ⛔ 不含任何密钥本体**(只有网络 / nonce / 有效期 / **公钥**)——
* stdout 也⛔ 不打印任何私钥。
* ③ 🔴 **失败一律具名**(`✗ <命令> 失败:<reason> — <detail>` 走 stderr、`exit 1`),
* ⛔ **零空 `catch` 块**(这是 `OBS-25` 的机器判据之一)。
*
* ## 全局选项(**键名不可混用**)
* | 选项 | 含义 | 备注 |
* |---|---|---|
* | `--dir` | 注册表**目录** | 优先于 env `DSHS_OVERLAY_REGISTRY_DIR`,缺省 `/var/lib/dshs/overlay` |
* | `--registry` | 注册表**文件**(覆盖 `--dir` 下的 `nodes.json`) | 🔴 **⛔ 不要用 `--file`** —— 那是 `apply` 的申请单入参 |
* | `--signer-pub` | 受信签名者公钥(可重复) | 未给 ⇒ 回落 env `DSHS_OVERLAY_SIGNER_PUBKEYS` |
*
* @module scripts/overlay-node-admit
*/
'use strict'
const { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs')
const { randomBytes } = require('node:crypto')
const { dirname, join } = require('node:path')
const reg = require('../lib/net/relay/registry.js')
const joinx = require('../lib/net/relay/join.js')
const id = require('../lib/net/relay/identity.js')
const [, , cmd, ...rest] = process.argv
/** 解析 `--k v` / 开关 `--flag`(与 `overlay-keyring.cjs` 同一套,⛔ 不另造)。 */
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()
}
function opt(name) {
const v = args[name]
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
}
/** 注册表目录(`DSHS_OVERLAY_REGISTRY_DIR` 可覆盖;⛔ 生产值不在代码里写死第二份口径)。 */
const REGISTRY_DIR = opt('dir') ?? process.env.DSHS_OVERLAY_REGISTRY_DIR ?? '/var/lib/dshs/overlay'
/**
* 🔴 **覆盖注册表文件用 `--registry`,⛔ 不是 `--file`** —— 真机首轮实测踩坑:
* `--file` 在本 CLI 里已被 **`apply --file <申请单>`** 占用(还有 `issue-invite --out`),
* 原先这里写 `opt('file')` ⇒ **`apply --file app.json` 会把注册表文件也指到 app.json**
* ⇒ 收单在第 4 步(`loadReg`)炸出「注册表 …/app.json 形状非法」,**前 3 步副作用已发生**
* (nonce 已被原子占位)⇒ 表现为「收单失败 + 同一张邀请再也用不了」。
* ⇒ 两个语义**必须分开两个键**;⛔ 不许再退回 `--file`。
*/
const REGISTRY_FILE = opt('registry') ?? join(REGISTRY_DIR, 'nodes.json')
const CONSUMED_DIR = join(REGISTRY_DIR, 'consumed')
/** `--signer-pub` 未给时回落到 env(与 relay / dsh 同一套受信来源)。 */
function trustedSigners() {
const explicit = opt('signer-pub')
if (explicit !== undefined) return explicit.split(',').map((s) => s.trim()).filter((s) => s !== '')
return id.identityEnvTrustedSigners(process.env)
}
function nowIso() {
return new Date().toISOString()
}
function ok(line) {
process.stdout.write(`${line}\n`)
}
/**
* 邀请的**本地**有效期(分钟)。缺省 30 min(短窗 = 凭据泄露窗口小)。
* ⚠️ 这是**控制面侧签发参数**,⛔ 不是生产 env。
*/
function inviteTtlMs() {
const raw = opt('ttl-min')
const min = raw === undefined ? 30 : Number(raw)
if (!Number.isFinite(min) || min <= 0) throw new Error('--ttl-min 必须是正数(分钟)')
return min * 60 * 1000
}
function loadReg() {
return reg.loadRegistry(REGISTRY_FILE)
}
/** 写回注册表 —— ⛔ 只有控制面可写(`registry.ts` 的 `saveRegistry` 不做任何权限判断,纪律在调用方)。 */
function commit(r) {
reg.saveRegistry(REGISTRY_FILE, r)
}
const COMMANDS = {
/** 建注册表目录(**惰性**:只 `mkdir` + 写一份空注册表;⛔ 不动任何既有 drop-in / env)。 */
init: () => {
mkdirSync(REGISTRY_DIR, { recursive: true })
mkdirSync(CONSUMED_DIR, { recursive: true })
if (!existsSync(REGISTRY_FILE)) commit(reg.emptyRegistry())
chmodSync(REGISTRY_FILE, 0o644)
ok(`✓ 注册表就绪:${REGISTRY_FILE}(0644)`)
ok(`✓ 一次性台账目录:${CONSUMED_DIR}`)
ok(`ℹ️ ${reg.describeRegistryLine(loadReg())}`)
ok('ℹ️ ⛔ 本命令不改任何既有配置;白名单派生要显式跑 `derive --apply`(缺省只打印)。')
},
/** 签一张准入邀请(**网络绑定 + 有效期 + 一次性 nonce**;⛔ 载荷内无任何密钥)。 */
'issue-invite': () => {
const network = need('network')
const doc = {
version: reg.NODES_REGISTRY_VERSION,
network,
nonce: reg.newInviteNonce(randomBytes),
issuedAt: nowIso(),
expiresAt: new Date(Date.now() + inviteTtlMs()).toISOString(),
}
const sig = id.signPayloadWith(readFileSync(need('signer-key'), 'utf8'), reg.networkInvitePayload(doc))
const out = opt('out')
if (out !== undefined) {
mkdirSync(dirname(out), { recursive: true })
writeFileSync(out, `${JSON.stringify({ doc, sig }, null, 2)}\n`, { mode: 0o644 })
ok(`✓ 邀请已写出:${out}`)
} else {
process.stdout.write(`${JSON.stringify({ doc, sig })}\n`)
}
ok(`✓ 邀请:network=${doc.network} nonce=${doc.nonce} 有效期至 ${doc.expiresAt}`)
ok('🔴 载荷内**不含任何密钥本体**(只有网 / nonce / 有效期)—— 节点私钥在节点上生成、永不出机。')
},
/** 列出网与节点(只读)。 */
list: () => {
const r = loadReg()
const network = opt('network')
if (args.json === true) {
process.stdout.write(
`${JSON.stringify({ file: REGISTRY_FILE, networks: reg.summarizeNetworks(r), nodes: reg.listNodes(r, network) }, null, 2)}\n`,
)
return
}
ok(`注册表:${REGISTRY_FILE}`)
ok(`ℹ️ ${reg.describeRegistryLine(r)}`)
for (const n of reg.summarizeNetworks(r)) {
if (network !== undefined && n.network !== network) continue
ok(` · ${n.network} total=${n.total} approved=${n.approved} pending=${n.pending}`)
}
for (const node of reg.listNodes(r, network)) {
ok(
` - ${node.network}/${node.hostId} status=${node.status}` +
` group=${node.group || '(默认)'} key=${node.nodeKey.slice(0, 16)}…` +
` appliedAt=${node.appliedAt}${node.approvedAt === '' ? '' : ` approvedAt=${node.approvedAt}`}`,
)
}
},
/**
* 收一份申请单 —— **四件都做**:验签 → 原子占位 nonce(一次性)→ 记 `pending` → 打印。
*
* 🔴 **一次性是这里保证的**(`consumeNonce` 走 `O_CREAT|O_EXCL`,内核原子):
* 两台机器**同时**拿同一张邀请来收单 ⇒ **恰好一台**成功,另一台得到 `invite-already-used`。
*/
apply: () => {
// 🔴 读的是**申请单**(`join.ts#parseApplication` 的**唯一形状**)—— 真机首轮就栽在
// "apply 按裸 `{doc,sig}` 去读 join 的产物" ⇒ ⛔ 两处形状必须由同一个解析函数读。
let app
try {
app = joinx.readApplicationFile(need('file'))
} catch (err) {
process.stderr.write(`✗ apply 失败:application-shape-invalid — ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
}
const { network, hostId, nodeKey } = app
const signers = trustedSigners()
if (signers.length === 0) {
process.stderr.write('✗ apply 失败:invite-no-trusted-signer — 未配置受信签名者(--signer-pub 或 DSHS_OVERLAY_SIGNER_PUBKEYS)\n')
process.exit(1)
}
const inv = app.invite
const verdict = reg.verifyNetworkInvite(inv.doc, inv.sig, signers, { network })
if (!verdict.ok) {
process.stderr.write(`✗ apply 失败:invite-${verdict.reason} — 邀请验签未通过(network=${network})\n`)
process.exit(1)
}
// 🔴 一次性占位:**先占位再落库**(占位失败 ⇒ 根本不产生任何状态变化)。
const led = reg.consumeNonce({ dir: CONSUMED_DIR }, verdict.doc.nonce, `apply network=${network} host=${hostId}`)
if (!led.ok) {
process.stderr.write(
`✗ apply 失败:invite-already-used — nonce ${verdict.doc.nonce} 已被用掉(一次性凭据,⛔ 不重复受理)\n`,
)
process.exit(1)
}
const r = loadReg()
const edit = reg.applyApplication(r, { network, hostId, nodeKey, at: nowIso(), group: opt('group') })
if (!edit.ok) {
process.stderr.write(`✗ apply 失败:${edit.reason} — network=${network} host=${hostId} nodeKey=${nodeKey.slice(0, 16)}…\n`)
process.exit(1)
}
commit(r)
ok(`✓ 已收单(${edit.created ? '新申请' : '刷新既有申请'}):${network}/${hostId} status=${edit.record.status}`)
ok('ℹ️ ⛔ 收单**不等于**批准 —— 该节点仍未进白名单(默认拒绝 ⇒ relay 侧拨不动),要 `approve` 才生效。')
},
/** 批准(⇒ 进入派生白名单)。 */
approve: () => {
const r = loadReg()
const edit = reg.approveNode(r, need('network'), need('host'), nowIso(), opt('group'))
if (!edit.ok) {
process.stderr.write(`✗ approve 失败:${edit.reason} — 该节点未在注册表里申请过(⛔ 不凭空批准)\n`)
process.exit(1)
}
commit(r)
ok(`✓ 已批准:${edit.record.network}/${edit.record.hostId}(approvedAt=${edit.record.approvedAt})`)
},
/** 移除(⇒ 退出派生白名单;⚠️ relay 侧生效要等一次 reload)。 */
remove: () => {
const r = loadReg()
const edit = reg.removeNode(r, need('network'), need('host'))
if (!edit.ok) {
process.stderr.write(`✗ remove 失败:${edit.reason} — 注册表里没有该节点\n`)
process.exit(1)
}
commit(r)
ok(`✓ 已移除:${edit.record.network}/${edit.record.hostId}`)
ok('⚠️ relay 侧要等一次 `systemctl restart dshs-relay` 才不再接受它的拨号(派生只改配置源)。')
},
/**
* **白名单派生** —— 把该网 approved 集合投影成 `DSHS_RELAY_DIALERS`。
*
* 🔴 **缺省 `--dry-run` 语义**(只打印,⛔ 不落盘)—— P1 的硬门是"不动任何既有配置"。
* 要落盘必须显式 `--apply`;`--out` 可指定别处(做对照用)。
*/
derive: () => {
const r = loadReg()
const network = need('network')
const content = reg.deriveDropIn(r, {
network,
unit: opt('unit') ?? 'dshs-relay',
libDir: opt('lib'),
varName: opt('var'),
})
const audit = reg.auditDerivation(r)
ok(`ℹ️ ${reg.describeRegistryLine(r)}`)
ok(`ℹ️ 派生自审:${audit.ok ? '一致 ✅' : '❌ 不一致'}`)
for (const m of audit.mismatches) ok(` ❌ 集合不配:${m}`)
for (const m of audit.misfiled) ok(` ❌ 结构性错桶(跨网共享):${m}`)
const target = opt('out') ?? reg.dropInPath(opt('unit') ?? 'dshs-relay').replace(/ \(lib=.*\)$/, '')
if (args.apply === true) {
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, `${content}\n`, { mode: 0o644 })
ok(`✓ 已落盘:${target}(0644)`)
ok('⚠️ 生效要一次 `systemctl daemon-reload && systemctl restart dshs-relay`(⛔ 本命令不代跑)。')
ok('🔴 手写 drop-in 是**应急通道**,⛔ 未被删除 —— 控制面不可用时照旧生效。')
} else {
ok(`ℹ️ 未落盘(缺省 dry-run)⇒ 打印内容如下;要落盘加 \`--apply --out ${target}\``)
process.stdout.write(`${content}\n`)
}
},
/**
* **端到端自检**(临时目录里真跑一遍全部动作)—— `OBS-25` 的数据源。
*
* 判据不是"函数跑通了",而是**六件同时成立**(逐条给原始读数):
* ① 四步编排真跑完(`runJoin` 四条 step 全 ok);② **回环的正当性**:申请单里
* **逐字不含私钥**;③ 节点私钥文件权限 = `600`;④ 收单**一次性**(第二次 ⇒ `invite-already-used`);
* ⑤ 四条**负腿**各自**具名**拒绝(坏签名 / 过期 / 错网 / 无受信签名者);
* ⑥ 派生自审一致 + 两张网**零共享**。
*/
selfcheck: async () => {
const tmp = opt('tmp') ?? join(require('node:os').tmpdir(), `dshs-selfcheck-${process.pid}`)
const dir = {
root: tmp,
key: join(tmp, 'signer.key'),
nodeKey: join(tmp, 'node.key'),
nodeCfg: join(tmp, 'node.json'),
app: join(tmp, 'application.json'),
regDir: join(tmp, 'registry'),
}
mkdirSync(join(dir.regDir, 'consumed'), { recursive: true })
const signer = id.generateAuthorityKey()
writeFileSync(dir.key, signer.privateKeyPem, { mode: 0o600 })
/** 签一张邀请(helper:与 `issue-invite` 同一条路径,⛔ 不复制逻辑)。 */
const issue = (network, expiresInMs) => {
const doc = {
version: reg.NODES_REGISTRY_VERSION,
network,
nonce: reg.newInviteNonce(randomBytes),
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
}
return { doc, sig: id.signPayloadWith(signer.privateKeyPem, reg.networkInvitePayload(doc)) }
}
const io = joinx.nodeJoinIo({
fss: { existsSync, readFileSync, writeFileSync, mkdirSync },
crypto: id,
os: require('node:os'),
fetchImpl: async () => ({ status: 599, body: 'selfcheck 不走 HTTP 通道' }),
})
const signers = [signer.publicKey]
// ── ① 正当路径:真的跑完四步 ──────────────────────────────────────────
const goodInvite = issue('selfcheck-net', 5 * 60 * 1000)
const joined = await joinx.runJoin(
{
network: 'selfcheck-net',
hostId: 'node-a',
invite: goodInvite,
trustedSigners: signers,
nodeKeyFile: dir.nodeKey,
localConfigFile: dir.nodeCfg,
outFile: dir.app,
},
io,
)
// ── ② 申请单逐字不含私钥(**字节级**,⛔ 不是"语义上应该没有")──────────
const appText = existsSync(dir.app) ? readFileSync(dir.app, 'utf8') : ''
const nodePriv = existsSync(dir.nodeKey) ? readFileSync(dir.nodeKey, 'utf8') : ''
// 取私钥 PEM 的**主体行**(去掉 BEGIN/END 包裹与换行)当探针串 —— 出现即泄露。
const privBody = (nodePriv.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('') || '')
const privateKeyLeak = appText === '' || privBody === '' ? -1 : appText.includes(privBody) ? 1 : 0
// ── ③ 节点私钥文件权限 ────────────────────────────────────────────────
const nodeKeyMode = existsSync(dir.nodeKey) ? (require('node:fs').statSync(dir.nodeKey).mode & 0o777).toString(8) : 'absent'
// ── ④ 控制面**按 CLI 同样的路径**收单(解析申请单文件 → 验签 → 原子占位 → 落 pending)──
// 🔴 真机首轮实测教训:selfcheck 原先**直接调库**、绕过了 `apply` 的解析 ⇒ 掩盖了
// "join 产出的申请单 与 apply 的解析形状不一致"这个真缺陷。⇒ 这里必须走**文件 → 解析**。
const failures = []
const regFile = join(dir.regDir, 'nodes.json')
const led = { dir: join(dir.regDir, 'consumed') }
let parsedApp
let appShapeErr = ''
try {
parsedApp = joinx.readApplicationFile(dir.app)
} catch (err) {
appShapeErr = err instanceof Error ? err.message : String(err)
}
if (appShapeErr !== '') {
failures.push({ leg: 'cli-roundtrip', step: 'register', reason: 'application-shape-invalid', named: true, detail: appShapeErr.slice(0, 160) })
}
const invFromFile =
parsedApp === undefined
? { ok: false, reason: 'bad-payload' }
: reg.verifyNetworkInvite(parsedApp.invite.doc, parsedApp.invite.sig, signers, { network: parsedApp.network })
const first = invFromFile.ok ? reg.consumeNonce(led, invFromFile.doc.nonce, 'selfcheck first') : { ok: false, reason: 'invite-bad-payload' }
const second = invFromFile.ok ? reg.consumeNonce(led, invFromFile.doc.nonce, 'selfcheck second') : { ok: false, reason: 'invite-bad-payload' }
const r = reg.emptyRegistry()
const applied =
parsedApp !== undefined && first.ok
? reg.applyApplication(r, {
network: parsedApp.network,
hostId: parsedApp.hostId,
nodeKey: parsedApp.nodeKey,
at: new Date().toISOString(),
})
: { ok: false, reason: 'not-applied' }
const approved = reg.approveNode(r, parsedApp?.network ?? 'selfcheck-net', parsedApp?.hostId ?? 'node-a', new Date().toISOString())
reg.saveRegistry(regFile, r)
// ── ⑤ 四条负腿:每条都必须**具名**拒绝 ────────────────────────────────
const record = (leg, outcome) => {
const named = typeof outcome.reason === 'string' && outcome.reason !== ''
failures.push({
leg,
step: typeof outcome.step === 'string' ? outcome.step : '',
reason: named ? outcome.reason : '',
named,
detail: typeof outcome.detail === 'string' ? outcome.detail.slice(0, 160) : '',
})
}
const joinBase = {
network: 'selfcheck-net',
hostId: 'node-b',
trustedSigners: signers,
nodeKeyFile: join(tmp, 'node-b.key'),
localConfigFile: join(tmp, 'node-b.json'),
outFile: join(tmp, 'application-b.json'),
}
// 负腿 1:签名被篡改
const tampered = issue('selfcheck-net', 5 * 60 * 1000)
tampered.sig = `${tampered.sig.slice(0, -4)}AAAA`
record('bad-signature', await joinx.runJoin({ ...joinBase, invite: tampered }, io))
// 负腿 2:已过期
record('expired', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', -60 * 1000) }, io))
// 负腿 3:邀请绑的是别张网
record('wrong-network', await joinx.runJoin({ ...joinBase, invite: issue('other-net', 5 * 60 * 1000) }, io))
// 负腿 4:没配受信签名者(**不可验 = 不接受**)
record('no-trusted-signer', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', 5 * 60 * 1000), trustedSigners: [] }, io))
// 负腿 5:既没 --portal 也没 --out(⛔ 不许静默成功)
record('no-channel', await joinx.runJoin({ ...joinBase, invite: issue('selfcheck-net', 5 * 60 * 1000), outFile: '' }, io))
// ── ⑥ 派生自审 + 两网零共享 ─────────────────────────────────────────
const audit = reg.auditDerivation(r)
const derived = reg.deriveDialers(r)
const nets = [...derived.keys()].sort()
// **跨网共享** = 同一 hostId 出现在 ≥2 张网(结构性隔离下**必然为 0**:桶键含网络)。
const seen = new Map()
let crossNetworkShared = 0
for (const n of nets) for (const h of derived.get(n) ?? []) {
if (seen.has(h)) crossNetworkShared++
seen.set(h, n)
}
const dropIn = reg.deriveDropIn(r, { network: 'selfcheck-net' })
const dropInHasNetwork = dropIn.includes('selfcheck-net/node-a')
const steps = joined.ok ? joined.steps : []
// ⚠️ **权限只在 Linux 上可判**:Windows 的 `stat.mode` 是只读属性模拟(恒 666/444),
// 拿它当"0600 成立"= **假绿**。⇒ 记 `platform` 并让判据可区分"不成立"与"这台机器不可判"。
const modeOk = process.platform === 'linux' ? nodeKeyMode === '600' : null
const report = {
version: 1,
at: new Date().toISOString(),
platform: process.platform,
tmp,
artifacts: { join: true, registry: true, admitCli: true },
joinSteps: steps.map((s) => ({ step: s.step, ok: s.ok })),
joinStepsMin: joinx.JOIN_STEPS.length,
joinOk: joined.ok === true,
joinFailStep: joined.ok ? '' : joined.step,
joinFailReason: joined.ok ? '' : joined.reason,
application: {
bytes: Buffer.byteLength(appText, 'utf8'),
hasPrivateKey: privateKeyLeak,
containsOnlyPublicKey: joined.ok ? appText.includes(joined.nodeKey) : false,
// 🔴 **CLI 回环**:申请单文件能否被 `parseApplication` 读回(真机首轮栽的就是这条)。
shapeOk: parsedApp !== undefined,
shapeError: appShapeErr.slice(0, 200),
// 申请单里读回的 hostId/network 必须与 join 的输入**逐字一致**(防"写对了但读歪了")。
hostMatches: parsedApp !== undefined && parsedApp.hostId === 'node-a' && parsedApp.network === 'selfcheck-net',
},
nodeKeyMode,
// `null` = **本平台不可判**(⛔ 不是 PASS);判据只对 `600` 取真。
nodeKeyModeOk: modeOk,
ledger: { first: first.ok ? 'ok' : first.reason, second: second.ok ? 'ok' : second.reason },
registry: {
file: regFile,
applied: applied.ok ? applied.record.status : applied.reason,
approved: approved.ok ? approved.record.status : approved.reason,
auditOk: audit.ok,
mismatches: audit.mismatches,
misfiled: audit.misfiled,
},
derivation: { networks: nets, crossNetworkShared, dropInHasNetwork },
namedFailures: failures,
silentRejections: failures.filter((f) => f.named !== true || f.reason === '').length,
}
const out = opt('out')
if (args.json === true || out !== undefined) {
const text = `${JSON.stringify(report, null, 2)}\n`
if (out !== undefined) writeFileSync(out, text, { mode: 0o644 })
if (args.json === true && out === undefined) process.stdout.write(text)
if (out !== undefined) ok(`✓ 自检读数已写出:${out}`)
return
}
ok(`✓ join 四步:${steps.filter((s) => s.ok).length}/${joinx.JOIN_STEPS.length}|ok=${report.joinOk}`)
ok(`✓ 申请单 ${report.application.bytes} B|私钥出现次数=${report.application.hasPrivateKey}(须 0)`)
ok(
modeOk === null
? `⚠️ 私钥权限=${nodeKeyMode} —— platform=${process.platform} ⇒ **本平台不可判**(⛔ 不当 PASS;真机腿见部署后 ssh stat -c %a)`
: `✓ 私钥权限=${nodeKeyMode}(须 600)⇒ ${modeOk ? '成立' : '不成立'}`,
)
ok(`✓ 一次性:first=${report.ledger.first} second=${report.ledger.second}(须 invite-already-used)`)
ok(`✓ 派生自审 auditOk=${audit.ok}|网=${nets.join(',')}|跨网共享=${crossNetworkShared}(须 0)`)
for (const f of failures) ok(` · 负腿 ${f.leg}: step=${f.step} reason=${f.reason}`)
ok(`✓ 具名失败 ${failures.length} 条|静默拒绝=${report.silentRejections}(须 0)`)
},
}
async 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)
}
await fn()
}
main().catch((err) => {
process.stderr.write(`✗ ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env node
/**
* 覆盖网络 **节点侧** 命令行:一条命令接入(序㊱ · P1 · S3)。
*
* ## 用户口径(原话)
* 「**b 要实现开启一台结点服务器,就能连上覆盖网络**,这样做这个网络才有价值」。
* ⇒ 判据 = **一条命令**,⛔ 不是"照文档改四处配置"(现状:装单元 / 配密钥 / 写 drop-in / DB 登记)。
*
* ## 用法
* ```bash
* # ① 离线通道(控制面尚未开放接收入口时唯一可用;把申请单交给控制面收单)
* node scripts/overlay-node-join.cjs --network ops --invite /tmp/invite.json \
* --signer-pub <hex> --out /tmp/application.json
*
* # ② HTTP 通道(控制面已开放接收入口时;才谈得上"一条命令完成")
* node scripts/overlay-node-join.cjs --network ops --invite <json|file> \
* --signer-pub <hex> --portal https://<控制面>/dshs-overlay/join
* ```
*
* | 选项 | 缺省 | 说明 |
* |---|---|---|
* | `--network` | **必填** | 要加入的网(`ops` | `u:<租户>` | 显式命名网) |
* | `--invite` | **必填** | 邀请凭据:**文件路径**或**内联 JSON** |
* | `--host` | 本机主机名(规范化) | 该节点在这张网里的**逻辑名** |
* | `--key` | `/etc/dshs/node.key` | 节点私钥落点(**`0600`,⛔ 永不出机**) |
* | `--config` | `/etc/dshs/overlay-node.json` | 本机配置落点(`0600`,只记"私钥在哪") |
* | `--out` / `--portal` | — | 二选一(⛔ 都没有 ⇒ **具名失败**,不静默成功) |
* | `--direct` | **开**(🆕 序㊵) | 本机**直连(打洞)开关**:可写 `1/true/on/yes` 或 `0/false/off/no`;⛔ 取值非法 ⇒ **具名失败**(不静默取缺省)。关闭 ⇒ ⛔ 不绑 UDP 口、⛔ 不发直连候选 |
* | `--signer-pub` | env `DSHS_OVERLAY_SIGNER_PUBKEYS` | 受信签名者(空 ⇒ **不可验 = 不接受**) |
*
* ## 🔴 失败一律具名
* 打印**走到第几步**(四步逐条 `✓`/`✗`)+ **原因码**,`exit 1`。
* ⛔ 本文件与 `join.ts` 内**零空 `catch` 块** —— 这是本线治"配置错长得像网络不通"的机器判据。
*
* @module scripts/overlay-node-join
*/
'use strict'
const { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } = require('node:fs')
const joinx = require('../lib/net/relay/join.js')
const id = require('../lib/net/relay/identity.js')
const directx = require('../lib/net/relay/direct/index.js')
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(process.argv.slice(2))
function need(name) {
const v = args[name]
if (typeof v !== 'string' || v.trim() === '') {
process.stderr.write(`✗ join 失败:缺少必填参数 --${name}(用法见本文件头部表格)\n`)
process.exit(2)
}
return v.trim()
}
function opt(name) {
const v = args[name]
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined
}
/** 受信签名者(`--signer-pub` 优先,回落 env —— 与 relay / dsh 同一套来源,⛔ 不另造)。 */
function trustedSigners() {
const explicit = opt('signer-pub')
if (explicit !== undefined) return explicit.split(',').map((s) => s.trim()).filter((s) => s !== '')
return id.identityEnvTrustedSigners(process.env)
}
/** `--invite` 既接受**文件路径**也接受**内联 JSON**(`{doc, sig}`)。 */
function readInvite(spec) {
const trimmed = spec.trim()
if (trimmed.startsWith('{')) {
const raw = JSON.parse(trimmed)
if (raw === null || typeof raw !== 'object') throw new Error('--invite 的 JSON 不是对象')
const r = raw
if (r.doc === undefined || r.sig === undefined) throw new Error('--invite 的 JSON 缺 doc / sig 字段')
return { doc: r.doc, sig: r.sig }
}
return joinx.readSignedJson(trimmed)
}
const fss = { existsSync, readFileSync, writeFileSync, mkdirSync }
/** 真的发一次 POST(`--portal` 通道)。响应体**原样**带回 —— 控制面的原因码不许被吞掉。 */
async function post(url, body) {
const res = await fetch(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
})
return { status: res.status, body: await res.text() }
}
async function main() {
const network = need('network')
const invite = readInvite(need('invite'))
const hostFromArg = opt('host')
const hostId = hostFromArg ?? joinx.defaultHostId(require('node:os').hostname())
if (hostId === '') {
process.stderr.write('✗ join 失败:hostId 推不出来(本机主机名规范化后为空)⇒ 必须显式给 --host\n')
process.exit(2)
}
const nodeKeyFile = opt('key') ?? '/etc/dshs/node.key'
const localConfigFile = opt('config') ?? '/etc/dshs/overlay-node.json'
const outFile = opt('out')
const portalUrl = opt('portal')
const signers = trustedSigners()
// 🆕 序㊵(P2/S5):直连(打洞)开关 —— **缺省开**(用户口径②),可用 `--direct 0` 当场关掉。
const directArg = opt('direct')
let direct = directx.DEFAULT_DIRECT_ENABLED
if (directArg !== undefined) {
const v = directArg.trim().toLowerCase()
if (directx.DIRECT_ON_VALUES.includes(v)) direct = true
else if (directx.DIRECT_OFF_VALUES.includes(v)) direct = false
else {
// ⛔ 取值非法 ⇒ **具名失败**(⛔ 不静默取缺省 —— 本线所有"配置错长得像网络不通"都源于这一手)
process.stderr.write(
`✗ join 失败:--direct ${JSON.stringify(directArg)} 既不在开集 ${directx.DIRECT_ON_VALUES.join('/')} 也不在关集 ${directx.DIRECT_OFF_VALUES.join('/')}\n`,
)
process.exit(2)
}
}
const outcome = await joinx.runJoin(
{ network, hostId, invite, trustedSigners: signers, nodeKeyFile, localConfigFile, outFile, portalUrl, direct },
joinx.nodeJoinIo({ fss, crypto: id, os: require('node:os'), fetchImpl: post }),
)
for (const s of outcome.steps) {
process.stdout.write(`${s.ok ? '✓' : '✗'} [${s.step}] ${s.detail}\n`)
}
if (outcome.ok) {
process.stdout.write(`${joinx.describeJoin(outcome)}\n`)
process.stdout.write(
`ℹ️ 节点文件权限:${statSync(nodeKeyFile).mode & 0o777 ? (statSync(nodeKeyFile).mode & 0o777).toString(8) : 'n/a'}` +
`(私钥)|${(statSync(localConfigFile).mode & 0o777).toString(8)}(配置)\n`,
)
process.stdout.write('🔴 本命令⛔ 不装服务单元、⛔ 不写 drop-in —— 白名单由**控制面派生**(`overlay-node-admit.cjs derive`)。\n')
// 🆕 序㊵(P2/S5):**用户口径③「提示用户」** —— 开启直连(打洞)时必须说清
// ① 谁可能连进来 ② 怎么关 ③ 关掉不影响什么(⛔ 禁止只写"已启用直连")。
process.stdout.write(`ℹ️ 直连(打洞)= ${direct ? '开(缺省)' : '关'}|开关键 ${directx.DIRECT_ENV_KEY}|本机配置字段 direct\n`)
if (direct) {
for (const line of directx.directHintLines()) process.stdout.write(` ${line}\n`)
} else {
process.stdout.write(
` 已按 --direct 关闭:⛔ 不绑任何 UDP 端口、⛔ 不发直连候选;跨机流量走中继(接入与准入不受影响)。\n`,
)
}
if (outcome.applicationFile !== '') {
process.stdout.write(`➡️ 把申请单交给控制面收单:overlay-node-admit.cjs apply --file ${outcome.applicationFile}\n`)
}
return
}
process.stdout.write(`${joinx.describeJoin(outcome)}\n`)
process.stderr.write(`✗ join 失败:${outcome.reason} — 停在「${outcome.step}」步:${outcome.detail}\n`)
process.exit(1)
}
main().catch((err) => {
// ⛔ 唯一的兜底 catch,**必须打印原文**(空 catch = 静默失败 = 本线要根治的病)。
process.stderr.write(`✗ join 异常:${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
process.exit(1)
})
+797 -63
View File
File diff suppressed because it is too large. Load diff
+382 -8
View File
@@ -18,8 +18,24 @@
* 3. **纯新增、可选、缺省可用**:relay 侧没装内容面时,`snapshot()` 返回 `undefined`
* ⇒ `/status` 不含 `content` 键 ⇒ 与序㉔ 之前的字节级兼容(⛔ 不改任何既有字段)。
*
* ## 🆕 序㊶ · S6:`peer` 档**接线**(优先直连 → 回落 wss)
* 序㉔ 的 `peer` 档是**诚实回"没有"**的空壳(取回通道未接线)。S6 把它接上**两条通道**:
*
* | 顺序 | 通道 | 是什么 |
* |---|---|---|
* | ① | `direct` | S5 的直连(打洞)通道 —— **同时复用 S5 的准入判定**(`candidate.ts#admitCandidate`,其后端是 `network.ts#isAllowedDialer`) |
* | ② | `wss` | 既有 wss 取回通道(**注入位**;relay 协议侧暂无内容取回 op ⇒ 缺省 = 具名 `not-wired`) |
*
* 🔴 **D7 闸门(本模块最要紧的一行逻辑)**:取回的**落库字节**必须**复算**出与请求相同的块 id
* (`chunker#blockIdOf`)。不复算的接线会把"零回源"直接做成假绿 —— 块 id 口径一动,
* "命中 peer" 仍然全绿,而内容已经拼不上。
*
* ## ⛔ 本模块**不做**的事
* - 不做网络取块(peer 的真实取回通道在真机验证阶段由上层接线);
* - **不做准入判定**:直连候选只从 {@link ContentRuntime.noteDirectCandidate} 进来,
* 而它只收 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果 ⇒ ⛔ 本模块**不另写一份白名单**;
* - 不做直连**数据面**(打洞成功后的块交换需要 UDP 应答端 = 新暴露面 ⇒ 独立后续项,
* 现状按具名 `data-plane-pending` 回落到 wss,⛔ 不静默当"没有");
* - 不读 `src/config.ts`(relay 是独立单元,见 `main.ts` 头部说明);
* - 不写日志(日志在装配点给;本模块只负责**算账与报数**)。
*
@@ -34,6 +50,165 @@ import { ContentStore, DEFAULT_MAX_BYTES } from './store.js'
import type { ContentStoreCounters } from './store.js'
import { chunkify, planOf, reassemble, blockIdOf, DEFAULT_BLOCK_SIZE } from './chunker.js'
import type { ContentCipher, ContentCryptoCounters } from './crypto.js'
import { DIRECT_CAND_MAX_ADDRS, isValidAddress } from '../direct/candidate.js'
import type { CandidateVerdict, DirectAddress } from '../direct/candidate.js'
import { DEFAULT_DIRECT_COOLDOWN_MS, DirectCooldown } from '../direct/punch.js'
import { DIRECT_ENV_KEY, resolveDirectSwitch } from '../direct/index.js'
import type { DirectSwitchState } from '../direct/index.js'
/* ── 🆕 序㊶ · S6:`peer` 档取块**通道**(接线) ───────────────────────────────────────── */
/** peer 档取块通道名。 */
export type PeerChannelName = 'direct' | 'wss'
/**
* 🔴 通道优先级 —— **唯一权威**(⛔ 不许在调用方另写一份数组)。
* `direct` 在前(省中继一跳);不可用 / 未命中 / 出错 ⇒ 逐个往后回落。
*/
export const PEER_CHANNEL_ORDER: readonly PeerChannelName[] = ['direct', 'wss']
/**
* 通道**此刻不可用**的具名原因。
*
* 🔴 与"未命中"**必须可区分**(`source.ts` 纪律 2 的同一条纪律):
* - `miss` = 通道问了,对端说"我这没有";
* - `unavailable` = 这条通道**根本没问**(开关关 / 冷却中 / 没候选 / 没接线 / 数据面未建成)。
*
* 混在一起 ⇒ "直连从没生效过"会被读成"直连问过了但没有",正是本线的假绿形态。
*/
export type PeerChannelDownReason =
/** 用户/运维把直连关了(`DSHS_OVERLAY_DIRECT=0`)。 */
| 'disabled'
/** 开关**取值非法**(⛔ 不静默当开、⛔ 也不静默当关)。 */
| 'invalid-switch'
/** 判死后仍在冷却里(⛔ 本次连 socket 都不开)。 */
| 'cooldown'
/** 没有**准入过**的候选地址(候选交换还没喂进来 / 全被拒)。 */
| 'no-address'
/** 🔴 直连**数据面**未建成(S5 只做打洞**探测**)⇒ 具名回落,⛔ 不假装取到。 */
| 'data-plane-pending'
/** 该通道**未装配**(`wss` 缺省:relay 协议侧暂无内容取回 op)。 */
| 'not-wired'
/** 一次 peer 取块的**对象**(逻辑名 + 网 + 组;组键由 `peer.ts` 判)。 */
export interface PeerRef {
name: string
network: string
group: string
}
/** 一条取块通道 —— ⛔ 只回字节,**不做判定**(`available` 只答"此刻能不能问")。 */
export interface PeerBlockChannel {
readonly name: PeerChannelName
/** 此刻是否可用(不可用 ⇒ **具名**,调用方据此记账并走下一通道)。 */
available(peer: PeerRef, id: string): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string }
/** 取块:拿到字节(**落库字节**)⇒ 返回;对端说没有 ⇒ `undefined`;出错 ⇒ 抛。 */
fetch(peer: PeerRef, id: string): Promise<Buffer | undefined>
}
/** `peer` 接线面的只读快照(探针 / 管理面读这一份)。 */
export interface PeerWireSnapshot {
/** 通道优先级(= {@link PEER_CHANNEL_ORDER} 的副本)。 */
order: PeerChannelName[]
/** 每条通道是否已装配。 */
wired: Record<PeerChannelName, boolean>
/** 逐通道"真问了"的次数。 */
attempts: Record<PeerChannelName, number>
/** 逐通道**命中**(=取回字节 + **复算通过**)。 */
hits: Record<PeerChannelName, number>
/** 逐通道**未命中**(问了,对端说没有)。 */
misses: Record<PeerChannelName, number>
/** 逐通道**抛错**(与未命中可区分)。 */
errors: Record<PeerChannelName, number>
/** 逐通道**不可用**(根本没问)。 */
unavailable: Record<PeerChannelName, number>
/** 不可用原因的具名分布(`<channel>:<reason>` → 次数)。 */
downReasons: Record<string, number>
/** 🔴 **D7 闸门**:复算过的块数。 */
idChecks: number
/** 🔴 **D7 闸门**:复算**不符**被丢弃的块数(恒应 ≤ `idChecks`;命中时必为 0 增长)。 */
idMismatches: number
/** 已登记的直连候选 peer 数(只含准入过的)。 */
directCandidates: number
/** 最近一次 peer 取块的实况(⛔ 不许"没有原因地走了 wss")。 */
last: { peer: string; channel: PeerChannelName | null; reason: string } | null
}
/**
* 直连通道的装配选项。
*
* ⚠️ `addresses` 是**唯一**的候选来源,而它由 {@link ContentRuntime.noteDirectCandidate} 喂养 ——
* 那条路径只接受 S5 准入判定 `ok:true` 的结果 ⇒ 白名单判定**不在这里**(⛔ 不另写一份)。
*/
export interface DirectBlockChannelOptions {
/** 开关解析结果(由 `direct/index.ts#resolveDirectSwitch` 产出)。 */
switchState: DirectSwitchState
/** 冷却表(与 S5 同一份纪律:`ms <= 0` ⇒ **构造期即抛**)。 */
cooldown: DirectCooldown
/** 该 peer 的准入候选地址(`undefined` = 没有 ⇒ 具名 `no-address`)。 */
addresses: (peer: string) => readonly DirectAddress[] | undefined
}
/**
* 直连通道的**数据面未建成**标记。
*
* ⛔ 为什么不直接返回 `undefined`:那会被记成"未命中"(= 对端没有),而真相是
* "这条通道根本没能力取"。两者混同 ⇒ "直连零生效"被读成"直连没省到"(本线假绿形态)。
*/
export const DIRECT_DATA_PLANE_PENDING = 'data-plane-pending'
/**
* 造一条**直连通道**(顺序链的①)。
*
* 🔴 三级闸门**顺序固定**(每一级都必须具名):
* ① 开关(关 / 非法 ⇒ `disabled` / `invalid-switch`)
* ② 冷却(判死过 ⇒ `cooldown`;⛔ 本次连 socket 都不开)
* ③ 候选地址(没准入过 ⇒ `no-address`)
* ④ 数据面(S6 未建成 ⇒ `data-plane-pending`,⛔ 不假装取到)
*/
export function createDirectBlockChannel(opts: DirectBlockChannelOptions): PeerBlockChannel {
return {
name: 'direct',
available(peer: PeerRef): { ok: true } | { ok: false; reason: PeerChannelDownReason; detail: string } {
const s = opts.switchState
if (s.enabled !== true) {
return s.enabled === false
? { ok: false, reason: 'disabled', detail: `${s.envKey} 关闭(来源 ${s.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接回落` }
: { ok: false, reason: 'invalid-switch', detail: s.invalid === '' ? `${s.envKey} 取值非法` : s.invalid }
}
if (opts.cooldown.blocked(peer.name)) {
return {
ok: false,
reason: 'cooldown',
detail: `${peer.name} 在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打洞,直接回落(⛔ 不进重试风暴)`,
}
}
const addrs = opts.addresses(peer.name)
if (addrs === undefined || addrs.length === 0) {
return {
ok: false,
reason: 'no-address',
detail: `${peer.name} 没有准入过的直连候选地址 ⇒ 回落(⛔ 不是"打洞失败")`,
}
}
return {
ok: false,
reason: 'data-plane-pending',
detail:
`有 ${addrs.length} 条准入候选、开关开、未冷却,但**直连数据面未建成**` +
`(S5 只做打洞探测,打洞后的块交换需新增 UDP 应答端 ⇒ 独立后续项)⇒ 具名回落,⛔ 不假装取到`,
}
},
async fetch(peer: PeerRef): Promise<Buffer | undefined> {
// ⛔ 防御性:`available()` 在数据面建成前**恒不返回 ok:true** ⇒ 这里理应不可达。
// 真被调到 ⇒ 大喊,而不是静默回"没有"(后者会把"没建成"伪装成"对端没有")。
throw new Error(
`direct: 直连数据面未建成 —— ${peer.name} 的块交换未实现(${DIRECT_DATA_PLANE_PENDING});` +
`⛔ 不许把它记成"未命中",请走 available() 的具名降级`,
)
},
}
}
/** 内容面运行时装配选项。 */
export interface ContentRuntimeOptions {
@@ -60,6 +235,18 @@ export interface ContentRuntimeOptions {
onDecodeRejected?: (tier: SourceTier, id: string, reason: 'decode-failed') => void
/** 日志函数(可选)。⚠️ ⛔ 不许把明文块塞进日志(`OBS-23` 会扫)。 */
log?: (line: string) => void
/**
* 🆕 序㊶ · S6:**额外注入**的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。
* ⚠️ 缺省只装内置的 `direct`(见 {@link ContentRuntimeOptions.direct});
* `wss` 通道**必须注入**才有(relay 协议侧暂无内容取回 op)。
*/
peerChannels?: Partial<Record<PeerChannelName, PeerBlockChannel>>
/**
* 🆕 序㊶ · S6:内置直连通道的选项。
* - 缺省 ⇒ 装配(开关读 `process.env` ⇒ 与 `DSHS_OVERLAY_DIRECT`「**缺省即开**」一致);
* - `false` ⇒ **不装配**(该通道缺 ⇒ 具名 `not-wired`)。
*/
direct?: false | { env?: Readonly<Record<string, string | undefined>>; cooldownMs?: number }
}
/**
@@ -95,6 +282,11 @@ export interface ContentSnapshot {
storeBlocks: number
/** 本节点**组内**已声明的 peer 名单(供上层做真机取块接线)。 */
groupMembers: string[]
/**
* 🆕 序㊶ · S6:**peer 档接线面**(通道顺序 / 具名降级 / **D7 复算闸门**)。
* ⚠️ 纯新增键 —— 既有消费方(`OBS-17` 只逐键看 `source`/`peer`/`store`)零影响。
*/
peerWire: PeerWireSnapshot
/**
* 🆕 序㉘ · 单 B:**组密钥加密判别器**(探针 `OBS-23` 的读取口径)。
* ⚠️ **不启用加密时本键整体缺席** ⇒ `OBS-23` 记 **SKIP**("缺省不启用"是合法状态)。
@@ -117,11 +309,40 @@ export class ContentRuntime {
readonly cipher: ContentCipher | undefined
private readonly storeMaxBytes: number
/** 本节点所属网(候选登记的防御性比对用;⛔ 不从别处猜)。 */
private readonly network: string
/** 日志函数(未注入 ⇒ 静默)。 */
private readonly log: ((line: string) => void) | undefined
/** 自证写入的最后一个块 id(仅供 `selfProbe` 读回用)。 */
private lastProbeId: string | undefined
/** 🆕 S6:已装配的取块通道(按 {@link PEER_CHANNEL_ORDER} 排顺序)。 */
private readonly peerChannels = new Map<PeerChannelName, PeerBlockChannel>()
/** 🆕 S6:直连通道的开关解析结果(观测面用;未装配 ⇒ `undefined`)。 */
private readonly directSwitch: DirectSwitchState | undefined
/** 🆕 S6:直连候选地址表(**只经 `noteDirectCandidate` 写入**,⛔ 不从别处塞)。 */
private readonly directAddrs = new Map<string, DirectAddress[]>()
/**
* 🆕 S6:接线面计数。
*
* ⚠️ `downReasons` 的键是 `` `${channel}:${reason}` `` —— 用字符串键而不是嵌套对象,
* 是为了让 `JSON.stringify` 出来的 `/status` 一眼可读、探针逐键断言也简单。
*/
private readonly wire = {
attempts: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
hits: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
misses: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
errors: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
unavailable: { direct: 0, wss: 0 } as Record<PeerChannelName, number>,
downReasons: {} as Record<string, number>,
idChecks: 0,
idMismatches: 0,
last: null as PeerWireSnapshot['last'],
}
constructor(opts: ContentRuntimeOptions) {
this.storeMaxBytes = opts.storeMaxBytes ?? DEFAULT_MAX_BYTES
this.network = opts.network
this.log = opts.log
this.cipher = opts.cipher
this.store = new ContentStore({
maxBytes: this.storeMaxBytes,
@@ -134,6 +355,26 @@ export class ContentRuntime {
...(this.cipher === undefined ? {} : { epoch: this.cipher.epoch }),
...(opts.log === undefined ? {} : { log: opts.log }),
})
// ── 🆕 序㊶ · S6:装配取块通道(顺序由 `PEER_CHANNEL_ORDER` 定,⛔ 不在这里排)──────
if (opts.direct !== false) {
const env = opts.direct?.env ?? process.env
const switchState = resolveDirectSwitch(env)
this.directSwitch = switchState
this.peerChannels.set(
'direct',
createDirectBlockChannel({
switchState,
cooldown: new DirectCooldown(opts.direct?.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS),
addresses: (peer) => this.directAddrs.get(peer),
}),
)
}
const injected = opts.peerChannels ?? {}
for (const name of PEER_CHANNEL_ORDER) {
const ch = injected[name]
// ⚠️ 注入的通道**覆盖**内置的(便于夹具替身),⛔ 但不许改名(顺序权威只此一份)
if (ch !== undefined) this.peerChannels.set(name, ch)
}
this.source = new ContentSourceChain({
fetchers: {
// ① 本地:内容寻址存储命中即返回(零网络 —— 最省的档位)
@@ -143,15 +384,10 @@ export class ContentRuntime {
? undefined
: { tier: 'local' as const, bytes }
},
// ② 同组 peer:**诚实回"没有"**,直到上层把真实取回通道接上。
// ② 同组 peer:**S6 接线** —— 逐候选 × 逐通道(direct → wss)尝试;
// 🔴 取回的**落库字节**必须复算出同一个块 id(D7 闸门),否则**丢弃且不算命中**。
// ⛔ 绝不许在这里伪造字节 —— 那会把 E1 的"零回源"做成假绿(本线的老病根)。
peer: async (id: string) => {
const cands = this.peers.candidates(id)
if (cands.length === 0) return undefined
// 有候选但取回通道尚未接线 ⇒ 逐条记账后诚实回"没有"
for (const c of cands) this.peers.markDenied(c.name, id)
return undefined
},
peer: async (id: string) => this.fetchFromPeers(id),
},
// 🆕 单 B:**唯一解密点**(生产路径)—— 五档一律在这里解密
...(this.cipher === undefined ? {} : { decode: (stored: Buffer) => this.cipher?.decodeBlock(stored) }),
@@ -163,6 +399,142 @@ export class ContentRuntime {
this.blockSize = DEFAULT_BLOCK_SIZE
}
/**
* 🆕 序㊶ · S6:**peer 档接线的唯一执行点**。
*
* 顺序写死:**候选**(同组,`peer.ts` 的 E5 闸门)→ **通道**(`direct` → `wss`)。
* 每一步都记账,且三类结果**互相可区分**:
* - `unavailable`(这条通道压根没问)/`misses`(问了,对端没有)/`errors`(问了,炸了)/`hits`(拿到**且复算通过**)。
*
* @returns 命中 ⇒ `{tier:'peer', bytes}`(**落库字节**,解密由链上唯一解密点做);否则 `undefined`
*/
private async fetchFromPeers(id: string): Promise<{ tier: 'peer'; bytes: Buffer } | undefined> {
const cands = this.peers.candidates(id)
if (cands.length === 0) return undefined
for (const c of cands) {
// 🔴 E5:跨组 ⇒ **显式拒绝**(计数在 `markDenied` 内,语义与序㉔ 逐字不变)
if (this.peers.markDenied(c.name, id)) continue
const ref: PeerRef = { name: c.name, network: c.network, group: c.group }
for (const name of PEER_CHANNEL_ORDER) {
const ch = this.peerChannels.get(name)
if (ch === undefined) {
this.wire.unavailable[name] += 1
this.noteDown(name, 'not-wired')
continue
}
const av = ch.available(ref, id)
if (!av.ok) {
// ⛔ 不可用 ≠ 未命中:这条通道**没问**,只是被具名降级了
this.wire.unavailable[name] += 1
this.noteDown(name, av.reason)
this.wire.last = { peer: c.name, channel: name, reason: av.reason }
continue
}
this.wire.attempts[name] += 1
let gotBytes: Buffer | undefined
try {
gotBytes = await ch.fetch(ref, id)
} catch (err) {
this.wire.errors[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'error' }
this.logLine(`[content-peer] ⛔ channel=${name} peer=${c.name} 取块抛错:${String(err)}`)
continue
}
if (gotBytes === undefined) {
this.wire.misses[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'miss' }
continue
}
// ── 🔴 **D7 闸门**:复算块 id(⛔ 这一行不许省、不许"先信后验")────────────
this.wire.idChecks += 1
const actual = blockIdOf(gotBytes)
if (actual !== id) {
this.wire.idMismatches += 1
this.wire.errors[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'id-mismatch' }
this.logLine(
`[content-peer] ⛔ 复算不符 channel=${name} peer=${c.name}` +
` 期望=${id.slice(0, 8)}… 实得=${actual.slice(0, 8)}… ⇒ **丢弃**(⛔ 不算命中、⛔ 不返回字节)`,
)
continue
}
this.wire.hits[name] += 1
this.wire.last = { peer: c.name, channel: name, reason: 'hit' }
return { tier: 'peer', bytes: gotBytes }
}
}
return undefined
}
/** 记账:不可用原因分布(⛔ 不许"没有原因地走了另一条通道")。 */
private noteDown(channel: PeerChannelName, reason: PeerChannelDownReason): void {
const key = `${channel}:${reason}`
this.wire.downReasons[key] = (this.wire.downReasons[key] ?? 0) + 1
this.wire.last = this.wire.last ?? { peer: '', channel, reason }
}
/** 日志(未注入 ⇒ 静默;⛔ 不把块字节写进日志)。 */
private logLine(line: string): void {
this.log?.(line)
}
/**
* 🆕 序㊶ · S6:登记一条**直连候选**(**唯一入口**)。
*
* 🔴 **准入判定不在这里** —— 只接受 S5 `CandidateLedger#judge` 判过 `ok:true` 的结果
* (那条链路复用 `network.ts#isAllowedDialer`,⛔ 本模块不另写一份白名单)。
* 这里只做两件**防御性**收尾:① 网必须与本节点一致(跨网 ⇒ 拒)② 地址逐条过 `isValidAddress` + 条数上限。
*/
noteDirectCandidate(verdict: CandidateVerdict): { ok: true; peer: string; addrs: number } | { ok: false; reason: string } {
if (!verdict.ok) return { ok: false, reason: verdict.reason }
const msg = verdict.message
if (msg.network !== this.network) {
return { ok: false, reason: `cross-network(载荷 ${msg.network} ≠ 本节点 ${this.network})` }
}
const addrs = msg.addrs.filter((a) => isValidAddress(a)).slice(0, DIRECT_CAND_MAX_ADDRS)
if (addrs.length === 0) return { ok: false, reason: 'bad-address(过滤后为空)' }
// ⚠️ 键 = **逻辑名** `<network>/<hostId>`(与 `peer.ts` 的 `PeerDeclaration.name` 同口径)
// —— 候选载荷里只有 `hostId`(relay 逻辑名的后半段),两处不同形 ⇒ 在这里统一,⛔ 别让调用方猜。
const key = `${msg.network}/${msg.hostId}`
this.directAddrs.set(key, addrs.map((a) => ({ ...a })))
return { ok: true, peer: key, addrs: addrs.length }
}
/** 🆕 S6:候选下线(该 peer 不再作为直连对象)。 */
withdrawDirectCandidate(peer: string): boolean {
return this.directAddrs.delete(peer)
}
/** 🆕 S6:已登记的直连候选(只读视图)。 */
directCandidates(): string[] {
return [...this.directAddrs.keys()].sort()
}
/** 🆕 S6:接线面只读快照(探针 / 管理面共用这一份 —— ⛔ 别各写一套)。 */
peerWireSnapshot(): PeerWireSnapshot {
const wired = { direct: false, wss: false } as Record<PeerChannelName, boolean>
for (const name of PEER_CHANNEL_ORDER) wired[name] = this.peerChannels.has(name)
return {
order: [...PEER_CHANNEL_ORDER],
wired,
attempts: { ...this.wire.attempts },
hits: { ...this.wire.hits },
misses: { ...this.wire.misses },
errors: { ...this.wire.errors },
unavailable: { ...this.wire.unavailable },
downReasons: { ...this.wire.downReasons },
idChecks: this.wire.idChecks,
idMismatches: this.wire.idMismatches,
directCandidates: this.directAddrs.size,
last: this.wire.last === null ? null : { ...this.wire.last },
}
}
/** 🆕 S6:直连开关的解析结果(`undefined` = 内置直连通道未装配)。 */
directSwitchState(): DirectSwitchState | undefined {
return this.directSwitch
}
/** 🆕 是否启用加密(判据用:区分"没启用"与"启用了但没解过")。 */
get cryptoEnabled(): boolean {
return this.cipher !== undefined
@@ -282,6 +654,8 @@ export class ContentRuntime {
storeBytes: this.store.bytes,
storeBlocks: this.store.size,
groupMembers: this.peers.sameGroupPeers(),
// 🆕 S6:peer 档接线面(**纯新增键** ⇒ 既有消费方零影响)
peerWire: this.peerWireSnapshot(),
// ⚠️ 不启用加密 ⇒ 本键**整体缺席**(不是补零!补零会让"没启用"与"启用了但零值"同形)
...(this.cipher === undefined ? {} : { crypto: this.cipher.counters() }),
}
+4
View File
@@ -39,6 +39,10 @@
* - 🔴 **不做完整性校验**(`E4` 那半边在 `store.get` 的读侧复算里)。⚠️ **如实留档**:
* `peer` 档的真实取回通道**尚未接线** ⇒ 接线时**必须**在取回后复算 `blockIdOf`
* (否则"篡改块被丢弃"只在 `local` 档成立 —— 已在单 B §8.13 登记)。
* ✅ **序㊶(S6)已兑现**:复算闸门落在**装配点** `runtime.ts#fetchFromPeers`
* (取回的落库字节必须复算出同一个块 id,不符 ⇒ **丢弃且不算命中**,逐条 `idMismatches` 计数)。
* ⛔ 刻意**不放在本模块**:链的职责是"按顺序问",块 id 口径属装配层的接线纪律
* —— 放进来会让"五档通用链"认识块格式(分层退化)。
*
* @module dshs/net/relay/content/source
*/
+382
View File
@@ -0,0 +1,382 @@
/**
* 覆盖网络 **直连候选交换**(序㊵ · P2 · S5)—— 候选地址的**收发与校验**。
*
* ## 它解决的确切问题
* 打洞前双方必须知道**对方的公网 UDP 落点**。这份模块定义"这条消息长什么样"以及
* "**什么样的对端才有资格发它**",其余(怎么打洞)在 `punch.ts`。
*
* ## 🔴 三条不可退让的设计(每一条都对应本线吃过的一次亏)
*
* | # | 纪律 | 为什么 |
* |---|---|---|
* | ① | **走既有 relay 通道**,⛔ 零新口、⛔ 零新协议 | 候选交换**不是**数据面:它只需要"已被鉴权的那条连接"。新增监听口 = 命中 R5 |
* | ② | 准入**复用** `network.ts#isAllowedDialer`(= `server.ts` DIAL 白名单那一条策略) | 本线老病根「同一事实两处写」:另写一份 ⇒ 两处迟早分叉,而 `server.ts` 那两处是**冻结的只读面** |
* | ③ | 拒绝**必须显式 + 计数**,⛔ **不许静默返空** | "配置错长得像网络不通"是本线反复踩的假绿面:静默返空 = 判据全绿而功能全废 |
*
* ## ⛔ 载荷里不许有什么
* 只准 `{kind, hostId, network, addrs[], ts}`。**任何**含密钥 / 签名 / 凭据字样的字段名
* ⇒ `secret-field` 直接拒(判定见 {@link findSecretField})。理由:候选是**对端**给的、
* 会被写进日志与观测面;它一旦能携带身份材料,就等于给"密钥本体不经网络"这条纪律开了一个洞。
*
* @module dshs/net/relay/direct/candidate
*/
import { isAllowedDialer, isHostId, isNetworkId, logicalName } from '../network.js'
/** 候选消息的类型标签(走既有 relay 通道时的 JSON 信封)。⛔ 别改名 —— 探针与单测按它取值。 */
export const DIRECT_MESSAGE_KIND = 'DIRECT_CANDIDATE'
/** 单条消息里候选地址的上限(缺省)。⚠️ 权威值在参数表 `DIRECT_CAND_MAX_ADDRS`。 */
export const DIRECT_CAND_MAX_ADDRS = 4
/** 候选的**新鲜期**(ms):超期 ⇒ `stale`。理由 = NAT 映射有寿命,过期的落点打不通还会浪费一次探测。 */
export const DIRECT_CAND_TTL_MS = 60_000
/** 打洞用的候选地址(**IPv4 / IPv6 字面量**,⛔ 不收主机名 —— 打洞不能依赖 DNS)。 */
export interface DirectAddress {
host: string
port: number
}
/** 候选消息本体。 */
export interface DirectCandidateMessage {
kind: typeof DIRECT_MESSAGE_KIND
/** 申报者自己在**这张网**里的逻辑名(= 发送方自己的 hostId)。 */
hostId: string
network: string
addrs: DirectAddress[]
/** 产生时刻(epoch ms);`0` = 未标注(不判新鲜期)。 */
ts: number
}
/**
* 拒绝原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一类被拒"可被机器判、可被计数。
*
* ⚠️ `'cross-network'` 与 `'not-self-candidate'` 是**安全判据**(跨网 / 冒名申报第三人地址),
* ⛔ 不是"配置错了"。
*/
export type CandidateReason =
/** 载荷形状不合法(缺字段 / 类型不对 / `kind` 不是本类型)。 */
| 'bad-shape'
/** 载荷里出现了疑似密钥 / 凭据字段。 */
| 'secret-field'
/** 申报的网与会话所属的网不一致(跨网)。 */
| 'cross-network'
/** 申报的 `hostId` **不是发送方自己**(= 替第三人申报地址)。 */
| 'not-self-candidate'
/** 发送方不在该网白名单里(⛔ 默认拒绝)。 */
| 'not-a-dialer'
/** 本机不在该网白名单里 ⇒ 连"能拨"都不成立,没必要建立直连。 */
| 'self-not-a-dialer'
/** 地址不是合法字面量(主机名 / 端口越界 / 非法 IP)。 */
| 'bad-address'
/** 地址条数超上限。 */
| 'too-many-addrs'
/** 超过新鲜期。 */
| 'stale'
/** 一次准入判定的结果(成功与失败都带 `detail`,⛔ 不许只回 `false`)。 */
export type CandidateVerdict =
| { ok: true; message: DirectCandidateMessage }
| { ok: false; reason: CandidateReason; detail: string }
/**
* 疑似密钥 / 凭据的字段名(**子串匹配,大小写不敏感**)。
*
* ⚠️ 这份清单**故意从宽**:漏掉一个词 = 给"密钥本体不经网络"开洞;
* 误伤一个正常的字段名 = 改个字段名就行(代价不对称 ⇒ 从严)。
*/
const SECRET_FIELD_TOKENS = [
'key',
'secret',
'token',
'pem',
'priv',
'sign',
'sig',
'cert',
'pass',
'cred',
'hmac',
'bearer',
] as const
/**
* 递归找一个"像密钥 / 凭据"的字段名(**任意深度**)。
*
* ⛔ 不看**值**(那要靠猜测):只看**字段名** —— 判据必须可复现、不靠人看。
*/
export function findSecretField(value: unknown, path = '$'): string | undefined {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const hit = findSecretField(value[i], `${path}[${i}]`)
if (hit !== undefined) return hit
}
return undefined
}
if (value === null || typeof value !== 'object') return undefined
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
const low = k.toLowerCase()
if (SECRET_FIELD_TOKENS.some((t) => low.includes(t))) return `${path}.${k}`
const hit = findSecretField(v, `${path}.${k}`)
if (hit !== undefined) return hit
}
return undefined
}
/** IPv4 字面量(**逐段 0–255**;⛔ 不收 `1.2.3`、⛔ 不收前导零)。 */
export function isIpv4(raw: string): boolean {
const parts = raw.split('.')
if (parts.length !== 4) return false
return parts.every((p) => /^(0|[1-9][0-9]{0,2})$/.test(p) && Number(p) <= 255)
}
/**
* IPv6 字面量(**宽松但排除了主机名字符**)。
*
* 口径:只允许 `[0-9a-fA-F:]` 且冒号数量 ≥ 2(⇒ 单冒号形态如 `host:80` 不可能通过)。
* ⛔ 刻意**不**做完整 RFC 解析:打洞只要求"能直接喂给 `dgram.send`",而形状错的地址
* 会在 `dgram` 那里立刻报错 ⇒ 这里只负责拦掉"看着像主机名"的那一类。
*/
export function isIpv6(raw: string): boolean {
if (!/^[0-9a-fA-F:]+$/.test(raw)) return false
const colons = (raw.match(/:/g) ?? []).length
return colons >= 2 && colons <= 7
}
/** 地址校验(只判**形状**;端口 1–65535)。 */
export function isValidAddress(a: DirectAddress): boolean {
if (typeof a.host !== 'string' || a.host === '') return false
if (!isIpv4(a.host) && !isIpv6(a.host)) return false
return Number.isInteger(a.port) && a.port > 0 && a.port <= 65535
}
/**
* 严格解析一条候选消息。
*
* ⛔ **不宽容**:任何形状问题都返回 `undefined`(调用方转成 `bad-shape` **并计数**),
* ⛔ 不补默认值、⛔ 不做类型强转(`"80"` 不是 `80`)。
*/
export function parseDirectMessage(raw: string): DirectCandidateMessage | undefined {
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return undefined
}
return normalizeDirectMessage(parsed)
}
/** 与 {@link parseDirectMessage} 同一口径,但收**已解析**的对象(信封解出来之后的入口)。 */
export function normalizeDirectMessage(parsed: unknown): DirectCandidateMessage | undefined {
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
const r = parsed as Record<string, unknown>
if (r.kind !== DIRECT_MESSAGE_KIND) return undefined
const hostId = typeof r.hostId === 'string' ? r.hostId : ''
const network = typeof r.network === 'string' ? r.network : ''
if (!isHostId(hostId) || !isNetworkId(network)) return undefined
if (!Array.isArray(r.addrs) || r.addrs.length === 0) return undefined
const addrs: DirectAddress[] = []
for (const item of r.addrs) {
if (item === null || typeof item !== 'object') return undefined
const a = item as Record<string, unknown>
if (typeof a.host !== 'string' || typeof a.port !== 'number') return undefined
addrs.push({ host: a.host, port: a.port })
}
const ts = typeof r.ts === 'number' && Number.isFinite(r.ts) ? r.ts : 0
return { kind: DIRECT_MESSAGE_KIND, hostId, network, addrs, ts }
}
/**
* 编码一条候选消息(**唯一构造点**)。
*
* ⛔ 先过一遍 {@link findSecretField}:构造侧就不许把身份材料塞进来(失败早于上线)。
*/
export function encodeDirectMessage(msg: {
hostId: string
network: string
addrs: DirectAddress[]
ts?: number
}): string {
const out: DirectCandidateMessage = {
kind: DIRECT_MESSAGE_KIND,
hostId: msg.hostId,
network: msg.network,
addrs: msg.addrs.map((a) => ({ host: a.host, port: a.port })),
ts: msg.ts ?? Date.now(),
}
const secret = findSecretField(out)
if (secret !== undefined) throw new Error(`候选载荷里出现疑似凭据字段 ${secret}(⛔ 候选只含地址与端口)`)
const back = normalizeDirectMessage(out)
if (back === undefined) throw new Error('候选消息形状非法(⛔ 不许把坏形状编出去)')
return JSON.stringify(out)
}
/** 一条候选的完整上下文(判定所需的一切**都是显式传入的** ⇒ 单测无需 mock 判据)。 */
export interface CandidateContext {
/** 归一化后的拨号方白名单(`network → hostId 集合`)—— 与 `server.ts` 的 `dialers` **同型同源**。 */
dialers: ReadonlyMap<string, ReadonlySet<string>>
/** 收到这条消息的会话所属的网 + 对端 hostId(由 relay 侧鉴权后的会话表给出,⛔ 不信载荷)。 */
from: { network: string; hostId: string }
/** 本机在这张网里的 hostId。 */
selfHostId: string
/** 地址条数上限(缺省 {@link DIRECT_CAND_MAX_ADDRS},权威值在参数表)。 */
maxAddrs?: number
/** 新鲜期(缺省 {@link DIRECT_CAND_TTL_MS})。 */
ttlMs?: number
now?: number
}
/**
* **准入判定**(判据 `D1`)。
*
* 顺序刻意如此(**先安全后形状**会掩盖错配:"跨网"的证据在 `from.network` 与载荷的对比里):
* ① 形状(`bad-shape` / `secret-field`)
* ② 网一致(`cross-network`)—— 🔴 安全:⛔ 绝不让一张网里的会话申报另一张网的落点
* ③ **只准申报自己**(`not-self-candidate`)—— 🔴 安全:⛔ 不许替第三人申报地址
* ④ 双向白名单(`not-a-dialer` / `self-not-a-dialer`)—— **复用** `isAllowedDialer`
* ⑤ 地址形状与条数(`bad-address` / `too-many-addrs`)
* ⑥ 新鲜期(`stale`)
*/
export function admitCandidate(raw: string, ctx: CandidateContext): CandidateVerdict {
const maxAddrs = ctx.maxAddrs ?? DIRECT_CAND_MAX_ADDRS
const ttlMs = ctx.ttlMs ?? DIRECT_CAND_TTL_MS
const now = ctx.now ?? Date.now()
// ① 形状(含凭据字段扫描 —— 扫描在**归一之前**,坏形状也不能夹带)
let json: unknown
try {
json = JSON.parse(raw)
} catch (err) {
return { ok: false, reason: 'bad-shape', detail: `JSON 解析失败:${err instanceof Error ? err.message : String(err)}` }
}
const secret = findSecretField(json)
if (secret !== undefined) {
return { ok: false, reason: 'secret-field', detail: `载荷含疑似凭据字段 ${secret}(⛔ 候选只准含地址与端口)` }
}
const msg = normalizeDirectMessage(json)
if (msg === undefined) {
return { ok: false, reason: 'bad-shape', detail: `不是 ${DIRECT_MESSAGE_KIND} 形态(须含 hostId / network / addrs[非空]{host,port})` }
}
// ② 网一致(跨网 ⇒ 结构性拒绝)
if (msg.network !== ctx.from.network) {
return {
ok: false,
reason: 'cross-network',
detail: `载荷申报的网 "${msg.network}" ≠ 会话所属的网 "${ctx.from.network}"`,
}
}
// ③ 只准申报自己(⛔ 替第三人申报 = 冒名,会变成"借别人的手把流量引到目标")
if (msg.hostId !== ctx.from.hostId) {
return {
ok: false,
reason: 'not-self-candidate',
detail: `载荷申报 ${logicalName(msg.network, msg.hostId)},而会话持有的身份是 ${logicalName(ctx.from.network, ctx.from.hostId)}(⛔ 只准申报自己)`,
}
}
// ④ 双向白名单(**复用** network.ts#isAllowedDialer —— ⛔ 不另写一份)
if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.from.hostId)) {
return {
ok: false,
reason: 'not-a-dialer',
detail: `对端 ${logicalName(ctx.from.network, ctx.from.hostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里(默认拒绝)`,
}
}
if (!isAllowedDialer(ctx.dialers, ctx.from.network, ctx.selfHostId)) {
return {
ok: false,
reason: 'self-not-a-dialer',
detail: `本机 ${logicalName(ctx.from.network, ctx.selfHostId)} 不在网 "${ctx.from.network}" 的拨号方白名单里 ⇒ 建不成直连`,
}
}
// ⑤ 地址形状与条数
if (msg.addrs.length > maxAddrs) {
return { ok: false, reason: 'too-many-addrs', detail: `候选地址 ${msg.addrs.length} 条 > 上限 ${maxAddrs}` }
}
for (const a of msg.addrs) {
if (!isValidAddress(a)) {
return { ok: false, reason: 'bad-address', detail: `非法候选地址 ${JSON.stringify(a)}(须 IPv4/IPv6 字面量 + 端口 1–65535)` }
}
}
// ⑥ 新鲜期(`ts = 0` ⇒ 未标注 ⇒ 不判 —— ⛔ 但也不当"新鲜"混过去:detail 里写明)
if (msg.ts > 0 && ttlMs > 0 && now - msg.ts > ttlMs) {
return { ok: false, reason: 'stale', detail: `候选已过期 ${now - msg.ts} ms > 新鲜期 ${ttlMs} ms` }
}
return { ok: true, message: msg }
}
/** 计数的快照(观测面与探针都取这一份)。 */
export interface CandidateLedgerSnapshot {
/** 收到的消息总数(**进过判定**的)。 */
received: number
accepted: number
rejected: { reason: CandidateReason; detail: string }[]
/**
* 🔴 **静默拒绝数**(本线老病根的可断言面)。
*
* 恒等式 = `received - accepted - rejected.length`;**按构造它必须是 0** —— 它不是"统计",
* 而是一道**不变量守卫**:将来谁加了一条 `return {ok:false}` 却忘了记账,它立刻非零。
* ⛔ 探针见到非零 ⇒ **FAIL 并点名**(静默返空 = 判据全绿而功能全废)。
*/
silentRejections: number
/** 拒绝原因的分布(`reason → 条数`,便于一眼看出"全是同一类")。 */
byReason: Record<string, number>
}
/**
* 候选收发的记账器。
*
* ⚠️ **每条进路都必须记账**:`receive()` 先记"收到",之后要么 `accept()` 要么 `reject()`。
* 判据看 {@link CandidateLedgerSnapshot.silentRejections} 是否为零。
*/
export class CandidateLedger {
private received = 0
private accepted = 0
private rejected: { reason: CandidateReason; detail: string }[] = []
/** 收到一条(**必须在判定之前**调用)。 */
receive(): void {
this.received += 1
}
/** 接受一条。 */
accept(): void {
this.accepted += 1
}
/** 拒绝一条(**必须带原因**,⛔ 不许只计数不写原因)。 */
reject(reason: CandidateReason, detail: string): void {
this.rejected.push({ reason, detail })
}
/**
* 判定 + 记账的**唯一入口**(调用方不需要记得先 `receive()`)。
*
* ⇒ "忘了记账"这件事在**接口层**就已经不可能 —— 这是比"靠自觉"更强的形态。
*/
judge(raw: string, ctx: CandidateContext): CandidateVerdict {
this.receive()
const verdict = admitCandidate(raw, ctx)
if (verdict.ok) this.accept()
else this.reject(verdict.reason, verdict.detail)
return verdict
}
snapshot(): CandidateLedgerSnapshot {
const byReason: Record<string, number> = {}
for (const r of this.rejected) byReason[r.reason] = (byReason[r.reason] ?? 0) + 1
return {
received: this.received,
accepted: this.accepted,
rejected: this.rejected.map((r) => ({ ...r })),
silentRejections: this.received - this.accepted - this.rejected.length,
byReason,
}
}
}
+356
View File
@@ -0,0 +1,356 @@
/**
* 覆盖网络 **直连(打洞)总装配**(序㊵ · P2 · S5)—— 开关 + 默认值 + 提示 + 降级编排。
*
* ## 用户口径(原话,⛔ 别改写成技术题)
* ```
* 1 用户可设置,默认开启提示用户 2 乙
* ```
* ⇒ 本模块是那三件事的**唯一落点**:
*
* | # | 口径 | 落在这里的什么 |
* |---|---|---|
* | ① | **用户可设置** | {@link resolveDirectSwitch}:env(运维强制)> 节点本地配置(用户设置)> **缺省**;{@link writeNodeConfigDirect} 是"用户改"的写入口 |
* | ② | **默认开启** | {@link DEFAULT_DIRECT_ENABLED} = `true`(⛔ 不是"缺省关、用户主动开") |
* | ③ | **提示用户** | {@link DIRECT_HINT_PARTS}(三段:**谁会连进来 / 怎么关 / 关掉不影响什么**) |
*
* ## 🔴 开关的三种状态,⛔ 没有第四种
* `enabled: true` | `enabled: false` | `enabled: null`(**取值非法** —— ⛔ **不许**静默当开、
* ⛔ 也不许静默当关;调用方必须**具名报错**)。本线所有"配置错长得像网络不通"的事故,
* 第一步都是"给个缺省值糊过去"。
*
* ## 降级编排(关闭 / 打洞失败 ⇒ 全部回落中继)
* ⛔ **关闭不是功能降级**:P1 已交付的「一键加入 + 分组准入」照旧完整可用,只是跨机流量走中继。
* 回落必须是**具名**的(`disabled` / `cooldown` / `no-address` / `one-way` / `deadline`),
* ⛔ 不许出现"没有原因地走了中继"。
*
* @module dshs/net/relay/direct
*/
import {
CandidateLedger,
DIRECT_CAND_MAX_ADDRS,
DIRECT_CAND_TTL_MS,
type CandidateContext,
type CandidateLedgerSnapshot,
type CandidateReason,
type CandidateVerdict,
type DirectAddress,
} from './candidate.js'
import {
DEFAULT_DIRECT_COOLDOWN_MS,
DEFAULT_PUNCH_DEADLINE_MS,
DirectCooldown,
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
pickPunchPort,
runPunchAttempt,
type PunchAttempt,
type PunchReason,
} from './punch.js'
/** 总开关的 env 键名(用户口径①)。⚠️ **缺省即可用**(缺省 = 开)⇒ 不写进任何 drop-in 也生效。 */
export const DIRECT_ENV_KEY = 'DSHS_OVERLAY_DIRECT'
/** 用户口径②:**默认开启**。⛔ 改它 = 改用户拍过的口径。 */
export const DEFAULT_DIRECT_ENABLED = true
/** 显式**开**的取值(大小写不敏感)。 */
export const DIRECT_ON_VALUES = ['1', 'true', 'on', 'yes'] as const
/** 显式**关**的取值(大小写不敏感)。 */
export const DIRECT_OFF_VALUES = ['0', 'false', 'off', 'no'] as const
/** 节点本地配置的缺省落点(与 `join.ts` 的 `--config` 缺省一致;⛔ 两处必须是同一个值)。 */
export const NODE_CONFIG_FILE_DEFAULT = '/etc/dshs/overlay-node.json'
/** 开关解析结果。 */
export interface DirectSwitchState {
/** 键名(便于日志与观测面**原样**说出"是哪个键在起作用")。 */
envKey: string
/** env 原文(未设 ⇒ `null`)。 */
raw: string | null
/** 生效来源 —— ⛔ 三态必须能分开(否则"为什么它是开的"永远说不清)。 */
source: 'env' | 'node-config' | 'default'
/** `null` = **取值非法**(⛔ 不许当开也不许当关)。 */
enabled: boolean | null
/** 非法时的人读原因(`enabled !== null` 时为空串)。 */
invalid: string
/** 缺省值(便于观测面自证"缺省 = 开")。 */
defaultEnabled: boolean
}
/**
* 解析总开关(**唯一解析点**)。
*
* 优先级:env(显式设了就以它为准)> 节点本地配置的 `direct` > **缺省 = 开**。
* ⚠️ 为什么 env 优先:它是**运维通道**("这台机器强制不直连"必须能压过用户设置);
* 用户改的是**本地配置**({@link writeNodeConfigDirect})—— 两条路各自有明确的主人。
*/
export function resolveDirectSwitch(
env: Readonly<Record<string, string | undefined>>,
opts: { localDirect?: boolean; envKey?: string } = {},
): DirectSwitchState {
const envKey = opts.envKey ?? DIRECT_ENV_KEY
const defaultEnabled = DEFAULT_DIRECT_ENABLED
const raw = env[envKey]
const base = { envKey, defaultEnabled }
if (raw !== undefined && raw.trim() !== '') {
const v = raw.trim().toLowerCase()
if ((DIRECT_ON_VALUES as readonly string[]).includes(v)) {
return { ...base, raw: raw.trim(), source: 'env', enabled: true, invalid: '' }
}
if ((DIRECT_OFF_VALUES as readonly string[]).includes(v)) {
return { ...base, raw: raw.trim(), source: 'env', enabled: false, invalid: '' }
}
return {
...base,
raw: raw.trim(),
source: 'env',
enabled: null,
invalid: `${envKey}=${JSON.stringify(raw.trim())} 既不在开集 ${DIRECT_ON_VALUES.join('/')} 也不在关集 ${DIRECT_OFF_VALUES.join('/')} ⇒ ⛔ 不静默取缺省`,
}
}
if (typeof opts.localDirect === 'boolean') {
return { ...base, raw: null, source: 'node-config', enabled: opts.localDirect, invalid: '' }
}
return { ...base, raw: null, source: 'default', enabled: defaultEnabled, invalid: '' }
}
/** 提示文案的三段(**顺序即语义**,⛔ 别合并成一段 —— 探针按段数判)。 */
export const DIRECT_HINT_PARTS = [
`① 开启后,**谁**可能直连到这台机器:只有**同一张覆盖网里、且双向都在拨号白名单**里的节点;⛔ 公网任意源、⛔ 跨网节点都连不进来。`,
`② 怎么关:给本机设 ${DIRECT_ENV_KEY}=0(或 false/off/no)后重启节点服务;也可以在管理面把本机配置里的 direct 改成 false。关闭后**不再绑定任何 UDP 端口**、也**不再发送直连候选**。`,
`③ 关掉会影响什么:⛔ 不影响接入与准入 —— 一键加入、分组白名单、跨机取块**照旧可用**,跨机流量改走中继;代价只是"多一跳中继"(延迟与中继带宽略升)。`,
] as const
/** 三段提示(数组形态,便于管理面渲染)。 */
export function directHintLines(): string[] {
return [...DIRECT_HINT_PARTS]
}
/** 三段提示(一行文本形态,供 CLI `stdout` 打印)。 */
export function directHintText(): string {
return DIRECT_HINT_PARTS.join('\n')
}
/** 降级/拒绝原因的**统一口径**(⛔ 不许出现"没有原因地走了中继")。 */
export type DirectRefusal = 'disabled' | 'invalid-switch' | PunchReason | CandidateReason
/** 直连总装(**只读观测面 + 编排**;⛔ 它不做传输、不做接线 —— 那是 S6)。 */
export class DirectPath {
readonly ledger = new CandidateLedger()
readonly cooldown: DirectCooldown
private readonly switchState: DirectSwitchState
private readonly portBase: number
private readonly portSpan: number
private readonly deadlineMs: number
private readonly maxAddrs: number
private readonly ttlMs: number
private udpSocketsOpened = 0
private candidatesEmitted = 0
private punchOk = 0
private punchDead = 0
private last?: PunchAttempt
constructor(opts: {
/** 开关解析结果(**必传** —— 避免"忘了解析就用上真值")。 */
switchState: DirectSwitchState
cooldownMs?: number
portBase?: number
portSpan?: number
deadlineMs?: number
maxAddrs?: number
ttlMs?: number
}) {
this.switchState = opts.switchState
this.cooldown = new DirectCooldown(opts.cooldownMs ?? DEFAULT_DIRECT_COOLDOWN_MS)
this.portBase = opts.portBase ?? PUNCH_PORT_BASE
this.portSpan = opts.portSpan ?? PUNCH_PORT_SPAN
this.deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS
this.maxAddrs = opts.maxAddrs ?? DIRECT_CAND_MAX_ADDRS
this.ttlMs = opts.ttlMs ?? DIRECT_CAND_TTL_MS
}
/** 生效值:`true` / `false` / `null`(非法)。⛔ 调用方**必须**处理 `null`。 */
get enabled(): boolean | null {
return this.switchState.enabled
}
/** 候选准入(**关闭 ⇒ 一条都不判、一条都不发**)。 */
offerCandidate(raw: string, ctx: Omit<CandidateContext, 'maxAddrs' | 'ttlMs'>): CandidateVerdict | { ok: false; reason: DirectRefusal; detail: string } {
if (this.switchState.enabled !== true) {
return {
ok: false,
reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch',
detail:
this.switchState.enabled === false
? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不发候选、⛔ 不开 UDP 口`
: this.switchState.invalid,
}
}
this.candidatesEmitted += 1
return this.ledger.judge(raw, { ...ctx, maxAddrs: this.maxAddrs, ttlMs: this.ttlMs })
}
/**
* 尝试建立直连(**关闭 ⇒ 零 socket**)。
*
* ⛔ 这里**只做探测**:成功与否都不改变"能不能用中继"这件事(回落是无条件的)。
*/
async attempt(
peer: string,
targets: readonly DirectAddress[],
io: { sleep: (ms: number) => Promise<void> },
offset = 0,
): Promise<PunchAttempt | { ok: false; reason: DirectRefusal; detail: string }> {
if (this.switchState.enabled !== true) {
return {
ok: false,
reason: this.switchState.enabled === false ? 'disabled' : 'invalid-switch',
detail:
this.switchState.enabled === false
? `${DIRECT_ENV_KEY} 关闭(来源 ${this.switchState.source})⇒ ⛔ 不打洞、⛔ 零 UDP socket,直接用中继`
: this.switchState.invalid,
}
}
const r = await runPunchAttempt(
{
peer,
selfPort: pickPunchPort(this.portBase, this.portSpan, offset),
targets,
deadlineMs: this.deadlineMs,
cooldown: this.cooldown,
onSocketOpen: () => {
this.udpSocketsOpened += 1
},
},
io,
)
this.last = r
if (r.ok) this.punchOk += 1
else if (r.reason !== 'cooldown' && r.reason !== 'no-address') this.punchDead += 1
return r
}
/** **只读观测面**(探针 / 管理面 / 日志共用这一份 —— ⛔ 别各写一套)。 */
status(): {
envKey: string
enabled: boolean | null
defaultEnabled: boolean
source: DirectSwitchState['source']
raw: string | null
invalid: string
hint: string[]
portBase: number
portSpan: number
deadlineMs: number
maxAddrs: number
cooldownMs: number
counters: {
udpSocketsOpened: number
candidatesEmitted: number
punchOk: number
punchDead: number
cooldownBlocked: number
candidateSilentRejections: number
}
ledger: CandidateLedgerSnapshot
last: PunchAttempt | null
} {
const snap = this.cooldown.snapshot()
const led = this.ledger.snapshot()
return {
envKey: this.switchState.envKey,
enabled: this.switchState.enabled,
defaultEnabled: this.switchState.defaultEnabled,
source: this.switchState.source,
raw: this.switchState.raw,
invalid: this.switchState.invalid,
hint: directHintLines(),
portBase: this.portBase,
portSpan: this.portSpan,
deadlineMs: this.deadlineMs,
maxAddrs: this.maxAddrs,
cooldownMs: snap.ms,
counters: {
udpSocketsOpened: this.udpSocketsOpened,
candidatesEmitted: this.candidatesEmitted,
punchOk: this.punchOk,
punchDead: this.punchDead,
cooldownBlocked: snap.blocked,
candidateSilentRejections: led.silentRejections,
},
ledger: led,
last: this.last ?? null,
}
}
}
/** 从节点本地配置里读 `direct`(`undefined` = 没写这一项 ⇒ 走缺省)。 */
export function directFromNodeConfig(raw: unknown): boolean | undefined {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const v = (raw as Record<string, unknown>).direct
return typeof v === 'boolean' ? v : undefined
}
/** 读一读节点本地配置(**形状不对 ⇒ 抛**;文件不存在 ⇒ `undefined`)。 */
export function readNodeConfig(file: string, io: { exists: (p: string) => boolean; read: (p: string) => string }): Record<string, unknown> | undefined {
if (!io.exists(file)) return undefined
const parsed: unknown = JSON.parse(io.read(file))
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${file} 不是 JSON 对象(⛔ 不当作"没配置"静默放过)`)
}
return parsed as Record<string, unknown>
}
/** 写回结果(**失败必须具名** —— 与 `join.ts` 同一条纪律)。 */
export type NodeConfigWriteOutcome =
| { ok: true; file: string; direct: boolean }
| { ok: false; reason: 'node-config-missing' | 'node-config-bad' | 'node-config-unwritable'; detail: string }
/**
* 把 `direct` 写进节点本地配置(**用户口径①「用户可设置」的写入口**)。
*
* 🔴 三条纪律:
* ① **只改 `direct` 一个字段**(其余字段原样保留 —— ⛔ 不做"顺手规范化");
* ② **文件不存在 ⇒ 具名失败**(`node-config-missing`)—— ⛔ 不静默创建:本地配置的存在本身就意味着
* "这台机器跑过 join",凭空造一个会让节点看起来"加入过";
* ③ **原子写 + `0600`**(先写 `<file>.tmp` 再 `rename`)—— 半截文件会让节点**起不来**。
*/
export function writeNodeConfigDirect(
file: string,
enabled: boolean,
io: {
exists: (p: string) => boolean
read: (p: string) => string
write: (p: string, text: string, mode: number) => void
rename: (from: string, to: string) => void
},
): NodeConfigWriteOutcome {
let current: Record<string, unknown> | undefined
try {
current = readNodeConfig(file, io)
} catch (err) {
return { ok: false, reason: 'node-config-bad', detail: `${file}:${err instanceof Error ? err.message : String(err)}` }
}
if (current === undefined) {
return {
ok: false,
reason: 'node-config-missing',
detail: `${file} 不存在 ⇒ ⛔ 不凭空创建(本机还没跑过 join)`,
}
}
const next = { ...current, direct: enabled }
const tmp = `${file}.tmp`
try {
io.write(tmp, `${JSON.stringify(next, null, 2)}\n`, 0o600)
io.rename(tmp, file)
} catch (err) {
return {
ok: false,
reason: 'node-config-unwritable',
detail: `${file}:${err instanceof Error ? err.message : String(err)}`,
}
}
return { ok: true, file, direct: enabled }
}
+586
View File
@@ -0,0 +1,586 @@
/**
* 覆盖网络 **UDP 打洞探测**(序㊵ · P2 · S5)。
*
* ## 它做什么(照抄成熟做法的最小集,⛔ 不发明)
* ① 本机绑一个 **UDP 口**(端口从**参数表区间**取:`PUNCH_PORT_BASE` + `PUNCH_PORT_SPAN`);
* ② **双方同时**向对方候选地址发包(间隔 {@link PUNCH_PROBE_INTERVAL_MS},持续到 deadline);
* ③ `PUNCH_DEADLINE_MS` 内**收到对方的包** ⇒ 该方向成立;
* ④ 🔴 **双向都成立**才判直连可用;否则 ⇒ **判死**(`one-way` / `deadline`);
* ⑤ 判死后:**立即降级中继** + 该候选进**冷却**(复用本线既有冷却纪律,⛔ 不自造一套)。
*
* ## 🔴 三条纪律
* | # | 纪律 | 为什么 |
* |---|---|---|
* | ① | 只用 Node 内建 `dgram`,⛔ **不引入新依赖** | 本仓既有口径:`addr-override.ts` 头注「本项目**不引入 `undici` / `ws`**」 |
* | ② | ⛔ **用户态**、⛔ 无内核驱动、⛔ 无虚拟网卡 | 参数表 §8-① 已把"虚拟网卡"收窄为**不做**;要打洞也只走用户态 UDP |
* | ③ | 冷却**必须非零**(`ms <= 0` ⇒ 构造即抛) | `COOLDOWN=0` ⇒ "判死"退化成**重试风暴**;本线已把 `RELAY_FAILOVER_COOLDOWN_MS=0` 写成硬禁令 |
*
* ## 🧪 离线夹具(文件末段 · ⛔ 不进生产接线路径)
* 打洞的**成功路径**需要"两台机器 + 两个会做 NAT 映射的网络"。云上那条真机腿被**云安全组**挡着
* (见 `交接单_覆盖网络直连与P2P_20260918.md` §2-3),⇒ 本模块自带一个
* **NAT 模拟器**({@link SimulatedNat} + {@link runPunchPair}):它用**真的 `dgram` socket**
* 跑**同一份打洞代码**,只是把"两跳 NAT"用回环上的四个 socket + 两份映射表来表现。
* ⇒ 机器断言证明的是"**这段打洞逻辑**在 NAT 穿透场景下成立",⛔ **不是**"公网一定能打洞"。
*
* @module dshs/net/relay/direct/punch
*/
import { createSocket, type Socket } from 'node:dgram'
import { isIpv6, type DirectAddress } from './candidate.js'
/** 打洞 socket 的**端口基址**(沿用序⑥ S4 观察器用过的 21100 段 ⇒ ⛔ 不新造魔数)。 */
export const PUNCH_PORT_BASE = 21100
/** 打洞端口区间跨度 ⇒ `[21100, 21116)`。⚠️ **UDP** 口,与 `LISTEN_ALLOWED_RANGES`(TCP)**不同族**。 */
export const PUNCH_PORT_SPAN = 16
/** 单次探测的**重发间隔**(双方同时发包,直到 deadline)。 */
export const PUNCH_PROBE_INTERVAL_MS = 150
/** 收包窗口缺省值(真值在参数表 `PUNCH_DEADLINE_MS`)。 */
export const DEFAULT_PUNCH_DEADLINE_MS = 3_000
/** 判死后的冷却缺省值(真值在参数表 `DIRECT_COOLDOWN_MS`)。🔴 **必须非零**。 */
export const DEFAULT_DIRECT_COOLDOWN_MS = 300_000
/** 探测结论(**具名**,⛔ 不收自由文本)。 */
export type PunchReason =
/** 双向都成立 ⇒ 直连可用。 */
| 'ok'
/** 一个方向成立、另一个不成立 ⇒ **判死**(⛔ 单向不算直连)。 */
| 'one-way'
/** 窗内一个包都没收到 ⇒ **判死**。 */
| 'deadline'
/** 该对端在冷却里 ⇒ 本次**连 socket 都不开**。 */
| 'cooldown'
/** 没有可用候选地址(⛔ 不是"打洞失败"⇒ ⛔ 不进冷却)。 */
| 'no-address'
/** 绑定失败(具名带上原始错误)。 */
| 'socket-error'
/** 一次探测的完整读数(**成功与失败都出这一份** ⇒ 观测面不用猜)。 */
export interface PunchAttempt {
/** 对端逻辑名(`<network>/<hostId>`,冷却按它做键)。 */
peer: string
ok: boolean
reason: PunchReason
/** 本机收到的包数(**本方向**是否成立)。 */
recvLocal: number
/** 对端是否收到我们的包(真机经 relay 通道回报;离线夹具由 NAT 模拟给出)。 */
peerSeen: boolean
/** 🔴 双向都成立(`recvLocal > 0 && peerSeen`)—— **只有它为真才算直连可用**。 */
bidirectional: boolean
sent: number
elapsedMs: number
at: number
/** 回包来源(`ip:port`,升序)—— **"谁回的"必须看得见**(真机腿靠它区分"对端回的"与"别的什么东西回的")。 */
sources: string[]
/** 失败时的人读原因(⛔ 不许只回 `false`)。 */
detail: string
}
/**
* 候选地址的**冷却表**(判死后才写入)。
*
* 🔴 `ms` **必须 > 0**:`0` 会让"判死"退化成"每次重试都真打一遍" = 重试风暴
* (本线把 `RELAY_FAILOVER_COOLDOWN_MS=0` 列为硬禁令,同一条纪律在这里落地为**构造期断言**)。
*/
export class DirectCooldown {
readonly ms: number
private until = new Map<string, number>()
private blockedCount = 0
constructor(ms: number = DEFAULT_DIRECT_COOLDOWN_MS) {
if (!Number.isFinite(ms) || ms <= 0) {
throw new Error(
`直连冷却时长必须 > 0,收到 ${JSON.stringify(ms)}(🔴 0 ⇒ 判死退化成重试风暴,本线硬禁令)`,
)
}
this.ms = Math.floor(ms)
}
/** 该对端是否还在冷却里(**命中即计数** —— 观测面上要看得见"被挡了几次")。 */
blocked(peer: string, now: number = Date.now()): boolean {
const until = this.until.get(peer)
if (until === undefined) return false
if (until <= now) {
this.until.delete(peer)
return false
}
this.blockedCount += 1
return true
}
/** 记一次判死 ⇒ 进入冷却。 */
noteDead(peer: string, now: number = Date.now()): void {
this.until.set(peer, now + this.ms)
}
/** 成功 ⇒ 撤销冷却(**只有成功才撤**,⛔ 别拿"尝试过"当成功)。 */
clear(peer: string): void {
this.until.delete(peer)
}
snapshot(now: number = Date.now()): { ms: number; blocked: number; cooling: string[] } {
const cooling: string[] = []
for (const [peer, until] of this.until) if (until > now) cooling.push(peer)
return { ms: this.ms, blocked: this.blockedCount, cooling: cooling.sort() }
}
}
/** 一个**已绑定的** UDP 探测口(收发包 + 计数)。⛔ 只做收发,判定在上层。 */
export class PunchSocket {
private sock?: Socket
private packets = 0
private readonly senders = new Set<string>()
private lastError = ''
/** 已成功绑定的端口(`0` = 尚未绑定)。 */
port = 0
constructor(private readonly host = '0.0.0.0') {}
/** 绑定(`port = 0` ⇒ 由内核分配;真机路径给参数表区间里的口)。 */
open(port: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
const family = isIpv6(this.host) ? 'udp6' : 'udp4'
const sock = createSocket({ type: family, reuseAddr: false })
const fail = (err: Error): void => {
this.lastError = err.message
try {
sock.close()
} catch {
// 已经关掉了 ⇒ 无事可做(⛔ 但**不吞**:原始错误在 `lastError` 里)
}
reject(err)
}
sock.once('error', fail)
sock.on('message', (_msg, rinfo) => {
this.packets += 1
this.senders.add(`${rinfo.address}:${rinfo.port}`)
})
sock.bind({ port, address: this.host }, () => {
sock.off('error', fail)
// 绑定之后的错误(如 ICMP 端口不可达)只记账,⛔ 不让它把进程炸掉
sock.on('error', (err) => {
this.lastError = err.message
})
const addr = sock.address()
this.port = typeof addr === 'object' && addr !== null ? addr.port : 0
this.sock = sock
resolve()
})
})
}
/** 向若干候选地址各发一包。返回**实际发出**的包数。 */
send(targets: readonly DirectAddress[], payload = 'dshs-punch'): number {
const sock = this.sock
if (sock === undefined) return 0
let sent = 0
for (const t of targets) {
try {
sock.send(Buffer.from(payload, 'utf8'), t.port, t.host)
sent += 1
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err)
}
}
return sent
}
/** 本机收到的包数(截至此刻)。 */
received(): number {
return this.packets
}
/** 收到过包的对端来源(`ip:port`,升序)。 */
sources(): string[] {
return [...this.senders].sort()
}
errorText(): string {
return this.lastError
}
close(): void {
const sock = this.sock
this.sock = undefined
if (sock === undefined) return
try {
sock.close()
} catch {
// 关两次 ⇒ 忽略;本类是**唯一**关闭点,正常路径不会走到这
}
}
}
/** 探测入参。⛔ 所有阈值**都是显式传入的**(真值在参数表,模块里只有缺省)。 */
export interface PunchOptions {
/** 对端逻辑名(冷却键)。 */
peer: string
/** 本机要绑的 UDP 口(来自 {@link pickPunchPort};`0` = 内核分配,夹具用)。 */
selfPort: number
/** 对端候选地址(**打洞的目标**)。 */
targets: readonly DirectAddress[]
deadlineMs?: number
probeIntervalMs?: number
/** 冷却表(**必传** —— 冷却纪律是判死的一部分,⛔ 不许可选)。 */
cooldown: DirectCooldown
/**
* 对端是否收到我们的包(真机 = 经 relay 通道回报;离线夹具 = NAT 模拟器给出)。
* ⛔ 缺省 = `() => false`(= 只看见自己的方向 ⇒ 判 `one-way`)。**刻意不给"乐观缺省"**:
* 拿不到对端证据时**判不上直连**,而不是"先当成功"。
*/
peerSeen?: () => boolean
/** 本机绑定地址(缺省 `0.0.0.0`;离线夹具用 `127.0.0.1`)。 */
bindHost?: string
now?: number
/** 观测钩子:每绑一次 UDP socket 调一次(探针的"关闭 ⇒ 零 socket"靠它计数)。 */
onSocketOpen?: () => void
/** 测试钩子:拿到刚绑好的 socket(离线夹具用它读**对端**的实时收包数)。 */
onSocket?: (sock: PunchSocket) => void
}
/**
* 跑一次打洞探测(**唯一执行点**)。
*
* 顺序:冷却闸门 → 候选闸门 → 绑口 → 重发到 deadline(**每轮都重判双向**)→ 判定 → 判死/撤冷却
* → **必定关口**。⛔ 提前返回的两条路径(冷却 / 无候选)**一个 socket 都不开**。
*/
export async function runPunchAttempt(
opts: PunchOptions,
io: { sleep: (ms: number) => Promise<void> },
): Promise<PunchAttempt> {
const now = opts.now ?? Date.now()
const deadlineMs = opts.deadlineMs ?? DEFAULT_PUNCH_DEADLINE_MS
const interval = opts.probeIntervalMs ?? PUNCH_PROBE_INTERVAL_MS
const base: Omit<PunchAttempt, 'ok' | 'reason' | 'detail'> = {
peer: opts.peer,
recvLocal: 0,
peerSeen: false,
bidirectional: false,
sent: 0,
elapsedMs: 0,
at: now,
sources: [],
}
// ① 冷却闸门(⛔ 连 socket 都不开)
if (opts.cooldown.blocked(opts.peer, now)) {
return { ...base, ok: false, reason: 'cooldown', detail: `在冷却中(${opts.cooldown.ms} ms)⇒ 本次不打,沿用中继` }
}
// ② 候选闸门(⛔ 不是"打洞失败" ⇒ 不进冷却)
if (opts.targets.length === 0) {
return { ...base, ok: false, reason: 'no-address', detail: '没有可用候选地址 ⇒ 不打洞(⛔ 不进冷却)' }
}
const sock = new PunchSocket(opts.bindHost ?? '0.0.0.0')
const started = Date.now()
try {
await sock.open(opts.selfPort)
opts.onSocketOpen?.()
opts.onSocket?.(sock)
} catch (err) {
return {
...base,
ok: false,
reason: 'socket-error',
detail: `绑定 UDP ${opts.selfPort} 失败:${err instanceof Error ? err.message : String(err)}`,
elapsedMs: Date.now() - started,
}
}
const peerSeenOf = opts.peerSeen ?? ((): boolean => false)
try {
// ③ 双方同时发包:按 interval 重发,每轮**重判双向**(任一方向一旦成立就可以停了)
let sent = 0
const deadlineAt = started + deadlineMs
let recvLocal = 0
let peerSeen = false
for (;;) {
sent += sock.send(opts.targets)
recvLocal = sock.received()
peerSeen = peerSeenOf()
if (recvLocal > 0 && peerSeen) break
const remain = deadlineAt - Date.now()
if (remain <= 0) break
await io.sleep(Math.min(interval, remain))
}
recvLocal = sock.received()
peerSeen = peerSeenOf()
const bidirectional = recvLocal > 0 && peerSeen
const elapsedMs = Date.now() - started
if (bidirectional) {
opts.cooldown.clear(opts.peer)
return {
...base,
ok: true,
reason: 'ok',
recvLocal,
peerSeen,
bidirectional: true,
sent,
elapsedMs,
sources: sock.sources(),
detail: `直连成立:本方向收 ${recvLocal} 包 + 对端确认收到 ⇒ 双向 ✅(耗时 ${elapsedMs} ms,发 ${sent} 包)`,
}
}
const reason: PunchReason = recvLocal === 0 && !peerSeen ? 'deadline' : 'one-way'
opts.cooldown.noteDead(opts.peer, started)
return {
...base,
ok: false,
reason,
recvLocal,
peerSeen,
bidirectional: false,
sent,
elapsedMs,
sources: sock.sources(),
detail:
reason === 'one-way'
? `单向(本方向收 ${recvLocal} 包 / 对端确认=${peerSeen})⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms`
: `窗内零收包(deadline ${deadlineMs} ms,实耗 ${elapsedMs} ms,发 ${sent} 包)⇒ 判死 + 进冷却 ${opts.cooldown.ms} ms`,
}
} finally {
sock.close()
}
}
/**
* 从参数表区间里挑一个口(**取模上扫**;真机路径用它给 `selfPort`)。
*
* ⛔ 不写死单个端口:两台 worker 可能各自打洞,踩同一口会互相干扰;
* 区间由 `PUNCH_PORT_BASE` / `PUNCH_PORT_SPAN` 给出。
*/
export function pickPunchPort(base: number = PUNCH_PORT_BASE, span: number = PUNCH_PORT_SPAN, offset = 0): number {
const n = Math.max(1, Math.floor(span))
return base + (Math.abs(Math.floor(offset)) % n)
}
/* ══════════════════════════════════════════════════════════════════════════════════════
* 🧪 离线夹具(⛔ **不进生产接线路径**)
*
* 打洞的**成功路径**在云上被安全组挡着,所以这一段的用途只有一个:
* **用真的 `dgram` socket 跑同一份打洞代码**,把"两跳 NAT + 双方同时发包 ⇒ 打穿"这件事
* 变成机器可断言的读数。⛔ 它**不改**打洞逻辑(`runPunchAttempt` 一字未动),只替换"网络长什么样"。
* ══════════════════════════════════════════════════════════════════════════════════════ */
/** NAT 模拟器的形态开关(每个开关对应一条真实的失败模式)。 */
export interface SimulatedNatOptions {
/**
* `true` ⇒ 即使有映射也**丢弃入向包**(模拟"对称 NAT / 单向不可达" ⇒ 只能出、不能进)。
* 用于产出 `one-way` 这条负腿。
*/
dropInbound?: boolean
}
/** NAT 模拟器的计数(观测面用 —— ⛔ 只读)。 */
export interface SimNatCounters {
fromNode: number
fromPeer: number
forwardedIn: number
droppedNoMapping: number
droppedByPolicy: number
}
/**
* 一台**模拟 NAT**(= 两个回环 UDP socket + 一份"出过向才放行入向"的映射表)。
*
* 语义(真 NAT 的**最小充分**语义):
* - 节点把包发给 {@link gateway}(内侧门牌 = `innerSocket` 的口);
* - NAT 见**内侧来的包** ⇒ 记映射({@link isMapped} 变真)+ 从 `outerSocket`(外侧门牌)转给对端 NAT;
* - NAT 收**对端 NAT 来的包** ⇒ **只有已建映射才**从内侧门牌交回节点(无映射 ⇒ **丢**,并计数)。
*
* 🔴 `dropInbound` 只影响第三条(= 单向不可达)。
*/
export class SimulatedNat {
private innerSock?: Socket
private outerSock?: Socket
private nodePort = 0
private mapped = false
private readonly dropInbound: boolean
readonly counters: SimNatCounters = {
fromNode: 0,
fromPeer: 0,
forwardedIn: 0,
droppedNoMapping: 0,
droppedByPolicy: 0,
}
/** 对端 NAT 的**外侧门牌**(两侧都建好之后回填;真网里靠候选交换得到)。 */
peerAddress?: DirectAddress
constructor(opts: SimulatedNatOptions = {}) {
this.dropInbound = opts.dropInbound === true
}
/** 绑定两个门牌并开始转发(返回后 {@link gateway} / {@link natAddress} 才是真值)。 */
async open(): Promise<void> {
this.outerSock = await this.bind()
this.innerSock = await this.bind()
this.outerSock.on('message', (msg, rinfo) => this.onOuter(msg, rinfo))
this.innerSock.on('message', (msg, rinfo) => this.onInner(msg, rinfo))
}
private bind(): Promise<Socket> {
return new Promise<Socket>((resolve, reject) => {
const sock = createSocket({ type: 'udp4', reuseAddr: false })
sock.once('error', reject)
sock.bind({ port: 0, address: '127.0.0.1' }, () => {
sock.off('error', reject)
sock.on('error', () => {
// 发送目标已关 ⇒ 夹具里的正常竞态,忽略
})
resolve(sock)
})
})
}
private portOf(sock: Socket): number {
const addr = sock.address()
return typeof addr === 'object' && addr !== null ? addr.port : 0
}
/** 内侧来的包(= 节点要出去)⇒ 建映射 + 转给对端 NAT。 */
private onInner(msg: Buffer, rinfo: { address: string; port: number }): void {
this.counters.fromNode += 1
this.nodePort = rinfo.port
this.mapped = true
const peer = this.peerAddress
if (peer === undefined || this.outerSock === undefined) return
try {
this.outerSock.send(msg, peer.port, peer.host)
} catch {
// 对端已关 ⇒ 忽略
}
}
/** 外侧来的包(= 对端打过来的)⇒ 只有已建映射才放行入向。 */
private onOuter(msg: Buffer, rinfo: { address: string; port: number }): void {
const peer = this.peerAddress
if (peer === undefined || rinfo.port !== peer.port) return
this.counters.fromPeer += 1
if (!this.mapped) {
this.counters.droppedNoMapping += 1
return
}
if (this.dropInbound) {
this.counters.droppedByPolicy += 1
return
}
if (this.nodePort === 0 || this.innerSock === undefined) return
try {
this.innerSock.send(msg, this.nodePort, '127.0.0.1')
this.counters.forwardedIn += 1
} catch {
// 节点已关 ⇒ 忽略
}
}
/** 节点侧要发往的地址(**内侧门牌** = 我的网关)。 */
get gateway(): DirectAddress {
return { host: '127.0.0.1', port: this.innerSock === undefined ? 0 : this.portOf(this.innerSock) }
}
/** 对外门牌(= **对端要打的目标**,也是本机"公网地址"的替身)。 */
get natAddress(): DirectAddress {
return { host: '127.0.0.1', port: this.outerSock === undefined ? 0 : this.portOf(this.outerSock) }
}
get isMapped(): boolean {
return this.mapped
}
close(): void {
for (const sock of [this.innerSock, this.outerSock]) {
try {
sock?.close()
} catch {
// 已关 ⇒ 忽略
}
}
this.innerSock = undefined
this.outerSock = undefined
}
}
/** 一对打洞的读数(`bidirectional` = **两侧都成立** ⇒ 直连可用)。 */
export interface PunchPairResult {
a: PunchAttempt
b: PunchAttempt
bidirectional: boolean
nat: { a: SimNatCounters; b: SimNatCounters }
}
/**
* 跑一对打洞探测(**两侧并发**,各自走真实 {@link runPunchAttempt})。
*
* - `aNat` / `bNat` 的 `dropInbound` 决定这是"能打穿"还是"单向不可达";
* - 节点把包发给**自己的 NAT 网关**,目标是**对端 NAT 的外侧门牌**(= 真网里的"打公网门牌");
* - `peerSeen` = **对端 socket 的实时收包数 > 0**(= "对端确认收到了我的包"这一事实的进程内等价物)。
*/
export async function runPunchPair(
pair: {
aPeer: string
bPeer: string
deadlineMs?: number
cooldown?: number
/** 让哪一侧"只能出不能进"(`'a'` / `'b'` / `'both'` / `'none'`;缺省 `'none'`)。 */
oneWay?: 'a' | 'b' | 'both' | 'none'
},
io: { sleep: (ms: number) => Promise<void> },
): Promise<PunchPairResult> {
const oneWay = pair.oneWay ?? 'none'
const natA = new SimulatedNat({ dropInbound: oneWay === 'a' || oneWay === 'both' })
const natB = new SimulatedNat({ dropInbound: oneWay === 'b' || oneWay === 'both' })
await natA.open()
await natB.open()
natA.peerAddress = natB.natAddress
natB.peerAddress = natA.natAddress
const cooldownMs = pair.cooldown ?? DEFAULT_DIRECT_COOLDOWN_MS
const cdA = new DirectCooldown(cooldownMs)
const cdB = new DirectCooldown(cooldownMs)
const handles: { a?: PunchSocket; b?: PunchSocket } = {}
const mk = (
peer: string,
own: SimulatedNat,
other: 'a' | 'b',
self: 'a' | 'b',
cd: DirectCooldown,
): Promise<PunchAttempt> =>
runPunchAttempt(
{
peer,
selfPort: 0,
// ⚠️ 目标 = **本机自己的 NAT 网关**(= 真网里"经我这条 NAT 把包发到对端公网门牌")
// 夹具里 NAT 知道对端是谁(`peerAddress`),所以目标写成网关即可;
// ⛔ 不能直接写对端 NAT 的口 —— 那样**绕过自己的 NAT** ⇒ 映射建不起来(第一版就这么错)
targets: [own.gateway],
deadlineMs: pair.deadlineMs,
cooldown: cd,
bindHost: '127.0.0.1',
peerSeen: () => (handles[other]?.received() ?? 0) > 0,
onSocket: (s) => {
handles[self] = s
},
},
io,
)
const [a, b] = await Promise.all([mk(pair.aPeer, natA, 'b', 'a', cdA), mk(pair.bPeer, natB, 'a', 'b', cdB)])
const out: PunchPairResult = {
a,
b,
bidirectional: a.bidirectional && b.bidirectional,
nat: { a: natA.counters, b: natB.counters },
}
natA.close()
natB.close()
return out
}
+102 -1
View File
@@ -33,7 +33,8 @@ 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 { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, describeNetwork, isAllowedDialer, isHostId, isNetworkId, logicalName, networkKindOf, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export type { NetworkKind } 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'
@@ -155,3 +156,103 @@ export type {
GroupKeyLoadResult,
LoadGroupKeyOptions,
} from './content/crypto.js'
// ── 序㊱ · 「节点一键加入与分组准入」P1(S1–S4):网注册表 / 准入凭据 / 白名单派生 / join 编排 ──
export {
NETWORK_INVITE_TAG,
NODES_REGISTRY_VERSION,
networkInvitePayload,
parseNetworkInvite,
newInviteNonce,
verifyNetworkInvite,
noncePath,
consumeNonce,
isNonceConsumed,
emptyRegistry,
parseRegistry,
loadRegistry,
saveRegistry,
summarizeNetworks,
listNodes,
applyApplication,
approveNode,
removeNode,
deriveDialers,
auditDerivation,
deriveDropIn,
dropInPath,
describeRegistryLine,
} from './registry.js'
export type {
NetworkInvite,
InviteReason,
InviteVerdict,
NonceLedger,
NodeStatus,
NetworkNodeRecord,
NodesRegistry,
NetworkSummary,
DerivationAudit,
RegistryEdit,
} from './registry.js'
export {
JOIN_STEPS,
joinReasonOfInvite,
defaultHostId,
runJoin,
nodeJoinIo,
readSignedJson,
describeJoin,
canWrite,
maybeRead,
} from './join.js'
export type { JoinStep, JoinReason, StepReport, JoinOutcome, JoinIo, JoinOptions } from './join.js'
export { buildApplication, parseApplication, readApplicationFile } from './join.js'
export type { NodeApplication } from './join.js'
// ── 序㊵ · P2/S5:直连候选交换 + 打洞探测(开关 / 默认值 / 提示 / 观测面)──
export {
DIRECT_MESSAGE_KIND,
DIRECT_CAND_MAX_ADDRS,
DIRECT_CAND_TTL_MS,
findSecretField,
isIpv4,
isIpv6,
isValidAddress,
parseDirectMessage,
normalizeDirectMessage,
encodeDirectMessage,
admitCandidate,
CandidateLedger,
} from './direct/candidate.js'
export type { DirectAddress, DirectCandidateMessage, CandidateReason, CandidateVerdict, CandidateContext, CandidateLedgerSnapshot } from './direct/candidate.js'
export {
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
PUNCH_PROBE_INTERVAL_MS,
DEFAULT_PUNCH_DEADLINE_MS,
DEFAULT_DIRECT_COOLDOWN_MS,
DirectCooldown,
PunchSocket,
runPunchAttempt,
pickPunchPort,
SimulatedNat,
runPunchPair,
} from './direct/punch.js'
export type { PunchReason, PunchAttempt, PunchOptions, PunchPairResult, SimNatCounters, SimulatedNatOptions } from './direct/punch.js'
export {
DIRECT_ENV_KEY,
DEFAULT_DIRECT_ENABLED,
DIRECT_ON_VALUES,
DIRECT_OFF_VALUES,
NODE_CONFIG_FILE_DEFAULT,
resolveDirectSwitch,
DIRECT_HINT_PARTS,
directHintLines,
directHintText,
DirectPath,
directFromNodeConfig,
readNodeConfig,
writeNodeConfigDirect,
} from './direct/index.js'
export type { DirectSwitchState, DirectRefusal, NodeConfigWriteOutcome } from './direct/index.js'
+434
View File
@@ -0,0 +1,434 @@
/**
* 覆盖网络 **S3 · join 编排**(序㊱ · 「节点一键加入与分组准入」P1)。
*
* ## 它解决的确切问题(缺口 ①:「一键加入」)
* 今天把一台机器加进覆盖网要**手工做四件事**:
* ① 装 relay / worker 单元 → ② 配节点密钥 → ③ drop-in 写 `DSHS_RELAY_DIALERS` → ④ DB 登记。
* 全仓**无 join 类入口**(`grep` 实证)。⇒ 本模块把它编成**一条命令的四步**:
*
* | 步 | 做什么 | 失败后果(**必须具名**) |
* |---|---|---|
* | ① `verify-invite` | 校验邀请凭据(签名 + 网络绑定 + 有效期) | `invite-*` 五条具名原因 |
* | ② `node-key` | **本机**生成 / 复用节点密钥(**私钥不出机**,`0600`) | `node-key-unwritable` |
* | ③ `local-config` | 落地本机配置(`0600`) | `local-config-unwritable` |
* | ④ `register` | 向控制面提交申请(HTTP 或**落盘申请单**) | `register-*` 三条具名原因 |
*
* ## 🔴 一条不可退让的纪律:**⛔ 不许静默拒绝**
* 本线的病根反复是"配置错**长得像**网络不通"。⇒ 本模块**没有** `catch {}` 兜底:
* 每一步的失败都返回 `{ ok:false, step, reason, detail }`,`detail` 里带**原始**信息
* (路径 / 原因码 / HTTP 码),调用方(CLI)负责把它**原样打到 stderr** 并 `exit 1`。
* 判据:源码里**零**空 `catch` 块(`OBS-25` 的机器判据之一)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - ⛔ 不装服务单元、不写 drop-in、不 reload(那是控制面派生 + 运维动作,不是节点侧的事);
* - ⛔ 不落任何**密钥本体**到 stdout / 申请单(申请单里只有**公钥**);
* - ⛔ 不自己判"一次性" —— 一笔邀请是否已用过是**控制面台账**的事实(见 `registry.ts#consumeNonce`)。
*
* @module dshs/net/relay/join
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { assertNetworkId, isHostId } from './network.js'
import { verifyNetworkInvite, type InviteReason } from './registry.js'
import { DEFAULT_DIRECT_ENABLED } from './direct/index.js'
/** 四步(顺序即执行顺序;⛔ 改这个数组 = 改判据口径,`OBS-25` 会读到它)。 */
export const JOIN_STEPS = ['verify-invite', 'node-key', 'local-config', 'register'] as const
export type JoinStep = (typeof JOIN_STEPS)[number]
/**
* 失败原因(**枚举**,⛔ 不收自由文本)—— 目的:让"哪一步的哪种错"可被机器判。
*
* ⚠️ `invite-*` 五条与 `registry.ts#InviteReason` **一一对应**(不是另造一套口径):
* 映射表见 {@link joinReasonOfInvite}。
*/
export type JoinReason =
| 'invite-missing'
| 'invite-bad-payload'
| 'invite-no-trusted-signer'
| 'invite-signature-mismatch'
| 'invite-expired'
| 'invite-network-mismatch'
| 'node-key-unwritable'
| 'local-config-unwritable'
| 'register-unreachable'
| 'register-rejected'
| 'register-malformed'
/** 邀请验签原因 → join 原因(**唯一映射点**;⛔ 别在别处再写一遍)。 */
export function joinReasonOfInvite(reason: InviteReason): JoinReason {
switch (reason) {
case 'bad-payload':
return 'invite-bad-payload'
case 'no-trusted-keys':
return 'invite-no-trusted-signer'
case 'network-mismatch':
return 'invite-network-mismatch'
case 'expired':
return 'invite-expired'
case 'not-yet-valid':
return 'invite-expired'
default:
return 'invite-signature-mismatch'
}
}
/** 单步的执行记录(**成功与失败都记** —— 报告里要能看到"走到哪一步了")。 */
export interface StepReport {
step: JoinStep
ok: boolean
/** 人读的一行;⛔ 不含任何密钥本体。 */
detail: string
}
export type JoinOutcome =
| {
ok: true
network: string
hostId: string
/** 节点**公钥**(hex)。 */
nodeKey: string
/** 申请单落盘路径(`--out` 通道);HTTP 通道下为 `''`。 */
applicationFile: string
/** 控制面返回(HTTP 通道)或 `''`。 */
ack: string
steps: StepReport[]
}
| { ok: false; step: JoinStep; reason: JoinReason; detail: string; steps: StepReport[] }
/** 外部世界的边界(**全部注入** ⇒ 单测可在临时目录里真跑,⛔ 不 mock 判据)。 */
export interface JoinIo {
exists(path: string): boolean
read(path: string): string
/** 写文件;`mode` 由调用方给(密钥类 `0600`,公开类 `0644`)。 */
write(path: string, text: string, mode: number): void
generateNodeKey(): { privateKeyPem: string; publicKey: string }
publicKeyOfPrivate(privateKeyPem: string): string
hostname(): string
/** 提交申请(HTTP 通道)。 */
post(
url: string,
body: string,
): Promise<{ status: number; body: string }>
}
export interface JoinOptions {
/** 要加入的网(`ops` / `u:<租户>` / 显式命名 —— 合法形状由 `network.ts` 判)。 */
network: string
hostId: string
/** 邀请凭据(`{doc, sig}` 的原始 JSON 值;解析与验签都在 `registry.ts`)。 */
invite: { doc: unknown; sig: unknown }
/** 受信签名者公钥(hex)。**空 ⇒ 直接失败**(不可验 = 不接受)。 */
trustedSigners: readonly string[]
/** 节点私钥落点(缺省 `/etc/dshs/node.key`)。 */
nodeKeyFile: string
/** 本机配置落点(缺省 `/etc/dshs/overlay-node.json`)。 */
localConfigFile: string
/** 申请单落盘路径(**离线通道**;与 `portalUrl` 二选一)。 */
outFile?: string
/** 控制面入口(**HTTP 通道**;使用它需要控制面已开放接收入口)。 */
portalUrl?: string
/**
* 🆕 序㊵(P2/S5):本机的**直连(打洞)开关**是否开启。
*
* 缺省 = {@link DEFAULT_DIRECT_ENABLED}(**开** —— 用户 2026-09-18 12:22 原话「默认开启提示用户」)。
* ⚠️ 它只写进**本机配置**(`direct` 字段)—— ⛔ 不写任何 drop-in / env:
* 节点的直连开关属于"跟着用户走"的那类数据(放实例 / 节点自己的配置),不属于控制面共享值。
*/
direct?: boolean
now?: number
}
/** 从主机名派一个合法 `hostId`(非法字符替换为 `-`;⛔ 不猜、不静默回落到 `localhost`)。 */
export function defaultHostId(raw: string): string {
const v = raw.trim().toLowerCase().replace(/[^a-z0-9_.-]+/g, '-').replace(/^[^a-z0-9]+/, '').slice(0, 63)
return v === '' ? '' : v
}
/**
* 跑完整 join 编排(四步)。
*
* ⚠️ **顺序不可颠倒**:先验凭据(否则会为一张无效邀请生成无用的密钥),再生成密钥,
* 再落地本机配置,最后才向控制面提交(前一步失败 ⇒ **不产生下一步的副作用**)。
*/
export async function runJoin(opts: JoinOptions, io: JoinIo): Promise<JoinOutcome> {
const steps: StepReport[] = []
const fail = (step: JoinStep, reason: JoinReason, detail: string): JoinOutcome => {
steps.push({ step, ok: false, detail: `${reason}:${detail}` })
return { ok: false, step, reason, detail, steps }
}
// ── 形状先判:网络 id / hostId 非法 ⇒ 立即具名失败(⛔ 不"兜个默认网")───────
try {
assertNetworkId(opts.network, 'join 的目标网')
} catch (err) {
return fail('verify-invite', 'invite-network-mismatch', err instanceof Error ? err.message : String(err))
}
if (!isHostId(opts.hostId)) {
return fail('verify-invite', 'invite-bad-payload', `hostId 非法:${JSON.stringify(opts.hostId)}`)
}
// ── 步 ①:校验邀请凭据 ───────────────────────────────────────────────────
if (opts.trustedSigners.length === 0) {
return fail('verify-invite', 'invite-no-trusted-signer', '未配置受信签名者(不可验 = 不接受,⛔ 不降级放行)')
}
if (opts.invite === undefined || opts.invite === null) {
return fail('verify-invite', 'invite-missing', '未提供邀请凭据(--invite)')
}
const verdict = verifyNetworkInvite(opts.invite.doc, opts.invite.sig, opts.trustedSigners, {
network: opts.network,
now: opts.now,
})
if (!verdict.ok) {
return fail('verify-invite', joinReasonOfInvite(verdict.reason), `invite 验签失败:${verdict.reason}`)
}
steps.push({
step: 'verify-invite',
ok: true,
detail: `network=${verdict.doc.network} nonce=${verdict.doc.nonce.slice(0, 8)}… 有效期至 ${verdict.doc.expiresAt || '(未设)'}`,
})
// ── 步 ②:节点密钥(**私钥不出机**;已存在 ⇒ 复用,重跑 join 不换钥匙)─────
let publicKey = ''
let firstTime = false
try {
if (io.exists(opts.nodeKeyFile)) {
publicKey = io.publicKeyOfPrivate(io.read(opts.nodeKeyFile))
} else {
const key = io.generateNodeKey()
io.write(opts.nodeKeyFile, key.privateKeyPem, 0o600)
publicKey = key.publicKey
firstTime = true
}
} catch (err) {
return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile}:${err instanceof Error ? err.message : String(err)}`)
}
if (publicKey === '') {
return fail('node-key', 'node-key-unwritable', `${opts.nodeKeyFile} 里推不出公钥(形状非法)`)
}
steps.push({
step: 'node-key',
ok: true,
detail: `${firstTime ? '新生成' : '复用既有'}节点密钥 ${opts.nodeKeyFile}(0600)|公钥指纹 ${publicKey.slice(0, 16)}…`,
})
// ── 步 ③:本机配置(`0600`;⛔ 不含私钥本体,只记"私钥在哪")───────────────
// 🆕 序㊵(P2/S5):多记一个 `direct` —— 本机**直连开关**(用户口径「默认开启提示用户」的落点)。
const directEnabled = opts.direct ?? DEFAULT_DIRECT_ENABLED
const localConfig = {
version: 1,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKeyFile: opts.nodeKeyFile,
nodeKey: publicKey,
invitedBy: verdict.doc.nonce,
joinedAt: new Date(opts.now ?? Date.now()).toISOString(),
/** 🆕 直连(打洞)开关 —— **用户可改**(管理面 / 手改本文件均可)。 */
direct: directEnabled,
}
try {
io.write(opts.localConfigFile, `${JSON.stringify(localConfig, null, 2)}\n`, 0o600)
} catch (err) {
return fail(
'local-config',
'local-config-unwritable',
`${opts.localConfigFile}:${err instanceof Error ? err.message : String(err)}`,
)
}
steps.push({
step: 'local-config',
ok: true,
detail: `已落地 ${opts.localConfigFile}(0600)|直连(打洞)=${directEnabled ? '开(缺省)' : '关'}`,
})
// ── 步 ④:向控制面提交申请 ──────────────────────────────────────────────
// 🔴 **唯一的申请单构造点** —— 控制面 `apply` 用 `parseApplication` 读它(⛔ 别在这两处各写一份形状)。
const application = buildApplication({
hostId: opts.hostId,
network: verdict.doc.network,
// 只有公钥 —— 私钥永不出机、永不进这份申请单。
nodeKey: publicKey,
appliedAt: new Date(opts.now ?? Date.now()).toISOString(),
invite: { doc: opts.invite.doc, sig: opts.invite.sig },
})
const payload = `${JSON.stringify(application, null, 2)}\n`
if (typeof opts.portalUrl === 'string' && opts.portalUrl.trim() !== '') {
let res: { status: number; body: string }
try {
res = await io.post(opts.portalUrl.trim(), payload)
} catch (err) {
return fail(
'register',
'register-unreachable',
`${opts.portalUrl}:${err instanceof Error ? err.message : String(err)}`,
)
}
if (res.status < 200 || res.status >= 300) {
// ⚠️ 控制面拒绝时必须把**它对外的原因码**带回来 —— ⛔ 不许把 4xx 说成"网络不通"。
return fail('register', 'register-rejected', `HTTP ${res.status}:${res.body.slice(0, 200)}`)
}
steps.push({ step: 'register', ok: true, detail: `控制面已受理(HTTP ${res.status})` })
return {
ok: true,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKey: publicKey,
applicationFile: '',
ack: res.body.slice(0, 500),
steps,
}
}
const outFile = opts.outFile ?? ''
if (outFile === '') {
return fail(
'register',
'register-malformed',
'既未给 --portal(HTTP 通道)也未给 --out(离线申请单通道)⇒ ⛔ 不静默成功',
)
}
try {
mkdirSync(dirname(outFile), { recursive: true })
// `0644`:申请单里只有**公钥**与签名,本就是给控制面看的公开物。
writeFileSync(outFile, payload, { mode: 0o644 })
} catch (err) {
return fail('register', 'register-malformed', `${outFile}:${err instanceof Error ? err.message : String(err)}`)
}
steps.push({ step: 'register', ok: true, detail: `申请单已落盘 ${outFile}(0644,只有公钥)` })
return {
ok: true,
network: verdict.doc.network,
hostId: opts.hostId,
nodeKey: publicKey,
applicationFile: outFile,
ack: '',
steps,
}
}
/** **Node 侧**的实现(真的读写文件系统、真的发 HTTP)。⛔ 判据不在这里。 */
export function nodeJoinIo(deps: {
fss: {
existsSync: (p: string) => boolean
readFileSync: (p: string, enc: 'utf8') => string
writeFileSync: (p: string, data: string, o: { mode: number }) => void
mkdirSync: (p: string, o: { recursive: boolean }) => void
}
crypto: { generateNodeKey: () => { privateKeyPem: string; publicKey: string }; publicKeyOfPrivate: (pem: string) => string }
os: { hostname: () => string }
fetchImpl: (url: string, body: string) => Promise<{ status: number; body: string }>
}): JoinIo {
return {
exists: (p) => deps.fss.existsSync(p),
read: (p) => deps.fss.readFileSync(p, 'utf8'),
write: (p, text, mode) => {
deps.fss.mkdirSync(dirname(p), { recursive: true })
deps.fss.writeFileSync(p, text, { mode })
},
generateNodeKey: () => deps.crypto.generateNodeKey(),
publicKeyOfPrivate: (pem) => deps.crypto.publicKeyOfPrivate(pem),
hostname: () => deps.os.hostname(),
post: (url, body) => deps.fetchImpl(url, body),
}
}
/** 读一份邀请 / 申请单文件(`{doc, sig}` 形态;⛔ 形状不对 ⇒ **抛**,不返回 `undefined`)。 */
export function readSignedJson(file: string): { doc: unknown; sig: unknown } {
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
if (raw === null || typeof raw !== 'object') throw new Error(`${file} 不是 {doc, sig} 形态的 JSON`)
const r = raw as Record<string, unknown>
if (r.doc === undefined || r.sig === undefined) throw new Error(`${file} 缺 doc / sig 字段`)
return { doc: r.doc, sig: r.sig }
}
/**
* **join 申请单**(`register` 步产出的那份东西)。
*
* 🔴 **存在的理由(真机首轮实测踩到)**:`join` 写出的是**申请单**(`hostId` / `network` / `nodeKey`
* / 内嵌的 `invite:{doc,sig}`),而控制面 `apply` 一开始按"裸 `{doc, sig}`"去读 ⇒ **读不出来**
* (`✗ … 缺 doc / sig 字段`)。两个形状**必须由同一处定义**、并由同一个解析函数读 ——
* 否则 S3 的产物与 S2 的输入会各自演进、在真机上才暴露(本线"同一事实两处写"的又一例)。
*
* ⛔ **载荷内只有公钥** —— 私钥永不出机、永不进这份申请单。
*/
export interface NodeApplication {
version: number
hostId: string
network: string
/** 节点**公钥**(hex)。 */
nodeKey: string
appliedAt: string
/** 内嵌的邀请凭据(**验签与一次性都由控制面判**)。 */
invite: { doc: unknown; sig: unknown }
}
/** 构造一份申请单(**唯一构造点** —— `runJoin` 与夹具都走它)。 */
export function buildApplication(a: {
hostId: string
network: string
nodeKey: string
appliedAt: string
invite: { doc: unknown; sig: unknown }
}): NodeApplication {
return {
version: 1,
hostId: a.hostId,
network: a.network,
nodeKey: a.nodeKey,
appliedAt: a.appliedAt,
invite: { doc: a.invite.doc, sig: a.invite.sig },
}
}
/** 解析申请单(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
export function parseApplication(raw: unknown): NodeApplication | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : ''
const network = typeof r.network === 'string' ? r.network.trim() : ''
const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : ''
const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : ''
if (!isHostId(hostId) || network === '' || !/^[0-9a-f]{64}$/.test(nodeKey) || appliedAt === '') return undefined
const inv = r.invite
if (inv === null || typeof inv !== 'object') return undefined
const i = inv as Record<string, unknown>
if (i.doc === undefined || i.sig === undefined) return undefined
const version = typeof r.version === 'number' ? r.version : 1
return { version, hostId, network, nodeKey, appliedAt, invite: { doc: i.doc, sig: i.sig } }
}
/** 读一份申请单文件;形状不对 ⇒ **抛**(调用方负责转成具名失败,⛔ 不静默回落)。 */
export function readApplicationFile(file: string): NodeApplication {
const parsed = parseApplication(JSON.parse(readFileSync(file, 'utf8')))
if (parsed === undefined) {
throw new Error(`${file} 不是合法的**申请单**(须含 hostId / network / nodeKey(64hex) / appliedAt / invite.doc / invite.sig)`)
}
return parsed
}
/** 一行摘要(给 CLI 收尾打印)。 */
export function describeJoin(outcome: JoinOutcome): string {
if (outcome.ok) {
return `✓ join 成功:${outcome.network}/${outcome.hostId}|公钥 ${outcome.nodeKey.slice(0, 16)}…|四步 ${outcome.steps.length}/4`
}
return `✗ join 失败于「${outcome.step}」:${outcome.reason} — ${outcome.detail}`
}
/** 供 CLI 判断"这个路径能不能写"(⛔ 不真写,避免试错产生半成品)。 */
export function canWrite(file: string): boolean {
try {
mkdirSync(dirname(file), { recursive: true })
return true
} catch {
return false
}
}
/** 兜底:确无该文件时返回 `undefined`(**只用于可选输入**,⛔ 不用于密钥)。 */
export function maybeRead(file: string): string | undefined {
return existsSync(file) ? readFileSync(file, 'utf8') : undefined
}
+66
View File
@@ -74,6 +74,46 @@ export function assertNetworkId(raw: string, what = 'network id'): string {
return v
}
/**
* 网络 id 的**类别**(`isNetworkId` 那三条分支的**具名**形态)。
*
* ## 为什么要有它(序㊱ · 「节点一键加入与分组准入」S1)
* 管理面要回答「**有哪些网**、每张是什么性质」—— 只回一个 `true/false` 不够:分组准入的
* **默认分组**、**能否与 `ops` 共享骨干**、**是否允许显式命名**,三处判据都要这个类别。
*
* ⛔ **纯函数、无 IO、不改既有语义** —— `isNetworkId` 仍是唯一的合法性判据,本函数只是
* 把它的三条分支**具名化**(`kind !== undefined` ⟺ `isNetworkId(raw) === true`)。
*
* | 类别 | 形态 | 含义 |
* |---|---|---|
* | `ops` | 固定值 | 平台自己的机器(中继 / 骨干 / Worker) |
* | `tenant` | `u:<租户>` | 该用户名下的全部设备(含桌面客户端) |
* | `named` | `[a-z0-9][a-z0-9_.-]*` | **二级网 / 测试网 / 独立覆盖网络** —— 新增一张网**零代码** |
*/
export type NetworkKind = 'ops' | 'tenant' | 'named'
export function networkKindOf(raw: string): NetworkKind | undefined {
const v = raw.trim()
if (!isNetworkId(v)) return undefined
if (v === OPS_NETWORK) return 'ops'
if (v.startsWith('u:')) return 'tenant'
return 'named'
}
/**
* 网名的**规范展示名**(管理面 / CLI 输出用)—— `ops` 后面跟着它是什么。
*
* ⛔ 只用于**人读的输出**(日志 / `list` 表)。**判据一律用 `networkKindOf`** ——
* 拿展示名去做字符串匹配 = 把"好看的输出"变成判据(本线反复要根治的那类病)。
*/
export function describeNetwork(raw: string): string {
const kind = networkKindOf(raw)
if (kind === undefined) return `${raw}(⛔ 非法网名)`
if (kind === 'ops') return `${raw}(运维网 · 平台自己的机器)`
if (kind === 'tenant') return `${raw}(租户网)`
return `${raw}(显式命名网 · 可作独立覆盖网络)`
}
/** 节点逻辑名 = `<network_id>/<hostId>`。**唯一拼法**(别在调用方拼)。 */
export function logicalName(network: string, hostId: string): string {
return `${network}${NAME_SEP}${hostId}`
@@ -192,6 +232,32 @@ export function normalizeDialers(
return out
}
/**
* **白名单准入的唯一定义**(序㊵ · P2/S5)—— `true` = 该 `hostId` 在 `network` 这张网里可拨。
*
* ## 为什么必须单列成一个函数(⛔ 不是为了好看)
* 在它之前,「不在白名单就拒」这条策略**只有 `server.ts` 里三处内联写法**:
* `this.dialers.get(network)?.has(hostId) === true`(注册闸门 `:1182`)、
* `this.dialers.get(session.network)?.has(session.hostId) !== true`(`DIAL` 闸门 `:1491`)。
* 直连(P2)要**再判一次同一条策略**(候选交换/打洞都只准发生在"同网 + 白名单内"),
* 若直连模块**自己再写一份**,就正好撞上本线反复吃过的病根:**同一事实两处写** ⇒
* 两处有一天会分叉(`server.ts` 那两处是**冻结的只读面**,不许改也不许被复制)。
*
* ⇒ 本函数 = **该策略的唯一可复用出口**;`server.ts` 的内联写法与它**逐字等价**
* (同 map 、同 `=== true` 默认拒绝语义、同"按网络分桶")。等价性由
* `test/overlay-direct.test.mjs#T4` 做**机器守卫**(读 `server.ts` 源码核对那两处的原文形态)。
*
* ⚠️ 语义上 `network` 是**桶键**:拿别的网的桶去查同名 hostId 只会得到 `false`
* (这正是 P0-1「跨网结构性隔离」—— ⛔ 不是"策略允许/不允许"的区别)。
*/
export function isAllowedDialer(
map: ReadonlyMap<string, ReadonlySet<string>>,
network: string,
hostId: string,
): boolean {
return map.get(network)?.has(hostId) === true
}
/**
* 类型判别:`Map` 形态 vs 扁平 `Set` 形态。
*
+503
View File
@@ -0,0 +1,503 @@
/**
* 覆盖网络 **S1+S2+S4 · 网注册表 + 准入凭据 + 白名单派生**(序㊱ · 「节点一键加入与分组准入」P1)。
*
* ## 它解决的确切问题(缺口 ②:「分组准入门面」)
* `server.ts` 的拨号方白名单**机制已经完整**(`dialers: Map<network, Set<hostId>>`、默认拒绝、
* 跨网在"能不能拨"这一步就走不到 —— 结构性隔离)。缺的是**把它变成可管理的东西**:
* 今天配置靠**手写字符串**(`DSHS_RELAY_DIALERS="u:5:manager"`),写错了长得像"这张网不存在"。
*
* ⇒ 本模块把「**哪些网、每张网有哪些节点、谁已批准**」变成**一份注册表**,
* 并**派生**出 relay 侧的白名单(`DSHS_RELAY_DIALERS`)。
*
* ## 三条硬约束
* 1. **控制面是权威单点** —— 注册表只有控制面写(用户既有口径:「归属 / 租约 / 骨干资格只能控制面写」)。
* 2. **手写 drop-in 退化为应急通道,⛔ 不删** —— 派生只是**多一条**产生白名单的路;控制面不可用
* 时手写仍然照旧生效(`normalizeDialers` 同时接受扁平 `Set` 与分桶 `Map`)。
* 3. **凭据载荷 ⛔ 不含密钥本体** —— 邀请凭据只回答「哪张网 + 有效期 + 一次性 nonce」,
* 与组密钥凭据同形(`content/crypto.ts` 的 `(network, group, epoch, keyId)`)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不碰 `server.ts` 的 DIAL / 白名单**语义**(只**读取**既有形状,派生出的仍是同一个 `Map` 形状);
* - 不开监听、不写 env、不 reload 任何单元(**派生是纯函数**,落盘与 reload 是调用方的事);
* - 不发明第二种签名 —— 一律走 `identity.ts#verifySignedPayload` / `signPayloadWith`。
*
* @module dshs/net/relay/registry
*/
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { OPS_NETWORK, assertNetworkId, isHostId, logicalName, networkKindOf } from './network.js'
import { verifySignedPayload, type IdentityReason } from './identity.js'
// ── 邀请(准入)凭据 ─────────────────────────────────────────────────────────
/** 载荷域分隔标签。⛔ **改它 = 令所有既有邀请失效**。 */
export const NETWORK_INVITE_TAG = 'dshs-overlay-netinvite/v1'
/** 注册表文件的结构版本。 */
export const NODES_REGISTRY_VERSION = 1
/** 输入长度的上界(防"超长 hostId 撑爆注册表"这类形状攻击;⛔ 与 `HOST_RE` 同量级)。 */
const NONCE_RE = /^[0-9a-f]{32}$/
const HEX_PUB_RE = /^[0-9a-f]{64}$/
/**
* 一张网的**准入邀请凭据**。
*
* 🔴 **载荷内不含任何密钥本体**(私钥 / 对称密钥都不在)—— 它只是"控制面允许某台机器
* 以某张网的身份来申请"。**节点自己的密钥在节点上生成、私钥永不出机**(`join.ts` 第 ② 步)。
*/
export interface NetworkInvite {
version: number
/** 该邀请**绑定的网** —— 拿它去申请别的网 ⇒ `network-mismatch`(具名拒绝)。 */
network: string
/** **一次性** nonce(32 hex)。控制面在**收单时**占位,第二次用 ⇒ `invite-already-used`。 */
nonce: string
issuedAt: string
/** **有效期**(ISO)。空串 = 不过期(⛔ 不推荐;控制面签发时缺省给 TTL)。 */
expiresAt: string
}
/** 邀请凭据的拒绝原因(**在 `IdentityReason` 之上再加两条注册表侧原因**)。 */
export type InviteReason = IdentityReason | 'bad-payload' | 'invite-already-used'
/** 邀请验签结论:**失败一律带具体原因**(⛔ 不许静默)。 */
export type InviteVerdict = { ok: true; doc: NetworkInvite; payload: string } | { ok: false; reason: InviteReason }
/** 规范拼接(字段顺序**写死**;⛔ 改顺序 = 令所有既有签名失效)。 */
export function networkInvitePayload(doc: NetworkInvite): string {
return [
NETWORK_INVITE_TAG,
`version=${String(doc.version)}`,
`network=${doc.network}`,
`nonce=${doc.nonce}`,
`issuedAt=${doc.issuedAt}`,
`expiresAt=${doc.expiresAt}`,
].join('\n')
}
/** 解析(**严格**:字段不全 / 类型不对 ⇒ `undefined`,⛔ 不猜、不补默认值)。 */
export function parseNetworkInvite(raw: unknown): NetworkInvite | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const network = typeof r.network === 'string' ? r.network.trim() : ''
const nonce = typeof r.nonce === 'string' ? r.nonce.trim().toLowerCase() : ''
const issuedAt = typeof r.issuedAt === 'string' ? r.issuedAt.trim() : ''
const expiresAt = typeof r.expiresAt === 'string' ? r.expiresAt.trim() : ''
if (network === '' || issuedAt === '') return undefined
if (networkKindOf(network) === undefined) return undefined
if (!NONCE_RE.test(nonce)) return undefined
const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION
return { version, network, nonce, issuedAt, expiresAt }
}
/**
* 验一份邀请凭据 —— **四件套**:受信签名者 + 网络绑定 + 有效期 + 载荷形状。
*
* ⚠️ **一次性不在这里判** —— 一笔邀请的"是否已被用过"是**控制面台账**的事实(跨进程共享),
* 不是签名能表达的东西。⇒ 由 {@link consumeNonce} 在**收单时**用原子占位判。
* 把两件事分开,是为了让"凭据合法但已用过"与"凭据不合法"在排障时**可分**。
*/
export function verifyNetworkInvite(
doc: unknown,
sig: unknown,
trustedSigners: readonly string[],
opts: { network?: string; now?: number } = {},
): InviteVerdict {
const parsed = parseNetworkInvite(doc)
if (parsed === undefined) return { ok: false, reason: 'bad-payload' }
const payload = networkInvitePayload(parsed)
const verdict = verifySignedPayload(payload, sig, trustedSigners)
if (verdict !== 'ok') return { ok: false, reason: verdict }
if (opts.network !== undefined && parsed.network !== opts.network) {
return { ok: false, reason: 'network-mismatch' }
}
const now = opts.now ?? Date.now()
if (parsed.expiresAt !== '') {
const at = Date.parse(parsed.expiresAt)
if (!Number.isFinite(at)) return { ok: false, reason: 'bad-payload' }
if (now > at) return { ok: false, reason: 'expired' }
}
return { ok: true, doc: parsed, payload }
}
/** 生成一个**一次性 nonce**(32 hex = 16 字节随机)。 */
export function newInviteNonce(randomBytes: (n: number) => Buffer): string {
return randomBytes(16).toString('hex')
}
// ── 一次性台账(**原子占位**,治"同一凭据被两台机器同时用")─────────────────────
/** 台账入参:目录 + 时间源(注入 ⇒ 可单测,⛔ 不读全局时钟)。 */
export interface NonceLedger {
dir: string
now?: () => number
}
/** 单个 nonce 的台账文件路径(`<dir>/<nonce>.used`)。 */
export function noncePath(ledger: NonceLedger, nonce: string): string {
return `${ledger.dir}/${nonce}.used`
}
/**
* 占位一个 nonce(**一次性**)。
*
* 🔴 **做法 = `writeFileSync(..., { flag: 'wx' })`** —— `O_CREAT|O_EXCL` 是**内核原子**的:
* 两台机器**同时**拿同一张邀请来收单,**恰好一台**拿到 `ok`,另一台得到 `invite-already-used`。
* ⛔ **不许**"先 `existsSync` 再写" —— 那是两次系统调用的竞态,正是本线「判据成立 ≠ 机制成立」
* 的典型反例(单机测着对、并发下漏一个)。
*
* ⚠️ 占位文件里写**收单侧可读的事实**(时间 + 用途),便于事后排障;⛔ 不写任何密钥 / 公钥。
*/
export function consumeNonce(
ledger: NonceLedger,
nonce: string,
note: string,
): { ok: true; file: string } | { ok: false; reason: 'invite-already-used' } {
const n = nonce.trim().toLowerCase()
if (!NONCE_RE.test(n)) return { ok: false, reason: 'invite-already-used' }
mkdirSync(ledger.dir, { recursive: true })
const file = noncePath(ledger, n)
const at = new Date(ledger.now?.() ?? Date.now()).toISOString()
try {
writeFileSync(file, `${at} ${note}\n`, { flag: 'wx', mode: 0o644 })
} catch (err) {
const code = (err as { code?: string }).code
if (code === 'EEXIST') return { ok: false, reason: 'invite-already-used' }
throw err
}
return { ok: true, file }
}
/** 只读:该 nonce 是否已被用过(**不占位**)。 */
export function isNonceConsumed(ledger: NonceLedger, nonce: string): boolean {
const n = nonce.trim().toLowerCase()
return NONCE_RE.test(n) && existsSync(noncePath(ledger, n))
}
// ── 网注册表(S1 的数据模型)─────────────────────────────────────────────────
/**
* 一个节点在注册表里的状态。
*
* | 状态 | 含义 | 后果 |
* |---|---|---|
* | `pending` | **已申请、待批准** | ⛔ **不进白名单** ⇒ relay 侧拨不动(默认拒绝) |
* | `approved` | 已批准 | 进派生白名单 ⇒ 可接入 |
*/
export type NodeStatus = 'pending' | 'approved'
/** 注册表里的一条节点记录(⛔ 只有**公钥**,没有私钥 / 没有密钥本体)。 */
export interface NetworkNodeRecord {
network: string
hostId: string
/** 节点**公钥**(64 hex,Ed25519 裸公钥)。 */
nodeKey: string
status: NodeStatus
/** 申请时刻(ISO)。 */
appliedAt: string
/** 批准时刻(ISO);`''` = 未批准。 */
approvedAt: string
/** 分组(`''` = 该网默认组)。⚠️ 分组只作**管理信息**,⛔ 不参与"能不能拨"的判据。 */
group: string
}
/** 注册表文件(键 = **逻辑名** `<network>/<hostId>`,与 `server.ts` 会话表同口径)。 */
export interface NodesRegistry {
version: number
nodes: Record<string, NetworkNodeRecord>
}
export function emptyRegistry(): NodesRegistry {
return { version: NODES_REGISTRY_VERSION, nodes: {} }
}
/**
* 解析注册表(**严格**:形状不对 ⇒ `undefined`)。
*
* ⛔ **不做"宽容修复"** —— 一条 `status` 拼错的记录如果被静默当成 `pending`,
* 表现就是"批准了却连不上"(本线反复要根治的那类病)。
*/
export function parseRegistry(raw: unknown): NodesRegistry | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const r = raw as Record<string, unknown>
const nodesRaw = r.nodes
if (nodesRaw === undefined || typeof nodesRaw !== 'object' || Array.isArray(nodesRaw)) return undefined
const version = typeof r.version === 'number' ? r.version : NODES_REGISTRY_VERSION
const nodes: Record<string, NetworkNodeRecord> = {}
for (const [key, value] of Object.entries(nodesRaw as Record<string, unknown>)) {
const rec = parseRecord(value)
if (rec === undefined) return undefined
if (key !== logicalName(rec.network, rec.hostId)) return undefined
nodes[key] = rec
}
return { version, nodes }
}
function parseRecord(raw: unknown): NetworkNodeRecord | undefined {
if (raw === undefined || raw === null || typeof raw !== 'object') return undefined
const r = raw as Record<string, unknown>
const network = typeof r.network === 'string' ? r.network.trim() : ''
const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : ''
const nodeKey = typeof r.nodeKey === 'string' ? r.nodeKey.trim().toLowerCase() : ''
const status = typeof r.status === 'string' ? r.status.trim() : ''
const appliedAt = typeof r.appliedAt === 'string' ? r.appliedAt.trim() : ''
const approvedAt = typeof r.approvedAt === 'string' ? r.approvedAt.trim() : ''
const group = typeof r.group === 'string' ? r.group.trim() : ''
if (networkKindOf(network) === undefined) return undefined
if (!isHostId(hostId)) return undefined
if (!HEX_PUB_RE.test(nodeKey)) return undefined
if (status !== 'pending' && status !== 'approved') return undefined
if (appliedAt === '') return undefined
if (status === 'approved' && approvedAt === '') return undefined
return { network, hostId, nodeKey, status, appliedAt, approvedAt, group }
}
/** 读注册表;**文件不存在 ⇒ 空注册表**(不是错误:还没开始用)。形状不对 ⇒ **抛**(具名)。 */
export function loadRegistry(file: string): NodesRegistry {
if (!existsSync(file)) return emptyRegistry()
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
const reg = parseRegistry(raw)
if (reg === undefined) throw new Error(`注册表 ${file} 形状非法(⛔ 不静默修复;字段口径见 registry.ts)`)
return reg
}
/** 写注册表(**`0644`** —— 内容只有公钥与状态,⛔ 无密钥;走临时文件 + `rename` 不留半成品)。 */
export function saveRegistry(file: string, reg: NodesRegistry): void {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp`
writeFileSync(tmp, `${JSON.stringify(sortedRegistryJson(reg), null, 2)}\n`, { mode: 0o644 })
renameSync(tmp, file)
}
/** 稳定序列化:`nodes` 的键**按键名排序**,让"没有改动"与"改动了"在 `diff` 里可分。 */
function sortedRegistryJson(reg: NodesRegistry): NodesRegistry {
const nodes: Record<string, NetworkNodeRecord> = {}
for (const key of Object.keys(reg.nodes).sort()) {
const rec = reg.nodes[key]
if (rec === undefined) continue
nodes[key] = rec
}
return { version: reg.version, nodes }
}
// ── 读只面(S1)─────────────────────────────────────────────────────────────
export interface NetworkSummary {
network: string
/** 该网记录总数。 */
total: number
/** 其中已批准数。 */
approved: number
/** 其中待批准数。 */
pending: number
}
/** 逐网汇总(**排序稳定**:`ops` 先,其余按名)。 */
export function summarizeNetworks(reg: NodesRegistry): NetworkSummary[] {
const acc = new Map<string, NetworkSummary>()
for (const rec of Object.values(reg.nodes)) {
const cur = acc.get(rec.network) ?? { network: rec.network, total: 0, approved: 0, pending: 0 }
cur.total++
if (rec.status === 'approved') cur.approved++
else cur.pending++
acc.set(rec.network, cur)
}
return [...acc.values()].sort((a, b) => {
if (a.network === OPS_NETWORK) return -1
if (b.network === OPS_NETWORK) return 1
return a.network < b.network ? -1 : a.network > b.network ? 1 : 0
})
}
/** 列出节点(可选按网过滤;**排序稳定**:网名 → hostId)。 */
export function listNodes(reg: NodesRegistry, network?: string): NetworkNodeRecord[] {
return Object.values(reg.nodes)
.filter((r) => network === undefined || r.network === network)
.sort((a, b) => {
if (a.network !== b.network) return a.network < b.network ? -1 : 1
return a.hostId < b.hostId ? -1 : a.hostId > b.hostId ? 1 : 0
})
}
// ── 写面(S1;**只有控制面调**)──────────────────────────────────────────────
/** 收单结果:失败**具名**(⛔ 不许静默拒绝)。 */
export type RegistryEdit =
| { ok: true; record: NetworkNodeRecord; created: boolean }
| { ok: false; reason: 'unknown-node' | 'unknown-network' }
/**
* 收一份**申请**(`join` 的第 ④ 步)。
*
* 语义(**刻意如此**):
* - 新节点 ⇒ 建一条 `pending`(⛔ **不自动批准** —— 批准权在控制面,见 §4.1);
* - **已 `approved` 的节点再次申请** ⇒ **保持 `approved`**(幂等:重装 / 重跑 join 不该把自己踢回待批);
* - 已 `pending` 再次申请 ⇒ 刷新 `appliedAt` / `nodeKey`(换机重装场景)。
*/
export function applyApplication(
reg: NodesRegistry,
app: { network: string; hostId: string; nodeKey: string; at: string; group?: string },
): RegistryEdit {
const network = app.network.trim()
if (networkKindOf(network) === undefined) return { ok: false, reason: 'unknown-network' }
if (!isHostId(app.hostId)) return { ok: false, reason: 'unknown-node' }
if (!HEX_PUB_RE.test(app.nodeKey.trim().toLowerCase())) return { ok: false, reason: 'unknown-node' }
const key = logicalName(network, app.hostId)
const prev = reg.nodes[key]
const record: NetworkNodeRecord = {
network,
hostId: app.hostId,
nodeKey: app.nodeKey.trim().toLowerCase(),
status: prev?.status === 'approved' ? 'approved' : 'pending',
appliedAt: app.at,
approvedAt: prev?.status === 'approved' ? prev.approvedAt : '',
group: app.group ?? prev?.group ?? '',
}
reg.nodes[key] = record
return { ok: true, record, created: prev === undefined }
}
/** 批准一个节点(⛔ 只在**已申请**的节点上生效 ⇒ 不存在"凭空批准一个没申请过的 hostId")。 */
export function approveNode(
reg: NodesRegistry,
network: string,
hostId: string,
at: string,
group?: string,
): RegistryEdit {
const net = network.trim()
if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' }
const key = logicalName(net, hostId)
const prev = reg.nodes[key]
if (prev === undefined) return { ok: false, reason: 'unknown-node' }
const record: NetworkNodeRecord = {
...prev,
status: 'approved',
approvedAt: at,
group: group ?? prev.group,
}
reg.nodes[key] = record
return { ok: true, record, created: false }
}
/** 移除一个节点(⇒ 立刻退出派生白名单;⚠️ relay 侧生效要等一次 reload —— 见 §4.1 注)。 */
export function removeNode(reg: NodesRegistry, network: string, hostId: string): RegistryEdit {
const net = network.trim()
if (networkKindOf(net) === undefined) return { ok: false, reason: 'unknown-network' }
const key = logicalName(net, hostId)
const prev = reg.nodes[key]
if (prev === undefined) return { ok: false, reason: 'unknown-node' }
delete reg.nodes[key]
return { ok: true, record: prev, created: false }
}
// ── S4 · 白名单派生 ─────────────────────────────────────────────────────────
/**
* 把**已批准集合**投影成 relay 侧的白名单(`dialers` 的**分桶 Map** 形状)。
*
* ⚠️ 这是 `network.ts#normalizeDialers` **能吃**的形状 ⇒ 派生结果与手写 drop-in
* 走的是**同一个归一化入口**(⛔ 不存在"派生的白名单和手写的不是一回事")。
* 🔴 `pending` **一律不投影** —— 派生只表达"已批准",待批节点的可拨性由既有默认拒绝兜住。
*/
export function deriveDialers(reg: NodesRegistry, networks?: readonly string[]): Map<string, Set<string>> {
const out = new Map<string, Set<string>>()
if (networks !== undefined) for (const n of networks) out.set(assertNetworkId(n, '派生的网名'), new Set())
for (const rec of Object.values(reg.nodes)) {
if (rec.status !== 'approved') continue
if (networks !== undefined && !networks.includes(rec.network)) continue
const bucket = out.get(rec.network) ?? new Set<string>()
bucket.add(rec.hostId)
out.set(rec.network, bucket)
}
return out
}
/** 派生结果的一致性问题(`OBS-24` 的判据②③,**逐条点名**)。 */
export interface DerivationAudit {
ok: boolean
/** 派生集合 ≠ 已批准集合的网(逐条点名,含差集)。 */
mismatches: string[]
/** **结构性错桶**:bucket 里的 hostId 对应记录属于**另一张网**(跨网零共享的机器判据)。 */
misfiled: string[]
}
/**
* 自审派生结果 —— **S4 的判据不是"函数跑通了",而是三件事同时成立**:
* ① 每张网的派生集合 ≡ 该网 `approved` 集合(多一个 / 少一个都点名);
* ② 任何 bucket 里的 hostId 都**来属于该 bucket 的网**(⇒ 「独立网零共享」是**结构性**的,
* 不是"我们用的时候记得过滤");
* ③ `pending` 一个都不在派生里。
*/
export function auditDerivation(reg: NodesRegistry, derived?: Map<string, Set<string>>): DerivationAudit {
const d = derived ?? deriveDialers(reg)
const mismatches: string[] = []
const misfiled: string[] = []
const byNetwork = new Map<string, Set<string>>()
const ownerOf = new Map<string, string>()
for (const rec of Object.values(reg.nodes)) {
ownerOf.set(`${rec.network}${'\u0000'}${rec.hostId}`, rec.network)
if (rec.status !== 'approved') continue
const bucket = byNetwork.get(rec.network) ?? new Set<string>()
bucket.add(rec.hostId)
byNetwork.set(rec.network, bucket)
}
for (const network of new Set<string>([...byNetwork.keys(), ...d.keys()])) {
const want = byNetwork.get(network) ?? new Set<string>()
const got = d.get(network) ?? new Set<string>()
const missing = [...want].filter((h) => !got.has(h)).sort()
const extra = [...got].filter((h) => !want.has(h)).sort()
if (missing.length > 0 || extra.length > 0) {
mismatches.push(`${network}: 缺[${missing.join(',')}] 多[${extra.join(',')}]`)
}
}
for (const [network, hosts] of d.entries()) {
for (const host of hosts) {
const owner = ownerOf.get(`${network}${'\u0000'}${host}`)
if (owner !== network) misfiled.push(`${network}/${host}(记录属于 ${owner ?? '不存在'})`)
}
}
return { ok: mismatches.length === 0 && misfiled.length === 0, mismatches, misfiled }
}
/**
* 派生一份 **relay drop-in** 的**内容**(纯字符串,⛔ 本函数不落盘、不 reload)。
*
* 🔴 **手写 drop-in 是应急通道,⛔ 不删** —— 派生出的这份与手写那份作用**完全等价**
* (同一个 env 键、同一套归一化),差别只在"谁产生它"。⇒ 控制面不可用时,手写照旧生效。
*/
export function deriveDropIn(
reg: NodesRegistry,
opts: { network: string; varName?: string; libDir?: string; unit?: string },
): string {
const network = assertNetworkId(opts.network, '派生的网名')
const varName = opts.varName ?? 'DSHS_RELAY_DIALERS'
const hosts = [...(deriveDialers(reg, [network]).get(network) ?? new Set<string>())].sort()
// 逻辑名形态(`<网>/<hostId>`)—— `normalizeDialers` 的**规范形态**;⛔ 不用 `网:hostId` 旧式写法。
const entries = hosts.map((h) => logicalName(network, h))
const libDir = opts.libDir ?? '/opt/dsh-relay'
const unit = opts.unit ?? 'dshs-relay'
return [
`# 由控制面**派生**(${unit} · network=${network})—— 源 = 网注册表里该网的 approved 集合`,
`# 🔴 手写 drop-in 是**应急通道**(控制面不可用时照旧生效),⛔ 本文件不取代它。`,
`# ⛔ 不许手改:改了下一次派生就被覆盖;要改就改注册表(approve / remove)。`,
'[Service]',
`Environment="${varName}=${entries.join(',')}"`,
].join('\n')
}
/** drop-in 的**目标路径**(同一套命名,⛔ 别在调用方拼)。 */
export function dropInPath(unit: string, libDir?: string): string {
const suffix = libDir === undefined ? '' : ` (lib=${libDir})`
return `/etc/systemd/system/${unit}.service.d/50-overlay-dialers.conf${suffix}`
}
// ── 一行摘要(给 CLI / 日志;**同一条信息只说一次**)─────────────────────────
export function describeRegistryLine(reg: NodesRegistry): string {
const nets = summarizeNetworks(reg)
if (nets.length === 0) return `注册表:0 张网 0 个节点(⛔ 空注册表 ⇒ 派生结果为空 ⇒ 默认拒绝一切拨号)`
return `注册表:${nets.map((n) => `${n.network}(approved ${n.approved}/pending ${n.pending})`).join(' · ')}`
}
+142
View File
@@ -0,0 +1,142 @@
/**
* 覆盖网络 **管理面 API**(序㊵ · P2/S5 一并做 —— 该文件是 P1 的出口项)。
*
* ## 它回答两个问题
* | 端点 | 作用 | 鉴权 |
* |---|---|---|
* | `GET /api/admin/overlay-nodes` | **有哪些网 / 每个节点什么状态**(只读汇总) | `requireAdmin` |
* | `GET /api/admin/overlay-nodes/direct` | 本机**直连开关**现状 + **三段提示** | `requireAdmin` |
* | `POST /api/admin/overlay-nodes/direct` | **用户可设置**(口径①)—— 只改本机配置的 `direct` 字段 | `requireAdmin` |
*
* ## 🔴 三条纪律
* ① **全部走 `requireAdmin`** —— ⛔ 不做第二个无鉴权端点:既有的 `GET /dshs-overlay/bootstrap`
* 之所以能无鉴权,是因为它**只服务还没有凭据的新节点**且内容被收窄到"去哪儿";
* "**有哪些节点**"是**拓扑信息**,放公网 = 扩大暴露面(命中 **R5**)。
* ② **只写"跟着这台机器走"的那一项** —— 直连开关放**本机配置**(`<NODE_CONFIG_FILE>.direct`),
* ⛔ 不写共享 drop-in / env:那属于控制面或运维的通道(见 `direct/index.ts#resolveDirectSwitch`
* 的优先级口径:env > 本机配置 > 缺省)。
* ③ **失败具名** —— 文件不存在 / 形状坏 / 写不进去,各自一个原因码,⛔ 不吞、⛔ 不静默创建。
*
* @module dshs/web/routes/overlay-nodes
*/
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import type { FastifyPluginAsync } from 'fastify'
import {
DIRECT_ENV_KEY,
DEFAULT_DIRECT_ENABLED,
NODE_CONFIG_FILE_DEFAULT,
directFromNodeConfig,
directHintLines,
resolveDirectSwitch,
writeNodeConfigDirect,
} from '../../net/relay/direct/index.js'
import { loadRegistry, summarizeNetworks, listNodes } from '../../net/relay/registry.js'
import { requireAdmin } from '../middleware/authn.js'
/** 本机配置落点(`DSHS_OVERLAY_NODE_CONFIG` 可覆盖;缺省与 `join` 的 `--config` 一致)。 */
function nodeConfigFile(): string {
const v = process.env.DSHS_OVERLAY_NODE_CONFIG
return typeof v === 'string' && v.trim() !== '' ? v.trim() : NODE_CONFIG_FILE_DEFAULT
}
/** 注册表落点(`DSHS_OVERLAY_NODES_FILE` 可覆盖;缺省 = 参数表 `NODES_REGISTRY_FILE`)。 */
function registryFile(): string {
const v = process.env.DSHS_OVERLAY_NODES_FILE
return typeof v === 'string' && v.trim() !== '' ? v.trim() : '/var/lib/dshs/overlay/nodes.json'
}
const fss = {
exists: (p: string) => existsSync(p),
read: (p: string) => readFileSync(p, 'utf8'),
write: (p: string, text: string, mode: number) => writeFileSync(p, text, { mode }),
rename: (from: string, to: string) => renameSync(from, to),
}
/** 读本机配置里的 `direct`(**缺文件 ⇒ `undefined`**,形状坏 ⇒ 抛给调用方具名化)。 */
function readLocalDirect(): { file: string; present: boolean; direct?: boolean } {
const file = nodeConfigFile()
if (!existsSync(file)) return { file, present: false }
const raw: unknown = JSON.parse(readFileSync(file, 'utf8'))
return { file, present: true, direct: directFromNodeConfig(raw) }
}
export const overlayNodeRoutes: FastifyPluginAsync = async (app) => {
/** 只读汇总:有哪些网 / 每张网几个 approved、几个 pending / 节点清单。 */
app.get('/api/admin/overlay-nodes', { preHandler: requireAdmin }, async (_request, reply) => {
const file = registryFile()
if (!existsSync(file)) {
// "这套准入还没开始用"是**合法状态** ⇒ 200 + 明确的 `present:false`(⛔ 不是 500,也不是空 200)
return reply.send({ registry: { file, present: false }, networks: [], nodes: [] })
}
let reg
try {
reg = loadRegistry(file)
} catch (err) {
return reply.code(500).send({ error: 'registry-unreadable', detail: err instanceof Error ? err.message : String(err) })
}
return reply.send({
registry: { file, present: true, version: reg.version },
networks: summarizeNetworks(reg),
nodes: listNodes(reg).map((n) => ({
network: n.network,
hostId: n.hostId,
status: n.status,
group: n.group,
appliedAt: n.appliedAt,
approvedAt: n.approvedAt,
})),
})
})
/** 直连开关现状(含**三段提示** —— 用户口径③的"设置面显示"落点)。 */
app.get('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (_request, reply) => {
let local: { file: string; present: boolean; direct?: boolean }
try {
local = readLocalDirect()
} catch (err) {
return reply.code(500).send({ error: 'node-config-bad', detail: err instanceof Error ? err.message : String(err) })
}
const state = resolveDirectSwitch(process.env, { localDirect: local.direct })
return reply.send({
envKey: DIRECT_ENV_KEY,
defaultEnabled: DEFAULT_DIRECT_ENABLED,
effective: state.enabled,
source: state.source,
raw: state.raw,
invalid: state.invalid,
nodeConfig: { file: local.file, present: local.present, direct: local.direct ?? null },
hint: directHintLines(),
})
})
/**
* 设置直连开关(**用户可设置**)。
*
* ⛔ 只动本机配置的 `direct`;⚠️ 若 env 里显式设了 `DSHS_OVERLAY_DIRECT`,它会**压过**本项
* ⇒ 回执里把这件事**明说**(否则用户会以为"改了没生效 = 平台坏了")。
*/
app.post('/api/admin/overlay-nodes/direct', { preHandler: requireAdmin }, async (request, reply) => {
const body = request.body as { enabled?: unknown } | undefined
if (body === undefined || typeof body.enabled !== 'boolean') {
return reply.code(400).send({ error: 'bad-body', detail: 'body 须为 {enabled: boolean}' })
}
const outcome = writeNodeConfigDirect(nodeConfigFile(), body.enabled, fss)
if (!outcome.ok) return reply.code(400).send({ error: outcome.reason, detail: outcome.detail })
const state = resolveDirectSwitch(process.env, { localDirect: outcome.direct })
return reply.send({
ok: true,
file: outcome.file,
direct: outcome.direct,
effective: state.enabled,
source: state.source,
envOverrides: state.source === 'env',
note:
state.source === 'env'
? `⚠️ 本机 env 里已显式设了 ${DIRECT_ENV_KEY}=${state.raw} ⇒ 它**压过**本次设置(env > 本机配置 > 缺省)`
: '已生效(重启节点服务后按本文件生效)',
})
})
}
+3
View File
@@ -62,6 +62,7 @@ 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 { overlayNodeRoutes } from './routes/overlay-nodes.js'
import { skillRoutes } from './routes/skills.js'
import { whitelistRoutes } from './routes/whitelist.js'
@@ -1163,6 +1164,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
await app.register(dshRoutes)
await app.register(domainRoutes)
await app.register(overlayRoutes)
// 序㊵(P2/S5):覆盖网络**管理面**(节点清单只读 + 直连开关读写)—— 全部走 requireAdmin
await app.register(overlayNodeRoutes)
await app.register(skillRoutes)
await app.register(whitelistRoutes)
+403
View File
@@ -0,0 +1,403 @@
/**
* 覆盖网络 **直连(打洞)** 单测(序㊵ · P2 · S5)。
*
* ⚠️ **刻意不进 `npm test`** —— 那是**硬编码文件列表**,加进去会改测试总数(既有口径:
* `test/overlay-join.test.mjs` 同样"刻意不进")。本文件单独跑:
* ```bash
* node --test test/overlay-direct.test.mjs
* ```
*
* | 用例 | 判据 | 断言的东西 |
* |---|---|---|
* | T1 | D3 |开关解析 | 缺省=开;关集/开集;**非法值 ⇒ `null`(⛔ 不静默取缺省)**;本机配置次之 |
* | T2 | D2 |关闭生效 | 关闭 ⇒ **零 UDP socket** + **零候选**(⛔ 不是"少发几条") |
* | T3 | D1 |候选准入矩阵 | 同网+白名单内接受;跨网 / 替第三人申报 / 白名单外 / 本机不在白名单 / 坏形状 / 夹带凭据 / 坏地址 / 超限 / 过期 **各自具名拒绝**;`silentRejections = 0` |
* | T4 | D1 |**准入策略与 server.ts 等价** | 读 `server.ts` 源码核对两处闸门与 `isAllowedDialer` **同策略**(⛔ 防"两处写分叉") |
* | T5 | D5 |打洞成功路径 | NAT 模拟器 + 真 `dgram` ⇒ 双向成立(`punchOk ≥ 1`) |
* | T6 | D5 |**单向不算直连** | 一个方向成立 ⇒ 判死 `one-way`(⛔ 不许把单向当成功) |
* | T7 | D6 |失败判死有界 | 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试) |
* | T8 | D6 |冷却 | 判死后二次尝试被挡(**且不开 socket**);`ms = 0` ⇒ **构造即抛** |
* | T9 | D3/D4 |join 回读 + 提示 | 本机配置读回 `direct = true`;`--direct 0` 被尊重 |
* | T10 | D4 |提示可行动 | 三段齐(谁可能连 / 怎么关 / 关掉影响什么),且**含开关键与关值** |
*/
import assert from 'node:assert/strict'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { randomBytes } from 'node:crypto'
import { test } from 'node:test'
import { createSocket } from 'node:dgram'
import { signPayloadWith } from '../lib/net/relay/identity.js'
import {
CandidateLedger,
DEFAULT_DIRECT_COOLDOWN_MS,
DEFAULT_DIRECT_ENABLED,
DIRECT_CAND_MAX_ADDRS,
DIRECT_ENV_KEY,
DIRECT_OFF_VALUES,
DirectCooldown,
DirectPath,
NODES_REGISTRY_VERSION,
PUNCH_PORT_BASE,
PUNCH_PORT_SPAN,
directFromNodeConfig,
directHintLines,
directHintText,
encodeDirectMessage,
generateAuthorityKey,
generateNodeKey,
isAllowedDialer,
isValidAddress,
networkInvitePayload,
newInviteNonce,
nodeJoinIo,
normalizeDialers,
publicKeyOfPrivate,
readNodeConfig,
resolveDirectSwitch,
runJoin,
runPunchAttempt,
runPunchPair,
writeNodeConfigDirect,
} from '../lib/net/relay/index.js'
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const root = fileURLToPath(new URL('..', import.meta.url))
const ssPath = join(root, 'src', 'net', 'relay', 'network.ts')
const serverPath = join(root, 'src', 'net', 'relay', 'server.ts')
/** 真·白名单(**走既有归一化实现** ⇒ 量的是"同一份策略"的行为)。 */
const dialers = () =>
normalizeDialers(
new Map([
['ops', new Set(['manager', 'w-106'])],
['u:5', new Set(['w-106'])],
]),
)
/** 找一个没人监听的本地 UDP 端口(用于产出 `deadline` 负腿)。 */
async function deadPort() {
const s = createSocket('udp4')
await new Promise((r) => s.bind({ port: 0, address: '127.0.0.1' }, r))
const p = s.address().port
await new Promise((r) => s.close(r))
return p
}
const candidate = (over = {}) =>
encodeDirectMessage({
hostId: 'w-106',
network: 'ops',
addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }],
ts: Date.now(),
...over,
})
// ── T1 · 开关解析 ────────────────────────────────────────────────────────────────
test('T1 开关:缺省=开|开集/关集|非法值 ⇒ null(⛔ 不静默取缺省)|本机配置次之', () => {
const d = resolveDirectSwitch({})
assert.equal(d.enabled, true, '缺省必须 = 开(用户口径②)')
assert.equal(d.source, 'default')
assert.equal(DEFAULT_DIRECT_ENABLED, true)
for (const v of ['1', 'true', 'ON', 'Yes']) {
const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v })
assert.equal(s.enabled, true, `${v} 应判开`)
assert.equal(s.source, 'env')
}
for (const v of DIRECT_OFF_VALUES) {
const s = resolveDirectSwitch({ [DIRECT_ENV_KEY]: v.toUpperCase() })
assert.equal(s.enabled, false, `${v} 应判关`)
}
// 🔴 非法值:**既不许当开也不许当关**
const bad = resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' })
assert.equal(bad.enabled, null)
assert.match(bad.invalid, /不静默取缺省/)
// 本机配置(用户设置)在 env 缺省时生效;env 显式设了就压过它
assert.equal(resolveDirectSwitch({}, { localDirect: false }).enabled, false)
assert.equal(resolveDirectSwitch({}, { localDirect: false }).source, 'node-config')
assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: '1' }, { localDirect: false }).enabled, true)
// 空串 = 未设(⛔ 不算非法值)
assert.equal(resolveDirectSwitch({ [DIRECT_ENV_KEY]: ' ' }).enabled, true)
})
// ── T2 · 关闭 ⇒ 零 socket / 零候选 ──────────────────────────────────────────────
test('T2 关闭 ⇒ 零 UDP socket + 零候选;开启才真的开', async () => {
const ctxOf = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' }
const off = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'false' }), deadlineMs: 300 })
const v = off.offerCandidate(candidate(), ctxOf)
assert.equal(v.ok, false)
assert.equal(v.reason, 'disabled')
const a = await off.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })
assert.equal(a.reason, 'disabled')
const st = off.status()
assert.equal(st.counters.udpSocketsOpened, 0, '关闭 ⇒ ⛔ 一个 UDP socket 都不许开')
assert.equal(st.counters.candidatesEmitted, 0, '关闭 ⇒ ⛔ 一条候选都不许发')
// 非法开关值同样**两种动作都不做**(避免"取值写错 ⇒ 静默当成开")
const bad = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'maybe' }), deadlineMs: 300 })
assert.equal(bad.offerCandidate(candidate(), ctxOf).reason, 'invalid-switch')
assert.equal((await bad.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })).reason, 'invalid-switch')
assert.equal(bad.status().counters.udpSocketsOpened, 0)
const on = new DirectPath({ switchState: resolveDirectSwitch({ [DIRECT_ENV_KEY]: 'true' }), deadlineMs: 300 })
assert.equal(on.offerCandidate(candidate(), ctxOf).ok, true)
await on.attempt('ops/void', [{ host: '127.0.0.1', port: await deadPort() }], { sleep })
assert.equal(on.status().counters.udpSocketsOpened, 1, '开启 ⇒ 真的绑了 1 个 UDP socket')
assert.equal(on.status().counters.candidatesEmitted, 1)
})
// ── T3 · 候选准入矩阵 ───────────────────────────────────────────────────────────
test('T3 候选准入:同网+白名单内接受;其余**逐类具名**拒绝;静默拒绝 = 0', () => {
const led = new CandidateLedger()
const inOps = { dialers: dialers(), from: { network: 'ops', hostId: 'w-106' }, selfHostId: 'manager' }
const expectOk = (raw, ctx = inOps) => {
const v = led.judge(raw, ctx)
assert.equal(v.ok, true, `应接受:${v.ok ? '' : v.reason} ${v.ok ? '' : v.detail}`)
}
const expectReject = (raw, reason, ctx = inOps) => {
const v = led.judge(raw, ctx)
assert.equal(v.ok, false)
assert.equal(v.reason, reason)
assert.ok(typeof v.detail === 'string' && v.detail !== '', '拒绝必须带人读原因(⛔ 不许只回 false)')
}
expectOk(candidate())
expectOk(candidate({ addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }, { host: '2001:db8::1', port: PUNCH_PORT_BASE + 2 }] }))
// 同名跨网**互不可见**:同一 hostId 在另一张网里是合法身份
expectOk(candidate({ network: 'u:5' }), { dialers: dialers(), from: { network: 'u:5', hostId: 'w-106' }, selfHostId: 'w-106' })
expectReject(candidate({ network: 'u:5' }), 'cross-network')
expectReject(candidate({ hostId: 'w-999' }), 'not-self-candidate')
expectReject(candidate(), 'not-a-dialer', { ...inOps, dialers: normalizeDialers(new Map([['ops', new Set(['manager'])]])) })
expectReject(candidate(), 'self-not-a-dialer', { ...inOps, selfHostId: 'w-nobody' })
expectReject('{"kind":"DIRECT_CANDIDATE"}', 'bad-shape')
expectReject('not json at all', 'bad-shape')
expectReject(
JSON.stringify({ kind: 'DIRECT_CANDIDATE', hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: PUNCH_PORT_BASE + 1 }], nodeKey: 'deadbeef' }),
'secret-field',
)
expectReject(candidate({ addrs: [{ host: 'example.com', port: 80 }] }), 'bad-address')
expectReject(candidate({ addrs: [{ host: '10.0.0.9', port: 0 }] }), 'bad-address')
expectReject(
candidate({ addrs: Array.from({ length: DIRECT_CAND_MAX_ADDRS + 1 }, (_, i) => ({ host: '10.0.0.9', port: PUNCH_PORT_BASE + i })) }),
'too-many-addrs',
)
expectReject(candidate({ ts: Date.now() - 10 * 60_000 }), 'stale')
const snap = led.snapshot()
assert.equal(snap.accepted, 3)
assert.equal(snap.rejected.length, 11, '负腿 11 条:跨网/替第三人/白名单外/本机不在白名单/坏形状×2/凭据字段/坏地址×2/超限/过期')
assert.equal(snap.silentRejections, 0, '静默拒绝必须为 0(本线老病根的不变量守卫)')
assert.equal(snap.received, 14)
// 每一条拒绝**都有具名原因**
for (const r of snap.rejected) assert.ok(typeof r.reason === 'string' && r.reason !== '')
// 白名单是**按网络分桶**的:拿 ops 的桶查 u:5 的同名 hostId 必须为假
assert.equal(isAllowedDialer(dialers(), 'ops', 'w-106'), true)
assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-106'), true)
assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-106'), false)
assert.equal(isAllowedDialer(dialers(), 'ops', 'w-999'), false)
// 地址形状:IPv4/IPv6 收,主机名不收
assert.equal(isValidAddress({ host: '10.0.0.9', port: 21100 }), true)
assert.equal(isValidAddress({ host: '1.2.3', port: 21100 }), false)
assert.equal(isValidAddress({ host: '256.1.1.1', port: 21100 }), false)
assert.equal(isValidAddress({ host: 'a.b.c', port: 21100 }), false)
})
// ── T4 · 准入策略与 server.ts 的等价性(机器守卫) ──────────────────────────────
test('T4 准入策略**复用** server.ts 的那一条(⛔ 防两处写分叉)', () => {
const netSrc = readFileSync(ssPath, 'utf8')
const srvSrc = readFileSync(serverPath, 'utf8')
// ① 唯一出口确实在 network.ts 里,且语义就是"该网桶里有没有这个 hostId"
assert.match(netSrc, /export function isAllowedDialer\(/)
assert.match(netSrc, /return map\.get\(network\)\?\.has\(hostId\) === true/)
// ② server.ts 的两处闸门与它**同策略**(同 map 、同 `=== true` 默认拒绝)
const registerGate = srvSrc.includes('const wantDialer = this.dialers.get(network)?.has(hostId) === true')
const dialGate = srvSrc.includes('if (this.dialers.get(session.network)?.has(session.hostId) !== true) {')
assert.ok(registerGate, '注册闸门(server.ts)应仍是 dialers.get(network)?.has(hostId) === true(⛔ 本棒不许改它的语义)')
assert.ok(dialGate, 'DIAL 闸门(server.ts)应仍是 dialers.get(session.network)?.has(session.hostId) !== true')
// ③ 直连模块**不许**自己再判一遍(⛔ 防"同一事实两处写")
for (const f of ['direct/candidate.ts', 'direct/index.ts', 'direct/punch.ts']) {
const src = readFileSync(join(root, 'src', 'net', 'relay', f), 'utf8')
assert.ok(!/dialers\.get\(/.test(src), `${f} 里出现了 dialers.get( ⇒ 疑似把白名单判定抄了一份`)
assert.ok(!/\.has\(hostId\)/.test(src), `${f} 里出现了 .has(hostId) ⇒ 疑似把白名单判定抄了一份`)
}
// ④ 等价性:默认拒绝(空桶 / 未列网络 / 名字错)—— 与 server.ts 同为"默认拒绝"
assert.equal(isAllowedDialer(new Map(), 'ops', 'manager'), false)
assert.equal(isAllowedDialer(dialers(), 'ops', 'MANAGER'), false, 'hostId 大小写敏感(与 HOST_RE 口径一致)')
})
// ── T5 · 打洞成功路径(真 dgram + NAT 模拟) ────────────────────────────────────
test('T5 打洞成功路径:双向都成立才算直连(punchOk ≥ 1)', async () => {
const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 2500 }, { sleep })
assert.equal(r.a.bidirectional, true, `A 侧应双向成立:${r.a.detail}`)
assert.equal(r.b.bidirectional, true, `B 侧应双向成立:${r.b.detail}`)
assert.equal(r.a.reason, 'ok')
assert.equal(r.b.reason, 'ok')
assert.equal(r.bidirectional, true)
assert.ok(r.a.recvLocal > 0 && r.b.recvLocal > 0, '两侧都必须真的收到包')
assert.equal(r.nat.a.forwardedIn > 0, true, 'NAT 侧记录:真的放行了入向包')
})
// ── T6 · 单向不算直连 ───────────────────────────────────────────────────────────
test('T6 单向 ⇒ 判死 one-way(⛔ 不许把单向当成功)', async () => {
const r = await runPunchPair({ aPeer: 'ops/w-47', bPeer: 'ops/w-106', deadlineMs: 1200, oneWay: 'a' }, { sleep })
assert.equal(r.bidirectional, false)
assert.equal(r.a.reason, 'one-way', `A 侧(只能出不能进)应判 one-way:${r.a.detail}`)
assert.equal(r.a.bidirectional, false)
assert.equal(r.b.reason, 'one-way', `B 侧(收到包但对端没收到)应判 one-way:${r.b.detail}`)
assert.equal(r.b.recvLocal > 0, true, 'B 的确收到了包 ⇒ 不能因为"有收包"就判成功')
assert.equal(r.b.peerSeen, false)
})
// ── T7 · 失败判死有界 ───────────────────────────────────────────────────────────
test('T7 对端不响应 ⇒ 窗内判死,且耗时**有界**(⛔ 不无限重试)', async () => {
const cd = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS)
const deadlineMs = 600
const r = await runPunchAttempt(
{
peer: 'ops/void',
selfPort: 0,
targets: [{ host: '127.0.0.1', port: await deadPort() }],
deadlineMs,
cooldown: cd,
bindHost: '127.0.0.1',
},
{ sleep },
)
assert.equal(r.reason, 'deadline')
assert.equal(r.ok, false)
assert.equal(r.recvLocal, 0)
assert.ok(r.sent > 1, '窗内应重发(单发一次赶不上对端同时发包)')
assert.ok(r.elapsedMs <= deadlineMs + 3 * 150, `耗时必须有界:${r.elapsedMs} ms`)
assert.equal(cd.snapshot().cooling.includes('ops/void'), true, '判死 ⇒ 该候选进冷却')
// 没有候选 ⇒ **不是**打洞失败 ⇒ ⛔ 不进冷却
const cd2 = new DirectCooldown(DEFAULT_DIRECT_COOLDOWN_MS)
const r2 = await runPunchAttempt({ peer: 'ops/none', selfPort: 0, targets: [], cooldown: cd2 }, { sleep })
assert.equal(r2.reason, 'no-address')
assert.equal(cd2.snapshot().cooling.length, 0)
})
// ── T8 · 冷却 ──────────────────────────────────────────────────────────────────
test('T8 冷却:判死后二次被挡且**不开 socket**;`ms = 0` 构造即抛', async () => {
const cd = new DirectCooldown(5_000)
let opened = 0
const port = await deadPort()
const mk = (targets) =>
runPunchAttempt(
{
peer: 'ops/void',
selfPort: 0,
targets,
deadlineMs: 300,
cooldown: cd,
bindHost: '127.0.0.1',
onSocketOpen: () => {
opened += 1
},
},
{ sleep },
)
const first = await mk([{ host: '127.0.0.1', port }])
assert.equal(first.reason, 'deadline')
assert.equal(opened, 1)
const second = await mk([{ host: '127.0.0.1', port }])
assert.equal(second.reason, 'cooldown')
assert.equal(opened, 1, '冷却命中的路径**一个 socket 都不许开**')
assert.ok(cd.snapshot().blocked >= 1)
// 🔴 0 = 重试风暴 ⇒ 构造期就炸(本线硬禁令)
assert.throws(() => new DirectCooldown(0), /必须 > 0/)
assert.throws(() => new DirectCooldown(-1), /必须 > 0/)
assert.throws(() => new DirectCooldown(Number.NaN), /必须 > 0/)
})
// ── T9/T10 · join 回读 + 提示 ─────────────────────────────────────────────────
test('T9 join 本机配置读回 direct = true,且 --direct 0 被尊重', async () => {
const tmp = mkdtempSync(join(tmpdir(), 'dshs-direct-t9-'))
const signer = generateAuthorityKey()
const issue = () => {
const doc = {
version: NODES_REGISTRY_VERSION,
network: 't9-net',
nonce: newInviteNonce(randomBytes),
issuedAt: new Date().toISOString(),
expiresAt: new Date(Date.now() + 60_000).toISOString(),
}
return { doc, sig: signPayloadWith(signer.privateKeyPem, networkInvitePayload(doc)) }
}
const io = nodeJoinIo({
fss: { existsSync, readFileSync, writeFileSync, mkdirSync },
crypto: { generateNodeKey: () => generateNodeKey(), publicKeyOfPrivate: (pem) => publicKeyOfPrivate(pem) },
os: { hostname: () => 't9-host' },
fetchImpl: async () => ({ status: 599, body: 'n/a' }),
})
const run = (name, extra) =>
runJoin(
{
network: 't9-net',
hostId: name,
invite: issue(),
trustedSigners: [signer.publicKey],
nodeKeyFile: join(tmp, `${name}.key`),
localConfigFile: join(tmp, `${name}.json`),
outFile: join(tmp, `${name}-app.json`),
...extra,
},
io,
)
const a = await run('node-a', {})
assert.equal(a.ok, true)
const cfgA = JSON.parse(readFileSync(join(tmp, 'node-a.json'), 'utf8'))
assert.equal(cfgA.direct, true, '缺省必须写进本机配置 = 开(用户口径②)')
assert.equal(directFromNodeConfig(cfgA), true)
assert.match(a.steps.find((s) => s.step === 'local-config').detail, /直连\(打洞\)=开/)
const b = await run('node-b', { direct: false })
assert.equal(b.ok, true)
assert.equal(directFromNodeConfig(JSON.parse(readFileSync(join(tmp, 'node-b.json'), 'utf8'))), false)
// 管理面写回:**只改 direct 一个字段**,其余原样;缺失文件 ⇒ 具名失败(⛔ 不凭空创建)
const file = join(tmp, 'node-a.json')
const before = JSON.parse(readFileSync(file, 'utf8'))
const writer = {
exists: existsSync,
read: (p) => readFileSync(p, 'utf8'),
write: (p, t, mode) => writeFileSync(p, t, { mode }),
rename: renameSync,
}
const w = writeNodeConfigDirect(file, false, writer)
assert.equal(w.ok, true)
const after = JSON.parse(readFileSync(file, 'utf8'))
assert.equal(after.direct, false)
assert.deepEqual({ ...after, direct: true }, before, '⛔ 除 direct 外任何字段都不许被动到')
const miss = writeNodeConfigDirect(join(tmp, 'nope.json'), true, writer)
assert.equal(miss.ok, false)
assert.equal(miss.reason, 'node-config-missing')
// 形状坏 ⇒ 具名
writeFileSync(join(tmp, 'bad.json'), '[1,2,3]')
assert.throws(() => readNodeConfig(join(tmp, 'bad.json'), { exists: () => true, read: (p) => readFileSync(p, 'utf8') }), /不是 JSON 对象/)
})
test('T10 提示文案必须**可行动**:三段齐 + 含开关键与关值', () => {
const lines = directHintLines()
assert.equal(lines.length, 3)
const text = directHintText()
assert.ok(text.includes('同一张覆盖网') && text.includes('白名单'), '①段要写清"谁可能连进来"')
assert.ok(text.includes(DIRECT_ENV_KEY), '②段要写清"怎么关"(含开关键名)')
assert.ok(DIRECT_OFF_VALUES.some((v) => text.includes(v)), '②段要给出具体的关闭取值')
assert.ok(text.includes('不影响'), '③段要写清"关掉不影响什么"')
assert.ok(text.includes('中继'), '③段要写清回落路径')
// ⛔ 禁止只写"已启用直连"
assert.ok(!/^已启用直连$/.test(text.trim()))
// 编码侧的结构性防线:**只吃白名单字段** ⇒ 多给的任何字段(含凭据)都进不了载荷
const encoded = JSON.parse(encodeDirectMessage({ hostId: 'w-106', network: 'ops', addrs: [{ host: '10.0.0.9', port: 1 }], secret: 'x' }))
assert.deepEqual(Object.keys(encoded).sort(), ['addrs', 'hostId', 'kind', 'network', 'ts'])
assert.equal(encoded.secret, undefined, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)')
})
+634
View File
@@ -0,0 +1,634 @@
/**
* 覆盖网络 · **序㊱ 「节点一键加入与分组准入」P1(S1–S4)** 单测。
*
* ## 这个文件要回答的六个问题(= `交接单_节点一键加入与分组准入_20260918.md §5` 的 J1–J6)
* | 组 | 判据 | 落在哪个 J |
* |---|---|---|
* | A | **邀请凭据**:网络绑定 / 有效期 / 受信签名者 / 载荷形状,四件都**具名**拒 | J1 前半 |
* | B | **一次性**:同一 nonce 二次使用被拒;**并发**下**恰好一台**拿到(内核原子占位) | **J1** |
* | C | **网注册表**:`pending` 不进白名单、`approved` 才进;批准不存在的节点被具名拒 | J2/J4 |
* | D | **白名单派生**:分桶形状 + 与 `normalizeDialers` **同一入口** + **跨网零共享**(结构性) | **J5** |
* | E | **join 编排**:四步真跑;申请单**字节级**不含私钥;失败**具名且带步迹** | J4/J6 |
* | F | **「⛔ 不许静默拒绝」的机器判据**:join / registry 产物里**零空 `catch` 块** | §9-5 |
*
* ## 🔴 「先红后绿」在本文件里的落点(⛔ 不是口头声明)
* `lib/net/relay/registry.js` 里把 `consumeNonce` 的 `'wx'` 改成 `'w'`(= 不再原子占位)
* ⇒ **B 组的 `T9`(并发恰好一台)与 `T8`(二次使用)转红**,其余全绿;改回 ⇒ 全绿。
* 该实验的原文级输出记录在执行棒回报 §8.2(⚠️ 破坏的是 `lib` 产物、`src` 未动)。
*
* 运行:`node --test test/overlay-join.test.mjs`(Node ≥ 22;测的是 `lib/` 产物,先 `npm run build`)。
* ⚠️ **刻意不进 `npm test`** —— 那个脚本是**硬编码文件列表**,加进去会改测试总数(与序㉔/㉘/㉚ 同纪律)。
*
* @module test/overlay-join
*/
import assert from 'node:assert/strict'
import { execFileSync } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { test } from 'node:test'
import { fileURLToPath } from 'node:url'
import { generateAuthorityKey, generateNodeKey, publicKeyOfPrivate, signPayloadWith } from '../lib/net/relay/identity.js'
import {
NETWORK_INVITE_TAG,
applyApplication,
approveNode,
auditDerivation,
consumeNonce,
deriveDialers,
deriveDropIn,
emptyRegistry,
loadRegistry,
newInviteNonce,
parseNetworkInvite,
parseRegistry,
removeNode,
saveRegistry,
summarizeNetworks,
verifyNetworkInvite,
} from '../lib/net/relay/registry.js'
import { JOIN_STEPS, joinReasonOfInvite, parseApplication, readApplicationFile, runJoin } from '../lib/net/relay/join.js'
import { normalizeDialers } from '../lib/net/relay/network.js'
// ── 夹具 ────────────────────────────────────────────────────────────────────
const tmps = []
function mkTmp(tag) {
const d = mkdtempSync(join(tmpdir(), `dshs-join-${tag}-`))
tmps.push(d)
return d
}
process.on('exit', () => {
for (const d of tmps) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* 临时目录清理失败不影响判据 */
}
}
})
/** 一把一次性使用的**测试签名者**(⛔ 与生产密钥无关;用完即丢)。 */
function signerFixture() {
const k = generateAuthorityKey()
return { pem: k.privateKeyPem, pub: k.publicKey }
}
function inviteFixture(signer, network, opts = {}) {
const doc = {
version: 1,
network,
nonce: opts.nonce ?? newInviteNonce(randomBytes),
issuedAt: new Date(opts.now ?? Date.now()).toISOString(),
expiresAt: opts.expiresAt ?? new Date((opts.now ?? Date.now()) + 10 * 60 * 1000).toISOString(),
}
return { doc, sig: signPayloadWith(signer.pem, invitePayloadOf(doc)) }
}
/** 与 `registry.ts#networkInvitePayload` 同一条规范拼接(⛔ 测试里不另造口径)。 */
function invitePayloadOf(doc) {
return [
NETWORK_INVITE_TAG,
`version=${String(doc.version)}`,
`network=${doc.network}`,
`nonce=${doc.nonce}`,
`issuedAt=${doc.issuedAt}`,
`expiresAt=${doc.expiresAt}`,
].join('\n')
}
/** 一个**完全离线**的 `JoinIo`:真写临时目录,⛔ 不发网络。 */
function fakeIo(calls = {}) {
return {
exists: existsSync,
read: (p) => readFileSync(p, 'utf8'),
write: (p, text, mode) => {
mkdirSync(join(p, '..'), { recursive: true })
writeFileSync(p, text, { mode })
},
generateNodeKey,
publicKeyOfPrivate,
hostname: () => 'test-host',
post: async (url, body) => {
calls.posts = calls.posts ?? []
calls.posts.push({ url, body })
return calls.postReply ?? { status: 200, body: '{"ok":true}' }
},
}
}
function joinOpts(dir, network, invite, over = {}) {
return {
network,
hostId: 'node-a',
invite,
trustedSigners: [over.signerPub],
nodeKeyFile: join(dir, 'node.key'),
localConfigFile: join(dir, 'node.json'),
outFile: join(dir, 'application.json'),
...over,
}
}
// ── A 组 · 邀请凭据(S2)─────────────────────────────────────────────────────
test('A1 正当邀请:验签通过(网络/有效期/形状四件都过)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, true, `期望通过,实际 ${JSON.stringify(v)}`)
assert.equal(v.doc.network, 'ops')
})
test('A2 坏签名 ⇒ 具名拒(invite-signature-mismatch,由 joinReasonOfInvite 映射)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, `${inv.sig.slice(0, -6)}AAAAAA`, [s.pub], { network: 'ops' })
assert.equal(v.ok, false)
assert.notEqual(v.reason, 'ok')
// 映射必须落在**具名**的 invite-* 上,⛔ 不许落成通用失败
assert.match(joinReasonOfInvite(v.reason), /^invite-/)
})
test('A3 过期 ⇒ expired(有效期是真判据)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops', { now: Date.now() - 3600_000, expiresAt: new Date(Date.now() - 60_000).toISOString() })
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'expired')
assert.equal(joinReasonOfInvite(v.reason), 'invite-expired')
})
test('A4 网络绑定:拿 ops 的邀请去加入别的网 ⇒ network-mismatch(⛔ 不静默放行)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [s.pub], { network: 'u:5' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'network-mismatch')
})
test('A5 无受信签名者 ⇒ 不可验 = 不接受(⛔ 不降级)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const v = verifyNetworkInvite(inv.doc, inv.sig, [], { network: 'ops' })
assert.equal(v.ok, false)
assert.equal(v.reason, 'no-trusted-keys')
assert.equal(joinReasonOfInvite(v.reason), 'invite-no-trusted-signer')
})
test('A6 载荷形状非法(nonce 不是 32 hex)⇒ bad-payload(⛔ 不猜、不补默认值)', () => {
assert.equal(parseNetworkInvite({ version: 1, network: 'ops', nonce: 'short', issuedAt: 'x', expiresAt: '' }), undefined)
assert.equal(parseNetworkInvite({ version: 1, network: '', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined)
assert.equal(parseNetworkInvite(null), undefined)
// ⛔ 裸 `u` 是保留字:不是合法网名 ⇒ 邀请形状也不成立
assert.equal(parseNetworkInvite({ version: 1, network: 'u', nonce: 'a'.repeat(32), issuedAt: 'x', expiresAt: '' }), undefined)
})
test('A7 🔴 邀请凭据载荷**逐字不含任何密钥本体**(只有网 / nonce / 有效期)', () => {
const s = signerFixture()
const inv = inviteFixture(s, 'ops')
const text = JSON.stringify(inv)
const privBody = s.pem.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('')
assert.ok(privBody.length > 40, '夹具本身要有可探的私钥主体')
assert.equal(text.includes(privBody), false, '⛔ 邀请里出现了签名者私钥')
// 键集合也钉死:多一个 `key` 字段就是回归
assert.deepEqual(Object.keys(inv.doc).sort(), ['expiresAt', 'issuedAt', 'network', 'nonce', 'version'])
})
// ── B 组 · 一次性(S2 · J1)──────────────────────────────────────────────────
test('B1 同一 nonce 第二次使用 ⇒ invite-already-used(一次性成立)', () => {
const dir = mkTmp('ledger')
const led = { dir: join(dir, 'consumed') }
const n = newInviteNonce(randomBytes)
const first = consumeNonce(led, n, 'first')
const second = consumeNonce(led, n, 'second')
assert.equal(first.ok, true, `首次应成功:${JSON.stringify(first)}`)
assert.equal(second.ok, false)
assert.equal(second.reason, 'invite-already-used')
// 台账文件确实落了盘(⇒ "失败"不是因为"根本没写")
assert.equal(readdirSync(led.dir).length, 1)
})
test('B2 🔴 并发:12 个进程同时抢同一个 nonce ⇒ **恰好 1 个**拿到', () => {
const dir = mkTmp('race')
const ledDir = join(dir, 'consumed')
mkdirSync(ledDir, { recursive: true })
const nonce = newInviteNonce(randomBytes)
// 真并发:走**独立进程**(同进程内的"并发"是假并发,⛔ 判不出竞态)
const worker = join(dir, 'worker.cjs')
writeFileSync(
worker,
[
"const reg = require(process.env.REG_LIB)",
"const r = reg.consumeNonce({ dir: process.env.NONCE_DIR }, process.env.NONCE, 'race')",
"process.stdout.write(r.ok ? 'ok' : r.reason)",
].join('\n'),
'utf8',
)
const regLib = join(process.cwd(), 'lib', 'net', 'relay', 'registry.js')
const results = []
for (let i = 0; i < 12; i++) {
results.push(
execFileSync(process.execPath, [worker], {
encoding: 'utf8',
env: { ...process.env, NONCE_DIR: ledDir, NONCE: nonce, REG_LIB: regLib },
}),
)
}
const oks = results.filter((r) => r === 'ok').length
assert.equal(oks, 1, `⛔ 竞态未守住:ok=${oks}(须恰好 1)|读数 ${JSON.stringify(results)}`)
assert.equal(results.filter((r) => r === 'invite-already-used').length, 11)
assert.equal(readdirSync(ledDir).length, 1)
})
test('B3 nonce 形状非法 ⇒ 拒(⛔ 不许"非法也当未用过"放过去)', () => {
const dir = mkTmp('bad-nonce')
const led = { dir: join(dir, 'consumed') }
const r = consumeNonce(led, 'ZZZZ', 'x')
assert.equal(r.ok, false)
assert.equal(r.reason, 'invite-already-used')
})
// ── C 组 · 网注册表(S1)────────────────────────────────────────────────────
test('C1 空注册表 ⇒ 派生为空 ⇒ 默认拒绝一切拨号(不是"默认放行")', () => {
const r = emptyRegistry()
const d = deriveDialers(r)
assert.equal(d.size, 0)
assert.equal(auditDerivation(r).ok, true)
assert.equal(summarizeNetworks(r).length, 0)
})
test('C2 收单只产生 pending;🔴 pending **不进**派生白名单', () => {
const r = emptyRegistry()
const e = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
assert.equal(e.ok, true)
assert.equal(e.record.status, 'pending')
assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false, '⛔ 待批节点不许进白名单')
})
test('C3 批准才进白名单;移除即退出', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
const ap = approveNode(r, 'ops', 'w-108', 'T1')
assert.equal(ap.ok, true)
assert.equal(deriveDialers(r).get('ops')?.has('w-108'), true)
const rm = removeNode(r, 'ops', 'w-108')
assert.equal(rm.ok, true)
assert.equal(deriveDialers(r).get('ops')?.has('w-108') ?? false, false)
})
test('C4 🔴 批准一个**没申请过**的节点 ⇒ unknown-node(⛔ 不凭空批准)', () => {
const r = emptyRegistry()
const ap = approveNode(r, 'ops', 'never-applied', 'T1')
assert.equal(ap.ok, false)
assert.equal(ap.reason, 'unknown-node')
// 非法网名也要**具名**,⛔ 不抛异常
assert.deepEqual(approveNode(r, 'u', 'x', 'T1'), { ok: false, reason: 'unknown-network' })
})
test('C5 已批准节点重复申请 ⇒ **保持 approved**(幂等:重装 / 重跑 join 不该被踢回待批)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'w-108', 'T1')
const again = applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'b'.repeat(64), at: 'T2' })
assert.equal(again.ok, true)
assert.equal(again.record.status, 'approved')
assert.equal(again.record.approvedAt, 'T1', '批准时刻不该被重申请刷新')
})
test('C6 解析**严格**:status 拼错 / 键与记录不符 ⇒ undefined(⛔ 不静默修复)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
assert.notEqual(parseRegistry(r), undefined)
const bad = JSON.parse(JSON.stringify(r))
bad.nodes['ops/w-108'].status = 'aproved'
assert.equal(parseRegistry(bad), undefined)
const misfiled = JSON.parse(JSON.stringify(r))
misfiled.nodes['u:5/w-108'] = misfiled.nodes['ops/w-108']
assert.equal(parseRegistry(misfiled), undefined, '键(逻辑名)与记录里的网/节点名必须一致')
})
test('C7 落盘往返:稳定排序 ⇒ 无改动时字节级一致', () => {
const dir = mkTmp('roundtrip')
const file = join(dir, 'nodes.json')
const r = emptyRegistry()
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'c'.repeat(64), at: 'T0' })
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
saveRegistry(file, r)
const first = readFileSync(file, 'utf8')
const back = loadRegistry(file)
saveRegistry(file, back)
assert.equal(readFileSync(file, 'utf8'), first, '往返必须逐字一致(否则 diff 永远"有改动")')
assert.equal(loadRegistry(join(dir, 'absent.json')).nodes && Object.keys(loadRegistry(join(dir, 'absent.json')).nodes).length, 0)
})
// ── D 组 · 白名单派生(S4 · J5)─────────────────────────────────────────────
test('D1 分桶形态:两张网各自独立,同一 hostId 在两网**不串桶**', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' })
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'pc-1', 'T1')
approveNode(r, 'u:5', 'pc-1', 'T1')
const d = deriveDialers(r)
assert.equal(d.get('ops')?.has('pc-1'), true)
assert.equal(d.get('u:5')?.has('pc-1'), true)
// 🔴 独立性判据:**移除一张网里的它**,另一张网**不受影响**
removeNode(r, 'u:5', 'pc-1')
assert.equal(deriveDialers(r).get('ops')?.has('pc-1'), true, '⛔ 移除 u:5 的 pc-1 连带把 ops 的也删了')
assert.equal(deriveDialers(r).get('u:5')?.has('pc-1') ?? false, false)
})
test('D2 🔴 派生结果**必须能被 normalizeDialers 吃**(与手写 drop-in 同一条归一化入口)', () => {
const r = emptyRegistry()
for (const [net, host] of [
['ops', 'w-108'],
['u:5', 'pc-1'],
['lab-net', 'lab-1'],
]) {
applyApplication(r, { network: net, hostId: host, nodeKey: 'd'.repeat(64), at: 'T0' })
approveNode(r, net, host, 'T1')
}
const derived = deriveDialers(r)
const normalized = normalizeDialers(derived)
assert.equal(normalized.size, 3, `期望 3 张网,实际 ${[...normalized.keys()].join(',')}`)
assert.equal(normalized.get('ops')?.has('w-108'), true)
assert.equal(normalized.get('u:5')?.has('pc-1'), true)
assert.equal(normalized.get('lab-net')?.has('lab-1'), true)
// ⛔ 跨网串桶 = 结构性隔离失效;这里钉死"每张网只看得到自己的成员"
assert.equal(normalized.get('ops')?.has('pc-1') ?? false, false)
assert.equal(normalized.get('u:5')?.has('w-108') ?? false, false)
})
test('D3 auditDerivation:人工造"错桶" ⇒ misfiled **点名**(跨网零共享的机器判据)', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'ops', hostId: 'w-108', nodeKey: 'a'.repeat(64), at: 'T0' })
approveNode(r, 'ops', 'w-108', 'T1')
applyApplication(r, { network: 'u:5', hostId: 'pc-1', nodeKey: 'b'.repeat(64), at: 'T0' })
approveNode(r, 'u:5', 'pc-1', 'T1')
assert.equal(auditDerivation(r).ok, true)
// 造错桶:把 u:5 的 pc-1 塞进 ops 桶(模拟"实现把两张网合并了")
const bad = new Map(deriveDialers(r))
bad.get('ops').add('pc-1')
const audit = auditDerivation(r, bad)
assert.equal(audit.ok, false)
assert.ok(audit.misfiled.some((m) => m.includes('ops/pc-1')), `misfiled 应点名 ops/pc-1:${JSON.stringify(audit.misfiled)}`)
// 另一类错:集合比 approved 多了/少了
const short = new Map()
short.set('ops', new Set())
short.set('u:5', new Set(['pc-1']))
const audit2 = auditDerivation(r, short)
assert.equal(audit2.ok, false)
assert.ok(audit2.mismatches.some((m) => m.includes('ops') && m.includes('缺[w-108]')), JSON.stringify(audit2.mismatches))
})
test('D4 deriveDropIn 用**逻辑名**形态、且写明"手写 drop-in 是应急通道"', () => {
const r = emptyRegistry()
applyApplication(r, { network: 'lab-net', hostId: 'lab-1', nodeKey: 'e'.repeat(64), at: 'T0' })
approveNode(r, 'lab-net', 'lab-1', 'T1')
const content = deriveDropIn(r, { network: 'lab-net' })
assert.match(content, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, content)
assert.match(content, /应急通道/, '必须写明"手写 drop-in 是应急通道,⛔ 不删"')
assert.match(content, /\[Service\]/, '必须是 systemd drop-in 形态')
})
// ── E 组 · join 编排(S3 · J4/J6)──────────────────────────────────────────
test('E1 四步真跑全绿:真生成密钥 + 真落盘 + 真出申请单', async () => {
const dir = mkTmp('join-ok')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true, `期望成功:${JSON.stringify(out)}`)
assert.deepEqual(
out.steps.map((x) => x.step),
[...JOIN_STEPS],
'步迹必须覆盖四步且顺序一致',
)
assert.equal(existsSync(join(dir, 'node.key')), true)
assert.equal(existsSync(join(dir, 'node.json')), true)
assert.equal(existsSync(join(dir, 'application.json')), true)
})
test('E2 🔴 申请单**字节级**不含私钥,但含公钥(私钥不出机)', async () => {
const dir = mkTmp('join-leak')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
const app = readFileSync(join(dir, 'application.json'), 'utf8')
const priv = readFileSync(join(dir, 'node.key'), 'utf8')
const body = priv.split('\n').filter((l) => l !== '' && !l.startsWith('-----')).join('')
assert.ok(body.length > 40)
assert.equal(app.includes(body), false, '⛔ 申请单里出现了节点私钥本体')
assert.equal(app.includes(out.nodeKey), true, '公钥必须在(否则控制面无法登记)')
// 本机配置里也只能记"私钥在哪",不能记私钥本身
assert.equal(readFileSync(join(dir, 'node.json'), 'utf8').includes(body), false, '⛔ 本机配置里出现了私钥本体')
})
test('E3 节点密钥已存在 ⇒ **复用**(重跑 join 不换钥匙,公钥不变)', async () => {
const dir = mkTmp('join-reuse')
const s = signerFixture()
const first = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(first.ok, true)
const before = readFileSync(join(dir, 'node.key'), 'utf8')
const second = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(second.ok, true)
assert.equal(second.nodeKey, first.nodeKey, '⛔ 重跑 join 换了公钥 ⇒ 会导致控制面登记与实物不符')
assert.equal(readFileSync(join(dir, 'node.key'), 'utf8'), before)
assert.match(second.steps[1].detail, /复用既有/)
})
test('E4 ⛔ 既没 --portal 也没 --out ⇒ register-malformed(不静默成功)', async () => {
const dir = mkTmp('join-nochan')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '' }), fakeIo())
assert.equal(out.ok, false)
assert.equal(out.step, 'register')
assert.equal(out.reason, 'register-malformed')
})
test('E5 HTTP 通道:4xx 必须带回控制面的原因码(⛔ 不许说成"网络不通")', async () => {
const dir = mkTmp('join-http')
const s = signerFixture()
const io = fakeIo({ postReply: { status: 409, body: '{"error":"invite-already-used"}' } })
const out = await runJoin(
joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/dshs-overlay/join' }),
io,
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'register-rejected')
assert.match(out.detail, /invite-already-used/, '控制面的原因码必须原样带回来')
})
test('E6 HTTP 通道:连不上 ⇒ register-unreachable(与"被拒"可分)', async () => {
const dir = mkTmp('join-http2')
const s = signerFixture()
const io = fakeIo()
io.post = async () => {
throw new Error('ECONNREFUSED')
}
const out = await runJoin(
joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub, outFile: '', portalUrl: 'https://cp.example/x' }),
io,
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'register-unreachable')
})
test('E7 失败**带步迹**:停在第一步时能看到是哪一步、哪种原因', async () => {
const dir = mkTmp('join-trace')
const s = signerFixture()
const bad = inviteFixture(s, 'ops')
bad.sig = `${bad.sig.slice(0, -6)}AAAAAA`
const out = await runJoin(joinOpts(dir, 'ops', bad, { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, false)
assert.equal(out.step, 'verify-invite')
assert.equal(out.steps.length, 1)
assert.equal(out.steps[0].ok, false)
// 🔴 第一步失败 ⇒ ⛔ 不许产生后面几步的副作用
assert.equal(existsSync(join(dir, 'node.key')), false, '⛔ 凭据没过就生成了密钥(无谓副作用)')
})
test('E8 无受信签名者 ⇒ 连密钥都不生成(不可验 = 不接受,且不浪费副作用)', async () => {
const dir = mkTmp('join-nosigner')
const s = signerFixture()
const out = await runJoin(
{ ...joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), trustedSigners: [] },
fakeIo(),
)
assert.equal(out.ok, false)
assert.equal(out.reason, 'invite-no-trusted-signer')
assert.equal(existsSync(join(dir, 'node.key')), false)
})
// ── F 组 · 机器判据:「⛔ 不许静默拒绝」──────────────────────────────────────
test('F1 join / registry 产物里**零空 catch 块**(静默失败的可断言面)', () => {
const files = [join(process.cwd(), 'lib', 'net', 'relay', 'join.js'), join(process.cwd(), 'lib', 'net', 'relay', 'registry.js')]
const emptyCatch = /catch\s*(\([^)]*\))?\s*\{\s*\}/
// ⚠️ **先剥注释再扫** —— 本文件的 doc 注释里**引用**了 `catch {}` 这个写法(在讲"我们不用它"),
// 不剥就会把自己讲道理的那句话判成违规(= 判据打在文字上,不打在代码上)。
const stripComments = (text) =>
text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1')
const offenders = []
for (const f of files) {
assert.equal(existsSync(f), true, `缺产物 ${f}(先 npm run build)`)
const code = stripComments(readFileSync(f, 'utf8'))
if (emptyCatch.test(code)) offenders.push(f)
}
assert.deepEqual(offenders, [], `⛔ 出现空 catch(= 静默拒绝):${offenders.join(', ')}`)
})
test('F2 节点私钥落点权限:**只在 Linux 上可判**,Windows 上判据必须说"不可判"而非 PASS', async () => {
const dir = mkTmp('join-mode')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
const mode = statSync(join(dir, 'node.key')).mode & 0o777
if (process.platform === 'linux') {
assert.equal(mode.toString(8), '600', `⛔ 私钥权限不是 600:${mode.toString(8)}`)
} else {
// 🔴 「没测到」与「不成立」必须可分 —— ⛔ 不许把"这台机器测不出来"当绿
assert.notEqual(process.platform, 'linux')
assert.ok(true, `platform=${process.platform} ⇒ 权限判据**本平台不可判**(真机腿 = 47 上 stat -c %a 实测)`)
}
})
test('E9 CLI 回环:join 产出的申请单必须能被控制面 apply 的解析读回(真机首轮栽在这)', async () => {
const dir = mkTmp('join-roundtrip')
const s = signerFixture()
const out = await runJoin(joinOpts(dir, 'ops', inviteFixture(s, 'ops'), { signerPub: s.pub }), fakeIo())
assert.equal(out.ok, true)
// 🔴 这两步合起来就是控制面 `apply` 的真实路径:读文件 → 解析申请单
const app = readApplicationFile(join(dir, 'application.json'))
assert.equal(app.hostId, 'node-a')
assert.equal(app.network, 'ops')
assert.equal(app.nodeKey, out.nodeKey)
const v = verifyNetworkInvite(app.invite.doc, app.invite.sig, [s.pub], { network: 'ops' })
assert.equal(v.ok, true, `申请单里内嵌的邀请必须能验通:${JSON.stringify(v)}`)
const led = { dir: join(dir, 'consumed') }
assert.equal(consumeNonce(led, v.doc.nonce, 'apply').ok, true)
assert.equal(consumeNonce(led, v.doc.nonce, 'apply again').ok, false)
const r = emptyRegistry()
const e = applyApplication(r, { network: app.network, hostId: app.hostId, nodeKey: app.nodeKey, at: 'T0' })
assert.equal(e.ok, true)
assert.equal(e.record.status, 'pending', '⛔ 收单只产生 pending(批准权在控制面)')
// 形状严格:缺 invite / nodeKey 非 64 hex / 空对象 ⇒ undefined
assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x'.repeat(64), appliedAt: 't', invite: {} }), undefined)
assert.equal(parseApplication({ hostId: 'a', network: 'ops', nodeKey: 'x', appliedAt: 't', invite: { doc: {}, sig: 'x' } }), undefined)
assert.equal(parseApplication({}), undefined)
assert.equal(parseApplication(null), undefined)
})
// ── E10:**CLI 级**真回环(真子进程,⛔ 不是调库)─────────────────────────────
// 🔴 为什么必须有这条:E9 是**调库**回环,它绕过了 CLI 的**参数解析**,
// 所以「`apply --file <申请单>` 把注册表文件也指到申请单」这个真机缺陷
// **E9 抓不到**(真机首轮就栽在这:收单炸在 `loadReg`,而 nonce 已被占位
// ⇒ 表现为「收单失败 + 同一张邀请再也用不了」)。
// 本用例只走 `node <cli> …`,任何参数键名混用都会在这里具名转红。
test('E10 CLI 真回环:`apply --file` ⛔ 不得改写注册表路径(--file 与 --registry 两键分离)', () => {
const repo = resolve(fileURLToPath(new URL('..', import.meta.url)))
const admit = join(repo, 'scripts', 'overlay-node-admit.cjs')
const joinCli = join(repo, 'scripts', 'overlay-node-join.cjs')
const keyring = join(repo, 'scripts', 'overlay-keyring.cjs')
const root = mkTmp('cli-roundtrip')
const regDir = join(root, 'reg')
const run = (file, args) =>
execFileSync(process.execPath, [file, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] })
const runFail = (file, args) => {
try {
run(file, args)
return ''
} catch (err) {
return `${err.stdout ?? ''}${err.stderr ?? ''}`
}
}
// (1) 一次性测试签名者 / init / 签发邀请
run(keyring, ['init-signer', '--key', join(root, 'signer.key')])
const pub = readFileSync(join(root, 'signer.key.pub'), 'utf8').trim()
run(admit, ['init', '--dir', regDir])
run(admit, [
'issue-invite', '--network', 'lab-net',
'--signer-key', join(root, 'signer.key'),
'--out', join(root, 'invite.json'), '--dir', regDir,
])
// (2) 节点一条命令 join ⇒ 申请单
run(joinCli, [
'--network', 'lab-net', '--host', 'lab-1',
'--invite', join(root, 'invite.json'), '--signer-pub', pub,
'--key', join(root, 'node.key'), '--config', join(root, 'node.json'),
'--out', join(root, 'app.json'),
])
// (3) 🔴 判据本体:`apply --file <申请单>` 必须**成功**,且注册表仍落在 regDir
const outApp = run(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir])
assert.match(outApp, /已收单/, '⛔ `apply --file` 必须成功收单')
const regFile = join(regDir, 'nodes.json')
assert.equal(existsSync(regFile), true, '⛔ 注册表必须落在 `--dir` 下的 nodes.json')
const saved = loadRegistry(regFile)
assert.equal(saved.nodes['lab-net/lab-1']?.status, 'pending', '⛔ 收单只产生 pending')
// (4) 同一张邀请二次收单 ⇒ 必须是 **invite-already-used**;
// ⛔ 一旦出现「注册表 …形状非法」就说明 `--file` 又被当成注册表文件了(键名混用回归)
const err2 = runFail(admit, ['apply', '--file', join(root, 'app.json'), '--signer-pub', pub, '--dir', regDir])
assert.match(err2, /invite-already-used/, '⛔ 二次收单必须具名拒 invite-already-used')
assert.doesNotMatch(err2, /形状非法/, '⛔ 出现"形状非法"= `--file` 键名混用回归')
// (5) 批准 ⇒ 派生里必须出现这条拨号
run(admit, ['approve', '--network', 'lab-net', '--host', 'lab-1', '--dir', regDir])
const d = run(admit, ['derive', '--network', 'lab-net', '--dir', regDir])
assert.match(d, /Environment="DSHS_RELAY_DIALERS=lab-net\/lab-1"/, '⛔ approved 必须进派生白名单')
// (6) `--registry` 是注册表文件的唯一覆盖键 —— 指到别处 ⇒ 那里为空表(= 确实换了文件)
const dAlt = run(admit, ['list', '--registry', join(root, 'alt.json'), '--dir', regDir])
assert.match(dAlt, /0 张网 0 个节点/, '⛔ --registry 必须真的改写注册表文件路径')
})