1 { config, lib, pkgs, options, ... }:
6 cfg = config.services.transmission;
7 inherit (config.environment) etc;
8 apparmor = config.security.apparmor.enable;
9 stateDir = "/var/lib/transmission";
10 # TODO: switch to configGen.json once RFC0042 is implemented
11 settingsFile = pkgs.writeText "settings.json" (builtins.toJSON (cfg.settings // {
12 download-dir = "${stateDir}/Downloads";
13 incomplete-dir = "${stateDir}/.incomplete";
15 settingsDir = ".config/transmission-daemon";
16 makeAbsolute = base: path:
17 if builtins.match "^/.*" path == null
18 then base+"/"+path else path;
22 services.transmission = {
23 enable = mkEnableOption ''
24 Whether or not to enable the headless Transmission BitTorrent daemon.
26 Transmission daemon can be controlled via the RPC interface using
27 transmission-remote, the WebUI (http://${cfg.settings.rpc-bind-address}:${toString cfg.settings.rpc-port}/ by default),
28 or other clients like stig or tremc.
30 Torrents are downloaded to ${cfg.settings.download-dir} by default and are
31 accessible to users in the "transmission" group.
34 settings = mkOption rec {
35 # TODO: switch to types.config.json as prescribed by RFC0042 once it's implemented
38 let super = recursiveUpdate default attrs; in
40 download-dir = makeAbsolute cfg.home super.download-dir;
41 incomplete-dir = makeAbsolute cfg.home super.incomplete-dir;
45 download-dir = "${cfg.home}/Downloads";
46 incomplete-dir = "${cfg.home}/.incomplete";
47 incomplete-dir-enabled = true;
49 peer-port-random-high = 65535;
50 peer-port-random-low = 49152;
51 peer-port-random-on-start = false;
52 rpc-bind-address = "127.0.0.1";
54 umask = 18; # 0o022 in decimal as expected by Transmission, obtained with: echo $((8#022))
58 download-dir = "/srv/torrents/";
59 incomplete-dir = "/srv/torrents/.incomplete/";
60 incomplete-dir-enabled = true;
61 rpc-whitelist = "127.0.0.1,192.168.*.*";
64 Attribute set whose fields overwrites fields in
65 <literal>.config/transmission-daemon/settings.json</literal>
66 (each time the service starts). String values must be quoted, integer and
67 boolean values must not.
69 See https://github.com/transmission/transmission/wiki/Editing-Configuration-Files
74 downloadDirPermissions = mkOption {
79 The permissions set by the <literal>systemd-tmpfiles-setup</literal> service
80 on <link linkend="opt-services.transmission.settings">settings.download-dir</link>
81 and <link linkend="opt-services.transmission.settings">settings.incomplete-dir</link>.
88 TCP port number to run the RPC/web interface.
90 If instead you want to change the peer port,
91 use <link linkend="opt-services.transmission.settings">settings.peer-port</link>
92 or <link linkend="opt-services.transmission.settings">settings.peer-port-random-on-start</link>.
100 The directory where Transmission will create <literal>.config/transmission-daemon/</literal>.
101 as well as <literal>Downloads/</literal> unless <link linkend="opt-services.transmission.settings">settings.download-dir</link> is changed,
102 and <literal>.incomplete/</literal> unless <link linkend="opt-services.transmission.settings">settings.incomplete-dir</link> is changed.
108 default = "transmission";
109 description = "User account under which Transmission runs.";
114 default = "transmission";
115 description = "Group account under which Transmission runs.";
118 credentialsFile = mkOption {
121 Path to a JSON file to be merged with the settings.
122 Useful to merge a file which is better kept out of the Nix store
123 because it contains sensible data like <link linkend="opt-services.transmission.settings">settings.rpc-password</link>.
125 default = "/dev/null";
126 example = "/var/lib/secrets/transmission/settings.json";
129 openFirewall = mkOption {
133 Whether to automatically open the peer port(s) in the firewall.
139 config = mkIf cfg.enable {
140 systemd.tmpfiles.rules =
141 optional (cfg.home != stateDir) "d '${cfg.home}/${settingsDir}' 700 '${cfg.user}' '${cfg.group}' - -"
142 ++ [ "d '${cfg.settings.download-dir}' '${cfg.downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -" ]
143 ++ optional cfg.settings.incomplete-dir-enabled
144 "d '${cfg.settings.incomplete-dir}' '${cfg.downloadDirPermissions}' '${cfg.user}' '${cfg.group}' - -";
147 { assertion = builtins.match "^/.*" cfg.home != null;
148 message = "`services.transmission.home' must be an absolute path.";
150 { assertion = types.port.check cfg.settings.rpc-port;
151 message = "${toString cfg.settings.rpc-port} is not a valid port number for `services.transmission.settings.rpc-port`.";
153 # In case both port and settings.rpc-port are explicitely defined: they must be the same.
154 { assertion = !options.services.transmission.port.isDefined || cfg.port == cfg.settings.rpc-port;
155 message = "`services.transmission.port' is not equal to `services.transmission.settings.rpc-port'";
159 services.transmission.settings =
160 optionalAttrs options.services.transmission.port.isDefined { rpc-port = cfg.port; };
162 systemd.services.transmission = {
163 description = "Transmission BitTorrent Service";
164 after = [ "network.target" ] ++ optional apparmor "apparmor.service";
165 requires = optional apparmor "apparmor.service";
166 wantedBy = [ "multi-user.target" ];
167 environment.CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source;
170 ${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' >'${stateDir}/${settingsDir}/settings.json'
174 WorkingDirectory = stateDir;
175 ExecStart = "${pkgs.transmission}/bin/transmission-daemon -f";
176 ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
179 StateDirectory = removePrefix "/var/lib/" stateDir + "/" + settingsDir;
180 StateDirectoryMode = "0700";
182 optional (cfg.home != stateDir) "${cfg.home}/${settingsDir}:${stateDir}/${settingsDir}"
183 ++ [ "${cfg.settings.download-dir}:${stateDir}/Downloads" ]
184 ++ optional cfg.settings.incomplete-dir-enabled "${cfg.settings.incomplete-dir}:${stateDir}/.incomplete";
185 # The following options give:
186 # systemd-analyze security transmission
187 # → Overall exposure level for transmission.service: 1.5 OK
188 AmbientCapabilities = "";
189 CapabilityBoundingSet = "";
190 LockPersonality = true;
191 MemoryDenyWriteExecute = true;
192 NoNewPrivileges = true;
193 PrivateDevices = true;
194 PrivateMounts = true;
195 PrivateNetwork = false;
197 PrivateUsers = false;
199 ProtectControlGroups = true;
200 ProtectHome = mkDefault true;
201 ProtectHostname = true;
202 ProtectKernelLogs = true;
203 ProtectKernelModules = true;
204 ProtectKernelTunables = true;
205 ProtectSystem = mkDefault "strict";
206 ReadWritePaths = [ stateDir ];
208 RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ];
209 RestrictNamespaces = true;
210 RestrictRealtime = true;
211 RestrictSUIDSGID = true;
212 # In case transmission crashes with status=31/SYS,
213 # having systemd.coredump.enable = true
214 # and environment.enableDebugInfo = true
215 # enables to use coredumpctl debug to find the denied syscall.
235 # Reached when querying infos through RPC (eg. with stig)
248 SystemCallArchitectures = "native";
253 # It's useful to have transmission in path, e.g. for remote control
254 environment.systemPackages = [ pkgs.transmission ];
256 users.users = optionalAttrs (cfg.user == "transmission") ({
259 uid = config.ids.uids.transmission;
260 description = "Transmission BitTorrent user";
266 users.groups = optionalAttrs (cfg.group == "transmission") ({
268 gid = config.ids.gids.transmission;
272 networking.firewall = mkIf cfg.openFirewall (
273 if cfg.settings.peer-port-random-on-start
275 { allowedTCPPortRanges =
276 [ { from = cfg.settings.peer-port-random-low;
277 to = cfg.settings.peer-port-random-high;
280 allowedUDPPortRanges =
281 [ { from = cfg.settings.peer-port-random-low;
282 to = cfg.settings.peer-port-random-high;
287 { allowedTCPPorts = [ cfg.settings.peer-port ];
288 allowedUDPPorts = [ cfg.settings.peer-port ];
292 # You can add --Complain to apparmor_parser calls in services.apparmor's ExecStart=
293 # (because aa-complain is not working with the setup currently made by services.apparmor)
294 # then use journalctl -b --grep apparmor= to see denied accesses.
295 security.apparmor.profiles = mkIf apparmor [
296 (pkgs.writeText "apparmor-transmission-daemon" ''
297 #include <tunables/global>
299 ${pkgs.transmission}/bin/transmission-daemon {
300 #include <abstractions/base>
301 #include <abstractions/nameservice>
303 # FIXME: these lines should be removed once <abstractions/base>
304 # has been fixed to fit NixOS.
305 ${etc."hosts".source} r,
306 /etc/ld-nix.so.preload r,
307 ${etc."ld-nix.so.preload".source} r,
308 ${concatMapStrings (p: optionalString (p != "") (p+" mr,\n"))
309 (splitString "\n" config.environment.etc."ld-nix.so.preload".text)}
310 ${etc."ssl/certs/ca-certificates.crt".source} r,
312 ${getLib pkgs.glibc}/lib/*.so* mr,
313 ${getLib pkgs.libevent}/lib/libevent*.so* mr,
314 ${getLib pkgs.curl}/lib/libcurl*.so* mr,
315 ${getLib pkgs.openssl}/lib/libssl*.so* mr,
316 ${getLib pkgs.openssl}/lib/libcrypto*.so* mr,
317 ${getLib pkgs.zlib}/lib/libz*.so* mr,
318 ${getLib pkgs.libssh2}/lib/libssh2*.so* mr,
319 ${getLib pkgs.systemd}/lib/libsystemd*.so* mr,
320 ${getLib pkgs.xz}/lib/liblzma*.so* mr,
321 ${getLib pkgs.libgcrypt}/lib/libgcrypt*.so* mr,
322 ${getLib pkgs.libgpgerror}/lib/libgpg-error*.so* mr,
323 ${getLib pkgs.nghttp2}/lib/libnghttp2*.so* mr,
324 ${getLib pkgs.c-ares}/lib/libcares*.so* mr,
325 ${getLib pkgs.libcap}/lib/libcap*.so* mr,
326 ${getLib pkgs.attr}/lib/libattr*.so* mr,
327 ${getLib pkgs.lz4}/lib/liblz4*.so* mr,
328 ${getLib pkgs.libkrb5}/lib/lib*.so* mr,
329 ${getLib pkgs.keyutils}/lib/libkeyutils*.so* mr,
330 ${getLib pkgs.utillinuxMinimal.out}/lib/libblkid.so.* mr,
331 ${getLib pkgs.utillinuxMinimal.out}/lib/libmount.so.* mr,
332 ${getLib pkgs.utillinuxMinimal.out}/lib/libuuid.so.* mr,
333 ${getLib pkgs.gcc.cc.lib}/lib/libstdc++.so.* mr,
334 ${getLib pkgs.gcc.cc.lib}/lib/libgcc_s.so.* mr,
335 ${pkgs.tzdata}/share/zoneinfo/** r,
337 @{PROC}/sys/kernel/random/uuid r,
338 @{PROC}/sys/vm/overcommit_memory r,
339 @{PROC}/@{pid}/environ r,
340 @{PROC}/@{pid}/mounts r,
341 /tmp/tr_session_id_* rwk,
343 ${pkgs.openssl.out}/etc/** r,
344 ${pkgs.transmission}/share/transmission/** r,
346 owner ${stateDir}/${settingsDir}/** rw,
348 ${stateDir}/Downloads/** rw,
349 ${optionalString cfg.settings.incomplete-dir-enabled ''
350 ${stateDir}/.incomplete/** rw,
357 meta.maintainers = with lib.maintainers; [ julm ];