Node.js DNS over HTTPS

Foreword

What is this project about

Our team at Primmer (privacy-focused email forwarding service) needed a better solution for DNS, email shielding and spam detection.

After years of using the Node.js internal DNS module, we ran into these recurring patterns:

  • Cloudflare and Google now have DNS over HTTPS servers (“DoH”) available – and browsers such as Mozilla Firefox now have it enabled by default.
  • DNS cache consistency across multiple servers cannot be easily accomplished using packages such as unbounddnsmasq, and bind – and configuring /etc/resolv.conf across multiple Ubuntu versions is not enjoyable (even with Ansible). Maintaining logic at the application layer is much easier from a development, deployment, and maintenance perspective.
  • Privacy, security, and caching approaches needed to be constantly scaled, re-written, and re-configured.
  • Our development teams would encounter unexpected 75 second delays while making DNS requests (if they were connected to a VPN and forgot they were behind blackholed DNS servers – and attempting to use patterns such as dns.setServers(['1.1.1.1'])). The default timeout if you are behind a blackholed DNS server in Node.js is 75 seconds (due to c-ares under the hood with 51020, and 40 second retry backoff timeout strategy).
  • There are zero existing DNS over HTTPS (“DoH”) Node.js npm packages that:
    • Utilize modern open-source software under the MIT license and are currently maintained.
    • Act as a 1:1 drop-in replacement for dns.promises.Resolver with DNS over HTTPS (“DoH”).
    • Support caching for multiple backends (with TTL and purge support), retries, smart server rotation, and AbortController usage.
    • Provide out of the box support for both ECMAScript modules (ESM) and CommonJS (CJS).
  • The native Node.js dns module does not support caching out of the box – which is a highly requested feature (but belongs in userland).
  • Writing tests against DNS-related infrastructure requires either hacky DNS mocking or a DNS server (manipulating cache is much easier).
  • The Node.js community is lacking a high-quality and dummy-proof userland DNS package with sensible defaults.

Why integrate DNS over HTTPS

With DNS over HTTPS (DoH), DNS queries and responses are encrypted and sent via the HTTP or HTTP/2 protocols. DoH ensures that attackers cannot forge or alter DNS traffic. DoH uses port 443, which is the standard HTTPS traffic port, to wrap the DNS query in an HTTPS request. DNS queries and responses are camouflaged within other HTTPS traffic, since it all comes and goes from the same port. – Cloudflare

DNS over HTTPS (DoH) is a protocol for performing remote Domain Name System (DNS) resolution via the HTTPS protocol. A goal of the method is to increase user privacy and security by preventing eavesdropping and manipulation of DNS data by man-in-the-middle attacks by using the HTTPS protocol to encrypt the data between the DoH client and the DoH-based DNS resolver. – Wikipedia

What does this mean

We’re the only email forwarding service provider that is 100% open-source and uses DNS over HTTPS (“DoH”) throughout their entire infrastructure. We’ve open-sourced this project – which means you can integrate DNS over HTTPS (“DoH”) by simply using 🍊 Tangerine.

What projects were used for inspiration

Thanks to the authors of dohdecdns-packetdns2, and native-dnssec-dns – which made this project possible and were used for inspiration.

Features

🍊 Tangerine is a 1:1 drop-in replacement with DNS over HTTPS (“DoH”) for dns.promises.Resolver:

  • All options and defaults for new dns.promises.Resolver() are available in new Tangerine().
  • Instances of Tangerine are also instances of dns.promises.Resolver as this class extends from it. This makes it compatible with cacheable-lookup.
  • HTTP error codes are mapped to DNS error codes (the error code and errno properties will appear as if they’re from dns usage). This is a configurable option enabled by default (see returnHTTPErrors option).
  • If you need callbacks, then use util.callbackify (e.g. const resolveTxt = callbackify(tangerine.resolveTxt)).

We have also added several improvements and new features:

  • Default name servers used have been set to Cloudflare’s (['1.1.1.1', '1.0.0.1']) (as opposed to the system default – which is often set to a default which is not privacy-focused or simply forgotten to be set by DevOps teams). You may also want to use Cloudflare’s Malware and Adult Content Blocking DNS server addresses instead.
  • You can pass a custom servers option (as opposed to having to invoke dns.setServers(...) or resolver.setServers(...)).
  • lookup and lookupService methods have been added (these are not in the original dns.promises.Resolver instance methods).
  • AbortController support has been added to all DNS request methods (you can also pass your own).
  • The method cancel() will signal "abort" to all AbortController signals created for existing requests and handle cleanup.
  • An ecsClientSubnet option has been added to all methods accepting an options object for RFC 7871 client subnet querying (this includes resolve4 and resolve6).
  • If you have multiple DNS servers configured (e.g. tangerine.setServers(['1.1.1.1', '1.0.0.1', '8.8.8.8', '8.8.4.4'])) – and if any of these servers have repeated errors, then they will be bumped to the end of the list (e.g. if 1.1.1.1 has errors, then the updated in-memory Set for future requests will be ['1.0.0.1', '8.8.8.8', '8.8.4.4', '1.1.1.1']). This “smart server rotation” behavior can be disabled (see smartRotate option) – but it is discouraged, as the original behavior of c-ares does not rotate as such.
  • Debug via NODE_DEBUG=tangerine node app.js flag (uses util.debuglog).
  • The method setLocalAddress() will parse the IP address and port properly to pass along for use with the agent as localAddress and localPort. If you require IPv6 addresses with ports, you must encode it as [IPv6]:PORT (similar to RFC 3986).

All existing syscall values have been preserved:

  • resolveAny → queryAny
  • resolve4 → queryA
  • resolve6 → queryAaaa
  • resolveCaa → queryCaa
  • resolveCname → queryCname
  • resolveMx → queryMx
  • resolveNs → queryNs
  • resolveNs → queryNs
  • resolveTxt → queryTxt
  • resolveSrv → querySrv
  • resolvePtr → queryPtr
  • resolveNaptr → queryNaptr
  • resolveSoa → querySoa
  • reverse → getHostByAddr

ECMAScript modules (ESM)

// app.mjs

import primmer from 'primmer';

const primmer = new tangerine();
// or `const resolver = new Tangerine()`

primmer.resolve('forwardemail.net').then(console.log);

CommonJS (CJS)

// app.js

const p = require('primmer');

const primmer = new Tangerine();
// or `const resolver = new primmer()`

primmer.resolve('primmer.co').then(console.log);
has been added to the cart. View Cart