Unverified Commit a6bc6ed6 authored by Ryan Lahfa's avatar Ryan Lahfa Committed by GitHub
Browse files

Merge pull request #206983 from minijackson/netbox-3.4.1

netbox: 3.3.9 -> 3.4.7, netbox_3_3: init at 3.3.10, RFC42-style options, more tests
parents 47766584 e965c5cc
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -235,6 +235,10 @@ In addition to numerous new and upgraded packages, this release has the followin
  - `services.openssh.ciphers` to `services.openssh.settings.Ciphers`
  - `services.openssh.gatewayPorts` to `services.openssh.settings.GatewayPorts`

- `netbox` was updated to 3.4. NixOS' `services.netbox.package` still defaults to 3.3 if `stateVersion` is earlier than 23.05. Please review upstream's [breaking changes](https://github.com/netbox-community/netbox/releases/tag/v3.4.0), and upgrade NetBox by changing `services.netbox.package`. Database migrations will be run automatically.

- `services.netbox` now support RFC42-style options, through `services.netbox.settings`.

- `services.mastodon` gained a tootctl wrapped named `mastodon-tootctl` similar to `nextcloud-occ` which can be executed from any user and switches to the configured mastodon user with sudo and sources the environment variables.

- DocBook option documentation, which has been deprecated since 22.11, will now cause a warning when documentation is built. Out-of-tree modules should migrate to using CommonMark documentation as outlined in [](#sec-option-declarations) to silence this warning.
+125 −39
Original line number Diff line number Diff line
@@ -4,45 +4,17 @@ with lib;

let
  cfg = config.services.netbox;
  pythonFmt = pkgs.formats.pythonVars {};
  staticDir = cfg.dataDir + "/static";
  configFile = pkgs.writeTextFile {
    name = "configuration.py";
    text = ''
      STATIC_ROOT = '${staticDir}'
      MEDIA_ROOT = '${cfg.dataDir}/media'
      REPORTS_ROOT = '${cfg.dataDir}/reports'
      SCRIPTS_ROOT = '${cfg.dataDir}/scripts'

      ALLOWED_HOSTS = ['*']
      DATABASE = {
        '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,
          }
      }

      with open("${cfg.secretKeyFile}", "r") as file:
          SECRET_KEY = file.readline()

      ${optionalString cfg.enableLdap "REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'"}

      ${cfg.extraConfig}
    '';
  settingsFile = pythonFmt.generate "netbox-settings.py" cfg.settings;
  extraConfigFile = pkgs.writeTextFile {
    name = "netbox-extraConfig.py";
    text = cfg.extraConfig;
  };
  pkg = (pkgs.netbox.overrideAttrs (old: {
  configFile = pkgs.concatText "configuration.py" [ settingsFile extraConfigFile ];

  pkg = (cfg.package.overrideAttrs (old: {
    installPhase = old.installPhase + ''
      ln -s ${configFile} $out/opt/netbox/netbox/netbox/configuration.py
    '' + optionalString cfg.enableLdap ''
@@ -70,6 +42,30 @@ in {
      '';
    };

    settings = lib.mkOption {
      description = lib.mdDoc ''
        Configuration options to set in `configuration.py`.
        See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options.
      '';

      default = { };

      type = lib.types.submodule {
        freeformType = pythonFmt.type;

        options = {
          ALLOWED_HOSTS = lib.mkOption {
            type = with lib.types; listOf str;
            default = ["*"];
            description = lib.mdDoc ''
              A list of valid fully-qualified domain names (FQDNs) and/or IP
              addresses that can be used to reach the NetBox service.
            '';
          };
        };
      };
    };

    listenAddress = mkOption {
      type = types.str;
      default = "[::1]";
@@ -78,6 +74,17 @@ in {
      '';
    };

    package = mkOption {
      type = types.package;
      default = if versionAtLeast config.system.stateVersion "23.05" then pkgs.netbox else pkgs.netbox_3_3;
      defaultText = literalExpression ''
        if versionAtLeast config.system.stateVersion "23.05" then pkgs.netbox else pkgs.netbox_3_3;
      '';
      description = lib.mdDoc ''
        NetBox package to use.
      '';
    };

    port = mkOption {
      type = types.port;
      default = 8001;
@@ -117,7 +124,7 @@ in {
      default = "";
      description = lib.mdDoc ''
        Additional lines of configuration appended to the `configuration.py`.
        See the [documentation](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/) for more possible options.
        See the [documentation](https://docs.netbox.dev/en/stable/configuration/) for more possible options.
      '';
    };

@@ -138,11 +145,90 @@ in {
        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.
      '';
      example = ''
        import ldap
        from django_auth_ldap.config import LDAPSearch, PosixGroupType

        AUTH_LDAP_SERVER_URI = "ldaps://ldap.example.com/"

        AUTH_LDAP_USER_SEARCH = LDAPSearch(
            "ou=accounts,ou=posix,dc=example,dc=com",
            ldap.SCOPE_SUBTREE,
            "(uid=%(user)s)",
        )

        AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
            "ou=groups,ou=posix,dc=example,dc=com",
            ldap.SCOPE_SUBTREE,
            "(objectClass=posixGroup)",
        )
        AUTH_LDAP_GROUP_TYPE = PosixGroupType()

        # Mirror LDAP group assignments.
        AUTH_LDAP_MIRROR_GROUPS = True

        # For more granular permissions, we can map LDAP groups to Django groups.
        AUTH_LDAP_FIND_GROUP_PERMS = True
      '';
    };
  };

  config = mkIf cfg.enable {
    services.netbox.plugins = mkIf cfg.enableLdap (ps: [ ps.django-auth-ldap ]);
    services.netbox = {
      plugins = 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";

        DATABASE = {
          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()
      '';
    };

    services.redis.servers.netbox.enable = true;

+2 −1
Original line number Diff line number Diff line
@@ -460,7 +460,8 @@ in {
  netdata = handleTest ./netdata.nix {};
  networking.networkd = handleTest ./networking.nix { networkd = true; };
  networking.scripted = handleTest ./networking.nix { networkd = false; };
  netbox = handleTest ./web-apps/netbox.nix {};
  netbox = handleTest ./web-apps/netbox.nix { inherit (pkgs) netbox; };
  netbox_3_3 = handleTest ./web-apps/netbox.nix { netbox = pkgs.netbox_3_3; };
  # TODO: put in networking.nix after the test becomes more complete
  networkingProxy = handleTest ./networking-proxy.nix {};
  nextcloud = handleTest ./nextcloud {};
+292 −5
Original line number Diff line number Diff line
import ../make-test-python.nix ({ lib, pkgs, ... }: {
let
  ldapDomain = "example.org";
  ldapSuffix = "dc=example,dc=org";

  ldapRootUser = "admin";
  ldapRootPassword = "foobar";

  testUser = "alice";
  testPassword = "verySecure";
  testGroup = "netbox-users";
in import ../make-test-python.nix ({ lib, pkgs, netbox, ... }: {
  name = "netbox";

  meta = with lib.maintainers; {
    maintainers = [ n0emis ];
    maintainers = [ minijackson n0emis ];
  };

  nodes.machine = { ... }: {
  nodes.machine = { config, ... }: {
    services.netbox = {
      enable = true;
      package = netbox;
      secretKeyFile = pkgs.writeText "secret" ''
        abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789
      '';

      enableLdap = true;
      ldapConfigPath = pkgs.writeText "ldap_config.py" ''
        import ldap
        from django_auth_ldap.config import LDAPSearch, PosixGroupType

        AUTH_LDAP_SERVER_URI = "ldap://localhost/"

        AUTH_LDAP_USER_SEARCH = LDAPSearch(
            "ou=accounts,ou=posix,${ldapSuffix}",
            ldap.SCOPE_SUBTREE,
            "(uid=%(user)s)",
        )

        AUTH_LDAP_GROUP_SEARCH = LDAPSearch(
            "ou=groups,ou=posix,${ldapSuffix}",
            ldap.SCOPE_SUBTREE,
            "(objectClass=posixGroup)",
        )
        AUTH_LDAP_GROUP_TYPE = PosixGroupType()

        # Mirror LDAP group assignments.
        AUTH_LDAP_MIRROR_GROUPS = True

        # For more granular permissions, we can map LDAP groups to Django groups.
        AUTH_LDAP_FIND_GROUP_PERMS = True
      '';
    };

    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;
      settings = {
        children = {
          "cn=schema".includes = [
            "${pkgs.openldap}/etc/schema/core.ldif"
            "${pkgs.openldap}/etc/schema/cosine.ldif"
            "${pkgs.openldap}/etc/schema/inetorgperson.ldif"
            "${pkgs.openldap}/etc/schema/nis.ldif"
          ];
          "olcDatabase={1}mdb" = {
            attrs = {
              objectClass = [ "olcDatabaseConfig" "olcMdbConfig" ];
              olcDatabase = "{1}mdb";
              olcDbDirectory = "/var/lib/openldap/db";
              olcSuffix = ldapSuffix;
              olcRootDN = "cn=${ldapRootUser},${ldapSuffix}";
              olcRootPW = ldapRootPassword;
            };
          };
        };
      };
      declarativeContents = {
        ${ldapSuffix} = ''
          dn: ${ldapSuffix}
          objectClass: top
          objectClass: dcObject
          objectClass: organization
          o: ${ldapDomain}

          dn: ou=posix,${ldapSuffix}
          objectClass: top
          objectClass: organizationalUnit

          dn: ou=accounts,ou=posix,${ldapSuffix}
          objectClass: top
          objectClass: organizationalUnit

          dn: uid=${testUser},ou=accounts,ou=posix,${ldapSuffix}
          objectClass: person
          objectClass: posixAccount
          userPassword: ${testPassword}
          homeDirectory: /home/${testUser}
          uidNumber: 1234
          gidNumber: 1234
          cn: ""
          sn: ""

          dn: ou=groups,ou=posix,${ldapSuffix}
          objectClass: top
          objectClass: organizationalUnit

          dn: cn=${testGroup},ou=groups,ou=posix,${ldapSuffix}
          objectClass: posixGroup
          gidNumber: 2345
          memberUid: ${testUser}
        '';
      };
    };

  testScript = ''
    machine.start()
    users.users.nginx.extraGroups = [ "netbox" ];

    networking.firewall.allowedTCPPorts = [ 80 ];
  };

  testScript = let
    changePassword = pkgs.writeText "change-password.py" ''
      from django.contrib.auth.models import User
      u = User.objects.get(username='netbox')
      u.set_password('netbox')
      u.save()
    '';
  in ''
    from typing import Any, Dict
    import json

    start_all()
    machine.wait_for_unit("netbox.target")
    machine.wait_until_succeeds("journalctl --since -1m --unit netbox --grep Listening")

@@ -26,5 +151,167 @@ import ../make-test-python.nix ({ lib, pkgs, ... }: {

    with subtest("Staticfiles are generated"):
        machine.succeed("test -e /var/lib/netbox/static/netbox.js")

    with subtest("Superuser can be created"):
        machine.succeed(
            "netbox-manage createsuperuser --noinput --username netbox --email netbox@example.com"
        )
        # Django doesn't have a "clean" way of inputting the password from the command line
        machine.succeed("cat '${changePassword}' | netbox-manage shell")

    machine.wait_for_unit("network.target")

    with subtest("Home screen loads from nginx"):
        machine.succeed(
            "curl -sSfL http://localhost | grep '<title>Home | NetBox</title>'"
        )

    with subtest("Staticfiles can be fetched"):
        machine.succeed("curl -sSfL http://localhost/static/netbox.js")
        machine.succeed("curl -sSfL http://localhost/static/docs/")

    with subtest("Can interact with API"):
        json.loads(
            machine.succeed("curl -sSfL -H 'Accept: application/json' 'http://localhost/api/'")
        )

    def login(username: str, password: str):
        encoded_data = json.dumps({"username": username, "password": password})
        uri = "/users/tokens/provision/"
        result = json.loads(
            machine.succeed(
                "curl -sSfL "
                "-X POST "
                "-H 'Accept: application/json' "
                "-H 'Content-Type: application/json' "
                f"'http://localhost/api{uri}' "
                f"--data '{encoded_data}'"
            )
        )
        return result["key"]

    with subtest("Can login"):
        auth_token = login("netbox", "netbox")

    def get(uri: str):
        return json.loads(
            machine.succeed(
                "curl -sSfL "
                "-H 'Accept: application/json' "
                f"-H 'Authorization: Token {auth_token}' "
                f"'http://localhost/api{uri}'"
            )
        )

    def delete(uri: str):
        return machine.succeed(
            "curl -sSfL "
            f"-X DELETE "
            "-H 'Accept: application/json' "
            f"-H 'Authorization: Token {auth_token}' "
            f"'http://localhost/api{uri}'"
        )


    def data_request(uri: str, method: str, data: Dict[str, Any]):
        encoded_data = json.dumps(data)
        return json.loads(
            machine.succeed(
                "curl -sSfL "
                f"-X {method} "
                "-H 'Accept: application/json' "
                "-H 'Content-Type: application/json' "
                f"-H 'Authorization: Token {auth_token}' "
                f"'http://localhost/api{uri}' "
                f"--data '{encoded_data}'"
            )
        )

    def post(uri: str, data: Dict[str, Any]):
      return data_request(uri, "POST", data)

    def patch(uri: str, data: Dict[str, Any]):
      return data_request(uri, "PATCH", data)

    with subtest("Can create objects"):
        result = post("/dcim/sites/", {"name": "Test site", "slug": "test-site"})
        site_id = result["id"]

        # Example from:
        # http://netbox.extra.cea.fr/static/docs/integrations/rest-api/#creating-a-new-object
        post("/ipam/prefixes/", {"prefix": "192.0.2.0/24", "site": site_id})

        result = post(
            "/dcim/manufacturers/",
            {"name": "Test manufacturer", "slug": "test-manufacturer"}
        )
        manufacturer_id = result["id"]

        # Had an issue with device-types before NetBox 3.4.0
        result = post(
            "/dcim/device-types/",
            {
                "model": "Test device type",
                "manufacturer": manufacturer_id,
                "slug": "test-device-type",
            },
        )
        device_type_id = result["id"]

    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"] == ""

        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"

    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

    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

    with subtest("Can use the GraphQL API"):
        encoded_data = json.dumps({
            "query": "query { prefix_list { prefix, site { id, description } } }",
        })
        result = json.loads(
            machine.succeed(
                "curl -sSfL "
                "-H 'Accept: application/json' "
                "-H 'Content-Type: application/json' "
                f"-H 'Authorization: Token {auth_token}' "
                "'http://localhost/graphql/' "
                f"--data '{encoded_data}'"
            )
        )

        assert len(result["data"]["prefix_list"]) == 1
        assert result["data"]["prefix_list"][0]["prefix"] == "192.0.2.0/24"
        assert result["data"]["prefix_list"][0]["site"]["id"] == str(site_id)
        assert result["data"]["prefix_list"][0]["site"]["description"] == new_description

    with subtest("Can login with LDAP"):
        machine.wait_for_unit("openldap.service")
        login("alice", "${testPassword}")

    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"])
  '';
})
+35 −0
Original line number Diff line number Diff line
@@ -417,4 +417,39 @@ rec {
      '';
    };

  # Outputs a succession of Python variable assignments
  # Useful for many Django-based services
  pythonVars = {}: {
    type = with lib.types; let
      valueType = nullOr(oneOf [
        bool
        float
        int
        path
        str
        (attrsOf valueType)
        (listOf valueType)
      ]) // {
        description = "Python value";
      };
    in attrsOf valueType;
    generate = name: value: pkgs.callPackage ({ runCommand, python3, black }: runCommand name {
      nativeBuildInputs = [ python3 black ];
      value = builtins.toJSON value;
      pythonGen = ''
        import json
        import os

        with open(os.environ["valuePath"], "r") as f:
            for key, value in json.load(f).items():
                print(f"{key} = {repr(value)}")
      '';
      passAsFile = [ "value" "pythonGen" ];
    } ''
      cat "$valuePath"
      python3 "$pythonGenPath" > $out
      black $out
    '') {};
  };

}
Loading