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
+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