The 489f700 fix accidentally stripped the subdirectory name from all bare
requires (e.g. managers/state-manager → .//state-manager instead of
./managers/state-manager). Fixed all 39 occurrences with correct subdir
prefixes (managers/, security/, monitoring/, docker/, utilities/, recipes/).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
963 lines
33 KiB
JavaScript
963 lines
33 KiB
JavaScript
/**
|
|
* Express application setup
|
|
* Configures middleware, assembles context, and mounts routes
|
|
*/
|
|
const express = require('express');
|
|
const https = require('https');
|
|
const fs = require('fs');
|
|
|
|
// Configuration
|
|
const config = require('./config');
|
|
const { assembleContext } = require('./context');
|
|
const { createLogger, logError, safeErrorMessage } = require('./utils/logging');
|
|
const { fetchT } = require('./utils/http');
|
|
const { errorResponse, ok } = require('./utils/responses');
|
|
// Note: 3-arg asyncHandler signature (logError, fn, context) preserved per Hermes review
|
|
// — 49 route files still use this signature.
|
|
const { asyncHandler } = require('./utils/async-handler');
|
|
|
|
// Managers and utilities
|
|
const StateManager = require('./managers/state-manager');
|
|
const platformPaths = require('../platform-paths');
|
|
const { LicenseManager } = require('./managers/license-manager');
|
|
const credentialManager = require('./managers/credential-manager');
|
|
const authManager = require('./managers/auth-manager');
|
|
const dockerSecurity = require('./security/docker-security');
|
|
const auditLogger = require('./security/audit-logger');
|
|
const portLockManager = require('./managers/port-lock-manager');
|
|
const resourceMonitor = require('./managers/resource-monitor');
|
|
const backupManager = require('./utilities/backup-manager');
|
|
const healthChecker = require('./monitoring/health-checker');
|
|
const updateManager = require('./managers/update-manager');
|
|
const selfUpdater = require('./docker/self-updater');
|
|
const configureMiddleware = require('./utilities/middleware');
|
|
const { validateStartupConfig: _validateStartupConfig, syncHealthCheckerServices } = require('./utilities/startup-validator');
|
|
const { CSRF_HEADER_NAME } = require('./security/csrf-protection');
|
|
const { resolveServiceUrl } = require('./utilities/url-resolver');
|
|
const metrics = require('./monitoring/metrics');
|
|
const { validateURL } = require('./security/input-validator');
|
|
|
|
// Optional modules
|
|
let dockerMaintenance, logDigest;
|
|
try { dockerMaintenance = require('./docker/docker-maintenance'); } catch (_) { /* optional module */ }
|
|
try { logDigest = require('./security/log-digest'); } catch (_) { /* optional module */ }
|
|
|
|
// Workflow engine (bundled workflows)
|
|
let bundledWorkflowsModule;
|
|
let workflowEngine = null;
|
|
try {
|
|
bundledWorkflowsModule = require('./recipes/bundled-workflows');
|
|
} catch (_) { /* optional module */ }
|
|
|
|
// Templates
|
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('./docker/app-templates');
|
|
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('./recipes/recipe-templates');
|
|
|
|
// Route modules
|
|
const healthRoutes = require('../routes/health');
|
|
const monitoringRoutes = require('../routes/monitoring');
|
|
const updatesRoutes = require('../routes/updates');
|
|
const authRoutes = require('../routes/auth');
|
|
const configRoutes = require('../routes/config');
|
|
const dnsRoutes = require('../routes/dns');
|
|
const notificationRoutes = require('../routes/notifications');
|
|
const containerRoutes = require('../routes/containers');
|
|
const serviceRoutes = require('../routes/services');
|
|
const tailscaleRoutes = require('../routes/tailscale');
|
|
const sitesRoutes = require('../routes/sites');
|
|
const credentialsRoutes = require('../routes/credentials');
|
|
const arrRoutes = require('../routes/arr');
|
|
const appsRoutes = require('../routes/apps');
|
|
const logsRoutes = require('../routes/logs');
|
|
const backupsRoutes = require('../routes/backups');
|
|
const caRoutes = require('../routes/ca');
|
|
const browseRoutes = require('../routes/browse');
|
|
const errorLogsRoutes = require('../routes/errorlogs');
|
|
const licenseRoutes = require('../routes/license');
|
|
const openClawRoutes = require('../routes/openclaw');
|
|
const recipesRoutes = require('../routes/recipes');
|
|
const themesRoutes = require('../routes/themes');
|
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
|
const eventsRoutes = require('../routes/events');
|
|
const workflowsRoutes = require('../routes/workflows');
|
|
const dependenciesRoutes = require('../routes/dependencies');
|
|
const DependencyManager = require('./managers/dependency-manager');
|
|
const autoRestartRoutes = require('../routes/auto-restart');
|
|
const configDriftRoutes = require('../routes/config-drift');
|
|
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
|
const { AutoRestartManager } = require('./managers/auto-restart-manager');
|
|
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
|
|
const SSLMonitor = require('./monitoring/ssl-monitor');
|
|
const DNSPropagationChecker = require('./dns/dns-propagation');
|
|
|
|
// Constants
|
|
const { APP } = require('./utilities/constants');
|
|
|
|
/**
|
|
* Create and configure the Express application
|
|
*/
|
|
// eslint-disable-next-line require-await -- kept async for API consistency with other factory functions
|
|
async function createApp() {
|
|
const app = express();
|
|
|
|
// Global request timeout (default 5 minutes — covers slow Docker pulls)
|
|
// Routes that need longer can override per-request with req.setTimeout()
|
|
const REQUEST_TIMEOUT_MS = parseInt(process.env.REQUEST_TIMEOUT_MS, 10) || 5 * 60 * 1000;
|
|
app.use((req, res, next) => {
|
|
req.setTimeout(REQUEST_TIMEOUT_MS);
|
|
res.setTimeout(REQUEST_TIMEOUT_MS);
|
|
next();
|
|
});
|
|
// Disable x-powered-by header for security (don't advertise framework)
|
|
app.disable('x-powered-by');
|
|
// Trust first proxy (Caddy/nginx in front of us) so req.ip works correctly
|
|
app.set('trust proxy', 1);
|
|
|
|
// Initialize logging
|
|
const log = createLogger(config.LOG_LEVEL);
|
|
|
|
// Load site configuration
|
|
config.loadSiteConfig(config.CONFIG_FILE, log);
|
|
|
|
// Create state managers
|
|
const servicesStateManager = new StateManager(config.SERVICES_FILE);
|
|
const configStateManager = new StateManager(config.CONFIG_FILE);
|
|
|
|
// Initialize license manager
|
|
const licenseManager = new LicenseManager(credentialManager, config.CONFIG_FILE, console);
|
|
licenseManager.loadSecret(config.LICENSE_SECRET_FILE);
|
|
|
|
// HTTPS agent for internal CA
|
|
const CA_CERT_PATH = process.env.CA_CERT_PATH || platformPaths.pkiRootCert;
|
|
let httpsAgent;
|
|
try {
|
|
const caCert = fs.readFileSync(CA_CERT_PATH);
|
|
httpsAgent = new https.Agent({ ca: [...require('tls').rootCertificates, caCert] });
|
|
log.info('server', 'HTTPS agent configured with CA certificate', { path: CA_CERT_PATH });
|
|
} catch {
|
|
httpsAgent = new https.Agent();
|
|
log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH });
|
|
}
|
|
|
|
// TOTP configuration
|
|
const totpConfig = {
|
|
enabled: false,
|
|
sessionDuration: 'never',
|
|
isSetUp: false
|
|
};
|
|
|
|
// Load TOTP config from file
|
|
try {
|
|
if (fs.existsSync(config.TOTP_CONFIG_FILE)) {
|
|
const loaded = JSON.parse(fs.readFileSync(config.TOTP_CONFIG_FILE, 'utf8'));
|
|
delete loaded.secret; // secret belongs only in credential-manager
|
|
Object.assign(totpConfig, loaded);
|
|
log.info('config', 'TOTP config loaded', { enabled: totpConfig.enabled });
|
|
}
|
|
} catch (e) {
|
|
log.warn('config', 'Could not load TOTP config', { error: e.message });
|
|
}
|
|
|
|
// Tailscale configuration
|
|
const tailscaleConfig = {
|
|
enabled: false,
|
|
requireAuth: false,
|
|
allowedTailnet: null,
|
|
devices: [],
|
|
oauthConfigured: false,
|
|
tailnet: null,
|
|
syncInterval: 300,
|
|
lastSync: null
|
|
};
|
|
|
|
// Helper functions needed by middleware
|
|
function isValidContainerId(id) {
|
|
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
|
|
return typeof id === 'string' && CONTAINER_ID_RE.test(id);
|
|
}
|
|
|
|
function isTailscaleIP(ip) {
|
|
if (!ip) return false;
|
|
const parts = ip.split('.');
|
|
if (parts.length !== 4) return false;
|
|
const first = parseInt(parts[0]);
|
|
const second = parseInt(parts[1]);
|
|
return first === 100 && second >= 64 && second <= 127;
|
|
}
|
|
|
|
function isPrivateLan(ip) {
|
|
if (!ip) return false;
|
|
if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true;
|
|
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip);
|
|
}
|
|
|
|
function collectNetworkInterfaces(osModule) {
|
|
const out = [];
|
|
const interfaces = osModule.networkInterfaces();
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
for (const addr of addrs) {
|
|
if (addr.internal || addr.family !== 'IPv4') continue;
|
|
out.push({ name, ip: addr.address });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
|
|
async function getTailscaleStatus() {
|
|
// Stub for now - will be populated by context
|
|
return null;
|
|
}
|
|
|
|
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
|
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
|
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
|
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
|
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
|
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
|
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
|
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
|
app.use((req, res, next) => {
|
|
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
|
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Configure middleware
|
|
const middlewareResult = configureMiddleware(app, {
|
|
siteConfig: config.siteConfig,
|
|
totpConfig,
|
|
tailscaleConfig,
|
|
metrics,
|
|
auditLogger,
|
|
authManager,
|
|
log,
|
|
cryptoUtils: require('./security/crypto-utils'),
|
|
isValidContainerId,
|
|
isTailscaleIP,
|
|
getTailscaleStatus,
|
|
RATE_LIMITS: require('./utilities/constants').RATE_LIMITS,
|
|
LIMITS: require('./utilities/constants').LIMITS,
|
|
APP: require('./utilities/constants').APP,
|
|
CACHE_CONFIGS: require('./utilities/cache-config').CACHE_CONFIGS,
|
|
createCache: require('./utilities/cache-config').createCache,
|
|
});
|
|
|
|
const { strictLimiter } = middlewareResult;
|
|
|
|
// Helper functions
|
|
async function getServiceById(serviceId) {
|
|
const services = await servicesStateManager.read();
|
|
return services.find(s => s.id === serviceId) || null;
|
|
}
|
|
|
|
// eslint-disable-next-line require-await -- may grow awaits as config loading evolves
|
|
async function readConfig() {
|
|
const { readJsonFile } = require('./utilities/fs-helpers');
|
|
return readJsonFile(config.CONFIG_FILE, {});
|
|
}
|
|
|
|
async function saveConfig(updates) {
|
|
return await configStateManager.update(cfg => Object.assign(cfg, updates));
|
|
}
|
|
|
|
async function addServiceToConfig(service) {
|
|
await servicesStateManager.update(services => {
|
|
const existingIndex = services.findIndex(s => s.id === service.id);
|
|
if (existingIndex >= 0) {
|
|
services[existingIndex] = { ...services[existingIndex], ...service };
|
|
} else {
|
|
services.push(service);
|
|
}
|
|
return services;
|
|
});
|
|
log.info('deploy', 'Service added to config', { serviceId: service.id });
|
|
}
|
|
|
|
async function saveTotpConfig() {
|
|
try {
|
|
const { writeJsonFile } = require('./utilities/fs-helpers');
|
|
await writeJsonFile(config.TOTP_CONFIG_FILE, totpConfig);
|
|
} catch (e) {
|
|
log.error('config', 'Could not save TOTP config', { error: e.message });
|
|
}
|
|
}
|
|
|
|
async function loadNotificationConfig() {
|
|
// Stub - will be implemented
|
|
}
|
|
|
|
// Forwards the promise from syncHealthCheckerServices — intentionally not
|
|
// `async` since there is no `await` inside. Callers use `.catch()` on it.
|
|
function resyncHealthChecker() {
|
|
return syncHealthCheckerServices({
|
|
log,
|
|
SERVICES_FILE: config.SERVICES_FILE,
|
|
servicesStateManager,
|
|
healthChecker,
|
|
buildServiceUrl: config.buildServiceUrl,
|
|
siteConfig: config.siteConfig,
|
|
APP
|
|
});
|
|
}
|
|
|
|
// Create bound logError function (3-arg signature: ctx, err, extra)
|
|
// The unified logger module has its own ERROR_LOG_FILE from process.env,
|
|
// so we just route through its logErrorWrapper.
|
|
const boundLogError = (context, error, additionalInfo) =>
|
|
logError(context, error, additionalInfo);
|
|
|
|
// Create bound asyncHandler (3-arg: logError, fn, context)
|
|
const boundAsyncHandler = (fn, context) => asyncHandler(boundLogError, fn, context);
|
|
|
|
// Assemble context
|
|
const ctx = assembleContext({
|
|
// Config
|
|
siteConfig: config.siteConfig,
|
|
buildDomain: config.buildDomain,
|
|
buildServiceUrl: config.buildServiceUrl,
|
|
SERVICES_FILE: config.SERVICES_FILE,
|
|
CONFIG_FILE: config.CONFIG_FILE,
|
|
TOTP_CONFIG_FILE: config.TOTP_CONFIG_FILE,
|
|
TAILSCALE_CONFIG_FILE: config.TAILSCALE_CONFIG_FILE,
|
|
NOTIFICATIONS_FILE: config.NOTIFICATIONS_FILE,
|
|
ERROR_LOG_FILE: config.ERROR_LOG_FILE,
|
|
DNS_CREDENTIALS_FILE: config.DNS_CREDENTIALS_FILE,
|
|
CADDYFILE_PATH: config.CADDYFILE_PATH,
|
|
CADDY_ADMIN_URL: config.CADDY_ADMIN_URL,
|
|
|
|
// State managers
|
|
servicesStateManager,
|
|
configStateManager,
|
|
|
|
// Managers
|
|
credentialManager,
|
|
authManager,
|
|
licenseManager,
|
|
healthChecker,
|
|
updateManager,
|
|
backupManager,
|
|
resourceMonitor,
|
|
auditLogger,
|
|
portLockManager,
|
|
selfUpdater,
|
|
dockerMaintenance,
|
|
logDigest,
|
|
dockerSecurity,
|
|
|
|
// Templates
|
|
APP_TEMPLATES,
|
|
TEMPLATE_CATEGORIES,
|
|
DIFFICULTY_LEVELS,
|
|
RECIPE_TEMPLATES,
|
|
RECIPE_CATEGORIES,
|
|
|
|
// Helpers
|
|
asyncHandler: boundAsyncHandler,
|
|
errorResponse,
|
|
ok,
|
|
fetchT,
|
|
httpsAgent,
|
|
log,
|
|
logError: boundLogError,
|
|
safeErrorMessage,
|
|
getServiceById,
|
|
readConfig,
|
|
saveConfig,
|
|
addServiceToConfig,
|
|
validateURL,
|
|
strictLimiter,
|
|
totpConfig,
|
|
saveTotpConfig,
|
|
loadSiteConfig: () => config.loadSiteConfig(config.CONFIG_FILE, log),
|
|
loadNotificationConfig,
|
|
resyncHealthChecker,
|
|
|
|
// Middleware result
|
|
middlewareResult,
|
|
|
|
// App
|
|
app,
|
|
});
|
|
|
|
// Initialize workflow engine if bundled-workflows is available
|
|
if (bundledWorkflowsModule && ctx.docker) {
|
|
try {
|
|
const { WorkflowEngine } = bundledWorkflowsModule;
|
|
const workflowCtx = {
|
|
docker: ctx.docker,
|
|
notification: ctx.notification,
|
|
backupManager: ctx.backupManager,
|
|
resourceMonitor: ctx.resourceMonitor,
|
|
servicesStateManager: ctx.servicesStateManager
|
|
};
|
|
workflowEngine = new WorkflowEngine(workflowCtx);
|
|
ctx.workflowEngine = workflowEngine;
|
|
log.info('app', 'Workflow engine initialized');
|
|
} catch (err) {
|
|
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
|
|
}
|
|
}
|
|
|
|
// Initialize dependency manager
|
|
const dependencyManager = new DependencyManager({
|
|
servicesStateManager,
|
|
docker: ctx.docker,
|
|
notification: ctx.notification,
|
|
log,
|
|
});
|
|
ctx.dependencyManager = dependencyManager;
|
|
log.info('app', 'Dependency manager initialized');
|
|
|
|
// Initialize auto-restart manager
|
|
const autoRestartManager = new AutoRestartManager(ctx);
|
|
ctx.autoRestartManager = autoRestartManager;
|
|
autoRestartManager.start();
|
|
log.info('app', 'Auto-restart manager initialized');
|
|
|
|
// Initialize config drift detector
|
|
const driftDetector = new ConfigDriftDetector(ctx);
|
|
ctx.driftDetector = driftDetector;
|
|
driftDetector.startPolling(300000); // 5 min
|
|
log.info('app', 'Config drift detector initialized');
|
|
|
|
// Initialize SSL monitor
|
|
const sslMonitor = new SSLMonitor(ctx);
|
|
ctx.sslMonitor = sslMonitor;
|
|
sslMonitor.start(3600000); // 1 hour
|
|
log.info('app', 'SSL monitor initialized');
|
|
|
|
// Initialize DNS propagation checker
|
|
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
|
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
|
log.info('app', 'DNS propagation checker initialized');
|
|
|
|
// Build versioned API router
|
|
const apiRouter = express.Router();
|
|
|
|
// Version endpoint — public, no auth required
|
|
// Reads version from package.json at startup so the response always matches the running code
|
|
let appVersion = '0.0.0';
|
|
let appName = 'dashcaddy-api';
|
|
try {
|
|
const pkg = require('../package.json');
|
|
appVersion = pkg.version || appVersion;
|
|
appName = pkg.name || appName;
|
|
} catch { /* package.json unreadable — keep fallback */ }
|
|
apiRouter.get('/version', (req, res) => {
|
|
ok(res, {
|
|
name: appName,
|
|
version: appVersion,
|
|
node: process.version,
|
|
platform: process.platform,
|
|
arch: process.arch,
|
|
uptime: process.uptime(),
|
|
instanceId: process.env.DASHCADDY_INSTANCE_ID || null
|
|
});
|
|
});
|
|
log.info('app', `Version endpoint available at /api/v1/version (v${appVersion})`);
|
|
|
|
// Wire up notification listeners for resourceMonitor and backupManager
|
|
if (ctx.notification && ctx.resourceMonitor) {
|
|
ctx.resourceMonitor.on('alert', (alertData) => {
|
|
ctx.notification.sendAlert(alertData).catch(err => {
|
|
log.error('notification', 'Failed to send alert', { error: err.message });
|
|
});
|
|
});
|
|
ctx.resourceMonitor.on('auto-restart', (data) => {
|
|
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
|
|
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
if (ctx.notification && ctx.backupManager) {
|
|
ctx.backupManager.on('backup-complete', (data) => {
|
|
ctx.notification.send('backup-complete', data).catch(err => {
|
|
log.error('notification', 'Failed to send backup-complete', { error: err.message });
|
|
});
|
|
});
|
|
ctx.backupManager.on('backup-failed', (data) => {
|
|
ctx.notification.send('backup-failed', data).catch(err => {
|
|
log.error('notification', 'Failed to send backup-failed', { error: err.message });
|
|
});
|
|
});
|
|
}
|
|
|
|
// Mount route modules
|
|
apiRouter.use(authRoutes(ctx));
|
|
apiRouter.use(configRoutes(ctx));
|
|
apiRouter.use('/dns', dnsRoutes({
|
|
dns: ctx.dns,
|
|
siteConfig: ctx.siteConfig,
|
|
asyncHandler: ctx.asyncHandler,
|
|
log: ctx.log,
|
|
safeErrorMessage: ctx.safeErrorMessage,
|
|
fetchT: ctx.fetchT,
|
|
credentialManager: ctx.credentialManager,
|
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
|
}));
|
|
apiRouter.use('/notifications', notificationRoutes({
|
|
notification: ctx.notification,
|
|
asyncHandler: ctx.asyncHandler,
|
|
ok: ctx.ok
|
|
}));
|
|
apiRouter.use('/containers', containerRoutes({
|
|
docker: ctx.docker,
|
|
log: ctx.log,
|
|
asyncHandler: ctx.asyncHandler,
|
|
workflowEngine: ctx.workflowEngine
|
|
}));
|
|
apiRouter.use(serviceRoutes({
|
|
servicesStateManager: ctx.servicesStateManager,
|
|
credentialManager: ctx.credentialManager,
|
|
siteConfig: ctx.siteConfig,
|
|
buildServiceUrl: ctx.buildServiceUrl,
|
|
buildDomain: ctx.buildDomain,
|
|
fetchT: ctx.fetchT,
|
|
asyncHandler: ctx.asyncHandler,
|
|
SERVICES_FILE: ctx.SERVICES_FILE,
|
|
log: ctx.log,
|
|
safeErrorMessage: ctx.safeErrorMessage,
|
|
resyncHealthChecker: ctx.resyncHealthChecker,
|
|
caddy: ctx.caddy,
|
|
dns: ctx.dns
|
|
}));
|
|
apiRouter.use(healthRoutes({
|
|
fetchT: ctx.fetchT,
|
|
SERVICES_FILE: ctx.SERVICES_FILE,
|
|
servicesStateManager: ctx.servicesStateManager,
|
|
siteConfig: ctx.siteConfig,
|
|
buildServiceUrl: ctx.buildServiceUrl,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
healthChecker: ctx.healthChecker
|
|
}));
|
|
apiRouter.use(monitoringRoutes({
|
|
resourceMonitor: ctx.resourceMonitor,
|
|
docker: ctx.docker,
|
|
asyncHandler: ctx.asyncHandler,
|
|
log: ctx.log,
|
|
notificationManager: ctx.notification
|
|
}));
|
|
apiRouter.use(updatesRoutes({
|
|
updateManager: ctx.updateManager,
|
|
selfUpdater: ctx.selfUpdater,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
ok: ctx.ok
|
|
}));
|
|
apiRouter.use('/tailscale', tailscaleRoutes({
|
|
tailscale: ctx.tailscale,
|
|
caddy: ctx.caddy,
|
|
servicesStateManager: ctx.servicesStateManager,
|
|
credentialManager: ctx.credentialManager,
|
|
buildDomain: ctx.buildDomain,
|
|
asyncHandler: ctx.asyncHandler,
|
|
ok: ctx.ok,
|
|
SERVICES_FILE: ctx.SERVICES_FILE,
|
|
log: ctx.log
|
|
}));
|
|
apiRouter.use(sitesRoutes({
|
|
asyncHandler: ctx.asyncHandler,
|
|
ok: ctx.ok,
|
|
caddy: ctx.caddy,
|
|
dns: ctx.dns,
|
|
fetchT: ctx.fetchT,
|
|
buildDomain: ctx.buildDomain,
|
|
addServiceToConfig: ctx.addServiceToConfig,
|
|
siteConfig: ctx.siteConfig,
|
|
log: ctx.log
|
|
}));
|
|
apiRouter.use(credentialsRoutes({
|
|
credentialManager: ctx.credentialManager,
|
|
asyncHandler: ctx.asyncHandler
|
|
}));
|
|
apiRouter.use(arrRoutes(ctx));
|
|
apiRouter.use(appsRoutes(ctx));
|
|
apiRouter.use('/openclaw', openClawRoutes(ctx));
|
|
apiRouter.use(logsRoutes({
|
|
asyncHandler: ctx.asyncHandler,
|
|
ok: ctx.ok,
|
|
docker: ctx.docker,
|
|
logDigest: ctx.logDigest,
|
|
dockerMaintenance: ctx.dockerMaintenance
|
|
}));
|
|
apiRouter.use(backupsRoutes({
|
|
backupManager: ctx.backupManager,
|
|
licenseManager: ctx.licenseManager,
|
|
asyncHandler: ctx.asyncHandler
|
|
}));
|
|
apiRouter.use('/ca', caRoutes(ctx));
|
|
apiRouter.use(browseRoutes({
|
|
asyncHandler: ctx.asyncHandler,
|
|
validateSecurePath: ctx.validateSecurePath,
|
|
auditLogger: ctx.auditLogger,
|
|
docker: ctx.docker
|
|
}));
|
|
apiRouter.use(errorLogsRoutes({
|
|
ERROR_LOG_FILE: ctx.ERROR_LOG_FILE,
|
|
auditLogger: ctx.auditLogger,
|
|
asyncHandler: ctx.asyncHandler
|
|
}));
|
|
apiRouter.use('/license', licenseRoutes({
|
|
licenseManager: ctx.licenseManager,
|
|
asyncHandler: ctx.asyncHandler
|
|
}));
|
|
apiRouter.use('/recipes', recipesRoutes(ctx));
|
|
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler, log: ctx.log }));
|
|
apiRouter.use('/docker', dockerResourcesRoutes({
|
|
docker: ctx.docker,
|
|
asyncHandler: ctx.asyncHandler
|
|
}));
|
|
apiRouter.use('/events', eventsRoutes({
|
|
resourceMonitor: ctx.resourceMonitor,
|
|
healthChecker: ctx.healthChecker,
|
|
updateManager: ctx.updateManager,
|
|
logError: ctx.logError,
|
|
ok: ctx.ok,
|
|
dependencyManager: ctx.dependencyManager,
|
|
autoRestartManager: ctx.autoRestartManager,
|
|
driftDetector: ctx.driftDetector,
|
|
sslMonitor: ctx.sslMonitor,
|
|
dnsPropagationChecker: ctx.dnsPropagationChecker
|
|
}));
|
|
apiRouter.use('/workflows', workflowsRoutes({
|
|
workflowEngine: ctx.workflowEngine,
|
|
licenseManager: ctx.licenseManager,
|
|
asyncHandler: ctx.asyncHandler,
|
|
ok: ctx.ok
|
|
}));
|
|
apiRouter.use('/dependencies', dependenciesRoutes({
|
|
dependencyManager: ctx.dependencyManager,
|
|
servicesStateManager: ctx.servicesStateManager,
|
|
docker: ctx.docker,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
resyncHealthChecker: ctx.resyncHealthChecker,
|
|
log: ctx.log,
|
|
}));
|
|
apiRouter.use(autoRestartRoutes({
|
|
autoRestartManager: ctx.autoRestartManager,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
}));
|
|
apiRouter.use(configDriftRoutes({
|
|
driftDetector: ctx.driftDetector,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
}));
|
|
apiRouter.use(sslMonitorRoutes({
|
|
sslMonitor: ctx.sslMonitor,
|
|
asyncHandler: ctx.asyncHandler,
|
|
logError: ctx.logError,
|
|
}));
|
|
|
|
// Inline API routes (mounted under /api/v1 below)
|
|
// Note: /health lives at root only — see root-level health check below.
|
|
// Probes (/healthz, /readyz, /health/live, /health/ready) also at root only.
|
|
// Do NOT add another /api/v1/health route — it's been consolidated.
|
|
|
|
apiRouter.get('/csrf-token', (req, res) => {
|
|
ok(res, { token: req.csrfToken, headerName: CSRF_HEADER_NAME });
|
|
});
|
|
|
|
apiRouter.get('/metrics', (req, res) => {
|
|
ok(res, { metrics: metrics.getSummary() });
|
|
});
|
|
|
|
// Mount at /api/v1 (canonical, single version)
|
|
app.use('/api/v1', apiRouter);
|
|
|
|
// ===========================================================================
|
|
// Health probes — root-level, no auth, no CSRF, no rate limit.
|
|
//
|
|
// Two semantics, four paths:
|
|
//
|
|
// LIVENESS — "is the Node.js process alive?"
|
|
// /health/live (explicit, recommended)
|
|
// /healthz (k8s/Docker-standard alias)
|
|
// READINESS — "are critical dependencies reachable?"
|
|
// /health/ready (explicit, recommended)
|
|
// /readyz (k8s/Docker-standard alias)
|
|
//
|
|
// k8s/Docker/Caddy call these to decide whether to RESTART or ROUTE TRAFFIC.
|
|
// They MUST stay cheap (no DB queries, no logging side effects, no auth).
|
|
//
|
|
// Plain /health is kept for backwards compatibility and returns the same
|
|
// payload as /health/live. Use /health/live or /healthz in new code.
|
|
// ===========================================================================
|
|
|
|
// Liveness — pure process check, no deps.
|
|
const livenessHandler = (req, res) => {
|
|
ok(res, { status: 'alive', uptime: process.uptime() });
|
|
};
|
|
|
|
// Readiness — checks critical dependencies (config, services file,
|
|
// Docker daemon, Caddy admin). 200 if all OK, 503 if any failed.
|
|
const readinessHandler = boundAsyncHandler(async (req, res) => {
|
|
const checks = {};
|
|
let allOk = true;
|
|
|
|
// Check 1: Config file readable
|
|
try {
|
|
const fs = require('fs');
|
|
if (fs.existsSync(config.CONFIG_FILE)) {
|
|
fs.readFileSync(config.CONFIG_FILE, 'utf8');
|
|
checks.configFile = { ok: true };
|
|
} else {
|
|
checks.configFile = { ok: false, error: 'Config file not found' };
|
|
allOk = false;
|
|
}
|
|
} catch (e) {
|
|
checks.configFile = { ok: false, error: e.message };
|
|
allOk = false;
|
|
}
|
|
|
|
// Check 2: Services file readable
|
|
try {
|
|
const fs = require('fs');
|
|
if (fs.existsSync(config.SERVICES_FILE)) {
|
|
fs.readFileSync(config.SERVICES_FILE, 'utf8');
|
|
checks.servicesFile = { ok: true };
|
|
} else {
|
|
checks.servicesFile = { ok: false, error: 'Services file not found' };
|
|
allOk = false;
|
|
}
|
|
} catch (e) {
|
|
checks.servicesFile = { ok: false, error: e.message };
|
|
allOk = false;
|
|
}
|
|
|
|
// Check 3: Docker daemon reachable
|
|
try {
|
|
const docker = require('dockerode')();
|
|
await docker.ping();
|
|
checks.docker = { ok: true };
|
|
} catch (e) {
|
|
checks.docker = { ok: false, error: e.message };
|
|
allOk = false;
|
|
}
|
|
|
|
// Check 4: Caddy admin API reachable
|
|
try {
|
|
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
const response = await fetch(`${caddyUrl}/config/`, {
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timeout);
|
|
checks.caddy = { ok: response.ok, status: response.status };
|
|
if (!response.ok) allOk = false;
|
|
} catch (e) {
|
|
checks.caddy = { ok: false, error: e.message };
|
|
allOk = false;
|
|
}
|
|
|
|
const body = {
|
|
status: allOk ? 'ready' : 'not-ready',
|
|
timestamp: new Date().toISOString(),
|
|
checks
|
|
};
|
|
ok(res, body, allOk ? 200 : 503);
|
|
});
|
|
|
|
// Liveness paths
|
|
app.get('/health', livenessHandler);
|
|
app.get('/health/live', livenessHandler);
|
|
app.get('/healthz', livenessHandler);
|
|
|
|
// Readiness paths
|
|
app.get('/health/ready', readinessHandler);
|
|
app.get('/readyz', readinessHandler);
|
|
|
|
// Lightweight probe endpoint
|
|
app.get('/probe/:id', boundAsyncHandler(async (req, res) => {
|
|
const id = req.params.id;
|
|
const { exists } = require('./utilities/fs-helpers');
|
|
|
|
let service = null;
|
|
if (id !== 'internet' && await exists(config.SERVICES_FILE)) {
|
|
const data = await servicesStateManager.read();
|
|
const services = Array.isArray(data) ? data : data.services || [];
|
|
service = services.find(s => s.id === id);
|
|
}
|
|
|
|
const url = resolveServiceUrl(id, service, config.siteConfig, config.buildServiceUrl);
|
|
const parsed = new URL(url);
|
|
const isHttps = parsed.protocol === 'https:';
|
|
const lib = isHttps ? https : require('http');
|
|
|
|
const options = {
|
|
hostname: parsed.hostname,
|
|
port: parsed.port || (isHttps ? 443 : 80),
|
|
path: parsed.pathname + parsed.search,
|
|
method: 'HEAD',
|
|
timeout: 5000,
|
|
agent: isHttps ? httpsAgent : undefined,
|
|
headers: { 'User-Agent': APP.USER_AGENTS.PROBE },
|
|
};
|
|
|
|
const makeRequest = (method) => new Promise((resolve, reject) => {
|
|
const reqOpts = { ...options, method };
|
|
const probeReq = lib.request(reqOpts, (response) => {
|
|
response.resume();
|
|
resolve(response.statusCode);
|
|
});
|
|
probeReq.on('error', reject);
|
|
probeReq.on('timeout', () => { probeReq.destroy(); reject(new Error('Timeout')); });
|
|
probeReq.end();
|
|
});
|
|
|
|
let statusCode;
|
|
try {
|
|
statusCode = await makeRequest('HEAD');
|
|
if (statusCode === 501 || statusCode === 405) {
|
|
statusCode = await makeRequest('GET');
|
|
}
|
|
} catch {
|
|
// Direct probe failed — try Pylon relay if configured
|
|
const pylonConfig = config.siteConfig?.pylon;
|
|
if (pylonConfig?.url) {
|
|
try {
|
|
const pylonUrl = `${pylonConfig.url}/probe?url=${encodeURIComponent(url)}`;
|
|
const headers = { 'User-Agent': APP.USER_AGENTS.PROBE };
|
|
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
|
|
const controller = new AbortController();
|
|
const pylonTimeout = setTimeout(() => controller.abort(), 8000);
|
|
const pylonRes = await fetchT(pylonUrl, { method: 'GET', signal: controller.signal, headers });
|
|
clearTimeout(pylonTimeout);
|
|
if (pylonRes.ok) {
|
|
const data = await pylonRes.json();
|
|
statusCode = data.statusCode || 502;
|
|
}
|
|
} catch {
|
|
// Pylon also failed — fall through to domain fallback
|
|
}
|
|
}
|
|
|
|
// Domain-based fallback (last resort)
|
|
if (!statusCode) {
|
|
const fallbackUrl = `https://${config.buildDomain(id)}`;
|
|
const fp = new URL(fallbackUrl);
|
|
statusCode = await new Promise((resolve, reject) => {
|
|
const fReq = https.request({
|
|
hostname: fp.hostname,
|
|
port: 443,
|
|
path: '/',
|
|
method: 'GET',
|
|
timeout: 5000,
|
|
agent: httpsAgent,
|
|
headers: { 'User-Agent': APP.USER_AGENTS.PROBE }
|
|
}, (fRes) => {
|
|
fRes.resume();
|
|
resolve(fRes.statusCode);
|
|
});
|
|
fReq.on('error', reject);
|
|
fReq.on('timeout', () => { fReq.destroy(); reject(new Error('Timeout')); });
|
|
fReq.end();
|
|
}).catch(() => 502);
|
|
}
|
|
}
|
|
|
|
res.status(statusCode).send();
|
|
}, 'probe'));
|
|
|
|
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
|
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
|
function detectInterfaceIps() {
|
|
const os = require('os');
|
|
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
|
const all = [];
|
|
let lan = null;
|
|
let tailscale = null;
|
|
const interfaces = os.networkInterfaces();
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
for (const addr of addrs || []) {
|
|
if (addr.internal || addr.family !== 'IPv4') continue;
|
|
const { address: ip } = addr;
|
|
all.push({ name, ip });
|
|
if (!tailscale && ip.startsWith('100.')) {
|
|
tailscale = ip;
|
|
} else if (!lan && LAN_RANGE.test(ip)) {
|
|
lan = ip;
|
|
}
|
|
}
|
|
}
|
|
return { lan, tailscale, all };
|
|
}
|
|
|
|
// Network IPs endpoint
|
|
app.get('/api/v1/network/ips', (req, res) => {
|
|
try {
|
|
const envLan = process.env.HOST_LAN_IP;
|
|
const envTailscale = process.env.HOST_TAILSCALE_IP;
|
|
|
|
const result = {
|
|
localhost: '127.0.0.1',
|
|
lan: envLan || null,
|
|
tailscale: envTailscale || null,
|
|
all: []
|
|
};
|
|
|
|
if (!envLan || !envTailscale) {
|
|
result.all = collectNetworkInterfaces(os);
|
|
if (!result.tailscale) {
|
|
result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null;
|
|
}
|
|
if (!result.lan) {
|
|
result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null;
|
|
}
|
|
}
|
|
|
|
ok(res, result);
|
|
} catch (error) {
|
|
errorResponse(res, 500, safeErrorMessage(error));
|
|
}
|
|
});
|
|
|
|
// API Documentation
|
|
app.get('/api/v1/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/v1/docs/spec',dom_id:'#swagger-ui',deepLinking:true})</script>
|
|
</body>
|
|
</html>`);
|
|
});
|
|
|
|
app.get('/api/v1/docs/spec', boundAsyncHandler(async (req, res) => {
|
|
const path = require('path');
|
|
const { exists } = require('./utilities/fs-helpers');
|
|
const fsp = require('fs').promises;
|
|
|
|
const specPath = path.join(__dirname, '../openapi.yaml');
|
|
if (await exists(specPath)) {
|
|
const yaml = await fsp.readFile(specPath, 'utf8');
|
|
res.type('text/yaml').send(yaml);
|
|
} else {
|
|
errorResponse(res, 404, 'OpenAPI spec not found');
|
|
}
|
|
}, 'api-docs-spec'));
|
|
|
|
// Error handlers (MUST be last)
|
|
const { notFoundHandler, errorMiddleware } = require('./utilities/error-handler');
|
|
app.use('/api', notFoundHandler);
|
|
app.use(errorMiddleware);
|
|
|
|
return { app, log, config: config.siteConfig, licenseManager };
|
|
}
|
|
|
|
module.exports = { createApp };
|