Run your own Dynamic DNS server

Posted by ads' corner on Tuesday, 2014-04-08
Posted in [Software]

Dyn, the company behind the widely known dynamic DNS service DynDNS, will shut down it’s free service effectively May 7th, 2014. Of course there are plenty of other free or freemium services out there, but history will repeat itself and these services will vanish over time, or change their business modell.

A while ago I spent an afternoon to implement my own dynamic DNS service.

What’s required:

  • A DNS server which understands updates, I’m using Bind 9 here
  • A domain (just domain in this blog post)
  • Optional: a webserver (for the “what is my IP” service)

For my case, I created an updates subdomain in my domain, and pointed it to my DNS server, from /etc/bind/named.conf.local:

zone "updates.mydomain" {
        type master;
        file "/etc/bind/updates.mydomain.zone";
};

In the zone for domain, I created a new origin entry and pointed it back to my nameserver:

; dynamic ip addresses
$ORIGIN updates.domain.
@       IN      NS      my.nameserver.

Finally, the zone file for the updates subdomain:

$TTL 86400
$ORIGIN updates.domain.
@               IN      SOA     my.nameserver.        postmaster.domain. (
                                2013090907   ; Serial
                                2h           ; Refresh
                                15M          ; Retry
                                604800       ; Expire
                                2h    )      ; Minimum

                        IN      NS      my.nameserver.

; dynamic ip addresses
$ORIGIN entry1.updates.domain.
@       IN      NS      my.nameserver.

$ORIGIN entry2.updates.domain.
@       IN      NS      my.nameserver.

I define two entries for host entry1 and entry2, and point them back to my nameserver. The reason for this: I can configure extra keys for each zone, to allow proper authentication and encryption. From /etc/bind/named.conf.local:

zone "entry1.updates.domain" {
        type master;
        file "/etc/bind/dynamic-updates/entry1.updates.domain.zone";
        allow-transfer {
                key "entry1-transfer";
        };
        allow-update {
                key "entry1-transfer";
        };
};

Please move the zone into a separate file, this file and the directory must be writable by bind (hence I moved these zones into a separate directory: /etc/bind/dynamic-updates). The initial zone for entry1:

$ORIGIN .
$TTL 86400      ; 1 day
entry1.updates.domain IN SOA my.nameserver. postmaster.domain. (
                                2013091050 ; serial
                                7200       ; refresh (2 hours)
                                900        ; retry (15 minutes)
                                604800     ; expire (1 week)
                                7200       ; minimum (2 hours)
                                )
                        NS      my.nameserver.
$TTL 60 ; 1 minute
                        A       1.2.3.4

Once you submit updates to this zone, bind will write these changes into a .jnl file in the same directory.

Ok, what about the entry1-transfer key? I define this key in /etc/bind/entry1-transfer.key:

key "entry1-transfer" {
algorithm       hmac-md5;
secret          "put secret key here";
};

And the line is included in /etc/bind/named.conf.local:

include "/etc/bind/entry1-transfer.key";

The key can be created using dnssec-keygen:

dnssec-keygen -a HMAC-MD5 -b 512 -n USER entry.updates.domain

This creates two files in the current directory, the filenames are a bit cryptic, but one file ends on .key (contains the public key), the other one with .private (contains the private key). For HMAC-MD5 the public and the private key are the same.

Ok, enough server configuration - what about the client?

The client must use the same key - that’s the reason why I generate different keys for each client, and let every client only authenticate it’s own subdomain. For clients without nsupdate see this posting. To make things more easy and exchangeable I wrote a small Perl script (dns-update.pl) which can use the very same key file as input, and update the zone on the DNS server:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
#!/usr/bin/perl

use strict;
use FileHandle;
use Net::DNS;
use Data::Dumper;
use Data::Validate::IP qw(is_ipv4 is_ipv6);
use IPC::Open3;
use Symbol 'gensym';

# first parameter is the key file
if (!defined($ARGV[0])) {
    help();
    exit(0);
}
my $key = extract_key($ARGV[0]);
if (!$key or $key !~ /.+:.+/) {
    print STDERR "Cannot extract key from keyfile ...\n";
    exit(1);
}
$key =~ s/:/ /;

# second parameter is the hostname which shall be updated
if (!defined($ARGV[1])) {
    help();
    exit(0);
}
my $hostname = $ARGV[1];
if ($hostname !~ /^[a-zA-Z0-9\.\-]+$/) {
    print STDERR "Invalid hostname ...\n";
    exit(1);
}
if ($hostname =~ /\.$/) {
    print STDERR "Hostname must not end with a dot ...\n";
    exit(1);
}

my $resolver = Net::DNS::Resolver->new([recurse => 1]);
#$resolver->debug(1);
my $packet = $resolver->send("$hostname", 'NS');
if (!$packet) {
    print STDERR "Error resolving hostname ...\n";
    exit(1);
}
if ($packet->header->ancount == 0) {
    print STDERR "Cannot find nameserver for hostname ...\n";
    exit(1);
}
my @nameserver = $packet->answer;
if (scalar(@nameserver) == 0) {
    print STDERR "Cannot find nameserver for hostname ...\n";
    exit(1);
}
my $nameserver = shift(@nameserver);
$nameserver = $nameserver->string;
if ($nameserver =~ /[\d]+[\s\t]+IN[\s\t]+NS[\s\t]+([a-zA-Z0-9\.\-]+)/) {
    $nameserver = $1;
    #print "nameserver: " . $nameserver . "\n";
} else {
    print STDERR "Cannot find nameserver for hostname ...\n";
    exit(1);
}

# third parameter is the IP address
# todo: handle IPv4 and IPv6 address
if (!defined($ARGV[2])) {
    help();
    exit(0);
}
my $ip = $ARGV[2];
if (!is_ipv4($ip) and !is_ipv6($ip)) {
    print STDERR "Not a valid IP address ...\n";
    exit(1);
}

# finally call the nsupdate program
my ($infh, $outfh, $pid);
my $err = gensym;
eval {
    $pid = open3($infh, $outfh, $err, 'nsupdate');
};
if ($@) {
    print STDERR "Error executing 'nsupdate' ...\n";
    exit(1);
}
#print "pid: $pid\n";
print $infh "server $nameserver\n";
print $infh "key $key\n";
print $infh "zone $hostname\n";
print $infh "update delete $hostname.\n";
print $infh "update add $hostname 60 IN A $ip\n";
print $infh "send\n";

my $exit_status = $? >> 8;
if ($exit_status != 0) {
    print STDERR "Update failed ...\n";
    exit(1);
}
print "Update OK\n";
exit(0);

sub extract_key {
    my $file = shift;

    my $fh = new FileHandle;
    if (!open($fh, "<", $file)) {
        print STDERR "error: $!\n";
        return '';
    }
    my @content = <$fh>;
    close($fh);

    my $name = '';
    my $key = '';

    my $content = join("\n", @content);
    if ($content =~ /key.+?\"(.+?)\".+?secret.+?\"(.+?)\".+server/s) {
        # looks like a file with a komplete bind definition, extract key
        $name = $1;
        $key = $2;
    }

    #print "$name:$key\n";
    return $name . ':' . $key;
}

sub help {
    print "\n";
    print "Update Dynamic DNS record\n";
    print "\n";
    print "\n";
    print "Usage:\n";
    print "\n";
    print " $0 <key file> <dynamic host name> <new ip address>\n";
    print "\n";
}

This script is called with three parameters:

  1. The key file (same file used in the DNS server)
  2. The hostname which shall be updated, as example: entry1.updates.domain
  3. The new IP address

Example:

1
dns-update.pl entry1-transfer.key entry1.updates.domain 10.0.0.1

To make things more easy for me, I also wrote a small script which is hosted on one of my servers, which returns the current IP address of the website visitor. My cron job for automatic updates of the zone looks as follow:

0-59/15 *    * * *   root    /root/dns-update.pl /etc/bind/entry1-transfer.key entry1.updates.domain `lynx -source -dump http://updates.domain/my_ip.php` > /dev/null 2> /dev/null

Voila, every 15 minutes the zone is updated (if the IP is changed).


Categories: [Software]