push sheeet
Some checks failed
Periodic Merges (6h) / master → staging-nixos (push) Failing after 12m50s
Periodic Merges (6h) / master → staging-next (push) Failing after 12m54s
Periodic Merges (24h) / merge-base(master,staging) → haskell-updates (push) Failing after 11m54s
Periodic Merges (6h) / staging-next → staging (push) Failing after 12m13s
Periodic Merges (24h) / staging-next-25.05 → staging-25.05 (push) Failing after 13m24s
Periodic Merges (24h) / release-25.05 → staging-next-25.05 (push) Failing after 14m28s

This commit is contained in:
Dark Steveneq
2025-10-09 14:15:47 +02:00
commit 646b892680
49168 changed files with 5897842 additions and 0 deletions

View File

@@ -0,0 +1,195 @@
{
pkgs,
config,
lib,
...
}:
let
cfg = config.services.bitmagnet;
inherit (lib)
mkEnableOption
mkIf
mkOption
mkPackageOption
optional
;
inherit (lib.types)
bool
int
port
str
submodule
;
inherit (lib.generators) toYAML;
freeformType = (pkgs.formats.yaml { }).type;
in
{
options.services.bitmagnet = {
enable = mkEnableOption "Bitmagnet service";
useLocalPostgresDB = mkOption {
description = "Use a local postgresql database, create user and database";
type = bool;
default = true;
};
settings = mkOption {
description = "Bitmagnet configuration (https://bitmagnet.io/setup/configuration.html).";
default = { };
type = submodule {
inherit freeformType;
options = {
http_server = mkOption {
default = { };
description = "HTTP server settings";
type = submodule {
inherit freeformType;
options = {
port = mkOption {
type = str;
default = ":3333";
description = "HTTP server listen port";
};
};
};
};
dht_server = mkOption {
default = { };
description = "DHT server settings";
type = submodule {
inherit freeformType;
options = {
port = mkOption {
type = port;
default = 3334;
description = "DHT listen port";
};
};
};
};
postgres = mkOption {
default = { };
description = "PostgreSQL database configuration";
type = submodule {
inherit freeformType;
options = {
host = mkOption {
type = str;
default = "";
description = "Address, hostname or Unix socket path of the database server";
};
name = mkOption {
type = str;
default = "bitmagnet";
description = "Database name to connect to";
};
user = mkOption {
type = str;
default = "";
description = "User to connect as";
};
password = mkOption {
type = str;
default = "";
description = "Password for database user";
};
};
};
};
};
};
};
package = mkPackageOption pkgs "bitmagnet" { };
user = mkOption {
description = "User running bitmagnet";
type = str;
default = "bitmagnet";
};
group = mkOption {
description = "Group of user running bitmagnet";
type = str;
default = "bitmagnet";
};
openFirewall = mkOption {
description = "Open DHT ports in firewall";
type = bool;
default = false;
};
};
config = mkIf cfg.enable {
environment.etc."xdg/bitmagnet/config.yml" = {
text = toYAML { } cfg.settings;
mode = "0440";
user = cfg.user;
group = cfg.group;
};
systemd.services.bitmagnet = {
enable = true;
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
]
++ optional cfg.useLocalPostgresDB "postgresql.target";
requires = optional cfg.useLocalPostgresDB "postgresql.target";
serviceConfig = {
Type = "simple";
DynamicUser = true;
User = cfg.user;
Group = cfg.group;
ExecStart = "${cfg.package}/bin/bitmagnet worker run --all";
Restart = "on-failure";
WorkingDirectory = "/var/lib/bitmagnet";
StateDirectory = "bitmagnet";
# Sandboxing (sorted by occurrence in https://www.freedesktop.org/software/systemd/man/systemd.exec.html)
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
PrivateDevices = true;
ProtectHostname = true;
ProtectClock = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectKernelLogs = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
PrivateMounts = true;
};
};
users.users = mkIf (cfg.user == "bitmagnet") {
bitmagnet = {
group = cfg.group;
isSystemUser = true;
};
};
users.groups = mkIf (cfg.group == "bitmagnet") { bitmagnet = { }; };
networking.firewall = mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.settings.dht_server.port ];
allowedUDPPorts = [ cfg.settings.dht_server.port ];
};
services.postgresql = mkIf cfg.useLocalPostgresDB {
enable = true;
ensureDatabases = [
cfg.settings.postgres.name
(if (cfg.settings.postgres.user == "") then cfg.user else cfg.settings.postgres.user)
];
ensureUsers = [
{
name = if (cfg.settings.postgres.user == "") then cfg.user else cfg.settings.postgres.user;
ensureDBOwnership = true;
}
];
};
};
meta.maintainers = with lib.maintainers; [ gileri ];
}

View File

@@ -0,0 +1,255 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.cross-seed;
inherit (lib)
mkEnableOption
mkPackageOption
mkOption
types
;
settingsFormat = pkgs.formats.json { };
generatedConfig =
pkgs.runCommand "cross-seed-gen-config" { nativeBuildInputs = [ pkgs.cross-seed ]; }
''
export HOME=$(mktemp -d)
cross-seed gen-config
mkdir $out
cp -r $HOME/.cross-seed/config.js $out/
'';
in
{
options.services.cross-seed = {
enable = mkEnableOption "cross-seed";
package = mkPackageOption pkgs "cross-seed" { };
user = mkOption {
type = types.str;
default = "cross-seed";
description = "User to run cross-seed as.";
};
group = mkOption {
type = types.str;
default = "cross-seed";
example = "torrents";
description = "Group to run cross-seed as.";
};
configDir = mkOption {
type = types.path;
default = "/var/lib/cross-seed";
description = "Cross-seed config directory";
};
useGenConfigDefaults = mkOption {
type = types.bool;
default = false;
description = ''
Whether to use the option defaults from the configuration generated by
{command}`cross-seed gen-config`.
Those are the settings recommended by the project, and can be inspected
from their [template file](https://github.com/cross-seed/cross-seed/blob/master/src/config.template.cjs).
Settings set in {option}`services.cross-seed.settings` and
{option}`services.cross-seed.settingsFile` will override the ones from
this option.
'';
};
settings = mkOption {
default = { };
type = types.submodule {
freeformType = settingsFormat.type;
options = {
dataDirs = mkOption {
type = types.listOf types.path;
default = [ ];
description = ''
Paths to be searched for matching data.
If you use Injection, cross-seed will use the specified linkType
to create a link to the original file in the linkDirs.
If linkType is hardlink, these must be on the same volume as the
data.
'';
};
linkDirs = mkOption {
type = types.listOf types.path;
default = [ ];
description = ''
List of directories where cross-seed will create links.
If linkType is hardlink, these must be on the same volume as the data.
'';
};
torrentDir = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Directory containing torrent files, or if you're using a torrent
client integration and injection - your torrent client's .torrent
file store/cache.
'';
};
outputDir = mkOption {
type = types.path;
default = "${cfg.configDir}/output";
defaultText = ''''${cfg.configDir}/output'';
description = "Directory where cross-seed will place torrent files it finds.";
};
port = mkOption {
type = types.port;
default = 2468;
example = 3000;
description = "Port the cross-seed daemon listens on.";
};
};
};
description = ''
Configuration options for cross-seed.
Secrets should not be set in this option, as they will be available in
the Nix store. For secrets, please use settingsFile.
For more details, see [the cross-seed documentation](https://www.cross-seed.org/docs/basics/options).
'';
};
settingsFile = lib.mkOption {
default = null;
type = types.nullOr types.path;
description = ''
Path to a JSON file containing settings that will be merged with the
settings option. This is suitable for storing secrets, as they will not
be exposed on the Nix store.
'';
};
};
config =
let
jsonSettingsFile = settingsFormat.generate "settings.json" cfg.settings;
genConfigSegment =
lib.optionalString cfg.useGenConfigDefaults # js
''
const gen_config_js = "${generatedConfig}/config.js";
Object.assign(loaded_settings, require(gen_config_js));
'';
# Since cross-seed uses a javascript config file, we can use node's
# ability to parse JSON directly to avoid having to do any conversion.
# This also means we don't need to use any external programs to merge the
# secrets.
secretSettingsSegment =
lib.optionalString (cfg.settingsFile != null) # js
''
const path = require("node:path");
const secret_settings_json = path.join(process.env.CREDENTIALS_DIRECTORY, "secretSettingsFile");
Object.assign(loaded_settings, JSON.parse(fs.readFileSync(secret_settings_json, "utf8")));
'';
javascriptConfig =
pkgs.writeText "config.js" # js
''
"use strict";
const fs = require("fs");
const settings_json = "${jsonSettingsFile}";
let loaded_settings = {};
${genConfigSegment}
Object.assign(loaded_settings, JSON.parse(fs.readFileSync(settings_json, "utf8")));
${secretSettingsSegment}
module.exports = loaded_settings;
'';
in
lib.mkIf (cfg.enable) {
assertions = [
{
assertion = !(cfg.settings ? apiKey);
message = ''
The API key should be set via the settingsFile option, to avoid
exposing it on the Nix store.
'';
}
];
systemd.tmpfiles.settings."10-cross-seed" = {
${cfg.configDir}.d = {
inherit (cfg) group user;
mode = "700";
};
${cfg.settings.outputDir}.d = {
inherit (cfg) group user;
mode = "750";
};
};
systemd.services.cross-seed = {
description = "cross-seed";
after = [ "network-online.target" ];
wants = [ "network-online.target" ];
wantedBy = [ "multi-user.target" ];
environment.CONFIG_DIR = cfg.configDir;
preStart = ''
install -D -m 600 -o '${cfg.user}' -g '${cfg.group}' '${javascriptConfig}' '${cfg.configDir}/config.js'
'';
serviceConfig = {
ExecStart = "${lib.getExe cfg.package} daemon";
User = cfg.user;
Group = cfg.group;
# Only allow binding to the specified port.
SocketBindDeny = "any";
SocketBindAllow = cfg.settings.port;
LoadCredential = lib.mkIf (cfg.settingsFile != null) "secretSettingsFile:${cfg.settingsFile}";
StateDirectory = "cross-seed";
ReadWritePaths = [ cfg.settings.outputDir ];
ReadOnlyPaths = lib.optional (cfg.settings.torrentDir != null) cfg.settings.torrentDir;
};
unitConfig = {
# Unfortunately, we can not protect these if we are to hardlink between them, as they need to be on the same volume for hardlinks to work.
RequiresMountsFor = lib.flatten [
cfg.settings.dataDirs
cfg.settings.linkDirs
cfg.settings.outputDir
];
};
};
# It's useful to have the package in the path, to be able to e.g. get the API key.
environment.systemPackages = [ cfg.package ];
users.users = lib.mkIf (cfg.user == "cross-seed") {
cross-seed = {
group = cfg.group;
description = "cross-seed user";
isSystemUser = true;
home = cfg.configDir;
};
};
users.groups = lib.mkIf (cfg.group == "cross-seed") {
cross-seed = { };
};
};
}

View File

@@ -0,0 +1,304 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.deluge;
cfg_web = config.services.deluge.web;
isDeluge1 = lib.versionOlder cfg.package.version "2.0.0";
openFilesLimit = 4096;
listenPortsDefault = [
6881
6889
];
listToRange = x: {
from = lib.elemAt x 0;
to = lib.elemAt x 1;
};
configDir = "${cfg.dataDir}/.config/deluge";
configFile = pkgs.writeText "core.conf" (builtins.toJSON cfg.config);
declarativeLockFile = "${configDir}/.declarative";
preStart =
if cfg.declarative then
''
if [ -e ${declarativeLockFile} ]; then
# Was declarative before, no need to back up anything
${if isDeluge1 then "ln -sf" else "cp"} ${configFile} ${configDir}/core.conf
ln -sf ${cfg.authFile} ${configDir}/auth
else
# Declarative for the first time, backup stateful files
${if isDeluge1 then "ln -s" else "cp"} -b --suffix=.stateful ${configFile} ${configDir}/core.conf
ln -sb --suffix=.stateful ${cfg.authFile} ${configDir}/auth
echo "Autogenerated file that signifies that this server configuration is managed declaratively by NixOS" \
> ${declarativeLockFile}
fi
''
else
''
if [ -e ${declarativeLockFile} ]; then
rm ${declarativeLockFile}
fi
'';
in
{
options = {
services = {
deluge = {
enable = lib.mkEnableOption "Deluge daemon";
openFilesLimit = lib.mkOption {
default = openFilesLimit;
type = lib.types.either lib.types.int lib.types.str;
description = ''
Number of files to allow deluged to open.
'';
};
config = lib.mkOption {
type = lib.types.attrs;
default = { };
example = lib.literalExpression ''
{
download_location = "/srv/torrents/";
max_upload_speed = "1000.0";
share_ratio_limit = "2.0";
allow_remote = true;
daemon_port = 58846;
listen_ports = [ ${toString listenPortsDefault} ];
}
'';
description = ''
Deluge core configuration for the core.conf file. Only has an effect
when {option}`services.deluge.declarative` is set to
`true`. String values must be quoted, integer and
boolean values must not. See
<https://git.deluge-torrent.org/deluge/tree/deluge/core/preferencesmanager.py#n41>
for the available options.
'';
};
declarative = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Whether to use a declarative deluge configuration.
Only if set to `true`, the options
{option}`services.deluge.config`,
{option}`services.deluge.openFirewall` and
{option}`services.deluge.authFile` will be
applied.
'';
};
openFirewall = lib.mkOption {
default = false;
type = lib.types.bool;
description = ''
Whether to open the firewall for the ports in
{option}`services.deluge.config.listen_ports`. It only takes effet if
{option}`services.deluge.declarative` is set to
`true`.
It does NOT apply to the daemon port nor the web UI port. To access those
ports securely check the documentation
<https://dev.deluge-torrent.org/wiki/UserGuide/ThinClient#CreateSSHTunnel>
or use a VPN or configure certificates for deluge.
'';
};
dataDir = lib.mkOption {
type = lib.types.path;
default = "/var/lib/deluge";
description = ''
The directory where deluge will create files.
'';
};
authFile = lib.mkOption {
type = lib.types.path;
example = "/run/keys/deluge-auth";
description = ''
The file managing the authentication for deluge, the format of this
file is straightforward, each line contains a
username:password:level tuple in plaintext. It only has an effect
when {option}`services.deluge.declarative` is set to
`true`.
See <https://dev.deluge-torrent.org/wiki/UserGuide/Authentication> for
more information.
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "deluge";
description = ''
User account under which deluge runs.
'';
};
group = lib.mkOption {
type = lib.types.str;
default = "deluge";
description = ''
Group under which deluge runs.
'';
};
extraPackages = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
description = ''
Extra packages available at runtime to enable Deluge's plugins. For example,
extraction utilities are required for the built-in "Extractor" plugin.
This always contains unzip, gnutar, xz and bzip2.
'';
};
package = lib.mkPackageOption pkgs "deluge-2_x" { };
};
deluge.web = {
enable = lib.mkEnableOption "Deluge Web daemon";
port = lib.mkOption {
type = lib.types.port;
default = 8112;
description = ''
Deluge web UI port.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open ports in the firewall for deluge web daemon
'';
};
};
};
};
config = lib.mkIf cfg.enable {
services.deluge.package = lib.mkDefault (
if lib.versionAtLeast config.system.stateVersion "20.09" then
pkgs.deluge-2_x
else
# deluge-1_x is no longer packaged and this will resolve to an error
# thanks to the alias for this name. This is left here so that anyone
# using NixOS older than 20.09 receives that error when they upgrade
# and is forced to make an intentional choice to switch to deluge-2_x.
# That might be slightly inconvenient but there is no path to
# downgrade from 2.x to 1.x so NixOS should not automatically perform
# this state migration.
pkgs.deluge-1_x
);
# Provide a default set of `extraPackages`.
services.deluge.extraPackages = with pkgs; [
unzip
gnutar
xz
bzip2
];
systemd.tmpfiles.settings."10-deluged" =
let
defaultConfig = {
inherit (cfg) user group;
mode = "0770";
};
in
{
"${cfg.dataDir}".d = defaultConfig;
"${cfg.dataDir}/.config".d = defaultConfig;
"${cfg.dataDir}/.config/deluge".d = defaultConfig;
}
// lib.optionalAttrs (cfg.config ? download_location) {
${cfg.config.download_location}.d = defaultConfig;
}
// lib.optionalAttrs (cfg.config ? torrentfiles_location) {
${cfg.config.torrentfiles_location}.d = defaultConfig;
}
// lib.optionalAttrs (cfg.config ? move_completed_path) {
${cfg.config.move_completed_path}.d = defaultConfig;
};
systemd.services.deluged = {
after = [ "network.target" ];
description = "Deluge BitTorrent Daemon";
wantedBy = [ "multi-user.target" ];
path = [ cfg.package ] ++ cfg.extraPackages;
serviceConfig = {
ExecStart = ''
${cfg.package}/bin/deluged \
--do-not-daemonize \
--config ${configDir}
'';
# To prevent "Quit & shutdown daemon" from working; we want systemd to
# manage it!
Restart = "on-success";
User = cfg.user;
Group = cfg.group;
UMask = "0002";
LimitNOFILE = cfg.openFilesLimit;
};
preStart = preStart;
};
systemd.services.delugeweb = lib.mkIf cfg_web.enable {
after = [
"network.target"
"deluged.service"
];
requires = [ "deluged.service" ];
description = "Deluge BitTorrent WebUI";
wantedBy = [ "multi-user.target" ];
path = [ cfg.package ];
serviceConfig = {
ExecStart = ''
${cfg.package}/bin/deluge-web \
${lib.optionalString (!isDeluge1) "--do-not-daemonize"} \
--config ${configDir} \
--port ${toString cfg.web.port}
'';
User = cfg.user;
Group = cfg.group;
};
};
networking.firewall = lib.mkMerge [
(lib.mkIf (cfg.declarative && cfg.openFirewall && !(cfg.config.random_port or true)) {
allowedTCPPortRanges = lib.singleton (listToRange (cfg.config.listen_ports or listenPortsDefault));
allowedUDPPortRanges = lib.singleton (listToRange (cfg.config.listen_ports or listenPortsDefault));
})
(lib.mkIf (cfg.web.openFirewall) {
allowedTCPPorts = [ cfg.web.port ];
})
];
environment.systemPackages = [ cfg.package ];
users.users = lib.mkIf (cfg.user == "deluge") {
deluge = {
group = cfg.group;
uid = config.ids.uids.deluge;
home = cfg.dataDir;
description = "Deluge Daemon user";
};
};
users.groups = lib.mkIf (cfg.group == "deluge") {
deluge = {
gid = config.ids.gids.deluge;
};
};
};
}

View File

@@ -0,0 +1,104 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.flexget;
pkg = cfg.package;
ymlFile = pkgs.writeText "flexget.yml" ''
${cfg.config}
${lib.optionalString cfg.systemScheduler "schedules: no"}
'';
configFile = "${toString cfg.homeDir}/flexget.yml";
in
{
options = {
services.flexget = {
enable = lib.mkEnableOption "FlexGet daemon";
package = lib.mkPackageOption pkgs "flexget" { };
user = lib.mkOption {
default = "deluge";
example = "some_user";
type = lib.types.str;
description = "The user under which to run flexget.";
};
homeDir = lib.mkOption {
default = "/var/lib/deluge";
example = "/home/flexget";
type = lib.types.path;
description = "Where files live.";
};
interval = lib.mkOption {
default = "10m";
example = "1h";
type = lib.types.str;
description = "When to perform a {command}`flexget` run. See {command}`man 7 systemd.time` for the format.";
};
systemScheduler = lib.mkOption {
default = true;
example = false;
type = lib.types.bool;
description = "When true, execute the runs via the flexget-runner.timer. If false, you have to specify the settings yourself in the YML file.";
};
config = lib.mkOption {
default = "";
type = lib.types.lines;
description = "The YAML configuration for FlexGet.";
};
};
};
config = lib.mkIf cfg.enable {
environment.systemPackages = [ pkg ];
systemd.services = {
flexget = {
description = "FlexGet Daemon";
path = [ pkg ];
serviceConfig = {
User = cfg.user;
ExecStartPre = "${pkgs.coreutils}/bin/install -m644 ${ymlFile} ${configFile}";
ExecStart = "${pkg}/bin/flexget -c ${configFile} daemon start";
ExecStop = "${pkg}/bin/flexget -c ${configFile} daemon stop";
ExecReload = "${pkg}/bin/flexget -c ${configFile} daemon reload";
Restart = "on-failure";
PrivateTmp = true;
WorkingDirectory = toString cfg.homeDir;
};
wantedBy = [ "multi-user.target" ];
};
flexget-runner = lib.mkIf cfg.systemScheduler {
description = "FlexGet Runner";
after = [ "flexget.service" ];
wants = [ "flexget.service" ];
serviceConfig = {
User = cfg.user;
ExecStart = "${pkg}/bin/flexget -c ${configFile} execute";
PrivateTmp = true;
WorkingDirectory = toString cfg.homeDir;
};
};
};
systemd.timers.flexget-runner = lib.mkIf cfg.systemScheduler {
description = "Run FlexGet every ${cfg.interval}";
wantedBy = [ "timers.target" ];
timerConfig = {
OnBootSec = "5m";
OnUnitInactiveSec = cfg.interval;
Unit = "flexget-runner.service";
};
};
};
}

View File

@@ -0,0 +1,101 @@
{
config,
lib,
pkgs,
utils,
...
}:
let
cfg = config.services.flood;
in
{
meta.maintainers = with lib.maintainers; [ thiagokokada ];
options.services.flood = {
enable = lib.mkEnableOption "flood";
package = lib.mkPackageOption pkgs "flood" { };
openFirewall = lib.mkEnableOption "" // {
description = "Whether to open the firewall for the port in {option}`services.flood.port`.";
};
port = lib.mkOption {
type = lib.types.port;
description = "Port to bind webserver.";
default = 3000;
example = 3001;
};
host = lib.mkOption {
type = lib.types.str;
description = "Host to bind webserver.";
default = "localhost";
example = "::";
};
extraArgs = lib.mkOption {
type = with lib.types; listOf str;
description = "Extra arguments passed to `flood`.";
default = [ ];
example = [ "--baseuri=/" ];
};
};
config = lib.mkIf cfg.enable {
systemd.services.flood = {
description = "A modern web UI for various torrent clients.";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
unitConfig = {
Documentation = "https://github.com/jesec/flood/wiki";
};
serviceConfig = {
Restart = "on-failure";
RestartSec = "3s";
ExecStart = utils.escapeSystemdExecArgs (
[
(lib.getExe cfg.package)
"--host"
cfg.host
"--port"
(toString cfg.port)
"--rundir=/var/lib/flood"
]
++ cfg.extraArgs
);
CapabilityBoundingSet = [ "" ];
DynamicUser = true;
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
ProtectHome = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "strict";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
StateDirectory = "flood";
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"@pkey"
"~@privileged"
];
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [
cfg.port
];
};
}

View File

@@ -0,0 +1,237 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.magnetico;
dataDir = "/var/lib/magnetico";
credFile =
with cfg.web;
if credentialsFile != null then
credentialsFile
else
pkgs.writeText "magnetico-credentials" (
lib.concatStrings (lib.mapAttrsToList (user: hash: "${user}:${hash}\n") cfg.web.credentials)
);
# default options in magneticod/main.go
dbURI = lib.concatStrings [
"sqlite3://${dataDir}/database.sqlite3"
"?_journal_mode=WAL"
"&_busy_timeout=3000"
"&_foreign_keys=true"
];
crawlerArgs =
with cfg.crawler;
lib.escapeShellArgs (
[
"--database=${dbURI}"
"--indexer-addr=${address}:${toString port}"
"--indexer-max-neighbors=${toString maxNeighbors}"
"--leech-max-n=${toString maxLeeches}"
]
++ extraOptions
);
webArgs =
with cfg.web;
lib.escapeShellArgs (
[
"--database=${dbURI}"
(
if (cfg.web.credentialsFile != null || cfg.web.credentials != { }) then
"--credentials=${toString credFile}"
else
"--no-auth"
)
"--addr=${address}:${toString port}"
]
++ extraOptions
);
in
{
###### interface
options.services.magnetico = {
enable = lib.mkEnableOption "Magnetico, Bittorrent DHT crawler";
crawler.address = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
example = "1.2.3.4";
description = ''
Address to be used for indexing DHT nodes.
'';
};
crawler.port = lib.mkOption {
type = lib.types.port;
default = 0;
description = ''
Port to be used for indexing DHT nodes.
This port should be added to
{option}`networking.firewall.allowedTCPPorts`.
'';
};
crawler.maxNeighbors = lib.mkOption {
type = lib.types.ints.positive;
default = 1000;
description = ''
Maximum number of simultaneous neighbors of an indexer.
Be careful changing this number: high values can very
easily cause your network to be congested or even crash
your router.
'';
};
crawler.maxLeeches = lib.mkOption {
type = lib.types.ints.positive;
default = 200;
description = ''
Maximum number of simultaneous leeches.
'';
};
crawler.extraOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra command line arguments to pass to magneticod.
'';
};
web.address = lib.mkOption {
type = lib.types.str;
default = "localhost";
example = "1.2.3.4";
description = ''
Address the web interface will listen to.
'';
};
web.port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = ''
Port the web interface will listen to.
'';
};
web.credentials = lib.mkOption {
type = lib.types.attrsOf lib.types.str;
default = { };
example = lib.literalExpression ''
{
myuser = "$2y$12$YE01LZ8jrbQbx6c0s2hdZO71dSjn2p/O9XsYJpz.5968yCysUgiaG";
}
'';
description = ''
The credentials to access the web interface, in case authentication is
enabled, in the format `username:hash`. If unset no
authentication will be required.
Usernames must start with a lowercase ([a-z]) ASCII character, might
contain non-consecutive underscores except at the end, and consists of
small-case a-z characters and digits 0-9. The
{command}`htpasswd` tool from the `apacheHttpd`
package may be used to generate the hash:
{command}`htpasswd -bnBC 12 username password`
::: {.warning}
The hashes will be stored world-readable in the nix store.
Consider using the `credentialsFile` option if you
don't want this.
:::
'';
};
web.credentialsFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = ''
The path to the file holding the credentials to access the web
interface. If unset no authentication will be required.
The file must contain user names and password hashes in the format
`username:hash`, one for each line. Usernames must
start with a lowecase ([a-z]) ASCII character, might contain
non-consecutive underscores except at the end, and consists of
small-case a-z characters and digits 0-9.
The {command}`htpasswd` tool from the `apacheHttpd`
package may be used to generate the hash:
{command}`htpasswd -bnBC 12 username password`
'';
};
web.extraOptions = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = ''
Extra command line arguments to pass to magneticow.
'';
};
};
###### implementation
config = lib.mkIf cfg.enable {
users.users.magnetico = {
description = "Magnetico daemons user";
group = "magnetico";
isSystemUser = true;
};
users.groups.magnetico = { };
systemd.services.magneticod = {
description = "Magnetico DHT crawler";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
User = "magnetico";
Restart = "on-failure";
ExecStart = "${pkgs.magnetico}/bin/magneticod ${crawlerArgs}";
};
};
systemd.services.magneticow = {
description = "Magnetico web interface";
wantedBy = [ "multi-user.target" ];
after = [
"network.target"
"magneticod.service"
];
serviceConfig = {
User = "magnetico";
StateDirectory = "magnetico";
Restart = "on-failure";
ExecStart = "${pkgs.magnetico}/bin/magneticow ${webArgs}";
};
};
assertions = [
{
assertion = cfg.web.credentialsFile == null || cfg.web.credentials == { };
message = ''
The options services.magnetico.web.credentialsFile and
services.magnetico.web.credentials are mutually exclusives.
'';
}
];
};
meta.maintainers = with lib.maintainers; [ rnhmjoj ];
}

View File

@@ -0,0 +1,41 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.opentracker;
in
{
options.services.opentracker = {
enable = lib.mkEnableOption "opentracker";
package = lib.mkPackageOption pkgs "opentracker" { };
extraOptions = lib.mkOption {
type = lib.types.separatedString " ";
description = ''
Configuration Arguments for opentracker
See <https://erdgeist.org/arts/software/opentracker/> for all params
'';
default = "";
};
};
config = lib.mkIf cfg.enable {
systemd.services.opentracker = {
description = "opentracker server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
restartIfChanged = true;
serviceConfig = {
ExecStart = "${cfg.package}/bin/opentracker ${cfg.extraOptions}";
PrivateTmp = true;
WorkingDirectory = "/var/empty";
# By default opentracker drops all privileges and runs in chroot after starting up as root.
};
};
};
}

View File

@@ -0,0 +1,75 @@
{
config,
lib,
options,
pkgs,
...
}:
let
cfg = config.services.peerflix;
opt = options.services.peerflix;
configFile = pkgs.writeText "peerflix-config.json" ''
{
"connections": 50,
"tmp": "${cfg.downloadDir}"
}
'';
in
{
###### interface
options.services.peerflix = {
enable = lib.mkOption {
description = "Whether to enable peerflix service.";
default = false;
type = lib.types.bool;
};
stateDir = lib.mkOption {
description = "Peerflix state directory.";
default = "/var/lib/peerflix";
type = lib.types.path;
};
downloadDir = lib.mkOption {
description = "Peerflix temporary download directory.";
default = "${cfg.stateDir}/torrents";
defaultText = lib.literalExpression ''"''${config.${opt.stateDir}}/torrents"'';
type = lib.types.path;
};
};
###### implementation
config = lib.mkIf cfg.enable {
systemd.tmpfiles.rules = [
"d '${cfg.stateDir}' - peerflix - - -"
];
systemd.services.peerflix = {
description = "Peerflix Daemon";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment.HOME = cfg.stateDir;
preStart = ''
mkdir -p "${cfg.stateDir}"/{torrents,.config/peerflix-server}
ln -fs "${configFile}" "${cfg.stateDir}/.config/peerflix-server/config.json"
'';
serviceConfig = {
ExecStart = "${pkgs.nodePackages.peerflix-server}/bin/peerflix-server";
User = "peerflix";
};
};
users.users.peerflix = {
isSystemUser = true;
group = "peerflix";
};
users.groups.peerflix = { };
};
}

View File

@@ -0,0 +1,241 @@
{
config,
pkgs,
lib,
utils,
...
}:
let
cfg = config.services.qbittorrent;
inherit (builtins) concatStringsSep isAttrs isString;
inherit (lib)
literalExpression
getExe
mkEnableOption
mkOption
mkPackageOption
mkIf
maintainers
escape
collect
mapAttrsRecursive
optionals
;
inherit (lib.types)
str
port
path
nullOr
listOf
attrsOf
anything
submodule
;
inherit (lib.generators) toINI mkKeyValueDefault mkValueStringDefault;
gendeepINI = toINI {
mkKeyValue =
let
sep = "=";
in
k: v:
if isAttrs v then
concatStringsSep "\n" (
collect isString (
mapAttrsRecursive (
path: value:
"${escape [ sep ] (concatStringsSep "\\" ([ k ] ++ path))}${sep}${mkValueStringDefault { } value}"
) v
)
)
else
mkKeyValueDefault { } sep k v;
};
configFile = pkgs.writeText "qBittorrent.conf" (gendeepINI cfg.serverConfig);
in
{
options.services.qbittorrent = {
enable = mkEnableOption "qbittorrent, BitTorrent client";
package = mkPackageOption pkgs "qbittorrent-nox" { };
user = mkOption {
type = str;
default = "qbittorrent";
description = "User account under which qbittorrent runs.";
};
group = mkOption {
type = str;
default = "qbittorrent";
description = "Group under which qbittorrent runs.";
};
profileDir = mkOption {
type = path;
default = "/var/lib/qBittorrent/";
description = "the path passed to qbittorrent via --profile.";
};
openFirewall = mkEnableOption "opening both the webuiPort and torrentPort over TCP in the firewall";
webuiPort = mkOption {
default = 8080;
type = nullOr port;
description = "the port passed to qbittorrent via `--webui-port`";
};
torrentingPort = mkOption {
default = null;
type = nullOr port;
description = "the port passed to qbittorrent via `--torrenting-port`";
};
serverConfig = mkOption {
default = { };
type = submodule {
freeformType = attrsOf (attrsOf anything);
};
description = ''
Free-form settings mapped to the `qBittorrent.conf` file in the profile.
Refer to [Explanation-of-Options-in-qBittorrent](https://github.com/qbittorrent/qBittorrent/wiki/Explanation-of-Options-in-qBittorrent).
The Password_PBKDF2 format is oddly unique, you will likely want to use [this tool](https://codeberg.org/feathecutie/qbittorrent_password) to generate the format.
Alternatively you can run qBittorrent independently first and use its webUI to generate the format.
Optionally an alternative webUI can be easily set. VueTorrent for example:
```nix
{
Preferences = {
WebUI = {
AlternativeUIEnabled = true;
RootFolder = "''${pkgs.vuetorrent}/share/vuetorrent";
};
};
}
];
```
'';
example = literalExpression ''
{
LegalNotice.Accepted = true;
Preferences = {
WebUI = {
Username = "user";
Password_PBKDF2 = "generated ByteArray.";
};
General.Locale = "en";
};
}
'';
};
extraArgs = mkOption {
type = listOf str;
default = [ ];
description = ''
Extra arguments passed to qbittorrent. See `qbittorrent -h`, or the [source code](https://github.com/qbittorrent/qBittorrent/blob/master/src/app/cmdoptions.cpp), for the available arguments.
'';
example = [
"--confirm-legal-notice"
];
};
};
config = mkIf cfg.enable {
systemd = {
tmpfiles.settings = {
qbittorrent = {
"${cfg.profileDir}/qBittorrent/"."d" = {
mode = "755";
inherit (cfg) user group;
};
"${cfg.profileDir}/qBittorrent/config/"."d" = {
mode = "755";
inherit (cfg) user group;
};
"${cfg.profileDir}/qBittorrent/config/qBittorrent.conf"."L+" = mkIf (cfg.serverConfig != { }) {
mode = "1400";
inherit (cfg) user group;
argument = "${configFile}";
};
};
};
services.qbittorrent = {
description = "qbittorrent BitTorrent client";
wants = [ "network-online.target" ];
after = [
"local-fs.target"
"network-online.target"
"nss-lookup.target"
];
wantedBy = [ "multi-user.target" ];
restartTriggers = optionals (cfg.serverConfig != { }) [ configFile ];
serviceConfig = {
Type = "simple";
User = cfg.user;
Group = cfg.group;
ExecStart = utils.escapeSystemdExecArgs (
[
(getExe cfg.package)
"--profile=${cfg.profileDir}"
]
++ optionals (cfg.webuiPort != null) [ "--webui-port=${toString cfg.webuiPort}" ]
++ optionals (cfg.torrentingPort != null) [ "--torrenting-port=${toString cfg.torrentingPort}" ]
++ cfg.extraArgs
);
TimeoutStopSec = 1800;
# https://github.com/qbittorrent/qBittorrent/pull/6806#discussion_r121478661
PrivateTmp = false;
PrivateNetwork = false;
RemoveIPC = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateUsers = true;
ProtectHome = "yes";
ProtectProc = "invisible";
ProcSubset = "pid";
ProtectSystem = "full";
ProtectClock = true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [
"AF_INET"
"AF_INET6"
"AF_NETLINK"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
SystemCallArchitectures = "native";
CapabilityBoundingSet = "";
SystemCallFilter = [ "@system-service" ];
};
};
};
users = {
users = mkIf (cfg.user == "qbittorrent") {
qbittorrent = {
inherit (cfg) group;
isSystemUser = true;
};
};
groups = mkIf (cfg.group == "qbittorrent") { qbittorrent = { }; };
};
networking.firewall.allowedTCPPorts = mkIf cfg.openFirewall (
optionals (cfg.webuiPort != null) [ cfg.webuiPort ]
++ optionals (cfg.torrentingPort != null) [ cfg.torrentingPort ]
);
};
meta.maintainers = with maintainers; [
fsnkty
undefined-landmark
];
}

View File

@@ -0,0 +1,258 @@
{
config,
options,
pkgs,
lib,
...
}:
with lib;
let
cfg = config.services.rtorrent;
opt = options.services.rtorrent;
in
{
meta.maintainers = with lib.maintainers; [ thiagokokada ];
options.services.rtorrent = {
enable = mkEnableOption "rtorrent";
dataDir = mkOption {
type = types.str;
default = "/var/lib/rtorrent";
description = ''
The directory where rtorrent stores its data files.
'';
};
dataPermissions = mkOption {
type = types.str;
default = "0750";
example = "0755";
description = ''
Unix Permissions in octal on the rtorrent directory.
'';
};
downloadDir = mkOption {
type = types.str;
default = "${cfg.dataDir}/download";
defaultText = literalExpression ''"''${config.${opt.dataDir}}/download"'';
description = ''
Where to put downloaded files.
'';
};
user = mkOption {
type = types.str;
default = "rtorrent";
description = ''
User account under which rtorrent runs.
'';
};
group = mkOption {
type = types.str;
default = "rtorrent";
description = ''
Group under which rtorrent runs.
'';
};
package = mkPackageOption pkgs "rtorrent" { };
port = mkOption {
type = types.port;
default = 50000;
description = ''
The rtorrent port.
'';
};
openFirewall = mkOption {
type = types.bool;
default = false;
description = ''
Whether to open the firewall for the port in {option}`services.rtorrent.port`.
'';
};
rpcSocket = mkOption {
type = types.str;
readOnly = true;
default = "/run/rtorrent/rpc.sock";
description = ''
RPC socket path.
'';
};
configText = mkOption {
type = types.lines;
default = "";
description = ''
The content of {file}`rtorrent.rc`. The [modernized configuration template](https://rtorrent-docs.readthedocs.io/en/latest/cookbook.html#modernized-configuration-template) with the values specified in this module will be prepended using mkBefore. You can use mkForce to overwrite the config completely.
'';
};
};
config = mkIf cfg.enable {
users.groups = mkIf (cfg.group == "rtorrent") {
rtorrent = { };
};
users.users = mkIf (cfg.user == "rtorrent") {
rtorrent = {
group = cfg.group;
shell = pkgs.bashInteractive;
home = cfg.dataDir;
description = "rtorrent Daemon user";
isSystemUser = true;
};
};
networking.firewall.allowedTCPPorts = mkIf (cfg.openFirewall) [ cfg.port ];
services.rtorrent.configText = mkBefore ''
# Instance layout (base paths)
method.insert = cfg.basedir, private|const|string, (cat,"${cfg.dataDir}/")
method.insert = cfg.watch, private|const|string, (cat,(cfg.basedir),"watch/")
method.insert = cfg.logs, private|const|string, (cat,(cfg.basedir),"log/")
method.insert = cfg.logfile, private|const|string, (cat,(cfg.logs),(system.time),".log")
method.insert = cfg.rpcsock, private|const|string, (cat,"${cfg.rpcSocket}")
# Create instance directories
execute.throw = sh, -c, (cat, "mkdir -p ", (cfg.basedir), "/session ", (cfg.watch), " ", (cfg.logs))
# Listening port for incoming peer traffic (fixed; you can also randomize it)
network.port_range.set = ${toString cfg.port}-${toString cfg.port}
network.port_random.set = no
# Tracker-less torrent and UDP tracker support
# (conservative settings for 'private' trackers, change for 'public')
dht.mode.set = disable
protocol.pex.set = no
trackers.use_udp.set = no
# Peer settings
throttle.max_uploads.set = 100
throttle.max_uploads.global.set = 250
throttle.min_peers.normal.set = 20
throttle.max_peers.normal.set = 60
throttle.min_peers.seed.set = 30
throttle.max_peers.seed.set = 80
trackers.numwant.set = 80
protocol.encryption.set = allow_incoming,try_outgoing,enable_retry
# Limits for file handle resources, this is optimized for
# an `ulimit` of 1024 (a common default). You MUST leave
# a ceiling of handles reserved for rTorrent's internal needs!
network.http.max_open.set = 50
network.max_open_files.set = 600
network.max_open_sockets.set = 3000
# Memory resource usage (increase if you have a large number of items loaded,
# and/or the available resources to spend)
pieces.memory.max.set = 1800M
network.xmlrpc.size_limit.set = 4M
# Basic operational settings (no need to change these)
session.path.set = (cat, (cfg.basedir), "session/")
directory.default.set = "${cfg.downloadDir}"
log.execute = (cat, (cfg.logs), "execute.log")
##log.xmlrpc = (cat, (cfg.logs), "xmlrpc.log")
execute.nothrow = sh, -c, (cat, "echo >", (session.path), "rtorrent.pid", " ", (system.pid))
# Other operational settings (check & adapt)
encoding.add = utf8
system.umask.set = 0027
system.cwd.set = (cfg.basedir)
network.http.dns_cache_timeout.set = 25
schedule2 = monitor_diskspace, 15, 60, ((close_low_diskspace, 1000M))
# Watch directories (add more as you like, but use unique schedule names)
#schedule2 = watch_start, 10, 10, ((load.start, (cat, (cfg.watch), "start/*.torrent")))
#schedule2 = watch_load, 11, 10, ((load.normal, (cat, (cfg.watch), "load/*.torrent")))
# Logging:
# Levels = critical error warn notice info debug
# Groups = connection_* dht_* peer_* rpc_* storage_* thread_* tracker_* torrent_*
print = (cat, "Logging to ", (cfg.logfile))
log.open_file = "log", (cfg.logfile)
log.add_output = "info", "log"
##log.add_output = "tracker_debug", "log"
# XMLRPC
scgi_local = (cfg.rpcsock)
schedule = scgi_group,0,0,"execute.nothrow=chown,\":${cfg.group}\",(cfg.rpcsock)"
schedule = scgi_permission,0,0,"execute.nothrow=chmod,\"g+w,o=\",(cfg.rpcsock)"
'';
systemd = {
services = {
rtorrent =
let
rtorrentConfigFile = pkgs.writeText "rtorrent.rc" cfg.configText;
in
{
description = "rTorrent system service";
after = [ "network.target" ];
path = [
cfg.package
pkgs.bash
];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
User = cfg.user;
Group = cfg.group;
Type = "simple";
Restart = "on-failure";
WorkingDirectory = cfg.dataDir;
ExecStartPre = ''${pkgs.bash}/bin/bash -c "if test -e ${cfg.dataDir}/session/rtorrent.lock && test -z $(${pkgs.procps}/bin/pidof rtorrent); then rm -f ${cfg.dataDir}/session/rtorrent.lock; fi"'';
ExecStart = "${cfg.package}/bin/rtorrent -n -o system.daemon.set=true -o import=${rtorrentConfigFile}";
RuntimeDirectory = "rtorrent";
RuntimeDirectoryMode = 750;
CapabilityBoundingSet = [ "" ];
LockPersonality = true;
NoNewPrivileges = true;
PrivateDevices = true;
PrivateTmp = true;
ProtectClock = true;
ProtectControlGroups = true;
# If the default user is changed, there is a good chance that they
# want to store data in e.g.: $HOME directory
# Relax hardening in this case
ProtectHome = lib.mkIf (cfg.user == "rtorrent") true;
ProtectHostname = true;
ProtectKernelLogs = true;
ProtectKernelModules = true;
ProtectKernelTunables = true;
ProtectProc = "invisible";
ProtectSystem = "full";
RestrictAddressFamilies = [
"AF_UNIX"
"AF_INET"
"AF_INET6"
];
RestrictNamespaces = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
SystemCallArchitectures = "native";
SystemCallFilter = [
"@system-service"
"~@privileged"
];
};
};
};
tmpfiles.rules = [ "d '${cfg.dataDir}' ${cfg.dataPermissions} ${cfg.user} ${cfg.group} -" ];
};
};
}

View File

@@ -0,0 +1,58 @@
{
config,
lib,
pkgs,
...
}:
let
cfg = config.services.torrentstream;
dataDir = "/var/lib/torrentstream/";
in
{
options.services.torrentstream = {
enable = lib.mkEnableOption "TorrentStream daemon";
package = lib.mkPackageOption pkgs "torrentstream" { };
port = lib.mkOption {
type = lib.types.port;
default = 5082;
description = ''
TorrentStream port.
'';
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open ports in the firewall for TorrentStream daemon.
'';
};
address = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = ''
Address to listen on.
'';
};
};
config = lib.mkIf cfg.enable {
systemd.services.torrentstream = {
after = [ "network.target" ];
description = "TorrentStream Daemon";
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ExecStart = lib.getExe cfg.package;
Restart = "on-failure";
UMask = "077";
StateDirectory = "torrentstream";
DynamicUser = true;
};
environment = {
WEB_PORT = toString cfg.port;
DOWNLOAD_PATH = "%S/torrentstream";
LISTEN_ADDR = cfg.address;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}

View File

@@ -0,0 +1,608 @@
{
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;
apparmor = config.security.apparmor;
rootDir = "/run/transmission";
settingsDir = ".config/transmission-daemon";
downloadsDir = "Downloads";
incompleteDir = ".incomplete";
watchDir = "watchdir";
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"
]
)
];
options = {
services.transmission = {
enable = mkEnableOption "transmission" // {
description = ''
Whether to enable the headless Transmission BitTorrent daemon.
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 [](#opt-services.transmission.home)/${downloadsDir} by default and are
accessible to users in the "transmission" group.
'';
};
settings = mkOption {
description = ''
Settings whose options overwrite fields in
`.config/transmission-daemon/settings.json`
(each time the service starts).
See [Transmission's Wiki](https://github.com/transmission/transmission/wiki/Editing-Configuration-Files)
for documentation of settings not explicitly covered by this module.
'';
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.";
};
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 {manpage}`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).
'';
};
};
};
};
package = mkPackageOption pkgs "transmission" {
default = "transmission_3";
example = "pkgs.transmission_4";
};
downloadDirPermissions = mkOption {
type = with types; nullOr str;
default = null;
example = "770";
description = ''
If not `null`, is used as the permissions
set by `system.activationScripts.transmission-daemon`
on the directories [](#opt-services.transmission.settings.download-dir),
[](#opt-services.transmission.settings.incomplete-dir).
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
'';
};
home = mkOption {
type = types.path;
default = "/var/lib/transmission";
description = ''
The directory where Transmission will create `${settingsDir}`.
as well as `${downloadsDir}/` unless
[](#opt-services.transmission.settings.download-dir) is changed,
and `${incompleteDir}/` unless
[](#opt-services.transmission.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
to set secret config parameters like `rpc-password`.
'';
default = "/dev/null";
example = "/var/lib/secrets/transmission/settings.json";
};
extraFlags = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "--log-debug" ];
description = ''
Extra flags passed to the transmission command in the service definition.
'';
};
openPeerPorts = mkEnableOption "opening of the peer port(s) in the firewall";
openRPCPort = mkEnableOption "opening of the RPC port in the firewall";
performanceNetParameters = mkEnableOption "performance tweaks" // {
description = ''
Whether to enable tweaking of kernel parameters
to open many more connections at the same time.
Note that you may also want to increase
`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.
'';
};
webHome = mkOption {
type = types.nullOr types.path;
default = null;
example = "pkgs.flood-for-transmission";
description = ''
If not `null`, sets the value of the `TRANSMISSION_WEB_HOME`
environment variable used by the service. Useful for overriding
the web interface files, without overriding the transmission
package and thus requiring rebuilding it locally. Use this if
you want to use an alternative web interface, such as
`pkgs.flood-for-transmission`.
'';
};
};
};
config = mkIf cfg.enable {
# 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 -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}'
''}
'';
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 = config.security.pki.caBundle;
TRANSMISSION_WEB_HOME = lib.mkIf (cfg.webHome != null) cfg.webHome;
};
serviceConfig = {
Type = "notify";
# 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}";
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";
# 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
# 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;
StateDirectory = [
"transmission"
"transmission/${settingsDir}"
"transmission/${incompleteDir}"
"transmission/${downloadsDir}"
"transmission/${watchDir}"
];
StateDirectoryMode = mkDefault 750;
# 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 = mkDefault true;
PrivateNetwork = mkDefault false;
PrivateTmp = true;
PrivateUsers = mkDefault true;
ProtectClock = true;
ProtectControlGroups = true;
# ProtectHome=true would not allow BindPaths= to work across /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";
};
};
# It's useful to have transmission in path, e.g. for remote control
environment.systemPackages = [ cfg.package ];
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") {
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;
}
];
}
else
{
allowedTCPPorts = [ cfg.settings.peer-port ];
allowedUDPPorts = [ cfg.settings.peer-port ];
}
))
(mkIf cfg.openRPCPort { allowedTCPPorts = [ cfg.settings.rpc-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" = mkDefault "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" = mkDefault 60;
# Timeout faster established but inactive connections.
# Usual default is 432000.
"net.netfilter.nf_conntrack_tcp_timeout_established" = mkDefault 600;
# Clear immediately TCP states after timeout.
# Usual default is 120.
"net.netfilter.nf_conntrack_tcp_timeout_time_wait" = mkDefault 1;
# Increase the number of trackable connections.
# Usual default is 262144.
# Find out your current usage with: conntrack -C
"net.netfilter.nf_conntrack_max" = mkDefault 1048576;
})
];
security.apparmor.policies."bin.transmission-daemon".profile = ''
include "${cfg.package.apparmor}/bin.transmission-daemon"
'';
security.apparmor.includes."local/bin.transmission-daemon" = ''
r ${config.systemd.services.transmission.environment.CURL_CA_BUNDLE},
owner rw ${cfg.home}/${settingsDir}/**,
rw ${cfg.settings.download-dir}/**,
${optionalString cfg.settings.incomplete-dir-enabled ''
rw ${cfg.settings.incomplete-dir}/**,
''}
${optionalString cfg.settings.watch-dir-enabled ''
r${optionalString cfg.settings.trash-original-torrent-files "w"} ${cfg.settings.watch-dir}/**,
''}
profile dirs {
rw ${cfg.settings.download-dir}/**,
${optionalString cfg.settings.incomplete-dir-enabled ''
rw ${cfg.settings.incomplete-dir}/**,
''}
${optionalString cfg.settings.watch-dir-enabled ''
r${optionalString cfg.settings.trash-original-torrent-files "w"} ${cfg.settings.watch-dir}/**,
''}
}
${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}/**,
''}
'';
};
meta.maintainers = with lib.maintainers; [ julm ];
}