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:
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user