diff --git a/OFFSCALE_API.md b/OFFSCALE_API.md new file mode 100644 index 0000000..ab8bd1f --- /dev/null +++ b/OFFSCALE_API.md @@ -0,0 +1,133 @@ +# The OffScale HTTP contract + +What `officer-offscale` serves, and what the platform forwards to it. + +OffScale is Officer's tooling layer over Headscale. It adds nothing destructive and deviates from +nothing Headscale does — it composes, normalises and gates. The naming follows that split exactly: +**everything Officer owns is `offscale`; the server being managed is a Headscale server and is still +called one.** So the sidecar registers as `offscale`, mounts at `/api/offscale`, and stores its rows in +`offscale_servers` — while an error saying `headscale unreachable` means precisely what it says. + +## How a request gets here + +``` +browser ──▶ /api/offscale/_officer/nodes the platform: auth + permission gate only + ──▶ createSidecarProxy strips the prefix ../api/router.ts, derived from mountPrefix() + ──▶ 127.0.0.1:/_officer/nodes this sidecar + ──▶ https:///api/v1/node the Headscale admin API, with the stored key +``` + +The platform holds **no Headscale credential** and does not know any Headscale URL. It knows a +permission key and a port. That is the whole of its involvement. + +The prefix is derived from `mountPrefix()`, never written as a literal — the proxy strips +`prefix.length` characters, so a hardcoded `/api/offscale` would forward the wrong subpath the moment +this plugin were published by anyone but `officerdev` (it would mount at `/api/p//offscale`). + +## Many servers, one active + +Officer manages **many** Headscale servers, not one. Each is registered with a URL and an API key +generated on that server; one is active at a time. + +Configuration therefore lives in Postgres (`offscale_servers`, keys encrypted at rest) and **not** in +environment variables. This sidecar deliberately reads neither `OFFSCALE_URL` nor `OFFSCALE_API_KEY`, so +a registered server can never be silently shadowed by host env. + +Every route below `/_officer/servers` acts on the **active** server and answers `409` when none is +selected. + +## Routes + +### Liveness + +| Method | Path | Notes | +| ------ | ---------- | ----------------------------------------------------------------------------------------------- | +| `GET` | `/_health` | This sidecar only. Per-server reachability is a different question — see `/servers/:id/health`. | + +### Server registry + +| Method | Path | Notes | +| -------- | -------------------------------- | ------------------------------------------------------------------------- | +| `GET` | `/_officer/servers` | Registered servers. **Never** includes API keys. | +| `POST` | `/_officer/servers` | `{name?, url, apiKey}` — validated against the server before it is saved. | +| `PATCH` | `/_officer/servers/:id` | Re-validated when `url` or `apiKey` changes. | +| `DELETE` | `/_officer/servers/:id` | Promotes the newest survivor if the deleted one was active. | +| `POST` | `/_officer/servers/:id/activate` | Switch the active server. | +| `GET` | `/_officer/servers/:id/health` | Reachable? Version? Is the key still accepted? | + +### Nodes + +| Method | Path | Notes | +| -------- | ---------------------------- | ---------------------------------------------------------------------------------------------------------- | +| `GET` | `/_officer/nodes` | Normalised. `?user=` filters. | +| `GET` | `/_officer/nodes/:id` | | +| `DELETE` | `/_officer/nodes/:id` | Removes it from the tailnet. | +| `POST` | `/_officer/nodes/:id/rename` | `{name}` | +| `POST` | `/_officer/nodes/:id/tags` | `{tags}` — a `tag:` prefix is added when missing. | +| `POST` | `/_officer/nodes/:id/routes` | `{routes}` for the whole set, or `{route, approved}` for a single toggle (read-modify-write happens here). | +| `POST` | `/_officer/nodes/:id/expire` | Expires its key, forcing re-auth. **Not** a delete. | + +### Users + +| Method | Path | Notes | +| -------- | ---------------------------- | ------------------------------------------------------ | +| `GET` | `/_officer/users` | Each with a node count the admin API does not provide. | +| `POST` | `/_officer/users` | `{name, displayName?, email?}` | +| `POST` | `/_officer/users/:id/rename` | `{name}` | +| `DELETE` | `/_officer/users/:id` | Refused upstream while the user still owns nodes. | + +### Pre-auth keys + +| Method | Path | Notes | +| -------- | --------------------------- | -------------------------------------------------------------------------------------------------------------- | +| `GET` | `/_officer/keys` | Secrets masked, with a derived status. | +| `POST` | `/_officer/keys` | `{userId, reusable?, ephemeral?, expirationDays?, aclTags?}` — **the only response carrying the real secret.** | +| `POST` | `/_officer/keys/:id/expire` | Expire without deleting. | +| `DELETE` | `/_officer/keys/:id` | Delete outright. | + +### Enrollment + +| Method | Path | Notes | +| ------ | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/_officer/enroll` | `{userId?}` → `{controlUrl, authKey}`, a single-use 10-minute key for a joining device. `userId` is required only when the server has more than one user. | + +`/_officer/enroll` has had **no direct caller since 2026-08-14**, when `/api/vpn/enroll` was deleted. It +is 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 `/api/v1/*` passthrough + +Headscale's REST shape changed repeatedly below 0.29 and its ids are uint64-as-JSON-string. Proxying raw +would push all of that into the browser — the mistake the Soulseek panels made with 37 raw upstream +calls. Every quirk is absorbed here instead. + +## The three protobuf leaks, and where they are contained + +Headscale's REST layer is a gRPC gateway marshalling protobuf. It leaks in exactly three ways, all +handled in `sidecar/normalize.ts` (and pinned by `sidecar/normalize.test.ts`): + +1. **Every uint64 is a JSON string.** Ids stay strings end to end. Never `Number()` them — it breaks + silently above 2^53, and Headscale's ids are database-assigned, not small by contract. +2. **Unset timestamps are the protobuf zero value**, serialised as `0001-01-01T00:00:00Z` rather than + omitted. Rendered naively that reads as the year 1; it means "never", so it becomes `null`. +3. **`EmitUnpopulated`** means absent repeated fields arrive as `[]` and absent messages as `null`. There + is no way to distinguish "unset" from "empty", so every accessor tolerates both. + +## The version floor + +Officer targets **Headscale >= 0.29** and nothing older, checked once at registration by an +unauthenticated `GET /version` on the server itself. Below that floor the admin API changed shape +repeatedly — identifiers went name→numeric at 0.26, `/api/v1/routes` was removed at 0.26 in favour of +node-owned route sets, `forcedTags`/`validTags` collapsed into `tags` at 0.28, pre-auth key expiry became +id-based at 0.28, and `MoveNode` was removed at 0.28. Supporting 0.23–0.28 would mean carrying several +incompatible data models; refusing them at registration costs one probe. + +A version that will not parse — a self-built image reporting the literal `dev` — is reported as +`supported: 'unknown'` rather than refused. Locking out legitimately self-built deployments would be the +worse failure. `sidecar/version.test.ts` pins that case specifically. + +Do not confuse `GET /version` with the two similarly-named endpoints: `GET /health` (root, +unauthenticated, `{status:'pass'}`) and `GET /api/v1/health` (authenticated, +`{databaseConnectivity:true}`) carry no version at all. diff --git a/api/router.ts b/api/router.ts index 3d3a09a..ffb0369 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,18 +1,35 @@ import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; -// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge: -// this file must never grow app logic. +// /api/offscale/* — auth, then forward to officer-offscale. No routes of its own and no Headscale +// knowledge: this file must never grow app logic. // // The sidecar exposes only Officer-owned routes under `/_officer/` — Headscale's REST shape differs // across releases, and version handling belongs in the sidecar. It holds the admin API key; the platform -// does not know Headscale's URL. - +// does not know any Headscale URL. +// +// ── The prefix is DERIVED, not written ── +// +// It was the literal '/api/offscale'. That is wrong in a way that only shows up for someone else's +// plugin: the proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded +// '/api/offscale' (13 chars) is correct only because `mountPrefix` happens to return `/offscale` for a +// first-party publisher. The same plugin published by anyone else mounts at +// `/api/p//offscale` and would forward `/alice/offscale/nodes` to a sidecar expecting +// `/nodes`. +// +// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A +// literal here is that rule being broken quietly, which is how first-party and third-party become two +// systems with only one of them tested. Carried over from music, which hit this first. +// +// `appName` is a literal because this file cannot see its own directory name — the platform imports +// `router.ts` and reads `router`, so there is nowhere to inject it. Same known gap music records. const proxy = createSidecarProxy({ - name: 'headscale', - prefix: '/api/offscale', + name: 'offscale', + prefix: `/api${mountPrefix({ appName: 'offscale', manifest })}`, }); export const router = proxy.router; /** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ -export const getHeadscaleServerUrl = proxy.getHttpUrl; +export const getOffscaleServerUrl = proxy.getHttpUrl; diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..81a706b Binary files /dev/null and b/assets/icon.png differ diff --git a/db/queries.ts b/db/queries.ts index ba3b155..5213a2f 100644 --- a/db/queries.ts +++ b/db/queries.ts @@ -1,20 +1,20 @@ import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; -import { headscaleServers } from './schema'; +import { offscaleServers } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; -// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT — +// Headscale server registry access for the officer-offscale sidecar. Callers deal in PLAINTEXT — // encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto. -// See ../crypto.ts and ../schema/headscale.ts. +// See ./schema.ts — the encryption lives in the platform, via officerdb. // // Two return types on purpose: -// HeadscaleServer — safe to serialize to the browser. Has NO api key field at all. -// HeadscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned +// OffscaleServer — safe to serialize to the browser. Has NO api key field at all. +// OffscaleServerCredentials — url + decrypted key, for the sidecar's own upstream calls. Never returned // by a route handler. // The `serverCols` projection is what enforces that: `select()` without it would leak the ciphertext column // into every list response the moment someone forgot to strip it. -export type HeadscaleServer = { +export type OffscaleServer = { id: number; name: string; url: string; @@ -25,49 +25,49 @@ export type HeadscaleServer = { createdAt: Date; }; -export type HeadscaleServerCredentials = { id: number; name: string; url: string; apiKey: string }; +export type OffscaleServerCredentials = { id: number; name: string; url: string; apiKey: string }; const serverCols = { - id: headscaleServers.id, - name: headscaleServers.name, - url: headscaleServers.url, - version: headscaleServers.version, - sshHost: headscaleServers.sshHost, - isActive: headscaleServers.isActive, - lastSeenAt: headscaleServers.lastSeenAt, - createdAt: headscaleServers.createdAt, + id: offscaleServers.id, + name: offscaleServers.name, + url: offscaleServers.url, + version: offscaleServers.version, + sshHost: offscaleServers.sshHost, + isActive: offscaleServers.isActive, + lastSeenAt: offscaleServers.lastSeenAt, + createdAt: offscaleServers.createdAt, }; /** Every server the owner has registered, active first then newest. Never includes the API key. */ -export async function listHeadscaleServers(userId: number): Promise { +export async function listOffscaleServers(userId: number): Promise { return db .select(serverCols) - .from(headscaleServers) - .where(eq(headscaleServers.userId, userId)) - .orderBy(desc(headscaleServers.isActive), desc(headscaleServers.createdAt)); + .from(offscaleServers) + .where(eq(offscaleServers.userId, userId)) + .orderBy(desc(offscaleServers.isActive), desc(offscaleServers.createdAt)); } /** The currently selected server with its key decrypted, or null when none is registered/active. */ -export async function getActiveHeadscaleCredentials(userId: number): Promise { +export async function getActiveOffscaleCredentials(userId: number): Promise { const [row] = await db .select() - .from(headscaleServers) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + .from(offscaleServers) + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true))); if (!row) return null; - return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; + return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', row.apiKey) }; } /** One server's credentials by id — for probing a specific server rather than the active one. */ -export async function getHeadscaleCredentials(userId: number, id: number): Promise { +export async function getOffscaleCredentials(userId: number, id: number): Promise { const [row] = await db .select() - .from(headscaleServers) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); + .from(offscaleServers) + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id))); if (!row) return null; - return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) }; + return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('offscale', row.apiKey) }; } -type CreateHeadscaleServerParams = { +type CreateOffscaleServerParams = { userId: number; name: string; url: string; @@ -80,22 +80,22 @@ type CreateHeadscaleServerParams = { }; /** Register a server. The key is encrypted before write; the returned row carries no key. */ -export async function createHeadscaleServer(params: CreateHeadscaleServerParams): Promise { +export async function createOffscaleServer(params: CreateOffscaleServerParams): Promise { const { userId, name, url, apiKey, version, sshHost, activate } = params; return db.transaction(async (tx) => { if (activate) { await tx - .update(headscaleServers) + .update(offscaleServers) .set({ isActive: false, updatedAt: new Date() }) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true))); } const [row] = await tx - .insert(headscaleServers) + .insert(offscaleServers) .values({ userId, name, url, - apiKey: encryptSecret('headscale', apiKey), + apiKey: encryptSecret('offscale', apiKey), version, sshHost, isActive: activate, @@ -108,39 +108,39 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams) // `sshHost: null` clears the console target; omitting the field leaves it alone. The two must stay // distinguishable, which is why this is `string | null` and not `string`. -type UpdateHeadscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null }; +type UpdateOffscaleServerParams = { name?: string; url?: string; apiKey?: string; sshHost?: string | null }; /** Edit a registration. Omitted fields are left alone; a supplied key is re-encrypted. */ -export async function updateHeadscaleServer( +export async function updateOffscaleServer( userId: number, id: number, - params: UpdateHeadscaleServerParams, -): Promise { + params: UpdateOffscaleServerParams, +): Promise { const set: Record = { updatedAt: new Date() }; if (params.name !== undefined) set.name = params.name; if (params.url !== undefined) set.url = params.url; - if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey); + if (params.apiKey !== undefined) set.apiKey = encryptSecret('offscale', params.apiKey); if (params.sshHost !== undefined) set.sshHost = params.sshHost; const [row] = await db - .update(headscaleServers) + .update(offscaleServers) .set(set) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id))) .returning(serverCols); return row ?? null; } /** Select a server. Clearing the others first keeps the one-active partial index satisfied. */ -export async function setActiveHeadscaleServer(userId: number, id: number): Promise { +export async function setActiveOffscaleServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { await tx - .update(headscaleServers) + .update(offscaleServers) .set({ isActive: false, updatedAt: new Date() }) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true))); + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.isActive, true))); const [row] = await tx - .update(headscaleServers) + .update(offscaleServers) .set({ isActive: true, updatedAt: new Date() }) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id))) .returning(serverCols); return row ?? null; }); @@ -151,26 +151,26 @@ export async function setActiveHeadscaleServer(userId: number, id: number): Prom * active server would leave the UI with servers registered but none selected, which reads as "not * configured" and is a confusing place to land. */ -export async function deleteHeadscaleServer(userId: number, id: number): Promise { +export async function deleteOffscaleServer(userId: number, id: number): Promise { return db.transaction(async (tx) => { const [deleted] = await tx - .delete(headscaleServers) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))) - .returning({ id: headscaleServers.id, wasActive: headscaleServers.isActive }); + .delete(offscaleServers) + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id))) + .returning({ id: offscaleServers.id, wasActive: offscaleServers.isActive }); if (!deleted) return false; if (deleted.wasActive) { const [next] = await tx - .select({ id: headscaleServers.id }) - .from(headscaleServers) - .where(eq(headscaleServers.userId, userId)) - .orderBy(desc(headscaleServers.createdAt)) + .select({ id: offscaleServers.id }) + .from(offscaleServers) + .where(eq(offscaleServers.userId, userId)) + .orderBy(desc(offscaleServers.createdAt)) .limit(1); if (next) { await tx - .update(headscaleServers) + .update(offscaleServers) .set({ isActive: true, updatedAt: new Date() }) - .where(eq(headscaleServers.id, next.id)); + .where(eq(offscaleServers.id, next.id)); } } return true; @@ -178,9 +178,9 @@ export async function deleteHeadscaleServer(userId: number, id: number): Promise } /** Record a successful reachability probe: the version observed and when we last reached the server. */ -export async function recordHeadscaleProbe(userId: number, id: number, version: string | null): Promise { +export async function recordOffscaleProbe(userId: number, id: number, version: string | null): Promise { await db - .update(headscaleServers) + .update(offscaleServers) .set({ version, lastSeenAt: new Date(), updatedAt: new Date() }) - .where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id))); + .where(and(eq(offscaleServers.userId, userId), eq(offscaleServers.id, id))); } diff --git a/db/schema.ts b/db/schema.ts index 08376bb..fd60a35 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -2,7 +2,7 @@ import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from import { sql } from 'drizzle-orm'; import { users } from 'officerdb/auth/schema'; -// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single +// The Headscale servers the owner manages, for the officer-offscale sidecar. Officer targets no single // Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and // toggles between them, so this is configuration the user creates at runtime rather than env vars. // @@ -12,12 +12,12 @@ import { users } from 'officerdb/auth/schema'; // at all. SECURITY_AUDIT.md L2 records plaintext credential storage as an open finding, so the plaintext // email/integrations tables are debt to avoid copying, not a precedent to follow. // -// Every table here is `headscale_`-prefixed and this file holds nothing else: when sidecars own their own -// schema it moves wholesale into src/servers/sidecar/headscale/ with no untangling. Only the -// officer-headscale sidecar reads or writes these tables. +// Every table here is `offscale_`-prefixed and this file holds nothing else: when sidecars own their own +// schema it moves wholesale into ../sidecar/ with no untangling. Only the +// officer-offscale sidecar reads or writes these tables. -export const headscaleServers = pgTable( - 'headscale_servers', +export const offscaleServers = pgTable( + 'offscale_servers', { id: serial('id').primaryKey(), userId: integer('user_id') @@ -44,11 +44,11 @@ export const headscaleServers = pgTable( }, (t) => [ // One registration per URL — re-registering the same server should be an edit, not a duplicate. - uniqueIndex('uq_headscale_servers_user_url').on(t.userId, t.url), + uniqueIndex('uq_offscale_servers_user_url').on(t.userId, t.url), // At most one active server per owner, enforced by the DB rather than by convention: a partial unique - // index over the active rows only. setActiveHeadscaleServer still clears the others in a transaction, + // index over the active rows only. setActiveOffscaleServer still clears the others in a transaction, // but a bug there fails loudly here instead of silently leaving two servers active. - uniqueIndex('uq_headscale_servers_one_active') + uniqueIndex('uq_offscale_servers_one_active') .on(t.userId) .where(sql`${t.isActive}`), ], diff --git a/manifest.ts b/manifest.ts index 2a01d9a..b80a94f 100644 --- a/manifest.ts +++ b/manifest.ts @@ -25,7 +25,7 @@ export const manifest: PluginManifest = { // One permission gating the whole surface, grantable per role at read or write like every other. // // `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries - // still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own + // still scope by the caller (`listOffscaleServers(userId)`), so a granted member would see their own // empty server list rather than the owner's, and could register a Headscale of their own. The model in // ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it. // That is a change inside these queries, not a flag on the manifest. diff --git a/sidecar/active.ts b/sidecar/active.ts index d41e781..a1e1706 100644 --- a/sidecar/active.ts +++ b/sidecar/active.ts @@ -1,5 +1,5 @@ -import { getActiveHeadscaleCredentials } from '../db/queries'; -import { createClient, type HeadscaleClient } from './client'; +import { getActiveOffscaleCredentials } from '../db/queries'; +import { createClient, type OffscaleClient } from './client'; // Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That // choice lives in Postgres (one row, enforced by a partial unique index), not in a request parameter, so @@ -11,8 +11,8 @@ import { createClient, type HeadscaleClient } from './client'; * 409 rather than 404: the route exists and the request was well-formed, the account just has no server * selected yet. The UI maps it to "pick a server", which is a different message from "that node is gone". */ -export async function activeClient(userId: number): Promise { - const creds = await getActiveHeadscaleCredentials(userId); +export async function activeClient(userId: number): Promise { + const creds = await getActiveOffscaleCredentials(userId); if (!creds) { return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); } diff --git a/sidecar/client.ts b/sidecar/client.ts index 5e51fb3..1cb5edc 100644 --- a/sidecar/client.ts +++ b/sidecar/client.ts @@ -1,4 +1,4 @@ -import type { HeadscaleServerCredentials } from '../db/queries'; +import type { OffscaleServerCredentials } from '../db/queries'; // The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the // wire-level quirks are handled once: @@ -18,7 +18,7 @@ import type { HeadscaleServerCredentials } from '../db/queries'; const DEFAULT_TIMEOUT_MS = 15_000; /** An upstream failure carrying the HTTP status to surface, mapped to a response at the route boundary. */ -export class HeadscaleError extends Error { +export class OffscaleError extends Error { constructor( readonly status: number, message: string, @@ -33,7 +33,7 @@ export class HeadscaleError extends Error { readonly detail?: string, ) { super(message); - this.name = 'HeadscaleError'; + this.name = 'OffscaleError'; } } @@ -55,14 +55,14 @@ async function errorMessage(res: Response): Promise { return text.slice(0, 300); } -export type HeadscaleClient = { +export type OffscaleClient = { readonly serverId: number; - /** Call an admin API path (e.g. `/api/v1/node`). Throws HeadscaleError on any non-2xx. */ + /** Call an admin API path (e.g. `/api/v1/node`). Throws OffscaleError on any non-2xx. */ call: (path: string, opts?: CallOptions) => Promise; }; /** Build a client bound to one registered server's credentials. */ -export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient { +export function createClient(creds: OffscaleServerCredentials): OffscaleClient { async function call(path: string, opts: CallOptions = {}): Promise { const { method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS } = opts; @@ -82,13 +82,13 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient }); } catch (err) { const timedOut = err instanceof Error && err.name === 'TimeoutError'; - throw new HeadscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable'); + throw new OffscaleError(504, timedOut ? 'headscale timed out' : 'headscale unreachable'); } if (res.status === 401 || res.status === 403) { // The stored key is wrong, expired, or was revoked on the server. Actionable, and distinct from an // Officer-side auth problem — the UI should point the owner at re-entering the key. - throw new HeadscaleError(502, 'headscale rejected the stored API key'); + throw new OffscaleError(502, 'headscale rejected the stored API key'); } if (!res.ok) { @@ -96,7 +96,7 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient console.error(`[headscale] ${method} ${path} -> ${res.status}: ${message}`); // 4xx from the admin API is usually a bad argument and safe to relay; 5xx is not, so it's generalized. const serverSide = res.status >= 500; - throw new HeadscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message); + throw new OffscaleError(serverSide ? 502 : res.status, serverSide ? 'headscale error' : message, message); } // 204 and empty 200s are legitimate: several mutations return `{}` or nothing at all. @@ -105,7 +105,7 @@ export function createClient(creds: HeadscaleServerCredentials): HeadscaleClient try { return JSON.parse(text) as T; } catch { - throw new HeadscaleError(502, 'headscale returned a non-JSON body'); + throw new OffscaleError(502, 'headscale returned a non-JSON body'); } } diff --git a/sidecar/companion.ts b/sidecar/companion.ts index c44a21c..30ee41f 100644 --- a/sidecar/companion.ts +++ b/sidecar/companion.ts @@ -1,9 +1,9 @@ -import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries'; +import { getActiveOffscaleCredentials, type OffscaleServerCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes'; // The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the // admin API structurally cannot: is the container up, what do its logs say, and start/stop/restart it. -// Contract: COMMS/HEADSCALE_COMPANION_API.md. +// Contract: COMMS/OFFSCALE_COMPANION_API.md. // // Three facts shape everything here. // @@ -38,7 +38,7 @@ type CompanionCall = { path: string; method?: string; body?: unknown; timeoutMs? * "unreachable" is information rather than a failure. */ export async function callCompanion( - creds: HeadscaleServerCredentials, + creds: OffscaleServerCredentials, { path, method = 'GET', body, timeoutMs = DEFAULT_TIMEOUT_MS, signal }: CompanionCall, ): Promise { let res: Response; @@ -85,8 +85,8 @@ export async function readBody(res: Response): Promise | } /** The active server's credentials, or a 409 the UI already knows how to render. */ -export async function activeCreds(userId: number): Promise { - const creds = await getActiveHeadscaleCredentials(userId); +export async function activeCreds(userId: number): Promise { + const creds = await getActiveOffscaleCredentials(userId); if (!creds) { return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); } @@ -94,7 +94,7 @@ export async function activeCreds(userId: number): Promise { +async function health(creds: OffscaleServerCredentials): Promise { const res = await callCompanion(creds, { path: '/health' }); if (typeof res === 'string') return Response.json(unavailable(res)); @@ -106,7 +106,7 @@ async function health(creds: HeadscaleServerCredentials): Promise { } /** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */ -async function logs(creds: HeadscaleServerCredentials, url: URL): Promise { +async function logs(creds: OffscaleServerCredentials, url: URL): Promise { const tail = Number(url.searchParams.get('tail') ?? 200); if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); @@ -127,7 +127,7 @@ async function logs(creds: HeadscaleServerCredentials, url: URL): Promise { +async function logStream(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise { const tail = Number(ctx.url.searchParams.get('tail') ?? 200); if (!Number.isInteger(tail) || tail < 1 || tail > 2000) return badRequest('tail must be 1–2000'); @@ -165,7 +165,7 @@ const ACTIONS = new Set(['restart', 'stop', 'start']); * Every one of these drops every node's control-plane connection for the duration. That is the intended * "kill it" behaviour and the reason the UI asks twice; it is not something to retry automatically. */ -async function action(creds: HeadscaleServerCredentials, name: string): Promise { +async function action(creds: OffscaleServerCredentials, name: string): Promise { // 60s: docker restart on a busy container is not fast, and a timeout here reads as "did it work?" — the // one question this feature exists to answer. const res = await callCompanion(creds, { path: `/${name}`, method: 'POST', timeoutMs: 60_000 }); diff --git a/sidecar/enroll.ts b/sidecar/enroll.ts index eed8195..ea09027 100644 --- a/sidecar/enroll.ts +++ b/sidecar/enroll.ts @@ -1,8 +1,8 @@ import type { OfficerContext } from './routes'; import type { OfficerUser } from './normalize'; -import { getActiveHeadscaleCredentials } from '../db/queries'; +import { getActiveOffscaleCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, readJson } from './routes'; -import { createClient, type HeadscaleClient } from './client'; +import { createClient, type OffscaleClient } from './client'; import { arrayField, toUser } from './normalize'; import { handleInvitesRoute } from './invites'; @@ -10,8 +10,8 @@ import { handleInvitesRoute } from './invites'; // 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, +// OFFSCALE_URL, OFFSCALE_API_KEY +// and OFFSCALE_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, @@ -32,7 +32,7 @@ const KEY_TTL_MS = 10 * 60_000; * 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 { +async function resolveOwner(client: OffscaleClient, ctx: OfficerContext): Promise { const body = await readJson(ctx.req); const requested = typeof body?.userId === 'string' ? body.userId.trim() : ''; @@ -75,7 +75,7 @@ export async function handleEnrollRoute(ctx: OfficerContext, segments: string[]) 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); + const creds = await getActiveOffscaleCredentials(ctx.userId); if (!creds) { return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 }); } diff --git a/sidecar/index.ts b/sidecar/index.ts index ebb5ff5..2373e4f 100644 --- a/sidecar/index.ts +++ b/sidecar/index.ts @@ -4,19 +4,20 @@ import { handleOfficerRoute } from './routes'; import { MIN_VERSION_LABEL } from './version'; import { API_URL } from '@@/officer-url.mjs'; -// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and +// The officer-offscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and // their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform -// API is a thin auth-gated forwarder (src/servers/api/headscale/router.ts) holding no Headscale credentials. +// API is a thin auth-gated forwarder (../api/router.ts) holding no Headscale credentials. // // Officer manages MANY Headscale servers, not one. The owner registers each with a URL and an API key // 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. +// Postgres (offscale_servers, keys encrypted at rest), NOT in env vars — this sidecar deliberately reads +// neither OFFSCALE_URL nor OFFSCALE_API_KEY, so a registered server can never be shadowed by host env. // 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. +// OFFSCALE_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. +// HTTP CONTRACT — the platform strips its /api/offscale mount prefix before forwarding. +// Published in full as ../OFFSCALE_API.md; keep the two in step. // // GET /_health ours. Sidecar liveness only. Per-server reachability is a // different question and needs an owner, so it lives below. @@ -79,7 +80,7 @@ const server = Bun.serve({ // Liveness, not upstream health: with many registered servers there is no single upstream to probe, and // choosing one would need an authenticated owner. See /_officer/servers/:id/health for that. if (url.pathname === '/_health') { - return Response.json({ ok: true, minHeadscaleVersion: MIN_VERSION_LABEL }); + return Response.json({ ok: true, minOffscaleVersion: MIN_VERSION_LABEL }); } if (url.pathname.startsWith('/_officer/')) { @@ -87,7 +88,7 @@ const server = Bun.serve({ const res = await handleOfficerRoute(req, url); return res ?? new Response('not found', { status: 404 }); } catch (err) { - console.error(`[headscale] ${req.method} ${url.pathname} failed`, err); + console.error(`[offscale] ${req.method} ${url.pathname} failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } @@ -96,7 +97,7 @@ const server = Bun.serve({ }, }); -console.log(`[headscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`); +console.log(`[offscale] listening on 127.0.0.1:${port} (Headscale >=${MIN_VERSION_LABEL})`); // ── Command handlers ── @@ -120,22 +121,22 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, - name: 'headscale', - handles: ['headscale'], + name: 'offscale', + handles: ['offscale'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { - // Tell the API where we're listening, so it can forward /api/headscale/* here. - connection.send({ type: 'headscale:server', port }); - console.log(`[headscale] reported port ${port} to API`); + // Tell the API where we're listening, so it can forward /api/offscale/* here. + connection.send({ type: 'offscale:server', port }); + console.log(`[offscale] reported port ${port} to API`); }, }); // ── Graceful shutdown ── function shutdown(signal: string) { - console.log(`[headscale] ${signal} received, shutting down...`); + console.log(`[offscale] ${signal} received, shutting down...`); try { server.stop(true); } catch { diff --git a/sidecar/invites.ts b/sidecar/invites.ts index 37fed1d..11fd00c 100644 --- a/sidecar/invites.ts +++ b/sidecar/invites.ts @@ -1,4 +1,4 @@ -import type { HeadscaleServerCredentials } from '../db/queries'; +import type { OffscaleServerCredentials } from '../db/queries'; import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; import { activeCreds, callCompanion, readBody, unavailable } from './companion'; @@ -122,7 +122,7 @@ function withNameHint(url: unknown, name: string | undefined): unknown { * dropped here rather than passed on: it carries the same claim token in its fragment, and a second copy of * a single-use credential in the browser is a second chance to leak it. Nothing on our side opens it. */ -async function create(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise { +async function create(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise { const input = parseCreate(await readJson(ctx.req)); if (input instanceof Response) return input; @@ -153,13 +153,13 @@ function pickInvites(body: Record): unknown[] { } /** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */ -async function list(creds: HeadscaleServerCredentials): Promise { +async function list(creds: OffscaleServerCredentials): Promise { const res = await callCompanion(creds, { path: INVITES_PATH }); return relay(res, (body) => ({ available: true, invites: pickInvites(body) })); } /** `DELETE /_officer/enroll/invites/:id` — revoke an unclaimed invite. A no-op on a claimed one. */ -async function revoke(creds: HeadscaleServerCredentials, id: string): Promise { +async function revoke(creds: OffscaleServerCredentials, id: string): Promise { const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' }); return relay(res, (body) => ({ available: true, ...body })); } diff --git a/sidecar/nodes.ts b/sidecar/nodes.ts index dc34a2c..838724f 100644 --- a/sidecar/nodes.ts +++ b/sidecar/nodes.ts @@ -1,6 +1,6 @@ import type { OfficerContext } from './routes'; import { badRequest, notFound, methodNotAllowed, readJson } from './routes'; -import type { HeadscaleClient } from './client'; +import type { OffscaleClient } from './client'; import { activeClient } from './active'; import { toNode, arrayField, type OfficerNode } from './normalize'; @@ -32,7 +32,7 @@ async function listNodes(ctx: OfficerContext): Promise { } /** Re-read one node after a mutation. Headscale's mutation responses are inconsistent; a GET never is. */ -async function getNode(client: HeadscaleClient, id: string): Promise { +async function getNode(client: OffscaleClient, id: string): Promise { const body = await client.call<{ node?: Record }>(`/api/v1/node/${encodeURIComponent(id)}`); return body.node ? toNode(body.node) : null; } diff --git a/sidecar/normalize.test.ts b/sidecar/normalize.test.ts new file mode 100644 index 0000000..d459b94 --- /dev/null +++ b/sidecar/normalize.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, test } from 'bun:test'; +import { arrayField, isoOrNull, toNode, toUser } from './normalize'; + +// Headscale's REST layer is a gRPC gateway marshalling protobuf, and it leaks in three specific ways. +// These transforms are where that leak is contained, so they are the file most likely to be quietly wrong +// after an upstream release — and they had no tests at all. + +describe('isoOrNull', () => { + test("the protobuf zero timestamp means 'never', not the year 1", () => { + // The leak that matters most: unset timestamps arrive as this literal rather than being omitted. + // Rendered naively a node's expiry reads as year 1, which looks like an expired node rather than one + // that never expires. + expect(isoOrNull('0001-01-01T00:00:00Z')).toBeNull(); + }); + + test('pre-1971 is treated as the sentinel too, for builds that emit a different zero', () => { + expect(isoOrNull('1970-01-01T00:00:00Z')).toBeNull(); + expect(isoOrNull('1960-06-01T00:00:00Z')).toBeNull(); + }); + + test('a real timestamp survives, normalised to ISO', () => { + expect(isoOrNull('2026-08-15T10:30:00Z')).toBe('2026-08-15T10:30:00.000Z'); + }); + + test('anything that is not a usable string is null rather than a crash', () => { + // EmitUnpopulated means absent messages arrive as null, so these are normal input, not corruption. + for (const bad of [null, undefined, '', 'not a date', 42, {}, []]) { + expect(isoOrNull(bad)).toBeNull(); + } + }); +}); + +describe('toNode', () => { + test('ids stay STRINGS — never numbers', () => { + // Headscale ids are uint64 serialized as JSON strings. Number() breaks silently above 2^53, and the + // ids are database-assigned rather than small by contract, so this is a real ceiling and not theory. + const node = toNode({ id: '9007199254740993', name: 'a' }); + expect(node.id).toBe('9007199254740993'); + expect(typeof node.id).toBe('string'); + }); + + test('givenName wins over name, falling back when it is unset', () => { + expect(toNode({ id: '1', givenName: 'laptop', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop'); + expect(toNode({ id: '1', name: 'laptop.tail1234.ts.net' }).name).toBe('laptop.tail1234.ts.net'); + }); + + test('an exit node is recognised from either default route', () => { + expect(toNode({ id: '1', availableRoutes: ['0.0.0.0/0'] }).isExitNode).toBe(true); + expect(toNode({ id: '1', availableRoutes: ['::/0'] }).isExitNode).toBe(true); + expect(toNode({ id: '1', availableRoutes: ['10.0.0.0/24'] }).isExitNode).toBe(false); + expect(toNode({ id: '1' }).isExitNode).toBe(false); + }); + + test('an unknown register method degrades rather than leaking the enum', () => { + expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_AUTH_KEY' }).registerMethod).toBe('authkey'); + // A method added in a future release must not put REGISTER_METHOD_SOMETHING_NEW in the UI. + expect(toNode({ id: '1', registerMethod: 'REGISTER_METHOD_FUTURE' }).registerMethod).toBe('unknown'); + expect(toNode({ id: '1' }).registerMethod).toBe('unknown'); + }); + + test('online is strictly true, so a missing field is offline rather than truthy', () => { + expect(toNode({ id: '1', online: true }).online).toBe(true); + expect(toNode({ id: '1', online: 'true' }).online).toBe(false); + expect(toNode({ id: '1' }).online).toBe(false); + }); + + test('a node with nothing but an id normalises instead of throwing', () => { + // EmitUnpopulated guarantees absent repeated fields arrive as [] and absent messages as null, and + // there is no way to tell "unset" from "empty" — so every accessor has to tolerate both. + const node = toNode({ id: '7' }); + expect(node.ipAddresses).toEqual([]); + expect(node.tags).toEqual([]); + expect(node.user).toBeNull(); + expect(node.lastSeen).toBeNull(); + }); + + test('non-string entries are dropped from string arrays rather than rendered', () => { + expect(toNode({ id: '1', ipAddresses: ['100.64.0.1', null, 42, '::1'] }).ipAddresses).toEqual([ + '100.64.0.1', + '::1', + ]); + }); +}); + +describe('toUser', () => { + test('null and undefined pass through as null', () => { + expect(toUser(null)).toBeNull(); + expect(toUser(undefined)).toBeNull(); + }); +}); + +describe('arrayField', () => { + test('pulls the named array, keeping only objects', () => { + expect(arrayField({ nodes: [{ id: '1' }, null, 'x', { id: '2' }] }, 'nodes')).toEqual([{ id: '1' }, { id: '2' }]); + }); + + test('a missing field or a non-array body is an empty list, not a throw', () => { + expect(arrayField({}, 'nodes')).toEqual([]); + expect(arrayField(null, 'nodes')).toEqual([]); + expect(arrayField({ nodes: 'not-an-array' }, 'nodes')).toEqual([]); + }); +}); diff --git a/sidecar/policy.ts b/sidecar/policy.ts index 8a5a3bd..db79588 100644 --- a/sidecar/policy.ts +++ b/sidecar/policy.ts @@ -1,6 +1,6 @@ import type { OfficerContext } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes'; -import { HeadscaleError } from './client'; +import { OffscaleError } from './client'; import { activeClient } from './active'; import { handlePolicyAssistRoute } from './assist'; @@ -23,7 +23,7 @@ import { handlePolicyAssistRoute } from './assist'; // be the one shown. So the text goes up untouched and Headscale's verdict comes back verbatim. // // 3. **Both of those arrive as HTTP 500** from grpc-gateway, which the client layer normally generalizes -// to "headscale error". `HeadscaleError.detail` is how the real message survives that; see client.ts. +// to "headscale error". `OffscaleError.detail` is how the real message survives that; see client.ts. /** * Does this failure mean "writing is turned off here", as opposed to "your document is wrong"? @@ -59,7 +59,7 @@ async function getPolicy(ctx: OfficerContext): Promise { const body = await client.call('/api/v1/policy'); return Response.json({ policy: asText(body.policy), updatedAt: asDate(body.updatedAt) }); } catch (err) { - if (!(err instanceof HeadscaleError)) throw err; + if (!(err instanceof OffscaleError)) throw err; const detail = err.detail ?? err.message; if (err.status === 404 || detail.toLowerCase().includes('not found')) { return Response.json({ policy: '', updatedAt: null }); @@ -91,7 +91,7 @@ async function putPolicy(ctx: OfficerContext): Promise { // never blanks the editor. return Response.json({ policy: asText(saved.policy) || body.policy, updatedAt: asDate(saved.updatedAt) }); } catch (err) { - if (!(err instanceof HeadscaleError)) throw err; + if (!(err instanceof OffscaleError)) throw err; const detail = err.detail ?? err.message; if (isWriteDisabled(detail)) { diff --git a/sidecar/routes.ts b/sidecar/routes.ts index c939de7..ad99ae4 100644 --- a/sidecar/routes.ts +++ b/sidecar/routes.ts @@ -1,4 +1,4 @@ -import { HeadscaleError } from './client'; +import { OffscaleError } from './client'; import { handleServersRoute } from './servers'; import { handleNodesRoute } from './nodes'; import { handleUsersRoute } from './users'; @@ -74,7 +74,7 @@ export async function handleOfficerRoute(req: Request, url: URL): Promise { const { req, userId } = ctx; if (req.method === 'GET') { - return Response.json({ servers: await listHeadscaleServers(userId) }); + return Response.json({ servers: await listOffscaleServers(userId) }); } if (req.method === 'POST') { @@ -94,8 +94,8 @@ async function handleCollection(ctx: OfficerContext): Promise { if (validated instanceof Response) return validated; // First registration becomes active, so the owner is never left with servers but none selected. - const existing = await listHeadscaleServers(userId); - const server = await createHeadscaleServer({ + const existing = await listOffscaleServers(userId); + const server = await createOffscaleServer({ userId, name, url, @@ -115,13 +115,13 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef if (action === 'activate') { if (req.method !== 'POST') return methodNotAllowed(); - const server = await setActiveHeadscaleServer(userId, id); + const server = await setActiveOffscaleServer(userId, id); return server ? Response.json({ server }) : notFound('no such server'); } if (action === 'health') { if (req.method !== 'GET') return methodNotAllowed(); - const creds = await getHeadscaleCredentials(userId, id); + const creds = await getOffscaleCredentials(userId, id); if (!creds) return notFound('no such server'); const started = Date.now(); @@ -132,11 +132,11 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef try { await createClient(creds).call('/api/v1/apikey'); } catch (err) { - const message = err instanceof HeadscaleError ? err.message : 'upstream error'; + const message = err instanceof OffscaleError ? err.message : 'upstream error'; return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started }); } - await recordHeadscaleProbe(userId, id, probe.version); + await recordOffscaleProbe(userId, id, probe.version); return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started }); } @@ -146,7 +146,7 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef const body = (await req.json().catch(() => null)) as Record | null; if (!body) return badRequest('expected a JSON body'); - const current = await getHeadscaleCredentials(userId, id); + const current = await getOffscaleCredentials(userId, id); if (!current) return notFound('no such server'); let url: string | undefined; @@ -177,12 +177,12 @@ async function handleOne(ctx: OfficerContext, id: number, action: string | undef if (validated instanceof Response) return validated; } - const server = await updateHeadscaleServer(userId, id, { name, url, apiKey, sshHost }); + const server = await updateOffscaleServer(userId, id, { name, url, apiKey, sshHost }); return server ? Response.json({ server }) : notFound('no such server'); } if (req.method === 'DELETE') { - const deleted = await deleteHeadscaleServer(userId, id); + const deleted = await deleteOffscaleServer(userId, id); return deleted ? new Response(null, { status: 204 }) : notFound('no such server'); } diff --git a/sidecar/version.test.ts b/sidecar/version.test.ts new file mode 100644 index 0000000..d7c47da --- /dev/null +++ b/sidecar/version.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from 'bun:test'; +import { MIN_MAJOR, MIN_MINOR, MIN_VERSION_LABEL, meetsFloor, parseVersion } from './version'; + +// The version floor is the plugin's whole compatibility story: Headscale changed its admin API shape +// repeatedly below 0.29, so `meetsFloor` is what stops a server with an incompatible data model being +// registered at all. It has no I/O, so the interesting cases are cheap to pin down — and they were not +// pinned down at all until now. + +describe('parseVersion', () => { + test('reads major.minor from the shapes a server actually reports', () => { + expect(parseVersion('0.29.0')).toEqual({ major: 0, minor: 29 }); + expect(parseVersion('v0.29.0')).toEqual({ major: 0, minor: 29 }); + expect(parseVersion(' 0.30.1 ')).toEqual({ major: 0, minor: 30 }); + expect(parseVersion('1.0')).toEqual({ major: 1, minor: 0 }); + }); + + test("returns null for 'dev', which is what a self-built image reports", () => { + // Not an error case. probeVersion turns this into supported:'unknown' rather than a refusal, so that + // someone building Headscale from source is not locked out. If this ever returned a version, those + // servers would start being REJECTED — the failure would look like a compatibility bug. + expect(parseVersion('dev')).toBeNull(); + }); + + test('returns null rather than guessing at non-semver', () => { + expect(parseVersion('')).toBeNull(); + expect(parseVersion('unstable')).toBeNull(); + expect(parseVersion('.29')).toBeNull(); + }); +}); + +describe('meetsFloor', () => { + test('accepts the floor itself and anything above it', () => { + expect(meetsFloor({ major: MIN_MAJOR, minor: MIN_MINOR })).toBe(true); + expect(meetsFloor({ major: 0, minor: 30 })).toBe(true); + expect(meetsFloor({ major: 1, minor: 0 })).toBe(true); + }); + + test('refuses the releases whose API shape Officer cannot speak', () => { + // 0.26 moved identifiers name→numeric, 0.28 collapsed forcedTags/validTags. Supporting these would + // mean carrying several incompatible models, which is the cost the floor exists to avoid. + expect(meetsFloor({ major: 0, minor: 28 })).toBe(false); + expect(meetsFloor({ major: 0, minor: 26 })).toBe(false); + expect(meetsFloor({ major: 0, minor: 0 })).toBe(false); + }); + + test('a higher major wins regardless of minor — 1.0 is not below 0.29', () => { + // The bug this guards: comparing minor first makes 1.0 (minor 0) fail against a floor of 0.29, so the + // first stable Headscale release would be refused by the plugin as too old. + expect(meetsFloor({ major: 1, minor: 0 })).toBe(true); + }); + + test('the advertised label agrees with the numeric floor', () => { + // Two constants describing one fact. They drift silently otherwise: the label is what users are told + // to install, and the numbers are what actually gates them. + expect(MIN_VERSION_LABEL).toBe(`${MIN_MAJOR}.${MIN_MINOR}`); + }); +}); diff --git a/web/ConsoleView.tsx b/web/ConsoleView.tsx index fe932b9..333612a 100644 --- a/web/ConsoleView.tsx +++ b/web/ConsoleView.tsx @@ -1,8 +1,8 @@ import { useCallback } from 'react'; import { Link } from 'react-router'; import { Loader2, TerminalSquare } from 'lucide-react'; -import { headscaleSectionPath } from './shared'; -import { useHeadscaleServers } from './useHeadscaleServers'; +import { offscaleSectionPath } from './shared'; +import { useOffscaleServers } from './useOffscaleServers'; import { TerminalView } from 'officerdev'; import { Button } from './Cards'; @@ -31,7 +31,7 @@ const Centred = ({ children }: { children: React.ReactNode }) => ( ); export const ConsoleView = () => { - const { active, isLoading } = useHeadscaleServers(); + const { active, isLoading } = useOffscaleServers(); // The terminal reports its connection state; nothing in this section acts on it yet, but TerminalView wants // a stable callback and an inline arrow would remount its effect on every render. @@ -67,7 +67,7 @@ export const ConsoleView = () => { the Headscale hostname — the console is most useful exactly when that name has stopped answering.

- + diff --git a/web/DiagnosticsView.tsx b/web/DiagnosticsView.tsx index 9595d8c..2f534fb 100644 --- a/web/DiagnosticsView.tsx +++ b/web/DiagnosticsView.tsx @@ -13,13 +13,13 @@ import { } from 'lucide-react'; import type { CompanionAction, CompanionContainer, CompanionHealthBody, CompanionVerdict } from './shared'; import { timeAgo } from './format'; -import { useHeadscaleServers } from './useHeadscaleServers'; +import { useOffscaleServers } from './useOffscaleServers'; import { useCompanionAction, useCompanionHealth, useCompanionLogStream, useCompanionLogs, -} from './useHeadscaleCompanion'; +} from './useOffscaleCompanion'; import { Card, SectionHeader, Button, Dot, ErrorNote } from './Cards'; import { ViewShell } from './ViewShell'; @@ -261,7 +261,7 @@ const Lifecycle = ({ running }: { running: boolean | null }) => { }; export const DiagnosticsView = () => { - const { active } = useHeadscaleServers(); + const { active } = useOffscaleServers(); const query = useCompanionHealth(); const result = query.data; diff --git a/web/InvitesView.tsx b/web/InvitesView.tsx index 81ca3f5..7d218e5 100644 --- a/web/InvitesView.tsx +++ b/web/InvitesView.tsx @@ -1,11 +1,11 @@ import { useEffect, useRef, useState } from 'react'; import QRCode from 'qrcode'; import { Check, Copy, Loader2, Plus, QrCode, Share2, Smartphone, Trash2 } from 'lucide-react'; -import type { HeadscaleInvite, HeadscaleInviteCreated, InviteStatus } from './shared'; +import type { OffscaleInvite, OffscaleInviteCreated, InviteStatus } from './shared'; import { INVITE_TTL_DEFAULT_SECONDS } from './shared'; -import { useHeadscaleInvites } from './useHeadscaleInvites'; -import { useHeadscaleUsers } from './useHeadscaleData'; -import { headscaleErrorMessage } from './useHeadscaleServers'; +import { useOffscaleInvites } from './useOffscaleInvites'; +import { useOffscaleUsers } from './useOffscaleData'; +import { offscaleErrorMessage } from './useOffscaleServers'; import { fullDate, timeAgo, timeUntil } from './format'; import { Badge, Button, Card, Dot, ErrorNote, Field } from './Cards'; import { EmptyBody, ViewShell } from './ViewShell'; @@ -76,7 +76,7 @@ const InviteQr = ({ url }: { url: string }) => { return ; }; -type InviteLinkPanelProps = { invite: HeadscaleInviteCreated; onDismiss: () => void }; +type InviteLinkPanelProps = { invite: OffscaleInviteCreated; onDismiss: () => void }; const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { const [showQr, setShowQr] = useState(true); @@ -138,11 +138,11 @@ const InviteLinkPanel = ({ invite, onDismiss }: InviteLinkPanelProps) => { ); }; -type CreateInviteFormProps = { onCreated: (invite: HeadscaleInviteCreated) => void; onClose: () => void }; +type CreateInviteFormProps = { onCreated: (invite: OffscaleInviteCreated) => void; onClose: () => void }; const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { - const { users } = useHeadscaleUsers(); - const { create } = useHeadscaleInvites(); + const { users } = useOffscaleUsers(); + const { create } = useOffscaleInvites(); const [user, setUser] = useState(''); const [note, setNote] = useState(''); const [ttl, setTtl] = useState(INVITE_TTL_DEFAULT_SECONDS); @@ -171,7 +171,7 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { onCreated(invite); onClose(); } catch (err) { - setError(headscaleErrorMessage(err)); + setError(offscaleErrorMessage(err)); } }; @@ -266,17 +266,17 @@ const CreateInviteForm = ({ onCreated, onClose }: CreateInviteFormProps) => { ); }; -type InviteRowProps = { invite: HeadscaleInvite; onError: (message: string) => void }; +type InviteRowProps = { invite: OffscaleInvite; onError: (message: string) => void }; const InviteRow = ({ invite, onError }: InviteRowProps) => { - const { revoke } = useHeadscaleInvites(); + const { revoke } = useOffscaleInvites(); const [confirming, setConfirming] = useState(false); const run = async () => { try { await revoke.mutateAsync(invite.id); } catch (err) { - onError(headscaleErrorMessage(err)); + onError(offscaleErrorMessage(err)); } }; @@ -338,9 +338,9 @@ const InviteRow = ({ invite, onError }: InviteRowProps) => { }; export const InvitesView = () => { - const { invites, unavailable, isLoading, error } = useHeadscaleInvites(); + const { invites, unavailable, isLoading, error } = useOffscaleInvites(); const [creating, setCreating] = useState(false); - const [created, setCreated] = useState(null); + const [created, setCreated] = useState(null); const [actionError, setActionError] = useState(null); const pending = invites.filter((i) => i.status === 'pending').length; diff --git a/web/KeysView.tsx b/web/KeysView.tsx index e3b25d2..098efd2 100644 --- a/web/KeysView.tsx +++ b/web/KeysView.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { KeyRound, Plus, Trash2, TimerOff, Copy, Check, Loader2, ShieldAlert } from 'lucide-react'; -import type { HeadscalePreAuthKey } from './shared'; -import { useHeadscaleKeys, useHeadscaleUsers } from './useHeadscaleData'; -import { useHeadscaleServers, headscaleErrorMessage } from './useHeadscaleServers'; +import type { OffscalePreAuthKey } from './shared'; +import { useOffscaleKeys, useOffscaleUsers } from './useOffscaleData'; +import { useOffscaleServers, offscaleErrorMessage } from './useOffscaleServers'; import { timeAgo, timeUntil, fullDate } from './format'; import { Card, Button, Field, Badge, Dot, ErrorNote } from './Cards'; import { ViewShell, EmptyBody } from './ViewShell'; @@ -102,8 +102,8 @@ const Toggle = ({ checked, onChange, label, hint }: ToggleProps) => ( type CreateKeyFormProps = { onCreated: (secret: string) => void; onClose: () => void }; const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => { - const { users } = useHeadscaleUsers(); - const { create } = useHeadscaleKeys(); + const { users } = useOffscaleUsers(); + const { create } = useOffscaleKeys(); const [userId, setUserId] = useState(''); const [reusable, setReusable] = useState(false); const [ephemeral, setEphemeral] = useState(false); @@ -133,7 +133,7 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => { if (result.key.key) onCreated(result.key.key); onClose(); } catch (err) { - setError(headscaleErrorMessage(err)); + setError(offscaleErrorMessage(err)); } }; @@ -203,10 +203,10 @@ const CreateKeyForm = ({ onCreated, onClose }: CreateKeyFormProps) => { ); }; -type KeyRowProps = { entry: HeadscalePreAuthKey; onError: (message: string) => void }; +type KeyRowProps = { entry: OffscalePreAuthKey; onError: (message: string) => void }; const KeyRow = ({ entry, onError }: KeyRowProps) => { - const { expire, remove } = useHeadscaleKeys(); + const { expire, remove } = useOffscaleKeys(); const [confirming, setConfirming] = useState(false); const busy = expire.isPending || remove.isPending; @@ -214,7 +214,7 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => { try { await fn(); } catch (err) { - onError(headscaleErrorMessage(err)); + onError(offscaleErrorMessage(err)); } }; @@ -271,8 +271,8 @@ const KeyRow = ({ entry, onError }: KeyRowProps) => { }; export const KeysView = () => { - const { keys, isLoading, error } = useHeadscaleKeys(); - const { active } = useHeadscaleServers(); + const { keys, isLoading, error } = useOffscaleKeys(); + const { active } = useOffscaleServers(); const [creating, setCreating] = useState(false); const [secret, setSecret] = useState(null); const [filter, setFilter] = useState('active'); diff --git a/web/NodesView.tsx b/web/NodesView.tsx index bdbc479..8ccc1c2 100644 --- a/web/NodesView.tsx +++ b/web/NodesView.tsx @@ -14,9 +14,9 @@ import { ArrowRightLeft, Tag as TagIcon, } from 'lucide-react'; -import type { HeadscaleNode } from './shared'; -import { useHeadscaleNodes, useHeadscaleUsers } from './useHeadscaleData'; -import { headscaleErrorMessage } from './useHeadscaleServers'; +import type { OffscaleNode } from './shared'; +import { useOffscaleNodes, useOffscaleUsers } from './useOffscaleData'; +import { offscaleErrorMessage } from './useOffscaleServers'; import { timeAgo, timeUntil, fullDate } from './format'; import { Card, Button, Dot, Badge, ErrorNote } from './Cards'; import { ViewShell, EmptyBody } from './ViewShell'; @@ -72,7 +72,7 @@ const parseTags = (text: string): string[] => { /** Set comparison, not sequence: Headscale is free to store the tags in an order the owner didn't type. */ const sameTags = (a: string[], b: string[]) => a.length === b.length && a.every((t) => b.includes(t)); -type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: string) => void }; +type OwnershipProps = { node: OffscaleNode; busy: boolean; onError: (message: string) => void }; /** * Owner and tags — the two things that decide which ACL rules apply to a node, which is why they sit @@ -83,8 +83,8 @@ type OwnershipProps = { node: HeadscaleNode; busy: boolean; onError: (message: s * shared with the Users section, so an expanded card is usually a cache hit anyway. */ const Ownership = ({ node, busy, onError }: OwnershipProps) => { - const { setTags, moveToUser } = useHeadscaleNodes(); - const { users } = useHeadscaleUsers(); + const { setTags, moveToUser } = useOffscaleNodes(); + const { users } = useOffscaleUsers(); const [owner, setOwner] = useState(node.user?.id ?? ''); const [draftTags, setDraftTags] = useState(node.tags.join(' ')); @@ -99,7 +99,7 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => { try { await fn(); } catch (err) { - onError(headscaleErrorMessage(err)); + onError(offscaleErrorMessage(err)); } }; @@ -185,10 +185,10 @@ const Ownership = ({ node, busy, onError }: OwnershipProps) => { ); }; -type NodeCardProps = { node: HeadscaleNode; onError: (message: string) => void }; +type NodeCardProps = { node: OffscaleNode; onError: (message: string) => void }; const NodeCard = ({ node, onError }: NodeCardProps) => { - const { rename, toggleRoute, expire, remove } = useHeadscaleNodes(); + const { rename, toggleRoute, expire, remove } = useOffscaleNodes(); const [open, setOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [draftName, setDraftName] = useState(node.name); @@ -200,7 +200,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => { try { await fn(); } catch (err) { - onError(headscaleErrorMessage(err)); + onError(offscaleErrorMessage(err)); } }; @@ -373,7 +373,7 @@ const NodeCard = ({ node, onError }: NodeCardProps) => { }; export const NodesView = () => { - const { nodes, isLoading, error } = useHeadscaleNodes(); + const { nodes, isLoading, error } = useOffscaleNodes(); const [filter, setFilter] = useState(''); const [actionError, setActionError] = useState(null); diff --git a/web/HeadscaleNav.tsx b/web/OffscaleNav.tsx similarity index 83% rename from web/HeadscaleNav.tsx rename to web/OffscaleNav.tsx index 2e14b31..9a29d22 100644 --- a/web/HeadscaleNav.tsx +++ b/web/OffscaleNav.tsx @@ -1,17 +1,17 @@ import type { LucideIcon } from 'lucide-react'; import { NavLink } from 'react-router'; import { Server, Laptop, Users, KeyRound, Smartphone, ShieldCheck, Activity, TerminalSquare } from 'lucide-react'; -import { HEADSCALE_SECTIONS, headscaleSectionPath, type HeadscaleSectionId } from './shared'; -import { useHeadscaleServers } from './useHeadscaleServers'; +import { OFFSCALE_SECTIONS, offscaleSectionPath, type OffscaleSectionId } from './shared'; +import { useOffscaleServers } from './useOffscaleServers'; // Lower-left panel of the /headscale workspace: the section list. Which server it all acts on is the panel -// above (HeadscaleServerPicker) — that one mutates, this one navigates, which is why they are separate. +// above (OffscaleServerPicker) — that one mutates, this one navigates, which is why they are separate. // // Sections are real links to /headscale/
, not channel writes — so they cmd-click into a new tab, // survive a reload, and answer the back button. Active state comes from react-router's NavLink rather than // being derived in JS, per the navigation audit's Phase 4. -const ICONS: Record = { +const ICONS: Record = { servers: Server, nodes: Laptop, users: Users, @@ -36,13 +36,13 @@ const SectionBody = ({ icon: Icon, label, selected }: SectionBodyProps) => ( ); -export const HeadscaleNav = () => { - const { active } = useHeadscaleServers(); +export const OffscaleNav = () => { + const { active } = useOffscaleServers(); return (