Dynamic DNS from scratch

Imagine you self-host most of your services and have to move. Then you find out new ISP's services are not exactly stellar, say your external IP changes more often than you'd like it to (hello BT).

I have found myself in this exact situation recently. Wanting my primary server to be always accessible, this was a challenge I could not refuse even if I wanted to. Luckily I had all I needed to be successful, a DNS zone I fully control on a server that implements DNSSEC.

The way it works is that whenever the external address changes, a signed DNS update is sent to the DNS server to replace the old address.

Set up of the service is quite easy and the following is very verbose:

The Bind9 set-up

The following sets up a shared key both the client and server use, as this kind of set up is easier. You can check out other guides for information on how to adapt this using public keys hosted in the zone itself.
  1. Generate a TSIG key with name ddns-key:
    dnssec-keygen -a HMAC-SHA512 -b 512 -n USER ddns-key
    Two files will be created in the current directory — Kddns-key.+165+random.key and Kddns-key.+165+random.private, their contents will look like this, but you want to keep yours secret:
    Kddns-key.+165+random.key:
    ddns-key. IN KEY 0 3 165 bMyHJ/P3NvfPSAa6fuXCxR8duF+0vsHMzwRnSc2NrPZBu/Qq9tSzpeyY GH8IIjCu7u8j5KCOGfl6IROPdAxWMg==
    Kddns-key.+165+random.private:
    Private-key-format: v1.3
    Algorithm: 165 (HMAC_SHA512)
    Key: bMyHJ/P3NvfPSAa6fuXCxR8duF+0vsHMzwRnSc2NrPZBu/Qq9tSzpeyYGH8IIjCu7u8j5KCOGfl6IROPdAxWMg==
    Bits: AAA=
    Created: 20130530165503
    Publish: 20130530165503
    Activate: 20130530165503
    
  2. Create a file /var/cache/bind/ddns-key.conf with the following contents (do not forget to insert the key material from the file Kddns-key.+165+random.key) and make it readable for the root or bind user only:
    key ddns-key {
        algorithm HMAC-SHA512;
        secret "bMyHJ/P3NvfPSAa6fuXCxR8duF+0vsHMzwRnSc2NrPZBu/Qq9tSzpeyY GH8IIjCu7u8j5KCOGfl6IROPdAxWMg==";
    };
  3. Move the files Kddns-key.+165+random.key and Kddns-key.+165+random.private to ~/.config/nsupdate.key and ~/.config/nsupdate.private and make sure the permissions are set to 0400.
  4. In the Bind9 configuration where you define your domain (usually named.conf.local or a file included from there), include the ddns-key.conf file (include "ddns-key.conf";) and add the following section in the zone definition:
    allow-update {
        key ddns-key;
    };
    Your zone configuration might look like this now:
    include "ddns-key.conf";
    
    zone "example.com" {
        type master;
        [...]
    
        allow-update {
            key ddns-key;
        };
    };
    
    If you only want to allow updates to certain parts of the zone, check out the update-policy section of the zone config.
  5. You now have your Bind9 server configured, it is a good time to check that the configuration is valid, run the following as the root or bind user and watch out for errors:
    named-checkconf

nsupdate scripting

  1. Next, we want to update the domain records. The following command will replace the A records for www.example.com with IP address 127.0.0.1 and will contact the server ns.example.com to do that:
    
    
    When the update is successful, the zone information will be updated and the zone's serial incremented by one. If you omit the server line, the master server specified in your zone's SOA will be contacted by default.
  2. Making a script that updates the IP address is then easy:
    
    
  3. If you are lucky and know exactly when your external IP changes and the new value, you are set, just execute this script with the new IP as its first parameter. The BT HomeHub does not notify me nor can I find out the address easily, so there is a couple more steps to the setup.
  4. To find the external IP, I just ssh to another server and retrieve the value from $SSH_CONNECTION:
    conn_details=( $SSH_CONNECTION )
    new_ip="${conn_details[0]}"
    
  5. I do not get notified when the changes happen, therefore a cron job is set to check every 15 minutes, crontab -l:
    */15 * * * * /usr/bin/ssh -T -i $HOME/.ssh/nsupdate ssh.example.com ./bin/update-domain
  6. Updating the domain every 15 minutes would result in 96 DNS changes every day, pushing the zone serials through the roof, so a mechanism to detect whether a change is needed was required. Luckily we have our previous IP recorded in the zone itself. RFC 2136 specifies how to do conditional updates, yet we cannot use them exactly as given in this case, since we need to build a conditional request with the following meaning or a very similar one: Only if the A record for www.example.com does not contain the new value, perform the update.

    Since we do not care that much about race conditions here, we give up atomicity and check, then update. The final script, including logging, looks like this:

    /dev/null
    server $name_server
    prereq yxrrset $host A $ip
    send
    END
    }
    
    if is_up_to_date "$record_name" "$new_ip"; then
        logger -t update-domain -p user.info "Domain is still up to date, exiting"
        exit 0;
    fi
    
    logger -t update-domain -p user.info "Updating entry for '$record_name' to $new_ip"
    nsupdate -k "$keyfile" <
    

This setup seems usable enough so as not to feel like an atrocious hack, yet it still leaves something to wish for, like being notified when the IP changes and not having to poll for it. Sadly the BT HomeHub can only push the new IP address to a predefined set of DDNS providers and nothing more.