{ config, lib, pkgs, homeyConfig, ... }: # Caddy reverse proxy. # # Features: # - DNS-01 ACME via Cloudflare API → real wildcard cert for *.zakobar.com # - forward_auth to Authelia for protected vhosts # - Plain reverse_proxy for public vhosts (authelia itself, nextcloud) # - Listens on :80 (redirect) and :443 (TLS) # # Because nixpkgs ships Caddy without the cloudflare DNS plugin by default, # we build a custom Caddy with it using the xcaddy wrapper from nixpkgs. # # Secrets consumed from sops: # cloudflare/api_token let cfg = config.homey.caddy; domain = homeyConfig.domain; # Build Caddy with the Cloudflare DNS plugin using the nixos-25.05 API. # `withPlugins` is a passthru function on the caddy package; it uses xcaddy # under the hood to produce a fixed-output derivation. caddyWithCloudflare = pkgs.caddy.withPlugins { plugins = [ # v0.2.4 tag points to commit a8737d0 which includes the fix for # cfut_/cfat_ token format validation (PR #123). "github.com/caddy-dns/cloudflare@v0.2.4" ]; hash = "sha256-pRrLBlYRaAyMYwPXeTy4WqWNRu/L9K6Mn2src11dGh8="; }; # Reverse-proxy snippet for cloudflared http:// vhosts. # Cloudflare terminates TLS; cloudflared connects to Caddy over plain HTTP. # We must override X-Forwarded-Proto so upstream services (especially # Authelia) know the client is actually on HTTPS. cfProxy = port: '' reverse_proxy localhost:${toString port} { header_up X-Forwarded-Proto https } ''; # Reusable Authelia forward_auth snippet # Returns a Caddyfile snippet block that applies forward_auth. # copy_headers makes Authelia's Remote-* headers available downstream. autheliaForwardAuth = '' forward_auth localhost:9091 { uri /api/verify?rd=https://auth.${domain} copy_headers Remote-User Remote-Name Remote-Groups Remote-Email # Always tell Authelia the scheme is https (cloudflared terminates TLS # externally; Caddy's http:// vhosts are only for the tunnel loopback). header_up X-Forwarded-Proto https # On auth failure, redirect to the authelia login page @goauth status 401 handle_response @goauth { redir https://auth.${domain}?rm={method} 302 } } ''; in { options.homey.caddy = { enable = lib.mkEnableOption "Caddy reverse proxy"; acmeEmail = lib.mkOption { type = lib.types.str; default = "admin@zakobar.com"; description = "Email for Let's Encrypt ACME registration."; }; }; config = lib.mkIf cfg.enable { # ----------------------------------------------------------------------- # Secrets # ----------------------------------------------------------------------- sops.secrets."cloudflare/api_token" = { owner = config.services.caddy.user; }; # ----------------------------------------------------------------------- # Caddy service # ----------------------------------------------------------------------- services.caddy = { enable = true; package = caddyWithCloudflare; # Global options globalConfig = '' email ${cfg.acmeEmail} # Use Cloudflare DNS-01 challenge for wildcard cert acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN} ''; # Each virtual host. # # Each service gets two vhost entries: # - "host" (no scheme) → Caddy handles HTTPS + auto cert (for LAN access) # - "http://host" → plain HTTP for cloudflared on loopback (no redirect) # # Caddy auto-redirects HTTP→HTTPS only when no explicit http:// vhost exists. # By defining http:// explicitly we suppress that redirect so cloudflared # (which talks plain HTTP on port 80) gets a direct response. virtualHosts = { # ------------------------------------------------------------------ # Authelia — public, no auth gate (it IS the auth gate) # ------------------------------------------------------------------ "auth.${domain}" = { extraConfig = '' reverse_proxy localhost:9091 ''; }; "http://auth.${domain}" = { extraConfig = cfProxy 9091; }; # ------------------------------------------------------------------ # Gitea — no forward_auth; git HTTP clients can't handle SSO redirects. # Access control is handled by Gitea itself (LDAP auth + private repos). # ------------------------------------------------------------------ "git.${domain}" = { extraConfig = '' reverse_proxy localhost:3000 ''; }; "http://git.${domain}" = { extraConfig = cfProxy 3000; }; # ------------------------------------------------------------------ # Nextcloud — public auth (Nextcloud manages its own users + LDAP) # ------------------------------------------------------------------ "nextcloud.${domain}" = { extraConfig = '' # Redirect CardDAV/CalDAV discovery redir /.well-known/carddav /remote.php/dav/ 301 redir /.well-known/caldav /remote.php/dav/ 301 # Large uploads (5 GB) request_body { max_size 5GB } reverse_proxy localhost:8080 { header_up X-Forwarded-For {remote_host} } ''; }; "http://nextcloud.${domain}" = { extraConfig = '' redir /.well-known/carddav /remote.php/dav/ 301 redir /.well-known/caldav /remote.php/dav/ 301 request_body { max_size 5GB } reverse_proxy localhost:8080 { header_up X-Forwarded-Proto https header_up X-Forwarded-For {remote_host} } ''; }; # ------------------------------------------------------------------ # phpLDAPadmin — two_factor, admins only (enforced by authelia policy) # ------------------------------------------------------------------ "ldapadmin.${domain}" = { extraConfig = '' ${autheliaForwardAuth} reverse_proxy localhost:8081 ''; }; "http://ldapadmin.${domain}" = { extraConfig = '' ${autheliaForwardAuth} ${cfProxy 8081} ''; }; # ------------------------------------------------------------------ # Jellyfin — no forward_auth; Jellyfin has its own login UI and # native app clients can't handle SSO redirects. # ------------------------------------------------------------------ "jellyfin.${domain}" = { extraConfig = '' reverse_proxy localhost:8096 ''; }; "http://jellyfin.${domain}" = { extraConfig = cfProxy 8096; }; # ------------------------------------------------------------------ # Transmission — two_factor, admins only (enforced by authelia policy) # ------------------------------------------------------------------ "torrent.${domain}" = { extraConfig = '' ${autheliaForwardAuth} reverse_proxy localhost:9092 ''; # NOTE: transmission is bound to 9092 to avoid clash with authelia on 9091. }; "http://torrent.${domain}" = { extraConfig = '' ${autheliaForwardAuth} ${cfProxy 9092} ''; }; # ------------------------------------------------------------------ # Uptime Kuma — two_factor, admins only (enforced by authelia policy) # ------------------------------------------------------------------ "uptime.${domain}" = { extraConfig = '' ${autheliaForwardAuth} reverse_proxy localhost:3001 ''; }; "http://uptime.${domain}" = { extraConfig = '' ${autheliaForwardAuth} ${cfProxy 3001} ''; }; # ------------------------------------------------------------------ # Ntfy — no forward_auth; ntfy has its own token/password auth so the # mobile app can connect without Authelia SSO complications. # ------------------------------------------------------------------ "ntfy.${domain}" = { extraConfig = '' reverse_proxy localhost:2586 ''; }; "http://ntfy.${domain}" = { extraConfig = cfProxy 2586; }; # ------------------------------------------------------------------ # Grafana — two_factor, admins only (enforced by authelia policy). # After Authelia verifies the user, Caddy maps the Remote-User header # to X-WEBAUTH-USER so Grafana's proxy auth auto-signs the user in. # ------------------------------------------------------------------ "grafana.${domain}" = { extraConfig = '' ${autheliaForwardAuth} reverse_proxy localhost:3002 { header_up X-WEBAUTH-USER {http.request.header.Remote-User} } ''; }; "http://grafana.${domain}" = { extraConfig = '' ${autheliaForwardAuth} reverse_proxy localhost:3002 { header_up X-Forwarded-Proto https header_up X-WEBAUTH-USER {http.request.header.Remote-User} } ''; }; }; }; # ----------------------------------------------------------------------- # Pass Cloudflare token as env var to the caddy systemd unit. # # The caddy-dns/cloudflare plugin reads CLOUDFLARE_API_TOKEN directly. # sops decrypts the secret to a file at runtime; we write a transient # env file to /run/ in ExecStartPre so systemd picks it up via # EnvironmentFile. The file is removed in ExecStopPost. # ----------------------------------------------------------------------- systemd.services.caddy = { serviceConfig = { # LoadCredential stages the sops-decrypted secret into a # per-invocation directory ($CREDENTIALS_DIRECTORY) before any # Exec* step. ExecStart then reads the file contents and exports # CLOUDFLARE_API_TOKEN before exec-ing caddy, so there is no # intermediate env file and no ordering race with EnvironmentFile. LoadCredential = "cloudflare_api_token:${config.sops.secrets."cloudflare/api_token".path}"; # Systemd requires clearing ExecStart= before setting a new value for # non-oneshot services. The empty string resets the list; the second # entry is the actual start command. ExecStart = lib.mkForce [ "" (pkgs.writeShellScript "caddy-start" '' export CLOUDFLARE_API_TOKEN=$(cat "$CREDENTIALS_DIRECTORY/cloudflare_api_token") exec ${caddyWithCloudflare}/bin/caddy run --environ --config /etc/caddy/caddy_config --adapter caddyfile '') ]; }; after = lib.mkAfter [ "podman-authelia.service" ]; wants = lib.mkAfter [ "podman-authelia.service" ]; }; # ----------------------------------------------------------------------- # Firewall — open HTTP + HTTPS (already in common.nix, explicit here too) # ----------------------------------------------------------------------- networking.firewall.allowedTCPPorts = [ 80 443 ]; }; }