Finally building out the DNS server

Ronit PandaRonit Panda
5 min read

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();
});
  1. 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');
      
  2. 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);
      
  3. 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}`);
            }
        });
      
  4. 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');
        }
      
  5. 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');
        }
      
  6. 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 and DNSCache 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);
      }
    
  1. 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);
      
  2. 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.

0
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