UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

205 lines (204 loc) 6.66 kB
import { w as parseStrictPositiveInteger } from "./number-coercion-CLj0HTDM.js"; import { d as normalizeStringEntries } from "./string-normalization-DsCfAx8q.js"; import { n as isErrno } from "./errno-CkbDOfLk.js"; import { s as sleepWithAbort } from "./src-BQ327IOM.js"; import { n as isAbortError, r as racePromiseWithAbortSignal, t as createAbortError } from "./abort-signal-D2k14JsD.js"; import { t as formatErrorMessage } from "./errors-Db3Ymjlb.js"; import { r as tryListenOnPort } from "./ports-probe-funEe1wt.js"; import { r as ensurePortAvailable, t as PortInUseError } from "./ports-CU06KPCY.js"; import "./backoff-BkMI1WEL.js"; import { t as resolveSshClient } from "./ssh-client-CjtQp8D3.js"; import { spawn } from "node:child_process"; import net from "node:net"; //#region src/infra/ssh-tunnel.ts function hasControlOrWhitespace(value) { for (const char of value) { const code = char.charCodeAt(0); if (code <= 31 || code === 127 || /\s/.test(char)) return true; } return false; } function isSafeSshTargetUser(user) { return !hasControlOrWhitespace(user) && !user.startsWith("-"); } function isSafeSshTargetHost(host) { return !hasControlOrWhitespace(host) && !host.startsWith("-") && !host.startsWith(":") && !host.endsWith(":") && !host.includes("@"); } function parseSshTarget(raw) { const trimmed = raw.trim().replace(/^ssh\s+/, ""); if (!trimmed) return null; const [userPart, hostPart] = trimmed.includes("@") ? (() => { const idx = trimmed.indexOf("@"); const user = trimmed.slice(0, idx).trim(); const host = trimmed.slice(idx + 1).trim(); return [user || void 0, host]; })() : [void 0, trimmed]; const colonIdx = hostPart.lastIndexOf(":"); if (colonIdx > 0 && colonIdx < hostPart.length - 1) { const host = hostPart.slice(0, colonIdx).trim(); const portRaw = hostPart.slice(colonIdx + 1).trim(); const port = parseStrictPositiveInteger(portRaw); if (!host || port === void 0 || port > 65535) return null; if (!isSafeSshTargetHost(host)) return null; if (userPart !== void 0 && !isSafeSshTargetUser(userPart)) return null; return { user: userPart, host, port }; } if (!hostPart) return null; if (!isSafeSshTargetHost(hostPart)) return null; if (userPart !== void 0 && !isSafeSshTargetUser(userPart)) return null; return { user: userPart, host: hostPart, port: 22 }; } async function canConnectLocal(port, signal) { signal.throwIfAborted(); return await new Promise((resolve) => { const socket = net.connect({ host: "127.0.0.1", port }); let connected = false; const destroy = () => socket.destroy(); signal.addEventListener("abort", destroy, { once: true }); socket.once("connect", () => { connected = true; destroy(); }); socket.once("error", destroy); socket.setTimeout(250, destroy); socket.once("close", () => { signal.removeEventListener("abort", destroy); resolve(connected); }); }); } async function waitForLocalListener(port, timeoutMs, signal) { const startedAt = performance.now(); while (performance.now() - startedAt < timeoutMs) { if (await canConnectLocal(port, signal)) return; await sleepWithAbort(50, signal); } throw new Error(`ssh tunnel did not start listening on localhost:${port}`); } async function startSshPortForward(opts) { const parsed = parseSshTarget(opts.target); if (!parsed) throw new Error(`invalid SSH target: ${opts.target}`); const sshPath = resolveSshClient(); if (!sshPath) throw new Error("trusted SSH client not found in system directories"); let localPort = opts.localPortPreferred; try { await ensurePortAvailable(localPort, "127.0.0.1"); } catch (err) { if (err instanceof PortInUseError || isErrno(err) && err.code === "EADDRINUSE") localPort = await tryListenOnPort({ port: 0, host: "127.0.0.1" }); else throw err; } const userHost = parsed.user ? `${parsed.user}@${parsed.host}` : parsed.host; const args = [ "-N", "-L", `127.0.0.1:${localPort}:127.0.0.1:${opts.remotePort}`, "-p", String(parsed.port), "-o", "ExitOnForwardFailure=yes", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", "-o", "UpdateHostKeys=yes", "-o", "ConnectTimeout=5", "-o", "ServerAliveInterval=15", "-o", "ServerAliveCountMax=3" ]; if (opts.identity?.trim()) args.push("-i", opts.identity.trim()); args.push("--", userHost); if (opts.signal?.aborted) throw createAbortError("SSH tunnel start aborted", { cause: opts.signal.reason }); const stderr = []; const child = spawn(sshPath, args, { stdio: [ "ignore", "ignore", "pipe" ] }); const stderrStream = child.stderr; stderrStream?.on("error", () => {}); stderrStream?.setEncoding("utf8"); stderrStream?.on("data", (chunk) => { const lines = normalizeStringEntries(String(chunk).split("\n")); stderr.push(...lines); }); const exited = new Promise((resolve) => { child.once("exit", () => resolve()); child.once("close", () => resolve()); }); let onAbort; const detachAbort = () => { if (onAbort) { opts.signal?.removeEventListener("abort", onAbort); onAbort = void 0; } }; let stopping; const stop = () => stopping ??= (async () => { detachAbort(); const timer = setTimeout(() => child.kill("SIGKILL"), 1500); try { child.kill("SIGTERM"); await exited; } finally { clearTimeout(timer); } })(); const readinessController = new AbortController(); const readiness = waitForLocalListener(localPort, Math.max(250, opts.timeoutMs), readinessController.signal); try { try { await racePromiseWithAbortSignal(Promise.race([readiness, new Promise((_, reject) => { child.once("error", (err) => reject(err)); child.once("exit", (code, signal) => { reject(/* @__PURE__ */ new Error(`ssh exited (${code ?? "null"}${signal ? `/${signal}` : ""})`)); }); })]), opts.signal); } finally { readinessController.abort(); await readiness.catch(() => {}); } } catch (err) { await stop(); if (isAbortError(err)) throw err; const suffix = stderr.length > 0 ? `\n${stderr.join("\n")}` : ""; throw new Error(`${formatErrorMessage(err)}${suffix}`, { cause: err }); } if (opts.signal) { onAbort = () => void stop().catch(() => {}); opts.signal.addEventListener("abort", onAbort, { once: true }); if (opts.signal.aborted) { onAbort(); await stop(); throw createAbortError("SSH tunnel start aborted", { cause: opts.signal.reason }); } } exited.then(detachAbort); return { parsedTarget: parsed, localPort, remotePort: opts.remotePort, pid: typeof child.pid === "number" ? child.pid : null, stderr, stop }; } //#endregion export { startSshPortForward as n, parseSshTarget as t };