feat: bump headscale minimum to 0.27

This commit is contained in:
Aarnav Tale
2026-05-30 17:32:00 -04:00
parent 7901f37002
commit d7f1d665a4
11 changed files with 101 additions and 112 deletions
+20 -62
View File
@@ -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,
);
}
}
-8
View File
@@ -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"),
};
}
+47 -18
View File
@@ -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<Hea
let retryTimer: ReturnType<typeof setTimeout> | 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<boolean> {
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;
}
+1 -1
View File
@@ -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.
//
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+3 -4
View File
@@ -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();
}
+1 -1
View File
@@ -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 {
@@ -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();
});
+1 -11
View File
@@ -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);
});
});