From 69a7d421c7cb2d5e74ef1625f4f85b5f02eb99d5 Mon Sep 17 00:00:00 2001 From: Igor Ramazanov Date: Wed, 14 Jan 2026 06:16:25 +0000 Subject: [PATCH 1/6] maintainers: `igor-ramazanov` --- maintainers/maintainer-list.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index 2cd35cd6768b..cd1603f9101c 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -11202,6 +11202,12 @@ githubId = 55025025; name = "Feather Lin"; }; + igor-ramazanov = { + email = "personal@igorramazanov.tech"; + github = "igor-ramazanov"; + githubId = 12570166; + name = "Igor Ramazanov"; + }; igsha = { email = "igor.sharonov@gmail.com"; github = "igsha"; From 6758fac0638c4b371786cdab294c60567c359b01 Mon Sep 17 00:00:00 2001 From: Igor Ramazanov Date: Wed, 14 Jan 2026 06:16:53 +0000 Subject: [PATCH 2/6] maintainers: add `stealthbadger747` --- maintainers/maintainer-list.nix | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/maintainers/maintainer-list.nix b/maintainers/maintainer-list.nix index cd1603f9101c..232583976f08 100644 --- a/maintainers/maintainer-list.nix +++ b/maintainers/maintainer-list.nix @@ -25824,6 +25824,12 @@ github = "StayBlue"; githubId = 23127866; }; + stealthbadger747 = { + email = "parawell.erik@gmail.com"; + github = "StealthBadger747"; + githubId = 26052996; + name = "Erik Parawell"; + }; steamwalker = { email = "steamwalker@xs4all.nl"; github = "steamwalker"; From 86c772911f9415a3f2048c9141a679d401b607a2 Mon Sep 17 00:00:00 2001 From: Igor Ramazanov Date: Wed, 14 Jan 2026 06:18:47 +0000 Subject: [PATCH 3/6] headplane: init at 0.6.1 https://github.com/tale/headplane/releases/tag/v0.6.1 Co-authored-by: Erik Parawell --- doc/release-notes/rl-2605.section.md | 2 + pkgs/by-name/he/headplane-agent/package.nix | 37 +++++++ pkgs/by-name/he/headplane/package.nix | 116 ++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 pkgs/by-name/he/headplane-agent/package.nix create mode 100644 pkgs/by-name/he/headplane/package.nix diff --git a/doc/release-notes/rl-2605.section.md b/doc/release-notes/rl-2605.section.md index ed1f18a3c3bb..6d73249aae56 100644 --- a/doc/release-notes/rl-2605.section.md +++ b/doc/release-notes/rl-2605.section.md @@ -294,6 +294,8 @@ gnuradioMinimal.override { } ``` +- Added `headplane` and `headplane-agent` packages. + ## Nixpkgs Library {#sec-nixpkgs-release-26.05-lib} diff --git a/pkgs/by-name/he/headplane-agent/package.nix b/pkgs/by-name/he/headplane-agent/package.nix new file mode 100644 index 000000000000..e12db5adecf7 --- /dev/null +++ b/pkgs/by-name/he/headplane-agent/package.nix @@ -0,0 +1,37 @@ +{ + buildGoModule, + fetchFromGitHub, + lib, +}: +buildGoModule (finalAttrs: { + pname = "headplane-agent"; + # Note, if you are upgrading this, you should upgrade headplane at the same time + version = "0.6.1"; + + src = fetchFromGitHub { + owner = "tale"; + repo = "headplane"; + tag = "v${finalAttrs.version}"; + hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4="; + }; + + vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA="; + + ldflags = [ + "-s" + "-w" + ]; + env.CGO_ENABLED = 0; + + meta = { + description = "Optional sidecar process providing additional features for headplane"; + homepage = "https://github.com/tale/headplane"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + igor-ramazanov + stealthbadger747 + ]; + mainProgram = "hp_agent"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +}) diff --git a/pkgs/by-name/he/headplane/package.nix b/pkgs/by-name/he/headplane/package.nix new file mode 100644 index 000000000000..51c6b74609a6 --- /dev/null +++ b/pkgs/by-name/he/headplane/package.nix @@ -0,0 +1,116 @@ +{ + buildGoModule, + fetchFromGitHub, + fetchPnpmDeps, + git, + lib, + makeWrapper, + nodejs_22, + pnpm_10, + pnpmConfigHook, + stdenv, +}: +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="; + src = fetchFromGitHub { + owner = "tale"; + repo = "headplane"; + tag = "v${version}"; + hash = "sha256-hsrnmEwKXJlPjV4aIfmS6GAE414ArVRGoPPpZGmV0x4="; + }; + + headplaneSshWasm = buildGoModule { + pname = "headplane-ssh-wasm"; + inherit version src; + subPackages = [ "cmd/hp_ssh" ]; + vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA="; + env.CGO_ENABLED = 0; + doCheck = false; + buildPhase = '' + export GOOS=js + export GOARCH=wasm + go build -o hp_ssh.wasm ./cmd/hp_ssh + ''; + + installPhase = '' + runHook preInstall + install -Dm444 hp_ssh.wasm "$out/hp_ssh.wasm" + + # Go's WebAssembly shim (wasm_exec.js) has no stable `go env` key, and + # different Go packages may place it in different locations under GOROOT. + # 1. Ask `go env GOROOT` for the active GOROOT. + # 2. First try the path misc/wasm/wasm_exec.js. + # 3. If that fails, fall back to searching under GOROOT to handle + # distro / OS / packaging layout variations. + goRoot="$(go env GOROOT)" + + wasm_exec="$goRoot/misc/wasm/wasm_exec.js" + if [ ! -e "$wasm_exec" ]; then + wasm_exec="$(find "$goRoot" -path '*wasm_exec.js' -print -quit || true)" + fi + + if [[ -z "$wasm_exec" || ! -e "$wasm_exec" ]]; then + echo "ERROR: wasm_exec.js not found under GOROOT=$goRoot" >&2 + exit 1 + fi + + install -Dm444 "$wasm_exec" "$out/wasm_exec.js" + runHook postInstall + ''; + }; +in +stdenv.mkDerivation (finalAttrs: { + inherit pname version src; + + nativeBuildInputs = [ + git + makeWrapper + nodejs_22 + pnpm_10 + pnpmConfigHook + ]; + + pnpmDeps = fetchPnpmDeps { + inherit (finalAttrs) pname version src; + hash = pnpmDepsHash; + fetcherVersion = 3; + }; + + buildPhase = '' + runHook preBuild + cp ${headplaneSshWasm}/hp_ssh.wasm app/hp_ssh.wasm + cp ${headplaneSshWasm}/wasm_exec.js app/wasm_exec.js + pnpm --offline build + runHook postBuild + ''; + + installPhase = '' + runHook preInstall + mkdir -p $out/{bin,share/headplane} + cp -r build $out/share/headplane/ + cp -r node_modules $out/share/headplane/ + cp -r drizzle $out/share/headplane/ + substituteInPlace $out/share/headplane/build/server/index.js \ + --replace "$PWD" "../.." + makeWrapper ${lib.getExe nodejs_22} $out/bin/headplane \ + --chdir $out/share/headplane \ + --add-flags $out/share/headplane/build/server/index.js + runHook postInstall + ''; + + meta = { + description = "Feature-complete Web UI for Headscale"; + homepage = "https://github.com/tale/headplane"; + changelog = "https://github.com/tale/headplane/releases/tag/${finalAttrs.version}"; + license = lib.licenses.mit; + maintainers = with lib.maintainers; [ + igor-ramazanov + stealthbadger747 + ]; + mainProgram = "headplane"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +}) From 9ffc6aa12ea89c2e5307ccd32984efeff1dc2ec9 Mon Sep 17 00:00:00 2001 From: Igor Ramazanov Date: Mon, 14 Apr 2025 20:42:04 +0000 Subject: [PATCH 4/6] nixos/headplane: init module https://github.com/tale/headplane/releases/tag/v0.6.1 Co-authored-by: Erik Parawell --- doc/release-notes/rl-2605.section.md | 2 +- .../manual/release-notes/rl-2605.section.md | 4 +- nixos/modules/module-list.nix | 1 + .../modules/services/networking/headplane.nix | 420 ++++++++++++++++++ 4 files changed, 425 insertions(+), 2 deletions(-) create mode 100644 nixos/modules/services/networking/headplane.nix diff --git a/doc/release-notes/rl-2605.section.md b/doc/release-notes/rl-2605.section.md index 6d73249aae56..b5a2008c5976 100644 --- a/doc/release-notes/rl-2605.section.md +++ b/doc/release-notes/rl-2605.section.md @@ -294,7 +294,7 @@ gnuradioMinimal.override { } ``` -- Added `headplane` and `headplane-agent` packages. +- Added `headplane` and `headplane-agent` packages, and `services.headplane` service. ## Nixpkgs Library {#sec-nixpkgs-release-26.05-lib} diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index 5220d49e71e2..72b551b4d947 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -108,7 +108,9 @@ - [tabbyAPI](https://github.com/theroyallab/tabbyAPI), the official OpenAI compatible API server for Exllama. Available as [services.tabbyapi](#opt-services.tabbyapi.enable). -- [Tdarr](https://tdarr.io), Audio/Video Library Analytics & Transcode/Remux Automation. Available as [services.tdarr](#opt-services.tdarr.enable) +- [Tdarr](https://tdarr.io), Audio/Video Library Analytics & Transcode/Remux Automation. Available as [services.tdarr](#opt-services.tdarr.enable). + +- [Headplane](https://headplane.net), a feature-complete Web UI for Headscale. Available as [services.headplane](#opt-services.headplane.enable). ## Backward Incompatibilities {#sec-release-26.05-incompatibilities} diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index df81fa95aca2..19f1e83d98ad 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1219,6 +1219,7 @@ ./services/networking/hans.nix ./services/networking/haproxy.nix ./services/networking/harmonia.nix + ./services/networking/headplane.nix ./services/networking/headscale.nix ./services/networking/hickory-dns.nix ./services/networking/hostapd.nix diff --git a/nixos/modules/services/networking/headplane.nix b/nixos/modules/services/networking/headplane.nix new file mode 100644 index 000000000000..ef35f19e4a01 --- /dev/null +++ b/nixos/modules/services/networking/headplane.nix @@ -0,0 +1,420 @@ +{ + config, + lib, + pkgs, + ... +}: +let + inherit (lib) + mkEnableOption + mkIf + mkOption + mkPackageOption + types + ; + inherit (lib.attrsets) filterAttrsRecursive; + cfg = config.services.headplane; + settingsFile = (pkgs.formats.yaml { }).generate "headplane-config.yaml" ( + # Headplane config can't have `null` values. + filterAttrsRecursive (n: v: v != null) cfg.settings + ); +in +{ + options.services.headplane = { + enable = mkEnableOption "Headplane"; + package = mkPackageOption pkgs "headplane" { }; + + debug = mkEnableOption "Enable debug loggin"; + + settings = mkOption { + description = '' + Headplane configuration options. Generates a YAML config file. + See: https://github.com/tale/headplane/blob/main/config.example.yaml + ''; + type = types.submodule { + options = { + server = mkOption { + type = types.submodule { + options = { + host = mkOption { + type = types.str; + default = "127.0.0.1"; + description = "The host address to bind to."; + example = "0.0.0.0"; + }; + + port = mkOption { + type = types.port; + default = 3000; + description = "The port to listen on."; + }; + + cookie_secret_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the cookie secret. + The secret must be exactly 32 characters long. + ''; + example = "config.sops.secrets.headplane_cookie.path"; + }; + + cookie_secure = mkOption { + type = types.bool; + default = true; + description = '' + Should the cookies only work over HTTPS? + Set to false if running via HTTP without a proxy. + Recommended to be true in production. + ''; + }; + + 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 = { }; + description = "Server configuration for Headplane web application."; + }; + + headscale = mkOption { + type = types.submodule { + options = { + url = mkOption { + type = types.str; + default = "http://127.0.0.1:${toString config.services.headscale.port}"; + defaultText = lib.literalExpression "http://127.0.0.1:\${toString config.services.headscale.port}"; + description = '' + The URL to your Headscale instance. + All API requests are routed through this URL. + THIS IS NOT the gRPC endpoint, but the HTTP endpoint. + IMPORTANT: If you are using TLS this MUST be set to `https://`. + ''; + example = "https://headscale.example.com"; + }; + + tls_cert_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the TLS certificate. + ''; + example = "config.sops.secrets.tls_cert.path"; + }; + + public_url = mkOption { + type = types.nullOr types.str; + default = config.services.headscale.settings.server_url; + defaultText = lib.literalExpression "config.services.headscale.settings.server_url"; + description = "Public URL if different. This affects certain parts of the web UI."; + example = "https://headscale.example.com"; + }; + + config_path = mkOption { + type = types.nullOr types.path; + default = config.services.headscale.configFile; + defaultText = lib.literalExpression "config.services.headscale.configFile"; + description = '' + Path to the Headscale configuration file. + This is optional, but HIGHLY recommended for the best experience. + If this is read only, Headplane will show your configuration settings + in the Web UI, but they cannot be changed. + ''; + example = "/etc/headscale/config.yaml"; + }; + + config_strict = mkEnableOption '' + Headplane internally validates the Headscale configuration + to ensure that it changes the configuration in a safe way. + Disabled by default because it clashes with how the Headplane works in NixOS. + ''; + + 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 = mkOption { + type = types.submodule { + options = { + agent = mkOption { + type = types.submodule { + options = { + 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. + ''; + }; + + executable_path = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.settings.integration.agent.package}/bin/hp_agent"; + defaultText = lib.literalExpression ''"''\${config.services.headplane.settings.integration.agent.package}/bin/hp_agent"''; + description = '' + Path to the headplane agent binary. + ''; + }; + + 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.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. + ''; + }; + + 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 + Headplane are running outside of a container. There is no additional + configuration, but you need to ensure that the Headplane process + can terminate the Headscale process. + ''; + }; + }; + }; + default = { }; + description = "Native process integration settings."; + }; + }; + }; + default = { }; + description = "Integration configurations for Headplane to interact with Headscale."; + }; + + oidc = mkOption { + type = types.submodule { + options = { + issuer = mkOption { + type = types.nullOr types.str; + default = null; + description = "URL to OpenID issuer."; + example = "https://provider.example.com/issuer-url"; + }; + + client_id = mkOption { + type = types.nullOr types.str; + default = null; + description = "The client ID for the OIDC client."; + example = "your-client-id"; + }; + + client_secret_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the OIDC client secret. + ''; + example = "config.sops.secrets.oidc_client_secret.path"; + }; + + disable_api_key_login = mkOption { + type = types.bool; + default = false; + description = "Whether to disable API key login."; + }; + + 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; + default = null; + description = '' + Path to a file containing the Headscale API key. + ''; + example = "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. + ''; + 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"; + }; + + profile_picture_source = mkOption { + type = types.enum [ + "oidc" + "gravatar" + ]; + default = "oidc"; + 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"; + description = "OIDC scope to request."; + }; + + extra_params = mkOption { + type = types.nullOr (types.attrsOf types.str); + default = null; + description = "Extra parameters to send to the OIDC provider."; + example = { + prompt = "consent"; + }; + }; + + authorization_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom authorization endpoint URL."; + example = "https://provider.example.com/authorize"; + }; + + token_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom token endpoint URL."; + example = "https://provider.example.com/token"; + }; + + userinfo_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom userinfo endpoint URL."; + example = "https://provider.example.com/userinfo"; + }; + }; + }; + default = { }; + description = "OIDC Configuration for authentication."; + }; + }; + }; + default = { }; + }; + }; + + config = mkIf cfg.enable { + environment = { + systemPackages = [ cfg.package ]; + etc."headplane/config.yaml".source = "${settingsFile}"; + }; + + systemd.services.headplane = { + description = "Headscale Web UI"; + + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + config.systemd.services.headscale.name + ]; + requires = [ config.systemd.services.headscale.name ]; + + environment = { + HEADPLANE_DEBUG_LOG = toString cfg.debug; + }; + serviceConfig = { + User = config.services.headscale.user; + Group = config.services.headscale.group; + StateDirectory = "headplane"; + + ExecStart = lib.getExe cfg.package; + Restart = "always"; + RestartSec = 5; + + # TODO: Harden `systemd` security according to the "The Principle of Least Power". + # See: `$ systemd-analyze security headplane`. + }; + }; + }; +} From d97d82b37eeb6876d578df034422d86af0acd9bf Mon Sep 17 00:00:00 2001 From: StealthBadger747 Date: Mon, 27 Apr 2026 21:35:14 -0700 Subject: [PATCH 5/6] nixos/headplane: address review feedback --- .../modules/services/networking/headplane.nix | 373 ++++++++++-------- pkgs/by-name/he/headplane-agent/package.nix | 1 + 2 files changed, 201 insertions(+), 173 deletions(-) diff --git a/nixos/modules/services/networking/headplane.nix b/nixos/modules/services/networking/headplane.nix index ef35f19e4a01..d4cdeac233a5 100644 --- a/nixos/modules/services/networking/headplane.nix +++ b/nixos/modules/services/networking/headplane.nix @@ -14,16 +14,31 @@ let ; inherit (lib.attrsets) filterAttrsRecursive; cfg = config.services.headplane; - settingsFile = (pkgs.formats.yaml { }).generate "headplane-config.yaml" ( - # Headplane config can't have `null` values. - filterAttrsRecursive (n: v: v != null) cfg.settings + settingsFormat = pkgs.formats.yaml { }; + filterSettings = lib.converge ( + filterAttrsRecursive ( + _: v: + !lib.elem v [ + { } + null + ] + ) ); + agentSettings = cfg.settings.integration.agent; + settings = cfg.settings // { + integration = cfg.settings.integration // { + agent = if agentSettings == null || !agentSettings.enabled then null else agentSettings; + }; + }; + settingsFile = settingsFormat.generate "headplane-config.yaml" (filterSettings settings); in { options.services.headplane = { enable = mkEnableOption "Headplane"; package = mkPackageOption pkgs "headplane" { }; + agent.package = mkPackageOption pkgs "headplane-agent" { }; + debug = mkEnableOption "Enable debug loggin"; settings = mkOption { @@ -157,74 +172,74 @@ in type = types.submodule { options = { agent = mkOption { - type = types.submodule { - options = { - 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. - ''; - }; + type = types.nullOr ( + types.submodule { + options = { + 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. + ''; + }; - executable_path = mkOption { - type = types.path; - readOnly = true; - default = "${cfg.settings.integration.agent.package}/bin/hp_agent"; - defaultText = lib.literalExpression ''"''\${config.services.headplane.settings.integration.agent.package}/bin/hp_agent"''; - description = '' - Path to the headplane agent binary. - ''; - }; + executable_path = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.agent.package}/bin/hp_agent"; + defaultText = lib.literalExpression ''"''${config.services.headplane.agent.package}/bin/hp_agent"''; + description = '' + Path to the headplane agent binary. + ''; + }; - 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"; - }; + 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"; - }; + 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. - ''; - }; + 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. + ''; + }; - cache_path = mkOption { - type = types.path; - default = "/var/lib/headplane/agent_cache.json"; - description = "Where to store the agent cache."; - }; + 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. - ''; + 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 = { }; + } + ); + default = null; description = "Agent configuration for the Headplane agent."; }; @@ -253,128 +268,127 @@ in }; oidc = mkOption { - type = types.submodule { - options = { - issuer = mkOption { - type = types.nullOr types.str; - default = null; - description = "URL to OpenID issuer."; - example = "https://provider.example.com/issuer-url"; - }; + type = types.nullOr ( + types.submodule { + options = { + issuer = mkOption { + type = types.str; + description = "URL to OpenID issuer."; + example = "https://provider.example.com/issuer-url"; + }; - client_id = mkOption { - type = types.nullOr types.str; - default = null; - description = "The client ID for the OIDC client."; - example = "your-client-id"; - }; + client_id = mkOption { + type = types.str; + description = "The client ID for the OIDC client."; + example = "your-client-id"; + }; - client_secret_path = mkOption { - type = types.nullOr types.path; - default = null; - description = '' - Path to a file containing the OIDC client secret. - ''; - example = "config.sops.secrets.oidc_client_secret.path"; - }; + client_secret_path = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + Path to a file containing the OIDC client secret. + ''; + example = "config.sops.secrets.oidc_client_secret.path"; + }; - disable_api_key_login = mkOption { - type = types.bool; - default = false; - description = "Whether to disable API key login."; - }; + disable_api_key_login = mkOption { + type = types.bool; + default = false; + description = "Whether to disable API key login."; + }; - 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."; - }; + 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; - default = null; - description = '' - Path to a file containing the Headscale API key. - ''; - example = "config.sops.secrets.headscale_api_key.path"; - }; + headscale_api_key_path = mkOption { + type = types.path; + description = '' + Path to a file containing the Headscale API key. + ''; + example = "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. - ''; - example = "https://headscale.example.com/admin/oidc/callback"; - }; + 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. + ''; + 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"; - }; + 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"; + }; - profile_picture_source = mkOption { - type = types.enum [ - "oidc" - "gravatar" - ]; - default = "oidc"; - description = "Source for user profile pictures."; - }; + profile_picture_source = mkOption { + type = types.enum [ + "oidc" + "gravatar" + ]; + default = "oidc"; + description = "Source for user profile pictures."; + }; - strict_validation = mkOption { - type = types.bool; - default = true; - description = "Enable strict validation of OIDC configuration."; - }; + strict_validation = mkOption { + type = types.bool; + default = true; + description = "Enable strict validation of OIDC configuration."; + }; - scope = mkOption { - type = types.str; - default = "openid email profile"; - description = "OIDC scope to request."; - }; + scope = mkOption { + type = types.str; + default = "openid email profile"; + description = "OIDC scope to request."; + }; - extra_params = mkOption { - type = types.nullOr (types.attrsOf types.str); - default = null; - description = "Extra parameters to send to the OIDC provider."; - example = { - prompt = "consent"; + extra_params = mkOption { + type = types.nullOr (types.attrsOf types.str); + default = null; + description = "Extra parameters to send to the OIDC provider."; + example = { + prompt = "consent"; + }; + }; + + authorization_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom authorization endpoint URL."; + example = "https://provider.example.com/authorize"; + }; + + token_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom token endpoint URL."; + example = "https://provider.example.com/token"; + }; + + userinfo_endpoint = mkOption { + type = types.nullOr types.str; + default = null; + description = "Custom userinfo endpoint URL."; + example = "https://provider.example.com/userinfo"; }; }; - - authorization_endpoint = mkOption { - type = types.nullOr types.str; - default = null; - description = "Custom authorization endpoint URL."; - example = "https://provider.example.com/authorize"; - }; - - token_endpoint = mkOption { - type = types.nullOr types.str; - default = null; - description = "Custom token endpoint URL."; - example = "https://provider.example.com/token"; - }; - - userinfo_endpoint = mkOption { - type = types.nullOr types.str; - default = null; - description = "Custom userinfo endpoint URL."; - example = "https://provider.example.com/userinfo"; - }; - }; - }; - default = { }; + } + ); + default = null; description = "OIDC Configuration for authentication."; }; }; @@ -384,6 +398,19 @@ in }; config = mkIf cfg.enable { + assertions = [ + { + 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. + ''; + } + ]; + environment = { systemPackages = [ cfg.package ]; etc."headplane/config.yaml".source = "${settingsFile}"; diff --git a/pkgs/by-name/he/headplane-agent/package.nix b/pkgs/by-name/he/headplane-agent/package.nix index e12db5adecf7..40f3bd7102e7 100644 --- a/pkgs/by-name/he/headplane-agent/package.nix +++ b/pkgs/by-name/he/headplane-agent/package.nix @@ -16,6 +16,7 @@ buildGoModule (finalAttrs: { }; vendorHash = "sha256-MvrqKMD+A+qBZmzQv+T9920U5uJop+pjfJpZdm2ZqEA="; + subPackages = [ "cmd/hp_agent" ]; ldflags = [ "-s" From aee6662b429cf1d5b349cb6234aeddcaf1eae481 Mon Sep 17 00:00:00 2001 From: StealthBadger747 Date: Mon, 27 Apr 2026 22:40:08 -0700 Subject: [PATCH 6/6] nixos/headplane: add config assertions and fix option examples --- .../modules/services/networking/headplane.nix | 46 ++++++++++++++++--- pkgs/by-name/he/headplane-agent/package.nix | 1 + pkgs/by-name/he/headplane/package.nix | 3 ++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/nixos/modules/services/networking/headplane.nix b/nixos/modules/services/networking/headplane.nix index d4cdeac233a5..5fac2f04e37d 100644 --- a/nixos/modules/services/networking/headplane.nix +++ b/nixos/modules/services/networking/headplane.nix @@ -39,7 +39,7 @@ in agent.package = mkPackageOption pkgs "headplane-agent" { }; - debug = mkEnableOption "Enable debug loggin"; + debug = mkEnableOption "debug logging"; settings = mkOption { description = '' @@ -71,7 +71,7 @@ in Path to a file containing the cookie secret. The secret must be exactly 32 characters long. ''; - example = "config.sops.secrets.headplane_cookie.path"; + example = lib.literalExpression "config.sops.secrets.headplane_cookie.path"; }; cookie_secure = mkOption { @@ -122,7 +122,7 @@ in description = '' Path to a file containing the TLS certificate. ''; - example = "config.sops.secrets.tls_cert.path"; + example = lib.literalExpression "config.sops.secrets.tls_cert.path"; }; public_url = mkOption { @@ -203,7 +203,7 @@ in 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"; + example = lib.literalExpression "config.sops.secrets.agent_pre_authkey.path"; }; host_name = mkOption { @@ -289,7 +289,7 @@ in description = '' Path to a file containing the OIDC client secret. ''; - example = "config.sops.secrets.oidc_client_secret.path"; + example = lib.literalExpression "config.sops.secrets.oidc_client_secret.path"; }; disable_api_key_login = mkOption { @@ -309,11 +309,13 @@ in }; headscale_api_key_path = mkOption { - type = types.path; + type = types.nullOr types.path; + default = null; description = '' Path to a file containing the Headscale API key. + Required when `services.headplane.settings.oidc` is set. ''; - example = "config.sops.secrets.headscale_api_key.path"; + example = lib.literalExpression "config.sops.secrets.headscale_api_key.path"; }; redirect_uri = mkOption { @@ -399,6 +401,27 @@ in config = mkIf cfg.enable { assertions = [ + { + assertion = config.services.headscale.enable; + message = '' + services.headplane requires services.headscale.enable = true. + The headplane module references the headscale systemd unit + (in `after`/`requires`) and reads its configFile, port, user, + and group. Enable headscale or disable headplane. + ''; + } + { + assertion = cfg.settings.server.cookie_secret_path != null; + message = '' + services.headplane.settings.server.cookie_secret_path must be set. + Headplane refuses to start without either `cookie_secret` or + `cookie_secret_path` (validated at startup, see upstream + app/server/config/schema.ts). The NixOS module only exposes the + *_path form to keep secrets out of the world-readable /nix/store. + Provide a path to a file containing a 32-character secret, e.g. + via systemd `LoadCredential` or sops-nix. + ''; + } { assertion = cfg.settings.integration.agent == null @@ -409,6 +432,15 @@ in 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. + ''; + } ]; environment = { diff --git a/pkgs/by-name/he/headplane-agent/package.nix b/pkgs/by-name/he/headplane-agent/package.nix index 40f3bd7102e7..a645d2e576f8 100644 --- a/pkgs/by-name/he/headplane-agent/package.nix +++ b/pkgs/by-name/he/headplane-agent/package.nix @@ -5,6 +5,7 @@ }: 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"; diff --git a/pkgs/by-name/he/headplane/package.nix b/pkgs/by-name/he/headplane/package.nix index 51c6b74609a6..bb42ab31bcee 100644 --- a/pkgs/by-name/he/headplane/package.nix +++ b/pkgs/by-name/he/headplane/package.nix @@ -65,6 +65,9 @@ in stdenv.mkDerivation (finalAttrs: { inherit pname version src; + strictDeps = true; + __structuredAttrs = true; + nativeBuildInputs = [ git makeWrapper