Commit b5119b2

HPCesia <me@hpcesia.com>
2026-09-05 09:16:05
headscale: add tailnet grants policy
Assisted-by: opencode:deepseek-v4-flash
1 parent a270c97
Changed files (6)
modules/core/openssh.nix
@@ -56,6 +56,12 @@ in {
       ];
     };
 
+    tailscaleGrant = {host, ...}:
+      lib.optional (host.settings.core.openssh.server.enable) {
+        src = ["*"];
+        ip = lib.unique ["tcp:22" "tcp:${toString host.settings.core.openssh.server.port}"];
+      };
+
     nixos = {host, ...}: {
       services.openssh = {
         enable = host.settings.core.openssh.server.enable;
modules/services/headscale/_lib.nix
@@ -52,6 +52,60 @@
       reverseProxy
     );
 
+  # Inject the implicit dst (the emitting host's tailscale addresses) into
+  # provenance-wrapped grants and dedupe identical entries so that multiple
+  # hosts sharing an explicit dst (e.g. a tag) collapse into one rule.
+  mkPolicyGrants = grants:
+    lib.unique (
+      map (grant: let
+        value = grant.value;
+        host = grant.source.host;
+        ip = value.ip or [];
+        app = value.app or [];
+        src = value.src or [];
+      in
+        assert lib.assertMsg (src != [])
+        "Host '${host.name}' emits a tailscale grant with no sources (src)";
+        assert lib.assertMsg (ip != [] || app != [])
+        "Host '${host.name}' emits a tailscale grant with no capabilities (ip or app)"; let
+          dst =
+            if value.dst or null != null
+            then [value.dst]
+            else let
+              v4 = host.address.ipv4.tailscale;
+              v6 = host.address.ipv6.tailscale;
+            in
+              assert lib.assertMsg (v4 != null || v6 != null)
+              "Host '${host.name}' emits a tailscale grant without a dst, but has no tailscale IPv4 or IPv6 address (see `address` in modules/hosts/schema.nix).";
+                lib.optionals (v4 != null) [v4] ++ lib.optionals (v6 != null) [v6];
+        in
+          {
+            inherit src dst;
+          }
+          // lib.optionalAttrs (ip != []) {inherit ip;}
+          // lib.optionalAttrs (app != []) {inherit app;})
+      grants
+    );
+
+  # Tailnet reverse-proxied domains are served by the host's caddy on the
+  # tailnet, so each host with such entries gets a grant for its caddy ports.
+  mkCaddyGrants = reverseProxy:
+    lib.unique (
+      map (r: let
+        v4 = r.source.host.address.ipv4.tailscale;
+        v6 = r.source.host.address.ipv6.tailscale;
+      in
+        assert lib.assertMsg (v4 != null || v6 != null)
+        "Host '${r.source.host.name}' serves tailnet domains but has no tailscale IPv4 or IPv6 address (see `address` in modules/hosts/schema.nix)."; {
+          src = ["*"];
+          ip = ["tcp:80" "tcp:443"];
+          dst = lib.optionals (v4 != null) [v4] ++ lib.optionals (v6 != null) [v6];
+        })
+      (lib.filter
+        (r: builtins.any (conf: conf.tailscale or false) (builtins.attrValues r.value))
+        reverseProxy)
+    );
+
   mkDnsRecords = {
     reverseProxy,
     tailnetDomain,
@@ -98,8 +152,10 @@
       tailnetEntries;
 in {
   inherit
+    mkCaddyGrants
     mkDirectEntries
     mkDnsRecords
+    mkPolicyGrants
     ;
 
   configFilePath = "/var/lib/headscale/config.yaml";
modules/services/headscale/default.nix
@@ -7,6 +7,7 @@
     includes = [
       den.aspects.services.headscale.derp
       den.aspects.services.headscale.dns
+      den.aspects.services.headscale.policy
     ];
 
     settings.host = {
modules/services/headscale/policy.nix
@@ -0,0 +1,23 @@
+{
+  den.aspects.services.headscale.policy = {
+    nixos = {
+      tailscaleGrant,
+      reverseProxy,
+      pkgs,
+      lib,
+      ...
+    }: let
+      inherit
+        (import ./_lib.nix {inherit lib;})
+        mkCaddyGrants
+        mkPolicyGrants
+        ;
+    in {
+      services.headscale.settings.policy.path =
+        pkgs.writeText "headscale-policy.json"
+        (builtins.toJSON {
+          grants = mkPolicyGrants tailscaleGrant ++ mkCaddyGrants reverseProxy;
+        });
+    };
+  };
+}
modules/services/headscale/quirks.nix
@@ -0,0 +1,28 @@
+{den, ...}: {
+  den.quirks.tailscaleGrant.description = ''
+    Tailnet access grants emitted by aspects (host)
+
+    The destination is implicit (the emitting host's tailscale IPv4 and IPv6
+    addresses) unless `dst` is set explicitly. Grants are collected fleet-wide
+    and rendered into the headscale policy file.
+
+    Example:
+      {
+        src = ["*"];        # Sources allowed to connect
+        ip = ["tcp:22"];    # Network capabilities
+        app = [];           # Application capabilities (optional)
+        dst = null;         # Optional explicit destination ("tag:xxx")
+      }
+  '';
+
+  den.policies.collect-tailscale-grants = _: let
+    inherit (den.lib.policy) pipe;
+  in [
+    (pipe.from "tailscaleGrant" [
+      (pipe.collectAll ({host, ...}: true))
+      pipe.withProvenance
+    ])
+  ];
+
+  den.schema.host.includes = [den.policies.collect-tailscale-grants];
+}
modules/services/mihomo/default.nix
@@ -45,6 +45,7 @@
       proxy-groups
       rules
       sniffer
+      grants
     ];
 
     cache = {
@@ -105,4 +106,11 @@
       };
     };
   };
+
+  den.aspects.services.mihomo.grants = {host, ...}: {
+    tailscaleGrant = lib.optional (host.settings.services.mihomo.tailscaleWebControl) {
+      src = ["autogroup:member"];
+      ip = ["tcp:${toString host.settings.services.mihomo.ports.controller}"];
+    };
+  };
 }