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