From c59632a0b7228e41aa7ca6193fbd7db5b66c0cac Mon Sep 17 00:00:00 2001 From: Igor Ramazanov Date: Sun, 27 Jul 2025 02:20:09 +0000 Subject: [PATCH] feat: nix: match NixOS module to the config schema --- nix/module.nix | 291 ++++++++++++++++++++++++++----------------------- 1 file changed, 157 insertions(+), 134 deletions(-) diff --git a/nix/module.nix b/nix/module.nix index 8680677..43af525 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -6,63 +6,61 @@ }: let inherit (lib) - attrsToList - listToAttrs - map + filterAttrs + filterAttrsRecursive mkEnableOption mkIf mkOption mkPackageOption - typeOf + recursiveUpdate types + updateManyAttrsByPath ; cfg = config.services.headplane; settingsFormat = pkgs.formats.yaml {}; - settingsFile = settingsFormat.generate "headplane-config.yaml" cfg.settings; - agentEnv = listToAttrs (map (n: { - name = n.name; - value = - if ((typeOf n.value) == "bool") - then - ( - if (n.value) - then "true" - else "false" - ) - else n.value; - }) (attrsToList cfg.agent.settings)); + settingsWithAgentExecutablePath = recursiveUpdate cfg.settings { + integration.agent.executable_path = "${cfg.settings.integration.agent.package}/bin/hp_agent"; + }; + settingsWithoutAgentPackage = + updateManyAttrsByPath [ + { + path = ["integration" "agent"]; + update = old: filterAttrs (key: value: key != "package") old; + } + ] + settingsWithAgentExecutablePath; + settingsWithoutNulls = filterAttrsRecursive (key: value: value != null) settingsWithoutAgentPackage; + settingsFile = settingsFormat.generate "headplane-config.yaml" settingsWithoutNulls; in { options.services.headplane = { enable = mkEnableOption "headplane"; package = mkPackageOption pkgs "headplane" {}; - settings = lib.mkOption { + settings = mkOption { description = '' Headplane configuration options. Generates a YAML config file. See: https://github.com/tale/headplane/blob/main/config.example.yaml ''; - type = lib.types.submodule { - freeformType = settingsFormat.type; - + type = types.submodule { options = { - server = lib.mkOption { - type = lib.types.submodule { + server = mkOption { + type = types.submodule { options = { - host = lib.mkOption { - type = lib.types.str; + host = mkOption { + type = types.str; default = "127.0.0.1"; description = "The host address to bind to."; example = "0.0.0.0"; }; - port = lib.mkOption { - type = lib.types.port; + port = mkOption { + type = types.port; default = 3000; description = "The port to listen on."; }; - cookie_secret_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; + cookie_secret_path = mkOption { + type = types.nullOr types.path; default = null; description = '' Path to a file containing the cookie secret. @@ -71,8 +69,8 @@ in { example = "config.sops.secrets.headplane_cookie.path"; }; - cookie_secure = lib.mkOption { - type = lib.types.bool; + cookie_secure = mkOption { + type = types.bool; default = true; description = '' Should the cookies only work over HTTPS? @@ -81,33 +79,15 @@ in { ''; }; - agent = lib.mkOption { - type = lib.types.submodule { - options = { - authkey_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; - default = null; - description = '' - Path to a file containing the agent auth key. - ''; - example = "config.sops.secrets.agent_authkey.path"; - }; - - ttl = lib.mkOption { - type = lib.types.int; - default = 180000; - description = "How long to cache agent information (in milliseconds)."; - }; - - cache_path = lib.mkOption { - type = lib.types.str; - default = "/var/lib/headplane/agent_cache.json"; - description = "Where to store the agent cache."; - }; - }; - }; - default = {}; - description = "Agent configuration for the Headplane agent."; + 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"; }; }; }; @@ -115,11 +95,11 @@ in { description = "Server configuration for Headplane web application."; }; - headscale = lib.mkOption { - type = lib.types.submodule { + headscale = mkOption { + type = types.submodule { options = { - url = lib.mkOption { - type = lib.types.str; + url = mkOption { + type = types.str; default = "http://127.0.0.1:8080"; description = '' The URL to your Headscale instance. @@ -130,8 +110,8 @@ in { example = "https://headscale.example.com"; }; - tls_cert_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; + tls_cert_path = mkOption { + type = types.nullOr types.path; default = null; description = '' Path to a file containing the TLS certificate. @@ -139,17 +119,15 @@ in { example = "config.sops.secrets.tls_cert.path"; }; - tls_key_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; + public_url = mkOption { + type = types.nullOr types.str; default = null; - description = '' - Path to a file containing the TLS private key. - ''; - example = "config.sops.secrets.tls_key.path"; + description = "Public URL if differrent. This affects certain parts of the web UI."; + example = "https://headscale.example.com"; }; - config_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; + config_path = mkOption { + type = types.nullOr types.path; default = null; description = '' Path to the Headscale configuration file. @@ -160,8 +138,8 @@ in { example = "/etc/headscale/config.yaml"; }; - config_strict = lib.mkOption { - type = lib.types.bool; + config_strict = mkOption { + type = types.bool; default = true; description = '' Headplane internally validates the Headscale configuration @@ -169,20 +147,93 @@ in { If you want to disable this validation, set this to false. ''; }; + + dns_records_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records. + Ensure that the file is both readable and writable by the Headplane process. + When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes. + ''; + example = "/var/lib/headplane/extra_records.json"; + }; }; }; default = {}; description = "Headscale specific settings for Headplane integration."; }; - integration = lib.mkOption { - type = lib.types.submodule { + integration = mkOption { + type = types.submodule { options = { - proc = lib.mkOption { - type = lib.types.submodule { + agent = mkOption { + type = types.submodule { options = { - enabled = lib.mkOption { - type = lib.types.bool; + enabled = mkOption { + type = types.bool; + default = false; + description = '' + 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. + ''; + }; + + 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 = "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.int; + 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. + ''; + }; + + cache_path = mkOption { + type = types.path; + default = "/var/lib/headplane/agent_cache.json"; + description = "Where to store the agent cache."; + }; + + work_dir = mkOption { + type = types.path; + default = "/var/lib/headplane/agent"; + description = '' + Do not change this unless you are running a custom deployment. + The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet. + It needs to be writable by the user running the Headplane process. + ''; + }; + + package = mkPackageOption pkgs "headplane-agent" {}; + }; + }; + default = {}; + description = "Agent configuration for the Headplane agent."; + }; + + proc = mkOption { + type = types.submodule { + options = { + enabled = mkOption { + type = types.bool; default = true; description = '' Enable "Native" integration that works when Headscale and @@ -202,25 +253,25 @@ in { description = "Integration configurations for Headplane to interact with Headscale."; }; - oidc = lib.mkOption { - type = lib.types.submodule { + oidc = mkOption { + type = types.submodule { options = { - issuer = lib.mkOption { - type = lib.types.str; + issuer = mkOption { + type = types.str; default = ""; description = "URL to OpenID issuer."; example = "https://provider.example.com/issuer-url"; }; - client_id = lib.mkOption { - type = lib.types.str; + client_id = mkOption { + type = types.str; default = ""; description = "The client ID for the OIDC client."; example = "your-client-id"; }; - client_secret_path = lib.mkOption { - type = lib.types.nullOr lib.types.path; + client_secret_path = mkOption { + type = types.nullOr types.path; default = null; description = '' Path to a file containing the OIDC client secret. @@ -228,23 +279,24 @@ in { example = "config.sops.secrets.oidc_client_secret.path"; }; - disable_api_key_login = lib.mkOption { - type = lib.types.bool; + disable_api_key_login = mkOption { + type = types.bool; default = false; description = "Whether to disable API key login."; }; - token_endpoint_auth_method = lib.mkOption { - type = lib.types.enum [ + 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 = lib.mkOption { - type = lib.types.nullOr lib.types.path; + headscale_api_key_path = mkOption { + type = types.nullOr types.path; default = null; description = '' Path to a file containing the Headscale API key. @@ -252,8 +304,8 @@ in { example = "config.sops.secrets.headscale_api_key.path"; }; - redirect_uri = lib.mkOption { - type = lib.types.str; + redirect_uri = mkOption { + type = types.str; default = ""; description = '' This should point to your publicly accessible URL @@ -261,6 +313,15 @@ in { ''; example = "https://headscale.example.com/admin/oidc/callback"; }; + + user_storage_file = mkOption { + type = types.path; + default = "/var/lib/headplane/users.json"; + description = '' + Path to a file containing the users and their permissions for Headplane. + ''; + example = "/var/lib/headplane/users.json"; + }; }; }; default = {}; @@ -270,20 +331,6 @@ in { }; default = {}; }; - - agent = mkOption { - type = types.submodule { - options = { - enable = mkEnableOption "headplane-agent"; - package = mkPackageOption pkgs "headplane-agent" {}; - settings = mkOption { - type = types.attrsOf [types.str types.bool]; - description = "Headplane agent env vars config. See: https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"; - default = {}; - }; - }; - }; - }; }; config = mkIf cfg.enable { @@ -292,30 +339,6 @@ in { etc."headplane/config.yaml".source = "${settingsFile}"; }; - systemd.services.headplane-agent = - mkIf cfg.agent.enable - { - description = "Headplane side-running agent"; - - wantedBy = ["multi-user.target"]; - after = ["headplane.service"]; - requires = ["headplane.service"]; - - environment = agentEnv; - - serviceConfig = { - User = config.services.headscale.user; - Group = config.services.headscale.group; - - ExecStart = "${pkgs.headplane-agent}/bin/hp_agent"; - Restart = "always"; - RestartSec = 5; - - # TODO: Harden `systemd` security according to the "The Principle of Least Power". - # See: `$ systemd-analyze security headplane-agent`. - }; - }; - systemd.services.headplane = { description = "Headscale Web UI";