Unverified Commit 84d174e3 authored by nuko's avatar nuko Committed by fsnkty
Browse files

nixos/qbittorrent: init service module

nixos/qbittorrent: add default serverConfig & fix test

Migrate to runTest

Replace lib.optional with lib.optionals

nixos/qbittorrent: update release notes to 2511
parent 378f0fa6
Loading
Loading
Loading
Loading
+2 −0
Original line number Diff line number Diff line
@@ -48,6 +48,8 @@

- [Newt](https://github.com/fosrl/newt), a fully user space WireGuard tunnel client and TCP/UDP proxy, designed to securely expose private resources controlled by Pangolin. Available as [services.newt](options.html#opt-services.newt.enable).

- [qBittorrent](https://www.qbittorrent.org/), is a bittorrent client programmed in C++ / Qt that uses libtorrent by Arvid Norberg. Available as [services.qbittorrent](#opt-services.qbittorrent.enable).

- [Szurubooru](https://github.com/rr-/szurubooru), an image board engine inspired by services such as Danbooru, dedicated for small and medium communities. Available as [services.szurubooru](#opt-services.szurubooru.enable).

- The [Neat IP Address Planner](https://spritelink.github.io/NIPAP/) (NIPAP) can now be enabled through [services.nipap.enable](#opt-services.nipap.enable).
+1 −0
Original line number Diff line number Diff line
@@ -1498,6 +1498,7 @@
  ./services/torrent/magnetico.nix
  ./services/torrent/opentracker.nix
  ./services/torrent/peerflix.nix
  ./services/torrent/qbittorrent.nix
  ./services/torrent/rtorrent.nix
  ./services/torrent/torrentstream.nix
  ./services/torrent/transmission.nix
+238 −0
Original line number Diff line number Diff line
{
  config,
  pkgs,
  lib,
  utils,
  ...
}:
let
  cfg = config.services.qbittorrent;
  inherit (builtins) concatStringsSep isAttrs isString;
  inherit (lib)
    literalExpression
    getExe
    mkEnableOption
    mkOption
    mkPackageOption
    mkIf
    maintainers
    escape
    collect
    mapAttrsRecursive
    optionals
    ;
  inherit (lib.types)
    str
    port
    path
    nullOr
    listOf
    attrsOf
    anything
    submodule
    ;
  inherit (lib.generators) toINI mkKeyValueDefault mkValueStringDefault;
  gendeepINI = toINI {
    mkKeyValue =
      let
        sep = "=";
      in
      k: v:
      if isAttrs v then
        concatStringsSep "\n" (
          collect isString (
            mapAttrsRecursive (
              path: value:
              "${escape [ sep ] (concatStringsSep "\\" ([ k ] ++ path))}${sep}${mkValueStringDefault { } value}"
            ) v
          )
        )
      else
        mkKeyValueDefault { } sep k v;
  };
  configFile = pkgs.writeText "qBittorrent.conf" (gendeepINI cfg.serverConfig);
in
{
  options.services.qbittorrent = {
    enable = mkEnableOption "qbittorrent, BitTorrent client";

    package = mkPackageOption pkgs "qbittorrent-nox" { };

    user = mkOption {
      type = str;
      default = "qbittorrent";
      description = "User account under which qbittorrent runs.";
    };

    group = mkOption {
      type = str;
      default = "qbittorrent";
      description = "Group under which qbittorrent runs.";
    };

    profileDir = mkOption {
      type = path;
      default = "/var/lib/qBittorrent/";
      description = "the path passed to qbittorrent via --profile.";
    };

    openFirewall = mkEnableOption "opening both the webuiPort and torrentPort over TCP in the firewall";

    webuiPort = mkOption {
      default = 8080;
      type = nullOr port;
      description = "the port passed to qbittorrent via `--webui-port`";
    };

    torrentingPort = mkOption {
      default = null;
      type = nullOr port;
      description = "the port passed to qbittorrent via `--torrenting-port`";
    };

    serverConfig = mkOption {
      default = { };
      type = submodule {
        freeformType = attrsOf (attrsOf anything);
      };
      description = ''
        Free-form settings mapped to the `qBittorrent.conf` file in the profile.
        Refer to [Explanation-of-Options-in-qBittorrent](https://github.com/qbittorrent/qBittorrent/wiki/Explanation-of-Options-in-qBittorrent).
        The Password_PBKDF2 format is oddly unique, you will likely want to use [this tool](https://codeberg.org/feathecutie/qbittorrent_password) to generate the format.
        Alternatively you can run qBittorrent independently first and use its webUI to generate the format.

        Optionally an alternative webUI can be easily set. VueTorrent for example:
        ```nix
        {
          Preferences = {
            WebUI = {
              AlternativeUIEnabled = true;
              RootFolder = "''${pkgs.vuetorrent}/share/vuetorrent";
            };
          };
        }
        ];
        ```
      '';
      example = literalExpression ''
        {
          LegalNotice.Accepted = true;
          Preferences = {
            WebUI = {
              Username = "user";
              Password_PBKDF2 = "generated ByteArray.";
            };
            General.Locale = "en";
          };
        }
      '';
    };

    extraArgs = mkOption {
      type = listOf str;
      default = [ ];
      description = ''
        Extra arguments passed to qbittorrent. See `qbittorrent -h`, or the [source code](https://github.com/qbittorrent/qBittorrent/blob/master/src/app/cmdoptions.cpp), for the available arguments.
      '';
      example = [
        "--confirm-legal-notice"
      ];
    };
  };
  config = mkIf cfg.enable {
    systemd = {
      tmpfiles.settings = {
        qbittorrent = {
          "${cfg.profileDir}/qBittorrent/"."d" = {
            mode = "755";
            inherit (cfg) user group;
          };
          "${cfg.profileDir}/qBittorrent/config/"."d" = {
            mode = "755";
            inherit (cfg) user group;
          };
          "${cfg.profileDir}/qBittorrent/config/qBittorrent.conf"."L+" = mkIf (cfg.serverConfig != { }) {
            mode = "1400";
            inherit (cfg) user group;
            argument = "${configFile}";
          };
        };
      };
      services.qbittorrent = {
        description = "qbittorrent BitTorrent client";
        wants = [ "network-online.target" ];
        after = [
          "local-fs.target"
          "network-online.target"
          "nss-lookup.target"
        ];
        wantedBy = [ "multi-user.target" ];
        restartTriggers = optionals (cfg.serverConfig != { }) [ configFile ];

        serviceConfig = {
          Type = "simple";
          User = cfg.user;
          Group = cfg.group;
          ExecStart = utils.escapeSystemdExecArgs (
            [
              (getExe cfg.package)
              "--profile=${cfg.profileDir}"
            ]
            ++ optionals (cfg.webuiPort != null) [ "--webui-port=${toString cfg.webuiPort}" ]
            ++ optionals (cfg.torrentingPort != null) [ "--torrenting-port=${toString cfg.torrentingPort}" ]
            ++ cfg.extraArgs
          );
          TimeoutStopSec = 1800;

          # https://github.com/qbittorrent/qBittorrent/pull/6806#discussion_r121478661
          PrivateTmp = false;

          PrivateNetwork = false;
          RemoveIPC = true;
          NoNewPrivileges = true;
          PrivateDevices = true;
          PrivateUsers = true;
          ProtectHome = "yes";
          ProtectProc = "invisible";
          ProcSubset = "pid";
          ProtectSystem = "full";
          ProtectClock = true;
          ProtectHostname = true;
          ProtectKernelLogs = true;
          ProtectKernelModules = true;
          ProtectKernelTunables = true;
          ProtectControlGroups = true;
          RestrictAddressFamilies = [
            "AF_INET"
            "AF_INET6"
            "AF_NETLINK"
          ];
          RestrictNamespaces = true;
          RestrictRealtime = true;
          RestrictSUIDSGID = true;
          LockPersonality = true;
          MemoryDenyWriteExecute = true;
          SystemCallArchitectures = "native";
          CapabilityBoundingSet = "";
          SystemCallFilter = [ "@system-service" ];
        };
      };
    };

    users = {
      users = mkIf (cfg.user == "qbittorrent") {
        qbittorrent = {
          inherit (cfg) group;
          isSystemUser = true;
        };
      };
      groups = mkIf (cfg.group == "qbittorrent") { qbittorrent = { }; };
    };

    networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall (
      optionals (cfg.webuiPort != null) [ cfg.webuiPort ]
      ++ optionals (cfg.torrentingPort != null) [ cfg.torrentingPort ]
    );
  };
  meta.maintainers = with maintainers; [ fsnkty ];
}
+1 −0
Original line number Diff line number Diff line
@@ -1220,6 +1220,7 @@ in
  public-inbox = runTest ./public-inbox.nix;
  pufferpanel = runTest ./pufferpanel.nix;
  pulseaudio = discoverTests (import ./pulseaudio.nix);
  qbittorrent = runTest ./qbittorrent.nix;
  qboot = handleTestOn [ "x86_64-linux" "i686-linux" ] ./qboot.nix { };
  qemu-vm-restrictnetwork = handleTest ./qemu-vm-restrictnetwork.nix { };
  qemu-vm-volatile-root = runTest ./qemu-vm-volatile-root.nix;
+190 −0
Original line number Diff line number Diff line
{ pkgs, lib, ... }:
{
  name = "qbittorrent";

  meta = with pkgs.lib.maintainers; {
    maintainers = [ fsnkty ];
  };

  nodes = {
    simple = {
      services.qbittorrent.enable = true;

      specialisation.portChange.configuration = {
        services.qbittorrent = {
          enable = true;
          webuiPort = 5555;
          torrentingPort = 44444;
        };
      };

      specialisation.openPorts.configuration = {
        services.qbittorrent = {
          enable = true;
          openFirewall = true;
          webuiPort = 8080;
          torrentingPort = 55555;
        };
      };

      specialisation.serverConfig.configuration = {
        services.qbittorrent = {
          enable = true;
          webuiPort = null;
          serverConfig.Preferences.WebUI.Port = "8181";
        };
      };
    };
    # Seperate vm because it's not possible to reboot into a specialisation with
    # switch-to-configuration: https://github.com/NixOS/nixpkgs/issues/82851
    # For one of the test we check if manual changes are overridden during
    # reboot, therefore it's necessary to reboot into a declarative setup.
    declarative = {
      services.qbittorrent = {
        enable = true;
        webuiPort = null;
        serverConfig = {
          Preferences = {
            WebUI = {
              Username = "user";
              # Default password: adminadmin
              Password_PBKDF2 = "@ByteArray(6DIf26VOpTCYbgNiO6DAFQ==:e6241eaAWGzRotQZvVA5/up9fj5wwSAThLgXI2lVMsYTu1StUgX9MgmElU3Sa/M8fs+zqwZv9URiUOObjqJGNw==)";
              Port = lib.mkDefault "8181";
            };
          };
        };
      };

      specialisation.serverConfigChange.configuration = {
        services.qbittorrent = {
          enable = true;
          webuiPort = null;
          serverConfig.Preferences.WebUI.Port = "7171";
        };
      };
    };
  };

  testScript =
    { nodes, ... }:
    let
      simpleSpecPath = "${nodes.simple.system.build.toplevel}/specialisation";
      declarativeSpecPath = "${nodes.declarative.system.build.toplevel}/specialisation";
      portChange = "${simpleSpecPath}/portChange";
      openPorts = "${simpleSpecPath}/openPorts";
      serverConfig = "${simpleSpecPath}/serverConfig";
      serverConfigChange = "${declarativeSpecPath}/serverConfigChange";
    in
    ''
      simple.start(allow_reboot=True)
      declarative.start(allow_reboot=True)


      def test_webui(machine, port):
          machine.wait_for_unit("qbittorrent.service")
          machine.wait_for_open_port(port)
          machine.wait_until_succeeds(f"curl --fail http://localhost:{port}")


      # To simulate an interactive change in the settings
      def setPreferences_api(machine, port, post_creds, post_data):
          qb_url = f"http://localhost:{port}"
          api_url = f"{qb_url}/api/v2"
          cookie_path = "/tmp/qbittorrent.cookie"

          machine.succeed(
              f'curl --header "Referer: {qb_url}" \
              --data "{post_creds}" {api_url}/auth/login \
              -c {cookie_path}'
          )
          machine.succeed(
              f'curl --header "Referer: {qb_url}" \
              --data "{post_data}" {api_url}/app/setPreferences \
              -b {cookie_path}'
          )


      # A randomly generated password is printed in the service log when no
      # password it set
      def get_temp_pass(machine):
          _, password = machine.execute(
              "journalctl -u qbittorrent.service |\
              grep 'The WebUI administrator password was not set.' |\
              awk '{ print $NF }' | tr -d '\n'"
          )
          return password


      # Non declarative tests

      with subtest("webui works with all default settings"):
          test_webui(simple, 8080)

      with subtest("check if manual changes in settings are saved correctly"):
          temp_pass = get_temp_pass(simple)

          ## Change some settings
          api_post = [r"json={\"listen_port\": 33333}", r"json={\"web_ui_port\": 9090}"]
          for x in api_post:
              setPreferences_api(
                  machine=simple,
                  port=8080,
                  post_creds=f"username=admin&password={temp_pass}",
                  post_data=x,
              )

          simple.wait_for_open_port(33333)
          test_webui(simple, 9090)

          ## Test which settings are reset
          ## As webuiPort is passed as an cli it should reset after reboot
          ## As torrentingPort is not passed as an cli it should not reset after
          ## reboot
          simple.reboot()
          test_webui(simple, 8080)
          simple.wait_for_open_port(33333)

      with subtest("ports are changed on config change"):
          simple.succeed("${portChange}/bin/switch-to-configuration test")
          test_webui(simple, 5555)
          simple.wait_for_open_port(44444)

      with subtest("firewall is opened correctly"):
          simple.succeed("${openPorts}/bin/switch-to-configuration test")
          test_webui(simple, 8080)
          declarative.wait_until_succeeds("curl --fail http://simple:8080")
          declarative.wait_for_open_port(55555, "simple")

      with subtest("switching from simple to declarative works"):
          simple.succeed("${serverConfig}/bin/switch-to-configuration test")
          test_webui(simple, 8181)


      # Declarative tests

      with subtest("serverConfig is applied correctly"):
          test_webui(declarative, 8181)

      with subtest("manual changes are overridden during reboot"):
          ## Change some settings
          setPreferences_api(
              machine=declarative,
              port=8181, # as set through serverConfig
              post_creds="username=user&password=adminadmin",
              post_data=r"json={\"web_ui_port\": 9191}",
          )

          test_webui(declarative, 9191)

          ## Test which settings are reset
          ## The generated qBittorrent.conf is, apparently, reapplied after reboot.
          ## Because the port is set in `serverConfig` this overrides the manually
          ## set port.
          declarative.reboot()
          test_webui(declarative, 8181)

      with subtest("changes in serverConfig are applied correctly"):
          declarative.succeed("${serverConfigChange}/bin/switch-to-configuration test")
          test_webui(declarative, 7171)
    '';
}
Loading