Files
dsh_shenxian/src/net/relay/directory.ts
T

789 lines
35 KiB
TypeScript
Raw Normal View History

/**
* 覆盖网络 P0-2:**首次入网引导的三级链**(内置种子 → 签名目录 → 离线降级)。
*
* ## 为什么要有这个模块
*
* ① 的终态里,客户端(Manager / worker / 未来的用户设备)**只能**从 env 拿到中继地址。
* 一旦域名或机器要换,`env` 只存在于**我们自己的部署脚本**里 —— 用户设备上的那个值
* 改不到 ⇒ `§A2` 点名的灾难:**所有客户端必须升级重装**。
* 引导链把"地址"从**编译期常量**变成**可在线轮换的下发物**:
*
* | 级 | 来源 | 作用 |
* |---|---|---|
* | ① | **env 显式** | 运维最后手段,**压制一切**(调试 / 应急,不查目录、不联网) |
* | ② | **缓存目录**(未过期) | 日常路径:省一次网络往返,也保证"控制面挂掉不影响已入网节点" |
* | ③ | **内置种子** | 冷启动:拿种子 origin 去取**签名目录**,拿到 `relays[]` 才连 |
* | ④ | 离线降级 | 目录不可达 ⇒ 用**过期但签名有效**的缓存(只影响**新节点加入**) |
* | ⑤ | 种子兜底 | 连目录都取不到 ⇒ 直接用种子地址本身当入口(种子 = 中继入口,**同源**) |
*
* ## 🔴 三条不可动摇的判据
*
* 1. **签名不对 ⇒ 失败关闭**:目录只有在**受信公钥验签通过**时才被采用,
* **既不写缓存、也不拿它的地址去连**(`§A2`)。受信公钥为空 ⇒ 同样拒绝(**不可验 = 不接受**)。
* 2. **`bootstrap[]` 是轮换的唯一抓手**:取目录的 origin **优先取缓存里的 `bootstrap[]`**,
* 而不是编译进来的种子 ⇒ 目录里把 `bootstrap[]` 改成新地址,**下一次刷新就跟着走**,
* **不重装、不升级**(`§A2` 的关键设计要求)。
* 3. **本模块只处理地址,不碰身份**:目录里**没有** hostId / 密钥 / 用户数据 / 内网地址;
* 连上之后能不能拨、能拨谁,仍由 relay 侧「网维度 + 白名单」(P0-1)决定。
*
* 纯函数(payload / 解析 / 验签 / 取址决策)与 IO(缓存读写、取目录)分开放,
* 前者可单测、后者只做搬运 —— 与 `network.ts` 同一风格。
* @module dshs/net/relay/directory
*/
import {
createPrivateKey,
createPublicKey,
sign as cryptoSign,
verify as cryptoVerify,
type KeyObject,
} from 'node:crypto'
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
// 序④(443/TCP 兜底 · L1):**取目录这一腿也要走地址覆盖**(另一处注入点在 `client.ts` 的建连点)。
import { ensureOverlayAddrOverrides } from './addr-override.js'
/** 目录端点的**固定路径**(服务端注册、客户端派生都用它,⛔ 不要在调用方各写一份)。 */
export const DIRECTORY_PATH = '/dshs-overlay/bootstrap'
/** 签名载荷的**版本标签**:换载荷格式时改这里 ⇒ 老客户端**验签失败 ⇒ 失败关闭**(不会误读新格式)。 */
export const DIRECTORY_PAYLOAD_TAG = 'dshs-overlay-directory/v1'
/** 目录文档结构版本。未知版本 ⇒ 拒绝(而不是"尽力解析")。 */
export const DIRECTORY_VERSION = 1
/** 刷新周期的安全区间(防止目录里写"缓存十年"把轮换能力锁死)。 */
export const MIN_REFRESH_SECONDS = 30
export const MAX_REFRESH_SECONDS = 86400
/** 默认刷新周期(秒):服务端签发时用、客户端拿不到目录时的兜底节奏也用。 */
export const DEFAULT_DIRECTORY_REFRESH_SECONDS = 300
/** 一份目录里地址条目的上限(防放大 / 防误配)。 */
const MAX_ENTRIES = 8
const MAX_ENTRY_LEN = 512
/** Ed25519 裸 32 字节公钥的 **SPKI DER 前缀**(`302a300506032b6570032100`)。 */
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
/**
* **内置种子的字面量**(引导链的常量位)。已持证书的门户域名、**不新增域名成本**。
* ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量,
* **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。
*/
export const DEFAULT_OVERLAY_SEED = 'https://alotbuy.com/dshs-relay'
/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */
export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] {
const raw = env.DSHS_OVERLAY_BOOTSTRAP_SEEDS
if (raw === undefined) return [DEFAULT_OVERLAY_SEED]
return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))]
}
/**
* 从环境变量取受信目录公钥。**没配 ⇒ 空数组 ⇒ 目录一律不接受**(不可验 = 不接受),
* 于是引导链退化成"env / 种子兜底"两级 —— 行为等价于 P0-2 之前,不会更危险。
*/
export function overlayEnvTrustedKeys(env: NodeJS.ProcessEnv = process.env): string[] {
const raw = env.DSHS_OVERLAY_DIR_PUBKEYS
if (raw === undefined) return []
return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))]
}
/** 引导目录文档(**签名覆盖全部字段**,顺序由 {@link directoryPayload} 固定)。 */export interface OverlayDirectory {
/** 结构版本,必须 === {@link DIRECTORY_VERSION}。 */
version: number
/** 签发时间(ISO 8601)。 */
issuedAt: string
/** 客户端缓存多久后重新取目录(秒)。 */
refreshAfterSeconds: number
/** 该目录所属的网(P0-1 的 `network_id`,运维网 = `ops`)。 */
network: string
/** 当前可用的**中继端点**(连接用;`https://` 会被归一化成 `wss://`)。 */
relays: string[]
/** **可轮换的引导地址清单**(下一轮取目录的 origin;也是最终兜底入口)。 */
bootstrap: string[]
}
/** 验签结论。失败一律带**具体原因** —— 静默拒绝会让"目录写错"看起来像"网络不通"。 */
export type DirectoryVerdict =
| { ok: true; doc: OverlayDirectory; payload: string; keyIndex: number }
| { ok: false; reason: string }
/** 取址来源(写日志用;也是验收判据要断言的东西)。 */
export type OverlayAddressSource =
| 'env'
| 'cache'
| 'seed-directory'
| 'stale-cache'
| 'seed-fallback'
| 'none'
/** 一次取址的结论。 */
export interface OverlayRelayResolution {
/** 归一化后的中继地址(`ws://` / `wss://`);空 = 取不到(行为等同"未配 relay")。 */
url: string
source: OverlayAddressSource
/** 供日志 / 取证:具体用了哪个 origin、哪条拒绝原因。 */
detail: string
/** 本次结论的**保鲜期**(秒):调用方据此决定下一次刷新的节奏。 */
refreshAfterSeconds: number
}
// ── 纯函数:载荷 / 解析 / 验签 ─────────────────────────────────────────────
/**
* **签名载荷的规范形式**。服务端签发与客户端验签都必须走这个函数 ——
* 用 `JSON.stringify` 会踩"键序不同 ⇒ payload 不同"的坑(换一个 TS 版本就可能变)。
*/
export function directoryPayload(doc: OverlayDirectory): string {
return [
DIRECTORY_PAYLOAD_TAG,
String(doc.version),
doc.issuedAt,
String(doc.refreshAfterSeconds),
doc.network,
doc.relays.join(','),
doc.bootstrap.join(','),
].join('\n')
}
/** 把 hex / base64 / base64url 解成字节;都不是 ⇒ `undefined`。 */
function decodeRawKey(spec: string): Buffer | undefined {
if (/^[0-9a-fA-F]{64}$/.test(spec)) return Buffer.from(spec, 'hex')
if (/^[A-Za-z0-9+/=_-]+$/.test(spec)) {
const b64 = spec.replace(/-/g, '+').replace(/_/g, '/')
const buf = Buffer.from(b64, 'base64')
if (buf.length === 32) return buf
}
return undefined
}
/**
* 解析一个受信公钥:接受 **PEM(SPKI)** 或 **裸 32 字节(hex / base64 / base64url)**。
* 解析不出来 ⇒ `undefined`(调用方**跳过**这一把,而不是让整条链断掉)。
*/
export function publicKeyFrom(spec: string): KeyObject | undefined {
const s = spec.trim()
if (s === '') return undefined
if (s.includes('BEGIN')) {
try {
const k = createPublicKey(s)
return k.asymmetricKeyType === 'ed25519' ? k : undefined
} catch {
return undefined
}
}
const raw = decodeRawKey(s)
if (raw === undefined) return undefined
try {
const k = createPublicKey({
key: Buffer.concat([ED25519_SPKI_PREFIX, raw]),
format: 'der',
type: 'spki',
})
return k.asymmetricKeyType === 'ed25519' ? k : undefined
} catch {
return undefined
}
}
/** 单个地址条目:必须是非空、≤512 字符、`http(s)` / `ws(s)` 的绝对 URL。 */
function parseEntry(value: unknown): string | undefined {
if (typeof value !== 'string') return undefined
const s = value.trim()
if (s === '' || s.length > MAX_ENTRY_LEN) return undefined
let u: URL
try {
u = new URL(s)
} catch {
return undefined
}
if (u.protocol !== 'https:' && u.protocol !== 'http:' && u.protocol !== 'wss:' && u.protocol !== 'ws:') {
return undefined
}
return s
}
/** 地址清单:数组、≤{@link MAX_ENTRIES} 条、条条合法、去重后返回。 */
function parseEntryList(value: unknown): string[] | undefined {
if (!Array.isArray(value) || value.length > MAX_ENTRIES) return undefined
const out: string[] = []
for (const item of value) {
const entry = parseEntry(item)
if (entry === undefined) return undefined
if (!out.includes(entry)) out.push(entry)
}
return out
}
/**
* 严格解析(**只做形状与取值域校验,不验签**)。任何一条不合规 ⇒ `undefined`:
* 对齐"默认拒绝" —— 目录是**下发物**,宁可不接受,也不要"尽力解析出一个半成品"。
*/
export function parseDirectory(raw: unknown): OverlayDirectory | undefined {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
const r = raw as Record<string, unknown>
if (r.version !== DIRECTORY_VERSION) return undefined
if (typeof r.issuedAt !== 'string' || r.issuedAt.length > 40) return undefined
if (Number.isNaN(Date.parse(r.issuedAt))) return undefined
if (typeof r.refreshAfterSeconds !== 'number' || !Number.isFinite(r.refreshAfterSeconds)) return undefined
if (r.refreshAfterSeconds < MIN_REFRESH_SECONDS || r.refreshAfterSeconds > MAX_REFRESH_SECONDS) {
return undefined
}
if (typeof r.network !== 'string' || r.network === '' || r.network.length > 64) return undefined
const relays = parseEntryList(r.relays)
if (relays === undefined) return undefined
const bootstrap = parseEntryList(r.bootstrap)
if (bootstrap === undefined) return undefined
if (relays.length === 0 && bootstrap.length === 0) return undefined
return {
version: DIRECTORY_VERSION,
issuedAt: r.issuedAt,
refreshAfterSeconds: r.refreshAfterSeconds,
network: r.network,
relays,
bootstrap,
}
}
/**
* 验签。**受信公钥为空 ⇒ 拒绝**(不可验 = 不接受)—— 这条是 `§A2` 的失败关闭语义,
* ⛔ 不要"配不上密钥就先用着",那等于把引导链的信任根交给 MITM(TLS 之外的那一层防线就没了)。
*/
export function verifyDirectory(
rawDoc: unknown,
sig: unknown,
keys: readonly string[],
): DirectoryVerdict {
const doc = parseDirectory(rawDoc)
if (doc === undefined) return { ok: false, reason: 'bad-document' }
if (typeof sig !== 'string' || sig.trim() === '') return { ok: false, reason: 'no-signature' }
const sigBuf = Buffer.from(sig.trim(), 'base64')
if (sigBuf.length !== 64) return { ok: false, reason: 'bad-signature-length' }
if (keys.length === 0) return { ok: false, reason: 'no-trusted-keys' }
const payload = directoryPayload(doc)
for (let i = 0; i < keys.length; i += 1) {
const key = publicKeyFrom(keys[i] ?? '')
if (key === undefined) continue
if (cryptoVerify(null, Buffer.from(payload, 'utf8'), key, sigBuf)) {
return { ok: true, doc, payload, keyIndex: i }
}
}
return { ok: false, reason: 'signature-mismatch' }
}
/** 签发(**只跑在控制面**)。私钥只从内存里的 PEM 字符串进来,⛔ 本模块不读任何密钥文件。 */
export function signDirectory(doc: OverlayDirectory, privateKeyPem: string): string {
const key = createPrivateKey(privateKeyPem)
if (key.asymmetricKeyType !== 'ed25519') {
throw new Error(`overlay directory key must be ed25519 (got ${String(key.asymmetricKeyType)})`)
}
return cryptoSign(null, Buffer.from(directoryPayload(doc), 'utf8'), key).toString('base64')
}
/** 组装一份**已经清洗过**的目录文档(服务端与单测共用,保证两边载荷完全一致)。 */
export function buildDirectoryDocument(input: {
relays: readonly string[]
bootstrap: readonly string[]
network: string
now: number
refreshAfterSeconds?: number
}): OverlayDirectory {
const relays = parseEntryList(input.relays.slice(0, MAX_ENTRIES)) ?? []
const bootstrap = parseEntryList(input.bootstrap.slice(0, MAX_ENTRIES)) ?? []
if (relays.length === 0 && bootstrap.length === 0) {
throw new Error('overlay directory needs at least one relay or bootstrap address')
}
const refresh = input.refreshAfterSeconds ?? 300
if (!Number.isFinite(refresh) || refresh < MIN_REFRESH_SECONDS || refresh > MAX_REFRESH_SECONDS) {
throw new Error(`invalid refreshAfterSeconds ${String(refresh)}`)
}
return {
version: DIRECTORY_VERSION,
issuedAt: new Date(input.now).toISOString(),
refreshAfterSeconds: Math.floor(refresh),
network: input.network,
relays,
bootstrap,
}
}
// ── 纯函数:地址派生 / 归一化 ───────────────────────────────────────────────
/**
* 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。
*
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`,
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
*/
export function directoryUrlFor(entry: string): string | undefined {
const raw = parseEntry(entry)
if (raw === undefined) return undefined
const u = new URL(raw)
if (u.pathname !== DIRECTORY_PATH) {
u.pathname = DIRECTORY_PATH
u.search = ''
u.hash = ''
}
if (u.protocol === 'wss:') u.protocol = 'https:'
if (u.protocol === 'ws:') u.protocol = 'http:'
return u.toString()
}
/**
* 中继地址归一化:`https→wss` / `http→ws`(`RelayClient` 只吃 ws 方言)。
* ⛔ 不改路径 —— `/dshs-relay` 这个 path 是 nginx 的 location 判据(R2 定的)。
*/
export function toRelayUrl(entry: string): string | undefined {
const raw = parseEntry(entry)
if (raw === undefined) return undefined
const u = new URL(raw)
if (u.protocol === 'https:') u.protocol = 'wss:'
else if (u.protocol === 'http:') u.protocol = 'ws:'
return u.toString()
}
/**
* 从一份目录里列出**全部候选中继地址**(有序)。
*
* 顺序 = `relays[]` → `bootstrap[]`(控制面下发的**有序**清单,⛔ 不重排);
* 按 `host` 去重(同一台机器在多条清单里各写一遍只算一个候选);非法项跳过。
*
* ## 为什么需要"全部"而不是"第一个"(序⑦ · 本函数存在的理由)
* 改造前 `pickFromDoc` **取到第一个可用项就 `return`** ⇒ 候选集退化成**单点**:
* 目录里排第二的那台中继**永远选不中**(除非首位此刻不可用)。后果是"换址"这条路径形同虚设
* —— 重解析一百次,拿回来的还是同一个字符串 ⇒ 上层按"地址没变"直接 `return`。
* ⇒ **根因是"候选集退化成单点",不是"没写重解析"**(对序⑥ §8.8-1 的证据级细化)。
*/
function listCandidatesFromDoc(doc: OverlayDirectory): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const candidate of [...doc.relays, ...doc.bootstrap]) {
const url = toRelayUrl(candidate)
if (url === undefined) continue
const key = hostKeyOf(url)
if (key === undefined) continue
if (seen.has(key)) continue
seen.add(key)
out.push(url)
}
return out
}
/**
* 主机键 = `host[:port]`(丢掉 scheme 与 path/query/hash);不是绝对 URL ⇒ `undefined`。
*
* ⚠️ **不能连 scheme 一起比**:目录 origin 是 `https://…`(HTTP 取目录),而清单里的是
* `wss://…`(中继入口)—— 同一个 host 在两条链路上 scheme 本来就不同。`URL#host` 会把默认端口
* (80/443/ws 80/wss 443)一并省掉 ⇒ 既"同 host 即同源",又不会被非默认端口误判成同源。
*/
function hostKeyOf(url: string): string | undefined {
try {
return new URL(url).host.toLowerCase()
} catch {
return undefined
}
}
/**
* **同源优先**(序④ · 443/TCP 兜底):目录是**哪个 origin 答出来的**,就优先用它的同源中继入口。
*
* ## 为什么必须有这一条
* `relays[]` 是控制面下发的**有序**清单,而 `pickFromDoc` 取的是**首位**(= 主入口)。
* 于是"主入口不可达、兜底 origin 却能答出目录"这种降级场景里,客户端仍会去连**首位那条**
* ⇒ 兜底入口**永远轮不到** ——「多一条入口」落不成「CF / 门户 conf 不可用时还能连」,
* 整个兜底就只剩装饰性(端点能 101,但没有任何客户端会去连它)。
*
* ## 它不是新概念
* 它就是既有约定「**引导地址 = 中继入口同源**」在**选择时刻**的落地
* (见 `config.ts#overlayBootstrapSeeds` 与 {@link DIRECTORY_PATH} 的注释)。
*
* ## 什么时候**不动**
* - origin 不在文档的 `relays[]` / `bootstrap[]` 里(那它就不是一个中继入口)⇒ 返回 `undefined`,
* 调用方退回 `pickFromDoc`(**与今天逐字一致**);
* - 命中的就是首位 ⇒ 替换是空操作(调用方按字符串比对后不进日志)。
*
* ⚠️ 只作用于"**目录被某个 origin 取到手**"这一支;缓存两条路径(② 新鲜缓存 / ④ 过期缓存)没有
* "答出者"信息 ⇒ 保持原样(代价:CF 打挂后,最多等一个刷新周期 `refreshAfterSeconds` 才切过去)。
*/
function sameOriginRelayUrl(doc: OverlayDirectory, dirUrl: string): string | undefined {
const target = hostKeyOf(dirUrl)
if (target === undefined) return undefined
for (const candidate of [...doc.relays, ...doc.bootstrap]) {
const url = toRelayUrl(candidate)
if (url === undefined) continue
if (hostKeyOf(url) === target) return url
}
return undefined
}
/** 单标签主机名 / 回环 / 私网 / 链路本地 / CGNAT / IPv6 ⇒ **不是**可对外公布的地址。 */
function isPublicHost(host: string): boolean {
const h = host.toLowerCase()
if (h === '') return false
if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) {
return false
}
if (h.includes(':')) return false // IPv6 一律不公布(避免 `::1` / ULA 漏进目录)
if (!h.includes('.')) return false // 单标签 = 内网主机名
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(h)
if (m === null) return true // 域名 ⇒ 公布
const a = Number(m[1])
const b = Number(m[2])
if (a === 0 || a === 127 || a === 10) return false
if (a === 192 && b === 168) return false
if (a === 172 && b >= 16 && b <= 31) return false
if (a === 169 && b === 254) return false
if (a === 100 && b >= 64 && b <= 127) return false // CGNAT(D1 点名的段)
return true
}
/**
* 地址的**语义身份**:同一台机器、同一个口、同一个路径 ⇒ 同一个身份。
* `https`/`wss` 同属 443、`http`/`ws` 同属 80 ⇒ 按"是否 TLS + 主机 + 有效口 + 路径"归一。
* 为什么需要它:配置里 `wss://host/dshs-relay` 与种子 `https://host/dshs-relay` 是**同一个端点**,
* 不去重就会在目录里出现两条同义项(客户端无碍,但读目录的人会误以为有两个中继)。
*/
function entryIdentity(u: URL): string {
const secure = u.protocol === 'https:' || u.protocol === 'wss:'
const port = u.port !== '' ? u.port : secure ? '443' : '80'
return `${secure ? 'tls' : 'plain'}|${u.hostname.toLowerCase()}:${port}${u.pathname}`
}
/**
* 选出**可以对外公布**的中继 / 引导地址。
*
* ⛔ 回环与私网一律剔除:目录是**公网可读**的 —— 公布 `127.0.0.1:20080` 对客户端毫无用处,
* 还白送一份内网拓扑。对齐红线「**权限只准收窄**」(这里收窄的是**暴露面**)。
* 同一语义身份只保留**首次出现**的那条(⇒ `wss://` 写法优先于同源的 `https://` 写法)。
*/
export function publicRelayEntries(entries: readonly string[]): string[] {
const out: string[] = []
const seen = new Set<string>()
for (const entry of entries) {
const raw = parseEntry(entry)
if (raw === undefined) continue
const u = new URL(raw)
if (!isPublicHost(u.hostname)) continue
const id = entryIdentity(u)
if (seen.has(id)) continue
seen.add(id)
out.push(raw)
}
return out
}
// ── IO:缓存读写 ──────────────────────────────────────────────────────────
/** 磁盘上的缓存条目。 */
export interface CachedDirectory {
doc: OverlayDirectory
sig: string
/** 写入时刻(ms)。 */
fetchedAt: number
}
/**
* 读缓存。**读也要验签**:缓存文件是本地普通文件,被改坏/被换掉时
* 必须与"从网上取到脏目录"同等待遇(拒绝),⛔ 不要因为"是本机文件"就免检。
*/
export function readCachedDirectory(
file: string,
keys: readonly string[],
now: number,
): { entry: CachedDirectory; fresh: boolean } | undefined {
if (file === '') return undefined
let raw: string
try {
raw = readFileSync(file, 'utf8')
} catch {
return undefined
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return undefined
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined
const o = parsed as Record<string, unknown>
const fetchedAt = typeof o.fetchedAt === 'number' && Number.isFinite(o.fetchedAt) ? o.fetchedAt : Number.NaN
if (Number.isNaN(fetchedAt)) return undefined
const verdict = verifyDirectory(o.doc, o.sig, keys)
if (!verdict.ok) return undefined
const age = Math.max(0, now - fetchedAt)
return {
entry: { doc: verdict.doc, sig: String(o.sig), fetchedAt },
fresh: age <= verdict.doc.refreshAfterSeconds * 1000,
}
}
/** 写缓存(同目录临时文件 + `rename`,避免读到写了一半的 JSON)。 */
export function writeCachedDirectory(
file: string,
doc: OverlayDirectory,
sig: string,
fetchedAt: number,
): void {
if (file === '') return
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp`
writeFileSync(tmp, `${JSON.stringify({ doc, sig, fetchedAt }, null, 2)}\n`, { mode: 0o600 })
renameSync(tmp, file)
}
// ── 取目录 ────────────────────────────────────────────────────────────────
const DEFAULT_FETCH_TIMEOUT_MS = 5000
type FetchResult =
| { ok: true; doc: OverlayDirectory; sig: string }
| { ok: false; reason: string }
/** 取一份目录并当场验签。**验签不过的目录绝不外泄给调用方**(返回的 `ok:false` 不带地址)。 */
async function fetchDirectory(
url: string,
keys: readonly string[],
doFetch: typeof fetch,
timeoutMs: number,
): Promise<FetchResult> {
try {
const res = await doFetch(url, {
signal: AbortSignal.timeout(timeoutMs),
headers: { accept: 'application/json' },
})
if (!res.ok) return { ok: false, reason: `http-${res.status}` }
const body = (await res.json()) as unknown
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return { ok: false, reason: 'bad-body' }
}
const o = body as Record<string, unknown>
const sig = o.sig
const doc: Record<string, unknown> = { ...o }
delete doc.sig
const verdict = verifyDirectory(doc, sig, keys)
if (!verdict.ok) return { ok: false, reason: verdict.reason }
return { ok: true, doc: verdict.doc, sig: String(sig) }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
return { ok: false, reason: `unreachable: ${msg.slice(0, 80)}` }
}
}
/** {@link resolveOverlayRelay} 的入参。 */
export interface ResolveOverlayRelayOptions {
/** env 显式地址(`DSHS_RELAY_URL`);非空 ⇒ **压制一切**,不查目录。 */
envUrl?: string
/** 内置种子(D3 的常量位;② 第二地域留空 ⇒ 只填第一条)。 */
seeds: readonly string[]
/** 受信目录签名公钥(PEM 或裸 32 字节)。**空 ⇒ 目录一律不接受**。 */
trustedKeys: readonly string[]
/** 缓存文件绝对路径;空 ⇒ 不缓存(每次取目录)。 */
cacheFile?: string
log?: (line: string) => void
/** 注入点(单测用);默认全局 `fetch`。 */
fetchImpl?: typeof fetch
/** 注入点(单测用);默认 `Date.now`。 */
nowMs?: number
timeoutMs?: number
/**
* **要排除的地址**(序⑦ · 中继失败切流):通常 = "刚刚不健康的那一台"。
*
* 语义 = "在这份候选链里**跳过**这些地址,取第一个没被排除的"。作用范围**只在选择这一步**
* —— ⛔ 它**不会**让任何候选凭空出现:被排除后若没有别的候选,返回 `url: ''`
* (调用方按"无候选可切"处理 ⇒ **保持原地退避**,见 D6)。
*
* 缺省 / 空数组 ⇒ **行为与改造前逐字一致**(D9:存量调用点零影响)。
*/
exclude?: readonly string[]
}
/**
* 引导链算出的**有序候选集**(序⑦ 新增)。
*
* 与 {@link OverlayRelayResolution} 的区别:后者只有 `url`(首位),这里给**整条链**,
* 供"首位不健康时换下一个"使用。`source` / `detail` / `refreshAfterSeconds` 语义不变。
*/
export interface OverlayRelayCandidates {
/** 有序候选地址(首位 = 改造前的"本次该连的地址");空数组 = 取不到任何候选。 */
urls: readonly string[]
source: OverlayAddressSource
detail: string
refreshAfterSeconds: number
}
/**
* **引导三级链的单一入口**:给出**整条有序候选链**(序⑦ 起)。
*
* 任何情况下都不抛异常(最坏返回 `urls: []`)—— 调用方按"未配 relay"处理即可。
* ⛔ **取址代码只有这一份**:{@link resolveOverlayRelay} 与 {@link listOverlayRelayCandidates}
* 都是它的薄包装(另一份取址 = 另一处静默失效,本线已有两次同类教训)。
*/
async function resolveOverlayRelayChain(
opts: ResolveOverlayRelayOptions,
): Promise<OverlayRelayCandidates> {
const log = opts.log ?? ((): void => undefined)
/**
* 序④(443/TCP 兜底 · L1):**取目录这一腿也必须走地址覆盖**。
*
* 为什么少这一处 L1 就不成立:CF 不可用时,若目录还按 DNS 去取,则第 ③ 步(取目录)会
* **全 origin 失败**,链只能走到第 ⑤ 步 —— 而 ⑤ 回落的是 `seeds[0]`(主入口 = 同样走 CF)
* ⇒ 兜底入口永远轮不到。装上覆盖后,兜底 origin 的这一腿直连 47,目录取得回来。
* ⛔ 未配 `DSHS_OVERLAY_ADDR_OVERRIDES` ⇒ 零动作。
*/
ensureOverlayAddrOverrides(undefined, log)
const now = opts.nowMs ?? Date.now()
const doFetch = opts.fetchImpl ?? fetch
const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS
const seeds = opts.seeds.map((s) => s.trim()).filter((s) => s !== '')
// ① env 显式:运维最后手段。**支持空串=未配**,但配错了(非 http/ws 地址)要明确报一行。
const envUrl = (opts.envUrl ?? '').trim()
if (envUrl !== '') {
const url = toRelayUrl(envUrl)
if (url !== undefined) {
log(`[overlay-dir] 取址 = env 显式(压制引导链):${url}`)
return { urls: [url], source: 'env', detail: 'env', refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS }
}
log(`[overlay-dir] ⛔ env 里的地址不是 http(s)/ws(s) 绝对 URL ⇒ 忽略它,继续走引导链`)
}
const cacheFile = opts.cacheFile ?? ''
const cached = readCachedDirectory(cacheFile, opts.trustedKeys, now)
// ② 缓存未过期:直接用(省一次往返;控制面抖动不影响已入网节点)。
if (cached !== undefined && cached.fresh) {
const urls = listCandidatesFromDoc(cached.entry.doc)
if (urls.length > 0) {
log(`[overlay-dir] 取址 = 缓存目录(未过期,net=${cached.entry.doc.network}):${urls[0]}(候选 ${urls.length} 条)`)
return { urls, source: 'cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds }
}
}
// ③ 取目录。**origin 优先取缓存里的 `bootstrap[]`** —— 这就是"引导地址可在线轮换"的落点:
// 控制面把 bootstrap[] 改成新地址 ⇒ 下一次刷新就去新地址,**不重装**。
const origins: string[] = []
for (const candidate of [...(cached?.entry.doc.bootstrap ?? []), ...seeds]) {
if (!origins.includes(candidate)) origins.push(candidate)
}
for (const origin of origins) {
const dirUrl = directoryUrlFor(origin)
if (dirUrl === undefined) continue
const got = await fetchDirectory(dirUrl, opts.trustedKeys, doFetch, timeoutMs)
if (!got.ok) {
log(`[overlay-dir] ⚠ 拒绝 ${dirUrl}(${got.reason})—— 未签名 / 签名不符的目录**不写缓存、不采用**`)
continue
}
writeCachedDirectory(cacheFile, got.doc, got.sig, now)
/**
* **同源优先**(序④):谁答出的目录,就先认它的同源中继入口 —— 否则主入口一挂,
* 客户端会一直去连 `relays[]` 的首位(= 主入口),兜底入口形同不存在。见 {@link sameOriginRelayUrl}。
*
* ⚠️ 序⑦:同源优先必须作用在**整条链**上("同源那条排第一,原首位排其后"),
* ⛔ 不是"只看首位" —— 否则候选集又会退化成单点。见 {@link listCandidatesFromDoc}。
*/
const list = listCandidatesFromDoc(got.doc)
const base = list[0]
const sameOrigin = sameOriginRelayUrl(got.doc, dirUrl)
const urls =
sameOrigin === undefined ? list : [sameOrigin, ...list.filter((u) => u !== sameOrigin)]
if (sameOrigin !== undefined && base !== undefined && sameOrigin !== base) {
log(
`[overlay-dir] ↪ 同源优先:目录由 ${dirUrl} 答出 ⇒ 采用其同源中继入口 ${sameOrigin}` +
`(不在 relays[] 首位;首位 ${base} 本次未被采用)`,
)
}
if (urls.length === 0) {
log(`[overlay-dir] ⚠ ${dirUrl} 的目录里没有可用中继地址 ⇒ 换下一个引导地址`)
continue
}
log(
`[overlay-dir] 取址 = 签名目录(来自 ${dirUrl},version=${got.doc.version},` +
`net=${got.doc.network},relays=${got.doc.relays.length},bootstrap=${got.doc.bootstrap.length}):${urls[0]}(候选 ${urls.length} 条)`,
)
return { urls, source: 'seed-directory', detail: dirUrl, refreshAfterSeconds: got.doc.refreshAfterSeconds }
}
// ④ 离线降级:目录全都取不到 / 全被拒 ⇒ 用**过期但签名有效**的缓存(D3 ③)。
if (cached !== undefined) {
const urls = listCandidatesFromDoc(cached.entry.doc)
if (urls.length > 0) {
log('[overlay-dir] ⚠ 目录不可达 ⇒ 离线降级:用**过期缓存**里的地址(已建连接不受影响)')
return { urls, source: 'stale-cache', detail: cacheFile, refreshAfterSeconds: cached.entry.doc.refreshAfterSeconds }
}
}
// ⑤ 种子兜底:种子地址本身就是中继入口(同源约定)⇒ 连目录端点挂了也还能起来。
const first = seeds[0]
if (first !== undefined) {
const url = toRelayUrl(first)
if (url !== undefined) {
log(`[overlay-dir] ⚠ 取目录全部失败 ⇒ 回落到内置种子地址本身:${url}`)
return { urls: [url], source: 'seed-fallback', detail: first, refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS }
}
}
log('[overlay-dir] ⛔ 引导链全部失败且无内置种子 ⇒ 本次不接中继(等同"未配 relay")')
return { urls: [], source: 'none', detail: 'none', refreshAfterSeconds: DEFAULT_DIRECTORY_REFRESH_SECONDS }
}
/**
* **引导链的有序候选集**(序⑦):把整条链交给调用方,供"当前中继不健康 ⇒ 换下一个"使用。
*
* ⛔ 只是 {@link resolveOverlayRelayChain} 的转发 —— **不另写一份取址**。
* 与 {@link resolveOverlayRelay} 的关系:本函数**不套用 `exclude`**(它要的是"候选全集")。
*/
export async function listOverlayRelayCandidates(
opts: ResolveOverlayRelayOptions,
): Promise<OverlayRelayCandidates> {
return resolveOverlayRelayChain(opts)
}
/**
* **引导三级链的单一入口**:给出"这次该连哪个中继地址"(= 候选链首位)。
* 任何情况下都不抛异常(最坏返回 `url: ''`)—— 调用方按"未配 relay"处理即可。
*
* 序⑦ 起它是 {@link resolveOverlayRelayChain} 的**薄包装**:链 → 剔除 `exclude` → 取第一个。
* **`exclude` 缺省时与改造前逐字一致**(D9)。
*/
export async function resolveOverlayRelay(
opts: ResolveOverlayRelayOptions,
): Promise<OverlayRelayResolution> {
const chain = await resolveOverlayRelayChain(opts)
const log = opts.log ?? ((): void => undefined)
const excluded = opts.exclude ?? []
if (excluded.length === 0) {
return {
url: chain.urls[0] ?? '',
source: chain.source,
detail: chain.detail,
refreshAfterSeconds: chain.refreshAfterSeconds,
}
}
const skip = new Set(excluded)
const url = chain.urls.find((u) => !skip.has(u))
if (url === undefined) {
/**
* D6:被排除后**没有别的候选** ⇒ 返回空串让调用方"保持原地退避"。
* ⛔ 绝不静默回退默认机、绝不把候选集外的地址当作兜底(那才是真的 R5)。
*/
log(
`[overlay-dir] ⚠ 候选链里除已排除项外无可用地址(候选 ${chain.urls.length} 条,` +
`排除 ${excluded.length} 条)⇒ 本次不换址(保持原地退避)`,
)
return {
url: '',
source: chain.source,
detail: `${chain.detail}|exhausted`,
refreshAfterSeconds: chain.refreshAfterSeconds,
}
}
return {
url,
source: chain.source,
detail: chain.detail,
refreshAfterSeconds: chain.refreshAfterSeconds,
}
}