dnsomatic 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|