add POST /api/vpn/enroll for OffTail
The in-app Tailscale needs one thing from the platform: a way to turn an authenticated
Officer session into a Headscale pre-auth key, so the phone registers itself instead of
someone pasting a key by hand. The VPN's control and data planes talk directly to
Headscale — never through /api — so this is not a proxy and should not become one.
Headscale itself runs on a separate host, managed manually. The platform consumes
HEADSCALE_URL (also returned as controlUrl) and HEADSCALE_API_KEY, both from the host
env. Neither is set yet, which is why an unconfigured instance answers 503 rather than
crashing — Headscale is being stood up in parallel.
The response is `{ controlUrl, authKey }` exactly, because enrollVpn() in
@officer/core/officer-net.ts reads those two fields; changing the shape means changing the
app.
Two things the spec's sketch does not do:
- The `user` field changed meaning across Headscale versions — a name on <=v0.22, a
numeric id on v0.23+ — and we cannot see which one is running from here. So it resolves
the id via /api/v1/user and tries that first, falling back to the name. Whichever the
live server accepts wins, and neither version needs a config flag.
- Upstream calls carry a 10s timeout, and upstream error bodies are logged but never
returned to the client: that is an admin API and its errors are descriptive.
The standalone app's origin follows the platform's own convention rather than the spec's
literal: every other app origin is an env var with a scope rule, so this one is
OFFICER_TAIL_ORIGIN (set in .env on this host), restricted to /api/auth + /api/vpn the way
OffVault is restricted to /api/auth + /api/vault. The OffTail tile embedded in the main
Officer app needs nothing — it reuses OFFICER_APP_ORIGIN.
Not implemented: the optional GET/DELETE /api/vpn/devices. They are not needed for a first
connection and are better written against a running Headscale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<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),
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user