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