Merge krystie-improvements into main

Resolves 24 conflicts between Hermes (DC-008/009/010 + response-helper
envelope standardization) and Krystie (DC-005 src/ refactor path fixes,
DC-006 TOTP integration, DC-007 new test suites, cloud backup
destinations).

Conflict resolutions:
- src/utils/logging.js:    took ours (consumers depend on logError/
                            safeErrorMessage/createLogger exports)
- src/config/site.js:      merged (her factored validateAndLogConfig +
                            applyConfigFields helpers)
- src/context/dns.js:      took hers (admin/readonly role iteration for
                            write operations)
- src/utilities/backup-
  manager.js:              took hers (Dropbox/WebDAV/SFTP cloud feature)
- status/dist/*, status/
  sw.js:                   took hers (minified bundles + newer SW cache)

Additional fix (post-merge regression):
- src/monitoring/health-checker.js: fixed DC-005 path miss —
  'require(./platform-paths)' → 'require(../../platform-paths)'

Test status: 921/922 passing. One known failure in logging.test.js
(async file-handle timing) tracked as follow-up.
This commit is contained in:
Hermes
2026-06-25 16:43:10 -07:00
171 changed files with 11759 additions and 1006 deletions
+263 -51
View File
@@ -17,40 +17,41 @@ const { errorResponse, ok } = require('./utils/responses');
const { asyncHandler } = require('./utils/async-handler');
// Managers and utilities
const StateManager = require('../state-manager');
const { LicenseManager } = require('../license-manager');
const credentialManager = require('../credential-manager');
const authManager = require('../auth-manager');
const dockerSecurity = require('../docker-security');
const auditLogger = require('../audit-logger');
const portLockManager = require('../port-lock-manager');
const resourceMonitor = require('../resource-monitor');
const backupManager = require('../backup-manager');
const healthChecker = require('../health-checker');
const updateManager = require('../update-manager');
const selfUpdater = require('../self-updater');
const configureMiddleware = require('../middleware');
const { syncHealthCheckerServices } = require('../startup-validator');
const { CSRF_HEADER_NAME } = require('../csrf-protection');
const { resolveServiceUrl } = require('../url-resolver');
const metrics = require('../metrics');
const { validateURL } = require('../input-validator');
const StateManager = require('managers/state-manager');
const platformPaths = require('../platform-paths');
const { LicenseManager } = require('managers/license-manager');
const credentialManager = require('managers/credential-manager');
const authManager = require('managers/auth-manager');
const dockerSecurity = require('security/docker-security');
const auditLogger = require('security/audit-logger');
const portLockManager = require('managers/port-lock-manager');
const resourceMonitor = require('managers/resource-monitor');
const backupManager = require('utilities/backup-manager');
const healthChecker = require('monitoring/health-checker');
const updateManager = require('managers/update-manager');
const selfUpdater = require('docker/self-updater');
const configureMiddleware = require('utilities/middleware');
const { validateStartupConfig: _validateStartupConfig, syncHealthCheckerServices } = require('utilities/startup-validator');
const { CSRF_HEADER_NAME } = require('security/csrf-protection');
const { resolveServiceUrl } = require('utilities/url-resolver');
const metrics = require('monitoring/metrics');
const { validateURL } = require('security/input-validator');
// Optional modules
let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
try { dockerMaintenance = require('docker/docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('security/log-digest'); } catch (_) { /* optional module */ }
// Workflow engine (bundled workflows)
let bundledWorkflowsModule;
let workflowEngine = null;
try {
bundledWorkflowsModule = require('../bundled-workflows');
bundledWorkflowsModule = require('recipes/bundled-workflows');
} catch (_) { /* optional module */ }
// Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('docker/app-templates');
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('recipes/recipe-templates');
// Route modules
const healthRoutes = require('../routes/health');
@@ -79,16 +80,39 @@ const themesRoutes = require('../routes/themes');
const dockerResourcesRoutes = require('../routes/docker-resources');
const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
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 { AutoRestartManager } = require('managers/auto-restart-manager');
const { ConfigDriftDetector } = require('managers/config-drift-detector');
const SSLMonitor = require('monitoring/ssl-monitor');
const DNSPropagationChecker = require('dns/dns-propagation');
// Constants
const { APP } = require('../constants');
const { APP } = require('utilities/constants');
/**
* Create and configure the Express application
*/
function createApp() {
// eslint-disable-next-line require-await -- kept async for API consistency with other factory functions
async function createApp() {
const app = express();
// Global request timeout (default 5 minutes — covers slow Docker pulls)
// Routes that need longer can override per-request with req.setTimeout()
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
app.use((req, res, next) => {
req.setTimeout(REQUEST_TIMEOUT_MS);
res.setTimeout(REQUEST_TIMEOUT_MS);
next();
});
// Disable x-powered-by header for security (don't advertise framework)
app.disable('x-powered-by');
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
app.set('trust proxy', 1);
// Initialize logging
const log = createLogger(config.LOG_LEVEL);
@@ -104,7 +128,7 @@ function createApp() {
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
// HTTPS agent for internal CA
const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt';
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
let httpsAgent;
try {
const caCert = fs.readFileSync(CA_CERT_PATH);
@@ -161,7 +185,26 @@ function createApp() {
return first === 100 && second >= 64 && second <= 127;
}
function getTailscaleStatus() {
function isPrivateLan(ip) {
if (!ip) return false;
if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true;
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip);
}
function collectNetworkInterfaces(osModule) {
const out = [];
const interfaces = osModule.networkInterfaces();
for (const [name, addrs] of Object.entries(interfaces)) {
for (const addr of addrs) {
if (addr.internal || addr.family !== 'IPv4') continue;
out.push({ name, ip: addr.address });
}
}
return out;
}
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
async function getTailscaleStatus() {
// Stub for now - will be populated by context
return null;
}
@@ -190,15 +233,15 @@ function createApp() {
auditLogger,
authManager,
log,
cryptoUtils: require('../crypto-utils'),
cryptoUtils: require('security/crypto-utils'),
isValidContainerId,
isTailscaleIP,
getTailscaleStatus,
RATE_LIMITS: require('../constants').RATE_LIMITS,
LIMITS: require('../constants').LIMITS,
APP: require('../constants').APP,
CACHE_CONFIGS: require('../cache-config').CACHE_CONFIGS,
createCache: require('../cache-config').createCache,
RATE_LIMITS: require('utilities/constants').RATE_LIMITS,
LIMITS: require('utilities/constants').LIMITS,
APP: require('utilities/constants').APP,
CACHE_CONFIGS: require('utilities/cache-config').CACHE_CONFIGS,
createCache: require('utilities/cache-config').createCache,
});
const { strictLimiter } = middlewareResult;
@@ -209,9 +252,10 @@ function createApp() {
return services.find(s => s.id === serviceId) || null;
}
// eslint-disable-next-line require-await -- may grow awaits as config loading evolves
async function readConfig() {
const { readJsonFile } = require('../fs-helpers');
return await readJsonFile(config.CONFIG_FILE, {});
const { readJsonFile } = require('utilities/fs-helpers');
return readJsonFile(config.CONFIG_FILE, {});
}
async function saveConfig(updates) {
@@ -233,7 +277,7 @@ function createApp() {
async function saveTotpConfig() {
try {
const { writeJsonFile } = require('../fs-helpers');
const { writeJsonFile } = require('utilities/fs-helpers');
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
} catch (e) {
log.error('config', 'Could not save TOTP config', { error: e.message });
@@ -356,9 +400,64 @@ function createApp() {
}
}
// Initialize dependency manager
const dependencyManager = new DependencyManager({
servicesStateManager,
docker: ctx.docker,
notification: ctx.notification,
log,
});
ctx.dependencyManager = dependencyManager;
log.info('app', 'Dependency manager initialized');
// Initialize auto-restart manager
const autoRestartManager = new AutoRestartManager(ctx);
ctx.autoRestartManager = autoRestartManager;
autoRestartManager.start();
log.info('app', 'Auto-restart manager initialized');
// Initialize config drift detector
const driftDetector = new ConfigDriftDetector(ctx);
ctx.driftDetector = driftDetector;
driftDetector.startPolling(300000); // 5 min
log.info('app', 'Config drift detector initialized');
// Initialize SSL monitor
const sslMonitor = new SSLMonitor(ctx);
ctx.sslMonitor = sslMonitor;
sslMonitor.start(3600000); // 1 hour
log.info('app', 'SSL monitor initialized');
// Initialize DNS propagation checker
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
ctx.dnsPropagationChecker = dnsPropagationChecker;
log.info('app', 'DNS propagation checker initialized');
// Build versioned API router
const apiRouter = express.Router();
// Version endpoint — public, no auth required
// Reads version from package.json at startup so the response always matches the running code
let appVersion = '0.0.0';
let appName = 'dashcaddy-api';
try {
const pkg = require('../package.json');
appVersion = pkg.version || appVersion;
appName = pkg.name || appName;
} catch { /* package.json unreadable — keep fallback */ }
apiRouter.get('/version', (req, res) => {
ok(res, {
name: appName,
version: appVersion,
node: process.version,
platform: process.platform,
arch: process.arch,
uptime: process.uptime(),
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
});
});
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
// Wire up notification listeners for resourceMonitor and backupManager
if (ctx.notification && ctx.resourceMonitor) {
ctx.resourceMonitor.on('alert', (alertData) => {
@@ -396,7 +495,8 @@ function createApp() {
log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage,
fetchT: ctx.fetchT,
credentialManager: ctx.credentialManager
credentialManager: ctx.credentialManager,
dnsPropagationChecker: ctx.dnsPropagationChecker
}));
apiRouter.use('/notifications', notificationRoutes({
notification: ctx.notification,
@@ -516,26 +616,55 @@ function createApp() {
healthChecker: ctx.healthChecker,
updateManager: ctx.updateManager,
logError: ctx.logError,
ok: ctx.ok
ok: ctx.ok,
dependencyManager: ctx.dependencyManager,
autoRestartManager: ctx.autoRestartManager,
driftDetector: ctx.driftDetector,
sslMonitor: ctx.sslMonitor,
dnsPropagationChecker: ctx.dnsPropagationChecker
}));
apiRouter.use(workflowsRoutes({
apiRouter.use('/workflows', workflowsRoutes({
workflowEngine: ctx.workflowEngine,
licenseManager: ctx.licenseManager,
asyncHandler: ctx.asyncHandler,
ok: ctx.ok
}));
apiRouter.use('/dependencies', dependenciesRoutes({
dependencyManager: ctx.dependencyManager,
servicesStateManager: ctx.servicesStateManager,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
resyncHealthChecker: ctx.resyncHealthChecker,
log: ctx.log,
}));
apiRouter.use(autoRestartRoutes({
autoRestartManager: ctx.autoRestartManager,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(configDriftRoutes({
driftDetector: ctx.driftDetector,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
apiRouter.use(sslMonitorRoutes({
sslMonitor: ctx.sslMonitor,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
}));
// Inline API routes
apiRouter.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
});
apiRouter.get('/csrf-token', (req, res) => {
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
});
apiRouter.get('/metrics', (req, res) => {
res.json({ success: true, metrics: metrics.getSummary() });
ok(res, { metrics: metrics.getSummary() });
});
// Mount at /api/v1 (canonical, single version)
@@ -543,13 +672,93 @@ function createApp() {
// Root-level health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
ok(res, { status: 'ok', timestamp: new Date().toISOString() });
});
// Liveness probe — "is the process alive?"
// Always returns 200 unless the Node.js event loop is completely blocked.
// Used by k8s/Docker to decide whether to RESTART the container.
// DO NOT add dependency checks here — those belong in /health/ready.
app.get('/health/live', (req, res) => {
ok(res, { status: 'alive', uptime: process.uptime() });
});
// Readiness probe — "is the app ready to serve traffic?"
// Checks critical dependencies: Docker daemon, Caddy admin API, config file.
// Returns 200 with details if all OK, 503 with failed components otherwise.
// Used by k8s/Docker to decide whether to ROUTE TRAFFIC to this instance.
app.get('/health/ready', boundAsyncHandler(async (req, res) => {
const checks = {};
let allOk = true;
// Check 1: Config file readable
try {
const fs = require('fs');
if (fs.existsSync(config.CONFIG_FILE)) {
fs.readFileSync(config.CONFIG_FILE, 'utf8');
checks.configFile = { ok: true };
} else {
checks.configFile = { ok: false, error: 'Config file not found' };
allOk = false;
}
} catch (e) {
checks.configFile = { ok: false, error: e.message };
allOk = false;
}
// Check 2: Services file readable
try {
const fs = require('fs');
if (fs.existsSync(config.SERVICES_FILE)) {
fs.readFileSync(config.SERVICES_FILE, 'utf8');
checks.servicesFile = { ok: true };
} else {
checks.servicesFile = { ok: false, error: 'Services file not found' };
allOk = false;
}
} catch (e) {
checks.servicesFile = { ok: false, error: e.message };
allOk = false;
}
// Check 3: Docker daemon reachable
try {
const docker = require('dockerode')();
await docker.ping();
checks.docker = { ok: true };
} catch (e) {
checks.docker = { ok: false, error: e.message };
allOk = false;
}
// Check 4: Caddy admin API reachable
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 3000);
const response = await fetch(`${caddyUrl}/config/`, {
signal: controller.signal
});
clearTimeout(timeout);
checks.caddy = { ok: response.ok, status: response.status };
if (!response.ok) allOk = false;
} catch (e) {
checks.caddy = { ok: false, error: e.message };
allOk = false;
}
const body = {
status: allOk ? 'ready' : 'not-ready',
timestamp: new Date().toISOString(),
checks
};
ok(res, body, allOk ? 200 : 503);
}));
// Lightweight probe endpoint
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
const id = req.params.id;
const { exists } = require('../fs-helpers');
const { exists } = require('utilities/fs-helpers');
let service = null;
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
@@ -676,13 +885,16 @@ function createApp() {
};
if (!envLan || !envTailscale) {
const detected = detectInterfaceIps();
if (!result.lan) result.lan = detected.lan;
if (!result.tailscale) result.tailscale = detected.tailscale;
result.all = detected.all;
result.all = collectNetworkInterfaces(os);
if (!result.tailscale) {
result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null;
}
if (!result.lan) {
result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null;
}
}
res.json(result);
ok(res, result);
} catch (error) {
errorResponse(res, 500, safeErrorMessage(error));
}
@@ -709,7 +921,7 @@ function createApp() {
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
const path = require('path');
const { exists } = require('../fs-helpers');
const { exists } = require('utilities/fs-helpers');
const fsp = require('fs').promises;
const specPath = path.join(__dirname, '../openapi.yaml');
@@ -722,7 +934,7 @@ function createApp() {
}, 'api-docs-spec'));
// Error handlers (MUST be last)
const { notFoundHandler, errorMiddleware } = require('../error-handler');
const { notFoundHandler, errorMiddleware } = require('utilities/error-handler');
app.use('/api', notFoundHandler);
app.use(errorMiddleware);
+1 -1
View File
@@ -4,7 +4,7 @@
*/
const paths = require('./paths');
const site = require('./site');
const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../../constants');
const { APP, LIMITS, TIMEOUTS, RETRIES, CADDY } = require('../utilities/constants');
// Load logging level
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
+142
View File
@@ -0,0 +1,142 @@
/**
* Config migration system
*
* When config.json schema changes between versions, register a migration
* function here. On load, the loader detects the stored version, runs all
* migrations from that version forward, and writes the result back.
*
* Migration format:
* migrations[<toVersion>] = (rawConfig) => { ...mutations, _version: toVersion }
*
* Each migration is responsible for transforming the previous version's
* shape into the next version's shape. They run sequentially, so v1→v2→v3
* all execute in order.
*
* For first-time users with no config file, the loader creates a fresh
* config with CURRENT_VERSION, so they start at the latest schema.
*/
const fs = require('fs');
const path = require('path');
const _platformPaths = require('../../platform-paths');
const CURRENT_VERSION = 2;
/**
* Migrations: keys are the version they PRODUCE.
* Each migration takes a raw config object and returns the next version.
*/
const migrations = {
// v0 (unversioned) → v1: add _version field, normalize dns structure
1: (raw) => {
const migrated = { ...raw };
if (!migrated._version) migrated._version = 1;
// Normalize: older configs may have dns as a string IP, convert to object
if (typeof migrated.dns === 'string') {
migrated.dns = { ip: migrated.dns, port: 5380 };
} else if (!migrated.dns) {
migrated.dns = { ip: '', port: 5380 };
}
return migrated;
},
// v1 → v2: add dns.provider field (default: 'technitium' for backwards compat)
2: (raw) => {
const migrated = { ...raw };
if (migrated.dns && !migrated.dns.provider) {
migrated.dns.provider = 'technitium';
}
migrated._version = 2;
return migrated;
}
};
/**
* Run all migrations from `fromVersion` (or detected) to CURRENT_VERSION.
* @param {object} raw - The raw config object (may or may not have _version)
* @returns {object} The migrated config
*/
function migrate(raw) {
if (!raw || typeof raw !== 'object') {
// First-time load: return minimal config at current version
return { _version: CURRENT_VERSION };
}
const fromVersion = raw._version || 0;
if (fromVersion > CURRENT_VERSION) {
// Config from a future version — bail out, don't corrupt it
// The validation step will catch any actual issues
return raw;
}
let current = { ...raw };
for (let v = fromVersion + 1; v <= CURRENT_VERSION; v++) {
if (migrations[v]) {
current = migrations[v](current);
} else {
// No migration defined for this version, just bump _version
current._version = v;
}
}
return current;
}
/**
* Load config from disk, run migrations if needed, and write back the
* migrated version. Safe to call on every startup.
* @param {string} configFile - Absolute path to config.json
* @param {object} log - Logger instance
* @returns {object} The migrated config object
*/
function loadAndMigrate(configFile, log) {
let raw = null;
let fileExisted = false;
if (fs.existsSync(configFile)) {
fileExisted = true;
try {
raw = JSON.parse(fs.readFileSync(configFile, 'utf8'));
} catch (e) {
if (log && log.error) {
log.error('config-migration', 'Failed to parse config.json, using defaults', { error: e.message });
}
raw = null;
}
}
const fromVersion = raw && raw._version ? raw._version : 0;
const migrated = migrate(raw);
// Only write back to disk if:
// 1. The file already existed (we don't create configs on fresh installs —
// the loader's defaults handle that case), AND
// 2. The version actually changed (no point rewriting identical content)
if (fileExisted && fromVersion < CURRENT_VERSION) {
if (log && log.info) {
log.info('config-migration', `Migrated config v${fromVersion} → v${CURRENT_VERSION}`, {
from: fromVersion,
to: CURRENT_VERSION,
path: configFile
});
}
// Write back the migrated config
try {
// Ensure parent dir exists
const dir = path.dirname(configFile);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(configFile, JSON.stringify(migrated, null, 2));
} catch (e) {
if (log && log.warn) {
log.warn('config-migration', 'Failed to write migrated config back to disk', { error: e.message });
}
}
}
return migrated;
}
module.exports = {
CURRENT_VERSION,
migrations,
migrate,
loadAndMigrate
};
+27 -18
View File
@@ -1,10 +1,15 @@
/**
* Site configuration loader
* Loads and manages site-wide settings from config.json
*
* Includes automatic migration from older config versions (see migrations.js).
* Users never see the migration — it runs silently on startup, writes the
* updated config back, and the rest of the app only ever sees the current
* schema.
*/
const fs = require('fs');
const { validateConfig } = require('../../config-schema');
const { CADDY } = require('../../constants');
const { validateConfig } = require('../utilities/config-schema');
const { CADDY } = require('../utilities/constants');
const { loadAndMigrate, CURRENT_VERSION } = require('./migrations');
const siteConfig = {
tld: '.home',
@@ -19,7 +24,7 @@ const siteConfig = {
routingMode: 'subdomain'
};
function applyRawConfig(raw) {
function applyConfigFields(raw) {
siteConfig.tld = raw.tld || '.home';
if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld;
siteConfig.caName = raw.caName || '';
@@ -33,24 +38,27 @@ function applyRawConfig(raw) {
siteConfig.routingMode = raw.routingMode || 'subdomain';
siteConfig.pylon = raw.pylon || null;
}
function validateAndLogConfig(raw, log) {
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
if (log && log.warn) {
if (!valid) {
log.warn('config', 'Config validation errors', { errors: configErrors });
}
for (const w of configWarnings) {
log.warn('config', w);
}
}
}
function loadSiteConfig(CONFIG_FILE, log) {
try {
if (fs.existsSync(CONFIG_FILE)) {
const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
// Run migrations first — this handles config.json files from older
// versions of DashCaddy and writes the migrated version back to disk.
const raw = loadAndMigrate(CONFIG_FILE, log);
// Validate config and log any issues
const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw);
if (log && log.warn) {
if (!valid) {
log.warn('config', 'Config validation errors', { errors: configErrors });
}
for (const w of configWarnings) {
log.warn('config', w);
}
}
applyRawConfig(raw);
if (raw && Object.keys(raw).length > 0) {
validateAndLogConfig(raw, log);
applyConfigFields(raw);
}
} catch (e) {
if (log && log.error) {
@@ -80,4 +88,5 @@ module.exports = {
loadSiteConfig,
buildDomain,
buildServiceUrl,
CURRENT_VERSION
};
+4 -4
View File
@@ -2,7 +2,7 @@
* Caddy context - Caddyfile manipulation and reload
*/
const fsp = require('fs').promises;
const { RETRIES } = require('../../constants');
const { RETRIES } = require('../utilities/constants');
/**
* Atomically read-modify-write the Caddyfile and reload Caddy.
@@ -43,6 +43,7 @@ async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) {
/**
* Read the current Caddyfile content
*/
// eslint-disable-next-line require-await -- fsp.readFile already returns a promise
async function readCaddyfile(CADDYFILE_PATH) {
return await fsp.readFile(CADDYFILE_PATH, 'utf8');
}
@@ -93,9 +94,8 @@ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts
try {
const response = await fetchT(`https://${domain}/`, {
method: 'HEAD',
agent: httpsAgent,
timeout: 5000
});
agent: httpsAgent
}, 5000);
log.info('caddy', 'Site is accessible', { domain, status: response.status });
return true;
+45 -7
View File
@@ -1,8 +1,14 @@
/**
* DNS context - Technitium DNS operations and token management
*
* DEPRECATED: This module is kept for backward compatibility.
* New code should use src/context/provider-dns.js which supports multiple providers.
*
* This module now delegates to the provider system internally.
*/
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../utilities/constants');
const { createCache, CACHE_CONFIGS } = require('../utilities/cache-config');
const { createProviderDnsContext } = require('./provider-dns');
// DNS token management
let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
@@ -52,9 +58,9 @@ async function refreshDnsToken(username, password, server, fetchT, log) {
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
},
timeout: 10000
}
}
},
10000
);
const result = await response.json();
@@ -95,6 +101,20 @@ async function refreshWithPerServerCredentials(dnsId, serverIp, credentialManage
/**
* Ensure we have a valid DNS token (auto-refresh if needed)
*/
async function tryCredentialPair(dnsId, role, primaryIp, siteConfig, credentialManager, fetchT, log) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
if (username && password) {
return await refreshDnsToken(username, password, primaryIp, fetchT, log);
}
return null;
} catch (err) {
log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message });
return null;
}
}
async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
// Check if token is valid and not expired
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
@@ -105,8 +125,10 @@ async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) {
if (primaryIp) {
const dnsId = dnsIpToDnsId(primaryIp, siteConfig);
if (dnsId) {
const result = await refreshWithPerServerCredentials(dnsId, primaryIp, credentialManager, fetchT, log);
if (result) return result;
for (const role of ['admin', 'readonly']) {
const result = await tryCredentialPair(dnsId, role, primaryIp, siteConfig, credentialManager, fetchT, log);
if (result) return result;
}
}
}
@@ -291,6 +313,10 @@ function invalidateTokenForServer(serverIp) {
}
function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
// Create the new provider-aware context
const providerCtx = createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
// Legacy Technitium-specific wrappers (kept for backward compat)
const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
@@ -299,6 +325,7 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
return {
// Legacy Technitium-specific interface (unchanged)
call,
buildUrl: buildDnsUrl,
requireToken: require,
@@ -312,6 +339,17 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
invalidateTokenForServer,
refresh,
credentialsFile: DNS_CREDENTIALS_FILE,
// Provider-aware methods (new)
getProviderId: providerCtx.getProviderId,
getActiveProvider: providerCtx.getActiveProvider,
getAvailableProviders: providerCtx.getAvailableProviders,
supportsCapability: providerCtx.supportsCapability,
// Universal DNS helpers (delegated to provider context)
universalCreateRecord: providerCtx.universalCreateRecord,
universalDeleteRecord: providerCtx.universalDeleteRecord,
universalResolveRecord: providerCtx.universalResolveRecord,
};
}
+1 -1
View File
@@ -2,7 +2,7 @@
* Docker context - Docker client and operations
*/
const Docker = require('dockerode');
const { DOCKER } = require('../../constants');
const { DOCKER } = require('../utilities/constants');
const docker = new Docker();
+1 -1
View File
@@ -6,7 +6,7 @@ const { createDockerContext } = require('./docker');
const { createCaddyContext } = require('./caddy');
const { createDnsContext } = require('./dns');
const { createSessionContext } = require('./session');
const NotificationManager = require('../../notification-manager');
const NotificationManager = require('../managers/notification-manager');
/**
* Assemble the full application context
+310
View File
@@ -0,0 +1,310 @@
/**
* Provider-aware DNS Context
* Replaces the Technitium-only context with a provider-agnostic layer.
* Delegates to the active DNS provider adapter based on config.
*
* Falls back to legacy Technitium context for backward compatibility
* when no provider is explicitly configured.
*/
const { createCache, CACHE_CONFIGS } = require('../utilities/cache-config');
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../utilities/constants');
const registry = require('../../dns-providers/registry');
// Per-server token cache (legacy Technitium)
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens);
let dnsToken = '';
let dnsTokenExpiry = null;
/**
* Create a provider-aware DNS context.
* This wraps both the new provider system and the legacy Technitium context
* for seamless migration.
*/
function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) {
/** Resolve the active provider from config */
function getProviderId() {
// New explicit provider field
if (siteConfig.dns?.provider) return siteConfig.dns.provider;
// Legacy: if dns.ip is set, default to technitium
if (siteConfig.dnsServerIp || siteConfig.dns?.ip) return 'technitium';
// No DNS configured
return 'manual';
}
/** Get provider-specific config from site config */
function getProviderConfig(providerId) {
const dnsConfig = siteConfig.dns || {};
const builders = {
technitium: () => ({
serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '',
serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380',
dnsServers: siteConfig.dnsServers || {},
dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1'
}),
cloudflare: () => ({
apiToken: dnsConfig.apiToken || '',
zoneId: dnsConfig.zoneId || '',
domain: siteConfig.domain || ''
}),
rfc2136: () => ({
server: dnsConfig.server || siteConfig.dnsServerIp || '',
port: dnsConfig.port || 53,
zone: siteConfig.tld?.replace(/^\./, '') || '',
tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256',
tsigKeyName: dnsConfig.tsigKeyName || '',
tsigSecret: dnsConfig.tsigSecret || ''
}),
manual: () => ({})
};
const builder = builders[providerId];
return builder ? builder() : dnsConfig;
}
/** Get or create the active provider adapter */
function getActiveProvider() {
const providerId = getProviderId();
const config = getProviderConfig(providerId);
const ctx = { log, credentialManager, fetchT, httpsAgent };
return registry.getProvider(providerId, config, ctx);
}
// ===== Legacy Technitium helpers (kept for backward compat) =====
function buildDnsUrl(server, apiPath, params) {
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : '';
const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString();
return `${protocol}://${server}${port}${apiPath}?${qs}`;
}
async function callDns(server, apiPath, params) {
const url = buildDnsUrl(server, apiPath, params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' },
agent: httpsAgent
}, TIMEOUTS.HTTP_LONG);
return response.json();
}
async function refreshDnsToken(username, password, server) {
try {
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
const response = await fetchT(
`http://${server}:5380/api/user/login?${params.toString()}`,
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } },
10000
);
const result = await response.json();
if (result.status === 'ok' && result.token) {
dnsToken = result.token;
dnsTokenExpiry = new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString();
log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
return { success: true, token: dnsToken };
}
return { success: false, error: result.errorMessage || 'Login failed' };
} catch (error) {
log.error('dns', 'DNS token refresh error', { error: error.message });
return { success: false, error: error.message };
}
}
function dnsIpToDnsId(serverIp) {
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
if (info.ip === serverIp) return dnsId;
}
return null;
}
async function tryServerRoleCredentials(dnsId, role, primaryIp) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
if (username && password) return await refreshDnsToken(username, password, primaryIp);
} catch (err) { /* try next */ }
return null;
}
async function tryGlobalCredentials(primaryIp) {
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
const server = await credentialManager.retrieve('dns.server');
if (username && password) return await refreshDnsToken(username, password, server || primaryIp);
} catch (err) { /* no global creds */ }
return null;
}
async function ensureValidDnsToken() {
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
return { success: true, token: dnsToken };
}
const primaryIp = siteConfig.dnsServerIp;
if (primaryIp) {
const dnsId = dnsIpToDnsId(primaryIp);
if (dnsId) {
for (const role of ['admin', 'readonly']) {
const result = await tryServerRoleCredentials(dnsId, role, primaryIp);
if (result) return result;
}
}
}
const globalResult = await tryGlobalCredentials(primaryIp);
if (globalResult) return globalResult;
return { success: false, error: 'No DNS credentials configured' };
}
async function getTokenForServer(targetServer, role = 'readonly') {
const cacheKey = `${targetServer}:${role}`;
const cached = dnsServerTokens.get(cacheKey);
if (cached?.token && cached?.expiry && new Date() < new Date(cached.expiry)) {
return { success: true, token: cached.token };
}
const serverPort = siteConfig.dnsServerPort || '5380';
async function authToServer(username, password) {
const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' });
const response = await fetchT(
`http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`,
{ method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } }
);
const result = await response.json();
if (result.status === 'ok' && result.token) {
dnsServerTokens.set(cacheKey, { token: result.token, expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() });
log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
return { success: true, token: result.token };
}
return { success: false, error: result.errorMessage || 'Login failed' };
}
const dnsId = dnsIpToDnsId(targetServer);
if (dnsId) {
for (const r of [role, role === 'readonly' ? 'admin' : 'readonly']) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${r}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${r}.password`);
if (username && password) return await authToServer(username, password);
} catch { /* try next */ }
}
}
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
if (username && password) return await authToServer(username, password);
} catch { /* no global creds */ }
return { success: false, error: 'No DNS credentials configured' };
}
async function requireDnsToken(providedToken) {
if (providedToken) return providedToken;
const result = await ensureValidDnsToken();
if (result.success) return result.token;
const err = new Error('No valid DNS token available. ' + result.error);
err.statusCode = 401;
throw err;
}
function invalidateTokenForServer(serverIp) {
dnsServerTokens.delete(`${serverIp}:readonly`);
dnsServerTokens.delete(`${serverIp}:admin`);
}
// ===== Public context API =====
// This maintains the same interface as the old createDnsContext()
// but adds provider-aware methods on top.
return {
// --- Provider-aware methods ---
/** Get the active provider ID */
getProviderId,
/** Get the active provider adapter instance */
getActiveProvider,
/** Get metadata for all available providers */
getAvailableProviders: () => registry.getProviderMeta(),
/** Check if the active provider supports a capability */
supportsCapability: (cap) => {
try { return getActiveProvider().supportsCapability(cap); }
catch { return false; }
},
// --- Legacy Technitium context (backward compat) ---
call: callDns,
buildUrl: buildDnsUrl,
requireToken: requireDnsToken,
ensureToken: ensureValidDnsToken,
getToken: () => dnsToken,
setToken: (t) => { dnsToken = t; },
getTokenExpiry: () => dnsTokenExpiry,
setTokenExpiry: (e) => { dnsTokenExpiry = e; },
getTokenForServer,
invalidateTokenForServer,
refresh: refreshDnsToken,
credentialsFile: DNS_CREDENTIALS_FILE,
// --- Universal DNS helpers (provider-agnostic) ---
/**
* Create a DNS A record using the active provider.
* Gracefully handles manual adapters that return instructions instead of performing the action.
*/
async universalCreateRecord(subdomain, ip) {
const provider = getActiveProvider();
const result = await provider.createRecord({
domain: buildDomain(subdomain),
zone: siteConfig.tld?.replace(/^\./, '') || '',
type: 'A',
value: ip,
ttl: 300,
overwrite: true,
});
// Manual adapter returns instructions instead of performing the action
if (result?.manual || result?.instructions) {
return { success: true, manual: true, instructions: result.instructions || result };
}
return result;
},
/**
* Delete a DNS A record using the active provider.
* Gracefully handles manual adapters that return instructions instead of performing the action.
*/
async universalDeleteRecord(domain, ip) {
const provider = getActiveProvider();
const result = await provider.deleteRecord({
domain,
type: 'A',
value: ip,
});
if (result?.manual || result?.instructions) {
return { success: true, manual: true, instructions: result.instructions || result };
}
return result;
},
/**
* Resolve DNS records using the active provider.
* Returns parsed IP addresses from the result.
*/
async universalResolveRecord(domain, type) {
const provider = getActiveProvider();
const result = await provider.resolveRecords({
domain,
zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A',
});
// Parse IP addresses from the result
if (Array.isArray(result)) {
return result;
}
if (result?.records) {
return result.records.map(r => r.ipAddress || r.value || r.address || r).filter(Boolean);
}
if (result?.ips) {
return result.ips;
}
return result;
},
};
}
module.exports = { createProviderDnsContext };
+273
View File
@@ -0,0 +1,273 @@
/**
* DNS Propagation Checker
* Verifies DNS record propagation by querying multiple resolvers.
* Runs as background jobs with configurable timeout and interval.
*
* @module dns-propagation
*/
const dns = require('dns').promises;
const EventEmitter = require('events');
/** Default verification options */
const DEFAULT_OPTIONS = {
timeout: 300000, // 5 minutes
interval: 10000, // 10 seconds
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
};
/** Maximum age for stored verification results (1 hour) */
const MAX_RESULT_AGE_MS = 3600000;
class DNSPropagationChecker extends EventEmitter {
/**
* Create a DNSPropagationChecker instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} domain → verification status */
this.verifications = new Map();
}
/**
* Verify that a DNS record has propagated by querying multiple resolvers.
* Retries every `interval` ms until `timeout` is reached.
*
* @param {string} domain - The domain to check (e.g., 'test.sami')
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
* @param {number} [options.interval=10000] - Time between retries (ms)
* @param {string[]} [options.resolvers] - DNS resolvers to query
* @returns {Promise<Object>} Verification result
*/
async verifyRecord(domain, expectedIp, options = {}) {
const startTime = Date.now();
const {
timeout = DEFAULT_OPTIONS.timeout,
interval = DEFAULT_OPTIONS.interval,
resolvers = DEFAULT_OPTIONS.resolvers
} = options;
const allResults = [];
let propagated = false;
while (Date.now() - startTime < timeout) {
const roundResults = [];
for (const resolver of resolvers) {
const checkStart = Date.now();
try {
// Use dns.resolve4 with a custom resolver
const resolverInstance = new dns.Resolver();
resolverInstance.setServers([resolver]);
resolverInstance.setTimeout(5000);
const addresses = await resolverInstance.resolve4(domain);
const matched = addresses.includes(expectedIp);
const result = {
resolver,
ips: addresses,
matched,
checkedAt: new Date().toISOString(),
responseTime: Date.now() - checkStart
};
roundResults.push(result);
if (matched) {
propagated = true;
}
} catch (err) {
roundResults.push({
resolver,
ips: [],
matched: false,
checkedAt: new Date().toISOString(),
error: err.code || err.message,
responseTime: Date.now() - checkStart
});
}
}
allResults.push(...roundResults);
// Emit progress event
this.emit('propagation-check', {
domain,
expectedIp,
roundResults,
elapsed: Date.now() - startTime,
propagated
});
if (propagated) {
break;
}
// Wait before next attempt
await new Promise(resolve => setTimeout(resolve, interval));
}
const totalTime = Date.now() - startTime;
return {
domain,
expectedIp,
propagated,
results: allResults,
totalTime,
checkedAt: new Date().toISOString()
};
}
/**
* Start a background DNS propagation verification.
* Does not block — returns immediately with the job reference.
*
* @param {string} domain - The domain to verify
* @param {string} expectedIp - The expected IP address
* @param {Object} [options={}] - Verification options
* @returns {Object} Job status object
*/
startVerification(domain, expectedIp, options = {}) {
// If there's already a running verification for this domain, return it
const existing = this.verifications.get(domain);
if (existing && existing.status === 'running') {
return existing;
}
const job = {
domain,
expectedIp,
status: 'running',
startedAt: new Date().toISOString(),
progress: [],
result: null
};
this.verifications.set(domain, job);
// Run verification in background (non-blocking)
this.verifyRecord(domain, expectedIp, options)
.then(result => {
job.status = 'completed';
job.result = result;
job.completedAt = new Date().toISOString();
if (result.propagated) {
this.emit('propagation-complete', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'success').catch(err => {
this.log.error('dns-propagation', 'Failed to send propagation notification', {
error: err.message
});
});
}
} else {
this.emit('propagation-timeout', result);
if (this.ctx.notification) {
this.ctx.notification.send('dns-propagation', {
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
domain,
expectedIp,
totalTime: result.totalTime
}, 'warning').catch(err => {
this.log.error('dns-propagation', 'Failed to send timeout notification', {
error: err.message
});
});
}
}
})
.catch(err => {
job.status = 'error';
job.error = err.message;
job.completedAt = new Date().toISOString();
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
error: err.message
});
});
return job;
}
/**
* Get the current verification status for a domain.
*
* @param {string} domain - The domain to look up
* @returns {Object|null} Verification status or null if not found
*/
getVerificationStatus(domain) {
const job = this.verifications.get(domain);
if (!job) return null;
return {
domain: job.domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
result: job.result || null,
error: job.error || null
};
}
/**
* Get all recent verifications.
*
* @returns {Object[]} Array of verification statuses
*/
getAllVerifications() {
const results = [];
for (const [domain, job] of this.verifications.entries()) {
results.push({
domain,
expectedIp: job.expectedIp,
status: job.status,
startedAt: job.startedAt,
completedAt: job.completedAt || null,
propagated: job.result?.propagated || null,
totalTime: job.result?.totalTime || null,
error: job.error || null
});
}
return results;
}
/**
* Remove verifications older than 1 hour.
*/
cleanup() {
const now = Date.now();
for (const [domain, job] of this.verifications.entries()) {
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
const startedAt = new Date(job.startedAt).getTime();
// Clean up completed/error jobs older than 1 hour
// Also clean up stale running jobs that started over 2 hours ago
const age = completedAt ? (now - completedAt) : (now - startedAt);
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
if (age > maxAge) {
this.verifications.delete(domain);
}
}
}
}
module.exports = DNSPropagationChecker;
@@ -0,0 +1,69 @@
/**
* Base DNS Provider Adapter
* All DNS provider adapters must extend this class and implement the required methods.
*
* Each adapter handles the specifics of talking to a particular DNS provider's API.
* The routes layer calls these methods generically — no provider-specific logic in routes.
*/
class BaseDNSProvider {
constructor(config, ctx) {
this.config = config; // Provider-specific config (api token, server url, etc.)
this.ctx = ctx; // Shared app context (log, credentialManager, fetchT, etc.)
this.providerId = 'base';
this.displayName = 'Base DNS Provider';
}
/** Check if this provider supports a given capability */
supportsCapability(cap) {
// Capabilities: 'create-record', 'delete-record', 'resolve', 'list-records',
// 'logs', 'restart', 'update-check', 'credentials', 'zones'
return false;
}
/** Authenticate and return a token/session */
async authenticate() { throw new Error('Not implemented'); }
/** Create a DNS record */
async createRecord({ domain, zone, type, value, ttl, overwrite }) { throw new Error('Not implemented'); }
/** Delete a DNS record */
async deleteRecord({ domain, type, value }) { throw new Error('Not implemented'); }
/** Resolve/query existing records for a domain */
async resolveRecords({ domain, zone, type }) { throw new Error('Not implemented'); }
/** List all records in a zone */
async listRecords({ zone }) { throw new Error('Not implemented'); }
/** Get DNS query logs */
async getLogs({ limit, server }) { throw new Error('Not implemented'); }
/** Restart the DNS server */
async restartServer({ server }) { throw new Error('Not implemented'); }
/** Check for DNS server updates */
async checkUpdate({ server }) { throw new Error('Not implemented'); }
/** Get provider status info */
async getStatus() {
return {
providerId: this.providerId,
displayName: this.displayName,
capabilities: this.getCapabilities(),
authenticated: false
};
}
/** Get list of supported capabilities */
getCapabilities() {
return [];
}
/** Validate provider-specific config */
validateConfig() { return { valid: true, errors: [] }; }
/** Clean up resources on shutdown */
async shutdown() {}
}
module.exports = BaseDNSProvider;
@@ -0,0 +1,269 @@
/**
* Cloudflare DNS Provider Adapter
* Manages DNS records via the Cloudflare API v4.
*/
const BaseDNSProvider = require('./base');
const CF_API_BASE = 'https://api.cloudflare.com/client/v4';
class CloudflareDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'cloudflare';
this.displayName = 'Cloudflare DNS';
// Resolve API token: explicit config takes priority, then credential manager
this.apiToken = config.apiToken
|| (ctx.credentialManager && ctx.credentialManager.get('dns.cloudflare.apiToken'))
|| null;
this.zoneId = config.zoneId || null;
this.domain = config.domain || null;
}
// ── Helpers ────────────────────────────────────────────────────────────
/** Build common request headers for Cloudflare API calls */
_headers() {
return {
'Authorization': `Bearer ${this.apiToken}`,
'Content-Type': 'application/json',
};
}
/** Make an authenticated request to the Cloudflare API */
async _cfRequest(method, path, body) {
const url = `${CF_API_BASE}${path}`;
const opts = {
method,
headers: this._headers(),
};
if (body !== undefined) {
opts.body = JSON.stringify(body);
}
return this.ctx.fetchT(url, opts);
}
/** Map a Cloudflare DNS record to the normalised format expected by routes */
_mapRecord(rec) {
return {
id: rec.id,
type: rec.type,
name: rec.name,
value: rec.content,
ttl: rec.ttl,
proxied: rec.proxied || false,
};
}
// ── Capabilities ───────────────────────────────────────────────────────
supportsCapability(cap) {
return this.getCapabilities().includes(cap);
}
getCapabilities() {
return ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials', 'zones'];
}
// ── Authentication ─────────────────────────────────────────────────────
/**
* Validate the API token by calling the Cloudflare verify endpoint.
* Stores basic zone info on success.
*/
async authenticate() {
this.ctx.log('[cloudflare] Authenticating verifying API token…');
if (!this.apiToken) {
return { status: 'error', message: 'No Cloudflare API token provided' };
}
const res = await this._cfRequest('GET', '/user/tokens/verify');
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Token verification failed';
this.ctx.log(`[cloudflare] Authentication failed: ${msg}`);
return { status: 'error', message: msg };
}
this.ctx.log(`[cloudflare] Token verified for status "${data.status}"`);
// Optionally fetch zone info if zoneId is configured
if (this.zoneId) {
try {
const zoneRes = await this._cfRequest('GET', `/zones/${this.zoneId}`);
const zoneData = await zoneRes.json();
if (zoneData.success && zoneData.result) {
this.zoneInfo = zoneData.result;
this.ctx.log(`[cloudflare] Zone loaded: ${zoneData.result.name} (${zoneData.result.id})`);
}
} catch (err) {
this.ctx.log(`[cloudflare] Could not fetch zone info: ${err.message}`);
}
}
return { status: 'ok', response: { status: data.status } };
}
// ── Create Record ──────────────────────────────────────────────────────
/**
* Create a DNS record.
* If overwrite is true, first delete any existing record with the same name+type.
*/
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
const targetDomain = domain || this.domain;
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
if (overwrite) {
this.ctx.log(`[cloudflare] Overwrite requested deleting existing ${type} record for ${targetDomain}`);
try {
await this.deleteRecord({ domain: targetDomain, type, value });
} catch (err) {
this.ctx.log(`[cloudflare] No existing record to overwrite (or delete failed): ${err.message}`);
}
}
const body = {
type,
name: targetDomain,
content: value,
ttl: ttl || 1, // 1 = automatic TTL in Cloudflare
proxied: false,
};
this.ctx.log(`[cloudflare] Creating ${type} record: ${targetDomain}${value}`);
const res = await this._cfRequest('POST', `/zones/${targetZone}/dns_records`, body);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Record creation failed';
this.ctx.log(`[cloudflare] Create failed: ${msg}`);
return { status: 'error', message: msg };
}
return { status: 'ok', response: { record: this._mapRecord(data.result) } };
}
// ── Delete Record ──────────────────────────────────────────────────────
/**
* Delete DNS records matching domain+type.
* Lists matching records first, then deletes each one.
*/
async deleteRecord({ domain, type, value }) {
const targetDomain = domain || this.domain;
const targetZone = this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
// List records matching name + type
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
if (type) {
queryPath += `&type=${encodeURIComponent(type)}`;
}
const listRes = await this._cfRequest('GET', queryPath);
const listData = await listRes.json();
if (!listData.success) {
const msg = (listData.errors && listData.errors[0] && listData.errors[0].message) || 'Failed to list records for deletion';
this.ctx.log(`[cloudflare] Delete list failed: ${msg}`);
return { status: 'error', message: msg };
}
const matching = listData.result || [];
if (matching.length === 0) {
this.ctx.log(`[cloudflare] No records found for ${targetDomain} (${type || 'any type'})`);
return { status: 'ok', response: { deleted: 0 } };
}
// If a specific value is given, only delete records matching that value
const toDelete = value
? matching.filter((r) => r.content === value)
: matching;
let deleted = 0;
for (const record of toDelete) {
const delRes = await this._cfRequest('DELETE', `/zones/${targetZone}/dns_records/${record.id}`);
const delData = await delRes.json();
if (delData.success) {
deleted++;
this.ctx.log(`[cloudflare] Deleted record ${record.id} (${record.type} ${record.name})`);
} else {
const msg = (delData.errors && delData.errors[0] && delData.errors[0].message) || 'Delete failed';
this.ctx.log(`[cloudflare] Failed to delete record ${record.id}: ${msg}`);
}
}
return { status: 'ok', response: { deleted } };
}
// ── Resolve Records ───────────────────────────────────────────────────
/**
* Resolve/query existing records for a domain.
* Returns records matching domain (and optionally type).
*/
async resolveRecords({ domain, zone, type }) {
const targetDomain = domain || this.domain;
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
let queryPath = `/zones/${targetZone}/dns_records?name=${encodeURIComponent(targetDomain)}`;
if (type) {
queryPath += `&type=${encodeURIComponent(type)}`;
}
this.ctx.log(`[cloudflare] Resolving records for ${targetDomain}${type ? ` (${type})` : ''}`);
const res = await this._cfRequest('GET', queryPath);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'Resolve failed';
this.ctx.log(`[cloudflare] Resolve failed: ${msg}`);
return { status: 'error', message: msg };
}
const records = (data.result || []).map(this._mapRecord);
return { status: 'ok', response: { records } };
}
// ── List Records ───────────────────────────────────────────────────────
/**
* List all DNS records in a zone.
*/
async listRecords({ zone }) {
const targetZone = zone || this.zoneId;
if (!targetZone) {
return { status: 'error', message: 'No zone ID configured for Cloudflare' };
}
this.ctx.log(`[cloudflare] Listing all records in zone ${targetZone}`);
const res = await this._cfRequest('GET', `/zones/${targetZone}/dns_records`);
const data = await res.json();
if (!data.success) {
const msg = (data.errors && data.errors[0] && data.errors[0].message) || 'List failed';
this.ctx.log(`[cloudflare] List failed: ${msg}`);
return { status: 'error', message: msg };
}
const records = (data.result || []).map(this._mapRecord);
return { status: 'ok', response: { records } };
}
}
module.exports = CloudflareDNSProvider;
@@ -0,0 +1,93 @@
/**
* Manual DNS Provider Adapter
* No-op adapter for users who manage DNS externally (manual, cPanel, other control panels).
* Provides propagation checking only — all record operations return helpful instructions.
*/
const BaseDNSProvider = require('./base');
class ManualDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'manual';
this.displayName = 'Manual / External DNS';
this.description = 'Manage DNS records yourself via your provider\'s control panel';
}
supportsCapability(cap) {
return ['credentials'].includes(cap);
}
getCapabilities() {
return ['credentials'];
}
async authenticate() {
return { success: true, message: 'Manual DNS — no authentication needed' };
}
async createRecord({ domain, zone, type, value, ttl }) {
return {
status: 'manual',
message: `Create this record manually in your DNS control panel:`,
instructions: {
name: domain,
type: type || 'A',
value,
ttl: ttl || 300
}
};
}
async deleteRecord({ domain, type, value }) {
return {
status: 'manual',
message: `Delete this record manually from your DNS control panel:`,
instructions: {
name: domain,
type: type || 'A',
value: value || '(any)'
}
};
}
async resolveRecords({ domain, zone, type }) {
// Use Node.js built-in DNS to resolve regardless of provider
const dns = require('dns').promises;
try {
const resolver = new dns.Resolver();
resolver.setServers(['1.1.1.1', '8.8.8.8']);
const records = await resolver.resolve(domain, type || 'A');
return {
status: 'ok',
response: {
records: records.map(r => ({
type: type || 'A',
domain,
rData: { ipAddress: r },
ttl: 0,
manual: true
}))
}
};
} catch (err) {
return { status: 'ok', response: { records: [] } };
}
}
async getStatus() {
return {
providerId: this.providerId,
displayName: this.displayName,
description: this.description,
capabilities: this.getCapabilities(),
authenticated: true,
note: 'DNS records are managed externally. Use propagation checks to verify changes.'
};
}
validateConfig() {
return { valid: true, errors: [] };
}
}
module.exports = ManualDNSProvider;
@@ -0,0 +1,101 @@
/**
* DNS Provider Registry
* Manages available DNS provider adapters.
* Providers register themselves, and the active provider is selected by config.
*/
const path = require('path');
class DNSProviderRegistry {
constructor() {
this.providers = new Map(); // providerId -> adapter class
this.instances = new Map(); // providerId -> adapter instance
}
/** Register a provider adapter class */
register(adapterClass) {
const instance = new adapterClass({}, {});
const id = instance.providerId;
if (this.providers.has(id)) {
console.warn(`DNS provider "${id}" already registered, overwriting`);
}
this.providers.set(id, adapterClass);
}
/** Get list of all registered provider IDs */
getProviderIds() {
return Array.from(this.providers.keys());
}
/** Get metadata for all providers (without instantiating with real config) */
getProviderMeta() {
return this.getProviderIds().map(id => {
const Adapter = this.providers.get(id);
const inst = new Adapter({}, {});
return {
id: inst.providerId,
displayName: inst.displayName,
capabilities: inst.getCapabilities()
};
});
}
/**
* Get or create an adapter instance for the given provider + config
* @param {string} providerId - The provider to instantiate
* @param {Object} config - Provider-specific configuration
* @param {Object} ctx - Shared application context
* @returns {BaseDNSProvider} The provider adapter instance
*/
getProvider(providerId, config, ctx) {
// Re-create if config changed
const cacheKey = providerId;
const Adapter = this.providers.get(providerId);
if (!Adapter) {
throw new Error(`Unknown DNS provider: ${providerId}. Available: ${this.getProviderIds().join(', ')}`);
}
const instance = new Adapter(config, ctx);
this.instances.set(cacheKey, instance);
return instance;
}
/** Auto-discover and register all providers in this directory */
autoDiscover() {
const fs = require('fs');
const dir = __dirname;
const files = fs.readdirSync(dir).filter(f =>
f !== 'base.js' && f !== 'registry.js' && f.endsWith('.js') && !f.startsWith('.')
);
for (const file of files) {
try {
const Loaded = require(path.join(dir, file));
// Support: module.exports = Class, module.exports = { Class }, or plain objects
let cls = null;
if (typeof Loaded === 'function') {
cls = Loaded;
} else if (typeof Loaded === 'object' && Loaded !== null) {
// Try to find a class in the exported object
cls = Object.values(Loaded).find(v => typeof v === 'function');
}
if (cls) {
// Verify it has providerId (on prototype or set in constructor)
try {
const test = new cls({}, {});
if (test.providerId && typeof test.getCapabilities === 'function') {
this.register(cls);
}
} catch {
// Not a valid provider adapter, skip
}
}
} catch (err) {
console.error(`Failed to load DNS provider from ${file}:`, err.message);
}
}
}
}
// Singleton
const registry = new DNSProviderRegistry();
registry.autoDiscover();
module.exports = registry;
@@ -0,0 +1,383 @@
/**
* RFC 2136 Dynamic DNS Provider Adapter
*
* Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool.
* Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server.
*
* Capabilities: create-record, delete-record, resolve, credentials
* Not supported: logs, restart, update-check, list-records, zones
*/
const { execFile } = require('child_process');
const { promisify } = require('util');
const dns = require('dns');
const os = require('os');
const path = require('path');
const fs = require('fs');
const execFileAsync = promisify(execFile);
const BaseDNSProvider = require('./base');
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials'];
const DEFAULT_PORT = 53;
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
const NSUPDATE_TIMEOUT_MS = 15000;
class RFC2136Provider extends BaseDNSProvider {
static providerId = 'rfc2136';
static displayName = 'RFC 2136 (Dynamic DNS)';
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'rfc2136';
this.displayName = 'RFC 2136 (Dynamic DNS)';
// Core config
this.server = config.server || null;
this.port = config.port || DEFAULT_PORT;
this.zone = config.zone || null;
// TSIG authentication
this.tsigAlgorithm = config.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM;
this.tsigKeyName = config.tsigKeyName || null;
this.tsigSecret = config.tsigSecret || null;
// Resolve credentials from credential manager if available
if (ctx && ctx.credentialManager) {
if (!this.tsigKeyName && ctx.credentialManager.get) {
this.tsigKeyName = ctx.credentialManager.get('rfc2136_tsigKeyName') || null;
}
if (!this.tsigSecret && ctx.credentialManager.get) {
this.tsigSecret = ctx.credentialManager.get('rfc2136_tsigSecret') || null;
}
}
// Logger shorthand
this._log = ctx && ctx.log ? ctx.ctx : null;
}
// ── Logging helper ────────────────────────────────────────────────────────
_log(level, message, meta) {
if (this.ctx && this.ctx.log && typeof this.ctx.log[level] === 'function') {
this.ctx.log[level](`[rfc2136] ${message}`, meta || {});
}
}
// ── Capabilities ──────────────────────────────────────────────────────────
supportsCapability(cap) {
return CAPABILITIES.includes(cap);
}
getCapabilities() {
return [...CAPABILITIES];
}
// ── Config validation ─────────────────────────────────────────────────────
validateConfig() {
const errors = [];
if (!this.server) errors.push('Missing required config: server');
if (!this.zone) errors.push('Missing required config: zone');
return { valid: errors.length === 0, errors };
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Ensure a domain name ends with a trailing dot (FQDN for nsupdate).
*/
_ensureFqdn(domain) {
if (!domain) return domain;
return domain.endsWith('.') ? domain : `${domain}.`;
}
/**
* Build the common nsupdate header lines (server, zone, key).
*/
_buildHeader() {
const lines = [];
lines.push(`server ${this.server} ${this.port}`);
lines.push(`zone ${this.zone}`);
if (this.tsigKeyName && this.tsigSecret) {
lines.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
return lines;
}
/**
* Execute an nsupdate script and return { stdout, stderr }.
* Writes commands to a temporary file and runs `nsupdate <file>`.
*/
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`);
try {
await fs.promises.writeFile(tmpFile, script, { mode: 0o600 });
this._log('debug', `Executing nsupdate script`, { script: script.trim() });
const { stdout, stderr } = await execFileAsync('nsupdate', [tmpFile], {
timeout: NSUPDATE_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
this._log('debug', 'nsupdate completed', { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() });
if (stderr && stderr.toLowerCase().includes('refused')) {
throw new Error(`nsupdate refused: ${stderr.trim()}`);
}
if (stderr && stderr.toLowerCase().includes('failed')) {
throw new Error(`nsupdate failed: ${stderr.trim()}`);
}
return { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() };
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('nsupdate command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
}
throw err;
} finally {
try { await fs.promises.unlink(tmpFile); } catch (_) { /* ignore */ }
}
}
// ── Authenticate ──────────────────────────────────────────────────────────
/**
* Verify nsupdate is available and optionally test connectivity.
* Runs a minimal nsupdate with just "show" (no-op) to confirm the tool works.
*/
async authenticate() {
const validation = this.validateConfig();
if (!validation.valid) {
throw new Error(`RFC 2136 config invalid: ${validation.errors.join('; ')}`);
}
// Check nsupdate binary is available with a dry-run command set
const commands = [
...this._buildHeader(),
'show',
];
try {
const { stdout } = await this._runNsupdate(commands);
this._log('info', 'Authenticated to RFC 2136 server', { server: this.server, port: this.port });
return { success: true, server: this.server, port: this.port };
} catch (err) {
this._log('error', 'Authentication test failed', { error: err.message });
// If nsupdate is missing, rethrow immediately
if (err.message.includes('not found')) throw err;
// Otherwise, the server might be unreachable but the tool works — return partial
return { success: false, error: err.message, server: this.server };
}
}
// ── Create Record ─────────────────────────────────────────────────────────
/**
* Create (add) a DNS record via RFC 2136 UPDATE.
*
* @param {Object} params
* @param {string} params.domain - Record name (e.g. "www.example.com")
* @param {string} params.zone - Zone name (overrides constructor zone)
* @param {string} params.type - Record type (A, AAAA, CNAME, TXT, etc.)
* @param {string} params.value - Record value
* @param {number} [params.ttl=300] - TTL in seconds
*/
async createRecord({ domain, zone, type, value, ttl }) {
const effectiveZone = zone || this.zone;
const effectiveTtl = ttl || 300;
const fqdn = this._ensureFqdn(domain);
const commands = [
`server ${this.server} ${this.port}`,
`zone ${effectiveZone}`,
];
if (this.tsigKeyName && this.tsigSecret) {
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
commands.push(`update add ${fqdn} ${effectiveTtl} ${type} ${value}`);
commands.push('show');
commands.push('send');
this._log('info', 'Creating DNS record', { domain: fqdn, type, value, ttl: effectiveTtl });
const result = await this._runNsupdate(commands);
return {
success: true,
action: 'create-record',
domain: fqdn,
type,
value,
ttl: effectiveTtl,
zone: effectiveZone,
raw: result.stdout,
};
}
// ── Delete Record ─────────────────────────────────────────────────────────
/**
* Delete a DNS record via RFC 2136 UPDATE.
*
* @param {Object} params
* @param {string} params.domain - Record name
* @param {string} params.type - Record type
* @param {string} [params.value] - Optional specific value to match
*/
async deleteRecord({ domain, type, value }) {
const effectiveZone = this.zone;
const fqdn = this._ensureFqdn(domain);
const commands = [
`server ${this.server} ${this.port}`,
`zone ${effectiveZone}`,
];
if (this.tsigKeyName && this.tsigSecret) {
commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`);
}
// "update delete" with value removes that specific RR;
// without value it removes all RRs of that type for the name.
const deleteClause = value
? `update delete ${fqdn} ${type} ${value}`
: `update delete ${fqdn} ${type}`;
commands.push(deleteClause);
commands.push('show');
commands.push('send');
this._log('info', 'Deleting DNS record', { domain: fqdn, type, value: value || '(all)' });
const result = await this._runNsupdate(commands);
return {
success: true,
action: 'delete-record',
domain: fqdn,
type,
value: value || null,
zone: effectiveZone,
raw: result.stdout,
};
}
// ── Resolve Records ───────────────────────────────────────────────────────
/**
* Resolve DNS records for a domain.
* First attempts dig against the configured server, then falls back to Node dns module.
*
* @param {Object} params
* @param {string} params.domain - Domain to resolve
* @param {string} [params.zone] - Zone (unused for resolution, kept for interface consistency)
* @param {string} [params.type='A'] - Record type to query
*/
async resolveRecords({ domain, zone, type }) {
const queryType = type || 'A';
const fqdn = domain.endsWith('.') ? domain : domain;
// Strategy 1: Use dig against the configured RFC 2136 server
try {
const { stdout } = await execFileAsync('dig', [
`@${this.server}`,
'-p', String(this.port),
fqdn,
queryType,
'+short',
'+time=5',
'+tries=1',
], { timeout: 10000 });
const records = stdout
.split('\n')
.map(line => line.trim())
.filter(Boolean);
if (records.length > 0) {
this._log('debug', `Resolved ${fqdn} ${queryType} via dig`, { records });
return {
domain: fqdn,
type: queryType,
records: records.map(r => ({ value: r, type: queryType })),
source: 'dig',
server: this.server,
};
}
} catch (err) {
this._log('warn', 'dig resolution failed, falling back to Node dns', { error: err.message });
}
// Strategy 2: Fallback to Node.js built-in resolver
try {
const resolver = new dns.Resolver();
resolver.setServers([this.server]);
const resolveMethod = this._getResolveMethod(queryType);
const resolveAsync = promisify(resolver[resolveMethod]).bind(resolver);
const results = await resolveAsync(fqdn);
const records = Array.isArray(results) ? results : [results];
this._log('debug', `Resolved ${fqdn} ${queryType} via Node dns`, { records });
return {
domain: fqdn,
type: queryType,
records: records.map(r => ({ value: String(r), type: queryType })),
source: 'node-dns',
server: this.server,
};
} catch (err) {
this._log('warn', 'Node dns resolution also failed', { error: err.message });
return {
domain: fqdn,
type: queryType,
records: [],
source: 'none',
server: this.server,
error: err.message,
};
}
}
/**
* Map record type to the Node dns resolver method name.
*/
_getResolveMethod(type) {
const map = {
A: 'resolve4',
AAAA: 'resolve6',
CNAME: 'resolveCname',
MX: 'resolveMx',
TXT: 'resolveTxt',
NS: 'resolveNs',
SOA: 'resolveSoa',
SRV: 'resolveSrv',
PTR: 'reverse',
};
return map[(type || '').toUpperCase()] || 'resolve4';
}
// ── Shutdown ──────────────────────────────────────────────────────────────
async shutdown() {
this._log('info', 'RFC 2136 provider shutting down');
}
}
// Expose providerId on the prototype so the registry's auto-discover can detect it
RFC2136Provider.prototype.providerId = 'rfc2136';
module.exports = RFC2136Provider;
@@ -0,0 +1,507 @@
/**
* Technitium DNS Server Provider Adapter
*
* Wraps Technitium-specific DNS logic into the standard adapter interface.
* Uses the Technitium HTTP API (default port 5380) for all operations.
*/
const BaseDNSProvider = require('./base');
const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24-hour token lifetime
class TechnitiumDNSProvider extends BaseDNSProvider {
constructor(config, ctx) {
super(config, ctx);
this.providerId = 'technitium';
this.displayName = 'Technitium DNS Server';
this.serverIp = config.serverIp;
this.serverPort = config.serverPort || 5380;
this.dnsId = config.dnsId || null;
// Token state
this.token = null;
this.tokenExpiry = null;
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
static CAPABILITIES = [
'create-record',
'delete-record',
'resolve',
'list-records',
'logs',
'restart',
'update-check',
'credentials',
'zones'
];
supportsCapability(cap) {
return TechnitiumDNSProvider.CAPABILITIES.includes(cap);
}
getCapabilities() {
return [...TechnitiumDNSProvider.CAPABILITIES];
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build the base URL for this server */
_baseUrl() {
return `http://${this.serverIp}:${this.serverPort}`;
}
/** Build a full API URL with query-string params */
_buildUrl(apiPath, params = {}) {
const qs = new URLSearchParams(params).toString();
return `${this._baseUrl()}${apiPath}${qs ? '?' + qs : ''}`;
}
/** Ensure we have a valid token; throws on failure */
async _requireToken() {
// Re-use existing token if still valid
if (this.token && this.tokenExpiry && new Date() < new Date(this.tokenExpiry)) {
return this.token;
}
const result = await this.authenticate();
if (!result.success) {
const err = new Error('No valid DNS token available. ' + (result.error || ''));
err.statusCode = 401;
throw err;
}
return this.token;
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/**
* Authenticate against the Technitium server.
* Checks per-server credentials first (dns.{dnsId}.readonly.username),
* then falls back to global credentials (dns.username).
*
* Stores token + expiry on success.
*/
async authenticate() {
const { credentialManager, log } = this.ctx;
// Try per-server credentials first
if (this.dnsId) {
for (const role of ['readonly', 'admin']) {
try {
const username = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.password`);
if (username && password) {
const result = await this._doLogin(username, password);
if (result.success) return result;
}
} catch (err) {
log.error('technitium', `Per-server ${role} credential error`, {
dnsId: this.dnsId,
error: err.message
});
}
}
}
// Fall back to global credentials
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
if (username && password) {
return await this._doLogin(username, password);
}
} catch (err) {
log.error('technitium', 'Global credential error', { error: err.message });
}
return {
success: false,
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
};
}
/**
* Perform the actual login POST to Technitium.
* Stores token on success.
*/
async _doLogin(username, password) {
const { fetchT, log } = this.ctx;
try {
const params = new URLSearchParams({
user: username,
pass: password,
includeInfo: 'false'
});
const url = `${this._baseUrl()}/api/user/login?${params.toString()}`;
const response = await fetchT(url, {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/x-www-form-urlencoded'
}
});
const result = await response.json();
if (result.status === 'ok' && result.token) {
this.token = result.token;
this.tokenExpiry = new Date(Date.now() + SESSION_TTL_MS).toISOString();
log.info('technitium', 'DNS token obtained', {
server: this.serverIp,
expires: this.tokenExpiry
});
return { success: true, token: this.token };
}
return { success: false, error: result.errorMessage || 'Login failed' };
} catch (error) {
log.error('technitium', 'Login error', { error: error.message });
return { success: false, error: error.message };
}
}
// ---------------------------------------------------------------------------
// Record Management
// ---------------------------------------------------------------------------
/**
* Create (or overwrite) a DNS record.
* GET /api/zones/records/add?token=...&domain=...&zone=...&type=...&ipAddress=...&ttl=...&overwrite=...
*/
async createRecord({ domain, zone, type, value, ttl, overwrite }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
zone,
type: type || 'A',
ipAddress: value,
ttl: String(ttl || 300),
overwrite: String(overwrite !== false)
};
try {
log.info('technitium', 'Creating DNS record', { domain, type, value });
const url = this._buildUrl('/api/zones/records/add', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS record created', { domain, type, value });
return { success: true };
}
// If token expired, re-authenticate and retry once
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
log.info('technitium', 'Token expired, re-authenticating');
this.token = null;
this.tokenExpiry = null;
const retryToken = await this._requireToken();
params.token = retryToken;
const retryUrl = this._buildUrl('/api/zones/records/add', params);
const retryResp = await fetchT(retryUrl, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const retryResult = await retryResp.json();
if (retryResult.status === 'ok') {
return { success: true };
}
throw new Error(retryResult.errorMessage || 'Failed after token refresh');
}
throw new Error(result.errorMessage || 'Unknown error');
} catch (error) {
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
}
}
/**
* Delete a DNS record.
* GET /api/zones/records/delete?token=...&domain=...&type=... (+ ipAddress if value provided)
*/
async deleteRecord({ domain, type, value }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
type: type || 'A'
};
if (value) {
params.ipAddress = value;
}
try {
log.info('technitium', 'Deleting DNS record', { domain, type, value });
const url = this._buildUrl('/api/zones/records/delete', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS record deleted', { domain, type, value });
return { success: true };
}
throw new Error(result.errorMessage || 'Unknown error');
} catch (error) {
throw new Error(`Failed to delete DNS record for ${domain}: ${error.message}`);
}
}
/**
* Resolve/query records for a domain in a zone.
* GET /api/zones/records/get?token=...&domain=...&zone=...&listZone=true
* Filters returned records by type if provided.
*/
async resolveRecords({ domain, zone, type }) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const params = {
token,
domain,
zone,
listZone: 'true'
};
try {
log.info('technitium', 'Resolving records', { domain, zone, type });
const url = this._buildUrl('/api/zones/records/get', params);
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status !== 'ok') {
throw new Error(result.errorMessage || 'Failed to resolve records');
}
let records = (result.response && result.response.records) || [];
// Filter by type if specified
if (type) {
records = records.filter(r => r.type === type);
}
return { success: true, records };
} catch (error) {
throw new Error(`Failed to resolve records for ${domain}: ${error.message}`);
}
}
/**
* List all records in a zone.
* Delegates to resolveRecords with a wildcard domain.
*/
async listRecords({ zone }) {
return this.resolveRecords({ domain: zone, zone, type: null });
}
// ---------------------------------------------------------------------------
// Logs
// ---------------------------------------------------------------------------
/**
* Fetch and parse DNS query logs.
* 1. GET /api/logs/list to discover the latest log file
* 2. GET /api/logs/download?token=...&fileName=... to download it
* 3. Parse text format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
*/
async getLogs({ limit, server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
const targetIp = server || this.serverIp;
const targetPort = this.serverPort;
const baseUrl = `http://${targetIp}:${targetPort}`;
try {
// Step 1: Get log file list
const listUrl = this._buildUrl('/api/logs/list', { token });
const listResp = await fetchT(listUrl.replace(this._baseUrl(), baseUrl), {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const listResult = await listResp.json();
if (listResult.status !== 'ok' || !listResult.response || !listResult.response.length) {
throw new Error(listResult.errorMessage || 'No log files found');
}
// Pick the latest log file (last entry)
const logFile = listResult.response[listResult.response.length - 1];
const fileName = logFile.name || logFile.fileName || logFile;
// Step 2: Download the log file
const downloadUrl = `${baseUrl}/api/logs/download?${new URLSearchParams({ token, fileName }).toString()}`;
const downloadResp = await fetchT(downloadUrl, {
method: 'GET'
});
const logText = await downloadResp.text();
// Step 3: Parse lines
const parsed = this._parseLogText(logText, limit);
return { success: true, logs: parsed };
} catch (error) {
log.error('technitium', 'Failed to fetch DNS logs', { error: error.message });
throw new Error(`Failed to get DNS logs: ${error.message}`);
}
}
/**
* Parse Technitium DNS log text format.
* Line format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer]
*/
_parseLogText(text, limit) {
const lines = text.split('\n').filter(l => l.trim());
const parsed = [];
// Process newest first if we need to limit
const iterable = limit ? lines.slice(-limit).reverse() : lines;
for (const line of iterable) {
try {
const entry = {};
// Extract timestamp: [2024-01-15 10:30:45]
const tsMatch = line.match(/\[([^\]]+)\]/);
if (tsMatch) entry.timestamp = tsMatch[1];
// Extract client:port: [192.168.1.100:12345]
const clientMatch = line.match(/\[([^\]]+:\d+)\]/g);
if (clientMatch && clientMatch.length >= 2) {
entry.client = clientMatch[1].replace(/\[|\]/g, '');
}
// Extract protocol: [UDP] or [TCP]
const protoMatch = line.match(/\]\s*\[(UDP|TCP|DoH|DoT|DoH2)\]/i);
if (protoMatch) entry.protocol = protoMatch[1];
// Extract key-value pairs: QNAME: value; QTYPE: value; etc.
const kvPattern = /(\w+):\s*([^;]+)/g;
let match;
while ((match = kvPattern.exec(line)) !== null) {
const key = match[1];
const val = match[2].trim();
if (['QNAME', 'QTYPE', 'QCLASS', 'RCODE'].includes(key)) {
entry[key.toLowerCase()] = val;
} else if (key === 'ANSWER') {
entry.answer = val;
}
}
entry.raw = line;
parsed.push(entry);
} catch {
// Skip unparseable lines
}
}
return parsed;
}
// ---------------------------------------------------------------------------
// Server Management
// ---------------------------------------------------------------------------
/**
* Restart the DNS server.
* POST /api/admin/restart?token=...
* Requires admin credentials.
*/
async restartServer({ server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
try {
log.info('technitium', 'Restarting DNS server', { server: this.serverIp });
const url = this._buildUrl('/api/admin/restart', { token });
const response = await fetchT(url, {
method: 'POST',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
log.info('technitium', 'DNS server restart initiated');
return { success: true, message: 'Server restart initiated' };
}
throw new Error(result.errorMessage || 'Restart failed');
} catch (error) {
log.error('technitium', 'DNS restart error', { error: error.message });
throw new Error(`Failed to restart DNS server: ${error.message}`);
}
}
/**
* Check for DNS server updates.
* GET /api/user/checkForUpdate?token=...
*/
async checkUpdate({ server } = {}) {
const token = await this._requireToken();
const { fetchT, log } = this.ctx;
try {
log.info('technitium', 'Checking for DNS server update', { server: this.serverIp });
const url = this._buildUrl('/api/user/checkForUpdate', { token });
const response = await fetchT(url, {
method: 'GET',
headers: { 'Accept': 'application/json' }
});
const result = await response.json();
if (result.status === 'ok') {
return {
success: true,
updateAvailable: !!(result.response && result.response.updateAvailable),
latestVersion: (result.response && result.response.latestVersion) || null,
currentVersion: (result.response && result.response.currentVersion) || null,
response: result.response
};
}
throw new Error(result.errorMessage || 'Update check failed');
} catch (error) {
log.error('technitium', 'Update check error', { error: error.message });
throw new Error(`Failed to check for updates: ${error.message}`);
}
}
// ---------------------------------------------------------------------------
// Config Validation
// ---------------------------------------------------------------------------
validateConfig() {
const errors = [];
if (!this.serverIp) {
errors.push('serverIp is required');
}
if (this.serverPort && (typeof this.serverPort !== 'number' || this.serverPort < 1 || this.serverPort > 65535)) {
errors.push('serverPort must be a valid port number (1-65535)');
}
return { valid: errors.length === 0, errors };
}
}
module.exports = TechnitiumDNSProvider;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
/**
* Docker Maintenance Module
* Scheduled cleanup to prevent Docker disk bloat:
* - Prunes dangling images
* - Prunes stopped non-managed containers
* - Clears build cache
* - Monitors disk usage and warns when thresholds exceeded
*/
const Docker = require('dockerode');
const EventEmitter = require('events');
const { DOCKER } = require('../utilities/constants');
const docker = new Docker();
class DockerMaintenance extends EventEmitter {
constructor() {
super();
this.interval = null;
this.running = false;
this.lastRun = null;
this.lastResult = null;
}
start() {
if (this.running) return;
this.running = true;
// Run first maintenance 5 minutes after startup (let everything settle)
setTimeout(() => {
if (!this.running) return;
this.runMaintenance().catch(() => {});
}, 5 * 60 * 1000);
// Then run on the configured interval (default 24h)
this.interval = setInterval(() => {
this.runMaintenance().catch(() => {});
}, DOCKER.MAINTENANCE.INTERVAL);
}
stop() {
if (!this.running) return;
this.running = false;
if (this.interval) {
clearInterval(this.interval);
this.interval = null;
}
}
async runMaintenance() {
const startTime = Date.now();
const result = {
timestamp: new Date().toISOString(),
pruned: { images: 0, containers: 0, buildCache: 0 },
spaceReclaimed: { images: 0, containers: 0, buildCache: 0, total: 0 },
diskUsage: null,
warnings: [],
containersWithoutLogLimits: []
};
try {
// 1. Prune dangling images
try {
const imgResult = await docker.pruneImages({ filters: { dangling: { true: true } } });
result.pruned.images = (imgResult.ImagesDeleted || []).length;
result.spaceReclaimed.images = imgResult.SpaceReclaimed || 0;
} catch (e) {
result.warnings.push(`Image prune failed: ${e.message}`);
}
// 2. Prune stopped containers (only non-managed ones)
try {
const stopped = await docker.listContainers({
all: true,
filters: { status: ['exited', 'dead'] }
});
for (const c of stopped) {
// Skip DashCaddy-managed containers — user may want to restart them
if (c.Labels?.['sami.managed'] === 'true') continue;
// Skip containers stopped less than 24h ago
const stoppedAge = Date.now() / 1000 - c.Created;
if (stoppedAge < 86400) continue;
try {
const container = docker.getContainer(c.Id);
await container.remove({ force: true });
result.pruned.containers++;
} catch (e) {
// Container may have been removed between list and remove
}
}
} catch (e) {
result.warnings.push(`Container prune failed: ${e.message}`);
}
// 3. Prune build cache
try {
const cacheResult = await docker.pruneBuilder();
result.spaceReclaimed.buildCache = cacheResult.SpaceReclaimed || 0;
result.pruned.buildCache = (cacheResult.CachesDeleted || []).length;
} catch (e) {
// Build cache prune may not be available on all Docker versions
result.warnings.push(`Build cache prune failed: ${e.message}`);
}
// 4. Get disk usage
try {
const df = await docker.df();
result.diskUsage = {
images: {
count: (df.Images || []).length,
sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0)
},
containers: {
count: (df.Containers || []).length,
sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0)
},
volumes: {
count: (df.Volumes?.Volumes || []).length,
sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0)
},
buildCache: {
count: (df.BuildCache || []).length,
sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0)
}
};
result.diskUsage.totalBytes =
result.diskUsage.images.sizeBytes +
result.diskUsage.containers.sizeBytes +
result.diskUsage.volumes.sizeBytes +
result.diskUsage.buildCache.sizeBytes;
result.diskUsage.totalGB = +(result.diskUsage.totalBytes / (1024 ** 3)).toFixed(2);
if (result.diskUsage.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) {
result.warnings.push(`Docker disk usage is ${result.diskUsage.totalGB}GB (threshold: ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB)`);
}
} catch (e) {
result.warnings.push(`Disk usage check failed: ${e.message}`);
}
// 5. Check for containers without log rotation
try {
const running = await docker.listContainers({ all: false });
for (const c of running) {
if (c.Labels?.['sami.managed'] !== 'true') continue;
try {
const container = docker.getContainer(c.Id);
const info = await container.inspect();
const logConfig = info.HostConfig?.LogConfig;
if (!logConfig?.Config?.['max-size']) {
result.containersWithoutLogLimits.push({
name: c.Names[0]?.replace(/^\//, '') || c.Id.slice(0, 12),
id: c.Id.slice(0, 12)
});
}
} catch (e) {
// Container may have stopped between list and inspect
}
}
if (result.containersWithoutLogLimits.length > 0) {
result.warnings.push(
`${result.containersWithoutLogLimits.length} container(s) have no log rotation — restart or update them to apply log limits: ${result.containersWithoutLogLimits.map(c => c.name).join(', ')}`
);
}
} catch (e) {
result.warnings.push(`Log config check failed: ${e.message}`);
}
result.spaceReclaimed.total =
result.spaceReclaimed.images +
result.spaceReclaimed.containers +
result.spaceReclaimed.buildCache;
result.duration = Date.now() - startTime;
this.lastRun = new Date().toISOString();
this.lastResult = result;
this.emit('maintenance-complete', result);
return result;
} catch (error) {
result.error = error.message;
result.duration = Date.now() - startTime;
this.lastResult = result;
this.emit('maintenance-failed', result);
throw error;
}
}
/** Get Docker disk usage snapshot (callable on demand) */
async getDiskUsage() {
try {
const df = await docker.df();
const images = { count: (df.Images || []).length, sizeBytes: (df.Images || []).reduce((sum, i) => sum + (i.Size || 0), 0) };
const containers = { count: (df.Containers || []).length, sizeBytes: (df.Containers || []).reduce((sum, c) => sum + (c.SizeRw || 0), 0) };
const volumes = { count: (df.Volumes?.Volumes || []).length, sizeBytes: (df.Volumes?.Volumes || []).reduce((sum, v) => sum + (v.UsageData?.Size || 0), 0) };
const buildCache = { count: (df.BuildCache || []).length, sizeBytes: (df.BuildCache || []).reduce((sum, b) => sum + (b.Size || 0), 0) };
const totalBytes = images.sizeBytes + containers.sizeBytes + volumes.sizeBytes + buildCache.sizeBytes;
return { images, containers, volumes, buildCache, totalBytes, totalGB: +(totalBytes / (1024 ** 3)).toFixed(2) };
} catch (e) {
return null;
}
}
getStatus() {
return {
running: this.running,
lastRun: this.lastRun,
lastResult: this.lastResult
};
}
}
module.exports = new DockerMaintenance();
+779
View File
@@ -0,0 +1,779 @@
/**
* DashCaddy Self-Updater
* Polls for new versions, downloads and stages updates,
* triggers host-side updater for API container rebuilds.
*
* Frontend files are updated directly (zero-downtime).
* API files require a container rebuild via the host-side systemd service.
*/
const EventEmitter = require('events');
const https = require('https');
const http = require('http');
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 platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows;
const DEFAULTS = {
CHECK_INTERVAL: 30 * 60 * 1000, // 30 minutes
UPDATE_URL: process.env.DASHCADDY_UPDATE_URL || 'https://get.dashcaddy.net/release',
MIRROR_URL: process.env.DASHCADDY_MIRROR_URL || 'https://get2.dashcaddy.net/release',
UPDATES_DIR: platformPaths.containerUpdatesDir,
// API_SOURCE_DIR is the HOST path — written to trigger.json for the host-side updater
API_SOURCE_DIR: path.join(platformPaths.caddySites, 'dashcaddy-api'),
// FRONTEND_DIR is the container path — dashboard is volume-mounted at /app/dashboard
FRONTEND_DIR: platformPaths.containerFrontendDir,
MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000,
CHANNEL: process.env.DASHCADDY_UPDATE_CHANNEL || 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
};
class SelfUpdater extends EventEmitter {
constructor(options = {}) {
super();
this.config = {
enabled: options.enabled !== false,
checkInterval: parseInt(options.checkInterval || DEFAULTS.CHECK_INTERVAL, 10),
updateUrl: options.updateUrl || DEFAULTS.UPDATE_URL,
mirrorUrl: options.mirrorUrl || DEFAULTS.MIRROR_URL,
updatesDir: options.updatesDir || DEFAULTS.UPDATES_DIR,
// hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
// Used when writing trigger.json so the host-side script can find staging files.
hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
// hostFrontendDir is the path on the HOST where Caddy serves the dashboard
// from. The in-container `frontendDir` is often a path that isn't mounted
// (e.g. /app/dashboard with no bind mount), so writing there is silently
// useless. When this is set, we pass it to the host-side updater script
// and skip the in-container copy entirely.
hostFrontendDir: options.hostFrontendDir
|| process.env.DASHCADDY_HOST_FRONTEND_DIR
|| (platformPaths.isWindows ? null : '/var/www/dashcaddy-status'),
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
};
this.status = 'idle'; // idle | checking | downloading | applying | waiting
this.checkTimer = null;
this.lastCheckTime = null;
this.lastCheckResult = null;
this.instanceId = this._loadOrCreateInstanceId();
// Ensure directories exist
this._ensureDirs();
// Notify-secret lives next to instance-id (alongside updates dir on Linux,
// <caddyBase>/notify-secret on Windows). Auto-generated on first start.
this.notifySecretFile = options.notifySecretFile
|| process.env.DASHCADDY_NOTIFY_SECRET_FILE
|| path.join(this.config.updatesDir, 'notify-secret');
this.notifySecret = this._loadOrCreateNotifySecret();
}
// ── Lifecycle ──
start() {
if (!this.config.enabled || this.checkTimer) return;
console.log('[SelfUpdater] Starting auto-update checks every %ds', this.config.checkInterval / 1000);
// First check after a short delay (let server finish startup)
setTimeout(() => {
this._autoCheckAndApply();
this.checkTimer = setInterval(() => this._autoCheckAndApply(), this.config.checkInterval);
}, 15000);
}
stop() {
if (this.checkTimer) {
clearInterval(this.checkTimer);
this.checkTimer = null;
}
}
// ── Version / Identity Info ──
getLocalVersion() {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
let commit = null;
try {
commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
} catch { /* ignore */ }
return { version: pkg.version, commit };
} catch (e) {
return { version: '0.0.0', commit: null };
}
}
getInstanceInfo() {
return {
instanceId: this.instanceId,
channel: this.config.channel,
hostname: os.hostname(),
platform: process.platform,
arch: process.arch,
isWindows,
version: this.getLocalVersion(),
};
}
getStatus() {
return this.status;
}
getNotifySecret() {
return this.notifySecret;
}
// Public wrapper for the auto-check+apply loop, used by the notify endpoint
// so the publisher can wake an instance up immediately instead of waiting
// for the next 30-min poll. Returns immediately; work runs async.
notifyAndApply(triggeredBy = 'notify') {
if (this.status !== 'idle' && this.status !== 'checking') {
return { accepted: false, reason: `busy (status: ${this.status})`, status: this.status };
}
// 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)
);
});
return { accepted: true, triggeredBy };
}
// ── Check for Updates ──
async checkForUpdate() {
this.status = 'checking';
try {
let remote;
let sourceUrl = this.config.updateUrl;
try {
remote = await this._fetchJson(`${this.config.updateUrl}/version.json`);
} catch (primaryErr) {
console.warn('[SelfUpdater] Primary server failed:', primaryErr.message, '— trying mirror');
try {
remote = await this._fetchJson(`${this.config.mirrorUrl}/version.json`);
sourceUrl = this.config.mirrorUrl;
} catch (mirrorErr) {
this.status = 'idle';
this.lastCheckTime = Date.now();
this.lastCheckResult = { available: false, error: 'Update servers unreachable' };
return this.lastCheckResult;
}
}
const local = this.getLocalVersion();
const policy = this._evaluateReleasePolicy(local, remote);
const available = policy.eligible && policy.newer;
this.lastCheckTime = Date.now();
this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
this.status = 'idle';
if (available) {
this.emit('update-available', remote);
}
return this.lastCheckResult;
} catch (e) {
this.status = 'idle';
this.lastCheckTime = Date.now();
this.lastCheckResult = { available: false, error: e.message };
return this.lastCheckResult;
}
}
// ── Apply Update ──
async applyUpdate(remoteInfo) {
if (this.status !== 'idle' && this.status !== 'checking') {
throw new Error(`Update already in progress (status: ${this.status})`);
}
const local = this.getLocalVersion();
const policy = this._evaluateReleasePolicy(local, remoteInfo);
if (!policy.eligible) {
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
}
const stagingDir = path.join(this.config.updatesDir, 'staging');
try {
// 1. Download (try primary, fallback to mirror)
this.status = 'downloading';
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
const mirrorUrl = `${this.config.mirrorUrl}/${remoteInfo.tarball}`;
try {
await this._downloadFile(primaryUrl, tarballPath);
} catch (dlErr) {
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
// Ensure file is fully cleaned up before mirror attempt
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
await this._downloadFile(mirrorUrl, tarballPath);
}
// 2. Verify SHA-256
const hash = await this._computeSha256(tarballPath);
if (hash !== remoteInfo.sha256) {
await fsp.unlink(tarballPath).catch(() => {});
throw new Error(`SHA-256 mismatch: expected ${remoteInfo.sha256}, got ${hash}`);
}
// 3. Extract
this.status = 'applying';
this.emit('update-progress', { step: 'extracting', version: remoteInfo.version });
await this._cleanDir(stagingDir);
await this._extractTarball(tarballPath, stagingDir);
// 4. Locate frontend source. The actual deploy is done either here (if no
// hostFrontendDir is configured, e.g. Windows or unusual setups) or by
// the host-side updater script via trigger.json (preferred path on Linux,
// where Caddy serves from /var/www/... outside the container's filesystem).
const frontendSrc = this._findDir(stagingDir, 'status');
let hostFrontendStagingPath = null;
if (frontendSrc && this.config.hostFrontendDir) {
// Defer the copy to the host-side script. Just compute the host path
// for staging so it can find the files.
hostFrontendStagingPath = frontendSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
} else if (frontendSrc) {
// No host path configured — copy in-container (legacy / Windows path).
await this._copyDir(frontendSrc, this.config.frontendDir, [
'dist', 'css', 'assets', 'vendor', 'js', 'index.html', 'sw.js'
]);
this.emit('update-progress', { step: 'frontend-updated', version: remoteInfo.version });
}
// 5. Trigger API rebuild (Linux only — host-side systemd service)
const apiSrc = this._findDir(stagingDir, 'dashcaddy-api');
if (apiSrc && !isWindows) {
this.status = 'waiting';
this.emit('update-progress', { step: 'triggering-rebuild', version: remoteInfo.version });
// Convert container path to host path for trigger.json
const hostApiSrc = apiSrc.replace(this.config.updatesDir, this.config.hostUpdatesDir);
const trigger = {
action: 'update',
version: remoteInfo.version,
commit: remoteInfo.commit,
fromVersion: local.version,
stagingDir: hostApiSrc,
apiSourceDir: this.config.apiSourceDir,
frontendStagingDir: hostFrontendStagingPath,
frontendTargetDir: this.config.hostFrontendDir || null,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2)
);
// The host-side systemd service will handle the rest.
// After container restart, checkPostUpdateResult() reads the result.
this._addToHistory({
version: remoteInfo.version,
fromVersion: local.version,
timestamp: new Date().toISOString(),
status: 'pending',
frontendUpdated: !!frontendSrc,
apiUpdated: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
} else if (isWindows) {
// Windows: frontend updated, API needs manual restart
this._addToHistory({
version: remoteInfo.version,
fromVersion: local.version,
timestamp: new Date().toISOString(),
status: 'partial',
frontendUpdated: !!frontendSrc,
apiUpdated: false,
note: 'API update requires manual container restart on Windows',
channel: this.config.channel,
instanceId: this.instanceId,
});
this.status = 'idle';
}
// Clean up tarball
await fsp.unlink(tarballPath).catch(() => {});
return {
success: true,
fromVersion: local.version,
toVersion: remoteInfo.version,
frontendUpdated: !!frontendSrc,
apiUpdated: !isWindows && !!apiSrc,
policy,
};
} catch (e) {
this.status = 'idle';
this._addToHistory({
version: remoteInfo.version,
fromVersion: local.version,
timestamp: new Date().toISOString(),
status: 'failed',
error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
});
throw e;
}
}
// ── Post-Update Result ──
async checkPostUpdateResult() {
const resultPath = path.join(this.config.updatesDir, 'result.json');
try {
const data = await fsp.readFile(resultPath, 'utf8');
const result = JSON.parse(data);
// Delete the result file so we don't process it again
await fsp.unlink(resultPath).catch(() => {});
// Update the matching history entry, preferring the newest pending item
// for the same target version. Fall back to the newest pending item if
// older result files lack enough metadata to match more precisely.
const history = this.getUpdateHistory();
const pendingIndex = history.findIndex(
h => h.status === 'pending' && (!result.version || h.version === result.version)
);
const fallbackIndex = pendingIndex === -1
? history.findIndex(h => h.status === 'pending')
: -1;
const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;
if (historyIndex !== -1) {
const pending = history[historyIndex];
pending.status = result.success ? 'success' : 'rolled-back';
pending.duration = result.duration;
if (result.error) pending.error = result.error;
if (result.version) pending.version = result.version;
if (result.timestamp) pending.completedAt = result.timestamp;
this._saveHistory(history);
}
this.status = 'idle';
return result;
} catch (_) {
return null;
}
}
// ── Rollback ──
async rollbackToVersion(version) {
if (isWindows) throw new Error('Auto-rollback not supported on Windows');
const backupDir = path.join(this.config.updatesDir, 'backups', version);
try {
await fsp.access(backupDir);
} catch (_) {
throw new Error(`No backup found for version ${version}`);
}
const local = this.getLocalVersion();
const hostBackupDir = backupDir.replace(this.config.updatesDir, this.config.hostUpdatesDir);
const trigger = {
action: 'rollback',
version: version,
fromVersion: local.version,
stagingDir: hostBackupDir,
apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
this.status = 'waiting';
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
JSON.stringify(trigger, null, 2)
);
this._addToHistory({
version: version,
fromVersion: local.version,
timestamp: new Date().toISOString(),
status: 'pending',
rollback: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
}
getAvailableRollbacks() {
const backupsDir = path.join(this.config.updatesDir, 'backups');
try {
return fs.readdirSync(backupsDir)
.filter(d => fs.statSync(path.join(backupsDir, d)).isDirectory())
.sort()
.reverse();
} catch (_) {
return [];
}
}
// ── History ──
getUpdateHistory() {
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
try {
return JSON.parse(fs.readFileSync(historyPath, 'utf8'));
} catch (_) {
return [];
}
}
// ── Private Methods ──
async _autoCheckAndApply() {
try {
const result = await this.checkForUpdate();
if (result.available && result.remote) {
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
await this.applyUpdate(result.remote);
}
} catch (e) {
console.error('[SelfUpdater] Auto-update error:', e.message);
}
}
_evaluateReleasePolicy(local, remote) {
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
const allowedChannels = Array.isArray(remote?.channels)
? remote.channels
: Array.isArray(remote?.eligibleChannels)
? remote.eligibleChannels
: [releaseChannel];
if (!allowedChannels.includes(this.config.channel)) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
releaseChannel,
allowedChannels,
};
}
if (remote?.revoked === true) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: 'release revoked',
releaseChannel,
allowedChannels,
};
}
const minUpdaterVersion = remote?.minUpdaterVersion;
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `requires updater >= ${minUpdaterVersion}`,
releaseChannel,
allowedChannels,
};
}
const rollout = this._normalizeRollout(remote?.rollout);
if (rollout < 100) {
const bucket = this._getRolloutBucket(this.instanceId);
if (bucket >= rollout) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `outside rollout (${bucket} >= ${rollout})`,
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: bucket,
};
}
}
const targets = remote?.targets;
if (targets && typeof targets === 'object') {
const platformKey = `${process.platform}-${process.arch}`;
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
if (!matchedTarget) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `no target for ${platformKey}`,
releaseChannel,
allowedChannels,
rollout,
};
}
}
return {
eligible: true,
newer: this._isNewer(local, remote),
reason: 'eligible',
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: this._getRolloutBucket(this.instanceId),
};
}
_isNewer(local, remote) {
if (!remote || !remote.version) return false;
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true;
if (versionCompare > 0) return false;
// Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false;
}
_compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const len = Math.max(av.length, bv.length, 3);
for (let i = 0; i < len; i++) {
const left = av[i] || 0;
const right = bv[i] || 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
_normalizeRollout(value) {
if (value == null) return 100;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 100;
return Math.max(0, Math.min(100, Math.floor(parsed)));
}
_getRolloutBucket(instanceId) {
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
return digest[0] % 100;
}
_loadOrCreateNotifySecret() {
try {
if (fs.existsSync(this.notifySecretFile)) {
const existing = fs.readFileSync(this.notifySecretFile, 'utf8').trim();
if (existing) return existing;
}
} catch (_) { /* regenerate */ }
const secret = crypto.randomBytes(24).toString('base64url');
try {
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);
}
return secret;
}
_loadOrCreateInstanceId() {
try {
if (fs.existsSync(this.config.instanceIdFile)) {
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
if (existing) return existing;
}
} catch (_) {
// Fall through and regenerate
}
const instanceId = crypto.randomUUID();
try {
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);
}
return instanceId;
}
_addToHistory(entry) {
const history = this.getUpdateHistory();
history.unshift(entry);
// Keep last 50 entries
if (history.length > 50) history.length = 50;
this._saveHistory(history);
}
_saveHistory(history) {
const historyPath = path.join(this.config.updatesDir, 'self-update-history.json');
try {
fs.writeFileSync(historyPath, JSON.stringify(history, null, 2));
} catch (e) {
console.error('[SelfUpdater] Failed to save history:', e.message);
}
}
async _ensureDirs() {
for (const dir of [this.config.updatesDir, path.join(this.config.updatesDir, 'staging'), path.join(this.config.updatesDir, 'backups')]) {
await fsp.mkdir(dir, { recursive: true }).catch(() => {});
}
}
async _fetchJson(url) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const req = mod.get(url, { timeout: 15000 }, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new Error(`HTTP ${res.statusCode} from ${url}`));
}
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error('Invalid JSON from ' + url));
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('Timeout fetching ' + url)); });
});
}
async _downloadFile(url, dest) {
return new Promise((resolve, reject) => {
const mod = url.startsWith('https') ? https : http;
const file = fs.createWriteStream(dest);
const req = mod.get(url, { timeout: DEFAULTS.DOWNLOAD_TIMEOUT }, (res) => {
if (res.statusCode !== 200) {
file.close();
fs.unlinkSync(dest);
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
}
res.pipe(file);
file.on('finish', () => { file.close(resolve); });
});
req.on('error', (e) => {
file.close();
fs.unlink(dest, () => {});
reject(e);
});
req.on('timeout', () => { req.destroy(); reject(new Error('Download timeout')); });
});
}
async _computeSha256(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => hash.update(chunk));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
async _extractTarball(tarballPath, destDir) {
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' });
} catch (e) {
throw new Error('Failed to extract tarball: ' + e.message);
}
}
_findDir(baseDir, name) {
const direct = path.join(baseDir, name);
if (fs.existsSync(direct)) return direct;
// Also check one level deeper (e.g., dashcaddy/dashcaddy-api)
try {
for (const entry of fs.readdirSync(baseDir)) {
const sub = path.join(baseDir, entry, name);
if (fs.existsSync(sub)) return sub;
}
} catch { /* ignore */ }
return null;
}
async _copyDir(src, dest, items) {
await fsp.mkdir(dest, { recursive: true });
for (const item of items) {
const srcPath = path.join(src, item);
const destPath = path.join(dest, item);
try {
const stat = await fsp.stat(srcPath);
if (stat.isDirectory()) {
await this._copyDirRecursive(srcPath, destPath);
} else {
await fsp.copyFile(srcPath, destPath);
}
} catch (_) {
// Item may not exist in the update — skip
}
}
}
async _copyDirRecursive(src, dest) {
await fsp.mkdir(dest, { recursive: true });
const entries = await fsp.readdir(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
await this._copyDirRecursive(srcPath, destPath);
} else {
await fsp.copyFile(srcPath, destPath);
}
}
}
async _cleanDir(dir) {
try {
await fsp.rm(dir, { recursive: true, force: true });
} catch { /* ignore */ }
await fsp.mkdir(dir, { recursive: true });
}
}
// Singleton
const selfUpdater = new SelfUpdater({
enabled: process.env.DASHCADDY_UPDATE_ENABLED !== 'false',
checkInterval: process.env.DASHCADDY_UPDATE_INTERVAL,
updateUrl: process.env.DASHCADDY_UPDATE_URL,
mirrorUrl: process.env.DASHCADDY_MIRROR_URL,
updatesDir: process.env.DASHCADDY_UPDATES_DIR,
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
});
module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;
+302
View File
@@ -0,0 +1,302 @@
/**
* Authentication Manager for DashCaddy
* Handles JWT tokens and API key generation/validation
* Provides defense-in-depth alongside Caddy forward_auth
*/
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const credentialManager = require('./credential-manager');
const cryptoUtils = require('../security/crypto-utils');
// JWT signing secret - derived from encryption key for consistency
const JWT_SECRET = cryptoUtils.loadOrCreateKey();
// Namespace for API keys in credential manager
const API_KEY_NAMESPACE = 'auth.apikey';
const API_KEY_METADATA_NAMESPACE = 'auth.metadata';
class AuthManager {
constructor() {
this.keyMetadataCache = new Map(); // Cache for API key metadata
console.log('[AuthManager] Initialized');
}
/**
* Generate JWT token
* @param {Object} payload - Token payload (must include sub: userId)
* @param {string} expiresIn - Expiration time (default: '24h')
* @returns {Promise<string>} JWT token
*/
async generateJWT(payload, expiresIn = '24h') {
try {
if (!payload.sub) {
throw new Error('JWT payload must include "sub" (subject/userId)');
}
const token = jwt.sign(
{
...payload,
iat: Math.floor(Date.now() / 1000),
scope: payload.scope || ['read', 'write']
},
JWT_SECRET,
{ expiresIn }
);
console.log(`[AuthManager] Generated JWT for user: ${payload.sub}, expires in: ${expiresIn}`);
return token;
} catch (error) {
console.error('[AuthManager] JWT generation failed:', error.message);
throw error;
}
}
/**
* Verify JWT token
* @param {string} token - JWT token to verify
* @returns {Promise<Object|null>} Decoded payload or null if invalid
*/
async verifyJWT(token) {
try {
const decoded = jwt.verify(token, JWT_SECRET);
return {
userId: decoded.sub,
scope: decoded.scope || [],
iat: decoded.iat,
exp: decoded.exp
};
} catch (error) {
if (error.name === 'TokenExpiredError') {
console.log('[AuthManager] JWT token expired');
} else if (error.name === 'JsonWebTokenError') {
console.log('[AuthManager] JWT token invalid:', error.message);
} else {
console.error('[AuthManager] JWT verification failed:', error.message);
}
return null;
}
}
/**
* Generate API key
* @param {string} name - Human-readable name for the key
* @param {Array<string>} scopes - Permission scopes (default: ['read', 'write'])
* @returns {Promise<Object>} { key, id, name, scopes, createdAt }
*/
async generateAPIKey(name, scopes = ['read', 'write']) {
try {
if (!name || typeof name !== 'string') {
throw new Error('API key name is required');
}
// Generate secure random key (32 bytes = 64 hex chars)
const keyId = crypto.randomBytes(16).toString('hex');
const keySecret = crypto.randomBytes(32).toString('hex');
const apiKey = `dk_${keyId}_${keySecret}`; // dk = DashCaddy Key
// Store key hash (not the key itself) in credential manager
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex');
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
await credentialManager.store(credentialKey, keyHash);
// Store metadata separately (non-sensitive)
const metadata = {
id: keyId,
name,
scopes,
createdAt: new Date().toISOString(),
lastUsed: null
};
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
await credentialManager.store(metadataKey, JSON.stringify(metadata));
// Cache metadata
this.keyMetadataCache.set(keyId, metadata);
console.log(`[AuthManager] Generated API key: ${name} (${keyId})`);
return {
key: apiKey,
id: keyId,
name,
scopes,
createdAt: metadata.createdAt
};
} catch (error) {
console.error('[AuthManager] API key generation failed:', error.message);
throw error;
}
}
/**
* Verify API key
* @param {string} key - API key to verify
* @returns {Promise<Object|null>} { keyId, scopes, name } or null if invalid
*/
async verifyAPIKey(key) {
try {
// Parse key format: dk_<keyId>_<secret>
if (!key || !key.startsWith('dk_')) {
return null;
}
const parts = key.split('_');
if (parts.length !== 3) {
return null;
}
const keyId = parts[1];
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
// Retrieve stored hash
const storedHash = await credentialManager.retrieve(credentialKey);
if (!storedHash) {
console.log(`[AuthManager] 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}`);
return null;
}
// Get metadata
const metadata = await this.getKeyMetadata(keyId);
if (!metadata) {
console.log(`[AuthManager] 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)
);
console.log(`[AuthManager] API key verified: ${metadata.name} (${keyId})`);
return {
keyId,
scopes: metadata.scopes || [],
name: metadata.name
};
} catch (error) {
console.error('[AuthManager] API key verification failed:', error.message);
return null;
}
}
/**
* Revoke API key
* @param {string} keyId - Key ID to revoke
* @returns {Promise<boolean>} Success status
*/
async revokeAPIKey(keyId) {
try {
const credentialKey = `${API_KEY_NAMESPACE}.${keyId}`;
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
await credentialManager.delete(credentialKey);
await credentialManager.delete(metadataKey);
this.keyMetadataCache.delete(keyId);
console.log(`[AuthManager] Revoked API key: ${keyId}`);
return true;
} catch (error) {
console.error(`[AuthManager] Failed to revoke API key ${keyId}:`, error.message);
return false;
}
}
/**
* List all API keys (returns metadata, not actual keys)
* @returns {Promise<Array<Object>>} Array of API key metadata
*/
async listAPIKeys() {
try {
const allKeys = await credentialManager.list();
const metadataKeys = allKeys.filter(k => k.startsWith(API_KEY_METADATA_NAMESPACE));
const keys = [];
for (const metaKey of metadataKeys) {
const keyId = metaKey.replace(`${API_KEY_METADATA_NAMESPACE}.`, '');
const metadata = await this.getKeyMetadata(keyId);
if (metadata) {
keys.push(metadata);
}
}
return keys;
} catch (error) {
console.error('[AuthManager] Failed to list API keys:', error.message);
return [];
}
}
/**
* Get metadata for a specific API key
* @param {string} keyId - Key ID
* @returns {Promise<Object|null>} Metadata or null
*/
async getKeyMetadata(keyId) {
try {
// Check cache first
if (this.keyMetadataCache.has(keyId)) {
return this.keyMetadataCache.get(keyId);
}
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
const metadataJson = await credentialManager.retrieve(metadataKey);
if (!metadataJson) {
return null;
}
const metadata = JSON.parse(metadataJson);
this.keyMetadataCache.set(keyId, metadata);
return metadata;
} catch (error) {
console.error(`[AuthManager] Failed to get metadata for ${keyId}:`, error.message);
return null;
}
}
/**
* Update last used timestamp for API key
* @param {string} keyId - Key ID
* @param {Object} metadata - Current metadata
* @returns {Promise<void>}
*/
async updateLastUsed(keyId, metadata) {
try {
const updatedMetadata = {
...metadata,
lastUsed: new Date().toISOString()
};
const metadataKey = `${API_KEY_METADATA_NAMESPACE}.${keyId}`;
await credentialManager.store(metadataKey, JSON.stringify(updatedMetadata));
this.keyMetadataCache.set(keyId, updatedMetadata);
} catch (error) {
console.error(`[AuthManager] Failed to update lastUsed for ${keyId}:`, error.message);
}
}
/**
* Clear metadata cache (useful for testing or cache invalidation)
*/
clearCache() {
this.keyMetadataCache.clear();
console.log('[AuthManager] Cache cleared');
}
}
// Export singleton instance
module.exports = new AuthManager();
@@ -0,0 +1,503 @@
/**
* Auto-Restart Manager - Per-container restart policies with retry tracking
*
* When a container goes down, attempts automatic restart up to N times
* (configurable per-service). Sends notifications on each attempt and
* when max retries are exceeded. Integrates with HealthChecker events.
*
* @module auto-restart-manager
*/
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('../utilities/fs-helpers');
/**
* Default policy values applied when a new policy is created.
* @readonly
*/
const DEFAULT_POLICY = {
enabled: true,
maxRetries: 3,
retryIntervalMs: 5000,
windowMinutes: 10,
currentRetries: 0,
lastRestartAt: null,
cooldownUntil: null,
};
/**
* Manages automatic container restart policies and execution.
*
* @extends EventEmitter
*
* @fires AutoRestartManager#auto-restart-attempt
* @fires AutoRestartManager#auto-restart-success
* @fires AutoRestartManager#auto-restart-failed
* @fires AutoRestartManager#auto-restart-max-reached
*/
class AutoRestartManager extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.healthChecker - HealthChecker singleton
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
* @param {string} ctx.SERVICES_FILE - Path to services.json (used to derive data dir)
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
this.docker = ctx.docker;
this.healthChecker = ctx.healthChecker;
this.notification = ctx.notification;
/** @type {Map<string, Object>} serviceId -> policy */
this.policies = new Map();
/** Path to the JSON file that persists policies */
this.policiesFile = path.join(path.dirname(ctx.SERVICES_FILE), 'auto-restart-policies.json');
/** Track previous health status per service for transition detection */
this._previousHealth = new Map();
/** Bound handlers so we can remove them on stop() */
this._onStatusCheck = this._handleStatusCheck.bind(this);
this._started = false;
}
// ─── Lifecycle ────────────────────────────────────────────────────────
/**
* Load persisted policies, then wire into HealthChecker events.
* @returns {Promise<void>}
*/
async start() {
if (this._started) return;
// Load persisted policies from disk
try {
const data = await readJsonFile(this.policiesFile, {});
for (const [serviceId, policy] of Object.entries(data)) {
this.policies.set(serviceId, { ...DEFAULT_POLICY, ...policy });
}
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
} catch (err) {
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
}
// Listen to health checker status transitions
if (this.healthChecker) {
this.healthChecker.on('status-check', this._onStatusCheck);
}
this._started = true;
this.log.info('auto-restart', 'Manager started');
}
/**
* Remove event listeners and stop processing health events.
*/
stop() {
if (!this._started) return;
if (this.healthChecker) {
this.healthChecker.removeListener('status-check', this._onStatusCheck);
}
this._started = false;
this.log.info('auto-restart', 'Manager stopped');
}
// ─── Policy CRUD ─────────────────────────────────────────────────────
/**
* Create or update a restart policy for a service.
*
* @param {string} serviceId - Unique service identifier
* @param {Object} policy - Partial policy fields to merge
* @param {boolean} [policy.enabled=true]
* @param {number} [policy.maxRetries=3]
* @param {number} [policy.retryIntervalMs=5000]
* @param {number} [policy.windowMinutes=10]
* @returns {Promise<Object>} The resulting policy
* @throws {Error} If serviceId is invalid
*/
async setPolicy(serviceId, policy) {
if (!serviceId || typeof serviceId !== 'string') {
throw new Error('serviceId is required');
}
const existing = this.policies.get(serviceId) || { ...DEFAULT_POLICY, serviceId };
const merged = {
...existing,
...policy,
serviceId,
// Never allow caller to override runtime counters directly
currentRetries: existing.currentRetries || 0,
lastRestartAt: existing.lastRestartAt,
cooldownUntil: existing.cooldownUntil,
};
this.policies.set(serviceId, merged);
await this._savePolicies();
this.log.info('auto-restart', 'Policy set', { serviceId, enabled: merged.enabled });
return { ...merged };
}
/**
* Retrieve the policy for a service.
*
* @param {string} serviceId
* @returns {Object|null} Policy object or null if none exists
*/
getPolicy(serviceId) {
const policy = this.policies.get(serviceId);
return policy ? { ...policy } : null;
}
/**
* Return all policies as an array.
* @returns {Object[]}
*/
listPolicies() {
return Array.from(this.policies.values()).map(p => ({ ...p }));
}
/**
* Remove a service's restart policy.
*
* @param {string} serviceId
* @returns {Promise<boolean>} true if a policy was removed
*/
async removePolicy(serviceId) {
if (!this.policies.has(serviceId)) return false;
this.policies.delete(serviceId);
await this._savePolicies();
this.log.info('auto-restart', 'Policy removed', { serviceId });
return true;
}
// ─── Core Restart Logic ──────────────────────────────────────────────
/**
* Called when a container is detected as down.
*
* Checks policy, cooldown, and retry count, then either attempts a
* Docker restart or notifies that max retries were exceeded.
*
* @param {string} serviceId - Service identifier
* @param {string} containerId - Docker container ID to restart
* @returns {Promise<Object>} Result of the operation
*/
async handleContainerDown(serviceId, containerId) {
const policy = this.policies.get(serviceId);
if (!policy) {
return { action: 'ignored', reason: 'no-policy' };
}
if (!policy.enabled) {
return { action: 'ignored', reason: 'disabled' };
}
// Check cooldown window
const now = Date.now();
if (policy.cooldownUntil && now < policy.cooldownUntil) {
this.log.info('auto-restart', 'Skipping — cooldown active', {
serviceId,
cooldownUntil: new Date(policy.cooldownUntil).toISOString(),
});
return { action: 'skipped', reason: 'cooldown' };
}
// Max retries exceeded — notify and enter cooldown
if (policy.currentRetries >= policy.maxRetries) {
const cooldownMs = policy.windowMinutes * 60 * 1000;
policy.cooldownUntil = now + cooldownMs;
policy.currentRetries = 0; // Reset so next window can try again
await this._savePolicies();
const eventData = {
serviceId,
containerId,
maxRetries: policy.maxRetries,
cooldownUntil: policy.cooldownUntil,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-max-reached
* @type {Object}
*/
this.emit('auto-restart-max-reached', eventData);
// Send notification
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `⛔ Max auto-restart retries (${policy.maxRetries}) exceeded for "${serviceId}". Cooldown until ${new Date(policy.cooldownUntil).toISOString()}.`,
...eventData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
return { action: 'max-reached', ...eventData };
}
// Wait for the configured retry interval before attempting
if (policy.retryIntervalMs > 0 && policy.lastRestartAt) {
const elapsed = now - new Date(policy.lastRestartAt).getTime();
if (elapsed < policy.retryIntervalMs) {
const waitMs = policy.retryIntervalMs - elapsed;
this.log.info('auto-restart', 'Waiting for retry interval', { serviceId, waitMs });
await new Promise(resolve => setTimeout(resolve, waitMs));
}
}
// Attempt restart
policy.currentRetries += 1;
const attemptNum = policy.currentRetries;
const maxRetries = policy.maxRetries;
/**
* @event AutoRestartManager#auto-restart-attempt
* @type {Object}
*/
this.emit('auto-restart-attempt', {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
});
try {
if (!this.docker?.client) {
throw new Error('Docker client not available');
}
const container = this.docker.client.getContainer(containerId);
await container.start();
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const successData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-success
* @type {Object}
*/
this.emit('auto-restart-success', successData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `🔄 Auto-restart attempt ${attemptNum}/${maxRetries} succeeded for "${serviceId}".`,
...successData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.info('auto-restart', 'Container restarted', {
serviceId,
attempt: attemptNum,
maxRetries,
});
return { action: 'restarted', ...successData };
} catch (restartErr) {
policy.lastRestartAt = new Date().toISOString();
await this._savePolicies();
const failData = {
serviceId,
containerId,
attempt: attemptNum,
maxRetries,
error: restartErr.message,
timestamp: new Date().toISOString(),
};
/**
* @event AutoRestartManager#auto-restart-failed
* @type {Object}
*/
this.emit('auto-restart-failed', failData);
// Notify
try {
await this._notify('auto-restart', {
containerName: serviceId,
message: `❌ Auto-restart attempt ${attemptNum}/${maxRetries} failed for "${serviceId}": ${restartErr.message}`,
...failData,
});
} catch (notifErr) {
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
}
this.log.error('auto-restart', 'Restart failed', {
serviceId,
attempt: attemptNum,
error: restartErr.message,
});
return { action: 'failed', ...failData };
}
}
/**
* Called when a container recovers to healthy state.
* Resets the retry counter for the associated service.
*
* @param {string} serviceId
* @returns {Promise<void>}
*/
async handleContainerUp(serviceId) {
const policy = this.policies.get(serviceId);
if (!policy) return;
if (policy.currentRetries > 0) {
policy.currentRetries = 0;
policy.cooldownUntil = null;
await this._savePolicies();
this.log.info('auto-restart', 'Retries reset after recovery', { serviceId });
}
}
// ─── Health Event Bridge ─────────────────────────────────────────────
/**
* Internal handler for HealthChecker `status-check` events.
* Detects healthy→unhealthy and unhealthy→healthy transitions for tracked services.
*
* @param {Object} status - HealthChecker status object
* @param {string} status.serviceId
* @param {string} status.status - "up" or "down"
* @private
*/
async _handleStatusCheck(status) {
const { serviceId, status: currentStatus } = status;
if (!serviceId) return;
// Only process services that have a restart policy
if (!this.policies.has(serviceId)) return;
const previousStatus = this._previousHealth.get(serviceId);
this._previousHealth.set(serviceId, currentStatus);
// Transition: healthy → unhealthy
if (previousStatus === 'up' && currentStatus === 'down') {
// Find the containerId from the health checker config or status details
const containerId = this._resolveContainerId(serviceId, status);
if (containerId) {
try {
await this.handleContainerDown(serviceId, containerId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
// Transition: unhealthy → healthy (recovery)
if (previousStatus === 'down' && currentStatus === 'up') {
try {
await this.handleContainerUp(serviceId);
} catch (err) {
this.logError('auto-restart-health-bridge', err);
}
}
}
/**
* Attempt to find the containerId for a service from various sources.
*
* @param {string} serviceId
* @param {Object} status - The status-check event data
* @returns {string|null}
* @private
*/
_resolveContainerId(serviceId, status) {
// Check if it's in the status details (some health checks embed it)
if (status.details?.containerId) return status.details.containerId;
// Look in the health checker config
const hcService = this.healthChecker?.config?.services?.[serviceId];
if (hcService?.containerId) return hcService.containerId;
// Try to look it up from the services state manager
try {
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const readResult = servicesStateManager.read();
if (readResult && typeof readResult.then === 'function') {
// It returns a promise — fire-and-forget lookup
readResult.then(list => {
const found = (list || []).find(s => s.id === serviceId);
return found?.containerId || null;
}).catch(() => null);
} else {
const found = (readResult || []).find(s => s.id === serviceId);
if (found?.containerId) return found.containerId;
}
}
} catch (_) { /* best effort */ }
return null;
}
// ─── Persistence ─────────────────────────────────────────────────────
/**
* Persist current policies to disk.
* @returns {Promise<void>}
* @private
*/
async _savePolicies() {
try {
const obj = {};
for (const [serviceId, policy] of this.policies.entries()) {
obj[serviceId] = { ...policy };
}
await writeJsonFile(this.policiesFile, obj);
} catch (err) {
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
}
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Send a notification via the notification manager.
*
* @param {string} event - Event type (e.g. 'auto-restart')
* @param {Object} data - Notification payload
* @returns {Promise<Object>}
* @private
*/
async _notify(event, data) {
if (this.notification?.send) {
return this.notification.send(event, data);
}
return { success: false, reason: 'no-notification-manager' };
}
}
module.exports = { AutoRestartManager, DEFAULT_POLICY };
@@ -0,0 +1,376 @@
/**
* Config Drift Detector - Compares services.json with live Docker state
*
* Detects discrepancies between the configured service list and what is
* actually running in Docker, including missing containers, unknown
* containers, port mismatches, state mismatches, and stale records.
*
* @module config-drift-detector
*/
const EventEmitter = require('events');
/**
* @typedef {Object} DriftReport
* @property {string} checkedAt - ISO timestamp of the check
* @property {Object[]} missingContainers - Services with containerId but container absent in Docker
* @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json
* @property {Object[]} portMismatch - Service port != container mapped port
* @property {Object[]} stateMismatch - Service expected up but container stopped/absent
* @property {Object[]} staleRecords - Services with containerId pointing to removed containers
* @property {boolean} hasDrift - Whether any drift category is non-empty
*/
/**
* Detects and reports configuration drift between services.json and Docker.
*
* @extends EventEmitter
*
* @fires ConfigDriftDetector#drift-detected
*/
class ConfigDriftDetector extends EventEmitter {
/**
* @param {Object} ctx - Shared application context
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {Function} ctx.logError - Error logging function
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
this.logError = ctx.logError || ((_c, err) => console.error(err));
this.docker = ctx.docker;
this.servicesStateManager = ctx.servicesStateManager;
this.notification = ctx.notification;
/** @type {DriftReport|null} Cached report from last detection */
this.lastReport = null;
/** @type {NodeJS.Timeout|null} Polling timer reference */
this._pollTimer = null;
/** Whether polling is currently active */
this._polling = false;
}
// ─── Detection ───────────────────────────────────────────────────────
/**
* Run a full drift detection and return the report.
*
* Reads services from servicesStateManager and live containers from Docker,
* then compares them across five drift categories.
*
* @returns {Promise<DriftReport>}
*/
async detect() {
const checkedAt = new Date().toISOString();
// Gather configured services
let services = [];
try {
const data = await this.servicesStateManager.read();
services = Array.isArray(data) ? data : (data.services || []);
} catch (err) {
this.log.error('drift', 'Failed to read services', { error: err.message });
}
// Gather live Docker containers
let containers = [];
try {
containers = await this.docker.client.listContainers({ all: true });
} catch (err) {
this.log.error('drift', 'Failed to list containers', { error: err.message });
}
// Build lookup maps
const containerById = new Map(); // containerId (short or long) → container info
const containerByName = new Map(); // container name → container info
for (const c of containers) {
// Store by full ID
containerById.set(c.Id, c);
// Store by short ID (first 12 chars)
if (c.Id && c.Id.length >= 12) {
containerById.set(c.Id.substring(0, 12), c);
}
// Store by name (strip leading /)
for (const name of (c.Names || [])) {
containerByName.set(name.replace(/^\//, ''), c);
}
}
// Build set of service containerIds for reverse lookup
const serviceContainerIds = new Set();
const serviceByContainerId = new Map();
for (const svc of services) {
if (svc.containerId) {
serviceContainerIds.add(svc.containerId);
// Index by both full and short ID
serviceByContainerId.set(svc.containerId, svc);
if (svc.containerId.length >= 12) {
serviceByContainerId.set(svc.containerId.substring(0, 12), svc);
}
}
}
const missingContainers = [];
const portMismatch = [];
const stateMismatch = [];
const staleRecords = [];
for (const svc of services) {
if (!svc.containerId) continue;
// Look up the container
const container = containerById.get(svc.containerId)
|| containerById.get(svc.containerId.substring(0, 12));
if (!container) {
// Container ID referenced but not found in Docker at all
staleRecords.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
reason: 'Container not found in Docker',
});
continue;
}
// Missing container — service expects it but it's not running
if (container.State !== 'running') {
missingContainers.push({
serviceId: svc.id,
name: svc.name,
containerId: svc.containerId,
containerState: container.State,
containerStatus: container.Status,
});
// Also a state mismatch if the service is expected to be up
stateMismatch.push({
serviceId: svc.id,
name: svc.name,
expectedState: 'running',
actualState: container.State,
containerId: svc.containerId,
});
}
// Port mismatch detection
if (svc.port && container.State === 'running') {
const actualPorts = this._extractContainerPorts(container);
if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) {
portMismatch.push({
serviceId: svc.id,
name: svc.name,
configuredPort: svc.port,
actualPorts,
containerId: svc.containerId,
});
}
}
}
// Unknown managed containers: Docker containers with sami.managed label
// that are NOT in services.json
const unknownContainers = [];
for (const c of containers) {
const isManaged = c.Labels && c.Labels['sami.managed'] === 'true';
if (!isManaged) continue;
const isInServices = serviceByContainerId.has(c.Id)
|| serviceByContainerId.has(c.Id.substring(0, 12));
if (!isInServices) {
unknownContainers.push({
containerId: c.Id,
name: (c.Names && c.Names[0] || '').replace(/^\//, ''),
image: c.Image,
state: c.State,
status: c.Status,
app: c.Labels?.['sami.app'] || null,
subdomain: c.Labels?.['sami.subdomain'] || null,
});
}
}
const report = {
checkedAt,
missingContainers,
unknownContainers,
portMismatch,
stateMismatch,
staleRecords,
hasDrift: missingContainers.length > 0
|| unknownContainers.length > 0
|| portMismatch.length > 0
|| stateMismatch.length > 0
|| staleRecords.length > 0,
};
// Cache for quick API access
this.lastReport = report;
// Emit and notify if drift detected
if (report.hasDrift) {
/**
* @event ConfigDriftDetector#drift-detected
* @type {DriftReport}
*/
this.emit('drift-detected', report);
try {
await this._sendDriftNotification(report);
} catch (notifErr) {
this.log.error('drift', 'Failed to send drift notification', {
error: notifErr.message,
});
}
}
this.log.info('drift', 'Detection complete', {
hasDrift: report.hasDrift,
missing: report.missingContainers.length,
unknown: report.unknownContainers.length,
portMismatch: report.portMismatch.length,
stateMismatch: report.stateMismatch.length,
stale: report.staleRecords.length,
});
return report;
}
// ─── Auto-fix ────────────────────────────────────────────────────────
/**
* Attempt to auto-fix drift:
* - Remove stale records (services referencing removed containers)
* - Flag unknown containers for review
*
* @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>}
*/
async autoFix() {
const report = await this.detect();
let staleRemoved = 0;
// Remove stale records from services.json
if (report.staleRecords.length > 0) {
const staleIds = new Set(report.staleRecords.map(r => r.serviceId));
await this.servicesStateManager.update(services => {
const before = services.length;
const cleaned = services.filter(s => !staleIds.has(s.id));
staleRemoved = before - cleaned.length;
return cleaned;
});
}
const unknownFlagged = report.unknownContainers.length;
this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged });
return { staleRemoved, unknownFlagged };
}
// ─── Polling ─────────────────────────────────────────────────────────
/**
* Start periodic drift detection.
*
* @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min)
*/
startPolling(intervalMs = 300000) {
this.stopPolling();
this._polling = true;
this._pollTimer = setInterval(async () => {
try {
await this.detect();
} catch (err) {
this.logError('drift-poll', err);
}
}, intervalMs);
this.log.info('drift', 'Polling started', { intervalMs });
}
/**
* Stop periodic drift detection.
*/
stopPolling() {
if (this._pollTimer) {
clearInterval(this._pollTimer);
this._pollTimer = null;
}
this._polling = false;
this.log.info('drift', 'Polling stopped');
}
/**
* Whether polling is currently active.
* @returns {boolean}
*/
isPolling() {
return this._polling;
}
// ─── Helpers ─────────────────────────────────────────────────────────
/**
* Extract mapped host ports from a Docker container info object.
*
* @param {Object} container - Dockerode container info
* @returns {number[]} Array of host port numbers
* @private
*/
_extractContainerPorts(container) {
const ports = [];
if (!container.Ports) return ports;
for (const p of container.Ports) {
if (p.PublicPort) {
ports.push(p.PublicPort);
}
}
return ports;
}
/**
* Send a notification about detected drift.
*
* @param {DriftReport} report
* @returns {Promise<Object>}
* @private
*/
async _sendDriftNotification(report) {
if (!this.notification?.send) {
return { success: false, reason: 'no-notification-manager' };
}
const parts = [];
if (report.missingContainers.length > 0) {
parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`);
}
if (report.unknownContainers.length > 0) {
parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`);
}
if (report.portMismatch.length > 0) {
parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`);
}
if (report.staleRecords.length > 0) {
parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`);
}
return this.notification.send('drift-detected', {
text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`,
report,
});
}
}
module.exports = { ConfigDriftDetector };
@@ -0,0 +1,461 @@
/**
* Credential Manager for DashCaddy
* Unified interface for secure credential storage
* Uses OS keychain when available, falls back to encrypted file storage
*/
const keychainManager = require('../security/keychain-manager');
const cryptoUtils = require('../security/crypto-utils');
const lockfile = require('proper-lockfile');
const fs = require('fs');
const path = require('path');
// Resolve credentials file path — supports both standard install (/app/credentials.json)
// and custom deployments with consolidated data directory (/app/data/credentials.json)
function resolveCredentialsFile() {
if (process.env.CREDENTIALS_FILE) {
return process.env.CREDENTIALS_FILE;
}
const candidates = [
path.join(__dirname, 'credentials.json'),
path.join(__dirname, 'data', 'credentials.json'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// No existing file — return standard path so first store() creates it there
return candidates[0];
}
const CREDENTIALS_FILE = resolveCredentialsFile();
class CredentialManager {
constructor() {
this.useKeychain = keychainManager.available;
this.cache = new Map(); // In-memory cache with TTL
this.CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
this.lockOptions = {
retries: { retries: 10, minTimeout: 100, maxTimeout: 300 },
stale: 30000
};
console.log(`[CredentialManager] Initialized with ${this.useKeychain ? 'OS keychain' : 'encrypted file'} storage`);
}
/**
* Store a credential securely
* @param {string} key - Credential identifier (e.g., 'dns.token', 'cloudflare.apikey')
* @param {string} value - Credential value
* @param {Object} metadata - Optional metadata (non-sensitive)
* @returns {Promise<boolean>} Success status
*/
async store(key, value, metadata = {}) {
try {
// Validate inputs
if (!key || typeof key !== 'string') {
throw new Error('Credential key is required');
}
if (!value || typeof value !== 'string') {
throw new Error('Credential value is required');
}
// Try OS keychain first
if (this.useKeychain) {
const success = await keychainManager.store(key, value);
if (success) {
// 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`);
return true;
}
console.warn(`[CredentialManager] Keychain storage failed for '${key}', falling back to encrypted file`);
}
// 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`);
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to store '${key}':`, error.message);
return false;
}
}
/**
* Retrieve a credential
* @param {string} key - Credential identifier
* @returns {Promise<string|null>} Credential value or null
*/
async retrieve(key) {
try {
// Check cache first (with TTL expiration)
if (this.cache.has(key)) {
const cached = this.cache.get(key);
if (Date.now() < cached.exp) {
return cached.value;
}
this.cache.delete(key);
}
// Try OS keychain first
if (this.useKeychain) {
const value = await keychainManager.retrieve(key);
if (value) {
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
return value;
}
}
// Fallback to encrypted file storage
const value = await this.retrieveFromFile(key);
if (value) {
this.cache.set(key, { value, exp: Date.now() + this.CACHE_TTL_MS });
}
return value;
} catch (error) {
console.error(`[CredentialManager] Failed to retrieve '${key}':`, error.message);
return null;
}
}
/**
* Delete a credential
* @param {string} key - Credential identifier
* @returns {Promise<boolean>} Success status
*/
async delete(key) {
try {
// Remove from cache
this.cache.delete(key);
// Try OS keychain
if (this.useKeychain) {
await keychainManager.delete(key);
}
// Remove from file storage
await this.deleteFromFile(key);
console.log(`[CredentialManager] Deleted '${key}'`);
return true;
} catch (error) {
console.error(`[CredentialManager] Failed to delete '${key}':`, error.message);
return false;
}
}
/**
* List all stored credential keys (not values)
* @returns {Promise<Array<string>>} Array of credential keys
*/
async list() {
try {
const credentials = await this.loadCredentialsFile();
return Object.keys(credentials);
} catch (error) {
console.error('[CredentialManager] Failed to list credentials:', error.message);
return [];
}
}
/**
* Get metadata for a credential
* @param {string} key - Credential identifier
* @returns {Promise<Object|null>} Metadata object or null
*/
async getMetadata(key) {
try {
const credentials = await this.loadCredentialsFile();
return credentials[key]?.metadata || null;
} catch (error) {
return null;
}
}
/**
* Rotate encryption key (re-encrypt all credentials with new key)
* @returns {Promise<boolean>} Success status
*/
async rotateEncryptionKey() {
let release;
try {
console.log('[CredentialManager] Starting encryption key rotation...');
// Ensure file exists before locking
this._ensureFileExists();
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
const credentials = JSON.parse(data);
const keys = Object.keys(credentials);
if (keys.length === 0) {
console.log('[CredentialManager] No credentials to rotate');
return true;
}
// Decrypt all values with the CURRENT key first
const decryptedEntries = {};
for (const key of keys) {
const value = credentials[key].value;
decryptedEntries[key] = {
plaintext: cryptoUtils.isEncrypted(value) ? cryptoUtils.decrypt(value) : value,
metadata: credentials[key].metadata
};
}
// Generate new key (this replaces the cached key and saves to disk)
const { oldKey } = cryptoUtils.rotateKey();
// Re-encrypt all credentials with the new key
const rotated = {};
for (const key of keys) {
rotated[key] = {
value: cryptoUtils.encrypt(decryptedEntries[key].plaintext),
metadata: decryptedEntries[key].metadata,
rotatedAt: new Date().toISOString()
};
}
// Save with new encryption
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(rotated, null, 2), { mode: 0o600 });
// Clear cache to force reload
this.cache.clear();
console.log(`[CredentialManager] Successfully rotated ${keys.length} credentials`);
return true;
} catch (error) {
console.error('[CredentialManager] Key rotation failed:', error.message);
return false;
} finally {
if (release) {
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
}
}
}
/**
* Migrate plaintext credentials to encrypted format
* @returns {Promise<Object>} Migration results
*/
async migrateToEncrypted() {
try {
let migrated = 0;
let skipped = 0;
await this._lockedUpdate(credentials => {
for (const [key, data] of Object.entries(credentials)) {
if (!cryptoUtils.isEncrypted(data.value)) {
credentials[key].value = cryptoUtils.encrypt(data.value);
credentials[key].migratedAt = new Date().toISOString();
migrated++;
} else {
skipped++;
}
}
return credentials;
});
if (migrated > 0) {
this.cache.clear();
console.log(`[CredentialManager] Migrated ${migrated} plaintext credentials to encrypted format`);
}
return { migrated, skipped, total: migrated + skipped };
} catch (error) {
console.error('[CredentialManager] Migration failed:', error.message);
throw error;
}
}
// Private methods
/**
* Ensure credentials file exists (needed before locking)
* @private
*/
_ensureFileExists() {
if (!fs.existsSync(CREDENTIALS_FILE)) {
const dir = path.dirname(CREDENTIALS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CREDENTIALS_FILE, '{}', { mode: 0o600 });
}
}
/**
* Atomic read-modify-write with file locking
* @param {Function} updateFn - Receives current credentials object, returns updated object
* @returns {Promise<Object>} Updated credentials
* @private
*/
async _lockedUpdate(updateFn) {
this._ensureFileExists();
let release;
try {
release = await lockfile.lock(CREDENTIALS_FILE, this.lockOptions);
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
const credentials = JSON.parse(data);
const updated = await updateFn(credentials);
fs.writeFileSync(CREDENTIALS_FILE, JSON.stringify(updated, null, 2), { mode: 0o600 });
return updated;
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('Credentials file is locked by another process. Try again.');
}
throw error;
} finally {
if (release) {
try { await release(); } catch (e) { /* lock will expire via stale timeout */ }
}
}
}
async storeInFile(key, value, metadata) {
await this._lockedUpdate(credentials => {
credentials[key] = {
value: cryptoUtils.encrypt(value),
metadata,
updatedAt: new Date().toISOString()
};
return credentials;
});
}
async retrieveFromFile(key) {
const credentials = await this.loadCredentialsFile();
const data = credentials[key];
if (!data) return null;
return cryptoUtils.isEncrypted(data.value)
? cryptoUtils.decrypt(data.value)
: data.value;
}
/**
* Retrieve a credential with diagnostic info on failure.
*
* Used by the TOTP recovery flow: when a user is locked out and the secret
* can't be decrypted (e.g. encryption key was rotated by a container
* recreate), we need to distinguish "no secret was ever set" from "secret
* is on disk but unreadable" so the UI can show a useful next step.
*
* Status codes:
* 'ok' — value decrypted / returned as-is
* 'missing' — key is not present in the store at all
* 'unreadable' — key is present but decryption failed (key mismatch / corruption)
* 'malformed' — entry exists but value is not in expected encrypted format
*
* @param {string} key - Credential identifier
* @returns {Promise<{ status: string, value: string|null, error?: string }>}
*/
async diagnose(key) {
try {
const credentials = await this.loadCredentialsFile();
const data = credentials[key];
if (!data) return { status: 'missing', value: null };
if (!cryptoUtils.isEncrypted(data.value)) {
// Plaintext entry — return as-is
return { status: 'ok', value: data.value };
}
try {
const decrypted = cryptoUtils.decrypt(data.value);
return { status: 'ok', value: decrypted };
} catch (decryptErr) {
// 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}`
);
return { status: 'unreadable', value: null, error: decryptErr.message };
}
} catch (err) {
console.error(`[CredentialManager] diagnose('${key}') failed:`, err.message);
return { status: 'malformed', value: null, error: err.message };
}
}
async deleteFromFile(key) {
await this._lockedUpdate(credentials => {
delete credentials[key];
return credentials;
});
}
async storeMetadata(key, metadata) {
await this._lockedUpdate(credentials => {
if (!credentials[key]) {
credentials[key] = { metadata };
} else {
credentials[key].metadata = metadata;
}
credentials[key].updatedAt = new Date().toISOString();
return credentials;
});
}
async loadCredentialsFile() {
try {
if (!fs.existsSync(CREDENTIALS_FILE)) {
return {};
}
const data = fs.readFileSync(CREDENTIALS_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('[CredentialManager] Failed to load credentials file:', error.message);
return {};
}
}
/**
* Export credentials for backup (encrypted)
* @returns {Promise<string>} Encrypted backup data
*/
async exportBackup() {
const credentials = await this.loadCredentialsFile();
const backup = {
version: '1.0',
exportedAt: new Date().toISOString(),
credentials
};
return cryptoUtils.encrypt(JSON.stringify(backup));
}
/**
* Import credentials from backup
* @param {string} encryptedBackup - Encrypted backup data
* @returns {Promise<boolean>} Success status
*/
async importBackup(encryptedBackup) {
try {
const decrypted = cryptoUtils.decrypt(encryptedBackup);
const backup = JSON.parse(decrypted);
if (backup.version !== '1.0') {
throw new Error('Unsupported backup version');
}
await this._lockedUpdate(() => backup.credentials);
this.cache.clear();
console.log('[CredentialManager] Successfully imported backup');
return true;
} catch (error) {
console.error('[CredentialManager] Failed to import backup:', error.message);
return false;
}
}
}
// Export singleton instance
module.exports = new CredentialManager();
@@ -0,0 +1,605 @@
/**
* Dependency Manager - Service dependency tracking with ordered restart chains
*
* Manages directed acyclic graph (DAG) of service dependencies. Services can
* declare which other services they depend on, and this manager provides:
* - Full dependency graph inspection
* - Topological ordering for safe restart chains
* - Circular dependency detection
* - Health-aware restart with per-service polling
*
* Dependencies are stored directly on service objects in services.json:
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
*
* @module dependency-manager
*/
const EventEmitter = require('events');
/** Maximum seconds to wait for a single container to become healthy after restart */
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
/** Interval between container health polls */
const HEALTH_CHECK_INTERVAL_MS = 1_000;
/**
* @typedef {Object} ServiceNode
* @property {string} serviceId
* @property {string} name
* @property {string|null} containerId
*/
/**
* @typedef {Object} DependencyEdge
* @property {string} from - The service that depends
* @property {string} to - The service being depended upon
*/
/**
* @typedef {Object} DependencyGraph
* @property {ServiceNode[]} nodes
* @property {DependencyEdge[]} edges
*/
/**
* @typedef {Object} DependencyStatusEntry
* @property {string} serviceId
* @property {string} name
* @property {boolean} isUp
* @property {string} [error]
*/
/**
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
*
* Events emitted:
* - `dependency-restart-start` ({ serviceId, chain: string[] })
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
*
* @extends EventEmitter
*/
class DependencyManager extends EventEmitter {
/**
* @param {Object} ctx - Application context
* @param {Object} ctx.servicesStateManager - StateManager for services.json
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
*/
constructor(ctx) {
super();
/** @private */
this.ctx = ctx;
/** @private */
this._servicesStateManager = ctx.servicesStateManager;
/** @private */
this._docker = ctx.docker;
/** @private */
this._notification = ctx.notification;
/** @private */
this._log = ctx.log || console;
}
// ---------------------------------------------------------------------------
// Core helpers
// ---------------------------------------------------------------------------
/**
* Load all services from the state manager.
* @private
* @returns {Promise<Object[]>}
*/
async _loadServices() {
const data = await this._servicesStateManager.read();
return Array.isArray(data) ? data : (data.services || []);
}
/**
* Find a single service by ID.
* @private
* @param {string} serviceId
* @returns {Promise<Object|null>}
*/
async _findService(serviceId) {
const services = await this._loadServices();
return services.find(s => s.id === serviceId) || null;
}
// ---------------------------------------------------------------------------
// Graph queries
// ---------------------------------------------------------------------------
/**
* Return the full dependency graph for visualisation.
*
* @returns {Promise<DependencyGraph>}
*/
async getDependencyGraph() {
const services = await this._loadServices();
const nodes = services.map(s => ({
serviceId: s.id,
name: s.name,
containerId: s.containerId || null,
}));
const edges = [];
for (const service of services) {
const deps = service.dependsOn || [];
for (const depId of deps) {
edges.push({ from: service.id, to: depId });
}
}
return { nodes, edges };
}
/**
* Return the services that depend on the given service (reverse deps).
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
*/
async getDependents(serviceId) {
const services = await this._loadServices();
return services.filter(s => (s.dependsOn || []).includes(serviceId));
}
/**
* Return the direct dependencies for a service.
*
* @param {string} serviceId
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
*/
async getDependencies(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) return [];
const depIds = service.dependsOn || [];
return services.filter(s => depIds.includes(s.id));
}
// ---------------------------------------------------------------------------
// Topological sort
// ---------------------------------------------------------------------------
/**
* Build an adjacency list for the current dependency graph.
* Edge direction: service → its dependencies (i.e. what it depends on).
*
* @private
* @param {Object[]} services
* @returns {Map<string, string[]>}
*/
_buildAdjacencyList(services) {
const adj = new Map();
for (const service of services) {
adj.set(service.id, (service.dependsOn || []).slice());
}
return adj;
}
/**
* DFS-based topological sort with cycle detection (white/gray/black coloring).
*
* Returns services in restart order: dependencies first, dependents last.
* The target service is included at the end.
*
* @private
* @param {string} serviceId - Target service (will be last in the result).
* @param {Object[]} services - All services.
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
* @returns {string[]} Ordered service IDs for restart.
* @throws {Error} If a circular dependency is detected.
*/
_topologicalSort(serviceId, services, adj) {
// Collect only the reachable sub-graph from serviceId
const visited = new Set();
const reachable = new Set();
const collectReachable = (id) => {
if (reachable.has(id)) return;
reachable.add(id);
for (const dep of (adj.get(id) || [])) {
collectReachable(dep);
}
};
collectReachable(serviceId);
// DFS topological sort on the reachable sub-graph
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const id of reachable) color.set(id, WHITE);
const result = [];
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
result.push(id);
};
// Visit the target last so it ends up at the end of the result
// Actually, we want deps *first* then the target.
// The DFS naturally puts deps before dependents, so starting from
// serviceId will place it last (which is correct for restart order).
dfs(serviceId);
return result;
}
/**
* Get the topologically ordered restart chain for a service.
*
* The returned array lists all services that must be restarted,
* starting with leaf dependencies and ending with the target service.
*
* @param {string} serviceId - The service to build the chain for.
* @returns {Promise<string[]>} Ordered service IDs.
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
*/
async getOrderedRestartChain(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
const adj = this._buildAdjacencyList(services);
return this._topologicalSort(serviceId, services, adj);
}
// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------
/**
* Validate a proposed set of dependencies for a service.
*
* Checks:
* - All referenced service IDs exist.
* - Adding these dependencies would not create a circular dependency.
* - A service cannot depend on itself.
*
* @param {string} serviceId - The service to set dependencies on.
* @param {string[]} dependsOn - Proposed dependency IDs.
* @returns {Promise<{ valid: boolean, errors: string[] }>}
*/
async validateDependencies(serviceId, dependsOn) {
const errors = [];
if (!Array.isArray(dependsOn)) {
return { valid: false, errors: ['dependsOn must be an array'] };
}
const services = await this._loadServices();
const allIds = new Set(services.map(s => s.id));
// Service must exist
if (!allIds.has(serviceId)) {
return { valid: false, errors: [`Service "${serviceId}" not found`] };
}
// Self-dependency
if (dependsOn.includes(serviceId)) {
errors.push(`Service "${serviceId}" cannot depend on itself`);
}
// Existence check
for (const depId of dependsOn) {
if (!allIds.has(depId)) {
errors.push(`Dependency service "${depId}" does not exist`);
}
}
if (errors.length > 0) {
return { valid: false, errors };
}
// Circular dependency check: temporarily set the proposed dependsOn
// and attempt a topological sort.
const tempServices = services.map(s => {
if (s.id === serviceId) {
return { ...s, dependsOn: dependsOn.slice() };
}
return { ...s };
});
const adj = this._buildAdjacencyList(tempServices);
// Check every node for cycles with the new edges
try {
const WHITE = 0, GRAY = 1, BLACK = 2;
const color = new Map();
for (const s of tempServices) color.set(s.id, WHITE);
const dfs = (id) => {
if (color.get(id) === BLACK) return;
if (color.get(id) === GRAY) {
throw new Error(`Circular dependency detected involving service "${id}"`);
}
color.set(id, GRAY);
for (const dep of (adj.get(id) || [])) {
dfs(dep);
}
color.set(id, BLACK);
};
for (const s of tempServices) {
if (color.get(s.id) === WHITE) {
dfs(s.id);
}
}
} catch (err) {
errors.push(err.message);
}
return { valid: errors.length === 0, errors };
}
// ---------------------------------------------------------------------------
// Health status
// ---------------------------------------------------------------------------
/**
* Get the current container status for a service and all its transitive dependencies.
*
* @param {string} serviceId
* @returns {Promise<DependencyStatusEntry[]>}
* @throws {Error} If `serviceId` doesn't exist.
*/
async getDependencyStatus(serviceId) {
const services = await this._loadServices();
const service = services.find(s => s.id === serviceId);
if (!service) {
throw new Error(`Service "${serviceId}" not found`);
}
// Collect all transitive dependencies via BFS
const serviceMap = new Map(services.map(s => [s.id, s]));
const visited = new Set();
const queue = [serviceId];
const allRelated = [];
while (queue.length > 0) {
const currentId = queue.shift();
if (visited.has(currentId)) continue;
visited.add(currentId);
const svc = serviceMap.get(currentId);
if (!svc) continue;
allRelated.push(svc);
for (const depId of (svc.dependsOn || [])) {
if (!visited.has(depId)) {
queue.push(depId);
}
}
}
// Query container status for each
const results = [];
for (const svc of allRelated) {
const entry = {
serviceId: svc.id,
name: svc.name,
isUp: false,
};
if (!svc.containerId) {
entry.error = 'No container associated with this service';
results.push(entry);
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
const info = await container.inspect();
entry.isUp = info.State?.Running === true;
} catch (err) {
entry.error = err.message || 'Unable to inspect container';
}
results.push(entry);
}
return results;
}
// ---------------------------------------------------------------------------
// Restart with dependencies
// ---------------------------------------------------------------------------
/**
* Wait for a container to report as running after a restart.
*
* @private
* @param {string} containerId
* @param {number} [timeoutMs=30000]
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
*/
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const container = this._docker.client.getContainer(containerId);
const info = await container.inspect();
if (info.State?.Running === true) {
return true;
}
} catch {
// Container might not be inspectable during restart — keep polling
}
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
}
return false;
}
/**
* Restart a service and all its dependencies in topological order.
*
* Emits progress events and sends a notification on completion/failure.
* This method is designed to be called from the route handler and
* **does not throw** — errors are reported via events and notifications.
*
* @param {string} serviceId - Target service to restart (with deps).
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
*/
async restartWithDependencies(serviceId) {
const service = await this._findService(serviceId);
if (!service) {
const err = new Error(`Service "${serviceId}" not found`);
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
let chain;
try {
chain = await this.getOrderedRestartChain(serviceId);
} catch (err) {
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: serviceId,
error: err.message,
chain: [],
});
throw err;
}
const services = await this._loadServices();
const serviceMap = new Map(services.map(s => [s.id, s]));
this._log.info('dependency', 'Starting dependency restart chain', {
serviceId,
chain,
});
this.emit('dependency-restart-start', { serviceId, chain });
const results = [];
const total = chain.length;
for (let i = 0; i < total; i++) {
const currentId = chain[i];
const svc = serviceMap.get(currentId);
this.emit('dependency-restart-progress', {
serviceId,
currentServiceId: currentId,
index: i,
total,
});
if (!svc || !svc.containerId) {
const msg = !svc
? `Service "${currentId}" not found in state`
: `Service "${currentId}" has no container — skipping restart`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
continue;
}
try {
const container = this._docker.client.getContainer(svc.containerId);
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
containerId: svc.containerId,
});
await container.restart();
// Wait for it to come back up
const healthy = await this._waitForContainerHealthy(svc.containerId);
if (!healthy) {
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
this._log.warn('dependency', msg);
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
// Abort chain — dependency didn't come back
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
results.push({ serviceId: currentId, restarted: true, healthy: true });
} catch (err) {
const msg = err.message || 'Unknown error during restart';
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
error: msg,
});
results.push({ serviceId: currentId, restarted: false, error: msg });
this.emit('dependency-restart-failed', {
serviceId,
failedServiceId: currentId,
error: msg,
chain,
});
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
return { success: false, chain, results };
}
}
this.emit('dependency-restart-complete', { serviceId, chain, results });
await this._notifyRestartResult(serviceId, true, chain, results);
return { success: true, chain, results };
}
/**
* Send a notification about the restart result.
*
* @private
* @param {string} serviceId
* @param {boolean} success
* @param {string[]} chain
* @param {Array} results
* @param {string} [failedServiceId]
*/
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
if (!this._notification) return;
try {
if (success) {
await this._notification.send('dependency-restart-complete', {
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
serviceId,
chain,
results,
});
} else {
await this._notification.send('dependency-restart-failed', {
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
serviceId,
failedServiceId,
chain,
results,
});
}
} catch (err) {
this._log.error('dependency', 'Failed to send restart notification', {
error: err.message,
});
}
}
}
module.exports = DependencyManager;
@@ -0,0 +1,494 @@
/**
* DashCaddy License Manager
*
* Runtime license validation, activation, and feature gating.
* Uses credential-manager for secure storage of activation tokens.
*
* Hybrid model:
* - First activation: online validation against license server (if reachable)
* - Fallback: offline HMAC validation using embedded master secret hash
* - Ongoing: locally stored activation token checked on each premium request
*/
const crypto = require('crypto');
const os = require('os');
const fs = require('fs');
const path = require('path');
const { verifyCode, parseCode, VALID_DURATIONS } = require('./license-keygen');
const { errorResponse } = require('../utils/responses');
const LICENSE_CRED_KEY = 'license.activation';
const LICENSE_SERVER_URL = process.env.LICENSE_SERVER_URL || null; // Set when license server exists
// Features gated behind premium
const PREMIUM_FEATURES = {
sso: { name: 'Auto-Login SSO', description: 'Automatic single sign-on for deployed apps' },
recipes: { name: 'Recipes', description: 'Multi-container stack deployment' },
swarm: { name: 'Docker Swarm', description: 'Multi-node cluster orchestration' }
};
class LicenseManager {
constructor(credentialManager, configFile, log) {
this.credentialManager = credentialManager;
this.configFile = configFile;
this.log = log || console;
this.activation = null; // Cached activation state
this.masterSecretHash = null; // Loaded from shipped secret hash (not the secret itself)
this._loaded = false;
}
/**
* Load license state from storage on startup.
* Primary: encrypted credential store. Fallback: config.json backup.
* If the credential store fails (e.g. encryption key changed after rebuild),
* restores from the config.json backup automatically.
*/
async load() {
try {
const stored = await this.credentialManager.retrieve(LICENSE_CRED_KEY);
if (stored) {
this.activation = JSON.parse(stored);
if (this.isExpired()) {
this.log.info?.('license', 'License has expired', {
code: this._maskCode(this.activation.code),
expiredAt: this.activation.expiresAt
});
} else {
this.log.info?.('license', 'License loaded', {
code: this._maskCode(this.activation.code),
expiresAt: this.activation.expiresAt,
daysRemaining: this.daysRemaining()
});
}
this._loaded = true;
return;
}
} catch (error) {
this.log.warn?.('license', 'Failed to load from credential store, trying config backup', { error: error.message });
}
// Fallback: restore from config.json backup
try {
const fsp = require('fs').promises;
const data = await fsp.readFile(this.configFile, 'utf8');
const config = JSON.parse(data);
if (config.licenseBackup) {
this.activation = config.licenseBackup;
this.log.info?.('license', 'License restored from config backup', {
code: this._maskCode(this.activation.code),
lifetime: this.activation.lifetime
});
// Re-store in credential manager so future loads succeed
try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation));
this.log.info?.('license', 'License re-stored in credential manager');
} catch (storeErr) {
this.log.warn?.('license', 'Could not re-store license in credential manager', { error: storeErr.message });
}
this._loaded = true;
return;
}
} catch (_) {
// Config doesn't exist or no backup — continue
}
this.log.info?.('license', 'No active license');
this.activation = null;
this._loaded = true;
}
/**
* Load the shipped master secret hash for offline validation.
* The actual master secret is NEVER shipped — only a hash of it is embedded
* in the product, and the keygen embeds HMAC signatures in codes using the real secret.
* For offline validation, we verify the code's internal HMAC consistency.
*
* @param {string} secretFile - Path to .license-secret file (dev only) or .license-secret-hash (shipped)
*/
loadSecret(secretFile) {
try {
if (fs.existsSync(secretFile)) {
const secret = fs.readFileSync(secretFile, 'utf8').trim();
this.masterSecretHash = secret;
return true;
}
} catch (error) {
this.log.warn?.('license', 'Could not load license secret', { error: error.message });
}
return false;
}
/**
* Generate a machine fingerprint for activation binding
*/
getMachineFingerprint() {
const components = [
os.hostname(),
os.platform(),
os.arch(),
os.cpus()[0]?.model || 'unknown'
];
// Get primary MAC address
const interfaces = os.networkInterfaces();
for (const name of Object.keys(interfaces)) {
for (const iface of interfaces[name]) {
if (!iface.internal && iface.mac && iface.mac !== '00:00:00:00:00:00') {
components.push(iface.mac);
break;
}
}
}
return crypto.createHash('sha256').update(components.join('|')).digest('hex').substring(0, 16);
}
/**
* Activate a license code
* @param {string} code - License code (DC-XXXXX-XXXXX-XXXXX-XXXXX-XXXXX)
* @returns {Object} { success, message, activation? }
*/
async activate(code) {
if (!code || typeof code !== 'string') {
return { success: false, message: 'License code is required' };
}
// Normalize code format
code = code.trim().toUpperCase();
if (!code.startsWith('DC-')) {
return { success: false, message: 'Invalid code format. Codes start with DC-' };
}
// Check if already activated with this code
if (this.activation && this.activation.code === code && !this.isExpired()) {
return {
success: true,
message: 'This code is already activated',
activation: this.getStatus()
};
}
// Try online validation first
let onlineResult = null;
if (LICENSE_SERVER_URL) {
onlineResult = await this._validateOnline(code);
if (onlineResult && !onlineResult.success) {
// Server explicitly rejected — don't fallback to offline
return onlineResult;
}
}
// Offline validation (HMAC check)
if (!onlineResult) {
const offlineResult = this._validateOffline(code);
if (!offlineResult.valid) {
return { success: false, message: offlineResult.reason || 'Invalid license code' };
}
// Code is cryptographically valid
const machineId = this.getMachineFingerprint();
const now = new Date();
const isLifetime = offlineResult.durationDays === 0;
const expiresAt = isLifetime
? new Date('2099-12-31T23:59:59.999Z')
: new Date(now.getTime() + offlineResult.durationDays * 86400000);
this.activation = {
code,
codeId: offlineResult.codeId,
durationDays: offlineResult.durationDays,
lifetime: isLifetime,
activatedAt: now.toISOString(),
expiresAt: expiresAt.toISOString(),
machineId,
validationMethod: 'offline',
features: Object.keys(PREMIUM_FEATURES)
};
} else {
// Online validation succeeded — use server response
this.activation = onlineResult.activation;
this.activation.validationMethod = 'online';
}
// Store activation token
try {
await this.credentialManager.store(LICENSE_CRED_KEY, JSON.stringify(this.activation), {
activatedAt: this.activation.activatedAt,
expiresAt: this.activation.expiresAt
});
} catch (error) {
this.log.error?.('license', 'Failed to store activation', { error: error.message });
return { success: false, message: 'License validated but failed to save activation' };
}
// Update config.json with license info (non-sensitive)
await this._updateConfig();
this.log.info?.('license', 'License activated', {
code: this._maskCode(code),
durationDays: this.activation.durationDays,
expiresAt: this.activation.expiresAt,
method: this.activation.validationMethod
});
const durationLabel = this.activation.lifetime ? 'lifetime' : `${this.activation.durationDays} days`;
return {
success: true,
message: `License activated for ${durationLabel}`,
activation: this.getStatus()
};
}
/**
* Deactivate the current license
* @returns {Object} { success, message }
*/
async deactivate() {
if (!this.activation) {
return { success: false, message: 'No active license to deactivate' };
}
const code = this._maskCode(this.activation.code);
// If online server exists, notify it of deactivation
if (LICENSE_SERVER_URL) {
try {
await this._notifyDeactivation();
} catch (error) {
this.log.warn?.('license', 'Could not notify license server of deactivation', { error: error.message });
}
}
// Clear local activation
await this.credentialManager.delete(LICENSE_CRED_KEY);
this.activation = null;
await this._updateConfig();
this.log.info?.('license', 'License deactivated', { code });
return { success: true, message: 'License deactivated. You can reuse this code on another machine.' };
}
/**
* Get current license status
* @returns {Object} Status object
*/
getStatus() {
if (!this.activation) {
return {
active: false,
tier: 'free',
features: [],
premiumFeatures: PREMIUM_FEATURES
};
}
const expired = this.isExpired();
const isLifetime = !!(this.activation.lifetime || this.activation.durationDays === 0);
const daysRemaining = isLifetime ? null : this.daysRemaining();
return {
active: !expired,
tier: expired ? 'free' : 'premium',
lifetime: isLifetime,
code: this._maskCode(this.activation.code),
durationDays: this.activation.durationDays,
activatedAt: this.activation.activatedAt,
expiresAt: isLifetime ? null : this.activation.expiresAt,
daysRemaining: isLifetime ? null : Math.max(0, daysRemaining),
expired,
features: expired ? [] : (this.activation.features || Object.keys(PREMIUM_FEATURES)),
premiumFeatures: PREMIUM_FEATURES,
validationMethod: this.activation.validationMethod
};
}
/**
* Check if a specific premium feature is available
* @param {string} feature - Feature key (e.g., 'sso', 'recipes', 'swarm')
* @returns {boolean}
*/
hasFeature(feature) {
if (!this.activation) return false;
if (this.isExpired()) return false;
const features = this.activation.features || Object.keys(PREMIUM_FEATURES);
return features.includes(feature);
}
/**
* Check if the license has expired
*/
isExpired() {
if (!this.activation) return true;
// Lifetime licenses never expire
if (this.activation.lifetime || this.activation.durationDays === 0) return false;
if (!this.activation.expiresAt) return false; // No expiry set = lifetime
return Date.now() > new Date(this.activation.expiresAt).getTime();
}
/**
* Get days remaining on the license
*/
daysRemaining() {
if (!this.activation) return 0;
const remaining = new Date(this.activation.expiresAt).getTime() - Date.now();
return Math.ceil(remaining / 86400000);
}
/**
* Express middleware: gate a route behind a premium feature
* @param {string} feature - Feature key
* @returns {Function} Express middleware
*/
requirePremium(feature) {
return (req, res, next) => {
if (this.hasFeature(feature)) {
return next();
}
const featureInfo = PREMIUM_FEATURES[feature] || { name: feature };
return errorResponse(res, 403, `${featureInfo.name} requires a DashCaddy Premium subscription.`, {
premiumRequired: true,
feature,
featureName: featureInfo.name,
featureDescription: featureInfo.description,
currentTier: this.isExpired() ? 'free' : 'expired',
upgradeUrl: '/settings#license'
});
};
}
// Private methods
/**
* Validate code offline using HMAC
*/
_validateOffline(code) {
if (!this.masterSecretHash) {
// No secret available — try structural validation only
try {
const parsed = parseCode(code);
// Without the secret we can't verify HMAC, but we can check structure
if (parsed.version !== 1) return { valid: false, reason: 'Unsupported code version' };
if (parsed.durationDays !== 0 && !VALID_DURATIONS.includes(parsed.durationDays)) return { valid: false, reason: 'Invalid duration' };
// Can't verify signature without secret — reject
return { valid: false, reason: 'License validation unavailable. Please try again when connected to the internet.' };
} catch (e) {
return { valid: false, reason: e.message };
}
}
// Full verification with secret
return verifyCode(this.masterSecretHash, code);
}
/**
* Validate code against online license server
*/
async _validateOnline(code) {
try {
const machineId = this.getMachineFingerprint();
const response = await fetch(`${LICENSE_SERVER_URL}/api/license/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, machineId }),
signal: AbortSignal.timeout(10000) // 10s timeout
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
return { success: false, message: data.error || `Server returned ${response.status}` };
}
const data = await response.json();
if (data.success) {
return {
success: true,
activation: {
code,
codeId: data.codeId,
durationDays: data.durationDays,
activatedAt: new Date().toISOString(),
expiresAt: data.expiresAt,
machineId,
features: data.features || Object.keys(PREMIUM_FEATURES),
serverToken: data.token
}
};
}
return { success: false, message: data.message || 'License server rejected the code' };
} catch (error) {
// Server unreachable — return null to fallback to offline
this.log.warn?.('license', 'License server unreachable, falling back to offline validation', {
error: error.message
});
return null;
}
}
/**
* Notify license server of deactivation
*/
async _notifyDeactivation() {
if (!LICENSE_SERVER_URL || !this.activation) return;
await fetch(`${LICENSE_SERVER_URL}/api/license/deactivate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
code: this.activation.code,
machineId: this.activation.machineId,
serverToken: this.activation.serverToken
}),
signal: AbortSignal.timeout(10000)
});
}
/**
* Update config.json with license info and full activation backup.
* The backup ensures the license survives encryption key changes
* (e.g. container rebuilds that generate new keys).
*/
async _updateConfig() {
try {
const fsp = require('fs').promises;
let config = {};
try {
const data = await fsp.readFile(this.configFile, 'utf8');
config = JSON.parse(data);
} catch (e) {
// Config doesn't exist yet
}
if (this.activation && !this.isExpired()) {
config.license = {
active: true,
tier: 'premium',
expiresAt: this.activation.expiresAt,
daysRemaining: this.daysRemaining(),
features: this.activation.features || Object.keys(PREMIUM_FEATURES)
};
// Full backup of activation data (config.json is volume-mounted and persists)
config.licenseBackup = this.activation;
} else {
config.license = { active: false, tier: 'free' };
delete config.licenseBackup;
}
config.updatedAt = new Date().toISOString();
await fsp.writeFile(this.configFile, JSON.stringify(config, null, 2), 'utf8');
} catch (error) {
this.log.error?.('license', 'Failed to update config with license info', { error: error.message });
}
}
/**
* Mask a license code for display (show first and last groups only)
*/
_maskCode(code) {
if (!code) return 'none';
const parts = code.split('-');
if (parts.length < 4) return 'DC-*****';
return `${parts[0]}-${parts[1]}-*****-*****-${parts[parts.length - 1]}`;
}
}
module.exports = { LicenseManager, PREMIUM_FEATURES };
@@ -0,0 +1,501 @@
/**
* Notification Manager - Multi-provider notification delivery
* Supports Discord, Telegram, ntfy, and Email notifications
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const nodemailer = require('nodemailer');
const DEFAULT_CONFIG = {
enabled: true,
providers: {
discord: { enabled: false, webhookUrl: '' },
telegram: { enabled: false, botToken: '', chatId: '' },
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' }
},
events: {
'container-down': true,
'container-up': false,
'alert': true,
'backup-complete': true,
'backup-failed': true,
'update-available': true
}
};
class NotificationManager extends EventEmitter {
constructor(ctx) {
super();
this.ctx = ctx;
this.NOTIFICATIONS_FILE = ctx.NOTIFICATIONS_FILE;
this.log = ctx.log || console;
this.config = { ...DEFAULT_CONFIG };
this.lastSent = null;
this.history = [];
this.maxHistory = 100;
this.healthDaemonInterval = null;
this.healthState = new Map();
this._loadConfig();
}
/**
* Load config from file
*/
_loadConfig() {
try {
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
}
} catch (error) {
this.log.error('notification', 'Failed to load config', { error: error.message });
}
}
/**
* Merge loaded config with defaults
*/
_mergeConfig(defaults, loaded) {
const result = { ...defaults };
for (const key of Object.keys(defaults)) {
if (loaded && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) {
result[key] = { ...defaults[key], ...loaded[key] };
} else if (loaded && loaded[key] !== undefined) {
result[key] = loaded[key];
}
}
return result;
}
/**
* Get current config (for API)
*/
getConfig() {
return this.config;
}
/**
* Save config to file
*/
async saveConfig() {
try {
const dir = path.dirname(this.NOTIFICATIONS_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
return true;
} catch (error) {
this.log.error('notification', 'Failed to save config', { error: error.message });
throw error;
}
}
/**
* Get notification history
*/
getHistory() {
return this.history.slice();
}
/**
* Clear notification history
*/
clearHistory() {
this.history = [];
}
/**
* Add entry to history
*/
_addToHistory(entry) {
this.history.unshift({
...entry,
timestamp: new Date().toISOString()
});
if (this.history.length > this.maxHistory) {
this.history = this.history.slice(0, this.maxHistory);
}
this.lastSent = new Date().toISOString();
}
/**
* Send notification via all enabled providers
*/
async send(event, data, type = 'info') {
if (!this.config.enabled) {
return { success: false, error: 'Notifications disabled' };
}
// Check if event is enabled
if (event && this.config.events && !this.config.events[event]) {
return { success: false, error: `Event ${event} not enabled` };
}
const results = [];
const providers = this.config.providers;
// Discord
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
try {
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
results.push({ provider: 'discord', ...result });
} catch (error) {
results.push({ provider: 'discord', success: false, error: error.message });
}
}
// Telegram
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
try {
const result = await this.sendTelegram(this._formatText(data, event));
results.push({ provider: 'telegram', ...result });
} catch (error) {
results.push({ provider: 'telegram', success: false, error: error.message });
}
}
// ntfy
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
try {
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
results.push({ provider: 'ntfy', ...result });
} catch (error) {
results.push({ provider: 'ntfy', success: false, error: error.message });
}
}
// Email
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
try {
const result = await this.sendEmail(
this._formatTitle(event),
this._formatText(data, event)
);
results.push({ provider: 'email', ...result });
} catch (error) {
results.push({ provider: 'email', success: false, error: error.message });
}
}
const allSucceeded = results.every(r => r.success);
this._addToHistory({
title: this._formatTitle(event),
type,
event,
results
});
return { success: allSucceeded, results };
}
/**
* Send Discord webhook notification
*/
async sendDiscord(text, embed) {
const { webhookUrl } = this.config.providers.discord;
if (!webhookUrl) {
throw new Error('Discord webhook not configured');
}
const payload = {
content: text,
embeds: embed ? [embed] : []
};
const response = await this.ctx.fetchT(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
throw new Error(`Discord API error: ${response.status}`);
}
return { success: true };
}
/**
* Send Telegram message
*/
async sendTelegram(text) {
const { botToken, chatId } = this.config.providers.telegram;
if (!botToken || !chatId) {
throw new Error('Telegram not configured');
}
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const payload = {
chat_id: chatId,
text,
parse_mode: 'Markdown'
};
const response = await this.ctx.fetchT(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json();
if (!data.ok) {
throw new Error(`Telegram error: ${data.description}`);
}
return { success: true };
}
/**
* Send ntfy notification
*/
async sendNtfy(text, title) {
const { serverUrl, topic } = this.config.providers.ntfy;
if (!topic) {
throw new Error('ntfy topic not configured');
}
const url = `${serverUrl.replace(/\/$/, '')}/${topic}`;
const response = await this.ctx.fetchT(url, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'Title': title || 'DashCaddy',
'Priority': '3'
},
body: text
});
if (!response.ok) {
throw new Error(`ntfy error: ${response.status}`);
}
return { success: true };
}
/**
* Send email notification
*/
async sendEmail(subject, body) {
const { host, port, to, from, username, password, secure } = this.config.providers.email;
if (!host || !to) {
throw new Error('Email not configured');
}
// Create transporter
const transporter = nodemailer.createTransport({
host,
port: parseInt(port) || 587,
secure: !!secure,
auth: username ? {
user: username,
pass: password
} : undefined
});
// Send mail
await transporter.sendMail({
from: from || username,
to,
subject,
text: body,
html: `<pre style="font-family: monospace;">${body}</pre>`
});
return { success: true };
}
/**
* Send resource alert
*/
async sendAlert(alert) {
const text = this._formatAlertText(alert);
const embed = {
title: `⚠️ Resource Alert: ${alert.containerName}`,
color: this._getAlertColor(alert.alerts),
fields: alert.alerts.map(a => ({
name: a.type.toUpperCase(),
value: a.message,
inline: true
})),
footer: {
text: 'DashCaddy Resource Monitor'
},
timestamp: alert.timestamp
};
return this.send('alert', { ...alert, text, embed }, 'warning');
}
/**
* Send backup complete notification
*/
async sendBackupComplete(backup) {
const event = backup.status === 'success' ? 'backup-complete' : 'backup-failed';
const type = backup.status === 'success' ? 'success' : 'error';
const text = backup.status === 'success'
? `✅ Backup "${backup.name}" completed successfully`
: `❌ Backup "${backup.name}" failed: ${backup.error}`;
return this.send(event, { ...backup, text }, type);
}
/**
* Send service event notification (container up/down, deploy success/fail)
*/
async sendServiceEvent(event, service) {
const eventMap = {
'container-up': { type: 'success', text: `✅ Container "${service.containerName || service.name}" is now UP` },
'container-down': { type: 'error', text: `🔴 Container "${service.containerName || service.name}" is DOWN` },
'deploy-success': { type: 'success', text: `✅ "${service.name}" deployed successfully` },
'deploy-failed': { type: 'error', text: `❌ "${service.name}" deployment failed` },
'auto-restart': { type: 'warning', text: `🔄 Container "${service.containerName || service.name}" auto-restarted` }
};
const info = eventMap[event] || { type: 'info', text: `Service event: ${event}` };
return this.send(event, { ...service, text: info.text }, info.type);
}
// ===== Helper Methods =====
_formatTitle(event) {
const titles = {
'container-down': 'Container Down',
'container-up': 'Container Recovered',
'alert': 'Resource Alert',
'backup-complete': 'Backup Complete',
'backup-failed': 'Backup Failed',
'update-available': 'Update Available',
'test': 'Test Notification',
'auto-restart': 'Auto-Restart',
'deploy-success': 'Deployment Success',
'deploy-failed': 'Deployment Failed'
};
return titles[event] || 'DashCaddy Notification';
}
_formatText(data, event) {
if (typeof data === 'string') return data;
return data.text || data.message || this._formatTitle(event);
}
_formatEmbed(data, event, type) {
if (typeof data === 'string') return null;
if (data.embed) return data.embed;
return {
title: this._formatTitle(event),
description: data.text || data.message || '',
color: this._getTypeColor(type),
timestamp: new Date().toISOString()
};
}
_formatAlertText(alert) {
const lines = [
`**${alert.containerName}**`,
'',
...alert.alerts.map(a => `${a.message}`)
];
return lines.join('\n');
}
_getAlertColor(alerts) {
if (alerts.some(a => a.severity === 'critical')) return 15158332; // Red
if (alerts.some(a => a.severity === 'warning')) return 16776960; // Yellow
return 3447003; // Blue
}
_getTypeColor(type) {
const colors = {
success: 3066993, // Green
error: 15158332, // Red
warning: 16776960, // Yellow
info: 3447003 // Blue
};
return colors[type] || colors.info;
}
// ===== Health Check Daemon =====
startHealthDaemon() {
if (this.healthDaemonInterval) return;
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
this.healthDaemonInterval = setInterval(() => {
this.checkHealth().catch(err => {
this.log.error('notification', 'Health check failed', { error: err.message });
});
}, interval);
this.log.info('notification', 'Health daemon started', { intervalMinutes: this.config.healthCheck?.intervalMinutes });
}
stopHealthDaemon() {
if (this.healthDaemonInterval) {
clearInterval(this.healthDaemonInterval);
this.healthDaemonInterval = null;
this.log.info('notification', 'Health daemon stopped');
}
}
async checkHealth() {
if (!this.config.healthCheck?.enabled || !this.ctx.docker) {
return { checked: false };
}
try {
const containers = await this.ctx.docker.listContainers({ all: true });
const previousState = new Map(this.healthState);
for (const container of containers) {
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
const wasDown = previousState.get(container.Id) === false;
const isDown = container.State !== 'running';
this.healthState.set(container.Id, isDown);
if (wasDown && !isDown) {
// Container recovered
await this.sendServiceEvent('container-up', {
containerId: container.Id,
containerName: name,
state: container.State
});
} else if (!wasDown && isDown) {
// Container went down
await this.sendServiceEvent('container-down', {
containerId: container.Id,
containerName: name,
state: container.State
});
}
}
// Update last check time
this.config.healthCheck = this.config.healthCheck || {};
this.config.healthCheck.lastCheck = new Date().toISOString();
await this.saveConfig();
return {
checked: true,
containersMonitored: containers.length,
lastCheck: this.config.healthCheck.lastCheck
};
} catch (error) {
this.log.error('notification', 'Health check error', { error: error.message });
throw error;
}
}
getHealthState() {
return new Map(this.healthState);
}
}
module.exports = NotificationManager;
@@ -0,0 +1,235 @@
/**
* Port Lock Manager
* Provides atomic port allocation using file-based locks to prevent race conditions
* during concurrent container deployments
*/
const fs = require('fs');
const path = require('path');
const lockfile = require('proper-lockfile');
const LOCK_DIR = path.join(__dirname, '.port-locks');
const LOCK_TIMEOUT = 120000; // 2 minutes
const LOCK_STALE_THRESHOLD = 120000; // 2 minutes
const LOCK_RETRY_OPTIONS = {
retries: {
retries: 10,
minTimeout: 100,
maxTimeout: 1000,
randomize: true
},
stale: LOCK_STALE_THRESHOLD,
realpath: false
};
class PortLockManager {
constructor() {
this.activeLocks = new Map(); // Map of lockId -> { ports: [], release: fn }
this.ensureLockDirectory();
}
/**
* Ensure lock directory exists
*/
ensureLockDirectory() {
if (!fs.existsSync(LOCK_DIR)) {
fs.mkdirSync(LOCK_DIR, { recursive: true });
console.log('[PortLockManager] Created lock directory:', LOCK_DIR);
}
}
/**
* Get lock file path for a port
*/
getLockFilePath(port) {
return path.join(LOCK_DIR, `port-${port}.lock`);
}
/**
* Acquire locks for multiple ports atomically
* Ports are sorted to prevent deadlocks
* @param {string[]} ports - Array of port numbers as strings
* @returns {Promise<string>} Lock ID for releasing locks later
*/
async acquirePorts(ports) {
if (!Array.isArray(ports) || ports.length === 0) {
throw new Error('Ports must be a non-empty array');
}
const lockId = `lock-${Date.now()}-${Math.random().toString(36).substring(7)}`;
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(', ')}`);
// Acquire locks in sorted order to prevent deadlocks
for (const port of sortedPorts) {
const lockFilePath = this.getLockFilePath(port);
// Create lock file if it doesn't exist
if (!fs.existsSync(lockFilePath)) {
fs.writeFileSync(lockFilePath, JSON.stringify({
created: new Date().toISOString(),
port
}));
}
// Acquire lock with retry
const release = await lockfile.lock(lockFilePath, LOCK_RETRY_OPTIONS);
acquiredLocks.push(port);
releaseFunctions.push(release);
console.log(`[PortLockManager] Locked port ${port}`);
}
// Store lock information
this.activeLocks.set(lockId, {
ports: sortedPorts,
releases: releaseFunctions,
timestamp: Date.now()
});
console.log(`[PortLockManager] Successfully acquired all locks (ID: ${lockId})`);
return lockId;
} catch (error) {
// Release any locks we managed to acquire
console.error(`[PortLockManager] Failed to acquire all locks:`, error.message);
for (const release of releaseFunctions) {
try {
await release();
} catch (releaseError) {
console.error(`[PortLockManager] Error releasing lock during cleanup:`, releaseError.message);
}
}
throw new Error(`Failed to acquire port locks: ${error.message}`);
}
}
/**
* Release locks for a lock ID
* @param {string} lockId - Lock ID returned from acquirePorts
*/
async releasePorts(lockId) {
const lockInfo = this.activeLocks.get(lockId);
if (!lockInfo) {
console.warn(`[PortLockManager] Lock ID ${lockId} not found (may have been released already)`);
return;
}
console.log(`[PortLockManager] Releasing locks for ports: ${lockInfo.ports.join(', ')}`);
const errors = [];
for (const release of lockInfo.releases) {
try {
await release();
} catch (error) {
errors.push(error.message);
console.error(`[PortLockManager] Error releasing lock:`, error.message);
}
}
this.activeLocks.delete(lockId);
if (errors.length > 0) {
console.warn(`[PortLockManager] Released locks with ${errors.length} errors`);
} else {
console.log(`[PortLockManager] Successfully released all locks (ID: ${lockId})`);
}
}
/**
* Clean up stale lock files
* Removes locks older than LOCK_STALE_THRESHOLD
*/
async cleanupStaleLocks() {
console.log('[PortLockManager] Cleaning up stale locks...');
this.ensureLockDirectory();
let cleaned = 0;
let errors = 0;
try {
const files = fs.readdirSync(LOCK_DIR);
for (const file of files) {
if (!file.endsWith('.lock')) continue;
const lockFilePath = path.join(LOCK_DIR, file);
try {
// Check if lock is stale using proper-lockfile's built-in check
const isLocked = await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD });
if (!isLocked) {
// Lock is stale or not locked, safe to remove
fs.unlinkSync(lockFilePath);
cleaned++;
console.log(`[PortLockManager] 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);
}
}
}
console.log(`[PortLockManager] Cleanup complete: ${cleaned} stale locks removed, ${errors} errors`);
} catch (error) {
console.error('[PortLockManager] Error during cleanup:', error.message);
}
}
/**
* Get current lock status
*/
getStatus() {
const activeLocks = Array.from(this.activeLocks.entries()).map(([lockId, info]) => ({
lockId,
ports: info.ports,
age: Date.now() - info.timestamp,
timestamp: new Date(info.timestamp).toISOString()
}));
return {
activeLocks: activeLocks.length,
locks: activeLocks,
lockDirectory: LOCK_DIR
};
}
/**
* Check if a port is currently locked
* @param {string} port - Port number as string
* @returns {Promise<boolean>}
*/
async isPortLocked(port) {
const lockFilePath = this.getLockFilePath(port);
if (!fs.existsSync(lockFilePath)) {
return false;
}
try {
return await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD });
} catch (error) {
// If we can't check, assume it's not locked
return false;
}
}
}
// Singleton instance
const portLockManager = new PortLockManager();
module.exports = portLockManager;
@@ -0,0 +1,987 @@
/**
* Container Resource Monitoring Module
* Tracks CPU, memory, disk, and network usage for Docker containers
* Provides alerts and historical data
*/
const Docker = require('dockerode');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const docker = new Docker();
// Configuration
const STATS_FILE = process.env.STATS_FILE || path.join(__dirname, 'container-stats.json');
const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(__dirname, 'container-stats-hourly.json');
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(__dirname, 'container-stats-daily.json');
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(__dirname, 'alert-config.json');
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(__dirname, 'alert-history.json');
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
const MONITORING_INTERVAL = parseInt(process.env.MONITORING_INTERVAL || '10000', 10); // 10 seconds
const ROLLUP_HOURLY_INTERVAL = parseInt(process.env.ROLLUP_HOURLY_INTERVAL || String(60 * 60 * 1000), 10); // 1h
const ROLLUP_DAILY_INTERVAL = parseInt(process.env.ROLLUP_DAILY_INTERVAL || String(24 * 60 * 60 * 1000), 10); // 24h
class ResourceMonitor extends EventEmitter {
constructor() {
super();
this.monitoring = false;
this.monitoringInterval = null;
this.hourlyRollupTimer = null;
this.dailyRollupTimer = null;
this.stats = new Map(); // containerId -> { name, history: [...] } (raw 10s samples, 7d)
this.hourlyHistory = new Map(); // containerId -> { name, samples: [...] } (hourly avg, 30d)
this.dailyHistory = new Map(); // containerId -> { name, samples: [...] } (daily avg, 365d)
this.alerts = new Map(); // containerId -> alert config
this.lastAlerts = new Map(); // containerId -> last alert timestamp
this.alertHistory = []; // alert history entries
this.notificationManager = null;
this.loadStats();
this.loadHourlyStats();
this.loadDailyStats();
this.loadAlertConfig();
this.loadAlertHistory();
}
/**
* Set the notification manager for sending alerts
*/
setNotificationManager(nm) {
this.notificationManager = nm;
}
/**
* Start monitoring all containers
*/
start() {
if (this.monitoring) {
console.log('[ResourceMonitor] Already monitoring');
return;
}
console.log('[ResourceMonitor] 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); }
}, ROLLUP_HOURLY_INTERVAL);
// Daily rollup — schedule first run at the next midnight, then fire every 24h
const now = new Date();
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); }
this.dailyRollupTimer = setInterval(() => {
try { this.rollupDaily(); } catch (e) { console.error('[ResourceMonitor] daily rollup error:', e.message); }
}, ROLLUP_DAILY_INTERVAL);
}, msUntilMidnight);
// Initial collection
this.collectStats();
}
/**
* Stop monitoring
*/
stop() {
if (!this.monitoring) return;
console.log('[ResourceMonitor] Stopping container monitoring');
this.monitoring = false;
if (this.monitoringInterval) {
clearInterval(this.monitoringInterval);
this.monitoringInterval = null;
}
if (this.hourlyRollupTimer) {
clearInterval(this.hourlyRollupTimer);
this.hourlyRollupTimer = null;
}
if (this.dailyRollupTimer) {
clearInterval(this.dailyRollupTimer);
this.dailyRollupTimer = null;
}
this.saveStats();
this.saveHourlyStats();
this.saveDailyStats();
}
/**
* Collect stats from all running containers
*/
async collectStats() {
try {
const containers = await docker.listContainers({ all: false });
for (const containerInfo of containers) {
try {
const container = docker.getContainer(containerInfo.Id);
const stats = await this.getContainerStats(container);
if (stats) {
this.recordStats(containerInfo.Id, containerInfo.Names[0], stats);
this.checkAlerts(containerInfo.Id, containerInfo.Names[0], stats);
}
} catch (error) {
console.error(`[ResourceMonitor] Error collecting stats for ${containerInfo.Names[0]}:`, error.message);
}
}
// Cleanup old stats
this.cleanupOldStats();
// Persist stats periodically
if (Math.random() < 0.1) { // 10% chance to save (every ~100 seconds)
this.saveStats();
}
} catch (error) {
console.error('[ResourceMonitor] Error collecting container stats:', error.message);
}
}
/**
* Get stats for a single container
*/
async getContainerStats(container) {
return new Promise((resolve, reject) => {
container.stats({ stream: false }, (err, stats) => {
if (err) {
reject(err);
return;
}
// Calculate CPU percentage
const cpuDelta = stats.cpu_stats.cpu_usage.total_usage -
(stats.precpu_stats.cpu_usage?.total_usage || 0);
const systemDelta = stats.cpu_stats.system_cpu_usage -
(stats.precpu_stats.system_cpu_usage || 0);
const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 : 0;
// Calculate memory usage
const memoryUsage = stats.memory_stats.usage || 0;
const memoryLimit = stats.memory_stats.limit || 0;
const memoryPercent = memoryLimit > 0 ? (memoryUsage / memoryLimit) * 100 : 0;
// Calculate network I/O
let networkRx = 0;
let networkTx = 0;
if (stats.networks) {
Object.values(stats.networks).forEach(net => {
networkRx += net.rx_bytes || 0;
networkTx += net.tx_bytes || 0;
});
}
// Calculate block I/O
let blockRead = 0;
let blockWrite = 0;
if (stats.blkio_stats?.io_service_bytes_recursive) {
stats.blkio_stats.io_service_bytes_recursive.forEach(io => {
if (io.op === 'Read') blockRead += io.value;
if (io.op === 'Write') blockWrite += io.value;
});
}
resolve({
timestamp: new Date().toISOString(),
cpu: {
percent: Math.round(cpuPercent * 100) / 100,
usage: stats.cpu_stats.cpu_usage.total_usage
},
memory: {
usage: memoryUsage,
limit: memoryLimit,
percent: Math.round(memoryPercent * 100) / 100,
usageMB: Math.round(memoryUsage / 1024 / 1024),
limitMB: Math.round(memoryLimit / 1024 / 1024)
},
network: {
rxBytes: networkRx,
txBytes: networkTx,
rxMB: Math.round(networkRx / 1024 / 1024 * 100) / 100,
txMB: Math.round(networkTx / 1024 / 1024 * 100) / 100
},
disk: {
readBytes: blockRead,
writeBytes: blockWrite,
readMB: Math.round(blockRead / 1024 / 1024 * 100) / 100,
writeMB: Math.round(blockWrite / 1024 / 1024 * 100) / 100
},
pids: stats.pids_stats?.current || 0
});
});
});
}
/**
* Record stats for a container
*/
recordStats(containerId, containerName, stats) {
if (!this.stats.has(containerId)) {
this.stats.set(containerId, {
name: containerName,
history: []
});
}
const containerStats = this.stats.get(containerId);
containerStats.name = containerName; // Update name in case it changed
containerStats.history.push(stats);
// Keep only recent stats (based on retention policy)
const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000);
containerStats.history = containerStats.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime
);
}
/**
* Check if any alerts should be triggered
*/
checkAlerts(containerId, containerName, stats) {
const alertConfig = this.alerts.get(containerId);
if (!alertConfig || !alertConfig.enabled) return;
const now = Date.now();
const lastAlert = this.lastAlerts.get(containerId) || 0;
const cooldown = (alertConfig.cooldownMinutes || 15) * 60 * 1000;
// Don't spam alerts - respect cooldown period
if (now - lastAlert < cooldown) return;
const alerts = [];
// Check CPU threshold
if (alertConfig.cpuThreshold && stats.cpu.percent > alertConfig.cpuThreshold) {
alerts.push({
type: 'cpu',
severity: 'warning',
message: `CPU usage ${stats.cpu.percent.toFixed(1)}% exceeds threshold ${alertConfig.cpuThreshold}%`,
value: stats.cpu.percent,
threshold: alertConfig.cpuThreshold
});
}
// Check memory threshold
if (alertConfig.memoryThreshold && stats.memory.percent > alertConfig.memoryThreshold) {
alerts.push({
type: 'memory',
severity: 'warning',
message: `Memory usage ${stats.memory.percent.toFixed(1)}% exceeds threshold ${alertConfig.memoryThreshold}%`,
value: stats.memory.percent,
threshold: alertConfig.memoryThreshold
});
}
// Check disk I/O threshold (MB/s)
if (alertConfig.diskIOThreshold) {
const diskIO = stats.disk.readMB + stats.disk.writeMB;
if (diskIO > alertConfig.diskIOThreshold) {
alerts.push({
type: 'disk',
severity: 'warning',
message: `Disk I/O ${diskIO.toFixed(1)} MB/s exceeds threshold ${alertConfig.diskIOThreshold} MB/s`,
value: diskIO,
threshold: alertConfig.diskIOThreshold
});
}
}
if (alerts.length > 0) {
this.lastAlerts.set(containerId, now);
// Add alert history entries
for (const alert of alerts) {
this.addAlertHistoryEntry({
id: `${containerId}-${Date.now()}-${alert.type}`,
timestamp: new Date().toISOString(),
containerId,
containerName,
type: alert.type,
metric: alert.type,
value: alert.value,
threshold: alert.threshold,
severity: alert.severity,
notified: !!this.notificationManager,
autoRestartTriggered: !!alertConfig.autoRestart
});
}
const alertPayload = {
containerId,
containerName,
timestamp: new Date().toISOString(),
alerts,
stats,
config: alertConfig
};
this.emit('alert', alertPayload);
// 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);
});
}
// Auto-restart if configured
if (alertConfig.autoRestart) {
this.restartContainer(containerId, containerName, alerts);
}
// Trigger bundled workflows for resource-alert
this.triggerWorkflows('resource-alert', {
containerId,
containerName,
alerts,
stats,
diskPercent: (stats.disk?.readBytes + stats.disk?.writeBytes) > 0
? Math.round((stats.disk.readBytes / (stats.disk.readBytes + stats.disk.writeBytes)) * 100)
: 0,
host: require('os').hostname()
});
}
}
/**
* Restart a container due to resource alerts
*/
async restartContainer(containerId, containerName, alerts) {
try {
console.log(`[ResourceMonitor] Auto-restarting ${containerName} due to alerts:`, alerts.map(a => a.type).join(', '));
const container = docker.getContainer(containerId);
await container.restart();
this.emit('auto-restart', {
containerId,
containerName,
timestamp: new Date().toISOString(),
reason: alerts
});
// Send notification if manager is configured
if (this.notificationManager) {
this.notificationManager.send('auto-restart', {
containerId,
containerName,
timestamp: new Date().toISOString(),
reason: alerts
}).catch(err => {
console.error('[ResourceMonitor] Failed to send auto-restart notification:', err.message);
});
}
} catch (error) {
console.error(`[ResourceMonitor] Failed to restart ${containerName}:`, error.message);
}
}
/**
* Trigger bundled workflows for an event
*/
triggerWorkflows(eventType, eventData) {
if (!this.workflowEngine) {
console.log('[ResourceMonitor] Workflow engine not set, skipping workflow trigger');
return;
}
try {
this.workflowEngine.triggerForEvent(eventType, eventData)
.then(results => {
if (results && results.length > 0) {
console.log(`[ResourceMonitor] Triggered ${results.length} workflow(s) for ${eventType}`);
}
})
.catch(err => {
console.error('[ResourceMonitor] Workflow trigger error:', err.message);
});
} catch (error) {
console.error('[ResourceMonitor] Error triggering workflows:', error.message);
}
}
/**
* Set the workflow engine for triggering workflows
*/
setWorkflowEngine(workflowEngine) {
this.workflowEngine = workflowEngine;
console.log('[ResourceMonitor] Workflow engine configured');
}
/**
* Get current stats for a container
*/
getCurrentStats(containerId) {
const containerStats = this.stats.get(containerId);
if (!containerStats || containerStats.history.length === 0) {
return null;
}
return containerStats.history[containerStats.history.length - 1];
}
/**
* Get historical stats for a container
*/
getHistoricalStats(containerId, hours = 24) {
const containerStats = this.stats.get(containerId);
if (!containerStats) return [];
const cutoffTime = Date.now() - (hours * 60 * 60 * 1000);
return containerStats.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime
);
}
/**
* Get aggregated stats for a container
*/
getAggregatedStats(containerId, hours = 24) {
const history = this.getHistoricalStats(containerId, hours);
if (history.length === 0) return null;
const cpuValues = history.map(s => s.cpu.percent);
const memoryValues = history.map(s => s.memory.percent);
return {
cpu: {
current: cpuValues[cpuValues.length - 1],
avg: cpuValues.reduce((a, b) => a + b, 0) / cpuValues.length,
max: Math.max(...cpuValues),
min: Math.min(...cpuValues)
},
memory: {
current: memoryValues[memoryValues.length - 1],
avg: memoryValues.reduce((a, b) => a + b, 0) / memoryValues.length,
max: Math.max(...memoryValues),
min: Math.min(...memoryValues)
},
dataPoints: history.length,
timeRange: hours
};
}
/**
* Get stats for all containers
*/
getAllStats() {
const result = {};
for (const [containerId, data] of this.stats.entries()) {
const current = this.getCurrentStats(containerId);
const aggregated = this.getAggregatedStats(containerId, 24);
result[containerId] = {
name: data.name,
current,
aggregated,
alertConfig: this.alerts.get(containerId)
};
}
return result;
}
/**
* Configure alerts for a container
*/
setAlertConfig(containerId, config) {
this.alerts.set(containerId, {
enabled: config.enabled !== false,
cpuThreshold: config.cpuThreshold || null,
memoryThreshold: config.memoryThreshold || null,
diskIOThreshold: config.diskIOThreshold || null,
cooldownMinutes: config.cooldownMinutes || 15,
autoRestart: config.autoRestart || false,
notificationChannels: config.notificationChannels || []
});
this.saveAlertConfig();
}
/**
* Get alert configuration for a container
*/
getAlertConfig(containerId) {
return this.alerts.get(containerId) || null;
}
/**
* Remove alert configuration
*/
removeAlertConfig(containerId) {
this.alerts.delete(containerId);
this.lastAlerts.delete(containerId);
this.saveAlertConfig();
}
/**
* Get all alert configurations
*/
getAllAlertConfigs() {
const configs = {};
for (const [containerId, config] of this.alerts.entries()) {
configs[containerId] = config;
}
return configs;
}
/**
* Add entry to alert history
*/
addAlertHistoryEntry(entry) {
this.alertHistory.unshift(entry);
// Keep only last 1000 entries
if (this.alertHistory.length > 1000) {
this.alertHistory = this.alertHistory.slice(0, 1000);
}
this.saveAlertHistory();
}
/**
* Get alert history
*/
getAlertHistory(limit = 50) {
return this.alertHistory.slice(0, limit);
}
/**
* Load alert history from disk
*/
loadAlertHistory() {
try {
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`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading alert history:', error.message);
}
}
/**
* Save alert history to disk
*/
saveAlertHistory() {
try {
fs.writeFileSync(ALERT_HISTORY_FILE, JSON.stringify(this.alertHistory, null, 2));
} catch (error) {
console.error('[ResourceMonitor] Error saving alert history:', error.message);
}
}
/**
* Cleanup old stats beyond retention period
*/
cleanupOldStats() {
const cutoffTime = Date.now() - (STATS_RETENTION_HOURS * 60 * 60 * 1000);
for (const [containerId, data] of this.stats.entries()) {
data.history = data.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime
);
// Remove container stats if no recent data
if (data.history.length === 0) {
this.stats.delete(containerId);
}
}
}
/**
* Load stats from disk
*/
loadStats() {
try {
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`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading stats:', error.message);
}
}
/**
* Save stats to disk
*/
saveStats() {
try {
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);
}
}
/**
* Load alert configuration from disk
*/
loadAlertConfig() {
try {
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`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading alert config:', error.message);
}
}
/**
* Save alert configuration to disk
*/
saveAlertConfig() {
try {
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);
}
}
/**
* Aggregate a list of raw samples into a single rollup sample
* @param {Array} samples - Raw stats samples
* @param {string} timestamp - ISO timestamp to use for the rollup bucket
* @returns {Object|null} Aggregated sample, or null if input is empty
*/
_aggregateSamples(samples, timestamp) {
if (!samples || samples.length === 0) return null;
let cpuSum = 0, cpuMax = 0;
let memSum = 0, memMax = 0;
let memPctSum = 0, memPctMax = 0;
let netRxSum = 0, netTxSum = 0;
let diskRSum = 0, diskWSum = 0;
for (const s of samples) {
const cpu = s.cpu?.percent || 0;
const memUsage = s.memory?.usage || 0;
const memPct = s.memory?.percent || 0;
cpuSum += cpu; if (cpu > cpuMax) cpuMax = cpu;
memSum += memUsage; if (memUsage > memMax) memMax = memUsage;
memPctSum += memPct; if (memPct > memPctMax) memPctMax = memPct;
netRxSum += s.network?.rxBytes || 0;
netTxSum += s.network?.txBytes || 0;
diskRSum += s.disk?.readBytes || 0;
diskWSum += s.disk?.writeBytes || 0;
}
const n = samples.length;
return {
timestamp,
sampleCount: n,
cpu: {
avg: Math.round((cpuSum / n) * 100) / 100,
max: Math.round(cpuMax * 100) / 100,
},
memory: {
avgUsage: Math.round(memSum / n),
maxUsage: memMax,
avgPercent: Math.round((memPctSum / n) * 100) / 100,
maxPercent: Math.round(memPctMax * 100) / 100,
avgUsageMB: Math.round(memSum / n / 1024 / 1024),
maxUsageMB: Math.round(memMax / 1024 / 1024),
},
network: {
rxBytes: netRxSum,
txBytes: netTxSum,
rxMB: Math.round(netRxSum / 1024 / 1024 * 100) / 100,
txMB: Math.round(netTxSum / 1024 / 1024 * 100) / 100,
},
disk: {
readBytes: diskRSum,
writeBytes: diskWSum,
readMB: Math.round(diskRSum / 1024 / 1024 * 100) / 100,
writeMB: Math.round(diskWSum / 1024 / 1024 * 100) / 100,
},
};
}
/**
* Combine already-aggregated samples (e.g. hourly buckets) into a single coarser bucket
* @param {Array} samples - Aggregated samples (output of _aggregateSamples)
* @param {string} timestamp - ISO timestamp to use for the rollup bucket
* @returns {Object|null}
*/
_combineAggregated(samples, timestamp) {
if (!samples || samples.length === 0) return null;
let totalCount = 0;
let cpuWeightedSum = 0, cpuMax = 0;
let memWeightedSum = 0, memMax = 0;
let memPctWeightedSum = 0, memPctMax = 0;
let netRxSum = 0, netTxSum = 0;
let diskRSum = 0, diskWSum = 0;
for (const s of samples) {
const w = s.sampleCount || 1;
totalCount += w;
cpuWeightedSum += (s.cpu?.avg || 0) * w;
if ((s.cpu?.max || 0) > cpuMax) cpuMax = s.cpu.max;
memWeightedSum += (s.memory?.avgUsage || 0) * w;
if ((s.memory?.maxUsage || 0) > memMax) memMax = s.memory.maxUsage;
memPctWeightedSum += (s.memory?.avgPercent || 0) * w;
if ((s.memory?.maxPercent || 0) > memPctMax) memPctMax = s.memory.maxPercent;
netRxSum += s.network?.rxBytes || 0;
netTxSum += s.network?.txBytes || 0;
diskRSum += s.disk?.readBytes || 0;
diskWSum += s.disk?.writeBytes || 0;
}
return {
timestamp,
sampleCount: totalCount,
cpu: {
avg: Math.round((cpuWeightedSum / totalCount) * 100) / 100,
max: Math.round(cpuMax * 100) / 100,
},
memory: {
avgUsage: Math.round(memWeightedSum / totalCount),
maxUsage: memMax,
avgPercent: Math.round((memPctWeightedSum / totalCount) * 100) / 100,
maxPercent: Math.round(memPctMax * 100) / 100,
avgUsageMB: Math.round(memWeightedSum / totalCount / 1024 / 1024),
maxUsageMB: Math.round(memMax / 1024 / 1024),
},
network: {
rxBytes: netRxSum,
txBytes: netTxSum,
rxMB: Math.round(netRxSum / 1024 / 1024 * 100) / 100,
txMB: Math.round(netTxSum / 1024 / 1024 * 100) / 100,
},
disk: {
readBytes: diskRSum,
writeBytes: diskWSum,
readMB: Math.round(diskRSum / 1024 / 1024 * 100) / 100,
writeMB: Math.round(diskWSum / 1024 / 1024 * 100) / 100,
},
};
}
/**
* Roll up the previous complete hour of raw samples into a single hourly point.
* Trims hourlyHistory entries older than STATS_HOURLY_RETENTION_DAYS.
*/
rollupHourly() {
const now = new Date();
// The "previous complete hour" — bucket starts at top of (current_hour - 1)
const bucketStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours() - 1, 0, 0);
const bucketEnd = new Date(bucketStart.getTime() + 60 * 60 * 1000);
const bucketStartMs = bucketStart.getTime();
const bucketEndMs = bucketEnd.getTime();
const bucketTimestamp = bucketStart.toISOString();
for (const [containerId, data] of this.stats.entries()) {
const samples = data.history.filter(s => {
const t = new Date(s.timestamp).getTime();
return t >= bucketStartMs && t < bucketEndMs;
});
if (samples.length === 0) continue;
const rollup = this._aggregateSamples(samples, bucketTimestamp);
if (!rollup) continue;
if (!this.hourlyHistory.has(containerId)) {
this.hourlyHistory.set(containerId, { name: data.name, samples: [] });
}
const entry = this.hourlyHistory.get(containerId);
entry.name = data.name;
// Avoid duplicate buckets if rollup ran twice
if (!entry.samples.find(s => s.timestamp === bucketTimestamp)) {
entry.samples.push(rollup);
}
// Trim old entries
const cutoff = Date.now() - (STATS_HOURLY_RETENTION_DAYS * 24 * 60 * 60 * 1000);
entry.samples = entry.samples.filter(s => new Date(s.timestamp).getTime() > cutoff);
}
this.saveHourlyStats();
}
/**
* Roll up the previous complete day of hourly samples into a single daily point.
* Trims dailyHistory entries older than STATS_DAILY_RETENTION_DAYS.
*/
rollupDaily() {
const now = new Date();
// Previous calendar day, midnight to midnight
const bucketStart = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, 0, 0, 0);
const bucketEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0);
const bucketStartMs = bucketStart.getTime();
const bucketEndMs = bucketEnd.getTime();
const bucketTimestamp = bucketStart.toISOString();
for (const [containerId, data] of this.hourlyHistory.entries()) {
const samples = data.samples.filter(s => {
const t = new Date(s.timestamp).getTime();
return t >= bucketStartMs && t < bucketEndMs;
});
if (samples.length === 0) continue;
const rollup = this._combineAggregated(samples, bucketTimestamp);
if (!rollup) continue;
if (!this.dailyHistory.has(containerId)) {
this.dailyHistory.set(containerId, { name: data.name, samples: [] });
}
const entry = this.dailyHistory.get(containerId);
entry.name = data.name;
if (!entry.samples.find(s => s.timestamp === bucketTimestamp)) {
entry.samples.push(rollup);
}
const cutoff = Date.now() - (STATS_DAILY_RETENTION_DAYS * 24 * 60 * 60 * 1000);
entry.samples = entry.samples.filter(s => new Date(s.timestamp).getTime() > cutoff);
}
this.saveDailyStats();
}
/**
* Get history for a container by time range, auto-selecting the appropriate tier.
* - <= 24h → raw 10s samples
* - 1-30 days → hourly rollups
* - > 30 days → daily rollups
* @param {string} containerId
* @param {number} startTime - epoch ms
* @param {number} endTime - epoch ms
* @returns {{ tier: 'raw'|'hourly'|'daily', samples: Array, unit: string }}
*/
getHistoryByRange(containerId, startTime, endTime) {
const rangeMs = endTime - startTime;
const oneDay = 24 * 60 * 60 * 1000;
const thirtyDays = 30 * oneDay;
let tier, samples;
if (rangeMs <= oneDay) {
tier = 'raw';
const data = this.stats.get(containerId);
samples = data ? data.history.filter(s => {
const t = new Date(s.timestamp).getTime();
return t >= startTime && t <= endTime;
}) : [];
} else if (rangeMs <= thirtyDays) {
tier = 'hourly';
const data = this.hourlyHistory.get(containerId);
samples = data ? data.samples.filter(s => {
const t = new Date(s.timestamp).getTime();
return t >= startTime && t <= endTime;
}) : [];
} else {
tier = 'daily';
const data = this.dailyHistory.get(containerId);
samples = data ? data.samples.filter(s => {
const t = new Date(s.timestamp).getTime();
return t >= startTime && t <= endTime;
}) : [];
}
return { tier, samples, unit: tier === 'raw' ? '10s' : tier === 'hourly' ? '1h' : '1d' };
}
/**
* Load hourly rollups from disk
*/
loadHourlyStats() {
try {
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`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading hourly stats:', error.message);
}
}
/**
* Save hourly rollups to disk
*/
saveHourlyStats() {
try {
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);
}
}
/**
* Load daily rollups from disk
*/
loadDailyStats() {
try {
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`);
}
} catch (error) {
console.error('[ResourceMonitor] Error loading daily stats:', error.message);
}
}
/**
* Save daily rollups to disk
*/
saveDailyStats() {
try {
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);
}
}
/**
* Export stats for backup
*/
exportStats() {
return {
stats: Object.fromEntries(this.stats),
hourlyHistory: Object.fromEntries(this.hourlyHistory),
dailyHistory: Object.fromEntries(this.dailyHistory),
alerts: Object.fromEntries(this.alerts),
exportedAt: new Date().toISOString()
};
}
/**
* Import stats from backup
*/
importStats(data) {
if (data.stats) {
this.stats = new Map(Object.entries(data.stats));
}
if (data.hourlyHistory) {
this.hourlyHistory = new Map(Object.entries(data.hourlyHistory));
}
if (data.dailyHistory) {
this.dailyHistory = new Map(Object.entries(data.dailyHistory));
}
if (data.alerts) {
this.alerts = new Map(Object.entries(data.alerts));
}
this.saveStats();
this.saveHourlyStats();
this.saveDailyStats();
this.saveAlertConfig();
}
}
// Export singleton instance
module.exports = new ResourceMonitor();
+237
View File
@@ -0,0 +1,237 @@
/**
* State Manager - Thread-safe file operations with locking
*
* Prevents data corruption when multiple API requests modify state files concurrently.
* Uses file-based locking with automatic retry and timeout handling.
*
* @module state-manager
*/
const lockfile = require('proper-lockfile');
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
class StateManager {
/**
* Create a StateManager instance
* @param {string} filePath - Path to the state file (e.g., services.json)
* @param {Object} options - Configuration options
* @param {number} options.lockTimeout - Max time to wait for lock (ms)
* @param {number} options.lockRetries - Number of lock acquisition retries
* @param {number} options.lockRetryInterval - Time between retries (ms)
*/
constructor(filePath, options = {}) {
this.filePath = filePath;
this.lockOptions = {
retries: {
retries: options.lockRetries || 10,
minTimeout: options.lockRetryInterval || 100,
maxTimeout: (options.lockRetryInterval || 100) * 3
},
stale: options.lockTimeout || 30000 // 30 seconds
};
// Ensure file exists
this._ensureFileExists();
}
/**
* Ensure the state file exists, create with empty array if not
* @private
*/
_ensureFileExists() {
if (!fsSync.existsSync(this.filePath)) {
const dir = path.dirname(this.filePath);
if (!fsSync.existsSync(dir)) {
fsSync.mkdirSync(dir, { recursive: true });
}
fsSync.writeFileSync(this.filePath, '[]', 'utf8');
}
}
/**
* Read the state file (no locking required for read-only operations)
* @returns {Promise<any>} Parsed JSON data
* @throws {Error} If file doesn't exist or JSON is invalid
*/
async read() {
try {
const content = await fs.readFile(this.filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
// File doesn't exist — recreate without locking (no file to lock)
this._ensureFileExists();
return [];
}
throw new Error(`Failed to read state file: ${error.message}`);
}
}
/**
* Write data to the state file (with locking)
* @param {any} data - Data to write (will be JSON.stringify'd)
* @returns {Promise<void>}
* @throws {Error} If lock cannot be acquired or write fails
*/
async write(data) {
let release;
try {
// Acquire lock
release = await lockfile.lock(this.filePath, this.lockOptions);
// Write data with pretty formatting
await fs.writeFile(this.filePath, JSON.stringify(data, null, 2), 'utf8');
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('State file is locked by another process. Try again.');
}
throw new Error(`Failed to write state file: ${error.message}`);
} finally {
// Always release lock
if (release) {
try {
await release();
} catch (e) {
// Lock release failure (non-critical, lock will expire via stale timeout)
}
}
}
}
/**
* Update the state file using a callback function (atomic operation)
* This is the recommended method for most operations.
*
* @param {Function} updateFn - Function that receives current data and returns updated data
* @returns {Promise<any>} The updated data
* @throws {Error} If lock cannot be acquired or update fails
*
* @example
* // Add a new service
* await stateManager.update(services => {
* services.push({ id: 'new-service', name: 'New Service' });
* return services;
* });
*
* @example
* // Remove a service
* await stateManager.update(services => {
* return services.filter(s => s.id !== 'old-service');
* });
*/
async update(updateFn) {
let release;
try {
// Acquire lock
release = await lockfile.lock(this.filePath, this.lockOptions);
// Read current data
const content = await fs.readFile(this.filePath, 'utf8');
const currentData = JSON.parse(content);
// Apply update function
const updatedData = await updateFn(currentData);
// Write updated data
await fs.writeFile(this.filePath, JSON.stringify(updatedData, null, 2), 'utf8');
return updatedData;
} catch (error) {
if (error.code === 'ELOCKED') {
throw new Error('State file is locked by another process. Try again.');
}
throw new Error(`Failed to update state file: ${error.message}`);
} finally {
// Always release lock
if (release) {
try {
await release();
} catch (e) {
// Lock release failure (non-critical, lock will expire via stale timeout)
}
}
}
}
/**
* Check if the state file is currently locked
* @returns {Promise<boolean>} True if locked, false otherwise
*/
async isLocked() {
try {
return await lockfile.check(this.filePath);
} catch (error) {
return false;
}
}
/**
* Forcefully unlock the state file (use with caution!)
* Only use this if a lock is stuck due to a crashed process.
* @returns {Promise<void>}
*/
async forceUnlock() {
try {
await lockfile.unlock(this.filePath);
} catch (error) {
// Ignore errors if file wasn't locked
if (error.code !== 'ENOTACQUIRED') {
throw error;
}
}
}
/**
* Add an item to the state array (convenience method)
* @param {any} item - Item to add
* @returns {Promise<any>} Updated array
*/
async addItem(item) {
return await this.update(items => {
items.push(item);
return items;
});
}
/**
* Remove an item from the state array by ID (convenience method)
* @param {string} id - ID of item to remove
* @returns {Promise<any>} Updated array
*/
async removeItem(id) {
return await this.update(items => {
return items.filter(item => item.id !== id);
});
}
/**
* Update an item in the state array by ID (convenience method)
* @param {string} id - ID of item to update
* @param {Object} updates - Properties to update
* @returns {Promise<any>} Updated array
*/
async updateItem(id, updates) {
return await this.update(items => {
return items.map(item => {
if (item.id === id) {
return { ...item, ...updates };
}
return item;
});
});
}
/**
* Find an item in the state array by ID (convenience method)
* @param {string} id - ID of item to find
* @returns {Promise<any|null>} Found item or null
*/
async findItem(id) {
const items = await this.read();
return items.find(item => item.id === id) || null;
}
}
module.exports = StateManager;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,610 @@
/**
* Health Check Dashboard Module
* Monitors service health, response times, and uptime
* Provides SLA tracking and incident management
*/
const https = require('https');
const http = require('http');
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const paths = require('./platform-paths');
// Persist health config + history alongside the other state files (services.json,
// config.json) rather than next to the source. In a container that data dir is the
// mounted /app/data volume, so uptime history survives container recreates/updates;
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
// still override.
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
// Legacy locations (next to the source) used before the data-dir default. Read these
// once on first load if the new files are absent, so upgrading installs migrate their
// accumulated history/config instead of starting empty. The next save() rewrites to
// the new location.
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
class HealthChecker extends EventEmitter {
constructor() {
super();
this.config = this.loadConfig();
this.history = this.loadHistory();
this.currentStatus = new Map();
this.incidents = [];
this.checking = false;
this.checkInterval = null;
this.consecutiveFailures = new Map(); // serviceId -> failure count
this.serviceTimers = new Map(); // serviceId -> timer for per-service backoff
}
/**
* Start health checking
*/
start() {
if (this.checking) return;
this.checking = true;
// Initial check
this.checkAll();
// Schedule periodic checks
this.checkInterval = setInterval(() => this.checkAll(), CHECK_INTERVAL);
}
/**
* Stop health checking
*/
stop() {
if (!this.checking) return;
this.checking = false;
if (this.checkInterval) {
clearInterval(this.checkInterval);
this.checkInterval = null;
}
// Clear per-service backoff timers
for (const timer of this.serviceTimers.values()) {
clearTimeout(timer);
}
this.serviceTimers.clear();
}
/**
* Get the backoff interval for a service based on consecutive failures.
* Doubles the interval for each failure, capped at MAX_CHECK_INTERVAL.
*/
getBackoffInterval(serviceId) {
const failures = this.consecutiveFailures.get(serviceId) || 0;
if (failures === 0) return CHECK_INTERVAL;
return Math.min(CHECK_INTERVAL * Math.pow(2, failures), MAX_CHECK_INTERVAL);
}
/**
* Check all configured services
*/
async checkAll() {
const services = Object.entries(this.config.services || {});
for (const [serviceId, config] of services) {
if (config.enabled !== false) {
try {
await this.checkService(serviceId, config);
} catch (error) {
// Error logged via checkForIncidents
}
}
}
// Cleanup old history
this.cleanupHistory();
}
/**
* Check a single service
*/
async checkService(serviceId, config) {
const startTime = Date.now();
try {
const result = await this.performHealthCheck(config);
const responseTime = Date.now() - startTime;
const status = {
serviceId,
timestamp: new Date().toISOString(),
status: result.healthy ? 'up' : 'down',
responseTime,
statusCode: result.statusCode,
message: result.message,
details: result.details
};
// Track consecutive failures for exponential backoff
if (result.healthy) {
this.consecutiveFailures.delete(serviceId);
} else {
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
}
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config);
return status;
} catch (error) {
const responseTime = Date.now() - startTime;
// Increment failure count for backoff
this.consecutiveFailures.set(serviceId, (this.consecutiveFailures.get(serviceId) || 0) + 1);
const status = {
serviceId,
timestamp: new Date().toISOString(),
status: 'down',
responseTime,
error: error.message
};
this.recordStatus(serviceId, status);
this.checkForIncidents(serviceId, status, config);
return status;
}
}
/**
* Perform actual health check
*/
async performHealthCheck(config) {
const result = await this._doRequest(config, config.method || 'GET');
// Fall back to GET if HEAD is not supported
if ((result.statusCode === 501 || result.statusCode === 405) && (config.method || '').toUpperCase() === 'HEAD') {
return this._doRequest({ ...config, method: 'GET' }, 'GET');
}
return result;
}
_doRequest(config, method) {
return new Promise((resolve, reject) => {
const url = new URL(config.url);
const protocol = url.protocol === 'https:' ? https : http;
const options = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method,
timeout: config.timeout || 20000,
headers: config.headers || {},
rejectUnauthorized: false // Trust internal CA certs (.sami TLD)
};
const req = protocol.request(options, (res) => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
const healthy = this.evaluateHealth(res.statusCode, data, config);
resolve({
healthy,
statusCode: res.statusCode,
message: healthy ? 'Service is healthy' : 'Service check failed',
details: {
headers: res.headers,
bodyLength: data.length
}
});
});
});
req.on('error', (error) => {
reject(error);
});
req.on('timeout', () => {
req.destroy();
reject(new Error('Health check timeout'));
});
if (config.body) {
req.write(JSON.stringify(config.body));
}
req.end();
});
}
/**
* Evaluate if service is healthy based on response
*/
evaluateHealth(statusCode, body, config) {
// Check status code
const expectedCodes = config.expectedStatusCodes || [200, 201, 204, 301, 302, 303, 307, 308];
if (!expectedCodes.includes(statusCode)) {
return false;
}
// Check response body if pattern specified
if (config.expectedBodyPattern) {
const regex = new RegExp(config.expectedBodyPattern);
if (!regex.test(body)) {
return false;
}
}
// Check response body contains expected text
if (config.expectedBodyContains) {
if (!body.includes(config.expectedBodyContains)) {
return false;
}
}
return true;
}
/**
* Record service status
*/
recordStatus(serviceId, status) {
// Update current status
this.currentStatus.set(serviceId, status);
// Add to history
if (!this.history[serviceId]) {
this.history[serviceId] = [];
}
this.history[serviceId].push(status);
// Emit status event
this.emit('status-check', status);
// Save history periodically
if (Math.random() < 0.05) { // 5% chance (every ~20 checks)
this.saveHistory();
}
}
/**
* Check for incidents (downtime, slow response, etc.)
*/
checkForIncidents(serviceId, status, config) {
const previous = this.currentStatus.get(serviceId);
// Check for status change (up -> down or down -> up)
if (previous && previous.status !== status.status) {
if (status.status === 'down') {
this.createIncident(serviceId, 'outage', 'Service is down', status);
} else if (status.status === 'up') {
this.resolveIncident(serviceId, 'outage', status);
}
}
// Check for slow response time
const slowThreshold = config.slowResponseThreshold || 5000; // 5 seconds
if (status.responseTime > slowThreshold) {
this.createIncident(serviceId, 'slow-response',
`Response time ${status.responseTime}ms exceeds threshold ${slowThreshold}ms`,
status);
}
// Check SLA violations
const sla = config.sla;
if (sla) {
const uptime = this.calculateUptime(serviceId, sla.period || 24);
if (uptime < sla.target) {
this.createIncident(serviceId, 'sla-violation',
`Uptime ${uptime.toFixed(2)}% below SLA target ${sla.target}%`,
status);
}
}
}
/**
* Create a new incident
*/
createIncident(serviceId, type, message, status) {
// Check if similar incident already exists
const existing = this.incidents.find(i =>
i.serviceId === serviceId &&
i.type === type &&
i.status === 'open'
);
if (existing) {
// Update existing incident
existing.lastOccurrence = status.timestamp;
existing.occurrences++;
return;
}
// Create new incident
const incident = {
id: `incident-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId,
type,
message,
status: 'open',
severity: this.calculateSeverity(type),
createdAt: status.timestamp,
lastOccurrence: status.timestamp,
occurrences: 1,
details: status
};
this.incidents.push(incident);
this.emit('incident-created', incident);
this.emit('log', 'info', `Incident created: ${incident.id} - ${message}`);
}
/**
* Resolve an incident
*/
resolveIncident(serviceId, type, status) {
const incident = this.incidents.find(i =>
i.serviceId === serviceId &&
i.type === type &&
i.status === 'open'
);
if (incident) {
incident.status = 'resolved';
incident.resolvedAt = status.timestamp;
incident.duration = new Date(incident.resolvedAt) - new Date(incident.createdAt);
this.emit('incident-resolved', incident);
this.emit('log', 'info', `Incident resolved: ${incident.id}`);
}
}
/**
* Calculate incident severity
*/
calculateSeverity(type) {
switch (type) {
case 'outage':
return 'critical';
case 'sla-violation':
return 'high';
case 'slow-response':
return 'medium';
default:
return 'low';
}
}
/**
* Calculate uptime percentage for a service
*/
calculateUptime(serviceId, hours = 24) {
const history = this.getServiceHistory(serviceId, hours);
if (history.length === 0) return 100;
const upChecks = history.filter(h => h.status === 'up').length;
return (upChecks / history.length) * 100;
}
/**
* Calculate average response time
*/
calculateAverageResponseTime(serviceId, hours = 24) {
const history = this.getServiceHistory(serviceId, hours);
if (history.length === 0) return 0;
const total = history.reduce((sum, h) => sum + (h.responseTime || 0), 0);
return total / history.length;
}
/**
* Get service history for specified time period
*/
getServiceHistory(serviceId, hours = 24) {
const cutoffTime = Date.now() - (hours * 60 * 60 * 1000);
const history = this.history[serviceId] || [];
return history.filter(h =>
new Date(h.timestamp).getTime() > cutoffTime
);
}
/**
* Get current status for all services
*/
getCurrentStatus() {
const result = {};
for (const [serviceId, status] of this.currentStatus.entries()) {
const config = this.config.services[serviceId];
const uptime24h = this.calculateUptime(serviceId, 24);
const uptime7d = this.calculateUptime(serviceId, 168);
const avgResponseTime = this.calculateAverageResponseTime(serviceId, 24);
result[serviceId] = {
...status,
name: config?.name || serviceId,
uptime: {
'24h': uptime24h,
'7d': uptime7d
},
avgResponseTime,
sla: config?.sla
};
}
return result;
}
/**
* Get service statistics
*/
getServiceStats(serviceId, hours = 24) {
const history = this.getServiceHistory(serviceId, hours);
if (history.length === 0) return null;
const upChecks = history.filter(h => h.status === 'up').length;
const downChecks = history.length - upChecks;
const responseTimes = history.map(h => h.responseTime || 0);
return {
serviceId,
period: `${hours}h`,
totalChecks: history.length,
upChecks,
downChecks,
uptime: (upChecks / history.length) * 100,
responseTime: {
avg: responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length,
min: Math.min(...responseTimes),
max: Math.max(...responseTimes),
p95: this.calculatePercentile(responseTimes, 95),
p99: this.calculatePercentile(responseTimes, 99)
}
};
}
/**
* Calculate percentile
*/
calculatePercentile(values, percentile) {
const sorted = values.slice().sort((a, b) => a - b);
const index = Math.ceil((percentile / 100) * sorted.length) - 1;
return sorted[index] || 0;
}
/**
* Get open incidents
*/
getOpenIncidents() {
return this.incidents.filter(i => i.status === 'open');
}
/**
* Get incident history
*/
getIncidentHistory(limit = 50) {
return this.incidents.slice(-limit).reverse();
}
/**
* Configure health check for a service
*/
configureService(serviceId, config) {
if (!this.config.services) {
this.config.services = {};
}
this.config.services[serviceId] = {
enabled: config.enabled !== false,
name: config.name || serviceId,
url: config.url,
method: config.method || 'GET',
timeout: config.timeout || 20000,
expectedStatusCodes: config.expectedStatusCodes || [200],
expectedBodyPattern: config.expectedBodyPattern,
expectedBodyContains: config.expectedBodyContains,
slowResponseThreshold: config.slowResponseThreshold || 5000,
sla: config.sla,
headers: config.headers || {},
body: config.body
};
this.saveConfig();
}
/**
* Remove service configuration
*/
removeService(serviceId) {
if (this.config.services) {
delete this.config.services[serviceId];
this.saveConfig();
}
this.currentStatus.delete(serviceId);
delete this.history[serviceId];
}
/**
* Cleanup old history
*/
cleanupHistory() {
const cutoffTime = Date.now() - (HISTORY_RETENTION_DAYS * 24 * 60 * 60 * 1000);
for (const serviceId in this.history) {
this.history[serviceId] = this.history[serviceId].filter(h =>
new Date(h.timestamp).getTime() > cutoffTime
);
}
}
/**
* Load configuration
*/
loadConfig() {
try {
const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
: (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
if (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
} catch (error) {
this.emit('log', 'error', `Error loading config: ${error.message}`);
}
return { services: {} };
}
/**
* Save configuration
*/
saveConfig() {
try {
fs.writeFileSync(HEALTH_CONFIG_FILE, JSON.stringify(this.config, null, 2));
} catch (error) {
this.emit('log', 'error', `Error saving config: ${error.message}`);
}
}
/**
* Load history
*/
loadHistory() {
try {
const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
: (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
if (file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
} catch (error) {
this.emit('log', 'error', `Error loading history: ${error.message}`);
}
return {};
}
/**
* Save history
*/
saveHistory() {
try {
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
this.emit('log', 'error', `Error saving history: ${error.message}`);
}
}
}
// Export singleton instance
module.exports = new HealthChecker();
+115
View File
@@ -0,0 +1,115 @@
/**
* Simple metrics collector for DashCaddy API
* Tracks request counts, durations, errors, and business metrics
* No external dependencies — all in-memory
*/
class Metrics {
constructor() {
this.startTime = Date.now();
this.requests = {
total: 0,
byStatus: {},
byMethod: {},
byPath: {}
};
this.errors = {
total: 0,
byType: {}
};
this.business = {
containersDeployed: 0,
containersDeleted: 0,
containerUpdates: 0,
dnsRecordsCreated: 0,
backupsCreated: 0,
totpLogins: 0,
siteAdded: 0,
siteRemoved: 0,
credentialRotations: 0
};
}
recordRequest(method, path, statusCode, durationMs) {
this.requests.total++;
this.requests.byStatus[statusCode] = (this.requests.byStatus[statusCode] || 0) + 1;
this.requests.byMethod[method] = (this.requests.byMethod[method] || 0) + 1;
const normalized = this.normalizePath(path);
if (!this.requests.byPath[normalized]) {
this.requests.byPath[normalized] = { count: 0, totalDuration: 0 };
}
const entry = this.requests.byPath[normalized];
entry.count++;
entry.totalDuration += durationMs;
}
recordError(errorType) {
this.errors.total++;
this.errors.byType[errorType] = (this.errors.byType[errorType] || 0) + 1;
}
recordBusinessEvent(eventType) {
if (eventType in this.business) {
this.business[eventType]++;
}
}
normalizePath(p) {
return p
.replace(/\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi, '/:id')
.replace(/\/[0-9a-f]{12,}/gi, '/:id')
.replace(/\/\d+/g, '/:n');
}
getSummary() {
const uptimeMs = Date.now() - this.startTime;
const uptimeSec = Math.floor(uptimeMs / 1000);
const topEndpoints = Object.entries(this.requests.byPath)
.sort((a, b) => b[1].count - a[1].count)
.slice(0, 15)
.map(([path, s]) => ({ path, count: s.count, avgMs: Math.round(s.totalDuration / s.count) }));
return {
uptime: { ms: uptimeMs, human: this.formatUptime(uptimeSec) },
requests: {
total: this.requests.total,
perSecond: uptimeSec > 0 ? +(this.requests.total / uptimeSec).toFixed(2) : 0,
byStatus: this.requests.byStatus,
byMethod: this.requests.byMethod,
topEndpoints
},
errors: {
total: this.errors.total,
rate: this.requests.total > 0 ? +((this.errors.total / this.requests.total) * 100).toFixed(2) : 0,
byType: this.errors.byType
},
business: this.business,
process: {
memory: process.memoryUsage(),
pid: process.pid,
nodeVersion: process.version
}
};
}
formatUptime(sec) {
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = sec % 60;
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m ${s}s`;
if (m > 0) return `${m}m ${s}s`;
return `${s}s`;
}
reset() {
this.startTime = Date.now();
this.requests = { total: 0, byStatus: {}, byMethod: {}, byPath: {} };
this.errors = { total: 0, byType: {} };
}
}
module.exports = new Metrics();
+411
View File
@@ -0,0 +1,411 @@
/**
* SSL Certificate Monitor
* Periodically checks SSL certificates on services with HTTPS URLs.
* Alerts at 30, 14, and 7 days before expiry.
*
* @module ssl-monitor
*/
const tls = require('tls');
const EventEmitter = require('events');
const path = require('path');
const { readJsonFile, writeJsonFile } = require('../utilities/fs-helpers');
const { resolveServiceUrl } = require('../utilities/url-resolver');
/** Default check interval: 1 hour */
const DEFAULT_INTERVAL_MS = 3600000;
/** Alert thresholds in days */
const THRESHOLDS = {
WARNING: 30,
URGENT: 14,
CRITICAL: 7
};
/** TLS connection timeout in milliseconds */
const TLS_TIMEOUT_MS = 10000;
class SSLMonitor extends EventEmitter {
/**
* Create an SSLMonitor instance.
* @param {Object} ctx - Shared application context
* @param {Object} ctx.servicesStateManager - State manager for reading services
* @param {Function} ctx.buildServiceUrl - URL builder helper
* @param {Object} ctx.siteConfig - Site configuration
* @param {Object} ctx.notification - NotificationManager instance
* @param {Object} ctx.log - Logger instance
* @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache
*/
constructor(ctx) {
super();
this.ctx = ctx;
this.log = ctx.log || console;
/** @type {Map<string, Object>} hostname → last cert check result */
this.certStatus = new Map();
/** @type {Map<string, number>} hostname → last notified threshold level */
this.notifiedThresholds = new Map();
/** @type {Map<string, string>} hostname → service ID mapping */
this.hostnameToServiceId = new Map();
/** @type {NodeJS.Timeout|null} */
this.intervalHandle = null;
/** Current config */
this.config = {
enabled: true,
intervalMs: DEFAULT_INTERVAL_MS
};
/** Cache file path */
this.cacheFile = ctx.SSL_CACHE_FILE ||
path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json');
}
/**
* Check the SSL certificate for a given hostname and port.
* Connects via TLS with rejectUnauthorized: false to retrieve certificate info.
*
* @param {string} hostname - The hostname to check
* @param {number} [port=443] - The port to connect to
* @returns {Promise<Object>} Certificate information
*/
async checkCert(hostname, port = 443) {
return new Promise((resolve, reject) => {
const socket = tls.connect({
host: hostname,
port,
rejectUnauthorized: false,
servername: hostname,
timeout: TLS_TIMEOUT_MS
}, () => {
try {
const cert = socket.getPeerCertificate();
if (!cert || Object.keys(cert).length === 0) {
socket.destroy();
return reject(new Error(`No certificate returned for ${hostname}:${port}`));
}
const validFrom = new Date(cert.valid_from);
const validTo = new Date(cert.valid_to);
const now = new Date();
const msRemaining = validTo.getTime() - now.getTime();
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
const result = {
hostname,
port,
subject: cert.subject?.CN || cert.subject?.O || 'Unknown',
issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown',
validFrom: cert.valid_from,
validTo: cert.valid_to,
daysRemaining,
fingerprint: cert.fingerprint || null,
isExpiring: daysRemaining <= THRESHOLDS.WARNING,
checkedAt: new Date().toISOString()
};
socket.destroy();
resolve(result);
} catch (err) {
socket.destroy();
reject(err);
}
});
socket.on('error', (err) => {
reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`));
});
socket.setTimeout(TLS_TIMEOUT_MS, () => {
socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`));
reject(new Error(`TLS connection timeout for ${hostname}:${port}`));
});
});
}
/**
* Check SSL certificates for all services that have HTTPS URLs.
* Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert.
*
* @returns {Promise<Object>} Map of hostname → cert status
*/
async checkAll() {
if (!this.config.enabled) {
this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check');
return this.getStatus();
}
let servicesData;
try {
servicesData = await this.ctx.servicesStateManager.read();
} catch (err) {
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
return this.getStatus();
}
const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []);
for (const service of services) {
const serviceId = service.id || service.name?.toLowerCase();
if (!serviceId) continue;
try {
const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl);
if (!url) continue;
const parsed = new URL(url);
if (parsed.protocol !== 'https:') continue;
const hostname = parsed.hostname;
const port = parseInt(parsed.port) || 443;
// Map hostname back to service ID
this.hostnameToServiceId.set(hostname, serviceId);
const result = await this.checkCert(hostname, port);
// Store result
this.certStatus.set(hostname, result);
// Emit check event
this.emit('cert-check', { serviceId, hostname, result });
// Check alert thresholds
await this._checkAndNotify(hostname, result, serviceId);
} catch (err) {
this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, {
error: err.message
});
}
}
// Persist results
await this._saveCache();
return this.getStatus();
}
/**
* Start periodic SSL certificate checking.
*
* @param {number} [intervalMs=3600000] - Check interval in milliseconds
*/
start(intervalMs) {
if (intervalMs !== undefined) {
this.config.intervalMs = intervalMs;
}
if (this.intervalHandle) {
this.log.warn('ssl-monitor', 'SSL monitor is already running');
return;
}
this.config.enabled = true;
// Load cached data
this._loadCache().catch(err => {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
});
// Initial check (non-blocking)
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
});
// Schedule periodic checks
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
this.log.info('ssl-monitor', 'SSL monitoring started', {
intervalMs: this.config.intervalMs
});
}
/**
* Stop periodic SSL certificate checking.
*/
stop() {
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = null;
}
this.config.enabled = false;
this.log.info('ssl-monitor', 'SSL monitoring stopped');
}
/**
* Get the current SSL certificate status for all checked hostnames.
*
* @returns {Object} Map of hostname → cert status
*/
getStatus() {
const status = {};
for (const [hostname, cert] of this.certStatus.entries()) {
status[hostname] = { ...cert };
}
return status;
}
/**
* Get the SSL certificate status for a specific service.
*
* @param {string} serviceId - The service ID to look up
* @returns {Object|null} Certificate status or null if not found
*/
getServiceCertStatus(serviceId) {
// Find hostname mapped to this service
for (const [hostname, id] of this.hostnameToServiceId.entries()) {
if (id === serviceId) {
const cert = this.certStatus.get(hostname);
return cert ? { ...cert, serviceId } : null;
}
}
return null;
}
/**
* Get current monitoring configuration.
*
* @returns {Object} Config with interval and enabled state
*/
getConfig() {
return { ...this.config };
}
/**
* Update monitoring configuration.
*
* @param {Object} updates - Config updates
* @param {boolean} [updates.enabled] - Enable/disable monitoring
* @param {number} [updates.intervalMs] - Check interval in milliseconds
*/
updateConfig(updates) {
if (typeof updates.enabled === 'boolean') {
this.config.enabled = updates.enabled;
if (!updates.enabled && this.intervalHandle) {
this.stop();
}
}
if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) {
this.config.intervalMs = updates.intervalMs;
// Restart interval if running
if (this.intervalHandle) {
clearInterval(this.intervalHandle);
this.intervalHandle = setInterval(() => {
this.checkAll().catch(err => {
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
});
}, this.config.intervalMs);
}
}
}
// ===== Private Methods =====
/**
* Check alert thresholds and send notifications if thresholds are crossed.
* Only sends one notification per threshold per hostname.
*
* @param {string} hostname
* @param {Object} certResult
* @param {string} serviceId
*/
async _checkAndNotify(hostname, certResult, serviceId) {
const { daysRemaining } = certResult;
const key = hostname;
const lastNotified = this.notifiedThresholds.get(key) || Infinity;
let level = null;
let eventType = null;
let message = null;
if (daysRemaining <= THRESHOLDS.CRITICAL) {
level = THRESHOLDS.CRITICAL;
eventType = 'cert-critical';
message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`;
} else if (daysRemaining <= THRESHOLDS.URGENT) {
level = THRESHOLDS.URGENT;
eventType = 'cert-expiring';
message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`;
} else if (daysRemaining <= THRESHOLDS.WARNING) {
level = THRESHOLDS.WARNING;
eventType = 'cert-expiring';
message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`;
}
if (level !== null && level < lastNotified) {
// New threshold crossed — send notification
this.notifiedThresholds.set(key, level);
this.emit(eventType, { hostname, serviceId, daysRemaining, level });
if (this.ctx.notification) {
try {
await this.ctx.notification.send('ssl-cert-expiry', {
text: message,
hostname,
serviceId,
daysRemaining,
level,
validTo: certResult.validTo
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
} catch (err) {
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
}
}
} else if (level === null) {
// Cert is healthy — reset notification tracking
this.notifiedThresholds.delete(key);
}
}
/**
* Persist cert status cache to disk.
*/
async _saveCache() {
try {
const data = {
lastChecked: new Date().toISOString(),
certs: {},
hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId)
};
for (const [hostname, cert] of this.certStatus.entries()) {
data.certs[hostname] = cert;
}
await writeJsonFile(this.cacheFile, data);
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message });
}
}
/**
* Load cert status cache from disk.
*/
async _loadCache() {
try {
const data = await readJsonFile(this.cacheFile, null);
if (data && data.certs) {
for (const [hostname, cert] of Object.entries(data.certs)) {
this.certStatus.set(hostname, cert);
}
if (data.hostnameToServiceId) {
for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) {
this.hostnameToServiceId.set(hostname, serviceId);
}
}
this.log.info('ssl-monitor', 'Loaded SSL cache', {
certCount: this.certStatus.size
});
}
} catch (err) {
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
}
}
}
module.exports = SSLMonitor;
@@ -0,0 +1,575 @@
/**
* Bundled Workflows - Pre-configured automation templates
*
* Workflows attach to events (container-down, pre-update, resource-alert, scheduled)
* and execute a sequence of actions when triggered.
*/
const EventEmitter = require('events');
const fs = require('fs');
const path = require('path');
const WORKFLOWS_FILE = process.env.WORKFLOWS_FILE || path.join(__dirname, 'workflows-config.json');
const WORKFLOW_HISTORY_FILE = process.env.WORKFLOW_HISTORY_FILE || path.join(__dirname, 'workflow-history.json');
/**
* Bundled workflow templates
*/
const BUNDLED_WORKFLOWS = {
'auto-restart-on-crash': {
id: 'auto-restart-on-crash',
name: 'Auto-Restart on Crash',
description: 'Automatically restart a container when it goes down',
trigger: 'container-down',
actions: [
{ type: 'docker-restart', containerId: '{{containerId}}' },
{ type: 'notify', message: 'Container {{containerId}} restarted automatically' }
]
},
'backup-before-update': {
id: 'backup-before-update',
name: 'Backup Before Update',
description: 'Create a backup before any app update',
trigger: 'pre-update',
actions: [
{ type: 'backup-create', appId: '{{appId}}', label: 'pre-update' },
{ type: 'notify', message: 'Backup created before updating {{appId}}' }
]
},
'health-check-on-interval': {
id: 'health-check-on-interval',
name: 'Periodic Health Check',
description: 'Run health checks every 15 minutes and alert if degraded',
trigger: 'scheduled',
interval: 15 * 60 * 1000, // 15 minutes
actions: [
{ type: 'health-check', target: '{{serviceId}}' },
{ type: 'notify-on-failure', message: 'Health check failed for {{serviceId}}' }
]
},
'disk-space-alert': {
id: 'disk-space-alert',
name: 'Disk Space Alert',
description: 'Alert when disk usage exceeds 80%',
trigger: 'resource-alert',
condition: 'diskPercent > 80',
actions: [
{ type: 'notify', message: '⚠️ Disk usage at {{diskPercent}}% on {{host}}' }
]
},
'weekly-container-report': {
id: 'weekly-container-report',
name: 'Weekly Container Report',
description: 'Send a weekly summary of container status and resource usage',
trigger: 'scheduled',
interval: 7 * 24 * 60 * 60 * 1000, // weekly
actions: [
{ type: 'collect-metrics', period: '7d' },
{ type: 'notify', message: '{{report}}' }
]
}
};
/**
* WorkflowEngine - Executes bundled workflows
*/
class WorkflowEngine extends EventEmitter {
constructor(ctx) {
super();
this.ctx = ctx;
this.enabled = new Map();
this.history = [];
this.scheduledJobs = new Map();
this.loadConfig();
this.loadHistory();
this.startScheduledWorkflows();
}
/**
* Load enabled/disabled state for workflows
*/
loadConfig() {
try {
if (fs.existsSync(WORKFLOWS_FILE)) {
const data = JSON.parse(fs.readFileSync(WORKFLOWS_FILE, 'utf8'));
this.enabled = new Map(Object.entries(data.enabled || {}));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading config:', error.message);
}
// Default all workflows to enabled if not explicitly set
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (!this.enabled.has(id)) {
this.enabled.set(id, true);
}
}
}
/**
* Save enabled/disabled state
*/
saveConfig() {
try {
const data = {
enabled: Object.fromEntries(this.enabled)
};
fs.writeFileSync(WORKFLOWS_FILE, JSON.stringify(data, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving config:', error.message);
}
}
/**
* Load execution history
*/
loadHistory() {
try {
if (fs.existsSync(WORKFLOW_HISTORY_FILE)) {
this.history = JSON.parse(fs.readFileSync(WORKFLOW_HISTORY_FILE, 'utf8'));
}
} catch (error) {
console.error('[WorkflowEngine] Error loading history:', error.message);
this.history = [];
}
}
/**
* Save execution history
*/
saveHistory() {
try {
fs.writeFileSync(WORKFLOW_HISTORY_FILE, JSON.stringify(this.history, null, 2));
} catch (error) {
console.error('[WorkflowEngine] Error saving history:', error.message);
}
}
/**
* Start scheduled workflows
*/
startScheduledWorkflows() {
for (const [id, workflow] of Object.entries(BUNDLED_WORKFLOWS)) {
if (workflow.trigger === 'scheduled' && workflow.interval) {
this.startScheduledWorkflow(id, workflow);
}
}
}
/**
* Start a scheduled workflow
*/
startScheduledWorkflow(workflowId, workflow) {
if (!this.enabled.get(workflowId)) return;
// Clear existing job if any
this.stopScheduledWorkflow(workflowId);
const job = setInterval(() => {
this.executeWorkflow(workflowId, { trigger: 'scheduled', timestamp: new Date().toISOString() })
.catch(err => console.error(`[WorkflowEngine] Scheduled workflow ${workflowId} failed:`, err.message));
}, workflow.interval);
this.scheduledJobs.set(workflowId, job);
console.log(`[WorkflowEngine] Scheduled workflow '${workflowId}' every ${workflow.interval}ms`);
}
/**
* Stop a scheduled workflow
*/
stopScheduledWorkflow(workflowId) {
if (this.scheduledJobs.has(workflowId)) {
clearInterval(this.scheduledJobs.get(workflowId));
this.scheduledJobs.delete(workflowId);
}
}
/**
* Execute a workflow by ID
*/
async executeWorkflow(workflowId, triggerData = {}) {
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (!workflow) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
if (!this.enabled.get(workflowId)) {
console.log(`[WorkflowEngine] Workflow ${workflowId} is disabled, skipping`);
return { skipped: true, reason: 'disabled' };
}
const executionId = `${workflowId}-${Date.now()}`;
const startTime = Date.now();
console.log(`[WorkflowEngine] Executing workflow: ${workflowId}`);
this.emit('workflow-start', { workflowId, executionId, triggerData });
const results = [];
for (const action of workflow.actions) {
try {
const result = await this.executeAction(action, triggerData);
results.push({ action: action.type, success: true, result });
} catch (error) {
console.error(`[WorkflowEngine] Action ${action.type} failed:`, error.message);
results.push({ action: action.type, success: false, error: error.message });
// Continue with other actions but log failure
}
}
const duration = Date.now() - startTime;
const allSucceeded = results.every(r => r.success);
const historyEntry = {
executionId,
workflowId,
workflowName: workflow.name,
trigger: triggerData.trigger || 'manual',
timestamp: new Date().toISOString(),
duration,
success: allSucceeded,
results
};
this.history.push(historyEntry);
// Keep history to last 500 entries
if (this.history.length > 500) {
this.history = this.history.slice(-500);
}
this.saveHistory();
this.emit('workflow-complete', historyEntry);
console.log(`[WorkflowEngine] Workflow ${workflowId} completed in ${duration}ms, success: ${allSucceeded}`);
return historyEntry;
}
/**
* Execute a single action
*/
async executeAction(action, context) {
switch (action.type) {
case 'docker-restart':
return this.restartContainer(this.interpolate(action.containerId, context));
case 'backup-create':
return this.createBackup(
this.interpolate(action.appId, context),
action.label
);
case 'notify':
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'notify-on-failure':
// Only send if previous action failed
return this.notify(
this.interpolate(action.message, context),
action.channel
);
case 'health-check':
return this.healthCheckService(this.interpolate(action.target, context));
case 'collect-metrics':
return this.collectMetrics(context.containerId, action.period);
default:
console.warn(`[WorkflowEngine] Unknown action type: ${action.type}`);
return { skipped: true, reason: `Unknown action type: ${action.type}` };
}
}
/**
* Interpolate template variables in a string
*/
interpolate(str, context) {
if (!str || typeof str !== 'string') return str;
return str.replace(/\{\{(\w+)\}\}/g, (match, key) => {
return context[key] !== undefined ? context[key] : match;
});
}
/**
* Health check action
*/
async healthCheckService(serviceId) {
if (!serviceId || serviceId === '{{serviceId}}') {
// Run health check on all services
const results = [];
const servicesStateManager = this.ctx.servicesStateManager;
if (servicesStateManager) {
const services = servicesStateManager.getState() || [];
for (const service of services) {
if (service.containerId) {
try {
const healthy = await this.checkContainerHealth(service.containerId);
results.push({ service: service.id, healthy });
} catch (e) {
results.push({ service: service.id, healthy: false, error: e.message });
}
}
}
}
return { checked: results.length, healthy: results.filter(r => r.healthy).length, results };
}
// Single service check
const healthy = await this.checkContainerHealth(serviceId);
return { serviceId, healthy };
}
/**
* Check if a container is healthy
*/
async checkContainerHealth(containerId) {
try {
const docker = this.ctx.docker?.client;
if (!docker) return false;
const container = docker.getContainer(containerId);
const info = await container.inspect();
return info.State && info.State.Running && info.State.Health !== 'unhealthy';
} catch (error) {
return false;
}
}
/**
* Docker restart action
*/
async restartContainer(containerId) {
const docker = this.ctx.docker?.client;
if (!docker) {
throw new Error('Docker client not available');
}
if (!containerId || containerId === '{{containerId}}') {
throw new Error('Container ID not provided');
}
console.log(`[WorkflowEngine] Restarting container: ${containerId}`);
const container = docker.getContainer(containerId);
await container.restart();
return { restarted: containerId };
}
/**
* Backup create action
*/
async createBackup(appId, label = 'workflow') {
const backupManager = this.ctx.backupManager;
if (!backupManager) {
throw new Error('Backup manager not available');
}
if (!appId || appId === '{{appId}}') {
throw new Error('App ID not provided');
}
console.log(`[WorkflowEngine] Creating backup for: ${appId}`);
// Use backup manager's executeBackup if available
const backupName = `${appId}-${label}`;
const backupConfig = backupManager.config?.backups?.[appId];
if (backupConfig) {
const result = await backupManager.executeBackup(backupName, backupConfig);
return { backupId: result.backupId, appId, label };
}
// Fallback: trigger manual backup via backup manager
if (backupManager.executeBackup) {
const result = await backupManager.executeBackup(appId, {
include: ['config', 'data'],
schedule: 'manual'
});
return { backupId: result.backupId, appId, label };
}
throw new Error('Backup execution not available');
}
/**
* Notify action
*/
async notify(message, channel) {
const notification = this.ctx.notification;
if (!notification) {
console.warn('[WorkflowEngine] Notification manager not available');
return { notified: false, reason: 'no notification manager' };
}
console.log(`[WorkflowEngine] Sending notification: ${message}`);
notification.send('workflow', 'Workflow Notification', message, 'info');
return { notified: true, message };
}
/**
* Collect metrics action
*/
async collectMetrics(containerId, period = '7d') {
const resourceMonitor = this.ctx.resourceMonitor;
if (!resourceMonitor) {
throw new Error('Resource monitor not available');
}
// Get aggregated stats
const stats = resourceMonitor.getAllStats();
// Build report
let report = `# Weekly Container Report\n\n`;
report += `Generated: ${new Date().toLocaleString()}\n\n`;
for (const [id, info] of Object.entries(stats)) {
const agg = info.aggregated;
report += `## ${info.name || id}\n`;
report += `- Status: ${info.current?.status || 'unknown'}\n`;
if (agg) {
report += `- CPU: avg ${agg.cpu?.avg?.toFixed(1)}%, max ${agg.cpu?.max?.toFixed(1)}%\n`;
report += `- Memory: avg ${agg.memory?.avg?.toFixed(1)}%, max ${agg.memory?.max?.toFixed(1)}%\n`;
}
report += '\n';
}
return { report, containerCount: Object.keys(stats).length };
}
/**
* List all available workflows
*/
listWorkflows() {
return Object.entries(BUNDLED_WORKFLOWS).map(([id, workflow]) => ({
...workflow,
enabled: this.enabled.get(id) ?? true
}));
}
/**
* Enable or disable a workflow
*/
setWorkflowEnabled(workflowId, enabled) {
if (!BUNDLED_WORKFLOWS[workflowId]) {
throw new Error(`Unknown workflow: ${workflowId}`);
}
this.enabled.set(workflowId, enabled);
this.saveConfig();
// Handle scheduled workflows
const workflow = BUNDLED_WORKFLOWS[workflowId];
if (workflow.trigger === 'scheduled' && workflow.interval) {
if (enabled) {
this.startScheduledWorkflow(workflowId, workflow);
} else {
this.stopScheduledWorkflow(workflowId);
}
}
console.log(`[WorkflowEngine] Workflow ${workflowId} ${enabled ? 'enabled' : 'disabled'}`);
return { workflowId, enabled };
}
/**
* Get execution history for a workflow
*/
getHistory(workflowId = null, limit = 50) {
let history = this.history;
if (workflowId) {
history = history.filter(h => h.workflowId === workflowId);
}
return history.slice(-limit).reverse();
}
/**
* Trigger workflows for a specific event
*/
async triggerForEvent(eventType, eventData) {
const matchingWorkflows = Object.entries(BUNDLED_WORKFLOWS)
.filter(([id, workflow]) => {
if (workflow.trigger !== eventType) return false;
if (!this.enabled.get(id)) return false;
// Check condition if specified
if (workflow.condition && eventData) {
try {
// Simple condition evaluation
const conditionMet = this.evaluateCondition(workflow.condition, eventData);
return conditionMet;
} catch (e) {
console.warn(`[WorkflowEngine] Condition evaluation failed for ${id}:`, e.message);
return false;
}
}
return true;
});
const results = [];
for (const [workflowId, workflow] of matchingWorkflows) {
try {
const result = await this.executeWorkflow(workflowId, {
trigger: eventType,
timestamp: new Date().toISOString(),
...eventData
});
results.push({ workflowId, success: true, result });
} catch (error) {
results.push({ workflowId, success: false, error: error.message });
}
}
return results;
}
/**
* Evaluate a simple condition string
*/
evaluateCondition(condition, data) {
// Simple condition like "diskPercent > 80"
// Supports: >, <, >=, <=, ==, !=
const match = condition.match(/^(\w+)\s*(>=|<=|==|!=|>|<)\s*(\S+)$/);
if (!match) return true;
const [, field, operator, value] = match;
const fieldValue = data[field];
if (fieldValue === undefined) return false;
const numValue = parseFloat(value);
const numFieldValue = parseFloat(fieldValue);
switch (operator) {
case '>': return numFieldValue > numValue;
case '<': return numFieldValue < numValue;
case '>=': return numFieldValue >= numValue;
case '<=': return numFieldValue <= numValue;
case '==': return fieldValue == value;
case '!=': return fieldValue != value;
default: return false;
}
}
/**
* Stop all scheduled workflows
*/
stop() {
for (const [workflowId] of this.scheduledJobs) {
this.stopScheduledWorkflow(workflowId);
}
console.log('[WorkflowEngine] All scheduled workflows stopped');
}
}
module.exports = { WorkflowEngine, BUNDLED_WORKFLOWS };
@@ -0,0 +1,339 @@
// DashCaddy Recipe Templates
// Multi-container application stacks deployed as a single unit
const RECIPE_TEMPLATES = {
// === MEDIA & ENTERTAINMENT ===
"htpc-suite": {
name: "HTPC Suite",
description: "Complete media automation: find, download, organize, and stream",
icon: "\uD83C\uDFAC",
category: "Media",
type: "recipe",
difficulty: "Intermediate",
popularity: 98,
components: [
{
id: "prowlarr",
role: "Indexer Manager",
templateRef: "prowlarr",
required: true,
order: 1
},
{
id: "qbittorrent",
role: "Download Client",
templateRef: "qbittorrent",
required: true,
order: 2
},
{
id: "sonarr",
role: "TV Show Manager",
templateRef: "sonarr",
required: true,
order: 3
},
{
id: "radarr",
role: "Movie Manager",
templateRef: "radarr",
required: true,
order: 4
},
{
id: "lidarr",
role: "Music Manager",
templateRef: "lidarr",
required: false,
order: 5
},
{
id: "overseerr",
role: "Request Manager",
templateRef: "seerr",
required: false,
order: 6
}
],
sharedVolumes: {
media: {
label: "Media Library",
description: "Root folder for all media (movies, TV, music)",
defaultPath: "/media",
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"]
},
downloads: {
label: "Downloads",
description: "Shared downloads folder for all download clients",
defaultPath: "/downloads",
usedBy: ["sonarr", "radarr", "lidarr", "qbittorrent"]
}
},
autoConnect: {
enabled: true,
description: "Automatically connects Sonarr/Radarr to Prowlarr and qBittorrent",
steps: [
{ action: "configureProwlarrApps", targets: ["sonarr", "radarr", "lidarr"] },
{ action: "configureDownloadClient", client: "qbittorrent", targets: ["sonarr", "radarr", "lidarr"] }
]
},
setupInstructions: [
"All services share the same media and downloads folders",
"Prowlarr is pre-connected to Sonarr, Radarr, and Lidarr",
"Add indexers in Prowlarr \u2014 they sync automatically to all *arr apps",
"Add your media library root folders in Sonarr and Radarr",
"qBittorrent is pre-configured as the download client"
]
},
// === PRODUCTIVITY ===
"nextcloud-complete": {
name: "Nextcloud Complete",
description: "Full productivity suite: cloud storage, office editing, and collaboration",
icon: "\u2601\uFE0F",
category: "Productivity",
type: "recipe",
difficulty: "Intermediate",
popularity: 90,
components: [
{
id: "nextcloud-db",
role: "Database",
required: true,
order: 0,
docker: {
image: "mariadb:11",
ports: [],
volumes: ["/opt/nextcloud-db/data:/var/lib/mysql"],
environment: {
"MYSQL_ROOT_PASSWORD": "{{GENERATED_PASSWORD}}",
"MYSQL_DATABASE": "nextcloud",
"MYSQL_USER": "nextcloud",
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}"
}
},
internal: true
},
{
id: "nextcloud-redis",
role: "Cache",
required: true,
order: 0,
docker: {
image: "redis:7-alpine",
ports: [],
volumes: ["/opt/nextcloud-redis/data:/data"],
environment: {}
},
internal: true
},
{
id: "nextcloud",
role: "Cloud Platform",
templateRef: "nextcloud",
required: true,
order: 1,
envOverrides: {
"MYSQL_HOST": "dashcaddy-nextcloud-db",
"MYSQL_DATABASE": "nextcloud",
"MYSQL_USER": "nextcloud",
"MYSQL_PASSWORD": "{{GENERATED_PASSWORD}}",
"REDIS_HOST": "dashcaddy-nextcloud-redis"
}
},
{
id: "collabora",
role: "Office Suite",
required: false,
order: 2,
docker: {
image: "collabora/code:latest",
ports: ["{{PORT}}:9980"],
volumes: [],
environment: {
"aliasgroup1": "https://{{NEXTCLOUD_DOMAIN}}",
"extra_params": "--o:ssl.enable=false --o:ssl.termination=true"
}
},
subdomain: "office",
defaultPort: 9980,
healthCheck: "/"
}
],
network: {
name: "dashcaddy-nextcloud",
driver: "bridge"
},
sharedVolumes: {
data: {
label: "Cloud Storage",
description: "Nextcloud data directory for user files",
defaultPath: "/opt/nextcloud/data",
usedBy: ["nextcloud"]
}
},
setupInstructions: [
"Complete the Nextcloud initial setup wizard in the browser",
"MariaDB and Redis are pre-configured and connected",
"If Collabora is enabled, configure it in Nextcloud: Settings \u2192 Nextcloud Office",
"Point Nextcloud Office to your Collabora URL (e.g., https://office.sami)",
"Configure email, 2FA, and other settings in Nextcloud admin panel"
]
},
// === DEVELOPMENT ===
"dev-environment": {
name: "Dev Environment",
description: "Self-hosted development workflow: Git, CI/CD, IDE, and database",
icon: "\uD83D\uDCBB",
category: "Development",
type: "recipe",
difficulty: "Advanced",
popularity: 82,
components: [
{
id: "dev-postgres",
role: "Database",
required: true,
order: 0,
docker: {
image: "postgres:16-alpine",
ports: [],
volumes: ["/opt/dev-postgres/data:/var/lib/postgresql/data"],
environment: {
"POSTGRES_DB": "gitea",
"POSTGRES_USER": "gitea",
"POSTGRES_PASSWORD": "{{GENERATED_PASSWORD}}"
}
},
internal: true
},
{
id: "gitea",
role: "Git Server",
templateRef: "gitea",
required: true,
order: 1,
envOverrides: {
"GITEA__database__DB_TYPE": "postgres",
"GITEA__database__HOST": "dashcaddy-dev-postgres:5432",
"GITEA__database__NAME": "gitea",
"GITEA__database__USER": "gitea",
"GITEA__database__PASSWD": "{{GENERATED_PASSWORD}}"
}
},
{
id: "drone",
role: "CI/CD Pipeline",
templateRef: "drone",
required: false,
order: 2
},
{
id: "vscode-server",
role: "Web IDE",
templateRef: "vscode-server",
required: false,
order: 3
}
],
network: {
name: "dashcaddy-dev",
driver: "bridge"
},
setupInstructions: [
"Gitea is pre-configured with PostgreSQL database",
"Complete the Gitea initial setup wizard in the browser",
"If Drone CI is enabled, connect it to Gitea via OAuth application",
"VS Code Server provides a full IDE in your browser",
"All development services share a Docker network for inter-service communication"
]
},
// === HOME AUTOMATION ===
"smart-home": {
name: "Smart Home Hub",
description: "Home automation: control, automate, and monitor IoT devices",
icon: "\uD83C\uDFE0",
category: "Home Automation",
type: "recipe",
difficulty: "Intermediate",
popularity: 88,
components: [
{
id: "mosquitto",
role: "MQTT Broker",
required: true,
order: 0,
docker: {
image: "eclipse-mosquitto:2",
ports: ["1883:1883", "9001:9001"],
volumes: [
"/opt/mosquitto/config:/mosquitto/config",
"/opt/mosquitto/data:/mosquitto/data",
"/opt/mosquitto/log:/mosquitto/log"
],
environment: {}
},
subdomain: "mqtt",
defaultPort: 1883,
internal: false,
setupNote: "MQTT broker for IoT device communication"
},
{
id: "homeassistant",
role: "Automation Hub",
templateRef: "homeassistant",
required: true,
order: 1
},
{
id: "nodered",
role: "Flow Automation",
templateRef: "nodered",
required: true,
order: 2
},
{
id: "zigbee2mqtt",
role: "Zigbee Bridge",
required: false,
order: 3,
docker: {
image: "koenkk/zigbee2mqtt:latest",
ports: ["{{PORT}}:8080"],
volumes: ["/opt/zigbee2mqtt/data:/app/data"],
environment: {
"TZ": "{{TIMEZONE}}"
}
},
subdomain: "zigbee",
defaultPort: 8080,
healthCheck: "/",
note: "Requires a Zigbee USB adapter (e.g., Sonoff Zigbee 3.0 USB Dongle Plus)"
}
],
network: {
name: "dashcaddy-smarthome",
driver: "bridge"
},
setupInstructions: [
"Mosquitto MQTT broker is ready for IoT device connections on port 1883",
"Complete the Home Assistant onboarding wizard in the browser",
"Connect Home Assistant to MQTT: Settings \u2192 Integrations \u2192 MQTT",
"Node-RED provides visual flow automation \u2014 connect it to MQTT for device control",
"If Zigbee2MQTT is enabled, it requires a physical Zigbee USB adapter"
]
}
};
// Recipe category metadata (separate from app categories)
const RECIPE_CATEGORIES = {
"Media": { icon: "\uD83C\uDFAC", color: "#e74c3c", description: "Media streaming and automation stacks" },
"Productivity": { icon: "\u2601\uFE0F", color: "#3498db", description: "Cloud storage and office suites" },
"Development": { icon: "\uD83D\uDCBB", color: "#9b59b6", description: "Self-hosted development environments" },
"Home Automation": { icon: "\uD83C\uDFE0", color: "#27ae60", description: "IoT and smart home control" }
};
module.exports = { RECIPE_TEMPLATES, RECIPE_CATEGORIES };
+178
View File
@@ -0,0 +1,178 @@
const path = require('path');
const StateManager = require('../managers/state-manager');
const crypto = require('crypto');
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(__dirname, 'audit-log.json');
const MAX_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
// Route path → readable action mapping
const ACTION_MAP = {
'POST /api/v1/services/update': 'service.reorder',
'POST /api/v1/services': 'service.create',
'PUT /api/v1/services': 'service.update',
'DELETE /api/v1/services/': 'service.delete',
'POST /api/v1/site': 'caddy.add-site',
'POST /api/v1/site/external': 'caddy.add-external',
'DELETE /api/v1/site/': 'caddy.remove-site',
'POST /api/v1/caddy/reload': 'caddy.reload',
'POST /api/v1/dns/record': 'dns.add-record',
'DELETE /api/v1/dns/record': 'dns.delete-record',
'POST /api/v1/dns/credentials': 'dns.save-credentials',
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
'POST /api/v1/dns/update': 'dns.update-server',
'POST /api/v1/containers/': 'container.action',
'DELETE /api/v1/containers/': 'container.delete',
'POST /api/v1/apps/deploy': 'container.deploy',
'DELETE /api/v1/apps/': 'container.undeploy',
'POST /api/v1/backups/execute': 'backup.execute',
'POST /api/v1/backups/restore/': 'backup.restore',
'POST /api/v1/backups/config': 'backup.config',
'POST /api/v1/config': 'config.update',
'DELETE /api/v1/config': 'config.reset',
'POST /api/v1/notifications/config': 'config.notifications',
'POST /api/v1/totp/setup': 'auth.totp-setup',
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
'POST /api/v1/totp/disable': 'auth.totp-disable',
'POST /api/v1/totp/config': 'auth.totp-config',
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
'POST /api/v1/updates/update/': 'container.update',
'POST /api/v1/updates/rollback/': 'container.rollback',
'POST /api/v1/updates/auto-update/': 'container.auto-update',
'POST /api/v1/updates/check': 'container.check-updates',
'POST /api/v1/health-checks/': 'config.health-check',
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
'POST /api/v1/arr/credentials': 'config.arr-credentials',
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
'POST /api/v1/logo': 'config.logo-upload',
'DELETE /api/v1/logo': 'config.logo-delete',
'POST /api/v1/favicon': 'config.favicon-upload',
'DELETE /api/v1/favicon': 'config.favicon-delete',
'POST /api/v1/tailscale/config': 'config.tailscale',
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
};
// Paths to skip logging (noisy or internal)
const SKIP_PATHS = [
'/api/v1/totp/verify',
'/api/v1/totp/check-session',
'/api/v1/auth/gate/',
'/api/v1/auth/app-token/',
'/api/v1/audit-logs',
'/api/v1/health',
'/health',
'/api/v1/notifications/test',
'/api/v1/notifications/health-check',
];
class AuditLogger {
constructor() {
this.stateManager = new StateManager(AUDIT_LOG_FILE);
}
resolveAction(method, urlPath) {
const key = `${method} ${urlPath}`;
// Exact match first
if (ACTION_MAP[key]) return ACTION_MAP[key];
// Prefix match (for parameterized routes like /api/services/:id)
for (const [pattern, action] of Object.entries(ACTION_MAP)) {
if (key.startsWith(pattern)) return action;
}
// Fallback: derive from path
const parts = urlPath.replace('/api/v1/', '').split('/');
const category = parts[0] || 'unknown';
return `${category}.${method.toLowerCase()}`;
}
extractResource(urlPath) {
// Pull a meaningful resource identifier from the URL path
const parts = urlPath.replace('/api/v1/', '').split('/');
if (parts.length >= 2) return parts.slice(1).join('/');
return parts[0] || '';
}
shouldSkip(method, urlPath) {
if (method === 'GET') return true;
for (const skip of SKIP_PATHS) {
if (urlPath.startsWith(skip)) return true;
}
return false;
}
async log({ action, resource, details, outcome, ip }) {
try {
const entry = {
id: crypto.randomUUID(),
timestamp: new Date().toISOString(),
ip: ip || '',
action: action || '',
resource: resource || '',
details: details || {},
outcome: outcome || 'unknown'
};
await this.stateManager.update(entries => {
entries.unshift(entry);
if (entries.length > MAX_ENTRIES) {
entries.length = MAX_ENTRIES;
}
return entries;
});
} catch (e) {
console.error('[AuditLogger] Failed to write entry:', e.message);
}
}
async query({ limit = 50, offset = 0, action } = {}) {
try {
let entries = await this.stateManager.read();
if (action) {
entries = entries.filter(e => e.action && e.action.startsWith(action));
}
return entries.slice(offset, offset + limit);
} catch (e) {
console.error('[AuditLogger] Failed to read:', e.message);
return [];
}
}
async clear() {
await this.stateManager.write([]);
}
middleware() {
return (req, res, next) => {
if (this.shouldSkip(req.method, req.path)) return next();
const originalJson = res.json.bind(res);
res.json = (data) => {
// Log asynchronously — don't block the response
const ip = req.ip || req.socket?.remoteAddress || '';
const action = this.resolveAction(req.method, req.path);
const resource = this.extractResource(req.path);
const outcome = data && data.success === false ? 'failure' : 'success';
// Sanitize details — don't log passwords or tokens
const details = {};
if (req.params && Object.keys(req.params).length) details.params = req.params;
if (req.body) {
const safe = { ...req.body };
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
if (safe[key]) safe[key] = '***';
}
details.body = safe;
}
this.log({ action, resource, details, outcome, ip }).catch(() => {});
return originalJson(data);
};
next();
};
}
}
module.exports = new AuditLogger();
+453
View File
@@ -0,0 +1,453 @@
/**
* Crypto Utilities for DashCaddy
* Handles encryption/decryption of sensitive credentials
* Uses AES-256-GCM for authenticated encryption
*/
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
// Encryption settings
const ALGORITHM = 'aes-256-gcm';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 16; // 128 bits for GCM
const AUTH_TAG_LENGTH = 16;
const SALT_LENGTH = 32;
// Resolve encryption key file path — supports both standard install (/app/.encryption-key)
// and custom deployments with consolidated data directory (/app/data/.encryption-key)
function resolveKeyFile() {
if (process.env.ENCRYPTION_KEY_FILE) {
return process.env.ENCRYPTION_KEY_FILE;
}
const candidates = [
path.join(__dirname, '.encryption-key'),
path.join(__dirname, 'data', '.encryption-key'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// No existing file — return standard path so first load creates it there
return candidates[0];
}
const KEY_FILE = resolveKeyFile();
let encryptionKey = null;
/**
* Generate a new encryption key
* @returns {Buffer} 32-byte encryption key
*/
function generateKey() {
return crypto.randomBytes(KEY_LENGTH);
}
/**
* Derive a key from a password using PBKDF2 (async, non-blocking)
* @param {string} password - Password to derive key from
* @param {Buffer} salt - Salt for key derivation
* @returns {Promise<Buffer>} Derived key
*/
async function deriveKey(password, salt) {
return new Promise((resolve, reject) => {
crypto.pbkdf2(password, salt, 100000, KEY_LENGTH, 'sha512', (err, key) => {
if (err) reject(err);
else resolve(key);
});
});
}
/**
* Load or create the encryption key
* @returns {Buffer} The encryption key
*/
function loadOrCreateKey() {
if (encryptionKey) {
return encryptionKey;
}
// Check for key in environment variable first
if (process.env.DASHCADDY_ENCRYPTION_KEY) {
encryptionKey = Buffer.from(process.env.DASHCADDY_ENCRYPTION_KEY, 'hex');
console.log('[Crypto] Using encryption key from environment variable');
return encryptionKey;
}
// Try to load from file
if (fs.existsSync(KEY_FILE)) {
try {
const keyData = fs.readFileSync(KEY_FILE, 'utf8').trim();
if (keyData.length >= 64) {
encryptionKey = Buffer.from(keyData, 'hex');
console.log('[Crypto] Loaded encryption key from file');
// First-run bootstrap: if .bak doesn't exist yet, write the current
// key to it. This ensures the silent recovery path is available from
// the very next restart without requiring an explicit rotateKey().
if (!fs.existsSync(KEY_FILE + '.bak')) {
try {
fs.writeFileSync(KEY_FILE + '.bak', keyData, { mode: 0o600 });
console.log(`[Crypto] Seeded ${KEY_FILE}.bak with current key for future fallback`);
} catch (e) {
console.warn('[Crypto] Could not seed .bak key file:', e.message);
}
}
// Try fallback to .bak key if primary can't decrypt existing credentials.
// This handles the "container recreate rotated the key" case where the
// backup key on disk is the ORIGINAL key that can still read the
// bind-mounted /app/data/credentials.json written before the upgrade.
if (fs.existsSync(KEY_FILE + '.bak')) {
try {
const backupData = fs.readFileSync(KEY_FILE + '.bak', 'utf8').trim();
if (backupData.length >= 64) {
encryptionKey = tryFallbackToBackupKey(Buffer.from(keyData, 'hex'), Buffer.from(backupData, 'hex'));
}
} catch (e) {
console.warn('[Crypto] Could not check backup key:', e.message);
}
}
return encryptionKey;
}
// File exists but key is invalid/empty - will generate new one below
} catch (error) {
console.error('[Crypto] Error loading key file:', error.message);
}
}
// Generate new key
encryptionKey = generateKey();
try {
// Save key to file with restricted permissions
fs.writeFileSync(KEY_FILE, encryptionKey.toString('hex'), { mode: 0o600 });
console.log('[Crypto] Generated and saved new encryption key');
} catch (error) {
console.warn('[Crypto] Could not save key to file:', error.message);
console.warn('[Crypto] Key will be regenerated on restart - credentials will need to be re-entered');
}
return encryptionKey;
}
/**
* If the primary key fails to decrypt any existing credentials, try the backup
* key. This is the silent recovery path: if a container recreate replaced
* .encryption-key with a fresh one but left .encryption-key.bak (the previous
* key), the old key can still decrypt the bind-mounted credentials.json and
* the user stays logged in without ever noticing.
*
* Called only at startup when both key files exist. Returns the working key
* (either primary or backup). If neither works, returns the primary (existing
* behavior `retrieve()` will surface "unreadable" via credential-manager.diagnose).
*
* @param {Buffer} primaryKey - key from .encryption-key
* @param {Buffer} backupKey - key from .encryption-key.bak
* @returns {Buffer} the key that should be used
*/
function tryFallbackToBackupKey(primaryKey, backupKey) {
const CREDENTIALS_FILE = process.env.CREDENTIALS_FILE ||
require('path').join(__dirname, 'credentials.json');
if (!fs.existsSync(CREDENTIALS_FILE)) return primaryKey;
let credentials;
try {
credentials = JSON.parse(fs.readFileSync(CREDENTIALS_FILE, 'utf8'));
} catch {
return primaryKey;
}
// Find the first encrypted entry to probe
const probeEntry = Object.values(credentials).find(v => v && v.value && isEncrypted(v.value));
if (!probeEntry) return primaryKey;
const tryDecrypt = (key) => {
const parts = probeEntry.value.split(':');
if (parts.length !== 3) return false;
try {
const iv = Buffer.from(parts[0], 'base64');
const tag = Buffer.from(parts[1], 'base64');
const ct = Buffer.from(parts[2], 'base64');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(tag);
Buffer.concat([decipher.update(ct), decipher.final()]);
return true;
} catch { return false; }
};
if (tryDecrypt(primaryKey)) return primaryKey;
if (tryDecrypt(backupKey)) {
console.warn(
'[Crypto] Primary encryption key failed to decrypt credentials; ' +
'fell back to .encryption-key.bak. The current primary key was set ' +
'without preserving the original. Consider rotating the key explicitly ' +
'via the credential-manager API to avoid this warning next restart.'
);
return backupKey;
}
return primaryKey; // neither works — credential-manager.diagnose() will report 'unreadable'
}
/**
* Encrypt sensitive data
* @param {string|object} data - Data to encrypt (strings or objects)
* @returns {string} Encrypted data as base64 string with format: iv:authTag:ciphertext
*/
function encrypt(data) {
const key = loadOrCreateKey();
const iv = crypto.randomBytes(IV_LENGTH);
// Convert object to string if needed
const plaintext = typeof data === 'object' ? JSON.stringify(data) : String(data);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8', 'base64');
encrypted += cipher.final('base64');
const authTag = cipher.getAuthTag();
// Return format: iv:authTag:ciphertext (all base64)
return `${iv.toString('base64')}:${authTag.toString('base64')}:${encrypted}`;
}
/**
* Decrypt encrypted data
* @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
* @returns {string} Decrypted plaintext
*/
function decrypt(encryptedData) {
const key = loadOrCreateKey();
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
/**
* Check if a string is encrypted (has our format)
* @param {string} data - Data to check
* @returns {boolean} True if data appears to be encrypted
*/
function isEncrypted(data) {
if (typeof data !== 'string') return false;
const parts = data.split(':');
if (parts.length !== 3) return false;
// Check if parts look like base64
try {
Buffer.from(parts[0], 'base64');
Buffer.from(parts[1], 'base64');
return true;
} catch {
return false;
}
}
/**
* Encrypt specific fields in an object
* @param {object} obj - Object with fields to encrypt
* @param {string[]} fields - Array of field names to encrypt
* @returns {object} Object with specified fields encrypted
*/
function encryptFields(obj, fields) {
const result = { ...obj };
for (const field of fields) {
if (result[field] !== undefined && result[field] !== null) {
// Don't double-encrypt
if (!isEncrypted(result[field])) {
result[field] = encrypt(result[field]);
}
}
}
result._encrypted = true; // Mark as encrypted
result._encryptedFields = fields;
return result;
}
/**
* Decrypt specific fields in an object
* @param {object} obj - Object with encrypted fields
* @param {string[]} fields - Array of field names to decrypt (optional, uses _encryptedFields if available)
* @returns {object} Object with specified fields decrypted
*/
function decryptFields(obj, fields = null) {
if (!obj._encrypted) {
return obj; // Not encrypted, return as-is
}
const fieldsToDecrypt = fields || obj._encryptedFields || [];
const result = { ...obj };
for (const field of fieldsToDecrypt) {
if (result[field] !== undefined && isEncrypted(result[field])) {
try {
result[field] = decrypt(result[field]);
} catch (error) {
console.error(`[Crypto] Failed to decrypt field '${field}':`, error.message);
// Leave the field as-is if decryption fails
}
}
}
// Remove encryption markers from result
delete result._encrypted;
delete result._encryptedFields;
return result;
}
/**
* Migrate plaintext credentials to encrypted format
* @param {object} credentials - Credentials object that may or may not be encrypted
* @param {string[]} sensitiveFields - Fields that should be encrypted
* @returns {object} Encrypted credentials object
*/
function migrateToEncrypted(credentials, sensitiveFields) {
if (credentials._encrypted) {
return credentials; // Already encrypted
}
console.log('[Crypto] Migrating plaintext credentials to encrypted format');
return encryptFields(credentials, sensitiveFields);
}
/**
* Read and decrypt a credentials file
* @param {string} filePath - Path to credentials file
* @param {string[]} sensitiveFields - Fields that are encrypted
* @returns {object|null} Decrypted credentials or null if file doesn't exist
*/
function readEncryptedFile(filePath, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
const data = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(data);
// Check if this is encrypted data
if (parsed._encrypted) {
return decryptFields(parsed, sensitiveFields);
}
// Plain text data - migrate it
console.log(`[Crypto] Found plaintext data in ${filePath}, will encrypt on next save`);
return parsed;
} catch (error) {
console.error(`[Crypto] Error reading ${filePath}:`, error.message);
return null;
}
}
/**
* Encrypt and write credentials to a file
* @param {string} filePath - Path to credentials file
* @param {object} credentials - Credentials to save
* @param {string[]} sensitiveFields - Fields to encrypt
*/
function writeEncryptedFile(filePath, credentials, sensitiveFields = ['password', 'token', 'apiKey', 'secret']) {
const encrypted = encryptFields(credentials, sensitiveFields);
fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), 'utf8');
console.log(`[Crypto] Saved encrypted credentials to ${filePath}`);
}
/**
* Rotate the encryption key generates a new key and returns both old and new
* @returns {{ oldKey: Buffer, newKey: Buffer }} Old and new key pair
* @throws {Error} If new key cannot be saved to disk
*/
function rotateKey() {
const oldKey = loadOrCreateKey(); // Ensure we have the current key loaded
const newKey = generateKey();
// Save the OLD key to .bak BEFORE swapping the primary. This gives the
// startup-time fallback a way to recover the previous key if a future
// restart loses the new one (e.g. another accidental recreate). The .bak
// file is overwritten on each rotate so it always holds the previous key,
// not an ever-accumulating history.
try {
fs.writeFileSync(KEY_FILE + '.bak', oldKey.toString('hex'), { mode: 0o600 });
} catch (error) {
console.warn(`[Crypto] Could not save backup key to ${KEY_FILE}.bak:`, error.message);
}
try {
fs.writeFileSync(KEY_FILE, newKey.toString('hex'), { mode: 0o600 });
} catch (error) {
throw new Error(`Failed to save new encryption key: ${error.message}`);
}
// Only update the cached key after file write succeeds
encryptionKey = newKey;
return { oldKey, newKey };
}
/**
* Decrypt data using a specific key (for key rotation)
* @param {string} encryptedData - Encrypted string in format iv:authTag:ciphertext
* @param {Buffer} key - The key to decrypt with
* @returns {string} Decrypted plaintext
*/
function decryptWithKey(encryptedData, key) {
const parts = encryptedData.split(':');
if (parts.length !== 3) {
throw new Error('Invalid encrypted data format');
}
const iv = Buffer.from(parts[0], 'base64');
const authTag = Buffer.from(parts[1], 'base64');
const ciphertext = parts[2];
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(ciphertext, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// Lazy-initialize: key is loaded on first encrypt/decrypt call.
// Do NOT call loadOrCreateKey() here — during Docker build, it would generate
// a key baked into the image that conflicts with the mounted production key.
/**
* Clear the cached encryption key so it reloads from file on next use.
* Called after restoring an encryption key from backup.
*/
function clearCachedKey() {
encryptionKey = null;
}
module.exports = {
encrypt,
decrypt,
isEncrypted,
encryptFields,
decryptFields,
migrateToEncrypted,
readEncryptedFile,
writeEncryptedFile,
loadOrCreateKey,
deriveKey,
rotateKey,
decryptWithKey,
clearCachedKey
};
@@ -0,0 +1,225 @@
/**
* CSRF Protection Module
* Implements HMAC-signed double-submit cookie pattern for stateless CSRF protection.
* The cookie contains a random nonce; the header must carry the HMAC signature
* of that nonce computed with a server-side secret. An attacker who can inject
* a cookie still cannot forge the matching header without the secret.
*/
const crypto = require('crypto');
const cryptoUtils = require('./crypto-utils');
const { errorResponse } = require('../utils/responses');
const CSRF_TOKEN_LENGTH = 32;
const CSRF_COOKIE_NAME = 'dashcaddy_csrf';
const CSRF_HEADER_NAME = 'x-csrf-token';
/**
* Generate a cryptographically secure CSRF nonce
* @returns {string} Base64URL-encoded random nonce
*/
function generateToken() {
return crypto.randomBytes(CSRF_TOKEN_LENGTH).toString('base64url');
}
/**
* Compute HMAC signature for a CSRF nonce using the server-side encryption key
* @param {string} nonce - The random nonce to sign
* @returns {string} Base64URL-encoded HMAC signature
*/
function signToken(nonce) {
const key = cryptoUtils.loadOrCreateKey();
return crypto.createHmac('sha256', key).update(nonce).digest('base64url');
}
/**
* Parse cookie header string into object
* @param {string} cookieHeader - Cookie header value
* @returns {Object} Parsed cookies
*/
function parseCookie(cookieHeader) {
if (!cookieHeader) return {};
return cookieHeader.split(';').reduce((cookies, cookie) => {
const [name, ...rest] = cookie.trim().split('=');
if (name && rest.length > 0) {
cookies[name] = rest.join('=');
}
return cookies;
}, {});
}
/**
* Create CSRF middleware with cookie domain support.
* When a TLD (e.g. ".sami") is provided, cookies are set with Domain=.sami
* so they are shared across all subdomains for forward_auth SSO.
* @param {Object} [options]
* @param {string} [options.cookieDomain] - e.g. ".sami" to share cookies across subdomains
* @returns {{ csrfCookieMiddleware: Function, renewCSRFToken: Function }}
*/
function createCSRFMiddleware(options = {}) {
const { cookieDomain } = options;
/**
* Middleware to set CSRF cookie on requests.
* Preserves existing nonce to avoid invalidating tokens the client has cached.
* New nonce is generated only on first visit (no cookie) or after TOTP login
* (which calls renewCSRFToken). If TOTP is disabled, the nonce is set once
* and never changes.
*/
function csrfCookieMiddleware(req, res, next) {
const cookies = parseCookie(req.headers.cookie);
const existingNonce = cookies[CSRF_COOKIE_NAME];
// Reuse existing nonce; only generate fresh if no cookie exists yet
const csrfNonce = existingNonce || generateToken();
// Store nonce + signature on request so endpoints can access them
req.csrfToken = signToken(csrfNonce);
req.csrfNonce = csrfNonce;
// Only set cookie if it's new (avoids unnecessary Set-Cookie headers)
if (!existingNonce) {
const cookieOpts = {
httpOnly: false, // Must be readable by JavaScript for signing
secure: req.secure || req.protocol === 'https',
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000 // 1 year (effectively permanent)
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
}
next();
}
/**
* Generate a fresh CSRF nonce and set it on the response.
* Called after TOTP login to rotate the token for the new session.
* @param {Object} res - Express response object
* @param {boolean} secure - Whether to set Secure flag on cookie
* @returns {string} The new CSRF signed token
*/
function renewCSRFToken(res, secure) {
const csrfNonce = generateToken();
const cookieOpts = {
httpOnly: false,
secure: !!secure,
sameSite: 'strict',
path: '/',
maxAge: 365 * 24 * 60 * 60 * 1000
};
if (cookieDomain) cookieOpts.domain = cookieDomain;
res.cookie(CSRF_COOKIE_NAME, csrfNonce, cookieOpts);
return signToken(csrfNonce);
}
return { csrfCookieMiddleware, renewCSRFToken };
}
/**
* Middleware to validate CSRF token on state-changing requests
* Validates that the token in the cookie matches the token in the header
*/
function csrfValidationMiddleware(req, res, next) {
const method = req.method.toUpperCase();
// Skip validation for safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(method)) {
return next();
}
// Skip CSRF validation in test environment
if (process.env.NODE_ENV === 'test') {
return next();
}
// Excluded paths that don't require CSRF validation
const excludedPaths = [
'/api/v1/totp/verify',
'/api/v1/totp/verify-setup',
'/api/v1/totp/setup',
'/health',
'/api/v1/health',
// Machine-to-machine: publishing host POSTs here with its own shared-secret
// header (X-DashCaddy-Notify-Secret) — browsers never reach this endpoint.
'/api/v1/system/update-notify'
];
const isExcluded = excludedPaths.some(path => req.path === path) ||
req.path.startsWith('/api/v1/auth/gate/');
if (isExcluded) {
return next();
}
// Get nonce from cookie
const cookies = parseCookie(req.headers.cookie);
const cookieNonce = cookies[CSRF_COOKIE_NAME];
// Get signed token from header (case-insensitive)
const headerToken = req.headers[CSRF_HEADER_NAME] ||
req.headers[CSRF_HEADER_NAME.toLowerCase()];
// Skip CSRF for API key-authenticated requests (API keys are not sent automatically by browsers)
if (req.headers['x-api-key'] || (req.headers.authorization && req.headers.authorization.startsWith('Bearer '))) {
return next();
}
// Validate both values exist
if (!cookieNonce) {
console.warn(`[CSRF] Missing CSRF cookie: ${method} ${req.path} from ${req.ip}`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
message: 'CSRF cookie not found. Please refresh the page (Ctrl+Shift+R) and try again.'
});
}
if (!headerToken) {
console.warn(`[CSRF] Missing CSRF header: ${method} ${req.path} from ${req.ip}`);
return errorResponse(res, 403, '[DC-100] CSRF token missing', {
message: 'CSRF token not provided in request headers. Please refresh the page (Ctrl+Shift+R) and try again.'
});
}
// Validate that the header token is the correct HMAC signature of the cookie nonce
try {
const expectedSig = signToken(cookieNonce);
const expectedBuffer = Buffer.from(expectedSig, 'base64url');
const headerBuffer = Buffer.from(headerToken, 'base64url');
if (expectedBuffer.length !== headerBuffer.length) {
throw new Error('Token length mismatch');
}
if (!crypto.timingSafeEqual(expectedBuffer, headerBuffer)) {
throw new Error('Token mismatch');
}
// Signature valid — request is authentic
next();
} catch (err) {
console.warn(`[CSRF] Invalid CSRF token: ${method} ${req.path} from ${req.ip} - ${err.message}`);
return errorResponse(res, 403, '[DC-101] CSRF token invalid', {
message: 'CSRF token validation failed. Please refresh the page (Ctrl+Shift+R) and try again.'
});
}
}
// Default instance (no domain) for backward compatibility with tests
const defaultInstance = createCSRFMiddleware();
module.exports = {
CSRF_TOKEN_LENGTH,
CSRF_COOKIE_NAME,
CSRF_HEADER_NAME,
generateToken,
signToken,
parseCookie,
createCSRFMiddleware,
csrfValidationMiddleware,
// Default instance exports for backward compat
csrfCookieMiddleware: defaultInstance.csrfCookieMiddleware,
renewCSRFToken: defaultInstance.renewCSRFToken
};
@@ -0,0 +1,346 @@
/**
* Docker Security Module
* Provides image digest verification to ensure container images match expected digests
* Protects against supply chain attacks and malicious image replacements
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const Docker = require('dockerode');
const docker = new Docker();
const SECURITY_CONFIG_FILE = process.env.DOCKER_SECURITY_CONFIG || path.join(__dirname, 'docker-security-config.json');
const VERIFICATION_MODE = process.env.DOCKER_VERIFICATION_MODE || 'verify'; // strict | verify | permissive
class DockerSecurity {
constructor() {
this.config = this.loadConfig();
this.mode = VERIFICATION_MODE;
console.log(`[DockerSecurity] Initialized in ${this.mode} mode`);
}
/**
* Load security configuration
*/
loadConfig() {
try {
if (fs.existsSync(SECURITY_CONFIG_FILE)) {
const data = fs.readFileSync(SECURITY_CONFIG_FILE, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.warn(`[DockerSecurity] Failed to load config: ${error.message}`);
}
// Default configuration
return {
trustedDigests: {},
verificationMode: VERIFICATION_MODE,
allowUnverified: true,
updateTrustedOnPull: true
};
}
/**
* Save security configuration
*/
saveConfig() {
try {
fs.writeFileSync(SECURITY_CONFIG_FILE, JSON.stringify(this.config, null, 2));
} catch (error) {
console.error(`[DockerSecurity] Failed to save config: ${error.message}`);
}
}
/**
* Get image digest from Docker
* @param {string} imageName - Full image name with tag (e.g., "nginx:latest")
* @returns {Promise<string>} Image digest (sha256:...)
*/
async getImageDigest(imageName) {
try {
const image = docker.getImage(imageName);
const inspect = await image.inspect();
// RepoDigests contains the full image reference with digest
// Example: ["nginx@sha256:abcd1234..."]
if (inspect.RepoDigests && inspect.RepoDigests.length > 0) {
const digestPart = inspect.RepoDigests[0].split('@')[1];
return digestPart;
}
// If no RepoDigest, use the local Image ID
// This happens with locally built images or images pulled before digests were tracked
return inspect.Id;
} catch (error) {
throw new Error(`Failed to get image digest: ${error.message}`);
}
}
/**
* Fetch manifest from Docker registry
* @param {string} imageName - Image name (e.g., "nginx:latest")
* @returns {Promise<object>} Manifest data with digest
*/
async fetchRegistryManifest(imageName) {
// Parse image name
const parts = imageName.split('/');
let registry = 'registry-1.docker.io';
let repository = imageName;
let tag = 'latest';
// Handle different image name formats
if (imageName.includes(':')) {
const tagSplit = imageName.split(':');
tag = tagSplit[tagSplit.length - 1];
repository = tagSplit.slice(0, -1).join(':');
}
// Handle custom registries
if (parts.length > 2 || (parts.length === 2 && parts[0].includes('.'))) {
registry = parts[0];
repository = parts.slice(1).join('/').split(':')[0];
} else if (parts.length === 1) {
// Official Docker Hub images need 'library/' prefix
repository = `library/${repository.split(':')[0]}`;
} else {
repository = repository.split(':')[0];
}
console.log(`[DockerSecurity] Fetching manifest for ${registry}/${repository}:${tag}`);
return new Promise((resolve, reject) => {
const isDockerHub = registry === 'registry-1.docker.io';
const tokenUrl = isDockerHub
? `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${repository}:pull`
: null;
const fetchManifest = (token) => {
const options = {
hostname: registry,
path: `/v2/${repository}/manifests/${tag}`,
method: 'GET',
headers: {
'Accept': 'application/vnd.docker.distribution.manifest.v2+json',
}
};
if (token) {
options.headers['Authorization'] = `Bearer ${token}`;
}
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode === 200) {
try {
const manifest = JSON.parse(data);
const digest = res.headers['docker-content-digest'];
resolve({ manifest, digest });
} catch (error) {
reject(new Error(`Failed to parse manifest: ${error.message}`));
}
} else {
reject(new Error(`Registry returned status ${res.statusCode}: ${data}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`Registry request failed: ${error.message}`));
});
req.end();
};
// Get auth token for Docker Hub
if (isDockerHub) {
https.get(tokenUrl, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const authData = JSON.parse(data);
fetchManifest(authData.token);
} catch (error) {
reject(new Error(`Failed to get auth token: ${error.message}`));
}
});
}).on('error', (error) => {
reject(new Error(`Auth request failed: ${error.message}`));
});
} else {
fetchManifest(null);
}
});
}
/**
* Verify image digest against trusted digests
* @param {string} imageName - Image name with tag
* @param {string} actualDigest - Actual digest from pulled image
* @returns {Promise<object>} Verification result
*/
async verifyImageDigest(imageName, actualDigest) {
const baseImageName = imageName.split(':')[0];
const trustedDigest = this.config.trustedDigests[imageName] || this.config.trustedDigests[baseImageName];
const result = {
verified: false,
mode: this.mode,
imageName,
actualDigest,
trustedDigest: trustedDigest || null,
action: 'unknown'
};
if (!trustedDigest) {
// No trusted digest configured
if (this.mode === 'strict') {
result.verified = false;
result.action = 'reject';
result.reason = 'No trusted digest configured (strict mode)';
} else {
result.verified = true;
result.action = 'accept';
result.reason = 'No trusted digest configured (permissive mode)';
if (this.config.updateTrustedOnPull) {
this.config.trustedDigests[imageName] = actualDigest;
this.saveConfig();
console.log(`[DockerSecurity] Added trusted digest for ${imageName}`);
}
}
} else if (actualDigest === trustedDigest) {
// Digest matches
result.verified = true;
result.action = 'accept';
result.reason = 'Digest matches trusted value';
} else {
// Digest mismatch
if (this.mode === 'strict') {
result.verified = false;
result.action = 'reject';
result.reason = 'Digest mismatch (strict mode)';
} else if (this.mode === 'verify') {
result.verified = false;
result.action = 'warn';
result.reason = 'Digest mismatch (verify mode - warning only)';
} else {
result.verified = true;
result.action = 'accept';
result.reason = 'Digest mismatch (permissive mode - accepted)';
}
}
return result;
}
/**
* Verify an image after pulling
* @param {string} imageName - Image name with tag
* @returns {Promise<object>} Verification result
*/
async verifyPulledImage(imageName) {
console.log(`[DockerSecurity] Verifying image: ${imageName}`);
try {
const actualDigest = await this.getImageDigest(imageName);
const result = await this.verifyImageDigest(imageName, actualDigest);
if (result.action === 'reject') {
console.error(`[DockerSecurity] REJECTED: ${result.reason}`);
throw new Error(`Image verification failed: ${result.reason}`);
} else if (result.action === 'warn') {
console.warn(`[DockerSecurity] WARNING: ${result.reason}`);
console.warn(`[DockerSecurity] Expected: ${result.trustedDigest}`);
console.warn(`[DockerSecurity] Actual: ${result.actualDigest}`);
} else {
console.log(`[DockerSecurity] ACCEPTED: ${result.reason}`);
}
return result;
} catch (error) {
console.error(`[DockerSecurity] Verification error: ${error.message}`);
if (this.mode === 'strict') {
throw error;
}
return {
verified: false,
mode: this.mode,
imageName,
action: this.mode === 'permissive' ? 'accept' : 'warn',
error: error.message,
reason: `Verification error (${this.mode} mode)`
};
}
}
/**
* Add or update trusted digest for an image
* @param {string} imageName - Image name with tag
* @param {string} digest - Trusted digest
*/
setTrustedDigest(imageName, digest) {
this.config.trustedDigests[imageName] = digest;
this.saveConfig();
console.log(`[DockerSecurity] Updated trusted digest for ${imageName}`);
}
/**
* Remove trusted digest for an image
* @param {string} imageName - Image name with tag
*/
removeTrustedDigest(imageName) {
delete this.config.trustedDigests[imageName];
this.saveConfig();
console.log(`[DockerSecurity] Removed trusted digest for ${imageName}`);
}
/**
* Get all trusted digests
*/
getTrustedDigests() {
return { ...this.config.trustedDigests };
}
/**
* Set verification mode
* @param {string} mode - strict | verify | permissive
*/
setMode(mode) {
if (!['strict', 'verify', 'permissive'].includes(mode)) {
throw new Error('Invalid mode. Must be: strict, verify, or permissive');
}
this.mode = mode;
this.config.verificationMode = mode;
this.saveConfig();
console.log(`[DockerSecurity] Verification mode set to: ${mode}`);
}
/**
* Get security status
*/
getStatus() {
return {
mode: this.mode,
trustedImagesCount: Object.keys(this.config.trustedDigests).length,
configFile: SECURITY_CONFIG_FILE,
updateTrustedOnPull: this.config.updateTrustedOnPull
};
}
}
// Singleton instance
const dockerSecurity = new DockerSecurity();
module.exports = dockerSecurity;
@@ -0,0 +1,606 @@
/**
* Input Validation Module for DashCaddy
* Comprehensive validation to prevent injection attacks and ensure data integrity
*/
const path = require('path');
const validator = require('validator');
class ValidationError extends Error {
constructor(message, field = null) {
super(message);
this.name = 'ValidationError';
this.field = field;
this.statusCode = 400;
}
}
/**
* Validate DNS record data
*/
function validateDNSRecord(data) {
const errors = [];
// Validate subdomain
if (!data.subdomain || typeof data.subdomain !== 'string') {
errors.push({ field: 'subdomain', message: 'Subdomain is required' });
} else {
// DNS label validation: alphanumeric and hyphens, 1-63 chars, no leading/trailing hyphens
const subdomainRegex = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i;
if (!subdomainRegex.test(data.subdomain)) {
errors.push({
field: 'subdomain',
message: 'Invalid subdomain format. Use only letters, numbers, and hyphens (1-63 chars)'
});
}
// Prevent DNS injection attempts
const dangerousChars = [';', '&', '|', '`', '$', '(', ')', '<', '>', '\n', '\r', '\\'];
if (dangerousChars.some(char => data.subdomain.includes(char))) {
errors.push({ field: 'subdomain', message: 'Subdomain contains invalid characters' });
}
}
// Validate domain
if (data.domain && typeof data.domain === 'string') {
if (!validator.isFQDN(data.domain, { require_tld: false })) {
errors.push({ field: 'domain', message: 'Invalid domain format' });
}
}
// Validate IP address
if (!data.ip || typeof data.ip !== 'string') {
errors.push({ field: 'ip', message: 'IP address is required' });
} else {
if (!validator.isIP(data.ip, 4) && !validator.isIP(data.ip, 6)) {
errors.push({ field: 'ip', message: 'Invalid IP address format' });
}
// Prevent SSRF by blocking private IPs in certain contexts
if (data.blockPrivateIPs && isPrivateIP(data.ip)) {
errors.push({ field: 'ip', message: 'Private IP addresses are not allowed in this context' });
}
}
// Validate TTL if provided
if (data.ttl !== undefined) {
const ttl = parseInt(data.ttl, 10);
if (isNaN(ttl) || ttl < 60 || ttl > 86400) {
errors.push({ field: 'ttl', message: 'TTL must be between 60 and 86400 seconds' });
}
}
if (errors.length > 0) {
const error = new ValidationError('DNS record validation failed');
error.errors = errors;
throw error;
}
return {
subdomain: data.subdomain.toLowerCase().trim(),
domain: data.domain ? data.domain.toLowerCase().trim() : null,
ip: data.ip.trim(),
ttl: data.ttl ? parseInt(data.ttl, 10) : 3600
};
}
/**
* Validate Docker container deployment data
*/
function validateDockerDeployment(data) {
const errors = [];
// Validate container name
if (!data.name || typeof data.name !== 'string') {
errors.push({ field: 'name', message: 'Container name is required' });
} else {
// Docker name validation: alphanumeric, underscores, periods, hyphens
const nameRegex = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
if (!nameRegex.test(data.name)) {
errors.push({
field: 'name',
message: 'Invalid container name. Use only letters, numbers, underscores, periods, and hyphens'
});
}
if (data.name.length > 255) {
errors.push({ field: 'name', message: 'Container name too long (max 255 chars)' });
}
}
// Validate Docker image
if (!data.image || typeof data.image !== 'string') {
errors.push({ field: 'image', message: 'Docker image is required' });
} else {
// Docker image validation: registry/repo:tag format
// Allow: alpine, nginx:latest, docker.io/library/nginx:1.21, ghcr.io/user/repo:tag
const imageRegex = /^(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?::[0-9]{1,5})?\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*(?:\/[a-z0-9]+(?:[._-][a-z0-9]+)*)*(?::[a-z0-9]+(?:[._-][a-z0-9]+)*)?$/i;
if (!imageRegex.test(data.image)) {
errors.push({
field: 'image',
message: 'Invalid Docker image format'
});
}
// Block dangerous image patterns
const dangerousPatterns = [';', '&', '|', '`', '$', '$(', '&&', '||', '\n', '\r'];
if (dangerousPatterns.some(pattern => data.image.includes(pattern))) {
errors.push({ field: 'image', message: 'Docker image contains invalid characters' });
}
if (data.image.length > 512) {
errors.push({ field: 'image', message: 'Docker image name too long' });
}
}
// Validate ports
if (data.ports) {
if (!Array.isArray(data.ports)) {
errors.push({ field: 'ports', message: 'Ports must be an array' });
} else {
data.ports.forEach((port, index) => {
if (typeof port === 'string') {
// Format: "8080:80" or "8080:80/tcp"
const portRegex = /^(\d{1,5}):(\d{1,5})(?:\/(tcp|udp))?$/;
if (!portRegex.test(port)) {
errors.push({
field: `ports[${index}]`,
message: 'Invalid port format. Use "host:container" or "host:container/protocol"'
});
} else {
const [, hostPort, containerPort] = port.match(portRegex);
if (!isValidPort(hostPort) || !isValidPort(containerPort)) {
errors.push({ field: `ports[${index}]`, message: 'Port numbers must be between 1 and 65535' });
}
}
} else if (typeof port === 'number') {
if (!isValidPort(port)) {
errors.push({ field: `ports[${index}]`, message: 'Port number must be between 1 and 65535' });
}
} else {
errors.push({ field: `ports[${index}]`, message: 'Invalid port type' });
}
});
}
}
// Validate volumes
if (data.volumes) {
if (!Array.isArray(data.volumes)) {
errors.push({ field: 'volumes', message: 'Volumes must be an array' });
} else {
data.volumes.forEach((volume, index) => {
if (typeof volume !== 'string') {
errors.push({ field: `volumes[${index}]`, message: 'Volume must be a string' });
} else {
// Validate volume format and prevent path traversal
const volumeErrors = validateVolumePath(volume, index);
errors.push(...volumeErrors);
}
});
}
}
// Validate environment variables
if (data.environment) {
if (typeof data.environment !== 'object' || Array.isArray(data.environment)) {
errors.push({ field: 'environment', message: 'Environment must be an object' });
} else {
Object.entries(data.environment).forEach(([key, value]) => {
// Validate env var name
const envKeyRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
if (!envKeyRegex.test(key)) {
errors.push({
field: `environment.${key}`,
message: 'Invalid environment variable name'
});
}
// Ensure value is string or number
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') {
errors.push({
field: `environment.${key}`,
message: 'Environment variable value must be string, number, or boolean'
});
}
});
}
}
if (errors.length > 0) {
const error = new ValidationError('Docker deployment validation failed');
error.errors = errors;
throw error;
}
return {
name: data.name.trim(),
image: data.image.trim(),
ports: data.ports || [],
volumes: data.volumes || [],
environment: data.environment || {}
};
}
/**
* Validate file path to prevent directory traversal
*/
function validateFilePath(filePath, allowedBasePaths = []) {
if (!filePath || typeof filePath !== 'string') {
throw new ValidationError('File path is required', 'path');
}
// Normalize path
const normalized = path.normalize(filePath);
// Check for directory traversal attempts
if (normalized.includes('..') || normalized.includes('~')) {
throw new ValidationError('Path traversal detected', 'path');
}
// Block absolute paths to sensitive locations
const blockedPaths = [
'/etc',
'/sys',
'/proc',
'/root',
'C:\\Windows',
'C:\\Program Files',
'/var/run',
'/var/lib/docker'
];
const lowerPath = normalized.toLowerCase();
if (blockedPaths.some(blocked => lowerPath.startsWith(blocked.toLowerCase()))) {
throw new ValidationError('Access to this path is not allowed', 'path');
}
// If allowed base paths specified, ensure path is within them
if (allowedBasePaths.length > 0) {
const isAllowed = allowedBasePaths.some(basePath => {
const normalizedBase = path.normalize(basePath);
return normalized.startsWith(normalizedBase);
});
if (!isAllowed) {
throw new ValidationError('Path is outside allowed directories', 'path');
}
}
return normalized;
}
/**
* Validate volume path for Docker
*/
function validateVolumePath(volume, index) {
const errors = [];
// Format: /host/path:/container/path or /host/path:/container/path:ro
const volumeRegex = /^([^:]+):([^:]+)(?::(ro|rw|z|Z))?$/;
const match = volume.match(volumeRegex);
if (!match) {
errors.push({
field: `volumes[${index}]`,
message: 'Invalid volume format. Use "host:container" or "host:container:mode"'
});
return errors;
}
const [, hostPath, containerPath, mode] = match;
// Validate host path
try {
validateFilePath(hostPath);
} catch (error) {
errors.push({
field: `volumes[${index}].hostPath`,
message: `Invalid host path: ${error.message}`
});
}
// Validate container path
if (containerPath.includes('..') || !path.isAbsolute(containerPath)) {
errors.push({
field: `volumes[${index}].containerPath`,
message: 'Container path must be absolute and not contain ..'
});
}
// Validate mode if present
if (mode && !['ro', 'rw', 'z', 'Z'].includes(mode)) {
errors.push({
field: `volumes[${index}].mode`,
message: 'Invalid volume mode. Use ro, rw, z, or Z'
});
}
return errors;
}
/**
* Validate URL
*/
function validateURL(url, options = {}) {
if (!url || typeof url !== 'string') {
throw new ValidationError('URL is required', 'url');
}
const validatorOptions = {
protocols: options.protocols || ['http', 'https'],
require_protocol: options.requireProtocol !== false,
require_valid_protocol: true,
allow_underscores: false,
...options
};
if (!validator.isURL(url, validatorOptions)) {
throw new ValidationError('Invalid URL format', 'url');
}
// Block localhost/private IPs if specified
if (options.blockPrivate) {
try {
const urlObj = new URL(url);
if (urlObj.hostname === 'localhost' ||
urlObj.hostname === '127.0.0.1' ||
isPrivateIP(urlObj.hostname)) {
throw new ValidationError('Private URLs are not allowed', 'url');
}
} catch (e) {
if (e instanceof ValidationError) throw e;
throw new ValidationError('Invalid URL', 'url');
}
}
return url;
}
/**
* Validate API token format
*/
function validateToken(token) {
if (!token || typeof token !== 'string') {
throw new ValidationError('Token is required', 'token');
}
// Token should be alphanumeric with possible special chars, reasonable length
if (token.length < 8) {
throw new ValidationError('Token too short (minimum 8 characters)', 'token');
}
if (token.length > 512) {
throw new ValidationError('Token too long (maximum 512 characters)', 'token');
}
// Block obvious injection attempts
const dangerousPatterns = [';', '&', '|', '`', '\n', '\r', '$(', '&&'];
if (dangerousPatterns.some(pattern => token.includes(pattern))) {
throw new ValidationError('Token contains invalid characters', 'token');
}
return token.trim();
}
/**
* Validate service configuration
*/
function validateServiceConfig(service) {
const errors = [];
// Validate ID
if (!service.id || typeof service.id !== 'string') {
errors.push({ field: 'id', message: 'Service ID is required' });
} else {
const idRegex = /^[a-z0-9-_]+$/i;
if (!idRegex.test(service.id)) {
errors.push({ field: 'id', message: 'Invalid service ID format' });
}
}
// Validate name
if (!service.name || typeof service.name !== 'string') {
errors.push({ field: 'name', message: 'Service name is required' });
} else if (service.name.length > 100) {
errors.push({ field: 'name', message: 'Service name too long (max 100 chars)' });
}
// Validate URL if provided
if (service.url) {
try {
validateURL(service.url);
} catch (error) {
errors.push({ field: 'url', message: error.message });
}
}
// Validate port if provided
if (service.port !== undefined && !isValidPort(service.port)) {
errors.push({ field: 'port', message: 'Invalid port number' });
}
if (errors.length > 0) {
const error = new ValidationError('Service configuration validation failed');
error.errors = errors;
throw error;
}
return service;
}
/**
* Helper: Check if port is valid
*/
function isValidPort(port) {
const portNum = typeof port === 'string' ? parseInt(port, 10) : port;
return !isNaN(portNum) && portNum >= 1 && portNum <= 65535;
}
/**
* Helper: Check if IP is private
*/
function isPrivateIP(ip) {
// IPv4 private ranges
const privateRanges = [
/^10\./,
/^172\.(1[6-9]|2[0-9]|3[0-1])\./,
/^192\.168\./,
/^127\./,
/^169\.254\./,
/^::1$/,
/^fc00:/,
/^fe80:/
];
return privateRanges.some(range => range.test(ip));
}
/**
* Sanitize string for safe display (prevent XSS)
*/
function sanitizeString(str, maxLength = 1000) {
if (typeof str !== 'string') return '';
return str
.slice(0, maxLength)
.replace(/[<>'"]/g, char => {
const entities = { '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' };
return entities[char] || char;
});
}
/**
* Validate secure path with realpath resolution and traversal detection
* This is CRITICAL for preventing path traversal attacks
* @param {string} requestedPath - The path requested by the user
* @param {Array<string>} allowedRoots - Array of allowed root directories
* @param {object} auditLogger - Optional audit logger for security events
* @returns {Promise<string>} - Resolved safe path
*/
async function validateSecurePath(requestedPath, allowedRoots, auditLogger = null) {
const fs = require('fs').promises;
if (!requestedPath || typeof requestedPath !== 'string') {
throw new ValidationError('Path is required', 'path');
}
if (!Array.isArray(allowedRoots) || allowedRoots.length === 0) {
throw new ValidationError('No allowed roots configured', 'path');
}
// Check for null byte injection
if (requestedPath.includes('\0')) {
if (auditLogger) {
auditLogger.logSecurityEvent('path_traversal_blocked', {
requestedPath,
reason: 'null_byte_detected',
severity: 'high'
});
}
throw new ValidationError('Invalid path - null byte detected', 'path');
}
// Check for encoded traversal sequences
const decodedPath = decodeURIComponent(requestedPath);
const suspiciousPatterns = [
/\.\./, // ..
/%2e%2e/i, // URL encoded ..
/\.%2f/i, // .%2F (encoded ./)
/%2e\./i, // %2E.
/\.\\/, // .\ (Windows)
/%5c/i // URL encoded backslash
];
if (suspiciousPatterns.some(pattern => pattern.test(requestedPath)) ||
suspiciousPatterns.some(pattern => pattern.test(decodedPath))) {
if (auditLogger) {
auditLogger.logSecurityEvent('path_traversal_blocked', {
requestedPath,
decodedPath,
reason: 'traversal_sequence_detected',
severity: 'high'
});
}
throw new ValidationError('Path traversal detected', 'path');
}
// Normalize the path for the current platform
const normalized = path.normalize(requestedPath);
// Try to resolve the real path (follows symlinks)
let realPath;
try {
realPath = await fs.realpath(normalized);
} catch (error) {
if (error.code === 'ENOENT') {
// Path doesn't exist - that's okay, just use normalized path
// But we still need to check if parent exists and is within allowed roots
const parentDir = path.dirname(normalized);
try {
const parentReal = await fs.realpath(parentDir);
// Construct the real path using the resolved parent
realPath = path.join(parentReal, path.basename(normalized));
} catch (parentError) {
if (parentError.code === 'ENOENT') {
// Parent doesn't exist either - use normalized path
realPath = normalized;
} else if (parentError.code === 'EACCES') {
throw new ValidationError('Access denied to path', 'path');
} else {
throw parentError;
}
}
} else if (error.code === 'EACCES') {
throw new ValidationError('Access denied to path', 'path');
} else {
throw error;
}
}
// Normalize for cross-platform comparison (Windows is case-insensitive)
const isWindows = process.platform === 'win32';
const normalizePath = (p) => {
const normalized = path.normalize(p).replace(/\\/g, '/');
return isWindows ? normalized.toLowerCase() : normalized;
};
const normalizedReal = normalizePath(realPath);
// Check if the resolved path is within any allowed root
const isWithinAllowedRoot = allowedRoots.some(root => {
const normalizedRoot = normalizePath(root);
return normalizedReal.startsWith(normalizedRoot);
});
if (!isWithinAllowedRoot) {
if (auditLogger) {
auditLogger.logSecurityEvent('path_traversal_blocked', {
requestedPath,
realPath,
allowedRoots,
reason: 'outside_allowed_roots',
severity: 'critical'
});
}
throw new ValidationError('Access denied - path is outside allowed directories', 'path');
}
return realPath;
}
module.exports = {
ValidationError,
validateDNSRecord,
validateDockerDeployment,
validateVolumePath,
validateFilePath,
validateURL,
validateToken,
validateServiceConfig,
sanitizeString,
isValidPort,
isPrivateIP,
validateSecurePath
};
@@ -0,0 +1,212 @@
/**
* Keychain Manager for DashCaddy
* Provides secure credential storage using OS-native keychains
* Falls back to encrypted file storage if keychain is unavailable
*/
const { execSync, execFileSync } = require('child_process');
const os = require('os');
const crypto = require('crypto');
const SERVICE_NAME = 'DashCaddy';
const ACCOUNT_PREFIX = 'dashcaddy';
class KeychainManager {
constructor() {
this.platform = os.platform();
this.available = this.checkAvailability();
}
/**
* Check if OS keychain is available
* @returns {boolean}
*/
checkAvailability() {
try {
if (this.platform === 'win32') {
// Check if PowerShell is available
execSync('powershell -Command "Get-Command Get-Credential"', { stdio: 'ignore' });
return true;
} else if (this.platform === 'darwin') {
// Check if security command is available
execSync('which security', { stdio: 'ignore' });
return true;
} else if (this.platform === 'linux') {
// Check if secret-tool (libsecret) is available
try {
execSync('which secret-tool', { stdio: 'ignore' });
return true;
} catch {
// Try gnome-keyring
execSync('which gnome-keyring-daemon', { stdio: 'ignore' });
return true;
}
}
return false;
} catch {
console.warn('[Keychain] OS keychain not available, will use encrypted file storage');
return false;
}
}
/**
* Store a credential in the OS keychain
* @param {string} key - Credential identifier
* @param {string} value - Credential value
* @returns {Promise<boolean>} Success status
*/
async store(key, value) {
if (!this.available) {
return false;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.storeWindows(account, value);
} else if (this.platform === 'darwin') {
return await this.storeMacOS(account, value);
} else if (this.platform === 'linux') {
return await this.storeLinux(account, value);
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to store ${key}:`, error.message);
return false;
}
}
/**
* Retrieve a credential from the OS keychain
* @param {string} key - Credential identifier
* @returns {Promise<string|null>} Credential value or null
*/
async retrieve(key) {
if (!this.available) {
return null;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.retrieveWindows(account);
} else if (this.platform === 'darwin') {
return await this.retrieveMacOS(account);
} else if (this.platform === 'linux') {
return await this.retrieveLinux(account);
}
return null;
} catch (error) {
console.error(`[Keychain] Failed to retrieve ${key}:`, error.message);
return null;
}
}
/**
* Delete a credential from the OS keychain
* @param {string} key - Credential identifier
* @returns {Promise<boolean>} Success status
*/
async delete(key) {
if (!this.available) {
return false;
}
const account = `${ACCOUNT_PREFIX}.${key}`;
try {
if (this.platform === 'win32') {
return await this.deleteWindows(account);
} else if (this.platform === 'darwin') {
return await this.deleteMacOS(account);
} else if (this.platform === 'linux') {
return await this.deleteLinux(account);
}
return false;
} catch (error) {
console.error(`[Keychain] Failed to delete ${key}:`, error.message);
return false;
}
}
// Windows Credential Manager implementation (uses execFileSync to prevent injection)
async storeWindows(account, value) {
execFileSync('cmdkey', [`/generic:${SERVICE_NAME}:${account}`, `/user:${account}`, `/pass:${value}`], { stdio: 'ignore' });
return true;
}
async retrieveWindows(account) {
try {
const result = execFileSync('cmdkey', [`/list:${SERVICE_NAME}:${account}`], { encoding: 'utf8' });
const match = result.match(/Password:\s*(.+)/);
return match ? match[1].trim() : null;
} catch {
return null;
}
}
async deleteWindows(account) {
execFileSync('cmdkey', [`/delete:${SERVICE_NAME}:${account}`], { stdio: 'ignore' });
return true;
}
// macOS Keychain implementation (uses execFileSync to prevent injection)
async storeMacOS(account, value) {
try {
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
} catch {
// Ignore if doesn't exist
}
execFileSync('security', ['add-generic-password', '-s', SERVICE_NAME, '-a', account, '-w', value], { stdio: 'ignore' });
return true;
}
async retrieveMacOS(account) {
try {
const result = execFileSync('security', ['find-generic-password', '-s', SERVICE_NAME, '-a', account, '-w'], { encoding: 'utf8' });
return result.trim() || null;
} catch {
return null;
}
}
async deleteMacOS(account) {
execFileSync('security', ['delete-generic-password', '-s', SERVICE_NAME, '-a', account], { stdio: 'ignore' });
return true;
}
// Linux Secret Service implementation (uses execFileSync + stdin to prevent injection)
async storeLinux(account, value) {
try {
execFileSync('secret-tool', ['store', `--label=${SERVICE_NAME}:${account}`, 'service', SERVICE_NAME, 'account', account], {
input: value,
stdio: ['pipe', 'ignore', 'ignore']
});
return true;
} catch {
return false;
}
}
async retrieveLinux(account) {
try {
const result = execFileSync('secret-tool', ['lookup', 'service', SERVICE_NAME, 'account', account], { encoding: 'utf8' });
return result.trim() || null;
} catch {
return null;
}
}
async deleteLinux(account) {
try {
execFileSync('secret-tool', ['clear', 'service', SERVICE_NAME, 'account', account], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
}
module.exports = new KeychainManager();
+575
View File
@@ -0,0 +1,575 @@
/**
* Log Digest Module
* Collects container logs hourly, generates daily summaries.
* Gives users a single place to see what happened across all services
* and guidance on where to look for more detail.
*/
const Docker = require('dockerode');
const EventEmitter = require('events');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const { DOCKER } = require('../utilities/constants');
const docker = new Docker();
const ERROR_PATTERNS = [
/\berror\b/i, /\bfailed\b/i, /\bfatal\b/i, /\bpanic\b/i,
/\bcrash(ed)?\b/i, /\bexception\b/i, /\btimeout\b/i,
/\bOOM\b/, /\bout of memory\b/i, /\bkilled\b/i,
/\bdenied\b/i, /\bunauthorized\b/i, /\brefused\b/i
];
const WARNING_PATTERNS = [
/\bwarn(ing)?\b/i, /\bdeprecated\b/i, /\bretry(ing)?\b/i,
/\bslow\b/i, /\blatency\b/i
];
const EVENT_PATTERNS = [
{ pattern: /\b(start(ed|ing)?|boot(ed|ing)?|init(ializ(ed|ing))?)\b/i, type: 'startup' },
{ pattern: /\b(stop(ped|ping)?|shutdown|exit(ed|ing)?|terminat(ed|ing)?)\b/i, type: 'shutdown' },
{ pattern: /\b(restart(ed|ing)?|reload(ed|ing)?)\b/i, type: 'restart' },
{ pattern: /\bhealth.?check.*(fail|unhealthy)\b/i, type: 'health_failure' },
{ pattern: /\b(update|upgrade|migration)\b/i, type: 'update' }
];
class LogDigest extends EventEmitter {
constructor() {
super();
this.collectInterval = null;
this.digestTimeout = null;
this.running = false;
this.hourlySummaries = []; // Ring buffer of hourly snapshots
this.digestDir = null; // Set during start()
this.lastCollect = null;
this._lastCollectTimestamp = {}; // Per-container: last log timestamp fetched
}
/**
* Start the log digest system.
* @param {string} digestDir - Directory to write daily digest files
*/
start(digestDir) {
if (this.running) return;
this.running = true;
this.digestDir = digestDir;
// Ensure digest directory exists
if (!fs.existsSync(digestDir)) {
fs.mkdirSync(digestDir, { recursive: true });
}
// Collect logs every hour
this.collectInterval = setInterval(() => {
this._collectHourlyLogs().catch(e =>
console.error('[LogDigest] Hourly collection failed:', e.message)
);
}, DOCKER.DIGEST.COLLECT_INTERVAL);
// Schedule daily digest generation
this._scheduleDailyDigest();
// Run initial collection after 2 minutes
setTimeout(() => {
if (this.running) {
this._collectHourlyLogs().catch(() => {});
}
}, 2 * 60 * 1000);
}
stop() {
if (!this.running) return;
this.running = false;
if (this.collectInterval) {
clearInterval(this.collectInterval);
this.collectInterval = null;
}
if (this.digestTimeout) {
clearTimeout(this.digestTimeout);
this.digestTimeout = null;
}
}
/**
* Collect logs from all managed containers for the last hour.
*/
async _collectHourlyLogs() {
const now = new Date();
const sinceTimestamp = Math.floor((now.getTime() - DOCKER.DIGEST.COLLECT_INTERVAL) / 1000);
const hourKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}T${String(now.getHours()).padStart(2, '0')}:00`;
const hourSummary = {
hour: hourKey,
timestamp: now.toISOString(),
services: {}
};
try {
const containers = await docker.listContainers({ all: true });
const managed = containers.filter(c => c.Labels?.['sami.managed'] === 'true');
for (const containerInfo of managed) {
const name = containerInfo.Names[0]?.replace(/^\//, '') || containerInfo.Id.slice(0, 12);
const appId = containerInfo.Labels['sami.app'] || name;
const isRunning = containerInfo.State === 'running';
const serviceSummary = {
name,
appId,
state: containerInfo.State,
errors: [],
warnings: [],
events: [],
errorCount: 0,
warningCount: 0,
totalLines: 0
};
if (isRunning) {
try {
const container = docker.getContainer(containerInfo.Id);
const logBuffer = await container.logs({
stdout: true,
stderr: true,
since: sinceTimestamp,
tail: DOCKER.DIGEST.LOG_TAIL,
timestamps: true
});
const lines = this._parseDockerLogs(logBuffer);
serviceSummary.totalLines = lines.length;
for (const line of lines) {
// Check for errors
if (line.stream === 'stderr' || ERROR_PATTERNS.some(p => p.test(line.text))) {
serviceSummary.errorCount++;
if (serviceSummary.errors.length < 10) {
serviceSummary.errors.push({
time: line.timestamp || hourKey,
text: line.text.slice(0, 500)
});
}
continue;
}
// Check for warnings
if (WARNING_PATTERNS.some(p => p.test(line.text))) {
serviceSummary.warningCount++;
if (serviceSummary.warnings.length < 5) {
serviceSummary.warnings.push({
time: line.timestamp || hourKey,
text: line.text.slice(0, 300)
});
}
continue;
}
// Check for notable events
for (const { pattern, type } of EVENT_PATTERNS) {
if (pattern.test(line.text)) {
serviceSummary.events.push({
type,
time: line.timestamp || hourKey,
text: line.text.slice(0, 300)
});
break;
}
}
}
} catch (logErr) {
serviceSummary.errors.push({
time: now.toISOString(),
text: `Failed to fetch logs: ${logErr.message}`
});
serviceSummary.errorCount++;
}
} else {
serviceSummary.events.push({
type: 'not_running',
time: now.toISOString(),
text: `Container is ${containerInfo.State}`
});
}
hourSummary.services[appId] = serviceSummary;
}
} catch (e) {
console.error('[LogDigest] Container enumeration failed:', e.message);
}
// Add to ring buffer
this.hourlySummaries.push(hourSummary);
if (this.hourlySummaries.length > DOCKER.DIGEST.MAX_HOURLY_ENTRIES) {
this.hourlySummaries.shift();
}
this.lastCollect = now.toISOString();
this.emit('hourly-collected', hourSummary);
return hourSummary;
}
/**
* Parse Docker multiplexed log stream into lines.
*/
_parseDockerLogs(logData) {
const lines = [];
const buffer = Buffer.isBuffer(logData) ? logData : Buffer.from(logData);
let offset = 0;
while (offset < buffer.length) {
if (offset + 8 > buffer.length) break;
const streamType = buffer[0 + offset];
const size = buffer.readUInt32BE(4 + offset);
if (offset + 8 + size > buffer.length) break;
const text = buffer.slice(offset + 8, offset + 8 + size).toString('utf8').trim();
if (text) {
// Try to extract timestamp from Docker's format: "2026-03-13T12:00:00.000000000Z message"
let timestamp = null;
let message = text;
const tsMatch = text.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})\.\d+Z\s(.*)$/s);
if (tsMatch) {
timestamp = tsMatch[1];
message = tsMatch[2];
}
lines.push({
stream: streamType === 2 ? 'stderr' : 'stdout',
text: message,
timestamp
});
}
offset += 8 + size;
}
return lines;
}
/**
* Schedule the daily digest at the configured hour.
*/
_scheduleDailyDigest() {
const now = new Date();
const targetHour = DOCKER.DIGEST.DIGEST_HOUR;
const next = new Date(now);
next.setHours(targetHour, 5, 0, 0); // 5 minutes past the hour
if (next <= now) next.setDate(next.getDate() + 1);
const delay = next.getTime() - now.getTime();
this.digestTimeout = setTimeout(() => {
this.generateDailyDigest().catch(e =>
console.error('[LogDigest] Daily digest generation failed:', e.message)
);
// Reschedule for tomorrow
if (this.running) this._scheduleDailyDigest();
}, delay);
}
/**
* Generate the daily digest from accumulated hourly summaries.
* Can also be called on-demand.
*/
async generateDailyDigest(dateStr) {
const date = dateStr || new Date(Date.now() - 86400000).toISOString().slice(0, 10);
const relevantHours = this.hourlySummaries.filter(h => h.hour.startsWith(date));
// Aggregate per-service stats across all hours
const serviceAgg = {};
const notableEvents = [];
for (const hour of relevantHours) {
for (const [appId, svc] of Object.entries(hour.services)) {
if (!serviceAgg[appId]) {
serviceAgg[appId] = {
name: svc.name,
appId,
totalErrors: 0,
totalWarnings: 0,
totalLines: 0,
lastState: svc.state,
topErrors: [],
events: []
};
}
const agg = serviceAgg[appId];
agg.totalErrors += svc.errorCount;
agg.totalWarnings += svc.warningCount;
agg.totalLines += svc.totalLines;
agg.lastState = svc.state;
// Keep top errors (deduplicated-ish)
for (const err of svc.errors) {
if (agg.topErrors.length < 5) {
agg.topErrors.push(err);
}
}
// Collect notable events
for (const evt of svc.events) {
notableEvents.push({ ...evt, service: svc.name, appId });
}
}
}
// Get Docker disk usage
let diskUsage = null;
try {
const dockerMaintenance = require('../docker/docker-maintenance');
diskUsage = await dockerMaintenance.getDiskUsage();
} catch (e) {
// Module may not be loaded yet
}
// Build digest object
const digest = {
date,
generatedAt: new Date().toISOString(),
hoursCollected: relevantHours.length,
services: serviceAgg,
notableEvents: notableEvents.sort((a, b) => (a.time || '').localeCompare(b.time || '')),
diskUsage,
summary: {
totalServices: Object.keys(serviceAgg).length,
servicesWithErrors: Object.values(serviceAgg).filter(s => s.totalErrors > 0).length,
totalErrors: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalErrors, 0),
totalWarnings: Object.values(serviceAgg).reduce((sum, s) => sum + s.totalWarnings, 0)
}
};
// Write formatted digest file
const formatted = this._formatDigest(digest);
const filename = `digest-${date}.log`;
const filepath = path.join(this.digestDir, filename);
await fsp.writeFile(filepath, formatted, 'utf8');
// Also write JSON for API consumption
const jsonPath = path.join(this.digestDir, `digest-${date}.json`);
await fsp.writeFile(jsonPath, JSON.stringify(digest, null, 2), 'utf8');
// Cleanup old digests
await this._cleanupOldDigests();
this.emit('digest-generated', { date, filepath, digest });
return digest;
}
/**
* Format digest into human-readable text.
*/
_formatDigest(digest) {
const lines = [];
const hr = '='.repeat(55);
const sr = '-'.repeat(55);
lines.push(hr);
lines.push(' DashCaddy Daily Log Digest');
lines.push(` ${digest.date}`);
lines.push(` Generated: ${digest.generatedAt}`);
lines.push(hr);
lines.push('');
// Service summary table
lines.push('-- Service Summary ' + '-'.repeat(36));
const services = Object.values(digest.services);
if (services.length === 0) {
lines.push(' No managed services found.');
} else {
for (const svc of services) {
const stateIcon = svc.lastState === 'running' ? 'OK' : '!!';
const errStr = `${svc.totalErrors} error${svc.totalErrors !== 1 ? 's' : ''}`;
const warnStr = `${svc.totalWarnings} warning${svc.totalWarnings !== 1 ? 's' : ''}`;
const flag = svc.totalErrors > 0 ? ' <-- investigate' : '';
lines.push(` ${svc.name.padEnd(18)} ${stateIcon.padEnd(10)} ${errStr.padEnd(14)} ${warnStr}${flag}`);
}
}
lines.push('');
// Notable events
const events = digest.notableEvents;
if (events.length > 0) {
lines.push('-- Notable Events ' + '-'.repeat(37));
for (const evt of events) {
const time = (evt.time || '').slice(11, 16) || '??:??';
lines.push(` [${time}] ${evt.service}: ${evt.text.slice(0, 80)}`);
// Add guidance for where to look further
const containerName = `${DOCKER.CONTAINER_PREFIX}${evt.appId}`;
if (evt.type === 'health_failure' || evt.type === 'restart') {
const sinceDate = digest.date + 'T' + (evt.time || '').slice(11, 13) + ':00:00';
lines.push(` See: docker logs ${containerName} --since ${sinceDate}`);
}
}
lines.push('');
}
// Top errors per service
const errServices = services.filter(s => s.totalErrors > 0);
if (errServices.length > 0) {
lines.push('-- Error Details ' + '-'.repeat(38));
for (const svc of errServices) {
lines.push(` ${svc.name} (${svc.totalErrors} errors):`);
for (const err of svc.topErrors) {
const time = (err.time || '').slice(11, 16) || '??:??';
lines.push(` [${time}] ${err.text.slice(0, 100)}`);
}
const containerName = `${DOCKER.CONTAINER_PREFIX}${svc.appId}`;
lines.push(` Full logs: docker logs ${containerName} --since ${digest.date}T00:00:00`);
lines.push('');
}
}
// Docker disk usage
if (digest.diskUsage) {
lines.push('-- Docker Disk Usage ' + '-'.repeat(34));
const du = digest.diskUsage;
lines.push(` Images: ${formatBytes(du.images.sizeBytes)} (${du.images.count} images)`);
lines.push(` Containers: ${formatBytes(du.containers.sizeBytes)}`);
lines.push(` Volumes: ${formatBytes(du.volumes.sizeBytes)} (${du.volumes.count} volumes)`);
lines.push(` Build Cache: ${formatBytes(du.buildCache.sizeBytes)}`);
lines.push(` Total: ${du.totalGB} GB`);
if (du.totalGB > DOCKER.MAINTENANCE.DISK_WARN_GB) {
lines.push(` WARNING: Exceeds ${DOCKER.MAINTENANCE.DISK_WARN_GB}GB threshold!`);
lines.push(' Run: docker system prune -a (removes unused images/cache)');
}
lines.push('');
}
// Summary
lines.push(sr);
lines.push(` ${digest.summary.totalServices} service(s) monitored | ${digest.summary.totalErrors} error(s) | ${digest.summary.totalWarnings} warning(s)`);
lines.push(` Hours collected: ${digest.hoursCollected}/24`);
lines.push(hr);
return lines.join('\n') + '\n';
}
/**
* Remove digest files older than MAX_DIGEST_FILES days.
*/
async _cleanupOldDigests() {
if (!this.digestDir) return;
try {
const files = await fsp.readdir(this.digestDir);
const digestFiles = files.filter(f => f.startsWith('digest-')).sort();
// Each date has .log + .json = 2 files per day
const maxFiles = DOCKER.DIGEST.MAX_DIGEST_FILES * 2;
if (digestFiles.length > maxFiles) {
const toDelete = digestFiles.slice(0, digestFiles.length - maxFiles);
for (const f of toDelete) {
await fsp.unlink(path.join(this.digestDir, f)).catch(() => {});
}
}
} catch (e) {
// Directory may not exist yet
}
}
/**
* Get the latest daily digest (JSON).
*/
async getLatestDigest() {
if (!this.digestDir) return null;
try {
const files = await fsp.readdir(this.digestDir);
const jsonFiles = files.filter(f => f.endsWith('.json')).sort();
if (jsonFiles.length === 0) return null;
const latest = path.join(this.digestDir, jsonFiles[jsonFiles.length - 1]);
return JSON.parse(await fsp.readFile(latest, 'utf8'));
} catch (e) {
return null;
}
}
/**
* Get digest for a specific date.
*/
async getDigestByDate(dateStr) {
if (!this.digestDir) return null;
const jsonPath = path.join(this.digestDir, `digest-${dateStr}.json`);
try {
return JSON.parse(await fsp.readFile(jsonPath, 'utf8'));
} catch (e) {
return null;
}
}
/**
* Get the formatted text version of a digest.
*/
async getDigestText(dateStr) {
if (!this.digestDir) return null;
const logPath = path.join(this.digestDir, `digest-${dateStr}.log`);
try {
return await fsp.readFile(logPath, 'utf8');
} catch (e) {
return null;
}
}
/**
* List available digest dates.
*/
async listDigests() {
if (!this.digestDir) return [];
try {
const files = await fsp.readdir(this.digestDir);
return files
.filter(f => f.endsWith('.json'))
.map(f => f.replace('digest-', '').replace('.json', ''))
.sort()
.reverse();
} catch (e) {
return [];
}
}
/**
* Get live data: current day's accumulated hourly summaries.
*/
getLiveData() {
const today = new Date().toISOString().slice(0, 10);
const todayHours = this.hourlySummaries.filter(h => h.hour.startsWith(today));
// Aggregate
const serviceAgg = {};
for (const hour of todayHours) {
for (const [appId, svc] of Object.entries(hour.services)) {
if (!serviceAgg[appId]) {
serviceAgg[appId] = { name: svc.name, appId, totalErrors: 0, totalWarnings: 0, lastState: svc.state, recentErrors: [] };
}
serviceAgg[appId].totalErrors += svc.errorCount;
serviceAgg[appId].totalWarnings += svc.warningCount;
serviceAgg[appId].lastState = svc.state;
for (const err of svc.errors) {
if (serviceAgg[appId].recentErrors.length < 10) {
serviceAgg[appId].recentErrors.push(err);
}
}
}
}
return {
date: today,
hoursCollected: todayHours.length,
lastCollect: this.lastCollect,
services: serviceAgg
};
}
getStatus() {
return {
running: this.running,
lastCollect: this.lastCollect,
hourlySummaries: this.hourlySummaries.length,
digestDir: this.digestDir
};
}
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
}
module.exports = new LogDigest();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,61 @@
// Cache Configuration Module
// Provides LRU cache configurations to prevent memory leaks
const { LRUCache } = require('lru-cache');
/**
* Cache configuration presets for different data types
* All TTL values are in milliseconds
*/
const CACHE_CONFIGS = {
// App session cookies (login tokens for SSO)
appSessions: {
max: 500, // Max 500 different services
ttl: 60 * 60 * 1000, // 1 hour TTL
updateAgeOnGet: true, // Refresh TTL on access
ttlAutopurge: true // Auto-cleanup expired entries
},
// IP-based router sessions (Frontier NVG468MQ)
ipSessions: {
max: 1000, // Support up to 1000 IP addresses
ttl: 24 * 60 * 60 * 1000, // 24 hour TTL
updateAgeOnGet: true,
ttlAutopurge: true
},
// DNS server authentication tokens (Technitium)
dnsTokens: {
max: 50, // Max 50 DNS servers
ttl: 6 * 60 * 60 * 1000, // 6 hour TTL (matches SESSION_TTL.DNS_TOKEN)
updateAgeOnGet: false, // Don't refresh - tokens have fixed expiry
ttlAutopurge: true
},
// Tailscale network status
tailscaleStatus: {
max: 1, // Only one status object
ttl: 60 * 1000, // 1 minute TTL
updateAgeOnGet: false,
ttlAutopurge: true
},
// Tailscale API responses (devices, ACLs)
tailscaleAPI: {
max: 5, // devices + ACL + misc
ttl: 5 * 60 * 1000, // 5 min (matches sync interval)
updateAgeOnGet: false,
ttlAutopurge: true
}
};
/**
* Factory function to create a configured LRU cache
* @param {Object} config - Cache configuration from CACHE_CONFIGS
* @returns {LRUCache} Configured LRU cache instance
*/
function createCache(config) {
return new LRUCache(config);
}
module.exports = { CACHE_CONFIGS, createCache };
@@ -0,0 +1,141 @@
/**
* Config Schema Validation for DashCaddy
* 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'
];
/**
* Validate a config object and return errors/warnings.
* @param {object} config - The config object to validate
* @returns {{ valid: boolean, errors: string[], warnings: string[] }}
*/
function validateConfig(config) {
const errors = [];
const 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?`);
}
}
return { valid: errors.length === 0, errors, warnings };
}
module.exports = { validateConfig };
+186
View File
@@ -0,0 +1,186 @@
// DashCaddy Shared Constants
// Centralizes configuration values used across the application.
// Edit values here instead of hunting through server.js.
// ── App Identity ──────────────────────────────────────────────
const APP = {
NAME: 'DashCaddy',
VERSION: '1.1',
PORT: 3001,
USER_AGENTS: {
PROBE: 'DashCaddy-Probe/1.0',
API: 'DashCaddy/1.0',
HEALTH: 'DashCaddy-HealthCheck/1.0',
},
DEVICE_IDS: {
SSO: 'dashcaddy-sso', // Backend auth gate (never invalidates browser token)
BROWSER: 'dashcaddy-browser', // Browser-side localStorage token
},
};
// ── Default Ports for Media/Arr Apps ──────────────────────────
const APP_PORTS = {
plex: 32400,
radarr: 7878,
sonarr: 8989,
seerr: 5055,
lidarr: 8686,
prowlarr: 9696,
};
// Arr service discovery config (used by /api/arr/* endpoints)
const ARR_SERVICES = {
plex: { names: ['plex'], port: APP_PORTS.plex, configPath: 'Plex' },
radarr: { names: ['radarr'], port: APP_PORTS.radarr, configPath: 'radarr' },
sonarr: { names: ['sonarr'], port: APP_PORTS.sonarr, configPath: 'sonarr' },
seerr: { names: ['seerr', 'requests'], port: APP_PORTS.seerr, configPath: 'seerr' },
lidarr: { names: ['lidarr'], port: APP_PORTS.lidarr, configPath: 'lidarr' },
prowlarr: { names: ['prowlarr'], port: APP_PORTS.prowlarr, configPath: 'prowlarr' },
};
// ── Timeouts (ms) ─────────────────────────────────────────────
const TIMEOUTS = {
HTTP_DEFAULT: 5000, // Standard fetch/http timeout
HTTP_LONG: 10000, // DNS ops, downloads, login requests
DEPLOY_SETTLE: 3000, // Wait after container start before health check
CADDY_PRE_RELOAD: 2000, // Pause before Caddy reload
RETRY_SHORT: 1000, // Short retry delay
RETRY_MEDIUM: 2000, // Medium retry delay
SHUTDOWN_GRACE: 5000, // Graceful shutdown window
SHUTDOWN_ERROR: 1000, // Error shutdown window
PORT_CHECK: 2000, // TCP port availability check
};
// ── Retry Configuration ───────────────────────────────────────
const RETRIES = {
CADDY_RELOAD: 3, // Max attempts to reload Caddy
};
// ── Session / Cache Expiry (ms) ───────────────────────────────
const SESSION_TTL = {
IP_SESSION: 30 * 60 * 1000, // 30 min — router IP-based sessions
COOKIE_SESSION: 30 * 60 * 1000, // 30 min — cookie-based login sessions
TOKEN_SESSION: 60 * 60 * 1000, // 60 min — JWT/access token sessions (Jellyfin, Plex, etc.)
FAILED_LOGIN: 5 * 60 * 1000, // 5 min — cooldown after failed login
DNS_TOKEN: 6 * 60 * 60 * 1000, // 6 hrs — DNS auto-refresh interval
};
// ── Rate Limiting ─────────────────────────────────────────────
const RATE_LIMITS = {
GENERAL: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 1000,
},
STRICT: {
windowMs: 15 * 60 * 1000,
max: 20,
},
TOTP: {
windowMs: 15 * 60 * 1000,
max: 10,
},
};
// ── Caddy ─────────────────────────────────────────────────────
const CADDY = {
CONTENT_TYPE: 'text/caddyfile',
DEFAULT_DNS_PORT: '5380',
DEFAULT_TTL: 300,
TTL_MIN: 60,
TTL_MAX: 86400,
};
// ── Validation Patterns ─────────────────────────────────────────
const REGEX = {
SUBDOMAIN: /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/i,
DOMAIN: /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/i,
};
// ── DNS ─────────────────────────────────────────────────────────
const DNS_RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT', 'NS', 'SRV', 'PTR', 'SOA'];
// ── Docker ──────────────────────────────────────────────────────
const DOCKER = {
CONTAINER_PREFIX: 'sami-',
TIMEOUT: 300000, // 300s — timeout for docker pull/create operations
LOG_CONFIG: {
Type: 'json-file',
Config: { 'max-size': '10m', 'max-file': '3' } // 30MB max per container
},
MAINTENANCE: {
INTERVAL: 24 * 60 * 60 * 1000, // 24 hours
DISK_WARN_GB: 20, // Warn when Docker uses more than 20GB
},
DIGEST: {
COLLECT_INTERVAL: 60 * 60 * 1000, // Hourly log collection
DIGEST_HOUR: 0, // Generate daily digest at midnight
MAX_HOURLY_ENTRIES: 24, // Keep 24 hours of hourly summaries
MAX_DIGEST_FILES: 30, // Keep 30 days of daily digests
LOG_TAIL: 500, // Lines to fetch per container per hour
},
};
// ── Emby/Jellyfin Auth Header Builder ─────────────────────────
function buildMediaAuth(deviceId) {
return `MediaBrowser Client="${APP.NAME}", Device="${APP.NAME}", DeviceId="${deviceId}", Version="${APP.VERSION}"`;
}
// ── Plex Auth Headers ─────────────────────────────────────────
const PLEX = {
AUTH_URL: 'https://plex.tv/users/sign_in.json',
};
// ── Tailscale API ────────────────────────────────────────────
const TAILSCALE = {
API_BASE: 'https://api.tailscale.com/api/v2',
OAUTH_TOKEN_URL: 'https://api.tailscale.com/api/v2/oauth/token',
};
// ── Error Log ─────────────────────────────────────────────────
const LIMITS = {
ERROR_LOG_SIZE: 5 * 1024 * 1024, // 5 MB
BODY_DEFAULT: '1mb',
BODY_UPLOAD: '10mb',
};
// HTTP Status Codes
const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};
// Network Constants
const NETWORK = {
LOCALHOST: '127.0.0.1',
PRIVATE_RANGES: ['192.168.', '10.', /^172\.(1[6-9]|2[0-9]|3[0-1])\./],
BUFFER_SIZE: 1024,
};
module.exports = {
APP,
TAILSCALE,
APP_PORTS,
ARR_SERVICES,
TIMEOUTS,
RETRIES,
SESSION_TTL,
RATE_LIMITS,
CADDY,
PLEX,
LIMITS,
REGEX,
DNS_RECORD_TYPES,
DOCKER,
buildMediaAuth,
HTTP_STATUS,
NETWORK,
};
@@ -0,0 +1,87 @@
/**
* DashCaddy Error Handler Middleware
* Centralizes error handling logic to eliminate duplicate catch blocks
*
* Logging: this middleware uses the unified logError from src/utils/logging.js
* (same one src/app.js uses), so all errors go to one log file. The legacy
* ./error-logger.js and its ./error.log file have been retired.
*/
const path = require('path');
const { AppError } = require('./errors');
const { LIMITS } = require('./constants');
const { logError: unifiedLogError, safeErrorMessage } = require('../utils/logging');
const { errorResponse } = require('../utils/responses');
const ERROR_LOG_FILE = path.join(__dirname, 'error.log');
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
/**
* Global error handling middleware
* MUST be registered after all routes in server.js
*/
function errorMiddleware(err, req, res, next) {
// Log all errors with request context (unified, same file the rest of the app uses)
unifiedLogError(
ERROR_LOG_FILE,
MAX_ERROR_LOG_SIZE,
req.path,
err,
{
method: req.method,
ip: req.ip,
userId: req.user?.id,
body: req.body
}
).catch(e => console.error('Failed to write to error log:', e.message));
// Determine if this is an operational error (AppError) or programming error
const isOperational = err.isOperational || err instanceof AppError;
// Status code
const statusCode = err.statusCode || 500;
// Error code (DC-XXX format)
const code = err.code || `DC-${statusCode}`;
// Build extras for response
const extras = { code };
// Add optional fields if present
if (err.requiresTotp) extras.requiresTotp = true;
if (err.retryAfter) extras.retryAfter = err.retryAfter;
if (err.field) extras.field = err.field;
if (err.resource) extras.resource = err.resource;
if (err.details && Object.keys(err.details).length > 0) extras.details = err.details;
// Development mode: include stack trace
if (process.env.NODE_ENV === 'development') {
extras.stack = err.stack;
}
// Send response
errorResponse(res, statusCode, isOperational ? safeErrorMessage(err) : 'Internal server error', extras);
// 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
});
}
}
/**
* 404 handler for routes not found
* Register this before the global error handler
*/
function notFoundHandler(req, res, next) {
const { NotFoundError } = require('./errors');
next(new NotFoundError(`Route ${req.method} ${req.path}`));
}
module.exports = {
errorMiddleware,
notFoundHandler
};
+672
View File
@@ -0,0 +1,672 @@
[2026-06-13T19:17:44.371Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-13T19:17:50.481Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-13T19:17:53.507Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-13T19:18:02.572Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-13T19:18:02.581Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-17T12:28:55.678Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-17T12:29:01.912Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-17T12:29:04.955Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-17T12:29:14.042Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-17T12:29:14.049Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-19T12:32:55.465Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-19T12:33:01.774Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-19T12:33:04.933Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-19T12:33:14.042Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-19T12:33:14.047Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:10.270Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:16.503Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:19.514Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:28.661Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:28.668Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:49.276Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:55.437Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:56:58.495Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:57:07.762Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T22:57:07.770Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:07.948Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:14.165Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:17.174Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:26.237Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:26.243Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:52.183Z] /api/containers/missing123/start: Container missing123 not found
NotFoundError: Container missing123 not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:02:58.556Z] /api/containers/abc123/update: port already allocated
Error: port already allocated
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:274:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:03:01.603Z] /api/containers/abc123/update: start failed
Error: start failed
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:290:44)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:03:10.686Z] /api/containers/missing/start: Container missing not found
NotFoundError: Container missing not found
at getVerifiedContainer (/root/dashcaddy/dashcaddy-api/routes/containers.js:26:15)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at /root/dashcaddy/dashcaddy-api/routes/containers.js:35:23
at /root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:85:13
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
[2026-06-25T23:03:10.698Z] /api/containers/abc123/start: docker daemon not running
Error: docker daemon not running
at Object.<anonymous> (/root/dashcaddy/dashcaddy-api/__tests__/routes/containers.routes.test.js:429:43)
at Promise.then.completed (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:298:28)
at new Promise (<anonymous>)
at callAsyncCircusFn (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/utils.js:231:10)
at _callCircusTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:316:40)
at processTicksAndRejections (node:internal/process/task_queues:103:5)
at _runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:252:3)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:126:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at _runTestsForDescribeBlock (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:121:9)
at run (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/run.js:71:3)
at runAndTransformResultsToJestFormat (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapterInit.js:122:21)
at jestAdapter (/root/dashcaddy/dashcaddy-api/node_modules/jest-circus/build/legacy-code-todo-rewrite/jestAdapter.js:79:19)
at runTestInternal (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:367:16)
at runTest (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/runTest.js:444:34)
at Object.worker (/root/dashcaddy/dashcaddy-api/node_modules/jest-runner/build/testWorker.js:106:12)
Additional Info: {
"method": "POST",
"ip": "::ffff:127.0.0.1",
"body": {}
}
================================================================================
+105
View File
@@ -0,0 +1,105 @@
/**
* DashCaddy API Error Classes
* All errors inherit from AppError and provide consistent structure.
*/
class AppError extends Error {
constructor(message, statusCode = 500, code = null) {
super(message);
this.name = this.constructor.name;
this.statusCode = statusCode;
this.code = code || this.constructor.name.toUpperCase().replace(/ERROR$/, '_ERROR');
this.isOperational = true; // Distinguishes from programming errors
}
}
// 4xx Client Errors
class ValidationError extends AppError {
constructor(message, field = null) {
super(message, 400, 'DC-400');
this.field = field;
}
}
class AuthenticationError extends AppError {
constructor(message = 'Authentication required', requiresTotp = false) {
super(message, 401, 'DC-401');
this.requiresTotp = requiresTotp;
}
}
class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'DC-403');
}
}
class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'DC-404');
this.resource = resource;
}
}
class ConflictError extends AppError {
constructor(message, conflictingResource = null) {
super(message, 409, 'DC-409');
this.conflictingResource = conflictingResource;
}
}
class RateLimitError extends AppError {
constructor(retryAfter = 60) {
super('Rate limit exceeded', 429, 'DC-429');
this.retryAfter = retryAfter;
}
}
// 5xx Server Errors
class DockerError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 500, 'DC-500-DOCKER');
this.operation = operation;
this.details = details;
}
}
class CaddyError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 502, 'DC-502-CADDY');
this.operation = operation;
this.details = details;
}
}
class DNSError extends AppError {
constructor(message, operation = null, details = {}) {
super(message, 502, 'DC-502-DNS');
this.operation = operation;
this.details = details;
}
}
class ServiceUnavailableError extends AppError {
constructor(service, retryAfter = null) {
super(`Service unavailable: ${service}`, 503, 'DC-503');
this.service = service;
this.retryAfter = retryAfter;
}
}
module.exports = {
AppError,
ValidationError,
AuthenticationError,
ForbiddenError,
NotFoundError,
ConflictError,
RateLimitError,
DockerError,
CaddyError,
DNSError,
ServiceUnavailableError
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Async File System Helpers for DashCaddy
* Replaces common sync patterns with async equivalents.
*/
const fsp = require('fs').promises;
const fs = require('fs');
/**
* Async file existence check (replaces fs.existsSync)
*/
async function exists(filePath) {
try {
await fsp.access(filePath);
return true;
} catch {
return false;
}
}
/**
* Read and parse a JSON file with fallback (replaces existsSync + readFileSync + JSON.parse)
*/
async function readJsonFile(filePath, fallback = null) {
try {
const content = await fsp.readFile(filePath, 'utf8');
return JSON.parse(content);
} catch (e) {
if (e.code === 'ENOENT') return fallback;
throw e;
}
}
/**
* Write data as formatted JSON (replaces writeFileSync + JSON.stringify)
*/
async function writeJsonFile(filePath, data) {
await fsp.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8');
}
/**
* Read a text file with fallback (replaces existsSync + readFileSync)
*/
async function readTextFile(filePath, fallback = '') {
try {
return await fsp.readFile(filePath, 'utf8');
} catch (e) {
if (e.code === 'ENOENT') return fallback;
throw e;
}
}
/**
* Check if path is accessible with given mode (replaces accessSync)
*/
async function isAccessible(filePath, mode = fs.constants.R_OK) {
try {
await fsp.access(filePath, mode);
return true;
} catch {
return false;
}
}
module.exports = { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible };
+476
View File
@@ -0,0 +1,476 @@
/**
* Middleware Configuration Module
* Extracts the entire middleware stack from server.js (Phase 3 refactoring)
*
* Configures: CORS, Helmet, body parser, compression, CSRF, request IDs,
* metrics/access logging, Tailscale auth, TOTP sessions, JWT/API key auth,
* rate limiting, and audit logging.
*/
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const crypto = require('crypto');
const rateLimit = require('express-rate-limit');
const { createCSRFMiddleware, csrfValidationMiddleware, CSRF_HEADER_NAME } = require('../security/csrf-protection');
const { RATE_LIMITS, LIMITS, APP } = require('./constants');
const { errorResponse, unauthorized, forbidden, validationError } = require('../utils/responses');
const { CACHE_CONFIGS, createCache } = require('./cache-config');
/**
* Configure all middleware on the Express app.
*
* @param {import('express').Express} app
* @param {Object} deps - Dependencies from server.js
* @returns {Object} Items that routes and ctx need
*/
module.exports = function configureMiddleware(app, {
siteConfig, totpConfig, tailscaleConfig,
metrics, auditLogger, authManager, log, cryptoUtils,
isValidContainerId, isTailscaleIP, getTailscaleStatus
}) {
// ── Container ID param validation ──
app.param('id', (req, res, next, id) => {
if (req.path.includes('/containers/') && !isValidContainerId(id)) {
return validationError(res, 'Invalid container ID');
}
next();
});
// ── CORS (#9: origins derived from config) ──
const corsOrigins = [`https://${siteConfig.dashboardHost}`];
if (process.env.NODE_ENV !== 'production') corsOrigins.push('http://localhost:3001');
app.use(cors({
origin: corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
credentials: true
}));
// ── Security headers with Helmet ──
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'"],
fontSrc: ["'self'", "data:"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"]
}
},
crossOriginEmbedderPolicy: false,
crossOriginResourcePolicy: { policy: "cross-origin" }
}));
// ── Trust proxy (one hop — Caddy) ──
app.set('trust proxy', 1);
// ── JSON body parser (default 1MB limit) ──
app.use(express.json({ limit: LIMITS.BODY_DEFAULT }));
// ── Compress responses (gzip/brotli) ──
app.use(compression());
// ── CSRF protection (cookie domain set to TLD for cross-subdomain SSO) ──
const { csrfCookieMiddleware, renewCSRFToken } = createCSRFMiddleware({
cookieDomain: siteConfig.tld || undefined
});
app.use(csrfCookieMiddleware);
app.use(csrfValidationMiddleware);
// ── Request ID ──
app.use((req, res, next) => {
req.id = crypto.randomUUID();
res.setHeader('X-Request-ID', req.id);
next();
});
// ── Metrics + access log ──
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
metrics.recordRequest(req.method, req.path, res.statusCode, duration);
if (req.path !== '/health' && req.path !== '/api/v1/health') {
const level = res.statusCode >= 500 ? 'error' : res.statusCode >= 400 ? 'warn' : 'debug';
log[level]('http', `${req.method} ${req.path} ${res.statusCode}`, {
ms: duration, ip: req.ip, id: req.id
});
}
});
next();
});
// ── Tailscale authentication middleware (optional) ──
const tailscaleAuthMiddleware = async (req, res, next) => {
if (!tailscaleConfig.enabled || !tailscaleConfig.requireAuth) {
return next();
}
if (req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/')) {
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()));
if (!fromTailscale) {
return errorResponse(res, 403, '[DC-120] Access denied. This dashboard requires Tailscale connection.', {
requiresTailscale: true,
clientIP: clientIP
});
}
if (tailscaleConfig.allowedTailnet) {
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
});
}
}
}
} catch (e) {
log.warn('tailscale', 'Tailnet verification failed, allowing request', { error: e.message });
}
}
next();
};
app.use(tailscaleAuthMiddleware);
// ── TOTP AUTHENTICATION ──
const SESSION_COOKIE_NAME = 'dashcaddy_session';
const SESSION_DURATIONS = {
'15m': 15 * 60 * 1000,
'30m': 30 * 60 * 1000,
'1h': 60 * 60 * 1000,
'2h': 2 * 60 * 60 * 1000,
'4h': 4 * 60 * 60 * 1000,
'8h': 8 * 60 * 60 * 1000,
'12h': 12 * 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'never': null
};
// IP-based session store (solves cross-domain cookie issues with .sami TLD)
const ipSessions = createCache(CACHE_CONFIGS.ipSessions);
function getClientIP(req) {
return req.ip || req.socket?.remoteAddress || '';
}
function createIPSession(req, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) {
log.warn('auth', 'createIPSession: invalid duration, no session created', { durationKey });
return;
}
const ip = getClientIP(req);
ipSessions.set(ip, { exp: Date.now() + durationMs });
}
function verifyIPSession(req) {
const ip = getClientIP(req);
const session = ipSessions.get(ip);
if (!session) return false;
if (session.exp <= Date.now()) {
ipSessions.delete(ip);
return false;
}
return true;
}
function clearIPSession(req) {
ipSessions.delete(getClientIP(req));
}
function setSessionCookie(res, durationKey) {
const durationMs = SESSION_DURATIONS[durationKey];
if (!durationMs) return;
const maxAge = Math.floor(durationMs / 1000);
const payload = { v: true, exp: Date.now() + durationMs };
const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url');
const key = cryptoUtils.loadOrCreateKey();
const sig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=${payloadB64}.${sig}${domainAttr}; Max-Age=${maxAge}; Path=/; HttpOnly; Secure; SameSite=Lax`
);
}
function parseCookies(cookieHeader) {
const cookies = {};
if (!cookieHeader) return cookies;
cookieHeader.split(';').forEach(pair => {
const [name, ...rest] = pair.trim().split('=');
if (name) cookies[name.trim()] = rest.join('=').trim();
});
return cookies;
}
function verifySessionCookie(cookieValue) {
if (!cookieValue) return false;
const parts = cookieValue.split('.');
if (parts.length !== 2) return false;
const [payloadB64, sig] = parts;
const key = cryptoUtils.loadOrCreateKey();
const expectedSig = crypto.createHmac('sha256', key).update(payloadB64).digest('base64url');
try {
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expectedSig))) return false;
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
return payload.v === true && payload.exp > Date.now();
} catch {
return false;
}
}
function clearSessionCookie(res) {
const domainAttr = siteConfig.tld ? `; Domain=${siteConfig.tld}` : '';
res.setHeader('Set-Cookie',
`${SESSION_COOKIE_NAME}=; Max-Age=0${domainAttr}; Path=/; HttpOnly; SameSite=Lax`
);
}
function isSessionValid(req) {
if (verifyIPSession(req)) return true;
const cookies = parseCookies(req.headers.cookie);
if (verifySessionCookie(cookies[SESSION_COOKIE_NAME])) {
const ip = getClientIP(req);
if (totpConfig.sessionDuration && SESSION_DURATIONS[totpConfig.sessionDuration]) {
ipSessions.set(ip, { exp: Date.now() + SESSION_DURATIONS[totpConfig.sessionDuration] });
}
return true;
}
return false;
}
// ── Public routes (bypass TOTP and JWT auth) ──
// Routes here are accessible without authentication. By default the
// monitoring/health-check endpoints are public so the dashboard can
// render widgets before the user logs in. Set MONITORING_PUBLIC=false
// (env var) or `monitoring: { public: false }` (config.json) to require
// auth for these — useful for internet-exposed deployments where
// CPU/memory/disk data is sensitive.
const MONITORING_PUBLIC = (() => {
if (process.env.MONITORING_PUBLIC === 'false') return false;
if (process.env.MONITORING_PUBLIC === 'true') return true;
// Default: check config.json if loaded
try {
const cfg = require('../config/site').siteConfig;
if (cfg && cfg.monitoring && typeof cfg.monitoring.public === 'boolean') {
return cfg.monitoring.public;
}
} catch { /* config not loaded yet, use default */ }
return true; // default: public (current behavior, dashboard needs it)
})();
const PUBLIC_ROUTES = [
{ path: '/health', exact: true },
{ path: '/health/live', exact: true },
{ path: '/health/ready', exact: true },
{ path: '/api/v1/health', exact: true },
{ path: '/api/v1/health/live', exact: true },
{ path: '/api/v1/health/ready', exact: true },
{ path: '/probe/', prefix: true },
{ path: '/api/v1/tailscale/', prefix: true },
{ path: '/api/v1/totp/config', exact: true, method: 'GET' },
{ path: '/api/v1/totp/recovery-info', exact: true, method: 'GET' },
{ path: '/api/v1/totp/verify', exact: true },
{ path: '/api/v1/totp/setup', exact: true, method: 'POST' },
{ path: '/api/v1/totp/verify-setup', exact: true, method: 'POST' },
{ path: '/api/v1/totp/check-session', exact: true },
{ path: '/api/v1/auth/gate/', prefix: true },
{ path: '/api/v1/auth/app-token/', prefix: true },
{ path: '/api/v1/services', exact: true, method: 'GET' },
{ path: '/api/v1/ca/info', exact: true, method: 'GET' },
{ path: '/api/v1/ca/root.crt', exact: true, method: 'GET' },
{ path: '/api/v1/ca/install-script', exact: true, method: 'GET' },
{ path: '/api/v1/health/ca', exact: true, method: 'GET' },
{ path: '/api/v1/ca/cert/', prefix: true, method: 'GET' },
{ path: '/api/v1/ca/certs', exact: true, method: 'GET' },
{ path: '/api/v1/csrf-token', exact: true, method: 'GET' },
{ path: '/api/v1/logo', exact: true, method: 'GET' },
{ path: '/api/v1/favicon', exact: true, method: 'GET' },
{ path: '/api/v1/themes', exact: true, method: 'GET' },
{ path: '/api/v1/license/status', exact: true, method: 'GET' },
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
{ 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' },
// 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' },
// Read-only update/version info shown on the dashboard view (verification
// modal, topbar version, update badges). Mutating actions — update-apply,
// rollback (POST) — are NOT listed here and stay TOTP-protected.
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
// Monitoring endpoints — only public if MONITORING_PUBLIC is true
...(MONITORING_PUBLIC ? [
{ path: '/api/v1/monitoring/stats', exact: true, method: 'GET' },
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
] : []),
{ path: '/api/v1/version', exact: true, method: 'GET' },
];
function isPublicRoute(req) {
return PUBLIC_ROUTES.some(r => {
if (r.method && req.method !== r.method) return false;
return r.prefix ? req.path.startsWith(r.path) : req.path === r.path;
});
}
// ── TOTP auth middleware ──
const totpAuthMiddleware = (req, res, next) => {
// If TOTP is not enabled at all, skip auth entirely — this is the initial-setup state
if (!totpConfig.enabled) {
req.auth = {
type: 'none',
scope: ['admin']
};
return next();
}
// TOTP is enabled — require a valid session, JWT, or API key
if (isPublicRoute(req)) return next();
if (isSessionValid(req)) return next();
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
};
app.use(totpAuthMiddleware);
// ── JWT/API Key authentication middleware ──
const jwtApiKeyAuthMiddleware = async (req, res, next) => {
if (req.totpSessionValid || isSessionValid(req)) {
req.auth = {
type: 'session',
scope: ['admin']
};
return next();
}
if (isPublicRoute(req)) return next();
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.substring(7);
const jwtPayload = await authManager.verifyJWT(token);
if (jwtPayload) {
req.auth = {
type: 'jwt',
userId: jwtPayload.userId,
scope: jwtPayload.scope || []
};
return next();
}
}
const apiKey = req.headers['x-api-key'];
if (apiKey) {
const keyData = await authManager.verifyAPIKey(apiKey);
if (keyData) {
req.auth = {
type: 'apikey',
keyId: keyData.keyId,
name: keyData.name,
scope: keyData.scopes || []
};
return next();
}
}
// No valid auth — reject
return errorResponse(res, 401, '[DC-110] Authentication required - provide TOTP session, JWT token, or API key', {
requiresTotp: totpConfig.enabled
});
};
app.use(jwtApiKeyAuthMiddleware);
// ── Rate limiting (skipped in test environment) ──
const isTest = process.env.NODE_ENV === 'test';
const generalLimiter = rateLimit({
...RATE_LIMITS.GENERAL,
standardHeaders: true,
legacyHeaders: false,
skip: (req) => isTest || req.path === '/health' || req.path === '/api/v1/health' || req.path.startsWith('/probe/') || req.path.startsWith('/api/v1/auth/gate/') || req.path === '/api/v1/totp/check-session' || req.path.endsWith('/health-checks/status') || req.path.endsWith('/monitoring/stats') || req.path.endsWith('/csrf-token') || req.path === '/api/v1/dns/logs' || req.path === '/api/v1/license/status' || req.path.startsWith('/api/v1/license/feature/') || req.path === '/api/v1/services' || req.path === '/api/v1/config',
message: { success: false, error: 'Too many requests, please try again later' }
});
const strictLimiter = rateLimit({
...RATE_LIMITS.STRICT,
standardHeaders: true,
legacyHeaders: false,
skip: () => isTest,
message: { success: false, error: 'Too many requests to this endpoint, please try again later' }
});
app.use(generalLimiter);
app.use('/api/v1/dns/credentials', strictLimiter);
app.use('/api/v1/apps/deploy', strictLimiter);
app.use('/api/v1/backup/restore', strictLimiter);
app.use('/api/v1/site', strictLimiter);
app.use('/api/v1/credentials/rotate-key', strictLimiter);
const totpLimiter = rateLimit({
...RATE_LIMITS.TOTP,
standardHeaders: true,
legacyHeaders: false,
message: { success: false, error: 'Too many TOTP attempts, please try again later' }
});
app.use('/api/v1/totp/verify', totpLimiter);
app.use('/api/v1/totp/verify-setup', totpLimiter);
// ── Audit logging middleware (logs non-GET API requests) ──
app.use(auditLogger.middleware());
// ── Return items that routes and ctx need ──
return {
strictLimiter,
SESSION_DURATIONS,
getClientIP,
createIPSession,
setSessionCookie,
clearIPSession,
clearSessionCookie,
isSessionValid,
ipSessions,
renewCSRFToken
};
};
+53
View File
@@ -0,0 +1,53 @@
/**
* Pagination helper for list endpoints.
* Only paginates when ?page= or ?limit= query params are present (backward compat).
*
* Usage:
* const { paginate, parsePaginationParams } = require('./pagination');
* router.get('/items', asyncHandler(async (req, res) => {
* const items = await getAllItems();
* const params = parsePaginationParams(req.query);
* res.json({ success: true, ...paginate(items, params) });
* }));
*/
const DEFAULT_LIMIT = 50;
const MAX_LIMIT = 200;
/**
* Parse pagination params from query string.
* Returns null if no pagination requested (backward compat: return full list).
*/
function parsePaginationParams(query) {
if (!query.page && !query.limit) return null;
const page = Math.max(1, parseInt(query.page, 10) || 1);
const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(query.limit, 10) || DEFAULT_LIMIT));
return { page, limit };
}
/**
* Paginate an array of items.
* If params is null, returns { data: items } (no pagination metadata).
*/
function paginate(items, params) {
if (!params) return { data: items };
const { page, limit } = params;
const total = items.length;
const totalPages = Math.ceil(total / limit);
const start = (page - 1) * limit;
const data = items.slice(start, start + limit);
return {
data,
pagination: {
page,
limit,
total,
totalPages,
hasMore: page < totalPages,
},
};
}
module.exports = { paginate, parsePaginationParams, DEFAULT_LIMIT, MAX_LIMIT };
@@ -0,0 +1,224 @@
/**
* Startup Validation Module
* Extracts startup configuration validation and health checker sync from server.js
* (Phase 3 refactoring)
*/
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const http = require('http');
const https = require('https');
const { exists, isAccessible } = require('./fs-helpers');
const { resolveServiceUrl } = require('./url-resolver');
/**
* Validate startup configuration and environment.
* Fail fast with clear error messages if critical issues are found.
*
* @param {Object} deps
* @param {Function} deps.log - structured logger
* @param {string} deps.CADDYFILE_PATH
* @param {string} deps.SERVICES_FILE
* @param {string} deps.CONFIG_FILE
* @param {string} deps.CADDY_ADMIN_URL
* @param {number} deps.PORT
*/
async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }) {
const errors = [];
const warnings = [];
log.info('startup', 'Validating startup configuration...');
// 1. Check if Caddyfile exists and is writable
try {
if (await exists(CADDYFILE_PATH)) {
if (!(await isAccessible(CADDYFILE_PATH, fs.constants.R_OK | fs.constants.W_OK))) {
errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`);
} else {
log.info('startup', 'Caddyfile is accessible', { path: CADDYFILE_PATH });
}
} else {
warnings.push(`Caddyfile does not exist: ${CADDYFILE_PATH} (will be created if needed)`);
}
} catch (error) {
errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`);
}
// 2. Check if services file exists or can be created
const servicesDir = path.dirname(SERVICES_FILE);
try {
if (await exists(SERVICES_FILE)) {
// Validate it's valid JSON
try {
const content = await fsp.readFile(SERVICES_FILE, 'utf8');
JSON.parse(content);
log.info('startup', 'Services file is valid JSON', { path: SERVICES_FILE });
} catch (parseError) {
errors.push(`Services file exists but contains invalid JSON: ${SERVICES_FILE}`);
}
} else {
// Check if parent directory exists and is writable
if (await exists(servicesDir)) {
if (!(await isAccessible(servicesDir, fs.constants.W_OK))) {
errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`);
} else {
log.info('startup', 'Services file directory is writable', { path: servicesDir });
}
} else {
errors.push(`Services file directory does not exist: ${servicesDir}`);
}
}
} catch (error) {
errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`);
}
// 3. Check if port is available
const net = require('net');
const portCheckServer = net.createServer();
try {
portCheckServer.listen(PORT, '0.0.0.0');
portCheckServer.close();
log.info('startup', `Port ${PORT} is available`);
} catch (error) {
errors.push(`Port ${PORT} is already in use or cannot be bound`);
}
// 4. Check if config file is valid JSON (if exists)
try {
if (await exists(CONFIG_FILE)) {
const content = await fsp.readFile(CONFIG_FILE, 'utf8');
JSON.parse(content);
log.info('startup', 'Config file is valid JSON', { path: CONFIG_FILE });
} else {
log.warn('startup', 'Config file does not exist (will use defaults)', { path: CONFIG_FILE });
}
} catch (error) {
errors.push(`Config file exists but contains invalid JSON: ${CONFIG_FILE}`);
}
// 5. Check Caddy admin API reachability (warning only, not critical)
const checkCaddyAdmin = () => {
return new Promise((resolve) => {
const urlObj = new URL(CADDY_ADMIN_URL);
const client = urlObj.protocol === 'https:' ? https : http;
const req = client.request({
hostname: urlObj.hostname,
port: urlObj.port,
path: '/config/',
method: 'GET',
timeout: 2000
}, (res) => {
resolve(res.statusCode >= 200 && res.statusCode < 500);
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
};
// Run async Caddy check (don't block startup)
checkCaddyAdmin().then(isReachable => {
if (isReachable) {
log.info('startup', 'Caddy admin API is reachable', { url: CADDY_ADMIN_URL });
} else {
log.warn('startup', 'Caddy admin API is not reachable (may start later)', { url: CADDY_ADMIN_URL });
}
});
// Print warnings
if (warnings.length > 0) {
warnings.forEach(warning => log.warn('startup', warning));
}
// Fail fast if there are critical errors
if (errors.length > 0) {
errors.forEach(err => log.error('startup', err));
log.error('startup', 'Cannot start server — fix the above errors and try again');
process.exit(1);
}
log.info('startup', 'Startup configuration validation passed');
}
/**
* Full-sync health checker from services.json + top-card services.
* Adds missing, updates changed URLs, removes deleted services.
*
* @param {Object} deps
* @param {Function} deps.log - structured logger
* @param {string} deps.SERVICES_FILE
* @param {Object} deps.servicesStateManager - StateManager instance
* @param {Object} deps.healthChecker - health checker module
* @param {Function} deps.buildServiceUrl - canonical URL builder
* @param {Object} deps.siteConfig - site config (dnsServers, etc.)
* @param {Object} deps.APP - app constants (USER_AGENTS)
*/
async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }) {
try {
const topCardServices = [
{ id: 'internet', name: 'Internet' },
];
// Dynamically add all configured DNS servers from config
for (const [id, info] of Object.entries(siteConfig?.dnsServers || {})) {
topCardServices.push({ id, name: info.name || id.toUpperCase() });
}
let appServices = [];
if (await exists(SERVICES_FILE)) {
const data = await servicesStateManager.read();
appServices = Array.isArray(data) ? data : data.services || [];
}
const allServices = [...topCardServices, ...appServices];
const desiredIds = new Set();
let added = 0, updated = 0, removed = 0;
for (const svc of allServices) {
const id = svc.id || svc.name?.toLowerCase();
if (!id) continue;
desiredIds.add(id);
const url = resolveServiceUrl(id, svc, siteConfig, buildServiceUrl);
const existing = healthChecker.config.services?.[id];
if (!existing) {
healthChecker.configureService(id, {
name: svc.name || id,
url,
method: 'HEAD',
timeout: 5000,
expectedStatusCodes: [200, 201, 204, 301, 302, 303, 307, 308, 401, 403],
headers: { 'User-Agent': APP.USER_AGENTS.HEALTH },
});
added++;
} else if (existing.url !== url) {
healthChecker.configureService(id, { ...existing, url });
updated++;
}
}
// Remove services no longer in the desired set
const configuredIds = Object.keys(healthChecker.config.services || {});
for (const id of configuredIds) {
if (!desiredIds.has(id)) {
healthChecker.removeService(id);
removed++;
}
}
if (added > 0 || updated > 0 || removed > 0) {
log.info('health', 'Health checker synced', { added, updated, removed });
}
} catch (error) {
log.error('health', 'Error syncing health checker', { error: error.message });
}
}
module.exports = { validateStartupConfig, syncHealthCheckerServices };
@@ -0,0 +1,32 @@
/**
* Unified URL Resolver
* Single source of truth for resolving service URLs across all systems
* (probes, health checks, health checker auto-config, SSO).
*/
/**
* Resolve the canonical URL for a service.
*
* 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)
*
* @param {string} id - service identifier
* @param {Object|null} service - service object from services.json (may be null for top-card services)
* @param {Object|null} siteConfig - site config containing dnsServers etc.
* @param {Function} buildServiceUrl - fallback URL builder (subdomain or subdirectory mode)
* @returns {string} resolved URL
*/
function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) {
if (id === 'internet') return 'https://www.google.com';
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];
if (dnsServer) return `http://${dnsServer.ip}:${dnsServer.port || 5380}`;
return buildServiceUrl(id);
}
module.exports = { resolveServiceUrl };
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* Async handler wrapper - Eliminates try/catch boilerplate
*/
const { AppError } = require('../../errors');
const { AppError } = require('../utilities/errors');
/**
* Wrap async route handlers - catches errors and logs them
+11 -3
View File
@@ -3,7 +3,7 @@
*/
const http = require('http');
const https = require('https');
const { TIMEOUTS } = require('../../constants');
const { TIMEOUTS } = require('../utilities/constants');
// HTTPS agent that trusts internal CA certs (self-signed .sami TLD etc.)
// Lazy-initialized singleton to avoid creating a new agent per request.
@@ -38,7 +38,15 @@ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
if (!opts.signal) {
opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) };
}
delete opts.timeout;
// The `timeout` key in fetch() opts is silently ignored by undici. Callers
// should use the third arg of fetchT() (timeoutMs) instead. If a caller
// 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(' <- ')}`);
const { timeout: _timeout, ...rest } = opts;
opts = rest;
}
return fetch(url, opts);
}
@@ -160,7 +168,7 @@ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
});
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`));
+107 -5
View File
@@ -1,22 +1,124 @@
/**
* Response helpers - Standard API response formats
*
* Single source of truth for HTTP response shapes across DashCaddy.
* Standard envelope: { success: true, ...data } or { success: false, error: "..." }.
*
* All routes should import from this module do not call res.json/res.status
* directly with the response shape, use these helpers instead.
*/
const { HTTP_STATUS } = require('../utilities/constants');
// ── Success helpers ────────────────────────────────────────────
/**
* Standard error response
* Standard success response. Use this in route handlers.
* Wraps the data object with a `success: true` envelope.
* @param {object} res Express response
* @param {object} [data={}] fields to include in the response body
* @param {number} [statusCode=200] HTTP status code
*/
function ok(res, data = {}, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, ...data });
}
/**
* Alias for `ok` prefer `ok` in new code, but kept for code that imports as `success`.
*/
function success(res, data, statusCode) {
return ok(res, data, statusCode);
}
/**
* Success response with a human-readable message field.
* Use when there's no data to return, just confirmation.
*/
function successMessage(res, message, statusCode = HTTP_STATUS.OK) {
return res.status(statusCode).json({ success: true, message });
}
/**
* 201 Created response.
*/
function created(res, data = {}) {
return res.status(HTTP_STATUS.CREATED).json({ success: true, ...data });
}
/**
* 204 No Content response.
*/
function noContent(res) {
return res.status(HTTP_STATUS.NO_CONTENT).send();
}
// ── Error helpers ──────────────────────────────────────────────
/**
* Standard error response. Use this in route handlers.
* @param {object} res Express response
* @param {number} statusCode HTTP status code
* @param {string} message Human-readable error message
* @param {object} [extras={}] additional fields to merge into the response
*/
function errorResponse(res, statusCode, message, extras = {}) {
return res.status(statusCode).json({ success: false, error: message, ...extras });
}
/**
* Standard success response
* Alias for `errorResponse` kept for code that imports as `error`.
*/
function ok(res, data = {}) {
return res.json({ success: true, ...data });
function error(res, message, statusCode = HTTP_STATUS.INTERNAL_ERROR) {
return res.status(statusCode).json({ success: false, error: message });
}
/**
* 400 Bad Request invalid input from the user.
*/
function validationError(res, message) {
return res.status(HTTP_STATUS.BAD_REQUEST).json({ success: false, error: message });
}
/**
* 401 Unauthorized no valid credentials.
*/
function unauthorized(res, message = 'Unauthorized') {
return res.status(HTTP_STATUS.UNAUTHORIZED).json({ success: false, error: message });
}
/**
* 403 Forbidden credentials valid but permission denied.
*/
function forbidden(res, message = 'Forbidden') {
return res.status(HTTP_STATUS.FORBIDDEN).json({ success: false, error: message });
}
/**
* 404 Not Found resource doesn't exist.
*/
function notFound(res, message = 'Not found') {
return res.status(HTTP_STATUS.NOT_FOUND).json({ success: false, error: message });
}
/**
* 409 Conflict request conflicts with current state (e.g. duplicate).
*/
function conflict(res, message) {
return res.status(HTTP_STATUS.CONFLICT).json({ success: false, error: message });
}
module.exports = {
errorResponse,
// Success helpers
ok,
success,
successMessage,
created,
noContent,
// Error helpers
errorResponse,
error,
validationError,
unauthorized,
forbidden,
notFound,
conflict,
};