-{ config, lib, pkgs, ... }:
+{ config, lib, pkgs, options, ... }:
with lib;
let
cfg = config.services.transmission;
apparmor = config.security.apparmor.enable;
- # TODO: switch to configGen.json once RFC42 is implemented
- settingsFile = pkgs.writeText "settings.json" (builtins.toJSON cfg.settings);
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
{
- imports = [
- (mkRemovedOptionModule [ "services" "transmission" "port" ]
- "Instead, use the option `services.transmission.settings.rpc-port'.")
- (mkRemovedOptionModule [ "services" "transmission" "home" ]
- "Instead, use systemd's StateDirectory: `${stateDir}'.")
- (mkRemovedOptionModule [ "services" "transmission" "downloadDirPermissions" ] ''
- Instead, use the option `services.transmission.settings.umask'
- (which currently is: ${toString cfg.settings.umask}) (in decimal, as expected by Transmission)
- and `systemd.services.transmission.serviceConfig.StateDirectoryMode'
- (which currently is: ${config.systemd.services.transmission.serviceConfig.StateDirectoryMode}).
- '')
- ];
options = {
services.transmission = {
enable = mkEnableOption ''
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 ${stateDir}/${cfg.settings.download-dir} by default and are
+ 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 RFC42 once it's implemented
+ # TODO: switch to types.config.json as prescribed by RFC0042 once it's implemented
type = types.attrs;
- apply = recursiveUpdate default;
+ 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 = "Downloads";
- incomplete-dir = ".incomplete";
+ 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; # echo $((8#077))
+ umask = 63; # 0o077 in decimal as expected by Transmission, obtained with: echo $((8#077))
};
example =
{
- download-dir = "Torrents";
- incomplete-dir = ".Torrents";
+ download-dir = "/srv/torrents/";
+ incomplete-dir = "/srv/torrents/.incomplete/";
incomplete-dir-enabled = true;
rpc-whitelist = "127.0.0.1,192.168.*.*";
};
'';
};
+ downloadDirPermissions = mkOption {
+ type = types.str;
+ default = "770";
+ example = "775";
+ description = ''
+ The permissions set by the <literal>systemd-tmpfiles-setup</literal> service
+ on <literal>settings.download-dir</literal> and <literal>settings.incomplete-dir</literal>.
+ '';
+ };
+
+ 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 <literal>.config/transmission-daemon/</literal>.
+ as well as <literal>Downloads/</literal> unless <literal>settings.download-dir</literal> is changed,
+ and <literal>.incomplete/</literal> unless <literal>settings.incomplete-dir</literal> is changed.
+ '';
+ };
+
user = mkOption {
type = types.str;
default = "transmission";
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 <literal>rpc-password</literal>.
+ '';
+ 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.settings.download-dir == null;
- message = "`services.transmission.settings.download-dir' " +
- "can no longer be an absolute path, it must be relative to `${stateDir}'"; }
- { assertion = builtins.match "^/.*" cfg.settings.incomplete-dir == null;
- message = "`services.transmission.settings.incomplete-dir' " +
- "can no longer be an absolute path, it must be relative to `${stateDir}'"; }
+ { 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 = mkIf apparmor [ "apparmor.service" ];
+ requires = optional apparmor "apparmor.service";
wantedBy = [ "multi-user.target" ];
preStart = ''
- chmod 755 '${stateDir}'
- chmod 0700 '${stateDir}/${settingsDir}'
- cp -f ${settingsFile} ${settingsDir}/settings.json
+ set -eux
+ ${pkgs.jq}/bin/jq --slurp add ${settingsFile} '${cfg.credentialsFile}' >'${stateDir}/${settingsDir}/settings.json'
'';
serviceConfig = {
WorkingDirectory = stateDir;
- StateDirectory = concatMapStringsSep " " (p: "transmission/${p}")
- ([ settingsDir cfg.settings.download-dir ]
- ++ optional cfg.settings.incomplete-dir-enabled cfg.settings.incomplete-dir);
- StateDirectoryMode = mkDefault "770";
- ExecStart = "${pkgs.transmission}/bin/transmission-daemon -f --config-dir ${settingsDir}";
+ ExecStart = "${pkgs.transmission}/bin/transmission-daemon -f";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
User = cfg.user;
Group = cfg.group;
- UMask = "0007";
-
- # Hardening options
- #DevicePolicy = "closed";
- #LockPersonality = true;
- #MemoryDenyWriteExecute = true;
- #NoNewPrivileges = true;
- #PrivateDevices = true;
- #PrivateTmp = true;
- #ProtectControlGroups = true;
- #ProtectHome = true;
- #ProtectKernelModules = true;
- #ProtectKernelTunables = true;
- #ProtectSystem = "strict";
- #RestrictAddressFamilies = "AF_UNIX AF_INET AF_INET6 AF_NETLINK";
- #RestrictNamespaces = true;
- #RestrictRealtime = true;
- #RestrictSUIDSGID = true;
+ 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";
};
};
uid = config.ids.uids.transmission;
description = "Transmission BitTorrent user";
home = stateDir;
- createHome = true;
+ createHome = false;
};
});
#include <abstractions/base>
#include <abstractions/nameservice>
- ${getLib pkgs.gcc-unwrapped}/lib/*.so* mr,
${getLib pkgs.glibc}/lib/*.so* mr,
${getLib pkgs.libevent}/lib/libevent*.so* mr,
${getLib pkgs.curl}/lib/libcurl*.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,
owner ${stateDir}/${settingsDir}/** rw,
- ${stateDir}/${cfg.settings.download-dir}/** rw,
+ ${stateDir}/Downloads/** rw,
${optionalString cfg.settings.incomplete-dir-enabled ''
- ${stateDir}/${cfg.settings.incomplete-dir}/** rw,
+ ${stateDir}/.incomplete/** rw,
''}
}
'')