From 2815233e86b6066143b9233658bd06436d6cc772 Mon Sep 17 00:00:00 2001 From: Sami Date: Sat, 14 Mar 2026 23:01:20 -0700 Subject: [PATCH] Unify URL resolution, add health checker sync, and make modules optional - Add url-resolver.js with single resolveServiceUrl() used by all 5 consumers (probes, health routes, health checker auto-config) - Health checker now does full sync (add/update/remove) instead of add-only, and re-syncs automatically after every services.json mutation - docker-maintenance and log-digest are now optional imports with try/catch, preventing container crashes when these files are absent - Add null guards in routes/logs.js for graceful 503 responses Co-Authored-By: Claude Opus 4.6 --- dashcaddy-api/routes/context.js | 2 + dashcaddy-api/routes/health.js | 23 +----- dashcaddy-api/routes/logs.js | 7 ++ dashcaddy-api/routes/services.js | 10 +-- dashcaddy-api/server.js | 108 ++++++++++++++--------------- dashcaddy-api/startup-validator.js | 69 +++++++++++------- dashcaddy-api/url-resolver.js | 32 +++++++++ 7 files changed, 145 insertions(+), 106 deletions(-) create mode 100644 dashcaddy-api/url-resolver.js diff --git a/dashcaddy-api/routes/context.js b/dashcaddy-api/routes/context.js index 8ad8acd..7f0518d 100644 --- a/dashcaddy-api/routes/context.js +++ b/dashcaddy-api/routes/context.js @@ -111,10 +111,12 @@ const ctx = { logError: null, safeErrorMessage: null, buildDomain: null, + buildServiceUrl: null, getServiceById: null, readConfig: null, saveConfig: null, addServiceToConfig: null, + resyncHealthChecker: null, validateURL: null, // Middleware diff --git a/dashcaddy-api/routes/health.js b/dashcaddy-api/routes/health.js index 8221379..ac1cbe5 100644 --- a/dashcaddy-api/routes/health.js +++ b/dashcaddy-api/routes/health.js @@ -6,6 +6,7 @@ const { TIMEOUTS } = require('../constants'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const platformPaths = require('../platform-paths'); +const { resolveServiceUrl } = require('../url-resolver'); module.exports = function(ctx) { const router = express.Router(); @@ -36,18 +37,7 @@ module.exports = function(ctx) { let checkType = 'http'; // Determine URL to check - if (service.isExternal && service.externalUrl) { - url = service.externalUrl; - } else if (service.containerId || service.containerName) { - // Local container - check via localhost and port - const port = service.port || 80; - url = `http://localhost:${port}`; - } else if (service.url) { - url = service.url.startsWith('http') ? service.url : `https://${service.url}`; - } else if (service.id) { - // Try common URL pattern - url = `https://${ctx.buildDomain(service.id)}`; - } + url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); if (!url) { health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; @@ -157,14 +147,7 @@ module.exports = function(ctx) { } // Determine URL - let url = null; - if (service.isExternal && service.externalUrl) { - url = service.externalUrl; - } else if (service.url) { - url = service.url.startsWith('http') ? service.url : `https://${service.url}`; - } else { - url = `https://${ctx.buildDomain(serviceId)}`; - } + const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); // Check health const controller = new AbortController(); diff --git a/dashcaddy-api/routes/logs.js b/dashcaddy-api/routes/logs.js index da0cc60..e59e13d 100644 --- a/dashcaddy-api/routes/logs.js +++ b/dashcaddy-api/routes/logs.js @@ -140,6 +140,7 @@ module.exports = function(ctx) { // Get latest daily digest router.get('/logs/digest/latest', ctx.asyncHandler(async (req, res) => { + if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const digest = await ctx.logDigest.getLatestDigest(); if (!digest) { return res.json({ success: true, digest: null, message: 'No digest available yet. First digest is generated at midnight.' }); @@ -149,18 +150,21 @@ module.exports = function(ctx) { // Get live digest data (today's accumulated stats) router.get('/logs/digest/live', ctx.asyncHandler(async (req, res) => { + if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const live = ctx.logDigest.getLiveData(); res.json({ success: true, ...live }); }, 'logs-digest-live')); // List available digest dates router.get('/logs/digest/history', ctx.asyncHandler(async (req, res) => { + if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const dates = await ctx.logDigest.listDigests(); res.json({ success: true, dates }); }, 'logs-digest-history')); // Generate digest on demand (for today or a specific date) router.post('/logs/digest/generate', ctx.asyncHandler(async (req, res) => { + if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const date = req.body.date || new Date().toISOString().slice(0, 10); const digest = await ctx.logDigest.generateDailyDigest(date); res.json({ success: true, digest }); @@ -168,6 +172,7 @@ module.exports = function(ctx) { // Get digest for a specific date (JSON) router.get('/logs/digest/:date', ctx.asyncHandler(async (req, res) => { + if (!ctx.logDigest) return ctx.errorResponse(res, 503, 'Log digest not available'); const { date } = req.params; if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { return ctx.errorResponse(res, 400, 'Invalid date format. Use YYYY-MM-DD.'); @@ -186,6 +191,7 @@ module.exports = function(ctx) { // Get Docker disk usage snapshot router.get('/logs/docker-disk', ctx.asyncHandler(async (req, res) => { + if (!ctx.dockerMaintenance) return ctx.errorResponse(res, 503, 'Docker maintenance not available'); const diskUsage = await ctx.dockerMaintenance.getDiskUsage(); const status = ctx.dockerMaintenance.getStatus(); res.json({ success: true, diskUsage, maintenance: status }); @@ -193,6 +199,7 @@ module.exports = function(ctx) { // Trigger Docker maintenance manually router.post('/logs/docker-maintenance', ctx.asyncHandler(async (req, res) => { + if (!ctx.dockerMaintenance) return ctx.errorResponse(res, 503, 'Docker maintenance not available'); const result = await ctx.dockerMaintenance.runMaintenance(); res.json({ success: true, result }); }, 'logs-docker-maintenance')); diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index abedd0f..d06836d 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -8,6 +8,7 @@ const { APP, REGEX, TIMEOUTS } = require('../constants'); const { validateServiceConfig, isValidPort } = require('../input-validator'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); +const { resolveServiceUrl } = require('../url-resolver'); module.exports = function(ctx) { const router = express.Router(); @@ -33,10 +34,7 @@ module.exports = function(ctx) { } function resolveProbeUrl(id, service) { - if (id === 'internet') return 'https://www.google.com'; - if (service?.isExternal && service.externalUrl) return service.externalUrl; - if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`; - return ctx.buildServiceUrl(id); + return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl); } function requestStatusCode(url, method) { @@ -308,6 +306,7 @@ module.exports = function(ctx) { return services; }); + ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service "${name}" added to dashboard` }); } catch (error) { ctx.log.error('deploy', 'Error adding service', { error: error.message }); @@ -339,6 +338,7 @@ module.exports = function(ctx) { } await ctx.servicesStateManager.write(services); + ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, @@ -367,6 +367,7 @@ module.exports = function(ctx) { return ctx.errorResponse(res, 404, `Service "${id}" not found`); } + ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service "${id}" removed from dashboard` }); }, 'services-delete')); @@ -454,6 +455,7 @@ module.exports = function(ctx) { }); } + ctx.resyncHealthChecker?.().catch(() => {}); res.json({ success: true, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, diff --git a/dashcaddy-api/server.js b/dashcaddy-api/server.js index 64ec6e4..400e1cd 100644 --- a/dashcaddy-api/server.js +++ b/dashcaddy-api/server.js @@ -17,6 +17,7 @@ const credentialManager = require('./credential-manager'); const { CACHE_CONFIGS, createCache } = require('./cache-config'); const { AppError } = require('./errors'); const { validateConfig } = require('./config-schema'); +const { resolveServiceUrl } = require('./url-resolver'); const { APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES, SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS, @@ -50,8 +51,10 @@ const backupManager = require('./backup-manager'); const healthChecker = require('./health-checker'); const updateManager = require('./update-manager'); const selfUpdater = require('./self-updater'); -const dockerMaintenance = require('./docker-maintenance'); -const logDigest = require('./log-digest'); +let dockerMaintenance; +try { dockerMaintenance = require('./docker-maintenance'); } catch (_) { console.warn('[WARN] docker-maintenance module not found, skipped'); } +let logDigest; +try { logDigest = require('./log-digest'); } catch (_) { console.warn('[WARN] log-digest module not found, skipped'); } const StateManager = require('./state-manager'); const auditLogger = require('./audit-logger'); const portLockManager = require('./port-lock-manager'); @@ -1173,6 +1176,7 @@ Object.assign(ctx, { loadDnsCredentials: () => {}, SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE, NOTIFICATIONS_FILE, ERROR_LOG_FILE, + resyncHealthChecker: () => syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }), }); // Build versioned API router — all route modules attach here @@ -1225,30 +1229,16 @@ app.get('/probe/:id', asyncHandler(async (req, res) => { const id = req.params.id; try { - let url; - - if (id === 'internet') { - // Internet connectivity check - url = 'https://www.google.com'; - } else { - // Look up service in services.json for custom URLs - let service = null; - if (await exists(SERVICES_FILE)) { - const data = await servicesStateManager.read(); - const services = Array.isArray(data) ? data : data.services || []; - service = services.find(s => s.id === id); - } - - if (service?.isExternal && service?.externalUrl) { - url = service.externalUrl; - } else if (service?.url) { - url = service.url.startsWith('http') ? service.url : `https://${service.url}`; - } else { - // Build URL from configured routing mode (subdomain or subdirectory) - url = buildServiceUrl(id); - } + // Look up service in services.json + let service = null; + if (id !== 'internet' && await exists(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, siteConfig, buildServiceUrl); + const parsed = new URL(url); const isHttps = parsed.protocol === 'https:'; const lib = isHttps ? https : http; @@ -1734,6 +1724,8 @@ async function addServiceToConfig(service) { return services; }); log.info('deploy', 'Service added to config', { serviceId: service.id }); + // Sync health checker with updated services list + ctx.resyncHealthChecker?.().catch(() => {}); } catch (error) { log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message }); throw error; @@ -1852,7 +1844,7 @@ const server = app.listen(PORT, '0.0.0.0', () => { (async () => { try { // Auto-configure health checker from services.json - await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP }); + await syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }); healthChecker.start(); log.info('server', 'Health checker started'); } catch (error) { @@ -1887,37 +1879,41 @@ const server = app.listen(PORT, '0.0.0.0', () => { log.error('server', 'Self-updater failed to start', { error: error.message }); } - try { - dockerMaintenance.start(); - log.info('server', 'Docker maintenance started'); - dockerMaintenance.on('maintenance-complete', (result) => { - const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); - if (saved > 0 || result.warnings.length > 0) { - log.info('maintenance', 'Docker maintenance completed', { - spaceReclaimedMB: saved, - pruned: result.pruned, - warnings: result.warnings.length - }); - } - if (result.warnings.length > 0) { - for (const w of result.warnings) log.warn('maintenance', w); - } - }); - } catch (error) { - log.error('server', 'Docker maintenance failed to start', { error: error.message }); + if (dockerMaintenance) { + try { + dockerMaintenance.start(); + log.info('server', 'Docker maintenance started'); + dockerMaintenance.on('maintenance-complete', (result) => { + const saved = Math.round(result.spaceReclaimed.total / 1024 / 1024); + if (saved > 0 || result.warnings.length > 0) { + log.info('maintenance', 'Docker maintenance completed', { + spaceReclaimedMB: saved, + pruned: result.pruned, + warnings: result.warnings.length + }); + } + if (result.warnings.length > 0) { + for (const w of result.warnings) log.warn('maintenance', w); + } + }); + } catch (error) { + log.error('server', 'Docker maintenance failed to start', { error: error.message }); + } } - try { - logDigest.start(platformPaths.digestDir); - log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); - logDigest.on('digest-generated', ({ date }) => { - log.info('digest', `Daily digest generated for ${date}`); - if (typeof ctx.notification?.send === 'function') { - ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); - } - }); - } catch (error) { - log.error('server', 'Log digest failed to start', { error: error.message }); + if (logDigest) { + try { + logDigest.start(platformPaths.digestDir); + log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); + logDigest.on('digest-generated', ({ date }) => { + log.info('digest', `Daily digest generated for ${date}`); + if (typeof ctx.notification?.send === 'function') { + ctx.notification.send('system.digest', 'Daily Log Digest', `Log digest for ${date} is ready. View it in the DashCaddy dashboard.`, 'info'); + } + }); + } catch (error) { + log.error('server', 'Log digest failed to start', { error: error.message }); + } } // Tailscale API sync (if OAuth configured) @@ -1935,8 +1931,8 @@ function shutdown(signal) { log.info('shutdown', `${signal} received, draining connections...`); resourceMonitor.stop(); backupManager.stop(); - dockerMaintenance.stop(); - logDigest.stop(); + if (dockerMaintenance) dockerMaintenance.stop(); + if (logDigest) logDigest.stop(); healthChecker.stop(); updateManager.stop(); selfUpdater.stop(); diff --git a/dashcaddy-api/startup-validator.js b/dashcaddy-api/startup-validator.js index abb7555..74ec0e1 100644 --- a/dashcaddy-api/startup-validator.js +++ b/dashcaddy-api/startup-validator.js @@ -10,6 +10,7 @@ const path = require('path'); const http = require('http'); const https = require('https'); const { exists, isAccessible } = require('./fs-helpers'); +const { resolveServiceUrl } = require('./url-resolver'); /** * Validate startup configuration and environment. @@ -146,17 +147,19 @@ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFI } /** - * Auto-configure health checker from services.json + top-card services. + * Full-sync health checker from services.json + top-card services. + * Adds missing, updates changed URLs, removes deleted services. * * @param {Object} deps * @param {Function} deps.log - structured logger * @param {string} deps.SERVICES_FILE * @param {Object} deps.servicesStateManager - StateManager instance * @param {Object} deps.healthChecker - health checker module - * @param {Function} deps.buildDomain - builds domain from subdomain + * @param {Function} deps.buildServiceUrl - canonical URL builder + * @param {Object} deps.siteConfig - site config (dnsServers, etc.) * @param {Object} deps.APP - app constants (USER_AGENTS) */ -async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildDomain, APP }) { +async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateManager, healthChecker, buildServiceUrl, siteConfig, APP }) { try { const topCardServices = [ { id: 'dns1', name: 'DNS1' }, @@ -164,6 +167,11 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana { id: 'internet', name: 'Internet' }, ]; + // Add dns3 if it exists in dnsServers config + if (siteConfig?.dnsServers?.dns3) { + topCardServices.push({ id: 'dns3', name: 'DNS3' }); + } + let appServices = []; if (await exists(SERVICES_FILE)) { const data = await servicesStateManager.read(); @@ -171,38 +179,47 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana } const allServices = [...topCardServices, ...appServices]; - const configuredIds = new Set(Object.keys(healthChecker.config.services || {})); - let added = 0; + const desiredIds = new Set(); + let added = 0, updated = 0, removed = 0; for (const svc of allServices) { const id = svc.id || svc.name?.toLowerCase(); - if (!id || configuredIds.has(id)) continue; + if (!id) continue; + desiredIds.add(id); - let url; - if (id === 'internet') { - url = 'https://www.google.com'; - } else if (svc.isExternal && svc.externalUrl) { - url = svc.externalUrl; - } else { - url = `https://${buildDomain(id)}`; + const url = resolveServiceUrl(id, svc, siteConfig, buildServiceUrl); + const existing = healthChecker.config.services?.[id]; + + if (!existing) { + healthChecker.configureService(id, { + name: svc.name || id, + url, + method: 'HEAD', + timeout: 5000, + expectedStatusCodes: [200, 201, 204, 301, 302, 303, 307, 308, 401, 403], + headers: { 'User-Agent': APP.USER_AGENTS.HEALTH }, + }); + added++; + } else if (existing.url !== url) { + healthChecker.configureService(id, { ...existing, url }); + updated++; } - - healthChecker.configureService(id, { - name: svc.name || id, - url, - method: 'HEAD', - timeout: 5000, - expectedStatusCodes: [200, 201, 204, 301, 302, 303, 307, 308, 401, 403], - headers: { 'User-Agent': APP.USER_AGENTS.HEALTH }, - }); - added++; } - if (added > 0) { - log.info('health', 'Auto-configured services from services.json', { count: added }); + // Remove services no longer in the desired set + const configuredIds = Object.keys(healthChecker.config.services || {}); + for (const id of configuredIds) { + if (!desiredIds.has(id)) { + healthChecker.removeService(id); + removed++; + } + } + + if (added > 0 || updated > 0 || removed > 0) { + log.info('health', 'Health checker synced', { added, updated, removed }); } } catch (error) { - log.error('health', 'Error syncing services', { error: error.message }); + log.error('health', 'Error syncing health checker', { error: error.message }); } } diff --git a/dashcaddy-api/url-resolver.js b/dashcaddy-api/url-resolver.js new file mode 100644 index 0000000..a961466 --- /dev/null +++ b/dashcaddy-api/url-resolver.js @@ -0,0 +1,32 @@ +/** + * Unified URL Resolver + * Single source of truth for resolving service URLs across all systems + * (probes, health checks, health checker auto-config, SSO). + */ + +/** + * Resolve the canonical URL for a service. + * + * Priority: + * 1. internet → https://www.google.com + * 2. isExternal + externalUrl → use as-is + * 3. service.url → prepend https:// if no protocol + * 4. dnsServers config → http://{ip}:{port} + * 5. fallback → buildServiceUrl(id) + * + * @param {string} id - service identifier + * @param {Object|null} service - service object from services.json (may be null for top-card services) + * @param {Object|null} siteConfig - site config containing dnsServers etc. + * @param {Function} buildServiceUrl - fallback URL builder (subdomain or subdirectory mode) + * @returns {string} resolved URL + */ +function resolveServiceUrl(id, service, siteConfig, buildServiceUrl) { + if (id === 'internet') return 'https://www.google.com'; + if (service?.isExternal && service.externalUrl) return service.externalUrl; + if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`; + const dnsServer = siteConfig?.dnsServers?.[id]; + if (dnsServer) return `http://${dnsServer.ip}:${dnsServer.port || 5380}`; + return buildServiceUrl(id); +} + +module.exports = { resolveServiceUrl };