/** * 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'); 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/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 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 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 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('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); // 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); } function isTailscaleIP(ip) { if (!ip) return false; const parts = ip.split('.'); if (parts.length !== 4) return false; const first = parseInt(parts[0]); const second = parseInt(parts[1]); return first === 100 && second >= 64 && second <= 127; } 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; } // 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 two // 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. app.use((req, res, next) => { if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) { req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4 } 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', 'Could not save TOTP config', { error: e.message }); } } 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, // 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', 'Failed to initialize workflow engine', { error: err.message }); } } // 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) => { ctx.notification.sendAlert(alertData).catch(err => { log.error('notification', 'Failed to send alert', { error: err.message }); }); }); ctx.resourceMonitor.on('auto-restart', (data) => { ctx.notification.sendServiceEvent('auto-restart', data).catch(err => { log.error('notification', 'Failed to send auto-restart notification', { error: err.message }); }); }); } if (ctx.notification && ctx.backupManager) { ctx.backupManager.on('backup-complete', (data) => { ctx.notification.send('backup-complete', data).catch(err => { log.error('notification', 'Failed to send backup-complete', { error: err.message }); }); }); ctx.backupManager.on('backup-failed', (data) => { ctx.notification.send('backup-failed', data).catch(err => { log.error('notification', 'Failed to send backup-failed', { error: err.message }); }); }); } // Mount route modules apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); 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 })); 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(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('/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) => { ok(res, { status: 'ok', timestamp: new Date().toISOString() }); }); 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() }); }); // Mount at /api/v1 (canonical, single version) app.use('/api/v1', apiRouter); // Root-level health check app.get('/health', (req, res) => { 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('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'); const options = { hostname: parsed.hostname, port: parsed.port || (isHttps ? 443 : 80), path: parsed.pathname + parsed.search, method: 'HEAD', timeout: 5000, agent: isHttps ? httpsAgent : undefined, headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, }; 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)}`; const headers = { 'User-Agent': APP.USER_AGENTS.PROBE }; 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: 5000, agent: httpsAgent, headers: { 'User-Agent': APP.USER_AGENTS.PROBE } }, (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')); // Scan OS network interfaces and classify the first LAN + Tailscale IPv4 // addresses. Extracted to keep the route handler below ESLint's max-depth. function detectInterfaceIps() { const os = require('os'); const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/; const all = []; let lan = null; let tailscale = null; const interfaces = os.networkInterfaces(); for (const [name, addrs] of Object.entries(interfaces)) { for (const addr of addrs || []) { if (addr.internal || addr.family !== 'IPv4') continue; const { address: ip } = addr; all.push({ name, ip }); if (!tailscale && ip.startsWith('100.')) { tailscale = ip; } else if (!lan && LAN_RANGE.test(ip)) { lan = ip; } } } return { lan, tailscale, all }; } // 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) { 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; } } 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); return { app, log, config: config.siteConfig, licenseManager }; } module.exports = { createApp };