pending-dns 1.2.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.github/codeql/codeql-config.yml +11 -0
  2. package/.github/workflows/codeql.yml +52 -0
  3. package/.github/workflows/deploy.yml +16 -3
  4. package/.github/workflows/release.yaml +43 -0
  5. package/.github/workflows/test.yml +75 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CLAUDE.md +109 -0
  9. package/README.md +111 -9
  10. package/SECURITY.md +88 -0
  11. package/SECURITY.txt +27 -0
  12. package/bin/pending-dns.js +1 -1
  13. package/config/default.toml +43 -0
  14. package/config/test.toml +35 -0
  15. package/eslint.config.js +38 -0
  16. package/lib/api-server.js +198 -23
  17. package/lib/cached-resolver.js +5 -3
  18. package/lib/certs.js +12 -20
  19. package/lib/dns-handler.js +362 -32
  20. package/lib/dns-server.js +120 -43
  21. package/lib/dns-tcp-server.js +1 -1
  22. package/lib/dns-udp-server.js +1 -1
  23. package/lib/dnssec-wire.js +321 -0
  24. package/lib/dnssec.js +461 -0
  25. package/lib/lock.js +37 -0
  26. package/lib/logger.js +3 -0
  27. package/lib/public-server.js +20 -2
  28. package/lib/sentry.js +72 -0
  29. package/lib/tools.js +1 -1
  30. package/lib/zone-store.js +90 -7
  31. package/package.json +46 -33
  32. package/release-please-config.json +14 -0
  33. package/server.js +5 -24
  34. package/systemd/pending-dns.service +4 -4
  35. package/test/api.test.js +231 -0
  36. package/test/cached-resolver.test.js +57 -0
  37. package/test/certs.test.js +34 -0
  38. package/test/dns-handler.test.js +171 -0
  39. package/test/dns-server.test.js +162 -0
  40. package/test/dnssec-handler.test.js +550 -0
  41. package/test/dnssec-wire.test.js +163 -0
  42. package/test/dnssec.test.js +213 -0
  43. package/test/helpers.js +27 -0
  44. package/test/sentry.test.js +21 -0
  45. package/test/tools.test.js +48 -0
  46. package/test/zone-store.test.js +245 -0
  47. package/workers/api.js +3 -1
  48. package/workers/dns.js +2 -24
  49. package/workers/health.js +3 -26
  50. package/workers/public.js +3 -25
  51. package/.eslintrc +0 -14
  52. package/Gruntfile.js +0 -16
package/lib/zone-store.js CHANGED
@@ -1,15 +1,35 @@
1
1
  'use strict';
2
2
 
3
3
  const punycode = require('punycode/');
4
- const shortid = require('shortid');
4
+ const { nanoid } = require('nanoid');
5
5
  const db = require('./db');
6
6
  const logger = require('./logger').child({ component: 'zone-store' });
7
7
  const { normalizeDomain } = require('./tools');
8
8
 
9
- const orderList = ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'URL', 'NS'];
9
+ const orderList = ['A', 'AAAA', 'ANAME', 'CNAME', 'MX', 'TXT', 'CAA', 'TLSA', 'URL', 'NS'];
10
10
  const allowedTypes = new Set(orderList);
11
11
  const allowedTags = new Set(['issue', 'issuewild', 'iodef']);
12
12
 
13
+ // TLSA records are stored as a positional value array
14
+ // [usage, selector, matchingType, certificate]. These keep that ordering in one
15
+ // place so the API, the store, and the DNS handler cannot drift apart.
16
+ const tlsaToValue = data => [data.usage, data.selector, data.matchingType, data.certificate];
17
+ // TLSA certificate association data must be even-length hex. The API enforces
18
+ // this in Joi, but guard the store boundary too so a direct caller cannot store
19
+ // a value that Buffer.from(..,'hex') would silently truncate into a corrupt DANE
20
+ // association.
21
+ const isEvenHex = value => typeof value === 'string' && value.length > 0 && value.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(value);
22
+ const tlsaFromValue = value => ({
23
+ usage: Number(value[0]) || 0,
24
+ selector: Number(value[1]) || 0,
25
+ matchingType: Number(value[2]) || 0,
26
+ certificate: value[3]
27
+ });
28
+
29
+ // Single-label wildcard key for a label-reversed name: replace the most-specific
30
+ // (trailing) label with '*'. resolve() and existingTypes() both probe this key.
31
+ const toWildcardName = name => name.replace(/\.[^.]+$/, '.*');
32
+
13
33
  class ZoneStore {
14
34
  constructor(db, options) {
15
35
  this.db = db;
@@ -70,8 +90,8 @@ class ZoneStore {
70
90
  let recordKey = `d:${name}:r:${type}`;
71
91
  let res = await this.db.redisWrite.multi().hdel(recordKey, hid).exists(recordKey).exec();
72
92
 
73
- if (res[res.length - 1] && !res[res.length - 1][0] && !res[res.length - 1][0]) {
74
- // key was deleted, update zone
93
+ if (res[res.length - 1] && !res[res.length - 1][0] && !res[res.length - 1][1]) {
94
+ // the record key no longer exists (no entries left), remove it from the zone
75
95
  await this.db.redisWrite.srem(`d:${zone}:z`, recordKey);
76
96
  }
77
97
 
@@ -168,6 +188,10 @@ class ZoneStore {
168
188
  result.ns = entry.value[0];
169
189
  break;
170
190
 
191
+ case 'TLSA':
192
+ Object.assign(result, tlsaFromValue(entry.value));
193
+ break;
194
+
171
195
  case 'TXT':
172
196
  result.data = entry.value[0];
173
197
  break;
@@ -322,6 +346,49 @@ class ZoneStore {
322
346
  return list;
323
347
  }
324
348
 
349
+ /**
350
+ * List record types that exist at an exact name, optionally also folding in
351
+ * the types a single-level wildcard could answer at that name (probed in the
352
+ * same pipeline). Used to build DNSSEC NSEC type bitmaps: listing the
353
+ * wildcard-answerable types keeps an RFC 8198 aggressive-NSEC resolver from
354
+ * synthesizing a NODATA that suppresses the wildcard answer.
355
+ * @param {String} domain Exact domain name
356
+ * @param {Boolean} includeWildcard Also probe the single-level wildcard key
357
+ * @returns {Array} Array of record type strings present at the name
358
+ */
359
+ async existingTypes(domain, includeWildcard) {
360
+ let name = this.domainToName(domain);
361
+ if (!name) {
362
+ return [];
363
+ }
364
+
365
+ // Same single-level wildcard key construction resolve() uses: replace the
366
+ // most-specific label with '*'.
367
+ let wildcardName = includeWildcard && name.includes('.') ? toWildcardName(name) : null;
368
+ let names = wildcardName && wildcardName !== name ? [name, wildcardName] : [name];
369
+
370
+ let req = this.db.redisRead.multi();
371
+ for (let probe of names) {
372
+ for (let type of orderList) {
373
+ req = req.exists(`d:${probe}:r:${type}`);
374
+ }
375
+ }
376
+ let res = await req.exec();
377
+
378
+ let types = new Set();
379
+ names.forEach((probe, n) => {
380
+ // Each probe contributed one EXISTS per orderList type, in order; take
381
+ // that probe's contiguous slice of the flat pipeline result.
382
+ let probeRes = res.slice(n * orderList.length, (n + 1) * orderList.length);
383
+ orderList.forEach((type, i) => {
384
+ if (probeRes[i] && probeRes[i][1]) {
385
+ types.add(type);
386
+ }
387
+ });
388
+ });
389
+ return [...types];
390
+ }
391
+
325
392
  /**
326
393
  * Add new resource record to Zone
327
394
  * @param {String} zone Zone domain
@@ -355,8 +422,14 @@ class ZoneStore {
355
422
  return false;
356
423
  }
357
424
 
425
+ // Reject TLSA records whose certificate data is not even-length hex; the
426
+ // wire encoder would otherwise silently truncate it (RFC 6698 RDATA).
427
+ if (type === 'TLSA' && !isEvenHex(value[3])) {
428
+ return false;
429
+ }
430
+
358
431
  let recordKey = `d:${name}:r:${type}`;
359
- let hid = shortid.generate();
432
+ let hid = nanoid();
360
433
 
361
434
  let id = this.getFullId(name, type, hid);
362
435
 
@@ -421,6 +494,13 @@ class ZoneStore {
421
494
  return await this.add(this.nameToDomain(zone), updatedSubdomain, updatedType, value);
422
495
  }
423
496
 
497
+ // Same TLSA even-length-hex guard add() applies: the changed-name/type path
498
+ // above routes through add(), but this unchanged path writes directly and
499
+ // would otherwise let a corrupt cert through that the wire encoder truncates.
500
+ if (type === 'TLSA' && !isEvenHex(Array.isArray(value) ? value[3] : null)) {
501
+ return false;
502
+ }
503
+
424
504
  let updatedId = this.getFullId(name, type, hid);
425
505
 
426
506
  let recordKey = `d:${name}:r:${type}`;
@@ -495,9 +575,9 @@ class ZoneStore {
495
575
  return await checkHealthStatus(exactRes);
496
576
  }
497
577
 
498
- let wildcardName = name.replace(/\.[^.]+$/, '.*');
578
+ let wildcardName = toWildcardName(name);
499
579
  let wildcardDomain = this.nameToDomain(wildcardName);
500
- let wildcardRecordKey = `d:${name.replace(/\.[^.]+$/, '.*')}:r:${type}`;
580
+ let wildcardRecordKey = `d:${wildcardName}:r:${type}`;
501
581
 
502
582
  let wildcardRecord = await this.db.redisRead.hgetall(wildcardRecordKey);
503
583
  let wildRes = this.parseHashRecord(zone, wildcardName, domain, type, short, wildcardRecord);
@@ -578,3 +658,6 @@ module.exports.ZoneStore = ZoneStore;
578
658
  module.exports.zoneStore = new ZoneStore(db);
579
659
  module.exports.allowedTypes = [...allowedTypes];
580
660
  module.exports.allowedTags = [...allowedTags];
661
+ module.exports.tlsaToValue = tlsaToValue;
662
+ module.exports.tlsaFromValue = tlsaFromValue;
663
+ module.exports.isEvenHex = isEvenHex;
package/package.json CHANGED
@@ -1,15 +1,24 @@
1
1
  {
2
2
  "name": "pending-dns",
3
- "version": "1.2.5",
3
+ "version": "1.4.0",
4
4
  "description": "Lightweight API driven DNS server",
5
+ "productTitle": "PendingDNS",
5
6
  "main": "index.js",
7
+ "private": false,
8
+ "engines": {
9
+ "node": ">=18"
10
+ },
6
11
  "scripts": {
7
12
  "start": "node server.js",
8
- "test": "grunt",
9
- "build-source": "rm -rf node_modules package-lock.json && npm install && npm run licenses && rm -rf node_modules package-lock.json && npm install --production && rm -rf package-lock.json",
10
- "build-dist-fast": "npx pkg --debug package.json && rm -rf package-lock.json && npm install",
11
- "build-dist": "npx pkg --compress Brotli package.json && rm -rf package-lock.json && npm install",
12
- "licenses": "license-report --only=prod --output=table --config license-report-config.json > licenses.txt"
13
+ "lint": "eslint .",
14
+ "format": "prettier --write 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' '*.js'",
15
+ "format:check": "prettier --check 'lib/**/*.js' 'workers/**/*.js' 'test/**/*.js' '*.js'",
16
+ "test": "NODE_ENV=test node --test --test-force-exit --test-concurrency=1 test/*.test.js",
17
+ "build-source": "rm -rf node_modules && npm install && npm run licenses && rm -rf node_modules && npm ci --omit=dev",
18
+ "build-dist-fast": "pkg --debug package.json && npm install",
19
+ "build-dist": "pkg --compress Brotli package.json && npm install",
20
+ "licenses": "license-report --only=prod --output=table --config license-report-config.json > licenses.txt",
21
+ "update": "rm -rf node_modules package-lock.json && ncu -u && npm install"
13
22
  },
14
23
  "repository": {
15
24
  "type": "git",
@@ -28,55 +37,59 @@
28
37
  },
29
38
  "homepage": "https://github.com/postalsys/pending-dns#readme",
30
39
  "devDependencies": {
31
- "eslint": "8.47.0",
40
+ "@eslint/eslintrc": "3.3.5",
41
+ "@eslint/js": "9.39.4",
42
+ "eslint": "9.39.4",
32
43
  "eslint-config-nodemailer": "1.2.0",
33
- "eslint-config-prettier": "9.0.0",
34
- "grunt": "1.6.1",
35
- "grunt-cli": "1.4.3",
36
- "grunt-eslint": "24.3.0",
37
- "license-report": "6.4.0"
44
+ "eslint-config-prettier": "10.1.8",
45
+ "license-report": "6.8.5",
46
+ "prettier": "3.8.4"
38
47
  },
39
48
  "dependencies": {
40
- "@bugsnag/js": "7.21.0",
41
49
  "@fidm/x509": "1.2.1",
42
50
  "@hapi/boom": "10.0.1",
43
- "@hapi/hapi": "21.3.2",
44
- "@hapi/inert": "7.1.0",
45
- "@hapi/joi": "17.1.1",
51
+ "@hapi/hapi": "21.4.9",
52
+ "@hapi/inert": "7.1.2",
46
53
  "@hapi/vision": "7.0.3",
47
54
  "@root/acme": "3.1.0",
48
55
  "@root/csr": "0.8.1",
49
- "dns2": "2.1.0",
50
- "handlebars": "4.7.8",
51
- "hapi-pino": "12.1.0",
52
- "hapi-swagger": "17.1.0",
56
+ "@sentry/node": "10.57.0",
57
+ "dns2": "3.0.0",
58
+ "handlebars": "4.7.9",
59
+ "hapi-pino": "13.0.0",
60
+ "hapi-swagger": "17.3.2",
53
61
  "http-proxy": "1.18.1",
54
- "ioredfour": "1.2.0-ioredis-07",
55
- "ioredis": "5.3.2",
56
- "ipaddr.js": "2.1.0",
62
+ "ioredfour": "1.4.1",
63
+ "ioredis": "5.11.1",
64
+ "ipaddr.js": "2.4.0",
65
+ "joi": "17.13.4",
57
66
  "minimist": "1.2.8",
58
- "node-rsa": "1.1.1",
67
+ "nanoid": "3.3.12",
59
68
  "pem-jwk": "2.0.0",
60
- "pino": "8.15.0",
61
- "punycode": "2.3.0",
62
- "shortid": "2.2.16",
63
- "uuid": "9.0.0",
64
- "wild-config": "1.7.0"
69
+ "pino": "10.3.1",
70
+ "punycode": "2.3.1",
71
+ "wild-config": "1.7.1"
65
72
  },
66
73
  "bin": {
67
74
  "pending-dns": "bin/pending-dns.js"
68
75
  },
69
76
  "pkg": {
77
+ "scripts": [
78
+ "lib/**/*.js",
79
+ "workers/**/*.js"
80
+ ],
70
81
  "assets": [
82
+ "lib/lua/**/*",
83
+ "config/*.pem",
71
84
  "licenses.txt",
72
85
  "LICENSE.txt",
73
86
  "help.txt"
74
87
  ],
75
88
  "targets": [
76
- "node18-linux-x64",
77
- "node18-macos-x64",
78
- "node18-macos-arm64",
79
- "node18-win-x64"
89
+ "node24-linux-x64",
90
+ "node24-macos-x64",
91
+ "node24-macos-arm64",
92
+ "node24-win-x64"
80
93
  ],
81
94
  "outputPath": "ee-dist"
82
95
  }
@@ -0,0 +1,14 @@
1
+ {
2
+ "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
3
+ "include-component-in-tag": false,
4
+ "packages": {
5
+ ".": {
6
+ "release-type": "node",
7
+ "package-name": "pending-dns",
8
+ "changelog-path": "CHANGELOG.md",
9
+ "bump-minor-pre-major": false,
10
+ "draft": false,
11
+ "prerelease": false
12
+ }
13
+ }
14
+ }
package/server.js CHANGED
@@ -7,7 +7,6 @@ const argv = process.argv.slice(2);
7
7
  const config = require('wild-config');
8
8
  const logger = require('./lib/logger').child({ component: 'server' });
9
9
  const pathlib = require('path');
10
- const packageData = require('./package.json');
11
10
  const { Worker, SHARE_ENV } = require('worker_threads');
12
11
  const { isemail } = require('./lib/tools');
13
12
 
@@ -16,28 +15,7 @@ if (!config.acme || !isemail(config.acme.email)) {
16
15
  process.exit(51);
17
16
  }
18
17
 
19
- const Bugsnag = require('@bugsnag/js');
20
- if (process.env.BUGSNAG_API_KEY) {
21
- Bugsnag.start({
22
- apiKey: process.env.BUGSNAG_API_KEY,
23
- appVersion: packageData.version,
24
- logger: {
25
- debug(...args) {
26
- logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
27
- },
28
- info(...args) {
29
- logger.debug({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
30
- },
31
- warn(...args) {
32
- logger.warn({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
33
- },
34
- error(...args) {
35
- logger.error({ msg: args.shift(), worker: 'main', source: 'bugsnag', args: args.length ? args : undefined });
36
- }
37
- }
38
- });
39
- logger.notifyError = Bugsnag.notify.bind(Bugsnag);
40
- }
18
+ require('./lib/sentry').initSentry('main');
41
19
 
42
20
  let closing = false;
43
21
 
@@ -109,7 +87,10 @@ const closeProcess = (code, errType, err) => {
109
87
  err
110
88
  });
111
89
 
112
- if (!logger.notifyError) {
90
+ if (!logger.errorReportingEnabled) {
91
+ // No external reporter is handling the crash, so exit here and let the
92
+ // supervisor restart us. When Sentry is enabled its crash integration
93
+ // captures, flushes and exits instead.
113
94
  setTimeout(() => process.exit(code), 10);
114
95
  }
115
96
  };
@@ -35,15 +35,15 @@ Environment="NODE_CONFIG_PATH=/etc/pending-dns.toml"
35
35
 
36
36
  # This is the folder where PendingDNS files reside.
37
37
 
38
- # Normally this folder would include a clean copy from the PendingDNS Github repository + `npm install --production`.
38
+ # Normally this folder would include a clean copy from the PendingDNS Github repository + `npm install --omit=dev`.
39
39
  # To set up:
40
- # git clone git://github.com/postalsys/pending-dns.git
40
+ # git clone https://github.com/postalsys/pending-dns.git
41
41
  # cd pending-dns
42
- # npm install --production
42
+ # npm install --omit=dev
43
43
 
44
44
  # If PendingDNS files are cloned from Github then an easy way to upgrade the application would look like this:
45
45
  # git pull origin master
46
- # npm install --production
46
+ # npm install --omit=dev
47
47
  # And then (as root or a user with sudo privileges):
48
48
  # sudo systemctl restart pending-dns
49
49
 
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const { createServer } = require('../lib/api-server');
7
+ const { config, flushTestDb, closeDb } = require('./helpers');
8
+
9
+ let server;
10
+
11
+ test.before(async () => {
12
+ server = await createServer();
13
+ await server.initialize();
14
+ });
15
+
16
+ test.after(async () => {
17
+ if (server) {
18
+ await server.stop();
19
+ }
20
+ await closeDb();
21
+ });
22
+
23
+ test.beforeEach(async () => {
24
+ await flushTestDb();
25
+ });
26
+
27
+ const inject = opts => server.inject(opts);
28
+
29
+ test('GET records returns an empty list for an unknown zone', async () => {
30
+ const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
31
+ assert.equal(res.statusCode, 200);
32
+ const body = JSON.parse(res.payload);
33
+ assert.equal(body.zone, 'example.com');
34
+ assert.deepEqual(body.records, []);
35
+ });
36
+
37
+ test('unknown routes return 404', async () => {
38
+ const res = await inject({ method: 'GET', url: '/does/not/exist' });
39
+ assert.equal(res.statusCode, 404);
40
+ });
41
+
42
+ test('POST creates an A record and GET lists it alongside SOA/NS', async () => {
43
+ const create = await inject({
44
+ method: 'POST',
45
+ url: '/v1/zone/example.com/records',
46
+ payload: { subdomain: 'www', type: 'A', address: '1.2.3.4' }
47
+ });
48
+ assert.equal(create.statusCode, 200);
49
+ const created = JSON.parse(create.payload);
50
+ assert.ok(created.record, 'should return the new record id');
51
+
52
+ const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
53
+ const body = JSON.parse(res.payload);
54
+
55
+ const a = body.records.find(r => r.type === 'A');
56
+ assert.ok(a);
57
+ assert.equal(a.address, '1.2.3.4');
58
+ assert.equal(a.subdomain, 'www');
59
+
60
+ // system records are appended with id=null
61
+ assert.ok(body.records.some(r => r.type === 'SOA' && r.id === null));
62
+ assert.ok(body.records.some(r => r.type === 'NS' && r.id === null));
63
+ });
64
+
65
+ test('POST rejects an A record without an address', async () => {
66
+ const res = await inject({
67
+ method: 'POST',
68
+ url: '/v1/zone/example.com/records',
69
+ payload: { type: 'A' }
70
+ });
71
+ assert.equal(res.statusCode, 400);
72
+ const body = JSON.parse(res.payload);
73
+ assert.ok(Array.isArray(body.fields), 'validation errors are reported in the fields array');
74
+ });
75
+
76
+ test('POST creates a CAA record and stores its tag', async () => {
77
+ // Regression: the CAA "tag" field must be accepted and persisted.
78
+ const create = await inject({
79
+ method: 'POST',
80
+ url: '/v1/zone/example.com/records',
81
+ payload: { type: 'CAA', value: 'letsencrypt.org', tag: 'issue', flags: 0 }
82
+ });
83
+ assert.equal(create.statusCode, 200, `expected CAA create to succeed, got ${create.payload}`);
84
+
85
+ const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
86
+ const body = JSON.parse(res.payload);
87
+ const caa = body.records.find(r => r.type === 'CAA' && r.id);
88
+ assert.ok(caa, 'CAA record should be listed');
89
+ assert.equal(caa.value, 'letsencrypt.org');
90
+ assert.equal(caa.tag, 'issue');
91
+ });
92
+
93
+ test('POST creates a TLSA record with an underscore-labelled subdomain', async () => {
94
+ const certHex = '92003ba34942dc74152e2f2c408d29eca5a520e7f2e06bb944f4dca346baf63c';
95
+ const create = await inject({
96
+ method: 'POST',
97
+ url: '/v1/zone/example.com/records',
98
+ payload: { subdomain: '_443._tcp.www', type: 'TLSA', usage: 3, selector: 1, matchingType: 1, certificate: certHex }
99
+ });
100
+ assert.equal(create.statusCode, 200, `expected TLSA create to succeed, got ${create.payload}`);
101
+
102
+ const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
103
+ const body = JSON.parse(res.payload);
104
+ const tlsa = body.records.find(r => r.type === 'TLSA' && r.id);
105
+ assert.ok(tlsa, 'TLSA record should be listed');
106
+ assert.equal(tlsa.subdomain, '_443._tcp.www');
107
+ assert.equal(tlsa.usage, 3);
108
+ assert.equal(tlsa.selector, 1);
109
+ assert.equal(tlsa.matchingType, 1);
110
+ assert.equal(tlsa.certificate, certHex);
111
+ });
112
+
113
+ test('POST rejects a TLSA record with odd-length hex', async () => {
114
+ const res = await inject({
115
+ method: 'POST',
116
+ url: '/v1/zone/example.com/records',
117
+ payload: { subdomain: '_443._tcp.www', type: 'TLSA', usage: 3, selector: 1, matchingType: 1, certificate: 'abc' }
118
+ });
119
+ assert.equal(res.statusCode, 400);
120
+ });
121
+
122
+ test('POST rejects TLSA-only fields on a non-TLSA record type', async () => {
123
+ const res = await inject({
124
+ method: 'POST',
125
+ url: '/v1/zone/example.com/records',
126
+ payload: { subdomain: '', type: 'A', address: '1.2.3.4', usage: 3, certificate: 'abcd' }
127
+ });
128
+ assert.equal(res.statusCode, 400);
129
+ });
130
+
131
+ test('PUT updates a record and echoes back the record id', async () => {
132
+ const create = await inject({
133
+ method: 'POST',
134
+ url: '/v1/zone/example.com/records',
135
+ payload: { subdomain: 'www', type: 'A', address: '1.2.3.4' }
136
+ });
137
+ const id = JSON.parse(create.payload).record;
138
+
139
+ const update = await inject({
140
+ method: 'PUT',
141
+ url: `/v1/zone/example.com/records/${id}`,
142
+ payload: { subdomain: 'www', type: 'A', address: '5.6.7.8' }
143
+ });
144
+ assert.equal(update.statusCode, 200);
145
+ const body = JSON.parse(update.payload);
146
+ assert.equal(body.zone, 'example.com');
147
+ assert.ok(body.record, 'PUT should return the (possibly new) record id under "record"');
148
+ });
149
+
150
+ test('DELETE removes a record', async () => {
151
+ const create = await inject({
152
+ method: 'POST',
153
+ url: '/v1/zone/example.com/records',
154
+ payload: { type: 'A', address: '1.2.3.4' }
155
+ });
156
+ const id = JSON.parse(create.payload).record;
157
+
158
+ const del = await inject({ method: 'DELETE', url: `/v1/zone/example.com/records/${id}` });
159
+ assert.equal(del.statusCode, 200);
160
+ const body = JSON.parse(del.payload);
161
+ assert.equal(body.deleted, true);
162
+
163
+ const res = await inject({ method: 'GET', url: '/v1/zone/example.com/records' });
164
+ assert.deepEqual(JSON.parse(res.payload).records, []);
165
+ });
166
+
167
+ test('DNSSEC can be enabled, inspected and disabled over the API', async () => {
168
+ const enable = await inject({ method: 'POST', url: '/v1/zone/example.com/dnssec', payload: { algorithm: 13 } });
169
+ assert.equal(enable.statusCode, 200, `expected enable to succeed, got ${enable.payload}`);
170
+ const enabled = JSON.parse(enable.payload);
171
+ assert.equal(enabled.enabled, true);
172
+ assert.ok(enabled.ds.length >= 1 && enabled.ds[0].presentation, 'DS presentation should be returned');
173
+
174
+ const status = await inject({ method: 'GET', url: '/v1/zone/example.com/dnssec' });
175
+ assert.equal(status.statusCode, 200);
176
+ assert.equal(JSON.parse(status.payload).enabled, true);
177
+
178
+ const disable = await inject({ method: 'DELETE', url: '/v1/zone/example.com/dnssec' });
179
+ assert.equal(disable.statusCode, 200);
180
+ assert.equal(JSON.parse(disable.payload).disabled, true);
181
+
182
+ const after = await inject({ method: 'GET', url: '/v1/zone/example.com/dnssec' });
183
+ assert.equal(JSON.parse(after.payload).enabled, false);
184
+ });
185
+
186
+ test('DNSSEC algorithm rollover and key removal over the API', async () => {
187
+ const enable = JSON.parse((await inject({ method: 'POST', url: '/v1/zone/example.com/dnssec', payload: { algorithm: 13 } })).payload);
188
+ const oldKeyTag = enable.keyTag;
189
+
190
+ // Re-enable with a new algorithm -> rollover keeps both keys.
191
+ const rolled = await inject({ method: 'POST', url: '/v1/zone/example.com/dnssec', payload: { algorithm: 15 } });
192
+ const rolledBody = JSON.parse(rolled.payload);
193
+ assert.equal(rolledBody.algorithm, 15);
194
+ assert.equal(rolledBody.ds.length, 2, 'both keys are published during the rollover');
195
+ assert.notEqual(rolledBody.keyTag, oldKeyTag);
196
+
197
+ // The active key cannot be removed.
198
+ const refuse = await inject({ method: 'DELETE', url: `/v1/zone/example.com/dnssec/key/${rolledBody.keyTag}` });
199
+ assert.equal(refuse.statusCode, 400);
200
+
201
+ // Removing the old key finishes the rollover.
202
+ const remove = await inject({ method: 'DELETE', url: `/v1/zone/example.com/dnssec/key/${oldKeyTag}` });
203
+ assert.equal(remove.statusCode, 200);
204
+ assert.equal(JSON.parse(remove.payload).removed, true);
205
+
206
+ const final = JSON.parse((await inject({ method: 'GET', url: '/v1/zone/example.com/dnssec' })).payload);
207
+ assert.equal(final.ds.length, 1);
208
+ assert.equal(final.keyTag, rolledBody.keyTag);
209
+ });
210
+
211
+ test('POST /dnssec is refused (400) when DNSSEC is globally disabled', async () => {
212
+ config.dnssec.enabled = false;
213
+ try {
214
+ const res = await inject({ method: 'POST', url: '/v1/zone/example.com/dnssec', payload: { algorithm: 13 } });
215
+ assert.equal(res.statusCode, 400, `expected 400, got ${res.statusCode}: ${res.payload}`);
216
+ } finally {
217
+ config.dnssec.enabled = true;
218
+ }
219
+ });
220
+
221
+ test('POST /v1/acme requires at least one domain', async () => {
222
+ // Fails Joi validation (min 1) before the handler runs, so no ACME/network work.
223
+ const res = await inject({ method: 'POST', url: '/v1/acme', payload: { domains: [] } });
224
+ assert.equal(res.statusCode, 400);
225
+ });
226
+
227
+ // NB: the "domain without a managed zone" rejection path is intentionally not
228
+ // exercised here - the /v1/acme handler calls acme.init() (a live Let's Encrypt
229
+ // directory fetch) before the zone check, which is slow and network-dependent.
230
+ // The underlying behaviour is covered offline by the zone-store tests
231
+ // (resolveDomainZone returns false for unknown domains).
@@ -0,0 +1,57 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const cachedResolver = require('../lib/cached-resolver');
7
+ const { db, flushTestDb, closeDb } = require('./helpers');
8
+
9
+ test.after(async () => {
10
+ await closeDb();
11
+ });
12
+
13
+ test.beforeEach(async () => {
14
+ await flushTestDb();
15
+ });
16
+
17
+ const cacheKey = (target, type) => ['d', 'cache', target, type].join(':');
18
+
19
+ test('successful lookups are cached in Redis with a TTL', async t => {
20
+ let resolved;
21
+ try {
22
+ resolved = await cachedResolver('one.one.one.one', 'A');
23
+ } catch (err) {
24
+ t.skip(`network unavailable: ${err.message}`);
25
+ return;
26
+ }
27
+
28
+ assert.ok(Array.isArray(resolved) && resolved.length, 'should resolve at least one address');
29
+
30
+ const ttl = await db.redisRead.ttl(cacheKey('one.one.one.one', 'A'));
31
+ assert.ok(ttl > 0, 'a positive TTL should be set on the cache entry');
32
+
33
+ // a second call returns the cached value
34
+ const again = await cachedResolver('one.one.one.one', 'A');
35
+ assert.deepEqual(again.slice().sort(), resolved.slice().sort());
36
+ });
37
+
38
+ test('failed lookups cache an error with a bounded TTL', async t => {
39
+ const bogus = 'does-not-exist-pendingdns-test.invalid';
40
+
41
+ let threw = false;
42
+ try {
43
+ await cachedResolver(bogus, 'A');
44
+ } catch (err) {
45
+ threw = true;
46
+ }
47
+
48
+ if (!threw) {
49
+ t.skip('resolver unexpectedly succeeded; cannot exercise the error path');
50
+ return;
51
+ }
52
+
53
+ const ttl = await db.redisRead.ttl(cacheKey(bogus, 'A'));
54
+ // Regression: the error entry must have a real, positive expiry (not NaN/-1).
55
+ assert.ok(ttl > 0, `error cache entry must expire (ttl was ${ttl})`);
56
+ assert.ok(ttl <= 60 * 60, 'error TTL should be bounded by errorMaxTtl (1h)');
57
+ });