This repository has no description
13 kB
453 lines
1{ config, pkgs, ... }:
2{
3 users.users.download = {
4 isSystemUser = true;
5 uid = 1001;
6 group = "download";
7 };
8 users.groups.download.members = [
9 "transmission"
10 "sabnzbd"
11 ];
12 services.transmission = {
13 enable = true;
14 package = pkgs.transmission_4;
15 home = "/var/state/transmission";
16 group = "download";
17 downloadDirPermissions = "775";
18 settings = {
19 download-dir = "/var/download/transmission";
20 incomplete-dir-enabled = false;
21 rpc-bind-address = "::";
22 message-level = 1;
23 umask = 18;
24 rpc-whitelist-enabled = false;
25 rpc-host-whitelist-enabled = false;
26 # stop seeding after reaching 5.0 ratio
27 ratio-limit = 5;
28 ratio-limit-enabled = true;
29 # limit concurrent seeding to reduce resource usage
30 seed-queue-enabled = true;
31 seed-queue-size = 20;
32 };
33 };
34
35 # garbage collection for old torrents
36 systemd.services.transmission-gc = {
37 description = "Remove old completed torrents from Transmission";
38 after = [ "transmission.service" ];
39 path = [
40 pkgs.transmission_4
41 pkgs.jq
42 pkgs.coreutils
43 pkgs.gawk
44 pkgs.gnugrep
45 pkgs.findutils
46 ];
47 script = ''
48 set -efu
49 TRANSMISSION_HOST="128.0.0.1:9091"
50 MAX_AGE_DAYS=30
51 MIN_RATIO=5.0
52
53 # get list of all torrents as JSON
54 torrents=$(transmission-remote "$TRANSMISSION_HOST" -l 2>/dev/null | tail -n +2 | head -n -1 || true)
55
56 if [ -z "$torrents" ]; then
57 echo "No torrents found"
58 exit 0
59 fi
60
61 # process each torrent
62 echo "$torrents" | while read -r line; do
63 id=$(echo "$line" | awk '{print $1}' | tr -d '*')
64 # skip if not a valid ID
65 [ -z "$id" ] || [ "$id" = "ID" ] && continue
66
67 # get torrent info
68 info=$(transmission-remote "$TRANSMISSION_HOST" -t "$id" -i 2>/dev/null || continue)
69
70 # extract ratio and completion status
71 ratio=$(echo "$info" | grep "Ratio:" | awk '{print $2}')
72 percent=$(echo "$info" | grep "Percent Done:" | awk '{print $3}' | tr -d '%')
73 state=$(echo "$info" | grep "State:" | cut -d: -f2- | xargs)
74
75 # skip if not 100% complete
76 [ "$percent" != "100" ] && continue
77
78 # check if ratio reached
79 ratio_reached=false
80 if [ -n "$ratio" ] && [ "$ratio" != "None" ]; then
81 if awk "BEGIN {exit !($ratio >= $MIN_RATIO)}"; then
82 ratio_reached=true
83 fi
84 fi
85
86 # check age via date added
87 date_added=$(echo "$info" | grep "Date added:" | cut -d: -f2- | xargs)
88 if [ -n "$date_added" ]; then
89 added_epoch=$(date -d "$date_added" +%s 2>/dev/null || echo 0)
90 now_epoch=$(date +%s)
91 age_days=$(( (now_epoch - added_epoch) / 86400 ))
92 else
93 age_days=0
94 fi
95
96 # remove if ratio reached OR older than max age
97 if [ "$ratio_reached" = "true" ]; then
98 echo "Removing torrent $id (ratio: $ratio >= $MIN_RATIO)"
99 transmission-remote "$TRANSMISSION_HOST" -t "$id" --remove-and-delete
100 elif [ "$age_days" -ge "$MAX_AGE_DAYS" ]; then
101 echo "Removing torrent $id (age: $age_days days >= $MAX_AGE_DAYS)"
102 transmission-remote "$TRANSMISSION_HOST" -t "$id" --remove-and-delete
103 fi
104 done
105 '';
106 serviceConfig = {
107 Type = "oneshot";
108 User = "transmission";
109 };
110 };
111
112 systemd.timers.transmission-gc = {
113 wantedBy = [ "timers.target" ];
114 timerConfig = {
115 OnCalendar = "daily";
116 Persistent = true;
117 RandomizedDelaySec = "1h";
118 };
119 };
120 systemd.services.transmission-watcher = {
121 wantedBy = [ "multi-user.target" ];
122 startAt = "*:0/5";
123 path = [
124 pkgs.curl
125 pkgs.systemdMinimal
126 ];
127 script = ''
128 set -efu -o pipefail
129 # don't restart if transmission is currently starting or stopping
130 state=$(systemctl show -p ActiveState --value transmission.service)
131 if [ "$state" = "activating" ] || [ "$state" = "deactivating" ]; then
132 echo "transmission is $state, skipping"
133 exit 0
134 fi
135 # check if transmission responds (with timeout)
136 if ! curl -SsfL --max-time 10 http://transmission.r; then
137 echo "transmission not responding, restarting"
138 systemctl restart transmission.service
139 fi
140 '';
141 };
142
143 # pause downloads when /var/download runs low, so a full disk can no longer
144 # hard-wedge sabnzbd with an unrecoverable "Disk full! Forcing Pause".
145 # sabnzbd pauses itself once free disk drops below download_free/complete_free
146 # (auto-resuming above); this guard keeps those set to THRESH GiB. Transmission
147 # has no equivalent, so below the threshold the guard stops all its torrents
148 # (restarting them on recovery) and posts a Matrix alert on each transition.
149 systemd.services.download-space-guard = {
150 wantedBy = [ "multi-user.target" ];
151 after = [ "sabnzbd.service" ];
152 startAt = "*:0/5";
153 path = [
154 pkgs.curl
155 pkgs.coreutils
156 pkgs.gnugrep
157 pkgs.gawk
158 pkgs.jq
159 pkgs.transmission_4
160 ];
161 script = ''
162 set -efu
163 DIR=/var/download
164 THRESH=100
165 # !MRjkvAaFPsjdYCpeiZ:lassul.us, URL-encoded for the API path
166 ROOM=%21MRjkvAaFPsjdYCpeiZ%3Alassul.us
167 STATE="$STATE_DIRECTORY/state"
168
169 avail=$(df -Pk "$DIR" | awk 'NR==2 {print int($4 / 1024 / 1024)}')
170
171 INI=/var/lib/sabnzbd/sabnzbd.ini
172 key=$(grep -E '^api_key' "$INI" | head -1 | cut -d= -f2 | tr -d ' ')
173 sab() {
174 curl -fsS --max-time 15 "http://127.0.0.1:8080/api?output=json&apikey=$key&$1" >/dev/null || :
175 }
176 tr_remote() {
177 transmission-remote 128.0.0.1:9091 "$@" >/dev/null || :
178 }
179 notify() {
180 token=$(cat "$CREDENTIALS_DIRECTORY/matrix-token")
181 body=$(jq -nc --arg b "$1" '{msgtype: "m.text", body: $b}')
182 curl -fsS --max-time 15 -X POST \
183 -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
184 "https://matrix.lassul.us/_matrix/client/v3/rooms/$ROOM/send/m.room.message" \
185 --data "$body" >/dev/null || :
186 }
187
188 # sabnzbd pauses continuously once free disk drops below these (and
189 # auto-resumes above). Keep them at THRESH GiB; re-assert only if changed.
190 for k in download_free complete_free; do
191 grep -qxF "$k = ''${THRESH}G" "$INI" \
192 || sab "mode=set_config§ion=misc&keyword=$k&value=''${THRESH}G"
193 done
194
195 prev=ok
196 if [ -f "$STATE" ]; then prev=$(cat "$STATE"); fi
197 if [ "$avail" -lt "$THRESH" ]; then cur=low; else cur=ok; fi
198
199 if [ "$cur" = low ]; then
200 # stop everything each run so newly-grabbed torrents can't fill the disk
201 tr_remote -t all --stop
202 if [ "$prev" != low ]; then
203 echo "free ''${avail}G < ''${THRESH}G: stopped transmission, alerting"
204 notify "⚠️ yellow: /var/download down to ''${avail} GiB free (< ''${THRESH} GiB). Stopped all transmission torrents; sabnzbd auto-paused via download_free."
205 fi
206 elif [ "$prev" = low ]; then
207 echo "free ''${avail}G >= ''${THRESH}G: resumed transmission, alerting"
208 tr_remote -t all --start
209 notify "✅ yellow: /var/download recovered to ''${avail} GiB free. Restarted transmission torrents."
210 fi
211 echo "$cur" > "$STATE"
212 '';
213 serviceConfig = {
214 Type = "oneshot";
215 User = "root";
216 StateDirectory = "download-space-guard";
217 LoadCredential = [
218 "matrix-token:${config.clan.core.vars.generators.archiver-matrix.files."matrix-access-token".path}"
219 ];
220 };
221 };
222
223 security.acme.defaults.email = "spam@krebsco.de";
224 security.acme.acceptTerms = true;
225 security.acme.certs."yellow.r".server = config.krebs.ssl.acmeURL;
226 security.acme.certs."jelly.r".server = config.krebs.ssl.acmeURL;
227 security.acme.certs."radar.r".server = config.krebs.ssl.acmeURL;
228 security.acme.certs."sonar.r".server = config.krebs.ssl.acmeURL;
229 security.acme.certs."transmission.r".server = config.krebs.ssl.acmeURL;
230 services.nginx = {
231 enable = true;
232 package = pkgs.nginx.override {
233 modules = with pkgs.nginxModules; [
234 fancyindex
235 ];
236 };
237 virtualHosts."yellow.r" = {
238 serverAliases = [ "flix.r" ];
239 default = true;
240 enableACME = true;
241 addSSL = true;
242 locations."/" = {
243 root = "/var/download";
244 extraConfig = ''
245 fancyindex on;
246 include ${pkgs.nginx}/conf/mime.types;
247 include ${pkgs.writeText "extrMime" ''
248 types {
249 video/webm mkv;
250 }
251 ''};
252 create_full_put_path on;
253 '';
254 };
255
256 };
257 virtualHosts."jelly.r" = {
258 enableACME = true;
259 addSSL = true;
260 locations."/".extraConfig = ''
261 proxy_pass http://localhost:8096/;
262 proxy_set_header Accept-Encoding "";
263 '';
264 };
265 virtualHosts."transmission.r" = {
266 enableACME = true;
267 addSSL = true;
268 locations."/" = {
269 proxyWebsockets = true;
270 proxyPass = "http://128.0.0.1:9091";
271 };
272 };
273 virtualHosts."radar.r" = {
274 enableACME = true;
275 addSSL = true;
276 locations."/" = {
277 proxyWebsockets = true;
278 proxyPass = "http://localhost:7878";
279 };
280 };
281 virtualHosts."sonar.r" = {
282 enableACME = true;
283 addSSL = true;
284 locations."/" = {
285 proxyWebsockets = true;
286 proxyPass = "http://localhost:8989";
287 # sonar.r now has forced authentication
288 extraConfig = ''
289 proxy_set_header Authorization "Basic a3JlYnM6YWlkc2JhbGxz";
290 '';
291 };
292 };
293 };
294
295 services.samba = {
296 enable = true;
297 enableNmbd = false;
298 settings.global = {
299 "hosts allow" = "42::/16 10.243.0.0/16 10.244.0.0/16 fdcc:c5da:5295:c853:d499::/80";
300 "use sendfile" = "true";
301 "disable netbios" = "true";
302 "mangled names" = "illegal";
303 "socket options" = "TCP_NODELAY IPTOS_LOWDELAY SO_RCVBUF=65536 SO_SNDBUF=65536";
304 "load printers" = "false";
305 "disable spoolss" = "true";
306 "printcap name" = "/dev/null";
307 "map to guest" = "Bad User";
308 "max log size" = "50";
309 "dns proxy" = "no";
310 "security" = "user";
311 "syslog only" = "yes";
312 };
313 shares.public = {
314 comment = "Warez";
315 path = "/var/download";
316 public = "yes";
317 "only guest" = "yes";
318 "create mask" = "0644";
319 "directory mask" = "2777";
320 writable = "no";
321 printable = "no";
322 };
323 };
324
325 networking.firewall.allowedTCPPorts = [
326 80 # nginx
327 443 # nginx
328 9091 # transmission web
329 8096 # jellyfin
330 8920 # jellyfin
331 51413 # transmission traffic
332 445 # smbd
333 111 # smbd
334 2049 # smbd
335 4000 # smbd
336 4001 # smbd
337 4002 # smbd
338 ];
339 networking.firewall.allowedUDPPorts = [
340 51413 # transmission traffic
341 1900 # jellyfin
342 7359 # jellyfin
343 111 # smbd
344 2049 # smbd
345 4000 # smbd
346 4001 # smbd
347 4002 # smbd
348 ];
349 krebs.iptables = {
350 enable = true;
351 tables.nat.PREROUTING.rules = [
352 # transmission rpc port
353 {
354 predicate = "-i retiolum -p tcp --dport 9091";
355 target = "DNAT --to-destination fdb4:3310:947::2";
356 v4 = false;
357 }
358 ];
359 tables.filter.FORWARD.policy = "ACCEPT"; # we need this so we can forward into the the transmission network namespace
360 };
361
362 systemd.services.flix-index = {
363 wantedBy = [ "multi-user.target" ];
364 path = [
365 pkgs.coreutils
366 pkgs.findutils
367 pkgs.inotify-tools
368 ];
369 startAt = "hourly";
370 serviceConfig = {
371 Type = "oneshot";
372 ExecStart = pkgs.writers.writeDash "flix-index" ''
373 set -efu
374 index(){
375 # /var/download churns constantly (download temp files appearing and
376 # vanishing), so find can exit non-zero when an entry disappears
377 # mid-scan. Ignore that — the entries we did enumerate are still valid
378 # — but only replace the live index if we actually produced one.
379 find . -type f > "$DIR"/index.tmp || :
380 if [ -s "$DIR"/index.tmp ]; then
381 mv "$DIR"/index.tmp "$DIR"/index
382 fi
383 }
384
385 DIR=/var/download
386 cd "$DIR"
387 index
388 '';
389 };
390 };
391
392 services.jellyfin = {
393 enable = true;
394 group = "download";
395 };
396
397 # request managment
398 services.jellyseerr = {
399 enable = true;
400 openFirewall = true;
401 };
402
403 # movies
404 services.radarr = {
405 enable = true;
406 openFirewall = true;
407 user = "download";
408 group = "download";
409 };
410
411 # shows
412 services.sonarr = {
413 enable = true;
414 openFirewall = true;
415 user = "download";
416 group = "download";
417 };
418 # sonarr needs unsecure packages
419 nixpkgs.config.permittedInsecurePackages = [
420 "aspnetcore-runtime-wrapped-6.0.36"
421 "aspnetcore-runtime-6.0.36"
422 "dotnet-sdk-wrapped-6.0.428"
423 "dotnet-sdk-6.0.428"
424 "olm-3.2.16"
425 ];
426
427 # indexers
428 services.prowlarr = {
429 enable = true;
430 openFirewall = true;
431 };
432
433 # flaresolverr for bypassing cloudflare on some indexers
434 services.flaresolverr = {
435 enable = true;
436 openFirewall = true;
437 };
438
439 # subtitles
440 services.bazarr = {
441 enable = true;
442 openFirewall = true;
443 user = "download";
444 group = "download";
445 };
446
447 # usenet download client
448 services.sabnzbd = {
449 enable = true;
450 group = "download";
451 openFirewall = true;
452 };
453}