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

@@ -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();