覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)
验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
748 lines
33 KiB
JavaScript
748 lines
33 KiB
JavaScript
/**
|
||
* 覆盖网络 · **② 引导三级链(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://alotbuy.com/dshs-relay'), `https://alotbuy.com${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://alotbuy.com/dshs-relay'), 'wss://alotbuy.com/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://alotbuy.com/dshs-relay',
|
||
'https://relay.example.com/dshs-relay',
|
||
]),
|
||
['https://alotbuy.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
|
||
)
|
||
|
||
// 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本)
|
||
assert.equal(DEFAULT_OVERLAY_SEED, 'https://alotbuy.com/dshs-relay')
|
||
|
||
// 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443)
|
||
// ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继)
|
||
assert.deepEqual(
|
||
publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']),
|
||
['wss://alotbuy.com/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://alotbuy.com/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://alotbuy.com/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://alotbuy.com/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://alotbuy.com/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://alotbuy.com/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()
|
||
// 服务端逻辑:候选 = 显式中继入口 + 种子;**过滤回环/私网**后再组装
|
||
const relays = publicRelayEntries(['ws://127.0.0.1:20080/dshs-relay', DEFAULT_OVERLAY_SEED])
|
||
const bootstrap = publicRelayEntries([DEFAULT_OVERLAY_SEED])
|
||
const doc = buildDirectoryDocument({ relays, bootstrap, network: 'ops', now: Date.now() })
|
||
const sig = signDirectory(doc, keys.privatePem)
|
||
|
||
assert.deepEqual(relays, [DEFAULT_OVERLAY_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 { doc, sig } = signedDoc(DEFAULT_OVERLAY_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://alotbuy.com/dshs-relay'
|
||
const FB_ALT = 'https://relay-direct.alotbuy.com/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://alotbuy.com/')) 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://alotbuy.com/dshs-overlay/bootstrap')),
|
||
'缺"逐 origin 拒绝原因"这一行',
|
||
)
|
||
// ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据)
|
||
assert.equal(r.source, 'seed-directory')
|
||
assert.equal(r.url, 'wss://relay-direct.alotbuy.com/dshs-relay')
|
||
assert.ok(
|
||
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.alotbuy.com/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://alotbuy.com/dshs-relay')
|
||
assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志')
|
||
})
|