headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
104 lines
4.9 KiB
TypeScript
104 lines
4.9 KiB
TypeScript
import type { OfficerContext } from './routes';
|
|
import type { OfficerUser } from './normalize';
|
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
|
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 });
|
|
}
|