mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-28 20:22:45 +00:00
feat(hosts): connect to saved hosts through a daemon
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHostCommand } from "./commands/host/index.js";
|
||||
import { pairCommand } from "./commands/daemon/pair.js";
|
||||
import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
@@ -58,6 +59,8 @@ export function createCli(): Command {
|
||||
.option("--no-color", "disable colored output");
|
||||
addDaemonHostOption(program);
|
||||
|
||||
program.addCommand(createHostCommand());
|
||||
|
||||
// Primary agent commands (top-level)
|
||||
addJsonAndDaemonHostOptions(addLsOptions(program.command("ls"))).action(withOutput(runLsCommand));
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Command } from "commander";
|
||||
import type { HostSummary, HostUpdate } from "@getpaseo/client";
|
||||
import {
|
||||
withOutput,
|
||||
type CommandOptions,
|
||||
type OutputSchema,
|
||||
type CommandResult,
|
||||
} from "../../output/index.js";
|
||||
import { addJsonAndDaemonHostOptions, collectMultiple } from "../../utils/command-options.js";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
|
||||
const schema: OutputSchema<HostSummary> = {
|
||||
idField: "registrationId",
|
||||
columns: [
|
||||
{ header: "ID", field: "registrationId" },
|
||||
{ header: "LABEL", field: "label" },
|
||||
{ header: "SERVER", field: "serverId" },
|
||||
{ header: "ENABLED", field: "enabled" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "SOURCE", field: (row) => `${row.source.id}/${row.source.key}` },
|
||||
],
|
||||
};
|
||||
function metadata(values: string[] = []): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
values.map((entry) => {
|
||||
const split = entry.indexOf("=");
|
||||
if (split < 1) throw { code: "invalid_metadata", message: "Metadata must use key=value" };
|
||||
return [entry.slice(0, split), entry.slice(split + 1)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
interface Options extends CommandOptions {
|
||||
label?: string;
|
||||
meta?: string[];
|
||||
removeMeta?: string[];
|
||||
connection?: string;
|
||||
replaceIdentity?: boolean;
|
||||
}
|
||||
async function run(
|
||||
action: string,
|
||||
id: string | undefined,
|
||||
options: Options,
|
||||
): Promise<CommandResult<HostSummary>> {
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
try {
|
||||
if (action === "add")
|
||||
return {
|
||||
type: "single",
|
||||
data: await client.addHost({
|
||||
connection: id!,
|
||||
label: options.label,
|
||||
metadata: metadata(options.meta),
|
||||
}),
|
||||
schema,
|
||||
};
|
||||
if (action === "ls") return { type: "list", data: (await client.listHosts()).hosts, schema };
|
||||
const host = (await client.listHosts()).hosts.find((entry) => entry.registrationId === id);
|
||||
if (!host) throw { code: "host_unknown", message: `Unknown registration ${id}` };
|
||||
if (action === "inspect")
|
||||
return {
|
||||
type: "single",
|
||||
data: host,
|
||||
schema: { ...schema, renderHuman: () => JSON.stringify(host, null, 2) },
|
||||
};
|
||||
if (action === "rm") {
|
||||
await client.removeHost(id!);
|
||||
return { type: "single", data: host, schema };
|
||||
}
|
||||
const patch: HostUpdate = {};
|
||||
if (action === "enable" || action === "disable") patch.enabled = action === "enable";
|
||||
if (options.label !== undefined) patch.label = options.label || null;
|
||||
if (options.connection !== undefined) patch.connection = options.connection;
|
||||
if (options.replaceIdentity) patch.replaceIdentity = true;
|
||||
if (options.meta?.length || options.removeMeta?.length) {
|
||||
patch.metadata = { ...host.metadata, ...metadata(options.meta) };
|
||||
for (const key of options.removeMeta ?? []) delete patch.metadata[key];
|
||||
}
|
||||
return { type: "single", data: await client.updateHost(id!, patch), schema };
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
export function createHostCommand(): Command {
|
||||
const host = new Command("host").description("Manage hosts connected to the selected daemon");
|
||||
addJsonAndDaemonHostOptions(
|
||||
host.command("ls").description("List saved hosts, including disabled and failed entries"),
|
||||
).action(withOutput((options: Options, _command: Command) => run("ls", undefined, options)));
|
||||
addJsonAndDaemonHostOptions(
|
||||
host
|
||||
.command("add")
|
||||
.argument("<connection>")
|
||||
.option("--label <name>")
|
||||
.option("--meta <key=value>", "Set metadata", collectMultiple, []),
|
||||
).action(
|
||||
withOutput((connection: string, options: Options, _command: Command) =>
|
||||
run("add", connection, options),
|
||||
),
|
||||
);
|
||||
addJsonAndDaemonHostOptions(
|
||||
host
|
||||
.command("update")
|
||||
.argument("<registration-id>")
|
||||
.option("--label <name>")
|
||||
.option("--connection <connection>")
|
||||
.option("--replace-identity", "Accept a new destination identity")
|
||||
.option("--meta <key=value>", "Replace supplied metadata keys", collectMultiple, [])
|
||||
.option("--remove-meta <key>", "Remove a metadata key", collectMultiple, []),
|
||||
).action(
|
||||
withOutput((id: string, options: Options, _command: Command) => run("update", id, options)),
|
||||
);
|
||||
for (const action of ["inspect", "enable", "disable", "rm"])
|
||||
addJsonAndDaemonHostOptions(host.command(action).argument("<registration-id>")).action(
|
||||
withOutput((id: string, options: Options, _command: Command) => run(action, id, options)),
|
||||
);
|
||||
return host;
|
||||
}
|
||||
@@ -1,90 +1,2 @@
|
||||
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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
export { createSshTunnel, resolveSshFailureDetail } from "@getpaseo/client/node";
|
||||
export type { SshTunnel } from "@getpaseo/client/node";
|
||||
|
||||
@@ -1,42 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it } 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" }));
|
||||
import { parseHostTarget } from "@getpaseo/client/node";
|
||||
|
||||
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.",
|
||||
@@ -45,21 +11,14 @@ describe("CLI SSH transport", () => {
|
||||
"ssh exited with code 255",
|
||||
);
|
||||
});
|
||||
|
||||
it("routes an SSH host through a local tunnel", async () => {
|
||||
await connectToDaemon({
|
||||
target: { kind: "endpoint", 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",
|
||||
it("uses the shared SSH target grammar and excludes reusable credentials from discovery", () => {
|
||||
expect(parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777")).toEqual({
|
||||
kind: "ssh",
|
||||
ssh: { host: "deploy@build-box", sshPort: 2222, daemonPort: 7777 },
|
||||
description: "ssh://deploy@build-box:2222 (daemon port 7777)",
|
||||
});
|
||||
expect(() => parseHostTarget("ssh://deploy:secret@build-box")).toThrow(
|
||||
"Invalid host connection",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { createHostClient, parseHostTarget } from "@getpaseo/client/node";
|
||||
import {
|
||||
waitForDaemonReady,
|
||||
resolvePaseoHome,
|
||||
@@ -8,21 +9,12 @@ import { describeDaemonTarget, type DaemonTarget } from "./daemon-target.js";
|
||||
export type { DaemonTarget } from "./daemon-target.js";
|
||||
import {
|
||||
buildDaemonWebSocketUrl,
|
||||
buildRelayWebSocketUrl,
|
||||
normalizeHostPort,
|
||||
parseConnectionUri,
|
||||
shouldUseTlsForDefaultHostedRelay,
|
||||
} from "@getpaseo/protocol/daemon-endpoints";
|
||||
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 { WebSocket } from "ws";
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { getOrCreateCliClientId } from "./client-id.js";
|
||||
import { resolveCliVersion } from "../version.js";
|
||||
import { createSshTunnel } from "../ssh/ssh-tunnel.js";
|
||||
|
||||
export interface ConnectOptions {
|
||||
target: DaemonTarget;
|
||||
@@ -170,11 +162,8 @@ export function resolveDaemonTarget(host: string): TransportTarget {
|
||||
}
|
||||
|
||||
export function resolveDaemonPassword(host: string): string | undefined {
|
||||
const trimmed = host.trim();
|
||||
if (trimmed.startsWith("tcp://")) {
|
||||
const fromUri = parseConnectionUri(trimmed).password;
|
||||
if (fromUri) return fromUri;
|
||||
}
|
||||
const fromUri = parseHostTarget(host).password;
|
||||
if (fromUri) return fromUri;
|
||||
const fromEnv = process.env.PASEO_PASSWORD;
|
||||
return fromEnv && fromEnv.length > 0 ? fromEnv : undefined;
|
||||
}
|
||||
@@ -189,117 +178,9 @@ export function resolveDaemonCredential(
|
||||
return token ? { kind: "localCredential", token } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebSocket factory that works in Node.js
|
||||
*/
|
||||
function createNodeWebSocketFactory() {
|
||||
return (
|
||||
url: string,
|
||||
options?: { headers?: Record<string, string>; protocols?: string[]; socketPath?: string },
|
||||
): WebSocketLike => {
|
||||
return new WebSocket(url, options?.protocols, {
|
||||
headers: options?.headers,
|
||||
...(options?.socketPath ? { socketPath: options.socketPath } : {}),
|
||||
}) as unknown as WebSocketLike;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and connect a daemon client
|
||||
* Returns the connected client or throws if connection fails
|
||||
*/
|
||||
async function tryConnectHost(
|
||||
host: string,
|
||||
credential: ReturnType<typeof resolveDaemonCredential>,
|
||||
home: string,
|
||||
clientId: string,
|
||||
timeout: number,
|
||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||
): Promise<{ client: DaemonClient } | { error: unknown }> {
|
||||
const target = resolveDaemonTarget(host);
|
||||
const client = new DaemonClient({
|
||||
url: target.url,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
...(credential?.kind === "password" ? { password: credential.password } : {}),
|
||||
...(credential?.kind === "localCredential"
|
||||
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
|
||||
: {}),
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (
|
||||
url: string,
|
||||
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) =>
|
||||
nodeWebSocketFactory(url, {
|
||||
headers: config?.headers,
|
||||
protocols: config?.protocols,
|
||||
...(target.type === "ipc" ? { socketPath: target.socketPath } : {}),
|
||||
}),
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
return { client };
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
async function connectViaRelayOffer(
|
||||
offer: ConnectionOffer,
|
||||
clientId: string,
|
||||
timeout: number,
|
||||
nodeWebSocketFactory: ReturnType<typeof createNodeWebSocketFactory>,
|
||||
): Promise<DaemonClient> {
|
||||
const url = buildRelayWebSocketUrl({
|
||||
endpoint: offer.relay.endpoint,
|
||||
serverId: offer.serverId,
|
||||
role: "client",
|
||||
useTls: offer.relay.useTls ?? shouldUseTlsForDefaultHostedRelay(offer.relay.endpoint),
|
||||
});
|
||||
|
||||
const client = new DaemonClient({
|
||||
url,
|
||||
clientId,
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
connectTimeoutMs: timeout,
|
||||
webSocketFactory: (
|
||||
target: string,
|
||||
config?: { headers?: Record<string, string>; protocols?: string[] },
|
||||
) => nodeWebSocketFactory(target, { headers: config?.headers, protocols: config?.protocols }),
|
||||
e2ee: { enabled: true, daemonPublicKeyB64: offer.daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close().catch(() => {});
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const lastError = client.lastError ? ` (${client.lastError})` : "";
|
||||
throw new Error(`Failed to connect via relay offer: ${message}${lastError}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
function parseHostOfferOrNull(host: string | undefined): ConnectionOffer | null {
|
||||
if (!host) return null;
|
||||
try {
|
||||
return parseConnectionOfferFromUrl(host);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Invalid pairing offer URL: ${message}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonClient> {
|
||||
const timeout = options.timeout ?? DEFAULT_TIMEOUT;
|
||||
const deadline = Date.now() + timeout;
|
||||
const explicitHost =
|
||||
const host =
|
||||
options.target.kind === "endpoint"
|
||||
? options.target.host
|
||||
: (
|
||||
@@ -309,58 +190,25 @@ async function connectSelectedDaemon(options: ConnectOptions): Promise<DaemonCli
|
||||
})
|
||||
).listen;
|
||||
const home = resolveClientPaseoHome(options.target);
|
||||
const clientId = await getOrCreateCliClientId(home);
|
||||
const nodeWebSocketFactory = createNodeWebSocketFactory();
|
||||
|
||||
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 ? { kind: "password", password } : null,
|
||||
home,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) {
|
||||
const close = result.client.close.bind(result.client);
|
||||
result.client.close = async () => {
|
||||
try {
|
||||
await close();
|
||||
} finally {
|
||||
tunnel.close();
|
||||
}
|
||||
};
|
||||
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 credential = resolveDaemonCredential(host, home);
|
||||
const client = createHostClient(host, {
|
||||
clientId: await getOrCreateCliClientId(home),
|
||||
clientType: "cli",
|
||||
appVersion: resolveCliVersion(),
|
||||
...(credential?.kind === "password" ? { password: credential.password } : {}),
|
||||
...(credential?.kind === "localCredential"
|
||||
? { localCredential: () => readLocalCredentialForTarget(home, host) ?? undefined }
|
||||
: {}),
|
||||
connectTimeoutMs: timeout,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
return client;
|
||||
} catch (error) {
|
||||
await client.close();
|
||||
throw error;
|
||||
}
|
||||
const offer = parseHostOfferOrNull(explicitHost);
|
||||
if (offer) {
|
||||
return connectViaRelayOffer(
|
||||
offer,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await tryConnectHost(
|
||||
explicitHost,
|
||||
resolveDaemonCredential(explicitHost, home),
|
||||
home,
|
||||
clientId,
|
||||
Math.max(1, deadline - Date.now()),
|
||||
nodeWebSocketFactory,
|
||||
);
|
||||
if ("client" in result) return result.client;
|
||||
throw result.error;
|
||||
}
|
||||
|
||||
export async function connectToDaemon(options: ConnectOptions): Promise<DaemonClient> {
|
||||
|
||||
Reference in New Issue
Block a user