From 2c57187628cd2d6fed3a909541c749bd5d0284c6 Mon Sep 17 00:00:00 2001 From: Aarnav Tale Date: Thu, 26 Mar 2026 16:47:42 -0400 Subject: [PATCH] feat: switch to a generalized api key config --- app/routes/auth/login/config-error.tsx | 2 +- app/server/config/config-schema.ts | 19 +++++++++----- app/server/index.ts | 33 ++++++++++++++++++++----- config.example.yaml | 34 ++++++++++++-------------- 4 files changed, 56 insertions(+), 32 deletions(-) diff --git a/app/routes/auth/login/config-error.tsx b/app/routes/auth/login/config-error.tsx index 3aaa09b..788416d 100644 --- a/app/routes/auth/login/config-error.tsx +++ b/app/routes/auth/login/config-error.tsx @@ -56,7 +56,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) { node: ( The provided API key for OIDC authentication is invalid. Ensure that{" "} - oidc.headscale_api_key is a valid API key. + headscale.api_key is a valid API key. ), }); diff --git a/app/server/config/config-schema.ts b/app/server/config/config-schema.ts index 2b64c83..2343bed 100644 --- a/app/server/config/config-schema.ts +++ b/app/server/config/config-schema.ts @@ -9,9 +9,9 @@ import { deprecatedField } from "./utils"; export const pathSupportedKeys = [ "server.cookie_secret", + "headscale.api_key", "oidc.client_secret", "oidc.headscale_api_key", - "integration.agent.pre_authkey", ] as const; const serverConfig = type({ @@ -45,6 +45,7 @@ const headscaleConfig = type({ public_url: type("string.url") .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) .optional(), + api_key: "string?", config_path: "string.lower?", config_strict: "boolean = true", dns_records_path: "string.lower?", @@ -58,6 +59,7 @@ const partialHeadscaleConfig = type({ public_url: type("string.url") .pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)) .optional(), + api_key: "string?", config_path: "string.lower?", config_strict: "boolean?", dns_records_path: "string.lower?", @@ -69,7 +71,12 @@ const oidcConfig = type({ issuer: "string.url", client_id: "string", client_secret: "string", - headscale_api_key: "string", + headscale_api_key: type("string") + .pipe((value, ctx) => { + log.warn("config", "%s is deprecated, use headscale.api_key instead", ctx.propString); + return value; + }) + .optional(), use_pkce: "boolean = false", redirect_uri: type("string.url") .pipe((value, ctx) => { @@ -128,21 +135,21 @@ const partialOidcConfig = type({ const agentConfig = type({ enabled: "boolean", host_name: 'string = "headplane-agent"', - pre_authkey: "string", cache_ttl: "number.integer = 180000", - cache_path: 'string = "/var/lib/headplane/agent_cache.json"', executable_path: 'string = "/usr/libexec/headplane/agent"', work_dir: 'string = "/var/lib/headplane/agent"', + pre_authkey: type("unknown").narrow(deprecatedField()).optional(), + cache_path: type("unknown").narrow(deprecatedField()).optional(), }); const partialAgentConfig = type({ enabled: "boolean?", host_name: "string?", - pre_authkey: "string?", cache_ttl: "number.integer?", - cache_path: "string?", executable_path: "string?", work_dir: "string?", + pre_authkey: type("unknown").narrow(deprecatedField()).optional(), + cache_path: type("unknown").narrow(deprecatedField()).optional(), }); const integrationConfig = type({ diff --git a/app/server/index.ts b/app/server/index.ts index 6fe2772..aa1b072 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -11,7 +11,7 @@ import { createDbClient } from "./db/client.server"; import { createHeadscaleInterface } from "./headscale/api"; import { loadHeadscaleConfig } from "./headscale/config-loader"; import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store"; -import { createHeadplaneAgent } from "./hp-agent"; +import { createAgentManager } from "./hp-agent"; import { createAuthService } from "./web/auth"; declare global { @@ -36,10 +36,31 @@ try { } const db = await createDbClient(join(config.server.data_path, "hp_persist.db")); -const agents = await createHeadplaneAgent(config.integration?.agent, config.headscale.url, db); - const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path); +// Resolve the Headscale API key: headscale.api_key takes precedence, +// falling back to the deprecated oidc.headscale_api_key for compatibility. +const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key; + +const agents = headscaleApiKey + ? await createAgentManager( + config.integration?.agent, + config.headscale.url, + headscaleApiKey, + (user, ephemeral, reusable, expiration, aclTags) => + hsApi + .getRuntimeClient(headscaleApiKey) + .createPreAuthKey(user, ephemeral, reusable, expiration, aclTags), + () => hsApi.getRuntimeClient(headscaleApiKey).getUsers(), + db, + ) + : (() => { + if (config.integration?.agent?.enabled) { + log.warn("agent", "Agent is enabled but no headscale.api_key is configured"); + } + return undefined; + })(); + // We also use this file to load anything needed by the react router code. // These are usually per-request things that we need access to, like the // helper that can issue and revoke cookies. @@ -80,13 +101,13 @@ const appLoadContext = { agents, integration: await loadIntegration(config.integration), oidc: - config.oidc && config.oidc.enabled !== false + config.oidc && config.oidc.enabled !== false && headscaleApiKey ? { - apiKey: config.oidc.headscale_api_key, + apiKey: headscaleApiKey, connector: createLazyOidcConnector( config.server.base_url, config.oidc, - hsApi.getRuntimeClient(config.oidc.headscale_api_key), + hsApi.getRuntimeClient(headscaleApiKey), ), } : undefined, diff --git a/config.example.yaml b/config.example.yaml index d2dd7e4..5a2201a 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -70,6 +70,11 @@ headscale: # in the Web UI, but they cannot be changed. config_path: "/etc/headscale/config.yaml" + # The API key used by Headplane for server-side operations. + # This is required for OIDC authentication and the Headplane agent. + # Generate one with: headscale apikeys create + # api_key: "" + # Whether the Headscale configuration should be strictly validated # when reading from `config_path`. If true, Headplane will not interact # with Headscale if there are any issues with the configuration file. @@ -91,26 +96,19 @@ headscale: # Integration configurations for Headplane to interact with Headscale integration: - # The Headplane agent allows retrieving information about nodes - # This allows the UI to display version, OS, and connectivity data - # You will see the Headplane agent in your Tailnet as a node when - # it connects. + # The Headplane agent periodically syncs node information (version, OS, etc.) + # from your Tailnet. It auto-generates ephemeral pre-auth keys using the + # OIDC headscale_api_key, so no manual key configuration is needed. agent: enabled: false - # To connect to your Tailnet, you need to generate a pre-auth key - # This can be done via the web UI or through the `headscale` CLI. - pre_authkey: "" - - # Optionally change the name of the agent in the Tailnet. + # The name of the Headscale user to create pre-auth keys under. + # A user with this name must exist in Headscale. # host_name: "headplane-agent" - # Configure different caching settings. By default, the agent will store - # caches in the path below for a maximum of 1 minute. If you want data - # to update faster, reduce the TTL, but this will increase the frequency - # of requests to Headscale. - # cache_ttl: 60 - # cache_path: /var/lib/headplane/agent_cache.json + # How often to sync node information (in milliseconds). + # Default: 180000 (3 minutes) + # cache_ttl: 180000 # The work_dir represents where the agent will store its data to be able # to automatically reauthenticate with your Tailnet. It needs to be @@ -174,10 +172,8 @@ integration: # The OIDC issuer URL # issuer: "https://accounts.google.com" -# If you are using OIDC, you need to generate an API key -# that can be used to authenticate other sessions when signing in. -# -# This can be done with `headscale apikeys create --expiration 999d` +# DEPRECATED: Use headscale.api_key instead. +# If set, this will be used as a fallback for headscale.api_key. # headscale_api_key: "" # If your OIDC provider does not support discovery (does not have the URL at