headscale: an ssh console for when the api cannot answer

Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.

Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.

The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).

The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.

Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 14:10:33 +00:00
co-authored by Claude Opus 5
parent 2f92f9b15c
commit 208f26ad89
12 changed files with 365 additions and 11 deletions
+4
View File
@@ -4,6 +4,7 @@ import { handleNodesRoute } from './nodes';
import { handleUsersRoute } from './users';
import { handleKeysRoute } from './keys';
import { handleEnrollRoute } from './enroll';
import { handleSshTestRoute } from './ssh';
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
//
@@ -58,6 +59,9 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise<Respon
return await handleKeysRoute(ctx, segments.slice(1));
case 'enroll':
return await handleEnrollRoute(ctx, segments.slice(1));
// Not a Headscale call at all — a local `ssh` reachability probe for the console. See ssh.ts.
case 'ssh-test':
return await handleSshTestRoute(ctx, segments.slice(1));
default:
return null;
}
+13 -1
View File
@@ -11,6 +11,7 @@ import {
import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
import { normalizeSshHost } from './ssh';
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
@@ -85,6 +86,9 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
if (apiKey instanceof Response) return apiKey;
// The name is a label only; default it to the host so registration needs just a URL and a key.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
// Optional, and never validated by connecting: registration should not fail because a box is rebooting.
const sshHost = normalizeSshHost(body.sshHost);
if (sshHost instanceof Response) return sshHost;
const validated = await validateServer(url, apiKey);
if (validated instanceof Response) return validated;
@@ -97,6 +101,7 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
url,
apiKey,
version: validated,
sshHost,
activate: existing.length === 0,
});
return Response.json({ server }, { status: 201 });
@@ -157,6 +162,13 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
apiKey = parsed;
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
// Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null.
let sshHost: string | null | undefined;
if (body.sshHost !== undefined) {
const parsed = normalizeSshHost(body.sshHost);
if (parsed instanceof Response) return parsed;
sshHost = parsed;
}
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
// state registration works hard to prevent, and an edit can reintroduce it.
@@ -165,7 +177,7 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef
if (validated instanceof Response) return validated;
}
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey });
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
return server ? Response.json({ server }) : notFound('no such server');
}
+86
View File
@@ -0,0 +1,86 @@
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));
}