Initial release: F2B Control Center v1.0

Fail2Ban + Nginx Proxy Manager dashboard in a single Docker container.

Features:
- Auto-ban via badbot, http-errors, npm-probe, manual-bans, recidive jails
- Web dashboard: live ban grid, log scanner, per-IP access log viewer
- iptables-nft banning (DOCKER-USER + INPUT chains)
- Optional Cloudflare WAF banning
- Optional AbuseIPDB threat scoring
- Two-tier IP management: whitelist (trusted) vs exempt (reviewed)
- Auto log-file detection via logwatch (no restart needed for new NPM hosts)
This commit is contained in:
2026-02-20 18:59:56 +00:00
commit c104e27506
24 changed files with 3333 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Dependencies
dashboard/node_modules/
# 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

66
Dockerfile Normal file
View File

@@ -0,0 +1,66 @@
# ── 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 \
jq \
&& rm -rf /var/lib/apt/lists/* \
# Remove debian default jail (enables sshd which has no log file in container)
&& rm -f /etc/fail2ban/jail.d/defaults-debian.conf
# ── 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
COPY logwatch.sh /logwatch.sh
RUN chmod +x /entrypoint.sh /healthcheck.sh /logwatch.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"]

21
LICENSE Normal file
View File

@@ -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.

190
README.md Normal file
View File

@@ -0,0 +1,190 @@
# F2B Control Center
> Fail2Ban management dashboard for Nginx Proxy Manager — bans scanners, bots, and exploit probers automatically, with a browser UI to monitor and control everything.
<!-- SCREENSHOT: Main dashboard — full grid of ban cards visible, mix of BADBOT/NPM-PROBE/HTTP-ERRORS jail badges, AbuseIPDB scores showing on several cards, dark terminal UI -->
---
## What It Does
F2B Control Center runs alongside Nginx Proxy Manager as a single Docker container. It watches your NPM access logs in real time and automatically bans IPs that are:
- Using known scanner or hacking-tool user-agents (masscan, sqlmap, zgrab, etc.)
- Probing for common exploits — WordPress logins, `.env` files, admin panels, PHP shells
- Hammering your services with repeated HTTP errors
Bans are enforced at the **iptables level** — blocked IPs can't reach anything on the host, not just one service. Optionally, bans also push to **Cloudflare WAF** to block traffic before it even hits your server.
The dashboard gives you a live view of every active ban, lets you manually ban/unban IPs, scan logs for suspicious activity, check threat reputation via AbuseIPDB, and manage your trusted IP whitelist — all from the browser.
---
## Requirements
- Docker + Docker Compose
- Linux host with iptables/nftables support
- Nginx Proxy Manager (or any reverse proxy writing NPM-format access logs)
- `xt_string` kernel module loaded on the host:
```bash
modprobe xt_string
echo xt_string >> /etc/modules # persist across reboots
```
---
## Quick Start
```bash
git clone https://github.com/YOUR_USERNAME/f2b-control-center
cd f2b-control-center
```
Open `docker-compose.yml` and set `SUBNETS_TO_IGNORE` to your local network ranges — these IPs will never be scanned or banned. Everything else works out of the box.
```bash
docker compose up -d
```
Dashboard is at `http://YOUR_HOST:4000`.
On first start the container populates a persistent config volume from the bundled defaults and begins monitoring NPM logs immediately. No manual fail2ban setup required.
---
## Configuration
All settings live in `docker-compose.yml`. Required fields are uncommented. Optional integrations are commented out — uncomment and fill in to enable them.
| Variable | Default | Description |
|---|---|---|
| `PORT` | `4000` | Dashboard port |
| `SUBNETS_TO_IGNORE` | RFC1918 ranges | Comma-separated CIDRs excluded from scanning and banning |
| `ABUSEIPDB_API_KEY` | *(optional)* | Enables AbuseIPDB threat scores and report submission |
| `CF_EMAIL` | *(optional)* | Cloudflare account email — enables WAF banning |
| `CF_APIKEY` | *(optional)* | Cloudflare Global API Key |
---
## Jails
| Jail | What triggers it | Default ban time |
|---|---|---|
| `badbot` | Known scanner/exploit user-agents | 24 hours |
| `http-errors` | 15+ 4xx/5xx errors in 5 minutes | 1 hour |
| `npm-probe` | Exploit path probing (.env, wp-login, admin panels, etc.) | 48 hours |
| `manual-bans` | Manually banned via dashboard or CLI | Permanent |
| `recidive` | Repeat offenders — 3 bans within 24h | 7 days *(disabled by default)* |
Jail thresholds (ban time, retry count, window) are tunable in the `fail2ban/jail.local` config, which persists in the `f2b-config` Docker volume across image updates. You can also edit it live through the dashboard's Jail Manager.
---
## How Banning Works
The container runs with `network_mode: host` and `NET_ADMIN`/`NET_RAW` capabilities so it can write iptables rules directly on the host network stack.
Each ban inserts two rules:
1. **DOCKER-USER chain** — drops forwarded traffic where the `X-Forwarded-For` header matches the banned IP. Catches traffic routed through Cloudflare or other CDNs.
2. **INPUT chain** — drops direct connections from the banned IP.
Bans survive container restarts (fail2ban reloads its state from disk). They do not survive host reboots unless you also enable the `recidive` jail or use persistent iptables (e.g., `iptables-persistent`).
### Cloudflare WAF (optional)
Uncomment `CF_EMAIL` and `CF_APIKEY` in `docker-compose.yml`. The container detects these on startup and activates Cloudflare banning automatically — no other config needed. Every ban also creates a Cloudflare firewall rule, blocking the IP at the edge before it reaches your server.
Get your Global API Key at: https://dash.cloudflare.com/profile/api-tokens
---
## Dashboard
See **[USERGUIDE.md](USERGUIDE.md)** for a plain-English walkthrough of every dashboard feature.
<!-- SCREENSHOT: Filter tab row — all tab buttons visible ([ALL][BADBOT][NPM][HTTP-ERRORS][PRISON][WHITELIST][EXEMPT][SCAN]), one tab active/highlighted -->
<!-- SCREENSHOT: Scan results panel — several suspect IP cards visible with [BAN], [EXEMPT], [THREAT], [RECORDS] buttons; one card showing an AbuseIPDB score -->
<!-- SCREENSHOT: Live log stream — scrolling fail2ban log output showing ban events in real time, terminal-style -->
---
## Log Format
Filters expect NPM access logs with the real client IP in a `[Client X.X.X.X]` field. This is the format NPM produces when sitting behind Cloudflare or any proxy that forwards `X-Forwarded-For`.
To verify your filters are working:
```bash
docker exec f2b-control-center \
fail2ban-regex /nginx-logs/proxy-host-1_access.log \
/etc/fail2ban/filter.d/http-errors.conf
```
---
## Volumes
| Volume | Container path | Contents |
|---|---|---|
| `f2b-data` | `/data` | Ban history, exemptions list |
| `f2b-config` | `/etc/fail2ban` | Jail and filter config (persists across image rebuilds) |
| bind mount | `/nginx-logs` | NPM log directory (read-only) |
To reset config to defaults: `docker volume rm f2b-control-center_f2b-config` then restart.
---
## CLI Reference
```bash
# Stream container logs
docker compose logs -f
# Reload fail2ban after manual config edits
docker exec f2b-control-center fail2ban-client reload
# Check jail status
docker exec f2b-control-center fail2ban-client status
docker exec f2b-control-center fail2ban-client status badbot
# Manual ban / unban
docker exec f2b-control-center fail2ban-client set manual-bans banip 1.2.3.4
docker exec f2b-control-center fail2ban-client set manual-bans unbanip 1.2.3.4
# Test a filter against your logs
docker exec f2b-control-center \
fail2ban-regex /nginx-logs/proxy-host-1_access.log \
/etc/fail2ban/filter.d/npm-probe.conf
```
---
## Credits
This project is built on and integrates the following open-source tools and services:
| | |
|---|---|
| [Fail2Ban](https://github.com/fail2ban/fail2ban) | IP ban engine and jail framework |
| [Nginx Proxy Manager](https://nginxproxymanager.com) — jc21 | The reverse proxy this was designed for |
| [AbuseIPDB](https://www.abuseipdb.com) | Threat intelligence API and reporting |
| [Express.js](https://expressjs.com) | Dashboard API server |
| [ipaddr.js](https://github.com/whitequark/ipaddr.js) | IP and CIDR address parsing |
| [VT323](https://fonts.google.com/specimen/VT323) | Retro terminal UI font (Google Fonts) |
| [supervisord](http://supervisord.org) | Multi-process management inside the container |
| [Docker](https://www.docker.com) | Container runtime |
### About This Project
This started as a personal setup I built to protect my own self-hosted services. The filters, iptables actions, and log parsing logic came out of real-world use on my own infrastructure.
The migration from personal scripts into a clean, shareable Docker project — packaging, entrypoint initialization, the dashboard UI, and this documentation — was done with significant help from [Claude](https://claude.ai) (Anthropic). If you're curious what AI-assisted development looks like in practice, this project is a fairly honest example.
---
## License
MIT — see [LICENSE](LICENSE).

159
USERGUIDE.md Normal file
View File

@@ -0,0 +1,159 @@
# F2B Control Center — User Guide
A plain-English walkthrough of the dashboard. No technical background required.
---
## What It Does For You
F2B Control Center watches the traffic hitting your self-hosted services and automatically bans IP addresses that are behaving badly — bots scanning for security holes, scrapers hammering your error pages, tools probing for WordPress vulnerabilities on a site that isn't even WordPress.
When something gets banned, it's blocked at the server level. The banned IP can't reach any of your services, not just the one it was probing.
The dashboard gives you a live view of what's happening, and lets you take action on anything you see.
---
## Opening the Dashboard
Navigate to `http://YOUR_SERVER_IP:4000` in your browser.
<!-- SCREENSHOT: Full dashboard on first load — ban grid visible with several cards, filter tabs across the top, action bar below the tabs, dark retro-terminal theme -->
---
## The Ban Grid
The main view is a grid of cards — one card per banned IP address.
<!-- SCREENSHOT: Close-up of 2-3 ban cards side by side — showing IP, jail badge (e.g. BADBOT), ban expiry time, AbuseIPDB score, and action buttons -->
Each card shows:
- **The IP address** that was banned
- **Why it was banned** — shown as a badge (see Jails section below)
- **When the ban expires** — or "permanent" for manual bans
- **Threat score** — a 0100 number from AbuseIPDB showing how widely this IP is known to be malicious *(only shown if AbuseIPDB is configured)*
---
## Jails — Why Was This IP Banned?
Every ban comes from a "jail" — a rule set that detected bad behaviour:
| Badge | What it caught |
|---|---|
| `[BADBOT]` | The IP's browser/tool identified itself as a known scanner or hacking tool (masscan, sqlmap, Nikto, etc.) |
| `[NPM-PROBE]` | The IP tried to access classic exploit targets — WordPress logins, `.env` files, PHP shells, admin panels — paths that have no business being requested on your server |
| `[HTTP-ERRORS]` | The IP generated a flood of 4xx or 5xx error responses in a short time window — typical scanning or brute-force behaviour |
| `[PRISON]` | You manually banned this IP |
| `[WHITELIST]` | This IP is fully trusted and will never be banned or flagged |
| `[EXEMPT]` | You've reviewed this IP and dismissed it — it won't appear in scan results, but the system is still watching it |
---
## Filter Tabs
<!-- SCREENSHOT: The filter tab row at full width — [ALL] [BADBOT] [NPM] [HTTP-ERRORS] [PRISON] [WHITELIST] [EXEMPT] [SCAN] — with one tab highlighted/active -->
The row of buttons across the top filters the grid:
- Click **`[ALL]`** to see every banned IP at once
- Click any jail name to show only that group (e.g. `[BADBOT]` to see only bot traffic)
- Click **`[SCAN]`** to run the log scanner *(see Scan section below)*
---
## Actions — What You Can Do With a Banned IP
Each card has buttons for acting on that IP:
| Button | What it does |
|---|---|
| `[RECORDS]` | Opens the access log for that IP — every request it ever made to your server |
| `[PAROLE]` | Removes all bans for this IP and lets it through again |
| `[ARREST]` | Manually bans this IP permanently (useful from scan results) |
| `[THREAT]` | Looks up this IP on AbuseIPDB — shows its global reputation and recent abuse reports |
| `[WHITELIST]` | Marks this IP as fully trusted — fail2ban will never ban it again |
| `[EXEMPT]` | Dismisses this IP from scan results without trusting it — it stays monitored |
| `[REMOVE]` | Removes an IP from the whitelist or exempt list |
---
## The Action Bar
<!-- SCREENSHOT: The action bar — IP address input field on the left, dropdown selector showing options (ARREST / PAROLE / WHITELIST / SEARCH), GO button on the right -->
The bar just below the filter tabs lets you act on any IP by address — even ones that aren't currently banned:
1. Type or paste an IP address
2. Choose an action from the dropdown
3. Press **`[GO]`**
Actions available here:
- **`[ARREST]`** — ban immediately, permanently
- **`[PAROLE]`** — remove all bans
- **`[WHITELIST]`** — trust this IP permanently
- **`[SEARCH]`** — highlight cards matching this IP in the current grid view
---
## Scan
<!-- SCREENSHOT: Scan results — 4-5 suspect IP cards visible, each with [BAN], [EXEMPT], [THREAT], [RECORDS] buttons; one card showing a high AbuseIPDB score in red -->
The Scan reads your NPM access logs and surfaces IP addresses that are behaving suspiciously but haven't been banned yet. Click **`[SCAN]`** in the filter tabs to run it.
It looks for IPs that are:
- Generating lots of errors
- Hitting known exploit paths
- Using scanner user-agents
From each scan result card you can:
- **`[BAN]`** — ban it right now
- **`[EXEMPT]`** — mark as reviewed; hide from future scans (monitoring continues)
- **`[THREAT]`** — check its reputation on AbuseIPDB
- **`[RECORDS]`** — see everything it requested
The scan is the best place to start when you first set things up. Run it after a day or two of traffic to get a sense of what's been probing your services.
---
## Whitelist vs. Exempt
These are two different levels of "leave this IP alone":
**Whitelist** is for IPs you fully trust — your own devices, uptime monitors, your office network. Fail2ban will never ban them. They won't appear in scans or the ban grid.
**Exempt** is for IPs you've looked at and decided aren't worth worrying about right now. They disappear from scan results so they don't keep coming up, but fail2ban is still watching them. If they start misbehaving later they'll get banned normally.
When in doubt about an IP you've reviewed: use Exempt. Reserve Whitelist for things you're certain about.
---
## Live Log
<!-- SCREENSHOT: Live log stream panel — scrolling text of fail2ban log output showing detection events and ban notices, monospace font, dark background, newest entry at bottom -->
The live log shows fail2ban's activity as it happens — every detection, ban, and unban in real time. Useful for confirming that a ban landed, watching a scan in progress, or just seeing what's hitting your server right now.
---
## Access Log Viewer (Records)
<!-- SCREENSHOT: Per-IP log viewer — showing a filtered list of HTTP requests from one IP: method, path, status code, timestamp, one or two suspicious paths highlighted -->
Click **`[RECORDS]`** on any ban card or scan result to open a filtered view of the NPM access log for that specific IP. You'll see every request it made — what paths it hit, what status codes it got back, and when.
This is useful for understanding *why* something was banned, or deciding whether to parole an IP that might have been caught by a threshold incorrectly.
---
## Tips
- **First time setup**: Run a Scan after your server has been running for a day or two. It'll show you what's already been poking around.
- **False positives**: If a legitimate service keeps getting banned, check its access log with `[RECORDS]` to understand why. Then either `[WHITELIST]` it (if you fully trust it) or raise the `maxretry` threshold in the jail config.
- **Recidive jail**: Disabled by default. Enable it after your other jails have been running for a while — it gives repeat offenders a 7-day ban automatically.
- **Manual bans are permanent**: Bans placed via `[ARREST]` or the action bar don't expire. Use `[PAROLE]` to lift them.
- **Cloudflare users**: With `CF_EMAIL` and `CF_APIKEY` set, every iptables ban also creates a Cloudflare firewall rule — the IP gets blocked at the edge, before it reaches your server at all.

816
dashboard/package-lock.json generated Normal file
View File

@@ -0,0 +1,816 @@
{
"name": "f2b-control-center",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "f2b-control-center",
"version": "1.0.0",
"dependencies": {
"dotenv": "*",
"express": "*",
"ipaddr.js": "*",
"node-fetch": "^2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
"integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
"integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"engines": {
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
}
}
}

18
dashboard/package.json Normal file
View File

@@ -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": "*",
"ipaddr.js": "*",
"node-fetch": "^2"
}
}

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" fill="#050a05"/>
<text x="4" y="24" font-family="monospace" font-size="22" font-weight="bold" fill="#00ff41">F2B</text>
</svg>

After

Width:  |  Height:  |  Size: 221 B

479
dashboard/public/index.html Normal file
View File

@@ -0,0 +1,479 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>F2B</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<div class="screen">
<!-- ── BANNER ────────────────────────────────────────────────────── -->
<div class="banner-wrap">
<div class="banner-sub">FAIL2BAN&nbsp;&nbsp;CONTROL&nbsp;&nbsp;CENTER</div>
<div class="banner-hr"></div>
</div>
<!-- ── MAIN LAYOUT: FEED | UNIFIED ─────────────────────────────── -->
<div class="main-col">
<!-- LEFT: LIVE FEED ─────────────────────────────────────────── -->
<div class="col-feed">
<div class="box feed-col-box">
<div class="feed-title">// LIVE BANS</div>
<div class="feed-indicator">
<span class="status-dot" id="feed-dot"></span>
</div>
<div class="feed-box" id="feed"></div>
<div class="feed-rate">&#8635; 2s</div>
</div>
</div>
<!-- RIGHT: UNIFIED CONTROLS + CARDS ─────────────────────────── -->
<div class="col-main">
<div class="control-bar">
<!-- Row 1: jail filters + scan controls -->
<div class="control-row">
<div class="filter-bar" id="jail-filter">
<button class="active" data-jail="all">[ALL]</button>
<button data-jail="badbot">[BADBOT]</button>
<button data-jail="http-errors">[HTTP]</button>
<button data-jail="npm-probe">[NPM]</button>
<button data-jail="manual-bans">[PRISON]</button>
<button data-jail="whitelist">[WHITELIST]</button>
<button data-jail="exempt">[EXEMPT]</button>
<button data-jail="scan">[SCAN]</button>
</div>
</div>
<!-- Row 2: scan + action controls -->
<div class="control-row control-row-tools">
<div class="tool-group">
<span class="muted tool-label">SCAN</span>
<input type="number" id="scan-days" value="3" min="1" max="30" title="Lookback days">
<span class="muted">d</span>
<input type="number" id="scan-minhits" value="1" min="1" title="Min hits">
<span class="muted">hits+</span>
<label class="cb-label" title="Exclude previously banned IPs">
<input type="checkbox" id="scan-excl-prev"> excl&nbsp;prev
</label>
<button onclick="refreshScan()">[RUN]</button>
</div>
<div class="tool-group">
<span class="muted tool-label">AUTO-BAN</span>
<input type="number" id="autoban-threshold" value="75" min="1" max="100" title="AbuseIPDB score threshold">
<span class="muted">thr</span>
<button class="btn-red" onclick="runAutoBan()">[EXECUTE]</button>
</div>
<div class="tool-group tool-group-right">
<input type="text" id="action-ip" placeholder="IP address&#8230;" autocomplete="off">
<select id="action-select" onchange="toggleNote()">
<option value="ban">[ARREST]</option>
<option value="unban-all">[PAROLE]</option>
<option value="whitelist">[WHITELIST]</option>
<option value="search">[SEARCH]</option>
</select>
<button onclick="executeAction()">[EXECUTE]</button>
<button class="btn-amber" onclick="forceAbuseCheck()">[FORCE ABUSE]</button>
<button class="btn-red" onclick="purgeLogs()" title="Truncate all nginx proxy log files">[PURGE LOGS]</button>
</div>
<div id="note-wrap" style="display:none; flex: 0 0 100%;">
<input type="text" id="action-note" placeholder="Note (optional)&#8230;" style="width:100%">
</div>
</div>
<div class="summary-bar" id="main-summary">&nbsp;</div>
</div>
<div class="card-grid" id="main-grid"></div>
</div>
</div>
<div class="prompt">_ <span class="blink">&#9608;</span></div>
<footer>F2B &nbsp;|&nbsp; fail2ban control center &nbsp;|&nbsp; :4000</footer>
</div>
<script>
// ── Utilities ─────────────────────────────────────────────────────
const esc = s => String(s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
async function api(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
if (!r.ok) throw new Error(await r.text());
return r;
}
// ── Score colour ──────────────────────────────────────────────────
function scoreColor(score, jail) {
if (jail === 'manual-bans') return '#8B4513';
if (jail === 'whitelist') return 'var(--green)';
if (jail === 'exempt') return 'var(--dim)';
if (score == null) return 'var(--dim)';
if (score >= 90) return 'var(--red)';
if (score <= 20) return 'var(--green2)';
const hue = 60 - ((score - 20) / 70 * 60);
return `hsl(${hue},100%,50%)`;
}
// ── Live feed ─────────────────────────────────────────────────────
const feedEl = document.getElementById('feed');
const feedDot = document.getElementById('feed-dot');
const MAX_FEED = 120;
function setFeedDot(cls) { feedDot.className = 'status-dot ' + cls; }
function parseBan(line) {
const m = line.match(/\[([^\]]+)\]\s+Ban\s+(\d{1,3}(?:\.\d{1,3}){3})/);
if (!m) return null;
const tsm = line.match(/(\d{2}:\d{2}):\d{2}/);
return { jail: m[1], ip: m[2], time: tsm ? tsm[1] : '' };
}
function addToFeed(lines) {
lines.forEach(line => {
const ban = parseBan(line);
if (!ban) return;
const el = document.createElement('div');
el.className = 'feed-entry';
el.innerHTML =
`<span class="feed-time">${esc(ban.time)}</span>` +
`<span class="feed-ip">${esc(ban.ip)}</span>` +
`<span class="feed-jail">${esc(ban.jail)}</span>`;
feedEl.insertBefore(el, feedEl.firstChild);
});
while (feedEl.children.length > MAX_FEED) feedEl.removeChild(feedEl.lastChild);
}
async function initFeed() {
try {
const { lines } = await (await api('GET', '/api/f2b/init')).json();
setFeedDot('ok');
addToFeed([...lines].reverse());
setInterval(pollFeed, 2000);
} catch {
setFeedDot('err');
setTimeout(initFeed, 4000);
}
}
async function pollFeed() {
try {
const { lines } = await (await api('GET', '/api/f2b/poll')).json();
if (lines.length) addToFeed(lines);
setFeedDot('ok');
} catch { setFeedDot('err'); }
}
// ── Multi-select filter ───────────────────────────────────────────
let activeJails = new Set();
function toggleJail(jail) {
if (jail === 'all') {
activeJails.clear();
} else {
activeJails.has(jail) ? activeJails.delete(jail) : activeJails.add(jail);
if (jail === 'scan' && activeJails.has('scan')) ensureScan();
}
updateFilterUI();
renderAll();
}
function updateFilterUI() {
document.querySelectorAll('#jail-filter button').forEach(btn => {
const j = btn.dataset.jail;
btn.classList.toggle('active',
j === 'all' ? activeJails.size === 0 : activeJails.has(j));
});
}
document.getElementById('jail-filter').addEventListener('click', e => {
const btn = e.target.closest('button');
if (btn) toggleJail(btn.dataset.jail);
});
// ── Active bans ───────────────────────────────────────────────────
let allBans = [];
async function loadBans() {
try {
allBans = await (await api('GET', '/api/bans')).json();
renderAll();
} catch (e) {
document.getElementById('main-summary').textContent = 'Error: ' + e.message;
}
}
// ── Scan data ─────────────────────────────────────────────────────
let scanData = [];
let scanLoaded = false;
let scanRunning = false;
async function ensureScan() {
if (scanLoaded || scanRunning) return;
scanRunning = true;
document.getElementById('main-summary').textContent = 'scanning…';
try {
const days = parseInt(document.getElementById('scan-days').value) || 3;
await api('POST', `/api/scan/start?days=${days}`);
pollScanResults();
} catch (e) {
document.getElementById('main-summary').textContent = 'scan error: ' + e.message;
scanRunning = false;
}
}
async function pollScanResults() {
try {
const job = await (await api('GET', '/api/scan/results')).json();
if (job.running) {
document.getElementById('main-summary').textContent = 'scanning…';
setTimeout(pollScanResults, 2000);
return;
}
if (job.error) throw new Error(job.error);
const minHits = parseInt(document.getElementById('scan-minhits').value) || 1;
const exclPrev = document.getElementById('scan-excl-prev').checked;
let results = job.results;
if (minHits > 1) results = results.filter(r => r.hits >= minHits);
if (exclPrev) results = results.filter(r => !r.previouslyBanned);
scanData = results;
scanLoaded = true;
scanRunning = false;
renderAll();
} catch (e) {
document.getElementById('main-summary').textContent = 'scan error: ' + e.message;
scanRunning = false;
}
}
async function refreshScan() {
scanLoaded = false;
scanData = [];
activeJails.add('scan');
updateFilterUI();
await ensureScan();
}
async function runAutoBan() {
const threshold = parseInt(document.getElementById('autoban-threshold').value) || 75;
const days = parseInt(document.getElementById('scan-days').value) || 3;
try {
await api('POST', '/api/auto-ban', { threshold, days });
alert(`Auto-ban running (threshold: ${threshold}, days: ${days}). Refresh in ~30s.`);
setTimeout(loadBans, 30000);
} catch (e) { alert('Auto-ban failed: ' + e.message); }
}
async function purgeLogs() {
if (!confirm('Truncate all nginx proxy access logs? This cannot be undone.')) return;
try {
const msg = await (await api('POST', '/api/purge-logs')).text();
alert(msg);
scanLoaded = false;
scanData = [];
renderAll();
} catch (e) { alert('Purge failed: ' + e.message); }
}
// ── Unified render ────────────────────────────────────────────────
function renderAll() {
const grid = document.getElementById('main-grid');
grid.innerHTML = '';
const showAll = activeJails.size === 0;
const showScan = showAll || activeJails.has('scan');
const banJails = new Set([...activeJails].filter(j => j !== 'scan'));
const showBans = showAll || banJails.size > 0;
let banCount = 0, scanCount = 0;
if (showBans) {
const bansToShow = banJails.size > 0
? allBans.filter(b => banJails.has(b.jail))
: allBans;
banCount = bansToShow.length;
bansToShow.forEach(b => grid.appendChild(makeBanCard(b)));
}
if (showScan && scanLoaded) {
scanCount = scanData.length;
scanData.forEach(d => grid.appendChild(makeScanCard(d)));
}
const parts = [];
if (showBans) parts.push(`${banCount} banned`);
if (showScan) parts.push(scanLoaded ? `${scanCount} suspicious` : 'scanning…');
document.getElementById('main-summary').textContent = parts.join(' · ');
}
// ── Ban card ──────────────────────────────────────────────────────
function makeBanCard(b) {
const card = document.createElement('div');
const color = scoreColor(b.score, b.jail);
card.className = 'card';
card.style.borderLeft = `3px solid ${color}`;
const scoreBadge = b.score != null
? `<span class="score-badge" style="color:${color};border-color:${color}">${b.score}</span>`
: '';
let meta = `<span>JAIL: ${esc(b.jail.toUpperCase())}</span>`;
if (b.jail !== 'whitelist' && b.jail !== 'exempt') {
meta += `<span>BANNED: ${b.banTime ? esc(b.banTime.slice(5,16)) : '—'}</span>`;
meta += `<span>EXPIRES: ${b.unbanTime ? esc(b.unbanTime.slice(5,16)) : '—'}</span>`;
}
if (b.country) meta += `<span>COUNTRY: ${esc(b.country)}</span>`;
if (b.note) meta += `<span class="good">NOTE: ${esc(b.note)}</span>`;
const actions = b.jail === 'whitelist'
? `<button class="btn-red" onclick="removeWhitelist('${esc(b.ip)}')">[REMOVE]</button>`
: b.jail === 'exempt'
? `<button class="btn-red" onclick="removeExemption('${esc(b.ip)}')">[REMOVE]</button>
<button class="btn-red" onclick="arrest('${esc(b.ip)}')">[ARREST]</button>
<button class="btn-amber" onclick="abuseCheck('${esc(b.ip)}',this)">[THREAT]</button>`
: `<button onclick="parole('${esc(b.ip)}','${esc(b.jail)}')">[PAROLE]</button>
<button class="btn-red" onclick="arrest('${esc(b.ip)}')">[ARREST]</button>
<a class="btn" href="/logs/${esc(b.ip)}" target="_blank">[RECORDS]</a>
<button class="btn-amber" onclick="abuseCheck('${esc(b.ip)}',this)">[THREAT]</button>`;
card.innerHTML =
`<div class="card-ip">${esc(b.ip)} ${scoreBadge}</div>` +
`<div class="card-meta">${meta}</div>` +
`<div class="card-actions">${actions}</div>`;
return card;
}
// ── Scan card ─────────────────────────────────────────────────────
function makeScanCard(d) {
const card = document.createElement('div');
card.className = 'card scan-card' + (d.previouslyBanned ? ' prev-banned' : '');
card.innerHTML =
`<div class="card-ip">${esc(d.ip)}<span class="scan-badge">SUSPICIOUS</span></div>` +
`<div class="card-meta">
<span class="hi">HITS: ${d.hits}</span>
<span class="muted">SITES: ${esc(d.sites.slice(0,3).join(', '))}</span>
${d.previouslyBanned ? `<span class="warn">PREV BANNED: ${d.banCount}x</span>` : ''}
</div>` +
`<div class="card-actions">
<a class="btn" href="/logs/${esc(d.ip)}" target="_blank">[RECORDS]</a>
<button class="btn-red" onclick="scanBan('${esc(d.ip)}',this)">[BAN]</button>
<button onclick="scanWL('${esc(d.ip)}',this)">[EXEMPT]</button>
<button class="btn-amber" onclick="scanAbuse('${esc(d.ip)}',this)">[THREAT]</button>
</div>`;
return card;
}
// ── Ban management ────────────────────────────────────────────────
async function parole(ip, jail) {
try { await api('POST', '/api/unban', { ip, jail }); await loadBans(); }
catch (e) { alert('Parole failed: ' + e.message); }
}
async function arrest(ip) {
try { await api('POST', '/api/ban', { ip }); await loadBans(); }
catch (e) { alert('Arrest failed: ' + e.message); }
}
async function removeWhitelist(ip) {
try { await api('DELETE', `/api/whitelist/${encodeURIComponent(ip)}`); await loadBans(); }
catch (e) { alert('Remove failed: ' + e.message); }
}
async function removeExemption(ip) {
try { await api('DELETE', `/api/exempt/${encodeURIComponent(ip)}`); await loadBans(); }
catch (e) { alert('Remove failed: ' + e.message); }
}
async function abuseCheck(ip, btn) {
btn.disabled = true; btn.textContent = '[…]';
try {
const d = await (await api('GET', `/api/check-abuse/${encodeURIComponent(ip)}`)).json();
alert(`AbuseIPDB: ${ip}\nScore: ${d.score}\nCountry: ${d.country || '—'}`);
await loadBans();
} catch (e) { alert('Check failed: ' + e.message); }
finally { btn.disabled = false; btn.textContent = '[THREAT]'; }
}
async function forceAbuseCheck() {
try {
await api('POST', '/api/force-abuse-check');
alert('Abuse checks running in background. Bans will refresh in ~5s.');
setTimeout(loadBans, 5000);
} catch (e) { alert('Failed: ' + e.message); }
}
function toggleNote() {
document.getElementById('note-wrap').style.display =
document.getElementById('action-select').value === 'whitelist' ? '' : 'none';
}
async function executeAction() {
const ip = document.getElementById('action-ip').value.trim();
const action = document.getElementById('action-select').value;
const note = document.getElementById('action-note')?.value.trim();
if (!ip) return;
try {
if (action === 'ban') await api('POST', '/api/ban', { ip });
else if (action === 'unban-all') await api('POST', '/api/unban-all', { ip });
else if (action === 'whitelist') await api('POST', '/api/whitelist', { ip, note });
else if (action === 'search') {
const q = ip.toLowerCase();
document.querySelectorAll('#main-grid .card').forEach(card => {
card.style.display =
card.querySelector('.card-ip').textContent.toLowerCase().includes(q) ? '' : 'none';
});
return;
}
document.getElementById('action-ip').value = '';
await loadBans();
} catch (e) { alert('Action failed: ' + e.message); }
}
async function scanBan(ip, btn) {
btn.disabled = true; btn.textContent = '[…]';
try {
await api('POST', '/api/ban', { ip });
btn.closest('.card').style.opacity = '.4';
btn.textContent = '[BANNED]';
loadBans();
} catch (e) { alert('Ban failed: ' + e.message); btn.disabled = false; btn.textContent = '[BAN]'; }
}
async function scanWL(ip, btn) {
btn.disabled = true; btn.textContent = '[…]';
try {
await api('POST', '/api/exempt', { ip });
btn.closest('.card').style.opacity = '.4';
btn.textContent = '[EXEMPTED]';
loadBans();
} catch (e) { alert('Exempt failed: ' + e.message); btn.disabled = false; btn.textContent = '[EXEMPT]'; }
}
async function scanAbuse(ip, btn) {
btn.disabled = true; btn.textContent = '[…]';
try {
const d = await (await api('GET', `/api/check-abuse/${encodeURIComponent(ip)}`)).json();
alert(`AbuseIPDB: ${ip}\nScore: ${d.score}\nCountry: ${d.country || '—'}`);
} catch (e) { alert('Check failed: ' + e.message); }
finally { btn.disabled = false; btn.textContent = '[THREAT]'; }
}
// ── Boot ──────────────────────────────────────────────────────────
initFeed();
loadBans();
</script>
</body>
</html>

555
dashboard/public/style.css Normal file
View File

@@ -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;
}

527
dashboard/server.js Normal file
View File

@@ -0,0 +1,527 @@
require('dotenv').config();
const express = require('express');
const fs = require('fs');
const path = require('path');
const rl = require('readline');
const net = require('net');
const { exec } = require('child_process');
const ipaddr = require('ipaddr.js');
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 EXEMPT_FILE = process.env.EXEMPT_FILE || '/data/exemptions.json';
const DEFAULT_DAYS = 3;
const ABUSE_KEY = process.env.ABUSEIPDB_API_KEY;
const AUTOBAN_THR = 75;
// ── 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[ \t]*=[ \t]*(.*)$/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 (/^ignoreip[ \t]*=/.test(lines[i]) && !lines[i].includes(ip)) {
lines[i] = lines[i].trimEnd() + ` ${ip}${note ? ` # ${note}` : ''}\n`;
break;
}
}
fs.writeFileSync(JAIL_LOCAL, lines.join('\n'));
// Live-update running fail2ban (no reload needed)
const jails = await getJails();
await Promise.all(jails.map(j => Promise.all([
run(`fail2ban-client set ${j} addignoreip ${ip}`).catch(() => {}),
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);
// Live-update running fail2ban (no reload needed)
const jails = await getJails();
await Promise.all(jails.map(j =>
run(`fail2ban-client set ${j} delignoreip ${ip}`).catch(() => {})
));
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 fail2ban ignoreip as "whitelist" (trusted — f2b won't monitor)
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),
});
});
// Append scan exemptions (reviewed — hidden from scan, f2b still watches)
readExemptions().forEach(({ ip, note }) => {
data.push({
ip, jail: 'exempt', duration: -1,
banTime: null, unbanTime: null,
score: abuseCache.get(ip)?.score ?? null,
country: abuseCache.get(ip)?.country ?? null,
note,
});
});
banCache = { data, ts: Date.now() };
return data;
}
async function banIP(ip) {
await run(`fail2ban-client set ${MANUAL_JAIL} banip ${ip}`);
banCache = null;
}
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); }
}
// ── Exemptions (scan-level: hide from scan results, fail2ban still watches) ───
function readExemptions() {
try {
if (!fs.existsSync(EXEMPT_FILE)) return [];
return JSON.parse(fs.readFileSync(EXEMPT_FILE, 'utf8'));
} catch { return []; }
}
function saveExemptions(list) {
fs.writeFileSync(EXEMPT_FILE, JSON.stringify(list, null, 2));
}
function isExempt(ip) {
return readExemptions().some(e => e.ip === ip);
}
function addExemption(ip, note = '') {
const list = readExemptions().filter(e => e.ip !== ip);
list.push({ ip, note, addedAt: new Date().toISOString() });
saveExemptions(list);
}
function removeExemption(ip) {
saveExemptions(readExemptions().filter(e => e.ip !== ip));
}
// ── Nginx log scanner ─────────────────────────────────────────────────────────
function isWhitelisted(ip) {
const wl = readIgnoreIP();
try {
return wl.some(entry => {
if (entry.includes('/')) {
const [range, bits] = ipaddr.parseCIDR(entry);
return ipaddr.parse(ip).match(range, bits);
}
return entry === 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) || isExempt(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 || (!net.isIPv4(ip) && !net.isIPv6(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.post('/api/exempt', (req, res) => {
const { ip, note } = req.body;
if (!ip) return res.status(400).send('ip required');
try { addExemption(ip, note || ''); banCache = null; res.send(`${ip} exempted from scan.`); }
catch (e) { res.status(500).send(e.message); }
});
app.delete('/api/exempt/:ip', (req, res) => {
try { removeExemption(req.params.ip); banCache = null; res.send(`${req.params.ip} removed from exemptions.`); }
catch (e) { res.status(500).send(e.message); }
});
app.get('/api/exemptions', (req, res) => res.json(readExemptions()));
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
let badge = '';
if (hist) badge = `<div class="hi" style="margin:.5rem 0">&#9888; Previously banned ${hist.banCount}x &mdash; last ${new Date(hist.lastSeen).toLocaleString()}</div>`;
res.send(`<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><title>Logs: ${esc(ip)}</title><link rel="stylesheet" href="/style.css"></head>
<body><div class="screen">
<pre class="ascii-banner"> F2B // IP LOOKUP</pre>
<div class="tagline">// NGINX ACCESS LOGS FOR ${esc(ip)}</div>
<h2 class="section">// ${esc(ip)}</h2>
<div class="box">
<div class="box-title">// LOG ENTRIES</div>
${badge}
<p class="muted">Entries in current scan window: ${logs.length}</p>
<pre>${logs.map(esc).join('\n') || '(no entries — run a scan first)'}</pre>
</div>
<div class="prompt">_ <span class="blink">&#9608;</span></div>
<footer>F2B Control Center | :${process.env.PORT || 4000}</footer>
</div></body></html>`);
});
// ── 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}`)
);

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
# F2B Control Center — edit values below, then: docker compose up -d
services:
npm:
image: jc21/nginx-proxy-manager:latest
container_name: nginx-proxy-manager
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "81:81"
volumes:
- ./data/npm:/data
- ./data/letsencrypt:/etc/letsencrypt
f2b-control-center:
build: .
container_name: f2b-control-center
restart: unless-stopped
depends_on:
- npm
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
environment:
PORT: "4000"
SUBNETS_TO_IGNORE: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
# ABUSEIPDB_API_KEY: "" # enables threat scoring & auto-ban
# CF_EMAIL: "" # Cloudflare account email (enables WAF banning)
# CF_APIKEY: "" # Cloudflare Global API Key (enables WAF banning)
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:
- ./data/npm/logs:/nginx-logs
- f2b-data:/data
- f2b-config:/etc/fail2ban
volumes:
f2b-data:
f2b-config:

66
entrypoint.sh Normal file
View File

@@ -0,0 +1,66 @@
#!/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..."
# ── Kernel module: xt_string (required for X-Forwarded-For matching) ──────────
if modprobe xt_string 2>/dev/null; then
echo "[f2b-cc] xt_string kernel module loaded OK"
else
echo "[f2b-cc] WARNING: xt_string module unavailable — X-Forwarded-For iptables rules will NOT work"
echo "[f2b-cc] Run 'modprobe xt_string' on the Docker host to fix this."
fi
# ── DOCKER-USER chain (must exist for the ban action to insert rules) ─────────
if iptables -L DOCKER-USER -n > /dev/null 2>&1; then
echo "[f2b-cc] DOCKER-USER iptables chain found OK"
else
echo "[f2b-cc] DOCKER-USER chain missing — creating it"
iptables -N DOCKER-USER 2>/dev/null || true
fi
# ── 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/
# Cloudflare credentials present → use the CF-enabled jail config
if [ -n "${CF_EMAIL}" ] && [ -n "${CF_APIKEY}" ]; then
echo "[f2b-cc] CF_EMAIL + CF_APIKEY detected — enabling Cloudflare jail config"
cp /etc/f2b-defaults/jail.cloudflare.local /etc/fail2ban/jail.local
fi
# 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 and has at least one file matching the glob.
# fail2ban requires a matching file to exist at startup — create a placeholder
# if NPM hasn't generated any proxy host logs yet.
mkdir -p /nginx-logs
if ! ls /nginx-logs/proxy-host-*_access.log > /dev/null 2>&1; then
echo "[f2b-cc] No NPM logs found — creating placeholder so fail2ban can start."
touch /nginx-logs/proxy-host-placeholder_access.log
fi
# ── Start supervisord (manages fail2ban + dashboard) ─────────────────────────
echo "[f2b-cc] Starting supervisord (fail2ban + dashboard)..."
exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf

View File

@@ -0,0 +1,33 @@
[Definition]
# Blocks/unblocks IPs at the Cloudflare account level via the Access Rules API.
# Bans are enforced by Cloudflare before traffic reaches your server.
# Enable by setting CF_EMAIL + CF_APIKEY in docker-compose.yml.
#
# NOTE: Uses the user-level API — applies across all zones on your account.
# For zone-scoped rules replace the URL with:
# https://api.cloudflare.com/client/v4/zones/<ZONE_ID>/firewall/access_rules/rules
actionban = curl -s -X POST \
-H "X-Auth-Email: %(cf_email)s" \
-H "X-Auth-Key: %(cf_apikey)s" \
-H "Content-Type: application/json" \
-d "{\"mode\":\"block\",\"configuration\":{\"target\":\"ip\",\"value\":\"<ip>\"},\"notes\":\"f2b-cc: <name>\"}" \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules" \
> /dev/null 2>&1 || true
actionunban = RULE_ID=$(curl -s \
-H "X-Auth-Email: %(cf_email)s" \
-H "X-Auth-Key: %(cf_apikey)s" \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules?configuration_target=ip&configuration_value=<ip>&mode=block&page=1&per_page=1" | \
jq -r '.result[0].id // empty' 2>/dev/null) ; \
[ -n "$RULE_ID" ] && \
curl -s -X DELETE \
-H "X-Auth-Email: %(cf_email)s" \
-H "X-Auth-Key: %(cf_apikey)s" \
"https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules/$RULE_ID" \
> /dev/null 2>&1 || true
[Init]
cf_email =
cf_apikey =

View File

@@ -0,0 +1,15 @@
[Definition]
# Three rules per ban:
# 1. DOCKER-USER source: blocks direct connections from the banned IP to any container
# 2. DOCKER-USER xt_string: blocks CDN-proxied requests where real IP is in X-Forwarded-For
# (requires xt_string kernel module on the host: modprobe xt_string)
# 3. INPUT: blocks direct connections to host services
actionban = iptables-nft -I DOCKER-USER -s <ip> -j DROP
iptables-nft -I DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP 2>/dev/null || true
iptables-nft -A INPUT -s <ip> -j DROP
actionunban = iptables-nft -D DOCKER-USER -s <ip> -j DROP || true
iptables-nft -D DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP 2>/dev/null || true
iptables-nft -D INPUT -s <ip> -j DROP || true

View File

@@ -0,0 +1,18 @@
[Definition]
# ── NPM access log format (current) ──────────────────────────────────────────
# [DD/Mon/YYYY:HH:MM:SS +0000] - STATUS STATUS - METHOD SCHEME HOST "PATH"
# [Client REAL_IP] [Length N] [Gzip N] [Sent-to IP] "UA" "REFERER"
#
# fail2ban strips the timestamp before applying failregex, leaving:
# " - STATUS STATUS - METHOD SCHEME HOST "PATH" [Client IP] ... "UA" ..."
#
# UA appears after [Sent-to ...] so .* is used between <HOST> and the UA match.
#
# Test against your logs:
# fail2ban-regex /nginx-logs/proxy-host-1_access.log /etc/fail2ban/filter.d/badbot.conf
# ─────────────────────────────────────────────────────────────────────────────
failregex = - \d+ \d+ - \S+ \S+ \S+ "[^"]*" \[Client <HOST>\].*"(?i:masscan|zgrab|python-requests|go-http-client/1\.1|nuclei|sqlmap|dirbuster|gobuster|nikto|wfuzz|metasploit|libwww-perl|wpscan|nmap|zmeu|jorgee|shodan\.com|censys|binaryedge|internet-measurement|netcraft|strikeready|dataforseo|semrushbot|ahrefsbot|mj12bot|dotbot)[^"]*"
ignoreregex =

View File

@@ -0,0 +1,20 @@
[Definition]
# ── NPM access log format (current) ──────────────────────────────────────────
# [DD/Mon/YYYY:HH:MM:SS +0000] - STATUS STATUS - METHOD SCHEME HOST "PATH"
# [Client REAL_IP] [Length N] [Gzip N] [Sent-to IP] "UA" "REFERER"
#
# fail2ban strips the timestamp before applying failregex, leaving:
# " - STATUS STATUS - METHOD SCHEME HOST "PATH" [Client IP] ..."
#
# Bans IPs generating excessive 4xx/5xx errors.
# Default jail: 15 errors in 5 minutes (tunable in jail.local).
#
# Test against your logs:
# fail2ban-regex /nginx-logs/proxy-host-1_access.log /etc/fail2ban/filter.d/http-errors.conf
# ─────────────────────────────────────────────────────────────────────────────
failregex = - [45]\d\d \d+ - \S+ \S+ \S+ "[^"]*" \[Client <HOST>\]
# Exclude common benign 404s to reduce noise.
ignoreregex = - 404 \d+ - \S+ \S+ \S+ "/(?:favicon\.ico|robots\.txt|sitemap\.xml|apple-touch-icon[^"]*|\.well-known/[^"]*)" \[Client <HOST>\]

View File

@@ -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 <IP>`.
#
# 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 =

View File

@@ -0,0 +1,19 @@
[Definition]
# ── NPM access log format (current) ──────────────────────────────────────────
# [DD/Mon/YYYY:HH:MM:SS +0000] - STATUS STATUS - METHOD SCHEME HOST "PATH"
# [Client REAL_IP] [Length N] [Gzip N] [Sent-to IP] "UA" "REFERER"
#
# fail2ban strips the timestamp before applying failregex, leaving:
# " - STATUS STATUS - METHOD SCHEME HOST "PATH" [Client IP] ..."
#
# Bans IPs probing for well-known vulnerable paths.
# Default jail: 3 hits in 30 minutes → 48h ban (very aggressive, intentionally).
#
# Test against your logs:
# fail2ban-regex /nginx-logs/proxy-host-1_access.log /etc/fail2ban/filter.d/npm-probe.conf
# ─────────────────────────────────────────────────────────────────────────────
failregex = - \d+ \d+ - \S+ \S+ \S+ "/(?:\.env[^"]*|\.git[^"]*|wp-login\.php[^"]*|wp-admin[^"]*|xmlrpc\.php[^"]*|phpmyadmin[^"]*|pma/[^"]*|adminer[^"]*|admin\.php[^"]*|config\.php[^"]*|setup\.php[^"]*|install\.php[^"]*|actuator[^"]*|console[^"]*|manager/html[^"]*|invoker/[^"]*|solr/[^"]*|geoserver/[^"]*|boaform/[^"]*|HNAP1[^"]*|cgi-bin/[^"]*|shell\.php[^"]*|cmd\.php[^"]*|eval-stdin\.php[^"]*)[^"]*" \[Client <HOST>\]
ignoreregex =

View File

@@ -0,0 +1,74 @@
# ── F2B Control Center — jail configuration (Cloudflare) ─────────────────────
# Installed when CF_EMAIL + CF_APIKEY are set in docker-compose.yml.
# Adds the Cloudflare WAF action to every jail alongside iptables.
# Credentials are injected from environment — not stored here.
# ─────────────────────────────────────────────────────────────────────────────
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
allowipv6 = auto
# Populated by entrypoint from SUBNETS_TO_IGNORE env var on first run.
# Updated live by the dashboard — do not edit by hand.
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
# Cloudflare credentials — set CF_EMAIL and CF_APIKEY in docker-compose.yml.
cf_email = %(ENV[CF_EMAIL])s
cf_apikey = %(ENV[CF_APIKEY])s
# ── NPM: Bad Bots ─────────────────────────────────────────────────────────────
[badbot]
enabled = true
filter = badbot
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 24h
findtime = 10m
maxretry = 3
action = docker-npm
cloudflare[cf_email="%(cf_email)s", cf_apikey="%(cf_apikey)s"]
# ── NPM: HTTP Error Spamming ──────────────────────────────────────────────────
[http-errors]
enabled = true
filter = http-errors
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 1h
findtime = 5m
maxretry = 15
action = docker-npm
cloudflare[cf_email="%(cf_email)s", cf_apikey="%(cf_apikey)s"]
# ── NPM: Exploit Probing ──────────────────────────────────────────────────────
[npm-probe]
enabled = true
filter = npm-probe
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 48h
findtime = 30m
maxretry = 3
action = docker-npm
cloudflare[cf_email="%(cf_email)s", cf_apikey="%(cf_apikey)s"]
# ── Manual Bans ───────────────────────────────────────────────────────────────
[manual-bans]
enabled = true
filter = manual-bans
logpath = /dev/null
bantime = -1
findtime = 1d
maxretry = 1
action = docker-npm
cloudflare[cf_email="%(cf_email)s", cf_apikey="%(cf_apikey)s"]
# ── Recidive — repeat offenders ───────────────────────────────────────────────
[recidive]
enabled = false
filter = recidive
logpath = /var/log/fail2ban.log
bantime = 7d
findtime = 1d
maxretry = 3
action = docker-npm
cloudflare[cf_email="%(cf_email)s", cf_apikey="%(cf_apikey)s"]

69
fail2ban/jail.local Normal file
View File

@@ -0,0 +1,69 @@
# ── F2B Control Center — jail configuration ───────────────────────────────────
# Installed to /etc/fail2ban/jail.local on first container start.
# Persisted in the f2b-config Docker volume — survives image updates.
#
# CLOUDFLARE: set CF_EMAIL + CF_APIKEY in docker-compose.yml to enable WAF banning.
# ─────────────────────────────────────────────────────────────────────────────
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
allowipv6 = auto
# Populated by entrypoint from SUBNETS_TO_IGNORE env var on first run.
# Updated live by the dashboard — do not edit by hand.
ignoreip = 127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16
# ── NPM: Bad Bots ─────────────────────────────────────────────────────────────
[badbot]
enabled = true
filter = badbot
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 24h
findtime = 10m
maxretry = 3
action = docker-npm
# ── NPM: HTTP Error Spamming ──────────────────────────────────────────────────
[http-errors]
enabled = true
filter = http-errors
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 1h
findtime = 5m
maxretry = 15
action = docker-npm
# ── NPM: Exploit Probing ──────────────────────────────────────────────────────
[npm-probe]
enabled = true
filter = npm-probe
logpath = /nginx-logs/proxy-host-*_access.log
bantime = 48h
findtime = 30m
maxretry = 3
action = docker-npm
# ── Manual Bans ───────────────────────────────────────────────────────────────
# Populated via dashboard or: fail2ban-client set manual-bans banip <IP>
[manual-bans]
enabled = true
filter = manual-bans
logpath = /dev/null
bantime = -1
findtime = 1d
maxretry = 1
action = docker-npm
# ── Recidive — repeat offenders ───────────────────────────────────────────────
# Escalates bans to 7d for IPs that get banned 3+ times within a day.
# Enable once your other jails have been running for a while.
[recidive]
enabled = false
filter = recidive
logpath = /var/log/fail2ban.log
bantime = 7d
findtime = 1d
maxretry = 3
action = docker-npm

11
healthcheck.sh Normal file
View File

@@ -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

22
logwatch.sh Normal file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# ── Log file watcher ──────────────────────────────────────────────────────────
# Polls /nginx-logs every 30s. If a new proxy-host-*_access.log appears,
# reloads fail2ban so it picks up the new file immediately.
# ─────────────────────────────────────────────────────────────────────────────
LOG_DIR="${LOG_DIR:-/nginx-logs}"
INTERVAL=30
known=$(ls "$LOG_DIR"/proxy-host-*_access.log 2>/dev/null | sort | tr '\n' ':')
echo "[logwatch] Watching $LOG_DIR for new proxy-host log files..."
while true; do
sleep "$INTERVAL"
current=$(ls "$LOG_DIR"/proxy-host-*_access.log 2>/dev/null | sort | tr '\n' ':')
if [ "$current" != "$known" ]; then
echo "[logwatch] New log file(s) detected — reloading fail2ban"
fail2ban-client reload 2>&1 | sed 's/^/[logwatch] /'
known="$current"
fi
done

67
supervisor.conf Normal file
View File

@@ -0,0 +1,67 @@
# ── 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
# ── log watcher ───────────────────────────────────────────────────────────────
[program:logwatch]
command=/logwatch.sh
autostart=true
autorestart=true
startretries=3
startsecs=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
priority=15
# ── dashboard ─────────────────────────────────────────────────────────────────
[program:dashboard]
command=/usr/local/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"