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 Name | Size (Bytes) | Purpose |
Header | 12 | Contains metadata about the DNS packet, such as ID, flags, and counts of various sections. |
Question | Variable | Specifies the query being made, including the domain name, query type, and query class. |
Answer | Variable | Contains resource records answering the query, including the domain name, type, class, TTL, data length, and data. |
Authority | Variable | Contains resource records pointing to authoritative name servers. |
Additional | Variable | Contains 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 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. |
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:
Field | Type | Description |
NAME | Label Sequence | The domain name being queried, encoded as a sequence of labels, each prefixed with its length, and terminated with a length of 0. |
TYPE | 2-byte Integer | The type of the query (e.g., A for address, NS for name server, CNAME for canonical name). |
CLASS | 2-byte Integer | The 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:
Field | Type | Size (Bytes) | Description |
NAME | string | Variable | The domain name to which this resource record pertains, encoded as a sequence of labels. (same as question name) |
TYPE | number | 2 | The type of the data in the resource record (e.g., A for address, NS for name server, CNAME for canonical name). |
CLASS | number | 2 | The class of the data in the resource record (typically IN for internet). |
TTL | number | 4 | The time to live, a 32-bit integer specifying the time interval that the resource record may be cached before it should be discarded. |
RDLENGTH | number | 2 | The length of the RDATA field, a 16-bit integer. |
RDATA | Buffer | Variable | The 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!