///
import { createHash, randomBytes } from "node:crypto";
import { readFileSync } from "node:fs";
import { posix as posixPath } from "node:path";
import { Client, type ClientChannel, type ConnectConfig, type SFTPWrapper } from "ssh2";
import { expandUserPath, type SshHostConfig } from "./config.ts";
export interface RemoteExecOptions {
onData: (data: Buffer) => void;
signal?: AbortSignal;
timeout?: number;
}
export interface RemoteTransport {
connect(): Promise;
dispose(): Promise;
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }>;
capture(command: string, cwd?: string, timeout?: number): Promise<{ exitCode: number | null; output: Buffer }>;
readFile(remotePath: string): Promise;
ensureReadable(remotePath: string): Promise;
ensureReadableWritable(remotePath: string): Promise;
detectImageMimeType(remotePath: string): Promise;
mkdir(remoteDir: string): Promise;
writeFile(remotePath: string, content: Buffer): Promise;
}
const DEFAULT_TIMEOUT_SECONDS = 300;
class CommandQueue {
private tail: Promise = Promise.resolve();
enqueue(task: () => Promise): Promise {
const run = this.tail.then(task, task);
this.tail = run.then(() => undefined, () => undefined);
return run;
}
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'"'"'`)}'`;
}
function keyAlgorithm(key: Buffer): string {
if (key.length < 4) return "unknown";
const length = key.readUInt32BE(0);
if (length < 1 || length > key.length - 4) return "unknown";
return key.subarray(4, 4 + length).toString("ascii");
}
export function fingerprintHostKey(key: Buffer): { algorithm: string; fingerprint: string } {
return {
algorithm: keyAlgorithm(key),
fingerprint: `SHA256:${createHash("sha256").update(key).digest("base64").replace(/=+$/, "")}`,
};
}
function buildConnectConfig(host: SshHostConfig): ConnectConfig {
const config: ConnectConfig = {
host: host.hostName,
port: host.port,
username: host.user,
readyTimeout: 20_000,
keepaliveInterval: 15_000,
keepaliveCountMax: 3,
hostVerifier: (key) => fingerprintHostKey(key).fingerprint === host.hostKey.fingerprint,
};
if (host.auth.type === "password") {
config.password = host.auth.password;
config.tryKeyboard = host.auth.method !== "password";
} else {
config.privateKey = readFileSync(expandUserPath(host.auth.identityFile));
if (host.auth.passphrase) config.passphrase = host.auth.passphrase;
}
return config;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export class Ssh2Transport implements RemoteTransport {
private readonly client: Client;
private readonly queue = new CommandQueue();
private connected = false;
private disposed = false;
private disconnectError: Error | null = null;
private sftpClient: SFTPWrapper | null = null;
private readonly host: SshHostConfig;
constructor(host: SshHostConfig, client: Client = new Client()) {
this.host = host;
this.client = client;
}
async connect(): Promise {
if (this.connected) return;
if (this.disposed) throw new Error("SSH2 transport is disposed");
await new Promise((resolve, reject) => {
let settled = false;
const succeed = () => {
if (settled) return;
settled = true;
this.connected = true;
resolve();
};
const fail = (error: unknown) => {
const normalized = error instanceof Error ? error : new Error(String(error));
this.disconnectError = normalized;
if (settled) return;
settled = true;
reject(normalized);
};
this.client.once("ready", succeed);
this.client.on("error", fail);
this.client.on("close", () => {
this.connected = false;
this.sftpClient = null;
if (!this.disposed && !this.disconnectError) this.disconnectError = new Error("SSH2 connection closed unexpectedly");
});
if (this.host.auth.type === "password" && this.host.auth.method !== "password") {
this.client.on("keyboard-interactive", (_name, _instructions, _language, prompts, finish) => {
finish(prompts.map(() => this.host.auth.type === "password" ? this.host.auth.password : ""));
});
}
try {
this.client.connect(buildConnectConfig(this.host));
} catch (error) {
fail(error);
}
});
}
async dispose(): Promise {
this.disposed = true;
this.connected = false;
this.sftpClient = null;
this.client.end();
}
private assertConnected(): void {
if (!this.connected) throw this.disconnectError ?? new Error("SSH2 connection is not active");
}
exec(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
return this.queue.enqueue(() => this.execUnqueued(command, cwd, options));
}
private async execUnqueued(command: string, cwd: string, options: RemoteExecOptions): Promise<{ exitCode: number | null }> {
this.assertConnected();
const remoteCommand = `cd -- ${shellQuote(cwd)} && bash -lc ${shellQuote(command)} {
let channel: ClientChannel | undefined;
let settled = false;
let timeoutHandle: NodeJS.Timeout | undefined;
const effectiveTimeout = options.timeout ?? DEFAULT_TIMEOUT_SECONDS;
const cleanup = () => {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (options.signal) options.signal.removeEventListener("abort", onAbort);
};
const fail = (error: Error) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
};
const onAbort = () => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error("SSH command aborted"));
};
if (options.signal?.aborted) {
fail(new Error("SSH command aborted"));
return;
}
options.signal?.addEventListener("abort", onAbort, { once: true });
if (effectiveTimeout > 0) {
timeoutHandle = setTimeout(() => {
try {
channel?.signal("KILL");
channel?.close();
} catch {
// channel may already be closed
}
fail(new Error(`SSH command timed out after ${effectiveTimeout}s`));
}, effectiveTimeout * 1000);
}
this.client.exec(remoteCommand, (error, stream) => {
if (error) {
fail(error);
return;
}
channel = stream;
stream.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.stderr.on("data", (data: Buffer | string) => options.onData(Buffer.isBuffer(data) ? data : Buffer.from(data)));
stream.once("error", fail);
stream.once("close", (code: number | undefined) => {
if (settled) return;
settled = true;
cleanup();
resolve({ exitCode: typeof code === "number" ? code : null });
});
});
});
}
async capture(command: string, cwd = ".", timeout = 30): Promise<{ exitCode: number | null; output: Buffer }> {
const chunks: Buffer[] = [];
const result = await this.exec(command, cwd, { timeout, onData: (data) => chunks.push(data) });
return { exitCode: result.exitCode, output: Buffer.concat(chunks) };
}
private async sftp(): Promise {
this.assertConnected();
if (this.sftpClient) return this.sftpClient;
this.sftpClient = await new Promise((resolve, reject) => {
this.client.sftp((error, sftp) => error ? reject(error) : resolve(sftp));
});
return this.sftpClient;
}
async readFile(remotePath: string): Promise {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
return new Promise((resolve, reject) => {
sftp.readFile(remotePath, (error, data) => error ? reject(error) : resolve(data));
});
});
}
private async ensureOpen(remotePath: string, flags: "r" | "r+"): Promise {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await new Promise((resolve, reject) => {
sftp.open(remotePath, flags, (error, handle) => {
if (error) {
reject(error);
return;
}
sftp.close(handle, (closeError) => closeError ? reject(closeError) : resolve());
});
});
});
}
ensureReadable(remotePath: string): Promise {
return this.ensureOpen(remotePath, "r");
}
ensureReadableWritable(remotePath: string): Promise {
return this.ensureOpen(remotePath, "r+");
}
async detectImageMimeType(remotePath: string): Promise {
const content = await this.readFile(remotePath);
if (content.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) return "image/jpeg";
if (content.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "image/png";
const prefix = content.subarray(0, 6).toString("ascii");
if (prefix === "GIF87a" || prefix === "GIF89a") return "image/gif";
if (content.subarray(0, 4).toString("ascii") === "RIFF" && content.subarray(8, 12).toString("ascii") === "WEBP") return "image/webp";
return null;
}
async mkdir(remoteDir: string): Promise {
return this.queue.enqueue(() => this.mkdirUnqueued(remoteDir));
}
private async mkdirUnqueued(remoteDir: string): Promise {
const sftp = await this.sftp();
const normalized = posixPath.normalize(remoteDir);
const segments = normalized.split("/").filter(Boolean);
let current = normalized.startsWith("/") ? "/" : "";
for (const segment of segments) {
current = current === "/" ? `/${segment}` : current ? `${current}/${segment}` : segment;
await new Promise((resolve, reject) => {
sftp.mkdir(current, (error) => {
if (!error) {
resolve();
return;
}
sftp.stat(current, (statError) => statError ? reject(error) : resolve());
});
});
}
}
async writeFile(remotePath: string, content: Buffer): Promise {
return this.queue.enqueue(async () => {
const sftp = await this.sftp();
await this.mkdirUnqueued(posixPath.dirname(remotePath));
const temporary = `${remotePath}.pi-ssh-${randomBytes(8).toString("hex")}.tmp`;
const write = (path: string, data: Buffer) => new Promise((resolve, reject) => {
sftp.writeFile(path, data, (error) => error ? reject(error) : resolve());
});
const standardRename = (from: string, to: string) => new Promise((resolve, reject) => {
sftp.rename(from, to, (error) => error ? reject(error) : resolve());
});
const atomicRename = typeof sftp.ext_openssh_rename === "function"
? (from: string, to: string) => new Promise((resolve, reject) => {
sftp.ext_openssh_rename?.(from, to, (error) => error ? reject(error) : resolve());
})
: undefined;
try {
await write(temporary, content);
if (atomicRename) {
await atomicRename(temporary, remotePath);
} else {
try {
await standardRename(temporary, remotePath);
} catch {
// SFTP v3 rename commonly refuses to replace an existing target.
// Fall back to a direct write without deleting the existing file first.
await write(remotePath, content);
await new Promise((resolve) => sftp.unlink(temporary, () => resolve()));
}
}
} catch (error) {
await new Promise((resolve) => sftp.unlink(temporary, () => resolve()));
throw new Error(`remote write failed: ${errorMessage(error)}`);
}
});
}
}
export async function probeHostKey(
hostName: string,
port: number,
user: string,
client: Client = new Client(),
timeoutMs = 15_000,
): Promise<{ algorithm: string; fingerprint: string }> {
return new Promise((resolve, reject) => {
let observed: { algorithm: string; fingerprint: string } | undefined;
const timer = setTimeout(() => {
client.destroy();
reject(new Error("timed out while obtaining SSH host key"));
}, timeoutMs);
client.on("error", (error) => {
clearTimeout(timer);
client.destroy();
observed ? resolve(observed) : reject(error);
});
client.on("close", () => {
clearTimeout(timer);
if (observed) resolve(observed);
});
client.connect({
host: hostName,
port,
username: user,
readyTimeout: Math.min(timeoutMs, 12_000),
hostVerifier: (key) => {
observed = fingerprintHostKey(key);
return false;
},
});
});
}