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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f636466fb4175eab983daffca93a4f3e09447bd49a4b50944078034c9067b46
4
- data.tar.gz: 462dcac524149c882e25e8f1962c9b29cc376177448b5a8320241cb135a83b01
3
+ metadata.gz: a674bd1519c4b8bd6a30814b43e70365191c18a59b24615e0ed93bbf8f79c286
4
+ data.tar.gz: c929821a57474a2d4be05f39f2e24e0cef38ab1484d5dbe6d8fbefba05ec60fc
5
5
  SHA512:
6
- metadata.gz: '0628bc131c08e8432e82c7b1f9a94587224871434df282ad52adc3cc1fe04b755c2b73ba849ee707a4c9c3ab6dfb69cd99b1289e6cfde41df9c72b1ccc874485'
7
- data.tar.gz: 4f55bace0450c64863dccbe394d160b00197db1294cc227e2fa119310c89415c5b6bf4ee52a9bd2d1e68a9aa362376e3079552947cc9703df3ee4e562d8e5fff
6
+ metadata.gz: e7678dc403b138a88de0d30a19a980c6c9d23ec3dcb6aa89157530cfc2e260b9e2dd610a1461b3d09174d3c70246c35d34d418a51802cc3c52cd651e5a42d1b2
7
+ data.tar.gz: 42bf9f0ba6346af233735e236e0cc39e69adc3caf9e41901f8aeddc6936cfdf602086b9a0b3b2999bc8852c493f72a453d66106c8543bc9c95b218caaae35a53
data/Gemfile CHANGED
@@ -4,4 +4,3 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  gem "debug"
7
- gem "dns-zonefile", path: "../dns-zonefile"
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"
@@ -4,16 +4,18 @@ require "zonesync/http"
4
4
  module Zonesync
5
5
  class Cloudflare < Provider
6
6
  def read
7
- http.get("/export")
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 = records.fetch(record)
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 = records.fetch(old_record)
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 records
37
- @records ||= begin
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.diffable_records, to.diffable_records)
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.to_h]]
14
+ [:remove, [change.old_element]]
15
15
  when "!"
16
- [:change, [change.old_element.to_h, change.new_element.to_h]]
16
+ [:change, [change.old_element, change.new_element]]
17
17
  when "+"
18
- [:add, [change.new_element.to_h]]
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 = block
29
+ @before_request << block
24
30
  end
25
31
 
26
32
  def after_response &block
27
- @after_response = block
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.call(request) if @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["Content-Type"].include?("application/json")
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.call(response) if @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")
@@ -13,11 +13,10 @@ class Logger
13
13
  loggers.each do |logger|
14
14
  operation = case args
15
15
  when Array
16
- args.map { |h| h.values.join(" ") }.join(" -> ")
17
- when Hash
18
- args.values.join(" ")
16
+ (args.length == 2 ? "\n" : "") +
17
+ args.map { |r| r.to_h.values.join(" ") }.join("->\n")
19
18
  else
20
- raise args.inspect
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
@@ -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 diffable_records
12
+ def records
12
13
  zonefile.records.map do |record|
13
14
  Record.from_dns_zonefile_record(record)
14
- end.select do |record|
15
- %w[A AAAA CNAME MX TXT SPF NAPTR PTR].include?(record.type)
16
- end.reject do |record|
17
- record.comment.to_s.downcase.include? "zonesync: ignore"
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
- body = read
23
- if body !~ /\sSOA\s/ # insert dummy SOA to trick parser if needed
24
- body.sub!(/\n([^$])/, "\n@ 1 SOA example.com example.com ( 2000010101 1 1 1 1 )\n\\1")
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
@@ -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
- [type, name, rdata, ttl]
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, type, ttl, rdata].join(" ")
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
+
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.7.0"
4
+ VERSION = "0.8.0"
5
5
  end
@@ -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, alternate_origin = nil)
15
- Zone.new(parse(zone_string).entries, alternate_origin)
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 = nil)
26
- alternate_origin ||= "."
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(from: destination, to: source)
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.7.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: 2024-08-23 00:00:00.000000000 Z
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.1
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