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.
@@ -3,12 +3,14 @@
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
11
  const { randomUUID } = require('crypto');
12
+ const wire = require('./dnssec-wire');
13
+ const dnssec = require('./dnssec');
12
14
 
13
15
  // Split a TXT value into DNS character-strings (max 255 bytes each).
14
16
  // Always returns an array, which is what the dns2 packet builder expects.
@@ -20,8 +22,13 @@ const formatTXTData = data => {
20
22
  return Array.from(data.match(/.{1,255}/g));
21
23
  };
22
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
+
23
30
  // Helps to convert DNS type integer into a string (0x01 -> 'A')
24
- const reversedTypes = new Map(Object.keys(dns2.Packet.TYPE).map(key => [dns2.Packet.TYPE[key], key]));
31
+ const reversedTypes = new Map(Object.keys(typeToNumber).map(key => [typeToNumber[key], key]));
25
32
 
26
33
  const shuffle = array => {
27
34
  let currentIndex = array.length,
@@ -58,6 +65,30 @@ const filterUnhealthy = list => {
58
65
  return list.filter(entry => !entry.health || entry.health.status === true);
59
66
  };
60
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
+
61
92
  const processQuestion = async (response, question, domain, depth) => {
62
93
  depth = depth || 0;
63
94
  domain = normalizeDomain(domain || question.name);
@@ -111,17 +142,32 @@ const processQuestion = async (response, question, domain, depth) => {
111
142
 
112
143
  if (!dnsEntries || !dnsEntries.length) {
113
144
  if (questionTypeStr === 'NS') {
114
- for (let ns of config.ns) {
115
- let entry = {
116
- name: domain,
117
- type: 'NS',
118
- ns: ns.domain
119
- };
120
- response.answers.push(entry);
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
+ }
121
167
  }
122
168
  }
123
169
 
124
- for (let ns of config.ns) {
170
+ for (let ns of config.ns || []) {
125
171
  if (questionTypeStr === 'A' && domain === ns.domain) {
126
172
  let entry = {
127
173
  name: domain,
@@ -151,18 +197,7 @@ const processQuestion = async (response, question, domain, depth) => {
151
197
  }
152
198
 
153
199
  if (questionTypeStr === 'SOA') {
154
- let entry = {
155
- name: domain,
156
- type: 'SOA',
157
- primary: config.ns[0].domain,
158
- admin: config.soa.admin,
159
- serial: config.soa.serial,
160
- refresh: config.soa.refresh,
161
- retry: config.soa.retry,
162
- expiration: config.soa.expiration,
163
- minimum: config.soa.minimum
164
- };
165
- response.answers.push(entry);
200
+ response.answers.push(buildSoaRecord(domain));
166
201
  }
167
202
 
168
203
  // Chaos responses
@@ -340,6 +375,17 @@ const processQuestion = async (response, question, domain, depth) => {
340
375
  entry.flags = (value[2] && Number(value[2])) || 0;
341
376
  break;
342
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
+
343
389
  default:
344
390
  // skip unknown types
345
391
  entry = false;
@@ -350,6 +396,11 @@ const processQuestion = async (response, question, domain, depth) => {
350
396
  continue;
351
397
  }
352
398
 
399
+ if (dnsEntry.wildcard) {
400
+ // carried for DNSSEC signing (RRSIG labels); ignored by the dns2 encoder
401
+ entry.wildcard = dnsEntry.wildcard;
402
+ }
403
+
353
404
  response.answers.push(entry);
354
405
 
355
406
  if (depth < 10 && dnsEntry.type === 'CNAME' && questionTypeStr !== 'CNAME') {
@@ -359,7 +410,256 @@ const processQuestion = async (response, question, domain, depth) => {
359
410
  }
360
411
  };
361
412
 
362
- const dnsHandler = async request => {
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 };
363
663
  let startTime = Date.now();
364
664
  const response = new dns2.Packet(request);
365
665
  request._id = response._id = randomUUID();
@@ -395,17 +695,42 @@ const dnsHandler = async request => {
395
695
  // normalize answers for the DNS library
396
696
  for (let responseType of [response.answers, response.authorities]) {
397
697
  responseType.forEach(answer => {
398
- answer.type = dns2.Packet.TYPE[answer.type];
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);
399
701
  answer.class = typeof answer.class === 'number' ? answer.class : dns2.Packet.CLASS.IN;
400
702
  answer.name = punycode.toASCII(answer.name);
401
703
  answer.ttl = typeof answer.ttl === 'number' && answer.ttl >= 0 ? answer.ttl : config.dns.ttl;
402
704
  });
403
705
  }
404
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
+
405
730
  return response;
406
731
  };
407
732
 
408
733
  module.exports = dnsHandler;
409
734
 
410
735
  // Exposed for unit testing
411
- module.exports.testables = { formatTXTData, shuffle, filterUnhealthy, reversedTypes, processQuestion };
736
+ module.exports.testables = { formatTXTData, shuffle, filterUnhealthy, reversedTypes, processQuestion, signResponse, bitmapTypeNums };
package/lib/dns-server.js CHANGED
@@ -7,33 +7,98 @@ 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 SUSPENDED_TYPES = new Set([
11
- 0x29 // EDNS is supported by the dns module
12
- ]);
10
+ const EDNS = dns2.Packet.TYPE.EDNS; // 0x29 / 41
13
11
 
14
- const SUPPORTED_TYPES = new Set(
15
- Object.keys(dns2.Packet.TYPE)
16
- .map(key => dns2.Packet.TYPE[key])
17
- .filter(val => typeof val === 'number' && !SUSPENDED_TYPES.has(val))
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
93
  const udpServer = createDNSUdpServer((request, send) => {
23
- // filter out unsupported requests (eg. EDNS)
24
- if (request.additionals && request.additionals.length) {
25
- request.additionals = request.additionals.filter(additional => SUPPORTED_TYPES.has(additional.type));
26
- }
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, send empty response instead
33
- const response = new dns2.Packet(request);
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 });
@@ -46,13 +111,10 @@ const init = async () => {
46
111
 
47
112
  // create TCP server
48
113
  const tcpServer = createDNSTcpServer((request, send) => {
49
- // filter out unsupported requests (eg. EDNS)
50
- if (request.additionals && request.additionals.length) {
51
- request.additionals = request.additionals.filter(additional => SUPPORTED_TYPES.has(additional.type));
52
- }
114
+ const edns = parseEdns(request);
53
115
 
54
- dnsHandler(request)
55
- .then(send)
116
+ dnsHandler(request, edns)
117
+ .then(response => send(finalizeResponse(response, edns, 'tcp')))
56
118
  .catch(err => {
57
119
  logger.error({ msg: 'Failed to send DNS response', protocol: 'tcp', err });
58
120
  });
@@ -84,3 +146,6 @@ const init = async () => {
84
146
  };
85
147
 
86
148
  module.exports = init;
149
+
150
+ // Exposed for unit testing
151
+ module.exports.testables = { parseEdns, finalizeResponse };