Go DNS package

Go DNS is a package that implements a DNS interface in Go. This library takes a new, innovative and enterprise ready approach sends and receives queries to and from the DNS. It is licensed under the same license as the official Go code, as this is a fork of that code.

The aim is to be powerful, simple and fast.


  • All RR types;
  • Synchronous and asynchronous queries and replies;
  • DNSSEC: validation, signing, key generation, reading .private key files
  • (Fast) sending/receiving/printing packets, RRs;
  • Full control over what is being send;
  • Zone transfers, EDNS0, TSIG, NSID;
  • Server side programming (a full blown nameserver).
  • (Fast) reading zones/RRs from files/strings.


The git repository is hosted on github.

Examples using this library can be found in exdns repository over at github.

Tutorials and more info.

Printing MX records

A small peek in to how to print MX records with Go DNS.

We want to create a little program that prints out the MX records of domains, like so:

% mx miek.nl
miek.nl.        86400   IN      MX      10 elektron.atoom.net.


% mx microsoft.com 
microsoft.com.  3600    IN      MX      10 mail.messaging.microsoft.com.

First the normal header of a Go program, with a bunch of imports. We need the dns package:

package main

import (

Next we need to get the local nameserver to use:

config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")

Then we create a dns.Client to perform the queries for us. In Go:

c := new(dns.Client)

We skip some error handling and assume a zone name is given. So we prepare our question. For that to work, we need:

  1. a new packet (dns.Msg);
  2. setting some header bits;
  3. define a question section;
  4. fill out the question section: os.Args[1] contains the zone name.

Which translates into:

m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(os.Args[1]), dns.TypeMX)
m.RecursionDesired = true

Then we need to finally 'ask' the question. We do this by calling the Exchange() function. The unused return value is the rtt (round trip time).

r, _, err := c.Exchange(m, net.JoinHostPort(config.Servers[0], config.Port))

Check if we got something sane. The following code snippet prints the answer section of the received packet:

Bail out on an error:

if r == nil {
    log.Fatalf("*** error: %s\n", err.Error())

if r.Rcode != dns.RcodeSuccess {
        log.Fatalf(" *** invalid answer name %s after MX query for %s\n", os.Args[1], os.Args[1])

// Stuff must be in the answer section
for _, a := range r.Answer {
        fmt.Printf("%v\n", a)

And we are done.

Full Source

package main

import (

func main() {
    config, _ := dns.ClientConfigFromFile("/etc/resolv.conf")
    c := new(dns.Client)

    m := new(dns.Msg)
    m.SetQuestion(dns.Fqdn(os.Args[1]), dns.TypeMX)
    m.RecursionDesired = true

    r, _, err := c.Exchange(m, net.JoinHostPort(config.Servers[0], config.Port))
    if r == nil {
        log.Fatalf("*** error: %s\n", err.Error())

    if r.Rcode != dns.RcodeSuccess {
            log.Fatalf(" *** invalid answer name %s after MX query for %s\n", os.Args[1], os.Args[1])
    // Stuff must be in the answer section
    for _, a := range r.Answer {
            fmt.Printf("%v\n", a)
Tagged ,

SkyDNS running live

SkyDNS is able to do DNSSEC. It generates signatures and NSEC3 records on the fly. For authenticated denial of existence SkyDNS uses NSEC3 white lies, of course implementing (and testing!) this isn't completely trivial.

To aid in debugging I've setup a live version of SkyDNS on voordeur.atoom.net, under the name the zone http://dnssex.nl:

% dig +mul +noall +answer @voordeur.atoom.net soa skydns.dnssex.nl
skydns.dnssex.nl.    3600 IN SOA ns1.dns.skydns.dnssex.nl. hostmaster.skydns.local. (
                            1403942400 ; serial
                            28800      ; refresh (8 hours)
                            7200       ; retry (2 hours)
                            604800     ; expire (1 week)
                            60         ; minimum (1 minute)

To help getting DNSSEC support 100% working this zone has been delegated and has an DS record in the parent zone. With unbound-host you can see the validation status of this zone:

% unbound-host -C /etc/unbound/unbound.conf -vt SOA skydns.dnssex.nl 
skydns.dnssex.nl has SOA record ns1.dns.skydns.dnssex.nl. hostmaster.skydns.local. 
    1403942400 28800 7200 604800 60 (secure)

Where (secure) indicates DNSSEC is in order.


However getting NXDOMAIN and NODATA response it gets a bit more flaky, but some stuff is working:

% unbound-host -C /etc/unbound/unbound.conf -vt TXT dns.skydns.dnssex.nl
dns.skydns.dnssex.nl has no TXT record (secure)

And some is not:

% unbound-host -C /etc/unbound/unbound.conf -vt SRV server2.miek.skydns.dnssex.nl
Host server2.miek.skydns.dnssex.nl not found: 3(NXDOMAIN). (BOGUS (security failure))
validation failure <server2.miek.skydns.dnssex.nl. SRV IN>: 
    nameerror proof failed from

sadface I believe this is due to defaulting to skydns.dnssex.nl as the closest encloser and *.skydns.dnssex.nl as the source of synthesis, but I haven't had the time to dig deeper into this.


In the near future I hope to update the current test, to include NSEC3 white lies tests.

Tagged , , , , , ,

SkyDNS version 2

SkyDNS version 1 was announced some time ago, since then it has seen some developments, which resulted in SkyDNS version 2. This new version uses Etcd as its backend. This blog post will walk you through the installation and shows how to use it.


SkyDNS(2) is a service discovery tool that utilizes the DNS to find hosts in a distributed environment. But using DNS means "legacy" clients can be used. Want to know if you MariaDB cluster is still up? ping mariadb.skydns.local can be used for that. By default SkyDNS will use skydns.local. as the domain to anchor all names.


If not already installed, install Go for your system, either via the package manager or from source. After that you will need Etcd and SkyDNS:

  • go get github.com/coreos/etcd
  • go get github.com/skynetservices/skydns

After the installation, start Etcd: ./etcd. This will run a lonely, non clusterized Etcd on port 4001 on your local machine. SkyDNS has the ability to use configuration stored in Etcd, but for now we use the command line flags to start SkyDNS:

% ./skydns -addr= -machines= \
[skydns] Jun  8 08:30:19.761 INFO      | ready for queries

Let's see if it works, by using dig:

% dig @ -p 1054 +noall +answer +add SOA skydns.local
skydns.local. 3600 IN SOA ns1.dns.skydns.local. hostmaster.skydns.local. (
                1402210800 ; serial
                28800      ; refresh (8 hours)
                7200       ; retry (2 hours)
                604800     ; expire (1 week)
                60         ; minimum (1 minute)

Somebody is answering! Note in the other examples, I will use the same command line for, but remove all the flags and options. If a query aimed at SkyDNS does not fall under skydns.local. it will forward it to and returns the answer from that:

% dig a miek.nl
miek.nl.        19827 IN A

With this you can configure SkyDNS as your nameserver in /etc/resolv.conf.


The original SkyDNS used a fix naming scheme, environment.service.version.region.skydns.local., SkyDNS2 does away with this, but still it makes sense to defines some scheme to be used in your environment. In this blog post I will use a very simple scheme that only uses a region, like "east", "west", etc.

Let register a service in Etcd, we want the register the name 'web01.east.skydns.local', which listens on port 80 and has an IP4 address of All names used by SkyDNS in Etcd are stored under /skydns/ and we need to reverse the domain name. So to register the name we need to use the key: /v2/keys/skydns/local/skydns/east/web01, the payload of it must be JSON like so:

% curl -XPUT \
    -d value='{"Port":80,"Host": ""}'

And retrieving it via DNS:

% dig A web01.east.skydns.local
web01.east.skydns.local. 3600 IN A

Now we also add another webservice in the east region, web02.east.skydns.local, with IP4 address of

Now suppose you want to have a list of all webservers in the east region? Simple just query for east.skydns.local:

% dig A east.skydns.local
east.skydns.local.  3600 IN A
east.skydns.local.  3600 IN A

Of course IP6 is also supported. Using A and AAAA records allows for "legacy" support, however the port number must be know by the client connection, because that information is not in the returned records. To fix this you can also query for SRV records.

SRV Records

SRV records return much more information than A/AAAA records, it includes a port number, a priority a weight and a name (not an address record). As the service information for web01 only includes an address, SkyDNS will synthesise the SRV record and includes the actual IP address in the additional section:

% dig SRV web01.east.skydns.local
web01.east.skydns.local. 3600 IN SRV 10 100 80 web01.east.skydns.local.
web01.east.skydns.local. 3600 IN A

The numbers "10", "100" and "80" in the SRV records are respectively:

  • 10: priority.
  • 20: weight (when multiple SRV records have the same priority, look at the weight). In SkyDNS weight is a percentage.
  • 80: the port number for the service, if the port is not given in the service, it defaults to 0.

Of course this all works when you query for east.skydns.local as well.


The DNS standards supports wildcards, but SkyDNS extends this usage to allow wildcards within a domainname. To show how this we add another service, this time web01.west.skydns.local. Suppose we want to target all web01 servers? With plain DNS you will need to do two queries (and know about west and east!), with SkyDNS only one is needed:

% dig web01.*.skydns.local
web01.*.skydns.local.   3600    IN  SRV 10 50 80 web01.east.skydns.local.
web01.*.skydns.local.   3600    IN  SRV 10 50 80 web01.west.skydns.local.
web01.east.skydns.local. 3600   IN  A
web01.west.skydns.local. 3600   IN  A


Signed responses are also supported, although authenticated denial of existence based on NSEC3 is a work in progress. A quick primer on how to enable it, as there are a few steps.

  1. Generate a DNSSEC keypair for SkyDNS:

    % dnssec-keygen skydns.local
    Generating key pair........................................++++++ .....++++++ 
  2. Use the basename of the generated key pair as an argument to SkyDNS:

    % ./skydns -addr= -machines= \
        -nameservers= -dnssec Kskydns.local.+005+04821
    [skydns] Jun  8 12:28:37.981 INFO      | ready for queries, signing with Kskydns.local.+005+04821

When you know query with the DO bit on (+dnssec in dig) you will get signed responses:

% dig +dnssec web01.*.skydns.local
web01.*.skydns.local.   3600 IN SRV 10 50 80 web01.east.skydns.local.
web01.*.skydns.local.   3600 IN SRV 10 50 80 web01.west.skydns.local.
web01.*.skydns.local.   3600 IN RRSIG SRV 5 4 3600 (
                     20140615113057 20140608083057 4821 skydns.local.
                     4ixafFhbJSD+Rc4eK764Rberhik/zUtuXDe8kXM= )
web01.east.skydns.local. 3600 IN A
web01.west.skydns.local. 3600 IN A
web01.east.skydns.local. 3600 IN RRSIG A 5 4 3600 (
                     20140615113057 20140608083057 4821 skydns.local.
                     BfPkVwACwBAWaPJWrxy90v43NXdSunl55eUVoP4= )
web01.west.skydns.local. 3600 IN RRSIG A 5 4 3600 (
                     20140615113057 20140608083057 4821 skydns.local.
                     HkbwFHe4Y9qNTF4ygvU0BtObbJ3+e0hW8wr6YIU= )

The signatures are cached, so this does not turn into an easy DDoS at once.

Other responses you expect from a DNS server are supported, like SOA, NS, TXT, etc.

Tagged , , , ,

Learning Go

"Learning Go" is a book that gives an introduction into the Go language of Google. It is licensed under a copy-left license. The book currently consists out +/- 120 (A4 sized) pages and the following chapters:

  1. Introduction
    Show how to install Go and details the lineage of the language Go.
  2. Basics
    Types, variables and control structures.
  3. Functions
    How to make and use functions.
  4. Packages
    Functions and data is grouped together in packages. Here you will see how to make your own package. How to unit test your package is also described.
  5. Beyond the basics
    Learn how to create your own data types and define function on them (called methods in Go).
  6. Interfaces
    Go does not support Object Orientation in the traditional sense. In Go the central concept is interfaces.
  7. Concurrency
    With the go keyword function can be started in separate routines (called goroutines). Communication with those goroutines is done via channels.
  8. Communication
    How to create/read/write from and to files. And how to do networking.

Each chapter concludes with a number of exercises (and answers) to may help you to get some hands on experience. Currently it has more than 30 exercises.

There is also a Chinese translation by Mike Spook.

What readers say:

I am really glad that I found your Go book. It's been a couple of weeks since I started learning Go, but didn't make much progress till I found your book.

I also read with great interest the (successive versions of the) free E-book by Miek Gieben & Co. Which I find definitely very well crafted and very useful. Definitely an extremely laudable initiative.

Prebuild PDFs can be found at /downloads/Go. The source code of the book can be found on github.

It is written in LaTeX with the Memoir class (and a bunch a extra classes).

Questions, patches, text, bug reports and general discussions can be directed to miek@miek.nl (in English or Dutch). If you like this work you may choose support it by sending me money :)

Tagged , , ,

DNS Router

Say you have a zone that does not fit in the memory of one machine. Who hasn't these zones nowadays? How would you solve such a problem? With a DNS router of course!

Dns router is a small Go program I whipped together that acts as a DNS router. Clients register an <ip:port, regexp> combination and will then only receive queries that match that regular expression. The registration happens in Etcd. Of course "Dns router" (I need a better name), has some features, it will:

  • health checks the server every 5 seconds using TCP using a id.server. TXT CH query;
  • set an Ectd watch to get updates when a new server is added or removed.

So it's pretty dynamic, but the health checking could be better, as servers will never be re-added once removed.

Ldns actually has an utility to split a zonefile into chunks (with a new SOA, called zsplit, see http://git.nlnetlabs.nl/ldns/tree/examples/ldns-zsplit.1. In this case I just manually split a zone into 2 chunks, one with names starting with [ab] and another with [cd]. Of course the apex of the zone needs to go somewhere, so this has to be specified somewhere. See the examples later in this article.

For the purpose of this article I've used 2 docker images with BIND9 and the 2 (split) zones I have prepared.

The whole "how-do-I-prepare-an-Docker-image" will be left out, there is plenty of documentation on the Net on this. In all I've created two docker images, two running BIND9 and pieces of miek.nl. After fiddling with docker I found the following command line would start my VMs OK:

docker run -p 5300:53/udp -p 5300:53 -d miek/bind:bind9a

And run the other docker container on a different port:

docker run -p 5301:53/udp -p 5301:53 -d miek/bind:bind9c

So, all a-b names are reachable on port 5300 and all c-d names can be found via port 5301.

Assuming we have an etcd running on our host we register our two docker VMs with it and then start dnsrouter.

curl -L -XPUT -d value=",^[ab]\.miek"
curl -L -XPUT -d value=",^[cd]\.miek"

And two routes for the apex of the zone, dnsrouter will round robin between the two servers.

% DNS_ADDR= ./dnsrouter
2014/05/17 10:54:38 enabling health checking
2014/05/17 10:54:38 setting watch
2014/05/17 10:54:38 getting initial list
2014/05/17 10:54:38 unable to parse node /dnsrouter with value # small bug I need to fix
2014/05/17 10:54:38 adding route ^[ab]\.miek for
2014/05/17 10:54:38 adding route ^[cd]\.miek for
2014/05/17 10:54:38 ready for queries

So dnsrouter is running on port 5299, lets try some queries and check the logs of dnsrouter.

% dig @localhost +noall +ans -p 5299 TXT a.miek.nl
a.miek.nl.      43200   IN  TXT "aa"
% dig @localhost +noall +ans -p 5299 TXT c.miek.nl
c.miek.nl.      43200   IN  TXT "cc"

And the logs from dnsrouters:

2014/05/17 11:04:07 routing a.miek.nl. to
2014/05/17 11:04:12 routing c.miek.nl. to

A request for the apex of the zone fails because we don't have setup a route for it, so let's add two:

2014/05/17 11:06:51 adding route ^miek for
2014/05/17 11:06:58 adding route ^miek for

And dig again:

% dig @localhost +noall +ans -p 5299 SOA miek.nl
miek.nl. 43200   IN  SOA linode.atoom.net. miek.miek.nl. 1282630056 14400 3600 604800 86400

And we even see some round robin at work:

2014/05/17 11:07:23 routing miek.nl. to
2014/05/17 11:07:23 routing miek.nl. to

Let's kill one of the docker VMs. Dnsrouter should detect this and disable that server. It does not autmatically re-add it, for that you need to write again to etcd, which will then automatically be picked up by Dnsrouter.

% docker stop 29fde54f64f8
2014/05/17 11:22:00 healthcheck failed for
2014/05/17 11:22:00 removing

And starting it again:

% docker start 29fde54f64f8
% curl -L -XPUT -d value=",^[ab]\.miek"
2014/05/17 11:23:41 adding route ^[ab]\.miek for

In an upcoming article I will describe how I got this running on CoreOS.

Tagged , ,


During two Ubuntu 14.04 upgrades, both on a Mac (so needing an EFI boot), grub was borked after the install resulting in a grub rescue prompt when booting.

The actual error was error: symbol 'grub_term_highlight_color' not found.

Needless to say I couldn't get the system to boot from this prompt.

I had a Fedora boot USB stick laying around, but using that did not really fix the problem, in any case I could use it to copy off /home if I could not rescue the system.

HOWEVER, the following procedure worked great!

  • Download super grub disk;
    • 1.4 MB download!
  • Copy it to an USB stick: dd if=super_grub_disk_hybrid-1.98s1.iso of=/dev/sdb;
  • Boot from this USB stick;
  • In the boot menu, choose detect OSs;
  • Boot you newly minted, unbootable, Ubuntu 14.04;
  • If all goes well it should boot Ubuntu and you can log in;
  • Install and use boot repair;
  • If you use EFI, check this: https://help.ubuntu.com/community/UEFI.
Tagged , ,


GNOME 3 finally pushed me over the edge. After I brief stint with cinnamon, I decided the only thing left was to configure a tiling window manager and some tweaks to make it more usable. For no reason at all, I settled on i3, which seems really nice and simple to configure.

But how to use i3 comfortable? i3 is a tiling window manager, which makes it ubercool, but with it you loose things like automount, brightness keys, etc.; all the things you expect from a Linux desktop nowadays. This blog item deals with getting the goodies from i3, without giving up on all the other things you like.

See http://i3wm.org/docs/user-contributed/lzap-config.html, and the Arch Wiki for some good documentation too https://wiki.archlinux.org/index.php/I3 to get this going.

I took the following steps to get it going, this is still rough around the edges, because I'm lazy. See this screenshot: i3 comfy screenshot

  • gnome-session-daemon
    1. Start this daemon, it makes stuff much easier, like default keys for brightness, etc.
    2. Also makes your gtk-apps look nice, because they are themed.
  • Keys
    1. Volume - works with gnome-session-daemon.
    2. Keyboard - idem.
    3. Screen - idem.
  • Locking

    1. i3lock
    2. When running gnome-settings-daemon, disable the screensaver:

      gconftool-2 --type boolean -s /apps/gnome_settings_daemon/screensaver/start_screensaver false

    3. Create a keycombo in your config to lock your screen, i3lock -i <image.png> -t can be used for that.

    4. And better yet: sudo apt-get remove gnome-screensaver
  • Network manager

    1. Start nm-applet.
  • Bluetooth
    1. apt-get install blueman, it gives you blueman-applet.
  • Notification
    1. There is dunst, but this was also fixed by running gnome-settings-daemon, but needs some extra configuration for automounting notifications.
  • Mounting external disks with notifications.

    1. Install the debian package of udisks-glue (64 bit) from: https://packages.debian.org/sid/amd64/udisks-glue/download
    2. Add some config for notifications: start with: udisks-glue -c <file> and in that config file add or set:
      match disks {
          post_insertion_command = "udisks --mount %device_file --mount-options sync"
          post_mount_command = "notify-send %device_file 'mounted %device_file %mount_point'"
          post_unmount_command = "notify-send %device_file 'unmounted %device_file %mount_point'"
  • puvacontrol for sound stuff other than volume control

    1. Add a shortcut that starts pavucontrol.
  • Solarized colors: https://github.com/lkraav/dotfiles/tree/master/.i3, which you can then tweak, to make them even better.
  • Lock screen when suspending:
    1. exec --no-startup-id xautolock -detectsleep -locker 'i3lock -i ~/.backgrounds/eunight2_pv_big.png -t'
    2. Actually this patch http://searchcode.com/codesearch/raw/40065437 is what you want.
    3. Actually you this is a patch that applies cleanly: https://gist.github.com/miekg/9430422

Config file


Add the following lines, to implement some of the above.

exec --no-startup-id /usr/bin/gnome-settings-daemon
exec --no-startup-id nm-applet
exec --no-startup-id blueman-applet
exec --no-startup-id udisks-glue
exec --no-startup-id export $(gnome-keyring-daemon)
exec --no-startup-id feh --bg-scale ~/.backgrounds/Early_Morning_by_Robert_Katzki.jpg


I had some truetype font problems with demnu, this was fix by:

~/.i3/dmenu_run -b -fn 'Source Code Pro-9', basically use dmenu.xft instead of dmenu, fix dmenu_run for this:

if [ -d "$cachedir" ]; then
        cache=$HOME/.dmenu_cache # if no xdg dir, fall back to dotfile in ~
        if stest -dqr -n "$cache" $PATH; then
                stest -flx $PATH | sort -u | tee "$cache" | dmenu.xft "$@"
                dmenu.xft "$@" < "$cache"
) | ${SHELL:-"/bin/sh"} &
Tagged , ,

New Site Based on Pelican

I've revamped http://miek.nl it is now using Pelican, instead of nanoblogger. The old miek.nl site is available via http://archive.miek.nl.

I'm in the progress of migration articles over. I still need to think what I want to do with my project pages, because most stuff lives on on https://github.com/miekg.


Personal Go install

With golang 1.2, both godoc and go vet are removed from the main repository. Here is a super short list of commands (that I personally use) to install Go and both tools.

export GOBIN=~/bin
export GOPATH=~/g
mkdir -p ~/upstream
mkdir -p ~/g
cd ~/upstream
hg clone https://code.google.com/p/go/
hg update release
cd src
go get code.google.com/p/go.tools/cmd/godoc
go get code.google.com/p/go.tools/cmd/vet
go get code.google.com/p/go.tools/cmd/cover

Why 13 DNS root servers?

Updated. Thanks to Carsten Strotmann, who chimmed in. The maximum packet is 576 octects as specified in RFC 791. Removing the headers, leaves ~512 octets for the payload. See https://ripe67.ripe.net/presentations/112-2013-10-16-dns-protocol.pdf. Numbers slightly updated.

So why are there (only) 13 root-nameservers? See the updates below, this scheme came into use in the 90ies.

A priming query is a query that a nameserver performs when it starts up to get a list of the root nameserver IP addresses. This is done to validate (and possibly update) the built-in list of the addresses it has. In the early days of the DNS, the maximum packet size was set to 512 bytes, so this list needed to fit in 512 bytes.

The returned message looks something like this. Here I only list {a,b}.root-servers.net and delete some modern features as AAAA and OPT records.

;.              IN  NS

.           518400  IN  NS  a.root-servers.net.
.           518400  IN  NS  b.root-servers.net.

a.root-servers.net. 3600000 IN  A
b.root-servers.net. 3600000 IN  A

So how big is this message? The DNS packet header is 12 bytes. The size of the question section is:

  • root-label: 00, 1 byte;
  • class, 2 bytes and;
  • the qtype: 2 bytes.

In total 5 bytes. Now the size of one resource record in the answer section is:

  • root-label: 1 byte;
  • ttl: 4 bytes;
  • class: 2 bytes;
  • type: 2 bytes;
  • rdlength: 2 bytes
  • nameserver name: <1>a<12>root-servers<3>net<0>: 20 bytes.

Totals: 31 bytes. The other records can employ DNS compression, so subsequent records have the root-label, ttl, class, type, rdlength and then <1><letter><compression pointer>, which is 4 bytes, so this comes to: 15 bytes.

And the A record in the additional section comes to:

  • nameserver name: <1>a<12>root-servers<3>net<0>: 20 bytes;
  • ttl: 4 bytes;
  • class: 2 bytes;
  • type: 2 bytes;
  • rdlength: 2 bytes;
  • address: 4 bytes.

But here the name can be fully compressed, so instead of 20 bytes, we can use 2 bytes for the compression pointer. So this totals 16 bytes. Again the packet, but then only with the sizes:

12       ;; ->>HEADER<<-
31 + 15n ;; ANSWER SECTION:

Usually m = n, so the equation becomes:

48 + 31n = 512
       n = 464 / 31 = 14.96

WTF, 14,9?

Update 1

According to @agercasa (Jaap Akkerhuis), Bill Manning said they wanted to be conservative and leave some room for future expansion.

Update 2

The original list didn't use the root-servers.net suffix, but was smaller than 512 bytes anyhow. When the list was extended the root-servers.net suffix was created to save space (compression) and made it possible to have 14 root servers, of which 13 have been allocated. This predates anycast so a large(r) number of servers was needed, according to Bill Maning:

... this predates anycast, so it was thought prudent to wait to select all the remaining operators. as it turns out, the realignment did not go according to plan, VSGN has two and ICANN has one. the original idea was a second operator in asia and on in south america...

Also the step going from a mishmash of names to the ones with a common suffix and the 512-byte calculation was done in one step.

Update 3

@isomer dug up an old root hints file from BIND 4.9.2-940221.

;       This file holds the information on root name servers needed to
;       initialize cache of Internet domain name servers
;       (e.g. reference this file in the "cache  .  <file>"
;       configuration file of BIND domain name servers).
;       This file is made available by InterNIC registration services
;       under anonymous FTP as
;           file                /domain/named.root
;           on server           FTP.RS.INTERNIC.NET
;       -OR- under Gopher at RS.INTERNIC.NET
;           under menu          InterNIC Registration Services (NSI)
;              submenu          InterNIC Registration Archives
;           file                named.root
;       last update:    April 21, 1993
;       related version of root zone:   930421
.                        99999999 IN  NS    NS.INTERNIC.NET.
NS.INTERNIC.NET.         99999999     A
.                        99999999     NS    KAVA.NISC.SRI.COM.
KAVA.NISC.SRI.COM.       99999999     A
.                        99999999     NS    C.NYSER.NET.
C.NYSER.NET.             99999999     A
.                        99999999     NS    TERP.UMD.EDU.
TERP.UMD.EDU.            99999999     A
.                        99999999     NS    NS.NASA.GOV.
NS.NASA.GOV.             99999999     A
                         99999999     A
.                        99999999     NS    NS.NIC.DDN.MIL.
NS.NIC.DDN.MIL.          99999999     A
.                        99999999     NS    AOS.ARL.ARMY.MIL.
AOS.ARL.ARMY.MIL.        99999999     A
                         99999999     A
.                        99999999     NS    NIC.NORDU.NET.
NIC.NORDU.NET.           99999999     A
Tagged ,