diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 11a9fac4..3c743314 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -4,7 +4,8 @@ import { IS_DEV_BUILD } from '../build-env'; import { verify } from '../jwt'; import { isSuperAdmin } from '../super-admin'; -const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN, OFFICER_VAULT_ORIGIN } = process.env; +const { PUBLIC_URL, OFFICER_APP_ORIGIN, MUSIC_APP_ORIGIN, OFFICER_VAULT_ORIGIN, OFFICER_TAIL_ORIGIN } = + process.env; // The allowed production web origin comes from PUBLIC_URL in .env (e.g. https://officer.pastilhas.dev), // not a hardcoded domain. @@ -37,6 +38,10 @@ const APP_ORIGINS: string[] = [ // the platform AND reach the Vaultwarden reverse-proxy; ORIGIN_RULES below restricts it to // /api/auth + /api/vault. Vault access uses its own Bitwarden bearer token (not a platform account). OFFICER_VAULT_ORIGIN, + // Standalone OffTail app (the in-app Tailscale) — its own custom-scheme origin. It signs in and enrolls + // with Headscale; ORIGIN_RULES below restricts it to /api/auth + /api/vpn. The OffTail tile embedded in + // the main Officer app needs nothing here — it reuses OFFICER_APP_ORIGIN. + OFFICER_TAIL_ORIGIN, ].filter((o): o is string => Boolean(o)); // The only path prefixes a non-owner account (and the music app) may reach. @@ -62,6 +67,9 @@ if (MUSIC_APP_ORIGIN) ORIGIN_RULES[MUSIC_APP_ORIGIN] = { paths: NON_OWNER_PATHS // else. No superAdminOnly: the owner signs in here, and vault calls carry Bitwarden tokens (not platform // accounts), so the account backstop never applies to them (verify() → null → passes). if (OFFICER_VAULT_ORIGIN) ORIGIN_RULES[OFFICER_VAULT_ORIGIN] = { paths: ['/api/auth', '/api/vault'] }; +// OffTail signs in (/api/auth) and mints its Headscale pre-auth key (/api/vpn) — nothing else. The VPN +// itself never comes through /api, so this pair is the app's entire platform surface. +if (OFFICER_TAIL_ORIGIN) ORIGIN_RULES[OFFICER_TAIL_ORIGIN] = { paths: ['/api/auth', '/api/vpn'] }; // True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login). export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean { diff --git a/src/servers/api/vpn/router.ts b/src/servers/api/vpn/router.ts new file mode 100644 index 00000000..a3b04535 --- /dev/null +++ b/src/servers/api/vpn/router.ts @@ -0,0 +1,109 @@ +import { createRouter } from '../../create-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. +// +// 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 { + 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 { + 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), + }); +} + +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. +vpnRouter.post('/enroll', async (ctx) => { + const { base, apiKey } = config(); + if (!base || !apiKey) { + return ctx.json({ error: 'VPN not configured' }, 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]; + + 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); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 507e4c41..45c38cc9 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -22,6 +22,7 @@ import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; import { slskdRouter } from './api/slskd/router'; +import { vpnRouter } from './api/vpn/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; import './api/music/sidecar-server'; // side-effect: capture the officer-music audio server port @@ -105,6 +106,7 @@ protectedRouter.route('/task-logs', taskLogsRouter); protectedRouter.route('/file-browser', fileBrowserRouter); protectedRouter.route('/music', musicRouter); protectedRouter.route('/slskd', slskdRouter); +protectedRouter.route('/vpn', vpnRouter); protectedRouter.route('/system-monitor', systemMonitorRouter); protectedRouter.route('/activity', activityRouter); protectedRouter.route('/dev-server', devServerRouter);