{ 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 ];
}