headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
114 lines
4.9 KiB
TypeScript
114 lines
4.9 KiB
TypeScript
import type { HeadscaleServerCredentials } from '../db/queries';
|
|
|
|
// 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,
|
|
/**
|
|
* Headscale's own words, kept even when `message` generalizes them.
|
|
*
|
|
* A 5xx is normally not safe to relay — it leaks internals and rarely helps. The policy endpoints are
|
|
* the exception: Headscale answers "policy is read from a file" and reports a HuJSON syntax error's
|
|
* line and column with the same 500, and there the message IS the feature. Callers that know their
|
|
* endpoint's 5xx is a real answer read this; everyone else keeps getting "headscale error".
|
|
*/
|
|
readonly detail?: 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.
|
|
const serverSide = res.status >= 500;
|
|
throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, 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 };
|
|
}
|