Introduces a unified security event store and HTTP API that ingests events from any of the configured sources (API audit, Caddy access log, fail2ban, shared_bans, future remote agents) and surfaces them in the dashboard. New files: src/security/event-store.js JSONL-backed store + in-memory query index src/security/host-registry.js Registered hosts with per-host API keys src/security/event-workers.js Tail-followers for Caddy/fail2ban/shared_bans logs routes/security.js Events, hosts, ingest, SSE stream endpoints status/js/security-center.js Dashboard modal with Overview/Events/Hosts tabs SECURITY-FEATURE.md Full feature documentation DEAD-CODE.md, DUP-CODE.md, HARDENING.md Prior audits Modified: src/app.js Mount /api/v1/security/* src/utilities/middleware.js Add ingest endpoints to PUBLIC_ROUTES src/security/audit-logger.js Mirror audit events into security store server.js Start security workers on boot status/build.js Bundle security-center.js status/index.html Add Security button to nav
14 KiB
DashCaddy Security Center — Feature Documentation
Built: 2026-07-13 Author: Sami Ahmed Code: assistant implementation Scope: Medium — multi-source ingest, no agent binary yet
What is the Security Center?
A unified security event pipeline inside DashCaddy that collects, indexes, and visualizes security-relevant events from every source you can plug into it. Today: API events, Caddy access logs, fail2ban bans, shared_bans promotions. Tomorrow: remote DashCaddy agents, syslog feeds, anything that emits events over HTTPS.
The goal: one place to ask "who is accessing what, where, and when?" across every service and every host you run DashCaddy on.
Architecture
┌──────────────────────────────────────────────────────────────────────────┐
│ DashCaddy (Central Instance) │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ audit-logger │ │ Caddy log tail │ │ fail2ban tail │ ... │
│ │ (API events) │ │ (HTTP requests) │ │ (SSH bans) │ │
│ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └────────────────────┼────────────────────┘ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Security Event │ │
│ │ Store (JSONL) │ │
│ │ + In-memory index │ │
│ └───────────┬───────────┘ │
│ │ │
│ ┌───────────┴───────────┐ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ REST API │ │ SSE Stream │ │
│ │ /security/ │ │ /events/ │ │
│ │ events │ │ stream │ │
│ │ hosts │ └──────┬───────┘ │
│ │ ingest │ │ │
│ └──────┬───────┘ │ │
│ │ │ │
└───────────────────┼───────────────────────┼───────────────────────────────┘
│ │
┌───────────┴───────────┐ │
│ │ │
▼ ▼ ▼
┌──────────┐ ┌─────────────────────────┐
│ Dashboard│ │ Remote DashCaddy Agents│
│ (UI) │ │ (POST /events/ingest) │
└──────────┘ └─────────────────────────┘
Three pillars:
- Event ingest — multiple sources feed a single store via a normalized schema
- Query API — REST endpoints + Server-Sent Events for live tail
- Dashboard UI — Overview / Events / Hosts tabs
Files added/changed
New files
| File | Purpose |
|---|---|
src/security/event-store.js |
JSONL-backed append-only store + in-memory query index |
src/security/host-registry.js |
Registered hosts/locations with per-host API keys |
src/security/event-workers.js |
Tail-followers for Caddy access log, fail2ban log, shared_bans apply log |
routes/security.js |
Express route factory: events, hosts, ingest, SSE stream |
status/js/security-center.js |
Dashboard modal: Overview / Events / Hosts tabs with live tail |
Modified files
| File | Change |
|---|---|
src/app.js |
Mounts /api/v1/security/* |
src/utilities/middleware.js |
Adds /api/v1/security/events/ingest and /events/batch to PUBLIC_ROUTES (per-host Bearer auth replaces TOTP) |
src/security/audit-logger.js |
Mirrors API audit events into the security store |
server.js |
Starts the security event workers on boot |
status/build.js |
Bundles security-center.js into features.js |
status/index.html |
Adds "🛡️ Security" button to dashboard nav |
Event schema
{
"id": "uuid-v4",
"ts": "2026-07-13T01:35:55.123Z",
"source_host": "dns2", // hostname or registered host id
"source_type": "api" | "caddy" | "fail2ban" | "shared-bans" | "agent" | "syslog",
"actor": "192.0.2.1", // IP, user, agent_id — null is allowed
"target": "/api/v1/auth/login", // endpoint, service id, host — null is allowed
"action": "auth.login", // free-form but stable per source_type
"outcome": "success" | "denied" | "blocked" | "rate-limited" | "error" | "unknown",
"severity": "info" | "notice" | "warn" | "error" | "critical",
"message": "human-readable one-liner",
"metadata": { ... } // free-form, source-specific
}
Severity semantics:
| Level | Meaning | Examples |
|---|---|---|
info |
Normal operation | API GET, successful login, shared_bans applied |
notice |
Worth a glance | failed login attempt, ban event, config change |
warn |
Attention needed | 401/403 on sensitive endpoint, auth.totp-disable, container.delete |
error |
Something failed | 5xx HTTP, dependency failure |
critical |
Active threat | (not auto-emitted in v1 — reserved for v2 alerting engine) |
API surface
All under /api/v1/security/*. Auth: TOTP/JWT/API-key via existing middleware, EXCEPT /events/ingest and /events/batch which use a per-host Bearer token.
Events
| Method | Path | Purpose |
|---|---|---|
| GET | /events |
List/query events with filters: source_type, source_host, severity, outcome, actor, actor_prefix, action, target, since, until. Pagination via limit/offset. |
| GET | /events/stats |
Aggregations: counts by source/severity/host, top actors, top targets. Use ?since=ISO for a time window. |
| GET | /events/stream |
Server-Sent Events for live tail. Initial payload = last 20 events. Subsequent payloads = new events as they happen. |
| GET | /events/:id |
Single event by id |
| POST | /events/ingest |
Single event ingest (per-host Bearer auth) |
| POST | /events/batch |
Batch ingest, max 500 events per request (per-host Bearer auth) |
Hosts
| Method | Path | Purpose |
|---|---|---|
| GET | /hosts |
List all registered hosts |
| POST | /hosts |
Register new host. Returns api_key once — caller must store it. |
| GET | /hosts/:id |
Host details |
| PATCH | /hosts/:id |
Update label, type, meta, enabled |
| DELETE | /hosts/:id |
Deregister host. Events already received remain. (Cannot delete self.) |
| GET | /hosts/:id/health |
Last seen, event count (24h), severity breakdown, online/stale status |
| POST | /hosts/:id/rotate-key |
Returns 501 in v1 — to rotate, deregister + re-register. |
Configuring the event workers
Caddy access log
The Caddy worker reads /var/log/caddy/access.log. To use it, configure Caddy to log in JSON format:
# In your Caddyfile global options:
{
log default {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 10
}
format json
}
}
Then reload Caddy. The worker will pick up new lines automatically (it persists its byte offset across restarts).
fail2ban log
Reads /var/log/fail2ban.log. Default location, no config needed. Captures both Ban and Unban events.
shared_bans apply log
Reads /var/log/shared-bans-apply.log. Default location, no config needed. Emits one event per "Applied N entries" line.
Override paths via env
export CADDY_ACCESS_LOG=/custom/path/caddy.log
export FAIL2BAN_LOG=/custom/path/fail2ban.log
export SHARED_BANS_LOG=/custom/path/shared-bans-apply.log
export DATA_DIR=/opt/dashcaddy/data # for offset state files
export SECURITY_EVENT_LOG_FILE=/opt/dashcaddy/data/security-events.jsonl
export SECURITY_HOSTS_FILE=/opt/dashcaddy/data/security-hosts.json
Dashboard UI
Click 🛡️ Security in the dashboard toolbar to open the Security Center.
Overview tab
- 5 stat cards: events (24h), warnings, errors, denied, hosts
- Top Actors (24h) — IPs / users hitting your services most
- Top Targets (24h) — endpoints most-hit
Events tab
- Filterable by source_type, severity, source_host, actor (prefix)
- Live-tail checkbox — toggles SSE stream
- Color-coded by severity
- Auto-refreshes on new events when live-tail is on
Hosts tab
- List of registered hosts with status dot (🟢 online / 🟡 stale / ⚪ never-seen / 🔴 disabled)
- Click "➕ Register Host" to add a new location
- api_key is shown exactly once at registration time, in a dialog the user must save
- Cannot delete the
selfhost from the UI
Adding a remote DashCaddy agent (v2 design)
The remote-agent path is already wired. To onboard a new DashCaddy location:
- Open the Security Center on the central instance
- Hosts tab → Register Host → id=
nas1, label="Synology NAS", type="dashcaddy" - Save the displayed
api_key - On the remote host, run:
curl -X POST https://central.sami/api/v1/security/events/ingest \ -H "Authorization: Bearer dca_xxx..." \ -H "Content-Type: application/json" \ -d '{ "source_type": "agent", "actor": "1.2.3.4", "target": "/volume1/web/login", "action": "auth.login", "outcome": "denied", "severity": "warn", "message": "Failed admin login" }' - The remote host now appears in the Security Center's Hosts tab
- Events show up in the Events tab tagged with
source_host=nas1
A standalone DCA (DashCaddy Agent) binary that tails /var/log/auth.log, /var/log/nginx/access.log, etc. is v2 work.
Performance & limits
| Metric | v1 limit | Where it hurts at scale |
|---|---|---|
| Events in memory | 10,000 | Querying ?limit=10000 works; going beyond this hits only disk |
| Events on disk | 100,000 (rotated) | Beyond this, oldest events get trimmed during _maybeTrim() |
| Batch ingest size | 500 events/request | Adjustable in routes/security.js if needed |
| SSE stream idle timeout | 30s heartbeat | Browser auto-reconnects |
| Concurrent SSE clients | unbounded (each holds 1 HTTP connection) | For v2, add per-client cap |
If you grow past 100k events on disk, switch the store to SQLite. The current JSONL design is intentionally simple for v1.
What I deliberately did NOT build
These are real features that you may want next, but I scoped them out to ship something working today:
- ❌ Alerting engine — rules like "5+ failures from one IP in 60s → notify" — v2
- ❌ Active ban-from-UI —
/api/v1/security/actions/banto push to shared_bans — v2 - ❌ GeoIP enrichment — translate IPs to countries on ingest — v2
- ❌ DCA agent binary — standalone Node.js process that tails arbitrary log files — v2
- ❌ Syslog UDP/TCP listener — receive syslog directly on port 514 — v2
- ❌ Per-IP timeline view — click an IP, see every event from them across all sources — v2
- ❌ Hot-archive / cold-archive tiering — keep 30 days hot, compress older to monthly files — v2
Testing performed (2026-07-13)
| Test | Result |
|---|---|
| Event store append + query + stats | ✅ PASS — 5 events appended, queried by severity, stats aggregated correctly |
| Persistence across "restart" | ✅ PASS — events survive reload from JSONL |
| Host registry + auth | ✅ PASS — self-registered on first boot, Bearer-token auth round-trip works |
| Caddy log worker (mock log) | ✅ PASS — 3 events emitted with correct severity (401→warn, 200→info) |
| fail2ban log worker (mock log) | ✅ PASS — Ban/Unban events emitted |
| shared_bans log worker (mock log) | ✅ PASS — "Applied N entries" event emitted |
| All routes load without syntax error | ✅ PASS |
| Routes factory returns Express Router | ✅ PASS |
| audit-logger still loads after changes | ✅ PASS |
Open questions / decisions to make
- Where should the api_key for a remote host live in storage? Currently it's returned once to the human operator, who must save it. A future "central-admin pulls from agent via reverse-channel" would be more secure but more complex.
- Should the Caddy access log parser be on by default? It requires Caddy to log JSON, which is a config change. The worker gracefully no-ops if the file doesn't exist.
- Event retention policy. Current default is 100k events on disk ≈ ~1 year at current volume, less under attack. Increase
SECURITY_EVENT_MAX_DISKif needed.
This document lives at /root/dashcaddy/SECURITY-FEATURE.md. Files committed as part of this build are listed in section "Files added/changed" above.