Files
dashcaddy/dashcaddy-api/src/app.js
T
Hermes 7f831510bd
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[grade=B] DC-106: Caddyfile-as-code — visual reverse proxy builder API
3 endpoints:
- POST /api/v1/caddycode/generate — generate Caddyfile block from JSON config
  (supports: TLS, auth gate, CORS, headers, WebSocket, compression, strip prefix)
- POST /api/v1/caddycode/validate — validate Caddyfile syntax (brace balance,
  domain check, reverse_proxy presence)
- GET  /api/v1/caddycode/templates — 5 preset configs (simple, WebSocket,
  auth-gated, CORS API, subdirectory)

Frontend can present a visual form, send JSON, get back Caddyfile snippet.
1633 tests pass.
2026-08-12 12:54:17 -07:00

1084 lines
40 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 { createShareStore } = require('./security/share-store');
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 shareRoutes = require('../routes/share');
const i18nRoutes = require('../routes/i18n');
const discoverRoutes = require('../routes/discover');
const discoverAdoptRoutes = require('../routes/discover-adopt');
const catalogRoutes = require('../routes/catalog');
const wizardRoutes = require('../routes/wizard');
const disasterRoutes = require('../routes/disaster-recovery');
const caddycodeRoutes = require('../routes/caddycode');
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 tailscaleAdminRoutes = require('../routes/tailscale-admin');
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 securityRoutes = require('../routes/security');
const billingRoutes = require('../routes/billing');
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 diskSpaceRoutes = require('../routes/disk-space');
const { AutoRestartManager } = require('./managers/auto-restart-manager');
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
const SSLMonitor = require('./monitoring/ssl-monitor');
const { DiskSpaceMonitor } = require('./monitoring/disk-space-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);
// DC-053: share-store. Single shared instance, lazy file creation on first
// write. Lives alongside user-store/invite-store semantics (defensive
// dataDir resolver, atomic JSON writes). Always available — Free tier
// simply blocks creation via the route-level _requirePro gate.
const shareStore = createShareStore({
dataDir: platformPaths.dataDir,
platformPaths,
log,
});
// 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);
}
// Tailscale CGNAT classification. Imported from network-detector.js (DC-031)
// so there's one source of truth — the local copy here had no malformed-input
// guards and would return false on NaN silently.
const { isTailscaleIP } = require('./utilities/network-detector');
// Real implementation — delegates to the tailscale manager which
// shells out to the host's `tailscale status --json` (cached 5min).
// Kept here as a top-level function for back-compat with middleware.js
// and any other call site that imports it via the createApp() factory.
const { getStatus: getTailscaleStatus } = require('./managers/tailscale-manager');
// 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
// 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.
//
// Path mapping (any -> canonical):
// /api/auth/gate/<id> -> /api/v1/auth/gate/<id> (mounted at /auth/gate/:serviceId)
// /api/v1/auth/gate/<id> -> /api/v1/auth/gate/<id> (drift, gate pre-1.5.0 sometimes used this)
// /api/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (mounted at /auth/app-token/:serviceId)
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
//
// The totp case drops `/auth` because the canonical route is /totp/check-session
// (no /auth prefix) but the legacy JS still uses /api/auth/totp/check-session
// (and a stale-browser version of the page uses /api/v1/auth/totp/check-session).
// Without these rewrites the JS gets a 404 and the page hangs at
// "Signing in to Plex..." forever (user-reported 2026-07-09).
//
// sso-exchange added 2026-07-24: same Caddy handle_path /dashcaddy-api/*
// strips only the /dashcaddy-api prefix, so the login-page JS's fetch to
// /dashcaddy-api/api/auth/sso-exchange arrives here as /api/auth/sso-exchange
// — needs the same rewrite as gate/app-token, not the check-session one
// (this route's canonical mount already includes /auth/).
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
|| req.url.startsWith('/api/auth/sso-exchange')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
// Legacy: /api/auth/totp/check-session -> /api/v1/totp/check-session
// Drop both '/api' and '/auth' prefixes (9 chars total).
req.url = '/api/v1' + req.url.slice(9); // '/api/auth'.length === 9
} else if (req.url.startsWith('/api/v1/auth/totp/check-session')) {
// Drift: /api/v1/auth/totp/check-session -> /api/v1/totp/check-session
// Drop the '/api/v1/auth' prefix (12 chars), keep the leading '/'.
req.url = '/api/v1' + req.url.slice(12); // '/api/v1/auth'.length === 12
}
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,
// DC-053: share store + signing secret
shareStore,
// 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 disk space monitor (disk budget + auto-cleanup)
const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig });
ctx.diskSpaceMonitor = diskSpaceMonitor;
diskSpaceMonitor.start(600000); // 10 min
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
// 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));
// DC-053: share routes (public share links + Tailscale-mediated share).
// Always mounted — Free tier enforcement is at the route level, not the
// mount level, so the API surface is uniform across tiers (operators can
// upgrade without restarting route registration).
apiRouter.use(shareRoutes({
shareStore: ctx.shareStore,
licenseManager: ctx.licenseManager,
tailscaleCoord: ctx.tailscaleCoord,
notificationManager: ctx.notification,
servicesStateManager: ctx.servicesStateManager,
servicesFile: platformPaths.servicesFile,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
// DC-055: billing is PUBLIC (customer hasn't paid yet → no session).
// Stripe-session creation only; the webhook side runs in scripts/stripe-license-bridge.js.
apiRouter.use('/billing', billingRoutes({
asyncHandler: ctx.asyncHandler,
}));
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
}));
// DC-077: i18n — language metadata and translations (public, no auth needed)
apiRouter.use(i18nRoutes());
// DC-100: Service discovery — auto-detect running containers
apiRouter.use(discoverRoutes({
docker: ctx.docker,
servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler,
}));
// DC-103: One-click adopt — auto-generate routes + DNS + service entry
apiRouter.use(discoverAdoptRoutes({
docker: ctx.docker,
servicesStateManager: ctx.servicesStateManager,
caddy: ctx.caddy,
dns: ctx.dns,
siteConfig: ctx.config,
asyncHandler: ctx.asyncHandler,
}));
// DC-104: App catalog — browse curated templates
apiRouter.use(catalogRoutes({
APP_TEMPLATES: require('./docker/app-templates'),
asyncHandler: ctx.asyncHandler,
}));
// DC-105: Smart defaults wizard
apiRouter.use(wizardRoutes({
APP_TEMPLATES: require('./docker/app-templates'),
asyncHandler: ctx.asyncHandler,
}));
// DC-107: Disaster recovery — full backup + restore
apiRouter.use(disasterRoutes({
servicesStateManager: ctx.servicesStateManager,
platformPaths: require('../platform-paths'),
log: ctx.log,
asyncHandler: ctx.asyncHandler,
}));
// DC-106: Caddyfile-as-code — visual reverse proxy builder
apiRouter.use(caddycodeRoutes({
asyncHandler: ctx.asyncHandler,
}));
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('/tailscale', tailscaleAdminRoutes({
tailscaleCoord: ctx.tailscaleCoord,
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,
log: ctx.log,
logError: ctx.logError,
}));
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('/security', securityRoutes({
log: ctx.log,
}));
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,
}));
apiRouter.use('/disk', diskSpaceRoutes({
diskSpaceMonitor: ctx.diskSpaceMonitor,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
// 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() });
});
// DC-097: Prometheus text-format endpoint for Grafana/Prometheus scraping
apiRouter.get('/metrics/prometheus', (req, res) => {
res.set('Content-Type', 'text/plain; version=0.0.4');
res.send(metrics.toPrometheus());
});
// 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
// Use fetchT() (NOT native fetch) because undici fetch rejects Caddy admin
// on :2019, and probe the LIGHTEST endpoint (srv0/listen = 9 bytes) to avoid
// head-of-line blocking when /load or another config mutation is in flight.
// A previous `/config/` probe hit the 3s AbortController timeout with
// "This operation was aborted" while Caddy was actually healthy.
try {
const caddyUrl = config.CADDY_ADMIN_URL || 'http://localhost:2019';
const response = await fetchT(`${caddyUrl}/config/apps/http/servers/srv0/listen`, {}, 10000);
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');
// X-DashCaddy-HealthCheck: 1 — Caddy's (dashcaddy_auth) block matches
// this header (from local container IPs) to bypass the forward_auth gate.
// Without it, every probe hits authLimiter → 429 → marked TIMEOUT.
// See /etc/caddy/Caddyfile (dashcaddy_auth) and the matching logic in
// src/monitoring/health-checker.js (which sets the same marker).
const options = {
hostname: parsed.hostname,
port: parsed.port || (isHttps ? 443 : 80),
path: parsed.pathname + parsed.search,
method: 'HEAD',
timeout: 8000,
agent: isHttps ? httpsAgent : undefined,
headers: {
'User-Agent': APP.USER_AGENTS.PROBE,
'X-DashCaddy-HealthCheck': '1',
},
};
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)}`;
// Forward healthcheck marker to the remote pylon relay in case its Caddy
// is configured to bypass forward_auth on the same header.
const headers = {
'User-Agent': APP.USER_AGENTS.PROBE,
'X-DashCaddy-HealthCheck': '1',
};
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: 8000,
agent: httpsAgent,
headers: {
'User-Agent': APP.USER_AGENTS.PROBE,
'X-DashCaddy-HealthCheck': '1',
}
}, (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'));
// Network IPs endpoint — see src/utilities/network-detector.js for the
// classification logic. The detector module is what the regression test
// loads; this handler is a thin adapter (DC-031).
const { detectInterfaceIps } = require('./utilities/network-detector');
// 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) {
const detected = detectInterfaceIps();
result.all = detected.all;
if (!result.lan) result.lan = detected.lan;
if (!result.tailscale) result.tailscale = detected.tailscale;
}
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 };