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.
262 lines
10 KiB
JavaScript
262 lines
10 KiB
JavaScript
/**
|
|
* 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());
|
|
});
|
|
});
|