Files
platform/src/servers/sidecar/headscale/servers.ts
T
pastilhasandClaude Opus 5 adf922de30 add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and
their admin api keys, the >=0.29 version floor, and every multi-call composition
the ui needs. the platform side is auth+forward only and holds no headscale
credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars
are untouched and unrelated.

officer manages many servers rather than one. the owner registers each with a url
and a key generated on that server and switches between them; exactly one is
active, enforced by a partial unique index rather than by convention. keys are
encrypted at rest and never leave the sidecar — the list projection cannot return
one. registration validates before it saves: an unauthenticated GET /version to
prove something headscale-shaped is there and meets the floor, then an
authenticated call to prove the key works. an edit that moves either half
re-validates.

there is deliberately no transparent /api/v1/* passthrough. headscale serialises
every uint64 as a json string and its rest shape moved repeatedly below 0.29;
proxying raw would push all of that into the browser, which is the mistake the
soulseek panels made with 37 raw upstream calls.

the /headscale workspace is nav + view over the panel system. only the servers
section is implemented — nodes, users and pre-auth keys say so plainly rather
than rendering an empty table that reads as a failed fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:55:32 +00:00

190 lines
7.7 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';
// 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;
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,
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;
// 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 });
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]);
}