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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
// 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.23–0.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) };
|
||||
}
|
||||
Reference in New Issue
Block a user