dyndnsd 2.0.0.rc2 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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,23 +24,45 @@ 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
- span = OpenTracing.start_span(operation)
48
+ scope = OpenTracing.start_active_span(operation)
49
+ span = scope.span
32
50
  span.set_tag('component', 'dyndnsd')
33
51
  span.set_tag('span.kind', 'server')
34
52
  begin
35
53
  block.call(span)
54
+ rescue StandardError => e
55
+ span.set_tag('error', true)
56
+ span.log_kv(
57
+ event: 'error',
58
+ 'error.kind': e.class.to_s,
59
+ 'error.object': e,
60
+ message: e.message,
61
+ stack: e.backtrace&.join("\n") || ''
62
+ )
63
+ raise
36
64
  ensure
37
- span.finish
65
+ scope.close
38
66
  end
39
67
  end
40
68
  end
@@ -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