rebrand to OffScale, and fix what the first extraction missed
Offscale was the first plugin extracted and it was done before we knew what "extracted" meant. Music, done last, is the standard. This brings offscale to it. ── The rebrand ── The plugin was `offscale` to the platform and `headscale` to itself: sidecar name and handles, the port announcement, the API proxy name, the React components, every hook, the react-query keys, the panel ids and appTypes, and the Postgres table. Now all of those say offscale. The line drawn, and it is deliberate: OffScale is Officer's tooling layer, and Headscale is the server it manages. So every IDENTIFIER is offscale, while a message like `headscale unreachable`, the `headscale apikeys create` hint and the ACL assistant's prompt still say Headscale — because they are talking about the remote server, and renaming them would make the code lie about what it reached. 495 occurrences became 180, and the 180 are all of that second kind. ── The live bug this uncovered ── `headscaleSectionPath` built links to `/headscale/<section>`. The shell has no such route — plugin routes come from `plugin.route`, which is `/offscale` — and it redirects unknown paths to the home page. So every section link in the nav, the console and the server picker silently went home. The extraction moved the route and left the link builder behind. Also live: ServersView told the user to run `pm2 start ecosystem.config.cjs --only officer-headscale`, a process that has not existed since the sidecar was renamed. ── The correctness fix music already had ── api/router.ts hardcoded `prefix: '/api/offscale'`. The proxy strips `prefix.length` characters, so a literal is correct only for a first-party publisher; published by anyone else this mounts at `/api/p/<publisher>/offscale` and forwards the wrong subpath. Derived from `mountPrefix()` now, as music does. ── The rest ── - assets/icon.png — the OffScale artwork, 256px to match music's. The tile stops being a glyph badge. - First tests: 21 of them, over the version floor and the protobuf normalisers. Those are the two places a Headscale release actually breaks this, and they had no coverage at all. `meetsFloor` has a real trap pinned now — comparing minor first would refuse 1.0 as older than 0.29. - OFFSCALE_API.md — the contract was a 45-line comment inside sidecar/index.ts, which is not linkable and not published. Now a document, as MUSIC_API.md is. - web/panels.ts re-exported three components. A plugin cannot export components; that was residue of the platform importing them before extraction. - Comments pointed at src/servers/api/headscale/ and src/servers/sidecar/headscale/, neither of which has existed since the extraction. The crypto purpose moved headscale → offscale too, and the secret-store row was renamed rather than left to create a fresh key — the material is preserved, so this is reversible. Free to do only because offscale_servers had 0 rows; with one stored API key it would have been a migration.
This commit is contained in:
+4
-4
@@ -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<HeadscaleClient | Response> {
|
||||
const creds = await getActiveHeadscaleCredentials(userId);
|
||||
export async function activeClient(userId: number): Promise<OffscaleClient | Response> {
|
||||
const creds = await getActiveOffscaleCredentials(userId);
|
||||
if (!creds) {
|
||||
return Response.json({ error: 'no active Headscale server', code: 'no_active_server' }, { status: 409 });
|
||||
}
|
||||
|
||||
+10
-10
@@ -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<string> {
|
||||
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: <T>(path: string, opts?: CallOptions) => Promise<T>;
|
||||
};
|
||||
|
||||
/** 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<T>(path: string, opts: CallOptions = {}): Promise<T> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Response | string> {
|
||||
let res: Response;
|
||||
@@ -85,8 +85,8 @@ export async function readBody(res: Response): Promise<Record<string, unknown> |
|
||||
}
|
||||
|
||||
/** The active server's credentials, or a 409 the UI already knows how to render. */
|
||||
export async function activeCreds(userId: number): Promise<HeadscaleServerCredentials | Response> {
|
||||
const creds = await getActiveHeadscaleCredentials(userId);
|
||||
export async function activeCreds(userId: number): Promise<OffscaleServerCredentials | Response> {
|
||||
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<HeadscaleServerCreden
|
||||
}
|
||||
|
||||
/** `GET /_officer/companion/health` — verdict, container state and, when unhappy, its own diagnosis. */
|
||||
async function health(creds: HeadscaleServerCredentials): Promise<Response> {
|
||||
async function health(creds: OffscaleServerCredentials): Promise<Response> {
|
||||
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<Response> {
|
||||
}
|
||||
|
||||
/** `GET /_officer/companion/logs?tail=N` — a snapshot of the last N lines. */
|
||||
async function logs(creds: HeadscaleServerCredentials, url: URL): Promise<Response> {
|
||||
async function logs(creds: OffscaleServerCredentials, url: URL): Promise<Response> {
|
||||
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<Respon
|
||||
* ReadableStream that Bun cancels when the client disconnects, which aborts the upstream fetch in turn.
|
||||
* Buffering it into frames here would break that, and would also mean a log line waiting on our own flush.
|
||||
*/
|
||||
async function logStream(creds: HeadscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||
async function logStream(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||
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<Response> {
|
||||
async function action(creds: OffscaleServerCredentials, name: string): Promise<Response> {
|
||||
// 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 });
|
||||
|
||||
+6
-6
@@ -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<OfficerUser | Response> {
|
||||
async function resolveOwner(client: OffscaleClient, ctx: OfficerContext): Promise<OfficerUser | Response> {
|
||||
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 });
|
||||
}
|
||||
|
||||
+16
-15
@@ -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 {
|
||||
|
||||
+4
-4
@@ -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<Response> {
|
||||
async function create(creds: OffscaleServerCredentials, ctx: OfficerContext): Promise<Response> {
|
||||
const input = parseCreate(await readJson(ctx.req));
|
||||
if (input instanceof Response) return input;
|
||||
|
||||
@@ -153,13 +153,13 @@ function pickInvites(body: Record<string, unknown>): unknown[] {
|
||||
}
|
||||
|
||||
/** `GET /_officer/enroll/invites` — the admin's audit list. Never carries a token or a key. */
|
||||
async function list(creds: HeadscaleServerCredentials): Promise<Response> {
|
||||
async function list(creds: OffscaleServerCredentials): Promise<Response> {
|
||||
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<Response> {
|
||||
async function revoke(creds: OffscaleServerCredentials, id: string): Promise<Response> {
|
||||
const res = await callCompanion(creds, { path: `${INVITES_PATH}/${encodeURIComponent(id)}`, method: 'DELETE' });
|
||||
return relay(res, (body) => ({ available: true, ...body }));
|
||||
}
|
||||
|
||||
+2
-2
@@ -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<Response> {
|
||||
}
|
||||
|
||||
/** 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<OfficerNode | null> {
|
||||
async function getNode(client: OffscaleClient, id: string): Promise<OfficerNode | null> {
|
||||
const body = await client.call<{ node?: Record<string, unknown> }>(`/api/v1/node/${encodeURIComponent(id)}`);
|
||||
return body.node ? toNode(body.node) : null;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+4
-4
@@ -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<Response> {
|
||||
const body = await client.call<PolicyBody>('/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<Response> {
|
||||
// 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)) {
|
||||
|
||||
+2
-2
@@ -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<Respon
|
||||
}
|
||||
} catch (err) {
|
||||
// Upstream failures carry their own status; everything else is ours and is a 500 the caller logs.
|
||||
if (err instanceof HeadscaleError) return Response.json({ error: err.message }, { status: err.status });
|
||||
if (err instanceof OffscaleError) return Response.json({ error: err.message }, { status: err.status });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
+20
-20
@@ -1,14 +1,14 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import {
|
||||
listHeadscaleServers,
|
||||
createHeadscaleServer,
|
||||
updateHeadscaleServer,
|
||||
setActiveHeadscaleServer,
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
listOffscaleServers,
|
||||
createOffscaleServer,
|
||||
updateOffscaleServer,
|
||||
setActiveOffscaleServer,
|
||||
deleteOffscaleServer,
|
||||
getOffscaleCredentials,
|
||||
recordOffscaleProbe,
|
||||
} from '../db/queries';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { createClient, OffscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
import { normalizeSshHost } from './ssh';
|
||||
@@ -56,12 +56,12 @@ async function validateServer(url: string, apiKey: string): Promise<string | Res
|
||||
}
|
||||
|
||||
// Cheapest authenticated GET whose path is stable across releases, and the same call Headscale's own
|
||||
// clients use to test a key. A wrong key surfaces here as HeadscaleError(502, 'rejected the stored key').
|
||||
// clients use to test a key. A wrong key surfaces here as OffscaleError(502, 'rejected the stored key').
|
||||
const client = createClient({ id: 0, name: 'probe', url, apiKey });
|
||||
try {
|
||||
await client.call('/api/v1/apikey');
|
||||
} catch (err) {
|
||||
if (err instanceof HeadscaleError) {
|
||||
if (err instanceof OffscaleError) {
|
||||
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
|
||||
}
|
||||
throw err;
|
||||
@@ -73,7 +73,7 @@ async function handleCollection(ctx: OfficerContext): Promise<Response> {
|
||||
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<Response> {
|
||||
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<string, unknown> | 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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user