Fail2ban reporting with fail2ban-stats
If you’re hardening a public-facing PBX, I offer Asterisk security consulting.
Fail2ban-stats
Generate a quick-glance fail2ban report from the command line. The script queries every active jail, prints a summary table of current and total bans, lists each banned IP with an optional reverse DNS lookup, and tails the last 20 ban events from the log.
Usage
fail2ban-stats # Full report with reverse DNS lookups
fail2ban-stats -n # Skip DNS lookups (faster output)
fail2ban-stats -j # Output as JSON
fail2ban-stats -j -n # JSON without DNS lookups
fail2ban-stats -j | jq . # Pretty-print JSON with jq
fail2ban-stats -h # Show help
The script must be run as root (or with sudo) since it calls fail2ban-client.
Script
#!/usr/bin/env bash
# Fail2ban statistics report
set -euo pipefail
resolve_dns=true
output_json=false
usage() {
echo "Usage: $(basename "$0") [-j] [-n] [-h]"
echo " -j Output as JSON"
echo " -n Skip reverse DNS lookups (faster output)"
echo " -h Show this help message"
exit 0
}
while getopts "jnh" opt; do
case $opt in
j) output_json=true ;;
n) resolve_dns=false ;;
h) usage ;;
*) usage ;;
esac
done
# -- Collect data
jails=()
jail_data=()
total_banned=0
total_current=0
for jail in $(fail2ban-client status | grep 'Jail list' | sed 's/.*://;s/,//g'); do
status=$(fail2ban-client status "$jail" 2>/dev/null)
current=$(echo "$status" | grep 'Currently banned' | awk '{print $NF}')
total=$(echo "$status" | grep 'Total banned' | awk '{print $NF}')
failed=$(echo "$status" | grep 'Total failed' | awk '{print $NF}')
banned=$(echo "$status" | grep 'Banned IP list' | sed 's/.*://')
ip_list=()
if [ -n "$(echo "$banned" | tr -d '[:space:]')" ]; then
for ip in $banned; do
rdns=""
if $resolve_dns; then
rdns=$(host "$ip" 2>/dev/null | awk '/domain name pointer/ {sub(/\.$/, "", $NF); print $NF}') || true
fi
ip_list+=("${ip}|${rdns}")
done
fi
jails+=("$jail")
ip_str=""
for entry in "${ip_list[@]+"${ip_list[@]}"}"; do
[ -n "$ip_str" ] && ip_str+=","
ip_str+="$entry"
done
jail_data+=("${current}|${total}|${failed}|${ip_str}")
total_banned=$((total_banned + total))
total_current=$((total_current + current))
done
# Collect recent ban events
mapfile -t recent_bans < <(grep -h ' Ban ' /var/log/fail2ban.log 2>/dev/null | tail -20)
# -- JSON output
if $output_json; then
first_jail=true
echo "{"
echo " \"hostname\": \"$(hostname)\","
echo " \"timestamp\": \"$(date -Iseconds)\","
echo " \"totals\": {\"currently_banned\": $total_current, \"total_banned\": $total_banned},"
echo " \"jails\": {"
for i in "${!jails[@]}"; do
IFS='|' read -r current total failed ip_str <<< "${jail_data[$i]}"
$first_jail || echo ","
first_jail=false
printf " \"%s\": {\n" "${jails[$i]}"
printf " \"currently_banned\": %s,\n" "$current"
printf " \"total_banned\": %s,\n" "$total"
printf " \"total_failed\": %s,\n" "$failed"
printf " \"banned_ips\": ["
if [ -n "$ip_str" ]; then
echo ""
first_ip=true
IFS=',' read -ra ip_entries <<< "$ip_str"
for entry in "${ip_entries[@]}"; do
ip="${entry%%|*}"
rdns="${entry#*|}"
$first_ip || echo ","
first_ip=false
if [ -n "$rdns" ]; then
printf " {\"ip\": \"%s\", \"rdns\": \"%s\"}" "$ip" "$rdns"
else
printf " {\"ip\": \"%s\"}" "$ip"
fi
done
echo ""
printf " ]"
else
printf "]"
fi
echo ""
printf " }"
done
echo ""
echo " },"
echo " \"recent_bans\": ["
first_event=true
for line in "${recent_bans[@]+"${recent_bans[@]}"}"; do
$first_event || echo ","
first_event=false
escaped=$(echo "$line" | sed 's/\\/\\\\/g; s/"/\\"/g')
printf " \"%s\"" "$escaped"
done
echo ""
echo " ]"
echo "}"
exit 0
fi
# -- Text output
divider="────────────────────────────────────────────────────"
echo "FAIL2BAN REPORT -- $(hostname) -- $(date '+%Y-%m-%d %H:%M %Z')"
echo "$divider"
echo ""
printf "%-22s %8s %8s %8s\n" "JAIL" "CURRENT" "TOTAL" "FAILED"
printf "%-22s %8s %8s %8s\n" "----" "-------" "-----" "------"
for i in "${!jails[@]}"; do
IFS='|' read -r current total failed _ <<< "${jail_data[$i]}"
printf "%-22s %8s %8s %8s\n" "${jails[$i]}" "$current" "$total" "$failed"
done
echo "$divider"
printf "%-22s %8s %8s\n" "TOTALS" "$total_current" "$total_banned"
echo ""
echo "CURRENTLY BANNED IPs"
echo "$divider"
for i in "${!jails[@]}"; do
IFS='|' read -r _ _ _ ip_str <<< "${jail_data[$i]}"
if [ -n "$ip_str" ]; then
echo ""
echo "[${jails[$i]}]"
IFS=',' read -ra ip_entries <<< "$ip_str"
for entry in "${ip_entries[@]}"; do
ip="${entry%%|*}"
rdns="${entry#*|}"
if [ -n "$rdns" ]; then
printf " %-18s %s\n" "$ip" "$rdns"
else
printf " %-18s\n" "$ip"
fi
done
fi
done
echo ""
echo "$divider"
echo "LAST 20 BAN EVENTS"
echo "$divider"
for line in "${recent_bans[@]+"${recent_bans[@]}"}"; do
echo " $line"
done
echo ""
How it works
The script collects all data in a single pass through the jail list, then branches to either text or JSON rendering.
Data collection: Iterates over every jail returned by fail2ban-client status and queries each one for its current ban count, total bans, failed attempts, and banned IP list. If DNS resolution is enabled (the default), each banned IP gets a reverse PTR lookup via the host command. The || true guard prevents set -e from killing the script when an IP has no reverse record. All results are packed into arrays for later output.
Recent ban events: Uses mapfile to read the last 20 lines matching Ban from /var/log/fail2ban.log into an array.
Text output: Prints a columnar jail summary table with running totals, followed by banned IPs grouped by jail (with optional rDNS hostnames), and the recent ban event log.
JSON output: Produces a structured document with hostname, timestamp, totals, per-jail statistics with banned_ips arrays, and a recent_bans string array. The JSON is built with printf and echo rather than requiring jq as a dependency. Backslashes and quotes in log lines are escaped to produce valid JSON.
JSON schema
The -j flag produces output in this structure:
json,
"jails": {
"sshd": {
"currently_banned": 3,
"total_banned": 210,
"total_failed": 1842,
"banned_ips": [
{"ip": "203.0.113.50", "rdns": "scanner.example.net"},
{"ip": "198.51.100.22"}
]
}
},
"recent_bans": [
"2026-03-08 14:22:01,234 fail2ban.actions [1234]: NOTICE [sshd] Ban 203.0.113.50"
]
}
The rdns field is only present when DNS resolution is enabled and a PTR record exists for that IP.
Tips
- Use
-nwhen you have a large number of banned IPs. Reverse DNS lookups run sequentially and each one can take several seconds to time out if no PTR record exists. - Pipe the output to a file or
lessfor easier reading:fail2ban-stats | less. - Use
-jwith jq for targeted queries:fail2ban-stats -j | jq '.jails | to_entries[] | select(.value.currently_banned > 0)'shows only jails with active bans. - Run it from cron to get periodic email reports:
0 8 * * * /usr/local/bin/fail2ban-stats -nwill send a daily summary at 8am (assuming local mail delivery is configured). - Feed the JSON output into monitoring tools, log aggregators, or dashboards for automated alerting.
- The script reads
/var/log/fail2ban.logdirectly. If your system uses a different log path or rotates logs frequently, the "last 20 ban events" section may show fewer results than expected. To include rotated logs, modify the grep to also search/var/log/fail2ban.log.1.
User Notes
No notes yet. Be the first to contribute a tip or example.
Contribute a note
Share a tip, gotcha, or practical example. Keep it under 2000 characters. No questions (use the Asterisk community forums for support). Wrap code in backticks.