1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7
8with lib;
9
10let
11 cfg = config.services.rutorrent;
12
13 rtorrentPluginDependencies = with pkgs; {
14 _task = [ procps ];
15 unpack = [
16 unzip
17 unrar
18 ];
19 rss = [ curl ];
20 mediainfo = [ mediainfo ];
21 spectrogram = [ sox ];
22 screenshots = [ ffmpeg ];
23 };
24
25 phpPluginDependencies = with pkgs; {
26 _cloudflare = [ python3 ];
27 };
28
29 getPluginDependencies = dependencies: concatMap (p: attrByPath [ p ] [ ] dependencies);
30
31in
32{
33 options = {
34 services.rutorrent = {
35 enable = mkEnableOption "ruTorrent";
36
37 hostName = mkOption {
38 type = types.str;
39 description = "FQDN for the ruTorrent instance.";
40 };
41
42 dataDir = mkOption {
43 type = types.str;
44 default = "/var/lib/rutorrent";
45 description = "Storage path of ruTorrent.";
46 };
47
48 user = mkOption {
49 type = types.str;
50 default = "rutorrent";
51 description = ''
52 User which runs the ruTorrent service.
53 '';
54 };
55
56 group = mkOption {
57 type = types.str;
58 default = "rutorrent";
59 description = ''
60 Group which runs the ruTorrent service.
61 '';
62 };
63
64 rpcSocket = mkOption {
65 type = types.str;
66 default = config.services.rtorrent.rpcSocket;
67 defaultText = "config.services.rtorrent.rpcSocket";
68 description = ''
69 Path to rtorrent rpc socket.
70 '';
71 };
72
73 plugins = mkOption {
74 type = with types; listOf (either str package);
75 default = [ "httprpc" ];
76 example = literalExpression ''[ "httprpc" "data" "diskspace" "edit" "erasedata" "theme" "trafic" ]'';
77 description = ''
78 List of plugins to enable. See the list of <link xlink:href="https://github.com/Novik/ruTorrent/wiki/Plugins#currently-there-are-the-following-plugins">available plugins</link>. Note: the <literal>unpack</literal> plugin needs the nonfree <literal>unrar</literal> package.
79 You need to either enable one of the <literal>rpc</literal> or <literal>httprpc</literal> plugin or enable the <xref linkend="opt-services.rutorrent.nginx.exposeInsecureRPC2mount"/> option.
80 '';
81 };
82
83 poolSettings = mkOption {
84 type =
85 with types;
86 attrsOf (oneOf [
87 str
88 int
89 bool
90 ]);
91 default = {
92 "pm" = "dynamic";
93 "pm.max_children" = 32;
94 "pm.start_servers" = 2;
95 "pm.min_spare_servers" = 2;
96 "pm.max_spare_servers" = 4;
97 "pm.max_requests" = 500;
98 };
99 description = ''
100 Options for ruTorrent's PHP pool. See the documentation on <literal>php-fpm.conf</literal> for details on configuration directives.
101 '';
102 };
103
104 nginx = {
105 enable = mkOption {
106 type = types.bool;
107 default = false;
108 description = ''
109 Whether to enable nginx virtual host management.
110 Further nginx configuration can be done by adapting <literal>services.nginx.virtualHosts.<name></literal>.
111 See <xref linkend="opt-services.nginx.virtualHosts"/> for further information.
112 '';
113 };
114
115 exposeInsecureRPC2mount = mkOption {
116 type = types.bool;
117 default = false;
118 description = ''
119 If you do not enable one of the <literal>rpc</literal> or <literal>httprpc</literal> plugins you need to expose an RPC mount through scgi using this option.
120 Warning: This allow to run arbitrary commands, as the rtorrent user, so make sure to use authentification. The simplest way would be to use the <literal>services.nginx.virtualHosts.<name>.basicAuth</literal> option.
121 '';
122 };
123 };
124 };
125 };
126
127 config = mkIf cfg.enable (mkMerge [
128 {
129 assertions =
130 let
131 usedRpcPlugins = intersectLists cfg.plugins [
132 "httprpc"
133 "rpc"
134 ];
135 in
136 [
137 {
138 assertion = (length usedRpcPlugins < 2);
139 message = "Please specify only one of httprpc or rpc plugins";
140 }
141 {
142 assertion = !(length usedRpcPlugins > 0 && cfg.nginx.exposeInsecureRPC2mount);
143 message = "Please do not use exposeInsecureRPC2mount if you use one of httprpc or rpc plugins";
144 }
145 ];
146
147 warnings =
148 let
149 nginxVhostCfg = config.services.nginx.virtualHosts."${cfg.hostName}";
150 in
151 [ ]
152 ++ (optional
153 (
154 cfg.nginx.exposeInsecureRPC2mount
155 && (nginxVhostCfg.basicAuth == { } || nginxVhostCfg.basicAuthFile == null)
156 )
157 ''
158 You are using exposeInsecureRPC2mount without using basic auth on the virtual host. The exposed rpc mount allow for remote command execution.
159
160 Please make sure it is not accessible from the outside.
161 ''
162 );
163
164 systemd = {
165 services = {
166 rtorrent.path = getPluginDependencies rtorrentPluginDependencies cfg.plugins;
167 rutorrent-setup =
168 let
169 rutorrentConfig = pkgs.writeText "rutorrent-config.php" ''
170 <?php
171 // configuration parameters
172
173 // for snoopy client
174 @define('HTTP_USER_AGENT', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.87 Safari/537.36', true);
175 @define('HTTP_TIME_OUT', 30, true); // in seconds
176 @define('HTTP_USE_GZIP', true, true);
177 $httpIP = null; // IP string. Or null for any.
178 $httpProxy = array
179 (
180 'use' => false,
181 'proto' => 'http', // 'http' or 'https'
182 'host' => 'PROXY_HOST_HERE',
183 'port' => 3128
184 );
185
186 @define('RPC_TIME_OUT', 5, true); // in seconds
187
188 @define('LOG_RPC_CALLS', false, true);
189 @define('LOG_RPC_FAULTS', true, true);
190
191 // for php
192 @define('PHP_USE_GZIP', false, true);
193 @define('PHP_GZIP_LEVEL', 2, true);
194
195 $schedule_rand = 10; // rand for schedulers start, +0..X seconds
196
197 $do_diagnostic = true;
198 $log_file = '${cfg.dataDir}/logs/errors.log'; // path to log file (comment or leave blank to disable logging)
199
200 $saveUploadedTorrents = true; // Save uploaded torrents to profile/torrents directory or not
201 $overwriteUploadedTorrents = false; // Overwrite existing uploaded torrents in profile/torrents directory or make unique name
202
203 $topDirectory = '/'; // Upper available directory. Absolute path with trail slash.
204 $forbidUserSettings = false;
205
206 $scgi_port = 0;
207 $scgi_host = "unix://${cfg.rpcSocket}";
208
209 $XMLRPCMountPoint = "/RPC2"; // DO NOT DELETE THIS LINE!!! DO NOT COMMENT THIS LINE!!!
210
211 $throttleMaxSpeed = 327625*1024;
212
213 $pathToExternals = array(
214 "php" => "${pkgs.php82}/bin/php", // Something like /usr/bin/php. If empty, will be found in PATH.
215 "curl" => "${pkgs.curl}/bin/curl", // Something like /usr/bin/curl. If empty, will be found in PATH.
216 "gzip" => "${pkgs.gzip}/bin/gzip", // Something like /usr/bin/gzip. If empty, will be found in PATH.
217 "id" => "${pkgs.coreutils}/bin/id", // Something like /usr/bin/id. If empty, will be found in PATH.
218 "stat" => "${pkgs.coreutils}/bin/stat", // Something like /usr/bin/stat. If empty, will be found in PATH.
219 "pgrep" => "${pkgs.procps}/bin/pgrep", // TODO why can't we use phpEnv.PATH
220 );
221
222 $localhosts = array( // list of local interfaces
223 "127.0.0.1",
224 "localhost",
225 );
226
227 $profilePath = '${cfg.dataDir}/share'; // Path to user profiles
228 $profileMask = 0770; // Mask for files and directory creation in user profiles.
229 // Both Webserver and rtorrent users must have read-write access to it.
230 // For example, if Webserver and rtorrent users are in the same group then the value may be 0770.
231
232 $tempDirectory = null; // Temp directory. Absolute path with trail slash. If null, then autodetect will be used.
233
234 $canUseXSendFile = false; // If true then use X-Sendfile feature if it exist
235
236 $locale = "UTF8";
237 '';
238 in
239 {
240 wantedBy = [ "multi-user.target" ];
241 before = [ "phpfpm-rutorrent.service" ];
242 script = ''
243 ln -sf ${pkgs.rutorrent}/{css,images,js,lang,index.html} ${cfg.dataDir}/
244 mkdir -p ${cfg.dataDir}/{conf,logs,plugins} ${cfg.dataDir}/share/{settings,torrents,users}
245 ln -sf ${pkgs.rutorrent}/conf/{access.ini,plugins.ini} ${cfg.dataDir}/conf/
246 ln -sf ${rutorrentConfig} ${cfg.dataDir}/conf/config.php
247
248 cp -r ${pkgs.rutorrent}/php ${cfg.dataDir}/
249
250 ${optionalString (cfg.plugins != [ ])
251 ''cp -r ${
252 concatMapStringsSep " " (p: "${pkgs.rutorrent}/plugins/${p}") cfg.plugins
253 } ${cfg.dataDir}/plugins/''
254 }
255
256 chown -R ${cfg.user}:${cfg.group} ${cfg.dataDir}/{conf,share,logs,plugins}
257 chmod -R 755 ${cfg.dataDir}/{conf,share,logs,plugins}
258 '';
259 serviceConfig.Type = "oneshot";
260 };
261 };
262
263 tmpfiles.rules = [ "d '${cfg.dataDir}' 0775 ${cfg.user} ${cfg.group} -" ];
264 };
265
266 users.groups."${cfg.group}" = { };
267
268 users.users = {
269 "${cfg.user}" = {
270 home = cfg.dataDir;
271 group = cfg.group;
272 extraGroups = [ config.services.rtorrent.group ];
273 description = "ruTorrent Daemon user";
274 isSystemUser = true;
275 };
276
277 "${config.services.rtorrent.user}" = {
278 extraGroups = [ cfg.group ];
279 };
280 };
281 }
282
283 (mkIf cfg.nginx.enable (mkMerge [
284 {
285 services = {
286 phpfpm.pools.rutorrent =
287 let
288 envPath = lib.makeBinPath (getPluginDependencies phpPluginDependencies cfg.plugins);
289 pool = {
290 user = cfg.user;
291 group = config.services.rtorrent.group;
292 settings =
293 mapAttrs (name: mkDefault) {
294 "listen.owner" = config.services.nginx.user;
295 "listen.group" = config.services.nginx.group;
296 }
297 // cfg.poolSettings;
298 };
299 in
300 if (envPath == "") then pool else pool // { phpEnv.PATH = envPath; };
301
302 nginx = {
303 enable = true;
304 virtualHosts = {
305 ${cfg.hostName} = {
306 root = cfg.dataDir;
307 locations = {
308 "~ [^/]\\.php(/|$)" = {
309 extraConfig = ''
310 fastcgi_split_path_info ^(.+?\.php)(/.*)$;
311 if (!-f $document_root$fastcgi_script_name) {
312 return 404;
313 }
314
315 # Mitigate https://httpoxy.org/ vulnerabilities
316 fastcgi_param HTTP_PROXY "";
317
318 fastcgi_pass unix:${config.services.phpfpm.pools.rutorrent.socket};
319 fastcgi_index index.php;
320
321 include ${pkgs.nginx}/conf/fastcgi.conf;
322 '';
323 };
324 };
325 };
326 };
327 };
328 };
329 }
330
331 (mkIf cfg.nginx.exposeInsecureRPC2mount {
332 services.nginx.virtualHosts."${cfg.hostName}".locations."/RPC2" = {
333 extraConfig = ''
334 include ${pkgs.nginx}/conf/scgi_params;
335 scgi_pass unix:${cfg.rpcSocket};
336 '';
337 };
338
339 services.rtorrent.group = "nginx";
340 })
341 ]))
342 ]);
343}