/** * Technitium DNS Server Provider Adapter * * Wraps Technitium-specific DNS logic into the standard adapter interface. * Uses the Technitium HTTP API (default port 5380) for all operations. */ const BaseDNSProvider = require('./base'); const SESSION_TTL_MS = 24 * 60 * 60 * 1000; // 24-hour token lifetime class TechnitiumDNSProvider extends BaseDNSProvider { constructor(config, ctx) { super(config, ctx); this.providerId = 'technitium'; this.displayName = 'Technitium DNS Server'; this.serverIp = config.serverIp; this.serverPort = config.serverPort || 5380; this.dnsId = config.dnsId || null; // Token state this.token = null; this.tokenExpiry = null; } // --------------------------------------------------------------------------- // Capabilities // --------------------------------------------------------------------------- static CAPABILITIES = [ 'create-record', 'delete-record', 'resolve', 'list-records', 'logs', 'restart', 'update-check', 'credentials', 'zones' ]; supportsCapability(cap) { return TechnitiumDNSProvider.CAPABILITIES.includes(cap); } getCapabilities() { return [...TechnitiumDNSProvider.CAPABILITIES]; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** Build the base URL for this server */ _baseUrl() { return `http://${this.serverIp}:${this.serverPort}`; } /** Build a full API URL with query-string params */ _buildUrl(apiPath, params = {}) { const qs = new URLSearchParams(params).toString(); return `${this._baseUrl()}${apiPath}${qs ? '?' + qs : ''}`; } /** Ensure we have a valid token; throws on failure */ async _requireToken() { // Re-use existing token if still valid if (this.token && this.tokenExpiry && new Date() < new Date(this.tokenExpiry)) { return this.token; } const result = await this.authenticate(); if (!result.success) { const err = new Error('No valid DNS token available. ' + (result.error || '')); err.statusCode = 401; throw err; } return this.token; } // --------------------------------------------------------------------------- // Authentication // --------------------------------------------------------------------------- /** * Authenticate against the Technitium server. * Checks per-server credentials first (dns.{dnsId}.readonly.username), * then falls back to global credentials (dns.username). * * Stores token + expiry on success. */ async authenticate() { const { credentialManager, log } = this.ctx; // Try per-server credentials first if (this.dnsId) { for (const role of ['readonly', 'admin']) { try { const username = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.username`); const password = await credentialManager.retrieve(`dns.${this.dnsId}.${role}.password`); if (username && password) { const result = await this._doLogin(username, password); if (result.success) return result; } } catch (err) { log.error('technitium', `Per-server ${role} credential error`, { dnsId: this.dnsId, error: err.message }); } } } // Fall back to global credentials try { const username = await credentialManager.retrieve('dns.username'); const password = await credentialManager.retrieve('dns.password'); if (username && password) { return await this._doLogin(username, password); } } catch (err) { log.error('technitium', err, null, { note: 'Global credential error' }); } return { success: false, error: 'No DNS credentials configured. Please set up credentials via /api/dns/credentials' }; } /** * Perform the actual login POST to Technitium. * Stores token on success. */ async _doLogin(username, password) { const { fetchT, log } = this.ctx; try { const params = new URLSearchParams({ user: username, pass: password, includeInfo: 'false' }); const url = `${this._baseUrl()}/api/user/login?${params.toString()}`; const response = await fetchT(url, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' } }); const result = await response.json(); if (result.status === 'ok' && result.token) { this.token = result.token; this.tokenExpiry = new Date(Date.now() + SESSION_TTL_MS).toISOString(); log.info('technitium', 'DNS token obtained', { server: this.serverIp, expires: this.tokenExpiry }); return { success: true, token: this.token }; } return { success: false, error: result.errorMessage || 'Login failed' }; } catch (error) { log.error('technitium', error, null, { note: 'Login error' }); return { success: false, error: error.message }; } } // --------------------------------------------------------------------------- // Record Management // --------------------------------------------------------------------------- /** * Create (or overwrite) a DNS record. * GET /api/zones/records/add?token=...&domain=...&zone=...&type=...&ipAddress=...&ttl=...&overwrite=... */ async createRecord({ domain, zone, type, value, ttl, overwrite }) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; const params = { token, domain, zone, type: type || 'A', ipAddress: value, ttl: String(ttl || 300), overwrite: String(overwrite !== false) }; try { log.info('technitium', 'Creating DNS record', { domain, type, value }); const url = this._buildUrl('/api/zones/records/add', params); const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (result.status === 'ok') { log.info('technitium', 'DNS record created', { domain, type, value }); return { success: true }; } // If token expired, re-authenticate and retry once if (result.errorMessage && result.errorMessage.toLowerCase().includes('token')) { log.info('technitium', 'Token expired, re-authenticating'); this.token = null; this.tokenExpiry = null; const retryToken = await this._requireToken(); params.token = retryToken; const retryUrl = this._buildUrl('/api/zones/records/add', params); const retryResp = await fetchT(retryUrl, { method: 'GET', headers: { 'Accept': 'application/json' } }); const retryResult = await retryResp.json(); if (retryResult.status === 'ok') { return { success: true }; } throw new Error(retryResult.errorMessage || 'Failed after token refresh'); } throw new Error(result.errorMessage || 'Unknown error'); } catch (error) { throw new Error(`Failed to create DNS record for ${domain}: ${error.message}`); } } /** * Delete a DNS record. * GET /api/zones/records/delete?token=...&domain=...&type=... (+ ipAddress if value provided) */ async deleteRecord({ domain, type, value }) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; const params = { token, domain, type: type || 'A' }; if (value) { params.ipAddress = value; } try { log.info('technitium', 'Deleting DNS record', { domain, type, value }); const url = this._buildUrl('/api/zones/records/delete', params); const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (result.status === 'ok') { log.info('technitium', 'DNS record deleted', { domain, type, value }); return { success: true }; } throw new Error(result.errorMessage || 'Unknown error'); } catch (error) { throw new Error(`Failed to delete DNS record for ${domain}: ${error.message}`); } } /** * Resolve/query records for a domain in a zone. * GET /api/zones/records/get?token=...&domain=...&zone=...&listZone=true * Filters returned records by type if provided. */ async resolveRecords({ domain, zone, type }) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; const params = { token, domain, zone, listZone: 'true' }; try { log.info('technitium', 'Resolving records', { domain, zone, type }); const url = this._buildUrl('/api/zones/records/get', params); const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (result.status !== 'ok') { throw new Error(result.errorMessage || 'Failed to resolve records'); } let records = (result.response && result.response.records) || []; // Filter by type if specified if (type) { records = records.filter(r => r.type === type); } return { success: true, records }; } catch (error) { throw new Error(`Failed to resolve records for ${domain}: ${error.message}`); } } /** * List all records in a zone. * Delegates to resolveRecords with a wildcard domain. */ async listRecords({ zone }) { return this.resolveRecords({ domain: zone, zone, type: null }); } // --------------------------------------------------------------------------- // Logs // --------------------------------------------------------------------------- /** * Fetch and parse DNS query logs. * 1. GET /api/logs/list to discover the latest log file * 2. GET /api/logs/download?token=...&fileName=... to download it * 3. Parse text format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer] */ async getLogs({ limit, server } = {}) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; const targetIp = server || this.serverIp; const targetPort = this.serverPort; const baseUrl = `http://${targetIp}:${targetPort}`; try { // Step 1: Get log file list const listUrl = this._buildUrl('/api/logs/list', { token }); const listResp = await fetchT(listUrl.replace(this._baseUrl(), baseUrl), { method: 'GET', headers: { 'Accept': 'application/json' } }); const listResult = await listResp.json(); if (listResult.status !== 'ok' || !listResult.response || !listResult.response.length) { throw new Error(listResult.errorMessage || 'No log files found'); } // Pick the latest log file (last entry) const logFile = listResult.response[listResult.response.length - 1]; const fileName = logFile.name || logFile.fileName || logFile; // Step 2: Download the log file const downloadUrl = `${baseUrl}/api/logs/download?${new URLSearchParams({ token, fileName }).toString()}`; const downloadResp = await fetchT(downloadUrl, { method: 'GET' }); const logText = await downloadResp.text(); // Step 3: Parse lines const parsed = this._parseLogText(logText, limit); return { success: true, logs: parsed }; } catch (error) { log.error('technitium', error, null, { note: 'Failed to fetch DNS logs' }); throw new Error(`Failed to get DNS logs: ${error.message}`); } } /** * Parse Technitium DNS log text format. * Line format: [timestamp] [client:port] [protocol] QNAME: domain; QTYPE: type; QCLASS: class; RCODE: rcode; ANSWER: [answer] */ _parseLogText(text, limit) { const lines = text.split('\n').filter(l => l.trim()); const parsed = []; // Process newest first if we need to limit const iterable = limit ? lines.slice(-limit).reverse() : lines; for (const line of iterable) { try { const entry = {}; // Extract timestamp: [2024-01-15 10:30:45] const tsMatch = line.match(/\[([^\]]+)\]/); if (tsMatch) entry.timestamp = tsMatch[1]; // Extract client:port: [192.168.1.100:12345] const clientMatch = line.match(/\[([^\]]+:\d+)\]/g); if (clientMatch && clientMatch.length >= 2) { entry.client = clientMatch[1].replace(/\[|\]/g, ''); } // Extract protocol: [UDP] or [TCP] const protoMatch = line.match(/\]\s*\[(UDP|TCP|DoH|DoT|DoH2)\]/i); if (protoMatch) entry.protocol = protoMatch[1]; // Extract key-value pairs: QNAME: value; QTYPE: value; etc. const kvPattern = /(\w+):\s*([^;]+)/g; let match; while ((match = kvPattern.exec(line)) !== null) { const key = match[1]; const val = match[2].trim(); if (['QNAME', 'QTYPE', 'QCLASS', 'RCODE'].includes(key)) { entry[key.toLowerCase()] = val; } else if (key === 'ANSWER') { entry.answer = val; } } entry.raw = line; parsed.push(entry); } catch { // Skip unparseable lines } } return parsed; } // --------------------------------------------------------------------------- // Server Management // --------------------------------------------------------------------------- /** * Restart the DNS server. * POST /api/admin/restart?token=... * Requires admin credentials. */ async restartServer({ server } = {}) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; try { log.info('technitium', 'Restarting DNS server', { server: this.serverIp }); const url = this._buildUrl('/api/admin/restart', { token }); const response = await fetchT(url, { method: 'POST', headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (result.status === 'ok') { log.info('technitium', 'DNS server restart initiated'); return { success: true, message: 'Server restart initiated' }; } throw new Error(result.errorMessage || 'Restart failed'); } catch (error) { log.error('technitium', error, null, { note: 'DNS restart error' }); throw new Error(`Failed to restart DNS server: ${error.message}`); } } /** * Check for DNS server updates. * GET /api/user/checkForUpdate?token=... */ async checkUpdate({ server } = {}) { const token = await this._requireToken(); const { fetchT, log } = this.ctx; try { log.info('technitium', 'Checking for DNS server update', { server: this.serverIp }); const url = this._buildUrl('/api/user/checkForUpdate', { token }); const response = await fetchT(url, { method: 'GET', headers: { 'Accept': 'application/json' } }); const result = await response.json(); if (result.status === 'ok') { return { success: true, updateAvailable: !!(result.response && result.response.updateAvailable), latestVersion: (result.response && result.response.latestVersion) || null, currentVersion: (result.response && result.response.currentVersion) || null, response: result.response }; } throw new Error(result.errorMessage || 'Update check failed'); } catch (error) { log.error('technitium', error, null, { note: 'Update check error' }); throw new Error(`Failed to check for updates: ${error.message}`); } } // --------------------------------------------------------------------------- // Config Validation // --------------------------------------------------------------------------- validateConfig() { const errors = []; if (!this.serverIp) { errors.push('serverIp is required'); } if (this.serverPort && (typeof this.serverPort !== 'number' || this.serverPort < 1 || this.serverPort > 65535)) { errors.push('serverPort must be a valid port number (1-65535)'); } return { valid: errors.length === 0, errors }; } } module.exports = TechnitiumDNSProvider;