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