How to be your own Dynamic DNS provider

khiltd

New Member
This is ridiculously long, so it's split up across multiple posts.

You might not have a static IP at home or on the road, but your VPS does, and it can allow the rest of the net to find you wherever you are or however you're connected without having to give companies like DynDNS a single dime. Some basic BIND configuration can allow you to access files at home, VNC into grandma's laptop to fix her printer while she's at Starbucks, run a webserver against your ISP's wishes, and even setup a delegate nameserver to logically bridge physically disparate networks together.

The first thing you have to do is setup BIND. In your named.conf file (or in a separate file included from named.conf), define a new zone to handle your dynamic updates. This is necessary because once you enable dynamic updates, that zone's zone file will be mangled to the point that it will no longer be readable by humans or hosting control panels. A sample configuration might look like this:

Code:
zone "ddns.mydomain.com" 
{
	type master;
	file "/var/named/ddns.mydomain.com.db";
	update-policy { grant [B]*.ddns.mydomain.com.[/B] self ddns.mydomain.com. A; };
};
This is not the ONLY way to configure this, but it makes the most sense for the purposes of this howto. We're essentially telling BIND that we want to allow anyone who has a valid key to update their own A record to point to a new IP. What makes a valid key? That's the next step.

There are several ways to make what BIND refers to as a TSIG key, but it's basically just an MD5'ed and Base64 encoded string we've told it to look out for. I like to base my TSIG keys on the MAC address of the client machine's primary NIC, so I generate my keys from the shell thusly:

Code:
echo 00:0b:92:d0:27:92 | openssl md5 | openssl base64
That gives us
Code:
YmM1YWQ0ZTQyNjhjZTRhMjE2ZTZmZDMwNDY1ZjgyMTMK
in return, so now we just have to tell BIND about it.

Back in your named.conf file (or another file included from named.conf) define a key as follows:

Code:
key [B]peppep.ddns.mydomain.com.[/B]
{
	algorithm hmac-md5;
	secret "YmM1YWQ0ZTQyNjhjZTRhMjE2ZTZmZDMwNDY1ZjgyMTMK";
};
The important parts of this declaration are the key name and the "secret." The "secret" is obviously the key we just generated through openssl above, but they key name needs a little explanation.

Back when we specified our update-policy, we told BIND to grant update permissions to a certain zone so long as the name of the user's key matched the zone being updated. In simpler terms, the name you give your key MUST match the pattern specified in the update-policy, in this case *.ddns.mydomain.com. So now, peppep.ddns.mydomain.com can alter its own A record all it likes so long as he provides the right key, but he will not be able to touch nana.ddns.mydomain.com's records no matter what. This is as it should be.

The last thing you need to do before you're up and running is to alter the permissions on /var/named so that named has write access to it. I'm not certain if everybody needs to do this themselves, but I can verify that cPanel installations do not grant named write access by default. If it can't write to its journal files, it can't process dynamic updates; simple as that.

One all that is done, either restart BIND or issue an rndc reconfig command (assuming you've setup your RNDC key of course [which you should by the way]). Now BIND should be ready to accept dynamic updates to ddns.mydomain.com.

But how do we issue these updates? Traditionally, one uses the nsupdate command from the shell, but that's probably a bit over Peppep and Nana's head. It's also difficult to use in practice, because most routers which support DDNS services are only capable of making HTTP requests and provide no means of issuing arbitrary shell commands utilizing tools which are not a part of their firmware. We need a web service to bridge the gap.

I've implemented such a service in PHP, which should be trivial to port to any other language you might prefer. To use it, you simply pass the khi_ddns_process_data function an associative array (such as the $_GET or $_POST array collected from an HTTP request) which contains:

1. The zone to update

2. The TSIG key to use

3. The IP to set

It will optionally accept a mutator callback for the TSIG key, so that your users will never know what their actual keys are on the server. A minor bump in security, but a major bump in typability since Base64 encoded strings aren't exactly memorable.

Place this in a file named ddnscommon.php within your document root:

[see code in second post below]

and all the necessary parts are in place.

An example of an extremely simple pseudo-form which utilizes this code is as follows (note that I'm assuming SSL is enabled for the domain in question; a good idea unless you want other people sniffing out your keys and abusing your services):

[see code in third post below]

You would tell your router to trigger that by providing a simple URL containing all of the requisite information, e.g.:

Code:
https://www.mydomain.com/ddns.php?zone=nana.ddns.mydomain.com&key=00:0b:96:d0:23:92&ip=192.168.1.1
And from that point forward, nana.ddns.mydomain.com will resolve to 192.168.1.1. When your ISP gives you a new IP, your router will just update it again.

Alternatively, if your router doesn't support custom DDNS services, you can setup a cron job to request it periodically through curl, or build an actual HTML form where users can fill out the fields themselves.
 

khiltd

New Member
ddnscommon.php

Code:
<?php

	/*!
		@header 	ddnscommon.php
					Backend implementation of a dynamic DNS web service
					to support user triggered zone updates over HTTP. 
		@copyright 	No licenses, no liabilities, no warranties, no support. 
		@updated 	01-13-08
	*/
	
	/*!
		@function 	khi_ddns_nsupdate
		@param		$commands String of newline separated nsupdate commands.
		@abstract	Executes BIND's nsupdate tool with the supplied commands. 
		@discussion	Note that these pipes will be closed as soon as a 'send' 
					command is encountered. If you need to perform multiple
					record updates then you'll need to call this function 
					more than once. 
	*/
	function khi_ddns_nsupdate($commands)
	{						
		$pipes			= array();
		$descriptorspec	= array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('pipe', 'w'));
		
		$nsupdate	= proc_open('nsupdate', $descriptorspec, $pipes);
		
		if ( is_resource($nsupdate) )
		{
			fwrite($pipes[0], $commands);
			fclose($pipes[0]);
			
			$stderr = '';
			
			while ( !feof($pipes[2]) )
			{
				$stderr .= fread($pipes[2], 8192);
			}
			
			fclose($pipes[1]);
			fclose($pipes[2]);
		
			proc_close($nsupdate);
			
			return $stderr;
		}
	}
	
	
	/*!
		@function 	khi_ddns_keygen
		@param		$key String to be mutated into a TSIG key.
		@abstract	Basic example of a keygen callback function. 
		@discussion	Whatever the input, a keygen callback must return
					a Base64 encoded string. 
	*/
	function khi_ddns_keygen($key)
	{
		$tsig	= strtolower($key);
		
		//PHP's md5 and base64_encode functions appear to be doing something
		//differently than OpenSSL's, so if we want them to match, we have to
		//use OpenSSL's.
		return exec("echo $tsig | openssl md5 | openssl base64");
	}
	
	
	/*!
		@function 	khi_ddns_process_data
		@param		$data An associative array containing predefined keys
					for processing.
		@param		$keygen Optional callback function for generating TSIG
					keys from user input.
		@abstract	Processes HTTP request variables and performs a 
					dynamic DNS update based on user input and the relevant
					SOA record for the zone being updated. 
		@discussion	The $data array is expected to contain the following keys:
		
					zone:	The hostname for the A record you wish to update

					key:	The TSIG key 
					
					ip:		The IP address for the A record you wish to update (optional)
					
					If the 'ip' key is not supplied, then the IP the HTTP request
					originated from will be assumed to be correct. Proxy servers
					and NAT routers can make this an unsafe assumption, so if
					you want accuracy, supply a valid IP in this array element. 
					
					If you pass a valid function pointer in the $keygen parameter
					then whatever value is provided in the 'key' element will be 
					mutated by this function. If you do not supply a valid function 
					pointer then it is assumed that the 'key' element is already 
					Base64 encoded and ready to send to nsupdate. 
					
					This function assumes that the authoritative nameserver for
					the zone being updated is configured to allow dynamic updates
					and that it will require a valid TSIG key. It does not support
					"keyless entry" as it were, and if you want to run a wide-open
					server where anyone in the world can add or delete records at 
					will, then you're on your own. 
		
	*/
	function khi_ddns_process_data($data, $keygen = nil)
	{	
		$zone		= escapeshellcmd(strtolower(@$data['zone']));
		$key		= escapeshellcmd(@$data['key']);
		$ip			= escapeshellcmd(@$data['ip']);
		
		//If a keygen callback was provided, then pass it the 
		//key for further processing. Otherwise we assume
		//that a Base64 encoded key has been provided ready to go. 
		if ( $keygen != nil )
		{
			$key = $keygen($key);
		}
		
		//If no IP was provided, then get the IP the 
		//HTTP request came from and use that
		if ( empty($ip) )
		{
			$ip = $_SERVER['REMOTE_ADDR'];
		}
		
		//Figure out what nameserver we should be talking to for this zone
		//by checking the SOA record. If this server is not configured to 
		//allow dynamic updates on this zone, then nothing's going to happen.
		$nameserver = preg_replace('/\.$/', '', exec("dig -t SOA " . preg_replace('/^[^\.]+\./', '', $zone, 1) . " | awk '/^[^;]/ { print $5; } ' "));
		
		//Check to see if we actually need to bother BIND
		//with any of this. 
		$currentIP	= exec("dig @$nameserver $zone A +short");
		
		if ( $ip != $currentIP )
		{
			//Ideally we'd execute both of these nsupdate sequences in one fell swoop,
			//but PHP just won't keep the pipes open long enough for that to work.
			
			//Remove the old record if it exists
			if ( !empty($currentIP) )
			{
				$err = khi_ddns_nsupdate("server $nameserver\nkey $zone. $key\nprereq yxdomain $zone.\nupdate delete $zone. A\nsend\n");
				
				if ( !empty($err) )
				{						
					echo("$err");
					
					return false; 
				}
			}
			
			//Then add the new record as long as another one doesn't already exist
			$err = khi_ddns_nsupdate("server $nameserver\nkey $zone. $key\nprereq nxdomain $zone.\nupdate add $zone. 300 A $ip\nsend\n");
			
			if ( !empty($err) )
			{						
				echo("$err");
				
				return false; 
			}			
			
			echo "$zone has been set to $ip\n";
		}
		else
		{
			echo("$zone is already set to $ip\n");
			
			return false;
		}
		
		return true; 
	}
?>
 

khiltd

New Member
Example form

Code:
<?php
	
	require_once("ddnscommon.php");
	
	if ( $_SERVER['HTTPS'] === 'on' )
	{
		ProcessData($_REQUEST);
	}
		
	function verify_zone($data)
	{
		if ( preg_match('/.*\.ddns\.mydomain\.com/', $data) )
		{
			return true;
		}
		else
		{
			echo 'You must supply a valid zone to update.';
			
			return false; 
		}
	}
	
	function verify_key($data)
	{
		if ( preg_match('/(\w{2}:){5}\w{2}/', $data) )
		{
			return true;
		}
		else
		{
			echo 'You must supply a valid MAC address e.g. 00:0b:96:d0:23:92';
			
			return false; 
		}
	}
	
	function verify_ip($data)
	{
		if ( preg_match('/(\d{1,3}\.){3}\d{1,3}/', $data) || empty($data) )
		{
			return true;
		}
		else
		{
			echo 'You must either enter a valid IP address or leave that field blank.';
			
			return false;
		}
	}
	
	function ProcessData($data)
	{
		if ( verify_zone(@$data['zone']) && verify_key(@$data['key']) && verify_ip(@$data['ip']) )
		{
			return khi_ddns_process_data($data, 'khi_ddns_keygen');
		}
	}	
	
?>
 

ppc

Moderator
khiltd,

Great tutorial! It is apparent you spent a long time on it, thank you.

I have a WRT54G at home, loaded DD-WRT onto it so this is the current custom functionality it offers(attached).

I found this page which seems to outline how to do it but am a bit confused.

Any ideas?

Regards,
 

Attachments

khiltd

New Member
I have a WRT54G at home, loaded DD-WRT onto it so this is the current custom functionality it offers(attached).

I found this page which seems to outline how to do it but am a bit confused.

Any ideas?

Regards,
I think all you need to do is fill out the URL field. I've only got one WRT here and it's running Tomato, so I can't test it to be certain, but that should be it.
 

hansliss

New Member
Nice solution!
The reason you can't get the MD5 + Base64 functions in PHP to produce the same result as your openssl command line is probably that you use "echo" without the "-n" flag. You have in effect encoded the MAC address plus a line feed character, which produces a completely different result.
 

hansliss

New Member
Nice solution!
The reason you can't get the MD5 + Base64 functions in PHP to produce the same result as your openssl command line is probably that you use "echo" without the "-n" flag. You have in effect encoded the MAC address plus a line feed character, which produces a completely different result.
I now notice another related error. You run "openssl md5" without the "-binary" switch, which causes it to output the hash as text.
 

khiltd

New Member
The -n flag definitely gets things real close, but it's still coming up one character off.

Anyway, here's a Ruby module that does the same basic thing if anybody's interested.

Code:
module DDNS
  
  require 'open3'
  require 'digest/md5'
  require 'base64'
  
  class UpdateError < StandardError; end
  
  def self.keygen(input)
    Base64.encode64(Digest::MD5.hexdigest(input.downcase))
  end

  def self.determine_soa(zone)
    soa = %x{dig -t SOA #{zone.gsub(/\.$/, "")} +noquestion +nostats +nocmd +noqr +nocomments +noadditional +nottlid}
    #Split lines into an array, filtering out comments and blanks
    soa = soa.split("\n").delete_if { |el| el.start_with?(";") || el.empty? }
    #Split remaining line into whitespace delimited fields
    soa = soa[0].split(/\s/)
    #Find the field we actually want, stripping the trailing dot
    soa[soa.index("SOA") + 1].gsub(/\.$/, "")
  end

  def self.determine_current_ip(zone, soa=nil)
    soa = determine_soa(zone) unless !soa.nil?
    %x{dig @#{soa} #{zone} A +short }
  end

  def self.update(zone, ip, key)
    soa         = determine_soa(zone)
    curip       = determine_current_ip(zone, soa)
    
    if curip != ip
      delete_seq  = <<-";"
                    server #{soa}
                    key #{zone}. #{key}
                    prereq yxdomain #{zone}.
                    update delete #{zone}. A
                    send
                    ;

      create_seq  = <<-";"
                    server #{soa}
                    key #{zone}. #{key}
                    prereq nxdomain #{zone}.  
                    update add #{zone}. 300 A #{ip}
                    send
                    ;
  
      Open3.popen3("nsupdate") do |stdin, stdout, stderr|
        stdin << delete_seq << create_seq
        stdin.close_write
        err = stderr.read
        raise UpdateError, err unless err.empty?
      end
    end
  end
  
end
Usage example:

Code:
DDNS::update("peppep.nana.ddns.mydomain.com", "192.168.192.1", DDNS::keygen("blah"))
 

khiltd

New Member
And a basic Sinatra wrapper to turn it all into a web service

Code:
get "/ddns/keygen/:key" do
  content_type :json
  {:key => DDNS::keygen(params[:key])}.to_json
end

get "/ddns/:zone" do
  zone  = Rack::Utils::escape(params[:zone])
  ip    = DDNS::determine_current_ip(zone)
  if !ip.nil? && !ip.empty?
    content_type :json
    {:zone => zone, :ip => ip}.to_json
  else
    status 404
    ""
  end
end

post "/ddns/:zone/:key" do
  zone  = Rack::Utils::escape(params[:zone])
  ip    = Rack::Utils::escape(request.env['REMOTE_ADDR'])
  begin
    DDNS::update(zone, ip, DDNS::keygen(params[:key]))
    content_type :json
    {:status => 200, :zone => zone, :ip => ip}.to_json
  rescue DDNS::UpdateError => e
    status 403
    e.message
  end
end
 

librun

New Member
Thanks for developing this--it's very handy.

Here's a bash script I threw together that can be executed every X minutes by crontab to keep the DDNS zone up to date.

Code:
#!/bin/bash

# specify the zone you are updating
zone=peppep.ddns.mydomain.com

# specify which network interface's IP address to update
interface=eth0

# find $interface's current IP address
curip=$(ifconfig $interface | grep 'inet addr' | cut -d: -f2 | awk '{ print $1}');

# determine $interface's MAC address
mac=$(ifconfig $interface | grep 'HWaddr' | cut -d " " -f11)

# determine the $zone's current IP address
servip=$(dig $zone | awk '/^[^;]/ { print $5; }');

# print diagnostics
echo "Current IP: $curip"
echo "DDNS IP: $servip"

# if the current $interface IP is not equal to the current $zone IP, update the zone
if [ "$curip" != "$servip" ]; then
        url="https://mydomain.com/ddns.php?zone=$zone&key=$mac&ip=$curip"
        wget --quiet $url
        echo $url
fi
 

albattros

New Member
VIEW "internal" in named.conf

Thanks, great post.
I ran into a problem while getting this to work and I thought I would share it.

Problem:
I have views defined in named.conf and when the PHP page made the update, it defaulted to the "internal" view and the notifies where sent accordingly. This resulted in a local nslookup returning the newly updated IP address but a remote nslookup returning an old IP address.

Solution:
I removed (actually put a never-met condition in match-client and match-destination) the view "internal" and after that the update was applied to the "external" view and could be seen by both local and remote calls to nslookup. Not sure if I will keep that as a definite fix though.

Jim
 

Peek

New Member
Dynamic DNS secret ?

Hi guys. Though the tutorial reads easily, implementation has been a nightmare on my side. It seems like it is only TSIG that is hammering me and I can't seem to pin it down three days later...

Herewith a breakdown on my config:

/etc/bind.conf

zone "ddns.za.net" {
type master;
allow-query { any; };
file "dynamic/ddns.za.net";
update-policy { grant *.ddns.za.net. self ddns.za.net. A; };
};

# echo "Client MAC" | openssl md5 | openssl base64 <- TSIG key

key gateway.ddns.za.net. {
algorithm hmac-md5;
secret "MMMjY2QyNzzzY2UYYYYYYTIxZTU0ZzZzZDJhYjBjNJGK" ;
};


With the "ddnscommon.php" residing within the webserver document root "/var/www/html/ddns.za.net/" and called via browser through "ddns.php"

ddns.php

<?php
require_once("ddnscommon.php");
; if($_SERVER['HTTP'] === 'on' )
; {
ProcessData($_REQUEST);
; }
function verify_zone($data)
{
if ( preg_match('/.*\.ddns\.za\.net/', $data) )
{
return true;
}
else
{
echo 'You must supply a valid zone to update.';
return false;
}
}
function verify_key($data)
{
if ( preg_match('/(\w{2}:){5}\w{2}/', $data) )
{
return true;
}
else
{
echo 'You must supply a valid MAC address e.g. 00:0b:96:d0:23:92';
return false;
}
}
function verify_ip($data)
{
if ( preg_match('/(\d{1,3}\.){3}\d{1,3}/', $data) || empty($data) )
{
return true;
}
else
{
echo 'You must either enter a valid IP address or leave that field blank.';
return false;
}
}
function ProcessData($data)
{
if ( verify_zone(@$data['zone']) && verify_key(@$data['key']) && verify_ip(@$data['ip']) )
{
return khi_ddns_process_data($data, 'khi_ddns_keygen');
}
}
?>


However, when entering the following URL within a browser :

http:// wwww.ddns.za.net / ddns.php?zone=gateway.ddns.za.net&key=00:08:ef:2d:eg:1b


would result in
"; TSIG error with server: tsig indicates error update failed: REFUSED(BADKEY) "

Simply adding a "." to the zone spec ie


http:// wwww.ddns.za.net / ddns.php?zone=gateway.ddns.za.net.&key=00:08:ef:2d:eg:1b


would result in
"could not parse key name syntax error"

So I currently *** believe *** (not being a programmer at all) the problem lies within the actual nsupdates commands being passed from ddnscommon.php as per lines


$err = khi_ddns_nsupdate("server $nameserver\nkey $zone. $key\nprereq yxdomain $zone.\nupdate delete $zone. A\nsend\n");


and


$err = khi_ddns_nsupdate("server $nameserver\nkey $zone. $key\nprereq nxdomain $zone.\nupdate add $zone. 300 A $ip\nsend\n");


Trying manually via nsupdate:


> server 127.0.0.1 53
> key gateway.ddns.za.net MMMjY2QyNzzzY2UYYYYYYTIxZTU0ZzZzZDJhYjBjNJGK
> prereq yxdomain ddns.za.net
> update delete gateway.ddns.za.net A
> send


; TSIG error with server: tsig indicates error
update failed: NOTAUTH(BADSIG)

However adding the subtle "." at the end of the domain name


> server 127.0.0.1 53
> key gateway.ddns.za.net. MMMjY2QyNzzzY2UYYYYYYTIxZTU0ZzZzZDJhYjBjNJGK
> prereq yxdomain ddns.za.net
> update delete gateway.ddns.za.net A
> send


; TSIG error with server: tsig indicates error
update failed: NOTAUTH(BADSIG)

has the same result, which has me believe the problem to be TSIG.

The TSIG key was generated via


echo 00:08:ef:2d:eg:1b | openssl md5 | openssl base64


then copied to the "secret" line under the key "gateway.ddns.za.net." within "named.conf"

When calling from the browser (via HTTP) only the MAC is specified as the "KEY" -
http:// wwww.ddns.za.net/ddns.php? zone=gateway.ddns.za.net&key=00:08:ef:2d:eg:1b as "ddnscommon.php" will mutate the MAC to the KEY prior to issueing the nsupdate commands. Right ?

Why am I feeling so lost then ...
 
Top