main
  1{den, ...}: {
  2  den.aspects.services.caddy = {
  3    includes = [
  4      den.aspects.services.caddy.reverse-proxy-collector
  5    ];
  6
  7    persist = {
  8      directories = [
  9        {
 10          directory = "/var/lib/caddy";
 11          user = "caddy";
 12          group = "caddy";
 13        }
 14      ];
 15    };
 16
 17    nixos = {
 18      services.caddy = {
 19        enable = true;
 20        enableReload = true;
 21
 22        globalConfig = ''
 23          http_port   80
 24          https_port  443
 25        '';
 26      };
 27
 28      networking.firewall.allowedTCPPorts = [80 443];
 29    };
 30  };
 31
 32  den.aspects.services.caddy.reverse-proxy-collector = {host, ...}: {
 33    nixos = {
 34      reverseProxy,
 35      config,
 36      lib,
 37      ...
 38    }: let
 39      inherit
 40        (import ./_lib.nix {inherit lib;})
 41        fmtAddr
 42        mkEntry
 43        entryBlock
 44        fallbackBlock
 45        templateName
 46        ;
 47
 48      # Resolve how Caddy on the current host should reach the host that
 49      # submitted the reverse proxy (the source host).
 50      #
 51      # Priority:
 52      #   1. Same host                -> loopback.
 53      #   2. Source has a clear text  -> use it directly.
 54      #   3. Source has a secret IP   -> import it from a vaultix template.
 55      #   4. Otherwise                -> abort with a helpful message.
 56      resolveAddress = srcHost: let
 57        src = srcHost.address;
 58
 59        v4ClearText = src.ipv4.clearText != null;
 60        v6ClearText = src.ipv6.clearText != null;
 61
 62        v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
 63        v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
 64      in
 65        if srcHost.name == host.name
 66        then {
 67          kind = "inline";
 68          address = "127.0.0.1";
 69        }
 70        else if v4ClearText
 71        then {
 72          kind = "inline";
 73          address = fmtAddr false src.ipv4.clearText;
 74        }
 75        else if v6ClearText
 76        then {
 77          kind = "inline";
 78          address = fmtAddr true src.ipv6.clearText;
 79        }
 80        else if v4Secret
 81        then {
 82          kind = "import";
 83          isV6 = false;
 84          secretName = src.ipv4.secret.name;
 85          secretFile = src.ipv4.secret.file;
 86        }
 87        else if v6Secret
 88        then {
 89          kind = "import";
 90          isV6 = true;
 91          secretName = src.ipv6.secret.name;
 92          secretFile = src.ipv6.secret.file;
 93        }
 94        else
 95          abort ''
 96            Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
 97
 98            Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
 99              - clear text IPv4/IPv6           (address.ipv4.clearText / address.ipv6.clearText)
100              - secret IPv4/IPv6               (address.ipv4.secret    / address.ipv6.secret)
101          '';
102
103      entries =
104        lib.concatMap (
105          r:
106            lib.mapAttrsToList (mkEntry (resolveAddress r.source.host)) (
107              lib.filterAttrs (_: conf: !(conf.tailscale or false)) r.value
108            )
109        )
110        (lib.filter (r: r.source.host.name == host.name) reverseProxy);
111
112      importEntries = lib.filter (e: e.resolution.kind == "import") entries;
113
114      virtualHosts = let
115        grouped = lib.groupBy (e: e.domain) entries;
116
117        mkVirtualHost = name: group: let
118          noPath = lib.filter (e: e.path == null) group;
119          withPath = lib.filter (e: e.path != null) group;
120          noPathCount = builtins.length noPath;
121        in
122          assert lib.assertMsg (noPathCount <= 1)
123          "Multiple entries without a path for domain '${name}': only one root (pathless) entry per domain is allowed, but found ${toString noPathCount}."; let
124            pathLines = lib.concatMap (entryBlock config) withPath;
125            fallbackLines =
126              if noPathCount == 1
127              then [(fallbackBlock config (builtins.head noPath))]
128              else [];
129            blocks = pathLines ++ fallbackLines;
130          in {
131            inherit name;
132            value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
133          };
134      in
135        lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
136
137      reverseProxyTemplates = lib.listToAttrs (map (e: {
138          name = templateName e.domain;
139          value = {
140            content = ''
141              reverse_proxy http://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
142            '';
143            owner = config.services.caddy.user;
144            group = config.services.caddy.group;
145            mode = "0400";
146          };
147        })
148        importEntries);
149
150      reverseProxySecrets = lib.listToAttrs (map (e: {
151          name = e.resolution.secretName;
152          value.file = e.resolution.secretFile;
153        })
154        importEntries);
155    in {
156      services.caddy.virtualHosts = virtualHosts;
157      vaultix.secrets = reverseProxySecrets;
158      vaultix.templates = reverseProxyTemplates;
159    };
160  };
161}