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:
@@ -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] });
|
||||
}
|
||||
Reference in New Issue
Block a user