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: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
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)
//#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//#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 atype
andclass
, 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 fordns
, one forronit
, and another fordev
.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.
- This means we add 1 to the
Then, write the label itself, so
dns
- So add the label length as bytes to
qBuffSize
. Fordns
, we add 3.
- So add the label length as bytes to
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).
- To account for this, we just do
//#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
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 ofRDATA
, 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.
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.
//#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
toheader.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-bitQR
by 15 bits to the left.(header.OPCODE << 11)
: Shift the 4-bitOPCODE
by 11 bits to the left.(header.AA << 10)
: Shift the 1-bitAA
by 10 bits to the left.(header.TC << 9)
: Shift the 1-bitTC
by 9 bits to the left.(header.RD << 8)
: Shift the 1-bitRD
by 8 bits to the left.(header.RA << 7)
: Shift the 1-bitRA
by 7 bits to the left.(header.Z << 4)
: Shift the 3-bitZ
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.
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
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;
Loop Through Questions: We iterate over each question in the
questions
array.for (const question of questions) {
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
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;
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;
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
Loop Through Each Section: The method iterates over each entry in the given section (answers, authority, or additional).
for (const entry of section) {
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
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;
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;
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;
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;
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
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
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