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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

### Unreleased

- test: split tests into 3 files, add 45 new tests
- feat(client): add retryOverTCP option #117
- feat(client): support `dns` argument, fix docs #116
- feat: add resolveSOA #115
- doc(README): add benchmark support #114
- feat: add typescript types file #113
- feat(packet): add RCODEs and usage docs #112
- feat(packet): add CAA decoding #111
- fix: reads across non-aligned bytes #111
- fix: avoid mutating in-place requests #111
- fix: avoid UTF8 corruption #111
- test: split tests into 3 files, add 45 new tests #108
- fix: drop mismatched IDs, filter senders, handle errs #104
- feat(client/doh): HTTP/2 transport #89
- feat(client/tcp): DNS-over-TLS support #88
- feat(packet): IPv6 subnet support in `EDNS.ECS.decode`
Expand Down
11 changes: 11 additions & 0 deletions client/udp.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module.exports = ({
port = 53,
socketType = 'udp4',
timeout = 10000,
retryOverTCP = true,
} = {}) => {
return (name, type = 'A', cls = Packet.CLASS.IN, options = {}) => {
const { clientIp, recursive = true } = options;
Expand Down Expand Up @@ -64,6 +65,16 @@ module.exports = ({
response.header.id, query.header.id);
return;
}
// RFC 1035 Β§4.2.1: if the TC (truncated) bit is set the upstream had
// more data than fits in a 512-byte UDP datagram. Retry over TCP so
// callers always receive a complete answer.
if (response.header.tc && retryOverTCP) {
debug('udp: TC bit set β€” retrying query over TCP');
cleanup();
const TCPClient = require('./tcp');
resolve(TCPClient({ dns, port })(name, type, cls, options));
return;
}
cleanup();
resolve(response);
}
Expand Down
5 changes: 3 additions & 2 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class DNS extends EventEmitter {
retries : 3,
timeout : 3,
recursive : true,
retryOverTCP : true,
resolverProtocol : 'UDP',
nameServers : [
'8.8.8.8',
Expand All @@ -46,10 +47,10 @@ class DNS extends EventEmitter {
* @param {*} cls
*/
resolve(domain, type = 'ANY', cls = DNS.Packet.CLASS.IN, options = {}) {
const { port, nameServers, resolverProtocol = 'UDP' } = this;
const { port, nameServers, resolverProtocol = 'UDP', retryOverTCP } = this;
const createResolver = DNS[resolverProtocol + 'Client'];
return Promise.race(nameServers.map(address => {
const resolve = createResolver({ dns: address, port });
const resolve = createResolver({ dns: address, port, retryOverTCP });
return resolve(domain, type, cls, options);
}));
}
Expand Down
77 changes: 77 additions & 0 deletions test/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,3 +331,80 @@ test('dns#constructor dns alias does not override explicit nameServers', () => {
assert.deepEqual(dns.nameServers, [ '9.9.9.9' ],
'explicit nameServers takes precedence over dns alias');
});

// ── Issue #45 β€” TC-bit fallback ───────────────────────────────────────────────

test('client/udp falls back to TCP when TC bit is set', async() => {
// DNS allows UDP and TCP to share a port. createServer binds both transports
// to the same port, letting the UDPClient's TC fallback reach the TCP server.
const net = require('node:net');

// Find a port that is free on both TCP and UDP.
const port = await new Promise(resolve => {
const probe = net.createServer();
probe.listen(0, '127.0.0.1', () => {
const { port } = probe.address();
probe.close(() => resolve(port));
});
});

const server = DNS.createServer({ udp: true, tcp: true });

server.on('request', (request, send, client) => {
const response = Packet.createResponseFromRequest(request);
// dgram.RemoteInfo (UDP) has a `family` string; net.Socket (TCP) does not.
const isUDP = typeof client.family === 'string';
if (isUDP) {
// Simulate a resolver that has more data than fits in 512 bytes.
response.header.tc = 1;
}
for (let i = 1; i <= 5; i++) {
response.answers.push({
name : request.questions[0].name,
type : Packet.TYPE.A,
class : Packet.CLASS.IN,
ttl : 300,
address : `10.0.0.${i}`,
});
}
send(response);
});

await server.listen({
udp : { port, address: '127.0.0.1' },
tcp : { port, address: '127.0.0.1' },
});

const query = UDPClient({ dns: '127.0.0.1', port, timeout: 3000 });
const reply = await query('tc.fallback');
assert.equal(reply.header.tc, 0, 'TCP response does not have TC set');
assert.equal(reply.answers.length, 5, 'all 5 answers received after TCP fallback');

await server.close();
});

test('client/udp respects retryOverTCP:false and returns truncated packet', async() => {
const udpServer = udp.createSocket('udp4');
await new Promise(resolve => udpServer.bind(0, '127.0.0.1', resolve));
const { port: serverPort } = udpServer.address();

udpServer.on('message', (msg, rinfo) => {
const request = Packet.parse(msg);
const response = Packet.createResponseFromRequest(request);
response.header.tc = 1;
response.answers.push({
name : request.questions[0].name,
type : Packet.TYPE.A,
class : Packet.CLASS.IN,
ttl : 300,
address : '1.2.3.4',
});
udpServer.send(response.toBuffer(), rinfo.port, rinfo.address);
});

const query = UDPClient({ dns: '127.0.0.1', port: serverPort, timeout: 2000, retryOverTCP: false });
const reply = await query('truncated.test');
assert.equal(reply.header.tc, 1, 'TC bit is set (no fallback)');
assert.equal(reply.answers.length, 1, 'only the truncated single answer returned');
await new Promise(resolve => udpServer.close(resolve));
});
4 changes: 4 additions & 0 deletions ts/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,8 @@ declare namespace DNS {
retries: number;
timeout: number;
recursive: boolean;
/** When using UDP and the TC (truncated) bit is set, automatically retry over TCP. Default: `true`. */
retryOverTCP: boolean;
resolverProtocol: 'UDP' | 'TCP' | 'DOH' | 'Google';
/** Shorthand alias for `nameServers`. A single IP string or an array. */
dns?: string | string[];
Expand All @@ -318,6 +320,8 @@ declare namespace DNS {
port?: number;
socketType?: dgram.SocketType;
timeout?: number;
/** When the TC (truncated) bit is set, automatically retry over TCP. Default: `true`. */
retryOverTCP?: boolean;
}

interface TcpClientOptions {
Expand Down