zonesync 0.11.0 → 0.12.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/lib/zonesync/errors.rb +95 -10
- data/lib/zonesync/provider.rb +2 -2
- data/lib/zonesync/record.rb +31 -0
- data/lib/zonesync/validator.rb +108 -66
- data/lib/zonesync/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aaa6c630bddb382ab5b051983a3af48b5d59583a2e6befb51bf4098e3ccbdbb0
|
|
4
|
+
data.tar.gz: 7005f724efb994e55bc9d581780a8ba5711729ba208b9ef4c8fb77cc48d018bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ca2cc5e0fefcbf14efe91cb80b84ef20b485715891d92b8f2cd1f58cb2b6193bf9a7a51e822fa046a516bb275a7c5a5fb98e19d2cb9ae08cee41208434986f9e
|
|
7
|
+
data.tar.gz: bb66c82041407b2e3d3a57e0c1c381dc9a7ac77fb1d09db52d61892fb76409541a3d5059f916205f00dbf304a71218a0df7f60c6fb995350a1928d8026e8b617
|
data/lib/zonesync/errors.rb
CHANGED
|
@@ -1,22 +1,56 @@
|
|
|
1
1
|
# typed: strict
|
|
2
2
|
require "sorbet-runtime"
|
|
3
|
+
require "zonesync/record_hash"
|
|
3
4
|
|
|
4
5
|
module Zonesync
|
|
6
|
+
class ValidationError < StandardError
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
sig { void }
|
|
10
|
+
def initialize
|
|
11
|
+
@errors = T.let([], T::Array[StandardError])
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
sig { params(error: StandardError).void }
|
|
15
|
+
def add(error)
|
|
16
|
+
@errors << error
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { returns(T::Boolean) }
|
|
20
|
+
def any?
|
|
21
|
+
@errors.any?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
sig { returns(String) }
|
|
25
|
+
def message
|
|
26
|
+
@errors.map(&:message).join("\n\n#{'-' * 60}\n\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(T::Array[StandardError]) }
|
|
30
|
+
attr_reader :errors
|
|
31
|
+
end
|
|
32
|
+
|
|
5
33
|
class ConflictError < StandardError
|
|
6
34
|
extend T::Sig
|
|
7
35
|
|
|
8
|
-
sig { params(
|
|
9
|
-
def initialize
|
|
10
|
-
@
|
|
11
|
-
@new = new
|
|
36
|
+
sig { params(conflicts: T::Array[[T.nilable(Record), Record]]).void }
|
|
37
|
+
def initialize(conflicts)
|
|
38
|
+
@conflicts = conflicts
|
|
12
39
|
end
|
|
13
40
|
|
|
14
41
|
sig { returns(String) }
|
|
15
42
|
def message
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
43
|
+
conflicts_text = @conflicts.sort_by { |_existing, new_rec| new_rec.name }.map do |existing_rec, new_rec|
|
|
44
|
+
" existing: #{existing_rec}\n new: #{new_rec}"
|
|
45
|
+
end.join("\n\n")
|
|
46
|
+
|
|
47
|
+
count = @conflicts.length
|
|
48
|
+
record_word = count == 1 ? "record" : "records"
|
|
49
|
+
exists_word = count == 1 ? "exists" : "exist"
|
|
50
|
+
|
|
51
|
+
<<~MSG.chomp
|
|
52
|
+
The following untracked DNS #{record_word} already #{exists_word} and would be overwritten:
|
|
53
|
+
#{conflicts_text}
|
|
20
54
|
MSG
|
|
21
55
|
end
|
|
22
56
|
end
|
|
@@ -41,20 +75,71 @@ module Zonesync
|
|
|
41
75
|
class ChecksumMismatchError < StandardError
|
|
42
76
|
extend T::Sig
|
|
43
77
|
|
|
44
|
-
sig {
|
|
45
|
-
|
|
78
|
+
sig {
|
|
79
|
+
params(
|
|
80
|
+
existing: T.nilable(Record),
|
|
81
|
+
new: T.nilable(Record),
|
|
82
|
+
expected_record: T.nilable(Record),
|
|
83
|
+
actual_record: T.nilable(Record),
|
|
84
|
+
missing_hash: T.nilable(String)
|
|
85
|
+
).void
|
|
86
|
+
}
|
|
87
|
+
def initialize(existing = nil, new = nil, expected_record: nil, actual_record: nil, missing_hash: nil)
|
|
46
88
|
@existing = existing
|
|
47
89
|
@new = new
|
|
90
|
+
@expected_record = expected_record
|
|
91
|
+
@actual_record = actual_record
|
|
92
|
+
@missing_hash = missing_hash
|
|
48
93
|
end
|
|
49
94
|
|
|
50
95
|
sig { returns(String) }
|
|
51
96
|
def message
|
|
97
|
+
# V2 manifest integrity violation
|
|
98
|
+
if @missing_hash
|
|
99
|
+
return generate_v2_message
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# V1 checksum mismatch
|
|
52
103
|
<<~MSG
|
|
53
104
|
The zonesync_checksum TXT record does not match the current state of the DNS records. This probably means that someone else has changed them.
|
|
54
105
|
existing: #{@existing}
|
|
55
106
|
new: #{@new}
|
|
56
107
|
MSG
|
|
57
108
|
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
sig { returns(String) }
|
|
113
|
+
def generate_v2_message
|
|
114
|
+
if @expected_record && @actual_record
|
|
115
|
+
# Record was modified
|
|
116
|
+
actual_hash = RecordHash.generate(@actual_record)
|
|
117
|
+
<<~MSG.chomp
|
|
118
|
+
The following tracked DNS record has been modified externally:
|
|
119
|
+
Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
|
|
120
|
+
Actual: #{@actual_record.name} #{@actual_record.ttl} #{@actual_record.type} #{@actual_record.rdata} (hash: #{actual_hash})
|
|
121
|
+
|
|
122
|
+
This probably means someone else has changed it. Use --force to override.
|
|
123
|
+
MSG
|
|
124
|
+
elsif @expected_record
|
|
125
|
+
# Record was deleted
|
|
126
|
+
<<~MSG.chomp
|
|
127
|
+
The following tracked DNS record has been deleted externally:
|
|
128
|
+
Expected: #{@expected_record.name} #{@expected_record.ttl} #{@expected_record.type} #{@expected_record.rdata} (hash: #{@missing_hash})
|
|
129
|
+
Not found in current remote records.
|
|
130
|
+
|
|
131
|
+
This probably means someone else has deleted it. Use --force to override.
|
|
132
|
+
MSG
|
|
133
|
+
else
|
|
134
|
+
# Fallback: we don't have source records to look up details
|
|
135
|
+
<<~MSG.chomp
|
|
136
|
+
The following tracked DNS record has been modified or deleted externally:
|
|
137
|
+
Expected hash: #{@missing_hash} (not found in current records)
|
|
138
|
+
|
|
139
|
+
This probably means someone else has changed it. Use --force to override.
|
|
140
|
+
MSG
|
|
141
|
+
end
|
|
142
|
+
end
|
|
58
143
|
end
|
|
59
144
|
|
|
60
145
|
class DuplicateRecordError < StandardError
|
data/lib/zonesync/provider.rb
CHANGED
|
@@ -26,7 +26,7 @@ module Zonesync
|
|
|
26
26
|
sig { params(other: Provider, force: T::Boolean).returns(T::Array[Operation]) }
|
|
27
27
|
def diff! other, force: false
|
|
28
28
|
operations = diff(other).call
|
|
29
|
-
Validator.call(operations, self, force: force)
|
|
29
|
+
Validator.call(operations, self, other, force: force)
|
|
30
30
|
operations
|
|
31
31
|
end
|
|
32
32
|
|
|
@@ -118,7 +118,7 @@ module Zonesync
|
|
|
118
118
|
|
|
119
119
|
missing = expected_set - found_set
|
|
120
120
|
if missing.any?
|
|
121
|
-
raise ConflictError.new(nil, diffable.first || remote_records.first)
|
|
121
|
+
raise ConflictError.new([[nil, diffable.first || remote_records.first]])
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
diffable.sort
|
data/lib/zonesync/record.rb
CHANGED
|
@@ -53,6 +53,37 @@ module Zonesync
|
|
|
53
53
|
string << " ; #{comment}" if comment
|
|
54
54
|
string
|
|
55
55
|
end
|
|
56
|
+
|
|
57
|
+
sig { params(other: Record).returns(T::Boolean) }
|
|
58
|
+
def identical_to?(other)
|
|
59
|
+
name == other.name && type == other.type && ttl == other.ttl && rdata == other.rdata
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { params(other: Record).returns(T::Boolean) }
|
|
63
|
+
def conflicts_with?(other)
|
|
64
|
+
return false unless name == other.name && type == other.type
|
|
65
|
+
|
|
66
|
+
case type
|
|
67
|
+
when "CNAME", "SOA"
|
|
68
|
+
true
|
|
69
|
+
when "MX"
|
|
70
|
+
existing_priority = rdata.split(' ').first
|
|
71
|
+
new_priority = other.rdata.split(' ').first
|
|
72
|
+
existing_priority == new_priority
|
|
73
|
+
else
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { params(type: String).returns(T::Boolean) }
|
|
79
|
+
def self.single_record_per_name?(type)
|
|
80
|
+
type == "CNAME" || type == "SOA"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
sig { params(records: T::Array[Record]).returns(T::Array[Record]) }
|
|
84
|
+
def self.non_meta(records)
|
|
85
|
+
records.reject { |r| r.manifest? || r.checksum? }
|
|
86
|
+
end
|
|
56
87
|
end
|
|
57
88
|
end
|
|
58
89
|
|
data/lib/zonesync/validator.rb
CHANGED
|
@@ -3,28 +3,42 @@ require "sorbet-runtime"
|
|
|
3
3
|
require "zonesync/record_hash"
|
|
4
4
|
|
|
5
5
|
module Zonesync
|
|
6
|
-
Validator = Struct.new(:operations, :destination) do
|
|
6
|
+
Validator = Struct.new(:operations, :destination, :source) do
|
|
7
7
|
extend T::Sig
|
|
8
8
|
|
|
9
|
-
sig { params(operations: T::Array[Operation], destination: Provider, force: T::Boolean).void }
|
|
10
|
-
def self.call(operations, destination, force: false)
|
|
11
|
-
new(operations, destination).call(force: force)
|
|
9
|
+
sig { params(operations: T::Array[Operation], destination: Provider, source: T.nilable(Provider), force: T::Boolean).void }
|
|
10
|
+
def self.call(operations, destination, source = nil, force: false)
|
|
11
|
+
new(operations, destination, source).call(force: force)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
sig { params(force: T::Boolean).void }
|
|
15
15
|
def call(force: false)
|
|
16
|
+
validation_error = ValidationError.new
|
|
17
|
+
|
|
16
18
|
if !force && operations.any? && !manifest.existing?
|
|
17
|
-
|
|
19
|
+
validation_error.add(MissingManifestError.new(manifest.generate))
|
|
18
20
|
end
|
|
19
|
-
|
|
21
|
+
|
|
20
22
|
if !force && manifest.v1_format? && manifest.existing_checksum && manifest.existing_checksum != manifest.generate_checksum
|
|
21
|
-
|
|
23
|
+
validation_error.add(ChecksumMismatchError.new(manifest.existing_checksum, manifest.generate_checksum))
|
|
22
24
|
end
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
|
|
26
|
+
if !force && manifest.v2_format?
|
|
27
|
+
integrity_error = validate_v2_manifest_integrity
|
|
28
|
+
validation_error.add(integrity_error) if integrity_error
|
|
27
29
|
end
|
|
30
|
+
|
|
31
|
+
conflicts = operations.map do |method, args|
|
|
32
|
+
method == :add ? validate_addition(args.first, force: force) : nil
|
|
33
|
+
end.compact
|
|
34
|
+
validation_error.add(ConflictError.new(conflicts)) if conflicts.any?
|
|
35
|
+
|
|
36
|
+
if validation_error.errors.length == 1
|
|
37
|
+
raise validation_error.errors.first
|
|
38
|
+
elsif validation_error.errors.length > 1
|
|
39
|
+
raise validation_error
|
|
40
|
+
end
|
|
41
|
+
|
|
28
42
|
nil
|
|
29
43
|
end
|
|
30
44
|
|
|
@@ -35,68 +49,96 @@ module Zonesync
|
|
|
35
49
|
destination.manifest
|
|
36
50
|
end
|
|
37
51
|
|
|
38
|
-
sig {
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
52
|
+
sig { returns(T.nilable(ChecksumMismatchError)) }
|
|
53
|
+
def validate_v2_manifest_integrity
|
|
54
|
+
manifest_data = T.must(manifest.existing).rdata[1..-2]
|
|
55
|
+
expected_hashes = manifest_data.split(",")
|
|
56
|
+
actual_records = Record.non_meta(destination.records)
|
|
57
|
+
actual_hash_to_record = actual_records.map { |r| [RecordHash.generate(r), r] }.to_h
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
59
|
+
missing_hash = expected_hashes.find { |hash| !actual_hash_to_record.key?(hash) }
|
|
60
|
+
return nil unless missing_hash
|
|
61
|
+
|
|
62
|
+
expected_record = find_expected_record(missing_hash)
|
|
63
|
+
actual_record = find_modified_record(expected_record, actual_records)
|
|
64
|
+
|
|
65
|
+
ChecksumMismatchError.new(
|
|
66
|
+
expected_record: expected_record,
|
|
67
|
+
actual_record: actual_record,
|
|
68
|
+
missing_hash: missing_hash
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
sig { params(missing_hash: String).returns(T.nilable(Record)) }
|
|
73
|
+
def find_expected_record(missing_hash)
|
|
74
|
+
return nil unless source
|
|
75
|
+
|
|
76
|
+
source_records = Record.non_meta(source.records)
|
|
77
|
+
source_records.find { |r| RecordHash.generate(r) == missing_hash }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
sig { params(expected_record: T.nilable(Record), actual_records: T::Array[Record]).returns(T.nilable(Record)) }
|
|
81
|
+
def find_modified_record(expected_record, actual_records)
|
|
82
|
+
return nil unless expected_record
|
|
48
83
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
next if expected_hashes.include?(r_hash)
|
|
56
|
-
|
|
57
|
-
# Skip if it's exactly the same record (we're just starting to track it)
|
|
58
|
-
next if r.name == record.name && r.type == record.type && r.ttl == record.ttl && r.rdata == record.rdata
|
|
59
|
-
|
|
60
|
-
# Check for conflicts based on record type
|
|
61
|
-
case record.type
|
|
62
|
-
when "CNAME", "SOA"
|
|
63
|
-
# These types only allow one record per name
|
|
64
|
-
r.name == record.name && r.type == record.type
|
|
65
|
-
when "MX"
|
|
66
|
-
# MX records conflict if same name and same priority (first part of rdata)
|
|
67
|
-
if r.name == record.name && r.type == record.type
|
|
68
|
-
existing_priority = r.rdata.split(' ').first
|
|
69
|
-
new_priority = record.rdata.split(' ').first
|
|
70
|
-
existing_priority == new_priority
|
|
71
|
-
end
|
|
72
|
-
else
|
|
73
|
-
# For other types (A, AAAA, TXT, etc.), multiple records are allowed
|
|
74
|
-
# Only conflict if trying to add identical record (but we already checked that above)
|
|
75
|
-
false
|
|
76
|
-
end
|
|
84
|
+
# For CNAME and SOA, only one record per name is allowed, so check for modification
|
|
85
|
+
# For other types (A, AAAA, TXT, MX), only check if there's exactly one record
|
|
86
|
+
# with that name/type - if there are multiples, we can't determine which one it "became"
|
|
87
|
+
if Record.single_record_per_name?(expected_record.type)
|
|
88
|
+
actual_records.find do |r|
|
|
89
|
+
r.name == expected_record.name && r.type == expected_record.type
|
|
77
90
|
end
|
|
78
91
|
else
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
# V1 name-based manifest: use old shorthand logic
|
|
82
|
-
shorthand = manifest.shorthand_for(record, with_type: true)
|
|
83
|
-
conflicting_record = destination.records.find do |r|
|
|
84
|
-
manifest.shorthand_for(r, with_type: true) == shorthand
|
|
85
|
-
end
|
|
86
|
-
else
|
|
87
|
-
# No manifest: only conflict if exact same record exists
|
|
88
|
-
conflicting_record = destination.records.find do |r|
|
|
89
|
-
r.name == record.name &&
|
|
90
|
-
r.type == record.type &&
|
|
91
|
-
r.ttl == record.ttl &&
|
|
92
|
-
r.rdata == record.rdata
|
|
93
|
-
end
|
|
92
|
+
matching_records = actual_records.select do |r|
|
|
93
|
+
r.name == expected_record.name && r.type == expected_record.type
|
|
94
94
|
end
|
|
95
|
+
matching_records.first if matching_records.count == 1
|
|
95
96
|
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
sig { params(record: Record, force: T::Boolean).returns(T.nilable([T.nilable(Record), Record])) }
|
|
100
|
+
def validate_addition record, force: false
|
|
101
|
+
return nil if manifest.matches?(record)
|
|
102
|
+
return nil if force
|
|
103
|
+
|
|
104
|
+
conflicting_record = if manifest.v2_format?
|
|
105
|
+
expected_hashes = manifest.existing.rdata[1..-2].split(",")
|
|
106
|
+
find_v2_conflict(record, expected_hashes)
|
|
107
|
+
elsif manifest.existing?
|
|
108
|
+
find_v1_conflict(record)
|
|
109
|
+
else
|
|
110
|
+
find_unmanaged_conflict(record)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
return nil if !conflicting_record
|
|
114
|
+
return nil if conflicting_record == record
|
|
115
|
+
[conflicting_record, record]
|
|
116
|
+
end
|
|
96
117
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
118
|
+
sig { params(record: Record, expected_hashes: T::Array[String]).returns(T.nilable(Record)) }
|
|
119
|
+
def find_v2_conflict(record, expected_hashes)
|
|
120
|
+
destination.records.find do |r|
|
|
121
|
+
next if r.manifest? || r.checksum?
|
|
122
|
+
next if expected_hashes.include?(RecordHash.generate(r))
|
|
123
|
+
next if r.identical_to?(record)
|
|
124
|
+
|
|
125
|
+
r.conflicts_with?(record)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
sig { params(record: Record).returns(T.nilable(Record)) }
|
|
130
|
+
def find_v1_conflict(record)
|
|
131
|
+
shorthand = manifest.shorthand_for(record, with_type: true)
|
|
132
|
+
destination.records.find do |r|
|
|
133
|
+
manifest.shorthand_for(r, with_type: true) == shorthand
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
sig { params(record: Record).returns(T.nilable(Record)) }
|
|
138
|
+
def find_unmanaged_conflict(record)
|
|
139
|
+
destination.records.find do |r|
|
|
140
|
+
r.identical_to?(record)
|
|
141
|
+
end
|
|
100
142
|
end
|
|
101
143
|
end
|
|
102
144
|
end
|
data/lib/zonesync/version.rb
CHANGED
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.12.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
|
+
date: 2025-10-19 00:00:00.000000000 Z
|
|
13
13
|
dependencies:
|
|
14
14
|
- !ruby/object:Gem::Dependency
|
|
15
15
|
name: diff-lcs
|