Skip to content

Commit d7f1d66

Browse files
committed
feat: bump headscale minimum to 0.27
1 parent 7901f37 commit d7f1d66

11 files changed

Lines changed: 100 additions & 111 deletions

File tree

app/routes/acls/acl-action.ts

Lines changed: 20 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -54,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
5454
throw error;
5555
}
5656

57-
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
58-
// we need to reference other error messages based on API version.
59-
if (context.headscale.capabilities.policyErrorsUseModernFormat) {
60-
if (message.includes("parsing HuJSON:")) {
61-
const cutIndex = message.indexOf("parsing HuJSON:");
62-
const trimmed =
63-
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
57+
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
58+
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
59+
// releases are no longer supported.
60+
if (message.includes("parsing HuJSON:")) {
61+
const cutIndex = message.indexOf("parsing HuJSON:");
62+
const trimmed =
63+
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
6464

65-
return data(
66-
{
67-
success: false,
68-
error: trimmed,
69-
policy: undefined,
70-
updatedAt: undefined,
71-
},
72-
400,
73-
);
74-
}
75-
76-
if (message.includes("parsing policy from bytes:")) {
77-
const cutIndex = message.indexOf("parsing policy from bytes:");
78-
const trimmed =
79-
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
80-
81-
return data(
82-
{
83-
success: false,
84-
error: trimmed,
85-
policy: undefined,
86-
updatedAt: undefined,
87-
},
88-
400,
89-
);
90-
}
91-
} else {
92-
// Pre-0.27.0 error messages
93-
if (message.includes("parsing hujson")) {
94-
const cutIndex = message.indexOf("err: hujson:");
95-
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
96-
97-
return data(
98-
{
99-
success: false,
100-
error: trimmed,
101-
policy: undefined,
102-
updatedAt: undefined,
103-
},
104-
400,
105-
);
106-
}
65+
return data(
66+
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
67+
400,
68+
);
69+
}
10770

108-
if (message.includes("unmarshalling policy")) {
109-
const cutIndex = message.indexOf("err:");
110-
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
71+
if (message.includes("parsing policy from bytes:")) {
72+
const cutIndex = message.indexOf("parsing policy from bytes:");
73+
const trimmed =
74+
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
11175

112-
return data(
113-
{
114-
success: false,
115-
error: trimmed,
116-
policy: undefined,
117-
updatedAt: undefined,
118-
},
119-
400,
120-
);
121-
}
76+
return data(
77+
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
78+
400,
79+
);
12280
}
12381
}
12482

app/server/headscale/api/capabilities.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -36,20 +36,12 @@ export interface Capabilities {
3636
* 0.28.0+.
3737
*/
3838
readonly nodeOwnerIsImmutable: boolean;
39-
40-
/**
41-
* Policy parse errors use the modern `parsing HuJSON:` /
42-
* `parsing policy from bytes:` prefixes. Pre-0.27 emitted
43-
* `parsing hujson` / `unmarshalling policy`. Introduced in 0.27.0.
44-
*/
45-
readonly policyErrorsUseModernFormat: boolean;
4639
}
4740

4841
export function capabilitiesFor(version: ServerVersion): Capabilities {
4942
return {
5043
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
5144
nodeTagsAreFlat: gte(version, "0.28.0"),
5245
nodeOwnerIsImmutable: gte(version, "0.28.0"),
53-
policyErrorsUseModernFormat: gte(version, "0.27.0"),
5446
};
5547
}

app/server/headscale/api/index.ts

Lines changed: 46 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,26 @@
11
// MARK: Headscale API
22
//
3-
// The public entry point for talking to a Headscale server. Boot
4-
// detects the server version via `GET /version` (unauthenticated,
5-
// available since Headscale 0.26.0), derives a typed `Capabilities`
6-
// object, and returns a `Headscale` value that constructs
7-
// authenticated `HeadscaleClient`s on demand.
3+
// The public entry point for talking to a Headscale server. At boot
4+
// we try `GET /version` (unauthenticated, present since Headscale
5+
// 0.27.0 — the minimum version Headplane supports) to derive a
6+
// typed `Capabilities` object. Boot outcomes:
7+
//
8+
// - success: parse the response, derive capabilities, done.
9+
// - 404: Headscale is reachable but predates 0.27.0 and is no
10+
// longer supported. Log an error and keep retrying so an
11+
// upgrade is picked up without a Headplane restart.
12+
// - any other failure (network, 5xx, parse): Headplane still
13+
// boots with `version = unknown` (capabilities-permissive) and
14+
// a background retry. This handles docker-compose start-order
15+
// races without making the whole process unhappy.
16+
//
17+
// Capabilities are always derived from `version`; once detection
18+
// finishes there's no further state to track.
819

920
import log from "~/utils/log";
1021

1122
import { type Capabilities, capabilitiesFor } from "./capabilities";
23+
import { isDataWithApiError } from "./error-client";
1224
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
1325
import { makeNodeApi, type NodeApi } from "./resources/nodes";
1426
import { makePolicyApi, type PolicyApi } from "./resources/policy";
@@ -17,6 +29,8 @@ import { makeUserApi, type UserApi } from "./resources/users";
1729
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
1830
import { createTransport } from "./transport";
1931

32+
const MIN_SUPPORTED_VERSION = "0.27.0";
33+
2034
export interface Headscale {
2135
readonly version: ServerVersion;
2236
readonly capabilities: Capabilities;
@@ -58,24 +72,39 @@ export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Hea
5872
let retryTimer: ReturnType<typeof setTimeout> | undefined;
5973
let disposed = false;
6074

75+
function settle(parsed: ServerVersion) {
76+
version = parsed;
77+
capabilities = capabilitiesFor(parsed);
78+
detected = true;
79+
if (parsed.unknown) {
80+
log.warn(
81+
"api",
82+
"Could not parse Headscale version %s, assuming newest known capabilities",
83+
parsed.raw,
84+
);
85+
} else {
86+
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
87+
}
88+
}
89+
6190
async function detectOnce(): Promise<boolean> {
6291
try {
6392
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
64-
const parsed = parseServerVersion(raw);
65-
version = parsed;
66-
capabilities = capabilitiesFor(parsed);
67-
detected = true;
68-
if (parsed.unknown) {
69-
log.warn(
93+
settle(parseServerVersion(raw));
94+
return true;
95+
} catch (error) {
96+
// 404 means Headscale is reachable but predates 0.27.0 (where
97+
// /version was introduced). That server is below the supported
98+
// floor, so we don't settle — leave capabilities permissive and
99+
// keep retrying in case the operator upgrades in place.
100+
if (isDataWithApiError(error) && error.data.statusCode === 404) {
101+
log.error(
70102
"api",
71-
"Could not parse Headscale version %s, assuming newest known capabilities",
72-
raw,
103+
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
104+
MIN_SUPPORTED_VERSION,
73105
);
74-
} else {
75-
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
106+
return false;
76107
}
77-
return true;
78-
} catch (error) {
79108
log.debug("api", "Headscale /version probe failed: %s", String(error));
80109
return false;
81110
}

app/server/headscale/api/server-version.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// Parses the response from Headscale's `GET /version` endpoint into a
44
// structured value that capability checks can reason about. The
5-
// endpoint exists in every Headscale release we support (0.26.0+),
5+
// endpoint exists in every Headscale release we support (0.27.0+) and
66
// returns a plain semver-like string such as `v0.28.0`, `v0.28.0-beta.1`,
77
// or `dev` for untagged builds.
88
//

docs/install/docker.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ with Docker.
1818
## Prerequisites
1919

2020
- Docker and Docker Compose
21-
- Headscale version 0.26.0 or later installed and running
21+
- Headscale version 0.27.0 or later installed and running
2222
- A [completed configuration file](./index.md#configuration) for Headplane.
2323

2424
## Installation
@@ -132,7 +132,7 @@ services:
132132
# Read-only access to the Docker socket (or a proxy)
133133
- "/var/run/docker.sock:/var/run/docker.sock:ro"
134134
headscale:
135-
image: headscale/headscale:0.26.0
135+
image: headscale/headscale:0.27.1
136136
container_name: headscale
137137
restart: unless-stopped
138138
command: serve
@@ -239,7 +239,7 @@ services:
239239
- "traefik.http.routers.headplane.entrypoints=websecure"
240240
- "traefik.http.routers.headplane.tls=true"
241241
headscale:
242-
image: headscale/headscale:0.26.0
242+
image: headscale/headscale:0.27.1
243243
container_name: headscale
244244
restart: unless-stopped
245245
command: serve

docs/install/limited-mode.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ advanced features, making it suitable for local testing and development.
1919
## Prerequisites
2020

2121
- Docker (and optionally Docker Compose)
22-
- Headscale version 0.26.0 or later installed and running
22+
- Headscale version 0.27.0 or later installed and running
2323
- A [completed configuration file](/index.md#configuration) for Headplane.
2424

2525
## Installation

docs/install/native-mode.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ or prefer to avoid containers.
2020
- A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora)
2121
- Go version 1.25.1 installed (only needed to build Headplane)
2222
- Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed
23-
- Headscale version 0.26.0 or later installed and running
23+
- Headscale version 0.27.0 or later installed and running
2424
- A [completed configuration file](./index.md#configuration) for Headplane.
2525

2626
Before building and running Headplane, ensure that the directory defined in

tests/integration/api/versions.test.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,9 @@ describe.for(HS_VERSIONS)("Headscale %s: Runtime Client", (version) => {
2424
expect(bootstrapper.capabilities.preAuthKeysHaveStableIds).toBe(gte(v, "0.28.0"));
2525
expect(bootstrapper.capabilities.nodeTagsAreFlat).toBe(gte(v, "0.28.0"));
2626
expect(bootstrapper.capabilities.nodeOwnerIsImmutable).toBe(gte(v, "0.28.0"));
27-
expect(bootstrapper.capabilities.policyErrorsUseModernFormat).toBe(gte(v, "0.27.0"));
28-
// The known version table only has 0.26+; if a future version is added
29-
// before this test is updated, surface that explicitly rather than passing.
30-
const known: Version[] = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"];
27+
// If a future version is added to HS_VERSIONS before this test is
28+
// updated, surface that explicitly rather than passing silently.
29+
const known: Version[] = ["0.27.0", "0.27.1", "0.28.0"];
3130
if (!known.includes(version)) {
3231
context.skip();
3332
}

tests/integration/setup/env.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
66
// The set of Headscale versions integration tests run against. Listed
77
// explicitly (rather than derived from a generated manifest) so the
88
// supported version matrix lives next to the code that uses it.
9-
export const HS_VERSIONS = ["0.26.1", "0.27.0", "0.27.1", "0.28.0"] as const;
9+
export const HS_VERSIONS = ["0.27.0", "0.27.1", "0.28.0"] as const;
1010
export type Version = (typeof HS_VERSIONS)[number];
1111

1212
interface VersionStateEntry {

tests/unit/headscale/headscale-resilience.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,29 @@ describe("createHeadscale boot-time resilience", () => {
7777
// Should not throw, and should default to "unknown" with permissive caps.
7878
expect(headscale.version.unknown).toBe(true);
7979
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(true);
80-
expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true);
80+
await headscale.dispose();
81+
});
82+
83+
test("a 404 from /version is treated as below the supported floor and keeps retrying", async () => {
84+
const probe = await startVersionServer(() => new Response("not found", { status: 404 }));
85+
86+
const headscale = await createHeadscale({ url: probe.url, retryIntervalMs: 25 });
87+
// 404 = pre-0.27 Headscale. We do NOT settle on an inferred version;
88+
// capabilities stay permissive and the background retry keeps probing
89+
// so an in-place upgrade is picked up without a Headplane restart.
90+
expect(headscale.version.unknown).toBe(true);
91+
92+
probe.setResponse(() =>
93+
Response.json({ version: "v0.28.0", commit: "x", buildTime: "", go: "", dirty: false }),
94+
);
95+
96+
const deadline = Date.now() + 2000;
97+
while (Date.now() < deadline && headscale.version.unknown) {
98+
await new Promise((r) => setTimeout(r, 25));
99+
}
100+
101+
expect(headscale.version.unknown).toBe(false);
102+
expect(headscale.version.raw).toBe("v0.28.0");
81103
await headscale.dispose();
82104
});
83105

@@ -100,7 +122,6 @@ describe("createHeadscale boot-time resilience", () => {
100122
expect(headscale.version.unknown).toBe(false);
101123
expect(headscale.version.raw).toBe("v0.27.1");
102124
expect(headscale.capabilities.preAuthKeysHaveStableIds).toBe(false);
103-
expect(headscale.capabilities.policyErrorsUseModernFormat).toBe(true);
104125
await headscale.dispose();
105126
});
106127

0 commit comments

Comments
 (0)