Files
dsh_shenxian/test/overlay-bootstrap.test.mjs
T

752 lines
33 KiB
JavaScript
Raw Normal View History

/**
* 覆盖网络 · **② 引导三级链(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:<code>`。 */
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, '首位命中时不该有多余日志')
})