DIY Debian Router - Part 6: Secure firewall with nftables
Introduction
This is Part 6 of the DIY Debian Router series. See Part 1 for the series introduction and links to all parts.
The firewall enforces the security boundary between untrusted WAN (Internet) and trusted LAN (internal network). This part covers the implementation of stateful packet filtering using nftables, the modern Linux firewall framework.
We'll cover:
- Default-deny ingress on WAN (only established/related traffic permitted)
- Stateful connection tracking for IPv4 and IPv6
- Anti-spoofing via bogon filtering and reverse path filtering
- ICMP/ICMPv6 rate limiting for DoS mitigation
- IPv4 NAT/masquerading for outbound Internet access
- Connection tracking optimization for performance
Firewall architecture overview
The ruleset is organized into multiple tables and chains:
Tables
inet raw(priority: raw): Connection tracking bypass for performanceinet filter(priority: filter): Stateful packet filtering (forward, input, output chains)ip nat(priority: srcnat/dstnat): IPv4 Network Address Translation
Chains
forward: Filters traffic traversing the router (LAN ↔ WAN)input: Filters traffic destined for the router itself (management, DNS, DHCP)output: Filters traffic originating from the router (minimal restrictions)postrouting(NAT): Source NAT (masquerading) for IPv4 LAN trafficprerouting(NAT): Destination NAT (port forwarding, not configured by default)
Complete firewall configuration
Create /etc/nftables.conf:
#!/usr/sbin/nft -f
# Home router firewall configuration
flush ruleset
################## Configuration variables ##################
define wan = enp8s0
define main_lan = br0
define home_ipv4 = 192.168.0.0/24
define home_ula = fd09:dead:beef::/64
# Bogon/martian addresses that should never appear from WAN
define bogons_v4 = {
0.0.0.0/8, # "This" network
10.0.0.0/8, # Private-Use
100.64.0.0/10, # Shared Address Space
127.0.0.0/8, # Loopback
169.254.0.0/16, # Link Local
172.16.0.0/12, # Private-Use
192.0.0.0/24, # IETF Protocol Assignments
192.0.2.0/24, # Documentation (TEST-NET-1)
192.168.0.0/16, # Private-Use
198.18.0.0/15, # Benchmarking
198.51.100.0/24, # Documentation (TEST-NET-2)
203.0.113.0/24, # Documentation (TEST-NET-3)
224.0.0.0/4, # Multicast
240.0.0.0/4, # Reserved
255.255.255.255/32 # Limited Broadcast
}
define bogons_v6 = {
0100::/64, # RFC 6666 Discard-Only
2001:2::/48, # RFC 5180 BMWG
2001:10::/28, # RFC 4843 ORCHID
2001:db8::/32, # RFC 3849 documentation
2002::/16, # RFC 7526 6to4 anycast relay
3ffe::/16, # RFC 3701 old 6bone
3fff::/20, # RFC 9637 documentation
5f00::/16, # RFC 9602 SRv6 SIDs
fc00::/7, # RFC 4193 unique local unicast
fe80::/10, # RFC 4291 link local unicast
fec0::/10, # RFC 3879 old site local unicast
ff00::/8 # RFC 4291 multicast
}
################## Connection tracking optimization ##################
table inet raw {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
# Bypass conntrack for established connections (performance)
ct state established notrack
}
chain output {
type filter hook output priority raw; policy accept;
# Bypass conntrack for outgoing established connections
ct state established notrack
}
}
################## Main firewall table ##################
table inet filter {
################## Forward Chain ##################
chain forward {
type filter hook forward priority filter; policy drop;
# Drop invalid connection states immediately
ct state invalid drop
# Allow established/related connections first (most common traffic)
ct state established,related accept
# Allow traffic within main LAN (full internal connectivity)
iifname $main_lan oifname $main_lan accept comment "LAN to LAN traffic"
# Allow main LAN to WAN (outbound Internet access)
iifname $main_lan oifname $wan accept comment "LAN to WAN (Internet)"
# WAN to LAN: only established/related (handled above, explicit for clarity)
# Everything else is dropped by policy
# Optional: Log dropped forward attempts (disable for performance)
# limit rate 5/minute log prefix "nft-forward-drop: "
}
################## Input Chain ##################
chain input {
type filter hook input priority filter; policy accept;
# Drop invalid states immediately
ct state invalid drop comment "Drop invalid packets"
# Allow loopback interface (always trusted)
iif lo accept comment "Allow loopback"
# Allow established/related connections (most traffic)
ct state established,related accept comment "Allow established connections"
# === LAN Interface Rules ===
# Trust all traffic from main LAN
iifname $main_lan accept comment "Trust main LAN completely"
# === WAN Interface Rules ===
# Everything below this point is WAN-specific with security hardening
# Anti-spoofing: Drop packets with private/bogon source addresses from WAN
iifname $wan ip saddr $bogons_v4 drop comment "Drop IPv4 bogon/spoofed addresses"
iifname $wan ip saddr $home_ipv4 drop comment "Drop spoofed LAN addresses"
iifname $wan ip6 saddr $bogons_v6 drop comment "Drop IPv6 bogon/spoofed addresses"
iifname $wan ip6 saddr $home_ula drop comment "Drop spoofed ULA addresses"
# DHCP client (router getting IP from ISP)
iifname $wan udp sport 67 udp dport 68 accept comment "DHCPv4 client"
iifname $wan udp sport 547 udp dport 546 accept comment "DHCPv6 client"
# === ICMP (IPv4) Rules ===
# Rate-limited ping responses (5 per second)
iifname $wan ip protocol icmp icmp type echo-request \
limit rate 5/second \
accept comment "Allow rate-limited ping"
# Essential ICMP types (always allow)
iifname $wan ip protocol icmp icmp type {
echo-reply,
destination-unreachable,
time-exceeded
} accept comment "Essential ICMP types"
# Path MTU Discovery (critical, no rate limit, all interfaces)
ip protocol icmp icmp type destination-unreachable \
icmp code frag-needed \
accept comment "PMTU Discovery"
# === ICMPv6 Rules ===
# Rate-limited IPv6 ping (5 per second)
iifname $wan meta l4proto ipv6-icmp icmpv6 type echo-request \
limit rate 5/second \
accept comment "Allow rate-limited ICMPv6 ping"
# Essential ICMPv6 for IPv6 operation (Neighbor Discovery, etc.)
# These should typically come from link-local addresses
iifname $wan ip6 saddr fe80::/10 meta l4proto ipv6-icmp icmpv6 type {
nd-router-advert,
nd-router-solicit,
nd-neighbor-solicit,
nd-neighbor-advert
} accept comment "ICMPv6 Neighbor Discovery (link-local)"
# Other essential ICMPv6 types (any source)
iifname $wan meta l4proto ipv6-icmp icmpv6 type {
echo-reply,
packet-too-big,
time-exceeded,
destination-unreachable,
mld-listener-query,
mld-listener-report,
mld-listener-done
} accept comment "Essential ICMPv6 types"
# === SYN Flood Protection ===
# Limit SYN packets (100/second with burst of 200)
iifname $wan tcp flags syn \
limit rate 100/second burst 200 packets \
accept comment "SYN flood protection"
# === Final WAN Policy ===
# Drop everything else from WAN
iifname $wan drop comment "Drop all other WAN traffic"
# Optional: Log dropped input (disable for performance)
# limit rate 5/minute log prefix "nft-input-drop: "
}
################## Output Chain ##################
chain output {
type filter hook output priority filter; policy accept;
# Output is generally trusted, but drop invalid states
ct state invalid drop
}
}
################## NAT Table (IPv4 only) ##################
table ip nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
# Masquerade outgoing IPv4 traffic from LAN to WAN
oifname $wan ip saddr $home_ipv4 masquerade comment "NAT for LAN to Internet"
}
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
# Port forwarding rules can be added here
# Example: iifname $wan tcp dport 80 dnat to 192.168.0.10:80
}
}
################## Management Notes ##################
#
# Apply this configuration:
# nft -f /etc/nftables.conf
#
# Save current ruleset:
# nft list ruleset > /etc/nftables.conf
#
# Monitor live traffic:
# nft monitor
#
# List ruleset with handles:
# nft list ruleset -a
#
# Delete specific rule:
# nft delete rule inet filter input handle <number>
#
# Enable at boot (systemd):
# systemctl enable --now nftables.service
#
# Rate limit configuration notes:
# - ICMP rate limit: 5/second (prevents ping floods)
# - SYN rate limit: 100/second burst 200 (prevents SYN floods)
# - Adjust these values based on your WAN bandwidth and threat model
#
################## End Configuration ##################
Key configuration aspects:
- Default-deny forward policy: Transit traffic blocked unless explicitly permitted; only established/related connections and LAN-initiated outbound traffic allowed
- WAN anti-spoofing protection: Bogon filtering drops packets from WAN with internal private addresses, loopback, link-local, or LAN source addresses
- Stateful connection tracking: All chains prioritize established/related traffic first, with invalid states dropped immediately to block malformed packets
- LAN trust boundary: Traffic from
br0fully trusted for router services (DNS, DHCP, SSH), based on assumption that LAN is secure - ICMP rate limiting: WAN ping requests limited to 5/second; essential types (destination-unreachable, time-exceeded) and PMTU Discovery always permitted
- ICMPv6 Neighbor Discovery: Link-local NDP messages (router-advert, neighbor-solicit) and packet-too-big allowed to prevent IPv6 breakage
- SYN flood mitigation: TCP SYN packets from WAN limited to 100/second with 200-packet burst to prevent connection table exhaustion
- Connection tracking bypass: Raw table optimization uses
notrackfor established connections, reducing CPU usage on high-throughput links - IPv4 masquerading: NAT postrouting translates LAN private addresses to WAN IP; IPv6 operates without NAT using stateful filtering only
- Port forwarding framework: DNAT prerouting chain configured for selective service exposure (disabled by default; requires corresponding forward chain rules)
Enable and start nftables:
systemctl enable --now nftables
Verify ruleset is loaded:
nft list ruleset
Test firewall functionality:
# From LAN client, test Internet access:
curl -I https://google.com
# From router, test DNS:
dig @127.0.0.1 google.com
# From external host, test WAN is blocked:
nmap -Pn [router WAN IP]
Should prove that LAN and router have Internet access, but external ports are blocked (shows all ports filtered).
Testing and verification
Connection tracking table
View active connections using conntrack:
conntrack -L
This displays all tracked connections (TCP, UDP, ICMP). Example output:
tcp 6 431999 ESTABLISHED src=192.168.0.10 dst=1.1.1.1 sport=54321 dport=443 ...ICMP rate limiting test
From an external host, flood the router with pings:
ping -f [router WAN IP]
Monitor firewall logs (if logging enabled):
journalctl -f | grep nft
Initial pings should succeed (within rate limit), subsequent pings should be dropped.
Troubleshooting common issues
All traffic blocked after applying firewall
Symptom: LAN clients lose Internet access.
Diagnosis:
-
Verify nftables loaded correctly:
nft list ruleset -
Check forward chain rules exist:
nft list chain inet filter forward -
Temporarily flush ruleset for testing:
nft flush ruleset
If Internet works with ruleset flushed, the configuration has errors.
Common issues:
- Incorrect interface names in
definevariables - Missing
ct state established,related acceptrule - Incorrect chain priorities
DNS queries fail from router
Symptom: dig fails on router, but works on LAN clients.
Cause: Output chain blocks DNS responses.
Fix: Ensure output chain has policy accept and allows established connections.
IPv6 connectivity broken
Symptom: IPv6 works until firewall is applied.
Cause: ICMPv6 Neighbor Discovery is blocked.
Fix: Ensure ICMPv6 rules (ND, packet-too-big) are present in input chain.
Connection tracking table full
Symptom: New connections fail with "nf_conntrack: table full" in dmesg.
Cause: Connection tracking table exhausted (default: 65536 entries).
Fix: Increase nf_conntrack_max, see Part 7.
Additional firewall techniques
Logging dropped packets
To log dropped packets for forensics:
# In input chain, before final drop:
limit rate 5/minute log prefix "nft-input-drop: " drop
View logs:
journalctl -k | grep nft-input-drop
Warning: Logging drops can generate massive log files under attack. Always rate-limit logging.
Port knocking
Implement SSH access control via port knocking (knock on specific ports in sequence to open SSH):
# Define a set to track knocking state
set knockers {
type ipv4_addr
timeout 30s
}
# Knock sequence: TCP 1234, then SSH allowed for 30s
chain input {
iifname $wan tcp dport 1234 add @knockers { ip saddr }
iifname $wan tcp dport 22 ip saddr @knockers accept
}
This is a simplified example; secure port knocking requires more complex state tracking.
GeoIP blocking
Block traffic from specific countries using nftables sets and GeoIP databases:
# Create set of IP ranges for specific country
define blocked_country = {
# ... list of IP ranges ...
}
# Drop traffic from blocked country
iifname $wan ip saddr $blocked_country drop
GeoIP databases (e.g., MaxMind GeoLite2) require regular updates.
Performance considerations
Connection tracking overhead
Connection tracking (conntrack) is CPU-intensive. For routers handling >1 Gbps throughput with >10,000 concurrent connections, consider:
- Increasing conntrack table size (covered in Part 7)
- Using raw table notrack optimization (already implemented)
- Offloading to hardware (if NIC supports conntrack offload)
Ruleset optimization
nftables evaluates rules sequentially. Place most common rules first:
# Most traffic is established/related:
ct state established,related accept # Evaluated first
# Less common:
ct state invalid drop # Evaluated if not established/related
Use sets for large lists (e.g., bogons) instead of individual rules for O(1) lookup performance.
Security best practices
Principle of Least Privilege
Only permit necessary traffic. The reference configuration:
- WAN input: Only DHCP client and essential ICMP
- LAN input: Trusted (all traffic allowed)
- Forward: LAN-to-WAN and established/related only
For higher security, apply per-service rules even on LAN (e.g., only permit DNS, DHCP).
Regular ruleset audits
Periodically review firewall rules to remove unnecessary exceptions:
nft list ruleset | grep acceptAttack surface minimization
The router exposes no services on WAN by default (no SSH, HTTP, etc.). To further reduce attack surface:
- Disable unused network protocols (if not using IPv6, disable it entirely)
- Run services as unprivileged users (DNS, DHCP)
- Apply AppArmor/SELinux policies (not covered in this series)
Next steps
With the firewall working, the router enforces security policies for all traffic. Part 7 continues the series by taking a look at kernel tuning via sysctl, covering IP forwarding, TCP stack hardening, DoS mitigation, and performance optimization.