How to be your own Dynamic DNS provider

Discussion in 'General Linux HOWTOs' started by khiltd, Jan 13, 2008.

  1. khiltd

    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.
     
  2. khiltd

    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; 
    	}
    ?>
    
     
  3. khiltd

    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');
    		}
    	}	
    	
    ?>
    
     
  4. ppc

    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,
     

    Attached Files:

  5. khiltd

    khiltd New Member

    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.
     
  6. hansliss

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

    hansliss New Member

    I now notice another related error. You run "openssl md5" without the "-binary" switch, which causes it to output the hash as text.
     
  8. khiltd

    khiltd New Member

    Thanks for the catch. I'll keep that in mind as I'm about to rewrite it for Sinatra.
     
  9. khiltd

    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"))
     
  10. khiltd

    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
    
     
  11. librun

    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
    
     
  12. albattros

    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
     
  13. Peek

    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 ...
     
  14. Ian Carruthers

    Ian Carruthers New Member

Share This Page