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

+162
View File
@@ -118,6 +118,83 @@ export interface ServerConfig {
* 删掉新 env 即回到旧路径,**零代码回滚**。
*/
clusterRendezvousUrl: string
/**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/
relayUrl: string
/**
* 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:20080/status`。
*
* 为什么必须查它:relay 为每个注册端口在**它自己的回环**上开一条监听,端口号是
* `listen(0)` 动态分配的(实测 42067)⇒ **Manager 无法从 `dsh_hosts.endpoint` 推出来**,
* 只能问 relay「hostId + 端口 → 回环口 + 在线态」。**空 = 不注册 `relay` 实现**
* (于是 `via='relay'` 会回退到 `manager-ssh`,行为与今天一致)。
*/
relayStatusUrl: string
/**
* **拨号通道**(覆盖网络 R5):Manager 用哪个 hostId 向 relay 注册为「拨号方」。
* 默认 `manager`;空 ⇒ **不启用拨号通道**(一切照旧)。
*
* 为什么需要它:R1–R4 的落点在 **relay 主机的回环**上 ⇒ Manager 必须与 relay 同机,
* 「中继可换机 / 多实例」就做不到。拨号通道把落点搬到 **Manager 自己本机**
* (`src/net/relay/dialer.ts` 的口池)⇒ relay 放哪台机器都行。
* ⚠️ 该 hostId 必须同时出现在 relay 的密钥文件里,且列在 relay 的 `DSHS_RELAY_DIALERS` 白名单里。
*/
relayDialHost: string
/** 拨号方的 64 位 hex 密钥(**与 relay 密钥文件里 `relayDialHost` 那一项逐字节相同**)。空 ⇒ 不启用。 */
relayDialSecret: string
/** 拨号落点口池起点。**必须避开 OS 临时端口段(32768–60999)与实例端口段**。默认 25000。 */
relayDialPortBase: number
/** 拨号落点口池扫描宽度。默认 1000。 */
relayDialPortSpan: number
/** 拨号落点口池大小(= 最多同时挂多少个 `(hostId, port)` 落点)。默认 64。 */
relayDialPool: number
/**
* **实例端口区间起点**(覆盖网络 S3)。`0` = 保持旧行为(`listen(0)` 随机取端口)。
*
* 为什么必须能配:跨机实例的**隧道落点全部挤在 Manager 的 `127.0.0.1`**,而
* `findFreePort()` 是**每台 worker 各自**用 `listen(0)` 随机取的 ⇒ **两台 worker 取到同号
* 就会撞号**:`-R` 失败被静默忽略(`tunnel.forward()` 的返回值无人看),Manager 仍按该
* 端口拨 ⇒ **打到别人的实例**(2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间
* 即可根治,且**不需要改 sshd、不需要端口映射表**。
*
* ⚠️ 选区间时应**避开 OS 临时端口段**(本项目两台机器均为 `32768-60999`)。
*/
instancePortBase: number
/** 实例端口区间长度(条数)。仅当 `instancePortBase > 0` 时生效。 */
instancePortSpan: number
// ── 覆盖网络 P0-2 引导三级链(`net/relay/directory.ts`)─────────────────
/**
* **本机所属的网**(P0-1 的 `network_id`)。运维网 = `ops`(47 / 106 / 未来的中继);
* 用户设备用 `u:<userId>`。它进目录文档的 `network` 字段(**被签名覆盖**)。
*/
overlayNetworkId: string
/**
* **内置种子**(引导链第 ①级之后的兜底入口)。常量位 —— 已持证书、**不新增域名**。
*
* ⚠️ 约定:"**引导地址 = 中继入口同源**":目录端点 = 种子 origin + 固定路径
* (`net/relay/directory.ts` 的 `DIRECTORY_PATH`)。本文件是**基础层**、
* 不许 import 能力层 ⇒ 路径字面量两处各写一次,**改动必须两处同改**。
*/
overlayBootstrapSeeds: string[]
/**
* **受信目录签名公钥**(PEM 或裸 32 字节 hex/base64,逗号分隔)。
* 空 ⇒ 目录一律不接受(**不可验 = 不接受**)⇒ 只剩 env 与种子兜底两级。
*/
overlayDirTrustedKeys: string[]
/** 目录**签名私钥**(Ed25519 PEM)路径。空 ⇒ 目录端点 `503`(**绝不发未签名目录**)。 */
overlayDirKeyFile: string
/** 目录缓存文件(客户端侧)。空 ⇒ 不缓存(每次取目录,多一次往返)。 */
overlayDirectoryCacheFile: string
// ── 覆盖网络 序③ 一机一钥 + 信任根(`net/relay/identity.ts`)────────────
/**
* **本机节点私钥**文件路径(Ed25519 PEM,**0600**、属主必须是跑 dsh 的那个用户)。
* 空 ⇒ 本机不发起身份(只做 HMAC,过渡期形态)。
*/
overlayNodeKeyFile: string
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
overlayNodeGrantFile: string
}
/** Untyped overrides collected from argv / env. */
@@ -165,9 +242,29 @@ export interface ConfigOverrides {
clusterInstanceHost?: string
clusterWorkerDataRoot?: string
clusterRendezvousUrl?: string
relayUrl?: string
relayStatusUrl?: string
relayDialHost?: string
relayDialSecret?: string
relayDialPortBase?: number | string
relayDialPortSpan?: number | string
relayDialPool?: number | string
instancePortBase?: number | string
instancePortSpan?: number | string
overlayNetworkId?: string
overlayNodeKeyFile?: string
overlayNodeGrantFile?: string
overlayBootstrapSeeds?: string[]
overlayDirTrustedKeys?: string[]
overlayDirKeyFile?: string
overlayDirectoryCacheFile?: string
}
const DEFAULT_HOST = '127.0.0.1'
// 覆盖网络 S3:实例端口区间(`0` = 保持旧行为 `listen(0)` 随机)。
const DEFAULT_INSTANCE_PORT_BASE = 0
const DEFAULT_INSTANCE_PORT_SPAN = 1000
const DEFAULT_PORT = 3080
const DEFAULT_DSH_COMMAND = ['dsh']
const DEFAULT_LOG_LEVEL = 'info'
@@ -207,6 +304,12 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
const DEFAULT_K8S_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
/**
* 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。
*/
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay']
/** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
@@ -231,12 +334,31 @@ function toBool(value: string | undefined, fallback: boolean): boolean {
return value === 'true' || value === '1'
}
/**
* 解析端口号(覆盖网络 S3)。空 / 非法 / 越界 ⇒ 回落到 `fallback`(`0` = 旧行为 `listen(0)`)。
* 不接受 NaN:`listen(NaN)` 会以"启动正常但实例起不来"的形式烂在运行时。
*/
function toPortNumber(value: string | number | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback
const n = Number(value)
return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback
}
/** Split a comma-separated CIDR list into a trimmed, de-duplicated array. */
function parseCidrs(value: string | undefined): string[] {
if (value === undefined || value === '') return []
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
}
/**
* 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
*/
function splitList(value: string | undefined): string[] | undefined {
if (value === undefined) return undefined
return [...new Set(value.split(',').map((s) => s.trim()).filter((s) => s !== ''))]
}
/** Parse an isolation-mode value, rejecting anything outside `soft`/`account`
* so a typo in the env var fails loudly at startup instead of silently
* falling back to `soft` isolation. */
@@ -367,5 +489,45 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
process.env.DSHS_RENDEZVOUS_URL ??
process.env.DSHS_TUNNEL_TARGET ??
'',
// 覆盖网络 R3:Manager 侧的 relay 接入(**空 = 完全不启用**,与 R2 之前行为一致)。
relayUrl: overrides.relayUrl ?? process.env.DSHS_RELAY_URL ?? '',
relayStatusUrl: overrides.relayStatusUrl ?? process.env.DSHS_RELAY_STATUS_URL ?? '',
// 覆盖网络 R5:Manager 侧拨号通道(**空密钥 = 完全不启用**,落回 `/status` 快照那条老路)。
relayDialHost: (overrides.relayDialHost ?? process.env.DSHS_RELAY_DIAL_HOST ?? 'manager').trim(),
relayDialSecret: (overrides.relayDialSecret ?? process.env.DSHS_RELAY_DIAL_SECRET ?? '').trim(),
relayDialPortBase: toPortNumber(overrides.relayDialPortBase ?? process.env.DSHS_RELAY_DIAL_PORT_BASE, 25000),
relayDialPortSpan: toPortNumber(overrides.relayDialPortSpan ?? process.env.DSHS_RELAY_DIAL_PORT_SPAN, 1000),
relayDialPool: Number(overrides.relayDialPool ?? process.env.DSHS_RELAY_DIAL_POOL ?? 64) || 64,
// 覆盖网络 S3:实例端口区间隔离(`0` = 旧行为)。非法值**回落**而不是变 NaN ——
// `listen(NaN)` 是那种"启动看起来正常、实例起不来"的坑。
instancePortBase: toPortNumber(
overrides.instancePortBase ?? process.env.DSHS_INSTANCE_PORT_BASE,
DEFAULT_INSTANCE_PORT_BASE,
),
instancePortSpan: toPortNumber(
overrides.instancePortSpan ?? process.env.DSHS_INSTANCE_PORT_SPAN,
DEFAULT_INSTANCE_PORT_SPAN,
),
// 覆盖网络 P0-2:引导三级链(env 显式 > 缓存目录 > 内置种子)。
// 这一组**全部有安全默认**:不配任何东西 ⇒ 与今天行为一致(只认 env;种子地址就是现网地址)。
overlayNetworkId: (overrides.overlayNetworkId ?? process.env.DSHS_OVERLAY_NETWORK_ID ?? 'ops').trim() || 'ops',
overlayBootstrapSeeds:
overrides.overlayBootstrapSeeds ??
splitList(process.env.DSHS_OVERLAY_BOOTSTRAP_SEEDS) ??
DEFAULT_OVERLAY_BOOTSTRAP_SEEDS,
overlayDirTrustedKeys:
overrides.overlayDirTrustedKeys ?? splitList(process.env.DSHS_OVERLAY_DIR_PUBKEYS) ?? [],
overlayDirKeyFile: (overrides.overlayDirKeyFile ?? process.env.DSHS_OVERLAY_DIR_KEY ?? '').trim(),
overlayDirectoryCacheFile: (
overrides.overlayDirectoryCacheFile ??
process.env.DSHS_OVERLAY_DIR_CACHE ??
join(dataRoot, 'overlay', 'directory.json')
).trim(),
// 覆盖网络 序③:本机节点身份(一机一钥 + 入网凭据)。
// **两个都配了**才发起身份 ⇒ 不配 = 与今天行为一致(只做 HMAC);配了却读不出来 ⇒ 起动即抛
//(见 `net/relay/identity.ts#loadClientIdentity` —— 静默退化成"没身份"会让"凭据坏了"
// 表现成"一切正常",等 relay 一开强制就整台失联)。
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(),
}
}
+15 -5
View File
@@ -53,7 +53,7 @@ types.setTypeParser(20, (value: string) => Number(value))
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const HOST_COLS = 'id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat'
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
const INSTANCE_COLS =
'id, user_id, workspace_id, role, pid, port, status, started_at, last_exit, exit_code, last_error, folder, patch, '
+ 'host_id, epoch, heartbeat_at, lease_until' // v7 集群化归属/租约(T08 S2)—— 漏了它们会让 hostId 恒为 null
@@ -602,15 +602,25 @@ export class PgAdapter implements DbAdapter {
async upsertDshHost(input: UpsertDshHostInput): Promise<DshHost> {
try {
const { rows } = await this.pool.query(
`INSERT INTO dsh_hosts (id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat)
VALUES ($1, $2, $3, $4, 0, $5, NULL)
`INSERT INTO dsh_hosts (id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat)
VALUES ($1, $2, COALESCE($3::text, 'manager-ssh'), COALESCE($4::text, 'ops'), $5, $6, 0, $7, NULL)
ON CONFLICT(id) DO UPDATE SET
endpoint = excluded.endpoint,
via = COALESCE($3::text, dsh_hosts.via),
network_id = COALESCE($4::text, dsh_hosts.network_id),
agent_token = excluded.agent_token,
capacity_mb = excluded.capacity_mb,
status = excluded.status
RETURNING id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat`,
[input.id, input.endpoint, input.agentToken, input.capacityMb, input.status ?? 'up'],
RETURNING id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat`,
[
input.id,
input.endpoint,
input.via ?? null,
input.networkId ?? null,
input.agentToken,
input.capacityMb,
input.status ?? 'up',
],
)
return toDshHost(rows[0] as Record<string, unknown>)
} catch (e) {
+22 -5
View File
@@ -595,19 +595,36 @@ export function deleteBusinessPlugin(db: Database, id: string): boolean {
// ⚠️ local 模式**不调用**这些函数(`LocalSpawner` 靠进程内 Map + 单机互斥),
// 所以它们的存在不会改变现有单机行为。
const HOST_COLS = 'id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat'
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
/** 注册/更新一台 worker。join 幂等:同 id 重复执行 = 更新(并把它标回 `up`)。 */
/** 注册/更新一台 worker。join 幂等:同 id 重复执行 = 更新(并把它标回 `up`)。
*
* ⚠️ `via` 与 `networkId` **省略时不覆盖已有值**(`COALESCE` 到旧值):否则一次不带它们的
* join 会把回填好的 `local` / 已划定的网络归属冲回列默认值(那种漂移在换中继、建第二张网之前
* **看不出症状**)。
*/
export function upsertDshHost(db: Database, input: UpsertDshHostInput): DshHost {
prepare(db, `
INSERT INTO dsh_hosts (id, endpoint, agent_token, capacity_mb, used_mb, status, last_heartbeat)
VALUES (?, ?, ?, ?, 0, ?, NULL)
INSERT INTO dsh_hosts (id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat)
VALUES (?, ?, COALESCE(?, 'manager-ssh'), COALESCE(?, 'ops'), ?, ?, 0, ?, NULL)
ON CONFLICT(id) DO UPDATE SET
endpoint = excluded.endpoint,
via = COALESCE(?, dsh_hosts.via),
network_id = COALESCE(?, dsh_hosts.network_id),
agent_token = excluded.agent_token,
capacity_mb = excluded.capacity_mb,
status = excluded.status
`).run(input.id, input.endpoint, input.agentToken, input.capacityMb, input.status ?? 'up')
`).run(
input.id,
input.endpoint,
input.via ?? null,
input.networkId ?? null,
input.agentToken,
input.capacityMb,
input.status ?? 'up',
input.via ?? null,
input.networkId ?? null,
)
const row = prepare(db, `SELECT ${HOST_COLS} FROM dsh_hosts WHERE id = ?`).get(input.id)
return toDshHost(row as Record<string, unknown>)
}
+39
View File
@@ -345,6 +345,43 @@ CREATE INDEX IF NOT EXISTS idx_dsh_instances_host ON dsh_instances (host_id);
CREATE INDEX IF NOT EXISTS idx_dsh_instances_lease ON dsh_instances (lease_until);
`
// v8(覆盖网络 S2):`dsh_hosts.via` = **"这台 worker 经谁可达"**(`Reachability.via` 词表)。
//
// 为什么必须加列:现网两条 `endpoint` 字符串**同形、语义不同**,表里无法表达"经谁中转"
// (会合中继拆分方案 §2 C3)⇒ 换会合 / 中继组件时无处安放,越晚改代价越大。
//
// 加列**带默认值** `manager-ssh` ⇒ **先加列 → 再改代码 → 最后回填**,任一步中断都不崩;
// 旧代码只读 `endpoint`,完全不受影响(列可留着不删 ⇒ 零风险回滚)。
// ⚠️ 默认值对 `w-106`(隧道落点)正确、对 `w-47`(同机直连)**不正确** ⇒ 回填由部署步骤
// 显式 `UPDATE ... WHERE id='w-47'` 完成,**不写进迁移**(迁移是静态 SQL,写死 hostId 在别的部署上会错)。
const SQLITE_V8 = `
ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';
`
const PG_V8 = `
ALTER TABLE dsh_hosts ADD COLUMN via TEXT NOT NULL DEFAULT 'manager-ssh';
`
// v9(覆盖网络 ②·P0-1):`dsh_hosts.network_id` = **这台 worker 属于哪张网**。
//
// 为什么必须加列:relay 的会话表 / 端点表原先只按 `hostId` 索引 —— 即平台自己的 Worker 隧道与
// 未来的用户设备挤进**同一个扁平命名空间**。今天只有 1 张网,问题不显形;一进第二类节点就会变成
// 「一张巨网 + 靠 ACL 兜」,而写错一条 ACL 就泄露 ⇒ 与「**权限只准收窄**」直接冲突。
// ⇒ 网维度必须做成**结构性**的:控制面这一侧落成显式列(本迁移),数据面落成 relay 的
// 「按网络分桶白名单 + 同网校验」(`src/net/relay/server.ts`)。
//
// 加列**带默认值** `ops`(运维网)⇒ **存量行天然正确**(现网 47 / 106 / manager 全在运维网),
// 且顺序是「**先加列 → 再改代码 → 最后才谈回填**」,任一步中断都不崩;旧代码只读旧列,零影响。
// ⚠️ 与 v8 的 `via` 同一条纪律:**迁移里不写 `UPDATE ... WHERE id='...'`**(迁移是静态 SQL,
// 写死 hostId 在别的部署上会错)—— 真要改某台机的网络归属,由部署步骤显式 `UPDATE` 完成。
const SQLITE_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
`
const PG_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
`
interface Migration {
version: number
name: string
@@ -360,6 +397,8 @@ const MIGRATIONS: readonly Migration[] = [
{ version: 5, name: 'business plugin candidate pool', sqlite: SQLITE_V5, pg: PG_V5 },
{ version: 6, name: 'user model providers', sqlite: SQLITE_V6, pg: PG_V6 },
{ version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 },
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
+45
View File
@@ -5,6 +5,10 @@
* @module dshs/db/types
*/
// 覆盖网络 S2:`via` 列的**词表来源**统一在 `net/reachability`(同属 ③ 能力层 ⇒ 不破分层)。
// 这里只引一个常量,不引取址逻辑 —— `db` 不做网络判定。
import { VIA_MANAGER_SSH } from '../net/reachability.js'
export type UserRole = 'admin' | 'pending' | 'active' | 'disabled'
/** A full user row, including secrets (never serialized to clients). */
@@ -308,11 +312,38 @@ export function toBusinessPlugin(row: Record<string, unknown>): BusinessPlugin {
/** Worker 健康状态(`dsh_hosts.status` 的 CHECK 镜像)。 */
export type DshHostStatus = 'up' | 'draining' | 'down'
/**
* 节点默认所属网 = **运维网**(覆盖网络 P0-1)。
*
* ⚠️ 与 `src/net/relay/network.ts` 的 `OPS_NETWORK` 是**同一个词表的同一个值**,但本层
* (④基础层)**不 import 能力层**(分层纪律,`npm run check:layering` 会拦)⇒ 两边各写一次
* 字面量,**改动必须两处同改**。取值口径见 `network.ts`:`ops` | `u:<userId>`。
*/
export const DEFAULT_HOST_NETWORK = 'ops'
/** 一台承载用户实例的 worker(= 设计里的 Worker 节点)。 */
export interface DshHost {
id: string
/** agent 的内网地址,如 `10.0.1.11:9000`。 */
endpoint: string
/**
* **经谁可达**(覆盖网络 S2):`Reachability.via` 的词表值,指向一个 `Rendezvous` 实现。
*
* ⚠️ 两条现网记录的 `endpoint` 字符串同形、语义不同 ⇒ **不能靠 endpoint 猜**:
* · `w-47` = `127.0.0.1:19100` = **同机直连**(Manager 与 worker 同机)⇒ `local`
* · `w-106` = `127.0.0.1:19000` = **Manager 主机上 sshd 的反向隧道落点** ⇒ `manager-ssh`
*/
via: string
/**
* **属于哪张网**(覆盖网络 P0-1,`dsh_hosts.network_id`)。
*
* `ops` = 运维网(平台自己的机器:`manager` / `w-47` / `w-106`,以及未来的中继与骨干);
* `u:<userId>` = 该用户名下的全部设备。取值与 `src/net/relay/network.ts` 同一词表。
*
* 为什么它是**结构性**维度而不是又一个标签:relay 侧按 `<network>/<hostId>` 建会话、且
* `DIAL` 必须**同网** ⇒ 不同网络的节点之间连"能不能拨"这一步都走不到(写错配置也不会泄露)。
*/
networkId: string
/** 内部 HMAC 密钥(**只应存在于 DB 与 Manager 内存**,绝不经 API 返回)。 */
agentToken: string
/** 该机可用内存预算(MB);0 = 不承载实例(只做门户/控制)。 */
@@ -328,6 +359,13 @@ export interface DshHost {
export interface UpsertDshHostInput {
id: string
endpoint: string
/** 不传 ⇒ 走列默认值 `manager-ssh`(S2 加列时的默认,保证旧 join 脚本不改也能用)。 */
via?: string
/**
* 所属网(P0-1)。**不传 ⇒ 不覆盖已有值**(`COALESCE` 到旧值,与 `via` 同款),
* 新行走列默认值 `ops`。⇒ 旧 join 脚本一行不改也不会把某台机的网络归属冲掉。
*/
networkId?: string
agentToken: string
capacityMb: number
status?: DshHostStatus
@@ -356,6 +394,13 @@ export function toDshHost(row: Record<string, unknown>): DshHost {
return {
id: row.id as string,
endpoint: row.endpoint as string,
// S2:列有默认值 ⇒ 行上必然有值;仍做一次兜底,避免"旧库 + 新代码"组合下出现 undefined。
via: ((row.via as string | null) ?? '') === '' ? VIA_MANAGER_SSH : (row.via as string),
// P0-1:列有默认值 ⇒ 行上必然有值;仍做一次兜底("旧库 + 新代码"组合下不出现 undefined)。
networkId:
((row.network_id as string | null) ?? '') === ''
? DEFAULT_HOST_NETWORK
: (row.network_id as string),
agentToken: row.agent_token as string,
capacityMb: (row.capacity_mb as number | null) ?? 0,
usedMb: (row.used_mb as number | null) ?? 0,
+6
View File
@@ -15,6 +15,11 @@ import { userRoot } from './workspace.js'
export interface ClusterFsRouting {
hostIdFor?: (userId: string) => Promise<string | undefined>
agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined
/**
* `agentFor` 未命中时的**按需补齐**钩子(2026-09-16 加,覆盖网络线缺陷 A1)。
* 不传 = 未命中直接失败关闭(503 `host_unresolved`),**不会**再静默回退默认机。
*/
ensureHost?: (hostId: string) => Promise<void>
}
/**
@@ -31,6 +36,7 @@ export function createUserFs(config: ServerConfig, routing: ClusterFsRouting = {
workerDataRoot: config.clusterWorkerDataRoot === '' ? config.dataRoot : config.clusterWorkerDataRoot,
hostIdFor: routing.hostIdFor,
agentFor: routing.agentFor,
ensureHost: routing.ensureHost,
})
}
return new LocalUserFs((userId) => userRoot(config.dataRoot, userId))
+46 -8
View File
@@ -36,6 +36,15 @@ export interface RemoteUserFsOptions {
hostIdFor?: (userId: string) => Promise<string | undefined>
/** hostId → 接入信息(与 RemoteSpawner 用**同一份**目录,避免两套漂移)。 */
agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined
/**
* `agentFor` **未命中**时的按需补齐钩子(2026-09-16 加,覆盖网络线缺陷 A1)。
*
* 为什么需要:`agentFor` 是同步查表,而那张表(`server.ts` 的 `hostDirectory`)是**惰性**的
* —— 唯一写入者是 `hostsProvider()`,此前只有 `RemoteSpawner.ensureHosts()` 会调它。
* ⇒ Manager 重启后若用户先碰**文件面**,表里只有"本机",别的机的用户就会取不到地址。
* 传了这个钩子,取不到时会先补一次表再判,而不是直接掉进下面的失败关闭。
*/
ensureHost?: (hostId: string) => Promise<void>
/** **worker 上**的 dataRoot(必须与该 worker 一致,用于 `resolvePath` 的路径数学)。 */
workerDataRoot: string
/** 单次请求超时(ms)。文件可能较大,默认 30 s。 */
@@ -52,30 +61,59 @@ export class RemoteUserFs implements UserFs {
private readonly doFetch: typeof fetch
private readonly hostIdFor?: (userId: string) => Promise<string | undefined>
private readonly agentFor?: (hostId: string) => { agentUrl: string; token: string } | undefined
private readonly ensureHost?: (hostId: string) => Promise<void>
constructor(options: RemoteUserFsOptions) {
this.base = options.agentUrl.replace(/\/$/, '')
this.token = options.token
this.hostIdFor = options.hostIdFor
this.agentFor = options.agentFor
this.ensureHost = options.ensureHost
this.workerDataRoot = options.workerDataRoot
this.timeoutMs = options.timeoutMs ?? 30_000
this.doFetch = options.fetchImpl ?? fetch
}
/** 解析该用户文件操作应打的那台 agent(查不到归属就用默认)。 */
/**
* 解析该用户文件操作应打的那台 agent。
*
* ## 归属已知却取不到地址 ⇒ **抛错,绝不回退默认机**(覆盖网络线缺陷 A1,2026-09-16 修)
*
* 旧行为是三种失败(查库抛错 / 没给 `agentFor` / `agentFor` 查不到)**一律静默回退默认机**。
* 为什么这是缺陷:默认机 = Manager 所在那台,它对**别的机**的用户只会给出两种答案 ——
* ① 那台 agent 上没有这个用户 ⇒ `{error:"not_found"}`,与"**文件夹不存在**"**完全同形**
* (用户读成"我的文件丢了",而真因是"请求根本没出这台机");
* ② 若本地恰好有同名目录 ⇒ 直接把文件写进**一份没人在看的副本**(更糟的静默写坏)。
* 实测现场:Manager 重启后 `hostDirectory` 尚未被 `hostsProvider()` 填充,w-106 用户点启动
* 连发 3 次 **全 404,且 relay 零 `DIAL`、拨号池零落点** ⇒ 请求根本没出去。
* **判别器 = 看 relay 有没有 `DIAL`**(本机单测打在 `fetch` 上,断言"没打默认机")。
*
* 只有**"确实还没有归属"**(`hostIdFor` 正常返回 `undefined`)才用默认 agent ——
* 那是设计内的单机 / 首次触达路径(见 `hostIdForFile` 的粘性说明),不是错误。
*/
private async target(userId: string): Promise<{ base: string; token: string }> {
if (this.hostIdFor === undefined) return { base: this.base, token: this.token }
let hostId: string | undefined
try {
const hostId = await this.hostIdFor(userId)
if (hostId !== undefined && hostId !== null && this.agentFor !== undefined) {
const agent = this.agentFor(hostId)
if (agent !== undefined) return { base: agent.agentUrl.replace(/\/$/, ''), token: agent.token }
}
hostId = (await this.hostIdFor(userId)) ?? undefined
} catch {
// 查库失败 ⇒ 落回默认 host(宁可"可能读错机",也不要整个文件面 500)
// 归属都查不出来 ⇒ 无法判断该打哪台 ⇒ 失败关闭(旧行为在这里静默打默认机)
throw new UserFsError('host_unresolved')
}
return { base: this.base, token: this.token }
if (hostId === undefined || hostId === '') return { base: this.base, token: this.token }
let hit = this.agentFor?.(hostId)
if (hit === undefined && this.agentFor !== undefined && this.ensureHost !== undefined) {
// 未命中往往只是"表还没被填"(重启窗口期)⇒ 先按需补齐一次再判。
// 这一步让下面的失败关闭**不波及正常的冷启动请求**(否则只是把"假 404"换成"真 503")。
try {
await this.ensureHost(hostId)
} catch {
/* 补齐失败 ⇒ 交给下面的失败关闭:带 hostId 的明确错误,好过静默打错机 */
}
hit = this.agentFor(hostId)
}
if (hit === undefined) throw new UserFsError('host_unresolved')
return { base: hit.agentUrl.replace(/\/$/, ''), token: hit.token }
}
/** 统一的 POST:把 agent 的 `{error: code}` 还原成 `UserFsError`(路由按 code 回前端)。 */
+5
View File
@@ -26,6 +26,10 @@ export type UserFsErrorCode =
| 'too_large'
// 该实现不支持(如 k8s sidecar 尚无 read 端点,档案 19 §C8 未验证路径)
| 'unsupported'
// 覆盖网络线缺陷 A1(2026-09-16):**归属的机器已知,却取不到它的接入信息**(或归属本身
// 查不出来)⇒ 503。刻意与 `not_found` 分开:`not_found` 会被读成"文件夹不存在"(数据丢了),
// 而这里的事实是"这次连该打哪台都没定下来",属于**可重试**的服务侧状态。
| 'host_unresolved'
/** HTTP status each code maps to (unchanged from the pre-seam routes). */
const STATUS: Record<UserFsErrorCode, number> = {
@@ -38,6 +42,7 @@ const STATUS: Record<UserFsErrorCode, number> = {
not_a_file: 400,
too_large: 413,
unsupported: 501,
host_unresolved: 503,
}
/**
+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' }
}
}
+5 -2
View File
@@ -37,7 +37,7 @@ import {
type CrashPolicyConfig,
} from './crash-policy.js'
import { createPortGuard, type PortGuard } from './firewall.js'
import { findFreePort, scrubEnv } from './spawn.js'
import { findInstancePort, scrubEnv } from './spawn.js'
import {
AlreadyRunningError,
CrashBreakerOpenError,
@@ -592,7 +592,10 @@ export class LocalSpawner implements Spawner {
private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> {
const isMain = role === 'main'
const port = isMain ? await findFreePort() : undefined
// 覆盖网络 S3:端口来自**本机专属区间**(未配 env ⇒ 退回旧的 `listen(0)`)。
// 为什么不能用 `listen(0)`:跨机实例的隧道落点全挤在 Manager 的 `127.0.0.1`,
// 两台 worker 各自随机取端口就会撞号,而 `-R` 失败是**静默**的 ⇒ 会拨到别人的实例。
const port = isMain ? await findInstancePort(this.config.instancePortBase, this.config.instancePortSpan) : undefined
const instance: Instance = {
id: randomUUID(),
userId,
+38 -4
View File
@@ -42,6 +42,14 @@ export interface ClusterHost {
token: string
/** 代理时使用的主机(同机 1a = `127.0.0.1`;跨机填 Worker 内网 IP)。 */
instanceHost?: string
/**
* **表里声明的会合形态**(`dsh_hosts.via` 原文,P0-3 加)。
*
* 只有一处用途:`agentBaseUrlOf()` 在 `via='relay'` 且**解析不出落点**时**拒绝回落到
* `agentUrl`**(relay 语义下 `endpoint` 是落点而非直连地址,回落会打到本机同号端口)。
* 省略 = 不触发该保护(同机 `clusterHostId` 那条就是这一类)。
*/
via?: string
}
export interface RemoteSpawnerOptions {
@@ -76,6 +84,18 @@ export interface RemoteSpawnerOptions {
hostsProvider?: () => Promise<ClusterHost[]>
/** 目录缓存时长(ms)。默认 30 s —— 与心跳同量级。 */
directoryTtlMs?: number
/**
* **实例端点翻译器**(覆盖网络 R4):把"Worker 视角的 `{host, port}`"翻成"Manager 能拨的
* `{host, port}`"。省略 = 不翻译(同号语义,即 ssh 隧道与同机形态的既有行为)。
*
* 为什么翻译必须发生在**拨号的那一端**:relay 为每个端口在**它自己的回环**上开一条监听,
* 口号是动态分配的 ⇒ 只有 Manager 知道"Worker 的 21000 此刻对应我本机的 42067"。
* 让 Worker 去上报这个口号,等于把"Manager 侧地址观"塞进 Worker(relay 换机部署时两头都要改)。
*
* 返回 `undefined` = **翻译不出来**(relay 里查不到该端口 / 该 host 不是 relay 形态)⇒
* 调用方按"实例不可达"处理,**绝不回退成 Worker 侧原口号**(那会打到死地址上)。
*/
translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined
}
const RETRY_DELAYS_MS = [200, 1000, 3000]
@@ -90,6 +110,7 @@ export class RemoteSpawner implements Spawner {
private readonly hostIdFor?: (userId: string) => Promise<string | undefined>
private readonly hostsProvider?: () => Promise<ClusterHost[]>
private readonly directoryTtlMs: number
private readonly translateEndpoint?: (hostId: string, endpoint: Endpoint) => Endpoint | undefined
private directoryLoadedAt = 0
constructor(options: RemoteSpawnerOptions) {
@@ -112,6 +133,12 @@ export class RemoteSpawner implements Spawner {
this.hostIdFor = options.hostIdFor
this.hostsProvider = options.hostsProvider
this.directoryTtlMs = options.directoryTtlMs ?? 30_000
// ⚠️ 漏掉这一行的后果(2026-09-16 实测踩到):`endpointFor` 会**静默回退成原样透传** ⇒
// Manager 拿着 Worker 视角的口号(如 `127.0.0.1:21000`)往**自己本机**拨 ⇒ 连接被拒两次
// ⇒ 代理 `reply.raw.destroy()` ⇒ 浏览器只看到 **空响应**(`curl` 报 `Empty reply from server`),
// 而平台上**一行错误日志都没有**。这正是覆盖网络线要消灭的那类静默失败。
// 判据:`test/remote-spawner.test.mjs` 第 1 例(构造时就断言 `translateEndpoint` 生效)。
this.translateEndpoint = options.translateEndpoint
}
/**
@@ -187,11 +214,17 @@ export class RemoteSpawner implements Spawner {
operationId?: string,
): Promise<T> {
const payload = body === undefined ? undefined : { ...body, ...(operationId === undefined ? {} : { operationId }) }
/**
* 取址**放在重试循环外**(P0-3):`agentBaseUrlOf()` 现在**会抛**(`via=relay` 却解析不出落点
* ⇒ 拒绝回落到 endpoint)。留在循环里,这条**确定性错误**会被重试 3 次,
* 而且在日志里长得像"网络抖动" —— 排障时最费时间的那类假象。
*/
const base = agentBaseUrlOf(host)
let lastErr: unknown
for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt += 1) {
if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAYS_MS[attempt - 1]))
try {
const res = await this.doFetch(`${agentBaseUrlOf(host)}${path}`, {
const res = await this.doFetch(`${base}${path}`, {
method,
headers: {
[AGENT_TOKEN_HEADER]: host.token,
@@ -282,9 +315,10 @@ export class RemoteSpawner implements Spawner {
'GET',
`/endpoint/${encodeURIComponent(userId)}`,
)
return res.running && res.host !== undefined && res.port !== undefined
? { host: res.host, port: res.port }
: undefined
if (!res.running || res.host === undefined || res.port === undefined) return undefined
const raw: Endpoint = { host: res.host, port: res.port }
// R4:`relay` 形态下 Manager 侧的口号是 relay 动态分配的 ⇒ 必须在这里翻译(见选项注释)。
return this.translateEndpoint === undefined ? raw : this.translateEndpoint(host.hostId, raw)
}
async stop(userId: string, hostId?: string): Promise<void> {
+45
View File
@@ -64,3 +64,48 @@ export function findFreePort(): Promise<number> {
})
})
}
/**
* 在 `[base, base + span)` 内取一个空闲回环端口(覆盖网络 S3)。
*
* ## 为什么不能用 `listen(0)`
* `listen(0)` 让**每台 worker 各自**随机取端口,而所有跨机实例的**隧道落点全挤在 Manager
* 的 `127.0.0.1`** 上 ⇒ **两台 worker 取到同号就撞号**:`ssh -R` 失败被静默忽略
* (`tunnel.forward()` 的返回值无人检查),Manager 仍按该端口拨 ⇒ **打到别人的实例**
* (2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间即可根治。
*
* ⛔ 区间用尽 **抛错**,不静默退回 `listen(0)` —— 那等于把撞号风险悄悄放回来。
*/
export function findFreePortInRange(base: number, span: number): Promise<number> {
return new Promise((resolve, reject) => {
let candidate = base
const end = base + span
const attempt = (): void => {
if (candidate >= end) {
reject(new Error(`实例端口区间已耗尽:${base}-${end - 1}(span=${span})`))
return
}
const port = candidate
candidate += 1
const server = createServer()
server.once('error', () => attempt())
server.listen(port, '127.0.0.1', () => {
const address = server.address()
const got = typeof address === 'object' && address !== null ? address.port : undefined
server.close(() => {
if (got === undefined) attempt()
else resolve(got)
})
})
}
attempt()
})
}
/**
* 取实例端口的**统一入口**:配了区间(`base>0 && span>0`)就走区间,否则退回旧的 `listen(0)`。
* 单机 / 未配 env 的部署因此**行为零变化**。
*/
export function findInstancePort(base: number, span: number): Promise<number> {
return base > 0 && span > 0 ? findFreePortInRange(base, span) : findFreePort()
}
+18 -2
View File
@@ -168,6 +168,9 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
hosts: hosts.map((h) => ({
id: h.id,
endpoint: h.endpoint,
// 覆盖网络 S2:`via` 必须**可见** —— 否则"经谁可达"这件事运维查不出来,
// 加列就等于白加(迁移出问题时第一眼就要能看见)。
via: h.via,
capacityMb: h.capacityMb,
usedMb: h.usedMb,
status: h.status,
@@ -182,6 +185,8 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const body = request.body as {
id?: string
endpoint?: string
/** 覆盖网络 S2:**经谁可达**(`local` / `manager-ssh` / 未来的 `relay:<id>`)。省略 = 列默认。 */
via?: string
token?: string
capacityMb?: number
}
@@ -191,13 +196,24 @@ export const adminRoutes: FastifyPluginAsync = async (app) => {
const host = await app.db.upsertDshHost({
id: body.id,
endpoint: body.endpoint,
via: body.via,
agentToken: body.token,
capacityMb: Number(body.capacityMb ?? 0),
})
await app.db.audit(request.user?.id ?? null, 'host.upsert', JSON.stringify({ id: host.id, endpoint: host.endpoint }))
await app.db.audit(
request.user?.id ?? null,
'host.upsert',
JSON.stringify({ id: host.id, endpoint: host.endpoint, via: host.via }),
)
return {
ok: true,
host: { id: host.id, endpoint: host.endpoint, capacityMb: host.capacityMb, status: host.status },
host: {
id: host.id,
endpoint: host.endpoint,
via: host.via,
capacityMb: host.capacityMb,
status: host.status,
},
}
})
+83
View File
@@ -0,0 +1,83 @@
/**
* 覆盖网络 P0-2:**引导目录端点** `GET /dshs-overlay/bootstrap`(**只读 + 无鉴权**)。
*
* ## 为什么必须无鉴权
*
* 它服务的对象正是「**还没有任何凭据**的新节点」(首次入网)⇒ 不能要求登录。
* 因此它的暴露面必须**小到可以不设防**,这份目录里只允许出现四类东西:
*
* | 字段 | 说明 |
* |---|---|
* | `version` / `issuedAt` / `refreshAfterSeconds` | 结构与缓存策略(**被签名覆盖**) |
* | `network` | 该目录所属的网(`ops`) |
* | `relays[]` | 中继端点(**只放公网 / 域名形态**,过滤回环与私网) |
* | `bootstrap[]` | **可轮换的引导地址清单** |
*
* ⛔ 目录里**不出现**:hostId、任何密钥、用户数据、内网地址、实例端口、版本号之外的内部标识。
* 连上之后**能拨谁**由 relay 侧的「网维度 + 白名单」(P0-1)决定 —— 目录只管"去哪儿"。
*
* ## 失败关闭
*
* 没有配签名私钥 / 私钥读不出来 / 签名抛错 ⇒ **`503 directory-unavailable`**,
* **绝不返回未签名目录**(否则"签名不对就拒绝"这条客户端判据会被一个半成品端点绕过)。
*
* 缓存策略 = `no-store`:目录**故意允许被轮换**,让 CDN / 浏览器留一份旧副本会把轮换能力拖死
* (客户端自己按 `refreshAfterSeconds` 缓存,不需要中间层再插一手)。
* @module dshs/web/routes/overlay
*/
import type { FastifyPluginAsync } from 'fastify'
import { readFileSync } from 'node:fs'
import type { ServerConfig } from '../../config.js'
import {
DIRECTORY_PATH,
buildDirectoryDocument,
publicRelayEntries,
signDirectory,
type OverlayDirectory,
} from '../../net/relay/directory.js'
/** 组装并签发目录;任一步不可用 ⇒ `undefined`(调用方回 503,**不发未签名目录**)。 */
function buildSignedDirectory(config: ServerConfig): { doc: OverlayDirectory; sig: string } | undefined {
const log = (line: string): void => {
process.stdout.write(`${line}\n`)
}
if (config.overlayDirKeyFile === '') {
log('[overlay-dir] ⛔ 未配 DSHS_OVERLAY_DIR_KEY ⇒ 目录端点返回 503(**不发未签名目录**)')
return undefined
}
let pem: string
try {
pem = readFileSync(config.overlayDirKeyFile, 'utf8')
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`[overlay-dir] ⛔ 读不到签名私钥 ${config.overlayDirKeyFile}(${msg})⇒ 503`)
return undefined
}
// `relays[]` 优先用**显式配置的中继入口**,其次用种子(种子本身就是中继入口 ⇒ 语义自洽)。
const candidates = config.relayUrl !== '' ? [config.relayUrl, ...config.overlayBootstrapSeeds] : [...config.overlayBootstrapSeeds]
const relays = publicRelayEntries(candidates)
const bootstrap = publicRelayEntries(config.overlayBootstrapSeeds)
try {
const doc = buildDirectoryDocument({
relays,
bootstrap,
network: config.overlayNetworkId,
now: Date.now(),
})
return { doc, sig: signDirectory(doc, pem) }
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
log(`[overlay-dir] ⛔ 目录组装/签发失败(${msg})⇒ 503`)
return undefined
}
}
export const overlayRoutes: FastifyPluginAsync = async (app) => {
app.get(DIRECTORY_PATH, async (_request, reply) => {
reply.header('cache-control', 'no-store')
const built = buildSignedDirectory(app.config)
if (built === undefined) return reply.code(503).send({ error: 'directory-unavailable' })
return { ...built.doc, sig: built.sig }
})
}
+472 -3
View File
@@ -17,7 +17,16 @@ import { RemoteUserFs } from '../fs/remote-user-fs.js'
import type { UserFs } from '../fs/user-fs.js'
import { decrypt, deriveKey } from '../crypto.js'
import { hashUid } from '../isolation.js'
import { agentBaseUrlOf } from '../net/reachability.js'
import { addressPort, agentBaseUrlOf, parseReachability, VIA_MANAGER_SSH, VIA_RELAY } from '../net/reachability.js'
import { LocalRendezvous, ManagerSshRendezvous, RendezvousRegistry } from '../net/rendezvous.js'
import { OPS_NETWORK, logicalName } from '../net/relay/network.js'
import { RelayRendezvous } from '../net/relay/rendezvous.js'
import { RelayDialer } from '../net/relay/dialer.js'
import { listOverlayRelayCandidates, resolveOverlayRelay } from '../net/relay/directory.js'
import { RelayClient, waitUpOnStatus } from '../net/relay/client.js'
import { loadClientIdentity } from '../net/relay/identity.js'
import { RelayFailoverSupervisor, relayFailoverThresholds } from '../net/relay/switcher.js'
import type { RelayChannelHandle } from '../net/relay/switcher.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { LeasedSpawner } from '../supervisor/leased-spawner.js'
import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js'
@@ -42,6 +51,7 @@ import { businessPluginRoutes } from './routes/business-plugins.js'
import { desktopRoutes } from './routes/desktop.js'
import { dshRoutes } from './routes/dsh.js'
import { domainRoutes } from './routes/domain.js'
import { overlayRoutes } from './routes/overlay.js'
import { skillRoutes } from './routes/skills.js'
import { whitelistRoutes } from './routes/whitelist.js'
@@ -59,6 +69,20 @@ declare module 'fastify' {
const webRoot = join(dirname(fileURLToPath(import.meta.url)), '../../web')
/* ── 覆盖网络 R3:relay 状态视图的三个时间常数(都在 Manager 本地回环上,量级取"秒")── */
/** 单次 `/status` 查询超时。relay 在回环上 ⇒ 超过这个数就是它卡了,不值得等。 */
const RELAY_STATUS_TIMEOUT_MS = 2000
/** 轮询间隔。取 5s:`dsh_hosts` 目录本身 TTL 是 30s,比它更密即可。 */
const RELAY_STATUS_POLL_MS = 5000
/** 快照陈旧阈值:超过它一律认为 relay 视图不可信 ⇒ `relay` 实现退回 `manager-ssh`。 */
const RELAY_SNAPSHOT_TTL_MS = 15000
/* ── 覆盖网络 P0-2:引导目录的后台刷新节奏 ── */
/** 首次刷新延迟。**必须 > 0**:控制面自己既是目录服务端又是客户端,启动瞬间自己的 3080 还没 listen。 */
const OVERLAY_REFRESH_FIRST_MS = 15000
/** 刷新周期下限(目录给的 `refreshAfterSeconds` 更小也按它兜底,避免退化成"每秒取一次")。 */
const OVERLAY_REFRESH_MIN_MS = 60000
/** Whether an `Origin` header belongs to the platform base domain (or a
* per-user subdomain of it). Used to allow cross-subdomain API calls from dsh
* instances (功能插件启停). */
@@ -254,13 +278,397 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
token: config.clusterAgentToken,
instanceHost: config.clusterInstanceHost,
})
/**
* 会合解析(覆盖网络 S2):**`via` → `Rendezvous` 实现 → `Reachability`**。
*
* 为什么要有这一层:`endpoint` 只说得清"拨哪个地址",说不清"**经谁**" —— 而现网两条
* 记录的 endpoint 恰好**字符串同形、语义不同**(`w-47` 同机直连 / `w-106` 隧道落点)。
* `via` 列把"经谁"显式化 ⇒ 换中继 / 会合时不必改表语义(会合中继拆分方案 §2 C3)。
*
* ⚠️ **S2 阶段行为零变化**:两种现役实现的 `resolve()` 都只把 endpoint 拆成
* `{scheme, address}`,`agentBaseUrlOf()` 拼回来与旧 `agentUrl` **逐字相等**
* (判据写死在 `test/reachability.test.mjs`,不靠肉眼比)。
*
* 🔑 **键是逻辑名** `<network>/<hostId>`(P0-3)—— 网络维度是**结构性**的,控制面的每一张
* 内存表都必须带着它;只按裸 hostId 建键时,两张网的同名 host 会**互相覆盖**(静默串网)。
*/
const hostAddresses = new Map<string, string>()
/**
* 每台 host 的 **agent 端口**(从 `dsh_hosts.endpoint` 剥出来的那个号码)。
*
* 为什么需要(覆盖网络 R3):relay 为**每个注册端口**在它自己的回环上开一条监听,回环口号
* 由 `listen(0)` **动态分配**(实测 42067)⇒ Manager 既拿不到也推不出,只能拿「要拨的端口号」
* 去 relay 的 `/status` 里查回环口号。而 agent 端口与 ssh 隧道落点**同号**(隧道就是同号反向
* 转发,见 `worker/tunnel.ts#forward`)⇒ 这个号码现有表里就有,**不需要新增列**。
*
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
*/
const hostAgentPorts = new Map<string, number>()
/**
* 每台 host **表里声明的会合形态**(`dsh_hosts.via` 原文),与 `reachability` 分开存。
*
* 为什么不能用 `reachability` 反推(R4 实测教训):`Reachability` 是**实时解析结果**,
* host 离线 / relay 快照陈旧时 `resolve()` 会回 `undefined`。若此时按"不是 relay ⇒ 原样透传"
* 处理,就会把 **Worker 侧口号**打到 Manager 本机(连接被拒、空响应、无日志)。
* ⇒ 「这台该走哪种会合」必须读**表里的静态声明**;「此刻可拨到什么地址」才读 `reachability`。
*
* 🔑 **键是逻辑名**(P0-3),与 `hostAddresses` 同口径。
*/
const hostVia = new Map<string, string>()
/**
* relay 的实时视图(覆盖网络 R3):`<network>/<hostId>:<port>` → `{ localPort, online }`。
*
* ⚠️ **键是逻辑名**(P0-3):只按裸 hostId 建键时,两张网各有一台同名 host 会**互相覆盖**
* —— 后刷进来的那张网把前一张的落点顶掉 ⇒ 请求被静默路由到**另一张网**的节点上
* (这正是 Step 1 在 relay 侧修掉的病,控制面这一份当时还是旧的)。
*
* ⚠️ **只在刷新成功时整表替换**(不做增量合并):relay 重启后回环口号会**全部重分配**,
* 增量会把过期口号永远留在表里 ⇒ Manager 继续往一个已不存在的口上打。
* 这正是 ssh 版「静默打到别人实例」的同一类病,不能在替代品里复现。
* 刷新失败时**保留上一份**,靠 `relaySnapshotAt` 判陈旧(陈旧 ⇒ `online` 回 false ⇒ 自动回退 `manager-ssh`)。
*/
const relayEndpoints = new Map<string, { localPort: number; online: boolean }>()
let relaySnapshotAt = 0
const refreshRelay = async (): Promise<void> => {
if (config.relayStatusUrl === '') return
try {
const res = await fetch(config.relayStatusUrl, { signal: AbortSignal.timeout(RELAY_STATUS_TIMEOUT_MS) })
if (!res.ok) return
const body = (await res.json()) as { endpoints?: unknown }
const rows = Array.isArray(body.endpoints) ? body.endpoints : []
const next = new Map<string, { localPort: number; online: boolean }>()
for (const raw of rows) {
const ep = raw as {
hostId?: unknown
network?: unknown
port?: unknown
localPort?: unknown
online?: unknown
}
if (typeof ep.hostId !== 'string' || typeof ep.port !== 'number') continue
if (typeof ep.localPort !== 'number' || ep.localPort <= 0) continue
// `network` 缺失 = 老 relay(P0-1 之前)⇒ 按 `ops` 读(与 DB 的列默认值同口径)。
const network = typeof ep.network === 'string' && ep.network !== '' ? ep.network : OPS_NETWORK
next.set(`${logicalName(network, ep.hostId)}:${ep.port}`, {
localPort: ep.localPort,
online: ep.online === true,
})
}
relayEndpoints.clear()
for (const [k, v] of next) relayEndpoints.set(k, v)
relaySnapshotAt = Date.now()
} catch {
// relay 不可达 / 超时 ⇒ **保留上一份快照**(临时抖动不抖路由),由陈旧判定兜底。
}
}
if (config.relayStatusUrl !== '') {
void refreshRelay()
const relayTimer = setInterval(() => void refreshRelay(), RELAY_STATUS_POLL_MS)
relayTimer.unref()
}
/**
* 覆盖网络 R5:**Manager 侧拨号通道** —— 「会合可换机」的收口件(`会合中继拆分` §9.4 那个断点)。
*
* 配了 `DSHS_RELAY_DIAL_SECRET` ⇒ Manager 也像 worker 一样**只拨出**一条 wss,并把落点建在
* **自己的回环**上(`RelayDialer` 的预绑口池)⇒ **不再依赖 relay 主机的 `127.0.0.1`**,
* relay 可以放在任何一台机器上(也可以多实例)。
* 没配 ⇒ `relayDialer` 为 `undefined` ⇒ 全部回落到 `/status` 快照,**行为与 R3 完全一致**。
*
* 密钥必须是 64 位 hex(与 relay 密钥表同格式)。格式不对 ⇒ **不启用**并明确报一行 ——
* 而不是带着一个坏密钥去反复握手失败(那会变成刷日志的噪音)。
*/
/**
* 覆盖网络 P0-2:**relay 地址走引导三级链**(env 显式 > 缓存目录 > 内置种子)。
*
* 为什么必须在这里改:`config.relayUrl` 只可能来自 env ⇒ 域名 / 机器一换,
* **用户设备上的那个值我们改不到**,只能让所有人重装(`§A2` 点名的灾难)。
* 引导链把地址变成"可在线轮换的下发物";env 仍然**压制一切**(运维最后手段)。
* 取不到(返回空串)⇒ 与"未配 relay"**完全同行为**(落回 `/status` 快照 / `manager-ssh`)。
*/
const overlayLog = (line: string): void => {
process.stdout.write(`${line}\n`)
}
const relayResolved = await resolveOverlayRelay({
envUrl: config.relayUrl,
seeds: config.overlayBootstrapSeeds,
trustedKeys: config.overlayDirTrustedKeys,
cacheFile: config.overlayDirectoryCacheFile,
log: overlayLog,
})
const relayUrl = relayResolved.url
const dialLog = (line: string): void => {
process.stdout.write(`${line}\n`)
}
/** 拨号通道能不能起:地址有了 + 密钥是 64 位 hex。**任一条不满足 ⇒ 完全不启用**(与 R5 同语义)。 */
const secretOk = /^[0-9a-f]{64}$/.test(config.relayDialSecret)
const dialerEnabled = relayUrl !== '' && config.relayDialSecret !== '' && secretOk
if (relayUrl !== '' && config.relayDialSecret !== '' && !secretOk) {
dialLog('[relay-dialer] ⛔ DSHS_RELAY_DIAL_SECRET 不是 64 位 hex ⇒ 拨号通道**不启用**(落回 /status 快照)')
}
/**
* 起一条拨号通道。抽成函数是因为 P0-2 要在**运行期换址**(见下面的后台刷新)。
*
* **先建新的、成功了再关旧的**:新通道任何一步失败都直接返回 `undefined`、旧通道原样保留
* —— 换址失败绝不能把"本来能用"的跨机通路打掉。
*/
const startDialer = async (
url: string,
): Promise<{ client: RelayClient; dialer: RelayDialer } | undefined> => {
const client = new RelayClient({
url,
hostId: config.relayDialHost,
secret: config.relayDialSecret,
ports: [],
dialer: true,
/**
* 序③:Manager 也是**一台机器**,同样要证明"被授权进入这张网"。
* 与 worker 侧同一装配入口(`loadClientIdentity`)⇒ 两侧只有一种写法。
*/
identity: loadClientIdentity({
keyFile: config.overlayNodeKeyFile,
grantFile: config.overlayNodeGrantFile,
log: dialLog,
}),
log: dialLog,
})
client.start()
const dialer = new RelayDialer({
client,
portBase: config.relayDialPortBase,
portSpan: config.relayDialPortSpan,
poolSize: config.relayDialPool,
log: dialLog,
})
// **必须 await**:口池绑完之前 `localPortFor()` 恒返回 undefined(失败关闭)⇒
// 不 await 就会在启动瞬间把"能给地址"缩成一个窗口期。
try {
await dialer.start()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
dialLog(`[relay-dialer] ⛔ 落点池绑定失败(${msg})⇒ 本条通道放弃,已有通道不受影响`)
try {
dialer.close()
} catch {
/* 已关 */
}
client.stop()
return undefined
}
return { client, dialer }
}
/**
* 序⑦:**通道句柄把 dialer 一起带着走** —— `relayDialer` 不再是一个独立可变量,
* 而是"监管器当前通道"的投影。这样"当前是谁"只有**一个**权威来源,
* ⛔ 不会出现"监管器已切到新通道、别处还拿着旧 dialer"的分叉。
*/
type C1Handle = RelayChannelHandle & { dialer: RelayDialer; client: RelayClient }
const toHandle = (
url: string,
started: { client: RelayClient; dialer: RelayDialer },
): C1Handle => ({
url,
dialer: started.dialer,
client: started.client,
health: () => {
const st = started.client.status()
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
},
close: () => {
try {
started.dialer.close()
} catch {
/* 已关 */
}
try {
started.client.stop()
} catch {
/* 已关 */
}
},
})
const overlayResolveOpts = (): Parameters<typeof listOverlayRelayCandidates>[0] => ({
envUrl: config.relayUrl,
seeds: config.overlayBootstrapSeeds,
trustedKeys: config.overlayDirTrustedKeys,
cacheFile: config.overlayDirectoryCacheFile,
log: overlayLog,
})
/** 序⑦ 切流阈值(唯一一份默认值在 `switcher.ts`;这里只是取一份实例)。 */
const failoverThresholds = relayFailoverThresholds()
/**
* 换址版等待:**非抛**,只回答"通没通"(`open` 要的是布尔)。
*
* 🔴 **序⑨ · RC-1(C1 装配点)**:实现已抽到 `net/relay/client.ts#waitUpOnStatus`
* (三个装配点共用一份,D7)。相对改造前的**唯一**差别 = **终态失败(死候选)立即返回 `false`**,
* ⛔ 不再白等满 `upTimeoutMs`。
* ⚠️ 为什么这里最痛:生产目录前两条候选**同在 47**(见下面 `open` 的注释)⇒ 每次从 47 切走
* **必然**先试同机的 `relay-direct`(已随 47 一起死)⇒ 白等 ≡ `upTimeoutMs`(12 000 ms),
* 实测两样本逐行复核(序⑨ §2-P10)。**慢候选(连得上、只是 `up` 来得晚)不受影响**。
*/
const waitUpOn = (client: RelayClient, timeoutMs: number): Promise<boolean> =>
waitUpOnStatus(client, timeoutMs, {
onDead: (st) =>
overlayLog(
`[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` +
`burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`,
),
})
/**
* **中继失败切流的唯一实现**(序⑦ · C1 装配点)。
*
* 触发信号全部复用现成状态机(D2):`RelayClient.status()` 的 `state/attempts/unhealthyForMs`
* ——⛔ 不新造心跳、不新增探测帧。
*/
const failover = new RelayFailoverSupervisor({
/**
* ⚠️ **换址版的 `open` 比启动版严一档**:启动只要"口池绑好"(`startDialer` 的语义,
* P0-2 要求启动不依赖网络);**换址**必须等新通道真到 `up` ——
* 否则会把"口池绑好了"误当成"通了",切到一条**同样连不上**的中继上
* (生产目录前两条候选落在同一台机器,这个坑一定会踩到)。
* 到不了 `up` ⇒ 关掉它、返回 `undefined` ⇒ 监管器保持原通道并把该候选进冷却(D4 + 链推进)。
*/
open: async (url) => {
const started = await startDialer(url)
if (started === undefined) return undefined
if (!(await waitUpOn(started.client, failoverThresholds.upTimeoutMs))) {
try {
started.dialer.close()
} catch {
/* 已关 */
}
started.client.stop()
return undefined
}
return toHandle(url, started)
},
candidates: async () => (await listOverlayRelayCandidates(overlayResolveOpts())).urls,
log: overlayLog,
thresholds: failoverThresholds,
})
/** 当前拨号通道(由监管器的"当前通道"派生 —— 单一权威来源)。 */
const currentDialer = (): RelayDialer | undefined =>
(failover.channel as C1Handle | undefined)?.dialer
if (dialerEnabled) {
const started = await startDialer(relayUrl)
if (started !== undefined) failover.seed(toHandle(relayUrl, started))
/**
* 健康巡检**独立定时器**:目录刷新周期可能长达分钟级(`refreshAfterSeconds`),
* 而故障切换要在 `RELAY_FAILOVER_DEADLINE_MS`(30 s)内完成 ⇒ 两者节奏必须分开。
*/
failover.start()
}
/**
* 覆盖网络 P0-2:**后台刷新目录**(启动后再取一次,之后按目录给的刷新周期)。
*
* 为什么不能在启动时"一次算完":**控制面自己也是客户端**(47 同机既是目录签发方、又是
* Manager)⇒ 启动那一刻自己的 3080 还没 `listen`,取目录必然 `502`;若就此定死,
* Manager 就**永远**拿不到目录里 `relays[]` / `bootstrap[]` 的轮换结果(能力等于没有)。
* 所以分成两步:**启动只用"缓存 / 种子"起来(启动不依赖网络)**,随后后台再取一次目录;
* 地址**真的变了**才换通道(无变化时零动作、零日志噪音)。
*
* 序⑦:换址动作统一走 {@link RelayFailoverSupervisor.replace}("先建新、成功再关旧" +
* 冷却表 + `[relay-switch]` 日志 + `switches` 计数),⛔ 不再在本函数里自己关旧通道。
*/
const refreshOverlay = async (): Promise<void> => {
if (!dialerEnabled) return
const next = await resolveOverlayRelay(overlayResolveOpts())
const cur = failover.channel?.url ?? relayUrl
if (next.url === '' || next.url === cur) return
overlayLog(`[overlay-dir] 🔁 目录给出的地址变了:${cur} -> ${next.url}(source=${next.source})⇒ 换拨号通道`)
/**
* 序⑧(D1):显式声明 `'directory'` —— 这条换址的触发条件("目录里的地址变了")与
* "旧通道是否可用"**无关** ⇒ ⛔ **没有打破冷却的权力**(有的话,"当前站在 106、目录首位是 47"
* 的每一轮巡检都会把刚冷却的 47 换回来 = 两位互相抢 = D5 想防的抖动风暴)。
*/
await failover.replace(next.url, `目录地址变更(source=${next.source})`, 'directory')
}
if (dialerEnabled) {
const first = setTimeout(() => void refreshOverlay(), OVERLAY_REFRESH_FIRST_MS)
first.unref()
const timer = setInterval(
() => void refreshOverlay(),
Math.max(OVERLAY_REFRESH_MIN_MS, relayResolved.refreshAfterSeconds * 1000),
)
timer.unref()
}
const rendezvous = new RendezvousRegistry([
new LocalRendezvous((id) => hostAddresses.get(id)),
new ManagerSshRendezvous({
target: config.clusterRendezvousUrl,
addressOf: (id) => hostAddresses.get(id),
}),
// `relay`(R3 / R5):**既没配 `/status`、也没启用拨号通道 ⇒ 不注册** ⇒ `via='relay'`
// 回落到 `manager-ssh`(`hostsProvider` 的回退分支),即"没配就完全等同今天"。
...(config.relayStatusUrl === '' && currentDialer() === undefined
? []
: [
new RelayRendezvous({
dialTargetUrl: relayUrl,
// 键一律是**逻辑名** `<network>/<hostId>`(P0-3):跨网同名 host 不会互相命中。
addressOf: (name) => {
const port = hostAgentPorts.get(name)
if (port === undefined) return undefined
// ① 首选**拨号通道**:落点在 Manager 自己本机 ⇒ relay 换机器也成立(R5)
// ⚠️ 这里同时是**控制面侧的跨网门**:通道只声明一张网,跨网申请**必然回 undefined**
// (`RelayDialer` 里带日志地拒掉)⇒ 绝不会把别张网的落点发出去。
const dialed = currentDialer()?.localPortFor(name, port)
if (dialed !== undefined) return `127.0.0.1:${dialed}`
// ② 回退:relay 快照里的动态回环口号(仅在 relay 与 Manager 同机时可用)
const hit = relayEndpoints.get(`${name}:${port}`)
return hit === undefined ? undefined : `127.0.0.1:${hit.localPort}`
},
online: (name) => {
const port = hostAgentPorts.get(name)
if (port === undefined) return false
/**
* ① **有新鲜快照** ⇒ 沿用 R3 的「先问在线、再给地址」:能明确区分"离线"与"端口没了",
* 失败语义更准(离线走 `undefined`,不必真去拨一次)。
*/
if (Date.now() - relaySnapshotAt <= RELAY_SNAPSHOT_TTL_MS) {
const hit = relayEndpoints.get(`${name}:${port}`)
if (hit !== undefined) return hit.online === true
}
/**
* ② **没有快照**(relay 在别的机器上 / 未配 `/status`)⇒ 交给**拨号本身**判定:
* 离线会拿到 `target-offline`,是**带原因的显式失败**,不会静默路由到别处。
*/
return currentDialer() !== undefined
},
}),
]),
])
const hostsProvider = async (): Promise<ClusterHost[]> => {
for (const row of await db.listDshHosts()) {
const rows = await db.listDshHosts()
/**
* **逻辑名 = 控制面里"这台 host"的唯一键**(P0-3)。
*
* 网络维度取自 `dsh_hosts.network_id`(P0-1 迁的列,**本步才真正被消费**)——
* 在此之前它只是"表里有、没人读",于是控制面所有键(地址表 / via 表 / 端口表 /
* relay 端点表 / 拨号口池)都按**裸 hostId**,两张网各有一台同名 host 时会**互相覆盖**。
*/
const nameOf = (row: { id: string; networkId: string }): string =>
logicalName(row.networkId === '' ? OPS_NETWORK : row.networkId, row.id)
// 先同步地址表(会合实现不直接连 DB),再逐行解析 —— 两遍是为了让 `resolve()` 只看纯内存表。
for (const row of rows) {
const name = nameOf(row)
const address = parseReachability(name, row.endpoint, row.via).address
hostAddresses.set(name, address)
hostVia.set(name, row.via)
const port = addressPort(address)
if (port === undefined) hostAgentPorts.delete(name)
else hostAgentPorts.set(name, port)
}
for (const row of rows) {
// `via` 认不出来(老行 / 手写错值)⇒ 回退到"今天唯一在跑的实现",**不抛**(过渡期要能跑)。
const impl = rendezvous.get(row.via) ?? rendezvous.get(VIA_MANAGER_SSH)
hostDirectory.set(row.id, {
hostId: row.id,
agentUrl: row.endpoint,
agentUrl: row.endpoint, // 保留:`agentBaseUrlOf` 的回退路径,S2 阶段与 reachability 等价
reachability: impl === undefined ? undefined : await impl.resolve(nameOf(row)),
token: row.agentToken,
instanceHost: config.clusterInstanceHost,
// 表里的声明(P0-3):`agentBaseUrlOf` 靠它区分"解析不出"与"可以回落 endpoint"。
via: row.via,
})
}
return [...hostDirectory.values()]
@@ -326,6 +734,32 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
resolveUid,
// 按 host 路由:每次操作都落到"该用户实例所在那台"(与文件面同一份)
hostIdFor: hostIdForUser,
/**
* 覆盖网络 R4:**实例端点的 relay 翻译**。
*
* `via='relay'` 的 host,其实例在 Worker 上监听 `127.0.0.1:<实例端口>`,而 Manager
* 要拨的是 relay 为那个端口开的**动态回环口号** ⇒ 必须按 `(hostId, 实例端口)` 查
* relay 快照翻译。查不到就回 `undefined`(= 实例不可达)—— **绝不原样透传**,
* 那会拨到一个"在 Manager 上毫无意义"的端口上。
*
* 非 `relay` 形态(`local` / `manager-ssh`)**原样返回**:隧道是同号反向转发,
* 两边口号相同 ⇒ 不需要也没法翻译(这正是 S3 端口区间隔离能根治撞号的前提)。
*
* ⚠️ 判据必须取 **`dsh_hosts.via` 原文**(`hostVia`),**不能**取 `reachability.via`:
* 后者在 host 离线 / relay 快照陈旧时为 `undefined`,若据此判"不是 relay ⇒ 原样透传",
* 就会**失败开放** —— 把 Worker 侧口号打到 Manager 本机(2026-09-16 实测:浏览器只见
* 空响应、平台零日志)。未知 host 仍按老行为原样返回(单机 / 默认 host 不受影响)。
*/
translateEndpoint: (hostId, ep) => {
if (hostVia.get(hostId) !== VIA_RELAY) return ep
// ① 首选**拨号通道**(R5):落点在 Manager 自己本机 ⇒ relay 换机器也成立
const dialed = currentDialer()?.localPortFor(hostId, ep.port)
if (dialed !== undefined) return { host: '127.0.0.1', port: dialed }
// ② 回退:relay 快照里的动态回环口号。查不到 ⇒ **失败关闭**(实例此刻不可达),
// 绝不回退成 Worker 侧口号 —— 那正是"浏览器只见空响应、平台零日志"的成因。
const hit = relayEndpoints.get(`${hostId}:${ep.port}`)
return hit === undefined ? undefined : { host: '127.0.0.1', port: hit.localPort }
},
}),
db,
{
@@ -352,6 +786,39 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
console.error('[cluster] heartbeat/register failed to start:', err)
})
}
/**
* 按需补齐 host 目录(2026-09-16 加,覆盖网络线缺陷 A1)。
*
* **为什么文件面必须自己会刷新**:`hostDirectory` 是**惰性** Map —— 唯一的写入者是
* `hostsProvider()`,而此前只有 `RemoteSpawner.ensureHosts()`(TTL 30 s)会调它 ⇒
* Manager 重启后若用户先碰文件面("我的文件" / launch 的 folder 检查),表里只有本机
* ⇒ `agentFor('w-106')` 返回 `undefined` ⇒ 旧行为**静默回退到本机 agent** ⇒ worker 上当然
* 没有这个用户 ⇒ 假 `404 {"error":"not_found"}`,与"文件夹不存在"完全同形,且平台零日志。
* (判别器 = relay 有没有 `DIAL`:没有 = 请求根本没出这台机。回归用例见
* `test/remote-user-fs.test.mjs`。)
*
* **成本**:只在**未命中**时查库(正常路径零开销);并发去重 + 5 s 冷却,
* 避免一个真正不存在的 hostId 把 PG 打穿。
*/
const DIRECTORY_COOLDOWN_MS = 5_000
let directoryRefreshing: Promise<void> | undefined
let directoryRefreshedAt = 0
const ensureHostDirectory = async (hostId: string): Promise<void> => {
if (hostDirectory.has(hostId)) return
if (directoryRefreshing !== undefined) return directoryRefreshing
if (Date.now() - directoryRefreshedAt < DIRECTORY_COOLDOWN_MS) return
directoryRefreshedAt = Date.now()
// **可观测性**(别省这一行):本行出现 = 真的走进了"未命中 ⇒ 补齐"这条路径,
// 也就是"旧实现会静默把请求打到本机 agent"的那个窗口。没有它,"这次为什么没 404"
// 只能靠推断;本项目吃过的静默失效亏,根子都在"关键分支不可观测"。
console.log(`[cluster] host 目录未命中 ${hostId} ⇒ 按需补齐(重启窗口期常见)`)
directoryRefreshing = hostsProvider()
.then(() => undefined)
.finally(() => {
directoryRefreshing = undefined
})
return directoryRefreshing
}
const userFs = createUserFs(config, {
hostIdFor: hostIdForFile,
agentFor: (hostId: string) => {
@@ -359,6 +826,7 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
// 同上一处:取址只经可达性入口(文件面与实例面共用同一份路由,别各自拼)
return h === undefined ? undefined : { agentUrl: agentBaseUrlOf(h), token: h.token }
},
ensureHost: ensureHostDirectory,
})
// T08 S5:cluster 模式下**所有 worker 的 dataRoot 必须是同一绝对路径**(基线约定,
// 设计 §14.3)。不一致会让 `resolvePath` 算出的"实例眼里的路径"与实际不符 ⇒
@@ -432,6 +900,7 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
await app.register(desktopRoutes)
await app.register(dshRoutes)
await app.register(domainRoutes)
await app.register(overlayRoutes)
await app.register(skillRoutes)
await app.register(whitelistRoutes)
+70 -15
View File
@@ -28,7 +28,11 @@ import { userRoot } from '../fs/workspace.js'
import { isUserFsErrorCode, UserFsError } from '../fs/user-fs.js'
import { hashUid } from '../isolation.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { normalizeTunnelTarget, SshTunnel } from './tunnel.js'
import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js'
import { loadClientIdentity } from '../net/relay/identity.js'
import { listOverlayRelayCandidates } from '../net/relay/directory.js'
import { overlayEnvSeeds, overlayEnvTrustedKeys } from '../net/relay/directory.js'
import { RelayTunnel, isRelayUrl } from './relay-tunnel.js'
import type { Instance } from '../supervisor/spawner.js'
/** 绑定的头部名(Manager/agent 双方约定)。 */
@@ -153,10 +157,12 @@ export function buildWorkerAgent(
*
* S1:会合地址**优先取 config**(`DSHS_RENDEZVOUS_URL` → 兜底 `DSHS_TUNNEL_TARGET`,在
* `config.ts` 里单点解析);显式 `options.tunnelTarget` 仍是最优先(测试/嵌入用)。
*
* R4:**按 scheme 选传输实现** —— 裸 `user@host:port` / `ssh://` ⇒ `SshTunnel`(旧路,保留可秒回滚);
* `ws://` / `wss://` ⇒ `RelayTunnel`(自研 relay,去掉对 sshd 的长期依赖)。两者同实现
* `WorkerTunnel` 面 ⇒ 本段以下所有代码(自愈 / 对账 / `/healthz`)**与传输无关**。
*/
const tunnelTarget = normalizeTunnelTarget(
options.tunnelTarget ?? config.clusterRendezvousUrl ?? '',
)
const rendezvousRaw = (options.tunnelTarget ?? config.clusterRendezvousUrl ?? '').trim()
const staticPorts = [
options.port,
...(process.env.DSHS_TUNNEL_STATIC_PORTS ?? '')
@@ -164,18 +170,67 @@ export function buildWorkerAgent(
.map((v) => Number(v.trim()))
.filter((v) => Number.isInteger(v) && v > 0),
]
const tunnel =
tunnelTarget === ''
const tunnel: WorkerTunnel | undefined =
rendezvousRaw === ''
? undefined
: new SshTunnel({
target: tunnelTarget,
identity:
options.tunnelIdentity ??
process.env.DSHS_TUNNEL_IDENTITY ??
`${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`,
controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`,
staticPorts,
})
: isRelayUrl(rendezvousRaw)
? new RelayTunnel({
url: rendezvousRaw,
hostId: options.hostId,
// 密钥必须显式给:**缺了就抛** —— 静默降级到别的传输会让"以为切过去了"变成假象。
secret: (() => {
const secret = (process.env.DSHS_RELAY_SECRET ?? '').trim()
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
throw new Error('DSHS_RENDEZVOUS_URL 用了 relay(ws/wss)⇒ 必须同时给 DSHS_RELAY_SECRET(64 hex)')
}
return secret
})(),
staticPorts,
/**
* 序⑦ · **C2 装配点:中继失败切流**。
*
* ⛔ **不改会合面**:起始地址仍然是上面那个 `rendezvousRaw`(= `DSHS_RENDEZVOUS_URL`
* 的既有取值顺序,一行没动)。这里只补一件事 —— 起始那台**不健康**时,
* 从**同一份引导链**(`listOverlayRelayCandidates`)里换到下一条。
*
* 为什么候选链不走 `rendezvousRaw`:那是"当前连哪台"的答案,不是"还有哪些台"的清单;
* 而链是**已签名目录 / 内置种子**的产物 ⇒ ⛔ 不可能指向任意 url(引不出任意重定向)。
* 链里取不到候选 ⇒ 什么都不做(D6),**行为与改造前逐字一致**。
*/
failover: {
candidates: async () =>
(
await listOverlayRelayCandidates({
envUrl: '',
seeds: overlayEnvSeeds(),
trustedKeys: overlayEnvTrustedKeys(),
cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '',
log: (line: string) => console.error(line),
})
).urls,
},
/**
* 序③:**本机节点身份**。配了 `DSHS_OVERLAY_NODE_KEY_FILE` + `..._NODE_GRANT_FILE`
* 才会带上(缺省 = 只做 HMAC,过渡期形态不变)。
* ⚠️ **配了却读不出来 ⇒ 起动即抛**(见 `identity.ts#loadClientIdentity`)——
* 静默退化成"没身份"会让"凭据坏了"表现成"一切正常",等 relay 一开强制就整台失联。
*/
identity: loadClientIdentity({
keyFile: config.overlayNodeKeyFile,
grantFile: config.overlayNodeGrantFile,
log: (line: string) => console.error(line),
}),
log: (line: string) => console.error(line),
})
: new SshTunnel({
target: normalizeTunnelTarget(rendezvousRaw),
identity:
options.tunnelIdentity ??
process.env.DSHS_TUNNEL_IDENTITY ??
`${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`,
controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`,
staticPorts,
})
let tunnelReady = tunnel === undefined
/**
+224
View File
@@ -0,0 +1,224 @@
/**
* Worker 侧的**中继隧道**(覆盖网络 R4)—— `SshTunnel` 的同面替代品。
*
* ## 它替掉的是什么
* 今天"Manager 连不到 Worker"靠的是 `ssh -R`:Worker 拨 Manager 的 sshd,把两边的
* `127.0.0.1:<port>` 接起来。代价是**长期依赖 sshd + root 凭据 + 一条公网 SSH 口**。
* 本类改用自研 relay:Worker 只拨一条 wss,relay 为每个注册端口在**它自己的回环**上开一条
* 监听 ⇒ 对 Manager 而言"又有一个 `host:port` 可以拨",形态与 sshd 版同形(S0 抽 `Reachability`
* 的全部意义)。**Worker 依旧零入站口**。
*
* ## 与 `SshTunnel` 的**唯一实质差别**(改代码前务必知道)
* | | 端口号 | 谁来翻译 |
* |---|---|---|
* | `SshTunnel` | **同号**(`-R p:127.0.0.1:p`)⇒ Manager 侧端口 = Worker 侧端口 | 不需要翻译 |
* | `RelayTunnel` | relay **动态分配**(实测 42067)⇒ Manager 侧端口 ≠ Worker 侧端口 | **Manager 侧**按 `(hostId, port)` 查 relay `/status` 翻译 |
*
* ⇒ 所以本类**只负责"把这个口声明给 relay"**,绝不把 relay 的回环口号回传给 agent 去上报
* (那会把"Manager 侧地址观"塞进 Worker,relay 换机部署时两头都要改)。
* Manager 侧的翻译在 `web/server.ts` 的 relay 快照 + `RemoteSpawner` 的 endpoint 翻译器里。
*
* ## 动态端口(实例端口)
* 实例端口是 `findFreePort()` 运行期分配的 ⇒ 用 `PORT_ADD` / `PORT_DEL`(对应 ssh 的
* `-O forward` / `-O cancel`),**不是**开局声明一整段(那样 relay 要为段内每个口开监听)。
* 断链后由 `RelayClient` 自动重放(见 `client.ts#replayDynamicPorts`)—— 这是"注册恢复"那一层。
*
* @module dshs/worker/relay-tunnel
*/
import { RelayClient, waitUpOnStatus, type RelayClientOptions, type WebSocketCtor } from '../net/relay/client.js'
import { RelayFailoverSupervisor } from '../net/relay/switcher.js'
import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/switcher.js'
import type { WorkerTunnel } from './tunnel.js'
export interface RelayTunnelOptions {
/** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
url: string
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
hostId: string
/** 与 relay 的预共享密钥(hex)。**缺失必须吵** —— 静默回退到别的传输比报错危险得多。 */
secret: string
/**
* **本机节点身份**(覆盖网络 序③):私钥 PEM + 入网凭据。
*
* 缺省 ⇒ `HELLO` 不带身份字段(过渡期形态,relay 未强制时照旧可用);
* 给了 ⇒ relay 侧可按**受信签名者**独立验证"这台机器被授权进入这张网",
* 而不再只依赖那张放在控制面上的密钥表。
*/
identity?: RelayClientOptions['identity']
/** 启动即声明的端口(agent 自身;还可带控制面 PG 等)。 */
staticPorts?: number[]
/** 首连等待上限(ms)。默认 12s:跨云一次 RTT 也就几十 ms,等这么久只可能是真连不上。 */
upTimeoutMs?: number
log?: (line: string) => void
webSocketCtor?: WebSocketCtor
/**
* **中继失败切流**(序⑦ · C2 装配点)。给了才启用;不给 ⇒ 行为与改造前**逐字一致**
* (启动解析一次、此后钉死 —— 这正是改造前 E9 后半不成立的原因)。
*
* ⛔ **候选链只来自"同一份引导链"**({@link listOverlayRelayCandidates})⇒ 只可能是
* **已签名目录 / 内置种子**里的地址,⛔ 不接受任意 url(否则就是任意重定向 = 真 R5)。
* ⚠️ 它**不改会合面**:起始地址仍由 `DSHS_RENDEZVOUS_URL` 决定(见 §3.2),
* 这里只补上"起始那台不健康时换到链里的下一条"。
*/
failover?: {
candidates: () => Promise<readonly string[]>
thresholds?: Partial<RelayFailoverThresholds>
}
}
/** 内部:一条通道 + 它自己的 `RelayClient`。 */
type TunnelChannel = RelayChannelHandle & { client: RelayClient }
function healthOf(client: RelayClient): { state: string; attempts: number; unhealthyForMs: number } {
const st = client.status()
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
}
export class RelayTunnel implements WorkerTunnel {
private readonly opts: RelayTunnelOptions
private readonly forwarded = new Set<number>()
private readonly log: (line: string) => void
private readonly failover: RelayFailoverSupervisor | undefined
/** 起始通道(监管器不在场时它就是唯一通道)。 */
private readonly initialChannel: TunnelChannel
constructor(options: RelayTunnelOptions) {
this.opts = options
this.log = options.log ?? ((line: string) => process.stdout.write(`${line}\n`))
this.initialChannel = this.channelFor(this.buildClient(options.url), options.url)
if (options.failover === undefined) {
this.failover = undefined
return
}
const sup = new RelayFailoverSupervisor({
/**
* **先建新、成功再关旧**:新客户端必须先真的到 `up`,本函数才返回句柄;
* 到不了 ⇒ 返回 `undefined` ⇒ 监管器**保持原通道**(D4)。
* 新客户端**一次就把已声明的端口全带上**(`staticPorts` + 运行期 `forwarded`),
* 否则切换完成后"实例面端口没了"—— 那是比不切更糟的结果。
*/
open: async (url) => {
const next = this.buildClient(url)
next.start()
const ok = await this.waitUpOn(next, this.opts.upTimeoutMs ?? 12_000)
if (!ok) {
next.stop()
return undefined
}
return this.channelFor(next, url)
},
candidates: options.failover.candidates,
log: this.log,
thresholds: options.failover.thresholds,
})
sup.seed(this.initialChannel)
sup.start()
this.failover = sup
}
/** 当前通道(监管器在场时以它为准 —— "当前是谁"只有**一个**权威来源)。 */
private get channel(): TunnelChannel {
return (this.failover?.channel as TunnelChannel | undefined) ?? this.initialChannel
}
private channelFor(client: RelayClient, url: string): TunnelChannel {
return {
url,
client,
health: () => healthOf(client),
close: () => client.stop(),
}
}
private buildClient(url: string): RelayClient {
return new RelayClient({
url,
hostId: this.opts.hostId,
secret: this.opts.secret,
ports: [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b),
identity: this.opts.identity,
log: this.log,
webSocketCtor: this.opts.webSocketCtor,
})
}
private get client(): RelayClient {
return this.channel.client
}
/** 当前"已声明给 relay"的端口(静态 + 运行期),诊断用。 */
get ports(): number[] {
return [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b)
}
/** **幂等**:已启动就只等它到 `up`(断链后 agent 的自愈走的正是这条路径)。 */
async ensureMaster(): Promise<void> {
this.client.start()
await this.waitUp(this.opts.upTimeoutMs ?? 12_000)
}
async isMasterAlive(): Promise<boolean> {
return this.client.status().state === 'up'
}
/** 加一个转发(实例起来时)。失败**不抛**:实例在本机照样可用,只是跨机代理这一跳不可用。 */
async forward(port: number): Promise<boolean> {
if (this.forwarded.has(port)) return true
const ok = await this.client.addPort(port)
if (ok) this.forwarded.add(port)
return ok
}
/** 撤销一条转发(实例停止时)。 */
async cancel(port: number): Promise<void> {
if (!this.forwarded.has(port)) return
if (await this.client.removePort(port)) this.forwarded.delete(port)
}
async close(): Promise<void> {
this.failover?.stop()
this.client.stop()
this.forwarded.clear()
}
/**
* 换址时用的**非抛版**等待(序⑦):`open()` 要的是"能不能起来"这个布尔,
* 而不是异常 —— 起不来就返回 `false`,由监管器决定"保持原通道"(D4)。
*
* 🔴 **序⑨ · RC-1(C2 装配点)**:实现已抽到 {@link waitUpOnStatus}(三个装配点共用一份,D7);
* 相对改造前的唯一差别 = **终态失败(死候选)立即 `false`**,⛔ 不再白等满 `upTimeoutMs`。
*/
private async waitUpOn(client: RelayClient, timeoutMs: number): Promise<boolean> {
return waitUpOnStatus(client, timeoutMs, {
onDead: (st) =>
this.log(
`[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` +
`burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`,
),
})
}
/** 轮询等 `up`(`RelayClient` 没有 up 事件;重连由它自己退避驱动,这里只负责等)。 */
private async waitUp(timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
for (;;) {
if (this.client.status().state === 'up') return
if (Date.now() >= deadline) {
const st = this.client.status()
throw new Error(`relay 未在 ${timeoutMs}ms 内就绪(state=${st.state} lastError=${st.lastError ?? '-'})`)
}
await new Promise((r) => setTimeout(r, 100))
}
}
}
/**
* 这个会合地址是不是"走 relay"(覆盖网络 R4)。
*
* 判据只看 scheme:`ws://` / `wss://` = relay;`ssh://` / 裸 `user@host:port` = 旧隧道。
* 于是一个环境变量就能在两种传输之间来回切(**删掉 relay 那行即回滚**,不需要回滚代码)。
*/
export function isRelayUrl(raw: string): boolean {
return /^wss?:\/\//i.test(raw.trim())
}
+23 -1
View File
@@ -57,7 +57,29 @@ export interface TunnelOptions {
sshBin?: string
}
export class SshTunnel {
/**
* Worker 侧隧道的**共同面**(覆盖网络 R4)。
*
* 为什么要有它:`SshTunnel`(拨 sshd)与 `RelayTunnel`(拨 relay)**必须能被 agent 一视同仁地使唤** ——
* agent 只做三件事:开起来、加/减转发、判活。传输换谁,这三件事的语义都不变 ⇒ 换传输就只是
* `DSHS_RENDEZVOUS_URL` 里 scheme 的差别(`ssh://…` ↔ `wss://…`),**agent 的代码不需要再动**。
*/
export interface WorkerTunnel {
/** 建立(或复用)长连接。**幂等**:已就绪时直接返回。 */
ensureMaster(): Promise<void>
/** 长连接是否还活着(**判据在传输侧**,不能只看本地记账 —— 见 `SshTunnel` 的注释)。 */
isMasterAlive(): Promise<boolean>
/** 运行期加一条转发。失败返回 `false`(**不抛**):跨机代理降级 ≠ 本机功能降级。 */
forward(port: number): Promise<boolean>
/** 撤销一条转发。 */
cancel(port: number): Promise<void>
/** 关闭长连接(进程退出时)。 */
close(): Promise<void>
/** 当前已转发的端口(对账自愈用)。 */
readonly ports: number[]
}
export class SshTunnel implements WorkerTunnel {
/** 内部一律用**已补默认值**的具体类型(否则 `sshBin` 会是 `string | undefined`)。 */
private readonly opts: {
target: string