Fail2ban with nftables and Crowd-Sourced Blacklists

Step 7: The blacklist update script

Save this as /usr/local/bin/update-abuse-blacklist.sh and make it executable:

sudo chmod +x /usr/local/bin/update-abuse-blacklist.sh
#!/bin/bash
# update-abuse-blacklist.sh
# Fetches crowd-sourced IP blocklists and loads them into nftables.
# Run as a nightly cron job: 0 2 * * * /usr/local/bin/update-abuse-blacklist.sh

API_KEY="YOUR_ABUSEIPDB_API_KEY"

# --------------------------------------------------
# 1. Fetch Blacklists
# --------------------------------------------------

# AbuseIPDB — plaintext, top 10,000 entries at 90%+ confidence
ABUSE=$(curl -s -L -G "https://api.abuseipdb.com/api/v2/blacklist" \
    -d "plaintext=true" \
    -d "limit=10000" \
    -d "confidenceMinimum=90" \
    -H "Key: $API_KEY" \
    -H "Accept: application/json")

# Bitwire — community-maintained inbound threat lists (IPv4 + IPv6)
BITWIRE4=$(curl -s -L "https://raw.githubusercontent.com/bitwire-it/ip-blocklists/main/lists/inbound.txt")
BITWIRE6=$(curl -s -L "https://raw.githubusercontent.com/bitwire-it/ip-blocklists/main/lists/inbound-ipv6.txt")

# Spamhaus DROP/DROPv6 — industry-leading threat intelligence.
# EDROP was consolidated into DROP in April 2024; the EDROP fetch
# is included for backwards compatibility but returns the same data.
SPAM4=$(curl -s https://www.spamhaus.org/drop/drop.txt | grep -v "^;" | awk '{print $1}')
SPAM4_EXT=$(curl -s https://www.spamhaus.org/drop/edrop.txt | grep -v "^;" | awk '{print $1}')
SPAM6=$(curl -s https://www.spamhaus.org/drop/dropv6.txt | grep -v "^;" | awk '{print $1}')

# CINS Army — curated bad-IP list, free, no API key required
CINS4=$(curl -s http://cinsscore.com/list/ci-badguys.txt | grep -E "^[0-9]+\.")

# --------------------------------------------------
# 2. Combine and Deduplicate
# --------------------------------------------------
IPV4=$(printf "%s\n%s\n%s\n%s\n%s\n" "$ABUSE" "$BITWIRE4" "$SPAM4" "$SPAM4_EXT" "$CINS4" \
    | grep -E "^[0-9]" \
    | sort -u)

# If you're not using AbuseIPDB (no API key), remove the $ABUSE reference:
# IPV4=$(printf "%s\n%s\n%s\n%s\n" "$BITWIRE4" "$SPAM4" "$SPAM4_EXT" "$CINS4" \
#     | grep -E "^[0-9]" \
#     | sort -u)

IPV6=$(printf "%s\n%s\n" "$BITWIRE6" "$SPAM6" \
    | grep ":" \
    | sort -u)

# --------------------------------------------------
# 3. Update nftables Sets
# --------------------------------------------------

# Flush existing sets to start fresh
nft flush set inet f2b-table abuse-blacklist
nft flush set inet f2b-table abuse-blacklist6

# IPv4: validate with regex, sort, add in batches of 400
echo "$IPV4" | \
    grep -E "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[12][0-9]|3[0-2]))?$" | \
    sort -u | \
    xargs -r -n 400 sh -c 'nft add element inet f2b-table abuse-blacklist { $(echo "$*" | tr " " ",") } 2>/dev/null' _

# IPv6: validate with regex, sort, add in batches of 400
echo "$IPV6" | \
    grep -E "^[0-9a-fA-F:]+(/[0-9]{1,3})?$" | \
    sort -u | \
    xargs -r -n 400 sh -c 'nft add element inet f2b-table abuse-blacklist6 { $(echo "$*" | tr " " ",") } 2>/dev/null' _

# --------------------------------------------------
# 4. Log Summary
# --------------------------------------------------
COUNT4=$(echo "$IPV4" | wc -l)
COUNT6=$(echo "$IPV6" | wc -l)

NGX_BANNED=$(fail2ban-client status nginx-forbidden | grep "Currently banned:" | awk '{print $NF}')
SSHD_BANNED=$(fail2ban-client status sshd | grep "Currently banned:" | awk '{print $NF}')

echo "[$(date)] Blacklists updated: $COUNT4 IPv4, $COUNT6 IPv6 entries loaded"
echo "[$(date)] Fail2ban — nginx-forbidden: $NGX_BANNED banned, sshd: $SSHD_BANNED banned"

The script fetches three sources:

AbuseIPDB — 10,000 worst offenders from the last 30 days at 90%+ confidence. A crowd-sourced database where thousands of sysadmins report abusive IPs. Consensus-driven: the more reporters flag an IP, the higher its confidence score.

Bitwire — An aggregator pulling from ~15 established upstream open-source threat intelligence feeds — including FireHOL, ThreatFox, IPSum, Tor exit node lists, C2IntelFeeds, and others. Updated every 2 hours. Covers both IPv4 and IPv6.

Spamhaus DROP — An industry-leading non-profit threat intelligence organization. The DROP (Don't Route Or Peer) list contains IP ranges Spamhaus has determined are so dangerous they should never be routed — hijacked netblocks, cybercriminal infrastructure, and the worst-of-the-worst traffic sources. (The EDROP list was consolidated into DROP in April 2024 and is now the same dataset.)

CINS Army — The Collective Intelligence Network Security project maintains a curated list of IP addresses that have been observed conducting malicious activity across the internet. Unlike AbuseIPDB's consensus model, CINS scores IPs based on a weighted algorithm that factors in the number of reports, the diversity of reporting sensors, and the severity of observed activity. The list is free, requires no API key, and is updated continuously. Because it's algorithmically scored rather than purely consensus-driven, it catches threats that other sources may not yet have flagged with high confidence — a useful complement to AbuseIPDB's 90% confidence floor.

A note on the numbers: At the time of this writing, the four sources produce 23,426 IPv4 entries — but those entries represent over 4.5 million unique IPv4 addresses when you calculate the subnet coverage. Many entries are CIDR ranges covering huge swaths of address space: a single Spamhaus /24 blocks 256 addresses, a /17 blocks 32,768. On the IPv6 side, 94 entries translates to roughly 14 undecillion addresses when you factor in the subnets — a single /48 covers an entire ISP-sized allocation, and the blacklist contains dozens. The entry count is modest. The address count is not.

Why batch at 400 entries? nftables processes add element commands atomically per batch. Dumping 10,000+ IPs as a single command can exceed kernel limits and time out. 400 at a time is a safe middle ground — fast, but won't choke the parser.


Step 8: Schedule the update

Add a cron job that runs nightly — 2 AM is a quiet window and doesn't interfere with peak traffic:

# /etc/cron.d/abuse-blacklist
0 2 * * * root /usr/local/bin/update-abuse-blacklist.sh > /dev/null 2>&1