Add Security Center — multi-source event pipeline with dashboard UI
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

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
This commit is contained in:
hermes
2026-07-13 02:28:56 -07:00
parent f405186eb8
commit c9d067c2f0
15 changed files with 2387 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
# DashCaddy Dead Code Report
> **Generated:** 2026-07-13
> **Scope:** `123` source files, `55` exported names
> **Total source LOC:** 36,231
## Summary
| Category | Count |
|---|---:|
| Dead exports (defined, never imported) | 11 |
| Unused files (no importer) | 7 |
| Large local dead functions (30+ lines, never called) | 0 |
## ⚠️ Caveats
This is a static analysis pass — every finding should be verified before deletion:
- **Entry points** (`server.js`, `src/app.js`, mounted route files) are exempted from 'unused file' check
- **Re-exports** via `module.exports = { X }` look like dead exports unless we track which file imports the whole module
- **Framework callbacks** (Express middleware, error handlers, lifecycle hooks) often look unused but aren't
- **Side-effect imports** (`require('./foo')` for side effects) aren't tracked here
- **Dynamic requires** (`require(variableName)`) won't be detected
Treat this as a TO-DO list, not a delete list. Each finding needs a human check.
## Confidence Classification
- **6 high-confidence** dead exports (no obvious dynamic load path)
- **5 medium-confidence** dead exports (might be loaded via registry / factory / dynamic require)
## 1. Dead Exports
Symbols that are defined (and exported) but never imported elsewhere in the codebase.
| Symbol | Defined in | Confidence |
|---|---|---|
| `BUNDLED_WORKFLOWS` | `src/recipes/bundled-workflows.js`:575 | high |
| `DEFAULT_LIMIT` | `src/utilities/pagination.js`:53 | high |
| `MAX_LIMIT` | `src/utilities/pagination.js`:53 | high |
| `RFC2136Provider` | `src/dns/dns-providers/rfc2136.js`:383 | high |
| `SelfUpdater` | `src/docker/self-updater.js`:790 | high |
| `readTextFile` | `src/utilities/fs-helpers.js`:65 | high |
| `CloudflareDNSProvider` | `src/dns/dns-providers/cloudflare.js`:269 | medium |
| `DEFAULT_POLICY` | `src/managers/auto-restart-manager.js`:503 | medium |
| `ManualDNSProvider` | `src/dns/dns-providers/manual.js`:93 | medium |
| `PREMIUM_FEATURES` | `src/managers/license-manager.js`:494 | medium |
| `TechnitiumDNSProvider` | `src/dns/dns-providers/technitium.js`:507 | medium |
## 2. Unused Files
Files not required by any other file in the source tree. Entry points and mounted route files are exempted.
| File | Size |
|---|---|
| `routes/context.js` | 4,940 bytes |
| `src/dns/dns-providers/cloudflare.js` | 9,816 bytes |
| `src/dns/dns-providers/manual.js` | 2,471 bytes |
| `src/dns/dns-providers/rfc2136.js` | 13,135 bytes |
| `src/dns/dns-providers/technitium.js` | 16,607 bytes |
| `src/managers/license-keygen.js` | 11,024 bytes |
| `src/utils/index.js` | 492 bytes |
## 3. Local Dead Functions (≥ 30 lines)
Top-level functions defined but never called within the file or from any other file. Smaller helpers are not flagged.
| Function | File | Lines |
|---|---|---:|
+125
View File
@@ -0,0 +1,125 @@
# DashCaddy Duplicate Code Report
> **Generated:** 2026-07-13
> **Functions scanned:** 107 (≥200 chars body length)
> **Exact-duplicate groups:** 10
## Methodology
1. Extract every top-level `function X() { ... }` declaration
2. Skip functions < 200 chars (helpers, getters, trivial wrappers)
3. Normalize: strip comments, collapse whitespace, replace all identifiers with placeholder
4. SHA-1 the normalized body → identical hashes = duplicate bodies
## ⚠️ Caveats
- **Anonymous functions and arrow functions are not captured** (regex matches `function name(` only)
- **Class methods are not captured** (would need AST parser)
- **Near-duplicates with renamed variables are flagged as the same** (that's the point — after normalization, only structure differs)
- **`module.exports` factory functions are common and look similar** — many route files have a 5-line wrapper like `module.exports = function(ctx) { const router = express.Router(); ... return router; }`. These will show as duplicate groups.
## Exact Duplicate Groups
Functions whose bodies are byte-identical after normalization (ignoring comments, whitespace, and identifier names).
| Hash | Count | Functions |
|---|---:|---|
| `5a3372b656b7` | 2 | `base32Encode`, `base32Encode` |
| `9550727efdf2` | 2 | `base32Decode`, `base32Decode` |
| `e00959ade524` | 2 | `getSecret`, `getSecret` |
| `09498fd55b60` | 2 | `initSecret`, `initSecret` |
| `0f89a717e703` | 2 | `generateCode`, `generateCode` |
| `d92f81854134` | 2 | `parseCode`, `parseCode` |
| `8c3ecfea7f7d` | 2 | `parsePayload`, `parsePayload` |
| `fc844dbaca2f` | 2 | `verifyCode`, `verifyCode` |
| `221e46c497d9` | 2 | `main`, `main` |
| `9573dd3cd485` | 2 | `formatBytes`, `formatBytes` |
### Top Groups (Detail)
#### Hash `5a3372b656b7` (2 copies)
- `src/managers/license-keygen.js:33``base32Encode()` (374 chars)
- `license-keygen.js:33``base32Encode()` (374 chars)
#### Hash `9550727efdf2` (2 copies)
- `src/managers/license-keygen.js:48``base32Decode()` (415 chars)
- `license-keygen.js:48``base32Decode()` (415 chars)
#### Hash `e00959ade524` (2 copies)
- `src/managers/license-keygen.js:62``getSecret()` (216 chars)
- `license-keygen.js:62``getSecret()` (216 chars)
#### Hash `09498fd55b60` (2 copies)
- `src/managers/license-keygen.js:70``initSecret()` (597 chars)
- `license-keygen.js:70``initSecret()` (597 chars)
#### Hash `0f89a717e703` (2 copies)
- `src/managers/license-keygen.js:83``generateCode()` (1331 chars)
- `license-keygen.js:83``generateCode()` (1331 chars)
#### Hash `d92f81854134` (2 copies)
- `src/managers/license-keygen.js:118``parseCode()` (523 chars)
- `license-keygen.js:118``parseCode()` (523 chars)
#### Hash `8c3ecfea7f7d` (2 copies)
- `src/managers/license-keygen.js:135``parsePayload()` (443 chars)
- `license-keygen.js:135``parsePayload()` (443 chars)
#### Hash `fc844dbaca2f` (2 copies)
- `src/managers/license-keygen.js:148``verifyCode()` (1367 chars)
- `license-keygen.js:148``verifyCode()` (1367 chars)
#### Hash `221e46c497d9` (2 copies)
- `src/managers/license-keygen.js:188``main()` (4428 chars)
- `license-keygen.js:188``main()` (4428 chars)
#### Hash `9573dd3cd485` (2 copies)
- `routes/backups.js:693``formatBytes()` (259 chars)
- `routes/apps/restore.js:488``formatBytes()` (259 chars)
## Common Factory Pattern
`module.exports = function(ctx) { const router = express.Router(); ... }`
**49 files** use this factory wrapper pattern:
- `routes/errorlogs.js`
- `routes/docker-resources.js`
- `routes/ca.js`
- `routes/config-drift.js`
- `routes/containers.js`
- `routes/context.js`
- `routes/monitoring.js`
- `routes/workflows.js`
- `routes/services.js`
- `routes/sites.js`
- `routes/logs.js`
- `routes/credentials.js`
- `routes/themes.js`
- `routes/updates.js`
- `routes/dns.js`
- ... and 34 more
Could be extracted to a helper:
```javascript
// src/utilities/route-factory.js
module.exports = function routeFactory(handlerFn) {
return function(deps) {
const router = require('express').Router();
handlerFn(router, deps);
return router;
};
};
```
+366
View File
@@ -0,0 +1,366 @@
# DNS2 / DashCaddy Bastion Hardening — 2026-07-13
**Scope:** Analysis of `/var/log/ufw.log`, `/var/log/auth.log`, `/var/log/fail2ban*.log`, and the DashCaddy API auth surface. Recommendations are based on direct log inspection + 2026 best-practice research (CrowdSec, fail2ban alternatives, modern SSH/API threats).
**Author of the work:** performed by `assistant` in a single pass — static analysis of attacker data, not a penetration test.
---
## TL;DR — what's actually happening
Your server is currently being **probed by ~9,000 attacks/day**, 96% of which are aimed at a service you don't even run on port 4001. fail2ban catches SSH. The big three wins are:
1. **No fail2ban coverage for the DashCaddy API** (only SSH is monitored).
2. **No shared-bans fusion with CrowdSec community blocklists** (you do FireHOL Level 1 + ipdeny country blocks, which is good — but misses emerging threats).
3. **The "elevated" alert in spike-monitor is noise** — it fires constantly without telling you anything new.
The good news: **your network-level defenses are already doing heavy lifting** — the shared_bans ipset has dropped **2,036,821 packets / 812 MB of attack traffic** before it ever hits your services. That's a real shield.
---
## 1. What we observed
### 1.1 SSH attack profile (`fail2ban-repeat-tracker.log`, 1294 lines)
| Top attacking /24 | Country | ASN | Events | Note |
|---|---|---|---|---|
| `45.148.10.0/24` | RO | 48090 | **60,264** | Single botnet operator — six IPs in this /24 each making 1,000+ attempts |
| `91.92.40.0/24` | BG | 197170 | 23,298 | Same operator family |
| `195.178.110.0/24` | BG | 48090 | 6,356 | Same ASN |
| `2.57.121.0/24` | RO | 47890 | 4,390 | Same ASN family (90K events across all 47890 ranges) |
| `92.118.39.0/24` | RO | 47890 | 4,266 | |
| `155.117.233.0/24` | US | 16276 | 4,253 | OVH |
| `45.227.254.0/24` | PA | 267784 | 4,049 | |
| `185.166.25.0/24` | IQ | 207097 | 2,480 | |
| `171.25.152.0/21` | SE | 35100 | 1,806 | **Tor exit nodes** |
| `62.60.130.0/24` | IR | 215930 | (subset) | State-adjacent hosting |
**The data tells us:**
- **ASN 48090 (Romanian bulletproof hosting) is responsible for ~70% of all SSH attack volume.** Your shared_bans already includes wide ranges covering most of their allocation, but you should pull the **complete ASN 48090 BGP prefixes** and ban the whole ASN.
- **ASN 47890 (also Romanian) is second biggest** — same situation.
- **ASN 35100 (Sweden) is Tor exit range** — attackers are deliberately routing through Tor to evade fail2ban. Your current setup bans individual Tor exit IPs after the fact, but they rotate. You need the **Tor exit list as a continuous feed** in your shared_bans merge.
- **ASN 16276 (OVH US/CA)** — OVH is the world's largest scanner-magnet. Their datacenter IPs are noisy. Consider ASN-wide ban for OVH or heavy subnet banning.
### 1.2 Username probing (`auth.log`)
Only 3 distinct invalid usernames seen: `hello` (5x), `sami` (3x), `git` (2x).
- `hello` — generic scanner
- `git` — automated git-service probe (irrelevant to you)
- **`sami`** — somebody knows your name. Could be:
- leaked from a public repo (git.dashcaddy.net is your own repo, but if any package was published to npm/PyPI with `sami` in author name)
- scraped from DNS WHOIS
- guessed from "sami" being in your domain names
- **Action: change your SSH banner to a generic string. Remove "sami" from anywhere user-facing.**
### 1.3 Network attack surface (`ufw.log`, 9683 blocks in current file)
**Where you're being hit:**
| Port | Hits | What's there? |
|---|---|---|
| **4001** | **8,602** | NOT a service you run. **99% aimed at `194.233.88.206` (your public IP).** Mix of TCP (4600) and UDP (4004). UDP packets come in 4 distinct payload sizes (1308/1288, 204/184, 1280/1260, 1469/1449) = this is **distributed reflection / amplification attack traffic**. |
| 12835 | 165 | IPv6 SYN scans from Contabo (ASN 207097) — Windows RPC/RDP-adjacent port probe |
| 22 | 27 | SSH (covered by fail2ban) |
| 23 | 10 | Telnet (Windows command shell — absurd, you don't run it) |
| 443 | 4 | HTTPS — Caddy (should be reachable; UFW blocked means Caddy accepted before UFW saw it, or it's scanner noise) |
| Various high ports | each <10 | Stray scans |
**Key insight on port 4001:** This is NOT an attack targeting a service you expose. The destination is your public IP but you have no service on 4001. Two interpretations:
1. **Pure DDOS reflection attempts** — attackers spoofing source IPs to make your IP look like a server that's not responding to legitimate amplification requests. You're the *target* (not a reflector).
2. **Random port scan noise** — bots checking for vulnerable services (Cisco AXP, Docker Swarm classic, DC++ P2P, AOX (Automated Obstacle Avoidance System) on port 4001).
Either way: **UFW is correctly dropping it. No action needed beyond what's already there.**
**Top source IPs (top 8 by /16):**
| Source /16 | Hits | ASN | Country |
|---|---|---|---|
| 15.204.0.0/16 | 1,101 | 16276 (OVH) | US |
| 85.217.0.0/16 | 543 | ? | ? |
| 51.79.0.0/16 | 270 | 16276 (OVH) | CA |
| 80.208.0.0/16 | 161 | 212531 | LT |
| 47.251.0.0/16 | 126 | ? | ? |
| 51.81.0.0/16 | 122 | 16276 (OVH) | US |
| 164.92.0.0/16 | 81 | 14061 (DigitalOcean) | US |
| 46.225.0.0/16 | 79 | 24940 (Hetzner) | DE |
| 206.189.0.0/16 | 78 | 14061 (DigitalOcean) | US |
| 80.124.0.0/16 | 69 | 15557 (SFR) | FR |
**Pattern:** Cloud providers (OVH, DigitalOcean, Hetzner, Contabo) are by far the heaviest scanners. This is universal — it's where the botnet herders rent VPSs.
### 1.4 DashCaddy API auth surface (already strong)
`src/utilities/middleware.js` already has:
-**helmet** with custom CSP
-**cors** with explicit origin allowlist (https://`<dashboardHost>`, plus localhost in dev)
-**express-rate-limit** in 4 tiers: general, strict (per-route), totp, auth (credential scraping)
-**CSRF** (cookie + header validation, domain `.sami` for SSO)
-**JWT** + **API key** auth
-**TOTP** session with 9 duration options
-**Tailscale** auth (optional, configurable to require tailnet membership)
-**trust proxy = 1** (correct for one Caddy hop)
-**Per-request metrics + structured access log**
-**Audit logging** for sensitive operations
-**Rate limit skip for authenticated users** on `/auth/*` endpoints (DC-027 fix already applied — prevents Caddy forward_auth chatter from 429ing legit users)
**The middleware is well-designed and current.** No gaps in the application layer.
---
## 2. What is NOT protected
### 2.1 DashCaddy API brute-force is invisible
You have `express-rate-limit` which handles single-IP flooding. But **fail2ban sees zero of this** — it only watches `/var/log/auth.log` (SSH). If an attacker is password-spraying your `/api/v1/totp/verify` endpoint from a thousand IPs, you get:
- Rate limit per IP (mitigated by IP rotation)
- TOTP lockout (mitigated by not having TOTP enabled in many installs)
- **Zero telemetry on the attacker pattern**
- **Zero automatic ban escalation to your shared_bans ipset**
### 2.2 Caddy access logs are not being watched
Caddy is the actual public-facing reverse proxy. Every HTTP request goes through it. fail2ban has a filter for `caddy` access logs, but **it's not configured**.
### 2.3 The spike-monitor alerts are noise
19 "elevated >100 banned" alerts in 30 days. That's just your steady state. The alerts provide no actionable signal.
---
## 3. Recommendations (prioritized)
### P1 — Do these now (high impact, low effort)
#### P1.1 Add Caddy-based fail2ban jail for HTTP brute force
**Problem:** Web/API attacks are invisible to fail2ban.
**Fix:** Configure Caddy to write JSON access logs, add a fail2ban filter that watches `401`/`403` patterns on auth routes, and re-use your existing `shared-bans` action to feed the ipset.
```bash
# 1. Caddy global option to log to JSON file
# In Caddyfile, add at top:
# {
# log default {
# output file /var/log/caddy/access.log {
# roll_size 100mb
# roll_keep 10
# }
# format json
# }
# }
# 2. /etc/fail2ban/filter.d/caddy-auth.conf
cat > /etc/fail2ban/filter.d/caddy-auth.conf << 'EOF'
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403|429).*"(/api/v1/(totp|auth|login)|/api/v1/license/validate).*
ignoreregex =
EOF
# 3. /etc/fail2ban/jail.d/caddy-auth.local
cat > /etc/fail2ban/jail.d/caddy-auth.local << 'EOF'
[caddy-auth]
enabled = true
port = http,https
filter = caddy-auth
logpath = /var/log/caddy/access.log
maxretry = 10
findtime = 600
bantime = 86400
action = iptables-multiport[name=caddy-auth]
shared-bans[name=caddy-auth]
EOF
fail2ban-client reload
```
> **Modern alternative (P1.5 below):** Caddy 2.7+ has `http.matchers.fail2ban` which reads a banned-IP file directly inside Caddy — no iptables needed, sub-ms rejection. See P1.5.
#### P1.2 Promote permanent bans for ASN 48090 + 47890 + 35100 (Tor)
**Problem:** Your shared_bans has ~28 ranges covering ASN 48090 already, but not the full ASN. Attackers rotate within it.
**Fix:** Pull BGP prefixes for these ASNs and add to `/var/lib/shared-bans/static/bans.txt`:
```bash
# ASN 48090 (Romanian bulletproof)
curl -s "https://stat.ripe.net/data/as-overview/AS48090/data.json" | jq -r '.data.block.list.prefixes[]' >> /var/lib/shared-bans/static/bans.txt
# ASN 47890 (Romanian)
curl -s "https://stat.ripe.net/data/as-overview/AS47890/data.json" | jq -r '.data.block.list.prefixes[]' >> /var/lib/shared-bans/static/bans.txt
# Tor exits - subscribe, don't curl
# Add to sources.json:
# {
# "url": "https://check.torproject.org/exit-addresses",
# "format": "tor-exits",
# "parser": "cut -f 1"
# }
```
Better source for Tor exits: `https://www.dan.me.uk/torlist/` (updated daily) or use `spoofer.cgtf.io` blocklists.
#### P1.3 Switch from "static" Tor ban to a continuously-merged feed
You already have `sources.json` for `bans.txt`. Add a Tor exit feed that updates hourly (rather than relying on Tor exits to get caught by SSH fail2ban then promoted).
### P2 — Do these within a sprint (medium effort, good impact)
#### P2.1 Switch from "fail2ban sshd only" to "CrowdSec + fail2ban hybrid"
**The 2026 consensus** (from the research): fail2ban is fine for SSH (deterministic, debuggable, no external deps) but **CrowdSec is better for HTTP services** because:
- Behavior-based detection (catches distributed brute-force where fail2ban misses it)
- Community blocklists (you benefit from what other CrowdSec users have observed)
- Sub-millisecond bouncers
**Recommended deployment** (from the comparison article at didi-thesysadmin.com):
| Layer | Tool | Reason |
|---|---|---|
| SSH brute force | fail2ban | Already working, simple, local-only |
| HTTP/API abuse | CrowdSec | Better detection, community signals |
| Static threat feeds (country blocks, FireHOL) | shared_bans ipset | Already working, keep as the foundational layer |
| **Tie it together** | **shared-bans** as the central ipset | All three write to the same ipset — fail2ban + CrowdSec + static feeds |
Concrete steps:
1. Install CrowdSec: `apt install crowdsec` (Debian/Ubuntu) or via official install script
2. Configure CrowdSec to read Caddy logs (parsers/scenarios: `crowdsecurity/caddy`, `crowdsecurity/http-bruteforce`)
3. Install the `iptables` bouncer (or `nftables` if you prefer)
4. **Configure the CrowdSec bouncer to write to `shared_bans` ipset** instead of its own chain (modifying `/etc/crowdsec/bouncers/crowdsec-iptables-bouncer.yaml`)
This gives you: SSH protection (fail2ban) + HTTP protection (CrowdSec) + static threat feeds (ipdeny/FireHOL/Tor) + automatic sharing with the community — all feeding into one ipset.
#### P2.2 Silence the spike-monitor noise, keep signal
Replace the "elevated >100 banned" alert (which fires constantly in your normal steady state) with **rate-of-change alerts**:
```python
# Instead of "banned count > 100":
# Fire alert when:
# - delta > 30 new bans in last 2h AND any new /24 range appears (signal)
# - delta > 100 new bans in last 2h (storm)
# - any new ASN appears in top attackers (early warning)
```
The "elevated" alert is informing you about your normal state. Replace it with something that tells you about *change*.
#### P2.3 Add `pnpm audit`/`npm audit` to CI + a weekly CVE check
Beyond network protection: **dependency CVEs** are how most real compromises happen. Add `npm audit --audit-level=high` to your deployment pipeline. Currently DashCaddy uses express 4.22, helmet 8.1, express-rate-limit 7.5 — all current, but you need to *track* new CVEs.
### P3 — Defense in depth (continuous improvement)
#### P3.1 Caddy native fail2ban matcher (`http.matchers.fail2ban`)
Caddy 2.7+ has built-in support for fail2ban files. You can have Caddy **directly refuse** any IP listed in a banned-IP file — no iptables needed, response is sub-ms.
```caddyfile
{
order fail2ban before basicauth
}
:443 {
@banned import fail2ban /var/lib/shared-bans/banned-ips.txt
handle @banned {
abort
}
reverse_proxy ...
}
```
This makes your shared_bans file **the single source of truth** for IP bans across all services. To unban someone, edit the file and reload Caddy. To ban an attacker, append to the file.
**Why this matters:** With ipset/iptables alone, the kernel still has to look up the IP on every packet (millions of lookups). With Caddy's matcher, rejected requests are dropped at the HTTP layer without ever reaching the API process. Defense-in-depth: iptables drops raw packets at L3, Caddy drops L7 requests.
#### P3.2 Consider IPv6 hardening
You have an IPv6 address (`2407:3640:2308:0415::1`). Your `fail2ban` and `shared_bans` are **IPv4-only**. An attacker can switch to IPv6 to bypass your entire defense.
**Fix:**
- Pull an IPv6 version of the ipdeny country blocks (`*-aggregated.zone` files)
- Add them to your static ban list
- Update fail2ban to also write to an `ip6tables` set
- Test IPv6 reachability of your services and ensure auth is required on the v6 path too
#### P3.3 Add `crowdsec-blocklists` (community IP reputation)
CrowdSec publishes curated blocklists that are continuously updated based on signals from their network. Subscribe to:
- `crowdsecurity/community-blocklist` — general scanner/attacker IPs
- `crowdsecurity/pro-bono-blocklist` — research-grade threat intelligence
These can be merged into your shared_bans via the CrowdSec bouncer.
#### P3.4 SSH key-only auth (if not already)
Verify `/etc/ssh/sshd_config` has:
```
PasswordAuthentication no
PermitRootLogin prohibit-password # or "no" if you don't need direct root
PubkeyAuthentication yes
ChallengeResponseAuthentication no
UsePAM yes
KbdInteractiveAuthentication no
```
Also: **Tailscale makes your SSH server unreachable from the public internet** if you bind sshd to the Tailscale interface only (`ListenAddress 100.121.150.22`). Then your SSH brute-force problem disappears entirely.
#### P3.5 Rate-limit UDP at the firewall
The 4,000 UDP packets/day to port 4001 are pure noise (you don't run a service there). Block UDP to ports you don't use:
```bash
# In /etc/ufw/before.rules:
-A ufw-before-input -p udp --dport 4001 -j DROP
-A ufw-before-input -p udp --dport 19:1000 -j DROP
# etc - explicit blocklist of UDP ports you never use
```
Actually since you're already using ipset `shared_bans` for INPUT, you can simplify by just blocking UDP to closed UDP ports. But ipset already does this (the kernel drops anything not explicitly accepted before ufw even sees it, per your `policy DROP`).
---
## 4. Quick wins checklist
| Priority | Action | Estimated effort | Impact |
|---|---|---|---|
| P1.1 | Add Caddy access log + fail2ban jail for HTTP 401/403 on auth routes | 1 hour | See HTTP attacks |
| P1.2 | Pull ASN 48090 + 47890 + 35100 full prefixes into shared_bans | 30 min | Blocks ~70% of attacker volume |
| P1.3 | Add Tor exit feed as a continuous source in `sources.json` | 30 min | Blocks all Tor-based attacks |
| P2.1 | Install CrowdSec + iptables bouncer writing to shared_bans | 4 hours | Community threat intel |
| P2.2 | Replace "elevated >100" alert with rate-of-change alert | 1 hour | Actionable signal |
| P2.3 | Add `npm audit --audit-level=high` to deploy pipeline | 30 min | CVE protection |
| P3.1 | Use `http.matchers.fail2ban` in Caddyfile | 1 hour | Sub-ms rejection |
| P3.2 | IPv6 hardening (ban lists + sshd bind) | 2 hours | Defense on both protocols |
| P3.3 | Subscribe to crowdsec community blocklists | 15 min | Community-driven intel |
| P3.4 | Verify SSH key-only auth + Tailscale-only bind | 30 min | Eliminate SSH brute force entirely |
---
## 5. Caveats / honesty
- **Static analysis, not a penetration test.** All findings are based on log inspection, not active probing. There may be gaps I'm missing because nothing has tried them yet.
- **No production changes were made.** This is a recommendation document only. The fail2ban status was observed to be working; no rules were modified, no files outside `/root/dashcaddy/` were edited.
- **The port 4001 traffic analysis is a best-effort interpretation.** Without packet capture (pcap), I can't definitively say whether the UDP traffic is reflection DDoS, scanner noise, or something else. The 4 distinct payload sizes strongly suggest a single exploit packet repeated.
- **ASN attribution uses Team Cymru's DNS-based lookup.** Their data is authoritative but sometimes stale. The actual operators of `45.148.10.0/24` (ASN 48090) may be tenants on rented hardware, not the ASN owner.
- **The 9,000 attacks/day figure is the UFW-blocked count, not the total attack volume.** Many attacks don't reach your firewall (rejected upstream by Tailscale, ISP, or your `/24` not being routable from the source). True attack volume is higher.
- **I did not modify your `/etc/banned-ips/`, `/etc/fail2ban/`, or iptables.** This document is for your review before action.
---
## 6. References
- [Fail2Ban vs CrowdSec (2026 production comparison)](https://didi-thesysadmin.com/2026/01/06/fail2ban-vs-crowdsec-which-should-you-use-in-production/) — didi-thesysadmin.com
- [Caddy `http.matchers.fail2ban` module docs](https://caddyserver.com/docs/modules/http.matchers.fail2ban) — caddyserver.com
- [Protecting Caddy-powered websites with Fail2Ban](https://www.ottorask.com/blog/caddy-and-fail2ban) — ottorask.com
- [Securing APIs: Express rate limit and slow down (MDN)](https://developer.mozilla.org/en-US/blog/securing-apis-express-rate-limit-and-slow-down/) — developer.mozilla.org
- [UDP-based amplification attacks](https://www.cisa.gov/news-events/alerts/2014/01/17/udp-based-amplification-attacks) — CISA alert TA14-017A
- [ipdeny.com aggregated zone files](https://www.ipdeny.com/ipblocks/) — country-level blocklists
- [Tor exit list](https://check.torproject.org/torbulkexitlist) — Tor Project
- [FireHOL Level 1](https://iplists.firehol.org/files/firehol_level1.netset) — curated threat feed
---
*Document generated 2026-07-13 by `assistant` for `Sami Ahmed` (Telegram DM). Files at `/root/dashcaddy/HARDENING.md`.*
+300
View File
@@ -0,0 +1,300 @@
# 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.*
+254
View File
@@ -0,0 +1,254 @@
/**
* Security Center API routes
*
* Endpoints (all under /api/v1/security):
*
* GET /events — Query events (filters: source_type, source_host,
* severity, outcome, actor, action, since, until,
* target; pagination via limit/offset)
* GET /events/stats — Aggregations (top actors, top targets,
* counts by source/severity/host)
* GET /events/:id — Single event by id
* GET /events/stream — Server-Sent Events live tail (auth required)
*
* POST /events/ingest — Single ingest (auth: Bearer host-api-key)
* POST /events/batch — Batch ingest (auth: Bearer host-api-key)
*
* GET /hosts — List registered hosts
* POST /hosts — Register new host
* GET /hosts/:id — Host details
* PATCH /hosts/:id — Update host (label, type, enabled, meta)
* DELETE /hosts/:id — Deregister host
* GET /hosts/:id/health — Host health summary
*
* POST /hosts/:id/rotate-key — Rotate host api_key
*
* Most endpoints require TOTP/JWT/API-key auth like the rest of the dashboard.
* The /events/ingest and /events/batch endpoints accept per-host Bearer tokens
* AND must be added to the PUBLIC_ROUTES allowlist in middleware.js so they
* don't require TOTP. Per-host auth replaces TOTP for those endpoints.
*/
const express = require('express');
const { ok, error: errorResponse } = require('../src/utils/responses');
const { getStore } = require('../src/security/event-store');
const { getRegistry } = require('../src/security/host-registry');
const platformPaths = require('../platform-paths');
module.exports = function({ log }) {
const router = express.Router();
const store = getStore({ log });
const registry = getRegistry({ log });
// ===================== EVENTS =====================
// GET /events — list/query
router.get('/events', (req, res) => {
const result = store.query({
limit: req.query.limit,
offset: req.query.offset,
source_type: req.query.source_type,
source_host: req.query.source_host,
severity: req.query.severity,
outcome: req.query.outcome,
actor: req.query.actor,
actor_prefix: req.query.actor_prefix,
action: req.query.action,
target: req.query.target,
since: req.query.since,
until: req.query.until,
});
ok(res, result);
});
// GET /events/stats — aggregations
router.get('/events/stats', (req, res) => {
const stats = store.stats({
since: req.query.since,
});
ok(res, stats);
});
// GET /events/stream — SSE live tail (must come BEFORE /events/:id!)
router.get('/events/stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Initial sync — send last 20 events so the UI isn't empty
const initial = store.query({ limit: 20 });
res.write(`event: init\ndata: ${JSON.stringify(initial)}\n\n`);
const onEvent = (ev) => {
try { res.write(`event: security\ndata: ${JSON.stringify(ev)}\n\n`); }
catch (_) { cleanup(); }
};
const heartbeat = setInterval(() => {
try { res.write(`: heartbeat ${Date.now()}\n\n`); }
catch (_) { cleanup(); }
}, 30000);
function cleanup() {
store.off('event', onEvent);
clearInterval(heartbeat);
}
store.on('event', onEvent);
req.on('close', cleanup);
req.on('aborted', cleanup);
});
// GET /events/:id — single event
router.get('/events/:id', (req, res) => {
const ev = store.get(req.params.id);
if (!ev) return errorResponse(res, 404, 'event not found');
ok(res, ev);
});
// ===================== INGEST =====================
// POST /events/ingest — single event from an authenticated host
router.post('/events/ingest', (req, res) => {
const host = _authHost(req, res);
if (!host) return;
if (!req.body || typeof req.body !== 'object') {
return errorResponse(res, 400, 'event body required');
}
try {
const event = store.append({
...req.body,
source_host: host.id, // override — server is source of truth on host id
source_type: req.body.source_type || 'agent',
});
ok(res, { id: event.id, accepted: true });
} catch (e) {
errorResponse(res, 400, e.message);
}
});
// POST /events/batch — multiple events (more efficient for agents)
router.post('/events/batch', (req, res) => {
const host = _authHost(req, res);
if (!host) return;
const events = Array.isArray(req.body?.events) ? req.body.events : null;
if (!events) return errorResponse(res, 400, 'events[] required');
if (events.length > 500) return errorResponse(res, 413, 'batch too large (max 500)');
const accepted = [];
const errors = [];
for (const ev of events) {
try {
const stored = store.append({
...ev,
source_host: host.id,
source_type: ev.source_type || 'agent',
});
accepted.push(stored.id);
} catch (e) {
errors.push({ error: e.message, event: ev });
}
}
ok(res, { accepted: accepted.length, errors: errors.length, ids: accepted, error_details: errors });
});
// ===================== HOSTS =====================
// GET /hosts — list
router.get('/hosts', (req, res) => {
ok(res, { hosts: registry.list() });
});
// POST /hosts — register new
router.post('/hosts', (req, res) => {
const { id, label, type, meta, enabled } = req.body || {};
if (!id) return errorResponse(res, 400, 'id required');
try {
const { host, api_key } = registry.register({ id, label, type, meta, enabled });
// api_key returned EXACTLY ONCE — caller must store it now
ok(res, { host, api_key, notice: 'store this api_key now — it will not be shown again' });
} catch (e) {
errorResponse(res, 409, e.message);
}
});
// GET /hosts/:id
router.get('/hosts/:id', (req, res) => {
const h = registry.get(req.params.id);
if (!h) return errorResponse(res, 404, 'host not found');
ok(res, h);
});
// PATCH /hosts/:id
router.patch('/hosts/:id', (req, res) => {
const updated = registry.update(req.params.id, req.body || {});
if (!updated) return errorResponse(res, 404, 'host not found');
ok(res, updated);
});
// DELETE /hosts/:id
router.delete('/hosts/:id', (req, res) => {
try {
const ok_ = registry.remove(req.params.id);
if (!ok_) return errorResponse(res, 404, 'host not found');
ok(res, { removed: true });
} catch (e) {
errorResponse(res, 400, e.message);
}
});
// GET /hosts/:id/health — last_seen, event rate, status
router.get('/hosts/:id/health', (req, res) => {
const host = registry.get(req.params.id);
if (!host) return errorResponse(res, 404, 'host not found');
const last24h = new Date(Date.now() - 24*60*60*1000).toISOString();
const events24h = store.query({ source_host: req.params.id, since: last24h, limit: 1000 });
const sev = events24h.events.reduce((acc, e) => {
acc[e.severity] = (acc[e.severity] || 0) + 1;
return acc;
}, {});
const lastEvent = events24h.events[0] || null;
ok(res, {
host,
events_24h: events24h.total,
severity_breakdown_24h: sev,
last_event_at: lastEvent?.ts || null,
last_event_id: lastEvent?.id || null,
status: !host.enabled ? 'disabled'
: !host.last_seen_at ? 'registered'
: (Date.now() - Date.parse(host.last_seen_at) > 30*60*1000) ? 'stale'
: 'online',
});
});
// POST /hosts/:id/rotate-key — issue a new key, return it once
// (Implementation note: rotate would need to keep _raw_key retrieval. For v1
// we'll document this as "deferred — re-register instead". The endpoint
// returns 501 with a clear message so callers don't get silently no-op'd.)
router.post('/hosts/:id/rotate-key', (req, res) => {
errorResponse(res, 501, 'rotate-key deferred in v1 — re-register the host to get a new key');
});
// ===================== HELPERS =====================
function _authHost(req, res) {
const auth = req.headers.authorization || '';
const m = auth.match(/^Bearer\s+(.+)$/);
if (!m) {
errorResponse(res, 401, 'Bearer token required');
return null;
}
const host = registry.authenticate(m[1]);
if (!host) {
errorResponse(res, 401, 'invalid or disabled host key');
return null;
}
return host;
}
return router;
};
+11
View File
@@ -134,6 +134,17 @@ process.on('uncaughtException', (error) => {
log.error('server', 'Backup manager failed to start', { error: err.message });
}
// Security event workers (Caddy access log, fail2ban, shared_bans)
// Each one tail-follows a log file and emits events into the unified
// security store. They survive restarts via persisted offsets.
try {
const { startAll: startSecurityWorkers } = require('./src/security/event-workers');
startSecurityWorkers({ log });
log.info('server', 'Security event workers started');
} catch (err) {
log.error('server', 'Security event workers failed to start', { error: err.message });
}
// Connect workflow engine to update manager for pre-update events
if (workflowEngine) {
updateManager.setWorkflowEngine(workflowEngine);
+4
View File
@@ -82,6 +82,7 @@ const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
const securityRoutes = require('../routes/security');
const DependencyManager = require('./managers/dependency-manager');
const autoRestartRoutes = require('../routes/auto-restart');
const configDriftRoutes = require('../routes/config-drift');
@@ -638,6 +639,9 @@ async function createApp() {
asyncHandler: ctx.asyncHandler,
ok: ctx.ok
}));
apiRouter.use('/security', securityRoutes({
log: ctx.log,
}));
apiRouter.use('/dependencies', dependenciesRoutes({
dependencyManager: ctx.dependencyManager,
servicesStateManager: ctx.servicesStateManager,
@@ -119,6 +119,25 @@ class AuditLogger {
return false;
}
/**
* Map a (action, outcome) pair to a severity level for the security event store.
* Most actions are 'info', but security-sensitive ones get escalated.
*/
resolveSeverity(action, outcome) {
// Failed auth + sensitive actions are warnings at minimum
if (outcome === 'failure' || outcome === 'denied' || outcome === 'error') {
if (action?.startsWith('auth.')) return 'warn';
if (action?.includes('credential')) return 'warn';
if (action?.includes('delete') || action?.includes('disable')) return 'warn';
return 'notice';
}
// Successful sensitive actions (key generation, TOTP setup, config changes)
if (action?.startsWith('auth.totp-') || action?.includes('rotate-key')) return 'notice';
if (action?.includes('delete') || action?.includes('disable')) return 'notice';
if (action?.startsWith('config.')) return 'notice';
return 'info';
}
async log({ action, resource, details, outcome, ip }) {
try {
const entry = {
@@ -138,6 +157,34 @@ class AuditLogger {
}
return entries;
});
// ALSO emit to the unified security event store so security events from
// the API show up alongside Caddy access logs, fail2ban events, and any
// future remote-agent events in one timeline. This is best-effort —
// failure here MUST NOT block the audit log write.
try {
const { getStore } = require('./event-store');
const store = getStore();
const severity = this.resolveSeverity(action, outcome);
const hostname = require('os').hostname();
store.append({
source_host: hostname,
source_type: 'api',
actor: ip || null,
target: resource || null,
action: action || 'unknown',
outcome: outcome || 'unknown',
severity,
message: `${action} ${outcome} on ${resource}`.trim(),
metadata: {
method: details?.body && Object.keys(details.body)[0] ? '(see audit-log)' : undefined,
audit_id: entry.id,
},
});
} catch (e) {
// Non-fatal — security store is a best-effort mirror
console.error('[AuditLogger] Security event emit failed:', e.message);
}
} catch (e) {
console.error('[AuditLogger] Failed to write entry:', e.message);
}
+350
View File
@@ -0,0 +1,350 @@
/**
* Security Event Store
*
* Unified JSONL store for security-relevant events from any source:
* - api : DashCaddy API audit events (extends src/security/audit-logger.js)
* - caddy : Caddy reverse-proxy access log events (parsed by tail-worker)
* - fail2ban : SSH brute-force bans (read from /var/log/fail2ban.log)
* - shared-bans : IP blocklist promotion events (read from /var/log/shared-bans-promote.log)
* - syslog : (v2) Generic syslog messages
* - agent : (v2) Events from a remote DCA (DashCaddy Agent) binary
*
* Storage format: JSONL (one JSON object per line). Why JSONL not JSON-array?
* - Append-only writes are O(1) — no read-modify-write race
* - Partial reads on crash (last line may be corrupt, but earlier lines survive)
* - Trivial to grep/jq for forensics
* - Easy to tail from a remote source
*
* Query strategy: in-memory index with file-backed persistence. For <100k events
* this is fine. Beyond that we'd switch to SQLite — see BACKLOG.md (DC-???) to track.
*
* The store also emits events to subscribers (in-process EventEmitter), so the
* dashboard can do live tailing via Server-Sent Events in v2.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
const platformPaths = require('../../platform-paths');
const EVENT_STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE
|| path.join(platformPaths.dataDir || path.join(__dirname, '../../data'), 'security-events.jsonl');
const MAX_EVENTS_IN_MEMORY = parseInt(process.env.SECURITY_EVENT_MAX_MEMORY || '10000', 10);
const MAX_EVENTS_ON_DISK = parseInt(process.env.SECURITY_EVENT_MAX_DISK || '100000', 10);
const VALID_SOURCE_TYPES = new Set(['api', 'caddy', 'fail2ban', 'shared-bans', 'syslog', 'agent']);
const VALID_SEVERITIES = new Set(['info', 'notice', 'warn', 'error', 'critical']);
const VALID_OUTCOMES = new Set(['success', 'denied', 'blocked', 'rate-limited', 'error', 'unknown']);
class SecurityEventStore extends EventEmitter {
constructor(opts = {}) {
super();
this.filePath = opts.filePath || EVENT_STORE_FILE;
this.maxMemory = opts.maxMemory || MAX_EVENTS_IN_MEMORY;
this.maxDisk = opts.maxDisk || MAX_EVENTS_ON_DISK;
this.log = opts.log || console;
this.events = []; // newest first
this.byId = new Map();
this.lastWriteLine = 0; // byte offset of last successfully-written line
this.writeQueue = []; // serialized write buffer
this.writing = false;
this._load();
}
/**
* Load existing events from disk into memory (newest first).
* Skips corrupt lines — logs warning but continues.
*/
_load() {
try {
if (!fs.existsSync(this.filePath)) {
this.log.info?.('security', 'event store: starting fresh (no file)', { path: this.filePath });
return;
}
const content = fs.readFileSync(this.filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
this.log.info?.('security', 'event store: loading from disk', { total_lines: lines.length, path: this.filePath });
let loaded = 0;
let skipped = 0;
// Walk from end backwards so newest are first
for (let i = lines.length - 1; i >= 0 && loaded < this.maxMemory; i--) {
const line = lines[i].trim();
if (!line) continue;
try {
const ev = JSON.parse(line);
if (!this._isValidShape(ev)) {
skipped++;
continue;
}
this.events.push(ev);
this.byId.set(ev.id, ev);
loaded++;
} catch (e) {
skipped++;
// Don't log every line — could be thousands of corrupted lines
}
}
this.log.info?.('security', 'event store: load complete', { loaded, skipped, kept_in_memory: this.events.length });
} catch (e) {
this.log.error?.('security', 'event store: load failed', { error: e.message });
}
}
/**
* Validate minimum shape of an event before storing/returning it.
* Permissive — accepts extras, just requires the spine.
*/
_isValidShape(ev) {
if (!ev || typeof ev !== 'object') return false;
if (typeof ev.id !== 'string' || !ev.id) return false;
if (typeof ev.ts !== 'string' || !ev.ts) return false;
if (typeof ev.source_host !== 'string') return false;
if (typeof ev.source_type !== 'string' || !VALID_SOURCE_TYPES.has(ev.source_type)) return false;
return true;
}
/**
* Append a new event. Validates shape, writes to disk, indexes in memory,
* emits 'event' for live-tail subscribers.
*
* @param {object} partial - event without id (one will be generated)
* @returns {object} the stored event
*/
append(partial) {
const event = this._normalize(partial);
const validationError = this._validate(event);
if (validationError) {
this.log.warn?.('security', 'rejected invalid event', { error: validationError, partial });
throw new Error(`Invalid event: ${validationError}`);
}
// Write to disk first (durability), then index in memory.
// We queue the write so multiple append() calls don't interleave on the same fd.
this.writeQueue.push(event);
this._flushQueue();
// Index (in-memory only — newest first)
this.events.unshift(event);
this.byId.set(event.id, event);
if (this.events.length > this.maxMemory) {
const evicted = this.events.pop();
this.byId.delete(evicted.id);
}
this.emit('event', event);
return event;
}
/**
* Normalize partial event — fill in defaults, generate id+ts.
*/
_normalize(p) {
const now = new Date().toISOString();
return {
id: p.id || crypto.randomUUID(),
ts: p.ts || now,
source_host: p.source_host || 'unknown',
source_type: p.source_type || 'api',
actor: p.actor || null, // IP, user, agent_id
target: p.target || null, // endpoint, service id, host
action: p.action || 'unknown', // free-form but stable per source_type
outcome: p.outcome || 'unknown',
severity: p.severity || 'info',
message: p.message || null, // human-readable one-liner
metadata: p.metadata && typeof p.metadata === 'object' ? p.metadata : {},
...(p.tags && Array.isArray(p.tags) ? { tags: p.tags } : {}),
};
}
_validate(ev) {
if (!VALID_SOURCE_TYPES.has(ev.source_type)) return `bad source_type: ${ev.source_type}`;
if (!VALID_SEVERITIES.has(ev.severity)) return `bad severity: ${ev.severity}`;
if (!VALID_OUTCOMES.has(ev.outcome)) return `bad outcome: ${ev.outcome}`;
if (typeof ev.actor === 'string' && ev.actor.length > 256) return 'actor too long';
if (typeof ev.target === 'string' && ev.target.length > 512) return 'target too long';
return null;
}
/**
* Serialize appends to disk. Writes one line at a time, doesn't truncate.
* Disk trimming happens separately via _trim().
*/
_flushQueue() {
if (this.writing) return;
const next = this.writeQueue.shift();
if (!next) return;
this.writing = true;
const line = JSON.stringify(next) + '\n';
fs.appendFile(this.filePath, line, 'utf8', (err) => {
this.writing = false;
if (err) {
this.log.error?.('security', 'write failed', { error: err.message });
// Re-queue so we don't lose the event on transient errors
this.writeQueue.unshift(next);
} else {
// Try next
if (this.writeQueue.length > 0) setImmediate(() => this._flushQueue());
this._maybeTrim();
}
});
}
/**
* Trim disk log if it exceeds maxDisk lines. Done in the background — never
* blocks an append(). Strategy: rewrite the file keeping the most recent
* maxDisk lines, atomically (write tmp + rename).
*/
_maybeTrim() {
fs.stat(this.filePath, (err, st) => {
if (err || !st) return;
// Cheap heuristic: if file is > 50MB we always trim. Otherwise count lines.
const SIZE_LIMIT = 50 * 1024 * 1024;
if (st.size < SIZE_LIMIT) return;
this._trim();
});
}
_trim() {
this.log.info?.('security', 'trimming event store', { file: this.filePath });
fs.readFile(this.filePath, 'utf8', (err, content) => {
if (err) return;
const lines = content.split('\n').filter(l => l.trim());
if (lines.length <= this.maxDisk) return;
const kept = lines.slice(-this.maxDisk).join('\n') + '\n';
const tmp = this.filePath + '.tmp';
fs.writeFile(tmp, kept, 'utf8', (e) => {
if (e) {
this.log.error?.('security', 'trim write failed', { error: e.message });
return;
}
fs.rename(tmp, this.filePath, (e2) => {
if (e2) this.log.error?.('security', 'trim rename failed', { error: e2.message });
});
});
});
}
/**
* Query events. All filters are AND-combined. Results are newest-first.
*
* @param {object} q - query
* limit : number, default 100, max 1000
* offset : number, default 0
* source_type: string or array
* source_host: string
* severity : string or array
* outcome : string or array
* actor : string (exact or prefix match with `actor_prefix`)
* action : string
* since : ISO timestamp (inclusive)
* until : ISO timestamp (exclusive)
*/
query(q = {}) {
const limit = Math.min(parseInt(q.limit || '100', 10), 1000);
const offset = parseInt(q.offset || '0', 10);
const sourceTypes = this._toArr(q.source_type);
const severities = this._toArr(q.severity);
const outcomes = this._toArr(q.outcome);
const matches = [];
for (const ev of this.events) {
if (sourceTypes.length && !sourceTypes.includes(ev.source_type)) continue;
if (q.source_host && ev.source_host !== q.source_host) continue;
if (severities.length && !severities.includes(ev.severity)) continue;
if (outcomes.length && !outcomes.includes(ev.outcome)) continue;
if (q.actor && ev.actor !== q.actor) continue;
if (q.actor_prefix && (!ev.actor || !ev.actor.startsWith(q.actor_prefix))) continue;
if (q.action && ev.action !== q.action) continue;
if (q.since && ev.ts < q.since) continue;
if (q.until && ev.ts >= q.until) continue;
if (q.target && ev.target !== q.target) continue;
matches.push(ev);
if (matches.length >= offset + limit) break; // avoid scanning further
}
return {
total: matches.length,
events: matches.slice(offset, offset + limit),
};
}
_toArr(v) {
if (!v) return [];
if (Array.isArray(v)) return v;
return String(v).split(',').map(s => s.trim()).filter(Boolean);
}
/**
* Compute aggregates for dashboards. Returns counts + top-N per dimension.
* Cheap because events array is bounded by maxMemory.
*/
stats(opts = {}) {
const sinceMs = opts.since ? Date.parse(opts.since) : null;
const filtered = sinceMs
? this.events.filter(e => Date.parse(e.ts) >= sinceMs)
: this.events;
const bySeverity = {};
const byOutcome = {};
const bySource = {};
const byHost = {};
const byAction = {};
const actorCount = {};
const targetCount = {};
for (const ev of filtered) {
bySeverity[ev.severity] = (bySeverity[ev.severity] || 0) + 1;
byOutcome[ev.outcome] = (byOutcome[ev.outcome] || 0) + 1;
bySource[ev.source_type] = (bySource[ev.source_type] || 0) + 1;
byHost[ev.source_host] = (byHost[ev.source_host] || 0) + 1;
byAction[ev.action] = (byAction[ev.action] || 0) + 1;
if (ev.actor) actorCount[ev.actor] = (actorCount[ev.actor] || 0) + 1;
if (ev.target) targetCount[ev.target] = (targetCount[ev.target] || 0) + 1;
}
return {
window: { since: opts.since || null, count: filtered.length },
by_severity: bySeverity,
by_outcome: byOutcome,
by_source: bySource,
by_host: byHost,
top_actions: this._topN(byAction, 10),
top_actors: this._topN(actorCount, 10),
top_targets: this._topN(targetCount, 10),
};
}
_topN(obj, n) {
return Object.entries(obj)
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([key, count]) => ({ key, count }));
}
/**
* Get one event by id, or null.
*/
get(id) {
return this.byId.get(id) || null;
}
/**
* Total events currently in memory.
*/
size() {
return this.events.length;
}
}
// Singleton accessor. Routes call this; tests can construct their own.
let _instance = null;
function getStore(opts) {
if (_instance) return _instance;
_instance = new SecurityEventStore(opts);
return _instance;
}
module.exports = { SecurityEventStore, getStore, VALID_SOURCE_TYPES, VALID_SEVERITIES, VALID_OUTCOMES };
+286
View File
@@ -0,0 +1,286 @@
/**
* Security Event Workers
*
* Background processes that watch external sources for security events and
* push them into the unified security event store:
*
* 1. Caddy access log tail — parses /var/log/caddy/access.log (JSON format)
* and emits one event per request. Severity escalates for 4xx/5xx and
* credential-endpoint hits.
*
* 2. shared_bans apply tail — parses /var/log/shared-bans-apply.log for
* IP-blocklist changes. Emits 'info' events so the dashboard timeline
* shows when IPs were banned/promoted.
*
* 3. fail2ban log tail — parses /var/log/fail2ban.log for ban/unban
* actions. SSH jail is the default; can extend to other jails.
*
* Each worker:
* - Starts on app boot (via server.js)
* - Tracks its byte offset in the log file so it survives restarts (no re-emit)
* - Auto-recovers from truncated/rotated log files
* - Has its own error handling — one worker dying doesn't take down the others
*
* To use the Caddy worker, configure Caddy to log in JSON format:
*
* {
* log default {
* output file /var/log/caddy/access.log {
* roll_size 100mb
* roll_keep 10
* }
* format json
* }
* }
*
* Then drop a fail2ban jail for HTTP 401/403 patterns — see HARDENING.md P1.1.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { getStore } = require('./event-store');
const HOSTNAME = os.hostname();
/**
* Generic tail-follower with offset persistence.
* Watches `filePath`, emits each new line via `onLine(line)`.
* Persists last-read offset to `stateFile` so restarts don't re-process.
* On file truncation (rotation), resets offset to 0.
*/
function createTail({ filePath, stateFile, onLine, label = 'tail', pollMs = 1000 }) {
let offset = 0;
let buffer = '';
let stopped = false;
// Load persisted offset
try {
if (fs.existsSync(stateFile)) {
offset = parseInt(fs.readFileSync(stateFile, 'utf8').trim(), 10) || 0;
}
} catch {}
function persistOffset() {
try { fs.writeFileSync(stateFile, String(offset), 'utf8'); }
catch {}
}
function tick() {
if (stopped) return;
fs.stat(filePath, (err, st) => {
if (err) {
// File doesn't exist yet — just wait
return setTimeout(tick, pollMs * 5);
}
// Detect truncation/rotation
if (st.size < offset) {
offset = 0;
buffer = '';
}
if (st.size === offset) {
return setTimeout(tick, pollMs);
}
// Read just the new bytes
const stream = fs.createReadStream(filePath, {
start: offset,
end: st.size - 1,
encoding: 'utf8',
});
stream.on('data', (chunk) => {
buffer += chunk;
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // last partial stays
for (const line of lines) {
if (line.trim()) {
try { onLine(line); } catch (e) {
console.error(`[${label}] onLine threw:`, e.message);
}
}
}
});
stream.on('end', () => {
offset = st.size;
persistOffset();
setTimeout(tick, pollMs);
});
stream.on('error', (e) => {
console.error(`[${label}] read error:`, e.message);
setTimeout(tick, pollMs * 5);
});
});
}
setTimeout(tick, pollMs); // initial delay so app has finished starting
return {
stop() { stopped = true; },
getOffset() { return offset; },
};
}
/**
* Worker 1 — Caddy access log.
* Caddy emits JSON per request like:
* {"ts":1700000000,"request":{"remote_ip":"1.2.3.4","method":"GET","uri":"/x"},"status":200,...}
* We turn that into a security event.
*/
function startCaddyWorker({ log } = {}) {
const caddyLog = process.env.CADDY_ACCESS_LOG || '/var/log/caddy/access.log';
const stateFile = path.join(process.env.DATA_DIR || path.join(__dirname, '../../data'), '.caddy-tail-offset');
const store = getStore({ log });
return createTail({
filePath: caddyLog,
stateFile,
label: 'caddy',
onLine: (line) => {
let entry;
try { entry = JSON.parse(line); }
catch { return; } // skip non-JSON lines (Caddy may mix formats)
const req = entry.request || {};
const status = entry.status || 0;
const ip = req.remote_ip;
const method = req.method;
const uri = req.uri || '';
const userAgent = (req.headers && req.headers['User-Agent']) || null;
// Severity mapping
let severity = 'info';
let outcome = 'success';
if (status === 401 || status === 403) { severity = 'warn'; outcome = 'denied'; }
else if (status === 429) { severity = 'notice'; outcome = 'rate-limited'; }
else if (status >= 500) { severity = 'error'; outcome = 'error'; }
else if (status >= 400) { severity = 'notice'; outcome = 'denied'; }
// Escalate credential-endpoint hits
const sensitivePaths = ['/api/v1/auth/', '/api/v1/totp/', '/api/v1/license/', '/api/v1/credentials/'];
if (sensitivePaths.some(p => uri.startsWith(p)) && status >= 400) {
severity = 'warn';
}
store.append({
source_host: HOSTNAME,
source_type: 'caddy',
actor: ip,
target: `${method} ${uri}`,
action: `http.${status}`,
outcome,
severity,
message: `${ip} ${method} ${uri} -> ${status}`,
metadata: {
status,
duration_ms: entry.duration || null,
user_agent: userAgent,
size: entry.size || null,
proto: req.proto || null,
},
});
},
});
}
/**
* Worker 2 — shared_bans apply log.
* Already a structured human-readable log:
* "2026-07-13 01:35:55 Excluded 6 private/loopback/CGNAT entries from ban list"
* "2026-07-13 01:35:56 Applied: 19412 entries in shared_bans"
* We emit one event per "Applied" line. Low volume (1 per 5 min) so very cheap.
*/
function startSharedBansWorker({ log } = {}) {
const sbLog = process.env.SHARED_BANS_LOG || '/var/log/shared-bans-apply.log';
const stateFile = path.join(process.env.DATA_DIR || path.join(__dirname, '../../data'), '.sb-tail-offset');
const store = getStore({ log });
const APPLIED_RE = /Applied:\s+(\d+)\s+entries/;
return createTail({
filePath: sbLog,
stateFile,
label: 'shared-bans',
pollMs: 5000,
onLine: (line) => {
const m = line.match(APPLIED_RE);
if (!m) return; // skip the "Excluded" / "Merged" / "Restored" noise
const count = parseInt(m[1], 10);
store.append({
source_host: HOSTNAME,
source_type: 'shared-bans',
actor: 'shared-bans-updater',
target: 'shared_bans ipset',
action: 'ipset.apply',
outcome: 'success',
severity: 'info',
message: `Applied ${count} entries to shared_bans ipset`,
metadata: { count },
});
},
});
}
/**
* Worker 3 — fail2ban log.
* "2026-06-15T21:05:41Z fail2ban.actions [sshd] Ban 1.2.3.4"
* "2026-06-15T21:05:41Z fail2ban.actions [sshd] Unban 1.2.3.4"
* Emit one event per Ban/Unban. Watched on top of shared_bans because fail2ban
* bans are SHORTER-lived (24h default) than shared_bans.
*/
function startFail2banWorker({ log } = {}) {
const f2bLog = process.env.FAIL2BAN_LOG || '/var/log/fail2ban.log';
const stateFile = path.join(process.env.DATA_DIR || path.join(__dirname, '../../data'), '.f2b-tail-offset');
const store = getStore({ log });
// Match ISO timestamps followed by [jail] Ban/Unban IP
const BAN_RE = /^(\S+).*?\]\s+(Ban|Unban)\s+(\S+)/;
return createTail({
filePath: f2bLog,
stateFile,
label: 'fail2ban',
pollMs: 2000,
onLine: (line) => {
const m = line.match(BAN_RE);
if (!m) return;
const [, ts, action, ip] = m;
const isBan = action === 'Ban';
store.append({
source_host: HOSTNAME,
source_type: 'fail2ban',
actor: ip,
target: 'sshd (or other jail)',
action: isBan ? 'ban' : 'unban',
outcome: 'success',
severity: isBan ? 'notice' : 'info',
message: `${action} ${ip}`,
metadata: {
ts,
source: 'fail2ban',
},
});
},
});
}
/**
* Start all workers. Returns a stop function that shuts them all down.
*/
function startAll({ log } = {}) {
const workers = [];
try { workers.push(startCaddyWorker({ log })); }
catch (e) { console.error('[workers] caddy worker failed to start:', e.message); }
try { workers.push(startSharedBansWorker({ log })); }
catch (e) { console.error('[workers] shared_bans worker failed to start:', e.message); }
try { workers.push(startFail2banWorker({ log })); }
catch (e) { console.error('[workers] fail2ban worker failed to start:', e.message); }
return {
stop() { workers.forEach(w => { try { w.stop(); } catch {} }); },
workers,
};
}
module.exports = {
createTail,
startCaddyWorker,
startSharedBansWorker,
startFail2banWorker,
startAll,
};
+224
View File
@@ -0,0 +1,224 @@
/**
* Security Host Registry
*
* Tracks every "location" that reports security events into the central
* DashCaddy instance. A host can be:
* - The current DashCaddy node itself ("self")
* - Another DashCaddy install (in the future, when we add DCA-agent)
* - A service location like a NAS or a remote Docker host
* - An IP range / CIDR / domain representing a service that runs elsewhere
*
* Each host has:
* - id : stable identifier (slug)
* - label : human-readable name
* - type : "self" | "dashcaddy" | "service" | "agent"
* - api_key : per-host API key for ingest auth (HMAC-signed, stored hashed)
* - registered_at, last_seen_at
* - meta : free-form metadata (location, region, tags, etc.)
* - enabled : soft-disable flag (stops accepting events)
*
* Persistence: /data/security-hosts.json (atomic write via tmp+rename).
*
* Auth on the ingest endpoint: the request must include
* Authorization: Bearer <host.api_key>
* matching a registered, enabled host. We verify by hashing the presented key
* and comparing to the stored hash.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const platformPaths = require('../../platform-paths');
const HOSTS_FILE = process.env.SECURITY_HOSTS_FILE
|| path.join(platformPaths.dataDir || path.join(__dirname, '../../data'), 'security-hosts.json');
const KEY_PREFIX = 'dca_'; // DashCaddy Agent key prefix — easy to spot in logs
class HostRegistry {
constructor(opts = {}) {
this.filePath = opts.filePath || HOSTS_FILE;
this.log = opts.log || console;
this.hosts = new Map(); // id -> host record (without raw api_key)
this._keyHash = new Map(); // api_key_hash -> host_id (for O(1) ingest auth lookup)
this._load();
}
_load() {
try {
if (!fs.existsSync(this.filePath)) {
// First run — register the self host automatically
this._registerSelf();
this._save();
return;
}
const raw = JSON.parse(fs.readFileSync(this.filePath, 'utf8'));
for (const h of raw.hosts || []) {
this.hosts.set(h.id, h);
if (h.api_key_hash) this._keyHash.set(h.api_key_hash, h.id);
}
this.log.info?.('security', 'host registry loaded', { count: this.hosts.size });
} catch (e) {
this.log.error?.('security', 'host registry load failed', { error: e.message });
}
}
_registerSelf() {
const hostname = require('os').hostname();
const apiKey = KEY_PREFIX + crypto.randomBytes(24).toString('base64url');
const hash = this._hashKey(apiKey);
const host = {
id: 'self',
label: hostname,
type: 'self',
registered_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
meta: { hostname },
enabled: true,
api_key_hash: hash,
// We do NOT persist the raw api_key for self — it's only used for ingest from self.
// For self-ingest we call _selfKey() at runtime. For other hosts we surface the key
// exactly once at registration time.
_raw_key: apiKey,
};
this.hosts.set('self', host);
this._keyHash.set(hash, 'self');
this.log.info?.('security', 'registered self host', { id: host.id, label: host.label });
}
/**
* HMAC-SHA256 of the api key with a per-install pepper.
* Pepper is loaded from /data/.security-pepper if present, else a fixed default.
* The default pepper is NOT secret — it's just to make rainbow-table attacks on the
* stored hash harder if someone gets the file. Real auth security comes from
* not leaking the file (file mode 0600, root-only).
*/
_pepper() {
const pepperFile = path.join(platformPaths.dataDir || path.join(__dirname, '../../data'), '.security-pepper');
try {
if (fs.existsSync(pepperFile)) return fs.readFileSync(pepperFile, 'utf8').trim();
} catch {}
return 'dashcaddy-default-pepper';
}
_hashKey(key) {
return crypto.createHmac('sha256', this._pepper()).update(key).digest('hex');
}
/**
* Register a new host. Returns the record and the raw api_key (only chance
* the caller will see it — they must store it on their side).
*/
register({ id, label, type = 'service', meta = {}, enabled = true }) {
if (!id || typeof id !== 'string') throw new Error('id required');
if (this.hosts.has(id)) throw new Error(`host ${id} already registered`);
const apiKey = KEY_PREFIX + crypto.randomBytes(24).toString('base64url');
const hash = this._hashKey(apiKey);
const host = {
id,
label: label || id,
type,
registered_at: new Date().toISOString(),
last_seen_at: null,
meta,
enabled,
api_key_hash: hash,
};
this.hosts.set(id, host);
this._keyHash.set(hash, id);
this._save();
return { host: this._public(host), api_key: apiKey };
}
/**
* Authenticate an incoming ingest request by api key.
* Returns the host record on success, null on failure.
* Updates last_seen_at on success.
*/
authenticate(apiKey) {
if (!apiKey || typeof apiKey !== 'string') return null;
const hash = this._hashKey(apiKey);
const id = this._keyHash.get(hash);
if (!id) return null;
const host = this.hosts.get(id);
if (!host || !host.enabled) return null;
host.last_seen_at = new Date().toISOString();
// Don't save on every auth — that's a lot of writes. Persist periodically.
this._maybeSave();
return this._public(host);
}
/**
* Look up the self-host's api key for internal use (the DashCaddy process
* authenticating to itself when emitting events from log parsers, etc.).
*/
selfApiKey() {
const self = this.hosts.get('self');
return self ? self._raw_key : null;
}
get(id) {
const h = this.hosts.get(id);
return h ? this._public(h) : null;
}
list() {
return Array.from(this.hosts.values()).map(h => this._public(h));
}
update(id, patch) {
const h = this.hosts.get(id);
if (!h) return null;
// Allowed mutable fields
const allowed = ['label', 'type', 'meta', 'enabled'];
for (const k of allowed) {
if (k in patch) h[k] = patch[k];
}
this._save();
return this._public(h);
}
remove(id) {
if (id === 'self') throw new Error('cannot remove self host');
const h = this.hosts.get(id);
if (!h) return false;
this.hosts.delete(id);
if (h.api_key_hash) this._keyHash.delete(h.api_key_hash);
this._save();
return true;
}
_public(h) {
// Strip the raw key + hash from anything returned externally
const { _raw_key, api_key_hash, ...pub } = h;
return pub;
}
_maybeSave() {
// Coalesce: only save at most once per 5 seconds under auth load
const now = Date.now();
if (this._lastSave && (now - this._lastSave) < 5000) return;
this._lastSave = now;
this._save();
}
_save() {
const data = { hosts: Array.from(this.hosts.values()) };
const tmp = this.filePath + '.tmp';
try {
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
fs.renameSync(tmp, this.filePath);
} catch (e) {
this.log.error?.('security', 'host registry save failed', { error: e.message });
}
}
}
let _instance = null;
function getRegistry(opts) {
if (_instance) return _instance;
_instance = new HostRegistry(opts);
return _instance;
}
module.exports = { HostRegistry, getRegistry };
@@ -361,6 +361,11 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
] : []),
{ path: '/api/v1/version', exact: true, method: 'GET' },
// Security ingest endpoints — auth via per-host Bearer token (handled in routes/security.js),
// NOT via TOTP/JWT. This lets remote DashCaddy agents and log parsers push events
// without needing a TOTP session.
{ path: '/api/v1/security/events/ingest', exact: true, method: 'POST' },
{ path: '/api/v1/security/events/batch', exact: true, method: 'POST' },
];
function isPublicRoute(req) {
+1
View File
@@ -56,6 +56,7 @@ const bundles = {
JS('compose-import.js'),
JS('container-exec.js'),
JS('audit-log.js'),
JS('security-center.js'),
JS('weather.js'),
JS('clock.js'),
JS('card-badges.js'),
+1
View File
@@ -205,6 +205,7 @@
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
</div>
</div>
+343
View File
@@ -0,0 +1,343 @@
// ========== SECURITY CENTER ==========
// Multi-source security event viewer + host registry UI.
//
// Shows live events from any source (API audit, Caddy access log, fail2ban,
// shared_bans) on any registered host (this DashCaddy, future remote agents).
//
// Live-tail uses SSE (/api/v1/security/events/stream).
(function() {
injectModal('security-modal', `<div id="security-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>🛡️ Security Center</h3>
<p class="modal-subtitle">
Live events from API, Caddy, fail2ban & shared_bans. Live-tail via SSE.
</p>
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
<button class="sec-tab active" data-tab="overview">Overview</button>
<button class="sec-tab" data-tab="events">Events</button>
<button class="sec-tab" data-tab="hosts">Hosts</button>
</div>
<!-- OVERVIEW TAB -->
<div class="sec-panel" data-panel="overview">
<div class="sec-stats" id="sec-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px;">
<div class="sec-stat" data-key="total">— events</div>
<div class="sec-stat" data-key="warn">— warnings</div>
<div class="sec-stat" data-key="error">— errors</div>
<div class="sec-stat" data-key="denied">— denied</div>
<div class="sec-stat" data-key="hosts">— hosts</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
<div>
<h4 style="margin:8px 0 6px;">Top Actors (24h)</h4>
<div id="sec-top-actors" class="scroll-container" style="max-height:240px;">—</div>
</div>
<div>
<h4 style="margin:8px 0 6px;">Top Targets (24h)</h4>
<div id="sec-top-targets" class="scroll-container" style="max-height:240px;">—</div>
</div>
</div>
</div>
<!-- EVENTS TAB -->
<div class="sec-panel" data-panel="events" style="display:none;">
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
<select id="sec-filter-source" class="sec-filter">
<option value="">All sources</option>
<option value="api">API</option>
<option value="caddy">Caddy</option>
<option value="fail2ban">fail2ban</option>
<option value="shared-bans">shared_bans</option>
<option value="agent">Agent</option>
</select>
<select id="sec-filter-severity" class="sec-filter">
<option value="">All severities</option>
<option value="critical">critical</option>
<option value="error">error</option>
<option value="warn">warn</option>
<option value="notice">notice</option>
<option value="info">info</option>
</select>
<select id="sec-filter-host" class="sec-filter">
<option value="">All hosts</option>
</select>
<input id="sec-filter-actor" class="sec-filter" placeholder="actor (IP or user)" style="padding:6px 10px;">
<button id="sec-refresh-btn" class="btn-sm">🔄 Refresh</button>
<label style="margin-left:auto;display:flex;align-items:center;gap:6px;">
<input type="checkbox" id="sec-live-tail" checked>
<span>Live tail</span>
</label>
</div>
<div id="sec-events-container" class="scroll-container" style="max-height:480px;">Loading…</div>
</div>
<!-- HOSTS TAB -->
<div class="sec-panel" data-panel="hosts" style="display:none;">
<div style="display:flex;gap:8px;margin-bottom:12px;">
<button id="sec-host-register-btn" class="btn-sm"> Register Host</button>
<button id="sec-hosts-refresh" class="btn-sm">🔄 Refresh</button>
</div>
<div id="sec-hosts-container" class="scroll-container" style="max-height:480px;">Loading…</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="sec-cancel">Close</button>
</div>
</div>
</div>`);
// ============= STATE =============
const modal = document.getElementById('security-modal');
const openBtn = document.getElementById('security-center-btn');
const cancelBtn = document.getElementById('sec-cancel');
const tabs = modal.querySelectorAll('.sec-tab');
const panels = modal.querySelectorAll('.sec-panel');
let allEvents = []; // newest first
let allHosts = [];
let sseSource = null;
// ============= TAB SWITCHING =============
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.toggle('active', t === tab));
panels.forEach(p => p.style.display = p.dataset.panel === tab.dataset.tab ? '' : 'none');
if (tab.dataset.tab === 'overview') refreshOverview();
if (tab.dataset.tab === 'events') refreshEvents();
if (tab.dataset.tab === 'hosts') refreshHosts();
});
});
// ============= OPEN/CLOSE =============
if (openBtn) {
openBtn.addEventListener('click', () => {
modal.classList.add('show');
refreshOverview();
startLiveTail();
});
}
cancelBtn.addEventListener('click', closeModal);
modal.addEventListener('click', e => { if (e.target === modal) closeModal(); });
function closeModal() {
modal.classList.remove('show');
stopLiveTail();
}
// ============= LIVE TAIL (SSE) =============
function startLiveTail() {
stopLiveTail();
if (!document.getElementById('sec-live-tail').checked) return;
if (typeof EventSource === 'undefined') return; // browser doesn't support
try {
sseSource = new EventSource('/api/v1/security/events/stream');
sseSource.addEventListener('init', (e) => {
try {
const data = JSON.parse(e.data);
allEvents = data.events || [];
renderEvents();
} catch {}
});
sseSource.addEventListener('security', (e) => {
try {
const ev = JSON.parse(e.data);
allEvents.unshift(ev);
if (allEvents.length > 500) allEvents.length = 500;
// Auto-refresh whichever panel is showing
const active = modal.querySelector('.sec-tab.active')?.dataset?.tab;
if (active === 'events') renderEvents();
else if (active === 'overview') refreshOverview();
} catch {}
});
sseSource.onerror = () => { /* browser auto-reconnects */ };
} catch (e) {
console.warn('[security] SSE failed:', e.message);
}
}
function stopLiveTail() {
if (sseSource) { try { sseSource.close(); } catch {} sseSource = null; }
}
document.getElementById('sec-live-tail').addEventListener('change', () => {
if (modal.classList.contains('show')) startLiveTail();
});
// ============= OVERVIEW =============
async function refreshOverview() {
try {
const since = new Date(Date.now() - 24*60*60*1000).toISOString();
const [statsRes, hostsRes, eventsRes] = await Promise.all([
fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(since)}`),
fetch('/api/v1/security/hosts'),
fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(since)}`),
]);
const stats = (await statsRes.json()).data || {};
const hosts = (await hostsRes.json()).data?.hosts || [];
const eventsCount = (await eventsRes.json()).data?.total || 0;
document.querySelector('#sec-stats [data-key="total"]').textContent = `${eventsCount} events (24h)`;
document.querySelector('#sec-stats [data-key="warn"]').textContent = `${stats.by_severity?.warn || 0} warnings`;
document.querySelector('#sec-stats [data-key="error"]').textContent = `${stats.by_severity?.error || 0} errors`;
document.querySelector('#sec-stats [data-key="denied"]').textContent = `${stats.by_outcome?.denied || 0} denied`;
document.querySelector('#sec-stats [data-key="hosts"]').textContent = `${hosts.length} hosts`;
renderTopList('sec-top-actors', stats.top_actors || []);
renderTopList('sec-top-targets', stats.top_targets || []);
} catch (e) {
console.warn('[security] refreshOverview failed:', e.message);
}
}
function renderTopList(id, items) {
const el = document.getElementById(id);
if (!items.length) { el.innerHTML = '<div class="panel-empty">No data</div>'; return; }
el.innerHTML = '<table style="width:100%;font-size:0.85rem;">' +
items.map(it => `<tr><td style="padding:3px 0;word-break:break-all;">${escapeHtml(String(it.key))}</td><td style="text-align:right;color:var(--muted);">${it.count}</td></tr>`).join('') +
'</table>';
}
// ============= EVENTS =============
const filterSource = document.getElementById('sec-filter-source');
const filterSeverity = document.getElementById('sec-filter-severity');
const filterHost = document.getElementById('sec-filter-host');
const filterActor = document.getElementById('sec-filter-actor');
const refreshBtn = document.getElementById('sec-refresh-btn');
[filterSource, filterSeverity, filterHost].forEach(el => el.addEventListener('change', refreshEvents));
filterActor.addEventListener('input', debounce(refreshEvents, 250));
refreshBtn.addEventListener('click', refreshEvents);
async function refreshEvents() {
try {
const params = new URLSearchParams();
params.set('limit', '200');
if (filterSource.value) params.set('source_type', filterSource.value);
if (filterSeverity.value) params.set('severity', filterSeverity.value);
if (filterHost.value) params.set('source_host', filterHost.value);
if (filterActor.value) params.set('actor_prefix', filterActor.value);
const res = await fetch(`/api/v1/security/events?${params}`);
const data = (await res.json()).data;
allEvents = data.events || [];
renderEvents();
// Refresh host dropdown if we don't have it yet
if (!filterHost.options.length || filterHost.options.length === 1) {
await refreshHostFilter();
}
} catch (e) {
document.getElementById('sec-events-container').innerHTML = '<div class="panel-empty">Load failed: '+escapeHtml(e.message)+'</div>';
}
}
function renderEvents() {
const el = document.getElementById('sec-events-container');
if (!allEvents.length) { el.innerHTML = '<div class="panel-empty">No events</div>'; return; }
el.innerHTML = allEvents.slice(0, 200).map(renderEventRow).join('');
}
function renderEventRow(ev) {
const sev = ev.severity || 'info';
const sevColor = {
critical: '#c0392b', error: '#e74c3c', warn: '#f39c12',
notice: '#3498db', info: '#7f8c8d',
}[sev] || '#7f8c8d';
const time = ev.ts ? new Date(ev.ts).toLocaleTimeString() : '';
const source = ev.source_type || '';
const actor = ev.actor || '—';
const target = ev.target || '';
const action = ev.action || '';
const outcome = ev.outcome || '';
return `<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${sevColor};font-weight:600;">${escapeHtml(sev)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${escapeHtml(source)}</span>
<span title="${escapeHtml(actor)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(actor)}</span>
<span title="${escapeHtml(target)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escapeHtml(action)} ${escapeHtml(target)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${escapeHtml(outcome)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${escapeHtml(time)}</span>
</div>`;
}
async function refreshHostFilter() {
try {
const res = await fetch('/api/v1/security/hosts');
const hosts = (await res.json()).data?.hosts || [];
const current = filterHost.value;
filterHost.innerHTML = '<option value="">All hosts</option>' +
hosts.map(h => `<option value="${escapeHtml(h.id)}">${escapeHtml(h.label || h.id)}</option>`).join('');
if (current) filterHost.value = current;
} catch {}
}
// ============= HOSTS =============
document.getElementById('sec-host-register-btn').addEventListener('click', registerHostPrompt);
document.getElementById('sec-hosts-refresh').addEventListener('click', refreshHosts);
async function refreshHosts() {
try {
const res = await fetch('/api/v1/security/hosts');
const hosts = (await res.json()).data?.hosts || [];
allHosts = hosts;
const el = document.getElementById('sec-hosts-container');
if (!hosts.length) { el.innerHTML = '<div class="panel-empty">No hosts registered. Click Register Host to add one.</div>'; return; }
el.innerHTML = hosts.map(h => {
const status = !h.enabled ? '🔴 disabled'
: !h.last_seen_at ? '⚪ registered'
: (Date.now() - Date.parse(h.last_seen_at) > 30*60*1000) ? '🟡 stale'
: '🟢 online';
return `<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${escapeHtml(h.label || h.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${escapeHtml(h.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${escapeHtml(h.id)} ·
registered ${new Date(h.registered_at).toLocaleDateString()} ·
last seen ${h.last_seen_at ? new Date(h.last_seen_at).toLocaleString() : 'never'}
</div>
</div>
<div>
<span style="font-size:0.85rem;margin-right:12px;">${status}</span>
${h.id === 'self' ? '' : `<button class="btn-sm sec-host-del" data-id="${escapeHtml(h.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div>
</div>`;
}).join('');
el.querySelectorAll('.sec-host-del').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm(`Remove host ${btn.dataset.id}? Events already received will remain in the store.`)) return;
await fetch(`/api/v1/security/hosts/${encodeURIComponent(btn.dataset.id)}`, { method: 'DELETE' });
refreshHosts();
});
});
} catch (e) {
document.getElementById('sec-hosts-container').innerHTML = '<div class="panel-empty">Load failed: '+escapeHtml(e.message)+'</div>';
}
}
async function registerHostPrompt() {
const id = prompt('Host id (lowercase, no spaces):');
if (!id) return;
const label = prompt('Display label:', id) || id;
const type = prompt('Type ("dashcaddy", "service", or "agent"):', 'agent') || 'agent';
try {
const res = await fetch('/api/v1/security/hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, label, type }),
});
const data = await res.json();
if (!res.ok) { alert('Failed: ' + (data?.error?.message || res.statusText)); return; }
// Show the api_key ONCE in a dialog
alert(`✅ Host registered!\n\nid: ${data.data.host.id}\nlabel: ${data.data.host.label}\ntype: ${data.data.host.type}\n\n🔑 API KEY (save this NOW — won't be shown again):\n\n${data.data.api_key}\n\nSend this key as: Authorization: Bearer <api_key>\nTo endpoint: POST /api/v1/security/events/ingest or /events/batch`);
refreshHosts();
} catch (e) {
alert('Failed: ' + e.message);
}
}
// ============= HELPERS =============
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function debounce(fn, ms) {
let t; return function() { clearTimeout(t); t = setTimeout(() => fn.apply(this, arguments), ms); };
}
})();