mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 06:02:56 +00:00
Connect to remote daemons over SSH (#3989)
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>
This commit is contained in:
co-authored by
James Strain
Reid Levesque
parent
7ea4288117
commit
667664847d
@@ -106,13 +106,16 @@ export type AgentLsResult = ListResult<AgentListItem>;
|
||||
|
||||
function daemonConnectionFailure(host: string, cause: unknown): CommandError {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
const isSsh = host.trim().startsWith("ssh://");
|
||||
return {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot reach the daemon at ${host}: ${reason}`,
|
||||
details: [
|
||||
"Start a local daemon with: paseo daemon start",
|
||||
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
|
||||
].join("\n"),
|
||||
details: isSsh
|
||||
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
|
||||
: [
|
||||
"Start a local daemon with: paseo daemon start",
|
||||
"To use another daemon, pass --host <host:port> or set PASEO_HOST.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSshFailureDetail } from "../ssh/ssh-tunnel.js";
|
||||
import { connectToDaemon } from "./client.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
configs: [] as Array<Record<string, unknown>>,
|
||||
createSshTunnel: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@getpaseo/client/internal/daemon-client", () => ({
|
||||
DaemonClient: class {
|
||||
lastError = null;
|
||||
|
||||
constructor(config: Record<string, unknown>) {
|
||||
mocks.configs.push(config);
|
||||
}
|
||||
|
||||
async connect() {}
|
||||
async close() {}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../ssh/ssh-tunnel.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../ssh/ssh-tunnel.js")>()),
|
||||
createSshTunnel: mocks.createSshTunnel,
|
||||
}));
|
||||
vi.mock("./client-id.js", () => ({ getOrCreateCliClientId: async () => "cli-test-id" }));
|
||||
|
||||
describe("CLI SSH transport", () => {
|
||||
beforeEach(() => {
|
||||
mocks.configs.length = 0;
|
||||
mocks.createSshTunnel.mockReset();
|
||||
mocks.createSshTunnel.mockResolvedValue({
|
||||
endpoint: "127.0.0.1:4567",
|
||||
close: vi.fn(),
|
||||
failureDetail: () => null,
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces SSH stderr before the child exit event settles", () => {
|
||||
expect(resolveSshFailureDetail(null, "Host key verification failed.\n")).toBe(
|
||||
"Host key verification failed.",
|
||||
);
|
||||
expect(resolveSshFailureDetail("ssh exited with code 255", "earlier stderr")).toBe(
|
||||
"ssh exited with code 255",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes an SSH host through a local tunnel", async () => {
|
||||
await connectToDaemon({ host: "ssh://deploy@build-box:2222?daemonPort=7777" });
|
||||
|
||||
expect(mocks.createSshTunnel).toHaveBeenCalledWith({
|
||||
host: "deploy@build-box",
|
||||
sshPort: 2222,
|
||||
daemonPort: 7777,
|
||||
});
|
||||
expect(mocks.configs[0]).toMatchObject({
|
||||
url: "ws://127.0.0.1:4567/ws",
|
||||
clientId: "cli-test-id",
|
||||
clientType: "cli",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
parseConnectionOfferFromUrl,
|
||||
type ConnectionOffer,
|
||||
} from "@getpaseo/protocol/connection-offer";
|
||||
import { parseSshTransportUri } from "@getpaseo/protocol/ssh-transport";
|
||||
import { DaemonClient, type WebSocketLike } from "@getpaseo/client/internal/daemon-client";
|
||||
import path from "node:path";
|
||||
import { WebSocket } from "ws";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
import { resolveCliVersion } from "../version.js";
|
||||
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
|
||||
|
||||
export interface ConnectOptions {
|
||||
host?: string;
|
||||
@@ -59,7 +61,9 @@ export function buildDaemonConnectionCommandError(options: {
|
||||
return {
|
||||
code: "DAEMON_NOT_RUNNING",
|
||||
message: `Cannot connect to daemon at ${host}: ${message}`,
|
||||
details: "Start the daemon with: paseo daemon start",
|
||||
details: host.trim().startsWith("ssh://")
|
||||
? "Start the Paseo daemon on the SSH host; SSH transport does not install or start it."
|
||||
: "Start the daemon with: paseo daemon start",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -357,6 +361,24 @@ export async function connectToDaemon(options?: ConnectOptions): Promise<DaemonC
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
|
||||
const explicitHost = options?.host ?? process.env.PASEO_HOST;
|
||||
if (explicitHost?.trim().startsWith("ssh://")) {
|
||||
const target = parseSshTransportUri(explicitHost.trim());
|
||||
const tunnel = await createSshTunnel(target);
|
||||
const password = resolveDaemonPassword(explicitHost);
|
||||
const result = await tryConnectHost(
|
||||
tunnel.endpoint,
|
||||
password,
|
||||
clientId,
|
||||
timeout,
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) return result.client;
|
||||
|
||||
const failure = tunnel.failureDetail();
|
||||
tunnel.close();
|
||||
if (failure) throw new Error(`SSH connection failed: ${failure}`, { cause: result.error });
|
||||
throw result.error;
|
||||
}
|
||||
const offer = parseHostOfferOrNull(explicitHost);
|
||||
if (offer) {
|
||||
return connectViaRelayOffer(offer, clientId, timeout, nodeWebSocketFactory);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Command } from "commander";
|
||||
|
||||
const JSON_OPTION_DESCRIPTION = "Output in JSON format";
|
||||
const DAEMON_HOST_OPTION_DESCRIPTION =
|
||||
"Daemon host target: host:port or tcp://host:port?ssl=true&password=secret (default: local socket/pipe, then localhost:6767)";
|
||||
"Daemon host target: host:port, tcp://host:port, or ssh://user@host (default: local socket/pipe, then localhost:6767)";
|
||||
|
||||
export function collectMultiple(value: string, previous: string[]): string[] {
|
||||
return previous.concat([value]);
|
||||
|
||||
Reference in New Issue
Block a user