Files
dashcaddy/dashcaddy-api/src/app.js
T
Hermes e80e3273b7 [pending-judge] feat(DC-163): AI assistant that works — LLM intent engine + fixed chat panel
The floating assistant was broken end-to-end; Sami said "Do it that works".
Three user-visible bugs fixed + the promised LLM delegation implemented:

FRONTEND (status/js/ai-chat.js — rewritten):
- Response parsing: old code read data.data; the API returns the FLAT ok()
  envelope — EVERY reply rendered "Sorry, something went wrong". Now reads
  the real shape (contract asserted by route test).
- Suggestion chips: onclick="...send('"Deploy Plex"')" — nested quotes,
  invalid JS, dead buttons (shipped like this). Now data-driven buttons with
  addEventListener.
- Deploy button: called window.openAppSelector which NEVER existed anywhere
  in the tree (grep-verified). Now opens the REAL deploy modal via new
  window.openDeployConfigFor(templateId) exported by app-selector.js —
  resolves against the live API template cache, reuses showDeployConfig
  (same path a manual catalog click takes). Widgets route to the grid.
- Conversation history (last 6 turns) now rides along per request, engine
  badge in header, ⚙ settings sub-panel (baseUrl/model/API key + Test +
  Remove), all server text via textContent (no HTML injection), CSP-safe
  (no inline handlers).

BACKEND (routes/ai-intent.js):
- TWO engines, one contract. LLM classifier (OpenAI-compatible chat
  completions) with system prompt embedding the LIVE 78-app catalog + the
  six wizard categories; regex patterns kept as the zero-config default and
  the automatic failover (HTTP error, timeout via AbortController,
  unparseable output -> pattern answers; response carries engine +
  llmError so the UI can say which served — no silent degradation).
- Closed-vocabulary schema validation: intent MUST be in the known set else
  'unknown'; appId MUST exist in APP_TEMPLATES else nulled (hallucinated
  ids cannot leak into deploy plans); categories filtered to the wizard's
  real keys; reply stripped of control chars + bounded to 600; history
  role-filtered (only user/assistant pass — client-injected system turns
  dropped) + capped; input capped to 1000 chars.
- enrich() rebuilds the response contract per intent; pattern-canned
  response is dropped when the LLM rerouted the intent (no stale shape);
  deployPlan body.port now fills from the template's defaultPort (was
  hardcoded null); deployPlan shape asserted against wizard usage.
- Key storage: PUT /ai/settings stores {baseUrl,model,apiKey} in the
  ENCRYPTED credential store (key 'ai:llm') via ctx.credentialManager —
  NOT config.json (GET /config echoes the raw file to the browser; that
  route's shape was verified before this decision). Blank apiKey on PUT
  keeps the stored one. GET /ai/settings reports configured/source/model/
  hasApiKey — the key value NEVER crosses the wire (asserted). Env
  fallback AI_LLM_BASE_URL/AI_LLM_API_KEY/AI_LLM_MODEL + legacy
  LLM_PROXY_URL for headless installs. POST /ai/settings/test = one-shot
  real classify probe returning ok/sample or ok:false/reason (never
  throws). DELETE clears.
- GET /ai/capabilities now reports engine + llm status (pattern was honest
  marketing before; now it can be true).

MOUNT (src/app.js): aiIntentRoutes receives siteConfig, log, fetchImpl
(ctx.fetchT — test-injectable) and credentialManager.

Docs: OpenAPI +4 AI path entries (intent, capabilities, settings, settings/test); CHANGELOG entry; dist bundle rebuilt
(openDeployConfigFor present in features.js; SW cache c62511c40d).

Tests: 20 NEW in __tests__/routes/ai-intent-engine.test.js (engine
selection w/ and w/o store, off-catalog phrasing via mock LLM,
hallucinated-id nulling, illegal intent coercion, HTTP-500 failover,
AbortError timeout failover, markdown-fence repair, reply sanitization,
history cap+role filter, env config path, intent-disagreement rebuild w/
real jellyfin port 8096, flat envelope contract, 400s, 1000-char cap,
settings GET/PUT/DELETE/test incl. key-never-echoed assertions,
capabilities engine reporting) + the 18 original pattern tests unchanged +
composition-root e2e 8 pass. Full suite: 3643 passed; only the pre-existing
pdf-parse billing family red (6 failed in documented band [5,7], patch
touches no billing file).
2026-10-08 14:42:31 -07:00

1392 lines
53 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');
// DC-048 — rehydrate process.env from disk-settings.json BEFORE any engine
// module reads env at module-load time. Must run before health-checker,
// audit-logger, and the backups route module (backups.js reads
// BACKUP_MAX_STORAGE_BYTES at module load too).
require('./config/disk-settings-loader')();
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');
require("./utilities/nesting-guard")();
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 fleetRoutes = require('../routes/fleet');
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 networkPeopleRoutes = require('../routes/network-people');
const networkAccessRoutes = require('../routes/network-access');
const networkInvitesRoutes = require('../routes/network-invites');
const networkLifecycleRoutes = require('../routes/network-lifecycle');
const networkGroupsRoutes = require('../routes/network-groups');
const networkResourcesRoutes = require('../routes/network-resources');
const networkWizardRoutes = require('../routes/network-wizard');
const networkWgRedeemRoutes = require('../routes/network-wg-redeem');
const sitesRoutes = require('../routes/sites');
const credentialsRoutes = require('../routes/credentials');
const arrRoutes = require('../routes/arr');
const appsRoutes = require('../routes/apps');
// DC-161: calendar widget backend (CalDAV/ICS sources + Radicale auto-wiring)
const calendarRoutes = require('../routes/calendar');
// DC-162: MCP over HTTP — "Connect your AI" (POST /api/v1/mcp JSON-RPC)
const mcpRoutes = require('../routes/mcp');
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 deploysRoutes = require('../routes/deploys');
const diskSettingsRoutes = require('../routes/disk-settings');
const aiIntentRoutes = require('../routes/ai-intent');
const logInsightsRoutes = require('../routes/log-insights');
const auditLogRoutes = require('../routes/audit-log');
const billingRoutes = require('../routes/billing');
const caddyUpstreamRoutes = require('../routes/caddy-upstreams');
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 caddyUpstreamWatcher = require('./monitoring/caddy-upstream-watcher');
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-161: calendar sources live next to services.json (same dataDir guarantees).
// Passed into assembleContext so the apps deploy hook (ctx.calendarStateManager)
// can auto-wire the widget when a calendar app is deployed.
const calendarStateManager = new StateManager(
require('path').join(require('path').dirname(config.SERVICES_FILE), 'calendar-sources.json')
);
// 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> -> (unchanged — already canonical, DC-111)
// /api/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (mounted at /auth/app-token/:serviceId)
// /api/v1/auth/app-token/<id> -> (unchanged — already canonical, DC-111)
// /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) => {
// DC-111: the '/api/v1/...' drift variants are ALREADY canonical — the
// gate and app-token routes mount at /auth/* INSIDE the /api/v1 router.
// DC-044 added them to the '/api' + slice(4) rewrite, which turned
// /api/v1/auth/gate/plex into /api/v1/v1/auth/gate/plex → 401/404 for
// every canonical-URI client (the exact drift case DC-044 meant to
// tolerate). Only the legacy '/api/auth/...' shapes need rewriting.
if (req.url.startsWith('/api/auth/gate/')
|| req.url.startsWith('/api/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
// (canonical route is /totp/check-session — genuinely different mount)
// 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', e, null, { note: 'Could not save TOTP config' });
}
}
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,
calendarStateManager,
// 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', err, null, { note: 'Failed to initialize workflow engine' });
}
}
// 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 caddy upstream watcher — independent probes of every
// reverse_proxy directive in /etc/caddy/sites/, emits 'dead' incidents
// after 5min of consecutive failures (so a single blip doesn't page).
caddyUpstreamWatcher.log = log;
caddyUpstreamWatcher.healthChecker = healthChecker;
caddyUpstreamWatcher.start();
ctx.caddyUpstreamWatcher = caddyUpstreamWatcher;
log.info('app', 'Caddy upstream watcher 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.
// The handler is implemented in routes/version.js but is registered inline here so
// public-routes-drift.test.js (which walks apiRouter.stack directly) can see it.
let appVersion = '0.0.0';
let appName = 'dashcaddy-api';
const versionRoute = require('../routes/version');
appVersion = versionRoute.getVersion();
appName = versionRoute.getName();
// Pre-build the version router once at startup and reuse it.
const versionRouter = versionRoute.buildRouter();
apiRouter.use(versionRouter);
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', err, null, { note: 'Failed to send alert' });
});
});
ctx.resourceMonitor.on('auto-restart', (data) => {
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
log.error('notification', err, null, { note: 'Failed to send auto-restart notification' });
});
});
}
if (ctx.notification && ctx.backupManager) {
ctx.backupManager.on('backup-complete', (data) => {
ctx.notification.send('backup-complete', data).catch(err => {
log.error('notification', err, null, { note: 'Failed to send backup-complete' });
});
});
ctx.backupManager.on('backup-failed', (data) => {
ctx.notification.send('backup-failed', data).catch(err => {
log.error('notification', err, null, { note: 'Failed to send backup-failed' });
});
});
}
// 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,
fetchT: ctx.fetchT,
asyncHandler: ctx.asyncHandler,
}));
// DC-104: App catalog — browse curated templates
const { APP_TEMPLATES: templatesArray } = require('./docker/app-templates');
apiRouter.use(catalogRoutes({
APP_TEMPLATES: templatesArray,
asyncHandler: ctx.asyncHandler,
}));
// DC-105: Smart defaults wizard
apiRouter.use(wizardRoutes({
APP_TEMPLATES: templatesArray,
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,
}));
// DC-108: Multi-host fleet management
apiRouter.use(fleetRoutes({
log: ctx.log,
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,
}));
// ── P7 Private Network mounts (DC-140..DC-152) ──────────────────────────
// Shared stores; providers self-register via src/network/index.js when the
// registry is required (tailscale + wireguard).
require('./network/index');
const { getActiveProvider } = require('./network/registry');
const p7PeopleStore = require('./security/network-people-store').createPeopleStore({ log: ctx.log });
const p7AccessStore = require('./security/network-access-store').createNetworkAccessStore({ log: ctx.log });
const p7ResourcesStore = require('./security/network-resources').createResourcesStore({ log: ctx.log });
const p7GroupsService = require('./security/network-groups').createGroupsService({
peopleStore: p7PeopleStore, accessStore: p7AccessStore, log: ctx.log,
});
const p7LifecycleModule = require('./network/lifecycle');
const p7LifecycleProxy = {
blockPerson: async (id) => p7GetLifecycle().blockPerson(id),
unblockPerson: async (id) => p7GetLifecycle().unblockPerson(id),
removePerson: async (id) => p7GetLifecycle().removePerson(id),
revokeDevice: async (pid, did) => p7GetLifecycle().revokeDevice(pid, did),
};
let p7LifecycleInstance = null;
function p7GetLifecycle() {
if (!p7LifecycleInstance) {
p7LifecycleInstance = p7LifecycleModule.createNetworkLifecycle({
provider: getActiveProvider(),
peopleStore: p7PeopleStore,
accessStore: p7AccessStore,
logger: ctx.log,
});
}
return p7LifecycleInstance;
}
apiRouter.use('/network/people', networkPeopleRoutes({
peopleStore: p7PeopleStore,
networkRegistry: { getActiveProvider },
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
apiRouter.use('/network/access', networkAccessRoutes({
accessStore: p7AccessStore,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
apiRouter.use('/network/invites', networkInvitesRoutes({
networkRegistry: { getActiveProvider },
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
apiRouter.use('/network', networkLifecycleRoutes({
lifecycle: p7LifecycleProxy,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
apiRouter.use('/network/groups', networkGroupsRoutes({
groupsService: p7GroupsService,
peopleStore: p7PeopleStore,
accessStore: p7AccessStore,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
apiRouter.use('/network/resources', networkResourcesRoutes({
resourcesStore: p7ResourcesStore,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
// Private Network wizard (DC-152). Dogfood finding 2026-10-04: the wizard
// factory needs INSTANCES keyed by mode id; without them every mode
// selection 503s provider_unavailable (unit tests inject their own map).
// Coord client wiring (dogfood finding #2, 2026-10-04): the provider is
// built with coordClient=null and nothing ever called attachCoordClient()
// in prod (the promised routes/network.js wiring never landed), so wizard
// credential validation always failed not_configured. Delegate every coord
// method to a FRESH client per call — the sanctioned context pattern
// (ctx.tailscaleCoord.getClient re-reads the token from credentialManager
// each call, so wizard-stored tokens take effect without a restart).
const p7CoordProxy = new Proxy({}, {
get(_t, prop) {
if (typeof prop !== 'string') return undefined;
return async (...args) => {
const client = await ctx.tailscaleCoord.getClient();
const fn = client ? client[prop] : null;
if (typeof fn !== 'function') {
throw new Error('coord client method unavailable: ' + String(prop));
}
return fn.apply(client, args);
};
},
});
const p7TailscaleProvider = require('./network/providers/tailscale').createTailscaleProvider({ // eslint-disable-line global-require
coordClient: p7CoordProxy,
});
const p7WireGuardProvider = require('./network/providers/wireguard').createWireGuardProvider(); // eslint-disable-line global-require
apiRouter.use('/network/wizard', networkWizardRoutes({
registry: { getActiveProvider },
providers: { tailscale: p7TailscaleProvider, wireguard: p7WireGuardProvider },
peopleStore: p7PeopleStore,
accessStore: p7AccessStore,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
}));
// Public WG invite redemption (DC-150). Lazy provider resolution: the
// factory requires a provider at construction; get one only when the
// WireGuard provider is registered, else mount a 503 stub.
const p7WgProviderFactory = () => {
let prov = null;
try {
prov = getActiveProvider();
} catch (_inactive) {
return null; // registry throws ProviderNotActiveError when inactive
}
return prov && prov.id && prov.id() === 'wireguard' ? prov : null;
};
let p7WgRedeemRouter = null;
const p7WgRedeemLazy = (req, res, next) => {
if (!p7WgRedeemRouter) {
const prov = p7WgProviderFactory();
if (!prov) {
return res.status(503).json({ error: 'network provider not active' });
}
p7WgRedeemRouter = networkWgRedeemRoutes({
provider: prov,
endpoint: ctx.networkWgEndpoint,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
});
}
return p7WgRedeemRouter(req, res, next);
};
apiRouter.use('/network/wg', p7WgRedeemLazy);
// forward_auth enforcement (DC-154, spec §4 Layer 2). Same lazy-provider
// pattern as p7WgRedeemLazy: the check service needs an active provider
// (any id — whois is part of the shared provider contract); without one,
// 503 { error: 'network provider not active' }. NO auth middleware in
// front — this route IS the auth check, called by Caddy forward_auth.
// Caddy passes the client IP via X-Forwarded-For; the route honors it.
const networkForwardAuthRoutes = require('../routes/network-forward-auth'); // eslint-disable-line global-require
const { createForwardAuthService } = require('./network/forward-auth'); // eslint-disable-line global-require
let p7ForwardAuthRouter = null;
const p7ForwardAuthLazy = (req, res, next) => {
if (!p7ForwardAuthRouter) {
let prov = null;
try {
prov = getActiveProvider();
} catch (_inactive) {
prov = null; // registry throws ProviderNotActiveError when inactive
}
if (!prov) {
return res.status(503).json({ error: 'network provider not active' });
}
const service = createForwardAuthService({
provider: prov,
accessStore: p7AccessStore,
resourcesStore: p7ResourcesStore,
peopleStore: p7PeopleStore,
// Reuse the cookie-only session validation from middleware.js (the
// same isSessionValid the TOTP gate uses) — never a reimplementation.
sessionValidator: (r) => Promise.resolve(middlewareResult.isSessionValid(r)),
logger: ctx.log,
auditLog: {
logForwardAuth: (entry) => ctx.auditLog
? ctx.auditLog.log({
action: 'network.' + entry.event,
resource: entry.resource || entry.host || '',
details: {
event: entry.event,
personId: entry.personId,
resource: entry.resource,
host: entry.host,
outcome: entry.outcome,
},
outcome: entry.outcome || 'unknown',
})
: Promise.resolve(),
},
});
p7ForwardAuthRouter = networkForwardAuthRoutes({
service,
asyncHandler: ctx.asyncHandler,
trustedProxies: (config.FORWARD_AUTH_TRUSTED_PROXIES || '127.0.0.1,::1')
.split(',').map((s) => s.trim()).filter(Boolean),
log: ctx.log,
});
}
return p7ForwardAuthRouter(req, res, next);
};
apiRouter.use('/network/forward-auth', p7ForwardAuthLazy);
// ── end P7 mounts ───────────────────────────────────────────────────────
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));
// DC-161: calendar — sources CRUD, aggregated events, Radicale auto-provision
apiRouter.use('/calendar', calendarRoutes({
calendarStateManager,
credentialManager: ctx.credentialManager,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,
errorResponse: ctx.errorResponse,
log: ctx.log,
fetchT: ctx.fetchT,
buildServiceUrl: ctx.buildServiceUrl,
}));
// DC-162: MCP-over-HTTP — same authenticated namespace, JSON-RPC 2.0.
// authManager mints connection keys in /mcp/config (session-only route).
apiRouter.use('/mcp', mcpRoutes({
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log,
authManager: ctx.authManager,
fetchImpl: ctx.fetchT,
}));
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,
}));
// DC-Shield: per-app security badges from the sec-dashboard snapshot.
apiRouter.use('/security', require('../routes/shield')({
log: ctx.log,
}));
// DC-130: shipdeck deploys — source deploys via the host-side bridge.
// Feature-flagged: 501 unless SHIPDECK_BRIDGE_URL is set (opt-in pattern).
apiRouter.use('/deploys', deploysRoutes({
asyncHandler: ctx.asyncHandler,
log: ctx.log,
auditLogger: ctx.auditLogger,
fetchT: ctx.fetchT,
}));
// Log Insights — plain English activity summary + safe log disposal
apiRouter.use('/disk-settings', diskSettingsRoutes);
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler, siteConfig: ctx.siteConfig, log: ctx.log, credentialManager: ctx.credentialManager }));
apiRouter.use(logInsightsRoutes({
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,
auditLogger: ctx.auditLogger,
securityEventStore: (function() {
try {
var getStore = require('./security/event-store').getStore;
return getStore();
} catch (e) { return null; }
})()
}));
// DC-050 — Audit log viewer route. The frontend at status/js/audit-log.js
// has been calling /api/v1/audit-logs since 2026-05-27; before this route
// existed the dashboard silently 404'd. The audit-logger module already
// exposes query() and clear() — this route just gives them an HTTP shape.
apiRouter.use(auditLogRoutes({
asyncHandler: ctx.asyncHandler,
auditLogger: ctx.auditLogger,
}));
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(caddyUpstreamRoutes({
caddyUpstreamWatcher: ctx.caddyUpstreamWatcher,
healthChecker: ctx.healthChecker,
asyncHandler: ctx.asyncHandler,
}));
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);
// Expose ctx on the app for entry points (server.js dashboard-WS wiring)
// without changing the returned shape for existing callers/tests.
app.locals.ctx = ctx;
return { app, log, config: config.siteConfig, licenseManager };
}
module.exports = { createApp };