// 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 { 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) }; }