mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 12:12:47 +00:00
Route SSH stdin stream errors through the existing tunnel failure handler so pending writes cannot emit an unhandled EPIPE. Reproduce the peer abort using real OpenSSH in an isolated caller process. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
93 lines
2.8 KiB
TypeScript
93 lines
2.8 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);
|
|
});
|
|
const fail = (error: Error) => {
|
|
failure = error.message;
|
|
acceptedSocket.destroy(error);
|
|
};
|
|
child.on("error", fail);
|
|
child.stdin.on("error", fail);
|
|
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),
|
|
});
|
|
});
|
|
});
|
|
}
|