Dynamic DNS

Simple Dynamic DNS

Most routers for home internet allow you to forward ports of your publicly visible IP address to internal IPs of your home network. In order to make that work, you first need to know your publicly visible IP. Most of the time, the router will fetch its IP from the ISP via DHCP, and the address is not constant. This is where countless dynamic IP services jump in, with the disadvantage that the names that they offer are mostly plain ugly (my-name.dynamic-ip-xyz.example.com) and most of the time they will make you pay for everything but the most basic services. If, however, you are the admin of the nameserver of your domain you can easily set up your own solution.

Prerequisites

A couple of difficulties have to be shouldered first. You need your own name - for example the notorious example.com - and you need full access to the nameserver of that domain. The latter will often be a challenge.

I also assume that the DNS for your domain has been set up correctly, and you also have to be familiar with the administration of name servers. If not, the HOWTO become a totally small time DNS admin{:target=_blank} is a classical reading.

Transferring Your IP

Our name server first has to know the current IP of our home internet connection. There is no way around pushing that information to your server. I personally did not want to open a new port for that but wanted to make do with what I already had: ssh access.

At home, I have a barebone PC permanently running, and that machine regularly copies its external IP via SSH into a file on the name server. This is done with a script /etc/cron.hourly/send-dyn-ip:

#! /bin/sh

set -e

my_ip=`dig +short myip.opendns.com @resolver1.opendns.com`
echo "$my_ip" | grep -q '^[1-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\.[1-9][0-9]*$'

ssh myuser@ns.example.com "echo home IN A $my_ip >/var/named/master/dyn/zuhause.inc"

Do not forget to make the script executable with chmod +x!

The most interesting line is probably line number 5. Although there are zillions of web-based services, that will tell you your own IP, I did not want to be dependent from web scraping, and besides, these services come and go. Fortunately, OpenDNS{:target=_blank} gives us that exact information in an absolutely reliable manner. You only need dig, normally either part of the package bind{:target=_blank} bundled separately with bind-tools depending on your vendor.

Line 6 does a quick check, whether the command was successful before the IP gets copied as a valid resource record to the nameserver. That requires writing privileges for the target directory on the name server. By the way, the zone files of the name server will often reside in /var/named/master but for example in /var/bind/pri.

Setup Of the Name Server

I decided for a subdomain for the dynamic addresses. The downside of this are the ugly names but I can allow longer caching times for the other DNS records.

For my name server software bind{:target=_blank}, the main config file is usually /etc/named.conf or /etc/bind/named.conf or the like. We now have to add the definition of the subdomain:

zone "dyn.example.com" IN {
    type master;
    file "master/dyn/example.com.zone";
    notify yes;
};

Without a slave name server, you can make do without notifications and omit line 4.

Dynamic DNS Update

Starting with bind{:target=_blank} version 8 the command nsupdate allows modification of zone file entries while the server is running. I opted for a simpler solution: a cron job on the name server regularly checks the files in a certain directory and generates a new file from it. The cron job is saved on the server under /etc/cron.hourly/dyn-dns. Do not forget the x bit!

The script expects as input either A or CNAME records. It does some consistency checks that will normally at least prevent the name server from not starting because of syntax checks. In commercial or military environments you may want more sincere checks.

The whole thing is not very portable, you need bash for it:

#! /bin/bash

# Change this to your needs.
domain=dyn.example.com
zonedir=/var/named/master
nameserver=ns1.example.com
contact=root.example.com
nameservers="ns1.example.com ns2.example.com"
reload="systemctl reload named"

# And this if you do not like the naming scheme.
dynfiles="$zonedir/dyn/*.inc"
zonefile="$zonedir/$domain.zone"

tmpfile=

dirty=

trap clean_up 1 2 3 15

clean_up() {
    test "x$tmpfile" != x && rm -f $tmpfile
}

read_dyn_info() {
    input="$1"

    while read -r line || [[ -n "$line" ]]; do
        fields=($line)
        hostname="${fields[0]}"
        class="${fields[1]}"
        type="${fields[2]}"
        addr="${fields[3]}" 

        if [[ !("$hostname" =~ ^([a-zA-Z0-9]|[a-zA-Z0-9][-a-zA-Z0-9]{0,61}[a-zA-Z0-9])$) ]]; then
            rm "$input"
            return
        fi

        if test "x$class" != 'xIN'; then
            rm "$input"
            return
        fi

        if test "x$type" = "xA"; then
            # Address record.  We need a valid IP.
            # First a syntax check.
            if [[ !("$addr" =~ ^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$) 
                  || ("$addr" == "0.0.0.0")
                  || ("$addr" == "255.255.255.255")
                  || ("$addr" == "10.0.0.0")
                  || ("$addr" == "10.255.255.255")
                  || ("$addr" =~ ^127)
                  || ("$addr" == "172.16.0.0")
                  || ("$addr" == "172.31.255.255")
                  || ("$addr" == "192.168.0.0")
                  || ("$addr" == "192.168.255.255")
                  || ("$addr" =~ ^169.254)
                ]]; then
                rm "$input"
                return
            fi

            ping -c 1 "$addr" >/dev/null
            if test $? != 0; then
                rm "$input"
                return
            fi

            current=`dig @$nameserver $hostname.$domain. +short`
            test "x$current" != "x$addr" && dirty=1
        elif test "x$type" = "xCNAME"; then
            # CNAME.  The addr part should be again a valid hostname.
            if [[ !("$addr" =~ ^([a-zA-Z0-9]|[a-zA-Z0-9][-a-zA-Z0-9]{0,61}[a-zA-Z0-9])$) ]]; then
                rm "$input"
                return
            fi
        else
             rm "$input"
             return
        fi

    done < "$input"

    # All checks passed.  Write the include statement.
    echo '$INCLUDE' "\"$input\"" $domain.
}

write_zone_file() {
    # Neither mktemp(1) is completely portable, nor is '+%s' support by all
    # flavors of date(1).  In doubt put GNU utils in your $PATH.
    tmpfile=`mktemp`
    exec 1>"$tmpfile"

    serial=`date '+%s'`

    cat <<EOF;
\$TTL 900 ; 30 minutes
$domain.        IN SOA  $nameserver. $contact. (
                $serial ; serial
                900     ; refresh (15 minutes)
                180     ; retry (3 minutes)
                2419200 ; expire (4 weeks)
                10800   ; minimum (3 hours)
                )
EOF

    # Now write the nameservers for our domain.
    for server in $nameservers; do
        echo "          NS      $server."
    done

    for file in $dynfiles; do
        read_dyn_info $file
    done

    set -e 

    # Do a final syntax check on the zone file.  However, this will not
    # enable us to find the real culprit, and we can only bail out here.
    named-checkzone -k fail $domain "$tmpfile"

    # Rename our tmpfile.
    mv "$tmpfile" "$zonefile"
    chmod 644 "$zonefile"

    test "x$dirty" = "x" || eval $reload
}

write_zone_file #"

Well, not exactly simple. But that is what cut & paste has been invented for!

A couple of variables have to be modified to your needs:

In line 4 you want to enter the (sub)domain for the dynamic addresses.

The standard directory for zone files in line 5 is also system-dependent. Line 6 contains the authoritative name server for our domain, line 7 holds the contact address for the SOA record.

In line 8 the NS records for the zone are following. Without slave servers, you will only have the authoritative name server from line 6 here.

Finally, in line 9 you have to specify the command that makes the name server reload the zone files. As you can see, my server has unfortunately been polluted by systemd{:target=_blank}. On other systems, you will see /etc/init.d/named reload or simply killall -HUP name here.

In the last line of the script the shell function write_zone_file() is invoked. The function write_zone_file() is defined in line 89. In order to avoid race conditions at least to a certain extent, we first write the new zone definition into a temporary file (Zeile 92) and redirect standard output into this very file, so that we can simply use echo and cat from now on.

The serial number is generated in a very simple manner. We use the epoch, aka the seconds since January 1st 1970 GMT (line 95).

The header is then written in line 97. The present configuration would produce something like the following:

$TTL 900 ; 30 minutes
dyn.example.com.    IN SOA  ns1.example.com. root.example.com. (
                1455636604 ; serial
                900     ; refresh (15 minutes)
                180     ; retry (3 minutes)
                2419200 ; expire (4 weeks)
                10800   ; minimum (3 hours)
                )
                NS      ns1.example.com.
                NS      ns2.example.com.

A rather long expiry time protects against long-lasting downtimes. Short caching times make will guarantee quick distribution of modifications in the zone. The NS records are written in lines 109-111.

Finally in lines 113-115, the snippets that had been transferred from our clients are getting processed in read_dyn_info() in line 25.

Those snippets are read in line by line. If needed, clients may write more than one single resource record into a single snipped. Every line then gets split up into four variables hostname, class (only IN is allowed here), type (either A or CNAME) and addr.

For all four fields a consistency check gets performed. In case of failure, the corresponding input file gets simply deleted and ignored. You may want to enhance that for better debugging facilities.

Starting with line 45, the IP addresses for A records (our main use case) are getting checked. After a regular pattern check, a couple of other illegal IPs are filtered out, before in line 65 a ping on the IP is attempted. In case of failure, the file is ignored again. Should you filter ICMP packets on your home router or accept downtimes, you should disable this check.

In line 70, the authoritative name server is queried for the current IP, and a dirty flag is set accordingly. This prevents gratuitous reloading of zone files and notifications to slave servers.

CNAME records are processed from line 72 and onwards. The only check is for a legal host name. In a production environment you may want to enhance that by checking that the name already has a valid A record.

Only if all checks have succeded, an $INCLUDE statement is getting written for the snippet.

After processing the input from the clients, the command named-checkzone performs a final check. In case of failure there is not much more to do than bailing out and hoping that the server's root mail will be read soon. Otherwise, starting in line 124, the temporary file gets renamed, and a reload of the zone files is triggered if needed. You can already skip the renaming if the dirty flag has not been set. That is mostly a matter of taste.

There is a little flaw: Old input files are not cleaned up. Although pinging every IP will ensure that the address is routable it could have been assigned to somebody else in the meantime. This could be remedied by checking a timestamp, for example the mtime of the input file.

Using nsupdate will certainly give you more possibilities. But the price for that is a higher administration effort. My simple solution abuses ssh's public key infrastructure and relies on no other software. You will often find that absolutely sufficient for private purposes.


blog comments powered by Disqus