Simple DNS client in Clojure

Writing a simple DNS client capable of resolving the IP address of a hostname. This can be done by sending a request to one of the root nameservers.

RFC

RFC-1035 details the implementation and specification of DNS.

In section 4 it details the format:

+---------------------+
| Header |
+---------------------+
| Question | the question for the name server
+---------------------+
| Answer | RRs answering the question
+---------------------+
| Authority | RRs pointing toward an authority
+---------------------+
| Additional | RRs holding additional information
+---------------------+

The header contains the following fields:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ID |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
|QR| Opcode |AA|TC|RD|RA| Z | RCODE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QDCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ANCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| NSCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| ARCOUNT |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

I’m not going to copy the description of those as the RFC has that information.

For generating a query we want:

  • ID: any 16 bit number
  • QR: 0 because it’s a query
  • OPCODE: 0 because it’s a standard query
  • AA: 0, not used for query
  • TC: 0 because message won’t be truncated
  • RD: 1 enable recursion. If the DNS that we’re querying doesn’t have the answer it will recursively find it for us
  • RA: 0, used on responses
  • Z: 0, reserved for future use
  • RCODE: 0, used for response
  • QDCOUNT: how many questions we’re asking. It will be 1.
  • ANCOUNT: 0
  • NSCOUNT: 0
  • ARCOUNT: 0

Creating the header

This take care of creating the header.

(defn print-in-oct
[arr]
(doseq [b arr]
(printf "%02X" b))
(println))
(defn pack-dns-header
[{:keys [id flags qd-count an-count ns-count ar-count]}]
(let [buffer (java.nio.ByteBuffer/allocate 12)] ; Allocate a 12-byte buffer for the DNS header
(.putShort buffer id)
(.putShort buffer flags)
(.putShort buffer qd-count)
(.putShort buffer an-count)
(.putShort buffer ns-count)
(.putShort buffer ar-count)
(.array buffer)))
(let [dns-header (pack-dns-header {:id 42
:flags 0x0100 ;; only RD bit is set (recursion desired)
:qd-count 1
:an-count 0
:ns-count 0
:ar-count 0})]
(println "DNS query")
(print-in-oct dns-header)) ;; => 002A01000001000000000000

Question

The question section has this format:

+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| |
/ QNAME /
/ /
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QTYPE |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+
| QCLASS |
+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+--+

For querying marcelofernandes.dev this section has the following value in hexadecimal:

106D617263656C6F6665726E616E646573036465760000010001

Which can be translated to:

HexTranslationComment
1016The length of the label
6DmStart of the label: marcelofernandes
61a
72r
63c
65e
6Cl
6Fo
66f
65e
72r
6En
64d
65e
73sEnd of the label: marcelofernandes
033The length of the upcoming label
64dStart of the label: dev
65e
76vEnd of the label: dev
000End of QNAME
000QTYPE is 16 bit, first half is 0 in this case
011QTYPE = Internet
000QCLASS is 16 bit, first half is 0 in this case
011QCLASS = A (hostname)

Creating the question

(defn pack-dns-question
[{:keys [domain-name qtype qclass]}]
(let [labels (str/split domain-name #"\.")
;; Allocate a buffer for the question
buffer (java.nio.ByteBuffer/allocate (+ (count labels) ;; each label has a length byte
(apply + (map count labels)) ;; each letter in the label is a byte
5))] ;; end of QName is one byte. QType and QClass are 2 byte each. For a total of 5 bytes
(doseq [label labels]
(.put buffer (byte (count label)))
(.put buffer (.getBytes label)))
(.put buffer (byte 0))
(.putShort buffer qtype)
(.putShort buffer qclass)
(.array buffer)))
(let [dns-header (pack-dns-header {:id 42
:flags 0x0100 ;; only RD bit is set (recursion desired)
:qd-count 1
:an-count 0
:ns-count 0
:ar-count 0})
question (pack-dns-question {:domain-name "marcelofernandes.dev"
:qtype 1
:qclass 1})
dns-query (byte-array (concat dns-header question))] ;; group both together for later
(println "DNS query")
(print-in-oct dns-header) ;; => 02A01000001000000000000
(println "DNS question")
(print-in-oct question)) ;; => 106D617263656C6F6665726E616E646573036465760000010001

Making the UDP request

To make a request we can send this query to root namespace server. To be more precise, we pick a root server from https://root-servers.org/ and sent a UDP request to it on port 53.

In my case I’m using Google’s server: 8.8.8.8. The complete code is:

(ns user
(:require [clojure.string :as str])
(:import (java.net DatagramSocket InetAddress DatagramPacket)))
(defn print-in-oct
[arr]
(doseq [b arr]
(printf "%02X" b))
(println))
(defn pack-dns-header
[{:keys [id flags qd-count an-count ns-count ar-count]}]
(let [buffer (java.nio.ByteBuffer/allocate 12)] ; Allocate a 12-byte buffer for the DNS header
(.putShort buffer id)
(.putShort buffer flags)
(.putShort buffer qd-count)
(.putShort buffer an-count)
(.putShort buffer ns-count)
(.putShort buffer ar-count)
(.array buffer)))
(defn pack-dns-question
[{:keys [domain-name qtype qclass]}]
(let [labels (str/split domain-name #"\.")
;; Allocate a buffer for the question
buffer (java.nio.ByteBuffer/allocate (+ (count labels) ;; each label has a length byte
(apply + (map count labels)) ;; each letter in the label is a byte
5))] ;; end of QName is one byte. QType and QClass are 2 byte each. For a total of 5 bytes
(doseq [label labels]
(.put buffer (byte (count label)))
(.put buffer (.getBytes label)))
(.put buffer (byte 0))
(.putShort buffer qtype)
(.putShort buffer qclass)
(.array buffer)))
(let [dns-header (pack-dns-header {:id 42
:flags 0x0100 ;; only RD bit is set (recursion desired)
:qd-count 1
:an-count 0
:ns-count 0
:ar-count 0})
question (pack-dns-question {:domain-name "marcelofernandes.dev"
:qtype 1
:qclass 1})
dns-query (byte-array (concat dns-header question))]
(println "DNS query")
(print-in-oct dns-header)
(println "DNS question")
(print-in-oct question)
(with-open [socket (DatagramSocket.)]
(let [request-packet (DatagramPacket. dns-query
(count dns-query)
(InetAddress/getByName "8.8.8.8")
53)]
(.send socket request-packet))))

Then we can use Wireshark for looking at the query and response/answer:

Query: Query

Answer: Response

Parsing response

First thing we need to grab the response:

(with-open [socket (DatagramSocket.)]
(let [request-packet (DatagramPacket. dns-query
(count dns-query)
(InetAddress/getByName "8.8.8.8")
53)]
(.send socket request-packet))
(let [buffer (byte-array 256)
response (DatagramPacket. buffer (count buffer))]
(.receive socket response)
(let [receive-msg (.getData response)]
(println "DNS response")
(print-in-oct receive-msg)))) ;; => 002A81800001000200000000106D617263656C6F6665726E616E646573036465760000010001C00C0001000100000014000434436156C00C00010001000000140004B147C3FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

Understanding the encoding of the response

Translating the response we have:

HexTranslationComment
002A42The ID we provided in our query
811000 0001This part is better visualized in binary. This sets the QR bit to 1 (response) and RD (recursion desired)
801000 0000RA (1) means recursion available
00011Question count was 1
00022Answer count was 2
000000 NSCOUNT on response
000000 ARCOUNT
106D6…010001This was our query

From here the answer part starts:

HexTranslationComment
C00C11 00000000001100It starts with 11, meaning it’s a pointer. The rest of the bits (which represent 12) is an offset. Applying this offset to the message leave us at 16marcelofernandes. I won’t go over details. The RFC explains this compression well.
00011TYPE = Internet
00011A (hostname)
0000001420TTL in seconds. This is 20s
00044The length in bytes of RDATA which is what comes next
3443615652.67.97.86Each byte is a part of the IP
C00C11 00000000001100Same as above. It’s a pointer to marcelofernandes.dev
00011TYPE = Internet
00011A (hostname)
0000001420TTL in seconds. This is 20s
00044The length in bytes of RDATA which is what comes next
B147C3FF177.71.195.255A second IP
00EndNothing else on the message

Getting to the answer bytes

(let [answer (->> receive-msg
(drop 12) ;; Skip the header
(drop-while #(not= % 0)) ;; Skip the question
(drop 5) ;; Skip QTYPE and QCLASS
)]
(println "DNS answer")
(print-in-oct answer)) ;; => C00C0001000100000014000436E86D09C00C00010001000000140004B147C3FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000

Can just skip the header which has a fixed width, the question by watching the NUL symbol, and skip QTYPE and QCLASS that are also fixed.

Parsing the answer

The parsing will be hand made for this case. It can be expanded to handle other cases later.

Identifying the pointer

To verify if the answer is a pointer we can do:

(let [answer 2r11000000 ;; same as C00C
pointer? (every? #(bit-test answer %) [7 6])] ;; checks if index 7 and 6 are set
pointer?)

Having that we need to join the other bits into a number:

(defn join-bytes
[high-byte low-byte]
(bit-or (bit-shift-left high-byte 8)
low-byte))
(let [answer [2r11000000 2r00001100]
pointer? (every? #(bit-test (first answer) %) [7 6])]
(if pointer?
(let [joined-bytes (join-bytes (first answer) (second answer))]
:TODO)
(throw (Exception. "Not a pointer. Don't supported yet"))))

Finally, ignore the first 2 bits and get the rest.

(defn join-bytes
[high-byte low-byte]
(bit-or (bit-shift-left high-byte 8)
low-byte))
(let [answer [2r11000000 2r00001100]
pointer? (every? #(bit-test (first answer) %) [7 6])]
(if pointer?
(let [joined-bytes (join-bytes (first answer) (second answer))]
(bit-and joined-bytes 0x3FFF))
(throw (Exception. "Not a pointer. Don't supported yet")))) ;=> 12

Now we need to get the response and skip 12 bytes:

(let [answer (take 2 (-> x :answer))
pointer? (every? #(bit-test (first answer) %) [7 6])]
(if pointer?
(let [joined-bytes (join-bytes (first answer) (second answer))
offset (bit-and joined-bytes 0x3FFF)]
(print-in-oct (drop offset (:response x)))) ;; => 106D617263656C6F6665726E616E646573036465760000010001C00C00010001000000140004B147C3FFC00C0001000100000014000436E86D09000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
;; => which is: 16marcelofernandes.dev ...
(throw (Exception. "Not a pointer. Don't supported yet"))))

Decoding the name

Decoding the name is a little trickier.

(defn decode-name
[msg]
(let [labels (take-while #(not= % 0) msg)]
{:length (inc (count labels))
:label (->> labels
(drop 1)
(map char)
(map (fn [c]
(if (Character/isLetterOrDigit c)
c
\.)))
(apply str))}))
(let [answer (take 2 (-> x :answer))
pointer? (every? #(bit-test (first answer) %) [7 6])]
(if pointer?
(let [joined-bytes (join-bytes (first answer) (second answer))
offset (bit-and joined-bytes 0x3FFF)]
(decode-name (drop offset (:response x)))) ;; => "marcelofernandes.dev"
(throw (Exception. "Not a pointer. Don't supported yet"))))

Parsing the rest

(ns user
(:require [clojure.string :as str])
(:import (java.net DatagramSocket InetAddress DatagramPacket)))
(defn print-in-oct
[arr]
(doseq [b arr]
(printf "%02X" b))
(println))
(defn pack-dns-header
[{:keys [id flags qd-count an-count ns-count ar-count]}]
(let [buffer (java.nio.ByteBuffer/allocate 12)] ; Allocate a 12-byte buffer for the DNS header
(.putShort buffer id)
(.putShort buffer flags)
(.putShort buffer qd-count)
(.putShort buffer an-count)
(.putShort buffer ns-count)
(.putShort buffer ar-count)
(.array buffer)))
(defn pack-dns-question
[{:keys [domain-name qtype qclass]}]
(let [labels (str/split domain-name #"\.")
;; Allocate a buffer for the question
buffer (java.nio.ByteBuffer/allocate (+ (count labels) ;; each label has a length byte
(apply + (map count labels)) ;; each letter in the label is a byte
5))] ;; end of QName is one byte. QType and QClass are 2 byte each. For a total of 5 bytes
(doseq [label labels]
(.put buffer (byte (count label)))
(.put buffer (.getBytes label)))
(.put buffer (byte 0))
(.putShort buffer qtype)
(.putShort buffer qclass)
(.array buffer)))
(defn join-bytes
[high-byte low-byte]
(bit-or (bit-shift-left high-byte 8)
low-byte))
(defn decode-name
[msg]
(->> msg
(take-while #(not= % 0))
(drop 1)
(map char)
(map (fn [c]
(if (Character/isLetterOrDigit c)
c
\.)))
(apply str)))
(defn byte-to-unsigned-int [b]
(bit-and b 0xFF))
(let [dns-header (pack-dns-header {:id 42
:flags 0x0100 ;; only RD bit is set (recursion desired)
:qd-count 1
:an-count 0
:ns-count 0
:ar-count 0})
question (pack-dns-question {:domain-name "marcelofernandes.dev"
:qtype 1
:qclass 1})
dns-query (byte-array (concat dns-header question))]
(println "DNS query")
(print-in-oct dns-header)
(println "DNS question")
(print-in-oct question)
(with-open [socket (DatagramSocket.)]
(let [request-packet (DatagramPacket. dns-query
(count dns-query)
(InetAddress/getByName "8.8.8.8")
53)]
(.send socket request-packet))
(let [buffer (byte-array 256)
response (DatagramPacket. buffer (count buffer))]
(.receive socket response)
(let [receive-msg (.getData response)]
(println "DNS response")
(print-in-oct receive-msg)
(let [answer (->> receive-msg
(drop 12) ;; Skip the header
(drop-while #(not= % 0)) ;; Skip the question
(drop 5) ;; Skip QTYPE and QCLASS
)]
(println "DNS answer")
(print-in-oct answer)
(let [name (take 2 answer)
pointer? (every? #(bit-test (first name) %) [7 6])]
(if pointer?
(let [joined-bytes (join-bytes (first answer) (second answer))
offset (bit-and joined-bytes 0x3FFF)
name (decode-name (drop offset receive-msg))
rest-of-answer (drop 2 answer)]
{:name name
:type (join-bytes (first rest-of-answer) (second rest-of-answer))
:class (join-bytes (nth rest-of-answer 2) (nth rest-of-answer 3))
:rdlen (join-bytes (nth rest-of-answer 8) (nth rest-of-answer 9))
:ip (->> (drop 12 answer)
(take 4)
(map byte-to-unsigned-int)
(str/join \.))})
(throw (Exception. "Not a pointer. Don't supported yet")))))))))

Conclusion

Far from perfect, but I just wanted to mess around with DNS. I wasn’t religious with naming things, haven’t parsed the TTL, and the second answer provided by the server.

However, this gets the job done in resolving a hostname to an IP address.

Back to notes