Finally building out the DNS server
Now that our parser and builder is ready and tested, let's move on to building the UDP server that is actually going to resolve DNS queries for us
if you don't have the codebase yet:https://github.com/rtpa25/dns-server
In this blog we will see and understand the structure of our UDP server and then in the upcoming blogs we will take a deep dive into each functional component.
Brief discussion about UDP first:
- Connectionless Protocol: UDP is a connectionless protocol, meaning it does not establish a connection before sending data and does not guarantee delivery.
Datagram Communication: Data is sent in discrete chunks called datagrams, which are independent and may arrive out of order or not at all.
Lightweight: Due to its simplicity, UDP is faster and has lower overhead compared to TCP, making it suitable for applications where speed is critical, such as video streaming or DNS servers.
pasting the whole code below as usual and we will try to understand what each and every line does in detail (src/udp-server.ts)
import * as dgram from 'node:dgram';
import { DNSCache } from './dns-cache';
import { DNSBuilder } from './message/builder';
import { dnsParser } from './message/parser';
import { Bool, DNSObject, QRIndicator } from './message/types';
import { recursiveLookup } from './reccursive-resolver';
import { redis } from './redis';
const udpSocket: dgram.Socket = dgram.createSocket('udp4');
udpSocket.bind(2053, '127.0.0.1');
console.log('UDP server is running on port 2053');
const dnsCache = new DNSCache(redis);
udpSocket.on('message', async (data: Buffer, remoteAddr: dgram.RemoteInfo) => {
try {
const { questions: reqQuestionPacket, header: reqHeaderPacket } =
dnsParser.parse(data);
if (reqQuestionPacket.length !== reqHeaderPacket.QDCOUNT) {
throw new Error(
'Question count does not match the number of questions found',
);
}
if (reqHeaderPacket.ANCOUNT > 0) {
throw new Error('Answer count must be 0');
}
if (reqHeaderPacket.QR !== 0) {
throw new Error('QR bit must be 0 to be considered as a valid query');
}
if (reqQuestionPacket.length !== 1 || reqHeaderPacket.QDCOUNT !== 1) {
throw new Error('Only one question per request is allowed');
}
// we are only interested in the first question in the packet
const question = reqQuestionPacket[0];
if (!question) {
throw new Error('No question found in the packet');
}
let responseObject: DNSObject;
// try to fetch from cache first
const cachedAnswers = await dnsCache.get(question);
if (cachedAnswers.length > 0) {
responseObject = {
header: {
...reqHeaderPacket,
QR: QRIndicator.RESPONSE,
RA: Bool.TRUE,
ANCOUNT: cachedAnswers.length,
},
questions: [question],
answers: cachedAnswers,
additional: [],
authority: [],
};
} else {
responseObject = await recursiveLookup(question, reqHeaderPacket);
if (responseObject.answers)
await dnsCache.set(question, responseObject.answers);
}
const dnsBuilder = new DNSBuilder(responseObject);
const response = dnsBuilder.toBuffer();
udpSocket.send(response, remoteAddr.port, remoteAddr.address);
} catch (e) {
console.error(`Error sending data: ${e}`);
}
});
udpSocket.on('error', (err) => {
console.error(`Error: ${err}`);
udpSocket.close();
});
Creating the UDP Socket:
Import the
dgram
module.Create and bind the UDP socket to port 2053.
Log the server status.
const udpSocket: dgram.Socket = dgram.createSocket('udp4'); udpSocket.bind(2053, '127.0.0.1'); console.log('UDP server is running on port 2053');
Setting Up DNS Cache:
Import the DNSCache module.
Create an instance of DNSCache using Redis.
Now, we will learn about implementing the DNSCache class in detail in upcoming blogs. For now, it's enough to know that this instance is used as a cache store. We check if a query can be resolved from the cache store itself without performing a recursive resolution, saving time for users and resources for us.
const dnsCache = new DNSCache(redis);
Handling Incoming Messages:
Listening for the
message
event on the UDP socket.Parsing the incoming DNS request using the custom
dnsParser
.This is the same parser we built a few blogs ago. It takes the entire buffer request and parses it into a valid DNS object, which we can use in our application layer.
udpSocket.on('message', async (data: Buffer, remoteAddr: dgram.RemoteInfo) => { try { const { questions: reqQuestionPacket, header: reqHeaderPacket } = dnsParser.parse(data); ... } catch (e) { console.error(`Error sending data: ${e}`); } });
Validating the Request:
Checking the question count.
Ensuring the answer count is zero. (because this should be a question request)
Validating the QR bit. (for it to be a valid dns query qr flag should be set to 0)
Ensuring only one question per request.
if (reqQuestionPacket.length !== reqHeaderPacket.QDCOUNT) { throw new Error('Question count does not match the number of questions found'); } if (reqHeaderPacket.ANCOUNT > 0) { throw new Error('Answer count must be 0'); } if (reqHeaderPacket.QR !== 0) { throw new Error('QR bit must be 0 to be considered as a valid query'); } if (reqQuestionPacket.length !== 1 || reqHeaderPacket.QDCOUNT !== 1) { throw new Error('Only one question per request is allowed'); }
Extracting the first question:
Extracting the question from the packet.
Checking if the question exists.
const question = reqQuestionPacket[0]; if (!question) { throw new Error('No question found in the packet'); }
Fetching from Cache or Performing Recursive Lookup:
- This is where all the dns magic happens
Attempting to fetch the answer from the cache.
If not found, performing a recursive lookup.
Caching the result if obtained from the recursive lookup.
For now just understanding the outline is enough, we will have two in depth blogs actually coding out both the
recursiveLookup
function andDNSCache
class.let responseObject: DNSObject; const cachedAnswers = await dnsCache.get(question); if (cachedAnswers.length > 0) { responseObject = { header: { ...reqHeaderPacket, QR: QRIndicator.RESPONSE, RA: Bool.TRUE, ANCOUNT: cachedAnswers.length, }, questions: [question], answers: cachedAnswers, additional: [], authority: [], }; } else { responseObject = await recursiveLookup(question, reqHeaderPacket); if (responseObject.answers) await dnsCache.set(question, responseObject.answers); }
Building and Sending the Response:
This is where we convert the response object into an actual buffer which can be transferred over UDP, using our DNSBuilder
We send it to to the remote address from where we received the request
const dnsBuilder = new DNSBuilder(responseObject); const response = dnsBuilder.toBuffer(); udpSocket.send(response, remoteAddr.port, remoteAddr.address);
Error Handling:
Here we are listening to the error event, for me am just logging the error, you might wanna send some response with the error info to the sender
And we also close the socket here
udpSocket.on('error', (err) => { console.error(`Error: ${err}`); udpSocket.close(); });
Now that we have the full understanding of the outline let's move to the next blog where we will be working on actively coding the recursive lookup function.
Subscribe to my newsletter
Read articles from Ronit Panda directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
Ronit Panda
Ronit Panda
Founding full stack engineer at dimension.dev