HIGH fixes: - TOTP disable now requires valid code verification - TOTP secret removed from plaintext file storage - Container ID validated before update/check-update/logs operations - DNS server parameter restricted to configured servers (SSRF prevention) - Backup export no longer includes encryption key - Backup restore of sensitive files requires TOTP re-authentication MEDIUM fixes: - Session cookie Secure flag added - Caddy reload errors no longer leaked to client - saveConfig uses atomic locked updates via configStateManager - Log file path traversal prevented via symlink resolution - Credential cache entries now expire after 5 minutes - _httpFetch enforces 10MB response size limit - External URL path injection into Caddyfile blocked - Custom volume host paths validated against allowed roots - Error logs endpoint no longer returns stack traces - Logo delete path traversal prevented via path.basename() Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
633 lines
25 KiB
JavaScript
633 lines
25 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('../constants');
|
|
const { exists } = require('../fs-helpers');
|
|
|
|
module.exports = function(ctx) {
|
|
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(ctx.siteConfig.dnsServers || {}).map(s => s.ip).filter(Boolean);
|
|
// Also allow the default dnsServerIp
|
|
if (ctx.siteConfig.dnsServerIp) configuredIps.push(ctx.siteConfig.dnsServerIp);
|
|
if (!configuredIps.includes(serverIp)) return null;
|
|
return serverIp;
|
|
}
|
|
|
|
// DELETE /record — Delete a DNS record from Technitium
|
|
router.delete('/record', ctx.asyncHandler(async (req, res) => {
|
|
const { domain, type, token, server, ipAddress } = req.query;
|
|
|
|
const dnsToken = await ctx.dns.requireToken(token);
|
|
|
|
if (!domain) {
|
|
return ctx.errorResponse(res, 400, 'domain is required');
|
|
}
|
|
|
|
// Validate domain format
|
|
if (!REGEX.DOMAIN.test(domain)) {
|
|
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format');
|
|
}
|
|
|
|
// Validate record type
|
|
if (type && !DNS_RECORD_TYPES.includes(type.toUpperCase())) {
|
|
return ctx.errorResponse(res, 400, 'Invalid DNS record type');
|
|
}
|
|
|
|
// Validate ipAddress if provided
|
|
if (ipAddress && !validatorLib.isIP(ipAddress)) {
|
|
return ctx.errorResponse(res, 400, '[DC-210] Invalid IP address');
|
|
}
|
|
|
|
// Validate server against configured DNS servers
|
|
if (server && !validateDnsServer(server)) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
// Default to dns1 LAN IP, allow override
|
|
const dnsServer = server || ctx.siteConfig.dnsServerIp;
|
|
const recordType = type || 'A';
|
|
|
|
try {
|
|
const p = { token: dnsToken, domain: domain, type: recordType };
|
|
if (ipAddress) p.ipAddress = ipAddress;
|
|
const result = await ctx.dns.call(dnsServer, '/api/zones/records/delete', p);
|
|
|
|
if (result.status === 'ok') {
|
|
res.json({ success: true, message: `DNS record ${domain} deleted` });
|
|
} else {
|
|
ctx.errorResponse(res, 500, result.errorMessage || 'DNS deletion failed');
|
|
}
|
|
} catch (error) {
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'dns-delete-record'));
|
|
|
|
// POST /record — Create a DNS record in Technitium
|
|
router.post('/record', ctx.asyncHandler(async (req, res) => {
|
|
const { domain, ip, ttl, token, server } = req.body;
|
|
|
|
const dnsToken = await ctx.dns.requireToken(token);
|
|
|
|
if (!domain || !ip) {
|
|
return ctx.errorResponse(res, 400, 'domain and ip are required');
|
|
}
|
|
|
|
// Validate domain format
|
|
if (!REGEX.DOMAIN.test(domain)) {
|
|
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format');
|
|
}
|
|
|
|
// Validate IP address
|
|
if (!validatorLib.isIP(ip)) {
|
|
return ctx.errorResponse(res, 400, '[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 ctx.errorResponse(res, 400, `TTL must be between ${CADDY.TTL_MIN} and ${CADDY.TTL_MAX}`);
|
|
}
|
|
}
|
|
|
|
// Validate server against configured DNS servers
|
|
if (server && !validateDnsServer(server)) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
// Default to dns1 LAN IP since Docker container can't access Tailscale network
|
|
const dnsServer = server || ctx.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('.') || ctx.siteConfig.tld.replace(/^\./, '');
|
|
|
|
const result = await ctx.dns.call(dnsServer, '/api/zones/records/add', {
|
|
token: dnsToken, domain, zone, type: 'A', ipAddress: ip, ttl: recordTtl.toString(), overwrite: 'true'
|
|
});
|
|
|
|
if (result.status === 'ok') {
|
|
res.json({ success: true, message: `DNS record ${domain} -> ${ip} created` });
|
|
} else {
|
|
ctx.errorResponse(res, 500, result.errorMessage || 'DNS creation failed');
|
|
}
|
|
} catch (error) {
|
|
ctx.log.error('dns', 'DNS record creation error', { error: error.message });
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error), { details: error.cause?.code || 'fetch failed' });
|
|
}
|
|
}, 'dns-create-record'));
|
|
|
|
// GET /resolve — Resolve a domain to IP address via Technitium
|
|
router.get('/resolve', ctx.asyncHandler(async (req, res) => {
|
|
const { domain, server, token } = req.query;
|
|
|
|
const dnsToken = await ctx.dns.requireToken(token);
|
|
|
|
if (!domain) {
|
|
return ctx.errorResponse(res, 400, 'domain is required');
|
|
}
|
|
|
|
// Validate domain format
|
|
if (!REGEX.DOMAIN.test(domain)) {
|
|
return ctx.errorResponse(res, 400, '[DC-301] Invalid domain format');
|
|
}
|
|
|
|
// Validate server against configured DNS servers
|
|
if (server && !validateDnsServer(server)) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
const dnsServer = server || ctx.siteConfig.dnsServerIp;
|
|
|
|
try {
|
|
const result = await ctx.dns.call(dnsServer, '/api/zones/records/get', {
|
|
token: dnsToken, domain, zone: ctx.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);
|
|
res.json({ success: true, answer: ipAddresses });
|
|
} else {
|
|
ctx.errorResponse(res, 404, 'No A records found for domain');
|
|
}
|
|
} else {
|
|
ctx.errorResponse(res, 500, result.errorMessage || 'DNS resolve failed');
|
|
}
|
|
} catch (error) {
|
|
ctx.log.error('dns', 'DNS resolve error', { error: error.message });
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'dns-resolve'));
|
|
|
|
// GET /logs — Fetch DNS query logs from Technitium
|
|
router.get('/logs', ctx.asyncHandler(async (req, res) => {
|
|
const { server, limit } = req.query;
|
|
|
|
if (!server) {
|
|
return ctx.errorResponse(res, 400, 'server is required');
|
|
}
|
|
|
|
// Validate server against configured DNS servers
|
|
const serverIp = validateDnsServer(server);
|
|
if (!serverIp) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
const logLimit = Math.min(parseInt(limit) || 25, 1000);
|
|
const dnsPort = ctx.siteConfig.dnsServerPort || '5380';
|
|
|
|
try {
|
|
// Auto-authenticate using stored read-only credentials for log access
|
|
const authResult = await ctx.dns.getTokenForServer(serverIp, 'readonly');
|
|
if (!authResult.success) {
|
|
return ctx.errorResponse(res, 401, '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 ctx.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}`;
|
|
ctx.log.info('dns', 'Fetching DNS logs', { server, logFileName });
|
|
|
|
const response = await ctx.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 res.json({
|
|
success: true,
|
|
server: server,
|
|
count: 0,
|
|
logs: [],
|
|
message: 'No logs available for this server'
|
|
});
|
|
}
|
|
return ctx.errorResponse(res, response.status, ctx.safeErrorMessage(errorJson.errorMessage || errorText));
|
|
} catch {
|
|
return ctx.errorResponse(res, response.status, 'DNS server returned an error');
|
|
}
|
|
}
|
|
|
|
// 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 res.json({
|
|
success: true,
|
|
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') {
|
|
ctx.dns.invalidateTokenForServer(serverIp);
|
|
}
|
|
return ctx.errorResponse(res, 400, ctx.safeErrorMessage(errorJson.errorMessage));
|
|
}
|
|
} 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
|
|
|
|
ctx.log.info('dns', 'Returning DNS log entries', { count: parsedLogs.length, logFileName });
|
|
res.json({
|
|
success: true,
|
|
server: server,
|
|
logFile: logFileName,
|
|
count: parsedLogs.length,
|
|
logs: parsedLogs
|
|
});
|
|
|
|
} catch (error) {
|
|
ctx.log.error('dns', 'DNS logs proxy error', { error: error.message });
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'dns-logs'));
|
|
|
|
// GET /token-status — Check DNS token/credentials status
|
|
router.get('/token-status', ctx.asyncHandler(async (req, res) => {
|
|
const username = await ctx.credentialManager.retrieve('dns.username');
|
|
const hasCredentials = !!username || await exists(ctx.dns.credentialsFile);
|
|
const hasToken = !!ctx.dns.getToken();
|
|
|
|
res.json({
|
|
success: true,
|
|
hasCredentials,
|
|
hasToken,
|
|
tokenExpiry: ctx.dns.getTokenExpiry(),
|
|
isExpired: ctx.dns.getTokenExpiry() ? new Date() > new Date(ctx.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', ctx.asyncHandler(async (req, res) => {
|
|
const { servers, username, password, server } = req.body;
|
|
const dangerousChars = [';', '&', '|', '`', '$', '\n', '\r'];
|
|
const dnsPort = ctx.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 = ctx.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 ctx.dns.refresh(typeCreds.username, typeCreds.password, serverIp);
|
|
if (testResult.success) {
|
|
await ctx.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 });
|
|
savedTypes.push(credType);
|
|
anySuccess = true;
|
|
ctx.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) {
|
|
return ctx.errorResponse(res, 400, 'username and password are required');
|
|
}
|
|
|
|
if (username.length > 100 || password.length > 512) {
|
|
return ctx.errorResponse(res, 400, 'Credentials exceed maximum length');
|
|
}
|
|
|
|
if (dangerousChars.some(char => username.includes(char))) {
|
|
return ctx.errorResponse(res, 400, 'Username contains invalid characters');
|
|
}
|
|
|
|
if (server && !validateDnsServer(server)) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
const testResult = await ctx.dns.refresh(username, password, server || ctx.siteConfig.dnsServerIp);
|
|
|
|
if (!testResult.success) {
|
|
return ctx.errorResponse(res, 401, `Invalid credentials: ${testResult.error}`);
|
|
}
|
|
|
|
const dnsServer = server || ctx.siteConfig.dnsServerIp;
|
|
await ctx.credentialManager.store('dns.username', username, { type: 'dns', server: dnsServer });
|
|
await ctx.credentialManager.store('dns.password', password, { type: 'dns', server: dnsServer });
|
|
await ctx.credentialManager.store('dns.server', dnsServer, { type: 'dns' });
|
|
ctx.log.info('dns', 'DNS credentials saved to credential manager (encrypted)');
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'DNS credentials saved and verified (encrypted)',
|
|
tokenExpiry: ctx.dns.getTokenExpiry()
|
|
});
|
|
}, 'dns-credentials'));
|
|
|
|
// DELETE /credentials — Delete stored DNS credentials
|
|
router.delete('/credentials', ctx.asyncHandler(async (req, res) => {
|
|
// Delete global credentials
|
|
await ctx.credentialManager.delete('dns.username');
|
|
await ctx.credentialManager.delete('dns.password');
|
|
await ctx.credentialManager.delete('dns.server');
|
|
// Delete per-server credentials (both old flat and new typed format)
|
|
for (const dnsId of Object.keys(ctx.siteConfig.dnsServers || {})) {
|
|
await ctx.credentialManager.delete(`dns.${dnsId}.username`);
|
|
await ctx.credentialManager.delete(`dns.${dnsId}.password`);
|
|
for (const role of ['readonly', 'admin']) {
|
|
await ctx.credentialManager.delete(`dns.${dnsId}.${role}.username`);
|
|
await ctx.credentialManager.delete(`dns.${dnsId}.${role}.password`);
|
|
}
|
|
}
|
|
if (await exists(ctx.dns.credentialsFile)) {
|
|
await fsp.unlink(ctx.dns.credentialsFile);
|
|
}
|
|
ctx.dns.setToken('');
|
|
ctx.dns.setTokenExpiry(null);
|
|
ctx.log.info('dns', 'DNS credentials deleted from credential manager');
|
|
res.json({ success: true, message: 'DNS credentials removed' });
|
|
}, 'dns-credentials-delete'));
|
|
|
|
// POST /restart/:dnsId — Restart a DNS server (proxied through backend for auth)
|
|
router.post('/restart/:dnsId', ctx.asyncHandler(async (req, res) => {
|
|
const { dnsId } = req.params;
|
|
const serverInfo = ctx.siteConfig.dnsServers?.[dnsId];
|
|
if (!serverInfo?.ip) {
|
|
return ctx.errorResponse(res, 400, `Unknown DNS server: ${dnsId}`);
|
|
}
|
|
|
|
const tokenResult = await ctx.dns.getTokenForServer(serverInfo.ip, 'admin');
|
|
if (!tokenResult.success) {
|
|
return ctx.errorResponse(res, 401, 'DNS admin authentication failed. Ensure admin credentials are configured.');
|
|
}
|
|
|
|
const dnsPort = ctx.siteConfig.dnsServerPort || '5380';
|
|
try {
|
|
const url = `http://${serverInfo.ip}:${dnsPort}/api/admin/restart?token=${encodeURIComponent(tokenResult.token)}`;
|
|
const response = await ctx.fetchT(url, { method: 'POST', timeout: 5000 });
|
|
const result = await response.json();
|
|
if (result.status === 'ok') {
|
|
res.json({ success: true, message: 'Restart initiated' });
|
|
} else {
|
|
ctx.errorResponse(res, 500, result.errorMessage || 'Restart failed');
|
|
}
|
|
} catch (err) {
|
|
// Connection drop is expected during restart
|
|
res.json({ success: true, message: 'Restart initiated (connection closed)' });
|
|
}
|
|
}, 'dns-restart'));
|
|
|
|
// POST /refresh-token — Force refresh DNS token
|
|
router.post('/refresh-token', ctx.asyncHandler(async (req, res) => {
|
|
const result = await ctx.dns.ensureToken();
|
|
|
|
if (result.success) {
|
|
res.json({
|
|
success: true,
|
|
message: 'Token refreshed successfully',
|
|
tokenExpiry: ctx.dns.getTokenExpiry()
|
|
});
|
|
} else {
|
|
ctx.errorResponse(res, 401, result.error);
|
|
}
|
|
}, 'dns-refresh-token'));
|
|
|
|
// GET /check-update — Check for Technitium DNS server updates
|
|
router.get('/check-update', ctx.asyncHandler(async (req, res) => {
|
|
try {
|
|
const { server } = req.query;
|
|
if (!server) {
|
|
return ctx.errorResponse(res, 400, 'Server IP required');
|
|
}
|
|
|
|
const serverIp = validateDnsServer(server);
|
|
if (!serverIp) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
// Authenticate with admin credentials for update check
|
|
const tokenResult = await ctx.dns.getTokenForServer(serverIp, 'admin');
|
|
if (!tokenResult.success) {
|
|
return ctx.errorResponse(res, 401, 'DNS authentication failed. Ensure credentials are configured.');
|
|
}
|
|
|
|
const dnsPort = ctx.siteConfig.dnsServerPort || '5380';
|
|
const url = `http://${serverIp}:${dnsPort}/api/user/checkForUpdate?token=${encodeURIComponent(tokenResult.token)}`;
|
|
ctx.log.info('dns', 'Checking DNS update', { server });
|
|
|
|
const response = await ctx.fetchT(url, {
|
|
method: 'GET',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'User-Agent': APP.USER_AGENTS.API
|
|
}
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
if (!text || text.trim() === '') {
|
|
return ctx.errorResponse(res, 500, 'Empty response from DNS server');
|
|
}
|
|
|
|
const result = JSON.parse(text);
|
|
|
|
if (result.status === 'ok') {
|
|
res.json({
|
|
success: true,
|
|
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 {
|
|
ctx.errorResponse(res, 500, result.errorMessage || 'Check failed');
|
|
}
|
|
} catch (error) {
|
|
ctx.log.error('dns', 'DNS update check error', { error: error.message });
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'dns-check-update'));
|
|
|
|
// POST /update — Update Technitium DNS server
|
|
// Note: Technitium v14+ has no installUpdate API. This endpoint checks for updates
|
|
// and returns download info. The frontend handles showing update instructions.
|
|
router.post('/update', ctx.asyncHandler(async (req, res) => {
|
|
try {
|
|
const { server } = req.query;
|
|
if (!server) {
|
|
return ctx.errorResponse(res, 400, 'Server IP required');
|
|
}
|
|
|
|
const serverIp = validateDnsServer(server);
|
|
if (!serverIp) {
|
|
return ctx.errorResponse(res, 400, 'Server must be a configured DNS server');
|
|
}
|
|
|
|
// Authenticate with admin credentials for update operations
|
|
const tokenResult = await ctx.dns.getTokenForServer(serverIp, 'admin');
|
|
if (!tokenResult.success) {
|
|
return ctx.errorResponse(res, 401, 'DNS authentication failed. Ensure credentials are configured.');
|
|
}
|
|
|
|
const dnsPort = ctx.siteConfig.dnsServerPort || '5380';
|
|
// Check if update is available
|
|
const checkResponse = await ctx.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 ctx.errorResponse(res, 500, 'Empty response from DNS server during check');
|
|
}
|
|
const checkResult = JSON.parse(checkText);
|
|
|
|
if (checkResult.status !== 'ok') {
|
|
return ctx.errorResponse(res, 500, checkResult.errorMessage || 'Update check failed');
|
|
}
|
|
|
|
if (!checkResult.response.updateAvailable) {
|
|
return res.json({
|
|
success: true,
|
|
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.
|
|
ctx.log.info('dns', 'Update available for DNS server', { server, currentVersion: checkResult.response.currentVersion, updateVersion: checkResult.response.updateVersion });
|
|
|
|
res.json({
|
|
success: true,
|
|
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) {
|
|
ctx.log.error('dns', 'DNS update error', { error: error.message });
|
|
ctx.errorResponse(res, 500, ctx.safeErrorMessage(error));
|
|
}
|
|
}, 'dns-update'));
|
|
|
|
return router;
|
|
};
|