Commit cc0b802

HPCesia <me@hpcesia.com>
2026-09-21 16:02:26
tailscale-avoid-direct: init
1 parent 726d7a9
Changed files (1)
modules/services/tailscale-avoid-direct.nix
@@ -0,0 +1,114 @@
+{
+  den,
+  lib,
+  ...
+}: {
+  den.aspects.services.tailscale-avoid-direct = {
+    settings.host.peers = lib.mkOption {
+      type = lib.types.listOf lib.types.str;
+      default = [];
+      description = "Peers to force over relay by dropping direct Tailscale UDP to their public addresses.";
+    };
+
+    nixos = {
+      host,
+      config,
+      pkgs,
+      ...
+    }: let
+      cfg = host.settings.services.tailscale-avoid-direct;
+
+      table = "tailscale-avoid-direct";
+
+      # Direct WireGuard/disco traffic uses tailscaled's listening port; matching it
+      # keeps QUIC, STUN and other UDP involving the peer's address untouched.
+      tailscalePort = 41641;
+
+      allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
+
+      hasPublic = addr:
+        addr.clearText != null || (addr.secret.name != null && addr.secret.file != null);
+
+      resolvePeer = name: let
+        matches = builtins.filter (h: h.name == name) allHosts;
+      in
+        assert lib.assertMsg (matches != [])
+        "tailscale-avoid-direct: host '${name}' is not declared in den.hosts"; let
+          peer = builtins.head matches;
+        in
+          assert lib.assertMsg (hasPublic peer.address.ipv4 || hasPublic peer.address.ipv6)
+          "tailscale-avoid-direct: host '${name}' has no public IPv4/IPv6 address (see `address` in modules/hosts/schema.nix)"; {
+            inherit name;
+            v4 = peer.address.ipv4;
+            v6 = peer.address.ipv6;
+          };
+
+      peers = map resolvePeer cfg.peers;
+
+      secrets = lib.unique (builtins.concatMap (p:
+        lib.optional (hasPublic p.v4 && p.v4.secret.name != null) {inherit (p.v4.secret) name file;}
+        ++ lib.optional (hasPublic p.v6 && p.v6.secret.name != null) {inherit (p.v6.secret) name file;})
+      peers);
+
+      # `vaultix.placeholder` is derived from `vaultix.secrets`, so resolve secret
+      # addresses only below to avoid infinite recursion.
+      mkAddress = addr:
+        if addr.clearText != null
+        then addr.clearText
+        else if addr.secret.name != null && addr.secret.file != null
+        then config.vaultix.placeholder.${addr.secret.name}
+        else null;
+
+      values = family: builtins.filter (v: v != null) (map (p: mkAddress p.${family}) peers);
+      v4 = values "v4";
+      v6 = values "v6";
+
+      mkDrop = family: direction: protocol: addrs:
+        lib.optionalString (addrs != [])
+        "${family} ${direction} { ${lib.concatStringsSep ", " addrs} } udp ${protocol} ${toString tailscalePort} counter drop;";
+
+      ruleset = ''
+        table inet ${table} {
+          chain input {
+            type filter hook input priority filter; policy accept;
+            ${mkDrop "ip" "saddr" "sport" v4}
+            ${mkDrop "ip6" "saddr" "sport" v6}
+          }
+
+          chain output {
+            type filter hook output priority filter; policy accept;
+            ${mkDrop "ip" "daddr" "dport" v4}
+            ${mkDrop "ip6" "daddr" "dport" v6}
+          }
+        }
+      '';
+    in
+      lib.optionalAttrs (cfg.peers != []) {
+        vaultix.secrets = builtins.listToAttrs (map (s: {
+            name = s.name;
+            value.file = s.file;
+          })
+          secrets);
+
+        vaultix.templates.${table} = {
+          content = ruleset;
+          mode = "0400";
+        };
+
+        systemd.services.${table} = {
+          description = "Block direct Tailscale connections to ${lib.concatStringsSep ", " cfg.peers}";
+          wantedBy = ["multi-user.target"];
+          after = ["vaultix-activate.service" "nftables.service"];
+          before = ["tailscaled.service"];
+          restartTriggers = [ruleset];
+          serviceConfig = {
+            Type = "oneshot";
+            RemainAfterExit = true;
+            ExecStartPre = "-${lib.getExe pkgs.nftables} delete table inet ${table}";
+            ExecStart = "${lib.getExe pkgs.nftables} -f ${config.vaultix.templates.${table}.path}";
+            ExecStop = "-${lib.getExe pkgs.nftables} delete table inet ${table}";
+          };
+        };
+      };
+  };
+}