/** * 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 * 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, '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, '.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 };