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

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

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

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

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

2253 lines
109 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* relay 服务端 —— **worker 拨它、Manager 经它到 worker**(覆盖网络 R1)。
*
* ## 拓扑(与 SSH 版**语义等价**,所以 Manager 侧零改动)
* ```text
* worker 侧 RelayClient ──(唯一一条出向 wss)──▶ 本服务(绑 127.0.0.1)
* │ 为每个注册端口开一条回环监听
* ▼
* Manager ──▶ 127.0.0.1:<动态分配端口> ──▶ 多路复用进那条 wss ──▶ worker 本地 127.0.0.1:<port>
* ```
* `Reachability.address` 仍是 `host:port` ⇒ `agentBaseUrlOf()` 拼出来的基址与 SSH 版
* **同形**,调用方不需要知道底下换成了 relay。这是"可替换实现"能成立的前提。
*
* ## 安全姿态(默认拒绝,逐条对应方案 §10.1 的判据)
* | 判据 | 做法 |
* |---|---|
* | ① 认证模型 | **每 worker 一密钥**(非共享 token);`HMAC-SHA256(secret, hostId\|ts\|nonce\|portsCsv)`;`timingSafeEqual` 比较;`ts` 窗口 ±60s;nonce 防重放(有界 LRU) |
* | ② 默认姿态 | 未认证连接 **只允许发 `HELLO`**,5s 未认证即关;未知类型直接关连接 |
* | ③ 零新增入站 | 默认绑 `127.0.0.1`;**只开回环监听**(`localPort` 动态分配,避开既有端口区间) |
* | ④ 爆炸半径 | worker 声明的端口必须落在**实例端口区间**内;每端口并发流上限;单流缓冲上限,超限**断流**而非静默丢弃 |
* | ⑤ 可观测 | `status()`:认证成败计数 / 拒绝计数 / 掉流计数 / 每 host 心跳龄 + 每 endpoint 的 `localPort` 与在线态 |
* | ⑥ **网维度**(P0-1) | 每条会话属于**一张网**(`HELLO.network`,缺省 `ops`);会话与端点表按**逻辑名 `<network>/<hostId>`** 索引;`DIAL` 必须**同网**且在**本网白名单**里 ⇒ 跨网连"能不能拨"都走不到(结构性隔离,不是策略性) |
*
* ## ⚠️ 为什么 `network` **不进 MAC**
* `HELLO` 的 MAC 输入刻意保持 `${hostId}|${ts}|${nonce}|${portsCsv}` **一字不改**:现网 47 / 106
* 上跑的是旧客户端,改 MAC 公式 = 硬断(必须两端同时升级)。而网络维度的**真判据在服务端**
* (白名单按网络分桶 + 同网校验),`network` 只是"我属于哪张网"的声明 —— 声明错了也不会多拿到
* 任何东西:想拨 `ops/<host-b>` 就得有一个**在 `ops` 桶里的 hostId 密钥**。⇒ 安全性不依赖这个字段,
* 而兼容性(旧客户端不声明 `network` ⇒ 按 `ops` 处理)正好是**存量全部落在运维网**的现网事实。
*
* ## 稳定性(本轮重点)
* - **精确失败**:`HELLO` 必须被 `HELLO_ACK` 确认、`OPEN` 必须被 `OPEN_ACK` 确认;任何一步没回音都**断开并记数**(SSH 版的病根正是 `-R` 撞号无人检查返回值)。
* - **不丢字节**:`OPEN` 到 `OPEN_ACK` 之间 Manager 侧数据被 TCP 层 `pause()` 挡住(`pauseOnConnect`),`OPEN_ACK` 后再 `resume()` ⇒ 没有"打开竞态窗口丢头几个字节"。
* - **有界缓冲**:任一队列超 `queueMaxBytes` 就断**那一条流**,不影响同连接其它流,也不让内存无上限增长。
* - **心跳**:client 每 `hbSec` 发 `PING`;服务端 45s 内没收到任何帧 ⇒ 判死链并回收该 host 全部流。
*
* @module dshs/net/relay/server
*/
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
import { createServer, type IncomingMessage, type Server as HttpServer } from 'node:http'
import { createServer as createTcpServer, type Server as TcpServer, type Socket } from 'node:net'
import type { Duplex } from 'node:stream'
import { MUX, WsConnection, WS_CLOSE, acceptWebSocket, decodeMux, encodeJsonFrame, encodeMux, parseJsonPayload, type MuxFrame } from './wire.js'
import { OPS_NETWORK, NAME_SEP, describeDialers, isNetworkId, logicalName, normalizeDialers, parseLogicalName } from './network.js'
import { lookupKey, type RelayKeyEntry } from './keys.js'
import { normalizePublicKey, verifyPeerGrant, verifyProof, type RevocationList } from './identity.js'
/**
* 序㉖:**relay 侧的抖动观测**用与平台侧**同一份**统计(`absDeltas` / `statsFromDeltas`)
* ⇒ "实时选路看到的 p95"与"relay `/status` 报出的 p95"不可能出现两个数(口径分叉 = 假红/假绿)。
*/
import { absDeltas, statsFromDeltas, jitterThresholds, type JitterStats } from './jitter.js'
/** WebSocket 升级路径 —— 与 nginx `location`(R2)逐字对应,改名要两边同改。 */
export const RELAY_PATH = '/dshs-relay'
/** 默认回环端口(**只回环**,不是公网口)。 */
export const DEFAULT_RELAY_PORT = 20080
/** 默认握手超时:连接建立后多久没收到合法 `HELLO` 就断。 */
export const DEFAULT_AUTH_DEADLINE_MS = 5_000
/** `HELLO` 时间戳容忍窗口。 */
export const DEFAULT_AUTH_WINDOW_MS = 60_000
/** 死链判定:多久没收到任何帧。 */
export const DEFAULT_IDLE_TIMEOUT_MS = 45_000
/** 心跳下发间隔。 */
export const DEFAULT_HB_SEC = 15
/** 满载(`at-capacity`)时给对端的**排队建议时长**(ms)。 */
export const DEFAULT_CAPACITY_RETRY_AFTER_MS = 5_000
/**
* **序㉖:利用率软门默认值(%)** —— 到它就拒新接入,留 30%+ 余量(用户口径「连接稳定高效」)。
*
* ⚠️ 它**不是**容量值:`RELAY_MAX_HOSTS`(7515 / 45% 设计口径)一字不动,软门只是提前拦。
* 值可从参数表键 `RELAY_UTIL_MAX_PCT` 覆盖(见 {@link RelayServerOptions.utilMaxPct})。
*/
export const DEFAULT_UTIL_MAX_PCT = 70
/**
* 从 env 读一个**纯数字**整数(非纯数字 / 空 ⇒ 默认值)。
*
* ⚠️ 与 `relayFailoverThresholds` / `jitterThresholds` 同款纪律:参数表值格必须是纯数字
* (写 `70`,⛔ 不许写 `70(70% 余量)` —— 后者解析失败会**静默回退默认值**)。
*/
function envNum(key: string, dflt: number): number {
const raw = (process.env[key] ?? '').trim()
if (raw === '') return dflt
return /^\d+$/.test(raw) ? Number(raw) : dflt
}
/**
* 优雅停机的**通知窗口**(ms):发出 `BYE` + `close 1001` 后等这么久,再强制收尾。
* 这个值就是"停机耗时"的上界 —— 而停机耗时 = 对端的恢复时间。
*/
export const DEFAULT_SHUTDOWN_GRACE_MS = 300
/** 同一 (host, port) 的并发流上限。 */
export const DEFAULT_MAX_STREAMS_PER_PORT = 64
/** 单流缓冲上限(入向 open 竞态窗口 + 出向背压队列共用)。 */
export const DEFAULT_QUEUE_MAX_BYTES = 1 << 20
/** 拨号流 peer 的出向水位(WS 缓冲到这个量就请上层转排队)。 */
export const WS_PEER_HIGH_WATER = 256 * 1024
/** 每 host 保留的 nonce 数(防重放的有界窗口)。 */
const NONCE_KEEP = 256
/* ═══════════ presence(在线态)默认值 —— 全部来自 `瓶颈落地方案 §1` ═══════════ */
/**
* 离线 **grace**(ms):断连后先不改口,仍报在线。
*
* 依据 = `覆盖网络_瓶颈落地方案 §1` 第 5 条「**grace period 5–15 s + 离线 debounce 30 s**,
* 防止'网络抖一下 = 状态闪烁'」。取区间上沿 **10 s**:presence 允许 5–15 s 陈旧(第 7 条),
* 而"少报一次离线"比"多报一次离线"代价小(后者会让上层把健康节点当死的)。
*/
export const DEFAULT_PRESENCE_GRACE_MS = 10_000
/**
* 离线 **debounce**(ms):与 grace 相加才是真正的"转为离线"时刻(默认 10 + 30 = 40 s)。
*
* 为什么与 grace 分开算而不是合成一个 40 s:两者语义不同 —— grace 是"**抖动容忍**"
* (抖动窗口内的重连**不该产生任何事件**),debounce 是"**事件合并**"
* (窗口内多次上下线只留最后一次)。合成一个数会让"≤ grace 仍在线"这条判据无处可测。
*/
export const DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS = 30_000
/**
* **批合并窗口**(ms)—— 每 s 一次 pipeline 提交。
*
* 依据 = 第 2 条原文「每个网关把本地心跳攒起来,**每 1 s 一次 pipeline 提交**」+ 第 4 条
* 「一条消息里带 **user 数组**;最多每几秒推一批」。
* 🔴 **为什么不取"零延迟逐次推"**:presence 允许 5–15 s 陈旧(第 7 条)⇒ 1 s 窗口完全够用,
* 而逐次推会在抖动时制造**帧爆炸**(正是本改造要消灭的现象)。
*/
export const DEFAULT_PRESENCE_BATCH_MS = 1_000
/**
* **TTL 安全网**相对 `HB_SEC` 的倍数(默认 ×3 ⇒ 15 s × 3 = **45 s**)。
*
* 依据 = 第 1 条原文「**存储带 TTL 只作安全网**(防网关崩溃漏事件)」⇒ 它是"**漏事件**"的兜底,
* ⛔ 不是主判据(主判据永远是连接生命周期)。取 ×3 而非 ×1:本机实测心跳往返可达 336 ms 量级,
* ×1 会在一次网络抖动里就把在线节点判死(假红)。
* ⚠️ **它只兜底"没有活连接"的条目**;有活连接的条目由会话心跳(`idleTimeoutMs`)负责。
*/
export const DEFAULT_PRESENCE_TTL_FACTOR = 3
/**
* 单条会话可订阅的 host 数上限(`0` = 不限;默认 0)。
*
* ⛔ **本键不是节流开关**:真正的节流是"1 s 批合并 + 只推变化"(第 2/4 条)。
* 它只是**有界性**保证(防一条会话声明一个无限大的订阅集把服务端内存吃光)。
*/
export const DEFAULT_PRESENCE_SUB_MAX = 0
export interface RelayServerOptions {
/** 监听地址。**默认且建议保持 `127.0.0.1`** —— 本机无防火墙(实测 `nft INPUT policy accept`),绑 `0.0.0.0` 会立刻公网可达。 */
host?: string
port?: number
/** hostId → 预共享密钥(**hex**,每 worker 一个)。空 map ⇒ 所有 `HELLO` 被拒(默认拒绝)。 */
keys: ReadonlyMap<string, string | RelayKeyEntry>
/** 实例端口区间起点(worker 声明的端口必须 ≥ 它)。 */
instancePortBase: number
/** 实例端口区间宽度。 */
instancePortSpan: number
maxStreamsPerPort?: number
queueMaxBytes?: number
authDeadlineMs?: number
authWindowMs?: number
idleTimeoutMs?: number
hbSec?: number
/**
* 允许同时在线的主机数上限。`0`(默认)= 不限。
*
* **满载是唯一的硬门**:满了就拒绝新节点加入(回 `at-capacity` + `retryAfterMs`),
* 对端据此**排队等待**。已在册的 hostId 重连**永远优先**(它占的位子本来就是它的)。
*/
maxHosts?: number
/** 满载时建议对端多久后再来(ms)。默认 5000。 */
capacityRetryAfterMs?: number
/**
* **序㉖:利用率软门(%)** —— `used / max × 100 ≥ 它` ⇒ 拒绝新接入(⛔ 不打满)。
*
* `0` = 关闭(行为逐字回到改造前)。缺省 ⇒ env `RELAY_UTIL_MAX_PCT` ⇒ 70(见字段注释)。
* ⚠️ **它不改任何既有生产值**:`RELAY_MAX_HOSTS` 仍是 7515,软门只是**提前**拦。
*/
utilMaxPct?: number
/** 优雅停机的通知窗口(ms),默认 300 —— 见 `DEFAULT_SHUTDOWN_GRACE_MS`。 */
shutdownGraceMs?: number
/**
* **允许发起 `DIAL` 的白名单**(覆盖网络 R5 / P0-1),按**网络分桶**:
* `Map<networkId, Set<hostId>>`。没列到的网络 ⇒ 该网络**一个都拨不动**(默认拒绝)。
*
* 也接受 R5 时代的**扁平 `Set<hostId>`**(等价于"这些都在 `ops` 网里")⇒ 旧配置照旧可用,
* 过渡期不必改任何 drop-in(见 `network.ts#normalizeDialers`)。
*
* 为什么用「服务端白名单 + 每主机独立密钥」而不是一个共享的拨号 token:
* - 密钥表本就是**每 worker 一个**(`keys.ts`:爆炸半径 = 那一台)⇒ worker A 无法冒充
* `manager` 去认证;再叠一层白名单,**即使**某台 worker 的密钥泄露也**拨不动**别人的实例。
* - 这条门是**收窄**而不是扩大:它只把「Manager 本来就能做的跨机代理」从"必须同机"变成
* "可以异地",不新增任何主体、不新增任何监听口。
*
* P0-1 加的**第二道门**不是白名单,而是"**同网**":不同网络的节点之间,连"能不能拨"这一步
* 都走不到。
* ⚠️ P0-3 起**对外不再有专属拒绝码**:跨网一律回 `target-offline`(与"本网无此节点"逐字同形),
* 区分只留在服务端日志里 —— 否则"它在别张网"这句话本身就是**可探测的对端清单**(D4)。
*/
dialers?: ReadonlySet<string> | ReadonlyMap<string, ReadonlySet<string>>
/**
* **受信签名者公钥**(覆盖网络 序③)—— 用来验收 `HELLO` 里那条**节点入网凭据**的签名。
*
* ⚠️ **relay 侧这道门是"辅助"**(D2):身份的真正判据在**节点自己**那里(`identity.ts` 的
* `verifyPeerGrant`)。relay 也验一遍,是为了让"未授权节点"**在英国人就近被挡住**,
* 而不是等它把端点注册进来、再由 Manager 去拒 —— 但**不能**把它当成唯一防线:
* 控制面(这台 relay)被攻破时,唯一还站得住的正是节点本地的那一道。
*
* 空 ⇒ **不启用身份校验**(存量形态:只认 HMAC)。是否**强制**由 `requireIdentity` 决定。
*/
trustedSignerKeys?: readonly string[]
/** 有效的吊销清单(已验签)。给 ⇒ 被撤的 hostId / 节点公钥一律拒。 */
revocations?: RevocationList
/**
* **强制**要求节点入网凭据(缺 / 无效 ⇒ 拒)。
*
* 缺省 `false`:现网 47 / 106 上跑的是**还没有身份层**的客户端,一律强制会**当场全断**。
* ⇒ 采用与 `dsh_hosts.network_id` 同一条纪律:「**先加能力 → 再改代码 → 最后才开强制**」,
* 每一步中断都不崩,且**任一步都能单独回滚**。
*/
requireIdentity?: boolean
/**
* 是否**参与校验**(不必强制)节点凭据:`true`(默认)⇒ 客户端**带了**凭据就得验,
* 验不过照样拒("带了但无效"绝不放行);`false` ⇒ 完全忽略凭据字段(仅调试用)。
*/
verifyIdentity?: boolean
/**
* 是否为注册端口在 relay **本机**开回环监听(默认 `true`,即 R1–R4 的行为)。
*
* 置 `false` ⇒ relay 退化成**纯流转发**:不为任何端口绑本地口(本机暴露面 = 0,`/status`
* 也不再给出 `localPort`)⇒ 「relay 与 Manager 是否同机」**彻底无关**。
* 这就是「会合可换机」的收口开关,也是它的端到端验收手段(`test/relay.test.mjs` T19)。
*/
exposeLoopback?: boolean
/* ── presence(在线态,覆盖网络「只做三件事」之一)—— 默认值见上方 DEFAULT_PRESENCE_* ── */
/** 离线 grace(ms),默认 {@link DEFAULT_PRESENCE_GRACE_MS}。 */
presenceGraceMs?: number
/** 离线 debounce(ms),默认 {@link DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS}。 */
presenceOfflineDebounceMs?: number
/** 批合并窗口(ms),默认 {@link DEFAULT_PRESENCE_BATCH_MS}。 */
presenceBatchMs?: number
/** TTL 安全网(ms);省略 ⇒ `hbSec × DEFAULT_PRESENCE_TTL_FACTOR`。 */
presenceTtlMs?: number
/** 单会话订阅上限(`0` = 不限,默认)。 */
presenceSubMax?: number
/**
* **内容分发判别器的注入位**(序㉔ · 可选)。
*
* relay 是独立进程、内容面的装配点在平台侧 ⇒ relay 内核**不认识** content 的任何类型;
* 谁装配谁注入一个"取快照"的纯函数即可(见 `RelayStatus.content`)。
* ⛔ 不给(缺省)⇒ `/status` 不含 `content` 字段 ⇒ 既有消费方零影响。
*
* ⚠️ 必须是**纯读**:它会在每次 `/status` 时被调用(`/status` 是观测面,⛔ 不得有副作用)。
*/
statusContent?: () => Record<string, unknown>
log?: (line: string) => void
}
/**
* relay 一侧的「流对端」。
*
* 有两条完全不同的来路,但对数据面**必须同形**:
* ① **注册端口**(R1–R4):relay 主机上的一个 TCP socket(`net.Socket` 天然满足本接口);
* ② **拨号**(R5):另一条 mux 会话(`WsStreamPeer` 把帧当字节流用)。
*
* 两者都只需要这 6 个方法 ⇒ 用结构化接口而不是 `Socket`,`onData` / `flushStream` / `closeStream`
* **一行都不用分叉**。
*/
export interface StreamPeer {
write(chunk: Buffer): boolean
once(event: 'drain', cb: () => void): void
pause(): void
resume(): void
end(): void
destroy(): void
}
/** 拨号流的回程信息(挂在 worker 侧那条 `MuxStream` 上)。 */
interface DialInfo {
/** 拨号方会话 —— 回程 `DATA` / `CLOSE` 的收件人。 */
session: Session
/** 拨号方那侧的 `streamId`(**与 worker 侧的 id 不是一个命名空间**,必须原样带回)。 */
streamId: number
/** worker 尚未 `OPEN_ACK` ⇒ 拨号方的数据先缓存(等价于 TCP 版的 `pauseOnConnect`)。 */
ready: boolean
pending: Buffer[]
pendingBytes: number
}
interface MuxStream {
id: number
port: number
/** 见 `StreamPeer`。 */
peer: StreamPeer
/** `OPEN` 已发但 `OPEN_ACK` 未回 —— 此刻入向数据只能缓存。 */
opening: boolean
/** 对端写满,暂停向它写(出向背压)。 */
paused: boolean
closed: boolean
queued: Buffer[]
queuedBytes: number
/** 仅拨号流有。 */
dial?: DialInfo
}
interface Session {
id: string
hostId: string
/**
* 本会话属于哪张网(P0-1)—— 注册时从 `HELLO.network` 取,**缺省 `ops`**(旧客户端兼容)。
* 会话表按**逻辑名 `<network>/<hostId>`** 索引 ⇒ 两张网里的同名 hostId **互不干扰**。
*/
network: string
conn: WsConnection
ports: Set<number>
streams: Map<number, MuxStream>
portStreams: Map<number, number>
/**
* 仅**拨号方**会话有:`拨号方 streamId → (worker 会话, worker 侧那条流)`。
*
* 拨号方发来的 `DATA` / `CLOSE` 带的是**它自己的** id,不查这张表就无处可去(worker 会话的
* `streams` 里装的是 relay 分配的 id,两者不是一个命名空间)。
*/
dialRoutes: Map<number, { session: Session; st: MuxStream }>
nextStreamId: number
lastSeen: number
heartbeat: NodeJS.Timeout
bytesIn: number
bytesOut: number
/** 注册时刻(诊断"这条会话活了多久")。 */
since: number
/** 服务端**主动**发 `PING` 的时刻;`PONG` 回来时用它算 RTT。 */
pingSentAt?: number
/** 最近一次心跳 RTT —— 链路质量的**实测**值,也是判断"是不是半开"的旁证。 */
rttMs?: number
/**
* **序㉖:本会话的 RTT 样本环**(每个 `PONG` 记一个,上限 = `JITTER_SAMPLE_MAX`)。
*
* 🔴 为什么按**会话**存而不是存一个全局序列:不同对端的 RTT **基线不同**(47↔106 是跨云、
* 47↔47 是回环)。把两台机器的 RTT 混进一条序列算差分,会把"切换对端"记成一次巨大抖动
* ⇒ 假红。⇒ **差分在会话内算,直方图在会话间合并**({@link RelayServer.jitterStats})。
*/
rttSamples?: number[]
/**
* presence 订阅集(`瓶颈落地方案 §1` 第 3 条:**订阅只活在连接期间**)。
*
* `undefined` = 未订阅(默认,零成本);`'all'` = 本网全部;`Set` = 点名订阅的逻辑名。
* ⛔ 没有"持久订阅"这种东西 —— 连接一断,条目随会话一起消失。
*/
subs?: 'all' | Set<string>
/**
* 待推的在线态变更(**1 s 批合并的缓冲**;第 2/4 条)—— 键 = 逻辑名。
*
* 为什么是 Map 而不是数组:同一 host 在 1 s 内上下线多次时,**只留最后一次**才算"合并"
* (数组会把中间态也推出去 ⇒ 批合并退化成"攒一批"而不是"合并")。
*/
presencePending: Map<string, PresenceEntry>
}
/**
* 在线态的**服务端权威记录**(序⑲)。
*
* 🔑 **粒度 = 连接**(`conns`),对外聚合到 **host**:`conns.size > 0` ⇒ 在线。
* 这样"同 hostId 两条连接断一条"天然仍是在线(第 6 条多设备聚合),而"全断"才进 grace。
*/
interface PresenceState {
name: string
network: string
hostId: string
/**
* **连接 → 最后一次活动时刻**(多设备聚合的载体,也是 TTL 安全网的载体)。
*
* 🔴 为什么记"每个连接各自的时间"而不是一个 `Set<connId>` + 会话表比对:
* 同一 hostId 的第二条连接注册后,**第一条连接仍然是活的**(relay 有意不主动掐它 ——
* 它只是不再在 `sessions` 表里)。若按"id 是否还在会话表里"来判死,那条**活着的**连接
* 会被误摘 ⇒ 第二条再断时就是**假离线**(R11 净退化)。
* 按"这个 id 自己多久没动静"判死则无此问题:活着的连接一直在刷,⛔ 永不被摘。
*/
conns: Map<string, number>
/** 最后一次连接活动(注册 / 收帧)。 */
lastSeenMs: number
/**
* 最后一条连接消失的时刻;`0` = 当前有活连接。
* 真正的"转离线"时刻 = `offlineSinceMs + grace + debounce`。
*/
offlineSinceMs: number
/** 该 host 声明的端口(`HELLO` 的 `ports` + 运行期 `PORT_ADD/DEL`)。 */
ports: Set<number>
/** 对外**已发布**的在线态 —— 与实时判定区分开(前者是已告知谁,后者是事实)。 */
published: boolean
/** 最近一次**已发布状态**变化的时刻。 */
changedAt: number
}
interface Endpoint {
hostId: string
/** 见 `Session.network`:端点也带网维度(表键 = `<network>/<hostId>:<port>`)。 */
network: string
port: number
server?: TcpServer
localPort: number
session?: Session
/**
* `listen()` 落定(或失败)后 resolve —— 只有**运行期动态加端口**那条路要等它:
* `PORT_ADD` 的应答里要回真实口号,而 `listen` 是异步的(注册路径不等,与 R1 行为一致)。
*/
ready?: Promise<void>
}
/**
* 一条**在线态**记录(presence)。
*
* 口径(`瓶颈落地方案 §1`):
* - 第 1 条:**来源是连接生命周期**(`conns` 非空 ⇒ 在线),⛔ 不是轮询推导;
* - 第 6 条:**同 hostId 多连接按 device 聚合**(任一 device 在线 ⇒ 在线)⇒ 所以带上 `devices`;
* - 第 5 条:断连后先过 **grace + debounce** 才改口 ⇒ 所以带上 `offlineInMs`(负值 = 已过窗口)。
*/
export interface PresenceEntry {
/** 逻辑名 `<network>/<hostId>`(与 `/status` 同一键口径)。 */
name: string
hostId: string
network: string
online: boolean
/** 同 hostId 的**在线连接数**(`> 1` = 多设备;聚合口径 = 任一在线即在线)。 */
devices: number
/** 该 host 声明的实例端口(供订阅方区分"离线"与"端口没了")。 */
ports: number[]
/**
* 每个声明端口在 **relay 本机**的回环落点(`localPort`;`0` = 未绑 / 纯流转发模式)。
*
* 🔑 **为什么把它放进 presence**:这正是 `/status` 除在线态之外**唯一**还被读的东西
* (`web/server.ts#addressOf` / `#translateEndpoint` 的第二回退)。不带上它,"订阅生效后
* 停止轮询"就会让那条回退路径拿不到地址 ⇒ **净退化(R11)**。
* ⚠️ 它**不是**让 Manager 重新依赖"relay 同机":拨号通道(R5)仍是首选,这只是兜底值。
*/
localPorts: { port: number; localPort: number }[]
/** 距最后一次连接活动多久(ms)。 */
lastSeenAgoMs: number
/**
* 距**转为离线**还剩多久(ms);**仅"已断连但仍在 grace/debounce 窗口内"时非 `undefined`**。
* 负值 = 窗口已过(下一次 flush 就会推 `online:false`)。⛔ 在线时不填(不要制造无意义的字段)。
*/
offlineInMs?: number
/** 本条目最后一次**状态**变化时刻(帧数对账用:没有变化 ⇒ 不推)。 */
changedAt: number
}
export interface RelayStatus {
listening: string
online: string[]
/**
* 允许发起 `DIAL` 的白名单(R5 / P0-1)。**空数组 = 该能力关闭**(默认)。
* `ops` 网的条目按 R5 的写法**省略网络前缀**(`manager`);其它网写全逻辑名(`u:5/d1`)。
* 逐网络视图见 `networks`。
*/
dialers: string[]
/**
* **网维度视图**(P0-1):每张网各有哪些拨号方、哪些节点在线。
*
* 为什么单列一个字段而不是只把 `network` 塞进 `sessions`:Step 3 的判据是
* 「`u:A` 的节点**看不到**也到不了 `u:B` 的节点」—— "看不到"这一半必须**可断言**,
* 而按网络聚合一眼就能看出"这张网里到底有谁",不必让调用方自己去 group。
*/
networks: { network: string; dialers: string[]; sessions: string[] }[]
/** 容量视图(`max = 0` ⇒ 不限)。`free` 仅在有限容量时有意义。 */
capacity: {
max: number
used: number
free?: number
/**
* **序㉖:当前利用率(%)** = `floor(used × 100 / max)`;`max = 0`(不限)⇒ `0`。
* 判据 = 它 **必须 ≤ `utilMaxPct`**(否则新接入已被拒 ⇒ 说明软门在拦人)。
*/
utilPct: number
/** **序㉖:利用率软门(%)** —— 探针拿它与参数表值对账(⛔ 防"装了但用的是另一套默认值")。 */
utilMaxPct: number
}
/**
* **序㉖:抖动观测块**(`E2` 的 relay 侧落点)。
*
* ⚠️ **没有样本时它也在**(各键为 0 / 空直方图)—— ⛔ 不许"没数据就少一个键":
* 那会让探针分不清「**没装**」与「**装了但还没采到**」,正是本线反复踩的"静默失效"。
* 全部字段 = `jitter.ts` 的 `JitterStats` + 四个本模块字段(见 `jitterStats()`)。
*/
jitter: JitterStats & {
/** 有 ≥2 个 RTT 样本的会话数(= 能算出差分的会话数)。 */
sessions: number
/** 劣化阈值(ms,= 参数表 **`JITTER_LIMIT_MS`**,序⑥ 就有的达标限值)—— 探针据此对账。 */
thresholdMs: number
/** `p95|ΔRTT| ≥ 阈值` ⇒ `true`(判别器;⛔ 不是"有样本就算超标")。 */
overThreshold: boolean
/** 累计告警次数(每次告警都对应一行 `[relay-jitter]`)。 */
alerts: number
}
counters: {
authed: number
authFailed: number
refused: number
/**
* **序㉖:因利用率软门(`RELAY_UTIL_MAX_PCT`)被拒的注册数**。
*
* ⛔ 与 `refused`(硬门 `at-capacity`)分开计:混在一起就分不清"真的装满了"与
* "为保余量提前拦"—— 前者是容量不足(要扩容),后者是**按设计工作**。
*/
utilRefused: number
/** 序③:通过节点凭据校验的注册数。 */
identityOk: number
/** 序③:是否**强制**要求节点凭据。 */
identityRequired: boolean
/** 序③:受信签名者把数(`0` + `identityRequired` ⇒ 谁也进不来,配置不完整)。 */
trustedSigners: number
/** 序③:当前吊销清单里的 hostId 数。 */
revokedHosts: number
dropped: number
streamsOpened: number
protocolErrors: number
backpressurePauses: number
/**
* 序⑤(观测最小集):`DIAL` 的**判别器计数**。
*
* 为什么需要它:443 单 §12 留下的教训原文是「**静默失效靠判别器定位**」,判别器就是
* 「relay 到底有没有 `DIAL`」—— 今天它**只存在于日志行**(`DIAL manager -> <host-b>:21000 ok`),
* 脚本无法断言 ⇒ 观测最小集缺了最关键的一条。
*
* 为什么不复用 `refused`:`refused` 是**所有**拒绝的合计(`HELLO` 越界、端口越界、`DIAL`…),
* 而判别器要回答的是更窄的问题 —— **拨号这条路本身通不通**。三分支互斥且可加和:
* - `dial` —— 拨号被**放行**(已回 `DIAL_ACK{ok:true}`,与 `streamsOpened` 同点自增);
* - `dialDenied` —— **策略拒绝**(不在拨号方白名单 / 服务停机中 / 每端口并发满);
* - `dialFailed` —— **目标不可达或请求非法**(节点离线或端口未声明 / `target`·`port` 非法)。
*
* 读法:`dial > 0` ⇒ 拨号路径通;三者恒 `0` 而业务流量存在 ⇒ 请求**根本没走到 `DIAL`**
* (正是 `RemoteSpawner.translateEndpoint` 静默失效那类故障的指纹)。
*/
dial: number
dialDenied: number
dialFailed: number
/**
* **presence 判别器**(序⑲)—— 口径与 `dial*` 同一条纪律:**不许只写日志**。
*
* - `subs` —— 当前**存活**的订阅数(**gauge**,不是累计);连接断了订阅就没了(第 3 条:
* 订阅只活在连接期间)⇒ 它必须能回到 `0`。"声明了订阅但 `subs` 恒 0" ⇒ 订阅层没生效。
* - `pushed` —— 累计**推出去的 presence 帧数**(含首帧 `SNAP`)。稳态(无状态变化)时
* 它**必须停住不走**(E1 的机器可读判据;⛔ 只靠人看日志是判不出来的)。
* - `snaps` —— 其中 `SNAP` 的帧数(E4「首帧即全量」的机器可读判据):**有订阅者却
* `snaps = 0`** ⇒ 首帧走的是"逐台拉"(N+1)那条老路。恒有 `snaps ≤ pushed`。
* - `rejected` —— 被**显式拒绝**的订阅请求数(跨网 / 越界)。⛔ 与"静默返空"互斥:
* 凡是拒绝都必须在这里 +1,否则就是本线反复踩的**假绿**。
*/
subs: number
pushed: number
snaps: number
rejected: number
/**
* `/status` 被读了几次(含**读它自己这一次**)。
*
* 🔑 存在的理由:序⑲ 的核心收益是"订阅生效后**不再轮询** `/status`",而在此之前
* **没有任何办法断言这件事**("没人轮询"与"轮询了但没被记录"完全同形)。
* 判据用法 = **两次读数之差**:差 `1` ⇒ 只有你在读(= 轮询确实停了)。
*/
statusHits: number
}
endpoints: { hostId: string; network: string; port: number; localPort: number; online: boolean; streams: number }[]
/**
* **结构化**会话视图(`online` 那串是给人读的,这里是给程序 / 前端 / 告警用的)。
* `lastSeenAgoMs` 与 `rttMs` 是判断"对端是否半开"的第一手证据。
*/
sessions: {
hostId: string
/** 该会话所属网(P0-1)。 */
network: string
/** 逻辑名 `<network>/<hostId>` —— 跨网同名时用它区分,`hostId` 单看会歧义。 */
name: string
sessionId: string
/** 已在线多久(ms)。 */
upForMs: number
/** 距最后一次收到帧多久(ms)。 */
lastSeenAgoMs: number
rttMs?: number
ports: number[]
streams: number
}[]
/**
* **在线态视图**(序⑲ presence)—— 与 `sessions` 的差别是**粒度**:
* `sessions` 是"一条连接一行"(诊断用),`presence` 是"**一台 host 一行**"
* (同 hostId 多连接**已按 device 聚合**,第 6 条)⇒ 这才是订阅方消费的那张表。
*
* ⚠️ 它同时是 `/status` **兜底路径**的数据源(D5:`/status` 降级但不删)⇒ 订阅与轮询
* 两条路读的是**同一份事实**,不会出现"两条路给出不同在线态"。
*/
presence: PresenceEntry[]
/**
* **内容分发视图(序㉔ · 可选)** —— 块级内容寻址的判别器快照。
*
* ⚠️ **可选**:未装配内容面(或装配方没注入 provider)时该字段为 `undefined`,
* 此时 `/status` 的 JSON **不含它** ⇒ 既有消费方(Manager / 探针 / 前端)**零影响**。
* 这是本字段敢加进 `/status` 而不破坏兼容的原因(纯新增、缺省不出现)。
*
* ⚠️ 为什么不直接 import 内容模块:relay 是**独立进程**(`main.ts`),
* 内容面的装配点在平台侧(`src/web/server.ts`)⇒ 两边**不共享进程内存**。
* 故此处只定义一个**注入位**:谁装配谁把 `statusContent` 传进来(见 `RelayServerOptions`)。
* 这样 relay 内核**不认识** content 的任何类型(零耦合,符合分层)。
*/
content?: Record<string, unknown>
/** presence 的时序口径(订阅方据此判陈旧,⛔ 不写死数字)。 */
presenceTiming: { graceMs: number; offlineDebounceMs: number; batchMs: number; ttlMs: number; subMax: number }
}
export class RelayServer {
private readonly opts: RelayServerOptions
private readonly host: string
private readonly port: number
private readonly maxStreamsPerPort: number
private readonly queueMaxBytes: number
private readonly authDeadlineMs: number
private readonly authWindowMs: number
private readonly idleTimeoutMs: number
private readonly hbSec: number
private readonly maxHosts: number
private readonly capacityRetryAfterMs: number
/**
* **序㉖ · 利用率软门**(`E4`):`used / max × 100 ≥ utilMaxPct` ⇒ **拒绝新接入**(⛔ 不打满)。
*
* 为什么要有它:`maxHosts`(7515,45% 设计口径)是"**硬容量**"——到那一格才拦,等于把
* **余量(55%)**当成可用空间;而用户口径要求的是「连接稳定高效」⇒ 中继必须**留 30%+ 余量**,
* 否则接入数一逼近容量,排队/重传/抖动会一起上来(那时再拦已经晚了)。
* ⇒ 软门取 **70%**(参数表 `RELAY_UTIL_MAX_PCT`,⛔ **不改** `RELAY_MAX_HOSTS` 本身)。
*
* ⚠️ 值来源两级:`opts.utilMaxPct` → env `RELAY_UTIL_MAX_PCT` → 默认 70。
* 走 env 兜底是**故意的**:装配点(`main.ts` 的 `--max-hosts` 那条路)**不在序㉖ 的在册文件集**内,
* 若要求"必须由装配点传",生产上就永远不会生效(= 静默失效)。
*/
private readonly utilMaxPct: number
private readonly shutdownGraceMs: number
private readonly base: number
private readonly span: number
private http: HttpServer | undefined
private sweeper: NodeJS.Timeout | undefined
/** 正在优雅下线:此时**拒绝新注册**(回 `retryable=true`,对端会自行重连)。 */
private draining = false
/** 归一化后的拨号方白名单(`network → hostId 集合`)。**构造时定型**,运行期不可改(安全判据)。 */
private readonly dialers: ReadonlyMap<string, ReadonlySet<string>>
/** 受信签名者(序③)。**构造时定型** —— 与白名单同一条纪律:安全判据不在运行期被改。 */
private readonly trustedSignerKeys: readonly string[]
private readonly revocations: RevocationList | undefined
private readonly requireIdentity: boolean
private readonly verifyIdentity: boolean
/** 会话表:键 = **逻辑名** `<network>/<hostId>`(P0-1:两张网里的同名 hostId 互不干扰)。 */
private readonly sessions = new Map<string, Session>()
/** 端点表:键 = `<network>/<hostId>:<port>`。 */
private readonly endpoints = new Map<string, Endpoint>()
private readonly seenNonces = new Map<string, Set<string>>()
private authed = 0
private authFailed = 0
private refused = 0
private dropped = 0
private streamsOpened = 0
private protocolErrors = 0
/** 通过了**节点入网凭据**校验的注册数(序③ 的可观测判据:`0` 而 `authed > 0` ⇒ 身份层没生效)。 */
private identityOk = 0
private backpressurePauses = 0
/** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(口径见 `RelayStatus.counters`)。 */
private dial = 0
private dialDenied = 0
private dialFailed = 0
/* ── presence(序⑲)── */
/** 时序口径(全部可注入 ⇒ 单测不必真等 40 s)。 */
private readonly presenceGraceMs: number
private readonly presenceOfflineDebounceMs: number
private readonly presenceBatchMs: number
private readonly presenceTtlMs: number
private readonly presenceSubMax: number
/**
* **内容分发判别器的注入位**(序㉔ · 可选)—— 见 `RelayServerOptions.statusContent`。
* `undefined` ⇒ `/status` 不含 `content` 字段(零影响既有消费方)。
*/
private readonly statusContent: (() => Record<string, unknown>) | undefined
/**
* **在线态权威表** —— 键 = 逻辑名 `<network>/<hostId>`。
*
* 为什么权威在 relay 侧(D2):在线态是**连接事实**,而连接终点在 relay。Manager 只消费 +
* 缓存。⚠️ 这与「权威状态单点(归属 / 租约 / 骨干资格只能控制面写)」**不冲突** ——
* 那条说的是**归属类**状态;presence 不是归属,它是"这条连接此刻在不在"。
*/
private readonly presence = new Map<string, PresenceState>()
/** 批合并定时器(1 s 窗口)—— presence 的**唯一**推送出口。 */
private presenceTimer: NodeJS.Timeout | undefined
/** 序⑲ 判别器:累计推出的 presence 帧数(含 `SNAP`)。稳态必须停住不走。 */
private pushed = 0
/**
* 序⑲ 判别器:其中 `SNAP`(**首帧即全量**)的帧数。
*
* ⚠️ 单独立一个计数(而不是靠日志)才能把 E4 变成**可断言**的:
* 有订阅者却一帧 `SNAP` 都没发过 ⇒ 说明首帧走的是"逐台拉"(N+1)那条老路。
*/
private snaps = 0
/** 序⑲ 判别器:被显式拒绝的订阅请求数。 */
private rejected = 0
/**
* `/status` 的读取次数(**只在 HTTP 处理器里自增** ⇒ `status()` 被内部调用不计数)。
* 首次读到的值就已包含"你这一次" ⇒ 判据用两次读数之差。
*/
private statusHits = 0
/** **序㉖ 判别器**:因**利用率软门**被拒的注册数(⛔ 与 `at-capacity` 硬门分开计 —— 两者意义不同)。 */
private utilRefused = 0
/** **序㉖ 判别器**:relay 侧观测到某会话抖动超标的次数("告警"这条判据的可断言面)。 */
private jitterAlerts = 0
/**
* 序㉖:`[relay-jitter]` 告警的**按会话节流表**(`key = network/hostId` → 上次告警时刻)。
*
* ⚠️ 用「会话 + 时间」两维节流:只按时间 ⇒ 多台同时抖会互相把对方的第一次挤掉;
* 只按会话 ⇒ 持续抖动时会每 15 s 刷一行。
*/
private readonly lastJitterWarnAt = new Map<string, number>()
/** 告警最小间隔(ms);心跳周期 × 4 ⇒ 每台每分钟左右最多一行。 */
private readonly jitterWarnGapMs = DEFAULT_HB_SEC * 4 * 1000
constructor(opts: RelayServerOptions) {
this.opts = opts
this.host = opts.host ?? '127.0.0.1'
this.port = opts.port ?? DEFAULT_RELAY_PORT
this.maxStreamsPerPort = opts.maxStreamsPerPort ?? DEFAULT_MAX_STREAMS_PER_PORT
this.queueMaxBytes = opts.queueMaxBytes ?? DEFAULT_QUEUE_MAX_BYTES
this.authDeadlineMs = opts.authDeadlineMs ?? DEFAULT_AUTH_DEADLINE_MS
this.authWindowMs = opts.authWindowMs ?? DEFAULT_AUTH_WINDOW_MS
this.idleTimeoutMs = opts.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS
this.hbSec = opts.hbSec ?? DEFAULT_HB_SEC
this.maxHosts = opts.maxHosts ?? 0
this.capacityRetryAfterMs = opts.capacityRetryAfterMs ?? DEFAULT_CAPACITY_RETRY_AFTER_MS
this.utilMaxPct = opts.utilMaxPct ?? envNum('RELAY_UTIL_MAX_PCT', DEFAULT_UTIL_MAX_PCT)
this.shutdownGraceMs = opts.shutdownGraceMs ?? DEFAULT_SHUTDOWN_GRACE_MS
this.base = opts.instancePortBase
this.span = opts.instancePortSpan
this.trustedSignerKeys = opts.trustedSignerKeys ?? []
this.revocations = opts.revocations
this.requireIdentity = opts.requireIdentity ?? false
this.verifyIdentity = opts.verifyIdentity ?? true
this.presenceGraceMs = opts.presenceGraceMs ?? DEFAULT_PRESENCE_GRACE_MS
this.presenceOfflineDebounceMs = opts.presenceOfflineDebounceMs ?? DEFAULT_PRESENCE_OFFLINE_DEBOUNCE_MS
this.presenceBatchMs = opts.presenceBatchMs ?? DEFAULT_PRESENCE_BATCH_MS
// TTL 安全网默认 = `HB_SEC` 的 3 倍(第 1 条:存储带 TTL 只作安全网,防网关崩溃漏事件)。
this.presenceTtlMs = opts.presenceTtlMs ?? this.hbSec * 1_000 * DEFAULT_PRESENCE_TTL_FACTOR
this.presenceSubMax = opts.presenceSubMax ?? DEFAULT_PRESENCE_SUB_MAX
this.statusContent = opts.statusContent
// ⚠️ 「强制身份」但「一把受信签名者都没有」= 谁也进不来(**仍然失败关闭**,不放开)。
// 这是有意的:那台 relay 的配置**不完整**,此时"少拒一点"比"全拒"危险得多
// (它会把"身份层根本没生效"伪装成"一切正常")。启动日志会把它喊出来。
// 白名单**在构造时**归一化(扁平 `Set` 也接受)⇒ 配置错(网络 id 非法)在这里就炸,
// 而不是变成运行期"谁也没匹配上"的静默拒绝。
this.dialers = normalizeDialers(opts.dialers)
}
private log(line: string): void {
;(this.opts.log ?? ((s: string) => process.stdout.write(`${s}\n`)))(`[relay] ${line}`)
}
async start(): Promise<void> {
const http = createServer((req, res) => {
if (req.url === '/status') {
this.statusHits += 1
res.writeHead(200, { 'content-type': 'application/json' })
res.end(JSON.stringify(this.status(), null, 2))
return
}
res.writeHead(404, { 'content-type': 'text/plain' })
res.end(`dshs relay: WebSocket upgrade only, at ${RELAY_PATH}\n`)
})
http.headersTimeout = 10_000
http.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head))
this.http = http
this.draining = false
await new Promise<void>((resolve, reject) => {
const onErr = (err: Error): void => reject(err)
http.once('error', onErr)
http.listen(this.port, this.host, () => {
http.off('error', onErr)
resolve()
})
})
this.log(`listening ws://${this.host}:${this.port}${RELAY_PATH} (loopback only) instance-ports=${this.base}..${this.base + this.span - 1}`)
this.sweeper = setInterval(() => this.sweep(), 5_000)
this.sweeper.unref()
/**
* presence 的**批合并出口**(第 2 条:每 1 s 一次 pipeline 提交)。
*
* ⚠️ 与 `sweeper`(5 s,扫端点)**分开**:两者周期不同、职责不同(一个是兜底巡检,
* 一个是推送节拍)。合并会让 presence 的时延被 5 s 拖累 ⇒ 白丢第 4 条的收益。
*/
this.presenceTimer = setInterval(() => this.flushPresence(), Math.max(50, this.presenceBatchMs))
this.presenceTimer.unref()
}
/**
* 停机 —— **优雅下线**:先给每条会话发 `BYE` + `close 1001`(going away)。
*
* 为什么不能直接掐 TCP:掐掉之后对端只能靠**心跳超时**(默认 45s)才发现 ⇒ 「计划内重启」
* 会被放大成 45 秒的服务中断。发 `1001` 后对端**不消耗退避**地立刻重连(实测见 §12 的 T8)。
*/
async stop(): Promise<void> {
this.draining = true
if (this.sweeper !== undefined) clearInterval(this.sweeper)
if (this.presenceTimer !== undefined) clearInterval(this.presenceTimer)
const sessions = [...this.sessions.values()]
// 第一步:**告知**,不是掐断 —— 发 `BYE` + `close 1001`,对端据此立刻开始快速重连。
for (const session of sessions) {
if (!session.conn.isClosed) {
session.conn.sendBinary(encodeJsonFrame(MUX.BYE, 0, { reason: 'server restarting' }))
session.conn.close(WS_CLOSE.GOING_AWAY, 'server restarting')
}
}
// 第二步:给一个**可控**的小窗口让通知真的发出去(默认 300ms),然后**不再等**对端回 close 帧。
// ⚠️ 这里是实测踩出来的:原来"边发边 drop",`http.close()` 要等 socket 收尾 ⇒ `stop()` 实测
// 耗时 1.8s+,把对端的快速重连窗口整个耗光(§12 T8 复盘)。停机耗时 = 恢复时间,必须可控。
if (sessions.length > 0) {
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, this.shutdownGraceMs)
t.unref()
})
}
// 第三步:**强制**收尾 —— 通知已发出,剩下的不等了。
for (const session of sessions) {
this.dropSession(session, 'server stopping (graceful)')
session.conn.destroy()
}
for (const ep of this.endpoints.values()) ep.server?.close()
this.endpoints.clear()
const http = this.http
this.http = undefined
if (http !== undefined) {
// 立刻掐掉残留连接(含空闲 keep-alive):`close()` 只等"已建立的请求"收尾,
// 空闲 keep-alive 连接会让它一直挂着 ⇒ **进程迟迟不退、端口不释放**。
// 实测(R1.5 跨机):旧进程没退 ⇒ 新进程 `EADDRINUSE` ⇒ 客户端只能一直撞那个正在
// `draining` 的旧实例,表现为"重启后再也连不上"。
http.closeAllConnections()
await new Promise<void>((resolve) => http.close(() => resolve()))
}
this.log('stopped (graceful: every session was told going-away)')
}
/** 实际绑定端口(`port: 0` 时由内核分配)—— 测试与诊断用。 */
get boundPort(): number {
const addr = this.http?.address()
return addr !== null && addr !== undefined && typeof addr === 'object' ? addr.port : this.port
}
/**
* 某 host 是否**实时**在线(心跳驱动,非 DB 快照 —— 这正是 relay 相对现状的增益)。
*
* `network` 缺省 `ops`:R5 之前的调用方(含既有单测)只说 hostId,语义不变;
* 一旦涉及第二张网就**必须显式给**(否则问的是"运维网里那个同名节点"—— 这是个正确的默认,
* 因为跨网同名才是歧义源)。
*/
isOnline(hostId: string, network: string = OPS_NETWORK): boolean {
return this.sessions.has(logicalName(network, hostId))
}
/** 某 (host, port) 在 Manager 侧对应的回环端口;离线或未注册 ⇒ `undefined`(**不猜**)。 */
localPortOf(hostId: string, port: number, network: string = OPS_NETWORK): number | undefined {
const ep = this.endpoints.get(endpointKey(network, hostId, port))
if (ep === undefined || ep.session === undefined || ep.localPort === 0) return undefined
return ep.localPort
}
/** 序㉖:当前利用率(%);`max = 0`(不限容量)⇒ `0`。 */
private utilPct(): number {
if (this.maxHosts <= 0) return 0
return Math.floor((this.sessions.size * 100) / this.maxHosts)
}
/**
* **序㉖:relay 侧抖动聚合**。
*
* 口径(⛔ 三条都来自 `Session.rttSamples` 的设计理由,别改):
* 1. **差分在会话内算**(不同对端 RTT 基线不同,混序列会把"换对端"记成巨大抖动 = 假红);
* 2. **直方图在会话间合并**(合并的是差分,基线已被消掉);
* 3. **无样本 ⇒ 也返回结构完整的块**(⛔ 不许少键)。
*/
private jitterStats(): RelayStatus['jitter'] {
const st = jitterThresholds()
const deltas: number[] = []
let sessions = 0
let samples = 0
for (const s of this.sessions.values()) {
const ring = s.rttSamples
if (ring === undefined) continue
samples += ring.length
if (ring.length < 2) continue
sessions += 1
for (const d of absDeltas(ring)) deltas.push(d)
}
const base = statsFromDeltas(deltas, st)
return {
...base,
// ⚠️ 覆盖 `statsFromDeltas` 的 `deltas + 1` 口径 —— 合并后"样本数"必须 = 各环长度之和。
samples,
sessions,
thresholdMs: st.switchMs,
overThreshold: deltas.length >= st.minSamples && base.p95AbsDeltaMs >= st.switchMs,
alerts: this.jitterAlerts,
}
}
status(): RelayStatus {
const now = Date.now()
const byNetwork = new Map<string, string[]>()
for (const session of this.sessions.values()) {
const bucket = byNetwork.get(session.network) ?? []
bucket.push(session.hostId)
byNetwork.set(session.network, bucket)
}
for (const network of this.dialers.keys()) if (!byNetwork.has(network)) byNetwork.set(network, [])
return {
listening: `${this.host}:${this.port}${RELAY_PATH}`,
dialers: describeDialers(this.dialers),
networks: [...byNetwork.keys()].sort().map((network) => ({
network,
dialers: [...(this.dialers.get(network) ?? [])].sort(),
sessions: (byNetwork.get(network) ?? []).sort(),
})),
capacity:
this.maxHosts > 0
? {
max: this.maxHosts,
used: this.sessions.size,
free: Math.max(0, this.maxHosts - this.sessions.size),
utilPct: this.utilPct(),
utilMaxPct: this.utilMaxPct,
}
: { max: 0, used: this.sessions.size, utilPct: 0, utilMaxPct: this.utilMaxPct },
/** 序㉖:抖动观测块(结构恒在,⛔ 不因"没样本"而缺键)。 */
jitter: this.jitterStats(),
online: [...this.sessions.values()].map(
(s) =>
`${s.network === OPS_NETWORK ? s.hostId : logicalName(s.network, s.hostId)}(session=${s.id} ports=${[...s.ports].sort((a, b) => a - b).join('/')} streams=${s.streams.size} hbAge=${now - s.lastSeen}ms in=${s.bytesIn}B out=${s.bytesOut}B)`,
),
counters: {
authed: this.authed,
authFailed: this.authFailed,
refused: this.refused,
/** 序㉖:利用率软门拒绝数(⛔ 与硬门 `refused` 分开)。 */
utilRefused: this.utilRefused,
dropped: this.dropped,
streamsOpened: this.streamsOpened,
protocolErrors: this.protocolErrors,
backpressurePauses: this.backpressurePauses,
/** 序⑤ 判别器:`DIAL` 放行 / 策略拒绝 / 目标不可达(脚本据此断言"拨号这条路通不通")。 */
dial: this.dial,
dialDenied: this.dialDenied,
dialFailed: this.dialFailed,
/** 序③:通过节点凭据校验的注册数 / 是否强制身份(`requireIdentity`)与受信签名者把数。 */
identityOk: this.identityOk,
identityRequired: this.requireIdentity,
trustedSigners: this.trustedSignerKeys.length,
revokedHosts: this.revocations?.hosts.length ?? 0,
/**
* 序⑲ presence 判别器(口径见 `RelayStatus.counters` 的注释)。
* `subs` 是 gauge(连接断了必须回到 0);`pushed` 稳态必须**停住不走**。
*/
subs: this.countSubs(),
pushed: this.pushed,
snaps: this.snaps,
rejected: this.rejected,
statusHits: this.statusHits,
},
endpoints: [...this.endpoints.values()].map((ep) => ({
hostId: ep.hostId,
network: ep.network,
port: ep.port,
localPort: ep.localPort,
online: ep.session !== undefined,
streams: ep.session?.portStreams.get(ep.port) ?? 0,
})),
sessions: [...this.sessions.values()].map((s) => ({
hostId: s.hostId,
network: s.network,
name: logicalName(s.network, s.hostId),
sessionId: s.id,
upForMs: now - s.since,
lastSeenAgoMs: now - s.lastSeen,
rttMs: s.rttMs,
ports: [...s.ports].sort((a, b) => a - b),
streams: s.streams.size,
})),
/**
* 序⑲ presence:**订阅与轮询读的是同一份事实**(D5)—— 订阅走 `SNAP` / `PRESENCE` 帧,
* 轮询走 `/status`,两条路都从这里取数 ⇒ 不可能出现"两条路给出不同在线态"。
*/
presence: [...this.presence.values()].map((st) => this.presenceEntry(st, now)),
presenceTiming: {
graceMs: this.presenceGraceMs,
offlineDebounceMs: this.presenceOfflineDebounceMs,
batchMs: this.presenceBatchMs,
ttlMs: this.presenceTtlMs,
subMax: this.presenceSubMax,
},
/**
* 序㉔:内容分发判别器快照 —— **可选**(未注入 ⇒ 本键不出现,旧消费方零影响)。
* ⚠️ 用 `...(cond ? {content} : {})` 而不是 `content: undefined`:
* 后者在 `JSON.stringify` 时同样消失,但会让"键存在"的断言在**内存态**下也成立
* ⇒ 两种模式下行为不一致(本线最忌的"看着一样、其实不同")。显式展开只保留一种形态。
*/
...(this.statusContent === undefined ? {} : { content: this.statusContent() }),
}
}
/* ═══════════ 连接建立与认证 ═══════════ */
private onUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
const url = req.url ?? ''
// 前缀匹配是**故意**收紧的:`/status` 不在此前缀下 ⇒ R2 的 nginx location 无法把它代理出去。
if (!url.startsWith(RELAY_PATH)) {
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
socket.destroy()
return
}
const conn = acceptWebSocket(req, socket as Socket, head)
if (conn === null) return
let session: Session | undefined
// 未认证超时:没有它,一个空闲 socket 就能白占服务端资源。
const deadline = setTimeout(() => {
if (session === undefined && !conn.isClosed) {
this.authFailed += 1
this.log(`AUTH TIMEOUT remote=${conn.remote}`)
conn.close(WS_CLOSE.POLICY_VIOLATION, 'auth required')
}
}, this.authDeadlineMs)
deadline.unref()
conn.on('protocolError', (why: string) => {
this.protocolErrors += 1
this.log(`protocol error remote=${conn.remote}: ${why}`)
})
conn.on('error', (err: Error) => this.log(`socket error remote=${conn.remote}: ${err.message}`))
conn.on('close', () => {
clearTimeout(deadline)
if (session !== undefined) this.dropSession(session, 'connection closed')
})
conn.on('message', (buf: Buffer, isBinary: boolean) => {
if (session === undefined) {
if (!isBinary) {
conn.close(WS_CLOSE.UNSUPPORTED_DATA, 'binary only')
return
}
const frame = decodeMux(buf)
if (frame === null) {
conn.close(WS_CLOSE.PROTOCOL_ERROR, 'short frame')
return
}
if (frame.type !== MUX.HELLO) {
this.authFailed += 1
this.log(`AUTH DENY remote=${conn.remote} why=hello expected, got type=${frame.type}`)
conn.close(WS_CLOSE.POLICY_VIOLATION, 'hello first')
return
}
const accepted = this.handleHello(conn, frame)
if (accepted !== undefined) {
clearTimeout(deadline)
session = accepted
}
return
}
this.onFrame(session, buf, isBinary)
})
}
/** 校验 `HELLO`;通过则建 session 并回 `HELLO_ACK`,否则关连接并返回 `undefined`。 */
private handleHello(conn: WsConnection, frame: MuxFrame): Session | undefined {
const msg = parseJsonPayload(frame.payload)
/**
* 拒绝注册 —— **必须结构化**(`HELLO_ERR` + 关闭码)。
*
* `retryable` 是给对端判断"要不要立刻重试"的:
* - `true`(时钟偏移 / 服务端正在下线)⇒ 对端**修正后立刻重连**就能好;
* - `false`(主机名 / 密钥 / 端口配错)⇒ 重试一万次也一样 ⇒ 对端退到上限重试并**明确报障**,
* 而不是刷日志("静默失败"与"刷屏噪音"是同一枚硬币的两面)。
* `serverTime` 让对端能**自己算出时钟偏移并校正** —— 没有它,一个时钟漂移 > 窗口的节点
* 会**永久无法重连**(这条曾是设计缺口,见传输方案 §12)。
*/
const deny = (why: string, retryable = false, extra: Record<string, unknown> = {}): undefined => {
this.authFailed += 1
this.log(`AUTH DENY remote=${conn.remote} why=${why} retryable=${retryable}`)
if (!conn.isClosed) {
conn.sendBinary(
encodeJsonFrame(MUX.HELLO_ERR, 0, { reason: why, retryable, serverTime: Date.now(), windowMs: this.authWindowMs, ...extra }),
)
}
// 关闭码本身就是给运维看的信号:`retryable` ⇒ 1013(过会儿再来),否则 1008(策略拒绝)。
conn.close(retryable ? WS_CLOSE.TRY_AGAIN_LATER : WS_CLOSE.POLICY_VIOLATION, why)
return undefined
}
if (msg === null) return deny('malformed-hello')
if (this.draining) return deny('server-draining', true)
const thisNow = Date.now() // 同一次校验内**只取一次**时间,避免"刚过窗/刚进窗"的边界撕裂
const hostId = typeof msg.hostId === 'string' ? msg.hostId : ''
/**
* P0-1:本节点属于**哪张网**。
*
* 缺省 `ops`:现网 47 / 106 上跑的是**不带这个字段**的旧客户端,而它们本来就在运维网里
* ⇒ 这条默认值正好等于事实(不改任何 drop-in 也不会走错网,见模块头的"为什么不进 MAC")。
* 给了但**形状非法** ⇒ **失败关闭**(`bad-network`),不静默当 `ops` ——
* 静默回落会把"网配错了"伪装成"网络不通",那是最难查的一类。
*/
const networkClaim = msg.network === undefined ? OPS_NETWORK : typeof msg.network === 'string' ? msg.network.trim() : ''
if (!isNetworkId(networkClaim)) return deny('bad-network')
const network = networkClaim
const ts = typeof msg.ts === 'number' ? msg.ts : 0
const nonce = typeof msg.nonce === 'string' ? msg.nonce : ''
const portsCsv = typeof msg.portsCsv === 'string' ? msg.portsCsv : ''
const mac = typeof msg.mac === 'string' ? msg.mac.toLowerCase() : ''
/**
* **成员资格(序③)**:密钥表就是权威 —— 它同时给出"这条密钥属于哪个 hostId"与
* "该 hostId 属于哪张网"。HELLO 里的 `network` 只是**待校验的声明**。
*
* ⇒ 声明与登记不一致 ⇒ **失败关闭**(`network-mismatch`):⛔ 不回落 `ops`、⛔ 不放行。
* 这一条补的正是「任何持有任意密钥的 host 都能进同一扁平命名空间」那个缺口
* (`交接单_网抽象与地址规划R6 §待办②`)。
*/
const entry = lookupKey(this.opts.keys, network, hostId)
if (hostId === '' || entry === undefined || entry.secret === '') return deny('unknown-host')
if (entry.network !== network) return deny('network-mismatch')
const secret = entry.secret
if (nonce.length < 16 || nonce.length > 64) return deny('bad-nonce')
if (mac.length !== 64) return deny('bad-mac-length')
const skew = thisNow - ts // 有符号:正是这个符号让对端知道该往前还是往后校正
if (Math.abs(skew) > this.authWindowMs) return deny('clock-skew', true)
const nonceKey = logicalName(network, hostId)
const seen = this.seenNonces.get(nonceKey)
if (seen !== undefined && seen.has(nonce)) return deny('nonce-replay', true)
const want = createHmac('sha256', Buffer.from(secret, 'hex')).update(`${hostId}|${ts}|${nonce}|${portsCsv}`).digest('hex')
if (!safeEqualHex(want, mac)) return deny('bad-mac')
/**
* ── 节点入网凭据(序③)────────────────────────────────────────────────────
*
* 两道**独立**证明,都要过:
* ① `grant` + `grantSig` —— 这台机器**被授权进入这张网**(由受信签名者签发);
* ② `nodeSig` —— **握有**那把节点私钥(光出示凭据只是"有证书";凭据是公开可转发的,
* 谁抄到都能出示 ⇒ 没有第②条,"入网 = 签名"就退化成了"入网 = 抄一段 JSON")。
*
* ⛔ **失败一律拒**,且原因**结构化**回给对端(对端据此明确报障,而不是无限重试)。
* ⛔ 无关"兜底":`grant` 无效时**绝不**"那就只按 HMAC 放行"(那等于身份层形同虚设)。
*/
const nodeKey = typeof msg.nodeKey === 'string' ? msg.nodeKey.trim() : ''
const identityRequested = this.requireIdentity || msg.grant !== undefined || nodeKey !== ''
if (identityRequested && !this.verifyIdentity) {
// `verifyIdentity=false` 只允许出现在"不强制"的调试场景;**强制**时它是配置矛盾 ⇒ 拒。
if (this.requireIdentity) return deny('identity-verification-disabled')
} else if (identityRequested) {
/**
* **不可验 = 不接受**(与 `directory.ts` 同条判据):一把受信签名者都没有时,
* "带了凭据"这件事**无法判定** ⇒ 拒。⛔ 不因为"没配"就放行。
*/
if (this.trustedSignerKeys.length === 0) return deny('identity-no-trusted-signers')
const normNodeKey = normalizePublicKey(nodeKey)
if (normNodeKey === undefined || msg.grant === undefined || typeof msg.grantSig !== 'string') {
return deny('identity-incomplete')
}
const verdict = verifyPeerGrant(msg.grant, msg.grantSig, {
trustedSignerKeys: this.trustedSignerKeys,
network, // 跨网签发 ⇒ network-mismatch
hostId, // 凭据被搬到别的 hostId ⇒ host-mismatch
nodeKey: normNodeKey, // 声明的节点公钥 ≠ 凭据里那把 ⇒ key-mismatch
revocations: this.revocations,
nowMs: thisNow,
})
if (!verdict.ok) return deny(`identity-${verdict.reason}`)
if (!verifyProof(normNodeKey, `${hostId}|${ts}|${nonce}|${portsCsv}`, msg.nodeSig)) {
return deny('identity-bad-proof')
}
this.identityOk += 1
}
/**
* **拨号方**(R5)与 **被连方**(worker)是两种身份,各自的门不一样:
* - worker **必须**声明端口(`no-ports` 照旧拒绝),它注册即开回环监听;
* - 拨号方**必须不**声明端口(它是来"开流"的,不是来"被连"的)—— 两条都收紧,
* 免得一个身份同时拿到两种能力(最小权限)。
* 放行与否只看**服务端白名单在本网那一桶**(`dialers.get(network)`)—— 默认拒绝:
* 没列到的网络里,**一个 hostId 都拨不动**(P0-1 的"结构性隔离"就落在这里)。
*/
const wantDialer = this.dialers.get(network)?.has(hostId) === true
if (portsCsv === '' && !wantDialer) return deny('no-ports')
if (portsCsv !== '' && wantDialer) return deny('dialer-must-not-declare-ports')
const ports: number[] = []
// ⚠️ 必须显式分支:`''.split(',')` 是 `['']`,`Number('')` 为 `0` ⇒ 会被下面的 `bad-port` 拒掉,
// 于是"拨号方空端口表"这条合法路径**永远走不到**(T18/T19 首跑就是这么红的两条)。
// 这里**不**用 `if (part === '') continue` 放宽:非拨号方的空/残缺端口表仍旧照原样拒绝。
if (portsCsv !== '') {
for (const part of portsCsv.split(',')) {
const p = Number(part)
if (!Number.isInteger(p) || p <= 0 || p > 65535) return deny('bad-port')
if (p < this.base || p >= this.base + this.span) return deny('port-out-of-range')
if (ports.includes(p)) return deny('duplicate-port')
ports.push(p)
}
}
// nonce 窗口(有界,防内存被重放表撑爆)
const bucket = seen ?? new Set<string>()
if (seen === undefined) this.seenNonces.set(nonceKey, bucket)
bucket.add(nonce)
while (bucket.size > NONCE_KEEP) {
const oldest = bucket.values().next().value
if (oldest === undefined) break
bucket.delete(oldest)
}
const sessionKey = logicalName(network, hostId)
const old = this.sessions.get(sessionKey)
if (old !== undefined) {
this.log(`host ${sessionKey} re-registered ⇒ dropping old session ${old.id}`)
this.dropSession(old, 'superseded by new session')
}
// ── 容量准入(**满载是唯一的硬门**)────────────────────────────────────────
// `maxHosts = 0` ⇒ 不限(默认,行为与 R1 一致)。满载时:
// ① **不驱逐**任何在线节点(后来者无权踢走先到者);
// ② **已在册**的 hostId 重连**永远优先**(它占的位子本来就是它的)⇒ 只拦"新面孔";
// ③ 回 `at-capacity` + `retryAfterMs` ⇒ 对端**排队等待**,而不是放弃或死循环硬撞。
if (this.maxHosts > 0 && !this.sessions.has(sessionKey) && this.sessions.size >= this.maxHosts) {
this.refused += 1
return deny('at-capacity', true, {
retryAfterMs: this.capacityRetryAfterMs,
capacity: { max: this.maxHosts, used: this.sessions.size, free: 0, utilPct: this.utilPct(), utilMaxPct: this.utilMaxPct },
})
}
/**
* ── 序㉖ · **利用率软门**(`E4`:留 30%+ 余量,⛔ 不打满)─────────────────────
*
* 与上面的 `at-capacity` **是两道不同的门**(⛔ 不许合并、⛔ 不许改 `maxHosts`):
* - `at-capacity` = **硬容量**(7515 / 45% 设计口径)—— 到那一格是"装不下了";
* - 本道 = **软余量**(`RELAY_UTIL_MAX_PCT`,默认 70%)—— 到它是"**再装下去会不稳**"。
*
* 🔑 为什么必须提前拦而不是打满再说:中继的"高效"取决于**排队深度**。接入数一逼近容量,
* 转发延迟的**方差**先炸(尾延迟),那时再拦已经晚了 —— 而用户对交互式会话的第一诉求
* 就是「不卡」。⇒ 判据 = `utilPct ≥ utilMaxPct` ⇒ 拒绝**新面孔**(⛔ 不驱逐任何在线节点、
* ⛔ 已在册的 hostId 重连仍永远优先 —— 与硬门逐字同规则)。
*
* ⚠️ 回 `retryable = true`:这是**临时**状态(容量会释放)⇒ 对端排队重试,而不是永久性拒绝。
*/
if (this.maxHosts > 0 && this.utilMaxPct > 0 && !this.sessions.has(sessionKey)) {
const pct = this.utilPct()
if (pct >= this.utilMaxPct) {
this.utilRefused += 1
this.log(
`⛔ UTIL DENY 拒绝新接入 host=${sessionKey}:利用率 ${pct}% ≥ 软门 ${this.utilMaxPct}%` +
`(used=${this.sessions.size} max=${this.maxHosts},留余量 ${100 - this.utilMaxPct}%;⛔ 不驱逐在线节点)`,
)
return deny('at-util-limit', true, {
retryAfterMs: this.capacityRetryAfterMs,
capacity: { max: this.maxHosts, used: this.sessions.size, free: 0, utilPct: pct, utilMaxPct: this.utilMaxPct },
})
}
}
const sessionId = randomBytes(8).toString('hex')
const session: Session = {
id: sessionId,
hostId,
network,
conn,
ports: new Set(ports),
streams: new Map(),
portStreams: new Map(),
dialRoutes: new Map(),
nextStreamId: 1,
lastSeen: Date.now(),
since: Date.now(),
heartbeat: setInterval(() => undefined),
bytesIn: 0,
bytesOut: 0,
/** presence:未订阅 ⇒ 这个 Map 永远是空的(零成本)。 */
presencePending: new Map(),
}
clearInterval(session.heartbeat)
// **双向**保活:服务端也主动发 `PING`(不只是等对端的)。
// 三个作用:① 测 RTT(可观测)② 让"半开"在对端也能被察觉 ③ 出向始终有流量,NAT 表项不过期。
session.heartbeat = setInterval(() => {
if (Date.now() - session.lastSeen > this.idleTimeoutMs) {
this.log(`session ${session.id} (${hostId}) idle ${this.idleTimeoutMs}ms ⇒ drop`)
this.dropSession(session, 'idle timeout')
return
}
session.pingSentAt = Date.now()
try {
session.conn.sendBinary(encodeMux(MUX.PING, 0))
} catch {
/* 写失败 ⇒ 交给 idle 超时兜底 */
}
}, Math.max(1_000, this.hbSec * 1_000))
session.heartbeat.unref()
this.sessions.set(sessionKey, session)
// 注册即开回环监听 ⇒ `resolve()` 变成纯查表(Manager 侧零改动的前提)。
for (const p of ports) this.ensureEndpoint(network, hostId, p).session = session
/**
* presence(第 1 条):**注册即在线** —— 在线态从连接生命周期来,⛔ 不等任何人来轮询。
*
* 🔴 **顺序必须在本行之上那个 `ensureEndpoint` 循环之后**(序⑲ 收口前实测踩到):
* `presenceEntry` 里要带 `localPorts`(回环落点口号),而那个口号正是 `ensureEndpoint`
* 才分配的。先 touch 再分配 ⇒ 首次推送里的落点是 **0**;而 `publishPresence` 只在
* "在线态翻转"时才重推 ⇒ 那个 0 **永远修不回来** ⇒ 订阅方 `addressOf` 查不到落点 ⇒
* 实例页拨不通(**假死:不报错、不 5xx,只是打不开**)。这正是本线头号教训的形态。
*/
this.presenceTouch(session)
this.authed += 1
conn.sendBinary(
encodeJsonFrame(MUX.HELLO_ACK, 0, {
sessionId,
accepted: ports,
/** 本会话是不是"拨号方"(R5)—— 对端据此决定能不能用 `openStream()`。 */
dialer: wantDialer,
/**
* **服务端认定的网**(P0-1)—— 回显给对端,让"我落在哪张网里"在**客户端日志**上可见。
* 没有它,一个写错 `network` 的节点只能从"拨不通"倒推,而拨不通的原因有七八种。
*/
network,
/** 本会话的逻辑名(`<network>/<hostId>`),与 `/status` 同一口径。 */
name: sessionKey,
hbSec: this.hbSec,
// 让对端**立刻**得到时钟基准(不必等到被拒一次才知道自己漂了)。
serverTime: Date.now(),
base: this.base,
span: this.span,
}),
)
this.log(`AUTH OK host=${sessionKey} session=${sessionId} ports=[${ports.join(',')}] remote=${conn.remote}`)
return session
}
/* ═══════════ 已认证连接上的帧 ═══════════ */
private onFrame(session: Session, buf: Buffer, isBinary: boolean): void {
session.lastSeen = Date.now()
// presence:任何帧都刷新"这条连接活着"(TTL 安全网的输入)。⛔ 心跳不会因此产生事件。
this.presenceTouch(session)
if (!isBinary) {
this.protocolErrors += 1
session.conn.close(WS_CLOSE.UNSUPPORTED_DATA, 'binary only')
return
}
const frame = decodeMux(buf)
if (frame === null) {
this.protocolErrors += 1
session.conn.close(WS_CLOSE.PROTOCOL_ERROR, 'short frame')
return
}
switch (frame.type) {
case MUX.OPEN_ACK:
this.onOpenAck(session, frame)
return
case MUX.DATA:
this.onData(session, frame)
return
case MUX.CLOSE: {
// 拨号方的 `CLOSE` 带的是**它自己的** id ⇒ 先查拨号路由表,再查普通流表。
const route = session.dialRoutes.get(frame.streamId)
if (route !== undefined) {
this.closeStream(route.session, route.st, 'dialer closed')
return
}
const st = session.streams.get(frame.streamId)
if (st !== undefined) this.closeStream(session, st, 'worker closed')
return
}
case MUX.PING:
session.conn.sendBinary(encodeMux(MUX.PONG, 0, frame.payload))
return
case MUX.PONG:
// RTT 采样(服务端 PING → 对端 PONG 的往返)。
if (session.pingSentAt !== undefined) {
session.rttMs = Date.now() - session.pingSentAt
session.pingSentAt = undefined
/**
* ── 序㉖:**抖动可观测**(`E2` 的 relay 侧落点)──
*
* 采样点选在 `PONG` 到达这一刻是应该的:这是**唯一**的"新测量"时刻(⛔ 不是巡检时
* 去读缓存 —— 那样同一个值会被反复记、差分恒 0、jitter 假绿,见 `jitter.ts`
* 的 `JITTER_SAMPLE_GAP_MS` 注释)。
*
* 判定 = 本会话 `p95|ΔRTT| ≥ JITTER_LIMIT_MS` ⇒ 记一次告警 + 写一行
* `[relay-jitter]`(⛔ **按会话 + 按时间**节流:心跳本身就 15 s 一次,
* 但采样一旦变成"每次 PONG 一行"会把日志刷满,本线吃过这个亏)。
*/
const st = jitterThresholds()
const ring = session.rttSamples ?? []
ring.push(session.rttMs)
const cap = Math.max(2, st.sampleMax)
if (ring.length > cap) ring.splice(0, ring.length - cap)
session.rttSamples = ring
const j = st.enabled && ring.length >= 2 ? statsFromDeltas(absDeltas(ring), st) : undefined
if (j !== undefined && j.deltas >= st.minSamples && j.p95AbsDeltaMs >= st.switchMs) {
this.jitterAlerts += 1
const key = `${session.network}/${session.hostId}`
const last = this.lastJitterWarnAt.get(key)
const nowMs = Date.now()
if (last === undefined || nowMs - last >= this.jitterWarnGapMs) {
this.lastJitterWarnAt.set(key, nowMs)
this.log(
`[relay-jitter] ⚠ 会话 ${key} 抖动超标(p95|ΔRTT|=${j.p95AbsDeltaMs}ms ≥ 阈值 ${st.switchMs}ms,` +
`样本 ${j.samples} 个,最近 rtt=${session.rttMs}ms)⇒ 该链路的对端应换更稳的候选`,
)
}
}
}
return
case MUX.BYE: {
// 对端**主动告别** ⇒ 立刻回收,**不等 45s 心跳超时**(把"计划内重启"的恢复时间压到毫秒级)。
const msg = parseJsonPayload(frame.payload)
const reason = msg !== null && typeof msg.reason === 'string' ? msg.reason : 'peer bye'
this.log(`BYE from ${session.hostId} (${session.id}): ${reason}`)
this.dropSession(session, `peer bye: ${reason}`)
return
}
case MUX.PORT_ADD:
void this.onPortChange(session, frame, true)
return
case MUX.PORT_DEL:
void this.onPortChange(session, frame, false)
return
case MUX.DIAL:
this.onDial(session, frame)
return
case MUX.SUB:
this.onSubscribe(session, frame, true)
return
case MUX.UNSUB:
this.onSubscribe(session, frame, false)
return
default: {
this.protocolErrors += 1
this.log(`unknown mux type=${frame.type} from ${session.hostId}`)
session.conn.close(WS_CLOSE.PROTOCOL_ERROR, 'unknown frame type')
}
}
}
/**
* 运行期端口增删(覆盖网络 R4)—— 对应 ssh 版的 `-O forward` / `-O cancel`。
*
* 校验口径**与 `HELLO` 逐条同源**(同一个 `[base, base+span)` 窗口 + 去重):两条路径若有
* 分歧,"注册能过、动态加不能过"会成为极难查的不一致。窗口是**纵深防御**(真正的门是 worker
* 自己的白名单,见 `client.ts` 的 `onOpenRequest`):注意 relay 只把流量导回**worker 自己**的
* 回环,不会把 relay 主机上的任意端口暴露出去。
*/
private async onPortChange(session: Session, frame: MuxFrame, add: boolean): Promise<void> {
const msg = parseJsonPayload(frame.payload)
const reqId = msg !== null && typeof msg.reqId === 'number' ? msg.reqId : 0
const port = msg !== null && typeof msg.port === 'number' ? msg.port : -1
const reply = (ok: boolean, extra: Record<string, unknown> = {}): void => {
session.conn.sendBinary(encodeJsonFrame(MUX.PORT_ACK, 0, { reqId, port, ok, ...extra }))
}
if (!Number.isInteger(port) || port <= 0 || port > 65535) return reply(false, { error: 'bad-port' })
if (port < this.base || port >= this.base + this.span) return reply(false, { error: 'port-out-of-range' })
if (!add) {
const known = session.ports.delete(port)
this.closeEndpoint(session.network, session.hostId, port)
this.presenceSyncPorts(session)
return reply(true, { removed: known })
}
const existing = this.endpoints.get(endpointKey(session.network, session.hostId, port))
if (session.ports.has(port) && existing !== undefined) {
// **幂等**:已经在册就直接回成功(调用方可能是"重连后补一次对账",报错反而会误导)。
await existing.ready
return reply(true, { localPort: existing.localPort, existed: true })
}
const ep = this.ensureEndpoint(session.network, session.hostId, port)
ep.session = session
session.ports.add(port)
this.presenceSyncPorts(session)
await ep.ready
this.log(`host ${logicalName(session.network, session.hostId)} +port ${port} -> 127.0.0.1:${ep.localPort}`)
return reply(true, { localPort: ep.localPort })
}
/**
* 拨号方请求开一条到 `(target, port)` 的流(覆盖网络 R5)。
*
* 与 `onManagerConn` 的差别**只有一处**:对端不是 relay 主机上的 socket,而是另一条会话。
* 因此除了 `peer` 换成 `WsStreamPeer`、以及多存一块"`OPEN_ACK` 前的水坝"以外,其余逐条同源
* (流号分配、每端口并发上限、`OPEN` 下发、`OPEN_ACK` 放行)—— 两条路**不能有第二套口径**,
* 否则"注册能过、拨号不能过"会变成最难查的那种不一致。
*
* 为什么校验 `worker.ports.has(port)` 而不是只查 endpoint 表:端点表**随会话建立**,
* 而"端口已声明"才是 worker 侧白名单的镜像。两者都查 = 纵深防御;真正的门仍在 worker。
*/
private onDial(session: Session, frame: MuxFrame): void {
const done = (ok: boolean, extra: Record<string, unknown> = {}): void => {
if (!session.conn.isClosed) {
session.conn.sendBinary(encodeJsonFrame(MUX.DIAL_ACK, frame.streamId, { ok, ...extra }))
}
}
if (this.dialers.get(session.network)?.has(session.hostId) !== true) {
this.refused += 1
this.dialDenied += 1
this.log(
`DIAL refused: ${logicalName(session.network, session.hostId)} 不在网络 "${session.network}" 的拨号方白名单里`,
)
done(false, { error: 'not-a-dialer' })
return
}
if (this.draining) {
this.dialDenied += 1
done(false, { error: 'server-draining', retryable: true })
return
}
const msg = parseJsonPayload(frame.payload)
const target = msg !== null && typeof msg.target === 'string' ? msg.target : ''
const port = msg !== null && typeof msg.port === 'number' ? msg.port : -1
if (target === '' || !Number.isInteger(port) || port <= 0 || port > 65535) {
this.dialFailed += 1
done(false, { error: 'bad-target' })
return
}
const dialerName = logicalName(session.network, session.hostId)
/**
* 目标**只在同一张网里找**(P0-1):`DIAL.target` 是 hostId、不含网络维度 ⇒ 这里显式拼上
* **拨号方自己**的网络。⛔ 绝不"在别的网里找到一个同名 host 就连过去" ——
* 那正是「一张巨网 + 靠 ACL 兜」的形态,也是本单要消灭的东西。
*/
const worker = this.sessions.get(logicalName(session.network, target))
if (worker === undefined) {
/**
* 本网里没有 ⇒ 再查一遍别的网,**只为日志**。
*
* ⛔ **对外与 `target-offline` 逐字同形**(P0-3 · D4「不下发对端清单」):
* 回一句 `dialer-not-in-network` + "它在 X 网"等于告诉拨号方「**这个 hostId 存在**、住在哪」
* —— 那就是一份**可探测的对端清单**,判据「`u:A` 看不到 `u:B` 的节点」当场失效
* (拿一串猜测的 hostId 打过来,按响应就能把别张网的成员枚举出来)。
* ⇒ 区分只留**服务端日志**(下一行,含 `foreign`):运维照样分得清"配错网"与"节点离线",
* 而拨号方**无从区分**"不存在"与"在别张网"。
*/
const foreign = this.findInOtherNetwork(session.network, target)
this.refused += 1
this.dialFailed += 1
if (foreign !== undefined) {
this.log(
`DIAL ${dialerName} -> ${target}:${port} 拒绝:该节点在别张网(${foreign})⇒ 跨网隔离 ` +
'dialer-not-in-network(**仅日志用语**;对外统一回 target-offline)',
)
done(false, { error: 'target-offline' })
return
}
this.log(`DIAL ${dialerName} -> ${target}:${port} refused (worker offline / port not declared)`)
done(false, { error: 'target-offline' })
return
}
if (worker.conn.isClosed || !worker.ports.has(port)) {
this.refused += 1
this.dialFailed += 1
this.log(`DIAL ${dialerName} -> ${target}:${port} refused (worker offline / port not declared)`)
done(false, { error: 'target-offline' })
return
}
const live = worker.portStreams.get(port) ?? 0
if (live >= this.maxStreamsPerPort) {
this.refused += 1
this.dialDenied += 1
done(false, { error: 'busy' })
return
}
const id = worker.nextStreamId++
if (worker.nextStreamId > 0xffffffff) worker.nextStreamId = 1
const st: MuxStream = {
id,
port,
peer: new WsStreamPeer(session.conn, frame.streamId, WS_PEER_HIGH_WATER),
opening: true,
paused: false,
closed: false,
queued: [],
queuedBytes: 0,
dial: { session, streamId: frame.streamId, ready: false, pending: [], pendingBytes: 0 },
}
worker.streams.set(id, st)
worker.portStreams.set(port, live + 1)
session.dialRoutes.set(frame.streamId, { session: worker, st })
this.streamsOpened += 1
// 序⑤ 判别器:**放行**点 —— 与 `streamsOpened` 同点自增,两条计数必须同步增长(口径:已回 ok:true)。
this.dial += 1
// 先回拨号方(它的写侧从此刻起可用),再让 worker 开流;两端各自的水坝保证不丢字节。
done(true, { workerStreamId: id })
worker.conn.sendBinary(encodeJsonFrame(MUX.OPEN, id, { port }))
this.log(
`DIAL ${dialerName} -> ${logicalName(session.network, target)}:${port} ok (workerStream=${id} dialStream=${frame.streamId})`,
)
}
/**
* 同名 hostId 是否存在于**别的网**;返回那张网的 id。
*
* ⚠️ **只喂日志**(P0-3):调用方拿到非 `undefined` 后写一行"该节点在别张网(X)",
* 但**对外仍回 `target-offline`**。别把它接到响应体上 —— 那等于把"别张网里有哪些 hostId"
* 做成一个可枚举的接口。
*/
private findInOtherNetwork(network: string, hostId: string): string | undefined {
for (const s of this.sessions.values()) {
if (s.network !== network && s.hostId === hostId) return s.network
}
return undefined
}
/**
* 拨号方送来的数据 ⇒ 往 worker 走(**方向与 `onData` 相反**,所以必须是独立的一条路)。
*
* worker 尚未 `OPEN_ACK` 时**只能缓存**:这一刻 worker 还没开始读那个口,直接发过去会让它
* 按"data before open-ack"判协议错(`client.ts` 那条判据在 worker 侧同样生效)。
*/
private onDialerData(session: Session, route: { session: Session; st: MuxStream }, frame: MuxFrame): void {
const st = route.st
const info = st.dial
if (info === undefined || st.closed) return
session.bytesIn += frame.payload.length
if (!info.ready) {
info.pending.push(frame.payload)
info.pendingBytes += frame.payload.length
if (info.pendingBytes > this.queueMaxBytes) {
this.dropped += 1
this.log(`dial stream ${frame.streamId} 起始水坝 ${info.pendingBytes}B > ${this.queueMaxBytes}B ⇒ close`)
this.closeStream(route.session, st, 'dial pre-open queue overflow')
}
return
}
this.pumpToWorker(route.session, st, frame.payload)
}
private onOpenAck(session: Session, frame: MuxFrame): void {
const st = session.streams.get(frame.streamId)
if (st === undefined || st.closed) return
const msg = parseJsonPayload(frame.payload)
if (msg === null || msg.ok !== true) {
const why = msg !== null && typeof msg.error === 'string' ? msg.error : 'worker refused'
this.dropped += 1
this.log(`stream ${frame.streamId} (${session.hostId}:${st.port}) refused by worker: ${why}`)
this.closeStream(session, st, `worker refused: ${why}`)
return
}
st.opening = false
st.peer.resume() // 放开 Manager 侧读取(打开竞态窗口到此结束)
/**
* **水坝放水**(拨号流专属):拨号方在 worker `OPEN_ACK` 之前写下的字节此刻才允许进 worker。
* 不这样做就会出现"头几个字节丢了"—— 与 TCP 版靠 `pauseOnConnect` 挡住的是同一件事。
*/
const info = st.dial
if (info !== undefined) {
info.ready = true
const pending = info.pending
info.pending = []
info.pendingBytes = 0
for (const chunk of pending) this.pumpToWorker(session, st, chunk)
}
const queued = st.queued
st.queued = []
st.queuedBytes = 0
for (const chunk of queued) {
session.bytesIn += chunk.length
session.conn.sendBinary(encodeMux(MUX.DATA, st.id, chunk))
}
}
private onData(session: Session, frame: MuxFrame): void {
// 拨号方这条路:`frame.streamId` 属于**拨号方的**命名空间 ⇒ 必须先查路由表。
const route = session.dialRoutes.get(frame.streamId)
if (route !== undefined) {
this.onDialerData(session, route, frame)
return
}
const st = session.streams.get(frame.streamId)
if (st === undefined || st.closed) return
if (st.opening) {
this.dropped += 1
this.log(`stream ${frame.streamId} got DATA before OPEN_ACK ⇒ drop`)
this.closeStream(session, st, 'protocol: data before open-ack')
return
}
session.bytesOut += frame.payload.length
if (st.paused) {
st.queued.push(frame.payload)
st.queuedBytes += frame.payload.length
if (st.queuedBytes > this.queueMaxBytes) {
this.dropped += 1
this.log(`stream ${frame.streamId} egress queue ${st.queuedBytes}B > ${this.queueMaxBytes}B ⇒ drop`)
this.closeStream(session, st, 'egress queue overflow')
}
return
}
if (!st.peer.write(frame.payload)) {
st.paused = true
this.backpressurePauses += 1
st.peer.once('drain', () => this.flushStream(session, st))
}
}
private flushStream(session: Session, st: MuxStream): void {
if (st.closed) return
st.paused = false
while (st.queued.length > 0) {
const chunk = st.queued.shift()
if (chunk === undefined) break
st.queuedBytes -= chunk.length
if (!st.peer.write(chunk)) {
st.paused = true
st.peer.once('drain', () => this.flushStream(session, st))
return
}
}
}
/* ═══════════ endpoint(Manager 侧入口) ═══════════ */
private ensureEndpoint(network: string, hostId: string, port: number): Endpoint {
const key = endpointKey(network, hostId, port)
const found = this.endpoints.get(key)
if (found !== undefined) return found
const ep: Endpoint = { hostId, network, port, localPort: 0 }
if (this.opts.exposeLoopback === false) {
/*
* **纯流转发模式**(R5):不绑任何本地口。
* 端点条目仍然登记(`online` / 计数照旧),但 `localPort` 恒为 `0` ⇒ 靠 `/status`
* 找落点的那条路会**自然失败**(失败关闭,不是静默透传);而拨号路径完全不受影响。
*/
this.endpoints.set(key, ep)
return ep
}
ep.server = createTcpServer({ pauseOnConnect: true }, (tcp) => this.onManagerConn(ep, tcp))
ep.ready = new Promise<void>((resolve) => {
let settled = false
const done = (): void => {
if (settled) return
settled = true
resolve()
}
ep.server?.on('error', (err: Error) => {
this.log(`endpoint ${key} listen error: ${err.message}`)
// ⚠️ 失败也必须 resolve:否则 `PORT_ADD` 的应答永远不来 ⇒ 调用方挂死等一个不存在的 ACK。
done()
})
ep.server?.listen(0, '127.0.0.1', () => {
const addr = ep.server?.address()
if (addr !== null && addr !== undefined && typeof addr === 'object') ep.localPort = addr.port
this.log(`endpoint ${key} -> 127.0.0.1:${ep.localPort} (loopback)`)
/**
* 🔴 落点口号是**异步**才拿到的(就在上面这一行)⇒ 必须在它落地**之后**再推一次 presence。
* ⛔ 只在 `handleHello` 里 touch 一次是不够的:那一刻 `localPort` 还是 `0`(实测踩到)。
* 订阅方(Manager)随后就靠它 `addressOf`;推不出去 = 页面**打不开但不报错**。
* ⚠️ 这**不违反 E1**:首次分配 + 运行期新增端口都是真的状态变化,且同一 host 的多次入队
* 会被 `presencePending`(按 host 去重)并进**同一帧**。
*/
this.presenceSyncPortsByKey(network, hostId)
done()
})
})
this.endpoints.set(key, ep)
return ep
}
/**
* 收掉一个 endpoint(**worker 主动撤销端口**)。
*
* 为什么必须真的把条目从 `endpoints` 里删掉、而不只是解绑 session:`/status` 是 Manager
* 唯一的地址来源 ⇒ 留一个"已关闭但还带着旧口号"的条目,Manager 会照旧拨它 ⇒ 打到死口上。
* **查不到 ⇒ 翻译不出来 ⇒ 上层明确失败**,这才是能查的病("静默拨到死地址"是最难查的)。
*/
private closeEndpoint(network: string, hostId: string, port: number): void {
const key = endpointKey(network, hostId, port)
const ep = this.endpoints.get(key)
if (ep === undefined) return
this.endpoints.delete(key)
// 在途流不在这里掐:它们各自的 Manager 侧 TCP 收尾会走 `forgetStream`。
try {
ep.server?.close()
} catch {
/* 已关 */
}
ep.session?.portStreams.delete(port)
ep.session = undefined
this.log(`endpoint ${key} closed (worker 撤销该端口)`)
}
private onManagerConn(ep: Endpoint, tcp: Socket): void {
tcp.setNoDelay(true)
const session = ep.session
if (session === undefined || session.conn.isClosed) {
this.refused += 1
this.log(`refuse ${ep.hostId}:${ep.port} (worker offline)`)
tcp.destroy()
return
}
const live = session.portStreams.get(ep.port) ?? 0
if (live >= this.maxStreamsPerPort) {
this.refused += 1
this.log(`refuse ${ep.hostId}:${ep.port} (busy, ${live} streams)`)
tcp.destroy()
return
}
const id = session.nextStreamId++
if (session.nextStreamId > 0xffffffff) session.nextStreamId = 1
const st: MuxStream = { id, port: ep.port, peer: tcp, opening: true, paused: false, closed: false, queued: [], queuedBytes: 0 }
session.streams.set(id, st)
session.portStreams.set(ep.port, live + 1)
this.streamsOpened += 1
// `pauseOnConnect` 让 TCP 层替我们挡住 OPEN_ACK 之前的数据 ⇒ 不存在"丢头几个字节"的竞态。
tcp.on('data', (chunk: Buffer) => this.pumpToWorker(session, st, chunk))
tcp.on('end', () => this.closeStream(session, st, 'manager eof'))
tcp.on('error', (err: Error) => {
this.log(`stream ${id} tcp error: ${err.message}`)
this.closeStream(session, st, 'manager tcp error')
})
tcp.on('close', () => this.forgetStream(session, st))
session.conn.sendBinary(encodeJsonFrame(MUX.OPEN, id, { port: ep.port }))
}
private pumpToWorker(session: Session, st: MuxStream, chunk: Buffer): void {
if (st.closed) return
if (st.opening) {
st.queued.push(chunk)
st.queuedBytes += chunk.length
if (st.queuedBytes > this.queueMaxBytes) {
this.dropped += 1
this.log(`stream ${st.id} open-queue ${st.queuedBytes}B > ${this.queueMaxBytes}B ⇒ drop`)
this.closeStream(session, st, 'open queue overflow')
return
}
st.peer.pause()
return
}
session.bytesIn += chunk.length
if (!session.conn.sendBinary(encodeMux(MUX.DATA, st.id, chunk))) {
// 连接级背压:一条 wss 承载全部流,暂停所有入向源,`drain` 后恢复。
// 粗粒度但**有界**——比"无界排队"安全,也比"只暂停一条流"正确。
this.backpressurePauses += 1
for (const s of session.streams.values()) s.peer.pause()
session.conn.once('drain', () => {
for (const s of session.streams.values()) if (!s.closed) s.peer.resume()
})
}
}
private closeStream(session: Session, st: MuxStream, why: string): void {
if (st.closed) return
st.closed = true
session.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, st.id, { reason: why }))
const left = (session.portStreams.get(st.port) ?? 1) - 1
if (left <= 0) session.portStreams.delete(st.port)
else session.portStreams.set(st.port, left)
st.peer.end()
const killer = setTimeout(() => st.peer.destroy(), 2_000)
killer.unref()
// 拨号流:把拨号方路由表里的条目一并删掉(否则它会留一条指向已死流的条目)。
const info = st.dial
if (info !== undefined) info.session.dialRoutes.delete(info.streamId)
this.log(`stream ${st.id} (${session.hostId}:${st.port}) closed: ${why}`)
}
private forgetStream(session: Session, st: MuxStream): void {
if (st.closed) {
// `closeStream` 已维护计数;这里只删条目(幂等)。
session.streams.delete(st.id)
return
}
st.closed = true
const left = (session.portStreams.get(st.port) ?? 1) - 1
if (left <= 0) session.portStreams.delete(st.port)
else session.portStreams.set(st.port, left)
session.streams.delete(st.id)
const info = st.dial
if (info !== undefined) info.session.dialRoutes.delete(info.streamId)
session.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, st.id, { reason: 'manager tcp closed' }))
}
/* ═══════════ presence(在线态)—— 权威表 + 订阅扇出 + 1 s 批合并 ═══════════ */
/**
* 取(必要时建)某 host 的在线态记录。键 = **逻辑名**(P0-1:两张网的同名 host 各算一台)。
*/
private presenceState(network: string, hostId: string): PresenceState {
const name = logicalName(network, hostId)
let st = this.presence.get(name)
if (st === undefined) {
st = {
name,
network,
hostId,
conns: new Map(),
lastSeenMs: Date.now(),
offlineSinceMs: 0,
ports: new Set(),
published: false,
changedAt: 0,
}
this.presence.set(name, st)
}
return st
}
/**
* 某 host 当前**活着**的连接数(TTL 窗口内的连接才算数)。
*
* 这就是第 6 条「多设备按 device 聚合」的**唯一判据**:任一条连接活着 ⇒ 整台在线。
*/
private presenceDevices(st: PresenceState, now: number): number {
let n = 0
for (const ts of st.conns.values()) if (now - ts <= this.presenceTtlMs) n++
return n
}
/**
* **连接生命周期驱动在线**(第 1 条):注册 / 收帧 ⇒ 这条连接算"活着"。
*
* ⚠️ **只在"离线 → 在线"时产生事件**;重复 touch(心跳每 15 s 一次)**零事件、零帧**
* —— 这正是"彻底干掉每 30 s 全员轮询"的落点。
*/
private presenceTouch(session: Session): void {
const st = this.presenceState(session.network, session.hostId)
const now = Date.now()
st.conns.set(session.id, now)
st.lastSeenMs = now
st.offlineSinceMs = 0
for (const p of session.ports) st.ports.add(p)
if (!st.published) this.publishPresence(st, true, now)
}
/** 某条连接消失(第 6 条:**还有别的连接就什么都不做** —— 多设备聚合的意义就在这里)。 */
private presenceDrop(session: Session): void {
const st = this.presence.get(logicalName(session.network, session.hostId))
if (st === undefined || !st.conns.delete(session.id)) return
// 还有活连接 ⇒ 聚合后仍在线:⛔ 不产生任何事件(否则双设备的机器会产生双倍帧 = 净退化)
if (this.presenceDevices(st, Date.now()) > 0) return
// 起算 grace + debounce;**此刻先不改口**(第 5 条:防"网络抖一下 = 状态闪烁")
st.offlineSinceMs = Date.now()
}
/**
* 运行期端口增删后同步(让订阅方拿到的 `ports` 与 `HELLO` / `PORT_ADD` 一致)。
*
* 🔑 **必须入队推送**(`force = true`)——`ports` / `localPorts` 也是"事实":
* 序⑲ 之后 Manager 侧 `/status` 轮询会被挂起,订阅推送**是它唯一在更新的落点来源**
* (`web/server.ts` 的 `addressOf` ②)。只改本地 `st.ports` 而不推 ⇒ 新端口永远到不了订阅方
* ⇒ 页面拨不通(同样是**假死**,不是报错)。这也**不违反 E1**:端口变了就是状态真的变了,
* 属于"≤1 帧/次变化"的正常配额(并由批合并并进同一帧)。
*/
private presenceSyncPorts(session: Session): void {
const st = this.presence.get(logicalName(session.network, session.hostId))
if (st === undefined) return
st.ports = new Set(session.ports)
this.publishPresence(st, st.published, Date.now(), true)
}
/**
* 与 `presenceSyncPorts` 同义,但调用方只知道 `network` / `hostId`(= `ensureEndpoint` 的回调里
* 只有键,没有 `Session`)。⛔ 不复制逻辑 —— 两条路都汇到 `publishPresence(force)`。
*/
private presenceSyncPortsByKey(network: string, hostId: string): void {
const st = this.presence.get(logicalName(network, hostId))
if (st === undefined) return
this.publishPresence(st, st.published, Date.now(), true)
}
private presenceEntry(st: PresenceState, now: number): PresenceEntry {
const ports = [...st.ports].sort((a, b) => a - b)
const devices = this.presenceDevices(st, now)
const entry: PresenceEntry = {
name: st.name,
hostId: st.hostId,
network: st.network,
online: st.published,
devices,
ports,
// 回环落点 = `/status` 里 `endpoints[]` 的同一份事实(D5:订阅与轮询读同一份来源)。
localPorts: ports.map((port) => ({
port,
localPort: this.endpoints.get(endpointKey(st.network, st.hostId, port))?.localPort ?? 0,
})),
lastSeenAgoMs: now - st.lastSeenMs,
changedAt: st.changedAt,
}
// 仅在"已断连、还在 grace/debounce 窗口内"时给出倒计时(在线时不填,⛔ 不制造无意义字段)。
if (devices === 0 && st.published) {
entry.offlineInMs = st.offlineSinceMs + this.presenceGraceMs + this.presenceOfflineDebounceMs - now
}
return entry
}
/**
* 状态**真的变了**才入队(第 2/4 条的前提:没有变化就一个帧都不发)。
*
* ⚠️ 这里是 presence 唯一的"写入出口" —— 任何新的状态来源都必须走它,否则会绕过批合并
* (= 帧爆炸)。第 3 条:只入队给**订阅了该 host**的会话(订阅式扇出,不是广播)。
*/
private publishPresence(st: PresenceState, online: boolean, now: number, force = false): void {
if (!force && st.published === online && st.changedAt !== 0) return
st.published = online
st.changedAt = now
const entry = this.presenceEntry(st, now)
for (const s of this.sessions.values()) {
if (s.subs === undefined) continue
if (s.subs !== 'all' && !s.subs.has(st.name)) continue
s.presencePending.set(st.name, entry)
}
}
/**
* **批合并出口**(第 2 条:每 1 s 一次 pipeline 提交)。
*
* 三件事按序做完:① grace/debounce 到期 ⇒ 真的改口离线;② TTL 安全网摘掉"指向已消失会话"的
* 连接 id(兜"漏掉 close 事件");③ 每条订阅会话把攒下的一批**拼成一帧**发出去。
*/
private flushPresence(): void {
const now = Date.now()
// ① 离线 debounce 到期 ⇒ 改口(10 s + 30 s = 40 s 之后才第一次说"它离线了")
for (const st of this.presence.values()) {
if (!st.published || st.offlineSinceMs === 0) continue
if (this.presenceDevices(st, now) > 0) continue
if (now - st.offlineSinceMs >= this.presenceGraceMs + this.presenceOfflineDebounceMs) {
this.publishPresence(st, false, now)
}
}
// ② TTL 安全网(第 1 条:存储带 TTL 只作安全网,防网关崩溃漏事件)
// 逐**连接**判死:某个 connId 超过 TTL 没动静 ⇒ 它已经不在了(典型成因 = 漏掉了 close 事件)。
for (const st of this.presence.values()) {
const before = st.conns.size
for (const [id, ts] of [...st.conns]) if (now - ts > this.presenceTtlMs) st.conns.delete(id)
const pruned = before - st.conns.size
if (pruned === 0) continue
if (this.presenceDevices(st, now) > 0) continue
if (st.offlineSinceMs === 0) {
st.offlineSinceMs = now
// 摘掉的是"已消失的连接" ⇒ 走正常 grace/debounce 收口(⛔ 不在这里直接改口,
// 否则 TTL 就绕过了第 5 条的抖动容忍)。
this.log(`presence ${st.name} 摘掉 ${pruned} 个超 TTL 未活动的连接 ⇒ 起算 grace+debounce`)
} else if (st.published && now - st.offlineSinceMs >= this.presenceGraceMs + this.presenceOfflineDebounceMs) {
this.publishPresence(st, false, now)
}
}
// ③ 推送:**每条订阅会话一帧**(一帧带 host 数组;⛔ 不是逐个 host 一条)
for (const s of this.sessions.values()) {
if (s.presencePending.size === 0) continue
const entries = [...s.presencePending.values()]
s.presencePending.clear()
try {
s.conn.sendBinary(encodeJsonFrame(MUX.PRESENCE, 0, { ok: true, entries, at: now }))
this.pushed += 1
} catch {
/* 写失败 ⇒ 交给会话自己的 idle 超时兜底(⛔ 不在这里 dropSession,避免重入) */
}
}
}
/** 订阅方当前应看到的全量(`SNAP` 用;一帧拿全,⛔ 无 N+1)。 */
private presenceSnapshot(session: Session): PresenceEntry[] {
const now = Date.now()
const out: PresenceEntry[] = []
for (const st of this.presence.values()) {
if (st.network !== session.network) continue
if (session.subs !== undefined && session.subs !== 'all' && !session.subs.has(st.name)) continue
// 只推"该 host 已在册"的条目(从未见过的主机无法枚举 —— 这是订阅语义的固有边界)。
out.push(this.presenceEntry(st, now))
}
return out
}
/** 现在有多少条**存活**的订阅(`subs` 判别器;连接断了就没了 ⇒ 必须能回到 0)。 */
private countSubs(): number {
let n = 0
for (const s of this.sessions.values()) if (s.subs !== undefined) n += 1
return n
}
/**
* `SUB` / `UNSUB`(第 3 条:**订阅只活在连接期间**)。
*
* 🔴 **跨网订阅必须显式拒绝**(D6):本线头号教训是"静默失败会被当成正常",
* 而"静默返空"与"这张网里确实没人"**完全同形** ⇒ 拒绝必须带上原因 + 计数(`rejected`)。
*/
private onSubscribe(session: Session, frame: MuxFrame, on: boolean): void {
const now = Date.now()
const msg = parseJsonPayload(frame.payload)
if (msg === null) {
this.rejectSubscribe(session, on, 'bad-payload')
return
}
const net = typeof msg.network === 'string' && msg.network !== '' ? msg.network : session.network
if (net !== session.network) {
this.rejectSubscribe(session, on, `cross-network:${net}`)
return
}
if (!on) {
// 退订**幂等**:本来就没订阅也不报错(否则会制造无意义的拒绝计数)。
session.subs = undefined
session.presencePending.clear()
return
}
const all = msg.all === true
const rawHosts = Array.isArray(msg.hosts) ? msg.hosts.filter((h): h is string => typeof h === 'string' && h !== '') : []
if (!all && rawHosts.length === 0) {
this.rejectSubscribe(session, on, 'empty-subscription')
return
}
if (this.presenceSubMax > 0 && rawHosts.length > this.presenceSubMax) {
this.rejectSubscribe(session, on, `too-many:${rawHosts.length}`)
return
}
const watched = new Set<string>()
for (const h of rawHosts) {
// 接受两种写法:裸 `hostId`(按本网补全)与完整逻辑名(必须落在本网,否则同属跨网 = 拒绝)。
const target = h.includes(NAME_SEP) ? h : logicalName(session.network, h)
const { network: targetNet } = parseLogicalName(target)
if (targetNet !== session.network) {
this.rejectSubscribe(session, on, `cross-network-host:${target}`)
return
}
watched.add(target)
}
session.subs = all ? 'all' : watched
session.presencePending.clear()
// 🔑 **首帧即全量**(第 6 条:重连后必须重新拉一次全量)—— 一帧完成,⛔ 不逐 host 拉
try {
session.conn.sendBinary(
encodeJsonFrame(MUX.SNAP, 0, {
ok: true,
entries: this.presenceSnapshot(session),
graceMs: this.presenceGraceMs,
offlineDebounceMs: this.presenceOfflineDebounceMs,
batchMs: this.presenceBatchMs,
ttlMs: this.presenceTtlMs,
at: now,
}),
)
this.pushed += 1
this.snaps += 1
} catch {
/* 同上:写失败交给 idle 超时 */
}
this.log(
`presence SUB host=${logicalName(session.network, session.hostId)} scope=${session.subs === 'all' ? 'all' : `${watched.size} 个点名`} subs=${this.countSubs()}`,
)
}
private rejectSubscribe(session: Session, on: boolean, why: string): void {
this.rejected += 1
this.log(
`presence ${on ? 'SUB' : 'UNSUB'} ⛔ 拒绝 host=${logicalName(session.network, session.hostId)} 原因=${why} rejected=${this.rejected}`,
)
try {
session.conn.sendBinary(encodeJsonFrame(MUX.PRESENCE, 0, { ok: false, error: why, at: Date.now() }))
} catch {
/* 写失败同上 */
}
}
private dropSession(session: Session, why: string): void {
clearInterval(session.heartbeat)
// ⚠️ 只有"当前 session 还是我"时才解绑 —— 否则新 session 刚注册就被旧 session 的收尾删掉。
const key = logicalName(session.network, session.hostId)
if (this.sessions.get(key) === session) this.sessions.delete(key)
for (const st of session.streams.values()) {
st.closed = true
st.peer.destroy()
// 这条流的对端若是「拨号方」,把它的路由条目一并清掉(否则拨号方留一条指向死流的记录)。
if (st.dial !== undefined) st.dial.session.dialRoutes.delete(st.dial.streamId)
}
session.streams.clear()
session.portStreams.clear()
// 本会话若是**拨号方**:它在别人的会话里占着流 ⇒ 一并收掉,否则 worker 侧留悬挂流。
for (const route of [...session.dialRoutes.values()]) {
this.closeStream(route.session, route.st, 'dialer session dropped')
}
session.dialRoutes.clear()
for (const ep of this.endpoints.values()) {
if (ep.session === session) ep.session = undefined
}
if (!session.conn.isClosed) session.conn.close(WS_CLOSE.NORMAL, why)
// presence(第 5 条):**先不改口** —— 起算 grace + debounce;订阅方在窗口内会看到
// `online:true` + `offlineInMs` 倒计时,到期才收到 `online:false`(把抖动合并掉)。
this.presenceDrop(session)
this.log(`session ${session.id} (${key}) dropped: ${why}`)
}
/** 兜底巡检:endpoint 指向的 session 已不在册(只可能走异常路径)⇒ 标离线,避免 Manager 打到死连接。 */
private sweep(): void {
for (const ep of this.endpoints.values()) {
if (ep.session !== undefined && this.sessions.get(logicalName(ep.network, ep.hostId)) !== ep.session) {
ep.session = undefined
this.log(`endpoint ${endpointKey(ep.network, ep.hostId, ep.port)} marked offline (stale session)`)
}
}
}
}
/**
* 端点表键 = `<network>/<hostId>:<port>`。
*
* `port` 用 `:` 分隔(与 `hostId` 的字符集不冲突),网络维度由 `/` 承担 —— 这样"跨网同名 host"
* 与"同网不同端口"两种情形在键里都天然可分。
*/
function endpointKey(network: string, hostId: string, port: number): string {
return `${logicalName(network, hostId)}:${port}`
}
/** 未配置拨号白名单 ⇒ `normalizeDialers` 返回**空 map** ⇒ 任何网络的 `DIAL` 一律被拒(默认拒绝)。 */
/**
* 拨号流的 `peer` —— 把「一条 mux 会话 + 一个流号」当字节流用(覆盖网络 R5)。
*
* ## 背压为什么看 `bufferedAmount` 而不是 `write()` 的返回值
* WS 的 `send()` 只是入队,**永远"成功"** ⇒ 拿返回值当背压判据等于没有背压(这正是 ssh 版
* "静默失败"的同族病)。真正的判据是 socket 出向水位,与 `RelayClient.overWater()` 同一口径。
*
* ## `pause()` / `resume()` 为什么是空实现
* WS 没有"暂停对端"这种能力。relay 不需要它:`write()` 返回 `false` ⇒ 上层的
* `paused` / `queued` 机制接手(数据进 relay 自己的队列),对端慢**不会**把 relay 撑爆。
*/
class WsStreamPeer implements StreamPeer {
constructor(
private readonly conn: WsConnection,
private readonly streamId: number,
private readonly highWater: number,
) {}
write(chunk: Buffer): boolean {
if (this.conn.isClosed) return false
this.conn.sendBinary(encodeMux(MUX.DATA, this.streamId, chunk))
return this.conn.bufferedAmount < this.highWater
}
once(_event: 'drain', cb: () => void): void {
this.conn.once('drain', cb)
}
pause(): void {
/* WS 无法暂停对端;背压由 relay 自己的队列兜住(见类注释)。 */
}
resume(): void {
/* 同上。 */
}
/** 关流:**必须**告诉拨号方(它那边的 `openStream()` 在等这个语义,缺了就是"静默断开")。 */
end(): void {
if (!this.conn.isClosed) this.conn.sendBinary(encodeJsonFrame(MUX.CLOSE, this.streamId, { reason: 'relay closed' }))
}
destroy(): void {
this.end()
}
}
/** 定长 hex 常量时间比较(长度不等直接 false,不泄漏长度以外的信息)。 */
function safeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8'))
}