{ config, lib, pkgs, options, ... }: with lib; let cfg = config.services.transmission; 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; rpc-bind-address = "127.0.0.1"; rpc-port = 9091; umask = 63; # 0o077 in decimal as expected by Transmission, obtained with: echo $((8#077)) }; 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 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."; }; 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 rpc-password. ''; default = "/dev/null"; example = "/var/lib/secrets/transmission/settings.json"; }; enableSandbox = mkOption { default = false; type = types.bool; description = '' Starting Transmission server with additional sandbox/hardening options. ''; }; }; }; 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" ]; 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; UMask = "0002"; 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"; NoNewPrivileges = true; AmbientCapabilities = [ "CAP_NET_BIND_SERVICE" "CAP_SYS_RESOURCE" ]; CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" "CAP_SYS_RESOURCE" ]; } // optionalAttrs cfg.enableSandbox { DevicePolicy = "closed"; LockPersonality = true; MemoryDenyWriteExecute = true; PrivateDevices = true; PrivateMounts = true; PrivateTmp = true; ProtectControlGroups = true; ProtectHome = mkDefault true; ProtectHostname = true; ProtectKernelModules = true; ProtectKernelTunables = true; ProtectSystem = mkDefault "strict"; ReadWritePaths = [ stateDir ]; RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ]; RestrictRealtime = true; RestrictSUIDSGID = true; 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 = stateDir; createHome = false; }; }); users.groups = optionalAttrs (cfg.group == "transmission") ({ transmission = { gid = config.ids.gids.transmission; }; }); # AppArmor profile security.apparmor.profiles = mkIf apparmor [ (pkgs.writeText "apparmor-transmission-daemon" '' #include ${pkgs.transmission}/bin/transmission-daemon { #include #include ${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, @{PROC}/sys/kernel/random/uuid r, @{PROC}/sys/vm/overcommit_memory r, ${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, ''} } '') ]; }; }