diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index eca0f9b880d6..e8d53ac85c4b 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -251,6 +251,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 changes to the configuration schema: + - `services.headplane.settings.oidc.redirect_uri` is deprecated. 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` is deprecated. Headplane 0.6.2 still accepts it to migrate the old JSON user database into the new internal SQL database. + - `services.headplane.settings.oidc.strict_validation` is deprecated and has no effect. + - `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` is deprecated and has 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..19e131fcab8d 100644 --- a/nixos/modules/services/networking/headplane.nix +++ b/nixos/modules/services/networking/headplane.nix @@ -64,6 +64,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 +94,32 @@ 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"; }; + }; }; default = { }; @@ -185,6 +211,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 +230,25 @@ 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"; + description = "Optionally change the name of the agent in the Tailnet."; }; cache_ttl = mkOption { - type = types.ints.positive; - default = 180000; + type = types.nullOr types.int; + default = null; 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. + Deprecated cache TTL for the agent. This option is accepted + by Headplane 0.6.2 but has no effect. ''; }; cache_path = mkOption { type = types.path; 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 +295,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 +325,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 +342,47 @@ 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. + ''; + }; + + 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. ''; - example = lib.literalExpression "config.sops.secrets.headscale_api_key.path"; }; redirect_uri = 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. + Deprecated OIDC redirect URI. Use services.headplane.settings.server.base_url + instead; Headplane derives the callback URL from it. ''; - example = "https://headscale.example.com/admin/oidc/callback"; + example = "https://headplane.example.com/admin/oidc/callback"; }; - user_storage_file = mkOption { - type = types.path; - default = "/var/lib/headplane/users.json"; + strict_validation = mkOption { + type = types.nullOr types.bool; + default = null; description = '' - Path to a file containing the users and their permissions for Headplane. + Deprecated OIDC validation setting. This option is accepted + by Headplane 0.6.2 but has no effect. ''; - example = "/var/lib/headplane/users.json"; }; profile_picture_source = mkOption { @@ -346,12 +394,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"; @@ -387,6 +429,16 @@ in description = "Custom userinfo endpoint URL."; example = "https://provider.example.com/userinfo"; }; + + user_storage_file = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Deprecated path to the pre-0.6.2 JSON user database. + Headplane uses this once to migrate users into its internal database. + ''; + example = "/var/lib/headplane/users.json"; + }; }; } ); @@ -400,6 +452,33 @@ in }; config = mkIf cfg.enable { + warnings = + lib.optionals (cfg.settings.oidc != null && cfg.settings.oidc.redirect_uri != null) [ + '' + services.headplane.settings.oidc.redirect_uri is deprecated by Headplane 0.6.2. + Use services.headplane.settings.server.base_url instead; Headplane derives + the OIDC callback URL from it. + '' + ] + ++ lib.optionals (cfg.settings.oidc != null && cfg.settings.oidc.strict_validation != null) [ + '' + services.headplane.settings.oidc.strict_validation is deprecated and has no effect + in Headplane 0.6.2. + '' + ] + ++ lib.optionals (cfg.settings.oidc != null && cfg.settings.oidc.user_storage_file != null) [ + '' + services.headplane.settings.oidc.user_storage_file is deprecated. Headplane 0.6.2 + uses it only to migrate the pre-0.6.2 JSON user database into the internal database. + '' + ] + ++ lib.optionals (agentSettings != null && agentSettings.cache_ttl != null) [ + '' + services.headplane.settings.integration.agent.cache_ttl is deprecated and has no + effect in Headplane 0.6.2. + '' + ]; + assertions = [ { assertion = config.services.headscale.enable; @@ -422,23 +501,20 @@ 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 56c496713f21..e49f415dc2a1 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -726,6 +726,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..331012b12fdd --- /dev/null +++ b/nixos/tests/headplane.nix @@ -0,0 +1,69 @@ +{ lib, pkgs, ... }: +let + cookieSecret = pkgs.writeText "cookie-secret" "01234567890123456789012345678901"; + oidcClientSecret = pkgs.writeText "oidc-client-secret" "client-secret"; + headscaleApiKey = pkgs.writeText "headscale-api-key" "headscale-api-key"; +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"; + }; + settings.oidc = { + enabled = false; + issuer = "https://provider.example.com/issuer-url"; + client_id = "your-client-id"; + client_secret_path = oidcClientSecret; + headscale_api_key_path = headscaleApiKey; + redirect_uri = "http://127.0.0.1:3000/admin/oidc/callback"; + strict_validation = true; + user_storage_file = "/var/lib/headplane/users.json"; + }; + }; + }; + + 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 and deprecated-compatible options"): + config = machine.succeed("cat /etc/headplane/config.yaml") + for key in ["base_url:", "cookie_max_age:", "cookie_domain:", "redirect_uri:", "strict_validation:", "user_storage_file:"]: + assert key in config, f"{key} missing from rendered config" + + with subtest("unsupported options are not rendered"): + config = machine.succeed("cat /etc/headplane/config.yaml") + for key in ["info_secret_path:", "subject_claims:", "allow_weak_rsa_keys:", "use_end_session:", "end_session_endpoint:", "post_logout_redirect_uri:"]: + assert key not in config, f"unsupported 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";