/** * Caddy context - Caddyfile manipulation and reload */ const fsp = require('fs').promises; const { RETRIES } = require('../../constants'); /** * Atomically read-modify-write the Caddyfile and reload Caddy. * Uses a mutex to prevent concurrent modifications. * Rolls back on reload failure. */ let _caddyfileLock = Promise.resolve(); async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) { let resolve; const prev = _caddyfileLock; _caddyfileLock = new Promise(r => { resolve = r; }); await prev; try { const original = await fsp.readFile(CADDYFILE_PATH, 'utf8'); const modified = await modifyFn(original); if (modified === null || modified === original) { return { success: false, error: 'No changes to apply' }; } await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8'); try { await reloadCaddy(modified); return { success: true }; } catch (err) { // Rollback await fsp.writeFile(CADDYFILE_PATH, original, 'utf8'); return { success: false, error: err.message, rolledBack: true }; } } finally { resolve(); } } /** * Read the current Caddyfile content */ async function readCaddyfile(CADDYFILE_PATH) { return fsp.readFile(CADDYFILE_PATH, 'utf8'); } /** * Reload Caddy via admin API */ async function reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log) { const maxRetries = RETRIES.CADDY_RELOAD; let lastError = null; for (let i = 0; i < maxRetries; i++) { try { const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { method: 'POST', headers: { 'Content-Type': 'text/caddyfile' }, body: content }); if (response.ok) { log.info('caddy', 'Caddy configuration reloaded successfully'); await new Promise(resolve => setTimeout(resolve, 1000)); return; } lastError = await response.text(); log.warn('caddy', 'Caddy reload attempt failed', { attempt: i + 1, error: lastError }); } catch (error) { lastError = error.message; log.warn('caddy', 'Caddy reload attempt error', { attempt: i + 1, error: lastError }); } if (i < maxRetries - 1) { await new Promise(resolve => setTimeout(resolve, 2000)); } } throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); } /** * Verify a site is accessible via HTTPS */ async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts = 5) { const delay = 2000; for (let i = 0; i < maxAttempts; i++) { try { const response = await fetchT(`https://${domain}/`, { method: 'HEAD', agent: httpsAgent, timeout: 5000 }); log.info('caddy', 'Site is accessible', { domain, status: response.status }); return true; } catch (error) { log.debug('caddy', 'Site verification attempt', { domain, attempt: i + 1, maxAttempts, error: error.message }); } if (i < maxAttempts - 1) { await new Promise(resolve => setTimeout(resolve, delay)); } } log.warn('caddy', 'Could not verify site accessibility', { domain }); return false; } /** * Generate Caddy config block for a service */ function generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options = {}) { const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; // Subdirectory mode if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { let config = ''; if (subpathSupport === 'native') { config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; config += `\thandle /${subdomain}/* {\n`; } else { config += `\thandle_path /${subdomain}/* {\n`; } if (tailscaleOnly) { config += `\t\t@blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) config += ` ${allowedIPs.join(' ')}`; config += `\n\t\trespond @blocked "Access denied. Tailscale connection required." 403\n`; } config += `\t\treverse_proxy ${ip}:${port}\n`; config += `\t}`; return config; } // Subdomain mode let config = `${buildDomain(subdomain)} {\n`; if (tailscaleOnly) { config += ` @blocked not remote_ip 100.64.0.0/10`; if (allowedIPs.length > 0) { config += ` ${allowedIPs.join(' ')}`; } config += `\n respond @blocked "Access denied. Tailscale connection required." 403\n`; } config += ` reverse_proxy ${ip}:${port}\n`; config += ` tls internal\n`; config += `}`; return config; } function createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain) { const reload = (content) => reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log); const read = () => readCaddyfile(CADDYFILE_PATH); const modify = (modifyFn) => modifyCaddyfile(CADDYFILE_PATH, reload, modifyFn); const verify = (domain, maxAttempts) => verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts); const generate = (subdomain, ip, port, options) => generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options); return { modify, read, reload, generateConfig: generate, verifySite: verify, adminUrl: CADDY_ADMIN_URL, filePath: CADDYFILE_PATH, }; } module.exports = { createCaddyContext };