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
+36 -97
View File
@@ -1,109 +1,48 @@
import { createRouter } from '../../create-router';
import { getHeadscaleServerUrl } from '../headscale/router';
// Enrollment for OffTail, the in-app Tailscale. The VPN's control and data planes talk DIRECTLY to
// Headscale — never through /api — so this router is not a proxy and must not grow into one. Its whole
// job is to turn an authenticated Officer session into a short-lived Headscale pre-auth key, so the phone
// can register itself without anyone pasting a key by hand.
// Enrollment for OffTail, the in-app Tailscale. Authenticate the owner, forward to officer-headscale, and
// hold no Headscale knowledge whatsoever no URL, no admin key, no user name.
//
// Headscale runs on a separate host, managed manually. The platform only consumes two env vars:
// HEADSCALE_URL e.g. https://headscale.pastilhas.dev — also handed back to the app as controlUrl
// HEADSCALE_API_KEY admin API key (bearer); a secret, so it lives in the host env, never the repo
// Both may be absent (Headscale is being stood up separately), which is a 503, not a crash.
const HEADSCALE_USER = process.env.HEADSCALE_USER ?? 'officer';
const KEY_TTL_MS = 10 * 60_000;
const UPSTREAM_TIMEOUT_MS = 10_000;
type PreAuthKeyResponse = { preAuthKey?: { key?: string } };
type UserListResponse = { users?: Array<{ id?: string | number; name?: string }> };
const config = () => ({
// Trailing slash would produce //api/v1/... — harmless on most servers, but not worth relying on.
base: (process.env.HEADSCALE_URL ?? '').replace(/\/+$/, ''),
apiKey: process.env.HEADSCALE_API_KEY ?? '',
});
const authHeaders = (apiKey: string) => ({
authorization: `Bearer ${apiKey}`,
'content-type': 'application/json',
});
/** Headscale's user id for `name`, or null if it can't be resolved (old API shape, or no such user). */
async function resolveUserId(base: string, apiKey: string, name: string): Promise<string | null> {
try {
const res = await fetch(`${base}/api/v1/user`, {
headers: authHeaders(apiKey),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
if (!res.ok) return null;
const body = (await res.json()) as UserListResponse;
const match = body.users?.find((u) => u.name === name);
return match?.id === undefined || match.id === null ? null : String(match.id);
} catch {
return null;
}
}
function mintKey(base: string, apiKey: string, user: string): Promise<Response> {
return fetch(`${base}/api/v1/preauthkey`, {
method: 'POST',
headers: authHeaders(apiKey),
body: JSON.stringify({
user,
reusable: false, // single use — one key, one device
ephemeral: false, // the node stays registered after it disconnects
expiration: new Date(Date.now() + KEY_TTL_MS).toISOString(), // RFC3339
}),
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
});
}
// This file used to mint the pre-auth key itself, from HEADSCALE_URL / HEADSCALE_API_KEY / HEADSCALE_USER
// read out of the host env. Three globals describe exactly one server; Officer keeps a registry of many in
// `headscale_servers`, one active at a time, so the env could contradict the server the owner had selected.
// The two credential vars were later removed and the failure was silent — `if (!base || !apiKey)` returned
// 503 before the rest of the route ever ran, so enrollment had simply stopped working and said nothing.
// The logic now lives in the sidecar that owns the registry (src/servers/sidecar/headscale/enroll.ts).
//
// It stays mounted at /api/vpn rather than moving under /api/headscale because the path is a contract:
// enrollVpn() in the mobile core POSTs exactly /api/vpn/enroll. createSidecarProxy strips its own prefix
// and cannot express that rewrite, so this one forward is spelled out by hand.
export const vpnRouter = createRouter();
// POST /api/vpn/enroll → { controlUrl, authKey }
//
// That response shape is a contract: enrollVpn() in @officer/core/officer-net.ts reads exactly those two
// fields, so changing it means changing the mobile app too.
// The response shape is the other half of the contract: enrollVpn() in @officer/core destructures exactly
// those two fields, so changing them means changing the mobile app too.
vpnRouter.post('/enroll', async (ctx) => {
const { base, apiKey } = config();
if (!base || !apiKey) {
return ctx.json({ error: 'VPN not configured' }, 503);
const baseUrl = getHeadscaleServerUrl();
if (!baseUrl) return ctx.json({ error: 'headscale sidecar not available' }, 503);
// Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several.
const body = await ctx.req.arrayBuffer();
let upstream: Response;
try {
upstream = await fetch(`${baseUrl}/_officer/enroll`, {
method: 'POST',
headers: {
'content-type': ctx.req.header('content-type') ?? 'application/json',
// The authenticated owner. The sidecar binds loopback only, so its presence is the trust signal.
'X-Officer-User': String(ctx.get('user').id),
},
body: body.byteLength ? body : undefined,
});
} catch (err) {
console.error('[vpn] headscale sidecar unreachable:', err);
return ctx.json({ error: 'headscale sidecar unreachable' }, 502);
}
// The `user` field changed meaning across Headscale versions: a name on <=v0.22, a numeric id on v0.23+.
// Rather than pin a version we can't see from here, try the id when we can resolve one and fall back to
// the name — whichever the running Headscale accepts wins.
const userId = await resolveUserId(base, apiKey, HEADSCALE_USER);
const attempts = userId ? [userId, HEADSCALE_USER] : [HEADSCALE_USER];
let lastStatus = 0;
let lastBody = '';
for (const user of attempts) {
let res: Response;
try {
res = await mintKey(base, apiKey, user);
} catch (err) {
// Unreachable or timed out — no point trying the other identifier against the same dead host.
console.error('[vpn] headscale unreachable:', err);
return ctx.json({ error: 'headscale unreachable' }, 502);
}
if (res.ok) {
const body = (await res.json()) as PreAuthKeyResponse;
const authKey = body.preAuthKey?.key;
if (!authKey) {
console.error('[vpn] headscale returned no preAuthKey.key');
return ctx.json({ error: 'headscale returned no key' }, 502);
}
return ctx.json({ controlUrl: base, authKey });
}
lastStatus = res.status;
lastBody = (await res.text().catch(() => '')).slice(0, 300);
}
// Never echo the upstream body to the client — it is an admin API and its errors can be descriptive.
console.error(`[vpn] headscale preauthkey failed (${lastStatus}): ${lastBody}`);
return ctx.json({ error: 'headscale preauthkey failed' }, 502);
return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) });
});