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>
80 lines
3.6 KiB
TypeScript
80 lines
3.6 KiB
TypeScript
// 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) };
|
||
}
|