record_store 0.3.0 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,102 @@
1
+ module RecordStore
2
+ class Provider
3
+ def self.provider_for(zone_name)
4
+ dns = Resolv::DNS.new(nameserver: ['8.8.8.8', '8.8.4.4'])
5
+
6
+ begin
7
+ ns_server = dns.getresource(zone_name, Resolv::DNS::Resource::IN::SOA).mname.to_s
8
+ rescue Resolv::ResolvError => e
9
+ abort "Domain doesn't exist"
10
+ end
11
+
12
+ case ns_server
13
+ when /dnsimple\.com\z/
14
+ 'DNSimple'
15
+ when /dynect\.net\z/
16
+ 'DynECT'
17
+ else
18
+ nil
19
+ end
20
+ end
21
+
22
+ def self.record_types
23
+ Set.new([
24
+ 'A',
25
+ 'AAAA',
26
+ 'CNAME',
27
+ 'MX',
28
+ 'NS',
29
+ 'SPF',
30
+ 'SRV',
31
+ 'TXT',
32
+ ])
33
+ end
34
+
35
+ def initialize(zone:)
36
+ @zone_name = zone
37
+ end
38
+
39
+ def add(record)
40
+ raise NotImplementedError
41
+ end
42
+
43
+ def remove(record)
44
+ raise NotImplementedError
45
+ end
46
+
47
+ def update(id, record)
48
+ raise NotImplementedError
49
+ end
50
+
51
+ # Applies changeset to provider
52
+ def apply_changeset(changeset, stdout = $stdout)
53
+ begin
54
+ stdout.puts "Applying #{changeset.additions.size} additions, #{changeset.removals.size} removals, & #{changeset.updates.size} updates..."
55
+
56
+ changeset.changes.each do |change|
57
+ case change.type
58
+ when :removal;
59
+ stdout.puts "Removing #{change.record}..."
60
+ remove(change.record)
61
+ when :addition;
62
+ stdout.puts "Creating #{change.record}..."
63
+ add(change.record)
64
+ when :update;
65
+ stdout.puts "Updating record with ID #{change.id} to #{change.record}..."
66
+ update(change.id, change.record)
67
+ else
68
+ raise ArgumentError, "Unknown change type #{change.type.inspect}"
69
+ end
70
+ end
71
+
72
+ puts "\nPublished #{@zone_name} changes"
73
+ end
74
+ end
75
+
76
+ # returns an array of Record objects that match the records which exist in the provider
77
+ def retrieve_current_records(stdout = $stdout)
78
+ raise NotImplementedError
79
+ end
80
+
81
+ # Returns an array of the zones managed by provider as strings
82
+ def zones
83
+ raise NotImplementedError
84
+ end
85
+
86
+ def secrets
87
+ @secrets ||= if File.exists?(RecordStore.secrets_path)
88
+ JSON.parse(File.read(RecordStore.secrets_path))
89
+ else
90
+ raise "You don't have a secrets.json file set up!"
91
+ end
92
+ end
93
+
94
+ def thawable?
95
+ self.class.method_defined?(:thaw)
96
+ end
97
+
98
+ def freezable?
99
+ self.class.method_defined?(:freeze_zone)
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,158 @@
1
+ require 'fog/dnsimple'
2
+
3
+ module RecordStore
4
+ class Provider::DNSimple < Provider
5
+ def self.record_types
6
+ super.add('ALIAS')
7
+ end
8
+
9
+ def add(record)
10
+ record_hash = api_hash(record)
11
+ res = session.create_record(
12
+ @zone_name,
13
+ record_hash.fetch(:name),
14
+ record.type,
15
+ record_hash.fetch(:content),
16
+ ttl: record_hash.fetch(:ttl),
17
+ priority: record_hash.fetch(:prio, nil)
18
+ )
19
+
20
+ if record.type == 'ALIAS'
21
+ txt_alias = retrieve_current_records.detect do |rr|
22
+ rr.type == 'TXT' && rr.fqdn == record.fqdn && rr.txtdata == "ALIAS for #{record.cname.gsub(/.\z/, '')}"
23
+ end
24
+ remove(txt_alias)
25
+ end
26
+
27
+ res
28
+ end
29
+
30
+ def remove(record)
31
+ session.delete_record(@zone_name, record.id)
32
+ end
33
+
34
+ def update(id, record)
35
+ record_hash = api_hash(record)
36
+ session.update_record(@zone_name, id, api_hash(record))
37
+ end
38
+
39
+ # returns an array of Record objects that match the records which exist in the provider
40
+ def retrieve_current_records(stdout = $stdout)
41
+ session.list_records(@zone_name).body.map do |record|
42
+ record_body = record.fetch('record')
43
+
44
+ begin
45
+ build_from_api(record_body)
46
+ rescue StandardError
47
+ stdout.puts "Cannot build record: #{record_body}"
48
+ raise
49
+ end
50
+ end.select(&:present?)
51
+ end
52
+
53
+ # Returns an array of the zones managed by provider as strings
54
+ def zones
55
+ session.zones.map(&:domain)
56
+ end
57
+
58
+ private
59
+
60
+ def discard_change_set
61
+ session.request(expects: 200, method: :delete, path: "ZoneChanges/#{@zone_name}")
62
+ end
63
+
64
+ def session
65
+ @dns ||= Fog::DNS.new(session_params)
66
+ end
67
+
68
+ def session_params
69
+ {
70
+ provider: 'DNSimple',
71
+ dnsimple_email: secrets.fetch('email'),
72
+ dnsimple_token: secrets.fetch('api_token'),
73
+ }
74
+ end
75
+
76
+ def secrets
77
+ super.fetch('dnsimple')
78
+ end
79
+
80
+ def build_from_api(api_record)
81
+ record_type = api_record.fetch('record_type')
82
+ record = {
83
+ record_id: api_record.fetch('id'),
84
+ ttl: api_record.fetch('ttl'),
85
+ fqdn: api_record.fetch('name').present? ? "#{api_record.fetch('name')}.#{@zone_name}" : @zone_name,
86
+ }
87
+
88
+ return if record_type == 'SOA'
89
+
90
+ case record_type
91
+ when 'A'
92
+ record.merge!(address: api_record.fetch('content'))
93
+ when 'AAAA'
94
+ record.merge!(address: api_record.fetch('content'))
95
+ when 'ALIAS'
96
+ record.merge!(cname: api_record.fetch('content'))
97
+ when 'CNAME'
98
+ record.merge!(cname: api_record.fetch('content'))
99
+ when 'MX'
100
+ record.merge!(preference: api_record.fetch('prio'), exchange: api_record.fetch('content'))
101
+ when 'NS'
102
+ record.merge!(nsdname: api_record.fetch('content'))
103
+ when 'SPF'
104
+ record.merge!(txtdata: api_record.fetch('content'))
105
+ when 'SRV'
106
+ weight, port, host = api_record.fetch('content').split(' ')
107
+
108
+ record.merge!(
109
+ priority: api_record.fetch('prio'),
110
+ weight: weight,
111
+ port: port,
112
+ target: Record.ensure_ends_with_dot(host),
113
+ )
114
+ when 'TXT'
115
+ record.merge!(txtdata: api_record.fetch('content'))
116
+ end
117
+
118
+ unless record.fetch(:fqdn).ends_with?('.')
119
+ record[:fqdn] += '.'
120
+ end
121
+
122
+ Record.const_get(record_type).new(record)
123
+ end
124
+
125
+ def api_hash(record)
126
+ record_hash = {
127
+ name: record.fqdn.gsub("#{Record.ensure_ends_with_dot(@zone_name)}", '').gsub(/.\z/, ''),
128
+ ttl: record.ttl,
129
+ type: record.type,
130
+ }
131
+
132
+ case record.type
133
+ when 'A'
134
+ record_hash[:content] = record.address
135
+ when 'AAAA'
136
+ record_hash[:content] = record.address
137
+ when 'ALIAS'
138
+ record_hash[:content] = record.cname.gsub(/.\z/, '')
139
+ when 'CNAME'
140
+ record_hash[:content] = record.cname.gsub(/.\z/, '')
141
+ when 'MX'
142
+ record_hash[:prio] = record.preference
143
+ record_hash[:content] = record.exchange.gsub(/.\z/, '')
144
+ when 'NS'
145
+ record_hash[:content] = record.nsdname.gsub(/.\z/, '')
146
+ when 'SPF'
147
+ record_hash[:content] = record.txtdata
148
+ when 'SRV'
149
+ record_hash[:content] = "#{record.weight} #{record.port} #{record.target.gsub(/.\z/, '')}"
150
+ record_hash[:prio] = record.priority
151
+ when 'TXT'
152
+ record_hash[:content] = record.txtdata
153
+ end
154
+
155
+ record_hash
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,97 @@
1
+ require 'fog/dynect'
2
+
3
+ module RecordStore
4
+ class Provider::DynECT < Provider
5
+ def freeze_zone
6
+ session.put_zone(@zone_name, freeze: true)
7
+ end
8
+
9
+ def thaw
10
+ session.put_zone(@zone_name, thaw: true)
11
+ end
12
+
13
+ def add(record)
14
+ session.post_record(record.type, @zone_name, record.fqdn, record.rdata, ttl: record.ttl)
15
+ end
16
+
17
+ def remove(record)
18
+ session.delete_record(record.type, @zone_name, record.fqdn, record.id)
19
+ end
20
+
21
+ def update(id, record)
22
+ session.put_record(record.type, @zone_name, record.fqdn, record.rdata, ttl: record.ttl, record_id: id)
23
+ end
24
+
25
+ def publish
26
+ session.put_zone(@zone_name, publish: true)
27
+ end
28
+
29
+ # Applies changeset to provider
30
+ def apply_changeset(changeset, stdout = $stdout)
31
+ begin
32
+ thaw
33
+ super
34
+ publish
35
+ rescue StandardError
36
+ puts "An exception occurred while applying DNS changes, deleting changeset"
37
+ discard_change_set
38
+ raise
39
+ ensure
40
+ freeze_zone
41
+ end
42
+ end
43
+
44
+ # returns an array of Record objects that match the records which exist in the provider
45
+ def retrieve_current_records(stdout = $stdout)
46
+ session.get_all_records(@zone_name).body.fetch('data').flat_map do |type, records|
47
+ records.map do |record_body|
48
+ begin
49
+ build_from_api(record_body)
50
+ rescue StandardError => e
51
+ stdout.puts "Cannot build record: #{record_body}"
52
+ end
53
+ end
54
+ end.select(&:present?)
55
+ end
56
+
57
+ # Returns an array of the zones managed by provider as strings
58
+ def zones
59
+ session.zones.map(&:domain)
60
+ end
61
+
62
+ private
63
+
64
+ def discard_change_set
65
+ session.request(expects: 200, method: :delete, path: "ZoneChanges/#{@zone_name}")
66
+ end
67
+
68
+ def session
69
+ @dns ||= Fog::DNS.new(session_params)
70
+ end
71
+
72
+ def session_params
73
+ {
74
+ provider: 'Dynect',
75
+ dynect_customer: secrets.fetch('customer'),
76
+ dynect_username: secrets.fetch('username'),
77
+ dynect_password: secrets.fetch('password')
78
+ }
79
+ end
80
+
81
+ def secrets
82
+ super.fetch('dynect')
83
+ end
84
+
85
+ def build_from_api(api_record)
86
+ record = api_record.merge(api_record.fetch('rdata')).slice!('rdata').symbolize_keys
87
+
88
+ return if record.fetch(:record_type) == 'SOA'
89
+
90
+ unless record.fetch(:fqdn).ends_with?('.')
91
+ record[:fqdn] = "#{record.fetch(:fqdn)}."
92
+ end
93
+
94
+ Record.const_get(record.fetch(:record_type)).new(record)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,70 @@
1
+ module RecordStore
2
+ class Record
3
+ FQDN_REGEX = /\A(\*\.)?([a-z0-9_]+(-[a-z0-9]+)*\._?)+[a-z]{2,}\.\Z/i
4
+ CNAME_REGEX = /\A(\*\.)?([a-z0-9]+(-[a-z0-9]+)*\._?)+[a-z]{2,}\.\Z/i
5
+
6
+ include ActiveModel::Validations
7
+
8
+ attr_accessor :fqdn, :ttl, :id
9
+
10
+ validates :ttl, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 2147483647 }
11
+ validates :fqdn, format: { with: Record::FQDN_REGEX }, length: { maximum: 254 }
12
+ validate :validate_label_length
13
+
14
+ def initialize(record)
15
+ @fqdn = Record.ensure_ends_with_dot(record.fetch(:fqdn))
16
+ @ttl = record.fetch(:ttl)
17
+ @id = record.fetch(:record_id, nil)
18
+ end
19
+
20
+ def self.build_from_yaml_definition(yaml_definition)
21
+ Record.const_get(yaml_definition.fetch(:type)).new(yaml_definition)
22
+ end
23
+
24
+ def log!(logger=STDOUT)
25
+ logger.puts to_s
26
+ end
27
+
28
+ def to_hash
29
+ {
30
+ type: type,
31
+ fqdn: fqdn,
32
+ ttl: ttl
33
+ }.merge(rdata)
34
+ end
35
+
36
+ def type
37
+ self.class.name.split('::').last
38
+ end
39
+
40
+ def ==(other)
41
+ other.class == self.class && other.to_hash == self.to_hash
42
+ end
43
+
44
+ alias_method :eql?, :==
45
+
46
+ def hash
47
+ to_hash.hash
48
+ end
49
+
50
+ def to_json
51
+ { ttl: ttl, rdata: rdata }
52
+ end
53
+
54
+ def key
55
+ "#{type},#{fqdn}"
56
+ end
57
+
58
+ protected
59
+
60
+ def validate_label_length
61
+ unless fqdn.split('.').all? { |label| label.length <= 63 }
62
+ errors.add(:fqdn, "A label should be at most 63 characters")
63
+ end
64
+ end
65
+
66
+ def self.ensure_ends_with_dot(fqdn)
67
+ fqdn.end_with?(".") ? fqdn : "#{fqdn}."
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,32 @@
1
+ module RecordStore
2
+ class Record::A < Record
3
+ attr_accessor :address
4
+
5
+ validates_presence_of :address
6
+ validate :valid_address?
7
+
8
+ def initialize(record)
9
+ super
10
+ @address = record.fetch(:address)
11
+ end
12
+
13
+ def to_s
14
+ "[ARecord] #{fqdn} #{ttl} IN A #{address}"
15
+ end
16
+
17
+ def rdata
18
+ { address: address }
19
+ end
20
+
21
+ private
22
+
23
+ def valid_address?
24
+ begin
25
+ ip = IPAddr.new(address)
26
+ errors.add(:address, 'is not an IPv4 address') unless ip.ipv4?
27
+ rescue IPAddr::InvalidAddressError => e
28
+ errors.add(:address, 'is invalid')
29
+ end
30
+ end
31
+ end
32
+ end