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
Maintainers
Readme
Table of Contents
- β¨ Why this library?
- π¦ Install
- π Quick Start
- βοΈ API
- π« Negative answers (NODATA / NXDOMAIN)
- π Enabling DNSSEC
- π ECS (EDNS Client Subnet)
- π Encrypted transports (DoT, DoQ)
- π DNS-over-HTTPS (DoH)
- π¦ Migrating from Zone Files
- π§© Dynamic DNS Recipes
- π Supported Record Types
- Performance Notes
- πΊοΈ Roadmap
- Troubleshooting FAQ
- Support the Project
- License
- References
β¨ 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/HTTPSfor service discovery, toTLSAfor DANE,CAAfor certificate policy, andURIfor 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-serverThe 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) β typicallyIN.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 (
AAAAfor a name with onlyA).rcodestays 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.typesfor 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,DNSKEYis 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 AUTHORITYThen 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 /16ecsDefaultScope 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
SNICallbackreturns a lemon-tls context (lemonTls.createSecureContext) β not anode:tlsone. - 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
infocarries 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 };transportis whatreq.transportshows the handler ('https'here).cb(err, responseBytes, info)βerrwhen the bytes are not a DNS message (answer HTTP 400);info.ttlis the smallest TTL in the response, formax-age,undefinedwhen 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().
$ORIGINand$TTLare honored (or passparseZone(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-groupsandkeyNNNNNβ quoted or not, with escapes (alpn="h3,h2",alpn=h2\,x). AliasMode (priority 0) takes no keys; a repeated key, or amandatorynaming a key that is absent, is an error with the line number.echis checked to be a well-formed ECHConfigList. A/AAAA from the hints are added toadditionals. - CAA (
0 issue "letsencrypt.org"), and text escapes as RFC 1035 Β§5.1 defines them:\065is 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
typesat 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 Uint8ArrayName-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) } } // EUI64OPENPGPKEY
{ 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.typescosts nothing extra. - The default ECS scope makes resolvers cache per client subnet, so they ask more often. Set
res.ecsScope = 0on 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
