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.
143 lines
6.9 KiB
TypeScript
143 lines
6.9 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
|
|
import type { OffscaleClient } 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: OffscaleClient, 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] });
|
|
}
|