Dynamisches DNS

Simples dynamisches DNS

Die meisten Router für den Internetanschluss zuhause erlauben es, Ports der nach außen sichtbaren IP-Adresse auf interne IPs im Heimnetzwerk weiterzuleiten. Dafür muss natürlich die öffentliche, nach außen sichtbare IP erst einmal bekannt sein. In der Regel holt sich der Router aber seine IP über DHCP vom ISP und die Adresse ist nicht konstant. Dafür gibt es unzählige dynamische DNS-Dienste, die aber den Nachteil haben, dass die resultierenden Namen hässlich sind (mein-name.dynamic-ip-xyz.example.com) und man meistens für den Service auch bezahlen muss. Wer allerdings den Nameserver für eine Domain selbst verwaltet, kann sich leicht eine eigene Lösung zusammenbasteln.

Voraussetzungen

Erst einmal müssen aber einige Hürden genommen werden. Wir brauchen eine eigene Domain - wir nehmen hier als Beispiel example.com - und wir müssen vollen Zugriff auf den Nameserver für diese Domain haben. An Letzterem wird es leider sehr häufig scheitern.

Ich gehe im Folgenden weiterhin davon aus, dass das DNS für die eigene Domain bereits korrekt aufgesetzt ist, und setze auch ein grundlegendes Verständnis der Administration von Nameservern voraus. Wer sich nicht sicher genug fühlt, sei auf den Klassiker HOWTO become a totally small time DNS admin{:target=_blank} verweisen.

Mitteilen der eigenen IP

Unser Nameserver muss natürlich die aktuelle IP unseres Internetanschlusses zuhause kennen, und das geht naturgemäß nur mit Pushen von zuhause auf den Server. Ich wollte dafür allerdings keinen neuen Port aufmachen, sondern nehme das, was ich schon habe, nämlich einen SSH-Zugang.

Zuhause läuft permanent ein Barebone PC, der regelmäßig - per Cronjob - die externe IP per SSH in eine Datei auf den Nameserver kopiert und zwar mit diesem Skript /etc/cron.hourly/send-dyn-ip erzeugt:

#! /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 zuhause IN A $my_ip >/var/named/master/dyn/zuhause.inc"

Nicht vergessen, das Skript mit chmod +x ausführbar zu machen!

Interessant ist Zeile 5. Es gibt zwar etliche, meist webbasierte Services, die einem die eigene IP liefern, aber ich wollte mich nicht von Webscraping abhängig machen, und außerdem, sind gehen diese Services oft so schnell, wie sie gekommen sind. Zum Glück gibt es OpenDNS{:target=_blank}, dass einem die gleiche Information absolut verlässlich liefert. Dazu braucht man dig, was normalerweise mit dem Paket bind{:target=_blank} oder einem separaten Paket bind-tools installiert wird.

In Zeile 6 wird grob überprüft, ob das Kommando Erfolg hatte, und dann wird die IP in Form eines gültigen Resource Records auf den Nameserver kopiert. Dafür braucht unser SSH-User natürlich Schreibrechte für das Zielverzeichnis auf dem Nameserver. Die Zone-Files des Nameservers liegen häufig nicht in /var/named/master, sondern zum Beispiel in /var/bind/pri.

Setup des Nameservers

Ich habe mich entschieden, für die dynamischen Adressen eine Subdomain einzurichten. Nachteil ist, dass die Namen etwas hässlich sind, Vorteil dagegen, dass wir für den Rest der Domain längere Caching-Zeiten für die DNS-Records erlauben können.

Für meinen Nameserver bind{:target=_blank} liegt die Konfigurationsdatei in der Regel unter /etc/named.conf oder /etc/bind/named.conf oder Ähnlichem. Dort fügen wir jetzt die Definition für unsere Subdomain ein:

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

Wer keinen Slave-Nameserver verwendet, kann auf Notifications verzichten und Zeile 4 weglassen.

Dynamisches DNS-Update

Seit bind{:target=_blank} Version 8 gibt es das Kommando nsupdate, mit dem Einträge in Zone-Files im laufenden Betrieb modifiziert werden können. Ich habe mich für eine simplere Lösung entschieden. Ein Cronjob auf dem Nameserver geht regelmäßig durch die Dateien in einem Verzeichnis durch, und generiert daraus gegebenenfalls ein neues Zone-File. Den Cronjob habe ich auf dem Server als /etc/cron.hourly/dyn-dns gespeichert. X-Bit nicht vergessen!

Das Skript erlaubt als Input von den Clients A- oder CNAME-Records, und macht Konsistenzchecks, die unter normalen Umständen verhindern, dass der Nameserver aufgrund von Syntaxfehlern nicht mehr starten kann. Für eine kommerzielle oder militärische Umgebung wird man sicher bessere Checks durchführen wollen.

Geschrieben ist das Ganze nicht wirklich portabel. Man braucht die Bash:

#! /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  #"

Okay, simpel ist etwas anderes. Aber wofür gibt es Cut & Paste?

Ein paar Variablen müssen an die eigenen Bedürfnisse angepasst werden.

In Zeile 4 müssen wir die Domain für die dynamischen Adressen einsetzen.

Das normale Verzeichnis für die Zone-Files in Zeile 5, ist ebenfalls systemabhängig. In Zeile 6 wird der autoritative Nameserver für unsere Domain eingetragen, in Zeile 7 die Kontaktadresse für den SOA-Record.

In Zeile 8 kommen die NS-Records für unsere Zone. Wer keine Slaves einsetzt, wird hier nur den autoritativen Nameserver aus Zeile 6 eintragen.

In Zeile 9 brauchen wir noch das Kommando, mit dem der Nameserver die Zone-Files neu einliest. Auf meinem Nameserver läuft unglücklicherweise systemd{:target=_blank}. Auf anderen Systemen wird man hier /etc/init.d/named reload oder einfach nur killall -HUP name finden.

In der letzten Zeile des Skripts wird die Shell-Funktion write_zone_file() aufgerufen, die in Zeile 89 definiert wird. Um Race-Conditions einigermaßen verlässlich zu vermeiden, schreiben wir die neue Zonendefinition erst einmal in eine temporäre Datei (Zeile 92) und leiten die Standardausgabe in diese Datei um, damit wir ab jetzt einfach echo und cat benutzen können.

Für die Seriennummer machen wir es uns ebenfalls ganz einfach, und benutzen die Zahl der Sekunden seit dem 1. Januar 1970 GMT (Zeile 95).

In Zeile 97 wird dann der Header geschrieben. Mit unserer Konfiguration kommt dabei folgendes heraus:

$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.

Ich habe eine relativ lange Expiry-Zeit, um gegen längere Downtimes geschützt zu sein, aber natürlich kurze Caching-Zeiten, um Änderungen an der Zone einigermaßen schnell zu verbreiten. In den Zeilen 109-111 werden dann noch die NS-Records geschrieben.

In der Schleife in den Zeilen 113-115 wird dann schließlich für Schnipsel, der von einem unserer Clients per ssh übertragen wurde, die Funktion read_dyn_info() in Zeile 25 aufgerufen.

Diese Dateien werden Zeile für Zeile eingelesen. Wer möchte, kann also durchaus auch etwas mehr als einen einzigen Resource Record von den Clients in die Schnipsel schreiben lassen. Jede Zeile wird den Zeilen 29-33 dann in vier Variablen hostname, class (nur IN erlaubt), type (entweder A oder CNAME) und addr aufgesplittet.

Für alle vier Felder findet jetzt ein Konsistenztest statt. Sollte ein Konsistenztest scheitern, wird die Eingabedatei einfach gelöscht und ignoriert. Das wird man eventuell etwas aufbohren wollen, um das Debuggen zu erleichtern.

Ab Zeile 45 werden die IP-Adressen für A-Records (also unser Hauptanwendungsfall) gecheckt. Nach einem normalen Patternmatch werden noch verschiedene andere illegale IPs ausgefiltert. Ab Zeile 65 wird noch ein Ping auf die IP versucht. Schlägt der fehl, wird die Datei ebenfalls ignoriert. Wer ICMP-Pakete auf dem Router zuhause filtert oder Downtimes akzeptiert, wird den Check natürlich auskommentieren.

In Zeile 70 wird der autoritative Nameserver nach der aktuellen IP befragt. Nur, wenn die sich geändert hat, wird ein Dirty-Flag gesetzt, um ein unnötiges Neuladen der Zonefiles und Senden der Notifications zu verhindern.

Ab Zeile 72 werden CNAME-Records überprüft. Hier findet lediglich ein Check auf einen regulären Hostnamen statt. Das sollte man im Produktivbetrieb noch aufbohren, und checken, dass der Name ebenfalls definiert wurde.

Wenn alle Checks erfolgreich absolviert wurden, wird ein $INCLUDE-Statement für den Schnipsel geschrieben.

Nachdem so der komplette Input der Clients verarbeitet wurde, wird noch ein finaler Check mit named-checkzone durchgeführt. Schlägt dieser fehl, können wir allerdings nicht viel mehr machen, als auszusteigen, und zu hoffen, dass wir unsere Root-Mails bald lesen wird.

Im Erfolgsfalle dagegen, wird ab Zeile 124 die temporäre Datei umbenannt, und der Nameserver gegebenenfalls zum Neuladen der Zone-Files veranlasst. Man kann auf das Umbenennen auch schon verzichten, wenn das Dirty-Flag nicht gesetzt war. Das ist Geschmackssache.

Eine kleine Schwäche liegt noch darin, dass alte Eingabedateien nicht unbedingt aufgeräumt werden. Durch das Pingen der IP wird zwar sichergestellt, dass die Adresse prinzipiell erreichbar ist, aber sie könnte natürlich mittlerweile anderweitig vergeben sein. Wen das stört, der muss noch irgendeinen Timestamp abfragen, beispielsweise die Modifikationszeit der Eingabedatei.

Mit nsupdate lässt sich das Ganze natürlich mächtiger gestalten, aber dafür ist der Administrationsaufwand auch höher. Für meine simple Lösung wird einfach die Public-Key-Infrastruktur von SSH missbraucht, und ansonsten keinerlei weitere Software verwendet. Für eine private Lösung ist das normalerweise ausreichend.


blog comments powered by Disqus