Using IPset with firewalld on Fedora: Block Tens of Thousands of Malicious IPs with O(1) Latency

Comparing Bulk IP Blocking Methods on Linux

When a server is hammered by SSH botnet attacks, the first instinct of many sysadmins is to open the terminal and ban IPs one by one. This approach works fine for a few dozen offending addresses. However, when blacklists grow into tens of thousands of IPs, your server will quickly become overwhelmed.

In production environments, we typically consider three main approaches:

  • Method 1: Sequential rules using rich rules or traditional iptables
    Each IP or subnet requires an individual rule (for example, firewall-cmd --add-rich-rule='rule family="ipv4" source address="x.x.x.x" drop').
  • Method 2: Blocking at the reverse proxy layer (Nginx, Caddy, or a WAF)
    Using a geoip2 module on the web server to drop connections right at the TLS/HTTP handshake stage.
  • Method 3: Leveraging IPset at the kernel layer
    Aggregating the entire IP list into a hash table in kernel space. The firewall then needs to evaluate only a single lookup rule.

Real-World Performance: Comparing the Three Approaches

Each approach processes packets using fundamentally different mechanisms within the network stack.

Criteria Sequential Rules (firewalld/iptables) Web Server Layer (Nginx GeoIP) IPset + firewalld (Kernel Hash)
Lookup Complexity O(N) – Linear O(log N) – Search Tree O(1) – Hash Table
CPU Load with Large Lists Spikes sharply past 2,000 rules Moderate, but consumes worker resources Near zero, rock solid
Scope of Protection All ports (SSH, DB, Web…) Limited to HTTP/HTTPS All network ports (L3/L4)
List Reload Time Very slow, takes tens of seconds to a full minute Fast during config reload Virtually instantaneous (a few milliseconds)

The fatal flaw of sequential rules is their O(N) complexity. Imagine a blacklist with 30,000 entries: every incoming packet from legitimate users must be evaluated against all 30,000 rules sequentially before it can proceed. Server CPU usage will skyrocket to 90–100%, causing high network latency and widespread packet drops.

While Nginx excels at handling web traffic, it leaves non-HTTP ports like SSH (22) or PostgreSQL (5432) completely exposed. Integrating IPset directly into your firewall is the only way to stop attacks right at the kernel boundary.

Optimal Architecture: Native IPset on Fedora

Fedora has long used nftables as the default backend for firewalld. Because of this, you can create and manage IP sets directly through the firewall-cmd utility without installing the standalone legacy ipset package from iptables.

The implementation process consists of three straightforward steps:

  1. Create a hash:net IPset in firewalld.
  2. Assign this IPset to the drop zone to silently discard packets.
  3. Set up an automated script to fetch CIDR blocks from IPdeny and regularly populate the hash table.

Step-by-Step Implementation on Fedora

Step 1: Initialize the IPset in firewalld

First, verify that firewalld is running properly:

sudo systemctl status firewalld

Next, create a hash table named blacklist-country of type hash:net. This data type allows you to store CIDR network prefixes such as 192.168.1.0/24 or 10.0.0.0/8:

# Initialize a new IPv4 ipset with a capacity of 200,000 network ranges
sudo firewall-cmd --permanent --new-ipset=blacklist-country --type=hash:net --option=family=inet --option=hashsize=4096 --option=maxelem=200000

# Reload configuration so firewalld recognizes the new set
sudo firewall-cmd --reload

A limit of maxelem=200000 provides plenty of headroom to hold all IP blocks from multiple botnet-heavy countries without running out of memory.

Step 2: Assign the IPset to the Drop Zone

The cleanest approach is to route all traffic originating from this IPset to the drop zone. When an IP in the list sends a packet, the kernel drops it silently. The server sends back neither RST packets nor ICMP error messages, minimizing outbound bandwidth usage.

# Route all IPs in the set to the drop zone
sudo firewall-cmd --permanent --zone=drop --add-source=ipset:blacklist-country

# Apply configuration changes immediately
sudo firewall-cmd --reload

Verify the configuration by checking the active sources of the drop zone:

sudo firewall-cmd --zone=drop --list-sources

If the output displays ipset:blacklist-country, the configuration is active.

Step 3: Write an Automated Script to Populate GeoIP Ranges

The IPdeny service provides free country-level CIDR lists as plain text files. For instance, you can download IP ranges for regions that frequently generate SSH brute-force traffic.

Create a dedicated directory to store system maintenance scripts:

sudo mkdir -p /opt/firewall-scripts
sudo nano /opt/firewall-scripts/update-geoip.sh

The automated download and update script is as follows:

#!/usr/bin/env bash
set -euo pipefail

IPSET_NAME="blacklist-country"
TEMP_FILE="/tmp/zone_data.txt"
# Example: download country IP list from IPdeny
URL="https://www.ipdeny.com/ipblocks/data/countries/cn.zone"

echo "[+] Starting IP list download..."
curl -sSL --fail "${URL}" -o "${TEMP_FILE}"

echo "[+] Flushing current entries..."
# Remove old entries to prevent stale data buildup
sudo firewall-cmd --permanent --ipset="${IPSET_NAME}" --remove-entries-from-file="${TEMP_FILE}" 2>/dev/null || true

echo "[+] Loading new list into kernel..."
sudo firewall-cmd --permanent --ipset="${IPSET_NAME}" --add-entries-from-file="${TEMP_FILE}"

echo "[+] Reloading firewall..."
sudo firewall-cmd --reload

rm -f "${TEMP_FILE}"
echo "[+] IPset update complete!"

Grant execution permissions and run the script once to test it:

sudo chmod 750 /opt/firewall-scripts/update-geoip.sh
sudo /opt/firewall-scripts/update-geoip.sh

Step 4: Verify the Results

Since Fedora runs firewalld on top of nftables, you can verify the loaded set directly via firewalld or using the nft command:

# Check set information and entry count
sudo firewall-cmd --info-ipset=blacklist-country

# Or inspect the live hash table in nftables
sudo nft list set inet firewalld blacklist-country | head -n 30

In terms of resource usage, storing 20,000 subnets consumes only about 2–4 MB of RAM in kernel space. This footprint is negligible, even on budget VPS instances with only 1 GB of RAM.

To quickly test whether a specific IP or subnet is matched by the set, simply run:

sudo firewall-cmd --ipset=blacklist-country --query-entry="1.2.4.0/24"

If the command outputs yes, all incoming connections from that network range will be blocked immediately.

Step 5: Automate Updates with a Systemd Timer

Internet IP allocations shift continuously. Instead of using cron jobs—which can make monitoring outputs tricky—systemd timers are the preferred approach on Fedora thanks to centralized logging via journalctl.

Create a service unit file at /etc/systemd/system/update-geoip.service:

[Unit]
Description=Update GeoIP IPset for Firewalld
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/firewall-scripts/update-geoip.sh
StandardOutput=journal
StandardError=journal

Next, create the corresponding timer file at /etc/systemd/system/update-geoip.timer to run automatically every Sunday at 03:00 AM:

[Unit]
Description=Trigger weekly IPset update

[Timer]
OnCalendar=Sun *-*-* 03:00:00
Persistent=true

[Install]
WantedBy=timers.target

Enable and start the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now update-geoip.timer

# Check the next scheduled execution time
sudo systemctl list-timers update-geoip.timer

Two Crucial Considerations Before Going to Production

  • Avoid locking yourself out: Always add your personal static IP or VPN subnets (such as WireGuard or OpenVPN) to the trusted zone. Rules in this zone take precedence over the drop zone, ensuring you never lose administrative access to your server.
  • Handle large blocklists carefully: When importing over 100,000 subnets at once, always fetch them over HTTPS and validate curl’s exit status. This prevents corrupt downloads or blank HTML error pages from being ingested into your firewall configuration.
Share: