(Native Executable) Command Line DNS Resolver
A Clojure implementation of Julia Evans' wonderful toy DNS resolver https://implement-dns.wizardzines.com/
The motivation was to see how low level binary network protocols could be written with Clojure and Java interop, see how DNS protocols work in detail, and implement my own DNS resolver. I additionally used GraalVM to build it into a small, fast native image.
RFC 1034 Domain Names - Concepts and Facilities
RFC 1035 Domain Names - Implementation and Specification
-
Java 17+
Optional to build native executable:
-
Unpack the package in your installation folder, add it to the path, and install
native-image
export GRAALVM_HOME=/full/path/to/graalvm
export PATH=$GRAALVM_HOME/bin:$PATH
gu install native-image
git clone https://github.com/iwrotesomecode/weekend-dns.git
If creating a native executable with GraalVM, enter the directory and additionally run the build script
./build.sh
For a menu of all options, run the program with the flag -h
or --help
clj -M:run -h
Weekend DNS Resolver
Usage (native image): ./dns url [options]
Usage (clj): clj -M:run url [options]
URL:
e.g. www.example.com
Options:
-t, --type TYPE 1 Record Type
-n, --nameserver IP 198.41.0.4 Nameserver IP
-r, --response Print DNS response
-v, --verbose
-h, --help
Examples:
./dns www.example.com
./dns www.example.com -v -t TYPE-A
clj -M:run www.example.com -r
clj -M:run www.example.com -rn 192.5.6.30
Example running from Clojure:
clj -M:run www.example.com
"93.184.216.34"
Example running from the native executable after building it with GraalVM:
./dns www.recurse.com -v
"Querying 198.41.0.4 for www.recurse.com"
"Querying 192.5.6.30 for www.recurse.com"
"Querying 205.251.193.2 for www.recurse.com"
"Querying 198.41.0.4 for www.recurse.com.herokudns.com"
"Querying 192.5.6.30 for www.recurse.com.herokudns.com"
"Querying 198.41.0.4 for dns1.p05.nsone.net"
"Querying 192.5.6.30 for dns1.p05.nsone.net"
"Querying 198.51.44.1 for dns1.p05.nsone.net"
"NS-domain dns1.p05.nsone.net found at 198.51.44.5"
"Querying 198.51.44.5 for www.recurse.com.herokudns.com"
"CNAME www.recurse.com.herokudns.com resolved"
"54.221.251.148"
To view just the DNS response (and optionally specify a nameserver), flag --response
or -r
:
./dns www.recurse.com -rn 192.5.6.30
{:header
{:id 32386,
:flags 32768,
:num-questions 1,
:num-answers 0,
:num-authorities 4,
:num-additionals 1},
:questions ({:name "www.recurse.com", :type 1, :class 1}),
:answers (),
:authorities
({:name "recurse.com",
:type 2,
:class 1,
:ttl 172800,
:data "ns-258.awsdns-32.com"}
{:name "recurse.com",
:type 2,
:class 1,
:ttl 172800,
:data "ns-950.awsdns-54.net"}
{:name "recurse.com",
:type 2,
:class 1,
:ttl 172800,
:data "ns-1045.awsdns-02.org"}
{:name "recurse.com",
:type 2,
:class 1,
:ttl 172800,
:data "ns-1724.awsdns-23.co.uk"}),
:additionals
({:name "ns-258.awsdns-32.com",
:type 1,
:class 1,
:ttl 172800,
:data "205.251.193.2"})}
To monitor traffic, you can additionally run:
sudo tcpdump -ni any port 53
20:10:43.792269 wlo1 Out IP 192.168.1.13.51934 > 198.41.0.4.53: 13099 A? example.com. (29)
20:10:43.809389 wlo1 In IP 198.41.0.4.53 > 192.168.1.13.51934: 13099- 0/13/14 (489)
20:10:43.811043 wlo1 Out IP 192.168.1.13.51934 > 192.5.6.30.53: 46963 A? example.com. (29)
20:10:43.839439 wlo1 In IP 192.5.6.30.53 > 192.168.1.13.51934: 46963- 0/2/0 (77)
20:10:43.840559 wlo1 Out IP 192.168.1.13.51934 > 198.41.0.4.53: 48004 A? a.iana-servers.net. (36)
20:10:43.856145 wlo1 In IP 198.41.0.4.53 > 192.168.1.13.51934: 48004- 0/13/14 (493)
20:10:43.859153 wlo1 Out IP 192.168.1.13.51934 > 192.5.6.30.53: 29805 A? a.iana-servers.net. (36)
20:10:43.885239 wlo1 In IP 192.5.6.30.53 > 192.168.1.13.51934: 29805- 0/4/6 (240)
20:10:43.886803 wlo1 Out IP 192.168.1.13.51934 > 199.43.135.53.53: 62564 A? a.iana-servers.net. (36)
20:10:43.901722 wlo1 In IP 199.43.135.53.53 > 192.168.1.13.51934: 62564*- 1/0/0 A 199.43.135.53 (52)
20:10:43.902754 wlo1 Out IP 192.168.1.13.51934 > 199.43.135.53.53: 3738 A? example.com. (29)
20:10:43.919264 wlo1 In IP 199.43.135.53.53 > 192.168.1.13.51934: 3738*- 1/0/0 A 93.184.216.34 (45)
-
This resolver is only able to query A records and additionally parse types AAAA, NS, and CNAME.
-
There is a possible exploit in the DNS compression that would lead to an infinite loop if a malicious actor sent a DNS response with a compression entry that points to itself.Added check for maximum compression pointers (126). rationale -
Caching not implemented.
-
EDNS0 (extended DNS) not implemented.
One particular hiccup in following along is the shortcoming of Java types. There
are no unsigned types except char, requiring a little mental accounting when
working with byte arrays and comparing with unsigned example outputs. In this
context, it meant using Clojure's unchecked-short
to coerce 2-byte numbers,
and recognizing that "32,768" is presented in two's complement as "-32,768" and
"65,535" is presented as "-1", though they have the same bit representations.
When relying on the actual representational value (e.g. to pass as an argument
where automatic type promotion happens otherwise), cast to an unsigned int:
;; equivalent cast methods
(Short/toUnsignedInt -1) ;; => 65535
(bit-and -1 0xffff) ;; => 65535
;; to see binary representation
(Integer/toBinaryString (bit-and 0xffff -32768)) ;; => "1000000000000000"
(Integer/toBinaryString 32768) ;; => "1000000000000000"
When using some of the stream methods, like .readByte
, the returned value in
Clojure gets promoted to Long
, and may need to be recast, for instance when
packing individual bytes to create the compression
pointer.
I wrapped the network response in Java's DataInputStream
to make it easier to
handle reading bytes. Unfortunately none of Java's default Input Streams allow
random access or seek
, only the ability to mark/reset
and skip
. Seeking is
necessary to parse DNS compression, however. I didn't want to write it to a file
to leverage seek
from Java's File io libraries, so I created a new stream at
the pointer offset anytime I encountered a compression pointer.
I followed Kira McLean's
guide
to build a native executable with GraalVM, with some changes to prefer
clojure/tools.build. In order to
properly compile the executable it was necessary to fix reflection warnings,
adding (set! *warn-on-reflection* true)
in all my source files, and also to
use delay
to instantiate the DatagramSocket
at runtime.
I like this more functional/Clojurey approach using drop
and take
on the byte array directly rather than reaching for Java interop to stream the bytes:
https://github.com/JGailor/simple-dns-server/blob/master/src/dns_server/core.clj
It would need some added functionality to handle pointer compression, but I'd probably head in this direction if refactoring this code.
(defn process-headers
"Process the 12-byte header into the appropriate fields"
[header]
(let [id-field (bytes->int (byte-array (take 2 header)))
byte3 (first (drop 2 header))
byte4 (first (drop 3 header))
qr-field (bit-and 2r00000001 byte3)
opcode-field (bit-and 2r00011110 byte3)
aa-field (bit-and 2r00100000 byte3)
tc-field (bit-and 2r01000000 byte3)
rd-field (bit-and 2r10000000 byte3)
ra-field (bit-and 2r00000001 byte4)
z-field (bit-and 2r00001110 byte4)
rcode-field (bit-and 2r11110000 byte4)
qd-count (bytes->int (byte-array (take 2 (drop 4 header))))
an-count (bytes->int (byte-array (take 2 (drop 6 header))))
ns-count (bytes->int (byte-array (take 2 (drop 8 header))))
ar-count (bytes->int (byte-array (take 2 (drop 10 header))))]
(hash-map :id-field id-field
:qr-field qr-field
:opcode-field opcode-field
:aa-field aa-field
:tc-field tc-field
:rd-field rd-field
:ra-field ra-field
:z-field z-field
:rcode-field rcode-field
:qd-count qd-count
:an-count an-count
:ns-count ns-count
:ar-count ar-count)))