From 3efa5dc3f44b63dbc8c2321d6c4b07e2f9bb1b24 Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 22 Mar 2026 11:08:05 +0100 Subject: [PATCH] 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 --- dashcaddy-api/src/context/caddy.js | 340 ++++++++++++++--------------- dashcaddy-api/src/context/dns.js | 211 ++++++++++++++++++ dashcaddy-api/src/context/index.js | 14 ++ 3 files changed, 386 insertions(+), 179 deletions(-) create mode 100644 dashcaddy-api/src/context/dns.js create mode 100644 dashcaddy-api/src/context/index.js diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js index ed2c06c..50e98c6 100644 --- a/dashcaddy-api/src/context/caddy.js +++ b/dashcaddy-api/src/context/caddy.js @@ -1,220 +1,202 @@ /** * Caddy context - * Caddyfile manipulation, reload, and site verification + * Caddyfile manipulation, reload, and configuration generation */ const fsp = require('fs').promises; -const { CADDY, RETRIES } = require('../../constants'); +const { RETRIES, CADDY } = 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}>} + * Create Caddy context + * @param {object} deps - Dependencies { caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent } */ -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(); +function createCaddyContext({ caddyfilePath, caddyAdminUrl, fetchT, log, buildDomain, siteConfig, httpsAgent }) { + /** + * Read the current Caddyfile content + * @returns {Promise} Caddyfile content + */ + async function readCaddyfile() { + return fsp.readFile(caddyfilePath, 'utf8'); } -} -/** - * 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 + */ + async function reloadCaddy(content) { + const maxRetries = RETRIES.CADDY_RELOAD; + let lastError = null; -/** - * 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(`${caddyAdminUrl}/load`, { + method: 'POST', + headers: { 'Content-Type': CADDY.CONTENT_TYPE }, + body: content, + }); - 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; + } - 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 }); } - 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)); + } } - if (i < maxRetries - 1) { - await new Promise((resolve) => setTimeout(resolve, 2000)); + throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); + } + + /** + * 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(); } } - throw new Error(`[DC-303] Caddy reload failed after ${maxRetries} attempts: ${lastError}`); -} + /** + * Generate Caddy configuration block for a service + * @param {string} subdomain - Service subdomain + * @param {string} ip - Target IP address + * @param {number} port - Target port + * @param {object} options - { tailscaleOnly, allowedIPs, subpathSupport } + * @returns {string} Caddy configuration block + */ + function generateConfig(subdomain, ip, port, options = {}) { + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; -/** - * 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 + if (siteConfig.routingMode === 'subdirectory' && siteConfig.domain) { + let config = ''; - // Subdirectory mode: generate handle/handle_path block (injected into main domain block) - 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`; + } - // 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 += '\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 += ' @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 += `\t\treverse_proxy ${ip}:${port}\n`; - config += '\t}'; + config += ` reverse_proxy ${ip}:${port}\n`; + config += ' tls internal\n'; + config += '}'; + return config; } - // Subdomain mode (default): standalone domain block - let config = `${buildDomain(subdomain)} {\n`; + /** + * Verify a site is accessible via HTTPS + * @param {string} domain - Domain to check + * @param {number} maxAttempts - Maximum retry attempts + * @returns {Promise} True if accessible + */ + async function verifySite(domain, maxAttempts = 5) { + const delay = 2000; - if (tailscaleOnly) { - config += ' @blocked not remote_ip 100.64.0.0/10'; - if (allowedIPs.length > 0) { - config += ` ${allowedIPs.join(' ')}`; + 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)); + } } - config += '\n respond @blocked "Access denied. Tailscale connection required." 403\n'; + + log.warn('caddy', 'Could not verify site accessibility, but deployment may still have succeeded', { domain }); + return false; } - config += ` reverse_proxy ${ip}:${port}\n`; - config += ' tls internal\n'; - config += '}'; - - return config; + return { + modify: modifyCaddyfile, + read: readCaddyfile, + reload: reloadCaddy, + generateConfig, + verifySite, + adminUrl: caddyAdminUrl, + filePath: caddyfilePath, + }; } -/** - * 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, -}; +module.exports = createCaddyContext; diff --git a/dashcaddy-api/src/context/dns.js b/dashcaddy-api/src/context/dns.js new file mode 100644 index 0000000..5ea1db8 --- /dev/null +++ b/dashcaddy-api/src/context/dns.js @@ -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} 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} 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} + */ + 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; diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js new file mode 100644 index 0000000..5ad4c26 --- /dev/null +++ b/dashcaddy-api/src/context/index.js @@ -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, +};