diff --git a/app/server/headscale/api/server-version.ts b/app/server/headscale/api/server-version.ts index 5c269991..a6ab4d14 100644 --- a/app/server/headscale/api/server-version.ts +++ b/app/server/headscale/api/server-version.ts @@ -15,6 +15,13 @@ const SEMVER_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/; +// Go pseudo-version prerelease segment: `<14-digit timestamp>-<12-hex sha>`, +// e.g. the prerelease part of `v0.0.0-20260703052708-048308511c72`. Untagged +// Headscale builds (per-commit `main-*` / `development` Docker images) report +// this instead of `dev`, and it parses as semver 0.0.0 — which would strip +// every capability from a server that actually runs the newest code. +const GO_PSEUDO_VERSION_PRERELEASE_RE = /^\d{14}-[0-9a-f]{12}$/; + export interface ServerVersion { readonly major: number; readonly minor: number; @@ -47,6 +54,26 @@ export function parseServerVersion(raw: string): ServerVersion { }; } const [, maj, min, pat, pre, build] = match; + // A Go pseudo-version (v0.0.0--) is an untagged dev build, + // not an ancient release: treat it like `dev` so capability checks assume + // the modern code paths instead of gating everything off. + if ( + Number(maj) === 0 && + Number(min) === 0 && + Number(pat) === 0 && + pre !== undefined && + GO_PSEUDO_VERSION_PRERELEASE_RE.test(pre) + ) { + return { + major: 0, + minor: 0, + patch: 0, + prerelease: pre, + build, + raw, + unknown: true, + }; + } return { major: Number(maj), minor: Number(min), diff --git a/tests/unit/headscale/server-version.test.ts b/tests/unit/headscale/server-version.test.ts index 399a0db1..9aa5c760 100644 --- a/tests/unit/headscale/server-version.test.ts +++ b/tests/unit/headscale/server-version.test.ts @@ -39,6 +39,21 @@ describe("parseServerVersion", () => { expect(v.unknown).toBe(true); expect(v.raw).toBe("dev"); }); + + test("flags Go pseudo-versions from untagged builds as unknown", () => { + // Headscale's per-commit Docker images (main-*, development) report a + // Go pseudo-version instead of `dev`. Parsing it as semver 0.0.0 would + // deny every capability to a server running the newest code. + const v = parseServerVersion("v0.0.0-20260703052708-048308511c72"); + expect(v.unknown).toBe(true); + expect(v.raw).toBe("v0.0.0-20260703052708-048308511c72"); + }); + + test("does not flag a genuine 0.0.0 prerelease as unknown", () => { + const v = parseServerVersion("v0.0.0-beta.1"); + expect(v.unknown).toBe(false); + expect(v.prerelease).toBe("beta.1"); + }); }); describe("gte", () => {