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:
220
dashcaddy-api/src/context/caddy.js
Normal file
220
dashcaddy-api/src/context/caddy.js
Normal 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,
|
||||
};
|
||||
Reference in New Issue
Block a user