Files
dashcaddy/dashcaddy-api/server.js
Krystie 970e862533 refactor(routes): Phase 3.4 - standardize dns.js with explicit dependencies
- Replaced god object ctx with explicit dependency injection
- Added JSDoc documenting required dependencies (7 deps vs 50+)
- Updated response calls to use response-helpers (success/error)
- Dependencies: dns, siteConfig, asyncHandler, log, safeErrorMessage, fetchT, credentialManager
- DNS record management, Technitium proxy, credential storage all preserved
- 632 lines, now self-documenting and testable
2026-03-28 19:28:17 -07:00

2000 lines
69 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(ctx));
apiRouter.use(updatesRoutes(ctx));
apiRouter.use('/tailscale', tailscaleRoutes(ctx));
apiRouter.use(sitesRoutes(ctx));
apiRouter.use(credentialsRoutes(ctx));
apiRouter.use(arrRoutes(ctx));
apiRouter.use(appsRoutes(ctx));
apiRouter.use(logsRoutes(ctx));
apiRouter.use(backupsRoutes(ctx));
apiRouter.use('/ca', caRoutes(ctx));
apiRouter.use(browseRoutes(ctx));
apiRouter.use(errorLogsRoutes(ctx));
apiRouter.use('/license', licenseRoutes(ctx));
apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes(ctx));
// Inline routes on the API router
apiRouter.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
apiRouter.get('/csrf-token', (req, res) => {
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
});
apiRouter.get('/metrics', (req, res) => {
res.json({ success: true, metrics: metrics.getSummary() });
});
// Mount at /api/v1 (canonical) and /api (legacy compat for tests & external consumers)
app.use('/api/v1', apiRouter);
app.use('/api', apiRouter);
// Root-level health check (no /api prefix)
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Lightweight probe endpoint - performs real health checks for frontend status dots
app.get('/probe/:id', asyncHandler(async (req, res) => {
const id = req.params.id;
try {
// 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(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>DashCaddy API Documentation</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"/>
<style>body{margin:0} .swagger-ui .topbar{display:none}</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>SwaggerUIBundle({url:'/api/docs/spec',dom_id:'#swagger-ui',deepLinking:true})</script>
</body>
</html>`);
});
app.get('/api/docs/spec', asyncHandler(async (req, res) => {
const specPath = path.join(__dirname, 'openapi.yaml');
if (await exists(specPath)) {
const yaml = await fsp.readFile(specPath, 'utf8');
res.type('text/yaml').send(yaml);
} else {
errorResponse(res, 404, 'OpenAPI spec not found');
}
}, 'api-docs-spec'));
// JSON 404 catch-all for unmatched API routes
app.use('/api', (req, res) => {
res.status(404).json({ success: false, error: `Not found: ${req.method} ${req.path}` });
});
// Global error handler for typed errors
app.use((err, req, res, next) => {
if (err instanceof AppError) {
return res.status(err.statusCode).json({
success: false,
error: err.message,
code: err.code,
...(err.details ? { details: err.details } : {})
});
}
if (err instanceof ValidationError) {
return res.status(err.statusCode || 400).json({
success: false,
error: err.message,
errors: err.errors || undefined
});
}
// Catch-all: never leak stack traces or internal paths
const status = err.status || err.statusCode || 500;
log.error('server', 'Unhandled error', { error: err.message, path: req.path, method: req.method });
res.status(status).json({ success: false, error: status === 413 ? 'Request payload too large' : 'An internal error occurred' });
});
// Export app for testing
module.exports = app;
if (require.main === module) {
// Validate configuration and wait for async config loads before starting server
(async () => {
await Promise.all([_configsReady, _notificationsReady]);
await licenseManager.load();
await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT });
const server = app.listen(PORT, '0.0.0.0', () => {
log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE });
if (BROWSE_ROOTS.length > 0) {
log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) });
}
// Start new feature modules
log.info('server', 'Starting DashCaddy feature modules');
// Clean up stale port locks
(async () => {
try {
await portLockManager.cleanupStaleLocks();
log.info('server', 'Port lock cleanup completed');
} catch (error) {
log.error('server', 'Port lock cleanup failed', { error: error.message });
}
})();
try {
resourceMonitor.start();
log.info('server', 'Resource monitoring started');
} catch (error) {
log.error('server', 'Resource monitoring failed to start', { error: error.message });
}
try {
backupManager.start();
log.info('server', 'Backup manager started');
} catch (error) {
log.error('server', 'Backup manager failed to start', { error: error.message });
}
(async () => {
try {
// Auto-configure health checker from services.json
await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, 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();
});