Drop fail2ban: Native Dynamic Blocklists with nftables Sets

Share
Drop fail2ban: Native Dynamic Blocklists with nftables Sets

fail2ban works, but it scales badly. Every banned IP becomes a discrete iptables -A rule, and at a few thousand entries the rule chain becomes the bottleneck — every packet walks a linear list. nftables sets fix this with O(1) lookup, native timeouts, and atomic updates. No daemon required for the dataplane.

This is the minimal config I use in production.

The base ruleset

/etc/nftables.conf:

#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    set blocklist4 {
        type ipv4_addr
        flags timeout
        size 65536
    }

    set blocklist6 {
        type ipv6_addr
        flags timeout
        size 65536
    }

    chain input {
        type filter hook input priority 0; policy drop;

        iif lo accept
        ct state established,related accept
        ct state invalid drop

        ip  saddr @blocklist4 drop
        ip6 saddr @blocklist6 drop

        tcp dport { 22, 80, 443 } accept
        icmp type echo-request limit rate 5/second accept
        icmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept
    }
}

Apply and enable:

nft -f /etc/nftables.conf
systemctl enable --now nftables

The flags timeout on the set means each element carries its own expiry. The kernel evicts entries automatically — no cron, no userspace sweep.

Banning an IP

nft add element inet filter blocklist4 '{ 203.0.113.45 timeout 1h }'

That's it. Lookup is O(1), and it expires in an hour without any further action.

Bulk add from a file:

nft -f - <<EOF
add element inet filter blocklist4 {
    198.51.100.10 timeout 24h,
    198.51.100.11 timeout 24h,
    198.51.100.12 timeout 24h
}
EOF

Inspect:

nft list set inet filter blocklist4

Remove early:

nft delete element inet filter blocklist4 '{ 203.0.113.45 }'

Wire it to your log scanners

Two clean options.

Option A: fail2ban with the nftables backend

If you already run fail2ban, swap the backend. Don't run both backends — pick one.

/etc/fail2ban/jail.local:

[DEFAULT]
banaction = nftables-allports
banaction_allports = nftables-allports

fail2ban will write to its own nftables table by default. To target the set above instead, drop a custom action in /etc/fail2ban/action.d/nft-blocklist.local:

[Definition]
actionban   = nft add element inet filter blocklist4 '{ <ip> timeout <bantime>s }'
actionunban = nft delete element inet filter blocklist4 '{ <ip> }' || true

Reference it in your jail:

[sshd]
enabled = true
banaction = nft-blocklist
bantime = 3600
findtime = 600
maxretry = 5

Now fail2ban's role is just log-scanning and decision-making. The dataplane is pure kernel.

Option B: drop fail2ban, use CrowdSec

CrowdSec is a modern log scanner with collective threat intel — your IPs get pre-emptive bans based on what other instances are reporting. It has a native nftables bouncer.

curl -s https://install.crowdsec.net | sudo sh
apt install crowdsec crowdsec-firewall-bouncer-nftables

The bouncer manages its own set (crowdsec-blacklists) under a crowdsec table by default. Override the config in /etc/crowdsec/bouncers/crowdsec-firewall-bouncer.yaml to write into your existing inet filter table if you want a single chokepoint.

⚠️ CrowdSec's free community blocklist is good but not infallible. Always run your local-decision scenarios alongside the cloud feed, not instead of it.

Why this is faster than legacy iptables fail2ban

  • Set lookup is hash-based, not linear chain walking. 50,000 entries cost the same per packet as 50.
  • Timeouts are kernel-managed; no userspace process to tick them down.
  • Updates are atomic — adding 10,000 IPs in one transaction doesn't disrupt traffic mid-update.
  • One rule (ip saddr @blocklist4 drop) handles the entire blocklist, regardless of size.

On a host that was hitting softirq saturation under iptables fail2ban with ~8,000 banned IPs, the same host with nftables sets sits at near-zero CPU for filtering.

Gotchas

⚠️ nft -f flushes the ruleset. Reloading /etc/nftables.conf wipes the live set contents along with the rules. If you want to preserve dynamic bans across reload, dump and reload:

nft list set inet filter blocklist4 > /tmp/blocklist4.nft
nft -f /etc/nftables.conf
nft -f /tmp/blocklist4.nft

Better: keep dynamic bans in a separate file the scanner manages, and never nft -f the main ruleset on a hot system.

⚠️ size 65536 is a hard ceiling. Hit it and add element starts failing silently for new entries. Bump it if you're seeing high volume:

set blocklist4 {
    type ipv4_addr
    flags timeout
    size 262144
}

⚠️ CIDR blocks need flags interval. The above sets store individual IPs only. To block ranges:

set blocklist4_cidr {
    type ipv4_addr
    flags interval, timeout
    size 65536
}

# Then:
nft add element inet filter blocklist4_cidr '{ 198.51.100.0/24 timeout 24h }'

You can have both an exact-match set and an interval set in the same chain, with two drop rules.

Hardening notes

  • Combine with IPAddressDeny= per-service in systemd for defense in depth — your firewall blocks at the host edge, systemd blocks at the service cgroup, and one failure doesn't bypass the other.
  • Don't expose the management interface used to write the set. If a userspace daemon updates it, the daemon's privileges are the trust boundary — sandbox it (see the systemd hardening cookbook).

Log dropped packets at limited rate before the drop:

ip saddr @blocklist4 limit rate 10/minute log prefix "blocklist4: " drop

Without the rate limit, a sustained flood becomes a journal DoS against itself.

The full migration from iptables fail2ban takes about twenty minutes per host. The CPU savings show up immediately. The rule-table sanity is permanent.