zonesync 0.6.1 → 0.8.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +1 -1
- data/README.md +6 -0
- data/lib/zonesync/cli.rb +3 -0
- data/lib/zonesync/cloudflare.rb +33 -10
- data/lib/zonesync/diff.rb +7 -5
- data/lib/zonesync/errors.rb +45 -0
- data/lib/zonesync/http.rb +15 -5
- data/lib/zonesync/logger.rb +3 -4
- data/lib/zonesync/manifest.rb +97 -0
- data/lib/zonesync/provider.rb +20 -8
- data/lib/zonesync/record.rb +28 -7
- data/lib/zonesync/route53.rb +140 -0
- data/lib/zonesync/validator.rb +45 -0
- data/lib/zonesync/version.rb +1 -1
- data/lib/zonesync/zonefile.rb +323 -0
- data/lib/zonesync/zonefile.treetop +586 -0
- data/lib/zonesync.rb +29 -1
- data/zonesync.gemspec +1 -1
- metadata +18 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a674bd1519c4b8bd6a30814b43e70365191c18a59b24615e0ed93bbf8f79c286
|
4
|
+
data.tar.gz: c929821a57474a2d4be05f39f2e24e0cef38ab1484d5dbe6d8fbefba05ec60fc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e7678dc403b138a88de0d30a19a980c6c9d23ec3dcb6aa89157530cfc2e260b9e2dd610a1461b3d09174d3c70246c35d34d418a51802cc3c52cd651e5a42d1b2
|
7
|
+
data.tar.gz: 42bf9f0ba6346af233735e236e0cc39e69adc3caf9e41901f8aeddc6936cfdf602086b9a0b3b2999bc8852c493f72a453d66106c8543bc9c95b218caaae35a53
|
data/.rspec
CHANGED
@@ -1,2 +1,2 @@
|
|
1
1
|
--color
|
2
|
-
--require
|
2
|
+
--require spec_helper
|
data/README.md
CHANGED
@@ -95,3 +95,9 @@ require 'zonesync'
|
|
95
95
|
Zonesync.call(zonefile: 'hostfile.txt', credentials: YAML.load('provider.yml'))
|
96
96
|
```
|
97
97
|
|
98
|
+
### Managing or avoiding conflicts with other people making edits to the DNS records
|
99
|
+
|
100
|
+
Zonesync writes two additional TXT records: `zonesync_manifest` and `zonesync_checksum`. These two records together try to handle the situation where someone else makes edits directly to the DNS records managed by zonesync.
|
101
|
+
* `zonesync_manifest`: a short list of all the records that zonesync is aware of and managing. If a record appears in the DNS records that is not in the manifest, zonesync will simply ignore it. This makes it possible to coexist with other editors, provided they don't touch the records managed by zonesync. If they do, we have a `zonesync_checksum` to detect that.
|
102
|
+
* `zonesync_checksum`: a fingerprint of the state of the managed records upon last save. If the checksum doesn't match the current state of the managed records, zonesync will refuse to save the new state. This is a safety measure to avoid overwriting changes made by other editors, and also to alert the user that the records have been changed outside of zonesync.
|
103
|
+
|
data/lib/zonesync/cli.rb
CHANGED
@@ -7,6 +7,9 @@ module Zonesync
|
|
7
7
|
method_option :dry_run, type: :boolean, default: false, aliases: :n, desc: "log operations to STDOUT but don't perform the sync"
|
8
8
|
def sync
|
9
9
|
Zonesync.call dry_run: options[:dry_run]
|
10
|
+
rescue ConflictError, MissingManifestError, ChecksumMismatchError => e
|
11
|
+
puts e.message
|
12
|
+
exit 1
|
10
13
|
end
|
11
14
|
|
12
15
|
desc "generate", "generates a Zonefile from the DNS server configured in Rails.application.credentials.zonesync"
|
data/lib/zonesync/cloudflare.rb
CHANGED
@@ -4,21 +4,24 @@ require "zonesync/http"
|
|
4
4
|
module Zonesync
|
5
5
|
class Cloudflare < Provider
|
6
6
|
def read
|
7
|
-
|
7
|
+
([fake_soa] + all.keys.map do |hash|
|
8
|
+
Record.new(hash)
|
9
|
+
end).map(&:to_s).join("\n") + "\n"
|
8
10
|
end
|
9
11
|
|
10
12
|
def remove record
|
11
|
-
id =
|
13
|
+
id = all.fetch(record.to_h)
|
12
14
|
http.delete("/#{id}")
|
13
15
|
end
|
14
16
|
|
15
17
|
def change old_record, new_record
|
16
|
-
id =
|
18
|
+
id = all.fetch(old_record.to_h)
|
17
19
|
http.patch("/#{id}", {
|
18
20
|
name: new_record[:name],
|
19
21
|
type: new_record[:type],
|
20
22
|
ttl: new_record[:ttl],
|
21
23
|
content: new_record[:rdata],
|
24
|
+
comment: new_record[:comment],
|
22
25
|
})
|
23
26
|
end
|
24
27
|
|
@@ -28,11 +31,12 @@ module Zonesync
|
|
28
31
|
type: record[:type],
|
29
32
|
ttl: record[:ttl],
|
30
33
|
content: record[:rdata],
|
34
|
+
comment: record[:comment],
|
31
35
|
})
|
32
36
|
end
|
33
37
|
|
34
|
-
def
|
35
|
-
@
|
38
|
+
def all
|
39
|
+
@all ||= begin
|
36
40
|
response = http.get(nil)
|
37
41
|
response["result"].reduce({}) do |map, attrs|
|
38
42
|
map.merge to_record(attrs) => attrs["id"]
|
@@ -47,14 +51,21 @@ module Zonesync
|
|
47
51
|
if %w[CNAME MX].include?(attrs["type"])
|
48
52
|
rdata = normalize_trailing_period(rdata)
|
49
53
|
end
|
54
|
+
if attrs["type"] == "MX"
|
55
|
+
rdata = "#{attrs["priority"]} #{rdata}"
|
56
|
+
end
|
50
57
|
if %w[TXT SPF NAPTR].include?(attrs["type"])
|
51
58
|
rdata = normalize_quoting(rdata)
|
52
59
|
end
|
60
|
+
if attrs["type"] == "TXT"
|
61
|
+
rdata = normalize_quoting(rdata)
|
62
|
+
end
|
53
63
|
Record.new(
|
54
|
-
normalize_trailing_period(attrs["name"]),
|
55
|
-
attrs["type"],
|
56
|
-
attrs["ttl"].to_i,
|
57
|
-
rdata
|
64
|
+
name: normalize_trailing_period(attrs["name"]),
|
65
|
+
type: attrs["type"],
|
66
|
+
ttl: attrs["ttl"].to_i,
|
67
|
+
rdata:,
|
68
|
+
comment: attrs["comment"],
|
58
69
|
).to_h
|
59
70
|
end
|
60
71
|
|
@@ -63,7 +74,19 @@ module Zonesync
|
|
63
74
|
end
|
64
75
|
|
65
76
|
def normalize_quoting value
|
66
|
-
value =~ /^".+"$/ ? value : %("#{value}")
|
77
|
+
value =~ /^".+"$/ ? value : %("#{value}") # handle quote wrapping
|
78
|
+
value.gsub('" "', "") # handle multiple txt record joining
|
79
|
+
end
|
80
|
+
|
81
|
+
def fake_soa
|
82
|
+
zone_name = http.get("/..")["result"]["name"]
|
83
|
+
Record.new(
|
84
|
+
name: normalize_trailing_period(zone_name),
|
85
|
+
type: "SOA",
|
86
|
+
ttl: 1,
|
87
|
+
rdata: "#{zone_name} admin.#{zone_name} 2000010101 1 1 1 1",
|
88
|
+
comment: nil,
|
89
|
+
)
|
67
90
|
end
|
68
91
|
|
69
92
|
def http
|
data/lib/zonesync/diff.rb
CHANGED
@@ -7,17 +7,19 @@ module Zonesync
|
|
7
7
|
end
|
8
8
|
|
9
9
|
def call
|
10
|
-
changes = ::Diff::LCS.sdiff(from
|
10
|
+
changes = ::Diff::LCS.sdiff(from, to)
|
11
11
|
changes.map do |change|
|
12
12
|
case change.action
|
13
13
|
when "-"
|
14
|
-
[:remove, [change.old_element
|
14
|
+
[:remove, [change.old_element]]
|
15
15
|
when "!"
|
16
|
-
[:change, [change.old_element
|
16
|
+
[:change, [change.old_element, change.new_element]]
|
17
17
|
when "+"
|
18
|
-
[:add, [change.new_element
|
18
|
+
[:add, [change.new_element]]
|
19
19
|
end
|
20
|
-
end.compact
|
20
|
+
end.compact.sort_by do |operation|
|
21
|
+
operation.first
|
22
|
+
end.reverse # perform remove operations first
|
21
23
|
end
|
22
24
|
end
|
23
25
|
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Zonesync
|
2
|
+
class ConflictError < StandardError
|
3
|
+
def initialize existing, new
|
4
|
+
@existing = existing
|
5
|
+
@new = new
|
6
|
+
end
|
7
|
+
|
8
|
+
def message
|
9
|
+
<<~MSG
|
10
|
+
The following untracked DNS record already exists and would be overwritten.
|
11
|
+
existing: #{@existing}
|
12
|
+
new: #{@new}
|
13
|
+
MSG
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
class MissingManifestError < StandardError
|
18
|
+
def initialize manifest
|
19
|
+
@manifest = manifest
|
20
|
+
end
|
21
|
+
|
22
|
+
def message
|
23
|
+
<<~MSG
|
24
|
+
The zonesync_manifest TXT record is missing. If this is the very first sync, make sure the Zonefile matches what's on the DNS server exactly. Otherwise, someone else may have removed it.
|
25
|
+
manifest: #{@manifest}
|
26
|
+
MSG
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
class ChecksumMismatchError < StandardError
|
31
|
+
def initialize existing, new
|
32
|
+
@existing = existing
|
33
|
+
@new = new
|
34
|
+
end
|
35
|
+
|
36
|
+
def message
|
37
|
+
<<~MSG
|
38
|
+
The zonesync_checksum TXT record does not match the current state of the DNS records. This probably means that someone else has changed them.
|
39
|
+
existing: #{@existing}
|
40
|
+
new: #{@new}
|
41
|
+
MSG
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
data/lib/zonesync/http.rb
CHANGED
@@ -3,6 +3,12 @@ require "json"
|
|
3
3
|
|
4
4
|
module Zonesync
|
5
5
|
class HTTP < Struct.new(:base)
|
6
|
+
def initialize(...)
|
7
|
+
super
|
8
|
+
@before_request = []
|
9
|
+
@after_response = []
|
10
|
+
end
|
11
|
+
|
6
12
|
def get path
|
7
13
|
request("get", path)
|
8
14
|
end
|
@@ -20,25 +26,29 @@ module Zonesync
|
|
20
26
|
end
|
21
27
|
|
22
28
|
def before_request &block
|
23
|
-
@before_request
|
29
|
+
@before_request << block
|
24
30
|
end
|
25
31
|
|
26
32
|
def after_response &block
|
27
|
-
@after_response
|
33
|
+
@after_response << block
|
28
34
|
end
|
29
35
|
|
30
36
|
def request method, path, body=nil
|
31
37
|
uri = URI.parse("#{base}#{path}")
|
32
38
|
request = Net::HTTP.const_get(method.to_s.capitalize).new(uri.path)
|
33
39
|
|
34
|
-
@before_request.
|
40
|
+
@before_request.each do |block|
|
41
|
+
block.call(request, uri, body)
|
42
|
+
end
|
35
43
|
|
36
44
|
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
|
37
|
-
body = JSON.dump(body) if request
|
45
|
+
body = JSON.dump(body) if request.fetch("Content-Type", "").include?("application/json")
|
38
46
|
http.request(request, body)
|
39
47
|
end
|
40
48
|
|
41
|
-
@after_response.
|
49
|
+
@after_response.each do |block|
|
50
|
+
call(response)
|
51
|
+
end
|
42
52
|
|
43
53
|
raise response.body unless response.code =~ /^20.$/
|
44
54
|
if response["Content-Type"].include?("application/json")
|
data/lib/zonesync/logger.rb
CHANGED
@@ -13,11 +13,10 @@ class Logger
|
|
13
13
|
loggers.each do |logger|
|
14
14
|
operation = case args
|
15
15
|
when Array
|
16
|
-
args.
|
17
|
-
|
18
|
-
args.values.join(" ")
|
16
|
+
(args.length == 2 ? "\n" : "") +
|
17
|
+
args.map { |r| r.to_h.values.join(" ") }.join("->\n")
|
19
18
|
else
|
20
|
-
|
19
|
+
args.to_h.values.join(" ")
|
21
20
|
end
|
22
21
|
logger.info "Zonesync: #{method.capitalize} #{operation}"
|
23
22
|
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
require "zonesync/record"
|
2
|
+
require "digest"
|
3
|
+
|
4
|
+
module Zonesync
|
5
|
+
class Manifest < Struct.new(:records, :zone)
|
6
|
+
DIFFABLE_RECORD_TYPES =
|
7
|
+
%w[A AAAA CNAME MX TXT SPF NAPTR PTR].sort
|
8
|
+
|
9
|
+
def existing
|
10
|
+
records.find(&:manifest?)
|
11
|
+
end
|
12
|
+
|
13
|
+
def existing?
|
14
|
+
!!existing
|
15
|
+
end
|
16
|
+
|
17
|
+
def generate
|
18
|
+
Record.new(
|
19
|
+
name: "zonesync_manifest.#{zone.origin}",
|
20
|
+
type: "TXT",
|
21
|
+
ttl: 3600,
|
22
|
+
rdata: generate_rdata,
|
23
|
+
comment: nil,
|
24
|
+
)
|
25
|
+
end
|
26
|
+
|
27
|
+
def existing_checksum
|
28
|
+
records.find(&:checksum?)
|
29
|
+
end
|
30
|
+
|
31
|
+
def generate_checksum
|
32
|
+
input_string = diffable_records.map(&:to_s).join
|
33
|
+
sha256 = Digest::SHA256.hexdigest(input_string)
|
34
|
+
Record.new(
|
35
|
+
name: "zonesync_checksum.#{zone.origin}",
|
36
|
+
type: "TXT",
|
37
|
+
ttl: 3600,
|
38
|
+
rdata: sha256.inspect,
|
39
|
+
comment: nil,
|
40
|
+
)
|
41
|
+
end
|
42
|
+
|
43
|
+
def diffable? record
|
44
|
+
if existing?
|
45
|
+
matches?(record)
|
46
|
+
else
|
47
|
+
DIFFABLE_RECORD_TYPES.include?(record.type)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def matches? record
|
52
|
+
return false unless existing?
|
53
|
+
hash = existing
|
54
|
+
.rdata[1..-2] # remove quotes
|
55
|
+
.split(";")
|
56
|
+
.reduce({}) do |hash, pair|
|
57
|
+
type, short_names = pair.split(":")
|
58
|
+
hash[type] = short_names.split(",")
|
59
|
+
hash
|
60
|
+
end
|
61
|
+
shorthands = hash.fetch(record.type, [])
|
62
|
+
shorthands.include?(shorthand_for(record))
|
63
|
+
end
|
64
|
+
|
65
|
+
def shorthand_for record, with_type: false
|
66
|
+
shorthand = record.short_name(zone.origin)
|
67
|
+
shorthand = "#{record.type}:#{shorthand}" if with_type
|
68
|
+
if record.type == "MX"
|
69
|
+
shorthand += " #{record.rdata[/^\d+/]}"
|
70
|
+
end
|
71
|
+
shorthand
|
72
|
+
end
|
73
|
+
|
74
|
+
private
|
75
|
+
|
76
|
+
def generate_rdata
|
77
|
+
generate_manifest.map do |type, short_names|
|
78
|
+
"#{type}:#{short_names.join(",")}"
|
79
|
+
end.join(";").inspect
|
80
|
+
end
|
81
|
+
|
82
|
+
def diffable_records
|
83
|
+
records.select do |record|
|
84
|
+
diffable?(record)
|
85
|
+
end.sort
|
86
|
+
end
|
87
|
+
|
88
|
+
def generate_manifest
|
89
|
+
diffable_records.reduce({}) do |hash, record|
|
90
|
+
hash[record.type] ||= []
|
91
|
+
hash[record.type] << shorthand_for(record)
|
92
|
+
hash[record.type].sort!
|
93
|
+
hash
|
94
|
+
end.sort_by(&:first)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
data/lib/zonesync/provider.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
|
-
require "dns/zonefile"
|
2
1
|
require "zonesync/record"
|
2
|
+
require "zonesync/zonefile"
|
3
|
+
require "zonesync/manifest"
|
3
4
|
|
4
5
|
module Zonesync
|
5
6
|
class Provider < Struct.new(:credentials)
|
@@ -8,20 +9,30 @@ module Zonesync
|
|
8
9
|
Zonesync.const_get(credentials[:provider]).new(credentials)
|
9
10
|
end
|
10
11
|
|
11
|
-
def
|
12
|
+
def records
|
12
13
|
zonefile.records.map do |record|
|
13
14
|
Record.from_dns_zonefile_record(record)
|
14
|
-
end
|
15
|
-
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def diffable_records
|
19
|
+
records.select do |record|
|
20
|
+
manifest.diffable?(record)
|
16
21
|
end.sort
|
17
22
|
end
|
18
23
|
|
24
|
+
def manifest
|
25
|
+
@manifest ||= Manifest.new(records, zonefile)
|
26
|
+
end
|
27
|
+
|
19
28
|
private def zonefile
|
20
|
-
|
21
|
-
|
22
|
-
body
|
29
|
+
@zonefile ||= begin
|
30
|
+
body = read
|
31
|
+
if body !~ /\sSOA\s/ # insert dummy SOA to trick parser if needed
|
32
|
+
body.sub!(/\n([^$])/, "\n@ 1 SOA example.com example.com ( 2000010101 1 1 1 1 )\n\\1")
|
33
|
+
end
|
34
|
+
Zonefile.load(body)
|
23
35
|
end
|
24
|
-
DNS::Zonefile.load(body)
|
25
36
|
end
|
26
37
|
|
27
38
|
def read record
|
@@ -46,6 +57,7 @@ module Zonesync
|
|
46
57
|
end
|
47
58
|
|
48
59
|
require "zonesync/cloudflare"
|
60
|
+
require "zonesync/route53"
|
49
61
|
|
50
62
|
class Memory < Provider
|
51
63
|
def read
|
data/lib/zonesync/record.rb
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
module Zonesync
|
2
|
-
class Record < Struct.new(:name, :type, :ttl, :rdata)
|
2
|
+
class Record < Struct.new(:name, :type, :ttl, :rdata, :comment, keyword_init: true)
|
3
3
|
def self.from_dns_zonefile_record record
|
4
4
|
type = record.class.name.split("::").last
|
5
5
|
rdata = case type
|
@@ -19,23 +19,44 @@ module Zonesync
|
|
19
19
|
end
|
20
20
|
|
21
21
|
new(
|
22
|
-
record.host,
|
23
|
-
type
|
24
|
-
record.ttl,
|
25
|
-
rdata
|
22
|
+
name: record.host,
|
23
|
+
type:,
|
24
|
+
ttl: record.ttl,
|
25
|
+
rdata:,
|
26
|
+
comment: record.comment,
|
26
27
|
)
|
27
28
|
end
|
28
29
|
|
30
|
+
def short_name origin
|
31
|
+
ret = name.sub(origin, "")
|
32
|
+
ret = ret.sub(/\.$/, "")
|
33
|
+
ret = "@" if ret == ""
|
34
|
+
ret
|
35
|
+
end
|
36
|
+
|
37
|
+
def manifest?
|
38
|
+
type == "TXT" &&
|
39
|
+
name =~ /^zonesync_manifest\./
|
40
|
+
end
|
41
|
+
|
42
|
+
def checksum?
|
43
|
+
type == "TXT" &&
|
44
|
+
name =~ /^zonesync_checksum\./
|
45
|
+
end
|
46
|
+
|
29
47
|
def <=> other
|
30
48
|
to_sortable <=> other.to_sortable
|
31
49
|
end
|
32
50
|
|
33
51
|
def to_sortable
|
34
|
-
|
52
|
+
is_soa = type == "SOA" ? 0 : 1
|
53
|
+
[is_soa, type, name, rdata, ttl]
|
35
54
|
end
|
36
55
|
|
37
56
|
def to_s
|
38
|
-
|
57
|
+
string = [name, ttl, type, rdata].join(" ")
|
58
|
+
string << " ; #{comment}" if comment
|
59
|
+
string
|
39
60
|
end
|
40
61
|
end
|
41
62
|
end
|
@@ -0,0 +1,140 @@
|
|
1
|
+
require "zonesync/record"
|
2
|
+
require "zonesync/http"
|
3
|
+
require "rexml/document"
|
4
|
+
|
5
|
+
module Zonesync
|
6
|
+
class Route53 < Provider
|
7
|
+
def read
|
8
|
+
doc = REXML::Document.new(http.get(nil))
|
9
|
+
records = doc.elements.collect("*/ResourceRecordSets/ResourceRecordSet") do |record_set|
|
10
|
+
to_records(record_set)
|
11
|
+
end.flatten.sort
|
12
|
+
records.map(&:to_s).join("\n") + "\n"
|
13
|
+
end
|
14
|
+
|
15
|
+
def remove(record)
|
16
|
+
change_record("DELETE", record)
|
17
|
+
end
|
18
|
+
|
19
|
+
def change(old_record, new_record)
|
20
|
+
remove(old_record)
|
21
|
+
add(new_record)
|
22
|
+
end
|
23
|
+
|
24
|
+
def add(record)
|
25
|
+
change_record("CREATE", record)
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def change_record(action, record)
|
31
|
+
http.post(nil, <<~XML)
|
32
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
33
|
+
<ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
|
34
|
+
<ChangeBatch>
|
35
|
+
<Changes>
|
36
|
+
<Change>
|
37
|
+
<Action>#{action}</Action>
|
38
|
+
<ResourceRecordSet>
|
39
|
+
<Name>#{record[:name]}</Name>
|
40
|
+
<Type>#{record[:type]}</Type>
|
41
|
+
<TTL>#{record[:ttl]}</TTL>
|
42
|
+
<ResourceRecords>
|
43
|
+
<ResourceRecord>
|
44
|
+
<Value>#{record[:rdata]}</Value>
|
45
|
+
</ResourceRecord>
|
46
|
+
</ResourceRecords>
|
47
|
+
</ResourceRecordSet>
|
48
|
+
</Change>
|
49
|
+
</Changes>
|
50
|
+
</ChangeBatch>
|
51
|
+
</ChangeResourceRecordSetsRequest>
|
52
|
+
XML
|
53
|
+
end
|
54
|
+
|
55
|
+
def to_records(el)
|
56
|
+
el.elements.collect("ResourceRecords/ResourceRecord") do |rr|
|
57
|
+
name = normalize_trailing_period(get_value(el, "Name"))
|
58
|
+
type = get_value(el, "Type")
|
59
|
+
rdata = get_value(rr, "Value")
|
60
|
+
|
61
|
+
record = Record.new(
|
62
|
+
name:,
|
63
|
+
type:,
|
64
|
+
ttl: get_value(el, "TTL"),
|
65
|
+
rdata:,
|
66
|
+
comment: nil, # Route 53 does not have a direct comment field
|
67
|
+
)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def get_value el, field
|
72
|
+
el.elements[field].text.gsub(/\\(\d{3})/) { $1.to_i(8).chr } # unescape octal
|
73
|
+
end
|
74
|
+
|
75
|
+
def from_record(record)
|
76
|
+
{
|
77
|
+
Name: normalize_trailing_period(record[:name]),
|
78
|
+
Type: record[:type],
|
79
|
+
TTL: record[:ttl],
|
80
|
+
ResourceRecords: record[:rdata].split(",").map { |value| { Value: value } }
|
81
|
+
}
|
82
|
+
end
|
83
|
+
|
84
|
+
def normalize_trailing_period(value)
|
85
|
+
value =~ /\.$/ ? value : value + "."
|
86
|
+
end
|
87
|
+
|
88
|
+
def http
|
89
|
+
return @http if @http
|
90
|
+
@http = HTTP.new("https://route53.amazonaws.com/2013-04-01/hostedzone/#{credentials.fetch(:hosted_zone_id)}/rrset")
|
91
|
+
@http.before_request do |request, uri, body|
|
92
|
+
request["Content-Type"] = "application/xml"
|
93
|
+
request["X-Amz-Date"] = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
94
|
+
request["Authorization"] = sign_request(request.method, uri, body)
|
95
|
+
end
|
96
|
+
@http
|
97
|
+
end
|
98
|
+
|
99
|
+
def sign_request(method, uri, body)
|
100
|
+
service = "route53"
|
101
|
+
date = Time.now.utc.strftime("%Y%m%d")
|
102
|
+
amz_date = Time.now.utc.strftime("%Y%m%dT%H%M%SZ")
|
103
|
+
canonical_uri = uri.path
|
104
|
+
canonical_querystring = uri.query.to_s
|
105
|
+
canonical_headers = "host:#{uri.host}\n" + "x-amz-date:#{amz_date}\n"
|
106
|
+
signed_headers = "host;x-amz-date"
|
107
|
+
payload_hash = OpenSSL::Digest::SHA256.hexdigest(body || "")
|
108
|
+
canonical_request = [
|
109
|
+
method,
|
110
|
+
canonical_uri,
|
111
|
+
canonical_querystring,
|
112
|
+
canonical_headers,
|
113
|
+
signed_headers,
|
114
|
+
payload_hash
|
115
|
+
].join("\n")
|
116
|
+
|
117
|
+
algorithm = "AWS4-HMAC-SHA256"
|
118
|
+
credential_scope = "#{date}/#{credentials.fetch(:aws_region)}/#{service}/aws4_request"
|
119
|
+
string_to_sign = [
|
120
|
+
algorithm,
|
121
|
+
amz_date,
|
122
|
+
credential_scope,
|
123
|
+
OpenSSL::Digest::SHA256.hexdigest(canonical_request)
|
124
|
+
].join("\n")
|
125
|
+
|
126
|
+
signing_key = get_signature_key(credentials.fetch(:aws_secret_access_key), date, credentials.fetch(:aws_region), service)
|
127
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
|
128
|
+
|
129
|
+
"#{algorithm} Credential=#{credentials.fetch(:aws_access_key_id)}/#{credential_scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
|
130
|
+
end
|
131
|
+
|
132
|
+
def get_signature_key(key, date_stamp, region_name, service_name)
|
133
|
+
k_date = OpenSSL::HMAC.digest("SHA256", "AWS4" + key, date_stamp)
|
134
|
+
k_region = OpenSSL::HMAC.digest("SHA256", k_date, region_name)
|
135
|
+
k_service = OpenSSL::HMAC.digest("SHA256", k_region, service_name)
|
136
|
+
OpenSSL::HMAC.digest("SHA256", k_service, "aws4_request")
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
140
|
+
|
@@ -0,0 +1,45 @@
|
|
1
|
+
module Zonesync
|
2
|
+
class Validator < Struct.new(:operations, :destination)
|
3
|
+
def self.call(...)
|
4
|
+
new(...).call
|
5
|
+
end
|
6
|
+
|
7
|
+
def call
|
8
|
+
if operations.any? && !manifest.existing?
|
9
|
+
raise MissingManifestError.new(manifest.generate)
|
10
|
+
end
|
11
|
+
if manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
|
12
|
+
raise ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum)
|
13
|
+
end
|
14
|
+
operations.each do |method, args|
|
15
|
+
send(method, *args)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def manifest
|
22
|
+
destination.manifest
|
23
|
+
end
|
24
|
+
|
25
|
+
def add record
|
26
|
+
return if manifest.matches?(record)
|
27
|
+
shorthand = manifest.shorthand_for(record, with_type: true)
|
28
|
+
conflicting_record = destination.records.find do |r|
|
29
|
+
manifest.shorthand_for(r, with_type: true) == shorthand
|
30
|
+
end
|
31
|
+
return if !conflicting_record
|
32
|
+
return if conflicting_record == record
|
33
|
+
raise Zonesync::ConflictError.new(conflicting_record, record)
|
34
|
+
end
|
35
|
+
|
36
|
+
def change *records
|
37
|
+
# FIXME? is it possible to break something with a tracked changed record
|
38
|
+
end
|
39
|
+
|
40
|
+
def remove record
|
41
|
+
# FIXME? is it possible to break something with a tracked removed record
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
data/lib/zonesync/version.rb
CHANGED