nixos/transmission: improvements (#350085)

This commit is contained in:
misuzu
2024-12-04 21:34:32 +02:00
committed by GitHub
+284 -195
View File
@@ -1,8 +1,29 @@
{ config, lib, pkgs, options, ... }:
with lib;
{
config,
lib,
pkgs,
options,
...
}:
let
inherit (lib)
mkRenamedOptionModule
mkAliasOptionModuleMD
mkEnableOption
mkOption
types
literalExpression
mkPackageOption
mkIf
optionalString
optional
mkDefault
escapeShellArgs
optionalAttrs
mkMerge
;
cfg = config.services.transmission;
opt = options.services.transmission;
inherit (config.environment) etc;
@@ -12,15 +33,36 @@ let
downloadsDir = "Downloads";
incompleteDir = ".incomplete";
watchDir = "watchdir";
settingsFormat = pkgs.formats.json {};
settingsFormat = pkgs.formats.json { };
settingsFile = settingsFormat.generate "settings.json" cfg.settings;
in
{
imports = [
(mkRenamedOptionModule ["services" "transmission" "port"]
["services" "transmission" "settings" "rpc-port"])
(mkAliasOptionModuleMD ["services" "transmission" "openFirewall"]
["services" "transmission" "openPeerPorts"])
(mkRenamedOptionModule
[
"services"
"transmission"
"port"
]
[
"services"
"transmission"
"settings"
"rpc-port"
]
)
(mkAliasOptionModuleMD
[
"services"
"transmission"
"openFirewall"
]
[
"services"
"transmission"
"openPeerPorts"
]
)
];
options = {
services.transmission = {
@@ -46,130 +88,137 @@ in
See [Transmission's Wiki](https://github.com/transmission/transmission/wiki/Editing-Configuration-Files)
for documentation of settings not explicitly covered by this module.
'';
default = {};
default = { };
type = types.submodule {
freeformType = settingsFormat.type;
options.download-dir = mkOption {
type = types.path;
default = "${cfg.home}/${downloadsDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${downloadsDir}"'';
description = "Directory where to download torrents.";
};
options.incomplete-dir = mkOption {
type = types.path;
default = "${cfg.home}/${incompleteDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${incompleteDir}"'';
description = ''
When enabled with
services.transmission.home
[](#opt-services.transmission.settings.incomplete-dir-enabled),
new torrents will download the files to this directory.
When complete, the files will be moved to download-dir
[](#opt-services.transmission.settings.download-dir).
'';
};
options.incomplete-dir-enabled = mkOption {
type = types.bool;
default = true;
description = "";
};
options.message-level = mkOption {
type = types.ints.between 0 6;
default = 2;
description = "Set verbosity of transmission messages.";
};
options.peer-port = mkOption {
type = types.port;
default = 51413;
description = "The peer port to listen for incoming connections.";
};
options.peer-port-random-high = mkOption {
type = types.port;
default = 65535;
description = ''
The maximum peer port to listen to for incoming connections
when [](#opt-services.transmission.settings.peer-port-random-on-start) is enabled.
'';
};
options.peer-port-random-low = mkOption {
type = types.port;
default = 65535;
description = ''
The minimal peer port to listen to for incoming connections
when [](#opt-services.transmission.settings.peer-port-random-on-start) is enabled.
'';
};
options.peer-port-random-on-start = mkOption {
type = types.bool;
default = false;
description = "Randomize the peer port.";
};
options.rpc-bind-address = mkOption {
type = types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
Where to listen for RPC connections.
Use `0.0.0.0` to listen on all interfaces.
'';
};
options.rpc-port = mkOption {
type = types.port;
default = 9091;
description = "The RPC port to listen to.";
};
options.script-torrent-done-enabled = mkOption {
type = types.bool;
default = false;
description = ''
Whether to run
[](#opt-services.transmission.settings.script-torrent-done-filename)
at torrent completion.
'';
};
options.script-torrent-done-filename = mkOption {
type = types.nullOr types.path;
default = null;
description = "Executable to be run at torrent completion.";
};
options.umask = mkOption {
type = types.int;
default = 2;
description = ''
Sets transmission's file mode creation mask.
See the umask(2) manpage for more information.
Users who want their saved torrents to be world-writable
may want to set this value to 0.
Bear in mind that the json markup language only accepts numbers in base 10,
so the standard umask(2) octal notation "022" is written in settings.json as 18.
'';
};
options.utp-enabled = mkOption {
type = types.bool;
default = true;
description = ''
Whether to enable [Micro Transport Protocol (µTP)](https://en.wikipedia.org/wiki/Micro_Transport_Protocol).
'';
};
options.watch-dir = mkOption {
type = types.path;
default = "${cfg.home}/${watchDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${watchDir}"'';
description = "Watch a directory for torrent files and add them to transmission.";
};
options.watch-dir-enabled = mkOption {
type = types.bool;
default = false;
description = ''Whether to enable the
[](#opt-services.transmission.settings.watch-dir).
'';
};
options.trash-original-torrent-files = mkOption {
type = types.bool;
default = false;
description = ''Whether to delete torrents added from the
[](#opt-services.transmission.settings.watch-dir).
'';
options = {
download-dir = mkOption {
type = types.path;
default = "${cfg.home}/${downloadsDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${downloadsDir}"'';
description = "Directory where to download torrents.";
};
incomplete-dir = mkOption {
type = types.path;
default = "${cfg.home}/${incompleteDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${incompleteDir}"'';
description = ''
When enabled with
services.transmission.home
[](#opt-services.transmission.settings.incomplete-dir-enabled),
new torrents will download the files to this directory.
When complete, the files will be moved to download-dir
[](#opt-services.transmission.settings.download-dir).
'';
};
incomplete-dir-enabled = mkOption {
type = types.bool;
default = true;
description = "";
};
message-level = mkOption {
type = types.ints.between 0 6;
default = 2;
description = "Set verbosity of transmission messages.";
};
peer-port = mkOption {
type = types.port;
default = 51413;
description = "The peer port to listen for incoming connections.";
};
peer-port-random-high = mkOption {
type = types.port;
default = 65535;
description = ''
The maximum peer port to listen to for incoming connections
when [](#opt-services.transmission.settings.peer-port-random-on-start) is enabled.
'';
};
peer-port-random-low = mkOption {
type = types.port;
default = 65535;
description = ''
The minimal peer port to listen to for incoming connections
when [](#opt-services.transmission.settings.peer-port-random-on-start) is enabled.
'';
};
peer-port-random-on-start = mkOption {
type = types.bool;
default = false;
description = "Randomize the peer port.";
};
rpc-bind-address = mkOption {
type = types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
Where to listen for RPC connections.
Use `0.0.0.0` to listen on all interfaces.
'';
};
rpc-port = mkOption {
type = types.port;
default = 9091;
description = "The RPC port to listen to.";
};
script-torrent-done-enabled = mkOption {
type = types.bool;
default = false;
description = ''
Whether to run
[](#opt-services.transmission.settings.script-torrent-done-filename)
at torrent completion.
'';
};
script-torrent-done-filename = mkOption {
type = types.nullOr types.path;
default = null;
description = "Executable to be run at torrent completion.";
};
umask = mkOption {
type = types.either types.int types.str;
default = if cfg.package == pkgs.transmission_3 then 18 else "022";
defaultText = literalExpression "if cfg.package == pkgs.transmission_3 then 18 else \"022\"";
description = ''
Sets transmission's file mode creation mask.
See the umask(2) manpage for more information.
Users who want their saved torrents to be world-writable
may want to set this value to 0/`"000"`.
Keep in mind, that if you are using Transmission 3, this has to
be passed as a base 10 integer, whereas Transmission 4 takes
an octal number in a string instead.
'';
};
utp-enabled = mkOption {
type = types.bool;
default = true;
description = ''
Whether to enable [Micro Transport Protocol (µTP)](https://en.wikipedia.org/wiki/Micro_Transport_Protocol).
'';
};
watch-dir = mkOption {
type = types.path;
default = "${cfg.home}/${watchDir}";
defaultText = literalExpression ''"''${config.${opt.home}}/${watchDir}"'';
description = "Watch a directory for torrent files and add them to transmission.";
};
watch-dir-enabled = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable the
[](#opt-services.transmission.settings.watch-dir).
'';
};
trash-original-torrent-files = mkOption {
type = types.bool;
default = false;
description = ''
Whether to delete torrents added from the
[](#opt-services.transmission.settings.watch-dir).
'';
};
};
};
};
@@ -191,6 +240,17 @@ in
and [](#opt-services.transmission.settings.watch-dir).
Note that you may also want to change
[](#opt-services.transmission.settings.umask).
Keep in mind, that if the default user is used, the `home` directory
is locked behind a `750` permission, which affects all subdirectories
as well. There are 3 ways to get around this:
1. (Recommended) add the users that should have access to the group
set by [](#opt-services.transmission.group)
2. Change [](#opt-services.transmission.settings.download-dir) to be
under a directory that has the right permissions
3. Change `systemd.services.transmission.serviceConfig.StateDirectoryMode`
to the same value as this option
'';
};
@@ -231,7 +291,7 @@ in
extraFlags = mkOption {
type = types.listOf types.str;
default = [];
default = [ ];
example = [ "--log-debug" ];
description = ''
Extra flags passed to the transmission command in the service definition.
@@ -279,40 +339,51 @@ in
# 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 = mkIf (cfg.downloadDirPermissions != null)
{ transmission-daemon = ''
install -d -m 700 '${cfg.home}/${settingsDir}'
chown -R '${cfg.user}:${cfg.group}' ${cfg.home}/${settingsDir}
system.activationScripts.transmission-daemon =
''
install -d -m 700 -o '${cfg.user}' -g '${cfg.group}' '${cfg.home}/${settingsDir}'
''
+ optionalString (cfg.downloadDirPermissions != null) ''
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}'
'' + optionalString cfg.settings.watch-dir-enabled ''
install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.watch-dir}'
'';
};
${optionalString cfg.settings.incomplete-dir-enabled ''
install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.incomplete-dir}'
''}
${optionalString cfg.settings.watch-dir-enabled ''
install -d -m '${cfg.downloadDirPermissions}' -o '${cfg.user}' -g '${cfg.group}' '${cfg.settings.watch-dir}'
''}
'';
systemd.services.transmission = {
description = "Transmission BitTorrent Service";
after = [ "network.target" ] ++ optional apparmor.enable "apparmor.service";
requires = optional apparmor.enable "apparmor.service";
wantedBy = [ "multi-user.target" ];
environment.CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source;
environment.TRANSMISSION_WEB_HOME = lib.mkIf (cfg.webHome != null) cfg.webHome;
environment = {
CURL_CA_BUNDLE = etc."ssl/certs/ca-certificates.crt".source;
TRANSMISSION_WEB_HOME = lib.mkIf (cfg.webHome != null) cfg.webHome;
};
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="${cfg.package}/bin/transmission-daemon -f -g ${cfg.home}/${settingsDir} ${escapeShellArgs cfg.extraFlags}";
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 = "${cfg.package}/bin/transmission-daemon -f -g ${cfg.home}/${settingsDir} ${escapeShellArgs cfg.extraFlags}";
ExecReload = "${pkgs.coreutils}/bin/kill -HUP $MAINPID";
User = cfg.user;
Group = cfg.group;
# Create rootDir in the host's mount namespace.
RuntimeDirectory = [(baseNameOf rootDir)];
RuntimeDirectory = [ (baseNameOf rootDir) ];
RuntimeDirectoryMode = "755";
# This is for BindPaths= and BindReadOnlyPaths=
# to allow traversal of directories they create in RootDirectory=.
@@ -330,27 +401,30 @@ in
RootDirectoryStartOnly = true;
MountAPIVFS = true;
BindPaths =
[ "${cfg.home}/${settingsDir}"
[
"${cfg.home}/${settingsDir}"
cfg.settings.download-dir
# Transmission may need to read in the host's /run (eg. /run/systemd/resolve)
# or write in its private /run (eg. /run/host).
"/run"
] ++
optional cfg.settings.incomplete-dir-enabled
cfg.settings.incomplete-dir ++
optional (cfg.settings.watch-dir-enabled && cfg.settings.trash-original-torrent-files)
cfg.settings.watch-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"
] ++
optional (cfg.settings.script-torrent-done-enabled &&
cfg.settings.script-torrent-done-filename != null)
cfg.settings.script-torrent-done-filename ++
optional (cfg.settings.watch-dir-enabled && !cfg.settings.trash-original-torrent-files)
cfg.settings.watch-dir;
]
++ optional cfg.settings.incomplete-dir-enabled cfg.settings.incomplete-dir
++ optional (
cfg.settings.watch-dir-enabled && cfg.settings.trash-original-torrent-files
) cfg.settings.watch-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"
]
++ optional (
cfg.settings.script-torrent-done-enabled && cfg.settings.script-torrent-done-filename != null
) cfg.settings.script-torrent-done-filename
++ optional (
cfg.settings.watch-dir-enabled && !cfg.settings.trash-original-torrent-files
) cfg.settings.watch-dir;
StateDirectory = [
"transmission"
"transmission/${settingsDir}"
@@ -389,7 +463,11 @@ in
RemoveIPC = true;
# AF_UNIX may become usable one day:
# https://github.com/transmission/transmission/issues/441
RestrictAddressFamilies = [ "AF_UNIX" "AF_INET" "AF_INET6" ];
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
@@ -398,7 +476,13 @@ in
# 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"
"~@aio"
"~@chown"
"~@keyring"
"~@memlock"
"~@resources"
"~@setuid"
"~@timer"
# In the @privileged group, but reached when querying infos through RPC (eg. with stig).
"quotactl"
];
@@ -409,38 +493,41 @@ in
# It's useful to have transmission in path, e.g. for remote control
environment.systemPackages = [ cfg.package ];
users.users = optionalAttrs (cfg.user == "transmission") ({
users.users = optionalAttrs (cfg.user == "transmission") {
transmission = {
group = cfg.group;
uid = config.ids.uids.transmission;
description = "Transmission BitTorrent user";
home = cfg.home;
};
});
};
users.groups = optionalAttrs (cfg.group == "transmission") ({
users.groups = optionalAttrs (cfg.group == "transmission") {
transmission = {
gid = config.ids.gids.transmission;
};
});
};
networking.firewall = mkMerge [
(mkIf cfg.openPeerPorts (
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;
}
];
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 ];
{
allowedTCPPorts = [ cfg.settings.peer-port ];
allowedUDPPorts = [ cfg.settings.peer-port ];
}
))
@@ -502,14 +589,16 @@ in
''}
}
${optionalString (cfg.settings.script-torrent-done-enabled &&
cfg.settings.script-torrent-done-filename != null) ''
# 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},
''}
${optionalString
(cfg.settings.script-torrent-done-enabled && cfg.settings.script-torrent-done-filename != null)
''
# 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},
''
}
${optionalString (cfg.webHome != null) ''
r ${cfg.webHome}/**,