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
300 lines
14 KiB
Markdown
300 lines
14 KiB
Markdown
# 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:**
|
||
|
||
1. **Event ingest** — multiple sources feed a single store via a normalized schema
|
||
2. **Query API** — REST endpoints + Server-Sent Events for live tail
|
||
3. **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
|
||
|
||
```json
|
||
{
|
||
"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:
|
||
|
||
```caddyfile
|
||
# 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
|
||
|
||
```bash
|
||
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 `self` host from the UI
|
||
|
||
---
|
||
|
||
## Adding a remote DashCaddy agent (v2 design)
|
||
|
||
The remote-agent path is **already wired**. To onboard a new DashCaddy location:
|
||
|
||
1. Open the Security Center on the central instance
|
||
2. Hosts tab → Register Host → id=`nas1`, label="Synology NAS", type="dashcaddy"
|
||
3. Save the displayed `api_key`
|
||
4. On the remote host, run:
|
||
```bash
|
||
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"
|
||
}'
|
||
```
|
||
5. The remote host now appears in the Security Center's Hosts tab
|
||
6. 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/ban` to 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
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **Event retention policy.** Current default is 100k events on disk ≈ ~1 year at current volume, less under attack. Increase `SECURITY_EVENT_MAX_DISK` if 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.* |