Full codebase including API server (32 modules + routes), dashboard frontend, DashCA certificate distribution, installer script, and deployment skills.
1862 lines
63 KiB
JavaScript
1862 lines
63 KiB
JavaScript
const express = require('express');
|
||
const crypto = require('crypto');
|
||
const fs = require('fs');
|
||
const fsp = require('fs').promises;
|
||
const { exists, readJsonFile, writeJsonFile, readTextFile, isAccessible } = require('./fs-helpers');
|
||
const os = require('os');
|
||
const http = require('http');
|
||
const https = require('https');
|
||
const { execSync } = require('child_process');
|
||
const path = require('path');
|
||
const {
|
||
ValidationError, validateFilePath, validateURL, validateToken,
|
||
validateServiceConfig, sanitizeString, isValidPort, validateSecurePath
|
||
} = require('./input-validator');
|
||
const validatorLib = require('validator');
|
||
const credentialManager = require('./credential-manager');
|
||
const { CACHE_CONFIGS, createCache } = require('./cache-config');
|
||
const { AppError } = require('./errors');
|
||
const { validateConfig } = require('./config-schema');
|
||
const {
|
||
APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES,
|
||
SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS,
|
||
REGEX, DNS_RECORD_TYPES, DOCKER, TAILSCALE, buildMediaAuth,
|
||
} = require('./constants');
|
||
const platformPaths = require('./platform-paths');
|
||
|
||
// Image processing for favicon conversion
|
||
let sharp, pngToIco;
|
||
try {
|
||
sharp = require('sharp');
|
||
pngToIco = require('png-to-ico');
|
||
} catch (e) {
|
||
log.warn('server', 'Image processing libraries not available - favicon conversion disabled');
|
||
}
|
||
|
||
// Docker integration
|
||
const Docker = require('dockerode');
|
||
const docker = new Docker();
|
||
|
||
// App templates
|
||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('./app-templates');
|
||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('./recipe-templates');
|
||
|
||
// Crypto utilities for credential encryption
|
||
const cryptoUtils = require('./crypto-utils');
|
||
|
||
// New feature modules
|
||
const resourceMonitor = require('./resource-monitor');
|
||
const backupManager = require('./backup-manager');
|
||
const healthChecker = require('./health-checker');
|
||
const updateManager = require('./update-manager');
|
||
const StateManager = require('./state-manager');
|
||
const auditLogger = require('./audit-logger');
|
||
const portLockManager = require('./port-lock-manager');
|
||
const dockerSecurity = require('./docker-security');
|
||
const authManager = require('./auth-manager');
|
||
const configureMiddleware = require('./middleware');
|
||
const { validateStartupConfig, syncHealthCheckerServices } = require('./startup-validator');
|
||
const { CSRF_HEADER_NAME } = require('./csrf-protection');
|
||
|
||
// Route modules
|
||
const ctx = require('./routes/context');
|
||
const healthRoutes = require('./routes/health');
|
||
const monitoringRoutes = require('./routes/monitoring');
|
||
const updatesRoutes = require('./routes/updates');
|
||
const authRoutes = require('./routes/auth');
|
||
const configRoutes = require('./routes/config');
|
||
const dnsRoutes = require('./routes/dns');
|
||
const notificationRoutes = require('./routes/notifications');
|
||
const containerRoutes = require('./routes/containers');
|
||
const serviceRoutes = require('./routes/services');
|
||
const tailscaleRoutes = require('./routes/tailscale');
|
||
const sitesRoutes = require('./routes/sites');
|
||
const credentialsRoutes = require('./routes/credentials');
|
||
const arrRoutes = require('./routes/arr');
|
||
const appsRoutes = require('./routes/apps');
|
||
const logsRoutes = require('./routes/logs');
|
||
const backupsRoutes = require('./routes/backups');
|
||
const caRoutes = require('./routes/ca');
|
||
const browseRoutes = require('./routes/browse');
|
||
const errorLogsRoutes = require('./routes/errorlogs');
|
||
const licenseRoutes = require('./routes/license');
|
||
const recipesRoutes = require('./routes/recipes');
|
||
const themesRoutes = require('./routes/themes');
|
||
const { LicenseManager } = require('./license-manager');
|
||
const metrics = require('./metrics');
|
||
|
||
const app = express();
|
||
const PORT = APP.PORT;
|
||
|
||
// Configuration from environment variables
|
||
const CADDYFILE_PATH = process.env.CADDYFILE_PATH || platformPaths.caddyfile;
|
||
const CADDY_ADMIN_URL = process.env.CADDY_ADMIN_URL || platformPaths.caddyAdminUrl;
|
||
const SERVICES_FILE = process.env.SERVICES_FILE || platformPaths.servicesFile;
|
||
const SERVICES_DIR = path.dirname(SERVICES_FILE);
|
||
const CONFIG_FILE = process.env.CONFIG_FILE || path.join(SERVICES_DIR, 'config.json');
|
||
const DNS_CREDENTIALS_FILE = process.env.DNS_CREDENTIALS_FILE || path.join(SERVICES_DIR, 'dns-credentials.json');
|
||
const TAILSCALE_CONFIG_FILE = process.env.TAILSCALE_CONFIG_FILE || path.join(SERVICES_DIR, 'tailscale-config.json');
|
||
const NOTIFICATIONS_FILE = process.env.NOTIFICATIONS_FILE || path.join(SERVICES_DIR, 'notifications.json');
|
||
const TOTP_CONFIG_FILE = process.env.TOTP_CONFIG_FILE || path.join(SERVICES_DIR, 'totp-config.json');
|
||
const ERROR_LOG_FILE = process.env.ERROR_LOG_FILE || path.join(__dirname, 'dashcaddy-errors.log');
|
||
const MAX_ERROR_LOG_SIZE = LIMITS.ERROR_LOG_SIZE;
|
||
const BROWSE_ROOTS = (process.env.MEDIA_BROWSE_ROOTS || '')
|
||
.split(',')
|
||
.filter(r => r.includes('='))
|
||
.map(r => {
|
||
const eqIndex = r.indexOf('=');
|
||
const containerPath = r.slice(0, eqIndex).trim();
|
||
const hostPath = r.slice(eqIndex + 1).trim();
|
||
return { containerPath, hostPath };
|
||
});
|
||
|
||
// State management with file locking (prevents data corruption)
|
||
const servicesStateManager = new StateManager(SERVICES_FILE);
|
||
const configStateManager = new StateManager(CONFIG_FILE);
|
||
|
||
// License manager for premium feature gating
|
||
const licenseManager = new LicenseManager(credentialManager, CONFIG_FILE, console);
|
||
const LICENSE_SECRET_FILE = process.env.LICENSE_SECRET_FILE || path.join(__dirname, '.license-secret');
|
||
licenseManager.loadSecret(LICENSE_SECRET_FILE);
|
||
|
||
// ===== Site configuration loaded from config.json (#5) =====
|
||
// These are read at startup and refreshed on config save.
|
||
// All code should use these instead of hardcoded values.
|
||
let siteConfig = { tld: '.home', caName: '', dnsServerIp: '', dnsServerPort: CADDY.DEFAULT_DNS_PORT, dashboardHost: '', timezone: 'UTC', dnsServers: {}, configurationType: 'homelab', domain: '' };
|
||
|
||
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 || '';
|
||
}
|
||
} 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 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 req = http.request(options, (res) => {
|
||
let data = '';
|
||
res.on('data', chunk => data += chunk);
|
||
res.on('end', () => {
|
||
resolve({
|
||
ok: res.statusCode >= 200 && res.statusCode < 300,
|
||
status: res.statusCode,
|
||
statusText: res.statusMessage,
|
||
json: () => Promise.resolve(JSON.parse(data)),
|
||
text: () => Promise.resolve(data),
|
||
headers: { get: (k) => res.headers[k.toLowerCase()] },
|
||
});
|
||
});
|
||
});
|
||
req.on('timeout', () => { req.destroy(); reject(new Error(`Request to ${url} timed out after ${timeoutMs}ms`)); });
|
||
req.on('error', reject);
|
||
if (opts.body) req.write(opts.body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
/** Pull a Docker image with timeout protection */
|
||
function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) {
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), timeoutMs);
|
||
docker.pull(imageName, (err, stream) => {
|
||
if (err) { clearTimeout(timer); return reject(err); }
|
||
docker.modem.followProgress(stream, (err, output) => {
|
||
clearTimeout(timer);
|
||
if (err) return reject(err);
|
||
resolve(output);
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
// ===== Structured Logging =====
|
||
const LOG_LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||
const LOG_LEVEL = LOG_LEVELS[process.env.LOG_LEVEL || 'info'] || 1;
|
||
|
||
function log(level, context, message, data = {}) {
|
||
if (LOG_LEVELS[level] < LOG_LEVEL) return;
|
||
const entry = {
|
||
t: new Date().toISOString(),
|
||
level,
|
||
ctx: context,
|
||
msg: message,
|
||
};
|
||
if (Object.keys(data).length) entry.data = data;
|
||
const fn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
||
fn(JSON.stringify(entry));
|
||
}
|
||
log.info = (ctx, msg, data) => log('info', ctx, msg, data);
|
||
log.warn = (ctx, msg, data) => log('warn', ctx, msg, data);
|
||
log.error = (ctx, msg, data) => log('error', ctx, msg, data);
|
||
log.debug = (ctx, msg, data) => log('debug', ctx, msg, data);
|
||
|
||
/** Standard error response — always returns { success: false, error, ...extras } */
|
||
function errorResponse(res, statusCode, message, extras = {}) {
|
||
return res.status(statusCode).json({ success: false, error: message, ...extras });
|
||
}
|
||
|
||
/** Standard success response — always returns { success: true, ...data } */
|
||
function ok(res, data = {}) {
|
||
return res.json({ success: true, ...data });
|
||
}
|
||
|
||
/** Look up a single service by ID from services.json */
|
||
async function getServiceById(serviceId) {
|
||
const services = await servicesStateManager.read();
|
||
return services.find(s => s.id === serviceId) || null;
|
||
}
|
||
|
||
/** Find a running Docker container by name substring */
|
||
async function findContainerByName(name, opts = { all: false }) {
|
||
const containers = await docker.listContainers(opts);
|
||
const match = containers.find(c =>
|
||
c.Names.some(n => n.toLowerCase().includes(name.toLowerCase()))
|
||
);
|
||
return match || null;
|
||
}
|
||
|
||
/** Read config.json with fallback to empty object */
|
||
async function readConfig() {
|
||
return readJsonFile(CONFIG_FILE, {});
|
||
}
|
||
|
||
/** Save config.json (merges with existing) */
|
||
async function saveConfig(updates) {
|
||
const config = await readConfig();
|
||
Object.assign(config, updates);
|
||
await writeJsonFile(CONFIG_FILE, config);
|
||
return config;
|
||
}
|
||
|
||
/**
|
||
* 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');
|
||
Object.assign(totpConfig, JSON.parse(data));
|
||
log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled });
|
||
|
||
// Auto-restore: if config has a backup secret but credential manager lost it, re-store it
|
||
if (totpConfig.secret && totpConfig.isSetUp) {
|
||
const existing = await credentialManager.retrieve('totp.secret');
|
||
if (!existing) {
|
||
await credentialManager.store('totp.secret', totpConfig.secret);
|
||
log.info('config', 'TOTP secret auto-restored from config backup');
|
||
}
|
||
}
|
||
}
|
||
} 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,
|
||
APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS, RECIPE_TEMPLATES, RECIPE_CATEGORIES,
|
||
asyncHandler, errorResponse, ok, fetchT, log, logError, safeErrorMessage,
|
||
buildDomain, getServiceById, readConfig, saveConfig, addServiceToConfig,
|
||
validateURL, strictLimiter,
|
||
totpConfig, saveTotpConfig,
|
||
loadSiteConfig, loadNotificationConfig,
|
||
loadDnsCredentials: () => {},
|
||
SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE,
|
||
NOTIFICATIONS_FILE, ERROR_LOG_FILE,
|
||
});
|
||
|
||
// Build versioned API router — all route modules attach here
|
||
const apiRouter = express.Router();
|
||
apiRouter.use(authRoutes(ctx));
|
||
apiRouter.use(configRoutes(ctx));
|
||
apiRouter.use('/dns', dnsRoutes(ctx));
|
||
apiRouter.use('/notifications', notificationRoutes(ctx));
|
||
apiRouter.use('/containers', containerRoutes(ctx));
|
||
apiRouter.use(serviceRoutes(ctx));
|
||
apiRouter.use(healthRoutes(ctx));
|
||
apiRouter.use(monitoringRoutes(ctx));
|
||
apiRouter.use(updatesRoutes(ctx));
|
||
apiRouter.use(tailscaleRoutes(ctx));
|
||
apiRouter.use(sitesRoutes(ctx));
|
||
apiRouter.use(credentialsRoutes(ctx));
|
||
apiRouter.use(arrRoutes(ctx));
|
||
apiRouter.use(appsRoutes(ctx));
|
||
apiRouter.use(logsRoutes(ctx));
|
||
apiRouter.use(backupsRoutes(ctx));
|
||
apiRouter.use('/ca', caRoutes(ctx));
|
||
apiRouter.use(browseRoutes(ctx));
|
||
apiRouter.use(errorLogsRoutes(ctx));
|
||
apiRouter.use('/license', licenseRoutes(ctx));
|
||
apiRouter.use('/recipes', recipesRoutes(ctx));
|
||
apiRouter.use(themesRoutes(ctx));
|
||
|
||
// Inline routes on the API router
|
||
apiRouter.get('/health', (req, res) => {
|
||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||
});
|
||
apiRouter.get('/csrf-token', (req, res) => {
|
||
res.json({ success: true, token: req.csrfToken, headerName: CSRF_HEADER_NAME });
|
||
});
|
||
apiRouter.get('/metrics', (req, res) => {
|
||
res.json({ success: true, metrics: metrics.getSummary() });
|
||
});
|
||
|
||
// Mount at /api/v1 (canonical) and /api (legacy compat for tests & external consumers)
|
||
app.use('/api/v1', apiRouter);
|
||
app.use('/api', apiRouter);
|
||
|
||
// Root-level health check (no /api prefix)
|
||
app.get('/health', (req, res) => {
|
||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||
});
|
||
|
||
// Lightweight probe endpoint - performs real health checks for frontend status dots
|
||
app.get('/probe/:id', asyncHandler(async (req, res) => {
|
||
const id = req.params.id;
|
||
|
||
try {
|
||
let url;
|
||
|
||
if (id === 'internet') {
|
||
// Internet connectivity check
|
||
url = 'https://www.google.com';
|
||
} else {
|
||
// Look up service in services.json for custom URLs
|
||
let service = null;
|
||
if (await exists(SERVICES_FILE)) {
|
||
const data = await servicesStateManager.read();
|
||
const services = Array.isArray(data) ? data : data.services || [];
|
||
service = services.find(s => s.id === id);
|
||
}
|
||
|
||
if (service?.isExternal && service?.externalUrl) {
|
||
url = service.externalUrl;
|
||
} else if (service?.url) {
|
||
url = service.url.startsWith('http') ? service.url : `https://${service.url}`;
|
||
} else {
|
||
// Check via configured TLD through Caddy
|
||
url = `https://${buildDomain(id)}`;
|
||
}
|
||
}
|
||
|
||
const parsed = new URL(url);
|
||
const isHttps = parsed.protocol === 'https:';
|
||
const lib = isHttps ? https : http;
|
||
|
||
const options = {
|
||
hostname: parsed.hostname,
|
||
port: parsed.port || (isHttps ? 443 : 80),
|
||
path: parsed.pathname + parsed.search,
|
||
method: 'HEAD',
|
||
timeout: 5000,
|
||
agent: isHttps ? httpsAgent : undefined,
|
||
headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
|
||
};
|
||
|
||
const makeRequest = (method) => new Promise((resolve, reject) => {
|
||
const reqOpts = { ...options, method };
|
||
const probeReq = lib.request(reqOpts, (response) => {
|
||
response.resume();
|
||
resolve(response.statusCode);
|
||
});
|
||
probeReq.on('error', reject);
|
||
probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); });
|
||
probeReq.end();
|
||
});
|
||
|
||
let statusCode;
|
||
try {
|
||
statusCode = await makeRequest('HEAD');
|
||
// Fall back to GET if HEAD is not supported
|
||
if (statusCode === 501 || statusCode === 405) {
|
||
statusCode = await makeRequest('GET');
|
||
}
|
||
} catch {
|
||
// Direct URL failed (e.g. HTTP/1.0 parse errors) — try via Caddy domain
|
||
const fallbackUrl = `https://${buildDomain(id)}`;
|
||
const fp = new URL(fallbackUrl);
|
||
const fLib = require('https');
|
||
statusCode = await new Promise((resolve, reject) => {
|
||
const fReq = fLib.request({
|
||
hostname: fp.hostname, port: 443, path: '/', method: 'GET',
|
||
timeout: 5000, agent: httpsAgent,
|
||
headers: { 'User-Agent': APP.USER_AGENTS.PROBE }
|
||
}, (fRes) => { fRes.resume(); resolve(fRes.statusCode); });
|
||
fReq.on('error', reject);
|
||
fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); });
|
||
fReq.end();
|
||
});
|
||
}
|
||
|
||
res.status(statusCode).send();
|
||
} catch {
|
||
res.status(502).send();
|
||
}
|
||
}, 'probe'));
|
||
|
||
// Get network IPs (LAN, Tailscale) for quick selection
|
||
app.get('/api/network/ips', (req, res) => {
|
||
try {
|
||
// Prefer environment variables (set in docker-compose.yml)
|
||
const envLan = process.env.HOST_LAN_IP;
|
||
const envTailscale = process.env.HOST_TAILSCALE_IP;
|
||
|
||
const result = {
|
||
localhost: '127.0.0.1',
|
||
lan: envLan || null,
|
||
tailscale: envTailscale || null,
|
||
all: []
|
||
};
|
||
|
||
// If env vars not set, try to detect from network interfaces
|
||
if (!envLan || !envTailscale) {
|
||
const interfaces = os.networkInterfaces();
|
||
|
||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||
for (const addr of addrs) {
|
||
// Skip internal and IPv6
|
||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||
|
||
const ip = addr.address;
|
||
result.all.push({ name, ip });
|
||
|
||
// Detect Tailscale (100.x.x.x range)
|
||
if (!result.tailscale && ip.startsWith('100.')) {
|
||
result.tailscale = ip;
|
||
}
|
||
// Detect common LAN ranges
|
||
else if (!result.lan && (ip.startsWith('192.168.') || ip.startsWith('10.') || ip.match(/^172\.(1[6-9]|2[0-9]|3[0-1])\./))) {
|
||
result.lan = ip;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Return null if not detected — let the frontend handle it
|
||
if (!result.lan) result.lan = null;
|
||
if (!result.tailscale) result.tailscale = null;
|
||
|
||
res.json(result);
|
||
} catch (error) {
|
||
errorResponse(res, 500, safeErrorMessage(error));
|
||
}
|
||
});
|
||
|
||
|
||
// (TOTP/auth inline routes moved to routes/auth.js)
|
||
|
||
// (SSO auth gate + getAppSession moved to routes/auth.js)
|
||
|
||
// (Tailscale routes moved to routes/tailscale.js)
|
||
|
||
// (Caddy/site routes moved to routes/sites.js)
|
||
|
||
// (Assets, config, backup routes moved to routes/config.js)
|
||
|
||
|
||
// (Credential management routes moved to routes/credentials.js)
|
||
|
||
// ===== DNS TOKEN AUTO-REFRESH FUNCTIONS =====
|
||
|
||
async function refreshDnsToken(username, password, server) {
|
||
try {
|
||
// Use /api/user/login to get a session token
|
||
const params = new URLSearchParams({
|
||
user: username,
|
||
pass: password,
|
||
includeInfo: 'false'
|
||
});
|
||
|
||
const response = await fetchT(
|
||
`http://${server}:5380/api/user/login?${params.toString()}`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Accept': 'application/json',
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
},
|
||
timeout: 10000
|
||
}
|
||
);
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'ok' && result.token) {
|
||
dnsToken = result.token;
|
||
// Technitium session tokens expire after inactivity, refresh every 6 hours to keep alive
|
||
dnsTokenExpiry = new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString();
|
||
log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
|
||
return { success: true, token: dnsToken };
|
||
}
|
||
|
||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||
} catch (error) {
|
||
log.error('dns', 'DNS token refresh error', { error: error.message });
|
||
return { success: false, error: error.message };
|
||
}
|
||
}
|
||
|
||
async function ensureValidDnsToken() {
|
||
// Check if token is valid and not expired
|
||
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
|
||
return { success: true, token: dnsToken };
|
||
}
|
||
|
||
// Try per-server admin credentials for the primary DNS server
|
||
const primaryIp = siteConfig.dnsServerIp;
|
||
if (primaryIp) {
|
||
const dnsId = dnsIpToDnsId(primaryIp);
|
||
if (dnsId) {
|
||
// Try admin credentials first (used for DNS record operations)
|
||
for (const role of ['admin', 'readonly']) {
|
||
try {
|
||
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||
if (username && password) {
|
||
return await refreshDnsToken(username, password, primaryIp);
|
||
}
|
||
} catch (err) {
|
||
log.error('dns', `Per-server ${role} credential error for primary DNS`, { dnsId, error: err.message });
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Fall back to global credentials
|
||
try {
|
||
const username = await credentialManager.retrieve('dns.username');
|
||
const password = await credentialManager.retrieve('dns.password');
|
||
const server = await credentialManager.retrieve('dns.server');
|
||
if (username && password) {
|
||
return await refreshDnsToken(username, password, server || primaryIp);
|
||
}
|
||
} catch (err) {
|
||
log.error('dns', 'Credential manager error', { error: err.message });
|
||
}
|
||
|
||
return {
|
||
success: false,
|
||
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials'
|
||
};
|
||
}
|
||
|
||
// Map a DNS server IP to its dnsId (dns1, dns2, dns3) using config
|
||
function dnsIpToDnsId(serverIp) {
|
||
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
|
||
if (info.ip === serverIp) return dnsId;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// Get a valid token for a specific DNS server (authenticates directly against that server)
|
||
// role: 'readonly' for logs, 'admin' for records/restart/updates (defaults to 'readonly')
|
||
async function getTokenForServer(targetServer, role = 'readonly') {
|
||
const cacheKey = `${targetServer}:${role}`;
|
||
|
||
// Check cached per-server token first
|
||
const cached = dnsServerTokens.get(cacheKey);
|
||
if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) {
|
||
return { success: true, token: cached.token };
|
||
}
|
||
|
||
const serverPort = siteConfig.dnsServerPort || '5380';
|
||
|
||
// Helper to authenticate against a DNS server via login
|
||
async function authenticateToServer(username, password) {
|
||
const params = new URLSearchParams({
|
||
user: username,
|
||
pass: password,
|
||
includeInfo: 'false'
|
||
});
|
||
|
||
const response = await fetchT(
|
||
`http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`,
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
'Accept': 'application/json',
|
||
'Content-Type': 'application/x-www-form-urlencoded'
|
||
}
|
||
}
|
||
);
|
||
|
||
const result = await response.json();
|
||
|
||
if (result.status === 'ok' && result.token) {
|
||
dnsServerTokens.set(cacheKey, {
|
||
token: result.token,
|
||
expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString()
|
||
});
|
||
log.info('dns', 'DNS token obtained for server', { server: targetServer, role });
|
||
return { success: true, token: result.token };
|
||
}
|
||
|
||
return { success: false, error: result.errorMessage || 'Login failed' };
|
||
}
|
||
|
||
const dnsId = dnsIpToDnsId(targetServer);
|
||
|
||
// Try per-server credentials with the requested role first
|
||
if (dnsId) {
|
||
try {
|
||
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
|
||
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
|
||
if (username && password) {
|
||
return await authenticateToServer(username, password);
|
||
}
|
||
} catch (err) {
|
||
log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message });
|
||
}
|
||
|
||
// Fall back to the other role (readonly -> admin or admin -> readonly)
|
||
const fallbackRole = role === 'readonly' ? 'admin' : 'readonly';
|
||
try {
|
||
const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`);
|
||
const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`);
|
||
if (username && password) {
|
||
return await authenticateToServer(username, password);
|
||
}
|
||
} catch (err) {
|
||
// ignore fallback errors
|
||
}
|
||
}
|
||
|
||
// Fall back to global credentials
|
||
try {
|
||
const username = await credentialManager.retrieve('dns.username');
|
||
const password = await credentialManager.retrieve('dns.password');
|
||
if (username && password) {
|
||
return await authenticateToServer(username, password);
|
||
}
|
||
} catch (err) {
|
||
log.error('dns', 'Credential manager error', { server: targetServer, error: err.message });
|
||
}
|
||
|
||
return { success: false, error: 'No DNS credentials configured' };
|
||
}
|
||
|
||
// Load credentials and refresh token on startup
|
||
(async function initDnsToken() {
|
||
if (dnsToken) {
|
||
log.info('dns', 'Using DNS token from environment variable');
|
||
return;
|
||
}
|
||
|
||
// Get token using credential manager
|
||
const result = await ensureValidDnsToken();
|
||
if (result.success) {
|
||
log.info('dns', 'DNS token obtained from stored credentials');
|
||
} else if (await credentialManager.retrieve('dns.username')) {
|
||
log.warn('dns', 'Failed to get DNS token', { error: result.error });
|
||
} else {
|
||
log.info('dns', 'No DNS credentials configured - DNS record management unavailable');
|
||
}
|
||
})();
|
||
|
||
// (Arr stack routes moved to routes/arr.js)
|
||
// (App deployment routes moved to routes/apps.js)
|
||
// (Container management routes moved to routes/containers.js)
|
||
// (Docker helper functions moved to routes/apps.js)
|
||
|
||
function generateCaddyConfig(subdomain, ip, port, options = {}) {
|
||
const { tailscaleOnly = false, allowedIPs = [] } = options;
|
||
|
||
let config = `${buildDomain(subdomain)} {\n`;
|
||
|
||
// Add Tailscale IP restriction if enabled
|
||
if (tailscaleOnly) {
|
||
// Tailscale CGNAT range: 100.64.0.0/10
|
||
config += ` @blocked not remote_ip 100.64.0.0/10`;
|
||
// Add any additional allowed IPs
|
||
if (allowedIPs.length > 0) {
|
||
config += ` ${allowedIPs.join(' ')}`;
|
||
}
|
||
config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`;
|
||
}
|
||
|
||
config += ` reverse_proxy ${ip}:${port}\n`;
|
||
config += ` tls internal\n`;
|
||
config += `}`;
|
||
|
||
return config;
|
||
}
|
||
|
||
// (generateStaticSiteConfig, addCaddyConfig, pullImage moved to routes/apps.js)
|
||
|
||
async function reloadCaddy(content) {
|
||
const maxRetries = RETRIES.CADDY_RELOAD;
|
||
let lastError = null;
|
||
|
||
for (let i = 0; i < maxRetries; i++) {
|
||
try {
|
||
const response = await fetchT(`${CADDY_ADMIN_URL}/load`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': CADDY.CONTENT_TYPE },
|
||
body: content
|
||
});
|
||
|
||
if (response.ok) {
|
||
log.info('caddy', 'Caddy configuration reloaded successfully');
|
||
// Wait a moment for Caddy to fully apply the config
|
||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||
return;
|
||
}
|
||
|
||
lastError = await response.text();
|
||
log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError });
|
||
} catch (error) {
|
||
lastError = error.message;
|
||
log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError });
|
||
}
|
||
|
||
if (i < maxRetries - 1) {
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
}
|
||
|
||
throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`);
|
||
}
|
||
|
||
async function verifySiteAccessible(domain, maxAttempts = 5) {
|
||
const delay = 2000;
|
||
|
||
for (let i = 0; i < maxAttempts; i++) {
|
||
try {
|
||
// Try HTTPS first (internal CA)
|
||
const response = await fetchT(`https://${domain}/`, {
|
||
method: 'HEAD',
|
||
agent: httpsAgent, // Ignore cert errors for internal CA
|
||
timeout: 5000
|
||
});
|
||
|
||
// Any response (even 4xx) means Caddy is serving the site
|
||
log.info('caddy', 'Site is accessible', { domain, status: response.status });
|
||
return true;
|
||
} catch (error) {
|
||
log.debug('caddy', 'Site verification attempt', { domain, attempt: i + 1, maxAttempts, error: error.message });
|
||
}
|
||
|
||
if (i < maxAttempts - 1) {
|
||
await new Promise(resolve => setTimeout(resolve, delay));
|
||
}
|
||
}
|
||
|
||
log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain });
|
||
return false;
|
||
}
|
||
|
||
async function createDnsRecord(subdomain, ip) {
|
||
// Ensure we have a valid token (auto-refresh if needed)
|
||
const tokenResult = await ensureValidDnsToken();
|
||
if (!tokenResult.success) {
|
||
throw new Error(`DNS token not available: ${tokenResult.error}. Configure credentials via POST /api/dns/credentials`);
|
||
}
|
||
|
||
const domain = buildDomain(subdomain);
|
||
const zone = siteConfig.tld.replace(/^\./, '');
|
||
|
||
const dnsParams = { token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: '300', overwrite: 'true' };
|
||
const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams);
|
||
|
||
try {
|
||
log.info('dns', 'Creating DNS record', { domain, ip });
|
||
const result = await callDnsApi();
|
||
|
||
if (result.status === 'ok') {
|
||
log.info('dns', 'DNS record created', { domain, ip });
|
||
return { success: true };
|
||
}
|
||
|
||
// Check for token expired error - try to refresh once
|
||
if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) {
|
||
log.info('dns', 'Token appears expired, attempting auto-refresh');
|
||
const refreshResult = await ensureValidDnsToken();
|
||
if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`);
|
||
|
||
const retryResult = await callDnsApi();
|
||
if (retryResult.status === 'ok') {
|
||
log.info('dns', 'DNS record created after token refresh', { domain, ip });
|
||
return { success: true };
|
||
}
|
||
throw new Error(retryResult.errorMessage || 'Unknown error after token refresh');
|
||
}
|
||
|
||
throw new Error(result.errorMessage || 'Unknown error');
|
||
} catch (error) {
|
||
throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
async function addServiceToConfig(service) {
|
||
try {
|
||
await servicesStateManager.update(services => {
|
||
// Check if service already exists
|
||
const existingIndex = services.findIndex(s => s.id === service.id);
|
||
if (existingIndex >= 0) {
|
||
// Update existing service
|
||
services[existingIndex] = { ...services[existingIndex], ...service };
|
||
} else {
|
||
// Add new service
|
||
services.push(service);
|
||
}
|
||
return services;
|
||
});
|
||
log.info('deploy', 'Service added to config', { serviceId: service.id });
|
||
} catch (error) {
|
||
log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message });
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// (Notification routes moved to routes/notifications.js)
|
||
// (Stats routes moved to routes/monitoring.js)
|
||
// (Container logs routes moved to routes/logs.js)
|
||
// (Health service routes moved to routes/health.js)
|
||
// (Resource monitoring routes moved to routes/monitoring.js)
|
||
// (Backup routes moved to routes/backups.js)
|
||
// (CA routes moved to routes/ca.js)
|
||
// (Error log, audit log, browse/media routes moved to route modules)
|
||
|
||
// API Documentation endpoint
|
||
app.get('/api/docs', (req, res) => {
|
||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://unpkg.com; style-src 'self' 'unsafe-inline' https://unpkg.com; img-src 'self' data: https:; connect-src 'self'; font-src 'self' data: https://unpkg.com;");
|
||
res.send(`<!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'));
|
||
|
||
// 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
|
||
});
|
||
}
|
||
next(err);
|
||
});
|
||
|
||
// Export app for testing
|
||
module.exports = app;
|
||
|
||
if (require.main === module) {
|
||
// Validate configuration and wait for async config loads before starting server
|
||
(async () => {
|
||
await Promise.all([_configsReady, _notificationsReady]);
|
||
await licenseManager.load();
|
||
await validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT });
|
||
|
||
const server = app.listen(PORT, '0.0.0.0', () => {
|
||
log.info('server', 'DashCaddy API server started', { port: PORT, caddyfile: CADDYFILE_PATH, caddyAdmin: CADDY_ADMIN_URL, services: SERVICES_FILE });
|
||
if (BROWSE_ROOTS.length > 0) {
|
||
log.info('server', 'Media browse roots configured', { roots: BROWSE_ROOTS.map(r => r.hostPath) });
|
||
}
|
||
|
||
// Start new feature modules
|
||
log.info('server', 'Starting DashCaddy feature modules');
|
||
|
||
// Clean up stale port locks
|
||
(async () => {
|
||
try {
|
||
await portLockManager.cleanupStaleLocks();
|
||
log.info('server', 'Port lock cleanup completed');
|
||
} catch (error) {
|
||
log.error('server', 'Port lock cleanup failed', { error: error.message });
|
||
}
|
||
})();
|
||
|
||
try {
|
||
resourceMonitor.start();
|
||
log.info('server', 'Resource monitoring started');
|
||
} catch (error) {
|
||
log.error('server', 'Resource monitoring failed to start', { error: error.message });
|
||
}
|
||
|
||
try {
|
||
backupManager.start();
|
||
log.info('server', 'Backup manager started');
|
||
} catch (error) {
|
||
log.error('server', 'Backup manager failed to start', { error: error.message });
|
||
}
|
||
|
||
(async () => {
|
||
try {
|
||
// Auto-configure health checker from services.json
|
||
await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP });
|
||
healthChecker.start();
|
||
log.info('server', 'Health checker started');
|
||
} catch (error) {
|
||
log.error('server', 'Health checker failed to start', { error: error.message });
|
||
}
|
||
})();
|
||
|
||
try {
|
||
updateManager.start();
|
||
log.info('server', 'Update manager started');
|
||
} catch (error) {
|
||
log.error('server', 'Update manager failed to start', { error: error.message });
|
||
}
|
||
|
||
// 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();
|
||
healthChecker.stop();
|
||
updateManager.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();
|
||
});
|