zonesync 0.7.0 → 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/Gemfile +0 -1
- data/README.md +6 -3
- data/lib/zonesync/cli.rb +3 -0
- data/lib/zonesync/cloudflare.rb +31 -11
- 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 +19 -9
- data/lib/zonesync/record.rb +26 -8
- 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 +9 -4
- data/lib/zonesync.rb +29 -1
- metadata +7 -3
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/Gemfile
CHANGED
data/README.md
CHANGED
@@ -48,11 +48,8 @@ wwwtest CNAME www
|
|
48
48
|
mail A 192.0.2.3
|
49
49
|
mail2 A 192.0.2.4
|
50
50
|
mail3 A 192.0.2.5
|
51
|
-
ignore A 192.0.2.6 ; zonesync: ignore
|
52
51
|
```
|
53
52
|
|
54
|
-
Note that records with a comment containing "zonesync: ignore" will not be touched during the sync. I'm considering inverting this from a blacklist to a whitelist in a future version, to avoid stomping on collaborators' records.
|
55
|
-
|
56
53
|
### DNS Host
|
57
54
|
|
58
55
|
We need to tell `zonesync` about our DNS host by building a small YAML file. The structure of this file will depend on your DNS host, so here are some examples:
|
@@ -98,3 +95,9 @@ require 'zonesync'
|
|
98
95
|
Zonesync.call(zonefile: 'hostfile.txt', credentials: YAML.load('provider.yml'))
|
99
96
|
```
|
100
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,16 +4,18 @@ 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],
|
@@ -33,8 +35,8 @@ module Zonesync
|
|
33
35
|
})
|
34
36
|
end
|
35
37
|
|
36
|
-
def
|
37
|
-
@
|
38
|
+
def all
|
39
|
+
@all ||= begin
|
38
40
|
response = http.get(nil)
|
39
41
|
response["result"].reduce({}) do |map, attrs|
|
40
42
|
map.merge to_record(attrs) => attrs["id"]
|
@@ -49,15 +51,21 @@ module Zonesync
|
|
49
51
|
if %w[CNAME MX].include?(attrs["type"])
|
50
52
|
rdata = normalize_trailing_period(rdata)
|
51
53
|
end
|
54
|
+
if attrs["type"] == "MX"
|
55
|
+
rdata = "#{attrs["priority"]} #{rdata}"
|
56
|
+
end
|
52
57
|
if %w[TXT SPF NAPTR].include?(attrs["type"])
|
53
58
|
rdata = normalize_quoting(rdata)
|
54
59
|
end
|
60
|
+
if attrs["type"] == "TXT"
|
61
|
+
rdata = normalize_quoting(rdata)
|
62
|
+
end
|
55
63
|
Record.new(
|
56
|
-
normalize_trailing_period(attrs["name"]),
|
57
|
-
attrs["type"],
|
58
|
-
attrs["ttl"].to_i,
|
59
|
-
rdata
|
60
|
-
attrs["comment"],
|
64
|
+
name: normalize_trailing_period(attrs["name"]),
|
65
|
+
type: attrs["type"],
|
66
|
+
ttl: attrs["ttl"].to_i,
|
67
|
+
rdata:,
|
68
|
+
comment: attrs["comment"],
|
61
69
|
).to_h
|
62
70
|
end
|
63
71
|
|
@@ -66,7 +74,19 @@ module Zonesync
|
|
66
74
|
end
|
67
75
|
|
68
76
|
def normalize_quoting value
|
69
|
-
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
|
+
)
|
70
90
|
end
|
71
91
|
|
72
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
1
|
require "zonesync/record"
|
2
2
|
require "zonesync/zonefile"
|
3
|
+
require "zonesync/manifest"
|
3
4
|
|
4
5
|
module Zonesync
|
5
6
|
class Provider < Struct.new(:credentials)
|
@@ -8,22 +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
|
-
|
16
|
-
|
17
|
-
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def diffable_records
|
19
|
+
records.select do |record|
|
20
|
+
manifest.diffable?(record)
|
18
21
|
end.sort
|
19
22
|
end
|
20
23
|
|
24
|
+
def manifest
|
25
|
+
@manifest ||= Manifest.new(records, zonefile)
|
26
|
+
end
|
27
|
+
|
21
28
|
private def zonefile
|
22
|
-
|
23
|
-
|
24
|
-
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)
|
25
35
|
end
|
26
|
-
Zonefile.load(body)
|
27
36
|
end
|
28
37
|
|
29
38
|
def read record
|
@@ -48,6 +57,7 @@ module Zonesync
|
|
48
57
|
end
|
49
58
|
|
50
59
|
require "zonesync/cloudflare"
|
60
|
+
require "zonesync/route53"
|
51
61
|
|
52
62
|
class Memory < Provider
|
53
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, :comment)
|
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,24 +19,42 @@ module Zonesync
|
|
19
19
|
end
|
20
20
|
|
21
21
|
new(
|
22
|
-
record.host,
|
23
|
-
type
|
24
|
-
record.ttl,
|
25
|
-
rdata
|
26
|
-
record.comment,
|
22
|
+
name: record.host,
|
23
|
+
type:,
|
24
|
+
ttl: record.ttl,
|
25
|
+
rdata:,
|
26
|
+
comment: record.comment,
|
27
27
|
)
|
28
28
|
end
|
29
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
|
+
|
30
47
|
def <=> other
|
31
48
|
to_sortable <=> other.to_sortable
|
32
49
|
end
|
33
50
|
|
34
51
|
def to_sortable
|
35
|
-
|
52
|
+
is_soa = type == "SOA" ? 0 : 1
|
53
|
+
[is_soa, type, name, rdata, ttl]
|
36
54
|
end
|
37
55
|
|
38
56
|
def to_s
|
39
|
-
string = [name,
|
57
|
+
string = [name, ttl, type, rdata].join(" ")
|
40
58
|
string << " ; #{comment}" if comment
|
41
59
|
string
|
42
60
|
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
data/lib/zonesync/zonefile.rb
CHANGED
@@ -8,11 +8,15 @@ module Zonesync
|
|
8
8
|
parser = ZonefileParser.new
|
9
9
|
result = parser.parse(zone_string)
|
10
10
|
return result if result
|
11
|
+
puts zone_string
|
11
12
|
raise ParsingError, parser.failure_reason
|
12
13
|
end
|
13
14
|
|
14
|
-
def load(zone_string
|
15
|
-
|
15
|
+
def load(zone_string)
|
16
|
+
parsed = parse(zone_string)
|
17
|
+
Zone.new(parsed.entries,
|
18
|
+
origin: parsed.variables["ORIGIN"],
|
19
|
+
)
|
16
20
|
end
|
17
21
|
end
|
18
22
|
|
@@ -22,8 +26,8 @@ module Zonesync
|
|
22
26
|
attr_reader :origin
|
23
27
|
attr_reader :records
|
24
28
|
|
25
|
-
def initialize(entries, alternate_origin
|
26
|
-
|
29
|
+
def initialize(entries, origin: nil, alternate_origin: ".")
|
30
|
+
@origin = origin
|
27
31
|
@records = []
|
28
32
|
@vars = {"origin" => alternate_origin, :last_host => "."}
|
29
33
|
entries.each do |e|
|
@@ -59,6 +63,7 @@ module Zonesync
|
|
59
63
|
end
|
60
64
|
end
|
61
65
|
end
|
66
|
+
@origin ||= soa.origin
|
62
67
|
end
|
63
68
|
|
64
69
|
def soa
|
data/lib/zonesync.rb
CHANGED
@@ -1,8 +1,10 @@
|
|
1
1
|
require "zonesync/provider"
|
2
2
|
require "zonesync/diff"
|
3
|
+
require "zonesync/validator"
|
3
4
|
require "zonesync/logger"
|
4
5
|
require "zonesync/cli"
|
5
6
|
require "zonesync/rake"
|
7
|
+
require "zonesync/errors"
|
6
8
|
|
7
9
|
module Zonesync
|
8
10
|
def self.call zonefile: "Zonefile", credentials: default_credentials, dry_run: false
|
@@ -33,7 +35,33 @@ module Zonesync
|
|
33
35
|
def call dry_run: false
|
34
36
|
source = Provider.from(self.source)
|
35
37
|
destination = Provider.from(self.destination)
|
36
|
-
operations = Diff.call(
|
38
|
+
operations = Diff.call(
|
39
|
+
from: destination.diffable_records,
|
40
|
+
to: source.diffable_records,
|
41
|
+
)
|
42
|
+
|
43
|
+
Validator.call(operations, destination)
|
44
|
+
|
45
|
+
smanifest = source.manifest.generate
|
46
|
+
dmanifest = destination.manifest.existing
|
47
|
+
if smanifest != dmanifest
|
48
|
+
if dmanifest
|
49
|
+
operations << [:change, [dmanifest, smanifest]]
|
50
|
+
else
|
51
|
+
operations << [:add, [smanifest]]
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
schecksum = source.manifest.generate_checksum
|
56
|
+
dchecksum = destination.manifest.existing_checksum
|
57
|
+
if schecksum != dchecksum
|
58
|
+
if dchecksum
|
59
|
+
operations << [:change, [dchecksum, schecksum]]
|
60
|
+
else
|
61
|
+
operations << [:add, [schecksum]]
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
37
65
|
operations.each do |method, args|
|
38
66
|
Logger.log(method, args, dry_run: dry_run)
|
39
67
|
destination.send(method, *args) unless dry_run
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: zonesync
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.8.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Micah Geisel
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2025-01-06 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: diff-lcs
|
@@ -115,11 +115,15 @@ files:
|
|
115
115
|
- lib/zonesync/cli.rb
|
116
116
|
- lib/zonesync/cloudflare.rb
|
117
117
|
- lib/zonesync/diff.rb
|
118
|
+
- lib/zonesync/errors.rb
|
118
119
|
- lib/zonesync/http.rb
|
119
120
|
- lib/zonesync/logger.rb
|
121
|
+
- lib/zonesync/manifest.rb
|
120
122
|
- lib/zonesync/provider.rb
|
121
123
|
- lib/zonesync/rake.rb
|
122
124
|
- lib/zonesync/record.rb
|
125
|
+
- lib/zonesync/route53.rb
|
126
|
+
- lib/zonesync/validator.rb
|
123
127
|
- lib/zonesync/version.rb
|
124
128
|
- lib/zonesync/zonefile.rb
|
125
129
|
- lib/zonesync/zonefile.treetop
|
@@ -146,7 +150,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
146
150
|
- !ruby/object:Gem::Version
|
147
151
|
version: '0'
|
148
152
|
requirements: []
|
149
|
-
rubygems_version: 3.5.
|
153
|
+
rubygems_version: 3.5.11
|
150
154
|
signing_key:
|
151
155
|
specification_version: 4
|
152
156
|
summary: Sync your Zone file with your DNS host
|