Files
dashcaddy/dashcaddy-api/src/context/dns.js
T
Hermes 2d394d882d
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Standardize response shapes and fix dead fetchT timeout keys
Three small cleanups for v1.14.0:

1. /caddy/cas now uses standard success envelope
   Was: { status: 'success', data: { cas: caList } }
   Now: { success: true, cas: caList }
   Updated frontend service-infrastructure.js to match.

2. /api/health/ca now uses standard envelope + meaningful HTTP codes
   Was: { status, message, daysUntilExpiration } with 200 on every error
   Now: { success, caStatus, message|error, daysUntilExpiration }
        with 200 / 404 / 500 as appropriate
   caStatus field preserves the original 'healthy'/'warning'/'critical'/'error'
   semantic so any future consumer of the CA-health state still has it.
   Tests updated to match.

3. Dead timeout: keys in fetchT opts are now a warning, not a silent strip
   src/utils/http.js:41 used to do  without telling
   anyone. Callers that wrote fetchT(url, { timeout: 5000 }) got the default
   5s timeout with no indication that their explicit value was ignored.
   Now it logs a warning naming the call site, then strips the key.
   Fixed 4 call sites that had stale timeout: keys:
   - src/context/caddy.js
   - src/context/dns.js
   - src/context/provider-dns.js
   - routes/dns.js (2 places)
2026-06-10 21:52:33 -07:00

331 lines
11 KiB
JavaScript

/**
* DNS context - Technitium DNS operations and token management
*
* DEPRECATED: This module is kept for backward compatibility.
* New code should use src/context/provider-dns.js which supports multiple providers.
*
* This module now delegates to the provider system internally.
*/
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../../constants');
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
const { createProviderDnsContext } = require('./provider-dns');
// 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'
}
},
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, credentialManager, 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) {
// Create the new provider-aware context
const providerCtx = createProviderDnsContext(siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log, DNS_CREDENTIALS_FILE);
// Legacy Technitium-specific wrappers (kept for backward compat)
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, credentialManager, fetchT, httpsAgent, log);
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
return {
// Legacy Technitium-specific interface (unchanged)
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,
// Provider-aware methods (new)
getProviderId: providerCtx.getProviderId,
getActiveProvider: providerCtx.getActiveProvider,
getAvailableProviders: providerCtx.getAvailableProviders,
supportsCapability: providerCtx.supportsCapability,
// Universal DNS helpers (delegated to provider context)
universalCreateRecord: providerCtx.universalCreateRecord,
universalDeleteRecord: providerCtx.universalDeleteRecord,
universalResolveRecord: providerCtx.universalResolveRecord,
};
}
module.exports = { createDnsContext };