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
+142
View File
@@ -0,0 +1,142 @@
import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import type { HeadscaleClient } from './client';
import { activeClient } from './active';
import { toNode, arrayField, type OfficerNode } from './normalize';
// Node routes — /_officer/nodes/*. A "node" is a machine in the tailnet.
//
// Two upstream shapes are worth knowing before reading this:
//
// • Renaming takes the new name in the PATH (`/node/{id}/rename/{newName}`), not a body. It must be
// encodeURIComponent'd or a name with a slash silently becomes a 404 on a different route.
// • Route approval is a whole-SET write (`approve_routes` replaces the approved list), not an
// add/remove. Approving one route means sending every route that should remain approved, so those
// operations are read-modify-write here rather than in the browser — see rule 5 in
// SIDECAR_ARCHITECTURE.md. Doing it client-side would make two admins racing lose each other's edits;
// doing it here still races, but over milliseconds instead of however long a form sits open.
/** Nodes on the active server, newest-registered first within each user. */
async function listNodes(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
// The upstream `user` filter takes a USERNAME, not an id — a trap worth keeping out of the browser.
const user = ctx.url.searchParams.get('user');
const path = user ? `/api/v1/node?user=${encodeURIComponent(user)}` : '/api/v1/node';
const body = await client.call(path);
const nodes = arrayField(body, 'nodes').map(toNode);
nodes.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name));
return Response.json({ nodes });
}
/** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */
async function getNode(client: HeadscaleClient, id: string): Promise<OfficerNode | null> {
const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
return body.node ? toNode(body.node) : null;
}
type NodeActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleNodeAction({ ctx, id, action }: NodeActionParams): Promise<Response> {
const { req } = ctx;
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === undefined) {
if (req.method === 'GET') {
const node = await getNode(client, id);
return node ? Response.json({ node }) : notFound('no such node');
}
if (req.method === 'DELETE') {
await client.call(`/api/v1/node/${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
if (req.method !== 'POST') return methodNotAllowed();
if (action === 'rename') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
const name = typeof body.name === 'string' ? body.name.trim() : '';
if (!name) return badRequest('name is required');
await client.call(`/api/v1/node/${encodeURIComponent(id)}/rename/${encodeURIComponent(name)}`, { method: 'POST' });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'tags') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
if (!Array.isArray(body.tags)) return badRequest('tags must be an array of strings');
const tags = body.tags.filter((t): t is string => typeof t === 'string').map((t) => t.trim());
if (tags.some((t) => !t)) return badRequest('tags cannot be empty strings');
// Headscale requires the `tag:` prefix and rejects anything else with a 500, which we'd surface as a
// useless "headscale error". Normalizing here means the UI can accept either form.
const prefixed = tags.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`));
await client.call(`/api/v1/node/${encodeURIComponent(id)}/tags`, { method: 'POST', body: { tags: prefixed } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'routes') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
let routes: string[];
if (Array.isArray(body.routes)) {
// Whole-set write: the caller states the complete approved list.
routes = body.routes.filter((r): r is string => typeof r === 'string');
} else if (typeof body.route === 'string' && typeof body.approved === 'boolean') {
// Single-toggle: read the current set, apply one change, write it back.
const current = await getNode(client, id);
if (!current) return notFound('no such node');
const set = new Set(current.approvedRoutes);
if (body.approved) set.add(body.route);
else set.delete(body.route);
routes = [...set];
} else {
return badRequest('expected {routes: string[]} or {route: string, approved: boolean}');
}
await client.call(`/api/v1/node/${encodeURIComponent(id)}/approve_routes`, { method: 'POST', body: { routes } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'user') {
const body = await readJson(req);
if (!body) return badRequest('expected a JSON body');
// Upstream takes the target user's numeric id, not its name — and uint64-as-string, so it is validated
// by shape and passed through as a string rather than parsed.
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!/^\d+$/.test(userId)) return badRequest('userId must be numeric');
// Moving a node changes which ACL rules and tag ownership apply to it — the routes it advertises and
// the tags it carries stay put, but what they now MEAN can differ. The UI says so before asking.
await client.call(`/api/v1/node/${encodeURIComponent(id)}/user`, { method: 'POST', body: { user: userId } });
return Response.json({ node: await getNode(client, id) });
}
if (action === 'expire') {
// Expires the node's key, forcing it to re-authenticate. Not a delete: the node stays registered.
await client.call(`/api/v1/node/${encodeURIComponent(id)}/expire`, { method: 'POST' });
return Response.json({ node: await getNode(client, id) });
}
return notFound();
}
/** Dispatch `/_officer/nodes/...`. `rest` is the path after `nodes`. */
export async function handleNodesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method !== 'GET') return methodNotAllowed();
return listNodes(ctx);
}
if (rest.length > 2) return notFound();
const id = rest[0];
// Upstream ids are uint64-as-string. Validate the shape without parsing — Number() would lose precision.
if (!id || !/^\d+$/.test(id)) return badRequest('node id must be numeric');
return handleNodeAction({ ctx, id, action: rest[1] });
}