2008-03-02

A very simple DNS-O-Matic update client.

I recently decided to switch to using OpenDNS instead of the DNS services of China Telecom (because the latter's ability to keep services alive verges on the comical). While I was setting myself up there, I found their DNS-O-Matic service -- a single point of entry to update any and all dynamic DNS services you may have registered with that they support. (They support a lot.) Intrigued, I signed up with DynDNS to test things out. After wrestling with a few conceptual issues (I'm an idiot), I went hunting for Linux clients to make things work.

OpenDNS doesn't support Linux clients. They list a couple of clients that can run under Linux, but they don't support them with instructions.

Given that I was going to have to do support myself for their service, I figured I might as well use my own client for it. Following their links for their API, I wrote the following cute little client in Ruby.

Note that this is very much a quick hack (literally thrown together in a morning). It is, however, already directly useful for more than just my purposes and it will be expanded upon in the near(ish) future to include:

  • More user-friendly error reports.
  • Support for more than just DNS-O-Matic updates. (Most dynamic DNS places have their own update systems which could be an attractive enhancement to this package.)
  • A daemon/service mode of operation than will permit scheduled, regular updates instead of just command line-based ones.
1 #! /usr/bin/ruby
2
3 # dnsomatic.rb -- Simple client to update all DNS-O-Matic IP addresses.
4 # Copyright (C) 2007 Michael T. Richter <ttmrichter@gmail.com>
5 #
6 # This program is free software; you can redistribute it and/or modify
7 # it under the terms of the GNU General Public License as published by
8 # the Free Software Foundation; Version 2, June 1991 (and no other).
9 #
10 # This program is distributed in the hope that it will be useful,
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 # GNU General Public License for more details.
14 #
15 # You should have received a copy of the GNU General Public License
16 # along with this program (in the file COPYING.txt); if not, write to the Free
17 # Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307,
18 # USA
19
20 # v0.1
21 # This simple client will pull the current IP address live from DNS-O-Matic and
22 # then set all registered DNS-O-Matic services to that address. It accepts
23 # only two arguments: one for the DNS-O-Matic account name and one for the
24 # matching password.
25 #
26 # Future directions
27 # Currently the client is a dead-simple CLI client. It is constructed for
28 # extension, however, and will be extended in the future to include daemon
29 # (UNIX) or service (Win32) operation as well as, possibly, a GNOME or KDE
30 # desktop tray client.
31 #
32 # Another future direction will be to grow the client away from only supporting
33 # DNS-O-Matic (and, indeed, some features currently in place anticipate this
34 # move, although this is hardly even close to being completed).
35 #
36 # Finally, at this point error reporting is pretty brutal. Better error
37 # error handling is definitely required.
38
39 begin require 'rubygems' ; rescue LoadError ; end
40
41 CLIENT_VERSION = "0.1"
42
43 # Class to support dynamic DNS hosts that use simple URI-based updating.
44 class DynamicDNSClient
45 require 'open-uri'
46 require 'net/http'
47 require 'net/https'
48
49 def initialize(username, password, &init_block)
50 self.DNSOMaticDefaults
51 @username = username
52 @password = password
53 instance_eval &init_block if init_block
54 @set_address %= [@host_name, self.get_ip]
55 self.freeze
56 end

Note here that I use my previously-blogged initialize function style. I am a firm believer in making object initialization something that happens in one pass instead of ... well, I'll comment on the other style below.
57
58 # Set up defaults for DNS-O-Matic accounts.
59 # Assumptions:
60 # 1. We will be updating all accounts instead of a single one.
61 # 2. We will be using TLS and Basic Authentication rather than
62 # HTTP with username/password in the URI.
63 # Note that assumption 1 can be overridden in the initialize block if the
64 # end-user so desires.
65 def DNSOMaticDefaults
66 @host_name = 'all.dnsomatic.com'
67 @query_address = 'http://myip.dnsomatic.com/1.2.3.4'
68 @set_address = 'https://updates.dnsomatic.com/nic/update?hostname=%s&myip=%s&wildcard=NOCHG&mx=NOCHG&backmx=NOCHG'
69 @user_agent = "Half-Baked - DNSOMaticClient - Ruby #{RUBY_VERSION} Client #{CLIENT_VERSION}"
70 end

This is one of the "sane defaults" functions (and in this case the only one for now) that sets up key values for a common use case. The block passed in to the initialize could be used to override values for another service provider in theory. (In practice I haven't investigated the other APIs closely enough yet to be sure that it would work on the first try.)
71
72 def set_ip
73 uri = URI.parse(@set_address)
74 request = Net::HTTP.new(uri.host, uri.port)
75 request.use_ssl = true
76 request.verify_mode = OpenSSL::SSL::VERIFY_NONE
77 response = request.get2(uri.path, 'User-Agent' => @user_agent,
78 'Authorization' => 'Basic ' + ["#{@username}:#{@password}"].pack('m').strip)
79 messages = []
80 response.body.each do |line|
81 case line
82 when /^good .*/
83 next
84 else
85 messages << line
86 next
87 end
88 end
89 messages
90 end

This would be one of those cases where I can't stand the usual approach to things. HTTP request objects are complicated entities with a large number of customizable parameters. The most common use cases for them, however, use only a small subset of these. Wouldn't it be a whole lot simpler to pass in a block and in that block use functions that set things up for the most common use cases? Wouldn't it then be great if, on top of that, you could tweak to your heart's content? And then wouldn't it just be so spectabulous that you have to invent a new word to describe it if after initializing the object you were guaranteed the object was in a usable state?!

The way things stand right now with that request variable, short of knowing the guts of its implementation I can't be sure that it's complete and usable. And, too, once it is complete and usable, there's nothing keeping it that way. I could accidentally screw it up between uses and be none the wiser until I get bug reports from my users (who could be me, adding to the frustration).
91
92 def get_ip
93 open(@query_address, "User-Agent" => @user_agent).read()
94 end
95
96 end

Getting the current IP address is trivial. There's also about a million different services out there that'll give it to you. I chose this one for now, but this will be a pluggable interface in future revisions.
97
98 # Mainline code
99 if __FILE__ == $PROGRAM_NAME then
100
101 require 'optparse'
102
103 class Arguments < Hash
104 def initialize(args)
105 super()
106 opts = OptionParser.new do |opts|
107 opts.banner = "Usage: #$0 [options]"
108 opts.separator ""
109 opts.on('-?', '--help', 'display this help and exit') do
110 puts opts
111 exit
112 end
113 opts.on('-u', '--username USERNAME', 'set the DNS-O-Matic username') do |username|
114 self[:username] = username
115 end
116 opts.on('-p', '--password PASSWORD', 'set the DNS-O-Matic password') do |password|
117 self[:password] = password
118 end
119 end
120
121 begin
122 opts.parse!(args)
123 rescue OptionParser::InvalidOption
124 puts opts
125 end
126 end
127 end
128 arguments = Arguments.new(ARGV)
129 client = DynamicDNSClient.new(arguments[:username], arguments[:password])
130 client.set_ip.each{|m| puts "ERROR #{m}"}

Note that here I haven't even bothered initializing the object with a block. The defaults are fine for me. Later, of course, as I expand this class to include other services I won't be so lackadaisical. There will be a block that does initialization -- albeit likely with just an eval-ed call to a boilerplate initialization helper. I mean really, how complex do you want to get with such a simple piece of code?
131
132 end
133


And there it is: an already-usable script for updating my DNS-O-Matic account (and, by extension, my OpenDNS account's services and my DynDNS redirector). For now I'll just drop it in my anacron directories and have the script run regularly. Later I'll make it its own daemon and maybe even a GNOME tray client just for laughs. As I do so, I'll update things here for fun and profit.

0 comments: