From 47db95888ad97161fa42243f4a062f57d2e3a5cc Mon Sep 17 00:00:00 2001 From: Erik Parawell Date: Fri, 1 May 2026 00:56:40 -0700 Subject: [PATCH] headplane: 0.6.1 -> 0.6.2 Atomic bump of headplane and headplane-agent to 0.6.2. Reworks the NixOS module to track upstream config changes: - Adds new 0.6.2 options for server (base_url, cookie_max_age, cookie_domain) and OIDC (PKCE, end-session, subject claims). - Migrates secret options to use _path variants for secure file loading. - Uses mkRemovedOptionModule for truly removed options (redirect_uri, user_storage_file, strict_validation, cache_ttl). - Hardens the NixOS test to verify rendered config and session cookie issuance. - Exposes headplane-agent and tests under headplane.passthru. --- .../manual/release-notes/rl-2605.section.md | 7 + .../modules/services/networking/headplane.nix | 235 +++++++++++++----- nixos/tests/all-tests.nix | 1 + nixos/tests/headplane.nix | 57 +++++ pkgs/by-name/he/headplane-agent/package.nix | 4 +- pkgs/by-name/he/headplane/package.nix | 13 +- 6 files changed, 247 insertions(+), 70 deletions(-) create mode 100644 nixos/tests/headplane.nix diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index a652c7a3657c..e95eb54daab1 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -245,6 +245,13 @@ of pulling the upstream container image from Docker Hub. If you want the old beh - Ethercalc and its associated module have been removed, as the package is unmaintained and cannot be installed from source with npm now. +- `services.headplane` has been updated to 0.6.2, which introduces several breaking changes to the configuration schema: + - `services.headplane.settings.oidc.redirect_uri` has been removed. Use `services.headplane.settings.server.base_url` instead; the OIDC redirect URI is now automatically derived from it. Ensure `base_url` is the bare host URL without the `/admin` suffix. + - `services.headplane.settings.oidc.user_storage_file` has been removed. Users are now stored in an internal SQL database. + - `services.headplane.settings.oidc.strict_validation` has been removed. + - `services.headplane.settings.oidc.token_endpoint_auth_method` now defaults to `null` (auto-detection), which typically falls back to `client_secret_basic`. Previous versions defaulted to `client_secret_post`. + - `services.headplane.settings.integration.agent.cache_ttl` has been removed as it had no effect in 0.6.2. + - `services.immich` no longer supports pgvecto.rs since the package has been removed from nixpkgs. As a result, options `services.immich.database.enableVectors` and `services.immich.database.enableVectorchord` have been removed, and VectorChord is now always used. If you have not completed the migration yet, ensure you completely remove the extension from your database before upgrading by following the [migration guide](https://github.com/NixOS/nixpkgs/blob/nixos-25.11/nixos/modules/services/web-apps/immich.md#migrating-from-pgvecto-rs-to-vectorchord-pre-2511-installations-module-services-immich-vectorchord-migration). diff --git a/nixos/modules/services/networking/headplane.nix b/nixos/modules/services/networking/headplane.nix index 5fac2f04e37d..5fa0d6a6df20 100644 --- a/nixos/modules/services/networking/headplane.nix +++ b/nixos/modules/services/networking/headplane.nix @@ -33,6 +33,37 @@ let settingsFile = settingsFormat.generate "headplane-config.yaml" (filterSettings settings); in { + imports = [ + (lib.mkRemovedOptionModule + [ + "services.headplane" + "settings" + "oidc" + "redirect_uri" + ] + "Use services.headplane.settings.server.base_url instead; the OIDC redirect URI is now derived from it. Do not include the /admin suffix." + ) + (lib.mkRemovedOptionModule [ + "services.headplane" + "settings" + "oidc" + "user_storage_file" + ] "Users are now stored in the internal database.") + (lib.mkRemovedOptionModule [ + "services.headplane" + "settings" + "oidc" + "strict_validation" + ] "This option has no effect in Headplane 0.6.2 and later.") + (lib.mkRemovedOptionModule [ + "services.headplane" + "settings" + "integration" + "agent" + "cache_ttl" + ] "This option has no effect in Headplane 0.6.2 and later.") + ]; + options.services.headplane = { enable = mkEnableOption "Headplane"; package = mkPackageOption pkgs "headplane" { }; @@ -64,6 +95,16 @@ in description = "The port to listen on."; }; + base_url = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + The base URL for Headplane. Used for OIDC redirect callback URL + detection. Should not include the dashboard prefix (/admin). + ''; + example = "https://headplane.example.com"; + }; + cookie_secret_path = mkOption { type = types.nullOr types.path; default = null; @@ -84,16 +125,43 @@ in ''; }; + cookie_max_age = mkOption { + type = types.ints.positive; + default = 86400; + description = "The maximum age of the session cookie in seconds."; + }; + + cookie_domain = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + Restrict the cookie to a specific domain. + This may not work as expected if not using a reverse proxy. + ''; + example = "example.com"; + }; + data_path = mkOption { type = types.path; default = "/var/lib/headplane"; description = '' The path to persist Headplane specific data. All data going forward is stored in this directory, including the internal database and any cache related files. - Data formats prior to 0.6.1 will automatically be migrated. ''; example = "/var/lib/headplane"; }; + + info_secret_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the info secret. + Allows access to certain debug endpoints that may expose + sensitive information about your Headplane instance. + If not set, these endpoints will be disabled. + ''; + example = lib.literalExpression "config.sops.secrets.headplane_info_secret.path"; + }; }; }; default = { }; @@ -185,6 +253,15 @@ in ''; }; + pre_authkey_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing a Headscale pre-auth key for the agent. + ''; + example = lib.literalExpression "config.sops.secrets.headplane_pre_authkey.path"; + }; + executable_path = mkOption { type = types.path; readOnly = true; @@ -195,36 +272,16 @@ in ''; }; - pre_authkey_path = mkOption { - type = types.nullOr types.path; - default = null; - description = '' - Path to a file containing the agent preauth key. - 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. - ''; - example = lib.literalExpression "config.sops.secrets.agent_pre_authkey.path"; - }; - host_name = mkOption { type = types.str; default = "headplane-agent"; - description = "Optionally change the name of the agent in the Tailnet"; - }; - - cache_ttl = mkOption { - type = types.ints.positive; - default = 180000; - description = '' - How long to cache agent information (in milliseconds). - If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale. - ''; + description = "Optionally change the name of the agent in the Tailnet."; }; cache_path = mkOption { - type = types.path; + type = types.str; default = "/var/lib/headplane/agent_cache.json"; - description = "Where to store the agent cache."; + description = "The path to store the agent's cache."; }; work_dir = mkOption { @@ -271,6 +328,15 @@ in type = types.nullOr ( types.submodule { options = { + enabled = mkOption { + type = types.bool; + default = true; + description = '' + Explicitly control OIDC availability. + Set to false to define OIDC config without enabling it. + ''; + }; + issuer = mkOption { type = types.str; description = "URL to OpenID issuer."; @@ -292,6 +358,16 @@ in example = lib.literalExpression "config.sops.secrets.oidc_client_secret.path"; }; + headscale_api_key_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the Headscale API key. + Required for OIDC authentication. + ''; + example = lib.literalExpression "config.sops.secrets.headscale_api_key.path"; + }; + disable_api_key_login = mkOption { type = types.bool; default = false; @@ -299,42 +375,81 @@ in }; token_endpoint_auth_method = mkOption { - type = types.enum [ - "client_secret_post" - "client_secret_basic" - "client_secret_jwt" - ]; - default = "client_secret_post"; - description = "The token endpoint authentication method."; - }; - - headscale_api_key_path = mkOption { - type = types.nullOr types.path; + type = types.nullOr ( + types.enum [ + "client_secret_post" + "client_secret_basic" + "client_secret_jwt" + ] + ); default = null; description = '' - Path to a file containing the Headscale API key. - Required when `services.headplane.settings.oidc` is set. + The token endpoint authentication method. + If not set, Headplane will auto-detect the best method + and fall back to client_secret_basic. ''; - example = lib.literalExpression "config.sops.secrets.headscale_api_key.path"; }; - redirect_uri = mkOption { + use_pkce = mkOption { + type = types.bool; + default = false; + description = '' + Whether to use PKCE when authenticating users. + Your OIDC provider must support PKCE and it must be enabled on the client. + ''; + }; + + use_end_session = mkOption { + type = types.bool; + default = false; + description = '' + Enable RP-initiated logout. When true, /logout redirects the user + to the IdP's end_session_endpoint. The post_logout_redirect_uri + MUST be pre-registered in your OIDC client configuration. + ''; + }; + + end_session_endpoint = mkOption { type = types.nullOr types.str; default = null; description = '' - This should point to your publicly accessible URL - for your Headplane instance with /admin/oidc/callback. + Override the auto-discovered end_session_endpoint, or supply + one if your provider does not advertise it via discovery. ''; - example = "https://headscale.example.com/admin/oidc/callback"; + example = "https://provider.example.com/logout"; }; - user_storage_file = mkOption { - type = types.path; - default = "/var/lib/headplane/users.json"; + post_logout_redirect_uri = mkOption { + type = types.nullOr types.str; + default = null; description = '' - Path to a file containing the users and their permissions for Headplane. + Where the identity provider should redirect after RP-initiated logout. + If unset, Headplane defaults to its own login page. + ''; + example = "https://headplane.example.com/admin/login?s=logout"; + }; + + subject_claims = mkOption { + type = types.nullOr (types.listOf types.str); + default = null; + description = '' + Fallback claims to use when your provider does not return a standard + OIDC `sub` claim. Headplane always checks `sub` first, then each + claim here in order. + ''; + example = [ + "open_id" + "email" + ]; + }; + + allow_weak_rsa_keys = mkOption { + type = types.bool; + default = false; + description = '' + Allow ID token verification with legacy RSA keys smaller than 2048 bits. + Only use as a temporary compatibility workaround. ''; - example = "/var/lib/headplane/users.json"; }; profile_picture_source = mkOption { @@ -346,12 +461,6 @@ in description = "Source for user profile pictures."; }; - strict_validation = mkOption { - type = types.bool; - default = true; - description = "Enable strict validation of OIDC configuration."; - }; - scope = mkOption { type = types.str; default = "openid email profile"; @@ -422,23 +531,19 @@ in via systemd `LoadCredential` or sops-nix. ''; } - { - assertion = - cfg.settings.integration.agent == null - || !cfg.settings.integration.agent.enabled - || cfg.settings.integration.agent.pre_authkey_path != null; - message = '' - services.headplane.settings.integration.agent.pre_authkey_path must be set - when services.headplane.settings.integration.agent.enabled is true. - ''; - } { assertion = cfg.settings.oidc == null || cfg.settings.oidc.headscale_api_key_path != null; message = '' services.headplane.settings.oidc.headscale_api_key_path must be set when services.headplane.settings.oidc is non-null. Headplane's OIDC - flow requires a Headscale API key to mint sessions; upstream config - validation rejects an OIDC block without it. + flow requires a Headscale API key to mint sessions. + ''; + } + { + assertion = agentSettings == null || !agentSettings.enabled || agentSettings.pre_authkey_path != null; + message = '' + services.headplane.settings.integration.agent.pre_authkey_path must be set + when the agent is enabled. ''; } ]; diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index b276d07786bc..383b40bb0ae7 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -724,6 +724,7 @@ in imports = [ ./hbase.nix ]; _module.args.getPackage = pkgs: pkgs.hbase_2_5; }; + headplane = runTest ./headplane.nix; headscale = runTest ./headscale.nix; healthchecks = runTest ./web-apps/healthchecks.nix; hedgedoc = runTest ./hedgedoc.nix; diff --git a/nixos/tests/headplane.nix b/nixos/tests/headplane.nix new file mode 100644 index 000000000000..d11bab00f999 --- /dev/null +++ b/nixos/tests/headplane.nix @@ -0,0 +1,57 @@ +{ lib, pkgs, ... }: +let + cookieSecret = pkgs.writeText "cookie-secret" "01234567890123456789012345678901"; +in +{ + name = "headplane"; + meta.maintainers = with lib.maintainers; [ + igor-ramazanov + stealthbadger747 + ]; + + nodes.machine = { + services.headscale = { + enable = true; + settings = { + server_url = "http://127.0.0.1"; + ip_prefixes = [ "100.64.0.0/10" ]; + dns = { + base_domain = "tailnet"; + override_local_dns = false; + }; + }; + }; + services.headplane = { + enable = true; + settings.server = { + cookie_secret_path = cookieSecret; + cookie_secure = false; + # Exercise the new 0.6.2 server-level options so the test fails + # if their option-name -> YAML-key mapping ever regresses. + base_url = "http://127.0.0.1:3000"; + cookie_max_age = 3600; + cookie_domain = "127.0.0.1"; + }; + }; + }; + + testScript = '' + machine.wait_for_unit("headscale.service") + machine.wait_for_unit("headplane.service") + machine.wait_until_succeeds("curl -sf http://127.0.0.1:3000/admin/login") + + with subtest("rendered config contains new 0.6.2 options"): + config = machine.succeed("cat /etc/headplane/config.yaml") + for key in ["base_url:", "cookie_max_age:", "cookie_domain:"]: + assert key in config, f"{key} missing from rendered config" + + with subtest("removed options are not rendered"): + config = machine.succeed("cat /etc/headplane/config.yaml") + for key in ["redirect_uri:", "user_storage_file:", "strict_validation:", "cache_ttl:"]: + assert key not in config, f"removed option {key} still rendered" + + with subtest("session cookie is set on login page"): + machine.succeed("curl -sf -c /tmp/cj http://127.0.0.1:3000/admin/login") + machine.succeed("test -s /tmp/cj") + ''; +} diff --git a/pkgs/by-name/he/headplane-agent/package.nix b/pkgs/by-name/he/headplane-agent/package.nix index a645d2e576f8..d821e5b94218 100644 --- a/pkgs/by-name/he/headplane-agent/package.nix +++ b/pkgs/by-name/he/headplane-agent/package.nix @@ -7,13 +7,13 @@ buildGoModule (finalAttrs: { pname = "headplane-agent"; __structuredAttrs = true; # Note, if you are upgrading this, you should upgrade headplane at the same time - version = "0.6.1"; + version = "0.6.2"; src = fetchFromGitHub { owner = "tale"; repo = "headplane"; tag = "v${finalAttrs.version}"; - hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4="; + hash = "sha256-2C/Pn2M2aHADtoljSFg9hz6xOaZp6IRI77jjy+LDAgw="; }; vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA="; diff --git a/pkgs/by-name/he/headplane/package.nix b/pkgs/by-name/he/headplane/package.nix index bb42ab31bcee..a596e5918356 100644 --- a/pkgs/by-name/he/headplane/package.nix +++ b/pkgs/by-name/he/headplane/package.nix @@ -3,8 +3,10 @@ fetchFromGitHub, fetchPnpmDeps, git, + headplane-agent, lib, makeWrapper, + nixosTests, nodejs_22, pnpm_10, pnpmConfigHook, @@ -13,13 +15,13 @@ let pname = "headplane"; # Note, if you are upgrading this, you should upgrade headplane-agent at the same time - version = "0.6.1"; - pnpmDepsHash = "sha256-AYfEL3HSRg87I+Y0fkLthFSDWgHTg5u0DBpzn6KBn1Q="; + version = "0.6.2"; + pnpmDepsHash = "sha256-CsmffCo9Se/4oiOqbcuhjPMuGmR2GL+YfcyWgzBTAh8="; src = fetchFromGitHub { owner = "tale"; repo = "headplane"; tag = "v${version}"; - hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4="; + hash = "sha256-2C/Pn2M2aHADtoljSFg9hz6xOaZp6IRI77jjy+LDAgw="; }; headplaneSshWasm = buildGoModule { @@ -104,6 +106,11 @@ stdenv.mkDerivation (finalAttrs: { runHook postInstall ''; + passthru = { + agent = headplane-agent; + tests = { inherit (nixosTests) headplane; }; + }; + meta = { description = "Feature-complete Web UI for Headscale"; homepage = "https://github.com/tale/headplane";