TranslateProject/sources/tech/20221106 Making a DNS query in Ruby from scratch.md
Xingyu Wang 5c2261eeb1 清除过期文章
@Drwhooooo 超期了
2023-06-06 15:26:44 +08:00

549 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

[#]: subject: "Making a DNS query in Ruby from scratch"
[#]: via: "https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/"
[#]: author: "Julia Evans https://jvns.ca/"
[#]: collector: "lujun9972"
[#]: translator: " "
[#]: reviewer: " "
[#]: publisher: " "
[#]: url: " "
Making a DNS query in Ruby from scratch
======
Hello! A while back I wrote a post about [how to write a toy DNS resolver in Go][1].
In that post I left out “how to generate and parse DNS queries” because I thought it was boring, but a few people pointed out that they did not know how to parse and generate DNS queries and they were interested in how to do it.
This made me curious how much work _is_ it do the DNS parsing? It turns out we can do it in a pretty nice 120-line Ruby program, which is not that bad.
So heres a quick post on how to generate DNS queries and parse DNS responses! Were going to do it in Ruby because Im giving a talk at a Ruby conference soon, and this blog post is partly prep for that talk :). Ive tried to keep it readable for folks who dont know Ruby though, Ive only used pretty basic Ruby code.
At the end were going to have a very simple toy Ruby version of `dig` that can look up domain names like this:
```
$ ruby dig.rb example.com
example.com 20314 A 93.184.216.34
```
The whole thing is about 120 lines of code, so its not _that_ much. (The final program is [dig.rb][2] if you want to skip the explanations and just read some code.) We wont implement the “how a DNS resolver works” from the previous post because, well, we already did that. Lets get into it!
Along the way Im going to try to explain how you could figure out some of this stuff yourself if you were trying to figure out how DNS queries are formatted from scratch. Mostly thats “poke around in Wireshark” and “read RFC 1035, the DNS RFC”.
### step 1: open a UDP socket
We need to actually _send_ our queries, so to do that we need to open a UDP socket. Well send our queries to `8.8.8.8`, Googles DNS server.
Heres the code to set up a UDP connection to `8.8.8.8`, port 53 (the DNS port).
```
require 'socket'
sock = UDPSocket.new
sock.bind('0.0.0.0', 12345)
sock.connect('8.8.8.8', 53)
```
##### a quick note on UDP
Im not going to say too much about UDP here, but I will say that the basic unit of computer networking is the “packet” (a packet is a string of bytes), and in this program were going to do the simplest possible thing you can do with a computer network send 1 packet and receive 1 packet in response.
So UDP is a way to send packets in the simplest possible way.
Its the most common way to send DNS queries, though you can also use TCP or DNS-over-HTTPS instead.
##### step 2: copy a DNS query from Wireshark
Next: lets say we have no idea how DNS works but we want to send a working query as fast as possible. The easiest way to get a DNS query to play with and make sure our UDP connection is working is to just copy one that already works!
So thats what were going to do, using Wireshark (an incredible packet analysis tool)
The steps I used to this are roughly:
1. Open Wireshark and click capture
2. Enter `udp.port == 53` as a filter (in the search bar)
3. Run `ping example.com` in my terminal (to generate a DNS query)
4. Click on the DNS query (“Standard query A example.com”)
5. Right click on “Domain Name System (query”) in the bottom left pane
6. Click Copy -> as a hex stream
7. Now I have “b96201000001000000000000076578616d706c6503636f6d0000010001” on my clipboard, to use in my Ruby program. Hooray!
##### step 3: decode the hex stream and send the DNS query
Now we can send our DNS query to `8.8.8.8`! Heres what that looks like: we just need to add 5 lines of code
```
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*')` is translating our hex string into a byte string. At this point we dont really know what this data _means_ but well get there in a second.
We can also take this opportunity to make sure our program is working and is sending valid data, using `tcpdump`. How I did that:
1. Run `sudo tcpdump -ni any port 53 and host 8.8.8.8` in a terminal tab
2. In a different terminal tab, run [this Ruby program][3] (`ruby dns-1.rb`)
Heres what the output looks like:
```
$ 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)
```
This is really good - we can see the DNS request (“whats the IP for `example.com`”) and the response (“its 93.184.216.34”). So everything is working. Now we just need to, you know, figure out how to generate and decode this data ourselves.
##### step 4: learn a little about how DNS queries are formatted
Now that we have a DNS query for `example.com`, lets learn about what it means.
Heres our query, formatted as hex.
```
b96201000001000000000000076578616d706c6503636f6d0000010001
```
If you poke around in Wireshark, youll see that this query has 2 parts:
* The **header** (`b96201000001000000000000`)
* The **question** (`076578616d706c6503636f6d0000010001`)
##### step 5: make the header
Our goal in this step is to generate the byte string `b96201000001000000000000`, but with a Ruby function instead of hardcoding it.
So: the header is 12 bytes. What do those 12 bytes mean? If you look at Wireshark (or read [RFC 1035][4]), youll see that its 6 2-byte numbers concatenated together.
The 6 numbers correspond to the query ID, the flags, and then the number of questions, answer records, authoritative records, and additional records in the packet.
We dont need to worry about what all those things are yet though we just need to put in 6 numbers.
And luckily we know exactly which 6 numbers to put because our goal is to literally generate the string `b96201000001000000000000`.
So heres a function to make the header. (note: theres no `return` because you dont need to write `return` in Ruby if its the last line of the function)
```
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
```
This is very short because weve hardcoded everything except the query ID.
##### whats `nnnnnn`?
You might be wondering what `nnnnnn` is in `.pack('nnnnnn')`. Thats a format string telling `.pack()` how to convert that array of 6 numbers into a byte string.
[The documentation for `.pack` is here][5], and it says that `n` means “represent it as “16-bit unsigned, network (big-endian) byte order”.
16 bits is the same as 2 bytes, and we need to use network byte order because this is computer networking. Im not going to explain byte order right now (though I do have a [comic attempting to explain it][6])
##### test the header code
Lets quickly test that our `make_question_header` function works.
```
puts make_question_header(0xb962) == ["b96201000001000000000000"].pack("H*")
```
This prints out “true”, so we win and we can move on.
##### step 5: encode the domain name
Next we need to generate the **question** (“whats the IP for `example.com`?“). This has 3 parts:
* the **domain name** (for example “example.com”)
* the **query type** (for example “A” is for “IPv4 **A**ddress”
* the **query class** (which is always the same, 1 is for **IN** is for **IN**ternet)
The hardest part of this is the domain name so lets write a function to do that.
`example.com` is encoded in a DNS query, in hex, as `076578616d706c6503636f6d00`. What does that mean?
Well, if we translate the bytes into ASCII, it looks like this:
```
076578616d706c6503636f6d00
7 e x a m p l e 3 c o m 0
```
So each segment (like `example`) has its length (like 7) in front of it.
Heres the Ruby code to translate `example.com` into `7 e x a m p l e 3 c o m 0`:
```
def encode_domain_name(domain)
domain
.split(".")
.map { |x| x.length.chr + x }
.join + "\0"
end
```
Other than that, to finish generating the question section we just need to append the type and class onto the end of the domain name.
##### step 6: write `make_dns_query`
Heres the final function to make a DNS query:
```
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
```
[Heres all the code weve written before in `dns-2.rb`][7] its still only 29 lines.
##### now for the parsing
Now that weve managed to _generate_ a DNS query, we get into the hard part: the parsing. Again, well split this into a bunch of different
* parse a DNS header
* parse a DNS name
* parse a DNS record
The hardest part of this (maybe surprisingly) is going to be “parse a DNS name”.
##### step 7: parse the DNS header
Lets start with the easiest part: the DNS header. We already talked about how its 6 numbers concatenated together.
So all we need to do is
* read the first 12 bytes
* convert that into an array of 6 numbers
* put those numbers in a class for convenience
Heres the Ruby code to do that.
```
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
```
Quick Ruby note: `attr_reader` is a Ruby thing that means “make these instance variables accessible as methods”. So you can call `header.flags` to look at the `@flags` variable.
We can call this with `DNSHeader(buf)`. Not so bad.
Lets move on to the hardest part: parsing a domain name.
##### step 8: parse a domain name
First, lets write a partial version.
```
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
```
This repeatedly reads 1 byte and then reads that length into a string until the length is 0.
This works great, for the first time we see a domain name (`example.com`) in our DNS response.
##### trouble with domain names: compression!
But the second time `example.com` appears, we run into trouble in Wireshark, it says that the domain is represented cryptically as just the 2 bytes `c00c`.
This is something called **DNS compression** and if we want to parse any DNS responses were going to have to implement it.
This is luckily not **that** hard. All `c00c` is saying is:
* The first 2 bits (`0b11.....`) mean “DNS compression ahead!”
* The remaining 14 bits are an integer. In this case that integer is `12` (`0x0c`), so that means “go back to the 12th byte in the packet and use the domain name you find there”
If you want to read more about DNS compression, I found the [explanation in the DNS RFC][8] relatively readable.
##### step 9: implement DNS compression
So we need a more complicated version of our `read_domain_name` function
Here it is.
```
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('.')
```
Basically whats happening is:
* if the first 2 bits are `0b11`, we need to do DNS compression. Then:
* read the second byte and do a little bit arithmetic to convert that into the offset
* save the current position in the buffer
* read the domain name at the offset we calculated
* restore our position in the buffer
This is kind of messy but its the most complicated part of parsing the DNS response, so were almost done!
##### step 10: parse a DNS query
You might think “why do we need to parse a DNS query? This is the response!”. But every DNS response has the original query in it, so we need to parse it.
Heres the code for parsing the DNS query.
```
class DNSQuery
attr_reader :domain, :type, :cls
def initialize(buf)
@domain = read_domain_name(buf)
@type, @cls = buf.read(4).unpack('nn')
end
end
```
Theres not very much to it: the type and class are 2 bytes each.
##### step 11: parse a DNS record
This is the exciting part the DNS record is where our query data lives! The “rdata field” (“record data”) is where the IP address were going to get in response to our DNS query lives.
Heres the code:
```
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
```
We also need to do a little work to make the `rdata` field human readable. The meaning of the record data depends on the record type for example for an “A” record its a 4-byte IP address, for but a “CNAME” record its a domain name.
So heres some code to make the request data 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
```
This function uses this `TYPES` hash to map the record type to a human-readable name:
```
TYPES = {
1 => "A",
2 => "NS",
5 => "CNAME",
# there are a lot more but we don't need them for this example
}
```
The most interesting part of `read_rdata` is probably the line `buf.read(length).unpack('C*').join('.')` its saying “hey, an IP address is 4 bytes, so convert it into an array of 4 numbers and then join those with “.“s”.
##### step 12: finish parsing the DNS response
Now were ready to parse the DNS response!
Heres some code to do that:
```
class DNSResponse
attr_reader :header, :queries, :answers, :authorities, :additionals
def initialize(bytes)
buf = StringIO.new(bytes)
@header = DNSHeader.new(buf)
@queries = (1..@header.num_questions).map { DNSQuery.new(buf) }
@answers = (1..@header.num_answers).map { DNSRecord.new(buf) }
@authorities = (1..@header.num_auth).map { DNSRecord.new(buf) }
@additionals = (1..@header.num_additional).map { DNSRecord.new(buf) }
end
end
```
This mostly just calls the other functions weve written to parse the DNS response.
It uses this cute `(1..@header.num_answers).map` construction to create an array of 2 DNS records if `@header.num_answers` is 2. (which is maybe a _little_ bit of Ruby magic but I think its kind of fun and hopefully isnt too hard to read)
We can integrate this code into our main function like this:
```
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]
```
Printing out the records looks awful though (it says something like `#<DNSRecord:0x00000001368e3118>`). So we need to write some pretty printing code to make it human readable.
##### step 13: pretty print our DNS records
We need to add a `.to_s` field to DNS records to make them have a nice string representation. This is just a 1-line method in `DNSRecord`:
```
def to_s
"#{@name}\t\t#{@ttl}\t#{@type_name}\t#{@parsed_rdata}"
end
```
You also might notice that I left out the `class` field of the DNS record. Thats because its always the same (IN for “internet”) so I felt it was redundant. Most DNS tools (like real `dig`) will print out the class though.
##### and were done!
Heres our final `main` function:
```
def main
# connect to google dns
sock = UDPSocket.new
sock.bind('0.0.0.0', 12345)
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
```
I dont think theres too much to say about this we connect, send a query, print out each of the answers, and exit. Success!
```
$ ruby dig.rb example.com
example.com 18608 A 93.184.216.34
```
You can see the final program as a gist here: [dig.rb][2]. You could add more features to it if you want, like
* pretty printing for other query types
* options to print out the “authority” and “additional” sections of the DNS response
* retries
* making sure that the DNS response we see is _actually_ a response to the query we sent (the query ID has to match!
Also [you can let me know on Twitter][9] if Ive made a mistake in this post somewhere I wrote this pretty quickly so I probably got something wrong.
--------------------------------------------------------------------------------
via: https://jvns.ca/blog/2022/11/06/making-a-dns-query-in-ruby-from-scratch/
作者:[Julia Evans][a]
选题:[lujun9972][b]
译者:[译者ID](https://github.com/译者ID)
校对:[校对者ID](https://github.com/校对者ID)
本文由 [LCTT](https://github.com/LCTT/TranslateProject) 原创编译,[Linux中国](https://linux.cn/) 荣誉推出
[a]: https://jvns.ca/
[b]: https://github.com/lujun9972
[1]: https://jvns.ca/blog/2022/02/01/a-dns-resolver-in-80-lines-of-go/
[2]: https://gist.github.com/jvns/1e5838a53520e45969687e2f90199770
[3]: https://gist.github.com/jvns/aa202b1edd97ae261715c806b2ba7d39
[4]: https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.1
[5]: https://ruby-doc.org/core-3.0.0/Array.html#method-i-pack
[6]: https://wizardzines.com/comics/little-endian/
[7]: https://gist.github.com/jvns/3587ea0b4a2a6c20dcfd8bf653fc11d9
[8]: https://datatracker.ietf.org/doc/html/rfc1035#section-4.1.4
[9]: https://twitter.com/b0rk