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.
- checksums.yaml +4 -4
- data/.gitignore +3 -9
- data/Gemfile +19 -2
- data/Gemfile.lock +178 -0
- data/{LICENSE.txt → LICENSE} +5 -5
- data/README.md +131 -44
- data/Rakefile +18 -1
- data/bin/record-store +7 -0
- data/bin/setup +8 -2
- data/bin/test +5 -0
- data/circle.yml +8 -0
- data/lib/record_store.rb +48 -104
- data/lib/record_store/changeset.rb +85 -0
- data/lib/record_store/cli.rb +255 -0
- data/lib/record_store/provider.rb +102 -0
- data/lib/record_store/provider/dnsimple.rb +158 -0
- data/lib/record_store/provider/dynect.rb +97 -0
- data/lib/record_store/record.rb +70 -0
- data/lib/record_store/record/a.rb +32 -0
- data/lib/record_store/record/aaaa.rb +32 -0
- data/lib/record_store/record/alias.rb +20 -0
- data/lib/record_store/record/cname.rb +20 -0
- data/lib/record_store/record/mx.rb +25 -0
- data/lib/record_store/record/ns.rb +20 -0
- data/lib/record_store/record/spf.rb +20 -0
- data/lib/record_store/record/srv.rb +27 -0
- data/lib/record_store/record/txt.rb +29 -0
- data/lib/record_store/version.rb +3 -0
- data/lib/record_store/zone.rb +193 -0
- data/lib/record_store/zone/config.rb +24 -0
- data/record_store.gemspec +30 -14
- data/template/Gemfile +3 -0
- data/template/bin/record-store +7 -0
- data/template/bin/setup +3 -0
- data/template/bin/test +5 -0
- data/template/config.yml +5 -0
- data/template/secrets.json +11 -0
- data/template/zones/dnsimple.example.com.yml +37 -0
- data/template/zones/dynect.example.com.yml +37 -0
- metadata +208 -22
- data/.travis.yml +0 -3
- data/bin/console +0 -14
@@ -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
|