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.