old
  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      normalizePath = p: let
130        p' =
131          if lib.hasPrefix "/" p
132          then p
133          else "/${p}";
134      in
135        lib.removeSuffix "/" p';
136
137      mkEntry = tailscale: resolution: domain: conf: {
138        inherit domain resolution tailscale;
139        https = conf.https or false;
140        port = conf.port;
141        path =
142          if conf ? path && conf.path != null
143          then normalizePath conf.path
144          else null;
145        stripPath = conf.stripPath or true;
146        upstream = conf.upstream or null;
147      };
148
149      publicEntries =
150        lib.concatMap (
151          r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
152        )
153        reverseProxy;
154
155      tailscaleEntries =
156        lib.concatMap (
157          r:
158            lib.mapAttrsToList (mkEntry true {
159              kind = "inline";
160              address = "127.0.0.1";
161            })
162            r.value
163        )
164        (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
165
166      entries = tailscaleEntries ++ lib.optionals external publicEntries;
167
168      proxyLine = e:
169        if e.resolution.kind == "import"
170        then "import ${config.vaultix.templates.${templateName e.domain}.path}"
171        else let
172          scheme =
173            if e.https
174            then "https"
175            else "http";
176          upstream =
177            if e.upstream != null
178            then e.upstream
179            else "";
180        in "reverse_proxy ${scheme}://${e.resolution.address}:${toString e.port}${upstream}";
181
182      entryLines = e: [(proxyLine e)];
183
184      importEntries = lib.filter (e: e.resolution.kind == "import") entries;
185
186      siteName = e:
187        if e.tailscale && !host.settings.services.webserver.caddy.tailscaleUseHttps
188        then "http://${e.domain}"
189        else e.domain;
190
191      forwardAuthBlock = ''
192        forward_auth unix//run/tailscale.nginx-auth.sock {
193            uri /auth
194            header_up Remote-Addr {remote_host}
195            header_up Remote-Port {remote_port}
196            header_up Original-URI {uri}
197            copy_headers {
198                Tailscale-User>X-Webauth-User
199                Tailscale-Name>X-Webauth-Name
200                Tailscale-Login>X-Webauth-Login
201                Tailscale-Tailnet>X-Webauth-Tailnet
202                Tailscale-Profile-Picture>X-Webauth-Profile-Picture
203            }
204        }
205      '';
206
207      entryBlock = e: let
208        lines = entryLines e;
209        directive =
210          if e.stripPath
211          then "handle_path"
212          else "handle";
213        mkBlock = p: "${directive} ${p} {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
214      in [
215        (mkBlock e.path)
216        (mkBlock "${e.path}/*")
217      ];
218
219      fallbackBlock = e: let
220        lines = entryLines e;
221      in "handle {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
222
223      virtualHosts = let
224        grouped = lib.groupBy (e: siteName e) entries;
225
226        mkVirtualHost = name: group: let
227          hasTailscale = lib.any (e: e.tailscale) group;
228          noPath = lib.filter (e: e.path == null) group;
229          withPath = lib.filter (e: e.path != null) group;
230          noPathCount = builtins.length noPath;
231        in
232          assert lib.assertMsg (noPathCount <= 1)
233          "Multiple entries without a path for domain '${name}': only one root (pathless) entry per domain is allowed, but found ${toString noPathCount}."; let
234            hasAuth = host.hasAspect den.aspects.services.tailscale-nginx-auth;
235          in
236            assert lib.assertMsg (!hasTailscale || hasAuth) (
237              "Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.\n\n"
238              + "Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable "
239              + "Tailscale authentication for reverse-proxied services."
240            ); let
241              authLines =
242                if hasTailscale
243                then [forwardAuthBlock]
244                else [];
245              pathLines = lib.concatMap entryBlock withPath;
246              fallbackLines =
247                if noPathCount == 1
248                then [(fallbackBlock (builtins.head noPath))]
249                else [];
250              blocks = authLines ++ pathLines ++ fallbackLines;
251            in {
252              inherit name;
253              value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
254            };
255      in
256        lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
257
258      reverseProxyTemplates = lib.listToAttrs (map (e: {
259          name = templateName e.domain;
260          value = {
261            content = ''
262              reverse_proxy http${lib.optionalString e.https "s"}://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
263            '';
264            owner = config.services.caddy.user;
265            group = config.services.caddy.group;
266            mode = "0400";
267          };
268        })
269        importEntries);
270
271      reverseProxySecrets = lib.listToAttrs (map (e: {
272          name = e.resolution.secretName;
273          value.file = e.resolution.secretFile;
274        })
275        importEntries);
276    in {
277      services.caddy.virtualHosts = virtualHosts;
278      vaultix.secrets = reverseProxySecrets;
279      vaultix.templates = reverseProxyTemplates;
280    };
281  };
282}