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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +35 -1
- data/README.md +78 -16
- data/exe/dyndnsd +6 -0
- data/lib/dyndnsd.rb +56 -8
- data/lib/dyndnsd/database.rb +6 -1
- data/lib/dyndnsd/generator/bind.rb +10 -5
- data/lib/dyndnsd/helper.rb +18 -1
- data/lib/dyndnsd/responder/dyndns_style.rb +16 -0
- data/lib/dyndnsd/responder/rest_style.rb +16 -0
- data/lib/dyndnsd/textfile_reporter.rb +14 -0
- data/lib/dyndnsd/updater/command_with_bind_zone.rb +16 -8
- data/lib/dyndnsd/updater/zone_transfer_server.rb +158 -0
- data/lib/dyndnsd/version.rb +2 -1
- metadata +50 -45
- data/.gitignore +0 -3
- data/.rubocop.yml +0 -80
- data/.rubocop_todo.yml +0 -7
- data/.travis.yml +0 -8
- data/Gemfile +0 -3
- data/Rakefile +0 -10
- data/bin/dyndnsd +0 -4
- data/dyndnsd.gemspec +0 -36
- data/spec/daemon_spec.rb +0 -240
- data/spec/spec_helper.rb +0 -8
- data/spec/support/dummy_database.rb +0 -27
- data/spec/support/dummy_updater.rb +0 -10
data/lib/dyndnsd/database.rb
CHANGED
@@ -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.
|
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
|
-
|
6
|
+
# @param domain [String]
|
7
|
+
# @param updater_params [Hash{String => Object}]
|
8
|
+
def initialize(domain, updater_params)
|
6
9
|
@domain = domain
|
7
|
-
@ttl =
|
8
|
-
@dns =
|
9
|
-
@email_addr =
|
10
|
-
@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}"
|
data/lib/dyndnsd/helper.rb
CHANGED
@@ -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
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
@
|
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
|
-
|
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
|
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(
|
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
|