/** * Startup Validation Module * Extracts startup configuration validation and health checker sync from server.js * (Phase 3 refactoring) */ const fs = require('fs'); const fsp = require('fs').promises; 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. * Fail fast with clear error messages if critical issues are found. * * @param {Object} deps * @param {Function} deps.log - structured logger * @param {string} deps.CADDYFILE_PATH * @param {string} deps.SERVICES_FILE * @param {string} deps.CONFIG_FILE * @param {string} deps.CADDY_ADMIN_URL * @param {number} deps.PORT */ async function validateStartupConfig({ log, CADDYFILE_PATH, SERVICES_FILE, CONFIG_FILE, CADDY_ADMIN_URL, PORT }) { const errors = []; const warnings = []; log.info('startup', 'Validating startup configuration...'); // 1. Check if Caddyfile exists and is writable try { if (await exists(CADDYFILE_PATH)) { if (!(await isAccessible(CADDYFILE_PATH, fs.constants.R_OK | fs.constants.W_OK))) { errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`); } else { log.info('startup', 'Caddyfile is accessible', { path: CADDYFILE_PATH }); } } else { warnings.push(`Caddyfile does not exist: ${CADDYFILE_PATH} (will be created if needed)`); } } catch (error) { errors.push(`Caddyfile is not readable/writable: ${CADDYFILE_PATH}`); } // 2. Check if services file exists or can be created const servicesDir = path.dirname(SERVICES_FILE); try { if (await exists(SERVICES_FILE)) { // Validate it's valid JSON try { const content = await fsp.readFile(SERVICES_FILE, 'utf8'); JSON.parse(content); log.info('startup', 'Services file is valid JSON', { path: SERVICES_FILE }); } catch (parseError) { errors.push(`Services file exists but contains invalid JSON: ${SERVICES_FILE}`); } } else { // Check if parent directory exists and is writable if (await exists(servicesDir)) { if (!(await isAccessible(servicesDir, fs.constants.W_OK))) { errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`); } else { log.info('startup', 'Services file directory is writable', { path: servicesDir }); } } else { errors.push(`Services file directory does not exist: ${servicesDir}`); } } } catch (error) { errors.push(`Cannot access services file or directory: ${SERVICES_FILE}`); } // 3. Check if port is available const net = require('net'); const portCheckServer = net.createServer(); try { portCheckServer.listen(PORT, '0.0.0.0'); portCheckServer.close(); log.info('startup', `Port ${PORT} is available`); } catch (error) { errors.push(`Port ${PORT} is already in use or cannot be bound`); } // 4. Check if config file is valid JSON (if exists) try { if (await exists(CONFIG_FILE)) { const content = await fsp.readFile(CONFIG_FILE, 'utf8'); JSON.parse(content); log.info('startup', 'Config file is valid JSON', { path: CONFIG_FILE }); } else { log.warn('startup', 'Config file does not exist (will use defaults)', { path: CONFIG_FILE }); } } catch (error) { errors.push(`Config file exists but contains invalid JSON: ${CONFIG_FILE}`); } // 5. Check Caddy admin API reachability (warning only, not critical) const checkCaddyAdmin = () => { return new Promise((resolve) => { const urlObj = new URL(CADDY_ADMIN_URL); const client = urlObj.protocol === 'https:' ? https : http; const req = client.request({ hostname: urlObj.hostname, port: urlObj.port, path: '/config/', method: 'GET', timeout: 2000 }, (res) => { resolve(res.statusCode >= 200 && res.statusCode < 500); }); req.on('error', () => resolve(false)); req.on('timeout', () => { req.destroy(); resolve(false); }); req.end(); }); }; // Run async Caddy check (don't block startup) checkCaddyAdmin().then(isReachable => { if (isReachable) { log.info('startup', 'Caddy admin API is reachable', { url: CADDY_ADMIN_URL }); } else { log.warn('startup', 'Caddy admin API is not reachable (may start later)', { url: CADDY_ADMIN_URL }); } }); // Print warnings if (warnings.length > 0) { warnings.forEach(warning => log.warn('startup', warning)); } // Fail fast if there are critical errors if (errors.length > 0) { errors.forEach(err => log.error('startup', err)); log.error('startup', 'Cannot start server — fix the above errors and try again'); process.exit(1); } log.info('startup', 'Startup configuration validation passed'); } /** * 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.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, buildServiceUrl, siteConfig, APP }) { try { const topCardServices = [ { id: 'internet', name: 'Internet' }, ]; // Dynamically add all configured DNS servers from config for (const [id, info] of Object.entries(siteConfig?.dnsServers || {})) { topCardServices.push({ id, name: info.name || id.toUpperCase() }); } let appServices = []; if (await exists(SERVICES_FILE)) { const data = await servicesStateManager.read(); appServices = Array.isArray(data) ? data : data.services || []; } const allServices = [...topCardServices, ...appServices]; 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) continue; desiredIds.add(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++; } } // 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 health checker', { error: error.message }); } } module.exports = { validateStartupConfig, syncHealthCheckerServices };