diff --git a/docs/offscale-plugin.md b/docs/offscale-plugin.md index d126f1a4..b1e961a8 100644 --- a/docs/offscale-plugin.md +++ b/docs/offscale-plugin.md @@ -245,6 +245,24 @@ invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to O - **phone → Companion** — untouched by anything here - **web admin → Officer → sidecar** — ours to rename freely +### There are THREE components, not two + +Easy to miss, and worth stating because two of them contain the word "enroll": + +| Component | Repo | Enrolment surface | +| ---------------- | ---------------------------- | ------------------------------------------------ | +| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only | +| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer | +| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` | + +The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero +references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's +`/health`. It never calls Officer and does not use `/api/offscale/*` either. + +**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at +`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put +in the invite (`https:///officer-api`). + **Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches `/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare `POST /_officer/enroll` handler below it is dead. diff --git a/src/servers/api/vpn/router.ts b/src/servers/api/vpn/router.ts deleted file mode 100644 index f7b2540f..00000000 --- a/src/servers/api/vpn/router.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { createRouter } from '../../create-router'; -import { getHeadscaleServerUrl } from '../headscale/router'; - -// 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. -// -// 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 } -// -// 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 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); - } - - return new Response(upstream.body, { status: upstream.status, headers: new Headers(upstream.headers) }); -}); diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index b33142da..1f7b07bd 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -236,16 +236,17 @@ export const CAPABILITIES: Capability[] = [ api: [], routes: ['/invoices'], }, - { - key: 'vpn', - label: 'VPN', - description: 'Enrol your own devices on the tailnet', - kind: 'app', - api: ['/vpn'], - // Minting a pre-auth key for your own device is the entire point of the capability, and the key is - // bound to the caller. Administering the tailnet is `headscale`, which is admin-only. - personal: ['/'], - }, + // `vpn` (POST /api/vpn/enroll) was here until 2026-08-14 — the one member-grantable piece of headscale, + // minting a pre-auth key bound to the caller. Deleted because it had no caller anywhere: the standalone + // OffScale app gates it on `embedded`, which it never sets, and it never will — the app is permanently + // independent of the platform, since the thing that gets you to the platform cannot itself need it. + // + // Device enrolment did not go away, it moved out. A phone claims an invite from the Companion at + // `${invite.base}/api/v1/enroll/claim`, which is a different component entirely and does not involve + // Officer. Confirmed against the mobile monorepo and the companion repo before removal. + // + // What this does cost: `headscale` is `admin`, so with `vpn` gone no member-grantable headscale surface + // remains. Reintroduce one here if members ever need to enrol their own devices through Officer. // Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is // the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`, // `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 720935a9..4776b1da 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -30,7 +30,6 @@ import { headscaleRouter } from './api/headscale/router'; // import { jellyfinRouter } from './api/jellyfin/router'; // import { photosRouter } from './api/photos/router'; // import { walletRouter } from './api/wallet/router'; -import { vpnRouter } from './api/vpn/router'; import { terminalRouter } from './api/terminal/sidecar-server'; // import { caldavRouter } from './api/dav/sidecar-server'; // import { memosRouter } from './api/memos/router'; @@ -214,7 +213,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType // ['/jellyfin', jellyfinRouter], // plugin — switched off 2026-08-13 // ['/photos', photosRouter], // plugin — switched off 2026-08-13 // ['/wallet', walletRouter], // plugin — switched off 2026-08-13 - ['/vpn', vpnRouter], ['/system-monitor', systemMonitorRouter], ['/activity', activityRouter], ['/dock', dockRouter], diff --git a/src/servers/sidecar/headscale/enroll.ts b/src/servers/sidecar/headscale/enroll.ts index d0547ca1..945fc540 100644 --- a/src/servers/sidecar/headscale/enroll.ts +++ b/src/servers/sidecar/headscale/enroll.ts @@ -9,7 +9,8 @@ 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` read HEADSCALE_URL, HEADSCALE_API_KEY +// 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 diff --git a/src/servers/sidecar/headscale/index.ts b/src/servers/sidecar/headscale/index.ts index c2b6cc00..4ad2e28b 100644 --- a/src/servers/sidecar/headscale/index.ts +++ b/src/servers/sidecar/headscale/index.ts @@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs'; // 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. +// has more than one user. +// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll, +// which is deleted. Kept because it is the handler a route under +// /api/offscale would reuse, and because `/enroll/invites` — which +// IS live — dispatches through the same function. // anything else 404 // // There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly @@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs'; // — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here. // ───────────────────────────────────────────────────────────────────────────────────────────────── - /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });