DIY Debian Router - Part 6: Secure firewall with nftables

Oct 8, 2025

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 performance
  • inet 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 traffic
  • prerouting (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 br0 fully 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 notrack for 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:

  1. Verify nftables loaded correctly:

    nft list ruleset
    
  2. Check forward chain rules exist:

    nft list chain inet filter forward
    
  3. Temporarily flush ruleset for testing:

    nft flush ruleset
    

If Internet works with ruleset flushed, the configuration has errors.

Common issues:

  • Incorrect interface names in define variables
  • Missing ct state established,related accept rule
  • 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:

  1. Increasing conntrack table size (covered in Part 7)
  2. Using raw table notrack optimization (already implemented)
  3. 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 accept

Attack 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.

RSS
https://yusefkarim.absurdum.ca/posts/feed.xml