Fail2ban reporting with fail2ban-stats

Monitoring -- Last reviewed 2026-06-19 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.

It reports on whatever jails fail2ban has active, including the asterisk jail from Fail2ban for Asterisk (or its systemd/no-rsyslog variant) alongside sshd and any other jails you've configured.

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 ;;
        *) echo "Usage: $(basename "$0") [-j] [-n] [-h]" >&2; exit 1 ;;
    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.

Field encoding: Each jail_data entry packs four fields as current|total|failed|ip_str, but ip_str itself is a comma-separated list of ip|rdns pairs, so it already contains | characters. This works because IFS='|' read -r current total failed ip_str assigns the literal remainder of the line to the last variable rather than splitting it further, so the embedded pipes in ip_str come through intact. It looks like it should break, but it's relying on documented read behavior, not a bug.

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

Know a tip or gotcha for this topic? Share it below and help others.

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