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/Kcnu-update.+157+31885.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> </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", $zone, $host, $addr, $now, $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