Initial commit: DashCaddy v1.0
Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
This commit is contained in:
178
dashcaddy-api/audit-logger.js
Normal file
178
dashcaddy-api/audit-logger.js
Normal file
@@ -0,0 +1,178 @@
|
||||
const path = require('path');
|
||||
const StateManager = require('./state-manager');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(__dirname, 'audit-log.json');
|
||||
const MAX_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
||||
|
||||
// Route path → readable action mapping
|
||||
const ACTION_MAP = {
|
||||
'POST /api/services/update': 'service.reorder',
|
||||
'POST /api/services': 'service.create',
|
||||
'PUT /api/services': 'service.update',
|
||||
'DELETE /api/services/': 'service.delete',
|
||||
'POST /api/site': 'caddy.add-site',
|
||||
'POST /api/site/external': 'caddy.add-external',
|
||||
'DELETE /api/site/': 'caddy.remove-site',
|
||||
'POST /api/caddy/reload': 'caddy.reload',
|
||||
'POST /api/dns/record': 'dns.add-record',
|
||||
'DELETE /api/dns/record': 'dns.delete-record',
|
||||
'POST /api/dns/credentials': 'dns.save-credentials',
|
||||
'DELETE /api/dns/credentials': 'dns.delete-credentials',
|
||||
'POST /api/dns/refresh-token': 'dns.refresh-token',
|
||||
'POST /api/dns/update': 'dns.update-server',
|
||||
'POST /api/containers/': 'container.action',
|
||||
'DELETE /api/containers/': 'container.delete',
|
||||
'POST /api/apps/deploy': 'container.deploy',
|
||||
'DELETE /api/apps/': 'container.undeploy',
|
||||
'POST /api/backups/execute': 'backup.execute',
|
||||
'POST /api/backups/restore/': 'backup.restore',
|
||||
'POST /api/backups/config': 'backup.config',
|
||||
'POST /api/config': 'config.update',
|
||||
'DELETE /api/config': 'config.reset',
|
||||
'POST /api/notifications/config': 'config.notifications',
|
||||
'POST /api/totp/setup': 'auth.totp-setup',
|
||||
'POST /api/totp/verify-setup': 'auth.totp-activate',
|
||||
'POST /api/totp/disable': 'auth.totp-disable',
|
||||
'POST /api/totp/config': 'auth.totp-config',
|
||||
'POST /api/credentials/rotate-key': 'config.rotate-key',
|
||||
'POST /api/updates/update/': 'container.update',
|
||||
'POST /api/updates/rollback/': 'container.rollback',
|
||||
'POST /api/updates/auto-update/': 'container.auto-update',
|
||||
'POST /api/updates/check': 'container.check-updates',
|
||||
'POST /api/health-checks/': 'config.health-check',
|
||||
'DELETE /api/health-checks/': 'config.health-check-delete',
|
||||
'POST /api/monitoring/alerts/': 'config.monitoring-alert',
|
||||
'DELETE /api/monitoring/alerts/': 'config.monitoring-alert-delete',
|
||||
'POST /api/arr/smart-connect': 'service.arr-connect',
|
||||
'POST /api/arr/credentials': 'config.arr-credentials',
|
||||
'DELETE /api/arr/credentials/': 'config.arr-credentials-delete',
|
||||
'POST /api/logo': 'config.logo-upload',
|
||||
'DELETE /api/logo': 'config.logo-delete',
|
||||
'POST /api/favicon': 'config.favicon-upload',
|
||||
'DELETE /api/favicon': 'config.favicon-delete',
|
||||
'POST /api/tailscale/config': 'config.tailscale',
|
||||
'POST /api/tailscale/protect-service': 'config.tailscale-protect',
|
||||
};
|
||||
|
||||
// Paths to skip logging (noisy or internal)
|
||||
const SKIP_PATHS = [
|
||||
'/api/totp/verify',
|
||||
'/api/totp/check-session',
|
||||
'/api/auth/gate/',
|
||||
'/api/auth/app-token/',
|
||||
'/api/audit-logs',
|
||||
'/api/health',
|
||||
'/health',
|
||||
'/api/notifications/test',
|
||||
'/api/notifications/health-check',
|
||||
];
|
||||
|
||||
class AuditLogger {
|
||||
constructor() {
|
||||
this.stateManager = new StateManager(AUDIT_LOG_FILE);
|
||||
}
|
||||
|
||||
resolveAction(method, urlPath) {
|
||||
const key = `${method} ${urlPath}`;
|
||||
// Exact match first
|
||||
if (ACTION_MAP[key]) return ACTION_MAP[key];
|
||||
// Prefix match (for parameterized routes like /api/services/:id)
|
||||
for (const [pattern, action] of Object.entries(ACTION_MAP)) {
|
||||
if (key.startsWith(pattern)) return action;
|
||||
}
|
||||
// Fallback: derive from path
|
||||
const parts = urlPath.replace('/api/', '').split('/');
|
||||
const category = parts[0] || 'unknown';
|
||||
return `${category}.${method.toLowerCase()}`;
|
||||
}
|
||||
|
||||
extractResource(urlPath) {
|
||||
// Pull a meaningful resource identifier from the URL path
|
||||
const parts = urlPath.replace('/api/', '').split('/');
|
||||
if (parts.length >= 2) return parts.slice(1).join('/');
|
||||
return parts[0] || '';
|
||||
}
|
||||
|
||||
shouldSkip(method, urlPath) {
|
||||
if (method === 'GET') return true;
|
||||
for (const skip of SKIP_PATHS) {
|
||||
if (urlPath.startsWith(skip)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async log({ action, resource, details, outcome, ip }) {
|
||||
try {
|
||||
const entry = {
|
||||
id: crypto.randomUUID(),
|
||||
timestamp: new Date().toISOString(),
|
||||
ip: ip || '',
|
||||
action: action || '',
|
||||
resource: resource || '',
|
||||
details: details || {},
|
||||
outcome: outcome || 'unknown'
|
||||
};
|
||||
|
||||
await this.stateManager.update(entries => {
|
||||
entries.unshift(entry);
|
||||
if (entries.length > MAX_ENTRIES) {
|
||||
entries.length = MAX_ENTRIES;
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[AuditLogger] Failed to write entry:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async query({ limit = 50, offset = 0, action } = {}) {
|
||||
try {
|
||||
let entries = await this.stateManager.read();
|
||||
if (action) {
|
||||
entries = entries.filter(e => e.action && e.action.startsWith(action));
|
||||
}
|
||||
return entries.slice(offset, offset + limit);
|
||||
} catch (e) {
|
||||
console.error('[AuditLogger] Failed to read:', e.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.stateManager.write([]);
|
||||
}
|
||||
|
||||
middleware() {
|
||||
return (req, res, next) => {
|
||||
if (this.shouldSkip(req.method, req.path)) return next();
|
||||
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = (data) => {
|
||||
// Log asynchronously — don't block the response
|
||||
const ip = req.ip || req.socket?.remoteAddress || '';
|
||||
const action = this.resolveAction(req.method, req.path);
|
||||
const resource = this.extractResource(req.path);
|
||||
const outcome = data && data.success === false ? 'failure' : 'success';
|
||||
|
||||
// Sanitize details — don't log passwords or tokens
|
||||
const details = {};
|
||||
if (req.params && Object.keys(req.params).length) details.params = req.params;
|
||||
if (req.body) {
|
||||
const safe = { ...req.body };
|
||||
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
||||
if (safe[key]) safe[key] = '***';
|
||||
}
|
||||
details.body = safe;
|
||||
}
|
||||
|
||||
this.log({ action, resource, details, outcome, ip }).catch(() => {});
|
||||
|
||||
return originalJson(data);
|
||||
};
|
||||
next();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuditLogger();
|
||||
Reference in New Issue
Block a user