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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -3
- data/README.md +78 -16
- data/exe/dyndnsd +6 -0
- data/lib/dyndnsd.rb +56 -9
- data/lib/dyndnsd/database.rb +6 -1
- data/lib/dyndnsd/generator/bind.rb +10 -5
- data/lib/dyndnsd/helper.rb +30 -2
- 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 +63 -58
- data/.gitignore +0 -3
- data/.rubocop.yml +0 -71
- data/.rubocop_todo.yml +0 -7
- data/.travis.yml +0 -7
- data/Gemfile +0 -3
- data/Rakefile +0 -8
- 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,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
|
-
|
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
|
-
|
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
|
-
|
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
|