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:
@@ -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);
|
||||
|
||||
// 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];
|
||||
// Forwarded verbatim: an optional {userId} picks the owning Headscale user when the server has several.
|
||||
const body = await ctx.req.arrayBuffer();
|
||||
|
||||
let lastStatus = 0;
|
||||
let lastBody = '';
|
||||
|
||||
for (const user of attempts) {
|
||||
let res: Response;
|
||||
let upstream: Response;
|
||||
try {
|
||||
res = await mintKey(base, apiKey, user);
|
||||
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) {
|
||||
// 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);
|
||||
console.error('[vpn] headscale sidecar unreachable:', err);
|
||||
return ctx.json({ error: 'headscale sidecar 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) });
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user