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
This commit is contained in:
Krystie
2026-03-22 11:07:03 +01:00
parent 6771e4775e
commit efa9c7ba6b

View File

@@ -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<string>} 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<void>}
* @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<boolean>} 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,
};