feat(overlay): 覆盖网络线序①–⑮ 代码与测试产物入库

覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 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
This commit is contained in:
admin committed 2026-09-17 17:03:27 +08:00
1 parent 640813e84e
commit 146c3d25ef
53 files changed
+16187 -63

No files matched your search

+70 -5
View File
@@ -16,6 +16,12 @@
*
* `Reachability` 把这件事显式化:`via` 指向一个 `Rendezvous` 实现,`address` 是真实地址。
*
* ## P0-3:为什么要带 `networkId`
* 地址是**网内**的:`127.0.0.1:19000` 在 `ops` 里指向 `w-106` 的 relay 落点,在 `u:5` 里
* 可能指另一台。只带 `hostId` 的重达性描述**在多网下是有歧义的**,而歧义会以
* "打到另一张网的同名节点"这种最贵的形态暴露(静默串网)。⇒ 本类型**必带**网络维度,
* `parseReachability()` 从**逻辑名**(`<network_id>/<hostId>`)里取它,调用方不许自己拼。
*
* ## 边界(勿破)
* 本模块**只做地址的表征与解析**,不承载任何权威状态 —— 归属 / 租约 / 骨干资格
* 一律仍只由控制面写(与 `集群化改造方案 §1.3` 数据分层一致)。
@@ -23,15 +29,28 @@
* @module dshs/net/reachability
*/
import { OPS_NETWORK, parseLogicalName } from './relay/network.js'
/** 同机直连:Manager 与 worker 在同一台机器上,不经任何中转。 */
export const VIA_LOCAL = 'local'
/** 今天唯一在跑的中转方式 = **Manager 主机上的 sshd 反向隧道**(S4 之后应被 relay 取代)。 */
export const VIA_MANAGER_SSH = 'manager-ssh'
/**
* 自研中继单元 `dshs-relay`(S4):worker **只拨出**、relay **只绑回环**、
* Manager 连 relay 分配的回环口 ⇒ 去掉对 sshd 的长期依赖(传输方案 §10 定案)。
*/
export const VIA_RELAY = 'relay'
export interface Reachability {
/** 哪台 worker。 */
/** 哪台 worker(**裸 hostId**,不含网络段 —— 网络在下一行,两者分开表达)。 */
hostId: string
/**
* **属于哪张网**(P0-3 的结构性维度):`ops` | `u:<租户>` | 其它显式命名。
* 地址是**网内**语义,缺了它就等于"一张巨网 + 靠 ACL 兜"。
*/
networkId: string
/** **经谁可达** —— 一个 `Rendezvous` 实现的 id。 */
via: string
/** agent 的真实地址 `host:port`(**不含 scheme**)。 */
@@ -45,6 +64,14 @@ export interface HostAddressable {
hostId?: string
agentUrl?: string
reachability?: Reachability
/**
* **表里声明的会合形态**(`dsh_hosts.via` 原文,P0-3 加)。
*
* 为什么取址需要它:`via='relay'` 时 `agentUrl`(= `endpoint`)是 **relay 落点**而非直连地址,
* 解析不出时必须**失败关闭**而不是回落它(见 `agentBaseUrlOf`)。少了这一位,那种回落
* 在代码里**完全看不出来**(`agentUrl` 与真地址字符串同形)。
*/
via?: string
}
/**
@@ -61,39 +88,77 @@ export function agentBaseUrl(reach: Reachability): string {
* ⇒ 本函数返回的就是原先直接用的那个字符串,**行为零变化**。
*
* ⚠️ 两者皆缺时**抛错**,不返回空串 —— "静默打到空地址"是跨机下最难查的失败。
*
* ## ⛔ `via='relay'` 时**不许**回落到 `agentUrl`(P0-3)
* `agentUrl` 就是 `dsh_hosts.endpoint`。relay 语义下它存的是**落点**(`127.0.0.1:<动态口>`,
* 旧形态则是 Worker 侧口号),与 `reachability.address` **字符串同形、语义完全不同**。
* 解析失败(离线 / 跨网 / 快照陈旧)时回落它 ⇒ 请求被打到**控制面本机的同号端口**上,
* 而那个端口可能正被**另一张网的落点**或**某个实例**占着 —— 与 A1「假 404 / 静默写坏」同族。
* ⇒ 宁可**显式失败**(上层拿得到原因),也不发一个"看起来像地址"的东西出去。
*/
export function agentBaseUrlOf(host: HostAddressable): string {
if (host.reachability !== undefined) return agentBaseUrl(host.reachability)
if (host.via === VIA_RELAY) {
throw new Error(
`host "${host.hostId ?? '?'}" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint ` +
'(relay 语义下 endpoint 是落点,回落会打到本机同号端口)',
)
}
if (host.agentUrl !== undefined && host.agentUrl !== '') return host.agentUrl.replace(/\/+$/, '')
throw new Error(`host "${host.hostId ?? '?'}" 既无 reachability 也无 agentUrl:拒绝静默降级`)
}
/**
* 从旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用)。
* 从**逻辑名** + 旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用 · P0-3 收口)。
*
* 兼容面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸
* ## 为什么第一个参数是"逻辑名"而不是 `hostId`(P0-3 的唯一入口)
* 地址是**网内**语义。若这里只收裸 `hostId`,网络维度就得由**每个调用方**自己补 ——
* 而本线复盘里"散着拼字符串"最后必出三套不一致的口径。⇒ 收 `place` 的唯一入口改成收
* `<network_id>/<hostId>`:网络段由**本函数**切出来(`parseLogicalName`),调用方不碰。
* ⚠️ **裸 `hostId` 仍兼容**(⇒ 落 `ops`):过渡期不破坏现网调用方与既有单测。
*
* 强制面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸
* `host:port` —— 没写 scheme 时按 `http` 处理,与 `RemoteSpawner` 原先"直接把它当
* fetch 基址"的行为一致(fetch 会补 `http://`)。
*/
export function parseReachability(
hostId: string,
name: string,
endpoint: string,
via: string = VIA_MANAGER_SSH,
): Reachability {
const { network, hostId } = parseLogicalName(name)
const trimmed = endpoint.trim()
const matched = /^(https?):\/\/(.*)$/i.exec(trimmed)
if (matched !== null) {
return {
hostId,
networkId: network,
via,
address: matched[2].replace(/\/+$/, ''),
scheme: matched[1].toLowerCase() === 'https' ? 'https' : 'http',
}
}
return { hostId, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' }
return { hostId, networkId: network, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' }
}
/** `Reachability` → 旧 `endpoint` 字符串(与 `parseReachability` 互逆,回填/回滚用)。 */
export function toEndpoint(reach: Reachability): string {
return agentBaseUrl(reach)
}
/**
* 从 `host:port` 里取端口(覆盖网络 R3)。
*
* 为什么需要:relay 为每个注册端口在**它自己的回环**上开一条监听,回环口号由 `listen(0)`
* 动态分配 ⇒ Manager 拿不到、也推不出,只能拿「要拨的端口号」去 relay 的 `/status` 里查。
* 而那个号码就住在 `dsh_hosts.endpoint` 里(`http://127.0.0.1:19000`)⇒ 统一在这里剥出来,
* 别在调用方各写一遍 `split(':')`(IPv6 字面量会切错)。
*/
export function addressPort(address: string): number | undefined {
const idx = address.lastIndexOf(':')
if (idx < 0) return undefined
const raw = address.slice(idx + 1).trim()
if (!/^\d+$/.test(raw)) return undefined
const port = Number(raw)
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : undefined
}
+234
View File
@@ -0,0 +1,234 @@
/**
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
*
* ## 它解决的唯一问题
* 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**,
* 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于
* "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果
* **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。
*
* ## 为什么落在 DNS 层(而不是 dispatcher / 换 URL)
* 本项目**不引入 `undici` / `ws`**(`client.ts` 用的是 Node 22 内建的全局 `WebSocket`)。
* WHATWG `WebSocket` 不接受自定义 `dispatcher` ⇒「换地址但保留 SNI」在 URL 层没有落点:
* 把 URL 换成 IP 会**连带**把 SNI 换成 IP ⇒ 证书校验必然失败(除非关校验 = 权限/安全净变差)。
* 实测(2026-09-17,Node v22.22.2):全局 `WebSocket` 的建连**走 JS 层 `dns.lookup`**
* ⇒ 在解析层做定向覆盖,是**零新依赖、不动默认路径**的最小实现。
*
* ## 安全边界(为什么它不是"通用 hosts 劫持")
* - **白名单语义**:只覆盖 `DSHS_OVERLAY_ADDR_OVERRIDES` 里**逐字列出**的域名;其余一律走原始
* `dns.lookup`。**未配 ⇒ 本模块完全不生效**,行为与今天逐字一致(零退化)。
* - **不写 DNS、不改 hosts、不碰 `/etc`**:只改本进程内的解析结果,进程退出即消失。
* - **失败关闭**:形状非法的条目**不安装**并写一行日志 —— 不静默忽略、更不回落成"通用覆盖"。
* - **零新增暴露面**:不监听端口、不新增依赖、不新增凭据。
*
* ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5)
* ```
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89
* ```
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
*
* @module dshs/net/relay/addr-override
*/
import { createRequire } from 'node:module'
import { isIPv4, isIPv6 } from 'node:net'
/** `dns.lookup` 的回调形状(只用到 `(err, address, family)` 与 `all: true` 两种)。 */
type LookupCallback = (
err: NodeJS.ErrnoException | null,
address?: string | Array<{ address: string; family: number }>,
family?: number,
) => void
/** `node:dns` 的最小面(用 `createRequire` 取,保证改的是 **net/undici 实际用的那个单例**)。 */
interface DnsModule {
lookup: (hostname: string, options?: unknown, callback?: LookupCallback) => void
promises: { lookup: (hostname: string, options?: unknown) => Promise<unknown> }
}
export interface AddrOverride {
/** 小写域名(**逐字匹配**,不做后缀/通配匹配)。 */
host: string
/** 覆盖后的直连地址。 */
ip: string
/** 地址族(由 IP 字面量推得)。 */
family: 4 | 6
}
/** 已安装的覆盖表 —— 模块级唯一状态(`ensureOverlayAddrOverrides` 幂等)。 */
const installed = new Map<string, AddrOverride>()
/** 已经打过一次补丁(**只打一次**;反复打会把自己的包装再包一层)。 */
let patched = false
/** 已经播报过的条目(避免每次建连都刷日志)。 */
const announced = new Set<string>()
/** 原始实现(仅供测试精确还原)。 */
let originalLookup: DnsModule['lookup'] | undefined
let originalPromisesLookup: DnsModule['promises']['lookup'] | undefined
/** 该字符串能不能当"域名"用:非空、含点、无协议/路径/端口/空白、不是 IP 字面量。 */
function isHostnameLike(host: string): boolean {
if (host === '') return false
if (!host.includes('.')) return false
if (isIPv4(host) || isIPv6(host)) return false
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(host)
}
/** IP 字面量 ⇒ 地址族;不是字面量 ⇒ `undefined`(**不接受主机名**:那会把"覆盖"变成"再解析一次")。 */
function ipFamily(ip: string): 4 | 6 | undefined {
if (isIPv4(ip)) return 4
if (isIPv6(ip)) return 6
return undefined
}
/**
* `host=ip`(逗号多值)⇒ 覆盖表。
*
* `bad` 装**非法条目的原文** —— 调用方必须把它播出去:静默丢弃会让"我明明配了"看起来像
* "覆盖没生效",而这正是本项目反复踩过的"静默失败"形态。
*/
export function parseAddrOverrides(raw: string): { list: AddrOverride[]; bad: string[] } {
const list: AddrOverride[] = []
const bad: string[] = []
const seen = new Set<string>()
for (const item of raw.split(',')) {
const spec = item.trim()
if (spec === '') continue
const eq = spec.indexOf('=')
const host = eq < 0 ? '' : spec.slice(0, eq).trim().toLowerCase()
const ip = eq < 0 ? '' : spec.slice(eq + 1).trim()
const family = ipFamily(ip)
if (!isHostnameLike(host) || family === undefined) {
bad.push(spec)
continue
}
if (seen.has(host)) continue
seen.add(host)
list.push({ host, ip, family })
}
return { list, bad }
}
/** 按 host 造一个 `lookup` 结果(尊重 `all` / `family` 两个入参形状)。 */
function overriddenLookup(
hit: AddrOverride,
options: unknown,
callback: LookupCallback,
): void {
const opts = (
options === null || typeof options !== 'object' ? {} : options
) as { all?: boolean; family?: number }
const family = opts.family === 4 || opts.family === 6 ? opts.family : hit.family
// 请求的地址族与覆盖项不符 ⇒ 交回调用方语义(给 ENOTFOUND),**不要**回一个错族的地址。
if (family !== hit.family) {
process.nextTick(() => {
// `node:dns` 的 ENOTFOUND 带 `.hostname`(不在 `ErrnoException` 的公开类型里)⇒ 显式补上,
// 让"配了 IPv4、却按 IPv6 问"这件事在报错里一眼可读。
const err = new Error(
`getaddrinfo ENOTFOUND ${hit.host} (addr-override 只提供 IPv${hit.family})`,
) as NodeJS.ErrnoException & { hostname?: string }
err.code = 'ENOTFOUND'
err.errno = -3008
err.syscall = 'getaddrinfo'
err.hostname = hit.host
callback(err)
})
return
}
if (opts.all === true) {
process.nextTick(() => callback(null, [{ address: hit.ip, family: hit.family }]))
return
}
process.nextTick(() => callback(null, hit.ip, hit.family))
}
/** 把回调版 `lookup` 包一层:**只**拦覆盖表里的域名,其余原样转发。 */
function wrapLookup(orig: DnsModule['lookup']): DnsModule['lookup'] {
return function patchedLookup(hostname: string, options?: unknown, callback?: LookupCallback): void {
let opts = options
let cb = callback
if (typeof opts === 'function') {
cb = opts as LookupCallback
opts = {}
}
const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined
if (hit === undefined || cb === undefined) {
orig(hostname, opts, cb as LookupCallback)
return
}
overriddenLookup(hit, opts, cb)
}
}
/** 打补丁(**只打一次**)。用 `createRequire` 取同一单例,确保 `net` / undici 都走新实现。 */
function installPatch(): void {
const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule
if (originalLookup === undefined) originalLookup = nodeDns.lookup
if (originalPromisesLookup === undefined) originalPromisesLookup = nodeDns.promises.lookup
nodeDns.lookup = wrapLookup(originalLookup)
nodeDns.promises.lookup = async (hostname: string, options?: unknown): Promise<unknown> => {
const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined
if (hit === undefined) return originalPromisesLookup!(hostname, options)
const opts = (options === null || typeof options !== 'object' ? {} : options) as { all?: boolean }
if (opts.all === true) return [{ address: hit.ip, family: hit.family }]
return { address: hit.ip, family: hit.family }
}
}
/**
* **幂等安装**:解析 `raw`(缺省读 `DSHS_OVERLAY_ADDR_OVERRIDES`)并装覆盖;返回当前生效的整表。
*
* ⚠️ 幂等但**累加**:多次调用可把新域名并进来(重连 / 多个建连点共用),不会重复打补丁。
*/
export function ensureOverlayAddrOverrides(
raw: string = process.env.DSHS_OVERLAY_ADDR_OVERRIDES ?? '',
log: (line: string) => void = (line) => process.stdout.write(`${line}\n`),
): readonly AddrOverride[] {
const { list, bad } = parseAddrOverrides(raw)
for (const spec of bad) {
log(`[addr-override] ⛔ 忽略非法条目 "${spec}"(语法应为 <域名>=<IP>,逗号分隔)`)
}
// 未配 ⇒ **完全不生效**(连补丁都不打)⇒ 与今天逐字一致。
if (installed.size === 0 && list.length === 0) return []
for (const o of list) {
const prev = installed.get(o.host)
if (prev !== undefined && prev.ip !== o.ip) {
log(`[addr-override] ⚠ ${o.host} 的覆盖地址被改写:${prev.ip} -> ${o.ip}`)
announced.delete(`${o.host}=${o.ip}`)
}
installed.set(o.host, o)
}
if (!patched) {
installPatch()
patched = true
log(`[addr-override] 已启用地址覆盖(白名单 ${installed.size} 条;DNS 层定向改写,SNI 仍为域名)`)
}
for (const o of installed.values()) {
const key = `${o.host}=${o.ip}`
if (announced.has(key)) continue
announced.add(key)
log(`[addr-override] 覆盖生效:${o.host} -> ${o.ip}(IPv${o.family},直连该 IP,TLS SNI 仍为 ${o.host})`)
}
return [...installed.values()]
}
/** 当前生效的覆盖表(只读快照)。 */
export function currentAddrOverrides(): readonly AddrOverride[] {
return [...installed.values()]
}
/**
* **仅供测试**:撤掉补丁并清空覆盖表。
* ⛔ 生产路径不许调用 —— 它会把"已生效"悄悄退化成"没生效"。
*/
export function resetOverlayAddrOverridesForTest(): void {
if (originalLookup !== undefined && originalPromisesLookup !== undefined) {
const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule
nodeDns.lookup = originalLookup
nodeDns.promises.lookup = originalPromisesLookup
}
originalLookup = undefined
originalPromisesLookup = undefined
installed.clear()
announced.clear()
patched = false
}
File diff suppressed because it is too large. Load diff
+312
View File
@@ -0,0 +1,312 @@
/**
* `RelayDialer` —— **会合可换机的收口件**(覆盖网络 R5)。
*
* ## 它解决的确切问题
* R1–R4 的落点是「relay 在**自己主机**的回环上开监听,Manager 去连那个回环口」。这带来一条
* 隐式前提:**Manager 必须与 relay 同机**(否则它根本够不到 relay 的 `127.0.0.1`)。
* 于是"中继可换机 / 可多实例 / Manager 不再兼任会合"这件事**做不到**。
*
* ## 做法(关键的一步换位)
* 把落点从 **relay 主机** 搬到 **Manager 本机**:
* - Manager 也像 worker 一样**只拨出**一条 wss(`RelayClient` 的 `dialer` 模式);
* - 本类在 Manager 自己的回环上预先绑一**小池**本机口,每个口号"指向"某个 `(hostId, port)`;
* - 有连接进来 ⇒ `client.openStream(hostId, port)` ⇒ 拿到一条 mux 流 ⇒ 与本机 socket 对接。
*
* ⇒ 对外形态**完全没变**(还是"一个 `127.0.0.1:port`"),所以 `translateEndpoint` 的调用方
* (`proxy.ts` / `RemoteSpawner` / `RemoteUserFs`)**一行都不用改**;变的是这个口号从哪来 ——
* 从"relay 主机"变成"Manager 自己"。**relay 放哪台机器都不再影响 Manager。**
*
* ## 为什么用「预绑池」而不是「按需 listen」
* `translateEndpoint` 是**同步**的(在请求路径上),而 `listen()` 是异步的 —— 按需绑会出现
* "口号已经返回、监听还没起来"的竞态窗口(表现为**首次**请求偶发连接被拒,最难查的那种)。
* 池在**启动时**一次性绑好 ⇒ 拿口号是纯查表,零竞态;容量上限也变成显式、可观测的。
*
* ⚠️ 池口号必须**避开 OS 临时端口段(32768–60999)**与实例端口段,否则会偶发撞号。
*
* @module dshs/net/relay/dialer
*/
import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net'
import type { RelayClient } from './client.js'
import { parseLogicalName } from './network.js'
export interface RelayDialerOptions {
/** **拨号方模式**的客户端(`dialer: true`)。 */
client: RelayClient
/** 本机口池起点。**必须避开** OS 临时端口段与实例端口段。 */
portBase: number
/** 本机口池扫描宽度。 */
portSpan: number
/** 池大小 = 最多同时挂多少个 `(hostId, port)` 落点。默认 64。 */
poolSize?: number
log?: (line: string) => void
}
interface Slot {
server: TcpServer
localPort: number
/** 当前指向的 `(hostId, port)`;`undefined` = 空闲可分配。 */
key?: string
/** 在途连接数(决定该槽位能不能被回收重分配)。 */
active: number
lastUsed: number
}
const DEFAULT_POOL = 64
export class RelayDialer {
private readonly client: RelayClient
private readonly log: (line: string) => void
private readonly portBase: number
private readonly portSpan: number
private readonly poolSize: number
/**
* **本拨号通道声明的那张网**(P0-3)—— 从客户端拿,**不另配一份**。
* 两处各配一次就会出现"通道在 A 网、判据按 B 网"这种最难查的错配。
*/
private readonly network: string
private readonly slots: Slot[] = []
private bound = 0
private denied = 0
/**
* **未分配槽位被连接命中的次数**(只丢弃那条连接、槽位原样保留)—— 序⑮ 缺陷 B 的判别器。
* 这个计数存在的唯一理由:该路径过去**零日志零计数**,缺陷只在下一次分配时才以
* `ECONNREFUSED` 的样子露面(现场看不出"这个口早就废了")。
*/
private stray = 0
private closed = false
constructor(opts: RelayDialerOptions) {
this.client = opts.client
this.log = opts.log ?? ((s: string) => process.stdout.write(`${s}\n`))
this.portBase = opts.portBase
this.portSpan = opts.portSpan
this.poolSize = opts.poolSize ?? DEFAULT_POOL
this.network = opts.client.networkId
}
/** 绑定整个池。**在 Manager 开始服务前 await 它**(否则 `localPortFor()` 恒返回 `undefined`)。 */
async start(): Promise<void> {
const next = this.portBase
let p = next
const limit = this.portBase + this.portSpan
for (let i = 0; i < this.poolSize; i++) {
let slot: Slot | undefined
while (p < limit) {
const port = p++
const server = createTcpServer({ pauseOnConnect: true })
const ok = await new Promise<boolean>((resolve) => {
const onErr = (): void => {
server.off('error', onErr)
resolve(false)
}
server.once('error', onErr)
server.listen(port, '127.0.0.1', () => {
server.off('error', onErr)
resolve(true)
})
})
if (!ok) {
server.close()
continue
}
slot = { server, localPort: port, active: 0, lastUsed: 0 }
break
}
if (slot === undefined) {
this.log(`[relay-dialer] 口池只绑到 ${i}/${this.poolSize}(${this.portBase}..${limit} 已用尽)`)
break
}
slot.server.on('connection', (tcp: Socket) => this.onConn(slot as Slot, tcp))
this.slots.push(slot)
}
this.bound = this.slots.length
this.log(`[relay-dialer] 本机落点池就绪:${this.bound} 个口(${this.portBase}..${this.portBase + this.portSpan})`)
}
/**
* 取 `(逻辑名, port)` 在本机的落点口号 —— **同步**返回,可直接用在 `translateEndpoint` 里。
*
* 返回 `undefined` 的四种情形**都必须让上层失败关闭**(不许回退成"原样透传",那会变成
* "Manager 拿 worker 侧口号往自己本机拨"的老毛病):
* ① 池未就绪;② 没有空闲槽位且都在途(容量到顶,会在日志里点名);③ 参数非法;
* ④ **跨网**(P0-3,见下)。
*
* ## 入参是逻辑名(P0-3)
* 键必须是 `<network_id>/<hostId>:<port>`:只按裸 `hostId` 建键时,两张网各有一台同名
* host 会**互相复用同一个落点** —— 也就是"给 A 网的口,发给了 B 网的目标"。
*/
localPortFor(name: string, port: number): number | undefined {
if (this.closed || this.bound === 0) return undefined
if (name === '' || !Number.isInteger(port) || port <= 0 || port > 65535) return undefined
/**
* ⛔ **跨网 ⇒ 本机口池直接拒**(P0-3 · 控制面侧的那道门)。
*
* 本通道只声明了一张网(`RelayClient.networkId`),relay 也**只会在这张网里找目标**
* ⇒ 按"另一张网的逻辑名"分配落点 = 把一个**必然失败的落点**当成可用地址发出去
* (上层会拿 `127.0.0.1:<口>` 去连,被 relay 拒、或撞上别的网的口)。
* 失败关闭 + **点名两边是哪张网**:不许让它退化成"连不通"。
*/
const { network } = parseLogicalName(name)
if (network !== this.network) {
this.denied += 1
this.log(
`[relay-dialer] ⛔ 跨网拒绝 ${name}:${port}(本通道 network=${this.network},目标 network=${network})`,
)
return undefined
}
const key = `${name}:${port}`
const now = Date.now()
const hit = this.slots.find((s) => s.key === key)
if (hit !== undefined) {
hit.lastUsed = now
return hit.localPort
}
/**
* 先找从未分配过的空槽,再找"空闲且最久没用过"的槽(LRU 回收)—— 两者都不动在途连接。
*
* 🔴 **两个候选都必须"真的还在听"**(`server.listening`,序⑮ 缺陷 B):
* 一旦某个槽位的监听已经关掉却还留在 `slots` 里,这里就会把一个**死口号**当成可用落点
* 发出去 ⇒ 调用方 `ECONNREFUSED`(实测 = `/api/dsh/enter` 回 500 `fetch failed`)。
* `listening` 是"这笔账到底还算不算数"的**唯一权威来源**;宁可失败关闭(下面点名),
* ⛔ 也绝不把一个没人听的口号交出去。
*/
const free = this.slots.find((s) => s.key === undefined && s.server.listening) ?? this.lruIdle()
if (free === undefined) {
this.denied += 1
const dead = this.slots.filter((s) => !s.server.listening).map((s) => s.localPort)
this.log(
dead.length === 0
? `[relay-dialer] 口池已满(${this.bound} 个全在途)⇒ 拒绝 ${key}(失败关闭,不静默回退)`
: `[relay-dialer] ⛔ 口池无可成交槽位(${this.bound} 个口里 ${dead.length} 个已不在听:${dead.join(',')})⇒ 拒绝 ${key}(失败关闭,不静默回退)`,
)
return undefined
}
free.key = key
free.lastUsed = now
// **必须可见**:不打印的话,"这个回环口号对应哪台机的哪个端口"在运行期完全不可查,
// 出问题只能靠猜(这正是本线反复踩的"静默"同族病)。重分配(LRU 回收)单独标出来。
this.log(`[relay-dialer] 落点 127.0.0.1:${free.localPort} -> ${key}`)
return free.localPort
}
private lruIdle(): Slot | undefined {
let best: Slot | undefined
for (const s of this.slots) {
if (s.active > 0) continue
// 不在听的槽⛔ 不许当"可回收空位"(同上:宁可失败关闭,也不发死口号)
if (!s.server.listening) continue
if (best === undefined || s.lastUsed < best.lastUsed) best = s
}
return best
}
/**
* 本机连接 ⇒ 拨号流 ⇒ 对接。
*
* `pauseOnConnect` 让内核替我们挡住"流还没开好时的请求字节"⇒ 不存在丢头几个字节的竞态
* (与 relay 侧 `onManagerConn` 用的是同一条手法)。拨号失败**必须**把连接拆掉:让调用方
* 看到连接被重置,而不是一个挂住不动的请求。
*/
private onConn(slot: Slot, tcp: Socket): void {
tcp.setNoDelay(true)
const key = slot.key
if (key === undefined) {
/**
* 🔴 **未分配槽位被一条连接命中**(序⑮ · 缺陷 B 的修复点)。
*
* ⛔ 这里**绝不许** `slot.server.close()` —— 关掉的只是"这个口的服务器",
* 而槽位**仍留在 `slots` 里、`key` 仍是 `undefined`** ⇒ `localPortFor()` 的
* `slots.find((s) => s.key === undefined)` **下次还会选中它**,把一个**已经没人听**的
* 口号当成可用落点发出去 ⇒ 调用方拿 `ECONNREFUSED`(**用户可见**:
* `POST /api/dsh/enter` 回 500 `fetch failed: connect ECONNREFUSED 127.0.0.1:25000`;
* 47 上实测 3 条,2026-09-17 16:00:31/35/40;现场表现为"池报 64 个口、`ss` 只见 63")。
*
* 另一种"看似彻底"的修法(顺手把槽位从 `slots` 摘掉)**是净退化**:那等于让**任意一条本地
* 连接**都能永久蚕食池容量 —— 扫 64 次就把池扫空(R11)。
* ⇒ 正确做法 = **只丢弃这条无法路由的连接,槽位原样保留**(它本来就没被分配过,
* 监听继续有效、容量不变)。同时**必须留下计数与点名日志**:这个缺陷最难查之处
* 恰恰是这条路径过去**零日志**,只在下一次分配时以 `ECONNREFUSED` 的样子露面。
*/
this.stray += 1
this.log(
`[relay-dialer] ⛔ 未分配落点 127.0.0.1:${slot.localPort} 收到一条连接 ⇒ 只丢弃该连接、槽位保留(累计 ${this.stray} 次)`,
)
tcp.destroy()
return
}
const idx = key.lastIndexOf(':')
/**
* ⛔ `DIAL.target` 是**裸 hostId**,不含网络段(服务端会显式拼上**拨号方自己**那张网,
* 见 `server.ts#onDial`)。键从 P0-3 起是逻辑名 ⇒ 这里**必须**剥掉网络段再发。
* 漏剥的表现极具误导性:服务端按 `logicalName('ops', 'ops/w-106')` = `ops/ops/w-106` 找会话,
* 找不到 ⇒ 回 `target-offline`("节点离线"),而节点其实**好好在册** ——
* 实测踩过一次(2026-09-17 07:32 线上,`refused: 8 / streamsOpened: 0`)。
*/
const { hostId } = parseLogicalName(key.slice(0, idx))
const port = Number(key.slice(idx + 1))
void this.client
.openStream(hostId, port, 5_000)
.then((duplex) => {
if (tcp.destroyed) {
duplex.destroy()
return
}
slot.active += 1
let released = false
const release = (): void => {
if (released) return
released = true
slot.active -= 1
}
tcp.on('close', release)
duplex.on('close', release)
tcp.on('error', () => tcp.destroy())
duplex.on('error', () => tcp.destroy())
tcp.pipe(duplex).pipe(tcp)
tcp.resume() // `pauseOnConnect` 到此结束:流已就绪,可以放字节了
})
.catch((err: unknown) => {
this.log(`[relay-dialer] 拨 ${key} 失败:${err instanceof Error ? err.message : String(err)}`)
tcp.destroy()
})
}
close(): void {
this.closed = true
for (const s of this.slots) {
try {
s.server.close()
} catch {
/* 已关 */
}
}
this.slots.length = 0
this.bound = 0
}
/**
* 诊断视图(`/status` 与管理面用):池有多大、几个已分配、被拒过几次、被误连过几次。
*
* ⚠️ `pool` 报的是**实际还在听的槽位数**(不是 `start()` 那一刻的那个数):它必须与
* "`ss` 数得出来的池口号数"**恒等**,否则就是"池在撒谎"(序⑮ 判据 a)。恒等不是靠这个写法
* 保证的(修复已让槽位永不被单方面关掉),而是靠它把任何未来的关闭动作**立刻显形**。
*/
status(): {
pool: number
assigned: number
denied: number
stray: number
slots: { key: string; localPort: number; active: number }[]
} {
return {
pool: this.slots.filter((s) => s.server.listening).length,
assigned: this.slots.filter((s) => s.key !== undefined).length,
denied: this.denied,
stray: this.stray,
slots: this.slots
.filter((s) => s.key !== undefined)
.map((s) => ({ key: s.key as string, localPort: s.localPort, active: s.active })),
}
}
}
+789
View File
@@ -0,0 +1,789 @@
/**
* 覆盖网络 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,
}
}
+74
View File
@@ -0,0 +1,74 @@
/**
* 一条 mux 流的**应用侧端口**(覆盖网络 R5 —— 拨号方用)。
*
* ## 语义
* 与 `net.Socket` 同形,可直接 `a.pipe(b)`:
* - **写** ⇒ `onOut(chunk)`(由调用方决定往哪送,这里就是打进 mux);
* - **读** ⇒ `feed(chunk)`(对端来的字节)、`eof()`(对端关闭)。
*
* ## 为什么不做成 `net.Socket`
* 拨号方的"落点"不在本机任何 TCP 端口上 —— 它就是一条多路复用的逻辑流。用一个 `Duplex`
* 表示,调用方既能直接 `pipe()`,也不需要为它准备 fd(**零新增监听口**)。
*
* @module dshs/net/relay/duplex
*/
import { Duplex, type DuplexOptions } from 'node:stream'
export interface MuxDuplexOptions extends DuplexOptions {
/** 应用写下来的字节往哪去(返回 `false` = 触发背压,调用方稍后应 `resumeOut()`)。 */
onOut: (chunk: Buffer) => boolean
/** 流出向关闭 / 被销毁时只调一次(用于给对端补 `CLOSE`)。 */
onClosed?: () => void
}
export class MuxDuplex extends Duplex {
private readonly onOut: (chunk: Buffer) => boolean
private readonly onClosed: () => void
private ended = false
constructor(opts: MuxDuplexOptions) {
// `allowHalfOpen`:对端先关一半不代表本侧不能再写(HTTP/1.1 里很常见),保持半开。
super({ ...opts, allowHalfOpen: true })
this.onOut = opts.onOut
this.onClosed = opts.onClosed ?? ((): void => undefined)
}
/** 读侧由 `feed()` 驱动;这里无事可做(**不能**返回错误,否则会误报"流坏了")。 */
override _read(): void {}
override _write(chunk: Buffer | string, _enc: BufferEncoding, cb: (err?: Error | null) => void): void {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
this.onOut(buf)
cb()
}
override _final(cb: (err?: Error | null) => void): void {
this.closeOnce()
cb()
}
override _destroy(err: Error | null, cb: (e?: Error | null) => void): void {
this.closeOnce()
cb(err)
}
/** 对端来的字节 ⇒ 交给读侧。返回 `false` = 读侧水位满(**不是错误**)。 */
feed(chunk: Buffer): boolean {
if (this.destroyed || this.ended) return false
return this.push(chunk)
}
/** 对端关闭 ⇒ 结束读侧(读方会收到 `end`),但**写侧仍可写**(半开)。 */
eof(): void {
if (this.ended || this.destroyed) return
this.ended = true
this.push(null)
}
private closeOnce(): void {
const first = !this.ended
this.ended = true
if (first) this.onClosed()
}
}
+656
View File
@@ -0,0 +1,656 @@
/**
* 覆盖网络 序③:**四层密钥模型 + 离线信任根**(一机一钥 / 入网即签名 / 本地校验 / 可单台吊销)。
*
* ## 它治的病(一句话)
* `keys.ts` 的 HMAC 预共享密钥解决的是"**爆炸半径 = 那一台**",但**没有解决"控制面自己被攻破"**:
* 密钥表放在控制面(47)上,谁改得动那张表,谁就能塞进一台自己的节点 —— 而所有对端都会照单全收,
* 因为对端**没有任何独立的判据**可以否决它。
*
* ⇒ 本模块引入一个控制面**看不到也改不了**的信任源(根 + 签名者),让"**能不能入网**"这件事
* 由**节点自己在本地**判定(D2:校验位置 = 节点本地,relay 侧只做**辅助**准入)。
*
* ## 四层(形状照抄 Tailnet Lock,见 `覆盖网络_问题逐条推演与解决方案_20260916.md §A4`)
*
* | 层 | 放哪 | 用途 | 丢失后果 |
* |---|---|---|---|
* | **根(离线)** | 用户手里(本工作区 `0600` 文件 / 纸质恢复码 / 离线 U 盘) | **只**授权/撤销"签名者" | 最严重 ⇒ 全网重建 |
* | **签名者(在线,多把)** | 管理员设备(现网 = 47,`/etc/dshs/overlay-signer-key.pem`) | 签发节点入网凭据 | 换一把(根仍在) |
* | **节点密钥(每机一把)** | 每台机器(`0600`,属主正确) | 设备身份;对接时证明"握有私钥" | 该设备重签 |
* | **会话密钥(内存)** | 隧道 | 传输加密 | 无感 |
*
* ⚠️ **根密钥的用途边界(⛔ 别搞错)**:它**只用于授权 / 撤销"签名者"** ——
* **不签发节点、不加密数据、不参与会话**。⇒ "根在线"**不带来任何性能问题**,唯一影响是**安全**
* 与**恢复**(`交接单_一机一钥与信任根_20260917.md §4.3.1`)。
*
* ## 🔴 四条不可动摇的判据(改动前先读)
* 1. **失败关闭**:任一校验未命中 ⇒ 返回**带具体原因**的 `ok:false`,⛔ 绝不静默回退到共享凭据、
* ⛔ 绝不回落默认机。原因必须**结构化**(`IdentityReason`),否则"配错了"会伪装成"网络不通"
* (本线反复复发的那类病,见 `交接单_relay落地R2-R4 §12` 的 A1)。
* 2. **不可验 = 不接受**:受信根 / 受信签名者为空 ⇒ **拒绝**(与 `directory.ts` 的 `no-trusted-keys` 同款)。
* 3. **签名覆盖全部字段**:载荷用**规范拼接**(`*Payload()`),⛔ 不用 `JSON.stringify`
* (键序变化 ⇒ 载荷变 ⇒ 验签结果随 TS 版本漂移;`directory.ts` 已踩过这条)。
* 4. **本模块只做身份,不做寻址**:不碰 `network.ts` 的地址规划、不碰 `directory.ts` 的地址下发。
* 它回答的唯一问题是「**这台机器是不是被授权进入这张网**」。
*
* 纯函数(载荷 / 解析 / 签发 / 验签 / 指纹)与 IO(密钥文件读写)分开放 —— 与 `network.ts` /
* `directory.ts` 同一风格,前者可单测、后者只做搬运。
*
* @module dshs/net/relay/identity
*/
import {
createHash,
createPrivateKey,
createPublicKey as cryptoCreatePublicKey,
generateKeyPairSync,
randomBytes,
sign as cryptoSign,
verify as cryptoVerify,
} from 'node:crypto'
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
import { publicKeyFrom } from './directory.js'
import { isHostId, isNetworkId } from './network.js'
/** 文档结构版本。未知版本 ⇒ 拒绝(而不是"尽力解析")。 */
export const IDENTITY_VERSION = 1
/** 载荷标签:换格式时改这里 ⇒ 老节点**验签失败 ⇒ 失败关闭**(不会误读新格式)。 */
export const SIGNER_SET_TAG = 'dshs-overlay-signerset/v1'
export const NODE_GRANT_TAG = 'dshs-overlay-nodegrant/v1'
export const REVOCATION_TAG = 'dshs-overlay-revocation/v1'
/**
* Ed25519 会话证明(node proof)的**域分隔串**。
*
* 为什么要它:节点私钥**同时**用来签入网凭据的持有证明与握手的挑战签名 —— 两种签名若不带上
* 各自的域标签,一个签名就可能被搬到另一个场景里重用(跨协议签名混淆)。带上标签后两者互不可用。
*/
export const PROOF_TAG = 'dshs-overlay-node-proof/v1'
/** 节点密钥文件的默认落点(**0600**,属主 = 跑 dsh 的那个用户)。 */
export const DEFAULT_NODE_KEY_FILE = '/etc/dshs/node.key'
// ── 类型 ────────────────────────────────────────────────────────────────────
/** **签名者集合**(由**根**签发)—— 回答"哪些签名者被授权签发节点"。 */
export interface SignerSet {
version: number
/** 该集合所属的网。**签名者只在它被授权的网里有效**(跨网签发 ⇒ 拒)。 */
network: string
/** 签发时间(ISO 8601)。 */
issuedAt: string
/** 受根授权的签名者公钥(PEM 或裸 32 字节 hex/base64)。 */
signers: string[]
}
/** **节点入网凭据**(由**签名者**签发)—— 回答"这台机器被授权进入这张网"。 */
export interface NodeGrant {
version: number
network: string
hostId: string
/** 该节点的公钥(**裸 32 字节 hex 小写**)。 */
nodeKey: string
issuedAt: string
/** 过期时间(ISO 8601);**空串 = 不过期**(运维网的常驻机器适用,照 Tailnet 的 tagged 语义)。 */
expiresAt: string
}
/**
* **吊销清单**(由**签名者**签发)—— 撤销单台 ≈ 只动这一份清单,**不牵动全网换密钥**。
*
* ⚠️ 撤销**签名者**不走这里(那是"根签一份新的 SignerSet"的职责);本清单只撤**节点**。
*/
export interface RevocationList {
version: number
network: string
issuedAt: string
/** 被撤销的 hostId(再签一份 grant 也不生效 —— 必须先把它从清单里移除)。 */
hosts: string[]
/** 被撤销的**节点公钥**(设备被盗场景:hostId 可能被复用,公钥不会)。 */
nodeKeys: string[]
}
/** 拒绝原因(**结构化**,直接进日志/HELLO_ERR 的 `why`)。 */
export type IdentityReason =
| 'bad-document'
| 'no-signature'
| 'bad-signature-length'
| 'no-trusted-keys'
| 'signature-mismatch'
| 'network-mismatch'
| 'host-mismatch'
| 'key-mismatch'
| 'expired'
| 'not-yet-valid'
| 'revoked-host'
| 'revoked-node-key'
/** 通用验签结论:**失败一律带具体原因**。 */
export type IdentityVerdict<T> = { ok: true; doc: T; payload: string } | { ok: false; reason: IdentityReason }
// ── 纯函数:载荷(规范拼接,⛔ 不用 JSON.stringify)────────────────────────────
export function signerSetPayload(set: SignerSet): string {
return [SIGNER_SET_TAG, String(set.version), set.network, set.issuedAt, set.signers.join(',')].join('\n')
}
export function nodeGrantPayload(grant: NodeGrant): string {
return [
NODE_GRANT_TAG,
String(grant.version),
grant.network,
grant.hostId,
grant.nodeKey,
grant.issuedAt,
grant.expiresAt,
].join('\n')
}
export function revocationPayload(list: RevocationList): string {
return [REVOCATION_TAG, String(list.version), list.network, list.issuedAt, list.hosts.join(','), list.nodeKeys.join(',')].join('\n')
}
/**
* 会话证明的载荷:`<PROOF_TAG>\n<challenge>`。
*
* `challenge` 由调用方决定(relay 握手用 `hostId|ts|nonce|portsCsv`,与本模块解耦)——
* 本模块只保证"域分隔"与"签名/验签用的是同一个串"。
*/
export function proofPayload(challenge: string): string {
return `${PROOF_TAG}\n${challenge}`
}
// ── 纯函数:公钥 / 指纹 ───────────────────────────────────────────────────────
/** 裸 32 字节 Ed25519 公钥 → 小写 hex;解析不出来 ⇒ `undefined`。 */
export function normalizePublicKey(spec: string): string | undefined {
const key = publicKeyFrom(spec)
if (key === undefined) return undefined
const der = key.export({ type: 'spki', format: 'der' }) as Buffer
// SPKI DER 前 12 字节是固定前缀,其后 32 字节即裸公钥(directory.ts 已用同一常量)。
return der.subarray(der.length - 32).toString('hex')
}
/**
* 公钥指纹:`sha256(裸 32 字节)` 的前 16 位 hex。
*
* **为什么不是直接显示公钥**:指纹是给人看的(日志 / 清点 / 演练报告),16 位 hex 足够唯一,
* 且**不可反推**。判据里"两台机器指纹不同"要的正是这个。
* ⚠️ 指纹**不是**校验依据(那是验签的职责)⇒ 永不参与任何准入判断。
*/
export function nodeKeyFingerprint(spec: string): string | undefined {
const raw = normalizePublicKey(spec)
if (raw === undefined) return undefined
return createHash('sha256').update(Buffer.from(raw, 'hex')).digest('hex').slice(0, 16)
}
/**
* 从**私钥 PEM** 推出对应的裸公钥 hex —— 免去"到处再存一份公钥"的错配风险。
*
* Node 的 `createPublicKey()` **直接吃私钥 `KeyObject`**(PKCS#8 里本就带着公钥),
* 所以这里一行就够;写成函数是为了让"公钥从哪来"在调用方是**同一个**答案。
*/
export function publicKeyOfPrivate(privateKeyPem: string): string {
const priv = createPrivateKey(privateKeyPem)
if (priv.asymmetricKeyType !== 'ed25519') {
throw new Error(`identity key must be ed25519 (got ${String(priv.asymmetricKeyType)})`)
}
const der = cryptoCreatePublicKey(priv).export({ type: 'spki', format: 'der' }) as Buffer
return der.subarray(der.length - 32).toString('hex')
}
// ── 纯函数:解析(只校验形状与取值域,**不验签**)─────────────────────────────
function asRecord(raw: unknown): Record<string, unknown> | undefined {
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined
return raw as Record<string, unknown>
}
function isoString(v: unknown, maxLen = 40): string | undefined {
if (typeof v !== 'string' || v.length > maxLen) return undefined
if (Number.isNaN(Date.parse(v))) return undefined
return v
}
function specList(v: unknown, max = 16): string[] | undefined {
if (!Array.isArray(v) || v.length > max) return undefined
const out: string[] = []
for (const item of v) {
if (typeof item !== 'string' || item.trim() === '') return undefined
const norm = normalizePublicKey(item)
if (norm === undefined) return undefined
if (!out.includes(norm)) out.push(norm)
}
return out
}
/** 严格解析签名者集合。任一条不合规 ⇒ `undefined`(宁可不接受,也不要"半成品")。 */
export function parseSignerSet(raw: unknown): SignerSet | undefined {
const r = asRecord(raw)
if (r === undefined) return undefined
if (r.version !== IDENTITY_VERSION) return undefined
const network = typeof r.network === 'string' ? r.network.trim() : ''
if (!isNetworkId(network)) return undefined
const issuedAt = isoString(r.issuedAt)
if (issuedAt === undefined) return undefined
const signers = specList(r.signers)
// **空集合 = 无授权签名者** ⇒ 不合法:它会让"签名者全部被撤"与"配置忘了填"长得一样。
if (signers === undefined || signers.length === 0) return undefined
return { version: IDENTITY_VERSION, network, issuedAt, signers }
}
/** 严格解析节点凭据。 */
export function parseNodeGrant(raw: unknown): NodeGrant | undefined {
const r = asRecord(raw)
if (r === undefined) return undefined
if (r.version !== IDENTITY_VERSION) return undefined
const network = typeof r.network === 'string' ? r.network.trim() : ''
if (!isNetworkId(network)) return undefined
const hostId = typeof r.hostId === 'string' ? r.hostId.trim() : ''
if (!isHostId(hostId)) return undefined
const nodeKey = typeof r.nodeKey === 'string' ? normalizePublicKey(r.nodeKey) : undefined
if (nodeKey === undefined) return undefined
const issuedAt = isoString(r.issuedAt)
if (issuedAt === undefined) return undefined
// `expiresAt` 允许空串(= 不过期),非空则必须是合法 ISO。
if (r.expiresAt !== '' && typeof r.expiresAt !== 'string') return undefined
const expiresAt = r.expiresAt === '' ? '' : isoString(r.expiresAt)
if (expiresAt === undefined) return undefined
return { version: IDENTITY_VERSION, network, hostId, nodeKey, issuedAt, expiresAt }
}
/** 严格解析吊销清单。 */
export function parseRevocationList(raw: unknown): RevocationList | undefined {
const r = asRecord(raw)
if (r === undefined) return undefined
if (r.version !== IDENTITY_VERSION) return undefined
const network = typeof r.network === 'string' ? r.network.trim() : ''
if (!isNetworkId(network)) return undefined
const issuedAt = isoString(r.issuedAt)
if (issuedAt === undefined) return undefined
const hosts = stringList(r.hosts, 256, (s) => isHostId(s))
if (hosts === undefined) return undefined
const nodeKeys = stringList(r.nodeKeys, 256, (s) => normalizePublicKey(s) !== undefined)
if (nodeKeys === undefined) return undefined
return {
version: IDENTITY_VERSION,
network,
issuedAt,
hosts,
nodeKeys: nodeKeys.map((s) => normalizePublicKey(s) as string),
}
}
function stringList(v: unknown, max: number, ok: (s: string) => boolean): string[] | undefined {
if (!Array.isArray(v) || v.length > max) return undefined
const out: string[] = []
for (const item of v) {
if (typeof item !== 'string') return undefined
const s = item.trim()
if (s === '' || !ok(s)) return undefined
if (!out.includes(s)) out.push(s)
}
return out
}
// ── 纯函数:签发 / 验签 ───────────────────────────────────────────────────────
/** 通用验签:`sig` 为 base64;`keys` 为受信公钥 spec 列表(PEM 或裸 32 字节)。 */
function verifySigned(payload: string, sig: unknown, keys: readonly string[]): IdentityReason | 'ok' {
if (typeof sig !== 'string' || sig.trim() === '') return 'no-signature'
const sigBuf = Buffer.from(sig.trim(), 'base64')
if (sigBuf.length !== 64) return 'bad-signature-length'
// **受信公钥为空 ⇒ 拒绝**(不可验 = 不接受)——与 directory.ts 同一条判据。
if (keys.length === 0) return 'no-trusted-keys'
const data = Buffer.from(payload, 'utf8')
for (const spec of keys) {
const key = publicKeyFrom(spec)
if (key === undefined) continue
if (cryptoVerify(null, data, key, sigBuf)) return 'ok'
}
return 'signature-mismatch'
}
function signPayload(payload: string, privateKeyPem: string): string {
const key = createPrivateKey(privateKeyPem)
if (key.asymmetricKeyType !== 'ed25519') {
throw new Error(`identity key must be ed25519 (got ${String(key.asymmetricKeyType)})`)
}
return cryptoSign(null, Buffer.from(payload, 'utf8'), key).toString('base64')
}
/** 验一份签名者集合(是否被**根**授权)。 */
export function verifySignerSet(raw: unknown, sig: unknown, trustedRootKeys: readonly string[]): IdentityVerdict<SignerSet> {
const doc = parseSignerSet(raw)
if (doc === undefined) return { ok: false, reason: 'bad-document' }
const payload = signerSetPayload(doc)
const verdict = verifySigned(payload, sig, trustedRootKeys)
if (verdict !== 'ok') return { ok: false, reason: verdict }
return { ok: true, doc, payload }
}
/** 验一份节点凭据(是否由**受信签名者**签发)。 */
export function verifyNodeGrant(
raw: unknown,
sig: unknown,
trustedSignerKeys: readonly string[],
): IdentityVerdict<NodeGrant> {
const doc = parseNodeGrant(raw)
if (doc === undefined) return { ok: false, reason: 'bad-document' }
const payload = nodeGrantPayload(doc)
const verdict = verifySigned(payload, sig, trustedSignerKeys)
if (verdict !== 'ok') return { ok: false, reason: verdict }
return { ok: true, doc, payload }
}
/** 验一份吊销清单(是否由**受信签名者**签发)。 */
export function verifyRevocations(
raw: unknown,
sig: unknown,
trustedSignerKeys: readonly string[],
): IdentityVerdict<RevocationList> {
const doc = parseRevocationList(raw)
if (doc === undefined) return { ok: false, reason: 'bad-document' }
const payload = revocationPayload(doc)
const verdict = verifySigned(payload, sig, trustedSignerKeys)
if (verdict !== 'ok') return { ok: false, reason: verdict }
return { ok: true, doc, payload }
}
/** 签发签名者集合(**只跑在离线的根密钥持有者那一侧**)。 */
export function signSignerSet(set: SignerSet, rootPrivateKeyPem: string): string {
return signPayload(signerSetPayload(set), rootPrivateKeyPem)
}
/** 签发节点凭据(跑在**在线签名者**那一侧,现网 = 47)。 */
export function signNodeGrant(grant: NodeGrant, signerPrivateKeyPem: string): string {
return signPayload(nodeGrantPayload(grant), signerPrivateKeyPem)
}
/** 签发吊销清单(跑在**在线签名者**那一侧)。 */
export function signRevocations(list: RevocationList, signerPrivateKeyPem: string): string {
return signPayload(revocationPayload(list), signerPrivateKeyPem)
}
/** 会话证明:用**节点私钥**对挑战签名(证明"握有私钥",光有凭据不够)。 */
export function signProof(nodePrivateKeyPem: string, challenge: string): string {
return signPayload(proofPayload(challenge), nodePrivateKeyPem)
}
/** 验会话证明。**公钥解析不出来 ⇒ 拒**(不静默当作"这张网没有身份要求")。 */
export function verifyProof(nodeKeySpec: string, challenge: string, sig: unknown): boolean {
const key = publicKeyFrom(nodeKeySpec)
if (key === undefined) return false
if (typeof sig !== 'string' || sig.trim() === '') return false
const sigBuf = Buffer.from(sig.trim(), 'base64')
if (sigBuf.length !== 64) return false
return cryptoVerify(null, Buffer.from(proofPayload(challenge), 'utf8'), key, sigBuf)
}
// ── 本地校验的单一入口(**节点自己判**,D2)──────────────────────────────────
/** {@link verifyPeerGrant} 的上下文。 */
export interface PeerVerifyContext {
/** 受根授权的签名者公钥(**直接**受信;要么来自 env,要么来自一份已验签的 SignerSet)。 */
trustedSignerKeys: readonly string[]
/** 期望的网。给 ⇒ 必须一致(跨网签发 ⇒ `network-mismatch`)。 */
network?: string
/** 期望的 hostId。给 ⇒ 必须一致(凭据被搬到别的 hostId 上 ⇒ `host-mismatch`)。 */
hostId?: string
/** 期望的节点公钥(**裸 32 字节 hex**)。给 ⇒ 必须一致(私钥被换过 ⇒ `key-mismatch`)。 */
nodeKey?: string
/** 当前有效的吊销清单(已验签;未配 ⇒ 只警告不拒 —— 见 `loadRevocations`)。 */
revocations?: RevocationList
/** 注入点:默认 `Date.now()`。 */
nowMs?: number
}
/**
* **本地校验一份对端凭据** —— 本模块唯一的高层入口(relay 与各节点都调它)。
*
* 校验顺序(**先结构、再签名、再语义、最后吊销**):任何一个不过 ⇒ 立即带原因返回。
* ⛔ 绝不"跳过某一步继续":那种"尽力而为"的校验正是"看起来验过了"的来源。
*/
export function verifyPeerGrant(
raw: unknown,
sig: unknown,
ctx: PeerVerifyContext,
): IdentityVerdict<NodeGrant> {
const verdict = verifyNodeGrant(raw, sig, ctx.trustedSignerKeys)
if (!verdict.ok) return verdict
const g = verdict.doc
if (ctx.network !== undefined && g.network !== ctx.network) return { ok: false, reason: 'network-mismatch' }
if (ctx.hostId !== undefined && g.hostId !== ctx.hostId) return { ok: false, reason: 'host-mismatch' }
if (ctx.nodeKey !== undefined && g.nodeKey !== normalizePublicKey(ctx.nodeKey)) {
return { ok: false, reason: 'key-mismatch' }
}
const now = ctx.nowMs ?? Date.now()
const issued = Date.parse(g.issuedAt)
// 允许 5 分钟的签发时刻容差(签发机与校验机的时钟不可能逐毫秒对齐)。
if (Number.isFinite(issued) && issued > now + 5 * 60_000) return { ok: false, reason: 'not-yet-valid' }
if (g.expiresAt !== '') {
const exp = Date.parse(g.expiresAt)
if (Number.isFinite(exp) && exp <= now) return { ok: false, reason: 'expired' }
}
const rev = ctx.revocations
if (rev !== undefined) {
// 吊销清单与凭据必须**同网**;不同网 ⇒ 清单不适用(凭据本身已通过 network 校验)。
if (rev.network === g.network) {
if (rev.hosts.includes(g.hostId)) return { ok: false, reason: 'revoked-host' }
if (rev.nodeKeys.includes(g.nodeKey)) return { ok: false, reason: 'revoked-node-key' }
}
}
return verdict
}
// ── 环境变量(relay 独立进程与 dsh 进程共用同一套口径)──────────────────────
function envList(env: NodeJS.ProcessEnv, name: string): string[] {
const raw = env[name]
if (raw === undefined) return []
return [...new Set(raw.split(',').map((s) => s.trim()).filter((s) => s !== ''))]
}
/**
* 受信**根**公钥(用于验 `SignerSet`)。
*
* ⚠️ 与 `directory.ts` 的 `DSHS_OVERLAY_DIR_PUBKEYS` 是**两把不同的密钥、两个不同的用途**:
* 那把签"地址目录"(可轮换的下发物),这把签"签名者名单"(身份根)。⛔ 不要合并成一个变量 ——
* 合并等于让"能换地址的人"顺带能加签名者。
*/
export function identityEnvTrustedRoots(env: NodeJS.ProcessEnv = process.env): string[] {
return envList(env, 'DSHS_OVERLAY_ROOT_PUBKEYS')
}
/** 受信**签名者**公钥(快速通道:直接信任某把签名者,不必先摆一份 SignerSet)。 */
export function identityEnvTrustedSigners(env: NodeJS.ProcessEnv = process.env): string[] {
return envList(env, 'DSHS_OVERLAY_SIGNER_PUBKEYS')
}
/** 是否**强制**要求节点凭据(`1` / `true` / `yes` ⇒ 开)。缺省关(存量节点平滑过渡,见 §8)。 */
export function identityEnvRequire(env: NodeJS.ProcessEnv = process.env): boolean {
const raw = (env.DSHS_OVERLAY_REQUIRE_IDENTITY ?? '').trim().toLowerCase()
return raw === '1' || raw === 'true' || raw === 'yes'
}
// ── IO:密钥与凭据文件(只做搬运,判据全在上面)─────────────────────────────
/** 生成一把 Ed25519 节点密钥。返回**私钥 PEM** 与**裸公钥 hex**。 */
export function generateNodeKey(): { privateKeyPem: string; publicKey: string } {
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }) as string
const der = publicKey.export({ type: 'spki', format: 'der' }) as Buffer
return { privateKeyPem, publicKey: der.subarray(der.length - 32).toString('hex') }
}
/** 生成一把 Ed25519 根 / 签名者密钥(同形,分开只为读起来不漏"这把是干什么用的")。 */
export const generateAuthorityKey = generateNodeKey
function writeFile0600(file: string, text: string): void {
mkdirSync(dirname(file), { recursive: true })
const tmp = `${file}.tmp-${randomBytes(4).toString('hex')}`
writeFileSync(tmp, text, { mode: 0o600 })
renameSync(tmp, file)
// `mode` 在**已存在**的文件上不生效(rename 覆盖时权限随 tmp)⇒ 显式再 chmod 一次,
// 保证"恢复演练 / 重签"之后权限仍然收窄(否则一次 rename 就能把 0600 悄悄放宽)。
chmodSync(file, 0o600)
}
/**
* **读或生成**节点密钥(`0600`)。
*
* ⛔ 不"生成一把临时的顶上去":身份是**长期**的,静默生成新身份 = 让"这台机器被吊销过"
* 这件事在下一次重启后自动失效(吊销形同虚设)。
*/
export function loadOrCreateNodeKey(
file: string,
log: (line: string) => void = (): void => undefined,
): { privateKeyPem: string; publicKey: string; created: boolean } {
if (file === '') throw new Error('node key file path is empty')
if (existsSync(file)) {
const privateKeyPem = readFileSync(file, 'utf8')
const publicKey = publicKeyOfPrivate(privateKeyPem)
// 权限收窄是**幂等**的:每次装载都确认一遍(被人 `chmod 644` 过也能自愈)。
chmodSync(file, 0o600)
return { privateKeyPem, publicKey, created: false }
}
const key = generateNodeKey()
writeFile0600(file, key.privateKeyPem)
log(`[identity] 生成新节点密钥:${file}(0600)指纹=${nodeKeyFingerprint(key.publicKey) ?? '-'}`)
return { ...key, created: true }
}
/** 读一份 `{doc, sig}` 形式的签名文件;文件不存在 ⇒ `undefined`(**不抛**,便于"未配"分支)。 */
export function readSignedFile(file: string): { doc: unknown; sig: unknown } | 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
}
const r = asRecord(parsed)
if (r === undefined) return undefined
return { doc: r.doc, sig: r.sig }
}
/** 写一份 `{doc, sig}` 形式的签名文件(同目录临时文件 + `rename`,**0600**)。 */
export function writeSignedFile(file: string, doc: unknown, sig: string): void {
if (file === '') throw new Error('signed file path is empty')
writeFile0600(file, `${JSON.stringify({ doc, sig }, null, 2)}\n`)
}
/**
* 装载**受信签名者**:`SignerSet`(经根验签)+ env 里直接列的签名者。
*
* ## 失败关闭在这个函数里的确切形态
* - 没配根、也没配签名者 ⇒ 返回 `{ keys: [] }`(调用方据此判定"不可验 = 不接受");
* - **配了根、但 SignerSet 读不到 / 验不过** ⇒ 返回 `{ keys: [], error }` —— ⛔ **不回落**到
* "那先用 env 里那几个吧":那种回落会让"签名者名单被换掉"表现成"一切正常"。
*
* ⚠️ 返回的签名者**还要过一道网校验**(`set.network` 必须等于本机所在网)—— 由调用方传入
* `network` 完成(这里不猜本机在哪张网)。
*/
export function loadTrustedSigners(opts: {
roots: readonly string[]
directSigners: readonly string[]
signerSetFile?: string
network?: string
log?: (line: string) => void
}): { keys: string[]; error?: string; signerSet?: SignerSet } {
const log = opts.log ?? ((): void => undefined)
const keys: string[] = [...opts.directSigners]
let signerSet: SignerSet | undefined
const file = opts.signerSetFile ?? ''
if (file === '') {
// 无签名者集合文件:只剩"直接受信签名者"这一条路(开发期形态)。
return { keys }
}
const loaded = readSignedFile(file)
if (loaded === undefined) {
return { keys: [], error: `signer-set-unreadable: ${file}` }
}
const verdict = verifySignerSet(loaded.doc, loaded.sig, opts.roots)
if (!verdict.ok) {
return { keys: [], error: `signer-set-rejected: ${verdict.reason}` }
}
signerSet = verdict.doc
if (opts.network !== undefined && signerSet.network !== opts.network) {
return { keys: [], error: `signer-set-network-mismatch: ${signerSet.network} != ${opts.network}` }
}
for (const k of signerSet.signers) if (!keys.includes(k)) keys.push(k)
log(`[identity] 受信签名者 ${keys.length} 把(来自 ${file},net=${signerSet.network},经根验签通过)`)
return { keys, signerSet }
}
/**
* 装载**吊销清单**(经签名者验签)。
*
* ⚠️ 与签名者集合**不同的一条**:清单**没配** ⇒ 返回 `undefined` 但**不报错**
* (`keys.ts` 的 HMAC 已经把"未登记的 hostId"挡住了,吊销清单是"已登记但被撤"的这一层);
* 清单**配了却验不过** ⇒ 返回 `{ error }`,调用方必须**拒**(清单被改坏 ⇒ 撤销失效 ⇒ 不能当没事)。
*/
export function loadRevocations(opts: {
file?: string
trustedSignerKeys: readonly string[]
log?: (line: string) => void
}): { list?: RevocationList; error?: string } {
const file = opts.file ?? ''
if (file === '') return {}
const loaded = readSignedFile(file)
if (loaded === undefined) return { error: `revocations-unreadable: ${file}` }
const verdict = verifyRevocations(loaded.doc, loaded.sig, opts.trustedSignerKeys)
if (!verdict.ok) return { error: `revocations-rejected: ${verdict.reason}` }
opts.log?.(`[identity] 吊销清单:net=${verdict.doc.network} hosts=[${verdict.doc.hosts.join(',')}] nodeKeys=${verdict.doc.nodeKeys.length}`)
return { list: verdict.doc }
}
/** 装载本机的 `{grant, sig}`;未配 ⇒ `undefined`(调用方按"本机还没入网凭据"处理)。 */
export function loadNodeGrant(file: string): { grant: NodeGrant; sig: string } | undefined {
const loaded = readSignedFile(file)
if (loaded === undefined) return undefined
const grant = parseNodeGrant(loaded.doc)
if (grant === undefined || typeof loaded.sig !== 'string') return undefined
return { grant, sig: loaded.sig }
}
/**
* **本机节点身份**(relay 客户端侧的唯一装配入口)—— `main.ts` / worker / Manager 都走它。
*
* 返回 `undefined` 的确切语义是「**本机没有身份可用**」,而不是「身份校验通过」:
* - 密钥文件或凭据文件**任一没配** ⇒ `undefined`(过渡期形态:只做 HMAC);
* - 配了却**读不出来 / 凭据不合法** ⇒ **抛**(⛔ 不静默退化成"没身份" ——
* 那会让"凭据坏了"表现成"relay 没要求身份时一切正常",等 relay 一开强制就整台失联,
* 而排障时看到的是"配置明明写了")。
*
* ⚠️ 这里**不校验凭据签名**(那是 relay 与本机对端的事):本机只是"把我的证书带上"。
* 本机对**自己**的凭据也不做本地校验 —— 它若无效,relay 会明确拒(`identity-*` 原因码),
* 那条日志才是可诊断的那一条。
*/
export function loadClientIdentity(opts: {
keyFile: string
grantFile: string
log?: (line: string) => void
}): { privateKeyPem: string; grant: NodeGrant; grantSig: string } | undefined {
const keyFile = (opts.keyFile ?? '').trim()
const grantFile = (opts.grantFile ?? '').trim()
if (keyFile === '' || grantFile === '') return undefined
const node = loadOrCreateNodeKey(keyFile, opts.log)
const loaded = loadNodeGrant(grantFile)
if (loaded === undefined) {
throw new Error(`node grant file ${grantFile} is missing or malformed (need {"doc":{...},"sig":"<base64>"})`)
}
opts.log?.(
`[identity] 本机节点 ${loaded.grant.network}/${loaded.grant.hostId} 指纹=${nodeKeyFingerprint(node.publicKey) ?? '-'}`,
)
return { privateKeyPem: node.privateKeyPem, grant: loaded.grant, grantSig: loaded.sig }
}
+88
View File
@@ -0,0 +1,88 @@
/**
* 覆盖网络中继(relay)—— **自研、零新增依赖、零新增公网口**(传输方案 §10 定案)。
*
* ## 一段话说完它是什么
* worker 侧 `RelayClient` **只拨出**一条 wss;`RelayServer` **只绑回环**,为每个注册端口在
* `127.0.0.1` 上开一条监听;Manager 连那条回环口 ⇒ 字节经多路复用跑回 worker 本地端口。
* 对 Manager 而言地址形态与 sshd 版**完全同形**(`host:port`),所以换它是 env 级动作。
*
* ## 三条硬约束(来自实测,改动前先读)
* 1. **零新增公网口**:47 无本机防火墙(`nft INPUT policy accept`)⇒ 绑 `0.0.0.0` 即公网可达。
* 2. **worker 只拨出**:任何"在 worker 上开监听"的方案都让暴露面从 O(1) 变 O(N)。
* 3. **精确 ACK**:注册与开流都必须有确认帧,未确认即断并计数 —— 治的是 SSH 版"静默失败"的病根。
*
* @module dshs/net/relay
*/
export { RelayServer, RELAY_PATH, DEFAULT_RELAY_PORT, DEFAULT_AUTH_DEADLINE_MS, DEFAULT_AUTH_WINDOW_MS, DEFAULT_IDLE_TIMEOUT_MS, DEFAULT_HB_SEC, DEFAULT_MAX_STREAMS_PER_PORT, DEFAULT_QUEUE_MAX_BYTES } from './server.js'
export type { RelayServerOptions, RelayStatus } from './server.js'
export { RelayClient, describeClientStatus, gracefulBurstMsDefault, openedChannelFailedTerminally, waitUpOnStatus } from './client.js'
export type { RelayClientOptions, RelayClientStatus, RelayClientState, WebSocketLike, WebSocketCtor, WaitUpStatusOptions } from './client.js'
export { RelayRendezvous } from './rendezvous.js'
export type { RelayRendezvousOptions } from './rendezvous.js'
export { RelayDialer } from './dialer.js'
export type { RelayDialerOptions } from './dialer.js'
export { RelayFailoverSupervisor, relayFailoverThresholds } from './switcher.js'
export type {
RelayChannelHandle,
RelayFailoverDeps,
RelayFailoverStats,
RelayFailoverThresholds,
} from './switcher.js'
export { MuxDuplex } from './duplex.js'
export type { MuxDuplexOptions } from './duplex.js'
export { MUX, WS_CLOSE, acceptWebSocket, encodeMux, decodeMux, encodeJsonFrame, parseJsonPayload, WsConnection } from './wire.js'
export type { MuxFrame, MuxType, WsServerOptions } from './wire.js'
export { OPS_NETWORK, NAME_SEP, assertNetworkId, assertSameNetwork, describeDialers, isHostId, isNetworkId, logicalName, normalizeDialers, parseLogicalName, sameNetwork } from './network.js'
export { DIRECTORY_PATH, DIRECTORY_PAYLOAD_TAG, DIRECTORY_VERSION, DEFAULT_OVERLAY_SEED, buildDirectoryDocument, directoryPayload, directoryUrlFor, overlayEnvSeeds, overlayEnvTrustedKeys, parseDirectory, publicKeyFrom, publicRelayEntries, readCachedDirectory, resolveOverlayRelay, listOverlayRelayCandidates, signDirectory, toRelayUrl, verifyDirectory, writeCachedDirectory } from './directory.js'
export type { CachedDirectory, DirectoryVerdict, OverlayAddressSource, OverlayDirectory, OverlayRelayCandidates, OverlayRelayResolution, ResolveOverlayRelayOptions } from './directory.js'
export { keyEntryOf, loadKeysFile, parseKeysInline, normalizeKeyRecord, lookupKey, describeKeyEntry, assertKey } from './keys.js'
export type { RelayKeyEntry, RelayKeyMap } from './keys.js'
export {
IDENTITY_VERSION,
NODE_GRANT_TAG,
REVOCATION_TAG,
SIGNER_SET_TAG,
PROOF_TAG,
DEFAULT_NODE_KEY_FILE,
generateAuthorityKey,
generateNodeKey,
identityEnvRequire,
identityEnvTrustedRoots,
identityEnvTrustedSigners,
loadNodeGrant,
loadOrCreateNodeKey,
loadRevocations,
loadTrustedSigners,
nodeGrantPayload,
nodeKeyFingerprint,
normalizePublicKey,
parseNodeGrant,
parseRevocationList,
parseSignerSet,
proofPayload,
publicKeyOfPrivate,
readSignedFile,
revocationPayload,
signNodeGrant,
signProof,
signRevocations,
signSignerSet,
signerSetPayload,
verifyNodeGrant,
verifyPeerGrant,
verifyProof,
verifyRevocations,
verifySignerSet,
writeSignedFile,
} from './identity.js'
export type {
IdentityReason,
IdentityVerdict,
NodeGrant,
PeerVerifyContext,
RevocationList,
SignerSet,
} from './identity.js'
export { chooseNode, describeDecision, rankCandidates, scoreCandidate } from './placement.js'
export type { ChooseOptions, NodeCandidate, PlacementDecision, PlacementScore, PlacementWeights } from './placement.js'
+193
View File
@@ -0,0 +1,193 @@
/**
* relay 密钥装载 —— **每 worker 一密钥**(不是共享 token),**且每条密钥带「属于哪张网」**。
*
* ## 为什么不是共享 token
* 共享 token 的问题是**爆炸半径 = 全部 worker**:任何一台 worker 被读走配置文件,攻击者就能
* 冒充**任意** worker 注册(方案 §10.1 判据①,`chisel` / `frp` 正是在这里垫底)。
* 每 worker 一密钥把爆炸半径收回到那一台。
*
* ## 为什么带「网」,以及为什么键是**逻辑名**
* 只按 `hostId` 索引时,**这张表回答不了「它属于哪张网」** ⇒ relay 只能相信 HELLO 里的 `network`
* 声明。于是任何持有任意一条有效密钥的节点,**声称任意合法网名就能在那张网里注册** ——
* 而「网内别的节点」与「这张网自己」都无从否决(`交接单_网抽象与地址规划R6 §待办②` 的确切缺口)。
*
* ⇒ 本模块让**密钥表本身**成为成员资格的唯一判据(配置是既成事实,声明只是待校验的输入)。
* 表的**键 = 逻辑名 `<network>/<hostId>`**(`network.ts` 的「唯一入口」口径),因为:
*
* 1. **同一台机器名可以出现在两张网里**(`u:5/pc-1` 与 `u:9/pc-1`)—— 用户设备名是客户端取的,
* 跨用户撞名是常态;拿 `hostId` 当键会让第二张网**注册不进来**(一个维度净变差)。
* 2. 与 `server.ts` 的会话表 / 端点表**同一个口径**(都是逻辑名)⇒ 不再有「这张表按 hostId、
* 那张表按逻辑名」两套写法(本线反复复发的那类病)。
*
* ## 格式(**向后兼容,旧配置一字不改照旧可用**)
* ```json
* {
* "w-47": "fc6c…7d01", // 旧写法:裸 hostId ⇒ 运维网 ops
* "manager": { "secret": "515d…3b6ea" }, // 新写法:显式给出 secret
* "u:5/pc-1": "aa11…77aa", // 带网:与 u:9/pc-1 互不干扰(网络取自**键**)
* "u:9/pc-1": { "secret": "bb22…88bb" }
* }
* ```
* 内联形式(便于 env 传入,**不建议**用于生产,因为 env 会进 `ps`/journald):
* `w-47:<64hex>,ops/manager:<64hex>,u:5/pc-1:<64hex>`
*
* ⚠️ 名字段一律走 `network.ts#parseLogicalName`(**唯一入口**)—— 它同时接受
* `网/hostId`、`网:hostId` 与旧形态裸 `hostId`,且**网络 id 非法就抛**。
* 抛而**不静默回落 `ops`**:静默回落会让「网配错了」表现成「网络不通」,那是最难查的一类。
*
* @module dshs/net/relay/keys
*/
import { readFileSync } from 'node:fs'
import { OPS_NETWORK, assertNetworkId, logicalName, parseLogicalName } from './network.js'
const HEX64 = /^[0-9a-f]{64}$/
/**
* 一条 relay 密钥。
*
* - `secret`:64 位 hex(= 32 字节,HMAC-SHA256 的推荐长度);
* - `network`:该 hostId **属于哪张网** —— 成员资格的唯一判据(序③);
* ⚠️ 它**是准入判据,不是注释**:relay 拿它与 HELLO 的声明比对,不一致即拒。
*/
export interface RelayKeyEntry {
network: string
secret: string
}
/** `逻辑名 <network>/<hostId> → 密钥条目`。 */
export type RelayKeyMap = Map<string, RelayKeyEntry>
/** 校验一条密钥:**必须 64 位 hex**。短密钥直接拒,不静默接受。 */
export function assertKey(hostId: string, secret: string): string {
const s = secret.trim().toLowerCase()
if (!HEX64.test(s)) throw new Error(`relay key for "${hostId}" must be 64 hex chars (got ${s.length} chars)`)
return s
}
/** 键里**显式写了网络**没有(`u:5/pc-1` / `ops/manager` ⇒ 有;`pc-1` ⇒ 没有)。 */
function isQualifiedName(raw: string): boolean {
return raw.includes('/') || raw.includes(':')
}
/**
* 把一条登记项归一化成 `{name, entry}`。
*
* 网络来源有**两个可能的位置**(键 / 值),规则刻意简单:
* - 键是**限定名**(`u:5/pc-1`)⇒ 网络**只认键**;值里若也写了且不一致 ⇒ **抛**
* (两处写不一致 = 埋雷,与 `normalizeDialers` 的「写了就必须一致」同一条纪律);
* - 键是**裸 hostId** ⇒ 网络取自值里的 `network`,缺省 `ops`(= R5 旧配置的事实)。
*/
export function normalizeKeyRecord(rawKey: string, rawValue: unknown): { name: string; entry: RelayKeyEntry } {
const parsed = parseLogicalName(rawKey)
const qualified = isQualifiedName(rawKey)
let network = parsed.network
let secretRaw: unknown = rawValue
if (rawValue !== null && typeof rawValue === 'object' && !Array.isArray(rawValue)) {
const o = rawValue as Record<string, unknown>
if (typeof o.network === 'string' && o.network.trim() !== '') {
const declared = assertNetworkId(o.network, `relay key "${rawKey}" 的网络段`)
if (qualified && declared !== parsed.network) {
throw new Error(`relay key "${rawKey}" 自相矛盾:键说 "${parsed.network}",值里的 network 说 "${declared}"`)
}
network = declared
}
secretRaw = o.secret
}
if (typeof secretRaw !== 'string') {
throw new Error(`relay key for "${rawKey}" must be a 64-hex string or {secret[, network]}`)
}
const secret = assertKey(parsed.hostId, secretRaw)
return { name: logicalName(network, parsed.hostId), entry: { network, secret } }
}
/**
* 宽容读取:把「旧调用方手里的 `Map<hostId, string>`」与「新的 `Map<逻辑名, RelayKeyEntry>`」
* 都读成 {@link RelayKeyEntry}。
*
* 存在的理由:`server.ts` 的 `keys` 选项在若干地方被构造(`main.ts` / `web/server.ts` / 单测),
* 逐个改成新形态会带来「漏改一处 ⇒ 运行期 `entry.secret` 是 `undefined` ⇒ 全部拒绝」的风险 ——
* 那个失效形态**看起来像网络不通**。收在一个函数里,读的地方只有一种写法。
*
* ⚠️ 本函数**只在没有网络信息可依据时**才把网络当 `ops`。若键本身是限定名(已表达网络),
* 调用方应当用 {@link lookupKey}(它会从键里取网络),否则会把 `u:5/pc-1` 误判成 `ops`。
*/
export function keyEntryOf(raw: string | RelayKeyEntry | undefined): RelayKeyEntry | undefined {
if (raw === undefined) return undefined
if (typeof raw === 'string') return { network: OPS_NETWORK, secret: raw }
return raw
}
/**
* 查一条密钥 —— **唯一的查表入口**(`server.ts` / `main.ts` 都走它,⛔ 不各自 `keys.get`)。
*
* 两步:① 按**逻辑名**查(新形态,能区分同名不同网);② 回落到**裸 hostId**(R5 旧形态:
* `Map<hostId, hex>` 与「键是裸 hostId 的 JSON 项」)。
*
* ⚠️ **网络归属从哪来**(这条搞错就会「配对了却仍被拒」):
* - 命中**逻辑名**键 ⇒ 值若是裸 hex 串,网络**取自键**(键是限定名,它已经说了是哪张网);
* 值若是对象,则用对象里那把(`normalizeKeyRecord` 保证两者一致;手写的 `Map` 若不一致,
* 由调用方的 `entry.network !== 声明网` 兜住 ⇒ 仍是失败关闭)。
* - 命中**裸 hostId** ⇒ 值的网络缺省 `ops`(R5 旧配置的**事实**,不是猜测)。
*
* ⛔ 第二步**不做网络过滤** —— 网络一致性的判据只有一处(调用方拿 `entry.network !== 声明网`
* 判):这样「裸条目 + 声明别张网」会得到**明确的 `network-mismatch`**,而不是含混的
* `unknown-host`(后者会让「配错网」看起来像「这台机器从没登记过」)。
*/
export function lookupKey(
keys: ReadonlyMap<string, string | RelayKeyEntry>,
network: string,
hostId: string,
): RelayKeyEntry | undefined {
const direct = keys.get(logicalName(network, hostId))
if (direct !== undefined) {
if (typeof direct === 'string') return { network, secret: direct }
return direct
}
return keyEntryOf(keys.get(hostId))
}
/**
* 解析 `hostId:secret` / `network/hostId:secret` / `network:hostId:secret`。
*
* **切点 = 最后一个 `:`**(不是第一个):网络 id 自己就可能含 `:`(`u:5`),
* 而 secret 是纯 hex **不含** `:` ⇒ 最后一个 `:` 必然是分隔符。
* ⛔ 用第一个 `:` 会把 `u:5/pc-1:<hex>` 切成网络段 `u`,于是「配置写错」长得像「这张网不存在」。
*/
export function parseKeysInline(text: string): RelayKeyMap {
const out: RelayKeyMap = new Map()
for (const pair of text.split(',')) {
const item = pair.trim()
if (item === '') continue
const idx = item.lastIndexOf(':')
if (idx <= 0 || idx === item.length - 1) throw new Error(`malformed relay key entry: ${item.slice(0, 12)}…`)
const name = item.slice(0, idx).trim()
const { name: key, entry } = normalizeKeyRecord(name, item.slice(idx + 1).trim())
out.set(key, entry)
}
return out
}
/** 读 JSON 密钥文件(生产用;权限应为 `600`,仅能跑 relay 的用户可读)。 */
export function loadKeysFile(path: string): RelayKeyMap {
const raw: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
throw new Error(
`relay keys file ${path} must be a JSON object of { "<net>/<hostId>": "<64hex>" | {secret[, network]} }`,
)
}
const out: RelayKeyMap = new Map()
for (const [rawKey, rawValue] of Object.entries(raw as Record<string, unknown>)) {
const { name, entry } = normalizeKeyRecord(rawKey, rawValue)
if (out.has(name)) throw new Error(`relay keys file ${path} 里 "${name}" 出现了两次`)
out.set(name, entry)
}
return out
}
/** 一行摘要(给启动日志 / `/status` 用,**同一条信息只说一次**)。 */
export function describeKeyEntry(hostId: string, entry: RelayKeyEntry): string {
// `ops` 下的条目**省略网络前缀** —— 与 R5 时代的日志写法一致,读起来不变。
return entry.network === OPS_NETWORK ? hostId : `${entry.network}/${hostId}`
}
+363
View File
@@ -0,0 +1,363 @@
/**
* `dshs-relay` 单元入口(R1)—— **一条命令既能起服务端也能起客户端**,便于分机验证。
*
* ```bash
* # 服务端(**只绑回环**;生产由 systemd 单元拉起)
* node lib/net/relay/main.js --port 20080 --keys-file /etc/dshs/relay-keys.json --base 20000 --span 1000
*
* # 客户端(worker 侧拨出;R1 用 `ssh -L` 把远端的回环口引到本机来拨)
* node lib/net/relay/main.js --client --url ws://127.0.0.1:20080/dshs-relay \
* --host w-47 --keys-file /etc/dshs/relay-keys.json --ports 20000
* ```
*
* ⚠️ **本文件暂不读 `src/config.ts`**:R1 阶段 relay 是**独立可选单元**,且 `src/config.ts`
* 目前有其它会话在改(工作区未提交改动)⇒ 先只认 `DSHS_RELAY_*` / `argv`,避免制造合并冲突。
* R3 集成进 dsh 进程时再收敛到 `config.ts`(那一步才有必要)。
*
* @module dshs/net/relay/main
*/
import { RelayClient, describeClientStatus, waitUpOnStatus } from './client.js'
import {
DEFAULT_OVERLAY_SEED,
overlayEnvSeeds,
overlayEnvTrustedKeys,
resolveOverlayRelay,
listOverlayRelayCandidates,
} from './directory.js'
import { RelayFailoverSupervisor, relayFailoverThresholds } from './switcher.js'
import type { RelayChannelHandle } from './switcher.js'
import { parseKeysInline, loadKeysFile, lookupKey, type RelayKeyMap } from './keys.js'
import {
identityEnvRequire,
identityEnvTrustedRoots,
identityEnvTrustedSigners,
loadClientIdentity,
loadRevocations,
loadTrustedSigners,
} from './identity.js'
import { OPS_NETWORK, describeDialers, normalizeDialers } from './network.js'
import { DEFAULT_RELAY_PORT, RelayServer } from './server.js'
interface Args {
client: boolean
url?: string
hostId?: string
/** 本节点所属网(P0-1)。缺省 `ops` —— 见 `network.ts`。 */
network: string
ports: number[]
port: number
keysFile?: string
base: number
span: number
/** 容量准入:允许同时在线的 host 数上限(`0` = 不限)。 */
maxHosts: number
quiet: boolean
}
function parseArgs(argv: readonly string[]): Args {
const args: Args = {
client: false,
network: process.env.DSHS_OVERLAY_NETWORK_ID ?? OPS_NETWORK,
ports: [],
port: Number(process.env.DSHS_RELAY_PORT ?? DEFAULT_RELAY_PORT),
keysFile: process.env.DSHS_RELAY_KEYS_FILE,
base: Number(process.env.DSHS_INSTANCE_PORT_BASE ?? 20000),
span: Number(process.env.DSHS_INSTANCE_PORT_SPAN ?? 1000),
maxHosts: Number(process.env.DSHS_RELAY_MAX_HOSTS ?? 0),
quiet: false,
}
for (let i = 0; i < argv.length; i++) {
const a = argv[i]
const next = (): string => {
const v = argv[++i]
if (v === undefined) throw new Error(`${a} requires a value`)
return v
}
if (a === '--client') args.client = true
else if (a === '--quiet') args.quiet = true
else if (a === '--url') args.url = next()
else if (a === '--host') args.hostId = next()
else if (a === '--network') args.network = next()
else if (a === '--port') args.port = Number(next())
else if (a === '--base') args.base = Number(next())
else if (a === '--span') args.span = Number(next())
else if (a === '--max-hosts') args.maxHosts = Number(next())
else if (a === '--keys-file') args.keysFile = next()
else if (a === '--ports') args.ports = next().split(',').map((s) => Number(s.trim())).filter((n) => Number.isInteger(n) && n > 0)
else if (a === '--help' || a === '-h') {
process.stdout.write(
'usage: main.js [--client --url <ws> --host <id> --ports <a,b>] [--network <id>] [--port n] [--keys-file p] [--base n] [--span n] [--max-hosts n]\n' +
' 容量准入:--max-hosts(0 = 不限);满载时新节点收到 at-capacity + retryAfterMs 并**排队等待**,已在册节点重连优先。\n' +
' 网维度(P0-1):--network 缺省 ops;拨号方白名单 DSHS_RELAY_DIALERS 接受 "ops:manager" / "manager"(旧写法)两种。\n' +
` 引导(P0-2):客户端**不带 --url** 时按「缓存目录 > 内置种子」取址;内置种子 = ${DEFAULT_OVERLAY_SEED}\n` +
' (env 显式 = --url / DSHS_RELAY_URL,**压制引导链**;受信目录公钥 = DSHS_OVERLAY_DIR_PUBKEYS)。\n',
)
process.exit(0)
} else throw new Error(`unknown argument: ${a}`)
}
return args
}
function loadKeys(args: Args): RelayKeyMap {
if (process.env.DSHS_RELAY_KEYS !== undefined && process.env.DSHS_RELAY_KEYS.trim() !== '') {
return parseKeysInline(process.env.DSHS_RELAY_KEYS)
}
if (args.keysFile !== undefined && args.keysFile !== '') return loadKeysFile(args.keysFile)
throw new Error('no relay keys: set --keys-file <path> or DSHS_RELAY_KEYS="<hostId>:<64hex>,…"')
}
/**
* 序③:relay 侧的**身份校验配置**(受信签名者 / 吊销清单 / 是否强制)。
*
* **失败关闭落在这里**:配了 `DSHS_OVERLAY_REQUIRE_IDENTITY=1` 却拿不出任何受信签名者
* ⇒ **起动即抛**(⛔ 不"先起来,慢慢拒")。理由与 `assertNetworkId` 同款 ——
* 配置不完整就该在**单元状态**上立刻可见(`systemctl status` 一眼看到失败原因),
* 而不是变成"服务 up、日志里全是 AUTH DENY"那种"看起来在跑、其实谁也进不来"的形态。
*/
function loadIdentityForServer(log: (line: string) => void): {
trustedSignerKeys: string[]
revocations: ReturnType<typeof loadRevocations>['list']
requireIdentity: boolean
} {
const requireIdentity = identityEnvRequire()
const loaded = loadTrustedSigners({
roots: identityEnvTrustedRoots(),
directSigners: identityEnvTrustedSigners(),
signerSetFile: process.env.DSHS_OVERLAY_SIGNER_SET_FILE,
// ⚠️ 这里**不传 network**:relay 服务的网是**每会话**声明的,不是整机一个属性
//(一台 relay 可以同时承载多张网)。跨网签发由每会话的 `network/hostId` 判据拦。
log,
})
if (loaded.error !== undefined) {
if (requireIdentity) throw new Error(`identity config incomplete: ${loaded.error}`)
log(`[relay] ⚠ 签名者集合不可用(${loaded.error})⇒ 本轮**不做身份校验**(仅 HMAC)`)
}
const rev = loadRevocations({
file: process.env.DSHS_OVERLAY_REVOCATIONS_FILE,
trustedSignerKeys: loaded.keys,
log,
})
if (rev.error !== undefined) {
if (requireIdentity) throw new Error(`identity config incomplete: ${rev.error}`)
log(`[relay] ⚠ 吊销清单不可用(${rev.error})⇒ 本轮**不做吊销校验**`)
}
if (requireIdentity && loaded.keys.length === 0) {
throw new Error('DSHS_OVERLAY_REQUIRE_IDENTITY=1 但没有任何受信签名者 ⇒ 拒绝启动(配置不完整,不静默放开)')
}
return { trustedSignerKeys: loaded.keys, revocations: rev.list, requireIdentity }
}
/**
* 换址用的**非抛版**等待(序⑦):`--client` 的 `open()` 要的是"能不能起来"这个布尔,
* 起不来就交回 `undefined`,由监管器决定"保持原通道"(D4)。
*
* 🔴 **序⑨ · RC-1(C3 装配点)**:实现已抽到 {@link waitUpOnStatus}(三个装配点共用一份,D7);
* 相对改造前的唯一差别 = **终态失败(死候选)立即 `false`**,⛔ 不再白等满 `upTimeoutMs`。
*/
async function waitUpOn(client: RelayClient, timeoutMs: number, log?: (line: string) => void): Promise<boolean> {
return waitUpOnStatus(client, timeoutMs, {
onDead: (st) =>
log?.(
`[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` +
`burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`,
),
})
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2))
const keys = loadKeys(args)
const log = args.quiet ? (): void => undefined : (line: string): void => {
process.stdout.write(`${line}\n`)
}
if (!args.client) {
/**
* 拨号方白名单(R5 / P0-1):`DSHS_RELAY_DIALERS="ops:manager,u:5:pc-1"` —— **空 = 该能力关闭**(默认)。
*
* 两种写法都接受(`normalizeDialers`):
* · **旧写法**(R5 时代,扁平 hostId)⇒ 等价于"这些都在 `ops` 网里" ⇒ 现网 drop-in 不必改动;
* · **新写法** `network:hostId`(也接受 `network/hostId`)⇒ 按网络分桶,**没列到的网一个都拨不动**。
* ⚠️ 网络 id 非法 ⇒ `normalizeDialers` 会**抛**(配置错就炸,不静默变成"谁也没匹配上")。
*/
const dialers = normalizeDialers(
new Set(
(process.env.DSHS_RELAY_DIALERS ?? '')
.split(',')
.map((s) => s.trim())
.filter((s) => s !== ''),
),
)
const identity = loadIdentityForServer(log)
const server = new RelayServer({
port: args.port,
keys,
instancePortBase: args.base,
instancePortSpan: args.span,
maxHosts: args.maxHosts,
dialers,
trustedSignerKeys: identity.trustedSignerKeys,
revocations: identity.revocations,
requireIdentity: identity.requireIdentity,
log,
})
await server.start()
if (dialers.size > 0) log(`[relay] 拨号方白名单:${describeDialers(dialers).join(',')}`)
log(
`[relay] 身份(序③):受信签名者 ${identity.trustedSignerKeys.length} 把,` +
`强制=${identity.requireIdentity ? 'on' : 'off'},吊销 hostId ${identity.revocations?.hosts.length ?? 0} 个`,
)
const shutdown = (sig: string): void => {
log(`[relay] ${sig} ⇒ stopping`)
// **硬兜底**:`stop()` 若被任何残留连接拖住,也必须按时退出 —— 等价于 systemd 的
// `TimeoutStopSec`。停机时长本身就是对端的恢复时间,不能被"礼貌"拖长。
const hard = setTimeout(() => {
log('[relay] stop() 超时 ⇒ 强制退出(停机时长必须可控)')
process.exit(0)
}, 2_000)
void server.stop().then(() => {
clearTimeout(hard)
process.exit(0)
})
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
return
}
if (args.hostId === undefined || args.ports.length === 0) {
throw new Error('client mode needs --host and --ports')
}
/**
* ⚠️ **必须在这里收进常量**:上面那两句校验把 `args.hostId` 收窄成 `string`,
* 但下面 `buildClient` 是**闭包**(序⑦ 换址时要在运行期再建客户端)——
* TS 的收窄**不进闭包**,直接引用会退回 `string | undefined`。
*/
const hostId: string = args.hostId
const ports: number[] = args.ports
/**
* 覆盖网络 P0-2:**取址走引导三级链**(env 显式 > 缓存目录 > 内置种子)。
*
* 本入口是**独立进程**、不经过 `resolveConfig()` ⇒ 种子 / 受信公钥 / 缓存路径从 env 直读。
* `--url` 与 `DSHS_RELAY_URL` 都算"env 显式"(压制引导链,运维最后手段)。
*/
let relayUrl = args.url ?? ''
if (relayUrl === '') {
const resolved = await resolveOverlayRelay({
envUrl: '',
seeds: overlayEnvSeeds(),
trustedKeys: overlayEnvTrustedKeys(),
cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '',
log,
})
relayUrl = resolved.url
log(
`[relay-client] 引导链取址 source=${resolved.source} ` +
`url=${relayUrl === '' ? '-' : relayUrl} detail=${resolved.detail}`,
)
}
if (relayUrl === '') {
throw new Error('client mode needs a relay url: --url / DSHS_RELAY_URL / 引导链(种子或签名目录)')
}
const entry = lookupKey(keys, args.network, args.hostId)
if (entry === undefined) throw new Error(`no key for host "${args.hostId}" in the keys file`)
if (entry.network !== args.network) {
// 成员资格在**本机**就先炸:拿着一张属于别的网的密钥去连,relay 必然拒(`network-mismatch`)
// ⇒ 与其等一轮"连上又被踢"的来回,不如在起动时把"密钥与网不匹配"直接讲清楚。
throw new Error(
`key for "${args.hostId}" is registered in network "${entry.network}", but this node declares "${args.network}"`,
)
}
/**
* 序③:本机节点身份(与 worker / Manager 走**同一个装配入口**)。
*
* ⚠️ **缺凭据不抛**(本轮是"先加能力"的过渡期):`identity` 缺省 ⇒ 退回纯 HMAC,
* 与存量行为完全一致。**强制**与否由 relay 侧决定 —— 客户端这边只负责"有就带上"。
*/
const identity = loadClientIdentity({
keyFile: process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '',
grantFile: process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '',
log,
})
/**
* 序⑦ · **C3 装配点:中继失败切流**。
*
* 一句话:`--url` / `DSHS_RELAY_URL`(env 显式)**压制引导链**,此时**不启用**监管器
* —— 运维把地址钉死了,就不该由我们背着它换。走引导链来的地址才启用。
*/
type C3Handle = RelayChannelHandle & { client: RelayClient }
const makeHandle = (url: string, c: RelayClient): C3Handle => ({
url,
client: c,
health: () => {
const st = c.status()
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
},
close: () => c.stop(),
})
const buildClient = (url: string): RelayClient =>
new RelayClient({
url,
hostId,
networkId: args.network,
secret: entry.secret,
ports,
identity,
log,
})
const client = buildClient(relayUrl)
client.start()
const failover =
args.url !== undefined && args.url !== ''
? undefined
: new RelayFailoverSupervisor({
/** **先建新、成功再关旧**:到不了 `up` 就返回 `undefined` ⇒ 监管器保持原通道(D4)。 */
open: async (target) => {
const next = buildClient(target)
next.start()
if (!(await waitUpOn(next, relayFailoverThresholds().upTimeoutMs, log))) {
next.stop()
return undefined
}
return makeHandle(target, next)
},
/** **同一份引导链**(⛔ 不另写取址;同源 = 只可能是已签名目录 / 内置种子里的地址)。 */
candidates: async () =>
(
await listOverlayRelayCandidates({
envUrl: '',
seeds: overlayEnvSeeds(),
trustedKeys: overlayEnvTrustedKeys(),
cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '',
log,
})
).urls,
log,
thresholds: relayFailoverThresholds(),
})
if (failover !== undefined) {
failover.seed(makeHandle(relayUrl, client))
failover.start()
}
/** 当前通道(单一权威来源 —— 切换后 reporter / shutdown 都要跟着走)。 */
const currentClient = (): RelayClient =>
(failover?.channel as C3Handle | undefined)?.client ?? client
const reporter = setInterval(
() => log(`[relay-client] ${describeClientStatus(currentClient().status())}`),
30_000,
)
reporter.unref()
const shutdown = (): void => {
failover?.stop()
currentClient().stop()
process.exit(0)
}
process.on('SIGTERM', shutdown)
process.on('SIGINT', shutdown)
}
main().catch((err: unknown) => {
process.stderr.write(`[relay] fatal: ${err instanceof Error ? err.message : String(err)}\n`)
process.exit(1)
})
+222
View File
@@ -0,0 +1,222 @@
/**
* 网抽象 —— **`network_id` 维度与"节点逻辑名"的唯一入口**(覆盖网络 ②·P0-1)。
*
* ## 它解决的确切问题
* R0–R5 跑通之后,relay 把**平台自己的 Worker 隧道**和**未来的用户设备**塞进**同一个扁平
* `hostId` 命名空间**(`server.ts` 的 `sessions` / `endpoints` 都只按 `hostId` 索引,全仓
* `grep -ri networkId|tailnet` = 0 命中)。今天只有 1 个用户、1 张网,问题不显形;一进第二类
* 节点就会变成「**一张巨网 + 靠 ACL 兜**」—— 而写错一条 ACL 就泄露,这与项目
* 「**权限只准收窄**」直接冲突。
*
* ⇒ 本模块把「**哪张网**」变成**结构性维度**(不是策略性):判据只有这一处,纯函数、无 IO、可单测。
* `server.ts` / `client.ts` / 运维配置**一律从这里取**,⛔ 不许在调用方拼字符串
* (散着拼迟早就出现三套不一致的口径 —— 本线复盘里反复出现的那类病)。
*
* ## 取值(D2 · 已定项)
* - **运维网固定 `ops`**:47(Manager)/ 106(Worker)/ 未来的中继与骨干 —— 即"平台自己的机器"。
* - **用户网 = `u:<userId>`**:该用户名下的全部设备(含桌面客户端)。
* ⇒ 存量数据天然落在 `ops`(这正是"加列带默认值"能零风险落地的原因)。
*
* ## ⛔ 本模块**不做**的事(故意)
* - 不碰虚拟网卡 / L3 地址 / `100.64.0.0/10`(`交接单_网抽象与地址规划R6 §2 D1`);
* - 不解析 DNS、不下发对端清单(D4):本阶段收敛为「**逻辑名 + 授权**」。
*
* @module dshs/net/relay/network
*/
/** 运维网(平台自己的机器)。**固定值**,与部署位置无关。 */
export const OPS_NETWORK = 'ops'
/**
* 网络 id 的合法形状(三条分支,见 `isNetworkId`)。
*
* 收在一个入口里,是为了让"配错了"在**装载配置时就炸**,而不是变成
* 「谁也没匹配上 ⇒ 静默拒绝」(静默拒绝是本线反复要根治的病:它把"配置错"伪装成"网络不通")。
*/
const TENANT_NET_RE = /^u:[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
const GENERIC_NET_RE = /^[a-z0-9][a-z0-9_.-]{0,63}$/
/** hostId 的合法形状(`w-47` / `w-106` / `manager` …)。 */
const HOST_RE = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$/
/** 逻辑名的**规范分隔符**。`<network_id>/<hostId>` —— `network_id` 不含 `/`,故**首个** `/` 即分隔点。 */
export const NAME_SEP = '/'
/**
* 网络 id 是否合法。三条分支:
* - `ops` —— 运维网(固定值);
* - `u:<租户 id>` —— 用户网(租户 id 是数字或字符串都行,故后半段放宽);
* - 其它显式命名(小写字母数字与 `_.-`)—— 留给将来的"二级网 / 测试网",**不新增机制**。
*
* ⛔ **裸 `u` 是保留字**,永远不是一个合法的网名:它是"用户网前缀"。放行它会带来一个极隐蔽的坑 ——
* `DSHS_RELAY_DIALERS="u:5:manager"` 里的网络段 `u:5` 一旦被写错成 `u:5` 之外的形式(或有人直接
* 写 `u:manager`),左/右切点会把网络段切成 `u`,于是"配置写错"**长得像"这张网不存在"**(静默拒绝)。
*/
export function isNetworkId(raw: string): boolean {
if (raw === OPS_NETWORK) return true
if (raw === 'u') return false
return TENANT_NET_RE.test(raw) || GENERIC_NET_RE.test(raw)
}
export function isHostId(raw: string): boolean {
return HOST_RE.test(raw)
}
/**
* 校验网络 id;非法 ⇒ **抛**(不返回 `undefined`)。
*
* 为什么抛而不"兜个默认值":`DSHS_OVERLAY_NETWORK_ID` 打错一个字,如果静默回落 `ops`,
* 那台机器会**以正确的样子加入错误的网**(表现是"另一张网里看不见它",且没有任何一行日志说得出原因)。
*/
export function assertNetworkId(raw: string, what = 'network id'): string {
const v = raw.trim()
if (!isNetworkId(v)) throw new Error(`${what} 非法:${JSON.stringify(raw)}(合法形状:ops | u:<租户> | [a-z0-9][a-z0-9_.-]*)`)
return v
}
/** 节点逻辑名 = `<network_id>/<hostId>`。**唯一拼法**(别在调用方拼)。 */
export function logicalName(network: string, hostId: string): string {
return `${network}${NAME_SEP}${hostId}`
}
/**
* 反解一个逻辑名 / 配置项。
*
* ## 三条分隔规则(顺序即优先级)
* 1. 含 `/` ⇒ 按**首个** `/` 切(`ops/manager` · `u:5/w-106`)—— 规范形态;
* 2. 否则含 `:` ⇒ 按**最后一个** `:` 切(`ops:manager` · `u:5:manager`)——
* 为的是兼容运维习惯的 `network:hostId` 写法;⚠️ 必须从**右**切:`u:5` 里的 `:` 属于网络 id;
* 3. 都不含 ⇒ **旧形态**(R5 时代的扁平 `hostId`)⇒ 落在 `ops`,**旧配置照旧可用**。
* (过渡期不破坏现网:`DSHS_RELAY_DIALERS="manager"` 与 `"ops:manager"` 等价。)
*
* ⚠️ 第 2/3 条里"切出来的网络 id 必须合法",否则**抛** —— 见 `assertNetworkId` 的理由。
*/
export function parseLogicalName(raw: string): { network: string; hostId: string } {
const { network, hostId } = parseEntry(raw)
return { network, hostId }
}
/**
* 同 `parseLogicalName`,但额外告诉调用方"这条**自己写了网络没有**"(`qualified`)。
*
* 为什么需要这个位:白名单的**两种入参**对"裸 hostId"的解释**不同** ——
* - 扁平列表(`Set`):裸 hostId = R5 旧写法 ⇒ `ops`;
* - 按网络分桶(`Map`):裸 hostId = **本桶那张网**里的 hostId。
* 少了这个位,"`Map{'u:x' => {'d-user'}}`" 会被当成"把 ops 的 host 塞进 u:x 桶"⇒ 判据永远不命中
* ⇒ **静默拒绝**(本线反复要根治的那类病:配置写对了,表现却像网络不通)。
*/
function parseEntry(raw: string): { network: string; hostId: string; qualified: boolean } {
const v = raw.trim()
if (v === '') throw new Error('逻辑名为空')
const slash = v.indexOf(NAME_SEP)
if (slash > 0 && slash < v.length - 1) {
const network = assertNetworkId(v.slice(0, slash), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(slash + 1), raw), qualified: true }
}
const colon = v.lastIndexOf(':')
if (colon > 0 && colon < v.length - 1) {
const network = assertNetworkId(v.slice(0, colon), `逻辑名 ${JSON.stringify(raw)} 里的网络段`)
return { network, hostId: assertHostId(v.slice(colon + 1), raw), qualified: true }
}
// 旧形态:扁平 hostId(**不带网络**,由调用方决定它属于哪张网,默认运维网)。
return { network: OPS_NETWORK, hostId: assertHostId(v, raw), qualified: false }
}
function assertHostId(raw: string, whole: string): string {
const v = raw.trim()
if (!isHostId(v)) throw new Error(`逻辑名 ${JSON.stringify(whole)} 里的 hostId 非法:${JSON.stringify(raw)}`)
return v
}
/** 两个节点是否同网(DIAL 的**结构性**判据:不同网 ⇒ 连"能不能拨"这一步都走不到)。 */
export function sameNetwork(a: string, b: string): boolean {
return a === b
}
/**
* 同网**断言**(跨网 ⇒ 抛)—— P0-3 的"控制面侧那道门"。
*
* 为什么不是让调用方用 `sameNetwork` 自己判:那样的常见写法是
* `if (!sameNetwork(a, b)) return undefined` ⇒ **跨网拒绝在日志里什么都不留**,
* 表现与"节点离线 / 名字打错"完全同形 —— 正是本线反复要根治的静默失效。
* 凡"跨网就是错"的地方一律用本函数:错误里**带着两边是哪张网**,排障不必猜。
*/
export function assertSameNetwork(expected: string, actual: string, what = '节点'): void {
if (expected === actual) return
throw new Error(`${what} 跨网:期望 "${expected}",实际 "${actual}"(结构性隔离 ⇒ 不允许跨网解析)`)
}
/**
* 拨号方白名单的**归一化** —— 同时接受两种入参,旧形态照旧可用(过渡期不破坏现网)。
*
* | 入参 | 桶里"裸 hostId"的含义 |
* |---|---|
* | `Set<string>`(R5 形态,`main.ts` 从 env 读出来的就是它) | 该条目**自带**网络(`ops:manager` / `u:5:d1`);裸 hostId ⇒ `ops` |
* | `Map<network, Set<hostId>>`(新形态) | 裸 hostId ⇒ **本桶那张网**里的 hostId |
*
* ⚠️ 两种入参对"裸 hostId"的解释不同,是**故意的**:分桶形态下,桶键已经表达了网络,
* 桶里再写一遍网络只会带来"两处写不一致"的新风险(所以**写了就必须一致,否则抛**)。
*
* ⚠️ 返回的 map **永远是新的**(不被调用方后续 `Set` 改动影响)—— 白名单是安全判据,
* 不能因为某个持有引用的调用方顺手 `add()` 就悄悄放宽。
*/
export function normalizeDialers(
input: ReadonlySet<string> | ReadonlyMap<string, ReadonlySet<string>> | undefined,
): Map<string, ReadonlySet<string>> {
const out = new Map<string, ReadonlySet<string>>()
if (input === undefined) return out
if (isDialerMap(input)) {
for (const [networkRaw, hosts] of input.entries()) {
const network = assertNetworkId(networkRaw, '拨号方白名单的网络段')
const bucket = new Set<string>(out.get(network) ?? [])
for (const host of hosts ?? []) {
const e = parseEntry(host)
if (e.qualified && e.network !== network) {
throw new Error(
`拨号方白名单自相矛盾:桶 "${network}" 里放了属于 "${e.network}" 的条目 ${JSON.stringify(host)}`,
)
}
bucket.add(e.hostId)
}
out.set(network, bucket)
}
return out
}
// 扁平列表(R5 形态):逐条按自带网络**分发**到各自的桶;裸 hostId ⇒ 运维网。
for (const host of input) {
const e = parseEntry(host)
const bucket = new Set<string>(out.get(e.network) ?? [])
bucket.add(e.hostId)
out.set(e.network, bucket)
}
return out
}
/**
* 类型判别:`Map` 形态 vs 扁平 `Set` 形态。
*
* 为什么要单独一个函数而不是直接 `input instanceof Map`:`ReadonlyMap` / `ReadonlySet` 都是
* **接口**,`instanceof` 收窄在联合类型上不可靠(TS 会把 else 分支仍当成联合,于是迭代出来的
* 元素类型变成 `string | [string, …]`)。写成显式类型谓词,编译器与读代码的人都清楚。
*/
function isDialerMap(
v: ReadonlySet<string> | ReadonlyMap<string, ReadonlySet<string>>,
): v is ReadonlyMap<string, ReadonlySet<string>> {
return typeof (v as ReadonlyMap<string, ReadonlySet<string>>).get === 'function'
}
/**
* 一行摘要(**同一条信息只说一次**)—— `main.ts` 启动日志与 `/status` 共用同一格式,
* 免得"日志说的"与"status 说的"长得不一样(那种差异排查时最费时间)。
*
* `ops` 下的条目**省略网络前缀**(与 R5 的日志/配置写法一致,读起来不变)。
*/
export function describeDialers(map: ReadonlyMap<string, ReadonlySet<string>>): string[] {
const out: string[] = []
for (const network of [...map.keys()].sort()) {
for (const host of [...(map.get(network) ?? [])].sort()) {
out.push(network === OPS_NETWORK ? host : logicalName(network, host))
}
}
return out
}
+209
View File
@@ -0,0 +1,209 @@
/**
* 节点选点(placement)—— **"加入哪个节点"这件事的唯一判据来源**。
*
* ## 为什么要有独立一层
* 「自动选择适合的节点」如果散在 client / Manager / 门户里各写一遍,就会出现三套不一致的
* 判据(这类不一致在复盘里反复出现)。所以:**判据只有这一处,纯函数、无 IO、可单测**。
*
* ## 两条硬规则(先说结论)
* 1. **手动选择永远优先**(`manual: true`)—— 显式指定就不该被算法偷偷改掉;
* 手动指定的节点**满了** ⇒ 返回 `queued`(排队等待)或 `rejected`,**不会静默换一个**。
* 2. **满载是唯一的硬门**(`capacity.free === 0` ⇒ 不可选)。其余(RTT 高、近期失败多)
* 只**降权**,不排除 —— 因为"唯一可用但慢"的节点也远好过"没有节点"。
*
* ## 排序依据(用户要求的两条:速度 + 负载)
* ```text
* speed = 100 / (1 + rttMs / rttHalfMs) // rtt 0→100 分;50ms→50 分;200ms→20 分
* load = 100 × (1 − used / max) // 容量未知 ⇒ 按 0.5 中性处理(不假装它空)
* score = weight × (wSpeed·speed + wLoad·load) − failurePenalty × recentFailures
* ```
* 权重默认 `速度 0.55 / 负载 0.45`:实测里"能不能连上"由 RTT 决定,"连上之后卡不卡"由负载决定,
* 而我们的第一瓶颈是 **presence(在线态)**,所以速度略重(传输方案 §12 的实测依据)。
*
* ## 与 relay 的关系
* 本模块**不连网、不开端口**:它只把"可观测画像"变成"一次可解释的选择"。
* relay 侧只负责把画像喂进来(`rttMs` 来自心跳 PONG,`capacity` 来自注册数)。
*
* @module dshs/net/relay/placement
*/
/** 一个候选节点(relay 或 worker,判据同构)的**可观测**画像。 */
export interface NodeCandidate {
id: string
/** 往返时延(ms)。未知 ⇒ 不猜,按"最差但有值"处理(见 `assumedRttMs`)。 */
rttMs?: number
/** 容量。`max <= 0` 或缺省 ⇒ **不限**(视为有空位,负载按中性算)。 */
capacity?: { max: number; used: number }
/** 近期失败次数(连接失败 / 被拒)—— 用于降权,**不用于排除**。 */
recentFailures?: number
/** 手工指定的节点。**最高优先级**:只要它没满就一定选它。 */
manual?: boolean
/** 额外权重(例如"存量锚点"、"同机房");`1` = 中性。 */
weight?: number
}
export interface PlacementWeights {
/** 速度权重。默认 0.55。 */
speed?: number
/** 负载权重。默认 0.45。 */
load?: number
/** 每次近期失败的扣分。默认 15。 */
failurePenalty?: number
/** 速度评分的半衰 RTT(ms)。默认 50。 */
rttHalfMs?: number
/** RTT 未知时的代用值(ms)。默认 120(明显偏保守,避免"没测速"被当成"很快")。 */
assumedRttMs?: number
/** 全部满载时的建议重试间隔(ms)。默认 5000。 */
queueRetryAfterMs?: number
}
export interface PlacementScore {
id: string
/** 0–100(负分表示被降权到负);`-Infinity` = 硬门拦住。 */
score: number
/** 硬门原因。只有 `full` 是硬门。 */
blocked?: 'full'
detail: {
speed: number
load: number
penalty: number
/** 剩余空位;容量未知时 `undefined`。 */
freeSlots?: number
/** 负载比例 0–1。 */
loadRatio?: number
}
}
export interface PlacementDecision {
/** 选中的节点。全部满载 ⇒ `undefined`。 */
chosen?: NodeCandidate
/** 结论形态:选中 / 排队等待 / 直接拒绝(无任何可选且不适合排队)。 */
outcome: 'chosen' | 'queued' | 'rejected'
/** 排队时建议的等待时长(ms)。 */
retryAfterMs?: number
/** **可解释**的理由(这个字段就是本模块存在的意义:拒绝也要说清为什么)。 */
reason: string
/** 完整排名(含被拦的),便于前端展示与排障。 */
ranking: PlacementScore[]
}
const DEFAULTS = {
speed: 0.55,
load: 0.45,
failurePenalty: 15,
rttHalfMs: 50,
assumedRttMs: 120,
queueRetryAfterMs: 5_000,
} as const
function clampScore(n: number): number {
return Math.max(-100, Math.min(100, n))
}
/** 给一个候选打分(纯函数;导出便于单测与前端复用同一套判据)。 */
export function scoreCandidate(c: NodeCandidate, w: PlacementWeights = {}): PlacementScore {
const W = { ...DEFAULTS, ...w }
const rtt = typeof c.rttMs === 'number' && c.rttMs >= 0 ? c.rttMs : W.assumedRttMs
const speed = clampScore((100 / (1 + rtt / W.rttHalfMs)) | 0)
const max = c.capacity !== undefined ? c.capacity.max : 0
const used = c.capacity !== undefined ? c.capacity.used : 0
const limited = max > 0
const freeSlots = limited ? Math.max(0, max - used) : undefined
// 容量未知 ⇒ 按 0.5 中性:既不当成空、也不当成满(不猜)。
const loadRatio = limited ? Math.min(1, used / max) : 0.5
const load = clampScore(Math.round(100 * (1 - loadRatio)))
const penalty = Math.round((c.recentFailures ?? 0) * W.failurePenalty)
const weight = typeof c.weight === 'number' && c.weight > 0 ? c.weight : 1
const raw = weight * (W.speed * speed + W.load * load) - penalty
const base = {
id: c.id,
detail: {
speed,
load,
penalty,
...(freeSlots === undefined ? {} : { freeSlots }),
...(limited ? { loadRatio } : {}),
},
}
// **唯一的硬门**:容量已满。满了就是不能加入 —— 只能排队或换节点。
if (freeSlots === 0) return { ...base, score: Number.NEGATIVE_INFINITY, blocked: 'full' }
return { ...base, score: Math.round(raw * 100) / 100 }
}
/** 全量排名(降序;`-Infinity` 排在最后)。 */
export function rankCandidates(candidates: readonly NodeCandidate[], w: PlacementWeights = {}): PlacementScore[] {
return candidates.map((c) => scoreCandidate(c, w)).sort((a, b) => b.score - a.score)
}
export interface ChooseOptions extends PlacementWeights {
/**
* 手动指定的 id。给了它 ⇒ **只考虑它**(不静默改选别的)。
* 这也是"手动选择"与"自动推荐"之间唯一的接口。
*/
manualId?: string
/** 允许排队等待(默认 `true`)。设 `false` ⇒ 满载直接 `rejected`("不能加入")。 */
allowQueue?: boolean
}
/**
* 选一个节点 —— **自动 / 手动 / 排队**三种出口都有明确语义:
* | 情况 | outcome | 说明 |
* |---|---|---|
* | 手动指定且未满 | `chosen` | 尊重显式选择 |
* | 手动指定但已满 | `queued` / `rejected` | **不会**偷偷换节点 |
* | 自动且有空位 | `chosen` | 按速度 + 负载打分 |
* | 自动但全满 | `queued` / `rejected` | 排队等位,或(`allowQueue:false`)拒绝加入 |
* | 候选为空 | `rejected` | 明确"没有可选节点",不抛异常给上层去猜 |
*/
export function chooseNode(candidates: readonly NodeCandidate[], opts: ChooseOptions = {}): PlacementDecision {
const allowQueue = opts.allowQueue !== false
const retryAfterMs = opts.queueRetryAfterMs ?? DEFAULTS.queueRetryAfterMs
const ranking = rankCandidates(candidates, opts)
if (ranking.length === 0) {
return { outcome: 'rejected', reason: '没有可用候选节点(候选列表为空)', ranking }
}
const manualId = opts.manualId
if (manualId !== undefined && manualId !== '') {
const hit = ranking.find((r) => r.id === manualId)
if (hit === undefined) {
return { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 不在候选列表里(不静默改选别的节点)`, ranking }
}
if (hit.blocked === 'full') {
return allowQueue
? { outcome: 'queued', retryAfterMs, reason: `手动指定的节点 "${manualId}" 已满载(容量已用尽)⇒ 排队等待`, ranking }
: { outcome: 'rejected', reason: `手动指定的节点 "${manualId}" 已满载 ⇒ 拒绝加入(未开启排队)`, ranking }
}
return { chosen: candidates.find((c) => c.id === manualId), outcome: 'chosen', reason: `按手动指定选择 "${manualId}"`, ranking }
}
const best = ranking[0]
if (best.blocked !== 'full') {
const c = candidates.find((x) => x.id === best.id)
return {
chosen: c,
outcome: 'chosen',
reason:
`自动选择 "${best.id}"(速度 ${best.detail.speed} 分 / 负载 ${best.detail.load} 分` +
`${best.detail.freeSlots === undefined ? '' : ` / 空位 ${best.detail.freeSlots}`}` +
`${best.detail.penalty > 0 ? ` / 近期失败扣 ${best.detail.penalty}` : ''})`,
ranking,
}
}
const fullCount = ranking.filter((r) => r.blocked === 'full').length
return allowQueue
? { outcome: 'queued', retryAfterMs, reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 排队等待(${retryAfterMs}ms 后重试)`, ranking }
: { outcome: 'rejected', reason: `全部 ${fullCount} 个候选节点都已满载 ⇒ 拒绝加入(未开启排队)`, ranking }
}
/** 供日志/UI 一行展示(**同一条信息只说一次**,避免各处自定义格式)。 */
export function describeDecision(d: PlacementDecision): string {
const head = `placement=${d.outcome}${d.chosen === undefined ? '' : ` node=${d.chosen.id}`}`
const retry = d.retryAfterMs === undefined ? '' : ` retryAfter=${d.retryAfterMs}ms`
return `${head}${retry} reason="${d.reason}"`
}
+57
View File
@@ -0,0 +1,57 @@
/**
* `relay:<id>` —— 会合实现的第三个(也是**去掉 sshd 依赖的那一个**)。
*
* ## 与其他两个实现的关系(同一 `Rendezvous` 接口,可共存、可逐个切换)
* | 实现 | `via` | Manager 侧看到的地址 | 数据面 |
* |---|---|---|---|
* | `LocalRendezvous` | `local` | `127.0.0.1:19100`(同机直连) | 无中转 |
* | `ManagerSshRendezvous` | `manager-ssh` | `127.0.0.1:19000`(**sshd 反向隧道落点**) | Manager 主机上的 sshd |
* | **`RelayRendezvous`** | `relay` | `127.0.0.1:<relay 动态分配>`(**relay 开了回环监听**) | relay 的一条出向 wss |
*
* 三者对 Manager 侧**同形**(都是 `host:port`)⇒ 换实现不动调用方,这是 S0 抽 `Reachability`
* 的全部意义(`src/net/rendezvous.ts:64` 早就写了"本类应被 `relay:<id>` 实现替换")。
*
* ## 独有增益:**实时在线态**
* 前两个实现只能回答"表里写的地址是什么"——`dsh_instances.status` 是 DB 快照,**不实时**。
* relay 的注册由心跳维持(45s 无帧即判死)⇒ 本实现可以**先问在线、再给地址**,
* 离线直接回 `undefined`(让上层回退别的实现,而不是往死地址上打、干等到超时)。
*
* @module dshs/net/relay/rendezvous
*/
import { VIA_RELAY, type Reachability } from '../reachability.js'
import type { AddressLookup, Rendezvous } from '../rendezvous.js'
import { parseLogicalName } from './network.js'
export interface RelayRendezvousOptions {
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */
dialTargetUrl: string
/**
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
* 键是逻辑名(P0-3)⇒ 同名 host 分属两张网时各查各的,不会互相覆盖。
*/
addressOf: AddressLookup
/**
* **实时**在线判定(relay 心跳驱动)。省略 = 不判在线(退化成与另两个实现相同的纯查表)。
* 入参同样**逻辑名**(relay 的端点视图按 `network/hostId:port` 建键)。
*/
online?: (name: string) => boolean
}
export class RelayRendezvous implements Rendezvous {
readonly id = VIA_RELAY
constructor(private readonly opts: RelayRendezvousOptions) {}
dialTarget(): string {
return this.opts.dialTargetUrl
}
async resolve(name: string): Promise<Reachability | undefined> {
if (this.opts.online !== undefined && !this.opts.online(name)) return undefined
const address = this.opts.addressOf(name)
if (address === undefined) return undefined
const { network, hostId } = parseLogicalName(name)
return { hostId, networkId: network, via: this.id, address, scheme: 'http' }
}
}
File diff suppressed because it is too large. Load diff
+519
View File
@@ -0,0 +1,519 @@
/**
* 中继失败切流 · **唯一一份实现**(覆盖网络 · 序⑦)
*
* ## 为什么只能是"唯一一份"
* 本线已经吃过两次**同类**教训:`translateEndpoint` 漏赋值、`target()` 静默回退
* —— 都是"同一件事在多处各写一遍,其中一处悄悄漏了"。所以切换逻辑**只写在这里**,
* 三个装配点(Manager 拨号通道 / worker 实例面 / 独立 `relay --client`)复用同一个类。
*
* ## 它解决的是"候选集退化成单点"
* 改造前 `RelayClient` 退避**永不放弃**地重试**同一个 url**,而取址层 `pickFromDoc`
* **取到第一个候选就 `return`** ⇒ 重解析一百次拿回来的还是同一个字符串。
* ⇒ 本模块负责"**发现当前这条不健康 ⇒ 换到链里的下一条**"。
*
* ## 判据全部复用现成的(D2:⛔ 不新造心跳 / 不新增探测帧)
* - 不健康 = `state === 'backoff'` 且(`attempts ≥ MIN_ATTEMPTS` ∨ `unhealthyForMs ≥ GRACE`);
* - 候选链 = {@link listOverlayRelayCandidates}(**同一份引导链**,⛔ 不另写取址);
* - "先建新、成功再关旧" = 由调用方的 `open()` 保证(见 {@link RelayFailoverDeps.open})。
*
* ## 硬纪律(每条都有对应验收)
* - **D4**:新通道**建不起来 ⇒ 保持原通道**(把"本来能用"的通路打掉才是真事故);
* - **D5**:被换掉的 url 进**冷却表**,冷却期内不回跳(否则两台互相抢 = 抖动风暴);
* - **D6**:链里除已排除项外**没有候选 ⇒ 不切换**(原地退避);⛔ 绝不切到空、**绝不静默回退默认机**;
* - **D7**:**每次切换**输出一行 `[relay-switch] #N …` + 累加计数 `switches`
* ⇒ `grep -c '^\[relay-switch\]'` 与 `switches` **必然相等**(判别器可断言)。
* ⚠️ 所以"**没切**"的行必须用**另一个前缀** `[relay-skip]` —— 否则计数对不上(E9 会红)。
*
* ## 序⑧(2026-09-17)新增:**冷却语义的不对称拆分**(解决"候选池被自己耗干")
* - **D1**:**「当前通道已不可用」有权打破自己刚设下的冷却;「目录说该换回首位」没有**。
* ⇒ `replace()` 新增 `origin: 'health' | 'directory'`,闸门**只对 directory 收口**。
* - **D2**:冷却键**仍是 url**;新增 `kind`(`switched-away` / `open-failed`)**只**决定豁免优先级。
* ⛔ 原因做键 ⇒ 同一 url 会同时存在多条 ⇒ 从头抖回来 = 净退化。
* - **D4**:豁免**有界** —— 每 url **每冷却周期一次**(`exemptedAtMs`);豁免后 `open()` 再失败
* ⇒ 重置该 url 冷却**且本周期不再豁免**(⛔ 否则每 2 s 试一次 = 重试风暴)。
* - **D5**:豁免优先级 = `switched-away` 优先,同类按 `untilMs` 升序。
* - **D3 护栏**:豁免**只在 D6 现场**启用;**有干净候选时行为逐字不变**(单测 F15 锁住)。
* - **D6 开关**:`RELAY_FAILOVER_EXEMPT`(默认 `1`;置 `0` ⇒ 逐字回到序⑦ 行为)= 第二层回滚。
*/
/** 阈值(全部来自参数表 / env;脚本与实现**零数字字面量**)。 */
export interface RelayFailoverThresholds {
/** 连续失败次数达到这个数即视为不健康。 */
minAttempts: number
/** 或:持续不健康时间达到这个数(ms)即视为不健康。 */
graceMs: number
/** 被换掉的 url 的冷却时长(ms);冷却期内不参与候选。 */
cooldownMs: number
/** 从"判定不健康"到"切换完成"的允许上限(ms)——**验收判据**,实现只用它做日志。 */
deadlineMs: number
/** 巡检周期(ms)。 */
checkMs: number
/**
* **换址时"新通道算不算建起来了"的等待上限(ms)**。
*
* ⚠️ 只作用于**换址**,⛔ **不作用于启动** —— 启动那条通道必须"不依赖网络"就走完
* (P0-2 的设计前提:控制面自己也是客户端,启动那一刻自己的门户还没 listen)。
* 不给这个上限,`open()` 就会把"口池绑好了"当成"通了" ⇒ 会切到一条**同样连不上**的中继上。
*/
upTimeoutMs: number
/**
* **序⑧ 新增:一跳豁免总开关**(`RELAY_FAILOVER_EXEMPT`,默认开)。
*
* 语义 = "**当前这条已经挂了**"有权打破自己刚设下的冷却(见 {@link RelayFailoverSupervisor.pickExemptTarget})。
* 置 `0` ⇒ 逐字回到序⑦ 行为(D6 现场只写 `[relay-skip]`、不切换)⇒ **第二层回滚**。
* ⚠️ 与 `minAttempts <= 0`(把整个监管器关掉)**不是同一层级**,⛔ 不许合并(D6 口径)。
*/
exempt: boolean
}
/**
* 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值并在日志里说清)。
*
* 键名与本线参数表 `参数表_覆盖网络_20260917.md` 的 `RELAY_FAILOVER_*` 一一对应。
*/
export function relayFailoverThresholds(
env: Record<string, string | undefined> = process.env,
): RelayFailoverThresholds {
const num = (key: string, dflt: number): number => {
const raw = (env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
return {
minAttempts: num('RELAY_FAILOVER_MIN_ATTEMPTS', 3),
graceMs: num('RELAY_FAILOVER_GRACE_MS', 15_000),
cooldownMs: num('RELAY_FAILOVER_COOLDOWN_MS', 300_000),
deadlineMs: num('RELAY_FAILOVER_DEADLINE_MS', 30_000),
checkMs: num('RELAY_FAILOVER_CHECK_MS', 2_000),
upTimeoutMs: num('RELAY_FAILOVER_UP_TIMEOUT_MS', 12_000),
/**
* 值格仍走 `num()`(`/^\d+$/`)⇒ ⛔ 参数表里的值格**必须纯数字**(写 `1` / `0`,
* ⛔ 不许写成 `true` 或带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**)。
*/
exempt: num('RELAY_FAILOVER_EXEMPT', 1) !== 0,
}
}
/** 一条 relay 通道(把"通道"和它的健康快照绑在一起,避免调用方各记一份)。 */
export interface RelayChannelHandle {
/** 这条通道连的地址(`ws://` / `wss://`)。 */
readonly url: string
/** 该通道自己的健康快照(实现里通常就是 `RelayClient.status()` 的投影)。 */
health(): { state: string; attempts: number; unhealthyForMs: number }
/** 关掉这条通道。**幂等**、不抛。 */
close(): void
}
export interface RelayFailoverDeps {
/**
* 建一条新通道,**成功才返回句柄**(失败返回 `undefined`)。
*
* ⛔ 顺序由本函数负责:**先把新通道建起来并确认可用,本模块之后才去关旧的**。
* 反着做(先关后建)会在切换失败时把"本来能用"的通路打掉 ⇒ 违反 R11。
*/
open: (url: string) => Promise<RelayChannelHandle | undefined>
/** **候选链**(有序)。生产上就是 `listOverlayRelayCandidates()` 的投影。 */
candidates: () => Promise<readonly string[]>
log: (line: string) => void
thresholds?: Partial<RelayFailoverThresholds>
/** 注入点(单测用);默认 `Date.now`。 */
nowMs?: () => number
/** 注入点(单测用);默认 `setTimeout` 自链。 */
setTimerImpl?: (fn: () => void, ms: number) => unknown
clearTimerImpl?: (handle: unknown) => void
}
/**
* **冷却原因**(序⑧ / D2):⛔ **不作冷却表的键**,只决定**豁免优先级**(D5)。
*
* - `switched-away` —— "**我们主动离开了它**",它**曾可用**({@link RelayFailoverSupervisor.replace} 换址成功那条)⇒ 优先豁免。
* - `open-failed` —— "**刚证明它建不起来**"(`open()` 失败那条)⇒ 次选。
*
* 🔴 **为什么原因不能做键**(本线已有同类教训:`translateEndpoint` 漏赋值):同一 url 会**先后**因不同
* 原因进冷却 ⇒ 原因做键会让同一 url 同时存在多条条目 ⇒ 它会通过"另一条原因键"被再次尝试 ⇒
* 抖动抑制失效 = **净退化**。
*/
export type RelayCooldownKind = 'switched-away' | 'open-failed'
/** 冷却表条目(序⑧ / D2 + D4)。 */
export interface RelayCooldownEntry {
/** 解除时刻(epoch ms)。 */
untilMs: number
/** **本冷却周期的起点**(epoch ms)—— 判定"本周期的豁免是否已用掉"的锚点(D4)。 */
sinceMs: number
/** 进冷却的原因;只喂给豁免优先级(D5)。 */
kind: RelayCooldownKind
/**
* **本周期内上一次用掉豁免**的时刻(D4 的有界性)。
* 判据 = `exemptedAtMs >= sinceMs` ⇒ 本周期不再豁免它(否则每 2 s 巡检都试一次 = **重试风暴**)。
*/
exemptedAtMs?: number
}
/** 切换统计(D7:**可读计数**,与日志行数必须一致)。 */
export interface RelayFailoverStats {
/** 成功切换次数(= `[relay-switch]` 日志行数)。 */
switches: number
/** 巡检次数。 */
checks: number
/** "判定不健康但无候选可切"的次数(D6 的现场证据)。 */
noCandidateChecks: number
/** "新通道建不起来 ⇒ 保持原通道"的次数(D4 的现场证据)。 */
openFailed: number
/** **序⑧ 新增**:走"一跳豁免"完成的切换次数(⊆ `switches`;这些行都带 `|豁免`)。 */
exemptSwitches: number
/** 最近一次成功切换的时刻(epoch ms)。 */
lastSwitchAtMs?: number
/** 冷却表中的地址与解除时刻(⚠️ 保持 `{url, untilMs}` 外形;`kind` 为序⑧ 追加的只读字段)。 */
cooldown: { url: string; untilMs: number; kind: RelayCooldownKind }[]
}
export class RelayFailoverSupervisor {
private readonly deps: RelayFailoverDeps
private readonly th: RelayFailoverThresholds
private readonly log: (line: string) => void
private readonly now: () => number
private readonly setTimer: (fn: () => void, ms: number) => unknown
private readonly clearTimer: (handle: unknown) => void
private current: RelayChannelHandle | undefined
/** 冷却表:**键 = url**(单一事实:冷却期内该地址不可用);值是 {@link RelayCooldownEntry}(序⑧ 结构化)。 */
private readonly cooling = new Map<string, RelayCooldownEntry>()
private switches = 0
private checks = 0
private noCandidateChecks = 0
private openFailed = 0
private exemptSwitches = 0
private lastSwitchAtMs: number | undefined
private lastSkipLogAtMs: number | undefined
private timer: unknown
private running = false
private ticking = false
constructor(deps: RelayFailoverDeps) {
this.deps = deps
this.log = deps.log
this.now = deps.nowMs ?? ((): number => Date.now())
this.setTimer =
deps.setTimerImpl ??
((fn, ms): unknown => {
/**
* **默认 unref**:巡检是**后台**活动,⛔ 不许因为它把进程钉在事件循环上
* (`relay --client` / worker 这类进程本来就可能随时退出;一个 pending timer
* 会让"该退出的进程退不掉"—— 那是净退化)。
*/
const h = setTimeout(fn, ms)
;(h as { unref?: () => void }).unref?.()
return h
})
this.clearTimer = deps.clearTimerImpl ?? ((h): void => clearTimeout(h as ReturnType<typeof setTimeout>))
this.th = { ...relayFailoverThresholds({}), ...(deps.thresholds ?? {}) }
}
/** 当前通道(启动时由装配点灌入第一条)。 */
get channel(): RelayChannelHandle | undefined {
return this.current
}
get thresholds(): RelayFailoverThresholds {
return { ...this.th }
}
/** 灌入起始通道(启动那条,**不计数、不写 `[relay-switch]`** —— 它不是"切换")。 */
seed(handle: RelayChannelHandle | undefined): void {
this.current = handle
}
stats(): RelayFailoverStats {
const now = this.now()
return {
switches: this.switches,
checks: this.checks,
noCandidateChecks: this.noCandidateChecks,
openFailed: this.openFailed,
exemptSwitches: this.exemptSwitches,
lastSwitchAtMs: this.lastSwitchAtMs,
cooldown: [...this.cooling.entries()]
.filter(([, e]) => e.untilMs > now)
.map(([url, e]) => ({ url, untilMs: e.untilMs, kind: e.kind }))
.sort((a, b) => a.untilMs - b.untilMs),
}
}
/** 是否满足"不健康"判据(**口径**:只有 `backoff` 累计才算;`handshaking` 不算)。 */
private unhealthy(h: { state: string; attempts: number; unhealthyForMs: number }): boolean {
/**
* 🔑 **`RELAY_FAILOVER_MIN_ATTEMPTS=0` = 总开关关闭**(§7 回滚第 1 层)。
* 语义必须这样定:置 0 后监管器**永不触发** ⇒ 不改代码就能回到"原地退避重试"的现状。
* ⛔ 不能理解成"`attempts >= 0` 恒真"—— 那会变成"一进 backoff 就切",是**反向**效果。
*/
if (this.th.minAttempts <= 0) return false
if (h.state !== 'backoff') return false
return h.attempts >= this.th.minAttempts || h.unhealthyForMs >= this.th.graceMs
}
/**
* **唯一的"换址"动作**(三处触发路径共用:健康巡检 / 健康巡检的**一跳豁免** / 目录地址变了)。
*
* 成功 → 关旧、记冷却、`switches += 1`、写一行 `[relay-switch]`;失败 → 原通道**原样保留**。
*
* ## 序⑧:`origin` = 「这条换址**有没有**打破冷却的权力」(D1)
* - `'directory'` ⇒ ⛔ **没有**。它的触发条件("目录里的地址变了")与"旧通道是否可用"**无关**;
* 给它豁免权 ⇒ "当前站在 106、目录首位是 47"的每一轮巡检都想把刚冷却的 47 换回来 ⇒
* **两位互相抢 = D5 想防的那个抖动风暴**(真机 11:43:26 已实测踩到)。
* - `'health'` ⇒ 有。`tick()` 稳态只用它传**已过滤掉冷却**的候选;唯一会传"冷却中目标"的场合是
* **D6 现场的一跳豁免**(见 {@link tick})—— ⚠️ 豁免**挑谁**由 `tick()` 单点判定(D4/D5),
* 本函数只做一件与豁免有关的事:**认领**它(写 `exemptedAtMs`)+ 按 D4 处理"豁免也失败"。
*/
async replace(
targetUrl: string,
reason: string,
origin: 'health' | 'directory',
): Promise<boolean> {
const old = this.current
if (old !== undefined && old.url === targetUrl) {
this.log(`[relay-skip] 目标地址与当前通道相同(${targetUrl})⇒ 不动`)
return false
}
/**
* 🔴 **冷却闸门放在这里(不是只放在 `tick()` 里)** —— 真机实测逼出来的修正:
*
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
*
* 序⑧(D1):闸门**只对 `'directory'` 收口** —— `'health'` 传进来的"冷却中目标"就是一跳豁免本身。
*/
const now = this.now()
const entry = this.cooling.get(targetUrl)
const inCooling = entry !== undefined && entry.untilMs > now
if (inCooling && origin === 'directory') {
this.log(
`[relay-skip] 目标 ${targetUrl} 仍在冷却(剩 ${entry.untilMs - now}ms / 共 ${this.th.cooldownMs}ms)` +
`⇒ 不换(D5 防抖动;目录路径**无豁免权** D1);原因本为:${reason}`,
)
return false
}
const opened = await this.deps.open(targetUrl)
if (opened === undefined) {
this.openFailed += 1
const failAt = this.now()
/**
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
*
* 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
* 链虽然"不再退化成单点",却依然**换不过去**。
* ⇒ 语义 = 「排除 **当前** + 排除 **试过且失败的** ⇒ 取下一个」,失败项冷却 `cooldownMs` 后自动回归。
*/
if (inCooling) {
/**
* **D4 后半句**:豁免尝试也失败 ⇒ ① **重置该 url 冷却** ② **本周期不再豁免它**。
* 实现 = `exemptedAtMs = sinceMs = failAt` ⇒ `exemptedAtMs >= sinceMs` 恒真 ⇒ 本周期额度用尽。
* ⛔ 不这样写就是"每 2 s 豁免一次、每次都失败" = **重试风暴**(比不切更糟,R11)。
*/
this.cooling.set(targetUrl, {
untilMs: failAt + this.th.cooldownMs,
sinceMs: failAt,
kind: 'open-failed',
exemptedAtMs: failAt,
})
this.log(
`[relay-skip] ⛔ 豁免尝试也起不来(${targetUrl})⇒ **保持原通道**;` +
`重置该候选冷却 ${this.th.cooldownMs}ms 且**本周期不再豁免**(D4 防重试风暴)`,
)
return false
}
this.cooling.set(targetUrl, {
untilMs: failAt + this.th.cooldownMs,
sinceMs: failAt,
kind: 'open-failed',
})
this.log(
`[relay-skip] ⛔ 新通道起不来(${targetUrl})⇒ **保持原通道**(不做半途替换);` +
`该候选进冷却 ${this.th.cooldownMs}ms(否则它会把链堵死)`,
)
return false
}
this.current = opened
if (old !== undefined) {
try {
old.close()
} catch {
/* 已关 */
}
/**
* D5:**被换掉的那条进冷却**。不冷却的话,它一恢复就会被立刻换回来,
* 两台互相抢 = 抖动风暴("A 挂 → 切 B → A 恢复 → 切回 A → 再挂…")。
*/
const awayAt = this.now()
this.cooling.set(old.url, {
untilMs: awayAt + this.th.cooldownMs,
sinceMs: awayAt,
kind: 'switched-away',
})
}
/** 认领本次豁免(D4):本周期内**不再**把它当豁免对象。 */
const wasExempt = inCooling && entry !== undefined
if (wasExempt) {
this.cooling.set(targetUrl, { ...entry, exemptedAtMs: this.now() })
this.exemptSwitches += 1
}
this.switches += 1
this.lastSwitchAtMs = this.now()
this.log(
`[relay-switch] #${this.switches} ${old === undefined ? '(无)' : old.url} -> ${opened.url}` +
`(原因:${reason};冷却 ${old === undefined ? '-' : old.url} 至 +${this.th.cooldownMs}ms)` +
(wasExempt ? `|豁免 kind=${entry.kind} 剩 ${entry.untilMs - now}ms` : ''),
)
return true
}
/**
* 一次巡检:**当前通道不健康 ⇒ 换到链里的下一条**(排除当前 + 冷却中的)。
*
* ⛔ 不健康但无候选 ⇒ 序⑧ 之前是**什么都不做**(D6:原地退避,⛔ 不切到空 / 不静默回退默认机);
* 序⑧ 起:**先试一次"一跳豁免"**(D1/D4/D5),拿不到豁免对象才回到原地退避。
*/
async tick(): Promise<void> {
this.checks += 1
const cur = this.current
if (cur === undefined) return
const h = cur.health()
if (!this.unhealthy(h)) return
const now = this.now()
let urls: readonly string[]
try {
urls = await this.deps.candidates()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-skip] ⚠ 候选链解析失败(${msg})⇒ 本次不换址(保持原地退避)`)
return
}
const blocked = new Set<string>([cur.url])
for (const [url, e] of this.cooling) if (e.untilMs > now) blocked.add(url)
/**
* ⚠️ **这段文案是 D3 的护栏本身**:稳态(有干净候选)路径上必须**逐字不变** ——
* 否则序⑦ 的 E1–E11 结论会被本单"自己推翻自己"。
*/
const reason =
`当前通道不健康(state=${h.state} attempts=${h.attempts} unhealthyForMs=${h.unhealthyForMs}` +
` ≥ 阈值 minAttempts=${this.th.minAttempts}/graceMs=${this.th.graceMs})`
const target = urls.find((u) => !blocked.has(u))
if (target !== undefined) {
await this.replace(target, reason, 'health')
return
}
/**
* ═══ **D6 现场**:链里除"当前 + 冷却中的"之外**没有候选** ═══
*
* 序⑧ 的立项依据正是这里:生产目录 3 条候选里有 **2 条同机**,一次 47 故障就把它们
* **同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 ⇒
* 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
*
* ⇒ 语义拆分(D1):**"当前这条已经挂了"有权打破自己刚设下的冷却**(一跳豁免),
* 而"目录说该换回首位"没有这个权力(见 {@link replace} 的 `origin`)。
*/
const exempt = this.pickExemptTarget(urls, cur.url, now)
if (exempt !== undefined) {
await this.replace(
exempt,
`${reason};D6 现场:**候选池已被冷却耗干** ⇒ 动用一跳豁免(每 url 每冷却周期一次,D4)`,
'health',
)
return
}
this.noCandidateChecks += 1
/**
* D6 现场证据。⚠️ 这句话**必然**会被反复写(每次巡检一次)⇒ 按 grace 节流,
* 否则日志被它刷满(本线的日志纪律:`attempts` 那种每秒一行的噪音已经吃过一次)。
*/
if (this.lastSkipLogAtMs === undefined || now - this.lastSkipLogAtMs >= this.th.graceMs) {
this.lastSkipLogAtMs = now
this.log(
`[relay-skip] ⚠ 当前通道不健康(state=${h.state} attempts=${h.attempts} ` +
`unhealthyForMs=${h.unhealthyForMs})但**链里无其他候选**(候选 ${urls.length} 条,` +
`排除 ${blocked.size} 条)⇒ 保持原地退避(⛔ 不切到空、不静默回退默认机)`,
)
}
}
/**
* **一跳豁免的"挑人"单点判据**(D1 / D4 / D5)。
*
* ⛔ **只有 D6 现场才允许调用它**(D3)—— 有干净候选时**根本不该走到这里**。
* 这条护栏由单测 **F15** 锁住:豁免一旦泄漏进正常路径,就会变成"每轮巡检都想回跳"(新抖动源)。
*
* 候选池 = `urls`(**链里真正存在**,即已签名目录给出的候选)∩ 冷却中 - 当前 url - 本周期已豁免过的。
* ⚠️ 池子**严格限定在候选链内** ⇒ ⛔ 绝不放宽成"任意 url"(那是 R5:扩大信任面 = 任意重定向)。
*
* 排序(D5):`switched-away` 优先("我们主动离开了一件**曾可用**的东西"),
* 同类按 `untilMs` **升序**(越早解除 ⇒ 越可能已经恢复)。
*/
private pickExemptTarget(
urls: readonly string[],
curUrl: string,
now: number,
): string | undefined {
if (!this.th.exempt) return undefined
const pool: { url: string; entry: RelayCooldownEntry }[] = []
for (const u of urls) {
if (u === curUrl) continue
const entry = this.cooling.get(u)
if (entry === undefined || entry.untilMs <= now) continue
/** D4:本周期已用掉豁免 ⇒ 不再挑它(否则每 2 s 一次 = 重试风暴)。 */
if (entry.exemptedAtMs !== undefined && entry.exemptedAtMs >= entry.sinceMs) continue
pool.push({ url: u, entry })
}
if (pool.length === 0) return undefined
pool.sort((a, b) => {
const ka = a.entry.kind === 'switched-away' ? 0 : 1
const kb = b.entry.kind === 'switched-away' ? 0 : 1
if (ka !== kb) return ka - kb
return a.entry.untilMs - b.entry.untilMs
})
return pool[0].url
}
/** 启动巡检(**幂等**)。周期 = `checkMs`;自链自推,不依赖调用方的定时器。 */
start(): void {
if (this.running) return
this.running = true
const loop = (): void => {
if (!this.running) return
void this.runOnce().finally(() => {
if (!this.running) return
this.timer = this.setTimer(loop, this.th.checkMs)
})
}
this.timer = this.setTimer(loop, this.th.checkMs)
}
/** 停巡检(**幂等**)。⛔ 不关通道 —— 通道归装配点管。 */
stop(): void {
this.running = false
if (this.timer !== undefined) {
this.clearTimer(this.timer)
this.timer = undefined
}
}
/** 单次巡检的重入保护(`tick()` 里会 await 网络 ⇒ 两次巡检可能交叠)。 */
private async runOnce(): Promise<void> {
if (this.ticking) return
this.ticking = true
try {
await this.tick()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.log(`[relay-skip] ⚠ 巡检异常(${msg})⇒ 忽略本轮(下轮继续)`)
} finally {
this.ticking = false
}
}
}
+453
View File
@@ -0,0 +1,453 @@
/**
* relay 传输层 —— **最小 WebSocket 服务端帧(RFC 6455 子集)** + **多路复用帧**。
*
* ## 为什么手写而不是引 `ws`
* 全仓依赖表里没有 `ws`(`package.json` 只有 fastify / better-sqlite3 / pg / rate-limit / static);
* Node 22 内建的 `WebSocket` **只有客户端实现**。为此给整个平台加一个生产依赖,与
* 「relay 只是一个可选单元」(传输方案 §10.2 第 5 点)的定位不符 ⇒ 服务端手写最小帧层。
* **不自造密码学**:握手 SHA-1 与上层 MAC 全用 `node:crypto`,这里只做字节搬运。
*
* ## 支持的子集(边界写死,超出一律按协议错误关闭 —— 不留"说不清的行为")
* | 支持 | 不支持 ⇒ 行为 |
* |---|---|
* | binary / text / close / ping / pong | 其它 opcode ⇒ `1003` |
* | 连续分片重组(上限 `maxMessageBytes`,默认 4 MiB) | RSV 置位(未协商扩展)⇒ `1002` |
* | 7 / 16 / 64 位负载长度 | 单帧超上限 ⇒ `1009` |
* | 客户端必须 mask(RFC 6455 §5.1) | 未 mask ⇒ `1002` |
* | 服务端发出的帧**不 mask**(省一次全量拷贝) | permessage-deflate ⇒ 从不协商 |
*
* ## 多路复用帧(relay 内层,跑在 WS 的 binary 帧里)
* ```text
* 0 1 5 N
* +--------+----------------+----------------------+
* | type | streamId BE32 | payload (N-5 bytes) |
* +--------+----------------+----------------------+
* ```
* 固定 5 字节头(而非 JSON 每包)是**性能**上的选择:小包场景下省掉 JSON 解析与字符串分配。
*
* @module dshs/net/relay/wire
*/
import { createHash } from 'node:crypto'
import { EventEmitter } from 'node:events'
import type { IncomingMessage } from 'node:http'
import type { Socket } from 'node:net'
const EMPTY = Buffer.alloc(0)
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
/* ═══════════════════ 1. mux 帧 ═══════════════════ */
/** relay 内层帧类型。**新增类型必须同时改 server 与 client 的 switch,未知类型一律关连接**。 */
export const MUX = {
/** client → server:带 MAC 的注册请求(**未注册前只允许发这一种**)。 */
HELLO: 0x01,
/** server → client:注册结果(`sessionId` / `accepted` / 心跳间隔)。 */
HELLO_ACK: 0x02,
/** server → client:请求为 `streamId` 打开一条到 client 本地 `port` 的连接。 */
OPEN: 0x03,
/** client → server:`OPEN` 的结果(`ok` / `error`)—— **没有它就不算就绪**。 */
OPEN_ACK: 0x04,
/** 双向:某个流的原始字节。 */
DATA: 0x05,
/** 双向:关闭某个流(`reason`)。 */
CLOSE: 0x06,
/** 双向:应用层心跳(内建 WebSocket 不暴露 WS ping,故放在 mux 层)。 */
PING: 0x07,
/** 双向:心跳回应。 */
PONG: 0x08,
/**
* server → client:**注册被拒的结构化原因**(`reason` / `detail` / `serverTime` / `fatal`)。
*
* 为什么要单独一帧而不是只用 WS close reason:client 必须能区分
* **「重试就能好」**(`clock-skew` ⇒ 用 `serverTime` 校正后立刻重试)与
* **「重试一万次也没用」**(`unknown-host` / `bad-mac` ⇒ 密钥配错了,要立刻报障而不是刷日志)。
* close reason 只有 123 字节且拿不到结构化字段,不够用。**先发此帧,再关连接。**
*/
HELLO_ERR: 0x09,
/**
* 双向:**优雅告别**(`reason`)。
* 发出方承诺「我按计划下线」⇒ 收到方**立刻回收**(清 endpoints / 标离线),
* 而不是干等心跳超时(默认 45s)。把「计划内重启」的恢复时间从 45s 压到毫秒级。
*/
BYE: 0x0a,
/**
* client → server:**运行期加一个可被中继的端口**(`{reqId, port}`)—— 覆盖网络 R4。
*
* 为什么必须有它:实例端口是**实例起来时才**由 `findFreePort()` 分配的,而 `HELLO` 的端口表
* 在注册那一刻就定死了。没有这一帧,worker 只能"开局声明一整段",relay 就要为**那一段的每一个
* 口**开一条回环监听(1000 端口段 = 1000 个 fd,实测每台机只跑 1–2 个实例)——纯浪费,
* 而且让"声明即暴露"的安全姿态白白变差。对应 ssh 版的 `ssh -O forward`。
*/
PORT_ADD: 0x0b,
/** client → server:**撤销**一个端口(`{reqId, port}`)—— 实例停了就该把口收掉,不留悬挂监听。 */
PORT_DEL: 0x0c,
/**
* server → client:上面两者的结果(`{reqId, port, ok, localPort?, error?}`)。
*
* `localPort` = relay 为这个端口开在**它自己回环**上的口号(`0` = 尚未绑定完成);
* 用 `reqId` 关联请求(增删可能并发,靠端口号关联会串)。**没有它就等于 ssh 版"静默失败"**。
*/
PORT_ACK: 0x0d,
/**
* client → server:**拨号方**请 relay 给自己开一条到 `(target, port)` 的双向流(覆盖网络 R5)。
*
* 为什么需要这一帧:`PORT_ADD` 那条路(relay 开回环监听 → Manager 连回环)把**落点钉在
* relay 主机的 `127.0.0.1`** 上 ⇒ Manager 必须与 relay **同机**,`会合可换机` 就断在这里。
* 有了它,Manager 也能像 worker 一样**只拨出**一条 wss,并在自己的会话里请求开流 ⇒
* **relay 放哪台机器都行**,且 Manager 侧不再需要 `/status` 或任何回环落点。
*
* `payload = { target: <hostId>, port: n }`;`streamId` 由**拨号方**自己分配(与 worker 侧
* 的 id 各属一个命名空间,relay 负责配对)。
*/
DIAL: 0x0e,
/** server → client:`DIAL` 的结果(`{ok, error?, localPort?}`)。**没有它就等于静默失败**。 */
DIAL_ACK: 0x0f,
} as const
export type MuxType = (typeof MUX)[keyof typeof MUX]
/** 解出来的一个完整 mux 帧。 */
export interface MuxFrame {
type: number
streamId: number
payload: Buffer
}
/** 拼一个 mux 帧(一次分配,无中间拷贝)。 */
export function encodeMux(type: number, streamId: number, payload: Buffer = EMPTY): Buffer {
const out = Buffer.allocUnsafe(5 + payload.length)
out[0] = type & 0xff
out.writeUInt32BE(streamId >>> 0, 1)
if (payload.length > 0) payload.copy(out, 5)
return out
}
/** 解析一个 mux 帧;长度 < 5 ⇒ `null`(**调用方必须当成协议错误**,不许当空帧吞掉)。 */
export function decodeMux(frame: Buffer): MuxFrame | null {
if (frame.length < 5) return null
return { type: frame[0], streamId: frame.readUInt32BE(1), payload: frame.subarray(5) }
}
/** mux 帧 + JSON 负载(控制帧用;数据帧一律走 `encodeMux`,不做 JSON)。 */
export function encodeJsonFrame(type: number, streamId: number, obj: unknown): Buffer {
return encodeMux(type, streamId, Buffer.from(JSON.stringify(obj), 'utf8'))
}
/** 解析控制帧负载;**非对象或坏 JSON ⇒ `null`**(不抛,由调用方按协议错误处理)。 */
export function parseJsonPayload(payload: Buffer): Record<string, unknown> | null {
try {
const v: unknown = JSON.parse(payload.toString('utf8'))
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
} catch {
return null
}
}
/* ═══════════════════ 2. WebSocket 服务端(最小子集) ═══════════════════ */
/** RFC 6455 关闭码(只列本模块会用到的)。 */
export const WS_CLOSE = {
NORMAL: 1000,
GOING_AWAY: 1001,
PROTOCOL_ERROR: 1002,
UNSUPPORTED_DATA: 1003,
POLICY_VIOLATION: 1008,
TOO_BIG: 1009,
INTERNAL_ERROR: 1011,
/** 1013 = Try Again Later(RFC 6455):**"现在没位子,过会儿再来"** —— 容量准入用它,语义比 1008 准。 */
TRY_AGAIN_LATER: 1013,
} as const
export interface WsServerOptions {
/** 单帧上限(字节),超过 ⇒ `1009` 关闭。默认 1 MiB。 */
maxFrameBytes?: number
/** 分片重组后的消息上限。默认 4 MiB。 */
maxMessageBytes?: number
}
/**
* 完成握手并把 `req` 对应的 socket 提升为 WebSocket 连接。
*
* 失败(缺 `Upgrade` / `Sec-WebSocket-Key` / 版本非 13)⇒ 回 `400` 并 destroy,返回 `null`。
* ⚠️ 握手**不做任何认证** —— 认证在 mux `HELLO`(见 `server.ts`),因此上层**必须**设
* 「未认证超时」,否则任何能连到回环口的进程都能白占一个 socket。
*/
export function acceptWebSocket(
req: IncomingMessage,
socket: Socket,
head: Buffer,
opts: WsServerOptions = {},
): WsConnection | null {
const key = req.headers['sec-websocket-key']
const upgrade = String(req.headers.upgrade ?? '').toLowerCase()
const version = String(req.headers['sec-websocket-version'] ?? '')
if (upgrade !== 'websocket' || typeof key !== 'string' || key === '' || version !== '13') {
socket.write('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
socket.destroy()
return null
}
const accept = createHash('sha1').update(key + WS_GUID).digest('base64')
socket.write(
'HTTP/1.1 101 Switching Protocols\r\n' +
'Upgrade: websocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
)
// 性能:relay 是「小包、低延迟」形态,Nagle 会平白加一个 RTT 的排队延迟。
socket.setNoDelay(true)
const conn = new WsConnection(socket, opts)
if (head.length > 0) conn.feed(head)
return conn
}
/** 服务端侧的 WebSocket 连接。事件:`message(buf, isBinary)` / `drain` / `close` / `error` / `protocolError(why)`。 */
export class WsConnection extends EventEmitter {
private readonly sock: Socket
private readonly maxFrame: number
private readonly maxMessage: number
private stash: Buffer = EMPTY
private fragOpcode = 0
private fragParts: Buffer[] = []
private fragBytes = 0
private dead = false
private closeSent = false
private closeTimer: NodeJS.Timeout | undefined
private code = 1006
private reason = ''
constructor(socket: Socket, opts: WsServerOptions = {}) {
super()
this.sock = socket
this.maxFrame = opts.maxFrameBytes ?? 1024 * 1024
this.maxMessage = opts.maxMessageBytes ?? 4 * 1024 * 1024
socket.on('data', (chunk: Buffer) => this.feed(chunk))
socket.on('drain', () => this.emit('drain'))
socket.on('error', (err: Error) => {
this.teardown()
this.emit('error', err)
})
socket.on('close', () => {
this.teardown()
this.emit('close', this.code, this.reason)
})
}
get isClosed(): boolean {
return this.dead || this.closeSent
}
get remote(): string {
return `${this.sock.remoteAddress ?? '?'}:${this.sock.remotePort ?? 0}`
}
/** 出向待发字节(背压判据 —— 上层据此暂停读取源,**不得当成"失败"**)。 */
get bufferedAmount(): number {
return this.sock.writableLength
}
feed(chunk: Buffer): void {
if (this.dead) return
if (chunk.length > 0) this.stash = this.stash.length === 0 ? chunk : Buffer.concat([this.stash, chunk])
for (;;) {
const frame = this.parseFrame()
if (frame === null || this.dead || this.closeSent) return
if (!this.handleFrame(frame)) return
}
}
/** 发一个 binary 消息。返回 `false` = 触发背压(**已入 socket 缓冲,不是失败**)。 */
sendBinary(payload: Buffer): boolean {
return this.writeFrame(0x2, payload)
}
/** 发一个 text 消息(只在诊断时用;数据面一律 binary)。 */
sendText(text: string): boolean {
return this.writeFrame(0x1, Buffer.from(text, 'utf8'))
}
close(code: number = WS_CLOSE.NORMAL, reason = ''): void {
if (this.closeSent || this.dead) return
this.closeSent = true
this.code = code
this.reason = reason
const r = Buffer.from(reason, 'utf8').subarray(0, 123)
const payload = Buffer.allocUnsafe(2 + r.length)
payload.writeUInt16BE(code, 0)
r.copy(payload, 2)
this.writeFrame(0x8, payload)
// 对端不回 close 也要收尸,否则 fd 泄漏。
this.closeTimer = setTimeout(() => this.teardown(), 3000)
this.closeTimer.unref()
}
/**
* **强制**收尾(不等对端回 close 帧)。
*
* 停机路径专用:实测(传输方案 §12 T8)等对端回帧会把 `stop()` 从 ~300ms 拖到 **1.8s+**,
* 而这 1.8s 正好被对端记成"服务中断"。**停机时长本身就是恢复时间的一部分** ⇒
* 这里宁可主动销毁,也不"礼貌地等"。
*/
destroy(): void {
this.teardown()
}
/* ── 内部 ── */
private writeFrame(opcode: number, payload: Buffer): boolean {
if (this.dead) return false
const n = payload.length
let head: Buffer
if (n < 126) {
head = Buffer.allocUnsafe(2)
head[1] = n
} else if (n < 65536) {
head = Buffer.allocUnsafe(4)
head[1] = 126
head.writeUInt16BE(n, 2)
} else {
head = Buffer.allocUnsafe(10)
head[1] = 127
head.writeBigUInt64BE(BigInt(n), 2)
}
head[0] = 0x80 | opcode // FIN=1,服务端不 mask ⇒ 不拷负载
if (n < 4096) return this.sock.write(Buffer.concat([head, payload], head.length + n))
// 大包不 concat(避免一次全量拷贝),两次 write 由内核合并。
const a = this.sock.write(head)
const b = this.sock.write(payload)
return a && b
}
private parseFrame(): { fin: boolean; opcode: number; payload: Buffer } | null {
const b = this.stash
if (b.length < 2) return null
const fin = (b[0] & 0x80) !== 0
const rsv = b[0] & 0x70
const opcode = b[0] & 0x0f
const masked = (b[1] & 0x80) !== 0
let len = b[1] & 0x7f
let off = 2
if (len === 126) {
if (b.length < 4) return null
len = b.readUInt16BE(2)
off = 4
} else if (len === 127) {
if (b.length < 10) return null
const big = b.readBigUInt64BE(2)
if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
this.fail(WS_CLOSE.TOO_BIG, 'frame length overflow')
return null
}
len = Number(big)
off = 10
}
if (rsv !== 0) {
this.fail(WS_CLOSE.PROTOCOL_ERROR, 'rsv set without extension')
return null
}
if (len > this.maxFrame) {
this.fail(WS_CLOSE.TOO_BIG, `frame ${len} > ${this.maxFrame}`)
return null
}
if (!masked) {
this.fail(WS_CLOSE.PROTOCOL_ERROR, 'client frame must be masked')
return null
}
if (b.length < off + 4 + len) return null
const mask = b.subarray(off, off + 4)
const raw = b.subarray(off + 4, off + 4 + len)
const payload = Buffer.allocUnsafe(len)
for (let i = 0; i < len; i++) payload[i] = raw[i] ^ mask[i & 3]
this.stash = b.subarray(off + 4 + len)
return { fin, opcode, payload }
}
private handleFrame(f: { fin: boolean; opcode: number; payload: Buffer }): boolean {
if (f.opcode === 0x8) {
this.readClose(f.payload)
return false
}
if (f.opcode === 0x9) {
this.writeFrame(0xa, f.payload)
return true
}
if (f.opcode === 0xa) {
this.emit('pong')
return true
}
if (f.opcode === 0x0) {
if (this.fragOpcode === 0) {
this.fail(WS_CLOSE.PROTOCOL_ERROR, 'continuation without start')
return false
}
this.fragBytes += f.payload.length
if (this.fragBytes > this.maxMessage) {
this.fail(WS_CLOSE.TOO_BIG, 'fragmented message too large')
return false
}
this.fragParts.push(f.payload)
if (f.fin) {
const opcode = this.fragOpcode
const message = Buffer.concat(this.fragParts, this.fragBytes)
this.resetFrag()
this.emit('message', message, opcode === 0x2)
}
return true
}
if (f.opcode !== 0x1 && f.opcode !== 0x2) {
this.fail(WS_CLOSE.UNSUPPORTED_DATA, `opcode ${f.opcode}`)
return false
}
if (this.fragOpcode !== 0) {
this.fail(WS_CLOSE.PROTOCOL_ERROR, 'interleaved data frame')
return false
}
if (f.fin) {
this.emit('message', f.payload, f.opcode === 0x2)
return true
}
this.fragOpcode = f.opcode
this.fragParts = [f.payload]
this.fragBytes = f.payload.length
return true
}
private readClose(payload: Buffer): void {
if (payload.length >= 2) {
this.code = payload.readUInt16BE(0)
this.reason = payload.subarray(2).toString('utf8')
} else {
this.code = WS_CLOSE.NORMAL
}
if (!this.closeSent) {
this.closeSent = true
this.writeFrame(0x8, payload.subarray(0, Math.min(payload.length, 125)))
}
this.teardown()
}
private resetFrag(): void {
this.fragOpcode = 0
this.fragParts = []
this.fragBytes = 0
}
private fail(code: number, why: string): void {
this.emit('protocolError', why)
this.close(code, why)
// 协议已错 ⇒ 不等对端回应,立刻收尸。
const t = setTimeout(() => this.teardown(), 100)
t.unref()
}
private teardown(): void {
if (this.dead) return
this.dead = true
if (this.closeTimer !== undefined) clearTimeout(this.closeTimer)
this.stash = EMPTY
this.resetFrag()
this.sock.destroy()
}
}
+23 -9
View File
@@ -21,6 +21,7 @@
*/
import { VIA_LOCAL, VIA_MANAGER_SSH, type Reachability } from './reachability.js'
import { parseLogicalName } from './relay/network.js'
export interface Rendezvous {
/** 实现 id —— `Reachability.via` 指向它。 */
@@ -29,14 +30,23 @@ export interface Rendezvous {
dialTarget(): string
/**
* 解析某台 worker 的可达性。
*
* ⚠️ **入参是逻辑名 `<network_id>/<hostId>`**(P0-3)—— 不是裸 `hostId`。
* 地址是**网内**语义:两张网各有一台 `w-1` 时,只给 `hostId` 的解析**必然有歧义**,
* 而歧义会以"打到另一张网的同名节点"这种最贵的形态暴露。裸 `hostId` 仍兼容(⇒ 落 `ops`),
* 但**调用方不许自己拼网络段** —— 名字由 `logicalName()` 产出。
*
* ⚠️ **本实现管不到 ⇒ 回 `undefined`,不抛** —— 由调用方决定回退哪种实现
* (抛错会让"多实现并存"的过渡期没法跑)。
*/
resolve(hostId: string): Promise<Reachability | undefined>
resolve(name: string): Promise<Reachability | undefined>
}
/** 由调用方提供"hostId → `host:port`"的查表函数(会合实现不直接连 DB)。 */
export type AddressLookup = (hostId: string) => string | undefined
/**
* 由调用方提供"**逻辑名** → `host:port`"的查表函数(会合实现不直接连 DB)。
* 键是逻辑名(P0-3):查表这一层也必须带网络维度,否则同名 host 两张网互相覆盖。
*/
export type AddressLookup = (name: string) => string | undefined
/** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`w-47` 就是这一类)。 */
export class LocalRendezvous implements Rendezvous {
@@ -48,9 +58,11 @@ export class LocalRendezvous implements Rendezvous {
return '(direct)'
}
async resolve(hostId: string): Promise<Reachability | undefined> {
const address = this.addressOf(hostId)
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
async resolve(name: string): Promise<Reachability | undefined> {
const address = this.addressOf(name)
if (address === undefined) return undefined
const { network, hostId } = parseLogicalName(name)
return { hostId, networkId: network, via: this.id, address, scheme: 'http' }
}
}
@@ -79,9 +91,11 @@ export class ManagerSshRendezvous implements Rendezvous {
return this.opts.target
}
async resolve(hostId: string): Promise<Reachability | undefined> {
const address = this.opts.addressOf(hostId)
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
async resolve(name: string): Promise<Reachability | undefined> {
const address = this.opts.addressOf(name)
if (address === undefined) return undefined
const { network, hostId } = parseLogicalName(name)
return { hostId, networkId: network, via: this.id, address, scheme: 'http' }
}
}