From 1a736c89cc2ee517c7ac9580f72ac31e76301b13 Mon Sep 17 00:00:00 2001 From: Hermes Date: Thu, 29 Oct 2026 03:21:48 -0700 Subject: [PATCH] =?UTF-8?q?[grade=3DB=20urn:ump:wokpmecjfauc7jvnxyyyu4fq3z?= =?UTF-8?q?kkvwge455a5mxlmi3pdzkfdw3a]=20feat(DC-171):=20DNS=20provider=20?= =?UTF-8?q?plug-in=20parity=20phase=201=20=E2=80=94=20BIND=209=20AXFR=20li?= =?UTF-8?q?sting=20+=20capability=20honesty?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 24 +- .../__tests__/dns/dns-provider-parity.test.js | 261 +++++++++++ .../routes/dns-records.routes.test.js | 17 +- dashcaddy-api/routes/dns.js | 11 +- .../src/dns/dns-providers/rfc2136.js | 137 +++++- dashcaddy-api/src/mcp/mcp-core.js | 16 +- status/dist/features.js | 442 +++++++++--------- status/index.html | 3 +- status/js/setup-wizard.js | 29 ++ status/sw.js | 2 +- 10 files changed, 704 insertions(+), 238 deletions(-) create mode 100644 dashcaddy-api/__tests__/dns/dns-provider-parity.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index b860f5c..31ae4f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **DC-171: DNS provider plug-in parity, phase 1 (BIND 9 upgrade + capability + honesty).** RFC 2136 adapters can now LIST records: `listRecords` performs an + AXFR zone transfer via `dig` with the same TSIG identity as nsupdate (temp + 0600 keyfile, cleaned up after), parses transfer output into + `{zone, count, records}`, and — when the server denies the transfer — errors + with the exact server-side fix (`allow-transfer { key ; };`). The + capability matrix is pinned by a parity test suite (adapters vs wizard hints + vs route guidance). `GET /api/v1/dns/records` 501s now name the active + provider, list what it CAN do, and point at AXFR/manager tools; the MCP + `dashcaddy_list_dns` tool attaches the same actionable guidance for AI + clients; the setup wizard shows per-provider capability hints at choice time. + Also fixed a latent rfc2136 bug: an instance `_log` property shadowed the + logging method, so every nsupdate call threw after the DC-170-era refactor. - **DC-170: full-software reliability and truth sweep (2026-10-27/29).** 46 findings from a three-agent audit, top items fixed in 7 graded batches: TOTP gate no longer blocks API-key/JWT auth (machine credentials were dead on every TOTP install — @@ -54,10 +67,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 endpoint advertises what the running build actually does (route counts, templates, MCP tools measured at runtime), so the marketing site, docs, and llms.txt can never disagree with the product. -- **DC-171 (ticketed, pending): DNS provider plug-in parity** — capability - reporting at provider-choice time (RFC2136 is push-only: create/resolve yes, - listing is protocol-impossible); BIND 9 near-parity via the RFC2136 adapter - plus AXFR-based listing; PowerDNS/deSEC/AdGuard Home adapters on the roadmap. +- **DC-171 (roadmap): further provider plug-in parity** — BIND 9 AXFR-based + listing shipped in phase 1 (above); remaining roadmap: PowerDNS / + deSEC / AdGuard Home adapters, richer per-provider setup validation. + ~~Capability reporting at provider-choice time~~ shipped in phase 1 + (wizard hints + route guidance + MCP guidance); the original "listing is + protocol-impossible" note was superseded — RFC2136 listing works via + AXFR zone transfer with the `allow-transfer` prerequisite. ### Notes - Historical per-change review outcomes are recorded in commit messages and in diff --git a/dashcaddy-api/__tests__/dns/dns-provider-parity.test.js b/dashcaddy-api/__tests__/dns/dns-provider-parity.test.js new file mode 100644 index 0000000..acb51c2 --- /dev/null +++ b/dashcaddy-api/__tests__/dns/dns-provider-parity.test.js @@ -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