openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
205 lines (204 loc) • 6.66 kB
JavaScript
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 };