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.
- package/.github/codeql/codeql-config.yml +11 -0
- package/.github/workflows/codeql.yml +52 -0
- package/.github/workflows/deploy.yml +16 -3
- package/.github/workflows/release.yaml +43 -0
- package/.github/workflows/test.yml +75 -0
- package/.release-please-manifest.json +3 -0
- package/CHANGELOG.md +16 -0
- package/CLAUDE.md +109 -0
- package/README.md +111 -9
- package/SECURITY.md +88 -0
- package/SECURITY.txt +27 -0
- package/bin/pending-dns.js +1 -1
- package/config/default.toml +43 -0
- package/config/test.toml +35 -0
- package/eslint.config.js +38 -0
- package/lib/api-server.js +198 -23
- package/lib/cached-resolver.js +5 -3
- package/lib/certs.js +12 -20
- package/lib/dns-handler.js +362 -32
- package/lib/dns-server.js +120 -43
- package/lib/dns-tcp-server.js +1 -1
- package/lib/dns-udp-server.js +1 -1
- package/lib/dnssec-wire.js +321 -0
- package/lib/dnssec.js +461 -0
- package/lib/lock.js +37 -0
- package/lib/logger.js +3 -0
- package/lib/public-server.js +20 -2
- package/lib/sentry.js +72 -0
- package/lib/tools.js +1 -1
- package/lib/zone-store.js +90 -7
- package/package.json +46 -33
- package/release-please-config.json +14 -0
- package/server.js +5 -24
- package/systemd/pending-dns.service +4 -4
- package/test/api.test.js +231 -0
- package/test/cached-resolver.test.js +57 -0
- package/test/certs.test.js +34 -0
- package/test/dns-handler.test.js +171 -0
- package/test/dns-server.test.js +162 -0
- package/test/dnssec-handler.test.js +550 -0
- package/test/dnssec-wire.test.js +163 -0
- package/test/dnssec.test.js +213 -0
- package/test/helpers.js +27 -0
- package/test/sentry.test.js +21 -0
- package/test/tools.test.js +48 -0
- package/test/zone-store.test.js +245 -0
- package/workers/api.js +3 -1
- package/workers/dns.js +2 -24
- package/workers/health.js +3 -26
- package/workers/public.js +3 -25
- package/.eslintrc +0 -14
- package/Gruntfile.js +0 -16
package/lib/dns-handler.js
CHANGED
|
@@ -3,24 +3,32 @@
|
|
|
3
3
|
const config = require('wild-config');
|
|
4
4
|
const dns2 = require('dns2');
|
|
5
5
|
const punycode = require('punycode/');
|
|
6
|
-
const { zoneStore } = require('./zone-store');
|
|
6
|
+
const { zoneStore, tlsaFromValue } = require('./zone-store');
|
|
7
7
|
const cachedResolver = require('./cached-resolver');
|
|
8
8
|
const ipaddr = require('ipaddr.js');
|
|
9
9
|
const logger = require('./logger').child({ component: 'dns-handler' });
|
|
10
10
|
const { normalizeDomain } = require('./tools');
|
|
11
|
-
const {
|
|
11
|
+
const { randomUUID } = require('crypto');
|
|
12
|
+
const wire = require('./dnssec-wire');
|
|
13
|
+
const dnssec = require('./dnssec');
|
|
12
14
|
|
|
13
|
-
// Split
|
|
15
|
+
// Split a TXT value into DNS character-strings (max 255 bytes each).
|
|
16
|
+
// Always returns an array, which is what the dns2 packet builder expects.
|
|
14
17
|
const formatTXTData = data => {
|
|
15
18
|
data = (data || '').toString();
|
|
16
|
-
if (data.length
|
|
17
|
-
return
|
|
19
|
+
if (!data.length) {
|
|
20
|
+
return [''];
|
|
18
21
|
}
|
|
19
|
-
return Array.from(data.match(/.{1,
|
|
22
|
+
return Array.from(data.match(/.{1,255}/g));
|
|
20
23
|
};
|
|
21
24
|
|
|
25
|
+
// dns2's Packet.TYPE is missing several DNSSEC/DANE types (TLSA, RRSIG, NSEC,
|
|
26
|
+
// DS, ...). Merge those in so we can recognize such queries and serialize the
|
|
27
|
+
// answers via dns2's raw-RDATA fallback.
|
|
28
|
+
const typeToNumber = Object.assign({}, wire.EXTRA_TYPES, dns2.Packet.TYPE);
|
|
29
|
+
|
|
22
30
|
// Helps to convert DNS type integer into a string (0x01 -> 'A')
|
|
23
|
-
const reversedTypes = new Map(Object.keys(
|
|
31
|
+
const reversedTypes = new Map(Object.keys(typeToNumber).map(key => [typeToNumber[key], key]));
|
|
24
32
|
|
|
25
33
|
const shuffle = array => {
|
|
26
34
|
let currentIndex = array.length,
|
|
@@ -57,6 +65,30 @@ const filterUnhealthy = list => {
|
|
|
57
65
|
return list.filter(entry => !entry.health || entry.health.status === true);
|
|
58
66
|
};
|
|
59
67
|
|
|
68
|
+
// SOA MNAME (primary) - first configured nameserver, with safe fallbacks so an
|
|
69
|
+
// empty [[ns]] config cannot throw on the synthesis / signing paths.
|
|
70
|
+
const soaPrimary = () => (config.ns && config.ns.length && config.ns[0].domain) || config.soa.admin || 'localhost';
|
|
71
|
+
|
|
72
|
+
// Single SOA record builder shared by the processQuestion synthesis and the
|
|
73
|
+
// DNSSEC authority-section record, so the two cannot drift apart. `extra` lets
|
|
74
|
+
// the authority variant set a numeric type/class/ttl; the synthesized answer
|
|
75
|
+
// keeps the string type and is normalized later.
|
|
76
|
+
const buildSoaRecord = (name, extra) =>
|
|
77
|
+
Object.assign(
|
|
78
|
+
{
|
|
79
|
+
name,
|
|
80
|
+
type: 'SOA',
|
|
81
|
+
primary: soaPrimary(),
|
|
82
|
+
admin: config.soa.admin,
|
|
83
|
+
serial: config.soa.serial,
|
|
84
|
+
refresh: config.soa.refresh,
|
|
85
|
+
retry: config.soa.retry,
|
|
86
|
+
expiration: config.soa.expiration,
|
|
87
|
+
minimum: config.soa.minimum
|
|
88
|
+
},
|
|
89
|
+
extra || {}
|
|
90
|
+
);
|
|
91
|
+
|
|
60
92
|
const processQuestion = async (response, question, domain, depth) => {
|
|
61
93
|
depth = depth || 0;
|
|
62
94
|
domain = normalizeDomain(domain || question.name);
|
|
@@ -91,7 +123,7 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
91
123
|
if (records && records.length > 1) {
|
|
92
124
|
switch (type) {
|
|
93
125
|
case 'A':
|
|
94
|
-
case '
|
|
126
|
+
case 'AAAA':
|
|
95
127
|
// randomize A/AAAA records
|
|
96
128
|
records = shuffle(filterUnhealthy(records));
|
|
97
129
|
break;
|
|
@@ -110,17 +142,32 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
110
142
|
|
|
111
143
|
if (!dnsEntries || !dnsEntries.length) {
|
|
112
144
|
if (questionTypeStr === 'NS') {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
145
|
+
// Only synthesize fallback NS at the zone apex (or for names outside any
|
|
146
|
+
// served zone, preserving the legacy answer-for-anything behavior). A
|
|
147
|
+
// below-apex NS-without-SOA is the RFC 4034 4.1.3 insecure-delegation
|
|
148
|
+
// signal; served unsigned it makes a DO query bogus, so leave it empty
|
|
149
|
+
// there and let signResponse turn it into a signed NODATA (SOA+NSEC).
|
|
150
|
+
// This intentionally changes the old answer-for-anything behavior for
|
|
151
|
+
// ALL clients (not just DO ones): a record-less below-apex NS query now
|
|
152
|
+
// returns empty NOERROR rather than the configured nameservers. That is
|
|
153
|
+
// the DNSSEC-correct choice and is kept uniform to avoid DO/non-DO skew.
|
|
154
|
+
// resolveDomainZone returns the Unicode form, directly comparable to the
|
|
155
|
+
// already-normalized `domain`. The lookup is only reached on a
|
|
156
|
+
// record-less NS query, so the extra Redis walk is rare.
|
|
157
|
+
let zone = await zoneStore.resolveDomainZone(domain);
|
|
158
|
+
if (!zone || zone === domain) {
|
|
159
|
+
for (let ns of config.ns || []) {
|
|
160
|
+
let entry = {
|
|
161
|
+
name: domain,
|
|
162
|
+
type: 'NS',
|
|
163
|
+
ns: ns.domain
|
|
164
|
+
};
|
|
165
|
+
response.answers.push(entry);
|
|
166
|
+
}
|
|
120
167
|
}
|
|
121
168
|
}
|
|
122
169
|
|
|
123
|
-
for (let ns of config.ns) {
|
|
170
|
+
for (let ns of config.ns || []) {
|
|
124
171
|
if (questionTypeStr === 'A' && domain === ns.domain) {
|
|
125
172
|
let entry = {
|
|
126
173
|
name: domain,
|
|
@@ -150,18 +197,7 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
150
197
|
}
|
|
151
198
|
|
|
152
199
|
if (questionTypeStr === 'SOA') {
|
|
153
|
-
|
|
154
|
-
name: domain,
|
|
155
|
-
type: 'SOA',
|
|
156
|
-
primary: config.ns[0].domain,
|
|
157
|
-
admin: config.soa.admin,
|
|
158
|
-
serial: config.soa.serial,
|
|
159
|
-
refresh: config.soa.refresh,
|
|
160
|
-
retry: config.soa.retry,
|
|
161
|
-
expiration: config.soa.expiration,
|
|
162
|
-
minimum: config.soa.minimum
|
|
163
|
-
};
|
|
164
|
-
response.answers.push(entry);
|
|
200
|
+
response.answers.push(buildSoaRecord(domain));
|
|
165
201
|
}
|
|
166
202
|
|
|
167
203
|
// Chaos responses
|
|
@@ -275,7 +311,8 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
275
311
|
try {
|
|
276
312
|
entry.address = ipaddr.parse(value[0]).toNormalizedString();
|
|
277
313
|
} catch (err) {
|
|
278
|
-
|
|
314
|
+
// skip just this malformed record, keep processing the rest
|
|
315
|
+
continue;
|
|
279
316
|
}
|
|
280
317
|
break;
|
|
281
318
|
|
|
@@ -338,6 +375,17 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
338
375
|
entry.flags = (value[2] && Number(value[2])) || 0;
|
|
339
376
|
break;
|
|
340
377
|
|
|
378
|
+
case 'TLSA':
|
|
379
|
+
// dns2 has no TLSA encoder; emit pre-built raw RDATA (RFC 6698).
|
|
380
|
+
try {
|
|
381
|
+
entry.data = wire.encodeTLSARdata(tlsaFromValue(value));
|
|
382
|
+
} catch (err) {
|
|
383
|
+
// skip a malformed stored TLSA record, keep processing the rest
|
|
384
|
+
logger.error({ msg: 'Skipping malformed TLSA record', id: response._id, domain: dnsEntry.domain, err });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
break;
|
|
388
|
+
|
|
341
389
|
default:
|
|
342
390
|
// skip unknown types
|
|
343
391
|
entry = false;
|
|
@@ -348,6 +396,11 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
348
396
|
continue;
|
|
349
397
|
}
|
|
350
398
|
|
|
399
|
+
if (dnsEntry.wildcard) {
|
|
400
|
+
// carried for DNSSEC signing (RRSIG labels); ignored by the dns2 encoder
|
|
401
|
+
entry.wildcard = dnsEntry.wildcard;
|
|
402
|
+
}
|
|
403
|
+
|
|
351
404
|
response.answers.push(entry);
|
|
352
405
|
|
|
353
406
|
if (depth < 10 && dnsEntry.type === 'CNAME' && questionTypeStr !== 'CNAME') {
|
|
@@ -357,10 +410,259 @@ const processQuestion = async (response, question, domain, depth) => {
|
|
|
357
410
|
}
|
|
358
411
|
};
|
|
359
412
|
|
|
360
|
-
|
|
413
|
+
// --- DNSSEC online signing -------------------------------------------------
|
|
414
|
+
|
|
415
|
+
const numericType = t => (typeof t === 'number' ? t : typeToNumber[t]);
|
|
416
|
+
|
|
417
|
+
// NSEC type bitmap (numeric types) for a name. `excludeTypeNum` (optional) is the
|
|
418
|
+
// numeric type this proof denies and must never be listed (see the final filter).
|
|
419
|
+
// The apex additionally serves NS/SOA/DNSKEY; RRSIG+NSEC always cover a signed name.
|
|
420
|
+
const bitmapTypeNums = (existing, isApex, excludeTypeNum) => {
|
|
421
|
+
const hosts = (config.public && config.public.hosts) || {};
|
|
422
|
+
const names = new Set();
|
|
423
|
+
for (const type of existing) {
|
|
424
|
+
if (type === 'URL') {
|
|
425
|
+
// A URL record answers A/AAAA only from config.public.hosts. List a
|
|
426
|
+
// family only when it is actually configured (hence answerable):
|
|
427
|
+
// advertising AAAA while public.hosts.AAAA is empty would contradict
|
|
428
|
+
// the NODATA the server returns for AAAA at a URL name and make a
|
|
429
|
+
// validating resolver treat the answer as bogus (SERVFAIL).
|
|
430
|
+
if (hosts.A && hosts.A.length) {
|
|
431
|
+
names.add('A');
|
|
432
|
+
}
|
|
433
|
+
if (hosts.AAAA && hosts.AAAA.length) {
|
|
434
|
+
names.add('AAAA');
|
|
435
|
+
}
|
|
436
|
+
} else if (type === 'ANAME') {
|
|
437
|
+
// ANAME answerable families are dynamic (live resolution of the target),
|
|
438
|
+
// so list both; excludeTypeNum drops whichever one this query proved
|
|
439
|
+
// absent so the proof can never contradict the answer.
|
|
440
|
+
names.add('A');
|
|
441
|
+
names.add('AAAA');
|
|
442
|
+
} else {
|
|
443
|
+
// Stored types pass through, including a real below-apex NS: that is a
|
|
444
|
+
// genuine delegation and is correctly listed. Only the *synthesized*
|
|
445
|
+
// apex NS is added in the isApex block below.
|
|
446
|
+
names.add(type);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// The server synthesizes CAA and SOA for ANY name, so every signed name
|
|
450
|
+
// effectively has them; list them so aggressive NSEC (RFC 8198) does not
|
|
451
|
+
// synthesize a NODATA that suppresses those answers. SOA alone (without NS)
|
|
452
|
+
// does not imply a zone cut, so it is safe at non-apex names. Synthesized NS is
|
|
453
|
+
// withheld below the apex on purpose: an NS-without-SOA is the RFC 4034 4.1.3
|
|
454
|
+
// insecure-delegation signal and would mislead a validator into treating the
|
|
455
|
+
// name as a delegation point.
|
|
456
|
+
names.add('SOA');
|
|
457
|
+
names.add('CAA');
|
|
458
|
+
names.add('RRSIG');
|
|
459
|
+
names.add('NSEC');
|
|
460
|
+
if (isApex) {
|
|
461
|
+
names.add('NS');
|
|
462
|
+
names.add('DNSKEY');
|
|
463
|
+
}
|
|
464
|
+
// Never list the type this proof denies. In the NODATA branch the queried type
|
|
465
|
+
// produced no answer; in the wildcard branch the answer came from the wildcard
|
|
466
|
+
// owner (the RRSIG Labels field signals the expansion). Listing it at the exact
|
|
467
|
+
// name would make the proof self-contradictory and validators would reject it.
|
|
468
|
+
return [...names].map(numericType).filter(n => typeof n === 'number' && n !== excludeTypeNum);
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const soaAuthorityRecord = zone => buildSoaRecord(zone, { type: dns2.Packet.TYPE.SOA, class: dns2.Packet.CLASS.IN, ttl: config.soa.minimum });
|
|
472
|
+
|
|
473
|
+
// Compact "black lie" NSEC: owner = the queried name, next name sorts directly
|
|
474
|
+
// after it (\000.<name>) so the record covers only this name and cannot be used
|
|
475
|
+
// to deny any other (safe for aggressive NSEC caching).
|
|
476
|
+
const nsecRecord = (owner, typeNums, ttl) => ({
|
|
477
|
+
name: owner,
|
|
478
|
+
type: wire.TYPE.NSEC,
|
|
479
|
+
class: dns2.Packet.CLASS.IN,
|
|
480
|
+
ttl,
|
|
481
|
+
data: wire.encodeNSECRdata(`\x00.${owner}`, typeNums)
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
// A question is positively answered when the answer section holds a record of
|
|
485
|
+
// the queried type (or a CNAME) AT THE QUERIED NAME. Scoping to `qname` keeps a
|
|
486
|
+
// multi-question packet from letting one question's answer mask another's
|
|
487
|
+
// missing answer (which would suppress the latter's denial proof).
|
|
488
|
+
const hasPositiveAnswer = (response, question, qname) =>
|
|
489
|
+
// For ANY, "positive" means any (non-RRSIG) record exists at the name - there is
|
|
490
|
+
// no record whose type literally equals ANY. With no record present the query is
|
|
491
|
+
// a true NODATA and must still get a signed denial, so do not short-circuit.
|
|
492
|
+
question.type === dns2.Packet.TYPE.ANY
|
|
493
|
+
? response.answers.some(rr => rr.name === qname && rr.type !== wire.TYPE.RRSIG)
|
|
494
|
+
: response.answers.some(rr => rr.name === qname && (rr.type === question.type || rr.type === dns2.Packet.TYPE.CNAME));
|
|
495
|
+
|
|
496
|
+
// Sign every in-zone RRset in a section, appending the RRSIG records. Returns
|
|
497
|
+
// true if anything was signed.
|
|
498
|
+
const signSection = async (section, zoneFor, signerFor) => {
|
|
499
|
+
const groups = new Map();
|
|
500
|
+
for (let rr of section) {
|
|
501
|
+
if (rr.type === wire.TYPE.RRSIG) {
|
|
502
|
+
continue; // never sign RRSIGs
|
|
503
|
+
}
|
|
504
|
+
let key = `${rr.name}\x00${rr.type}`;
|
|
505
|
+
if (!groups.has(key)) {
|
|
506
|
+
groups.set(key, []);
|
|
507
|
+
}
|
|
508
|
+
groups.get(key).push(rr);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
let rrsigs = [];
|
|
512
|
+
for (let rrs of groups.values()) {
|
|
513
|
+
let owner = rrs[0].name;
|
|
514
|
+
let zone = await zoneFor(owner); // A-label form (canonicalized in zoneFor)
|
|
515
|
+
if (!zone) {
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
518
|
+
// Never sign a delegation NS RRset: an NS below the apex marks a zone cut
|
|
519
|
+
// and is non-authoritative referral data the parent must not sign
|
|
520
|
+
// (RFC 4035 2.2). Apex NS (owner === zone) is authoritative and is signed.
|
|
521
|
+
if (rrs[0].type === wire.TYPE.NS && owner !== zone) {
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
let signer = await signerFor(zone);
|
|
525
|
+
if (!signer) {
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
// For a wildcard expansion the signature is computed over the wildcard
|
|
529
|
+
// owner (`*.zone`, canonical A-label form), not the expanded wire name, so
|
|
530
|
+
// a validator can reconstruct it (RFC 4035 5.3.2). signRRset returns one
|
|
531
|
+
// RRSIG per signing key (one per algorithm in the zone).
|
|
532
|
+
let wildcard = rrs.find(rr => rr.wildcard);
|
|
533
|
+
let signingOwner = wildcard ? punycode.toASCII(wildcard.wildcard) : owner;
|
|
534
|
+
rrsigs.push(...dnssec.signRRset(signer, owner, signingOwner, rrs[0].type, rrs[0].ttl, rrs));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
for (let sig of rrsigs) {
|
|
538
|
+
section.push(sig);
|
|
539
|
+
}
|
|
540
|
+
return rrsigs.length > 0;
|
|
541
|
+
};
|
|
542
|
+
|
|
543
|
+
// Add DNSKEY answers, denial-of-existence proofs, and RRSIGs to a response that
|
|
544
|
+
// is already normalized. Only invoked when the client set the DO bit.
|
|
545
|
+
const signResponse = async (request, response) => {
|
|
546
|
+
// NSEC / negative-cache TTL tracks the SOA MINIMUM so the denial proof and
|
|
547
|
+
// the negative answer expire together in caches (RFC 2308). typeof guard so a
|
|
548
|
+
// configured SOA minimum of 0 is honored rather than falling back to 300.
|
|
549
|
+
const soaMinimum = config.soa && config.soa.minimum;
|
|
550
|
+
const nsecTtl = typeof soaMinimum === 'number' ? soaMinimum : 300;
|
|
551
|
+
|
|
552
|
+
// Emit at most one SOA per zone and one NSEC per owner across all questions: a
|
|
553
|
+
// multi-question packet must not produce duplicate - or, in the wildcard case,
|
|
554
|
+
// contradictory - denial records that signSection would then group and sign as
|
|
555
|
+
// one inconsistent RRset. First-wins is correct: an owner has exactly one NSEC
|
|
556
|
+
// RRset, and either question's bitmap is a valid black-lie for that owner (do
|
|
557
|
+
// not merge bitmaps - folding a wildcard-answered type back in would make the
|
|
558
|
+
// wildcard proof bogus).
|
|
559
|
+
//
|
|
560
|
+
// Residual (intentional, do not "fix"): with QDCOUNT >= 2 for the same owner,
|
|
561
|
+
// only the first question's denied type is excluded from the shared NSEC, so a
|
|
562
|
+
// second same-owner NODATA of a different type could leave that type listed.
|
|
563
|
+
// Multi-question packets are effectively never sent in practice, and the
|
|
564
|
+
// single-question path (the norm) is fully covered by the excludeTypeNum below.
|
|
565
|
+
const soaDone = new Set();
|
|
566
|
+
const nsecDone = new Set();
|
|
567
|
+
const addSoaOnce = zone => {
|
|
568
|
+
if (!soaDone.has(zone)) {
|
|
569
|
+
soaDone.add(zone);
|
|
570
|
+
response.authorities.push(soaAuthorityRecord(zone));
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
const pushNsec = (owner, types, isApex, excludeTypeNum) => {
|
|
574
|
+
if (nsecDone.has(owner)) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
nsecDone.add(owner);
|
|
578
|
+
response.authorities.push(nsecRecord(owner, bitmapTypeNums(types, isApex, excludeTypeNum), nsecTtl));
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
// Memoize the two per-zone lookups across the question loop and both
|
|
582
|
+
// signing passes: resolveDomainZone walks the label hierarchy (several
|
|
583
|
+
// Redis round-trips) and getSigner reads + parses the zone keys.
|
|
584
|
+
const zoneCache = new Map();
|
|
585
|
+
const zoneFor = async name => {
|
|
586
|
+
if (!zoneCache.has(name)) {
|
|
587
|
+
let zone = await zoneStore.resolveDomainZone(name);
|
|
588
|
+
// Canonicalize to the A-label form once here. resolveDomainZone returns
|
|
589
|
+
// the Unicode form (lib/certs.js relies on that), but every DNSSEC
|
|
590
|
+
// consumer - the apex test, the SOA/signer name, the signerFor cache
|
|
591
|
+
// key - needs punycode, so normalize at the cache boundary.
|
|
592
|
+
zoneCache.set(name, zone ? punycode.toASCII(zone) : zone);
|
|
593
|
+
}
|
|
594
|
+
return zoneCache.get(name);
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const signerCache = new Map();
|
|
598
|
+
const signerFor = async zone => {
|
|
599
|
+
if (!signerCache.has(zone)) {
|
|
600
|
+
signerCache.set(zone, await dnssec.getSigner(zone));
|
|
601
|
+
}
|
|
602
|
+
return signerCache.get(zone);
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
for (let question of request.questions) {
|
|
606
|
+
let qname = punycode.toASCII(normalizeDomain(question.name));
|
|
607
|
+
let zone = await zoneFor(qname); // A-label form (canonicalized in zoneFor)
|
|
608
|
+
if (!zone) {
|
|
609
|
+
continue;
|
|
610
|
+
}
|
|
611
|
+
let signer = await signerFor(zone);
|
|
612
|
+
if (!signer) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// DNSKEY at the apex: serve and self-sign the key set. Records arrive
|
|
617
|
+
// ready to sign; only the post-normalization fields need finishing.
|
|
618
|
+
// By design DNSKEY answers are produced only here, on the DO path: this is
|
|
619
|
+
// an online signer, so a non-DO client gets the same empty NOERROR it would
|
|
620
|
+
// for any signing-only type (a validator always sets DO before asking).
|
|
621
|
+
if (question.type === wire.TYPE.DNSKEY && qname === zone) {
|
|
622
|
+
for (let rr of dnssec.buildDnskeyRecords(signer)) {
|
|
623
|
+
rr.type = wire.TYPE.DNSKEY;
|
|
624
|
+
rr.name = punycode.toASCII(rr.name);
|
|
625
|
+
response.answers.push(rr);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
let wildcardAnswer = response.answers.find(rr => rr.wildcard && rr.name === qname);
|
|
630
|
+
|
|
631
|
+
if (!hasPositiveAnswer(response, question, qname)) {
|
|
632
|
+
// NODATA, always NOERROR ("black lies"). Because the server
|
|
633
|
+
// synthesizes CAA/NS/SOA for every name, no name is truly nonexistent,
|
|
634
|
+
// so NXDOMAIN would be incorrect; prove the absence of the queried
|
|
635
|
+
// type with SOA + a compact NSEC at the queried name. Below the apex,
|
|
636
|
+
// fold in the types a single-level wildcard could answer so an RFC 8198
|
|
637
|
+
// aggressive-NSEC resolver cannot cache this NSEC and synthesize a
|
|
638
|
+
// NODATA that suppresses the wildcard.
|
|
639
|
+
addSoaOnce(zone);
|
|
640
|
+
pushNsec(qname, await zoneStore.existingTypes(qname, qname !== zone), qname === zone, question.type);
|
|
641
|
+
} else if (wildcardAnswer) {
|
|
642
|
+
// Wildcard expansion: prove the exact name had no direct match. This
|
|
643
|
+
// NSEC MUST list only the exact-name types, never the wildcard-supplied
|
|
644
|
+
// type - the RRSIG Labels field signals the expansion, and listing the
|
|
645
|
+
// answered type here would make the wildcard proof bogus. Residual
|
|
646
|
+
// (inherent to compact black-lies online signing): an aggressive-NSEC
|
|
647
|
+
// resolver caching this NSEC may suppress a later same-name/same-type
|
|
648
|
+
// query; the minimally-covering next-name (\x00.<owner>) bounds it to
|
|
649
|
+
// this exact name. Matches Cloudflare/Knot/PowerDNS online signers.
|
|
650
|
+
pushNsec(qname, await zoneStore.existingTypes(qname), false, question.type);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
await signSection(response.answers, zoneFor, signerFor);
|
|
655
|
+
await signSection(response.authorities, zoneFor, signerFor);
|
|
656
|
+
|
|
657
|
+
// The AD bit is intentionally not set: it is a validating-resolver signal
|
|
658
|
+
// (RFC 6840 5.7), not something an authoritative server asserts.
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const dnsHandler = async (request, edns) => {
|
|
662
|
+
edns = edns || { hasOpt: false, doFlag: false };
|
|
361
663
|
let startTime = Date.now();
|
|
362
664
|
const response = new dns2.Packet(request);
|
|
363
|
-
request._id = response._id =
|
|
665
|
+
request._id = response._id = randomUUID();
|
|
364
666
|
|
|
365
667
|
response.header.qr = 1;
|
|
366
668
|
response.header.aa = 1;
|
|
@@ -393,14 +695,42 @@ const dnsHandler = async request => {
|
|
|
393
695
|
// normalize answers for the DNS library
|
|
394
696
|
for (let responseType of [response.answers, response.authorities]) {
|
|
395
697
|
responseType.forEach(answer => {
|
|
396
|
-
|
|
698
|
+
// numericType (via typeToNumber) also covers TLSA/RRSIG/NSEC/DS, which
|
|
699
|
+
// dns2.Packet.TYPE does not know about and would normalize to undefined.
|
|
700
|
+
answer.type = numericType(answer.type);
|
|
397
701
|
answer.class = typeof answer.class === 'number' ? answer.class : dns2.Packet.CLASS.IN;
|
|
398
702
|
answer.name = punycode.toASCII(answer.name);
|
|
399
703
|
answer.ttl = typeof answer.ttl === 'number' && answer.ttl >= 0 ? answer.ttl : config.dns.ttl;
|
|
400
704
|
});
|
|
401
705
|
}
|
|
402
706
|
|
|
707
|
+
// DNSSEC: only when signing is globally enabled, the client signalled
|
|
708
|
+
// support (DO bit), and the zone is signed (the per-zone gate is in
|
|
709
|
+
// getSigner). Clients that do not set DO get unsigned answers. (Non-EDNS UDP
|
|
710
|
+
// responses over 512 bytes are truncated with TC=1 per RFC 1035 - see
|
|
711
|
+
// finalizeResponse in dns-server.js - so they are not byte-identical to the
|
|
712
|
+
// pre-DNSSEC server for large answers.)
|
|
713
|
+
if (edns.doFlag && config.dnssec && config.dnssec.enabled) {
|
|
714
|
+
// Never let a signing failure drop the whole response: dns-server's send
|
|
715
|
+
// error path returns nothing, so an uncaught throw here would time the
|
|
716
|
+
// client out. On error, restore the pre-signing (unsigned but consistent)
|
|
717
|
+
// sections and return them - a validator treats the unsigned reply as
|
|
718
|
+
// bogus/insecure, which is strictly better than no answer at all.
|
|
719
|
+
const savedAnswers = response.answers.slice();
|
|
720
|
+
const savedAuthorities = response.authorities.slice();
|
|
721
|
+
try {
|
|
722
|
+
await signResponse(request, response);
|
|
723
|
+
} catch (err) {
|
|
724
|
+
logger.error({ msg: 'Failed to sign DNS response', id: request._id, err });
|
|
725
|
+
response.answers = savedAnswers;
|
|
726
|
+
response.authorities = savedAuthorities;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
403
730
|
return response;
|
|
404
731
|
};
|
|
405
732
|
|
|
406
733
|
module.exports = dnsHandler;
|
|
734
|
+
|
|
735
|
+
// Exposed for unit testing
|
|
736
|
+
module.exports.testables = { formatTXTData, shuffle, filterUnhealthy, reversedTypes, processQuestion, signResponse, bitmapTypeNums };
|
package/lib/dns-server.js
CHANGED
|
@@ -7,68 +7,145 @@ const logger = require('./logger').child({ component: 'dns-server' });
|
|
|
7
7
|
const { createDNSTcpServer } = require('./dns-tcp-server');
|
|
8
8
|
const { createDNSUdpServer } = require('./dns-udp-server');
|
|
9
9
|
|
|
10
|
-
const
|
|
11
|
-
0x29 // EDNS is supported by the dns module
|
|
12
|
-
]);
|
|
10
|
+
const EDNS = dns2.Packet.TYPE.EDNS; // 0x29 / 41
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
// EDNS UDP payload size bounds. Anything a requestor advertises is clamped into
|
|
13
|
+
// this range; the floor is the pre-EDNS 512 minimum.
|
|
14
|
+
const MIN_UDP_PAYLOAD = 512;
|
|
15
|
+
const MAX_UDP_PAYLOAD = 4096;
|
|
16
|
+
|
|
17
|
+
const ourUdpPayloadSize = () => (config.dnssec && config.dnssec.udpPayloadSize) || 1232;
|
|
18
|
+
|
|
19
|
+
// Extract the EDNS context from a request: whether an OPT was present, the DO
|
|
20
|
+
// (DNSSEC OK) bit, and the requestor's advertised UDP payload size. dns2 has
|
|
21
|
+
// already decoded doFlag and stored the payload size in `class`.
|
|
22
|
+
const parseEdns = request => {
|
|
23
|
+
const opt = request.additionals && request.additionals.find(record => record && record.type === EDNS);
|
|
24
|
+
return {
|
|
25
|
+
hasOpt: !!opt,
|
|
26
|
+
doFlag: !!(opt && opt.doFlag),
|
|
27
|
+
udpPayloadSize: opt ? opt.class : MIN_UDP_PAYLOAD
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const buildOpt = doFlag =>
|
|
32
|
+
// eslint-disable-next-line new-cap
|
|
33
|
+
dns2.Packet.Resource.EDNS([], {
|
|
34
|
+
udpPayloadSize: ourUdpPayloadSize(),
|
|
35
|
+
doFlag,
|
|
36
|
+
version: 0
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// The additional section we emit: our own OPT for EDNS queries, nothing
|
|
40
|
+
// otherwise (responses to EDNS queries must carry an OPT - RFC 6891).
|
|
41
|
+
const optSection = edns => (edns.hasOpt ? [buildOpt(edns.doFlag)] : []);
|
|
42
|
+
|
|
43
|
+
// Strip a packet to header + question + OPT and set TC, so the resolver retries
|
|
44
|
+
// over TCP. A partial answer would be a protocol error.
|
|
45
|
+
const truncate = (packet, edns) => {
|
|
46
|
+
packet.header.tc = 1;
|
|
47
|
+
packet.answers = [];
|
|
48
|
+
packet.authorities = [];
|
|
49
|
+
packet.additionals = optSection(edns);
|
|
50
|
+
return packet;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Finalize a response before sending: replace the additional section with our
|
|
54
|
+
// own OPT, and for UDP truncate to the negotiated size. Returns a Buffer
|
|
55
|
+
// (already serialized) or a Packet; both are accepted by send.
|
|
56
|
+
const finalizeResponse = (response, edns, proto) => {
|
|
57
|
+
// The response object is the same instance as the request (dns2.Packet
|
|
58
|
+
// returns its argument), so the inbound OPT and any request additionals
|
|
59
|
+
// leak in here - replace the section outright rather than appending.
|
|
60
|
+
response.additionals = optSection(edns);
|
|
61
|
+
|
|
62
|
+
if (proto !== 'udp') {
|
|
63
|
+
// TCP carries arbitrarily large messages; never truncate.
|
|
64
|
+
return response;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cap the datagram at the smaller of (the requestor's advertised size, clamped
|
|
68
|
+
// to [512, 4096]) and our own configured size (default 1232, chosen to avoid IP
|
|
69
|
+
// fragmentation). Honoring our cap regardless of what the resolver advertises is
|
|
70
|
+
// the point: a 4096-advertising resolver must still get TC=1 above 1232 rather
|
|
71
|
+
// than a fragmenting datagram that middleboxes drop.
|
|
72
|
+
const requestorMax = Math.min(Math.max(edns.udpPayloadSize || MIN_UDP_PAYLOAD, MIN_UDP_PAYLOAD), MAX_UDP_PAYLOAD);
|
|
73
|
+
const negotiated = Math.min(requestorMax, ourUdpPayloadSize());
|
|
74
|
+
const buffer = response.toBuffer();
|
|
75
|
+
if (buffer.length <= negotiated) {
|
|
76
|
+
return buffer;
|
|
77
|
+
}
|
|
78
|
+
// Too large for UDP: truncate to header+question+OPT (TC=1) and serialize the
|
|
79
|
+
// small packet here, so the UDP path always returns a ready Buffer and send
|
|
80
|
+
// never re-serializes.
|
|
81
|
+
return truncate(response, edns).toBuffer();
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Last-resort truncation if the socket itself rejects an oversized datagram.
|
|
85
|
+
const truncatedResponse = (request, edns) => {
|
|
86
|
+
request.header.qr = 1;
|
|
87
|
+
request.header.aa = 1;
|
|
88
|
+
return truncate(request, edns);
|
|
89
|
+
};
|
|
19
90
|
|
|
20
91
|
const init = async () => {
|
|
21
92
|
// create UDP server
|
|
22
|
-
createDNSUdpServer((request, send) => {
|
|
23
|
-
|
|
24
|
-
if (request.additionals && request.additionals.length) {
|
|
25
|
-
request.additionals = request.additionals.filter(additional => SUPPORTED_TYPES.has(additional.type));
|
|
26
|
-
}
|
|
93
|
+
const udpServer = createDNSUdpServer((request, send) => {
|
|
94
|
+
const edns = parseEdns(request);
|
|
27
95
|
|
|
28
|
-
dnsHandler(request)
|
|
29
|
-
.then(send)
|
|
96
|
+
dnsHandler(request, edns)
|
|
97
|
+
.then(response => send(finalizeResponse(response, edns, 'udp')))
|
|
30
98
|
.catch(err => {
|
|
31
99
|
if (err.code === 'EMSGSIZE') {
|
|
32
|
-
//too large response,
|
|
33
|
-
|
|
34
|
-
response.header.qr = 1;
|
|
35
|
-
response.header.aa = 1;
|
|
36
|
-
send(response).catch(err => logger.error({ msg: 'Failed to send empty response', err }));
|
|
100
|
+
// too large response, fall back to a truncated reply
|
|
101
|
+
send(truncatedResponse(request, edns)).catch(err => logger.error({ msg: 'Failed to send truncated response', err }));
|
|
37
102
|
return;
|
|
38
103
|
}
|
|
39
104
|
logger.error({ msg: 'Failed to send DNS response', protocol: 'udp', err });
|
|
40
105
|
});
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
logger.error({ msg: 'DNS server error', protocol: 'udp', err });
|
|
47
|
-
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
udpServer.on('error', err => {
|
|
109
|
+
logger.error({ msg: 'DNS server error', protocol: 'udp', err });
|
|
110
|
+
});
|
|
48
111
|
|
|
49
112
|
// create TCP server
|
|
50
|
-
createDNSTcpServer((request, send) => {
|
|
51
|
-
|
|
52
|
-
if (request.additionals && request.additionals.length) {
|
|
53
|
-
request.additionals = request.additionals.filter(additional => SUPPORTED_TYPES.has(additional.type));
|
|
54
|
-
}
|
|
113
|
+
const tcpServer = createDNSTcpServer((request, send) => {
|
|
114
|
+
const edns = parseEdns(request);
|
|
55
115
|
|
|
56
|
-
dnsHandler(request)
|
|
57
|
-
.then(send)
|
|
116
|
+
dnsHandler(request, edns)
|
|
117
|
+
.then(response => send(finalizeResponse(response, edns, 'tcp')))
|
|
58
118
|
.catch(err => {
|
|
59
119
|
logger.error({ msg: 'Failed to send DNS response', protocol: 'tcp', err });
|
|
60
120
|
});
|
|
61
|
-
})
|
|
62
|
-
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
tcpServer.on('error', err => {
|
|
124
|
+
let method = 'error';
|
|
125
|
+
if (err && ['ECONNRESET'].includes(err.code)) {
|
|
126
|
+
method = 'trace';
|
|
127
|
+
}
|
|
128
|
+
logger[method]({ msg: 'DNS server error', protocol: 'tcp', err });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await new Promise(resolve => {
|
|
132
|
+
udpServer.listen(config.dns.port, config.dns.host, () => {
|
|
133
|
+
logger.info({ msg: 'DNS server listening', protocol: 'udp', host: config.dns.host, port: config.dns.port });
|
|
134
|
+
resolve();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await new Promise(resolve => {
|
|
139
|
+
tcpServer.listen(config.dns.port, config.dns.host, () => {
|
|
63
140
|
logger.info({ msg: 'DNS server listening', protocol: 'tcp', host: config.dns.host, port: config.dns.port });
|
|
64
|
-
|
|
65
|
-
.on('error', err => {
|
|
66
|
-
let method = 'error';
|
|
67
|
-
if (err && ['ECONNRESET'].includes(err.code)) {
|
|
68
|
-
method = 'trace';
|
|
69
|
-
}
|
|
70
|
-
logger[method]({ msg: 'DNS server error', protocol: 'tcp', err });
|
|
141
|
+
resolve();
|
|
71
142
|
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return { udpServer, tcpServer };
|
|
72
146
|
};
|
|
73
147
|
|
|
74
148
|
module.exports = init;
|
|
149
|
+
|
|
150
|
+
// Exposed for unit testing
|
|
151
|
+
module.exports.testables = { parseEdns, finalizeResponse };
|