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:
1 parent
640813e84e
commit
146c3d25ef
53 files changed
+16187
-63
No files matched your search
+162
@@ -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
@@ -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
@@ -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>)
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 回前端)。 */
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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 })),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}"`
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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' }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user