From efa9c7ba6b9d3e6638e97906a48ae1b2a4795d98 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:07:03 +0100 Subject: [PATCH] Phase 2 (WIP): Add caddy context module - src/context/caddy.js: Caddyfile manipulation, reload, config generation - Uses dependency injection (init() pattern) for siteConfig, log, fetchT - Atomic mutex-based modifications with rollback on failure - All Caddy operations now in one module --- dashcaddy-api/src/context/caddy.js | 220 +++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 dashcaddy-api/src/context/caddy.js diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js new file mode 100644 index 0000000..ed2c06c --- /dev/null +++ b/dashcaddy-api/src/context/caddy.js @@ -0,0 +1,220 @@ +/** + * Caddy context + * Caddyfile manipulation, reload, and site verification + */ + +const fsp = require('fs').promises; +const { CADDY, RETRIES } = require('../../constants'); +const { safeErrorMessage } = require('../utils/safe-error'); + +// Will be initialized by init() +let config = null; +let log = null; +let fetchT = null; +let buildDomain = null; +let siteConfig = null; +let httpsAgent = null; + +/** + * Initialize caddy context with dependencies + * @param {object} deps - { config, log, fetchT, buildDomain, siteConfig, httpsAgent } + */ +function init(deps) { + config = deps.config; + log = deps.log; + fetchT = deps.fetchT; + buildDomain = deps.buildDomain; + siteConfig = deps.siteConfig; + httpsAgent = deps.httpsAgent; +} + +// Mutex for atomic Caddyfile modifications +let _caddyfileLock = Promise.resolve(); + +/** + * Atomically read-modify-write the Caddyfile and reload Caddy + * Uses a mutex to prevent concurrent modifications from clobbering each other + * Rolls back on reload failure + * @param {function} modifyFn - receives current content, returns modified content (or null to skip) + * @returns {Promise<{success: boolean, error?: string, rolledBack?: boolean}>} + */ +async function modifyCaddyfile(modifyFn) { + let resolve; + const prev = _caddyfileLock; + _caddyfileLock = new Promise((r) => { + resolve = r; + }); + + await prev; // wait for any in-flight modification to finish + + try { + const original = await readCaddyfile(); + const modified = await modifyFn(original); + + if (modified === null || modified === original) { + return { success: false, error: 'No changes to apply' }; + } + + await fsp.writeFile(config.caddyfilePath, modified, 'utf8'); + + try { + await reloadCaddy(modified); + return { success: true }; + } catch (err) { + // Rollback + await fsp.writeFile(config.caddyfilePath, original, 'utf8'); + return { success: false, error: safeErrorMessage(err), rolledBack: true }; + } + } finally { + resolve(); + } +} + +/** + * Read the current Caddyfile content + * @returns {Promise} Caddyfile content + */ +async function readCaddyfile() { + return fsp.readFile(config.caddyfilePath, 'utf8'); +} + +/** + * Reload Caddy configuration via admin API + * @param {string} content - New Caddyfile content + * @returns {Promise} + * @throws {Error} If reload fails after max retries + */ +async function reloadCaddy(content) { + const maxRetries = RETRIES.CADDY_RELOAD; + let lastError = null; + + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetchT(`${config.caddyAdminUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': CADDY.CONTENT_TYPE }, + body: content, + }); + + if (response.ok) { + log.info('caddy', 'Caddy configuration reloaded successfully'); + // Wait a moment for Caddy to fully apply the config + 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}`); +} + +/** + * Generate Caddy reverse proxy configuration + * @param {string} subdomain - Service subdomain + * @param {string} ip - Backend IP address + * @param {number} port - Backend port + * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } + * @returns {string} Caddy configuration snippet + */ +function generateConfig(subdomain, ip, port, options = {}) { + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; + + // Subdirectory mode: generate handle/handle_path block (injected into main domain block) + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + let config = ''; + + // Native-support apps: use handle (preserve path prefix) + // Strip-mode apps: use handle_path (remove path prefix before proxying) + 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 (default): standalone domain block + 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; +} + +/** + * Verify a site is accessible via HTTPS + * @param {string} domain - Domain to check + * @param {number} maxAttempts - Max verification attempts + * @returns {Promise} true if site is accessible + */ +async function verifySite(domain, maxAttempts = 5) { + const delay = 2000; + + for (let i = 0; i < maxAttempts; i++) { + try { + // Try HTTPS first (internal CA) + const response = await fetchT(`https://${domain}/`, { + method: 'HEAD', + agent: httpsAgent, // Use CA-aware agent + timeout: 5000, + }); + + // Any response (even 4xx) means Caddy is serving the site + 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, but deployment may still have succeeded', { domain }); + return false; +} + +module.exports = { + init, + modify: modifyCaddyfile, + read: readCaddyfile, + reload: reloadCaddy, + generateConfig, + verifySite, +};