Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,36 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

### [3.1.0] - 2026-07-26

- chore: declare Node.js 22 as the floor
- feat(packet): Extended DNS Errors (RFC 8914) — `Packet.EDE` INFO-CODEs, an EDE
EDNS option codec, and `Packet.createErrorResponseFromRequest`
- feat(packet): `Packet.RCODE` completed from the IANA registry — 6-11 and 16-23
- feat(ts): `Packet.RCODE` was missing from the type declarations entirely
- feat(packet): Packet.parse stops at a failure that leaves the reader misaligned
- feat(packet): RDLENGTH bounds every rdata decoder, so a malformed record no longer cascades (RFC 1035 §4.1.3)
- feat(packet): `Packet.typeName`, `Packet.TYPE_NAME`, `Packet.EDNS_OPTION_NAME`
- feat(client/udp): a query timeout names the last dropped response and why
- feat(index): `resolve()` uses `Promise.any`, so one dead NS no longer fails all
- fix(packet): parse throws `Packet.DecodeError` for a message with no usable header
- fix(packet): parse no longer swallows per-record decode failures, reports on `packet.errors`
- fix(packet): encoding a record or question whose TYPE/CLASS is not a 16-bit int throws
- fix(packet): encoding an A/AAAA record with an invalid address throws, was `0.0.0.0`
- fix(packet): RRSIG was unreachable — `Packet.TYPE.RRSIG` (46) was missing
- fix(packet): RRSIG timestamps mixed the local-time year with UTC fields
- fix(packet): RRSIG retains raw rdata so re-serializing keeps the signature
- fix(packet): EDNS skipped unknown options by bits instead of octets
- fix(packet): length validation for A, AAAA, CAA, DNSKEY, RRSIG and ECS rdata
- fix(packet): `Packet.readStream` rejects a truncated message
- fix: DNS option timeout is ms and now reaches the client (default `3000`)
- fix(index): `resolveA(domain, clientIp)` never sent the ECS option
- fix(client/doh): a non-200 response rejected and then resolved
- fix(packet): drop the `Array.prototype.flatMap` polyfill for Node 10

### [3.0.0] - 2026-05-26

- **BREAKING**, TXT `data` is now always an array of strings
- BREAKING: TXT `data` is now always an array of strings
- fix(packet): TXT decode preserves character-string boundaries (RFC 1035 §3.3.14)

### [2.4.0] - 2026-05-26
Expand Down Expand Up @@ -82,3 +109,4 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
[2.3.0]: https://github.com/lsongdev/node-dns/releases/tag/v2.3.0
[2.2.0]: https://github.com/lsongdev/node-dns/releases/tag/v2.2.0
[2.2.1]: https://github.com/lsongdev/node-dns/releases/tag/v2.2.1
[3.1.0]: https://github.com/lsongdev/node-dns/releases/tag/v3.1.0
160 changes: 152 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const options = {
// nameServers: ['8.8.8.8'] — array of DNS server IPs (default: Google + 114dns)
// port: 53 — DNS server port (number)
// recursive: true — Recursion Desired flag (boolean, default true)
// timeout: 3000 — per-name-server timeout in milliseconds
};
const dns = new dns2(options);

Expand All @@ -53,6 +54,128 @@ The high-level `DNS` class exposes convenience methods for common record types:

For any record type not listed above, use `dns.resolve(domain, 'TYPE')` directly.

Every name server is queried in parallel and the first successful reply wins. If
all of them fail, the rejection names each server and its reason.

### When a packet fails to decode

`Packet.parse` throws a `Packet.DecodeError` when a message cannot be decoded at
all — it is shorter than a 12-octet header, or not a Buffer:

```js
try {
Packet.parse(buffer);
} catch (err) {
// "message is 7 octets, too short for the 12-octet header (RFC 1035 §4.1.1)"
console.error(err.message);
}
```

Once the header decodes, a malformed _record_ no longer disappears silently.
Records that cannot be decoded are dropped — a half-populated record would be
worse than none — and the reason is reported on `packet.errors`:

```js
const packet = Packet.parse(buffer);

for (const err of packet.errors) {
console.error(err.message);
// "answers[0] at offset 12: TXT decode: character-string of 10 octets
// overruns RDATA (4 octets remaining)"
err.section; // 'questions' | 'answers' | 'authorities' | 'additionals'
err.index; // position within that section
err.offset; // octet offset in the message where the record started
err.recovered; // see below
}
```

`packet.errors` is empty for a clean parse, so `packet.errors.length` is the test
for "did anything go wrong". Comparing a section's length against its header
count (`packet.answers.length` vs `packet.header.ancount`) shows how much of the
message survived.

`err.recovered` distinguishes the two kinds of failure:

- `true` — the damage was confined to one record's RDATA. RDLENGTH says where
the next record begins, so decoding continued and later records are intact.
- `false` — the failure left the reader misaligned (a truncated message, or a
name that ran off the end). Nothing after that point can be located, so
decoding stopped there instead of emitting junk records.

Servers surface the same information. A query that cannot be decoded at all
raises `requestError`; one that partially decodes reaches the handler with
`request.errors` populated, which is enough to answer `FORMERR` if you prefer:

```js
dns2
.createServer({
udp: true,
handle: (request, send) => {
if (request.errors.length) {
const response = Packet.createResponseFromRequest(request);
response.header.rcode = Packet.RCODE.FORMERR;
return send(response);
}
// ...
},
})
.on('requestError', err => console.error('undecodable query:', err.message));
```

#### Telling the client why: Extended DNS Errors

`FORMERR` says "malformed" in four bits and nothing more. RFC 8914 Extended DNS
Errors add an EDNS option carrying an INFO-CODE plus free-form text, which is
where a decode reason belongs. `Packet.EDE.INVALID_DATA` (24) is the code for
data that could not be interpreted.

`Packet.createErrorResponseFromRequest` composes the whole reply:

```js
const server = dns2.createServer({
udp: true,
handle: (request, send) => {
if (request.errors.length) {
return send(
Packet.createErrorResponseFromRequest(request, Packet.RCODE.FORMERR, {
infoCode: Packet.EDE.INVALID_DATA,
extraText: request.errors.map(e => e.message).join('; '),
}),
);
}
// ... normal handling
},
});
```

A client reads it back off any response:

```js
const response = await resolve('example.com');
const opt = response.additionals.find(r => r.type === Packet.TYPE.EDNS);
for (const option of opt?.rdata ?? []) {
if (option.ednsCode !== Packet.EDNS_OPTION_CODE.EDE) continue;
console.error(
`${Packet.EDE_NAME[option.infoCode] ?? option.infoCode}: ${option.extraText}`,
);
// "INVALID_DATA: answers[0] at offset 12: TXT decode: character-string ..."
}
```

Extended errors are additive — they annotate a response without changing its
RCODE, and appear on `NOERROR` responses too. Three details the builder handles:

- The option is attached **only when the request carried an OPT record**
(RFC 8914 §3), since a client that didn't signal EDNS cannot be sent EDNS
options. A request too malformed to have a readable OPT gets a bare RCODE.
- An OPT is added regardless when the RCODE exceeds 15, so its high byte
survives serialization — otherwise `BADVERS` would go out as `NOERROR`.
- `extraText` is truncated to `Packet.EDE_MAX_TEXT` (256), because the reply
still has to fit the negotiated UDP payload size.

`Packet.EDE` holds the full registry of INFO-CODEs and `Packet.EDE_NAME` maps a
received code back to its name.

#### Example: SOA record lookup

SOA (Start of Authority) records contain the authoritative zone information for a domain.
Expand Down Expand Up @@ -227,14 +350,35 @@ will be found in `request.questions[0].name`.

Use `Packet.RCODE` to send standard DNS error responses from your handler:

| Constant | Value | Meaning |
| ----------------------- | ----- | ------------------- |
| `Packet.RCODE.NOERROR` | 0 | No error |
| `Packet.RCODE.FORMERR` | 1 | Format error |
| `Packet.RCODE.SERVFAIL` | 2 | Server failure |
| `Packet.RCODE.NXDOMAIN` | 3 | Non-existent domain |
| `Packet.RCODE.NOTIMP` | 4 | Not implemented |
| `Packet.RCODE.REFUSED` | 5 | Query refused |
| Constant | Value | Meaning |
| ------------------------ | ----- | ---------------------------------- |
| `Packet.RCODE.NOERROR` | 0 | No error |
| `Packet.RCODE.FORMERR` | 1 | Format error |
| `Packet.RCODE.SERVFAIL` | 2 | Server failure |
| `Packet.RCODE.NXDOMAIN` | 3 | Non-existent domain |
| `Packet.RCODE.NOTIMP` | 4 | Not implemented |
| `Packet.RCODE.REFUSED` | 5 | Query refused |
| `Packet.RCODE.YXDOMAIN` | 6 | Name exists when it should not |
| `Packet.RCODE.YXRRSET` | 7 | RRset exists when it should not |
| `Packet.RCODE.NXRRSET` | 8 | RRset that should exist does not |
| `Packet.RCODE.NOTAUTH` | 9 | Not authoritative / not authorized |
| `Packet.RCODE.NOTZONE` | 10 | Name not contained in zone |
| `Packet.RCODE.DSOTYPENI` | 11 | DSO-TYPE not implemented |
| `Packet.RCODE.BADVERS` | 16 | Bad OPT version |
| `Packet.RCODE.BADSIG` | 16 | TSIG signature failure |
| `Packet.RCODE.BADKEY` | 17 | Key not recognized |
| `Packet.RCODE.BADTIME` | 18 | Signature out of time window |
| `Packet.RCODE.BADMODE` | 19 | Bad TKEY mode |
| `Packet.RCODE.BADNAME` | 20 | Duplicate key name |
| `Packet.RCODE.BADALG` | 21 | Algorithm not supported |
| `Packet.RCODE.BADTRUNC` | 22 | Bad truncation |
| `Packet.RCODE.BADCOOKIE` | 23 | Bad or missing server cookie |

Codes above 15 do not fit the header's 4-bit RCODE field — their high byte
travels in an OPT record's TTL (RFC 6891 §6.1.3), so a response using one must
carry an OPT. `Packet.createErrorResponseFromRequest` attaches one for you.
Note that 16 has two names in the IANA registry: they share the code point on
the wire, and only context distinguishes them.

```js
const dns2 = require('dns2');
Expand Down
4 changes: 3 additions & 1 deletion client/doh.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ const readStream = res =>
.on('end', () => {
const data = Buffer.concat(chunks);
if (res.statusCode !== 200) {
reject(new Error(`HTTP ${res.statusCode}: ${data.toString()}`));
return reject(
new Error(`HTTP ${res.statusCode}: ${data.toString()}`),
);
}
resolve(data);
});
Expand Down
8 changes: 8 additions & 0 deletions client/tcp.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
const tls = require('node:tls');
const tcp = require('node:net');
const Packet = require('../packet');
const { debuglog } = require('node:util');

const debug = debuglog('dns2');

const makeQuery = ({
name,
Expand Down Expand Up @@ -46,6 +49,11 @@ const TCPClient = ({
const message = makeQuery({ name, type, cls, ...options });
const [host] = dns.split(':');
const client = protocols[protocol](host, port);
// The socket outlives the single-message read below — we still end() it —
// and Packet.readStream releases its own listeners once it has the message.
// An 'error' with no listener is fatal to the process, so hold one here for
// the socket's whole life; readStream's rejection reports the failure.
client.on('error', err => debug('tcp: socket error: %s', err.message));

sendQuery(client, message);
const data = await Packet.readStream(client);
Expand Down
27 changes: 24 additions & 3 deletions client/udp.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
const udp = require('node:dgram');
const net = require('node:net');
const crypto = require('node:crypto');
const Packet = require('../packet');
const { debuglog } = require('node:util');

Expand All @@ -16,7 +15,7 @@ module.exports = ({
return (name, type = 'A', cls = Packet.CLASS.IN, options = {}) => {
const { clientIp, recursive = true } = options;
const query = new Packet();
query.header.id = crypto.randomInt(0x10000);
query.header.id = Packet.uuid();
// see https://github.com/song940/node-dns/issues/29
if (recursive) {
query.header.rd = 1;
Expand All @@ -38,6 +37,10 @@ module.exports = ({
return new Promise((resolve, reject) => {
let settled = false;
let timer;
// Packets that cannot be decoded are dropped, a forged or corrupt datagram
// must not pre-empt the real reply. Keep the reason so a subsequent timeout
// can explain itself
let lastDropped;
const cleanup = () => {
if (settled) return;
settled = true;
Expand All @@ -56,6 +59,9 @@ module.exports = ({
rinfo.port !== port ||
(expectedAddress && rinfo.address !== expectedAddress)
) {
lastDropped =
`packet came from ${rinfo.address}:${rinfo.port}, not the ` +
`configured resolver ${dns}:${port}`;
debug(
'udp: dropping packet from unexpected sender %s:%d',
rinfo.address,
Expand All @@ -67,11 +73,23 @@ module.exports = ({
try {
response = Packet.parse(message);
} catch (e) {
lastDropped = `response could not be decoded: ${e.message}`;
debug('udp: dropping unparseable packet: %s', e.message);
return;
}
if (response.errors.length) {
debug(
'udp: response %d decoded with %d error(s): %s',
response.header.id,
response.errors.length,
response.errors.map(e => e.message).join('; '),
);
}
// Stray / late reply from a reused ephemeral port — keep listening.
if (response.header.id !== query.header.id) {
lastDropped =
`response id ${response.header.id} did not match the query ` +
`id ${query.header.id}`;
debug(
'udp: dropping response with mismatched id %d (expected %d)',
response.header.id,
Expand Down Expand Up @@ -102,7 +120,10 @@ module.exports = ({
if (timeout > 0) {
timer = setTimeout(() => {
cleanup();
const err = new Error(`DNS query timed out after ${timeout}ms`);
const err = new Error(
`DNS query timed out after ${timeout}ms` +
(lastDropped ? ` (last ${lastDropped})` : ''),
);
err.code = 'ETIMEDOUT';
reject(err);
}, timeout);
Expand Down
47 changes: 37 additions & 10 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ class DNS extends EventEmitter {
{
port: 53,
retries: 3,
timeout: 3,
timeout: 3000, // milliseconds
recursive: true,
retryOverTCP: true,
resolverProtocol: 'UDP',
Expand Down Expand Up @@ -60,19 +60,46 @@ class DNS extends EventEmitter {
* @param {*} type
* @param {*} cls
*/
resolve(domain, type = 'ANY', cls = DNS.Packet.CLASS.IN, options = {}) {
const { port, nameServers, resolverProtocol = 'UDP', retryOverTCP } = this;
async resolve(domain, type = 'ANY', cls = DNS.Packet.CLASS.IN, options = {}) {
const {
port,
nameServers,
resolverProtocol = 'UDP',
retryOverTCP,
timeout,
} = this;
const createResolver = DNS[resolverProtocol + 'Client'];
return Promise.race(
nameServers.map(address => {
const resolve = createResolver({ dns: address, port, retryOverTCP });
return resolve(domain, type, cls, options);
}),
);
try {
// Promise.any, so one unreachable or misbehaving name server doesn't
// fail the lookup while another is able to answer.
return await Promise.any(
nameServers.map(address => {
const resolve = createResolver({
dns: address,
port,
retryOverTCP,
timeout,
});
return resolve(domain, type, cls, options);
}),
);
} catch (e) {
// AggregateError's own message ("All promises were rejected") hides which
// server failed and why; name each one.
if (!(e instanceof AggregateError)) throw e;
const reasons = nameServers
.map((address, i) => `${address}: ${e.errors[i]?.message}`)
.join('; ');
throw new Error(
`${type} lookup of ${domain} failed on all ${nameServers.length} ` +
`name server(s) — ${reasons}`,
{ cause: e },
);
}
}

resolveA(domain, clientIp) {
return this.resolve(domain, 'A', undefined, clientIp);
return this.resolve(domain, 'A', undefined, clientIp ? { clientIp } : {});
}

resolveAAAA(domain) {
Expand Down
Loading