main
  1{
  2  den,
  3  lib,
  4  ...
  5}: {
  6  den.aspects.services.tailscale-avoid-direct = {
  7    settings.host.peers = lib.mkOption {
  8      type = lib.types.listOf lib.types.str;
  9      default = [];
 10      description = "Peers to force over relay by dropping direct Tailscale UDP to their public addresses.";
 11    };
 12
 13    nixos = {
 14      host,
 15      config,
 16      pkgs,
 17      ...
 18    }: let
 19      cfg = host.settings.services.tailscale-avoid-direct;
 20
 21      table = "tailscale-avoid-direct";
 22
 23      # Direct WireGuard/disco traffic uses tailscaled's listening port; matching it
 24      # keeps QUIC, STUN and other UDP involving the peer's address untouched.
 25      tailscalePort = 41641;
 26
 27      allHosts = builtins.concatMap builtins.attrValues (builtins.attrValues den.hosts);
 28
 29      hasPublic = addr:
 30        addr.clearText != null || (addr.secret.name != null && addr.secret.file != null);
 31
 32      resolvePeer = name: let
 33        matches = builtins.filter (h: h.name == name) allHosts;
 34      in
 35        assert lib.assertMsg (matches != [])
 36        "tailscale-avoid-direct: host '${name}' is not declared in den.hosts"; let
 37          peer = builtins.head matches;
 38        in
 39          assert lib.assertMsg (hasPublic peer.address.ipv4 || hasPublic peer.address.ipv6)
 40          "tailscale-avoid-direct: host '${name}' has no public IPv4/IPv6 address (see `address` in modules/hosts/schema.nix)"; {
 41            inherit name;
 42            v4 = peer.address.ipv4;
 43            v6 = peer.address.ipv6;
 44          };
 45
 46      peers = map resolvePeer cfg.peers;
 47
 48      secrets = lib.unique (builtins.concatMap (p:
 49        lib.optional (hasPublic p.v4 && p.v4.secret.name != null) {inherit (p.v4.secret) name file;}
 50        ++ lib.optional (hasPublic p.v6 && p.v6.secret.name != null) {inherit (p.v6.secret) name file;})
 51      peers);
 52
 53      # `vaultix.placeholder` is derived from `vaultix.secrets`, so resolve secret
 54      # addresses only below to avoid infinite recursion.
 55      mkAddress = addr:
 56        if addr.clearText != null
 57        then addr.clearText
 58        else if addr.secret.name != null && addr.secret.file != null
 59        then config.vaultix.placeholder.${addr.secret.name}
 60        else null;
 61
 62      values = family: builtins.filter (v: v != null) (map (p: mkAddress p.${family}) peers);
 63      v4 = values "v4";
 64      v6 = values "v6";
 65
 66      mkDrop = family: direction: protocol: addrs:
 67        lib.optionalString (addrs != [])
 68        "${family} ${direction} { ${lib.concatStringsSep ", " addrs} } udp ${protocol} ${toString tailscalePort} counter drop;";
 69
 70      ruleset = ''
 71        table inet ${table} {
 72          chain input {
 73            type filter hook input priority filter; policy accept;
 74            ${mkDrop "ip" "saddr" "sport" v4}
 75            ${mkDrop "ip6" "saddr" "sport" v6}
 76          }
 77
 78          chain output {
 79            type filter hook output priority filter; policy accept;
 80            ${mkDrop "ip" "daddr" "dport" v4}
 81            ${mkDrop "ip6" "daddr" "dport" v6}
 82          }
 83        }
 84      '';
 85    in
 86      lib.optionalAttrs (cfg.peers != []) {
 87        vaultix.secrets = builtins.listToAttrs (map (s: {
 88            name = s.name;
 89            value.file = s.file;
 90          })
 91          secrets);
 92
 93        vaultix.templates.${table} = {
 94          content = ruleset;
 95          mode = "0400";
 96        };
 97
 98        systemd.services.${table} = {
 99          description = "Block direct Tailscale connections to ${lib.concatStringsSep ", " cfg.peers}";
100          wantedBy = ["multi-user.target"];
101          after = ["vaultix-activate.service" "nftables.service"];
102          before = ["tailscaled.service"];
103          restartTriggers = [ruleset];
104          serviceConfig = {
105            Type = "oneshot";
106            RemainAfterExit = true;
107            ExecStartPre = "-${lib.getExe pkgs.nftables} delete table inet ${table}";
108            ExecStart = "${lib.getExe pkgs.nftables} -f ${config.vaultix.templates.${table}.path}";
109            ExecStop = "-${lib.getExe pkgs.nftables} delete table inet ${table}";
110          };
111        };
112      };
113  };
114}