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
+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 };