dnsync 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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'