diff options
Diffstat (limited to 'nixos/modules/services/torrent')
-rw-r--r-- | nixos/modules/services/torrent/deluge.nix | 279 | ||||
-rw-r--r-- | nixos/modules/services/torrent/flexget.nix | 100 | ||||
-rw-r--r-- | nixos/modules/services/torrent/magnetico.nix | 220 | ||||
-rw-r--r-- | nixos/modules/services/torrent/opentracker.nix | 45 | ||||
-rw-r--r-- | nixos/modules/services/torrent/peerflix.nix | 71 | ||||
-rw-r--r-- | nixos/modules/services/torrent/rtorrent.nix | 211 | ||||
-rw-r--r-- | nixos/modules/services/torrent/transmission.nix | 487 |
7 files changed, 1413 insertions, 0 deletions
diff --git a/nixos/modules/services/torrent/deluge.nix b/nixos/modules/services/torrent/deluge.nix new file mode 100644 index 00000000000..cb0da9e83b4 --- /dev/null +++ b/nixos/modules/services/torrent/deluge.nix @@ -0,0 +1,279 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.deluge; + cfg_web = config.services.deluge.web; + isDeluge1 = versionOlder cfg.package.version "2.0.0"; + + openFilesLimit = 4096; + listenPortsDefault = [ 6881 6889 ]; + + listToRange = x: { from = elemAt x 0; to = elemAt x 1; }; + + configDir = "${cfg.dataDir}/.config/deluge"; + configFile = pkgs.writeText "core.conf" (builtins.toJSON cfg.config); + declarativeLockFile = "${configDir}/.declarative"; + + preStart = if cfg.declarative then '' + if [ -e ${declarativeLockFile} ]; then + # Was declarative before, no need to back up anything + ${if isDeluge1 then "ln -sf" else "cp"} ${configFile} ${configDir}/core.conf + ln -sf ${cfg.authFile} ${configDir}/auth + else + # Declarative for the first time, backup stateful files + ${if isDeluge1 then "ln -s" else "cp"} -b --suffix=.stateful ${configFile} ${configDir}/core.conf + ln -sb --suffix=.stateful ${cfg.authFile} ${configDir}/auth + echo "Autogenerated file that signifies that this server configuration is managed declaratively by NixOS" \ + > ${declarativeLockFile} + fi + '' else '' + if [ -e ${declarativeLockFile} ]; then + rm ${declarativeLockFile} + fi + ''; +in { + options = { + services = { + deluge = { + enable = mkEnableOption "Deluge daemon"; + + openFilesLimit = mkOption { + default = openFilesLimit; + type = types.either types.int types.str; + description = '' + Number of files to allow deluged to open. + ''; + }; + + config = mkOption { + type = types.attrs; + default = {}; + example = literalExpression '' + { + download_location = "/srv/torrents/"; + max_upload_speed = "1000.0"; + share_ratio_limit = "2.0"; + allow_remote = true; + daemon_port = 58846; + listen_ports = [ ${toString listenPortsDefault} ]; + } + ''; + description = '' + Deluge core configuration for the core.conf file. Only has an effect + when <option>services.deluge.declarative</option> is set to + <literal>true</literal>. String values must be quoted, integer and + boolean values must not. See + <link xlink:href="https://git.deluge-torrent.org/deluge/tree/deluge/core/preferencesmanager.py#n41"/> + for the availaible options. + ''; + }; + + declarative = mkOption { + type = types.bool; + default = false; + description = '' + Whether to use a declarative deluge configuration. + Only if set to <literal>true</literal>, the options + <option>services.deluge.config</option>, + <option>services.deluge.openFirewall</option> and + <option>services.deluge.authFile</option> will be + applied. + ''; + }; + + openFirewall = mkOption { + default = false; + type = types.bool; + description = '' + Whether to open the firewall for the ports in + <option>services.deluge.config.listen_ports</option>. It only takes effet if + <option>services.deluge.declarative</option> is set to + <literal>true</literal>. + + It does NOT apply to the daemon port nor the web UI port. To access those + ports secuerly check the documentation + <link xlink:href="https://dev.deluge-torrent.org/wiki/UserGuide/ThinClient#CreateSSHTunnel"/> + or use a VPN or configure certificates for deluge. + ''; + }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/deluge"; + description = '' + The directory where deluge will create files. + ''; + }; + + authFile = mkOption { + type = types.path; + example = "/run/keys/deluge-auth"; + description = '' + The file managing the authentication for deluge, the format of this + file is straightforward, each line contains a + username:password:level tuple in plaintext. It only has an effect + when <option>services.deluge.declarative</option> is set to + <literal>true</literal>. + See <link xlink:href="https://dev.deluge-torrent.org/wiki/UserGuide/Authentication"/> for + more informations. + ''; + }; + + user = mkOption { + type = types.str; + default = "deluge"; + description = '' + User account under which deluge runs. + ''; + }; + + group = mkOption { + type = types.str; + default = "deluge"; + description = '' + Group under which deluge runs. + ''; + }; + + extraPackages = mkOption { + type = types.listOf types.package; + default = []; + description = '' + Extra packages available at runtime to enable Deluge's plugins. For example, + extraction utilities are required for the built-in "Extractor" plugin. + This always contains unzip, gnutar, xz and bzip2. + ''; + }; + + package = mkOption { + type = types.package; + example = literalExpression "pkgs.deluge-2_x"; + description = '' + Deluge package to use. + ''; + }; + }; + + deluge.web = { + enable = mkEnableOption "Deluge Web daemon"; + + port = mkOption { + type = types.port; + default = 8112; + description = '' + Deluge web UI port. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Open ports in the firewall for deluge web daemon + ''; + }; + }; + }; + }; + + config = mkIf cfg.enable { + + services.deluge.package = mkDefault ( + if versionAtLeast config.system.stateVersion "20.09" then + pkgs.deluge-2_x + else + # deluge-1_x is no longer packaged and this will resolve to an error + # thanks to the alias for this name. This is left here so that anyone + # using NixOS older than 20.09 receives that error when they upgrade + # and is forced to make an intentional choice to switch to deluge-2_x. + # That might be slightly inconvenient but there is no path to + # downgrade from 2.x to 1.x so NixOS should not automatically perform + # this state migration. + pkgs.deluge-1_x + ); + + # Provide a default set of `extraPackages`. + services.deluge.extraPackages = with pkgs; [ unzip gnutar xz bzip2 ]; + + systemd.tmpfiles.rules = [ + "d '${cfg.dataDir}' 0770 ${cfg.user} ${cfg.group}" + "d '${cfg.dataDir}/.config' 0770 ${cfg.user} ${cfg.group}" + "d '${cfg.dataDir}/.config/deluge' 0770 ${cfg.user} ${cfg.group}" + ] + ++ optional (cfg.config ? download_location) + "d '${cfg.config.download_location}' 0770 ${cfg.user} ${cfg.group}" + ++ optional (cfg.config ? torrentfiles_location) + "d '${cfg.config.torrentfiles_location}' 0770 ${cfg.user} ${cfg.group}" + ++ optional (cfg.config ? move_completed_path) + "d '${cfg.config.move_completed_path}' 0770 ${cfg.user} ${cfg.group}"; + + systemd.services.deluged = { + after = [ "network.target" ]; + description = "Deluge BitTorrent Daemon"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.package ] ++ cfg.extraPackages; + serviceConfig = { + ExecStart = '' + ${cfg.package}/bin/deluged \ + --do-not-daemonize \ + --config ${configDir} + ''; + # To prevent "Quit & shutdown daemon" from working; we want systemd to + # manage it! + Restart = "on-success"; + User = cfg.user; + Group = cfg.group; + UMask = "0002"; + LimitNOFILE = cfg.openFilesLimit; + }; + preStart = preStart; + }; + + systemd.services.delugeweb = mkIf cfg_web.enable { + after = [ "network.target" "deluged.service"]; + requires = [ "deluged.service" ]; + description = "Deluge BitTorrent WebUI"; + wantedBy = [ "multi-user.target" ]; + path = [ cfg.package ]; + serviceConfig = { + ExecStart = '' + ${cfg.package}/bin/deluge-web \ + ${optionalString (!isDeluge1) "--do-not-daemonize"} \ + --config ${configDir} \ + --port ${toString cfg.web.port} + ''; + User = cfg.user; + Group = cfg.group; + }; + }; + + networking.firewall = mkMerge [ + (mkIf (cfg.declarative && cfg.openFirewall && !(cfg.config.random_port or true)) { + allowedTCPPortRanges = singleton (listToRange (cfg.config.listen_ports or listenPortsDefault)); + allowedUDPPortRanges = singleton (listToRange (cfg.config.listen_ports or listenPortsDefault)); + }) + (mkIf (cfg.web.openFirewall) { + allowedTCPPorts = [ cfg.web.port ]; + }) + ]; + + environment.systemPackages = [ cfg.package ]; + + users.users = mkIf (cfg.user == "deluge") { + deluge = { + group = cfg.group; + uid = config.ids.uids.deluge; + home = cfg.dataDir; + description = "Deluge Daemon user"; + }; + }; + + users.groups = mkIf (cfg.group == "deluge") { + deluge = { + gid = config.ids.gids.deluge; + }; + }; + }; +} diff --git a/nixos/modules/services/torrent/flexget.nix b/nixos/modules/services/torrent/flexget.nix new file mode 100644 index 00000000000..e500e02d861 --- /dev/null +++ b/nixos/modules/services/torrent/flexget.nix @@ -0,0 +1,100 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.flexget; + pkg = pkgs.flexget; + ymlFile = pkgs.writeText "flexget.yml" '' + ${cfg.config} + + ${optionalString cfg.systemScheduler "schedules: no"} +''; + configFile = "${toString cfg.homeDir}/flexget.yml"; +in { + options = { + services.flexget = { + enable = mkEnableOption "Run FlexGet Daemon"; + + user = mkOption { + default = "deluge"; + example = "some_user"; + type = types.str; + description = "The user under which to run flexget."; + }; + + homeDir = mkOption { + default = "/var/lib/deluge"; + example = "/home/flexget"; + type = types.path; + description = "Where files live."; + }; + + interval = mkOption { + default = "10m"; + example = "1h"; + type = types.str; + description = "When to perform a <command>flexget</command> run. See <command>man 7 systemd.time</command> for the format."; + }; + + systemScheduler = mkOption { + default = true; + example = false; + type = types.bool; + description = "When true, execute the runs via the flexget-runner.timer. If false, you have to specify the settings yourself in the YML file."; + }; + + config = mkOption { + default = ""; + type = types.lines; + description = "The YAML configuration for FlexGet."; + }; + }; + }; + + config = mkIf cfg.enable { + + environment.systemPackages = [ pkg ]; + + systemd.services = { + flexget = { + description = "FlexGet Daemon"; + path = [ pkg ]; + serviceConfig = { + User = cfg.user; + Environment = "TZ=${config.time.timeZone}"; + ExecStartPre = "${pkgs.coreutils}/bin/install -m644 ${ymlFile} ${configFile}"; + ExecStart = "${pkg}/bin/flexget -c ${configFile} daemon start"; + ExecStop = "${pkg}/bin/flexget -c ${configFile} daemon stop"; + ExecReload = "${pkg}/bin/flexget -c ${configFile} daemon reload"; + Restart = "on-failure"; + PrivateTmp = true; + WorkingDirectory = toString cfg.homeDir; + }; + wantedBy = [ "multi-user.target" ]; + }; + + flexget-runner = mkIf cfg.systemScheduler { + description = "FlexGet Runner"; + after = [ "flexget.service" ]; + wants = [ "flexget.service" ]; + serviceConfig = { + User = cfg.user; + ExecStart = "${pkg}/bin/flexget -c ${configFile} execute"; + PrivateTmp = true; + WorkingDirectory = toString cfg.homeDir; + }; + }; + }; + + systemd.timers.flexget-runner = mkIf cfg.systemScheduler { + description = "Run FlexGet every ${cfg.interval}"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnBootSec = "5m"; + OnUnitInactiveSec = cfg.interval; + Unit = "flexget-runner.service"; + }; + }; + }; +} diff --git a/nixos/modules/services/torrent/magnetico.nix b/nixos/modules/services/torrent/magnetico.nix new file mode 100644 index 00000000000..3dd7b1ece76 --- /dev/null +++ b/nixos/modules/services/torrent/magnetico.nix @@ -0,0 +1,220 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.magnetico; + + dataDir = "/var/lib/magnetico"; + + credFile = with cfg.web; + if credentialsFile != null + then credentialsFile + else pkgs.writeText "magnetico-credentials" + (concatStrings (mapAttrsToList + (user: hash: "${user}:${hash}\n") + cfg.web.credentials)); + + # default options in magneticod/main.go + dbURI = concatStrings + [ "sqlite3://${dataDir}/database.sqlite3" + "?_journal_mode=WAL" + "&_busy_timeout=3000" + "&_foreign_keys=true" + ]; + + crawlerArgs = with cfg.crawler; escapeShellArgs + ([ "--database=${dbURI}" + "--indexer-addr=${address}:${toString port}" + "--indexer-max-neighbors=${toString maxNeighbors}" + "--leech-max-n=${toString maxLeeches}" + ] ++ extraOptions); + + webArgs = with cfg.web; escapeShellArgs + ([ "--database=${dbURI}" + (if (cfg.web.credentialsFile != null || cfg.web.credentials != { }) + then "--credentials=${toString credFile}" + else "--no-auth") + "--addr=${address}:${toString port}" + ] ++ extraOptions); + +in { + + ###### interface + + options.services.magnetico = { + enable = mkEnableOption "Magnetico, Bittorrent DHT crawler"; + + crawler.address = mkOption { + type = types.str; + default = "0.0.0.0"; + example = "1.2.3.4"; + description = '' + Address to be used for indexing DHT nodes. + ''; + }; + + crawler.port = mkOption { + type = types.port; + default = 0; + description = '' + Port to be used for indexing DHT nodes. + This port should be added to + <option>networking.firewall.allowedTCPPorts</option>. + ''; + }; + + crawler.maxNeighbors = mkOption { + type = types.ints.positive; + default = 1000; + description = '' + Maximum number of simultaneous neighbors of an indexer. + Be careful changing this number: high values can very + easily cause your network to be congested or even crash + your router. + ''; + }; + + crawler.maxLeeches = mkOption { + type = types.ints.positive; + default = 200; + description = '' + Maximum number of simultaneous leeches. + ''; + }; + + crawler.extraOptions = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Extra command line arguments to pass to magneticod. + ''; + }; + + web.address = mkOption { + type = types.str; + default = "localhost"; + example = "1.2.3.4"; + description = '' + Address the web interface will listen to. + ''; + }; + + web.port = mkOption { + type = types.port; + default = 8080; + description = '' + Port the web interface will listen to. + ''; + }; + + web.credentials = mkOption { + type = types.attrsOf types.str; + default = {}; + example = lib.literalExpression '' + { + myuser = "$2y$12$YE01LZ8jrbQbx6c0s2hdZO71dSjn2p/O9XsYJpz.5968yCysUgiaG"; + } + ''; + description = '' + The credentials to access the web interface, in case authentication is + enabled, in the format <literal>username:hash</literal>. If unset no + authentication will be required. + + Usernames must start with a lowercase ([a-z]) ASCII character, might + contain non-consecutive underscores except at the end, and consists of + small-case a-z characters and digits 0-9. The + <command>htpasswd</command> tool from the <package>apacheHttpd + </package> package may be used to generate the hash: <command>htpasswd + -bnBC 12 username password</command> + + <warning> + <para> + The hashes will be stored world-readable in the nix store. + Consider using the <literal>credentialsFile</literal> option if you + don't want this. + </para> + </warning> + ''; + }; + + web.credentialsFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + The path to the file holding the credentials to access the web + interface. If unset no authentication will be required. + + The file must constain user names and password hashes in the format + <literal>username:hash </literal>, one for each line. Usernames must + start with a lowecase ([a-z]) ASCII character, might contain + non-consecutive underscores except at the end, and consists of + small-case a-z characters and digits 0-9. + The <command>htpasswd</command> tool from the <package>apacheHttpd + </package> package may be used to generate the hash: + <command>htpasswd -bnBC 12 username password</command> + ''; + }; + + web.extraOptions = mkOption { + type = types.listOf types.str; + default = []; + description = '' + Extra command line arguments to pass to magneticow. + ''; + }; + + }; + + ###### implementation + + config = mkIf cfg.enable { + + users.users.magnetico = { + description = "Magnetico daemons user"; + group = "magnetico"; + isSystemUser = true; + }; + users.groups.magnetico = {}; + + systemd.services.magneticod = { + description = "Magnetico DHT crawler"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + + serviceConfig = { + User = "magnetico"; + Restart = "on-failure"; + ExecStart = "${pkgs.magnetico}/bin/magneticod ${crawlerArgs}"; + }; + }; + + systemd.services.magneticow = { + description = "Magnetico web interface"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "magneticod.service"]; + + serviceConfig = { + User = "magnetico"; + StateDirectory = "magnetico"; + Restart = "on-failure"; + ExecStart = "${pkgs.magnetico}/bin/magneticow ${webArgs}"; + }; + }; + + assertions = + [ + { + assertion = cfg.web.credentialsFile == null || cfg.web.credentials == { }; + message = '' + The options services.magnetico.web.credentialsFile and + services.magnetico.web.credentials are mutually exclusives. + ''; + } + ]; + + }; + + meta.maintainers = with lib.maintainers; [ rnhmjoj ]; + +} diff --git a/nixos/modules/services/torrent/opentracker.nix b/nixos/modules/services/torrent/opentracker.nix new file mode 100644 index 00000000000..d76d61dfe85 --- /dev/null +++ b/nixos/modules/services/torrent/opentracker.nix @@ -0,0 +1,45 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.services.opentracker; +in { + options.services.opentracker = { + enable = mkEnableOption "opentracker"; + + package = mkOption { + type = types.package; + description = '' + opentracker package to use + ''; + default = pkgs.opentracker; + defaultText = literalExpression "pkgs.opentracker"; + }; + + extraOptions = mkOption { + type = types.separatedString " "; + description = '' + Configuration Arguments for opentracker + See https://erdgeist.org/arts/software/opentracker/ for all params + ''; + default = ""; + }; + }; + + config = lib.mkIf cfg.enable { + + systemd.services.opentracker = { + description = "opentracker server"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + restartIfChanged = true; + serviceConfig = { + ExecStart = "${cfg.package}/bin/opentracker ${cfg.extraOptions}"; + PrivateTmp = true; + WorkingDirectory = "/var/empty"; + # By default opentracker drops all privileges and runs in chroot after starting up as root. + }; + }; + }; +} + diff --git a/nixos/modules/services/torrent/peerflix.nix b/nixos/modules/services/torrent/peerflix.nix new file mode 100644 index 00000000000..821c829f6b4 --- /dev/null +++ b/nixos/modules/services/torrent/peerflix.nix @@ -0,0 +1,71 @@ +{ config, lib, options, pkgs, ... }: + +with lib; + +let + cfg = config.services.peerflix; + opt = options.services.peerflix; + + configFile = pkgs.writeText "peerflix-config.json" '' + { + "connections": 50, + "tmp": "${cfg.downloadDir}" + } + ''; + +in { + + ###### interface + + options.services.peerflix = { + enable = mkOption { + description = "Whether to enable peerflix service."; + default = false; + type = types.bool; + }; + + stateDir = mkOption { + description = "Peerflix state directory."; + default = "/var/lib/peerflix"; + type = types.path; + }; + + downloadDir = mkOption { + description = "Peerflix temporary download directory."; + default = "${cfg.stateDir}/torrents"; + defaultText = literalExpression ''"''${config.${opt.stateDir}}/torrents"''; + type = types.path; + }; + }; + + ###### implementation + + config = mkIf cfg.enable { + systemd.tmpfiles.rules = [ + "d '${cfg.stateDir}' - peerflix - - -" + ]; + + systemd.services.peerflix = { + description = "Peerflix Daemon"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + environment.HOME = cfg.stateDir; + + preStart = '' + mkdir -p "${cfg.stateDir}"/{torrents,.config/peerflix-server} + ln -fs "${configFile}" "${cfg.stateDir}/.config/peerflix-server/config.json" + ''; + + serviceConfig = { + ExecStart = "${pkgs.nodePackages.peerflix-server}/bin/peerflix-server"; + User = "peerflix"; + }; + }; + + users.users.peerflix = { + isSystemUser = true; + group = "peerflix"; + }; + users.groups.peerflix = {}; + }; +} diff --git a/nixos/modules/services/torrent/rtorrent.nix b/nixos/modules/services/torrent/rtorrent.nix new file mode 100644 index 00000000000..759dcfe2e6c --- /dev/null +++ b/nixos/modules/services/torrent/rtorrent.nix @@ -0,0 +1,211 @@ +{ config, options, pkgs, lib, ... }: + +with lib; + +let + + cfg = config.services.rtorrent; + opt = options.services.rtorrent; + +in { + options.services.rtorrent = { + enable = mkEnableOption "rtorrent"; + + dataDir = mkOption { + type = types.str; + default = "/var/lib/rtorrent"; + description = '' + The directory where rtorrent stores its data files. + ''; + }; + + downloadDir = mkOption { + type = types.str; + default = "${cfg.dataDir}/download"; + defaultText = literalExpression ''"''${config.${opt.dataDir}}/download"''; + description = '' + Where to put downloaded files. + ''; + }; + + user = mkOption { + type = types.str; + default = "rtorrent"; + description = '' + User account under which rtorrent runs. + ''; + }; + + group = mkOption { + type = types.str; + default = "rtorrent"; + description = '' + Group under which rtorrent runs. + ''; + }; + + package = mkOption { + type = types.package; + default = pkgs.rtorrent; + defaultText = literalExpression "pkgs.rtorrent"; + description = '' + The rtorrent package to use. + ''; + }; + + port = mkOption { + type = types.port; + default = 50000; + description = '' + The rtorrent port. + ''; + }; + + openFirewall = mkOption { + type = types.bool; + default = false; + description = '' + Whether to open the firewall for the port in <option>services.rtorrent.port</option>. + ''; + }; + + rpcSocket = mkOption { + type = types.str; + readOnly = true; + default = "/run/rtorrent/rpc.sock"; + description = '' + RPC socket path. + ''; + }; + + configText = mkOption { + type = types.lines; + default = ""; + description = '' + The content of <filename>rtorrent.rc</filename>. The <link xlink:href="https://rtorrent-docs.readthedocs.io/en/latest/cookbook.html#modernized-configuration-template">modernized configuration template</link> with the values specified in this module will be prepended using mkBefore. You can use mkForce to overwrite the config completly. + ''; + }; + }; + + config = mkIf cfg.enable { + + users.groups = mkIf (cfg.group == "rtorrent") { + rtorrent = {}; + }; + + users.users = mkIf (cfg.user == "rtorrent") { + rtorrent = { + group = cfg.group; + shell = pkgs.bashInteractive; + home = cfg.dataDir; + description = "rtorrent Daemon user"; + isSystemUser = true; + }; + }; + + networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall) [ cfg.port ]; + + services.rtorrent.configText = mkBefore '' + # Instance layout (base paths) + method.insert = cfg.basedir, private|const|string, (cat,"${cfg.dataDir}/") + method.insert = cfg.watch, private|const|string, (cat,(cfg.basedir),"watch/") + method.insert = cfg.logs, private|const|string, (cat,(cfg.basedir),"log/") + method.insert = cfg.logfile, private|const|string, (cat,(cfg.logs),(system.time),".log") + method.insert = cfg.rpcsock, private|const|string, (cat,"${cfg.rpcSocket}") + + # Create instance directories + execute.throw = sh, -c, (cat, "mkdir -p ", (cfg.basedir), "/session ", (cfg.watch), " ", (cfg.logs)) + + # Listening port for incoming peer traffic (fixed; you can also randomize it) + network.port_range.set = ${toString cfg.port}-${toString cfg.port} + network.port_random.set = no + + # Tracker-less torrent and UDP tracker support + # (conservative settings for 'private' trackers, change for 'public') + dht.mode.set = disable + protocol.pex.set = no + trackers.use_udp.set = no + + # Peer settings + throttle.max_uploads.set = 100 + throttle.max_uploads.global.set = 250 + + throttle.min_peers.normal.set = 20 + throttle.max_peers.normal.set = 60 + throttle.min_peers.seed.set = 30 + throttle.max_peers.seed.set = 80 + trackers.numwant.set = 80 + + protocol.encryption.set = allow_incoming,try_outgoing,enable_retry + + # Limits for file handle resources, this is optimized for + # an `ulimit` of 1024 (a common default). You MUST leave + # a ceiling of handles reserved for rTorrent's internal needs! + network.http.max_open.set = 50 + network.max_open_files.set = 600 + network.max_open_sockets.set = 3000 + + # Memory resource usage (increase if you have a large number of items loaded, + # and/or the available resources to spend) + pieces.memory.max.set = 1800M + network.xmlrpc.size_limit.set = 4M + + # Basic operational settings (no need to change these) + session.path.set = (cat, (cfg.basedir), "session/") + directory.default.set = "${cfg.downloadDir}" + log.execute = (cat, (cfg.logs), "execute.log") + ##log.xmlrpc = (cat, (cfg.logs), "xmlrpc.log") + execute.nothrow = sh, -c, (cat, "echo >", (session.path), "rtorrent.pid", " ", (system.pid)) + + # Other operational settings (check & adapt) + encoding.add = utf8 + system.umask.set = 0027 + system.cwd.set = (cfg.basedir) + network.http.dns_cache_timeout.set = 25 + schedule2 = monitor_diskspace, 15, 60, ((close_low_diskspace, 1000M)) + + # Watch directories (add more as you like, but use unique schedule names) + #schedule2 = watch_start, 10, 10, ((load.start, (cat, (cfg.watch), "start/*.torrent"))) + #schedule2 = watch_load, 11, 10, ((load.normal, (cat, (cfg.watch), "load/*.torrent"))) + + # Logging: + # Levels = critical error warn notice info debug + # Groups = connection_* dht_* peer_* rpc_* storage_* thread_* tracker_* torrent_* + print = (cat, "Logging to ", (cfg.logfile)) + log.open_file = "log", (cfg.logfile) + log.add_output = "info", "log" + ##log.add_output = "tracker_debug", "log" + + # XMLRPC + scgi_local = (cfg.rpcsock) + schedule = scgi_group,0,0,"execute.nothrow=chown,\":rtorrent\",(cfg.rpcsock)" + schedule = scgi_permission,0,0,"execute.nothrow=chmod,\"g+w,o=\",(cfg.rpcsock)" + ''; + + systemd = { + services = { + rtorrent = let + rtorrentConfigFile = pkgs.writeText "rtorrent.rc" cfg.configText; + in { + description = "rTorrent system service"; + after = [ "network.target" ]; + path = [ cfg.package pkgs.bash ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + User = cfg.user; + Group = cfg.group; + Type = "simple"; + Restart = "on-failure"; + WorkingDirectory = cfg.dataDir; + ExecStartPre=''${pkgs.bash}/bin/bash -c "if test -e ${cfg.dataDir}/session/rtorrent.lock && test -z $(${pkgs.procps}/bin/pidof rtorrent); then rm -f ${cfg.dataDir}/session/rtorrent.lock; fi"''; + ExecStart="${cfg.package}/bin/rtorrent -n -o system.daemon.set=true -o import=${rtorrentConfigFile}"; + RuntimeDirectory = "rtorrent"; + RuntimeDirectoryMode = 755; + }; + }; + }; + + tmpfiles.rules = [ "d '${cfg.dataDir}' 0750 ${cfg.user} ${cfg.group} -" ]; + }; + }; +} diff --git a/nixos/modules/services/torrent/transmission.nix b/nixos/modules/services/torrent/transmission.nix new file mode 100644 index 00000000000..d12d8aa2398 --- /dev/null +++ b/nixos/modules/services/torrent/transmission.nix @@ -0,0 +1,487 @@ +{ config, lib, pkgs, options, ... }: + +with lib; + +let + cfg = config.services.transmission; + opt = options.services.transmission; + inherit (config.environment) etc; + apparmor = config.security.apparmor; + rootDir = "/run/transmission"; + settingsDir = ".config/transmission-daemon"; + downloadsDir = "Downloads"; + incompleteDir = ".incomplete"; + watchDir = "watchdir"; + settingsFormat = pkgs.formats.json {}; + settingsFile = settingsFormat.generate "settings.json" cfg.settings; +in +{ + imports = [ + (mkRenamedOptionModule ["services" "transmission" "port"] + ["services" "transmission" "settings" "rpc-port"]) + (mkAliasOptionModule ["services" "transmission" "openFirewall"] + ["services" "transmission" "openPeerPorts"]) + ]; + options = { + services.transmission = { + enable = mkEnableOption ''the headless Transmission BitTorrent daemon. + + Transmission daemon can be controlled via the RPC interface using + transmission-remote, the WebUI (http://127.0.0.1:9091/ by default), + or other clients like stig or tremc. + + Torrents are downloaded to <xref linkend="opt-services.transmission.home"/>/${downloadsDir} by default and are + accessible to users in the "transmission" group''; + + settings = mkOption { + description = '' + Settings whose options overwrite fields in + <literal>.config/transmission-daemon/settings.json</literal> + (each time the service starts). + + See <link xlink:href="https://github.com/transmission/transmission/wiki/Editing-Configuration-Files">Transmission's Wiki</link> + for documentation of settings not explicitely covered by this module. + ''; + default = {}; + type = types.submodule { + freeformType = settingsFormat.type; + options.download-dir = mkOption { + type = types.path; + default = "${cfg.home}/${downloadsDir}"; + defaultText = literalExpression ''"''${config.${opt.home}}/${downloadsDir}"''; + description = "Directory where to download torrents."; + }; + options.incomplete-dir = mkOption { + type = types.path; + default = "${cfg.home}/${incompleteDir}"; + defaultText = literalExpression ''"''${config.${opt.home}}/${incompleteDir}"''; + description = '' + When enabled with + services.transmission.home + <xref linkend="opt-services.transmission.settings.incomplete-dir-enabled"/>, + new torrents will download the files to this directory. + When complete, the files will be moved to download-dir + <xref linkend="opt-services.transmission.settings.download-dir"/>. + ''; + }; + options.incomplete-dir-enabled = mkOption { + type = types.bool; + default = true; + description = ""; + }; + options.message-level = mkOption { + type = types.ints.between 0 3; + default = 2; + description = "Set verbosity of transmission messages."; + }; + options.peer-port = mkOption { + type = types.port; + default = 51413; + description = "The peer port to listen for incoming connections."; + }; + options.peer-port-random-high = mkOption { + type = types.port; + default = 65535; + description = '' + The maximum peer port to listen to for incoming connections + when <xref linkend="opt-services.transmission.settings.peer-port-random-on-start"/> is enabled. + ''; + }; + options.peer-port-random-low = mkOption { + type = types.port; + default = 65535; + description = '' + The minimal peer port to listen to for incoming connections + when <xref linkend="opt-services.transmission.settings.peer-port-random-on-start"/> is enabled. + ''; + }; + options.peer-port-random-on-start = mkOption { + type = types.bool; + default = false; + description = "Randomize the peer port."; + }; + options.rpc-bind-address = mkOption { + type = types.str; + default = "127.0.0.1"; + example = "0.0.0.0"; + description = '' + Where to listen for RPC connections. + Use \"0.0.0.0\" to listen on all interfaces. + ''; + }; + options.rpc-port = mkOption { + type = types.port; + default = 9091; + description = "The RPC port to listen to."; + }; + options.script-torrent-done-enabled = mkOption { + type = types.bool; + default = false; + description = '' + Whether to run + <xref linkend="opt-services.transmission.settings.script-torrent-done-filename"/> + at torrent completion. + ''; + }; + options.script-torrent-done-filename = mkOption { + type = types.nullOr types.path; + default = null; + description = "Executable to be run at torrent completion."; + }; + options.umask = mkOption { + type = types.int; + default = 2; + description = '' + Sets transmission's file mode creation mask. + See the umask(2) manpage for more information. + Users who want their saved torrents to be world-writable + may want to set this value to 0. + Bear in mind that the json markup language only accepts numbers in base 10, + so the standard umask(2) octal notation "022" is written in settings.json as 18. + ''; + }; + options.utp-enabled = mkOption { + type = types.bool; + default = true; + description = '' + Whether to enable <link xlink:href="http://en.wikipedia.org/wiki/Micro_Transport_Protocol">Micro Transport Protocol (µTP)</link>. + ''; + }; + options.watch-dir = mkOption { + type = types.path; + default = "${cfg.home}/${watchDir}"; + defaultText = literalExpression ''"''${config.${opt.home}}/${watchDir}"''; + description = "Watch a directory for torrent files and add them to transmission."; + }; + options.watch-dir-enabled = mkOption { + type = types.bool; + default = false; + description = ''Whether to enable the + <xref linkend="opt-services.transmission.settings.watch-dir"/>. + ''; + }; + options.trash-original-torrent-files = mkOption { + type = types.bool; + default = false; + description = ''Whether to delete torrents added from the + <xref linkend="opt-services.transmission.settings.watch-dir"/>. + ''; + }; + }; + }; + + downloadDirPermissions = mkOption { + type = with types; nullOr str; + default = null; + example = "770"; + description = '' + If not <code>null</code>, is used as the permissions + set by <literal>systemd.activationScripts.transmission-daemon</literal> + on the directories <xref linkend="opt-services.transmission.settings.download-dir"/>, + <xref linkend="opt-services.transmission.settings.incomplete-dir"/>. + and <xref linkend="opt-services.transmission.settings.watch-dir"/>. + Note that you may also want to change + <xref linkend="opt-services.transmission.settings.umask"/>. + ''; + }; + + home = mkOption { + type = types.path; + default = "/var/lib/transmission"; + description = '' + The directory where Transmission will create <literal>${settingsDir}</literal>. + as well as <literal>${downloadsDir}/</literal> unless + <xref linkend="opt-services.transmission.settings.download-dir"/> is changed, + and <literal>${incompleteDir}/</literal> unless + <xref linkend="opt-services.transmission.settings.incomplete-dir"/> is changed. + ''; + }; + + user = mkOption { + type = types.str; + default = "transmission"; + description = "User account under which Transmission runs."; + }; + + group = mkOption { + type = types.str; + default = "transmission"; + description = "Group account under which Transmission runs."; + }; + + credentialsFile = mkOption { + type = types.path; + description = '' + Path to a JSON file to be merged with the settings. + Useful to merge a file which is better kept out of the Nix store + to set secret config parameters like <code>rpc-password</code>. + ''; + default = "/dev/null"; + example = "/var/lib/secrets/transmission/settings.json"; + }; + + extraFlags = mkOption { + type = types.listOf types.str; + default = []; + example = [ "--log-debug" ]; + description = '' + Extra flags passed to the transmission command in the service definition. + ''; + }; + + openPeerPorts = mkEnableOption "opening of the peer port(s) in the firewall"; + + openRPCPort = mkEnableOption "opening of the RPC port in the firewall"; + + performanceNetParameters = mkEnableOption ''tweaking of kernel parameters + to open many more connections at the same time. + + Note that you may also want to increase + <code>peer-limit-global"</code>. + And be aware that these settings are quite aggressive + and might not suite your regular desktop use. + For instance, SSH sessions may time out more easily''; + }; + }; + + config = mkIf cfg.enable { + # Note that using systemd.tmpfiles would not work here + # because it would fail when creating a directory + # with a different owner than its parent directory, by saying: + # Detected unsafe path transition /home/foo → /home/foo/Downloads during canonicalization of /home/foo/Downloads + # when /home/foo is not owned by cfg.user. + # Note also that using an ExecStartPre= wouldn't work either + # because BindPaths= needs these directories before. + system.activationScripts = mkIf (cfg.downloadDirPermissions != null) + { transmission-daemon = '' + install -d -m 700 '${cfg.home}/${settingsDir}' + chown -R '${cfg.user}:${cfg.group}' ${cfg.home}/${settingsDir} + install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.download-dir}' + '' + optionalString cfg.settings.incomplete-dir-enabled '' + install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.incomplete-dir}' + '' + optionalString cfg.settings.watch-dir-enabled '' + install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.watch-dir}' + ''; + }; + + systemd.services.transmission = { + description = "Transmission BitTorrent Service"; + after = [ "network.target" ] ++ optional apparmor.enable "apparmor.service"; + requires = optional apparmor.enable "apparmor.service"; + wantedBy = [ "multi-user.target" ]; + environment.CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source; + + serviceConfig = { + # Use "+" because credentialsFile may not be accessible to User= or Group=. + ExecStartPre = [("+" + pkgs.writeShellScript "transmission-prestart" '' + set -eu${lib.optionalString (cfg.settings.message-level >= 3) "x"} + ${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' | + install -D -m 600 -o '${cfg.user}' -g '${cfg.group}' /dev/stdin \ + '${cfg.home}/${settingsDir}/settings.json' + '')]; + ExecStart="${pkgs.transmission}/bin/transmission-daemon -f -g ${cfg.home}/${settingsDir} ${escapeShellArgs cfg.extraFlags}"; + ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; + User = cfg.user; + Group = cfg.group; + # Create rootDir in the host's mount namespace. + RuntimeDirectory = [(baseNameOf rootDir)]; + RuntimeDirectoryMode = "755"; + # This is for BindPaths= and BindReadOnlyPaths= + # to allow traversal of directories they create in RootDirectory=. + UMask = "0066"; + # Using RootDirectory= makes it possible + # to use the same paths download-dir/incomplete-dir + # (which appear in user's interfaces) without requiring cfg.user + # to have access to their parent directories, + # by using BindPaths=/BindReadOnlyPaths=. + # Note that TemporaryFileSystem= could have been used instead + # but not without adding some BindPaths=/BindReadOnlyPaths= + # that would only be needed for ExecStartPre=, + # because RootDirectoryStartOnly=true would not help. + RootDirectory = rootDir; + RootDirectoryStartOnly = true; + MountAPIVFS = true; + BindPaths = + [ "${cfg.home}/${settingsDir}" + cfg.settings.download-dir + ] ++ + optional cfg.settings.incomplete-dir-enabled + cfg.settings.incomplete-dir ++ + optional (cfg.settings.watch-dir-enabled && cfg.settings.trash-original-torrent-files) + cfg.settings.watch-dir; + BindReadOnlyPaths = [ + # No confinement done of /nix/store here like in systemd-confinement.nix, + # an AppArmor profile is provided to get a confinement based upon paths and rights. + builtins.storeDir + "/etc" + "/run" + ] ++ + optional (cfg.settings.script-torrent-done-enabled && + cfg.settings.script-torrent-done-filename != null) + cfg.settings.script-torrent-done-filename ++ + optional (cfg.settings.watch-dir-enabled && !cfg.settings.trash-original-torrent-files) + cfg.settings.watch-dir; + StateDirectory = [ + "transmission" + "transmission/.config/transmission-daemon" + "transmission/.incomplete" + "transmission/Downloads" + "transmission/watch-dir" + ]; + StateDirectoryMode = mkDefault 750; + # The following options are only for optimizing: + # systemd-analyze security transmission + AmbientCapabilities = ""; + CapabilityBoundingSet = ""; + # ProtectClock= adds DeviceAllow=char-rtc r + DeviceAllow = ""; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateMounts = true; + PrivateNetwork = mkDefault false; + PrivateTmp = true; + PrivateUsers = true; + ProtectClock = true; + ProtectControlGroups = true; + # ProtectHome=true would not allow BindPaths= to work accross /home, + # and ProtectHome=tmpfs would break statfs(), + # preventing transmission-daemon to report the available free space. + # However, RootDirectory= is used, so this is not a security concern + # since there would be nothing in /home but any BindPaths= wanted by the user. + ProtectHome = "read-only"; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectSystem = "strict"; + RemoveIPC = true; + # AF_UNIX may become usable one day: + # https://github.com/transmission/transmission/issues/441 + RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallFilter = [ + "@system-service" + # Groups in @system-service which do not contain a syscall + # listed by perf stat -e 'syscalls:sys_enter_*' transmission-daemon -f + # in tests, and seem likely not necessary for transmission-daemon. + "~@aio" "~@chown" "~@keyring" "~@memlock" "~@resources" "~@setuid" "~@timer" + # In the @privileged group, but reached when querying infos through RPC (eg. with stig). + "quotactl" + ]; + SystemCallArchitectures = "native"; + }; + }; + + # It's useful to have transmission in path, e.g. for remote control + environment.systemPackages = [ pkgs.transmission ]; + + users.users = optionalAttrs (cfg.user == "transmission") ({ + transmission = { + group = cfg.group; + uid = config.ids.uids.transmission; + description = "Transmission BitTorrent user"; + home = cfg.home; + }; + }); + + users.groups = optionalAttrs (cfg.group == "transmission") ({ + transmission = { + gid = config.ids.gids.transmission; + }; + }); + + networking.firewall = mkMerge [ + (mkIf cfg.openPeerPorts ( + if cfg.settings.peer-port-random-on-start + then + { allowedTCPPortRanges = + [ { from = cfg.settings.peer-port-random-low; + to = cfg.settings.peer-port-random-high; + } + ]; + allowedUDPPortRanges = + [ { from = cfg.settings.peer-port-random-low; + to = cfg.settings.peer-port-random-high; + } + ]; + } + else + { allowedTCPPorts = [ cfg.settings.peer-port ]; + allowedUDPPorts = [ cfg.settings.peer-port ]; + } + )) + (mkIf cfg.openRPCPort { allowedTCPPorts = [ cfg.settings.rpc-port ]; }) + ]; + + boot.kernel.sysctl = mkMerge [ + # Transmission uses a single UDP socket in order to implement multiple uTP sockets, + # and thus expects large kernel buffers for the UDP socket, + # https://trac.transmissionbt.com/browser/trunk/libtransmission/tr-udp.c?rev=11956. + # at least up to the values hardcoded here: + (mkIf cfg.settings.utp-enabled { + "net.core.rmem_max" = mkDefault "4194304"; # 4MB + "net.core.wmem_max" = mkDefault "1048576"; # 1MB + }) + (mkIf cfg.performanceNetParameters { + # Increase the number of available source (local) TCP and UDP ports to 49151. + # Usual default is 32768 60999, ie. 28231 ports. + # Find out your current usage with: ss -s + "net.ipv4.ip_local_port_range" = mkDefault "16384 65535"; + # Timeout faster generic TCP states. + # Usual default is 600. + # Find out your current usage with: watch -n 1 netstat -nptuo + "net.netfilter.nf_conntrack_generic_timeout" = mkDefault 60; + # Timeout faster established but inactive connections. + # Usual default is 432000. + "net.netfilter.nf_conntrack_tcp_timeout_established" = mkDefault 600; + # Clear immediately TCP states after timeout. + # Usual default is 120. + "net.netfilter.nf_conntrack_tcp_timeout_time_wait" = mkDefault 1; + # Increase the number of trackable connections. + # Usual default is 262144. + # Find out your current usage with: conntrack -C + "net.netfilter.nf_conntrack_max" = mkDefault 1048576; + }) + ]; + + security.apparmor.policies."bin.transmission-daemon".profile = '' + include "${pkgs.transmission.apparmor}/bin.transmission-daemon" + ''; + security.apparmor.includes."local/bin.transmission-daemon" = '' + r ${config.systemd.services.transmission.environment.CURL_CA_BUNDLE}, + + owner rw ${cfg.home}/${settingsDir}/**, + rw ${cfg.settings.download-dir}/**, + ${optionalString cfg.settings.incomplete-dir-enabled '' + rw ${cfg.settings.incomplete-dir}/**, + ''} + ${optionalString cfg.settings.watch-dir-enabled '' + r${optionalString cfg.settings.trash-original-torrent-files "w"} ${cfg.settings.watch-dir}/**, + ''} + profile dirs { + rw ${cfg.settings.download-dir}/**, + ${optionalString cfg.settings.incomplete-dir-enabled '' + rw ${cfg.settings.incomplete-dir}/**, + ''} + ${optionalString cfg.settings.watch-dir-enabled '' + r${optionalString cfg.settings.trash-original-torrent-files "w"} ${cfg.settings.watch-dir}/**, + ''} + } + + ${optionalString (cfg.settings.script-torrent-done-enabled && + cfg.settings.script-torrent-done-filename != null) '' + # Stack transmission_directories profile on top of + # any existing profile for script-torrent-done-filename + # FIXME: to be tested as I'm not sure it works well with NoNewPrivileges= + # https://gitlab.com/apparmor/apparmor/-/wikis/AppArmorStacking#seccomp-and-no_new_privs + px ${cfg.settings.script-torrent-done-filename} -> &@{dirs}, + ''} + ''; + }; + + meta.maintainers = with lib.maintainers; [ julm ]; +} |