Files
offscale/sidecar/ssh.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00

87 lines
4.0 KiB
TypeScript

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 <host>` 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<SshProbe> {
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<Response> {
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));
}