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