Files
offscale/sidecar/version.ts
T
pastilhasandClaude Opus 5 8a446bb4b5 offscale, extracted from the platform into its own repository
The tailnet plugin — machines, users, pre-auth keys, access policy and device
invites. Moved out of officerdev/platform, where it had lived in plugins/ since
the plugin system was built.

Until now this code existed in exactly one place: the platform repository. That
made "gitignore the plugins directory" impossible to do safely, because
untracking it would have left 49 files on a single disk with no remote. This
repository is what makes that move safe.

Same extraction as plugins/music before it: source only, no history. The
platform's history still holds every commit that shaped this, and the SHAs cited
across the codebase keep resolving — replaying it here would have created a
second, divergent account of the same work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:12:57 +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) };
}