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