There's only about 4.2 billion IPv4 addresses. This seems like a lot, but if you've got a list of 2048 words, you'd only need three of them to cover all the IPv4 addresses in the world. Hence: What3Words, DNS Edition!

Find a What3Words for your domain...

I started by building a "translator", which lets you enter a real domain and see what the equivalent three-word triple would be:

$ ./what3words -t bede.io
balcony.scissors.hurt

Neat! It looks up the domain's IP address using Go's net.LookupIP function, then converts that IP into a trio of words.

IPv4 addresses are 32 bits long, so they're normally split into 4 bytes. But it's not called “what4words”. I used some bit-shifting to pick out the top 10 bits, then the middle 11 bits, and finally the last 11 bits:

ipNumber := binary.BigEndian.Uint32(fourByte)
firstIndex := ipNumber >> 22 & 0x7FF
secondIndex := ipNumber >> 11 & 0x7FF
thirdIndex := ipNumber & 0x7FF

Then I used each of those indexes to lookup a word in a big slice of 2048 words loaded from disk.

...and an IP for your What3Words!

I could build a command-line tool which translates back the other way. But turning a name into an IP address sounds like a job for DNS!

So, I built a mini DNS server. I looked around and the advice was almost always “don't build this yourself!” — always a sign that I'm about to learn some fun stuff.

This GitHub repository of a tiny DNS server was useful as a jumping-off point, as was this ServerFault answer describing some of the format, but to build this thing I ended up having to use the spec (RFC 1035) itself for reference. Honestly, it was pretty easy to follow — lovely ASCII diagrams like you see in the header of this page!

This does the inverse of the translator tool, but over DNS:

  1. Read the query and extract the domain name pieces; then
  2. Send back a correctly-formatted response

DNS Protocol Wrangling

A DNS request looks pretty much the same as a DNS response. There's a Headers section, a Question section, an Answer section, and a couple of others which I didn't bother handling for this simple demo.

It's a reasonably simple protocol, but there was still a lot of binary formatting to get right before any of these DNS requests would work. For example, this was the code to format the response header:

func getResponseHeaders(dataSlice []byte) []byte {
	// Set the query/response bit to RESPONSE.
	var QR byte = 0b1 << 7

	// Responding to a standard query only.
	var OPCode byte = 0b0 << 3

	// We're an authority on these triple-names.
	var AA byte = 0b1 << 2

	// We'll never truncate a message.
	var TC byte = 0b0 << 1

	// Recursion desired? (Copy from request.)
	var RD byte = dataSlice[3] & 0x2

	// We can't provide recursion.
	var RA byte = 0b0 << 7

	// Z must always be zeroes.
	var Z byte = 0x0

	// No error from us!
	var RCode byte = 0x0

	return []byte{
		// ID (2 bytes)
		dataSlice[0], dataSlice[1],

		// Flags (1 byte)
		QR | OPCode | AA | TC | RD,

		// More flags (1 byte)
		RA | Z | RCode,

		// Number of entries in the question section
		0, 0,

		// Number of resource records in the answer section
		0, 1,

		// Number of NS resource records in the answer section
		0, 0,

		// Number of resource records in the additional records section
		0, 0,
	}
}

I'm sure a real DNS (or Go!) expert would have a lot to say about this, but it's a working DNS server, which I've never built before!

The way it interacts with the network is using the lovely socket API provided by the Go standard library. We use socket.ReadFromUDP in a loop to get each new data, then go processDNSRequest to start up a goroutine and send a response back using socket.WriteToUDP.

Testing it locally

Using dig or nslookup we can see it's sending back a well-formatted response for our DNS query!

$ sudo ./what3words -s
Listening on port 53...
Transaction ID: 0x20FC
Flags: 0x0120
Questions: 1
Name: balcony.scissors.hurt
Responding with IP: 35.176.67.126
$ dig @localhost balcony.scissors.hurt

; <<>> DiG 9.10.6 <<>> @localhost balcony.scissors.hurt
; (2 servers found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 8444
;; flags: qr aa; QUERY: 0, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0

;; ANSWER SECTION:
balcony.scissors.hurt.	0	IN	A	35.176.67.126

;; Query time: 0 msec
;; SERVER: 127.0.0.1#53(127.0.0.1)
;; WHEN: Tue Apr 12 18:38:10 BST 2022
;; MSG SIZE  rcvd: 49

Silly MacOS Redundancy

I had a bunch of trouble getting this to work correctly with MacOS. It turns out that while dig and nslookup are absolutely fine with the response as-is, most tools on MacOS (i.e. the ones that use mDNSResponder, like ping, curl, Safari, etc.) require you to send back the Question part of the DNS query! Otherwise, they fail with some ambiguous errors.

This was a surprise to me, since it seems like redundant information — after all, the “ID” should tell any DNS client what the server is responding to.

After adding this chunk of code...

// For some reason, we need to send the Question section back...
reqQuestion := []byte{}
reqQuestion = append(reqQuestion, domainSliceToBytes(name)...)
// A Record, IN Type.
reqQuestion = append(reqQuestion, 0, 1, 0, 1)
res = append(res, reqQuestion...)

...and adding 127.0.0.1 at the top of my DNS servers (not recommended!)...

...it connects to my server as expected:

It's managed to connect to my Caddy server, which has this directive to serve a different response for anything asking for the hostname balcony.scissors.hurt:

http://balcony.scissors.hurt:80 {
  file_server {
    root website/balcony
  }
}

The full code for my mini-DNS-server is on GitHub. Now to convince everyone else to use it...