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-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:
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
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:
Hex | Translation | Comment |
---|---|---|
10 | 16 | The length of the label |
6D | m | Start of the label: marcelofernandes |
61 | a | |
72 | r | |
63 | c | |
65 | e | |
6C | l | |
6F | o | |
66 | f | |
65 | e | |
72 | r | |
6E | n | |
64 | d | |
65 | e | |
73 | s | End of the label: marcelofernandes |
03 | 3 | The length of the upcoming label |
64 | d | Start of the label: dev |
65 | e | |
76 | v | End of the label: dev |
00 | 0 | End of QNAME |
00 | 0 | QTYPE is 16 bit, first half is 0 in this case |
01 | 1 | QTYPE = Internet |
00 | 0 | QCLASS is 16 bit, first half is 0 in this case |
01 | 1 | QCLASS = A (hostname) |
(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
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:
Answer:
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
Translating the response we have:
Hex | Translation | Comment |
---|---|---|
002A | 42 | The ID we provided in our query |
81 | 1000 0001 | This part is better visualized in binary. This sets the QR bit to 1 (response) and RD (recursion desired) |
80 | 1000 0000 | RA (1) means recursion available |
0001 | 1 | Question count was 1 |
0002 | 2 | Answer count was 2 |
0000 | 0 | 0 NSCOUNT on response |
0000 | 0 | 0 ARCOUNT |
106D6…010001 | This was our query |
From here the answer part starts:
Hex | Translation | Comment |
---|---|---|
C00C | 11 00000000001100 | It 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. |
0001 | 1 | TYPE = Internet |
0001 | 1 | A (hostname) |
00000014 | 20 | TTL in seconds. This is 20s |
0004 | 4 | The length in bytes of RDATA which is what comes next |
34436156 | 52.67.97.86 | Each byte is a part of the IP |
C00C | 11 00000000001100 | Same as above. It’s a pointer to marcelofernandes.dev |
0001 | 1 | TYPE = Internet |
0001 | 1 | A (hostname) |
00000014 | 20 | TTL in seconds. This is 20s |
0004 | 4 | The length in bytes of RDATA which is what comes next |
B147C3FF | 177.71.195.255 | A second IP |
00 | End | Nothing else on the message |
(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.
The parsing will be hand made for this case. It can be expanded to handle other cases later.
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 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"))))
(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")))))))))
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.