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
+6604 -78

No files matched your search

+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 必须真的改写注册表文件路径')
})