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>
190 lines
7.7 KiB
TypeScript
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]);
|
|
}
|