flake-update-20260201
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"
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 ../common/services/prometheus-exporters-node.nix
104 ../common/services/openssh.nix
105 ];
106
107 # Age secrets
108 age.secrets."ntfy-token" = {
109 file = ../../secrets/sakhalin/ntfy-token.age;
110 mode = "400";
111 owner = "root";
112 group = "root";
113 };
114
115 # TODO make it an option ? (otherwise I'll add it for all)
116 users.users.vincent.linger = true;
117
118 # Allow Caddy to access git repositories in vincent's home
119 users.users.caddy.extraGroups = [ "users" ];
120
121 # Allow vincent to run systemd-run without password (for git hooks)
122 # Use /run/current-system/sw/bin path to avoid hardcoded Nix store paths
123 security.sudo.extraRules = [
124 {
125 users = [ "vincent" ];
126 commands = [
127 {
128 command = "/run/current-system/sw/bin/systemd-run";
129 options = [ "NOPASSWD" ];
130 }
131 ];
132 }
133 ];
134
135 # Install gitmal for self-hosted git web view
136 environment.systemPackages = with pkgs; [
137 gitmal
138 ];
139
140 # Git hook background task execution with notifications
141 systemd.services."git-notify@" = {
142 description = "Git build notification for %i";
143 serviceConfig = {
144 Type = "oneshot";
145 ExecStart = "${pkgs.writeShellScript "git-notify" ''
146 #!/usr/bin/env bash
147 set -euo pipefail
148
149 UNIT_NAME="$1"
150 RESULT=$(${pkgs.systemd}/bin/systemctl show -p Result --value "$UNIT_NAME")
151 EXIT_CODE=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStatus --value "$UNIT_NAME")
152
153 # Get execution timestamps (in microseconds since epoch)
154 START_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainStartTimestamp --value "$UNIT_NAME")
155 EXIT_TIME=$(${pkgs.systemd}/bin/systemctl show -p ExecMainExitTimestamp --value "$UNIT_NAME")
156
157 # Calculate duration in seconds
158 START_EPOCH=$(${pkgs.coreutils}/bin/date -d "$START_TIME" +%s 2>/dev/null || echo "0")
159 EXIT_EPOCH=$(${pkgs.coreutils}/bin/date -d "$EXIT_TIME" +%s 2>/dev/null || echo "0")
160 DURATION=$((EXIT_EPOCH - START_EPOCH))
161
162 # Format duration as human-readable
163 if [ "$DURATION" -ge 60 ]; then
164 MINUTES=$((DURATION / 60))
165 SECONDS=$((DURATION % 60))
166 DURATION_STR="''${MINUTES}m ''${SECONDS}s"
167 else
168 DURATION_STR="''${DURATION}s"
169 fi
170
171 # Parse unit name to extract job type and repo
172 # Format: git-<job>-<repo>-<timestamp>
173 JOB_TYPE=$(echo "$UNIT_NAME" | cut -d'-' -f2)
174 REPO=$(echo "$UNIT_NAME" | cut -d'-' -f3)
175
176 # Only notify on failure
177 if [ "$RESULT" != "success" ]; then
178 ${pkgs.curl}/bin/curl -s \
179 -H "Authorization: Bearer $(${pkgs.coreutils}/bin/tr -d '\n' < ${
180 config.age.secrets."ntfy-token".path
181 })" \
182 -H "Title: ❌ Git $JOB_TYPE Failed: $REPO (after $DURATION_STR)" \
183 -H "Priority: high" \
184 -H "Tags: x,git,$JOB_TYPE,warning" \
185 -d "Job $UNIT_NAME failed after $DURATION_STR (exit code: $EXIT_CODE). Check logs: journalctl -u $UNIT_NAME" \
186 "https://ntfy.sbr.pm/git-builds" || true
187 fi
188 ''} %i";
189 };
190 };
191
192 # Helper script for gitmal generation (called from post-receive hooks)
193 environment.etc."git-hooks/generate-gitmal.sh" = {
194 text = ''
195 #!${pkgs.bash}/bin/bash
196 set -euo pipefail
197
198 # Set PATH to include git and coreutils (gitmal needs git, script needs basename)
199 export PATH="${pkgs.git}/bin:${pkgs.coreutils}/bin:$PATH"
200
201 REPO_PATH="$1"
202 THEME="''${2:-github-dark}" # Default to 'github-dark' theme if not specified
203 REPO_NAME=$(basename "$REPO_PATH" .git)
204 OUTPUT_DIR="/home/vincent/git/public/$REPO_NAME"
205
206 echo "Generating gitmal for repository: $REPO_NAME"
207 echo "Repository path: $REPO_PATH"
208 echo "Output directory: $OUTPUT_DIR"
209 echo "Theme: $THEME"
210
211 # Generate static site with gitmal
212 cd "$REPO_PATH"
213 ${pkgs.gitmal}/bin/gitmal --output "$OUTPUT_DIR" --theme "$THEME"
214
215 echo "Gitmal generation complete: $OUTPUT_DIR"
216 '';
217 mode = "0755";
218 };
219
220 # Remote build forwarding script
221 environment.etc."git-hooks/forward-build.sh" = {
222 text = ''
223 #!${pkgs.bash}/bin/bash
224 set -euo pipefail
225
226 REPO_NAME="$1"
227 BUILD_TYPE="''${2:-auto}"
228 TIMESTAMP=$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S)
229 UNIT_NAME="build-remote-''${REPO_NAME}-''${TIMESTAMP}"
230
231 echo "Forwarding build to aomi: $REPO_NAME ($BUILD_TYPE)..."
232
233 # SSH to aomi and trigger build with systemd-run
234 ${pkgs.openssh}/bin/ssh -o BatchMode=yes builder@10.100.0.17 \
235 "sudo /run/current-system/sw/bin/systemd-run \
236 --unit=\"$UNIT_NAME\" \
237 --description=\"Remote build: $REPO_NAME ($BUILD_TYPE)\" \
238 --property=\"OnSuccess=job-notify@\''${UNIT_NAME}.service\" \
239 --property=\"OnFailure=job-notify@\''${UNIT_NAME}.service\" \
240 --property=\"User=builder\" \
241 --property=\"Group=users\" \
242 --property=\"WorkingDirectory=/var/lib/git-builds\" \
243 /etc/git-builds/execute-build.sh \"$REPO_NAME\" \"$BUILD_TYPE\""
244
245 echo "✓ Build queued on aomi: $UNIT_NAME"
246 echo " View status: ssh aomi 'systemctl status $UNIT_NAME'"
247 echo " View logs: ssh aomi 'journalctl -u $UNIT_NAME'"
248 '';
249 mode = "0755";
250 };
251
252 # Example post-receive hook template
253 environment.etc."git-hooks/post-receive.example" = {
254 text = ''
255 #!${pkgs.bash}/bin/bash
256 # Example post-receive hook for git repositories
257 # Copy this to your repository's hooks/post-receive and make it executable
258 #
259 # This hook uses systemd-run to execute gitmal generation in the background
260 # with automatic notifications via ntfy when the job completes.
261 #
262 # Optionally, it can also trigger remote builds on aomi.
263
264 set -euo pipefail
265
266 # Configuration
267 GITMAL_ENABLED="true"
268 GITMAL_THEME="github-dark" # Options: github-dark, github-light, dark, light, auto
269 REMOTE_BUILD_ENABLED="false" # Set to "true" to enable remote builds on aomi
270 BUILD_TYPE="nixos" # Options: nixos, make, docker, go, custom, auto
271
272 REPO_PATH="$(pwd)"
273 REPO_NAME=$(basename "$REPO_PATH" .git)
274 TIMESTAMP=$(date +%Y%m%d-%H%M%S)
275
276 # 1. Generate gitmal (local static site on kerkouane)
277 if [ "$GITMAL_ENABLED" = "true" ]; then
278 UNIT_NAME="git-gitmal-''${REPO_NAME}-''${TIMESTAMP}"
279 echo "Queuing gitmal generation for $REPO_NAME with theme: $GITMAL_THEME..."
280
281 sudo /run/current-system/sw/bin/systemd-run \
282 --unit="$UNIT_NAME" \
283 --description="Gitmal generation for $REPO_NAME" \
284 --property="OnSuccess=git-notify@''${UNIT_NAME}.service" \
285 --property="OnFailure=git-notify@''${UNIT_NAME}.service" \
286 --property="User=vincent" \
287 --property="Group=users" \
288 --working-directory="$REPO_PATH" \
289 /etc/git-hooks/generate-gitmal.sh "$REPO_PATH" "$GITMAL_THEME"
290
291 echo "✓ Gitmal generation queued as: $UNIT_NAME"
292 echo " View status: systemctl status $UNIT_NAME"
293 echo " View logs: journalctl -u $UNIT_NAME"
294 fi
295
296 # 2. Trigger remote build (on aomi)
297 if [ "$REMOTE_BUILD_ENABLED" = "true" ]; then
298 /etc/git-hooks/forward-build.sh "$REPO_NAME" "$BUILD_TYPE"
299 fi
300 '';
301 mode = "0755";
302 };
303
304 # Setup permissions for git directories (via systemd tmpfiles)
305 systemd.tmpfiles.rules = [
306 "d /home/vincent 0711 vincent users -" # Allow traversal to git directory
307 "d /home/vincent/git 0700 vincent users -" # Private git directory
308 "d /home/vincent/git/public 0755 vincent users -" # Public repositories only
309 "d /var/log/git-builds 0755 vincent users -" # Git build logs
310 ];
311
312 # Disable TPM2 (VPS has no TPM hardware)
313 security.tpm2.enable = lib.mkForce false;
314
315 # Override common SSH config to restrict to VPN network only
316 services.openssh = {
317 listenAddresses = [
318 {
319 addr = builtins.head globals.machines.kerkouane.net.vpn.ips;
320 port = 22;
321 }
322 ];
323 openFirewall = lib.mkForce false;
324 };
325
326 services.wireguard.server = {
327 enable = true;
328 ips = libx.wg-ips globals.machines.kerkouane.net.vpn.ips;
329 peers = libx.generateWireguardPeers globals.machines;
330 };
331
332 services.gosmee = {
333 enable = true;
334 public-url = "https://webhook.sbr.pm";
335 };
336
337 services.ntfy-sh = {
338 enable = true;
339 settings = {
340 base-url = "https://ntfy.sbr.pm";
341 upstream-base-url = "https://ntfy.sh";
342 listen-http = "localhost:8111";
343 behind-proxy = true;
344 enable-login = true;
345 auth-default-access = "deny-all";
346 };
347 };
348
349 # Firewall configuration
350 # TODO: Migrate to nftables once wireguard server module supports it
351 networking.firewall = {
352 allowPing = true;
353 # Public ports
354 allowedTCPPorts = [
355 80 # HTTP
356 443 # HTTPS
357 ];
358
359 # Additional iptables rules
360 extraCommands = ''
361 # Allow node exporter (9000) only from VPN network
362 iptables -A nixos-fw -p tcp -s 10.100.0.0/16 --dport 9000 -j nixos-fw-accept
363 '';
364 };
365 # Allow Caddy to access public git repositories only (override ProtectHome)
366 systemd.services.caddy.serviceConfig = {
367 ProtectHome = lib.mkForce "tmpfs"; # Allow read access to /home with bind mounts
368 BindReadOnlyPaths = [ "/home/vincent/git/public" ];
369 };
370
371 services.caddy = {
372 enable = true;
373 email = "vincent@sbr.pm";
374
375 # Use Caddy with rate-limit plugin
376 package = pkgs.caddy.withPlugins {
377 plugins = [ "github.com/mholt/caddy-ratelimit@v0.1.1-0.20250915152450-04ea34edc0c4" ];
378 hash = "sha256-h3FJ2zUJiWbkC6Gop9Xj897Qt5K3px0zgXgLXykekRA=";
379 };
380
381 # Enable Prometheus metrics on VPN interface only
382 globalConfig = ''
383 admin ${builtins.head globals.machines.kerkouane.net.vpn.ips}:2019
384 metrics
385 '';
386
387 # Enable JSON access logging (NixOS option)
388 logFormat = ''
389 output file /var/log/caddy/access.log {
390 roll_size 100MiB
391 roll_keep 10
392 roll_keep_for 720h
393 }
394 format json
395 '';
396
397 virtualHosts = {
398 # File server with directory browsing (replaces fancyindex)
399 "dl.sbr.pm".extraConfig = ''
400 ${blockAIBotsSnippet}
401 ${robotsTxtSnippet}
402
403 root * /var/www/dl.sbr.pm
404 file_server browse {
405 hide .fancyindex README.md HEADER.md
406 }
407
408 ${securityHeaders}
409 '';
410
411 # Alias for dl.sbr.pm
412 "files.sbr.pm".extraConfig = ''
413 redir https://dl.sbr.pm{uri} permanent
414 '';
415
416 # ntfy - reverse proxy with websockets
417 "ntfy.sbr.pm".extraConfig = ''
418 # Rate limiting for notification service
419 rate_limit {
420 zone ntfy_publish {
421 key {remote_host}
422 events 50
423 window 1m
424 }
425 }
426
427 reverse_proxy localhost:8111
428 '';
429
430 # Static sites
431 "paste.sbr.pm".extraConfig = ''
432 ${blockAIBotsSnippet}
433 ${robotsTxtSnippet}
434
435 root * /var/www/paste.sbr.pm
436 file_server
437 ${securityHeaders}
438 '';
439
440 "sbr.pm".extraConfig = ''
441 ${blockAIBotsSnippet}
442 ${robotsTxtSnippet}
443
444 root * /var/www/sbr.pm
445 file_server
446 ${securityHeaders}
447 '';
448
449 # Go vanity URL service
450 "go.sbr.pm".extraConfig = ''
451 reverse_proxy localhost:8080
452 ${securityHeaders}
453 '';
454
455 # Whoami service (remote)
456 "whoami.sbr.pm".extraConfig = ''
457 reverse_proxy 10.100.0.8:80 {
458 header_up Host {host}
459 }
460 '';
461
462 # Immich photo management (proxied to rhea)
463 "immich.sbr.pm".extraConfig = ''
464 ${blockAIBotsSnippet}
465 ${robotsTxtSnippet}
466
467 # Allow large photo/video uploads (50GB limit)
468 request_body {
469 max_size 50GB
470 }
471
472 # Strict rate limiting for authentication endpoints
473 @auth {
474 path /auth/* /api/auth/*
475 }
476 route @auth {
477 rate_limit {
478 zone immich_auth {
479 key {remote_host}
480 events 10
481 window 1m
482 }
483 }
484 reverse_proxy 10.100.0.50:2283 {
485 header_up Host {host}
486 header_up X-Real-IP {remote_host}
487 }
488 }
489
490 # Moderate rate limiting for API endpoints
491 @api {
492 path /api/*
493 }
494 route @api {
495 rate_limit {
496 zone immich_api {
497 key {remote_host}
498 events 100
499 window 1m
500 }
501 }
502 reverse_proxy 10.100.0.50:2283 {
503 header_up Host {host}
504 header_up X-Real-IP {remote_host}
505 }
506 }
507
508 # Permissive rate limiting for media/general requests
509 rate_limit {
510 zone immich_media {
511 key {remote_host}
512 events 1000
513 window 1m
514 }
515 }
516
517 reverse_proxy 10.100.0.50:2283 {
518 header_up Host {host}
519 header_up X-Real-IP {remote_host}
520 }
521
522 ${mediaSecurityHeaders}
523 '';
524
525 # Navidrome music streaming (proxied to aion)
526 "navidrome.sbr.pm".extraConfig = ''
527 ${blockAIBotsSnippet}
528 ${robotsTxtSnippet}
529
530 # Rate limiting for music streaming
531 rate_limit {
532 zone navidrome_general {
533 key {remote_host}
534 events 500
535 window 1m
536 }
537 }
538
539 reverse_proxy 10.100.0.49:4533 {
540 header_up Host {host}
541 header_up X-Real-IP {remote_host}
542 }
543
544 ${mediaSecurityHeaders}
545 '';
546
547 # Jellyfin media server (proxied to rhea)
548 "jellyfin.sbr.pm".extraConfig = ''
549 ${blockAIBotsSnippet}
550 ${robotsTxtSnippet}
551
552 # Rate limiting for media server
553 rate_limit {
554 zone jellyfin_general {
555 key {remote_host}
556 events 500
557 window 1m
558 }
559 }
560
561 reverse_proxy 10.100.0.50:8096 {
562 header_up Host {host}
563 header_up X-Real-IP {remote_host}
564 }
565
566 ${mediaSecurityHeaders}
567 '';
568
569 # Audiobookshelf audiobook server (proxied to aion)
570 "audiobookshelf.sbr.pm".extraConfig = ''
571 ${blockAIBotsSnippet}
572 ${robotsTxtSnippet}
573
574 # Rate limiting for audiobook streaming
575 rate_limit {
576 zone audiobookshelf_general {
577 key {remote_host}
578 events 500
579 window 1m
580 }
581 }
582
583 reverse_proxy 10.100.0.49:13378 {
584 header_up Host {host}
585 header_up X-Real-IP {remote_host}
586 }
587
588 ${mediaSecurityHeaders}
589 '';
590
591 # Service aliases (user-friendly URLs - transparent proxy)
592 "music.sbr.pm".extraConfig = ''
593 ${blockAIBotsSnippet}
594 ${robotsTxtSnippet}
595
596 # Rate limiting for music streaming
597 rate_limit {
598 zone music_general {
599 key {remote_host}
600 events 500
601 window 1m
602 }
603 }
604
605 reverse_proxy 10.100.0.49:4533 {
606 header_up Host {host}
607 header_up X-Real-IP {remote_host}
608 }
609
610 ${mediaSecurityHeaders}
611 '';
612
613 "photos.sbr.pm".extraConfig = ''
614 ${blockAIBotsSnippet}
615 ${robotsTxtSnippet}
616
617 # Allow large photo/video uploads (50GB limit)
618 request_body {
619 max_size 50GB
620 }
621
622 # Strict rate limiting for authentication endpoints
623 @auth {
624 path /auth/* /api/auth/*
625 }
626 route @auth {
627 rate_limit {
628 zone photos_auth {
629 key {remote_host}
630 events 10
631 window 1m
632 }
633 }
634 reverse_proxy 10.100.0.50:2283 {
635 header_up Host {host}
636 header_up X-Real-IP {remote_host}
637 }
638 }
639
640 # Moderate rate limiting for API endpoints
641 @api {
642 path /api/*
643 }
644 route @api {
645 rate_limit {
646 zone photos_api {
647 key {remote_host}
648 events 100
649 window 1m
650 }
651 }
652 reverse_proxy 10.100.0.50:2283 {
653 header_up Host {host}
654 header_up X-Real-IP {remote_host}
655 }
656 }
657
658 # Permissive rate limiting for media/general requests
659 rate_limit {
660 zone photos_media {
661 key {remote_host}
662 events 1000
663 window 1m
664 }
665 }
666
667 reverse_proxy 10.100.0.50:2283 {
668 header_up Host {host}
669 header_up X-Real-IP {remote_host}
670 }
671
672 ${mediaSecurityHeaders}
673 '';
674
675 "podcasts.sbr.pm".extraConfig = ''
676 ${blockAIBotsSnippet}
677 ${robotsTxtSnippet}
678
679 # Rate limiting for audiobook streaming
680 rate_limit {
681 zone podcasts_general {
682 key {remote_host}
683 events 500
684 window 1m
685 }
686 }
687
688 reverse_proxy 10.100.0.49:13378 {
689 header_up Host {host}
690 header_up X-Real-IP {remote_host}
691 }
692
693 ${mediaSecurityHeaders}
694 '';
695
696 # Webhook/gosmee service with SSE support
697 "webhook.sbr.pm".extraConfig = ''
698 reverse_proxy localhost:3333 {
699 flush_interval -1
700 }
701 '';
702
703 # Personal website with directory browsing
704 "vincent.demeester.fr".extraConfig = ''
705 ${blockAIBotsSnippet}
706 ${robotsTxtSnippet}
707
708 root * /var/www/vincent.demeester.fr
709
710 # Try files with .html extension
711 try_files {path} {path}.html {path}/ /index.html
712
713 file_server browse {
714 hide .fancyindex README.md HEADER.md
715 }
716
717 ${securityHeaders}
718 '';
719
720 # Self-hosted git repositories (public only)
721 "git.sbr.pm".extraConfig = ''
722 ${blockAIBotsSnippet}
723 ${robotsTxtSnippet}
724
725 root * /home/vincent/git/public
726 file_server browse {
727 hide .fancyindex README.md HEADER.md
728 }
729
730 ${gitSecurityHeaders}
731 '';
732 };
733 };
734
735 services.govanityurl = {
736 enable = true;
737 user = "caddy";
738 host = "go.sbr.pm";
739 config = ''
740 paths:
741 /x:
742 repo: https://github.com/vdemeester/x
743 /lord:
744 repo: https://github.com/vdemeester/lord
745 /ape:
746 repo: https://git.sr.ht/~vdemeester/ape
747 /nr:
748 repo: https://git.sr.ht/~vdemeester/nr
749 /ram:
750 repo: https://git.sr.ht/~vdemeester/ram
751 /sec:
752 repo: https://git.sr.ht/~vdemeester/sec
753 '';
754 };
755 security.acme = {
756 acceptTerms = true;
757 defaults.email = "vincent@sbr.pm";
758 };
759}