Files
paseo/packages/protocol/src/ssh-transport.ts
T

96 lines
3.2 KiB
TypeScript

export const DEFAULT_SSH_DAEMON_PORT = 6767;
export interface SshTransportTarget {
host: string;
sshPort?: number;
daemonPort: number;
}
export function validatePort(value: string | number, label: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`${label} must be between 1 and 65535`);
}
return port;
}
export function validateSshHost(host: string): string {
const normalized = host.trim();
if (!normalized) throw new Error("SSH host is required");
if (/\s/u.test(normalized) || normalized.startsWith("-")) {
throw new Error("SSH host is invalid");
}
return normalized;
}
export function parseSshTransportUri(value: string): SshTransportTarget {
let url: URL;
try {
url = new URL(value);
} catch (error) {
throw new Error("Invalid SSH host URI", { cause: error });
}
if (url.protocol !== "ssh:" || url.password || (url.pathname !== "" && url.pathname !== "/")) {
throw new Error("Invalid SSH host URI");
}
if (url.hash) throw new Error("SSH host URI does not support fragments");
for (const key of url.searchParams.keys()) {
if (key !== "daemonPort") throw new Error(`Unsupported SSH host option: ${key}`);
}
const daemonPorts = url.searchParams.getAll("daemonPort");
if (daemonPorts.length > 1) throw new Error("daemonPort may only be specified once");
const urlHostname = url.hostname;
const hostname = validateSshHost(
urlHostname.startsWith("[") && urlHostname.endsWith("]")
? urlHostname.slice(1, -1)
: urlHostname,
);
const username = decodeURIComponent(url.username);
const host = validateSshHost(username ? `${username}@${hostname}` : hostname);
return {
host,
...(url.port ? { sshPort: validatePort(url.port, "SSH port") } : {}),
daemonPort:
daemonPorts[0] === undefined
? DEFAULT_SSH_DAEMON_PORT
: validatePort(daemonPorts[0], "Daemon port"),
};
}
/** Serialize an SSH target without daemon credentials. */
export function serializeSshTransportUri(target: SshTransportTarget): string {
const host = validateSshHost(target.host);
const separator = host.lastIndexOf("@");
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
const hostname = host.slice(separator + 1);
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
return `ssh://${username}${authority}${port}${query}`;
}
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
const host = validateSshHost(target.host);
const daemonPort = validatePort(target.daemonPort, "Daemon port");
const args = [
"-T",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
"-o",
"ClearAllForwardings=yes",
"-o",
"ExitOnForwardFailure=yes",
];
if (target.sshPort !== undefined) {
args.push("-p", String(validatePort(target.sshPort, "SSH port")));
}
args.push("-W", `127.0.0.1:${daemonPort}`, host);
return args;
}