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