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 +4 -4
- data/README.md +37 -22
- data/lib/zonesync/cli.rb +8 -0
- data/lib/zonesync/manifest.rb +2 -2
- data/lib/zonesync/repair.rb +273 -0
- data/lib/zonesync/route53.rb +79 -67
- data/lib/zonesync/version.rb +1 -1
- data/lib/zonesync/zonefile.rb +23 -4
- data/lib/zonesync.rb +8 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27a11c2ecbacb7edb9dc6fef0443c5c1d1c8835af8380345bcf2f6e680e3fefb
|
|
4
|
+
data.tar.gz: c92dc751c801a7b65fd0950f6b454341334dac0660860441c3808418b79df3fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
###
|
|
53
|
+
### Credentials
|
|
54
54
|
|
|
55
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
100
|
+
$ bundle exec zonesync --source=Zonefile --destination=zonesync
|
|
101
|
+
$ bundle exec zonesync generate --source=zonesync --destination=Zonefile
|
|
88
102
|
```
|
|
89
|
-
#### Ruby
|
|
90
103
|
|
|
91
|
-
|
|
104
|
+
#### Ruby
|
|
92
105
|
|
|
93
106
|
```ruby
|
|
94
|
-
require
|
|
95
|
-
Zonesync.call
|
|
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
|
data/lib/zonesync/manifest.rb
CHANGED
|
@@ -5,9 +5,9 @@ require "zonesync/record_hash"
|
|
|
5
5
|
require "digest"
|
|
6
6
|
|
|
7
7
|
module Zonesync
|
|
8
|
-
|
|
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
|
data/lib/zonesync/route53.rb
CHANGED
|
@@ -18,57 +18,52 @@ module Zonesync
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def remove(record)
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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
|
|
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}/#{
|
|
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,
|
|
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}"
|
data/lib/zonesync/version.rb
CHANGED
data/lib/zonesync/zonefile.rb
CHANGED
|
@@ -4,11 +4,30 @@ require "zonesync/parser"
|
|
|
4
4
|
|
|
5
5
|
module Zonesync
|
|
6
6
|
class Zonefile
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|
|
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
|