diff --git a/CHANGELOG.md b/CHANGELOG.md index c944654..0d7ee84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Rename all `shb.arr.*.APIKey` to `shb.arr.*.ApiKey`. - Remove `shb.vaultwarden.ldapEndpoint` option because it was not used in the implementation anyway. - Bump Nextcloud default version from 27 to 28. Add support for version 29. +- Deluge config breaks the authFile into an attrset of user to password file. Also deluge has tests now. ## User Facing Backwards Compatible Changes diff --git a/flake.nix b/flake.nix index 94ad937..204621b 100644 --- a/flake.nix +++ b/flake.nix @@ -116,6 +116,7 @@ // (vm_test "arr" ./test/vm/arr.nix) // (vm_test "audiobookshelf" ./test/vm/audiobookshelf.nix) // (vm_test "authelia" ./test/vm/authelia.nix) + // (vm_test "deluge" ./test/vm/deluge.nix) // (vm_test "grocy" ./test/vm/grocy.nix) // (vm_test "home-assistant" ./test/vm/home-assistant.nix) // (vm_test "jellyfin" ./test/vm/jellyfin.nix) diff --git a/modules/services/deluge.nix b/modules/services/deluge.nix index 565c950..45a1680 100644 --- a/modules/services/deluge.nix +++ b/modules/services/deluge.nix @@ -4,8 +4,18 @@ let cfg = config.shb.deluge; contracts = pkgs.callPackage ../contracts {}; + shblib = pkgs.callPackage ../../lib {}; fqdn = "${cfg.subdomain}.${cfg.domain}"; + + authGenerator = users: + let + genLine = name: { password, priority ? 10 }: + "${name}:${password}:${toString priority}"; + + lines = lib.mapAttrsToList genLine users; + in + lib.concatStringsSep "\n" lines; in { options.shb.deluge = { @@ -151,17 +161,34 @@ in authEndpoint = lib.mkOption { type = lib.types.nullOr lib.types.str; description = "OIDC endpoint for SSO"; + default = null; example = "https://authelia.example.com"; }; - authFile = lib.mkOption { + extraUsers = lib.mkOption { + description = "Users having access to this deluge instance. Attrset of username to user options."; + type = lib.types.attrsOf (lib.types.submodule { + options = { + password = lib.mkOption { + type = shblib.secretFileType; + description = "File containing the user password."; + }; + }; + }); + }; + + localclientPasswordFile = lib.mkOption { + description = "File containing password for mandatory localclient user."; type = lib.types.path; - description = "File containing auth lines in the format expected by deluge. See https://dev.deluge-torrent.org/wiki/UserGuide/Authentication."; }; enabledPlugins = lib.mkOption { type = lib.types.listOf lib.types.str; - description = "Plugins to enable, can include those from additionalPlugins."; + description = '' + Plugins to enable, can include those from additionalPlugins. + + Label is automatically enabled if any of the `shb.arr.*` service is enabled. + ''; example = ["Label"]; default = []; }; @@ -175,7 +202,7 @@ in logLevel = lib.mkOption { type = lib.types.nullOr (lib.types.enum ["critical" "error" "warning" "info" "debug"]); description = "Enable logging."; - default = false; + default = null; example = true; }; }; @@ -229,12 +256,21 @@ in new_release_check = false; }; - inherit (cfg) authFile; + + authFile = "${config.services.deluge.dataDir}/.config/deluge/authTemplate"; web.enable = true; web.port = cfg.webPort; }; + systemd.services.deluged.preStart = lib.mkBefore (shblib.replaceSecrets { + userConfig = cfg.extraUsers // { + localclient.password.source = config.shb.deluge.localclientPasswordFile; + }; + resultPath = "${config.services.deluge.dataDir}/.config/deluge/authTemplate"; + generator = name: value: pkgs.writeText "delugeAuth" (authGenerator value); + }); + systemd.services.deluged.serviceConfig.ExecStart = lib.mkForce (lib.concatStringsSep " \\\n " ([ "${config.services.deluge.package}/bin/deluged" "--do-not-daemonize" @@ -254,15 +290,17 @@ in ]; shb.nginx.vhosts = [ - { - inherit (cfg) subdomain domain authEndpoint ssl; + ({ + inherit (cfg) subdomain domain ssl; upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}"; autheliaRules = lib.mkIf (cfg.authEndpoint != null) [{ domain = fqdn; policy = "two_factor"; subject = ["group:deluge_user"]; }]; - } + } // (lib.optionalAttrs (cfg.authEndpoint != null) { + inherit (cfg) authEndpoint; + })) ]; # We want deluge to create files in the media group and to make those files group readable. diff --git a/test/vm/deluge.nix b/test/vm/deluge.nix new file mode 100644 index 0000000..7e779cb --- /dev/null +++ b/test/vm/deluge.nix @@ -0,0 +1,296 @@ +{ pkgs, lib, ... }: +let + pkgs' = pkgs; + + subdomain = "d"; + domain = "example.com"; + fqdn = "${subdomain}.${domain}"; + + commonTestScript = { nodes, ... }: + let + hasSSL = !(isNull nodes.server.shb.deluge.ssl); + proto_fqdn = if hasSSL then "https://${fqdn}" else "http://${fqdn}"; + in + '' + import json + import os + import pathlib + + start_all() + server.wait_for_unit("nginx.service") + server.wait_for_unit("deluged.service") + server.wait_for_unit("delugeweb.service") + server.wait_for_open_port(${toString nodes.server.shb.deluge.daemonPort}) + server.wait_for_open_port(${toString nodes.server.shb.deluge.webPort}) + + if ${if hasSSL then "True" else "False"}: + server.copy_from_vm("/etc/ssl/certs/ca-certificates.crt") + client.succeed("rm -r /etc/ssl/certs") + client.copy_from_host(str(pathlib.Path(os.environ.get("out", os.getcwd())) / "ca-certificates.crt"), "/etc/ssl/certs/ca-certificates.crt") + + def curl(target, format, endpoint, succeed=True): + return json.loads(target.succeed( + "curl --fail-with-body --silent --show-error --output /dev/null --location" + + " --connect-to ${fqdn}:443:server:443" + + " --connect-to ${fqdn}:80:server:80" + + f" --write-out '{format}'" + + " " + endpoint + )) + + print(server.succeed('journalctl -n100 -u deluged')) + print(server.succeed('systemctl status deluged')) + print(server.succeed('systemctl status delugeweb')) + + with subtest("access"): + response = curl(client, """{"code":%{response_code}}""", "${proto_fqdn}") + + if response['code'] != 200: + raise Exception(f"Code is {response['code']}") + ''; + + # TODO: Test login directly to deluge daemon to exercise extraUsers + authTestScript = { nodes, ... }: + let + hasSSL = !(isNull nodes.server.shb.deluge.ssl); + proto_fqdn = if hasSSL then "https://${fqdn}" else "http://${fqdn}"; + delugeCurlCfg = pkgs.writeText "curl.cfg" '' + request = "POST" + compressed + cookie = "cookie_deluge.txt" + cookie-jar = "cookie_deluge.txt" + header = "Content-Type: application/json" + header = "Accept: application/json" + url = "${proto_fqdn}/json" + write-out = "\n" + ''; + in + '' + with subtest("web connect"): + print(server.succeed("cat ${nodes.server.services.deluge.dataDir}/.config/deluge/auth")) + + response = json.loads(client.succeed( + "curl --fail-with-body --show-error -K ${delugeCurlCfg}" + + " --connect-to ${fqdn}:443:server:443" + + " --connect-to ${fqdn}:80:server:80" + + """ --data '{"method": "auth.login", "params": ["deluge"], "id": 1}'""" + )) + print(response) + if not response['result']: + raise Exception(f"result is {response['code']}") + + response = json.loads(client.succeed( + "curl --fail-with-body --show-error -K ${delugeCurlCfg}" + + " --connect-to ${fqdn}:443:server:443" + + " --connect-to ${fqdn}:80:server:80" + + """ --data '{"method": "web.get_hosts", "params": [], "id": 1}'""" + )) + print(response) + + hostID = response['result'][0][0] + response = json.loads(client.succeed( + "curl --fail-with-body --show-error -K ${delugeCurlCfg}" + + " --connect-to ${fqdn}:443:server:443" + + " --connect-to ${fqdn}:80:server:80" + + f""" --data '{{"method": "web.connect", "params": ["{hostID}"], "id": 1}}'""" + )) + print(response) + if response['error']: + raise Exception(f"result had an error {response['error']}") + ''; + + base = { + imports = [ + (pkgs'.path + "/nixos/modules/profiles/headless.nix") + (pkgs'.path + "/nixos/modules/profiles/qemu-guest.nix") + { + options = { + shb.backup = lib.mkOption { type = lib.types.anything; }; + shb.arr.radarr.enable = lib.mkEnableOption "radarr"; + shb.arr.sonarr.enable = lib.mkEnableOption "sonarr"; + shb.arr.bazarr.enable = lib.mkEnableOption "bazarr"; + shb.arr.readarr.enable = lib.mkEnableOption "readarr"; + shb.arr.lidarr.enable = lib.mkEnableOption "lidarr"; + }; + } + ../../modules/blocks/nginx.nix + ]; + + # Nginx port. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + }; + + certs = { config, ... }: { + imports = [ + ../../modules/blocks/ssl.nix + ]; + + shb.certs = { + cas.selfsigned.myca = { + name = "My CA"; + }; + certs.selfsigned = { + n = { + ca = config.shb.certs.cas.selfsigned.myca; + domain = "*.${domain}"; + group = "nginx"; + }; + }; + }; + + systemd.services.nginx.after = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + systemd.services.nginx.requires = [ config.shb.certs.certs.selfsigned.n.systemdService ]; + }; + + basic = { config, ... }: { + imports = [ + ../../modules/services/deluge.nix + ]; + + shb.deluge = { + enable = true; + inherit domain subdomain; + + settings = { + downloadLocation = "/var/lib/deluge"; + }; + + extraUsers = { + user.password.source = pkgs.writeText "userpw" "userpw"; + }; + + localclientPasswordFile = pkgs.writeText "localclientpw" "localclientpw"; + }; + }; + + prometheus = { + shb.deluge = { + prometheusScraperPasswordFile = pkgs.writeText "prompw" "prompw"; + }; + }; + + https = { config, ...}: { + shb.deluge = { + ssl = config.shb.certs.certs.selfsigned.n; + }; + }; + + ldap = { config, ... }: { + imports = [ + ../../modules/blocks/ldap.nix + ]; + + shb.ldap = { + enable = true; + inherit domain; + subdomain = "ldap"; + ldapPort = 3890; + webUIListenPort = 17170; + dcdomain = "dc=example,dc=com"; + ldapUserPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + }; + + networking.hosts = { + "127.0.0.1" = [ "${config.shb.ldap.subdomain}.${domain}" ]; + }; + }; + + sso = { config, ... }: { + imports = [ + ../../modules/blocks/authelia.nix + ../../modules/blocks/postgresql.nix + ]; + + shb.authelia = { + enable = true; + inherit domain; + subdomain = "auth"; + ssl = config.shb.certs.certs.selfsigned.n; + + ldapEndpoint = "ldap://127.0.0.1:${builtins.toString config.shb.ldap.ldapPort}"; + dcdomain = config.shb.ldap.dcdomain; + + secrets = { + jwtSecretFile = pkgs.writeText "jwtSecret" "jwtSecret"; + ldapAdminPasswordFile = pkgs.writeText "ldapUserPassword" "ldapUserPassword"; + sessionSecretFile = pkgs.writeText "sessionSecret" "sessionSecret"; + storageEncryptionKeyFile = pkgs.writeText "storageEncryptionKey" "storageEncryptionKey"; + identityProvidersOIDCHMACSecretFile = pkgs.writeText "identityProvidersOIDCHMACSecret" "identityProvidersOIDCHMACSecret"; + identityProvidersOIDCIssuerPrivateKeyFile = (pkgs.runCommand "gen-private-key" {} '' + mkdir $out + ${pkgs.openssl}/bin/openssl genrsa -out $out/private.pem 4096 + '') + "/private.pem"; + }; + }; + + networking.hosts = { + "127.0.0.1" = [ "${config.shb.authelia.subdomain}.${domain}" ]; + }; + + shb.deluge = { + authEndpoint = "https://${config.shb.authelia.subdomain}.${config.shb.authelia.domain}"; + }; + }; +in +{ + basic = pkgs.testers.runNixOSTest { + name = "deluge_basic"; + + nodes.server = lib.mkMerge [ + base + basic + { + options = { + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ]; + + nodes.client = {}; + + testScript = inputs: + (commonTestScript inputs) + + (authTestScript inputs); + }; + + https = pkgs.testers.runNixOSTest { + name = "deluge_https"; + + nodes.server = lib.mkMerge [ + base + certs + basic + https + { + options = { + shb.authelia = lib.mkOption { type = lib.types.anything; }; + }; + } + ]; + + nodes.client = {}; + + testScript = inputs: + (commonTestScript inputs) + + (authTestScript inputs); + }; + + # TODO: make this work, needs to authenticate to Authelia + # + # sso = pkgs.testers.runNixOSTest { + # name = "deluge_sso"; + # + # nodes.server = lib.mkMerge [ + # base + # basic + # certs + # https + # ldap + # sso + # ]; + # + # nodes.client = {}; + # + # testScript = commonTestScript; + # }; +}