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}