diff --git a/dashcaddy-api/server-old.js b/dashcaddy-api/server-old.js
new file mode 100644
index 0000000..0a82307
--- /dev/null
+++ b/dashcaddy-api/server-old.js
@@ -0,0 +1,1997 @@
+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 { resolveServiceUrl } = require('./url-resolver');
+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');
+let dockerMaintenance;
+try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { logger.warn('[WARN] docker-maintenance module not found, skipped'); }
+let logDigest;
+try { logDigest = require('./log-digest'); } catch (_) { logger.warn('[WARN] log-digest module not found, skipped'); }
+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 responseHelpers = require('./response-helpers');
+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';
+ siteConfig.pylon = raw.pylon || null; // { url, key? } — health check relay
+ }
+ } 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' ? logger.error : level === 'warn' ? logger.warn : logger.info;
+ 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,
+ resyncHealthChecker: () => syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }),
+});
+
+// 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({
+ 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(ctx));
+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 }));
+
+// 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 {
+ // Look up service in services.json
+ let service = null;
+ if (id !== 'internet' && await exists(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, siteConfig, buildServiceUrl);
+
+ 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 });
+ // Sync health checker with updated services list
+ ctx.resyncHealthChecker?.().catch(() => {});
+ } 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'));
+
+// Unified error handlers (order matters!)
+const { notFoundHandler, errorMiddleware } = require('./error-handler');
+
+// 404 handler for unmatched API routes
+app.use('/api', notFoundHandler);
+
+// Global error handler (MUST be last middleware)
+app.use(errorMiddleware);
+
+// 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, buildServiceUrl, siteConfig, 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 });
+ }
+
+ if (dockerMaintenance) {
+ 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 });
+ }
+ }
+
+ if (logDigest) {
+ 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();
+ if (dockerMaintenance) dockerMaintenance.stop();
+ if (logDigest) 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();
+});
+
+
diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js
index 0a82307..10a9d73 100644
--- a/dashcaddy-api/server.js
+++ b/dashcaddy-api/server.js
@@ -1,1997 +1,230 @@
-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 { resolveServiceUrl } = require('./url-resolver');
-const {
- APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES,
- SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS,
- REGEX, DNS_RECORD_TYPES, DOCKER, TAILSCALE, buildMediaAuth,
-} = require('./constants');
+/**
+ * DashCaddy API Server - Entry Point
+ * Minimal startup script - all logic moved to src/
+ */
+const { createApp } = require('./src/app');
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');
-let dockerMaintenance;
-try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { logger.warn('[WARN] docker-maintenance module not found, skipped'); }
-let logDigest;
-try { logDigest = require('./log-digest'); } catch (_) { logger.warn('[WARN] log-digest module not found, skipped'); }
-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 responseHelpers = require('./response-helpers');
-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';
- siteConfig.pylon = raw.pylon || null; // { url, key? } — health check relay
- }
- } 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' ? logger.error : level === 'warn' ? logger.warn : logger.info;
- 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,
- resyncHealthChecker: () => syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }),
-});
-
-// 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({
- 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(ctx));
-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 }));
-
-// 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 {
- // Look up service in services.json
- let service = null;
- if (id !== 'internet' && await exists(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, siteConfig, buildServiceUrl);
-
- 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 });
- // Sync health checker with updated services list
- ctx.resyncHealthChecker?.().catch(() => {});
- } 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'));
-
-// Unified error handlers (order matters!)
-const { notFoundHandler, errorMiddleware } = require('./error-handler');
-
-// 404 handler for unmatched API routes
-app.use('/api', notFoundHandler);
-
-// Global error handler (MUST be last middleware)
-app.use(errorMiddleware);
-
-// 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, buildServiceUrl, siteConfig, 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 });
- }
-
- if (dockerMaintenance) {
- 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 });
- }
- }
-
- if (logDigest) {
- 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();
- if (dockerMaintenance) dockerMaintenance.stop();
- if (logDigest) 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
+// Unhandled error handlers
process.on('unhandledRejection', (reason) => {
- logError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
+ console.error('[FATAL] Unhandled Promise Rejection:', reason);
+ process.exit(1);
});
+
process.on('uncaughtException', (error) => {
- logError('uncaughtException', error);
- // Give the error log time to flush, then exit
+ console.error('[FATAL] Uncaught Exception:', error);
setTimeout(() => process.exit(1), 1000).unref();
});
+// Main startup
+(async () => {
+ try {
+ // Create and configure Express app
+ const { app, log, config, licenseManager } = await createApp();
+
+ // Load license
+ await licenseManager.load();
+ const PORT = process.env.PORT || 3001;
+ 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 CONFIG_FILE = process.env.CONFIG_FILE || platformPaths.servicesFile.replace('services.json', 'config.json');
+
+ // Validate startup configuration
+ const { validateStartupConfig } = require('./startup-validator');
+ await validateStartupConfig({
+ log,
+ CADDYFILE_PATH,
+ SERVICES_FILE,
+ CONFIG_FILE,
+ CADDY_ADMIN_URL,
+ PORT
+ });
+
+ // Start HTTP server
+ 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,
+ environment: process.env.NODE_ENV || 'production'
+ });
+
+ // Start 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 portLockManager = require('./port-lock-manager');
+
+ // Optional modules
+ let dockerMaintenance, logDigest;
+ try { dockerMaintenance = require('./docker-maintenance'); } catch (_) {}
+ try { logDigest = require('./log-digest'); } catch (_) {}
+
+ log.info('server', 'Starting feature modules');
+
+ // Clean up stale port locks
+ portLockManager.cleanupStaleLocks()
+ .then(() => log.info('server', 'Port lock cleanup completed'))
+ .catch(err => log.error('server', 'Port lock cleanup failed', { error: err.message }));
+
+ // Resource monitoring
+ try {
+ resourceMonitor.start();
+ log.info('server', 'Resource monitoring started');
+ } catch (err) {
+ log.error('server', 'Resource monitoring failed to start', { error: err.message });
+ }
+
+ // Backup manager
+ try {
+ backupManager.start();
+ log.info('server', 'Backup manager started');
+ } catch (err) {
+ log.error('server', 'Backup manager failed to start', { error: err.message });
+ }
+
+ // Health checker (with service sync)
+ (async () => {
+ try {
+ const { syncHealthCheckerServices } = require('./startup-validator');
+ const StateManager = require('./state-manager');
+ const servicesStateManager = new StateManager(SERVICES_FILE);
+
+ await syncHealthCheckerServices({
+ log,
+ SERVICES_FILE,
+ servicesStateManager,
+ healthChecker,
+ buildServiceUrl: (subdomain) => config.routingMode === 'subdirectory' && config.domain
+ ? `https://${config.domain}/${subdomain}`
+ : `https://${subdomain}${config.tld}`,
+ siteConfig: config,
+ APP: require('./constants').APP
+ });
+
+ healthChecker.start();
+ log.info('server', 'Health checker started');
+ } catch (err) {
+ log.error('server', 'Health checker failed to start', { error: err.message });
+ }
+ })();
+
+ // Update manager
+ try {
+ updateManager.start();
+ log.info('server', 'Update manager started');
+ } catch (err) {
+ log.error('server', 'Update manager failed to start', { error: err.message });
+ }
+
+ // Self-updater
+ try {
+ selfUpdater.start();
+ log.info('server', 'Self-updater started', {
+ interval: selfUpdater.config.checkInterval,
+ url: selfUpdater.config.updateUrl
+ });
+
+ selfUpdater.checkPostUpdateResult()
+ .then(result => {
+ if (result) {
+ log.info('server', 'Post-update result', result);
+ }
+ })
+ .catch(() => {});
+ } catch (err) {
+ log.error('server', 'Self-updater failed to start', { error: err.message });
+ }
+
+ // Docker maintenance (optional)
+ if (dockerMaintenance) {
+ 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 (err) {
+ log.error('server', 'Docker maintenance failed to start', { error: err.message });
+ }
+ }
+
+ // Log digest (optional)
+ if (logDigest) {
+ 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}`);
+ });
+ } catch (err) {
+ log.error('server', 'Log digest failed to start', { error: err.message });
+ }
+ }
+
+ log.info('server', 'All feature modules initialized');
+ });
+
+ // Graceful shutdown
+ function shutdown(signal) {
+ log.info('shutdown', `${signal} received, draining connections...`);
+
+ 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');
+
+ resourceMonitor.stop();
+ backupManager.stop();
+ healthChecker.stop();
+ updateManager.stop();
+ selfUpdater.stop();
+
+ try {
+ const dockerMaintenance = require('./docker-maintenance');
+ dockerMaintenance.stop();
+ } catch (_) {}
+
+ try {
+ const logDigest = require('./log-digest');
+ logDigest.stop();
+ } catch (_) {}
+
+ 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'));
+
+ } catch (error) {
+ console.error('[FATAL] Server startup failed:', error);
+ process.exit(1);
+ }
+})();
+
+// Export for testing
+module.exports = require('./src/app');
diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js
new file mode 100644
index 0000000..ca45e4a
--- /dev/null
+++ b/dashcaddy-api/src/app.js
@@ -0,0 +1,539 @@
+/**
+ * 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(ctx));
+ 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 };