Files
platform/plugins/offscale/sidecar/version.ts
T
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00

80 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Headscale version detection and the supported floor.
//
// Officer targets Headscale >= 0.29 and nothing older. That is a deliberate, narrow floor: the admin API
// changed shape repeatedly below it — 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.230.28 would mean
// carrying several incompatible data models; refusing them at registration time costs one probe.
//
// Detection uses the server's own unauthenticated `GET /version`, which exists in 0.28 and 0.29 and sits at
// the root — NOT under /api/v1, and not behind the bearer middleware. Do not confuse it with the three
// other similarly-named endpoints: `GET /health` (root, unauthenticated, `{status:'pass'}`) and
// `GET /api/v1/health` (authenticated, `{databaseConnectivity:true}`) carry no version at all.
export const MIN_MAJOR = 0;
export const MIN_MINOR = 29;
export const MIN_VERSION_LABEL = '0.29';
const PROBE_TIMEOUT_MS = 8000;
export type VersionProbe =
| { ok: true; version: string; supported: true }
/** Reached the server but can't judge the version — self-built images report the literal 'dev'. */
| { ok: true; version: string; supported: 'unknown' }
| { ok: true; version: string; supported: false }
| { ok: false; error: string };
/** `major.minor` from a Headscale version string, or null when it isn't semver (e.g. the literal 'dev'). */
export function parseVersion(raw: string): { major: number; minor: number } | null {
const m = raw
.trim()
.replace(/^v/, '')
.match(/^(\d+)\.(\d+)/);
if (!m) return null;
return { major: Number(m[1]), minor: Number(m[2]) };
}
/** Whether a parsed version is at or above the supported floor. */
export function meetsFloor(v: { major: number; minor: number }): boolean {
if (v.major !== MIN_MAJOR) return v.major > MIN_MAJOR;
return v.minor >= MIN_MINOR;
}
/**
* Probe a base URL's `GET /version`. Unauthenticated, so this also doubles as the reachability check during
* registration — it tells us "is there a Headscale here at all" before we bother validating a key.
*
* An unparseable version is reported as `supported: 'unknown'` rather than rejected: a server built without
* VCS build info reports 'dev', and refusing those would lock out legitimately self-built deployments.
*/
export async function probeVersion(baseUrl: string): Promise<VersionProbe> {
let res: Response;
try {
res = await fetch(`${baseUrl}/version`, {
headers: { accept: 'application/json' },
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
} catch {
return { ok: false, error: 'server unreachable' };
}
if (!res.ok) {
// A Headscale that answers /version with a non-2xx isn't one we can identify. Most often this is a URL
// pointing at a reverse proxy or an unrelated service rather than at Headscale itself.
return { ok: false, error: `GET /version returned ${res.status} — is this a Headscale server?` };
}
let version: string;
try {
const body = (await res.json()) as { version?: unknown };
if (typeof body.version !== 'string' || !body.version) return { ok: false, error: 'no version in response' };
version = body.version;
} catch {
return { ok: false, error: 'GET /version did not return JSON' };
}
const parsed = parseVersion(version);
if (!parsed) return { ok: true, version, supported: 'unknown' };
return { ok: true, version, supported: meetsFloor(parsed) };
}