pending-dns 1.3.0 → 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.
package/test/api.test.js CHANGED
@@ -4,7 +4,7 @@ const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
5
 
6
6
  const { createServer } = require('../lib/api-server');
7
- const { flushTestDb, closeDb } = require('./helpers');
7
+ const { config, flushTestDb, closeDb } = require('./helpers');
8
8
 
9
9
  let server;
10
10
 
@@ -90,6 +90,44 @@ test('POST creates a CAA record and stores its tag', async () => {
90
90
  assert.equal(caa.tag, 'issue');
91
91
  });
92
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
+
93
131
  test('PUT updates a record and echoes back the record id', async () => {
94
132
  const create = await inject({
95
133
  method: 'POST',
@@ -126,6 +164,60 @@ test('DELETE removes a record', async () => {
126
164
  assert.deepEqual(JSON.parse(res.payload).records, []);
127
165
  });
128
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
+
129
221
  test('POST /v1/acme requires at least one domain', async () => {
130
222
  // Fails Joi validation (min 1) before the handler runs, so no ACME/network work.
131
223
  const res = await inject({ method: 'POST', url: '/v1/acme', payload: { domains: [] } });
@@ -46,7 +46,10 @@ test('shuffle returns the same elements (a permutation)', () => {
46
46
  const input = [1, 2, 3, 4, 5, 6, 7, 8];
47
47
  const out = shuffle(input.slice());
48
48
  assert.equal(out.length, input.length);
49
- assert.deepEqual(out.slice().sort((a, b) => a - b), input);
49
+ assert.deepEqual(
50
+ out.slice().sort((a, b) => a - b),
51
+ input
52
+ );
50
53
  });
51
54
 
52
55
  test('filterUnhealthy drops unhealthy entries when at least one is healthy', () => {
@@ -104,6 +107,14 @@ test('dnsHandler returns configured NS records when none are stored', async () =
104
107
  assert.ok(nsAnswers.length >= 1, 'should fall back to configured name servers');
105
108
  });
106
109
 
110
+ test('dnsHandler does not synthesise NS below the apex', async () => {
111
+ await flushTestDb();
112
+ // Adding any record registers the example.com zone.
113
+ await zoneStore.add('example.com', 'www', 'A', ['1.2.3.4']);
114
+ const response = await dnsHandler(buildRequest('sub.example.com', 'NS'));
115
+ assert.ok(!response.answers.some(a => a.type === Packet.TYPE.NS), 'a record-less below-apex name is not a delegation, so no NS is synthesised');
116
+ });
117
+
107
118
  test('dnsHandler synthesises a SOA record', async () => {
108
119
  await flushTestDb();
109
120
  const response = await dnsHandler(buildRequest('example.com', 'SOA'));
@@ -127,6 +138,26 @@ test('dnsHandler filters unhealthy AAAA records', async () => {
127
138
  assert.equal(aaaa.length, 1, 'the unhealthy AAAA record should be filtered out');
128
139
  });
129
140
 
141
+ test('dnsHandler answers a TLSA query with raw RDATA that round-trips on the wire', async () => {
142
+ await flushTestDb();
143
+ const certHex = '92003ba34942dc74152e2f2c408d29eca5a520e7f2e06bb944f4dca346baf63c';
144
+ await zoneStore.add('example.com', '_443._tcp.www', 'TLSA', [3, 1, 1, certHex]);
145
+
146
+ const req = new Packet({});
147
+ req.questions = [{ name: '_443._tcp.www.example.com', type: 52, class: Packet.CLASS.IN }];
148
+ req.source = { type: 'udp', address: '127.0.0.1', port: 5353 };
149
+
150
+ const response = await dnsHandler(req);
151
+ const tlsa = response.answers.find(a => a.type === 52);
152
+ assert.ok(tlsa, 'a TLSA answer should be returned');
153
+
154
+ // Serialize then reparse to confirm dns2 carries the raw RDATA intact.
155
+ const reparsed = Packet.parse(response.toBuffer());
156
+ const rr = reparsed.answers.find(a => a.type === 52);
157
+ assert.ok(rr, 'TLSA record survives wire serialization');
158
+ assert.equal(rr.data.toString('hex'), `030101${certHex}`);
159
+ });
160
+
130
161
  test('dnsHandler resolves CNAME chains', async () => {
131
162
  await flushTestDb();
132
163
  await zoneStore.add('example.com', 'alias', 'CNAME', ['example.com']);
@@ -4,10 +4,16 @@ const test = require('node:test');
4
4
  const assert = require('node:assert/strict');
5
5
  const { Resolver } = require('node:dns').promises;
6
6
 
7
+ const dns2 = require('dns2');
8
+ const Packet = dns2.Packet;
9
+
7
10
  const initDnsServer = require('../lib/dns-server');
11
+ const { parseEdns, finalizeResponse } = initDnsServer.testables;
8
12
  const { zoneStore } = require('../lib/zone-store');
9
13
  const { config, flushTestDb, closeDb } = require('./helpers');
10
14
 
15
+ const EDNS = Packet.TYPE.EDNS;
16
+
11
17
  let servers;
12
18
  let resolver;
13
19
 
@@ -67,3 +73,90 @@ test('returns configured NS records over the wire', async () => {
67
73
  const ns = await resolver.resolveNs('example.com');
68
74
  assert.ok(Array.isArray(ns) && ns.length >= 1);
69
75
  });
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // EDNS / OPT handling (pure, no network)
79
+ // ---------------------------------------------------------------------------
80
+
81
+ const mkResponse = answers => {
82
+ const p = new Packet({});
83
+ p.header = new Packet.Header({ id: 1, qr: 1, aa: 1 });
84
+ p.questions = [{ name: 'example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN }];
85
+ p.answers = answers;
86
+ return p;
87
+ };
88
+
89
+ test('parseEdns reads the DO bit and payload size from an OPT record', () => {
90
+ // eslint-disable-next-line new-cap
91
+ const withOpt = { additionals: [Packet.Resource.EDNS([], { udpPayloadSize: 1232, doFlag: true })] };
92
+ const edns = parseEdns(withOpt);
93
+ assert.equal(edns.hasOpt, true);
94
+ assert.equal(edns.doFlag, true);
95
+ assert.equal(edns.udpPayloadSize, 1232);
96
+
97
+ const noOpt = parseEdns({ additionals: [] });
98
+ assert.equal(noOpt.hasOpt, false);
99
+ assert.equal(noOpt.doFlag, false);
100
+ });
101
+
102
+ test('finalizeResponse adds an OPT to EDNS replies and drops leaked additionals', () => {
103
+ const response = mkResponse([{ name: 'example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN, ttl: 300, address: '1.2.3.4' }]);
104
+ // simulate a leaked inbound OPT plus stray additional
105
+ response.additionals = [
106
+ // eslint-disable-next-line new-cap
107
+ Packet.Resource.EDNS([], { udpPayloadSize: 4096, doFlag: true }),
108
+ { name: 'x', type: Packet.TYPE.A, class: 1, ttl: 1, address: '9.9.9.9' }
109
+ ];
110
+
111
+ const out = finalizeResponse(response, { hasOpt: true, doFlag: true, udpPayloadSize: 4096 }, 'tcp');
112
+ assert.equal(out.additionals.length, 1);
113
+ assert.equal(out.additionals[0].type, EDNS);
114
+ assert.equal(out.additionals[0].doFlag, true);
115
+ });
116
+
117
+ test('finalizeResponse omits OPT when the query had none', () => {
118
+ const response = mkResponse([{ name: 'example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN, ttl: 300, address: '1.2.3.4' }]);
119
+ const out = finalizeResponse(response, { hasOpt: false, doFlag: false, udpPayloadSize: 512 }, 'tcp');
120
+ assert.deepEqual(out.additionals, []);
121
+ });
122
+
123
+ test('finalizeResponse truncates oversized UDP responses with TC set', () => {
124
+ // Many large TXT answers easily exceed the 512-byte floor.
125
+ const answers = [];
126
+ for (let i = 0; i < 20; i++) {
127
+ answers.push({ name: 'example.com', type: Packet.TYPE.TXT, class: Packet.CLASS.IN, ttl: 300, data: ['z'.repeat(200)] });
128
+ }
129
+ const response = mkResponse(answers);
130
+ const out = finalizeResponse(response, { hasOpt: true, doFlag: true, udpPayloadSize: 512 }, 'udp');
131
+ // truncated path returns a serialized Buffer (TC=1, empty body, our OPT)
132
+ assert.ok(Buffer.isBuffer(out));
133
+ const reparsed = Packet.parse(out);
134
+ assert.equal(reparsed.header.tc, 1);
135
+ assert.equal(reparsed.answers.length, 0);
136
+ assert.ok(reparsed.additionals.some(r => r.type === EDNS));
137
+ });
138
+
139
+ test('finalizeResponse caps UDP at our configured size, not the requestor advertised max', () => {
140
+ // A response between our 1232 cap and the 4096 ceiling: a resolver advertising
141
+ // 4096 must still get TC=1, because we never emit a datagram larger than our
142
+ // configured udpPayloadSize (anti-fragmentation), regardless of the advertised max.
143
+ const answers = [];
144
+ for (let i = 0; i < 10; i++) {
145
+ answers.push({ name: 'example.com', type: Packet.TYPE.TXT, class: Packet.CLASS.IN, ttl: 300, data: ['z'.repeat(200)] });
146
+ }
147
+ const response = mkResponse(answers);
148
+ const out = finalizeResponse(response, { hasOpt: true, doFlag: true, udpPayloadSize: 4096 }, 'udp');
149
+ assert.ok(Buffer.isBuffer(out));
150
+ const reparsed = Packet.parse(out);
151
+ assert.equal(reparsed.header.tc, 1, 'response above our configured cap is truncated even when 4096 is advertised');
152
+ assert.equal(reparsed.answers.length, 0);
153
+ });
154
+
155
+ test('finalizeResponse returns a serialized buffer for UDP responses that fit', () => {
156
+ const response = mkResponse([{ name: 'example.com', type: Packet.TYPE.A, class: Packet.CLASS.IN, ttl: 300, address: '1.2.3.4' }]);
157
+ const out = finalizeResponse(response, { hasOpt: true, doFlag: false, udpPayloadSize: 1232 }, 'udp');
158
+ assert.ok(Buffer.isBuffer(out));
159
+ // reparse to confirm the OPT made it onto the wire
160
+ const reparsed = Packet.parse(out);
161
+ assert.ok(reparsed.additionals.some(r => r.type === EDNS));
162
+ });