Files
pastilhas 95b84ea748 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.
2026-08-15 18:41:52 +00:00

202 lines
8.2 KiB
TypeScript

import type { OfficerContext } from './routes';
import {
listOffscaleServers,
createOffscaleServer,
updateOffscaleServer,
setActiveOffscaleServer,
deleteOffscaleServer,
getOffscaleCredentials,
recordOffscaleProbe,
} from '../db/queries';
import { createClient, OffscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes';
import { normalizeSshHost } from './ssh';
// Server registry routes — /_officer/servers/*. Officer manages any number of Headscale servers; the owner
// registers each with a URL and an admin API key generated on that server, and one is active at a time.
//
// Registration VALIDATES before it saves, in two steps, because a bad registration is otherwise only
// discovered later as a confusing failure on some unrelated screen:
// 1. unauthenticated GET /version — proves something Headscale-shaped is there and enforces the >=0.29 floor
// 2. an authenticated call — proves the key actually works
// Neither step is skippable, and a rejected registration is never written.
/** Normalize a user-supplied base URL, or null if it isn't a usable http(s) origin. */
function normalizeUrl(raw: unknown): string | null {
if (typeof raw !== 'string' || !raw.trim()) return null;
let candidate = raw.trim();
// Bare host/port is the most common paste; assume https rather than rejecting it.
if (!/^https?:\/\//i.test(candidate)) candidate = `https://${candidate}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null;
// Trailing slash would produce `//api/v1/...`; query/hash are meaningless on a base URL.
return `${parsed.origin}${parsed.pathname.replace(/\/+$/, '')}`;
}
function requireString(value: unknown, field: string): string | Response {
if (typeof value !== 'string' || !value.trim()) return badRequest(`${field} is required`);
return value.trim();
}
/**
* Confirm a URL+key pair is a supported, reachable Headscale we can authenticate against.
* Returns the observed version on success, or a ready-to-send error Response.
*/
async function validateServer(url: string, apiKey: string): Promise<string | Response> {
const probe = await probeVersion(url);
if (!probe.ok) return badRequest(probe.error);
if (probe.supported === false) {
return badRequest(`Headscale ${probe.version} is not supported — Officer requires ${MIN_VERSION_LABEL} or newer`);
}
// 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 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 OffscaleError) {
return badRequest(err.status === 502 ? 'the API key was rejected by that server' : err.message);
}
throw err;
}
return probe.version;
}
async function handleCollection(ctx: OfficerContext): Promise<Response> {
const { req, userId } = ctx;
if (req.method === 'GET') {
return Response.json({ servers: await listOffscaleServers(userId) });
}
if (req.method === 'POST') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const url = normalizeUrl(body.url);
if (!url) return badRequest('url must be a valid http(s) URL');
const apiKey = requireString(body.apiKey, 'apiKey');
if (apiKey instanceof Response) return apiKey;
// The name is a label only; default it to the host so registration needs just a URL and a key.
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : new URL(url).host;
// Optional, and never validated by connecting: registration should not fail because a box is rebooting.
const sshHost = normalizeSshHost(body.sshHost);
if (sshHost instanceof Response) return sshHost;
const validated = await validateServer(url, apiKey);
if (validated instanceof Response) return validated;
// First registration becomes active, so the owner is never left with servers but none selected.
const existing = await listOffscaleServers(userId);
const server = await createOffscaleServer({
userId,
name,
url,
apiKey,
version: validated,
sshHost,
activate: existing.length === 0,
});
return Response.json({ server }, { status: 201 });
}
return methodNotAllowed();
}
async function handleOne(ctx: OfficerContext, id: number, action: string | undefined): Promise<Response> {
const { req, userId } = ctx;
if (action === 'activate') {
if (req.method !== 'POST') return methodNotAllowed();
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 getOffscaleCredentials(userId, id);
if (!creds) return notFound('no such server');
const started = Date.now();
const probe = await probeVersion(creds.url);
if (!probe.ok) return Response.json({ ok: false, error: probe.error, ms: Date.now() - started });
// Reachable — confirm the key too, so "healthy" means "we can actually use this server".
try {
await createClient(creds).call('/api/v1/apikey');
} catch (err) {
const message = err instanceof OffscaleError ? err.message : 'upstream error';
return Response.json({ ok: false, version: probe.version, error: message, ms: Date.now() - started });
}
await recordOffscaleProbe(userId, id, probe.version);
return Response.json({ ok: true, version: probe.version, supported: probe.supported, ms: Date.now() - started });
}
if (action !== undefined) return notFound();
if (req.method === 'PATCH') {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
if (!body) return badRequest('expected a JSON body');
const current = await getOffscaleCredentials(userId, id);
if (!current) return notFound('no such server');
let url: string | undefined;
if (body.url !== undefined) {
const normalized = normalizeUrl(body.url);
if (!normalized) return badRequest('url must be a valid http(s) URL');
url = normalized;
}
let apiKey: string | undefined;
if (body.apiKey !== undefined) {
const parsed = requireString(body.apiKey, 'apiKey');
if (parsed instanceof Response) return parsed;
apiKey = parsed;
}
const name = typeof body.name === 'string' && body.name.trim() ? body.name.trim() : undefined;
// Absent = leave it; '' or null = clear the console target. normalizeSshHost collapses both to null.
let sshHost: string | null | undefined;
if (body.sshHost !== undefined) {
const parsed = normalizeSshHost(body.sshHost);
if (parsed instanceof Response) return parsed;
sshHost = parsed;
}
// Re-validate whenever either half of the credentials moves — a saved-but-broken server is the exact
// state registration works hard to prevent, and an edit can reintroduce it.
if (url !== undefined || apiKey !== undefined) {
const validated = await validateServer(url ?? current.url, apiKey ?? current.apiKey);
if (validated instanceof Response) return validated;
}
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 deleteOffscaleServer(userId, id);
return deleted ? new Response(null, { status: 204 }) : notFound('no such server');
}
return methodNotAllowed();
}
/** Dispatch `/_officer/servers/...`. `rest` is the path after `servers`. */
export async function handleServersRoute(ctx: OfficerContext, rest: string[]): Promise<Response> {
if (rest.length === 0) return handleCollection(ctx);
const id = Number(rest[0]);
if (!Number.isInteger(id) || id <= 0) return badRequest('server id must be a positive integer');
if (rest.length > 2) return notFound();
return handleOne(ctx, id, rest[1]);
}