diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d588a..3d1f18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/client/udp.js b/client/udp.js index 9fa92bf..844c432 100644 --- a/client/udp.js +++ b/client/udp.js @@ -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; @@ -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); } diff --git a/index.js b/index.js index 735e062..60251bd 100644 --- a/index.js +++ b/index.js @@ -27,6 +27,7 @@ class DNS extends EventEmitter { retries : 3, timeout : 3, recursive : true, + retryOverTCP : true, resolverProtocol : 'UDP', nameServers : [ '8.8.8.8', @@ -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); })); } diff --git a/test/client.js b/test/client.js index fc89029..6426259 100644 --- a/test/client.js +++ b/test/client.js @@ -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)); +}); diff --git a/ts/index.d.ts b/ts/index.d.ts index d797b86..323c97f 100644 --- a/ts/index.d.ts +++ b/ts/index.d.ts @@ -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[]; @@ -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 {