1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8 cfg = config.services.cross-seed;
9
10 inherit (lib)
11 mkEnableOption
12 mkPackageOption
13 mkOption
14 types
15 ;
16 settingsFormat = pkgs.formats.json { };
17
18 generatedConfig =
19 pkgs.runCommand "cross-seed-gen-config" { nativeBuildInputs = [ pkgs.cross-seed ]; }
20 ''
21 export HOME=$(mktemp -d)
22 cross-seed gen-config
23 mkdir $out
24 cp -r $HOME/.cross-seed/config.js $out/
25 '';
26in
27{
28 options.services.cross-seed = {
29 enable = mkEnableOption "cross-seed";
30
31 package = mkPackageOption pkgs "cross-seed" { };
32
33 user = mkOption {
34 type = types.str;
35 default = "cross-seed";
36 description = "User to run cross-seed as.";
37 };
38
39 group = mkOption {
40 type = types.str;
41 default = "cross-seed";
42 example = "torrents";
43 description = "Group to run cross-seed as.";
44 };
45
46 configDir = mkOption {
47 type = types.path;
48 default = "/var/lib/cross-seed";
49 description = "Cross-seed config directory";
50 };
51
52 useGenConfigDefaults = mkOption {
53 type = types.bool;
54 default = false;
55 description = ''
56 Whether to use the option defaults from the configuration generated by
57 {command}`cross-seed gen-config`.
58
59 Those are the settings recommended by the project, and can be inspected
60 from their [template file](https://github.com/cross-seed/cross-seed/blob/master/src/config.template.cjs).
61
62 Settings set in {option}`services.cross-seed.settings` and
63 {option}`services.cross-seed.settingsFile` will override the ones from
64 this option.
65 '';
66 };
67
68 settings = mkOption {
69 default = { };
70 type = types.submodule {
71 freeformType = settingsFormat.type;
72 options = {
73 dataDirs = mkOption {
74 type = types.listOf types.path;
75 default = [ ];
76 description = ''
77 Paths to be searched for matching data.
78
79 If you use Injection, cross-seed will use the specified linkType
80 to create a link to the original file in the linkDirs.
81
82 If linkType is hardlink, these must be on the same volume as the
83 data.
84 '';
85 };
86
87 linkDirs = mkOption {
88 type = types.listOf types.path;
89 default = [ ];
90 description = ''
91 List of directories where cross-seed will create links.
92
93 If linkType is hardlink, these must be on the same volume as the data.
94 '';
95 };
96
97 torrentDir = mkOption {
98 type = types.nullOr types.path;
99 default = null;
100 description = ''
101 Directory containing torrent files, or if you're using a torrent
102 client integration and injection - your torrent client's .torrent
103 file store/cache.
104 '';
105 };
106
107 outputDir = mkOption {
108 type = types.path;
109 default = "${cfg.configDir}/output";
110 defaultText = ''''${cfg.configDir}/output'';
111 description = "Directory where cross-seed will place torrent files it finds.";
112 };
113
114 port = mkOption {
115 type = types.port;
116 default = 2468;
117 example = 3000;
118 description = "Port the cross-seed daemon listens on.";
119 };
120 };
121 };
122
123 description = ''
124 Configuration options for cross-seed.
125
126 Secrets should not be set in this option, as they will be available in
127 the Nix store. For secrets, please use settingsFile.
128
129 For more details, see [the cross-seed documentation](https://www.cross-seed.org/docs/basics/options).
130 '';
131 };
132
133 settingsFile = lib.mkOption {
134 default = null;
135 type = types.nullOr types.path;
136 description = ''
137 Path to a JSON file containing settings that will be merged with the
138 settings option. This is suitable for storing secrets, as they will not
139 be exposed on the Nix store.
140 '';
141 };
142 };
143
144 config =
145 let
146 jsonSettingsFile = settingsFormat.generate "settings.json" cfg.settings;
147
148 genConfigSegment =
149 lib.optionalString cfg.useGenConfigDefaults # js
150 ''
151 const gen_config_js = "${generatedConfig}/config.js";
152 Object.assign(loaded_settings, require(gen_config_js));
153 '';
154
155 # Since cross-seed uses a javascript config file, we can use node's
156 # ability to parse JSON directly to avoid having to do any conversion.
157 # This also means we don't need to use any external programs to merge the
158 # secrets.
159 secretSettingsSegment =
160 lib.optionalString (cfg.settingsFile != null) # js
161 ''
162 const path = require("node:path");
163 const secret_settings_json = path.join(process.env.CREDENTIALS_DIRECTORY, "secretSettingsFile");
164 Object.assign(loaded_settings, JSON.parse(fs.readFileSync(secret_settings_json, "utf8")));
165 '';
166
167 javascriptConfig =
168 pkgs.writeText "config.js" # js
169 ''
170 "use strict";
171 const fs = require("fs");
172 const settings_json = "${jsonSettingsFile}";
173 let loaded_settings = {};
174 ${genConfigSegment}
175 Object.assign(loaded_settings, JSON.parse(fs.readFileSync(settings_json, "utf8")));
176 ${secretSettingsSegment}
177 module.exports = loaded_settings;
178 '';
179 in
180 lib.mkIf (cfg.enable) {
181 assertions = [
182 {
183 assertion = !(cfg.settings ? apiKey);
184 message = ''
185 The API key should be set via the settingsFile option, to avoid
186 exposing it on the Nix store.
187 '';
188 }
189 ];
190
191 systemd.tmpfiles.settings."10-cross-seed" = {
192 ${cfg.configDir}.d = {
193 inherit (cfg) group user;
194 mode = "700";
195 };
196
197 ${cfg.settings.outputDir}.d = {
198 inherit (cfg) group user;
199 mode = "750";
200 };
201 };
202
203 systemd.services.cross-seed = {
204 description = "cross-seed";
205 after = [ "network-online.target" ];
206 wants = [ "network-online.target" ];
207 wantedBy = [ "multi-user.target" ];
208 environment.CONFIG_DIR = cfg.configDir;
209 preStart = ''
210 install -D -m 600 -o '${cfg.user}' -g '${cfg.group}' '${javascriptConfig}' '${cfg.configDir}/config.js'
211 '';
212
213 serviceConfig = {
214 ExecStart = "${lib.getExe cfg.package} daemon";
215 User = cfg.user;
216 Group = cfg.group;
217
218 # Only allow binding to the specified port.
219 SocketBindDeny = "any";
220 SocketBindAllow = cfg.settings.port;
221
222 LoadCredential = lib.mkIf (cfg.settingsFile != null) "secretSettingsFile:${cfg.settingsFile}";
223
224 StateDirectory = "cross-seed";
225 ReadWritePaths = [ cfg.settings.outputDir ];
226 ReadOnlyPaths = lib.optional (cfg.settings.torrentDir != null) cfg.settings.torrentDir;
227 };
228
229 unitConfig = {
230 # 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.
231 RequiresMountsFor = lib.flatten [
232 cfg.settings.dataDirs
233 cfg.settings.linkDirs
234 cfg.settings.outputDir
235 ];
236 };
237 };
238
239 # It's useful to have the package in the path, to be able to e.g. get the API key.
240 environment.systemPackages = [ cfg.package ];
241
242 users.users = lib.mkIf (cfg.user == "cross-seed") {
243 cross-seed = {
244 group = cfg.group;
245 description = "cross-seed user";
246 isSystemUser = true;
247 home = cfg.configDir;
248 };
249 };
250
251 users.groups = lib.mkIf (cfg.group == "cross-seed") {
252 cross-seed = { };
253 };
254 };
255}