#!/usr/sbin/nft -f # # /etc/nftables.conf # # Check syntax: # $ nft -c -f /etc/nftables.conf # List rules: # $ nft list ruleset # # Assisted-by: Google Gemini Flash (https://gemini.google.com/) # Assisted-by: Anthropic Claude Sonnet 5 (https://claude.ai/) # # Aaron LI # 2026-07-04: Initial version # 2026-07-07: Fix main VLAN DHCP # Fix layer-2 inbound filtering # 2026-07-08: Fix guest VLAN isolation bypass via ISP modem reflection flush ruleset include "/etc/nftables/mac_whitelist.conf" # Interface definitions. define IF_ISP = end0 # Physical wire to ISP modem define IF_HOME = end1 # Physical trunk link to home VLAN switch define IF_BR = br0 # members: $IF_ISP, $IF_MAIN define IF_WG = wg0 define IF_MAIN = vlan33 # Main trusted network interface define IF_GUEST = vlan44 # Isolated guest network interface # Firewall mark definitions. define MARK_GUEST = 44 # tag guest VLAN traffic define MARK_TPROXY = 64 # tag TPROXY'ed traffic # Local networks to exclude from TPROXY. define LOCAL_NETS_V4 = { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, } define LOCAL_NETS_V6 = { ::1/128, fe80::/10, fd00::/8, } # Users of processes that perform DNS queries. define DNS_USERS = { "dnsmasq", "_dnscrypt-proxy" } define SSH_PORT = 22 # Layer 2 bridge filtering. # (main network <-> ISP modem) table bridge bridge_filter { # Filter frames destined to the firewall itself, including # locally-absorbed broadcast/multicast (e.g., DHCPDISCOVER). chain input { type filter hook input priority filter policy drop ct state established,related accept \ comment "allow returning established traffic" ether type arp accept \ comment "allow indispensable layer 2 ARP resolution" # Only the main LAN may talk to the firewall's own services. iifname $IF_MAIN accept \ comment "allow main VLAN to reach firewall services" # Explicit exceptions from the untrusted ISP side. iifname $IF_ISP meta l4proto udp \ udp sport { 67, 547 } accept \ comment "allow DHCP replies" iifname $IF_ISP meta l4proto ipv6-icmp \ icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept \ comment "allow IPv6 NDP/SLAAC" counter comment "bridge input drops" } # Filter frames being relayed between bridge ports. chain forward { type filter hook forward priority filter policy drop ct state established,related accept ether type arp accept # Outbound: enforce MAC whitelist boundary to the ISP modem. iifname $IF_MAIN oifname $IF_ISP ether saddr $TRUSTED_MACS accept \ comment "allow whitelisted LAN hardware out to WAN" # Inbound: explicit exceptions from the untrusted ISP side. iifname $IF_ISP oifname $IF_MAIN meta l4proto udp \ udp sport { 67, 547 } accept \ comment "allow DHCP replies" iifname $IF_ISP oifname $IF_MAIN meta l4proto ipv6-icmp \ icmpv6 type { nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept \ comment "allow IPv6 NDP/SLAAC" counter comment "bridge forward drops" } chain output { type filter hook output priority filter policy accept meta mark $MARK_GUEST meta obrname $IF_MAIN counter drop \ comment "prevent guest VLAN from accessing main VLAN" counter comment "bridge output accepts" # XXX } } # Configure rules to protect the firewall host and to isolate guest VLAN. # # NOTE: Accepted layer-2 traffic also enters here because of enabled # 'net.bridge.bridge-nf-call-*' sysctls. # # NOTE: For traffic actually crossing the bridge (main VLAN <-> ISP modem), # the kernel reports BOTH iifname and oifname as br0, never the real member # port (vlan33 / end0) -- this is the same reason classic iptables needs # the `physdev` match for bridged traffic. table inet filter { chain prerouting { # Use priority 'raw' to evaulate earlier than 'proxy_deflect' # and 'dns_hijack', making the logic here more solid and clean. type filter hook prerouting priority raw policy accept # NOTE: This is required to implement guest VLAN isolation! # The main VLAN and the ISP modem share the same L3 network, # so the guest traffic targeting the main network will be # reflected back by the ISP modem, bypassing the isolation # policy in the 'bridge_filter output' chain. iifname != "lo" fib saddr type local counter log prefix "NFT:anti-spoof:" drop \ comment "anti-spoof: drop packet claiming a locally-owned saddr" # XXX: multicast ??? # Jul 08 21:35:40 firewall kernel: NFT:anti-spoof:IN=vlan44 OUT= MAC= SRC=fe80:0000:0000:0000:082e:65ff:fed7:d587 DST=ff02:0000:0000:0000:0000:0000:0000:0001 LEN=128 TC=192 HOPLIMIT=255 FLOWLBL=902282 PROTO=ICMPv6 TYPE=134 CODE=0 iifname $IF_GUEST meta mark set $MARK_GUEST counter \ comment "tag traffic originating from guest VLAN" } chain input { type filter hook input priority filter policy drop iifname "lo" accept ct state established,related accept ip protocol icmp accept ip protocol igmp accept ip6 nexthdr ipv6-icmp accept # Bridged main VLAN traffic presents iifname=$IF_BR here. # Access from the untrusted ISP side is already excluded by the # bridge_filter's input chain above. iifname { $IF_BR, $IF_WG } tcp dport $SSH_PORT counter accept \ comment "allow SSH access from trusted networks" iifname $IF_BR udp dport { 67, 547 } counter accept \ comment "allow local copy of DHCP broadcast from main VLAN" iifname $IF_BR udp sport { 67, 547 } counter accept \ comment "allow local copy of DHCP broadcast from ISP modem" iifname $IF_GUEST udp dport { 67, 547 } counter accept \ comment "allow DHCP requests from guest VLAN" udp dport 5353 counter accept \ comment "allow mDNS discovery" # Accept hijacked DNS traffic heading to local DNS server. meta l4proto { udp, tcp } th dport 53 counter accept \ comment "allow access to local DNS service" counter comment "input drops" } chain forward { type filter hook forward priority filter policy drop ct state established,related accept # Bridged main VLAN traffic presents iifname/oifname=$IF_BR # here. Since L2 boundary is already enforced in the # bridge_filter table, it's safe to accept here. iifname $IF_BR oifname $IF_BR counter accept \ comment "allow bridged main VLAN traffic" # Guest VLAN is genuinely routed, so iifname/oifname correctly # reflect the real interface. iifname $IF_GUEST oifname $IF_BR counter accept \ comment "allow guest VLAN for outbound internet access" counter comment "forward drops" } chain output { type filter hook output priority filter policy accept counter comment "output accepts" # XXX } } # Configure NAT for the guest VLAN. table inet guest_nat { chain postrouting { type nat hook postrouting priority srcnat policy accept iifname $IF_GUEST oifname $IF_BR masquerade \ comment "masquerade outbound packets from the guest VLAN" } } # Configure TPROXY for censorship circumvention. table inet proxy_deflect { set censored_addrs_v4 { type ipv4_addr flags dynamic, timeout timeout 24h } set censored_addrs_v6 { type ipv6_addr flags dynamic, timeout timeout 24h } chain prerouting { type filter hook prerouting priority mangle policy accept iifname $IF_GUEST return \ comment "bypass guest network traffic" ip daddr $LOCAL_NETS_V4 return \ comment "shortcut local traffic" ip6 daddr $LOCAL_NETS_V6 return \ comment "shortcut local traffic" # Divert censored destinations via TPROXY. # # NOTE: Must configure the following policy routing rules to # deliver the packets locally: # ``` # ip rule add fwmark 64 table 100 # ip route replace local default dev lo table 100 # ip -6 rule add fwmark 64 table 100 # ip -6 route replace local default dev lo table 100 # ``` ip daddr @censored_addrs_v4 meta l4proto { tcp, udp } \ meta mark set $MARK_TPROXY tproxy ip to :8964 counter accept \ comment "divert transit traffic targeting censored destinations" ip6 daddr @censored_addrs_v6 meta l4proto { tcp, udp } \ meta mark set $MARK_TPROXY tproxy ip6 to :8964 counter accept \ comment "divert transit traffic targeting censored destinations" } } # Configure DNS Hijacking table inet dns_hijack { chain prerouting { type nat hook prerouting priority dstnat policy accept iifname $IF_GUEST return \ comment "bypass guest DNS queries" # Prevent routing loops from self-initiated DNS queries. meta skuid $DNS_USERS counter return \ comment "ignore self-initiated traffic to prevent loop" # Hijack transit DNS queries to local resolver. meta l4proto { udp, tcp } th dport 53 counter redirect to :53 \ comment "hijack transit DNS queries to local resolver" } }