Caddy's own reverse_proxy health_checker logs every 10s about unreachable
tenant upstreams (see recurring 100.120.159.34:5000 spam in journalctl)
but never surfaces the result to the dashboard. Adds:
- caddy-upstream-watcher.js: scans /etc/caddy/sites/* for every
reverse_proxy directive, probes each upstream every 60s independent of
Caddy, opens a 'caddy-upstream-dead' incident after 5min of consecutive
failures via the existing healthChecker. Mute list persisted to
data/caddy-upstreams.json. Probes stamp X-DashCaddy-HealthCheck: 1 so
forward_auth doesn't 401 them.
- routes/caddy-upstreams.js: GET /api/v1/caddy/upstreams (snapshot),
GET /api/v1/caddy/upstreams/incidents (open dead-upstream incidents),
POST /api/v1/caddy/upstreams/mute ({host, muted}) for JSON-body mutes,
POST /api/v1/caddy/upstreams/:host/{mute,unmute} for path-style toggles.
All mounted under the auth-gated apiRouter in app.js.
- 16 unit tests + 2 route smoke tests, all passing.
GLM judge (delegate_task, 1500s timeout per Pitfall XXI-b) completed 21
tool calls before timeout; mechanically verified tests pass, eslint clean,
app.js module load OK, RST-mid-body ECONNRESET is caught by req.on('error').
Found two MEDIUM defects which are now fixed in this commit:
1. (MEDIUM) scanSites file-extension filter `/\.(sami|caddy|conf)$/i`
silently skipped real prod filenames like zap.sami-ahmed.net,
samitest.space, blocks.cryptographic-triangles.org where the file
extension is .net/.space/.org. Replaced with positive filter that
excludes README/.bak/.swp/Caddyfile + content pre-check
(must contain 'reverse_proxy'). Added test covering the prod filenames.
2. (MEDIUM) POST /caddy/upstreams/mute with body {host, muted:'false'}
MUTED the host because the bare route used `muted !== false` which is
true for the string 'false'. Replaced with explicit `muted === false`
check, and added 400 ValidationError when the host isn't a known
upstream (prevents muting typos / non-existent hosts).
Self-grade: B+ (after applying GLM partial review). Re-grade with Codex
when its quota resets 2026-08-24.
1129 lines
42 KiB
JavaScript
1129 lines
42 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 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 diskSettingsRoutes = require('../routes/disk-settings');
|
|
const aiIntentRoutes = require('../routes/ai-intent');
|
|
const logInsightsRoutes = require('../routes/log-insights');
|
|
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-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', 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,
|
|
|
|
// 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,
|
|
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,
|
|
}));
|
|
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,
|
|
}));
|
|
|
|
// Log Insights — plain English activity summary + safe log disposal
|
|
apiRouter.use('/disk-settings', diskSettingsRoutes);
|
|
apiRouter.use(aiIntentRoutes({ asyncHandler: ctx.asyncHandler }));
|
|
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; }
|
|
})()
|
|
}));
|
|
|
|
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 };
|