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 ownttl
value. rr
is an array of resource records, each of which have aname
(the first label), a lowercasetype
(such asa
,mx
, etc.) anddata
. The latter is specific to the record type. A resource record’sname
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…