dyndnsd 2.0.0 → 2.3.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.
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  require 'forwardable'
3
4
 
@@ -7,19 +8,22 @@ module Dyndnsd
7
8
 
8
9
  def_delegators :@db, :[], :[]=, :each, :has_key?
9
10
 
11
+ # @param db_file [String]
10
12
  def initialize(db_file)
11
13
  @db_file = db_file
12
14
  end
13
15
 
16
+ # @return [void]
14
17
  def load
15
18
  if File.file?(@db_file)
16
- @db = JSON.parse(File.open(@db_file, 'r', &:read))
19
+ @db = JSON.parse(File.read(@db_file, mode: 'r'))
17
20
  else
18
21
  @db = {}
19
22
  end
20
23
  @db_hash = @db.hash
21
24
  end
22
25
 
26
+ # @return [void]
23
27
  def save
24
28
  Helper.span('database_save') do |_span|
25
29
  File.open(@db_file, 'w') { |f| JSON.dump(@db, f) }
@@ -27,6 +31,7 @@ module Dyndnsd
27
31
  end
28
32
  end
29
33
 
34
+ # @return [Boolean]
30
35
  def changed?
31
36
  @db_hash != @db.hash
32
37
  end
@@ -1,15 +1,20 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module Dyndnsd
3
4
  module Generator
4
5
  class Bind
5
- def initialize(domain, config)
6
+ # @param domain [String]
7
+ # @param updater_params [Hash{String => Object}]
8
+ def initialize(domain, updater_params)
6
9
  @domain = domain
7
- @ttl = config['ttl']
8
- @dns = config['dns']
9
- @email_addr = config['email_addr']
10
- @additional_zone_content = config['additional_zone_content']
10
+ @ttl = updater_params['ttl']
11
+ @dns = updater_params['dns']
12
+ @email_addr = updater_params['email_addr']
13
+ @additional_zone_content = updater_params['additional_zone_content']
11
14
  end
12
15
 
16
+ # @param db [Dyndnsd::Database]
17
+ # @return [String]
13
18
  def generate(db)
14
19
  out = []
15
20
  out << "$TTL #{@ttl}"
@@ -1,8 +1,12 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  require 'ipaddr'
3
4
 
4
5
  module Dyndnsd
5
6
  class Helper
7
+ # @param hostname [String]
8
+ # @param domain [String]
9
+ # @return [Boolean]
6
10
  def self.fqdn_valid?(hostname, domain)
7
11
  return false if hostname.length < domain.length + 2
8
12
  return false if !hostname.end_with?(domain)
@@ -11,6 +15,8 @@ module Dyndnsd
11
15
  true
12
16
  end
13
17
 
18
+ # @param ip [String]
19
+ # @return [Boolean]
14
20
  def self.ip_valid?(ip)
15
21
  IPAddr.new(ip)
16
22
  true
@@ -18,15 +24,26 @@ module Dyndnsd
18
24
  false
19
25
  end
20
26
 
27
+ # @param username [String]
28
+ # @param password [String]
29
+ # @param users [Hash]
30
+ # @return [Boolean]
21
31
  def self.user_allowed?(username, password, users)
22
32
  (users.key? username) && (users[username]['password'] == password)
23
33
  end
24
34
 
35
+ # @param hostname [String]
36
+ # @param myips [Array]
37
+ # @param hosts [Hash]
38
+ # @return [Boolean]
25
39
  def self.changed?(hostname, myips, hosts)
26
40
  # myips order is always deterministic
27
41
  ((!hosts.include? hostname) || (hosts[hostname] != myips)) && !myips.empty?
28
42
  end
29
43
 
44
+ # @param operation [String]
45
+ # @param block [Proc]
46
+ # @return [void]
30
47
  def self.span(operation, &block)
31
48
  scope = OpenTracing.start_active_span(operation)
32
49
  span = scope.span
@@ -41,7 +58,7 @@ module Dyndnsd
41
58
  'error.kind': e.class.to_s,
42
59
  'error.object': e,
43
60
  message: e.message,
44
- stack: e.backtrace.join("\n")
61
+ stack: e.backtrace&.join("\n") || ''
45
62
  )
46
63
  raise
47
64
  ensure
@@ -1,11 +1,15 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module Dyndnsd
3
4
  module Responder
4
5
  class DynDNSStyle
6
+ # @param app [#call]
5
7
  def initialize(app)
6
8
  @app = app
7
9
  end
8
10
 
11
+ # @param env [Hash{String => String}]
12
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
9
13
  def call(env)
10
14
  @app.call(env).tap do |status_code, headers, body|
11
15
  if headers.key?('X-DynDNS-Response')
@@ -18,6 +22,10 @@ module Dyndnsd
18
22
 
19
23
  private
20
24
 
25
+ # @param status_code [Integer]
26
+ # @param headers [Hash{String => String}]
27
+ # @param body [Array{String}]
28
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
21
29
  def decorate_dyndnsd_response(status_code, headers, body)
22
30
  if status_code == 200
23
31
  [200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
@@ -26,6 +34,10 @@ module Dyndnsd
26
34
  end
27
35
  end
28
36
 
37
+ # @param status_code [Integer]
38
+ # @param headers [Hash{String => String}]
39
+ # @param _body [Array{String}]
40
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
29
41
  def decorate_other_response(status_code, headers, _body)
30
42
  if status_code == 400
31
43
  [status_code, headers, ['Bad Request']]
@@ -34,10 +46,14 @@ module Dyndnsd
34
46
  end
35
47
  end
36
48
 
49
+ # @param changes [Array{Symbol}]
50
+ # @param myips [Array{String}]
51
+ # @return [String]
37
52
  def get_success_body(changes, myips)
38
53
  changes.map { |change| "#{change} #{myips.join(' ')}" }.join("\n")
39
54
  end
40
55
 
56
+ # @return [Hash{String => Object}]
41
57
  def error_response_map
42
58
  {
43
59
  # general http errors
@@ -1,11 +1,15 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module Dyndnsd
3
4
  module Responder
4
5
  class RestStyle
6
+ # @param app [#call]
5
7
  def initialize(app)
6
8
  @app = app
7
9
  end
8
10
 
11
+ # @param env [Hash{String => String}]
12
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
9
13
  def call(env)
10
14
  @app.call(env).tap do |status_code, headers, body|
11
15
  if headers.key?('X-DynDNS-Response')
@@ -18,6 +22,10 @@ module Dyndnsd
18
22
 
19
23
  private
20
24
 
25
+ # @param status_code [Integer]
26
+ # @param headers [Hash{String => String}]
27
+ # @param body [Array{String}]
28
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
21
29
  def decorate_dyndnsd_response(status_code, headers, body)
22
30
  if status_code == 200
23
31
  [200, {'Content-Type' => 'text/plain'}, [get_success_body(body[0], body[1])]]
@@ -26,6 +34,10 @@ module Dyndnsd
26
34
  end
27
35
  end
28
36
 
37
+ # @param status_code [Integer]
38
+ # @param headers [Hash{String => String}]
39
+ # @param _body [Array{String}]
40
+ # @return [Array{Integer,Hash{String => String},Array{String}}]
29
41
  def decorate_other_response(status_code, headers, _body)
30
42
  if status_code == 400
31
43
  [status_code, headers, ['Bad Request']]
@@ -34,10 +46,14 @@ module Dyndnsd
34
46
  end
35
47
  end
36
48
 
49
+ # @param changes [Array{Symbol}]
50
+ # @param myips [Array{String}]
51
+ # @return [String]
37
52
  def get_success_body(changes, myips)
38
53
  changes.map { |change| change == :good ? "Changed to #{myips.join(' ')}" : "No change needed for #{myips.join(' ')}" }.join("\n")
39
54
  end
40
55
 
56
+ # @return [Hash{String => Object}]
41
57
  def error_response_map
42
58
  {
43
59
  # general http errors
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  # Adapted from https://github.com/eric/metriks-graphite/blob/master/lib/metriks/reporter/graphite.rb
3
4
 
@@ -5,8 +6,11 @@ require 'metriks'
5
6
 
6
7
  module Dyndnsd
7
8
  class TextfileReporter
9
+ # @return [String]
8
10
  attr_reader :file
9
11
 
12
+ # @param file [String]
13
+ # @param options [Hash{Symbol => Object}]
10
14
  def initialize(file, options = {})
11
15
  @file = file
12
16
 
@@ -17,6 +21,7 @@ module Dyndnsd
17
21
  @on_error = options[:on_error] || proc { |ex| }
18
22
  end
19
23
 
24
+ # @return [void]
20
25
  def start
21
26
  @thread ||= Thread.new do
22
27
  loop do
@@ -33,16 +38,19 @@ module Dyndnsd
33
38
  end
34
39
  end
35
40
 
41
+ # @return [void]
36
42
  def stop
37
43
  @thread&.kill
38
44
  @thread = nil
39
45
  end
40
46
 
47
+ # @return [void]
41
48
  def restart
42
49
  stop
43
50
  start
44
51
  end
45
52
 
53
+ # @return [void]
46
54
  def write
47
55
  File.open(@file, 'w') do |f|
48
56
  @registry.each do |name, metric|
@@ -85,6 +93,12 @@ module Dyndnsd
85
93
  end
86
94
  end
87
95
 
96
+ # @param file [String]
97
+ # @param base_name [String]
98
+ # @param metric [Object]
99
+ # @param keys [Array{Symbol}]
100
+ # @param snapshot_keys [Array{Symbol}]
101
+ # @return [void]
88
102
  def write_metric(file, base_name, metric, keys, snapshot_keys = [])
89
103
  time = Time.now.to_i
90
104
 
@@ -1,26 +1,34 @@
1
+ # frozen_string_literal: true
1
2
 
2
3
  module Dyndnsd
3
4
  module Updater
4
5
  class CommandWithBindZone
5
- def initialize(domain, config)
6
- @zone_file = config['zone_file']
7
- @command = config['command']
8
- @generator = Generator::Bind.new(domain, config)
6
+ # @param domain [String]
7
+ # @param updater_params [Hash{String => Object}]
8
+ def initialize(domain, updater_params)
9
+ @zone_file = updater_params['zone_file']
10
+ @command = updater_params['command']
11
+ @generator = Generator::Bind.new(domain, updater_params)
9
12
  end
10
13
 
11
- def update(zone)
14
+ # @param db [Dyndnsd::Database]
15
+ # @return [void]
16
+ def update(db)
17
+ # do not regenerate zone file (assumed to be persistent) if DB did not change
18
+ return if !db.changed?
19
+
12
20
  Helper.span('updater_update') do |span|
13
- span.set_tag('dyndnsd.updater.name', self.class.name.split('::').last)
21
+ span.set_tag('dyndnsd.updater.name', self.class.name&.split('::')&.last || 'None')
14
22
 
15
23
  # write zone file in bind syntax
16
- File.open(@zone_file, 'w') { |f| f.write(@generator.generate(zone)) }
24
+ File.open(@zone_file, 'w') { |f| f.write(@generator.generate(db)) }
17
25
  # call user-defined command
18
26
  pid = fork do
19
27
  exec @command
20
28
  end
21
29
 
22
30
  # detach so children don't become zombies
23
- Process.detach(pid)
31
+ Process.detach(pid) if pid
24
32
  end
25
33
  end
26
34
  end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'resolv'
4
+ require 'securerandom'
5
+
6
+ require 'async/dns'
7
+
8
+ module Dyndnsd
9
+ module Updater
10
+ class ZoneTransferServer
11
+ DEFAULT_SERVER_LISTENS = ['0.0.0.0@53'].freeze
12
+
13
+ # @param domain [String]
14
+ # @param updater_params [Hash{String => Object}]
15
+ def initialize(domain, updater_params)
16
+ @domain = domain
17
+
18
+ @server_listens = self.class.parse_endpoints(updater_params['server_listens'] || DEFAULT_SERVER_LISTENS)
19
+ @notify_targets = (updater_params['send_notifies'] || []).map { |e| self.class.parse_endpoints([e]) }
20
+
21
+ @zone_rr_ttl = updater_params['zone_ttl']
22
+ @zone_nameservers = updater_params['zone_nameservers'].map { |n| Resolv::DNS::Name.create(n) }
23
+ @zone_email_address = Resolv::DNS::Name.create(updater_params['zone_email_address'])
24
+ @zone_additional_ips = updater_params['zone_additional_ips'] || []
25
+
26
+ @server = ZoneTransferServerHelper.new(@server_listens, @domain)
27
+
28
+ # run Async::DNS server in background thread
29
+ Thread.new do
30
+ @server.run
31
+ end
32
+ end
33
+
34
+ # @param db [Dyndnsd::Database]
35
+ # @return [void]
36
+ def update(db)
37
+ Helper.span('updater_update') do |span|
38
+ span.set_tag('dyndnsd.updater.name', self.class.name&.split('::')&.last || 'None')
39
+
40
+ soa_rr = Resolv::DNS::Resource::IN::SOA.new(
41
+ @zone_nameservers[0], @zone_email_address,
42
+ db['serial'],
43
+ 10_800, # 3h
44
+ 300, # 5m
45
+ 604_800, # 1w
46
+ 3_600 # 1h
47
+ )
48
+
49
+ default_options = {ttl: @zone_rr_ttl}
50
+
51
+ # array containing all resource records for an AXFR request in the right order
52
+ rrs = []
53
+ # AXFR responses need to start with zone's SOA RR
54
+ rrs << [soa_rr, default_options]
55
+
56
+ # return RRs for all of the zone's nameservers
57
+ @zone_nameservers.each do |ns|
58
+ rrs << [Resolv::DNS::Resource::IN::NS.new(ns), default_options]
59
+ end
60
+
61
+ # return A/AAAA RRs for all additional IPv4s/IPv6s for the domain itself
62
+ @zone_additional_ips.each do |ip|
63
+ rrs << [create_addr_rr_for_ip(ip), default_options]
64
+ end
65
+
66
+ # return A/AAAA RRs for the dyndns hostnames
67
+ db['hosts'].each do |hostname, ips|
68
+ ips.each do |ip|
69
+ rrs << [create_addr_rr_for_ip(ip), default_options.merge({name: hostname})]
70
+ end
71
+ end
72
+
73
+ # AXFR responses need to end with zone's SOA RR again
74
+ rrs << [soa_rr, default_options]
75
+
76
+ # point Async::DNS server thread's variable to this new RR array
77
+ @server.axfr_rrs = rrs
78
+
79
+ # only send DNS NOTIFY if there really was a change
80
+ if db.changed?
81
+ send_dns_notify
82
+ end
83
+ end
84
+ end
85
+
86
+ # converts into suitable parameter form for Async::DNS::Resolver or Async::DNS::Server
87
+ #
88
+ # @param endpoint_list [Array{String}]
89
+ # @return [Array{Array{Object}}]
90
+ def self.parse_endpoints(endpoint_list)
91
+ endpoint_list.map { |addr_string| addr_string.split('@') }
92
+ .map { |addr_parts| [addr_parts[0], addr_parts[1].to_i || 53] }
93
+ .map { |addr| [:tcp, :udp].map { |type| [type] + addr } }
94
+ .flatten(1)
95
+ end
96
+
97
+ private
98
+
99
+ # creates correct Resolv::DNS::Resource object for IP address type
100
+ #
101
+ # @param ip_string [String]
102
+ # @return [Resolv::DNS::Resource::IN::A,Resolv::DNS::Resource::IN::AAAA]
103
+ def create_addr_rr_for_ip(ip_string)
104
+ ip = IPAddr.new(ip_string).native
105
+
106
+ if ip.ipv6?
107
+ Resolv::DNS::Resource::IN::AAAA.new(ip.to_s)
108
+ else
109
+ Resolv::DNS::Resource::IN::A.new(ip.to_s)
110
+ end
111
+ end
112
+
113
+ # https://tools.ietf.org/html/rfc1996
114
+ #
115
+ # @return [void]
116
+ def send_dns_notify
117
+ Async::Reactor.run do
118
+ @notify_targets.each do |notify_target|
119
+ target = Async::DNS::Resolver.new(notify_target)
120
+
121
+ # assemble DNS NOTIFY message
122
+ request = Resolv::DNS::Message.new(SecureRandom.random_number(2**16))
123
+ request.opcode = Resolv::DNS::OpCode::Notify
124
+ request.add_question("#{@domain}.", Resolv::DNS::Resource::IN::SOA)
125
+
126
+ _response = target.dispatch_request(request)
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ class ZoneTransferServerHelper < Async::DNS::Server
133
+ attr_accessor :axfr_rrs
134
+
135
+ def initialize(endpoints, domain)
136
+ super(endpoints, logger: Dyndnsd.logger)
137
+ @domain = domain
138
+ end
139
+
140
+ # @param name [String]
141
+ # @param resource_class [Resolv::DNS::Resource]
142
+ # @param transaction [Async::DNS::Transaction]
143
+ # @return [void]
144
+ def process(name, resource_class, transaction)
145
+ if name != @domain || resource_class != Resolv::DNS::Resource::Generic::Type252_Class1
146
+ transaction.fail!(:NXDomain)
147
+ return
148
+ end
149
+
150
+ # https://tools.ietf.org/html/rfc5936
151
+ transaction.append_question!
152
+ @axfr_rrs.each do |rr|
153
+ transaction.add([rr[0]], rr[1])
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end