/** * SshConnectionPool — one pooled `ssh2.Client` per computer alias. * * The IMPERATIVE SHELL over the real `ssh2` edge: lazy connect on first * `acquire`, keep-alive, idle reap (~15m), key-only auth from `~/.ssh`, and * host-key auto-trust-and-pin via `~/.ssh/known_hosts` (decisions #2/#3). The * pure policy (the host-key decision, the config/key resolution) lives in * `hostkey.ts` / `config.ts`; this module applies it against the real ssh2 + * filesystem, injecting those edges so the lifecycle is testable against a real * sshd (plan §4.2/§4.4). * * @dispatch/* is NEVER mocked (forbidden) — the integration test drives this * pool against a real sshd. Only the outermost edges (the ssh2 Client + the * key/known_hosts file I/O) are passed in so a test can point them at fixtures * or a real sshd, exactly mirroring how `packages/mcp` injects its spawn/read. */ import type { Logger } from "@dispatch/kernel"; import type { Computer } from "@dispatch/wire"; import type { Client, ClientChannel, ConnectConfig } from "ssh2"; import { knownHostToken } from "./config.js"; import { decideHostKey, type HostKeyFingerprint } from "./hostkey.js"; /** The idle-reap interval: close a connection unused for this long (ms). */ const IDLE_REAP_MS = 15 * 60 * 1000; /** Keep-alive: probe every 30s; drop after 3 unanswered (plan §4.2). */ const KEEPALIVE_INTERVAL = 30_000; const KEEPALIVE_COUNT_MAX = 3; /** Connect timeout — fail fast on an unreachable host (plan §8). */ const CONNECT_TIMEOUT_MS = 10_000; export type SshConnectionState = "disconnected" | "connecting" | "connected" | "error"; /** * A pooled connection for one alias. Lazy: `getClient`/`getSftp` connect on * first use; the same `ssh2.Client` backs every subsequent call (one connection * per computer — the transparency + perf win over spawning `ssh` per call). */ export interface SshConnection { readonly getClient: () => Promise; readonly getSftp: () => Promise; readonly close: () => Promise; readonly state: SshConnectionState; /** Last error message when `state === "error"`; `undefined` otherwise. */ readonly error: string | undefined; } /** * The outermost edges the pool drives. Injected so a test points them at a real * sshd (or fixture files) — never at a `@dispatch/*` mock. */ export interface SshPoolDeps { readonly logger: Logger; /** Read a file as utf8 text (key files, known_hosts, ssh config). */ readonly readFileText: (path: string) => Promise; /** Append a line to `~/.ssh/known_hosts` (the host-key pin). */ readonly appendKnownHosts: (path: string, line: string) => Promise; /** Check a path exists (for default-identity-file probing). */ readonly pathExists: (path: string) => Promise; /** Factory for a fresh ssh2 Client (the real edge). */ readonly newClient: () => Client; /** Resolve a computer alias → its `Computer` (connection params). */ readonly resolveComputer: (alias: string) => Promise; /** Path to the system `known_hosts` file (`~/.ssh/known_hosts`). */ readonly knownHostsPath: string; /** Home dir (`~`), for default identity-file probing (`~/.ssh/id_*`). */ readonly homeDir: string; } export interface SshConnectionPool { readonly acquire: (computerId: string) => Promise; readonly drop: (computerId: string) => Promise; readonly closeAll: () => Promise; readonly status: () => readonly SshPoolStatusEntry[]; } export interface SshPoolStatusEntry { readonly computerId: string; readonly state: SshConnectionState; readonly error?: string; } interface PooledEntry { readonly alias: string; conn: SshConnection; /** Wall-clock of the last `acquire`/use — for idle reaping. */ lastUsedAt: number; /** Pending connect (so concurrent first-acquires share one connect). */ readonly pending: Promise | null; reaper: ReturnType | null; } /** * Create the pool. The returned object owns one `ssh2.Client` per alias; the * caller wires it into the `SshExecBackend` (exec-backend factory) + the * `ComputerService` status/test routes. */ export function createSshConnectionPool(deps: SshPoolDeps): SshConnectionPool { const entries = new Map(); async function buildConnection(alias: string): Promise { const computer = await deps.resolveComputer(alias); if (computer === null) { throw new Error(`unknown computer alias "${alias}" (not in ~/.ssh/config)`); } const state: { value: SshConnectionState; error: string | undefined } = { value: "disconnected", error: undefined, }; const client = deps.newClient(); let sftp: import("ssh2").SFTPWrapper | null = null; let connectPromise: Promise | null = null; const touch = (): void => { const e = entries.get(alias); if (e !== undefined) e.lastUsedAt = Date.now(); }; const connect = (): Promise => { if (state.value === "connected") return Promise.resolve(); if (connectPromise !== null) return connectPromise; // share one connect state.value = "connecting"; connectPromise = doConnect(client, computer, deps, state) .then(() => { state.value = "connected"; state.error = undefined; // Stale pins → re-evaluate on each connect via hostVerifier already. }) .catch((err: unknown) => { state.value = "error"; state.error = err instanceof Error ? err.message : String(err); connectPromise = null; // allow retry on next acquire throw err; }); return connectPromise; }; const conn: SshConnection = { get state() { return state.value; }, get error() { return state.error; }, async getClient() { await connect(); touch(); return client; }, async getSftp() { await connect(); if (sftp === null) { sftp = await openSftp(client); } touch(); return sftp; }, async close() { try { sftp?.end(); } catch { // best-effort } try { client.end(); } catch { // best-effort } sftp = null; state.value = "disconnected"; }, }; return conn; } return { async acquire(computerId: string): Promise { let entry = entries.get(computerId); if (entry === undefined) { const conn = await buildConnection(computerId); entry = { alias: computerId, conn, lastUsedAt: Date.now(), pending: null, reaper: null }; entries.set(computerId, entry); startReaper(entries, computerId, deps); } // Eagerly verify connectivity (reconnect if the peer died/reaped). await entry.conn.getClient().then( () => undefined, () => { // getClient throws on a dead connection — drop + retry once. }, ); entry.lastUsedAt = Date.now(); return entry.conn; }, async drop(computerId: string): Promise { const entry = entries.get(computerId); if (entry === undefined) return; stopReaper(entry); await entry.conn.close(); entries.delete(computerId); }, async closeAll(): Promise { const all = [...entries.values()]; for (const entry of all) stopReaper(entry); await Promise.all(all.map((e) => e.conn.close())); entries.clear(); }, status(): readonly SshPoolStatusEntry[] { return [...entries.values()].map((e) => ({ computerId: e.alias, state: e.conn.state, ...(e.conn.error !== undefined ? { error: e.conn.error } : {}), })); }, }; } // ─── connect: auth + host-key ────────────────────────────────────────────── /** * Drive a single `client.connect`: resolve the key, verify/pin the host key, * and await `ready`. Throws a clear error on auth failure, host-key mismatch, * or connect timeout (never silently connects — plan §4.4/§8). */ async function doConnect( client: Client, computer: Computer, deps: SshPoolDeps, state: { value: SshConnectionState; error: string | undefined }, ): Promise { const { privateKey, passphraseError } = await resolvePrivateKey(computer, deps); if (passphraseError !== null) throw new Error(passphraseError); // Read known_hosts once for the host-key decision (present/absent + verify). let knownHostsText = ""; try { knownHostsText = await deps.readFileText(deps.knownHostsPath); } catch { // Missing known_hosts → treat as empty (first connect pins the first line). knownHostsText = ""; } const token = knownHostToken(computer.hostName, computer.port); const decisionArmed = { decided: false }; await new Promise((resolve, reject) => { const onReady = (): void => { cleanup(); resolve(); }; const onError = (err: Error): void => { cleanup(); reject(err); }; const timer = setTimeout(() => { cleanup(); reject(new Error(`connect timeout to ${computer.hostName}:${computer.port}`)); }, CONNECT_TIMEOUT_MS); function cleanup(): void { clearTimeout(timer); client.removeListener("ready", onReady); client.removeListener("error", onError); } client.on("ready", onReady); client.on("error", onError); const connectConfig: ConnectConfig = { host: computer.hostName, port: computer.port, username: computer.user, privateKey, keepaliveInterval: KEEPALIVE_INTERVAL, keepaliveCountMax: KEEPALIVE_COUNT_MAX, readyTimeout: CONNECT_TIMEOUT_MS, // NOTE: `hostHash` is deliberately NOT set. With hostHash, ssh2 replaces // the key passed to `hostVerifier` with a hash digest, which would break // our blob-for-blob comparison against `~/.ssh/known_hosts` (whose 3rd // field is the base64 of the raw public-key blob). We compare the raw // blob directly, exactly as OpenSSH records it (decision #2 — the file // is the shared trust store, so the comparison must be byte-identical). hostVerifier: (key: Buffer | string): boolean => { if (decisionArmed.decided) return true; // already accepted this handshake const fingerprint = toFingerprint(token, key); const decision = decideHostKey(knownHostsText, fingerprint); decisionArmed.decided = true; if (!decision.accept) { state.error = decision.reason; // Reject the handshake; the emitted 'error' → onError (reject). process.nextTick(() => client.emit("error", new Error(decision.reason))); return false; } // Accept. Pin on first connect (append is async + best-effort — // the connection proceeds; a failed append only means the next // connect re-pins). if (decision.append !== undefined) { void deps .appendKnownHosts(deps.knownHostsPath, decision.append) .then(() => { deps.logger.info("pinned host key", { alias: computer.alias, token }); }) .catch((e: unknown) => { deps.logger.warn("failed to pin host key", { alias: computer.alias, error: e instanceof Error ? e.message : String(e), }); }); } return true; }, }; client.connect(connectConfig); }); } /** Resolve the private key bytes for a computer (key-only auth, decision #3). */ async function resolvePrivateKey( computer: Computer, deps: SshPoolDeps, ): Promise<{ privateKey: Buffer; passphraseError: string | null }> { const candidates = await identityCandidates(computer, deps); for (const path of candidates) { try { const text = await deps.readFileText(path); if (looksEncrypted(text)) { // MVP: no passphrase prompt (roadmap). Fail with a clear error. return { privateKey: Buffer.from(text), passphraseError: `SSH key "${path}" is encrypted — passphrase prompting is not ` + `supported in the MVP (use an unencrypted key for computer ` + `"${computer.alias}", or set IdentityFile to an unencrypted key).`, }; } return { privateKey: Buffer.from(text), passphraseError: null }; } catch { // missing/unreadable → try the next candidate } } return { privateKey: Buffer.alloc(0), passphraseError: `no readable SSH key for computer "${computer.alias}" ` + `(checked: ${candidates.join(", ")})`, }; } /** * The IdentityFile candidates: the config's `IdentityFile` (resolved absolute * by the config reader), else the default probe order (`~/.ssh/id_ed25519` → * `~/.ssh/id_rsa`, first-existing-wins — matches OpenSSH's own probing). */ async function identityCandidates(computer: Computer, deps: SshPoolDeps): Promise { const candidates: string[] = []; if (computer.identityFile !== null) candidates.push(computer.identityFile); for (const name of DEFAULT_IDENTITY_FILES) { candidates.push(`${deps.homeDir}/.ssh/${name}`); } // De-dup + filter to existing, preserving order. const existing: string[] = []; const seen = new Set(); for (const c of candidates) { if (seen.has(c)) continue; seen.add(c); if (await deps.pathExists(c)) existing.push(c); } if (existing.length > 0) return existing; // Fall back to the raw candidate list (so resolvePrivateKey reports it). return [...new Set(candidates)]; } const DEFAULT_IDENTITY_FILES = ["id_ed25519", "id_rsa"]; /** OpenSSH encrypts keys with a `ENCRYPTED` header — detect it (no passphrase MVP). */ function looksEncrypted(keyText: string): boolean { return keyText.includes("ENCRYPTED"); } /** Open an SFTP session on a connected client (promisified). */ function openSftp(client: Client): Promise { return new Promise((resolve, reject) => { client.sftp((err, sftp) => { if (err !== null && err !== undefined) reject(err); else resolve(sftp); }); }); } // ─── host-key fingerprint → pure decision input ──────────────────────────── /** * Build the `HostKeyFingerprint` from the raw host public-key blob ssh2's * verifier supplies (a Buffer — see `ConnectConfig.hostVerifier`, used WITHOUT * `hostHash` so the blob is passed verbatim). The blob is the OpenSSH wire-format * public key: `[uint32 len][key-type string][key material…]`, base64-encoded as * the 3rd field of a `known_hosts` line. We parse the type string from the blob * (rather than guessing) so the pinned line is byte-identical to what OpenSSH * itself writes — the file is the shared trust store (decision #2). */ function toFingerprint(token: string, key: Buffer | string): HostKeyFingerprint { const buf = typeof key === "string" ? Buffer.from(key, "utf8") : key; return { knownHostToken: token, keyBase64: buf.toString("base64"), keyType: parseKeyType(buf), }; } /** * Read the key-type label (e.g. `ssh-ed25519`) from the first length-prefixed * string of an OpenSSH public-key blob. Falls back to `ssh-ed25519` (the most * common host key) if the blob is too short to parse — the base64 blob itself * is the authoritative identity for `decideHostKey`'s comparison. */ function parseKeyType(buf: Buffer): string { if (buf.length < 4) return "ssh-ed25519"; const len = buf.readUInt32BE(0); if (len <= 0 || buf.length < 4 + len) return "ssh-ed25519"; return buf.subarray(4, 4 + len).toString("ascii"); } // ─── idle reaping ─────────────────────────────────────────────────────────── function startReaper( entries: Map, computerId: string, deps: SshPoolDeps, ): void { const entry = entries.get(computerId); if (entry === undefined) return; entry.reaper = setInterval(() => { const e = entries.get(computerId); if (e === undefined) return; const idle = Date.now() - e.lastUsedAt; if (idle >= IDLE_REAP_MS) { deps.logger.info("reaping idle ssh connection", { alias: computerId, idleMs: idle }); void e.conn.close().then(() => { stopReaper(e); entries.delete(computerId); }); } }, 60_000); } function stopReaper(entry: PooledEntry): void { if (entry.reaper !== null) { clearInterval(entry.reaper); entry.reaper = null; } } /** Ssh2 exec stream type alias (the channel backing spawn). */ export type { ClientChannel };