Commit b21dfd1

HPCesia <me@hpcesia.com>
2026-06-20 09:23:11
Implement cross host reverse proxy
Assisted-by: opencode/deepseek-v4-pro
1 parent 1880494
Changed files (1)
modules
services
webserver
modules/services/webserver/caddy.nix
@@ -35,34 +35,150 @@
   };
 
   den.aspects.services.webserver.caddy.reverse-proxy-collector = {host, ...}: {
-    nixos = {reverseProxy, ...}: let
-      _processedRP = lib.mergeAttrsList (map (
+    nixos = {
+      reverseProxy,
+      config,
+      ...
+    }: let
+      # Wrap IPv6 addresses in brackets so they can be used in a URL authority.
+      fmtAddr = isV6: addr:
+        if isV6
+        then "[${addr}]"
+        else addr;
+
+      # Resolve how Caddy on the current host should reach the host that
+      # submitted the reverse proxy (the source host).
+      #
+      # Priority:
+      #   1. Same host                -> loopback.
+      #   2. Both ends have Tailscale -> use the source's Tailscale IP.
+      #   3. Source has a clear text  -> use it directly.
+      #   4. Source has a secret IP   -> import it from a vaultix template.
+      #   5. Otherwise                -> abort with a helpful message.
+      resolveAddress = srcHost: let
+        src = srcHost.address;
+        cur = host.address;
+
+        v4TailscaleBoth = src.ipv4.tailscale != null && cur.ipv4.tailscale != null;
+        v6TailscaleBoth = src.ipv6.tailscale != null && cur.ipv6.tailscale != null;
+
+        v4ClearText = src.ipv4.clearText != null;
+        v6ClearText = src.ipv6.clearText != null;
+
+        v4Secret = src.ipv4.secret.name != null && src.ipv4.secret.file != null;
+        v6Secret = src.ipv6.secret.name != null && src.ipv6.secret.file != null;
+      in
+        if srcHost.name == host.name
+        then {
+          kind = "inline";
+          address = "127.0.0.1";
+        }
+        else if v4TailscaleBoth
+        then {
+          kind = "inline";
+          address = fmtAddr false src.ipv4.tailscale;
+        }
+        else if v6TailscaleBoth
+        then {
+          kind = "inline";
+          address = fmtAddr true src.ipv6.tailscale;
+        }
+        else if v4ClearText
+        then {
+          kind = "inline";
+          address = fmtAddr false src.ipv4.clearText;
+        }
+        else if v6ClearText
+        then {
+          kind = "inline";
+          address = fmtAddr true src.ipv6.clearText;
+        }
+        else if v4Secret
+        then {
+          kind = "import";
+          isV6 = false;
+          secretName = src.ipv4.secret.name;
+          secretFile = src.ipv4.secret.file;
+        }
+        else if v6Secret
+        then {
+          kind = "import";
+          isV6 = true;
+          secretName = src.ipv6.secret.name;
+          secretFile = src.ipv6.secret.file;
+        }
+        else
+          abort ''
+            Caddy on host '${host.name}' cannot reverse proxy to host '${srcHost.name}': no usable address is configured.
+
+            Please configure an address for host '${srcHost.name}' (see `address` in modules/hosts/schema.nix), one of:
+              - Tailscale IPv4/IPv6 on both '${srcHost.name}' and '${host.name}' (address.ipv4.tailscale / address.ipv6.tailscale)
+              - clear text IPv4/IPv6           (address.ipv4.clearText / address.ipv6.clearText)
+              - secret IPv4/IPv6               (address.ipv4.secret    / address.ipv6.secret)
+          '';
+
+      sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
+      templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
+
+      entries =
+        lib.concatMap (
           r:
-            lib.mapAttrs (
-              _: conf:
-                {
-                  address =
-                    if (r.source.host.name == host.name)
-                    then "127.0.0.1"
-                    else abort "Not implemented for outside host webserver";
-                  https = false;
-                }
-                // conf
+            lib.mapAttrsToList (
+              domain: conf: {
+                inherit domain;
+                https = conf.https or false;
+                port = conf.port;
+                resolution = resolveAddress r.source.host;
+              }
             )
             r.value
         )
-        reverseProxy);
+        reverseProxy;
 
-      virtualHosts =
-        lib.mapAttrs (domain: conf: {
-          extraConfig = ''
-            encode zstd gzip
-            reverse_proxy http${lib.optionalString conf.https "s"}://${conf.address}:${toString conf.port}
-          '';
+      importEntries = lib.filter (e: e.resolution.kind == "import") entries;
+
+      virtualHosts = lib.listToAttrs (map (e: {
+          name = e.domain;
+          value = {
+            extraConfig =
+              if e.resolution.kind == "import"
+              then ''
+                encode zstd gzip
+                import ${config.vaultix.templates.${templateName e.domain}.path}
+              ''
+              else ''
+                encode zstd gzip
+                reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}
+              '';
+          };
+        })
+        entries);
+
+      # Each secret IP is rendered into a Caddyfile snippet through vaultix, so
+      # the plain text address never lands in the Nix store. Caddy then splices
+      # the snippet in with the `import` directive.
+      reverseProxyTemplates = lib.listToAttrs (map (e: {
+          name = templateName e.domain;
+          value = {
+            content = ''
+              reverse_proxy http${lib.optionalString e.https "s"}://${fmtAddr e.resolution.isV6 config.vaultix.placeholder.${e.resolution.secretName}}:${toString e.port}
+            '';
+            owner = config.services.caddy.user;
+            group = config.services.caddy.group;
+            mode = "0400";
+          };
+        })
+        importEntries);
+
+      reverseProxySecrets = lib.listToAttrs (map (e: {
+          name = e.resolution.secretName;
+          value.file = e.resolution.secretFile;
         })
-        _processedRP;
+        importEntries);
     in {
       services.caddy.virtualHosts = virtualHosts;
+      vaultix.secrets = reverseProxySecrets;
+      vaultix.templates = reverseProxyTemplates;
     };
   };
 }