main
1{
2 config,
3 globals,
4 lib,
5 libx,
6 pkgs,
7 ...
8}:
9let
10 # Common security headers for Caddy
11 securityHeaders = ''
12 header {
13 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
14 X-Content-Type-Options "nosniff"
15 X-Frame-Options "SAMEORIGIN"
16 Referrer-Policy "strict-origin-when-cross-origin"
17 Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=(), magnetometer=(), gyroscope=(), accelerometer=()"
18 Content-Security-Policy "default-src 'self' *.sbr.pm *.demeester.fr; style-src 'self' 'unsafe-inline'; script-src 'self'"
19 X-XSS-Protection "1; mode=block"
20 Cache-Control "public, max-age=604800, immutable"
21 -Server
22 }
23 '';
24
25 # Security headers for media services (more permissive CSP for multimedia)
26 mediaSecurityHeaders = ''
27 header {
28 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
29 X-Content-Type-Options "nosniff"
30 X-Frame-Options "SAMEORIGIN"
31 Referrer-Policy "strict-origin-when-cross-origin"
32 Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
33 -Server
34 }
35 '';
36
37 # Security headers for git repository viewer (allow inline scripts/styles for gitmal)
38 gitSecurityHeaders = ''
39 header {
40 Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
41 X-Content-Type-Options "nosniff"
42 X-Frame-Options "SAMEORIGIN"
43 Referrer-Policy "strict-origin-when-cross-origin"
44 Permissions-Policy "geolocation=(), microphone=(), camera=(), payment=(), usb=()"
45 Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"
46 X-XSS-Protection "1; mode=block"
47 -Server
48 }
49 '';
50
51 # Robots.txt snippet - polite request to AI scrapers
52 robotsTxtSnippet = ''
53 @robots path /robots.txt
54 handle @robots {
55 respond 200 {
56 body `User-agent: CCBot
57 Disallow: /
58
59 User-agent: ChatGPT-User
60 Disallow: /
61
62 User-agent: GPTBot
63 Disallow: /
64
65 User-agent: Google-Extended
66 Disallow: /
67
68 User-agent: anthropic-ai
69 Disallow: /
70
71 User-agent: Omgilibot
72 Disallow: /
73
74 User-agent: Omgili
75 Disallow: /
76
77 User-agent: FacebookBot
78 Disallow: /`
79 close
80 }
81 }
82 '';
83
84 # HTTP echo template - echoes request headers as plain text
85 echoTemplate = pkgs.writeTextDir "index.txt" ''
86 HTTP Echo — echo.sbr.pm
87 ========================
88
89 Request
90 -------
91 {{.Req.Method}} {{placeholder "http.request.orig_uri"}} {{.Req.Proto}}
92
93 Headers
94 -------
95 {{range $name, $values := .Req.Header}}{{range $values}}{{$name}}: {{.}}
96 {{end}}{{end}}
97 Connection
98 ----------
99 Host: {{.Req.Host}}
100 Remote IP: {{.RemoteIP}}
101 '';
102
103 # AI bot blocking snippet - enforcement via HTTP 403
104 blockAIBotsSnippet = ''
105 @aibots {
106 header User-Agent *CCBot*
107 header User-Agent *ChatGPT-User*
108 header User-Agent *GPTBot*
109 header User-Agent *Google-Extended*
110 header User-Agent *anthropic-ai*
111 header User-Agent *Omgilibot*
112 header User-Agent *Omgili*
113 header User-Agent *FacebookBot*
114 }
115 handle @aibots {
116 respond "AI scraping not permitted" 403
117 }
118 '';
119in
120{
121 imports = [
122
123 ../common/services/openssh.nix
124 ];
125
126 # ── Fail2ban ────────────────────────────────────────────────────────
127 services.fail2ban = {
128 enable = true;
129
130 # Ban for 1 hour, increase on repeat offenders via recidive jail
131 bantime = "1h";
132 bantime-increment = {
133 enable = true;
134 maxtime = "168h"; # Max 1 week ban
135 factor = "4"; # Aggressive escalation
136 };
137
138 maxretry = 5;
139
140 # Ignore VPN, loopback, and dynamic home IP (managed by athena)
141 ignoreIP = [
142 "127.0.0.0/8"
143 "::1"
144 "10.100.0.0/24" # WireGuard VPN
145 ];
146
147 jails = {
148 # Caddy auth failures (401/403 responses)
149 caddy-auth = ''
150 enabled = true
151 backend = auto
152 filter = caddy-auth
153 logpath = /var/log/caddy/access*.log
154 maxretry = 10
155 findtime = 600
156 bantime = 3600
157 '';
158
159 # Caddy aggressive scanning (404 floods)
160 caddy-scan = ''
161 enabled = true
162 backend = auto
163 filter = caddy-scan
164 logpath = /var/log/caddy/access*.log
165 maxretry = 30
166 findtime = 60
167 bantime = 3600
168 '';
169
170 # Caddy rate abuse (too many requests)
171 caddy-flood = ''
172 enabled = true
173 backend = auto
174 filter = caddy-flood
175 logpath = /var/log/caddy/access*.log
176 maxretry = 200
177 findtime = 60
178 bantime = 7200
179 '';
180 };
181 };
182
183 # Caddy fail2ban filters for JSON access logs
184 environment.etc = {
185 # Ban IPs that get too many 401/403 responses (brute force / unauthorized access)
186 # Excludes services with their own auth that naturally return 401 for token refreshes
187 "fail2ban/filter.d/caddy-auth.conf".text = ''
188 [Definition]
189 failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403),.*$
190 ignoreregex = ^.*"host":"immich\.sbr\.pm".*$
191 ^.*"host":"photos\.sbr\.pm".*$
192 ^.*"host":"navidrome\.sbr\.pm".*$
193 ^.*"host":"music\.sbr\.pm".*$
194 ^.*"host":"jellyfin\.sbr\.pm".*$
195 ^.*"host":"audiobookshelf\.sbr\.pm".*$
196 ^.*"host":"podcasts\.sbr\.pm".*$
197 ^.*"host":"ntfy\.sbr\.pm".*$
198 datepattern = "ts":{EPOCH}
199 '';
200
201 # Ban IPs that trigger excessive 404s (scanning for vulnerabilities)
202 "fail2ban/filter.d/caddy-scan.conf".text = ''
203 [Definition]
204 failregex = ^.*"remote_ip":"<HOST>".*"status":404,.*$
205 ignoreregex =
206 datepattern = "ts":{EPOCH}
207 '';
208
209 # Ban IPs with excessive request volume (flood / DDoS)
210 "fail2ban/filter.d/caddy-flood.conf".text = ''
211 [Definition]
212 failregex = ^.*"remote_ip":"<HOST>".*"status":\d+,.*$
213 ignoreregex = ^.*"remote_ip":"10\.100\.0\..*$
214 ^.*"host":"ntfy\.sbr\.pm".*$
215 ^.*"host":"git\.sbr\.pm".*$
216 ^.*"host":"music\.sbr\.pm".*$
217 ^.*"host":"navidrome\.sbr\.pm".*$
218 ^.*"host":"immich\.sbr\.pm".*$
219 ^.*"host":"photos\.sbr\.pm".*$
220 ^.*"host":"audiobookshelf\.sbr\.pm".*$
221 ^.*"host":"podcasts\.sbr\.pm".*$
222 ^.*"host":"jellyfin\.sbr\.pm".*$
223 datepattern = "ts":{EPOCH}
224 '';
225 };
226
227 # Dynamic home IP whitelist for fail2ban
228 # athena pushes the home public IP to /var/lib/fail2ban/home-ip.txt via SSH
229 # A path unit watches the file and reloads fail2ban ignoreip accordingly
230 systemd.tmpfiles.settings.fail2ban = {
231 "/var/lib/fail2ban".d = {
232 mode = "0755";
233 user = "root";
234 group = "root";
235 };
236 };
237
238 systemd.services.fail2ban-home-ip = {
239 description = "Update fail2ban with dynamic home IP";
240 serviceConfig = {
241 Type = "oneshot";
242 ExecStart = pkgs.writeShellScript "fail2ban-home-ip" ''
243 #!/usr/bin/env bash
244 set -euo pipefail
245 IP_FILE="/var/lib/fail2ban/home-ip.txt"
246 if [ ! -f "$IP_FILE" ]; then
247 echo "No home IP file found, skipping"
248 exit 0
249 fi
250 NEW_IP=$(${pkgs.coreutils}/bin/cat "$IP_FILE" | ${pkgs.coreutils}/bin/tr -d '[:space:]')
251 if [ -z "$NEW_IP" ]; then
252 echo "Empty IP file, skipping"
253 exit 0
254 fi
255 # Validate IP format
256 if ! echo "$NEW_IP" | ${pkgs.gnugrep}/bin/grep -qP '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$'; then
257 echo "Invalid IP format: $NEW_IP"
258 exit 1
259 fi
260 OLD_IP_FILE="/var/lib/fail2ban/home-ip-current.txt"
261 OLD_IP=""
262 if [ -f "$OLD_IP_FILE" ]; then
263 OLD_IP=$(${pkgs.coreutils}/bin/cat "$OLD_IP_FILE" | ${pkgs.coreutils}/bin/tr -d '[:space:]')
264 fi
265 if [ "$NEW_IP" = "$OLD_IP" ]; then
266 echo "Home IP unchanged: $NEW_IP"
267 exit 0
268 fi
269 echo "Updating home IP: $OLD_IP -> $NEW_IP"
270 # Remove old IP from all jails
271 if [ -n "$OLD_IP" ]; then
272 for jail in caddy-auth caddy-flood caddy-scan sshd; do
273 ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" delignoreip "$OLD_IP" 2>/dev/null || true
274 # Also unban in case it was already banned
275 ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" unbanip "$OLD_IP" 2>/dev/null || true
276 done
277 fi
278 # Add new IP to all jails
279 for jail in caddy-auth caddy-flood caddy-scan sshd; do
280 ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" addignoreip "$NEW_IP" 2>/dev/null || true
281 # Unban new IP too in case it was banned before being whitelisted
282 ${pkgs.fail2ban}/bin/fail2ban-client set "$jail" unbanip "$NEW_IP" 2>/dev/null || true
283 done
284 echo "$NEW_IP" > "$OLD_IP_FILE"
285 echo "Home IP updated successfully: $NEW_IP"
286 '';
287 };
288 after = [ "fail2ban.service" ];
289 requires = [ "fail2ban.service" ];
290 };
291
292 systemd.paths.fail2ban-home-ip = {
293 description = "Watch for home IP changes";
294 wantedBy = [ "multi-user.target" ];
295 pathConfig = {
296 PathModified = "/var/lib/fail2ban/home-ip.txt";
297 };
298 };
299
300 # Allow athena to write the home IP file via SSH
301 # athena will: curl -s ifconfig.me | ssh carthage.vpn 'cat > /var/lib/fail2ban/home-ip.txt'
302
303 # Age secrets
304 age.secrets."ntfy-token" = {
305 file = ../../secrets/sakhalin/ntfy-token.age;
306 mode = "400";
307 owner = "root";
308 group = "root";
309 };
310
311 age.secrets."flux-github-token" = {
312 file = ../../secrets/carthage/flux-github-token.age;
313 mode = "400";
314 owner = "vincent";
315 group = "users";
316 };
317
318 # Flux — website generator (hourly)
319 systemd.tmpfiles.rules = [
320 "d /var/lib/flux 0755 vincent users -"
321 # Git directory permissions (for Caddy access to public repos)
322 "d /home/vincent 0711 vincent users -" # Allow traversal to git directory
323 "d /home/vincent/git 0700 vincent users -" # Private git directory
324 "d /home/vincent/git/public 0755 vincent users -" # Public repositories only
325 "d /var/log/git-builds 0755 vincent users -" # Git build logs
326 ];
327
328 systemd.services.flux-generate = {
329 description = "Generate and deploy vincent.demeester.fr";
330 serviceConfig = {
331 Type = "oneshot";
332 User = "vincent";
333 Group = "users";
334 WorkingDirectory = "/var/lib/flux";
335 ExecStart = "/var/lib/flux/www/scripts/flux-generate.sh";
336 Environment = "HOME=/home/vincent";
337 };
338 path = with pkgs; [
339 nix
340 bash
341 git
342 openssh
343 rsync
344 coreutils
345 findutils
346 gnused
347 gnugrep
348 gnutar
349 gzip
350 ];
351 };
352
353 systemd.timers.flux-generate = {
354 description = "Generate website hourly";
355 wantedBy = [ "timers.target" ];
356 timerConfig = {
357 OnCalendar = "hourly";
358 Persistent = true;
359 RandomizedDelaySec = "5min";
360 };
361 };
362
363 # Allow Caddy to access git repositories in vincent's home
364 users.users.caddy.extraGroups = [ "users" ];
365
366 # Allow vincent to run systemd-run without password (for git hooks)
367 # Use /run/current-system/sw/bin path to avoid hardcoded Nix store paths
368 security.sudo.extraRules = [
369 {
370 users = [ "vincent" ];
371 commands = [
372 {
373 command = "/run/current-system/sw/bin/systemd-run";
374 options = [ "NOPASSWD" ];
375 }
376 {
377 command = "${pkgs.rsync}/bin/rsync -a --delete /var/lib/pds/ /tmp/pds-backup/";
378 options = [ "NOPASSWD" ];
379 }
380 ];
381 }
382 ];
383
384 # Install gitmal for self-hosted git web view
385 environment.systemPackages = with pkgs; [
386 gitmal
387 ];
388
389 # Git hook background task execution with notifications
390 systemd.services."git-notify@" = {
391 description = "Git build notification for %i";
392 serviceConfig = {
393 Type = "oneshot";
394 ExecStart = "${pkgs.writeShellScript "git-notify" ''
395 #!/usr/bin/env bash
396 set -euo pipefail
397
398 UNIT_NAME="$1"
399 RESULT=$(${pkgs.systemd}/bin/systemctl show -p Result --value "$UNIT_NAME")
400 EXIT_CODE=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStatus --value "$UNIT_NAME")
401
402 # Get execution timestamps (in microseconds since epoch)
403 START_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStartTimestamp --value "$UNIT_NAME")
404 EXIT_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainExitTimestamp --value "$UNIT_NAME")
405
406 # Calculate duration in seconds
407 START_EPOCH=$(${pkgs.coreutils}/bin/date -d "$START_TIME" +%s 2>/dev/null || echo "0")
408 EXIT_EPOCH=$(${pkgs.coreutils}/bin/date -d "$EXIT_TIME" +%s 2>/dev/null || echo "0")
409 DURATION=$((EXIT_EPOCH - START_EPOCH))
410
411 # Format duration as human-readable
412 if [ "$DURATION" -ge 60 ]; then
413 MINUTES=$((DURATION / 60))
414 SECONDS=$((DURATION % 60))
415 DURATION_STR="''${MINUTES}m ''${SECONDS}s"
416 else
417 DURATION_STR="''${DURATION}s"
418 fi
419
420 # Parse unit name to extract job type and repo
421 # Format: git-<job>-<repo>-<timestamp>
422 JOB_TYPE=$(echo "$UNIT_NAME" | cut -d'-' -f2)
423 REPO=$(echo "$UNIT_NAME" | cut -d'-' -f3)
424
425 # Only notify on failure
426 if [ "$RESULT" != "success" ]; then
427 ${pkgs.curl}/bin/curl -s \
428 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
429 config.age.secrets."ntfy-token".path
430 })" \
431 -H "Title: ❌ Git $JOB_TYPE Failed: $REPO (after $DURATION_STR)" \
432 -H "Priority: high" \
433 -H "Tags: x,git,$JOB_TYPE,warning" \
434 -d "Job $UNIT_NAME failed after $DURATION_STR (exit code: $EXIT_CODE). Check logs: journalctl -u $UNIT_NAME" \
435 "https://ntfy.sbr.pm/git-builds" || true
436 fi
437 ''} %i";
438 };
439 };
440
441 # Helper script for gitmal generation (called from post-receive hooks)
442 environment.etc."git-hooks/generate-gitmal.sh" = {
443 text = ''
444 #!${pkgs.bash}/bin/bash
445 set -euo pipefail
446
447 # Set PATH to include git and coreutils (gitmal needs git, script needs basename)
448 export PATH="${pkgs.git}/bin:${pkgs.coreutils}/bin:$PATH"
449
450 REPO_PATH="$1"
451 THEME="''${2:-github-dark}" # Default to 'github-dark' theme if not specified
452 REPO_NAME=$(basename "$REPO_PATH" .git)
453 OUTPUT_DIR="/home/vincent/git/public/$REPO_NAME"
454
455 echo "Generating gitmal for repository: $REPO_NAME"
456 echo "Repository path: $REPO_PATH"
457 echo "Output directory: $OUTPUT_DIR"
458 echo "Theme: $THEME"
459
460 # Generate static site with gitmal
461 cd "$REPO_PATH"
462 ${pkgs.gitmal}/bin/gitmal --output "$OUTPUT_DIR" --theme "$THEME"
463
464 echo "Gitmal generation complete: $OUTPUT_DIR"
465 '';
466 mode = "0755";
467 };
468
469 # Remote build forwarding script
470 environment.etc."git-hooks/forward-build.sh" = {
471 text = ''
472 #!${pkgs.bash}/bin/bash
473 set -euo pipefail
474
475 REPO_NAME="$1"
476 BUILD_TYPE="''${2:-auto}"
477 TIMESTAMP=$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)
478 UNIT_NAME="build-remote-''${REPO_NAME}-''${TIMESTAMP}"
479
480 echo "Forwarding build to aomi: $REPO_NAME ($BUILD_TYPE)..."
481
482 # SSH to aomi and trigger build with systemd-run
483 ${pkgs.openssh}/bin/ssh -o BatchMode=yes builder@10.100.0.17 \
484 "sudo /run/current-system/sw/bin/systemd-run \
485 --unit=\"$UNIT_NAME\" \
486 --description=\"Remote build: $REPO_NAME ($BUILD_TYPE)\" \
487 --property=\"OnSuccess=job-notify@\''${UNIT_NAME}.service\" \
488 --property=\"OnFailure=job-notify@\''${UNIT_NAME}.service\" \
489 --property=\"User=builder\" \
490 --property=\"Group=users\" \
491 --property=\"WorkingDirectory=/var/lib/git-builds\" \
492 /etc/git-builds/execute-build.sh \"$REPO_NAME\" \"$BUILD_TYPE\""
493
494 echo "✓ Build queued on aomi: $UNIT_NAME"
495 echo " View status: ssh aomi 'systemctl status $UNIT_NAME'"
496 echo " View logs: ssh aomi 'journalctl -u $UNIT_NAME'"
497 '';
498 mode = "0755";
499 };
500
501 # Example post-receive hook template
502 environment.etc."git-hooks/post-receive.example" = {
503 text = ''
504 #!${pkgs.bash}/bin/bash
505 # Example post-receive hook for git repositories
506 # Copy this to your repository's hooks/post-receive and make it executable
507 #
508 # This hook uses systemd-run to execute gitmal generation in the background
509 # with automatic notifications via ntfy when the job completes.
510 #
511 # Optionally, it can also trigger remote builds on aomi.
512
513 set -euo pipefail
514
515 # Configuration
516 GITMAL_ENABLED="true"
517 GITMAL_THEME="github-dark" # Options: github-dark, github-light, dark, light, auto
518 REMOTE_BUILD_ENABLED="false" # Set to "true" to enable remote builds on aomi
519 BUILD_TYPE="nixos" # Options: nixos, make, docker, go, custom, auto
520
521 REPO_PATH="$(pwd)"
522 REPO_NAME=$(basename "$REPO_PATH" .git)
523 TIMESTAMP=$(date +%Y%m%d-%H%M%S)
524
525 # 1. Generate gitmal (local static site on carthage)
526 if [ "$GITMAL_ENABLED" = "true" ]; then
527 UNIT_NAME="git-gitmal-''${REPO_NAME}-''${TIMESTAMP}"
528 echo "Queuing gitmal generation for $REPO_NAME with theme: $GITMAL_THEME..."
529
530 sudo /run/current-system/sw/bin/systemd-run \
531 --unit="$UNIT_NAME" \
532 --description="Gitmal generation for $REPO_NAME" \
533 --property="OnSuccess=git-notify@''${UNIT_NAME}.service" \
534 --property="OnFailure=git-notify@''${UNIT_NAME}.service" \
535 --property="User=vincent" \
536 --property="Group=users" \
537 --working-directory="$REPO_PATH" \
538 /etc/git-hooks/generate-gitmal.sh "$REPO_PATH" "$GITMAL_THEME"
539
540 echo "✓ Gitmal generation queued as: $UNIT_NAME"
541 echo " View status: systemctl status $UNIT_NAME"
542 echo " View logs: journalctl -u $UNIT_NAME"
543 fi
544
545 # 2. Trigger remote build (on aomi)
546 if [ "$REMOTE_BUILD_ENABLED" = "true" ]; then
547 /etc/git-hooks/forward-build.sh "$REPO_NAME" "$BUILD_TYPE"
548 fi
549 '';
550 mode = "0755";
551 };
552
553 # Git directory permissions are set in the systemd.tmpfiles.rules above (with flux)
554
555 # Disable TPM2 (VPS has no TPM hardware)
556 security.tpm2.enable = lib.mkForce false;
557
558 # Override common SSH config to restrict to VPN network only
559 # SSH only on VPN interface — no public access
560 services.openssh = {
561 listenAddresses = [
562 {
563 addr = builtins.head globals.machines.carthage.net.vpn.ips;
564 port = 22;
565 }
566 ];
567 openFirewall = lib.mkForce false;
568 };
569
570 services.wireguard.server = {
571 enable = true;
572 ips = libx.wg-ips globals.machines.carthage.net.vpn.ips;
573 peers = libx.generateWireguardPeers globals.machines;
574 };
575
576 services.gosmee = {
577 enable = true;
578 public-url = "https://webhook.sbr.pm";
579 };
580
581 services.ntfy-sh = {
582 enable = true;
583 settings = {
584 base-url = "https://ntfy.sbr.pm";
585 upstream-base-url = "https://ntfy.sh";
586 listen-http = "localhost:8111";
587 behind-proxy = true;
588 enable-login = true;
589 auth-default-access = "deny-all";
590 };
591 };
592
593 # Firewall configuration
594 # TODO: Migrate to nftables once wireguard server module supports it
595 networking.firewall = {
596 allowPing = true;
597 # Public ports
598 allowedTCPPorts = [
599 80 # HTTP
600 443 # HTTPS
601 ];
602
603 # Additional iptables rules
604 extraCommands = ''
605 # Allow node exporter (9000) only from VPN network
606 iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 9000 -j nixos-fw-accept
607
608 # Allow microsocks SOCKS5 proxy (1080) only from VPN network
609 iptables -A nixos-fw -p tcp -s 10.100.0.0/24 --dport 1080 -j nixos-fw-accept
610
611 # Block known SYN flood source (USBINF INFORMATICA LTDA, Brazil)
612 iptables -I nixos-fw 1 -s 45.233.176.0/22 -j DROP
613 ip6tables -I nixos-fw 1 -s ::ffff:45.233.176.0/118 -j DROP
614
615 # SYN flood protection: limit new connections per /24 subnet
616 iptables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
617 ip6tables -A nixos-fw -p tcp --syn -m connlimit --connlimit-above 30 --connlimit-mask 24 -j DROP
618 '';
619 };
620 # ATProto PDS (Personal Data Server)
621 age.secrets."pds.env" = {
622 file = ../../secrets/carthage/pds.env.age;
623 mode = "400";
624 owner = "pds";
625 group = "pds";
626 };
627
628 age.secrets."restic-aix-password" = {
629 file = ../../secrets/carthage/restic-aix-password.age;
630 mode = "400";
631 owner = "vincent";
632 group = "users";
633 };
634
635 services.restic.backups.aix-pds = {
636 user = "vincent";
637 repository = "sftp:vincent@aix.sbr.pm:/data/backup/restic/carthage";
638 passwordFile = config.age.secrets."restic-aix-password".path;
639
640 paths = [
641 "/tmp/pds-backup" # Snapshot copy of PDS data
642 ];
643
644 timerConfig = {
645 OnCalendar = "daily";
646 Persistent = true;
647 RandomizedDelaySec = "1h";
648 };
649
650 pruneOpts = [
651 "--keep-daily 7"
652 "--keep-weekly 4"
653 "--keep-monthly 12"
654 ];
655
656 extraBackupArgs = [
657 "--exclude-caches"
658 "--verbose"
659 ];
660
661 checkOpts = [
662 "--read-data-subset=5%"
663 ];
664
665 backupPrepareCommand = ''
666 ${pkgs.coreutils}/bin/rm -rf /tmp/pds-backup
667 ${pkgs.sudo}/bin/sudo ${pkgs.rsync}/bin/rsync -a --delete /var/lib/pds/ /tmp/pds-backup/
668 ${pkgs.curl}/bin/curl \
669 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
670 config.age.secrets."ntfy-token".path
671 })" \
672 -H "Title: Restic Backup Starting (carthage)" \
673 -d "Starting PDS backup to aix" \
674 https://ntfy.sbr.pm/backups
675 '';
676
677 backupCleanupCommand = ''
678 ${pkgs.curl}/bin/curl \
679 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
680 config.age.secrets."ntfy-token".path
681 })" \
682 -H "Title: Restic Backup Complete (carthage)" \
683 -H "Tags: white_check_mark" \
684 -d "PDS backup to aix completed successfully" \
685 https://ntfy.sbr.pm/backups || \
686 ${pkgs.curl}/bin/curl \
687 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
688 config.age.secrets."ntfy-token".path
689 })" \
690 -H "Title: Restic Backup Failed (carthage)" \
691 -H "Tags: x,warning" \
692 -H "Priority: high" \
693 -d "PDS backup to aix failed! Check logs: journalctl -u restic-backups-aix-pds.service" \
694 https://ntfy.sbr.pm/backups
695 '';
696 };
697
698 services.bluesky-pds = {
699 enable = true;
700 pdsadmin.enable = true;
701 goat.enable = true;
702 settings = {
703 PDS_HOSTNAME = "pds.demeester.fr";
704 PDS_DATA_DIRECTORY = "/var/lib/pds";
705 PDS_BLOBSTORE_DISK_LOCATION = "/var/lib/pds/blocks";
706 PDS_INVITE_REQUIRED = "true";
707 };
708 environmentFiles = [
709 config.age.secrets."pds.env".path
710 ];
711 };
712
713 # Allow Caddy to access public git repositories only (override ProtectHome)
714 systemd.services.caddy.serviceConfig = {
715 ProtectHome = lib.mkForce "tmpfs"; # Allow read access to /home with bind mounts
716 BindReadOnlyPaths = [ "/home/vincent/git/public" ];
717 };
718
719 services.caddy = {
720 enable = true;
721 email = "vincent@sbr.pm";
722
723 # Use Caddy with rate-limit plugin
724 package = pkgs.caddy.withPlugins {
725 plugins = [ "github.com/mholt/caddy-ratelimit@v0.1.1-0.20250915152450-04ea34edc0c4" ];
726 hash = "sha256-x5jeKfjXeUj4t5t6+gRRjpmjF3n5P25T0lVED4EEu54=";
727 };
728
729 # Enable Prometheus metrics on VPN interface only
730 globalConfig = ''
731 admin ${builtins.head globals.machines.carthage.net.vpn.ips}:2019
732 metrics
733 servers {
734 log_credentials
735 }
736 '';
737
738 # Enable JSON access logging (NixOS option)
739 logFormat = ''
740 output file /var/log/caddy/access.log {
741 roll_size 100MiB
742 roll_keep 10
743 roll_keep_for 720h
744 }
745 format json
746 '';
747
748 virtualHosts = {
749 # File server with directory browsing (replaces fancyindex)
750 "dl.sbr.pm".extraConfig = ''
751 ${blockAIBotsSnippet}
752 ${robotsTxtSnippet}
753
754 root * /var/www/dl.sbr.pm
755 file_server browse {
756 hide .fancyindex README.md HEADER.md
757 }
758
759 ${securityHeaders}
760 '';
761
762 # Alias for dl.sbr.pm
763 "files.sbr.pm".extraConfig = ''
764 redir https://dl.sbr.pm{uri} permanent
765 '';
766
767 # ntfy - reverse proxy with websockets
768 "ntfy.sbr.pm".extraConfig = ''
769 # Rate limiting for notification service
770 rate_limit {
771 zone ntfy_publish {
772 key {remote_host}
773 events 50
774 window 1m
775 }
776 }
777
778 reverse_proxy localhost:8111
779 '';
780
781 # Static sites
782 "paste.sbr.pm".extraConfig = ''
783 ${blockAIBotsSnippet}
784 ${robotsTxtSnippet}
785
786 root * /var/www/paste.sbr.pm
787 file_server
788 ${securityHeaders}
789 '';
790
791 "sbr.pm".extraConfig = ''
792 ${blockAIBotsSnippet}
793 ${robotsTxtSnippet}
794
795 root * /var/www/sbr.pm
796 file_server
797 ${securityHeaders}
798 '';
799
800 # Go vanity URL service
801 "go.sbr.pm".extraConfig = ''
802 reverse_proxy localhost:8080
803 ${securityHeaders}
804 '';
805
806 # Whoami service (remote)
807 "whoami.sbr.pm".extraConfig = ''
808 reverse_proxy 10.100.0.8:80 {
809 header_up Host {host}
810 }
811 '';
812
813 # Immich photo management (proxied to rhea)
814 "immich.sbr.pm".extraConfig = ''
815 ${blockAIBotsSnippet}
816 ${robotsTxtSnippet}
817
818 # Allow large photo/video uploads (50GB limit)
819 request_body {
820 max_size 50GB
821 }
822
823 # Strict rate limiting for authentication endpoints
824 @auth {
825 path /auth/* /api/auth/*
826 }
827 route @auth {
828 rate_limit {
829 zone immich_auth {
830 key {remote_host}
831 events 10
832 window 1m
833 }
834 }
835 reverse_proxy 10.100.0.50:2283 {
836 header_up Host {host}
837 header_up X-Real-IP {remote_host}
838 }
839 }
840
841 # Moderate rate limiting for API endpoints
842 @api {
843 path /api/*
844 }
845 route @api {
846 rate_limit {
847 zone immich_api {
848 key {remote_host}
849 events 100
850 window 1m
851 }
852 }
853 reverse_proxy 10.100.0.50:2283 {
854 header_up Host {host}
855 header_up X-Real-IP {remote_host}
856 }
857 }
858
859 # Permissive rate limiting for media/general requests
860 rate_limit {
861 zone immich_media {
862 key {remote_host}
863 events 1000
864 window 1m
865 }
866 }
867
868 reverse_proxy 10.100.0.50:2283 {
869 header_up Host {host}
870 header_up X-Real-IP {remote_host}
871 }
872
873 ${mediaSecurityHeaders}
874 '';
875
876 # Navidrome music streaming (proxied to aion)
877 "navidrome.sbr.pm".extraConfig = ''
878 ${blockAIBotsSnippet}
879 ${robotsTxtSnippet}
880
881 # Rate limiting for music streaming
882 rate_limit {
883 zone navidrome_general {
884 key {remote_host}
885 events 500
886 window 1m
887 }
888 }
889
890 reverse_proxy 10.100.0.49:4533 {
891 header_up Host {host}
892 header_up X-Real-IP {remote_host}
893 }
894
895 ${mediaSecurityHeaders}
896 '';
897
898 # Jellyfin media server (proxied to rhea)
899 "jellyfin.sbr.pm".extraConfig = ''
900 ${blockAIBotsSnippet}
901 ${robotsTxtSnippet}
902
903 # Rate limiting for media server
904 rate_limit {
905 zone jellyfin_general {
906 key {remote_host}
907 events 500
908 window 1m
909 }
910 }
911
912 reverse_proxy 10.100.0.50:8096 {
913 header_up Host {host}
914 header_up X-Real-IP {remote_host}
915 }
916
917 ${mediaSecurityHeaders}
918 '';
919
920 # Audiobookshelf audiobook server (proxied to aion)
921 "audiobookshelf.sbr.pm".extraConfig = ''
922 ${blockAIBotsSnippet}
923 ${robotsTxtSnippet}
924
925 # Rate limiting for audiobook streaming
926 rate_limit {
927 zone audiobookshelf_general {
928 key {remote_host}
929 events 500
930 window 1m
931 }
932 }
933
934 reverse_proxy 10.100.0.49:13378 {
935 header_up Host {host}
936 header_up X-Real-IP {remote_host}
937 }
938
939 ${mediaSecurityHeaders}
940 '';
941
942 # Service aliases (user-friendly URLs - transparent proxy)
943 "music.sbr.pm".extraConfig = ''
944 ${blockAIBotsSnippet}
945 ${robotsTxtSnippet}
946
947 # Rate limiting for music streaming
948 rate_limit {
949 zone music_general {
950 key {remote_host}
951 events 500
952 window 1m
953 }
954 }
955
956 reverse_proxy 10.100.0.49:4533 {
957 header_up Host {host}
958 header_up X-Real-IP {remote_host}
959 }
960
961 ${mediaSecurityHeaders}
962 '';
963
964 "photos.sbr.pm".extraConfig = ''
965 ${blockAIBotsSnippet}
966 ${robotsTxtSnippet}
967
968 # Allow large photo/video uploads (50GB limit)
969 request_body {
970 max_size 50GB
971 }
972
973 # Strict rate limiting for authentication endpoints
974 @auth {
975 path /auth/* /api/auth/*
976 }
977 route @auth {
978 rate_limit {
979 zone photos_auth {
980 key {remote_host}
981 events 10
982 window 1m
983 }
984 }
985 reverse_proxy 10.100.0.50:2283 {
986 header_up Host {host}
987 header_up X-Real-IP {remote_host}
988 }
989 }
990
991 # Moderate rate limiting for API endpoints
992 @api {
993 path /api/*
994 }
995 route @api {
996 rate_limit {
997 zone photos_api {
998 key {remote_host}
999 events 100
1000 window 1m
1001 }
1002 }
1003 reverse_proxy 10.100.0.50:2283 {
1004 header_up Host {host}
1005 header_up X-Real-IP {remote_host}
1006 }
1007 }
1008
1009 # Permissive rate limiting for media/general requests
1010 rate_limit {
1011 zone photos_media {
1012 key {remote_host}
1013 events 1000
1014 window 1m
1015 }
1016 }
1017
1018 reverse_proxy 10.100.0.50:2283 {
1019 header_up Host {host}
1020 header_up X-Real-IP {remote_host}
1021 }
1022
1023 ${mediaSecurityHeaders}
1024 '';
1025
1026 "podcasts.sbr.pm".extraConfig = ''
1027 ${blockAIBotsSnippet}
1028 ${robotsTxtSnippet}
1029
1030 # Rate limiting for audiobook streaming
1031 rate_limit {
1032 zone podcasts_general {
1033 key {remote_host}
1034 events 500
1035 window 1m
1036 }
1037 }
1038
1039 reverse_proxy 10.100.0.49:13378 {
1040 header_up Host {host}
1041 header_up X-Real-IP {remote_host}
1042 }
1043
1044 ${mediaSecurityHeaders}
1045 '';
1046
1047 # HTTP echo — echoes request headers (debug tool)
1048 # Logs all headers (including Authorization) to a dedicated log file
1049 "echo.sbr.pm".extraConfig = ''
1050 log {
1051 output file /var/log/caddy/echo-requests.log {
1052 roll_size 10MiB
1053 roll_keep 5
1054 roll_keep_for 720h
1055 }
1056 format json
1057 }
1058
1059 root * ${echoTemplate}
1060 templates {
1061 mime text/plain
1062 }
1063 try_files /index.txt
1064 file_server
1065 '';
1066
1067 # Webhook/gosmee service with SSE support
1068 "webhook.sbr.pm".extraConfig = ''
1069 reverse_proxy localhost:3333 {
1070 flush_interval -1
1071 }
1072 '';
1073
1074 # ATProto PDS
1075 "pds.demeester.fr".extraConfig = ''
1076 reverse_proxy localhost:3000 {
1077 header_up Host {host}
1078 }
1079 '';
1080
1081 # Personal website with directory browsing
1082 "vincent.demeester.fr".extraConfig = ''
1083 ${blockAIBotsSnippet}
1084 ${robotsTxtSnippet}
1085
1086 root * /var/www/vincent.demeester.fr
1087
1088 # Try files with .html extension
1089 try_files {path} {path}.html {path}/ /index.html
1090
1091 file_server browse {
1092 hide .fancyindex README.md HEADER.md
1093 }
1094
1095 ${securityHeaders}
1096 '';
1097
1098 # Self-hosted git repositories (public only)
1099 "git.sbr.pm".extraConfig = ''
1100 ${blockAIBotsSnippet}
1101 ${robotsTxtSnippet}
1102
1103 root * /home/vincent/git/public
1104 file_server browse {
1105 hide .fancyindex README.md HEADER.md
1106 }
1107
1108 ${gitSecurityHeaders}
1109 '';
1110 };
1111 };
1112
1113 services.govanityurl = {
1114 enable = true;
1115 user = "caddy";
1116 host = "go.sbr.pm";
1117 config = ''
1118 paths:
1119 /x:
1120 repo: https://github.com/vdemeester/x
1121 /lord:
1122 repo: https://github.com/vdemeester/lord
1123 /ape:
1124 repo: https://git.sr.ht/~vdemeester/ape
1125 /nr:
1126 repo: https://git.sr.ht/~vdemeester/nr
1127 /ram:
1128 repo: https://git.sr.ht/~vdemeester/ram
1129 /sec:
1130 repo: https://git.sr.ht/~vdemeester/sec
1131 '';
1132 };
1133 security.acme = {
1134 acceptTerms = true;
1135 defaults.email = "vincent@sbr.pm";
1136 };
1137}