zonesync 0.7.0 → 0.8.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/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
         |