Files
platform/src/servers/sidecar/headscale/client.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

104 lines
4.4 KiB
TypeScript

import type { HeadscaleServerCredentials } from 'officerdb';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once:
//
// • Auth is `Authorization: Bearer <apiKey>`. Headscale's swagger declares no securityDefinitions at all,
// so a generated client would omit it entirely.
// • 401/403 bodies are PLAIN TEXT ("Unauthorized"), with no content-type — every other error is
// grpc-gateway `{code,message,details}` JSON. Blindly .json()-ing an error body throws on exactly the
// auth failure you most want to report clearly.
// • Every uint64 is serialized as a JSON STRING, not a number: `node.id` arrives as "7". We keep ids as
// strings end to end and never round-trip them through Number, which would silently break above 2^53.
// • The gateway marshals with EmitUnpopulated, so absent values come back as [] / null / "" / false rather
// than being omitted. You cannot distinguish "unset" from "empty" — don't try.
// • It also marshals with DiscardUnknown, so a misspelled request field is IGNORED rather than rejected.
// Silent no-ops are the failure mode; mutations here read the object back where the API returns it.
const DEFAULT_TIMEOUT_MS = 15_000;
/** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */
export class HeadscaleError extends Error {
constructor(
readonly status: number,
message: string,
) {
super(message);
this.name = 'HeadscaleError';
}
}
type CallOptions = { method?: string; body?: unknown; timeoutMs?: number };
/**
* Extract a human-usable message from a Headscale error response, tolerating both of its formats.
* Never returned verbatim to the browser for auth failures — see callers.
*/
async function errorMessage(res: Response): Promise<string> {
const text = await res.text().catch(() => '');
if (!text) return `upstream returned ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: unknown };
if (typeof parsed.message === 'string' && parsed.message) return parsed.message;
} catch {
/* plain text — the 401 case */
}
return text.slice(0, 300);
}
export type HeadscaleClient = {
readonly serverId: number;
/** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */
call: <T>(path: string, opts?: CallOptions) => Promise<T>;
};
/** Build a client bound to one registered server's credentials. */
export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient {
async function call<T>(path: string, opts: CallOptions = {}): Promise<T> {
const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts;
const headers: Record<string, string> = {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
};
if (body !== undefined) headers['content-type'] = 'application/json';
let res: Response;
try {
res = await fetch(`${creds.url}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const timedOut = err instanceof Error && err.name === 'TimeoutError';
throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable');
}
if (res.status === 401 || res.status === 403) {
// The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an
// Officer-side auth problem — the UI should point the owner at re-entering the key.
throw new HeadscaleError(502, 'headscale rejected the stored API key');
}
if (!res.ok) {
const message = await errorMessage(res);
console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`);
// 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized.
throw new HeadscaleError(res.status >= 500 ? 502 : res.status, res.status >= 500 ? 'headscale error' : message);
}
// 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all.
const text = await res.text();
if (!text) return {} as T;
try {
return JSON.parse(text) as T;
} catch {
throw new HeadscaleError(502, 'headscale returned a non-JSON body');
}
}
return { serverId: creds.id, call };
}