From 2a49db6a89efb8825379aa2211b183f734164b31 Mon Sep 17 00:00:00 2001 From: Julien Moutinho Date: Thu, 30 Jul 2020 00:09:44 +0200 Subject: transmission: apply RFC0042 and harden the service --- nixos/modules/services/torrent/transmission.nix | 437 ++++++++++++++++++------ 1 file changed, 336 insertions(+), 101 deletions(-) (limited to 'nixos/modules/services/torrent/transmission.nix') diff --git a/nixos/modules/services/torrent/transmission.nix b/nixos/modules/services/torrent/transmission.nix index 1bfcf2de82f..92df46083ec 100644 --- a/nixos/modules/services/torrent/transmission.nix +++ b/nixos/modules/services/torrent/transmission.nix @@ -1,52 +1,51 @@ -{ config, lib, pkgs, ... }: +{ config, lib, pkgs, options, ... }: with lib; let cfg = config.services.transmission; + inherit (config.environment) etc; apparmor = config.security.apparmor.enable; - - homeDir = cfg.home; - downloadDirPermissions = cfg.downloadDirPermissions; - downloadDir = "${homeDir}/Downloads"; - incompleteDir = "${homeDir}/.incomplete"; - - settingsDir = "${homeDir}/config"; - settingsFile = pkgs.writeText "settings.json" (builtins.toJSON fullSettings); - - # for users in group "transmission" to have access to torrents - fullSettings = { umask = 2; download-dir = downloadDir; incomplete-dir = incompleteDir; } // cfg.settings; - - preStart = pkgs.writeScript "transmission-pre-start" '' - #!${pkgs.runtimeShell} - set -ex - cp -f ${settingsFile} ${settingsDir}/settings.json - ''; + rootDir = "/run/transmission"; + homeDir = "/var/lib/transmission"; + settingsDir = ".config/transmission-daemon"; + downloadsDir = "Downloads"; + incompleteDir = ".incomplete"; + # TODO: switch to configGen.json once RFC0042 is implemented + settingsFile = pkgs.writeText "settings.json" (builtins.toJSON cfg.settings); in { options = { services.transmission = { - enable = mkOption { - type = types.bool; - default = false; - description = '' - Whether or not to enable the headless Transmission BitTorrent daemon. + enable = mkEnableOption ''the headless Transmission BitTorrent daemon. - Transmission daemon can be controlled via the RPC interface using - transmission-remote or the WebUI (http://localhost:9091/ by default). + 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 ${downloadDir} by default and are - accessible to users in the "transmission" group. - ''; - }; + Torrents are downloaded to ${homeDir}/${downloadsDir} by default and are + accessible to users in the "transmission" group''; - settings = mkOption { + settings = mkOption rec { + # TODO: switch to types.config.json as prescribed by RFC0042 once it's implemented type = types.attrs; + apply = recursiveUpdate default; default = { - download-dir = downloadDir; - incomplete-dir = incompleteDir; + download-dir = "${cfg.home}/${downloadsDir}"; + incomplete-dir = "${cfg.home}/${incompleteDir}"; incomplete-dir-enabled = true; + message-level = 1; + 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; + script-torrent-done-enabled = false; + script-torrent-done-filename = ""; + umask = 2; # 0o002 in decimal as expected by Transmission + utp-enabled = true; }; example = { @@ -56,11 +55,12 @@ in rpc-whitelist = "127.0.0.1,192.168.*.*"; }; description = '' - Attribute set whos fields overwrites fields in settings.json (each - time the service starts). String values must be quoted, integer and + 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 + See Transmission's Wiki for documentation. ''; }; @@ -70,22 +70,32 @@ in default = "770"; example = "775"; description = '' - The permissions to set for download-dir and incomplete-dir. - They will be applied on every service start. + The permissions set by systemd.activationScripts.transmission-daemon + on the directories settings.download-dir + and settings.incomplete-dir. + Note that you may also want to change + settings.umask. ''; }; port = mkOption { - type = types.int; - default = 9091; - description = "TCP port number to run the RPC/web interface."; + 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 = "/var/lib/transmission"; + default = homeDir; description = '' - The directory where transmission will create files. + The directory where Transmission will create ${settingsDir}. + as well as ${downloadsDir}/ unless settings.download-dir is changed, + and ${incompleteDir}/ unless settings.incomplete-dir is changed. ''; }; @@ -100,32 +110,174 @@ in 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 = mkEnableOption "opening of the peer port(s) 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 + settings.peer-limit-global. + 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 { - systemd.tmpfiles.rules = [ - "d '${homeDir}' 0770 '${cfg.user}' '${cfg.group}' - -" - "d '${settingsDir}' 0700 '${cfg.user}' '${cfg.group}' - -" - "d '${fullSettings.download-dir}' '${downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -" - "d '${fullSettings.incomplete-dir}' '${downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -" + # 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.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}' + ''; + + assertions = [ + { assertion = builtins.match "^/.*" cfg.home != null; + message = "`services.transmission.home' must be an absolute path."; + } + { assertion = types.path.check cfg.settings.download-dir; + message = "`services.transmission.settings.download-dir' must be an absolute path."; + } + { assertion = types.path.check cfg.settings.incomplete-dir; + message = "`services.transmission.settings.incomplete-dir' must be an absolute path."; + } + { assertion = cfg.settings.script-torrent-done-filename == "" || types.path.check cfg.settings.script-torrent-done-filename; + message = "`services.transmission.settings.script-torrent-done-filename' 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 = mkIf apparmor [ "apparmor.service" ]; + requires = optional apparmor "apparmor.service"; wantedBy = [ "multi-user.target" ]; + environment.CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source; - # 1) Only the "transmission" user and group have access to torrents. - # 2) Optionally update/force specific fields into the configuration file. - serviceConfig.ExecStartPre = preStart; - serviceConfig.ExecStart = "${pkgs.transmission}/bin/transmission-daemon -f --port ${toString config.services.transmission.port} --config-dir ${settingsDir}"; - serviceConfig.ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID"; - serviceConfig.User = cfg.user; - serviceConfig.Group = cfg.group; - # NOTE: transmission has an internal umask that also must be set (in settings.json) - serviceConfig.UMask = "0002"; + 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"; + 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"; + # Avoid mounting rootDir in the own rootDir of ExecStart='s mount namespace. + InaccessiblePaths = ["-+${rootDir}"]; + # 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; + 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/hosts" + "-/etc/ld-nix.so.preload" + "-/etc/localtime" + ] ++ + optional (cfg.settings.script-torrent-done-enabled && + cfg.settings.script-torrent-done-filename != "") + cfg.settings.script-torrent-done-filename; + # 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"; + SystemCallErrorNumber = "EPERM"; + }; }; # It's useful to have transmission in path, e.g. for remote control @@ -133,70 +285,153 @@ in users.users = optionalAttrs (cfg.user == "transmission") ({ transmission = { - name = "transmission"; group = cfg.group; uid = config.ids.uids.transmission; description = "Transmission BitTorrent user"; - home = homeDir; - createHome = true; + home = cfg.home; }; }); users.groups = optionalAttrs (cfg.group == "transmission") ({ transmission = { - name = "transmission"; gid = config.ids.gids.transmission; }; }); - # AppArmor profile + 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 ]; + } + ); + + 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" = "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" = 60; + # Timeout faster established but inactive connections. + # Usual default is 432000. + "net.netfilter.nf_conntrack_tcp_timeout_established" = 600; + # Clear immediately TCP states after timeout. + # Usual default is 120. + "net.netfilter.nf_conntrack_tcp_timeout_time_wait" = 1; + # Increase the number of trackable connections. + # Usual default is 262144. + # Find out your current usage with: conntrack -C + "net.netfilter.nf_conntrack_max" = 1048576; + }) + ]; + security.apparmor.profiles = mkIf apparmor [ (pkgs.writeText "apparmor-transmission-daemon" '' - #include + 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 ${settingsDir}/** rw, - - ${fullSettings.download-dir}/** rw, - ${optionalString fullSettings.incomplete-dir-enabled '' - ${fullSettings.incomplete-dir}/** rw, + include + include + + # NOTE: https://github.com/NixOS/nixpkgs/pull/93457 + # will remove the need for these by fixing + r ${etc."hosts".source}, + r /etc/ld-nix.so.preload, + ${lib.optionalString (builtins.hasAttr "ld-nix.so.preload" etc) '' + r ${etc."ld-nix.so.preload".source}, + ${concatMapStrings (p: optionalString (p != "") ("mr ${p},\n")) + (splitString "\n" config.environment.etc."ld-nix.so.preload".text)} ''} + r ${etc."ssl/certs/ca-certificates.crt".source}, + r ${pkgs.tzdata}/share/zoneinfo/**, + r ${pkgs.stdenv.cc.libc}/share/i18n/**, + r ${pkgs.stdenv.cc.libc}/share/locale/**, + + mr ${getLib pkgs.stdenv.cc.cc}/lib/*.so*, + mr ${getLib pkgs.stdenv.cc.libc}/lib/*.so*, + mr ${getLib pkgs.attr}/lib/libattr*.so*, + mr ${getLib pkgs.c-ares}/lib/libcares*.so*, + mr ${getLib pkgs.curl}/lib/libcurl*.so*, + mr ${getLib pkgs.keyutils}/lib/libkeyutils*.so*, + mr ${getLib pkgs.libcap}/lib/libcap*.so*, + mr ${getLib pkgs.libevent}/lib/libevent*.so*, + mr ${getLib pkgs.libgcrypt}/lib/libgcrypt*.so*, + mr ${getLib pkgs.libgpgerror}/lib/libgpg-error*.so*, + mr ${getLib pkgs.libkrb5}/lib/lib*.so*, + mr ${getLib pkgs.libssh2}/lib/libssh2*.so*, + mr ${getLib pkgs.lz4}/lib/liblz4*.so*, + mr ${getLib pkgs.nghttp2}/lib/libnghttp2*.so*, + mr ${getLib pkgs.openssl}/lib/libcrypto*.so*, + mr ${getLib pkgs.openssl}/lib/libssl*.so*, + mr ${getLib pkgs.systemd}/lib/libsystemd*.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.xz}/lib/liblzma*.so*, + mr ${getLib pkgs.zlib}/lib/libz*.so*, + + r @{PROC}/sys/kernel/random/uuid, + r @{PROC}/sys/vm/overcommit_memory, + # @{pid} is not a kernel variable yet but a regexp + #r @{PROC}/@{pid}/environ, + r @{PROC}/@{pid}/mounts, + rwk /tmp/tr_session_id_*, + + r ${pkgs.openssl.out}/etc/**, + r ${config.systemd.services.transmission.environment.CURL_CA_BUNDLE}, + r ${pkgs.transmission}/share/transmission/**, + + owner rw ${cfg.home}/${settingsDir}/**, + rw ${cfg.settings.download-dir}/**, + ${optionalString cfg.settings.incomplete-dir-enabled '' + rw ${cfg.settings.incomplete-dir}/**, + ''} + profile dirs { + rw ${cfg.settings.download-dir}/**, + ${optionalString cfg.settings.incomplete-dir-enabled '' + rw ${cfg.settings.incomplete-dir}/**, + ''} + } + + ${optionalString (cfg.settings.script-torrent-done-enabled && + cfg.settings.script-torrent-done-filename != "") '' + # 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}, + ''} + + # FIXME: enable customizing using https://github.com/NixOS/nixpkgs/pull/93457 + # include } '') ]; }; + meta.maintainers = with lib.maintainers; [ julm ]; } -- cgit 1.4.1