Commit 1c3bdd3

HPCesia <me@hpcesia.com>
2026-07-21 15:08:55
Support reverse-proxy subpath routing
Support three new per-entry fields: - path: route only matching URL prefix to this backend (enables multiple services to share a single domain) - stripPath: strip the path prefix before forwarding to backend (default true; uses handle_path vs handle in Caddy) - upstream: prepend a path to the backend URL (for apps served from a subpath internally) Entries for the same domain are merged into one virtual host. forward_auth now lives at virtual-host level so it covers all path-handled entries for that domain. Assisted-by: opencode:deepseek-v4-pro
1 parent e7f04a3
Changed files (2)
modules
services
modules/services/webserver/caddy.nix
@@ -126,10 +126,24 @@
       sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
       templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
 
+      normalizePath = p: let
+        p' =
+          if lib.hasPrefix "/" p
+          then p
+          else "/${p}";
+      in
+        lib.removeSuffix "/" p';
+
       mkEntry = tailscale: resolution: domain: conf: {
         inherit domain resolution tailscale;
         https = conf.https or false;
         port = conf.port;
+        path =
+          if conf ? path && conf.path != null
+          then normalizePath conf.path
+          else null;
+        stripPath = conf.stripPath or true;
+        upstream = conf.upstream or null;
       };
 
       publicEntries =
@@ -154,56 +168,92 @@
       proxyLine = e:
         if e.resolution.kind == "import"
         then "import ${config.vaultix.templates.${templateName e.domain}.path}"
-        else "reverse_proxy http${lib.optionalString e.https "s"}://${e.resolution.address}:${toString e.port}";
-
-      entryLines = e:
-        if !e.tailscale
-        then [(proxyLine e)]
-        else if (host.hasAspect den.aspects.services.tailscale-nginx-auth)
-        then [
-          ''
-            forward_auth unix//run/tailscale.nginx-auth.sock {
-                uri /auth
-                header_up Remote-Addr {remote_host}
-                header_up Remote-Port {remote_port}
-                header_up Original-URI {uri}
-                copy_headers {
-                    Tailscale-User>X-Webauth-User
-                    Tailscale-Name>X-Webauth-Name
-                    Tailscale-Login>X-Webauth-Login
-                    Tailscale-Tailnet>X-Webauth-Tailnet
-                    Tailscale-Profile-Picture>X-Webauth-Profile-Picture
-                }
-            }
-          ''
-          (proxyLine e)
-        ]
-        else
-          abort ''
-            Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.
+        else let
+          scheme =
+            if e.https
+            then "https"
+            else "http";
+          upstream =
+            if e.upstream != null
+            then e.upstream
+            else "";
+        in "reverse_proxy ${scheme}://${e.resolution.address}:${toString e.port}${upstream}";
 
-            Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable
-            Tailscale authentication for reverse-proxied services.
-          '';
+      entryLines = e: [(proxyLine e)];
 
       importEntries = lib.filter (e: e.resolution.kind == "import") entries;
 
-      # Tailscale-only vhosts are served over plain HTTP: their domains have no
-      # public DNS, so Caddy's automatic HTTPS would fail trying to reach a
-      # public ACME CA. And self-hosted Headscale did'nt support HTTPS serve yet.
-      # The `http://` scheme disables automatic HTTPS for the site
-      # (Tailscale/WireGuard already encrypts the transport). Public vhosts keep
-      # automatic HTTPS.
       siteName = e:
         if e.tailscale && !host.settings.services.webserver.caddy.tailscaleUseHttps
         then "http://${e.domain}"
         else e.domain;
 
-      virtualHosts = lib.listToAttrs (map (e: {
-          name = siteName e;
-          value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ entryLines e);
-        })
-        entries);
+      forwardAuthBlock = ''
+        forward_auth unix//run/tailscale.nginx-auth.sock {
+            uri /auth
+            header_up Remote-Addr {remote_host}
+            header_up Remote-Port {remote_port}
+            header_up Original-URI {uri}
+            copy_headers {
+                Tailscale-User>X-Webauth-User
+                Tailscale-Name>X-Webauth-Name
+                Tailscale-Login>X-Webauth-Login
+                Tailscale-Tailnet>X-Webauth-Tailnet
+                Tailscale-Profile-Picture>X-Webauth-Profile-Picture
+            }
+        }
+      '';
+
+      entryBlock = e: let
+        lines = entryLines e;
+        directive =
+          if e.stripPath
+          then "handle_path"
+          else "handle";
+        mkBlock = p: "${directive} ${p} {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
+      in [
+        (mkBlock e.path)
+        (mkBlock "${e.path}/*")
+      ];
+
+      fallbackBlock = e: let
+        lines = entryLines e;
+      in "handle {\n${lib.concatMapStrings (l: "\t${l}\n") lines}}";
+
+      virtualHosts = let
+        grouped = lib.groupBy (e: siteName e) entries;
+
+        mkVirtualHost = name: group: let
+          hasTailscale = lib.any (e: e.tailscale) group;
+          noPath = lib.filter (e: e.path == null) group;
+          withPath = lib.filter (e: e.path != null) group;
+          noPathCount = builtins.length noPath;
+        in
+          assert lib.assertMsg (noPathCount <= 1)
+          "Multiple entries without a path for domain '${name}': only one root (pathless) entry per domain is allowed, but found ${toString noPathCount}."; let
+            hasAuth = host.hasAspect den.aspects.services.tailscale-nginx-auth;
+          in
+            assert lib.assertMsg (!hasTailscale || hasAuth) (
+              "Caddy on host '${host.name}' cannot use Tailscale reverse proxy: the tailscale-nginx-auth aspect is not enabled.\n\n"
+              + "Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable "
+              + "Tailscale authentication for reverse-proxied services."
+            ); let
+              authLines =
+                if hasTailscale
+                then [forwardAuthBlock]
+                else [];
+              pathLines = lib.concatMap entryBlock withPath;
+              fallbackLines =
+                if noPathCount == 1
+                then [(fallbackBlock (builtins.head noPath))]
+                else [];
+              blocks = authLines ++ pathLines ++ fallbackLines;
+            in {
+              inherit name;
+              value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ blocks);
+            };
+      in
+        lib.listToAttrs (lib.mapAttrsToList mkVirtualHost grouped);
 
       reverseProxyTemplates = lib.listToAttrs (map (e: {
           name = templateName e.domain;
modules/services/webserver/quirks.nix
@@ -3,10 +3,11 @@
   #
   # reverseProxy = {
   #   "example.com" = {
-  #     # address = "127.0.0.1"
   #     port = 12345;
-  #     https = true;
-  #     aliases = ["example.org"];
+  #     # https = true;         # optional: proxy HTTPS site
+  #     # path = "/app";        # optional: only proxy requests under this path prefix
+  #     # stripPath = true;     # optional (default true): strip path prefix before forwarding
+  #     # upstream = "/api";    # optional: prepend a path to the backend URL
   #   }
   # }
   den.quirks.reverseProxy.description = "Reverse proxies for web services";