Files
platform/plugins/offscale/sidecar/keys.ts
T
pastilhasandClaude Opus 5 e13128846b 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>
2026-08-15 00:15:38 +00:00

120 lines
5.4 KiB
TypeScript

import type { OfficerContext } from './routes';
import { badRequest, notFound, methodNotAllowed, readJson } from './routes';
import { activeClient } from './active';
import { toPreAuthKey, arrayField } from './normalize';
// Pre-auth key routes — /_officer/keys/*. These are the tokens a machine uses to join the tailnet.
//
// The one thing that matters here: since 0.28 Headscale stores pre-auth keys HASHED and returns the real
// secret ONLY in the create response. Every later list returns it masked as `hskey-auth-<prefix>-***`. A
// creation response that the UI drops is a key the owner can never recover — it has to be shown once, with
// a copy affordance, and the API has to make the difference legible. `key` is non-null exactly once.
//
// That "exactly once" is enforced by call path, not by inspecting the value: keys created before 0.28 are
// still plaintext upstream and Headscale hands them back in full from the LIST endpoint for backwards
// compatibility. So listing passes reveal:false and drops the secret unconditionally; only createKey
// reveals. A server with history in it would otherwise leak live keys into the browser's query cache.
//
// Also note the shape of the delete/expire pair: expire takes the id in a POST BODY, delete takes it in a
// query STRING, and neither is a REST-shaped path. Both are hidden behind ordinary Officer routes.
/** Default lifetime when the caller doesn't pick one; matches Headscale's own CLI default. */
const DEFAULT_EXPIRY_DAYS = 90;
const MAX_EXPIRY_DAYS = 3650;
async function listKeys(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
// 0.29 lists every user's keys in one call (pre-0.29 required a ?user= filter and one call per user).
const body = await client.call('/api/v1/preauthkey');
const keys = arrayField(body, 'preAuthKeys').map((raw) => toPreAuthKey(raw, { reveal: false }));
// Usable keys first, then by newest — a spent key is history, an active one is the thing you came for.
const rank = { active: 0, used: 1, expired: 2 } as const;
keys.sort((a, b) => rank[a.status] - rank[b.status] || (b.createdAt ?? '').localeCompare(a.createdAt ?? ''));
return Response.json({ keys });
}
async function createKey(ctx: OfficerContext): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
const body = await readJson(ctx.req);
if (!body) return badRequest('expected a JSON body');
// CreatePreAuthKey takes a numeric user ID — unlike the node list filter, which takes a username. The
// two are easy to confuse and the failure is a confusing upstream error, so it's validated here.
const userId = typeof body.userId === 'string' ? body.userId.trim() : '';
if (!/^\d+$/.test(userId)) return badRequest('userId must be the numeric id of a Headscale user');
const days = body.expirationDays === undefined ? DEFAULT_EXPIRY_DAYS : Number(body.expirationDays);
if (!Number.isFinite(days) || days <= 0 || days > MAX_EXPIRY_DAYS) {
return badRequest(`expirationDays must be between 1 and ${MAX_EXPIRY_DAYS}`);
}
const aclTags = Array.isArray(body.aclTags)
? body.aclTags
.filter((t): t is string => typeof t === 'string')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`))
: [];
const created = await client.call<{ preAuthKey?: Record<string, unknown> }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: userId,
reusable: body.reusable === true,
ephemeral: body.ephemeral === true,
expiration: new Date(Date.now() + days * 86_400_000).toISOString(),
aclTags,
},
});
if (!created.preAuthKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
const key = toPreAuthKey(created.preAuthKey, { reveal: true });
// Stated explicitly rather than left for the client to infer from `key !== null`: this response is the
// only time the secret exists anywhere outside the joining machine.
return Response.json({ key, secretShownOnce: true }, { status: 201 });
}
type KeyActionParams = { ctx: OfficerContext; id: string; action: string | undefined };
async function handleKeyAction({ ctx, id, action }: KeyActionParams): Promise<Response> {
const client = await activeClient(ctx.userId);
if (client instanceof Response) return client;
if (action === 'expire') {
if (ctx.req.method !== 'POST') return methodNotAllowed();
await client.call('/api/v1/preauthkey/expire', { method: 'POST', body: { id } });
return new Response(null, { status: 204 });
}
if (action !== undefined) return notFound();
if (ctx.req.method === 'DELETE') {
await client.call(`/api/v1/preauthkey?id=${encodeURIComponent(id)}`, { method: 'DELETE' });
return new Response(null, { status: 204 });
}
return methodNotAllowed();
}
/** Dispatch `/_officer/keys/...`. `rest` is the path after `keys`. */
export async function handleKeysRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) {
if (ctx.req.method === 'GET') return listKeys(ctx);
if (ctx.req.method === 'POST') return createKey(ctx);
return methodNotAllowed();
}
if (rest.length > 2) return notFound();
const id = rest[0];
if (!id || !/^\d+$/.test(id)) return badRequest('key id must be numeric');
return handleKeyAction({ ctx, id, action: rest[1] });
}