Phase 2 (WIP): Extract context modules (caddy, dns)

- src/context/caddy.js: Caddyfile manipulation, reload, config generation
- src/context/dns.js: DNS API wrapper with token management
- All context modules use factory pattern with explicit dependencies
This commit is contained in:
Krystie
2026-03-22 11:08:05 +01:00
parent efa9c7ba6b
commit 3efa5dc3f4
3 changed files with 386 additions and 179 deletions

View File

@@ -1,88 +1,31 @@
/** /**
* Caddy context * Caddy context
* Caddyfile manipulation, reload, and site verification * Caddyfile manipulation, reload, and configuration generation
*/ */
const fsp = require('fs').promises; const fsp = require('fs').promises;
const { CADDY, RETRIES } = require('../../constants'); const { RETRIES, CADDY } = require('../../constants');
const { safeErrorMessage } = require('../utils/safe-error'); 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 // Mutex for atomic Caddyfile modifications
let _caddyfileLock = Promise.resolve(); let _caddyfileLock = Promise.resolve();
/** /**
* Atomically read-modify-write the Caddyfile and reload Caddy * Create Caddy context
* Uses a mutex to prevent concurrent modifications from clobbering each other * @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }
* 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) { function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) {
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 * Read the current Caddyfile content
* @returns {Promise<string>} Caddyfile content * @returns {Promise<string>} Caddyfile content
*/ */
async function readCaddyfile() { async function readCaddyfile() {
return fsp.readFile(config.caddyfilePath, 'utf8'); return fsp.readFile(caddyfilePath, 'utf8');
} }
/** /**
* Reload Caddy configuration via admin API * Reload Caddy configuration via admin API
* @param {string} content - New Caddyfile content * @param {string} content - New Caddyfile content
* @returns {Promise<void>}
* @throws {Error} If reload fails after max retries
*/ */
async function reloadCaddy(content) { async function reloadCaddy(content) {
const maxRetries = RETRIES.CADDY_RELOAD; const maxRetries = RETRIES.CADDY_RELOAD;
@@ -90,7 +33,7 @@ async function reloadCaddy(content) {
for (let i = 0; i < maxRetries; i++) { for (let i = 0; i < maxRetries; i++) {
try { try {
const response = await fetchT(`${config.caddyAdminUrl}/load`, { const response = await fetchT(`${caddyAdminUrl}/load`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': CADDY.CONTENT_TYPE }, headers: { 'Content-Type': CADDY.CONTENT_TYPE },
body: content, body: content,
@@ -119,22 +62,59 @@ async function reloadCaddy(content) {
} }
/** /**
* Generate Caddy reverse proxy configuration * Atomically read-modify-write the Caddyfile and reload Caddy
* Uses a mutex to prevent concurrent modifications
* 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(caddyfilePath, modified, 'utf8');
try {
await reloadCaddy(modified);
return { success: true };
} catch (err) {
// Rollback
await fsp.writeFile(caddyfilePath, original, 'utf8');
return { success: false, error: safeErrorMessage(err), rolledBack: true };
}
} finally {
resolve();
}
}
/**
* Generate Caddy configuration block for a service
* @param {string} subdomain - Service subdomain * @param {string} subdomain - Service subdomain
* @param {string} ip - Backend IP address * @param {string} ip - Target IP address
* @param {number} port - Backend port * @param {number} port - Target port
* @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport }
* @returns {string} Caddy configuration snippet * @returns {string} Caddy configuration block
*/ */
function generateConfig(subdomain, ip, port, options = {}) { function generateConfig(subdomain, ip, port, options = {}) {
const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options;
// Subdirectory mode: generate handle/handle_path block (injected into main domain block) // Subdirectory mode: generate handle/handle_path block
if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) {
let config = ''; let config = '';
// Native-support apps: use handle (preserve path prefix)
// Strip-mode apps: use handle_path (remove path prefix before proxying)
if (subpathSupport === 'native') { if (subpathSupport === 'native') {
config += `\tredir /${subdomain} /${subdomain}/ permanent\n`; config += `\tredir /${subdomain} /${subdomain}/ permanent\n`;
config += `\thandle /${subdomain}/* {\n`; config += `\thandle /${subdomain}/* {\n`;
@@ -174,22 +154,20 @@ function generateConfig(subdomain, ip, port, options = {}) {
/** /**
* Verify a site is accessible via HTTPS * Verify a site is accessible via HTTPS
* @param {string} domain - Domain to check * @param {string} domain - Domain to check
* @param {number} maxAttempts - Max verification attempts * @param {number} maxAttempts - Maximum retry attempts
* @returns {Promise<boolean>} true if site is accessible * @returns {Promise<boolean>} True if accessible
*/ */
async function verifySite(domain, maxAttempts = 5) { async function verifySite(domain, maxAttempts = 5) {
const delay = 2000; const delay = 2000;
for (let i = 0; i < maxAttempts; i++) { for (let i = 0; i < maxAttempts; i++) {
try { try {
// Try HTTPS first (internal CA)
const response = await fetchT(`https://${domain}/`, { const response = await fetchT(`https://${domain}/`, {
method: 'HEAD', method: 'HEAD',
agent: httpsAgent, // Use CA-aware agent agent: httpsAgent,
timeout: 5000, timeout: 5000,
}); });
// Any response (even 4xx) means Caddy is serving the site
log.info('caddy', 'Site is accessible', { domain, status: response.status }); log.info('caddy', 'Site is accessible', { domain, status: response.status });
return true; return true;
} catch (error) { } catch (error) {
@@ -210,11 +188,15 @@ async function verifySite(domain, maxAttempts = 5) {
return false; return false;
} }
module.exports = { return {
init,
modify: modifyCaddyfile, modify: modifyCaddyfile,
read: readCaddyfile, read: readCaddyfile,
reload: reloadCaddy, reload: reloadCaddy,
generateConfig, generateConfig,
verifySite, verifySite,
adminUrl: caddyAdminUrl,
filePath: caddyfilePath,
}; };
}
module.exports = createCaddyContext;

View File

@@ -0,0 +1,211 @@
/**
* DNS context
* Technitium DNS API wrapper with token management
*/
const { CADDY } = require('../../constants');
/**
* Create DNS context
* @param {object} deps - Dependencies { siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }
*/
function createDnsContext({ siteConfig, fetchT, log, credentialManager, buildDomain, createCache, CACHE_CONFIGS }) {
// DNS token state
let dnsToken = process.env.DNS_ADMIN_TOKEN || '';
let dnsTokenExpiry = null;
// Per-server token cache
const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens);
/**
* Build full Technitium DNS API URL
* @param {string} server - Server IP or hostname
* @param {string} apiPath - API path (e.g., '/api/zones/records/add')
* @param {object|URLSearchParams} params - Query parameters
* @returns {string} Full API URL
*/
function buildUrl(server, apiPath, params) {
const protocol = server.match(/^\d+\.\d+\.\d+\.\d+$/) ? 'http' : 'https';
const port = protocol === 'http' ? `:${CADDY.DEFAULT_DNS_PORT}` : '';
const qs = params instanceof URLSearchParams ? params.toString() : new URLSearchParams(params).toString();
return `${protocol}://${server}${port}${apiPath}?${qs}`;
}
/**
* Call DNS API endpoint
* @param {string} server - Server IP or hostname
* @param {string} apiPath - API path
* @param {object} params - Query parameters
* @returns {Promise<object>} Parsed JSON response
*/
async function call(server, apiPath, params) {
const url = buildUrl(server, apiPath, params);
const response = await fetchT(url, {
method: 'GET',
headers: { Accept: 'application/json' },
});
return response.json();
}
/**
* Refresh DNS token via login
* @param {string} username - DNS username
* @param {string} password - DNS password
* @param {string} server - Server IP
* @returns {Promise<{success: boolean, token?: string, error?: string}>}
*/
async function refreshToken(username, password, server) {
try {
const params = new URLSearchParams({
user: username,
pass: password,
includeInfo: 'false',
});
const response = await fetchT(`http://${server}:5380/api/user/login?${params.toString()}`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/x-www-form-urlencoded',
},
timeout: 10000,
});
const result = await response.json();
if (result.status === 'ok' && result.token) {
dnsToken = result.token;
dnsTokenExpiry = new Date(Date.now() + 6 * 60 * 60 * 1000).toISOString(); // 6 hours
log.info('dns', 'DNS token refreshed', { expires: dnsTokenExpiry });
return { success: true, token: dnsToken };
}
return { success: false, error: result.errorMessage || 'Login failed' };
} catch (error) {
log.error('dns', 'DNS token refresh error', { error: error.message });
return { success: false, error: error.message };
}
}
/**
* Map DNS server IP to dnsId (dns1, dns2, dns3)
* @param {string} serverIp - Server IP address
* @returns {string|null} DNS ID or null
*/
function dnsIpToDnsId(serverIp) {
for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) {
if (info.ip === serverIp) return dnsId;
}
return null;
}
/**
* Ensure a valid DNS token exists (auto-refresh if needed)
* @returns {Promise<{success: boolean, token?: string, error?: string}>}
*/
async function ensureToken() {
// Check if token is valid and not expired
if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) {
return { success: true, token: dnsToken };
}
// Try per-server admin credentials for the primary DNS server
const primaryIp = siteConfig.dnsServerIp;
if (primaryIp) {
const dnsId = dnsIpToDnsId(primaryIp);
if (dnsId) {
for (const role of ['admin', 'readonly']) {
try {
const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`);
const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`);
if (username && password) {
return await refreshToken(username, password, primaryIp);
}
} catch (err) {
log.error('dns', `Per-server ${role} credential error`, { dnsId, error: err.message });
}
}
}
}
// Fall back to global credentials
try {
const username = await credentialManager.retrieve('dns.username');
const password = await credentialManager.retrieve('dns.password');
const server = await credentialManager.retrieve('dns.server');
if (username && password) {
return await refreshToken(username, password, server || primaryIp);
}
} catch (err) {
log.error('dns', 'Credential manager error', { error: err.message });
}
return {
success: false,
error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials',
};
}
/**
* Require a valid DNS token (auto-refresh if needed)
* @param {string} providedToken - Optional token provided by caller
* @returns {Promise<string>} Valid token
* @throws {Error} If no valid token can be obtained
*/
async function requireToken(providedToken) {
if (providedToken) return providedToken;
const result = await ensureToken();
if (result.success) return result.token;
const err = new Error(`No valid DNS token available. ${result.error}`);
err.statusCode = 401;
throw err;
}
/**
* Create a DNS A record
* @param {string} subdomain - Service subdomain
* @param {string} ip - IP address
* @returns {Promise<void>}
*/
async function createRecord(subdomain, ip) {
const tokenResult = await ensureToken();
if (!tokenResult.success) {
throw new Error(`DNS token not available: ${tokenResult.error}`);
}
const domain = buildDomain(subdomain);
const zone = siteConfig.tld.replace(/^\./, '');
const params = {
token: dnsToken,
domain,
zone,
type: 'A',
ipAddress: ip,
ttl: '300',
overwrite: 'true',
};
log.info('dns', 'Creating DNS record', { domain, ip });
await call(siteConfig.dnsServerIp, '/api/zones/records/add', params);
}
return {
call,
buildUrl,
requireToken,
ensureToken,
createRecord,
refresh: refreshToken,
getToken: () => dnsToken,
setToken: (t) => {
dnsToken = t;
},
getTokenExpiry: () => dnsTokenExpiry,
setTokenExpiry: (e) => {
dnsTokenExpiry = e;
},
};
}
module.exports = createDnsContext;

View File

@@ -0,0 +1,14 @@
/**
* Context modules
* Domain-specific context factories
*/
const docker = require('./docker');
const createCaddyContext = require('./caddy');
const createDnsContext = require('./dns');
module.exports = {
docker,
createCaddyContext,
createDnsContext,
};