覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 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
790 lines
35 KiB
TypeScript
790 lines
35 KiB
TypeScript
/**
|
||
* 覆盖网络 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,
|
||
}
|
||
}
|