Nix-dns: A Nix DSL for DNS zone files

Many years ago I migrated the VPS that hosts my DNS zone from OpenVZ to KVM and switched to NixOS. When setting up nsd, I was surprised that I had to builtins.readFile my existing horrible zone instead of rewriting it in Nix. I recently had to write another zone file by hand, and that was too much, therefore:

nix-dns is a Nix library that exports types for DNS-related stuff, such as zones and records, and also provides a couple of combinators to simplify your life. I see two primary use cases:

  1. Whenever you need a zone file, instead of writing it by hand, you define it in Nix, convert to a zone, write to a file, and then use normally.
  2. One can extend existing Nix-based solutions with DNS functionality:
    • NixOps, instead of using Route 53, could generate a DNS zone from the network description and deploy a server running nsd and serving this zone.
    • NixOS modules can (optionally) configure DNS records related to them, and all these get merged automatically by the module system.

Here is a short zone just to give you a taste of it (there are more examples in the readme):

with dns.combinators {
  SOA = {
    nameServer = "ns1";
    adminEmail = "admin@example.com";
    serial = 2019030800;
  };

  NS = [
    "ns1.example.com."
    "ns2.example.com."
  ];

  CAA = letsEncrypt "admin@example.com";

  MX = mx.google;

  TXT = [ (with spf; strict [google]) ];

  A = [ "203.0.113.1" ];
  AAAA = [ "4321:0:1:2:3:4:567:89ab" ];

  subdomains = rec {
    foobar = host "203.0.113.2" "4321:0:1:2:3:4:567:89bb";

    ns1 = foobar;
    ns2 = host "203.0.113.3" "4321:0:1:2:3:4:567:89cc";
  };
}

I am considering upstreaming this into Nixpkgs, so that existing modules can use it. I see two possible approaches: 1) add a new module, such as networking.dns that will keep all zone definitions and will configure enabled dns servers accordingly; 2) integrate everything directly into DNS server modules, so that, e.g., services.nsd.zones will be able to take not only strings, but these structured zones as well. I am not sure which way to do it, and, honestly, whether it is worth it at all and whether anyone will be using it, so let me know what you think.

16 Likes

I guess another interesting use case would be to configure remote dns services like ovh with such kind of configuration.

Not using nix-dns but if you look for a simple authoritive DNS server (knot) setup with DNSSEC: https://github.com/Mic92/dotfiles/blob/459ce7fe40c4b229d6e6e66cef5f565cb0ca8064/nixos/eve/modules/knot/default.nix
I am using he.net as a DNS slave so I don’t need to rent a second server for redundancy.

related:
https://github.com/NixOS/nixpkgs/pull/50891

Writing Nix DSLs for DNS seems to be a popular topic, so to throw a thirdsecond (the pull request on tinydns isn’t a full DSL but only a set of helpers for generating strings for a few resource records) implementation into the mix:

Example:

While both implementations seem to be somewhat similar, there are a few differences:

  • Nix-dns uses an explicit subdomains option to distinguish between resource records and subdomains, whereas my implementation involves a bit more magic in that every non-uppercase name or defaultTTL to distinguish between them. While this involves more magic, it also leads to a more concise specification, for example com.example.www instead of com.subdomains.example.subdomains.www.
  • My implementation is more tailored towards the NixOS module system and basically builds a global DNS tree for all domains with only an isZone flag to specify whether the domain is a zone (the flag is true by default if a SOA record is defined). The reason for this is to be able to specify resource records across several modules.
  • While the nix-dns implementation is also using the NixOS module system it is less tied towards the module system and could more easily be used outside of it.
  • My implementation has specific types for relative/absolute domains, IPv4 and IPv6 addresses and thus can eg. check for typos in labels across domains. This also means that the implementation isn’t as lightweight as nix-dns.
  • Nix-dns also has a small set of combinators and has more predefined record types (wrong, both have the same set of predefined records plus my implementation has SSHFP).
  • Another major difference is how the records are structured internally: While nix-dns generates record-specific options, my implementation uses the structured record options to generate a generic resource record definition that is used for all resource records. The rationale behind the latter is that having a such a generic option it would be rather easy to write generators for specific zone file formats (currently it only generates bind zone files).

However, please take it with a grain of salt, since I’m obviously biased towards my own implementation.

While it would be great to have such an abstraction upstream, I’ve so far been reluctant to upstream it, since it does come with additional complexity and of course also some magic (see above).

The reason however why I made it the way it turned out to be is that this design makes it easy to define records on a per-module and thus per-service basis and also make it discoverable via the nodes attribute in my deployments, eg. to use something like nodes.ns1.config.dns.zones.com.example.A.value in other services.

So personally, I’d choose option 1 and just specify the the roots which a specific DNS server should serve, that way you also don’t need to deal with different zone files and have it discoverable.

2 Likes

To throw my own perspective into the mix… Regarding not being a DSL: I beg to differ. If you mean by DSL that it adheres to the NixOS module system, true, my implementation does not and it does not capture all possible record types. It defines a varargs capturing function and a set of translator functions that process the args into an output format. Writing translators for a new zone format would cost you about 2 lines per record type. But, true, this may be brushed up a little more to appeal to other zone file formats and be more complete.

I’ve chosen this format for the following reason, and your examples help me to make my point. The most important feature for me was to have a concise input language to define loads of DNS records. Looking at your examples, this is exactly what I not want to use to manage my zone. It is optimizing the input language for the wrong audience. Nobody except a tiny minority of experts have to maintain a DNS zone and they know what fields their records should contain. The entry format doesn’t need to be immediately recognizable (for the cost of clutter).

Second: While having a list (instead of an attribute set) may seem low tech, it serves an important purpose. I have to keep variants of the same record in the database when transitioning a DNS name to a new host. The record runs out at some point and the new record takes over. Tinydns supports defining a start-of-validity time for a record, so I can define the transition ahead of time. I don’t see that this is easily possible with the proposed input languages.

Third: Does either implementation support defining horizons, i.e., returning different records depending on the IP address of the requesting party? Tinydns supports that and my implementation also captures that. So, again, I have to be able to keep variants of DNS records. One could implement this with attribute sets as well, though, by capturing the horizon at the top of the tree.

Emphasis should probably have been on full, but sure, even if it’s just a set of string generators it’s fine. As mentioned even when comparing both implementations, having less abstraction certainly has a valid use-case and might even be more desirable to be included in nixpkgs.

I beg to differ here, since at least for my implementation the goal wasn’t to make a dumbed-down way (if that is what you’re implying?) to assign resource records but to focus on mergability and introspection within the module system. Generating zone files using string generation functions is certainly one way to do it but it’s hard to have inter-machine introspection without re-parsing the zone data in Nix.

Nevertheless however, if you want to have more concise functions like in your approach, even the other two approaches support it (nix-dns even has a few shortcuts, even though they’re pretty opinionated).

You’re correct here. Doing something like this would either mean not supporting it at all or add options that only a subset of DNS servers support. The same also applies for split horizons.

Again, both examples you mention are not the scope of at least my implementation. If you need support for scheduled transitions or split horizons you can still do that in an implementation-specific way or even use mkOrder to merge in lines into specific parts of your zone (albeit merging lists is always a bit ugly based on weights).

So essentially, I wouldn’t say that both approaches are mutually exclusive, but could even complement each other. However, one thing is pretty certain: Your approach probably would be the least controversial one since it’s specific to djbdns (note, that I’m not saying that as a negative here, because implementing this generally will probably introduce lots of bikeshedding) and also way more lightweight.