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