headscale: device invites — admin surface for offscale enrollment
This commit is contained in:
@@ -26,26 +26,31 @@ import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './r
|
||||
* companion is a state to render, not a request that failed — the admin API on the same domain is
|
||||
* independent and may still be working, so this must not surface as an error the UI swallows.
|
||||
*/
|
||||
const unavailable = (reason: string) => ({ available: false as const, reason });
|
||||
export const unavailable = (reason: string) => ({ available: false as const, reason });
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
type CompanionCall = { path: string; method?: string; timeoutMs?: number; signal?: AbortSignal };
|
||||
type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs?: number; signal?: AbortSignal };
|
||||
|
||||
/**
|
||||
* One request to a server's companion. Returns the raw Response, or a reason string when the companion
|
||||
* itself could not be reached — the caller decides how to present that, because for this feature
|
||||
* "unreachable" is information rather than a failure.
|
||||
*/
|
||||
async function callCompanion(
|
||||
export async function callCompanion(
|
||||
creds: HeadscaleServerCredentials,
|
||||
{ path, method = 'GET', timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
|
||||
{ path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall,
|
||||
): Promise<Response | string> {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${creds.url}/officer-api${path}`, {
|
||||
method,
|
||||
headers: { authorization: `Bearer ${creds.apiKey}`, accept: 'application/json' },
|
||||
headers: {
|
||||
authorization: `Bearer ${creds.apiKey}`,
|
||||
accept: 'application/json',
|
||||
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: signal ?? AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -67,7 +72,7 @@ async function callCompanion(
|
||||
}
|
||||
|
||||
/** Parse a companion JSON body, or a reason when it isn't JSON after all. */
|
||||
async function readBody(res: Response): Promise<Record<string, unknown> | string> {
|
||||
export async function readBody(res: Response): Promise<Record<string, unknown> | string> {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (!text) return 'the companion returned an empty body';
|
||||
try {
|
||||
@@ -80,7 +85,7 @@ async function readBody(res: Response): Promise<Record<string, unknown> | string
|
||||
}
|
||||
|
||||
/** The active server's credentials, or a 409 the UI already knows how to render. */
|
||||
async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
||||
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
||||
const creds = await getActiveHeadscaleCredentials(userId);
|
||||
if (!creds) {
|
||||
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
import { arrayField, toUser } from './normalize';
|
||||
import { handleInvitesRoute } from './invites';
|
||||
|
||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||
@@ -64,6 +65,11 @@ async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promi
|
||||
}
|
||||
|
||||
export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]): Promise<Response | null> {
|
||||
// `/enroll/invites…` is the admin invite surface — a different flow entirely (see invites.ts): the device
|
||||
// is not here and there is no Officer session on it. Same prefix because it is the same feature to the
|
||||
// person using it, and because the spec names it that way.
|
||||
if (segments[0] === 'invites') return handleInvitesRoute(ctx, segments.slice(1));
|
||||
|
||||
if (segments.length > 0) return null;
|
||||
if (ctx.req.method !== 'POST') return methodNotAllowed();
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||
|
||||
// Enrolment invites — the admin half of COMMS/OFFSCALE_INVITE_ENROLLMENT.md. An admin mints a single-use
|
||||
// invite, sends the link to whoever needs to join, and their phone exchanges the claim token for a pre-auth
|
||||
// key it never had to be told.
|
||||
//
|
||||
// WHY THESE PROXY THE COMPANION RATHER THAN LIVING HERE. The invite store has to sit somewhere the joining
|
||||
// phone can reach without an Officer account, and this sidecar is not that: it binds loopback on an
|
||||
// ephemeral port behind Officer's auth. The spec's own argument settles it — an invite must still work when
|
||||
// the platform is down, because the tailnet is often how you reach the platform. So the invite records, the
|
||||
// token hashing and the claim endpoint belong next to Headscale, on its public origin, which is exactly what
|
||||
// the Officer Companion already is. Officer is the admin surface and nothing more: create, list, revoke.
|
||||
//
|
||||
// Officer therefore stores no invite and no token. §5: "Never display, log or store the claim token beyond
|
||||
// the moment it is handed to the admin." The create response passes through this process once, in memory,
|
||||
// on its way to the browser — that is the whole of its life here.
|
||||
//
|
||||
// A server without the enrolment API answers `{available: false, reason}` at HTTP 200, like every other
|
||||
// companion route: most registered servers have no companion at all, and that is a state to render rather
|
||||
// than a request that failed.
|
||||
|
||||
/** Spec §4.1: default 900, max 86400. The floor is ours — a sub-minute invite cannot be sent to anyone. */
|
||||
const DEFAULT_TTL_SECONDS = 900;
|
||||
const MIN_TTL_SECONDS = 60;
|
||||
const MAX_TTL_SECONDS = 86_400;
|
||||
|
||||
type CreateInput = {
|
||||
user: string;
|
||||
ttlSeconds: number;
|
||||
ephemeral: boolean;
|
||||
tags: string[];
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/** Validate the admin's form into the companion's request body, or a 400 saying which field was wrong. */
|
||||
function parseCreate(body: Record<string, unknown> | null): CreateInput | Response {
|
||||
const user = typeof body?.user === 'string' ? body.user.trim() : '';
|
||||
if (!user) return badRequest('user is required — an invite files the joining device under one Headscale user');
|
||||
|
||||
const raw = body?.ttlSeconds;
|
||||
const ttlSeconds = raw === undefined || raw === null ? DEFAULT_TTL_SECONDS : Number(raw);
|
||||
if (!Number.isInteger(ttlSeconds) || ttlSeconds < MIN_TTL_SECONDS || ttlSeconds > MAX_TTL_SECONDS) {
|
||||
return badRequest(`ttlSeconds must be an integer between ${MIN_TTL_SECONDS} and ${MAX_TTL_SECONDS}`);
|
||||
}
|
||||
|
||||
// Tags are admin-set and passed through opaquely (spec §9.2). The `tag:` prefix is Headscale's, and
|
||||
// adding it here means the admin can type either form without minting a key that silently has no tag.
|
||||
const tags = Array.isArray(body?.tags)
|
||||
? [
|
||||
...new Set(
|
||||
body.tags
|
||||
.filter((t): t is string => typeof t === 'string')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => (t.startsWith('tag:') ? t : `tag:${t}`)),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
|
||||
const note = typeof body?.note === 'string' ? body.note.trim().slice(0, 200) : '';
|
||||
|
||||
return { user, ttlSeconds, ephemeral: body?.ephemeral === true, tags, ...(note ? { note } : {}) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a companion answer into ours.
|
||||
*
|
||||
* The three cases are distinct and the UI needs them to stay that way: unreachable is `available: false`
|
||||
* (render an explanation), a companion refusal keeps its own status and message (the admin typed something
|
||||
* the server rejected), and success is the body with `available: true` on it.
|
||||
*/
|
||||
async function relay(res: Response | string, wrap: (body: Record<string, unknown>) => unknown): Promise<Response> {
|
||||
if (typeof res === 'string') return Response.json(unavailable(res));
|
||||
|
||||
const body = await readBody(res);
|
||||
if (typeof body === 'string') return Response.json(unavailable(body));
|
||||
|
||||
if (!res.ok) {
|
||||
const error = typeof body.error === 'string' ? body.error : `the companion returned ${res.status}`;
|
||||
return Response.json(
|
||||
{ error, code: typeof body.code === 'string' ? body.code : undefined },
|
||||
{ status: res.status },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(wrap(body));
|
||||
}
|
||||
|
||||
/** `POST /_officer/enroll/invites` — mint an invite. The response carries the link, and only this once. */
|
||||
async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||
const input = parseCreate(await readJson(ctx.req));
|
||||
if (input instanceof Response) return input;
|
||||
|
||||
const res = await callCompanion(creds, { path: '/enroll/invites', method: 'POST', body: input });
|
||||
return relay(res, (body) => ({ available: true, invite: body.invite ?? body }));
|
||||
}
|
||||
|
||||
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
|
||||
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
|
||||
const res = await callCompanion(creds, { path: '/enroll/invites' });
|
||||
return relay(res, (body) => ({ available: true, invites: Array.isArray(body.invites) ? body.invites : [] }));
|
||||
}
|
||||
|
||||
/** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */
|
||||
async function revoke(creds: HeadscaleServerCredentials, id: string): Promise<Response> {
|
||||
const res = await callCompanion(creds, { path: `/enroll/invites/${encodeURIComponent(id)}`, method: 'DELETE' });
|
||||
return relay(res, (body) => ({ available: true, ...body }));
|
||||
}
|
||||
|
||||
/** Dispatch `/_officer/enroll/invites...`. Acts on the ACTIVE server, like every other domain route. */
|
||||
export async function handleInvitesRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
|
||||
const creds = await activeCreds(ctx.userId);
|
||||
if (creds instanceof Response) return creds;
|
||||
|
||||
const [id, extra] = rest;
|
||||
if (extra) return notFound();
|
||||
|
||||
if (!id) {
|
||||
if (ctx.req.method === 'POST') return create(creds, ctx);
|
||||
if (ctx.req.method === 'GET') return list(creds);
|
||||
return methodNotAllowed();
|
||||
}
|
||||
|
||||
if (ctx.req.method !== 'DELETE') return methodNotAllowed();
|
||||
return revoke(creds, id);
|
||||
}
|
||||
Reference in New Issue
Block a user