Building a Simple DNS Client in Rust

Building a Simple DNS Client in Rust


Hi there.

Previously, I blogged about implementing WebSocket protocol on my own in Go. Since then I love building primitive technology from scratch. Today we’re going to diving into the world of DNS protocol in Rust. While I won’t build a full-fledged DNS server and client (that’s a big project!), we’ll make a simple DNS client that focuses on A record lookups.

You may find resources like this DNS tutorial that explain DNS concepts more elegantly. However, my goal is to explore DNS by building a client myself - even if it’s a bit rough around the edges, it’s a great learning experience!

You can find my code on GitHub

Let’s get started!


Before reading RFC document for DNS protocol, I highly recommend you to have Wireshark installed on your machine and see what a simple DNS query for A record looks like. This network analysis tool will be our best friend! I personally found it very difficult to understand how one protocol works only by reading RFC. If you are like me, utilizing Wireshark could help you by showing what the actual byte stream looks like. So go ahead and install one for your machine accordingyly.

After that, open the tool and start capturing network packets by selecting your active network interface. On my machine (macOS), it’s en0:

wireshark screenshot

Then open your terminal and hit the following command:

dig @ A

This command sends your DNS query for with type of A record to Google’s DNS server. And you should be able to see some network traffics in your Wireshark window. You can stop capturing now by clicking stop button on top left of the window (the red one). Also go to the filter form on top of the window and type “dns”. This will filter out all the packets in the current buffer except ones with DNS prortocol.

wireshark filtering screenshot

If your Wireshark windows look like mine, we are ready to check the RFC 🎉

RFC 1035

The next step is to read the protocol specification (It’s here). We don’t have to read it the from top to the bottom. Instead, focus on these key sections:

3.1. Name space definitions

This section explains how DNS labels (parts of a domain like “example” and “com” in “”) are encoded. Let’s again consider the domain name

  • a word example is the length of 7 in ascii. So in this case we first put 0x07, then example in octet.
  • a word com is the length of 3 in ascii. So we put 0x03, then the com in octet.
  • and finally put 0x00 at the end to indicate this is the end of the domain name.

Here is the result of how is encoded:

0x07 0x65 0x78 0x61 0x6d 0x70 0x6c 0x65 0x03 0x63 0x6f 0x6d 0x00

You can also see it yourself in Wireshark. (Domain Name System (query) > Queries > type A, class IN > Name)

name space definitions

4. Messages

This section outlines the format of DNS message format (queries and responses). Each message always has Header section as well as zero or more Question, Answer, Authority, and Additional sections in it. (Typically, one DNS query has Header and one Question sections (no Answer section). And one DNS response has Header, Question(s), Answer (with the same numbers Question has), and other sections as well).

4.1.4. Message compression

Since DNS messages is limited up to 512 bytes, DNS itself has a nice compression mechanism to express domain names in it. If there exists the same domain name in UDP data repeatedly, we don’t have to consume the same size of bytes for the name.

Let’s consider the following example. When you query A record for the domain name, the response should contains the name 2 times. In this case, in order to encode the domain name, we need 13 bytes for each name. After all the domain name consumes 13 x 2 -> 26 bytes of UDP data in total. -> 0x07 0x65 0x78 0x61 0x6d 0x70 0x6c 0x65 0x03 0x63 0x6f 0x6d 0x00 (13 bytes!)

This is where the message compression comes into play. When we pick up the first 2 bytes (16 bits) of the label and if the 2 most significant bits are 1, it means the name is compressed. And the OFFSET below points to the index of the starting point of the domain name in the response:

    | 1  1|                OFFSET                   |

Let’s see what it looks like in our response in Wireshark. The response also has Question section that includes Name field with the value It occupies 13 bytes as this is the first time that the domain name appears in the message. Then, when you go to Answer section, you can see Name field the the same value again. But this time, our Wireshark shows the byte representation of the domain name is 0xc00c:

message compression

You can think of it as a information from DNS protocol such as:

Hey, we saw the domain name somewhere in this message before! Let's see... the rest of bits represents 0x0c. So the label must start at 12th byte of the message. Go ahead!

And if you see the 12th byte of the message, it point to the domain name

Building Our Rust Client

Let’s write down the important Rust structs we’ll need:

pub struct Query {
    header: Header,
    questions: Vec<Question>,
    // technically we need other section too, but this time let's ignore them.

pub struct Response {
    pub header: Header,
    pub questions: Vec<Question>,
    pub answers: Vec<ResourceRecord>,
    pub authorities: Vec<ResourceRecord>,
    pub additionals: Vec<ResourceRecord>,

Note: I’m omitting full code listings, but you can find them on GitHub.

Next, let’s implement TryFrom trait for each struct. Here is the example of Header struct:

impl From<&[u8; 512]> for Header {
    fn from(value: &[u8; 512]) -> Self {
        let id = (value[0] as u16) << 8 | value[1] as u16;
        let qr = (value[2] & 0x80) != 0;
        let opcode = (value[2] & 0x78) >> 3;
        let aa = (value[2] & 0x04) != 0;
        let tc = (value[2] & 0x02) != 0;
        let rd = (value[2] & 0x01) != 0;
        let ra = (value[3] & 0x80) != 0;
        let rcode = value[3] & 0x0F;
        let qdcount = (value[4] as u16) << 8 | value[5] as u16;
        let ancount = (value[6] as u16) << 8 | value[7] as u16;
        let nscount = (value[8] as u16) << 8 | value[9] as u16;
        let arcount = (value[10] as u16) << 8 | value[11] as u16;
            id, qr, opcode, aa, tc, rd, ra, rcode, qdcount, ancount, nscount, arcount,

We can use the method to decode DNS message as follows:

let header: Header = Header::from(bytes);

At this point I realized we need to track the current offset to decode the whole message. Remember message compression? It gives us the index of bytes stream pointing to the domain name we want.

So I decided to pass the offset (mutable reference) to each try_from function. This doesn’t look like elegant - but let me go this way 🙏.

Here’s an example of my code for ResourceRecord:

impl TryFrom<(&[u8; 512], &mut usize)> for ResourceRecord {
    type Error = String;

    fn try_from((bytes, offset): (&[u8; 512], &mut usize)) -> Result<Self, Self::Error> {
        if *offset + 12 >= bytes.len() {
            return Err("data is too short".to_string());
        let name = if bytes[*offset] == 192 {
            // message compression
            let mut tmp_offset = bytes[*offset + 1] as usize;
            *offset += 2;
            get_name(bytes, &mut tmp_offset)?
        } else {
            get_name(bytes, offset)?
        let query_type = match ((bytes[*offset] as u16) << 8) + (bytes[*offset + 1] as u16) {
            1 => QueryType::A,
            28 => QueryType::AAAA,
            _ => panic!(),
        *offset += 2;
        let query_class = QueryClass::IN;
        *offset += 2;
        let ttl = ((bytes[*offset] as u32) << 24)
            + ((bytes[*offset + 1] as u32) << 16)
            + ((bytes[*offset + 2] as u32) << 8)
            + (bytes[*offset + 3] as u32);
        *offset += 4;
        let rdlength = (((bytes[*offset] as u16) << 8) + bytes[*offset + 1] as u16) as u16;
        *offset += 2;
        let rdata = match query_type {
            QueryType::A => RData::A([
                bytes[*offset + 1],
                bytes[*offset + 2],
                bytes[*offset + 3],
            // we just only consider A record this time
            _ => unimplemented!(),
        *offset += rdlength as usize;

        Ok(ResourceRecord {

Let’s test It!

Once you have a basic client, give it a try:

cargo run --            

If everything works as expected, you should see an IP address for Google!

This is the end of our journey to build a super simplified DNS client focused on A records. Here are some challenges to take your learning further:

  • More Record Types: Support lookups for records like AAAA (IPv6), MX (mail), etc.
  • Error Handling: Handle potential errors and malformed responses.
  • Recursion: A real-world client would likely make recursive queries if the initial server doesn’t have the answer.

Thanks for reading ✌️

© 2024 Hiro