Files
offscale/sidecar/servers.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +00:00

202 lines
8.3 KiB
TypeScript

import type { OfficerContext } from './routes';
import {
listHeadscaleServers,
createHeadscaleServer,
updateHeadscaleServer,
setActiveHeadscaleServer,
deleteHeadscaleServer,
getHeadscaleCredentials,
recordHeadscaleProbe,
} from '../db/queries';
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]);
}