From 3b3bced9c5da2a36f29dbec11f1164dd1bf1d97b Mon Sep 17 00:00:00 2001 From: Pedro Alves Date: Fri, 14 Feb 2025 13:48:04 +0000 Subject: [PATCH] nixos/cross-seed: init module --- .../manual/release-notes/rl-2505.section.md | 2 + nixos/modules/module-list.nix | 1 + nixos/modules/services/torrent/cross-seed.nix | 214 ++++++++++++++++++ nixos/tests/all-tests.nix | 1 + nixos/tests/cross-seed.nix | 43 ++++ pkgs/by-name/cr/cross-seed/package.nix | 3 + 6 files changed, 264 insertions(+) create mode 100644 nixos/modules/services/torrent/cross-seed.nix create mode 100644 nixos/tests/cross-seed.nix diff --git a/nixos/doc/manual/release-notes/rl-2505.section.md b/nixos/doc/manual/release-notes/rl-2505.section.md index 261bd5590d72..9eaae5543893 100644 --- a/nixos/doc/manual/release-notes/rl-2505.section.md +++ b/nixos/doc/manual/release-notes/rl-2505.section.md @@ -111,6 +111,8 @@ - [autobrr](https://autobrr.com), a modern download automation tool for torrents and usenets. Available as [services.autobrr](#opt-services.autobrr.enable). +- [cross-seed](https://www.cross-seed.org), a tool to set-up fully automatic cross-seeding of torrents. Available as [services.cross-seed](#opt-services.cross-seed.enable). + - [agorakit](https://github.com/agorakit/agorakit), an organization tool for citizens' collectives. Available with [services.agorakit](options.html#opt-services.agorakit.enable). - [vivid](https://github.com/sharkdp/vivid), a generator for LS_COLOR. Available as [programs.vivid](#opt-programs.vivid.enable). diff --git a/nixos/modules/module-list.nix b/nixos/modules/module-list.nix index 14aed8fdb3af..bb9927c07fc7 100644 --- a/nixos/modules/module-list.nix +++ b/nixos/modules/module-list.nix @@ -1424,6 +1424,7 @@ ./services/system/userborn.nix ./services/system/zram-generator.nix ./services/torrent/bitmagnet.nix + ./services/torrent/cross-seed.nix ./services/torrent/deluge.nix ./services/torrent/flexget.nix ./services/torrent/flood.nix diff --git a/nixos/modules/services/torrent/cross-seed.nix b/nixos/modules/services/torrent/cross-seed.nix new file mode 100644 index 000000000000..61769fe6006a --- /dev/null +++ b/nixos/modules/services/torrent/cross-seed.nix @@ -0,0 +1,214 @@ +{ + config, + lib, + pkgs, + ... +}: +let + cfg = config.services.cross-seed; + + inherit (lib) + mkEnableOption + mkPackageOption + mkOption + types + ; + settingsFormat = pkgs.formats.json { }; +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"; + }; + + 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; + + # 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 = 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"; + }; + + 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 = { }; + }; + }; +} diff --git a/nixos/tests/all-tests.nix b/nixos/tests/all-tests.nix index 65fdf19f6fdc..0f3792ce9a8f 100644 --- a/nixos/tests/all-tests.nix +++ b/nixos/tests/all-tests.nix @@ -253,6 +253,7 @@ in { curl-impersonate = handleTest ./curl-impersonate.nix {}; custom-ca = handleTest ./custom-ca.nix {}; croc = handleTest ./croc.nix {}; + cross-seed = runTest ./cross-seed.nix; cyrus-imap = runTest ./cyrus-imap.nix; darling = handleTest ./darling.nix {}; darling-dmg = runTest ./darling-dmg.nix; diff --git a/nixos/tests/cross-seed.nix b/nixos/tests/cross-seed.nix new file mode 100644 index 000000000000..c50f0da2dbef --- /dev/null +++ b/nixos/tests/cross-seed.nix @@ -0,0 +1,43 @@ +{ lib, ... }: +let + apiKey = "twentyfourcharacterskey!"; +in +{ + name = "cross-seed"; + meta.maintainers = with lib.maintainers; [ pta2002 ]; + + nodes.machine = + { pkgs, config, ... }: + let + cfg = config.services.cross-seed; + in + { + systemd.tmpfiles.settings."0-cross-seed-test"."${cfg.settings.torrentDir}".d = { + inherit (cfg) user group; + mode = "700"; + }; + + services.cross-seed = { + enable = true; + settings = { + outputDir = "/var/lib/cross-seed/output"; + torrentDir = "/var/lib/torrents"; + torznab = [ ]; + useClientTorrents = false; + }; + # # We create this secret in the Nix store (making it readable by everyone). + # # DO NOT DO THIS OUTSIDE OF TESTS!! + settingsFile = (pkgs.formats.json { }).generate "secrets.json" { + inherit apiKey; + }; + }; + }; + + testScript = # python + '' + start_all() + machine.wait_for_unit("cross-seed.service") + machine.wait_for_open_port(2468) + machine.succeed("curl --fail -XPOST http://localhost:2468/api/search?apiKey=${apiKey}") + ''; +} diff --git a/pkgs/by-name/cr/cross-seed/package.nix b/pkgs/by-name/cr/cross-seed/package.nix index b44b9e0ba8f4..6bc0eda1fb85 100644 --- a/pkgs/by-name/cr/cross-seed/package.nix +++ b/pkgs/by-name/cr/cross-seed/package.nix @@ -2,6 +2,7 @@ lib, buildNpmPackage, fetchFromGitHub, + nixosTests, }: buildNpmPackage rec { @@ -17,6 +18,8 @@ buildNpmPackage rec { npmDepsHash = "sha256-hqQi0kSPm9SKEoLu6InvRMPxbQ+CBpKVPJhhOdo2ZII="; + passthru.tests.cross-seed = nixosTests.cross-seed; + meta = { description = "Fully-automatic torrent cross-seeding with Torznab"; homepage = "https://cross-seed.org";