npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, πŸ‘‹, I’m Ryan HefnerΒ  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you πŸ™

Β© 2026 – Pkg Stats / Ryan Hefner

dnssec-server

v0.5.0

Published

πŸ“‘ Pure JavaScript authoritative DNS server for Node.js with built-in DNSSEC, dynamic zones, and modern record support.

Downloads

73

Readme

Table of Contents

✨ Why this library?

Running DNS in production is usually associated with heavyweight software such as BIND, NSD or PowerDNS.
These are robust and widely used, but they come with trade-offs:

  • Zone files are static and cumbersome to manage when your infrastructure changes frequently.
  • Rolling out new records or policies often requires reloads, manual key management, or external tooling.
  • Enabling DNSSEC usually means a separate signing pipeline, cron jobs, or custom scripts.

For modern stacks that are API-driven, containerized, and globally distributed, this is painful and slow.

dnssec-server takes a different approach: it is a pure JavaScript authoritative DNS server designed to live inside your Node.js application and give you DNS as code.

  • πŸ›‘οΈ DNSSEC at runtime β€” responses are signed on the fly (RRSIG), DNSKEY is served for you, and negative answers carry a signed proof of non-existence (compact NSEC, RFC 9824). No offline signer, no cron jobs, no external toolchain.
  • ⚑ Dynamic zones β€” answers are computed from logic you write in JavaScript. Use the client subnet (ECS) to return the nearest PoP, shift traffic with weighted canaries, or implement feature-flagged DNS in seconds.
  • πŸ“ Standards filled in β€” AA on authoritative answers, the negative-answer TTL rules of RFC 2308, the ECS scope of RFC 7871. You write the records; res.send() adds what the RFCs require.
  • 🌍 Modern record support β€” from SVCB/HTTPS for service discovery, to TLSA for DANE, CAA for certificate policy, and URI for service endpoints.
  • πŸ”’ Encrypted transports β€” DNS-over-TLS and DNS-over-QUIC, with certificates chosen per connection through an SNICallback; DNS-over-HTTPS through your own HTTP server.
  • 🧩 Simple API surface β€” one request/response handler where you push records and call res.send(). No zone file syntax to learn.

This makes dnssec-server especially useful when:

  • You want DNS decisions tied directly to application logic (load balancing, canaries, failover).
  • You need DNSSEC but don't want to manage signing infrastructure.
  • You're building modern protocols (HTTPS/SVCB, ECH, DoT, DoQ) and need a flexible testbed.
  • You want to prototype quickly without carrying the weight of BIND/NSD.

πŸ“¦ Install

npm i dnssec-server

The package is an ES module and is loaded straight from its source β€” there is no build step.

// ESM
import DNSServer from 'dnssec-server';
import { createServer, buildDnssecMaterial } from 'dnssec-server';

// CommonJS β€” through require(esm), Node.js 20.19+ / 22.12+
const DNSServer = require('dnssec-server');

Dependencies: lemon-tls and quico, used by the encrypted transports (DoT, DoQ).

πŸš€ Quick Start

A minimal authoritative server for example.com. on UDP and TCP port 53. It answers an A record for the apex; for every other name in the zone it returns NXDOMAIN with the zone's SOA, as a negative answer must.

import DNSServer from 'dnssec-server';

const SOA = {
  name: 'example.com.', type: 'SOA', class: 'IN', ttl: 300,
  data: {
    mname: 'ns1.example.com.', rname: 'hostmaster.example.com.',
    serial: 2025081001, refresh: 3600, retry: 600, expire: 604800, minimum: 300
  }
};

DNSServer.createServer({}, function (req, res) {
  console.log(`[${req.transport}] ${req.remoteAddress}  q=${req.name} ${req.type}  DO=${req.flag_do}  ECS=${req.ecsAddress || '-'}`);

  // Not our zone: refuse. (REFUSED claims no authority β€” AA stays off.)
  if (req.name !== 'example.com.' && !req.name.endsWith('.example.com.')) {
    res.header.rcode = 5;
    return res.send();
  }

  if (req.name === 'example.com.' && req.type === 'A') {
    res.answers.push({ name: 'example.com.', type: 'A', class: 'IN', ttl: 300, data: { address: '203.0.113.10' } });
    return res.send();
  }

  if (req.name !== 'example.com.') res.header.rcode = 3;   // NXDOMAIN: no such name
  // else: NODATA β€” the name exists, the type does not (rcode stays 0)
  res.authority.push(SOA);                                  // required on every negative answer
  res.send();
});

Running on port 53 needs the privilege to bind it. For development, pick other ports: { udp: { port: 5353 }, tcp: { port: 5353 } }.

βš™οΈ API

createServer(options, handler)

Creates and starts an authoritative DNS server.

Transports

| Option | Default | Shape | |---|---|---| | udp | UDP on 0.0.0.0:53 and [::]:53 | false, or { port, host } β€” a host binds only its own family ('127.0.0.1' β†’ IPv4 only, '::1' β†’ IPv6 only); no host binds both β€” or { udp4: { host, port }, udp6: { host, port } } | | tcp | TCP on [::]:53 | false, or { port, host, idleMs } β€” idleMs closes an idle connection (default 30000) | | tls | off | { port: 853, host, SNICallback, key, cert, ALPNProtocols, maxConnections, idleMs } β€” DNS-over-TLS (RFC 7858) | | quic | off | { port: 853, host, SNICallback, key, cert, alpn, maxConnections, maxOpenStreams } β€” DNS-over-QUIC (RFC 9250) |

See Encrypted transports for the TLS / QUIC certificate options.

Behavior

| Option | Default | Meaning | |---|---|---| | aa | true | Set the Authoritative Answer flag on NOERROR / NXDOMAIN responses. false leaves it off unless a handler sets res.header.aa. | | always_ra | false | Set Recursion Available. An authoritative server should not. | | ecsDefaultScope | the query's source prefix | ECS scope returned when the handler does not set res.ecsScope. A number overrides it. See ECS. | | onError | ignored | function (err, transport) β€” socket errors, signing errors, and standards warnings (such as a negative answer without an SOA). |

DNSSEC

dnssec (optional) β€” see Enabling DNSSEC.

| Option | Default | Meaning | |---|---|---| | keyCallback | β€” | function (name, cb) β€” called for queries with DO=1; cb(null, material) signs with that material, cb(null, null) leaves the answer unsigned. | | cache | none | true for the built-in LRU signature cache, or { get, set } for your own. | | cacheMax | 10000 | Entries in the built-in cache. | | signatureValidity | 7 days | Lifetime of a new RRSIG, in seconds. | | dnskeyValidity | 30 days | Lifetime of the RRSIG over DNSKEY, in seconds. | | autoDenial | true | Add the NSEC proving a negative answer. false leaves negative answers without one. | | autoDnskey | true | Answer DNSKEY queries at the zone apex from the material. false passes them to the handler. | | autoCds | true | Answer CDS and CDNSKEY queries at the apex from the material. See CDS / CDNSKEY. |

handler(req, res)

Your request handler. Populate res.answers / res.authority / res.additionals, then call res.send() β€” once.

Returns

| Member | | |---|---| | close(cb) | Stop every transport and drop open TCP/TLS connections. | | handleMessage(bytes, peer, cb) | Answer one DNS message from a transport you run β€” see DoH. | | connectionCount() | Open TCP/TLS connections. | | clearRrsigCache() | Flush the built-in signature cache (e.g. after a key change). | | rrsigCacheSize() | Entries in the built-in cache. | | context | Internal server context (advanced use). |

Request (req) object

  • id (number) β€” message ID.
  • transport ('udp4' | 'udp6' | 'tcp' | 'tls' | 'quic') β€” which transport delivered the query.
  • remoteAddress (string), remotePort (number) β€” the socket peer: usually the resolver, not the end user. See ECS.
  • client ({ address, port, family }) β€” the same, with 'IPv4' / 'IPv6'.
  • name (FQDN, lowercase, with trailing dot) β€” qname, e.g. example.com.
  • type (string) β€” qtype, e.g. A, AAAA, TXT, …
  • class (string) β€” typically IN.
  • flag_qr / flag_aa / flag_tc / flag_rd / flag_ra / flag_ad / flag_cd (booleans) β€” header flags.
  • flag_do (boolean) β€” DO bit (DNSSEC OK).
  • edns (object | undefined) β€” parsed EDNS(0): udpSize, do, z, options, optionsStructured, …
  • edns_udp_size (number | undefined) β€” advertised UDP payload size.
  • ednsOptions (array | undefined) β€” raw EDNS options.
  • ecs (object | undefined) β€” the EDNS Client Subnet option: { family, sourcePrefixLength, scopePrefixLength, addressBytes }.
  • ecsAddress (string | undefined) β€” the client subnet address, e.g. 200.118.80.0.
  • ecsSourcePrefixLength / ecsScopePrefixLength (number | undefined).
  • tls (object | undefined) β€” { authorized, alpn } on encrypted transports.
  • raw (Uint8Array) β€” original wire bytes.
  • message (object) β€” the full decoded query.

Response (res) object

| Member | | |---|---| | header | Flags and rcode. res.header.rcode β€” 0 NOERROR, 2 SERVFAIL, 3 NXDOMAIN, 5 REFUSED, … res.header.aa β€” leave it unset and it is decided for you (below). | | answers, authority, additionals | Arrays of RRs: { name, type, class: 'IN', ttl, data } β€” see Supported Record Types. | | types | (optional) On a negative answer: the types that do exist at the name, e.g. ['A', 'MX']. Makes the DNSSEC proof exact. See Negative answers. | | ecsScope | (optional) For a query with ECS: how wide a subnet this answer is good for. 0 = everyone. See ECS. | | edns | The response's EDNS(0) record, present when the query had one. | | send() | Complete and transmit the response. Call it once per query. |

What res.send() completes for you

You write the records; send() adds what the standards require on top. Each step is skipped when you did it yourself.

| Step | When | Standard | |---|---|---| | AA flag β€” on for NOERROR / NXDOMAIN, off for REFUSED / SERVFAIL / FORMERR | res.header.aa was left unset and aa is not false | RFC 1035 Β§4.1.1 | | Negative TTL β€” the SOA in a negative answer gets min(SOA TTL, SOA minimum) | No answers, rcode 0 or 3, SOA in authority | RFC 2308 Β§5 | | ECS scope β€” the ECS option is echoed with a scope | The query carried ECS and you did not add an ECS option yourself | RFC 7871 Β§7.2.1 | | RRSIG β€” every RRset in answers and authority is signed | DO=1, keyCallback returned material, and no RRSIG is in the response already | RFC 4034, 4035 | | NSEC β€” the proof of a negative answer | Signing, autoDenial not false, no NSEC/NSEC3 in authority | RFC 9824, 9077 | | NXNAME β€” a signed NXDOMAIN becomes NOERROR + NXNAME, unless the resolver set CO | As above, rcode 3 | RFC 9824 Β§5.1 | | Warning β€” onError reports a negative answer without an SOA | No answers, no SOA, and no NS (not a referral) | RFC 2308 Β§3 |

πŸ”§ encodeMessage / decodeMessage

Low-level primitives to parse and serialize raw DNS messages β€” for your own transports, middleboxes, or tests.

import { encodeMessage, decodeMessage } from 'dnssec-server';

const query = decodeMessage(rawBuffer);
query.answers.push({ name: 'example.com.', type: 'A', class: 'IN', ttl: 60, data: { address: '203.0.113.10' } });
const wire = encodeMessage(query);

🚫 Negative answers (NODATA / NXDOMAIN)

A negative answer is one with no records of the asked type. There are two kinds:

  • NODATA β€” the name exists, the type does not (AAAA for a name with only A). rcode stays 0, answers are empty.
  • NXDOMAIN β€” the name does not exist at all. rcode = 3.

Either way the zone's SOA goes in res.authority (RFC 2308 Β§3). Resolvers use it to know how long to remember the "no" β€” without it they cannot cache it, and some treat the answer as broken. Push the SOA as you would any record; its TTL is capped for you. Leave it out and onError tells you.

// NODATA: www.example.com has an A record, but no AAAA
res.authority.push(SOA);
res.types = ['A'];          // optional: the types that do exist here
res.send();

With DNSSEC

A signed zone must also prove a negative answer β€” otherwise a validating resolver cannot tell a real "no" from an attacker deleting records, and answers SERVFAIL. res.send() adds the proof: one NSEC record in the compact form of RFC 9824, signed online.

  • Its owner is the query name and its next name is the immediate successor (\000.<name>), so it covers exactly one name and the zone cannot be walked.
  • NODATA: the NSEC lists the types that exist at the name. Give them as res.types for an exact proof. Without it, a broad list of common types is used, minus the asked type β€” always safe, since claiming a type that is absent only makes the resolver ask for it, while omitting one that exists would let a resolver deny it from cache (RFC 8198). At the apex, DNSKEY is added for you.
  • NXDOMAIN: the NSEC lists only NXNAME (RFC 9824). The response goes out as NOERROR β€” what resolvers that predate compact denial expect β€” unless the resolver set the Compact Answers OK (CO) flag, in which case it keeps NXDOMAIN and gets CO back.

answerFromZone() returns types for NODATA results, so zone-file setups need nothing extra: res.types = result.types.

πŸ” Enabling DNSSEC

1. Generate keys with buildDnssecMaterial

import { buildDnssecMaterial } from 'dnssec-server';

// New random keys
const material = buildDnssecMaterial({ signersName: 'example.com.' });

// Or your existing private keys (base64 string or Uint8Array, P-256)
const material = buildDnssecMaterial({
  signersName: 'example.com.',
  ksk: { privateKey: 'BASE64-P256-PRIVATE-KEY' },
  zsk: { privateKey: 'BASE64-P256-PRIVATE-KEY' }
});

Output:

{
  signersName: 'example.com.',
  ds: 'example.com. IN DS 14257 13 2 A1B2C3D4E5F6...',   // ready for the registrar
  ksk: { keyTag: 14257, privateKey: '...', publicKey: '...', algorithm: 13, digestType: 2, digest: 'A1B2C3D4E5F6...' },
  zsk: { keyTag: 27179, privateKey: '...', publicKey: '...', algorithm: 13 }
}

Algorithm 13 (ECDSA P-256 / SHA-256) and digest type 2 (SHA-256) β€” the modern defaults every validating resolver supports.

2. Keep the keys stable across restarts

⚠️ This is the step that breaks domains. Once the parent zone holds a DS for your KSK, a different KSK means every validating resolver (Google, Cloudflare, most ISPs) answers SERVFAIL for your whole domain until the DS is updated and caches expire β€” hours.

buildDnssecMaterial() without keys makes new random ones every call. Store the private keys (material.ksk.privateKey, material.zsk.privateKey) and pass them back on every start:

import fs from 'node:fs';

function loadMaterial(zone, file) {
  let saved = null;
  try { saved = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) {}
  if (saved) return buildDnssecMaterial({ signersName: zone, ksk: { privateKey: saved.ksk }, zsk: { privateKey: saved.zsk } });

  const m = buildDnssecMaterial({ signersName: zone });
  fs.writeFileSync(file, JSON.stringify({ ksk: m.ksk.privateKey, zsk: m.zsk.privateKey }), { mode: 0o600 });
  return m;
}

The file holds private keys: keep it out of version control and out of anything served publicly.

3. Wire DNSSEC into the server

Return the material from keyCallback. The server serves DNSKEY at the apex, signs every answer, and proves negative answers.

const material = loadMaterial('example.com.', './dnssec-example.com.json');

DNSServer.createServer({
  dnssec: {
    keyCallback: function (name, cb) {
      // Several zones: return each one's material by suffix.
      if (name === 'example.com.' || name.endsWith('.example.com.')) cb(null, material);
      else cb(null, null);                  // not ours β€” no signing
    },
    cache: true
  }
}, handler);

keyCallback may answer asynchronously β€” read keys from a database, a KMS, or another process.

4. Add the DS at your registrar

Copy material.ds (it is also printed by the example above β€” log it once). Registrars ask for its four fields:

| Key Tag | Algorithm | Digest Type | Digest | | ------- | --------- | ----------- | ------ | | 14257 | 13 | 2 | A1B2C3D4E5F6... |

Only the KSK produces a DS. Add it after step 2 is in place and you have restarted once and seen the same key tag.

5. Verify

dig +dnssec example.com DNSKEY @your-server
dig +dnssec example.com A @your-server
dig +dnssec example.com AAAA @your-server     # a negative answer: SOA + NSEC + RRSIGs in AUTHORITY

Then dnsviz.net or dnssec-debugger.verisignlabs.com for the whole chain.

6. Signature validity and caching

A signature is a proof anyone can replay until it expires. Signing online makes short windows free, so new RRSIGs are valid for 7 days (signatureValidity), and 30 days over DNSKEY (dnskeyValidity). A cached signature is replaced once three quarters of its window is spent, so no resolver is handed one that is about to expire.

ECDSA P-256 signing costs about 1–5 ms per RRset. With cache, identical RRsets reuse their signature.

// Built-in in-memory LRU
dnssec: { keyCallback, cache: true, cacheMax: 5000 }

// Your own backend (Redis, …)
dnssec: {
  keyCallback,
  cache: {
    get(params, cb) {                // params: { name, type, class, keyTag, hash }
      redis.get(params.hash + ':' + params.keyTag, (err, raw) => cb(err, raw ? JSON.parse(raw) : null));
    },
    set(params, value, cb) {         // value: { rrsig, expiration, inception }
      const ttl = value.expiration - Math.floor(Date.now() / 1000);
      redis.setex(params.hash + ':' + params.keyTag, ttl, JSON.stringify(value), cb);
    }
  }
}

hash is the SHA-256 of the canonical RRset bytes β€” it changes whenever the records do, so a changed answer is never served an old signature. server.clearRrsigCache() flushes the built-in cache, e.g. after a key change.

7. Doing it by hand

Every automatic step can be taken over:

| You want | Do | |---|---| | Your own signatures | Put RRSIG records in the response β€” nothing is signed for you then. | | Your own denial proof (NSEC3, a different NSEC) | Put it in res.authority β€” no NSEC is added. Or dnssec.autoDenial: false. | | To answer DNSKEY yourself | dnssec.autoDnskey: false, and answer it in the handler. | | To answer CDS / CDNSKEY yourself (or not at all) | dnssec.autoCds: false. | | Your own AA decision | Set res.header.aa. Or aa: false server-wide. | | Your own ECS option | Add it to res.edns.options β€” it is left as is. | | No DNSSEC at all | Don't pass dnssec. |

8. CDS / CDNSKEY β€” DS updates without the registrar

The DS lives at the parent, and normally a person types it into the registrar's page β€” at the start, and again after every KSK change. Forget it once after a key change and the domain goes dark.

CDS and CDNSKEY (RFC 7344, RFC 8078) are your zone's own copy of "the DS I want my parent to hold": CDS in DS form, CDNSKEY as the key itself. They sit at your apex, signed with the KSK. A registry that scans for them:

  • with a DS already in place β€” accepts a change only if the new CDS is signed by the key the current DS points at, so only the key holder can change it; then updates the DS itself;
  • with no DS yet β€” applies its own acceptance policy (typically: seen unchanged for some days, from every name server) and installs it;
  • CDS 0 0 0 00 β€” the zone asks for DNSSEC to be turned off.

The server answers both at the apex, from the material keyCallback returns β€” always matching the KSK it signs with, signed with that KSK, TTL 3600. The apex NSEC lists them. Support depends on the registry of your TLD (many ccTLDs scan; check yours) β€” where nobody scans, the records are simply never asked for. dnssec.autoCds: false turns them off.

πŸ”Ž ECS (EDNS Client Subnet)

The query does not come from the user. It comes from their resolver β€” an ISP's, or a public one like Google (8.8.8.8). So req.remoteAddress is the resolver's address.

Some resolvers add the EDNS Client Subnet option: a prefix of the user's address (never all of it), e.g. 200.118.80.0/24. It is exposed as req.ecsAddress and req.ecsSourcePrefixLength.

The scope β€” who the answer is good for

A resolver serves millions of users from its cache. If your answer depends on where the user is, the resolver must not hand it to everyone β€” so the reply says for how wide a subnet it holds: the ECS scope prefix.

| Scope | The resolver | |---|---| | 24 (= the source prefix) | keeps the answer for 200.118.80.0/24 only, and asks you again for every other /24 | | 16 | keeps it for 200.118.x.x β€” e.g. when you decide by country | | 0 | keeps one answer for everyone |

By default the reply carries the source prefix the resolver sent β€” the safe choice: the resolver asks you for every subnet, and your handler decides each time. Set res.ecsScope per answer when you know better:

res.ecsScope = 0;     // this answer is the same for everyone β€” one cache entry at the resolver
res.ecsScope = 16;    // decided by a /16

ecsDefaultScope changes the default server-wide. A query without ECS gets no ECS in the reply; a client that opted out (source /0) always gets scope 0.

Not every resolver sends ECS β€” Cloudflare's 1.1.1.1 never does. Location logic should fall back to req.remoteAddress.

πŸ”’ Encrypted transports (DoT, DoQ)

Both run on lemon-tls (DoT through quico's TCP listener, DoQ through quico), so one certificate source serves both.

import lemonTls from 'lemon-tls';

const ctx = lemonTls.createSecureContext({ key: fs.readFileSync('key.pem'), cert: fs.readFileSync('cert.pem') });

DNSServer.createServer({
  tls:  { port: 853, SNICallback: (servername, cb, info) => cb(null, ctx) },
  quic: { port: 853, SNICallback: (servername, cb) => cb(null, ctx) }
}, handler);
  • The SNICallback returns a lemon-tls context (lemonTls.createSecureContext) β€” not a node:tls one.
  • It is called for every connection, including clients that send no server name. DNS clients are usually configured by IP, and an IP is never sent as SNI (RFC 6066 Β§3) β€” for DoT, the third argument info carries the local address the connection arrived on, so you can pick a certificate by it.
  • DoT and DoQ use the same port number, 853, on different protocols (TCP and UDP).

🌐 DNS-over-HTTPS (DoH)

The library serves no HTTP. DoH (RFC 8484) is wired through the HTTP server you already run, with server.handleMessage(bytes, peer, cb): hand it the DNS message from the request, send back what it returns. The query goes through exactly the same path as UDP or TCP β€” key sets, your handler, signing, negative answers.

import http from 'node:http';

const dns = DNSServer.createServer({ /* … */ }, handler);

http.createServer((req, res) => {
  if (!req.url.startsWith('/dns-query')) { res.writeHead(404); return res.end(); }

  const answer = (bytes) => dns.handleMessage(bytes, { address: req.socket.remoteAddress, transport: 'https' }, (err, wire, info) => {
    if (err) { res.writeHead(400); return res.end(); }          // not a DNS message
    res.writeHead(200, {
      'content-type': 'application/dns-message',
      'cache-control': typeof info.ttl === 'number' ? 'max-age=' + info.ttl : 'no-store'   // RFC 8484 Β§5.1
    });
    res.end(Buffer.from(wire));
  });

  if (req.method === 'POST') {                                  // body: application/dns-message
    const chunks = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => answer(new Uint8Array(Buffer.concat(chunks))));
  } else {                                                      // GET /dns-query?dns=<base64url>
    const m = /[?&]dns=([A-Za-z0-9_-]+)/.exec(req.url);
    if (!m) { res.writeHead(400); return res.end(); }
    answer(new Uint8Array(Buffer.from(m[1], 'base64url')));
  }
}).listen(8443);
  • peer β€” { address, port, transport, tls }; transport is what req.transport shows the handler ('https' here).
  • cb(err, responseBytes, info) β€” err when the bytes are not a DNS message (answer HTTP 400); info.ttl is the smallest TTL in the response, for max-age, undefined when it carries no records.
  • Run it behind HTTPS in production; DoH over plain HTTP exists only for testing.

Who uses DoH against an authoritative server? Mostly no one yet β€” browsers speak DoH to their resolver, and resolvers reach authoritative servers over port 53, DoT or DoQ. It is here for completeness and for tools that only speak DoH.

πŸ“¦ Migrating from Zone Files

Already running BIND with zone files? Load them with parseZone() and answer with answerFromZone().

  • $ORIGIN and $TTL are honored (or pass parseZone(text, { origin: 'example.com.' })).
  • A, AAAA, NS, CNAME, PTR, MX, TXT, SRV, CAA, NAPTR, DNSKEY, DS, RRSIG, NSEC, NSEC3PARAM.
  • SVCB/HTTPS in the RFC 9460 presentation format: every key β€” mandatory, alpn, no-default-alpn, port, ipv4hint, ech, ipv6hint, dohpath, ohttp, tls-supported-groups and keyNNNNN β€” quoted or not, with escapes (alpn="h3,h2", alpn=h2\,x). AliasMode (priority 0) takes no keys; a repeated key, or a mandatory naming a key that is absent, is an error with the line number. ech is checked to be a well-formed ECHConfigList. A/AAAA from the hints are added to additionals.
  • CAA (0 issue "letsencrypt.org"), and text escapes as RFC 1035 Β§5.1 defines them: \065 is decimal (A), \" and \\ are the character.
  • Any type in the RFC 3597 generic form β€” \# 4 c0000201 β€” for a type this library does not know, or one written that way by another tool.
@   IN HTTPS 1 . alpn="h3,h2" ipv4hint=192.0.2.10 ipv6hint=2001:db8::10 ech="AEX+DQBB…"
_dns IN SVCB 1 dns.example.com. alpn=dot,doq port=853 mandatory=alpn,port
@   IN CAA   0 issue "letsencrypt.org"
  • Glue for MX/SRV/NS and SVCB/HTTPS targets present in the zone; CNAME chains inside the zone are followed; wildcards are synthesized.
  • Negative answers come back with the SOA, a correct NXDOMAIN / NODATA, and the types at the name β€” so DNSSEC proofs are exact with no extra work.
import fs from 'node:fs';
import DNSServer from 'dnssec-server';

const zones = {
  'example.com.': DNSServer.parseZone(fs.readFileSync('example.com.zone', 'utf8')),
  'example.net.': DNSServer.parseZone(fs.readFileSync('example.net.zone', 'utf8'))
};

// Longest-suffix match
function pickZone(qname) {
  let best = null;
  for (const origin of Object.keys(zones)) {
    if ((qname === origin || qname.endsWith('.' + origin)) && (!best || origin.length > best.length)) best = origin;
  }
  return best ? zones[best] : null;
}

DNSServer.createServer({}, (req, res) => {
  const zone = pickZone(req.name);
  if (!zone) { res.header.rcode = 5; return res.send(); }   // not ours: REFUSED

  const r = DNSServer.answerFromZone(zone, req.name, req.type, req.class);

  // πŸ‘‡ the place to adjust: TTLs, ECS-based answers, extra records…

  res.header.rcode = r.rcode;
  res.answers.push(...r.answers);
  res.authority.push(...r.authority);
  res.additionals.push(...r.additionals);
  if (r.types) res.types = r.types;
  res.send();
});

🧩 Dynamic DNS Recipes

Geo Load Balancer by client subnet

Return the nearest PoP by the client's subnet (ECS) when the resolver sends it, by the resolver's address otherwise.

function pickRegion(ip) {
  // toy example: replace with a GeoIP database or your own CIDR map
  if (ip.startsWith('200.10.')) return 'sa-east';
  if (ip.startsWith('192.0.2.')) return 'us-east';
  return 'eu-west';
}

const POPS = { 'sa-east': '200.10.10.1', 'us-east': '198.51.100.7', 'eu-west': '203.0.113.42' };

function handler(req, res) {
  const region = pickRegion(req.ecsAddress || req.remoteAddress || '');
  res.answers.push({ name: req.name, type: 'A', class: 'IN', ttl: 30, data: { address: POPS[region] } });
  // The answer depends on the client subnet. The default ECS scope (the
  // source prefix) already keeps resolvers from sharing it across subnets;
  // a coarser decision can say so:
  // res.ecsScope = 16;
  res.send();
}

Keep TTLs short for agility.

Blue/Green & Canary releases

function canaryA(req, res) {
  const canary = Math.random() * 100 < 5;   // 5% canary
  res.answers.push({ name: req.name, type: 'A', class: 'IN', ttl: 20, data: { address: canary ? '203.0.113.77' : '203.0.113.10' } });
  res.send();
}

Service discovery with SVCB/HTTPS

Advertise ALPNs, ports, address hints and ECH config (RFC 9460/9461).

res.answers.push({
  name: 'example.com.', type: 'HTTPS', class: 'IN', ttl: 300,
  data: {
    priority: 1,
    targetName: '.',
    paramsStructured: { alpn: ['h3', 'h2'], ipv4hint: ['192.0.2.10', '192.0.2.11'] }
  }
});

Regional failover

Synthesize answers from upstream health checks; switch A/AAAA sets when a region is down. Pair with short TTLs β€” the 7-day signature window does not hold a changed answer back, since a changed RRset is signed anew.

πŸ“œ Supported Record Types (spec‑aligned)

General RR structure in this library:

{
  type: 'A' | 'AAAA' | 'MX' | ...,
  name: 'example.com',
  class: 'IN',
  ttl: 300,
  data: { ... } // record-specific fields
}

The following record types are supported. Each example shows the data object expected during encodeMessage (and returned during decodeMessage).

Address

A

{ data: { address: '127.0.0.1' } }

AAAA

{ data: { address: '2001:db8::1' } }   // or a 16-byte Uint8Array

Name-based

NS, CNAME, PTR, DNAME

{ data: { name: 'ns1.example.net.' } }

Obsolete name-only: MD, MF, MB, MG, MR, NSAP_PTR, MAILA, MAILB

{ data: { name: 'old-target.example.' } }

Core / Basics

SOA

{ 
  data: {
    mname: 'ns1.example.',
    rname: 'hostmaster.example.',
    serial: 2025082601, 
    refresh: 3600, 
    retry: 600, 
    expire: 1209600, 
    minimum: 300
  }
}

MX

{ 
  data: { 
    preference: 10, 
    exchange: 'mail.example.' 
  } 
}

TXT

{ data: { texts: ['hello', 'world'] } }

HINFO

{ data: { cpu: 'Intel', 
os: 'Linux' } }

MINFO

{ 
  data: { 
    rmailbx: 'r.mail.example.', 
    emailbx: 'e.mail.example.' 
  } 
}

RP

{ 
  data: { 
    mbox: 'admin.example.', 
    txt: 'info.example.' 
  } 
}

AFSDB

{ 
  data: { 
    subtype: 1, 
    hostname: 'afsdb.example.' 
  } 
}

X25

{ data: { address: '311061700956' } }

ISDN

{ data: { address: '150862028003217', 
sa: '004' } }

RT

{ data: { 
  preference: 10, 
  host: 'intermediate.example.' 
} }

NSAP

{ data: { address: Uint8Array([...]) } }

PX

{ data: { 
  preference: 10, 
  MAP822: 'user.example.', 
  MAPX400: 'x400.example.' 
} }

GPOS

{ 
  data: { 
    latitude: '48 51 29.0 N', 
    longitude: '2 17 40.0 E', 
    altitude: '0.00' 
  } 
}

WKS

{ 
  data: { 
    address: '192.0.2.1', 
    protocol: 6, 
    bitmap: Uint8Array([...]) 
  } 
}

NULL

{ data: { raw: Uint8Array([ ... ]) } }

SRV / NAPTR / URI

SRV

{ 
  data: { 
    priority: 10, 
    weight: 5, 
    port: 443, 
    target: 'svc.example.' 
  } 
}

NAPTR

{ 
  data: { 
    order: 100, 
    preference: 10, 
    flags: 'U', 
    services: 'SIP+D2T',
    regexp: '!^.*$!sip:[email protected]!', 
    replacement: '.' 
  } 
}

URI

{ 
  data: { 
    priority: 1, 
    weight: 10, 
    target: 'https://api.example/v1' 
  } 
}

EDNS(0)

OPT

{ 
  type: 'OPT', 
  name: '.', 
  udpPayloadSize: 4096,
  data: {
    options: [
      { 
        code: 12, 
        data: Uint8Array(31) 
      }
    ]
  }
}

Modern service mapping

SVCB / HTTPS

{ 
  data: {
    priority: 1,
    targetName: 'svc.example.',
    paramsStructured: {
      alpn: ['h3','h2','http/1.1'],
      noDefaultAlpn: true,
      port: 8443,
      ipv4hint: ['192.0.2.10','192.0.2.11'],
      ech: Uint8Array([...]),
      dohpath: '/dns-query{?dns}',
      ohttp: true,                     // a flag: present or absent (RFC 9540)
      tlsSupportedGroups: [29,23],
      mandatory: ['alpn', 'port']      // names or key numbers
    }
  }
}

Keys go on the wire in increasing order, as RFC 9460 Β§2.2 requires; a mandatory naming itself or an absent key, or an ech that is not a well-formed ECHConfigList, throws when the record is encoded rather than going out broken. ipv4hint / ipv6hint take address strings.

DNSSEC & Security

DNSKEY / CDNSKEY

{ 
  data: { 
    flags: 257, 
    protocol: 3, 
    algorithm: 8, 
    key: Uint8Array([...]) 
  } 
}

KEY

{ 
  data: { 
    flags: 256, 
    protocol: 3, 
    algorithm: 5, 
    key: Uint8Array([...]) 
  } 
}

DS / CDS / DLV / TA

{ 
  data: { 
    keyTag: 12345, 
    algorithm: 8, 
    digestType: 2, 
    digest: Uint8Array([...]) 
  } 
}

RRSIG / SIG

{ 
  data: {
    typeCovered: 1, 
    algorithm: 8, 
    labels: 1,
    originalTTL: 300, 
    expiration: 1735689600, 
    inception: 1735603200,
    keyTag: 12345, 
    signersName: 'example.',
    signature: Uint8Array([...])
  }
}

NSEC

{ 
  data: { 
    nextDomainName: 'b.example.', 
    types: ['A','TXT','RRSIG'] 
  } 
}

NSEC3

{ 
  data: { 
    hashAlgorithm: 1, 
    flags: 0, 
    iterations: 10,
    salt: Uint8Array([0xde,0xad]), 
    nextHashedOwnerName: Uint8Array([...]),
    types: ['A','AAAA','RRSIG'] 
  } 
}

NSEC3PARAM

{ 
  data: { 
    hashAlgorithm: 1, 
    flags: 0, 
    iterations: 10, 
    salt: Uint8Array([0xde,0xad]) 
  } 
}

DHCID

{ data: { data: Uint8Array([...]) } }

TLSA / SMIMEA

{ 
  data: { 
    usage: 3, 
    selector: 1, 
    matchingType: 1, 
    certificate: Uint8Array([...]) 
  } 
}

CERT

{ data: { 
    certType: 1, 
    keyTag: 12345, 
    algorithm: 8, 
    certificate: Uint8Array([...]) 
  }
}

SSHFP

{ 
  data: { 
    algorithm: 1, 
    hash: 2, 
    fingerprint: Uint8Array([...]) 
  } 
}

IPSECKEY

{ 
  data: { 
    precedence: 10, 
    gatewayType: 3, 
    algorithm: 1,
    gateway: 'gw.example.', 
    publicKey: Uint8Array([...]) 
  } 
}

Location / Topology / Misc

LOC

{ 
  data: { 
    version: 0, 
    size: { 
      mant:0, 
      exp:0 
    }, 
    horizontal:{ 
      mant:0, 
      exp:0 
    },
    vertical:{ 
      mant:0, 
      exp:0 
    }, 
    latitude: 0, 
    longitude: 0, 
    altitude: 0 
  }
}

APL

{ data: { items: [ { family: 1, 
prefix: 24, 
neg: false, 
address: Uint8Array([192,0,2]) } ] } }

HIP

{ 
  data: { 
    algorithm: 1, 
    hit: Uint8Array([...]), 
    publicKey: Uint8Array([...]),
    servers: ['rendezvous1.example.'] 
  } 
}

NID

{ 
  data: { 
    preference: 10, 
    nodeIdHigh: 0x01234567, 
    nodeIdLow: 0x89abcdef 
  } 
}

L32

{ 
  data: { 
    preference: 10, 
    locator32: '192.0.2.55' 
  } 
}

L64

{ 
  data: { 
    preference: 10, 
    locator64High: 0x01234567, 
    locator64Low: 0x89abcdef 
  } 
}

LP

{ 
  data: { 
    preference: 10, 
    fqdn: 'locator.example.' 
  } 
}

EUI48 / EUI64

{ data: { address: Uint8Array([0x00,0x1A,0x2B,0x3C,0x4D,0x5E]) } }  // EUI48
{ data: { address: Uint8Array(8) } }                                  // EUI64

OPENPGPKEY

{ data: { key: Uint8Array([...]) } }

ZONEMD

{ 
  data: { 
    scheme: 1, 
    hashAlgorithm: 1, 
    digest: Uint8Array([...]) 
  } 
}

Transfer / Meta

TKEY

{ 
  data: { 
    algorithm: 'hmac-sha256.', 
    inception: 1710000000, 
    expiration: 1710003600,
    mode: 3, 
    error: 0, 
    key: Uint8Array([...]), 
    other: Uint8Array([]) 
  } 
}

TSIG

{ 
  data: { 
    algorithm: 'hmac-sha256.', 
    timeSignedHigh: 0, 
    timeSignedLow: 1710000123,
    fudge: 300, 
    mac: Uint8Array([...]), 
    originalId: 0x1234, 
    error: 0, 
    otherData: Uint8Array([]) 
  } 
}

IXFR, AXFR, ANY

{ data: { 
    raw: Uint8Array([]) 
  } 
}

Raw passthrough

Types preserved as raw (round-trip only): EID, NIMLOC, ATMA, SINK, NINFO, RKEY, TALINK.

{ data: { raw: Uint8Array([ ... ]) } }

Performance Notes

  • Keep RRset TTLs aligned with how often your dynamic decisions change.
  • Turn on the signature cache (dnssec.cache) β€” identical RRsets are then signed once per validity window instead of once per query.
  • A negative answer to a DNSSEC query costs one extra signature (the NSEC); giving res.types costs nothing extra.
  • The default ECS scope makes resolvers cache per client subnet, so they ask more often. Set res.ecsScope = 0 on answers that are the same for everyone.
  • If you accept high QPS, consider sharding instances per region and delegating via NS.

πŸ—ΊοΈ Roadmap

  • πŸ›‘οΈ DNSSEC
    • Key rollover helpers (ZSK/KSK) with automated timing β€” today one KSK and one ZSK per zone. CDS/CDNSKEY already carry a new KSK to the parent; what remains is publishing old and new keys side by side through the change.
  • πŸ“¦ Operational
    • DNS Cookies (RFC 7873) and response rate limiting.
    • Minimal responses to ANY (RFC 8482).
    • Metrics and logging hooks.
  • πŸ”§ Developer experience
    • TypeScript definitions.
  • πŸš€ Future
    • AXFR/IXFR secondary support with TSIG.

πŸ’‘ Want something added? Open a discussion or file an issue with the tag roadmap.

Troubleshooting FAQ

Q: My domain returns SERVFAIL after enabling DNSSEC.
A: The DS at your registrar must match your current KSK (key tag, algorithm, digest type, digest β€” or simply material.ds). The usual cause is keys that changed on restart: see Keep the keys stable. Check the chain with dnsviz.net.

Q: onError says "negative answer … has no SOA in authority".
A: A response with no answers must carry the zone's SOA in res.authority (RFC 2308). Push it in every branch that answers "no".

Q: Geo routing seems off.
A: Many resolvers don't send ECS β€” fall back to req.remoteAddress. If answers leak across regions, check that you did not set res.ecsScope = 0 on location-dependent answers.

Q: How do I serve both IPv4 and IPv6?
A: Push parallel A and AAAA answers. A name with no AAAA should answer NODATA (SOA in authority), not NXDOMAIN β€” the name exists.

Q: Responses to other domains have no AA flag.
A: That is intended: AA is set on NOERROR / NXDOMAIN only. Answer names outside your zones with REFUSED (rcode = 5).

Support the Project

If this library saves you time, consider supporting:

  • ⭐ Star the repo β€” helps visibility.
  • πŸ› Issues/PRs β€” report bugs, propose features.
  • πŸ’– Sponsorships β€” GitHub Sponsors or your preferred platform.
  • πŸ§ͺ Production stories β€” share how you use dnssec-server (helps guide the roadmap).

For commercial support or consulting, please open an issue titled [support] and we'll coordinate privately.

License

Apache License 2.0

Copyright Β© 2025 colocohen

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

References

  • RFC 1034 / 1035 β€” DNS concepts and implementation
  • RFC 2308 β€” Negative caching of DNS queries
  • RFC 4033 / 4034 / 4035 β€” DNSSEC
  • RFC 4470 β€” Minimally covering NSEC records and online signing
  • RFC 5155 β€” NSEC3
  • RFC 6840 β€” DNSSEC clarifications
  • RFC 6891 β€” EDNS(0)
  • RFC 7344 / 8078 β€” CDS / CDNSKEY
  • RFC 7858 β€” DNS over TLS
  • RFC 8484 β€” DNS over HTTPS
  • RFC 7871 β€” EDNS Client Subnet
  • RFC 8198 β€” Aggressive use of DNSSEC-validated cache
  • RFC 9077 β€” NSEC and NSEC3 TTLs
  • RFC 9250 β€” DNS over QUIC
  • RFC 9460 / 9461 β€” SVCB / HTTPS
  • RFC 9824 β€” Compact Denial of Existence in DNSSEC