Using isc-bind-9

  • Create an update key

Create a key in /etc/namedb/ or the base config dir of your bind installation.

[cyberleo@mtumishi ~]$ dnssec-keygen -a HMAC-MD5 -b 512 -n HOST update
Kupdate.+157+16663
[cyberleo@mtumishi ~]$

This will create two files (?) -- The numbers in the names will vary, depending on.. I dunno why. Read up on dnssec-keygen for more details.

-rw-------  1 cyberleo  users  115 Dec 11 08:25 Kupdate.+157+16663.key
-rw-------  1 cyberleo  users  145 Dec 11 08:25 Kupdate.+157+16663.private

Make those two files readable by your webserver user.

chown root:www Kupdate.+157+16663.*
chmod 640 Kupdate.+157+16663.*

We will need to take the generated key (the base64-encoded hash present in either of the two files) and format it so that it will fit into Bind's config file.

[cyberleo@mtumishi ~]$ cat Kupdate.+157+16663.private
Private-key-format: v1.2
Algorithm: 157 (HMAC_MD5)
Key: KtxcPgvCCn5unobfWXejTT8JKcqcFBjM2hx/sToM8DERlcy6ZkQTdLJf2Hru3qSlcFIfs5cE7pSRNtsTO1TWdg==
[cyberleo@mtumishi ~]$ cat > update.key <<EOF
key "update" {
        algorithm hmac-md5;
        secret "KtxcPgvCCn5unobfWXejTT8JKcqcFBjM2hx/sToM8DERlcy6ZkQTdLJf2Hru3qSlcFIfs5cE7pSRNtsTO1TWdg==";
};
EOF
[cyberleo@mtumishi ~]$
  • Configure your zone

I picked a subdomain to control, which would not get propagated to my slaves. To ensure the zone shows up properly, I had to create a and ns records for the root of the subdomain in my main zone. The A record points the root of the zone (dyn.cyberleo.net) to the server that will house the update script. The NS record ensures that anyone who asks my slave about any entries under this subdomain is told to ask the master, instead of being given a 'doesn't exist' response.

$ORIGIN cyberleo.net.
dyn    3600    IN     A    69.72.129.14      ;Cl=2
dyn    3600    IN     NS   ns1.cyberleo.net. ;Cl=2

The subdomain itself is set up as a standard dynamic zone in bind.

include "/etc/namedb/update.key";

zone "dyn.cyberleo.net" {
        type master;
        file "dynamic/dyn.cyberleo.net";
        allow-update { key update; };
        allow-transfer {
                127.0.0.1;
        };
};
  • Server update script

Drop this on your webserver, in a vhost directory assigned to the zone you just configured.

Pay attention to the zone and sec variables. sec is a rudimentary shared secret that is used to validate updates. Also keep in mind the ordering of the variables down there, where it computes the md5 hash. This is important, because it prevents someone from using any information in the submission to change the zone, and prevents the submission from being accepted an hour later.

The security token (sec) can also be used by itself to change any zone, so be careful with it!

<?php

$logfile = sprintf("%s/update.log", dirname(__FILE__));

$ns = 'localhost';
$zone='dyn.cyberleo.net';
$ttl = 30;
$now = gmdate('YmdH');
$sec = "SecuritySecret";
$nsupdate_key = "/etc/namedb/Kupdate .+157+16663 .private";

define("SIGNATURE", "nsupdate/1.0.1 at {$zone} port 80");

function multilog($lines) {
        if (!is_resource($GLOBALS['logfile']))
                $GLOBALS['logfile'] = fopen($GLOBALS['logfile'], "a");
        if (is_string($lines))
                $lines = explode("\n", trim($lines));
        foreach($lines as $line)
                fputs($GLOBALS['logfile'], sprintf("%s: %s\n", strftime('%Y%m%d:%H%M%S'), $line));
}

function nsupdate($host, $zone, $addr, $ttl = NULL, $ns = NULL) {
        if (NULL === $ttl)
                $ttl = $GLOBALS['ttl'];
        if (NULL === $ns)
                $ns = $GLOBALS['ns'];

        if ($addr && !preg_match(
            '/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/',
            $addr))
                return "invalid rdata format: bad dotted quad";


        $script = <<<EOF
server {$ns}
zone {$zone}
update delete {$host}.{$zone}

EOF;
        if ($addr)
                $script.= <<<EOF
update add {$host}.{$zone} {$ttl} IN A {$addr}

EOF;
        $script.= <<<EOF
show
send

EOF;

        $script_file = tempnam("/tmp", "nsupdate.");
        file_put_contents($script_file, $script);
        $cmd = sprintf("nsupdate -k %s < %s 2>&1", $GLOBALS['nsupdate_key'], $script_file);
        exec($cmd, $res, $exit);
        multilog($script);
        multilog($res);
        unlink($script_file);
        if ($res === FALSE || $exit != 0) {
                return implode('\n', $res);
        }
        return true;
}

function unauthorized($html = true) {
        $SIGNATURE = SIGNATURE;
        header('HTTP/1.0 401 Unauthorized');
        if ($html) {
                echo <<<EOF
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
 <head>
  <title>401 Unauthorized</title>
 </head>
 <body>
  <h1>Unauthorized</h1>
  <p>I was not able to determine your authorization for the requested resource.</p>
  <hr>
  <address>{$SIGNATURE}</address>
 </body>
</html>

EOF;
                die();
        } else {
                header('Content-type: text/plain');
                echo "Unauthorized";
        }
        die();
}

function updated($host, $addr, $html = true) {
        $SIGNATURE = SIGNATURE;
        if ($addr)
                $response = "Host {$host} is now at address {$addr}";
        else    $response = "Host {$host} is now removed";
        if ($html) {
                echo <<<EOF
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
 <head>
  <title>Updated</title>
 </head>
 <body>
  <h1>Updated</h1>
  <p>{$response}</p>
  <hr>
  <address>{$SIGNATURE}</address>
 </body>
</html>

EOF;
        } else {
                header('Content-type: text/plain');
                echo $response;
        }
        die();
}

function failed($syndrome, $html = true) {
        $SIGNATURE = SIGNATURE;
        header("HTTP/1.0 500 Update Failure");
        if ($html) {
                echo <<<EOF
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
 <head>
  <title>500 Update Failure</title>
 </head>
 <body>
  <h1>Update Failure</h1>
  <p>Operation failed during update.</p>
  <pre>{$syndrome}</pre>
  <hr>
  <address>{$SIGNATURE}</address>
 </body>
</html>

EOF;
        } else {
                header('Content-type: text/plain');
                echo "Update failed: {$syndrome}";
        }
        die();
}
function form() {
        $SIGNATURE = SIGNATURE;
        echo <<<EOF
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
 <head>
  <title>Update Form</title>
 </head>
 <body>
  <h1>Update Form</h1>
  <form method="get">
   <table>
    <tr>
     <td><label for="host">Hostname:</label></td>
     <td><input type="text" name="host" id="host" /></td>
    </tr>
    <tr>
     <td><label for="addr">Address:</label></td>
     <td><input type="text" name="addr" id="addr" /></td>
    </tr>
    <tr>
     <td><label for="key">Password:</label></td>
     <td><input type="password" name="key" id="key" /></td>
    </tr>
    <tr>
     <td>&nbsp;</td>
     <td>
      <input type="hidden" name="verbose" id="verbose" value="true" />
      <input type="submit" value="Submit" /><input type="reset" value="Reset" />
     </td>
    </tr>
   </table>
  </form>
  <hr>
  <address>{$SIGNATURE}</address>
 </body>
</html>

EOF;
        die();
}

$html = isset($_REQUEST['plain']) ? false : true;

if (!isset($_GET['host']))
        form();

$host = isset($_GET['host'])? $_GET['host']: "";
$addr = isset($_GET['addr'])? $_GET['addr']: "";
$key = isset($_GET['key'])? $_GET['key']: "";

// Remove zone if it was included
if (substr($host, -strlen($zone), strlen($zone)) == $zone)
        $host = substr($host, 0, (strlen($host) - (strlen($zone) + 1)));

// Validate the key
$mykey = md5(sprintf("%s|%s|%s|%s|%s", $host, $addr, $now, $zone , $sec));
if ($key != $mykey && $key != $sec)
        unauthorized($html);

$res = nsupdate($host, $zone, $addr);
if ($res === true) {
        updated($host, $addr, $html);
} else {
        failed($res, $html);
}
  • Client submission script

And last, a client submission script, to send updates to the server whenever an IP change is detected.

  • Note that this does not assign the publicly routable IP to the hostname, but instead the supplied IP, because I'm using this inside a private network
#!/usr/bin/env bash

host="$(hostname -f)"
key=""
domain="dyn.cyberleo.net"
dyndns_cache="/tmp/dyn-${domain}"
service_url="http://%s/?plain&host=%s&addr=%s&key=%s"
now="$(date -u '+%Y%m%d%H')"
sec="SecuritySecret"

# Boilerplate
meh() { printf "\033[1;32m*\033[0m ${*}\n"; }
omg() { printf "\033[1;33m*\033[0m ${*}\n"; }
wtf() { printf "\033[1;31m*\033[0m ${*}\n"; exit 1; kill $$; }
pebkac() {
  meh "Updates IP with dyndns registry"
  meh "Flags:"
  meh " -q  - Shh! Be quiet!"
  exit 64
}

# Make sure cachefile is not insecure
cache_permissions_okay() {
  # If the cache permissions are not 0:0 0600, delete the cache and recreate.
  file="${1:-"${dyndns_cache}"}" # ", stupid syntax highlighter not handling nested-quotes-in-nested-braces

  # stat is different on different systems
  # this case should generate a command that will return a proper set of variables to check
  case "$(uname -s)" in
  Linux) cmd="stat -c 'user=%u mode=100%a type=\"%F\"' '${file}'" ;;
  FreeBSD) cmd="stat -f 'user=%u mode=%p type=\"regular file\"' '${file}'" ;;
  *) cmd="stat -c 'user=%u mode=100%a type=\"%F\"' '${file}'" ;;
  esac

  eval $(eval ${cmd} 2>/dev/null)

  [ "${user}" ] && [ "$(id -u)" -eq "${user}" -a "100600" == "${mode}" -a \( "regular file" == "${type}" -o "regular empty file" == "${type}" \) ] || return 1
  return 0
}
fix_cache_permissions() {
  cache_permissions_okay "${dyndns_cache}" && return 0
  omg "Insecure cache ownership/permissions! Removing..."
  rm -f "${dyndns_cache}" || wtf "Cannot remove insecure cache! Aborting!"
  dyndns_cache_tmp="$(mktemp "${dyndns_cache}.XXXXXXXX")"
  echo "foom" > "${dyndns_cache_tmp}"
  cache_permissions_okay "${dyndns_cache_tmp}" || wtf "Tempfile permissions wrong! Aborting!"
  mv "${dyndns_cache_tmp}" "${dyndns_cache}"
  return 0
}

# Find useful binaries
find_md5() {
  if which md5 >/dev/null 2>&1
  then
    echo "$(which md5)"
  else
    if which md5sum >/dev/null 2>&1
    then
      echo "$(which md5sum) | awk '{print \$1}'"
    else
      echo "md5/md5sum not available!" >&2
      echo "cat > /dev/null"
    fi
  fi
}
find_wget() {
  [ -n "${quiet}" ] && flags="-q"
  if which wget >/dev/null 2>&1
  then
    echo "wget -nv ${flags} -O-"
  else
    if which fetch >/dev/null 2>&1
    then
      echo "fetch ${flags} -o-"
    else
      if which lynx >/dev/null 2>&1
      then
        echo "lynx -dump"
      else
        echo "No HTTP wrapper found!" >&2
        echo "echo"
      fi
    fi
  fi
}
find_ipaddr() {
  # Try and find the default route
  ${bin_route} | egrep '^[0-9]+' | awk '{print "dest=" $1 " gateway=" $2 " mask=" $3 " iface=" $8 }' | while read line
  do
    eval ${line}
    if [ "${dest}" = "0.0.0.0" -a "${mask}" = "0.0.0.0" ]
    then # Hope this is my default route!
      ${bin_ifconfig} ${iface} | grep 'inet addr:' | head -n 1 | sed -e 's/^[ ]\+inet addr:\([0-9.]\+\).*$/\1/'
      break;
    fi
  done
}

# Main
while [ -n "${1}" ]
do
  case "${1}" in
  -q)  quiet="yes"  ;;
  *)   pebkac       ;;
  esac
  shift
done

bin_route="/bin/netstat -rn"
bin_ifconfig="/sbin/ifconfig"
bin_md5="$(find_md5)"
bin_wget="$(find_wget)"
bin_host="/usr/bin/host"

host="${host%%.${domain}}"
host="${host%%.cyberleo.net}"
ipaddr="$(find_ipaddr)"

fix_cache_permissions

dyndns_cache_modtime="$(stat -c '%Y' "${dyndns_cache}" 2>/dev/null)"
dyndns_cache_modtime="${dyndns_cache_modtime:-0}"
dyndns_cache_age="$(( $(date '+%s') - ${dyndns_cache_modtime} ))"
oldip="$(cat "${dyndns_cache}" 2>/dev/null)"

if [ "${dyndns_cache_age}" -le 86400 -a "${oldip}" = "${ipaddr}" ]
then # Cachefile is less than a day old, and the IP hasn't changed
  exit 0
fi

[ -z "${key}" ] && key="$(echo -n "${host}|${ipaddr}|${now}|${domain}|${sec}" | eval ${bin_md5})"

service_url="$(printf "${service_url}" "${domain}" "${host}" "${ipaddr}" "${key}")"

${bin_wget} "${service_url}"

# Check DNS to make sure we're where we need to be.
newip="$(${bin_host} ${host}.${domain} | sed -e 's/^.* \([0-9.]\+\)$/\1/g')"
if [ "${newip}" = "${ipaddr}" ]
then
  # Update cache, so that we don't run this whole thing again until something changes.
  echo "${ipaddr}" > "${dyndns_cache}"
fi