Netgate Discussion Forum
    • Categories
    • Recent
    • Tags
    • Popular
    • Users
    • Search
    • Register
    • Login
    Introducing Netgate Nexus: Multi-Instance Management at Your Fingertips.

    PROJECT HAProxy Sentinel: Kernel-level Ban for Backend Authentication Failures

    Scheduled Pinned Locked Moved Development
    2 Posts 1 Posters 168 Views 1 Watching
    Loading More Posts
    • Oldest to Newest
    • Newest to Oldest
    • Most Votes
    Reply
    • Reply as topic
    Log in to reply
    This topic has been deleted. Only users with topic management privileges can see it.
    • K Offline
      keope
      last edited by keope

      Introduction

      Hi everyone! I’ve been a pfSense user for years, and I love constantly upgrading my firewall's capabilities. A few months ago, I set up HAProxy as a Reverse Proxy. While it’s incredibly convenient for "obfuscating" backend servers, I felt I lacked total control over countermeasures against brute-force attacks targeting backend services (e.g., Nextcloud, Apache portals, etc.).

      I wanted an authentication failure on the real server to trigger an immediate block—not just at the application level (L7), but at the Kernel level (L3) directly on the pfSense WAN, leveraging the power of pf.

      Transparency Note: This is my first experience developing such an integration. I used AI support to generate the logic for the PHP and shell scripts, while I focused on the core idea, system architecture, and extensive testing.
      Project Logic

      The system connects the web server and the firewall in a clean, efficient way:

      Real Server (Apache): Detects the attack via fail2ban.
      
      Communication: Fail2ban sends a remote log via syslog to pfSense.
      
      pfSense (syslog-ng): Receives the log and writes it to a dedicated file.
      
      The Daemon (Sentinel): A shell script monitors the log in real-time and:
      
          Adds the IP to the pf table (via easyrule).
      
          Records the ban in an SQLite database for persistence.
      
          Sends a native pfSense notification (Email/System).
      
      GUI: A dedicated PHP page in Diagnostics allows you to view history and manually unblock IPs.
      

      Requirements on the Real Server (Apache/Backend)

      To ensure the firewall receives the correct source IP and not the proxy's internal IP, you need:

      mod_security and mod_remoteip properly configured.
      
      Fail2ban with a dedicated action.
      

      Example Fail2ban Action (/etc/fail2ban/action.d/pfsense-logger.conf):
      Ini, TOML

      [Definition]
      actionban = logger -n <PFSENSE_IP> -P 5140 -t fail2ban "BAN <ip>"
      actionunban =

      Installation on pfSense

      1. Prerequisites

        Install the syslog-ng package.

        Configure syslog-ng to listen on port 5140 (UDP) and write logs to /var/syslog-ng/fail2ban_remote.log.

      2. The Engine: f2b_light.sh

      Place this in /root/f2b_light.sh (permissions 755). This script is the heart of the system, monitoring logs and managing the database.
      Bash

      #!/bin/sh
      # Fail2ban Sentinel Light - Stability Edition
      DB_FILE="/var/db/f2b_pfsense.db"
      TABLE="EasyRuleBlockHostsWAN"
      LOG_FILE="/var/syslog-ng/fail2ban_remote.log"
      
      # Attendi che syslog-ng sia effettivamente pronto (evita crash al boot)
      sleep 5
      
      # Crea il database se non esiste
      /usr/local/bin/sqlite3 "$DB_FILE" "CREATE TABLE IF NOT EXISTS bans (ip TEXT PRIMARY KEY, timestamp DATETIME);"
      
      # Monitoraggio log più stabile
      # Usiamo tail -F e un controllo pipe
      tail -n0 -F "$LOG_FILE" | while read -r LINE; do
          case "$LINE" in
              *"fail2ban: BAN"*)
                  IP=${LINE##* }
                  if [ -n "$IP" ]; then
                      # 1. Ban nel firewall
                      /sbin/pfctl -t "$TABLE" -T add "$IP" 2>/dev/null
                      
                      # 2. Registrazione nel database
                      /usr/local/bin/sqlite3 "$DB_FILE" "INSERT OR REPLACE INTO bans VALUES ('$IP', datetime('now'));"
                      
                      # 3. Invio Notifica Email (Metodo Diretto PHP)
                      /usr/local/bin/php-cgi -q <<EOF
      <?php
      require_once("config.inc");
      require_once("notices.inc");
      \$subject = "IP BAN: $IP";
      \$message = "Sentinel Light ha bloccato l'host: $IP\nData: " . date("Y-m-d H:i:s");
      notify_all_remote(\$message, \$subject);
      ?>
      EOF
                      # 4. Log di sistema (senza logger per non sovraccaricare syslog durante un ban)
                      # Usiamo un semplice echo nel log per evitare loop
                  fi
                  ;;
          esac
      done
      
      1. The Service: f2b_sentinel.sh

      For automatic startup, place this in /usr/local/etc/rc.d/f2b_sentinel.sh (permissions 755).
      Bash

      #!/bin/sh
      
      # PROVIDE: f2b_sentinel
      # REQUIRE: LOGIN
      # KEYWORD: shutdown
      
      . /etc/rc.subr
      
      name="f2b_sentinel"
      rcvar="f2b_sentinel_enable"
      start_cmd="f2b_sentinel_start"
      stop_cmd="f2b_sentinel_stop"
      
      f2b_sentinel_start() {
          echo "Avvio Sentinel Light..."
          # Controlla se è già attivo per evitare duplicati
          if ! pgrep -f "f2b_light.sh" > /dev/null; then
              /usr/sbin/daemon -f /root/f2b_light.sh
          else
              echo "Sentinel è già in esecuzione."
          fi
      }
      
      f2b_sentinel_stop() {
          echo "Arresto Sentinel Light..."
          pkill -f f2b_light.sh
      }
      
      case $1 in
          start)
              f2b_sentinel_start
              ;;
          stop)
              f2b_sentinel_stop
              ;;
          restart)
              f2b_sentinel_stop
              sleep 2
              f2b_sentinel_start
              ;;
          *)
              # Se chiamato senza argomenti o argomenti sconosciuti, avvia se non attivo
              f2b_sentinel_start
              ;;
      esac
      
      1. The GUI: fail2ban.php

      Copy this file to /usr/local/www/diagnostics_fail2ban.php. It will be accessible directly via URL.
      PHP

      <?php
      require_once("guiconfig.inc");
      
      $pgtitle = array(gettext("Diagnostics"), gettext("Fail2ban Manager"));
      include("head.inc");
      
      $db_path = "/var/db/f2b_pfsense.db";
      $table_name = "EasyRuleBlockHostsWAN";
      
      // --- LOGICA DI ELIMINAZIONE (SBLOCCO) ---
      if ($_POST['act'] == 'del' && !empty($_POST['ip'])) {
          $ip_to_unblock = $_POST['ip'];
          
          // 1. Forza la rimozione immediata dalla tabella del firewall (RAM)
          exec("/sbin/pfctl -t " . escapeshellarg($table_name) . " -T delete " . escapeshellarg($ip_to_unblock));
          
          // 2. Tenta di rimuoverlo dalla configurazione persistente di pfSense
          exec("/usr/local/bin/easyrule unblock wan " . escapeshellarg($ip_to_unblock));
      
          // 3. Rimuovi dal database locale SQLite
          $db = new SQLite3($db_path);
          if ($db) {
              $stmt = $db->prepare('DELETE FROM bans WHERE ip = :ip');
              $stmt->bindValue(':ip', $ip_to_unblock, SQLITE3_TEXT);
              $stmt->execute();
              $db->close();
              $savemsg = "IP $ip_to_unblock rimosso con successo dal firewall e dal database.";
          }
      }
      
      // --- RECUPERO DATI ---
      $bans = [];
      if (file_exists($db_path)) {
          $db = new SQLite3($db_path);
          $results = $db->query('SELECT ip, timestamp FROM bans ORDER BY timestamp DESC');
          while ($row = $results->fetchArray(SQLITE3_ASSOC)) {
              $bans[] = $row;
          }
          $db->close();
      }
      
      // --- INTERFACCIA GRAFICA ---
      ?>
      
      <div class="panel panel-default">
          <div class="panel-heading"><h2 class="panel-title">Host Bannati Recentemente</h2></div>
          <div class="panel-body">
              <?php if ($savemsg) print_info_box($savemsg, 'success'); ?>
              
              <div class="table-responsive">
                  <table class="table table-striped table-hover">
                      <thead>
                          <tr>
                              <th>Indirizzo IP</th>
                              <th>Data/Ora Ban</th>
                              <th>Stato Firewall</th>
                              <th>Azioni</th>
                          </tr>
                      </thead>
                      <tbody>
                          <?php if (empty($bans)): ?>
                              <tr><td colspan="4" class="text-center">Nessun IP bannato al momento.</td></tr>
                          <?php else: foreach ($bans as $ban): 
                              // Verifica se l'IP è realmente presente nella tabella pf attiva
                              exec("/sbin/pfctl -t " . escapeshellarg($table_name) . " -T show | grep " . escapeshellarg($ban['ip']), $output, $return);
                              $is_active = ($return == 0);
                          ?>
                              <tr>
                                  <td><?= htmlspecialchars($ban['ip']) ?></td>
                                  <td><?= htmlspecialchars($ban['timestamp']) ?></td>
                                  <td>
                                      <span class="label label-<?= $is_active ? 'danger' : 'default' ?>">
                                          <?= $is_active ? 'BLOCCATO' : 'INATTIVO' ?>
                                      </span>
                                  </td>
                                  <td>
                                      <form method="post" style="display:inline;">
                                          <input type="hidden" name="act" value="del">
                                          <input type="hidden" name="ip" value="<?= htmlspecialchars($ban['ip']) ?>">
                                          <button type="submit" class="btn btn-xs btn-warning">
                                              <i class="fa fa-unlock"></i> Sblocca
                                          </button>
                                      </form>
                                  </td>
                              </tr>
                          <?php endforeach; endif; ?>
                      </tbody>
                  </table>
              </div>
          </div>
      </div>
      
      <div class="alert alert-info">
          <p><strong>Nota:</strong> Gli IP vengono rimossi automaticamente dal firewall dopo 7 giorni dallo script Sentinel. 
          Il tasto "Sblocca" forza la rimozione immediata sia dalla memoria (pf) che dalle regole permanenti.</p>
      </div>
      
      <?php include("foot.inc"); ?>
      
      1. Automatic Cleanup (Cron)

      This cleans up bans older than 7 days from both the firewall and the DB.
      Bash

      0 3 * * * root /usr/local/bin/sqlite3 /var/db/f2b_pfsense.db "SELECT ip FROM bans WHERE timestamp < datetime('now', '-7 days');" | xargs -I {} sh -c "/sbin/pfctl -t EasyRuleBlockHostsWAN -T delete {}; /usr/local/bin/sqlite3 /var/db/f2b_pfsense.db "DELETE FROM bans WHERE ip = '{}'";"

      Final Notes & Tips

      Firewall Table: Ensure there is a "Block" rule on your WAN interface using the EasyRuleBlockHostsWAN table.
      
      Persistence: I have verified that IPs remain in the SQLite DB after a reboot. Currently, the pf table is repopulated when the daemon starts.
      
      Why not just Snort/pfBlocker? Snort works on signatures, and pfBlocker on known lists. This system reacts in real-time to specific behaviors on your apps, blocking the attacker at the Kernel level before their second attempt.
      
      Feedback: If you find this project useful or have suggestions for improvement (especially regarding PHP or menu integration), please leave a comment! I would love to hear back from the community.
      
      1 Reply Last reply Reply Quote 0
      • K Offline
        keope
        last edited by

        I kindly ask a moderator/administrator to remove this post if I posted something inappropriate. If so, I apologize.

        1 Reply Last reply Reply Quote 0
        • First post
          Last post
        Copyright 2026 Rubicon Communications LLC (Netgate). All rights reserved.