dnsomatic 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/bin/dnsomatic +53 -0
- data/lib/dnsomatic.rb +162 -0
- data/lib/dnsomatic/config.rb +142 -0
- data/lib/dnsomatic/iplookup.rb +142 -0
- data/lib/dnsomatic/logger.rb +21 -0
- data/lib/dnsomatic/opts.rb +112 -0
- data/lib/dnsomatic/updater.rb +79 -0
- data/tests/all_tests.rb +9 -0
- data/tests/test_iplookup.rb +59 -0
- data/tests/test_ipstatus.rb +39 -0
- data/tests/test_opts.rb +78 -0
- data/tests/test_updater.rb +33 -0
- metadata +65 -0
data/bin/dnsomatic
ADDED
@@ -0,0 +1,53 @@
|
|
1
|
+
#!/usr/bin/ruby
|
2
|
+
|
3
|
+
begin
|
4
|
+
require 'dnsomatic'
|
5
|
+
rescue LoadError => e
|
6
|
+
# a small hack in the case where we aren't a gem
|
7
|
+
$: << File.join(File.dirname($0), '..', 'lib')
|
8
|
+
retry
|
9
|
+
end
|
10
|
+
|
11
|
+
begin
|
12
|
+
$opts = DNSOMatic::Opts.instance
|
13
|
+
$opts.parse(ARGV)
|
14
|
+
|
15
|
+
c = DNSOMatic::Config.new($opts.cf)
|
16
|
+
|
17
|
+
if $opts.showcf
|
18
|
+
$stdout.puts "This is your configuration after merging in the defaults."
|
19
|
+
$stdout.puts "It has been pruned to only display the stanza named: #{$opts.name}" if $opts.name
|
20
|
+
$stdout.puts c.merged_config($opts.name)
|
21
|
+
exit
|
22
|
+
end
|
23
|
+
|
24
|
+
if $opts.stanza and $opts.verbose
|
25
|
+
$stdout.puts "Will only update #{$opts.name}."
|
26
|
+
end
|
27
|
+
|
28
|
+
updaters = c.updaters($opts.name)
|
29
|
+
|
30
|
+
updaters.each_pair do |name, obj|
|
31
|
+
$stdout.puts "Working with host update definition: #{name}" if $opts.verbose
|
32
|
+
if $opts.print
|
33
|
+
puts obj
|
34
|
+
else
|
35
|
+
obj.send( $opts.force ? 'update!' : 'update')
|
36
|
+
end
|
37
|
+
end
|
38
|
+
rescue DNSOMatic::Error => e
|
39
|
+
$stderr.puts e
|
40
|
+
if $opts.debug
|
41
|
+
$stderr.puts "Backtrace:"
|
42
|
+
$stderr.puts e.backtrace
|
43
|
+
end
|
44
|
+
rescue => e
|
45
|
+
$stderr.puts "Rescued an unhandled exception of type: #{e.class}"
|
46
|
+
$stderr.puts "The exception contains the following message:"
|
47
|
+
$stderr.puts e.message
|
48
|
+
if $opts.debug
|
49
|
+
$stderr.puts "Backtrace:"
|
50
|
+
$stderr.puts e.backtrace
|
51
|
+
end
|
52
|
+
exit 1
|
53
|
+
end
|
data/lib/dnsomatic.rb
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
require 'open-uri'
|
3
|
+
|
4
|
+
# :title: dnsomatic - a DNS-o-Matic update client
|
5
|
+
#
|
6
|
+
# = dnsomatic - a DNS-o-Matic update client
|
7
|
+
#
|
8
|
+
# Author:: Ben Walton (mailto: bdwalton@gmail.com)
|
9
|
+
# Copyright:: Copyright (c) 2008 Ben Walton
|
10
|
+
# License:: GNU General Public License (GPL) version 3
|
11
|
+
#
|
12
|
+
# = Summary
|
13
|
+
#
|
14
|
+
# By default, dnsomatic tries to avoid polling or updating its address too
|
15
|
+
# often. It will not check a remote IP lookup url more often than every half
|
16
|
+
# hour. It will avoid updating DNS-o-Matic for up to 15 days if the IP
|
17
|
+
# detected hasn't changed.
|
18
|
+
#
|
19
|
+
# = Configuration
|
20
|
+
#
|
21
|
+
# Configuration is specified by a small YAML file with a default location
|
22
|
+
# of $HOME/.dnsomatic.cf
|
23
|
+
#
|
24
|
+
# A basic configuration can be as simple as:
|
25
|
+
# defaults:
|
26
|
+
# username: YOUR_DNSOMATIC_USERNAME
|
27
|
+
# password: YOUR_DNSOMATIC_PASSWORD
|
28
|
+
#
|
29
|
+
# The dnsomatic client may update all of your registered DNS-o-Matic names
|
30
|
+
# in one shot or individual names one at a time. The defaults: stanza in the
|
31
|
+
# configuration provides default values for any hostname update stanzas to
|
32
|
+
# follow, although no extra stanzas are required.
|
33
|
+
#
|
34
|
+
# To provide alternate stanzas, add items in your config like:
|
35
|
+
# myhost:
|
36
|
+
# hostname: myhost.dyndns.foo
|
37
|
+
# mx: mx.example.net
|
38
|
+
#
|
39
|
+
# When that stanza is used in conjunction with the above defaults stanza, you
|
40
|
+
# have a fully operation host update definition (myhost: would inherit the
|
41
|
+
# username and password from defaults:)
|
42
|
+
#
|
43
|
+
# In some cases it may be desirable to fetch the Web visible IP for a system
|
44
|
+
# from a different URL than the dnsomatic option (http://myip.dnsomatic.com).
|
45
|
+
# To allow for this, you may add a webipfetchurl: paramter to a confiruation
|
46
|
+
# stanza (or defaults:). Consider:
|
47
|
+
# otherhost:
|
48
|
+
# hostname: otherhost.dyndns.foo
|
49
|
+
# webipfetchurl: http://ipfetch.example.net/
|
50
|
+
#
|
51
|
+
# If all three of the above stanzas were in the same configuration file, you
|
52
|
+
# would send two update requests (conditional upon changed IP addresses and/or
|
53
|
+
# expiration times), with the second polling for its IP at the
|
54
|
+
# ipfetch.example.net url instead of the default myip.dnsomatic.com service.
|
55
|
+
#
|
56
|
+
# When there are update stanzas named specifically (in addition to defaults:),
|
57
|
+
# defaults is used only to provide default values for the other stanzas. For
|
58
|
+
# most users wanting to define more than one updater stanza, you would provide
|
59
|
+
# the username and password via the defaults: stanza and then a hostname value
|
60
|
+
# for the individual stanza definitions. Once the defaults are merged into the
|
61
|
+
# specific stanzas, they are ignored. To see how the merging works, you may
|
62
|
+
# use the 'display' option (see options with --help), which will dump to stdout
|
63
|
+
# a YAML serialized view of the merged configuration.
|
64
|
+
#
|
65
|
+
# This allows you to obtain an IP from multiple (or just alternate services).
|
66
|
+
# Although this might sound less than useful, it would be handy if you wanted
|
67
|
+
# different IP's associated with machines that could route to the internet via
|
68
|
+
# two different public IP's, or if you wanted to poll an internal service and
|
69
|
+
# update a public name with an internal IP.
|
70
|
+
#
|
71
|
+
# A complete list of options that may be specified in the defaults: or a named
|
72
|
+
# update definition are:
|
73
|
+
# * username - your dnsomatic username
|
74
|
+
# * password - your dnsomatic password
|
75
|
+
# * mx - a hostname that will handle mail delivery for this host. it must
|
76
|
+
# resolve to an IP or DNS-o-Matic will ignore it.
|
77
|
+
# * backmx - a lower priority mx record. sames rules as mx. you may also list
|
78
|
+
# these as NOCHG, which tells DNS-o-Matic to leave them as is.
|
79
|
+
# * hostname - the hostname to update. the defaults specify this as
|
80
|
+
# all.dnsomatic.com, which tells dnsomatic to update all listed
|
81
|
+
# records with the same values.
|
82
|
+
# * wildcard - indicates whether foo.hostname and bar.hostname and baz.hostname should also resolve to the same IP as hostname.
|
83
|
+
# - ON = enable
|
84
|
+
# - NOCHG = leave it as is
|
85
|
+
# - _other_ = disable
|
86
|
+
# * offline - sets the hostname of offline mode, which may do some redirection
|
87
|
+
# things depending on the service being updated.
|
88
|
+
# - YES = enable
|
89
|
+
# - NOCHG = leave it as is
|
90
|
+
# - _other_ = disable
|
91
|
+
#
|
92
|
+
# = Usage
|
93
|
+
#
|
94
|
+
# Usage: dnsomatic [options]
|
95
|
+
# -m, --minimum SEC The minimum time between updates (def: 30m)
|
96
|
+
# -M, --maximum SEC The maximum time between updates (def: 15d)
|
97
|
+
# -a, --alert Emit an alert if the IP is updated
|
98
|
+
# -n, --name NAME Only update host stanza NAME
|
99
|
+
# -d, --display-config Display the configuration and exit
|
100
|
+
# -c, --config FILE Use an alternate config file
|
101
|
+
# -f, --force Force an update, even if IP is unchanged
|
102
|
+
# -p, --print Output the update URLs. No action taken
|
103
|
+
# -v, --verbose Display runtime messages
|
104
|
+
# -V, --version Display version and exit
|
105
|
+
# -x, --debug Output additional info in error situations
|
106
|
+
# -h, --help Display this help text
|
107
|
+
|
108
|
+
module DNSOMatic
|
109
|
+
VERSION = '0.1.1'
|
110
|
+
USERAGENT = "Ruby_DNS-o-Matic/#{VERSION}"
|
111
|
+
|
112
|
+
# We provide our easily distinguishable exception class so that we can easily
|
113
|
+
# differntiate our errors from others.
|
114
|
+
class Error < Exception; end
|
115
|
+
|
116
|
+
def self.http_fetch (url)
|
117
|
+
uri = URI.parse(url)
|
118
|
+
|
119
|
+
begin
|
120
|
+
res = if uri.user and uri.password
|
121
|
+
open(url, 'User-Agent' => USERAGENT,
|
122
|
+
:http_basic_authentication => [uri.user, uri.password])
|
123
|
+
else
|
124
|
+
open(url, 'User-Agent' => USERAGENT)
|
125
|
+
end
|
126
|
+
res.read
|
127
|
+
rescue OpenURI::HTTPError, SocketError => e
|
128
|
+
msg = "Error communicating with #{uri.host}\n"
|
129
|
+
msg += "Message was: #{e.message}\n"
|
130
|
+
msg += "Full URL being requested: #{uri}"
|
131
|
+
raise(DNSOMatic::Error, msg)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
def self.yaml_read(file)
|
136
|
+
begin
|
137
|
+
yaml = YAML::load(File.open(file))
|
138
|
+
rescue Exception => e
|
139
|
+
msg = "An exception (#{e.class}) occurred while reading a yaml file: #{file}\n"
|
140
|
+
msg += "The error message is: #{e.message}"
|
141
|
+
raise(DNSOMatic::Error, msg)
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
def self.yaml_write(file, data)
|
146
|
+
begin
|
147
|
+
File.open(file, 'w') do |f|
|
148
|
+
f.puts data.to_yaml
|
149
|
+
end
|
150
|
+
rescue Exception => e
|
151
|
+
msg = "An exception (#{e.class}) occurred while writing a yaml file: #{file}\n"
|
152
|
+
msg += "The error message is: #{e.message}"
|
153
|
+
raise(DNSOMatic::Error, msg)
|
154
|
+
end
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
require 'dnsomatic/opts'
|
159
|
+
require 'dnsomatic/config'
|
160
|
+
require 'dnsomatic/updater'
|
161
|
+
require 'dnsomatic/iplookup'
|
162
|
+
require 'dnsomatic/logger'
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'net/https'
|
2
|
+
|
3
|
+
module DNSOMatic
|
4
|
+
# A class to handle 'parsing' the configuration files and setting defaults
|
5
|
+
# as required. Config files are actually YAML files, so the parsing is
|
6
|
+
# offloaded for the most part.
|
7
|
+
class Config
|
8
|
+
#in most cases, a user can simply set username and password in a defaults:
|
9
|
+
#stanza and fire the client.
|
10
|
+
@@defaults = { 'hostname' => 'all.dnsomatic.com',
|
11
|
+
'wildcard' => 'NOCHG',
|
12
|
+
'mx' => 'NOCHG',
|
13
|
+
'backmx' => 'NOCHG',
|
14
|
+
'offline' => 'NOCHG',
|
15
|
+
'webipfetchurl' => 'http://myip.dnsomatic.com/' }
|
16
|
+
|
17
|
+
# Create a new instance with the option of specifying an alternate config
|
18
|
+
# file to read. The default config file is either $HOME/.dnsomatic.cf (on
|
19
|
+
# unix or if Windows specifies a $HOME environment variable) or
|
20
|
+
# %APPDATA%/.dnsomatic.cf for most Windows environments.
|
21
|
+
def initialize(conffile = nil)
|
22
|
+
stdcf = File.join(ENV['HOME'] || ENV['APPDATA'], '.dnsomatic.cf')
|
23
|
+
if conffile
|
24
|
+
if File.exists?(conffile)
|
25
|
+
@cf = conffile
|
26
|
+
else
|
27
|
+
raise(DNSOMatic::ConfErr, "Invalid config file: #{conffile}")
|
28
|
+
end
|
29
|
+
elsif File.exists?(stdcf)
|
30
|
+
@cf = stdcf
|
31
|
+
else
|
32
|
+
#in this case, the values from the defaults: stanza in the default conf
|
33
|
+
#will provide all values required.
|
34
|
+
@cf = nil
|
35
|
+
end
|
36
|
+
|
37
|
+
@updaters = nil
|
38
|
+
@config = {}
|
39
|
+
|
40
|
+
# the user config must supply values for these, either in a specific
|
41
|
+
# host updater stanza or by overriding the global default in defaults:
|
42
|
+
@req_conf = %w(username password)
|
43
|
+
|
44
|
+
msg = @cf.nil? ? "Couldn't find a user config. Using defaults only." : \
|
45
|
+
"Using config file #{@cf}"
|
46
|
+
Logger::log(msg)
|
47
|
+
|
48
|
+
load()
|
49
|
+
end
|
50
|
+
|
51
|
+
# Provide a view of the configuration file after merging defaults. This
|
52
|
+
# is returned as a YAML string, suitable for redirecting right into a
|
53
|
+
# config file. Optionally, the return value can be 'pruned' to show only
|
54
|
+
# one host update stanza by passing in the desired name as a string.
|
55
|
+
def merged_config(prune_to = nil)
|
56
|
+
prune_to.nil? ? @config.to_yaml : one_key(@config, prune_to).to_yaml
|
57
|
+
end
|
58
|
+
|
59
|
+
# Return a list of Updater objects. Each host update stanza is turned into
|
60
|
+
# an individual object. This may be pruned to a single Updater by passing
|
61
|
+
# in the name of the stanza title.
|
62
|
+
def updaters(prune_to = nil)
|
63
|
+
#don't create updater objects until they're actually requested.
|
64
|
+
#(saves a little overhead if just displaying the config)
|
65
|
+
|
66
|
+
if @updaters.nil?
|
67
|
+
@updaters = {}
|
68
|
+
@config.each_key do |token|
|
69
|
+
@updaters[token] = Updater.new(@config[token])
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
prune_to.nil? ? @updaters : one_key(@updaters, prune_to)
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
def one_key(hsh, key)
|
79
|
+
if ! hsh.has_key?(key)
|
80
|
+
msg = "Invalid host stanza filter ('#{key}').\n"
|
81
|
+
msg += "You config doesn't define anything with that name."
|
82
|
+
raise(DNSOMatic::ConfErr, msg)
|
83
|
+
else
|
84
|
+
{ key => hsh[key] }
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def load
|
89
|
+
return @@defaults if @cf.nil?
|
90
|
+
|
91
|
+
conf = DNSOMatic::yaml_read(@cf)
|
92
|
+
raise DNSOMatic::ConfErr, "Invalid configuration format in #{@cf}" unless conf.kind_of?(Hash)
|
93
|
+
|
94
|
+
if conf.has_key?('defaults')
|
95
|
+
#allow the user to override our built-in defaults
|
96
|
+
@@defaults.merge!(conf['defaults'])
|
97
|
+
#if they've provided only the defaults stanza, we'll use it to perform
|
98
|
+
#the update, otherwise remove it as it has been folded into @@defaults
|
99
|
+
conf.delete('defaults') if conf.keys.size > 1
|
100
|
+
end
|
101
|
+
|
102
|
+
conf.each_key do |token|
|
103
|
+
stanza = @@defaults.merge(conf[token])
|
104
|
+
@req_conf.each do |required|
|
105
|
+
#still test for existence in case the defaults get munged.
|
106
|
+
if !stanza.has_key?(required) or stanza[required].nil?
|
107
|
+
msg = "Invalid configuration for Host Updater named '#{token}'\n"
|
108
|
+
msg += "Please define the field: #{required}."
|
109
|
+
raise DNSOMatic::ConfErr, msg
|
110
|
+
end
|
111
|
+
end
|
112
|
+
|
113
|
+
#just in case
|
114
|
+
stanza.each_pair do |k,v|
|
115
|
+
stanza[k] = fmt(v)
|
116
|
+
end
|
117
|
+
|
118
|
+
#save our merged version in case we're just dump our config to stdout
|
119
|
+
@config[token] = stanza
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
def fmt(val)
|
124
|
+
#because YAML interprets a raw YES or NO as a boolean true/false and we
|
125
|
+
#don't want to burden user with prefixing the value with !str, we'll
|
126
|
+
#attempt to deduce what they meant here...
|
127
|
+
if [TrueClass, FalseClass].include?(val.class)
|
128
|
+
val ? 'ON' : 'OFF'
|
129
|
+
elsif val.kind_of?(NilClass)
|
130
|
+
'NOCHG'
|
131
|
+
elsif val.kind_of?(String)
|
132
|
+
case val.downcase
|
133
|
+
when 'no': 'OFF'
|
134
|
+
when 'yes': 'ON'
|
135
|
+
else val.gsub(/\s+/, '')
|
136
|
+
end
|
137
|
+
else
|
138
|
+
val.to_s.gsub(/\s+/, '')
|
139
|
+
end
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,142 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'date'
|
3
|
+
|
4
|
+
module DNSOMatic
|
5
|
+
# This class handles actually fetching the remotely (not necessarily internet)
|
6
|
+
# visible IP for a provided URL. It manages both a minimum delay between
|
7
|
+
# checks (default 30m) and a maximum time before it reports a CHANGED status
|
8
|
+
# regardless of the result from the remote ip lookup url.
|
9
|
+
class IPStatus
|
10
|
+
CHANGED = true
|
11
|
+
UNCHANGED = false
|
12
|
+
|
13
|
+
attr_reader :ip
|
14
|
+
|
15
|
+
# A URL must be provided that returns the IP of the system that requests
|
16
|
+
# the URL. A commonly used example is http://www.whatismyip.org
|
17
|
+
def initialize(url)
|
18
|
+
@url = url
|
19
|
+
@status = CHANGED #all new lookups are changed.
|
20
|
+
@ip = getip()
|
21
|
+
@last_update = Time.now
|
22
|
+
Logger::log("Fetched new IP #{@ip} from #{@url}.")
|
23
|
+
end
|
24
|
+
|
25
|
+
# Tell a client whether or not we have a different IP than the last time
|
26
|
+
# we were asked. This may be faked in the case where we've reached the
|
27
|
+
# the maximum time we allow before forcing an update.
|
28
|
+
def changed?
|
29
|
+
@status
|
30
|
+
end
|
31
|
+
|
32
|
+
# Calling this method requests that we retrieve our IP from the remote URL
|
33
|
+
# and potentially alter our status as returned by changed?.
|
34
|
+
def update
|
35
|
+
if min_elapsed?
|
36
|
+
Logger::log("Returned cached IP #{@ip} from #{@url}.")
|
37
|
+
@status = UNCHANGED
|
38
|
+
else
|
39
|
+
ip = getip()
|
40
|
+
@last_update = @status ? Time.now : @last_update
|
41
|
+
|
42
|
+
if !@ip.eql?(ip)
|
43
|
+
Logger::log("Detected IP change (#{@ip} -> #{ip}) from #{@url}.")
|
44
|
+
else
|
45
|
+
Logger::log("No IP change detected from #{@url}.")
|
46
|
+
end
|
47
|
+
|
48
|
+
@status = (max_elapsed? or !@ip.eql?(ip)) ? CHANGED : UNCHANGED
|
49
|
+
@ip = ip
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
private
|
54
|
+
def min_elapsed?
|
55
|
+
if Time.now - @last_update <= $opts.minimum
|
56
|
+
Logger::log("Minimum lookup interval not expired.")
|
57
|
+
true
|
58
|
+
else
|
59
|
+
false
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def max_elapsed?
|
64
|
+
if Time.now - @last_update >= $opts.maximum
|
65
|
+
Logger::log("Maximum interval between updates has elapsed. Update will be forced.")
|
66
|
+
true
|
67
|
+
else
|
68
|
+
false
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def getip
|
73
|
+
ip = DNSOMatic::http_fetch(@url)
|
74
|
+
if !ip.match(/(\d{1,3}\.){3}\d{1,3}/)
|
75
|
+
msg = "Strange return value from IP Lookup service: #{@url}\n"
|
76
|
+
msg += "Body of HTTP response was:\n"
|
77
|
+
msg += ip
|
78
|
+
raise(DNSOMatic::Error, msg)
|
79
|
+
else
|
80
|
+
ip
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
# Any callers wishing to use IPStatus objects should request them via an
|
86
|
+
# instance of IPLookup. IPLookup is a singleton object that will cache
|
87
|
+
# IPStatus objects across sessions by persisting them to a YAML file.
|
88
|
+
class IPLookup
|
89
|
+
include Singleton
|
90
|
+
|
91
|
+
# Allow a caller to toggle the cache on and off.
|
92
|
+
attr_accessor :persist
|
93
|
+
|
94
|
+
# Because we're a singleton, we don't take any initialization arguments.
|
95
|
+
def initialize
|
96
|
+
fn = 'dnsomatic-' + Process.uid.to_s + '.cache'
|
97
|
+
@cache_file = File.join(ENV['TEMP'] || '/tmp', fn)
|
98
|
+
|
99
|
+
@cache = Hash.new
|
100
|
+
@persist = true
|
101
|
+
end
|
102
|
+
|
103
|
+
# This allows callers to alter which file we will persist our cache of
|
104
|
+
# IPStatus objects to.
|
105
|
+
def setcachefile(file)
|
106
|
+
if !File.writable?(File.dirname(file))
|
107
|
+
raise(DNSOMatic::Error, "Unwritable cache file directory.")
|
108
|
+
elsif File.exists?(file) and !File.writable(file)
|
109
|
+
raise(DNSOMatic::Error, "Unwritable cache file")
|
110
|
+
end
|
111
|
+
@cache_file = file
|
112
|
+
end
|
113
|
+
|
114
|
+
# This is the method a caller would use to request and IPStatus object.
|
115
|
+
# The url is passed to new() when the object is created or returned from
|
116
|
+
# the cache.
|
117
|
+
def ip_from_url(url)
|
118
|
+
load()
|
119
|
+
#implement a simple cache to prevent making multiple http requests
|
120
|
+
#to the same remote agent (in the case where a user defines multiple
|
121
|
+
#updater stanzas that use the same ip fetch url).
|
122
|
+
#because an access for a key that doesn't exist returns and inserts
|
123
|
+
#a new IPStatus object, we don't differntiate between seen and unseen
|
124
|
+
#here.
|
125
|
+
(@cache[url] ||= IPStatus.new(url)).update
|
126
|
+
|
127
|
+
save() #ensure that we get spooled to disk.
|
128
|
+
@cache[url]
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
def load
|
133
|
+
if File.exists?(@cache_file) and @persist
|
134
|
+
@cache = DNSOMatic::yaml_read(@cache_file)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
def save
|
139
|
+
DNSOMatic::yaml_write(@cache_file, @cache) if @persist
|
140
|
+
end
|
141
|
+
end
|
142
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module DNSOMatic
|
2
|
+
# A simple class to provide consistent logging throughout the various other
|
3
|
+
# classes. All methods are class methods, so no instance is required.
|
4
|
+
class Logger
|
5
|
+
# Output a message to stdout if the user specified the verbose command
|
6
|
+
# line option.
|
7
|
+
def self.log(msg)
|
8
|
+
$stdout.puts msg if $opts.verbose
|
9
|
+
end
|
10
|
+
|
11
|
+
# Output a message to stderr regardless of verbose command line option.
|
12
|
+
def self.warn(msg)
|
13
|
+
$stderr.puts msg
|
14
|
+
end
|
15
|
+
|
16
|
+
# Output a message to stdout if either verbose or alert was specified.
|
17
|
+
def self.alert(msg)
|
18
|
+
$stdout.puts msg if $opts.verbose or $opts.alert
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'optparse'
|
2
|
+
require 'ostruct'
|
3
|
+
require 'singleton'
|
4
|
+
|
5
|
+
module DNSOMatic
|
6
|
+
# A class to handle option parsing for the dnsomatic client.
|
7
|
+
# It acts as a singleton and will recreate its internal representation
|
8
|
+
# of the options each time parse(ARGS) is called.
|
9
|
+
class Opts
|
10
|
+
@@opts = OpenStruct.new
|
11
|
+
|
12
|
+
include Singleton
|
13
|
+
|
14
|
+
# No arguments, but sets the internal options to the defaults.
|
15
|
+
def initialize
|
16
|
+
setdefaults()
|
17
|
+
end
|
18
|
+
|
19
|
+
# Parse and array of arguments and set the interal options. Typically
|
20
|
+
# ARGV is passed, but that is not required. Any array of strings that
|
21
|
+
# look like arguments will work (makes testing easier).
|
22
|
+
def parse(args)
|
23
|
+
setdefaults()
|
24
|
+
begin
|
25
|
+
opts = OptionParser.new do |o|
|
26
|
+
o.on('-m', '--minimum SEC', 'The minimum time between updates (def: 30m)') do |i|
|
27
|
+
@@opts.minimum = i.to_i
|
28
|
+
end
|
29
|
+
|
30
|
+
o.on('-M', '--maximum SEC', 'The maximum time between updates (def: 15d)') do |i|
|
31
|
+
@@opts.maximum = i.to_i
|
32
|
+
end
|
33
|
+
#
|
34
|
+
#making this an option (off by default) means we can operate
|
35
|
+
#completely silently by default.
|
36
|
+
o.on('-a', '--alert', 'Emit an alert if the IP is updated') do |a|
|
37
|
+
@@opts.alert = true
|
38
|
+
end
|
39
|
+
|
40
|
+
o.on('-n', '--name NAME', 'Only update host stanza NAME') do |n|
|
41
|
+
@@opts.name = n
|
42
|
+
end
|
43
|
+
|
44
|
+
o.on('-d', '--display-config', 'Display the configuration and exit') do
|
45
|
+
@@opts.showcf = true
|
46
|
+
end
|
47
|
+
|
48
|
+
o.on('-c', '--config FILE', 'Use an alternate config file') do |f|
|
49
|
+
@@opts.cf = f
|
50
|
+
end
|
51
|
+
|
52
|
+
o.on('-f', '--force', 'Force an update, even if IP is unchanged') do
|
53
|
+
@@opts.force = true
|
54
|
+
end
|
55
|
+
|
56
|
+
o.on('-p', '--print', 'Output the update URLs. No action taken') do
|
57
|
+
@@opts.print = true
|
58
|
+
end
|
59
|
+
|
60
|
+
o.on('-v', '--verbose', 'Display runtime messages') do
|
61
|
+
@@opts.verbose = true
|
62
|
+
end
|
63
|
+
|
64
|
+
o.on('-V', '--version', 'Display version and exit') do
|
65
|
+
$stdout.puts DNSOMatic::VERSION
|
66
|
+
exit 0
|
67
|
+
end
|
68
|
+
|
69
|
+
o.on('-x', '--debug', 'Output additional info in error situations') do
|
70
|
+
@@opts.debug = true
|
71
|
+
end
|
72
|
+
|
73
|
+
o.on('-h', '--help', 'Display this help text') { puts o; exit; }
|
74
|
+
end
|
75
|
+
opts.parse!(args)
|
76
|
+
|
77
|
+
if args.size > 0
|
78
|
+
raise(DNSOMatic::Error, "Extra arguments given: #{args.join(', ')}")
|
79
|
+
end
|
80
|
+
|
81
|
+
rescue OptionParser::ParseError => e
|
82
|
+
msg = "Extra/Unknown arguments used:\n"
|
83
|
+
msg += "\t#{e.message}\n"
|
84
|
+
msg += "Remaining args: #{args.join(', ')}" if args.size > 0
|
85
|
+
raise(DNSOMatic::Error, msg)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
# This is a simple wrapper that passes method calls to our internal option
|
90
|
+
# store (an openstruct) so that a client can call opts.alert and get the
|
91
|
+
# value from the @@opts.alert variable.
|
92
|
+
def method_missing(meth, *args)
|
93
|
+
@@opts.send(meth)
|
94
|
+
end
|
95
|
+
|
96
|
+
private
|
97
|
+
# A quick way to set all of our preferred defaults
|
98
|
+
def setdefaults
|
99
|
+
@@opts.name = nil
|
100
|
+
@@opts.cf = nil
|
101
|
+
@@opts.showcf = false
|
102
|
+
@@opts.force = false
|
103
|
+
@@opts.print = false
|
104
|
+
@@opts.verbose = false
|
105
|
+
@@opts.minimum = 1800 #30 minutes
|
106
|
+
@@opts.maximum = 1296000 #15 days
|
107
|
+
@@opts.debug = false
|
108
|
+
@@opts.alert = false
|
109
|
+
end
|
110
|
+
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
|
2
|
+
module DNSOMatic
|
3
|
+
# This is the primary interface used to perform updates of IP information
|
4
|
+
# to the DNS-o-Matic service.
|
5
|
+
class Updater
|
6
|
+
# Initialize and updater object with a set of config options. config is
|
7
|
+
# a hash that must contain the following keys:
|
8
|
+
# * hostname: a string representing the hostname to be updated
|
9
|
+
# * mx: a string representing a valid MX for hostname
|
10
|
+
# * backmx: a string representing a secondary MX for hostname
|
11
|
+
# * username: the username with rights to update hostname
|
12
|
+
# * password: the corresponding password
|
13
|
+
# * webipfetchurl: a url that returns the IP of the system requesting the it
|
14
|
+
# * offline: determines whether 'offline' mode should be used.
|
15
|
+
def initialize(config)
|
16
|
+
@config = config
|
17
|
+
#use a cache in case other host stanzas have already looked up
|
18
|
+
#our ip using the same remote agent.
|
19
|
+
@lookup = DNSOMatic::IPLookup.instance
|
20
|
+
end
|
21
|
+
|
22
|
+
# Build and send the url that is passed to the DNS-o-Matic system to
|
23
|
+
# request an update for the hostname provided in the configuration.
|
24
|
+
# By default, the we'll honour the changed? status of the IPStatus object
|
25
|
+
# returned to us, but if force is set to true, we'll send the update
|
26
|
+
# request anyway.
|
27
|
+
def update(force = false) url = upd_url()
|
28
|
+
|
29
|
+
if !@ipstatus.changed? and !force
|
30
|
+
Logger::log("No change in IP detected for #{@config['hostname']}. Not updating.")
|
31
|
+
else
|
32
|
+
Logger::alert("Updating IP for #{@config['hostname']} to #{@ipstatus.ip}.")
|
33
|
+
update = DNSOMatic::http_fetch(url)
|
34
|
+
|
35
|
+
if !update.match(/^good\s+#{@ipstatus.ip}$/)
|
36
|
+
msg = "Error updating host definition for #{@config['hostname']}\n"
|
37
|
+
msg += "Results:\n#{update}\n"
|
38
|
+
msg += "Error codes at: https://www.dnsomatic.com/wiki/api"
|
39
|
+
raise(DNSOMatic::Error, msg)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
true
|
44
|
+
end
|
45
|
+
|
46
|
+
# A simple wrapper around update that sets force to true. The forceful
|
47
|
+
# nature of the update is logged if verbosity is enabled.
|
48
|
+
def update!
|
49
|
+
Logger::log("Forcing update at user request.")
|
50
|
+
update(true)
|
51
|
+
end
|
52
|
+
|
53
|
+
# This is used to simply return the url that would be sent to DNS-o-Matic.
|
54
|
+
# Using this is an easy way to build a list of update URL's that could
|
55
|
+
# be directed elsewhere (a log, or wget, etc).
|
56
|
+
def to_s
|
57
|
+
upd_url
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
def upd_url
|
62
|
+
opt_params = %w(wildcard mx backmx offline)
|
63
|
+
u = @config['username']
|
64
|
+
p = @config['password']
|
65
|
+
@ipstatus = @lookup.ip_from_url(@config['webipfetchurl'])
|
66
|
+
|
67
|
+
#we'll use nil as a key from the ip lookup to determine that the ip
|
68
|
+
#hasn't changed, so no update is required.
|
69
|
+
url = "https://#{u}:#{p}@updates.dnsomatic.com/nic/update?"
|
70
|
+
name_ip = "hostname=#{@config['hostname']}&myip=#{@ipstatus.ip}"
|
71
|
+
url += opt_params.inject(name_ip) do |params, curp|
|
72
|
+
val = @config[curp]
|
73
|
+
next if val.eql?('') or val.nil?
|
74
|
+
params += "&#{curp}=#{val}"
|
75
|
+
end
|
76
|
+
url
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end #end module
|
data/tests/all_tests.rb
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
$: << '../lib'
|
3
|
+
|
4
|
+
require 'test/unit'
|
5
|
+
require 'ostruct'
|
6
|
+
|
7
|
+
require 'dnsomatic'
|
8
|
+
|
9
|
+
class TestIPLookup < Test::Unit::TestCase
|
10
|
+
def setup
|
11
|
+
$opts = DNSOMatic::Opts.instance
|
12
|
+
$fp = File.join('/tmp', 'dnsomatic.testcache-' + Process.pid.to_s)
|
13
|
+
$iplookup = DNSOMatic::IPLookup.instance
|
14
|
+
$iplookup.setcachefile($fp)
|
15
|
+
$iplookup.persist = false
|
16
|
+
$local = 'http://benandwen.net/~bwalton/ip_lookup.php'
|
17
|
+
$local_ip = %x{wget -q -O - http://benandwen.net/~bwalton/ip_lookup.php}
|
18
|
+
$random = 'http://benandwen.net/~bwalton/ip_rand.php'
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_cache_works
|
22
|
+
stat = $iplookup.ip_from_url($local)
|
23
|
+
assert_equal(DNSOMatic::IPStatus::CHANGED, stat.changed?)
|
24
|
+
assert_equal($local_ip, stat.ip)
|
25
|
+
stat.update
|
26
|
+
assert_equal(DNSOMatic::IPStatus::UNCHANGED, stat.changed?)
|
27
|
+
end
|
28
|
+
|
29
|
+
def test_different_source_returns_new_val
|
30
|
+
stat = $iplookup.ip_from_url($random)
|
31
|
+
assert_equal(DNSOMatic::IPStatus::CHANGED, stat.changed?)
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_known_ip_change_still_prefers_cache
|
35
|
+
#ensure we have a long interval between polls, so we know we get cached ip
|
36
|
+
$opts.parse(%w(-m 1800))
|
37
|
+
stat = $iplookup.ip_from_url($random)
|
38
|
+
assert_equal(DNSOMatic::IPStatus::UNCHANGED, stat.changed?)
|
39
|
+
end
|
40
|
+
|
41
|
+
def test_expiration_with_known_change
|
42
|
+
stat = $iplookup.ip_from_url($random)
|
43
|
+
assert_equal(DNSOMatic::IPStatus::UNCHANGED, stat.changed?)
|
44
|
+
$opts.parse(%w(-m 0)) #change expiration to 2s.
|
45
|
+
sleep(1) #make sure we pass the expiration time we just set.
|
46
|
+
stat = $iplookup.ip_from_url($random)
|
47
|
+
assert_equal(DNSOMatic::IPStatus::CHANGED, stat.changed?)
|
48
|
+
end
|
49
|
+
|
50
|
+
def test_persist_stores_to_file
|
51
|
+
$iplookup.persist = true
|
52
|
+
stat = $iplookup.ip_from_url($random)
|
53
|
+
assert(File.exists?($fp), "${fp} doesn't exist when persist is enabled.")
|
54
|
+
end
|
55
|
+
|
56
|
+
def teardown
|
57
|
+
File.delete($fp) if File.exists?($fp)
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
$: << '../lib'
|
3
|
+
|
4
|
+
require 'test/unit'
|
5
|
+
require 'ostruct'
|
6
|
+
|
7
|
+
require 'dnsomatic'
|
8
|
+
|
9
|
+
class TestIPStatus < Test::Unit::TestCase
|
10
|
+
def setup
|
11
|
+
$opts = DNSOMatic::Opts.instance
|
12
|
+
$opts.parse(%w(-m 0))
|
13
|
+
$local = 'http://benandwen.net/~bwalton/ip_lookup.php'
|
14
|
+
$local_ip = %x{wget -q -O - http://benandwen.net/~bwalton/ip_lookup.php}
|
15
|
+
$random = 'http://benandwen.net/~bwalton/ip_rand.php'
|
16
|
+
$non_lookup = 'http://benandwen.net/~bwalton/non_ip_lookup.txt'
|
17
|
+
end
|
18
|
+
|
19
|
+
def test_returns_right_ip
|
20
|
+
stat = DNSOMatic::IPStatus.new($local)
|
21
|
+
assert_equal($local_ip, stat.ip)
|
22
|
+
end
|
23
|
+
|
24
|
+
def test_returns_right_status
|
25
|
+
stat1 = DNSOMatic::IPStatus.new($local)
|
26
|
+
assert_equal(true, stat1.changed?)
|
27
|
+
stat1.update
|
28
|
+
assert_equal(false, stat1.changed?)
|
29
|
+
|
30
|
+
stat2 = DNSOMatic::IPStatus.new($random)
|
31
|
+
assert_equal(true, stat2.changed?)
|
32
|
+
stat2.update
|
33
|
+
assert_equal(true, stat2.changed?)
|
34
|
+
end
|
35
|
+
|
36
|
+
def test_raise_on_bad_args
|
37
|
+
assert_raise(DNSOMatic::Error) { DNSOMatic::IPStatus.new($non_lookup) }
|
38
|
+
end
|
39
|
+
end
|
data/tests/test_opts.rb
ADDED
@@ -0,0 +1,78 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
$: << '../lib'
|
3
|
+
|
4
|
+
require 'test/unit'
|
5
|
+
require 'ostruct'
|
6
|
+
|
7
|
+
require 'dnsomatic'
|
8
|
+
|
9
|
+
class TestOpts < Test::Unit::TestCase
|
10
|
+
def setup
|
11
|
+
$opts = DNSOMatic::Opts.instance
|
12
|
+
end
|
13
|
+
|
14
|
+
def test_set_verbose
|
15
|
+
$opts.parse(%w(-v))
|
16
|
+
assert($opts.verbose)
|
17
|
+
$opts.parse(%w(--verbose))
|
18
|
+
assert($opts.verbose)
|
19
|
+
end
|
20
|
+
|
21
|
+
def test_set_minimum
|
22
|
+
$opts.parse(%w(-m 10))
|
23
|
+
assert_equal(10, $opts.minimum)
|
24
|
+
$opts.parse(%w(--minimum 15))
|
25
|
+
assert_equal(15, $opts.minimum)
|
26
|
+
end
|
27
|
+
|
28
|
+
def test_set_maximum
|
29
|
+
$opts.parse(%w(-M 10))
|
30
|
+
assert_equal(10, $opts.maximum)
|
31
|
+
$opts.parse(%w(--maximum 15))
|
32
|
+
assert_equal(15, $opts.maximum)
|
33
|
+
end
|
34
|
+
|
35
|
+
def test_set_alert
|
36
|
+
$opts.parse(%w(-a))
|
37
|
+
assert($opts.alert)
|
38
|
+
$opts.parse(%w(--alert))
|
39
|
+
assert($opts.alert)
|
40
|
+
end
|
41
|
+
|
42
|
+
def test_set_name
|
43
|
+
$opts.parse(%w(-n testcase))
|
44
|
+
assert_equal('testcase', $opts.name)
|
45
|
+
$opts.parse(%w(--name testcase2))
|
46
|
+
assert_equal('testcase2', $opts.name)
|
47
|
+
end
|
48
|
+
|
49
|
+
def test_set_display_config
|
50
|
+
$opts.parse(%w(-d))
|
51
|
+
assert($opts.showcf)
|
52
|
+
$opts.parse(%w(--display-config))
|
53
|
+
assert($opts.showcf)
|
54
|
+
end
|
55
|
+
|
56
|
+
def test_set_config_file
|
57
|
+
$opts.parse(%w(-c /tmp/dnsomatic))
|
58
|
+
assert_equal('/tmp/dnsomatic', $opts.cf)
|
59
|
+
$opts.parse(%w(--config /tmp/dnsomatic2))
|
60
|
+
assert_equal('/tmp/dnsomatic2', $opts.cf)
|
61
|
+
end
|
62
|
+
|
63
|
+
def test_set_force
|
64
|
+
$opts.parse(%w(-f))
|
65
|
+
assert($opts.force)
|
66
|
+
$opts.parse(%w(--force))
|
67
|
+
assert($opts.force)
|
68
|
+
end
|
69
|
+
|
70
|
+
def test_extra_opts_raise_exception
|
71
|
+
assert_raise(DNSOMatic::Error) { $opts.parse(%w(--badoption)) }
|
72
|
+
end
|
73
|
+
|
74
|
+
def test_extra_args_raise_exception
|
75
|
+
assert_raise(DNSOMatic::Error) { $opts.parse(%w(somearg)) }
|
76
|
+
end
|
77
|
+
|
78
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
#!/usr/bin/ruby -w
|
2
|
+
$: << '../lib'
|
3
|
+
|
4
|
+
require 'test/unit'
|
5
|
+
require 'ostruct'
|
6
|
+
|
7
|
+
require 'dnsomatic'
|
8
|
+
|
9
|
+
class TestUpdater < Test::Unit::TestCase
|
10
|
+
def setup
|
11
|
+
$opts = DNSOMatic::Opts.instance
|
12
|
+
$fp = File.join('/tmp', 'dnsomatic.testcache-' + Process.pid.to_s)
|
13
|
+
$iplookup = DNSOMatic::IPLookup.instance
|
14
|
+
$iplookup.setcachefile($fp)
|
15
|
+
$iplookup.persist = false
|
16
|
+
$local = 'http://benandwen.net/~bwalton/ip_lookup.php'
|
17
|
+
$local_ip = %x{wget -q -O - http://benandwen.net/~bwalton/ip_lookup.php}
|
18
|
+
$random = 'http://benandwen.net/~bwalton/ip_rand.php'
|
19
|
+
end
|
20
|
+
|
21
|
+
def giveconf(u, p, mx, bmx, w, o, wf = $local, hn = 'all.dnsomatic.com')
|
22
|
+
{ 'username' => u, 'password' => p, 'mx' => mx, 'backmx' => bmx,
|
23
|
+
'wildcard' => w, 'offline' => o , 'webipfetchurl' => wf,
|
24
|
+
'hostname' => hn}
|
25
|
+
end
|
26
|
+
|
27
|
+
def test_config_taken
|
28
|
+
c = giveconf('user', 'mypass', 'NOCHG', 'NOCHG', 'NOCHG', 'NOCHG')
|
29
|
+
url = "https://user:mypass@updates.dnsomatic.com/nic/update?hostname=all.dnsomatic.com&myip=#{$local_ip}&wildcard=NOCHG&mx=NOCHG&backmx=NOCHG&offline=NOCHG"
|
30
|
+
u = DNSOMatic::Updater.new(c)
|
31
|
+
assert_equal(url, u.to_s)
|
32
|
+
end
|
33
|
+
end
|
metadata
ADDED
@@ -0,0 +1,65 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: dnsomatic
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.1
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Ben Walton
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
|
12
|
+
date: 2008-08-26 00:00:00 -04:00
|
13
|
+
default_executable: dnsomatic
|
14
|
+
dependencies: []
|
15
|
+
|
16
|
+
description:
|
17
|
+
email: bdwalton@gmail.com
|
18
|
+
executables:
|
19
|
+
- dnsomatic
|
20
|
+
extensions: []
|
21
|
+
|
22
|
+
extra_rdoc_files: []
|
23
|
+
|
24
|
+
files:
|
25
|
+
- bin/dnsomatic
|
26
|
+
- lib/dnsomatic
|
27
|
+
- lib/dnsomatic/updater.rb
|
28
|
+
- lib/dnsomatic/logger.rb
|
29
|
+
- lib/dnsomatic/iplookup.rb
|
30
|
+
- lib/dnsomatic/opts.rb
|
31
|
+
- lib/dnsomatic/config.rb
|
32
|
+
- lib/dnsomatic.rb
|
33
|
+
- tests/test_ipstatus.rb
|
34
|
+
- tests/test_opts.rb
|
35
|
+
- tests/test_updater.rb
|
36
|
+
- tests/all_tests.rb
|
37
|
+
- tests/test_iplookup.rb
|
38
|
+
has_rdoc: true
|
39
|
+
homepage: http://rubyforge.org/projects/dnsomatic
|
40
|
+
post_install_message:
|
41
|
+
rdoc_options:
|
42
|
+
- -x tests/
|
43
|
+
require_paths:
|
44
|
+
- lib
|
45
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
46
|
+
requirements:
|
47
|
+
- - ">="
|
48
|
+
- !ruby/object:Gem::Version
|
49
|
+
version: "0"
|
50
|
+
version:
|
51
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
52
|
+
requirements:
|
53
|
+
- - ">="
|
54
|
+
- !ruby/object:Gem::Version
|
55
|
+
version: "0"
|
56
|
+
version:
|
57
|
+
requirements: []
|
58
|
+
|
59
|
+
rubyforge_project: http://rubyforge.org/projects/dnsomatic
|
60
|
+
rubygems_version: 1.1.1
|
61
|
+
signing_key:
|
62
|
+
specification_version: 2
|
63
|
+
summary: A DNS-O-Matic Update Client
|
64
|
+
test_files:
|
65
|
+
- tests/all_tests.rb
|