Files
dsh_shenxian/src/worker/relay-tunnel.ts
T

224 lines
9.8 KiB
TypeScript
Raw Normal View History

/**
* 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())
}