Add Security Center — multi-source event pipeline with dashboard UI
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:
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user