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 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 23:01:20 -07:00
parent 70b818c2bd
commit 2815233e86
7 changed files with 145 additions and 106 deletions

View File

@@ -111,10 +111,12 @@ const ctx = {
logError: null, logError: null,
safeErrorMessage: null, safeErrorMessage: null,
buildDomain: null, buildDomain: null,
buildServiceUrl: null,
getServiceById: null, getServiceById: null,
readConfig: null, readConfig: null,
saveConfig: null, saveConfig: null,
addServiceToConfig: null, addServiceToConfig: null,
resyncHealthChecker: null,
validateURL: null, validateURL: null,
// Middleware // Middleware

View File

@@ -6,6 +6,7 @@ const { TIMEOUTS } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver');
module.exports = function(ctx) { module.exports = function(ctx) {
const router = express.Router(); const router = express.Router();
@@ -36,18 +37,7 @@ module.exports = function(ctx) {
let checkType = 'http'; let checkType = 'http';
// Determine URL to check // Determine URL to check
if (service.isExternal && service.externalUrl) { url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl);
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)}`;
}
if (!url) { if (!url) {
health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; health[serviceId] = { status: 'unknown', reason: 'No URL configured' };
@@ -157,14 +147,7 @@ module.exports = function(ctx) {
} }
// Determine URL // Determine URL
let url = null; const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl);
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)}`;
}
// Check health // Check health
const controller = new AbortController(); const controller = new AbortController();

View File

@@ -140,6 +140,7 @@ module.exports = function(ctx) {
// Get latest daily digest // Get latest daily digest
router.get('/logs/digest/latest', ctx.asyncHandler(async (req, res) => { 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(); const digest = await ctx.logDigest.getLatestDigest();
if (!digest) { if (!digest) {
return res.json({ success: true, digest: null, message: 'No digest available yet. First digest is generated at midnight.' }); 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) // Get live digest data (today's accumulated stats)
router.get('/logs/digest/live', ctx.asyncHandler(async (req, res) => { 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(); const live = ctx.logDigest.getLiveData();
res.json({ success: true, ...live }); res.json({ success: true, ...live });
}, 'logs-digest-live')); }, 'logs-digest-live'));
// List available digest dates // List available digest dates
router.get('/logs/digest/history', ctx.asyncHandler(async (req, res) => { 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(); const dates = await ctx.logDigest.listDigests();
res.json({ success: true, dates }); res.json({ success: true, dates });
}, 'logs-digest-history')); }, 'logs-digest-history'));
// Generate digest on demand (for today or a specific date) // Generate digest on demand (for today or a specific date)
router.post('/logs/digest/generate', ctx.asyncHandler(async (req, res) => { 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 date = req.body.date || new Date().toISOString().slice(0, 10);
const digest = await ctx.logDigest.generateDailyDigest(date); const digest = await ctx.logDigest.generateDailyDigest(date);
res.json({ success: true, digest }); res.json({ success: true, digest });
@@ -168,6 +172,7 @@ module.exports = function(ctx) {
// Get digest for a specific date (JSON) // Get digest for a specific date (JSON)
router.get('/logs/digest/:date', ctx.asyncHandler(async (req, res) => { 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; const { date } = req.params;
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return ctx.errorResponse(res, 400, 'Invalid date format. Use YYYY-MM-DD.'); 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 // Get Docker disk usage snapshot
router.get('/logs/docker-disk', ctx.asyncHandler(async (req, res) => { 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 diskUsage = await ctx.dockerMaintenance.getDiskUsage();
const status = ctx.dockerMaintenance.getStatus(); const status = ctx.dockerMaintenance.getStatus();
res.json({ success: true, diskUsage, maintenance: status }); res.json({ success: true, diskUsage, maintenance: status });
@@ -193,6 +199,7 @@ module.exports = function(ctx) {
// Trigger Docker maintenance manually // Trigger Docker maintenance manually
router.post('/logs/docker-maintenance', ctx.asyncHandler(async (req, res) => { 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(); const result = await ctx.dockerMaintenance.runMaintenance();
res.json({ success: true, result }); res.json({ success: true, result });
}, 'logs-docker-maintenance')); }, 'logs-docker-maintenance'));

View File

@@ -8,6 +8,7 @@ const { APP, REGEX, TIMEOUTS } = require('../constants');
const { validateServiceConfig, isValidPort } = require('../input-validator'); const { validateServiceConfig, isValidPort } = require('../input-validator');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const { resolveServiceUrl } = require('../url-resolver');
module.exports = function(ctx) { module.exports = function(ctx) {
const router = express.Router(); const router = express.Router();
@@ -33,10 +34,7 @@ module.exports = function(ctx) {
} }
function resolveProbeUrl(id, service) { function resolveProbeUrl(id, service) {
if (id === 'internet') return 'https://www.google.com'; return resolveServiceUrl(id, service, ctx.siteConfig, ctx.buildServiceUrl);
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);
} }
function requestStatusCode(url, method) { function requestStatusCode(url, method) {
@@ -308,6 +306,7 @@ module.exports = function(ctx) {
return services; return services;
}); });
ctx.resyncHealthChecker?.().catch(() => {});
res.json({ success: true, message: `Service "${name}" added to dashboard` }); res.json({ success: true, message: `Service "${name}" added to dashboard` });
} catch (error) { } catch (error) {
ctx.log.error('deploy', 'Error adding service', { error: error.message }); ctx.log.error('deploy', 'Error adding service', { error: error.message });
@@ -339,6 +338,7 @@ module.exports = function(ctx) {
} }
await ctx.servicesStateManager.write(services); await ctx.servicesStateManager.write(services);
ctx.resyncHealthChecker?.().catch(() => {});
res.json({ res.json({
success: true, success: true,
@@ -367,6 +367,7 @@ module.exports = function(ctx) {
return ctx.errorResponse(res, 404, `Service "${id}" not found`); return ctx.errorResponse(res, 404, `Service "${id}" not found`);
} }
ctx.resyncHealthChecker?.().catch(() => {});
res.json({ success: true, message: `Service "${id}" removed from dashboard` }); res.json({ success: true, message: `Service "${id}" removed from dashboard` });
}, 'services-delete')); }, 'services-delete'));
@@ -454,6 +455,7 @@ module.exports = function(ctx) {
}); });
} }
ctx.resyncHealthChecker?.().catch(() => {});
res.json({ res.json({
success: true, success: true,
message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`, message: `Service updated: ${oldSubdomain} -> ${newSubdomain}`,

View File

@@ -17,6 +17,7 @@ const credentialManager = require('./credential-manager');
const { CACHE_CONFIGS, createCache } = require('./cache-config'); const { CACHE_CONFIGS, createCache } = require('./cache-config');
const { AppError } = require('./errors'); const { AppError } = require('./errors');
const { validateConfig } = require('./config-schema'); const { validateConfig } = require('./config-schema');
const { resolveServiceUrl } = require('./url-resolver');
const { const {
APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES, APP, APP_PORTS, ARR_SERVICES, TIMEOUTS, RETRIES,
SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS, SESSION_TTL, RATE_LIMITS, CADDY, PLEX, LIMITS,
@@ -50,8 +51,10 @@ const backupManager = require('./backup-manager');
const healthChecker = require('./health-checker'); const healthChecker = require('./health-checker');
const updateManager = require('./update-manager'); const updateManager = require('./update-manager');
const selfUpdater = require('./self-updater'); const selfUpdater = require('./self-updater');
const dockerMaintenance = require('./docker-maintenance'); let dockerMaintenance;
const logDigest = require('./log-digest'); 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 StateManager = require('./state-manager');
const auditLogger = require('./audit-logger'); const auditLogger = require('./audit-logger');
const portLockManager = require('./port-lock-manager'); const portLockManager = require('./port-lock-manager');
@@ -1173,6 +1176,7 @@ Object.assign(ctx, {
loadDnsCredentials: () => {}, loadDnsCredentials: () => {},
SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE, SERVICES_FILE, CONFIG_FILE, TOTP_CONFIG_FILE, TAILSCALE_CONFIG_FILE,
NOTIFICATIONS_FILE, ERROR_LOG_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 // Build versioned API router — all route modules attach here
@@ -1225,29 +1229,15 @@ app.get('/probe/:id', asyncHandler(async (req, res) => {
const id = req.params.id; const id = req.params.id;
try { try {
let url; // Look up service in services.json
if (id === 'internet') {
// Internet connectivity check
url = 'https://www.google.com';
} else {
// Look up service in services.json for custom URLs
let service = null; let service = null;
if (await exists(SERVICES_FILE)) { if (id !== 'internet' && await exists(SERVICES_FILE)) {
const data = await servicesStateManager.read(); const data = await servicesStateManager.read();
const services = Array.isArray(data) ? data : data.services || []; const services = Array.isArray(data) ? data : data.services || [];
service = services.find(s => s.id === id); service = services.find(s => s.id === id);
} }
if (service?.isExternal && service?.externalUrl) { const url = resolveServiceUrl(id, service, siteConfig, buildServiceUrl);
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);
}
}
const parsed = new URL(url); const parsed = new URL(url);
const isHttps = parsed.protocol === 'https:'; const isHttps = parsed.protocol === 'https:';
@@ -1734,6 +1724,8 @@ async function addServiceToConfig(service) {
return services; return services;
}); });
log.info('deploy', 'Service added to config', { serviceId: service.id }); log.info('deploy', 'Service added to config', { serviceId: service.id });
// Sync health checker with updated services list
ctx.resyncHealthChecker?.().catch(() => {});
} catch (error) { } catch (error) {
log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message }); log.error('deploy', 'Failed to add service to config', { serviceId: service.id, error: error.message });
throw error; throw error;
@@ -1852,7 +1844,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
(async () => { (async () => {
try { try {
// Auto-configure health checker from services.json // 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(); healthChecker.start();
log.info('server', 'Health checker started'); log.info('server', 'Health checker started');
} catch (error) { } catch (error) {
@@ -1887,6 +1879,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
log.error('server', 'Self-updater failed to start', { error: error.message }); log.error('server', 'Self-updater failed to start', { error: error.message });
} }
if (dockerMaintenance) {
try { try {
dockerMaintenance.start(); dockerMaintenance.start();
log.info('server', 'Docker maintenance started'); log.info('server', 'Docker maintenance started');
@@ -1906,7 +1899,9 @@ const server = app.listen(PORT, '0.0.0.0', () => {
} catch (error) { } catch (error) {
log.error('server', 'Docker maintenance failed to start', { error: error.message }); log.error('server', 'Docker maintenance failed to start', { error: error.message });
} }
}
if (logDigest) {
try { try {
logDigest.start(platformPaths.digestDir); logDigest.start(platformPaths.digestDir);
log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir }); log.info('server', 'Log digest started', { digestDir: platformPaths.digestDir });
@@ -1919,6 +1914,7 @@ const server = app.listen(PORT, '0.0.0.0', () => {
} catch (error) { } catch (error) {
log.error('server', 'Log digest failed to start', { error: error.message }); log.error('server', 'Log digest failed to start', { error: error.message });
} }
}
// Tailscale API sync (if OAuth configured) // Tailscale API sync (if OAuth configured)
if (tailscaleConfig.oauthConfigured) { if (tailscaleConfig.oauthConfigured) {
@@ -1935,8 +1931,8 @@ function shutdown(signal) {
log.info('shutdown', `${signal} received, draining connections...`); log.info('shutdown', `${signal} received, draining connections...`);
resourceMonitor.stop(); resourceMonitor.stop();
backupManager.stop(); backupManager.stop();
dockerMaintenance.stop(); if (dockerMaintenance) dockerMaintenance.stop();
logDigest.stop(); if (logDigest) logDigest.stop();
healthChecker.stop(); healthChecker.stop();
updateManager.stop(); updateManager.stop();
selfUpdater.stop(); selfUpdater.stop();

View File

@@ -10,6 +10,7 @@ const path = require('path');
const http = require('http'); const http = require('http');
const https = require('https'); const https = require('https');
const { exists, isAccessible } = require('./fs-helpers'); const { exists, isAccessible } = require('./fs-helpers');
const { resolveServiceUrl } = require('./url-resolver');
/** /**
* Validate startup configuration and environment. * 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 {Object} deps
* @param {Function} deps.log - structured logger * @param {Function} deps.log - structured logger
* @param {string} deps.SERVICES_FILE * @param {string} deps.SERVICES_FILE
* @param {Object} deps.servicesStateManager - StateManager instance * @param {Object} deps.servicesStateManager - StateManager instance
* @param {Object} deps.healthChecker - health checker module * @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) * @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 { try {
const topCardServices = [ const topCardServices = [
{ id: 'dns1', name: 'DNS1' }, { id: 'dns1', name: 'DNS1' },
@@ -164,6 +167,11 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana
{ id: 'internet', name: 'Internet' }, { id: 'internet', name: 'Internet' },
]; ];
// Add dns3 if it exists in dnsServers config
if (siteConfig?.dnsServers?.dns3) {
topCardServices.push({ id: 'dns3', name: 'DNS3' });
}
let appServices = []; let appServices = [];
if (await exists(SERVICES_FILE)) { if (await exists(SERVICES_FILE)) {
const data = await servicesStateManager.read(); const data = await servicesStateManager.read();
@@ -171,22 +179,18 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana
} }
const allServices = [...topCardServices, ...appServices]; const allServices = [...topCardServices, ...appServices];
const configuredIds = new Set(Object.keys(healthChecker.config.services || {})); const desiredIds = new Set();
let added = 0; let added = 0, updated = 0, removed = 0;
for (const svc of allServices) { for (const svc of allServices) {
const id = svc.id || svc.name?.toLowerCase(); const id = svc.id || svc.name?.toLowerCase();
if (!id || configuredIds.has(id)) continue; if (!id) continue;
desiredIds.add(id);
let url; const url = resolveServiceUrl(id, svc, siteConfig, buildServiceUrl);
if (id === 'internet') { const existing = healthChecker.config.services?.[id];
url = 'https://www.google.com';
} else if (svc.isExternal && svc.externalUrl) {
url = svc.externalUrl;
} else {
url = `https://${buildDomain(id)}`;
}
if (!existing) {
healthChecker.configureService(id, { healthChecker.configureService(id, {
name: svc.name || id, name: svc.name || id,
url, url,
@@ -196,13 +200,26 @@ async function syncHealthCheckerServices({ log, SERVICES_FILE, servicesStateMana
headers: { 'User-Agent': APP.USER_AGENTS.HEALTH }, headers: { 'User-Agent': APP.USER_AGENTS.HEALTH },
}); });
added++; added++;
} else if (existing.url !== url) {
healthChecker.configureService(id, { ...existing, url });
updated++;
}
} }
if (added > 0) { // Remove services no longer in the desired set
log.info('health', 'Auto-configured services from services.json', { count: added }); 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) { } catch (error) {
log.error('health', 'Error syncing services', { error: error.message }); log.error('health', 'Error syncing health checker', { error: error.message });
} }
} }

View File

@@ -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 };