Files
dashcaddy/dashcaddy-api/src/context/provider-dns.js
T
Krystie e99413150e
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):

P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.

P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).

P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.

Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).

Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.

Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
2026-08-16 04:18:07 -07:00

311 lines
12 KiB
JavaScript

/**
* 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('../utilities/cache-config');
const { TIMEOUTS, SESSION_TTL, CADDY } = require('../utilities/constants');
const registry = require('../dns/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 || {};
const builders = {
technitium: () => ({
serverIp: siteConfig.dnsServerIp || dnsConfig.ip || '',
serverPort: siteConfig.dnsServerPort || dnsConfig.port || '5380',
dnsServers: siteConfig.dnsServers || {},
dnsId: Object.keys(siteConfig.dnsServers || {})[0] || 'dns1'
}),
cloudflare: () => ({
apiToken: dnsConfig.apiToken || '',
zoneId: dnsConfig.zoneId || '',
domain: siteConfig.domain || ''
}),
rfc2136: () => ({
server: dnsConfig.server || siteConfig.dnsServerIp || '',
port: dnsConfig.port || 53,
zone: siteConfig.tld?.replace(/^\./, '') || '',
tsigAlgorithm: dnsConfig.tsigAlgorithm || 'hmac-sha256',
tsigKeyName: dnsConfig.tsigKeyName || '',
tsigSecret: dnsConfig.tsigSecret || ''
}),
manual: () => ({})
};
const builder = builders[providerId];
return builder ? builder() : 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', error, null, { note: 'DNS token refresh error' });
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 tryServerRoleCredentials(dnsId, role, primaryIp) {
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 */ }
return null;
}
async function tryGlobalCredentials(primaryIp) {
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 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']) {
const result = await tryServerRoleCredentials(dnsId, role, primaryIp);
if (result) return result;
}
}
}
const globalResult = await tryGlobalCredentials(primaryIp);
if (globalResult) return globalResult;
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 };