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('../constants'); const { exists } = require('../fs-helpers'); const { success, error: errorResponse } = require('../src/utils/responses'); const { ValidationError, AuthenticationError, NotFoundError } = require('../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', 'Universal DNS record creation error', { error: error.message }); 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', 'Universal DNS record deletion error', { error: error.message }); 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', 'Universal DNS resolve error', { error: error.message }); 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', 'DNS record creation error', { error: error.message }); 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', 'DNS resolve error', { error: error.message }); // 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' }, timeout: 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', 'DNS logs proxy error', { error: error.message }); // 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 res.json({ success: anySuccess, 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', timeout: 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', 'DNS update check error', { error: error.message }); // 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', 'DNS update error', { error: error.message }); // 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; };