mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
Add transport-only SSH connectivity for the CLI and Electron desktop through the user's OpenSSH configuration. Remote installation and daemon lifecycle remain explicit, separate operations. Preserve actionable SSH failure details when the proxy socket closes before the SSH child exits. Closes #3949 Co-authored-by: James Strain <jstrain@nvidia.com> Co-authored-by: Reid Levesque <reidlevesque@users.noreply.github.com>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
import { createServer, type Server, type Socket } from "node:net";
|
|
import { buildSshTunnelArgs, type SshTransportTarget } from "@getpaseo/protocol/ssh-transport";
|
|
|
|
const SSH_STDERR_LIMIT = 8192;
|
|
|
|
export interface SshTunnel {
|
|
endpoint: string;
|
|
close(): void;
|
|
failureDetail(): string | null;
|
|
}
|
|
|
|
function formatSshFailure(
|
|
stderr: string,
|
|
code: number | null,
|
|
signal: NodeJS.Signals | null,
|
|
): string {
|
|
const detail = stderr.trim();
|
|
if (detail) return detail;
|
|
if (signal) return `ssh exited with signal ${signal}`;
|
|
return `ssh exited with code ${code ?? "unknown"}`;
|
|
}
|
|
|
|
export function resolveSshFailureDetail(failure: string | null, stderr: string): string | null {
|
|
return failure ?? (stderr.trim() || null);
|
|
}
|
|
|
|
export function createSshTunnel(target: SshTransportTarget): Promise<SshTunnel> {
|
|
let server: Server | null = null;
|
|
let socket: Socket | null = null;
|
|
let child: ChildProcessWithoutNullStreams | null = null;
|
|
let stderr = "";
|
|
let failure: string | null = null;
|
|
|
|
function close(): void {
|
|
server?.close();
|
|
server = null;
|
|
socket?.destroy();
|
|
socket = null;
|
|
if (child && !child.killed) child.kill();
|
|
child = null;
|
|
}
|
|
|
|
return new Promise((resolve, reject) => {
|
|
server = createServer((acceptedSocket) => {
|
|
socket = acceptedSocket;
|
|
server?.close();
|
|
server = null;
|
|
|
|
child = spawn("ssh", buildSshTunnelArgs(target), {
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
windowsHide: true,
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer | string) => {
|
|
stderr = `${stderr}${chunk.toString()}`.slice(-SSH_STDERR_LIMIT);
|
|
});
|
|
child.on("error", (error) => {
|
|
failure = error.message;
|
|
acceptedSocket.destroy(error);
|
|
});
|
|
child.on("exit", (code, signal) => {
|
|
if (code !== 0 || signal) failure = formatSshFailure(stderr, code, signal);
|
|
acceptedSocket.destroy(failure ? new Error(failure) : undefined);
|
|
});
|
|
acceptedSocket.on("error", () => undefined);
|
|
acceptedSocket.on("close", () => {
|
|
if (child && !child.killed) child.kill();
|
|
});
|
|
acceptedSocket.pipe(child.stdin);
|
|
child.stdout.pipe(acceptedSocket);
|
|
});
|
|
server.once("error", (error) => {
|
|
close();
|
|
reject(error);
|
|
});
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server?.address();
|
|
if (!address || typeof address === "string") {
|
|
close();
|
|
reject(new Error("Failed to allocate the SSH tunnel port"));
|
|
return;
|
|
}
|
|
resolve({
|
|
endpoint: `127.0.0.1:${address.port}`,
|
|
close,
|
|
failureDetail: () => resolveSshFailureDetail(failure, stderr),
|
|
});
|
|
});
|
|
});
|
|
}
|