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
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user