dnsync 1.0.0

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.
@@ -0,0 +1,65 @@
1
+ require 'faraday'
2
+ require 'faraday_middleware'
3
+ require 'active_support/core_ext/object/blank'
4
+
5
+ require 'dnsync/zone'
6
+
7
+ module Dnsync
8
+ class Dnsimple
9
+ attr_reader :domain
10
+
11
+ def initialize(email, token, domain)
12
+ unless email.present?
13
+ raise ArgumentError, "email must be specified"
14
+ end
15
+
16
+ unless token.present?
17
+ raise ArgumentError, "token must be specified"
18
+ end
19
+
20
+ unless domain.present?
21
+ raise ArgumentError, "domain must be specified"
22
+ end
23
+
24
+ @email = email
25
+ @token = token
26
+ @domain = domain
27
+ end
28
+
29
+ def connection
30
+ @connection ||= Faraday.new('https://api.dnsimple.com/v1/') do |conn|
31
+ conn.request :url_encoded # form-encode POST params
32
+
33
+ # conn.response :logger
34
+ conn.response :raise_error
35
+ conn.response :json, :content_type => /\bjson$/
36
+
37
+ conn.adapter Faraday.default_adapter
38
+
39
+ conn.headers['X-DNSimple-Token'] = "#{@email}:#{@token}"
40
+
41
+ conn.options.timeout = 5
42
+ conn.options.open_timeout = 5
43
+ end
44
+ end
45
+
46
+ def zone
47
+ records_by_record = connection.get("domains/#{@domain}/records").body.group_by do |dnsimple_record|
48
+ [ dnsimple_record['record']['name'], dnsimple_record['record']['record_type'] ]
49
+ end
50
+
51
+ records = records_by_record.map do |(name, record_type), records|
52
+ fqdn = name.present? ? "#{name}.#{@domain}" : @domain
53
+ ttl = records.first['record']['ttl']
54
+
55
+ answers = records.map do |record|
56
+ Answer.new(record['record']['content'], record['record']['prio'])
57
+ end
58
+
59
+ Record.new(fqdn, record_type, ttl, answers)
60
+ end
61
+
62
+ Zone.new(@domain, records)
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,56 @@
1
+ require 'webrick'
2
+
3
+ module Dnsync
4
+ class HttpStatus
5
+ def initialize(port, updater)
6
+ @port = port
7
+ @updater = updater
8
+ end
9
+
10
+ def start
11
+ return if @server || @thread
12
+
13
+ logger = WEBrick::Log.new
14
+ logger.level = WEBrick::Log::WARN
15
+
16
+ @server = WEBrick::HTTPServer.new(:Port => @port,
17
+ :Logger => logger, :AccessLog => [])
18
+ @server.mount_proc("/status", &method(:handler))
19
+
20
+ @thread = Thread.new do
21
+ Thread.current.abort_on_exception = true
22
+ @server.start
23
+ end
24
+
25
+ self
26
+ end
27
+
28
+ def stop
29
+ if @server
30
+ @server.stop
31
+ end
32
+
33
+ self
34
+ end
35
+
36
+ def join
37
+ if @thread
38
+ @thread.join
39
+ end
40
+
41
+ self
42
+ end
43
+
44
+ def handler(request, response)
45
+ health_problems = @updater.health_problems
46
+
47
+ if health_problems.blank?
48
+ response.status = 200
49
+ response.body = "OK\n"
50
+ else
51
+ response.status = 500
52
+ response.body = health_problems + "\n"
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,106 @@
1
+ module Dnsync
2
+ class Nsone
3
+ def initialize(api_key, domain)
4
+ unless api_key.present?
5
+ raise ArgumentError, "api_key must be specified"
6
+ end
7
+
8
+ unless domain.present?
9
+ raise ArgumentError, "domain must be specified"
10
+ end
11
+
12
+ @api_key = api_key
13
+ @domain = domain
14
+ end
15
+
16
+ def connection
17
+ @connection ||= Faraday.new('https://api.nsone.net/v1/') do |conn|
18
+ conn.request :json
19
+
20
+ # conn.response :logger
21
+ conn.response :raise_error
22
+ conn.response :json, :content_type => /\bjson$/
23
+
24
+ conn.adapter Faraday.default_adapter
25
+
26
+ conn.headers['X-NSONE-Key'] = @api_key
27
+
28
+ conn.options.timeout = 5
29
+ conn.options.open_timeout = 5
30
+ end
31
+ end
32
+
33
+ def zone
34
+ zone = connection.get("zones/#{@domain}").body
35
+
36
+ records = zone['records'].map do |record|
37
+ record_for(record['domain'], record['type'])
38
+ end
39
+
40
+ Zone.new(@domain, records)
41
+ end
42
+
43
+ def record_for(fqdn, record_type)
44
+ record = connection.get("zones/#{@domain}/#{fqdn}/#{record_type}").body
45
+
46
+ answers = record['answers'].map do |answer_record|
47
+ case answer_record['answer'].length
48
+ when 2
49
+ priority, content = *answer_record['answer']
50
+ when 1
51
+ content = answer_record['answer'].first
52
+ else
53
+ raise "Unknown answer format: #{answer_record.inspect}"
54
+ end
55
+
56
+ Answer.new(content, priority)
57
+ end
58
+
59
+ Record.new(record['domain'], record['type'], record['ttl'], answers)
60
+ end
61
+
62
+ def create_record(record)
63
+ answers = record.answers.map do |answer|
64
+ if answer.priority
65
+ { :answer => [ answer.priority, answer.content ] }
66
+ else
67
+ { :answer => [ answer.content ] }
68
+ end
69
+ end
70
+
71
+ connection.put("zones/#{@domain}/#{record.name}/#{record.type}") do |req|
72
+ req.body = {
73
+ :type => record.type,
74
+ :zone => @domain,
75
+ :domain => record.name,
76
+ :ttl => record.ttl,
77
+ :answers => answers
78
+ }
79
+ end
80
+ end
81
+
82
+ def update_record(record)
83
+ answers = record.answers.map do |answer|
84
+ if answer.priority
85
+ { :answer => [ answer.priority, answer.content ] }
86
+ else
87
+ { :answer => [ answer.content ] }
88
+ end
89
+ end
90
+
91
+ connection.post("zones/#{@domain}/#{record.name}/#{record.type}") do |req|
92
+ req.body = {
93
+ :type => record.type,
94
+ :zone => @domain,
95
+ :domain => record.name,
96
+ :ttl => record.ttl,
97
+ :answers => answers
98
+ }
99
+ end
100
+ end
101
+
102
+ def remove_record(record)
103
+ connection.delete("zones/#{@domain}/#{record.name}/#{record.type}")
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,46 @@
1
+ require 'active_support/core_ext/object/blank'
2
+
3
+ require 'dnsync/answer'
4
+ require 'dnsync/record_identifier'
5
+
6
+ module Dnsync
7
+ class Record
8
+ include Comparable
9
+
10
+ attr_reader :identifier, :ttl, :answers
11
+
12
+ def initialize(name, type, ttl, answers)
13
+ unless ttl.present?
14
+ raise ArgumentError, 'ttl must be provided'
15
+ end
16
+
17
+ unless answers.present?
18
+ raise ArgumentError, 'at least one answer must be provided'
19
+ end
20
+
21
+ @identifier = RecordIdentifier.new(name, type)
22
+ @ttl = ttl
23
+ @answers = answers.sort
24
+
25
+ freeze
26
+ end
27
+
28
+ def name
29
+ @identifier.name
30
+ end
31
+
32
+ def type
33
+ @identifier.type
34
+ end
35
+
36
+ def <=>(other)
37
+ [ identifier, ttl, answers ] <=> [ other.identifier, other.ttl, other.answers ]
38
+ end
39
+
40
+ def hash
41
+ [ identifier, ttl, answers ].hash
42
+ end
43
+
44
+ alias_method :eql?, :==
45
+ end
46
+ end
@@ -0,0 +1,34 @@
1
+ require 'active_support/core_ext/object/blank'
2
+
3
+ module Dnsync
4
+ class RecordIdentifier
5
+ include Comparable
6
+
7
+ attr_reader :name, :type
8
+
9
+ def initialize(name, type)
10
+ unless name.present?
11
+ raise ArgumentError, 'name must be provided'
12
+ end
13
+
14
+ unless type.present?
15
+ raise ArgumentError, 'type must be provided'
16
+ end
17
+
18
+ @name = name
19
+ @type = type
20
+
21
+ freeze
22
+ end
23
+
24
+ def <=>(other)
25
+ [ name, type ] <=> [ other.name, other.type ]
26
+ end
27
+
28
+ def hash
29
+ [ name, type ].hash
30
+ end
31
+
32
+ alias_method :eql?, :==
33
+ end
34
+ end
@@ -0,0 +1,152 @@
1
+ require 'dnsync/zone_updater'
2
+ require 'atomic'
3
+
4
+ module Dnsync
5
+ class RecurringZoneUpdater
6
+ def initialize(source, destination, frequency)
7
+ @source = source
8
+ @destination = destination
9
+ @frequency = frequency
10
+
11
+ @thread = Atomic.new(nil)
12
+ @running = Atomic.new(false)
13
+ @last_updated_at = Atomic.new(nil)
14
+ @last_exception = Atomic.new(nil)
15
+ end
16
+
17
+ def start
18
+ if (thread = @thread.value) && thread.alive?
19
+ return self
20
+ end
21
+
22
+ @running.value = true
23
+
24
+ @thread.value = Thread.new do
25
+ Thread.current.abort_on_exception = true
26
+ run
27
+ end
28
+ end
29
+
30
+ def stop
31
+ @running.value = false
32
+ self
33
+ end
34
+
35
+ def join
36
+ if thread = @thread.value
37
+ thread.join
38
+ end
39
+
40
+ self
41
+ end
42
+
43
+ def healthy?
44
+ health_problems.blank?
45
+ end
46
+
47
+ def health_problems
48
+ thread = @thread.value
49
+ running = @running.value
50
+ updated = last_updated
51
+ exception = @last_exception.value
52
+
53
+ problems = []
54
+
55
+ unless running
56
+ problems << "Component not running"
57
+ end
58
+
59
+ unless thread && thread.alive?
60
+ problems << "Thread not alive"
61
+ end
62
+
63
+ unless recently_updated?(updated)
64
+ if updated
65
+ time_description = "in %0.2f seconds (should have been %d seconds)" % [ updated.to_f, @frequency ]
66
+ else
67
+ time_description = "ever"
68
+ end
69
+
70
+ problems << "Successful update hasn't occured #{time_description}"
71
+ end
72
+
73
+ if exception
74
+ problems << "Last update failed with #{exception.class}: #{exception.message}"
75
+ end
76
+
77
+ unless problems.empty?
78
+ problems.join('; ')
79
+ end
80
+ end
81
+
82
+ def recently_updated?(updated = nil)
83
+ updated ||= last_updated
84
+ updated && updated < (@frequency * 2)
85
+ end
86
+
87
+ def last_updated
88
+ if at = @last_updated_at.value
89
+ Time.now.to_f - at.to_f
90
+ end
91
+ end
92
+
93
+ protected
94
+ def run
95
+ active_zone = nil
96
+
97
+ while @running.value
98
+ begin
99
+ source_zone = nil
100
+
101
+ Scrolls.log(:from => :recurring_zone_updater, :zone => @source.domain, :for => :source) do
102
+ source_zone = @source.zone
103
+ end
104
+
105
+ if !active_zone
106
+ Scrolls.log(:from => :recurring_zone_updater, :zone => @source.domain, :for => :destination) do
107
+ active_zone = @destination.zone
108
+ end
109
+ end
110
+
111
+ diff = ZoneDifference.new(active_zone, source_zone, %w(NS SOA))
112
+
113
+ Scrolls.log(:from => :recurring_zone_updater, :zone => @source.domain,
114
+ :action => :updating, :adding => diff.added.length,
115
+ :updating => diff.changed.length, :removing => diff.removed.length) do
116
+
117
+ updater = ZoneUpdater.new(diff, @destination)
118
+ updater.call
119
+ end
120
+
121
+ active_zone = source_zone
122
+ @last_updated_at.value = Time.now
123
+ @last_exception.value = nil
124
+ rescue => e
125
+ Scrolls.log_exception({ :from => :recurring_zone_updater, :zone => @source.domain }, e)
126
+ @last_exception.value = e
127
+ end
128
+
129
+ if @running.value
130
+ sleep_until_next_deadline
131
+ end
132
+ end
133
+ end
134
+
135
+ def sleep_until_next_deadline
136
+ if !@deadline
137
+ @deadline = Time.now.to_f
138
+ end
139
+
140
+ @deadline = @deadline + @frequency
141
+
142
+ sleep_duration = @deadline - Time.now.to_f
143
+
144
+ if sleep_duration <= 0
145
+ Scrolls.log(:from => :recurring_zone_updater, :for => :missed_deadline, :by => sleep_duration)
146
+ @deadline = Time.now.to_f
147
+ else
148
+ sleep sleep_duration
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,36 @@
1
+ require 'set'
2
+ require 'dnsync/record'
3
+
4
+
5
+ module Dnsync
6
+ class Zone
7
+ attr_reader :name
8
+
9
+ def initialize(name, records)
10
+ @name = name
11
+
12
+ @records_by_identifier = {}
13
+ records.each do |record|
14
+ @records_by_identifier[record.identifier] = record
15
+ end
16
+
17
+ freeze
18
+ end
19
+
20
+ def [](identifier)
21
+ @records_by_identifier[identifier]
22
+ end
23
+
24
+ def records_at(*identifiers)
25
+ @records_by_identifier.values_at(*identifiers.flatten)
26
+ end
27
+
28
+ def records
29
+ @records_by_identifier.values
30
+ end
31
+
32
+ def record_identifiers
33
+ @records_by_identifier.keys
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,52 @@
1
+ module Dnsync
2
+ class ZoneDifference
3
+ def initialize(original, updated, ignored_types = nil)
4
+ @original = original
5
+ @updated = updated
6
+ @ignored_types = ignored_types || []
7
+ end
8
+
9
+ def added
10
+ @added ||= begin
11
+ added_identifiers = @updated.record_identifiers - @original.record_identifiers
12
+ added_identifiers = filter_types(added_identifiers)
13
+ @updated.records_at(added_identifiers)
14
+ end
15
+ end
16
+
17
+ def changed
18
+ @changed ||= begin
19
+ overlapping_identifiers = @updated.record_identifiers & @original.record_identifiers
20
+ overlapping_identifiers = filter_types(overlapping_identifiers)
21
+
22
+ overlapping_identifiers.map do |identifier|
23
+ original_record = @original[identifier]
24
+ updated_record = @updated[identifier]
25
+
26
+ if original_record != updated_record
27
+ updated_record
28
+ else
29
+ nil
30
+ end
31
+ end.compact
32
+ end
33
+ end
34
+
35
+ def removed
36
+ @removed ||= begin
37
+ removed_identifiers = @original.record_identifiers - @updated.record_identifiers
38
+ removed_identifiers = filter_types(removed_identifiers)
39
+
40
+ @original.records_at(removed_identifiers)
41
+ end
42
+ end
43
+
44
+ def filter_types(identifiers)
45
+ if @ignored_types.blank?
46
+ return identifiers
47
+ end
48
+
49
+ identifiers.reject { |identifier| @ignored_types.include?(identifier.type) }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,22 @@
1
+ module Dnsync
2
+ class ZoneUpdater
3
+ def initialize(difference, target)
4
+ @difference = difference
5
+ @target = target
6
+ end
7
+
8
+ def call
9
+ @difference.added.each do |record|
10
+ @target.create_record(record)
11
+ end
12
+
13
+ @difference.changed.each do |record|
14
+ @target.update_record(record)
15
+ end
16
+
17
+ @difference.removed.each do |record|
18
+ @target.remove_record(record)
19
+ end
20
+ end
21
+ end
22
+ end
data/lib/dnsync.rb ADDED
@@ -0,0 +1,4 @@
1
+
2
+ module Dnsync
3
+ VERSION = '1.0.0'
4
+ end
@@ -0,0 +1,35 @@
1
+ require 'test_helper'
2
+
3
+ require 'set'
4
+
5
+ require 'dnsync/record_identifier'
6
+
7
+ class RecordIdentifierTest < Minitest::Test
8
+ def test_equality
9
+ a1 = Dnsync::RecordIdentifier.new("record1", "A")
10
+ a2 = Dnsync::RecordIdentifier.new("record1", "A")
11
+
12
+ assert a1 == a2
13
+ end
14
+
15
+ def test_eql
16
+ a1 = Dnsync::RecordIdentifier.new("record1", "A")
17
+ a2 = Dnsync::RecordIdentifier.new("record1", "A")
18
+
19
+ assert a1.eql?(a2)
20
+ end
21
+
22
+ def test_spaceship
23
+ a1 = Dnsync::RecordIdentifier.new("record1", "A")
24
+ a2 = Dnsync::RecordIdentifier.new("record1", "A")
25
+
26
+ assert (a1 <=> a2) == 0
27
+ end
28
+
29
+ def test_array_subtract
30
+ a1 = Dnsync::RecordIdentifier.new("record1", "A")
31
+ a2 = Dnsync::RecordIdentifier.new("record1", "A")
32
+
33
+ assert [a1] - [a2] == []
34
+ end
35
+ end
@@ -0,0 +1,28 @@
1
+ require 'test_helper'
2
+
3
+ require 'set'
4
+
5
+ require 'dnsync/record'
6
+
7
+ class RecordTest < Minitest::Test
8
+ def test_equality
9
+ a1 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
10
+ a2 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
11
+
12
+ assert a1 == a2
13
+ end
14
+
15
+ def test_inequality
16
+ a1 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
17
+ a2 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
18
+
19
+ assert !(a1 != a2)
20
+ end
21
+
22
+ def test_spaceship
23
+ a1 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
24
+ a2 = Dnsync::Record.new("record1", "a", 300, [ Dnsync::Answer.new("127.0.0.1", nil) ])
25
+
26
+ assert (a1 <=> a2) == 0
27
+ end
28
+ end
@@ -0,0 +1,5 @@
1
+ require 'minitest/autorun'
2
+
3
+ Thread.abort_on_exception = true
4
+
5
+ require 'dnsync'