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