commit dd7f8dd1a2debb34421699d8b0e0366dc84f9eed Author: gitea Date: Fri Feb 20 14:40:59 2026 +0000 Initial release: F2B Control Center v1.0 Dockerized Fail2Ban + dashboard for Nginx Proxy Manager. - Single-container image (fail2ban + Node.js + supervisord) - Pre-built NPM filters: badbot, http-errors, npm-probe, manual-bans - Web dashboard with live ban feed, log scanner, AbuseIPDB integration - Configurable via environment variables and .env file - Persistent volumes for config and ban history - Webhook support for ban event notifications - README, .gitignore, MIT license Co-Authored-By: Claude Sonnet 4.6 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..af880be --- /dev/null +++ b/.env.example @@ -0,0 +1,47 @@ +# ── F2B Control Center — environment configuration ─────────────────────────── +# Copy this file to .env and fill in your values. +# Only NPM_LOG_DIR is strictly required to get started. +# ───────────────────────────────────────────────────────────────────────────── + +# ── Required ────────────────────────────────────────────────────────────────── + +# Path to your Nginx Proxy Manager log directory on the host. +# This directory will be mounted read-only inside the container. +# Common paths: +# /opt/npm/data/logs +# /home/docker/NGINX/data/logs +# /docker/nginx-proxy-manager/data/logs +NPM_LOG_DIR=/opt/npm/data/logs + +# ── Dashboard ───────────────────────────────────────────────────────────────── + +# Port the dashboard listens on (host port when using network_mode: host) +DASHBOARD_PORT=4000 + +# ── AbuseIPDB integration (optional but recommended) ───────────────────────── +# Enables IP reputation lookups and auto-ban by abuse score. +# Free API keys available at https://www.abuseipdb.com/ +ABUSEIPDB_API_KEY= + +# Minimum AbuseIPDB confidence score (0–100) to trigger auto-ban +AUTOBAN_THRESHOLD=75 + +# ── Log scanning ────────────────────────────────────────────────────────────── + +# Default lookback window when scanning nginx logs (days) +DEFAULT_LOOKBACK_DAYS=3 + +# Comma-separated CIDR subnets to skip during log scanning and banning. +# Include your LAN, Docker bridge, and any trusted networks. +SUBNETS_TO_IGNORE=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 + +# ── Optional integrations ───────────────────────────────────────────────────── + +# Webhook URL: receives a POST request on every manual ban action. +# Payload: { "action": "ban", "ip": "1.2.3.4", "jail": "manual-bans", "ts": "..." } +# Examples: Discord webhook, n8n, Slack, custom endpoint +WEBHOOK_URL= + +# Path to a custom script to run after whitelist changes (e.g. Cloudflare sync). +# The script is executed as a background fire-and-forget process. +# CF_SYNC=/usr/local/bin/cloudflare-whitelist-sync.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77db778 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Dependencies +dashboard/node_modules/ + +# Environment (contains secrets) +.env + +# Runtime data +data/ +*.json +!dashboard/package.json +!dashboard/package-lock.json + +# Docker build cache +.dockerignore + +# Editor +.vscode/ +.idea/ +*.swp +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1206059 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# ── F2B Control Center ──────────────────────────────────────────────────────── +# Single-container image: Fail2Ban + Node.js dashboard + supervisord +# +# Build: docker build -t f2b-control-center . +# Run: docker-compose up -d +# ───────────────────────────────────────────────────────────────────────────── + +FROM node:18-slim + +LABEL org.opencontainers.image.title="F2B Control Center" \ + org.opencontainers.image.description="Fail2Ban + dashboard for Nginx Proxy Manager" \ + org.opencontainers.image.licenses="MIT" + +# ── System dependencies ─────────────────────────────────────────────────────── +# fail2ban – the core banning daemon +# supervisor – process manager (runs fail2ban + node in one container) +# iptables – default ban action backend (requires NET_ADMIN + NET_RAW) +# ipset – optional; used by some fail2ban actions for performance +# curl – used by the webhook action and healthcheck +RUN apt-get update && apt-get install -y --no-install-recommends \ + fail2ban \ + supervisor \ + iptables \ + ipset \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# ── Dashboard dependencies ──────────────────────────────────────────────────── +WORKDIR /app +COPY dashboard/package*.json ./ +RUN npm ci --omit=dev --prefer-offline + +# ── Dashboard source ────────────────────────────────────────────────────────── +COPY dashboard/server.js ./ +COPY dashboard/public ./public/ + +# ── Default fail2ban config (copied to /etc/fail2ban on first run) ──────────── +COPY fail2ban/ /etc/f2b-defaults/ + +# ── Process management ──────────────────────────────────────────────────────── +COPY supervisor.conf /etc/supervisor/conf.d/f2b-control-center.conf + +# ── Startup and health ──────────────────────────────────────────────────────── +COPY entrypoint.sh /entrypoint.sh +COPY healthcheck.sh /healthcheck.sh +RUN chmod +x /entrypoint.sh /healthcheck.sh + +# ── Runtime directories ─────────────────────────────────────────────────────── +RUN mkdir -p /data /nginx-logs /var/log /var/run/fail2ban + +# ── Persistent volumes ──────────────────────────────────────────────────────── +# /data – ban-history.json and other app state +# /nginx-logs – mount your NPM log directory here (read-only) +# /etc/fail2ban – persists user-edited jail config across image updates +VOLUME ["/data", "/nginx-logs", "/etc/fail2ban"] + +EXPOSE 4000 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=25s --retries=3 \ + CMD /healthcheck.sh + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..24a8a5e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 F2B Control Center Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..74994e8 --- /dev/null +++ b/README.md @@ -0,0 +1,321 @@ +# F2B Control Center + +A batteries-included Fail2Ban distribution for [Nginx Proxy Manager](https://nginxproxymanager.com/) environments, packaged as a single Docker container. + +Combines Fail2Ban (active blocking), a web dashboard (monitoring + management), and pre-built detection rules tailored for NPM reverse proxy logs — all with a minimal setup process. + +--- + +## Features + +- **Live ban feed** — real-time stream of Fail2Ban ban events +- **Jail management** — view, ban, unban, and whitelist IPs across all jails +- **Nginx log scanner** — identify suspicious IPs from access logs before they trigger automatic rules +- **AbuseIPDB integration** — look up IP reputation scores; auto-ban by threshold +- **Ban history** — persistent record of all banned IPs with ban counts and timestamps +- **Pre-built filters** for NPM logs: bad bots, HTTP error spamming, exploit probing +- **Webhook support** — POST ban events to any HTTP endpoint (Discord, Slack, n8n) +- **Cloudflare sync hook** — optional script to sync whitelist with CF firewall rules + +--- + +## Requirements + +- Docker Engine 20.10+ +- Docker Compose v2+ +- Nginx Proxy Manager running and producing access logs +- Linux host with iptables support (for active banning) + +--- + +## Quick Start + +**1. Clone the repository** + +```bash +git clone http://YOUR_GITEA_HOST/YOUR_USER/f2b-control-center.git +cd f2b-control-center +``` + +**2. Configure your environment** + +```bash +cp .env.example .env +``` + +Edit `.env` and set at minimum: + +```env +# Path to your NPM log directory on the host +NPM_LOG_DIR=/home/docker/NGINX/data/logs + +# AbuseIPDB API key (optional but strongly recommended) +ABUSEIPDB_API_KEY=your_key_here +``` + +**3. Start the container** + +```bash +docker-compose up -d +``` + +**4. Open the dashboard** + +Navigate to `http://YOUR_HOST:4000` in your browser. + +On first start, the container will install the default Fail2Ban configuration into a persistent Docker volume. Fail2Ban will begin monitoring your NPM logs immediately. + +--- + +## Configuration Reference + +All settings are controlled via `.env` and the `docker-compose.yml` environment section. + +| Variable | Default | Description | +|---|---|---| +| `NPM_LOG_DIR` | `/opt/npm/data/logs` | Host path to NPM access log directory | +| `DASHBOARD_PORT` | `4000` | Port the dashboard listens on | +| `ABUSEIPDB_API_KEY` | _(empty)_ | AbuseIPDB API key — enables threat scoring | +| `AUTOBAN_THRESHOLD` | `75` | Abuse score threshold for auto-ban (0–100) | +| `DEFAULT_LOOKBACK_DAYS` | `3` | Default scan window for nginx logs | +| `SUBNETS_TO_IGNORE` | `10.0.0.0/8,...` | Comma-separated CIDRs to ignore in scans | +| `WEBHOOK_URL` | _(empty)_ | HTTP endpoint to notify on manual bans | + +--- + +## Network Mode + +The container runs with `network_mode: host` by default. + +This is intentional. For Fail2Ban's iptables rules to block traffic arriving at the host before it reaches NPM, the container must share the host's network namespace. Without this, iptables rules created inside the container operate in an isolated network namespace and do not affect inbound traffic. + +**If you only want the dashboard** (monitoring without active iptables blocking), you can switch to bridge mode: + +```yaml +# In docker-compose.yml — comment out network_mode and uncomment: +ports: + - "${DASHBOARD_PORT:-4000}:4000" +cap_add: + - NET_ADMIN + - NET_RAW +``` + +In bridge mode, bans will be created in the container's namespace but will not block host-level traffic. + +--- + +## Fail2Ban Configuration + +### Jails + +The default jails installed by the container are: + +| Jail | Filter | Purpose | Default: bantime / findtime / maxretry | +|---|---|---|---| +| `badbot` | `badbot.conf` | Blocks known scanner/exploit UAs | 24h / 10m / 3 | +| `http-errors` | `http-errors.conf` | Blocks high 4xx/5xx error rates | 1h / 5m / 15 | +| `npm-probe` | `npm-probe.conf` | Blocks exploit path probes | 48h / 30m / 3 | +| `manual-bans` | _(none)_ | Permanent manual bans via dashboard | permanent | + +### Customising jails + +The Fail2Ban config is persisted in the `f2b-config` Docker volume. To edit it: + +```bash +docker exec -it f2b-control-center bash +vi /etc/fail2ban/jail.local +fail2ban-client reload +``` + +Or mount a local directory instead of the volume for easier editing: + +```yaml +# In docker-compose.yml: +volumes: + - ./my-fail2ban-config:/etc/fail2ban +``` + +### Log format + +The default filters expect NPM access logs that include the real client IP in a `[Client X.X.X.X]` field at the end of each line. This format is produced when NPM is behind Cloudflare or another upstream proxy that forwards the real IP via `X-Real-IP` or `X-Forwarded-For`. + +**If your NPM logs have the client IP at the start of each line** (standard nginx combined format), edit the `failregex` lines in each filter file: + +```bash +# Example: switch http-errors filter to standard nginx format +docker exec -it f2b-control-center bash +vi /etc/fail2ban/filter.d/http-errors.conf +# Comment out Primary, uncomment Alternative lines +fail2ban-client reload +``` + +Test a filter against your actual logs: + +```bash +docker exec f2b-control-center \ + fail2ban-regex /nginx-logs/proxy-host-1_access.log \ + /etc/fail2ban/filter.d/http-errors.conf +``` + +--- + +## Optional Integrations + +### AbuseIPDB + +Set `ABUSEIPDB_API_KEY` in `.env`. Free keys are available at [abuseipdb.com](https://www.abuseipdb.com/). + +Once configured: +- All ban cards show an abuse confidence score +- Use **[THREAT]** on any card to look up a specific IP +- Use **[FORCE ABUSE]** to background-check all currently banned IPs +- Use **AUTO-BAN** to scan logs and automatically ban IPs above the score threshold + +### Webhook + +Set `WEBHOOK_URL` in `.env` to receive a POST request on every manual ban action: + +```json +{ + "action": "ban", + "ip": "1.2.3.4", + "jail": "manual-bans", + "ts": "2026-02-20T14:00:00.000Z" +} +``` + +For Fail2Ban-triggered bans (from filters), use the `webhook` action in `jail.local` — see `fail2ban/action.d/webhook.conf` for setup instructions. + +### Cloudflare whitelist sync + +Set `CF_SYNC` in `.env` to the path of a script inside the container. The script will be executed (fire-and-forget) whenever a whitelist change is made: + +```env +CF_SYNC=/usr/local/bin/cloudflare-whitelist-sync.sh +``` + +Mount the script into the container: + +```yaml +volumes: + - ./cloudflare-whitelist-sync.sh:/usr/local/bin/cloudflare-whitelist-sync.sh:ro +``` + +--- + +## Managing the Container + +```bash +# View logs +docker-compose logs -f + +# Restart +docker-compose restart + +# Update (rebuild image after pulling changes) +docker-compose pull +docker-compose build --no-cache +docker-compose up -d + +# Reload fail2ban config without restart +docker exec f2b-control-center fail2ban-client reload + +# View current ban status +docker exec f2b-control-center fail2ban-client status + +# Manually ban an IP +docker exec f2b-control-center fail2ban-client set manual-bans banip 1.2.3.4 + +# Manually unban an IP +docker exec f2b-control-center fail2ban-client set manual-bans unbanip 1.2.3.4 +``` + +--- + +## Persistent Data + +| Volume | Mount | Contents | +|---|---|---| +| `f2b-data` | `/data` | `ban-history.json` — ban tracking database | +| `f2b-config` | `/etc/fail2ban` | Jail config, filter files (survives image updates) | +| _(bind mount)_ | `/nginx-logs` | Your NPM log directory (read-only) | + +To back up or inspect volumes: + +```bash +docker run --rm -v f2b-data:/data busybox tar czf - /data > f2b-data-backup.tar.gz +``` + +--- + +## Troubleshooting + +**Dashboard shows no bans / "Error: ..."** + +Fail2Ban may still be starting up. Check: +```bash +docker-compose logs f2b-control-center | grep fail2ban +docker exec f2b-control-center fail2ban-client ping +``` + +**Bans are created but IPs are not actually blocked** + +Ensure `network_mode: host` is set in `docker-compose.yml`. In bridge mode, iptables rules are isolated to the container's network namespace and won't block host-level traffic. + +**Log scan returns no results** + +- Verify `NPM_LOG_DIR` in `.env` points to the correct directory +- Check the volume is mounted: `docker exec f2b-control-center ls /nginx-logs/` +- Confirm log files match the pattern `proxy-host-*_access.log` +- Check that logs contain `[Client X.X.X.X]` entries (see Log Format section) + +**"iptables: command not found" or permission errors** + +The container requires `network_mode: host` (or `cap_add: [NET_ADMIN, NET_RAW]` for bridge mode). Verify your `docker-compose.yml` configuration. + +**Fail2Ban filter not matching** + +Test the filter directly: +```bash +docker exec f2b-control-center \ + fail2ban-regex /nginx-logs/proxy-host-1_access.log \ + /etc/fail2ban/filter.d/http-errors.conf --print-all-matched +``` + +--- + +## Project Structure + +``` +f2b-control-center/ +├── Dockerfile # Single-container image (fail2ban + node + supervisor) +├── docker-compose.yml # Service definition +├── .env.example # Configuration template +├── entrypoint.sh # First-run init + supervisor start +├── healthcheck.sh # Docker health check +├── supervisor.conf # Process management (fail2ban + dashboard) +├── LICENSE +├── README.md +├── dashboard/ +│ ├── package.json +│ ├── server.js # Express API + log scanning +│ └── public/ +│ ├── index.html # Dashboard UI +│ ├── style.css # Terminal-style theme +│ └── favicon.svg +└── fail2ban/ + ├── jail.local # Jail configuration (installed on first run) + ├── filter.d/ + │ ├── badbot.conf # Bad bot UA detection + │ ├── http-errors.conf # HTTP error spamming + │ ├── npm-probe.conf # Exploit path probing + │ └── manual-bans.conf # Empty filter (manual bans only) + └── action.d/ + └── webhook.conf # Optional: HTTP webhook on ban/unban +``` + +--- + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..7806b7b --- /dev/null +++ b/dashboard/package.json @@ -0,0 +1,18 @@ +{ + "name": "f2b-control-center", + "version": "1.0.0", + "description": "Fail2Ban dashboard for Nginx Proxy Manager — batteries-included security monitoring", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "dotenv": "*", + "express": "*", + "ip": "*", + "node-fetch": "^2" + } +} diff --git a/dashboard/public/favicon.svg b/dashboard/public/favicon.svg new file mode 100644 index 0000000..8b76f10 --- /dev/null +++ b/dashboard/public/favicon.svg @@ -0,0 +1,4 @@ + + + F2B + diff --git a/dashboard/public/index.html b/dashboard/public/index.html new file mode 100644 index 0000000..a43a3a5 --- /dev/null +++ b/dashboard/public/index.html @@ -0,0 +1,468 @@ + + + + + + F2B + + + + +
+ + + + + +
+ + +
+
+
// LIVE BANS
+
+ +
+
+
↻ 2s
+
+
+ + +
+ +
+ + +
+
+ + + + + + + +
+
+ + +
+ +
+ SCAN + + d + + hits+ + + +
+ +
+ AUTO-BAN + + thr + +
+ +
+ + + + + +
+ + + +
+ +
 
+
+ +
+ +
+
+ +
_
+
F2B  |  fail2ban control center  |  :4000
+
+ + + + diff --git a/dashboard/public/style.css b/dashboard/public/style.css new file mode 100644 index 0000000..4c6fa48 --- /dev/null +++ b/dashboard/public/style.css @@ -0,0 +1,555 @@ +@import url('https://fonts.googleapis.com/css2?family=VT323&family=Share+Tech+Mono&display=swap'); + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --green: #00ff41; + --green2: #00cc33; + --dim: #005514; + --cyan: #00ffff; + --amber: #ffaa00; + --red: #ff3333; + --bg: #050a05; + --bg2: #0a0f0a; + --border: #00661a; +} + +html { font-size: 16px; } + +body { + background: var(--bg); + color: var(--green); + font-family: 'Share Tech Mono', 'Courier New', monospace; + min-height: 100vh; + overflow-x: hidden; + position: relative; +} + +/* CRT scanlines */ +body::before { + content: ''; + position: fixed; inset: 0; + background: repeating-linear-gradient(0deg, rgba(0,0,0,.10) 0px, rgba(0,0,0,.10) 1px, transparent 1px, transparent 3px); + pointer-events: none; z-index: 9999; +} +/* CRT vignette */ +body::after { + content: ''; + position: fixed; inset: 0; + background: radial-gradient(ellipse at center, transparent 60%, rgba(0,0,0,.65) 100%); + pointer-events: none; z-index: 9998; +} + +.screen { width: 100%; padding: 1.5rem 2rem 4rem; box-sizing: border-box; } + +/* .ascii-banner legacy — replaced by .ascii-art inside .banner-wrap */ +.ascii-banner { + font-family: 'VT323', monospace; + color: var(--green); + font-size: 1.05rem; + line-height: 1.2; + white-space: pre; + text-shadow: 0 0 8px var(--green); + margin-bottom: .4rem; + text-align: center; +} + +.tagline { color: var(--dim); font-size: .82rem; margin-bottom: .2rem; } + +/* ── Banner ──────────────────────────────────────────────────── */ +.banner-wrap { text-align: left; margin-bottom: 1rem; } + +.banner-hr { + border: none; + border-top: 1px solid var(--border); + margin: .3rem 0; +} + +.ascii-art { + font-family: 'VT323', monospace; + color: var(--green); + font-size: 1rem; + line-height: 1.2; + white-space: pre; + text-shadow: 0 0 8px var(--green); + display: inline-block; + text-align: left; + margin: .3rem 0 .1rem; + background: transparent; + border: none; + padding: 0; +} + +.banner-sub { + font-family: 'VT323', monospace; + font-size: clamp(1rem, 4vw, 2.2rem); + color: var(--amber); + text-shadow: 0 0 12px var(--amber); + letter-spacing: clamp(.05em, 1vw, .55em); + margin: .4rem 0 .2rem; +} + +.banner-sub::after { + content: '█'; + color: var(--green); + text-shadow: 0 0 8px var(--green); + margin-left: .2em; + animation: blink 1s step-end infinite; +} + +.host-line { + color: var(--cyan); + font-size: .85rem; + text-shadow: 0 0 6px var(--cyan); + margin-bottom: 1.5rem; +} + +.box { + border: 1px solid var(--border); + background: var(--bg2); + margin-bottom: 1.5rem; + padding: 1rem 1.2rem; +} + +.box-title { + font-family: 'VT323', monospace; + font-size: 1.4rem; + color: var(--cyan); + text-shadow: 0 0 8px var(--cyan); + border-bottom: 1px solid var(--border); + padding-bottom: .4rem; + margin-bottom: .9rem; + letter-spacing: 1px; +} + +h2.section { + font-family: 'VT323', monospace; + font-size: 1.5rem; + color: var(--amber); + text-shadow: 0 0 8px var(--amber); + margin: 2rem 0 .8rem; + letter-spacing: 1px; +} + +p { line-height: 1.7; margin-bottom: .7rem; font-size: .86rem; } +p:last-child { margin-bottom: 0; } + +pre { + background: #000d03; + border: 1px solid var(--border); + color: var(--green2); + padding: .75rem 1rem; + overflow-x: auto; + font-size: .78rem; + line-height: 1.65; + margin: .5rem 0 1rem; + white-space: pre-wrap; + word-break: break-all; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.card { + border: 1px solid var(--border); + background: var(--bg2); + padding: .85rem 1rem; + display: flex; + flex-direction: column; +} + +.card-meta { flex: 1; } + + +.card.prev-banned { border-color: var(--amber); } +.card.whitelisted { border-color: var(--green); } + +.card-ip { + font-family: 'VT323', monospace; + font-size: 1.25rem; + color: var(--cyan); + text-shadow: 0 0 5px var(--cyan); + margin-bottom: .3rem; + word-break: break-all; + overflow-wrap: break-word; +} + +.card-ip::before { content: '▸ '; color: var(--green); } + +.card-meta { font-size: .78rem; color: #777; line-height: 1.6; } +.card-meta span { display: block; } + +.score-badge { + display: inline-block; + font-family: 'VT323', monospace; + font-size: 1.1rem; + padding: .05rem .5rem; + border: 1px solid; + float: right; + margin-top: -.1rem; +} + +.card-actions { + display: flex; + flex-wrap: wrap; + gap: .35rem; + margin-top: .7rem; +} + +.card-actions button, .card-actions a { + flex: 1 1 45%; + font-size: .72rem; + text-align: center; + text-decoration: none; +} + +.hi { color: var(--amber); } +.good { color: var(--green); } +.warn { color: var(--red); } +.info { color: var(--cyan); } +.muted { color: #555; } + +button, a.btn { + display: inline-block; + background: #001a05; + border: 1px solid var(--border); + color: var(--green); + font-family: 'Share Tech Mono', monospace; + font-size: .82rem; + padding: .28rem .7rem; + cursor: pointer; + transition: background .15s, color .15s; + text-decoration: none; +} + +button:hover:not(:disabled), a.btn:hover { background: var(--dim); color: #fff; } +button:disabled { color: var(--dim); cursor: not-allowed; border-color: #002608; } +button.btn-red, a.btn-red { border-color: var(--red); color: var(--red); } +button.btn-red:hover:not(:disabled), a.btn-red:hover { background: #330000; color: #fff; } +button.btn-amber, a.btn-amber { border-color: var(--amber); color: var(--amber); } +button.btn-amber:hover { background: #1a1000; color: #fff; } + +.blink { animation: blink 1s step-end infinite; } +@keyframes blink { 50% { opacity: 0; } } + +.prompt { color: var(--dim); font-size: .78rem; margin-top: 2rem; } +.prompt::before { content: 'root@mm-dc:~# '; color: var(--green2); } + +footer { + border-top: 1px solid var(--border); + color: #333; + font-size: .72rem; + padding-top: .7rem; + margin-top: 3rem; + text-align: center; +} + +/* ── Live Feed ─────────────────────────────────────────────── */ +.feed-status { + font-size: .78rem; + color: var(--amber); + margin-bottom: .5rem; +} +.feed-status.ok { color: var(--green); } +.feed-status.err { color: var(--red); } + +.feed-box { + height: 200px; + overflow-y: auto; + background: #000d03; + border: 1px solid var(--border); + padding: .5rem .75rem; + font-size: .75rem; + line-height: 1.6; +} + +.feed-box::-webkit-scrollbar { width: 6px; } +.feed-box::-webkit-scrollbar-track { background: var(--bg); } +.feed-box::-webkit-scrollbar-thumb { background: var(--dim); } + +.feed-line { color: var(--dim); display: block; } +.feed-line.ban { color: var(--red); } + +/* ── Feed entries (ban-only, IP + jail display) ─────────────── */ +.feed-entry { + display: grid; + grid-template-columns: 3.5rem 1fr auto; + gap: .4rem; + align-items: baseline; + padding: .15rem 0; + border-bottom: 1px solid #001a05; +} +.feed-time { color: var(--dim); font-size: .72rem; } +.feed-ip { color: var(--green); font-size: .82rem; } +.feed-jail { color: var(--amber); font-size: .72rem; text-align: right; } + +/* ── Two-column layout [feed | unified main] ─────────────────── */ +.main-col { + display: grid; + grid-template-columns: 280px 1fr; + gap: 1.2rem; + align-items: start; + margin-top: .8rem; +} + +.col-main { min-width: 0; } + +.col-feed { + position: sticky; + top: 1rem; + height: calc(100vh - 3rem); +} + +.feed-col-box { + height: 100%; + display: flex; + flex-direction: column; + margin-bottom: 0; +} + +.col-feed .feed-box { + flex: 1; + min-height: 0; + height: auto; +} + +/* ── Feed column title ───────────────────────────────────────── */ +.feed-title { + font-family: 'VT323', monospace; + font-size: 1.1rem; + color: var(--cyan); + text-shadow: 0 0 6px var(--cyan); + text-align: center; + letter-spacing: 2px; + padding: .4rem 0 .3rem; + border-bottom: 1px solid var(--border); +} + +/* ── Feed status dot ─────────────────────────────────────────── */ +.feed-indicator { + text-align: center; + padding: .5rem 0 .3rem; +} + +.status-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--dim); + box-shadow: 0 0 4px var(--dim); + transition: background .3s, box-shadow .3s; +} + +.status-dot.ok { background: var(--green); box-shadow: 0 0 8px var(--green); } +.status-dot.err { background: var(--red); box-shadow: 0 0 8px var(--red); } + +.feed-rate { + text-align: center; + color: var(--dim); + font-size: .65rem; + padding: .3rem 0 .2rem; +} + +/* ── Unified control bar ─────────────────────────────────────── */ +.control-bar { + background: var(--bg2); + border: 1px solid var(--border); + padding: .6rem .9rem; + margin-bottom: 1rem; +} + +.control-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: .5rem; + margin-bottom: .5rem; +} + +.control-row-tools { + gap: .8rem; + border-top: 1px solid var(--border); + padding-top: .5rem; + margin-top: .1rem; +} + +.tool-group { + display: flex; + align-items: center; + gap: .35rem; + flex-wrap: wrap; +} + +.tool-group-right { margin-left: auto; } + +.tool-label { + font-size: .68rem; + letter-spacing: 1px; + border-right: 1px solid var(--border); + padding-right: .4rem; +} + +.tool-group input[type="number"] { + width: 46px; + padding: .25rem .4rem; + background: #001205; + border: 1px solid var(--border); + color: var(--green); + font-family: 'Share Tech Mono', monospace; + font-size: .82rem; + text-align: center; +} + +.tool-group input[type="number"]:focus { outline: none; border-color: var(--green); } + +.cb-label { + display: flex; + align-items: center; + gap: .25rem; + font-size: .75rem; + color: var(--dim); + cursor: pointer; +} + +.cb-label input[type="checkbox"] { cursor: pointer; accent-color: var(--green); } + +.summary-bar { + font-size: .75rem; + color: var(--dim); + margin-top: .35rem; + border-top: 1px solid var(--border); + padding-top: .3rem; +} + +/* ── Scan filter button ──────────────────────────────────────── */ +button[data-jail="scan"] { + border-color: var(--amber); + color: var(--amber); +} +button[data-jail="scan"].active { + background: #1a1000; + border-color: var(--amber); + color: var(--amber); +} + +/* ── Scan card differentiation ───────────────────────────────── */ +.scan-card { + background: #0d0d05; + border-left: 3px solid var(--amber) !important; +} + +.scan-badge { + display: inline-block; + font-size: .6rem; + color: var(--amber); + border: 1px solid var(--amber); + padding: .05rem .3rem; + margin-left: .4rem; + vertical-align: middle; + font-family: 'Share Tech Mono', monospace; +} + +/* ── Card grid: fixed 4 columns ──────────────────────────────── */ +.col-main .card-grid { + grid-template-columns: repeat(4, 1fr); +} + +@media (max-width: 900px) { + .main-col { grid-template-columns: 1fr; } + .col-feed { position: static; height: auto; } + .feed-col-box { height: auto; } + .col-feed .feed-box { height: 220px; flex: none; } + .col-main .card-grid { grid-template-columns: repeat(2, 1fr); } + + .filter-bar { flex-wrap: wrap; } + .filter-bar button { flex: 1 1 auto; min-width: 4rem; } + + .control-row-tools { flex-direction: column; align-items: flex-start; } + .tool-group-right { margin-left: 0; width: 100%; flex-wrap: wrap; } + .tool-group-right input[type="text"] { flex: 1 1 100%; } +} + +/* ── Filter bar ────────────────────────────────────────────── */ +.filter-bar { + display: flex; + flex: 1; + gap: .4rem; +} + +.filter-bar button { + flex: 1; + font-size: .75rem; + padding: .2rem .4rem; + text-align: center; +} + +.filter-bar button.active { + background: var(--dim); + border-color: var(--green); + color: var(--green); +} + +/* ── Action bar ────────────────────────────────────────────── */ +.action-bar { + display: flex; + flex-wrap: wrap; + gap: .5rem; + align-items: flex-end; + margin-bottom: 1rem; +} + +.action-bar input[type="text"], +.action-bar select { + background: #001205; + border: 1px solid var(--border); + color: var(--green); + font-family: 'Share Tech Mono', monospace; + font-size: .82rem; + padding: .28rem .5rem; + flex: 2 1 160px; +} + +.action-bar input::placeholder { color: var(--dim); } +.action-bar select { flex: 1 1 130px; cursor: pointer; } + +.action-bar input:focus, +.action-bar select:focus { outline: none; border-color: var(--green); } + +#note-wrap { flex: 0 0 100%; } +#note-wrap input { width: 100%; } + +/* ── Scan controls ─────────────────────────────────────────── */ +.scan-controls { + display: flex; + align-items: center; + gap: .6rem; + margin-bottom: .8rem; + flex-wrap: wrap; +} + +.scan-controls label { color: var(--dim); font-size: .82rem; } + +.scan-controls input[type="number"] { + width: 56px; + padding: .25rem .4rem; + background: #001205; + border: 1px solid var(--border); + color: var(--green); + font-family: 'Share Tech Mono', monospace; + font-size: .82rem; + text-align: center; +} + +.scan-controls input:focus { outline: none; border-color: var(--green); } + +/* ── Summary line ──────────────────────────────────────────── */ +.summary { + font-size: .78rem; + color: var(--dim); + margin-bottom: .8rem; +} diff --git a/dashboard/server.js b/dashboard/server.js new file mode 100644 index 0000000..2668af5 --- /dev/null +++ b/dashboard/server.js @@ -0,0 +1,473 @@ +require('dotenv').config(); +const express = require('express'); +const fs = require('fs'); +const path = require('path'); +const rl = require('readline'); +const { exec } = require('child_process'); +const ipLib = require('ip'); +const fetch = require('node-fetch'); + +const app = express(); +app.use(express.json()); +app.use(express.static(path.join(__dirname, 'public'))); + +// ── Config ──────────────────────────────────────────────────────────────────── +const FAIL2BAN_LOG = process.env.FAIL2BAN_LOG || '/var/log/fail2ban.log'; +const LOG_DIR = process.env.LOG_DIR || '/nginx-logs'; +const JAIL_LOCAL = process.env.JAIL_LOCAL || '/etc/fail2ban/jail.local'; +const CF_SYNC = process.env.CF_SYNC || '/usr/local/bin/cloudflare-whitelist-sync.sh'; +const MANUAL_JAIL = process.env.MANUAL_JAIL || 'manual-bans'; +const BAN_HIST_FILE = process.env.BAN_HIST_FILE || '/data/ban-history.json'; +const SUBNETS = (process.env.SUBNETS_TO_IGNORE || '10.0.0.0/8,172.16.0.0/12').split(',').map(s => s.trim()); +const DEFAULT_DAYS = parseInt(process.env.DEFAULT_LOOKBACK_DAYS || '3'); +const ABUSE_KEY = process.env.ABUSEIPDB_API_KEY; +const AUTOBAN_THR = parseInt(process.env.AUTOBAN_THRESHOLD || '75'); +// Optional: POST to this URL on every manual ban (Discord, Slack, n8n, etc.) +const WEBHOOK_URL = process.env.WEBHOOK_URL || ''; + +// ── In-memory state ─────────────────────────────────────────────────────────── +const abuseCache = new Map(); // ip → { score, country, ts } +const banHistory = new Map(); // ip → { firstSeen, lastSeen, banCount } +let banCache = null; // { data, ts } — 10s cache for ban list +let f2bPos = 0; +let f2bInode = 0; + +// scan state (per-scan, cleared each call) +let ipHits = new Map(); +let ipSites = new Map(); +let ipLogs = new Map(); + +// ── Utilities ───────────────────────────────────────────────────────────────── +function run(cmd) { + return new Promise((resolve, reject) => + exec(cmd, { maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => + err ? reject(new Error(stderr || err.message)) : resolve(stdout) + ) + ); +} + +// ── Jail.local helpers ──────────────────────────────────────────────────────── +function readIgnoreIP() { + try { + const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); + const match = content.match(/^ignoreip\s*=\s*(.+)$/m); + if (!match) return []; + return match[1].split(/\s+/).filter(s => s && !s.startsWith('#')); + } catch { return []; } +} + +function getWhitelistNote(ip) { + try { + const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); + const m = content.match(new RegExp(String.raw`${ip.replace('.', '\\.')}\\s*#\\s*(.+?)(?:\\n|$)`)); + return m ? m[1].trim() : null; + } catch { return null; } +} + +async function addWhitelist(ip, note) { + const lines = fs.readFileSync(JAIL_LOCAL, 'utf8').split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].trimStart().startsWith('ignoreip') && !lines[i].includes(ip)) { + lines[i] = lines[i].trimEnd() + ` ${ip}${note ? ` # ${note}` : ''}\n`; + break; + } + } + fs.writeFileSync(JAIL_LOCAL, lines.join('\n')); + // unban from all jails + const jails = await getJails(); + await Promise.all(jails.map(j => + run(`fail2ban-client set ${j} unbanip ${ip}`).catch(() => {}) + )); + if (fs.existsSync(CF_SYNC)) exec(`${CF_SYNC} &`); +} + +async function removeWhitelist(ip) { + const content = fs.readFileSync(JAIL_LOCAL, 'utf8'); + const updated = content.replace( + new RegExp(`\\s*${ip.replace(/\./g, '\\.')}(?:\\s*#[^\\n]*)?`, 'g'), '' + ); + fs.writeFileSync(JAIL_LOCAL, updated); + if (fs.existsSync(CF_SYNC)) exec(`${CF_SYNC} &`); +} + +// ── Fail2ban queries ────────────────────────────────────────────────────────── +async function getJails() { + const out = await run('fail2ban-client status'); + const m = out.match(/Jail list:\s*(.*)/); + if (!m) return []; + return m[1].split(',').map(j => j.trim()).filter(j => j && j !== 'recidive'); +} + +async function getBanEntries(jail) { + try { + const out = await run(`fail2ban-client get ${jail} banip --with-time`); + return out.trim().split('\n').filter(Boolean).map(line => { + const parts = line.split(/\s+/); + if (parts.length < 7) return null; + const ip = parts[0]; + const duration = parseInt(parts[4]); + const banTime = `${parts[1]} ${parts[2]}`; + const unbanTime = `${parts[parts.length - 2]} ${parts[parts.length - 1]}`; + return { ip, jail, duration, banTime, unbanTime }; + }).filter(Boolean); + } catch { return []; } +} + +async function buildBanList() { + if (banCache && Date.now() - banCache.ts < 10_000) return banCache.data; + + const jails = await getJails(); + const entries = await Promise.all(jails.map(jail => getBanEntries(jail))); + const flat = entries.flat(); + const whitelist = readIgnoreIP(); + + // Attach cached abuse scores + const data = flat.map(b => { + const cached = abuseCache.get(b.ip); + const score = cached?.score ?? null; + const country = cached?.country ?? null; + return { ...b, score, country }; + }); + + // Append whitelist IPs as a virtual "jail" + whitelist.forEach(ip => { + data.push({ + ip, jail: 'whitelist', duration: -1, + banTime: null, unbanTime: null, + score: abuseCache.get(ip)?.score ?? null, + country: abuseCache.get(ip)?.country ?? null, + note: getWhitelistNote(ip), + }); + }); + + banCache = { data, ts: Date.now() }; + return data; +} + +async function banIP(ip) { + await run(`fail2ban-client set ${MANUAL_JAIL} banip ${ip}`); + banCache = null; + // Optional webhook notification + if (WEBHOOK_URL) { + fetch(WEBHOOK_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ action: 'ban', ip, jail: MANUAL_JAIL, ts: new Date().toISOString() }), + }).catch(() => {}); // fire and forget — never block on this + } +} + +async function unbanIP(ip, jail) { + await run(`fail2ban-client set ${jail} unbanip ${ip}`); + banCache = null; +} + +async function unbanAll(ip) { + const jails = await getJails(); + await Promise.all(jails.map(j => run(`fail2ban-client set ${j} unbanip ${ip}`).catch(() => {}))); + banCache = null; +} + +// ── AbuseIPDB ───────────────────────────────────────────────────────────────── +async function checkAbuse(ip) { + const WEEK = 7 * 24 * 3600 * 1000; + const cached = abuseCache.get(ip); + if (cached && Date.now() - cached.ts < WEEK) return cached; + if (!ABUSE_KEY) return { score: null, country: null }; + try { + const r = await fetch( + `https://api.abuseipdb.com/api/v2/check?ipAddress=${ip}&maxAgeInDays=90`, + { headers: { Key: ABUSE_KEY, Accept: 'application/json' } } + ); + const { data } = await r.json(); + const entry = { score: data.abuseConfidenceScore, country: data.countryCode, ts: Date.now() }; + abuseCache.set(ip, entry); + banCache = null; // invalidate so next /api/bans gets fresh scores + return entry; + } catch { + return { score: null, country: null }; + } +} + +// ── Ban history (for log scanner) ───────────────────────────────────────────── +function loadBanHistory() { + try { + if (fs.existsSync(BAN_HIST_FILE)) + Object.entries(JSON.parse(fs.readFileSync(BAN_HIST_FILE, 'utf8'))) + .forEach(([k, v]) => banHistory.set(k, v)); + } catch {} +} + +function saveBanHistory() { + try { fs.writeFileSync(BAN_HIST_FILE, JSON.stringify(Object.fromEntries(banHistory), null, 2)); } + catch {} +} + +async function refreshBanHistory() { + try { + const jails = await getJails(); + const entries = await Promise.all(jails.map(getBanEntries)); + const now = new Date().toISOString(); + entries.flat().forEach(({ ip }) => { + if (banHistory.has(ip)) { + banHistory.get(ip).lastSeen = now; + banHistory.get(ip).banCount++; + } else { + banHistory.set(ip, { firstSeen: now, lastSeen: now, banCount: 1 }); + } + }); + saveBanHistory(); + } catch (e) { console.error('ban history refresh:', e.message); } +} + +// ── Nginx log scanner ───────────────────────────────────────────────────────── +function isWhitelisted(ip) { + const wl = readIgnoreIP(); + if (wl.includes(ip)) return true; + try { return SUBNETS.some(s => ipLib.cidrSubnet(s).contains(ip)); } + catch { return false; } +} + +async function processLogFile(file, cutoff) { + return new Promise((resolve, reject) => { + const stream = fs.createReadStream(file); + const reader = rl.createInterface({ input: stream, crlfDelay: Infinity }); + reader.on('line', line => { + const tm = line.match(/\[(\d{2})\/(\w{3})\/(\d{4}):(\d{2}):(\d{2}):(\d{2})/); + if (tm) { + const months = {Jan:0,Feb:1,Mar:2,Apr:3,May:4,Jun:5,Jul:6,Aug:7,Sep:8,Oct:9,Nov:10,Dec:11}; + if (new Date(tm[3], months[tm[2]], tm[1], tm[4], tm[5], tm[6]).getTime() < cutoff) return; + } + const ipM = line.match(/\[Client ([^\]]+)\]/); + if (!ipM) return; + const ip = ipM[1]; + if (isWhitelisted(ip)) return; + const stM = line.match(/\s(\d{3})\s/); + if (!stM || stM[1] === '200') return; + const parts = line.split(/\s+/); + const ui = parts.findIndex(p => p === 'http' || p === 'https'); + if (ui === -1 || ui + 1 >= parts.length) return; + const host = parts[ui + 1]; + ipHits.set(ip, (ipHits.get(ip) || 0) + 1); + if (!ipSites.has(ip)) ipSites.set(ip, new Set()); + ipSites.get(ip).add(host); + if (!ipLogs.has(ip)) ipLogs.set(ip, []); + ipLogs.get(ip).push(line); + }); + reader.on('close', resolve); + reader.on('error', reject); + }); +} + +async function scanNginxLogs(days = DEFAULT_DAYS) { + ipHits.clear(); ipSites.clear(); ipLogs.clear(); + await refreshBanHistory(); + const cutoff = Date.now() - days * 86_400_000; + const jails = await getJails(); + const entries = await Promise.all(jails.map(getBanEntries)); + const banned = new Set(entries.flat().map(e => e.ip)); + + const files = fs.readdirSync(LOG_DIR) + .filter(f => f.startsWith('proxy-host-') && f.endsWith('_access.log')) + .map(f => path.join(LOG_DIR, f)); + await Promise.all(files.map(f => processLogFile(f, cutoff))); + + return Array.from(ipHits.entries()) + .filter(([ip]) => !banned.has(ip)) + .map(([ip, hits]) => { + const hist = banHistory.get(ip); + return { + ip, hits, + sites: Array.from(ipSites.get(ip) || []), + previouslyBanned: banHistory.has(ip), + banCount: hist?.banCount || 0, + lastBanned: hist?.lastSeen || null, + }; + }) + .sort((a, b) => b.hits - a.hits); +} + +// ── F2B log tail ─────────────────────────────────────────────────────────────── +function seedF2bPos() { + try { + const s = fs.statSync(FAIL2BAN_LOG); + f2bPos = s.size; f2bInode = s.ino; + } catch {} +} + +function f2bRecentLines(n = 50) { + try { + return fs.readFileSync(FAIL2BAN_LOG, 'utf8') + .split('\n').filter(l => l.trim()) + .slice(-n) + .filter(l => !l.includes('Ignore') && !l.includes('Unban')); + } catch { return []; } +} + +function f2bNewLines() { + try { + const s = fs.statSync(FAIL2BAN_LOG); + if (s.ino !== f2bInode) { f2bInode = s.ino; f2bPos = 0; } + if (s.size <= f2bPos) return []; + const fd = fs.openSync(FAIL2BAN_LOG, 'r'); + const buf = Buffer.alloc(s.size - f2bPos); + fs.readSync(fd, buf, 0, buf.length, f2bPos); + fs.closeSync(fd); + f2bPos = s.size; + return buf.toString('utf8').split('\n').map(l => l.trim()).filter(l => l && !l.includes('Ignore') && !l.includes('Unban')); + } catch { return []; } +} + +// ── Routes: F2B ban management ──────────────────────────────────────────────── +app.get('/api/bans', async (req, res) => { + try { res.json(await buildBanList()); } + catch (e) { res.status(500).json({ error: e.message }); } +}); + +app.post('/api/ban', async (req, res) => { + const { ip } = req.body; + if (!ip || (!ipLib.isV4Format(ip) && !ipLib.isV6Format(ip))) + return res.status(400).send('Invalid IP'); + try { await banIP(ip); res.send(`${ip} banned.`); } + catch (e) { res.status(500).send(e.message); } +}); + +app.post('/api/unban', async (req, res) => { + const { ip, jail } = req.body; + if (!ip || !jail) return res.status(400).send('ip and jail required'); + try { await unbanIP(ip, jail); res.send(`${ip} unbanned from ${jail}.`); } + catch (e) { res.status(500).send(e.message); } +}); + +app.post('/api/unban-all', async (req, res) => { + const { ip } = req.body; + if (!ip) return res.status(400).send('ip required'); + try { await unbanAll(ip); res.send(`${ip} unbanned from all jails.`); } + catch (e) { res.status(500).send(e.message); } +}); + +app.post('/api/whitelist', async (req, res) => { + const { ip, note } = req.body; + if (!ip) return res.status(400).send('ip required'); + try { await addWhitelist(ip, note || ''); banCache = null; res.send(`${ip} whitelisted.`); } + catch (e) { res.status(500).send(e.message); } +}); + +app.delete('/api/whitelist/:ip', async (req, res) => { + try { await removeWhitelist(req.params.ip); banCache = null; res.send(`${req.params.ip} removed from whitelist.`); } + catch (e) { res.status(500).send(e.message); } +}); + +app.get('/api/check-abuse/:ip', async (req, res) => { + if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); + try { res.json(await checkAbuse(req.params.ip)); } + catch (e) { res.status(500).send(e.message); } +}); + +app.post('/api/force-abuse-check', async (req, res) => { + if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); + res.send('Running abuse checks in background…'); + (async () => { + const bans = await buildBanList(); + for (const { ip, jail, score } of bans) { + if (score == null && jail !== 'whitelist') { + await checkAbuse(ip); + await new Promise(r => setTimeout(r, 1200)); + } + } + banCache = null; + })(); +}); + +// ── Routes: Log scanner (async job) ────────────────────────────────────────── +let scanJob = { running: false, done: false, results: [], error: null }; + +app.post('/api/scan/start', (req, res) => { + const days = parseInt(req.query.days || DEFAULT_DAYS); + if (scanJob.running) return res.json({ running: true }); + scanJob = { running: true, done: false, results: [], error: null }; + res.json({ running: true }); + scanNginxLogs(days) + .then(results => { scanJob = { running: false, done: true, results, error: null }; }) + .catch(e => { scanJob = { running: false, done: true, results: [], error: e.message }; }); +}); + +app.get('/api/scan/results', (req, res) => res.json(scanJob)); + +app.post('/api/auto-ban', async (req, res) => { + if (!ABUSE_KEY) return res.status(503).send('AbuseIPDB key not configured'); + const threshold = parseInt(req.body.threshold ?? AUTOBAN_THR); + const days = parseInt(req.body.days ?? DEFAULT_DAYS); + res.send('Auto-ban running in background…'); + (async () => { + const results = await scanNginxLogs(days); + for (const { ip, hits } of results) { + if (hits < 3) continue; + const { score } = await checkAbuse(ip); + if (score != null && score >= threshold) { + await banIP(ip).catch(() => {}); + console.log(`[auto-ban] ${ip} score=${score} threshold=${threshold}`); + } + await new Promise(r => setTimeout(r, 1000)); + } + console.log('[auto-ban] complete'); + })(); +}); + +// ── Routes: Purge nginx logs ────────────────────────────────────────────────── +app.post('/api/purge-logs', (req, res) => { + try { + const files = fs.readdirSync(LOG_DIR) + .filter(f => f.startsWith('proxy-host-') && f.endsWith('_access.log')); + files.forEach(f => fs.writeFileSync(path.join(LOG_DIR, f), '')); + ipHits.clear(); ipSites.clear(); ipLogs.clear(); + res.send(`Purged ${files.length} log file(s).`); + } catch (e) { res.status(500).send(e.message); } +}); + +// ── Routes: F2B log tail ────────────────────────────────────────────────────── +app.get('/api/f2b/init', (req, res) => { + seedF2bPos(); + res.json({ lines: f2bRecentLines(50) }); +}); + +app.get('/api/f2b/poll', (req, res) => { + res.json({ lines: f2bNewLines() }); +}); + +// ── Routes: Nginx log viewer ────────────────────────────────────────────────── +app.get('/logs/:ip', (req, res) => { + const ip = req.params.ip; + const logs = ipLogs.get(ip) || []; + const hist = banHistory.get(ip); + const esc = s => String(s).replace(/&/g,'&').replace(//g,'>'); + + let badge = ''; + if (hist) badge = `
⚠ Previously banned ${hist.banCount}x — last ${new Date(hist.lastSeen).toLocaleString()}
`; + + res.send(` +Logs: ${esc(ip)} +
+
 F2B // IP LOOKUP
+
// NGINX ACCESS LOGS FOR ${esc(ip)}
+

// ${esc(ip)}

+
+
// LOG ENTRIES
+ ${badge} +

Entries in current scan window: ${logs.length}

+
${logs.map(esc).join('\n') || '(no entries — run a scan first)'}
+
+
_
+
F2B Control Center | :${process.env.PORT || 4000}
+
`); +}); + +// ── Boot ────────────────────────────────────────────────────────────────────── +loadBanHistory(); +refreshBanHistory(); +setInterval(refreshBanHistory, 6 * 3600 * 1000); + +const PORT = process.env.PORT || 4000; +app.listen(PORT, '0.0.0.0', () => + console.log(`[f2b-cc] Dashboard listening on :${PORT}`) +); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0754275 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,92 @@ +# ── F2B Control Center — docker-compose ────────────────────────────────────── +# +# QUICK START: +# 1. cp .env.example .env +# 2. Set NPM_LOG_DIR to your Nginx Proxy Manager log path +# 3. docker-compose up -d +# +# NETWORK MODE: +# network_mode: host is the recommended default. +# This allows fail2ban's iptables rules to block traffic at the host level, +# which is required when Nginx Proxy Manager receives traffic from the host +# network stack (the typical Docker-based NPM setup). +# +# If you only want the dashboard (no active iptables blocking), you can +# switch to bridge networking by commenting out network_mode and +# uncommenting the ports section instead. +# ───────────────────────────────────────────────────────────────────────────── + +version: "3.9" + +services: + f2b-control-center: + build: . + image: f2b-control-center:latest + container_name: f2b-control-center + restart: unless-stopped + + # Required for iptables rules to manipulate the host network stack. + # network_mode: host makes the container share the host's network namespace, + # so fail2ban bans affect traffic arriving at the host. + network_mode: host + + # Alternative (bridge mode — dashboard only, no host-level blocking): + # Comment out network_mode above and uncomment these: + # ports: + # - "${DASHBOARD_PORT:-4000}:4000" + # cap_add: + # - NET_ADMIN + # - NET_RAW + + environment: + # ── Dashboard ──────────────────────────────────────────────────────── + PORT: "${DASHBOARD_PORT:-4000}" + + # AbuseIPDB integration (optional but recommended) + # Get a free API key at https://www.abuseipdb.com/ + ABUSEIPDB_API_KEY: "${ABUSEIPDB_API_KEY:-}" + + # Auto-ban: AbuseIPDB confidence score threshold (0-100) + AUTOBAN_THRESHOLD: "${AUTOBAN_THRESHOLD:-75}" + + # Default lookback window for nginx log scanning (days) + DEFAULT_LOOKBACK_DAYS: "${DEFAULT_LOOKBACK_DAYS:-3}" + + # Comma-separated CIDR subnets to ignore in scans and bans + SUBNETS_TO_IGNORE: "${SUBNETS_TO_IGNORE:-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" + + # Optional: POST ban events to this URL (e.g. Discord webhook, n8n, etc.) + WEBHOOK_URL: "${WEBHOOK_URL:-}" + + # ── Internal paths — change only if you remap volumes ──────────────── + LOG_DIR: "/nginx-logs" + FAIL2BAN_LOG: "/var/log/fail2ban.log" + JAIL_LOCAL: "/etc/fail2ban/jail.local" + MANUAL_JAIL: "manual-bans" + BAN_HIST_FILE: "/data/ban-history.json" + + volumes: + # ── REQUIRED: your Nginx Proxy Manager access log directory ────────── + # Change NPM_LOG_DIR in .env to match your setup. + # Default paths for common NPM Docker setups: + # /opt/npm/data/logs (appdata-style) + # /home/docker/NGINX/data/logs + # /docker/nginx-proxy-manager/data/logs + - "${NPM_LOG_DIR:-/opt/npm/data/logs}:/nginx-logs:ro" + + # ── Persistent application data (ban history) ───────────────────── + - f2b-data:/data + + # ── Fail2ban configuration (survives container updates) ─────────── + # Edit /etc/fail2ban/jail.local inside the container or mount a local + # directory here to manage config files outside the container. + - f2b-config:/etc/fail2ban + + labels: + com.f2b-control-center.description: "Fail2Ban Control Center for Nginx Proxy Manager" + +volumes: + f2b-data: + driver: local + f2b-config: + driver: local diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..3d863ba --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# ── F2B Control Center — container entrypoint ──────────────────────────────── +# Handles first-run initialisation, then hands off to supervisord. +# ───────────────────────────────────────────────────────────────────────────── +set -e + +echo "[f2b-cc] Starting F2B Control Center..." + +# ── First-run: install default fail2ban config if none exists ───────────────── +if [ ! -f /etc/fail2ban/jail.local ]; then + echo "[f2b-cc] First run — installing default fail2ban configuration..." + cp -r /etc/f2b-defaults/. /etc/fail2ban/ + + # Apply SUBNETS_TO_IGNORE from environment into jail.local's ignoreip line + if [ -n "${SUBNETS_TO_IGNORE}" ]; then + IGNORE_LINE="ignoreip = 127.0.0.1/8 ::1 ${SUBNETS_TO_IGNORE}" + sed -i "s|^ignoreip = .*|${IGNORE_LINE}|" /etc/fail2ban/jail.local + echo "[f2b-cc] ignoreip set to: 127.0.0.1/8 ::1 ${SUBNETS_TO_IGNORE}" + fi + + echo "[f2b-cc] Default configuration installed at /etc/fail2ban/" + echo "[f2b-cc] Edit /etc/fail2ban/jail.local to customise jails." +else + echo "[f2b-cc] Using existing fail2ban configuration." +fi + +# ── Ensure required directories and files exist ─────────────────────────────── +mkdir -p /data /var/log /var/run/fail2ban + +# Create fail2ban log file if it doesn't exist (prevents startup errors) +touch /var/log/fail2ban.log + +# Ensure nginx-logs directory exists (warn if empty/unmounted) +if [ ! -d /nginx-logs ] || [ -z "$(ls -A /nginx-logs 2>/dev/null)" ]; then + echo "[f2b-cc] WARNING: /nginx-logs appears empty or unmounted." + echo "[f2b-cc] Set NPM_LOG_DIR in .env and mount your NPM log directory." + echo "[f2b-cc] Log scanning will not return results until logs are available." + mkdir -p /nginx-logs +fi + +# ── Start supervisord (manages fail2ban + dashboard) ───────────────────────── +echo "[f2b-cc] Starting supervisord (fail2ban + dashboard)..." +exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf diff --git a/fail2ban/action.d/webhook.conf b/fail2ban/action.d/webhook.conf new file mode 100644 index 0000000..72c7a50 --- /dev/null +++ b/fail2ban/action.d/webhook.conf @@ -0,0 +1,37 @@ +# ── F2B Control Center — webhook action ────────────────────────────────────── +# +# Optional fail2ban action that POSTs a JSON payload to a webhook URL on +# ban and unban events. Useful for Discord, Slack, n8n, or any HTTP endpoint. +# +# USAGE: +# Set WEBHOOK_URL in your .env file. +# Then add to any jail in jail.local: +# +# action = %(action_)s +# webhook[url="%(webhook_url)s"] +# +# And in [DEFAULT]: +# webhook_url = YOUR_WEBHOOK_URL +# +# This action is NOT enabled by default. The dashboard's ban webhook is +# handled separately via the WEBHOOK_URL environment variable in server.js. +# ───────────────────────────────────────────────────────────────────────────── + +[Definition] + +# Variable: webhook URL (set via jail.local or override) +webhook_url = %(url)s + +actionban = curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{"action":"ban","ip":"","jail":"","failures":,"ts":"'$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)'"}' \ + "%(webhook_url)s" || true + +actionunban = curl -s -X POST \ + -H "Content-Type: application/json" \ + -d '{"action":"unban","ip":"","jail":"","ts":"'$(date -u +%%Y-%%m-%%dT%%H:%%M:%%SZ)'"}' \ + "%(webhook_url)s" || true + +[Init] +name = default +url = diff --git a/fail2ban/filter.d/badbot.conf b/fail2ban/filter.d/badbot.conf new file mode 100644 index 0000000..751f73c --- /dev/null +++ b/fail2ban/filter.d/badbot.conf @@ -0,0 +1,28 @@ +# ── F2B Control Center — badbot filter ─────────────────────────────────────── +# +# Blocks known malicious scanners, vulnerability testers, and exploit frameworks +# by their HTTP User-Agent string. +# +# LOG FORMAT: +# Primary pattern matches NPM logs with [Client IP] real-IP field: +# PROXY_IP - - [date] "GET /" 200 146 "-" "UserAgent" [Client REAL_IP] +# +# If your NPM logs have the client IP at line start (standard nginx combined), +# uncomment the alternative failregex lines instead. +# ───────────────────────────────────────────────────────────────────────────── + +[Definition] + +# ── Primary: NPM [Client IP] format ────────────────────────────────────────── +failregex = ^[^ ]+ - -[^\[]*\[[^\]]+\] "[A-Z]+ [^"]*" \d{3} \d+ "[^"]*" "(?:masscan|zgrab|python-requests|Go-http-client/1\.1|[Nn]uclei|sqlmap|dirbuster|gobuster|nikto|nmap|wfuzz|Metasploit|libwww-perl|WPScan|ZmEu|jorgee|NetcraftSurveyAgent|Expanse|Shodan|censys|BinaryEdge|internet-measurement|SemrushBot|AhrefsBot)[^"]*" \[Client \] + +# ── Alternative: standard nginx combined format (IP at line start) ──────────── +# Uncomment and comment out the Primary line above to use this instead. +# failregex = ^ - -[^\[]*\[[^\]]+\] "[A-Z]+ [^"]*" \d{3} \d+ "[^"]*" "(?:masscan|zgrab|python-requests|Go-http-client/1\.1|[Nn]uclei|sqlmap|dirbuster|gobuster|nikto|nmap|wfuzz|Metasploit|libwww-perl|WPScan|ZmEu|jorgee|NetcraftSurveyAgent|Expanse|Shodan|censys|BinaryEdge|internet-measurement)[^"]*" + +ignoreregex = + +# ── Notes ──────────────────────────────────────────────────────────────────── +# Add more UA patterns to the failregex alternation as needed. +# Test your filter with: +# fail2ban-regex /nginx-logs/proxy-host-1_access.log /etc/fail2ban/filter.d/badbot.conf diff --git a/fail2ban/filter.d/http-errors.conf b/fail2ban/filter.d/http-errors.conf new file mode 100644 index 0000000..ec3338d --- /dev/null +++ b/fail2ban/filter.d/http-errors.conf @@ -0,0 +1,28 @@ +# ── F2B Control Center — http-errors filter ────────────────────────────────── +# +# Bans IPs generating a high volume of HTTP 4xx/5xx error responses. +# Works well for catching brute-force login attempts, path traversal scans, +# and generally misbehaving clients. +# +# Tune `maxretry` and `findtime` in jail.local to adjust sensitivity. +# Default: 15 errors in 5 minutes. +# +# LOG FORMAT: +# Primary pattern matches NPM logs with [Client IP] real-IP field. +# See badbot.conf for details on switching to standard nginx format. +# ───────────────────────────────────────────────────────────────────────────── + +[Definition] + +# ── Primary: NPM [Client IP] format ────────────────────────────────────────── +# Matches any 4xx or 5xx response (excluding 200 implicitly by the status code). +failregex = ^[^ ]+ - -[^\[]*\[[^\]]+\] "[A-Z]+ [^"]*" [45]\d\d \d+ "[^"]*" "[^"]*" \[Client \] + +# ── Alternative: standard nginx combined format ─────────────────────────────── +# failregex = ^ - -[^\[]*\[[^\]]+\] "[A-Z]+ [^"]*" [45]\d\d + +# Ignore common false-positive 4xx codes: +# 404 — very common for missing favicons, /robots.txt, etc. Remove from +# ignoreregex below if you DO want to count 404s (recommended for +# aggressive probe detection when combined with high maxretry). +ignoreregex = ^[^ ]+ - -[^\[]*\[[^\]]+\] "[A-Z]+ /(?:favicon\.ico|robots\.txt|apple-touch-icon[^"]*) HTTP[^"]*" 404 .* \[Client \] diff --git a/fail2ban/filter.d/manual-bans.conf b/fail2ban/filter.d/manual-bans.conf new file mode 100644 index 0000000..b5da4c6 --- /dev/null +++ b/fail2ban/filter.d/manual-bans.conf @@ -0,0 +1,15 @@ +# ── F2B Control Center — manual-bans filter ────────────────────────────────── +# +# Empty filter — this jail is used exclusively for manual banning via the +# dashboard or `fail2ban-client set manual-bans banip `. +# +# No log-based automatic detection is performed. Bans are permanent (bantime = -1) +# and are only added or removed through explicit operator action. +# ───────────────────────────────────────────────────────────────────────────── + +[Definition] + +# Empty failregex: no automatic log-based detection +failregex = + +ignoreregex = diff --git a/fail2ban/filter.d/npm-probe.conf b/fail2ban/filter.d/npm-probe.conf new file mode 100644 index 0000000..0a329f3 --- /dev/null +++ b/fail2ban/filter.d/npm-probe.conf @@ -0,0 +1,29 @@ +# ── F2B Control Center — npm-probe filter ──────────────────────────────────── +# +# Bans IPs probing for well-known vulnerable paths. +# These requests almost always indicate automated exploit scanning — a single +# hit warrants a long ban (configured to 48h / maxretry 3 in jail.local). +# +# Covered categories: +# - PHP-based CMS admin paths (WordPress, Joomla, etc.) +# - Common config/credential file leaks (.env, .git, etc.) +# - Java frameworks (actuator, Spring Boot, Struts) +# - Web shells and common RCE payloads +# - Device/router admin interfaces (HNAP, boaform) +# - PHPMyAdmin, Adminer, database tools +# - Path traversal attempts (../) +# +# LOG FORMAT: +# Primary pattern matches NPM logs with [Client IP] real-IP field. +# See badbot.conf for details on switching to standard nginx format. +# ───────────────────────────────────────────────────────────────────────────── + +[Definition] + +# ── Primary: NPM [Client IP] format ────────────────────────────────────────── +failregex = ^[^ ]+ - -[^\[]*\[[^\]]+\] "(?:GET|POST|HEAD|OPTIONS) (?:/.env(?:\.[^"]*)?|/.git(?:/[^"]*)?|/wp-login\.php|/wp-admin(?:/[^"]*)?|/xmlrpc\.php|/phpmyadmin(?:/[^"]*)?|/pma(?:/[^"]*)?|/adminer(?:\.php)?|/admin\.php|/config\.php|/setup\.php|/install\.php|/actuator(?:/[^"]*)?|/console|/manager/html|/invoker/JMXInvokerServlet|/solr(?:/[^"]*)?|/geoserver(?:/[^"]*)?|/boaform(?:/[^"]*)?|/HNAP1(?:/[^"]*)?|/cgi-bin/[^"]*|/shell\.php|/cmd\.php|/eval-stdin\.php|/[^"]*\.\./[^"]*) HTTP[^"]*" \d{3} .* \[Client \] + +# ── Alternative: standard nginx combined format ─────────────────────────────── +# failregex = ^ - -[^\[]*\[[^\]]+\] "(?:GET|POST|HEAD) (?:/.env|/.git|/wp-login\.php|/wp-admin|/xmlrpc\.php|/phpmyadmin|/pma|/adminer|/admin\.php|/actuator|/console|/boaform|/HNAP1|/cgi-bin/|/shell\.php) HTTP" + +ignoreregex = diff --git a/fail2ban/jail.local b/fail2ban/jail.local new file mode 100644 index 0000000..f1decad --- /dev/null +++ b/fail2ban/jail.local @@ -0,0 +1,99 @@ +# ── F2B Control Center — fail2ban jail configuration ───────────────────────── +# +# This file is written to /etc/fail2ban/jail.local on first container start. +# After first run it is persisted in the f2b-config Docker volume so your +# customisations survive image updates. +# +# IMPORTANT: The dashboard manages the `ignoreip` line automatically. +# Do not edit it by hand unless you are not using the dashboard. +# +# LOG FORMAT NOTE: +# The default filters expect Nginx Proxy Manager access logs that include +# the real client IP in a [Client X.X.X.X] field — the format produced +# when NPM forwards through Cloudflare or another proxy. +# +# If your NPM logs have the client IP at the start of each line (standard +# nginx combined format), edit the failregex lines in each filter to use: +# failregex = ^ - - \[... +# See fail2ban/filter.d/*.conf for both patterns (commented examples included). +# ───────────────────────────────────────────────────────────────────────────── + +[DEFAULT] +# Default ban duration (supports suffixes: s, m, h, d) +bantime = 1h + +# Time window in which maxretry violations trigger a ban +findtime = 10m + +# Number of violations before banning +maxretry = 5 + +# IPs and subnets to never ban. +# The dashboard appends whitelisted IPs here — do not remove this line. +# The entrypoint script expands SUBNETS_TO_IGNORE from your .env on first run. +ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 + +# Default ban action: iptables-allports blocks all ports for banned IPs. +# Requires NET_ADMIN capability (granted in docker-compose.yml) and +# network_mode: host to affect host-level traffic. +banaction = iptables-allports + +# ── NPM: Bad Bots ───────────────────────────────────────────────────────────── +# Blocks known malicious scanners and exploit frameworks by User-Agent. +# Stricter settings: low maxretry, long ban time. +[badbot] +enabled = true +filter = badbot +logpath = /nginx-logs/proxy-host-*_access.log +bantime = 24h +findtime = 10m +maxretry = 3 +action = %(action_)s + +# ── NPM: HTTP Error Spamming ────────────────────────────────────────────────── +# Bans IPs that generate a high volume of 4xx/5xx errors. +# Useful for catching brute-force attempts, broken crawlers, and probes. +[http-errors] +enabled = true +filter = http-errors +logpath = /nginx-logs/proxy-host-*_access.log +bantime = 1h +findtime = 5m +maxretry = 15 +action = %(action_)s + +# ── NPM: Exploit Probing ────────────────────────────────────────────────────── +# Bans IPs requesting well-known vulnerable paths (.env, wp-admin, etc.). +# These are almost always malicious — short maxretry, long ban. +[npm-probe] +enabled = true +filter = npm-probe +logpath = /nginx-logs/proxy-host-*_access.log +bantime = 48h +findtime = 30m +maxretry = 3 +action = %(action_)s + +# ── Manual Bans ─────────────────────────────────────────────────────────────── +# Permanent bans added via the dashboard or `fail2ban-client set manual-bans banip`. +# No automatic log-based detection — only manual entries via the dashboard. +[manual-bans] +enabled = true +filter = manual-bans +logpath = /dev/null +bantime = -1 +findtime = 1d +maxretry = 1 +action = %(action_)s + +# ── Recidive ────────────────────────────────────────────────────────────────── +# Escalated bans for repeat offenders across any jail. +# Disabled by default — enable if you want long-term blocks for persistent attackers. +[recidive] +enabled = false +filter = recidive +logpath = /var/log/fail2ban.log +bantime = 7d +findtime = 1d +maxretry = 3 +action = %(action_)s diff --git a/healthcheck.sh b/healthcheck.sh new file mode 100644 index 0000000..72e8c14 --- /dev/null +++ b/healthcheck.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# Docker HEALTHCHECK — passes only if both fail2ban and dashboard are responding. +set -e + +# Check fail2ban daemon is alive +fail2ban-client ping > /dev/null 2>&1 || exit 1 + +# Check dashboard HTTP endpoint is responding +curl -sf --max-time 5 "http://localhost:${PORT:-4000}/api/bans" > /dev/null 2>&1 || exit 1 + +exit 0 diff --git a/supervisor.conf b/supervisor.conf new file mode 100644 index 0000000..2b2bacd --- /dev/null +++ b/supervisor.conf @@ -0,0 +1,54 @@ +# ── supervisord configuration for F2B Control Center ───────────────────────── +# Manages two processes inside the container: +# 1. fail2ban — the banning daemon (starts first) +# 2. dashboard — the Node.js web interface +# ───────────────────────────────────────────────────────────────────────────── + +[supervisord] +nodaemon=true +logfile=/dev/null +logfile_maxbytes=0 +pidfile=/var/run/supervisord.pid +loglevel=info + +[unix_http_server] +file=/var/run/supervisor.sock +chmod=0700 + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +# ── fail2ban ────────────────────────────────────────────────────────────────── +[program:fail2ban] +command=/usr/bin/fail2ban-server -xf start +autostart=true +autorestart=true +startretries=3 +startsecs=3 +stopwaitsecs=10 +# -x: remove stale socket before starting +# -f: run in foreground (required for supervisor) +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=10 + +# ── dashboard ───────────────────────────────────────────────────────────────── +[program:dashboard] +command=/usr/bin/node /app/server.js +directory=/app +autostart=true +autorestart=true +startretries=5 +startsecs=3 +stopwaitsecs=10 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 +priority=20 +environment=NODE_ENV="production"