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.
- 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
|