/** * 覆盖网络 **直连(打洞)** 单测(序㊵ · 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-2'])], ['u:5', new Set(['w-2'])], ]), ) /** 找一个没人监听的本地 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-2', 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-2' }, 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-2' }, 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-2' }, selfHostId: 'w-2' }) 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-2', 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-2'), true) assert.equal(isAllowedDialer(dialers(), 'u:5', 'w-2'), true) assert.equal(isAllowedDialer(dialers(), 'u:7', 'w-2'), 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-1', bPeer: 'ops/w-2', 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-1', bPeer: 'ops/w-2', 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-2', 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, '⛔ 载荷里不可能夹带凭据字段(构造侧结构性排除)') })