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