Files
dashcaddy/dashcaddy-api/src/dns/dns-providers/rfc2136.js
T
Hermes 1a736c89cc [grade=B urn:ump:wokpmecjfauc7jvnxyyyu4fq3zkkvwge455a5mxlmi3pdzkfdw3a] feat(DC-171): DNS provider plug-in parity phase 1 — BIND 9 AXFR listing + capability honesty
RFC2136/BIND9 adapter gains listRecords via AXFR zone transfer (dig with
the same TSIG identity as nsupdate; temp 0600 keyfile, always cleaned up;
transfer-denial errors carry the exact allow-transfer fix). TSIG fields
validated (name charset, HMAC algorithm whitelist, base64 secret) before
keyfile interpolation.

Capability honesty at every surface: GET /dns/records 501s name the active
provider + its supported capabilities + zone-transfer pointer; MCP
dashcaddy_list_dns attaches actionable guidance for AI clients; setup
wizard shows per-provider capability hints at choice time (parity-pinned
by tests against the adapters).

Also fixed a latent rfc2136 bug: this._log instance property shadowed the
prototype logging method — every nsupdate call threw since the DC-170-era
refactor. CHANGELOG stale 'protocol-impossible' DC-171 entry superseded.

Judge: grade B, zero blockers (urn:ump:wokpmecjfauc7jvnxyyyu4fq3zkkvwge455a5mxlmi3pdzkfdw3a).
Polish applied post-grade: stale changelog entry corrected, TSIG field
validation + 4 extra failure-path tests (exact dig argv, ENOENT install
hint, non-denial failure passthrough, hostile-field rejection),
dns-records 501 test strengthened to pin provider name + capabilities.
Suite: backend 174 suites / 3754 tests, frontend 68/68.
2026-10-29 03:21:48 -07:00

510 lines
19 KiB
JavaScript

/**
* 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, list-records (AXFR), credentials
* Not supported: logs, restart, update-check, zones
*
* DC-171 (BIND 9 plug-in parity): `list-records` is implemented via AXFR zone
* transfer (`dig AXFR`) using the same TSIG identity as nsupdate. Server-side
* prerequisite: the zone must permit transfers for this key — BIND:
* allow-transfer { key <tsigKeyName>; };
* RFC 2136 itself has no query operation, so listing is transfer-based; when
* the server denies the transfer, listRecords throws with that guidance.
*/
const { execFile } = require('child_process');
const { promisify } = require('util');
const crypto = require('crypto');
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', 'list-records', '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 / BIND 9 (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;
}
}
// NOTE: no logger shorthand here — the _log() prototype method (below)
// reads this.ctx.log. A previous version assigned an instance property
// (this._log = ctx.ctx) that shadowed the method with undefined and
// threw on every nsupdate call; fixed in DC-171.
}
// ── 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 <file>`.
*/
async _runNsupdate(commands) {
const script = commands.join('\n') + '\n';
const tmpFile = path.join(os.tmpdir(), `nsupdate-${crypto.randomBytes(4).toString('hex')}.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,
};
}
// ── List Records (AXFR zone transfer, DC-171) ────────────────────────────
/**
* Write a temporary dig TSIG keyfile from the configured key name/secret.
* nsupdate accepts the key inline in its script; dig -k requires a file:
* key "name" { algorithm <algo>; secret "<secret>"; };
* Returns the temp file path, or null when no TSIG identity is configured.
* Fields are validated before interpolation — a hostile/corrupt value must
* fail loudly here rather than produce a broken or misleading keyfile.
*/
async _writeDigKeyFile() {
if (!this.tsigKeyName || !this.tsigSecret) return null;
// Key name: an unquoted DNS hostname token (letters, digits, dots, dash,
// underscore for dnstool-generated names). Reject quotes/whitespace/;{}.
if (!/^[A-Za-z0-9._-]+$/.test(this.tsigKeyName)) {
throw new Error(`Invalid TSIG key name '${this.tsigKeyName}': expected a plain DNS name (A-Z a-z 0-9 . _ -).`);
}
// Algorithm: whitelist the RFC-standard HMAC algorithms dig supports.
const algo = String(this.tsigAlgorithm || DEFAULT_TSIG_ALGORITHM).toLowerCase();
if (!['hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512'].includes(algo)) {
throw new Error(`Unsupported TSIG algorithm '${this.tsigAlgorithm}': use one of hmac-md5/sha1/sha224/sha256/sha384/sha512.`);
}
// Secret: base64 alphabet only (dig rejects anything else anyway, but a
// stray quote here would corrupt the keyfile silently).
if (!/^[A-Za-z0-9+/=_-]+$/.test(this.tsigSecret)) {
throw new Error('Invalid TSIG secret: expected a base64 string (no quotes, whitespace or punctuation).');
}
const keyFile = path.join(os.tmpdir(), `digkey-${crypto.randomBytes(4).toString('hex')}.key`);
const content = `key "${this.tsigKeyName}" {\n algorithm ${algo};\n secret "${this.tsigSecret}";\n};\n`;
await fs.promises.writeFile(keyFile, content, { mode: 0o600 });
return keyFile;
}
/**
* List all records in a zone via AXFR zone transfer (DC-171 — BIND 9
* plug-in parity). Uses the same TSIG identity as nsupdate. Server-side
* prerequisite: the zone must permit transfers for this identity — BIND:
* allow-transfer { key <tsigKeyName>; };
* RFC 2136 has no query operation, so listing is transfer-based; when the
* server denies it, the thrown error carries that exact guidance.
*
* @param {Object} [params]
* @param {string} [params.zone] - Zone to transfer (defaults to config zone)
* @returns {Object} { zone, count, records: [{name, ttl, type, value}] }
*/
async listRecords({ zone } = {}) {
const effectiveZone = zone || this.zone;
if (!effectiveZone) throw new Error('Missing required config: zone');
const fqdnZone = this._ensureFqdn(effectiveZone);
const args = [
`@${this.server}`,
'-p', String(this.port),
'AXFR', fqdnZone,
'+nocomments', '+onesoa', '+nostats',
'+time=10', '+tries=1',
];
const keyFile = await this._writeDigKeyFile();
if (keyFile) args.push('-k', keyFile);
let stdout = '';
try {
const result = await execFileAsync('dig', args, {
timeout: 20000,
maxBuffer: 8 * 1024 * 1024,
});
stdout = result.stdout || '';
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('dig command not found. Install bind9utils (Debian/Ubuntu) or bind-utils (RHEL/CentOS).');
}
const combined = `${err.stderr || ''} ${err.stdout || ''}`;
if (/transfer failed|refused|denied/i.test(combined)) {
const hint = this.tsigKeyName
? `Zone transfer denied for '${effectiveZone}'. Add 'allow-transfer { key ${this.tsigKeyName}; };' to the zone in the server config and reload.`
: `Zone transfer denied for '${effectiveZone}'. Configure 'allow-transfer' for this identity on the server.`;
throw new Error(`AXFR failed: ${hint}`);
}
throw new Error(`AXFR failed: ${err.message}`);
} finally {
if (keyFile) {
try { await fs.promises.unlink(keyFile); } catch (_) { /* ignore */ }
}
}
// dig AXFR record lines: "name [ttl] [class] type rdata..."
// (+nocomments strips the ;; header/footer; +onesoa leaves one SOA bookend)
const records = [];
for (const raw of stdout.split('\n')) {
const line = raw.replace(/\s+;.*$/, '').trim(); // drop trailing comments
if (!line || line.startsWith(';')) continue;
const m = line.match(/^(\S+)\s+(\d+)\s+(?:IN\s+)?(\S+)\s+(.+)$/);
if (!m) continue;
let name = m[1];
// Relative owner names in transfer output belong to the zone origin
if (name === '@') name = fqdnZone;
records.push({
name,
zone: effectiveZone,
ttl: parseInt(m[2], 10),
type: m[3].toUpperCase(),
value: m[4].trim(),
});
}
this._log('info', 'AXFR zone transfer completed', {
zone: effectiveZone, count: records.length,
});
return { zone: effectiveZone, count: records.length, records };
}
// ── 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;