[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.
This commit is contained in:
Hermes committed 2026-10-29 03:21:48 -07:00
1 parent f0ce96f471
commit 1a736c89cc
10 files changed
+704 -238

No files matched your search

@@ -0,0 +1,261 @@
/**
* DC-171 — DNS provider plug-in parity.
*
* Pins the capability matrix for every shipped adapter against the
* documented DC-171 matrix, verifies the RFC2136/BIND AXFR listRecords
* implementation (parse + TSIG keyfile + guidance errors), and asserts the
* setup wizard's provider-hints stay in sync with the adapters'
* getCapabilities() so the choice-time reporting can't silently drift.
*/
'use strict';
const fs = require('fs');
const path = require('path');
jest.mock('child_process', () => ({
execFile: jest.fn(),
}));
const { execFile } = require('child_process');
const { promisify } = require('util');
// The adapter promisifies execFile at require time; rebuild the promise form
// per-test from the mocked execFile.
let execFileImpl;
beforeEach(() => {
execFile.mockImplementation((cmd, args, opts, cb) => {
execFileImpl(cmd, args, opts)
.then((r) => cb(null, r))
.catch((e) => cb(e));
});
});
function loadProvider(file) {
const Cls = require(path.join(REPO, 'src', 'dns', 'dns-providers', file));
return new Cls({}, {
log: { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} },
});
}
const REPO = path.join(__dirname, '..', '..');
// ── DC-171 capability matrix ────────────────────────────────────────────────
describe('DC-171 capability matrix (provider plug-in parity)', () => {
const MATRIX = {
// protocol-complete adapters
'technitium.js': {
must: ['create-record', 'delete-record', 'resolve', 'list-records', 'zones'],
forbidden: [],
},
'cloudflare.js': {
must: ['create-record', 'delete-record', 'resolve', 'list-records'],
forbidden: [],
},
// RFC 2136 is push-only; DC-171 added AXFR-based listing (BIND 9 parity).
'rfc2136.js': {
must: ['create-record', 'delete-record', 'resolve', 'credentials', 'list-records'],
forbidden: ['logs', 'restart', 'update-check', 'zones'],
},
// instructions-only by design
'manual.js': {
must: [],
forbidden: ['list-records', 'logs', 'restart', 'update-check'],
},
};
for (const [file, { must, forbidden }] of Object.entries(MATRIX)) {
test(`${file} declares exactly the DC-171 matrix`, () => {
const p = loadProvider(file);
const caps = p.getCapabilities();
for (const cap of must) {
expect(caps).toContain(cap);
expect(p.supportsCapability(cap)).toBe(true);
}
for (const cap of forbidden) {
expect(caps).not.toContain(cap);
expect(p.supportsCapability(cap)).toBe(false);
}
});
}
test('every registered adapter file is covered by the matrix', () => {
const dir = path.join(REPO, 'src', 'dns', 'dns-providers');
const adapters = fs.readdirSync(dir)
.filter((f) => f.endsWith('.js') && f !== 'base.js' && f !== 'registry.js')
.sort();
expect(adapters).toEqual(Object.keys(MATRIX).sort());
});
});
// ── RFC2136/BIND AXFR listRecords ───────────────────────────────────────────
describe('RFC2136 listRecords via AXFR (BIND 9 parity, DC-171)', () => {
function makeProvider(config) {
const p = loadProvider('rfc2136.js');
Object.assign(p, {
server: '192.0.2.10',
port: 53,
zone: 'home.example.com',
tsigKeyName: 'dashcaddy-key',
tsigSecret: 'base64secret==',
tsigAlgorithm: 'hmac-sha256',
...config,
});
return p;
}
// Emulates `dig AXFR +onesoa` stdout: one SOA bookend, no ;; comments.
const AXFR_OUTPUT = [
'home.example.com. 3600 IN SOA ns1.home.example.com. admin.home.example.com. 42 3600 600 86400 300',
'home.example.com. 300 IN NS ns1.home.example.com.',
'host.home.example.com. 300 IN A 10.0.0.5',
'nas.home.example.com. 300 IN A 10.0.0.6',
'plex.home.example.com. 300 IN CNAME home.example.com.',
].join('\n');
function mockDig(stdout, stderr = '') {
execFileImpl = async () => {
const err = new Error('dig exited');
err.stderr = stderr;
err.stdout = stdout;
if (stderr) throw err;
return { stdout, stderr: '' };
};
}
test('parses AXFR output into records and skips the second SOA bookend', async () => {
const fsMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig(AXFR_OUTPUT);
const p = makeProvider();
const result = await p.listRecords({});
expect(result.zone).toBe('home.example.com');
// +onesoa: exactly one SOA row survives
expect(result.records.filter((r) => r.type === 'SOA')).toHaveLength(1);
expect(result.count).toBe(result.records.length);
const a = result.records.find((r) => r.name === 'host.home.example.com.');
expect(a).toMatchObject({ type: 'A', value: '10.0.0.5', ttl: 300 });
const cname = result.records.find((r) => r.type === 'CNAME');
expect(cname.value).toBe('home.example.com.');
// TSIG keyfile was written 0600 and cleaned up
expect(fsMock.mock.calls[0][2]).toMatchObject({ mode: 0o600 });
expect(unlinkMock).toHaveBeenCalled();
fsMock.mockRestore();
unlinkMock.mockRestore();
});
test('transfer denial carries the allow-transfer guidance with the key name', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig('', ';; Transfer failed: REFUSED');
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/allow-transfer \{ key dashcaddy-key; \};/);
});
test('no-TSIG denial guides toward allow-transfer generally', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig('', ';; Transfer failed: REFUSED');
const p = makeProvider({ tsigKeyName: null, tsigSecret: null });
await expect(p.listRecords({})).rejects.toThrow(/Configure 'allow-transfer'/);
});
test('missing zone config throws before invoking dig', async () => {
const p = makeProvider({ zone: null });
await expect(p.listRecords({})).rejects.toThrow(/zone/i);
expect(execFile).not.toHaveBeenCalled();
});
test('builds the exact dig argument vector with the TSIG keyfile', async () => {
const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
mockDig(AXFR_OUTPUT);
const p = makeProvider();
await p.listRecords({ zone: 'other.example.com' });
const [, args] = execFile.mock.calls[0];
expect(args[0]).toBe('@192.0.2.10');
expect(args.slice(1, 3)).toEqual(['-p', '53']);
expect(args[3]).toBe('AXFR');
expect(args[4]).toBe('other.example.com.');
expect(args.slice(5)).toContain('+onesoa');
const kIdx = args.indexOf('-k');
expect(kIdx).toBeGreaterThan(-1);
// keyfile content is valid BIND key syntax with the configured identity
const keyContent = writeFileMock.mock.calls[0][1];
expect(keyContent).toContain('key "dashcaddy-key"');
expect(keyContent).toContain('algorithm hmac-sha256;');
expect(keyContent).toContain('secret "base64secret=="');
});
test('missing dig binary maps ENOENT to an install hint', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
execFileImpl = async () => {
const err = new Error("spawn dig ENOENT");
err.code = 'ENOENT';
throw err;
};
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/bind9utils|bind-utils/);
expect(unlinkMock).toHaveBeenCalled(); // keyfile still cleaned up
});
test('non-denial dig failure is rethrown with context, keyfile cleaned up', async () => {
jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const unlinkMock = jest.spyOn(fs.promises, 'unlink').mockResolvedValue();
execFileImpl = async () => {
throw new Error('communication timed out');
};
const p = makeProvider();
await expect(p.listRecords({})).rejects.toThrow(/AXFR failed: communication timed out/);
expect(unlinkMock).toHaveBeenCalled();
});
test('rejects hostile TSIG fields before writing any keyfile', async () => {
const writeFileMock = jest.spyOn(fs.promises, 'writeFile').mockResolvedValue();
const badName = makeProvider({ tsigKeyName: 'evil"; }; ' });
await expect(badName.listRecords({})).rejects.toThrow(/Invalid TSIG key name/);
const badAlgo = makeProvider({ tsigAlgorithm: 'hmac-rainbow' });
await expect(badAlgo.listRecords({})).rejects.toThrow(/Unsupported TSIG algorithm/);
const badSecret = makeProvider({ tsigSecret: 'abc "def' });
await expect(badSecret.listRecords({})).rejects.toThrow(/Invalid TSIG secret/);
expect(writeFileMock).not.toHaveBeenCalled(); // nothing written for bad inputs
});
});
// ── Setup wizard choice-time hints stay in sync with the adapters ───────────
describe('DC-171 setup wizard provider hints sync', () => {
// status/ lives one level above dashcaddy-api in the monorepo
const MONOREPO = path.join(__dirname, '..', '..', '..');
test('every wizard <option> value has a hint entry and vice versa', () => {
const html = fs.readFileSync(path.join(MONOREPO, 'status', 'index.html'), 'utf8');
const selectMatch = html.match(/<select id="setup-dns-provider"[^>]*>([\s\S]*?)<\/select>/);
expect(selectMatch).toBeTruthy();
const options = [...selectMatch[1].matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
expect(options.length).toBeGreaterThan(0);
const wizard = fs.readFileSync(path.join(MONOREPO, 'status', 'js', 'setup-wizard.js'), 'utf8');
const mapMatch = wizard.match(/const DNS_PROVIDER_HINTS = \{([\s\S]*?)\n \};/);
// The closer is anchored to a real closing line: the rfc2136 hint text
// legitimately contains a bare '};' (allow-transfer syntax) mid-string.
expect(mapMatch).toBeTruthy();
const hintKeys = [...mapMatch[1].matchAll(/^\s{4}(\w+):/gm)].map((m) => m[1]);
expect(hintKeys.sort()).toEqual(options.sort());
});
});
@@ -59,11 +59,22 @@ describe('GET /dns/records (DC-170 route + DC-171 capability behavior)', () => {
});
test('provider WITHOUT list-records capability answers 501 with guidance', async () => {
// RFC2136-style: push-only protocol, cannot enumerate records
const dns = { getActiveProvider: () => ({ supportsCapability: () => false }) };
// RFC2136-style: push-only protocol, cannot enumerate records.
// DC-171: the guidance must name the provider and its supported capabilities.
const dns = {
getActiveProvider: () => ({
supportsCapability: () => false,
getCapabilities: () => ['create-record', 'delete-record', 'resolve'],
}),
getProviderId: () => 'rfc2136',
};
const res = await request(buildApp(dns)).get('/dns/records');
expect(res.status).toBe(501);
expect(String(res.body.error || res.body.message)).toMatch(/not supported/i);
const msg = String(res.body.error || res.body.message);
expect(msg).toMatch(/not supported/i);
expect(msg).toMatch(/rfc2136/);
expect(msg).toMatch(/create-record, delete-record, resolve/);
expect(msg).toMatch(/zone transfers/i);
});
test('missing/unresolvable active provider answers 501, does not crash', async () => {
+10 -1
View File
@@ -73,7 +73,16 @@ module.exports = function({
const zone = req.query.zone || (siteConfig.tld ? String(siteConfig.tld).replace(/^\./, '') : '');
const provider = dns.getActiveProvider ? dns.getActiveProvider() : null;
if (!provider || (provider.supportsCapability && !provider.supportsCapability('list-records'))) {
return errorResponse(res, 'Record listing not supported by current DNS provider', 501);
// DC-171: actionable guidance — name the provider and what it CAN do
const providerId = dns.getProviderId ? dns.getProviderId() : 'unknown';
const caps = provider && provider.getCapabilities ? provider.getCapabilities() : [];
return errorResponse(res,
`Record listing is not supported by the active DNS provider (${providerId}). `
+ `Supported capabilities: ${caps.join(', ') || 'none'}. `
+ `Push-only protocols (RFC 2136/BIND dynamic updates) can create, delete and `
+ `resolve records but cannot enumerate them server-side — list them with the `
+ `server's own tools (e.g. dig, the provider's web UI) or enable zone transfers.`,
501);
}
const result = await provider.listRecords({ zone });
const records = Array.isArray(result) ? result : (result && result.records) || [];
+131 -6
View File
@@ -4,8 +4,15 @@
* 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
* 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');
@@ -20,7 +27,7 @@ const execFileAsync = promisify(execFile);
const BaseDNSProvider = require('./base');
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'credentials'];
const CAPABILITIES = ['create-record', 'delete-record', 'resolve', 'list-records', 'credentials'];
const DEFAULT_PORT = 53;
const DEFAULT_TSIG_ALGORITHM = 'hmac-sha256';
@@ -34,7 +41,7 @@ class RFC2136Provider extends BaseDNSProvider {
super(config, ctx);
this.providerId = 'rfc2136';
this.displayName = 'RFC 2136 (Dynamic DNS)';
this.displayName = 'RFC 2136 / BIND 9 (Dynamic DNS)';
// Core config
this.server = config.server || null;
@@ -56,8 +63,10 @@ class RFC2136Provider extends BaseDNSProvider {
}
}
// Logger shorthand
this._log = ctx && ctx.log ? ctx.ctx : 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 ────────────────────────────────────────────────────────
@@ -274,6 +283,122 @@ class RFC2136Provider extends BaseDNSProvider {
};
}
// ── 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 ───────────────────────────────────────────────────────
/**
+15 -1
View File
@@ -559,7 +559,21 @@ function createToolHandler({ baseUrl, apiKey, headers, fetchImpl, log }) {
// DC-170: GET /dns never existed — records live at GET /dns/records
let path = '/dns/records';
if (args.zone) path += `?zone=${encodeURIComponent(args.zone)}`;
return apiCall('GET', path);
const result = await apiCall('GET', path);
// DC-171: push-only providers (RFC2136/BIND) cannot enumerate records —
// give the AI client actionable guidance instead of a bare 501 envelope.
if (result.error && result.status === 501) {
return {
...result,
guidance: 'The active DNS provider cannot list records server-side. '
+ 'Use dashcaddy_dns_add_record and dashcaddy_dns_remove_record to manage '
+ 'records (dashcaddy_dns_remove_record also confirms existence), and verify '
+ 'propagation by resolving the record. Full listing requires a provider with '
+ 'the list-records capability (Technitium, Cloudflare) or AXFR zone transfers '
+ 'enabled on an RFC2136/BIND server.',
};
}
return result;
}
case 'dashcaddy_generate_caddyfile': {