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 { 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 { 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 | 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 { 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 | 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 { 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]); }