Understanding the DNS protocol

Understanding the DNS protocol

Blog-2 of the 10 Blog series

In this blog, we will explore the DNS protocol, understand DNS packets, and guide you on building a DNS packet parser and builder in TypeScript. Typically, DNS packets are sent using UDP transport and are limited to 512 bytes.

DNS is convenient because queries and responses use the same format. This means that once we've created a packet parser and a packet builder, our protocol work is complete. This is different from most Internet Protocols, which usually use different structures for requests and responses.

Sticking the repository below so you can follow along: github.com/rtpa25/dns-server

DNS Packet Structure

This is how a dns packet is structured on a high level:

Section NameSize (Bytes)Purpose
Header12Contains metadata about the DNS packet, such as ID, flags, and counts of various sections.
QuestionVariableSpecifies the query being made, including the domain name, query type, and query class.
AnswerVariableContains resource records answering the query, including the domain name, type, class, TTL, data length, and data.
AuthorityVariableContains resource records pointing to authoritative name servers.
AdditionalVariableContains additional resource records providing extra information related to the query.

Essentially, we have to support three different objects: Header, Question and Record. Conveniently, the lists of records and questions are just individual instances placed one after another, with no extra elements. The number of records in each section is provided by the header. The header structure looks as follows:

RFC NameDescriptive NameLength (Bytes)Description
IDIdentifier2A 16-bit identifier assigned by the program that generates the query.
QRQuery/Response1 bitSpecifies whether the message is a query (0) or a response (1).
OPCODEOpcode4 bitsSpecifies the kind of query (0 for standard query, 1 for inverse query, 2 for server status request).
AAAuthoritative Answer1 bitSpecifies that the responding name server is an authority for the domain name in question (valid in responses only).
TCTruncation1 bitSpecifies that the message was truncated due to length greater than that permitted on the transmission channel.
RDRecursion Desired1 bitDirected to the name server to pursue the query recursively.
RARecursion Available1 bitIndicates whether recursive query support is available in the name server.
ZReserved3 bitsReserved for future use and must be zero in all queries and responses.
RCODEResponse Code4 bitsSpecifies the result of the query (0 for no error, 1 for format error, 2 for server failure, etc.).
QDCOUNTQuestion Count2Number of entries in the question section.
ANCOUNTAnswer Count2Number of resource records in the answer section.
NSCOUNTAuthority Count2Number of name server resource records in the authority section.
ARCOUNTAdditional Count2Number of resource records in the additional records section.

Now let's write some code!

We will start by writing the type-system, starting by denoting dns header as an interface (navigate over to src/message/types.ts )

export enum QRIndicator {
    QUERY = 0,
    RESPONSE = 1,
}

export enum Bool {
    FALSE = 0,
    TRUE = 1,
}

export enum OPCODE {
    QUERY = 0,
    IQUERY = 1,
    STATUS = 2,
}

export enum RCode {
    NOERROR = 0,
    FORMERR = 1,
    SERVFAIL = 2,
    NXDOMAIN = 3,
    NOTIMP = 4,
    REFUSED = 5,
}

export interface DNSHeader {
    ID: number; // Packet identifier -- a random id assigned to query packets. Response packets should reply with the same id - 16 bits
    QR: QRIndicator; // Query/Response flag -- 0 for query, 1 for response -- 1 bit
    OPCODE: OPCODE; // Operation code -- 0 for standard query -- 4 bits
    AA: Bool; // Authoritative Answer -- 1 if the responding server is an authority for the domain name in question else 0 -- 1 bit
    TC: Bool; // TrunCation -- 1 if the message is larger than 512 bytes. Always 0 for UDP -- 1 bit
    RD: Bool; // Recursion Desired -- 1 if the client wants the server to query other servers on its behalf --> recursively resolve the query else 0 -- 1 bit
    RA: Bool; // Recursion Available -- 1 if the server supports recursion else 0 -- 1 bit
    Z: 0; // Reserved for future use. Must be 0 -- 3 bits
    RCODE: RCode; // Response code -- 0 for no error -- 4 bits
    QDCOUNT: number; // Number of entries in the question section --> generally 1 -- 16 bits
    ANCOUNT: number; // Number of resource records in the answer section --> as many resources are needed to resolve the query -- 16 bits
    NSCOUNT: number; // Number of name server resource records in the authority records section --> 16 bits
    ARCOUNT: number; // Number of resource records in the additional records section --> 16 bits
}

Now let's look at the question structure:

FieldTypeDescription
NAMELabel SequenceThe domain name being queried, encoded as a sequence of labels, each prefixed with its length, and terminated with a length of 0.
TYPE2-byte IntegerThe type of the query (e.g., A for address, NS for name server, CNAME for canonical name).
CLASS2-byte IntegerThe class of the query (typically IN for internet).

Their is a slight gotcha here with encoding of the domain name in a label sequence that will learn in depth while writing the parser and builder of the dns packets

Now let's write the interface for question so refer to the same file as above for header src/message/types.ts

// TYPE            value and meaning
// A               1 a host address
// NS              2 an authoritative name server
// MD              3 a mail destination (Obsolete - use MX)
// MF              4 a mail forwarder (Obsolete - use MX)
// CNAME           5 the canonical name for an alias
// SOA             6 marks the start of a zone of authority
// MB              7 a mailbox domain name (EXPERIMENTAL)
// MG              8 a mail group member (EXPERIMENTAL)
// MR              9 a mail rename domain name (EXPERIMENTAL)
// NULL            10 a null RR (EXPERIMENTAL)
// WKS             11 a well known service description
// PTR             12 a domain name pointer
// HINFO           13 host information
// MINFO           14 mailbox or mail list information
// MX              15 mail exchange
// TXT             16 text strings
export enum RECORD_TYPE {
    A = 1,
    NS = 2,
    MD = 3,
    MF = 4,
    CNAME = 5,
    SOA = 6,
    MB = 7,
    MG = 8,
    MR = 9,
    NULL = 10,
    WKS = 11,
    PTR = 12,
    HINFO = 13,
    MINFO = 14,
    MX = 15,
    TXT = 16,
}

export interface DNSQuestion {
    NAME: string; // The domain name, encoded as a sequence of labels. Each label consists of a length octet followed by that number of octets. The domain name is terminated with a length of 0. -- variable length

    TYPE: RECORD_TYPE; // Type of the query -- 2 bytes integer 16 bits

    CLASS: 1; // Class of the query -- 2 bytes integer 16 bits -- usually set to 1 for internet addresses
}

Now coming to records, all records have same schema inside packets, we are talking about answers, authority, and additional records.

Here's the detailed table of such a record type:

FieldTypeSize (Bytes)Description
NAMEstringVariableThe domain name to which this resource record pertains, encoded as a sequence of labels. (same as question name)
TYPEnumber2The type of the data in the resource record (e.g., A for address, NS for name server, CNAME for canonical name).
CLASSnumber2The class of the data in the resource record (typically IN for internet).
TTLnumber4The time to live, a 32-bit integer specifying the time interval that the resource record may be cached before it should be discarded.
RDLENGTHnumber2The length of the RDATA field, a 16-bit integer.
RDATABufferVariableThe resource data, the format of which varies according to the TYPE and CLASS of the resource record.

Now's let's write down the type of DNSAnswer which will be used for all records: answers, authority & additional (3a's)

navigate again to src/message/types.ts

export enum QRIndicator {
    QUERY = 0,
    RESPONSE = 1,
}

export enum Bool {
    FALSE = 0,
    TRUE = 1,
}

export enum OPCODE {
    QUERY = 0,
    IQUERY = 1,
    STATUS = 2,
}

export enum RCode {
    NOERROR = 0,
    FORMERR = 1,
    SERVFAIL = 2,
    NXDOMAIN = 3,
    NOTIMP = 4,
    REFUSED = 5,
}

export interface DNSHeader {
    ID: number; // Packet identifier -- a random id assigned to query packets. Response packets should reply with the same id - 16 bits
    QR: QRIndicator; // Query/Response flag -- 0 for query, 1 for response -- 1 bit
    OPCODE: OPCODE; // Operation code -- 0 for standard query -- 4 bits
    AA: Bool; // Authoritative Answer -- 1 if the responding server is an authority for the domain name in question else 0 -- 1 bit
    TC: Bool; // TrunCation -- 1 if the message is larger than 512 bytes. Always 0 for UDP -- 1 bit
    RD: Bool; // Recursion Desired -- 1 if the client wants the server to query other servers on its behalf --> recursively resolve the query else 0 -- 1 bit
    RA: Bool; // Recursion Available -- 1 if the server supports recursion else 0 -- 1 bit
    Z: 0; // Reserved for future use. Must be 0 -- 3 bits
    RCODE: RCode; // Response code -- 0 for no error -- 4 bits
    QDCOUNT: number; // Number of entries in the question section --> generally 1 -- 16 bits
    ANCOUNT: number; // Number of resource records in the answer section --> as many resources are needed to resolve the query -- 16 bits
    NSCOUNT: number; // Number of name server resource records in the authority records section --> 16 bits
    ARCOUNT: number; // Number of resource records in the additional records section --> 16 bits
}

// TYPE            value and meaning
// A               1 a host address
// NS              2 an authoritative name server
// MD              3 a mail destination (Obsolete - use MX)
// MF              4 a mail forwarder (Obsolete - use MX)
// CNAME           5 the canonical name for an alias
// SOA             6 marks the start of a zone of authority
// MB              7 a mailbox domain name (EXPERIMENTAL)
// MG              8 a mail group member (EXPERIMENTAL)
// MR              9 a mail rename domain name (EXPERIMENTAL)
// NULL            10 a null RR (EXPERIMENTAL)
// WKS             11 a well known service description
// PTR             12 a domain name pointer
// HINFO           13 host information
// MINFO           14 mailbox or mail list information
// MX              15 mail exchange
// TXT             16 text strings
export enum RECORD_TYPE {
    A = 1,
    NS = 2,
    MD = 3,
    MF = 4,
    CNAME = 5,
    SOA = 6,
    MB = 7,
    MG = 8,
    MR = 9,
    NULL = 10,
    WKS = 11,
    PTR = 12,
    HINFO = 13,
    MINFO = 14,
    MX = 15,
    TXT = 16,
}

export interface DNSQuestion {
    NAME: string; // The domain name, encoded as a sequence of labels. Each label consists of a length octet followed by that number of octets. The domain name is terminated with a length of 0. -- variable length

    TYPE: RECORD_TYPE; // Type of the query -- 2 bytes integer 16 bits

    CLASS: 1; // Class of the query -- 2 bytes integer 16 bits -- usually set to 1 for internet addresses
}

export interface DNSAnswer {
    NAME: string; // The domain name, encoded as a sequence of labels. Each label consists of a length octet followed by that number of octets. The domain name is terminated with a length of 0. -- variable length

    TYPE: RECORD_TYPE; // Type of the query -- 2 bytes integer 16 bits

    CLASS: 1; // Class of the query -- 2 bytes integer 16 bits -- usually set to 1 for internet addresses

    TTL: number; // Time to live -- 4 bytes integer 32 bits

    RDLENGTH: number; // Length of the RDATA field -- 2 bytes integer 16 bits

    RDATA: Buffer; // The resource data -- variable length ex: IP address for A records -- RDLENGTH bytes long
}

export interface DNSObject {
    header: DNSHeader;
    questions: DNSQuestion[];
    answers?: DNSAnswer[];
    authority?: DNSAnswer[];
    additional?: DNSAnswer[];
}

This is how finally our type-system will look like once answer interface is done we create a DNSObject interface which will denote how we will denote DNSPacket in our app layer after parsing.

generally each object will have one question but we still accept an array for extensibility sakes

Now that we understand the entire protocol and have built a type system for our app layer, let's create the builder that will convert a DNSObject into a buffer for transport via UDP (remember, DNS servers run on the UDP protocol).

Let's move on to the next blog!