DNS backed by CouchDB redux

May 3rd, 2010 | Categories: DNS, Database, NoSQL | Tags:

I'm revisiting the proof of concept I whipped up the other day (inspired by this) regarding storing DNS zone data in a CouchDB database, because I was perverting the concept of a document database, using my old-school relational model: I had one document per DNS resource record.

What I should have done in the first place, was to think "zone" — store a full zone into a single document and use CouchDB views to extract the data the way I need it. So here goes:

A DNS zone is one JSON document in CouchDB. Here is the proverbial example.org example:

I'd like to point out the following things:

  • The SOA object contains an optional serial value. If this isn't defined the document's _rev is used (the integer before the MD5 of the _rev). This means we can have auto-incrementing zone serial numbers, whenever the CouchDB document changes.
  • The default TTL for all records in the zone is in default_ttl, but each record can override this with its own ttl value.
  • rr is an array of resource records, each of which have a name (the first label), a lowercase type (such as a, mx, etc.) and data. The latter is specific to the record type. A resource record's name may be an empty string, in which case the record belongs to the zone.

In essence, once I retrieve the document for a zone, I have all I need, and the DNS server could "produce" the rest. Instead of doing that, I've created a CouchDB view which emits the zone's individual record types and their data. The key into the view is an array consisting of the domain name and the requested type: (the GET command you see below is resty's)

GET /rrq -d key='["example.org","ns"]'  -G
{"total_rows":32,"offset":9,"rows":[
{"id":"example.org","key":["example.org","ns"],"value":{"type":"ns","ttl":1801,"data":"ns1.example.com"}},
{"id":"example.org","key":["example.org","ns"],"value":{"type":"ns","ttl":1801,"data":"ns2.example.com"}},
{"id":"example.org","key":["example.org","ns"],"value":{"type":"ns","ttl":1801,"data":"ns3.example.com"}}
]}

To support DNS queries of type ANY, the view's map function also emits those. (If there is a better way of doing this, I'd be pleased to here of it.)

So, this is my view:

function (doc)
{
    if (doc.type == 'zone') {

        var zonettl = doc.default_ttl ? doc.default_ttl : 86400;

        // SOA
        var soa = doc.soa;
        var mname   = (soa.mname)   ? soa.mname   : 'dns.' + doc.zone;
        var rname   = (soa.rname)   ? soa.rname   : 'hostmaster.' + doc.zone;
        var serial  = (soa.serial)  ? soa.serial  : doc['_rev'].replace(/-.*/, "");
        var refresh = (soa.refresh) ? soa.refresh : 86400;
        var retry   = (soa.retry)   ? soa.retry   : 7200;
        var expire  = (soa.expire)  ? soa.expire  : 3600000;
        var minimum = (soa.minimum) ? soa.minimum : 172800;

        emit([ doc.zone, 'soa' ], {
            type : 'soa',
            ttl  : zonettl,
            data : {
                mname   : mname,
                rname   : rname,
                serial  : serial,
                refresh : refresh,
                retry   : retry,
                expire  : expire,
                minimum : minimum
            }});
        emit([ doc.zone, 'any' ], {
            type : 'soa',
            ttl  : zonettl,
            data : {
                mname   : mname,
                rname   : rname,
                serial  : serial,
                refresh : refresh,
                retry   : retry,
                expire  : expire,
                minimum : minimum
            }});

        // NS
        if (doc.ns && doc.ns.length > 0) {
            doc.ns.forEach( function(addr) { 

                emit([doc.zone, 'ns'], {
                    type : 'ns',
                    ttl  : zonettl,
                    data : addr
                    });
                emit([doc.zone, 'any'], {
                    type : 'ns',
                    ttl  : zonettl,
                    data : addr
                    });
            });
        }

        if (doc.rr && doc.rr.length > 0) {
            for (var i = 0; i < doc.rr.length; i++) {
                var rr = doc.rr[i];
                var ttl = (rr.ttl) ? rr.ttl : zonettl;
                var fqdn = (rr.name) ? rr.name + '.' : '';

                fqdn += doc.zone;
                emit([ fqdn, rr.type ], {
                        type: rr.type,
                        data: rr.data,
                        ttl: ttl,
                    });
                emit([ fqdn, 'any' ], {
                        type: rr.type,
                        data: rr.data,
                        ttl: ttl,
                    });
            }
        }

        // PTR
        if (doc.rr && doc.rr.length > 0) {
            // Cycle through array of Resource Records
            doc.rr.forEach( function(rr) {
                if (rr.type == 'a') {
                    var ttl = rr.ttl ? rr.ttl : doc.default_ttl;

                    ttl = (ttl) ? ttl : 9845;

                    // Cycle through array of IP addresses in A RR
                    rr.data.forEach( function(addr) {
                        var ip = addr.split('.');
                        var rev = ip[3]+'.'+ip[2]+'.'+ip[1]+'.'+ip[0];
                        var revname = rev + '.in-addr.arpa';
                        emit( [ revname, 'ptr' ], {
                            type: 'ptr',
                            data: rr.name + '.' + doc.zone,
                            ttl: ttl
                        });
                    });
                }
            });
        }
    }
}

Using this, the resulting name server becomes

#!/usr/bin/perl

use strict;
use IO::Socket;
use Stanford::DNS;
use Stanford::DNSserver;
use AnyEvent::CouchDB;
use Data::Dump qw(pp);

my $uri = 'http://127.0.0.1:5984/dns';
my $db = couchdb($uri);

my %querytypes = (
    '1'    => 'a',
    '2'    => 'ns',
    '5'    => 'cname',
    '6'    => 'soa',
    '12'    => 'ptr',
    '15'    => 'mx',
    '16'    => 'txt',
    '33'    => 'srv',
    '252'    => 'axfr',
    '255'    => 'any',
    );

my $ns = new Stanford::DNSserver (
    listen_on => ["192.168.1.20"],
    port      =>        9953,
    defttl    =>        60,
    debug     =>         1,
    daemon    =>      "no",
    pidfile   => "/tmp/example.pid",
    logfunc   => sub { print shift; print "\n" },
    exitfunc  => sub {
            print "Bye!\n";
            });

# Add empty domain, means I get all queries. However, $domain
# will be null, and $host contains queried name.
$ns->add_dynamic("" => \&userreq);

# Start serving answers... (doesn't return)
$ns->answer_queries();

sub userreq {
    my ($domain, $host, $qtype, $qclass, $dm, $from) = @_;
    my $v;

    print "DOMAIN=[$domain], HOST=[$host], QT=[$qtype] FROM=[$from]\n";
    my $querytype = $querytypes{$qtype};
    my @keys = ($host, $querytype);

    eval {
        $v = $db->view('dns/rrq', { key => [ @keys ] })->recv
    };
    if ($@) {
        die "$_ : $@";
    }

    if ($#{$v->{rows}} == -1) {
        $dm->{rcode} = NXDOMAIN;
        return;
    }

    $dm->{rcode} = NOERROR;

    foreach my $r (@{$v->{rows}}) {

        my $rr = $r->{value};
        my $ttl = $rr->{ttl};

        print pp($rr),"\n";

        if (($qtype == T_SOA || $qtype == T_ANY) && $rr->{type} eq 'soa') {

            my $s = $rr->{data};
            $dm->{answer} .= dns_answer(QPTR, T_SOA, C_IN, $ttl,
                rr_SOA($s->{mname}, $s->{rname}, $s->{serial},
                    $s->{refresh}, $s->{retry}, $s->{expire},
                    $s->{minimum}));
            $dm->{ancount} += 1;

        }

        if (($qtype == T_NS || $qtype == T_ANY) && $rr->{type} eq 'ns') {
            $dm->{answer} .= dns_answer(QPTR, T_NS, C_IN, $ttl,
                rr_NS($rr->{data}));
            $dm->{ancount} += 1;
        }

        if (($qtype == T_A || $qtype == T_ANY) && $rr->{type} eq 'a') {
            for my $ip (@{$rr->{data}}) {
                # push each IP back into Stanford::'s reply
                my $entry = unpack('N', inet_aton($ip));
                $dm->{answer} .= dns_answer(QPTR, T_A, C_IN, $ttl, rr_A($entry));
                $dm->{ancount} += 1;
            }
        }

        if (($qtype == T_CNAME || $qtype == T_ANY) && $rr->{type} eq 'cname') {
            $dm->{answer} .= dns_answer(QPTR, T_CNAME, C_IN, $ttl,
                rr_CNAME($rr->{data}));
            $dm->{ancount} += 1;
        }

        if (($qtype == T_TXT || $qtype == T_ANY) && $rr->{type} eq 'txt') {
            for my $txt (@{$rr->{data}}) {
                $dm->{answer} .= dns_answer(QPTR, T_TXT, C_IN, $ttl,
                        rr_TXT($txt));
                $dm->{ancount} += 1;
            }
        }

        if ($qtype == T_PTR && $rr->{type} eq 'ptr') {
            $dm->{answer} .= dns_answer(QPTR, T_PTR, C_IN, $ttl,
                rr_PTR($rr->{data}));
            $dm->{ancount} += 1;
        }

    }

    # If no answers available, return NXDOMAIN

    if (! $dm->{ancount} ) {
        $dm->{rcode} = NXDOMAIN;
    }
}

So, let's look at some example queries. First, a CNAME lookup:

$ dig ldap.example.org any
;; ANSWER SECTION:
ldap.example.org.       86400   IN      CNAME   www.example.org.

Now a TXT query. Note the TTL from the record's own entry:

$ dig www.example.org txt
;; ANSWER SECTION:
www.example.org.        60      IN      TXT     "text record"
www.example.org.        60      IN      TXT     "this is a"

And finally, the piece de resistance: the SOA with it's automatic serial number.

$ dig example.org soa
;; ANSWER SECTION:
example.org.      1801    IN      SOA     jp.example.org.
           hostmaster.example.org. 64 10800 1800 604800 86400

PTR lookups work as well, even though I'm cheating a bit: all addresses I find in the A resource records are converted into PTR RRs.

$ dig -x 10.0.0.1
;; ANSWER SECTION:
1.0.0.10.in-addr.arpa.  1801    IN      PTR     www.example.org.

This remains a proof of concept, but I believe it fits CouchDB's model better.

Continue…

  1. May 3rd, 2010 at 20:32
    Reply | Quote | #1

    yeah, that's more like it! Doing map/reduce to look up records in a zone is very cool.

  2. May 4th, 2010 at 17:40
    Reply | Quote | #2

    It would be cool if CouchDB could be used as a PowerDNS backend: http://doc.powerdns.com/backends-detail.html

    Currently PowerDNS supports various relational databases, Bind zone files and some other backends, but a NoSQL backend sounds like it could work. And Futon would make a pretty nice first admin interface, although a dedicated CouchApp might make this totally rocking.

  3. May 4th, 2010 at 18:00
    Reply | Quote | #3

    Yes, I'm rather aware of PowerDNS' backends… ;-) The easiest is a pipe backend, but that is rather ugly. I'd prefer C/C++ backend, but interfacing to CouchDB in those languages is not currently in CouchDB, AFAIK.

  4. May 5th, 2010 at 21:57
    Reply | Quote | #4

    Nice!

    I'd used couchdb for a similar usecase – mail server config file generation and DNS-Server config file generation. Your document structure looks good. I'd used multiple documents in the past which seems wrong and needs improvement. I'll may switch to your style.

    I have hacked together a very raw couchdb show function whicht crates a bind zone file (for pdns bind backend) using your example document.
    The gist is here: http://gist.github.com/391332

    Be aware its very raw but it works.

  5. May 6th, 2010 at 08:24
    Reply | Quote | #5

    Configuration file generation; I like that idea.

    Since you use PowerDNS with Bind back-end, you have seen my follow-up posting regarding PowerDNS?