Files
dashcaddy/dashcaddy-api/startup-validator.js
Sami f2f33b4b40 Make DNS servers fully dynamic from config.json
DNS server IDs (dns1, dns2, dns3) were hardcoded throughout the frontend
and backend. Now config.json's dnsServers object is the single source of
truth — adding or removing a DNS server in config automatically updates
the dashboard cards, credential modal, health checks, and probes.

- credentials.js: rebuild modal sections dynamically from SITE.dnsServers
- globals.js: add getPrimaryDnsId() helper for primary DNS lookups
- service-create.js, service-infrastructure.js: use dynamic DNS ID
- startup-validator.js: dynamic topCardServices from config
- middleware.js: add license endpoints to public routes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 16:55:07 -07:00

225 lines
7.5 KiB
JavaScript

/**
* 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 };