diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index 2107c31ea368..f1b37bf1dd93 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -97,6 +97,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). diff --git a/nixos/modules/services/web-apps/netbox.nix b/nixos/modules/services/web-apps/netbox.nix index 65f761373cda..d74c9c926b80 100644 --- a/nixos/modules/services/web-apps/netbox.nix +++ b/nixos/modules/services/web-apps/netbox.nix @@ -6,118 +6,433 @@ }: let - cfg = config.services.netbox; - pythonFmt = pkgs.formats.pythonVars { }; - staticDir = cfg.dataDir + "/static"; + inherit (lib) + any + attrValues + mkChangedOptionModule + mkEnableOption + mkOption + mkRemovedOptionModule + mkRenamedOptionModule + optionalString + types + ; - settingsFile = pythonFmt.generate "netbox-settings.py" cfg.settings; + cfg = config.services.netbox; + pythonVars = pkgs.formats.pythonVars { }; + + settingsFile = pythonVars.generate "netbox-settings.py" cfg.settings; extraConfigFile = pkgs.writeTextFile { name = "netbox-extraConfig.py"; text = cfg.extraConfig; }; configFile = pkgs.concatText "configuration.py" [ + nixosOptionsConfig settingsFile extraConfigFile ]; + secretKeyFile = + if cfg.secretKeyFile != null then cfg.secretKeyFile else "${cfg.dataDir}/secret.key"; - pkg = - (cfg.package.overrideAttrs (old: { + 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 + )} + } + ''; + }; + + enableLDAP = cfg.ldapConfigFile != null; + + finalPackage = + (cfg.package.overrideAttrs (prev: { installPhase = - old.installPhase + prev.installPhase + '' 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 { inherit (cfg) plugins; }; - netboxManageScript = - with pkgs; - (writeScriptBin "netbox-manage" '' - #!${stdenv.shell} - export PYTHONPATH=${pkg.pythonPath} + + netboxManageScript = ( + pkgs.writeShellScriptBin "netbox-manage" '' + set -a + ${lib.concatMapStringsSep "\n" (envFile: '' + . "${envFile}" + '') cfg.environmentFiles} + set +a + export PYTHONPATH=${finalPackage.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"; + supp-group = if cfg.redis.createLocally then config.services.redis.servers.netbox.group else null; + } + } -- ${finalPackage}/bin/netbox "$@";; + "netbox") + exec ${finalPackage}/bin/netbox "$@";; + *) + echo "This must be run by either the root or the 'netbox' user." >&2 + exit 1 esac - ''); + '' + ); in { + imports = [ + (mkChangedOptionModule + [ "services" "netbox" "apiTokenPeppersFile" ] + [ "services" "netbox" "apiTokenPepperFiles" ] + (config: { + "1" = config.services.netbox.apiTokenPeppersFile; + }) + ) + (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. + '') + (mkRemovedOptionModule [ "services" "netbox" "enableLdap" ] '' + LDAP support is automatically enabled when `services.netbox.ldapConfigFile` is configured. + '') + (mkRenamedOptionModule + [ "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 = { 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 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}`. + + [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 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`. - 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 + ''; + }; }; }; }; - listenAddress = lib.mkOption { - type = lib.types.str; - default = "[::1]"; + 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 = '' - Address the server will listen on. - Ignored if `unixSocket` is set. + 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# ''; }; - unixSocket = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; + bind = lib.mkOption { + type = types.str; + default = "unix:/run/netbox/netbox.sock"; + example = "[::1]:8001"; description = '' - Enable Unix Socket for the server to listen on. - `listenAddress` and `port` will be ignored. + IP and port or Unix domain socket path to bind the HTTP socket to. + + ::: {.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 { - type = lib.types.listOf lib.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; + ]; + ''; + }; + + 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; + 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 = 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 '' @@ -130,17 +445,8 @@ 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); + type = with types; functionTo (listOf package); default = _: [ ]; defaultText = lib.literalExpression '' python3Packages: with python3Packages; []; @@ -151,7 +457,7 @@ in }; dataDir = lib.mkOption { - type = lib.types.str; + type = types.str; default = "/var/lib/netbox"; description = '' Storage path of netbox. @@ -159,46 +465,71 @@ in }; secretKeyFile = lib.mkOption { - type = lib.types.path; + type = + with 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 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 ''; }; - 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 @@ -226,197 +557,206 @@ 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 { - 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" ]; - }; - }; - }; - - extraConfig = '' - with open("${cfg.secretKeyFile}", "r") as file: - SECRET_KEY = file.readline() - '' - + (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() - ''); - }; - - services.redis.servers.netbox.enable = true; - - services.postgresql = { - enable = true; - ensureDatabases = [ "netbox" ]; - ensureUsers = [ - { - name = "netbox"; - ensureDBOwnership = true; - } - ]; - }; - - environment.systemPackages = [ netboxManageScript ]; - - 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 - defaultServiceConfig = { - WorkingDirectory = "${cfg.dataDir}"; - User = "netbox"; - Group = "netbox"; - StateDirectory = "netbox"; - StateDirectoryMode = "0750"; - Restart = "on-failure"; - RestartSec = 30; - }; - in + config = lib.mkIf cfg.enable ( + lib.mkMerge [ { - netbox = { - description = "NetBox WSGI Service"; - documentation = [ "https://docs.netbox.dev/" ]; + 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" ]; - - environment.PYTHONPATH = pkg.pythonPath; - - preStart = '' - # 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 - - ${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" + services.postgresql = lib.mkIf cfg.postgresql.createLocally { + enable = true; + ensureDatabases = [ "netbox" ]; + ensureUsers = [ + { + name = "netbox"; + ensureDBOwnership = true; } - - ln -sfn "${cfg.package}" "$versionFile" - ''; - - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${pkg.gunicorn}/bin/gunicorn netbox.wsgi \ - --bind ${ - if (cfg.unixSocket != null) then - "unix:${cfg.unixSocket}" - else - "${cfg.listenAddress}:${toString cfg.port}" - } \ - --pythonpath ${pkg}/opt/netbox/netbox \ - ${lib.concatStringsSep " " cfg.gunicornArgs} - ''; - PrivateTmp = true; - TimeoutStartSec = lib.mkDefault "10min"; - }; + ]; }; - netbox-rq = { - description = "NetBox Request Queue Worker"; - documentation = [ "https://docs.netbox.dev/" ]; + environment.systemPackages = [ netboxManageScript ]; - wantedBy = [ "netbox.target" ]; - after = [ "netbox.service" ]; - - environment.PYTHONPATH = pkg.pythonPath; - - serviceConfig = defaultServiceConfig // { - ExecStart = '' - ${pkg}/bin/netbox rqworker high default low - ''; - PrivateTmp = true; - }; + systemd.slices.system-netbox = { + description = "Netbox DCIM/IPAM"; }; - netbox-housekeeping = { - description = "NetBox housekeeping job"; - documentation = [ "https://docs.netbox.dev/" ]; + 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; + SupplementaryGroups = lib.optionals cfg.redis.createLocally [ + config.services.redis.servers.netbox.group + ]; + + 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 + { + netbox = defaultUnitConfig // { + description = "NetBox WSGI Service"; + + 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 + '' + } + + # 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" + ]; + }; + }; + }; + + systemd.timers.netbox-housekeeping = { + description = "Run NetBox housekeeping job"; + documentation = [ "https://netboxlabs.com/docs/netbox/" ]; wantedBy = [ "multi-user.target" ]; @@ -426,49 +766,35 @@ in ]; wants = [ "network-online.target" ]; - environment.PYTHONPATH = pkg.pythonPath; - - serviceConfig = defaultServiceConfig // { - Type = "oneshot"; - ExecStart = '' - ${pkg}/bin/netbox housekeeping - ''; + timerConfig = { + OnCalendar = "daily"; + AccuracySec = "1h"; + Persistent = true; }; }; - }; - systemd.timers.netbox-housekeeping = { - description = "Run NetBox housekeeping job"; - documentation = [ "https://docs.netbox.dev/" ]; - - wantedBy = [ "multi-user.target" ]; - - after = [ - "network-online.target" - "netbox.service" - ]; - wants = [ "network-online.target" ]; - - 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" ]; - - assertions = [ - { - assertion = (lib.versionAtLeast cfg.package.version "4.5") -> (cfg.apiTokenPeppersFile != null); - message = "NetBox 4.5 or newer require setting `services.netbox.apiTokenPeppersFile`"; + users.users.netbox = { + home = "${cfg.dataDir}"; + isSystemUser = true; + group = "netbox"; + }; + users.groups.netbox = { }; } - ]; - }; + + (lib.mkIf cfg.nginx.enable { + # Access to STATIC_ROOT and the UNIX domain socket + systemd.services.nginx.serviceConfig.SupplementaryGroups = [ "netbox" ]; + + services.nginx = { + enable = true; + recommendedProxySettings = true; + + 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 c965e0c9b0f3..ffea21a648cd 100644 --- a/nixos/tests/web-apps/netbox-upgrade.nix +++ b/nixos/tests/web-apps/netbox-upgrade.nix @@ -34,23 +34,10 @@ 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 = { - enable = true; - - recommendedProxySettings = true; - - virtualHosts.netbox = { - default = true; - locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; - locations."/static/".alias = "/var/lib/netbox/static/"; + nginx = { + enable = true; + hostname = "localhost"; }; }; @@ -81,7 +68,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/default.nix b/nixos/tests/web-apps/netbox/default.nix index 803cf5c25ed3..4b72f703df89 100644 --- a/nixos/tests/web-apps/netbox/default.nix +++ b/nixos/tests/web-apps/netbox/default.nix @@ -27,23 +27,23 @@ import ../../make-test-python.nix ( skipTypeCheck = true; - nodes.machine = + containers.machine = { config, ... }: { - virtualisation.memorySize = 2048; + boot.kernelParams = [ + # helps debugging seccomp filter issues + "audit=1" + ]; 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" '' + nginx = { + enable = true; + hostname = "localhost"; + }; + + ldapConfigFile = pkgs.writeText "ldap_config.py" '' import ldap from django_auth_ldap.config import LDAPSearch, PosixGroupType @@ -70,18 +70,6 @@ import ../../make-test-python.nix ( ''; }; - services.nginx = { - enable = true; - - recommendedProxySettings = true; - - virtualHosts.netbox = { - default = true; - locations."/".proxyPass = "http://localhost:${toString config.services.netbox.port}"; - locations."/static/".alias = "/var/lib/netbox/static/"; - }; - }; - # Adapted from the sssd-ldap NixOS test services.openldap = { enable = true; diff --git a/nixos/tests/web-apps/netbox/testScript.py b/nixos/tests/web-apps/netbox/testScript.py index 4ff035a1f0cf..2e98cf4d87ef 100644 --- a/nixos/tests/web-apps/netbox/testScript.py +++ b/nixos/tests/web-apps/netbox/testScript.py @@ -62,11 +62,11 @@ 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"): - 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( @@ -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,8 @@ 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"])) + +# Print systemd unit hardening state +machine.log(machine.execute("systemd-analyze security netbox.service netbox-rq.service netbox-housekeeping.service | grep -v ✓")[1])