Merge krystie-improvements into main
Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).
Conflict resolutions:
- src/utils/logging.js: took ours (consumers depend on logError/
safeErrorMessage/createLogger exports)
- src/config/site.js: merged (her factored validateAndLogConfig +
applyConfigFields helpers)
- src/context/dns.js: took hers (admin/readonly role iteration for
write operations)
- src/utilities/backup-
manager.js: took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
sw.js: took hers (minified bundles + newer SW cache)
Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
'require(./platform-paths)' → 'require(../../platform-paths)'
Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
const path = require('path');
|
||||
const StateManager = require('../managers/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/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',
|
||||
};
|
||||
|
||||
// Paths to skip logging (noisy or internal)
|
||||
const 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',
|
||||
];
|
||||
|
||||
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/v1/', '').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/v1/', '').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();
|
||||
@@ -0,0 +1,453 @@
|
||||
/**
|
||||
* Crypto Utilities for DashCaddy
|
||||
* Handles encryption/decryption of sensitive credentials
|
||||
* Uses AES-256-GCM for authenticated encryption
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Encryption settings
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 16; // 128 bits for GCM
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const SALT_LENGTH = 32;
|
||||
|
||||
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
|
||||
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
|
||||
function resolveKeyFile() {
|
||||
if (process.env.ENCRYPTION_KEY_FILE) {
|
||||
return process.env.ENCRYPTION_KEY_FILE;
|
||||
}
|
||||
const candidates = [
|
||||
path.join(__dirname, '.encryption-key'),
|
||||
path.join(__dirname, 'data', '.encryption-key'),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
// No existing file — return standard path so first load creates it there
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
const KEY_FILE = resolveKeyFile();
|
||||
|
||||
let encryptionKey = null;
|
||||
|
||||
/**
|
||||
* Generate a new encryption key
|
||||
* @returns {Buffer} 32-byte encryption key
|
||||
*/
|
||||
function generateKey() {
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a key from a password using PBKDF2 (async, non-blocking)
|
||||
* @param {string} password - Password to derive key from
|
||||
* @param {Buffer} salt - Salt for key derivation
|
||||
* @returns {Promise<Buffer>} Derived key
|
||||
*/
|
||||
async function deriveKey(password, salt) {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.pbkdf2(password, salt, 100000, KEY_LENGTH, 'sha512', (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load or create the encryption key
|
||||
* @returns {Buffer} The encryption key
|
||||
*/
|
||||
function loadOrCreateKey() {
|
||||
if (encryptionKey) {
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
// Check for key in environment variable first
|
||||
if (process.env.DASHCADDY_ENCRYPTION_KEY) {
|
||||
encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex');
|
||||
console.log('[Crypto] Using encryption key from environment variable');
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
// Try to load from file
|
||||
if (fs.existsSync(KEY_FILE)) {
|
||||
try {
|
||||
const keyData = fs.readFileSync(KEY_FILE, 'utf8').trim();
|
||||
if (keyData.length >= 64) {
|
||||
encryptionKey = Buffer.from(keyData, 'hex');
|
||||
console.log('[Crypto] Loaded encryption key from file');
|
||||
// First-run bootstrap: if .bak doesn't exist yet, write the current
|
||||
// key to it. This ensures the silent recovery path is available from
|
||||
// the very next restart without requiring an explicit rotateKey().
|
||||
if (!fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
|
||||
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not seed .bak key file:', e.message);
|
||||
}
|
||||
}
|
||||
// Try fallback to .bak key if primary can't decrypt existing credentials.
|
||||
// This handles the "container recreate rotated the key" case where the
|
||||
// backup key on disk is the ORIGINAL key that can still read the
|
||||
// bind-mounted /app/data/credentials.json written before the upgrade.
|
||||
if (fs.existsSync(KEY_FILE + '.bak')) {
|
||||
try {
|
||||
const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
|
||||
if (backupData.length >= 64) {
|
||||
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Crypto] Could not check backup key:', e.message);
|
||||
}
|
||||
}
|
||||
return encryptionKey;
|
||||
}
|
||||
// File exists but key is invalid/empty - will generate new one below
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Error loading key file:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new key
|
||||
encryptionKey = generateKey();
|
||||
|
||||
try {
|
||||
// Save key to file with restricted permissions
|
||||
fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 });
|
||||
console.log('[Crypto] Generated and saved new encryption key');
|
||||
} catch (error) {
|
||||
console.warn('[Crypto] Could not save key to file:', error.message);
|
||||
console.warn('[Crypto] Key will be regenerated on restart - credentials will need to be re-entered');
|
||||
}
|
||||
|
||||
return encryptionKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the primary key fails to decrypt any existing credentials, try the backup
|
||||
* key. This is the silent recovery path: if a container recreate replaced
|
||||
* .encryption-key with a fresh one but left .encryption-key.bak (the previous
|
||||
* key), the old key can still decrypt the bind-mounted credentials.json and
|
||||
* the user stays logged in without ever noticing.
|
||||
*
|
||||
* Called only at startup when both key files exist. Returns the working key
|
||||
* (either primary or backup). If neither works, returns the primary (existing
|
||||
* behavior — `retrieve()` will surface "unreadable" via credential-manager.diagnose).
|
||||
*
|
||||
* @param {Buffer} primaryKey - key from .encryption-key
|
||||
* @param {Buffer} backupKey - key from .encryption-key.bak
|
||||
* @returns {Buffer} the key that should be used
|
||||
*/
|
||||
function tryFallbackToBackupKey(primaryKey, backupKey) {
|
||||
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
|
||||
require('path').join(__dirname, 'credentials.json');
|
||||
if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;
|
||||
|
||||
let credentials;
|
||||
try {
|
||||
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
|
||||
} catch {
|
||||
return primaryKey;
|
||||
}
|
||||
|
||||
// Find the first encrypted entry to probe
|
||||
const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
|
||||
if (!probeEntry) return primaryKey;
|
||||
|
||||
const tryDecrypt = (key) => {
|
||||
const parts = probeEntry.value.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
try {
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const tag = Buffer.from(parts[1], 'base64');
|
||||
const ct = Buffer.from(parts[2], 'base64');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
Buffer.concat([decipher.update(ct), decipher.final()]);
|
||||
return true;
|
||||
} catch { return false; }
|
||||
};
|
||||
|
||||
if (tryDecrypt(primaryKey)) return primaryKey;
|
||||
if (tryDecrypt(backupKey)) {
|
||||
console.warn(
|
||||
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
|
||||
'fell back to .encryption-key.bak. The current primary key was set ' +
|
||||
'without preserving the original. Consider rotating the key explicitly ' +
|
||||
'via the credential-manager API to avoid this warning next restart.'
|
||||
);
|
||||
return backupKey;
|
||||
}
|
||||
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt sensitive data
|
||||
* @param {string|object} data - Data to encrypt (strings or objects)
|
||||
* @returns {string} Encrypted data as base64 string with format: iv:authTag:ciphertext
|
||||
*/
|
||||
function encrypt(data) {
|
||||
const key = loadOrCreateKey();
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Convert object to string if needed
|
||||
const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data);
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
|
||||
encrypted += cipher.final('base64');
|
||||
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Return format: iv:authTag:ciphertext (all base64)
|
||||
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt encrypted data
|
||||
* @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
|
||||
* @returns {string} Decrypted plaintext
|
||||
*/
|
||||
function decrypt(encryptedData) {
|
||||
const key = loadOrCreateKey();
|
||||
|
||||
const parts = encryptedData.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted data format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const authTag = Buffer.from(parts[1], 'base64');
|
||||
const ciphertext = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is encrypted (has our format)
|
||||
* @param {string} data - Data to check
|
||||
* @returns {boolean} True if data appears to be encrypted
|
||||
*/
|
||||
function isEncrypted(data) {
|
||||
if (typeof data !== 'string') return false;
|
||||
const parts = data.split(':');
|
||||
if (parts.length !== 3) return false;
|
||||
|
||||
// Check if parts look like base64
|
||||
try {
|
||||
Buffer.from(parts[0], 'base64');
|
||||
Buffer.from(parts[1], 'base64');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt specific fields in an object
|
||||
* @param {object} obj - Object with fields to encrypt
|
||||
* @param {string[]} fields - Array of field names to encrypt
|
||||
* @returns {object} Object with specified fields encrypted
|
||||
*/
|
||||
function encryptFields(obj, fields) {
|
||||
const result = { ...obj };
|
||||
for (const field of fields) {
|
||||
if (result[field] !== undefined && result[field] !== null) {
|
||||
// Don't double-encrypt
|
||||
if (!isEncrypted(result[field])) {
|
||||
result[field] = encrypt(result[field]);
|
||||
}
|
||||
}
|
||||
}
|
||||
result._encrypted = true; // Mark as encrypted
|
||||
result._encryptedFields = fields;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt specific fields in an object
|
||||
* @param {object} obj - Object with encrypted fields
|
||||
* @param {string[]} fields - Array of field names to decrypt (optional, uses _encryptedFields if available)
|
||||
* @returns {object} Object with specified fields decrypted
|
||||
*/
|
||||
function decryptFields(obj, fields = null) {
|
||||
if (!obj._encrypted) {
|
||||
return obj; // Not encrypted, return as-is
|
||||
}
|
||||
|
||||
const fieldsToDecrypt = fields || obj._encryptedFields || [];
|
||||
const result = { ...obj };
|
||||
|
||||
for (const field of fieldsToDecrypt) {
|
||||
if (result[field] !== undefined && isEncrypted(result[field])) {
|
||||
try {
|
||||
result[field] = decrypt(result[field]);
|
||||
} catch (error) {
|
||||
console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message);
|
||||
// Leave the field as-is if decryption fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove encryption markers from result
|
||||
delete result._encrypted;
|
||||
delete result._encryptedFields;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate plaintext credentials to encrypted format
|
||||
* @param {object} credentials - Credentials object that may or may not be encrypted
|
||||
* @param {string[]} sensitiveFields - Fields that should be encrypted
|
||||
* @returns {object} Encrypted credentials object
|
||||
*/
|
||||
function migrateToEncrypted(credentials, sensitiveFields) {
|
||||
if (credentials._encrypted) {
|
||||
return credentials; // Already encrypted
|
||||
}
|
||||
|
||||
console.log('[Crypto] Migrating plaintext credentials to encrypted format');
|
||||
return encryptFields(credentials, sensitiveFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read and decrypt a credentials file
|
||||
* @param {string} filePath - Path to credentials file
|
||||
* @param {string[]} sensitiveFields - Fields that are encrypted
|
||||
* @returns {object|null} Decrypted credentials or null if file doesn't exist
|
||||
*/
|
||||
function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(filePath, 'utf8');
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Check if this is encrypted data
|
||||
if (parsed._encrypted) {
|
||||
return decryptFields(parsed, sensitiveFields);
|
||||
}
|
||||
|
||||
// Plain text data - migrate it
|
||||
console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error(`[Crypto] Error reading ${filePath}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt and write credentials to a file
|
||||
* @param {string} filePath - Path to credentials file
|
||||
* @param {object} credentials - Credentials to save
|
||||
* @param {string[]} sensitiveFields - Fields to encrypt
|
||||
*/
|
||||
function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
|
||||
const encrypted = encryptFields(credentials, sensitiveFields);
|
||||
fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
|
||||
console.log(`[Crypto] Saved encrypted credentials to ${filePath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate the encryption key — generates a new key and returns both old and new
|
||||
* @returns {{ oldKey: Buffer, newKey: Buffer }} Old and new key pair
|
||||
* @throws {Error} If new key cannot be saved to disk
|
||||
*/
|
||||
function rotateKey() {
|
||||
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
|
||||
const newKey = generateKey();
|
||||
|
||||
// Save the OLD key to .bak BEFORE swapping the primary. This gives the
|
||||
// startup-time fallback a way to recover the previous key if a future
|
||||
// restart loses the new one (e.g. another accidental recreate). The .bak
|
||||
// file is overwritten on each rotate so it always holds the previous key,
|
||||
// not an ever-accumulating history.
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save new encryption key: ${error.message}`);
|
||||
}
|
||||
|
||||
// Only update the cached key after file write succeeds
|
||||
encryptionKey = newKey;
|
||||
return { oldKey, newKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data using a specific key (for key rotation)
|
||||
* @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
|
||||
* @param {Buffer} key - The key to decrypt with
|
||||
* @returns {string} Decrypted plaintext
|
||||
*/
|
||||
function decryptWithKey(encryptedData, key) {
|
||||
const parts = encryptedData.split(':');
|
||||
if (parts.length !== 3) {
|
||||
throw new Error('Invalid encrypted data format');
|
||||
}
|
||||
|
||||
const iv = Buffer.from(parts[0], 'base64');
|
||||
const authTag = Buffer.from(parts[1], 'base64');
|
||||
const ciphertext = parts[2];
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
// Lazy-initialize: key is loaded on first encrypt/decrypt call.
|
||||
// Do NOT call loadOrCreateKey() here — during Docker build, it would generate
|
||||
// a key baked into the image that conflicts with the mounted production key.
|
||||
|
||||
/**
|
||||
* Clear the cached encryption key so it reloads from file on next use.
|
||||
* Called after restoring an encryption key from backup.
|
||||
*/
|
||||
function clearCachedKey() {
|
||||
encryptionKey = null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt,
|
||||
isEncrypted,
|
||||
encryptFields,
|
||||
decryptFields,
|
||||
migrateToEncrypted,
|
||||
readEncryptedFile,
|
||||
writeEncryptedFile,
|
||||
loadOrCreateKey,
|
||||
deriveKey,
|
||||
rotateKey,
|
||||
decryptWithKey,
|
||||
clearCachedKey
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* CSRF Protection Module
|
||||
* Implements HMAC-signed double-submit cookie pattern for stateless CSRF protection.
|
||||
* The cookie contains a random nonce; the header must carry the HMAC signature
|
||||
* of that nonce computed with a server-side secret. An attacker who can inject
|
||||
* a cookie still cannot forge the matching header without the secret.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const cryptoUtils = require('./crypto-utils');
|
||||
const { errorResponse } = require('../utils/responses');
|
||||
|
||||
const CSRF_TOKEN_LENGTH = 32;
|
||||
const CSRF_COOKIE_NAME = 'dashcaddy_csrf';
|
||||
const CSRF_HEADER_NAME = 'x-csrf-token';
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure CSRF nonce
|
||||
* @returns {string} Base64URL-encoded random nonce
|
||||
*/
|
||||
function generateToken() {
|
||||
return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute HMAC signature for a CSRF nonce using the server-side encryption key
|
||||
* @param {string} nonce - The random nonce to sign
|
||||
* @returns {string} Base64URL-encoded HMAC signature
|
||||
*/
|
||||
function signToken(nonce) {
|
||||
const key = cryptoUtils.loadOrCreateKey();
|
||||
return crypto.createHmac('sha256', key).update(nonce).digest('base64url');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse cookie header string into object
|
||||
* @param {string} cookieHeader - Cookie header value
|
||||
* @returns {Object} Parsed cookies
|
||||
*/
|
||||
function parseCookie(cookieHeader) {
|
||||
if (!cookieHeader) return {};
|
||||
|
||||
return cookieHeader.split(';').reduce((cookies, cookie) => {
|
||||
const [name, ...rest] = cookie.trim().split('=');
|
||||
if (name && rest.length > 0) {
|
||||
cookies[name] = rest.join('=');
|
||||
}
|
||||
return cookies;
|
||||
}, {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create CSRF middleware with cookie domain support.
|
||||
* When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami
|
||||
* so they are shared across all subdomains for forward_auth SSO.
|
||||
* @param {Object} [options]
|
||||
* @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains
|
||||
* @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }}
|
||||
*/
|
||||
function createCSRFMiddleware(options = {}) {
|
||||
const { cookieDomain } = options;
|
||||
|
||||
/**
|
||||
* Middleware to set CSRF cookie on requests.
|
||||
* Preserves existing nonce to avoid invalidating tokens the client has cached.
|
||||
* New nonce is generated only on first visit (no cookie) or after TOTP login
|
||||
* (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once
|
||||
* and never changes.
|
||||
*/
|
||||
function csrfCookieMiddleware(req, res, next) {
|
||||
const cookies = parseCookie(req.headers.cookie);
|
||||
const existingNonce = cookies[CSRF_COOKIE_NAME];
|
||||
|
||||
// Reuse existing nonce; only generate fresh if no cookie exists yet
|
||||
const csrfNonce = existingNonce || generateToken();
|
||||
|
||||
// Store nonce + signature on request so endpoints can access them
|
||||
req.csrfToken = signToken(csrfNonce);
|
||||
req.csrfNonce = csrfNonce;
|
||||
|
||||
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
|
||||
if (!existingNonce) {
|
||||
const cookieOpts = {
|
||||
httpOnly: false, // Must be readable by JavaScript for signing
|
||||
secure: req.secure || req.protocol === 'https',
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
|
||||
};
|
||||
if (cookieDomain) cookieOpts.domain = cookieDomain;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a fresh CSRF nonce and set it on the response.
|
||||
* Called after TOTP login to rotate the token for the new session.
|
||||
* @param {Object} res - Express response object
|
||||
* @param {boolean} secure - Whether to set Secure flag on cookie
|
||||
* @returns {string} The new CSRF signed token
|
||||
*/
|
||||
function renewCSRFToken(res, secure) {
|
||||
const csrfNonce = generateToken();
|
||||
const cookieOpts = {
|
||||
httpOnly: false,
|
||||
secure: !!secure,
|
||||
sameSite: 'strict',
|
||||
path: '/',
|
||||
maxAge: 365 * 24 * 60 * 60 * 1000
|
||||
};
|
||||
if (cookieDomain) cookieOpts.domain = cookieDomain;
|
||||
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
|
||||
return signToken(csrfNonce);
|
||||
}
|
||||
|
||||
return { csrfCookieMiddleware, renewCSRFToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware to validate CSRF token on state-changing requests
|
||||
* Validates that the token in the cookie matches the token in the header
|
||||
*/
|
||||
function csrfValidationMiddleware(req, res, next) {
|
||||
const method = req.method.toUpperCase();
|
||||
|
||||
// Skip validation for safe methods
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip CSRF validation in test environment
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Excluded paths that don't require CSRF validation
|
||||
const excludedPaths = [
|
||||
'/api/v1/totp/verify',
|
||||
'/api/v1/totp/verify-setup',
|
||||
'/api/v1/totp/setup',
|
||||
'/health',
|
||||
'/api/v1/health',
|
||||
// Machine-to-machine: publishing host POSTs here with its own shared-secret
|
||||
// header (X-DashCaddy-Notify-Secret) — browsers never reach this endpoint.
|
||||
'/api/v1/system/update-notify'
|
||||
];
|
||||
|
||||
const isExcluded = excludedPaths.some(path => req.path === path) ||
|
||||
req.path.startsWith('/api/v1/auth/gate/');
|
||||
|
||||
if (isExcluded) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Get nonce from cookie
|
||||
const cookies = parseCookie(req.headers.cookie);
|
||||
const cookieNonce = cookies[CSRF_COOKIE_NAME];
|
||||
|
||||
// Get signed token from header (case-insensitive)
|
||||
const headerToken = req.headers[CSRF_HEADER_NAME] ||
|
||||
req.headers[CSRF_HEADER_NAME.toLowerCase()];
|
||||
|
||||
// Skip CSRF for API key-authenticated requests (API keys are not sent automatically by browsers)
|
||||
if (req.headers['x-api-key'] || (req.headers.authorization && req.headers.authorization.startsWith('Bearer '))) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Validate both values exist
|
||||
if (!cookieNonce) {
|
||||
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
|
||||
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
||||
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
|
||||
});
|
||||
}
|
||||
|
||||
if (!headerToken) {
|
||||
console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`);
|
||||
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
|
||||
message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate that the header token is the correct HMAC signature of the cookie nonce
|
||||
try {
|
||||
const expectedSig = signToken(cookieNonce);
|
||||
const expectedBuffer = Buffer.from(expectedSig, 'base64url');
|
||||
const headerBuffer = Buffer.from(headerToken, 'base64url');
|
||||
|
||||
if (expectedBuffer.length !== headerBuffer.length) {
|
||||
throw new Error('Token length mismatch');
|
||||
}
|
||||
|
||||
if (!crypto.timingSafeEqual(expectedBuffer, headerBuffer)) {
|
||||
throw new Error('Token mismatch');
|
||||
}
|
||||
|
||||
// Signature valid — request is authentic
|
||||
next();
|
||||
|
||||
} catch (err) {
|
||||
console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`);
|
||||
return errorResponse(res, 403, '[DC-101] CSRF token invalid', {
|
||||
message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default instance (no domain) for backward compatibility with tests
|
||||
const defaultInstance = createCSRFMiddleware();
|
||||
|
||||
module.exports = {
|
||||
CSRF_TOKEN_LENGTH,
|
||||
CSRF_COOKIE_NAME,
|
||||
CSRF_HEADER_NAME,
|
||||
generateToken,
|
||||
signToken,
|
||||
parseCookie,
|
||||
createCSRFMiddleware,
|
||||
csrfValidationMiddleware,
|
||||
// Default instance exports for backward compat
|
||||
csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware,
|
||||
renewCSRFToken: defaultInstance.renewCSRFToken
|
||||
};
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Docker Security Module
|
||||
* Provides image digest verification to ensure container images match expected digests
|
||||
* Protects against supply chain attacks and malicious image replacements
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const Docker = require('dockerode');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
const SECURITY_CONFIG_FILE = process.env.DOCKER_SECURITY_CONFIG || path.join(__dirname, 'docker-security-config.json');
|
||||
const VERIFICATION_MODE = process.env.DOCKER_VERIFICATION_MODE || 'verify'; // strict | verify | permissive
|
||||
|
||||
class DockerSecurity {
|
||||
constructor() {
|
||||
this.config = this.loadConfig();
|
||||
this.mode = VERIFICATION_MODE;
|
||||
console.log(`[DockerSecurity] Initialized in ${this.mode} mode`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load security configuration
|
||||
*/
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(SECURITY_CONFIG_FILE)) {
|
||||
const data = fs.readFileSync(SECURITY_CONFIG_FILE, 'utf8');
|
||||
return JSON.parse(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[DockerSecurity] Failed to load config: ${error.message}`);
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
return {
|
||||
trustedDigests: {},
|
||||
verificationMode: VERIFICATION_MODE,
|
||||
allowUnverified: true,
|
||||
updateTrustedOnPull: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save security configuration
|
||||
*/
|
||||
saveConfig() {
|
||||
try {
|
||||
fs.writeFileSync(SECURITY_CONFIG_FILE, JSON.stringify(this.config, null, 2));
|
||||
} catch (error) {
|
||||
console.error(`[DockerSecurity] Failed to save config: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image digest from Docker
|
||||
* @param {string} imageName - Full image name with tag (e.g., "nginx:latest")
|
||||
* @returns {Promise<string>} Image digest (sha256:...)
|
||||
*/
|
||||
async getImageDigest(imageName) {
|
||||
try {
|
||||
const image = docker.getImage(imageName);
|
||||
const inspect = await image.inspect();
|
||||
|
||||
// RepoDigests contains the full image reference with digest
|
||||
// Example: ["nginx@sha256:abcd1234..."]
|
||||
if (inspect.RepoDigests && inspect.RepoDigests.length > 0) {
|
||||
const digestPart = inspect.RepoDigests[0].split('@')[1];
|
||||
return digestPart;
|
||||
}
|
||||
|
||||
// If no RepoDigest, use the local Image ID
|
||||
// This happens with locally built images or images pulled before digests were tracked
|
||||
return inspect.Id;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to get image digest: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch manifest from Docker registry
|
||||
* @param {string} imageName - Image name (e.g., "nginx:latest")
|
||||
* @returns {Promise<object>} Manifest data with digest
|
||||
*/
|
||||
async fetchRegistryManifest(imageName) {
|
||||
// Parse image name
|
||||
const parts = imageName.split('/');
|
||||
let registry = 'registry-1.docker.io';
|
||||
let repository = imageName;
|
||||
let tag = 'latest';
|
||||
|
||||
// Handle different image name formats
|
||||
if (imageName.includes(':')) {
|
||||
const tagSplit = imageName.split(':');
|
||||
tag = tagSplit[tagSplit.length - 1];
|
||||
repository = tagSplit.slice(0, -1).join(':');
|
||||
}
|
||||
|
||||
// Handle custom registries
|
||||
if (parts.length > 2 || (parts.length === 2 && parts[0].includes('.'))) {
|
||||
registry = parts[0];
|
||||
repository = parts.slice(1).join('/').split(':')[0];
|
||||
} else if (parts.length === 1) {
|
||||
// Official Docker Hub images need 'library/' prefix
|
||||
repository = `library/${repository.split(':')[0]}`;
|
||||
} else {
|
||||
repository = repository.split(':')[0];
|
||||
}
|
||||
|
||||
console.log(`[DockerSecurity] Fetching manifest for ${registry}/${repository}:${tag}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const isDockerHub = registry === 'registry-1.docker.io';
|
||||
const tokenUrl = isDockerHub
|
||||
? `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`
|
||||
: null;
|
||||
|
||||
const fetchManifest = (token) => {
|
||||
const options = {
|
||||
hostname: registry,
|
||||
path: `/v2/${repository}/manifests/${tag}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
|
||||
}
|
||||
};
|
||||
|
||||
if (token) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const manifest = JSON.parse(data);
|
||||
const digest = res.headers['docker-content-digest'];
|
||||
resolve({ manifest, digest });
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to parse manifest: ${error.message}`));
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Registry returned status ${res.statusCode}: ${data}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (error) => {
|
||||
reject(new Error(`Registry request failed: ${error.message}`));
|
||||
});
|
||||
|
||||
req.end();
|
||||
};
|
||||
|
||||
// Get auth token for Docker Hub
|
||||
if (isDockerHub) {
|
||||
https.get(tokenUrl, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const authData = JSON.parse(data);
|
||||
fetchManifest(authData.token);
|
||||
} catch (error) {
|
||||
reject(new Error(`Failed to get auth token: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}).on('error', (error) => {
|
||||
reject(new Error(`Auth request failed: ${error.message}`));
|
||||
});
|
||||
} else {
|
||||
fetchManifest(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify image digest against trusted digests
|
||||
* @param {string} imageName - Image name with tag
|
||||
* @param {string} actualDigest - Actual digest from pulled image
|
||||
* @returns {Promise<object>} Verification result
|
||||
*/
|
||||
async verifyImageDigest(imageName, actualDigest) {
|
||||
const baseImageName = imageName.split(':')[0];
|
||||
const trustedDigest = this.config.trustedDigests[imageName] || this.config.trustedDigests[baseImageName];
|
||||
|
||||
const result = {
|
||||
verified: false,
|
||||
mode: this.mode,
|
||||
imageName,
|
||||
actualDigest,
|
||||
trustedDigest: trustedDigest || null,
|
||||
action: 'unknown'
|
||||
};
|
||||
|
||||
if (!trustedDigest) {
|
||||
// No trusted digest configured
|
||||
if (this.mode === 'strict') {
|
||||
result.verified = false;
|
||||
result.action = 'reject';
|
||||
result.reason = 'No trusted digest configured (strict mode)';
|
||||
} else {
|
||||
result.verified = true;
|
||||
result.action = 'accept';
|
||||
result.reason = 'No trusted digest configured (permissive mode)';
|
||||
|
||||
if (this.config.updateTrustedOnPull) {
|
||||
this.config.trustedDigests[imageName] = actualDigest;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Added trusted digest for ${imageName}`);
|
||||
}
|
||||
}
|
||||
} else if (actualDigest === trustedDigest) {
|
||||
// Digest matches
|
||||
result.verified = true;
|
||||
result.action = 'accept';
|
||||
result.reason = 'Digest matches trusted value';
|
||||
} else {
|
||||
// Digest mismatch
|
||||
if (this.mode === 'strict') {
|
||||
result.verified = false;
|
||||
result.action = 'reject';
|
||||
result.reason = 'Digest mismatch (strict mode)';
|
||||
} else if (this.mode === 'verify') {
|
||||
result.verified = false;
|
||||
result.action = 'warn';
|
||||
result.reason = 'Digest mismatch (verify mode - warning only)';
|
||||
} else {
|
||||
result.verified = true;
|
||||
result.action = 'accept';
|
||||
result.reason = 'Digest mismatch (permissive mode - accepted)';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an image after pulling
|
||||
* @param {string} imageName - Image name with tag
|
||||
* @returns {Promise<object>} Verification result
|
||||
*/
|
||||
async verifyPulledImage(imageName) {
|
||||
console.log(`[DockerSecurity] Verifying image: ${imageName}`);
|
||||
|
||||
try {
|
||||
const actualDigest = await this.getImageDigest(imageName);
|
||||
const result = await this.verifyImageDigest(imageName, actualDigest);
|
||||
|
||||
if (result.action === 'reject') {
|
||||
console.error(`[DockerSecurity] REJECTED: ${result.reason}`);
|
||||
throw new Error(`Image verification failed: ${result.reason}`);
|
||||
} else if (result.action === 'warn') {
|
||||
console.warn(`[DockerSecurity] WARNING: ${result.reason}`);
|
||||
console.warn(`[DockerSecurity] Expected: ${result.trustedDigest}`);
|
||||
console.warn(`[DockerSecurity] Actual: ${result.actualDigest}`);
|
||||
} else {
|
||||
console.log(`[DockerSecurity] ACCEPTED: ${result.reason}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[DockerSecurity] Verification error: ${error.message}`);
|
||||
|
||||
if (this.mode === 'strict') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
verified: false,
|
||||
mode: this.mode,
|
||||
imageName,
|
||||
action: this.mode === 'permissive' ? 'accept' : 'warn',
|
||||
error: error.message,
|
||||
reason: `Verification error (${this.mode} mode)`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update trusted digest for an image
|
||||
* @param {string} imageName - Image name with tag
|
||||
* @param {string} digest - Trusted digest
|
||||
*/
|
||||
setTrustedDigest(imageName, digest) {
|
||||
this.config.trustedDigests[imageName] = digest;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Updated trusted digest for ${imageName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove trusted digest for an image
|
||||
* @param {string} imageName - Image name with tag
|
||||
*/
|
||||
removeTrustedDigest(imageName) {
|
||||
delete this.config.trustedDigests[imageName];
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Removed trusted digest for ${imageName}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all trusted digests
|
||||
*/
|
||||
getTrustedDigests() {
|
||||
return { ...this.config.trustedDigests };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set verification mode
|
||||
* @param {string} mode - strict | verify | permissive
|
||||
*/
|
||||
setMode(mode) {
|
||||
if (!['strict', 'verify', 'permissive'].includes(mode)) {
|
||||
throw new Error('Invalid mode. Must be: strict, verify, or permissive');
|
||||
}
|
||||
this.mode = mode;
|
||||
this.config.verificationMode = mode;
|
||||
this.saveConfig();
|
||||
console.log(`[DockerSecurity] Verification mode set to: ${mode}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get security status
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
mode: this.mode,
|
||||
trustedImagesCount: Object.keys(this.config.trustedDigests).length,
|
||||
configFile: SECURITY_CONFIG_FILE,
|
||||
updateTrustedOnPull: this.config.updateTrustedOnPull
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
const dockerSecurity = new DockerSecurity();
|
||||
|
||||
module.exports = dockerSecurity;
|
||||
@@ -0,0 +1,606 @@
|
||||
/**
|
||||
* Input Validation Module for DashCaddy
|
||||
* Comprehensive validation to prevent injection attacks and ensure data integrity
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const validator = require('validator');
|
||||
|
||||
class ValidationError extends Error {
|
||||
constructor(message, field = null) {
|
||||
super(message);
|
||||
this.name = 'ValidationError';
|
||||
this.field = field;
|
||||
this.statusCode = 400;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate DNS record data
|
||||
*/
|
||||
function validateDNSRecord(data) {
|
||||
const errors = [];
|
||||
|
||||
// Validate subdomain
|
||||
if (!data.subdomain || typeof data.subdomain !== 'string') {
|
||||
errors.push({ field: 'subdomain', message: 'Subdomain is required' });
|
||||
} else {
|
||||
// DNS label validation: alphanumeric and hyphens, 1-63 chars, no leading/trailing hyphens
|
||||
const subdomainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
|
||||
if (!subdomainRegex.test(data.subdomain)) {
|
||||
errors.push({
|
||||
field: 'subdomain',
|
||||
message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)'
|
||||
});
|
||||
}
|
||||
|
||||
// Prevent DNS injection attempts
|
||||
const dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\'];
|
||||
if (dangerousChars.some(char => data.subdomain.includes(char))) {
|
||||
errors.push({ field: 'subdomain', message: 'Subdomain contains invalid characters' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate domain
|
||||
if (data.domain && typeof data.domain === 'string') {
|
||||
if (!validator.isFQDN(data.domain, { require_tld: false })) {
|
||||
errors.push({ field: 'domain', message: 'Invalid domain format' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate IP address
|
||||
if (!data.ip || typeof data.ip !== 'string') {
|
||||
errors.push({ field: 'ip', message: 'IP address is required' });
|
||||
} else {
|
||||
if (!validator.isIP(data.ip, 4) && !validator.isIP(data.ip, 6)) {
|
||||
errors.push({ field: 'ip', message: 'Invalid IP address format' });
|
||||
}
|
||||
|
||||
// Prevent SSRF by blocking private IPs in certain contexts
|
||||
if (data.blockPrivateIPs && isPrivateIP(data.ip)) {
|
||||
errors.push({ field: 'ip', message: 'Private IP addresses are not allowed in this context' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate TTL if provided
|
||||
if (data.ttl !== undefined) {
|
||||
const ttl = parseInt(data.ttl, 10);
|
||||
if (isNaN(ttl) || ttl < 60 || ttl > 86400) {
|
||||
errors.push({ field: 'ttl', message: 'TTL must be between 60 and 86400 seconds' });
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const error = new ValidationError('DNS record validation failed');
|
||||
error.errors = errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
subdomain: data.subdomain.toLowerCase().trim(),
|
||||
domain: data.domain ? data.domain.toLowerCase().trim() : null,
|
||||
ip: data.ip.trim(),
|
||||
ttl: data.ttl ? parseInt(data.ttl, 10) : 3600
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Docker container deployment data
|
||||
*/
|
||||
function validateDockerDeployment(data) {
|
||||
const errors = [];
|
||||
|
||||
// Validate container name
|
||||
if (!data.name || typeof data.name !== 'string') {
|
||||
errors.push({ field: 'name', message: 'Container name is required' });
|
||||
} else {
|
||||
// Docker name validation: alphanumeric, underscores, periods, hyphens
|
||||
const nameRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
if (!nameRegex.test(data.name)) {
|
||||
errors.push({
|
||||
field: 'name',
|
||||
message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens'
|
||||
});
|
||||
}
|
||||
|
||||
if (data.name.length > 255) {
|
||||
errors.push({ field: 'name', message: 'Container name too long (max 255 chars)' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate Docker image
|
||||
if (!data.image || typeof data.image !== 'string') {
|
||||
errors.push({ field: 'image', message: 'Docker image is required' });
|
||||
} else {
|
||||
// Docker image validation: registry/repo:tag format
|
||||
// Allow: alpine, nginx:latest, docker.io/library/nginx:1.21, ghcr.io/user/repo:tag
|
||||
const imageRegex = /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?::[0-9]{1,5})?\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[a-z0-9]+(?:[._-][a-z0-9]+)*)?$/i;
|
||||
|
||||
if (!imageRegex.test(data.image)) {
|
||||
errors.push({
|
||||
field: 'image',
|
||||
message: 'Invalid Docker image format'
|
||||
});
|
||||
}
|
||||
|
||||
// Block dangerous image patterns
|
||||
const dangerousPatterns = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n', '\r'];
|
||||
if (dangerousPatterns.some(pattern => data.image.includes(pattern))) {
|
||||
errors.push({ field: 'image', message: 'Docker image contains invalid characters' });
|
||||
}
|
||||
|
||||
if (data.image.length > 512) {
|
||||
errors.push({ field: 'image', message: 'Docker image name too long' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ports
|
||||
if (data.ports) {
|
||||
if (!Array.isArray(data.ports)) {
|
||||
errors.push({ field: 'ports', message: 'Ports must be an array' });
|
||||
} else {
|
||||
data.ports.forEach((port, index) => {
|
||||
if (typeof port === 'string') {
|
||||
// Format: "8080:80" or "8080:80/tcp"
|
||||
const portRegex = /^(\d{1,5}):(\d{1,5})(?:\/(tcp|udp))?$/;
|
||||
if (!portRegex.test(port)) {
|
||||
errors.push({
|
||||
field: `ports[${index}]`,
|
||||
message: 'Invalid port format. Use "host:container" or "host:container/protocol"'
|
||||
});
|
||||
} else {
|
||||
const [, hostPort, containerPort] = port.match(portRegex);
|
||||
if (!isValidPort(hostPort) || !isValidPort(containerPort)) {
|
||||
errors.push({ field: `ports[${index}]`, message: 'Port numbers must be between 1 and 65535' });
|
||||
}
|
||||
}
|
||||
} else if (typeof port === 'number') {
|
||||
if (!isValidPort(port)) {
|
||||
errors.push({ field: `ports[${index}]`, message: 'Port number must be between 1 and 65535' });
|
||||
}
|
||||
} else {
|
||||
errors.push({ field: `ports[${index}]`, message: 'Invalid port type' });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate volumes
|
||||
if (data.volumes) {
|
||||
if (!Array.isArray(data.volumes)) {
|
||||
errors.push({ field: 'volumes', message: 'Volumes must be an array' });
|
||||
} else {
|
||||
data.volumes.forEach((volume, index) => {
|
||||
if (typeof volume !== 'string') {
|
||||
errors.push({ field: `volumes[${index}]`, message: 'Volume must be a string' });
|
||||
} else {
|
||||
// Validate volume format and prevent path traversal
|
||||
const volumeErrors = validateVolumePath(volume, index);
|
||||
errors.push(...volumeErrors);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Validate environment variables
|
||||
if (data.environment) {
|
||||
if (typeof data.environment !== 'object' || Array.isArray(data.environment)) {
|
||||
errors.push({ field: 'environment', message: 'Environment must be an object' });
|
||||
} else {
|
||||
Object.entries(data.environment).forEach(([key, value]) => {
|
||||
// Validate env var name
|
||||
const envKeyRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
if (!envKeyRegex.test(key)) {
|
||||
errors.push({
|
||||
field: `environment.${key}`,
|
||||
message: 'Invalid environment variable name'
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure value is string or number
|
||||
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
|
||||
errors.push({
|
||||
field: `environment.${key}`,
|
||||
message: 'Environment variable value must be string, number, or boolean'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const error = new ValidationError('Docker deployment validation failed');
|
||||
error.errors = errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
name: data.name.trim(),
|
||||
image: data.image.trim(),
|
||||
ports: data.ports || [],
|
||||
volumes: data.volumes || [],
|
||||
environment: data.environment || {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file path to prevent directory traversal
|
||||
*/
|
||||
function validateFilePath(filePath, allowedBasePaths = []) {
|
||||
if (!filePath || typeof filePath !== 'string') {
|
||||
throw new ValidationError('File path is required', 'path');
|
||||
}
|
||||
|
||||
// Normalize path
|
||||
const normalized = path.normalize(filePath);
|
||||
|
||||
// Check for directory traversal attempts
|
||||
if (normalized.includes('..') || normalized.includes('~')) {
|
||||
throw new ValidationError('Path traversal detected', 'path');
|
||||
}
|
||||
|
||||
// Block absolute paths to sensitive locations
|
||||
const blockedPaths = [
|
||||
'/etc',
|
||||
'/sys',
|
||||
'/proc',
|
||||
'/root',
|
||||
'C:\\Windows',
|
||||
'C:\\Program Files',
|
||||
'/var/run',
|
||||
'/var/lib/docker'
|
||||
];
|
||||
|
||||
const lowerPath = normalized.toLowerCase();
|
||||
if (blockedPaths.some(blocked => lowerPath.startsWith(blocked.toLowerCase()))) {
|
||||
throw new ValidationError('Access to this path is not allowed', 'path');
|
||||
}
|
||||
|
||||
// If allowed base paths specified, ensure path is within them
|
||||
if (allowedBasePaths.length > 0) {
|
||||
const isAllowed = allowedBasePaths.some(basePath => {
|
||||
const normalizedBase = path.normalize(basePath);
|
||||
return normalized.startsWith(normalizedBase);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
throw new ValidationError('Path is outside allowed directories', 'path');
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate volume path for Docker
|
||||
*/
|
||||
function validateVolumePath(volume, index) {
|
||||
const errors = [];
|
||||
|
||||
// Format: /host/path:/container/path or /host/path:/container/path:ro
|
||||
const volumeRegex = /^([^:]+):([^:]+)(?::(ro|rw|z|Z))?$/;
|
||||
const match = volume.match(volumeRegex);
|
||||
|
||||
if (!match) {
|
||||
errors.push({
|
||||
field: `volumes[${index}]`,
|
||||
message: 'Invalid volume format. Use "host:container" or "host:container:mode"'
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
const [, hostPath, containerPath, mode] = match;
|
||||
|
||||
// Validate host path
|
||||
try {
|
||||
validateFilePath(hostPath);
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
field: `volumes[${index}].hostPath`,
|
||||
message: `Invalid host path: ${error.message}`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate container path
|
||||
if (containerPath.includes('..') || !path.isAbsolute(containerPath)) {
|
||||
errors.push({
|
||||
field: `volumes[${index}].containerPath`,
|
||||
message: 'Container path must be absolute and not contain ..'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate mode if present
|
||||
if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) {
|
||||
errors.push({
|
||||
field: `volumes[${index}].mode`,
|
||||
message: 'Invalid volume mode. Use ro, rw, z, or Z'
|
||||
});
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL
|
||||
*/
|
||||
function validateURL(url, options = {}) {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new ValidationError('URL is required', 'url');
|
||||
}
|
||||
|
||||
const validatorOptions = {
|
||||
protocols: options.protocols || ['http', 'https'],
|
||||
require_protocol: options.requireProtocol !== false,
|
||||
require_valid_protocol: true,
|
||||
allow_underscores: false,
|
||||
...options
|
||||
};
|
||||
|
||||
if (!validator.isURL(url, validatorOptions)) {
|
||||
throw new ValidationError('Invalid URL format', 'url');
|
||||
}
|
||||
|
||||
// Block localhost/private IPs if specified
|
||||
if (options.blockPrivate) {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.hostname === 'localhost' ||
|
||||
urlObj.hostname === '127.0.0.1' ||
|
||||
isPrivateIP(urlObj.hostname)) {
|
||||
throw new ValidationError('Private URLs are not allowed', 'url');
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof ValidationError) throw e;
|
||||
throw new ValidationError('Invalid URL', 'url');
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate API token format
|
||||
*/
|
||||
function validateToken(token) {
|
||||
if (!token || typeof token !== 'string') {
|
||||
throw new ValidationError('Token is required', 'token');
|
||||
}
|
||||
|
||||
// Token should be alphanumeric with possible special chars, reasonable length
|
||||
if (token.length < 8) {
|
||||
throw new ValidationError('Token too short (minimum 8 characters)', 'token');
|
||||
}
|
||||
|
||||
if (token.length > 512) {
|
||||
throw new ValidationError('Token too long (maximum 512 characters)', 'token');
|
||||
}
|
||||
|
||||
// Block obvious injection attempts
|
||||
const dangerousPatterns = [';', '&', '|', '`', '\n', '\r', '$(', '&&'];
|
||||
if (dangerousPatterns.some(pattern => token.includes(pattern))) {
|
||||
throw new ValidationError('Token contains invalid characters', 'token');
|
||||
}
|
||||
|
||||
return token.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate service configuration
|
||||
*/
|
||||
function validateServiceConfig(service) {
|
||||
const errors = [];
|
||||
|
||||
// Validate ID
|
||||
if (!service.id || typeof service.id !== 'string') {
|
||||
errors.push({ field: 'id', message: 'Service ID is required' });
|
||||
} else {
|
||||
const idRegex = /^[a-z0-9-_]+$/i;
|
||||
if (!idRegex.test(service.id)) {
|
||||
errors.push({ field: 'id', message: 'Invalid service ID format' });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name
|
||||
if (!service.name || typeof service.name !== 'string') {
|
||||
errors.push({ field: 'name', message: 'Service name is required' });
|
||||
} else if (service.name.length > 100) {
|
||||
errors.push({ field: 'name', message: 'Service name too long (max 100 chars)' });
|
||||
}
|
||||
|
||||
// Validate URL if provided
|
||||
if (service.url) {
|
||||
try {
|
||||
validateURL(service.url);
|
||||
} catch (error) {
|
||||
errors.push({ field: 'url', message: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Validate port if provided
|
||||
if (service.port !== undefined && !isValidPort(service.port)) {
|
||||
errors.push({ field: 'port', message: 'Invalid port number' });
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
const error = new ValidationError('Service configuration validation failed');
|
||||
error.errors = errors;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if port is valid
|
||||
*/
|
||||
function isValidPort(port) {
|
||||
const portNum = typeof port === 'string' ? parseInt(port, 10) : port;
|
||||
return !isNaN(portNum) && portNum >= 1 && portNum <= 65535;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if IP is private
|
||||
*/
|
||||
function isPrivateIP(ip) {
|
||||
// IPv4 private ranges
|
||||
const privateRanges = [
|
||||
/^10\./,
|
||||
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
|
||||
/^192\.168\./,
|
||||
/^127\./,
|
||||
/^169\.254\./,
|
||||
/^::1$/,
|
||||
/^fc00:/,
|
||||
/^fe80:/
|
||||
];
|
||||
|
||||
return privateRanges.some(range => range.test(ip));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize string for safe display (prevent XSS)
|
||||
*/
|
||||
function sanitizeString(str, maxLength = 1000) {
|
||||
if (typeof str !== 'string') return '';
|
||||
|
||||
return str
|
||||
.slice(0, maxLength)
|
||||
.replace(/[<>'"]/g, char => {
|
||||
const entities = { '<': '<', '>': '>', "'": ''', '"': '"' };
|
||||
return entities[char] || char;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate secure path with realpath resolution and traversal detection
|
||||
* This is CRITICAL for preventing path traversal attacks
|
||||
* @param {string} requestedPath - The path requested by the user
|
||||
* @param {Array<string>} allowedRoots - Array of allowed root directories
|
||||
* @param {object} auditLogger - Optional audit logger for security events
|
||||
* @returns {Promise<string>} - Resolved safe path
|
||||
*/
|
||||
async function validateSecurePath(requestedPath, allowedRoots, auditLogger = null) {
|
||||
const fs = require('fs').promises;
|
||||
|
||||
if (!requestedPath || typeof requestedPath !== 'string') {
|
||||
throw new ValidationError('Path is required', 'path');
|
||||
}
|
||||
|
||||
if (!Array.isArray(allowedRoots) || allowedRoots.length === 0) {
|
||||
throw new ValidationError('No allowed roots configured', 'path');
|
||||
}
|
||||
|
||||
// Check for null byte injection
|
||||
if (requestedPath.includes('\0')) {
|
||||
if (auditLogger) {
|
||||
auditLogger.logSecurityEvent('path_traversal_blocked', {
|
||||
requestedPath,
|
||||
reason: 'null_byte_detected',
|
||||
severity: 'high'
|
||||
});
|
||||
}
|
||||
throw new ValidationError('Invalid path - null byte detected', 'path');
|
||||
}
|
||||
|
||||
// Check for encoded traversal sequences
|
||||
const decodedPath = decodeURIComponent(requestedPath);
|
||||
const suspiciousPatterns = [
|
||||
/\.\./, // ..
|
||||
/%2e%2e/i, // URL encoded ..
|
||||
/\.%2f/i, // .%2F (encoded ./)
|
||||
/%2e\./i, // %2E.
|
||||
/\.\\/, // .\ (Windows)
|
||||
/%5c/i // URL encoded backslash
|
||||
];
|
||||
|
||||
if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) ||
|
||||
suspiciousPatterns.some(pattern => pattern.test(decodedPath))) {
|
||||
if (auditLogger) {
|
||||
auditLogger.logSecurityEvent('path_traversal_blocked', {
|
||||
requestedPath,
|
||||
decodedPath,
|
||||
reason: 'traversal_sequence_detected',
|
||||
severity: 'high'
|
||||
});
|
||||
}
|
||||
throw new ValidationError('Path traversal detected', 'path');
|
||||
}
|
||||
|
||||
// Normalize the path for the current platform
|
||||
const normalized = path.normalize(requestedPath);
|
||||
|
||||
// Try to resolve the real path (follows symlinks)
|
||||
let realPath;
|
||||
try {
|
||||
realPath = await fs.realpath(normalized);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Path doesn't exist - that's okay, just use normalized path
|
||||
// But we still need to check if parent exists and is within allowed roots
|
||||
const parentDir = path.dirname(normalized);
|
||||
try {
|
||||
const parentReal = await fs.realpath(parentDir);
|
||||
// Construct the real path using the resolved parent
|
||||
realPath = path.join(parentReal, path.basename(normalized));
|
||||
} catch (parentError) {
|
||||
if (parentError.code === 'ENOENT') {
|
||||
// Parent doesn't exist either - use normalized path
|
||||
realPath = normalized;
|
||||
} else if (parentError.code === 'EACCES') {
|
||||
throw new ValidationError('Access denied to path', 'path');
|
||||
} else {
|
||||
throw parentError;
|
||||
}
|
||||
}
|
||||
} else if (error.code === 'EACCES') {
|
||||
throw new ValidationError('Access denied to path', 'path');
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize for cross-platform comparison (Windows is case-insensitive)
|
||||
const isWindows = process.platform === 'win32';
|
||||
const normalizePath = (p) => {
|
||||
const normalized = path.normalize(p).replace(/\\/g, '/');
|
||||
return isWindows ? normalized.toLowerCase() : normalized;
|
||||
};
|
||||
|
||||
const normalizedReal = normalizePath(realPath);
|
||||
|
||||
// Check if the resolved path is within any allowed root
|
||||
const isWithinAllowedRoot = allowedRoots.some(root => {
|
||||
const normalizedRoot = normalizePath(root);
|
||||
return normalizedReal.startsWith(normalizedRoot);
|
||||
});
|
||||
|
||||
if (!isWithinAllowedRoot) {
|
||||
if (auditLogger) {
|
||||
auditLogger.logSecurityEvent('path_traversal_blocked', {
|
||||
requestedPath,
|
||||
realPath,
|
||||
allowedRoots,
|
||||
reason: 'outside_allowed_roots',
|
||||
severity: 'critical'
|
||||
});
|
||||
}
|
||||
throw new ValidationError('Access denied - path is outside allowed directories', 'path');
|
||||
}
|
||||
|
||||
return realPath;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ValidationError,
|
||||
validateDNSRecord,
|
||||
validateDockerDeployment,
|
||||
validateVolumePath,
|
||||
validateFilePath,
|
||||
validateURL,
|
||||
validateToken,
|
||||
validateServiceConfig,
|
||||
sanitizeString,
|
||||
isValidPort,
|
||||
isPrivateIP,
|
||||
validateSecurePath
|
||||
};
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Keychain Manager for DashCaddy
|
||||
* Provides secure credential storage using OS-native keychains
|
||||
* Falls back to encrypted file storage if keychain is unavailable
|
||||
*/
|
||||
|
||||
const { execSync, execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const SERVICE_NAME = 'DashCaddy';
|
||||
const ACCOUNT_PREFIX = 'dashcaddy';
|
||||
|
||||
class KeychainManager {
|
||||
constructor() {
|
||||
this.platform = os.platform();
|
||||
this.available = this.checkAvailability();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if OS keychain is available
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkAvailability() {
|
||||
try {
|
||||
if (this.platform === 'win32') {
|
||||
// Check if PowerShell is available
|
||||
execSync('powershell -Command "Get-Command Get-Credential"', { stdio: 'ignore' });
|
||||
return true;
|
||||
} else if (this.platform === 'darwin') {
|
||||
// Check if security command is available
|
||||
execSync('which security', { stdio: 'ignore' });
|
||||
return true;
|
||||
} else if (this.platform === 'linux') {
|
||||
// Check if secret-tool (libsecret) is available
|
||||
try {
|
||||
execSync('which secret-tool', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
// Try gnome-keyring
|
||||
execSync('which gnome-keyring-daemon', { stdio: 'ignore' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
console.warn('[Keychain] OS keychain not available, will use encrypted file storage');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a credential in the OS keychain
|
||||
* @param {string} key - Credential identifier
|
||||
* @param {string} value - Credential value
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async store(key, value) {
|
||||
if (!this.available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const account = `${ACCOUNT_PREFIX}.${key}`;
|
||||
|
||||
try {
|
||||
if (this.platform === 'win32') {
|
||||
return await this.storeWindows(account, value);
|
||||
} else if (this.platform === 'darwin') {
|
||||
return await this.storeMacOS(account, value);
|
||||
} else if (this.platform === 'linux') {
|
||||
return await this.storeLinux(account, value);
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to store ${key}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a credential from the OS keychain
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<string|null>} Credential value or null
|
||||
*/
|
||||
async retrieve(key) {
|
||||
if (!this.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const account = `${ACCOUNT_PREFIX}.${key}`;
|
||||
|
||||
try {
|
||||
if (this.platform === 'win32') {
|
||||
return await this.retrieveWindows(account);
|
||||
} else if (this.platform === 'darwin') {
|
||||
return await this.retrieveMacOS(account);
|
||||
} else if (this.platform === 'linux') {
|
||||
return await this.retrieveLinux(account);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to retrieve ${key}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a credential from the OS keychain
|
||||
* @param {string} key - Credential identifier
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async delete(key) {
|
||||
if (!this.available) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const account = `${ACCOUNT_PREFIX}.${key}`;
|
||||
|
||||
try {
|
||||
if (this.platform === 'win32') {
|
||||
return await this.deleteWindows(account);
|
||||
} else if (this.platform === 'darwin') {
|
||||
return await this.deleteMacOS(account);
|
||||
} else if (this.platform === 'linux') {
|
||||
return await this.deleteLinux(account);
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Keychain] Failed to delete ${key}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Windows Credential Manager implementation (uses execFileSync to prevent injection)
|
||||
async storeWindows(account, value) {
|
||||
execFileSync('cmdkey', [`/generic:${SERVICE_NAME}:${account}`, `/user:${account}`, `/pass:${value}`], { stdio: 'ignore' });
|
||||
return true;
|
||||
}
|
||||
|
||||
async retrieveWindows(account) {
|
||||
try {
|
||||
const result = execFileSync('cmdkey', [`/list:${SERVICE_NAME}:${account}`], { encoding: 'utf8' });
|
||||
const match = result.match(/Password:\s*(.+)/);
|
||||
return match ? match[1].trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteWindows(account) {
|
||||
execFileSync('cmdkey', [`/delete:${SERVICE_NAME}:${account}`], { stdio: 'ignore' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// macOS Keychain implementation (uses execFileSync to prevent injection)
|
||||
async storeMacOS(account, value) {
|
||||
try {
|
||||
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
|
||||
} catch {
|
||||
// Ignore if doesn't exist
|
||||
}
|
||||
execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', value], { stdio: 'ignore' });
|
||||
return true;
|
||||
}
|
||||
|
||||
async retrieveMacOS(account) {
|
||||
try {
|
||||
const result = execFileSync('security', ['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w'], { encoding: 'utf8' });
|
||||
return result.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMacOS(account) {
|
||||
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Linux Secret Service implementation (uses execFileSync + stdin to prevent injection)
|
||||
async storeLinux(account, value) {
|
||||
try {
|
||||
execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
|
||||
input: value,
|
||||
stdio: ['pipe', 'ignore', 'ignore']
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async retrieveLinux(account) {
|
||||
try {
|
||||
const result = execFileSync('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', account], { encoding: 'utf8' });
|
||||
return result.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteLinux(account) {
|
||||
try {
|
||||
execFileSync('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', account], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new KeychainManager();
|
||||
@@ -0,0 +1,575 @@
|
||||
/**
|
||||
* Log Digest Module
|
||||
* Collects container logs hourly, generates daily summaries.
|
||||
* Gives users a single place to see what happened across all services
|
||||
* and guidance on where to look for more detail.
|
||||
*/
|
||||
|
||||
const Docker = require('dockerode');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { DOCKER } = require('../utilities/constants');
|
||||
|
||||
const docker = new Docker();
|
||||
|
||||
const ERROR_PATTERNS = [
|
||||
/\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i,
|
||||
/\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i,
|
||||
/\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i,
|
||||
/\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i
|
||||
];
|
||||
|
||||
const WARNING_PATTERNS = [
|
||||
/\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i,
|
||||
/\bslow\b/i, /\blatency\b/i
|
||||
];
|
||||
|
||||
const EVENT_PATTERNS = [
|
||||
{ pattern: /\b(start(ed|ing)?|boot(ed|ing)?|init(ializ(ed|ing))?)\b/i, type: 'startup' },
|
||||
{ pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' },
|
||||
{ pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' },
|
||||
{ pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' },
|
||||
{ pattern: /\b(update|upgrade|migration)\b/i, type: 'update' }
|
||||
];
|
||||
|
||||
class LogDigest extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.collectInterval = null;
|
||||
this.digestTimeout = null;
|
||||
this.running = false;
|
||||
this.hourlySummaries = []; // Ring buffer of hourly snapshots
|
||||
this.digestDir = null; // Set during start()
|
||||
this.lastCollect = null;
|
||||
this._lastCollectTimestamp = {}; // Per-container: last log timestamp fetched
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the log digest system.
|
||||
* @param {string} digestDir - Directory to write daily digest files
|
||||
*/
|
||||
start(digestDir) {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.digestDir = digestDir;
|
||||
|
||||
// Ensure digest directory exists
|
||||
if (!fs.existsSync(digestDir)) {
|
||||
fs.mkdirSync(digestDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Collect logs every hour
|
||||
this.collectInterval = setInterval(() => {
|
||||
this._collectHourlyLogs().catch(e =>
|
||||
console.error('[LogDigest] Hourly collection failed:', e.message)
|
||||
);
|
||||
}, DOCKER.DIGEST.COLLECT_INTERVAL);
|
||||
|
||||
// Schedule daily digest generation
|
||||
this._scheduleDailyDigest();
|
||||
|
||||
// Run initial collection after 2 minutes
|
||||
setTimeout(() => {
|
||||
if (this.running) {
|
||||
this._collectHourlyLogs().catch(() => {});
|
||||
}
|
||||
}, 2 * 60 * 1000);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.running) return;
|
||||
this.running = false;
|
||||
if (this.collectInterval) {
|
||||
clearInterval(this.collectInterval);
|
||||
this.collectInterval = null;
|
||||
}
|
||||
if (this.digestTimeout) {
|
||||
clearTimeout(this.digestTimeout);
|
||||
this.digestTimeout = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect logs from all managed containers for the last hour.
|
||||
*/
|
||||
async _collectHourlyLogs() {
|
||||
const now = new Date();
|
||||
const sinceTimestamp = Math.floor((now.getTime() - DOCKER.DIGEST.COLLECT_INTERVAL) / 1000);
|
||||
const hourKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T${String(now.getHours()).padStart(2, '0')}:00`;
|
||||
|
||||
const hourSummary = {
|
||||
hour: hourKey,
|
||||
timestamp: now.toISOString(),
|
||||
services: {}
|
||||
};
|
||||
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true });
|
||||
const managed = containers.filter(c => c.Labels?.['sami.managed'] === 'true');
|
||||
|
||||
for (const containerInfo of managed) {
|
||||
const name = containerInfo.Names[0]?.replace(/^\//, '') || containerInfo.Id.slice(0, 12);
|
||||
const appId = containerInfo.Labels['sami.app'] || name;
|
||||
const isRunning = containerInfo.State === 'running';
|
||||
|
||||
const serviceSummary = {
|
||||
name,
|
||||
appId,
|
||||
state: containerInfo.State,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
events: [],
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
totalLines: 0
|
||||
};
|
||||
|
||||
if (isRunning) {
|
||||
try {
|
||||
const container = docker.getContainer(containerInfo.Id);
|
||||
const logBuffer = await container.logs({
|
||||
stdout: true,
|
||||
stderr: true,
|
||||
since: sinceTimestamp,
|
||||
tail: DOCKER.DIGEST.LOG_TAIL,
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
const lines = this._parseDockerLogs(logBuffer);
|
||||
serviceSummary.totalLines = lines.length;
|
||||
|
||||
for (const line of lines) {
|
||||
// Check for errors
|
||||
if (line.stream === 'stderr' || ERROR_PATTERNS.some(p => p.test(line.text))) {
|
||||
serviceSummary.errorCount++;
|
||||
if (serviceSummary.errors.length < 10) {
|
||||
serviceSummary.errors.push({
|
||||
time: line.timestamp || hourKey,
|
||||
text: line.text.slice(0, 500)
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for warnings
|
||||
if (WARNING_PATTERNS.some(p => p.test(line.text))) {
|
||||
serviceSummary.warningCount++;
|
||||
if (serviceSummary.warnings.length < 5) {
|
||||
serviceSummary.warnings.push({
|
||||
time: line.timestamp || hourKey,
|
||||
text: line.text.slice(0, 300)
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for notable events
|
||||
for (const { pattern, type } of EVENT_PATTERNS) {
|
||||
if (pattern.test(line.text)) {
|
||||
serviceSummary.events.push({
|
||||
type,
|
||||
time: line.timestamp || hourKey,
|
||||
text: line.text.slice(0, 300)
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (logErr) {
|
||||
serviceSummary.errors.push({
|
||||
time: now.toISOString(),
|
||||
text: `Failed to fetch logs: ${logErr.message}`
|
||||
});
|
||||
serviceSummary.errorCount++;
|
||||
}
|
||||
} else {
|
||||
serviceSummary.events.push({
|
||||
type: 'not_running',
|
||||
time: now.toISOString(),
|
||||
text: `Container is ${containerInfo.State}`
|
||||
});
|
||||
}
|
||||
|
||||
hourSummary.services[appId] = serviceSummary;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[LogDigest] Container enumeration failed:', e.message);
|
||||
}
|
||||
|
||||
// Add to ring buffer
|
||||
this.hourlySummaries.push(hourSummary);
|
||||
if (this.hourlySummaries.length > DOCKER.DIGEST.MAX_HOURLY_ENTRIES) {
|
||||
this.hourlySummaries.shift();
|
||||
}
|
||||
|
||||
this.lastCollect = now.toISOString();
|
||||
this.emit('hourly-collected', hourSummary);
|
||||
return hourSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Docker multiplexed log stream into lines.
|
||||
*/
|
||||
_parseDockerLogs(logData) {
|
||||
const lines = [];
|
||||
const buffer = Buffer.isBuffer(logData) ? logData : Buffer.from(logData);
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (offset + 8 > buffer.length) break;
|
||||
const streamType = buffer[0 + offset];
|
||||
const size = buffer.readUInt32BE(4 + offset);
|
||||
if (offset + 8 + size > buffer.length) break;
|
||||
|
||||
const text = buffer.slice(offset + 8, offset + 8 + size).toString('utf8').trim();
|
||||
if (text) {
|
||||
// Try to extract timestamp from Docker's format: "2026-03-13T12:00:00.000000000Z message"
|
||||
let timestamp = null;
|
||||
let message = text;
|
||||
const tsMatch = text.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d+Z\s(.*)$/s);
|
||||
if (tsMatch) {
|
||||
timestamp = tsMatch[1];
|
||||
message = tsMatch[2];
|
||||
}
|
||||
|
||||
lines.push({
|
||||
stream: streamType === 2 ? 'stderr' : 'stdout',
|
||||
text: message,
|
||||
timestamp
|
||||
});
|
||||
}
|
||||
offset += 8 + size;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the daily digest at the configured hour.
|
||||
*/
|
||||
_scheduleDailyDigest() {
|
||||
const now = new Date();
|
||||
const targetHour = DOCKER.DIGEST.DIGEST_HOUR;
|
||||
const next = new Date(now);
|
||||
next.setHours(targetHour, 5, 0, 0); // 5 minutes past the hour
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
|
||||
const delay = next.getTime() - now.getTime();
|
||||
this.digestTimeout = setTimeout(() => {
|
||||
this.generateDailyDigest().catch(e =>
|
||||
console.error('[LogDigest] Daily digest generation failed:', e.message)
|
||||
);
|
||||
// Reschedule for tomorrow
|
||||
if (this.running) this._scheduleDailyDigest();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the daily digest from accumulated hourly summaries.
|
||||
* Can also be called on-demand.
|
||||
*/
|
||||
async generateDailyDigest(dateStr) {
|
||||
const date = dateStr || new Date(Date.now() - 86400000).toISOString().slice(0, 10);
|
||||
const relevantHours = this.hourlySummaries.filter(h => h.hour.startsWith(date));
|
||||
|
||||
// Aggregate per-service stats across all hours
|
||||
const serviceAgg = {};
|
||||
const notableEvents = [];
|
||||
|
||||
for (const hour of relevantHours) {
|
||||
for (const [appId, svc] of Object.entries(hour.services)) {
|
||||
if (!serviceAgg[appId]) {
|
||||
serviceAgg[appId] = {
|
||||
name: svc.name,
|
||||
appId,
|
||||
totalErrors: 0,
|
||||
totalWarnings: 0,
|
||||
totalLines: 0,
|
||||
lastState: svc.state,
|
||||
topErrors: [],
|
||||
events: []
|
||||
};
|
||||
}
|
||||
const agg = serviceAgg[appId];
|
||||
agg.totalErrors += svc.errorCount;
|
||||
agg.totalWarnings += svc.warningCount;
|
||||
agg.totalLines += svc.totalLines;
|
||||
agg.lastState = svc.state;
|
||||
|
||||
// Keep top errors (deduplicated-ish)
|
||||
for (const err of svc.errors) {
|
||||
if (agg.topErrors.length < 5) {
|
||||
agg.topErrors.push(err);
|
||||
}
|
||||
}
|
||||
|
||||
// Collect notable events
|
||||
for (const evt of svc.events) {
|
||||
notableEvents.push({ ...evt, service: svc.name, appId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get Docker disk usage
|
||||
let diskUsage = null;
|
||||
try {
|
||||
const dockerMaintenance = require('../docker/docker-maintenance');
|
||||
diskUsage = await dockerMaintenance.getDiskUsage();
|
||||
} catch (e) {
|
||||
// Module may not be loaded yet
|
||||
}
|
||||
|
||||
// Build digest object
|
||||
const digest = {
|
||||
date,
|
||||
generatedAt: new Date().toISOString(),
|
||||
hoursCollected: relevantHours.length,
|
||||
services: serviceAgg,
|
||||
notableEvents: notableEvents.sort((a, b) => (a.time || '').localeCompare(b.time || '')),
|
||||
diskUsage,
|
||||
summary: {
|
||||
totalServices: Object.keys(serviceAgg).length,
|
||||
servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length,
|
||||
totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0),
|
||||
totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0)
|
||||
}
|
||||
};
|
||||
|
||||
// Write formatted digest file
|
||||
const formatted = this._formatDigest(digest);
|
||||
const filename = `digest-${date}.log`;
|
||||
const filepath = path.join(this.digestDir, filename);
|
||||
await fsp.writeFile(filepath, formatted, 'utf8');
|
||||
|
||||
// Also write JSON for API consumption
|
||||
const jsonPath = path.join(this.digestDir, `digest-${date}.json`);
|
||||
await fsp.writeFile(jsonPath, JSON.stringify(digest, null, 2), 'utf8');
|
||||
|
||||
// Cleanup old digests
|
||||
await this._cleanupOldDigests();
|
||||
|
||||
this.emit('digest-generated', { date, filepath, digest });
|
||||
return digest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format digest into human-readable text.
|
||||
*/
|
||||
_formatDigest(digest) {
|
||||
const lines = [];
|
||||
const hr = '='.repeat(55);
|
||||
const sr = '-'.repeat(55);
|
||||
|
||||
lines.push(hr);
|
||||
lines.push(' DashCaddy Daily Log Digest');
|
||||
lines.push(` ${digest.date}`);
|
||||
lines.push(` Generated: ${digest.generatedAt}`);
|
||||
lines.push(hr);
|
||||
lines.push('');
|
||||
|
||||
// Service summary table
|
||||
lines.push('-- Service Summary ' + '-'.repeat(36));
|
||||
const services = Object.values(digest.services);
|
||||
if (services.length === 0) {
|
||||
lines.push(' No managed services found.');
|
||||
} else {
|
||||
for (const svc of services) {
|
||||
const stateIcon = svc.lastState === 'running' ? 'OK' : '!!';
|
||||
const errStr = `${svc.totalErrors} error${svc.totalErrors !== 1 ? 's' : ''}`;
|
||||
const warnStr = `${svc.totalWarnings} warning${svc.totalWarnings !== 1 ? 's' : ''}`;
|
||||
const flag = svc.totalErrors > 0 ? ' <-- investigate' : '';
|
||||
lines.push(` ${svc.name.padEnd(18)} ${stateIcon.padEnd(10)} ${errStr.padEnd(14)} ${warnStr}${flag}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
// Notable events
|
||||
const events = digest.notableEvents;
|
||||
if (events.length > 0) {
|
||||
lines.push('-- Notable Events ' + '-'.repeat(37));
|
||||
for (const evt of events) {
|
||||
const time = (evt.time || '').slice(11, 16) || '??:??';
|
||||
lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`);
|
||||
// Add guidance for where to look further
|
||||
const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`;
|
||||
if (evt.type === 'health_failure' || evt.type === 'restart') {
|
||||
const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00';
|
||||
lines.push(` See: docker logs ${containerName} --since ${sinceDate}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Top errors per service
|
||||
const errServices = services.filter(s => s.totalErrors > 0);
|
||||
if (errServices.length > 0) {
|
||||
lines.push('-- Error Details ' + '-'.repeat(38));
|
||||
for (const svc of errServices) {
|
||||
lines.push(` ${svc.name} (${svc.totalErrors} errors):`);
|
||||
for (const err of svc.topErrors) {
|
||||
const time = (err.time || '').slice(11, 16) || '??:??';
|
||||
lines.push(` [${time}] ${err.text.slice(0, 100)}`);
|
||||
}
|
||||
const containerName = `${DOCKER.CONTAINER_PREFIX}${svc.appId}`;
|
||||
lines.push(` Full logs: docker logs ${containerName} --since ${digest.date}T00:00:00`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Docker disk usage
|
||||
if (digest.diskUsage) {
|
||||
lines.push('-- Docker Disk Usage ' + '-'.repeat(34));
|
||||
const du = digest.diskUsage;
|
||||
lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`);
|
||||
lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`);
|
||||
lines.push(` Volumes: ${formatBytes(du.volumes.sizeBytes)} (${du.volumes.count} volumes)`);
|
||||
lines.push(` Build Cache: ${formatBytes(du.buildCache.sizeBytes)}`);
|
||||
lines.push(` Total: ${du.totalGB} GB`);
|
||||
if (du.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) {
|
||||
lines.push(` WARNING: Exceeds ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB threshold!`);
|
||||
lines.push(' Run: docker system prune -a (removes unused images/cache)');
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Summary
|
||||
lines.push(sr);
|
||||
lines.push(` ${digest.summary.totalServices} service(s) monitored | ${digest.summary.totalErrors} error(s) | ${digest.summary.totalWarnings} warning(s)`);
|
||||
lines.push(` Hours collected: ${digest.hoursCollected}/24`);
|
||||
lines.push(hr);
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove digest files older than MAX_DIGEST_FILES days.
|
||||
*/
|
||||
async _cleanupOldDigests() {
|
||||
if (!this.digestDir) return;
|
||||
try {
|
||||
const files = await fsp.readdir(this.digestDir);
|
||||
const digestFiles = files.filter(f => f.startsWith('digest-')).sort();
|
||||
// Each date has .log + .json = 2 files per day
|
||||
const maxFiles = DOCKER.DIGEST.MAX_DIGEST_FILES * 2;
|
||||
if (digestFiles.length > maxFiles) {
|
||||
const toDelete = digestFiles.slice(0, digestFiles.length - maxFiles);
|
||||
for (const f of toDelete) {
|
||||
await fsp.unlink(path.join(this.digestDir, f)).catch(() => {});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Directory may not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest daily digest (JSON).
|
||||
*/
|
||||
async getLatestDigest() {
|
||||
if (!this.digestDir) return null;
|
||||
try {
|
||||
const files = await fsp.readdir(this.digestDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json')).sort();
|
||||
if (jsonFiles.length === 0) return null;
|
||||
const latest = path.join(this.digestDir, jsonFiles[jsonFiles.length - 1]);
|
||||
return JSON.parse(await fsp.readFile(latest, 'utf8'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get digest for a specific date.
|
||||
*/
|
||||
async getDigestByDate(dateStr) {
|
||||
if (!this.digestDir) return null;
|
||||
const jsonPath = path.join(this.digestDir, `digest-${dateStr}.json`);
|
||||
try {
|
||||
return JSON.parse(await fsp.readFile(jsonPath, 'utf8'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the formatted text version of a digest.
|
||||
*/
|
||||
async getDigestText(dateStr) {
|
||||
if (!this.digestDir) return null;
|
||||
const logPath = path.join(this.digestDir, `digest-${dateStr}.log`);
|
||||
try {
|
||||
return await fsp.readFile(logPath, 'utf8');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List available digest dates.
|
||||
*/
|
||||
async listDigests() {
|
||||
if (!this.digestDir) return [];
|
||||
try {
|
||||
const files = await fsp.readdir(this.digestDir);
|
||||
return files
|
||||
.filter(f => f.endsWith('.json'))
|
||||
.map(f => f.replace('digest-', '').replace('.json', ''))
|
||||
.sort()
|
||||
.reverse();
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get live data: current day's accumulated hourly summaries.
|
||||
*/
|
||||
getLiveData() {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const todayHours = this.hourlySummaries.filter(h => h.hour.startsWith(today));
|
||||
|
||||
// Aggregate
|
||||
const serviceAgg = {};
|
||||
for (const hour of todayHours) {
|
||||
for (const [appId, svc] of Object.entries(hour.services)) {
|
||||
if (!serviceAgg[appId]) {
|
||||
serviceAgg[appId] = { name: svc.name, appId, totalErrors: 0, totalWarnings: 0, lastState: svc.state, recentErrors: [] };
|
||||
}
|
||||
serviceAgg[appId].totalErrors += svc.errorCount;
|
||||
serviceAgg[appId].totalWarnings += svc.warningCount;
|
||||
serviceAgg[appId].lastState = svc.state;
|
||||
for (const err of svc.errors) {
|
||||
if (serviceAgg[appId].recentErrors.length < 10) {
|
||||
serviceAgg[appId].recentErrors.push(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
date: today,
|
||||
hoursCollected: todayHours.length,
|
||||
lastCollect: this.lastCollect,
|
||||
services: serviceAgg
|
||||
};
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
running: this.running,
|
||||
lastCollect: this.lastCollect,
|
||||
hourlySummaries: this.hourlySummaries.length,
|
||||
digestDir: this.digestDir
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
module.exports = new LogDigest();
|
||||
Reference in New Issue
Block a user