enroll devices against the headscale server the owner picked

/api/vpn/enroll minted pre-auth keys itself, from HEADSCALE_URL, HEADSCALE_API_KEY
and HEADSCALE_USER in the host env. Three globals describe one server; Officer keeps
a registry of many in headscale_servers with one active, so the env could contradict
the server the owner had selected — and HEADSCALE_USER filed every joining device
under the same name on all of them.

The two credential vars had already been removed from the environment and nothing
noticed: the route checks `if (!base || !apiKey)` first, so it had been answering
503 to every enrollment attempt, silently. HEADSCALE_USER was read but never reached.

Enrollment moves into the sidecar that owns the registry and acts on the active
server. The owning user is resolved rather than hardcoded: an explicit userId wins,
one user on the server needs no choice, several is a 409 listing them instead of a
silent guess. The platform route keeps its path and response shape — both are a
contract with enrollVpn() in the mobile core — and is now a bare forward holding no
Headscale URL, key or user name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 01:07:56 +00:00
co-authored by Claude Opus 5
parent 8e7e129f02
commit 69961a52cd
4 changed files with 140 additions and 98 deletions
+96
View File
@@ -0,0 +1,96 @@
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';
// 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` 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> {
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 });
}
+5 -1
View File
@@ -11,7 +11,8 @@ import { MIN_VERSION_LABEL } from './version';
// generated on that server, and switches between them; one is active at a time. So configuration lives in
// Postgres (headscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads
// neither HEADSCALE_URL nor HEADSCALE_API_KEY, so a registered server can never be shadowed by host env.
// (Those two vars belong solely to the unrelated /api/vpn/enroll route, which is none of our business.)
// Device enrollment used to be the exception, minting keys in the platform from those two vars plus
// HEADSCALE_USER; it moved here (enroll.ts) and now acts on the active server like everything else.
//
// ─────────────────────────────────────────────────────────────────────────────────────────────────
// HTTP CONTRACT — the platform strips its /api/headscale mount prefix before forwarding.
@@ -43,6 +44,9 @@ import { MIN_VERSION_LABEL } from './version';
// → the ONLY response carrying the real secret
// POST /_officer/keys/:id/expire expire without deleting
// DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server
// has more than one user; reached via /api/vpn/enroll.
// anything else 404
//
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
+3
View File
@@ -3,6 +3,7 @@ import { handleServersRoute } from './servers';
import { handleNodesRoute } from './nodes';
import { handleUsersRoute } from './users';
import { handleKeysRoute } from './keys';
import { handleEnrollRoute } from './enroll';
// Officer-owned routes for the headscale sidecar — the entire feature surface lives under /_officer/.
//
@@ -55,6 +56,8 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise<Respon
return await handleUsersRoute(ctx, segments.slice(1));
case 'keys':
return await handleKeysRoute(ctx, segments.slice(1));
case 'enroll':
return await handleEnrollRoute(ctx, segments.slice(1));
default:
return null;
}