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

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

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

No files matched your search

+70 -15
View File
@@ -28,7 +28,11 @@ import { userRoot } from '../fs/workspace.js'
import { isUserFsErrorCode, UserFsError } from '../fs/user-fs.js'
import { hashUid } from '../isolation.js'
import { LocalSpawner } from '../supervisor/orchestrator.js'
import { normalizeTunnelTarget, SshTunnel } from './tunnel.js'
import { normalizeTunnelTarget, SshTunnel, type WorkerTunnel } from './tunnel.js'
import { loadClientIdentity } from '../net/relay/identity.js'
import { listOverlayRelayCandidates } from '../net/relay/directory.js'
import { overlayEnvSeeds, overlayEnvTrustedKeys } from '../net/relay/directory.js'
import { RelayTunnel, isRelayUrl } from './relay-tunnel.js'
import type { Instance } from '../supervisor/spawner.js'
/** 绑定的头部名(Manager/agent 双方约定)。 */
@@ -153,10 +157,12 @@ export function buildWorkerAgent(
*
* S1:会合地址**优先取 config**(`DSHS_RENDEZVOUS_URL` → 兜底 `DSHS_TUNNEL_TARGET`,在
* `config.ts` 里单点解析);显式 `options.tunnelTarget` 仍是最优先(测试/嵌入用)。
*
* R4:**按 scheme 选传输实现** —— 裸 `user@host:port` / `ssh://` ⇒ `SshTunnel`(旧路,保留可秒回滚);
* `ws://` / `wss://` ⇒ `RelayTunnel`(自研 relay,去掉对 sshd 的长期依赖)。两者同实现
* `WorkerTunnel` 面 ⇒ 本段以下所有代码(自愈 / 对账 / `/healthz`)**与传输无关**。
*/
const tunnelTarget = normalizeTunnelTarget(
options.tunnelTarget ?? config.clusterRendezvousUrl ?? '',
)
const rendezvousRaw = (options.tunnelTarget ?? config.clusterRendezvousUrl ?? '').trim()
const staticPorts = [
options.port,
...(process.env.DSHS_TUNNEL_STATIC_PORTS ?? '')
@@ -164,18 +170,67 @@ export function buildWorkerAgent(
.map((v) => Number(v.trim()))
.filter((v) => Number.isInteger(v) && v > 0),
]
const tunnel =
tunnelTarget === ''
const tunnel: WorkerTunnel | undefined =
rendezvousRaw === ''
? undefined
: new SshTunnel({
target: tunnelTarget,
identity:
options.tunnelIdentity ??
process.env.DSHS_TUNNEL_IDENTITY ??
`${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`,
controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`,
staticPorts,
})
: isRelayUrl(rendezvousRaw)
? new RelayTunnel({
url: rendezvousRaw,
hostId: options.hostId,
// 密钥必须显式给:**缺了就抛** —— 静默降级到别的传输会让"以为切过去了"变成假象。
secret: (() => {
const secret = (process.env.DSHS_RELAY_SECRET ?? '').trim()
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
throw new Error('DSHS_RENDEZVOUS_URL 用了 relay(ws/wss)⇒ 必须同时给 DSHS_RELAY_SECRET(64 hex)')
}
return secret
})(),
staticPorts,
/**
* 序⑦ · **C2 装配点:中继失败切流**。
*
* ⛔ **不改会合面**:起始地址仍然是上面那个 `rendezvousRaw`(= `DSHS_RENDEZVOUS_URL`
* 的既有取值顺序,一行没动)。这里只补一件事 —— 起始那台**不健康**时,
* 从**同一份引导链**(`listOverlayRelayCandidates`)里换到下一条。
*
* 为什么候选链不走 `rendezvousRaw`:那是"当前连哪台"的答案,不是"还有哪些台"的清单;
* 而链是**已签名目录 / 内置种子**的产物 ⇒ ⛔ 不可能指向任意 url(引不出任意重定向)。
* 链里取不到候选 ⇒ 什么都不做(D6),**行为与改造前逐字一致**。
*/
failover: {
candidates: async () =>
(
await listOverlayRelayCandidates({
envUrl: '',
seeds: overlayEnvSeeds(),
trustedKeys: overlayEnvTrustedKeys(),
cacheFile: process.env.DSHS_OVERLAY_DIR_CACHE ?? '',
log: (line: string) => console.error(line),
})
).urls,
},
/**
* 序③:**本机节点身份**。配了 `DSHS_OVERLAY_NODE_KEY_FILE` + `..._NODE_GRANT_FILE`
* 才会带上(缺省 = 只做 HMAC,过渡期形态不变)。
* ⚠️ **配了却读不出来 ⇒ 起动即抛**(见 `identity.ts#loadClientIdentity`)——
* 静默退化成"没身份"会让"凭据坏了"表现成"一切正常",等 relay 一开强制就整台失联。
*/
identity: loadClientIdentity({
keyFile: config.overlayNodeKeyFile,
grantFile: config.overlayNodeGrantFile,
log: (line: string) => console.error(line),
}),
log: (line: string) => console.error(line),
})
: new SshTunnel({
target: normalizeTunnelTarget(rendezvousRaw),
identity:
options.tunnelIdentity ??
process.env.DSHS_TUNNEL_IDENTITY ??
`${process.env.HOME ?? '/root'}/.ssh/tunnel_ed25519`,
controlPath: options.tunnelControlPath ?? `/tmp/dshs-tunnel-${options.hostId}.sock`,
staticPorts,
})
let tunnelReady = tunnel === undefined
/**
+224
View File
@@ -0,0 +1,224 @@
/**
* Worker 侧的**中继隧道**(覆盖网络 R4)—— `SshTunnel` 的同面替代品。
*
* ## 它替掉的是什么
* 今天"Manager 连不到 Worker"靠的是 `ssh -R`:Worker 拨 Manager 的 sshd,把两边的
* `127.0.0.1:<port>` 接起来。代价是**长期依赖 sshd + root 凭据 + 一条公网 SSH 口**。
* 本类改用自研 relay:Worker 只拨一条 wss,relay 为每个注册端口在**它自己的回环**上开一条
* 监听 ⇒ 对 Manager 而言"又有一个 `host:port` 可以拨",形态与 sshd 版同形(S0 抽 `Reachability`
* 的全部意义)。**Worker 依旧零入站口**。
*
* ## 与 `SshTunnel` 的**唯一实质差别**(改代码前务必知道)
* | | 端口号 | 谁来翻译 |
* |---|---|---|
* | `SshTunnel` | **同号**(`-R p:127.0.0.1:p`)⇒ Manager 侧端口 = Worker 侧端口 | 不需要翻译 |
* | `RelayTunnel` | relay **动态分配**(实测 42067)⇒ Manager 侧端口 ≠ Worker 侧端口 | **Manager 侧**按 `(hostId, port)` 查 relay `/status` 翻译 |
*
* ⇒ 所以本类**只负责"把这个口声明给 relay"**,绝不把 relay 的回环口号回传给 agent 去上报
* (那会把"Manager 侧地址观"塞进 Worker,relay 换机部署时两头都要改)。
* Manager 侧的翻译在 `web/server.ts` 的 relay 快照 + `RemoteSpawner` 的 endpoint 翻译器里。
*
* ## 动态端口(实例端口)
* 实例端口是 `findFreePort()` 运行期分配的 ⇒ 用 `PORT_ADD` / `PORT_DEL`(对应 ssh 的
* `-O forward` / `-O cancel`),**不是**开局声明一整段(那样 relay 要为段内每个口开监听)。
* 断链后由 `RelayClient` 自动重放(见 `client.ts#replayDynamicPorts`)—— 这是"注册恢复"那一层。
*
* @module dshs/worker/relay-tunnel
*/
import { RelayClient, waitUpOnStatus, type RelayClientOptions, type WebSocketCtor } from '../net/relay/client.js'
import { RelayFailoverSupervisor } from '../net/relay/switcher.js'
import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/switcher.js'
import type { WorkerTunnel } from './tunnel.js'
export interface RelayTunnelOptions {
/** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
url: string
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
hostId: string
/** 与 relay 的预共享密钥(hex)。**缺失必须吵** —— 静默回退到别的传输比报错危险得多。 */
secret: string
/**
* **本机节点身份**(覆盖网络 序③):私钥 PEM + 入网凭据。
*
* 缺省 ⇒ `HELLO` 不带身份字段(过渡期形态,relay 未强制时照旧可用);
* 给了 ⇒ relay 侧可按**受信签名者**独立验证"这台机器被授权进入这张网",
* 而不再只依赖那张放在控制面上的密钥表。
*/
identity?: RelayClientOptions['identity']
/** 启动即声明的端口(agent 自身;还可带控制面 PG 等)。 */
staticPorts?: number[]
/** 首连等待上限(ms)。默认 12s:跨云一次 RTT 也就几十 ms,等这么久只可能是真连不上。 */
upTimeoutMs?: number
log?: (line: string) => void
webSocketCtor?: WebSocketCtor
/**
* **中继失败切流**(序⑦ · C2 装配点)。给了才启用;不给 ⇒ 行为与改造前**逐字一致**
* (启动解析一次、此后钉死 —— 这正是改造前 E9 后半不成立的原因)。
*
* ⛔ **候选链只来自"同一份引导链"**({@link listOverlayRelayCandidates})⇒ 只可能是
* **已签名目录 / 内置种子**里的地址,⛔ 不接受任意 url(否则就是任意重定向 = 真 R5)。
* ⚠️ 它**不改会合面**:起始地址仍由 `DSHS_RENDEZVOUS_URL` 决定(见 §3.2),
* 这里只补上"起始那台不健康时换到链里的下一条"。
*/
failover?: {
candidates: () => Promise<readonly string[]>
thresholds?: Partial<RelayFailoverThresholds>
}
}
/** 内部:一条通道 + 它自己的 `RelayClient`。 */
type TunnelChannel = RelayChannelHandle & { client: RelayClient }
function healthOf(client: RelayClient): { state: string; attempts: number; unhealthyForMs: number } {
const st = client.status()
return { state: st.state, attempts: st.attempts, unhealthyForMs: st.unhealthyForMs }
}
export class RelayTunnel implements WorkerTunnel {
private readonly opts: RelayTunnelOptions
private readonly forwarded = new Set<number>()
private readonly log: (line: string) => void
private readonly failover: RelayFailoverSupervisor | undefined
/** 起始通道(监管器不在场时它就是唯一通道)。 */
private readonly initialChannel: TunnelChannel
constructor(options: RelayTunnelOptions) {
this.opts = options
this.log = options.log ?? ((line: string) => process.stdout.write(`${line}\n`))
this.initialChannel = this.channelFor(this.buildClient(options.url), options.url)
if (options.failover === undefined) {
this.failover = undefined
return
}
const sup = new RelayFailoverSupervisor({
/**
* **先建新、成功再关旧**:新客户端必须先真的到 `up`,本函数才返回句柄;
* 到不了 ⇒ 返回 `undefined` ⇒ 监管器**保持原通道**(D4)。
* 新客户端**一次就把已声明的端口全带上**(`staticPorts` + 运行期 `forwarded`),
* 否则切换完成后"实例面端口没了"—— 那是比不切更糟的结果。
*/
open: async (url) => {
const next = this.buildClient(url)
next.start()
const ok = await this.waitUpOn(next, this.opts.upTimeoutMs ?? 12_000)
if (!ok) {
next.stop()
return undefined
}
return this.channelFor(next, url)
},
candidates: options.failover.candidates,
log: this.log,
thresholds: options.failover.thresholds,
})
sup.seed(this.initialChannel)
sup.start()
this.failover = sup
}
/** 当前通道(监管器在场时以它为准 —— "当前是谁"只有**一个**权威来源)。 */
private get channel(): TunnelChannel {
return (this.failover?.channel as TunnelChannel | undefined) ?? this.initialChannel
}
private channelFor(client: RelayClient, url: string): TunnelChannel {
return {
url,
client,
health: () => healthOf(client),
close: () => client.stop(),
}
}
private buildClient(url: string): RelayClient {
return new RelayClient({
url,
hostId: this.opts.hostId,
secret: this.opts.secret,
ports: [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b),
identity: this.opts.identity,
log: this.log,
webSocketCtor: this.opts.webSocketCtor,
})
}
private get client(): RelayClient {
return this.channel.client
}
/** 当前"已声明给 relay"的端口(静态 + 运行期),诊断用。 */
get ports(): number[] {
return [...new Set([...(this.opts.staticPorts ?? []), ...this.forwarded])].sort((a, b) => a - b)
}
/** **幂等**:已启动就只等它到 `up`(断链后 agent 的自愈走的正是这条路径)。 */
async ensureMaster(): Promise<void> {
this.client.start()
await this.waitUp(this.opts.upTimeoutMs ?? 12_000)
}
async isMasterAlive(): Promise<boolean> {
return this.client.status().state === 'up'
}
/** 加一个转发(实例起来时)。失败**不抛**:实例在本机照样可用,只是跨机代理这一跳不可用。 */
async forward(port: number): Promise<boolean> {
if (this.forwarded.has(port)) return true
const ok = await this.client.addPort(port)
if (ok) this.forwarded.add(port)
return ok
}
/** 撤销一条转发(实例停止时)。 */
async cancel(port: number): Promise<void> {
if (!this.forwarded.has(port)) return
if (await this.client.removePort(port)) this.forwarded.delete(port)
}
async close(): Promise<void> {
this.failover?.stop()
this.client.stop()
this.forwarded.clear()
}
/**
* 换址时用的**非抛版**等待(序⑦):`open()` 要的是"能不能起来"这个布尔,
* 而不是异常 —— 起不来就返回 `false`,由监管器决定"保持原通道"(D4)。
*
* 🔴 **序⑨ · RC-1(C2 装配点)**:实现已抽到 {@link waitUpOnStatus}(三个装配点共用一份,D7);
* 相对改造前的唯一差别 = **终态失败(死候选)立即 `false`**,⛔ 不再白等满 `upTimeoutMs`。
*/
private async waitUpOn(client: RelayClient, timeoutMs: number): Promise<boolean> {
return waitUpOnStatus(client, timeoutMs, {
onDead: (st) =>
this.log(
`[relay-failover] ⛔ 新通道终态失败(state=${st.state} attempts=${st.attempts} ` +
`burst=${st.inGracefulBurstWindow} lastError="${st.lastError ?? ''}")⇒ 提前放弃,不等满 ${timeoutMs}ms`,
),
})
}
/** 轮询等 `up`(`RelayClient` 没有 up 事件;重连由它自己退避驱动,这里只负责等)。 */
private async waitUp(timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs
for (;;) {
if (this.client.status().state === 'up') return
if (Date.now() >= deadline) {
const st = this.client.status()
throw new Error(`relay 未在 ${timeoutMs}ms 内就绪(state=${st.state} lastError=${st.lastError ?? '-'})`)
}
await new Promise((r) => setTimeout(r, 100))
}
}
}
/**
* 这个会合地址是不是"走 relay"(覆盖网络 R4)。
*
* 判据只看 scheme:`ws://` / `wss://` = relay;`ssh://` / 裸 `user@host:port` = 旧隧道。
* 于是一个环境变量就能在两种传输之间来回切(**删掉 relay 那行即回滚**,不需要回滚代码)。
*/
export function isRelayUrl(raw: string): boolean {
return /^wss?:\/\//i.test(raw.trim())
}
+23 -1
View File
@@ -57,7 +57,29 @@ export interface TunnelOptions {
sshBin?: string
}
export class SshTunnel {
/**
* Worker 侧隧道的**共同面**(覆盖网络 R4)。
*
* 为什么要有它:`SshTunnel`(拨 sshd)与 `RelayTunnel`(拨 relay)**必须能被 agent 一视同仁地使唤** ——
* agent 只做三件事:开起来、加/减转发、判活。传输换谁,这三件事的语义都不变 ⇒ 换传输就只是
* `DSHS_RENDEZVOUS_URL` 里 scheme 的差别(`ssh://…` ↔ `wss://…`),**agent 的代码不需要再动**。
*/
export interface WorkerTunnel {
/** 建立(或复用)长连接。**幂等**:已就绪时直接返回。 */
ensureMaster(): Promise<void>
/** 长连接是否还活着(**判据在传输侧**,不能只看本地记账 —— 见 `SshTunnel` 的注释)。 */
isMasterAlive(): Promise<boolean>
/** 运行期加一条转发。失败返回 `false`(**不抛**):跨机代理降级 ≠ 本机功能降级。 */
forward(port: number): Promise<boolean>
/** 撤销一条转发。 */
cancel(port: number): Promise<void>
/** 关闭长连接(进程退出时)。 */
close(): Promise<void>
/** 当前已转发的端口(对账自愈用)。 */
readonly ports: number[]
}
export class SshTunnel implements WorkerTunnel {
/** 内部一律用**已补默认值**的具体类型(否则 `sshBin` 会是 `string | undefined`)。 */
private readonly opts: {
target: string