+1 909 277 6076 support@otsglobal.org

TL;DR: We layered fail2ban, CrowdSec, and firewalld with a strict whitelist, eliminated self-lockout risk, and stopped manually blocking IPs. Here's the full story.


The Problem

Our Rocky Linux 10 mail server (running iRedMail) was under constant SSH brute-force attack. Daily Logwatch reports showed hundreds of failed login attempts from around the world — a rotating cast of compromised IPs spanning multiple continents.

Our initial response was manual:

# Block an entire /24 because of one attacker
firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="203.0.113.0/24" drop'

This worked — until it didn't. Our firewall accumulated 65+ manually curated rich rules, each one a band-aid. Then we hit a bigger problem: attackers started using Google Cloud Platform IPs.

We did what any frustrated admin would do:

sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="34.0.0.0/8" drop'

That's 16.7 million IPs. It stopped the attack. It also blocked every legitimate GCP user on the planet.

We knew we had to do better.


The Wrong Fix: Whitelisting Cloud Ranges

At some point in the past, someone (probably us, half-asleep) added cloud provider ranges to our fail2ban and CrowdSec allowlists:

ignoreip = 127.0.0.1 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 198.51.100.0/28 203.0.113.0/16 192.0.2.0/16 34.0.0.0/8 ::1

That 34.0.0.0/8 at the end?

It meant every attacker using GCP was immune from fail2ban. Our fail2ban would dutifully detect the attack, log it, and then silently skip the ban because the IP was in the ignore list.

Attackers on GCP quickly learned they could brute-force us indefinitely.


The Self-Lockout Incident

While testing CrowdSec, one of us fat-fingered a password three times:

ssh -p 2222 wronguser@mail.example.com
# Enter wrong password 3 times

Then the workstation was banned. Not just on SSH — on every port:

ping mail.example.com        # ✅ Works
nc -zv mail.example.com 2222 # ❌ Connection refused
nc -zv mail.example.com 465  # ❌ Connection refused
nc -zv mail.example.com 587  # ❌ Connection refused

We couldn't SSH in, couldn't send mail through the server, couldn't use it as a relay. And we'd set bantime = 6048007 days.

The cause: fail2ban had banned us across four jails simultaneously (sshd, sshd-preauth, sshd-timeout, recidive), and one of them used firewallcmd-rich-rules with the all-ports action.

We eventually got back in via a jump host on the same network. Then we rebuilt the whole setup properly.


The Right Architecture

Here's what we ended up with:

┌──────────────────────────────────────────────────────┐
│  Layer 1: Non-standard SSH port (2222)               │
│           Eliminates 99% of automated scans          │
├──────────────────────────────────────────────────────┤
│  Layer 2: SSH hardening                              │
│           PermitRootLogin no, MaxAuthTries 3         │
├──────────────────────────────────────────────────────┤
│  Layer 3: fail2ban (10 jails)                        │
│           Strict SSH jails (2 tries → 7 days)        │
│           Centralized whitelist in jail.local        │
├──────────────────────────────────────────────────────┤
│  Layer 4: CrowdSec                                   │
│           Behavioral detection + community blocklist │
│           Own nftables table, no firewalld conflicts │
├──────────────────────────────────────────────────────┤
│  Layer 5: firewalld (static)                         │
│           Permanent blocks for known repeat offenders│
│           NO cloud /8 blocks anymore                 │
└──────────────────────────────────────────────────────┘

What fail2ban Handles

  • SSH brute-force — 2 failed attempts = 7-day ban
  • Mail auth failures — 3 failed SASL = 1-day ban
  • Dovecot / webmail — various thresholds

Key insight: Instead of ignoring entire cloud ranges, we centralized the whitelist in one file (jail.local [DEFAULT]) and removed all per-jail overrides:

[DEFAULT]
ignoreip = 127.0.0.0/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 198.51.100.0/28 203.0.113.0/24

That 203.0.113.0/24 is our ISP's residential range — it covers wherever our admin workstation happens to be within the ISP's network. Our workstation's IP can rotate freely, we stay whitelisted.

What CrowdSec Handles

Dynamic behavioral detection for:

  • SSH brute-force (ssh-bf)
  • HTTP scanning (http-probing, http-cve-probing)
  • Postfix relay attempts
  • Nginx bad user agents

CrowdSec uses its own nftables table (crowdsec, crowdsec6) — zero conflicts with firewalld.

What firewalld Handles

Static blocks for IPs that keep coming back after fail2ban bans:

sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="<persistent-attacker>" drop'

We removed every /8 cloud block. CrowdSec now handles GCP attackers dynamically — banning only the ones that actually misbehave.


The Whitelist That Saved Us

This is the single most important config in our setup:

ignoreip = 127.0.0.0/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 198.51.100.0/28 203.0.113.0/24
RangePurpose
127.0.0.0/8Localhost
10.0.0.0/8Private network
172.16.0.0/12Private network
192.168.0.0/16Private network
198.51.100.0/28Our office
203.0.113.0/24Our ISP (dynamic residential)

What's NOT in here anymore:

  • 34.0.0.0/8 (all of Google Cloud)
  • 192.0.2.0/16 (example cloud provider A)
  • 198.18.0.0/16 (example cloud provider B)

Those cloud ranges were our original sin. Removing them was the fix.


Why We Stopped at Dynamic ISP Range, Not IP

Our admin workstation uses a residential IP that rotates. Whitelisting a single IP is useless — tomorrow it's different. Whitelisting the ISP's entire /11 (8 million IPs) is reckless.

The sweet spot: whitelist the /24 your ISP hands out. It's:

  • Small enough that hostile actors are unlikely to be in it
  • Large enough to cover IP rotation within a period
  • Specific enough to be a precise rule

Finding it:

curl ifconfig.me
whois <your-ip> | grep -E "inetnum|route"

This gives you the exact CIDR your ISP uses.


The Test That Proved It Works

After all changes, we did the ultimate self-test:

# From the admin workstation
ssh -p 2222 wronguser@mail.example.com
# Enter wrong password 3 times

Then checked every layer:

for jail in sshd sshd-preauth sshd-timeout recidive; do
    sudo fail2ban-client status $jail | grep "$(curl -s ifconfig.me)" || echo "$jail: clean ✅"
done
sudo cscli decisions list | grep "$(curl -s ifconfig.me)" || echo "crowdsec: clean ✅"
sudo firewall-cmd --list-rich-rules | grep "$(curl -s ifconfig.me)" || echo "firewalld: clean ✅"

Every single check returned clean. 3 failed SSH attempts — normally enough to trigger bans in 4 jails — and nothing happened. The whitelist works.


The Results

Before

  • 65+ manual firewall rules
  • Constant fail2ban self-lockouts
  • Cloud ranges whitelisted, defeating detection
  • 34.0.0.0/8 emergency block because attackers were on GCP
  • Hours per week manually blocking IPs

After

  • 1 centralized fail2ban whitelist
  • 0 self-lockouts since the fix
  • Cloud ranges removed, fail2ban and CrowdSec both effective
  • Emergency /8 block removed
  • CrowdSec auto-banning attackers within seconds
  • Only checks needed: cscli alerts list once a day

Actual Numbers

MetricValue
fail2ban active bans54
CrowdSec decisionsRolling, auto-expiring
Attackers blocked100% of SSH attacks in last 24h
Self-lockouts0
Manual intervention required0

Deployment in 45 Minutes

For a second server, the process is:

  1. Backupcp -a /etc/fail2ban /root/fail2ban-backup/
  2. Centralize whitelist — one file, jail.local [DEFAULT]
  3. Remove per-jail ignoreipsed -i '/^ignoreip\s*=/d' jail.d/*.local
  4. Install CrowdSecdnf install crowdsec crowdsec-firewall-bouncer-nftables
  5. Create CrowdSec allowlistcscli allowlists create my_trusted_ips
  6. Remove cloud blocks from firewalld — if any exist
  7. Test — 3 failed SSH attempts, verify no bans
  8. Document/root/SECURITY.md
  9. Monitor — check daily for a week, then weekly

Total time: ~45 minutes for a clean deployment.


Lessons Learned

1. Never ignore cloud provider ranges globally

Blocking 34.0.0.0/8 because some GCP IP attacked you is like blocking a whole city because one resident jaywalked. It's lazy, it's blunt, and it will bite you.

Better: Use CrowdSec — it bans specific IPs based on behavior, so legitimate GCP users still reach you.

2. Centralize your whitelist

Scattered ignoreip values across 7 files caused a real problem: our admin IP wasn't in most of them, so we got banned in 4 jails simultaneously.

Better: One source of truth. jail.local [DEFAULT] is the canonical location. Remove all per-jail overrides.

3. Whitelist ranges, not single IPs, for dynamic admins

If your admin IP changes, a single-IP whitelist is useless. Use the smallest CIDR that covers your likely IPs.

Better: whois your IP, find the smallest route, whitelist that.

4. Layer defense, don't rely on one tool

fail2ban is great for immediate bans. CrowdSec is great for behavioral detection and community intelligence. firewalld is great for persistent blocks.

Better: Use all three. Each covers the others' weaknesses.

5. Test with real failures before declaring victory

We didn't just configure and hope. We deliberately failed auth 3 times and verified every layer stayed clean.

Better: If you can't test it, you don't know it works.

6. Document your emergency recovery path

We ended up locked out once. It took 20 minutes to figure out how to get back in.

Better: Write down how to unban from every layer, save it locally, and test the recovery path.


The Files That Matter

If you want to replicate this setup, here are the exact files:

/etc/fail2ban/jail.local

ini

[DEFAULT]
findtime    = 3600
bantime     = 3600
maxretry    = 2
ignoreip    = 127.0.0.0/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 <OFFICE> <ISP>

/etc/fail2ban/jail.d/00-firewalld.conf (default)

ini

[DEFAULT]
banaction = firewallcmd-rich-rules
banaction_allports = firewallcmd-rich-rules

CrowdSec allowlist

text

my_trusted_ips:
  - <OFFICE_RANGE>
  - <ISP_RANGE>

firewalld state

text

Ports: <SSH_PORT>/tcp 465/tcp 587/tcp
Rich rules: N static (no cloud /8s)

Is This Setup Right for You?

Yes, if:

  • You run a public-facing Linux server (mail, web, SSH)
  • You're tired of manually blocking IPs
  • You want automated defense without paid tools
  • You can tolerate a 45-minute one-time setup

No, if:

  • You're on a shared hosting plan (no root)
  • You can't disable password auth (we haven't yet)
  • You only have a handful of attacks per year

What's Next for Us

  1. Enroll in CrowdSec Console — unlocks the community blocklist (proactive blocking based on global intelligence)
  2. Consider disabling password auth — we've been putting this off; SSH keys would eliminate the last attack surface
  3. Replicate on our other servers — this is now our standard baseline

This post is based on our actual setup on Rocky Linux 10 servers running iRedMail. All IPs, hostnames, and ranges have been replaced with documentation-reserved examples (RFC 5737 for IPv4). Adapt ranges and ports to your environment.