/** * RFC 2136 Dynamic DNS Provider Adapter * * Manages DNS records via RFC 2136 dynamic updates using the nsupdate CLI tool. * Compatible with BIND, PowerDNS, Windows DNS, and any RFC 2136-compliant server. * * Capabilities: create-record, delete-record, resolve, credentials * Not supported: logs, restart, update-check, list-records, zones */ const { execFile } = require('child_process'); const { promisify } = require('util'); const dns = require('dns'); const os = require('os'); const path = require('path'); const fs = require('fs'); const execFileAsync = promisify(execFile); const BaseDNSProvider = require('./base'); const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials']; const DEFAULT_PORT = 53; const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256'; const NSUPDATE_TIMEOUT_MS = 15000; class RFC2136Provider extends BaseDNSProvider { static providerId = 'rfc2136'; static displayName = 'RFC 2136 (Dynamic DNS)'; constructor(config, ctx) { super(config, ctx); this.providerId = 'rfc2136'; this.displayName = 'RFC 2136 (Dynamic DNS)'; // Core config this.server = config.server || null; this.port = config.port || DEFAULT_PORT; this.zone = config.zone || null; // TSIG authentication this.tsigAlgorithm = config.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM; this.tsigKeyName = config.tsigKeyName || null; this.tsigSecret = config.tsigSecret || null; // Resolve credentials from credential manager if available if (ctx && ctx.credentialManager) { if (!this.tsigKeyName && ctx.credentialManager.get) { this.tsigKeyName = ctx.credentialManager.get('rfc2136_tsigKeyName') || null; } if (!this.tsigSecret && ctx.credentialManager.get) { this.tsigSecret = ctx.credentialManager.get('rfc2136_tsigSecret') || null; } } // Logger shorthand this._log = ctx && ctx.log ? ctx.ctx : null; } // ── Logging helper ──────────────────────────────────────────────────────── _log(level, message, meta) { if (this.ctx && this.ctx.log && typeof this.ctx.log[level] === 'function') { this.ctx.log[level](`[rfc2136] ${message}`, meta || {}); } } // ── Capabilities ────────────────────────────────────────────────────────── supportsCapability(cap) { return CAPABILITIES.includes(cap); } getCapabilities() { return [...CAPABILITIES]; } // ── Config validation ───────────────────────────────────────────────────── validateConfig() { const errors = []; if (!this.server) errors.push('Missing required config: server'); if (!this.zone) errors.push('Missing required config: zone'); return { valid: errors.length === 0, errors }; } // ── Helpers ─────────────────────────────────────────────────────────────── /** * Ensure a domain name ends with a trailing dot (FQDN for nsupdate). */ _ensureFqdn(domain) { if (!domain) return domain; return domain.endsWith('.') ? domain : `${domain}.`; } /** * Build the common nsupdate header lines (server, zone, key). */ _buildHeader() { const lines = []; lines.push(`server ${this.server} ${this.port}`); lines.push(`zone ${this.zone}`); if (this.tsigKeyName && this.tsigSecret) { lines.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`); } return lines; } /** * Execute an nsupdate script and return { stdout, stderr }. * Writes commands to a temporary file and runs `nsupdate `. */ async _runNsupdate(commands) { const script = commands.join('\n') + '\n'; const tmpFile = path.join(os.tmpdir(), `nsupdate-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.cmd`); try { await fs.promises.writeFile(tmpFile, script, { mode: 0o600 }); this._log('debug', `Executing nsupdate script`, { script: script.trim() }); const { stdout, stderr } = await execFileAsync('nsupdate', [tmpFile], { timeout: NSUPDATE_TIMEOUT_MS, maxBuffer: 1024 * 1024, }); this._log('debug', 'nsupdate completed', { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() }); if (stderr && stderr.toLowerCase().includes('refused')) { throw new Error(`nsupdate refused: ${stderr.trim()}`); } if (stderr && stderr.toLowerCase().includes('failed')) { throw new Error(`nsupdate failed: ${stderr.trim()}`); } return { stdout: (stdout || '').trim(), stderr: (stderr || '').trim() }; } catch (err) { if (err.code === 'ENOENT') { throw new Error('nsupdate command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).'); } throw err; } finally { try { await fs.promises.unlink(tmpFile); } catch (_) { /* ignore */ } } } // ── Authenticate ────────────────────────────────────────────────────────── /** * Verify nsupdate is available and optionally test connectivity. * Runs a minimal nsupdate with just "show" (no-op) to confirm the tool works. */ async authenticate() { const validation = this.validateConfig(); if (!validation.valid) { throw new Error(`RFC 2136 config invalid: ${validation.errors.join('; ')}`); } // Check nsupdate binary is available with a dry-run command set const commands = [ ...this._buildHeader(), 'show', ]; try { const { stdout } = await this._runNsupdate(commands); this._log('info', 'Authenticated to RFC 2136 server', { server: this.server, port: this.port }); return { success: true, server: this.server, port: this.port }; } catch (err) { this._log('error', 'Authentication test failed', { error: err.message }); // If nsupdate is missing, rethrow immediately if (err.message.includes('not found')) throw err; // Otherwise, the server might be unreachable but the tool works — return partial return { success: false, error: err.message, server: this.server }; } } // ── Create Record ───────────────────────────────────────────────────────── /** * Create (add) a DNS record via RFC 2136 UPDATE. * * @param {Object} params * @param {string} params.domain - Record name (e.g. "www.example.com") * @param {string} params.zone - Zone name (overrides constructor zone) * @param {string} params.type - Record type (A, AAAA, CNAME, TXT, etc.) * @param {string} params.value - Record value * @param {number} [params.ttl=300] - TTL in seconds */ async createRecord({ domain, zone, type, value, ttl }) { const effectiveZone = zone || this.zone; const effectiveTtl = ttl || 300; const fqdn = this._ensureFqdn(domain); const commands = [ `server ${this.server} ${this.port}`, `zone ${effectiveZone}`, ]; if (this.tsigKeyName && this.tsigSecret) { commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`); } commands.push(`update add ${fqdn} ${effectiveTtl} ${type} ${value}`); commands.push('show'); commands.push('send'); this._log('info', 'Creating DNS record', { domain: fqdn, type, value, ttl: effectiveTtl }); const result = await this._runNsupdate(commands); return { success: true, action: 'create-record', domain: fqdn, type, value, ttl: effectiveTtl, zone: effectiveZone, raw: result.stdout, }; } // ── Delete Record ───────────────────────────────────────────────────────── /** * Delete a DNS record via RFC 2136 UPDATE. * * @param {Object} params * @param {string} params.domain - Record name * @param {string} params.type - Record type * @param {string} [params.value] - Optional specific value to match */ async deleteRecord({ domain, type, value }) { const effectiveZone = this.zone; const fqdn = this._ensureFqdn(domain); const commands = [ `server ${this.server} ${this.port}`, `zone ${effectiveZone}`, ]; if (this.tsigKeyName && this.tsigSecret) { commands.push(`key ${this.tsigAlgorithm}:${this.tsigKeyName} ${this.tsigSecret}`); } // "update delete" with value removes that specific RR; // without value it removes all RRs of that type for the name. const deleteClause = value ? `update delete ${fqdn} ${type} ${value}` : `update delete ${fqdn} ${type}`; commands.push(deleteClause); commands.push('show'); commands.push('send'); this._log('info', 'Deleting DNS record', { domain: fqdn, type, value: value || '(all)' }); const result = await this._runNsupdate(commands); return { success: true, action: 'delete-record', domain: fqdn, type, value: value || null, zone: effectiveZone, raw: result.stdout, }; } // ── Resolve Records ─────────────────────────────────────────────────────── /** * Resolve DNS records for a domain. * First attempts dig against the configured server, then falls back to Node dns module. * * @param {Object} params * @param {string} params.domain - Domain to resolve * @param {string} [params.zone] - Zone (unused for resolution, kept for interface consistency) * @param {string} [params.type='A'] - Record type to query */ async resolveRecords({ domain, zone, type }) { const queryType = type || 'A'; const fqdn = domain.endsWith('.') ? domain : domain; // Strategy 1: Use dig against the configured RFC 2136 server try { const { stdout } = await execFileAsync('dig', [ `@${this.server}`, '-p', String(this.port), fqdn, queryType, '+short', '+time=5', '+tries=1', ], { timeout: 10000 }); const records = stdout .split('\n') .map(line => line.trim()) .filter(Boolean); if (records.length > 0) { this._log('debug', `Resolved ${fqdn} ${queryType} via dig`, { records }); return { domain: fqdn, type: queryType, records: records.map(r => ({ value: r, type: queryType })), source: 'dig', server: this.server, }; } } catch (err) { this._log('warn', 'dig resolution failed, falling back to Node dns', { error: err.message }); } // Strategy 2: Fallback to Node.js built-in resolver try { const resolver = new dns.Resolver(); resolver.setServers([this.server]); const resolveMethod = this._getResolveMethod(queryType); const resolveAsync = promisify(resolver[resolveMethod]).bind(resolver); const results = await resolveAsync(fqdn); const records = Array.isArray(results) ? results : [results]; this._log('debug', `Resolved ${fqdn} ${queryType} via Node dns`, { records }); return { domain: fqdn, type: queryType, records: records.map(r => ({ value: String(r), type: queryType })), source: 'node-dns', server: this.server, }; } catch (err) { this._log('warn', 'Node dns resolution also failed', { error: err.message }); return { domain: fqdn, type: queryType, records: [], source: 'none', server: this.server, error: err.message, }; } } /** * Map record type to the Node dns resolver method name. */ _getResolveMethod(type) { const map = { A: 'resolve4', AAAA: 'resolve6', CNAME: 'resolveCname', MX: 'resolveMx', TXT: 'resolveTxt', NS: 'resolveNs', SOA: 'resolveSoa', SRV: 'resolveSrv', PTR: 'reverse', }; return map[(type || '').toUpperCase()] || 'resolve4'; } // ── Shutdown ────────────────────────────────────────────────────────────── async shutdown() { this._log('info', 'RFC 2136 provider shutting down'); } } // Expose providerId on the prototype so the registry's auto-discover can detect it RFC2136Provider.prototype.providerId = 'rfc2136'; module.exports = RFC2136Provider;