feat: DNS provider abstraction — Technitium, Cloudflare, RFC 2136, Manual
- dns-providers/: adapter base class + registry with auto-discovery - technitium.js: wraps existing Technitium API calls into adapter interface - cloudflare.js: Cloudflare API v4 adapter (zones, records, credentials) - rfc2136.js: RFC 2136 dynamic DNS via nsupdate (BIND, PowerDNS, etc.) - manual.js: no-op adapter for external DNS management with instructions - provider-dns.js: provider-aware DNS context, resolves active adapter from config - Universal helper methods: universalCreateRecord/Delete/ResolveRecord - All 7 route files updated to use universal methods instead of raw dns.call() - Setup wizard: provider dropdown (Technitium, Cloudflare, RFC 2136, Manual) - DNS template selector: added Cloudflare and External/Manual options - Config schema: validates dns.provider field - Capability gating on Technitium-specific endpoints (logs, restart, update) - Backward compatible: no provider set = auto-detect (technitium if dns.ip exists)
This commit is contained in:
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* 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' }, 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 };
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user