Commit cc0b802
Changed files (1)
modules
services
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}";
+ };
+ };
+ };
+ };
+}