/** * 覆盖网络 · **② 引导三级链(P0-2)** 单测 —— 地址是"编译期常量"还是"可在线轮换的下发物"。 * * ## 这个文件要回答的唯一问题 * 「换域名 / 换机器」时,**已经装出去的客户端能不能自己跟上来**? * `§A2` 的判据很硬:**不能**就只能靠"所有客户端升级重装",那是灾难。 * 所以本文件的验收标准不是"能取到地址",而是四条**行为断言**: * * 1. **冷启动只靠内置种子就能起来**(清 env + 清缓存 ⇒ 走种子 → 取签名目录 → 拿到地址); * 2. **引导地址可在线轮换**:目录里改 `bootstrap[]` ⇒ 下一次刷新**从新地址取**,**不重装、不升级**; * 3. **签名不对 ⇒ 失败关闭**:既不写缓存、也不拿它的地址去连(受信公钥为空同样拒绝); * 4. **降级不越权**:取不到目录时可以用**过期但签名有效**的缓存 / 种子地址本身, * 但**绝不**因此接受一份未签名 / 签名不符的目录。 * * 运行:`node --test test/overlay-bootstrap.test.mjs`(Node ≥ 22;测 `lib/` 产物,先 `npm run build`)。 * * @module test/overlay-bootstrap */ import assert from 'node:assert/strict' import { generateKeyPairSync } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' import { test } from 'node:test' import { DEFAULT_OVERLAY_SEED, DIRECTORY_PATH, DIRECTORY_VERSION, buildDirectoryDocument, directoryPayload, directoryUrlFor, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory, } from '../lib/net/relay/index.js' // 序④(443/TCP 兜底 · L1):地址覆盖 —— 直接测模块(未从 index 再导出,避免动 index 的公共面)。 import { currentAddrOverrides, ensureOverlayAddrOverrides, parseAddrOverrides, resetOverlayAddrOverridesForTest, } from '../lib/net/relay/addr-override.js' import { createServer as createNetServer } from 'node:net' import { createRequire } from 'node:module' // ── 夹具 ────────────────────────────────────────────────────────────────── /** 生成一对 Ed25519;同时给 PEM 与**裸 32 字节 hex**(两条解析路径都要能验签)。 */ function makeKeys() { const { publicKey, privateKey } = generateKeyPairSync('ed25519') const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() const spki = publicKey.export({ type: 'spki', format: 'der' }) return { privatePem, publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(), publicRawHex: Buffer.from(spki.subarray(spki.length - 32)).toString('hex'), } } /** 一份自签目录(默认 relays / bootstrap 都是给定的 origin 形态)。 */ function signedDoc(origin, keyPem, overrides = {}) { const doc = buildDirectoryDocument({ relays: overrides.relays ?? [origin], bootstrap: overrides.bootstrap ?? [origin], network: overrides.network ?? 'ops', now: overrides.now ?? Date.now(), refreshAfterSeconds: overrides.refreshAfterSeconds ?? 300, }) return { doc, sig: signDirectory(doc, keyPem) } } /** * 起一个**真的**目录端点(`node:http`),请求任意路径都返回当前 `doc+sig`。 * 用真 HTTP 而不是 mock —— 本文件要证明的是"冷启动 / 轮换"这类**链路事实**。 */ function serveDirectory(keys, initial) { const state = { ...initial } const server = createServer((req, res) => { if (!req.url || !req.url.startsWith(DIRECTORY_PATH)) { res.writeHead(404).end('{}') return } res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }) res.end(JSON.stringify({ ...state.doc, sig: state.sig })) }) return { state, listen: () => new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const addr = server.address() resolve({ port: addr.port, origin: `http://127.0.0.1:${addr.port}/dshs-relay` }) }) }), /** 换一份目录内容(模拟控制面把 bootstrap[] / relays 轮换走)。 */ set(next) { state.doc = next.doc state.sig = next.sig }, close: () => new Promise((resolve) => server.close(() => resolve())), } } function tmpCacheFile() { const dir = mkdtempSync(join(tmpdir(), 'dshs-overlay-')) return { dir, file: join(dir, 'overlay', 'directory.json') } } /** 记录型 fetch 替身:便于断言"这一步**根本没联网**"。 */ function refusingFetch(calls) { return async (url) => { calls.push(String(url)) throw new Error('network disabled in this test') } } // ── B1 · 纯函数:载荷 / 派生 / 清洗 ──────────────────────────────────────── test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { // 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径 assert.equal(directoryUrlFor('https://example.net/dshs-relay'), `https://example.net${DIRECTORY_PATH}`) assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`) // 已经是目录地址 ⇒ 原样 assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`) // ws 方言 ⇒ 取目录用 http 方言 assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`) // 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据) assert.equal(toRelayUrl('https://example.net/dshs-relay'), 'wss://example.net/dshs-relay') assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay') assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x') assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝') assert.equal(toRelayUrl('not a url'), undefined) // 载荷:同字段 ⇒ 同字符串;`network` 被签名覆盖(改它 ⇒ 载荷变) const base = buildDirectoryDocument({ relays: ['https://a.example/dshs-relay'], bootstrap: ['https://a.example/dshs-relay'], network: 'ops', now: 1_700_000_000_000, }) const same = buildDirectoryDocument({ relays: ['https://a.example/dshs-relay'], bootstrap: ['https://a.example/dshs-relay'], network: 'ops', now: 1_700_000_000_000, }) assert.equal(directoryPayload(base), directoryPayload(same)) assert.notEqual(directoryPayload(base), directoryPayload({ ...base, network: 'u:5' })) assert.notEqual(directoryPayload(base), directoryPayload({ ...base, relays: ['https://b.example/x'] })) // 严格解析:坏文档一律 undefined(**默认拒绝**,不"尽力解析") assert.ok(parseDirectory(base) !== undefined) assert.equal(parseDirectory({ ...base, version: 2 }), undefined, '未知结构版本必须拒绝') assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 1 }), undefined, '刷新周期过小必须拒绝') assert.equal(parseDirectory({ ...base, refreshAfterSeconds: 999_999 }), undefined, '刷新周期过大必须拒绝') assert.equal(parseDirectory({ ...base, issuedAt: 'not-a-date' }), undefined) assert.equal(parseDirectory({ ...base, network: '' }), undefined) assert.equal(parseDirectory({ ...base, relays: ['file:///etc/passwd'] }), undefined, '非 http/ws 条目必须拒绝') assert.equal(parseDirectory({ ...base, relays: [42] }), undefined) assert.equal(parseDirectory({ ...base, relays: [], bootstrap: [] }), undefined, '全空的目录没有意义') // 对外公布的地址:回环 / 私网 / 单标签 / IPv6 / CGNAT 一律剔除(目录是**公网可读**的) assert.deepEqual( publicRelayEntries([ 'http://127.0.0.1:20080/dshs-relay', 'http://10.0.0.5/dshs-relay', 'http://192.168.1.9/dshs-relay', 'http://172.20.3.4/dshs-relay', 'http://100.64.7.7/dshs-relay', 'http://relaybox/dshs-relay', 'http://[::1]/dshs-relay', 'https://example.net/dshs-relay', 'https://relay.example.com/dshs-relay', ]), ['https://example.net/dshs-relay', 'https://relay.example.com/dshs-relay'], ) // ⛔ 内置种子**刻意留空** —— 种子是**具体部署的入口地址**,一律由配置提供 // (`DSHS_OVERLAY_BOOTSTRAP_SEEDS`,见 `config/platform.env`)⇒ 代码内不留真实域名。 assert.equal(DEFAULT_OVERLAY_SEED, '', '内置种子不得写死生产域名') // 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443) // ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继) assert.deepEqual( publicRelayEntries(['wss://example.net/dshs-relay', 'https://example.net/dshs-relay']), ['wss://example.net/dshs-relay'], ) // …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留 assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [ 'wss://a.example/x', 'http://a.example/x', ]) // 同源不同路径 = 不同端点 assert.deepEqual(publicRelayEntries(['https://a.example/dshs-relay', 'https://a.example/dshs-relay2']), [ 'https://a.example/dshs-relay', 'https://a.example/dshs-relay2', ]) }) // ── B2 · 验签(失败关闭) ──────────────────────────────────────────────── test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => { const keys = makeKeys() const other = makeKeys() const origin = 'https://example.net/dshs-relay' const { doc, sig } = signedDoc(origin, keys.privatePem) // 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验 const okPem = verifyDirectory(doc, sig, [keys.publicPem]) assert.equal(okPem.ok, true) assert.equal(okPem.ok === true ? okPem.doc.network : '', 'ops') assert.equal(verifyDirectory(doc, sig, [keys.publicRawHex]).ok, true) // 混进一把解不出来的 key 不该让整条链断掉(跳到下一把) assert.equal(verifyDirectory(doc, sig, ['not-a-key', keys.publicPem]).ok, true) assert.equal(publicKeyFrom('not-a-key'), undefined) // 签名覆盖内容:改 relays(哪怕只多一条)⇒ payload 变 ⇒ 验签失败 const tampered = { ...doc, relays: [...doc.relays, 'https://evil.example/dshs-relay'] } const bad1 = verifyDirectory(tampered, sig, [keys.publicPem]) assert.equal(bad1.ok, false) assert.equal(bad1.ok === false ? bad1.reason : '', 'signature-mismatch') // 换密钥签 ⇒ 拒绝 const foreign = signDirectory(doc, other.privatePem) assert.equal(verifyDirectory(doc, foreign, [keys.publicPem]).ok, false) assert.equal(verifyDirectory(doc, foreign, [other.publicPem]).ok, true) // **无受信密钥 ⇒ 拒绝**(不可验 = 不接受 —— 这条就是"失败关闭"本身) const noKeys = verifyDirectory(doc, sig, []) assert.equal(noKeys.ok, false) assert.equal(noKeys.ok === false ? noKeys.reason : '', 'no-trusted-keys') // 签名形态坏掉 ⇒ 拒绝 assert.equal(verifyDirectory(doc, '', [keys.publicPem]).ok, false) assert.equal(verifyDirectory(doc, 'AAAA', [keys.publicPem]).ok, false) assert.equal(verifyDirectory({ ...doc, version: 9 }, sig, [keys.publicPem]).ok, false) }) // ── B3 · 三级链的决策(注入 fetch / now,不依赖网络) ───────────────────── test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => { const keys = makeKeys() const seed = 'https://example.net/dshs-relay' // ① env 显式 ⇒ 压制引导链(**一次网络都不发**) { const calls = [] const r = await resolveOverlayRelay({ envUrl: 'wss://env.example/dshs-relay', seeds: [seed], trustedKeys: [keys.publicPem], fetchImpl: refusingFetch(calls), }) assert.equal(r.source, 'env') assert.equal(r.url, 'wss://env.example/dshs-relay') assert.deepEqual(calls, [], 'env 显式时不许联网') } // ② 缓存未过期 ⇒ 直接用(同样一次都不联网) { const { dir, file } = tmpCacheFile() try { const now = Date.now() const { doc, sig } = signedDoc(seed, keys.privatePem, { now }) writeCachedDirectory(file, doc, sig, now) const calls = [] const r = await resolveOverlayRelay({ seeds: [seed], trustedKeys: [keys.publicPem], cacheFile: file, fetchImpl: refusingFetch(calls), }) assert.equal(r.source, 'cache') assert.equal(r.url, 'wss://example.net/dshs-relay') assert.deepEqual(calls, [], '新鲜缓存不许联网') } finally { rmSync(dir, { recursive: true, force: true }) } } // ③ 无缓存 + 取不到 ⇒ 回落到**种子地址本身**(种子就是中继入口,同源约定) { const { dir, file } = tmpCacheFile() try { const calls = [] const r = await resolveOverlayRelay({ seeds: [seed], trustedKeys: [keys.publicPem], cacheFile: file, fetchImpl: refusingFetch(calls), }) assert.equal(r.source, 'seed-fallback') assert.equal(r.url, 'wss://example.net/dshs-relay') assert.ok(calls.length >= 1, '应当尝试过取目录') assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存') } finally { rmSync(dir, { recursive: true, force: true }) } } // ④ 有**过期但签名有效**的缓存 + 取不到 ⇒ 离线降级用旧值(D3 ③) { const { dir, file } = tmpCacheFile() try { const stale = Date.now() - 10 * 300_000 const { doc, sig } = signedDoc(seed, keys.privatePem, { now: stale }) writeCachedDirectory(file, doc, sig, stale) const r = await resolveOverlayRelay({ seeds: [seed], trustedKeys: [keys.publicPem], cacheFile: file, fetchImpl: refusingFetch([]), }) assert.equal(r.source, 'stale-cache') assert.equal(r.url, 'wss://example.net/dshs-relay') // 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签) assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined) } finally { rmSync(dir, { recursive: true, force: true }) } } // ⑤ 连种子都没有 ⇒ 明确返回"取不到"(等价于未配 relay,不抛异常) { const r = await resolveOverlayRelay({ seeds: [], trustedKeys: [], fetchImpl: refusingFetch([]) }) assert.equal(r.source, 'none') assert.equal(r.url, '') } }) // ── B4 · 判据 5:清 env + 清缓存冷启动,只靠内置种子起来 ───────────────── test('B4 冷启动:无 env、无缓存 ⇒ 走内置种子取到签名目录并留下缓存', async () => { const keys = makeKeys() const { dir, file } = tmpCacheFile() const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) try { const { origin } = await srv.listen() srv.set(signedDoc(origin, keys.privatePem)) assert.equal(existsSync(file), false) // 「清 env」= 不传 envUrl;「清缓存」= 缓存文件不存在 —— 只剩种子这一级 const first = await resolveOverlayRelay({ envUrl: '', seeds: [origin], trustedKeys: [keys.publicPem], cacheFile: file, }) assert.equal(first.source, 'seed-directory') assert.equal(first.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`) assert.equal(existsSync(file), true, '取到目录后必须写缓存') // 第二次(同一个"进程"重启)⇒ 读缓存,**不再联网**(把端点关掉也照样起来) await srv.close() const second = await resolveOverlayRelay({ envUrl: '', seeds: [origin], trustedKeys: [keys.publicPem], cacheFile: file, }) assert.equal(second.source, 'cache') assert.equal(second.url, first.url) } finally { await srv.close().catch(() => undefined) rmSync(dir, { recursive: true, force: true }) } }) // ── B5 · 判据 6:改目录里的 bootstrap[] ⇒ 新会话读到新值(不重装) ──────── test('B5 轮换:目录里 bootstrap[] 改值 ⇒ 下一轮从新地址取,客户端不重装', async () => { const keys = makeKeys() const { dir, file } = tmpCacheFile() const a = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) const b = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) try { const A = (await a.listen()).origin const B = (await b.listen()).origin a.set(signedDoc(A, keys.privatePem)) // 阶段 1:老地域 A 就是引导地址 b.set(signedDoc(B, keys.privatePem)) // 阶段 2:新地域 B 上线 // 显式时钟(**不要用 Date.now() 加减**:`now - fetchedAt` 会被 `Math.max(0, …)` 夹成 0 // ⇒ 缓存反而"看起来新鲜",测试会误判成"链路没走")。每次推 10 个刷新周期 ⇒ 缓存必过期。 const refreshMs = 300_000 let clock = Date.now() // 阶段 1:客户端只认识 A(= 编译进来的种子位) const p1 = await resolveOverlayRelay({ seeds: [A], trustedKeys: [keys.publicPem], cacheFile: file, nowMs: clock, }) assert.equal(p1.source, 'seed-directory') assert.equal(p1.url, `ws://127.0.0.1:${new URL(A).port}/dshs-relay`) // 控制面把引导地址轮换到 B:A 的目录里 bootstrap[] = [B]、relays[] = [B] a.set(signedDoc(B, keys.privatePem, { bootstrap: [B], relays: [B] })) clock += 10 * refreshMs const p2 = await resolveOverlayRelay({ seeds: [A], trustedKeys: [keys.publicPem], cacheFile: file, nowMs: clock, }) assert.equal(p2.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`, '这一轮就该连到新地址') const cached = readCachedDirectory(file, [keys.publicPem], clock) assert.equal(cached?.fresh, true, '刚写完的缓存应当是新鲜的') assert.deepEqual(cached?.entry.doc.bootstrap, [B], '缓存里的 bootstrap[] 必须已更新为 B') // **关键一步**:A 下线(老域名退役)。客户端**没有重装、没有改配置**, // 只因为"取目录的 origin 优先取缓存里的 bootstrap[]",它自己就找到了 B。 await a.close() clock += 10 * refreshMs const p3 = await resolveOverlayRelay({ seeds: [A], trustedKeys: [keys.publicPem], cacheFile: file, nowMs: clock, }) assert.equal(p3.source, 'seed-directory', 'A 已下线,仍应取到目录(说明 origin 走了 B)') assert.equal(p3.url, `ws://127.0.0.1:${new URL(B).port}/dshs-relay`) assert.equal(p3.detail.includes(`:${new URL(B).port}`), true, 'detail 必须指向 B 的端口') } finally { await a.close().catch(() => undefined) await b.close().catch(() => undefined) rmSync(dir, { recursive: true, force: true }) } }) // ── B6 · 失败关闭:签名不对 ⇒ 不写缓存、不采用 ─────────────────────────── test('B6 签名不对的目录:既不写缓存也不采用(有旧缓存则用它,没有则回落种子)', async () => { const keys = makeKeys() const attacker = makeKeys() const { dir, file } = tmpCacheFile() const srv = serveDirectory(keys, signedDoc('http://placeholder/dshs-relay', keys.privatePem)) try { const origin = (await srv.listen()).origin // 端点被换成了"另一把密钥签的目录"(或 MITM 自签)⇒ 我们**不认** srv.set({ doc: buildDirectoryDocument({ relays: ['https://evil.example/dshs-relay'], bootstrap: ['https://evil.example/dshs-relay'], network: 'ops', now: Date.now(), }), sig: signDirectory( buildDirectoryDocument({ relays: ['https://evil.example/dshs-relay'], bootstrap: ['https://evil.example/dshs-relay'], network: 'ops', now: Date.now(), }), attacker.privatePem, ), }) const r1 = await resolveOverlayRelay({ seeds: [origin], trustedKeys: [keys.publicPem], cacheFile: file, }) assert.equal(r1.source, 'seed-fallback', '坏目录必须被丢掉') assert.equal(r1.url, `ws://127.0.0.1:${new URL(origin).port}/dshs-relay`) assert.equal(existsSync(file), false, '坏目录**不许**被写进缓存') assert.equal(r1.url.includes('evil.example'), false, '⛔ 绝不能采用坏目录里的地址') // 有**有效但过期**的缓存 ⇒ 用旧的可信值(而不是接受坏目录) const good = serveDirectory(keys, signedDoc(origin, keys.privatePem)) const stale = Date.now() - 10 * 300_000 const old = signedDoc(origin, keys.privatePem, { now: stale }) writeCachedDirectory(file, old.doc, old.sig, stale) const r2 = await resolveOverlayRelay({ seeds: [origin], trustedKeys: [keys.publicPem], cacheFile: file, }) assert.equal(r2.source, 'stale-cache') assert.equal(r2.url.includes('evil.example'), false) await good.close().catch(() => undefined) } finally { await srv.close().catch(() => undefined) rmSync(dir, { recursive: true, force: true }) } }) // ── B7 · 端点契约(服务端组装 + 签发 + 验签闭环) ──────────────────────── test('B7 端点契约:只公布公网地址、签名可被受信公钥验过、无密钥即不可用', async () => { const keys = makeKeys() // 夹具用引导地址(RFC 2606 保留域):**不等于**内置种子常量 —— // 后者刻意留空(生产入口地址一律由配置提供),所以这里必须自带一个公网形态的夹具。 const FIXTURE_SEED = 'https://relay.example.net/dshs-relay' // 服务端逻辑:候选 = 显式中继入口 + 种子;**过滤回环/私网**后再组装 const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', FIXTURE_SEED]) const bootstrap = publicRelayEntries([FIXTURE_SEED]) const doc = buildDirectoryDocument({ relays, bootstrap, network: 'ops', now: Date.now() }) const sig = signDirectory(doc, keys.privatePem) assert.deepEqual(relays, [FIXTURE_SEED], '回环地址不得出现在目录里') assert.deepEqual(Object.keys(doc).sort(), [ 'bootstrap', 'issuedAt', 'network', 'refreshAfterSeconds', 'relays', 'version', ]) const verdict = verifyDirectory(doc, sig, [keys.publicRawHex]) assert.equal(verdict.ok, true) assert.equal(verdict.ok === true ? verdict.doc.version : 0, DIRECTORY_VERSION) // 目录里**没有**任何身份 / 内网信息(只暴露"去哪儿",不暴露"谁在哪") const flat = JSON.stringify(doc) for (const forbidden of ['hostId', 'w-', 'secret', '127.0.0.1', 'token']) { assert.equal(flat.includes(forbidden), false, `目录里不该出现 ${forbidden}`) } }) // ── B8 · 范围声明 ──────────────────────────────────────────────────────── test('B8 范围:本步只做 P0-2(引导三级链),不做 P0-3 / 应用层', () => { // Step 2 的边界(R7:别顺手做别的): // · P0-3 的"名字解析 / 按网络授权"已由 Step 1(P0-1 网抽象)承担,本步不重复实现; // · 应用层 / 房间层 / presence 排在 `清单 §五` 第 7 步,**本步不碰**; // · 本步**不新增任何监听口**(目录端点挂在既有门户 3080 上,由既有 nginx location 转发)。 assert.equal(DIRECTORY_PATH, '/dshs-overlay/bootstrap') assert.equal(existsSync(join(process.cwd(), 'src', 'net', 'relay', 'directory.ts')), true) }) // ── 夹具自检 ────────────────────────────────────────────────────────────── test('S0 夹具自检:缓存读写是字节级可复现的(避免"测试夹具自身有问题")', () => { const keys = makeKeys() const { dir, file } = tmpCacheFile() try { const now = Date.now() const FIXTURE_SEED = 'https://relay.example.net/dshs-relay' const { doc, sig } = signedDoc(FIXTURE_SEED, keys.privatePem, { now }) writeCachedDirectory(file, doc, sig, now) const raw = readFileSync(file, 'utf8') const parsed = JSON.parse(raw) assert.equal(parsed.doc.network, 'ops') assert.equal(parsed.fetchedAt, now) const back = readCachedDirectory(file, [keys.publicPem], now) assert.deepEqual(back?.entry.doc, doc) assert.equal(back?.fresh, true) // 不能留下临时文件(原子写的判据) assert.equal(existsSync(`${file}.tmp`), false) // 缓存文件是 JSON 且以换行结尾(便于 diff / 人工读) assert.equal(raw.endsWith('\n'), true) } finally { rmSync(dir, { recursive: true, force: true }) // 顺手证明夹具没往外写东西(writeFileSync 只用于夹具自检) assert.equal(typeof writeFileSync, 'function') } }) // ── 序④(443/TCP 兜底 · L1「去 CF」):地址覆盖 ───────────────────────────── // // 全部是**行为断言**(不是"函数返回了什么"): // A. **未配 ⇒ 零动作** —— 域名照旧不可解析,证明连补丁都没打(默认路径逐字不变); // B. **配了 ⇒ 内建 WebSocket 的建连真的走到覆盖 IP** —— 这是整件事的机制性前提 // (内建 `WebSocket` 必须走 JS 层 `dns.lookup`,否则本路线不成立); // C. **定向**而非全局劫持 —— 白名单之外的域名不受影响; // D. **失败关闭** —— 形状非法 ⇒ 不安装 + 报出来(不静默忽略); // E. **可撤销** —— 还原后回到未配状态(既是测试隔离,也是回滚判据)。 /** 取「net / undici 实际用的那个」`node:dns` 单例(`addr-override` 改的就是它)。 */ const nodeDns = createRequire(import.meta.url)('node:dns') /** 单地址解析(回调形状)⇒ 地址字符串或 `ERR:`。 */ function lookupOne(host) { return new Promise((resolve) => { nodeDns.lookup(host, (err, address) => resolve(err ? `ERR:${err.code}` : address)) }) } test('序④·L1-A 未配地址覆盖 ⇒ 零动作(默认解析路径逐字不变)', async () => { resetOverlayAddrOverridesForTest() try { assert.deepEqual(ensureOverlayAddrOverrides('', () => {}), []) assert.deepEqual(currentAddrOverrides(), []) assert.equal(await lookupOne('fb-nodns-9f3a.invalid'), 'ERR:ENOTFOUND') } finally { resetOverlayAddrOverridesForTest() } }) test('序④·L1-B 覆盖生效 ⇒ 白名单域名直连覆盖 IP,且内建 WebSocket 真的走到该 IP', async () => { const HOST = 'fb-direct-9f3a.invalid' let hits = 0 const srv = createNetServer((s) => { hits += 1 s.destroy() }) await new Promise((r) => srv.listen(0, '127.0.0.1', r)) const port = srv.address().port resetOverlayAddrOverridesForTest() try { // 覆盖前:该名字在公网不存在 ⇒ 不可解析(这就是"兜底子域没有可直连的解析结果"的等价场景) assert.equal(await lookupOne(HOST), 'ERR:ENOTFOUND') const applied = ensureOverlayAddrOverrides(`${HOST}=127.0.0.1`, () => {}) assert.deepEqual( applied.map((o) => `${o.host}=${o.ip}/v${o.family}`), [`${HOST}=127.0.0.1/v4`], ) assert.equal(await lookupOne(HOST), '127.0.0.1') // `all: true` 形状(`net` 在 autoSelectFamily 下会用它)也必须给出覆盖结果 const all = await new Promise((resolve) => { nodeDns.lookup(HOST, { all: true }, (err, addrs) => resolve(err ? `ERR:${err.code}` : addrs)) }) assert.deepEqual(all, [{ address: '127.0.0.1', family: 4 }]) // **机制性前提**:内建 WebSocket 的建连要走 JS 层 `dns.lookup`,覆盖才对它有效。 const before = hits await new Promise((resolve) => { const ws = new WebSocket(`ws://${HOST}:${port}/`) ws.addEventListener('open', () => resolve()) ws.addEventListener('error', () => resolve()) setTimeout(resolve, 3000) }) await new Promise((r) => setTimeout(r, 200)) assert.ok(hits > before, '内建 WebSocket 未走 JS 层 dns.lookup ⇒ 地址覆盖对建连无效(本路线不成立)') } finally { srv.close() resetOverlayAddrOverridesForTest() } }) test('序④·L1-C 白名单之外不受影响(定向覆盖,不是全局劫持)', async () => { resetOverlayAddrOverridesForTest() try { ensureOverlayAddrOverrides('fb-scoped-9f3a.invalid=127.0.0.1', () => {}) assert.equal(await lookupOne('fb-scoped-9f3a.invalid'), '127.0.0.1') assert.equal(await lookupOne('fb-other-9f3a.invalid'), 'ERR:ENOTFOUND') } finally { resetOverlayAddrOverridesForTest() } }) test('序④·L1-D 形状非法 ⇒ 不安装且报出去(失败关闭,不静默忽略)', async () => { resetOverlayAddrOverridesForTest() try { const logs = [] const applied = ensureOverlayAddrOverrides( 'no-equals,=1.2.3.4,host.invalid=not-an-ip,ok.invalid=1.2.3.4', (l) => logs.push(l), ) assert.deepEqual(applied.map((o) => o.host), ['ok.invalid']) assert.equal(logs.filter((l) => l.includes('忽略非法条目')).length, 3) // 纯函数面:同域名重复 ⇒ **先出现者生效**(小写归一后比对) assert.deepEqual( parseAddrOverrides('A.invalid=1.2.3.4,a.INVALID=5.6.7.8').list.map((o) => `${o.host}=${o.ip}`), ['a.invalid=1.2.3.4'], ) // 合法但请求了别的地址族 ⇒ 交回 ENOTFOUND 语义,**不**回一个错族的地址 assert.equal( await new Promise((resolve) => { nodeDns.lookup('ok.invalid', { family: 6 }, (err) => resolve(err ? `ERR:${err.code}` : 'OK')) }), 'ERR:ENOTFOUND', ) } finally { resetOverlayAddrOverridesForTest() } }) test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => { try { ensureOverlayAddrOverrides('fb-rollback-9f3a.invalid=127.0.0.1', () => {}) assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), '127.0.0.1') resetOverlayAddrOverridesForTest() assert.equal(await lookupOne('fb-rollback-9f3a.invalid'), 'ERR:ENOTFOUND') } finally { resetOverlayAddrOverridesForTest() } }) /** * **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」—— * `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。 */ const FB_MAIN = 'https://example.net/dshs-relay' const FB_ALT = 'https://relay-direct.example.net/dshs-relay' /** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */ function twoEntryDoc(keys) { const { doc, sig } = signedDoc(FB_MAIN, keys.privatePem, { relays: [FB_MAIN, FB_ALT], bootstrap: [FB_MAIN, FB_ALT], }) return JSON.stringify({ ...doc, sig }) } test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的同源入口,且降级可解释', async () => { const keys = makeKeys() const body = twoEntryDoc(keys) const calls = [] const logs = [] const fetchImpl = async (url) => { calls.push(String(url)) if (String(url).startsWith('https://example.net/')) throw new Error('cf unreachable') return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) } const r = await resolveOverlayRelay({ seeds: [FB_MAIN, FB_ALT], trustedKeys: [keys.publicPem], cacheFile: '', fetchImpl, log: (l) => logs.push(l), }) // ① 逐个 origin 试,主 origin 被拒后才到兜底 assert.equal(calls.length, 2) assert.ok( logs.some((l) => l.includes('拒绝 https://example.net/dshs-overlay/bootstrap')), '缺"逐 origin 拒绝原因"这一行', ) // ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据) assert.equal(r.source, 'seed-directory') assert.equal(r.url, 'wss://relay-direct.example.net/dshs-relay') assert.ok( logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.example.net/dshs-relay')), '缺"为什么走了兜底"这一行(可解释性)', ) }) test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首位,零退化)', async () => { const keys = makeKeys() const body = twoEntryDoc(keys) const logs = [] const fetchImpl = async () => new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }) const r = await resolveOverlayRelay({ seeds: [FB_MAIN, FB_ALT], trustedKeys: [keys.publicPem], cacheFile: '', fetchImpl, log: (l) => logs.push(l), }) assert.equal(r.url, 'wss://example.net/dshs-relay') assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志') })