main
  1{
  2  den,
  3  lib,
  4  ...
  5}: {
  6  den.aspects.services.webserver.caddy = {
  7    includes = [
  8      den.aspects.services.webserver.caddy.reverse-proxy-collector
  9    ];
 10
 11    settings = {
 12      tailscaleUseHttps = lib.mkEnableOption "Whether to use https for tailscale internal virtual hosts";
 13    };
 14
 15    persist = {
 16      directories = [
 17        {
 18          directory = "/var/lib/caddy";
 19          user = "caddy";
 20          group = "caddy";
 21        }
 22      ];
 23    };
 24
 25    nixos = {
 26      services.caddy = {
 27        enable = true;
 28        enableReload = true;
 29
 30        globalConfig = ''
 31          http_port   80
 32          https_port  443
 33        '';
 34      };
 35
 36      networking.firewall.allowedTCPPorts = [80 443];
 37    };
 38  };
 39
 40  den.aspects.services.webserver.caddy.reverse-proxy-collector = {host, ...}: {
 41    nixos = {
 42      reverseProxy,
 43      reverseProxyTailscale,
 44      config,
 45      ...
 46    }: let
 47      external = host.settings.services.webserver.external;
 48
 49      # Wrap IPv6 addresses in brackets so they can be used in a URL authority.
 50      fmtAddr = isV6: addr:
 51        if isV6
 52        then "[${addr}]"
 53        else addr;
 54
 55      # Resolve how Caddy on the current host should reach the host that
 56      # submitted the reverse proxy (the source host).
 57      #
 58      # Priority:
 59      #   1. Same host                -> loopback.
 60      #   2. Both ends have Tailscale -> use the source's Tailscale IP.
 61      #   3. Source has a clear text  -> use it directly.
 62      #   4. Source has a secret IP   -> import it from a vaultix template.
 63      #   5. Otherwise                -> abort with a helpful message.
 64      resolveAddress = srcHost: let
 65        src = srcHost.address;
 66        cur = host.address;
 67
 68        v4TailscaleBoth = src.ipv4.tailscale != null && cur.ipv4.tailscale != null;
 69        v6TailscaleBoth = src.ipv6.tailscale != null && cur.ipv6.tailscale != null;
 70
 71        v4ClearText = src.ipv4.clearText != null;
 72        v6ClearText = src.ipv6.clearText != null;
 73
 74        v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
 75        v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
 76      in
 77        if srcHost.name == host.name
 78        then {
 79          kind = "inline";
 80          address = "127.0.0.1";
 81        }
 82        else if v4TailscaleBoth
 83        then {
 84          kind = "inline";
 85          address = fmtAddr false src.ipv4.tailscale;
 86        }
 87        else if v6TailscaleBoth
 88        then {
 89          kind = "inline";
 90          address = fmtAddr true src.ipv6.tailscale;
 91        }
 92        else if v4ClearText
 93        then {
 94          kind = "inline";
 95          address = fmtAddr false src.ipv4.clearText;
 96        }
 97        else if v6ClearText
 98        then {
 99          kind = "inline";
100          address = fmtAddr true src.ipv6.clearText;
101        }
102        else if v4Secret
103        then {
104          kind = "import";
105          isV6 = false;
106          secretName = src.ipv4.secret.name;
107          secretFile = src.ipv4.secret.file;
108        }
109        else if v6Secret
110        then {
111          kind = "import";
112          isV6 = true;
113          secretName = src.ipv6.secret.name;
114          secretFile = src.ipv6.secret.file;
115        }
116        else
117          abort ''
118            Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
119
120            Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
121              - Tailscale IPv4/IPv6 on both '${srcHost.name}' and '${host.name}' (address.ipv4.tailscale / address.ipv6.tailscale)
122              - clear text IPv4/IPv6           (address.ipv4.clearText / address.ipv6.clearText)
123              - secret IPv4/IPv6               (address.ipv4.secret    / address.ipv6.secret)
124          '';
125
126      sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
127      templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
128
129      mkEntry = tailscale: resolution: domain: conf: {
130        inherit domain resolution tailscale;
131        https = conf.https or false;
132        port = conf.port;
133      };
134
135      publicEntries =
136        lib.concatMap (
137          r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
138        )
139        reverseProxy;
140
141      tailscaleEntries =
142        lib.concatMap (
143          r:
144            lib.mapAttrsToList (mkEntry true {
145              kind = "inline";
146              address = "127.0.0.1";
147            })
148            r.value
149        )
150        (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
151
152      entries = tailscaleEntries ++ lib.optionals external publicEntries;
153
154      proxyLine = e:
155        if e.resolution.kind == "import"
156        then "import ${config.vaultix.templates.${templateName e.domain}.path}"
157        else "reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}";
158
159      entryLines = e:
160        if !e.tailscale
161        then [(proxyLine e)]
162        else if (host.hasAspect den.aspects.services.tailscale-nginx-auth)
163        then [
164          ''
165            forward_auth unix//run/tailscale.nginx-auth.sock {
166                uri /auth
167                header_up Remote-Addr {remote_host}
168                header_up Remote-Port {remote_port}
169                header_up Original-URI {uri}
170                copy_headers {
171                    Tailscale-User>X-Webauth-User
172                    Tailscale-Name>X-Webauth-Name
173                    Tailscale-Login>X-Webauth-Login
174                    Tailscale-Tailnet>X-Webauth-Tailnet
175                    Tailscale-Profile-Picture>X-Webauth-Profile-Picture
176                }
177            }
178          ''
179          (proxyLine e)
180        ]
181        else
182          abort ''
183            Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.
184
185            Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable
186            Tailscale authentication for reverse-proxied services.
187          '';
188
189      importEntries = lib.filter (e: e.resolution.kind == "import") entries;
190
191      # Tailscale-only vhosts are served over plain HTTP: their domains have no
192      # public DNS, so Caddy's automatic HTTPS would fail trying to reach a
193      # public ACME CA. And self-hosted Headscale did'nt support HTTPS serve yet.
194      # The `http://` scheme disables automatic HTTPS for the site
195      # (Tailscale/WireGuard already encrypts the transport). Public vhosts keep
196      # automatic HTTPS.
197      siteName = e:
198        if e.tailscale && !host.settings.services.webserver.caddy.tailscaleUseHttps
199        then "http://${e.domain}"
200        else e.domain;
201
202      virtualHosts = lib.listToAttrs (map (e: {
203          name = siteName e;
204          value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ entryLines e);
205        })
206        entries);
207
208      reverseProxyTemplates = lib.listToAttrs (map (e: {
209          name = templateName e.domain;
210          value = {
211            content = ''
212              reverse_proxy http${lib.optionalString e.https "s"}://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
213            '';
214            owner = config.services.caddy.user;
215            group = config.services.caddy.group;
216            mode = "0400";
217          };
218        })
219        importEntries);
220
221      reverseProxySecrets = lib.listToAttrs (map (e: {
222          name = e.resolution.secretName;
223          value.file = e.resolution.secretFile;
224        })
225        importEntries);
226    in {
227      services.caddy.virtualHosts = virtualHosts;
228      vaultix.secrets = reverseProxySecrets;
229      vaultix.templates = reverseProxyTemplates;
230    };
231  };
232}