/** * Config Schema Validation for DashCaddy * Validates config.json structure to catch typos and invalid values early. */ const VALID_THEMES = ['dark', 'light', 'blue']; const VALID_ROUTING_MODES = ['subdomain', 'subdirectory']; const VALID_DNS_PROVIDERS = ['technitium', 'cloudflare', 'rfc2136', 'manual']; const KNOWN_KEYS = [ 'tld', 'caName', 'dns', 'dnsServers', 'dashboardHost', 'timezone', 'theme', 'updatedAt', 'timestamp', 'logo', 'logoPosition', 'favicon', 'weather', 'setupComplete', 'onboardingCompleted', 'configurationType', 'defaults', 'customLogo', 'customFavicon', 'dashboardTitle', 'tailscale', 'license', 'skipped', 'routingMode', 'domain', 'email', 'defaultIP', 'pylon', 'customLogoDark', 'customLogoLight', 'language', // license-manager.js persists the last activation to config.licenseBackup // (restore-on-restart path); src/config/migrations.js stamps _version. // Both are first-party writes — see DC-091. 'licenseBackup', '_version', // DC-096: monitoring.public gates whether /api/v1/monitoring/stats and // /api/v1/health-checks/status are public (middleware.js isMonitoringPublic). // Removed 'setupCompleted' and 'setupMode' — never written by any code // (past or present); they only existed here, where they masked the actual // typo of the real key `setupComplete` (writers: setup-wizard.js). 'monitoring' ]; /** * @param {string[]} arr * @param {string} val * @returns {boolean} */ function isInArray(arr, val) { return arr.includes(val); } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateTld(ctx, config) { if (config.tld === undefined) return; if (typeof config.tld !== 'string') { ctx.errors.push('tld must be a string'); return; } const tld = config.tld.startsWith('.') ? config.tld : '.' + config.tld; if (!/^\.[a-z0-9][a-z0-9-]*$/.test(tld)) { ctx.errors.push(`tld "${config.tld}" contains invalid characters (use lowercase alphanumeric)`); } if (tld.length > 20) { ctx.warnings.push(`tld "${config.tld}" is unusually long`); } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateDns(ctx, config) { if (config.dns === undefined) return; if (typeof config.dns !== 'object' || config.dns === null) { ctx.errors.push('dns must be an object'); return; } if (config.dns.ip !== undefined && typeof config.dns.ip !== 'string') { ctx.errors.push('dns.ip must be a string'); } if (config.dns.ip && !/^[\d.]+$/.test(config.dns.ip) && !/^[a-zA-Z0-9.-]+$/.test(config.dns.ip)) { ctx.errors.push(`dns.ip "${config.dns.ip}" is not a valid IP address or hostname`); } if (config.dns.port !== undefined) { const port = parseInt(config.dns.port, 10); if (isNaN(port) || port < 1 || port > 65535) { ctx.errors.push(`dns.port "${config.dns.port}" is not a valid port number (1-65535)`); } } if (config.dns.servers !== undefined) { if (typeof config.dns.servers !== 'object' || config.dns.servers === null) { ctx.errors.push('dns.servers must be an object'); } } if (config.dns.provider !== undefined) { if (typeof config.dns.provider !== 'string') { ctx.errors.push('dns.provider must be a string'); } else if (!isInArray(VALID_DNS_PROVIDERS, config.dns.provider)) { ctx.warnings.push(`dns.provider "${config.dns.provider}" is not one of: ${VALID_DNS_PROVIDERS.join(', ')}. It may still work if a custom adapter is installed.`); } } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateDashboardHost(ctx, config) { if (config.dashboardHost === undefined) return; if (typeof config.dashboardHost !== 'string') { ctx.errors.push('dashboardHost must be a string'); } else if (config.dashboardHost && !/^[a-zA-Z0-9][a-zA-Z0-9.-]*$/.test(config.dashboardHost)) { ctx.errors.push(`dashboardHost "${config.dashboardHost}" contains invalid characters`); } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateTimezone(ctx, config) { if (config.timezone === undefined) return; if (typeof config.timezone !== 'string') { ctx.errors.push('timezone must be a string'); } else if (config.timezone) { try { Intl.DateTimeFormat(undefined, { timeZone: config.timezone }); } catch { ctx.errors.push(`timezone "${config.timezone}" is not a recognized IANA timezone`); } } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateTheme(ctx, config) { if (config.theme === undefined) return; if (!isInArray(VALID_THEMES, config.theme)) { ctx.warnings.push(`theme "${config.theme}" is not one of: ${VALID_THEMES.join(', ')}`); } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateRoutingMode(ctx, config) { if (config.routingMode === undefined) return; if (!isInArray(VALID_ROUTING_MODES, config.routingMode)) { ctx.errors.push(`routingMode "${config.routingMode}" is not one of: ${VALID_ROUTING_MODES.join(', ')}`); } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateDomain(ctx, config) { if (config.domain === undefined) return; if (typeof config.domain !== 'string') { ctx.errors.push('domain must be a string'); } else if (config.domain && !/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(config.domain)) { ctx.warnings.push(`domain "${config.domain}" may not be a valid domain name`); } } /** * @param {{warnings:string[]}} ctx * @param {object} config */ function validateKnownKeys(ctx, config) { for (const key of Object.keys(config)) { if (!isInArray(KNOWN_KEYS, key)) { ctx.warnings.push(`Unknown config key "${key}" — possible typo?`); } } } /** * @param {{errors:string[], warnings:string[]}} ctx * @param {object} config */ function validateMonitoring(ctx, config) { if (config.monitoring === undefined) return; if (typeof config.monitoring !== 'object' || config.monitoring === null) { ctx.errors.push('monitoring must be an object'); return; } if (config.monitoring.public !== undefined && typeof config.monitoring.public !== 'boolean') { ctx.errors.push('monitoring.public must be a boolean'); } } /** * Validate a config object and return errors/warnings. * @param {object} config - The config object to validate * @returns {{ valid: boolean, errors: string[], warnings: string[] }} */ function validateConfig(config) { const errors = []; const warnings = []; const ctx = { errors, warnings }; if (!config || typeof config !== 'object') { return { valid: false, errors: ['Config must be a non-null object'], warnings }; } validateTld(ctx, config); validateDns(ctx, config); validateDashboardHost(ctx, config); validateTimezone(ctx, config); validateTheme(ctx, config); validateRoutingMode(ctx, config); validateDomain(ctx, config); validateMonitoring(ctx, config); validateKnownKeys(ctx, config); return { valid: errors.length === 0, errors, warnings }; } module.exports = { validateConfig };