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…

DNS, Database, CouchDB, and NoSQL :: 03 May 2010 :: e-mail