diff --git a/README.md b/README.md index 9dd3720..bafd9e5 100644 --- a/README.md +++ b/README.md @@ -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 /api/ban or /api/unban β”‚ β”‚ +β”‚ β”‚ Header: X-Callback-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:** diff --git a/docker-compose-allinone.example.yml b/docker-compose-allinone.example.yml index 8d8eed5..3e9783f 100644 --- a/docker-compose-allinone.example.yml +++ b/docker-compose-allinone.example.yml @@ -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) diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 3c879fe..7bd7924 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -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) diff --git a/internal/locales/de.json b/internal/locales/de.json index a196ee0..bcf197b 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -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", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index a3e0cfe..ea595b6 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -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", diff --git a/internal/locales/en.json b/internal/locales/en.json index 0471ad7..86399aa 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -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", diff --git a/internal/locales/es.json b/internal/locales/es.json index ca439fb..d1ff7c4 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -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", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index f40c319..37af7e7 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -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", diff --git a/internal/locales/it.json b/internal/locales/it.json index 8bbf015..6217adc 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -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", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 483cdc1..e0fa949 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -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, diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 17b10c4..8dda030 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -365,13 +365,25 @@ 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 { - limit = parsed + 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 + } } } @@ -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. diff --git a/pkg/web/static/js/dashboard.js b/pkg/web/static/js/dashboard.js index 71d7097..94e76f4 100644 --- a/pkg/web/static/js/dashboard.js +++ b/pkg/web/static/js/dashboard.js @@ -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); - latestBanEvents = latestBanEvents || []; + 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,58 +849,58 @@ function renderLogOverviewContent() { html += '

Recent stored events

'; + // Always show search bar and table (like Search Banned IPs) so user can clear search when no matches + var countries = getBanEventCountries(); + var recurringMap = getRecurringIPMap(); + var searchQuery = (banEventsFilterText || '').trim(); + var totalLabel = banEventsTotal != null ? banEventsTotal : 'β€”'; + + html += '' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + + html += '

' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + latestBanEvents.length + ' / ' + totalLabel + '

'; + + html += '' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; + if (!latestBanEvents.length) { - html += '

No stored events found.

'; + var hasFilter = (banEventsFilterText || '').trim().length > 0 || ((banEventsFilterCountry || 'all').trim() !== 'all'); + var emptyMsgKey = hasFilter ? 'logs.overview.recent_filtered_empty' : 'logs.overview.recent_empty'; + html += ''; } else { - var countries = getBanEventCountries(); - var filteredEvents = getFilteredBanEvents(); - var recurringMap = getRecurringIPMap(); - var searchQuery = (banEventsFilterText || '').trim(); - - html += '' - + '
' - + '
' - + ' ' - + ' ' - + '
' - + '
' - + ' ' - + ' ' - + '
' - + '
'; - - html += '

' + t('logs.overview.recent_count_label', 'Events shown') + ': ' + filteredEvents.length + ' / ' + latestBanEvents.length + '

'; - - if (!filteredEvents.length) { - html += '

No stored events match the current filters.

'; - } else { - html += '' - + '
' - + '
TimeServerIPActions
' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' '; - filteredEvents.forEach(function(event) { - var index = latestBanEvents.indexOf(event); + 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() { + ' ' + ' '; }); - html += '
TimeServerIPActions
'; - } + } + + html += ' '; + 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 += '
' + + '' + + '
'; } html += ''; diff --git a/pkg/web/static/js/globals.js b/pkg/web/static/js/globals.js index 940b2c0..c8520ce 100644 --- a/pkg/web/static/js/globals.js +++ b/pkg/web/static/js/globals.js @@ -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;