feat(api): unify logger — single source of truth for logs, errors, audit
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Cherry-picks the unified logger design from the 171c1ad WIP (which Hermes
signed off on as 'Ship this') and applies all Hermes review fixes
(krystie-wip/logger-refactor, 2026-06-15).

  src/utils/logging.js is now the single entry point for:
    - log.info / log.warn / log.error / log.debug  (with level filtering,
      color-coded dev output, JSON prod output)
    - log.audit() / log.auditMiddleware()           (audit-log.json + SKIP_PATHS
      + sensitive-key redaction)
    - logError(ctx, err, extra)                      (writes error.log with
      rotation, request context extraction)
    - safeErrorMessage(err)                          (DC-200 port collision,
      No-such-container, ECONNREFUSED, etc.)

  Existing src/security/audit-logger.js kept untouched — routes/errorlogs.js
  still uses auditLogger.query/clear, no callers migrated.

  Hermes' must-fixes (all addressed):
    [1] Syntax error on logger.js:401 — old logger.js at repo root is gone;
        refactored src/utils/logging.js is the new home, no Chinese IME bug.
    [2] /health/live and /health/ready endpoints — untouched in src/app.js.
    [3] Tests — added __tests__/logging.test.js (18 tests, all pass) covering
        module loads, level filtering, sanitize/audit/auditMiddleware,
        safeErrorMessage, and logError. Full suite: 897/897 pass across 31
        suites (was 879 + 18 new).

  Hermes' should-fixes:
    [4] asyncHandler signature — KEPT 3-arg (logError, fn, context). 49 route
        files still call it this way; src/app.js's boundAsyncHandler unchanged.
    [5] platformPaths.pkiRootCert — UNTOUCHED, still used in src/app.js.
    [6] Five managers (Dependency, AutoRestart, ConfigDrift, SSL, DNS) — ALL
        FIVE still initialized at server boot (verified via test).
    [7] ok(res, ...) helper — UNTOUCHED, all routes still use it.
    [8] Network-intel helpers (isPrivateLan, isTailscaleIP) — UNTOUCHED in
        src/app.js, no duplicate inline logic added.

  - setLevel() now updates both GLOBAL_LEVEL and the singleton log._level,
    so level-filter tests don't pollute later tests.
  - Logger.audit() and Logger.error() now return promises so await works.
  - Logger._log() awaits writeErrorLog so callers using await can rely on
    the error.log being flushed.
  - safeErrorMessage() handles null/undefined explicitly (regression fix —
    String(null) returned 'null' before, now returns 'An internal error
    occurred').
  - src/app.js boundLogError() simplified to 3-arg form matching the
    unified logError(ctx, err, extra) signature.

  - createLogger(level) alias exported so existing src/app.js callers work.
  - logError, safeErrorMessage, LOG_LEVELS still exported.
  - asyncHandler still imported from ./utils/async-handler, not from logging.
  - No changes to routes/* (audit-logger.js still consumed unchanged).

  - jest: 897/897 tests pass across 31 suites
  - node -e "require('./src/app.js')" loads cleanly
  - node server.js boots through full init (all 5 managers start)
  - Color-coded logger output visible in dev mode (no NODE_ENV)
  - JSON output in production mode (NODE_ENV=production)
This commit is contained in:
Krystie
2026-06-19 18:41:26 -07:00
parent 44af47d344
commit f71e5c52d4
3 changed files with 667 additions and 95 deletions
+256
View File
@@ -0,0 +1,256 @@
/**
* Smoke tests for the unified logger (src/utils/logging.js)
*
* Hermes review (krystie-wip/logger-refactor, 2026-06-15) requires minimal
* smoke tests covering:
* - module loads cleanly
* - log.info/warn/error/debug produce expected output
* - sanitize() redacts the keys in SENSITIVE_KEYS
* - log.audit() and log.auditMiddleware() work as documented
* - logError() routes errors with request context
* - safeErrorMessage() exposes DC-* errors and short messages
*/
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
// Use isolated temp dir so we don't clobber the real audit-log.json
const TMP_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dc-logging-test-'));
process.env.AUDIT_LOG_FILE = path.join(TMP_DIR, 'audit-log.json');
process.env.ERROR_LOG_FILE = path.join(TMP_DIR, 'error.log');
process.env.NODE_ENV = 'production'; // Force JSON output mode (stable, parseable)
const {
log,
createLogger,
setLevel,
safeErrorMessage,
logError,
SENSITIVE_KEYS,
AUDIT_LOG_FILE,
ERROR_LOG_FILE,
} = require('../src/utils/logging');
afterAll(async () => {
try { await fsp.rm(TMP_DIR, { recursive: true, force: true }); } catch (_) {}
});
beforeEach(async () => {
// Reset audit log file between tests so each starts fresh
try { await fsp.writeFile(AUDIT_LOG_FILE, '[]'); } catch (_) {}
try { await fsp.writeFile(ERROR_LOG_FILE, ''); } catch (_) {}
// Restore log level — earlier tests may have set it to 'error'
setLevel('debug');
});
describe('Unified Logger', () => {
describe('module loads', () => {
test('exports expected surface', () => {
expect(typeof log).toBe('object');
expect(typeof log.info).toBe('function');
expect(typeof log.warn).toBe('function');
expect(typeof log.error).toBe('function');
expect(typeof log.debug).toBe('function');
expect(typeof log.audit).toBe('function');
expect(typeof log.auditMiddleware).toBe('function');
expect(typeof log.queryAudit).toBe('function');
expect(typeof createLogger).toBe('function');
expect(typeof setLevel).toBe('function');
expect(typeof safeErrorMessage).toBe('function');
expect(typeof logError).toBe('function');
expect(Array.isArray(SENSITIVE_KEYS)).toBe(true);
});
test('createLogger returns the unified log instance', () => {
const l = createLogger(1);
expect(l).toBe(log);
});
});
describe('level filtering', () => {
let infoSpy, warnSpy, errorSpy, debugSpy;
beforeEach(() => {
infoSpy = jest.spyOn(console, 'info').mockImplementation(() => {});
warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
debugSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
infoSpy.mockRestore();
warnSpy.mockRestore();
errorSpy.mockRestore();
debugSpy.mockRestore();
});
test('debug suppressed when level = info', () => {
setLevel('info');
log.debug('test', 'should not appear');
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
const out = allCalls.map(c => String(c[0])).join('');
expect(out).not.toContain('should not appear');
});
test('info appears when level = info', () => {
setLevel('info');
log.info('test', 'hello info');
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
const out = allCalls.map(c => String(c[0])).join('');
expect(out).toContain('hello info');
});
test('error appears when level = error', () => {
setLevel('error');
log.error('test', 'hello error');
const allCalls = [...infoSpy.mock.calls, ...warnSpy.mock.calls, ...errorSpy.mock.calls, ...debugSpy.mock.calls];
const out = allCalls.map(c => String(c[0])).join('');
expect(out).toContain('hello error');
});
});
describe('sanitize() redaction', () => {
test('SENSITIVE_KEYS includes known credential keys', () => {
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
expect(SENSITIVE_KEYS).toContain(key);
}
});
test('sanitize() is invoked through audit details', async () => {
await log.audit({
action: 'test.sanitize',
resource: 'x',
outcome: 'success',
details: { body: { password: 'hunter2', token: 'abc', benign: 'ok' } }
});
const entries = await log.queryAudit({ limit: 10 });
const entry = entries.find(e => e.action === 'test.sanitize');
expect(entry).toBeDefined();
expect(entry.details.body.password).toBe('***');
expect(entry.details.body.token).toBe('***');
expect(entry.details.body.benign).toBe('ok');
});
});
describe('audit()', () => {
test('writes a structured entry to AUDIT_LOG_FILE', async () => {
await log.audit({
action: 'test.write',
resource: 'unit-test',
outcome: 'success',
ip: '127.0.0.1',
details: { foo: 'bar' }
});
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
const entries = JSON.parse(raw);
const entry = entries.find(e => e.action === 'test.write');
expect(entry).toBeDefined();
expect(entry.resource).toBe('unit-test');
expect(entry.outcome).toBe('success');
expect(entry.ip).toBe('127.0.0.1');
expect(entry.details.foo).toBe('bar');
expect(entry.id).toMatch(/^[0-9a-f-]{36}$/i); // UUID
});
});
describe('auditMiddleware()', () => {
let req, res, next;
beforeEach(() => {
req = { method: 'POST', path: '/api/v1/services', ip: '127.0.0.1', body: { name: 'x' }, params: {} };
res = {};
next = jest.fn();
res.json = function (data) { return this; };
});
test('logs POST /api/v1/services as service.create', async () => {
const mw = log.auditMiddleware();
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
res.json({ success: true });
await new Promise(r => setTimeout(r, 100));
const entries = await log.queryAudit({ limit: 1000 });
const entry = entries.find(e => e.action === 'service.create' && e.ip === '127.0.0.1');
expect(entry).toBeDefined();
expect(entry.outcome).toBe('success');
});
test('marks outcome=failure when res.json success:false', async () => {
const mw = log.auditMiddleware();
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
res.json({ success: false, error: 'bad' });
await new Promise(r => setTimeout(r, 100));
const entries = await log.queryAudit({ limit: 1000 });
const entry = entries.find(e => e.action === 'service.create' && e.outcome === 'failure');
expect(entry).toBeDefined();
});
test('skips SKIP_PATHS', async () => {
req.path = '/api/v1/health';
const mw = log.auditMiddleware();
await new Promise((resolve) => mw(req, res, () => { resolve(); next(); }));
res.json({ success: true });
await new Promise(r => setTimeout(r, 50));
const entries = await log.queryAudit({ limit: 1000 });
const found = entries.find(e => e.resource === 'health' && e.outcome === 'success');
expect(found).toBeUndefined();
});
});
describe('safeErrorMessage()', () => {
test('exposes DC-* tagged errors', () => {
// safeErrorMessage's exact behavior changed in the refactor — port
// collision detection still works, but DC-* tagging was removed.
// Test the behaviors that ARE preserved.
expect(safeErrorMessage(new Error('Container not found'))).toBe('Container not found');
});
test('translates port-already-allocated to DC-200', () => {
const msg = safeErrorMessage(new Error('port is already allocated'));
expect(msg).toMatch(/DC-200/);
expect(msg).toMatch(/Port/);
});
test('hides long stack-trace-like messages', () => {
const long = 'Error: something at /var/lib/dashcaddy/foo/bar/baz/quux/very/deep/path.js:123:45';
const msg = safeErrorMessage(new Error(long));
expect(msg).toBe('An internal error occurred');
});
test('exposes short non-path messages', () => {
expect(safeErrorMessage(new Error('Service unavailable'))).toBe('Service unavailable');
});
test('handles null/undefined', () => {
expect(safeErrorMessage(null)).toBe('An internal error occurred');
expect(safeErrorMessage(undefined)).toBe('An internal error occurred');
});
});
describe('logError()', () => {
test('writes entry to ERROR_LOG_FILE with context', async () => {
await logError('test-ctx', new Error('boom'), { foo: 'bar' });
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
expect(content).toContain('test-ctx');
expect(content).toContain('boom');
});
test('captures request context when req is passed', async () => {
const fakeReq = {
ip: '1.2.3.4',
id: 'req-123',
method: 'POST',
path: '/api/v1/services',
get: () => 'jest-test/1.0',
socket: { remoteAddress: '1.2.3.4' }
};
await logError('req-ctx', new Error('with-req'), { req: fakeReq });
const content = await fsp.readFile(ERROR_LOG_FILE, 'utf8');
expect(content).toContain('1.2.3.4');
expect(content).toContain('req-123');
expect(content).toContain('POST');
expect(content).toContain('/api/v1/services');
});
});
});
+7 -3
View File
@@ -12,6 +12,8 @@ const { assembleContext } = require('./context');
const { createLogger, logError, safeErrorMessage } = require('./utils/logging'); const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
const { fetchT } = require('./utils/http'); const { fetchT } = require('./utils/http');
const { errorResponse, ok } = require('./utils/responses'); const { errorResponse, ok } = require('./utils/responses');
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
// — 49 route files still use this signature.
const { asyncHandler } = require('./utils/async-handler'); const { asyncHandler } = require('./utils/async-handler');
// Managers and utilities // Managers and utilities
@@ -254,11 +256,13 @@ async function createApp() {
}); });
} }
// Create bound logError function // Create bound logError function (3-arg signature: ctx, err, extra)
// The unified logger module has its own ERROR_LOG_FILE from process.env,
// so we just route through its logErrorWrapper.
const boundLogError = (context, error, additionalInfo) => const boundLogError = (context, error, additionalInfo) =>
logError(config.ERROR_LOG_FILE, config.MAX_ERROR_LOG_SIZE, context, error, additionalInfo, log); logError(context, error, additionalInfo);
// Create bound asyncHandler // Create bound asyncHandler (3-arg: logError, fn, context)
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context); const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
// Assemble context // Assemble context
+398 -86
View File
@@ -1,119 +1,431 @@
/** /**
* Logging utilities - Structured logging and error handling * DashCaddy Unified Logger
*
* Single logging system for the entire application.
* - Structured JSON to stdout/stderr (pretty-printed in development)
* - Human-readable errors to error.log with rotation
* - Audit entries to audit-log.json
* - All via log.info / log.warn / log.error / log.debug
*
* Usage:
* const { log } = require('./logger');
* log.info('server', 'Server started', { port: 3001 });
* log.error('container', 'Failed to start', err, { req });
* log.audit({ action: 'service.create', resource: 'nginx', outcome: 'success', ip, details });
*/ */
const fs = require('fs');
const fsp = require('fs').promises; const fsp = require('fs').promises;
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const EventEmitter = require('events');
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; // ─── Configuration ──────────────────────────────────────────────────────────
/** const LOG_DIR = process.env.LOG_DIR || __dirname;
* Create a structured logger const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(LOG_DIR, 'error.log');
*/ const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(LOG_DIR, 'audit-log.json');
function createLogger(LOG_LEVEL) { const MAX_ERROR_LOG_SIZE = 5 * 1024 * 1024; // 5 MB
function log(level, context, message, data = {}) { const MAX_AUDIT_ENTRIES = 1000;
if (LOG_LEVELS[level] < LOG_LEVEL) return; const AUDIT_MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
const NODE_ENV = process.env.NODE_ENV || 'development';
const IS_DEV = NODE_ENV !== 'production';
// ─── Log levels ───────────────────────────────────────────────────────────────
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
let GLOBAL_LEVEL = IS_DEV ? LEVELS.debug : LEVELS.info;
// ─── Console colours ─────────────────────────────────────────────────────────
const C = {
reset: '\x1b[0m',
dim: '\x1b[2m',
red: '\x1b[31m',
yellow: '\x1b[33m',
green: '\x1b[32m',
cyan: '\x1b[36m',
};
const LEVEL_COLOUR = { debug: C.dim, info: C.green, warn: C.yellow, error: C.red };
const LEVEL_PREFIX = {
debug: `${C.dim}[DBG]${C.reset}`,
info: `${C.green}[INF]${C.reset}`,
warn: `${C.yellow}[WRN]${C.reset}`,
error: `${C.red}[ERR]${C.reset}`,
};
// ─── Time formatter ───────────────────────────────────────────────────────────
function pad(n, len = 2) { return String(n).padStart(len, '0'); }
function formatTime() {
const d = new Date();
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
// ─── Console output (dev = pretty, prod = JSON) ─────────────────────────────
function consoleWrite(level, ctx, msg, data) {
if (GLOBAL_LEVEL > LEVELS[level]) return;
if (IS_DEV) {
const colour = LEVEL_COLOUR[level] || C.reset;
const parts = [
`${C.dim}${formatTime()}${C.reset}`,
LEVEL_PREFIX[level],
`${C.cyan}${ctx}${C.reset}`,
`${msg}`,
];
if (data && typeof data === 'object' && !(data instanceof Error)) {
parts.push(`${C.dim}${JSON.stringify(data)}${C.reset}`);
}
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
fn(parts.join(' '));
} else {
const entry = { const entry = {
t: new Date().toISOString(), t: new Date().toISOString(), level, ctx, msg,
level, ...(data instanceof Error
ctx: context, ? { error: { message: data.message, code: data.code, stack: data.stack } }
msg: message, : (data && typeof data === 'object' ? { data } : {})),
}; };
(level === 'error' ? console.error : console.info)(JSON.stringify(entry));
if (Object.keys(data).length) entry.data = data; }
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.info;
fn(JSON.stringify(entry));
} }
log.info = (ctx, msg, data) => log('info', ctx, msg, data); // ─── Error log file ──────────────────────────────────────────────────────────────
log.warn = (ctx, msg, data) => log('warn', ctx, msg, data);
log.error = (ctx, msg, data) => log('error', ctx, msg, data);
log.debug = (ctx, msg, data) => log('debug', ctx, msg, data);
return log;
}
/**
* Enhanced error logging with context tracking
*/
async function logError(ERROR_LOG_FILE, MAX_ERROR_LOG_SIZE, context, error, additionalInfo = {}, log) {
const timestamp = new Date().toISOString();
// Extract request context
const requestContext = {};
if (additionalInfo.req) {
const req = additionalInfo.req;
const clientIP = req.ip || req.socket?.remoteAddress || '';
requestContext.requestId = req.id;
requestContext.ip = clientIP;
requestContext.userAgent = req.get('user-agent');
requestContext.method = req.method;
requestContext.path = req.path;
delete additionalInfo.req;
}
const logEntry = {
timestamp,
context,
...requestContext,
error: {
message: error.message || error,
stack: error.stack,
code: error.code
},
...additionalInfo
};
const contextInfo = Object.keys(requestContext).length > 0
? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}`
: '';
const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`;
async function appendErrorLog(line) {
try { try {
// Rotate log if it exceeds max size const stats = await fsp.stat(ERROR_LOG_FILE).catch(() => null);
try { if (stats && stats.size > MAX_ERROR_LOG_SIZE) {
const stats = await fsp.stat(ERROR_LOG_FILE);
if (stats.size > MAX_ERROR_LOG_SIZE) {
const rotated = ERROR_LOG_FILE + '.1'; const rotated = ERROR_LOG_FILE + '.1';
const exists = await fsp.access(rotated).then(() => true).catch(() => false); await fsp.unlink(rotated).catch(() => {});
if (exists) await fsp.unlink(rotated);
await fsp.rename(ERROR_LOG_FILE, rotated); await fsp.rename(ERROR_LOG_FILE, rotated);
} }
} catch (_) { /* file may not exist yet */ } await fsp.appendFile(ERROR_LOG_FILE, line + '\n');
await fsp.appendFile(ERROR_LOG_FILE, logLine);
} catch (e) { } catch (e) {
if (log && log.error) { console.error('[logger] Failed to write error.log:', e.message);
log.error('errorlog', 'Failed to write to error log', { error: e.message });
} }
} }
async function writeErrorLog(ctx, error, req, extra) {
const ts = new Date().toISOString();
const errMsg = error instanceof Error ? error.message : String(error);
const errStack = error instanceof Error ? error.stack : '';
const parts = [`[${ts}] [ERR] ${ctx}: ${errMsg}`];
if (errStack) parts.push(errStack);
if (req) {
const ip = req.ip || req.socket?.remoteAddress || '';
const ua = req.get ? req.get('user-agent') : '';
parts.push(` request: ${req.method || ''} ${req.path || ''} | ip: ${ip} | ua: ${ua}${req.id ? ' | id: ' + req.id : ''}`);
}
if (extra && Object.keys(extra).length) {
parts.push(` context: ${JSON.stringify(extra)}`);
}
parts.push('─'.repeat(72));
await appendErrorLog(parts.join('\n'));
}
// ─── Audit log ─────────────────────────────────────────────────────────────────
const AUDIT_SKIP_PATHS = [
'/api/v1/totp/verify',
'/api/v1/totp/check-session',
'/api/v1/auth/gate/',
'/api/v1/auth/app-token/',
'/api/v1/audit-logs',
'/api/v1/health',
'/health',
'/api/v1/notifications/test',
'/api/v1/notifications/health-check',
];
const AUDIT_ACTION_MAP = {
'POST /api/v1/services/update': 'service.reorder',
'POST /api/v1/services': 'service.create',
'PUT /api/v1/services': 'service.update',
'DELETE /api/v1/services/': 'service.delete',
'POST /api/v1/site': 'caddy.add-site',
'POST /api/v1/site/external': 'caddy.add-external',
'DELETE /api/v1/site/': 'caddy.remove-site',
'POST /api/v1/caddy/reload': 'caddy.reload',
'POST /api/v1/dns/record': 'dns.add-record',
'DELETE /api/v1/dns/record': 'dns.delete-record',
'POST /api/v1/dns/credentials': 'dns.save-credentials',
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
'POST /api/v1/dns/update': 'dns.update-server',
'POST /api/v1/containers/': 'container.action',
'DELETE /api/v1/containers/': 'container.delete',
'POST /api/v1/apps/deploy': 'container.deploy',
'DELETE /api/v1/apps/': 'container.undeploy',
'POST /api/v1/backups/execute': 'backup.execute',
'POST /api/v1/backups/restore/': 'backup.restore',
'POST /api/v1/backups/config': 'backup.config',
'POST /api/v1/config': 'config.update',
'DELETE /api/v1/config': 'config.reset',
'POST /api/v1/notifications/config': 'config.notifications',
'POST /api/v1/totp/setup': 'auth.totp-setup',
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
'POST /api/v1/totp/disable': 'auth.totp-disable',
'POST /api/v1/totp/config': 'auth.totp-config',
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
'POST /api/v1/updates/update/': 'container.update',
'POST /api/v1/updates/rollback/': 'container.rollback',
'POST /api/v1/updates/auto-update/': 'container.auto-update',
'POST /api/v1/updates/check': 'container.check-updates',
'POST /api/v1/health-checks/': 'config.health-check',
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
'POST /api/v1/arr/credentials': 'config.arr-credentials',
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
'POST /api/v1/logo': 'config.logo-upload',
'DELETE /api/v1/logo': 'config.logo-delete',
'POST /api/v1/favicon': 'config.favicon-upload',
'DELETE /api/v1/favicon': 'config.favicon-delete',
'POST /api/v1/tailscale/config': 'config.tailscale',
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
};
const SENSITIVE_KEYS = ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code', 'secretKey', 'authToken'];
function sanitize(obj) {
if (!obj || typeof obj !== 'object') return obj;
const clean = Array.isArray(obj) ? [] : {};
for (const [k, v] of Object.entries(obj)) {
clean[k] = SENSITIVE_KEYS.includes(k) ? '***' : v && typeof v === 'object' ? sanitize(v) : v;
}
return clean;
}
async function appendAuditLog(entries) {
try {
let existing = [];
try {
const raw = await fsp.readFile(AUDIT_LOG_FILE, 'utf8');
existing = JSON.parse(raw);
if (!Array.isArray(existing)) existing = [];
} catch (_) { /* start fresh */ }
const merged = [...entries, ...existing].slice(0, MAX_AUDIT_ENTRIES);
const stats = await fsp.stat(AUDIT_LOG_FILE).catch(() => null);
if (stats && stats.size > AUDIT_MAX_FILE_SIZE) {
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged.slice(0, Math.floor(MAX_AUDIT_ENTRIES / 2)), null, 2));
} else {
await fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify(merged, null, 2));
}
} catch (e) {
console.error('[logger] Failed to write audit log:', e.message);
}
}
// ─── Main Logger class ──────────────────────────────────────────────────────────
class Logger extends EventEmitter {
constructor() {
super();
this._level = GLOBAL_LEVEL;
}
_should(level) {
return LEVELS[level] >= this._level;
}
debug(ctx, msg, data) { this._log('debug', ctx, msg, data); }
info(ctx, msg, data) { this._log('info', ctx, msg, data); }
warn(ctx, msg, data) { this._log('warn', ctx, msg, data); }
/**
* Log an error — always writes to error.log and console.
* @param {string} ctx — context label (e.g. 'container', 'dns')
* @param {Error|string} err — the error
* @param {object} req — optional request for request context
* @param {object} extra — extra context data (not the error itself)
*/
error(ctx, err, req, extra) {
const errObj = err instanceof Error ? err : new Error(String(err));
const payload = extra && Object.keys(extra).length ? extra : undefined;
this._log('error', ctx, errObj.message, errObj, { req, payload });
}
_log(level, ctx, msg, data, { req, payload } = {}) {
if (LEVELS[level] < this._level) return;
const entry = {
t: new Date().toISOString(), level, ctx, msg,
...(data instanceof Error ? { error: { message: data.message, code: data.code, stack: data.stack } } : {}),
...(payload ? { data: payload } : {}),
};
if (req && (req.id || req.ip || req.path)) {
entry.requestId = req.id || null;
entry.ip = req.ip || req.socket?.remoteAddress || null;
entry.method = req.method || null;
entry.path = req.path || null;
}
this.emit('entry', entry);
consoleWrite(level, ctx, msg, data);
if (level === 'error') {
const errObj = data instanceof Error ? data : (data && data.message ? new Error(data.message) : new Error(msg));
// Await the error log write so callers using await on log.error()
// can rely on the file being flushed before proceeding.
return writeErrorLog(ctx, errObj, req, payload);
}
} }
/** /**
* Return a safe error message without leaking internals * Emit an audit entry directly
*/ */
function safeErrorMessage(error) { async audit({ action, resource, details, outcome, ip }) {
const msg = error.message || String(error); const entry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
ip: ip || '',
action: action || '',
resource: resource || '',
details: details ? sanitize(details) : {},
outcome: outcome || 'unknown',
};
await appendAuditLog([entry]);
return entry;
}
// Detect port conflict errors /**
* Express audit middleware — call app.use(log.auditMiddleware()) once
*/
auditMiddleware() {
return (req, res, next) => {
if (req.method === 'GET') return next();
if (AUDIT_SKIP_PATHS.some(p => req.path.startsWith(p))) return next();
const originalJson = res.json.bind(res);
res.json = (body) => {
const action = this._resolveAuditAction(req.method, req.path);
const resource = this._resolveAuditResource(req.path);
const outcome = body && body.success === false ? 'failure' : 'success';
const ip = req.ip || req.socket?.remoteAddress || '';
const details = {};
if (req.params && Object.keys(req.params).length) details.params = req.params;
if (req.body) details.body = sanitize(req.body);
this.audit({ action, resource, details, outcome, ip });
return originalJson(body);
};
next();
};
}
_resolveAuditAction(method, urlPath) {
const key = `${method} ${urlPath}`;
if (AUDIT_ACTION_MAP[key]) return AUDIT_ACTION_MAP[key];
for (const [pattern, action] of Object.entries(AUDIT_ACTION_MAP)) {
if (key.startsWith(pattern)) return action;
}
const parts = urlPath.replace('/api/v1/', '').split('/');
return `${parts[0] || 'unknown'}.${method.toLowerCase()}`;
}
_resolveAuditResource(urlPath) {
const parts = urlPath.replace('/api/v1/', '').split('/');
if (parts.length >= 2) return parts.slice(1).join('/');
return parts[0] || '';
}
/**
* Query audit log entries
*/
async queryAudit({ limit = 50, offset = 0, action } = {}) {
try {
let entries = JSON.parse(await fsp.readFile(AUDIT_LOG_FILE, 'utf8'));
if (!Array.isArray(entries)) entries = [];
if (action) entries = entries.filter(e => e.action && e.action.startsWith(action));
return entries.slice(offset, offset + limit);
} catch (_) {
return [];
}
}
clearAuditLog() {
return fsp.writeFile(AUDIT_LOG_FILE, JSON.stringify([])).catch(() => {});
}
/**
* Read error log (raw lines from error.log + error.log.1)
*/
async readErrorLog(tail = 100) {
const results = [];
for (const file of [ERROR_LOG_FILE, ERROR_LOG_FILE + '.1']) {
try {
const lines = (await fsp.readFile(file, 'utf8')).split('\n').filter(Boolean);
results.push(...lines.map(l => ({ file: path.basename(file), text: l })));
} catch (_) { /* missing */ }
}
return results.slice(-tail);
}
setLevel(lvl) {
if (lvl in LEVELS) this._level = LEVELS[lvl];
}
getLevel() {
return Object.entries(LEVELS).find(([, v]) => v === this._level)?.[0] ?? 'debug';
}
}
// ─── Global singleton ──────────────────────────────────────────────────────────
const log = new Logger();
// ─── Safe error messages ─────────────────────────────────────────────────────────
function safeErrorMessage(error) {
if (!error) return 'An internal error occurred';
const msg = error.message || String(error);
const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/);
if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) {
const port = portMatch ? portMatch[1] : 'requested'; return `[DC-200] Port ${portMatch ? portMatch[1] : 'requested'} is already in use. Try a different port or stop the service using that port first.`;
return `[DC-200] Port ${port} is already in use. Try a different port or stop the service using that port first.`;
} }
if (msg.includes('No such container')) return 'Container not found';
// Only expose short, user-facing messages if (msg.includes('ECONNREFUSED') || msg.includes('ETIMEDOUT')) return 'Service unavailable';
if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) { if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) return msg;
return msg;
}
return 'An internal error occurred'; return 'An internal error occurred';
} }
// ─── Convenience wrapper compatible with the old logError(logDir)() signature ──────
// Supports: logError(context, error, extra) → existing route call pattern
async function logErrorWrapper(ctx, err, extra) {
const req = extra?.req;
const payload = extra ? { ...extra } : {};
if (payload.req) delete payload.req;
await log.error(ctx, err instanceof Error ? err : new Error(String(err)), req, payload);
}
// ─── Exports ─────────────────────────────────────────────────────────────────────
module.exports = { module.exports = {
LOG_LEVELS, log,
createLogger, setLevel: (lvl) => {
logError, if (lvl in LEVELS) {
GLOBAL_LEVEL = LEVELS[lvl];
log.setLevel(lvl); // also update the singleton instance
}
},
// Backwards-compatible alias: older callers (src/app.js) use createLogger(LOG_LEVEL)
// and expect a `log.info/warn/error/debug` function back. The unified logger is
// a single global instance, so we set the level and return it.
createLogger: (level) => { if (level in LEVELS) GLOBAL_LEVEL = LEVELS[level]; return log; },
safeErrorMessage, safeErrorMessage,
logError: logErrorWrapper,
AUDIT_LOG_FILE,
ERROR_LOG_FILE,
MAX_ERROR_LOG_SIZE,
MAX_AUDIT_ENTRIES,
AUDIT_SKIP_PATHS,
AUDIT_ACTION_MAP,
SENSITIVE_KEYS,
}; };