DNS Fun, with Rust.

Posted by Patrick Elsen on 4 June 2022

Today, as I was browsing through lobste.rs, I found a very interesting article titled DNS toys. The author built a DNS server in golang, which abuses the protocol to implement some fun conversions and lookup routines, accessible via DNS queries. In this post, I will walk you through implementing some of that functionality in Rust.

DNS is the system that translates domain names, such as google.com, to the IP addresses of the servers hosting that website. As such, it is a core internet protocol. Typically, it is used to look up IP addresses (In DNS terms, these are called A and AAAA records) or other metadata (MX records give you email servers). If you want to learn more about DNS, I would very much recommend you to check out Julia Evan's post on How DNS works, it is a great overview and does a much better job of explaining it than I could.

Even though DNS is usually used to return useful information, nothing prevents you from using it to return anything of interest. The author of the article built a DNS server that lets you look up the weather and time for locations, convert units and currency, and do other fun things. Take a look:

# check local weather
$ dig +short darmstadt.weather @dns.toys
"Darmstadt (DE)" "15.20C (59.36F)" "66.50% hu." "cloudy" "01:00, Tue"
"Darmstadt (DE)" "15.00C (59.00F)" "70.30% hu." "cloudy" "03:00, Tue"
"Darmstadt (DE)" "14.20C (57.56F)" "76.50% hu." "fair_day" "05:00, Tue"

# check local time
$ dig +short newyork.time @dns.toys
"New York (America/New_York, US)" "Mon, 06 Jun 2022 18:15:05 -0400"

I always love it when people abuse protocols to do what they were perhaps not intended for. Seeing that makes me happy, that is the epitome of the hacking culture. This particular project is one that I understand, having worked with DNS before, and having myself implemented a DNS server. But since perhaps not everyone knows what DNS is, or how this all works, I figured I write a blog post about it, walking you through how such a service can be built in a language like Rust.

DNS in Rust

Thankfully, Rust has some crates that can help us here. There is the Trust DNS crate, which is a DNS server and client implemented in pure Rust. In addition to that there is Tokio, which is the most popular async runtime for Rust at this moment.

We can start with an empty project to try it out. I'm using cargo-edit to manage dependencies (which provides cargo add to easily add dependencies to a project).

$ cargo new dns-fun
     Created binary (application) `dns-fun` package
$ cargo add trust-dns-server
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding trust-dns-server v0.21.2 to dependencies
$ cargo add tokio --features macros
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding tokio v1.19.2 to dependencies with features: ["macros"]

Trust DNS makes it really easy for us to build a DNS server with custom logic. They provide a ServerFuture, which acts as a DNS server and handles requests and responses over different protocols (UDP, TCP, TLS and HTTPS). All that is needed is some variable that is called whenever a request comes in to determine what to respond. They have a built-in Catalog type, which you can add records to and it will generate appropriate responses.

This is how you build a DNS server with it. This will listen on UDP port 1053, so that it can be started without needing elevated privileges and does not conflict with any DNS server you might already have running on your system.

use tokio::net::UdpSocket;
use trust_dns_server::{authority::Catalog, ServerFuture};

#[tokio::main]
pub async fn main() {
    let catalog = Catalog::new();
    let mut server = ServerFuture::new(catalog);
    server.register_socket(UdpSocket::bind("0.0.0.0:1053").await.unwrap());
    server.block_until_done().await.unwrap();
}

Running this does not produce any results, because we have not added any zones to the catalog. The output of the dig command can be a little difficult to parse, but the important bit is the REFUSED flag. That means that we got a reply from the server, but the server indicated that it refused to send a response.

$ cargo run &
$ dig @127.0.0.1 -p 1053 hello.com

; <<>> DiG 9.16.27-Debian <<>> @127.0.0.1 -p 1053 hello.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: REFUSED, id: 34568
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available

;; QUESTION SECTION:
;hello.com.                     IN      A

;; Query time: 0 msec
;; SERVER: 127.0.0.1#1053(127.0.0.1)
;; WHEN: Thu Jun 09 13:16:00 CEST 2022
;; MSG SIZE  rcvd: 27

Custom ResponseHandler

What we want to do is handle some DNS request, do some custom logic to resolve them, and then generate a response. In other terms, we want to implement our own RequestHandler. This is a trait that requires a value to have exactly one function, handle_request(Request, R). The actual requirements look somewhat hard to read, partially because they have been autogenerated by async-trait, but don't worry about that.

Before we do anything further, we need to add async-trait help us implement that trait, and while we are at it, we also add env_logger and log so we can get some useful debug logs to help while we're building this.

$ cargo add async-trait
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding async-trait v0.1.56 to dependencies
$ cargo add env_logger
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding env_logger v0.9.0 to dependencies
$ cargo add log
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding log v0.4.17 to dependencies

With these two dependencies set, we can create a dummy type that implements RequestHandler. For the implementation of the handle_request() function, I looked at the code for the implementation of Catalog, which was rather helpful. This is what we have so far, similar to the previous code but with added support for logging (using env_logger) and a custom handler, instead of using the built-in Catalog:

use async_trait::async_trait;
use log::*;
use tokio::net::UdpSocket;
use trust_dns_server::{
    authority::MessageResponseBuilder,
    proto::op::{Header, ResponseCode},
    server::{Request, RequestHandler, ResponseHandler, ResponseInfo},
    ServerFuture,
};

struct Handler {}

impl Handler {
    pub fn new() -> Self {
        Handler {}
    }
}

#[async_trait]
impl RequestHandler for Handler {
    async fn handle_request<R: ResponseHandler>(
        &self,
        request: &Request,
        mut response_handle: R,
    ) -> ResponseInfo {
        let response = MessageResponseBuilder::from_message_request(request);

        let result = response_handle
            .send_response(response.error_msg(request.header(), ResponseCode::NotImp))
            .await;

        match result {
            Err(error) => {
                error!("Response error: {error}");
                let mut header = Header::new();
                header.set_response_code(ResponseCode::ServFail);
                header.into()
            }
            Ok(info) => info,
        }
    }
}

#[tokio::main]
pub async fn main() {
    env_logger::init();
    let catalog = Handler::new();
    let mut server = ServerFuture::new(catalog);
    server.register_socket(UdpSocket::bind("0.0.0.0:1053").await.unwrap());
    server.block_until_done().await.unwrap();
}

Let's walk through this code a bit. First, we are including some dependencies, all of the types that we need. Next, we define a struct that has no members, and a function to create a new instance of this struct. It might seem pointless right now, but we will need to put some stuff into that later.

Then, we implement RequestHandler for our own Handler type. Rust does not allow async functions in traits by default, but the async_trait macro that we are using there converts our async trait implementation into a non-async one. There is a blog post on why async fn in traits are hard that explains some of the background of this, but it's nothing to worry about for right now.

The RequestHandler's handle_request function gets called for every single request we get, and it has the ability to respond to requests by calling the send_response function on the response_handle function. Right now, for every request we get, we immediately respond with the not implemented response code.

We can try this out. Because we have added env_logger, setting the RUST_LOG environment variable to debug will enable a lot of debug log messages. If you run this code in one terminal, and make the curl request in another terminal, you can watch the request come in through the logs:

$ RUST_LOG=debug cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.03s
     Running `target/debug/dns-fun`
[2022-06-09T11:53:20Z DEBUG trust_dns_server::server::server_future] registering udp: PollEvented { io: Some(UdpSocket { addr: 0.0.0.0:1053, fd: 6 }) }
[2022-06-09T11:53:24Z DEBUG trust_dns_server::server::server_future] received udp request from: 127.0.0.1:55281
[2022-06-09T11:53:24Z DEBUG trust_dns_server::server::server_future] request:32256 src:UDP://127.0.0.1#55281 type:QUERY dnssec:false QUERY:hello.com.:A:IN qflags:RD,AD
[2022-06-09T11:53:24Z DEBUG trust_dns_server::server::response_handler] response: 32256 response_code: Not Implemented
[2022-06-09T11:53:24Z INFO  trust_dns_server::server::server_future] request:32256 src:UDP://127.0.0.1#55281 QUERY:hello.com.:A:IN qflags:RD,AD response:NotImp rr:0/0/0 rflags:RD
$ dig @127.0.0.1 -p 1053 hello.com

; <<>> DiG 9.16.27-Debian <<>> @127.0.0.1 -p 1053 hello.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOTIMP, id: 32256
;; flags: qr rd; QUERY: 1, ANSWER: 0, AUTHORITY: 0, ADDITIONAL: 0
;; WARNING: recursion requested but not available

;; WARNING: EDNS query returned status NOTIMP - retry with '+noedns'

;; QUESTION SECTION:
;hello.com.                     IN      A

;; Query time: 4 msec
;; SERVER: 127.0.0.1#1053(127.0.0.1)
;; WHEN: Thu Jun 09 13:53:24 CEST 2022
;; MSG SIZE  rcvd: 27

Again, the output of the dig command is a bit terse, but the crucial piece is where it sais status: NOTIMP, that tells us that the server sent a response with the not implemented status bit set. So the code is working as expected.

Returning IP address

Next, we can implement a handler that gets called when you try to resolve the domain name ip, which returns your own IP address back to you. To do this, we add some logic in the handler to make sure that we get the right request.

impl Handler {
    async fn handle_request_ip<R: ResponseHandler>(
        &self,
        request: &Request,
        mut response_handle: R,
    ) -> Result<ResponseInfo, std::io::Error> {
        let builder = MessageResponseBuilder::from_message_request(request);
        let mut header = Header::new();
        header.set_id(request.id());
        header.set_op_code(OpCode::Query);
        header.set_message_type(MessageType::Response);
        header.set_authoritative(true);
        let rdata = match request.src().ip() {
            IpAddr::V4(ipv4) => RData::A(ipv4),
            IpAddr::V6(ipv6) => RData::AAAA(ipv6),
        };
        let records = vec![Record::from_rdata(request.query().name().into(), 60, rdata)];
        let response = builder.build(header, records.iter(), &[], &[], &[]);
        response_handle.send_response(response).await.into()
    }
}

First, we can add a method that will, given a request, return a response with the sender's IP address. To make this work, we need to import a few things, which I'm not showing here. Calling request.src(), we get a SocketAddr of whoever sent us the DNS query. We can then match on the IP address and create the appropriate record: an A record if it's an IPv4, or an AAAA record if it's an IPv6.

From that data, we create a response and send it off. We use a TTL of 60 seconds, but that is basically meaningless, because this is not a real domain.

Next, we have to get back to the RequestHandler trait implementation, and call this function we've just written. Before we do that, we add one member to our Handler struct: the ip_zone. A zone in DNS is something like a subdomain. In this case, we want this handler to be run whenever you make a request for something ending in .ip, for example my.ip or just ip.

struct Handler {
    ip_zone: LowerName,
}

impl Handler {
    pub fn new() -> Self {
        Handler {
            ip_zone: LowerName::from(Name::from_str("ip").unwrap()),
        }
    }
}

With this zone name set, we can then add some logic in the RequestHandler to match the request based on the lookup name. What we want to do is call our IP handler if we get a query that matches the ip zone, otherwise we still return not implemented.

#[async_trait]
impl RequestHandler for Handler {
    async fn handle_request<R: ResponseHandler>(
        &self,
        request: &Request,
        mut response_handle: R,
    ) -> ResponseInfo {
        // Figure out what zone this request is for, and call the appropriate handler.
        // Otherwise respond with not implemented.
        let op = request.op_code();
        let message_type = request.message_type();
        let query = request.query().name();
        let result = match (message_type, op, query) {
            (MessageType::Query, OpCode::Query, query) if self.ip_zone.zone_of(query) => {
                self.handle_request_ip(request, response_handle).await
            }
            _ => {
                let response = MessageResponseBuilder::from_message_request(request);
                response_handle
                    .send_response(response.error_msg(request.header(), ResponseCode::NotImp))
                    .await
            }
        };

        match result {
            Err(error) => {
                error!("Response error: {error}");
                let mut header = Header::new();
                header.set_response_code(ResponseCode::ServFail);
                header.into()
            }
            Ok(info) => info,
        }
    }
}

Again, we can verify that this works by running it with cargo run and using the dig command to send queries against it. I'm using +noall +answer to shorten the output of the command, but you can see that making a request for my.ip returns an A record with my local IP address:

$ dig @127.0.0.1 -p 1053 my.ip +noall +answer
my.ip.                  60      IN      A       127.0.0.1

Unit Conversions

Next, we can implement unit conversions. The idea here is that looking up a domain like 15mi-km.unit should produce a response like 10km, whereby it converts from one unit into another one. Just for fun, I wanted to implement this slightly differently. I want the lookups to have the shape 9.12345.km.mi.unit, which would convert 9.12345km into miles. The syntax in this case would be <number>.<source-unit>.<target-unit>.unit.

To make this happen, we need a crate that can handle unit conversions. All of Rust crates are published on <crates.io>, and a search for unit conversions reveals a few interesting crates, one of those being rink. I chose it because it seems to do what we need, and it has been updated recently, so we know that it is still somewhat actively maintained.

Looking at the dependencies, we can see that it depends on rink-core, which implements all of the functionality. Rink itself it just a CLI wrapper.

So we start by adding a dependency on rink-core for the crate.

$ cargo add rink-core
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding rink-core v0.6.2 to dependencies.

Rink needs a Context that contains things such as type definitions and constants, so we need to add that to the Handler struct. We can use the simple_context function to create a new one that has some pre-populated definitions loaded into it. While we're at it, we also add a Zone name for the unit conversions, the unit. zone.

use rink_core::{simple_context, text_query, Context};

struct Handler {
    ip_zone: LowerName,
    unit_zone: LowerName,
    rink_context: Context,
}

impl Handler {
    pub fn new() -> Self {
        Handler {
            ip_zone: LowerName::from(Name::from_str("ip").unwrap()),
            unit_zone: LowerName::from(Name::from_str("unit").unwrap()),
            rink_context: simple_context().unwrap(),
        }
    }
}

Given this Handler, we can now write a function that will parse a conversion string like 500 km -> mi, and return a string containing the result of that conversion. We add this to the impl Handler block we already have.

impl Handler {
    fn convert_unit(&self, line: &str) -> String {
        let mut iter = text_query::TokenIterator::new(line.trim()).peekable();
        let expr = text_query::parse_query(&mut iter);
        debug!("{expr:?}");
        let res = self.rink_context.eval_outer(&expr);
        let res = match res {
            Ok(r) => r.to_string(),
            Err(r) => r.to_string(),
        };
        res
    }
}

With this set, we can add the actual request handler for unit conversions. This method is a bit longer, because we need to parse two different kinds of lookups (with decimals and without decimals), handle converting them into numbers, and turning the request back into a string that rink can parse.

impl Handler {
    /// Given a request for `*.unit`, returns a response containing the conversion result
    /// of the unit.
    async fn handle_request_unit<R: ResponseHandler>(
        &self,
        request: &Request,
        mut response_handle: R,
    ) -> Result<ResponseInfo, std::io::Error> {
        // get the original query, preserving casing
        let query = request.query().original().name();

        // if the query was a wildcard (*.unit), reject it
        if query.is_wildcard() {
            return self.handle_request_error(request, response_handle).await;
        }
        // convert the query into an array of labels (abc.def.unit -> ["abc", "def", "unit"]
        let labels: Result<Vec<String>, _> = query
            .iter()
            .map(|v| String::from_utf8(v.to_vec()))
            .collect();
        // parse the list of labels. two possibilities here:
        // four labels: "9.km.mi.unit" -> (9.0, "km", "mi")
        // five labels: "9.123.km.mi.unit -> (9.123, "km", "mi")
        let (value, source, target) = match labels {
            Ok(labels) if labels.len() == 5 => {
                let value: f64 = labels[0].parse().unwrap();
                let digits: f64 = labels[1].parse().unwrap();
                let value = value + digits / 10f64.powi(labels[1].len() as i32);
                (value, labels[2].clone(), labels[3].clone())
            }
            Ok(labels) if labels.len() == 4 => {
                let value: f64 = labels[0].parse().unwrap();
                (value, labels[1].clone(), labels[2].clone())
            }
            _ => return self.handle_request_error(request, response_handle).await,
        };
        // convert the parsed data back into a string and parse it as a rink query
        let query = format!("{value}{source} -> {target}");
        let res = self.convert_unit(&query);
        // create DNS response for the result
        let builder = MessageResponseBuilder::from_message_request(request);
        let mut header = Header::new();
        header.set_id(request.id());
        header.set_op_code(OpCode::Query);
        header.set_message_type(MessageType::Response);
        header.set_authoritative(true);
        let rdata = RData::TXT(TXT::new(vec![res]));
        let records = vec![Record::from_rdata(request.query().name().into(), 60, rdata)];
        let response = builder.build(header, records.iter(), &[], &[], &[]);
        response_handle.send_response(response).await.into()
    }
}

Finally, we need to call this handler in our main RequestHandler trait implementation.

#[async_trait]
impl RequestHandler for Handler {
    async fn handle_request<R: ResponseHandler>(
        &self,
        request: &Request,
        response_handle: R,
    ) -> ResponseInfo {
        // Figure out what zone this request is for, and call the appropriate handler.
        // Otherwise respond with not implemented.
        let op = request.op_code();
        let message_type = request.message_type();
        let query = request.query().name();
        let result = match (message_type, op, query) {
            (MessageType::Query, OpCode::Query, query) if self.ip_zone.zone_of(query) => {
                self.handle_request_ip(request, response_handle).await
            }
            (MessageType::Query, OpCode::Query, query) if self.unit_zone.zone_of(query) => {
                self.handle_request_unit(request, response_handle).await
            }
            _ => self.handle_request_error(request, response_handle).await,
        };

        match result {
            Err(error) => {
                error!("Response error: {error}");
                let mut header = Header::new();
                header.set_response_code(ResponseCode::ServFail);
                header.into()
            }
            Ok(info) => info,
        }
    }
}

With this change, we can now build and run the DNS server, and have it respond to lookups.

$ dig +short 15.km.mi.unit @127.0.0.1 -p 1053
"approx. 9.320567 mile (length)"

Adding Command-Line Options

Right now, we have the code hard-coded to launch a DNS server on port 1053, because that makes running it for development easier, since it needs no elevated privileges to listen on that port, unlike port 53 that is reserved for DNS. But what if we want the port to be settable at runtime?

Rust has the StructOpt crate, which makes parsing command-line options very straightforward. It lets you define a struct that contains all of the possible command-line options, and it will automatically generate a parser for command-line options from that definition using the similarly amazing Clap crate.

To use it, we again first add it using cargo-add:

$ cargo add structopt
    Updating 'https://github.com/rust-lang/crates.io-index' index
      Adding structopt v0.3.26 to dependencies.

Next, we add a struct that has the options we need. We want the DNS server to be able to listen on different ports, so we add a listen field that has an array of SocketAddrs (a SocketAddr is something like 0.0.0.0:53 -- it combines an IP address and a port). By specifying long and short we are saying that we want short (like -l 0.0.0.0:53) and long (like --listen 0.0.0.0:53) flags. Finally, we use env to allow parsing this option from an environment variable, and we set use_delimiter to allow a comma-delimited value. This lets you specify the environment variable DNS_LISTEN=0.0.0.0:53,0.0.0.0:1053 instead of using the command-line flags. Finally, we set required to true, which means that you need to supply at least one listen address. Because it is declared to be an array, and arrays can be empty, it otherwise does not require you to set any. But a server that doesn't listen on anything is not very useful.

use std::net::SocketAddr;
use structopt::StructOpt;

#[derive(StructOpt, Clone)]
pub struct Options {
    #[structopt(long, short, env = "DNS_LISTEN", use_delimiter = true, required = true)]
    listen: Vec<SocketAddr>,
}

With this definitions of the Options struct, we can now change the main() method to use it. Here we use the from_args() method that we get from the StructOpt trait.

#[tokio::main]
pub async fn main() {
    env_logger::init();
    let handler = Handler::new();
    let mut server = ServerFuture::new(handler);
    server.register_socket(UdpSocket::bind("0.0.0.0:1053").await.unwrap());
    server.block_until_done().await.unwrap();
}

Finally, we can validate that it works by running it without arguments once, to make sure that it does indeed require at least one listen socket, and then running it with multiple arguments and turning on logs to verify that it does indeed listen on all the sockets it is told.

$ cargo run
    Finished dev [unoptimized + debuginfo] target(s) in 0.08s
     Running `target/debug/dns-fun`
error: The following required arguments were not provided:
    --listen <listen>...

USAGE:
    dns-fun --listen <listen>...

For more information try --help
$ cargo run -- --help
    Finished dev [unoptimized + debuginfo] target(s) in 0.09s
     Running `target/debug/dns-fun --help`
dns-fun 0.1.0

USAGE:
    dns-fun --listen <listen>...

FLAGS:
    -h, --help       Prints help information
    -V, --version    Prints version information

OPTIONS:
    -l, --listen <listen>...     [env: DNS_LISTEN=]
$ RUST_LOG=debug cargo run -- -l 0.0.0.0:53 -l 0.0.0.0:1053
[2022-06-16T10:53:57Z DEBUG trust_dns_server::server::server_future] registering udp: PollEvented { io: Some(UdpSocket { addr: 0.0.0.0:2053, fd: 6 }) }
[2022-06-16T10:53:57Z DEBUG trust_dns_server::server::server_future] registering udp: PollEvented { io: Some(UdpSocket { addr: 0.0.0.0:1053, fd: 7 }) }

We get a --help flag, version information and parsing for free from StructOpt, which I think it very neat. It also looks to be working like intended: the server binds on the addresses that are passed to it. Being able to set command-line options via environment variables makes using Rust services pleasant for example in Docker containers.

Testing it

It is great that we are able to manually verify that the DNS server is working, but that is not a very robust way to make sure that it is indeed correct. What we need is some automated tests. While it might seem annoying now to write tests for things that we already know are working, these are indispensable for any solid code base.

One good way to test this DNS server is by launching it, and sending actual DNS requests to it. We can use the Trust DNS client implementation to do that.

Continuous Integration and Continuous Delivery

Now that we have tests, we need some place to run these tests. And if the tests pass, we need some way to deliver our component.

Sure, developers can test the code locally, and pinkie-promise that they will only push code up to the repository that they have tested and that works. But what if someone forgets to test it? Or what if it works on one developer's machine, but not on another one's?

It is good to have one central place where tests are run in a controlled environment. This is what Continuous Integration systems are useful for: they let us run code on every push to a Git repository. There is quite a good variety of CI/CD systems out there, I personally like to use GitLab CI, because I find it very easy to use.

We can start with a simple .gitlab-ci.yml file, which we place into the repository, where it will be automatically picked up. This file defines some stages (which run sequentially), and some jobs (which belong to the stages we defined earlier). Every job has a Docker image and a few commands that are run, and furthermore jobs can export files (which other jobs can consume). This is a simple config to use for this Rust project:

stages:
  - test
  - build
  - publish

test:
  stage: test
  image: rust
  script:
    - cargo test

build:
  stage: build
  image: rust
  script:
    - cargo build --release

docs:
  stage: build
  image: rust
  script:
    - cargo doc

pages:
  stage: publish
  script:
    - mkdir public
    - mv target/doc public/doc
    - mv target/release/dns-fun public/dns-fun

Publishing it

Now that we have built something that is at least somewhat useful, we can publish on crates.io so that it is easy to install.

To do so, we need to set some package metadata in the Cargo.toml file. There is a few values that crates.io requires for public crates, such as the authors and the license.

[package]
name = "dns-fun"
version = "0.1.0"
edition = "2021"
authors = ["Patrick Elsen <pelsen@xfbs.net>"]
description = "DNS server"

We need to make sure to commit this change, because cargo will complain if there are uncommitted changes in the repository and it will not let us publish the crate. So we commit and push the change first, and then we publish it with cargo.

$ git add .
$ git commit -m "Updates crate metadata for publishing"
$ git push origin master
$ cargo publish
...
    Finished dev [unoptimized + debuginfo] target(s) in 1m 07s
   Uploading dns-fun v0.1.0 (/home/patrick/Projects/xfbs/dns-fun)

Now, with the package published, you can go install it on your local system by running

cargo install dns-fun

Conclusion

The finished DNS server code from this blog post is available at xfbs/dns-fun. Feel free to clone it and have some fun with it.

I hope this post was insightful into how Rust works, and what you can do with it. There are still a few rough edges around the async stuff, for example that we needed to use async-trait. But everything considered, it is a very pleasant experience and the ecosystem is good. The Trust DNS server crate was quite pleasant to use, even if at times I needed to read a lot of the documentation and code to understand how to use it, but that is perhaps more owed to the fact that I don't fully understand DNS.