How To Display Fail2ban Banned Ip to Dashboard Complete Guide

⏲️ Estimated reading time: 12 min

Learn how to safely export Fail2ban status (blocked IPs) and display it in a WordPress dashboard widget without sudo or shell_exec. We’ll schedule a root/user cron, write JSON to uploads, then build a fast, secure, and scalable admin widget.


Why a “JSON Export Dashboard” Is the Right Approach

Showing system-level data (like Fail2ban blocked IPs) inside WordPress raises the classic permissions problem. WordPress (PHP-FPM) runs under a limited user and should not run privileged commands (e.g., fail2ban-client). The elegant, safe pattern is to move data collection into cron (root or a properly-privileged user) and publish the result as JSON in wp-content/uploads. Inside WP, we only read that JSON no shell_exec, no sudo, no risky escapes.

Benefits:

  • Security: zero command execution from PHP; WP only reads a file.
  • Performance: data is precomputed; WP just renders it.
  • Portability: the JSON can feed other sites, charts, alerts, etc.
  • Scalability: you can add more metrics to the export over time.

Architecture at a Glance

  1. Cron Script (bash): runs fail2ban-client status sshd, extracts IPs (IPv4/IPv6), computes currently_banned and total_banned, then writes clean JSON into wp-content/uploads/f2b-status.json.
  2. Cron Job: executes the script every 5 minutes (or your preferred interval).
  3. WordPress Plugin: reads the JSON from uploads and renders a dashboard widget in wp-admin, with a Refresh button via AJAX (re-reads JSON; it does not trigger cron).

1) Cron Script That Exports JSON to Uploads

Create /usr/local/bin/f2b-status.sh:

#!/usr/bin/env bash
set -euo pipefail

# Fail2ban jail
JAIL="sshd"

# Where to write JSON (your WP uploads)
OUT="/home/USER/public_html/wp-content/uploads/f2b-status.json"  # ← change USER + path

# Ensure directory exists
install -d -m 0755 "$(dirname "$OUT")"

# 1) Raw jail status
RAW="$(fail2ban-client status "$JAIL" 2>/dev/null || true)"

# 2) Extract all IPs (IPv4 + typical IPv6 forms) from any line
IPS_LIST="$(
  printf '%s\n' "$RAW" \
  | grep -Eo '([0-9]{1,3}\.){3}[0-9]{1,3}|([0-9a-fA-F:]{2,}:[0-9a-fA-F:]+)' \
  | sed '/^$/d' \
  | sort -u
)"

# 3) Current unique IP count
CURR_COUNT="$(printf '%s\n' "$IPS_LIST" | sed '/^$/d' | wc -l | tr -d '[:space:]')"

# 4) Total banned (cumulative) reported by Fail2ban
TOTAL_BANNED="$(
  printf '%s\n' "$RAW" | awk -F':' '/Total banned/ {print $2}' | xargs || true
)"

# 5) ISO timestamp
NOW="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"

# 6) Emit JSON (prefer jq; fallback to pure bash)
if command -v jq >/dev/null 2>&1; then
  jq -n \
    --arg jail "$JAIL" \
    --arg updated "$NOW" \
    --arg total "$TOTAL_BANNED" \
    --arg ips_line "$(printf '%s\n' "$IPS_LIST")" '
    {
      jail: $jail,
      updated: $updated,
      total_banned: ($total | tonumber? // $total),
      ips: ($ips_line | split("\n") | map(select(length>0)))
    } | . + { currently_banned: (.ips | length) }' > "$OUT.tmp"
else
  {
    echo "{"
    echo "\"jail\":\"$JAIL\","
    echo "\"updated\":\"$NOW\","
    echo "\"total_banned\":\"$TOTAL_BANNED\","
    echo "\"ips\":["
    first=1
    while IFS= read -r ip; do
      [ -z "$ip" ] && continue
      if [ $first -eq 0 ]; then printf ","; fi
      printf '"%s"' "$ip"
      first=0
    done <<< "$IPS_LIST"
    echo "],"
    echo "\"currently_banned\":$CURR_COUNT"
    echo "}"
  } > "$OUT.tmp"
fi

# Atomic move + safe permissions
mv -f "$OUT.tmp" "$OUT"
chmod 0644 "$OUT"

Configuration notes:

  • Replace USER and the OUT path to match your hosting/domain.
  • Make the script executable: chmod +x /usr/local/bin/f2b-status.sh

Quick test:

/usr/local/bin/f2b-status.sh
cat /home/USER/public_html/wp-content/uploads/f2b-status.json

You should get a JSON like:

{
  "jail": "sshd",
  "updated": "2025-09-25T16:45:00Z",
  "total_banned": 59,
  "ips": ["43.134.36.84", "172.174.72.225", "..."],
  "currently_banned": 59
}

2) Schedule the Cron (Root or User)

Recommended variants

A. Root cron (system-wide):

crontab -e

Add:

*/5 * * * * /usr/bin/bash /usr/local/bin/f2b-status.sh >/dev/null 2>&1

B. User cron (e.g., help_zone) – good for separation per account:

crontab -u help_zone -e

Add:

*/5 * * * * /usr/local/bin/f2b-status.sh >/dev/null 2>&1

Verify:

crontab -l                 # as root
crontab -u help_zone -l     # as that user
ls -l wp-content/uploads/f2b-status.json

3) WordPress Plugin: JSON-Only Dashboard

JSON-Only Dashboard Display Fail2ban Banned Ip to Dashboard

Create the folder:

wp-content/plugins/wp-fail2ban-dashboard-json/

Main file: wp-fail2ban-dashboard-json.php

<?php
/**
 * Plugin Name: WP Fail2ban Dashboard (JSON)
 * Description: Dashboard widget that displays blocked IPs for the sshd jail by reading /wp-content/uploads/f2b-status.json written by cron. No shell_exec.
 * Version: 1.1.0
 * Author: Help Zone
 */

if ( ! defined('ABSPATH') ) exit;

class WP_F2B_Dashboard_JSON {
    const CAP = 'manage_options';
    const NONCE_ACTION = 'wp_f2b_refresh';

    // Relative path in uploads
    const JSON_REL_PATH = 'f2b-status.json';
    // Consider data stale after X minutes
    const STALE_MINUTES = 15;

    public function __construct() {
        add_action('wp_dashboard_setup', [$this, 'add_widget']);
        add_action('admin_enqueue_scripts', [$this, 'assets']);
        add_action('wp_ajax_wp_f2b_refresh', [$this, 'ajax_refresh']);
    }

    public function assets($hook) {
        if ($hook !== 'index.php') return;
        wp_enqueue_script('wp-f2b-dashboard', plugin_dir_url(__FILE__) . 'wp-f2b.js', ['jquery'], '1.0.0', true);
        wp_localize_script('wp-f2b-dashboard', 'WP_F2B', [
            'ajaxurl' => admin_url('admin-ajax.php'),
            'nonce'   => wp_create_nonce(self::NONCE_ACTION),
        ]);
        wp_enqueue_style('wp-f2b-dashboard', plugin_dir_url(__FILE__) . 'wp-f2b.css', [], '1.0.0');
    }

    public function add_widget() {
        if ( ! current_user_can(self::CAP) ) return;
        wp_add_dashboard_widget('wp_f2b_widget', 'Fail2ban – Blocked IPs (JSON)', [$this, 'render_widget']);
    }

    public function render_widget() {
        echo '<div id="wp-f2b-wrap">';
        echo '<p>This widget reads IPs from <code>wp-content/uploads/'.esc_html(self::JSON_REL_PATH).'</code>, a JSON file written by cron (root or a user). No <code>shell_exec</code> is used.</p>';
        echo '<button id="wp-f2b-refresh" class="button button-primary">Refresh</button>';
        echo '<div id="wp-f2b-result" style="margin-top:10px;"><em>Loading…</em></div>';
        echo '</div>';

        $html = $this->get_status_html();
        echo "<script>window.addEventListener('load',function(){document.getElementById('wp-f2b-result').innerHTML=".wp_json_encode($html).";});</script>";
    }

    public function ajax_refresh() {
        if ( ! current_user_can(self::CAP) ) wp_send_json_error('unauthorized', 403);
        check_ajax_referer(self::NONCE_ACTION, 'nonce');
        $html = $this->get_status_html();
        wp_send_json_success(['html' => $html]);
    }

    private function get_status_html() {
        $data = $this->read_json_status();

        $html  = '<div class="wp-f2b-box">';
        $html .= '<div><strong>Mode:</strong> JSON cron <code>'.esc_html(self::JSON_REL_PATH).'</code></div>';

        if ( isset($data['error']) ) {
            $html .= '<p style="color:#b32d2e; margin:8px 0;">'.$data['error'].'</p>';
            $html .= $this->help_hints();
            $html .= '</div>';
            return $html;
        }

        $updated = !empty($data['updated']) ? esc_html($data['updated']) : '-';
        $jail    = !empty($data['jail']) ? esc_html($data['jail']) : 'sshd';
        $total   = isset($data['total_banned']) ? esc_html((string)$data['total_banned']) : '-';
        $ips     = is_array($data['ips'] ?? null) ? array_values(array_filter($data['ips'])) : [];
        $curr    = count($ips);

        $html .= '<div style="margin:6px 0;"><strong>Jail:</strong> <code>'.$jail.'</code></div>';
        $html .= '<div style="margin:6px 0;"><strong>Currently banned:</strong> '.$curr.'</div>';
        $html .= '<div style="margin:6px 0;"><strong>Total banned (cumulative):</strong> '.$total.'</div>';
        $html .= '<div style="margin:6px 0;"><strong>Updated:</strong> '.$updated.$this->stale_warning($data).'</div>';
        $html .= $this->render_ip_list($ips);
        $html .= '</div>';
        return $html;
    }

    private function render_ip_list($ips) {
        if (empty($ips)) return '<p><em>No IPs listed.</em></p>';
        $out = '<ul class="wp-f2b-ul">';
        foreach ($ips as $ip) {
            $out .= '<li><a href="https://ipinfo.io/'.rawurlencode($ip).'" target="_blank" rel="noreferrer"><code>'.esc_html($ip).'</code></a></li>';
        }
        $out .= '</ul>';
        return $out;
    }

    private function read_json_status() {
        $uploads = wp_get_upload_dir();
        if ( empty($uploads['basedir']) ) {
            return ['error' => 'Cannot determine uploads base directory (wp_get_upload_dir).'];
        }
        $file = trailingslashit($uploads['basedir']) . ltrim(self::JSON_REL_PATH, '/');

        if ( ! file_exists($file) ) {
            return ['error' => 'JSON file is missing: <code>'.esc_html(str_replace(ABSPATH,'',$file)).'</code>. Start the cron job.'];
        }
        if ( ! is_readable($file) ) {
            return ['error' => 'JSON exists but is not readable (permissions). Recommended: <code>0644</code>.'];
        }

        $raw = @file_get_contents($file);
        if ( ! is_string($raw) || $raw === '' ) {
            return ['error' => 'Could not read JSON contents.'];
        }

        $data = json_decode($raw, true);
        if ( json_last_error() !== JSON_ERROR_NONE ) {
            return ['error' => 'Invalid JSON: '.json_last_error_msg().'.'];
        }

        $data['jail'] = $data['jail'] ?? 'sshd';
        $data['ips']  = is_array($data['ips'] ?? null) ? $data['ips'] : [];
        $data['updated'] = $data['updated'] ?? '';
        $data['_filemtime'] = @filemtime($file) ?: null;

        return $data;
    }

    private function stale_warning($data) {
        $warn = '';
        $now  = time();
        $limit = self::STALE_MINUTES * 60;

        $age = null;
        if (!empty($data['updated'])) {
            $ts = strtotime($data['updated']);
            if ($ts) $age = $now - $ts;
        }
        if ($age === null && !empty($data['_filemtime'])) {
            $age = $now - intval($data['_filemtime']);
        }

        if ($age !== null && $age > $limit) {
            $mins = floor($age / 60);
            $warn = ' <span style="color:#b32d2e;">(warning: data ~'.$mins.' minutes old)</span>';
        }
        return $warn;
    }

    private function help_hints() {
        $uploads = wp_get_upload_dir();
        $target  = !empty($uploads['basedir']) ? trailingslashit($uploads['basedir']).self::JSON_REL_PATH : 'wp-content/uploads/'.self::JSON_REL_PATH;
        $hint  = '<div style="margin-top:8px; opacity:.8;"><strong>Setup hint:</strong>';
        $hint .= '<pre style="white-space:pre-wrap;"># Script that generates JSON (example):
/usr/local/bin/f2b-status.sh

# JSON target:
'.esc_html(str_replace(ABSPATH,'',$target)).'

# Cron (every 5 minutes):
*/5 * * * * /usr/bin/bash /usr/local/bin/f2b-status.sh >/dev/null 2>&1
</pre></div>';
        return $hint;
    }
}

new WP_F2B_Dashboard_JSON();

wp-f2b.js

jQuery(function ($) {
  $('#wp-f2b-refresh').on('click', function () {
    const $btn = $(this);
    const $out = $('#wp-f2b-result');
    $btn.prop('disabled', true).text('Refreshing...');
    $.post(WP_F2B.ajaxurl, { action: 'wp_f2b_refresh', nonce: WP_F2B.nonce })
      .done(function (res) {
        if (res && res.success && res.data && res.data.html) {
          $out.html(res.data.html);
        } else {
          $out.html('<p style="color:#b32d2e;">Error: invalid response.</p>');
        }
      })
      .fail(function () {
        $out.html('<p style="color:#b32d2e;">AJAX error.</p>');
      })
      .always(function () {
        $btn.prop('disabled', false).text('Refresh');
      });
  });
});

wp-f2b.css

#wp-f2b-wrap { line-height: 1.5; }
.wp-f2b-box { background:#fff; border:1px solid #e0e0e0; padding:12px; border-radius:6px; }
.wp-f2b-ul { max-height: 280px; overflow:auto; margin:8px 0 0; padding-left:1.2em; }
.wp-f2b-ul li { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }

Activate the plugin under Plugins → Installed Plugins.


Fail2ban JSON export

4) Testing & Debugging

  • Check the JSON: cat wp-content/uploads/f2b-status.json
  • Check cron execution: grep CRON /var/log/cron | tail -n 20
  • Check Fail2ban status: fail2ban-client status sshd

If the widget shows “No IPs listed” while Fail2ban shows otherwise, compare the “Updated” timestamp and file permissions (should be 0644). If timestamps lag, verify the cron schedule and that only one job writes the JSON to avoid conflicts.


5) Security & Best Practices

  • Do not let PHP run fail2ban-client. Keep logic in cron; export a readable JSON only.
  • Use minimal permissions: 0644 on the JSON; 0755 on its directory.
  • Avoid duplicate jobs: run one cron that writes the JSON (prevent race conditions).
  • If you intend to expose the JSON externally, add access rules (Apache/Nginx) to restrict or authenticate as needed.
  • Sanitize and escape everything you render in the widget (already handled in the sample).

6) Helpful Extensions (Optional)

  • Status badge (green/amber/red) based on JSON freshness.
  • Search filter in the IP list (small JS input to live-filter items).
  • List limiter (e.g., show first 100 IPs with a “Show more” button).
  • Export CSV from WordPress (button compiles ips into a downloadable CSV).
  • Webhooks: if currently_banned exceeds a threshold, post a Slack/Discord alert.
  • Multiple jails: extend the bash to iterate a list of jails and write f2b-<jail>.json per jail, or a single aggregated f2b-status-all.json.

Final Notes

By separating data collection (cron + bash) from display (WordPress widget that reads JSON), you get a clean, safe, and maintainable Export Dashboard. We avoid shell_exec in PHP, respect principle of least privilege, reduce server load, and still provide a friendly UI in wp-admin. The solution is easy to extend (add more security metrics) and portable across sites. With these steps, you’ve got a production-ready Fail2ban Dashboard that plays nicely with shared hosting, VPS, or dedicated servers.


🔔 For more tutorials like this, consider subscribing to our blog.
📩 Do you have questions or suggestions? Leave a comment or contact us!
🏷️ Tags: WordPress security, Fail2ban, JSON export, cron job, dashboard widget, wp-admin, server hardening, Bash script, VPS, sysadmin
📢 Hashtags: #WordPress, #Security, #Fail2ban, #Cron, #DevOps, #VPS, #SysAdmin, #PHP, #Bash, #Dashboard

Report an issue (max 5 words):

We store the message, post link, time, and IP (for abuse prevention). No account required.

Want to support us? Let friends in on the secret and share your favorite post!

2 online now

Live Referrers

Photo of author

Flo

How To Display Fail2ban Banned Ip to Dashboard Complete Guide

Published

Update

Welcome to HelpZone.blog, your go-to hub for expert insights, practical tips, and in-depth guides across technology, lifestyle, business, entertainment, and more! Our team of passionate writers and industry experts is dedicated to bringing you the latest trends, how-to tutorials, and valuable advice to enhance your daily life. Whether you're exploring WordPress tricks, gaming insights, travel hacks, or investment strategies, HelpZone is here to empower you with knowledge. Stay informed, stay inspired because learning never stops! 🚀

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.