From 46c6aaeac7c80da741a61e16305237a27dda1b86 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Jun 2026 18:28:28 +0200 Subject: [PATCH 01/16] nixos/netbox: improve secret management With this change secrets will be automatically generated if left at their individual option defaults. We now also allow configuration of multiple peppers, to allow proper rotation as intended by upstream. Finally, we now we prevent secrets from leaking into the store. --- nixos/modules/services/web-apps/netbox.nix | 112 +++++++++++++++++---- nixos/tests/web-apps/netbox-upgrade.nix | 6 -- nixos/tests/web-apps/netbox/default.nix | 7 -- 3 files changed, 91 insertions(+), 34 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 65f761373cda..fdfe33e6f970 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -6,6 +6,13 @@ }: let + inherit (lib) + any + attrValues + mkChangedOptionModule + optionalString + ; + cfg = config.services.netbox; pythonFmt = pkgs.formats.pythonVars { }; staticDir = cfg.dataDir + "/static"; @@ -19,6 +26,8 @@ let settingsFile extraConfigFile ]; + secretKeyFile = + if cfg.secretKeyFile != null then cfg.secretKeyFile else "${cfg.dataDir}/secret.key"; pkg = (cfg.package.overrideAttrs (old: { @@ -51,6 +60,16 @@ let in { + imports = [ + (mkChangedOptionModule + [ "services" "netbox" "apiTokenPeppersFile" ] + [ "services" "netbox" "apiTokenPepperFiles" ] + (config: { + "1" = config.services.netbox.apiTokenPeppersFile; + }) + ) + ]; + options.services.netbox = { enable = lib.mkOption { type = lib.types.bool; @@ -159,18 +178,58 @@ in }; secretKeyFile = lib.mkOption { - type = lib.types.path; + type = + with lib.types; + nullOr (pathWith { + inStore = false; + }); + default = null; description = '' - Path to a file containing the secret key. + Path to a file containing the [secret key]. + + The secret key is used for hashing passwords and signing HTTP cookies. + It can be rotated without data loss; however all existing user sessions + will be invalidated. + + ::: {.note} + If unset, a random secret will be created automatically at + `/var/lib/netbox/secret.key`. + ::: + + [secret key]: https://netboxlabs.com/docs/netbox/configuration/required-parameters/#secret_key ''; }; - apiTokenPeppersFile = lib.mkOption { - type = with lib.types; nullOr path; + apiTokenPepperFiles = lib.mkOption { + type = + with lib.types; + attrsOf (pathWith { + inStore = false; + }); + default = { + "1" = "${cfg.dataDir}/pepper.1"; + }; + defaultText = lib.literalExpression '' + { + "1" = "''${config.services.netbox.dataDir}/pepper.1"; + } + ''; + example = { + "1" = "/run/keys/netbox-pepper-old"; + "2" = "/run/keys/netbox-pepper-current"; + }; description = '' - Path to a file containing the API_TOKEN_PEPPER that will be configured for the pepper_id with the id 1. - This is required for netbox >= 4.5. For generating this pepper, - [consider using `$INSTALL_ROOT/netbox/generate_secret_key.py`](https://netboxlabs.com/docs/netbox/configuration/required-parameters/#api_token_peppers) + Mapping of cryptographic pepper IDs to files containing the pepper values. + + Peppers provide an additional secret input in hashing operations. They + are required for v2 API tokens (NetBox 4.5+). + + ::: {.note} + By default a random pepper will be created automatically at + `/var/lib/netbox/pepper.1` and configured with pepper ID 1. + ::: + + [cryptographic peppers]: https://netboxlabs.com/docs/netbox/configuration/required-parameters/#api_token_peppers ''; }; @@ -226,6 +285,7 @@ in AUTH_LDAP_FIND_GROUP_PERMS = True ''; }; + keycloakClientSecret = lib.mkOption { type = with lib.types; nullOr path; default = null; @@ -291,15 +351,17 @@ in }; extraConfig = '' - with open("${cfg.secretKeyFile}", "r") as file: + with open("${secretKeyFile}", "r") as file: SECRET_KEY = file.readline() + + API_TOKEN_PEPPERS = { + ${lib.concatStringsSep "\n" ( + lib.mapAttrsToList (id: file: '' + ${id}: open("${file}", "r").read().strip(), + '') cfg.apiTokenPepperFiles + )} + } '' - + (lib.optionalString (cfg.apiTokenPeppersFile != null) '' - with open("${cfg.apiTokenPeppersFile}", "r") as pepper_file: - API_TOKEN_PEPPERS = { - 1: pepper_file.readline(), - }; - '') + (lib.optionalString (cfg.keycloakClientSecret != null) '' with open("${cfg.keycloakClientSecret}", "r") as file: SOCIAL_AUTH_KEYCLOAK_SECRET = file.readline() @@ -356,6 +418,21 @@ in environment.PYTHONPATH = pkg.pythonPath; preStart = '' + # Generate random default secrets, if the user didn't supply any. + ${optionalString (cfg.secretKeyFile == null) '' + if [ ! -e "${secretKeyFile}" ]; then + ${pkg}/opt/netbox/netbox/generate_secret_key.py > "${secretKeyFile}" + fi + ''} + ${optionalString + (any (path: path == "${cfg.dataDir}/pepper.1") (attrValues cfg.apiTokenPepperFiles)) + '' + if [ ! -e "${cfg.dataDir}/pepper.1" ]; then + ${pkg}/opt/netbox/netbox/generate_secret_key.py > "${cfg.dataDir}/pepper.1" + fi + '' + } + # On the first run, or on upgrade / downgrade, run migrations and related. # This mostly correspond to upstream NetBox's 'upgrade.sh' script. versionFile="${cfg.dataDir}/version" @@ -463,12 +540,5 @@ in }; users.groups.netbox = { }; users.groups."${config.services.redis.servers.netbox.user}".members = [ "netbox" ]; - - assertions = [ - { - assertion = (lib.versionAtLeast cfg.package.version "4.5") -> (cfg.apiTokenPeppersFile != null); - message = "NetBox 4.5 or newer require setting `services.netbox.apiTokenPeppersFile`"; - } - ]; }; } diff --git a/nixos/tests/web-apps/netbox-upgrade.nix b/nixos/tests/web-apps/netbox-upgrade.nix index c965e0c9b0f3..389f60856c68 100644 --- a/nixos/tests/web-apps/netbox-upgrade.nix +++ b/nixos/tests/web-apps/netbox-upgrade.nix @@ -34,12 +34,6 @@ in # Pick the NetBox package from this config's "pkgs" argument, # so that `nixpkgs.config.permittedInsecurePackages` works package = pkgs.${oldNetbox}; - secretKeyFile = pkgs.writeText "secret" '' - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 - ''; - apiTokenPeppersFile = pkgs.writeText "pepper" '' - kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_ - ''; }; services.nginx = { diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index 803cf5c25ed3..da9d2c9bfe0f 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -34,13 +34,6 @@ import ../../make-test-python.nix ( services.netbox = { enable = true; package = netbox; - secretKeyFile = pkgs.writeText "secret" '' - abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 - ''; - # Value from https://netbox.readthedocs.io/en/feature/configuration/required-parameters/#api_token_peppers - apiTokenPeppersFile = pkgs.writeText "pepper" '' - kp7ht*76fiQAhUi5dHfASLlYUE_S^gI^(7J^K5M!LfoH@vl&b_ - ''; enableLdap = true; ldapConfigPath = pkgs.writeText "ldap_config.py" '' From f76c4fdb7f2908b66b5c243cebc4d0f8893285d5 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Jun 2026 20:40:31 +0200 Subject: [PATCH 02/16] nixos/netbox: merge nixos options support in concatFile While types.lines has a custom merge function it is generally not great style to configure module options needlessly from the config section. --- nixos/modules/services/web-apps/netbox.nix | 38 ++++++++++++---------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index fdfe33e6f970..307681d46b0f 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -23,12 +23,33 @@ let text = cfg.extraConfig; }; configFile = pkgs.concatText "configuration.py" [ + nixosOptionsConfig settingsFile extraConfigFile ]; secretKeyFile = if cfg.secretKeyFile != null then cfg.secretKeyFile else "${cfg.dataDir}/secret.key"; + nixosOptionsConfig = pkgs.writeTextFile { + name = "netbox-nixos-options.py"; + text = '' + with open("${secretKeyFile}", "r") as file: + SECRET_KEY = file.readline() + + API_TOKEN_PEPPERS = { + ${lib.concatStringsSep "\n" ( + lib.mapAttrsToList (id: file: '' + ${id}: open("${file}", "r").read().strip(), + '') cfg.apiTokenPepperFiles + )} + } + '' + + (lib.optionalString (cfg.keycloakClientSecret != null) '' + with open("${cfg.keycloakClientSecret}", "r") as file: + SOCIAL_AUTH_KEYCLOAK_SECRET = file.readline() + ''); + }; + pkg = (cfg.package.overrideAttrs (old: { installPhase = @@ -349,23 +370,6 @@ in }; }; }; - - extraConfig = '' - with open("${secretKeyFile}", "r") as file: - SECRET_KEY = file.readline() - - API_TOKEN_PEPPERS = { - ${lib.concatStringsSep "\n" ( - lib.mapAttrsToList (id: file: '' - ${id}: open("${file}", "r").read().strip(), - '') cfg.apiTokenPepperFiles - )} - } - '' - + (lib.optionalString (cfg.keycloakClientSecret != null) '' - with open("${cfg.keycloakClientSecret}", "r") as file: - SOCIAL_AUTH_KEYCLOAK_SECRET = file.readline() - ''); }; services.redis.servers.netbox.enable = true; From d6beee20afc4f8878af0810f918f4c2e281c83c6 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Jun 2026 20:48:25 +0200 Subject: [PATCH 03/16] nixos/netbox: drop keycloak specific option The django-social-auth library supports a massive list of authentication backends. Maintaining options at this granularity is just not great for maintainability. https://python-social-auth.readthedocs.io/en/latest/backends/index.html --- nixos/modules/services/web-apps/netbox.nix | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 307681d46b0f..148a2e3ac3d5 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -10,6 +10,7 @@ let any attrValues mkChangedOptionModule + mkRemovedOptionModule optionalString ; @@ -43,11 +44,7 @@ let '') cfg.apiTokenPepperFiles )} } - '' - + (lib.optionalString (cfg.keycloakClientSecret != null) '' - with open("${cfg.keycloakClientSecret}", "r") as file: - SOCIAL_AUTH_KEYCLOAK_SECRET = file.readline() - ''); + ''; }; pkg = @@ -89,6 +86,10 @@ in "1" = config.services.netbox.apiTokenPeppersFile; }) ) + (mkRemovedOptionModule + [ "services" "netbox" "keycloakClientSecret" ] '' + Too much granularity hurts maintainability. Please configure secret key loading via `services.netbox.extraConfig` instead. + '') ]; options.services.netbox = { @@ -306,14 +307,6 @@ in AUTH_LDAP_FIND_GROUP_PERMS = True ''; }; - - keycloakClientSecret = lib.mkOption { - type = with lib.types; nullOr path; - default = null; - description = '' - File that contains the keycloak client secret. - ''; - }; }; config = lib.mkIf cfg.enable { From 5c355dedbf8e162ec35e08dcc571b941ca774834 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Jun 2026 22:56:50 +0200 Subject: [PATCH 04/16] nixos/netbox: merge bind options into one They are all passed to gunicorn's --bind flag, so let the user pass whatever they want. --- nixos/modules/services/web-apps/netbox.nix | 56 ++++++++++------------ nixos/tests/web-apps/netbox-upgrade.nix | 2 +- nixos/tests/web-apps/netbox/default.nix | 2 +- nixos/tests/web-apps/netbox/testScript.py | 2 +- 4 files changed, 27 insertions(+), 35 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 148a2e3ac3d5..e9956b3e927d 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -10,6 +10,7 @@ let any attrValues mkChangedOptionModule + mkOption mkRemovedOptionModule optionalString ; @@ -86,10 +87,18 @@ in "1" = config.services.netbox.apiTokenPeppersFile; }) ) - (mkRemovedOptionModule - [ "services" "netbox" "keycloakClientSecret" ] '' - Too much granularity hurts maintainability. Please configure secret key loading via `services.netbox.extraConfig` instead. - '') + (mkRemovedOptionModule [ "services" "netbox" "listenAddress" ] '' + Use `services.netbox.bind` with : format instead. + '') + (mkRemovedOptionModule [ "services" "netbox" "port" ] '' + Use `services.netbox.bind` with : format instead. + '') + (mkRemovedOptionModule [ "services" "netbox" "unixSocket" ] '' + Use `services.netbox.bind` with unix: format instead. + '') + (mkRemovedOptionModule [ "services" "netbox" "keycloakClientSecret" ] '' + Too much granularity hurts maintainability. Please configure secret key loading via `services.netbox.extraConfig` instead. + '') ]; options.services.netbox = { @@ -128,23 +137,19 @@ in }; }; - listenAddress = lib.mkOption { + bind = lib.mkOption { type = lib.types.str; - default = "[::1]"; + default = "unix:/run/netbox/netbox.sock"; + example = "[::1]:8001"; description = '' - Address the server will listen on. - Ignored if `unixSocket` is set. - ''; - }; + IP and port or Unix domain socket path to bind the HTTP socket to. - unixSocket = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = '' - Enable Unix Socket for the server to listen on. - `listenAddress` and `port` will be ignored. + ::: {.tip} + This setting will be passed to gunicorn's [--bind] flag. + ::: + + [--bind]: https://gunicorn.org/reference/settings/#bind ''; - example = "/run/netbox/netbox.sock"; }; gunicornArgs = lib.mkOption { @@ -171,15 +176,6 @@ in ''; }; - port = lib.mkOption { - type = lib.types.port; - default = 8001; - description = '' - Port the server will listen on. - Ignored if `unixSocket` is set. - ''; - }; - plugins = lib.mkOption { type = with lib.types; functionTo (listOf package); default = _: [ ]; @@ -457,16 +453,12 @@ in serviceConfig = defaultServiceConfig // { ExecStart = '' ${pkg.gunicorn}/bin/gunicorn netbox.wsgi \ - --bind ${ - if (cfg.unixSocket != null) then - "unix:${cfg.unixSocket}" - else - "${cfg.listenAddress}:${toString cfg.port}" - } \ + --bind ${cfg.bind} \ --pythonpath ${pkg}/opt/netbox/netbox \ ${lib.concatStringsSep " " cfg.gunicornArgs} ''; PrivateTmp = true; + RuntimeDirectory = "netbox"; TimeoutStartSec = lib.mkDefault "10min"; }; }; diff --git a/nixos/tests/web-apps/netbox-upgrade.nix b/nixos/tests/web-apps/netbox-upgrade.nix index 389f60856c68..b8e23c6cf8de 100644 --- a/nixos/tests/web-apps/netbox-upgrade.nix +++ b/nixos/tests/web-apps/netbox-upgrade.nix @@ -43,7 +43,7 @@ in virtualHosts.netbox = { default = true; - locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; + locations."/".proxyPass = "http://${config.services.netbox.bind}"; locations."/static/".alias = "/var/lib/netbox/static/"; }; }; diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index da9d2c9bfe0f..6c1a48b30d94 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -70,7 +70,7 @@ import ../../make-test-python.nix ( virtualHosts.netbox = { default = true; - locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; + locations."/".proxyPass = "http://${toString config.services.netbox.bind}"; locations."/static/".alias = "/var/lib/netbox/static/"; }; }; diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py index 4ff035a1f0cf..8532a4538ff9 100644 --- a/nixos/tests/web-apps/netbox/testScript.py +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -62,7 +62,7 @@ def compare(a: str, b: str): with subtest("Home screen loads"): machine.wait_until_succeeds( - "curl -sSfL http://[::1]:8001 | grep 'Home | NetBox'" + "curl -sSfL http://[::1]:80 | grep 'Home | NetBox'" ) with subtest("Staticfiles are generated"): From 1a8e82de98615962b8e3e304f315d8b2055305b4 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 00:34:12 +0200 Subject: [PATCH 05/16] nixos/netbox: add option for environment files This is relevant to pass secrets that are read from custom code in extraConfig. As these secrets are then read for every configuration.py consumer this also affects netbox-manage. --- nixos/modules/services/web-apps/netbox.nix | 41 ++++++++++++++++------ 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index e9956b3e927d..214313b46321 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -62,20 +62,29 @@ let { inherit (cfg) plugins; }; - netboxManageScript = - with pkgs; - (writeScriptBin "netbox-manage" '' - #!${stdenv.shell} + + netboxManageScript = ( + pkgs.writeShellScriptBin "netbox-manage" '' + set -a + ${lib.concatMapStringsSep "\n" (envFile: '' + . "${envFile}" + '') cfg.environmentFiles} + set +a export PYTHONPATH=${pkg.pythonPath} case "$(whoami)" in - "root") - ${util-linux}/bin/runuser -u netbox -- ${pkg}/bin/netbox "$@";; - "netbox") - ${pkg}/bin/netbox "$@";; - *) - echo "This must be run by either by root 'netbox' user" + "root") + ${lib.getExe' pkgs.util-linux "runuser"} ${lib.cli.toCommandLineShellGNU { } { + preserve-environment = true; + user = "netbox"; + }} -- ${pkg}/bin/netbox "$@";; + "netbox") + exec ${pkg}/bin/netbox "$@";; + *) + echo "This must be run by either the root or the 'netbox' user." >&2 + exit 1 esac - ''); + '' + ); in { @@ -113,6 +122,15 @@ in ''; }; + environmentFiles = mkOption { + type = with lib.types; listOf path; + default = [ ]; + description = '' + Environment files loaded into all NetBox services and consumable in + {option}`services.netbox.extraConfig`. + ''; + }; + settings = lib.mkOption { description = '' Configuration options to set in `configuration.py`. @@ -396,6 +414,7 @@ in StateDirectoryMode = "0750"; Restart = "on-failure"; RestartSec = 30; + EnvironmentFile = cfg.environmentFiles; }; in { From c36fc51c38bb90105caa2ae2fbf27c7d32301965 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Fri, 12 Jun 2026 23:02:52 +0200 Subject: [PATCH 06/16] nixos/tests/netbox: run in nspawn Faster and cheaper. --- nixos/tests/web-apps/netbox/default.nix | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index 6c1a48b30d94..beb6d4a267f0 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -27,10 +27,9 @@ import ../../make-test-python.nix ( skipTypeCheck = true; - nodes.machine = + containers.machine = { config, ... }: { - virtualisation.memorySize = 2048; services.netbox = { enable = true; package = netbox; From 43bccced4775619fa4358043b62fcb857a374ed6 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 02:37:12 +0200 Subject: [PATCH 07/16] nixos/tests/netbox*: use unittest asserts These provide much richer detail, when assertions fail. --- nixos/tests/web-apps/netbox-upgrade.nix | 2 +- nixos/tests/web-apps/netbox/testScript.py | 36 +++++++++++------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/nixos/tests/web-apps/netbox-upgrade.nix b/nixos/tests/web-apps/netbox-upgrade.nix index b8e23c6cf8de..f8ac7c261dd4 100644 --- a/nixos/tests/web-apps/netbox-upgrade.nix +++ b/nixos/tests/web-apps/netbox-upgrade.nix @@ -75,7 +75,7 @@ in headers = machine.succeed( "curl -sSL http://localhost/api/ --head -H 'Content-Type: application/json'" ) - assert api_version(headers) == version + t.assertEqual(api_version(headers), version) with subtest("NetBox version is the old one"): check_api_version("${oldApiVersion}") diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py index 8532a4538ff9..914e5648560c 100644 --- a/nixos/tests/web-apps/netbox/testScript.py +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -155,7 +155,7 @@ def patch(uri: str, data: Dict[str, Any]): return data_request(uri, "PATCH", data) with subtest("Can retrieve netbox version"): - assert netbox_version == get("/status/")["netbox-version"] + t.assertEqual(netbox_version, get("/status/")["netbox-version"]) with subtest("Can create objects"): result = post("/dcim/sites/", {"name": "Test site", "slug": "test-site"}) @@ -196,28 +196,28 @@ with subtest("Can create objects"): with subtest("Can list objects"): result = get("/dcim/sites/") - assert result["count"] == 1 - assert result["results"][0]["id"] == site_id - assert result["results"][0]["name"] == "Test site" - assert result["results"][0]["description"] == "" + t.assertEqual(result["count"], 1) + t.assertEqual(result["results"][0]["id"], site_id) + t.assertEqual(result["results"][0]["name"], "Test site") + t.assertEqual(result["results"][0]["description"], "") result = get("/dcim/device-types/") - assert result["count"] == 1 - assert result["results"][0]["id"] == device_type_id - assert result["results"][0]["model"] == "Test device type" + t.assertEqual(result["count"], 1) + t.assertEqual(result["results"][0]["id"], device_type_id) + t.assertEqual(result["results"][0]["model"], "Test device type") with subtest("Can update objects"): new_description = "Test site description" patch(f"/dcim/sites/{site_id}/", {"description": new_description}) result = get(f"/dcim/sites/{site_id}/") - assert result["description"] == new_description + t.assertEqual(result["description"], new_description) with subtest("Can delete objects"): # Delete a device-type since no object depends on it delete(f"/dcim/device-types/{device_type_id}/") result = get("/dcim/device-types/") - assert result["count"] == 0 + t.assertEqual(result["count"], 0) def request_graphql(query: str): return machine.succeed( @@ -252,10 +252,10 @@ if compare(netbox_version, '4.2.0') >= 0: answer = request_graphql(graphql_query) result = json.loads(answer) - assert len(result["data"]["prefix_list"]) == 3 - assert test_objects["prefixes"]["v4-with-updated-desc"] in result["data"]["prefix_list"] - assert test_objects["prefixes"]["v6-cidr-32"] in result["data"]["prefix_list"] - assert test_objects["prefixes"]["v6-cidr-48"] in result["data"]["prefix_list"] + t.assertEqual(len(result["data"]["prefix_list"]), 3) + t.assertIn(test_objects["prefixes"]["v4-with-updated-desc"], result["data"]["prefix_list"]) + t.assertIn(test_objects["prefixes"]["v6-cidr-32"], result["data"]["prefix_list"]) + t.assertIn(test_objects["prefixes"]["v6-cidr-48"], result["data"]["prefix_list"]) if compare(netbox_version, '4.2.0') < 0: with subtest("Can use the GraphQL API (Netbox <= 4.2.0)"): @@ -270,8 +270,8 @@ if compare(netbox_version, '4.2.0') < 0: ''') result = json.loads(answer) print(result["data"]["prefix_list"][0]) - assert result["data"]["prefix_list"][0]["prefix"] == test_objects["prefixes"]["v4-with-updated-desc"]["prefix"] - assert int(result["data"]["prefix_list"][0]["site"]["id"]) == int(test_objects["prefixes"]["v4-with-updated-desc"]["scope"]["id"]) + t.assertEqual(result["data"]["prefix_list"][0]["prefix"], test_objects["prefixes"]["v4-with-updated-desc"]["prefix"]) + t.assertEqual(int(result["data"]["prefix_list"][0]["site"]["id"]), int(test_objects["prefixes"]["v4-with-updated-desc"]["scope"]["id"])) # With 4.5.2 and higher, obtaining a session cookie or token without supplying # proper CSRF tokens on the frontend /login/ endpoint is no longer possible @@ -283,5 +283,5 @@ if compare(netbox_version, '4.5.2') < 0: with subtest("Can associate LDAP groups"): result = get("/users/users/?username=${testUser}") - assert result["count"] == 1 - assert any(group["name"] == "${testGroup}" for group in result["results"][0]["groups"]) + t.assertEqual(result["count"], 1) + t.assertTrue(any(group["name"] == "${testGroup}" for group in result["results"][0]["groups"])) From 37220b42c4c5e1b4b6557127d82376092d1c7a87 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 04:11:32 +0200 Subject: [PATCH 08/16] nixos/netbox: refactor settings and make config introspectable Configuring a module from withing config is bad style. This makes what gets configured not introspectable. Worse, it requires special care to handle priorities correctly, so things will correctly overwrite or merge. The `GIT_PATH` setting was removed, when Netbox migrated Git operations to dulwhich. https://github.com/netbox-community/netbox/commit/b9cac97b73c1c08213db6272de11c03711491486 Furthermore, this reduces LDAP to just a single option, because once an LDAP configuration is set, we can already make assumptions. --- nixos/modules/services/web-apps/netbox.nix | 336 ++++++++++++++------- nixos/tests/web-apps/netbox/default.nix | 3 +- 2 files changed, 236 insertions(+), 103 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 214313b46321..38332d123ea9 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -12,14 +12,15 @@ let mkChangedOptionModule mkOption mkRemovedOptionModule + mkRenamedOptionModule optionalString + types ; cfg = config.services.netbox; - pythonFmt = pkgs.formats.pythonVars { }; - staticDir = cfg.dataDir + "/static"; + pythonVars = pkgs.formats.pythonVars { }; - settingsFile = pythonFmt.generate "netbox-settings.py" cfg.settings; + settingsFile = pythonVars.generate "netbox-settings.py" cfg.settings; extraConfigFile = pkgs.writeTextFile { name = "netbox-extraConfig.py"; text = cfg.extraConfig; @@ -48,6 +49,8 @@ let ''; }; + enableLDAP = cfg.ldapConfigFile != null; + pkg = (cfg.package.overrideAttrs (old: { installPhase = @@ -55,8 +58,8 @@ let + '' ln -s ${configFile} $out/opt/netbox/netbox/netbox/configuration.py '' - + lib.optionalString cfg.enableLdap '' - ln -s ${cfg.ldapConfigPath} $out/opt/netbox/netbox/netbox/ldap_config.py + + lib.optionalString enableLDAP '' + ln -s ${cfg.ldapConfigFile} $out/opt/netbox/netbox/netbox/ldap_config.py ''; })).override { @@ -108,22 +111,35 @@ in (mkRemovedOptionModule [ "services" "netbox" "keycloakClientSecret" ] '' Too much granularity hurts maintainability. Please configure secret key loading via `services.netbox.extraConfig` instead. '') + (mkRemovedOptionModule [ "services" "netbox" "enableLdap" ] '' + LDAP support is automatically enabled when `services.netbox.ldapConfigFile` is configured. + '') + (mkRenamedOptionModule + [ "services" "netbox" "ldapConfigPath" ] + [ "services" "netbox" "ldapConfigFile" ] + ) ]; options.services.netbox = { enable = lib.mkOption { - type = lib.types.bool; + type = types.bool; default = false; description = '' - Enable Netbox. + Whether to enable Netbox, a DCIM and IPAM source of truth. - This module requires a reverse proxy that serves `/static` separately. - See this [example](https://github.com/netbox-community/netbox/blob/develop/contrib/nginx.conf/) on how to configure this. + This module requires setting up a reverse proxy. The NetBox project has + example configurations for [nginx] and the [Apache httpd] server. + + The important change to make is to serve `/static` from + `''${config.services.netbox.settings.STATIC_ROOT}`. + + [nginx]: https://github.com/netbox-community/netbox/blob/main/contrib/nginx.conf + [Apache httpd]: https://github.com/netbox-community/netbox/blob/main/contrib/apache.conf ''; }; environmentFiles = mkOption { - type = with lib.types; listOf path; + type = with types; listOf path; default = [ ]; description = '' Environment files loaded into all NetBox services and consumable in @@ -133,30 +149,216 @@ in settings = lib.mkOption { description = '' - Configuration options to set in `configuration.py`. - See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options. + The main {file}`configuration.py` to set up NetBox. + + Can be used to define flat and nested key-value pairs. Check the \ + [NetBox documentation] for possible options. + + ::: {.tip} + Use {option}`services.netbox.extraConfig` to extend this file with Python code. + ::: + + [NetBox documentation]: https://netboxlabs.com/docs/netbox/configuration/#configuration-file ''; - default = { }; - - type = lib.types.submodule { - freeformType = pythonFmt.type; - + type = types.submodule { + freeformType = pythonVars.type; options = { ALLOWED_HOSTS = lib.mkOption { - type = with lib.types; listOf str; + type = with types; listOf str; default = [ "*" ]; description = '' A list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. ''; }; + + STATIC_ROOT = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.dataDir}/static/"; + defaultText = lib.literalExpression "$${config.services.netbox.dataDir}/static/"; + description = '' + Path to the collected static assets, served below `/static/`. + ''; + }; + + MEDIA_ROOT = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.dataDir}/media/"; + defaultText = lib.literalExpression "$${config.services.netbox.dataDir}/media"; + description = '' + Path where uploaded media is stored. + ''; + }; + + REPORTS_ROOT = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.dataDir}/reports/"; + defaultText = lib.literalExpression "$${config.services.netbox.dataDir}/reports"; + description = '' + Path where generated reports are stored. + ''; + }; + + SCRIPTS_ROOT = mkOption { + type = types.path; + readOnly = true; + default = "${cfg.dataDir}/scripts/"; + defaultText = lib.literalExpression "$${config.services.netbox.dataDir}/scripts"; + description = '' + Path where scripts are stored. + ''; + }; + + DATABASES = mkOption { + type = with types; attrsOf (attrsOf str); + default = { + "default" = { + NAME = "netbox"; + USER = "netbox"; + HOST = "/run/postgresql"; + }; + }; + description = '' + Configuration for one or multiple [database] backends. + + At least one database named `default` must be defined. + + [database]: https://netbox.readthedocs.io/en/stable/configuration/required-parameters/#database + ''; + }; + + # Redis database settings. Redis is used for caching and for queuing + # background tasks such as webhook events. A separate configuration + # exists for each. Full connection details are required in both + # sections, and it is strongly recommended to use two separate database + # IDs. + REDIS = { + tasks = { + URL = mkOption { + type = types.str; + default = "unix://${config.services.redis.servers.netbox.unixSocket}?db=0"; + defaultText = lib.literalExpression "unix://$${config.services.redis.servers.netbox.unixSocket}?db=0"; + description = '' + Redis database connection for queuing background tasks. + + > It is highly recommended to keep the task and cache + > databases separate. Using the same database number on the + > same Redis instance for both may result in queued background + > tasks being lost during cache flushing events. + + + ''; + }; + }; + caching = { + URL = mkOption { + type = types.str; + default = "unix://${config.services.redis.servers.netbox.unixSocket}?db=1"; + defaultText = "unix://$${config.services.redis.servers.netbox.unixSocket}?db=0"; + description = '' + Redis database connection for caching. + + > It is highly recommended to keep the task and cache + > databases separate. Using the same database number on the + > same Redis instance for both may result in queued background + > tasks being lost during cache flushing events. + + + ''; + }; + }; + }; + + REMOTE_AUTH_BACKEND = mkOption { + type = + with types; + oneOf [ + str + (listOf str) + ]; + default = + if enableLDAP then + "netbox.authentication.LDAPBackend" + else + "netbox.authentication.RemoteUserBackend"; + defaultText = lib.literalExpression '' + if config.services.netbox.ldapConfigFile != null then + "netbox.authentication.LDAPBackend" + else + "netbox.authentication.RemoteUserBackend" + ''; + description = '' + One or multiple [backends] used for authenticating external users. + + When multiple backends are specified, they are tried in order. + + [backends]: https://netbox.readthedocs.io/en/stable/configuration/remote-authentication/#remote_auth_backend + ''; + }; + + LOGGING = mkOption { + type = pythonVars.type; + default = { + version = 1; + + formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s"; + + handlers.console = { + class = "logging.StreamHandler"; + formatter = "precise"; + }; + + # log to console/systemd instead of file + root = { + level = "INFO"; + handlers = [ "console" ]; + }; + }; + description = '' + [Logging configuration] based on the Python [`logging.config`] module. + + [`logging.config`]: https://docs.python.org/3/library/logging.config.html + [Logging configuration]: https://netboxlabs.com/docs/netbox/configuration/system/#logging + ''; + }; }; }; }; + extraConfig = lib.mkOption { + type = types.lines; + default = ""; + example = '' + from os import environ + + # https://python-social-auth.readthedocs.io/en/latest/backends/oidc.html + # From the environment: + SOCIAL_AUTH_OIDC_SECRET = environ.get("OIDC_CLIENT_SECRET") + + # From a file: + with open("/run/keys/oidc-client-secret") as fd: + SOCIAL_AUTH_OIDC_SECRET = fd.read().strip() + ''; + description = '' + Additional lines that are appended to {file}`configuration.py`. + + This option supports native Python code and can be used for reading + secrets from files or the environment into configuration variables: + + Possible options can be found in the [NetBox documentation] or, for + authentication purposes, in the [Python Social Auth] documentation. + + [NetBox documentation]: https://netboxlabs.com/docs/netbox/configuration/ + [Python Social Auth]: https://python-social-auth.readthedocs.io/en/latest/backends/index.html# + ''; + }; + bind = lib.mkOption { - type = lib.types.str; + type = types.str; default = "unix:/run/netbox/netbox.sock"; example = "[::1]:8001"; description = '' @@ -171,7 +373,7 @@ in }; gunicornArgs = lib.mkOption { - type = lib.types.listOf lib.types.str; + type = types.listOf types.str; default = [ ]; description = "extra args for gunicorn when serving netbox"; example = [ @@ -181,7 +383,7 @@ in }; package = lib.mkOption { - type = lib.types.package; + type = types.package; default = if lib.versionAtLeast config.system.stateVersion "26.05" then pkgs.netbox_4_5 else pkgs.netbox_4_4; defaultText = lib.literalExpression '' @@ -195,7 +397,7 @@ in }; plugins = lib.mkOption { - type = with lib.types; functionTo (listOf package); + type = with types; functionTo (listOf package); default = _: [ ]; defaultText = lib.literalExpression '' python3Packages: with python3Packages; []; @@ -206,7 +408,7 @@ in }; dataDir = lib.mkOption { - type = lib.types.str; + type = types.str; default = "/var/lib/netbox"; description = '' Storage path of netbox. @@ -215,7 +417,7 @@ in secretKeyFile = lib.mkOption { type = - with lib.types; + with types; nullOr (pathWith { inStore = false; }); @@ -238,7 +440,7 @@ in apiTokenPepperFiles = lib.mkOption { type = - with lib.types; + with types; attrsOf (pathWith { inStore = false; }); @@ -269,31 +471,16 @@ in ''; }; - extraConfig = lib.mkOption { - type = lib.types.lines; - default = ""; + ldapConfigFile = lib.mkOption { + type = with types; nullOr path; + default = null; description = '' - Additional lines of configuration appended to the `configuration.py`. - See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options. - ''; - }; + Path to the [LDAP configuration] file, also known as {file}`ldap_config.py`. - enableLdap = lib.mkOption { - type = lib.types.bool; - default = false; - description = '' - Enable LDAP-Authentication for Netbox. + When set, will automatically load the `django-auth-ldap` plugin and + configure {option}`services.netbox.settings.REMOTE_AUTH_BACKEND`. - This requires a configuration file being pass through `ldapConfigPath`. - ''; - }; - - ldapConfigPath = lib.mkOption { - type = lib.types.path; - default = ""; - description = '' - Path to the Configuration-File for LDAP-Authentication, will be loaded as `ldap_config.py`. - See the [documentation](https://netbox.readthedocs.io/en/stable/installation/6-ldap/#configuration) for possible options. + [LDAP configuration]: https://netbox.readthedocs.io/en/stable/installation/6-ldap/#configuration ''; example = '' import ldap @@ -324,60 +511,7 @@ in }; config = lib.mkIf cfg.enable { - services.netbox = { - plugins = lib.mkIf cfg.enableLdap (ps: [ ps.django-auth-ldap ]); - settings = { - STATIC_ROOT = staticDir; - MEDIA_ROOT = "${cfg.dataDir}/media"; - REPORTS_ROOT = "${cfg.dataDir}/reports"; - SCRIPTS_ROOT = "${cfg.dataDir}/scripts"; - - GIT_PATH = "${pkgs.gitMinimal}/bin/git"; - - DATABASES = { - "default" = { - NAME = "netbox"; - USER = "netbox"; - HOST = "/run/postgresql"; - }; - }; - - # Redis database settings. Redis is used for caching and for queuing - # background tasks such as webhook events. A separate configuration - # exists for each. Full connection details are required in both - # sections, and it is strongly recommended to use two separate database - # IDs. - REDIS = { - tasks = { - URL = "unix://${config.services.redis.servers.netbox.unixSocket}?db=0"; - SSL = false; - }; - caching = { - URL = "unix://${config.services.redis.servers.netbox.unixSocket}?db=1"; - SSL = false; - }; - }; - - REMOTE_AUTH_BACKEND = lib.mkIf cfg.enableLdap "netbox.authentication.LDAPBackend"; - - LOGGING = lib.mkDefault { - version = 1; - - formatters.precise.format = "[%(levelname)s@%(name)s] %(message)s"; - - handlers.console = { - class = "logging.StreamHandler"; - formatter = "precise"; - }; - - # log to console/systemd instead of file - root = { - level = "INFO"; - handlers = [ "console" ]; - }; - }; - }; - }; + services.netbox.plugins = lib.mkIf enableLDAP (ps: [ ps.django-auth-ldap ]); services.redis.servers.netbox.enable = true; diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index beb6d4a267f0..176b08d7c952 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -34,8 +34,7 @@ import ../../make-test-python.nix ( enable = true; package = netbox; - enableLdap = true; - ldapConfigPath = pkgs.writeText "ldap_config.py" '' + ldapConfigFile = pkgs.writeText "ldap_config.py" '' import ldap from django_auth_ldap.config import LDAPSearch, PosixGroupType From 06a84e08885c5e184d7d1f43207000896484e74c Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 04:37:39 +0200 Subject: [PATCH 09/16] nixos/netbox: shared unit settings, structured command line The shared unit settings make it easier to make updates across the units. The structured command line always ensures proper tokenization. --- nixos/modules/services/web-apps/netbox.nix | 123 +++++++++++---------- 1 file changed, 67 insertions(+), 56 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 38332d123ea9..4b00188dd56b 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -51,10 +51,10 @@ let enableLDAP = cfg.ldapConfigFile != null; - pkg = - (cfg.package.overrideAttrs (old: { + finalPackage = + (cfg.package.overrideAttrs (prev: { installPhase = - old.installPhase + prev.installPhase + '' ln -s ${configFile} $out/opt/netbox/netbox/netbox/configuration.py '' @@ -73,15 +73,17 @@ let . "${envFile}" '') cfg.environmentFiles} set +a - export PYTHONPATH=${pkg.pythonPath} + export PYTHONPATH=${finalPackage.pythonPath} case "$(whoami)" in "root") - ${lib.getExe' pkgs.util-linux "runuser"} ${lib.cli.toCommandLineShellGNU { } { - preserve-environment = true; - user = "netbox"; - }} -- ${pkg}/bin/netbox "$@";; + ${lib.getExe' pkgs.util-linux "runuser"} ${ + lib.cli.toCommandLineShellGNU { } { + preserve-environment = true; + user = "netbox"; + } + } -- ${finalPackage}/bin/netbox "$@";; "netbox") - exec ${pkg}/bin/netbox "$@";; + exec ${finalPackage}/bin/netbox "$@";; *) echo "This must be run by either the root or the 'netbox' user." >&2 exit 1 @@ -118,6 +120,9 @@ in [ "services" "netbox" "ldapConfigPath" ] [ "services" "netbox" "ldapConfigFile" ] ) + (mkRemovedOptionModule [ "services" "nginx" "gunicornArgs" ] '' + Removed in favor of `services.netbox.gunicorn.extraArgs`, an attribute set passed to `lib.cli.toCommandLineGNU`. + '') ]; options.services.netbox = { @@ -372,14 +377,19 @@ in ''; }; - gunicornArgs = lib.mkOption { - type = types.listOf types.str; - default = [ ]; - description = "extra args for gunicorn when serving netbox"; - example = [ - "--workers" - "9" - ]; + gunicorn.extraArgs = lib.mkOption { + type = types.attrsOf types.str; + default = { }; + description = '' + Extra arguments passed the Gunicorn process that runs NetBox. + + See for possible flags. + ''; + example = lib.literalExpression '' + { + workers = 9; + ]; + ''; }; package = lib.mkOption { @@ -540,6 +550,10 @@ in systemd.services = let + defaultUnitConfig = { + documentation = [ "https://netboxlabs.com/docs/netbox/" ]; + environment.PYTHONPATH = finalPackage.pythonPath; + }; defaultServiceConfig = { WorkingDirectory = "${cfg.dataDir}"; User = "netbox"; @@ -552,29 +566,26 @@ in }; in { - netbox = { + netbox = defaultUnitConfig // { description = "NetBox WSGI Service"; - documentation = [ "https://docs.netbox.dev/" ]; wantedBy = [ "netbox.target" ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; - environment.PYTHONPATH = pkg.pythonPath; - preStart = '' # Generate random default secrets, if the user didn't supply any. ${optionalString (cfg.secretKeyFile == null) '' if [ ! -e "${secretKeyFile}" ]; then - ${pkg}/opt/netbox/netbox/generate_secret_key.py > "${secretKeyFile}" + ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${secretKeyFile}" fi ''} ${optionalString (any (path: path == "${cfg.dataDir}/pepper.1") (attrValues cfg.apiTokenPepperFiles)) '' if [ ! -e "${cfg.dataDir}/pepper.1" ]; then - ${pkg}/opt/netbox/netbox/generate_secret_key.py > "${cfg.dataDir}/pepper.1" + ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${cfg.dataDir}/pepper.1" fi '' } @@ -587,55 +598,56 @@ in exit 0 fi - ${pkg}/bin/netbox migrate - ${pkg}/bin/netbox trace_paths --no-input - ${pkg}/bin/netbox collectstatic --clear --no-input - ${pkg}/bin/netbox remove_stale_contenttypes --no-input - ${pkg}/bin/netbox reindex --lazy - ${pkg}/bin/netbox clearsessions - ${lib.optionalString - # The clearcache command was removed in 3.7.0: - # https://github.com/netbox-community/netbox/issues/14458 - (lib.versionOlder cfg.package.version "3.7.0") - "${pkg}/bin/netbox clearcache" - } + ${lib.getExe finalPackage} migrate + ${lib.getExe finalPackage} trace_paths --no-input + ${lib.getExe finalPackage} collectstatic --clear --no-input + ${lib.getExe finalPackage} remove_stale_contenttypes --no-input + ${lib.getExe finalPackage} reindex --lazy + ${lib.getExe finalPackage} clearsessions ln -sfn "${cfg.package}" "$versionFile" ''; serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${pkg.gunicorn}/bin/gunicorn netbox.wsgi \ - --bind ${cfg.bind} \ - --pythonpath ${pkg}/opt/netbox/netbox \ - ${lib.concatStringsSep " " cfg.gunicornArgs} - ''; + ExecStart = toString ( + [ + (lib.getExe finalPackage.gunicorn) + "netbox.wsgi" + ] + ++ lib.cli.toCommandLineGNU { } ( + { + inherit (cfg) bind; + pythonpath = "${finalPackage}/opt/netbox/netbox"; + } + // cfg.gunicorn.extraArgs + ) + ); PrivateTmp = true; RuntimeDirectory = "netbox"; TimeoutStartSec = lib.mkDefault "10min"; }; }; - netbox-rq = { + netbox-rq = defaultUnitConfig // { description = "NetBox Request Queue Worker"; - documentation = [ "https://docs.netbox.dev/" ]; wantedBy = [ "netbox.target" ]; after = [ "netbox.service" ]; - environment.PYTHONPATH = pkg.pythonPath; - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${pkg}/bin/netbox rqworker high default low - ''; + ExecStart = toString [ + (lib.getExe finalPackage) + "rqworker" + "high" + "default" + "low" + ]; PrivateTmp = true; }; }; - netbox-housekeeping = { + netbox-housekeeping = defaultUnitConfig // { description = "NetBox housekeeping job"; - documentation = [ "https://docs.netbox.dev/" ]; wantedBy = [ "multi-user.target" ]; @@ -645,20 +657,19 @@ in ]; wants = [ "network-online.target" ]; - environment.PYTHONPATH = pkg.pythonPath; - serviceConfig = defaultServiceConfig // { Type = "oneshot"; - ExecStart = '' - ${pkg}/bin/netbox housekeeping - ''; + ExecStart = toString [ + (lib.getExe finalPackage) + "housekeeping" + ]; }; }; }; systemd.timers.netbox-housekeeping = { description = "Run NetBox housekeeping job"; - documentation = [ "https://docs.netbox.dev/" ]; + documentation = [ "https://netboxlabs.com/docs/netbox/" ]; wantedBy = [ "multi-user.target" ]; From 53e8dc133a49534a6c7fb2af3c2a3b263f12d9f9 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 05:00:10 +0200 Subject: [PATCH 10/16] nixos/netbox: put all services into a systemd slice Simplifies resource overview and management. --- nixos/modules/services/web-apps/netbox.nix | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 4b00188dd56b..37a0f76257b7 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -538,6 +538,10 @@ in environment.systemPackages = [ netboxManageScript ]; + systemd.slices.system-netbox = { + description = "Netbox DCIM/IPAM"; + }; + systemd.targets.netbox = { description = "Target for all NetBox services"; wantedBy = [ "multi-user.target" ]; @@ -562,6 +566,7 @@ in StateDirectoryMode = "0750"; Restart = "on-failure"; RestartSec = 30; + Slice = "system-netbox.slice"; EnvironmentFile = cfg.environmentFiles; }; in From 4d576c4839a28b6094386384eb5abcf7ec12e57c Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 19:17:05 +0200 Subject: [PATCH 11/16] nixos/tests/netbox: use wait_for_file helper --- nixos/tests/web-apps/netbox/testScript.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py index 914e5648560c..9a38b7288bd6 100644 --- a/nixos/tests/web-apps/netbox/testScript.py +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -66,7 +66,7 @@ with subtest("Home screen loads"): ) with subtest("Staticfiles are generated"): - machine.succeed("test -e /var/lib/netbox/static/netbox.js") + machine.wait_for_file("/var/lib/netbox/static/netbox.js") with subtest("Superuser can be created"): machine.succeed( From 5bab8a265b7a195df3e5fb133190ffb7fc61b1a9 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 13 Jun 2026 20:53:56 +0200 Subject: [PATCH 12/16] nixos/netbox: allow opt-out from local postgres and redis This is important, because now users can easily understand that the module takes care of these things, and opt-out if needed. --- nixos/modules/services/web-apps/netbox.nix | 23 ++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 37a0f76257b7..584b159309f8 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -392,6 +392,25 @@ in ''; }; + redis.createLocally = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable and set up a Redis database for NetBox locally. + ''; + }; + + postgresql.createLocally = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable and set up PostgreSQL locally. + + This will automatically created a database and a local user, that can + authenticate over Unix domain sockets with `SO_PEERCRED`. + ''; + }; + package = lib.mkOption { type = types.package; default = @@ -523,9 +542,9 @@ in config = lib.mkIf cfg.enable { services.netbox.plugins = lib.mkIf enableLDAP (ps: [ ps.django-auth-ldap ]); - services.redis.servers.netbox.enable = true; + services.redis.servers.netbox.enable = cfg.redis.createLocally; - services.postgresql = { + services.postgresql = lib.mkIf cfg.postgresql.createLocally { enable = true; ensureDatabases = [ "netbox" ]; ensureUsers = [ From b50a65023c2f5a0b79ccc9d38238e00c56aa1d2c Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 20 Jun 2026 18:26:43 +0200 Subject: [PATCH 13/16] nixos/netbox: add opt-in nginx support This is part of making way for simpler onboarding, because it stops requiring users to guess where to point the static directory to. --- nixos/modules/services/web-apps/netbox.nix | 347 ++++++++++++--------- nixos/tests/web-apps/netbox-upgrade.nix | 13 +- nixos/tests/web-apps/netbox/default.nix | 17 +- 3 files changed, 201 insertions(+), 176 deletions(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 584b159309f8..e3373f3c962d 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -10,6 +10,7 @@ let any attrValues mkChangedOptionModule + mkEnableOption mkOption mkRemovedOptionModule mkRenamedOptionModule @@ -132,8 +133,9 @@ in description = '' Whether to enable Netbox, a DCIM and IPAM source of truth. - This module requires setting up a reverse proxy. The NetBox project has - example configurations for [nginx] and the [Apache httpd] server. + This module requires setting up a reverse proxy and has native support + for nginx. Additionally, the NetBox project has example configurations + for [nginx] and the [Apache httpd] server. The important change to make is to serve `/static` from `''${config.services.netbox.settings.STATIC_ROOT}`. @@ -392,6 +394,23 @@ in ''; }; + nginx = { + enable = mkEnableOption "nginx and configure a virtual host"; + + hostname = mkOption { + type = types.str; + example = "netbox.example.com"; + description = '' + The hostname for which an nginx virtual host should be created. + + ::: {.tip} + Customize the virtual host through + `services.nginx.virtualHosts.''${config.services.netbox.nginx.hostname}`. + ::: + ''; + }; + }; + redis.createLocally = mkOption { type = types.bool; default = true; @@ -539,139 +558,164 @@ in }; }; - config = lib.mkIf cfg.enable { - services.netbox.plugins = lib.mkIf enableLDAP (ps: [ ps.django-auth-ldap ]); - - services.redis.servers.netbox.enable = cfg.redis.createLocally; - - services.postgresql = lib.mkIf cfg.postgresql.createLocally { - enable = true; - ensureDatabases = [ "netbox" ]; - ensureUsers = [ - { - name = "netbox"; - ensureDBOwnership = true; - } - ]; - }; - - environment.systemPackages = [ netboxManageScript ]; - - systemd.slices.system-netbox = { - description = "Netbox DCIM/IPAM"; - }; - - systemd.targets.netbox = { - description = "Target for all NetBox services"; - wantedBy = [ "multi-user.target" ]; - wants = [ "network-online.target" ]; - after = [ - "network-online.target" - "redis-netbox.service" - ]; - }; - - systemd.services = - let - defaultUnitConfig = { - documentation = [ "https://netboxlabs.com/docs/netbox/" ]; - environment.PYTHONPATH = finalPackage.pythonPath; - }; - defaultServiceConfig = { - WorkingDirectory = "${cfg.dataDir}"; - User = "netbox"; - Group = "netbox"; - StateDirectory = "netbox"; - StateDirectoryMode = "0750"; - Restart = "on-failure"; - RestartSec = 30; - Slice = "system-netbox.slice"; - EnvironmentFile = cfg.environmentFiles; - }; - in + config = lib.mkIf cfg.enable ( + lib.mkMerge [ { - netbox = defaultUnitConfig // { - description = "NetBox WSGI Service"; + services.netbox.plugins = lib.mkIf enableLDAP (ps: [ ps.django-auth-ldap ]); - wantedBy = [ "netbox.target" ]; + services.redis.servers.netbox.enable = cfg.redis.createLocally; - after = [ "network-online.target" ]; - wants = [ "network-online.target" ]; - - preStart = '' - # Generate random default secrets, if the user didn't supply any. - ${optionalString (cfg.secretKeyFile == null) '' - if [ ! -e "${secretKeyFile}" ]; then - ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${secretKeyFile}" - fi - ''} - ${optionalString - (any (path: path == "${cfg.dataDir}/pepper.1") (attrValues cfg.apiTokenPepperFiles)) - '' - if [ ! -e "${cfg.dataDir}/pepper.1" ]; then - ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${cfg.dataDir}/pepper.1" - fi - '' + services.postgresql = lib.mkIf cfg.postgresql.createLocally { + enable = true; + ensureDatabases = [ "netbox" ]; + ensureUsers = [ + { + name = "netbox"; + ensureDBOwnership = true; } + ]; + }; - # On the first run, or on upgrade / downgrade, run migrations and related. - # This mostly correspond to upstream NetBox's 'upgrade.sh' script. - versionFile="${cfg.dataDir}/version" + environment.systemPackages = [ netboxManageScript ]; - if [[ -h "$versionFile" && "$(readlink -- "$versionFile")" == "${cfg.package}" ]]; then - exit 0 - fi + systemd.slices.system-netbox = { + description = "Netbox DCIM/IPAM"; + }; - ${lib.getExe finalPackage} migrate - ${lib.getExe finalPackage} trace_paths --no-input - ${lib.getExe finalPackage} collectstatic --clear --no-input - ${lib.getExe finalPackage} remove_stale_contenttypes --no-input - ${lib.getExe finalPackage} reindex --lazy - ${lib.getExe finalPackage} clearsessions + systemd.targets.netbox = { + description = "Target for all NetBox services"; + wantedBy = [ "multi-user.target" ]; + wants = [ "network-online.target" ]; + after = [ + "network-online.target" + "redis-netbox.service" + ]; + }; - ln -sfn "${cfg.package}" "$versionFile" - ''; + systemd.services = + let + defaultUnitConfig = { + documentation = [ "https://netboxlabs.com/docs/netbox/" ]; + environment.PYTHONPATH = finalPackage.pythonPath; + }; + defaultServiceConfig = { + WorkingDirectory = "${cfg.dataDir}"; + User = "netbox"; + Group = "netbox"; + StateDirectory = "netbox"; + StateDirectoryMode = "0750"; + Restart = "on-failure"; + RestartSec = 30; + Slice = "system-netbox.slice"; + EnvironmentFile = cfg.environmentFiles; + }; + in + { + netbox = defaultUnitConfig // { + description = "NetBox WSGI Service"; - serviceConfig = defaultServiceConfig // { - ExecStart = toString ( - [ - (lib.getExe finalPackage.gunicorn) - "netbox.wsgi" - ] - ++ lib.cli.toCommandLineGNU { } ( - { - inherit (cfg) bind; - pythonpath = "${finalPackage}/opt/netbox/netbox"; + wantedBy = [ "netbox.target" ]; + + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + preStart = '' + # Generate random default secrets, if the user didn't supply any. + ${optionalString (cfg.secretKeyFile == null) '' + if [ ! -e "${secretKeyFile}" ]; then + ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${secretKeyFile}" + fi + ''} + ${optionalString + (any (path: path == "${cfg.dataDir}/pepper.1") (attrValues cfg.apiTokenPepperFiles)) + '' + if [ ! -e "${cfg.dataDir}/pepper.1" ]; then + ${finalPackage}/opt/netbox/netbox/generate_secret_key.py > "${cfg.dataDir}/pepper.1" + fi + '' } - // cfg.gunicorn.extraArgs - ) - ); - PrivateTmp = true; - RuntimeDirectory = "netbox"; - TimeoutStartSec = lib.mkDefault "10min"; + + # On the first run, or on upgrade / downgrade, run migrations and related. + # This mostly correspond to upstream NetBox's 'upgrade.sh' script. + versionFile="${cfg.dataDir}/version" + + if [[ -h "$versionFile" && "$(readlink -- "$versionFile")" == "${cfg.package}" ]]; then + exit 0 + fi + + ${lib.getExe finalPackage} migrate + ${lib.getExe finalPackage} trace_paths --no-input + ${lib.getExe finalPackage} collectstatic --clear --no-input + ${lib.getExe finalPackage} remove_stale_contenttypes --no-input + ${lib.getExe finalPackage} reindex --lazy + ${lib.getExe finalPackage} clearsessions + + ln -sfn "${cfg.package}" "$versionFile" + ''; + + serviceConfig = defaultServiceConfig // { + ExecStart = toString ( + [ + (lib.getExe finalPackage.gunicorn) + "netbox.wsgi" + ] + ++ lib.cli.toCommandLineGNU { } ( + { + inherit (cfg) bind; + pythonpath = "${finalPackage}/opt/netbox/netbox"; + } + // cfg.gunicorn.extraArgs + ) + ); + PrivateTmp = true; + RuntimeDirectory = "netbox"; + RuntimeDirectoryMode = "0750"; + TimeoutStartSec = lib.mkDefault "10min"; + }; + }; + + netbox-rq = defaultUnitConfig // { + description = "NetBox Request Queue Worker"; + + wantedBy = [ "netbox.target" ]; + after = [ "netbox.service" ]; + + serviceConfig = defaultServiceConfig // { + ExecStart = toString [ + (lib.getExe finalPackage) + "rqworker" + "high" + "default" + "low" + ]; + PrivateTmp = true; + }; + }; + + netbox-housekeeping = defaultUnitConfig // { + description = "NetBox housekeeping job"; + + wantedBy = [ "multi-user.target" ]; + + after = [ + "network-online.target" + "netbox.service" + ]; + wants = [ "network-online.target" ]; + + serviceConfig = defaultServiceConfig // { + Type = "oneshot"; + ExecStart = toString [ + (lib.getExe finalPackage) + "housekeeping" + ]; + }; + }; }; - }; - netbox-rq = defaultUnitConfig // { - description = "NetBox Request Queue Worker"; - - wantedBy = [ "netbox.target" ]; - after = [ "netbox.service" ]; - - serviceConfig = defaultServiceConfig // { - ExecStart = toString [ - (lib.getExe finalPackage) - "rqworker" - "high" - "default" - "low" - ]; - PrivateTmp = true; - }; - }; - - netbox-housekeeping = defaultUnitConfig // { - description = "NetBox housekeeping job"; + systemd.timers.netbox-housekeeping = { + description = "Run NetBox housekeeping job"; + documentation = [ "https://netboxlabs.com/docs/netbox/" ]; wantedBy = [ "multi-user.target" ]; @@ -681,41 +725,36 @@ in ]; wants = [ "network-online.target" ]; - serviceConfig = defaultServiceConfig // { - Type = "oneshot"; - ExecStart = toString [ - (lib.getExe finalPackage) - "housekeeping" - ]; + timerConfig = { + OnCalendar = "daily"; + AccuracySec = "1h"; + Persistent = true; }; }; - }; - systemd.timers.netbox-housekeeping = { - description = "Run NetBox housekeeping job"; - documentation = [ "https://netboxlabs.com/docs/netbox/" ]; + users.users.netbox = { + home = "${cfg.dataDir}"; + isSystemUser = true; + group = "netbox"; + }; + users.groups.netbox = { }; + users.groups."${config.services.redis.servers.netbox.user}".members = [ "netbox" ]; + } - wantedBy = [ "multi-user.target" ]; + (lib.mkIf cfg.nginx.enable { + # Access to STATIC_ROOT and the UNIX domain socket + systemd.services.nginx.serviceConfig.SupplementaryGroups = [ "netbox" ]; - after = [ - "network-online.target" - "netbox.service" - ]; - wants = [ "network-online.target" ]; + services.nginx = { + enable = true; + recommendedProxySettings = true; - timerConfig = { - OnCalendar = "daily"; - AccuracySec = "1h"; - Persistent = true; - }; - }; - - users.users.netbox = { - home = "${cfg.dataDir}"; - isSystemUser = true; - group = "netbox"; - }; - users.groups.netbox = { }; - users.groups."${config.services.redis.servers.netbox.user}".members = [ "netbox" ]; - }; + virtualHosts.${cfg.nginx.hostname} = { + locations."/".proxyPass = "http://${toString config.services.netbox.bind}"; + locations."/static/".alias = cfg.settings.STATIC_ROOT; + }; + }; + }) + ] + ); } diff --git a/nixos/tests/web-apps/netbox-upgrade.nix b/nixos/tests/web-apps/netbox-upgrade.nix index f8ac7c261dd4..ffea21a648cd 100644 --- a/nixos/tests/web-apps/netbox-upgrade.nix +++ b/nixos/tests/web-apps/netbox-upgrade.nix @@ -34,17 +34,10 @@ in # Pick the NetBox package from this config's "pkgs" argument, # so that `nixpkgs.config.permittedInsecurePackages` works package = pkgs.${oldNetbox}; - }; - services.nginx = { - enable = true; - - recommendedProxySettings = true; - - virtualHosts.netbox = { - default = true; - locations."/".proxyPass = "http://${config.services.netbox.bind}"; - locations."/static/".alias = "/var/lib/netbox/static/"; + nginx = { + enable = true; + hostname = "localhost"; }; }; diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index 176b08d7c952..8ece8d8703fa 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -34,6 +34,11 @@ import ../../make-test-python.nix ( enable = true; package = netbox; + nginx = { + enable = true; + hostname = "localhost"; + }; + ldapConfigFile = pkgs.writeText "ldap_config.py" '' import ldap from django_auth_ldap.config import LDAPSearch, PosixGroupType @@ -61,18 +66,6 @@ import ../../make-test-python.nix ( ''; }; - services.nginx = { - enable = true; - - recommendedProxySettings = true; - - virtualHosts.netbox = { - default = true; - locations."/".proxyPass = "http://${toString config.services.netbox.bind}"; - locations."/static/".alias = "/var/lib/netbox/static/"; - }; - }; - # Adapted from the sssd-ldap NixOS test services.openldap = { enable = true; From f0cd25ba33f3160214c67e1deb94058411125e55 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 20 Jun 2026 18:27:47 +0200 Subject: [PATCH 14/16] nixos/netbox: harden systemd units Limits what the netbox services can do down to a reasonable scope. --- nixos/modules/services/web-apps/netbox.nix | 37 ++++++++++++++++++++++ nixos/tests/web-apps/netbox/default.nix | 4 +++ nixos/tests/web-apps/netbox/testScript.py | 3 ++ 3 files changed, 44 insertions(+) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index e3373f3c962d..0065fd382ab8 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -608,6 +608,43 @@ in RestartSec = 30; Slice = "system-netbox.slice"; EnvironmentFile = cfg.environmentFiles; + + AmbientCapabilities = [ "" ]; + CapabilityBoundingSet = [ "" ]; + DevicePolicy = "closed"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallErrorNumber = "EPERM"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + "~@resources" + "@chown" + ]; + UMask = "0027"; }; in { diff --git a/nixos/tests/web-apps/netbox/default.nix b/nixos/tests/web-apps/netbox/default.nix index 8ece8d8703fa..4b72f703df89 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -30,6 +30,10 @@ import ../../make-test-python.nix ( containers.machine = { config, ... }: { + boot.kernelParams = [ + # helps debugging seccomp filter issues + "audit=1" + ]; services.netbox = { enable = true; package = netbox; diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py index 9a38b7288bd6..2e98cf4d87ef 100644 --- a/nixos/tests/web-apps/netbox/testScript.py +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -285,3 +285,6 @@ if compare(netbox_version, '4.5.2') < 0: t.assertEqual(result["count"], 1) t.assertTrue(any(group["name"] == "${testGroup}" for group in result["results"][0]["groups"])) + +# Print systemd unit hardening state +machine.log(machine.execute("systemd-analyze security netbox.service netbox-rq.service netbox-housekeeping.service | grep -v ✓")[1]) From 7f7d069ad7fdcee7762b512a167ff58aa90c9d15 Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sat, 20 Jun 2026 22:07:17 +0200 Subject: [PATCH 15/16] nixos/netbox: add services to redis group conditionally This only makes sense when there is a local redis group, and then we can scope the group membership to the service instead of the whole user. --- nixos/modules/services/web-apps/netbox.nix | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 0065fd382ab8..d74c9c926b80 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -81,6 +81,7 @@ let lib.cli.toCommandLineShellGNU { } { preserve-environment = true; user = "netbox"; + supp-group = if cfg.redis.createLocally then config.services.redis.servers.netbox.group else null; } } -- ${finalPackage}/bin/netbox "$@";; "netbox") @@ -608,6 +609,9 @@ in RestartSec = 30; Slice = "system-netbox.slice"; EnvironmentFile = cfg.environmentFiles; + SupplementaryGroups = lib.optionals cfg.redis.createLocally [ + config.services.redis.servers.netbox.group + ]; AmbientCapabilities = [ "" ]; CapabilityBoundingSet = [ "" ]; @@ -775,7 +779,6 @@ in group = "netbox"; }; users.groups.netbox = { }; - users.groups."${config.services.redis.servers.netbox.user}".members = [ "netbox" ]; } (lib.mkIf cfg.nginx.enable { From 809048936dd5e99cf1c825f129d37e9aa8af38bb Mon Sep 17 00:00:00 2001 From: Martin Weinelt Date: Sun, 21 Jun 2026 01:33:14 +0200 Subject: [PATCH 16/16] nixos/doc/rl2611: netbox module changes --- nixos/doc/manual/release-notes/rl-2611.section.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 01507338ec5f..d669ff6cfc53 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -77,6 +77,16 @@ - The `shell_interact()` function on interactive runs of NixOS VM tests has been deprecated. Use the SSH backdoor instead. +- [services.netbox](#opt-services.netbox.enable) has received a number of updates: + - Default settings can now be introspected at [](#opt-services.netbox.settings). + - Environment files can now be passed at [](#opt-services.netbox.environmentFiles). + - When Django [secret key](#opt-services.netbox.secretKeyFile) or [API token peppers](#opt-services.netbox.apiTokenPepperFiles) + remain unset, random values will automatically be generated and stored below `/var/lib/netbox`. + - Multiple peppers can now be maintained, which allows for pepper rotation. + - All options to bind the gunicorn socket have been unified in [](#opt-services.netbox.bind) + and the default changed to a UNIX domain socket. + - A cookie-cutter nginx vhost can be enabled at [](#opt-services.netbox.nginx.enable). + - `security.run0.enableSudoAlias` now uses the `run0-sudo-shim` instead of a shell-script to improve compatibility. - With `system.etc.overlay.mutable = false`, NixOS now ships an empty `/etc/machine-id` in the image. Previously the file was absent and systemd logged `System cannot boot: Missing /etc/machine-id and /etc/ is read-only` while `ConditionFirstBoot` fired on every boot. With this change, systemd now overlays a transient ID from `/run/machine-id` for the session, and `systemd-machine-id-commit.service` has `ConditionFirstBoot` so it writes the machine-id through to a persistent backing file when one is bind-mounted over `/etc/machine-id`. To persist the machine-id across reboots, bind-mount a writable file containing `uninitialized` over `/etc/machine-id` from the initrd, or set `systemd.machine_id=` on the kernel command line (use `systemd.machine_id=firmware` to derive a stable ID on hardware that supports it).