[grade=pending] QA sprint: commit 103 at-risk files from multi-agent sprint work

Committed by Hermes autonomous QA sprint 2026-08-13.
These files were modified during the Aug 12 sprint but never committed.
This commit is contained in:
Krystie
2026-08-12 17:34:10 -07:00
parent 0bf4406253
commit 503de258b8
105 changed files with 14057 additions and 2632 deletions
+79
View File
@@ -60,6 +60,14 @@ const monitoringRoutes = require('../routes/monitoring');
const updatesRoutes = require('../routes/updates');
const authRoutes = require('../routes/auth');
const shareRoutes = require('../routes/share');
const i18nRoutes = require('../routes/i18n');
const discoverRoutes = require('../routes/discover');
const discoverAdoptRoutes = require('../routes/discover-adopt');
const catalogRoutes = require('../routes/catalog');
const wizardRoutes = require('../routes/wizard');
const disasterRoutes = require('../routes/disaster-recovery');
const caddycodeRoutes = require('../routes/caddycode');
const fleetRoutes = require('../routes/fleet');
const configRoutes = require('../routes/config');
const dnsRoutes = require('../routes/dns');
const notificationRoutes = require('../routes/notifications');
@@ -90,9 +98,11 @@ const DependencyManager = require('./managers/dependency-manager');
const autoRestartRoutes = require('../routes/auto-restart');
const configDriftRoutes = require('../routes/config-drift');
const sslMonitorRoutes = require('../routes/ssl-monitor');
const diskSpaceRoutes = require('../routes/disk-space');
const { AutoRestartManager } = require('./managers/auto-restart-manager');
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
const SSLMonitor = require('./monitoring/ssl-monitor');
const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor');
const DNSPropagationChecker = require('./dns/dns-propagation');
// Constants
@@ -455,6 +465,12 @@ async function createApp() {
sslMonitor.start(3600000); // 1 hour
log.info('app', 'SSL monitor initialized');
// Initialize disk space monitor (disk budget + auto-cleanup)
const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig });
ctx.diskSpaceMonitor = diskSpaceMonitor;
diskSpaceMonitor.start(600000); // 10 min
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
// Initialize DNS propagation checker
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
ctx.dnsPropagationChecker = dnsPropagationChecker;
@@ -587,6 +603,58 @@ async function createApp() {
log: ctx.log,
notificationManager: ctx.notification
}));
// DC-077: i18n — language metadata and translations (public, no auth needed)
apiRouter.use(i18nRoutes());
// DC-100: Service discovery — auto-detect running containers
apiRouter.use(discoverRoutes({
docker: ctx.docker,
servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler,
}));
// DC-103: One-click adopt — auto-generate routes + DNS + service entry
apiRouter.use(discoverAdoptRoutes({
docker: ctx.docker,
servicesStateManager: ctx.servicesStateManager,
caddy: ctx.caddy,
dns: ctx.dns,
siteConfig: ctx.config,
asyncHandler: ctx.asyncHandler,
}));
// DC-104: App catalog — browse curated templates
const { APP_TEMPLATES: templatesArray } = require('./docker/app-templates');
apiRouter.use(catalogRoutes({
APP_TEMPLATES: templatesArray,
asyncHandler: ctx.asyncHandler,
}));
// DC-105: Smart defaults wizard
apiRouter.use(wizardRoutes({
APP_TEMPLATES: templatesArray,
asyncHandler: ctx.asyncHandler,
}));
// DC-107: Disaster recovery — full backup + restore
apiRouter.use(disasterRoutes({
servicesStateManager: ctx.servicesStateManager,
platformPaths: require('../platform-paths'),
log: ctx.log,
asyncHandler: ctx.asyncHandler,
}));
// DC-106: Caddyfile-as-code — visual reverse proxy builder
apiRouter.use(caddycodeRoutes({
asyncHandler: ctx.asyncHandler,
}));
// DC-108: Multi-host fleet management
apiRouter.use(fleetRoutes({
log: ctx.log,
asyncHandler: ctx.asyncHandler,
}));
apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager,
selfUpdater: ctx.selfUpdater,
@@ -709,6 +777,11 @@ async function createApp() {
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use('/disk', diskSpaceRoutes({
diskSpaceMonitor: ctx.diskSpaceMonitor,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
// Inline API routes (mounted under /api/v1 below)
// Note: /health lives at root only — see root-level health check below.
@@ -723,6 +796,12 @@ async function createApp() {
ok(res, { metrics: metrics.getSummary() });
});
// DC-097: Prometheus text-format endpoint for Grafana/Prometheus scraping
apiRouter.get('/metrics/prometheus', (req, res) => {
res.set('Content-Type', 'text/plain; version=0.0.4');
res.send(metrics.toPrometheus());
});
// Mount at /api/v1 (canonical, single version)
app.use('/api/v1', apiRouter);
+1 -2
View File
@@ -410,8 +410,7 @@ class EmailMagicLinkProvider extends AuthProvider {
if (this.deps.log && typeof this.deps.log.warn === 'function') {
this.deps.log.warn('auth-magic-dev', marker);
} else {
// eslint-disable-next-line no-console
console.warn(marker);
process.stderr.write(`${marker}\n`);
}
}
@@ -16,7 +16,7 @@ class DNSProviderRegistry {
const instance = new adapterClass({}, {});
const id = instance.providerId;
if (this.providers.has(id)) {
console.warn(`DNS provider "${id}" already registered, overwriting`);
process.stderr.write(`[DNS Registry] Provider "${id}" already registered, overwriting\n`);
}
this.providers.set(id, adapterClass);
}
@@ -88,7 +88,7 @@ class DNSProviderRegistry {
}
}
} catch (err) {
console.error(`Failed to load DNS provider from ${file}:`, err.message);
process.stderr.write(`[DNS Registry] Failed to load DNS provider from ${file}: ${err.message}\n`);
}
}
}
@@ -10,6 +10,7 @@
const { execFile } = require('child_process');
const { promisify } = require('util');
const crypto = require('crypto');
const dns = require('dns');
const os = require('os');
const path = require('path');
@@ -117,7 +118,7 @@ class RFC2136Provider extends BaseDNSProvider {
*/
async _runNsupdate(commands) {
const script = commands.join('\n') + '\n';
const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`);
const tmpFile = path.join(os.tmpdir(), `nsupdate-${crypto.randomBytes(4).toString('hex')}.cmd`);
try {
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
+13 -12
View File
@@ -10,12 +10,13 @@
const EventEmitter = require('events');
const https = require('https');
const http = require('http');
const { log } = require('../utils/logging');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process');
const { execFileSync } = require('child_process');
const platformPaths = require('../../platform-paths');
const isWindows = platformPaths.isWindows;
@@ -86,7 +87,7 @@ class SelfUpdater extends EventEmitter {
start() {
if (!this.config.enabled || this.checkTimer) return;
console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);
log.info('updater', 'Starting auto-update checks', { intervalMs: this.config.checkInterval });
// First check after a short delay (let server finish startup)
setTimeout(() => {
@@ -124,7 +125,7 @@ class SelfUpdater extends EventEmitter {
return { version: pkg.version, commit };
} catch { /* try next candidate */ }
}
console.error('[SelfUpdater] getLocalVersion failed: no candidate package.json found');
log.error('updater', 'getLocalVersion failed: no candidate package.json found');
return { version: '0.0.0', commit: null };
}
@@ -158,7 +159,7 @@ class SelfUpdater extends EventEmitter {
// Fire-and-forget; the response shouldn't block on the container rebuild.
setImmediate(() => {
this._autoCheckAndApply().catch(err =>
console.error('[SelfUpdater] %s-triggered update error: %s', triggeredBy, err.message)
log.error('updater', err, { triggeredBy })
);
});
return { accepted: true, triggeredBy };
@@ -174,7 +175,7 @@ class SelfUpdater extends EventEmitter {
try {
remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
} catch (primaryErr) {
console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
log.warn('updater', 'Primary server failed, trying mirror', { error: primaryErr.message });
try {
remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
sourceUrl = this.config.mirrorUrl;
@@ -240,7 +241,7 @@ class SelfUpdater extends EventEmitter {
try {
await this._downloadFile(primaryUrl, tarballPath);
} catch (dlErr) {
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
log.warn('updater', 'Primary download failed, trying mirror', { error: dlErr.message });
// Ensure file is fully cleaned up before mirror attempt
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
await this._downloadFile(mirrorUrl, tarballPath);
@@ -468,11 +469,11 @@ class SelfUpdater extends EventEmitter {
try {
const result = await this.checkForUpdate();
if (result.available && result.remote) {
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
log.info('updater', 'Update available', { localVersion: result.local.version, remoteVersion: result.remote.version });
await this.applyUpdate(result.remote);
}
} catch (e) {
console.error('[SelfUpdater] Auto-update error:', e.message);
log.error('updater', e, { phase: 'autoUpdate' });
}
}
@@ -606,7 +607,7 @@ class SelfUpdater extends EventEmitter {
fs.mkdirSync(path.dirname(this.notifySecretFile), { recursive: true });
fs.writeFileSync(this.notifySecretFile, `${secret}\n`, { mode: 0o600 });
} catch (error) {
console.warn('[SelfUpdater] Failed to persist notify secret:', error.message);
log.warn('updater', 'Failed to persist notify secret', { error: error.message });
}
return secret;
}
@@ -626,7 +627,7 @@ class SelfUpdater extends EventEmitter {
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
} catch (error) {
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
log.warn('updater', 'Failed to persist instance ID', { error: error.message });
}
return instanceId;
}
@@ -644,7 +645,7 @@ class SelfUpdater extends EventEmitter {
try {
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
} catch (e) {
console.error('[SelfUpdater] Failed to save history:', e.message);
log.error('updater', e, { operation: 'saveHistory' });
}
}
@@ -713,7 +714,7 @@ class SelfUpdater extends EventEmitter {
await fsp.mkdir(destDir, { recursive: true });
// Use tar command (available on Linux, and Git Bash on Windows)
try {
execSync(`tar xzf "${tarballPath}" -C "${destDir}" --strip-components=1`, { stdio: 'pipe' });
execFileSync('tar', ['xzf', tarballPath, '-C', destDir, '--strip-components=1'], { stdio: 'pipe' });
} catch (e) {
throw new Error('Failed to extract tarball: ' + e.message);
}
+21 -20
View File
@@ -8,6 +8,7 @@ const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const credentialManager = require('./credential-manager');
const cryptoUtils = require('../security/crypto-utils');
const { log } = require('../utils/logging');
// JWT signing secret - derived from encryption key for consistency
const JWT_SECRET = cryptoUtils.loadOrCreateKey();
@@ -19,7 +20,7 @@ const API_KEY_METADATA_NAMESPACE = 'auth.metadata';
class AuthManager {
constructor() {
this.keyMetadataCache = new Map(); // Cache for API key metadata
console.log('[AuthManager] Initialized');
log.info('auth', 'Initialized');
}
/**
@@ -44,10 +45,10 @@ class AuthManager {
{ expiresIn }
);
console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`);
log.info('auth', 'Generated JWT', { user: payload.sub, expiresIn });
return token;
} catch (error) {
console.error('[AuthManager] JWT generation failed:', error.message);
log.error('auth', error, { operation: 'jwtGenerate' });
throw error;
}
}
@@ -68,11 +69,11 @@ class AuthManager {
};
} catch (error) {
if (error.name === 'TokenExpiredError') {
console.log('[AuthManager] JWT token expired');
log.info('auth', 'JWT token expired');
} else if (error.name === 'JsonWebTokenError') {
console.log('[AuthManager] JWT token invalid:', error.message);
log.info('auth', 'JWT token invalid', { error: error.message });
} else {
console.error('[AuthManager] JWT verification failed:', error.message);
log.error('auth', error, { operation: 'jwtVerify' });
}
return null;
}
@@ -116,7 +117,7 @@ class AuthManager {
// Cache metadata
this.keyMetadataCache.set(keyId, metadata);
console.log(`[AuthManager] Generated API key: ${name} (${keyId})`);
log.info('auth', 'Generated API key', { name, keyId });
return {
key: apiKey,
@@ -126,7 +127,7 @@ class AuthManager {
createdAt: metadata.createdAt
};
} catch (error) {
console.error('[AuthManager] API key generation failed:', error.message);
log.error('auth', error, { operation: 'apiKeyGenerate' });
throw error;
}
}
@@ -154,30 +155,30 @@ class AuthManager {
// Retrieve stored hash
const storedHash = await credentialManager.retrieve(credentialKey);
if (!storedHash) {
console.log(`[AuthManager] API key not found: ${keyId}`);
log.info('auth', 'API key not found', { keyId });
return null;
}
// Verify key matches stored hash
const providedHash = crypto.createHash('sha256').update(key).digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(storedHash), Buffer.from(providedHash))) {
console.log(`[AuthManager] API key hash mismatch: ${keyId}`);
log.info('auth', 'API key hash mismatch', { keyId });
return null;
}
// Get metadata
const metadata = await this.getKeyMetadata(keyId);
if (!metadata) {
console.log(`[AuthManager] API key metadata not found: ${keyId}`);
log.info('auth', 'API key metadata not found', { keyId });
return null;
}
// Update last used timestamp (non-blocking)
this.updateLastUsed(keyId, metadata).catch(err =>
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, err.message)
log.error('auth', err, { keyId, operation: 'updateLastUsed' })
);
console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`);
log.info('auth', 'API key verified', { name: metadata.name, keyId });
return {
keyId,
@@ -185,7 +186,7 @@ class AuthManager {
name: metadata.name
};
} catch (error) {
console.error('[AuthManager] API key verification failed:', error.message);
log.error('auth', error, { operation: 'apiKeyVerify' });
return null;
}
}
@@ -205,10 +206,10 @@ class AuthManager {
this.keyMetadataCache.delete(keyId);
console.log(`[AuthManager] Revoked API key: ${keyId}`);
log.info('auth', 'Revoked API key', { keyId });
return true;
} catch (error) {
console.error(`[AuthManager] Failed to revoke API key ${keyId}:`, error.message);
log.error('auth', error, { keyId, operation: 'revoke' });
return false;
}
}
@@ -233,7 +234,7 @@ class AuthManager {
return keys;
} catch (error) {
console.error('[AuthManager] Failed to list API keys:', error.message);
log.error('auth', error, { operation: 'listApiKeys' });
return [];
}
}
@@ -262,7 +263,7 @@ class AuthManager {
return metadata;
} catch (error) {
console.error(`[AuthManager] Failed to get metadata for ${keyId}:`, error.message);
log.error('auth', error, { keyId, operation: 'getMetadata' });
return null;
}
}
@@ -285,7 +286,7 @@ class AuthManager {
this.keyMetadataCache.set(keyId, updatedMetadata);
} catch (error) {
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, error.message);
log.error('auth', error, { keyId, operation: 'updateLastUsed' });
}
}
@@ -294,7 +295,7 @@ class AuthManager {
*/
clearCache() {
this.keyMetadataCache.clear();
console.log('[AuthManager] Cache cleared');
log.info('auth', 'Cache cleared');
}
}
@@ -50,7 +50,7 @@ class AutoRestartManager extends EventEmitter {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
this.logError = ctx.logError || ((_ctx, err) => process.stderr.write(`[auto-restart] ${err?.message || err}\n`));
this.docker = ctx.docker;
this.healthChecker = ctx.healthChecker;
this.notification = ctx.notification;
@@ -41,7 +41,7 @@ class ConfigDriftDetector extends EventEmitter {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_c, err) => console.error(err));
this.logError = ctx.logError || ((_c, err) => process.stderr.write(`[config-drift] ${err?.message || err}\n`));
this.docker = ctx.docker;
this.servicesStateManager = ctx.servicesStateManager;
this.notification = ctx.notification;
@@ -8,6 +8,7 @@ const keychainManager = require('../security/keychain-manager');
const cryptoUtils = require('../security/crypto-utils');
const lockfile = require('proper-lockfile');
const fs = require('fs');
const { log } = require('../utils/logging');
const path = require('path');
const platformPaths = require('../../platform-paths');
@@ -33,7 +34,7 @@ class CredentialManager {
stale: 30000
};
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
log.info('cred', 'Initialized', { storage: this.useKeychain ? 'keychain' : 'file' });
}
/**
@@ -60,19 +61,19 @@ class CredentialManager {
// Store metadata separately in file
await this.storeMetadata(key, metadata);
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
console.log(`[CredentialManager] Stored '${key}' in OS keychain`);
log.info('cred', 'Stored credential in keychain', { key });
return true;
}
console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`);
log.warn('cred', 'Keychain storage failed, falling back to encrypted file', { key });
}
// Fallback to encrypted file storage
await this.storeInFile(key, value, metadata);
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
console.log(`[CredentialManager] Stored '${key}' in encrypted file`);
log.info('cred', 'Stored credential in encrypted file', { key });
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to store '${key}':`, error.message);
log.error('cred', error, { key, operation: 'store' });
return false;
}
}
@@ -109,7 +110,7 @@ class CredentialManager {
}
return value;
} catch (error) {
console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message);
log.error('cred', error, { key, operation: 'retrieve' });
return null;
}
}
@@ -132,10 +133,10 @@ class CredentialManager {
// Remove from file storage
await this.deleteFromFile(key);
console.log(`[CredentialManager] Deleted '${key}'`);
log.info('cred', 'Deleted credential', { key });
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to delete '${key}':`, error.message);
log.error('cred', error, { key, operation: 'delete' });
return false;
}
}
@@ -149,7 +150,7 @@ class CredentialManager {
const credentials = await this.loadCredentialsFile();
return Object.keys(credentials);
} catch (error) {
console.error('[CredentialManager] Failed to list credentials:', error.message);
log.error('cred', error, { operation: 'list' });
return [];
}
}
@@ -175,7 +176,7 @@ class CredentialManager {
async rotateEncryptionKey() {
let release;
try {
console.log('[CredentialManager] Starting encryption key rotation...');
log.info('cred', 'Starting encryption key rotation');
// Ensure file exists before locking
this._ensureFileExists();
@@ -186,7 +187,7 @@ class CredentialManager {
const keys = Object.keys(credentials);
if (keys.length === 0) {
console.log('[CredentialManager] No credentials to rotate');
log.info('cred', 'No credentials to rotate');
return true;
}
@@ -219,10 +220,10 @@ class CredentialManager {
// Clear cache to force reload
this.cache.clear();
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
log.info('cred', 'Rotated credentials', { count: keys.length });
return true;
} catch (error) {
console.error('[CredentialManager] Key rotation failed:', error.message);
log.error('cred', error, { operation: 'rotate' });
return false;
} finally {
if (release) {
@@ -255,12 +256,12 @@ class CredentialManager {
if (migrated > 0) {
this.cache.clear();
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
log.info('cred', 'Migrated plaintext credentials', { count: migrated });
}
return { migrated, skipped, total: migrated + skipped };
} catch (error) {
console.error('[CredentialManager] Migration failed:', error.message);
log.error('cred', error, { operation: 'migrate' });
throw error;
}
}
@@ -365,14 +366,11 @@ class CredentialManager {
// Most common cause: the encryption key on disk is different from
// the key that originally encrypted this entry (rotated by a
// container recreate that didn't preserve CREDENTIALS_FILE env).
console.warn(
`[CredentialManager] '${key}' is present but cannot be decrypted ` +
`(likely encryption-key mismatch): ${decryptErr.message}`
);
log.warn('cred', 'Credential present but cannot be decrypted (likely encryption-key mismatch)', { key, error: decryptErr.message });
return { status: 'unreadable', value: null, error: decryptErr.message };
}
} catch (err) {
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
log.error('cred', err, { key, operation: 'diagnose' });
return { status: 'malformed', value: null, error: err.message };
}
}
@@ -404,7 +402,7 @@ class CredentialManager {
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('[CredentialManager] Failed to load credentials file:', error.message);
log.error('cred', error, { operation: 'loadFile' });
return {};
}
}
@@ -440,10 +438,10 @@ class CredentialManager {
await this._lockedUpdate(() => backup.credentials);
this.cache.clear();
console.log('[CredentialManager] Successfully imported backup');
log.info('cred', 'Successfully imported backup');
return true;
} catch (error) {
console.error('[CredentialManager] Failed to import backup:', error.message);
log.error('cred', error, { operation: 'importBackup' });
return false;
}
}
+19 -17
View File
@@ -6,8 +6,10 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const lockfile = require('proper-lockfile');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
const LOCK_DIR = process.env.PORT_LOCK_DIR || path.join(platformPaths.dataDir, '.port-locks');
const LOCK_TIMEOUT = 120000; // 2 minutes
@@ -35,7 +37,7 @@ class PortLockManager {
ensureLockDirectory() {
if (!fs.existsSync(LOCK_DIR)) {
fs.mkdirSync(LOCK_DIR, { recursive: true });
console.log('[PortLockManager] Created lock directory:', LOCK_DIR);
log.info('portlock', 'Created lock directory', { dir: LOCK_DIR });
}
}
@@ -57,13 +59,13 @@ class PortLockManager {
throw new Error('Ports must be a non-empty array');
}
const lockId = `lock-${Date.now()}-${Math.random().toString(36).substring(7)}`;
const lockId = `lock-${Date.now()}-${crypto.randomBytes(8).toString('hex')}`;
const sortedPorts = [...new Set(ports)].sort((a, b) => parseInt(a) - parseInt(b));
const acquiredLocks = [];
const releaseFunctions = [];
try {
console.log(`[PortLockManager] Acquiring locks for ports: ${sortedPorts.join(', ')}`);
log.info('portlock', 'Acquiring locks', { ports: sortedPorts });
// Acquire locks in sorted order to prevent deadlocks
for (const port of sortedPorts) {
@@ -83,7 +85,7 @@ class PortLockManager {
acquiredLocks.push(port);
releaseFunctions.push(release);
console.log(`[PortLockManager] Locked port ${port}`);
log.info('portlock', 'Locked port', { port });
}
// Store lock information
@@ -93,18 +95,18 @@ class PortLockManager {
timestamp: Date.now()
});
console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`);
log.info('portlock', 'Acquired all locks', { lockId });
return lockId;
} catch (error) {
// Release any locks we managed to acquire
console.error(`[PortLockManager] Failed to acquire all locks:`, error.message);
log.error('portlock', error, { operation: 'acquire', lockId });
for (const release of releaseFunctions) {
try {
await release();
} catch (releaseError) {
console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message);
log.error('portlock', releaseError, { operation: 'releaseCleanup', lockId });
}
}
@@ -120,11 +122,11 @@ class PortLockManager {
const lockInfo = this.activeLocks.get(lockId);
if (!lockInfo) {
console.warn(`[PortLockManager] Lock ID ${lockId} not found (may have been released already)`);
log.warn('portlock', 'Lock ID not found', { lockId });
return;
}
console.log(`[PortLockManager] Releasing locks for ports: ${lockInfo.ports.join(', ')}`);
log.info('portlock', 'Releasing locks', { lockId, ports: lockInfo.ports });
const errors = [];
@@ -133,16 +135,16 @@ class PortLockManager {
await release();
} catch (error) {
errors.push(error.message);
console.error(`[PortLockManager] Error releasing lock:`, error.message);
log.error('portlock', error, { operation: 'release', lockId });
}
}
this.activeLocks.delete(lockId);
if (errors.length > 0) {
console.warn(`[PortLockManager] Released locks with ${errors.length} errors`);
log.warn('portlock', 'Released locks with errors', { lockId, errorCount: errors.length });
} else {
console.log(`[PortLockManager] Successfully released all locks (ID: ${lockId})`);
log.info('portlock', 'Released all locks', { lockId });
}
}
@@ -151,7 +153,7 @@ class PortLockManager {
* Removes locks older than LOCK_STALE_THRESHOLD
*/
async cleanupStaleLocks() {
console.log('[PortLockManager] Cleaning up stale locks...');
log.info('portlock', 'Cleaning up stale locks');
this.ensureLockDirectory();
@@ -174,20 +176,20 @@ class PortLockManager {
// Lock is stale or not locked, safe to remove
fs.unlinkSync(lockFilePath);
cleaned++;
console.log(`[PortLockManager] Removed stale lock: ${file}`);
log.info('portlock', 'Removed stale lock', { file });
}
} catch (error) {
// File might not exist or might have been removed by another process
if (error.code !== 'ENOENT') {
errors++;
console.warn(`[PortLockManager] Error checking lock ${file}:`, error.message);
log.warn('portlock', 'Error checking lock', { file, error: error.message });
}
}
}
console.log(`[PortLockManager] Cleanup complete: ${cleaned} stale locks removed, ${errors} errors`);
log.info('portlock', 'Cleanup complete', { cleaned, errors });
} catch (error) {
console.error('[PortLockManager] Error during cleanup:', error.message);
log.error('portlock', error, { operation: 'cleanup' });
}
}
+33 -32
View File
@@ -9,6 +9,7 @@ const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
const docker = new Docker();
@@ -59,17 +60,17 @@ class ResourceMonitor extends EventEmitter {
*/
start() {
if (this.monitoring) {
console.log('[ResourceMonitor] Already monitoring');
log.info('monitor', 'Already monitoring');
return;
}
console.log('[ResourceMonitor] Starting container monitoring');
log.info('monitor', 'Starting container monitoring');
this.monitoring = true;
this.monitoringInterval = setInterval(() => this.collectStats(), MONITORING_INTERVAL);
// Hourly rollup — fires once an hour, computes the previous full hour
this.hourlyRollupTimer = setInterval(() => {
try { this.rollupHourly(); } catch (e) { console.error('[ResourceMonitor] hourly rollup error:', e.message); }
try { this.rollupHourly(); } catch (e) { log.error('monitor', e, { rollup: 'hourly' }); }
}, ROLLUP_HOURLY_INTERVAL);
// Daily rollup — schedule first run at the next midnight, then fire every 24h
@@ -77,9 +78,9 @@ class ResourceMonitor extends EventEmitter {
const nextMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1, 0, 0, 5);
const msUntilMidnight = nextMidnight.getTime() - now.getTime();
setTimeout(() => {
try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); }
try { this.rollupDaily(); } catch (e) { log.error('monitor', e, { rollup: 'daily' }); }
this.dailyRollupTimer = setInterval(() => {
try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); }
try { this.rollupDaily(); } catch (e) { log.error('monitor', e, { rollup: 'daily' }); }
}, ROLLUP_DAILY_INTERVAL);
}, msUntilMidnight);
@@ -93,7 +94,7 @@ class ResourceMonitor extends EventEmitter {
stop() {
if (!this.monitoring) return;
console.log('[ResourceMonitor] Stopping container monitoring');
log.info('monitor', 'Stopping container monitoring');
this.monitoring = false;
if (this.monitoringInterval) {
@@ -131,7 +132,7 @@ class ResourceMonitor extends EventEmitter {
this.checkAlerts(containerInfo.Id, containerInfo.Names[0], stats);
}
} catch (error) {
console.error(`[ResourceMonitor] Error collecting stats for ${containerInfo.Names[0]}:`, error.message);
log.error('monitor', error, { container: containerInfo.Names[0] });
}
}
@@ -143,7 +144,7 @@ class ResourceMonitor extends EventEmitter {
this.saveStats();
}
} catch (error) {
console.error('[ResourceMonitor] Error collecting container stats:', error.message);
log.error('monitor', error, { phase: 'collectStats' });
}
}
@@ -329,7 +330,7 @@ class ResourceMonitor extends EventEmitter {
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.sendAlert(alertPayload).catch(err => {
console.error('[ResourceMonitor] Failed to send alert notification:', err.message);
log.error('monitor', err, { phase: 'sendAlert' });
});
}
@@ -357,7 +358,7 @@ class ResourceMonitor extends EventEmitter {
*/
async restartContainer(containerId, containerName, alerts) {
try {
console.log(`[ResourceMonitor] Auto-restarting ${containerName} due to alerts:`, alerts.map(a => a.type).join(', '));
log.info('monitor', 'Auto-restarting container', { container: containerName, alerts: alerts.map(a => a.type) });
const container = docker.getContainer(containerId);
await container.restart();
@@ -377,11 +378,11 @@ class ResourceMonitor extends EventEmitter {
timestamp: new Date().toISOString(),
reason: alerts
}).catch(err => {
console.error('[ResourceMonitor] Failed to send auto-restart notification:', err.message);
log.error('monitor', err, { phase: 'sendAutoRestart' });
});
}
} catch (error) {
console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message);
log.error('monitor', error, { container: containerName, phase: 'restart' });
}
}
@@ -390,7 +391,7 @@ class ResourceMonitor extends EventEmitter {
*/
triggerWorkflows(eventType, eventData) {
if (!this.workflowEngine) {
console.log('[ResourceMonitor] Workflow engine not set, skipping workflow trigger');
log.info('monitor', 'Workflow engine not set, skipping workflow trigger');
return;
}
@@ -398,14 +399,14 @@ class ResourceMonitor extends EventEmitter {
this.workflowEngine.triggerForEvent(eventType, eventData)
.then(results => {
if (results && results.length > 0) {
console.log(`[ResourceMonitor] Triggered ${results.length} workflow(s) for ${eventType}`);
log.info('monitor', `Triggered workflows for ${eventType}`, { count: results.length });
}
})
.catch(err => {
console.error('[ResourceMonitor] Workflow trigger error:', err.message);
log.error('monitor', err, { phase: 'workflowTrigger' });
});
} catch (error) {
console.error('[ResourceMonitor] Error triggering workflows:', error.message);
log.error('monitor', error, { phase: 'workflowTrigger' });
}
}
@@ -414,7 +415,7 @@ class ResourceMonitor extends EventEmitter {
*/
setWorkflowEngine(workflowEngine) {
this.workflowEngine = workflowEngine;
console.log('[ResourceMonitor] Workflow engine configured');
log.info('monitor', 'Workflow engine configured');
}
/**
@@ -562,10 +563,10 @@ class ResourceMonitor extends EventEmitter {
if (fs.existsSync(ALERT_HISTORY_FILE)) {
const data = JSON.parse(fs.readFileSync(ALERT_HISTORY_FILE, 'utf8'));
this.alertHistory = Array.isArray(data) ? data : [];
console.log(`[ResourceMonitor] Loaded ${this.alertHistory.length} alert history entries`);
log.info('monitor', 'Loaded alert history', { count: this.alertHistory.length });
}
} catch (error) {
console.error('[ResourceMonitor] Error loading alert history:', error.message);
log.error('monitor', error, { operation: 'loadAlertHistory' });
}
}
@@ -576,7 +577,7 @@ class ResourceMonitor extends EventEmitter {
try {
fs.writeFileSync(ALERT_HISTORY_FILE, JSON.stringify(this.alertHistory, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving alert history:', error.message);
log.error('monitor', error, { operation: 'saveAlertHistory' });
}
}
@@ -606,10 +607,10 @@ class ResourceMonitor extends EventEmitter {
if (fs.existsSync(STATS_FILE)) {
const data = JSON.parse(fs.readFileSync(STATS_FILE, 'utf8'));
this.stats = new Map(Object.entries(data));
console.log(`[ResourceMonitor] Loaded stats for ${this.stats.size} containers`);
log.info('monitor', 'Loaded stats', { containerCount: this.stats.size });
}
} catch (error) {
console.error('[ResourceMonitor] Error loading stats:', error.message);
log.error('monitor', error, { operation: 'loadStats' });
}
}
@@ -621,7 +622,7 @@ class ResourceMonitor extends EventEmitter {
const data = Object.fromEntries(this.stats);
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving stats:', error.message);
log.error('monitor', error, { operation: 'saveStats' });
}
}
@@ -633,10 +634,10 @@ class ResourceMonitor extends EventEmitter {
if (fs.existsSync(ALERT_CONFIG_FILE)) {
const data = JSON.parse(fs.readFileSync(ALERT_CONFIG_FILE, 'utf8'));
this.alerts = new Map(Object.entries(data));
console.log(`[ResourceMonitor] Loaded alert config for ${this.alerts.size} containers`);
log.info('monitor', 'Loaded alert config', { containerCount: this.alerts.size });
}
} catch (error) {
console.error('[ResourceMonitor] Error loading alert config:', error.message);
log.error('monitor', error, { operation: 'loadAlertConfig' });
}
}
@@ -648,7 +649,7 @@ class ResourceMonitor extends EventEmitter {
const data = Object.fromEntries(this.alerts);
fs.writeFileSync(ALERT_CONFIG_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving alert config:', error.message);
log.error('monitor', error, { operation: 'saveAlertConfig' });
}
}
@@ -902,10 +903,10 @@ class ResourceMonitor extends EventEmitter {
if (fs.existsSync(STATS_HOURLY_FILE)) {
const data = JSON.parse(fs.readFileSync(STATS_HOURLY_FILE, 'utf8'));
this.hourlyHistory = new Map(Object.entries(data));
console.log(`[ResourceMonitor] Loaded hourly rollups for ${this.hourlyHistory.size} containers`);
log.info('monitor', 'Loaded hourly rollups', { containerCount: this.hourlyHistory.size });
}
} catch (error) {
console.error('[ResourceMonitor] Error loading hourly stats:', error.message);
log.error('monitor', error, { operation: 'loadHourlyStats' });
}
}
@@ -917,7 +918,7 @@ class ResourceMonitor extends EventEmitter {
const data = Object.fromEntries(this.hourlyHistory);
fs.writeFileSync(STATS_HOURLY_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving hourly stats:', error.message);
log.error('monitor', error, { operation: 'saveHourlyStats' });
}
}
@@ -929,10 +930,10 @@ class ResourceMonitor extends EventEmitter {
if (fs.existsSync(STATS_DAILY_FILE)) {
const data = JSON.parse(fs.readFileSync(STATS_DAILY_FILE, 'utf8'));
this.dailyHistory = new Map(Object.entries(data));
console.log(`[ResourceMonitor] Loaded daily rollups for ${this.dailyHistory.size} containers`);
log.info('monitor', 'Loaded daily rollups', { containerCount: this.dailyHistory.size });
}
} catch (error) {
console.error('[ResourceMonitor] Error loading daily stats:', error.message);
log.error('monitor', error, { operation: 'loadDailyStats' });
}
}
@@ -944,7 +945,7 @@ class ResourceMonitor extends EventEmitter {
const data = Object.fromEntries(this.dailyHistory);
fs.writeFileSync(STATS_DAILY_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving daily stats:', error.message);
log.error('monitor', error, { operation: 'saveDailyStats' });
}
}
@@ -0,0 +1,392 @@
/**
* Disk Space Monitor
*
* Tracks Docker + system disk usage against a user-configured budget.
* When usage exceeds thresholds, triggers automatic cleanup and notifications.
*
* Key concepts:
* - diskBudgetGB: How much disk the user is willing to give DashCaddy (default 10)
* - The monitor calculates Docker's footprint (images, volumes, containers, build cache)
* - Breakdown shows where space goes so users can make informed decisions
* - Auto-cleanup triggers at 80% (warning), 90% (aggressive), 95% (critical)
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const DEFAULT_BUDGET_GB = 10;
const DEFAULT_CONFIG = {
enabled: true,
diskBudgetGB: DEFAULT_BUDGET_GB,
warningThresholdPct: 80,
criticalThresholdPct: 90,
autoCleanup: true,
cleanupAggressivePct: 95,
};
class DiskSpaceMonitor extends EventEmitter {
constructor({ log, config }) {
super();
this.log = log;
this.config = config;
this.lastSnapshot = null;
this.lastCleanup = null;
this.intervalHandle = null;
this.diskConfig = { ...DEFAULT_CONFIG };
this._loadConfig();
}
/**
* Load disk budget config from the site config file
* Stored under `diskSpace` key in config.json
*/
_loadConfig() {
try {
const raw = this.config?.diskSpace;
if (raw) {
this.diskConfig = {
...DEFAULT_CONFIG,
...raw,
};
}
} catch {
// Use defaults
}
}
/**
* Update disk space settings
*/
configure(updates) {
const prev = { ...this.diskConfig };
this.diskConfig = { ...this.diskConfig, ...updates };
this._persistConfig();
this.emit('config-changed', { prev, current: this.diskConfig });
return this.diskConfig;
}
_persistConfig() {
// The config is persisted by the caller (settings route) which merges
// into config.json. We just expose the current state.
if (this.config) {
this.config.diskSpace = this.diskConfig;
}
}
/**
* Get a disk usage snapshot using `df` and `docker system df -v`
*/
async getSnapshot() {
const [diskInfo, dockerInfo] = await Promise.all([
this._getDiskInfo(),
this._getDockerInfo(),
]);
const snapshot = {
timestamp: new Date().toISOString(),
system: diskInfo,
docker: dockerInfo,
budget: {
configuredGB: this.diskConfig.diskBudgetGB,
dockerUsageGB: dockerInfo.totalGB,
remainingBudgetGB: Math.max(0, this.diskConfig.diskBudgetGB - dockerInfo.totalGB),
budgetUsedPct: Math.min(100, Math.round((dockerInfo.totalGB / this.diskConfig.diskBudgetGB) * 100)),
status: this._getBudgetStatus(dockerInfo.totalGB),
},
config: { ...this.diskConfig },
lastCleanup: this.lastCleanup,
};
this.lastSnapshot = snapshot;
// Check thresholds and emit events
this._checkThresholds(snapshot);
return snapshot;
}
_getBudgetStatus(dockerUsageGB) {
const pct = (dockerUsageGB / this.diskConfig.diskBudgetGB) * 100;
if (pct >= this.diskConfig.cleanupAggressivePct) return 'critical';
if (pct >= this.diskConfig.criticalThresholdPct) return 'aggressive';
if (pct >= this.diskConfig.warningThresholdPct) return 'warning';
return 'healthy';
}
_checkThresholds(snapshot) {
const { status, budgetUsedPct } = snapshot.budget;
if (status === 'critical' || status === 'aggressive') {
this.emit('budget-exceeded', snapshot);
if (this.diskConfig.autoCleanup) {
this.performCleanup(status === 'critical' ? 'aggressive' : 'standard').catch(() => {});
}
} else if (status === 'warning') {
this.emit('budget-warning', snapshot);
}
}
async _getDiskInfo() {
try {
const { stdout } = await execFileAsync('df', ['-B1', '/']);
const lines = stdout.trim().split('\n');
const parts = lines[1].split(/\s+/);
return {
totalBytes: parseInt(parts[1], 10),
usedBytes: parseInt(parts[2], 10),
availableBytes: parseInt(parts[3], 10),
usedPct: parseInt(parts[4], 10),
mount: parts[5],
totalGB: Math.round(parseInt(parts[1], 10) / 1073741824 * 10) / 10,
usedGB: Math.round(parseInt(parts[2], 10) / 1073741824 * 10) / 10,
availableGB: Math.round(parseInt(parts[3], 10) / 1073741824 * 10) / 10,
};
} catch {
return { totalBytes: 0, usedBytes: 0, availableBytes: 0, usedPct: 0, totalGB: 0, usedGB: 0, availableGB: 0 };
}
}
async _getDockerInfo() {
try {
const { stdout } = await execFileAsync('docker', ['system', 'df', '--format', '{{json .}}']);
const lines = stdout.trim().split('\n').filter(Boolean);
let images = { count: 0, totalGB: 0, reclaimableGB: 0 };
let containers = { count: 0, totalGB: 0, reclaimableGB: 0 };
let volumes = { count: 0, totalGB: 0, reclaimableGB: 0 };
let buildCache = { count: 0, totalGB: 0, reclaimableGB: 0 };
for (const line of lines) {
try {
const d = JSON.parse(line);
const type = d.Type?.toLowerCase() || '';
const sizeGB = this._parseSizeToGB(d.Size);
const reclaimGB = this._parseSizeToGB(d.Reclaimable);
if (type === 'images') images = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
else if (type === 'containers') containers = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
else if (type === 'local volumes') volumes = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
else if (type === 'build cache') buildCache = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
} catch { /* skip unparseable lines */ }
}
const totalGB = Math.round((images.totalGB + containers.totalGB + volumes.totalGB + buildCache.totalGB) * 100) / 100;
const reclaimableGB = Math.round((images.reclaimableGB + containers.reclaimableGB + volumes.reclaimableGB + buildCache.reclaimableGB) * 100) / 100;
return {
images,
containers,
volumes,
buildCache,
totalGB,
reclaimableGB,
};
} catch {
return { images: {}, containers: {}, volumes: {}, buildCache: {}, totalGB: 0, reclaimableGB: 0 };
}
}
/**
* Parse Docker's human-readable size strings (e.g., "2.519GB", "8.108MB", "0B")
*/
_parseSizeToGB(str) {
if (!str || str === '0B') return 0;
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers = { B: 1e-9, KB: 1e-6, MB: 1e-3, GB: 1, TB: 1e3 };
return Math.round(value * (multipliers[unit] || 0) * 1000) / 1000;
}
/**
* Get per-container log file sizes (the hidden disk hog)
*/
async _getContainerLogs() {
try {
const { stdout } = await execFileAsync('sh', ['-c', 'for f in /var/lib/docker/containers/*/*-json.log; do [ -f "$f" ] && stat -c "%s %n" "$f"; done 2>/dev/null | sort -rn | head -10']);
const entries = [];
for (const line of stdout.trim().split('\n').filter(Boolean)) {
const [sizeStr, ...fileParts] = line.split(' ');
const sizeBytes = parseInt(sizeStr, 10);
entries.push({
sizeBytes,
sizeMB: Math.round(sizeBytes / 1048576 * 10) / 10,
file: fileParts.join(' '),
});
}
return entries;
} catch {
return [];
}
}
/**
* Perform cleanup
* @param {string} level - 'standard' | 'aggressive' | 'logs-only'
* @returns {Object} cleanup result with bytes reclaimed
*/
async performCleanup(level = 'standard') {
const startTime = Date.now();
const result = {
level,
startedAt: new Date(startTime).toISOString(),
actions: [],
bytesReclaimed: 0,
};
try {
// Always: truncate oversized container logs
const logsBefore = await this._getContainerLogs();
let logBytesFreed = 0;
for (const log of logsBefore) {
if (log.sizeBytes > 100 * 1048576) { // > 100MB
try {
await execFileAsync('truncate', ['-s', '0', log.file]);
logBytesFreed += log.sizeBytes;
result.actions.push({ action: 'truncate-log', file: log.file, freedBytes: log.sizeBytes });
} catch { /* skip */ }
}
}
result.bytesReclaimed += logBytesFreed;
// Always: vacuum journald to 200MB
try {
const { stdout } = await execFileAsync('journalctl', ['--vacuum-size=200M']);
const freedMatch = stdout.match(/freed ([\d.]+[KMGT]?B)/i);
if (freedMatch) {
const freedBytes = this._humanToBytes(freedMatch[1]);
result.bytesReclaimed += freedBytes;
result.actions.push({ action: 'vacuum-journal', freedBytes, freedHuman: freedMatch[1] });
}
} catch { /* skip */ }
if (level === 'standard' || level === 'aggressive') {
// Prune dangling images
try {
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-f', '--filter', 'dangling=true']);
const reclaimed = this._extractDockerReclaimed(stdout);
result.bytesReclaimed += reclaimed;
result.actions.push({ action: 'prune-dangling-images', freedBytes: reclaimed });
} catch { /* skip */ }
// Prune unused volumes
try {
const { stdout } = await execFileAsync('docker', ['volume', 'prune', '-f']);
const reclaimed = this._extractDockerReclaimed(stdout);
result.bytesReclaimed += reclaimed;
result.actions.push({ action: 'prune-unused-volumes', freedBytes: reclaimed });
} catch { /* skip */ }
// Prune build cache (keep last 500MB)
try {
const { stdout } = await execFileAsync('docker', ['builder', 'prune', '-f', '--keep-storage', '500m']);
const reclaimed = this._extractDockerReclaimed(stdout);
result.bytesReclaimed += reclaimed;
result.actions.push({ action: 'prune-build-cache', freedBytes: reclaimed });
} catch { /* skip */ }
}
if (level === 'aggressive') {
// Remove ALL images not used by running containers
try {
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-a', '-f']);
const reclaimed = this._extractDockerReclaimed(stdout);
result.bytesReclaimed += reclaimed;
result.actions.push({ action: 'prune-all-unused-images', freedBytes: reclaimed });
} catch { /* skip */ }
// Prune stopped containers older than 24h
try {
const { stdout } = await execFileAsync('docker', ['container', 'prune', '-f', '--filter', 'until=24h']);
const reclaimed = this._extractDockerReclaimed(stdout);
result.bytesReclaimed += reclaimed;
result.actions.push({ action: 'prune-old-containers', freedBytes: reclaimed });
} catch { /* skip */ }
}
result.completedAt = new Date().toISOString();
result.durationMs = Date.now() - startTime;
result.bytesReclaimedGB = Math.round(result.bytesReclaimed / 1073741824 * 100) / 100;
this.lastCleanup = result;
this.emit('cleanup-complete', result);
if (this.log) {
this.log.info('disk', 'Disk cleanup completed', {
level,
bytesReclaimed: result.bytesReclaimed,
GBReclaimed: result.bytesReclaimedGB,
durationMs: result.durationMs,
actions: result.actions.length,
});
}
return result;
} catch (err) {
result.error = err.message;
result.completedAt = new Date().toISOString();
if (this.log) {
this.log.error('disk', 'Disk cleanup failed', { error: err.message, level });
}
return result;
}
}
_humanToBytes(str) {
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = match[2].toUpperCase();
const multipliers = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
return Math.round(value * (multipliers[unit] || 0));
}
_extractDockerReclaimed(stdout) {
const match = stdout.match(/reclaimed\s+([\d.]+[KMGT]?B)/i) || stdout.match(/Total reclaimed space:\s*([\d.]+[KMGT]?B)/i);
if (match) return this._humanToBytes(match[1]);
return 0;
}
/**
* Start periodic monitoring
* @param {number} intervalMs - check interval (default 10 minutes)
*/
start(intervalMs = 600000) {
if (this.intervalHandle) return;
this.log?.info?.('disk', 'Disk space monitor started', { intervalMs });
// Initial check
this.getSnapshot().catch(() => {});
this.intervalHandle = setInterval(() => {
this.getSnapshot().catch(() => {});
}, intervalMs);
}
stop() {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
}
getConfig() {
return { ...this.diskConfig };
}
async getDetailedBreakdown() {
const [snapshot, containerLogs] = await Promise.all([
this.getSnapshot(),
this._getContainerLogs(),
]);
return {
...snapshot,
containerLogs,
};
}
}
module.exports = { DiskSpaceMonitor, DEFAULT_DISK_CONFIG: DEFAULT_CONFIG };
@@ -6,6 +6,7 @@
const https = require('https');
const http = require('http');
const crypto = require('crypto');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
@@ -349,7 +350,7 @@ class HealthChecker extends EventEmitter {
// Create new incident
const incident = {
id: `incident-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
id: `incident-${crypto.randomUUID()}`,
serviceId,
type,
message,
+50
View File
@@ -110,6 +110,56 @@ class Metrics {
this.requests = { total: 0, byStatus: {}, byMethod: {}, byPath: {} };
this.errors = { total: 0, byType: {} };
}
/**
* DC-097: Prometheus text-format export for /metrics/prometheus
* Returns standard Prometheus exposition format text.
*/
toPrometheus() {
const uptimeSec = Math.floor((Date.now() - this.startTime) / 1000);
const mem = process.memoryUsage();
const lines = [];
lines.push('# HELP dashcaddy_uptime_seconds Server uptime in seconds');
lines.push('# TYPE dashcaddy_uptime_seconds counter');
lines.push(`dashcaddy_uptime_seconds ${uptimeSec}`);
lines.push('# HELP dashcaddy_requests_total Total HTTP requests');
lines.push('# TYPE dashcaddy_requests_total counter');
lines.push(`dashcaddy_requests_total ${this.requests.total}`);
for (const [status, count] of Object.entries(this.requests.byStatus || {})) {
lines.push(`dashcaddy_requests_by_status{status="${status}"} ${count}`);
}
for (const [method, count] of Object.entries(this.requests.byMethod || {})) {
lines.push(`dashcaddy_requests_by_method{method="${method}"} ${count}`);
}
lines.push('# HELP dashcaddy_errors_total Total errors');
lines.push('# TYPE dashcaddy_errors_total counter');
lines.push(`dashcaddy_errors_total ${this.errors.total}`);
lines.push('# HELP dashcaddy_containers_deployed Total containers deployed');
lines.push('# TYPE dashcaddy_containers_deployed counter');
lines.push(`dashcaddy_containers_deployed ${this.business.containersDeployed}`);
lines.push('# HELP dashcaddy_process_memory_heap_used_bytes Heap memory used');
lines.push('# TYPE dashcaddy_process_memory_heap_used_bytes gauge');
lines.push(`dashcaddy_process_memory_heap_used_bytes ${mem.heapUsed}`);
lines.push('# HELP dashcaddy_process_memory_heap_total_bytes Heap memory allocated');
lines.push('# TYPE dashcaddy_process_memory_heap_total_bytes gauge');
lines.push(`dashcaddy_process_memory_heap_total_bytes ${mem.heapTotal}`);
lines.push('# HELP dashcaddy_business_metric Business metrics');
lines.push('# TYPE dashcaddy_business_metric counter');
for (const [key, val] of Object.entries(this.business)) {
lines.push(`dashcaddy_business_metric{metric="${key}"} ${val}`);
}
return lines.join('\n') + '\n';
}
}
module.exports = new Metrics();
+243
View File
@@ -0,0 +1,243 @@
/**
* DC-080: Plugin/Extension system for DashCaddy
*
* Allows third-party extensions to register:
* - Custom service types with health-check logic
* - Custom notification providers
* - Custom workflow actions
* - Dashboard widgets (via manifest)
*
* Plugins are loaded from the data directory:
* {dataDir}/plugins/{plugin-name}/manifest.json
* {dataDir}/plugins/{plugin-name}/index.js
*
* The manifest.json describes capabilities and permissions.
* The index.js exports hooks that DashCaddy calls at appropriate times.
*
* Security: plugins run in the same process (no sandbox yet). The manifest
* declares required permissions, and the admin must approve on install.
*/
const fs = require('fs');
const path = require('path');
const EventEmitter = require('events');
const PLUGIN_DIR = process.env.PLUGIN_DIR || path.join(process.cwd(), 'data', 'plugins');
const HOOK_TYPES = [
'service:health-check', // Custom health check for a service type
'notification:provider', // Custom notification provider
'workflow:action', // Custom workflow action type
'dashboard:widget', // Custom dashboard widget manifest
'container:pre-deploy', // Hook before container deployment
'container:post-deploy', // Hook after container deployment
'config:validate', // Hook for config validation
];
class PluginManager extends EventEmitter {
constructor({ dataDir, log }) {
super();
this.pluginDir = dataDir ? path.join(dataDir, 'plugins') : PLUGIN_DIR;
this.log = log || console;
this.plugins = new Map(); // name → { manifest, module, hooks }
this.serviceTypes = new Map(); // typeName → pluginName
this.notificationProviders = new Map();
this.workflowActions = new Map();
this.dashboardWidgets = new Map();
this.loaded = false;
}
/**
* Discover and load all plugins from the plugin directory.
*/
async loadAll() {
if (this.loaded) return;
try {
if (!fs.existsSync(this.pluginDir)) {
fs.mkdirSync(this.pluginDir, { recursive: true });
this.log.info('plugins', 'Plugin directory created', { dir: this.pluginDir });
this.loaded = true;
return;
}
const entries = fs.readdirSync(this.pluginDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith('.')) continue;
try {
await this.loadOne(path.join(this.pluginDir, entry.name));
} catch (err) {
this.log.error('plugins', `Failed to load plugin: ${entry.name}`, { error: err.message });
}
}
this.loaded = true;
this.log.info('plugins', 'All plugins loaded', {
count: this.plugins.size,
serviceTypes: [...this.serviceTypes.keys()],
notificationProviders: [...this.notificationProviders.keys()],
workflowActions: [...this.workflowActions.keys()],
});
} catch (err) {
this.log.error('plugins', 'Failed to scan plugin directory', { error: err.message });
this.loaded = true; // Don't crash — just run without plugins
}
}
/**
* Load a single plugin from its directory.
*/
async loadOne(pluginPath) {
const manifestPath = path.join(pluginPath, 'manifest.json');
const indexPath = path.join(pluginPath, 'index.js');
if (!fs.existsSync(manifestPath)) {
throw new Error('manifest.json not found');
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
// Validate manifest
if (!manifest.name || !manifest.version) {
throw new Error('manifest.json must have name and version');
}
if (this.plugins.has(manifest.name)) {
throw new Error(`Plugin ${manifest.name} already loaded`);
}
// Load the plugin module if it exists
let module = {};
if (fs.existsSync(indexPath)) {
delete require.cache[require.resolve(indexPath)];
module = require(indexPath);
}
// Register hooks
const hooks = {};
if (module.hooks) {
for (const [hookType, fn] of Object.entries(module.hooks)) {
if (HOOK_TYPES.includes(hookType)) {
hooks[hookType] = fn;
this._registerHook(manifest.name, hookType, fn, manifest);
}
}
}
this.plugins.set(manifest.name, { manifest, module, hooks, path: pluginPath });
this.emit('plugin-loaded', manifest);
this.log.info('plugins', `Loaded plugin: ${manifest.name} v${manifest.version}`, {
hooks: Object.keys(hooks),
});
}
_registerHook(pluginName, hookType, fn, manifest) {
switch (hookType) {
case 'service:health-check':
if (manifest.serviceType) {
this.serviceTypes.set(manifest.serviceType, pluginName);
}
break;
case 'notification:provider':
if (manifest.providerName) {
this.notificationProviders.set(manifest.providerName, { pluginName, fn });
}
break;
case 'workflow:action':
if (manifest.actionType) {
this.workflowActions.set(manifest.actionType, { pluginName, fn });
}
break;
case 'dashboard:widget':
if (manifest.widget) {
this.dashboardWidgets.set(manifest.name, { pluginName, manifest: manifest.widget });
}
break;
}
}
/**
* Unload a plugin by name.
*/
unload(name) {
const plugin = this.plugins.get(name);
if (!plugin) return false;
// Clean up registrations
for (const [type, pName] of this.serviceTypes) {
if (pName === name) this.serviceTypes.delete(type);
}
for (const [type, { pluginName }] of this.notificationProviders) {
if (pluginName === name) this.notificationProviders.delete(type);
}
for (const [type, { pluginName }] of this.workflowActions) {
if (pluginName === name) this.workflowActions.delete(type);
}
for (const [wName, { pluginName }] of this.dashboardWidgets) {
if (pluginName === name) this.dashboardWidgets.delete(wName);
}
this.plugins.delete(name);
this.emit('plugin-unloaded', name);
this.log.info('plugins', `Unloaded plugin: ${name}`);
return true;
}
/**
* Execute a plugin hook for a specific type.
*/
async executeHook(hookType, ...args) {
// Try each plugin that registered this hook
const results = [];
for (const [name, plugin] of this.plugins) {
if (plugin.hooks[hookType]) {
try {
const result = await plugin.hooks[hookType](...args);
results.push({ plugin: name, result });
} catch (err) {
this.log.error('plugins', `Hook ${hookType} failed in ${name}`, { error: err.message });
results.push({ plugin: name, error: err.message });
}
}
}
return results;
}
/**
* Get list of loaded plugins with their manifests.
*/
list() {
return [...this.plugins.values()].map(p => ({
name: p.manifest.name,
version: p.manifest.version,
description: p.manifest.description || '',
hooks: Object.keys(p.hooks),
permissions: p.manifest.permissions || [],
}));
}
/**
* Get dashboard widget manifests from plugins.
*/
getWidgets() {
return [...this.dashboardWidgets.values()].map(w => w.manifest);
}
/**
* Get registered service types.
*/
getServiceTypes() {
return [...this.serviceTypes.keys()];
}
/**
* Get registered workflow action types.
*/
getWorkflowActions() {
return [...this.workflowActions.keys()];
}
}
module.exports = { PluginManager, HOOK_TYPES };
+47 -29
View File
@@ -8,6 +8,7 @@
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const { log } = require('../utils/logging');
const platformPaths = require('../../platform-paths');
const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(platformPaths.dataDir, 'workflows-config.json');
@@ -102,7 +103,7 @@ class WorkflowEngine extends EventEmitter {
this.enabled = new Map(Object.entries(data.enabled || {}));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading config:', error.message);
log.error('workflow', error, { operation: 'loadConfig' });
}
// Default all workflows to enabled if not explicitly set
@@ -123,7 +124,7 @@ class WorkflowEngine extends EventEmitter {
};
fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving config:', error.message);
log.error('workflow', error, { operation: 'saveConfig' });
}
}
@@ -136,7 +137,7 @@ class WorkflowEngine extends EventEmitter {
this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading history:', error.message);
log.error('workflow', error, { operation: 'loadHistory' });
this.history = [];
}
}
@@ -148,7 +149,7 @@ class WorkflowEngine extends EventEmitter {
try {
fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving history:', error.message);
log.error('workflow', error, { operation: 'saveHistory' });
}
}
@@ -174,11 +175,11 @@ class WorkflowEngine extends EventEmitter {
const job = setInterval(() => {
this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() })
.catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message));
.catch(err => log.error('workflow', err, { workflowId, phase: 'scheduled' }));
}, workflow.interval);
this.scheduledJobs.set(workflowId, job);
console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`);
log.info('workflow', 'Scheduled workflow', { workflowId, intervalMs: workflow.interval });
}
/**
@@ -201,14 +202,14 @@ class WorkflowEngine extends EventEmitter {
}
if (!this.enabled.get(workflowId)) {
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
log.info('workflow', 'Workflow disabled, skipping', { workflowId });
return { skipped: true, reason: 'disabled' };
}
const executionId = `${workflowId}-${Date.now()}`;
const startTime = Date.now();
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
log.info('workflow', 'Executing workflow', { workflowId });
this.emit('workflow-start', { workflowId, executionId, triggerData });
const results = await this._runActions(workflow.actions, triggerData);
@@ -237,7 +238,7 @@ class WorkflowEngine extends EventEmitter {
this.saveHistory();
this.emit('workflow-complete', historyEntry);
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
log.info('workflow', 'Workflow completed', { workflowId, durationMs: duration, success: allSucceeded });
return historyEntry;
}
@@ -251,32 +252,49 @@ class WorkflowEngine extends EventEmitter {
*/
async _runActions(actions, triggerData = {}) {
const results = [];
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
for (let i = 0; i < actions.length; i++) {
const action = actions[i];
const previousResult = i > 0 ? results[i - 1] : null;
// notify-on-failure needs to see the previous action's outcome to decide
// whether to fire. Passing the full results array in the trigger data lets
// executeAction do that lookup without changing the action shape.
// Also surface failingServices (set by healthCheckService on throw) so
// template variables like {{failingServices}} can interpolate.
const actionContext = {
...triggerData,
previousResult,
failingServices: previousResult && previousResult.failingServices ? previousResult.failingServices : undefined,
};
try {
const result = await this.executeAction(action, actionContext);
// DC-093: Retry with exponential backoff for transient failures
let lastError = null;
let result = null;
let succeeded = false;
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
try {
result = await this.executeAction(action, actionContext);
succeeded = true;
break;
} catch (error) {
lastError = error;
if (attempt < MAX_RETRIES) {
const delay = RETRY_DELAY_MS * Math.pow(2, attempt);
log.warn('workflow', `Action "${action.type}" failed (attempt ${attempt + 1}/${MAX_RETRIES + 1}), retrying in ${delay}ms`, { error: error.message });
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
if (succeeded) {
results.push({ action: action.type, success: true, result });
} catch (error) {
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
} else {
log.error('workflow', `Action "${action.type}" failed after ${MAX_RETRIES + 1} attempts`, { error: lastError.message });
results.push({
action: action.type,
success: false,
error: error.message,
failingServices: error.failingServices,
error: lastError.message,
failingServices: lastError.failingServices,
exhaustedRetries: MAX_RETRIES + 1,
});
// Continue with other actions but log failure
}
}
@@ -322,7 +340,7 @@ class WorkflowEngine extends EventEmitter {
return this.collectMetrics(context.containerId, action.period);
default:
console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`);
log.warn('workflow', 'Unknown action type', { actionType: action.type });
return { skipped: true, reason: `Unknown action type: ${action.type}` };
}
}
@@ -428,7 +446,7 @@ class WorkflowEngine extends EventEmitter {
throw new Error('Container ID not provided');
}
console.log(`[WorkflowEngine] Restarting container: ${containerId}`);
log.info('workflow', 'Restarting container', { containerId });
const container = docker.getContainer(containerId);
await container.restart();
@@ -448,7 +466,7 @@ class WorkflowEngine extends EventEmitter {
throw new Error('App ID not provided');
}
console.log(`[WorkflowEngine] Creating backup for: ${appId}`);
log.info('workflow', 'Creating backup', { appId });
// Use backup manager's executeBackup if available
const backupName = `${appId}-${label}`;
@@ -477,11 +495,11 @@ class WorkflowEngine extends EventEmitter {
async notify(message, channel) {
const notification = this.ctx.notification;
if (!notification) {
console.warn('[WorkflowEngine] Notification manager not available');
log.warn('workflow', 'Notification manager not available');
return { notified: false, reason: 'no notification manager' };
}
console.log(`[WorkflowEngine] Sending notification: ${message}`);
log.info('workflow', 'Sending notification', { message });
notification.send('workflow', 'Workflow Notification', message, 'info');
return { notified: true, message };
@@ -548,7 +566,7 @@ class WorkflowEngine extends EventEmitter {
}
}
console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`);
log.info('workflow', 'Workflow toggled', { workflowId, enabled });
return { workflowId, enabled };
}
@@ -581,7 +599,7 @@ class WorkflowEngine extends EventEmitter {
const conditionMet = this.evaluateCondition(workflow.condition, eventData);
return conditionMet;
} catch (e) {
console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message);
log.warn('workflow', 'Condition evaluation failed', { workflowId: id, error: e.message });
return false;
}
}
@@ -641,7 +659,7 @@ class WorkflowEngine extends EventEmitter {
for (const [workflowId] of this.scheduledJobs) {
this.stopScheduledWorkflow(workflowId);
}
console.log('[WorkflowEngine] All scheduled workflows stopped');
log.info('workflow', 'All scheduled workflows stopped');
}
}
+3 -3
View File
@@ -184,10 +184,10 @@ class AuditLogger {
});
} catch (e) {
// Non-fatal — security store is a best-effort mirror
console.error('[AuditLogger] Security event emit failed:', e.message);
process.stderr.write(`[AuditLogger] Security event emit failed: ${e.message}\n`);
}
} catch (e) {
console.error('[AuditLogger] Failed to write entry:', e.message);
process.stderr.write(`[AuditLogger] Failed to write entry: ${e.message}\n`);
}
}
@@ -199,7 +199,7 @@ class AuditLogger {
}
return entries.slice(offset, offset + limit);
} catch (e) {
console.error('[AuditLogger] Failed to read:', e.message);
process.stderr.write(`[AuditLogger] Failed to read: ${e.message}\n`);
return [];
}
}
+17 -21
View File
@@ -8,6 +8,7 @@ const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
// Encryption settings
const ALGORITHM = 'aes-256-gcm';
@@ -65,7 +66,7 @@ function loadOrCreateKey() {
// 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');
log.info('crypto', 'Using encryption key from environment variable');
return encryptionKey;
}
@@ -75,16 +76,16 @@ function loadOrCreateKey() {
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');
log.info('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`);
log.info('crypto', 'Seeded .bak key file for future fallback');
} catch (e) {
console.warn('[Crypto] Could not seed .bak key file:', e.message);
log.warn('crypto', 'Could not seed .bak key file', { error: e.message });
}
}
// Try fallback to .bak key if primary can't decrypt existing credentials.
@@ -98,14 +99,14 @@ function loadOrCreateKey() {
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
}
} catch (e) {
console.warn('[Crypto] Could not check backup key:', e.message);
log.warn('crypto', 'Could not check backup key', { error: 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);
log.error('crypto', error, { operation: 'loadKey' });
}
}
@@ -115,10 +116,10 @@ function loadOrCreateKey() {
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');
log.info('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');
log.warn('crypto', 'Could not save key to file', { error: error.message });
log.warn('crypto', 'Key will be regenerated on restart - credentials will need to be re-entered');
}
return encryptionKey;
@@ -171,12 +172,7 @@ function tryFallbackToBackupKey(primaryKey, backupKey) {
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.'
);
log.warn('crypto', 'Primary encryption key failed to decrypt credentials; fell back to .encryption-key.bak. Consider rotating the key explicitly via the credential-manager API.');
return backupKey;
}
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
@@ -291,7 +287,7 @@ function decryptFields(obj, fields = null) {
try {
result[field] = decrypt(result[field]);
} catch (error) {
console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message);
log.error('crypto', error, { field, operation: 'decryptField' });
// Leave the field as-is if decryption fails
}
}
@@ -315,7 +311,7 @@ function migrateToEncrypted(credentials, sensitiveFields) {
return credentials; // Already encrypted
}
console.log('[Crypto] Migrating plaintext credentials to encrypted format');
log.info('crypto', 'Migrating plaintext credentials to encrypted format');
return encryptFields(credentials, sensitiveFields);
}
@@ -340,10 +336,10 @@ function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'ap
}
// Plain text data - migrate it
console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`);
log.info('crypto', 'Found plaintext data', { filePath });
return parsed;
} catch (error) {
console.error(`[Crypto] Error reading ${filePath}:`, error.message);
log.error('crypto', error, { filePath, operation: 'readFile' });
return null;
}
}
@@ -357,7 +353,7 @@ function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'ap
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}`);
log.info('crypto', 'Saved encrypted credentials', { filePath });
}
/**
@@ -377,7 +373,7 @@ function rotateKey() {
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);
log.warn('crypto', 'Could not save backup key', { error: error.message });
}
try {
@@ -216,14 +216,14 @@ function csrfValidationMiddleware(req, res, next) {
// Validate both values exist
if (!cookieNonce) {
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
process.stderr.write(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}\n`);
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}`);
process.stderr.write(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}\n`);
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.'
});
@@ -247,7 +247,7 @@ function csrfValidationMiddleware(req, res, next) {
next();
} catch (err) {
console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`);
process.stderr.write(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}\n`);
return errorResponse(res, 403, '[DC-101] CSRF token invalid', {
message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.'
});
+16 -15
View File
@@ -9,6 +9,7 @@ const path = require('path');
const https = require('https');
const Docker = require('dockerode');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
const docker = new Docker();
@@ -19,7 +20,7 @@ class DockerSecurity {
constructor() {
this.config = this.loadConfig();
this.mode = VERIFICATION_MODE;
console.log(`[DockerSecurity] Initialized in ${this.mode} mode`);
log.info('security', 'Docker security initialized', { mode: this.mode });
}
/**
@@ -32,7 +33,7 @@ class DockerSecurity {
return JSON.parse(data);
}
} catch (error) {
console.warn(`[DockerSecurity] Failed to load config: ${error.message}`);
log.warn('security', 'Failed to load config', { error: error.message });
}
// Default configuration
@@ -51,7 +52,7 @@ class DockerSecurity {
try {
fs.writeFileSync(SECURITY_CONFIG_FILE, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error(`[DockerSecurity] Failed to save config: ${error.message}`);
log.error('security', error, { operation: 'saveConfig' });
}
}
@@ -110,7 +111,7 @@ class DockerSecurity {
repository = repository.split(':')[0];
}
console.log(`[DockerSecurity] Fetching manifest for ${registry}/${repository}:${tag}`);
log.info('security', 'Fetching manifest', { registry, repository, tag });
return new Promise((resolve, reject) => {
const isDockerHub = registry === 'registry-1.docker.io';
@@ -216,7 +217,7 @@ class DockerSecurity {
if (this.config.updateTrustedOnPull) {
this.config.trustedDigests[imageName] = actualDigest;
this.saveConfig();
console.log(`[DockerSecurity] Added trusted digest for ${imageName}`);
log.info('security', 'Added trusted digest', { imageName });
}
}
} else if (actualDigest === trustedDigest) {
@@ -250,26 +251,26 @@ class DockerSecurity {
* @returns {Promise<object>} Verification result
*/
async verifyPulledImage(imageName) {
console.log(`[DockerSecurity] Verifying image: ${imageName}`);
log.info('security', '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}`);
log.error('security', 'Image REJECTED', { imageName, reason: 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}`);
log.warn('security', 'Image WARNING', { imageName, reason: result.reason });
log.warn('security', 'Expected digest', { imageName, digest: result.trustedDigest });
log.warn('security', 'Actual digest', { imageName, digest: result.actualDigest });
} else {
console.log(`[DockerSecurity] ACCEPTED: ${result.reason}`);
log.info('security', 'Image ACCEPTED', { imageName, reason: result.reason });
}
return result;
} catch (error) {
console.error(`[DockerSecurity] Verification error: ${error.message}`);
log.error('security', error, { imageName, operation: 'verify' });
if (this.mode === 'strict') {
throw error;
@@ -294,7 +295,7 @@ class DockerSecurity {
setTrustedDigest(imageName, digest) {
this.config.trustedDigests[imageName] = digest;
this.saveConfig();
console.log(`[DockerSecurity] Updated trusted digest for ${imageName}`);
log.info('security', 'Updated trusted digest', { imageName });
}
/**
@@ -304,7 +305,7 @@ class DockerSecurity {
removeTrustedDigest(imageName) {
delete this.config.trustedDigests[imageName];
this.saveConfig();
console.log(`[DockerSecurity] Removed trusted digest for ${imageName}`);
log.info('security', 'Removed trusted digest', { imageName });
}
/**
@@ -325,7 +326,7 @@ class DockerSecurity {
this.mode = mode;
this.config.verificationMode = mode;
this.saveConfig();
console.log(`[DockerSecurity] Verification mode set to: ${mode}`);
log.info('security', 'Verification mode set', { mode });
}
/**
+6 -5
View File
@@ -37,6 +37,7 @@
*/
const fs = require('fs');
const { log } = require('../utils/logging');
const path = require('path');
const os = require('os');
const platformPaths = require('../../platform-paths');
@@ -95,7 +96,7 @@ function createTail({ filePath, stateFile, onLine, label = 'tail', pollMs = 1000
for (const line of lines) {
if (line.trim()) {
try { onLine(line); } catch (e) {
console.error(`[${label}] onLine threw:`, e.message);
log.error('events', e, { worker: label, phase: 'onLine' });
}
}
}
@@ -106,7 +107,7 @@ function createTail({ filePath, stateFile, onLine, label = 'tail', pollMs = 1000
setTimeout(tick, pollMs);
});
stream.on('error', (e) => {
console.error(`[${label}] read error:`, e.message);
log.error('events', e, { worker: label, phase: 'read' });
setTimeout(tick, pollMs * 5);
});
});
@@ -267,11 +268,11 @@ function startFail2banWorker({ log } = {}) {
function startAll({ log } = {}) {
const workers = [];
try { workers.push(startCaddyWorker({ log })); }
catch (e) { console.error('[workers] caddy worker failed to start:', e.message); }
catch (e) { log.error('events', e, { worker: 'caddy', phase: 'start' }); }
try { workers.push(startSharedBansWorker({ log })); }
catch (e) { console.error('[workers] shared_bans worker failed to start:', e.message); }
catch (e) { log.error('events', e, { worker: 'shared_bans', phase: 'start' }); }
try { workers.push(startFail2banWorker({ log })); }
catch (e) { console.error('[workers] fail2ban worker failed to start:', e.message); }
catch (e) { log.error('events', e, { worker: 'fail2ban', phase: 'start' }); }
return {
stop() { workers.forEach(w => { try { w.stop(); } catch {} }); },
workers,
@@ -7,6 +7,7 @@
const { execSync, execFileSync } = require('child_process');
const os = require('os');
const crypto = require('crypto');
const { log } = require('../utils/logging');
const SERVICE_NAME = 'DashCaddy';
const ACCOUNT_PREFIX = 'dashcaddy';
@@ -44,7 +45,7 @@ class KeychainManager {
}
return false;
} catch {
console.warn('[Keychain] OS keychain not available, will use encrypted file storage');
log.warn('keychain', 'OS keychain not available, will use encrypted file storage');
return false;
}
}
@@ -72,7 +73,7 @@ class KeychainManager {
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to store ${key}:`, error.message);
log.error('keychain', error, { key, operation: 'store' });
return false;
}
}
@@ -99,7 +100,7 @@ class KeychainManager {
}
return null;
} catch (error) {
console.error(`[Keychain] Failed to retrieve ${key}:`, error.message);
log.error('keychain', error, { key, operation: 'retrieve' });
return null;
}
}
@@ -126,7 +127,7 @@ class KeychainManager {
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to delete ${key}:`, error.message);
log.error('keychain', error, { key, operation: 'delete' });
return false;
}
}
+9 -4
View File
@@ -11,6 +11,7 @@ const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const { DOCKER } = require('../utilities/constants');
const { log } = require('../utils/logging');
const docker = new Docker();
@@ -63,7 +64,7 @@ class LogDigest extends EventEmitter {
// Collect logs every hour
this.collectInterval = setInterval(() => {
this._collectHourlyLogs().catch(e =>
console.error('[LogDigest] Hourly collection failed:', e.message)
log.error('logdigest', e, { phase: 'hourlyCollect' })
);
}, DOCKER.DIGEST.COLLECT_INTERVAL);
@@ -71,7 +72,7 @@ class LogDigest extends EventEmitter {
this._scheduleDailyDigest();
// Run initial collection after 2 minutes
setTimeout(() => {
this._initialTimeout = setTimeout(() => {
if (this.running) {
this._collectHourlyLogs().catch(() => {});
}
@@ -89,6 +90,10 @@ class LogDigest extends EventEmitter {
clearTimeout(this.digestTimeout);
this.digestTimeout = null;
}
if (this._initialTimeout) {
clearTimeout(this._initialTimeout);
this._initialTimeout = null;
}
}
/**
@@ -195,7 +200,7 @@ class LogDigest extends EventEmitter {
hourSummary.services[appId] = serviceSummary;
}
} catch (e) {
console.error('[LogDigest] Container enumeration failed:', e.message);
log.error('logdigest', e, { phase: 'enumerateContainers' });
}
// Add to ring buffer
@@ -258,7 +263,7 @@ class LogDigest extends EventEmitter {
const delay = next.getTime() - now.getTime();
this.digestTimeout = setTimeout(() => {
this.generateDailyDigest().catch(e =>
console.error('[LogDigest] Daily digest generation failed:', e.message)
log.error('logdigest', e, { phase: 'dailyDigest' })
);
// Reschedule for tomorrow
if (this.running) this._scheduleDailyDigest();
+37 -36
View File
@@ -9,6 +9,7 @@ const { execSync } = require('child_process');
const crypto = require('crypto');
const EventEmitter = require('events');
const platformPaths = require('../../platform-paths');
const { log } = require('../utils/logging');
// Format bytes to human readable string
function formatBytes(bytes) {
@@ -38,7 +39,7 @@ class BackupManager extends EventEmitter {
start() {
if (this.running) return;
console.log('[BackupManager] Starting backup scheduler');
log.info('backup', 'Starting backup scheduler');
this.running = true;
// Schedule all configured backups
@@ -55,7 +56,7 @@ class BackupManager extends EventEmitter {
stop() {
if (!this.running) return;
console.log('[BackupManager] Stopping backup scheduler');
log.info('backup', 'Stopping backup scheduler');
this.running = false;
// Clear all scheduled jobs
@@ -91,7 +92,7 @@ class BackupManager extends EventEmitter {
if (!isNaN(minutes) && minutes > 0) {
intervalMs = minutes * 60 * 1000;
} else {
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
log.warn('backup', 'Invalid schedule', { name, schedule: backup.schedule });
return;
}
}
@@ -100,17 +101,17 @@ class BackupManager extends EventEmitter {
// Schedule the job
const job = setInterval(() => {
this.executeBackup(name, backup).catch(error => {
console.error(`[BackupManager] Scheduled backup ${name} failed:`, error.message);
log.error('backup', error, { name });
});
}, intervalMs);
this.scheduledJobs.set(name, job);
console.log(`[BackupManager] Scheduled backup '${name}' every ${backup.schedule}`);
log.info('backup', 'Scheduled backup', { name, schedule: backup.schedule });
// Run immediately if configured
if (backup.runImmediately) {
this.executeBackup(name, backup).catch(error => {
console.error(`[BackupManager] Initial backup ${name} failed:`, error.message);
log.error('backup', error, { name, phase: 'initial' });
});
}
}
@@ -122,7 +123,7 @@ class BackupManager extends EventEmitter {
const startTime = Date.now();
const backupId = `${name}-${Date.now()}`;
console.log(`[BackupManager] Starting backup: ${name}`);
log.info('backup', 'Starting backup', { name });
this.emit('backup-start', { name, backupId, timestamp: new Date().toISOString() });
@@ -151,7 +152,7 @@ class BackupManager extends EventEmitter {
const location = await this.saveToDestination(finalData, dest, backupId);
savedLocations.push(location);
} catch (error) {
console.error(`[BackupManager] Failed to save to ${dest.type}:`, error.message);
log.error('backup', error, { destType: dest.type });
}
}
@@ -192,7 +193,7 @@ class BackupManager extends EventEmitter {
}
this.emit('backup-complete', historyEntry);
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
log.info('backup', 'Backup completed', { name, durationMs: duration });
return historyEntry;
} catch (error) {
@@ -263,7 +264,7 @@ class BackupManager extends EventEmitter {
return JSON.parse(fs.readFileSync(servicesFile, 'utf8'));
}
} catch (error) {
console.error('[BackupManager] Error backing up services:', error.message);
log.error('backup', error, { source: 'services' });
}
return null;
}
@@ -278,7 +279,7 @@ class BackupManager extends EventEmitter {
return JSON.parse(fs.readFileSync(configFile, 'utf8'));
}
} catch (error) {
console.error('[BackupManager] Error backing up config:', error.message);
log.error('backup', error, { source: 'config' });
}
return null;
}
@@ -291,7 +292,7 @@ class BackupManager extends EventEmitter {
const credentialManager = require('../managers/credential-manager');
return credentialManager.exportBackup();
} catch (error) {
console.error('[BackupManager] Error backing up credentials:', error.message);
log.error('backup', error, { source: 'credentials' });
}
return null;
}
@@ -304,7 +305,7 @@ class BackupManager extends EventEmitter {
const resourceMonitor = require('../managers/resource-monitor');
return resourceMonitor.exportStats();
} catch (error) {
console.error('[BackupManager] Error backing up stats:', error.message);
log.error('backup', error, { source: 'stats' });
}
return null;
}
@@ -374,7 +375,7 @@ class BackupManager extends EventEmitter {
});
}
} catch (volumeError) {
console.error(`[BackupManager] Error backing up volume ${volume.Name}:`, volumeError.message);
log.error('backup', volumeError, { volume: volume.Name });
backupResults.push({
name: volume.Name,
status: 'failed',
@@ -390,7 +391,7 @@ class BackupManager extends EventEmitter {
volumes: backupResults
};
} catch (error) {
console.error('[BackupManager] Error backing up volumes:', error.message);
log.error('backup', error, { source: 'volumes' });
return null;
}
}
@@ -461,9 +462,9 @@ class BackupManager extends EventEmitter {
timestamp: new Date().toISOString()
});
console.log(`[BackupManager] Volume ${volumeName} restored successfully`);
log.info('backup', 'Volume restored', { volume: volumeName });
} catch (restoreError) {
console.error(`[BackupManager] Error restoring volume ${volBackup.name}:`, restoreError.message);
log.error('backup', restoreError, { volume: volBackup.name });
restoreResults.push({
name: volBackup.name,
status: 'failed',
@@ -849,7 +850,7 @@ class BackupManager extends EventEmitter {
throw new Error('Backup verification failed: checksum mismatch');
}
console.log('[BackupManager] Backup verified successfully');
log.info('backup', 'Backup verified successfully');
return true;
}
@@ -860,7 +861,7 @@ class BackupManager extends EventEmitter {
* Restore from backup
*/
async restoreBackup(backupId, options = {}) {
console.log(`[BackupManager] Starting restore from backup: ${backupId}`);
log.info('backup', 'Starting restore', { backupId });
this.emit('restore-start', { backupId, timestamp: new Date().toISOString() });
@@ -922,7 +923,7 @@ class BackupManager extends EventEmitter {
timestamp: new Date().toISOString()
});
console.log('[BackupManager] Restore completed successfully');
log.info('backup', 'Restore completed successfully');
return { success: true, restored };
} catch (error) {
this.emit('restore-failed', {
@@ -940,7 +941,7 @@ class BackupManager extends EventEmitter {
restoreServices(services) {
const servicesFile = platformPaths.servicesFile;
fs.writeFileSync(servicesFile, JSON.stringify(services, null, 2));
console.log('[BackupManager] Services restored');
log.info('backup', 'Services restored');
}
/**
@@ -949,7 +950,7 @@ class BackupManager extends EventEmitter {
restoreConfig(config) {
const configFile = platformPaths.configFile;
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
console.log('[BackupManager] Config restored');
log.info('backup', 'Config restored');
}
/**
@@ -958,7 +959,7 @@ class BackupManager extends EventEmitter {
restoreCredentials(credentials) {
const credentialManager = require('../managers/credential-manager');
credentialManager.importBackup(credentials);
console.log('[BackupManager] Credentials restored');
log.info('backup', 'Credentials restored');
}
/**
@@ -967,7 +968,7 @@ class BackupManager extends EventEmitter {
restoreStats(stats) {
const resourceMonitor = require('../managers/resource-monitor');
resourceMonitor.importStats(stats);
console.log('[BackupManager] Stats restored');
log.info('backup', 'Stats restored');
}
/**
@@ -975,7 +976,7 @@ class BackupManager extends EventEmitter {
*/
async enforceStorageLimit(name, maxBytes) {
const maxStr = formatBytes(maxBytes);
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
log.info('backup', 'Enforcing storage limit', { name, limit: maxStr });
const backups = this.history
.filter(b => b.name === name && b.status === 'success')
@@ -994,10 +995,10 @@ class BackupManager extends EventEmitter {
}
}
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
log.info('backup', 'Current storage usage', { totalSize: formatBytes(totalSize), limit: maxStr });
if (totalSize <= maxBytes) {
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
log.info('backup', 'Storage limit OK', { totalSize: formatBytes(totalSize), limit: maxStr });
return;
}
@@ -1013,10 +1014,10 @@ class BackupManager extends EventEmitter {
const sz = backup.size || 0;
totalSize -= sz;
freed += sz;
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
log.info('backup', 'Deleted old backup file', { size: formatBytes(sz), path });
}
} catch (error) {
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
log.error('backup', error, { path });
}
}
@@ -1024,7 +1025,7 @@ class BackupManager extends EventEmitter {
}
this.saveHistory();
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
log.info('backup', 'Storage limit enforced', { freed: formatBytes(freed), totalSize: formatBytes(totalSize) });
}
/**
@@ -1051,9 +1052,9 @@ class BackupManager extends EventEmitter {
// Remove from history
this.history = this.history.filter(b => b.id !== backup.id);
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
log.info('backup', 'Deleted old backup', { backupId: backup.id });
} catch (error) {
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
log.error('backup', error, { backupId: backup.id });
}
}
@@ -1109,7 +1110,7 @@ class BackupManager extends EventEmitter {
return JSON.parse(fs.readFileSync(BACKUP_CONFIG_FILE, 'utf8'));
}
} catch (error) {
console.error('[BackupManager] Error loading config:', error.message);
log.error('backup', error, { operation: 'loadConfig' });
}
return {
@@ -1125,7 +1126,7 @@ class BackupManager extends EventEmitter {
try {
fs.writeFileSync(BACKUP_CONFIG_FILE, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error('[BackupManager] Error saving config:', error.message);
log.error('backup', error, { operation: 'saveConfig' });
}
}
@@ -1138,7 +1139,7 @@ class BackupManager extends EventEmitter {
return JSON.parse(fs.readFileSync(BACKUP_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[BackupManager] Error loading history:', error.message);
log.error('backup', error, { operation: 'loadHistory' });
}
return [];
}
@@ -1150,7 +1151,7 @@ class BackupManager extends EventEmitter {
try {
fs.writeFileSync(BACKUP_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[BackupManager] Error saving history:', error.message);
log.error('backup', error, { operation: 'saveHistory' });
}
}
}
+162 -116
View File
@@ -3,12 +3,161 @@
* Validates config.json structure to catch typos and invalid values early.
*/
const VALID_TIMEZONES_SAMPLE = [
'UTC', 'America/New_York', 'America/Chicago', 'America/Denver', 'America/Los_Angeles',
'Europe/London', 'Europe/Paris', 'Europe/Berlin', 'Asia/Tokyo', 'Asia/Shanghai',
'Asia/Singapore', 'Australia/Sydney', 'Pacific/Auckland'
const VALID_THEMES = ['dark', 'light', 'blue'];
const VALID_ROUTING_MODES = ['subdomain', 'subdirectory'];
const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
const KNOWN_KEYS = [
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
'customLogoDark', 'customLogoLight'
];
/**
* @param {string[]} arr
* @param {string} val
* @returns {boolean}
*/
function isInArray(arr, val) {
return arr.includes(val);
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateTld(ctx, config) {
if (config.tld === undefined) return;
if (typeof config.tld !== 'string') {
ctx.errors.push('tld must be a string');
return;
}
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
ctx.errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
}
if (tld.length > 20) {
ctx.warnings.push(`tld "${config.tld}" is unusually long`);
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateDns(ctx, config) {
if (config.dns === undefined) return;
if (typeof config.dns !== 'object' || config.dns === null) {
ctx.errors.push('dns must be an object');
return;
}
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
ctx.errors.push('dns.ip must be a string');
}
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
ctx.errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
}
if (config.dns.port !== undefined) {
const port = parseInt(config.dns.port, 10);
if (isNaN(port) || port < 1 || port > 65535) {
ctx.errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
}
}
if (config.dns.servers !== undefined) {
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
ctx.errors.push('dns.servers must be an object');
}
}
if (config.dns.provider !== undefined) {
if (typeof config.dns.provider !== 'string') {
ctx.errors.push('dns.provider must be a string');
} else if (!isInArray(VALID_DNS_PROVIDERS, config.dns.provider)) {
ctx.warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${VALID_DNS_PROVIDERS.join(', ')}. It may still work if a custom adapter is installed.`);
}
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateDashboardHost(ctx, config) {
if (config.dashboardHost === undefined) return;
if (typeof config.dashboardHost !== 'string') {
ctx.errors.push('dashboardHost must be a string');
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
ctx.errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateTimezone(ctx, config) {
if (config.timezone === undefined) return;
if (typeof config.timezone !== 'string') {
ctx.errors.push('timezone must be a string');
} else if (config.timezone) {
try {
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
} catch {
ctx.errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
}
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateTheme(ctx, config) {
if (config.theme === undefined) return;
if (!isInArray(VALID_THEMES, config.theme)) {
ctx.warnings.push(`theme "${config.theme}" is not one of: ${VALID_THEMES.join(', ')}`);
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateRoutingMode(ctx, config) {
if (config.routingMode === undefined) return;
if (!isInArray(VALID_ROUTING_MODES, config.routingMode)) {
ctx.errors.push(`routingMode "${config.routingMode}" is not one of: ${VALID_ROUTING_MODES.join(', ')}`);
}
}
/**
* @param {{errors:string[], warnings:string[]}} ctx
* @param {object} config
*/
function validateDomain(ctx, config) {
if (config.domain === undefined) return;
if (typeof config.domain !== 'string') {
ctx.errors.push('domain must be a string');
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
ctx.warnings.push(`domain "${config.domain}" may not be a valid domain name`);
}
}
/**
* @param {{warnings:string[]}} ctx
* @param {object} config
*/
function validateKnownKeys(ctx, config) {
for (const key of Object.keys(config)) {
if (!isInArray(KNOWN_KEYS, key)) {
ctx.warnings.push(`Unknown config key "${key}" — possible typo?`);
}
}
}
/**
* Validate a config object and return errors/warnings.
* @param {object} config - The config object to validate
@@ -17,123 +166,20 @@ const VALID_TIMEZONES_SAMPLE = [
function validateConfig(config) {
const errors = [];
const warnings = [];
const ctx = { errors, warnings };
if (!config || typeof config !== 'object') {
return { valid: false, errors: ['Config must be a non-null object'], warnings };
}
// TLD validation
if (config.tld !== undefined) {
if (typeof config.tld !== 'string') {
errors.push('tld must be a string');
} else {
const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld;
if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) {
errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`);
}
if (tld.length > 20) {
warnings.push(`tld "${config.tld}" is unusually long`);
}
}
}
// DNS config validation
if (config.dns !== undefined) {
if (typeof config.dns !== 'object' || config.dns === null) {
errors.push('dns must be an object');
} else {
if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') {
errors.push('dns.ip must be a string');
}
if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) {
errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`);
}
if (config.dns.port !== undefined) {
const port = parseInt(config.dns.port, 10);
if (isNaN(port) || port < 1 || port > 65535) {
errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`);
}
}
if (config.dns.servers !== undefined) {
if (typeof config.dns.servers !== 'object' || config.dns.servers === null) {
errors.push('dns.servers must be an object');
}
}
// DNS provider validation
if (config.dns.provider !== undefined) {
const validProviders = ['technitium', 'cloudflare', 'rfc2136', 'manual'];
if (typeof config.dns.provider !== 'string') {
errors.push('dns.provider must be a string');
} else if (!validProviders.includes(config.dns.provider)) {
warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${validProviders.join(', ')}. It may still work if a custom adapter is installed.`);
}
}
}
}
// Dashboard host validation
if (config.dashboardHost !== undefined) {
if (typeof config.dashboardHost !== 'string') {
errors.push('dashboardHost must be a string');
} else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) {
errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`);
}
}
// Timezone validation
if (config.timezone !== undefined) {
if (typeof config.timezone !== 'string') {
errors.push('timezone must be a string');
} else if (config.timezone) {
// Basic format check — full validation would require Intl API
try {
Intl.DateTimeFormat(undefined, { timeZone: config.timezone });
} catch {
errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`);
}
}
}
// Theme validation
if (config.theme !== undefined) {
const validThemes = ['dark', 'light', 'blue'];
if (!validThemes.includes(config.theme)) {
warnings.push(`theme "${config.theme}" is not one of: ${validThemes.join(', ')}`);
}
}
// Routing mode validation
if (config.routingMode !== undefined) {
const validModes = ['subdomain', 'subdirectory'];
if (!validModes.includes(config.routingMode)) {
errors.push(`routingMode "${config.routingMode}" is not one of: ${validModes.join(', ')}`);
}
}
// Domain validation
if (config.domain !== undefined) {
if (typeof config.domain !== 'string') {
errors.push('domain must be a string');
} else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) {
warnings.push(`domain "${config.domain}" may not be a valid domain name`);
}
}
// Warn on unknown top-level keys
const knownKeys = [
'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme',
'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather',
'setupComplete', 'setupCompleted', 'setupMode', 'onboardingCompleted',
'configurationType', 'defaults', 'customLogo', 'customFavicon',
'dashboardTitle', 'tailscale', 'license', 'skipped',
'routingMode', 'domain', 'email', 'defaultIP', 'pylon',
'customLogoDark', 'customLogoLight'
];
for (const key of Object.keys(config)) {
if (!knownKeys.includes(key)) {
warnings.push(`Unknown config key "${key}" — possible typo?`);
}
}
validateTld(ctx, config);
validateDns(ctx, config);
validateDashboardHost(ctx, config);
validateTimezone(ctx, config);
validateTheme(ctx, config);
validateRoutingMode(ctx, config);
validateDomain(ctx, config);
validateKnownKeys(ctx, config);
return { valid: errors.length === 0, errors, warnings };
}
+141
View File
@@ -0,0 +1,141 @@
/**
* DC-086: Structured error code system for consistent API error responses.
*
* Format: DC-[MODULE]-[NUMBER]
* Modules: AUTH, CONTAINER, SERVICE, DNS, CADDY, CA, BACKUP, CONFIG,
* BILL, HEALTH, NETWORK, SYSTEM, GENERAL
*
* Usage in routes:
* const { ErrorCodes } = require('../src/utilities/error-codes');
* errorResponse(res, 400, ErrorCodes.CONTAINER.INVALID_ID, 'Container ID has invalid characters');
*
* Clients can use the machine-readable code for i18n and error-specific handling
* while the human message provides immediate context.
*/
const ErrorCodes = {
// ── General ──
GENERAL: {
INVALID_INPUT: 'DC-GEN-001',
NOT_FOUND: 'DC-GEN-002',
RATE_LIMITED: 'DC-GEN-003',
INTERNAL: 'DC-GEN-004',
UNAUTHORIZED: 'DC-GEN-005',
FORBIDDEN: 'DC-GEN-006',
CONFLICT: 'DC-GEN-007',
TIMEOUT: 'DC-GEN-008',
},
// ── Authentication ──
AUTH: {
NO_SESSION: 'DC-AUTH-001',
INVALID_TOKEN: 'DC-AUTH-002',
SESSION_EXPIRED: 'DC-AUTH-003',
TOTP_REQUIRED: 'DC-AUTH-004',
TOTP_INVALID: 'DC-AUTH-005',
PROVIDER_DISABLED: 'DC-AUTH-006',
INVITE_EXPIRED: 'DC-AUTH-007',
INVITE_INVALID: 'DC-AUTH-008',
KEY_REVOKED: 'DC-AUTH-009',
LAST_ADMIN: 'DC-AUTH-010',
},
// ── Containers ──
CONTAINER: {
NOT_FOUND: 'DC-CONT-001',
INVALID_ID: 'DC-CONT-002',
INVALID_NAME: 'DC-CONT-003',
INVALID_IMAGE: 'DC-CONT-004',
ALREADY_RUNNING: 'DC-CONT-005',
ALREADY_STOPPED: 'DC-CONT-006',
START_FAILED: 'DC-CONT-007',
STOP_FAILED: 'DC-CONT-008',
DELETE_FAILED: 'DC-CONT-009',
INVALID_RESOURCES: 'DC-CONT-010',
DOCKER_UNREACHABLE: 'DC-CONT-011',
},
// ── Services ──
SERVICE: {
NOT_FOUND: 'DC-SVC-001',
INVALID_ID: 'DC-SVC-002',
INVALID_SUBDOMAIN: 'DC-SVC-003',
INVALID_PORT: 'DC-SVC-004',
DUPLICATE_ID: 'DC-SVC-005',
INVALID_URL: 'DC-SVC-006',
INVALID_PROTOCOL: 'DC-SVC-007',
PORT_IN_USE: 'DC-SVC-008',
DEPENDENCY_CYCLE: 'DC-SVC-009',
},
// ── DNS ──
DNS: {
INVALID_RECORD: 'DC-DNS-001',
INVALID_ZONE: 'DC-DNS-002',
PROVIDER_ERROR: 'DC-DNS-003',
PROPAGATION_TIMEOUT: 'DC-DNS-004',
INVALID_CREDENTIALS: 'DC-DNS-005',
},
// ── Caddy / Reverse Proxy ──
CADDY: {
ADMIN_UNREACHABLE: 'DC-CAD-001',
CONFIG_INVALID: 'DC-CAD-002',
RELOAD_FAILED: 'DC-CAD-003',
SITE_EXISTS: 'DC-CAD-004',
SITE_NOT_FOUND: 'DC-CAD-005',
},
// ── Certificate Authority ──
CA: {
NOT_INITIALIZED: 'DC-CA-001',
INVALID_DOMAIN: 'DC-CA-002',
CERT_NOT_FOUND: 'DC-CA-003',
GENERATION_FAILED: 'DC-CA-004',
INVALID_FORMAT: 'DC-CA-005',
},
// ── Backup ──
BACKUP: {
NO_SCHEDULE: 'DC-BAK-001',
BACKUP_FAILED: 'DC-BAK-002',
RESTORE_FAILED: 'DC-BAK-003',
INVALID_CONFIG: 'DC-BAK-004',
},
// ── Billing / License ──
BILL: {
CHECKOUT_FAILED: 'DC-BILL-001',
LICENSE_INVALID: 'DC-BILL-002',
LICENSE_EXPIRED: 'DC-BILL-003',
LICENSE_NOT_FOUND: 'DC-BILL-004',
FEATURE_LOCKED: 'DC-BILL-005',
WEBHOOK_INVALID: 'DC-BILL-006',
},
// ── Health Monitoring ──
HEALTH: {
CHECK_FAILED: 'DC-HLT-001',
INCIDENT_NOT_FOUND: 'DC-HLT-002',
INVALID_SEVERITY: 'DC-HLT-003',
},
// ── Network ──
NETWORK: {
INVALID_IP: 'DC-NET-001',
INVALID_CIDR: 'DC-NET-002',
INVALID_HOSTNAME: 'DC-NET-003',
GATEWAY_TIMEOUT: 'DC-NET-004',
},
// ── System / Config ──
SYSTEM: {
CONFIG_INVALID: 'DC-SYS-001',
CONFIG_SAVE_FAILED: 'DC-SYS-002',
STARTUP_FAILED: 'DC-SYS-003',
DATA_DIR_UNSAFE: 'DC-SYS-004',
DISK_FULL: 'DC-SYS-005',
},
};
module.exports = { ErrorCodes };
+2 -6
View File
@@ -34,7 +34,7 @@ function errorMiddleware(err, req, res, next) {
userId: req.user?.id,
body: req.body
}
).catch(e => console.error('Failed to write to error log:', e.message));
).catch(e => process.stderr.write(`[error-handler] Failed to write to error log: ${e.message}\n`));
// Determine if this is an operational error (AppError) or programming error
const isOperational = err.isOperational || err instanceof AppError;
@@ -65,11 +65,7 @@ function errorMiddleware(err, req, res, next) {
// For non-operational errors, log as fatal
if (!isOperational) {
console.error('FATAL: Non-operational error detected', {
error: err.message,
stack: err.stack,
path: req.path
});
process.stderr.write(`[FATAL] Non-operational error detected: ${JSON.stringify({ error: err.message, stack: err.stack, path: req.path })}\n`);
}
}
@@ -0,0 +1,160 @@
/**
* DC-071: Error tracking integration framework
*
* Provides an opt-in error tracking interface that can forward uncaught
* errors to external services (Sentry, Bugsnag, etc.) when configured.
*
* In production, set ERROR_TRACKING_DSN environment variable to enable.
* Without a DSN, errors are logged normally but not forwarded.
*
* Usage:
* const { errorTracker } = require('./utilities/error-tracker');
* errorTracker.init({ dsn: process.env.ERROR_TRACKING_DSN, release: '1.15.0' });
* errorTracker.capture(error, { extra: { route: req.path } });
*/
const os = require('os');
class ErrorTracker {
constructor() {
this.dsn = null;
this.release = null;
this.enabled = false;
this.pendingFlush = Promise.resolve();
}
/**
* Initialize the error tracker.
* If no DSN is provided, tracking is disabled (errors still log normally).
*/
init({ dsn, release, environment } = {}) {
this.dsn = dsn || process.env.ERROR_TRACKING_DSN;
this.release = release || process.env.npm_package_version || 'unknown';
this.environment = environment || process.env.NODE_ENV || 'production';
this.enabled = !!this.dsn;
return this.enabled;
}
/**
* Capture an error and forward to the tracking service.
* Non-blocking swallows network errors silently.
*/
capture(error, context = {}) {
if (!this.enabled || !error) return;
const payload = {
event_id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
timestamp: new Date().toISOString(),
platform: 'node',
level: 'error',
release: this.release,
environment: this.environment,
message: error.message || String(error),
stacktrace: error.stack || '',
exception: {
type: error.constructor.name,
value: error.message,
},
tags: {
hostname: os.hostname(),
node_version: process.version,
...context.tags,
},
extra: {
pid: process.pid,
memory: process.memoryUsage().rss,
uptime: process.uptime(),
...context.extra,
},
request: context.request || undefined,
user: context.user || undefined,
};
// Fire-and-forget — don't block the event loop
this.pendingFlush = this._send(payload).catch(() => {
// Silent failure — tracking errors should never crash the app
});
return payload.event_id;
}
/**
* Capture a message (not an error) at the specified level.
*/
captureMessage(message, level = 'info', context = {}) {
if (!this.enabled) return;
return this.capture(
Object.assign(new Error(message), { stack: '' }),
{ ...context, tags: { ...context.tags, level } }
);
}
/**
* Send the payload to the tracking service DSN.
* Currently implements the Sentry envelope format.
*/
async _send(payload) {
if (!this.dsn) return;
const url = new URL(this.dsn);
const projectId = url.pathname.replace(/^\//, '');
const apiKey = url.username;
const ingestUrl = `${url.protocol}//${url.host}/api/${projectId}/store/`;
const body = JSON.stringify(payload);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(ingestUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Sentry-Auth': `Sentry sentry_key=${apiKey}`,
},
body,
signal: controller.signal,
});
if (!response.ok) {
// Non-OK response — silently ignore
}
} finally {
clearTimeout(timeout);
}
}
/**
* Wait for all pending events to flush.
*/
async flush(timeoutMs = 2000) {
await Promise.race([
this.pendingFlush,
new Promise(resolve => setTimeout(resolve, timeoutMs)),
]);
}
/**
* Express error-handling middleware that captures errors before
* forwarding to the next error handler.
*/
middleware() {
return (err, req, res, next) => {
this.capture(err, {
request: {
url: req.url,
method: req.method,
headers: req.headers,
},
extra: {
requestId: req.id,
path: req.path,
},
});
next(err);
};
}
}
module.exports = new ErrorTracker();
+264
View File
@@ -0,0 +1,264 @@
/**
* DC-077: Internationalization (i18n) framework for DashCaddy
*
* Lightweight translation system for the dashboard frontend and API responses.
* Supports multiple languages via JSON translation files loaded on demand.
*
* Languages are stored in /assets/i18n/{lang}.json
* Default language is 'en' (English).
*
* Usage in frontend JS:
* const { t, setLanguage, getLanguage } = window.DCI18n;
* document.querySelector('.title').textContent = t('dashboard.title');
*
* Usage in API responses:
* const i18n = require('./i18n');
* const msg = i18n.t('error.container_not_found', req.lang || 'en');
*/
const fs = require('fs');
const path = require('path');
// Built-in translations (loaded synchronously at startup)
const TRANSLATIONS = {
en: {
'dashboard.title': 'Dashboard',
'dashboard.services': 'Services',
'dashboard.containers': 'Containers',
'dashboard.health': 'Health',
'dashboard.settings': 'Settings',
'dashboard.backups': 'Backups',
'dashboard.monitoring': 'Monitoring',
'dashboard.security': 'Security',
'service.status.healthy': 'Healthy',
'service.status.degraded': 'Degraded',
'service.status.down': 'Down',
'service.status.unknown': 'Unknown',
'service.status.pending': 'Pending',
'action.start': 'Start',
'action.stop': 'Stop',
'action.restart': 'Restart',
'action.delete': 'Delete',
'action.update': 'Update',
'action.deploy': 'Deploy',
'action.save': 'Save',
'action.cancel': 'Cancel',
'action.confirm': 'Confirm',
'error.not_found': 'Resource not found',
'error.unauthorized': 'Unauthorized',
'error.forbidden': 'Forbidden',
'error.rate_limited': 'Too many requests',
'error.internal': 'Internal server error',
'error.container_not_found': 'Container not found',
'error.service_not_found': 'Service not found',
'error.invalid_input': 'Invalid input',
'error.docker_unreachable': 'Docker daemon is not reachable',
'error.disk_full': 'Disk space is critically low',
},
es: {
'dashboard.title': 'Panel de control',
'dashboard.services': 'Servicios',
'dashboard.containers': 'Contenedores',
'dashboard.health': 'Salud',
'dashboard.settings': 'Configuración',
'dashboard.backups': 'Copias de seguridad',
'dashboard.monitoring': 'Monitoreo',
'dashboard.security': 'Seguridad',
'service.status.healthy': 'Saludable',
'service.status.degraded': 'Degradado',
'service.status.down': 'Caído',
'service.status.unknown': 'Desconocido',
'service.status.pending': 'Pendiente',
'action.start': 'Iniciar',
'action.stop': 'Detener',
'action.restart': 'Reiniciar',
'action.delete': 'Eliminar',
'action.update': 'Actualizar',
'action.deploy': 'Desplegar',
'action.save': 'Guardar',
'action.cancel': 'Cancelar',
'action.confirm': 'Confirmar',
'error.not_found': 'Recurso no encontrado',
'error.unauthorized': 'No autorizado',
'error.forbidden': 'Prohibido',
'error.rate_limited': 'Demasiadas solicitudes',
'error.internal': 'Error interno del servidor',
'error.container_not_found': 'Contenedor no encontrado',
'error.service_not_found': 'Servicio no encontrado',
'error.invalid_input': 'Entrada inválida',
'error.docker_unreachable': 'El demonio de Docker no es accesible',
'error.disk_full': 'Espacio en disco críticamente bajo',
},
fr: {
'dashboard.title': 'Tableau de bord',
'dashboard.services': 'Services',
'dashboard.containers': 'Conteneurs',
'dashboard.health': 'Santé',
'dashboard.settings': 'Paramètres',
'dashboard.backups': 'Sauvegardes',
'dashboard.monitoring': 'Surveillance',
'dashboard.security': 'Sécurité',
'service.status.healthy': 'Sain',
'service.status.degraded': 'Dégradé',
'service.status.down': 'Hors ligne',
'service.status.unknown': 'Inconnu',
'service.status.pending': 'En attente',
'action.start': 'Démarrer',
'action.stop': 'Arrêter',
'action.restart': 'Redémarrer',
'action.delete': 'Supprimer',
'action.update': 'Mettre à jour',
'action.deploy': 'Déployer',
'action.save': 'Enregistrer',
'action.cancel': 'Annuler',
'action.confirm': 'Confirmer',
'error.not_found': 'Ressource introuvable',
'error.unauthorized': 'Non autorisé',
'error.forbidden': 'Interdit',
'error.rate_limited': 'Trop de requêtes',
'error.internal': 'Erreur interne du serveur',
'error.container_not_found': 'Conteneur introuvable',
'error.service_not_found': 'Service introuvable',
'error.invalid_input': 'Entrée invalide',
'error.docker_unreachable': 'Le démon Docker est injoignable',
'error.disk_full': 'Espace disque critique',
},
de: {
'dashboard.title': 'Dashboard',
'dashboard.services': 'Dienste',
'dashboard.containers': 'Container',
'dashboard.health': 'Zustand',
'dashboard.settings': 'Einstellungen',
'dashboard.backups': 'Backups',
'dashboard.monitoring': 'Überwachung',
'dashboard.security': 'Sicherheit',
'service.status.healthy': 'Gesund',
'service.status.degraded': 'Beeinträchtigt',
'service.status.down': 'Ausgefallen',
'service.status.unknown': 'Unbekannt',
'service.status.pending': 'Ausstehend',
'action.start': 'Starten',
'action.stop': 'Stopp',
'action.restart': 'Neustart',
'action.delete': 'Löschen',
'action.update': 'Aktualisieren',
'action.deploy': 'Bereitstellen',
'action.save': 'Speichern',
'action.cancel': 'Abbrechen',
'action.confirm': 'Bestätigen',
'error.not_found': 'Ressource nicht gefunden',
'error.unauthorized': 'Nicht autorisiert',
'error.forbidden': 'Verboten',
'error.rate_limited': 'Zu viele Anfragen',
'error.internal': 'Interner Serverfehler',
'error.container_not_found': 'Container nicht gefunden',
'error.service_not_found': 'Dienst nicht gefunden',
'error.invalid_input': 'Ungültige Eingabe',
'error.docker_unreachable': 'Docker-Daemon ist nicht erreichbar',
'error.disk_full': 'Speicherplatz kritisch niedrig',
},
ar: {
'dashboard.title': 'لوحة التحكم',
'dashboard.services': 'الخدمات',
'dashboard.containers': 'الحاويات',
'dashboard.health': 'الصحة',
'dashboard.settings': 'الإعدادات',
'dashboard.backups': 'النسخ الاحتياطية',
'dashboard.monitoring': 'المراقبة',
'dashboard.security': 'الأمان',
'service.status.healthy': 'سليم',
'service.status.degraded': 'متدهور',
'service.status.down': 'متوقف',
'service.status.unknown': 'غير معروف',
'service.status.pending': 'قيد الانتظار',
'action.start': 'تشغيل',
'action.stop': 'إيقاف',
'action.restart': 'إعادة تشغيل',
'action.delete': 'حذف',
'action.update': 'تحديث',
'action.deploy': 'نشر',
'action.save': 'حفظ',
'action.cancel': 'إلغاء',
'action.confirm': 'تأكيد',
'error.not_found': 'المورد غير موجود',
'error.unauthorized': 'غير مصرح',
'error.forbidden': 'محظور',
'error.rate_limited': 'طلبات كثيرة جداً',
'error.internal': 'خطأ داخلي في الخادم',
'error.container_not_found': 'الحاوية غير موجودة',
'error.service_not_found': 'الخدمة غير موجودة',
'error.invalid_input': 'إدخال غير صالح',
'error.docker_unreachable': 'لا يمكن الوصول إلى Docker',
'error.disk_full': 'مساحة القرص منخفضة بشكل حرج',
},
};
const SUPPORTED_LANGUAGES = Object.keys(TRANSLATIONS);
const DEFAULT_LANGUAGE = 'en';
/**
* Translate a key to the specified language.
* Falls back to English, then to the key itself if not found.
*/
function t(key, lang = DEFAULT_LANGUAGE) {
const dict = TRANSLATIONS[lang] || TRANSLATIONS[DEFAULT_LANGUAGE];
return dict[key] || TRANSLATIONS[DEFAULT_LANGUAGE][key] || key;
}
/**
* Get the list of supported languages
*/
function getSupportedLanguages() {
return SUPPORTED_LANGUAGES;
}
/**
* Check if a language is supported
*/
function isSupported(lang) {
return SUPPORTED_LANGUAGES.includes(lang);
}
/**
* Detect language from Accept-Language header
*/
function detectLanguage(acceptLanguage) {
if (!acceptLanguage) return DEFAULT_LANGUAGE;
const langs = acceptLanguage.split(',').map(l => {
const [code, q] = l.trim().split(';q=');
return { code: code.split('-')[0].toLowerCase(), q: q ? parseFloat(q) : 1 };
}).sort((a, b) => b.q - a.q);
for (const { code } of langs) {
if (isSupported(code)) return code;
}
return DEFAULT_LANGUAGE;
}
module.exports = {
t,
getSupportedLanguages,
isSupported,
detectLanguage,
DEFAULT_LANGUAGE,
TRANSLATIONS,
};
+62 -36
View File
@@ -113,6 +113,41 @@ module.exports = function configureMiddleware(app, {
next();
});
// ── Tailscale authentication helpers ──
const PROBE_PATHS_TAILSCALE = new Set([
'/health', '/health/live', '/health/ready', '/healthz', '/readyz',
]);
function isTailScaleProbePath(reqPath) {
return PROBE_PATHS_TAILSCALE.has(reqPath) || reqPath.startsWith('/probe/');
}
function extractTailscaleIPs(req) {
const clientIP = req.ip || req.socket?.remoteAddress || '';
const forwardedFor = req.headers['x-forwarded-for'];
const realIP = req.headers['x-real-ip'];
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
const fromTailscale = ipsToCheck.some(ip =>
isTailscaleIP(ip.toString().split(',')[0].trim()));
const clientTailscaleIP = ipsToCheck
.map(ip => ip.toString().split(',')[0].trim())
.find(ip => isTailscaleIP(ip));
return { clientIP, ipsToCheck, fromTailscale, clientTailscaleIP };
}
async function isIPInTailnet(clientTailscaleIP) {
const status = await getTailscaleStatus();
if (!status) return true; // no status = can't verify = allow
const knownIPs = new Set();
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
for (const peer of Object.values(status.Peer || {})) {
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
}
return knownIPs.has(clientTailscaleIP);
}
// ── Tailscale authentication middleware (optional) ──
const tailscaleAuthMiddleware = async (req, res, next) => {
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
@@ -121,25 +156,11 @@ module.exports = function configureMiddleware(app, {
// Probe endpoints bypass Tailscale auth — k8s/Docker healthchecks
// don't carry a Tailscale identity header.
if (req.path === '/health'
|| req.path === '/health/live'
|| req.path === '/health/ready'
|| req.path === '/healthz'
|| req.path === '/readyz'
|| req.path.startsWith('/probe/')) {
if (isTailScaleProbePath(req.path) || req.path.startsWith('/api/v1/tailscale/')) {
return next();
}
if (req.path.startsWith('/api/v1/tailscale/')) {
return next();
}
const clientIP = req.ip || req.socket?.remoteAddress || '';
const forwardedFor = req.headers['x-forwarded-for'];
const realIP = req.headers['x-real-ip'];
const ipsToCheck = [clientIP, forwardedFor, realIP].filter(Boolean);
const fromTailscale = ipsToCheck.some(ip => isTailscaleIP(ip.toString().split(',')[0].trim()));
const { clientIP, fromTailscale, clientTailscaleIP } = extractTailscaleIPs(req);
if (!fromTailscale) {
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
@@ -148,27 +169,14 @@ module.exports = function configureMiddleware(app, {
});
}
if (tailscaleConfig.allowedTailnet) {
if (tailscaleConfig.allowedTailnet && clientTailscaleIP) {
try {
const status = await getTailscaleStatus();
if (status) {
const clientTailscaleIP = ipsToCheck
.map(ip => ip.toString().split(',')[0].trim())
.find(ip => isTailscaleIP(ip));
if (clientTailscaleIP) {
const knownIPs = new Set();
for (const ip of (status.Self?.TailscaleIPs || [])) knownIPs.add(ip);
for (const peer of Object.values(status.Peer || {})) {
for (const ip of (peer.TailscaleIPs || [])) knownIPs.add(ip);
}
if (!knownIPs.has(clientTailscaleIP)) {
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
requiresTailscale: true,
clientIP
});
}
}
const inTailnet = await isIPInTailnet(clientTailscaleIP);
if (!inTailnet) {
return errorResponse(res, 403, '[DC-121] Access denied. Device not in allowed tailnet.', {
requiresTailscale: true,
clientIP
});
}
} catch (e) {
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
@@ -429,6 +437,12 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
// DC-075: System health endpoint for external uptime monitoring (UptimeRobot, BetterStack)
{ path: '/api/v1/system/health', exact: true, method: 'GET' },
// DC-097: Prometheus metrics endpoint (scraped by Prometheus, no auth)
{ path: '/api/v1/metrics/prometheus', exact: true, method: 'GET' },
// DC-077: i18n endpoints (language list + translations, public)
{ path: '/api/v1/i18n/', prefix: true, method: 'GET' },
// System Overview widget on the dashboard — needs the flattened CPU/mem
// data without going through auth. See skill references/totp-and-system-overview-pitfalls.md §3.
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
@@ -561,6 +575,18 @@ module.exports = function configureMiddleware(app, {
});
app.use(generalLimiter);
// ── DC-073: Debug request logger (gated behind LOG_LEVEL=debug) ──
if (process.env.LOG_LEVEL === 'debug') {
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
process.stderr.write(`[req] ${req.method} ${req.path} ${res.statusCode} ${duration}ms\n`);
});
next();
});
}
app.use('/api/v1/dns/credentials', strictLimiter);
app.use('/api/v1/apps/deploy', strictLimiter);
app.use('/api/v1/backup/restore', strictLimiter);
@@ -74,11 +74,22 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI
}
// 3. Check if port is available
// CRITICAL: listen() and close() are async. If we fire-and-forget both
// (the old code), the kernel hasn't released the port by the time
// app.listen(PORT) runs in server.js → EADDRINUSE → crash loop.
// Await both via Promises so the port is truly free before we return.
const net = require('net');
const portCheckServer = net.createServer();
try {
portCheckServer.listen(PORT, '0.0.0.0');
portCheckServer.close();
await new Promise((resolve, reject) => {
portCheckServer.once('error', reject);
portCheckServer.listen(PORT, '0.0.0.0', () => {
portCheckServer.close(() => {
portCheckServer.removeListener('error', reject);
resolve();
});
});
});
log.info('startup', `Port ${PORT} is available`);
} catch (error) {
errors.push(`Port ${PORT} is already in use or cannot be bound`);
+6 -4
View File
@@ -9,10 +9,11 @@
*
* Priority:
* 1. internet https://www.google.com
* 2. isExternal + externalUrl use as-is
* 3. service.url prepend https:// if no protocol
* 4. dnsServers config http://{ip}:{port}
* 5. fallback buildServiceUrl(id)
* 2. healthCheckUrl use as-is (bypass SSO/Caddy for direct container health checks)
* 3. isExternal + externalUrl use as-is
* 4. service.url prepend https:// if no protocol
* 5. dnsServers config http://{ip}:{port}
* 6. fallback buildServiceUrl(id)
*
* @param {string} id - service identifier
* @param {Object|null} service - service object from services.json (may be null for top-card services)
@@ -22,6 +23,7 @@
*/
function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) {
if (id === 'internet') return 'https://www.google.com';
if (service?.healthCheckUrl) return service.healthCheckUrl;
if (service?.isExternal && service.externalUrl) return service.externalUrl;
if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`;
const dnsServer = siteConfig?.dnsServers?.[id];
+1 -1
View File
@@ -43,7 +43,7 @@ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
// passes `timeout: N` here, it's almost certainly a bug — we used to silently
// strip it, which masked the issue. Now we surface it in logs and strip it.
if ('timeout' in opts) {
console.warn(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}`);
process.stderr.write(`[fetchT] opts.timeout=${opts.timeout} is ignored — pass timeoutMs as the 3rd arg of fetchT() instead. Called from: ${new Error().stack.split('\n').slice(2, 4).join(' <- ')}\n`);
const { timeout: _timeout, ...rest } = opts;
opts = rest;
}
+9 -1
View File
@@ -59,9 +59,17 @@ function noContent(res) {
* @param {number} statusCode HTTP status code
* @param {string} message Human-readable error message
* @param {object} [extras={}] additional fields to merge into the response
*
* DC-086: If extras.code is set, it's treated as a machine-readable error code
* (e.g. 'DC-CONT-002'). If message looks like a DC code, it's auto-extracted.
*/
function errorResponse(res, statusCode, message, extras = {}) {
return res.status(statusCode).json({ success: false, error: message, ...extras });
const body = { success: false, error: message, ...extras };
// DC-086: surface machine-readable code at top level for client handling
if (extras.code) {
body.code = extras.code;
}
return res.status(statusCode).json(body);
}
/**
+259
View File
@@ -0,0 +1,259 @@
/**
* DC-076: WebSocket server for real-time dashboard updates
*
* Runs alongside the existing SSE endpoint (/api/v1/events/stream).
* Shares the same event broadcasts but over a bidirectional WebSocket
* connection, enabling clientserver commands (e.g. "subscribe to
* container X", "set alert threshold").
*
* Protocol: JSON messages with {type, data} envelope.
* Serverclient: {type: 'event', event: '<name>', data: {...}}
* Clientserver: {type: 'subscribe', events: ['resource-alert', ...]}
* {type: 'ping'} {type: 'pong'}
*/
const { WebSocketServer } = require('ws');
function createDashboardWS(server, deps = {}) {
const wss = new WebSocketServer({ noServer: true });
// Event broadcasters that the events.js SSE route already wires up.
// We listen to the same EventEmitters and forward to WS clients.
const {
resourceMonitor,
healthChecker,
updateManager,
dependencyManager,
autoRestartManager,
driftDetector,
sslMonitor,
dnsPropagationChecker,
log,
} = deps;
// Track connected clients and their subscriptions
const wsClients = new Set();
function broadcast(event, data) {
const msg = JSON.stringify({ type: 'event', event, data });
for (const client of wsClients) {
if (client.readyState !== 1) continue; // OPEN only
// Check subscription filter
if (client.subscribedEvents && !client.subscribedEvents.has(event)) continue;
try {
client.send(msg);
} catch {
wsClients.delete(client);
}
}
}
// ── Wire up EventEmitter listeners (same events as SSE) ──
if (resourceMonitor) {
resourceMonitor.on('alert', (data) => broadcast('resource-alert', data));
resourceMonitor.on('auto-restart', (data) => broadcast('auto-restart', data));
}
if (healthChecker) {
healthChecker.on('status-check', (data) => {
broadcast('status-change', {
serviceId: data.serviceId,
name: data.name,
status: data.status,
responseTime: data.responseTime,
timestamp: data.timestamp,
});
});
healthChecker.on('incident-created', (data) => broadcast('incident', { type: 'created', ...data }));
healthChecker.on('incident-resolved', (data) => broadcast('incident', { type: 'resolved', ...data }));
}
if (updateManager) {
updateManager.on('update-available', (data) => broadcast('update-available', data));
updateManager.on('update-start', (data) => broadcast('update-start', data));
updateManager.on('update-complete', (data) => broadcast('update-complete', data));
updateManager.on('update-failed', (data) => broadcast('update-failed', data));
updateManager.on('auto-update-start', (data) => broadcast('auto-update-start', data));
updateManager.on('auto-update-complete', (data) => broadcast('auto-update-complete', data));
}
if (dependencyManager) {
dependencyManager.on('dependency-restart-start', (data) => broadcast('dependency-restart-start', data));
dependencyManager.on('dependency-restart-progress', (data) => broadcast('dependency-restart-progress', data));
dependencyManager.on('dependency-restart-complete', (data) => broadcast('dependency-restart-complete', data));
dependencyManager.on('dependency-restart-failed', (data) => broadcast('dependency-restart-failed', data));
}
if (autoRestartManager) {
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
}
if (driftDetector) {
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
}
if (sslMonitor) {
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
}
if (dnsPropagationChecker) {
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
}
// ── Handle upgrade requests at /api/v1/ws ──
server.on('upgrade', (request, socket, head) => {
const url = new URL(request.url, 'http://localhost');
// Only handle exact /api/v1/ws path — the exec WS handler manages its own path
if (url.pathname !== '/api/v1/ws' && url.pathname !== '/ws/dashboard') {
return; // Let other upgrade handlers deal with it
}
// DC-076: Auth check — extract session/token from query params or cookies
// The SSE endpoint is behind auth middleware; WS needs the same gate.
// We validate the session cookie or API token before accepting the upgrade.
const cookies = (request.headers.cookie || '');
const hasSession = cookies.includes('dashcaddy_session') || cookies.includes('sid');
const token = url.searchParams.get('token');
const hasToken = token && token.length > 10;
if (!hasSession && !hasToken && process.env.NODE_ENV === 'production') {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
});
// ── Connection handler ──
wss.on('connection', (ws, req) => {
ws.subscribedEvents = null; // null = receive all events
wsClients.add(ws);
if (log) {
log.info('websocket', 'Client connected', { total: wsClients.size });
}
// Send welcome message
ws.send(JSON.stringify({
type: 'connected',
data: { clients: wsClients.size },
}));
// Heartbeat every 30s
ws.isAlive = true;
const heartbeat = setInterval(() => {
if (ws.readyState !== 1) {
clearInterval(heartbeat);
return;
}
ws.isAlive = false;
try {
ws.ping();
} catch {
clearInterval(heartbeat);
wsClients.delete(ws);
}
}, 30000);
ws.on('pong', () => { ws.isAlive = true; });
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw.toString());
} catch {
ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
return;
}
switch (msg.type) {
case 'subscribe':
if (Array.isArray(msg.events)) {
ws.subscribedEvents = new Set(msg.events);
ws.send(JSON.stringify({ type: 'subscribed', events: msg.events }));
}
break;
case 'unsubscribe':
// Actually unsubscribe — set to empty set so no events are received
ws.subscribedEvents = new Set();
ws.send(JSON.stringify({ type: 'unsubscribed' }));
break;
case 'subscribe-all':
// Reset to receive ALL events
ws.subscribedEvents = null;
ws.send(JSON.stringify({ type: 'subscribed-all' }));
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
case 'client-count':
ws.send(JSON.stringify({ type: 'client-count', count: wsClients.size }));
break;
default:
// Unknown message — ignore silently
break;
}
});
ws.on('close', () => {
clearInterval(heartbeat);
wsClients.delete(ws);
if (log) {
log.info('websocket', 'Client disconnected', { total: wsClients.size });
}
});
ws.on('error', () => {
clearInterval(heartbeat);
wsClients.delete(ws);
});
});
// Periodic sweep for dead connections
const sweepInterval = setInterval(() => {
for (const ws of wss.clients) {
if (!ws.isAlive) {
ws.terminate();
wsClients.delete(ws);
}
}
}, 60000);
sweepInterval.unref();
return {
wss,
getClientCount: () => wsClients.size,
broadcast,
close: () => {
clearInterval(sweepInterval);
for (const ws of wss.clients) {
ws.terminate();
}
wsClients.clear();
wss.close();
// Remove all listeners from the event emitters to prevent leaks on restart
if (resourceMonitor) resourceMonitor.removeAllListeners();
if (healthChecker) healthChecker.removeAllListeners();
if (updateManager) updateManager.removeAllListeners();
},
};
}
module.exports = createDashboardWS;