/** * Express application setup * Configures middleware, assembles context, and mounts routes */ const express = require('express'); const https = require('https'); const fs = require('fs'); // Configuration const config = require('./config'); const { assembleContext } = require('./context'); const { createLogger, logError, safeErrorMessage } = require('./utils/logging'); const { fetchT } = require('./utils/http'); const { errorResponse, ok } = require('./utils/responses'); // Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review // — 49 route files still use this signature. const { asyncHandler } = require('./utils/async-handler'); // Managers and utilities const StateManager = require('./managers/state-manager'); const platformPaths = require('../platform-paths'); // DC-048 — rehydrate process.env from disk-settings.json BEFORE any engine // module reads env at module-load time. Must run before health-checker, // audit-logger, and the backups route module (backups.js reads // BACKUP_MAX_STORAGE_BYTES at module load too). require('./config/disk-settings-loader')(); const { LicenseManager } = require('./managers/license-manager'); const credentialManager = require('./managers/credential-manager'); const authManager = require('./managers/auth-manager'); const { createShareStore } = require('./security/share-store'); 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'); require("./utilities/nesting-guard")(); 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/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('./recipes/bundled-workflows'); } catch (_) { /* optional module */ } // 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'); const monitoringRoutes = require('../routes/monitoring'); const updatesRoutes = require('../routes/updates'); const authRoutes = require('../routes/auth'); const shareRoutes = require('../routes/share'); const i18nRoutes = require('../routes/i18n'); const discoverRoutes = require('../routes/discover'); const discoverAdoptRoutes = require('../routes/discover-adopt'); const catalogRoutes = require('../routes/catalog'); const wizardRoutes = require('../routes/wizard'); const disasterRoutes = require('../routes/disaster-recovery'); const caddycodeRoutes = require('../routes/caddycode'); const fleetRoutes = require('../routes/fleet'); const configRoutes = require('../routes/config'); const dnsRoutes = require('../routes/dns'); const notificationRoutes = require('../routes/notifications'); const containerRoutes = require('../routes/containers'); const serviceRoutes = require('../routes/services'); const tailscaleRoutes = require('../routes/tailscale'); const tailscaleAdminRoutes = require('../routes/tailscale-admin'); const sitesRoutes = require('../routes/sites'); const credentialsRoutes = require('../routes/credentials'); const arrRoutes = require('../routes/arr'); const appsRoutes = require('../routes/apps'); const logsRoutes = require('../routes/logs'); const backupsRoutes = require('../routes/backups'); const caRoutes = require('../routes/ca'); const browseRoutes = require('../routes/browse'); const errorLogsRoutes = require('../routes/errorlogs'); const licenseRoutes = require('../routes/license'); const openClawRoutes = require('../routes/openclaw'); const recipesRoutes = require('../routes/recipes'); 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 securityRoutes = require('../routes/security'); const diskSettingsRoutes = require('../routes/disk-settings'); const aiIntentRoutes = require('../routes/ai-intent'); const logInsightsRoutes = require('../routes/log-insights'); const auditLogRoutes = require('../routes/audit-log'); const billingRoutes = require('../routes/billing'); const caddyUpstreamRoutes = require('../routes/caddy-upstreams'); const DependencyManager = require('./managers/dependency-manager'); const autoRestartRoutes = require('../routes/auto-restart'); const configDriftRoutes = require('../routes/config-drift'); const sslMonitorRoutes = require('../routes/ssl-monitor'); const diskSpaceRoutes = require('../routes/disk-space'); const { AutoRestartManager } = require('./managers/auto-restart-manager'); const { ConfigDriftDetector } = require('./managers/config-drift-detector'); const SSLMonitor = require('./monitoring/ssl-monitor'); const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor'); const caddyUpstreamWatcher = require('./monitoring/caddy-upstream-watcher'); const DNSPropagationChecker = require('./dns/dns-propagation'); // Constants const { APP } = require('./utilities/constants'); /** * Create and configure the Express application */ // 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); // Load site configuration config.loadSiteConfig(config.CONFIG_FILE, log); // Create state managers const servicesStateManager = new StateManager(config.SERVICES_FILE); const configStateManager = new StateManager(config.CONFIG_FILE); // DC-053: share-store. Single shared instance, lazy file creation on first // write. Lives alongside user-store/invite-store semantics (defensive // dataDir resolver, atomic JSON writes). Always available — Free tier // simply blocks creation via the route-level _requirePro gate. const shareStore = createShareStore({ dataDir: platformPaths.dataDir, platformPaths, log, }); // Initialize license manager const licenseManager = new LicenseManager(credentialManager, config.CONFIG_FILE, console); licenseManager.loadSecret(config.LICENSE_SECRET_FILE); // HTTPS agent for internal CA const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert; let httpsAgent; try { const caCert = fs.readFileSync(CA_CERT_PATH); httpsAgent = new https.Agent({ ca: [...require('tls').rootCertificates, caCert] }); log.info('server', 'HTTPS agent configured with CA certificate', { path: CA_CERT_PATH }); } catch { httpsAgent = new https.Agent(); log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH }); } // TOTP configuration const totpConfig = { enabled: false, sessionDuration: 'never', isSetUp: false }; // Load TOTP config from file try { if (fs.existsSync(config.TOTP_CONFIG_FILE)) { const loaded = JSON.parse(fs.readFileSync(config.TOTP_CONFIG_FILE, 'utf8')); delete loaded.secret; // secret belongs only in credential-manager Object.assign(totpConfig, loaded); log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled }); } } catch (e) { log.warn('config', 'Could not load TOTP config', { error: e.message }); } // Tailscale configuration const tailscaleConfig = { enabled: false, requireAuth: false, allowedTailnet: null, devices: [], oauthConfigured: false, tailnet: null, syncInterval: 300, lastSync: null }; // Helper functions needed by middleware function isValidContainerId(id) { const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/; return typeof id === 'string' && CONTAINER_ID_RE.test(id); } // Tailscale CGNAT classification. Imported from network-detector.js (DC-031) // so there's one source of truth — the local copy here had no malformed-input // guards and would return false on NaN silently. const { isTailscaleIP } = require('./utilities/network-detector'); // Real implementation — delegates to the tailscale manager which // shells out to the host's `tailscale status --json` (cached 5min). // Kept here as a top-level function for back-compat with middleware.js // and any other call site that imports it via the createApp() factory. const { getStatus: getTailscaleStatus } = require('./managers/tailscale-manager'); // Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service // auto-login pages) historically call these endpoints under the pre-1.5.0 // prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained // Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO // gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these // auth paths to the v1 mount so the gate is tolerant of that drift. // Must run before configureMiddleware() so CSRF/auth see the canonical path. // This is deliberately narrow — NOT a general `/api` -> `/api/v1` alias. // // Path mapping (any -> canonical): // /api/auth/gate/ -> /api/v1/auth/gate/ (mounted at /auth/gate/:serviceId) // /api/v1/auth/gate/ -> /api/v1/auth/gate/ (drift, gate pre-1.5.0 sometimes used this) // /api/auth/app-token/ -> /api/v1/auth/app-token/ (mounted at /auth/app-token/:serviceId) // /api/v1/auth/app-token/ -> /api/v1/auth/app-token/ (drift) // /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix) // /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift) // /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token) // // The totp case drops `/auth` because the canonical route is /totp/check-session // (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session // (and a stale-browser version of the page uses /api/v1/auth/totp/check-session). // Without these rewrites the JS gets a 404 and the page hangs at // "Signing in to Plex..." forever (user-reported 2026-07-09). // // sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/* // strips only the /dashcaddy-api prefix, so the login-page JS's fetch to // /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange // — needs the same rewrite as gate/app-token, not the check-session one // (this route's canonical mount already includes /auth/). app.use((req, res, next) => { if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/') || req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/') || req.url.startsWith('/api/auth/sso-exchange')) { req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4 } else if (req.url.startsWith('/api/auth/totp/check-session')) { // Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session // Drop both '/api' and '/auth' prefixes (9 chars total). req.url = '/api/v1' + req.url.slice(9); // '/api/auth'.length === 9 } else if (req.url.startsWith('/api/v1/auth/totp/check-session')) { // Drift: /api/v1/auth/totp/check-session -> /api/v1/totp/check-session // Drop the '/api/v1/auth' prefix (12 chars), keep the leading '/'. req.url = '/api/v1' + req.url.slice(12); // '/api/v1/auth'.length === 12 } next(); }); // Configure middleware const middlewareResult = configureMiddleware(app, { siteConfig: config.siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils: require('./security/crypto-utils'), isValidContainerId, isTailscaleIP, getTailscaleStatus, 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; // Helper functions async function getServiceById(serviceId) { const services = await servicesStateManager.read(); 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('./utilities/fs-helpers'); return readJsonFile(config.CONFIG_FILE, {}); } async function saveConfig(updates) { return await configStateManager.update(cfg => Object.assign(cfg, updates)); } async function addServiceToConfig(service) { await servicesStateManager.update(services => { const existingIndex = services.findIndex(s => s.id === service.id); if (existingIndex >= 0) { services[existingIndex] = { ...services[existingIndex], ...service }; } else { services.push(service); } return services; }); log.info('deploy', 'Service added to config', { serviceId: service.id }); } async function saveTotpConfig() { try { const { writeJsonFile } = require('./utilities/fs-helpers'); await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig); } catch (e) { log.error('config', e, null, { note: 'Could not save TOTP config' }); } } async function loadNotificationConfig() { // Stub - will be implemented } // Forwards the promise from syncHealthCheckerServices — intentionally not // `async` since there is no `await` inside. Callers use `.catch()` on it. function resyncHealthChecker() { return syncHealthCheckerServices({ log, SERVICES_FILE: config.SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl: config.buildServiceUrl, siteConfig: config.siteConfig, APP }); } // Create bound logError function (3-arg signature: ctx, err, extra) // The unified logger module has its own ERROR_LOG_FILE from process.env, // so we just route through its logErrorWrapper. const boundLogError = (context, error, additionalInfo) => logError(context, error, additionalInfo); // Create bound asyncHandler (3-arg: logError, fn, context) const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context); // Assemble context const ctx = assembleContext({ // Config siteConfig: config.siteConfig, buildDomain: config.buildDomain, buildServiceUrl: config.buildServiceUrl, SERVICES_FILE: config.SERVICES_FILE, CONFIG_FILE: config.CONFIG_FILE, TOTP_CONFIG_FILE: config.TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE: config.TAILSCALE_CONFIG_FILE, NOTIFICATIONS_FILE: config.NOTIFICATIONS_FILE, ERROR_LOG_FILE: config.ERROR_LOG_FILE, DNS_CREDENTIALS_FILE: config.DNS_CREDENTIALS_FILE, CADDYFILE_PATH: config.CADDYFILE_PATH, CADDY_ADMIN_URL: config.CADDY_ADMIN_URL, // State managers servicesStateManager, configStateManager, // DC-053: share store + signing secret shareStore, // Managers credentialManager, authManager, licenseManager, healthChecker, updateManager, backupManager, resourceMonitor, auditLogger, portLockManager, selfUpdater, dockerMaintenance, logDigest, dockerSecurity, // Templates APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES, // Helpers asyncHandler: boundAsyncHandler, errorResponse, ok, fetchT, httpsAgent, log, logError: boundLogError, safeErrorMessage, getServiceById, readConfig, saveConfig, addServiceToConfig, validateURL, strictLimiter, totpConfig, saveTotpConfig, loadSiteConfig: () => config.loadSiteConfig(config.CONFIG_FILE, log), loadNotificationConfig, resyncHealthChecker, // Middleware result middlewareResult, // App app, }); // Initialize workflow engine if bundled-workflows is available if (bundledWorkflowsModule && ctx.docker) { try { const { WorkflowEngine } = bundledWorkflowsModule; const workflowCtx = { docker: ctx.docker, notification: ctx.notification, backupManager: ctx.backupManager, resourceMonitor: ctx.resourceMonitor, servicesStateManager: ctx.servicesStateManager }; workflowEngine = new WorkflowEngine(workflowCtx); ctx.workflowEngine = workflowEngine; log.info('app', 'Workflow engine initialized'); } catch (err) { log.error('app', err, null, { note: 'Failed to initialize workflow engine' }); } } // 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 disk space monitor (disk budget + auto-cleanup) const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig }); ctx.diskSpaceMonitor = diskSpaceMonitor; diskSpaceMonitor.start(600000); // 10 min log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB }); // Initialize caddy upstream watcher — independent probes of every // reverse_proxy directive in /etc/caddy/sites/, emits 'dead' incidents // after 5min of consecutive failures (so a single blip doesn't page). caddyUpstreamWatcher.log = log; caddyUpstreamWatcher.healthChecker = healthChecker; caddyUpstreamWatcher.start(); ctx.caddyUpstreamWatcher = caddyUpstreamWatcher; log.info('app', 'Caddy upstream watcher 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. // The handler is implemented in routes/version.js but is registered inline here so // public-routes-drift.test.js (which walks apiRouter.stack directly) can see it. let appVersion = '0.0.0'; let appName = 'dashcaddy-api'; const versionRoute = require('../routes/version'); appVersion = versionRoute.getVersion(); appName = versionRoute.getName(); // Pre-build the version router once at startup and reuse it. const versionRouter = versionRoute.buildRouter(); apiRouter.use(versionRouter); 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) => { ctx.notification.sendAlert(alertData).catch(err => { log.error('notification', err, null, { note: 'Failed to send alert' }); }); }); ctx.resourceMonitor.on('auto-restart', (data) => { ctx.notification.sendServiceEvent('auto-restart', data).catch(err => { log.error('notification', err, null, { note: 'Failed to send auto-restart notification' }); }); }); } if (ctx.notification && ctx.backupManager) { ctx.backupManager.on('backup-complete', (data) => { ctx.notification.send('backup-complete', data).catch(err => { log.error('notification', err, null, { note: 'Failed to send backup-complete' }); }); }); ctx.backupManager.on('backup-failed', (data) => { ctx.notification.send('backup-failed', data).catch(err => { log.error('notification', err, null, { note: 'Failed to send backup-failed' }); }); }); } // Mount route modules apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); // DC-053: share routes (public share links + Tailscale-mediated share). // Always mounted — Free tier enforcement is at the route level, not the // mount level, so the API surface is uniform across tiers (operators can // upgrade without restarting route registration). apiRouter.use(shareRoutes({ shareStore: ctx.shareStore, licenseManager: ctx.licenseManager, tailscaleCoord: ctx.tailscaleCoord, notificationManager: ctx.notification, servicesStateManager: ctx.servicesStateManager, servicesFile: platformPaths.servicesFile, asyncHandler: ctx.asyncHandler, log: ctx.log, })); // DC-055: billing is PUBLIC (customer hasn't paid yet → no session). // Stripe-session creation only; the webhook side runs in scripts/stripe-license-bridge.js. apiRouter.use('/billing', billingRoutes({ asyncHandler: ctx.asyncHandler, })); apiRouter.use('/dns', dnsRoutes({ dns: ctx.dns, siteConfig: ctx.siteConfig, asyncHandler: ctx.asyncHandler, log: ctx.log, safeErrorMessage: ctx.safeErrorMessage, fetchT: ctx.fetchT, credentialManager: ctx.credentialManager, dnsPropagationChecker: ctx.dnsPropagationChecker })); apiRouter.use('/notifications', notificationRoutes({ notification: ctx.notification, asyncHandler: ctx.asyncHandler, ok: ctx.ok })); apiRouter.use('/containers', containerRoutes({ docker: ctx.docker, log: ctx.log, asyncHandler: ctx.asyncHandler, workflowEngine: ctx.workflowEngine })); apiRouter.use(serviceRoutes({ servicesStateManager: ctx.servicesStateManager, credentialManager: ctx.credentialManager, siteConfig: ctx.siteConfig, buildServiceUrl: ctx.buildServiceUrl, buildDomain: ctx.buildDomain, fetchT: ctx.fetchT, asyncHandler: ctx.asyncHandler, SERVICES_FILE: ctx.SERVICES_FILE, log: ctx.log, safeErrorMessage: ctx.safeErrorMessage, resyncHealthChecker: ctx.resyncHealthChecker, caddy: ctx.caddy, dns: ctx.dns })); apiRouter.use(healthRoutes({ fetchT: ctx.fetchT, SERVICES_FILE: ctx.SERVICES_FILE, servicesStateManager: ctx.servicesStateManager, siteConfig: ctx.siteConfig, buildServiceUrl: ctx.buildServiceUrl, asyncHandler: ctx.asyncHandler, logError: ctx.logError, healthChecker: ctx.healthChecker })); apiRouter.use(monitoringRoutes({ resourceMonitor: ctx.resourceMonitor, docker: ctx.docker, asyncHandler: ctx.asyncHandler, log: ctx.log, notificationManager: ctx.notification })); // DC-077: i18n — language metadata and translations (public, no auth needed) apiRouter.use(i18nRoutes()); // DC-100: Service discovery — auto-detect running containers apiRouter.use(discoverRoutes({ docker: ctx.docker, servicesStateManager: ctx.servicesStateManager, asyncHandler: ctx.asyncHandler, })); // DC-103: One-click adopt — auto-generate routes + DNS + service entry apiRouter.use(discoverAdoptRoutes({ docker: ctx.docker, servicesStateManager: ctx.servicesStateManager, caddy: ctx.caddy, dns: ctx.dns, siteConfig: ctx.config, fetchT: ctx.fetchT, asyncHandler: ctx.asyncHandler, })); // DC-104: App catalog — browse curated templates const { APP_TEMPLATES: templatesArray } = require('./docker/app-templates'); apiRouter.use(catalogRoutes({ APP_TEMPLATES: templatesArray, asyncHandler: ctx.asyncHandler, })); // DC-105: Smart defaults wizard apiRouter.use(wizardRoutes({ APP_TEMPLATES: templatesArray, asyncHandler: ctx.asyncHandler, })); // DC-107: Disaster recovery — full backup + restore apiRouter.use(disasterRoutes({ servicesStateManager: ctx.servicesStateManager, platformPaths: require('../platform-paths'), log: ctx.log, asyncHandler: ctx.asyncHandler, })); // DC-106: Caddyfile-as-code — visual reverse proxy builder apiRouter.use(caddycodeRoutes({ asyncHandler: ctx.asyncHandler, })); // DC-108: Multi-host fleet management apiRouter.use(fleetRoutes({ log: ctx.log, asyncHandler: ctx.asyncHandler, })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater, asyncHandler: ctx.asyncHandler, logError: ctx.logError, ok: ctx.ok })); apiRouter.use('/tailscale', tailscaleRoutes({ tailscale: ctx.tailscale, caddy: ctx.caddy, servicesStateManager: ctx.servicesStateManager, credentialManager: ctx.credentialManager, buildDomain: ctx.buildDomain, asyncHandler: ctx.asyncHandler, ok: ctx.ok, SERVICES_FILE: ctx.SERVICES_FILE, log: ctx.log })); apiRouter.use('/tailscale', tailscaleAdminRoutes({ tailscaleCoord: ctx.tailscaleCoord, asyncHandler: ctx.asyncHandler, ok: ctx.ok, log: ctx.log, logError: ctx.logError, })); apiRouter.use(sitesRoutes({ asyncHandler: ctx.asyncHandler, ok: ctx.ok, caddy: ctx.caddy, dns: ctx.dns, fetchT: ctx.fetchT, buildDomain: ctx.buildDomain, addServiceToConfig: ctx.addServiceToConfig, siteConfig: ctx.siteConfig, log: ctx.log })); apiRouter.use(credentialsRoutes({ credentialManager: ctx.credentialManager, asyncHandler: ctx.asyncHandler })); apiRouter.use(arrRoutes(ctx)); apiRouter.use(appsRoutes(ctx)); apiRouter.use('/openclaw', openClawRoutes(ctx)); apiRouter.use(logsRoutes({ asyncHandler: ctx.asyncHandler, ok: ctx.ok, docker: ctx.docker, logDigest: ctx.logDigest, dockerMaintenance: ctx.dockerMaintenance })); apiRouter.use(backupsRoutes({ backupManager: ctx.backupManager, licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler })); apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use(browseRoutes({ asyncHandler: ctx.asyncHandler, validateSecurePath: ctx.validateSecurePath, auditLogger: ctx.auditLogger, docker: ctx.docker })); apiRouter.use(errorLogsRoutes({ ERROR_LOG_FILE: ctx.ERROR_LOG_FILE, auditLogger: ctx.auditLogger, asyncHandler: ctx.asyncHandler })); apiRouter.use('/license', licenseRoutes({ licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler })); apiRouter.use('/recipes', recipesRoutes(ctx)); apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler, log: ctx.log })); apiRouter.use('/docker', dockerResourcesRoutes({ docker: ctx.docker, asyncHandler: ctx.asyncHandler })); apiRouter.use('/events', eventsRoutes({ resourceMonitor: ctx.resourceMonitor, healthChecker: ctx.healthChecker, updateManager: ctx.updateManager, logError: ctx.logError, ok: ctx.ok, dependencyManager: ctx.dependencyManager, autoRestartManager: ctx.autoRestartManager, driftDetector: ctx.driftDetector, sslMonitor: ctx.sslMonitor, dnsPropagationChecker: ctx.dnsPropagationChecker })); apiRouter.use('/workflows', workflowsRoutes({ workflowEngine: ctx.workflowEngine, licenseManager: ctx.licenseManager, asyncHandler: ctx.asyncHandler, ok: ctx.ok })); apiRouter.use('/security', securityRoutes({ log: ctx.log, })); // Log Insights — plain English activity summary + safe log disposal apiRouter.use('/disk-settings', diskSettingsRoutes); apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler })); apiRouter.use(logInsightsRoutes({ asyncHandler: ctx.asyncHandler, ok: ctx.ok, auditLogger: ctx.auditLogger, securityEventStore: (function() { try { var getStore = require('./security/event-store').getStore; return getStore(); } catch (e) { return null; } })() })); // DC-050 — Audit log viewer route. The frontend at status/js/audit-log.js // has been calling /api/v1/audit-logs since 2026-05-27; before this route // existed the dashboard silently 404'd. The audit-logger module already // exposes query() and clear() — this route just gives them an HTTP shape. apiRouter.use(auditLogRoutes({ asyncHandler: ctx.asyncHandler, auditLogger: ctx.auditLogger, })); 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, })); apiRouter.use(caddyUpstreamRoutes({ caddyUpstreamWatcher: ctx.caddyUpstreamWatcher, healthChecker: ctx.healthChecker, asyncHandler: ctx.asyncHandler, })); apiRouter.use('/disk', diskSpaceRoutes({ diskSpaceMonitor: ctx.diskSpaceMonitor, asyncHandler: ctx.asyncHandler, log: ctx.log, })); // Inline API routes (mounted under /api/v1 below) // Note: /health lives at root only — see root-level health check below. // Probes (/healthz, /readyz, /health/live, /health/ready) also at root only. // Do NOT add another /api/v1/health route — it's been consolidated. apiRouter.get('/csrf-token', (req, res) => { ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME }); }); apiRouter.get('/metrics', (req, res) => { ok(res, { metrics: metrics.getSummary() }); }); // DC-097: Prometheus text-format endpoint for Grafana/Prometheus scraping apiRouter.get('/metrics/prometheus', (req, res) => { res.set('Content-Type', 'text/plain; version=0.0.4'); res.send(metrics.toPrometheus()); }); // Mount at /api/v1 (canonical, single version) app.use('/api/v1', apiRouter); // =========================================================================== // Health probes — root-level, no auth, no CSRF, no rate limit. // // Two semantics, four paths: // // LIVENESS — "is the Node.js process alive?" // /health/live (explicit, recommended) // /healthz (k8s/Docker-standard alias) // READINESS — "are critical dependencies reachable?" // /health/ready (explicit, recommended) // /readyz (k8s/Docker-standard alias) // // k8s/Docker/Caddy call these to decide whether to RESTART or ROUTE TRAFFIC. // They MUST stay cheap (no DB queries, no logging side effects, no auth). // // Plain /health is kept for backwards compatibility and returns the same // payload as /health/live. Use /health/live or /healthz in new code. // =========================================================================== // Liveness — pure process check, no deps. const livenessHandler = (req, res) => { ok(res, { status: 'alive', uptime: process.uptime() }); }; // Readiness — checks critical dependencies (config, services file, // Docker daemon, Caddy admin). 200 if all OK, 503 if any failed. const readinessHandler = 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 // Use fetchT() (NOT native fetch) because undici fetch rejects Caddy admin // on :2019, and probe the LIGHTEST endpoint (srv0/listen = 9 bytes) to avoid // head-of-line blocking when /load or another config mutation is in flight. // A previous `/config/` probe hit the 3s AbortController timeout with // "This operation was aborted" while Caddy was actually healthy. try { const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019'; const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000); 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); }); // Liveness paths app.get('/health', livenessHandler); app.get('/health/live', livenessHandler); app.get('/healthz', livenessHandler); // Readiness paths app.get('/health/ready', readinessHandler); app.get('/readyz', readinessHandler); // Lightweight probe endpoint app.get('/probe/:id', boundAsyncHandler(async (req, res) => { const id = req.params.id; const { exists } = require('./utilities/fs-helpers'); let service = null; if (id !== 'internet' && await exists(config.SERVICES_FILE)) { const data = await servicesStateManager.read(); const services = Array.isArray(data) ? data : data.services || []; service = services.find(s => s.id === id); } const url = resolveServiceUrl(id, service, config.siteConfig, config.buildServiceUrl); const parsed = new URL(url); const isHttps = parsed.protocol === 'https:'; const lib = isHttps ? https : require('http'); // X-DashCaddy-HealthCheck: 1 — Caddy's (dashcaddy_auth) block matches // this header (from local container IPs) to bypass the forward_auth gate. // Without it, every probe hits authLimiter → 429 → marked TIMEOUT. // See /etc/caddy/Caddyfile (dashcaddy_auth) and the matching logic in // src/monitoring/health-checker.js (which sets the same marker). const options = { hostname: parsed.hostname, port: parsed.port || (isHttps ? 443 : 80), path: parsed.pathname + parsed.search, method: 'HEAD', timeout: 8000, agent: isHttps ? httpsAgent : undefined, headers: { 'User-Agent': APP.USER_AGENTS.PROBE, 'X-DashCaddy-HealthCheck': '1', }, }; const makeRequest = (method) => new Promise((resolve, reject) => { const reqOpts = { ...options, method }; const probeReq = lib.request(reqOpts, (response) => { response.resume(); resolve(response.statusCode); }); probeReq.on('error', reject); probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); }); probeReq.end(); }); let statusCode; try { statusCode = await makeRequest('HEAD'); if (statusCode === 501 || statusCode === 405) { statusCode = await makeRequest('GET'); } } catch { // Direct probe failed — try Pylon relay if configured const pylonConfig = config.siteConfig?.pylon; if (pylonConfig?.url) { try { const pylonUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(url)}`; // Forward healthcheck marker to the remote pylon relay in case its Caddy // is configured to bypass forward_auth on the same header. const headers = { 'User-Agent': APP.USER_AGENTS.PROBE, 'X-DashCaddy-HealthCheck': '1', }; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; const controller = new AbortController(); const pylonTimeout = setTimeout(() => controller.abort(), 8000); const pylonRes = await fetchT(pylonUrl, { method: 'GET', signal: controller.signal, headers }); clearTimeout(pylonTimeout); if (pylonRes.ok) { const data = await pylonRes.json(); statusCode = data.statusCode || 502; } } catch { // Pylon also failed — fall through to domain fallback } } // Domain-based fallback (last resort) if (!statusCode) { const fallbackUrl = `https://${config.buildDomain(id)}`; const fp = new URL(fallbackUrl); statusCode = await new Promise((resolve, reject) => { const fReq = https.request({ hostname: fp.hostname, port: 443, path: '/', method: 'GET', timeout: 8000, agent: httpsAgent, headers: { 'User-Agent': APP.USER_AGENTS.PROBE, 'X-DashCaddy-HealthCheck': '1', } }, (fRes) => { fRes.resume(); resolve(fRes.statusCode); }); fReq.on('error', reject); fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); }); fReq.end(); }).catch(() => 502); } } res.status(statusCode).send(); }, 'probe')); // Network IPs endpoint — see src/utilities/network-detector.js for the // classification logic. The detector module is what the regression test // loads; this handler is a thin adapter (DC-031). const { detectInterfaceIps } = require('./utilities/network-detector'); // Network IPs endpoint app.get('/api/v1/network/ips', (req, res) => { try { const envLan = process.env.HOST_LAN_IP; const envTailscale = process.env.HOST_TAILSCALE_IP; const result = { localhost: '127.0.0.1', lan: envLan || null, tailscale: envTailscale || null, all: [] }; if (!envLan || !envTailscale) { const detected = detectInterfaceIps(); result.all = detected.all; if (!result.lan) result.lan = detected.lan; if (!result.tailscale) result.tailscale = detected.tailscale; } ok(res, result); } catch (error) { errorResponse(res, 500, safeErrorMessage(error)); } }); // API Documentation app.get('/api/v1/docs', (req, res) => { res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://unpkg.com;"); res.send(` DashCaddy API Documentation
`); }); app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => { const path = require('path'); const { exists } = require('./utilities/fs-helpers'); const fsp = require('fs').promises; const specPath = path.join(__dirname, '../openapi.yaml'); if (await exists(specPath)) { const yaml = await fsp.readFile(specPath, 'utf8'); res.type('text/yaml').send(yaml); } else { errorResponse(res, 404, 'OpenAPI spec not found'); } }, 'api-docs-spec')); // Error handlers (MUST be last) const { notFoundHandler, errorMiddleware } = require('./utilities/error-handler'); app.use('/api', notFoundHandler); app.use(errorMiddleware); // Expose ctx on the app for entry points (server.js dashboard-WS wiring) // without changing the returned shape for existing callers/tests. app.locals.ctx = ctx; return { app, log, config: config.siteConfig, licenseManager }; } module.exports = { createApp };