覆盖网络线 S0+S1 落地:可达性单一入口 + 会合地址出 env
S0(本机):新增 src/net/reachability.ts(Reachability 描述 + agentBaseUrlOf 唯一取值入口)、src/net/rendezvous.ts、docs/architecture.md(四层划分 + 依赖方向 + R1-R4 判据)、scripts/check-layering.mjs + layering-baseline.json、test/reachability.test.mjs;src/supervisor/remote-spawner.ts 与 src/web/server.ts 改为统一走 agentBaseUrlOf(),agentUrl 降级为可选旧字段(向后兼容)。 S1(本机 + 106):新增 DSHS_RENDEZVOUS_URL 出 env(src/config.ts 解析 clusterRendezvousUrl,优先级 overrides > 新变量 > DSHS_TUNNEL_TARGET 兜底 > 空),src/worker/tunnel.ts 新增 normalizeTunnelTarget()、src/worker/agent.ts 改读新配置 ⇒ 双路径并存、可零代码回滚(删掉新 env 即走旧路径)。106 已上线,健康检查 tunnel.ready=true。 package.json 增加 check:layering 脚本并接入 verify;README 登记分层文档。 验收:npm run build 通过;npm test 44/44;check:layering 无新增违规(基线 5 条)。
This commit is contained in:
1 parent
08219c99da
commit
640813e84e
13 files changed
+660
-18
No files matched your search
@@ -111,6 +111,13 @@ export interface ServerConfig {
|
||||
* 所有 worker 的 dataRoot 必须是同一个绝对路径(同镜像即可满足,设计 §14.3)。
|
||||
*/
|
||||
clusterWorkerDataRoot: string
|
||||
/**
|
||||
* **worker 侧**会合地址(覆盖网络 S1):worker 主动拨入的 SSH 反向隧道落点。
|
||||
* 形如 `ssh://[email protected]:32022`(也接受不带 scheme 的 `root@host:port`)。空 = 隧道关闭。
|
||||
* ⚠️ 与旧变量 `DSHS_TUNNEL_TARGET` **双路径并存**(新变量优先、旧变量兜底)⇒
|
||||
* 删掉新 env 即回到旧路径,**零代码回滚**。
|
||||
*/
|
||||
clusterRendezvousUrl: string
|
||||
}
|
||||
|
||||
/** Untyped overrides collected from argv / env. */
|
||||
@@ -157,6 +164,7 @@ export interface ConfigOverrides {
|
||||
clusterAgentToken?: string
|
||||
clusterInstanceHost?: string
|
||||
clusterWorkerDataRoot?: string
|
||||
clusterRendezvousUrl?: string
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = '127.0.0.1'
|
||||
@@ -352,5 +360,12 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
|
||||
clusterAgentToken: overrides.clusterAgentToken ?? process.env.DSHS_CLUSTER_AGENT_TOKEN ?? '',
|
||||
clusterInstanceHost: overrides.clusterInstanceHost ?? process.env.DSHS_CLUSTER_INSTANCE_HOST ?? '127.0.0.1',
|
||||
clusterWorkerDataRoot: overrides.clusterWorkerDataRoot ?? process.env.DSHS_CLUSTER_WORKER_DATA_ROOT ?? '',
|
||||
// 覆盖网络 S1:会合地址出 env。新变量 `DSHS_RENDEZVOUS_URL` 优先,旧变量 `DSHS_TUNNEL_TARGET`
|
||||
// 兜底(两台机器可分先后改;删掉新 env 即回滚到旧路径,**不需要回滚代码**)。
|
||||
clusterRendezvousUrl:
|
||||
overrides.clusterRendezvousUrl ??
|
||||
process.env.DSHS_RENDEZVOUS_URL ??
|
||||
process.env.DSHS_TUNNEL_TARGET ??
|
||||
'',
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 可达性(Reachability)—— **"怎么到这台 worker"的可序列化描述**(覆盖网络 S0)。
|
||||
*
|
||||
* ## 为什么需要它
|
||||
* 现状里"怎么到一台 worker"被写死成两件事:`dsh_hosts.endpoint` 存一个 URL,
|
||||
* `clusterInstanceHost` 存一个**全局**主机名。两者都**表达不出"经谁中转"**,而现网
|
||||
* 恰好有两类语义完全不同的 host,字符串却同形:
|
||||
*
|
||||
* | hostId | endpoint | 真实语义 |
|
||||
* |---|---|---|
|
||||
* | `w-47` | `http://127.0.0.1:19100` | **直连本机**(Manager 与 worker 同机,node 直接监听) |
|
||||
* | `w-106` | `http://127.0.0.1:19000` | **经 47 上 sshd 的反向隧道落点**(隧道的副作用) |
|
||||
*
|
||||
* ⇒ 换会合 / 中继组件时,表里**无法表达**「via(经哪个中继)+ 真实可达地址」,
|
||||
* 只能改表;越晚改代价越大(会合中继拆分方案 §2 C3)。
|
||||
*
|
||||
* `Reachability` 把这件事显式化:`via` 指向一个 `Rendezvous` 实现,`address` 是真实地址。
|
||||
*
|
||||
* ## 边界(勿破)
|
||||
* 本模块**只做地址的表征与解析**,不承载任何权威状态 —— 归属 / 租约 / 骨干资格
|
||||
* 一律仍只由控制面写(与 `集群化改造方案 §1.3` 数据分层一致)。
|
||||
*
|
||||
* @module dshs/net/reachability
|
||||
*/
|
||||
|
||||
/** 同机直连:Manager 与 worker 在同一台机器上,不经任何中转。 */
|
||||
export const VIA_LOCAL = 'local'
|
||||
|
||||
/** 今天唯一在跑的中转方式 = **Manager 主机上的 sshd 反向隧道**(S4 之后应被 relay 取代)。 */
|
||||
export const VIA_MANAGER_SSH = 'manager-ssh'
|
||||
|
||||
export interface Reachability {
|
||||
/** 哪台 worker。 */
|
||||
hostId: string
|
||||
/** **经谁可达** —— 一个 `Rendezvous` 实现的 id。 */
|
||||
via: string
|
||||
/** agent 的真实地址 `host:port`(**不含 scheme**)。 */
|
||||
address: string
|
||||
/** 传输层。 */
|
||||
scheme: 'http' | 'https'
|
||||
}
|
||||
|
||||
/** 只取址所需的最小形状 —— 避免本模块反向依赖 `supervisor`。 */
|
||||
export interface HostAddressable {
|
||||
hostId?: string
|
||||
agentUrl?: string
|
||||
reachability?: Reachability
|
||||
}
|
||||
|
||||
/**
|
||||
* 把可达性拼成 agent 基址 —— **全仓唯一的拼接点**,别在别处再拼 `scheme://address`。
|
||||
*/
|
||||
export function agentBaseUrl(reach: Reachability): string {
|
||||
return `${reach.scheme}://${reach.address}`.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* 取一台 host 的 agent 基址:**可达性优先,回退旧 `agentUrl`**。
|
||||
*
|
||||
* S0 阶段的等价性:现网每个 host 都只有 `agentUrl`(`reachability` 全为 `undefined`)
|
||||
* ⇒ 本函数返回的就是原先直接用的那个字符串,**行为零变化**。
|
||||
*
|
||||
* ⚠️ 两者皆缺时**抛错**,不返回空串 —— "静默打到空地址"是跨机下最难查的失败。
|
||||
*/
|
||||
export function agentBaseUrlOf(host: HostAddressable): string {
|
||||
if (host.reachability !== undefined) return agentBaseUrl(host.reachability)
|
||||
if (host.agentUrl !== undefined && host.agentUrl !== '') return host.agentUrl.replace(/\/+$/, '')
|
||||
throw new Error(`host "${host.hostId ?? '?'}" 既无 reachability 也无 agentUrl:拒绝静默降级`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从旧的 `endpoint` 字符串解析出 `Reachability`(S2 迁移回填用)。
|
||||
*
|
||||
* 兼容面:`endpoint` 历史上是完整 URL(`http://127.0.0.1:19000`),也容忍裸
|
||||
* `host:port` —— 没写 scheme 时按 `http` 处理,与 `RemoteSpawner` 原先"直接把它当
|
||||
* fetch 基址"的行为一致(fetch 会补 `http://`)。
|
||||
*/
|
||||
export function parseReachability(
|
||||
hostId: string,
|
||||
endpoint: string,
|
||||
via: string = VIA_MANAGER_SSH,
|
||||
): Reachability {
|
||||
const trimmed = endpoint.trim()
|
||||
const matched = /^(https?):\/\/(.*)$/i.exec(trimmed)
|
||||
if (matched !== null) {
|
||||
return {
|
||||
hostId,
|
||||
via,
|
||||
address: matched[2].replace(/\/+$/, ''),
|
||||
scheme: matched[1].toLowerCase() === 'https' ? 'https' : 'http',
|
||||
}
|
||||
}
|
||||
return { hostId, via, address: trimmed.replace(/\/+$/, ''), scheme: 'http' }
|
||||
}
|
||||
|
||||
/** `Reachability` → 旧 `endpoint` 字符串(与 `parseReachability` 互逆,回填/回滚用)。 */
|
||||
export function toEndpoint(reach: Reachability): string {
|
||||
return agentBaseUrl(reach)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 会合(Rendezvous)—— **"该拨谁、经谁到"的解析器**(覆盖网络 S0)。
|
||||
*
|
||||
* ## 定位(分层口径,勿破)
|
||||
* | 组件 | 职责 | 可多实例? | 权威状态 |
|
||||
* |---|---|---|---|
|
||||
* | **会合 (rendezvous)** | 收 worker 注册、回"该拨谁"、下发中继分配;**不承载数据面流量** | ✅ 无状态可复制 | ❌ 只有位置视图 |
|
||||
* | **中继 (relay)** | 数据面:worker 拨它 → Manager / 其他节点经它到 worker | ✅ | ❌ |
|
||||
* | **控制面 (Manager)** | 归属 / 租约 / 骨干资格 / 容量准入 | ❌ 单点 | ✅ 唯一写入者 |
|
||||
*
|
||||
* ⛔ **硬约束**:会合与中继**不得**写入 `dsh_instances.host_id` / `epoch` / 骨干资格 ——
|
||||
* 否则就是双写脑裂。
|
||||
*
|
||||
* ## 现状与目标
|
||||
* 今天"会合 + 中继"**不是一个组件,而是 Manager 主机上 sshd 的副作用**:
|
||||
* 会合点 = `47.77.182.89:32022`,中继落点 = Manager 的 `127.0.0.1`。
|
||||
* 本模块的作用是**先把接口抽出来**,让 SSH 隧道退化成"第一个可替换实现"
|
||||
* —— ⛔ 这一步**不换协议**,只换绑定与寻址(换 WireGuard / TURN 属远期)。
|
||||
*
|
||||
* @module dshs/net/rendezvous
|
||||
*/
|
||||
|
||||
import { VIA_LOCAL, VIA_MANAGER_SSH, type Reachability } from './reachability.js'
|
||||
|
||||
export interface Rendezvous {
|
||||
/** 实现 id —— `Reachability.via` 指向它。 */
|
||||
readonly id: string
|
||||
/** 这个会合点**本身**怎么拨(诊断 / 管理面展示用)。 */
|
||||
dialTarget(): string
|
||||
/**
|
||||
* 解析某台 worker 的可达性。
|
||||
* ⚠️ **本实现管不到 ⇒ 回 `undefined`,不抛** —— 由调用方决定回退哪种实现
|
||||
* (抛错会让"多实现并存"的过渡期没法跑)。
|
||||
*/
|
||||
resolve(hostId: string): Promise<Reachability | undefined>
|
||||
}
|
||||
|
||||
/** 由调用方提供"hostId → `host:port`"的查表函数(会合实现不直接连 DB)。 */
|
||||
export type AddressLookup = (hostId: string) => string | undefined
|
||||
|
||||
/** 同机直连:Manager 能直接连到 worker 的端口,不经任何中转(`w-47` 就是这一类)。 */
|
||||
export class LocalRendezvous implements Rendezvous {
|
||||
readonly id = VIA_LOCAL
|
||||
|
||||
constructor(private readonly addressOf: AddressLookup) {}
|
||||
|
||||
dialTarget(): string {
|
||||
return '(direct)'
|
||||
}
|
||||
|
||||
async resolve(hostId: string): Promise<Reachability | undefined> {
|
||||
const address = this.addressOf(hostId)
|
||||
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager 主机上的 sshd 反向隧道 —— **当前唯一在跑的实现**。
|
||||
*
|
||||
* 语义:worker 主动 `ssh -R <port>:127.0.0.1:<port> root@<manager>:<port>`,把端口投到
|
||||
* Manager 的 **loopback**(`127.0.0.1:<同号端口>`)⇒ Manager 经 `127.0.0.1:<port>` 到达它。
|
||||
*
|
||||
* ⚠️ 这正是要拆掉的那一层(C1 会合地址硬编码 + C2 中继落点 = Manager loopback)。
|
||||
* S4 把"接收 worker 反拨"搬进独立单元 `dshs-relay.service` 后,本类应被 `relay:<id>`
|
||||
* 实现替换,而**调用方不需要改**(只认 `Rendezvous` 接口)。
|
||||
*/
|
||||
export class ManagerSshRendezvous implements Rendezvous {
|
||||
readonly id = VIA_MANAGER_SSH
|
||||
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
/** 会合点的 SSH 目标,如 `[email protected]:32022`。 */
|
||||
target: string
|
||||
addressOf: AddressLookup
|
||||
},
|
||||
) {}
|
||||
|
||||
dialTarget(): string {
|
||||
return this.opts.target
|
||||
}
|
||||
|
||||
async resolve(hostId: string): Promise<Reachability | undefined> {
|
||||
const address = this.opts.addressOf(hostId)
|
||||
return address === undefined ? undefined : { hostId, via: this.id, address, scheme: 'http' }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 `via` 选实现的注册表。
|
||||
*
|
||||
* 为什么需要:S2 起 `dsh_hosts` 会带 `via` 列,`hostsProvider` 必须"**先读 via →
|
||||
* 选对应实现 → 解析成 `Reachability`**";`via` 未设时回退旧 `endpoint` 语义。
|
||||
*/
|
||||
export class RendezvousRegistry {
|
||||
private readonly impls = new Map<string, Rendezvous>()
|
||||
|
||||
constructor(impls: readonly Rendezvous[] = []) {
|
||||
for (const impl of impls) this.register(impl)
|
||||
}
|
||||
|
||||
register(impl: Rendezvous): void {
|
||||
this.impls.set(impl.id, impl)
|
||||
}
|
||||
|
||||
get(id: string): Rendezvous | undefined {
|
||||
return this.impls.get(id)
|
||||
}
|
||||
|
||||
ids(): string[] {
|
||||
return [...this.impls.keys()]
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,26 @@
|
||||
* @module dshs/supervisor/remote-spawner
|
||||
*/
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { agentBaseUrlOf, type Reachability } from '../net/reachability.js'
|
||||
import { AGENT_TOKEN_HEADER } from '../worker/agent.js'
|
||||
import type { Endpoint, Instance, Spawner, UserStatus } from './spawner.js'
|
||||
|
||||
/** 一台 worker 的接入信息。 */
|
||||
/**
|
||||
* 一台 worker 的接入信息。
|
||||
*
|
||||
* ⚠️ `agentUrl` 与 `reachability` **至少要有一个** —— 取址一律走 `agentBaseUrlOf()`
|
||||
* (唯一入口,别在调用点自己拼字符串):
|
||||
* · `reachability` = S0 引入的**可达性描述**,比 `agentUrl` 多一层语义 ——
|
||||
* **经谁中转**(`via`)。现网两类 host 的 `endpoint` 字符串同形但语义完全不同
|
||||
* (`w-47` 是直连本机、`w-106` 是 Manager 上的隧道落点),只有它能表达。
|
||||
* · `agentUrl` = 旧字段,保留向后兼容。
|
||||
*/
|
||||
export interface ClusterHost {
|
||||
hostId: string
|
||||
/** agent 基址。 */
|
||||
agentUrl: string
|
||||
/** agent 基址(旧字段;与 `reachability` 至少给一个)。 */
|
||||
agentUrl?: string
|
||||
/** **可达性**:经谁中转 + 真实地址。给了就优先于 `agentUrl`(S2 起由 `dsh_hosts.via` 驱动)。 */
|
||||
reachability?: Reachability
|
||||
/** 与 agent 约定的共享密钥。 */
|
||||
token: string
|
||||
/** 代理时使用的主机(同机 1a = `127.0.0.1`;跨机填 Worker 内网 IP)。 */
|
||||
@@ -81,15 +93,17 @@ export class RemoteSpawner implements Spawner {
|
||||
private directoryLoadedAt = 0
|
||||
|
||||
constructor(options: RemoteSpawnerOptions) {
|
||||
// ⚠️ 这里**不再做** `replace(/\/$/,'')` 归一化 —— 归一化统一在 `agentBaseUrlOf()`
|
||||
// 里做(幂等)。存归一化值会让"可达性与 agentUrl 两套表示"混在一起,S2 之后难拆。
|
||||
this.defaultHost = {
|
||||
hostId: options.defaultHostId ?? 'local',
|
||||
agentUrl: options.agentUrl.replace(/\/$/, ''),
|
||||
agentUrl: options.agentUrl,
|
||||
token: options.token,
|
||||
instanceHost: options.instanceHost ?? '127.0.0.1',
|
||||
}
|
||||
this.hosts.set(this.defaultHost.hostId, this.defaultHost)
|
||||
for (const host of options.hosts ?? []) {
|
||||
this.hosts.set(host.hostId, { ...host, agentUrl: host.agentUrl.replace(/\/$/, '') })
|
||||
this.hosts.set(host.hostId, { ...host })
|
||||
}
|
||||
this.timeoutMs = options.timeoutMs ?? 10_000
|
||||
this.doFetch = options.fetchImpl ?? fetch
|
||||
@@ -110,7 +124,7 @@ export class RemoteSpawner implements Spawner {
|
||||
this.directoryLoadedAt = Date.now()
|
||||
try {
|
||||
for (const host of await this.hostsProvider()) {
|
||||
this.hosts.set(host.hostId, { ...host, agentUrl: host.agentUrl.replace(/\/$/, '') })
|
||||
this.hosts.set(host.hostId, { ...host })
|
||||
}
|
||||
this.hosts.set(this.defaultHost.hostId, this.defaultHost)
|
||||
} catch {
|
||||
@@ -177,7 +191,7 @@ export class RemoteSpawner implements Spawner {
|
||||
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(`${host.agentUrl}${path}`, {
|
||||
const res = await this.doFetch(`${agentBaseUrlOf(host)}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
[AGENT_TOKEN_HEADER]: host.token,
|
||||
@@ -314,7 +328,7 @@ export class RemoteSpawner implements Spawner {
|
||||
touch(userId: string): void {
|
||||
void this.hostFor(userId)
|
||||
.then((host) =>
|
||||
this.doFetch(`${host.agentUrl}/touch/${encodeURIComponent(userId)}`, {
|
||||
this.doFetch(`${agentBaseUrlOf(host)}/touch/${encodeURIComponent(userId)}`, {
|
||||
method: 'POST',
|
||||
headers: { [AGENT_TOKEN_HEADER]: host.token },
|
||||
}),
|
||||
|
||||
+5
-2
@@ -17,6 +17,7 @@ import { RemoteUserFs } from '../fs/remote-user-fs.js'
|
||||
import type { UserFs } from '../fs/user-fs.js'
|
||||
import { decrypt, deriveKey } from '../crypto.js'
|
||||
import { hashUid } from '../isolation.js'
|
||||
import { agentBaseUrlOf } from '../net/reachability.js'
|
||||
import { LocalSpawner } from '../supervisor/orchestrator.js'
|
||||
import { LeasedSpawner } from '../supervisor/leased-spawner.js'
|
||||
import { RemoteSpawner, type ClusterHost } from '../supervisor/remote-spawner.js'
|
||||
@@ -339,7 +340,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
selectHost,
|
||||
agentFor: (hostId: string) => {
|
||||
const h = hostDirectory.get(hostId)
|
||||
return h === undefined ? undefined : { agentUrl: h.agentUrl, token: h.token }
|
||||
// 取址统一走可达性入口(S0)—— `agentUrl` 已降级为可选旧字段
|
||||
return h === undefined ? undefined : { agentUrl: agentBaseUrlOf(h), token: h.token }
|
||||
},
|
||||
},
|
||||
))
|
||||
@@ -354,7 +356,8 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
hostIdFor: hostIdForFile,
|
||||
agentFor: (hostId: string) => {
|
||||
const h = hostDirectory.get(hostId)
|
||||
return h === undefined ? undefined : { agentUrl: h.agentUrl, token: h.token }
|
||||
// 同上一处:取址只经可达性入口(文件面与实例面共用同一份路由,别各自拼)
|
||||
return h === undefined ? undefined : { agentUrl: agentBaseUrlOf(h), token: h.token }
|
||||
},
|
||||
})
|
||||
// T08 S5:cluster 模式下**所有 worker 的 dataRoot 必须是同一绝对路径**(基线约定,
|
||||
|
||||
+7
-2
@@ -28,7 +28,7 @@ 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 { SshTunnel } from './tunnel.js'
|
||||
import { normalizeTunnelTarget, SshTunnel } from './tunnel.js'
|
||||
import type { Instance } from '../supervisor/spawner.js'
|
||||
|
||||
/** 绑定的头部名(Manager/agent 双方约定)。 */
|
||||
@@ -150,8 +150,13 @@ export function buildWorkerAgent(
|
||||
/**
|
||||
* 反向隧道(可选)。静态转发 = **agent 自身端口** + `DSHS_TUNNEL_STATIC_PORTS`(如控制面 PG);
|
||||
* 实例端口在 launch/stop 时动态加减,并在 `/healthz`(Manager 的心跳)里**对账自愈**。
|
||||
*
|
||||
* S1:会合地址**优先取 config**(`DSHS_RENDEZVOUS_URL` → 兜底 `DSHS_TUNNEL_TARGET`,在
|
||||
* `config.ts` 里单点解析);显式 `options.tunnelTarget` 仍是最优先(测试/嵌入用)。
|
||||
*/
|
||||
const tunnelTarget = options.tunnelTarget ?? process.env.DSHS_TUNNEL_TARGET ?? ''
|
||||
const tunnelTarget = normalizeTunnelTarget(
|
||||
options.tunnelTarget ?? config.clusterRendezvousUrl ?? '',
|
||||
)
|
||||
const staticPorts = [
|
||||
options.port,
|
||||
...(process.env.DSHS_TUNNEL_STATIC_PORTS ?? '')
|
||||
|
||||
+31
-4
@@ -13,7 +13,8 @@
|
||||
* 在**同一条长连接**上加/减转发,不必为每个端口重开连接。
|
||||
*
|
||||
* ⚠️ 定位:这是**演练级**传输(生产长期方案见设计 §2.3:受控网段白名单或隧道服务)。
|
||||
* ⚠️ 默认**关闭**:只有设了 `DSHS_TUNNEL_TARGET` 才启用 ⇒ 对同机/单机形态零影响。
|
||||
* ⚠️ 默认**关闭**:只有设了 `DSHS_RENDEZVOUS_URL` 才启用 ⇒ 对同机/单机形态零影响。
|
||||
* 旧变量 `DSHS_TUNNEL_TARGET` 作为**兜底**保留(新变量未设时才用它)。
|
||||
*
|
||||
* @module dshs/worker/tunnel
|
||||
*/
|
||||
@@ -23,8 +24,28 @@ 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`。 */
|
||||
/** 拨入目标,形如 `[email protected]:32022`(带 `ssh://` 前缀也接受,见 {@link normalizeTunnelTarget})。 */
|
||||
target: string
|
||||
/** 私钥路径(建议专用、且在 Manager 侧用 `restrict,port-forwarding` 限权)。 */
|
||||
identity: string
|
||||
@@ -52,11 +73,17 @@ export class SshTunnel {
|
||||
constructor(options: TunnelOptions) {
|
||||
// `user@host:port` 里的 port 是 **SSH 端口**(不是转发的端口)—— 47 上用 32022,
|
||||
// 必须经 `-p` 传,否则会去连 22 而失败。
|
||||
const [hostPart, portPart] = options.target.split(':')
|
||||
// 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: options.target,
|
||||
target: normalized,
|
||||
identity: options.identity,
|
||||
controlPath: options.controlPath,
|
||||
staticPorts: options.staticPorts ?? [],
|
||||
|
||||
Reference in new issue
Block a user