{ 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. # Uses the v4.38+ /api/authz/forward-auth endpoint which correctly honours # one_factor policy without forcing TOTP enrollment on new users. # copy_headers makes Authelia's Remote-* headers available downstream. autheliaForwardAuth = '' forward_auth localhost:9091 { uri /api/authz/forward-auth?authelia_url=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 } ''; 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."; }; virtualHosts = lib.mkOption { type = lib.types.listOf (lib.types.submodule { options = { subdomain = lib.mkOption { type = lib.types.str; description = "Subdomain under homeyConfig.domain (e.g. \"mealie\" → mealie.zakobar.com)."; }; port = lib.mkOption { type = lib.types.port; description = "Host port to reverse-proxy to."; }; auth = lib.mkOption { type = lib.types.bool; default = true; description = "Prepend Authelia forward_auth to this vhost."; }; extraConfig = lib.mkOption { type = lib.types.str; default = ""; description = "Replaces the auto-generated 'reverse_proxy localhost:' for HTTPS. Empty = use default."; }; extraHttpConfig = lib.mkOption { type = lib.types.str; default = ""; description = "Replaces the auto-generated cfProxy for the HTTP loopback vhost. Empty = use default."; }; }; }); default = []; description = "Virtual hosts to generate. Each service module contributes its own entries."; }; }; 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 is generated from homey.caddy.virtualHosts entries. # Each service module contributes its own entries to that list. # # Each entry produces two Caddy vhosts: # - "subdomain.domain" → HTTPS (LAN access + Let's Encrypt cert) # - "http://subdomain.domain" → plain HTTP for cloudflared loopback virtualHosts = lib.listToAttrs ( lib.concatMap (vh: let d = "${vh.subdomain}.${domain}"; authSnip = lib.optionalString vh.auth autheliaForwardAuth; httpsBody = if vh.extraConfig != "" then vh.extraConfig else "reverse_proxy localhost:${toString vh.port}\n"; httpBody = if vh.extraHttpConfig != "" then vh.extraHttpConfig else cfProxy vh.port; in [ { name = d; value.extraConfig = "${authSnip}${httpsBody}"; } { name = "http://${d}"; value.extraConfig = "${authSnip}${httpBody}"; } ] ) cfg.virtualHosts ); }; # ----------------------------------------------------------------------- # 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 ]; }; }