Files
dsh_shenxian/test/overlay-bootstrap.test.mjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

753 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络 · **② 引导三级链(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, '首位命中时不该有多余日志')
})