/** * Provider-aware DNS Context * Replaces the Technitium-only context with a provider-agnostic layer. * Delegates to the active DNS provider adapter based on config. * * Falls back to legacy Technitium context for backward compatibility * when no provider is explicitly configured. */ const { createCache, CACHE_CONFIGS } = require('../../cache-config'); const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants'); const registry = require('../../dns-providers/registry'); // Per-server token cache (legacy Technitium) const dnsServerTokens = createCache(CACHE_CONFIGS.dnsTokens); let dnsToken = ''; let dnsTokenExpiry = null; /** * Create a provider-aware DNS context. * This wraps both the new provider system and the legacy Technitium context * for seamless migration. */ function createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE) { /** Resolve the active provider from config */ function getProviderId() { // New explicit provider field if (siteConfig.dns?.provider) return siteConfig.dns.provider; // Legacy: if dns.ip is set, default to technitium if (siteConfig.dnsServerIp || siteConfig.dns?.ip) return 'technitium'; // No DNS configured return 'manual'; } /** Get provider-specific config from site config */ function getProviderConfig(providerId) { const dnsConfig = siteConfig.dns || {}; switch (providerId) { case 'technitium': return { serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '', serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380', dnsServers: siteConfig.dnsServers || {}, dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1' }; case 'cloudflare': return { apiToken: dnsConfig.apiToken || '', zoneId: dnsConfig.zoneId || '', domain: siteConfig.domain || '' }; case 'rfc2136': return { server: dnsConfig.server || siteConfig.dnsServerIp || '', port: dnsConfig.port || 53, zone: siteConfig.tld?.replace(/^\./, '') || '', tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256', tsigKeyName: dnsConfig.tsigKeyName || '', tsigSecret: dnsConfig.tsigSecret || '' }; case 'manual': return {}; default: return dnsConfig; } } /** Get or create the active provider adapter */ function getActiveProvider() { const providerId = getProviderId(); const config = getProviderConfig(providerId); const ctx = { log, credentialManager, fetchT, httpsAgent }; return registry.getProvider(providerId, config, ctx); } // ===== Legacy Technitium helpers (kept for backward compat) ===== 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}`; } async function callDns(server, apiPath, params) { 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(); } async function refreshDnsToken(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' } }, 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 }; } } function dnsIpToDnsId(serverIp) { for (const [dnsId, info] of Object.entries(siteConfig.dnsServers || {})) { if (info.ip === serverIp) return dnsId; } return null; } async function ensureValidDnsToken() { if (dnsToken && dnsTokenExpiry && new Date() < new Date(dnsTokenExpiry)) { return { success: true, token: dnsToken }; } 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 refreshDnsToken(username, password, primaryIp); } catch (err) { /* try next */ } } } } 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); } catch (err) { /* no global creds */ } return { success: false, error: 'No DNS credentials configured' }; } async function getTokenForServer(targetServer, role = 'readonly') { const cacheKey = `${targetServer}:${role}`; const cached = dnsServerTokens.get(cacheKey); if (cached?.token && cached?.expiry && new Date() < new Date(cached.expiry)) { return { success: true, token: cached.token }; } const serverPort = siteConfig.dnsServerPort || '5380'; async function authToServer(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); if (dnsId) { for (const r of [role, role === 'readonly' ? 'admin' : 'readonly']) { try { const username = await credentialManager.retrieve(`dns.${dnsId}.${r}.username`); const password = await credentialManager.retrieve(`dns.${dnsId}.${r}.password`); if (username && password) return await authToServer(username, password); } catch { /* try next */ } } } try { const username = await credentialManager.retrieve('dns.username'); const password = await credentialManager.retrieve('dns.password'); if (username && password) return await authToServer(username, password); } catch { /* no global creds */ } return { success: false, error: 'No DNS credentials configured' }; } async function requireDnsToken(providedToken) { if (providedToken) return providedToken; const result = await ensureValidDnsToken(); if (result.success) return result.token; const err = new Error('No valid DNS token available. ' + result.error); err.statusCode = 401; throw err; } function invalidateTokenForServer(serverIp) { dnsServerTokens.delete(`${serverIp}:readonly`); dnsServerTokens.delete(`${serverIp}:admin`); } // ===== Public context API ===== // This maintains the same interface as the old createDnsContext() // but adds provider-aware methods on top. return { // --- Provider-aware methods --- /** Get the active provider ID */ getProviderId, /** Get the active provider adapter instance */ getActiveProvider, /** Get metadata for all available providers */ getAvailableProviders: () => registry.getProviderMeta(), /** Check if the active provider supports a capability */ supportsCapability: (cap) => { try { return getActiveProvider().supportsCapability(cap); } catch { return false; } }, // --- Legacy Technitium context (backward compat) --- call: callDns, buildUrl: buildDnsUrl, requireToken: requireDnsToken, ensureToken: ensureValidDnsToken, getToken: () => dnsToken, setToken: (t) => { dnsToken = t; }, getTokenExpiry: () => dnsTokenExpiry, setTokenExpiry: (e) => { dnsTokenExpiry = e; }, getTokenForServer, invalidateTokenForServer, refresh: refreshDnsToken, credentialsFile: DNS_CREDENTIALS_FILE, // --- Universal DNS helpers (provider-agnostic) --- /** * Create a DNS A record using the active provider. * Gracefully handles manual adapters that return instructions instead of performing the action. */ async universalCreateRecord(subdomain, ip) { const provider = getActiveProvider(); const result = await provider.createRecord({ domain: buildDomain(subdomain), zone: siteConfig.tld?.replace(/^\./, '') || '', type: 'A', value: ip, ttl: 300, overwrite: true, }); // Manual adapter returns instructions instead of performing the action if (result?.manual || result?.instructions) { return { success: true, manual: true, instructions: result.instructions || result }; } return result; }, /** * Delete a DNS A record using the active provider. * Gracefully handles manual adapters that return instructions instead of performing the action. */ async universalDeleteRecord(domain, ip) { const provider = getActiveProvider(); const result = await provider.deleteRecord({ domain, type: 'A', value: ip, }); if (result?.manual || result?.instructions) { return { success: true, manual: true, instructions: result.instructions || result }; } return result; }, /** * Resolve DNS records using the active provider. * Returns parsed IP addresses from the result. */ async universalResolveRecord(domain, type) { const provider = getActiveProvider(); const result = await provider.resolveRecords({ domain, zone: siteConfig.tld?.replace(/^\./, '') || '', type: type || 'A', }); // Parse IP addresses from the result if (Array.isArray(result)) { return result; } if (result?.records) { return result.records.map(r => r.ipAddress || r.value || r.address || r).filter(Boolean); } if (result?.ips) { return result.ips; } return result; }, }; } module.exports = { createProviderDnsContext };