const express = require('express'); const crypto = require('crypto'); const fs = require('fs'); const fsp = require('fs').promises; const { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible } = require('./fs-helpers'); const os = require('os'); const http = require('http'); const https = require('https'); const { execSync } = require('child_process'); const path = require('path'); const { ValidationError, validateFilePath, validateURL, validateToken, validateServiceConfig, sanitizeString, isValidPort, validateSecurePath } = require('./input-validator'); const validatorLib = require('validator'); const credentialManager = require('./credential-manager'); const { CACHE_CONFIGS, createCache } = require('./cache-config'); const { AppError } = require('./errors'); const { validateConfig } = require('./config-schema'); const { APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES, SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS, REGEX, DNS_RECORD_TYPES, DOCKER, TAILSCALE, buildMediaAuth, } = require('./constants'); const platformPaths = require('./platform-paths'); // Image processing for favicon conversion let sharp, pngToIco; try { sharp = require('sharp'); pngToIco = require('png-to-ico'); } catch (e) { log.warn('server', 'Image processing libraries not available - favicon conversion disabled'); } // Docker integration const Docker = require('dockerode'); const docker = new Docker(); // App templates const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('./app-templates'); const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('./recipe-templates'); // Crypto utilities for credential encryption const cryptoUtils = require('./crypto-utils'); // New feature modules 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 dockerMaintenance = require('./docker-maintenance'); const logDigest = require('./log-digest'); const StateManager = require('./state-manager'); const auditLogger = require('./audit-logger'); const portLockManager = require('./port-lock-manager'); const dockerSecurity = require('./docker-security'); const authManager = require('./auth-manager'); const configureMiddleware = require('./middleware'); const { validateStartupConfig, syncHealthCheckerServices } = require('./startup-validator'); const { CSRF_HEADER_NAME } = require('./csrf-protection'); // Route modules const ctx = require('./routes/context'); 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'); const { LicenseManager } = require('./license-manager'); const metrics = require('./metrics'); const app = express(); const PORT = APP.PORT; // Configuration from environment variables const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile; const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl; const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile; const SERVICES_DIR = path.dirname(SERVICES_FILE); const CONFIG_FILE = process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json'); const DNS_CREDENTIALS_FILE = process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json'); const TAILSCALE_CONFIG_FILE = process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json'); const NOTIFICATIONS_FILE = process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json'); const TOTP_CONFIG_FILE = process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json'); const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(__dirname, 'dashcaddy-errors.log'); const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE; const BROWSE_ROOTS = (process.env.MEDIA_BROWSE_ROOTS || '') .split(',') .filter(r => r.includes('=')) .map(r => { const eqIndex = r.indexOf('='); const containerPath = r.slice(0, eqIndex).trim(); const hostPath = r.slice(eqIndex + 1).trim(); return { containerPath, hostPath }; }); // State management with file locking (prevents data corruption) const servicesStateManager = new StateManager(SERVICES_FILE); const configStateManager = new StateManager(CONFIG_FILE); // License manager for premium feature gating const licenseManager = new LicenseManager(credentialManager, CONFIG_FILE, console); const LICENSE_SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret'); licenseManager.loadSecret(LICENSE_SECRET_FILE); // ===== Site configuration loaded from config.json (#5) ===== // These are read at startup and refreshed on config save. // All code should use these instead of hardcoded values. let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '', routingMode: 'subdomain' }; function loadSiteConfig() { try { if (fs.existsSync(CONFIG_FILE)) { const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); // Validate config and log any issues (log.warn may not be assigned during initial load) const { valid, errors: configErrors, warnings: configWarnings } = validateConfig(raw); if (log.warn) { if (!valid) { log.warn('config', 'Config validation errors', { errors: configErrors }); } for (const w of configWarnings) { log.warn('config', w); } } siteConfig.tld = raw.tld || '.home'; if (!siteConfig.tld.startsWith('.')) siteConfig.tld = '.' + siteConfig.tld; siteConfig.caName = raw.caName || ''; siteConfig.dnsServerIp = (raw.dns && raw.dns.ip) || ''; siteConfig.dnsServerPort = (raw.dns && raw.dns.port) || CADDY.DEFAULT_DNS_PORT; siteConfig.dashboardHost = raw.dashboardHost || `status${siteConfig.tld}`; siteConfig.timezone = raw.timezone || 'UTC'; siteConfig.dnsServers = raw.dnsServers || {}; siteConfig.configurationType = raw.configurationType || 'homelab'; siteConfig.domain = raw.domain || ''; siteConfig.routingMode = raw.routingMode || 'subdomain'; } } catch (e) { // log.error may not be assigned yet during initial module load if (log.error) { log.error('config', 'Failed to load site config', { error: e.message }); } } } loadSiteConfig(); /** Build a domain from subdomain + configured TLD or public domain */ function buildDomain(subdomain) { if (siteConfig.configurationType === 'public' && siteConfig.domain) { return `${subdomain}.${siteConfig.domain}`; } return `${subdomain}${siteConfig.tld}`; } /** Build full service URL (protocol + host + path) for a given subdomain. * Subdirectory mode: https://example.com/sonarr * Subdomain mode: https://sonarr.example.com */ function buildServiceUrl(subdomain) { if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { return `https://${siteConfig.domain}/${subdomain}`; } return `https://${buildDomain(subdomain)}`; } /** Build full Technitium DNS API URL for any server (handles IP vs hostname, port) */ 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}`; } /** Call a Technitium DNS API endpoint and return parsed JSON */ 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(); } // ===== Shared Helpers ===== /** Fetch with automatic timeout — adds AbortSignal if no signal is present. * Drop-in replacement for fetch(); safely ignores calls that already have a signal. */ function fetchT(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { // Caddy admin API rejects Node.js undici fetch due to Sec-Fetch-* headers triggering // origin checking. Use raw http.request for Caddy admin calls to avoid this. if (url.includes(':2019')) { return _httpFetch(url, opts, timeoutMs); } if (!opts.signal) { opts = { ...opts, signal: AbortSignal.timeout(timeoutMs) }; } delete opts.timeout; return fetch(url, opts); } /** Raw http.request wrapper that returns a fetch-like Response for Caddy admin API */ function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { return new Promise((resolve, reject) => { const parsed = new URL(url); const options = { hostname: parsed.hostname, port: parsed.port || 2019, path: parsed.pathname + parsed.search, method: (opts.method || 'GET').toUpperCase(), headers: { ...opts.headers }, timeout: timeoutMs, }; if (opts.body) { options.headers['Content-Length'] = Buffer.byteLength(opts.body); } const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB const req = http.request(options, (res) => { let data = ''; let size = 0; res.on('data', chunk => { size += chunk.length; if (size > MAX_RESPONSE_SIZE) { res.destroy(); reject(new Error(`Response from ${url} exceeded ${MAX_RESPONSE_SIZE} bytes`)); return; } data += chunk; }); res.on('end', () => { resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, statusText: res.statusMessage, json: () => Promise.resolve(JSON.parse(data)), text: () => Promise.resolve(data), headers: { get: (k) => res.headers[k.toLowerCase()] }, }); }); }); req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); }); req.on('error', reject); if (opts.body) req.write(opts.body); req.end(); }); } /** Pull a Docker image with timeout protection */ function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) { return new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), timeoutMs); docker.pull(imageName, (err, stream) => { if (err) { clearTimeout(timer); return reject(err); } docker.modem.followProgress(stream, (err, output) => { clearTimeout(timer); if (err) return reject(err); resolve(output); }); }); }); } // ===== Structured Logging ===== const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }; const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1; function log(level, context, message, data = {}) { if (LOG_LEVELS[level] < LOG_LEVEL) return; const entry = { t: new Date().toISOString(), level, ctx: context, msg: message, }; if (Object.keys(data).length) entry.data = data; const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log; fn(JSON.stringify(entry)); } log.info = (ctx, msg, data) => log('info', ctx, msg, data); log.warn = (ctx, msg, data) => log('warn', ctx, msg, data); log.error = (ctx, msg, data) => log('error', ctx, msg, data); log.debug = (ctx, msg, data) => log('debug', ctx, msg, data); /** Standard error response — always returns { success: false, error, ...extras } */ function errorResponse(res, statusCode, message, extras = {}) { return res.status(statusCode).json({ success: false, error: message, ...extras }); } /** Standard success response — always returns { success: true, ...data } */ function ok(res, data = {}) { return res.json({ success: true, ...data }); } /** Look up a single service by ID from services.json */ async function getServiceById(serviceId) { const services = await servicesStateManager.read(); return services.find(s => s.id === serviceId) || null; } /** Find a running Docker container by name substring */ async function findContainerByName(name, opts = { all: false }) { const containers = await docker.listContainers(opts); const match = containers.find(c => c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())) ); return match || null; } /** Read config.json with fallback to empty object */ async function readConfig() { return readJsonFile(CONFIG_FILE, {}); } /** Save config.json (merges with existing, atomic with locking) */ async function saveConfig(updates) { return await configStateManager.update(config => { return Object.assign(config, updates); }); } /** * Resolve a DNS token: use the provided one or auto-refresh. * @returns {{ token: string }} or throws with 401-appropriate message */ 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; } /** Get all host ports currently in use by Docker containers */ async function getUsedPorts() { const containers = await docker.listContainers({ all: false }); const ports = new Set(); for (const c of containers) { for (const p of (c.Ports || [])) { if (p.PublicPort) ports.add(p.PublicPort); } } return ports; } /** * Atomically read-modify-write the Caddyfile and reload Caddy. * Uses a mutex to prevent concurrent modifications from clobbering each other. * Rolls back on reload failure. * @param {function} modifyFn - receives current content, returns modified content (or null to skip) * @returns {{ success: boolean, error?: string }} */ let _caddyfileLock = Promise.resolve(); async function modifyCaddyfile(modifyFn) { let resolve; const prev = _caddyfileLock; _caddyfileLock = new Promise(r => { resolve = r; }); await prev; // wait for any in-flight modification to finish try { const original = await readCaddyfile(); const modified = await modifyFn(original); if (modified === null || modified === original) { return { success: false, error: 'No changes to apply' }; } await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8'); try { await reloadCaddy(modified); return { success: true }; } catch (err) { // Rollback await fsp.writeFile(CADDYFILE_PATH, original, 'utf8'); return { success: false, error: safeErrorMessage(err), rolledBack: true }; } } finally { resolve(); } } /** Read the current Caddyfile content */ async function readCaddyfile() { return fsp.readFile(CADDYFILE_PATH, 'utf8'); } // Error logging function with enhanced context tracking async function logError(context, error, additionalInfo = {}) { const timestamp = new Date().toISOString(); // Extract request context if a request object is provided const requestContext = {}; if (additionalInfo.req) { const req = additionalInfo.req; const clientIP = req.ip || req.socket?.remoteAddress || ''; requestContext.requestId = req.id; requestContext.ip = clientIP; requestContext.userAgent = req.get('user-agent'); requestContext.method = req.method; requestContext.path = req.path; // Check session validity using ipSessions cache const session = ipSessions.get(clientIP); requestContext.sessionValid = session && session.exp > Date.now(); delete additionalInfo.req; // Remove req from additionalInfo to avoid circular refs } const logEntry = { timestamp, context, ...requestContext, error: { message: error.message || error, stack: error.stack, code: error.code }, ...additionalInfo }; // Format log line with request context const contextInfo = Object.keys(requestContext).length > 0 ? `\nRequest Context: ${JSON.stringify(requestContext, null, 2)}` : ''; const logLine = `[${timestamp}] ${context}: ${error.message || error}\n${error.stack || ''}${contextInfo}\nAdditional Info: ${JSON.stringify(additionalInfo, null, 2)}\n${'='.repeat(80)}\n`; try { // #7: Rotate log if it exceeds max size try { const stats = await fsp.stat(ERROR_LOG_FILE); if (stats.size > MAX_ERROR_LOG_SIZE) { const rotated = ERROR_LOG_FILE + '.1'; if (await exists(rotated)) await fsp.unlink(rotated); await fsp.rename(ERROR_LOG_FILE, rotated); } } catch (_) { /* file may not exist yet */ } await fsp.appendFile(ERROR_LOG_FILE, logLine); } catch (e) { log.error('errorlog', 'Failed to write to error log', { error: e.message }); } } /** #6: Return a safe error message to the client without leaking internals */ function safeErrorMessage(error) { const msg = error.message || String(error); // Detect port conflict errors from Docker const portMatch = msg.match(/exposing port TCP [^:]*:(\d+)/); if (portMatch || msg.includes('port is already allocated') || msg.includes('ports are not available')) { const port = portMatch ? portMatch[1] : 'requested'; return `[DC-200] Port ${port} is already in use. Try a different port in Advanced Options, or stop the service using that port first.`; } // Only expose messages that are clearly user-facing (short, no paths/stack frames) if (msg.length < 200 && !msg.includes('/') && !msg.includes('\\') && !msg.includes(' at ')) { return msg; } return 'An internal error occurred'; } /** Wrap async route handlers — catches unhandled errors, logs, and returns 500. * Eliminates try/catch boilerplate from route definitions. * @param {Function} fn - async (req, res, next) handler * @param {string} [context] - label for logError (defaults to req.path) */ function asyncHandler(fn, context) { return async (req, res, next) => { try { await fn(req, res, next); } catch (error) { // Let typed errors (AppError subclasses) propagate to the global error handler if (error instanceof AppError) { return next(error); } await logError(context || req.path, error); if (!res.headersSent) { errorResponse(res, 500, safeErrorMessage(error)); } } }; } /** #4: Validate Docker container IDs (hex SHA256 prefix or name) */ const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,127}$/; function isValidContainerId(id) { return typeof id === 'string' && CONTAINER_ID_RE.test(id); } // DNS token management - auto-refresh when expired let dnsToken = process.env.DNS_ADMIN_TOKEN || ''; let dnsTokenExpiry = null; // Per-server token cache for authenticating against specific DNS servers (e.g., for updates) const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); // LRU cache: serverIp -> { token, expiry } // Tailscale configuration cache let tailscaleConfig = { enabled: false, requireAuth: false, // Require Tailscale for dashboard access allowedTailnet: null, // Restrict to specific tailnet devices: [], // Cache of known devices oauthConfigured: false, // true when OAuth credentials are stored tailnet: null, // tailnet name for API calls (e.g., "example.com" or "-") syncInterval: 300, // seconds between API syncs (default 5 min) lastSync: null // ISO timestamp of last successful sync }; // Load Tailscale config from file async function loadTailscaleConfig() { try { if (await exists(TAILSCALE_CONFIG_FILE)) { const data = await fsp.readFile(TAILSCALE_CONFIG_FILE, 'utf8'); tailscaleConfig = { ...tailscaleConfig, ...JSON.parse(data) }; log.info('config', 'Tailscale config loaded', { enabled: tailscaleConfig.enabled }); } } catch (e) { await logError('loadTailscaleConfig', e); log.warn('config', 'Could not load Tailscale config', { error: e.message }); } } // Save Tailscale config to file async function saveTailscaleConfig() { try { await writeJsonFile(TAILSCALE_CONFIG_FILE, tailscaleConfig); } catch (e) { log.error('config', 'Could not save Tailscale config', { error: e.message }); } } // Check if an IP is a Tailscale IP (100.x.x.x CGNAT range) function isTailscaleIP(ip) { if (!ip) return false; // Tailscale uses 100.64.0.0/10 CGNAT range 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; } // Get Tailscale status (cached for performance) const tailscaleStatusCache = createCache(CACHE_CONFIGS.tailscaleStatus); async function getTailscaleStatus() { const cached = tailscaleStatusCache.get('status'); if (cached) { return cached; } try { const output = execSync('tailscale status --json', { encoding: 'utf8', timeout: 5000 }); const status = JSON.parse(output); tailscaleStatusCache.set('status', status); return status; } catch (e) { log.warn('config', 'Could not get Tailscale status', { error: e.message }); return null; } } // Get the local Tailscale IP async function getLocalTailscaleIP() { try { const status = await getTailscaleStatus(); if (status && status.Self && status.Self.TailscaleIPs) { // Return first IPv4 address return status.Self.TailscaleIPs.find(ip => !ip.includes(':')); } } catch (e) { log.warn('config', 'Could not get local Tailscale IP', { error: e.message }); } return null; } // ── Tailscale OAuth 2.0 Client Credentials ── let _tsTokenCache = { token: null, expiresAt: 0 }; async function getTailscaleAccessToken() { // Return cached token if still valid (with 60s buffer) if (_tsTokenCache.token && Date.now() < _tsTokenCache.expiresAt - 60000) { return _tsTokenCache.token; } const clientId = await credentialManager.retrieve('tailscale.oauth.client_id'); const clientSecret = await credentialManager.retrieve('tailscale.oauth.client_secret'); if (!clientId || !clientSecret) return null; const res = await fetch(TAILSCALE.OAUTH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `client_id=${encodeURIComponent(clientId)}&client_secret=${encodeURIComponent(clientSecret)}&grant_type=client_credentials` }); if (!res.ok) { log.error('tailscale', 'OAuth token exchange failed', { status: res.status }); _tsTokenCache = { token: null, expiresAt: 0 }; return null; } const data = await res.json(); _tsTokenCache = { token: data.access_token, expiresAt: Date.now() + (data.expires_in || 3600) * 1000 }; return data.access_token; } // Sync device list from Tailscale API (richer than local CLI) async function syncFromTailscaleAPI() { const token = await getTailscaleAccessToken(); const tailnet = tailscaleConfig.tailnet; if (!token || !tailnet) return null; const res = await fetch(`${TAILSCALE.API_BASE}/tailnet/${encodeURIComponent(tailnet)}/devices`, { headers: { Authorization: `Bearer ${token}` } }); if (!res.ok) throw new Error(`Tailscale API: HTTP ${res.status}`); const data = await res.json(); const devices = (data.devices || []).map(d => ({ id: d.id, name: d.name, hostname: d.hostname, addresses: d.addresses || [], ip: (d.addresses || []).find(a => !a.includes(':')) || null, os: d.os, user: d.user, authorized: d.authorized, tags: d.tags || [], lastSeen: d.lastSeen, clientVersion: d.clientVersion, isExternal: d.isExternal || false })); tailscaleConfig.devices = devices; tailscaleConfig.lastSync = new Date().toISOString(); await saveTailscaleConfig(); return devices; } let _tsSyncInterval = null; function startTailscaleSyncTimer() { if (_tsSyncInterval) clearInterval(_tsSyncInterval); const interval = (tailscaleConfig.syncInterval || 300) * 1000; _tsSyncInterval = setInterval(async () => { try { await syncFromTailscaleAPI(); log.debug('tailscale', 'API sync completed', { deviceCount: tailscaleConfig.devices.length }); } catch (error) { log.warn('tailscale', 'API sync failed', { error: error.message }); } }, interval); log.info('tailscale', 'API sync enabled', { interval: interval / 1000 + 's' }); } function stopTailscaleSyncTimer() { if (_tsSyncInterval) { clearInterval(_tsSyncInterval); _tsSyncInterval = null; } } // TOTP authentication configuration let totpConfig = { enabled: false, sessionDuration: 'never', // 'never' = disabled, or '15m','30m','1h','2h','4h','8h','12h','24h' isSetUp: false // true once a secret has been verified }; async function loadTotpConfig() { try { if (await exists(TOTP_CONFIG_FILE)) { const data = await fsp.readFile(TOTP_CONFIG_FILE, 'utf8'); const loaded = JSON.parse(data); // Never load secret from file — it belongs only in credential-manager delete loaded.secret; Object.assign(totpConfig, loaded); log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled }); } } catch (e) { await logError('loadTotpConfig', e); log.warn('config', 'Could not load TOTP config', { error: e.message }); } } async function saveTotpConfig() { try { await writeJsonFile(TOTP_CONFIG_FILE, totpConfig); } catch (e) { log.error('config', 'Could not save TOTP config', { error: e.message }); } } // Load config on startup (async — resolved before server starts listening) const _configsReady = (async () => { await loadTailscaleConfig(); await loadTotpConfig(); })(); // ===== NOTIFICATION SERVICE ===== // Notification configuration let notificationConfig = { enabled: false, providers: { discord: { enabled: false, webhookUrl: '' }, telegram: { enabled: false, botToken: '', chatId: '' }, ntfy: { enabled: false, serverUrl: 'https://ntfy.sh', topic: '' } }, events: { containerDown: true, containerUp: true, deploymentSuccess: true, deploymentFailed: true, serviceError: true }, healthCheck: { enabled: false, intervalMinutes: 5, lastCheck: null } }; // Notification history (in-memory, last 100 entries) let notificationHistory = []; const MAX_NOTIFICATION_HISTORY = 100; // Load notification config from file (with decryption of sensitive fields) async function loadNotificationConfig() { try { if (await exists(NOTIFICATIONS_FILE)) { const data = await fsp.readFile(NOTIFICATIONS_FILE, 'utf8'); const loaded = JSON.parse(data); // Decrypt sensitive fields if encrypted if (loaded._encrypted && loaded.providers) { if (loaded.providers.discord?.webhookUrl && cryptoUtils.isEncrypted(loaded.providers.discord.webhookUrl)) { loaded.providers.discord.webhookUrl = cryptoUtils.decrypt(loaded.providers.discord.webhookUrl); } if (loaded.providers.telegram?.botToken && cryptoUtils.isEncrypted(loaded.providers.telegram.botToken)) { loaded.providers.telegram.botToken = cryptoUtils.decrypt(loaded.providers.telegram.botToken); } delete loaded._encrypted; } notificationConfig = { ...notificationConfig, ...loaded }; log.info('config', 'Notification config loaded', { enabled: notificationConfig.enabled }); } } catch (e) { await logError('loadNotificationConfig', e); log.warn('config', 'Could not load notification config', { error: e.message }); } } // Save notification config to file (with encryption of sensitive fields) async function saveNotificationConfig() { try { // Create a copy for encryption const toSave = JSON.parse(JSON.stringify(notificationConfig)); // Encrypt sensitive fields if (toSave.providers) { if (toSave.providers.discord?.webhookUrl) { toSave.providers.discord.webhookUrl = cryptoUtils.encrypt(toSave.providers.discord.webhookUrl); } if (toSave.providers.telegram?.botToken) { toSave.providers.telegram.botToken = cryptoUtils.encrypt(toSave.providers.telegram.botToken); } } toSave._encrypted = true; await fsp.writeFile(NOTIFICATIONS_FILE, JSON.stringify(toSave, null, 2), 'utf8'); log.info('config', 'Notification config saved (encrypted)'); } catch (e) { await logError('saveNotificationConfig', e); log.error('config', 'Could not save notification config', { error: e.message }); } } // Add to notification history function addNotificationToHistory(notification) { notificationHistory.unshift({ ...notification, timestamp: new Date().toISOString() }); if (notificationHistory.length > MAX_NOTIFICATION_HISTORY) { notificationHistory = notificationHistory.slice(0, MAX_NOTIFICATION_HISTORY); } } // Send notification via Discord webhook async function sendDiscordNotification(title, message, type = 'info') { const { webhookUrl } = notificationConfig.providers.discord; if (!webhookUrl) return { success: false, error: 'No webhook URL configured' }; const colors = { success: 0x00ff00, // Green error: 0xff0000, // Red warning: 0xffff00, // Yellow info: 0x0099ff // Blue }; const payload = { embeds: [{ title: `DashCaddy: ${title}`, description: message, color: colors[type] || colors.info, timestamp: new Date().toISOString(), footer: { text: 'DashCaddy Notifications' } }] }; try { const response = await fetchT(webhookUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Discord API returned ${response.status}`); } return { success: true }; } catch (error) { await logError('sendDiscordNotification', error); return { success: false, error: error.message }; } } // Send notification via Telegram bot async function sendTelegramNotification(title, message, type = 'info') { const { botToken, chatId } = notificationConfig.providers.telegram; if (!botToken || !chatId) return { success: false, error: 'Bot token or chat ID not configured' }; const emoji = { success: '✅', error: '❌', warning: '⚠️', info: 'ℹ️' }; const text = `${emoji[type] || emoji.info} *DashCaddy: ${title}*\n\n${message}`; try { const response = await fetchT(`https://api.telegram.org/bot${botToken}/sendMessage`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ chat_id: chatId, text: text, parse_mode: 'Markdown' }) }); const result = await response.json(); if (!result.ok) { throw new Error(result.description || 'Telegram API error'); } return { success: true }; } catch (error) { await logError('sendTelegramNotification', error); return { success: false, error: error.message }; } } // Send notification via ntfy.sh async function sendNtfyNotification(title, message, type = 'info') { const { serverUrl, topic } = notificationConfig.providers.ntfy; if (!topic) return { success: false, error: 'No topic configured' }; const priority = { success: 3, // default error: 5, // max warning: 4, // high info: 3 // default }; const tags = { success: 'white_check_mark', error: 'x', warning: 'warning', info: 'information_source' }; try { const response = await fetchT(`${serverUrl}/${topic}`, { method: 'POST', headers: { 'Title': `DashCaddy: ${title}`, 'Priority': String(priority[type] || 3), 'Tags': tags[type] || 'information_source' }, body: message }); if (!response.ok) { throw new Error(`ntfy returned ${response.status}`); } return { success: true }; } catch (error) { await logError('sendNtfyNotification', error); return { success: false, error: error.message }; } } // Send notification to all enabled providers async function sendNotification(event, title, message, type = 'info') { if (!notificationConfig.enabled) { return { sent: false, reason: 'Notifications disabled' }; } // Check if this event type is enabled if (notificationConfig.events[event] === false) { return { sent: false, reason: `Event type '${event}' is disabled` }; } const results = {}; const providers = notificationConfig.providers; if (providers.discord?.enabled) { results.discord = await sendDiscordNotification(title, message, type); } if (providers.telegram?.enabled) { results.telegram = await sendTelegramNotification(title, message, type); } if (providers.ntfy?.enabled) { results.ntfy = await sendNtfyNotification(title, message, type); } // Log to history addNotificationToHistory({ event, title, message, type, results }); return { sent: true, results }; } // Container health monitoring state let containerHealthState = {}; let healthCheckInterval = null; // Check container health and send notifications async function checkContainerHealth() { if (!notificationConfig.enabled || !notificationConfig.healthCheck?.enabled) { return; } try { const containers = await docker.listContainers({ all: true }); const services = (await exists(SERVICES_FILE)) ? await servicesStateManager.read() : []; // Create a map of container IDs to service names const serviceMap = {}; for (const service of services) { if (service.containerId) { serviceMap[service.containerId] = service.name || service.id; } } for (const container of containers) { const containerId = container.Id; const containerName = container.Names?.[0]?.replace(/^\//, '') || containerId.slice(0, 12); const serviceName = serviceMap[containerId] || containerName; const isRunning = container.State === 'running'; const previousState = containerHealthState[containerId]; // Detect state changes if (previousState !== undefined && previousState !== isRunning) { if (isRunning) { // Container came back up await sendNotification( 'containerUp', 'Container Recovered', `**${serviceName}** is now running again.`, 'success' ); } else { // Container went down await sendNotification( 'containerDown', 'Container Down', `**${serviceName}** has stopped running.\nStatus: ${container.Status}`, 'error' ); } } containerHealthState[containerId] = isRunning; } // Update last check time notificationConfig.healthCheck.lastCheck = new Date().toISOString(); } catch (error) { await logError('checkContainerHealth', error); } } // Start health check daemon function startHealthCheckDaemon() { if (healthCheckInterval) { clearInterval(healthCheckInterval); } if (!notificationConfig.healthCheck?.enabled) { log.info('health', 'Health check daemon disabled'); return; } const intervalMs = (notificationConfig.healthCheck.intervalMinutes || 5) * 60 * 1000; log.info('health', 'Starting health check daemon', { intervalMinutes: notificationConfig.healthCheck.intervalMinutes }); // Initial check checkContainerHealth(); // Periodic checks healthCheckInterval = setInterval(checkContainerHealth, intervalMs); } // Stop health check daemon function stopHealthCheckDaemon() { if (healthCheckInterval) { clearInterval(healthCheckInterval); healthCheckInterval = null; log.info('health', 'Health check daemon stopped'); } } // Load notification config on startup (async — resolved before server starts listening) const _notificationsReady = (async () => { await loadNotificationConfig(); // Start health check if enabled if (notificationConfig.healthCheck?.enabled) { startHealthCheckDaemon(); } })(); // HTTPS agent for internal Caddy CA — load cert if available, keep system CAs too 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 + system CAs', { path: CA_CERT_PATH }); } catch { httpsAgent = new https.Agent(); log.error('server', 'CA cert not found — HTTPS calls to internal services may fail', { path: CA_CERT_PATH }); } // ── Configure middleware stack (CORS, auth, rate limiting, etc.) ── const middlewareResult = configureMiddleware(app, { siteConfig, totpConfig, tailscaleConfig, metrics, auditLogger, authManager, log, cryptoUtils, isValidContainerId, isTailscaleIP, getTailscaleStatus, RATE_LIMITS, LIMITS, APP, CACHE_CONFIGS, createCache }); const { strictLimiter, SESSION_DURATIONS, ipSessions, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult; // ── Populate route context and mount extracted route modules ── // Namespaced groups Object.assign(ctx.docker, { client: docker, pull: dockerPull, findContainer: findContainerByName, getUsedPorts, security: dockerSecurity, }); Object.assign(ctx.caddy, { modify: modifyCaddyfile, read: readCaddyfile, reload: reloadCaddy, generateConfig: generateCaddyConfig, verifySite: verifySiteAccessible, adminUrl: CADDY_ADMIN_URL, filePath: CADDYFILE_PATH, }); Object.assign(ctx.dns, { call: callDns, buildUrl: buildDnsUrl, requireToken: requireDnsToken, ensureToken: ensureValidDnsToken, createRecord: createDnsRecord, getToken: () => dnsToken, setToken: (t) => { dnsToken = t; }, getTokenExpiry: () => dnsTokenExpiry, setTokenExpiry: (e) => { dnsTokenExpiry = e; }, getTokenForServer, invalidateTokenForServer: (serverIp) => { dnsServerTokens.delete(`${serverIp}:readonly`); dnsServerTokens.delete(`${serverIp}:admin`); }, refresh: refreshDnsToken, credentialsFile: DNS_CREDENTIALS_FILE, }); Object.assign(ctx.session, { ipSessions, durations: SESSION_DURATIONS, getClientIP, create: createIPSession, setCookie: setSessionCookie, clear: clearIPSession, clearCookie: clearSessionCookie, isValid: isSessionValid, }); Object.assign(ctx.notification, { getConfig: () => notificationConfig, saveConfig: saveNotificationConfig, send: sendNotification, sendDiscord: sendDiscordNotification, sendTelegram: sendTelegramNotification, sendNtfy: sendNtfyNotification, getHistory: () => notificationHistory, clearHistory: () => { notificationHistory = []; }, startHealthDaemon: startHealthCheckDaemon, stopHealthDaemon: stopHealthCheckDaemon, checkHealth: checkContainerHealth, getHealthState: () => containerHealthState, }); Object.assign(ctx.tailscale, { config: tailscaleConfig, save: saveTailscaleConfig, getStatus: getTailscaleStatus, getLocalIP: getLocalTailscaleIP, isTailscaleIP, getAccessToken: getTailscaleAccessToken, syncAPI: syncFromTailscaleAPI, startSync: startTailscaleSyncTimer, stopSync: stopTailscaleSyncTimer, }); // Flat properties (shared across domains) Object.assign(ctx, { app, siteConfig, servicesStateManager, configStateManager, credentialManager, authManager, licenseManager, healthChecker, updateManager, backupManager, resourceMonitor, auditLogger, portLockManager, selfUpdater, dockerMaintenance, logDigest, APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES, asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage, buildDomain, buildServiceUrl, getServiceById, readConfig, saveConfig, addServiceToConfig, validateURL, strictLimiter, totpConfig, saveTotpConfig, loadSiteConfig, loadNotificationConfig, loadDnsCredentials: () => {}, SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE, NOTIFICATIONS_FILE, ERROR_LOG_FILE, }); // Build versioned API router — all route modules attach here const apiRouter = express.Router(); apiRouter.use(authRoutes(ctx)); apiRouter.use(configRoutes(ctx)); apiRouter.use('/dns', dnsRoutes(ctx)); apiRouter.use('/notifications', notificationRoutes(ctx)); apiRouter.use('/containers', containerRoutes(ctx)); apiRouter.use(serviceRoutes(ctx)); apiRouter.use(healthRoutes(ctx)); apiRouter.use(monitoringRoutes(ctx)); apiRouter.use(updatesRoutes(ctx)); apiRouter.use(tailscaleRoutes(ctx)); apiRouter.use(sitesRoutes(ctx)); apiRouter.use(credentialsRoutes(ctx)); apiRouter.use(arrRoutes(ctx)); apiRouter.use(appsRoutes(ctx)); apiRouter.use(logsRoutes(ctx)); apiRouter.use(backupsRoutes(ctx)); apiRouter.use('/ca', caRoutes(ctx)); apiRouter.use(browseRoutes(ctx)); apiRouter.use(errorLogsRoutes(ctx)); apiRouter.use('/license', licenseRoutes(ctx)); apiRouter.use('/recipes', recipesRoutes(ctx)); apiRouter.use(themesRoutes(ctx)); // Inline routes on the API router 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 compat for tests & external consumers) app.use('/api/v1', apiRouter); app.use('/api', apiRouter); // Root-level health check (no /api prefix) app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); // Lightweight probe endpoint - performs real health checks for frontend status dots app.get('/probe/:id', asyncHandler(async (req, res) => { const id = req.params.id; try { let url; if (id === 'internet') { // Internet connectivity check url = 'https://www.google.com'; } else { // Look up service in services.json for custom URLs let service = null; if (await exists(SERVICES_FILE)) { const data = await servicesStateManager.read(); const services = Array.isArray(data) ? data : data.services || []; service = services.find(s => s.id === id); } if (service?.isExternal && service?.externalUrl) { url = service.externalUrl; } else if (service?.url) { url = service.url.startsWith('http') ? service.url : `https://${service.url}`; } else { // Build URL from configured routing mode (subdomain or subdirectory) url = buildServiceUrl(id); } } const parsed = new URL(url); const isHttps = parsed.protocol === 'https:'; const lib = isHttps ? https : 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'); // Fall back to GET if HEAD is not supported if (statusCode === 501 || statusCode === 405) { statusCode = await makeRequest('GET'); } } catch { // Direct URL failed (e.g. HTTP/1.0 parse errors) — try via Caddy domain const fallbackUrl = `https://${buildDomain(id)}`; const fp = new URL(fallbackUrl); const fLib = require('https'); statusCode = await new Promise((resolve, reject) => { const fReq = fLib.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(); } catch { res.status(502).send(); } }, 'probe')); // Get network IPs (LAN, Tailscale) for quick selection app.get('/api/network/ips', (req, res) => { try { // Prefer environment variables (set in docker-compose.yml) 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 env vars not set, try to detect from network interfaces if (!envLan || !envTailscale) { const interfaces = os.networkInterfaces(); for (const [name, addrs] of Object.entries(interfaces)) { for (const addr of addrs) { // Skip internal and IPv6 if (addr.internal || addr.family !== 'IPv4') continue; const ip = addr.address; result.all.push({ name, ip }); // Detect Tailscale (100.x.x.x range) if (!result.tailscale && ip.startsWith('100.')) { result.tailscale = ip; } // Detect common LAN ranges 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; } } } } // Return null if not detected — let the frontend handle it if (!result.lan) result.lan = null; if (!result.tailscale) result.tailscale = null; res.json(result); } catch (error) { errorResponse(res, 500, safeErrorMessage(error)); } }); // (TOTP/auth inline routes moved to routes/auth.js) // (SSO auth gate + getAppSession moved to routes/auth.js) // (Tailscale routes moved to routes/tailscale.js) // (Caddy/site routes moved to routes/sites.js) // (Assets, config, backup routes moved to routes/config.js) // (Credential management routes moved to routes/credentials.js) // ===== DNS TOKEN AUTO-REFRESH FUNCTIONS ===== async function refreshDnsToken(username, password, server) { try { // Use /api/user/login to get a session token 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' }, timeout: 10000 } ); const result = await response.json(); if (result.status === 'ok' && result.token) { dnsToken = result.token; // Technitium session tokens expire after inactivity, refresh every 6 hours to keep alive 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 }; } } async function ensureValidDnsToken() { // Check if token is valid and not expired if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { return { success: true, token: dnsToken }; } // Try per-server admin credentials for the primary DNS server const primaryIp = siteConfig.dnsServerIp; if (primaryIp) { const dnsId = dnsIpToDnsId(primaryIp); if (dnsId) { // Try admin credentials first (used for DNS record operations) for (const role of ['admin', 'readonly']) { 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) { log.error('dns', `Per-server ${role} credential error for primary DNS`, { dnsId, error: err.message }); } } } } // Fall back to global credentials 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) { log.error('dns', 'Credential manager error', { error: err.message }); } return { success: false, error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials' }; } // Map a DNS server IP to its dnsId (dns1, dns2, dns3) using config function dnsIpToDnsId(serverIp) { for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { if (info.ip === serverIp) return dnsId; } return null; } // Get a valid token for a specific DNS server (authenticates directly against that server) // role: 'readonly' for logs, 'admin' for records/restart/updates (defaults to 'readonly') async function getTokenForServer(targetServer, role = 'readonly') { const cacheKey = `${targetServer}:${role}`; // Check cached per-server token first const cached = dnsServerTokens.get(cacheKey); if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) { return { success: true, token: cached.token }; } const serverPort = siteConfig.dnsServerPort || '5380'; // Helper to authenticate against a DNS server via login async function authenticateToServer(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); // Try per-server credentials with the requested role first if (dnsId) { try { const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); if (username && password) { return await authenticateToServer(username, password); } } catch (err) { log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message }); } // Fall back to the other role (readonly -> admin or admin -> readonly) const fallbackRole = role === 'readonly' ? 'admin' : 'readonly'; try { const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`); const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`); if (username && password) { return await authenticateToServer(username, password); } } catch (err) { // ignore fallback errors } } // 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 authenticateToServer(username, password); } } catch (err) { log.error('dns', 'Credential manager error', { server: targetServer, error: err.message }); } return { success: false, error: 'No DNS credentials configured' }; } // Load credentials and refresh token on startup (async function initDnsToken() { if (dnsToken) { log.info('dns', 'Using DNS token from environment variable'); return; } // Get token using credential manager const result = await ensureValidDnsToken(); if (result.success) { log.info('dns', 'DNS token obtained from stored credentials'); } else if (await credentialManager.retrieve('dns.username')) { log.warn('dns', 'Failed to get DNS token', { error: result.error }); } else { log.info('dns', 'No DNS credentials configured - DNS record management unavailable'); } })(); // (Arr stack routes moved to routes/arr.js) // (App deployment routes moved to routes/apps.js) // (Container management routes moved to routes/containers.js) // (Docker helper functions moved to routes/apps.js) function generateCaddyConfig(subdomain, ip, port, options = {}) { const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; // Subdirectory mode: generate handle/handle_path block (injected into main domain block) if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { let config = ''; // Native-support apps: use handle (preserve path prefix) // Strip-mode apps: use handle_path (remove path prefix before proxying) if (subpathSupport === 'native') { config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; config += `\thandle /${subdomain}/* {\n`; } else { config += `\thandle_path /${subdomain}/* {\n`; } if (tailscaleOnly) { config += `\t\t@blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; } config += `\t\treverse_proxy ${ip}:${port}\n`; config += `\t}`; return config; } // Subdomain mode (default): standalone domain block let config = `${buildDomain(subdomain)} {\n`; if (tailscaleOnly) { config += ` @blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) { config += ` ${allowedIPs.join(' ')}`; } config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`; } config += ` reverse_proxy ${ip}:${port}\n`; config += ` tls internal\n`; config += `}`; return config; } // (generateStaticSiteConfig, addCaddyConfig, pullImage moved to routes/apps.js) async function reloadCaddy(content) { const maxRetries = RETRIES.CADDY_RELOAD; let lastError = null; for (let i = 0; i < maxRetries; i++) { try { const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { method: 'POST', headers: { 'Content-Type': CADDY.CONTENT_TYPE }, body: content }); if (response.ok) { log.info('caddy', 'Caddy configuration reloaded successfully'); // Wait a moment for Caddy to fully apply the config await new Promise(resolve => setTimeout(resolve, 1000)); return; } lastError = await response.text(); log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); } catch (error) { lastError = error.message; log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); } if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); } async function verifySiteAccessible(domain, maxAttempts = 5) { const delay = 2000; for (let i = 0; i < maxAttempts; i++) { try { // Try HTTPS first (internal CA) const response = await fetchT(`https://${domain}/`, { method: 'HEAD', agent: httpsAgent, // Ignore cert errors for internal CA timeout: 5000 }); // Any response (even 4xx) means Caddy is serving the site log.info('caddy', 'Site is accessible', { domain, status: response.status }); return true; } catch (error) { log.debug('caddy', 'Site verification attempt', { domain, attempt: i + 1, maxAttempts, error: error.message }); } if (i < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, delay)); } } log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); return false; } async function createDnsRecord(subdomain, ip) { // Ensure we have a valid token (auto-refresh if needed) const tokenResult = await ensureValidDnsToken(); if (!tokenResult.success) { throw new Error(`DNS token not available: ${tokenResult.error}. Configure credentials via POST /api/dns/credentials`); } const domain = buildDomain(subdomain); const zone = siteConfig.tld.replace(/^\./, ''); const dnsParams = { token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: '300', overwrite: 'true' }; const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams); try { log.info('dns', 'Creating DNS record', { domain, ip }); const result = await callDnsApi(); if (result.status === 'ok') { log.info('dns', 'DNS record created', { domain, ip }); return { success: true }; } // Check for token expired error - try to refresh once if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) { log.info('dns', 'Token appears expired, attempting auto-refresh'); const refreshResult = await ensureValidDnsToken(); if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`); const retryResult = await callDnsApi(); if (retryResult.status === 'ok') { log.info('dns', 'DNS record created after token refresh', { domain, ip }); return { success: true }; } throw new Error(retryResult.errorMessage || 'Unknown error after token refresh'); } throw new Error(result.errorMessage || 'Unknown error'); } catch (error) { throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`); } } async function addServiceToConfig(service) { try { await servicesStateManager.update(services => { // Check if service already exists const existingIndex = services.findIndex(s => s.id === service.id); if (existingIndex >= 0) { // Update existing service services[existingIndex] = { ...services[existingIndex], ...service }; } else { // Add new service services.push(service); } return services; }); log.info('deploy', 'Service added to config', { serviceId: service.id }); } catch (error) { log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message }); throw error; } } // (Notification routes moved to routes/notifications.js) // (Stats routes moved to routes/monitoring.js) // (Container logs routes moved to routes/logs.js) // (Health service routes moved to routes/health.js) // (Resource monitoring routes moved to routes/monitoring.js) // (Backup routes moved to routes/backups.js) // (CA routes moved to routes/ca.js) // (Error log, audit log, browse/media routes moved to route modules) // API Documentation endpoint 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', asyncHandler(async (req, res) => { 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')); // JSON 404 catch-all for unmatched API routes app.use('/api', (req, res) => { res.status(404).json({ success: false, error: `Not found: ${req.method} ${req.path}` }); }); // Global error handler for typed errors app.use((err, req, res, next) => { if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, error: err.message, code: err.code, ...(err.details ? { details: err.details } : {}) }); } if (err instanceof ValidationError) { return res.status(err.statusCode || 400).json({ success: false, error: err.message, errors: err.errors || undefined }); } // Catch-all: never leak stack traces or internal paths const status = err.status || err.statusCode || 500; log.error('server', 'Unhandled error', { error: err.message, path: req.path, method: req.method }); res.status(status).json({ success: false, error: status === 413 ? 'Request payload too large' : 'An internal error occurred' }); }); // Export app for testing module.exports = app; if (require.main === module) { // Validate configuration and wait for async config loads before starting server (async () => { await Promise.all([_configsReady, _notificationsReady]); await licenseManager.load(); await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }); const server = app.listen(PORT, '0.0.0.0', () => { log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE }); if (BROWSE_ROOTS.length > 0) { log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) }); } // Start new feature modules log.info('server', 'Starting DashCaddy feature modules'); // Clean up stale port locks (async () => { try { await portLockManager.cleanupStaleLocks(); log.info('server', 'Port lock cleanup completed'); } catch (error) { log.error('server', 'Port lock cleanup failed', { error: error.message }); } })(); try { resourceMonitor.start(); log.info('server', 'Resource monitoring started'); } catch (error) { log.error('server', 'Resource monitoring failed to start', { error: error.message }); } try { backupManager.start(); log.info('server', 'Backup manager started'); } catch (error) { log.error('server', 'Backup manager failed to start', { error: error.message }); } (async () => { try { // Auto-configure health checker from services.json await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP }); healthChecker.start(); log.info('server', 'Health checker started'); } catch (error) { log.error('server', 'Health checker failed to start', { error: error.message }); } })(); try { updateManager.start(); log.info('server', 'Update manager started'); } catch (error) { log.error('server', 'Update manager failed to start', { error: error.message }); } try { selfUpdater.start(); log.info('server', 'Self-updater started', { interval: selfUpdater.config.checkInterval, url: selfUpdater.config.updateUrl }); // Check for post-update result (did a previous update succeed or roll back?) selfUpdater.checkPostUpdateResult().then(result => { if (result) { log.info('server', 'Post-update result', result); if (typeof ctx.notification?.send === 'function') { ctx.notification.send('system.update', result.success ? 'DashCaddy Updated' : 'DashCaddy Update Failed', result.success ? `Updated to v${result.version}` : `Update failed: ${result.error || 'Unknown'}. Rolled back.`, result.success ? 'info' : 'error' ); } } }).catch(() => {}); } catch (error) { log.error('server', 'Self-updater failed to start', { error: error.message }); } try { dockerMaintenance.start(); log.info('server', 'Docker maintenance started'); dockerMaintenance.on('maintenance-complete', (result) => { const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); if (saved > 0 || result.warnings.length > 0) { log.info('maintenance', 'Docker maintenance completed', { spaceReclaimedMB: saved, pruned: result.pruned, warnings: result.warnings.length }); } if (result.warnings.length > 0) { for (const w of result.warnings) log.warn('maintenance', w); } }); } catch (error) { log.error('server', 'Docker maintenance failed to start', { error: error.message }); } try { logDigest.start(platformPaths.digestDir); log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); logDigest.on('digest-generated', ({ date }) => { log.info('digest', `Daily digest generated for ${date}`); if (typeof ctx.notification?.send === 'function') { ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); } }); } catch (error) { log.error('server', 'Log digest failed to start', { error: error.message }); } // Tailscale API sync (if OAuth configured) if (tailscaleConfig.oauthConfigured) { startTailscaleSyncTimer(); // Run initial sync syncFromTailscaleAPI().catch(e => log.warn('tailscale', 'Initial API sync failed', { error: e.message })); } log.info('server', 'All feature modules initialized'); }); // Graceful shutdown — drain connections before exiting function shutdown(signal) { log.info('shutdown', `${signal} received, draining connections...`); resourceMonitor.stop(); backupManager.stop(); dockerMaintenance.stop(); logDigest.stop(); healthChecker.stop(); updateManager.stop(); selfUpdater.stop(); stopTailscaleSyncTimer(); server.close(() => { log.info('shutdown', 'HTTP server closed'); process.exit(0); }); // Force exit after 5s if connections don't drain setTimeout(() => process.exit(0), 5000).unref(); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); })(); // end async startup } // end if (require.main === module) // #2: Catch unhandled errors so the process doesn't crash silently process.on('unhandledRejection', (reason) => { logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason))); }); process.on('uncaughtException', (error) => { logError('uncaughtException', error); // Give the error log time to flush, then exit setTimeout(() => process.exit(1), 1000).unref(); });