diff --git a/nixos/doc/manual/release-notes/rl-2605.section.md b/nixos/doc/manual/release-notes/rl-2605.section.md index fc7801aa0c1c5..8bdb48295d376 100644 --- a/nixos/doc/manual/release-notes/rl-2605.section.md +++ b/nixos/doc/manual/release-notes/rl-2605.section.md @@ -20,6 +20,8 @@ - [reaction](https://reaction.ppom.me/), a daemon that scans program outputs for repeated patterns, and takes action. A common usage is to scan ssh and webserver logs, and to ban hosts that cause multiple authentication errors. A modern alternative to fail2ban. Available as [services.reaction](#opt-services.reaction.enable). +- [qui](https://github.com/autobrr/qui), a modern alternative webUI for qBittorrent, with multi-instance support. Written in Go/React. Available as [services.qui](#opt-services.qui.enable). + - [LibreChat](https://www.librechat.ai/), open-source self-hostable ChatGPT clone with Agents and RAG APIs. Available as [services.librechat](#opt-services.librechat.enable). - [DankMaterialShell](https://danklinux.com), a complete desktop shell for Wayland compositors built with Quickshell. Available as [programs.dms-shell](#opt-programs.dms-shell.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 2bf95dc1c926d..d52edc1094278 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1547,6 +1547,7 @@ ./services/torrent/opentracker.nix ./services/torrent/peerflix.nix ./services/torrent/qbittorrent.nix + ./services/torrent/qui.nix ./services/torrent/rtorrent.nix ./services/torrent/torrentstream.nix ./services/torrent/transmission.nix diff --git a/nixos/modules/services/torrent/qui.nix b/nixos/modules/services/torrent/qui.nix new file mode 100644 index 0000000000000..faeb506503080 --- /dev/null +++ b/nixos/modules/services/torrent/qui.nix @@ -0,0 +1,191 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + inherit (lib) + getExe + maintainers + mkEnableOption + mkIf + mkOption + mkPackageOption + ; + inherit (lib.types) + bool + path + port + str + submodule + ; + cfg = config.services.qui; + + stateDir = "/var/lib/qui"; + configFormat = pkgs.formats.toml { }; + configFile = configFormat.generate "qui.toml" cfg.settings; +in +{ + options = { + services.qui = { + enable = mkEnableOption "qui"; + + package = mkPackageOption pkgs "qui" { }; + + user = mkOption { + type = str; + default = "qui"; + description = "User to run qui as."; + }; + + group = mkOption { + type = str; + default = "qui"; + example = "torrents"; + description = "Group to run qui as."; + }; + + openFirewall = mkOption { + type = bool; + default = false; + description = "Whether or not to open ports in the firewall for qui."; + }; + + secretFile = mkOption { + type = path; + example = "/run/secrets/qui-session.txt"; + description = '' + Path to a file that contains the session secret. The session secret + can be generated with `openssl rand -hex 32`. + ''; + }; + + settings = mkOption { + default = { }; + example = { + port = 7777; + logLevel = "DEBUG"; + metricsEnabled = true; + }; + type = submodule { + freeformType = configFormat.type; + options = { + host = mkOption { + type = str; + default = "127.0.0.1"; + description = "The host address qui listens on."; + }; + + port = mkOption { + type = port; + default = 7476; + description = "The port qui listens on."; + }; + }; + }; + description = '' + qui configuration options. + + Refer to the [template config](https://github.com/autobrr/qui/blob/main/internal/config/config.go) + in the source code for the available options. + The documentation contains the available [environment variables](https://getqui.com/docs/configuration/environment/), + this can be used to get an overview. + ''; + }; + + }; + }; + + config = mkIf cfg.enable { + assertions = [ + { + assertion = !(cfg.settings ? sessionSecret); + message = '' + Session secrets should not be passed via settings, as + these are stored in the world-readable nix store. + + Use the secretFile option instead.''; + } + ]; + + systemd.services.qui = { + description = "qui: alternative qBittorrent webUI"; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + User = cfg.user; + Group = cfg.group; + + LoadCredential = "sessionSecret:${cfg.secretFile}"; + Environment = [ "QUI__SESSION_SECRET_FILE=%d/sessionSecret" ]; + StateDirectory = "qui"; + + ExecStartPre = '' + ${pkgs.coreutils}/bin/install -m 600 '${configFile}' '%S/qui/config.toml' + ''; + ExecStart = "${getExe cfg.package} serve --config-dir %S/qui"; + Restart = "on-failure"; + + # Based on qbittorrent and nemorosa hardening settings + # Similar to what systemd hardening helper suggests + CapabilityBoundingSet = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateNetwork = false; + PrivateTmp = true; + PrivateUsers = true; + ProcSubset = "pid"; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = "yes"; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + # This should allow for hardlinking to torrent client files + ProtectSystem = "full"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_NETLINK" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ "@system-service" ]; + }; + }; + + networking.firewall = mkIf cfg.openFirewall { + allowedTCPPorts = [ cfg.settings.port ]; + }; + + users = { + users = mkIf (cfg.user == "qui") { + qui = { + group = cfg.group; + description = "qui user"; + isSystemUser = true; + home = stateDir; + }; + }; + + groups = mkIf (cfg.group == "qui") { + qui = { }; + }; + }; + }; + + meta.maintainers = with maintainers; [ undefined-landmark ]; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 8ab11239fadfc..fbab6e29c358e 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -1314,6 +1314,7 @@ in qtile = runTestOn [ "x86_64-linux" "aarch64-linux" ] ./qtile/default.nix; qtile-extras = runTestOn [ "x86_64-linux" "aarch64-linux" ] ./qtile-extras/default.nix; quake3 = runTest ./quake3.nix; + qui = runTest ./qui.nix; quicktun = runTest ./quicktun.nix; quickwit = runTest ./quickwit.nix; rabbitmq = runTest ./rabbitmq.nix; diff --git a/nixos/tests/qui.nix b/nixos/tests/qui.nix new file mode 100644 index 0000000000000..cd3cc27f65ceb --- /dev/null +++ b/nixos/tests/qui.nix @@ -0,0 +1,47 @@ +{ lib, ... }: + +{ + name = "qui"; + meta.maintainers = with lib.maintainers; [ undefined-landmark ]; + + nodes.machine = + { pkgs, ... }: + let + # We create this secret in the Nix store (making it readable by everyone). + # DO NOT DO THIS OUTSIDE OF TESTS!! + testSecretFile = pkgs.writeText "session_secret" "not-secret"; + in + { + services.qui = { + enable = true; + secretFile = testSecretFile; + }; + + # Use port other than default to test if settings options work. + specialisation.settingsPort.configuration = { + services.qui = { + enable = true; + secretFile = testSecretFile; + settings.port = 7777; + }; + }; + }; + + testScript = + { nodes, ... }: + let + settingsPort = "${nodes.machine.system.build.toplevel}/specialisation/settingsPort"; + in + # python + '' + def test_webui(port): + machine.wait_for_unit("qui.service") + machine.wait_for_open_port(port) + machine.wait_until_succeeds(f"curl --fail http://localhost:{port}") + + test_webui(7476) + + machine.succeed("${settingsPort}/bin/switch-to-configuration test") + test_webui(7777) + ''; +} diff --git a/pkgs/by-name/qu/qui/package.nix b/pkgs/by-name/qu/qui/package.nix index 9048776cac106..d2e282a7092bd 100644 --- a/pkgs/by-name/qu/qui/package.nix +++ b/pkgs/by-name/qu/qui/package.nix @@ -3,6 +3,7 @@ buildGoModule, fetchFromGitHub, stdenvNoCC, + nixosTests, nix-update-script, nodejs, pnpm_9, @@ -72,11 +73,14 @@ buildGoModule (finalAttrs: { versionCheckProgramArg = "version"; doInstallCheck = true; - passthru.updateScript = nix-update-script { - extraArgs = [ - "--subpackage" - "qui-web" - ]; + passthru = { + updateScript = nix-update-script { + extraArgs = [ + "--subpackage" + "qui-web" + ]; + }; + tests.testService = nixosTests.qui; }; meta = {