Commit 3cb2915

HPCesia <me@hpcesia.com>
2026-06-20 17:27:03
Add Tailscale-only reverse proxies and external webserver toggle
Assisted-by: opencode/deepseek-v4-pro
1 parent b21dfd1
Changed files (7)
modules
modules/hosts/hyacine/services/services.nix
@@ -1,5 +1,7 @@
 {den, ...}: {
   den.hosts.hyacine.settings = {
+    services.webserver.external = true;
+
     services.headscale = {
       domain = "gate.trin.one";
       port = 3465;
modules/hosts/mobius/services/services.nix
@@ -1,6 +1,8 @@
 {den, ...}: {
   den.hosts.mobius = {
     settings = {
+      services.webserver.external = false;
+
       services.woodpecker.agent.agents = {
         codeberg = {
           server = "grpc.ci.codeberg.org:443";
@@ -15,6 +17,8 @@
 
   den.aspects.mobius = {
     includes = with den.aspects; [
+      services.webserver
+
       services.woodpecker.agent
     ];
   };
modules/services/webserver/caddy.nix
@@ -21,7 +21,6 @@
     nixos = {
       services.caddy = {
         enable = true;
-        # Reload Caddy instead of restarting it when configuration file changes.
         enableReload = true;
 
         globalConfig = ''
@@ -37,9 +36,12 @@
   den.aspects.services.webserver.caddy.reverse-proxy-collector = {host, ...}: {
     nixos = {
       reverseProxy,
+      reverseProxyTailscale,
       config,
       ...
     }: let
+      external = host.settings.services.webserver.external;
+
       # Wrap IPv6 addresses in brackets so they can be used in a URL authority.
       fmtAddr = isV6: addr:
         if isV6
@@ -120,43 +122,85 @@
       sanitize = lib.replaceStrings ["." ":" "/" "*"] ["-" "-" "-" "-"];
       templateName = domain: "caddy-reverse-proxy-${sanitize domain}";
 
-      entries =
+      mkEntry = tailscale: resolution: domain: conf: {
+        inherit domain resolution tailscale;
+        https = conf.https or false;
+        port = conf.port;
+      };
+
+      publicEntries =
+        lib.concatMap (
+          r: lib.mapAttrsToList (mkEntry false (resolveAddress r.source.host)) r.value
+        )
+        reverseProxy;
+
+      tailscaleEntries =
         lib.concatMap (
           r:
-            lib.mapAttrsToList (
-              domain: conf: {
-                inherit domain;
-                https = conf.https or false;
-                port = conf.port;
-                resolution = resolveAddress r.source.host;
-              }
-            )
+            lib.mapAttrsToList (mkEntry true {
+              kind = "inline";
+              address = "127.0.0.1";
+            })
             r.value
         )
-        reverseProxy;
+        (lib.filter (r: r.source.host.name == host.name) reverseProxyTailscale);
+
+      entries = tailscaleEntries ++ lib.optionals external publicEntries;
+
+      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.
+
+            Add `den.aspects.services.tailscale-nginx-auth` to the host's aspects to enable
+            Tailscale authentication for reverse-proxied services.
+          '';
 
       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
+        then "http://${e.domain}"
+        else e.domain;
+
       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}
-              '';
-          };
+          name = siteName e;
+          value.extraConfig = lib.concatLines (["encode zstd gzip"] ++ entryLines e);
         })
         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 = {
modules/services/webserver/default.nix
@@ -1,7 +1,28 @@
-{den, ...}: {
+{
+  den,
+  lib,
+  ...
+}: {
   den.aspects.services.webserver = {
+    settings = {
+      external = lib.mkOption {
+        description = ''
+          Whether this host's webserver is publicly reachable. Every host serves
+          its own Tailscale-only reverse proxies. When true, caddy additionally
+          serves the fleet-wide public reverse proxies on every interface
+          (0.0.0.0/::), and its Tailscale-only vhosts are kept off the public
+          interface by rejecting non-Tailscale source IPs. When false, caddy
+          only serves the host's own Tailscale reverse proxies, bound to the
+          host's own Tailscale IP.
+        '';
+        type = lib.types.bool;
+        default = false;
+      };
+    };
+
     includes = [
       den.aspects.services.webserver.reverse-proxy-processer
+      den.aspects.services.webserver.reverse-proxy-tailscale-processer
 
       den.aspects.services.webserver.caddy
     ];
modules/services/webserver/quirks.nix
@@ -11,6 +11,11 @@
   # }
   den.quirks.reverseProxy.description = "Reverse proxies for web services";
 
+  # Mirrors reverseProxy, but these services are never exposed publicly: caddy
+  # only serves them on the owning host's own Tailscale interface, and headscale
+  # publishes them as Magic DNS records pointing at that host's Tailscale IP.
+  den.quirks.reverseProxyTailscale.description = "Reverse proxies for web services exposed only over Tailscale";
+
   den.aspects.services.webserver.reverse-proxy-processer = {
     __isPolicy = true;
     fn = _: let
@@ -22,4 +27,16 @@
       ])
     ];
   };
+
+  den.aspects.services.webserver.reverse-proxy-tailscale-processer = {
+    __isPolicy = true;
+    fn = _: let
+      inherit (den.lib.policy) pipe;
+    in [
+      (pipe.from den.quirks.reverseProxyTailscale [
+        (pipe.collect ({host, ...}: true))
+        pipe.withProvenance
+      ])
+    ];
+  };
 }
modules/services/headscale.nix
@@ -52,11 +52,36 @@
       host,
       pkgs,
       config,
+      reverseProxyTailscale,
       ...
     }: let
       cfg = host.settings.services.headscale;
       hostAddr = host.address;
 
+      # Publish a Magic DNS record for every host's Tailscale-only reverse proxy,
+      # pointing each domain at the owning host's Tailscale IP.
+      tailscaleRecords =
+        lib.concatMap (
+          r: let
+            srcAddr = r.source.host.address;
+          in
+            lib.concatMap (
+              domain:
+                (lib.optional (srcAddr.ipv4.tailscale != null) {
+                  name = domain;
+                  type = "A";
+                  value = srcAddr.ipv4.tailscale;
+                })
+                ++ (lib.optional (srcAddr.ipv6.tailscale != null) {
+                  name = domain;
+                  type = "AAAA";
+                  value = srcAddr.ipv6.tailscale;
+                })
+            )
+            (builtins.attrNames r.value)
+        )
+        reverseProxyTailscale;
+
       headscale-wrapped =
         pkgs.runCommandLocal "headscale-wrapped" {
           nativeBuildInputs = [pkgs.makeWrapper];
@@ -106,6 +131,7 @@
               "2400:3200::1" # AliDNS
               "2606:4700:4700::1111" # Cloudflare
             ];
+            extra_records = tailscaleRecords;
           };
         };
       };
modules/services/tailscale-nginx-auth.nix
@@ -0,0 +1,30 @@
+{den, ...}: {
+  den.aspects.services.tailscale-nginx-auth = {
+    includes = [den.aspects.core.tailscale];
+
+    nixos = {pkgs, ...}: {
+      systemd.sockets.tailscale-nginx-auth = {
+        description = "Tailscale NGINX Authentication socket";
+        partOf = ["tailscale-nginx-auth.service"];
+        wantedBy = ["sockets.target"];
+        listenStreams = ["/run/tailscale.nginx-auth.sock"];
+      };
+
+      systemd.services.tailscale-nginx-auth = {
+        description = "Tailscale NGINX Authentication service";
+        requires = ["tailscale-nginx-auth.socket"];
+        after = ["tailscaled.service"];
+
+        serviceConfig = {
+          ExecStart = "${pkgs.tailscale-nginx-auth}/bin/tailscale.nginx-auth";
+          DynamicUser = true;
+          BindPaths = ["/run/tailscale/tailscaled.sock"];
+          PrivateDevices = true;
+          ProtectHome = true;
+          RestrictAddressFamilies = ["AF_UNIX"];
+          Restart = "on-failure";
+        };
+      };
+    };
+  };
+}