Files
dashcaddy/dashcaddy-api/routes/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

864 lines
33 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const validatorLib = require('validator');
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../src/utilities/constants');
const { exists } = require('../src/utilities/fs-helpers');
const { success, error: errorResponse } = require('../src/utils/responses');
const { ValidationError, AuthenticationError, NotFoundError } = require('../src/utilities/errors');
/**
* DNS routes factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.dns - DNS management interface (call, requireToken, getToken, etc.)
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {Function} deps.safeErrorMessage - Safe error message extractor
* @param {Function} deps.fetchT - Fetch wrapper with timeout
* @param {Object} deps.credentialManager - Credential storage manager
* @returns {express.Router}
*/
module.exports = function({
dns,
siteConfig,
asyncHandler,
log,
safeErrorMessage,
fetchT,
credentialManager,
dnsPropagationChecker
}) {
const router = express.Router();
/** Validate that a server IP is in the configured DNS servers list */
function validateDnsServer(server) {
const serverIp = server.includes(':') ? server.split(':')[0] : server;
if (!validatorLib.isIP(serverIp)) return null;
const configuredIps = Object.values(siteConfig.dnsServers || {}).map(s => s.ip).filter(Boolean);
// Also allow the default dnsServerIp
if (siteConfig.dnsServerIp) configuredIps.push(siteConfig.dnsServerIp);
if (!configuredIps.includes(serverIp)) return null;
return serverIp;
}
// ===== DNS PROVIDER ENDPOINTS =====
// GET /providers — List all available DNS providers
router.get('/providers', asyncHandler(async (req, res) => {
const providers = dns.getAvailableProviders ? dns.getAvailableProviders() : [];
const activeProvider = dns.getProviderId ? dns.getProviderId() : 'technitium';
success(res, { providers, activeProvider });
}, 'dns-providers-list'));
// GET /provider/status — Get active provider status
router.get('/provider/status', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return success(res, { providerId: 'technitium', capabilities: ['create-record', 'delete-record', 'resolve', 'list-records', 'logs', 'restart', 'update-check', 'credentials', 'zones'] });
}
try {
const provider = dns.getActiveProvider();
const status = await provider.getStatus();
success(res, status);
} catch (err) {
errorResponse(res, safeErrorMessage(err), 500);
}
}, 'dns-provider-status'));
// ===== UNIVERSAL RECORD ENDPOINTS (work with any provider) =====
// POST /universal/record — Create a DNS record via any provider
router.post('/universal/record', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
// Fallback to legacy Technitium route
return res.redirect(307, '/api/dns/record');
}
const { domain, ip, ttl, type, server } = req.body;
if (!domain || !ip) throw new ValidationError('domain and ip are required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
if (!validatorLib.isIP(ip)) throw new ValidationError('[DC-210] Invalid IP address');
try {
const provider = dns.getActiveProvider();
if (!provider.supportsCapability('create-record')) {
const result = await provider.createRecord({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
});
return success(res, {
message: result.message || `DNS record instructions provided`,
manual: true,
instructions: result.instructions
});
}
const result = await provider.createRecord({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A', value: ip, ttl: ttl || 300, overwrite: true
});
// Start propagation check in background
if (dnsPropagationChecker && ip) {
dnsPropagationChecker.startVerification(domain, ip).catch(err => {
log('DNS propagation check start failed:', err.message);
});
}
success(res, {
message: result.status === 'manual' ? result.message : `DNS record ${domain} -> ${ip} created`,
provider: dns.getProviderId(),
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
});
} catch (error) {
log.error('dns', error, null, { note: 'Universal DNS record creation error' });
errorResponse(res, safeErrorMessage(error), 500);
}
}, 'dns-universal-create'));
// DELETE /universal/record — Delete a DNS record via any provider
router.delete('/universal/record', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return res.redirect(307, '/api/dns/record');
}
const { domain, type, value } = req.query;
if (!domain) throw new ValidationError('domain is required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
try {
const provider = dns.getActiveProvider();
const result = await provider.deleteRecord({
domain, type: type || 'A', value
});
success(res, {
message: result.status === 'manual' ? result.message : `DNS record ${domain} deleted`,
provider: dns.getProviderId(),
...(result.instructions ? { manual: true, instructions: result.instructions } : {})
});
} catch (error) {
log.error('dns', error, null, { note: 'Universal DNS record deletion error' });
errorResponse(res, safeErrorMessage(error), 500);
}
}, 'dns-universal-delete'));
// GET /universal/resolve — Resolve a domain via any provider
router.get('/universal/resolve', asyncHandler(async (req, res) => {
if (!dns.getActiveProvider) {
return res.redirect(307, '/api/dns/resolve');
}
const { domain, type } = req.query;
if (!domain) throw new ValidationError('domain is required');
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
try {
const provider = dns.getActiveProvider();
const result = await provider.resolveRecords({
domain, zone: siteConfig.tld?.replace(/^\./, '') || '',
type: type || 'A'
});
if (result.response?.records?.length > 0) {
const ipAddresses = result.response.records
.filter(r => r.type === (type || 'A'))
.map(r => r.rData?.ipAddress || r.content || r.rData?.address)
.filter(Boolean);
success(res, { answer: ipAddresses });
} else {
throw new NotFoundError('No records found for domain');
}
} catch (error) {
log.error('dns', error, null, { note: 'Universal DNS resolve error' });
errorResponse(res, safeErrorMessage(error), error.statusCode || 500);
}
}, 'dns-universal-resolve'));
// ===== LEGACY TECHNITIUM-SPECIFIC ROUTES (unchanged) =====
router.delete('/record', asyncHandler(async (req, res) => {
const { domain, type, token, server, ipAddress } = req.query;
const dnsToken = await dns.requireToken(token);
if (!domain) {
throw new ValidationError('domain is required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate record type
if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) {
throw new ValidationError('Invalid DNS record type');
}
// Validate ipAddress if provided
if (ipAddress && !validatorLib.isIP(ipAddress)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
// Validate server against configured DNS servers
if (server && !validateDnsServer(server)) {
throw new ValidationError('Server must be a configured DNS server');
}
// Default to dns1 LAN IP, allow override
const dnsServer = server || siteConfig.dnsServerIp;
const recordType = type || 'A';
try {
const p = { token: dnsToken, domain: domain, type: recordType };
if (ipAddress) p.ipAddress = ipAddress;
const result = await dns.call(dnsServer, '/api/zones/records/delete', p);
if (result.status === 'ok') {
success(res, { message: `DNS record ${domain} deleted` });
} else {
// Error handled by middleware
}
} catch (error) {
// Error handled by middleware
}
}, 'dns-delete-record'));
// POST /record — Create a DNS record in Technitium
router.post('/record', asyncHandler(async (req, res) => {
const { domain, ip, ttl, token, server } = req.body;
const dnsToken = await dns.requireToken(token);
if (!domain || !ip) {
throw new ValidationError('domain and ip are required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate IP address
if (!validatorLib.isIP(ip)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
// Validate TTL if provided
if (ttl !== undefined) {
const parsedTtl = parseInt(ttl, 10);
if (isNaN(parsedTtl) || parsedTtl < CADDY.TTL_MIN || parsedTtl > CADDY.TTL_MAX) {
return errorResponse(res, `TTL must be between ${CADDY.TTL_MIN} and ${CADDY.TTL_MAX}`, 400);
}
}
// Validate server against configured DNS servers
if (server && !validateDnsServer(server)) {
throw new ValidationError('Server must be a configured DNS server');
}
// Default to dns1 LAN IP since Docker container can't access Tailscale network
const dnsServer = server || siteConfig.dnsServerIp;
const recordTtl = ttl || 300;
try {
// For Technitium, we need zone and subdomain separated
// domain = "test.sami" -> zone = "sami", subdomain = "test"
const parts = domain.split('.');
const subdomain = parts[0];
const zone = parts.slice(1).join('.') || siteConfig.tld.replace(/^\./, '');
const result = await dns.call(dnsServer, '/api/zones/records/add', {
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true'
});
if (result.status === 'ok') {
// Start DNS propagation verification in background
if (dnsPropagationChecker && ip) {
const fullDomain = domain;
dnsPropagationChecker.startVerification(fullDomain, ip).catch(err => {
log('DNS propagation check start failed:', err.message);
});
}
success(res, { message: `DNS record ${domain} -> ${ip} created` });
} else {
// Error handled by middleware
}
} catch (error) {
log.error('dns', error, null, { note: 'DNS record creation error' });
errorResponse(res, safeErrorMessage(error), 500, { details: error.cause?.code || 'fetch failed' });
}
}, 'dns-create-record'));
// GET /resolve — Resolve a domain to IP address via Technitium
router.get('/resolve', asyncHandler(async (req, res) => {
const { domain, server, token } = req.query;
const dnsToken = await dns.requireToken(token);
if (!domain) {
throw new ValidationError('domain is required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate server against configured DNS servers
if (server && !validateDnsServer(server)) {
throw new ValidationError('Server must be a configured DNS server');
}
const dnsServer = server || siteConfig.dnsServerIp;
try {
const result = await dns.call(dnsServer, '/api/zones/records/get', {
token: dnsToken, domain, zone: siteConfig.tld.replace(/^\./, ''), listZone: 'true'
});
if (result.status === 'ok' && result.response && result.response.records) {
// Find A records for this domain
const aRecords = result.response.records.filter(r => r.type === 'A');
if (aRecords.length > 0) {
const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean);
success(res, { answer: ipAddresses });
} else {
throw new NotFoundError('No A records found for domain');
}
} else {
// Error handled by middleware
}
} catch (error) {
log.error('dns', error, null, { note: 'DNS resolve error' });
// Error handled by middleware
}
}, 'dns-resolve'));
// GET /logs — Fetch DNS query logs (Technitium only)
router.get('/logs', asyncHandler(async (req, res) => {
// Capability gate: logs are provider-specific
if (dns.supportsCapability && !dns.supportsCapability('logs')) {
return success(res, { server: 'N/A', count: 0, logs: [], message: 'DNS logs not supported by current provider' });
}
const { server, limit } = req.query;
if (!server) {
throw new ValidationError('server is required');
}
// Validate server against configured DNS servers
const serverIp = validateDnsServer(server);
if (!serverIp) {
throw new ValidationError('Server must be a configured DNS server');
}
const logLimit = Math.min(parseInt(limit) || 25, 1000);
const dnsPort = siteConfig.dnsServerPort || '5380';
try {
// Auto-authenticate using stored read-only credentials for log access
const authResult = await dns.getTokenForServer(serverIp, 'readonly');
if (!authResult.success) {
throw new AuthenticationError('DNS auto-authentication failed. Ensure credentials are configured via the DNS panel.');
}
const effectiveToken = authResult.token;
// Try to get available log files first
const listUrl = `http://${serverIp}:${dnsPort}/api/logs/list?token=${encodeURIComponent(effectiveToken)}`;
const listResponse = await fetchT(listUrl, { method: 'GET', headers: { 'Accept': 'application/json' } });
let logFileName = new Date().toISOString().split('T')[0]; // Default to today
if (listResponse.ok) {
const listResult = await listResponse.json();
if (listResult.status === 'ok' && listResult.response?.logFiles?.length > 0) {
// Use most recent log file
logFileName = listResult.response.logFiles[0].fileName;
}
}
// Technitium logs/download endpoint - returns plain text logs
const technitiumUrl = `http://${serverIp}:${dnsPort}/api/logs/download?token=${encodeURIComponent(effectiveToken)}&fileName=${logFileName}`;
log.info('dns', 'Fetching DNS logs', { server, logFileName });
const response = await fetchT(technitiumUrl, {
method: 'GET',
headers: { 'Accept': 'text/plain' }
}, 10000);
if (!response.ok) {
const errorText = await response.text();
// Try to parse error as JSON
try {
const errorJson = JSON.parse(errorText);
if (errorJson.errorMessage?.includes('Could not find file')) {
return success(res, {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server'
});
}
return errorResponse(res, safeErrorMessage(errorJson.errorMessage || errorText), response.status);
} catch {
return errorResponse(res, 'DNS server returned an error', response.status);
}
}
// Parse plain text logs
const logText = await response.text();
// Check if it's an error JSON response
if (logText.startsWith('{')) {
try {
const errorJson = JSON.parse(logText);
if (errorJson.status && errorJson.status !== 'ok') {
if (errorJson.errorMessage?.includes('Could not find file')) {
return success(res, {
server: server,
count: 0,
logs: [],
message: 'No logs available for this server'
});
}
// Invalidate cached token on auth errors so next request re-authenticates
if (errorJson.status === 'invalid-token') {
dns.invalidateTokenForServer(serverIp);
}
return errorResponse(res, safeErrorMessage(errorJson.errorMessage), 400);
}
} catch { /* Not JSON, continue parsing as text */ }
}
const allLines = logText.split('\n').filter(line => line.trim() && !line.includes('Logging started'));
// Get last N lines (most recent)
const recentLines = allLines.slice(-logLimit);
// Parse each log line into structured format
const parsedLogs = recentLines.map(line => {
// Format: [2026-01-24 04:17:43 Local] [47.147.82.245:60001] [UDP] QNAME: domain; QTYPE: A; QCLASS: IN; RCODE: Refused; ANSWER: []
const match = line.match(/\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[^\]]*\]\s*\[([^\]]+)\]\s*\[(\w+)\]\s*QNAME:\s*([^;]+);\s*QTYPE:\s*([^;]+);\s*QCLASS:\s*([^;]+);\s*RCODE:\s*([^;]+);\s*ANSWER:\s*\[([^\]]*)\]/);
if (match) {
return {
timestamp: match[1],
client: match[2].split(':')[0], // Remove port
protocol: match[3],
domain: match[4].trim(),
type: match[5].trim(),
class: match[6].trim(),
rcode: match[7].trim(),
answer: match[8].trim() || null,
raw: line
};
}
return { raw: line, parsed: false };
}).reverse(); // Reverse to show most recent first
log.info('dns', 'Returning DNS log entries', { count: parsedLogs.length, logFileName });
success(res, {
server: server,
logFile: logFileName,
count: parsedLogs.length,
logs: parsedLogs
});
} catch (error) {
log.error('dns', error, null, { note: 'DNS logs proxy error' });
// Error handled by middleware
}
}, 'dns-logs'));
// GET /token-status — Check DNS token/credentials status
router.get('/token-status', asyncHandler(async (req, res) => {
const username = await credentialManager.retrieve('dns.username');
const hasCredentials = !!username || await exists(dns.credentialsFile);
const hasToken = !!dns.getToken();
success(res, {
hasCredentials,
hasToken,
tokenExpiry: dns.getTokenExpiry(),
isExpired: dns.getTokenExpiry() ? new Date() > new Date(dns.getTokenExpiry()) : null
});
}, 'dns-token-status'));
// POST /credentials — Store DNS credentials (encrypted)
// Accepts per-server format: { servers: { dns1: { username, password }, dns2: {...}, dns3: {...} } }
// Also accepts legacy format: { username, password, server }
router.post('/credentials', asyncHandler(async (req, res) => {
const { servers, username, password, server } = req.body;
const dangerousChars = [';', '&', '|', '`', '$', '\n', '\r'];
const dnsPort = siteConfig.dnsServerPort || '5380';
// Per-server format: { servers: { dns1: { readonly: { username, password }, admin: { username, password } }, ... } }
if (servers && typeof servers === 'object') {
const results = {};
let anySuccess = false;
for (const [dnsId, creds] of Object.entries(servers)) {
// Look up server IP from config
const serverInfo = siteConfig.dnsServers?.[dnsId];
const serverIp = serverInfo?.ip;
if (!serverIp) {
results[dnsId] = { success: false, error: `No IP configured for ${dnsId}` };
continue;
}
const savedTypes = [];
// Process both readonly and admin credential types
for (const credType of ['readonly', 'admin']) {
const typeCreds = creds[credType];
if (!typeCreds || !typeCreds.username || !typeCreds.password) continue;
if (typeCreds.username.length > 100 || typeCreds.password.length > 512) {
results[dnsId] = { success: false, error: `${credType} credentials exceed maximum length` };
continue;
}
if (dangerousChars.some(char => typeCreds.username.includes(char))) {
results[dnsId] = { success: false, error: `${credType} username contains invalid characters` };
continue;
}
// Test credentials by logging in to the target server
try {
const testResult = await dns.refresh(typeCreds.username, typeCreds.password, serverIp);
if (testResult.success) {
await credentialManager.store(`dns.${dnsId}.${credType}.username`, typeCreds.username, { type: 'dns', role: credType, server: serverIp });
await credentialManager.store(`dns.${dnsId}.${credType}.password`, typeCreds.password, { type: 'dns', role: credType, server: serverIp });
savedTypes.push(credType);
anySuccess = true;
log.info('dns', `${credType} credentials saved for ${dnsId}`, { server: serverIp });
} else {
if (!results[dnsId]) {
results[dnsId] = { success: false, error: `${credType}: ${testResult.error || 'Login failed'}` };
}
}
} catch (err) {
if (!results[dnsId]) {
results[dnsId] = { success: false, error: `${credType}: ${err.message}` };
}
}
}
if (savedTypes.length > 0) {
if (savedTypes.length === 2) {
results[dnsId] = { success: true };
} else {
results[dnsId] = { success: true, partial: `${savedTypes[0]} verified` };
}
}
}
return success(res, {
message: anySuccess ? 'Credentials saved for one or more servers' : 'All server credential tests failed',
results
});
}
// Legacy single-credential format: { username, password, server }
if (!username || !password) {
throw new ValidationError('username and password are required');
}
if (username.length > 100 || password.length > 512) {
throw new ValidationError('Credentials exceed maximum length');
}
if (dangerousChars.some(char => username.includes(char))) {
throw new ValidationError('Username contains invalid characters');
}
if (server && !validateDnsServer(server)) {
throw new ValidationError('Server must be a configured DNS server');
}
const testResult = await dns.refresh(username, password, server || siteConfig.dnsServerIp);
if (!testResult.success) {
return errorResponse(res, `Invalid credentials: ${testResult.error}`, 401);
}
const dnsServer = server || siteConfig.dnsServerIp;
await credentialManager.store('dns.username', username, { type: 'dns', server: dnsServer });
await credentialManager.store('dns.password', password, { type: 'dns', server: dnsServer });
await credentialManager.store('dns.server', dnsServer, { type: 'dns' });
log.info('dns', 'DNS credentials saved to credential manager (encrypted)');
success(res, {
message: 'DNS credentials saved and verified (encrypted)',
tokenExpiry: dns.getTokenExpiry()
});
}, 'dns-credentials'));
// DELETE /credentials — Delete stored DNS credentials
router.delete('/credentials', asyncHandler(async (req, res) => {
// Delete global credentials
await credentialManager.delete('dns.username');
await credentialManager.delete('dns.password');
await credentialManager.delete('dns.server');
// Delete per-server credentials (both old flat and new typed format)
for (const dnsId of Object.keys(siteConfig.dnsServers || {})) {
await credentialManager.delete(`dns.${dnsId}.username`);
await credentialManager.delete(`dns.${dnsId}.password`);
for (const role of ['readonly', 'admin']) {
await credentialManager.delete(`dns.${dnsId}.${role}.username`);
await credentialManager.delete(`dns.${dnsId}.${role}.password`);
}
}
if (await exists(dns.credentialsFile)) {
await fsp.unlink(dns.credentialsFile);
}
dns.setToken('');
dns.setTokenExpiry(null);
log.info('dns', 'DNS credentials deleted from credential manager');
success(res, { message: 'DNS credentials removed' });
}, 'dns-credentials-delete'));
// POST /restart/:dnsId — Restart a DNS server (Technitium only)
router.post('/restart/:dnsId', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('restart')) {
return errorResponse(res, 'Server restart not supported by current DNS provider', 501);
}
const { dnsId } = req.params;
const serverInfo = siteConfig.dnsServers?.[dnsId];
if (!serverInfo?.ip) {
return errorResponse(res, `Unknown DNS server: ${dnsId}`, 400);
}
const tokenResult = await dns.getTokenForServer(serverInfo.ip, 'admin');
if (!tokenResult.success) {
throw new AuthenticationError('DNS admin authentication failed. Ensure admin credentials are configured.');
}
const dnsPort = siteConfig.dnsServerPort || '5380';
try {
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
const response = await fetchT(url, { method: 'POST' }, 5000);
const result = await response.json();
if (result.status === 'ok') {
success(res, { message: 'Restart initiated' });
} else {
// Error handled by middleware
}
} catch (err) {
// Connection drop is expected during restart
success(res, { message: 'Restart initiated (connection closed)' });
}
}, 'dns-restart'));
// POST /refresh-token — Force refresh DNS token
router.post('/refresh-token', asyncHandler(async (req, res) => {
const result = await dns.ensureToken();
if (result.success) {
success(res, {
message: 'Token refreshed successfully',
tokenExpiry: dns.getTokenExpiry()
});
} else {
errorResponse(res, result.error, 401);
}
}, 'dns-refresh-token'));
// GET /check-update — Check for DNS server updates (Technitium only)
router.get('/check-update', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
return success(res, { updateAvailable: false, message: 'Update check not supported by current DNS provider' });
}
try {
const { server } = req.query;
if (!server) {
throw new ValidationError('Server IP required');
}
const serverIp = validateDnsServer(server);
if (!serverIp) {
throw new ValidationError('Server must be a configured DNS server');
}
// Authenticate with admin credentials for update check
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
if (!tokenResult.success) {
throw new AuthenticationError('DNS authentication failed. Ensure credentials are configured.');
}
const dnsPort = siteConfig.dnsServerPort || '5380';
const url = `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`;
log.info('dns', 'Checking DNS update', { server });
const response = await fetchT(url, {
method: 'GET',
headers: {
'Accept': 'application/json',
'User-Agent': APP.USER_AGENTS.API
}
});
const text = await response.text();
if (!text || text.trim() === '') {
return // Error handled by middleware
}
const result = JSON.parse(text);
if (result.status === 'ok') {
success(res, {
updateAvailable: result.response.updateAvailable,
currentVersion: result.response.currentVersion,
updateVersion: result.response.updateVersion || null,
updateTitle: result.response.updateTitle || null,
updateMessage: result.response.updateMessage || null,
downloadLink: result.response.downloadLink || null,
instructionsLink: result.response.instructionsLink || null
});
} else {
// Error handled by middleware
}
} catch (error) {
log.error('dns', error, null, { note: 'DNS update check error' });
// Error handled by middleware
}
}, 'dns-check-update'));
// POST /update — Update DNS server (Technitium only)
router.post('/update', asyncHandler(async (req, res) => {
// Capability gate
if (dns.supportsCapability && !dns.supportsCapability('update-check')) {
return errorResponse(res, 'Server update not supported by current DNS provider', 501);
}
try {
const { server } = req.query;
if (!server) {
throw new ValidationError('Server IP required');
}
const serverIp = validateDnsServer(server);
if (!serverIp) {
throw new ValidationError('Server must be a configured DNS server');
}
// Authenticate with admin credentials for update operations
const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
if (!tokenResult.success) {
throw new AuthenticationError('DNS authentication failed. Ensure credentials are configured.');
}
const dnsPort = siteConfig.dnsServerPort || '5380';
// Check if update is available
const checkResponse = await fetchT(
`http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`,
{ method: 'GET', headers: { 'Accept': 'application/json' } }
);
const checkText = await checkResponse.text();
if (!checkText || checkText.trim() === '') {
return // Error handled by middleware
}
const checkResult = JSON.parse(checkText);
if (checkResult.status !== 'ok') {
return // Error handled by middleware
}
if (!checkResult.response.updateAvailable) {
return success(res, {
message: 'Already up to date',
currentVersion: checkResult.response.currentVersion,
updated: false
});
}
// Technitium v14+ does not have an installUpdate API endpoint.
// Return the update info with download link so the frontend can guide the user.
log.info('dns', 'Update available for DNS server', { server, currentVersion: checkResult.response.currentVersion, updateVersion: checkResult.response.updateVersion });
success(res, {
message: `Update available: ${checkResult.response.updateVersion}`,
previousVersion: checkResult.response.currentVersion,
newVersion: checkResult.response.updateVersion,
downloadLink: checkResult.response.downloadLink || null,
instructionsLink: checkResult.response.instructionsLink || null,
updated: false,
manualUpdateRequired: true
});
} catch (error) {
log.error('dns', error, null, { note: 'DNS update error' });
// Error handled by middleware
}
}, 'dns-update'));
// ===== DNS PROPAGATION =====
// GET /propagation — Get all recent DNS propagation checks
router.get('/propagation', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verifications: [], message: 'DNS propagation checker not available' });
}
// Cleanup old entries
dnsPropagationChecker.cleanup();
const verifications = dnsPropagationChecker.getAllVerifications();
success(res, { verifications });
}, 'dns-propagation-all'));
// POST /propagation/verify — Manually trigger DNS propagation verification
router.post('/propagation/verify', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return errorResponse(res, 'DNS propagation checker not available', 503);
}
const { domain, expectedIp } = req.body;
if (!domain || !expectedIp) {
throw new ValidationError('domain and expectedIp are required');
}
// Validate domain format
if (!REGEX.DOMAIN.test(domain)) {
throw new ValidationError('[DC-301] Invalid domain format');
}
// Validate IP address
const validatorLib = require('validator');
if (!validatorLib.isIP(expectedIp)) {
throw new ValidationError('[DC-210] Invalid IP address');
}
const job = dnsPropagationChecker.startVerification(domain, expectedIp);
success(res, {
message: 'DNS propagation verification started',
domain,
expectedIp,
status: job.status
});
}, 'dns-propagation-verify'));
// GET /propagation/:domain — Get propagation status for a specific domain
router.get('/propagation/:domain', asyncHandler(async (req, res) => {
if (!dnsPropagationChecker) {
return success(res, { verification: null, message: 'DNS propagation checker not available' });
}
const { domain } = req.params;
const status = dnsPropagationChecker.getVerificationStatus(domain);
if (!status) {
throw new NotFoundError(`No propagation check found for domain: ${domain}`);
}
success(res, { verification: status });
}, 'dns-propagation-domain'));
return router;
};