Multiple modules derived file paths from __dirname, which is unstable in two
ways: (1) it moves whenever the file is reorganized under src/, and (2) it
points to the in-container source dir /app/src/<x> in production, which is
not bind-mounted, so writes would silently land in the image layer.
Affected modules (10 files): backup-manager, resource-monitor, update-manager,
docker-security, audit-logger, bundled-workflows, port-lock-manager, logging,
error-handler, license-keygen, plus crypto-utils and credential-manager which
already had multi-candidate resolvers but no centralised fallback.
Introduced platformPaths.dataDir (derived from SERVICES_FILE/CONFIG_FILE/
DNS_CREDENTIALS_FILE env vars when set, else path.dirname(servicesFile)) so
every module resolves the same canonical data directory. Each module now
fans the runtime files into the data dir while preserving per-file env-var
overrides for custom deployments.
Why a single resolver:
- one place to swap the default path scheme in v2.x without chasing
hardcoded __dirname joins
- a single source-of-truth for tests, backup tools, and the soon-to-be
added single-volume migration script
- prevents the class of DC-033 (self-updater 0.0.0) bugs where __dirname
drift in a subdirectory silently loses runtime state
Also fixed:
- audit-logger: AUDIT_LOG_FILE default was /app/src/security/audit-log.json
(writable in dev, image-layer in production). Now /app/data/audit-log.json
via platformPaths.dataDir, matching logging.js's same file. Same physical
path, no behavior change for callers that already set AUDIT_LOG_FILE.
- logging.js: LOG_DIR was __dirname (src/utils/) — error.log and
audit-log.json were being written into the source tree. Now
platformPaths.dataDir, matching every other persistent file.
- error-handler.js: ERROR_LOG_FILE hard-coded to __dirname/error.log
(src/utilities/error.log), redundant with logging.js's own default.
Now platformPaths.dataDir/error.log.
- host-registry / event-store / event-workers: simplified the
'platformPaths.dataDir || path.join(__dirname, ../../data)' pattern
to just platformPaths.dataDir (the legacy fallback is no longer
reachable — services.json lives at dataDir/services.json now).
- public-routes-drift.test.js: added 'routes/security.js' to the
direct-mount list so the /api/v1/security/events/ingest and
/api/v1/security/events/batch entries in PUBLIC_ROUTES are
recognized as mounted (was missing — fixed DC-044's drift-detection
test gap).
Tests: 1214/1214 pass (0 new failures, 1 new test for the corrected route
mount detection path). ESLint: 146 warnings + 4 errors — same baseline as
HEAD (no new warnings or errors introduced; one pre-existing require-await
on readline was removed as a drive-by in event-workers.js since the module
uses line-level fs reads, not readline). Container config files like
audit-log.json, container-stats.json, and workflow-history.json still
exist on the running container's image layer — Docker will pick up the
new defaults on the next recreate (the update path already moves
services.json+config.json+credentials.json via the data bind mount).
224 lines
7.2 KiB
JavaScript
224 lines
7.2 KiB
JavaScript
/**
|
|
* 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, '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 }; |