242 lines
9.5 KiB
TypeScript
242 lines
9.5 KiB
TypeScript
/**
|
||||
|
|
* Worker 侧的**反向隧道管理器**(T08 跨机演练)。
|
|||
|
|
*
|
|||
|
|
* 为什么需要它:Manager 要连 Worker 上的两样东西 —— **agent 端口**与**每个实例的端口**
|
|||
|
|
* (实例只监听 `127.0.0.1`,这是 portGuard 的设计前提)。而 Worker 公网入方向通常被
|
|||
|
|
* **云安全组**挡住(实测:106 的 19100 从 47 与本机都连不上),放通只能在控制台点。
|
|||
|
|
*
|
|||
|
|
* 绕法:**让 Worker 主动拨 Manager**,用 SSH 反向转发把两边的 `127.0.0.1:<port>` 接起来。
|
|||
|
|
* 好处(实测):
|
|||
|
|
* · **两端都不用新开端口** —— 只用已开放的 SSH 端口(47 是 32022);
|
|||
|
|
* · 链路是加密的,且 Manager 侧落在 loopback(`GatewayPorts no` 默认)⇒ 不对外暴露;
|
|||
|
|
* · 实例端口是**动态**的(`findFreePort()`)⇒ 用 **ControlMaster + `ssh -O forward/cancel`**
|
|||
|
|
* 在**同一条长连接**上加/减转发,不必为每个端口重开连接。
|
|||
|
|
*
|
|||
|
|
* ⚠️ 定位:这是**演练级**传输(生产长期方案见设计 §2.3:受控网段白名单或隧道服务)。
|
|||
* ⚠️ 默认**关闭**:只有设了 `DSHS_RENDEZVOUS_URL` 才启用 ⇒ 对同机/单机形态零影响。
|
||||
|
|
* 旧变量 `DSHS_TUNNEL_TARGET` 作为**兜底**保留(新变量未设时才用它)。
|
|||
*
|
||||
|
|
* @module dshs/worker/tunnel
|
|||
|
|
*/
|
|||
|
|
import { execFile } from 'node:child_process'
|
|||
|
|
import { existsSync, unlinkSync } from 'node:fs'
|
|||
|
|
import { promisify } from 'node:util'
|
|||
|
|
|
|||
|
|
const run = promisify(execFile)
|
|||
|
|
|
|||
/**
|
||||
|
|
* 归一化会合地址(覆盖网络 S1)。
|
|||
|
|
*
|
|||
|
|
* 为什么要它:会合点从"硬写在 env 里的 `user@host:port`"升格为**带 scheme 的 URL**
|
|||
|
|
* (`ssh://[email protected]:32022`)—— 以后换传输协议(中继/隧道服务)只改 scheme。
|
|||
|
|
* 而本类其余代码如下按 `user@host:port` 切分 ⇒ 必须在**入口处**剥掉 scheme:
|
|||
|
|
* 否则 `'ssh://root@h:32022'.split(':')` 会切成三截,把 `ssh` 当成主机名。
|
|||
|
|
*
|
|||
|
|
* 两种写法都接受(**单点归一,调用方不必判断**):
|
|||
|
|
* · `ssh://[email protected]:32022` → `[email protected]:32022`
|
|||
|
|
* · `[email protected]:32022` → 原样(兼容历史 env `DSHS_TUNNEL_TARGET`)
|
|||
|
|
*/
|
|||
|
|
export function normalizeTunnelTarget(raw: string): string {
|
|||
|
|
const trimmed = raw.trim()
|
|||
|
|
if (trimmed === '') return ''
|
|||
|
|
return trimmed
|
|||
|
|
.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // 剥 scheme(ssh:// / dshs+ssh:// …)
|
|||
|
|
.replace(/\/+$/, '') // 去掉可能的尾斜杠
|
|||
|
|
}
|
|||
|
|
|
|||
export interface TunnelOptions {
|
||||
/** 拨入目标,形如 `[email protected]:32022`(带 `ssh://` 前缀也接受,见 {@link normalizeTunnelTarget})。 */
|
||||
target: string
|
||||
|
|
/** 私钥路径(建议专用、且在 Manager 侧用 `restrict,port-forwarding` 限权)。 */
|
|||
|
|
identity: string
|
|||
|
|
/** ControlMaster socket 路径(同一路径复用同一条连接)。 */
|
|||
|
|
controlPath: string
|
|||
|
|
/** 启动时就转发的端口(agent 自身;还可带控制面 PG 等)。 */
|
|||
|
|
staticPorts?: number[]
|
|||
|
|
/** `ssh` 可执行文件路径。 */
|
|||
|
|
sshBin?: string
|
|||
|
|
}
|
|||
|
|
|
|||
/**
|
||||
|
|
* 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
|
|||
|
|
identity: string
|
|||
|
|
controlPath: string
|
|||
|
|
staticPorts: number[]
|
|||
|
|
sshBin: string
|
|||
|
|
}
|
|||
|
|
private readonly forwarded = new Set<number>()
|
|||
|
|
private readonly hostPart: string
|
|||
|
|
private readonly portPart: number | undefined
|
|||
|
|
|
|||
|
|
constructor(options: TunnelOptions) {
|
|||
|
|
// `user@host:port` 里的 port 是 **SSH 端口**(不是转发的端口)—— 47 上用 32022,
|
|||
|
|
// 必须经 `-p` 传,否则会去连 22 而失败。
|
|||
// S1:先归一化(剥 `ssh://` scheme),再按 `user@host:port` 切分。
|
||||
|
|
const normalized = normalizeTunnelTarget(options.target)
|
|||
|
|
const [hostPart, portPart] = normalized.split(':')
|
|||
|
|
if (portPart !== undefined && !/^\d+$/.test(portPart.trim())) {
|
|||
|
|
// 宁可起不来也不要"静默连到 22 端口":地址写错必须吵。
|
|||
|
|
throw new Error(`非法的会合地址(端口必须是数字):${options.target}`)
|
|||
|
|
}
|
|||
this.hostPart = hostPart
|
||||
|
|
this.portPart = portPart === undefined ? undefined : Number(portPart)
|
|||
|
|
this.opts = {
|
|||
target: normalized,
|
||||
identity: options.identity,
|
||||
|
|
controlPath: options.controlPath,
|
|||
|
|
staticPorts: options.staticPorts ?? [],
|
|||
|
|
sshBin: options.sshBin ?? '/usr/bin/ssh',
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 所有 ssh 调用的公共参数(`-p` 只在目标里显式给了端口时才加)。 */
|
|||
|
|
private baseArgs(): string[] {
|
|||
|
|
return this.portPart === undefined ? [] : ['-p', String(this.portPart)]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 当前已转发的端口(诊断用)。 */
|
|||
|
|
get ports(): number[] {
|
|||
|
|
return [...this.forwarded]
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 建立(或复用)ControlMaster 长连接,并把 `staticPorts` 转发上去。
|
|||
|
|
* 幂等:socket 已存在且 master 还活着就直接返回。
|
|||
|
|
*/
|
|||
|
|
async ensureMaster(): Promise<void> {
|
|||
|
|
if (existsSync(this.opts.controlPath)) {
|
|||
|
|
try {
|
|||
|
|
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'check', this.hostPart])
|
|||
|
|
// master 活着 ⇒ 只需补齐静态转发
|
|||
|
|
for (const port of this.opts.staticPorts ?? []) await this.forward(port)
|
|||
|
|
return
|
|||
|
|
} catch {
|
|||
|
|
try {
|
|||
|
|
unlinkSync(this.opts.controlPath) // 僵尸 socket:清掉重建
|
|||
|
|
} catch {
|
|||
|
|
/* 无所谓 */
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const args = [
|
|||
|
|
'-M',
|
|||
|
|
'-N',
|
|||
|
|
'-f',
|
|||
|
|
...this.baseArgs(),
|
|||
|
|
'-S',
|
|||
|
|
this.opts.controlPath,
|
|||
|
|
'-i',
|
|||
|
|
this.opts.identity,
|
|||
|
|
'-o',
|
|||
|
|
'BatchMode=yes',
|
|||
|
|
'-o',
|
|||
|
|
'StrictHostKeyChecking=accept-new',
|
|||
|
|
'-o',
|
|||
|
|
'ExitOnForwardFailure=yes',
|
|||
|
|
'-o',
|
|||
|
|
'ServerAliveInterval=15',
|
|||
|
|
'-o',
|
|||
|
|
'ServerAliveCountMax=4',
|
|||
|
|
]
|
|||
|
|
for (const port of this.opts.staticPorts ?? []) args.push('-R', `${port}:127.0.0.1:${port}`)
|
|||
|
|
args.push(this.hostPart)
|
|||
|
|
await run(this.opts.sshBin, args, { timeout: 20_000 })
|
|||
|
|
for (const port of this.opts.staticPorts ?? []) this.forwarded.add(port)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* master 是否还活着(`ssh -O check`)。
|
|||
|
|
*
|
|||
|
|
* 为什么需要:**对端 SSH 重启/断链后,转发会全部消失,而本地 `forwarded` 集合并不知情**
|
|||
|
|
* ⇒ 若只看本地状态,会以为"隧道还好",实际 Manager 已经连不上这台 Worker
|
|||
|
|
* (2026-09-15 收口"以跑通为目的"时补)。
|
|||
|
|
*/
|
|||
|
|
async isMasterAlive(): Promise<boolean> {
|
|||
|
|
try {
|
|||
|
|
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'check', this.hostPart], {
|
|||
|
|
timeout: 8_000,
|
|||
|
|
})
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
// check 失败 ⇒ master 不在了;顺手清掉本地记账,避免"以为还转着"
|
|||
|
|
this.forwarded.clear()
|
|||
|
|
return false
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 动态加一条反向转发(实例起来时调用)。
|
|||
|
|
* 用**同一个端口号**:实例在 Worker 上是 `127.0.0.1:<port>`,反向转发落到 Manager 的
|
|||
|
|
* `127.0.0.1:<port>` ⇒ Manager 侧无需端口映射表,`endpointFor` 直接回 `127.0.0.1`。
|
|||
|
|
*/
|
|||
|
|
async forward(port: number): Promise<boolean> {
|
|||
|
|
if (this.forwarded.has(port)) return true
|
|||
|
|
try {
|
|||
|
|
await run(
|
|||
|
|
this.opts.sshBin,
|
|||
|
|
[...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'forward', '-R', `${port}:127.0.0.1:${port}`, this.hostPart],
|
|||
|
|
{ timeout: 10_000 },
|
|||
|
|
)
|
|||
|
|
this.forwarded.add(port)
|
|||
|
|
return true
|
|||
|
|
} catch {
|
|||
|
|
return false // 失败不抛:实例本身仍在本机可用,只是跨机代理这跳不可用
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 撤销一条转发(实例停止/退出时调用)。 */
|
|||
|
|
async cancel(port: number): Promise<void> {
|
|||
|
|
if (!this.forwarded.has(port)) return
|
|||
|
|
try {
|
|||
|
|
await run(
|
|||
|
|
this.opts.sshBin,
|
|||
|
|
[...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'cancel', '-R', `${port}:127.0.0.1:${port}`, this.hostPart],
|
|||
|
|
{ timeout: 10_000 },
|
|||
|
|
)
|
|||
|
|
} catch {
|
|||
|
|
/* 连接已断也一样算撤销 */
|
|||
|
|
}
|
|||
|
|
this.forwarded.delete(port)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 关闭 master(进程退出时)。 */
|
|||
|
|
async close(): Promise<void> {
|
|||
|
|
try {
|
|||
|
|
await run(this.opts.sshBin, [...this.baseArgs(), '-S', this.opts.controlPath, '-O', 'exit', this.hostPart], {
|
|||
|
|
timeout: 10_000,
|
|||
|
|
})
|
|||
|
|
} catch {
|
|||
|
|
/* 已退出 */
|
|||
|
|
}
|
|||
|
|
this.forwarded.clear()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** 目标 SSH 端口(`root@h:32022` → 32022)。 */
|
|||
|
|
get targetPort(): number | undefined {
|
|||
|
|
return this.portPart
|
|||
|
|
}
|
|||
|
|
}
|