My 2018 contains robur and starts with re-engineering DNS

Written by hannes
Classified under: mirageosprotocol
Published: 2018-01-11 (last updated: 2023-11-20)

2018

At the end of 2017, I resigned from my PostDoc position at University of Cambridge (in the rems project). Early December 2017 I organised the 4th MirageOS hack retreat, with which I'm very satisfied. In March 2018 the 5th retreat will happen (please sign up!).

In 2018 I moved to Berlin and started to work for the (non-profit) Center for the cultivation of technology with our robur.coop project "At robur, we build performant bespoke minimal operating systems for high-assurance services". robur is only possible by generous donations in autumn 2017, enthusiastic collaborateurs, supportive friends, and a motivated community, thanks to all. We will receive funding from the prototypefund to work on a CalDAV server implementation in OCaml targeting MirageOS. We're still looking for donations and further funding, please get in touch. Apart from CalDAV, I want to start the year by finishing several projects which I discovered on my hard drive. This includes DNS, opam signing, TCP, ... . My personal goal for 2018 is to develop a flexible mirage deploy, because after configuring and building a unikernel, I want to get it smoothly up and running (spoiler: I already use albatross in production).

To kick off (3% of 2018 is already used) this year, I'll talk in more detail about µDNS, an opinionated from-scratch re-engineered DNS library, which I've been using since Christmas 2017 in production for ns.nqsb.io and ns.robur.io. The development started in March 2017, and continued over several evenings and long weekends. My initial motivation was to implement a recursive resolver to run on my laptop. I had a working prototype in use on my laptop over 4 months in the summer 2017, but that code was not in a good shape, so I went down the rabbit hole and (re)wrote a server (and learned more about GADT). A configurable resolver needs a server, as local overlay, usually anyways. Furthermore, dynamic updates are standardised and thus a configuration interface exists inside the protocol, even with hmac-signatures for authentication! Coincidentally, I started to solve another issue, namely automated management of let's encrypt certificates (see this branch for an initial hack). On my journey, I also reported a cache poisoning vulnerability, which was fixed in Docker for Windows.

But let's get started with some content. Please keep in mind that while the code is publicly available, it is not yet released (mainly since the test coverage is not high enough, and the lack of documentation). I appreciate early adopters, please let me know if you find any issues or find a use case which is not straightforward to solve. This won't be the last article about DNS this year - persistent storage, resolver, let's encrypt support are still missing.

What is DNS?

The domain name system is a core Internet protocol, which translates domain names to IP addresses. A domain name is easier to memorise for human beings than an IP address. DNS is hierarchical and decentralised. It was initially "specified" in Nov 1987 in RFC 1034 and RFC 1035. Nowadays it spans over more than 20 technical RFCs, 10 security related, 5 best current practises and another 10 informational. The basic encoding and mechanisms did not change.

On the Internet, there is a set of root servers (administrated by IANA) which provide the information about which name servers are authoritative for which top level domain (such as ".com"). They provide the information about which name servers are responsible for which second level domain name (such as "example.com"), and so on. There are at least two name servers for each domain name in separate networks - in case one is unavailable the other can be reached.

The building blocks for DNS are: the resolver, a stub (gethostbyname provided by your C library) or caching forwarding resolver (at your ISP), which send DNS packets to another resolver, or a recursive resolver which, once seeded with the root servers, finds out the IP address of a requested domain name. The other part are authoritative servers, which reply to requests for their configured domain.

To get some terminology, a DNS client sends a query, consisting of a domain name and a query type, and expects a set of answers, which are called resource records, and contain: name, time to live, type, and data. The resolver iteratively requests resource records from authoritative servers, until the requested domain name is resolved or fails (name does not exist, server failure, server offline).

DNS usually uses UDP as transport which is not reliable and limited to 512 byte payload on the Internet (due to various middleboxes). DNS can also be transported via TCP, and even via TLS over UDP or TCP. If a DNS packet transferred via UDP is larger than 512 bytes, it is cut at the 512 byte mark, and a bit in its header is set. The receiver can decide whether to use the 512 bytes of information, or to throw it away and attempt a TCP connection.

DNS packet

The packet encoding starts with a 16bit identifier followed by a 16bit header (containing operation, flags, status code), and four counters, each 16bit, specifying the amount of resource records in the body: questions, answers, authority records, and additional records. The header starts with one bit operation (query or response), four bits opcode, various flags (recursion, authoritative, truncation, ...), and the last four bit encode the response code.

A question consists of a domain name, a query type, and a query class. A resource record additionally contains a 32bit time to live, a length, and the data.

Each domain name is a case sensitive string of up to 255 bytes, separated by . into labels of up to 63 bytes each. A label is either encoded by its length followed by the content, or by an offset to the start of a label in the current DNS frame (poor mans compression). Care must be taken during decoding to avoid cycles in offsets. Common operations on domain names are comparison: equality, ordering, and also whether some domain name is a subdomain of another domain name, should be efficient. My initial representation naïvely was a list of strings, now it is an array of strings in reverse order. This speeds up common operations by a factor of 5 (see test/bench.ml).

The only really used class is IN (for Internet), as mentioned in RFC 6895. Various query types (MD, MF, MB, MG, MR, NULL, AFSDB, ...) are barely or never used. There is no need to convolute the implementation and its API with these legacy options (if you have a use case and see those in the wild, please tell me).

My implemented packet decoding does decompression, only allows valid internet domain names, and may return a partial parse - to use as many resource records in truncated packets as possible. There are no exceptions raised, the parsing uses a monadic style error handling. Since label decompression requires the parser to know absolute offsets, the original buffer and the offset is manually passed around at all times, instead of using smaller views on the buffer. The decoder does not allow for gaps, when the outer resource data length specifies a byte length which is not completely consumed by the specific resource data subparser (an A record must always consume four bytes). Failing to check this can lead to a way to exfiltrate data without getting noticed.

Each zone (a served domain name) contains a SOA "start of authority" entry, which includes the primary nameserver name, the hostmaster's email address (both encoded as domain name), a serial number of the zone, a refresh, retry, expiry, and minimum interval (all encoded as 32bit unsigned number in seconds). Common resource records include A, which payload is 32bit IPv4 address. A nameserver (NS) record carries a domain name as payload. A mail exchange (MX) whose payload is a 16bit priority and a domain name. A CNAME record is an alias to another domain name. These days, there are even records to specify the certificate authority authorisation (CAA) records containing a flag (critical), a tag ("issue") and a value ("letsencrypt.org").

Server

The operation of a DNS server is to listen for a request and serve a reply. Data to be served can be canonically encoded (the RFC describes the format) in a zone file. Apart from insecurity in DNS server implementations, another attack vector are amplification attacks where an attacker crafts a small UDP frame with a fake source IP address, and the server answers with a large response to that address which may lead to a DoS attack. Various mitigations exist including rate limiting, serving large replies only via TCP, ...

Internally, the zone file data is stored in a tree (module Dns_trie implementation), where each node contains two maps: sub, which key is a label and value is a subtree and dns_map (module Dns_map), which key is a resource record type and value is the resource record. Both use the OCaml Map ("also known as finite maps or dictionaries, given a total ordering function over the keys. All operations over maps are purely applicative (no side-effects). The implementation uses balanced binary trees, and therefore searching and insertion take time logarithmic in the size of the map").

The server looks up the queried name, and in the returned Dns_map the queried type. The found resource records are sent as answer, which also includes the question and authority information (NS records of the zone) and additional glue records (IP addresses of names mentioned earlier in the same zone).

Dns_map

The data structure which contains resource record types as key, and a collection of matching resource records as values. In OCaml the value type must be homogenous - using a normal sum type leads to an unneccessary unpacking step (or lacking type information):

let lookup_ns t =
  match Map.find NS t with
  | None -> Error `NotFound
  | Some (NS nameservers) -> Ok nameservers
  | Some _ -> Error `NotFound

Instead, I use in my current rewrite generalized algebraic data types (read OCaml manual and Mads Hartmann blog post about use cases for GADTs, Andreas Garnæs about using GADTs for GraphQL type modifiers) to preserve a relation between key and value (and A record has a list of IPv4 addresses and a ttl as value) - similar to hmap, but different: a closed key-value mapping (the GADT), no int for each key and mutable state. Thanks to Justus Matthiesen for helping me with GADTs and this code. Look into the interface and implementation.

(* an ordering relation, I dislike using int for that *)
module Order = struct
  type (_,_) t =
    | Lt : ('a, 'b) t
    | Eq : ('a, 'a) t
    | Gt : ('a, 'b) t
end

module Key = struct
  (* The key and its value type *)
  type _ t =
    | Soa : (int32 * Dns_packet.soa) t
    | A : (int32 * Ipaddr.V4.t list) t
    | Ns : (int32 * Dns_name.DomSet.t) t
    | Cname : (int32 * Dns_name.t) t

  (* we need a total order on our keys *)
  let compare : type a b. a t -> b t -> (a, b) Order.t = fun t t' ->
    let open Order in
    match t, t' with
    | Cname, Cname -> Eq | Cname, _ -> Lt | _, Cname -> Gt
    | Ns, Ns -> Eq | Ns, _ -> Lt | _, Ns -> Gt
    | Soa, Soa -> Eq | Soa, _ -> Lt | _, Soa -> Gt
    | A, A -> Eq
end

type 'a key = 'a Key.t

(* our OCaml Map with an encapsulated constructor as key *)
type k = K : 'a key -> k
module M = Map.Make(struct
    type t = k
    (* the price I pay for not using int as three-state value *)
    let compare (K a) (K b) = match Key.compare a b with
      | Order.Lt -> -1
      | Order.Eq -> 0
      | Order.Gt -> 1
  end)

(* v contains a key and value pair, wrapped by a single constructor *)
type v = V : 'a key * 'a -> v

(* t is the main type of a Dns_map, used by clients *)
type t = v M.t

(* retrieve a typed value out of the store *)
let get : type a. a Key.t -> t -> a = fun k t ->
  match M.find (K k) t with
  | V (k', v) ->
    (* this comparison is superfluous, just for the types *)
    match Key.compare k k' with
    | Order.Eq -> v
    | _ -> assert false

This helps me to programmaticaly retrieve tightly typed values from the cache, important when code depends on concrete values (i.e. when there are domain names, look these up as well and add as additional records). Look into server/dns_server.ml

Dynamic updates, notifications, and authentication

Dynamic updates specify in-protocol record updates (supported for example by nsupdate from ISC bind-tools), notifications are used by primary servers to notify secondary servers about updates, which then initiate a zone transfer to retrieve up to date data. Shared hmac secrets are used to ensure that the transaction (update, zone transfer) was authorised. These are all protocol extensions, there is no need to use out-of-protocol solutions.

The server logic for update and zone transfer frames is slightly more complex, and includes a dependency upon an authenticator (implemented using the nocrypto library, and ptime).

Deployment and Let's Encrypt

To deploy servers without much persistent data, an authentication schema is hardcoded in the dns-server: shared secrets are also stored as DNS entries (DNSKEY), and _transfer.zone, _update.zone, and _key-management.zone names are introduced to encode the permissions. A _transfer key also needs to encode the IP address of the primary (to know where to request zone transfers) and secondary IP (to know where to send notifications).

Please have a look at ns.robur.io and the examples for more details. The shared secrets are provided as boot parameter of the unikernel.

I hacked maker's ocaml-letsencrypt library to use µDNS and sending update frames to the given IP address. I already used this to have letsencrypt issue various certificates for my domains.

There is no persistent storage of updates yet, but this can be realised by implementing a secondary (which is notified on update) that writes every new zone to persistent storage (e.g. disk or git). I also plan to have an automated Let's Encrypt certificate unikernel which listens for certificate signing requests and stores signed certificates in DNS. Luckily the year only started and there's plenty of time left.

I'm interested in feedback, either via twitter hannesm@mastodon.social or via eMail.