const express = require('express'); const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); const { TIMEOUTS } = require('../constants'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); const platformPaths = require('../platform-paths'); module.exports = function(ctx) { const router = express.Router(); // In-memory cache for health results (local to this router) let serviceHealthCache = {}; let lastHealthCheck = null; // ===== HEALTH / SERVICES ===== // Check health of all services (performs live checks) router.get('/health/services', ctx.asyncHandler(async (req, res) => { if (!await exists(ctx.SERVICES_FILE)) { return res.json({ success: true, health: {} }); } const servicesData = await ctx.servicesStateManager.read(); const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const health = {}; // Check each service await Promise.all(services.map(async (service) => { const serviceId = service.id || service.name?.toLowerCase(); if (!serviceId) return; try { let url = null; 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)}`; } if (!url) { health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; return; } // Perform health check with timeout const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); clearTimeout(timeout); health[serviceId] = { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, checkedAt: new Date().toISOString() }; } catch (fetchError) { clearTimeout(timeout); // Try GET if HEAD fails try { const getController = new AbortController(); const getTimeout = setTimeout(() => getController.abort(), 5000); const getResponse = await ctx.fetchT(url, { method: 'GET', signal: getController.signal, redirect: 'follow' }); clearTimeout(getTimeout); health[serviceId] = { status: getResponse.ok || getResponse.status < 500 ? 'healthy' : 'unhealthy', statusCode: getResponse.status, url, checkedAt: new Date().toISOString() }; } catch (e) { health[serviceId] = { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, checkedAt: new Date().toISOString() }; } } } catch (e) { health[serviceId] = { status: 'error', reason: e.message, checkedAt: new Date().toISOString() }; } })); // Cache results serviceHealthCache = health; lastHealthCheck = new Date().toISOString(); const paginationParams = parsePaginationParams(req.query); const healthEntries = Object.entries(health); const result = paginate(healthEntries, paginationParams); const paginatedHealth = Object.fromEntries(result.data); res.json({ success: true, health: paginatedHealth, checkedAt: lastHealthCheck, ...(result.pagination && { pagination: result.pagination }) }); }, 'health-services')); // Get cached health status (fast, no re-check) router.get('/health/cached', ctx.asyncHandler(async (req, res) => { res.json({ success: true, health: serviceHealthCache, lastCheck: lastHealthCheck, cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null }); }, 'health-cached')); // Check health of single service router.get('/health/service/:id', ctx.asyncHandler(async (req, res) => { const serviceId = req.params.id; // Load service config if (!await exists(ctx.SERVICES_FILE)) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Services file'); } const servicesData = await ctx.servicesStateManager.read(); const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId); if (!service) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Service'); } // 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)}`; } // Check health const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); clearTimeout(timeout); res.json({ success: true, serviceId, health: { status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', statusCode: response.status, url, checkedAt: new Date().toISOString() } }); } catch (e) { clearTimeout(timeout); res.json({ success: true, serviceId, health: { status: 'unhealthy', reason: e.name === 'AbortError' ? 'Timeout' : e.message, url, checkedAt: new Date().toISOString() } }); } }, 'health-service')); // ===== HEALTH / CA ===== // Get CA certificate health status router.get('/health/ca', ctx.asyncHandler(async (req, res) => { // Try deployed location first, then Caddy PKI location const deployedCertPath = path.join(platformPaths.caCertDir, 'root.crt'); const pkiCertPath = platformPaths.pkiRootCert; const rootCertPath = await exists(deployedCertPath) ? deployedCertPath : pkiCertPath; try { // Check if certificate exists if (!await exists(rootCertPath)) { return res.json({ status: 'error', message: 'Root CA certificate not found', daysUntilExpiration: null }); } const dates = execSync(`openssl x509 -in "${rootCertPath}" -noout -dates`).toString(); const notAfter = dates.match(/notAfter=(.*)/)[1].trim(); const expirationDate = new Date(notAfter); const daysUntilExpiration = Math.floor((expirationDate - new Date()) / (1000 * 60 * 60 * 24)); // Alert thresholds let status = 'healthy'; let message = `CA certificate valid for ${daysUntilExpiration} days`; if (daysUntilExpiration < 0) { status = 'critical'; message = `CA certificate EXPIRED ${Math.abs(daysUntilExpiration)} days ago!`; } else if (daysUntilExpiration < 7) { status = 'critical'; message = `CA certificate expires in ${daysUntilExpiration} days!`; } else if (daysUntilExpiration < 30) { status = 'critical'; message = `CA certificate expires in ${daysUntilExpiration} days!`; } else if (daysUntilExpiration < 90) { status = 'warning'; message = `CA certificate expires in ${daysUntilExpiration} days`; } res.json({ status: status, message: message, daysUntilExpiration: daysUntilExpiration, expiresAt: notAfter }); } catch (error) { await ctx.logError('GET /api/health/ca', error); res.json({ status: 'error', message: error.message, daysUntilExpiration: null }); } }, 'health-ca')); // ===== HEALTH CHECK (health-checker module) ===== // Get current status for all services router.get('/health-checks/status', ctx.asyncHandler(async (req, res) => { const status = ctx.healthChecker.getCurrentStatus(); res.json({ success: true, status }); }, 'health-check-status')); // Get service statistics router.get('/health-checks/:serviceId/stats', ctx.asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; const stats = ctx.healthChecker.getServiceStats(req.params.serviceId, hours); if (!stats) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Service'); } res.json({ success: true, stats }); }, 'health-check-stats')); // Configure health check router.post('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { ctx.healthChecker.configureService(req.params.serviceId, req.body); res.json({ success: true, message: 'Health check configured' }); }, 'health-check-configure')); // Remove health check configuration router.delete('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { ctx.healthChecker.removeService(req.params.serviceId); res.json({ success: true, message: 'Health check removed' }); }, 'health-check-remove')); // Get open incidents router.get('/health-checks/incidents', ctx.asyncHandler(async (req, res) => { const incidents = ctx.healthChecker.getOpenIncidents(); const paginationParams = parsePaginationParams(req.query); const result = paginate(incidents, paginationParams); res.json({ success: true, incidents: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'health-check-incidents')); // Get incident history router.get('/health-checks/incidents/history', ctx.asyncHandler(async (req, res) => { const paginationParams = parsePaginationParams(req.query); // When paginating, fetch all history so pagination can slice correctly const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50); const history = ctx.healthChecker.getIncidentHistory(fetchLimit); const result = paginate(history, paginationParams); res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'health-check-incidents-history')); return router; };