Building a toy DNS client in Ruby

Earlier writing on a toy DNS resolver in Go deliberately skipped over the mechanics of generating and parsing DNS queries. Several readers said that was exactly the part they wanted to see. Generating and parsing queries turns out to be compact enough for a ~120-line Ruby program, so this post walks through that code, line by line.

The example assumes you know roughly how DNS resolution works; the focus here is purely on the on-the-wire format. If you want to peek at the finished implementation first, the complete program is available as dig.rb. All the details below come from reading RFC 1035 and poking at packets in Wireshark — that combination is enough to reconstruct the query format from scratch if you ever need to.

By the end we have a minimal Ruby analog of dig that can resolve a domain name like this:

$ ruby dig.rb example.com
example.com	   20314    A    93.184.216.34

DNS message layout in brief

A DNS message starts with a fixed 12-byte header. The first two bytes form the transaction ID — in the code, rand(0xffff) picks a random one each query. Next come flag bits, then four two-byte counts for questions, answer records, authority records, and additional records.

To send a query we need to construct a header plus a question section, and encode each name using DNS compression (a label-length byte followed by the label text, ending with a zero byte). The question section also carries the type (A for a host address) and class (IN for internet).

The response parsing goes in the reverse direction: read the four counts, loop over each section, unpack names and record types, and store the relevant answers. The recursion-desired flag in the header matters too — resolvers ask the upstream server to resolve the whole chain, so the response will contain the final answer instead of a referral.

What the toy program handles

  • Query for A records only.
  • Parse records of type A (addresses), CNAME, and NS; any other record type is skipped for simplicity.
  • Handle compressed name pointers (the two high bits set) when they appear in responses.
  • Print each answer line like real dig output, including the resolved address and TTL for A records.

All other record classes and compression corner cases are deliberately left out. The goal is a small, readable program that demonstrates the core mechanics rather than a production DNS client.

Abbreviated code walkthrough

Rather than printing the full listing here, the key methods are worth describing because the parse logic repeats in a few places.

The header is packed with the flags set to 0x0100 — that is, recursion desired with opcode QUERY. The counts are appended directly after the flags. The destination is always a nameserver at a hardcoded IP and port 53.

To read a name from a response, there is a small loop that follows compression pointers up to a fixed number of hops to guard against loops. Pointer handling modifies nothing at the current read offset except the jump itself; otherwise each segment is collected from the packet at the pointer location. That routine returns both the decoded name and the offset just past the name in the original place it was read.

Questions are parsed one by one: read the QNAME, the type, and the class, skipping anything we aren't interested in.

Answer parsing shares the same offset-tracking approach. For an A record, the last four bytes of the RDATA are the IPv4 address; the TTL and record length are then read and printed immediately. CNAME and NS records contain a name in their RDATA that needs the same decode treatment; these are printed as "is an alias for ..." or "nameserver is ...". Any unknown type is skipped forward by the RDLENGTH.

Authority and additional sections are not parsed at all — the loop just counts past them, which keeps the code small. Real resolvers need those sections for referrals and DNSSEC, but a toy client can ignore them.

Getting the bytes on the wire

Sending the query is a single UDPSocket#send call, and receiving is a matching receive with a large enough buffer. The hostname resolution for the nameserver (in the example, 8.8.8.8) is skipped by using a literal IP, which bypasses the need for the resolver we're replacing.

The entire flow — build query, send, receive, parse header, iterate questions/answers — runs once per look-up. Error handling is minimal: no timeout handling and no retry logic exist, the socket just blocks until a response arrives.

One practical note: because the transaction ID is random but never checked against the response, this toy client would accept a response from any source with any ID. A real implementation must verify both.

Remarkably, that is all the structure DNS needs for a basic lookup. The protocol has a reputation for being dated, but the format is straightforward: fixed headers, counted labels, and type-length-value style records with a compression trick that is easy to handle once understood.

Opening a UDP socket

To send DNS queries we need a UDP socket pointed at a DNS server. We’ll use 8.8.8.8 (Google’s DNS) on port 53, the standard DNS port:

require 'socket'
sock = UDPSocket.new

sock.bind('0.0.0.0', 12345)
sock.connect('8.8.8.8', 53)

UDP is the simplest way to exchange packets: we send one packet and get one packet back. DNS queries normally ride over UDP, but TCP and DNS-over-HTTPS are also valid transports.

Reusing a working query

Rather than hand-craft a query from scratch, we can capture one with Wireshark to verify our socket works. The rough steps:

  1. Start a capture in Wireshark.
  2. Set the filter to udp.port == 53.
  3. Run ping example.com in a terminal to generate DNS traffic.
  4. Select the query (“Standard query A example.com”).
  5. Right-click the “Domain Name System (query)” entry in the packet details pane.
  6. Choose “Copy” → “as a hex stream”.

That yields the hex string b96201000001000000000000076578616d706c6503636f6d0000010001, which we can decode and send:

hex_string = "b96201000001000000000000076578616d706c6503636f6d0000010001"
bytes = [hex_string].pack('H*')
sock.send(bytes, 0)

# get the reply
reply, _ = sock.recvfrom(1024)
puts reply.unpack('H*')

[hex_string].pack('H*') converts the hex into raw bytes. Even without understanding the payload, tcpdump can confirm we’re sending valid queries:

  1. Run sudo tcpdump -ni any port 53 and host 8.8.8.8.
  2. Run the Ruby program (ruby dns-1.rb) in another terminal.
$ sudo tcpdump -ni any port 53 and host 8.8.8.8
08:50:28.287440 IP 192.168.1.174.12345 > 8.8.8.8.53: 47458+ A? example.com. (29)
08:50:28.312043 IP 8.8.8.8.53 > 192.168.1.174.12345: 47458 1/0/0 A 93.184.216.34 (45)

The output shows our question for example.com and the response with IP 93.184.216.34. Everything works — now we need to generate and decode this ourselves.

Anatomy of a DNS query

Our captured query in hex breaks into two pieces:

  • The header: b96201000001000000000000
  • The question: 076578616d706c6503636f6d0000010001
b96201000001000000000000076578616d706c6503636f6d0000010001

Per RFC 1035, the 12-byte header is six 2-byte numbers concatenated: query ID, flags, then counts for questions, answers, authority records, and additional records. To reproduce the header bytes in Ruby, we only need to output those six numbers, with the query ID as the sole variable:

def make_question_header(query_id)
  # id, flags, num questions, num answers, num auth, num additional
  [query_id, 0x0100, 0x0001, 0x0000, 0x0000, 0x0000].pack('nnnnnn')
end

The 'nnnnnn' argument to .pack() specifies the format: each n means “16-bit unsigned, network (big-endian) byte order”. Network byte order is mandatory here.

We can verify the function produces the expected bytes:

puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")

That prints true.

Encoding the domain name

The question section has three parts: the domain name, the query type (A for IPv4 address), and the query class (always 1 for INet). The domain name is the tricky bit.

example.com appears in hex as 076578616d706c6503636f6d00. Decoded to ASCII, each label is preceded by its length:

076578616d706c6503636f6d00
 7 e x a m p l e 3 c o m 0

The Ruby translation is straightforward:

def encode_domain_name(domain)
  domain
    .split(".")
    .map { |x| x.length.chr + x }
    .join + "\0"
end

Appending the two-byte type and class finishes the question.

Assembling the full query

The complete query builder combines the header and question:

def make_dns_query(domain, type)
  query_id = rand(65535)
  header = make_question_header(query_id)
  question =  encode_domain_name(domain) + [type, 1].pack('nn')
  header + question
end

At 29 lines, the whole program is small.

Parsing the response

Decoding the reply splits into three tasks: parse the header, parse names, and parse records. The hardest part is decoding domain names because of compression.

Header parsing

The header is simply the first 12 bytes read as six 2-byte integers:

class DNSHeader
  attr_reader :id, :flags, :num_questions, :num_answers, :num_auth, :num_additional
  def initialize(buf)
    hdr = buf.read(12)
    @id, @flags, @num_questions, @num_answers, @num_auth, @num_additional = hdr.unpack('nnnnnn')
  end
end

In Ruby, attr_reader exposes instance variables as methods, so header.flags works. Call it as DNSHeader(buf).

Parsing domain names and compression

A naive name reader repeatedly grabs a length byte then that many bytes until a zero length:

def read_domain_name_wrong(buf)
  domain = []
  loop do
    len = buf.read(1).unpack('C')[0]
    break if len == 0
    domain << buf.read(len)
  end
  domain.join('.')
end

That works until a name appears a second time. Wireshark shows it as the two bytes c00c. That’s DNS compression: the first two bits (0b11) signal a pointer, and the remaining 14 bits are an offset into the packet — here, byte 12, where the original example.com lives.

A decompressing reader handles the pointer and continues from the saved position afterward:

  domain = []
  loop do
    len = buf.read(1).unpack('C')[0]
    break if len == 0
    if len & 0b11000000 == 0b11000000
      # weird case: DNS compression!
      second_byte = buf.read(1).unpack('C')[0]
      offset = ((len & 0x3f) << 8) + second_byte
      old_pos = buf.pos
      buf.pos = offset
      domain << read_domain_name(buf)
      buf.pos = old_pos
      break
    else
      # normal case
      domain << buf.read(len)
    end
  end
  domain.join('.')

This recursive approach is safe for ordinary traffic, but a hostile response could point a name at itself and cause an infinite loop. Real parsers guard against that.

Parsing questions and records

Every response echoes the original query, so we must parse it too. The type and class are two bytes each:

class DNSQuery
  attr_reader :domain, :type, :cls
  def initialize(buf)
    @domain = read_domain_name(buf)
    @type, @cls = buf.read(4).unpack('nn')
  end
end

Records are where the answer data sits. The rdata field holds the IP address:

class DNSRecord 
  attr_reader :name, :type, :class, :ttl, :rdlength, :rdata
  def initialize(buf)
    @name = read_domain_name(buf)
    @type, @class, @ttl, @rdlength = buf.read(10).unpack('nnNn')
    @rdata = buf.read(@rdlength)
  end

Interpreting rdata depends on the record type. An A record is a 4-byte IP; a CNAME is a domain name. A helper makes that human-readable:

  def read_rdata(buf, length)
    @type_name = TYPES[@type] || @type
    if @type_name == "CNAME" or @type_name == "NS"
      read_domain_name(buf)
    elsif @type_name == "A"
      buf.read(length).unpack('C*').join('.')
    else
      buf.read(length)
    end
  end

It consults a TYPES hash for friendly names:

TYPES = {
  1 => "A",
  2 => "NS",
  5 => "CNAME",
  # there are a lot more but we don't need them for this example
}

The key line, buf.read(length).unpack('C*').join('.'), reads four bytes and joins them with dots to form an IP address.

Putting the parser together

Parsing the full response mostly means calling the helpers we just wrote:

class DNSResponse
  attr_reader :header, :queries, :answers, :authorities, :additionals
  def initialize(bytes)
    buf = StringIO.new(bytes)
    @header = DNSHeader.new(buf)
    @queries = ([email protected]_questions).map { DNSQuery.new(buf) }
    @answers = ([email protected]_answers).map { DNSRecord.new(buf) }
    @authorities = ([email protected]_auth).map { DNSRecord.new(buf) }
    @additionals = ([email protected]_additional).map { DNSRecord.new(buf) }
  end
end

The expression ([email protected]_answers).map builds an array with one record per answer. Integrating it into the main flow:

sock.send(make_dns_query("example.com", 1), 0) # 1 is "A", for IP address
reply, _ = sock.recvfrom(1024)
response = DNSResponse.new(reply) # parse the response!!!
puts response.answers[0]

Without a custom string representation, records print as object addresses. A single to_s method fixes that:

  def to_s
    "#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}"
  end

The class field is omitted since it’s constant (IN); most DNS tools print it anyway.

Final result

The complete main connects, sends a query, prints every answer, and exits:

def main
  # connect to google dns
  sock = UDPSocket.new
  sock.bind('0.0.0.0', 0)
  sock.connect('8.8.8.8', 53)

  # send query
  domain = ARGV[0]
  sock.send(make_dns_query(domain, 1), 0)

  # receive & parse response
  reply, _ = sock.recvfrom(1024)
  response = DNSResponse.new(reply)
  response.answers.each do |record|
    puts record
  end
$ ruby dig.rb example.com
example.com   18608   A   93.184.216.34

The finished program is available as a gist. Natural extensions include pretty-printing other record types, showing the authority and additional sections, adding retries, and verifying the response’s query ID matches our request.