refactor(routes): Phase 3.4 - standardize dns.js with explicit dependencies

- Replaced god object ctx with explicit dependency injection
- Added JSDoc documenting required dependencies (7 deps vs 50+)
- Updated response calls to use response-helpers (success/error)
- Dependencies: dns, siteConfig, asyncHandler, log, safeErrorMessage, fetchT, credentialManager
- DNS record management, Technitium proxy, credential storage all preserved
- 632 lines, now self-documenting and testable
This commit is contained in:
Krystie
2026-03-28 19:28:17 -07:00
parent eac4ede21e
commit 970e862533
2 changed files with 175 additions and 155 deletions

View File

@@ -4,105 +4,126 @@ const fsp = require('fs').promises;
const validatorLib = require('validator'); const validatorLib = require('validator');
const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants'); const { APP, TIMEOUTS, CADDY, DNS_RECORD_TYPES, REGEX, SESSION_TTL } = require('../constants');
const { exists } = require('../fs-helpers'); const { exists } = require('../fs-helpers');
const { success, error: errorResponse } = require('../response-helpers');
module.exports = function(ctx) { /**
* 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
}) {
const router = express.Router(); const router = express.Router();
/** Validate that a server IP is in the configured DNS servers list */ /** Validate that a server IP is in the configured DNS servers list */
function validateDnsServer(server) { function validateDnsServer(server) {
const serverIp = server.includes(':') ? server.split(':')[0] : server; const serverIp = server.includes(':') ? server.split(':')[0] : server;
if (!validatorLib.isIP(serverIp)) return null; if (!validatorLib.isIP(serverIp)) return null;
const configuredIps = Object.values(ctx.siteConfig.dnsServers || {}).map(s => s.ip).filter(Boolean); const configuredIps = Object.values(siteConfig.dnsServers || {}).map(s => s.ip).filter(Boolean);
// Also allow the default dnsServerIp // Also allow the default dnsServerIp
if (ctx.siteConfig.dnsServerIp) configuredIps.push(ctx.siteConfig.dnsServerIp); if (siteConfig.dnsServerIp) configuredIps.push(siteConfig.dnsServerIp);
if (!configuredIps.includes(serverIp)) return null; if (!configuredIps.includes(serverIp)) return null;
return serverIp; return serverIp;
} }
// DELETE /record — Delete a DNS record from Technitium // DELETE /record — Delete a DNS record from Technitium
router.delete('/record', ctx.asyncHandler(async (req, res) => { router.delete('/record', asyncHandler(async (req, res) => {
const { domain, type, token, server, ipAddress } = req.query; const { domain, type, token, server, ipAddress } = req.query;
const dnsToken = await ctx.dns.requireToken(token); const dnsToken = await dns.requireToken(token);
if (!domain) { if (!domain) {
return ctx.errorResponse(res, 400, 'domain is required'); return errorResponse(res, 'domain is required', 400);
} }
// Validate domain format // Validate domain format
if (!REGEX.DOMAIN.test(domain)) { if (!REGEX.DOMAIN.test(domain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format'); return errorResponse(res, '[DC-301] Invalid domain format', 400);
} }
// Validate record type // Validate record type
if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) { if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) {
return ctx.errorResponse(res, 400, 'Invalid DNS record type'); return errorResponse(res, 'Invalid DNS record type', 400);
} }
// Validate ipAddress if provided // Validate ipAddress if provided
if (ipAddress && !validatorLib.isIP(ipAddress)) { if (ipAddress && !validatorLib.isIP(ipAddress)) {
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address'); return errorResponse(res, '[DC-210] Invalid IP address', 400);
} }
// Validate server against configured DNS servers // Validate server against configured DNS servers
if (server && !validateDnsServer(server)) { if (server && !validateDnsServer(server)) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
// Default to dns1 LAN IP, allow override // Default to dns1 LAN IP, allow override
const dnsServer = server || ctx.siteConfig.dnsServerIp; const dnsServer = server || siteConfig.dnsServerIp;
const recordType = type || 'A'; const recordType = type || 'A';
try { try {
const p = { token: dnsToken, domain: domain, type: recordType }; const p = { token: dnsToken, domain: domain, type: recordType };
if (ipAddress) p.ipAddress = ipAddress; if (ipAddress) p.ipAddress = ipAddress;
const result = await ctx.dns.call(dnsServer, '/api/zones/records/delete', p); const result = await dns.call(dnsServer, '/api/zones/records/delete', p);
if (result.status === 'ok') { if (result.status === 'ok') {
res.json({ success: true, message: `DNS record ${domain} deleted` }); success(res, { message: `DNS record ${domain} deleted` });
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'DNS deletion failed'); errorResponse(res, result.errorMessage || 'DNS deletion failed', 500);
} }
} catch (error) { } catch (error) {
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, safeErrorMessage(error), 500);
} }
}, 'dns-delete-record')); }, 'dns-delete-record'));
// POST /record — Create a DNS record in Technitium // POST /record — Create a DNS record in Technitium
router.post('/record', ctx.asyncHandler(async (req, res) => { router.post('/record', asyncHandler(async (req, res) => {
const { domain, ip, ttl, token, server } = req.body; const { domain, ip, ttl, token, server } = req.body;
const dnsToken = await ctx.dns.requireToken(token); const dnsToken = await dns.requireToken(token);
if (!domain || !ip) { if (!domain || !ip) {
return ctx.errorResponse(res, 400, 'domain and ip are required'); return errorResponse(res, 'domain and ip are required', 400);
} }
// Validate domain format // Validate domain format
if (!REGEX.DOMAIN.test(domain)) { if (!REGEX.DOMAIN.test(domain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format'); return errorResponse(res, '[DC-301] Invalid domain format', 400);
} }
// Validate IP address // Validate IP address
if (!validatorLib.isIP(ip)) { if (!validatorLib.isIP(ip)) {
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address'); return errorResponse(res, '[DC-210] Invalid IP address', 400);
} }
// Validate TTL if provided // Validate TTL if provided
if (ttl !== undefined) { if (ttl !== undefined) {
const parsedTtl = parseInt(ttl, 10); const parsedTtl = parseInt(ttl, 10);
if (isNaN(parsedTtl) || parsedTtl < CADDY.TTL_MIN || parsedTtl > CADDY.TTL_MAX) { if (isNaN(parsedTtl) || parsedTtl < CADDY.TTL_MIN || parsedTtl > CADDY.TTL_MAX) {
return ctx.errorResponse(res, 400, `TTL must be between ${CADDY.TTL_MIN} and ${CADDY.TTL_MAX}`); return errorResponse(res, `TTL must be between ${CADDY.TTL_MIN} and ${CADDY.TTL_MAX}`, 400);
} }
} }
// Validate server against configured DNS servers // Validate server against configured DNS servers
if (server && !validateDnsServer(server)) { if (server && !validateDnsServer(server)) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
// Default to dns1 LAN IP since Docker container can't access Tailscale network // Default to dns1 LAN IP since Docker container can't access Tailscale network
const dnsServer = server || ctx.siteConfig.dnsServerIp; const dnsServer = server || siteConfig.dnsServerIp;
const recordTtl = ttl || 300; const recordTtl = ttl || 300;
try { try {
@@ -110,48 +131,48 @@ module.exports = function(ctx) {
// domain = "test.sami" -> zone = "sami", subdomain = "test" // domain = "test.sami" -> zone = "sami", subdomain = "test"
const parts = domain.split('.'); const parts = domain.split('.');
const subdomain = parts[0]; const subdomain = parts[0];
const zone = parts.slice(1).join('.') || ctx.siteConfig.tld.replace(/^\./, ''); const zone = parts.slice(1).join('.') || siteConfig.tld.replace(/^\./, '');
const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', { const result = await dns.call(dnsServer, '/api/zones/records/add', {
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true' token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true'
}); });
if (result.status === 'ok') { if (result.status === 'ok') {
res.json({ success: true, message: `DNS record ${domain} -> ${ip} created` }); success(res, { message: `DNS record ${domain} -> ${ip} created` });
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'DNS creation failed'); errorResponse(res, result.errorMessage || 'DNS creation failed', 500);
} }
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS record creation error', { error: error.message }); log.error('dns', 'DNS record creation error', { error: error.message });
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { details: error.cause?.code || 'fetch failed' }); errorResponse(res, safeErrorMessage(error), 500, { details: error.cause?.code || 'fetch failed' });
} }
}, 'dns-create-record')); }, 'dns-create-record'));
// GET /resolve — Resolve a domain to IP address via Technitium // GET /resolve — Resolve a domain to IP address via Technitium
router.get('/resolve', ctx.asyncHandler(async (req, res) => { router.get('/resolve', asyncHandler(async (req, res) => {
const { domain, server, token } = req.query; const { domain, server, token } = req.query;
const dnsToken = await ctx.dns.requireToken(token); const dnsToken = await dns.requireToken(token);
if (!domain) { if (!domain) {
return ctx.errorResponse(res, 400, 'domain is required'); return errorResponse(res, 'domain is required', 400);
} }
// Validate domain format // Validate domain format
if (!REGEX.DOMAIN.test(domain)) { if (!REGEX.DOMAIN.test(domain)) {
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format'); return errorResponse(res, '[DC-301] Invalid domain format', 400);
} }
// Validate server against configured DNS servers // Validate server against configured DNS servers
if (server && !validateDnsServer(server)) { if (server && !validateDnsServer(server)) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
const dnsServer = server || ctx.siteConfig.dnsServerIp; const dnsServer = server || siteConfig.dnsServerIp;
try { try {
const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', { const result = await dns.call(dnsServer, '/api/zones/records/get', {
token: dnsToken, domain, zone: ctx.siteConfig.tld.replace(/^\./, ''), listZone: 'true' token: dnsToken, domain, zone: siteConfig.tld.replace(/^\./, ''), listZone: 'true'
}); });
if (result.status === 'ok' && result.response && result.response.records) { if (result.status === 'ok' && result.response && result.response.records) {
@@ -159,47 +180,47 @@ module.exports = function(ctx) {
const aRecords = result.response.records.filter(r => r.type === 'A'); const aRecords = result.response.records.filter(r => r.type === 'A');
if (aRecords.length > 0) { if (aRecords.length > 0) {
const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean); const ipAddresses = aRecords.map(r => r.rData?.ipAddress).filter(Boolean);
res.json({ success: true, answer: ipAddresses }); success(res, { answer: ipAddresses });
} else { } else {
ctx.errorResponse(res, 404, 'No A records found for domain'); errorResponse(res, 'No A records found for domain', 404);
} }
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'DNS resolve failed'); errorResponse(res, result.errorMessage || 'DNS resolve failed', 500);
} }
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS resolve error', { error: error.message }); log.error('dns', 'DNS resolve error', { error: error.message });
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, safeErrorMessage(error), 500);
} }
}, 'dns-resolve')); }, 'dns-resolve'));
// GET /logs — Fetch DNS query logs from Technitium // GET /logs — Fetch DNS query logs from Technitium
router.get('/logs', ctx.asyncHandler(async (req, res) => { router.get('/logs', asyncHandler(async (req, res) => {
const { server, limit } = req.query; const { server, limit } = req.query;
if (!server) { if (!server) {
return ctx.errorResponse(res, 400, 'server is required'); return errorResponse(res, 'server is required', 400);
} }
// Validate server against configured DNS servers // Validate server against configured DNS servers
const serverIp = validateDnsServer(server); const serverIp = validateDnsServer(server);
if (!serverIp) { if (!serverIp) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
const logLimit = Math.min(parseInt(limit) || 25, 1000); const logLimit = Math.min(parseInt(limit) || 25, 1000);
const dnsPort = ctx.siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
try { try {
// Auto-authenticate using stored read-only credentials for log access // Auto-authenticate using stored read-only credentials for log access
const authResult = await ctx.dns.getTokenForServer(serverIp, 'readonly'); const authResult = await dns.getTokenForServer(serverIp, 'readonly');
if (!authResult.success) { if (!authResult.success) {
return ctx.errorResponse(res, 401, 'DNS auto-authentication failed. Ensure credentials are configured via the DNS panel.'); return errorResponse(res, 'DNS auto-authentication failed. Ensure credentials are configured via the DNS panel.', 401);
} }
const effectiveToken = authResult.token; const effectiveToken = authResult.token;
// Try to get available log files first // Try to get available log files first
const listUrl = `http://${serverIp}:${dnsPort}/api/logs/list?token=${encodeURIComponent(effectiveToken)}`; const listUrl = `http://${serverIp}:${dnsPort}/api/logs/list?token=${encodeURIComponent(effectiveToken)}`;
const listResponse = await ctx.fetchT(listUrl, { method: 'GET', headers: { 'Accept': 'application/json' } }); const listResponse = await fetchT(listUrl, { method: 'GET', headers: { 'Accept': 'application/json' } });
let logFileName = new Date().toISOString().split('T')[0]; // Default to today let logFileName = new Date().toISOString().split('T')[0]; // Default to today
@@ -213,9 +234,9 @@ module.exports = function(ctx) {
// Technitium logs/download endpoint - returns plain text logs // Technitium logs/download endpoint - returns plain text logs
const technitiumUrl = `http://${serverIp}:${dnsPort}/api/logs/download?token=${encodeURIComponent(effectiveToken)}&fileName=${logFileName}`; const technitiumUrl = `http://${serverIp}:${dnsPort}/api/logs/download?token=${encodeURIComponent(effectiveToken)}&fileName=${logFileName}`;
ctx.log.info('dns', 'Fetching DNS logs', { server, logFileName }); log.info('dns', 'Fetching DNS logs', { server, logFileName });
const response = await ctx.fetchT(technitiumUrl, { const response = await fetchT(technitiumUrl, {
method: 'GET', method: 'GET',
headers: { 'Accept': 'text/plain' }, headers: { 'Accept': 'text/plain' },
timeout: 10000 timeout: 10000
@@ -227,17 +248,16 @@ module.exports = function(ctx) {
try { try {
const errorJson = JSON.parse(errorText); const errorJson = JSON.parse(errorText);
if (errorJson.errorMessage?.includes('Could not find file')) { if (errorJson.errorMessage?.includes('Could not find file')) {
return res.json({ return success(res, {
success: true,
server: server, server: server,
count: 0, count: 0,
logs: [], logs: [],
message: 'No logs available for this server' message: 'No logs available for this server'
}); });
} }
return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText)); return errorResponse(res, safeErrorMessage(errorJson.errorMessage || errorText), response.status);
} catch { } catch {
return ctx.errorResponse(res, response.status, 'DNS server returned an error'); return errorResponse(res, 'DNS server returned an error', response.status);
} }
} }
@@ -250,8 +270,7 @@ module.exports = function(ctx) {
const errorJson = JSON.parse(logText); const errorJson = JSON.parse(logText);
if (errorJson.status && errorJson.status !== 'ok') { if (errorJson.status && errorJson.status !== 'ok') {
if (errorJson.errorMessage?.includes('Could not find file')) { if (errorJson.errorMessage?.includes('Could not find file')) {
return res.json({ return success(res, {
success: true,
server: server, server: server,
count: 0, count: 0,
logs: [], logs: [],
@@ -260,9 +279,9 @@ module.exports = function(ctx) {
} }
// Invalidate cached token on auth errors so next request re-authenticates // Invalidate cached token on auth errors so next request re-authenticates
if (errorJson.status === 'invalid-token') { if (errorJson.status === 'invalid-token') {
ctx.dns.invalidateTokenForServer(serverIp); dns.invalidateTokenForServer(serverIp);
} }
return ctx.errorResponse(res, 400, ctx.safeErrorMessage(errorJson.errorMessage)); return errorResponse(res, safeErrorMessage(errorJson.errorMessage), 400);
} }
} catch { /* Not JSON, continue parsing as text */ } } catch { /* Not JSON, continue parsing as text */ }
} }
@@ -293,9 +312,8 @@ module.exports = function(ctx) {
return { raw: line, parsed: false }; return { raw: line, parsed: false };
}).reverse(); // Reverse to show most recent first }).reverse(); // Reverse to show most recent first
ctx.log.info('dns', 'Returning DNS log entries', { count: parsedLogs.length, logFileName }); log.info('dns', 'Returning DNS log entries', { count: parsedLogs.length, logFileName });
res.json({ success(res, {
success: true,
server: server, server: server,
logFile: logFileName, logFile: logFileName,
count: parsedLogs.length, count: parsedLogs.length,
@@ -303,33 +321,32 @@ module.exports = function(ctx) {
}); });
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS logs proxy error', { error: error.message }); log.error('dns', 'DNS logs proxy error', { error: error.message });
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, safeErrorMessage(error), 500);
} }
}, 'dns-logs')); }, 'dns-logs'));
// GET /token-status — Check DNS token/credentials status // GET /token-status — Check DNS token/credentials status
router.get('/token-status', ctx.asyncHandler(async (req, res) => { router.get('/token-status', asyncHandler(async (req, res) => {
const username = await ctx.credentialManager.retrieve('dns.username'); const username = await credentialManager.retrieve('dns.username');
const hasCredentials = !!username || await exists(ctx.dns.credentialsFile); const hasCredentials = !!username || await exists(dns.credentialsFile);
const hasToken = !!ctx.dns.getToken(); const hasToken = !!dns.getToken();
res.json({ success(res, {
success: true,
hasCredentials, hasCredentials,
hasToken, hasToken,
tokenExpiry: ctx.dns.getTokenExpiry(), tokenExpiry: dns.getTokenExpiry(),
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.dns.getTokenExpiry()) : null isExpired: dns.getTokenExpiry() ? new Date() > new Date(dns.getTokenExpiry()) : null
}); });
}, 'dns-token-status')); }, 'dns-token-status'));
// POST /credentials — Store DNS credentials (encrypted) // POST /credentials — Store DNS credentials (encrypted)
// Accepts per-server format: { servers: { dns1: { username, password }, dns2: {...}, dns3: {...} } } // Accepts per-server format: { servers: { dns1: { username, password }, dns2: {...}, dns3: {...} } }
// Also accepts legacy format: { username, password, server } // Also accepts legacy format: { username, password, server }
router.post('/credentials', ctx.asyncHandler(async (req, res) => { router.post('/credentials', asyncHandler(async (req, res) => {
const { servers, username, password, server } = req.body; const { servers, username, password, server } = req.body;
const dangerousChars = [';', '&', '|', '`', '$', '\n', '\r']; const dangerousChars = [';', '&', '|', '`', '$', '\n', '\r'];
const dnsPort = ctx.siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
// Per-server format: { servers: { dns1: { readonly: { username, password }, admin: { username, password } }, ... } } // Per-server format: { servers: { dns1: { readonly: { username, password }, admin: { username, password } }, ... } }
if (servers && typeof servers === 'object') { if (servers && typeof servers === 'object') {
@@ -338,7 +355,7 @@ module.exports = function(ctx) {
for (const [dnsId, creds] of Object.entries(servers)) { for (const [dnsId, creds] of Object.entries(servers)) {
// Look up server IP from config // Look up server IP from config
const serverInfo = ctx.siteConfig.dnsServers?.[dnsId]; const serverInfo = siteConfig.dnsServers?.[dnsId];
const serverIp = serverInfo?.ip; const serverIp = serverInfo?.ip;
if (!serverIp) { if (!serverIp) {
results[dnsId] = { success: false, error: `No IP configured for ${dnsId}` }; results[dnsId] = { success: false, error: `No IP configured for ${dnsId}` };
@@ -363,13 +380,13 @@ module.exports = function(ctx) {
// Test credentials by logging in to the target server // Test credentials by logging in to the target server
try { try {
const testResult = await ctx.dns.refresh(typeCreds.username, typeCreds.password, serverIp); const testResult = await dns.refresh(typeCreds.username, typeCreds.password, serverIp);
if (testResult.success) { if (testResult.success) {
await ctx.credentialManager.store(`dns.${dnsId}.${credType}.username`, typeCreds.username, { type: 'dns', role: credType, server: serverIp }); await credentialManager.store(`dns.${dnsId}.${credType}.username`, typeCreds.username, { type: 'dns', role: credType, server: serverIp });
await ctx.credentialManager.store(`dns.${dnsId}.${credType}.password`, typeCreds.password, { type: 'dns', role: credType, server: serverIp }); await credentialManager.store(`dns.${dnsId}.${credType}.password`, typeCreds.password, { type: 'dns', role: credType, server: serverIp });
savedTypes.push(credType); savedTypes.push(credType);
anySuccess = true; anySuccess = true;
ctx.log.info('dns', `${credType} credentials saved for ${dnsId}`, { server: serverIp }); log.info('dns', `${credType} credentials saved for ${dnsId}`, { server: serverIp });
} else { } else {
if (!results[dnsId]) { if (!results[dnsId]) {
results[dnsId] = { success: false, error: `${credType}: ${testResult.error || 'Login failed'}` }; results[dnsId] = { success: false, error: `${credType}: ${testResult.error || 'Login failed'}` };
@@ -400,132 +417,130 @@ module.exports = function(ctx) {
// Legacy single-credential format: { username, password, server } // Legacy single-credential format: { username, password, server }
if (!username || !password) { if (!username || !password) {
return ctx.errorResponse(res, 400, 'username and password are required'); return errorResponse(res, 'username and password are required', 400);
} }
if (username.length > 100 || password.length > 512) { if (username.length > 100 || password.length > 512) {
return ctx.errorResponse(res, 400, 'Credentials exceed maximum length'); return errorResponse(res, 'Credentials exceed maximum length', 400);
} }
if (dangerousChars.some(char => username.includes(char))) { if (dangerousChars.some(char => username.includes(char))) {
return ctx.errorResponse(res, 400, 'Username contains invalid characters'); return errorResponse(res, 'Username contains invalid characters', 400);
} }
if (server && !validateDnsServer(server)) { if (server && !validateDnsServer(server)) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
const testResult = await ctx.dns.refresh(username, password, server || ctx.siteConfig.dnsServerIp); const testResult = await dns.refresh(username, password, server || siteConfig.dnsServerIp);
if (!testResult.success) { if (!testResult.success) {
return ctx.errorResponse(res, 401, `Invalid credentials: ${testResult.error}`); return errorResponse(res, `Invalid credentials: ${testResult.error}`, 401);
} }
const dnsServer = server || ctx.siteConfig.dnsServerIp; const dnsServer = server || siteConfig.dnsServerIp;
await ctx.credentialManager.store('dns.username', username, { type: 'dns', server: dnsServer }); await credentialManager.store('dns.username', username, { type: 'dns', server: dnsServer });
await ctx.credentialManager.store('dns.password', password, { type: 'dns', server: dnsServer }); await credentialManager.store('dns.password', password, { type: 'dns', server: dnsServer });
await ctx.credentialManager.store('dns.server', dnsServer, { type: 'dns' }); await credentialManager.store('dns.server', dnsServer, { type: 'dns' });
ctx.log.info('dns', 'DNS credentials saved to credential manager (encrypted)'); log.info('dns', 'DNS credentials saved to credential manager (encrypted)');
res.json({ success(res, {
success: true,
message: 'DNS credentials saved and verified (encrypted)', message: 'DNS credentials saved and verified (encrypted)',
tokenExpiry: ctx.dns.getTokenExpiry() tokenExpiry: dns.getTokenExpiry()
}); });
}, 'dns-credentials')); }, 'dns-credentials'));
// DELETE /credentials — Delete stored DNS credentials // DELETE /credentials — Delete stored DNS credentials
router.delete('/credentials', ctx.asyncHandler(async (req, res) => { router.delete('/credentials', asyncHandler(async (req, res) => {
// Delete global credentials // Delete global credentials
await ctx.credentialManager.delete('dns.username'); await credentialManager.delete('dns.username');
await ctx.credentialManager.delete('dns.password'); await credentialManager.delete('dns.password');
await ctx.credentialManager.delete('dns.server'); await credentialManager.delete('dns.server');
// Delete per-server credentials (both old flat and new typed format) // Delete per-server credentials (both old flat and new typed format)
for (const dnsId of Object.keys(ctx.siteConfig.dnsServers || {})) { for (const dnsId of Object.keys(siteConfig.dnsServers || {})) {
await ctx.credentialManager.delete(`dns.${dnsId}.username`); await credentialManager.delete(`dns.${dnsId}.username`);
await ctx.credentialManager.delete(`dns.${dnsId}.password`); await credentialManager.delete(`dns.${dnsId}.password`);
for (const role of ['readonly', 'admin']) { for (const role of ['readonly', 'admin']) {
await ctx.credentialManager.delete(`dns.${dnsId}.${role}.username`); await credentialManager.delete(`dns.${dnsId}.${role}.username`);
await ctx.credentialManager.delete(`dns.${dnsId}.${role}.password`); await credentialManager.delete(`dns.${dnsId}.${role}.password`);
} }
} }
if (await exists(ctx.dns.credentialsFile)) { if (await exists(dns.credentialsFile)) {
await fsp.unlink(ctx.dns.credentialsFile); await fsp.unlink(dns.credentialsFile);
} }
ctx.dns.setToken(''); dns.setToken('');
ctx.dns.setTokenExpiry(null); dns.setTokenExpiry(null);
ctx.log.info('dns', 'DNS credentials deleted from credential manager'); log.info('dns', 'DNS credentials deleted from credential manager');
res.json({ success: true, message: 'DNS credentials removed' }); success(res, { message: 'DNS credentials removed' });
}, 'dns-credentials-delete')); }, 'dns-credentials-delete'));
// POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth) // POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth)
router.post('/restart/:dnsId', ctx.asyncHandler(async (req, res) => { router.post('/restart/:dnsId', asyncHandler(async (req, res) => {
const { dnsId } = req.params; const { dnsId } = req.params;
const serverInfo = ctx.siteConfig.dnsServers?.[dnsId]; const serverInfo = siteConfig.dnsServers?.[dnsId];
if (!serverInfo?.ip) { if (!serverInfo?.ip) {
return ctx.errorResponse(res, 400, `Unknown DNS server: ${dnsId}`); return errorResponse(res, `Unknown DNS server: ${dnsId}`, 400);
} }
const tokenResult = await ctx.dns.getTokenForServer(serverInfo.ip, 'admin'); const tokenResult = await dns.getTokenForServer(serverInfo.ip, 'admin');
if (!tokenResult.success) { if (!tokenResult.success) {
return ctx.errorResponse(res, 401, 'DNS admin authentication failed. Ensure admin credentials are configured.'); return errorResponse(res, 'DNS admin authentication failed. Ensure admin credentials are configured.', 401);
} }
const dnsPort = ctx.siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
try { try {
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`; const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
const response = await ctx.fetchT(url, { method: 'POST', timeout: 5000 }); const response = await fetchT(url, { method: 'POST', timeout: 5000 });
const result = await response.json(); const result = await response.json();
if (result.status === 'ok') { if (result.status === 'ok') {
res.json({ success: true, message: 'Restart initiated' }); success(res, { message: 'Restart initiated' });
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'Restart failed'); errorResponse(res, result.errorMessage || 'Restart failed', 500);
} }
} catch (err) { } catch (err) {
// Connection drop is expected during restart // Connection drop is expected during restart
res.json({ success: true, message: 'Restart initiated (connection closed)' }); success(res, { message: 'Restart initiated (connection closed)' });
} }
}, 'dns-restart')); }, 'dns-restart'));
// POST /refresh-token — Force refresh DNS token // POST /refresh-token — Force refresh DNS token
router.post('/refresh-token', ctx.asyncHandler(async (req, res) => { router.post('/refresh-token', asyncHandler(async (req, res) => {
const result = await ctx.dns.ensureToken(); const result = await dns.ensureToken();
if (result.success) { if (result.success) {
res.json({ success(res, {
success: true,
message: 'Token refreshed successfully', message: 'Token refreshed successfully',
tokenExpiry: ctx.dns.getTokenExpiry() tokenExpiry: dns.getTokenExpiry()
}); });
} else { } else {
ctx.errorResponse(res, 401, result.error); errorResponse(res, result.error, 401);
} }
}, 'dns-refresh-token')); }, 'dns-refresh-token'));
// GET /check-update — Check for Technitium DNS server updates // GET /check-update — Check for Technitium DNS server updates
router.get('/check-update', ctx.asyncHandler(async (req, res) => { router.get('/check-update', asyncHandler(async (req, res) => {
try { try {
const { server } = req.query; const { server } = req.query;
if (!server) { if (!server) {
return ctx.errorResponse(res, 400, 'Server IP required'); return errorResponse(res, 'Server IP required', 400);
} }
const serverIp = validateDnsServer(server); const serverIp = validateDnsServer(server);
if (!serverIp) { if (!serverIp) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
// Authenticate with admin credentials for update check // Authenticate with admin credentials for update check
const tokenResult = await ctx.dns.getTokenForServer(serverIp, 'admin'); const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
if (!tokenResult.success) { if (!tokenResult.success) {
return ctx.errorResponse(res, 401, 'DNS authentication failed. Ensure credentials are configured.'); return errorResponse(res, 'DNS authentication failed. Ensure credentials are configured.', 401);
} }
const dnsPort = ctx.siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
const url = `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`; const url = `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`;
ctx.log.info('dns', 'Checking DNS update', { server }); log.info('dns', 'Checking DNS update', { server });
const response = await ctx.fetchT(url, { const response = await fetchT(url, {
method: 'GET', method: 'GET',
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
@@ -536,14 +551,13 @@ module.exports = function(ctx) {
const text = await response.text(); const text = await response.text();
if (!text || text.trim() === '') { if (!text || text.trim() === '') {
return ctx.errorResponse(res, 500, 'Empty response from DNS server'); return errorResponse(res, 'Empty response from DNS server', 500);
} }
const result = JSON.parse(text); const result = JSON.parse(text);
if (result.status === 'ok') { if (result.status === 'ok') {
res.json({ success(res, {
success: true,
updateAvailable: result.response.updateAvailable, updateAvailable: result.response.updateAvailable,
currentVersion: result.response.currentVersion, currentVersion: result.response.currentVersion,
updateVersion: result.response.updateVersion || null, updateVersion: result.response.updateVersion || null,
@@ -553,55 +567,54 @@ module.exports = function(ctx) {
instructionsLink: result.response.instructionsLink || null instructionsLink: result.response.instructionsLink || null
}); });
} else { } else {
ctx.errorResponse(res, 500, result.errorMessage || 'Check failed'); errorResponse(res, result.errorMessage || 'Check failed', 500);
} }
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS update check error', { error: error.message }); log.error('dns', 'DNS update check error', { error: error.message });
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, safeErrorMessage(error), 500);
} }
}, 'dns-check-update')); }, 'dns-check-update'));
// POST /update — Update Technitium DNS server // POST /update — Update Technitium DNS server
// Note: Technitium v14+ has no installUpdate API. This endpoint checks for updates // Note: Technitium v14+ has no installUpdate API. This endpoint checks for updates
// and returns download info. The frontend handles showing update instructions. // and returns download info. The frontend handles showing update instructions.
router.post('/update', ctx.asyncHandler(async (req, res) => { router.post('/update', asyncHandler(async (req, res) => {
try { try {
const { server } = req.query; const { server } = req.query;
if (!server) { if (!server) {
return ctx.errorResponse(res, 400, 'Server IP required'); return errorResponse(res, 'Server IP required', 400);
} }
const serverIp = validateDnsServer(server); const serverIp = validateDnsServer(server);
if (!serverIp) { if (!serverIp) {
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server'); return errorResponse(res, 'Server must be a configured DNS server', 400);
} }
// Authenticate with admin credentials for update operations // Authenticate with admin credentials for update operations
const tokenResult = await ctx.dns.getTokenForServer(serverIp, 'admin'); const tokenResult = await dns.getTokenForServer(serverIp, 'admin');
if (!tokenResult.success) { if (!tokenResult.success) {
return ctx.errorResponse(res, 401, 'DNS authentication failed. Ensure credentials are configured.'); return errorResponse(res, 'DNS authentication failed. Ensure credentials are configured.', 401);
} }
const dnsPort = ctx.siteConfig.dnsServerPort || '5380'; const dnsPort = siteConfig.dnsServerPort || '5380';
// Check if update is available // Check if update is available
const checkResponse = await ctx.fetchT( const checkResponse = await fetchT(
`http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`, `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`,
{ method: 'GET', headers: { 'Accept': 'application/json' } } { method: 'GET', headers: { 'Accept': 'application/json' } }
); );
const checkText = await checkResponse.text(); const checkText = await checkResponse.text();
if (!checkText || checkText.trim() === '') { if (!checkText || checkText.trim() === '') {
return ctx.errorResponse(res, 500, 'Empty response from DNS server during check'); return errorResponse(res, 'Empty response from DNS server during check', 500);
} }
const checkResult = JSON.parse(checkText); const checkResult = JSON.parse(checkText);
if (checkResult.status !== 'ok') { if (checkResult.status !== 'ok') {
return ctx.errorResponse(res, 500, checkResult.errorMessage || 'Update check failed'); return errorResponse(res, checkResult.errorMessage || 'Update check failed', 500);
} }
if (!checkResult.response.updateAvailable) { if (!checkResult.response.updateAvailable) {
return res.json({ return success(res, {
success: true,
message: 'Already up to date', message: 'Already up to date',
currentVersion: checkResult.response.currentVersion, currentVersion: checkResult.response.currentVersion,
updated: false updated: false
@@ -610,10 +623,9 @@ module.exports = function(ctx) {
// Technitium v14+ does not have an installUpdate API endpoint. // Technitium v14+ does not have an installUpdate API endpoint.
// Return the update info with download link so the frontend can guide the user. // Return the update info with download link so the frontend can guide the user.
ctx.log.info('dns', 'Update available for DNS server', { server, currentVersion: checkResult.response.currentVersion, updateVersion: checkResult.response.updateVersion }); log.info('dns', 'Update available for DNS server', { server, currentVersion: checkResult.response.currentVersion, updateVersion: checkResult.response.updateVersion });
res.json({ success(res, {
success: true,
message: `Update available: ${checkResult.response.updateVersion}`, message: `Update available: ${checkResult.response.updateVersion}`,
previousVersion: checkResult.response.currentVersion, previousVersion: checkResult.response.currentVersion,
newVersion: checkResult.response.updateVersion, newVersion: checkResult.response.updateVersion,
@@ -623,8 +635,8 @@ module.exports = function(ctx) {
manualUpdateRequired: true manualUpdateRequired: true
}); });
} catch (error) { } catch (error) {
ctx.log.error('dns', 'DNS update error', { error: error.message }); log.error('dns', 'DNS update error', { error: error.message });
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error)); errorResponse(res, safeErrorMessage(error), 500);
} }
}, 'dns-update')); }, 'dns-update'));

View File

@@ -1185,7 +1185,15 @@ Object.assign(ctx, {
const apiRouter = express.Router(); const apiRouter = express.Router();
apiRouter.use(authRoutes(ctx)); apiRouter.use(authRoutes(ctx));
apiRouter.use(configRoutes(ctx)); apiRouter.use(configRoutes(ctx));
apiRouter.use('/dns', dnsRoutes(ctx)); apiRouter.use('/dns', dnsRoutes({
dns: ctx.dns,
siteConfig: ctx.siteConfig,
asyncHandler: ctx.asyncHandler,
log: ctx.log,
safeErrorMessage: ctx.safeErrorMessage,
fetchT: ctx.fetchT,
credentialManager: ctx.credentialManager
}));
apiRouter.use('/notifications', notificationRoutes(ctx)); apiRouter.use('/notifications', notificationRoutes(ctx));
apiRouter.use('/containers', containerRoutes({ apiRouter.use('/containers', containerRoutes({
docker: ctx.docker, docker: ctx.docker,