From d7f1d665a45cb67b5c18ace9617f3b7f67a5913c Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Sat, 30 May 2026 17:32:00 -0400 Subject: [PATCH] feat: bump headscale minimum to 0.27 --- app/routes/acls/acl-action.ts | 82 +++++-------------- app/server/headscale/api/capabilities.ts | 8 -- app/server/headscale/api/index.ts | 65 +++++++++++---- app/server/headscale/api/server-version.ts | 2 +- docs/install/docker.md | 6 +- docs/install/limited-mode.md | 2 +- docs/install/native-mode.md | 2 +- tests/integration/api/versions.test.ts | 7 +- tests/integration/setup/env.ts | 2 +- .../headscale/headscale-resilience.test.ts | 25 +++++- tests/unit/headscale/server-version.test.ts | 12 +-- 11 files changed, 101 insertions(+), 112 deletions(-) diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index f113f50..7bb1e2c 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -54,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) { throw error; } - // Starting in Headscale 0.27.0 the ACLs parsing was changed meaning - // we need to reference other error messages based on API version. - if (context.headscale.capabilities.policyErrorsUseModernFormat) { - if (message.includes("parsing HuJSON:")) { - const cutIndex = message.indexOf("parsing HuJSON:"); - const trimmed = - cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message; + // Policy parse errors carry one of two prefixes (HuJSON syntax vs. + // structural unmarshal). Headscale 0.27.0+ uses these forms; older + // releases are no longer supported. + if (message.includes("parsing HuJSON:")) { + const cutIndex = message.indexOf("parsing HuJSON:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { success: false, error: trimmed, policy: undefined, updatedAt: undefined }, + 400, + ); + } - if (message.includes("parsing policy from bytes:")) { - const cutIndex = message.indexOf("parsing policy from bytes:"); - const trimmed = - cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message; + if (message.includes("parsing policy from bytes:")) { + const cutIndex = message.indexOf("parsing policy from bytes:"); + const trimmed = + cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message; - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - } else { - // Pre-0.27.0 error messages - if (message.includes("parsing hujson")) { - const cutIndex = message.indexOf("err: hujson:"); - const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message; - - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } - - if (message.includes("unmarshalling policy")) { - const cutIndex = message.indexOf("err:"); - const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message; - - return data( - { - success: false, - error: trimmed, - policy: undefined, - updatedAt: undefined, - }, - 400, - ); - } + return data( + { success: false, error: trimmed, policy: undefined, updatedAt: undefined }, + 400, + ); } } diff --git a/app/server/headscale/api/capabilities.ts b/app/server/headscale/api/capabilities.ts index df0de26..212ab0e 100644 --- a/app/server/headscale/api/capabilities.ts +++ b/app/server/headscale/api/capabilities.ts @@ -36,13 +36,6 @@ export interface Capabilities { * 0.28.0+. */ readonly nodeOwnerIsImmutable: boolean; - - /** - * Policy parse errors use the modern `parsing HuJSON:` / - * `parsing policy from bytes:` prefixes. Pre-0.27 emitted - * `parsing hujson` / `unmarshalling policy`. Introduced in 0.27.0. - */ - readonly policyErrorsUseModernFormat: boolean; } export function capabilitiesFor(version: ServerVersion): Capabilities { @@ -50,6 +43,5 @@ export function capabilitiesFor(version: ServerVersion): Capabilities { preAuthKeysHaveStableIds: gte(version, "0.28.0"), nodeTagsAreFlat: gte(version, "0.28.0"), nodeOwnerIsImmutable: gte(version, "0.28.0"), - policyErrorsUseModernFormat: gte(version, "0.27.0"), }; } diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts index e1bd307..64c5c89 100644 --- a/app/server/headscale/api/index.ts +++ b/app/server/headscale/api/index.ts @@ -1,14 +1,26 @@ // MARK: Headscale API // -// The public entry point for talking to a Headscale server. Boot -// detects the server version via `GET /version` (unauthenticated, -// available since Headscale 0.26.0), derives a typed `Capabilities` -// object, and returns a `Headscale` value that constructs -// authenticated `HeadscaleClient`s on demand. +// The public entry point for talking to a Headscale server. At boot +// we try `GET /version` (unauthenticated, present since Headscale +// 0.27.0 — the minimum version Headplane supports) to derive a +// typed `Capabilities` object. Boot outcomes: +// +// - success: parse the response, derive capabilities, done. +// - 404: Headscale is reachable but predates 0.27.0 and is no +// longer supported. Log an error and keep retrying so an +// upgrade is picked up without a Headplane restart. +// - any other failure (network, 5xx, parse): Headplane still +// boots with `version = unknown` (capabilities-permissive) and +// a background retry. This handles docker-compose start-order +// races without making the whole process unhappy. +// +// Capabilities are always derived from `version`; once detection +// finishes there's no further state to track. import log from "~/utils/log"; import { type Capabilities, capabilitiesFor } from "./capabilities"; +import { isDataWithApiError } from "./error-client"; import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys"; import { makeNodeApi, type NodeApi } from "./resources/nodes"; import { makePolicyApi, type PolicyApi } from "./resources/policy"; @@ -17,6 +29,8 @@ import { makeUserApi, type UserApi } from "./resources/users"; import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version"; import { createTransport } from "./transport"; +const MIN_SUPPORTED_VERSION = "0.27.0"; + export interface Headscale { readonly version: ServerVersion; readonly capabilities: Capabilities; @@ -58,24 +72,39 @@ export async function createHeadscale(opts: CreateHeadscaleOptions): Promise | undefined; let disposed = false; + function settle(parsed: ServerVersion) { + version = parsed; + capabilities = capabilitiesFor(parsed); + detected = true; + if (parsed.unknown) { + log.warn( + "api", + "Could not parse Headscale version %s, assuming newest known capabilities", + parsed.raw, + ); + } else { + log.info("api", "Connected to Headscale %s", formatServerVersion(parsed)); + } + } + async function detectOnce(): Promise { try { const { version: raw } = await transport.getPublic<{ version: string }>("/version"); - const parsed = parseServerVersion(raw); - version = parsed; - capabilities = capabilitiesFor(parsed); - detected = true; - if (parsed.unknown) { - log.warn( - "api", - "Could not parse Headscale version %s, assuming newest known capabilities", - raw, - ); - } else { - log.info("api", "Connected to Headscale %s", formatServerVersion(parsed)); - } + settle(parseServerVersion(raw)); return true; } catch (error) { + // 404 means Headscale is reachable but predates 0.27.0 (where + // /version was introduced). That server is below the supported + // floor, so we don't settle — leave capabilities permissive and + // keep retrying in case the operator upgrades in place. + if (isDataWithApiError(error) && error.data.statusCode === 404) { + log.error( + "api", + "Headscale /version returned 404; Headplane requires Headscale %s or newer", + MIN_SUPPORTED_VERSION, + ); + return false; + } log.debug("api", "Headscale /version probe failed: %s", String(error)); return false; } diff --git a/app/server/headscale/api/server-version.ts b/app/server/headscale/api/server-version.ts index 5a40805..5c26999 100644 --- a/app/server/headscale/api/server-version.ts +++ b/app/server/headscale/api/server-version.ts @@ -2,7 +2,7 @@ // // Parses the response from Headscale's `GET /version` endpoint into a // structured value that capability checks can reason about. The -// endpoint exists in every Headscale release we support (0.26.0+), +// endpoint exists in every Headscale release we support (0.27.0+) and // returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`, // or `dev` for untagged builds. // diff --git a/docs/install/docker.md b/docs/install/docker.md index 03f6f14..5c31b7e 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -18,7 +18,7 @@ with Docker. ## Prerequisites - Docker and Docker Compose -- Headscale version 0.26.0 or later installed and running +- Headscale version 0.27.0 or later installed and running - A [completed configuration file](./index.md#configuration) for Headplane. ## Installation @@ -132,7 +132,7 @@ services: # Read-only access to the Docker socket (or a proxy) - "/var/run/docker.sock:/var/run/docker.sock:ro" headscale: - image: headscale/headscale:0.26.0 + image: headscale/headscale:0.27.1 container_name: headscale restart: unless-stopped command: serve @@ -239,7 +239,7 @@ services: - "traefik.http.routers.headplane.entrypoints=websecure" - "traefik.http.routers.headplane.tls=true" headscale: - image: headscale/headscale:0.26.0 + image: headscale/headscale:0.27.1 container_name: headscale restart: unless-stopped command: serve diff --git a/docs/install/limited-mode.md b/docs/install/limited-mode.md index d9be808..37cbd39 100644 --- a/docs/install/limited-mode.md +++ b/docs/install/limited-mode.md @@ -19,7 +19,7 @@ advanced features, making it suitable for local testing and development. ## Prerequisites - Docker (and optionally Docker Compose) -- Headscale version 0.26.0 or later installed and running +- Headscale version 0.27.0 or later installed and running - A [completed configuration file](/index.md#configuration) for Headplane. ## Installation diff --git a/docs/install/native-mode.md b/docs/install/native-mode.md index 03bd775..100d05d 100644 --- a/docs/install/native-mode.md +++ b/docs/install/native-mode.md @@ -20,7 +20,7 @@ or prefer to avoid containers. - A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora) - Go version 1.25.1 installed (only needed to build Headplane) - Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed -- Headscale version 0.26.0 or later installed and running +- Headscale version 0.27.0 or later installed and running - A [completed configuration file](./index.md#configuration) for Headplane. Before building and running Headplane, ensure that the directory defined in diff --git a/tests/integration/api/versions.test.ts b/tests/integration/api/versions.test.ts index 1343a9c..b7335e8 100644 --- a/tests/integration/api/versions.test.ts +++ b/tests/integration/api/versions.test.ts @@ -24,10 +24,9 @@ describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => { expect(bootstrapper.capabilities.preAuthKeysHaveStableIds).toBe(gte(v, "0.28.0")); expect(bootstrapper.capabilities.nodeTagsAreFlat).toBe(gte(v, "0.28.0")); expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0")); - expect(bootstrapper.capabilities.policyErrorsUseModernFormat).toBe(gte(v, "0.27.0")); - // The known version table only has 0.26+; if a future version is added - // before this test is updated, surface that explicitly rather than passing. - const known: Version[] = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"]; + // If a future version is added to HS_VERSIONS before this test is + // updated, surface that explicitly rather than passing silently. + const known: Version[] = ["0.27.0", "0.27.1", "0.28.0"]; if (!known.includes(version)) { context.skip(); } diff --git a/tests/integration/setup/env.ts b/tests/integration/setup/env.ts index 7842983..578e2c9 100644 --- a/tests/integration/setup/env.ts +++ b/tests/integration/setup/env.ts @@ -6,7 +6,7 @@ import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale"; // The set of Headscale versions integration tests run against. Listed // explicitly (rather than derived from a generated manifest) so the // supported version matrix lives next to the code that uses it. -export const HS_VERSIONS = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"] as const; +export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0"] as const; export type Version = (typeof HS_VERSIONS)[number]; interface VersionStateEntry { diff --git a/tests/unit/headscale/headscale-resilience.test.ts b/tests/unit/headscale/headscale-resilience.test.ts index 73c0dec..7043d85 100644 --- a/tests/unit/headscale/headscale-resilience.test.ts +++ b/tests/unit/headscale/headscale-resilience.test.ts @@ -77,7 +77,29 @@ describe("createHeadscale boot-time resilience", () => { // Should not throw, and should default to "unknown" with permissive caps. expect(headscale.version.unknown).toBe(true); expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true); - expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true); + await headscale.dispose(); + }); + + test("a 404 from /version is treated as below the supported floor and keeps retrying", async () => { + const probe = await startVersionServer(() => new Response("not found", { status: 404 })); + + const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 }); + // 404 = pre-0.27 Headscale. We do NOT settle on an inferred version; + // capabilities stay permissive and the background retry keeps probing + // so an in-place upgrade is picked up without a Headplane restart. + expect(headscale.version.unknown).toBe(true); + + probe.setResponse(() => + Response.json({ version: "v0.28.0", commit: "x", buildTime: "", go: "", dirty: false }), + ); + + const deadline = Date.now() + 2000; + while (Date.now() < deadline && headscale.version.unknown) { + await new Promise((r) => setTimeout(r, 25)); + } + + expect(headscale.version.unknown).toBe(false); + expect(headscale.version.raw).toBe("v0.28.0"); await headscale.dispose(); }); @@ -100,7 +122,6 @@ describe("createHeadscale boot-time resilience", () => { expect(headscale.version.unknown).toBe(false); expect(headscale.version.raw).toBe("v0.27.1"); expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(false); - expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true); await headscale.dispose(); }); diff --git a/tests/unit/headscale/server-version.test.ts b/tests/unit/headscale/server-version.test.ts index 21f5973..dd158bf 100644 --- a/tests/unit/headscale/server-version.test.ts +++ b/tests/unit/headscale/server-version.test.ts @@ -82,7 +82,6 @@ describe("capabilitiesFor", () => { preAuthKeysHaveStableIds: true, nodeTagsAreFlat: true, nodeOwnerIsImmutable: true, - policyErrorsUseModernFormat: true, }); }); @@ -92,19 +91,10 @@ describe("capabilitiesFor", () => { ); }); - test("0.27.1 only has the policy-error format flag", () => { + test("0.27.1 lacks every 0.28-gated capability", () => { const caps = capabilitiesFor(parseServerVersion("0.27.1")); expect(caps.preAuthKeysHaveStableIds).toBe(false); expect(caps.nodeTagsAreFlat).toBe(false); expect(caps.nodeOwnerIsImmutable).toBe(false); - expect(caps.policyErrorsUseModernFormat).toBe(true); - }); - - test("0.26.1 has none of the modern capabilities", () => { - const caps = capabilitiesFor(parseServerVersion("0.26.1")); - expect(caps.preAuthKeysHaveStableIds).toBe(false); - expect(caps.nodeTagsAreFlat).toBe(false); - expect(caps.nodeOwnerIsImmutable).toBe(false); - expect(caps.policyErrorsUseModernFormat).toBe(false); }); });