offscale is a plugin

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>
This commit is contained in:
2026-08-15 00:15:38 +00:00
co-authored by Claude Opus 5
parent 0e24aa3d52
commit e13128846b
111 changed files with 351 additions and 302 deletions
+204
View File
@@ -0,0 +1,204 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
// admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it.
// Contract: COMMS/HEADSCALE_COMPANION_API.md.
//
// Three facts shape everything here.
//
// 1. It lives at `${server.url}/officer-api` and authenticates with the SAME admin API key we already
// store, validated locally against Headscale's own key store — so auth keeps working while Headscale
// is down, which is exactly when `/restart` matters. Nothing new to register, and the key still never
// leaves this sidecar.
//
// 2. It is OPTIONAL and per-server. Of the four servers registered here today, one has it deployed. So
// "no companion" is a normal state, not an error: every route below answers 200 with
// `{available: false, reason}` rather than failing, and the UI degrades to what the admin API can do.
// Distinguishing the two 502s is the whole trick — nginx returns HTML when the companion is down,
// the companion returns JSON when a docker op fails. Branch on whether the body parses.
//
// 3. `GET /health` is ALWAYS 200, at every verdict. Never key anything off its HTTP status; read
// `verdict`. That inversion is deliberate on their side and is preserved on ours.
/**
* Every route answers `{available: true, ...}` or `{available: false, reason}` at HTTP 200. Not having a
* companion is a state to render, not a request that failed — the admin API on the same domain is
* independent and may still be working, so this must not surface as an error the UI swallows.
*/
export const unavailable = (reason: string) => ({ available: false as const, reason });
const DEFAULT_TIMEOUT_MS = 20_000;
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
/**
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
* itself could not be reached — the caller decides how to present that, because for this feature
* "unreachable" is information rather than a failure.
*/
export async function callCompanion(
creds: HeadscaleServerCredentials,
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
): Promise<Response | string> {
let res: Response;
try {
res = await fetch(`${creds.url}/officer-api${path}`, {
method,
headers: {
authorization: `Bearer ${creds.apiKey}`,
accept: 'application/json',
...(body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: body === undefined ? undefined : JSON.stringify(body),
signal: signal ?? AbortSignal.timeout(timeoutMs),
});
} catch (err) {
if (err instanceof Error && err.name === 'TimeoutError') return 'the companion timed out';
// A TLS failure or DNS miss on the server's own domain: the whole host is unreachable, not just this.
return 'could not reach the server';
}
if (res.status === 401) return 'the companion rejected the stored API key';
// Both 404 and 502 are ambiguous, and the same test settles both: a JSON body means the companion
// answered (no such container / the docker op failed) and that answer belongs to the caller; a
// non-JSON body means we never reached it — nginx's own 502 page, or a route that isn't there at all.
const isJson = (res.headers.get('content-type') ?? '').includes('json');
if (res.status === 404 && !isJson) return 'this server has no companion at /officer-api';
if (res.status === 502 && !isJson) return 'the companion is not deployed on this server';
if (res.status >= 500 && !isJson) return `the companion returned ${res.status}`;
return res;
}
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
export async function readBody(res: Response): Promise<Record<string, unknown> | string> {
const text = await res.text().catch(() => '');
if (!text) return 'the companion returned an empty body';
try {
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object') return 'the companion returned an unexpected body';
return parsed as Record<string, unknown>;
} catch {
return 'the companion returned a non-JSON body';
}
}
/** The active server's credentials, or a 409 the UI already knows how to render. */
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
const creds = await getActiveHeadscaleCredentials(userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
return creds;
}
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
async function health(creds: HeadscaleServerCredentials): Promise<Response> {
const res = await callCompanion(creds, { path: '/health' });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
// Passed through as-is. The companion owns this vocabulary and versions it; re-shaping it here would mean
// a new verdict or a new likely-cause silently disappearing on the way to the screen.
return Response.json({ available: true, health: body });
}
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Response> {
const tail = Number(url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
const res = await callCompanion(creds, { path: `/logs?tail=${tail}` });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
const lines = Array.isArray(body.lines) ? body.lines.filter((l): l is string => typeof l === 'string') : [];
return Response.json({ available: true, lines });
}
/**
* `GET /_officer/companion/logs/stream?tail=N` — the live tail, relayed frame for frame.
*
* The browser cannot open this itself: EventSource sends no Authorization header, and the key it would need
* is one this sidecar exists to keep. So the stream is proxied, and the body is returned UNTOUCHED — a
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
*/
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
const tail = Number(ctx.url.searchParams.get('tail') ?? 200);
if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 12000');
// No timeout: a quiet log is the normal case and must not look like a dropped connection. The request's
// own signal is the lifetime — when the panel closes, this closes.
const res = await callCompanion(creds, {
path: `/logs?tail=${tail}&follow=1`,
signal: ctx.req.signal,
});
// An unavailable companion still answers in the stream's own vocabulary, so the client has one parser and
// one place to show a problem rather than a second, JSON-shaped failure mode.
if (typeof res === 'string') {
return new Response(`event: error\ndata: ${res}\n\n`, {
headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
});
}
return new Response(res.body, {
status: 200,
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
// Belt and braces through our own proxy chain, matching what the companion already sets.
'x-accel-buffering': 'no',
},
});
}
const ACTIONS = new Set(['restart', 'stop', 'start']);
/**
* `POST /_officer/companion/:action` — restart / stop / start the Headscale container.
*
* Every one of these drops every node's control-plane connection for the duration. That is the intended
* "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically.
*/
async function action(creds: HeadscaleServerCredentials, name: string): Promise<Response> {
// 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the
// one question this feature exists to answer.
const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 });
if (typeof res === 'string') return Response.json(unavailable(res));
const body = await readBody(res);
if (typeof body === 'string') return Response.json(unavailable(body));
return Response.json({ available: true, ...body });
}
/** Dispatch `/_officer/companion/...`. Always acts on the ACTIVE server, like every other domain route. */
export async function handleCompanionRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
const creds = await activeCreds(ctx.userId);
if (creds instanceof Response) return creds;
const [head, tail] = rest;
if (head === 'health' && !tail) {
if (ctx.req.method !== 'GET') return methodNotAllowed();
return health(creds);
}
if (head === 'logs') {
if (ctx.req.method !== 'GET') return methodNotAllowed();
if (!tail) return logs(creds, ctx.url);
if (tail === 'stream') return logStream(creds, ctx);
return notFound();
}
if (head && ACTIONS.has(head) && !tail) {
if (ctx.req.method !== 'POST') return methodNotAllowed();
return action(creds, head);
}
return notFound();
}