record_store 0.3.0 → 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,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