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>
202 lines
8.3 KiB
TypeScript
202 lines
8.3 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import {
|
|
listHeadscaleServers,
|
|
createHeadscaleServer,
|
|
updateHeadscaleServer,
|
|
setActiveHeadscaleServer,
|
|
deleteHeadscaleServer,
|
|
getHeadscaleCredentials,
|
|
recordHeadscaleProbe,
|
|
} from 'officerdb';
|
|
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.
|
|
//
|
|
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
|
|
// discovered later as a confusing failure on some unrelated screen:
|
|
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
|
|
// 2. an authenticated call — proves the key actually works
|
|
// Neither step is skippable, and a rejected registration is never written.
|
|
|
|
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
|
|
function normalizeUrl(raw: unknown): string | null {
|
|
if (typeof raw !== 'string' || !raw.trim()) return null;
|
|
let candidate = raw.trim();
|
|
// Bare host/port is the most common paste; assume https rather than rejecting it.
|
|
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(candidate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
|
|
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
|
|
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
|
|
}
|
|
|
|
function requireString(value: unknown, field: string): string | Response {
|
|
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
|
|
return value.trim();
|
|
}
|
|
|
|
/**
|
|
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
|
|
* Returns the observed version on success, or a ready-to-send error Response.
|
|
*/
|
|
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
|
|
const probe = await probeVersion(url);
|
|
if (!probe.ok) return badRequest(probe.error);
|
|
if (probe.supported === false) {
|
|
return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`);
|
|
}
|
|
|
|
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
|
|
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
|
|
const client = createClient({ id: 0, name: 'probe', url, apiKey });
|
|
try {
|
|
await client.call('/api/v1/apikey');
|
|
} catch (err) {
|
|
if (err instanceof HeadscaleError) {
|
|
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
|
|
}
|
|
throw err;
|
|
}
|
|
return probe.version;
|
|
}
|
|
|
|
async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
|
const { req, userId } = ctx;
|
|
|
|
if (req.method === 'GET') {
|
|
return Response.json({ servers: await listHeadscaleServers(userId) });
|
|
}
|
|
|
|
if (req.method === 'POST') {
|
|
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
if (!body) return badRequest('expected a JSON body');
|
|
|
|
const url = normalizeUrl(body.url);
|
|
if (!url) return badRequest('url must be a valid http(s) URL');
|
|
const apiKey = requireString(body.apiKey, 'apiKey');
|
|
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;
|
|
|
|
// First registration becomes active, so the owner is never left with servers but none selected.
|
|
const existing = await listHeadscaleServers(userId);
|
|
const server = await createHeadscaleServer({
|
|
userId,
|
|
name,
|
|
url,
|
|
apiKey,
|
|
version: validated,
|
|
sshHost,
|
|
activate: existing.length === 0,
|
|
});
|
|
return Response.json({ server }, { status: 201 });
|
|
}
|
|
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise<Response> {
|
|
const { req, userId } = ctx;
|
|
|
|
if (action === 'activate') {
|
|
if (req.method !== 'POST') return methodNotAllowed();
|
|
const server = await setActiveHeadscaleServer(userId, id);
|
|
return server ? Response.json({ server }) : notFound('no such server');
|
|
}
|
|
|
|
if (action === 'health') {
|
|
if (req.method !== 'GET') return methodNotAllowed();
|
|
const creds = await getHeadscaleCredentials(userId, id);
|
|
if (!creds) return notFound('no such server');
|
|
|
|
const started = Date.now();
|
|
const probe = await probeVersion(creds.url);
|
|
if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started });
|
|
|
|
// Reachable — confirm the key too, so "healthy" means "we can actually use this server".
|
|
try {
|
|
await createClient(creds).call('/api/v1/apikey');
|
|
} catch (err) {
|
|
const message = err instanceof HeadscaleError ? err.message : 'upstream error';
|
|
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
|
|
}
|
|
|
|
await recordHeadscaleProbe(userId, id, probe.version);
|
|
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
|
|
}
|
|
|
|
if (action !== undefined) return notFound();
|
|
|
|
if (req.method === 'PATCH') {
|
|
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
|
|
if (!body) return badRequest('expected a JSON body');
|
|
|
|
const current = await getHeadscaleCredentials(userId, id);
|
|
if (!current) return notFound('no such server');
|
|
|
|
let url: string | undefined;
|
|
if (body.url !== undefined) {
|
|
const normalized = normalizeUrl(body.url);
|
|
if (!normalized) return badRequest('url must be a valid http(s) URL');
|
|
url = normalized;
|
|
}
|
|
let apiKey: string | undefined;
|
|
if (body.apiKey !== undefined) {
|
|
const parsed = requireString(body.apiKey, 'apiKey');
|
|
if (parsed instanceof Response) return parsed;
|
|
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.
|
|
if (url !== undefined || apiKey !== undefined) {
|
|
const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey);
|
|
if (validated instanceof Response) return validated;
|
|
}
|
|
|
|
const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost });
|
|
return server ? Response.json({ server }) : notFound('no such server');
|
|
}
|
|
|
|
if (req.method === 'DELETE') {
|
|
const deleted = await deleteHeadscaleServer(userId, id);
|
|
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
|
|
}
|
|
|
|
return methodNotAllowed();
|
|
}
|
|
|
|
/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */
|
|
export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
|
if (rest.length === 0) return handleCollection(ctx);
|
|
|
|
const id = Number(rest[0]);
|
|
if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer');
|
|
if (rest.length > 2) return notFound();
|
|
|
|
return handleOne(ctx, id, rest[1]);
|
|
}
|