Automatically escalates individual fail2ban IP bans to their allocated subnet using RDAP/whois lookups. Designed for private services (Dovecot IMAP) where no legitimate users should be authenticating from foreign ISPs.
Uses a dual-jail approach to eliminate nft interval set overlap errors:
| Jail | nft Set | Contents | Managed by |
|---|---|---|---|
dovecot |
addr-set-dovecot |
Individual IPs only | fail2ban (automatic) |
dovecot-subnet |
addr-set-dovecot-subnet |
Subnets only (CIDR) | Cron batch script |
Individual IPs are kept in the dovecot jail permanently as defense-in-depth against an nft interval set lookup bug in the pipapo backend (kernels < 6.17). The simple element set (addr-set-dovecot) uses the hash backend which is not affected.
A cron job runs every 10 minutes to:
- Read individual IPs from the
dovecotjail - Skip IPs already covered by existing subnet bans
- Look up the allocated subnet via RDAP (primary) or whois (fallback)
- Ban the subnet in the
dovecot-subnetjail
| File | Install to | Purpose |
|---|---|---|
f2b_subnet_ban.sh |
/usr/local/bin/ |
Single IP→subnet escalation |
batch_subnet_cached.py |
$F2B_DIR/ |
Batch converter with caching |
whois_cache_loop.sh |
$F2B_DIR/ |
Cron runner (lockfile, modes) |
get_f2b_stats.sh |
$F2B_DIR/ |
Quick status check |
find_outOfSync.sh |
$F2B_DIR/ |
Diff f2b vs nft |
backup_f2b_dovecot.sh |
$F2B_DIR/ |
Daily backup (both jails) |
dedup_subnets.sh |
$F2B_DIR/ |
Remove subnets covered by broader bans |
nft_miss_report.sh |
$F2B_DIR/ |
Subnet ban activity report (from logs) |
check-f2b-nft-sync.sh |
/usr/local/bin/ |
Nagios monitoring check |
| File | Install to | Purpose |
|---|---|---|
fail2ban/nftables.local |
/etc/fail2ban/action.d/ |
Parameterized nft set flags (addr_options) |
fail2ban/dovecot-subnet.conf |
/etc/fail2ban/filter.d/ |
Dummy filter for subnet jail |
fail2ban/jail-dovecot.conf |
Reference only | Dovecot jail config for jail.local |
fail2ban/jail-dovecot-subnet.conf |
Reference only | Subnet jail config for jail.local |
logrotate/f2b-subnet |
/etc/logrotate.d/ |
Log rotation |
selinux/f2b-complete.te |
$SE_DIR/ |
SELinux policy source |
| File | Purpose |
|---|---|
HANDOVER.md |
Full operations handover for Claude Code / skill integration |
All scripts auto-detect their directory from their own filesystem location
(dirname "$(readlink -f "$0")" in bash, os.path.dirname(os.path.abspath(__file__)) in Python).
No environment variables are needed for path configuration.
| Variable | Default | Used by | Description |
|---|---|---|---|
F2B_DIR |
Auto-detected | All scripts, install.sh |
Override base directory (optional — scripts self-locate) |
SE_DIR |
$F2B_DIR/selinux |
install.sh |
SELinux policy directory |
F2B_CACHE_TTL_SUCCESS |
2592000 (30 days) |
batch_subnet_cached.py |
Seconds before successful cache entries are eligible for revalidation (with --revalidate) |
F2B_CACHE_TTL_FAILURE |
86400 (24 hours) |
batch_subnet_cached.py |
Seconds before failed lookups are retried |
F2B_LOOKUP_TIMEOUT |
15 |
batch_subnet_cached.py |
Hard timeout (seconds) per RDAP+whois lookup |
F2B_REVALIDATE_MAX |
200 |
batch_subnet_cached.py |
Max cache entries to re-lookup per --revalidate run |
F2B_REVALIDATE_DELAY_MIN |
1 |
batch_subnet_cached.py |
Min seconds between revalidation lookups (jitter floor) |
F2B_REVALIDATE_DELAY_MAX |
5 |
batch_subnet_cached.py |
Max seconds between revalidation lookups (jitter ceiling) |
F2B_FROM |
fail2ban@$(hostname -f) |
f2b_subnet_ban.sh |
Sender address for subnet ban notification emails |
Where to set overrides: All variables have sensible defaults and most users
won't need to change anything. If you do need to override, set them in the
crontab — never in /etc/environment.
Lesson learned:
/etc/environmentis parsed bypam_env.so, not the shell. It only accepts bareKEY=VALUElines —export, quotes, variable expansion, and any other shell syntax will causepam_env.soto returnPAM_ABORT, which silently kills all cron jobs system-wide with a cryptic"Critical error - immediate abort"in/var/log/cron. The real error ("Missing delimiter") only appears in the systemd journal (journalctl -u crond).
Set variables at the top of root's crontab (crontab -e). These apply to all
cron jobs below them and are pure shell — export, quotes, and substitution all work.
# Override cache TTLs (optional — defaults are fine for most setups)
F2B_CACHE_TTL_SUCCESS=2592000
F2B_CACHE_TTL_FAILURE=86400
F2B_LOOKUP_TIMEOUT=15
F2B_FROM=fail2ban@mail.example.com
# Batch subnet escalation (every 10 min)
*/10 * * * * /root/fail2-backup/whois_cache_loop.sh --cron >> /var/log/f2b_subnet_ban.log 2>&1
# Weekly cache revalidation
0 3 * * 0 python3 /root/fail2-backup/batch_subnet_cached.py --revalidate >> /var/log/f2b_subnet_ban.log 2>&1Useful when only one job needs a non-default value:
*/10 * * * * F2B_LOOKUP_TIMEOUT=30 /root/fail2-backup/whois_cache_loop.sh --cron >> /var/log/f2b_subnet_ban.log 2>&1For complex overrides, wrap in a shell script:
#!/bin/bash
export F2B_CACHE_TTL_SUCCESS=604800 # 7 days
export F2B_CACHE_TTL_FAILURE=3600 # 1 hour
export F2B_LOOKUP_TIMEOUT=30
export F2B_REVALIDATE_MAX=500
exec /root/fail2-backup/whois_cache_loop.sh --cron| Variable | Where to set | Notes |
|---|---|---|
F2B_DIR |
Usually unnecessary | Scripts auto-detect from their own location |
F2B_CACHE_TTL_* |
Crontab or wrapper | Only needed if default TTLs don't suit your lookup volume |
F2B_LOOKUP_TIMEOUT |
Crontab or wrapper | Increase if RDAP servers are slow in your region |
F2B_REVALIDATE_* |
Crontab or wrapper | Tune for --revalidate runs only |
F2B_FROM |
Crontab or wrapper | Set to match your mail server's accepted sender addresses |
SE_DIR |
install.sh invocation only |
SE_DIR=/path install.sh — not needed at runtime |
# Automatic batch processing (used by cron via whois_cache_loop.sh)
batch_subnet_cached.py
# Dry-run — show what would happen without banning
batch_subnet_cached.py --dry-run
# Revalidate — re-lookup cached entries older than F2B_CACHE_TTL_SUCCESS
batch_subnet_cached.py --revalidateWhen RDAP/whois lookups fail (rate limiting, unreachable registries), you can manually inject subnet data from online whois tools.
# List IPs that have no subnet ban covering them
batch_subnet_cached.py --list-uncovered
# Inject a specific IP→subnet mapping (updates cache + bans)
batch_subnet_cached.py --inject 109.125.248.113 109.125.240.0/20
# Inject one or more CIDRs directly
batch_subnet_cached.py --inject-cidr 109.125.240.0/20 193.32.176.0/24
# Inject from a whois inetnum range (auto-converts to CIDRs)
batch_subnet_cached.py --inject-range 109.125.240.0 109.125.255.255All inject modes support --dry-run. They update the whois cache, clear any
failed cache entries for IPs in the range, and ban the subnet(s).
Typical workflow when the batch script reports failures:
batch_subnet_cached.py --list-uncovered— see which IPs need subnets- Look up the IP on an online whois tool (e.g., RIPE, ARIN, APNIC)
batch_subnet_cached.py --inject-cidr <cidr>— inject and banbatch_subnet_cached.py --dry-run— verify zero uncovered IPs
Lookups are cached in $F2B_DIR/whois_cache.json with timestamps:
- Successful lookups are used indefinitely during normal runs. They are
only re-checked when
--revalidateis passed and the entry is older thanF2B_CACHE_TTL_SUCCESS(default: 30 days). - Failed lookups are automatically retried after
F2B_CACHE_TTL_FAILURE(default: 24 hours). Manual injection (--inject*) clears failed entries for IPs covered by the injected subnet. - Rate limiting (HTTP 429) is detected and stops further lookups to that RIR immediately, rather than burning through all 5 RDAP servers.
Brute-force attackers targeting SMTP port 25 with SASL AUTH attempts trigger
Dovecot auth failures even when the IMAP port is blocked. This happens because
Postfix delegates SASL authentication to Dovecot's auth daemon — the attacker
never touches IMAP, but the auth failure still shows up in Dovecot's logs and
triggers the dovecot fail2ban jail.
Flow: Attacker → SMTP (port 25) → EHLO / AUTH → Postfix asks Dovecot auth → auth fails → both dovecot and postfix jails fire.
Fix: Disable SASL on port 25 (server-to-server relay) and restrict it to submission (587) and smtps (465) where legitimate clients connect:
# Disable SASL globally (affects port 25)
postconf -e 'smtpd_sasl_auth_enable=no'Then in /etc/postfix/master.cf, override on submission/smtps services:
submission inet n - n - - smtpd
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_security_level=encrypt
smtps inet n - n - - smtpd
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_wrappermode=yes
Verify with postfix check, then postfix reload. Confirm port 25 no longer
advertises AUTH:
# Port 25 — should NOT show 250-AUTH
(sleep 1; echo EHLO test; sleep 1; echo QUIT) | openssl s_client -connect localhost:25 -starttls smtp -quiet 2>/dev/null
# Port 587 — should show 250-AUTH PLAIN
(sleep 1; echo EHLO test; sleep 1; echo QUIT) | openssl s_client -connect localhost:587 -starttls smtp -quiet 2>/dev/null- Rocky Linux / CentOS Stream / RHEL
- fail2ban with nftables backend
- Python 3 (stdlib only — no pip)
- whois (
dnf install whois) - SELinux enforcing (custom policy provided)
# Batch subnet escalation (every 10 min)
*/10 * * * * /path/to/whois_cache_loop.sh --cron >> /var/log/f2b_subnet_ban.log 2>&1
# Daily backup (separate files per jail)
10 1 * * * /path/to/backup_f2b_dovecot.sh
# Weekly cache revalidation (re-check entries older than F2B_CACHE_TTL_SUCCESS)
0 3 * * 0 python3 /path/to/batch_subnet_cached.py --revalidate >> /var/log/f2b_subnet_ban.log 2>&1The cron runner (whois_cache_loop.sh --cron) logs a one-line summary per run:
whois_cache_loop.sh: skipped=7442 new_subnets=5 failed=2 ips=7446 subnets=2074
| Field | Meaning |
|---|---|
skipped |
IPs already covered by existing subnet bans |
new_subnets |
New subnet bans created this run |
failed |
RDAP/whois lookups that failed (retried after F2B_CACHE_TTL_FAILURE) |
ips |
Total individual IPs in the dovecot jail (kept permanently) |
subnets |
Total subnet bans in the dovecot-subnet jail |
nft_miss_report.sh parses the cron log to summarize banning activity,
lookup failures, and rate limiting events:
$F2B_DIR/nft_miss_report.sh [/var/log/f2b_subnet_ban.log]By default, fail2ban's nftables action uses blocktype = reject, which sends an
ICMP rejection back to the source. For hostile traffic, drop is preferable —
it discards the packet silently and gives the attacker no signal that the host
is reachable or that the port exists.
To switch globally for all nftables jails, create
/etc/fail2ban/action.d/nftables-common.local:
[Init]
blocktype = dropThis file is auto-included via after = nftables-common.local in
nftables.conf, so it survives package updates. A full restart is required —
reload only applies new config to new bans, leaving existing rules unchanged:
systemctl restart fail2banVerify the switch took effect:
nft list chain inet f2b-table input
# Each rule should end with `drop` (not `reject`)Lesson learned: Existing nft rules carry whatever
blocktypewas active when they were created. A reload alone won't rewrite them. Restart is the only way to flush + recreate. Bans persist via the fail2ban DB and are restored automatically on startup.
f2b_subnet_ban.sh writes a marker file in /tmp/f2b_subnet_dedup/ for every
IP it processes. If the same IP arrives again within 60 seconds, the script
exits silently — useful when fail2ban repeats actionban calls on jail reload
or restore. Markers older than 5 minutes are auto-cleaned on each run.
Lesson learned: The original implementation built the marker path with
${DEDUP_DIR}/${IP}then ran a single substitution${path//[\.\/]/_}intending to escape dots in the IP — but the regex also stripped the directory slashes, turning/tmp/f2b_subnet_dedup/1.2.3.4into the relative path_tmp_f2b_subnet_dedup_1_2_3_4. Marker files landed in$CWD(root's home under cron) instead of the dedup dir, and the auto-cleanup never found them. Sanitize the key, then join the path — never sanitize after joining.
check-f2b-nft-sync.sh dovecot,dovecot-subnet,postfix addr-set-dovecot,addr-set-dovecot-subnet,addr-set-postfix
MIT — see LICENSE.