Improve ban events search search through all db-entries and also implement pagination to load more events

This commit is contained in:
2026-02-03 14:31:52 +01:00
parent b3e32fd5c1
commit 5f14da5934
13 changed files with 494 additions and 225 deletions

276
README.md
View File

@@ -209,77 +209,11 @@ Modern enterprises face increasing security challenges with generally distribute
---
## 🏗️ Architecture
### System Components
```
┌────────────────────────────────────────────────────────────┐
│ Fail2Ban UI Web Interface │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Dashboard │ │ Management │ │ Settings │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└────────────────────────────────────────────────────────────┘
┌────────────────────────────────────────────────────────────────┐
│ Go Backend API Server │
│ ┌─────────────────────────────┐ ┌──────────────────────┐ │
│ │ Fail2Ban UI (Backend) │--->│ Send Alerts via Mail │ │
│ │ - Gin handlers + REST API │ │ (planned: Elastic) │ │
│ │ - Vanilla JS + Tailwind UI │ └──────────────────────┘ │
│ ->│ - SQLite storage │ │
│ │ └──────────────┬──────────────┘ │
│ │ │ │
│ │ ┌──────────┴────────────┐ ┌─────────────────────┐ │
│ │ │ Connector Manager and │-------│ Integrations │ │
│ │ │ handlers / actions │ │ Mikrotik / pfSense │ │
│ │ └────────────────────┬──┘ └─────────────────────┘ │
│ │ │ │
└─│───────────────────────────│──────────────────────────────────┘
│ │
│ ▼
┌─│─────────────────────────────────────────────────────────────┐
│ │ Connection to remote Server │
│ │ ───────────────────────────── │
│ │ │ │ │ │
│ │ ▼ ▼ ▼ │
│ │ ┌────────┐ ┌────────┐ ┌────────┐ │
│ │ │ Local │ │ SSH │ │ API │ │
│ │ │ Server │ │ Server │ │ Agent │ │
│ │ └────────┘ └────────┘ └────────┘ │
│ │ │ │ │ │
│ │ │ │ │ │
│ │ ┌─────┴─────────────┴─────────────┴─────┐ │
│ │ │ Fail2Ban instances on Reverse Proxies │ │
│ │ │ or remote / local Webserver │ │
│ │ └─────────────┬─────────────────────────┘ │
│ │ │ │
│ │ ┌──────────┴────────────┐ │
│ │ │ Report Alerts back to │ │
│ <----------│ Fail2Ban-UI REST with │ │
│ │ custom action │ │
│ └───────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```
### Technology Stack
- **Backend**: Go 1.24+ (Golang)
- **Frontend**: Vanilla JavaScript, Tailwind CSS
- **Database**: SQLite (embedded)
- **Container Runtime**: Podman/Docker compatible
- **Service Management**: systemd
- **Security**: SELinux compatible
---
## 🚀 Quick Start
### Prerequisites
- **Operating System**: Linux (RHEL 8+, Ubuntu 20.04+, Debian 11+, or containerized)
- **System**: Linux (RHEL 8+, Ubuntu 20.04+, Debian 11+) or Container-Environment
- **Fail2Ban**: At least version 0.10+ installed and configured
- **Go**: Version 1.24+ (only for source builds)
- **Node.js**: Version 16+ (only for source build - Tailwind CSS)
@@ -287,7 +221,7 @@ Modern enterprises face increasing security challenges with generally distribute
### Installation Methods (Example with mounts for local fail2ban connector)
#### Method 1: Container Deployment (Recommended for Production)
#### Method 1: Container Deployment (Recommended)
**Option A: Using Pre-built Image**
@@ -302,7 +236,7 @@ docker pull swissmakers/fail2ban-ui:latest
# podman pull registry.swissmakers.ch/infra/fail2ban-ui:latest
# docker pull registry.swissmakers.ch/infra/fail2ban-ui:latest
# Run the container
# Run the container (for usage with the local connector - fail2ban runs on same host)
podman run -d \
--name fail2ban-ui \
--network=host \
@@ -326,7 +260,7 @@ sudo podman build -t fail2ban-ui:dev .
# or with Docker:
sudo docker build -t fail2ban-ui:dev .
# Run the container
# Run the container (for usage with the local connector - fail2ban runs on same host)
sudo podman run -d \
--name fail2ban-ui \
--network=host \
@@ -339,14 +273,14 @@ sudo podman run -d \
**Option C: Using Docker Compose**
For easier management, use Docker Compose:
For a quicker start you can also use Docker Compose:
```bash
# Copy the example file
cp docker-compose.example.yml docker-compose.yml
# or
cp docker-compose-allinone.example.yml docker-compose.yml
# Edit docker-compose.yml to customize (e.g., change PORT)
# Edit docker-compose.yml to customize (e.g., change PORT and so on..)
# Then start:
podman compose up -d
# or
@@ -357,17 +291,37 @@ docker-compose up -d
Change the default port (8080) using the `PORT` environment variable:
```bash
podman run -d \
--name fail2ban-ui \
--network=host \
-e PORT=3080 \
-v /opt/podman-fail2ban-ui:/config:Z \
-v /etc/fail2ban:/etc/fail2ban:Z \
-v /var/log:/var/log:ro \
-v /var/run/fail2ban:/var/run/fail2ban \
swissmakers/fail2ban-ui:latest
-e PORT=3080 \
```
**Bind address**
By default, the HTTP server listens on `0.0.0.0`. To bind to a specific interface, set `BIND_ADDRESS` to an IP address (e.g. `127.0.0.1` for localhost only):
```bash
-e BIND_ADDRESS=127.0.0.1 \
```
**Disable External IP Lookup** (Privacy / air-gapped)
By default, the web UI displays your external IP address by querying external services. For privacy reasons, you can disable this feature using the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable:
```bash
-e DISABLE_EXTERNAL_IP_LOOKUP=true \
```
When set, the "Your ext. IP:" display will be completely hidden and no external IP lookup requests will be made.
**Disable version update check** (Privacy / air-gapped)
On page load, the footer can check the latest release on GitHub to show "Latest" or "Update available". To disable this external request (e.g. in air-gapped or privacy-sensitive environments), set `UPDATE_CHECK=false`:
```bash
-e UPDATE_CHECK=false \
```
When disabled, the footer still shows the current version but does not perform any request to GitHub.
**OIDC Authentication Configuration (Optional)**
Enable OIDC authentication by setting the required environment variables. This protects the web UI with your identity provider. The logout flow automatically redirects back to the login page after successful provider logout.
@@ -426,7 +380,7 @@ podman run -d \
-e OIDC_REDIRECT_URL=https://fail2ban-ui.example.com/auth/callback
```
**Advanced Options:**
**Advanced OIDC Options:**
```bash
-e OIDC_SCOPES=openid,profile,email,groups \
-e OIDC_SESSION_MAX_AGE=7200 \
@@ -436,26 +390,15 @@ podman run -d \
**Note:** If `OIDC_SESSION_SECRET` is not provided, a random secret will be generated on startup. For production, it's recommended to set a fixed secret.
Access the web interface at `http://localhost:3080`.
**Disable External IP Lookup** (Privacy)
**Email alert template style**
By default, the web UI displays your external IP address by querying external services. For privacy reasons, you can disable this feature using the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable:
Alert emails (ban/unban notifications) use a "modern" HTML template by default. To use the classic style fail2ban-UI instead, set:
```bash
podman run -d \
--name fail2ban-ui \
--network=host \
-e DISABLE_EXTERNAL_IP_LOOKUP=true \
-v /opt/podman-fail2ban-ui:/config:Z \
-v /etc/fail2ban:/etc/fail2ban:Z \
-v /var/log:/var/log:ro \
-v /var/run/fail2ban:/var/run/fail2ban \
swissmakers/fail2ban-ui:latest
-e emailStyle=classic
```
When set, the "Your ext. IP:" display will be completely hidden and no external IP lookup requests will be made.
**Volume Mounts Explained**
| Volume | Required | Purpose |
@@ -505,8 +448,8 @@ See the [Security Notes](#-security-notes) section for complete OIDC configurati
### First Launch
1. **Access the Web Interface**
- Navigate to `http://localhost:8080` (or your configured port)
- Default port: `8080` (configurable via `PORT` environment variable or in UI settings)
- Navigate to `http://localhost:8080` or `http://YOUR-LAN-IP:8080`
- Default port: `8080` (can be changed via `PORT` environment variable or in UI settings)
2. **Add Your First Server**
- **Local Server**: Enable the local connector if Fail2Ban runs on the same host
@@ -521,6 +464,141 @@ See the [Security Notes](#-security-notes) section for complete OIDC configurati
## 📚 Documentation
### Highlevel System Architecture / Communication
- Backend: Go 1.24+ (Golang)
- Frontend: Vanilla JavaScript, Tailwind CSS
- Database: SQLite (embedded)
- Container Runtime: Podman/Docker compatible
- Service Management: systemd / container
- Security: SELinux compliant / least privileges
The following diagrams should describe the communication paths between browser (frontend) ↔ API, WebSocket message types, callbacks from Fail2ban instances, and connector types.
#### Browser (Frontend) ↔ Backend (HTTP / WebSocket) communication
```
┌───────────────────────────────────────────────────────────────────────────────────┐
│ FRONTEND (Vanilla JS + Tailwind CSS) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Dashboard │ │ Filter Debug│ │ Settings │ │ (index) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ └────────────────┴────────────────┴────────────────┘ │
│ │ │
│ Communication to backend: │ HTTP/HTTPS (REST) │
│ • GET / │ • All /api/* (except callbacks) use your │
│ • GET /api/summary │ session when OIDC is enabled │
│ • GET /api/events/bans │ • X-F2B-Server header for server selection │
│ • GET /api/version │ │
│ • POST /api/jails/:jail/unban/:ip │ WebSocket: GET /api/ws (upgrade) │
│ • POST /api/jails/:jail/ban/:ip │ • Same origin, same cookies as HTTP │◀-┐
│ • POST /api/settings │ • Receives: heartbeat, console_log, │ │
│ • … (see diagram 2) │ ban_event, unban_event │ │
└────────────────────────────────────┼──────────────────────────────────────────────┘ │ W
│ │ e
▼ │ b
┌─────────────────────────────────────────────────────────────────────────────────┐ │ s
│ GO BACKEND (Gin) │ │ o
│ ┌───────────────────────────────────────────────────────────────────────────┐ │ │ c
│ │ PUBLIC (no OIDC-auth session needed for access): │ │ │ k
│ │ • /auth/login | /auth/callback | /auth/logout │ │ │ e
│ │ • /auth/status | /auth/user │ │ │ t
│ │ • POST /api/ban | POST /api/unban ← Fail2ban callbacks (a valid Callback │ │ │
│ │ • GET /api/ws (WebSocket) Secret is needed) │ │ │
│ │ • /static/* | /locales/* │ │----┘
│ └───────────────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
│ │ PROTECTED (when OIDC enabled): GET / | GET and POST to all other /api/* │ │
│ └───────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────┘
```
#### Backend internals: API routes, WebSocket hub, storage, connectors
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ GIN SERVER │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ REST API (group /api) │ │
│ │ • GET /summary → Connector(s) → Fail2ban(jails, banned IPs) │ │
│ │ • GET /jails/:jail/config • POST /jails/:jail/config │ │
│ │ • GET /jails/manage • POST /jails/manage | POST /jails │ │
│ │ • POST /jails/:jail/unban/:ip • POST /jails/:jail/ban/:ip │ │
│ │ • GET /settings • POST /settings │ │
│ │ • GET /events/bans • GET /events/bans/stats | /insights │ │
│ │ • GET /version (optional GitHub request if UPDATE_CHECK) │ │
│ │ • GET /servers | POST/DELETE /servers | POST /servers/:id/test │ │
│ │ • GET /filters/* • POST /filters/test | POST/DELETE /filters │ │
│ │ • POST /fail2ban/restart • GET/POST /advanced-actions/* │ │
│ │ • POST /ban (callback) • POST /unban (callback) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌─────────────────────────────────┴──────────────────────────────────────────┐ │
│ │ WebSocket Hub (GET /api/ws) │ │
│ │ • register / unregister clients │ │
│ │ • broadcast to all clients: │ │
│ │ - type: "heartbeat" (every ~30s) │ │
│ │ - type: "console_log" (debug console lines) │ │
│ │ - type: "ban_event" (after POST /api/ban → store → broadcast) │ │
│ │ - type: "unban_event" (after POST /api/unban → store → broadcast) │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ SQLite Storage │ │ Whois / GeoIP │ │
│ │ • ban_events │ │ • IP → country/hostname │ │
│ │ • app_settings, servers │ │ MaxMind or ip-api.com │ │
│ │ • permanent_blocks │ │ • Used in UI and emails │ │
│ └────────────────────────────┘ └────────────────────────────┘ │
│ ┌────────────────────────────┐ ┌────────────────────────────┐ │
│ │ Connector Manager │ │ Integrations + Email │ │
│ │ • Local (fail2ban.sock) │ │ • Mikrotik / pfSense / │ │
│ │ • SSH (exec on remote) │ │ OPNsense (block/unblock)│ │
│ │ • Agent (HTTP to agent) │ │ • SMTP alert emails │ │
│ │ • New server init: ensure │ └────────────────────────────┘ │
│ │ action.d (ui-custom- │ │
│ │ action.conf) │ │
│ └────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
```
#### Fail2ban instances → Fail2ban-UI (callbacks) and Fail2ban-UI → Fail2ban (via connectors)
```
┌──────────────────────────────────────────────────────────────────────────────────┐
│ FAIL2BAN INSTANCES (one per server: local, SSH host, or agent host) │
│ On each host: Fail2ban + action script (ui-custom-action.conf) │
│ On ban/unban → action runs → HTTP POST to Fail2ban-UI callback URL │
│ │
│ ┌───────────────────────────────────────────────────────────────┐ │
│ │ Outbound to Fail2ban-UI (from each Fail2ban host) │ │
│ │ POST <CallbackURL>/api/ban or /api/unban │ │
│ │ Header: X-Callback-Secret: <configured secret> │ │
│ │ Body: JSON { serverId, ip, jail, hostname, failures, logs } │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────────────────────────┐ │
│ │ Fail2ban-UI Backend │ │
│ │ 1. Validate X-Callback-Secret → 401 if missing/invalid │ │
│ │ 2. Resolve server (serverId or hostname) │ │
│ │ 3. Whois/GeoIP enrichment │ │
│ │ 4. Store event in SQLite DB (ban_events) if nothing was invalid │ │
│ │ 5. Broadcast current event to WebSocket clients (ban_event / unban_event) │ │
│ │ 6. Optional: send SMTP alert │ │
│ │ 7 Run additional actions (e.g. block on pfSense for recurring offenders) │ │
│ │ 8. Respond status 200 OK - if all above was without an error │ │
│ └────────────────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────┐
│ INBOUND from Fail2ban-UI to Fail2ban (per connector type) │
│ • Local: fail2ban-client over Unix socket (/var/run/fail2ban/fail2ban.sock) │
│ • SSH: SSH + fail2ban-client on remote host │
│ • Agent: HTTP to agent API (e.g. /v1/jails/:jail/unban, /v1/jails/:jail/ban) │
│ Used for: summary (jails, banned IPs), unban/ban from UI, config read/write, │
│ filter test, jail create/delete, restart/reload, logpath test │
└─────────────────────────────────────────────────────────────────────────────────┘
```
### Deployment Guides
- **[Container Deployment Guide](./deployment/container/README.md)**:
@@ -615,6 +693,7 @@ The **Fail2Ban Callback URL** is a critical setting that determines how Fail2Ban
**Privacy Settings**
- **External IP Lookup**: By default, the web UI displays your external IP address. To disable this feature for privacy reasons, set the `DISABLE_EXTERNAL_IP_LOOKUP` environment variable to `true` or `1`. This will hide the "Your ext. IP:" display and prevent any external IP lookup requests.
- **Version update check**: On page load, the footer may request the latest release from GitHub to show an "Update available" badge. Set `UPDATE_CHECK=false` to disable this external request (e.g. air-gapped or privacy-sensitive environments). The current version is still shown in the footer.
- For custom callback URLs (e.g., reverse proxy or custom IP), you must manually update them to match your setup
**Important Notes:**
@@ -721,6 +800,7 @@ OIDC_USERNAME_CLAIM=preferred_username # Claim to use as username (default:
OIDC_LOGOUT_URL=https://auth.example.com/logout # Provider logout URL (optional, auto-constructed if not set)
OIDC_CLIENT_SECRET_FILE=/path/to/secret-file # Path to client secret file (for auto-configuration)
OIDC_SKIP_VERIFY=false # Skip TLS verification (dev only, default: false)
OIDC_SKIP_LOGINPAGE=false # Skip login page and redirect directly to OIDC provider (default: false)
```
**Configuration Examples:**

View File

@@ -53,6 +53,9 @@ services:
# Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true
# Optional: Disable version update check (default: enabled).
# When set to false, the footer will not request the latest release from GitHub (e.g. air-gapped or privacy-sensitive environments).
# - UPDATE_CHECK=false
# ============================================
# OIDC Authentication (Optional)

View File

@@ -34,6 +34,9 @@ services:
# Optional: Disable external IP lookup for privacy (default: false).
# When set to true, the "Your ext. IP:" display will be hidden and no external IP lookup requests will be made.
# - DISABLE_EXTERNAL_IP_LOOKUP=true
# Optional: Disable version update check (default: enabled).
# When set to false, the footer will not request the latest release from GitHub (e.g. air-gapped or privacy-sensitive environments).
# - UPDATE_CHECK=false
# ============================================
# OIDC Authentication (Optional)

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Noch keine Serverdaten verfügbar.",
"logs.overview.recent_filtered_empty": "Keine gespeicherten Ereignisse passen zu den Filtern.",
"logs.overview.recent_count_label": "Angezeigte Ereignisse",
"logs.overview.load_more": "Mehr laden",
"logs.overview.country_unknown": "Unbekannt",
"logs.overview.last_seen": "Zuletzt gesehen",
"logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "No keni Serverdate verfügbar.",
"logs.overview.recent_filtered_empty": "Kei Ereigniss erfülle d Filter.",
"logs.overview.recent_count_label": "Aazeigti Ereigniss",
"logs.overview.load_more": "Mehr lade",
"logs.overview.country_unknown": "Unbekannt",
"logs.overview.last_seen": "Zletscht gseh",
"logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "No per-server data available yet.",
"logs.overview.recent_filtered_empty": "No stored events match the current filters.",
"logs.overview.recent_count_label": "Events shown",
"logs.overview.load_more": "Load more",
"logs.overview.country_unknown": "Unknown",
"logs.overview.last_seen": "Last seen",
"logs.table.server": "Server",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Aún no hay datos por servidor.",
"logs.overview.recent_filtered_empty": "No hay eventos que coincidan con los filtros.",
"logs.overview.recent_count_label": "Eventos mostrados",
"logs.overview.load_more": "Cargar más",
"logs.overview.country_unknown": "Desconocido",
"logs.overview.last_seen": "Última vez",
"logs.table.server": "Servidor",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Aucune donnée par serveur pour le moment.",
"logs.overview.recent_filtered_empty": "Aucun événement ne correspond aux filtres.",
"logs.overview.recent_count_label": "Événements affichés",
"logs.overview.load_more": "Charger plus",
"logs.overview.country_unknown": "Inconnu",
"logs.overview.last_seen": "Dernière apparition",
"logs.table.server": "Serveur",

View File

@@ -68,6 +68,7 @@
"logs.overview.per_server_empty": "Ancora nessun dato per server.",
"logs.overview.recent_filtered_empty": "Nessun evento corrisponde ai filtri.",
"logs.overview.recent_count_label": "Eventi mostrati",
"logs.overview.load_more": "Carica altri",
"logs.overview.country_unknown": "Sconosciuto",
"logs.overview.last_seen": "Ultima visualizzazione",
"logs.table.server": "Server",

View File

@@ -603,6 +603,139 @@ WHERE 1=1`
return results, rows.Err()
}
const (
// MaxBanEventsLimit is the maximum number of events per API request (pagination page size).
MaxBanEventsLimit = 50
// MaxBanEventsOffset is the maximum offset (total events loaded in UI capped for browser stability).
MaxBanEventsOffset = 1000
)
// ListBanEventsFiltered returns ban events with optional search and country filter, ordered by occurred_at DESC.
// search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset.
func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
if limit <= 0 || limit > MaxBanEventsLimit {
limit = MaxBanEventsLimit
}
if offset < 0 || offset > MaxBanEventsOffset {
offset = 0
}
baseQuery := `
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, event_type, occurred_at, created_at
FROM ban_events
WHERE 1=1`
args := []any{}
if serverID != "" {
baseQuery += " AND server_id = ?"
args = append(args, serverID)
}
if !since.IsZero() {
baseQuery += " AND occurred_at >= ?"
args = append(args, since.UTC())
}
search = strings.TrimSpace(search)
if search != "" {
baseQuery += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
pattern := "%" + search + "%"
for i := 0; i < 5; i++ {
args = append(args, pattern)
}
}
if country != "" && country != "all" {
if country == "__unknown__" {
baseQuery += " AND (country IS NULL OR country = '')"
} else {
baseQuery += " AND LOWER(COALESCE(country,'')) = ?"
args = append(args, strings.ToLower(country))
}
}
baseQuery += " ORDER BY occurred_at DESC LIMIT ? OFFSET ?"
args = append(args, limit, offset)
rows, err := db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []BanEventRecord
for rows.Next() {
var rec BanEventRecord
var eventType sql.NullString
if err := rows.Scan(
&rec.ID,
&rec.ServerID,
&rec.ServerName,
&rec.Jail,
&rec.IP,
&rec.Country,
&rec.Hostname,
&rec.Failures,
&rec.Whois,
&rec.Logs,
&eventType,
&rec.OccurredAt,
&rec.CreatedAt,
); err != nil {
return nil, err
}
if eventType.Valid {
rec.EventType = eventType.String
} else {
rec.EventType = "ban"
}
results = append(results, rec)
}
return results, rows.Err()
}
// CountBanEventsFiltered returns the total count of ban events matching the same filters as ListBanEventsFiltered.
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
}
query := `SELECT COUNT(*) FROM ban_events WHERE 1=1`
args := []any{}
if serverID != "" {
query += " AND server_id = ?"
args = append(args, serverID)
}
if !since.IsZero() {
query += " AND occurred_at >= ?"
args = append(args, since.UTC())
}
search = strings.TrimSpace(search)
if search != "" {
query += " AND (ip LIKE ? OR jail LIKE ? OR server_name LIKE ? OR COALESCE(hostname,'') LIKE ? OR COALESCE(country,'') LIKE ?)"
pattern := "%" + search + "%"
for i := 0; i < 5; i++ {
args = append(args, pattern)
}
}
if country != "" && country != "all" {
if country == "__unknown__" {
query += " AND (country IS NULL OR country = '')"
} else {
query += " AND LOWER(COALESCE(country,'')) = ?"
args = append(args, strings.ToLower(country))
}
}
var total int64
if err := db.QueryRowContext(ctx, query, args...).Scan(&total); err != nil {
return 0, err
}
return total, nil
}
// CountBanEventsByServer returns simple aggregation per server.
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
if db == nil {
@@ -918,6 +1051,7 @@ CREATE TABLE IF NOT EXISTS ban_events (
CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
CREATE INDEX IF NOT EXISTS idx_ban_events_ip ON ban_events(ip);
CREATE TABLE IF NOT EXISTS permanent_blocks (
id INTEGER PRIMARY KEY AUTOINCREMENT,

View File

@@ -365,15 +365,27 @@ func UnbanNotificationHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"})
}
// ListBanEventsHandler returns stored ban events from the internal database.
// ListBanEventsHandler returns stored ban events from the internal database with optional search and pagination.
// Query params: serverId, limit (default 50, max 50), offset (default 0, max 1000), since, search, country.
// When offset=0, response includes total (matching count). Response includes hasMore when more results exist.
func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId")
limit := 100
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" {
limit := storage.MaxBanEventsLimit
if limitStr := c.DefaultQuery("limit", strconv.Itoa(storage.MaxBanEventsLimit)); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
if parsed <= storage.MaxBanEventsLimit {
limit = parsed
}
}
}
offset := 0
if offsetStr := c.DefaultQuery("offset", "0"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
if parsed <= storage.MaxBanEventsOffset {
offset = parsed
}
}
}
var since time.Time
if sinceStr := c.Query("since"); sinceStr != "" {
@@ -381,13 +393,24 @@ func ListBanEventsHandler(c *gin.Context) {
since = parsed
}
}
search := strings.TrimSpace(c.Query("search"))
country := strings.TrimSpace(c.Query("country"))
events, err := storage.ListBanEvents(c.Request.Context(), serverID, limit, since)
ctx := c.Request.Context()
events, err := storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"events": events})
resp := gin.H{"events": events, "hasMore": len(events) == limit}
if offset == 0 {
total, errCount := storage.CountBanEventsFiltered(ctx, serverID, since, search, country)
if errCount == nil {
resp["total"] = total
}
}
c.JSON(http.StatusOK, resp)
}
// BanStatisticsHandler returns aggregated ban counts per server.

View File

@@ -69,61 +69,93 @@ function fetchBanStatisticsData() {
});
}
function fetchBanEventsData() {
return fetch('/api/events/bans?limit=200')
// Builds query string for ban events API: limit, offset, search, country, serverId
function buildBanEventsQuery(offset, append) {
var params = [
'limit=' + BAN_EVENTS_PAGE_SIZE,
'offset=' + (append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0)
];
var search = (banEventsFilterText || '').trim();
if (search) {
params.push('search=' + encodeURIComponent(search));
}
var country = (banEventsFilterCountry || 'all').trim();
if (country && country !== 'all') {
params.push('country=' + encodeURIComponent(country));
}
if (currentServerId) {
params.push('serverId=' + encodeURIComponent(currentServerId));
}
return '/api/events/bans?' + params.join('&');
}
// options: { append: true } to load next page and append; otherwise fetches first page (reset).
function fetchBanEventsData(options) {
options = options || {};
var append = options.append === true;
var offset = append ? Math.min(latestBanEvents.length, BAN_EVENTS_MAX_LOADED) : 0;
if (append && offset >= BAN_EVENTS_MAX_LOADED) {
return Promise.resolve();
}
var url = buildBanEventsQuery(offset, append);
return fetch(url)
.then(function(res) { return res.json(); })
.then(function(data) {
latestBanEvents = data && data.events ? data.events : [];
// Track the last event ID to prevent duplicates from WebSocket
if (latestBanEvents.length > 0 && wsManager) {
var events = data && data.events ? data.events : [];
if (append) {
latestBanEvents = latestBanEvents.concat(events);
} else {
latestBanEvents = events;
}
banEventsHasMore = data.hasMore === true;
if (offset === 0 && typeof data.total === 'number') {
banEventsTotal = data.total;
}
if (!append && latestBanEvents.length > 0 && wsManager) {
wsManager.lastBanEventId = latestBanEvents[0].id;
}
})
.catch(function(err) {
console.error('Error fetching ban events:', err);
if (!append) {
latestBanEvents = latestBanEvents || [];
banEventsTotal = null;
banEventsHasMore = false;
}
});
}
// Add new ban or unban event from WebSocket
// Add new ban or unban event from WebSocket (only when not searching; cap at BAN_EVENTS_MAX_LOADED)
function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs
var hasSearch = (banEventsFilterText || '').trim().length > 0;
if (hasSearch) {
// When user is searching, list is from API; don't prepend to avoid inconsistency
if (typeof showBanEventToast === 'function') {
showBanEventToast(event);
}
refreshDashboardData();
return;
}
var exists = false;
if (event.id) {
exists = latestBanEvents.some(function(e) {
return e.id === event.id;
});
exists = latestBanEvents.some(function(e) { return e.id === event.id; });
} else {
// If no ID, check by IP, jail, eventType, and occurredAt timestamp
exists = latestBanEvents.some(function(e) {
return e.ip === event.ip &&
e.jail === event.jail &&
e.eventType === event.eventType &&
e.occurredAt === event.occurredAt;
return e.ip === event.ip && e.jail === event.jail && e.eventType === event.eventType && e.occurredAt === event.occurredAt;
});
}
if (!exists) {
// Ensure eventType is set (default to 'ban' for backward compatibility)
if (!event.eventType) {
event.eventType = 'ban';
}
console.log('Adding new event from WebSocket:', event);
// Prepend to the beginning of the array
latestBanEvents.unshift(event);
// Keep only the last 200 events
if (latestBanEvents.length > 200) {
latestBanEvents = latestBanEvents.slice(0, 200);
if (latestBanEvents.length > BAN_EVENTS_MAX_LOADED) {
latestBanEvents = latestBanEvents.slice(0, BAN_EVENTS_MAX_LOADED);
}
// Show toast notification first
if (typeof showBanEventToast === 'function') {
showBanEventToast(event);
}
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData();
} else {
console.log('Skipping duplicate event:', event);
@@ -238,60 +270,38 @@ function getBanEventCountries() {
});
}
function getFilteredBanEvents() {
var text = (banEventsFilterText || '').toLowerCase();
var countryFilter = (banEventsFilterCountry || '').toLowerCase();
return latestBanEvents.filter(function(event) {
var matchesCountry = !countryFilter || countryFilter === 'all';
if (!matchesCountry) {
var eventCountryValue = (event.country || '').toLowerCase();
if (!eventCountryValue) {
eventCountryValue = '__unknown__';
}
matchesCountry = eventCountryValue === countryFilter;
}
if (!text) {
return matchesCountry;
}
var haystack = [
event.ip,
event.jail,
event.serverName,
event.hostname,
event.country
].map(function(value) {
return (value || '').toLowerCase();
});
var matchesText = haystack.some(function(value) {
return value.indexOf(text) !== -1;
});
return matchesCountry && matchesText;
});
}
function scheduleLogOverviewRender() {
// Debounced refetch of ban events from API (search/country) and re-render only the log overview (no full dashboard = no scroll jump)
function scheduleBanEventsRefetch() {
if (banEventsFilterDebounce) {
clearTimeout(banEventsFilterDebounce);
}
banEventsFilterDebounce = setTimeout(function() {
renderLogOverviewSection();
banEventsFilterDebounce = null;
}, 100);
fetchBanEventsData().then(function() {
renderLogOverviewSection();
});
}, 300);
}
function updateBanEventsSearch(value) {
banEventsFilterText = value || '';
scheduleLogOverviewRender();
scheduleBanEventsRefetch();
}
function updateBanEventsCountry(value) {
banEventsFilterCountry = value || 'all';
scheduleLogOverviewRender();
fetchBanEventsData().then(function() {
renderLogOverviewSection();
});
}
function loadMoreBanEvents() {
if (latestBanEvents.length >= BAN_EVENTS_MAX_LOADED || !banEventsHasMore) {
return;
}
fetchBanEventsData({ append: true }).then(function() {
renderLogOverviewSection();
});
}
function getRecurringIPMap() {
@@ -839,13 +849,11 @@ function renderLogOverviewContent() {
html += '<h4 class="text-md font-semibold text-gray-800 mb-3" data-i18n="logs.overview.recent_events_title">Recent stored events</h4>';
if (!latestBanEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_empty">No stored events found.</p>';
} else {
// Always show search bar and table (like Search Banned IPs) so user can clear search when no matches
var countries = getBanEventCountries();
var filteredEvents = getFilteredBanEvents();
var recurringMap = getRecurringIPMap();
var searchQuery = (banEventsFilterText || '').trim();
var totalLabel = banEventsTotal != null ? banEventsTotal : '—';
html += ''
+ '<div class="flex flex-col sm:flex-row gap-3 mb-4">'
@@ -870,11 +878,8 @@ function renderLogOverviewContent() {
+ ' </div>'
+ '</div>';
html += '<p class="text-xs text-gray-500 mb-3">' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '</p>';
html += '<p class="text-xs text-gray-500 mb-3">' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + latestBanEvents.length + ' / ' + totalLabel + '</p>';
if (!filteredEvents.length) {
html += '<p class="text-gray-500" data-i18n="logs.overview.recent_filtered_empty">No stored events match the current filters.</p>';
} else {
html += ''
+ '<div class="overflow-x-auto">'
+ ' <table class="min-w-full divide-y divide-gray-200 text-sm">'
@@ -889,8 +894,13 @@ function renderLogOverviewContent() {
+ ' </tr>'
+ ' </thead>'
+ ' <tbody class="bg-white divide-y divide-gray-200">';
filteredEvents.forEach(function(event) {
var index = latestBanEvents.indexOf(event);
if (!latestBanEvents.length) {
var hasFilter = (banEventsFilterText || '').trim().length > 0 || ((banEventsFilterCountry || 'all').trim() !== 'all');
var emptyMsgKey = hasFilter ? 'logs.overview.recent_filtered_empty' : 'logs.overview.recent_empty';
html += '<tr><td colspan="6" class="px-2 py-4 text-center text-gray-500" data-i18n="' + emptyMsgKey + '"></td></tr>';
} else {
latestBanEvents.forEach(function(event, index) {
var hasWhois = event.whois && event.whois.trim().length > 0;
var hasLogs = event.logs && event.logs.trim().length > 0;
var serverValue = event.serverName || event.serverId || '';
@@ -924,8 +934,14 @@ function renderLogOverviewContent() {
+ ' </td>'
+ ' </tr>';
});
html += ' </tbody></table></div>';
}
html += ' </tbody></table></div>';
if (banEventsHasMore && latestBanEvents.length > 0 && latestBanEvents.length < BAN_EVENTS_MAX_LOADED) {
var loadMoreLabel = typeof t === 'function' ? t('logs.overview.load_more', 'Load more') : 'Load more';
html += '<div class="mt-3 text-center">'
+ '<button type="button" class="px-4 py-2 text-sm font-medium text-blue-600 bg-blue-50 border border-blue-200 rounded-md hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500" onclick="loadMoreBanEvents()">' + loadMoreLabel + '</button>'
+ '</div>';
}
html += '</div>';

View File

@@ -9,6 +9,8 @@ var latestSummary = null;
var latestSummaryError = null;
var latestBanStats = {};
var latestBanEvents = [];
var banEventsTotal = null;
var banEventsHasMore = false;
var latestBanInsights = {
totals: { overall: 0, today: 0, week: 0 },
countries: [],
@@ -18,6 +20,8 @@ var latestServerInsights = null;
var banEventsFilterText = '';
var banEventsFilterCountry = 'all';
var banEventsFilterDebounce = null;
var BAN_EVENTS_PAGE_SIZE = 50;
var BAN_EVENTS_MAX_LOADED = 1000;
var translations = {};
var sshKeysCache = null;
var openModalCount = 0;