import { badRequest, methodNotAllowed, readJson, type OfficerContext } from './routes'; // SSH console support — the escape hatch for when the Headscale API cannot answer. // // Officer never handles a password, a key or a port here. The console runs `ssh ` in the owner's own // shell, so it authenticates with whatever `~/.ssh` already knows; the only thing stored is where to point it. // That is why this file has no credential handling at all, and why it must never grow any: the moment Officer // starts holding a private key or a password, this stops being "run the command you would have run yourself". // // The host string is typed into an interactive shell, so it is validated to a conservative charset rather than // quoted. Quoting would let a plausible-looking value survive to the shell and be someone else's problem; // rejecting it says which character is wrong while the form is still open. /** `user@` plus a hostname or IP. Deliberately no spaces, no flags, no shell metacharacters. */ const SSH_HOST_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*)?(?:@[A-Za-z0-9](?:[A-Za-z0-9._:-]*)?)?$/; /** * Validate a console target. Returns the trimmed host, null when the field was blank (meaning "no console"), * or an error Response. */ export function normalizeSshHost(raw: unknown): string | null | Response { if (raw === null) return null; if (typeof raw !== 'string') return badRequest('sshHost must be a string'); const host = raw.trim(); if (!host) return null; if (host.length > 255) return badRequest('sshHost is too long'); if (!SSH_HOST_RE.test(host)) { return badRequest('sshHost must be a plain host, IP or user@host — no ports, flags or spaces'); } return host; } type SshProbe = { ok: boolean; error?: string; ms: number }; /** * Prove the machine is reachable with the keys already on this box, without opening a session. * * `BatchMode=yes` is what makes this a test rather than a hang: ssh fails instead of prompting for a password * or a passphrase, which is exactly the outcome the owner needs to see. `accept-new` records an unknown host * key here rather than leaving the console to open on an interactive "are you sure" prompt the first time — * it still refuses a CHANGED key, which is the check worth keeping. */ export async function probeSsh(host: string): Promise { const started = Date.now(); const proc = Bun.spawn( ['ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=accept-new', host, 'true'], { stdout: 'ignore', stderr: 'pipe' }, ); // ConnectTimeout only bounds the TCP connect; a server that accepts and then stalls would hang forever. const timer = setTimeout(() => proc.kill(), 15_000); let stderr = ''; try { [stderr] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); } finally { clearTimeout(timer); } const ms = Date.now() - started; if (proc.exitCode === 0) return { ok: true, ms }; // ssh's own first line is the useful one ("Permission denied", "Connection timed out"); the rest is noise. const first = stderr .split('\n') .map((line) => line.trim()) .find((line) => line && !line.startsWith('Warning: Permanently added')); return { ok: false, error: first || `ssh exited ${proc.exitCode ?? 'on a signal'}`, ms }; } /** * `POST /_officer/ssh-test {host}`. Takes the host in the body rather than a server id on purpose: the form * needs to test a value the owner has typed but not yet saved, which is the case where a typo is still cheap. */ export async function handleSshTestRoute(ctx: OfficerContext, rest: string[]): Promise { if (rest.length > 0) return badRequest('unexpected path'); if (ctx.req.method !== 'POST') return methodNotAllowed(); const body = await readJson(ctx.req); if (!body) return badRequest('expected a JSON body'); const host = normalizeSshHost(body.host); if (host instanceof Response) return host; if (!host) return badRequest('host is required'); return Response.json(await probeSsh(host)); }