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
920import log from "~/utils/log" ;
1021
1122import { type Capabilities , capabilitiesFor } from "./capabilities" ;
23+ import { isDataWithApiError } from "./error-client" ;
1224import { type ApiKeyApi , makeApiKeyApi } from "./resources/api-keys" ;
1325import { makeNodeApi , type NodeApi } from "./resources/nodes" ;
1426import { makePolicyApi , type PolicyApi } from "./resources/policy" ;
@@ -17,6 +29,8 @@ import { makeUserApi, type UserApi } from "./resources/users";
1729import { formatServerVersion , parseServerVersion , type ServerVersion } from "./server-version" ;
1830import { createTransport } from "./transport" ;
1931
32+ const MIN_SUPPORTED_VERSION = "0.27.0" ;
33+
2034export 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 }
0 commit comments