/** * 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'); 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 { validateStartupConfig, 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'); // Optional modules let dockerMaintenance, logDigest; try { dockerMaintenance = require('../docker-maintenance'); } catch (_) {} try { logDigest = require('../log-digest'); } catch (_) {} // Templates const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../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 recipesRoutes = require('../routes/recipes'); const themesRoutes = require('../routes/themes'); // Constants const { APP } = require('../constants'); /** * Create and configure the Express application */ async function createApp() { const app = express(); // 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 || '/app/pki/root.crt'; 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 let totpConfig = { enabled: false, sessionDuration: 'never', isSetUp: false }; // Tailscale configuration let 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; } async function getTailscaleStatus() { // Stub for now - will be populated by context return null; } // Configure middleware const middlewareResult = configureMiddleware(app, { siteConfig: config.siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils: require('../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, }); const { strictLimiter } = middlewareResult; // Helper functions async function getServiceById(serviceId) { const services = await servicesStateManager.read(); return services.find(s => s.id === serviceId) || null; } async function readConfig() { const { readJsonFile } = require('../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() { // Stub - will be implemented } async function loadNotificationConfig() { // Stub - will be implemented } async function resyncHealthChecker() { return syncHealthCheckerServices({ log, SERVICES_FILE: config.SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl: config.buildServiceUrl, siteConfig: config.siteConfig, APP }); } // Create bound logError function const boundLogError = (context, error, additionalInfo) => logError(config.ERROR_LOG_FILE, config.MAX_ERROR_LOG_SIZE, context, error, additionalInfo, log); // Create bound asyncHandler 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, }); // Build versioned API router const apiRouter = express.Router(); // 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 })); apiRouter.use('/notifications', notificationRoutes(ctx)); apiRouter.use('/containers', containerRoutes({ docker: ctx.docker, log: ctx.log, asyncHandler: ctx.asyncHandler })); 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 })); apiRouter.use(updatesRoutes({ updateManager: ctx.updateManager, selfUpdater: ctx.selfUpdater, asyncHandler: ctx.asyncHandler, logError: ctx.logError })); apiRouter.use('/tailscale', tailscaleRoutes(ctx)); apiRouter.use(sitesRoutes(ctx)); apiRouter.use(credentialsRoutes({ credentialManager: ctx.credentialManager, asyncHandler: ctx.asyncHandler })); apiRouter.use(arrRoutes(ctx)); apiRouter.use(appsRoutes(ctx)); apiRouter.use(logsRoutes(ctx)); apiRouter.use(backupsRoutes({ backupManager: ctx.backupManager, asyncHandler: ctx.asyncHandler })); apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use(browseRoutes(ctx)); 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 })); // Inline API routes apiRouter.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); apiRouter.get('/csrf-token', (req, res) => { res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME }); }); apiRouter.get('/metrics', (req, res) => { res.json({ success: true, metrics: metrics.getSummary() }); }); // Mount at /api/v1 (canonical) and /api (legacy) app.use('/api/v1', apiRouter); app.use('/api', apiRouter); // Root-level health check app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Lightweight probe endpoint app.get('/probe/:id', boundAsyncHandler(async (req, res) => { const id = req.params.id; const { exists } = require('../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 { 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(); }); } res.status(statusCode).send(); }, 'probe')); // Network IPs endpoint app.get('/api/network/ips', (req, res) => { try { const os = require('os'); 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 interfaces = os.networkInterfaces(); for (const [name, addrs] of Object.entries(interfaces)) { for (const addr of addrs) { if (addr.internal || addr.family !== 'IPv4') continue; const ip = addr.address; result.all.push({ name, ip }); if (!result.tailscale && ip.startsWith('100.')) { result.tailscale = ip; } else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) { result.lan = ip; } } } } res.json(result); } catch (error) { errorResponse(res, 500, safeErrorMessage(error)); } }); // API Documentation app.get('/api/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/docs/spec', boundAsyncHandler(async (req, res) => { const path = require('path'); const { exists } = require('../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('../error-handler'); app.use('/api', notFoundHandler); app.use(errorMiddleware); return { app, log, config: config.siteConfig, licenseManager }; } module.exports = { createApp };