The tailnet plugin — machines, users, pre-auth keys, access policy and device invites. Moved out of officerdev/platform, where it had lived in plugins/ since the plugin system was built. Until now this code existed in exactly one place: the platform repository. That made "gitignore the plugins directory" impossible to do safely, because untracking it would have left 49 files on a single disk with no remote. This repository is what makes that move safe. Same extraction as plugins/music before it: source only, no history. The platform's history still holds every commit that shaped this, and the SHAs cited across the codebase keep resolving — replaying it here would have created a second, divergent account of the same work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
120 lines
5.4 KiB
TypeScript
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] });
|
|
}
|