[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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 client→server commands (e.g. "subscribe to
|
||||
* container X", "set alert threshold").
|
||||
*
|
||||
* Protocol: JSON messages with {type, data} envelope.
|
||||
* Server→client: {type: 'event', event: '<name>', data: {...}}
|
||||
* Client→server: {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;
|
||||
Reference in New Issue
Block a user