zonesync 0.6.1 → 0.8.0

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