Table of Contents
- Introduction: Why Build a DNS Server?
- Prerequisites
- Understanding DNS: A Quick Crash Course
- Setting Up the Project
- Defining DNS Header structure
- Printing the DNS Request
- Defining the DNS Question Structure
- Defining DNS Answer structure
- Constructing a (hardcoded) reply
- Conclusion
- Disclaimer
- Acknowledgments
- Share
Introduction: Why Build a DNS Server?
Imagine typing a web address into your browser, and behind the scenes, a digital detective springs into action translating that friendly URL into the cryptic IP address where the website lives. This is the magic of DNS (Domain Name System), a cornerstone of the internet’s functionality. Inspired by the hands-on learning approach championed by Code Crafters, this tutorial will guide you through building your own DNS server from scratch using Rust! You’ll not only deepen your understanding of how the web operates but also gain practical experience with Rust’s networking capabilities. Let’s dive in and unravel the mystery behind how the internet finds what you’re looking for!
Why Codecrafters?
Codecrafters provides hands-on system-building challenges, guiding you to create complex applications, like Redis, HTTP Server, and DNS servers—entirely from scratch. If you love learning by doing, this is the perfect platform for you.
Sign up using my referral link to support my content and unlock a 40% discount on your subscription. The best part? You can try it for free, no strings attached! If you go for a paid subscription, you might even qualify for reimbursement from your employer, so don’t miss out on this opportunity to level up your skills!
What You’ll Learn
Part 1:
- Understanding DNS requests and responses.
- Handling UDP packets in Rust.
- Parsing and constructing DNS packets.
Part 2:
- Implementing decompression of DNS packets.
- Forwarding DNS queries to resolvers.
Accompanying GitHub Repository
The complete source code for this tutorial is available on Github.
Prerequisites
Before diving in, ensure you have the following:
- Rust installed: If you haven’t, install it using rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- Basic knowledge of Rust: Familiarity with variables, functions, structs, and enums.
- Basic networking concepts: Understanding of DNS resolution, and the differences between UDP and TCP.
To test your DNS server, use tools like dig
or nslookup
. To install dig
, run:
# Ubuntu
sudo apt-get install dnsutils
# macOS
brew install bind
Later on we will explain how to use the dig tool to test our DNS server.
Understanding DNS: A Quick Crash Course
A DNS request involves:
- A client (like your browser) sending a DNS query to a server.
- The server responds with an IP address (or forwards the query).
- The client uses that IP to establish a connection.
DNS messages are encoded as binary data to ensure efficient transmission over the network. This means we will be working with raw bytes rather than plain text. A request typically includes:
- Header: Contains metadata.
- Question: The domain name being queried.
- Answer (in response): The resolved IP address.
The full specification is available in RFC 1035 I’ll refer to it throughout this article and highlight the most relevant chapters. If you want a step-by-step walkthrough of the DNS protocol, I recommend this article.
One of the great things about Rust is its strong type system, which allows us to define structures that closely resemble the DNS message format as specified in RFC 1035.
Setting Up the Project
Let’s create a Rust project for our DNS server:
cargo new dns-server
cd dns-server
Open your preferred text editor and navigate to the project directory. If you’re looking for a recommendation, Zed is a great option.
Now, open Cargo.toml and add dependencies:
[dependencies]
clap = { version = "4.5.28", features = ["derive"] }
thiserror = "1.0.38"
Another way of doing the same is using cargo add
in the project directory:
cargo add clap --features derive
cargo add thiserror
Clap is a powerful and intuitive command-line argument parser for Rust, supporting subcommands, flags, options, and arguments.
Thiserror is a lightweight crate for defining custom error types with Rust’s Error trait.
Now let's start writing our DNS server...
Defining DNS Header structure
To handle a DNS request, we first need to define the structure of a DNS message. Let’s start by defining the DNS Header in a separate file called dns.rs
. The DNS header structure is defined in RFC 1035 - 4.1.1.
// src/dns.rs
#[derive(Debug)]
pub struct Header {
pub id: u16, // identifier
pub qr: bool, // 0 for query, 1 for response
pub opcode: u8, // 0 for standard query
pub aa: bool, // authoritative answer
pub tc: bool, // truncated message
pub rd: bool, // recursion desired
pub ra: bool, // recursion available
pub z: u8, // reserved for future use
pub rcode: u8, // 0 for no error
pub qdcount: u16, // number of entries in the question section
pub ancount: u16, // number of resource records in the answer section
pub nscount: u16, // number of name server resource records in the authority records section
pub arcount: u16, // number of resource records in the additional records section
}
You do not need to know the meaning of each field in the DNS header. The fields are used by the DNS protocol to indicate the type of message, the status of the message, and the number of resource records in each section of the message among other things.
Since DNS messages are transmitted as raw binary data, we need to convert our Header struct into a byte array (serialization). When receiving a DNS request, we perform deserialization to reconstruct the Header from raw bytes.
Note that network byte order is big-endian (be) hence our use of the function u16::to_be_bytes()
and u16::from_be_bytes()
.
// src/dns.rs
impl Header {
const DNS_HEADER_LEN: usize = 12;
// Serialize the header into a byte array
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(Header::DNS_HEADER_LEN);
buf.extend_from_slice(&self.id.to_be_bytes());
buf.push(
(self.qr as u8) << 7
| self.opcode << 3
| (self.aa as u8) << 2
| (self.tc as u8) << 1
| self.rd as u8,
);
buf.push((self.ra as u8) << 7 | self.z << 4 | self.rcode);
buf.extend_from_slice(&self.qdcount.to_be_bytes());
buf.extend_from_slice(&self.ancount.to_be_bytes());
buf.extend_from_slice(&self.nscount.to_be_bytes());
buf.extend_from_slice(&self.arcount.to_be_bytes());
buf
}
// Deserialize the header from a byte array
pub fn from_bytes(buf: &[u8]) -> Result<Header, ErrorCondition> {
if buf.len() < Header::DNS_HEADER_LEN {
return Err(ErrorCondition::DeserializationErr(
"Buffer length is less than header length".to_string(),
));
}
Ok(Header {
id: u16::from_be_bytes([buf[0], buf[1]]),
qr: (buf[2] & 0b1000_0000) != 0,
opcode: (buf[2] & 0b0111_1000) >> 3,
aa: (buf[2] & 0b0000_0100) != 0,
tc: (buf[2] & 0b0000_0010) != 0,
rd: (buf[2] & 0b0000_0001) != 0,
ra: (buf[3] & 0b1000_0000) != 0,
z: (buf[3] & 0b0111_0000) >> 4,
rcode: buf[3] & 0b0000_1111,
qdcount: u16::from_be_bytes([buf[4], buf[5]]),
ancount: u16::from_be_bytes([buf[6], buf[7]]),
nscount: u16::from_be_bytes([buf[8], buf[9]]),
arcount: u16::from_be_bytes([buf[10], buf[11]]),
})
}
}
Since we added an Error type to the deserialization function we need to include thiserror
and define them. I will define the ErrorCondition enum at the top of the file.
// src/dns.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ErrorCondition {
#[error("Serialization Error: {0}")]
SerializationErr(String),
#[error("Deserialization Error: {0}")]
DeserializationErr(String),
}
#[derive(Debug)]
pub struct Header {
...
...
}
Now we are going to implement the main loop where we will receive DNS queries from clients and send back responses. To start, we’ll implement a simple DNS server that decodes the DNS header and prints the query.
// src/main.rs
use std::net::UdpSocket;
mod dns;
use dns::Header;
fn main() {
let socket = UdpSocket::bind("0.0.0.0:1053").expect("Could not bind to port 1053");
let mut buf = [0; 512];
println!("DNS server is running at port 1053");
loop {
let (len, addr) = socket.recv_from(&mut buf).expect("Could not receive data");
let header = Header::from_bytes(&buf[..len]).expect("Could not parse DNS header");
println!("Received query from {} {:?}", addr, header);
}
}
Our current code contains several expect
calls, which we’ll refine later. This exemplifies one of Rust’s key strengths: explicit error handling, ensuring that nothing is left to implicit behavior.
In the main function, we create a UDP socket bound to port 1053 and initialize a 512-byte buffer. We then print a message indicating that the DNS server is running. Inside the loop, the server listens for incoming requests, parses the DNS header, and prints the query details, continuously processing requests until stopped with Ctrl+C
.
Now, let’s run the server! Open a terminal and start the DNS server: cargo run
. Open another terminal and run dig @localhost -p 1053 rust-trends.com
Since we have not implemented DNS query processing, dig
won’t receive a valid response. Instead, it may retry a few times before giving up. Because UDP is an unreliable protocol, dig simply attempts the query again if no response is received.
Example output from our server:
$ cargo run
DNS server is running at port 1053
Received query from 127.0.0.1:63928 Header { id: 22295, qr: false, opcode: 0, aa: false, tc: false, rd: true, ra: false, z: 2, rcode: 0, qdcount: 1, ancount: 0, nscount: 0, arcount: 1 }
Code for this part can be found in the Github Repository under step 1
.
Wow! We’re running a DNS server in Rust! Are you curious how the rest of the request looks like? Let's print it out!
Printing the DNS Request
Before parsing the full DNS request, let’s first inspect the raw data. The function below prints the incoming bytes in a structured hex format, similar to how hex editors display binary files. This helps us visualize the request and debug potential issues.
If you’ve done low-level programming before, you’re probably familiar with hex editors. It has a convenient layout of printing bytes. An example of such a great editor can be found at hexed.it
Let’s take inspiration from this and extend main.rs to print the DNS request.
// src/main.rs
use std::net::UdpSocket;
mod dns;
use dns::Header;
// Debug print hex bytes of a buffer 16 bytes width followed by the ASCII representation of the bytes
fn debug_print_bytes(buf: &[u8]) {
for (i, chunk) in buf.chunks(16).enumerate() {
print!("{:08x}: ", i * 16);
for byte in chunk {
print!("{:02x} ", byte);
}
for _ in 0..(16 - chunk.len()) {
print!(" ");
}
print!(" ");
for byte in chunk {
if *byte >= 32 && *byte <= 126 {
print!("{}", *byte as char);
} else {
print!(".");
}
}
println!();
}
}
fn main() {
let socket = UdpSocket::bind("0.0.0.0:1053").expect("Could not bind to port 1053");
let mut buf = [0; 512];
println!("DNS server is running at port 1053");
loop {
let (len, addr) = socket.recv_from(&mut buf).expect("Could not receive data");
println!("\nReceived query from {} with length {} bytes", addr, len);
debug_print_bytes(&buf[..len]);
let header = Header::from_bytes(&buf[..len]).expect("Could not parse DNS header");
println!("\n{:?}", header);
}
}
Now, let’s run the server and send a DNS request using dig dig @localhost -p 1053 rust-trends.com
.
DNS server is running at port 1053
Received query from 127.0.0.1:62942 with length 44 bytes
00000000: 3e 3f 01 20 00 01 00 00 00 00 00 01 0b 72 75 73 >?. .........rus
00000010: 74 2d 74 72 65 6e 64 73 03 63 6f 6d 00 00 01 00 t-trends.com....
00000020: 01 00 00 29 10 00 00 00 00 00 00 00 ...)........
Header { id: 15935, qr: false, opcode: 0, aa: false, tc: false, rd: true, ra: false, z: 2, rcode: 0, qdcount: 1, ancount: 0, nscount: 0, arcount: 1 }
Each line in the output represents 16 bytes of the request. The first column is the byte offset (e.g., 00000000). The next columns show the hexadecimal values of the bytes. Finally, the rightmost column prints the ASCII representation of printable characters, replacing non-printable bytes with dots (.).
This debug information is useful for understanding the DNS request. Did you notice the quirk where z is set to 2? It's a reserved field that can be used for future extensions and was expected to be zero. Huh!? What's that about? You can read more about it at StackExchange. Apperently RFC's get amended. For now, we’ll ignore it and move on....
Since the DNS header is always 12 bytes, and our total request length is 44 bytes, we can infer that the remaining 32 bytes correspond to the Question Section. Next, we’ll decode it to extract the domain name being queried.
Code for this part can be found in the Github Repository under step 1
.
Defining the DNS Question Structure
In the Domain Name System (DNS), a question is a structured query that specifies the domain name and the type of record being requested. This structure is fundamental to DNS resolution and is formally defined in RFC 1035, Section 4.1.2.
A DNS question consists of three fields:
- QNAME: The domain name being queried, represented as a sequence of labels. Each label is encoded as a length-prefixed string, and the entire name is terminated with a zero byte.
- QTYPE: Specifies the type of DNS record being requested (e.g., A for IPv4 addresses, AAAA for IPv6 addresses, MX for mail exchange records).
- QCLASS: Indicates the class of the query, with the most common value being IN (Internet).
QNAME Encoding Details
DNS uses a compact encoding for domain names: 1. Each label (e.g., “www”, “rust-trends”, “com”) is prefixed by a single byte indicating its length. 2. Labels are concatenated sequentially. 3. The entire sequence is terminated with a zero byte (0x00).
Example DNS Question for www.rust-trends.com
A DNS query for www.rust-trends.com
requesting an A record (IPv4 address) in the Internet class would be structured as follows:
QNAME Encoding
The domain name "www.rust-trends.com" is split into labels: • "www" → 3 characters • "rust-trends" → 11 characters • "com" → 3 characters
DNS encoding rules: • Each label is prefixed by its length as a single byte. • The entire domain is terminated with a 0x00 byte.
Encoded QNAME:
[03] 'w' 'w 'w' [0B] 'r' 'u' 's' 't' '-' 't' 'r' 'e' 'n' 'd' 's' [03] 'c' 'o' 'm' [00]
# in bytes
03 77 77 77 0B 72 75 73 74 2D 74 72 65 6E 64 73 03 63 6F 6D 00
Breaking it down: - 03 → Length of "www", followed by ASCII 77 77 77 (www). - 0B → Length of "rust-trends", followed by ASCII 72 75 73 74 2D 74 72 65 6E 64 73 (rust-trends). - 03 → Length of "com", followed by ASCII 63 6F 6D (com). - 00 → End of QNAME.
Complete DNS Question Structure
A complete DNS question includes: - QNAME → Encoded domain name. - QTYPE → 00 01 (A record). - QCLASS → 00 01 (Internet class).
Final binary representation:
[03] 'w' 'w 'w' [0B] 'r' 'u' 's' 't' '-' 't' 'r' 'e' 'n' 'd' 's' [03] 'c' 'o' 'm' [00] [00] [01] [00] [01]
03 77 77 77 0B 72 75 73 74 2D 74 72 65 6E 64 73 03 63 6F 6D 00 00 01 00 01
This is how a DNS client would structure a query to resolve www.rust-trends.com into an IPv4 address.
How should we structure this? Instead of storing the domain name as a single string, we break it down into its individual labels (e.g., www, rust-trends, com). This allows for more efficient processing when serializing and handling compressed DNS messages later. We can represent QTYPE and QCLASS as enums.
// src/dns.rs
...
pub struct Question {
pub name: Vec<Label>,
pub qtype: Type,
pub qclass: Class,
}
#[derive(Debug, Clone)]
pub struct Label(String);
For the Types we can look at section 3.2.3 and section 3.2.2 of the RFC. Note that Types used in Resource Records are a subset of Types used in Questions, so for sake of simplicity we will use the same enum for both.
// src/dns.rs
...
#[derive(Debug, Clone)]
pub enum Type {
// Below are Resource Record Types and QTYPES
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
// Below are only QTYPES
AXFR = 252, // A request for a transfer of an entire zone
MAILB = 253, // A request for mailbox-related records (MB, MG or MR)
MAILA = 254, // A request for mail agent RRs (Obsolete - see MX)
_ALL_ = 255, // A request for all records
}
Finally, we define the QCLASS enum, as described in section 3.2.4, we will only be using the Internet class, but for the sake of completeness, we will add the other values in the enum:
// src/dns.rs
...
pub enum Class {
IN = 1, // the Internet
CS = 2, // the CSNET class (Obsolete - used only for examples in some obsolete RFCs)
CH = 3, // the CHAOS class
HS = 4, // Hesiod [Dyer 87]
}
Let's add the functionality to and deserialize the Question part of DNS query:
// src/dns.rs
impl Question {
// The from_bytes() function reconstructs a Question struct by iterating through the buffer, extracting labels,
// parsing the query type and class.
pub fn from_bytes(buf: &[u8]) -> Result<Self, ErrorCondition> {
let mut index = 0;
let mut labels: Vec<Label> = Vec::new();
println!("Labels:");
while buf[index] != 0 {
let len = buf[index] as usize;
index += 1;
labels.push(Label::new(&buf[index..index + len])?);
println!("{:?}", labels); // For debugging purposes
index += len;
}
index += 1;
let qtype = Type::from_bytes(&buf[index..index + 2])?;
index += 2;
let qclass = Class::from_bytes(&buf[index..index + 2])?;
Ok(Question {
name: labels,
qtype,
qclass,
})
}
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::new();
// Write the labels to the buffer and add . inbetween and end with 0
for label in &self.name {
buf.push(label.len() as u8);
buf.extend_from_slice(label.0.as_bytes());
}
buf.push(0);
// Write the question type and class to the buffer
buf.extend_from_slice(&self.qtype.to_bytes());
buf.extend_from_slice(&self.qclass.to_bytes());
buf
}
}
The code uses functions to serialize and deserialize Type (field qtype) and Class (field qclass) into bytes and back into their respective types, requiring proper implementation for from_bytes and to_bytes see below.
// src/dns.rs
impl Type {
pub fn from_bytes(bytes: &[u8]) -> Result<Type, ErrorCondition> {
match u16::from_be_bytes([bytes[0], bytes[1]]) {
1 => Ok(Type::A),
2 => Ok(Type::NS),
3 => Ok(Type::MD),
4 => Ok(Type::MF),
5 => Ok(Type::CNAME),
6 => Ok(Type::SOA),
7 => Ok(Type::MB),
8 => Ok(Type::MG),
9 => Ok(Type::MR),
10 => Ok(Type::NULL),
11 => Ok(Type::WKS),
12 => Ok(Type::PTR),
13 => Ok(Type::HINFO),
14 => Ok(Type::MINFO),
15 => Ok(Type::MX),
16 => Ok(Type::TXT),
252 => Ok(Type::AXFR),
253 => Ok(Type::MAILB),
254 => Ok(Type::MAILA),
255 => Ok(Type::_ALL_),
n => Err(ErrorCondition::DeserializationErr(
format!("Unknown Question Type {}", n).to_string(),
)),
}
}
pub fn to_bytes(&self) -> [u8; 2] {
let num = match self {
Type::A => 1,
Type::NS => 2,
Type::MD => 3,
Type::MF => 4,
Type::CNAME => 5,
Type::SOA => 6,
Type::MB => 7,
Type::MG => 8,
Type::MR => 9,
Type::NULL => 10,
Type::WKS => 11,
Type::PTR => 12,
Type::HINFO => 13,
Type::MINFO => 14,
Type::MX => 15,
Type::TXT => 16,
Type::AXFR => 252,
Type::MAILB => 253,
Type::MAILA => 254,
Type::_ALL_ => 255,
};
u16::to_be_bytes(num)
}
}
impl Class {
pub fn from_bytes(buf: &[u8]) -> Result<Self, ErrorCondition> {
let num = u16::from_be_bytes([buf[0], buf[1]]);
match num {
1 => Ok(Class::IN),
2 => Ok(Class::CS),
3 => Ok(Class::CH),
4 => Ok(Class::HS),
_ => Err(ErrorCondition::DeserializationErr(
format!("Unknown Question Class {}", num).to_string(),
)),
}
}
pub fn to_bytes(&self) -> [u8; 2] {
let num = match self {
Class::IN => 1,
Class::CS => 2,
Class::CH => 3,
Class::HS => 4,
Class::_ALL_ => 255,
};
u16::to_be_bytes(num)
}
}
We have all the plumbing in place to deserialize the question, we need to modify main.rs
to handle the incoming DNS queries and print them.
// src/main.rs
use std::net::UdpSocket;
mod dns;
use dns::{Header, Question};
// Debug print hex bytes of a buffer 16 bytes width followed by the ASCII representation of the bytes
fn debug_print_bytes(buf: &[u8]) {
...
}
fn main() {
let socket = UdpSocket::bind("0.0.0.0:1053").expect("Could not bind to port 1053");
let mut buf = [0; 512];
println!("DNS server is running at port 1053");
loop {
let (len, addr) = socket.recv_from(&mut buf).expect("Could not receive data");
println!("\nReceived query from {} with length {} bytes", addr, len);
println!("\n### DNS Query: ###");
debug_print_bytes(&buf[..len]);
let header = Header::from_bytes(&buf[..12]).expect("Could not parse DNS header");
println!("\n{:?}", header);
println!("\n### Question: ###");
debug_print_bytes(&buf[12..len]);
println!();
let question = Question::from_bytes(&buf[12..len]).expect("Could not parse DNS question");
println!("\n{:?}", question);
}
}
Now that we can deserialize both the Header and Question, let’s restart the server and send a DNS request to observe the deserialization in action.
dig @localhost -p 1053 www.rust-trends.com
cargo run
DNS server is running at port 1053
Received query from 127.0.0.1:64228 with length 48 bytes
### DNS Query: ###
00000000: ef 60 01 20 00 01 00 00 00 00 00 01 03 77 77 77 .`. .........www
00000010: 0b 72 75 73 74 2d 74 72 65 6e 64 73 03 63 6f 6d .rust-trends.com
00000020: 00 00 01 00 01 00 00 29 10 00 00 00 00 00 00 00 .......)........
Header { id: 61280, qr: false, opcode: 0, aa: false, tc: false, rd: true, ra: false, z: 2, rcode: 0, qdcount: 1, ancount: 0, nscount: 0, arcount: 1 }
### Question: ###
00000000: 03 77 77 77 0b 72 75 73 74 2d 74 72 65 6e 64 73 .www.rust-trends
00000010: 03 63 6f 6d 00 00 01 00 01 00 00 29 10 00 00 00 .com.......)....
00000020: 00 00 00 00 ....
Labels:
[Label("www")]
[Label("www"), Label("rust-trends")]
[Label("www"), Label("rust-trends"), Label("com")]
Question { name: [Label("www"), Label("rust-trends"), Label("com")], qtype: A, qclass: IN }
Code for this part can be found in the Github Repository under step 2
.
We see the label sequence and Question struct. We still do not have an answer for this DNS request so next we are going to implement a reply.
Defining DNS Answer structure
The answer section in a DNS query reply is also called a Resource record. It includes several fields, such as the domain name, time-to-live (TTL), the Type and Class we previously defined for the question part and the actual data, which can contain an IP address or other relevant information. Below you can find the structure in code:
// src/dns.rs
#[derive(Debug, Clone)]
pub struct ResourceRecord {
pub name: String,
pub rtype: Type,
pub rclass: Class,
pub ttl: u32,
pub rdlength: u16,
pub rdata: Vec<u8>,
}
impl ResourceRecord {
pub fn to_bytes(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(MAX_DNS_MESSAGE_SIZE);
self.name.split('.').for_each(|label| {
buf.push(label.len() as u8);
buf.extend_from_slice(label.as_bytes());
});
buf.push(0);
buf.extend_from_slice(&self.rtype.to_bytes());
buf.extend_from_slice(&self.rclass.to_bytes());
buf.extend_from_slice(&self.ttl.to_be_bytes());
buf.extend_from_slice(&self.rdlength.to_be_bytes());
buf.extend_from_slice(&self.rdata);
buf
}
}
With the above method, we can easily construct a ResourceRecord as part of the reply. Ready to answer the query?
Constructing a (hardcoded) reply
A valid DNS response consists of three main parts:
- Header: Includes metadata, response flags, and record counts.
- Question: Echoes the original query back to the client.
- Answer: Contains the resolved IP address for the requested domain.
For now, our server will always return the fixed IP address 172.67.221.148 when queried for www.rust-trends.com
.
We will use Rust’s Default trait to define a default ResourceRecord. This provides a simple and reusable way to hardcode a response for now.
// src/dns.rs
impl Default for ResourceRecord {
fn default() -> Self {
ResourceRecord {
name: String::from("www.rust-trends.com"),
rtype: Type::A,
rclass: Class::IN,
ttl: 60,
rdlength: 4,
rdata: Vec::from([172,67,221,148]),
}
}
}
Next, we adapt main.rs
to send a response. We need to add the ResourceRecord in the use statement.
// src/main.rs
use std::net::UdpSocket;
mod dns;
use dns::{Header, Question, ResourceRecord};
After printing the query, we construct the response.
// src/main.rs
fn main() {
let socket = UdpSocket::bind("0.0.0.0:1053").expect("Could not bind to port 1053");
let mut buf = [0; 512];
println!("DNS server is running at port 1053");
loop {
let (len, addr) = socket.recv_from(&mut buf).expect("Could not receive data");
println!("\nReceived query from {} with length {} bytes", addr, len);
println!("\n### DNS Query: ###");
debug_print_bytes(&buf[..len]);
let header = Header::from_bytes(&buf[..12]).expect("Could not parse DNS header");
println!("\n{:?}", header);
println!("\n### Question: ###");
debug_print_bytes(&buf[12..len]);
println!();
let question = Question::from_bytes(&buf[12..len]).expect("Could not parse DNS question");
println!("\n{:?}", question);
// We parsed the DNS query and question, now we can respond to it
let answer = ResourceRecord::default();
println!("{:?}", answer);
let response_header = Header {
id: header.id,
qr: true, // It is a query response
opcode: header.opcode, // Standard query
aa: false, // Not authoritative
tc: false, // Not truncated
rd: header.rd, // Recursion desired
ra: false, // Recursion not available
z: 0, // Reserved
rcode: if header.opcode == 0 { 0 } else { 4 },
qdcount: 1, // Question count we assume is 1
ancount: 1, // Answer count is 1
nscount: 0, // Name server count is 0
arcount: 0, // Additional record count is 0
};
// Create a response message with the header and question
let mut response: Vec<u8> = Vec::new();
response.extend_from_slice(&response_header.to_bytes());
response.extend_from_slice(&question.to_bytes());
response.extend_from_slice(&answer.to_bytes());
// Send the response back to the client
socket
.send_to(&response, addr)
.expect("Could not send response");
}
}
Code for this part can be found in the Github Repository under step 2
.
Our response reuses some fields from the incoming query but updates specific values: - qr = true → Indicates this is a response. - rcode = 0 (or 4 for unsupported opcode) → Specifies success or failure. - ancount = 1 → Indicates that one answer is included.
The deserialized question is re-serialized and included in the response. This ensures that the client can match the response to its original query.
A complete DNS response consists of:
- Header – Metadata and response flags
- Question – Echoed back from the request
- Answer – The resolved IP address or relevant data
Note: including the question section in the response is a standard part of the DNS protocol, allowing clients to verify the response corresponds to their request.
Last step is putting it in one byte array and sending it to the client.
Now let's test our DNS server! Fire up dig and start the server
dig @localhost -p 1053 www.rust-trends.com
cargo run
DNS server is running at port 1053
Received query from 127.0.0.1:54734 with length 48 bytes
### DNS Query: ###
00000000: 2e 1f 01 20 00 01 00 00 00 00 00 01 03 77 77 77 ... .........www
00000010: 0b 72 75 73 74 2d 74 72 65 6e 64 73 03 63 6f 6d .rust-trends.com
00000020: 00 00 01 00 01 00 00 29 10 00 00 00 00 00 00 00 .......)........
Header { id: 11807, qr: false, opcode: 0, aa: false, tc: false, rd: true, ra: false, z: 2, rcode: 0, qdcount: 1, ancount: 0, nscount: 0, arcount: 1 }
### Question: ###
00000000: 03 77 77 77 0b 72 75 73 74 2d 74 72 65 6e 64 73 .www.rust-trends
00000010: 03 63 6f 6d 00 00 01 00 01 00 00 29 10 00 00 00 .com.......)....
00000020: 00 00 00 00 ....
Labels:
[Label("www")]
[Label("www"), Label("rust-trends")]
[Label("www"), Label("rust-trends"), Label("com")]
Question { name: [Label("www"), Label("rust-trends"), Label("com")], qtype: A, qclass: IN }
ResourceRecord { name: "www.rust-trends.com", rtype: A, rclass: IN, ttl: 60, rdlength: 4, rdata: [172, 67, 221, 148] }
And looking at the dig output:
dig @localhost -p 1053 www.rust-trends.com
; <<>> DiG 9.10.6 <<>> @localhost -p 1053 www.rust-trends.com
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 11807
;; flags: qr rd; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available
;; QUESTION SECTION:
;www.rust-trends.com. IN A
;; ANSWER SECTION:
www.rust-trends.com. 60 IN A 172.67.221.148
;; Query time: 0 msec
;; SERVER: 127.0.0.1#1053(127.0.0.1)
;; WHEN: Fri Feb 28 12:44:09 CET 2025
;; MSG SIZE rcvd: 72
Wow it works! we can see it received the header, query and answer. Also note is show a warning about recursion requested but not available. By default, dig requests recursion, but our server does not perform recursive queries. The warning WARNING: recursion requested but not available
simply indicates that recursion was requested but not supported. To remove this warning, use: dig @localhost -p 1053 www.rust-trends.com +norecurse
.
You got your reply and can visit www.rust-trends.com.
Conclusion
We are coming to the end of Part 1 of this series. You've built a basic DNS server that can parse queries and return a hardcoded IP address. In the next part we will add support for DNS decompression and send the request to a resolver, collect the response, and construct a reply to the client.
This tutorial is inspired by the Codecrafters challenge of building your own DNS server. If you enjoy hands-on learning, use my referral link to get a 40% discount and support my content. You can even try it for free no strings attached!
Have questions or feedback? Drop a comment or reach out, I’d love to hear how your DNS server is coming along! Stay tuned for Part 2!
Disclaimer
This project is intended for educational purposes and is not fully optimized for production use.
When running cargo check
or cargo run
, you may notice warnings about unused code, such as from_bytes
and to_bytes
functions or unused enum variants. These have been intentionally left in place for illustrative purposes and to maintain code symmetry.
Contributions and improvements are always welcome! 🚀
Acknowledgments
A huge thanks to the Codecrafters team for their support and guidance throughout this project.