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 `. 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 { 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: (path: string, opts?: CallOptions) => Promise; }; /** Build a client bound to one registered server's credentials. */ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient { async function call(path: string, opts: CallOptions = {}): Promise { const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; const headers: Record = { 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 }; }