Writing a dns packet builder

Ronit PandaRonit Panda
14 min read

Honestly, if you are a web engineer who hasn't done much low-level work in your career just like me, following the upcoming blogs might be a bit challenging.

That's because we will be working with binary buffer data, understanding each bit and byte. You might need a refresher on bit manipulation and some recursion for the upcoming blogs.

But don't doubt yourself. It took me almost 3-4 weeks to build this DNS server on my own (I have a full-time job). So, this stuff will take time.

Now that we've finished the pep talk, let's move on to writing our DNS builder.

let's straight move on to src/message/builder.ts
if you don't have the codebase yet:https://github.com/rtpa25/dns-server

lemme paste the whole code block below then we can go part by part what each line does

import { DNSAnswer, DNSObject } from './types';

export class DNSBuilder {
    constructor(private dnsObject: DNSObject) {}

    private calculateSectionBufferSize(section: DNSAnswer[]): number {
        let sectionBufferSize = 0;
        for (const entry of section) {
            sectionBufferSize += 10; // 2 bytes for TYPE, 2 bytes for CLASS, 4 bytes for TTL, 2 bytes for RDLENGTH
            entry.NAME.split('.').forEach((label: string) => {
                sectionBufferSize += label.length + 1; // 1 byte for the length of the label
            });
            sectionBufferSize++; // for the terminating 0
            sectionBufferSize += entry.RDLENGTH; // for RDATA
        }
        return sectionBufferSize;
    }

    private writeSectionToBuffer(
        section: DNSAnswer[],
        buffer: Buffer,
        offset: number,
    ): number {
        for (const entry of section) {
            entry.NAME.split('.').forEach((label: string) => {
                buffer.writeUInt8(label.length, offset++);
                buffer.write(label, offset);
                offset += label.length;
            });
            buffer.writeUInt8(0, offset++); // write the terminating 0

            buffer.writeUInt16BE(entry.TYPE, offset);
            offset += 2;
            buffer.writeUInt16BE(entry.CLASS, offset);
            offset += 2;
            buffer.writeUInt32BE(entry.TTL, offset);
            offset += 4;
            buffer.writeUInt16BE(entry.RDLENGTH, offset);
            offset += 2;
            entry.RDATA.copy(buffer, offset); // write the RDATA buffer
            offset += entry.RDLENGTH; // Move offset by the length of RDATA
        }
        return offset;
    }

    public toBuffer(): Buffer {
        const {
            header,
            questions,
            answers = [],
            authority = [],
            additional = [],
        } = this.dnsObject;
        try {
            //#region  //*=========== Allocate buffer of required size in bytes ===========
            const hBuffSize = 12;

            //#region  //*=========== question buffer size ===========
            let qBuffSize = 0;
            for (const question of questions) {
                qBuffSize += 4; // 2 bytes for QTYPE and 2 bytes for QCLASS
                question.NAME.split('.').forEach((label: string) => {
                    qBuffSize += label.length + 1; // 1 byte for the length of the label
                });
                qBuffSize++; // for the terminating 0
            }
            //#endregion  //*======== question buffer size ===========

            //#region  //*=========== answer, authority, additional buffer sizes ===========
            const aBuffSize = this.calculateSectionBufferSize(answers);
            const nsBuffSize = this.calculateSectionBufferSize(authority);
            const arBuffSize = this.calculateSectionBufferSize(additional);
            //#endregion  //*======== answer, authority, additional buffer sizes ===========

            const allocSize =
                hBuffSize + qBuffSize + aBuffSize + nsBuffSize + arBuffSize;
            const response: Buffer = Buffer.alloc(allocSize);
            //#endregion  //*======== Allocate buffer of required size in bytes ===========

            //#region  //*=========== Populate header ===========
            response.writeUInt16BE(header.ID, 0);
            response.writeUInt16BE(
                (header.QR << 15) |
                    (header.OPCODE << 11) |
                    (header.AA << 10) |
                    (header.TC << 9) |
                    (header.RD << 8) |
                    (header.RA << 7) |
                    (header.Z << 4) |
                    header.RCODE,
                2,
            );
            response.writeUInt16BE(header.QDCOUNT, 4);
            response.writeUInt16BE(header.ANCOUNT, 6);
            response.writeUInt16BE(header.NSCOUNT, 8);
            response.writeUInt16BE(header.ARCOUNT, 10);
            //#endregion  //*======== Populate header ===========

            let offset = 12;
            //#region  //*=========== Populate question ===========
            for (const question of questions) {
                question.NAME.split('.').forEach((label: string) => {
                    response.writeUInt8(label.length, offset++);
                    response.write(label, offset);
                    offset += label.length;
                });
                response.writeUInt8(0, offset++); // write the terminating 0

                response.writeUInt16BE(question.TYPE, offset);
                offset += 2;
                response.writeUInt16BE(question.CLASS, offset);
                offset += 2;
            }
            //#endregion  //*======== Populate question ===========

            //#region  //*=========== Populate answer, authority, additional ===========
            offset = this.writeSectionToBuffer(answers, response, offset);
            offset = this.writeSectionToBuffer(authority, response, offset);
            offset = this.writeSectionToBuffer(additional, response, offset);
            //#endregion  //*======== Populate answer, authority, additional ===========

            return response;
        } catch (error) {
            return Buffer.alloc(0);
        }
    }
}

First we define the DNSBuilder class, which takes in a dnsObject as an argument in it's constructor

you can ignore the first 2 private methods for now and we will circle back to them shortly. First let's analyse public toBuffer() function

to give a brief description of this method it simply takes the dnsObject we passed inside the constructor and converts it into a buffer as the name suggests

  1.   public toBuffer(): Buffer {
          const {
              header,
              questions,
              answers = [],
              authority = [],
              additional = [],
          } = this.dnsObject;
    

    in this line we simply extract out the header, question and answer sections from the dnsObject for the 3a's we initialise them with empty array when they are undefined(these sections will be undefined when coming from a request packet or their is not answers)

  2.   //#region  //*=========== Allocate buffer of required size in bytes ===========
      const hBuffSize = 12;
    

    In the upcoming blocks we will be calculating the buffer size in bytes that we need to allocate in order to convert the object into buffer & we start with hBuffSize which means the header buffer size, and remember from the last blog a DNSHeader is always 12 bytes

  3.   //#region  //*=========== question buffer size ===========
      let qBuffSize = 0;
      for (const question of questions) {
          qBuffSize += 4; // 2 bytes for QTYPE and 2 bytes for QCLASS
          question.NAME.split('.').forEach((label: string) => {
              qBuffSize += label.length + 1; // 1 byte for the length of the label
          });
          qBuffSize++; // for the terminating 0
      }
      //#endregion  //*======== question buffer size ===========
    

    Now we calculate how much memory in bytes we need to allocate for the question. We start with qBuffSize as 0, then we loop over each question. Ideally, we will almost always have at least one question on our server, but to make the parser flexible, we will support multiple questions.

    For each iteration, we add 4 to qBuffSize because each question has a type and class, which take up 2 bytes (16 bits) each, adding up to 4 bytes.

    Now comes the interesting part: encoding the label sequence for the domains. Let's understand this with an example.

    Let's say we have dns.ronit.dev. We split this by . and iterate through each element, so in this case, we will have 3 iterations: one for dns, one for ronit, and another for dev.

    The way we build the buffer is as follows:

    • First, write the length of the label for dns --> 3

      • This means we add 1 to the qBuffSize for the integer length of the label.
    • Then, write the label itself, so dns

      • So add the label length as bytes to qBuffSize. For dns, we add 3.
    • Terminate with a 0, which indicates the end of a question domain.

      • To account for this, we just do qBuffSize++ (adding 1 byte because of the 0).
  1.   //#region  //*=========== answer, authority, additional buffer sizes ===========
      const aBuffSize = this.calculateSectionBufferSize(answers);
      const nsBuffSize = this.calculateSectionBufferSize(authority);
      const arBuffSize = this.calculateSectionBufferSize(additional);
      //#endregion  //*======== answer, authority, additional buffer sizes ===========
    

    now given the structure for answer, authority, & additional being the same (3a's) we have created a private utility method to calculate the size of all these sections. Which is called calculateSectionBufferSize

  2.   private calculateSectionBufferSize(section: DNSAnswer[]): number {
          let sectionBufferSize = 0;
          for (const entry of section) {
              sectionBufferSize += 10; // 2 bytes for TYPE, 2 bytes for CLASS, 4 bytes for TTL, 2 bytes for RDLENGTH
              entry.NAME.split('.').forEach((label: string) => {
                  sectionBufferSize += label.length + 1; // 1 byte for the length of the label
              });
              sectionBufferSize++; // for the terminating 0
              sectionBufferSize += entry.RDLENGTH; // for RDATA
          }
          return sectionBufferSize;
      }
    

    As we did for the question, we start with sectionBufferSize = 0 and then loop over the section (which is an array of entries).

    For each entry, we add 10 bytes (2 bytes for the record type, 2 bytes for the class, usually set to 1 for internet addresses, 4 bytes for the TTL, and 2 bytes for the RDLENGTH, which denotes the length of the data field).

    Next, we split the name of the entry, following the same logic we used for the question, including adding a terminating 0 for the end of each domain. I won't repeat that here.

    Additionally, we add the RDLENGTH integer field, which denotes the size of RDATA, to the buffer size.

    After completing all iterations, we simply return the final computed section buffer size.

    This method is used to get the size of the three sections: answer, authority, and additional.

  3.   const allocSize = hBuffSize + qBuffSize + aBuffSize + nsBuffSize + arBuffSize;
      const response: Buffer = Buffer.alloc(allocSize);
      //#endregion  //*======== Allocate buffer of required size in bytes ===========
    

    Finally we add up the allocation memory sizes and allocate a buffer with that size.

  4.   //#region  //*=========== Populate header ===========
      response.writeUInt16BE(header.ID, 0);
      response.writeUInt16BE(
          (header.QR << 15) |
              (header.OPCODE << 11) |
              (header.AA << 10) |
              (header.TC << 9) |
              (header.RD << 8) |
              (header.RA << 7) |
              (header.Z << 4) |
              header.RCODE,
          2,
      );
      response.writeUInt16BE(header.QDCOUNT, 4);
      response.writeUInt16BE(header.ANCOUNT, 6);
      response.writeUInt16BE(header.NSCOUNT, 8);
      response.writeUInt16BE(header.ARCOUNT, 10);
      //#endregion  //*======== Populate header ===========
    

    Now comes finally the hard part which is basically populating the actual buffer, starting with the header.
    let's analyse each subsection of the header so we can better understand the above logic:

    | RFC Name | Descriptive Name | Length (Bytes) | Description | | --- | --- | --- | --- | | ID | Identifier | 2 | A 16-bit identifier assigned by the program that generates the query. | | QR | Query/Response | 1 bit | Specifies whether the message is a query (0) or a response (1). | | OPCODE | Opcode | 4 bits | Specifies the kind of query (0 for standard query, 1 for inverse query, 2 for server status request). | | AA | Authoritative Answer | 1 bit | Specifies that the responding name server is an authority for the domain name in question (valid in responses only). | | TC | Truncation | 1 bit | Specifies that the message was truncated due to length greater than that permitted on the transmission channel. | | RD | Recursion Desired | 1 bit | Directed to the name server to pursue the query recursively. | | RA | Recursion Available | 1 bit | Indicates whether recursive query support is available in the name server. | | Z | Reserved | 3 bits | Reserved for future use and must be zero in all queries and responses. | | RCODE | Response Code | 4 bits | Specifies the result of the query (0 for no error, 1 for format error, 2 for server failure, etc.). | | QDCOUNT | Question Count | 2 | Number of entries in the question section. | | ANCOUNT | Answer Count | 2 | Number of resource records in the answer section. | | NSCOUNT | Authority Count | 2 | Number of name server resource records in the authority section. | | ARCOUNT | Additional Count | 2 | Number of resource records in the additional records section. |

    Writing the Identifier (ID)

    The identifier is a 16-bit integer written at the beginning of the buffer:

     response.writeUInt16BE(header.ID, 0);
    

    This line writes header.ID as a 16-bit unsigned integer at the 0th offset (the beginning of the buffer).

    Writing the Flags

    Next, we write all the flags (starting from header.QR to header.RCODE):

     response.writeUInt16BE(
         (header.QR << 15) |  // (QR: 1 bit) [16-1=15]
         (header.OPCODE << 11) | // (OPCODE: 4 bits) [15-4=11]
         (header.AA << 10) | // (AA: 1 bit) [11-1=10]
         (header.TC << 9) | // (TC: 1 bit) [10-1=9]
         (header.RD << 8) | // (RD: 1 bit) [9-1=8]
         (header.RA << 7) | // (RA: 1 bit) [8-1=7]
         (header.Z << 4) | // (Z: 3 bits) [7-3=4]
         header.RCODE, // (RCODE: 4 bits, no left shift needed)
         2,
     );
    

    This part involves bit manipulation to combine the flags into a 16-bit value. Here's a breakdown:

    • (header.QR << 15): Shift the 1-bit QR by 15 bits to the left.

    • (header.OPCODE << 11): Shift the 4-bit OPCODE by 11 bits to the left.

    • (header.AA << 10): Shift the 1-bit AA by 10 bits to the left.

    • (header.TC << 9): Shift the 1-bit TC by 9 bits to the left.

    • (header.RD << 8): Shift the 1-bit RD by 8 bits to the left.

    • (header.RA << 7): Shift the 1-bit RA by 7 bits to the left.

    • (header.Z << 4): Shift the 3-bit Z by 4 bits to the left.

    • Finally, we include header.RCODE which occupies the last 4 bits, so no left shift is needed.

Once all the shifts are done, we use the bitwise OR (|) operator to combine each bit into a single 16-bit value and write it at an offset of 2. This means the flags take up 16 bits (2 bytes) starting from the second byte.

Writing Remaining Header Fields

The remaining fields are straightforward:

    response.writeUInt16BE(header.QDCOUNT, 4);
    response.writeUInt16BE(header.ANCOUNT, 6);
    response.writeUInt16BE(header.NSCOUNT, 8);
    response.writeUInt16BE(header.ARCOUNT, 10);

Each field is 2 bytes, so we increment the offset by 2 each time and populate the response buffer with the corresponding property.

By following these steps, we ensure that the DNS packet header is accurately populated in the buffer.

  1. Populating the DNS Packet Questions

    After populating the header, we proceed to populate the question section of the DNS packet. Here’s a detailed explanation of the code that handles this part:

     let offset = 12;
    
     //#region  //*=========== Populate question ===========
     for (const question of questions) {
         question.NAME.split('.').forEach((label: string) => {
             response.writeUInt8(label.length, offset++);
             response.write(label, offset);
             offset += label.length;
         });
         response.writeUInt8(0, offset++); // write the terminating 0
    
         response.writeUInt16BE(question.TYPE, offset);
         offset += 2;
         response.writeUInt16BE(question.CLASS, offset);
         offset += 2;
     }
     //#endregion  //*======== Populate question ===========
    

    Understanding the Code

    1. Initialize Offset: We start with an offset set to 12. This is because the header occupies the first 12 bytes of the buffer.

       let offset = 12;
      
    2. Loop Through Questions: We iterate over each question in the questions array.

       for (const question of questions) {
      
    3. Write Domain Name: For each question, the domain name (NAME) is split into labels by the dot (.) separator. Each label is written to the buffer.

      • Write Label Length: The length of each label is written as a single byte.

          response.writeUInt8(label.length, offset++);
        
      • Write Label: The actual label is written to the buffer.

          response.write(label, offset);
          offset += label.length;
        
      • Terminating Zero: After all labels are written, a terminating zero byte is added to indicate the end of the domain name.

          response.writeUInt8(0, offset++); // write the terminating 0
        
    4. Write Query Type: The TYPE field specifies the type of query (e.g., A, NS, CNAME). It is a 16-bit integer and is written to the buffer at the current offset.

       response.writeUInt16BE(question.TYPE, offset);
       offset += 2;
      
    5. Write Query Class: The CLASS field specifies the class of the query (usually IN for internet). It is also a 16-bit integer and is written to the buffer at the current offset.

       response.writeUInt16BE(question.CLASS, offset);
       offset += 2;
      
  1. Populating the Answer, Authority, and Additional Sections

    In DNS packets, the sections for answers, authority, and additional records follow a similar structure. To efficiently handle these sections, we use a utility method called writeSectionToBuffer. This method is used to populate each of these sections in the buffer.

    Here’s how we populate these sections:

     //#region  //*=========== Populate answer, authority, additional ===========
     offset = this.writeSectionToBuffer(answers, response, offset);
     offset = this.writeSectionToBuffer(authority, response, offset);
     offset = this.writeSectionToBuffer(additional, response, offset);
     //#endregion  //*======== Populate answer, authority, additional ===========
    

    Utility Method: writeSectionToBuffer

    To streamline the process, we define a private utility method writeSectionToBuffer, which handles the writing of each section to the buffer.

     private writeSectionToBuffer(
         section: DNSAnswer[],
         buffer: Buffer,
         offset: number,
     ): number {
         for (const entry of section) {
             entry.NAME.split('.').forEach((label: string) => {
                 buffer.writeUInt8(label.length, offset++);
                 buffer.write(label, offset);
                 offset += label.length;
             });
             buffer.writeUInt8(0, offset++); // write the terminating 0
             buffer.writeUInt16BE(entry.TYPE, offset);
             offset += 2;
             buffer.writeUInt16BE(entry.CLASS, offset);
             offset += 2;
             buffer.writeUInt32BE(entry.TTL, offset);
             offset += 4;
             buffer.writeUInt16BE(entry.RDLENGTH, offset);
             offset += 2;
             entry.RDATA.copy(buffer, offset); // write the RDATA buffer
             offset += entry.RDLENGTH; // Move offset by the length of RDATA
         }
         return offset;
     }
    

    Understanding the Code

    1. Loop Through Each Section: The method iterates over each entry in the given section (answers, authority, or additional).

       for (const entry of section) {
      
    2. Write Domain Name: For each entry, the domain name (NAME) is split into labels. Each label is written to the buffer.

      • Write Label Length: The length of each label is written as a single byte.

          buffer.writeUInt8(label.length, offset++);
        
      • Write Label: The actual label is written to the buffer.

          buffer.write(label, offset);
          offset += label.length;
        
      • Terminating Zero: After all labels are written, a terminating zero byte is added to indicate the end of the domain name.

          buffer.writeUInt8(0, offset++); // write the terminating 0
        
    3. Write Record Type: The TYPE field specifies the type of data (e.g., A, NS, CNAME). It is a 16-bit integer and is written to the buffer at the current offset.

       buffer.writeUInt16BE(entry.TYPE, offset);
       offset += 2;
      
    4. Write Record Class: The CLASS field specifies the class of data (usually IN for internet). It is also a 16-bit integer and is written to the buffer at the current offset.

       buffer.writeUInt16BE(entry.CLASS, offset);
       offset += 2;
      
    5. Write Time-to-Live (TTL): The TTL field specifies the time interval that the record can be cached before it should be discarded. It is a 32-bit integer and is written to the buffer.

       buffer.writeUInt32BE(entry.TTL, offset);
       offset += 4;
      
    6. Write Resource Data Length (RDLENGTH): The RDLENGTH field specifies the length of the resource data. It is a 16-bit integer and is written to the buffer.

       buffer.writeUInt16BE(entry.RDLENGTH, offset);
       offset += 2;
      
    7. Write Resource Data (RDATA): The RDATA field contains the actual resource data. The data is copied into the buffer.

       entry.RDATA.copy(buffer, offset); // write the RDATA buffer
       offset += entry.RDLENGTH; // Move offset by the length of RDATA
      
    8. Return Updated Offset: After processing all entries, the method returns the updated offset.

       return offset;
      

Applying the Utility Method

We apply this utility method to the three sections (answers, authority, and additional) in sequence:

    offset = this.writeSectionToBuffer(answers, response, offset);
    offset = this.writeSectionToBuffer(authority, response, offset);
    offset = this.writeSectionToBuffer(additional, response, offset);

Each function call returns the updated offset value that we move along to the next function call for the next section

  1. Finally return the response

    return response;
    

With this we complete the message buffer builder class which takes in a dnsObject and converts it into a buffer which will be later transported over our UDB dns server.

Let's get down to writing the parser in the upcoming blog

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