zonesync 0.13.0 → 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d52bed9a567bd04886887c0846ca81a74086ca75e56636ccfd90ec16ca0a113f
4
- data.tar.gz: 7561f624f4360a4977a59941483f917832ab126f48843915216af9a51d0904c6
3
+ metadata.gz: 27a11c2ecbacb7edb9dc6fef0443c5c1d1c8835af8380345bcf2f6e680e3fefb
4
+ data.tar.gz: c92dc751c801a7b65fd0950f6b454341334dac0660860441c3808418b79df3fa
5
5
  SHA512:
6
- metadata.gz: 9b187df8cf8cce75a84187704c069e12b9d10d3e830f42065f26ab0d223d85ca79889c9b9f6b6d0edebd486fba38184ce5ad7047aa26759d39b6715ca9f1f785
7
- data.tar.gz: 021437d1e18832be6dbc131a60a47c7672d5018d01588a3f6de00cff2571948ca265a3f40b8a4185f6362c054d5f35471b876fabee3bb7da9dc2623c351db7b5
6
+ metadata.gz: ebfbd2e2da4d63229f968dd5f9287c486e10e9cfc25801376bdee3d94fe09c99debbd74cd6084015a36a0245d61db3736d14f4cc57a4f3ca5e657871078fe877
7
+ data.tar.gz: 3c6c48c37c130f8adb3fc4acfd5a7a2d20a66aaba23d0d2a2d4788dfd4f8e90dde1fea046a4d318e1731e37e4b1317efc6b6e25aa5b76c61b9b60c2c8fb78ab0
data/README.md CHANGED
@@ -50,49 +50,64 @@ mail2 A 192.0.2.4
50
50
  mail3 A 192.0.2.5
51
51
  ```
52
52
 
53
- ### DNS Host
53
+ ### Credentials
54
54
 
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:
55
+ Zonesync reads DNS provider credentials from Rails encrypted credentials (`config/credentials.yml.enc`). Edit them with:
56
+
57
+ ```
58
+ $ bin/rails credentials:edit
59
+ ```
60
+
61
+ Add a key (by default `zonesync`) with your provider configuration:
56
62
 
57
63
  **Cloudflare**
58
64
 
65
+ ```yaml
66
+ zonesync:
67
+ provider: Cloudflare
68
+ zone_id: <CLOUDFLARE_DOMAIN_ZONE_ID>
69
+ token: <CLOUDFLARE_API_TOKEN>
70
+ # or instead of token you can auth with:
71
+ # email: <CLOUDFLARE_EMAIL>
72
+ # key: <CLOUDFLARE_API_KEY>
59
73
  ```
60
- provider: Cloudflare
61
- zone_id: <CLOUDFLARE_DOMAIN_ZONE_ID>
62
- token: <CLOUDFLARE_API_TOKEN>
63
- # or instead of token you can auth with:
64
- email: <CLOUDFLARE_EMAIL>
65
- key: <CLOUDFLARE_API_KEY>
66
- ``
67
74
 
68
75
  **Route 53**
69
76
 
77
+ ```yaml
78
+ zonesync:
79
+ provider: Route53
80
+ aws_access_key_id: <AWS_ACCESS_KEY_ID>
81
+ aws_secret_access_key: <AWS_SECRET_ACCESS_KEY>
82
+ hosted_zone_id: <HOSTED_ZONE_ID>
70
83
  ```
71
- provider: AWS
72
- aws_access_key_id: <AWS_ACCESS_KEY_ID>
73
- aws_secret_access_key: <AWS_SECRET_ACCESS_KEY>
74
- ```
84
+
85
+ The encryption key is read from `config/master.key` or the `RAILS_MASTER_KEY` environment variable.
75
86
 
76
87
  ### Usage
77
88
 
78
89
  #### CLI
79
90
 
80
91
  ```
81
- $ bundle exec zonesync
82
- ```
83
- ```
84
- $ bundle exec zonesync --dry-run # log to STDOUT but don't actually perform the sync
92
+ $ bundle exec zonesync # sync Zonefile to DNS provider
93
+ $ bundle exec zonesync --dry-run # log to STDOUT but don't actually perform the sync
94
+ $ bundle exec zonesync generate # generate a Zonefile from the configured provider
85
95
  ```
96
+
97
+ By default, zonesync reads from `Zonefile` and uses the `zonesync` key in credentials. You can override these:
98
+
86
99
  ```
87
- $ bundle exec zonesync generate # generate a Zonefile from the configured provider
100
+ $ bundle exec zonesync --source=Zonefile --destination=zonesync
101
+ $ bundle exec zonesync generate --source=zonesync --destination=Zonefile
88
102
  ```
89
- #### Ruby
90
103
 
91
- Assuming your zone file lives in `hostfile.txt` and your DNS provider credentials are configured in `provider.yml`:
104
+ #### Ruby
92
105
 
93
106
  ```ruby
94
- require 'zonesync'
95
- Zonesync.call(zonefile: 'hostfile.txt', credentials: YAML.load('provider.yml'))
107
+ require "zonesync"
108
+ Zonesync.call # uses defaults
109
+ Zonesync.call(source: "Zonefile", destination: "zonesync", dry_run: true)
110
+ Zonesync.generate(source: "zonesync", destination: "Zonefile")
96
111
  ```
97
112
 
98
113
  ### Managing or avoiding conflicts with other people making edits to the DNS records
data/lib/zonesync/cli.rb CHANGED
@@ -26,6 +26,14 @@ module Zonesync
26
26
  Zonesync.generate(**kwargs)
27
27
  end
28
28
 
29
+ desc "repair --source=Zonefile --destination=zonesync", "interactively repair differences between Zonefile and DNS provider"
30
+ option :source, default: "Zonefile", desc: "path to the zonefile"
31
+ option :destination, default: "zonesync", desc: "key to the DNS server configuration in Rails.application.credentials"
32
+ def repair
33
+ kwargs = options.to_hash.transform_keys(&:to_sym)
34
+ Zonesync.repair(**kwargs)
35
+ end
36
+
29
37
  def self.exit_on_failure? = true
30
38
  end
31
39
  end
@@ -5,9 +5,9 @@ require "zonesync/record_hash"
5
5
  require "digest"
6
6
 
7
7
  module Zonesync
8
- Manifest = Struct.new(:records, :zone) do
9
- DIFFABLE_RECORD_TYPES = %w[A AAAA CNAME MX TXT SPF NAPTR PTR].sort
8
+ DIFFABLE_RECORD_TYPES = %w[A AAAA CNAME MX TXT SPF NAPTR PTR].sort.freeze
10
9
 
10
+ Manifest = Struct.new(:records, :zone) do
11
11
  def existing
12
12
  records.find(&:manifest?)
13
13
  end
@@ -0,0 +1,273 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zonesync
4
+ class Repair
5
+ def initialize(local, remote)
6
+ @local = local
7
+ @remote = remote
8
+ @remote_config = remote.config
9
+ end
10
+
11
+ def call(input: $stdin, output: $stdout)
12
+ differences = compute_differences
13
+ if differences.empty?
14
+ output.puts "Already in sync!"
15
+ return
16
+ end
17
+
18
+ output.puts "Found #{differences.length} difference#{"s" if differences.length != 1}:\n\n"
19
+
20
+ actions = differences.map.with_index do |diff, i|
21
+ display_difference(output, i + 1, diff)
22
+ prompt_action(input, output, diff)
23
+ end
24
+
25
+ output.puts
26
+ display_summary(output, actions)
27
+
28
+ return unless confirm_apply?(input, output)
29
+
30
+ apply_actions(actions)
31
+ update_manifest(output)
32
+ output.puts "\nRepair complete."
33
+ end
34
+
35
+ private
36
+
37
+ def all_diffable_records(provider)
38
+ # Get all records of diffable types, ignoring manifest filtering
39
+ Record.non_meta(provider.records).select { |r| DIFFABLE_RECORD_TYPES.include?(r.type) }.sort
40
+ end
41
+
42
+ def compute_differences
43
+ # For repair, we want ALL records of diffable types, not just those in manifest
44
+ local_records = all_diffable_records(@local)
45
+ remote_records = all_diffable_records(@remote)
46
+
47
+ local_by_key = local_records.group_by { |r| [r.name, r.type] }
48
+ remote_by_key = remote_records.group_by { |r| [r.name, r.type] }
49
+
50
+ differences = []
51
+
52
+ # Records only on remote
53
+ (remote_by_key.keys - local_by_key.keys).each do |key|
54
+ remote_by_key[key].each do |record|
55
+ differences << { type: :remote_only, record: record }
56
+ end
57
+ end
58
+
59
+ # Records only on local
60
+ (local_by_key.keys - remote_by_key.keys).each do |key|
61
+ local_by_key[key].each do |record|
62
+ differences << { type: :local_only, record: record }
63
+ end
64
+ end
65
+
66
+ # Records in both - check for changes
67
+ (local_by_key.keys & remote_by_key.keys).each do |key|
68
+ local_set = local_by_key[key]
69
+ remote_set = remote_by_key[key]
70
+
71
+ if local_set.length == 1 && remote_set.length == 1
72
+ if local_set.first != remote_set.first
73
+ differences << { type: :changed, local: local_set.first, remote: remote_set.first }
74
+ end
75
+ else
76
+ (remote_set - local_set).each { |r| differences << { type: :remote_only, record: r } }
77
+ (local_set - remote_set).each { |r| differences << { type: :local_only, record: r } }
78
+ end
79
+ end
80
+
81
+ differences.sort_by { |d| [(d[:record] || d[:local]).name, (d[:record] || d[:local]).type] }
82
+ end
83
+
84
+ def display_difference(output, num, diff)
85
+ case diff[:type]
86
+ when :remote_only
87
+ output.puts "#{num}. REMOTE ONLY:"
88
+ output.puts " #{diff[:record]}"
89
+ when :local_only
90
+ output.puts "#{num}. LOCAL ONLY:"
91
+ output.puts " #{diff[:record]}"
92
+ when :changed
93
+ output.puts "#{num}. CHANGED:"
94
+ output.puts " Local: #{diff[:local]}"
95
+ output.puts " Remote: #{diff[:remote]}"
96
+ end
97
+ output.puts
98
+ end
99
+
100
+ def prompt_action(input, output, diff)
101
+ case diff[:type]
102
+ when :remote_only
103
+ output.print " [a] Adopt [d] Delete [i] Ignore: "
104
+ loop do
105
+ choice = input.gets&.strip&.downcase
106
+ case choice
107
+ when "a" then return { action: :adopt, diff: diff }
108
+ when "d" then return { action: :delete_remote, diff: diff }
109
+ when "i" then return { action: :ignore, diff: diff }
110
+ else output.print " Invalid. [a/d/i]: "
111
+ end
112
+ end
113
+ when :local_only
114
+ output.print " [k] Keep (push to remote) [r] Remove from Zonefile [i] Ignore: "
115
+ loop do
116
+ choice = input.gets&.strip&.downcase
117
+ case choice
118
+ when "k" then return { action: :keep_local, diff: diff }
119
+ when "r" then return { action: :remove_local, diff: diff }
120
+ when "i" then return { action: :ignore, diff: diff }
121
+ else output.print " Invalid. [k/r/i]: "
122
+ end
123
+ end
124
+ when :changed
125
+ output.print " [l] Keep local [r] Keep remote [i] Ignore: "
126
+ loop do
127
+ choice = input.gets&.strip&.downcase
128
+ case choice
129
+ when "l" then return { action: :keep_local_changed, diff: diff }
130
+ when "r" then return { action: :keep_remote_changed, diff: diff }
131
+ when "i" then return { action: :ignore, diff: diff }
132
+ else output.print " Invalid. [l/r/i]: "
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ def display_summary(output, actions)
139
+ adopt = actions.count { |a| a[:action] == :adopt }
140
+ delete = actions.count { |a| a[:action] == :delete_remote }
141
+ keep_local = actions.count { |a| a[:action] == :keep_local }
142
+ keep_remote_changed = actions.count { |a| a[:action] == :keep_remote_changed }
143
+ remove_local = actions.count { |a| a[:action] == :remove_local }
144
+ keep_local_changed = actions.count { |a| a[:action] == :keep_local_changed }
145
+ ignore = actions.count { |a| a[:action] == :ignore }
146
+
147
+ output.puts "Summary:"
148
+ output.puts " #{adopt} record#{"s" if adopt != 1} to adopt into Zonefile" if adopt > 0
149
+ output.puts " #{delete} record#{"s" if delete != 1} to delete from remote" if delete > 0
150
+ output.puts " #{keep_local} record#{"s" if keep_local != 1} to push to remote" if keep_local > 0
151
+ output.puts " #{keep_remote_changed} record#{"s" if keep_remote_changed != 1} to pull from remote" if keep_remote_changed > 0
152
+ output.puts " #{remove_local} record#{"s" if remove_local != 1} to remove from Zonefile" if remove_local > 0
153
+ output.puts " #{keep_local_changed} local change#{"s" if keep_local_changed != 1} to push to remote" if keep_local_changed > 0
154
+ output.puts " #{ignore} record#{"s" if ignore != 1} ignored" if ignore > 0
155
+ end
156
+
157
+ def confirm_apply?(input, output)
158
+ output.print "\nApply changes? [y/n]: "
159
+ input.gets&.strip&.downcase == "y"
160
+ end
161
+
162
+ def update_manifest(output)
163
+ # Re-read both providers to get the updated records
164
+ updated_local = Provider.from({ provider: "Filesystem", path: @local.config.fetch(:path) })
165
+ updated_remote = Provider.from(@remote_config)
166
+
167
+ # Generate new manifest based on current local state
168
+ new_manifest = updated_local.manifest.generate
169
+
170
+ # Get existing manifest on remote
171
+ existing_manifest = updated_remote.manifest.existing
172
+
173
+ if existing_manifest
174
+ if new_manifest != existing_manifest
175
+ output.puts "Updating manifest..."
176
+ updated_remote.change(existing_manifest, new_manifest)
177
+ end
178
+ else
179
+ output.puts "Creating manifest..."
180
+ updated_remote.add(new_manifest)
181
+ end
182
+
183
+ # Remove old checksum if present (v2 manifests don't need it)
184
+ if existing_checksum = updated_remote.manifest.existing_checksum
185
+ output.puts "Removing old checksum..."
186
+ updated_remote.remove(existing_checksum)
187
+ end
188
+ end
189
+
190
+ def apply_actions(actions)
191
+ zonefile_additions = []
192
+ zonefile_removals = []
193
+
194
+ actions.each do |action|
195
+ case action[:action]
196
+ when :adopt
197
+ zonefile_additions << action[:diff][:record]
198
+ when :delete_remote
199
+ @remote.remove(action[:diff][:record])
200
+ when :keep_local
201
+ @remote.add(action[:diff][:record])
202
+ when :remove_local
203
+ zonefile_removals << action[:diff][:record]
204
+ when :keep_local_changed
205
+ @remote.change(action[:diff][:remote], action[:diff][:local])
206
+ when :keep_remote_changed
207
+ zonefile_additions << action[:diff][:remote]
208
+ zonefile_removals << action[:diff][:local]
209
+ end
210
+ end
211
+
212
+ if zonefile_additions.any? || zonefile_removals.any?
213
+ update_zonefile(zonefile_additions, zonefile_removals)
214
+ end
215
+ end
216
+
217
+ def update_zonefile(additions, removals)
218
+ content = @local.read
219
+ origin = @local.manifest.zone.origin
220
+
221
+ # Use treetop parser to find exact character positions of records to remove
222
+ intervals_to_remove = find_record_intervals(content, origin, removals)
223
+
224
+ # Remove intervals in reverse order to preserve positions
225
+ intervals_to_remove.sort_by { |i| -i.begin }.each do |interval|
226
+ content = content[0...interval.begin] + content[interval.end..]
227
+ end
228
+
229
+ if additions.any?
230
+ content = content.rstrip + "\n\n"
231
+ additions.each do |record|
232
+ short_name = record.short_name(origin)
233
+ line = "#{short_name} #{record.ttl} #{record.type} #{record.rdata}"
234
+ line += " ; #{record.comment}" if record.comment
235
+ content += "#{line}\n"
236
+ end
237
+ end
238
+
239
+ @local.write(content)
240
+ end
241
+
242
+ def find_record_intervals(content, origin, removals)
243
+ removal_set = removals.map { |r| [r.name, r.type, r.rdata] }.to_set
244
+
245
+ parseable_content, soa_insertion = Zonefile.ensure_soa(content)
246
+
247
+ parser = ZonefileParser.new
248
+ result = parser.parse(parseable_content)
249
+ raise Parser::ParsingError, parser.failure_reason unless result
250
+
251
+ # Build Zone to get properly qualified record objects
252
+ zone = Parser::Zone.new(result.entries, origin: origin)
253
+
254
+ # Pair raw record entries (for intervals) with parser records (for qualified fields)
255
+ raw_entries = result.entries.select { |e| e.parse_type == :record && e.record_type != "SOA" }
256
+ parser_records = zone.records.reject { |r| r.is_a?(Parser::SOA) }
257
+
258
+ intervals = []
259
+ raw_entries.zip(parser_records).each do |entry, parser_record|
260
+ record = Record.from_dns_zonefile_record(parser_record)
261
+
262
+ if removal_set.include?([record.name, record.type, record.rdata])
263
+ interval = entry.interval
264
+ if soa_insertion && interval.begin >= soa_insertion[:at]
265
+ interval = (interval.begin - soa_insertion[:length])...(interval.end - soa_insertion[:length])
266
+ end
267
+ intervals << interval
268
+ end
269
+ end
270
+ intervals
271
+ end
272
+ end
273
+ end
@@ -18,57 +18,52 @@ module Zonesync
18
18
  end
19
19
 
20
20
  def remove(record)
21
- if record.type == "TXT"
22
- # Route53 requires all TXT records with the same name to be managed together
23
- existing_txt_records = records.select do |r|
24
- r.name == record.name && r.type == "TXT"
25
- end
21
+ # Route53 requires all records with the same name/type to be managed together as a record set
22
+ existing_records = records.select do |r|
23
+ r.name == record.name && r.type == record.type
24
+ end
26
25
 
27
- if existing_txt_records.length == 1
28
- # Only one TXT record, delete it normally
29
- change_record("DELETE", record)
30
- else
31
- # Multiple TXT records - delete all, then recreate without the removed one
32
- remaining_txt_records = existing_txt_records.reject { |r| r == record }
33
-
34
- # Use change_records to handle both DELETE and CREATE in one request
35
- grouped = [
36
- [existing_txt_records, "DELETE"],
37
- [remaining_txt_records, "CREATE"]
38
- ]
39
-
40
- http.post("", ERB.new(<<~XML, trim_mode: "-").result(binding))
41
- <?xml version="1.0" encoding="UTF-8"?>
42
- <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
43
- <ChangeBatch>
44
- <Changes>
45
- <%- grouped.each do |records_list, action| -%>
46
- <%- records_grouped = records_list.group_by { |r| [r.name, r.type, r.ttl] } -%>
47
- <%- records_grouped.each do |(name, type, ttl), group_records| -%>
48
- <Change>
49
- <Action><%= action %></Action>
50
- <ResourceRecordSet>
51
- <Name><%= name %></Name>
52
- <Type><%= type %></Type>
53
- <TTL><%= ttl %></TTL>
54
- <ResourceRecords>
55
- <%- group_records.each do |group_record| -%>
56
- <ResourceRecord>
57
- <Value><%= group_record.rdata %></Value>
58
- </ResourceRecord>
59
- <%- end -%>
60
- </ResourceRecords>
61
- </ResourceRecordSet>
62
- </Change>
63
- <%- end -%>
64
- <%- end -%>
65
- </Changes>
66
- </ChangeBatch>
67
- </ChangeResourceRecordSetsRequest>
68
- XML
69
- end
70
- else
26
+ if existing_records.length == 1
71
27
  change_record("DELETE", record)
28
+ else
29
+ remaining_records = existing_records.reject { |r| r == record }
30
+
31
+ grouped = [
32
+ [existing_records, "DELETE"],
33
+ *(remaining_records.any? ? [[remaining_records, "CREATE"]] : [])
34
+ ]
35
+
36
+ http.post("", ERB.new(<<~XML, trim_mode: "-").result(binding))
37
+ <?xml version="1.0" encoding="UTF-8"?>
38
+ <ChangeResourceRecordSetsRequest xmlns="https://route53.amazonaws.com/doc/2013-04-01/">
39
+ <ChangeBatch>
40
+ <Changes>
41
+ <%- grouped.each do |records_list, action| -%>
42
+ <%- records_grouped = records_list.group_by { |r| [r.name, r.type, r.ttl] } -%>
43
+ <%- records_grouped.each do |(name, type, ttl), group_records| -%>
44
+ <Change>
45
+ <Action><%= action %></Action>
46
+ <ResourceRecordSet>
47
+ <Name><%= name %></Name>
48
+ <Type><%= type %></Type>
49
+ <TTL><%= ttl %></TTL>
50
+ <ResourceRecords>
51
+ <%- group_records.each do |group_record| -%>
52
+ <ResourceRecord>
53
+ <Value><%= rdata_for_api(group_record) %></Value>
54
+ </ResourceRecord>
55
+ <%- end -%>
56
+ </ResourceRecords>
57
+ </ResourceRecordSet>
58
+ </Change>
59
+ <%- end -%>
60
+ <%- end -%>
61
+ </Changes>
62
+ </ChangeBatch>
63
+ </ChangeResourceRecordSetsRequest>
64
+ XML
65
+
66
+ invalidate_cache!
72
67
  end
73
68
  end
74
69
 
@@ -80,25 +75,19 @@ module Zonesync
80
75
  def add(record)
81
76
  add_with_duplicate_handling(record) do
82
77
  begin
83
- if record.type == "TXT"
84
- # Route53 requires all TXT records with the same name to be combined into a single record set
85
- existing_txt_records = records.select do |r|
86
- r.name == record.name && r.type == "TXT"
87
- end
88
- all_txt_records = existing_txt_records + [record]
89
-
90
- # Use UPSERT if records already exist, CREATE if they don't
91
- action = existing_txt_records.empty? ? "CREATE" : "UPSERT"
92
- change_records(action, all_txt_records)
93
- else
94
- change_record("CREATE", record)
78
+ # Route53 requires all records with the same name/type to be in a single record set
79
+ existing_records = records.select do |r|
80
+ r.name == record.name && r.type == record.type
95
81
  end
82
+ all_records = (existing_records + [record]).uniq
83
+
84
+ action = existing_records.empty? ? "CREATE" : "UPSERT"
85
+ change_records(action, all_records)
86
+ invalidate_cache! if existing_records.any?
96
87
  rescue RuntimeError => e
97
- # Convert Route53-specific duplicate error to standard exception
98
88
  if e.message.include?("RRSet already exists")
99
89
  raise DuplicateRecordError.new(record, "Route53 duplicate record error")
100
90
  else
101
- # Re-raise other errors
102
91
  raise
103
92
  end
104
93
  end
@@ -107,6 +96,11 @@ module Zonesync
107
96
 
108
97
  private
109
98
 
99
+ def invalidate_cache!
100
+ @read = nil
101
+ @zonefile = nil
102
+ end
103
+
110
104
  def change_record(action, record)
111
105
  http.post("", <<~XML)
112
106
  <?xml version="1.0" encoding="UTF-8"?>
@@ -121,7 +115,7 @@ module Zonesync
121
115
  <TTL>#{record.ttl}</TTL>
122
116
  <ResourceRecords>
123
117
  <ResourceRecord>
124
- <Value>#{record.rdata}</Value>
118
+ <Value>#{rdata_for_api(record)}</Value>
125
119
  </ResourceRecord>
126
120
  </ResourceRecords>
127
121
  </ResourceRecordSet>
@@ -151,7 +145,7 @@ module Zonesync
151
145
  <ResourceRecords>
152
146
  <%- records.each do |record| -%>
153
147
  <ResourceRecord>
154
- <Value><%= record.rdata %></Value>
148
+ <Value><%= rdata_for_api(record) %></Value>
155
149
  </ResourceRecord>
156
150
  <%- end -%>
157
151
  </ResourceRecords>
@@ -164,12 +158,23 @@ module Zonesync
164
158
  XML
165
159
  end
166
160
 
161
+ def rdata_for_api(record)
162
+ return record.rdata unless record.type == "TXT"
163
+
164
+ # DNS TXT strings have a 255-character limit per quoted string.
165
+ # Split any single quoted string that exceeds this limit.
166
+ record.rdata.scan(/"([^"]*)"/).flatten.flat_map { |s|
167
+ s.scan(/.{1,255}/)
168
+ }.map { |s| %("#{s}") }.join(" ")
169
+ end
170
+
167
171
  def to_records(el)
168
172
  el.elements.collect("ResourceRecords/ResourceRecord") do |rr|
169
173
  name = normalize_trailing_period(get_value(el, "Name"))
170
174
  type = get_value(el, "Type")
171
175
  ttl = get_value(el, "TTL")
172
176
  rdata = get_value(rr, "Value")
177
+ rdata = normalize_txt_rdata(rdata) if type == "TXT"
173
178
 
174
179
  record = Record.new(
175
180
  name: name,
@@ -185,6 +190,13 @@ module Zonesync
185
190
  el.elements[field].text.gsub(/\\(\d{3})/) { $1.to_i(8).chr } # unescape octal
186
191
  end
187
192
 
193
+ def normalize_txt_rdata(rdata)
194
+ # Route53 splits long TXT strings at 255 chars. Join them back into a
195
+ # single quoted string so hashes match the Zonefile's canonical format.
196
+ strings = rdata.scan(/"([^"]*)"/).flatten
197
+ %("#{strings.join}")
198
+ end
199
+
188
200
  def normalize_trailing_period(value)
189
201
  value =~ /\.$/ ? value : value + "."
190
202
  end
@@ -219,7 +231,7 @@ module Zonesync
219
231
  ].join("\n")
220
232
 
221
233
  algorithm = "AWS4-HMAC-SHA256"
222
- credential_scope = "#{date}/#{config.fetch(:aws_region)}/#{service}/aws4_request"
234
+ credential_scope = "#{date}/us-east-1/#{service}/aws4_request"
223
235
  string_to_sign = [
224
236
  algorithm,
225
237
  amz_date,
@@ -227,7 +239,7 @@ module Zonesync
227
239
  OpenSSL::Digest::SHA256.hexdigest(canonical_request)
228
240
  ].join("\n")
229
241
 
230
- signing_key = get_signature_key(config.fetch(:aws_secret_access_key), date, config.fetch(:aws_region), service)
242
+ signing_key = get_signature_key(config.fetch(:aws_secret_access_key), date, "us-east-1", service)
231
243
  signature = OpenSSL::HMAC.hexdigest("SHA256", signing_key, string_to_sign)
232
244
 
233
245
  "#{algorithm} Credential=#{config.fetch(:aws_access_key_id)}/#{credential_scope}, SignedHeaders=#{signed_headers}, Signature=#{signature}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zonesync
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -4,11 +4,30 @@ require "zonesync/parser"
4
4
 
5
5
  module Zonesync
6
6
  class Zonefile
7
- def self.load(zone_string)
8
- if zone_string !~ /\sSOA\s/ # insert dummy SOA to trick parser if needed
9
- zone_string.sub!(/\n([^$])/, "\n@ 1 SOA example.com example.com ( 2000010101 1 1 1 1 )\n\\1")
7
+ DUMMY_SOA = "@ 1 SOA example.com example.com ( 2000010101 1 1 1 1 )\n"
8
+
9
+ # Inserts a dummy SOA record if needed for parsing.
10
+ # Returns [modified_content, insertion_offset] where insertion_offset is nil if no SOA was added,
11
+ # or the byte position and length of the inserted SOA.
12
+ def self.ensure_soa(zone_string)
13
+ if zone_string =~ /\sSOA\s/
14
+ [zone_string, nil]
15
+ else
16
+ content = zone_string.dup
17
+ match = content.match(/\n([^$])/)
18
+ if match
19
+ insertion_point = match.begin(0) + 1
20
+ content.sub!(/\n([^$])/, "\n#{DUMMY_SOA}\\1")
21
+ [content, { at: insertion_point, length: DUMMY_SOA.length }]
22
+ else
23
+ [content, nil]
24
+ end
10
25
  end
11
- zone = Parser.parse(zone_string)
26
+ end
27
+
28
+ def self.load(zone_string)
29
+ content, _ = ensure_soa(zone_string)
30
+ zone = Parser.parse(content)
12
31
  records = zone.records.map do |dns_zonefile_record|
13
32
  Zonesync::Record.from_dns_zonefile_record(dns_zonefile_record)
14
33
  end
data/lib/zonesync.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "zonesync/sync"
4
4
  require "zonesync/generate"
5
5
  require "zonesync/provider"
6
+ require "zonesync/repair"
6
7
  require "zonesync/cli"
7
8
  require "zonesync/rake"
8
9
  require "zonesync/errors"
@@ -29,6 +30,13 @@ module Zonesync
29
30
  ).call
30
31
  end
31
32
 
33
+ def self.repair(source: "Zonefile", destination: "zonesync")
34
+ Repair.new(
35
+ Provider.from({ provider: "Filesystem", path: source }),
36
+ Provider.from(credentials(destination.to_sym)),
37
+ ).call
38
+ end
39
+
32
40
  def self.credentials(key)
33
41
  ActiveSupport::EncryptedConfiguration.new(
34
42
  config_path: "config/credentials.yml.enc",
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.13.0
4
+ version: 0.14.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: 2025-12-22 00:00:00.000000000 Z
12
+ date: 2026-02-11 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: thor
@@ -113,6 +113,7 @@ files:
113
113
  - lib/zonesync/rake.rb
114
114
  - lib/zonesync/record.rb
115
115
  - lib/zonesync/record_hash.rb
116
+ - lib/zonesync/repair.rb
116
117
  - lib/zonesync/route53.rb
117
118
  - lib/zonesync/sync.rb
118
119
  - lib/zonesync/validator.rb