From 173dafa2f32d0d23997c77810e1c20295c50c21a Mon Sep 17 00:00:00 2001 From: Krystie Date: Sun, 29 Mar 2026 19:39:17 -0700 Subject: [PATCH] refactor(context): Extract context modules from god object - Create src/context/docker.js - Docker operations - Create src/context/caddy.js - Caddyfile manipulation - Create src/context/dns.js - DNS token management and API calls - Create src/context/session.js - Session wrapper - Create src/context/index.js - Context assembly (DI container) Breaks up the 50+ property ctx god object into domain-specific modules --- dashcaddy-api/src/context/caddy.js | 184 ++++++++++++++++ dashcaddy-api/src/context/dns.js | 308 +++++++++++++++++++++++++++ dashcaddy-api/src/context/docker.js | 67 ++++++ dashcaddy-api/src/context/index.js | 175 +++++++++++++++ dashcaddy-api/src/context/session.js | 21 ++ 5 files changed, 755 insertions(+) create mode 100644 dashcaddy-api/src/context/caddy.js create mode 100644 dashcaddy-api/src/context/dns.js create mode 100644 dashcaddy-api/src/context/docker.js create mode 100644 dashcaddy-api/src/context/index.js create mode 100644 dashcaddy-api/src/context/session.js diff --git a/dashcaddy-api/src/context/caddy.js b/dashcaddy-api/src/context/caddy.js new file mode 100644 index 0000000..04837ff --- /dev/null +++ b/dashcaddy-api/src/context/caddy.js @@ -0,0 +1,184 @@ +/** + * Caddy context - Caddyfile manipulation and reload + */ +const fsp = require('fs').promises; +const { RETRIES } = require('../../constants'); + +/** + * Atomically read-modify-write the Caddyfile and reload Caddy. + * Uses a mutex to prevent concurrent modifications. + * Rolls back on reload failure. + */ +let _caddyfileLock = Promise.resolve(); + +async function modifyCaddyfile(CADDYFILE_PATH, reloadCaddy, modifyFn) { + let resolve; + const prev = _caddyfileLock; + _caddyfileLock = new Promise(r => { resolve = r; }); + await prev; + + try { + const original = await fsp.readFile(CADDYFILE_PATH, 'utf8'); + const modified = await modifyFn(original); + + if (modified === null || modified === original) { + return { success: false, error: 'No changes to apply' }; + } + + await fsp.writeFile(CADDYFILE_PATH, modified, 'utf8'); + + try { + await reloadCaddy(modified); + return { success: true }; + } catch (err) { + // Rollback + await fsp.writeFile(CADDYFILE_PATH, original, 'utf8'); + return { success: false, error: err.message, rolledBack: true }; + } + } finally { + resolve(); + } +} + +/** + * Read the current Caddyfile content + */ +async function readCaddyfile(CADDYFILE_PATH) { + return fsp.readFile(CADDYFILE_PATH, 'utf8'); +} + +/** + * Reload Caddy via admin API + */ +async function reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log) { + const maxRetries = RETRIES.CADDY_RELOAD; + let lastError = null; + + for (let i = 0; i < maxRetries; i++) { + try { + const response = await fetchT(`${CADDY_ADMIN_URL}/load`, { + method: 'POST', + headers: { 'Content-Type': 'text/caddyfile' }, + body: content + }); + + if (response.ok) { + log.info('caddy', 'Caddy configuration reloaded successfully'); + 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}`); +} + +/** + * Verify a site is accessible via HTTPS + */ +async function verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts = 5) { + const delay = 2000; + + 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)); + } + } + + log.warn('caddy', 'Could not verify site accessibility', { domain }); + return false; +} + +/** + * Generate Caddy config block for a service + */ +function generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options = {}) { + const { tailscaleOnly = false, allowedIPs = [], subpathSupport = 'strip' } = options; + + // Subdirectory mode + 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`; + } + + 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 + 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; +} + +function createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain) { + const reload = (content) => reloadCaddy(CADDY_ADMIN_URL, content, fetchT, log); + const read = () => readCaddyfile(CADDYFILE_PATH); + const modify = (modifyFn) => modifyCaddyfile(CADDYFILE_PATH, reload, modifyFn); + const verify = (domain, maxAttempts) => verifySiteAccessible(domain, fetchT, httpsAgent, log, maxAttempts); + const generate = (subdomain, ip, port, options) => generateCaddyConfig(subdomain, ip, port, siteConfig, buildDomain, options); + + return { + modify, + read, + reload, + generateConfig: generate, + verifySite: verify, + adminUrl: CADDY_ADMIN_URL, + filePath: CADDYFILE_PATH, + }; +} + +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..2dba463 --- /dev/null +++ b/dashcaddy-api/src/context/dns.js @@ -0,0 +1,308 @@ +/** + * DNS context - Technitium DNS operations and token management + */ +const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants'); +const { createCache, CACHE_CONFIGS } = require('../../cache-config'); + +// DNS token management +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 + */ +function buildDnsUrl(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 a Technitium DNS API endpoint + */ +async function callDns(server, apiPath, params, fetchT, httpsAgent) { + const url = buildDnsUrl(server, apiPath, params); + const response = await fetchT(url, { + method: 'GET', + headers: { 'Accept': 'application/json' }, + agent: httpsAgent + }, TIMEOUTS.HTTP_LONG); + return response.json(); +} + +/** + * Refresh DNS token via login + */ +async function refreshDnsToken(username, password, server, fetchT, log) { + 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() + SESSION_TTL.DNS_TOKEN).toISOString(); + 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 }; + } +} + +/** + * Ensure we have a valid DNS token (auto-refresh if needed) + */ +async function ensureValidDnsToken(siteConfig, credentialManager, fetchT, log) { + // Check if token is valid and not expired + if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { + return { success: true, token: dnsToken }; + } + + const primaryIp = siteConfig.dnsServerIp; + if (primaryIp) { + const dnsId = dnsIpToDnsId(primaryIp, siteConfig); + 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 refreshDnsToken(username, password, primaryIp, fetchT, log); + } + } 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 refreshDnsToken(username, password, server || primaryIp, fetchT, log); + } + } 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' + }; +} + +/** + * Map DNS server IP to its ID + */ +function dnsIpToDnsId(serverIp, siteConfig) { + for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { + if (info.ip === serverIp) return dnsId; + } + return null; +} + +/** + * Get a valid token for a specific DNS server + */ +async function getTokenForServer(targetServer, siteConfig, credentialManager, fetchT, log, role = 'readonly') { + const cacheKey = `${targetServer}:${role}`; + const cached = dnsServerTokens.get(cacheKey); + + if (cached && cached.token && cached.expiry && new Date() < new Date(cached.expiry)) { + return { success: true, token: cached.token }; + } + + const serverPort = siteConfig.dnsServerPort || '5380'; + + async function authenticateToServer(username, password) { + const params = new URLSearchParams({ + user: username, + pass: password, + includeInfo: 'false' + }); + + const response = await fetchT( + `http://${targetServer}:${serverPort}/api/user/login?${params.toString()}`, + { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/x-www-form-urlencoded' + } + } + ); + + const result = await response.json(); + + if (result.status === 'ok' && result.token) { + dnsServerTokens.set(cacheKey, { + token: result.token, + expiry: new Date(Date.now() + SESSION_TTL.DNS_TOKEN).toISOString() + }); + log.info('dns', 'DNS token obtained for server', { server: targetServer, role }); + return { success: true, token: result.token }; + } + + return { success: false, error: result.errorMessage || 'Login failed' }; + } + + const dnsId = dnsIpToDnsId(targetServer, siteConfig); + + if (dnsId) { + try { + const username = await credentialManager.retrieve(`dns.${dnsId}.${role}.username`); + const password = await credentialManager.retrieve(`dns.${dnsId}.${role}.password`); + if (username && password) { + return await authenticateToServer(username, password); + } + } catch (err) { + log.error('dns', `Per-server ${role} credential error`, { dnsId, server: targetServer, error: err.message }); + } + + const fallbackRole = role === 'readonly' ? 'admin' : 'readonly'; + try { + const username = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.username`); + const password = await credentialManager.retrieve(`dns.${dnsId}.${fallbackRole}.password`); + if (username && password) { + return await authenticateToServer(username, password); + } + } catch (err) { + // ignore + } + } + + try { + const username = await credentialManager.retrieve('dns.username'); + const password = await credentialManager.retrieve('dns.password'); + if (username && password) { + return await authenticateToServer(username, password); + } + } catch (err) { + log.error('dns', 'Credential manager error', { server: targetServer, error: err.message }); + } + + return { success: false, error: 'No DNS credentials configured' }; +} + +/** + * Require a DNS token (throw if unavailable) + */ +async function requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log) { + if (providedToken) return providedToken; + const result = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); + if (result.success) return result.token; + const err = new Error('No valid DNS token available. ' + result.error); + err.statusCode = 401; + throw err; +} + +/** + * Create DNS record + */ +async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log) { + const tokenResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); + if (!tokenResult.success) { + throw new Error(`DNS token not available: ${tokenResult.error}`); + } + + const domain = buildDomain(subdomain); + const zone = siteConfig.tld.replace(/^\./, ''); + + const dnsParams = { + token: dnsToken, + domain, + zone, + type: 'A', + ipAddress: ip, + ttl: '300', + overwrite: 'true' + }; + + const callDnsApi = () => callDns(siteConfig.dnsServerIp, '/api/zones/records/add', dnsParams, fetchT, httpsAgent); + + try { + log.info('dns', 'Creating DNS record', { domain, ip }); + const result = await callDnsApi(); + + if (result.status === 'ok') { + log.info('dns', 'DNS record created', { domain, ip }); + return { success: true }; + } + + if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) { + log.info('dns', 'Token appears expired, attempting auto-refresh'); + const refreshResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); + if (!refreshResult.success) throw new Error(`Token refresh failed: ${refreshResult.error}`); + + const retryResult = await callDnsApi(); + if (retryResult.status === 'ok') { + log.info('dns', 'DNS record created after token refresh', { domain, ip }); + return { success: true }; + } + throw new Error(retryResult.errorMessage || 'Unknown error after token refresh'); + } + + throw new Error(result.errorMessage || 'Unknown error'); + } catch (error) { + throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`); + } +} + +function invalidateTokenForServer(serverIp) { + dnsServerTokens.delete(`${serverIp}:readonly`); + dnsServerTokens.delete(`${serverIp}:admin`); +} + +function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) { + const ensureToken = () => ensureValidDnsToken(siteConfig, credentialManager, fetchT, log); + const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log); + const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role); + const refresh = (username, password, server) => refreshDnsToken(username, password, server, fetchT, log); + const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log); + const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent); + + return { + call, + buildUrl: buildDnsUrl, + requireToken: require, + ensureToken, + createRecord: create, + getToken: () => dnsToken, + setToken: (t) => { dnsToken = t; }, + getTokenExpiry: () => dnsTokenExpiry, + setTokenExpiry: (e) => { dnsTokenExpiry = e; }, + getTokenForServer: getForServer, + invalidateTokenForServer, + refresh, + credentialsFile: DNS_CREDENTIALS_FILE, + }; +} + +module.exports = { createDnsContext }; diff --git a/dashcaddy-api/src/context/docker.js b/dashcaddy-api/src/context/docker.js new file mode 100644 index 0000000..5beb572 --- /dev/null +++ b/dashcaddy-api/src/context/docker.js @@ -0,0 +1,67 @@ +/** + * Docker context - Docker client and operations + */ +const Docker = require('dockerode'); +const { DOCKER } = require('../../constants'); + +const docker = new Docker(); + +/** + * Pull a Docker image with timeout protection + */ +function dockerPull(imageName, timeoutMs = DOCKER.TIMEOUT) { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Docker pull timed out after ${timeoutMs / 1000}s: ${imageName}`)), + timeoutMs + ); + docker.pull(imageName, (err, stream) => { + if (err) { + clearTimeout(timer); + return reject(err); + } + docker.modem.followProgress(stream, (err, output) => { + clearTimeout(timer); + if (err) return reject(err); + resolve(output); + }); + }); + }); +} + +/** + * Find a running Docker container by name substring + */ +async function findContainerByName(name, opts = { all: false }) { + const containers = await docker.listContainers(opts); + const match = containers.find(c => + c.Names.some(n => n.toLowerCase().includes(name.toLowerCase())) + ); + return match || null; +} + +/** + * Get all host ports currently in use by Docker containers + */ +async function getUsedPorts() { + const containers = await docker.listContainers({ all: false }); + const ports = new Set(); + for (const c of containers) { + for (const p of (c.Ports || [])) { + if (p.PublicPort) ports.add(p.PublicPort); + } + } + return ports; +} + +function createDockerContext(dockerSecurity) { + return { + client: docker, + pull: dockerPull, + findContainer: findContainerByName, + getUsedPorts, + security: dockerSecurity, + }; +} + +module.exports = { createDockerContext }; diff --git a/dashcaddy-api/src/context/index.js b/dashcaddy-api/src/context/index.js new file mode 100644 index 0000000..a25532a --- /dev/null +++ b/dashcaddy-api/src/context/index.js @@ -0,0 +1,175 @@ +/** + * Context assembly - Dependency injection container + * Assembles all context objects needed by routes + */ +const { createDockerContext } = require('./docker'); +const { createCaddyContext } = require('./caddy'); +const { createDnsContext } = require('./dns'); +const { createSessionContext } = require('./session'); + +/** + * Assemble the full application context + * This replaces the old "god object" ctx with explicit construction + */ +function assembleContext({ + // Config + siteConfig, + buildDomain, + buildServiceUrl, + SERVICES_FILE, + CONFIG_FILE, + TOTP_CONFIG_FILE, + TAILSCALE_CONFIG_FILE, + NOTIFICATIONS_FILE, + ERROR_LOG_FILE, + DNS_CREDENTIALS_FILE, + CADDYFILE_PATH, + CADDY_ADMIN_URL, + + // State managers + servicesStateManager, + configStateManager, + + // Managers + credentialManager, + authManager, + licenseManager, + healthChecker, + updateManager, + backupManager, + resourceMonitor, + auditLogger, + portLockManager, + selfUpdater, + dockerMaintenance, + logDigest, + dockerSecurity, + + // Templates + APP_TEMPLATES, + TEMPLATE_CATEGORIES, + DIFFICULTY_LEVELS, + RECIPE_TEMPLATES, + RECIPE_CATEGORIES, + + // Helpers + asyncHandler, + errorResponse, + ok, + fetchT, + httpsAgent, + log, + logError, + safeErrorMessage, + getServiceById, + readConfig, + saveConfig, + addServiceToConfig, + validateURL, + strictLimiter, + totpConfig, + saveTotpConfig, + loadSiteConfig, + loadNotificationConfig, + resyncHealthChecker, + + // Middleware result + middlewareResult, + + // App + app, +}) { + // Create domain-specific contexts + const docker = createDockerContext(dockerSecurity); + const caddy = createCaddyContext(CADDYFILE_PATH, CADDY_ADMIN_URL, fetchT, httpsAgent, log, siteConfig, buildDomain); + const dns = createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE); + const session = createSessionContext(middlewareResult); + + // Notification context (inline for now - could be extracted) + const notification = { + // These will be populated by server.js for now + // TODO: Extract notification module + }; + + // Tailscale context (inline for now - could be extracted) + const tailscale = { + // These will be populated by server.js for now + // TODO: Extract tailscale module + }; + + // Assemble flat context (temporary - routes still expect this) + const ctx = { + // Namespaced contexts + docker, + caddy, + dns, + session, + notification, + tailscale, + + // App and config + app, + siteConfig, + + // State managers + servicesStateManager, + configStateManager, + + // Managers + credentialManager, + authManager, + licenseManager, + healthChecker, + updateManager, + backupManager, + resourceMonitor, + auditLogger, + portLockManager, + selfUpdater, + dockerMaintenance, + logDigest, + + // Templates + APP_TEMPLATES, + TEMPLATE_CATEGORIES, + DIFFICULTY_LEVELS, + RECIPE_TEMPLATES, + RECIPE_CATEGORIES, + + // Helpers + asyncHandler, + errorResponse, + ok, + fetchT, + log, + logError, + safeErrorMessage, + buildDomain, + buildServiceUrl, + getServiceById, + readConfig, + saveConfig, + addServiceToConfig, + validateURL, + strictLimiter, + + // Config helpers + totpConfig, + saveTotpConfig, + loadSiteConfig, + loadNotificationConfig, + resyncHealthChecker, + + // File paths + SERVICES_FILE, + CONFIG_FILE, + TOTP_CONFIG_FILE, + TAILSCALE_CONFIG_FILE, + NOTIFICATIONS_FILE, + ERROR_LOG_FILE, + }; + + return ctx; +} + +module.exports = { assembleContext }; diff --git a/dashcaddy-api/src/context/session.js b/dashcaddy-api/src/context/session.js new file mode 100644 index 0000000..6b8f255 --- /dev/null +++ b/dashcaddy-api/src/context/session.js @@ -0,0 +1,21 @@ +/** + * Session context - IP-based session management + * (Implementation provided by middleware, just re-exported here) + */ + +function createSessionContext(middlewareResult) { + const { ipSessions, SESSION_DURATIONS, getClientIP, createIPSession, setSessionCookie, clearIPSession, clearSessionCookie, isSessionValid } = middlewareResult; + + return { + ipSessions, + durations: SESSION_DURATIONS, + getClientIP, + create: createIPSession, + setCookie: setSessionCookie, + clear: clearIPSession, + clearCookie: clearSessionCookie, + isValid: isSessionValid, + }; +} + +module.exports = { createSessionContext };