Files
platform/src/servers/sidecar/headscale/enroll.ts
T
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00

104 lines
4.9 KiB
TypeScript

import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize';
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.
//
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
// those two before it gets anywhere near the user name. Enrolment acts on the ACTIVE registered server now,
// like every other domain route here, and the platform holds no Headscale credentials at all.
//
// The response shape `{controlUrl, authKey}` is a CONTRACT: enrollVpn() in the mobile core
// (monorepo-mobile/packages/core/src/services/officer-net.ts) destructures exactly those two fields and
// feeds them to configure()/loginWithAuthKey(). Extra fields are safe; renaming those two is not.
/** Short by design: the key is redeemed seconds after it is issued, and a leaked one should die quickly. */
const KEY_TTL_MS = 10 * 60_000;
/**
* Which Headscale user the joining device is filed under.
*
* An explicit `userId` wins. Otherwise the choice is only made when it is UNAMBIGUOUS — one user on the
* server means there is nothing to choose. Several means the caller has to say, because picking silently
* files someone's phone under the wrong owner and the mistake stays invisible until somebody audits the
* tailnet. The old env var picked one name for every server at once, which is precisely that bug.
*/
async function resolveOwner(client: HeadscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
const body = await readJson(ctx.req);
const requested = typeof body?.userId === 'string' ? body.userId.trim() : '';
const listed = await client.call('/api/v1/user');
const users = arrayField(listed, 'users')
.map(toUser)
.filter((u): u is OfficerUser => !!u);
if (requested) {
const match = users.find((u) => u.id === requested);
return match ?? badRequest(`no Headscale user with id ${requested} on the active server`);
}
if (users.length === 1) return users[0]!;
if (users.length === 0) {
return Response.json(
{ error: 'the active Headscale server has no users — create one before enrolling a device', code: 'no_users' },
{ status: 409 },
);
}
return Response.json(
{
error: 'the active Headscale server has several users — pass userId to say which one owns this device',
code: 'ambiguous_user',
users: users.map((u) => ({ id: u.id, name: u.name })),
},
{ status: 409 },
);
}
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();
// Not activeClient(): the control URL goes back to the device, and only the credentials carry it.
const creds = await getActiveHeadscaleCredentials(ctx.userId);
if (!creds) {
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
}
const client = createClient(creds);
const owner = await resolveOwner(client, ctx);
if (owner instanceof Response) return owner;
const created = await client.call<{ preAuthKey?: { key?: string } }>('/api/v1/preauthkey', {
method: 'POST',
body: {
user: owner.id,
reusable: false, // one key, one device
ephemeral: false, // the node stays registered after it disconnects
expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339
},
});
const authKey = created.preAuthKey?.key;
if (!authKey) return Response.json({ error: 'headscale returned no key' }, { status: 502 });
// `server` and `user` are advisory — for a UI that wants to say what the device just joined.
return Response.json({ controlUrl: creds.url, authKey, server: creds.name, user: owner.name });
}