{ config, lib, pkgs, options, ... }: with lib; let cfg = config.services.transmission; inherit (config.environment) etc; apparmor = config.security.apparmor.enable; stateDir = "/var/lib/transmission"; # TODO: switch to configGen.json once RFC0042 is implemented settingsFile = pkgs.writeText "settings.json" (builtins.toJSON (cfg.settings // { download-dir = "${stateDir}/Downloads"; incomplete-dir = "${stateDir}/.incomplete"; })); settingsDir = ".config/transmission-daemon"; makeAbsolute = base: path: if builtins.match "^/.*" path == null then base+"/"+path else path; in { options = { services.transmission = { enable = mkEnableOption '' Whether or not to enable the headless Transmission BitTorrent daemon. Transmission daemon can be controlled via the RPC interface using transmission-remote, the WebUI (http://${cfg.settings.rpc-bind-address}:${toString cfg.settings.rpc-port}/ by default), or other clients like stig or tremc. Torrents are downloaded to ${cfg.settings.download-dir} by default and are accessible to users in the "transmission" group. ''; settings = mkOption rec { # TODO: switch to types.config.json as prescribed by RFC0042 once it's implemented type = types.attrs; apply = attrs: let super = recursiveUpdate default attrs; in super // { download-dir = makeAbsolute cfg.home super.download-dir; incomplete-dir = makeAbsolute cfg.home super.incomplete-dir; }; default = { download-dir = "${cfg.home}/Downloads"; incomplete-dir = "${cfg.home}/.incomplete"; incomplete-dir-enabled = true; peer-port = 51413; peer-port-random-high = 65535; peer-port-random-low = 49152; peer-port-random-on-start = false; rpc-bind-address = "127.0.0.1"; rpc-port = 9091; umask = 18; # 0o022 in decimal as expected by Transmission, obtained with: echo $((8#022)) }; example = { download-dir = "/srv/torrents/"; incomplete-dir = "/srv/torrents/.incomplete/"; incomplete-dir-enabled = true; rpc-whitelist = "127.0.0.1,192.168.*.*"; }; description = '' Attribute set whose fields overwrites fields in .config/transmission-daemon/settings.json (each time the service starts). String values must be quoted, integer and boolean values must not. See https://github.com/transmission/transmission/wiki/Editing-Configuration-Files for documentation. ''; }; downloadDirPermissions = mkOption { type = types.str; default = "770"; example = "775"; description = '' The permissions set by the systemd-tmpfiles-setup service on settings.download-dir and settings.incomplete-dir. ''; }; port = mkOption { type = types.port; description = '' TCP port number to run the RPC/web interface. If instead you want to change the peer port, use settings.peer-port or settings.peer-port-random-on-start. ''; }; home = mkOption { type = types.path; default = stateDir; description = '' The directory where Transmission will create .config/transmission-daemon/. as well as Downloads/ unless settings.download-dir is changed, and .incomplete/ unless 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 because it contains sensible data like settings.rpc-password. ''; default = "/dev/null"; example = "/var/lib/secrets/transmission/settings.json"; }; openFirewall = mkOption { type = types.bool; default = true; description = '' Whether to automatically open the peer port(s) in the firewall. ''; }; }; }; config = mkIf cfg.enable { systemd.tmpfiles.rules = optional (cfg.home != stateDir) "d '${cfg.home}/${settingsDir}' 700 '${cfg.user}' '${cfg.group}' - -" ++ [ "d '${cfg.settings.download-dir}' '${cfg.downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -" ] ++ optional cfg.settings.incomplete-dir-enabled "d '${cfg.settings.incomplete-dir}' '${cfg.downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -"; assertions = [ { assertion = builtins.match "^/.*" cfg.home != null; message = "`services.transmission.home' must be an absolute path."; } { assertion = types.port.check cfg.settings.rpc-port; message = "${toString cfg.settings.rpc-port} is not a valid port number for `services.transmission.settings.rpc-port`."; } # In case both port and settings.rpc-port are explicitely defined: they must be the same. { assertion = !options.services.transmission.port.isDefined || cfg.port == cfg.settings.rpc-port; message = "`services.transmission.port' is not equal to `services.transmission.settings.rpc-port'"; } ]; services.transmission.settings = optionalAttrs options.services.transmission.port.isDefined { rpc-port = cfg.port; }; systemd.services.transmission = { description = "Transmission BitTorrent Service"; after = [ "network.target" ] ++ optional apparmor "apparmor.service"; requires = optional apparmor "apparmor.service"; wantedBy = [ "multi-user.target" ]; environment.CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source; preStart = '' set -eux ${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' >'${stateDir}/${settingsDir}/settings.json' ''; serviceConfig = { WorkingDirectory = stateDir; ExecStart = "${pkgs.transmission}/bin/transmission-daemon -f"; ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; User = cfg.user; Group = cfg.group; StateDirectory = removePrefix "/var/lib/" stateDir + "/" + settingsDir; StateDirectoryMode = "0700"; BindPaths = optional (cfg.home != stateDir) "${cfg.home}/${settingsDir}:${stateDir}/${settingsDir}" ++ [ "${cfg.settings.download-dir}:${stateDir}/Downloads" ] ++ optional cfg.settings.incomplete-dir-enabled "${cfg.settings.incomplete-dir}:${stateDir}/.incomplete"; # The following options give: # systemd-analyze security transmission # → Overall exposure level for transmission.service: 1.5 OK AmbientCapabilities = ""; CapabilityBoundingSet = ""; LockPersonality = true; MemoryDenyWriteExecute = true; NoNewPrivileges = true; PrivateDevices = true; PrivateMounts = true; PrivateNetwork = false; PrivateTmp = true; PrivateUsers = false; ProtectClock = true; ProtectControlGroups = true; ProtectHome = mkDefault true; ProtectHostname = true; ProtectKernelLogs = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectSystem = mkDefault "strict"; ReadWritePaths = [ stateDir ]; RemoveIPC = true; RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; RestrictNamespaces = true; RestrictRealtime = true; RestrictSUIDSGID = true; # In case transmission crashes with status=31/SYS, # having systemd.coredump.enable = true # and environment.enableDebugInfo = true # enables to use coredumpctl debug to find the denied syscall. SystemCallFilter = [ "@default" "@aio" "@basic-io" #"@chown" #"@clock" #"@cpu-emulation" #"@debug" "@file-system" "@io-event" #"@ipc" #"@keyring" #"@memlock" #"@module" #"@mount" "@network-io" #"@obsolete" #"@pkey" #"@privileged" # Reached when querying infos through RPC (eg. with stig) "quotactl" "@process" #"@raw-io" #"@reboot" #"@resources" #"@setuid" "@signal" #"@swap" "@sync" "@system-service" "@timer" ]; SystemCallArchitectures = "native"; UMask = "0077"; }; }; # 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 = stateDir; createHome = false; }; }); users.groups = optionalAttrs (cfg.group == "transmission") ({ transmission = { gid = config.ids.gids.transmission; }; }); networking.firewall = mkIf cfg.openFirewall ( 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 ]; } ); # You can add --Complain to apparmor_parser calls in services.apparmor's ExecStart= # (because aa-complain is not working with the setup currently made by services.apparmor) # then use journalctl -b --grep apparmor= to see denied accesses. security.apparmor.profiles = mkIf apparmor [ (pkgs.writeText "apparmor-transmission-daemon" '' #include ${pkgs.transmission}/bin/transmission-daemon { #include #include # FIXME: these lines should be removed once # has been fixed to fit NixOS. ${etc."hosts".source} r, /etc/ld-nix.so.preload r, ${etc."ld-nix.so.preload".source} r, ${concatMapStrings (p: optionalString (p != "") (p+" mr,\n")) (splitString "\n" config.environment.etc."ld-nix.so.preload".text)} ${etc."ssl/certs/ca-certificates.crt".source} r, ${getLib pkgs.glibc}/lib/*.so* mr, ${getLib pkgs.libevent}/lib/libevent*.so* mr, ${getLib pkgs.curl}/lib/libcurl*.so* mr, ${getLib pkgs.openssl}/lib/libssl*.so* mr, ${getLib pkgs.openssl}/lib/libcrypto*.so* mr, ${getLib pkgs.zlib}/lib/libz*.so* mr, ${getLib pkgs.libssh2}/lib/libssh2*.so* mr, ${getLib pkgs.systemd}/lib/libsystemd*.so* mr, ${getLib pkgs.xz}/lib/liblzma*.so* mr, ${getLib pkgs.libgcrypt}/lib/libgcrypt*.so* mr, ${getLib pkgs.libgpgerror}/lib/libgpg-error*.so* mr, ${getLib pkgs.nghttp2}/lib/libnghttp2*.so* mr, ${getLib pkgs.c-ares}/lib/libcares*.so* mr, ${getLib pkgs.libcap}/lib/libcap*.so* mr, ${getLib pkgs.attr}/lib/libattr*.so* mr, ${getLib pkgs.lz4}/lib/liblz4*.so* mr, ${getLib pkgs.libkrb5}/lib/lib*.so* mr, ${getLib pkgs.keyutils}/lib/libkeyutils*.so* mr, ${getLib pkgs.utillinuxMinimal.out}/lib/libblkid.so.* mr, ${getLib pkgs.utillinuxMinimal.out}/lib/libmount.so.* mr, ${getLib pkgs.utillinuxMinimal.out}/lib/libuuid.so.* mr, ${getLib pkgs.gcc.cc.lib}/lib/libstdc++.so.* mr, ${getLib pkgs.gcc.cc.lib}/lib/libgcc_s.so.* mr, ${pkgs.tzdata}/share/zoneinfo/** r, @{PROC}/sys/kernel/random/uuid r, @{PROC}/sys/vm/overcommit_memory r, @{PROC}/@{pid}/environ r, @{PROC}/@{pid}/mounts r, /tmp/tr_session_id_* rwk, ${pkgs.openssl.out}/etc/** r, ${pkgs.transmission}/share/transmission/** r, owner ${stateDir}/${settingsDir}/** rw, ${stateDir}/Downloads/** rw, ${optionalString cfg.settings.incomplete-dir-enabled '' ${stateDir}/.incomplete/** rw, ''} } '') ]; }; meta.maintainers = with lib.maintainers; [ julm ]; }