Files
pastilhas 95b84ea748 rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what
"extracted" meant. Music, done last, is the standard. This brings offscale to it.

── The rebrand ──

The plugin was `offscale` to the platform and `headscale` to itself: sidecar
name and handles, the port announcement, the API proxy name, the React
components, every hook, the react-query keys, the panel ids and appTypes, and
the Postgres table. Now all of those say offscale.

The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and
Headscale is the server it manages. So every IDENTIFIER is offscale, while a
message like `headscale unreachable`, the `headscale apikeys create` hint and the
ACL assistant's prompt still say Headscale — because they are talking about the
remote server, and renaming them would make the code lie about what it reached.
495 occurrences became 180, and the 180 are all of that second kind.

── The live bug this uncovered ──

`headscaleSectionPath` built links to `/headscale/<section>`. The shell has no
such route — plugin routes come from `plugin.route`, which is `/offscale` — and
it redirects unknown paths to the home page. So every section link in the nav,
the console and the server picker silently went home. The extraction moved the
route and left the link builder behind.

Also live: ServersView told the user to run
`pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not
existed since the sidecar was renamed.

── The correctness fix music already had ──

api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips
`prefix.length` characters, so a literal is correct only for a first-party
publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale`
and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does.

── The rest ──

- assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops
  being a glyph badge.
- First tests: 21 of them, over the version floor and the protobuf normalisers.
  Those are the two places a Headscale release actually breaks this, and they had
  no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor
  first would refuse 1.0 as older than 0.29.
- OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts,
  which is not linkable and not published. Now a document, as MUSIC_API.md is.
- web/panels.ts re-exported three components. A plugin cannot export components;
  that was residue of the platform importing them before extraction.
- Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/,
  neither of which has existed since the extraction.

The crypto purpose moved headscale → offscale too, and the secret-store row was
renamed rather than left to create a fresh key — the material is preserved, so
this is reversible. Free to do only because offscale_servers had 0 rows; with one
stored API key it would have been a migration.
2026-08-15 18:41:52 +00:00

205 lines
9.7 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { getActiveOffscaleCredentials, type OffscaleServerCredentials } 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/OFFSCALE_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: OffscaleServerCredentials,
{ 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<OffscaleServerCredentials | Response> {
const creds = await getActiveOffscaleCredentials(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: OffscaleServerCredentials): 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: OffscaleServerCredentials, 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: OffscaleServerCredentials, 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: OffscaleServerCredentials, 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();
}