mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 07:48:14 +00:00
feat: bump headscale minimum to 0.27
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user