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

覆盖网络线累积产物(此前只在工作区、未入版本库):
- 新增 relay 子系统 src/net/relay/**(wire/duplex/server/client/dialer/switcher/directory/identity/keys/placement/network/addr-override/main/index)
- 新增 src/worker/relay-tunnel.ts、src/web/routes/overlay.ts
- 新增观测/演练脚本 overlay-probe、overlay-failover-drill、overlay-keyring、overlay-holepunch、overlay-jitter、overlay-wan、overlay-relaykey-add、relay-mem-calibrate
- 新增测试 12 个(relay / relay-failover / remote-spawner / instance-port / overlay-{network,auth,bootstrap,identity} / remote-user-fs 等)

验收基线:npm test = 162 pass / 0 fail / 1 skip;--scene all = 12 PASS / 0 SKIP / 0 FAIL;overlay-probe = 12/12
This commit is contained in:
admin committed 2026-09-17 17:03:27 +08:00
1 parent 640813e84e
commit 146c3d25ef
53 files changed
+16187 -63

No files matched your search

+162
View File
@@ -118,6 +118,83 @@ export interface ServerConfig {
* 删掉新 env 即回到旧路径,**零代码回滚**。
*/
clusterRendezvousUrl: string
/**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/
relayUrl: string
/**
* 中继的**状态查询地址**(覆盖网络 R3),如 `http://127.0.0.1:20080/status`。
*
* 为什么必须查它:relay 为每个注册端口在**它自己的回环**上开一条监听,端口号是
* `listen(0)` 动态分配的(实测 42067)⇒ **Manager 无法从 `dsh_hosts.endpoint` 推出来**,
* 只能问 relay「hostId + 端口 → 回环口 + 在线态」。**空 = 不注册 `relay` 实现**
* (于是 `via='relay'` 会回退到 `manager-ssh`,行为与今天一致)。
*/
relayStatusUrl: string
/**
* **拨号通道**(覆盖网络 R5):Manager 用哪个 hostId 向 relay 注册为「拨号方」。
* 默认 `manager`;空 ⇒ **不启用拨号通道**(一切照旧)。
*
* 为什么需要它:R1–R4 的落点在 **relay 主机的回环**上 ⇒ Manager 必须与 relay 同机,
* 「中继可换机 / 多实例」就做不到。拨号通道把落点搬到 **Manager 自己本机**
* (`src/net/relay/dialer.ts` 的口池)⇒ relay 放哪台机器都行。
* ⚠️ 该 hostId 必须同时出现在 relay 的密钥文件里,且列在 relay 的 `DSHS_RELAY_DIALERS` 白名单里。
*/
relayDialHost: string
/** 拨号方的 64 位 hex 密钥(**与 relay 密钥文件里 `relayDialHost` 那一项逐字节相同**)。空 ⇒ 不启用。 */
relayDialSecret: string
/** 拨号落点口池起点。**必须避开 OS 临时端口段(32768–60999)与实例端口段**。默认 25000。 */
relayDialPortBase: number
/** 拨号落点口池扫描宽度。默认 1000。 */
relayDialPortSpan: number
/** 拨号落点口池大小(= 最多同时挂多少个 `(hostId, port)` 落点)。默认 64。 */
relayDialPool: number
/**
* **实例端口区间起点**(覆盖网络 S3)。`0` = 保持旧行为(`listen(0)` 随机取端口)。
*
* 为什么必须能配:跨机实例的**隧道落点全部挤在 Manager 的 `127.0.0.1`**,而
* `findFreePort()` 是**每台 worker 各自**用 `listen(0)` 随机取的 ⇒ **两台 worker 取到同号
* 就会撞号**:`-R` 失败被静默忽略(`tunnel.forward()` 的返回值无人看),Manager 仍按该
* 端口拨 ⇒ **打到别人的实例**(2026-09-16 实测)。给每台 worker 一段**互不重叠**的区间
* 即可根治,且**不需要改 sshd、不需要端口映射表**。
*
* ⚠️ 选区间时应**避开 OS 临时端口段**(本项目两台机器均为 `32768-60999`)。
*/
instancePortBase: number
/** 实例端口区间长度(条数)。仅当 `instancePortBase > 0` 时生效。 */
instancePortSpan: number
// ── 覆盖网络 P0-2 引导三级链(`net/relay/directory.ts`)─────────────────
/**
* **本机所属的网**(P0-1 的 `network_id`)。运维网 = `ops`(47 / 106 / 未来的中继);
* 用户设备用 `u:<userId>`。它进目录文档的 `network` 字段(**被签名覆盖**)。
*/
overlayNetworkId: string
/**
* **内置种子**(引导链第 ①级之后的兜底入口)。常量位 —— 已持证书、**不新增域名**。
*
* ⚠️ 约定:"**引导地址 = 中继入口同源**":目录端点 = 种子 origin + 固定路径
* (`net/relay/directory.ts` 的 `DIRECTORY_PATH`)。本文件是**基础层**、
* 不许 import 能力层 ⇒ 路径字面量两处各写一次,**改动必须两处同改**。
*/
overlayBootstrapSeeds: string[]
/**
* **受信目录签名公钥**(PEM 或裸 32 字节 hex/base64,逗号分隔)。
* 空 ⇒ 目录一律不接受(**不可验 = 不接受**)⇒ 只剩 env 与种子兜底两级。
*/
overlayDirTrustedKeys: string[]
/** 目录**签名私钥**(Ed25519 PEM)路径。空 ⇒ 目录端点 `503`(**绝不发未签名目录**)。 */
overlayDirKeyFile: string
/** 目录缓存文件(客户端侧)。空 ⇒ 不缓存(每次取目录,多一次往返)。 */
overlayDirectoryCacheFile: string
// ── 覆盖网络 序③ 一机一钥 + 信任根(`net/relay/identity.ts`)────────────
/**
* **本机节点私钥**文件路径(Ed25519 PEM,**0600**、属主必须是跑 dsh 的那个用户)。
* 空 ⇒ 本机不发起身份(只做 HMAC,过渡期形态)。
*/
overlayNodeKeyFile: string
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
overlayNodeGrantFile: string
}
/** Untyped overrides collected from argv / env. */
@@ -165,9 +242,29 @@ export interface ConfigOverrides {
clusterInstanceHost?: string
clusterWorkerDataRoot?: string
clusterRendezvousUrl?: string
relayUrl?: string
relayStatusUrl?: string
relayDialHost?: string
relayDialSecret?: string
relayDialPortBase?: number | string
relayDialPortSpan?: number | string
relayDialPool?: number | string
instancePortBase?: number | string
instancePortSpan?: number | string
overlayNetworkId?: string
overlayNodeKeyFile?: string
overlayNodeGrantFile?: string
overlayBootstrapSeeds?: string[]
overlayDirTrustedKeys?: string[]
overlayDirKeyFile?: string
overlayDirectoryCacheFile?: string
}
const DEFAULT_HOST = '127.0.0.1'
// 覆盖网络 S3:实例端口区间(`0` = 保持旧行为 `listen(0)` 随机)。
const DEFAULT_INSTANCE_PORT_BASE = 0
const DEFAULT_INSTANCE_PORT_SPAN = 1000
const DEFAULT_PORT = 3080
const DEFAULT_DSH_COMMAND = ['dsh']
const DEFAULT_LOG_LEVEL = 'info'
@@ -207,6 +304,12 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
const DEFAULT_K8S_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
/**
* 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。
*/
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay']
/** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
@@ -231,12 +334,31 @@ function toBool(value: string | undefined, fallback: boolean): boolean {
return value === 'true' || value === '1'
}
/**
* 解析端口号(覆盖网络 S3)。空 / 非法 / 越界 ⇒ 回落到 `fallback`(`0` = 旧行为 `listen(0)`)。
* 不接受 NaN:`listen(NaN)` 会以"启动正常但实例起不来"的形式烂在运行时。
*/
function toPortNumber(value: string | number | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback
const n = Number(value)
return Number.isInteger(n) && n >= 0 && n <= 65535 ? n : fallback
}
/** Split a comma-separated CIDR list into a trimmed, de-duplicated array. */
function parseCidrs(value: string | undefined): string[] {
if (value === undefined || value === '') return []
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
}
/**
* 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
*/
function splitList(value: string | undefined): string[] | undefined {
if (value === undefined) return undefined
return [...new Set(value.split(',').map((s) => s.trim()).filter((s) => s !== ''))]
}
/** Parse an isolation-mode value, rejecting anything outside `soft`/`account`
* so a typo in the env var fails loudly at startup instead of silently
* falling back to `soft` isolation. */
@@ -367,5 +489,45 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
process.env.DSHS_RENDEZVOUS_URL ??
process.env.DSHS_TUNNEL_TARGET ??
'',
// 覆盖网络 R3:Manager 侧的 relay 接入(**空 = 完全不启用**,与 R2 之前行为一致)。
relayUrl: overrides.relayUrl ?? process.env.DSHS_RELAY_URL ?? '',
relayStatusUrl: overrides.relayStatusUrl ?? process.env.DSHS_RELAY_STATUS_URL ?? '',
// 覆盖网络 R5:Manager 侧拨号通道(**空密钥 = 完全不启用**,落回 `/status` 快照那条老路)。
relayDialHost: (overrides.relayDialHost ?? process.env.DSHS_RELAY_DIAL_HOST ?? 'manager').trim(),
relayDialSecret: (overrides.relayDialSecret ?? process.env.DSHS_RELAY_DIAL_SECRET ?? '').trim(),
relayDialPortBase: toPortNumber(overrides.relayDialPortBase ?? process.env.DSHS_RELAY_DIAL_PORT_BASE, 25000),
relayDialPortSpan: toPortNumber(overrides.relayDialPortSpan ?? process.env.DSHS_RELAY_DIAL_PORT_SPAN, 1000),
relayDialPool: Number(overrides.relayDialPool ?? process.env.DSHS_RELAY_DIAL_POOL ?? 64) || 64,
// 覆盖网络 S3:实例端口区间隔离(`0` = 旧行为)。非法值**回落**而不是变 NaN ——
// `listen(NaN)` 是那种"启动看起来正常、实例起不来"的坑。
instancePortBase: toPortNumber(
overrides.instancePortBase ?? process.env.DSHS_INSTANCE_PORT_BASE,
DEFAULT_INSTANCE_PORT_BASE,
),
instancePortSpan: toPortNumber(
overrides.instancePortSpan ?? process.env.DSHS_INSTANCE_PORT_SPAN,
DEFAULT_INSTANCE_PORT_SPAN,
),
// 覆盖网络 P0-2:引导三级链(env 显式 > 缓存目录 > 内置种子)。
// 这一组**全部有安全默认**:不配任何东西 ⇒ 与今天行为一致(只认 env;种子地址就是现网地址)。
overlayNetworkId: (overrides.overlayNetworkId ?? process.env.DSHS_OVERLAY_NETWORK_ID ?? 'ops').trim() || 'ops',
overlayBootstrapSeeds:
overrides.overlayBootstrapSeeds ??
splitList(process.env.DSHS_OVERLAY_BOOTSTRAP_SEEDS) ??
DEFAULT_OVERLAY_BOOTSTRAP_SEEDS,
overlayDirTrustedKeys:
overrides.overlayDirTrustedKeys ?? splitList(process.env.DSHS_OVERLAY_DIR_PUBKEYS) ?? [],
overlayDirKeyFile: (overrides.overlayDirKeyFile ?? process.env.DSHS_OVERLAY_DIR_KEY ?? '').trim(),
overlayDirectoryCacheFile: (
overrides.overlayDirectoryCacheFile ??
process.env.DSHS_OVERLAY_DIR_CACHE ??
join(dataRoot, 'overlay', 'directory.json')
).trim(),
// 覆盖网络 序③:本机节点身份(一机一钥 + 入网凭据)。
// **两个都配了**才发起身份 ⇒ 不配 = 与今天行为一致(只做 HMAC);配了却读不出来 ⇒ 起动即抛
//(见 `net/relay/identity.ts#loadClientIdentity` —— 静默退化成"没身份"会让"凭据坏了"
// 表现成"一切正常",等 relay 一开强制就整台失联)。
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(),
}
}