Fail2ban reporting with fail2ban-stats

Monitoring -- Last reviewed 2026-03-29 fail2ban security bash administration Found this useful? Upvote it. ×

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

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.

Moderated before publishing. Email never shown.

Related Snippets