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