main
  1{lib, ...}: {
  2  den.aspects.services.mihomo.tproxy = {
  3    nixos = {
  4      host,
  5      pkgs,
  6      ...
  7    }: let
  8      cfg = host.settings.services.mihomo;
  9      tproxyMark = "666";
 10      # Marks WAN-bound local traffic in the output chain. Must differ
 11      # from tproxyMark and its policy rule must stay un-qualified:
 12      # fib_validate_source() reverse lookups always run with
 13      # flowi4_iif = lo and (src_valid_mark=1, set by tailscaled) keep
 14      # the packet mark, so reusing mark 666 or adding "iif lo" would
 15      # route LAN reverse lookups into table 100 again and drop them
 16      # as martians. Looped-back self packets skip source validation
 17      # because their output dst survives loopback_xmit.
 18      selfMark = "667";
 19      tproxyRules = pkgs.writeText "mihomo-tproxy.nft" ''
 20        table inet mihomo {
 21          define MIHOMO_TPROXY_MARK=${tproxyMark}
 22          define MIHOMO_SELF_MARK=${selfMark}
 23          define MIHOMO_TPROXY_PORT=${toString cfg.ports.tproxy}
 24          define MIHOMO_DNS_PORT=${toString cfg.ports.dns}
 25          set bypass-ipv4 {
 26            type ipv4_addr
 27            flags interval
 28            elements = {
 29              0.0.0.0/8,
 30              10.0.0.0/8,
 31              100.64.0.0/10,
 32              127.0.0.0/8,
 33              169.254.0.0/16,
 34              172.16.0.0/12,
 35              192.168.0.0/16,
 36              224.0.0.0/4,
 37              240.0.0.0/4
 38            }
 39          }
 40          set bypass-ipv6 {
 41            type ipv6_addr
 42            flags interval
 43            elements = {
 44              ::/128,
 45              ::1/128,
 46              fc00::/7,
 47              fe80::/10,
 48              ff00::/8
 49            }
 50          }
 51          set bypass-tcp-ports {
 52            type inet_service
 53            elements = { 53, 67, 68, 123 }
 54          }
 55          set bypass-udp-ports {
 56            type inet_service
 57            # 3478: STUN; 41641: WireGuard endpoints of Tailscale peers.
 58            elements = { 53, 67, 68, 123, 3478, 41641 }
 59          }
 60          set bypass-udp-sports {
 61            type inet_service
 62            # Tailscale WireGuard sockets on LAN clients. Proxying them
 63            # breaks NAT endpoint discovery, and the router's own
 64            # tailscaled already occupies UDP 41641, so mihomo cannot
 65            # even bind its transparent reply socket (EADDRINUSE).
 66            elements = { 41641 }
 67          }
 68          set outbounds {
 69            type ifname
 70            elements = { ${cfg.interfaces.wan} }
 71          }
 72          chain tproxy-prerouting {
 73            type filter hook prerouting priority mangle; policy accept;
 74            meta l4proto { tcp, udp } socket transparent 1 mark set $MIHOMO_TPROXY_MARK return
 75            socket transparent 0 socket wildcard 0 return
 76            ip daddr @bypass-ipv4 return
 77            ip6 daddr @bypass-ipv6 return
 78            tcp dport @bypass-tcp-ports return
 79            udp dport @bypass-udp-ports return
 80            udp sport @bypass-udp-sports return
 81            fib daddr type { local, broadcast, anycast, multicast } return
 82            meta l4proto { tcp, udp } tproxy to :$MIHOMO_TPROXY_PORT meta mark set $MIHOMO_TPROXY_MARK return
 83          }
 84          chain tproxy-output {
 85            type route hook output priority mangle; policy accept;
 86            # Exempt mihomo's own traffic (proxy nodes, DoH, providers).
 87            meta skuid mihomo return
 88            oifname != @outbounds return
 89            ip daddr @bypass-ipv4 return
 90            ip6 daddr @bypass-ipv6 return
 91            tcp dport @bypass-tcp-ports return
 92            udp dport @bypass-udp-ports return
 93            udp sport @bypass-udp-sports return
 94            fib daddr type { local, broadcast, anycast, multicast } return
 95            meta l4proto { tcp, udp } meta mark set $MIHOMO_SELF_MARK return
 96          }
 97          chain dns-prerouting {
 98            type nat hook prerouting priority dstnat; policy accept;
 99            tcp dport 53 redirect to :$MIHOMO_DNS_PORT
100            udp dport 53 redirect to :$MIHOMO_DNS_PORT
101          }
102          chain dns-output {
103            type nat hook output priority dstnat; policy accept;
104            meta skuid mihomo return
105            # Keep systemd-resolved loops (127.0.0.53) and Tailscale
106            # MagicDNS (100.100.100.100) intact.
107            ip daddr @bypass-ipv4 return
108            ip6 daddr @bypass-ipv6 return
109            tcp dport 53 redirect to :$MIHOMO_DNS_PORT
110            udp dport 53 redirect to :$MIHOMO_DNS_PORT
111          }
112        }
113      '';
114    in {
115      boot.kernel.sysctl = {
116        "net.ipv4.conf.all.rp_filter" = 0;
117        "net.ipv4.conf.default.rp_filter" = 0;
118      };
119
120      # The nftables firewall's strict rpfilter chain (priority mangle + 10)
121      # runs right after tproxy-prerouting (priority mangle) and does
122      # `fib saddr . mark . iif oif` with the tproxy mark set: the lookup
123      # hits table 100 (local default dev lo), oif=lo never equals iif,
124      # so every tproxied packet would be dropped without this exemption.
125      networking.firewall.extraReversePathFilterRules = "meta mark { ${tproxyMark}, ${selfMark} } accept";
126
127      services.mihomo = {
128        # tunMode grants CAP_NET_ADMIN + PrivateUsers=false needed for
129        # IP_TRANSPARENT. tun.enable = false keeps the actual TUN
130        # device off — traffic interception is done via nftables instead.
131        tunMode = true;
132        config = {
133          tun.enable = lib.mkForce false;
134          tproxy-port = cfg.ports.tproxy;
135        };
136      };
137
138      systemd.services.mihomo = {
139        serviceConfig = let
140          ip = lib.getExe' pkgs.iproute2 "ip";
141          # tailscaled sets net.ipv4.conf.all.src_valid_mark=1, making
142          # fib_validate_source() keep the fwmark during its reverse
143          # lookup. Without an iif qualifier that lookup would also hit
144          # table 100 (local default dev lo -> RTN_LOCAL, not
145          # RTN_UNICAST) and every marked packet would be dropped as a
146          # martian. The reverse lookup runs with flowi4_iif = lo, so
147          # restricting the rule to LAN ingress keeps it out of source
148          # validation while forward lookups from LAN still match.
149          ipRulesAdd =
150            lib.concatMapStrings (ifname: ''
151              ${ip} rule add fwmark ${tproxyMark} iif ${ifname} lookup 100 pref 5000 2>/dev/null || true
152            '')
153            cfg.interfaces.lan;
154          ipRulesDel =
155            lib.concatMapStrings (ifname: ''
156              ${ip} rule del fwmark ${tproxyMark} iif ${ifname} lookup 100 pref 5000 2>/dev/null || true
157            '')
158            cfg.interfaces.lan;
159        in {
160          ExecStartPre = lib.mkAfter [
161            "+${pkgs.writeShellScript "mihomo-tproxy-start" ''
162              ${lib.getExe pkgs.nftables} delete table inet mihomo 2>/dev/null || true
163              ${lib.getExe pkgs.nftables} -f ${tproxyRules}
164              ${ipRulesAdd}
165              ${ip} rule add fwmark ${selfMark} lookup 100 pref 5001 2>/dev/null || true
166              ${ip} route add local 0.0.0.0/0 dev lo table 100 2>/dev/null || true
167            ''}"
168          ];
169          ExecStopPost = lib.mkAfter [
170            "+${pkgs.writeShellScript "mihomo-tproxy-stop" ''
171              ${ip} route del local 0.0.0.0/0 dev lo table 100 2>/dev/null || true
172              ${ip} rule del fwmark ${selfMark} lookup 100 pref 5001 2>/dev/null || true
173              ${ipRulesDel}
174              ${lib.getExe pkgs.nftables} delete table inet mihomo 2>/dev/null || true
175            ''}"
176          ];
177          AmbientCapabilities = lib.mkAfter ["CAP_NET_BIND_SERVICE"];
178          CapabilityBoundingSet = lib.mkAfter ["CAP_NET_BIND_SERVICE"];
179        };
180      };
181    };
182  };
183}