Posted on

Let's write our own DNS server?

What? Why? There are plenty of good options out there. Pi-hole, Blocky, Bind, regular old unbound, dns-crypt are all great projects that would be fine as a home dns server. I was wanting to do a personal project to help me learn Rust and this seemed like a good choice. Using Rust to write network systems is very much in its wheelhouse so it's a fun and pleasant experience. It's also a project that could be done in increments. DNS servers can be layered so I didn't have to support all the features I wanted at once. I could for example still have pi-hole blocking ads while it delegated upstream to my own simple resolving server. Anyway, here are some notes on how to get started writing a DNS server.

The MVP DNS server

To start creating a DNS server I'm leveraging Hickory DNS. It's a fantastic project that could be used to run a full fledged DNS server or in this case, be used as a library to build our own tiny DNS server.

The smallest server we can write will just accept connections on udp port and forward the request to an upstream server. The program then returns the upstream result back to the client. For the runtime we'll use Tokio.

Encoder/Decoder

Creating the codec is trivial, Hickory and Tokio do all the hard work for us. All we have to do is glue them together. For the Encoder :


 fn encode(&mut self, item: Message, dst: &mut BytesMut) -> Result<(), Self::Error> {
        let message = item.to_bytes();
        match message {
            Ok(response) => {
                dst.put_slice(&response);
                Ok(())
            }
            Err(error) => Err(io::Error::new(ErrorKind::InvalidData, error)),
        }
    }


And similarly for the decoder :


    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
        let len = src.len();
        if len == 0 {
            return Ok(None);
        }
        let payload = src.split_to(len).freeze();
        let message = Message::from_bytes(payload.as_ref());
        match message {
            Ok(request) => Ok(Some(request)),
            Err(error) => Err(io::Error::new(ErrorKind::InvalidData, error)),
        }
    }

Once we attach these to a socket we'll have the capability to read and write DNS messages. This is for UDP only, if we wanted to enable TCP support the codec looks very similar except that TCP uses a framed payload with the first 2 bytes being the message length.

Wiring up the main

Definitely not exhaustive but just an example on how we could take the request from a client, use the Hickory resolver to do the loop and pass the result back.


#[tokio::main]
async fn main() -> Result<(), Error> {
    let handle = tokio::spawn(async move {
        let interface = SocketAddrV4::new(Ipv4Addr::UNSPECIFIED, 53);
        let udp_socket = UdpSocket::bind(interface).await.expect("Failed to bind");
        let codec = DNSCodec::default();
        let mut frame = UdpFramed::new(udp_socket, codec);
        let resolver = resolver();
        loop {
            let next = frame.next().await;
            match next {
                None => {}
                Some(value) => match value {
                    Ok(request) => {
                        let mut message = request.0;
                        let socket = request.1;
                        if message.header().op_code() == Query {
                            let query = message.query().unwrap();
                            let resolved = resolver.lookup(query.name.clone(), query.query_type).await;
                            // Handle the error conditions. Not found for example will be an error and leave
                            // the client hanging. 
                            if let Ok(result) = resolved {
                                let mut new_header = *message.header();
                                new_header.set_message_type(MessageType::Response);
                                message.set_header(new_header);
                                message.insert_answers(result.records().to_vec());
                                frame.send((message ,socket)).await.expect("Error writing response");
                            }
                        }
                    }
                    Err(err) => {
                        eprintln!("{}", err);
                    }
                },
            }
        }
    });

    let _ = handle.await;

    tokio::select! {
        _ = signal::ctrl_c() => {
            println!("Server stopped");
        }
    }

    Ok(())
}

Next steps

Source code for this post in on my GitHub. This code provides the very basics to have something to play around with on your network.