dnsruby 1.74.0 → 2.0.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/.github/workflows/ci.yml +16 -4
- data/DNSSEC +3 -2
- data/EXAMPLES +1 -1
- data/README.md +370 -64
- data/RELEASE_NOTES.md +302 -1
- data/Rakefile +0 -3
- data/demo/axfr.rb +24 -6
- data/demo/rubydig.rb +0 -2
- data/dnsruby.gemspec +5 -4
- data/lib/dnsruby/cache.rb +279 -47
- data/lib/dnsruby/code_mapper.rb +35 -8
- data/lib/dnsruby/code_mappers.rb +63 -49
- data/lib/dnsruby/config.rb +75 -4
- data/lib/dnsruby/deadline_queue.rb +75 -0
- data/lib/dnsruby/dnssec.rb +148 -45
- data/lib/dnsruby/ipv6.rb +45 -5
- data/lib/dnsruby/key_cache.rb +202 -35
- data/lib/dnsruby/message/decoder.rb +85 -19
- data/lib/dnsruby/message/encoder.rb +27 -16
- data/lib/dnsruby/message/header.rb +8 -12
- data/lib/dnsruby/message/message.rb +53 -22
- data/lib/dnsruby/message/section.rb +12 -34
- data/lib/dnsruby/name.rb +5 -2
- data/lib/dnsruby/packet_sender.rb +645 -203
- data/lib/dnsruby/presentation.rb +147 -0
- data/lib/dnsruby/query_control.rb +132 -0
- data/lib/dnsruby/recursor.rb +795 -339
- data/lib/dnsruby/resolver.rb +468 -186
- data/lib/dnsruby/resource/CAA.rb +67 -16
- data/lib/dnsruby/resource/DLV.rb +6 -0
- data/lib/dnsruby/resource/DNSKEY.rb +108 -16
- data/lib/dnsruby/resource/DS.rb +27 -28
- data/lib/dnsruby/resource/HTTPS.rb +16 -0
- data/lib/dnsruby/resource/IN.rb +19 -0
- data/lib/dnsruby/resource/MX.rb +2 -2
- data/lib/dnsruby/resource/NSEC.rb +6 -10
- data/lib/dnsruby/resource/NSEC3.rb +55 -19
- data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
- data/lib/dnsruby/resource/RR.rb +108 -65
- data/lib/dnsruby/resource/RRSIG.rb +5 -5
- data/lib/dnsruby/resource/RRSet.rb +38 -22
- data/lib/dnsruby/resource/SRV.rb +2 -2
- data/lib/dnsruby/resource/SVCB.rb +775 -0
- data/lib/dnsruby/resource/TSIG.rb +266 -231
- data/lib/dnsruby/resource/TXT.rb +34 -42
- data/lib/dnsruby/resource/domain_name.rb +1 -1
- data/lib/dnsruby/resource/generic.rb +31 -0
- data/lib/dnsruby/resource/generic_cache.rb +384 -0
- data/lib/dnsruby/resource/generic_constants.rb +79 -0
- data/lib/dnsruby/select_thread.rb +979 -291
- data/lib/dnsruby/serial_time.rb +25 -0
- data/lib/dnsruby/single_resolver.rb +25 -6
- data/lib/dnsruby/single_verifier.rb +1620 -530
- data/lib/dnsruby/transport_clock.rb +9 -0
- data/lib/dnsruby/update.rb +8 -6
- data/lib/dnsruby/validation_budget.rb +143 -0
- data/lib/dnsruby/validator_thread.rb +139 -58
- data/lib/dnsruby/version.rb +1 -1
- data/lib/dnsruby/zone_reader.rb +196 -129
- data/lib/dnsruby/zone_transfer.rb +157 -25
- data/lib/dnsruby.rb +7 -2
- data/test/ci_watchdog.rb +33 -0
- data/test/fixtures/org-ds-2026-11-05.zone +5 -0
- data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
- data/test/fork_test_helper.rb +37 -0
- data/test/local_dns_server.rb +57 -0
- data/test/soak_server.rb +152 -0
- data/test/socket_helpers.rb +26 -0
- data/test/spec_helper.rb +8 -13
- data/test/tc_attempt_configuration.rb +137 -0
- data/test/tc_caa.rb +2 -2
- data/test/tc_cache_keys.rb +36 -0
- data/test/tc_cancelled_send_capacity.rb +158 -0
- data/test/tc_dnssec_alias_validation.rb +289 -0
- data/test/tc_dnssec_budget.rb +489 -0
- data/test/tc_dnssec_cache_bounds.rb +197 -0
- data/test/tc_dnssec_cache_lifetime.rb +615 -0
- data/test/tc_dnssec_denial_proofs.rb +285 -0
- data/test/tc_dnssec_recursion_limits.rb +186 -0
- data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
- data/test/tc_dnssec_trust_anchors.rb +286 -0
- data/test/tc_dnssec_trust_races.rb +147 -0
- data/test/tc_dnssec_wildcard_proofs.rb +249 -0
- data/test/tc_ds.rb +5 -5
- data/test/tc_edns_retry_regressions.rb +255 -0
- data/test/tc_escapedchars.rb +8 -0
- data/test/tc_final_review_fixes.rb +167 -0
- data/test/tc_final_transport_contracts.rb +104 -0
- data/test/tc_generic_cache.rb +852 -0
- data/test/tc_header.rb +26 -0
- data/test/tc_ipv6.rb +120 -0
- data/test/tc_key_cache_identity.rb +382 -0
- data/test/tc_malformed_tcp.rb +712 -0
- data/test/tc_name.rb +99 -5
- data/test/tc_nameserver_bootstrap.rb +322 -0
- data/test/tc_nsec.rb +4 -45
- data/test/tc_nsec3.rb +17 -1
- data/test/tc_pipeline_retirement.rb +358 -0
- data/test/tc_pipeline_retry.rb +462 -0
- data/test/tc_pipeline_send.rb +663 -0
- data/test/tc_protocol_review_fixes.rb +95 -0
- data/test/tc_query_control.rb +90 -0
- data/test/tc_record_codec_contracts.rb +107 -0
- data/test/tc_record_regressions.rb +225 -0
- data/test/tc_recur.rb +1 -0
- data/test/tc_recursor_address_fallback.rb +169 -0
- data/test/tc_recursor_limits.rb +139 -0
- data/test/tc_release_auxiliary_fixes.rb +288 -0
- data/test/tc_release_configuration_fixes.rb +612 -0
- data/test/tc_release_dnssec_completion.rb +405 -0
- data/test/tc_release_dnssec_fixes.rb +305 -0
- data/test/tc_release_negative_cache.rb +327 -0
- data/test/tc_release_recursor_cache.rb +212 -0
- data/test/tc_release_review_transport.rb +372 -0
- data/test/tc_release_review_transport_native.rb +56 -0
- data/test/tc_release_transport.rb +906 -0
- data/test/tc_release_transport_fixes.rb +196 -0
- data/test/tc_release_zone_defaults.rb +157 -0
- data/test/tc_release_zone_ttls.rb +39 -0
- data/test/tc_res_config.rb +42 -20
- data/test/tc_res_opt.rb +21 -1
- data/test/tc_resolver.rb +21 -19
- data/test/tc_resolver_concurrency.rb +383 -0
- data/test/tc_resolver_configuration_contracts.rb +31 -0
- data/test/tc_resolver_validation.rb +211 -0
- data/test/tc_review_cache.rb +163 -0
- data/test/tc_review_code_mapper.rb +279 -0
- data/test/tc_review_control_deadlines.rb +169 -0
- data/test/tc_review_followup_transport.rb +583 -0
- data/test/tc_review_packet_contracts.rb +369 -0
- data/test/tc_review_presentation.rb +213 -0
- data/test/tc_review_record_compatibility.rb +101 -0
- data/test/tc_review_transport_fixes.rb +195 -0
- data/test/tc_review_transport_native.rb +415 -0
- data/test/tc_review_validation_priming.rb +178 -0
- data/test/tc_rr-opt.rb +5 -2
- data/test/tc_rr-unknown.rb +19 -0
- data/test/tc_rr.rb +14 -0
- data/test/tc_rrset_grouping.rb +50 -0
- data/test/tc_rrset_review_fixes.rb +58 -0
- data/test/tc_rrsig.rb +21 -1
- data/test/tc_scoped_ipv6.rb +153 -0
- data/test/tc_scoped_ipv6_select.rb +157 -0
- data/test/tc_scoped_ipv6_transport.rb +329 -0
- data/test/tc_selector_wakeup.rb +42 -0
- data/test/tc_soak.rb +118 -196
- data/test/tc_socket_helpers.rb +90 -0
- data/test/tc_supporting_lookup_limits.rb +252 -0
- data/test/tc_svcb.rb +1043 -0
- data/test/tc_svcb_signed_target.rb +145 -0
- data/test/tc_svcb_transport.rb +120 -0
- data/test/tc_tcp.rb +69 -43
- data/test/tc_tcp_pipelining.rb +95 -204
- data/test/tc_transport_fork.rb +179 -0
- data/test/tc_tsig.rb +2 -2
- data/test/tc_tsig_api.rb +251 -0
- data/test/tc_tsig_rfc8945.rb +357 -0
- data/test/tc_v2_release_dnssec.rb +442 -0
- data/test/tc_v2_release_transport.rb +304 -0
- data/test/tc_v2_zone_transfer_native.rb +58 -0
- data/test/tc_zone_reader.rb +217 -1
- data/test/tcp_pipelining_server.rb +150 -0
- data/test/ts_offline.rb +77 -6
- data/test/ts_online.rb +2 -1
- metadata +105 -10
- data/.coveralls.yml +0 -2
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
require_relative 'tc_pipeline_send'
|
|
2
|
+
require 'minitest/mock'
|
|
3
|
+
|
|
4
|
+
class V2ReleaseTransportTest < Minitest::Test
|
|
5
|
+
def test_udp_readiness_without_data_keeps_the_attempt_registered
|
|
6
|
+
isolated(<<~'SCENARIO')
|
|
7
|
+
SELECTOR.define_singleton_method(:tcp?) { |_| false }
|
|
8
|
+
socket = FrameSocket.new
|
|
9
|
+
request = query
|
|
10
|
+
setting = settings(socket, :datagram, request)
|
|
11
|
+
setting.is_persistent_socket = false
|
|
12
|
+
SELECTOR.add_to_select(setting)
|
|
13
|
+
[IO::EAGAINWaitReadable, Errno::EINTR].each do |exception|
|
|
14
|
+
socket.define_singleton_method(:recvfrom_nonblock) { |_| raise exception }
|
|
15
|
+
raise 'unexpected response' unless SELECTOR.get_incoming_data(socket, 512).nil?
|
|
16
|
+
raise 'attempt lost' unless SELECTOR.query_settings_for_id(:datagram).equal?(setting)
|
|
17
|
+
raise 'socket closed' if socket.closed?
|
|
18
|
+
raise 'retryable read became an error' unless setting.client_queue.empty?
|
|
19
|
+
end
|
|
20
|
+
SELECTOR.remove_id(:datagram)
|
|
21
|
+
SCENARIO
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_pipeline_configuration_is_snapshotted_before_worker_dispatch
|
|
25
|
+
isolated(<<~'SCENARIO')
|
|
26
|
+
[false, true].each do |initial_mode|
|
|
27
|
+
socket = FrameSocket.new
|
|
28
|
+
writer = Dnsruby::PacketSender.new(server: '127.0.0.1', tcp_pipelining: initial_mode)
|
|
29
|
+
writer.define_singleton_method(:connect_tcp) { |*| socket }
|
|
30
|
+
writer.define_singleton_method(:tcp_pipeline_socket) do |*|
|
|
31
|
+
raise 'shared writer mutex missing' unless instance_variable_get(:@pipeline_mutex).owned?
|
|
32
|
+
[socket, true]
|
|
33
|
+
end
|
|
34
|
+
K.class_variable_set(:@@select_thread, Thread.current)
|
|
35
|
+
dispatched = nil
|
|
36
|
+
SELECTOR.define_singleton_method(:defer_send) do |owner, &operation|
|
|
37
|
+
dispatched = owner
|
|
38
|
+
writer.tcp_pipelining = !initial_mode
|
|
39
|
+
operation.call
|
|
40
|
+
end
|
|
41
|
+
request = query
|
|
42
|
+
writer.send_dnsruby(request.encode, request, initial_mode, Queue.new, true)
|
|
43
|
+
registered = SELECTOR.query_settings_for_id(initial_mode)
|
|
44
|
+
raise 'configuration changed admitted socket mode' unless registered &&
|
|
45
|
+
!!registered.is_persistent_socket == initial_mode
|
|
46
|
+
raise 'worker grouping changed during admission' unless dispatched.equal?(initial_mode ? writer : nil)
|
|
47
|
+
raise 'incomplete frame' unless socket.written.bytesize == request.encode.bytesize + 2
|
|
48
|
+
SELECTOR.remove_id(initial_mode)
|
|
49
|
+
socket.close
|
|
50
|
+
end
|
|
51
|
+
SCENARIO
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def test_packet_timeout_processing_visits_only_due_owners
|
|
55
|
+
isolated(<<~'SCENARIO')
|
|
56
|
+
now = Dnsruby::TransportClock.now
|
|
57
|
+
due = settings(FrameSocket.new, :due)
|
|
58
|
+
later = settings(FrameSocket.new, :later)
|
|
59
|
+
due.endtime, later.endtime = now - 1, now + 60
|
|
60
|
+
[due, later].each { |setting| SELECTOR.add_to_select(setting) }
|
|
61
|
+
table = K.class_variable_get(:@@timeouts)
|
|
62
|
+
[:each, :map, :keys].each { |method| table.define_singleton_method(method) { raise 'scanned packet table' } }
|
|
63
|
+
SELECTOR.process_timeouts
|
|
64
|
+
raise 'future attempt expired' unless SELECTOR.query_settings_for_id(:later).equal?(later)
|
|
65
|
+
raise 'due attempt retained' if SELECTOR.query_settings_for_id(:due)
|
|
66
|
+
raise 'timeout missing' unless due.client_queue.pop.last.is_a?(Dnsruby::ResolvTimeout)
|
|
67
|
+
SELECTOR.remove_id(:later)
|
|
68
|
+
SCENARIO
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def test_logical_tick_visits_only_due_plans_in_admission_order
|
|
72
|
+
isolated(<<~'SCENARIO')
|
|
73
|
+
sent = []
|
|
74
|
+
writer = Object.new
|
|
75
|
+
writer.define_singleton_method(:server) { '192.0.2.53' }
|
|
76
|
+
writer.define_singleton_method(:prepare_send_async) { |_, _, id, *| -> { sent << id[2] } }
|
|
77
|
+
parent = Struct.new(:single_resolvers, :single_res_mutex).new([writer], Mutex.new)
|
|
78
|
+
engine = Dnsruby::ResolverRuby.new(parent)
|
|
79
|
+
now = Dnsruby::TransportClock.now
|
|
80
|
+
plans = engine.instance_variable_get(:@timeouts)
|
|
81
|
+
[:due, :later].each do |id|
|
|
82
|
+
engine.instance_variable_get(:@query_list)[id] = [query, Queue.new, Queue.new, [], nil]
|
|
83
|
+
plans[id] = [now + 120, {(id == :due ? now - 1 : now + 60) => [writer, 0]}, false]
|
|
84
|
+
engine.send(:update_tick_deadline, id)
|
|
85
|
+
end
|
|
86
|
+
plans.define_singleton_method(:keys) { raise 'scanned all logical queries' }
|
|
87
|
+
plans[:later][1].define_singleton_method(:each_key) { raise 'visited future retry plan' }
|
|
88
|
+
engine.tick
|
|
89
|
+
raise 'incorrect due sends' unless sent == [:due]
|
|
90
|
+
raise 'incorrect next deadline' unless engine.next_tick_at == now + 60
|
|
91
|
+
SCENARIO
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def test_retries_are_bounded_per_server_and_transport
|
|
95
|
+
budget = Dnsruby::SelectThread::TransmissionBudget.new
|
|
96
|
+
3.times { assert(budget.admit('192.0.2.53', 53, false)) }
|
|
97
|
+
refute(budget.admit('192.0.2.53', 53, false))
|
|
98
|
+
assert(budget.admit('192.0.2.53', 53, true))
|
|
99
|
+
assert(budget.admit('192.0.2.54', 53, false))
|
|
100
|
+
resolver = Dnsruby::Resolver.new(nameserver: ['192.0.2.53', '192.0.2.54'], retry_times: 6)
|
|
101
|
+
counts = resolver.generate_timeouts.values.group_by(&:first).transform_values(&:length)
|
|
102
|
+
assert_equal([3, 3], counts.values.sort)
|
|
103
|
+
ensure
|
|
104
|
+
resolver&.close
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def test_validation_worker_shares_the_original_transmission_limit
|
|
108
|
+
budget = Dnsruby::SelectThread::TransmissionBudget.new
|
|
109
|
+
query = Dnsruby::Message.new('example.', 'A')
|
|
110
|
+
query.do_validation = true
|
|
111
|
+
assert(budget.admit('192.0.2.53', 53, false, query.question.first))
|
|
112
|
+
response = query.clone
|
|
113
|
+
completions = []
|
|
114
|
+
selector = Object.new
|
|
115
|
+
selector.define_singleton_method(:validation_transmission_budget) { |*| budget }
|
|
116
|
+
selector.define_singleton_method(:register_validation) { |*| true }
|
|
117
|
+
selector.define_singleton_method(:unregister_validation) { |*| }
|
|
118
|
+
selector.define_singleton_method(:push_validation_response_to_select) { |*args| completions << args }
|
|
119
|
+
sender = Struct.new(:dnssec).new(true)
|
|
120
|
+
validator = Dnsruby::ValidatorThread.new(:client, Queue.new, response, nil, query, selector, sender)
|
|
121
|
+
validate = lambda do |_, answer|
|
|
122
|
+
# Separate helper send tokens must count against the original resolution.
|
|
123
|
+
2.times do
|
|
124
|
+
token = Dnsruby::SelectThread::SendToken.new
|
|
125
|
+
assert(token.transmission_budget.admit('192.0.2.53', 53, false, query.question.first))
|
|
126
|
+
end
|
|
127
|
+
token = Dnsruby::SelectThread::SendToken.new
|
|
128
|
+
refute(token.transmission_budget.admit('192.0.2.53', 53, false, query.question.first))
|
|
129
|
+
answer.security_level = Dnsruby::Message::SecurityLevel.SECURE
|
|
130
|
+
true
|
|
131
|
+
end
|
|
132
|
+
Dnsruby::Dnssec.stub(:validate_with_query, validate) do
|
|
133
|
+
Dnsruby::PacketSender.stub(:cache, ->(*) {}) do
|
|
134
|
+
assert_nil(Thread.new { validator.do_validate; Dnsruby::SelectThread::TransmissionBudget.current }.value)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
assert_equal(1, completions.length)
|
|
138
|
+
assert_nil(completions.first[3])
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
class TransferSocket
|
|
142
|
+
attr_reader :written
|
|
143
|
+
def initialize(input = ''.b)
|
|
144
|
+
@input, @written, @closed = input.dup, +''.b, false
|
|
145
|
+
end
|
|
146
|
+
def write_nonblock(bytes)
|
|
147
|
+
count = [bytes.bytesize, 3].min
|
|
148
|
+
@written << bytes.byteslice(0, count)
|
|
149
|
+
count
|
|
150
|
+
end
|
|
151
|
+
def read_nonblock(length)
|
|
152
|
+
raise EOFError if @input.empty?
|
|
153
|
+
@input.slice!(0, [length, 5].min)
|
|
154
|
+
end
|
|
155
|
+
def close; @closed = true; end
|
|
156
|
+
def closed?; @closed; end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def test_zone_transfer_writes_complete_frames_and_reads_fragmented_frames
|
|
160
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
161
|
+
request = Dnsruby::Message.new('example.', 'AXFR')
|
|
162
|
+
socket = TransferSocket.new(frame(request))
|
|
163
|
+
transfer.send_message(socket, request)
|
|
164
|
+
assert_equal(frame(request), socket.written)
|
|
165
|
+
assert_equal(request.question, transfer.receive_message(socket).question)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def test_zone_transfer_accepts_multi_message_axfr_with_an_empty_later_question
|
|
169
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
170
|
+
soa = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60')
|
|
171
|
+
address = Dnsruby::RR.create('www.example. 300 IN A 192.0.2.1')
|
|
172
|
+
first = Dnsruby::Message.new('example.', 'AXFR')
|
|
173
|
+
first.add_answer(soa)
|
|
174
|
+
first.add_answer(address)
|
|
175
|
+
last = Dnsruby::Message.new
|
|
176
|
+
last.add_answer(soa.clone)
|
|
177
|
+
responses = [first, last]
|
|
178
|
+
socket = TransferSocket.new
|
|
179
|
+
transfer.define_singleton_method(:send_message) do |_, request|
|
|
180
|
+
responses.each { |response| response.header.id = request.header.id; response.header.qr = true }
|
|
181
|
+
end
|
|
182
|
+
transfer.define_singleton_method(:receive_message) { |_| responses.shift }
|
|
183
|
+
Socket.stub(:tcp, socket) do
|
|
184
|
+
assert_equal([soa, address], transfer.do_transfer('example.', '192.0.2.53'))
|
|
185
|
+
end
|
|
186
|
+
assert(socket.closed?)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_zone_transfer_timeout_configuration_and_interrupted_wait
|
|
190
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
191
|
+
assert_equal(30, transfer.io_timeout)
|
|
192
|
+
assert_nil(transfer.transfer_timeout)
|
|
193
|
+
transfer.io_timeout = 1
|
|
194
|
+
transfer.transfer_timeout = 2
|
|
195
|
+
assert_equal(1.0, transfer.io_timeout)
|
|
196
|
+
assert_equal(2.0, transfer.transfer_timeout)
|
|
197
|
+
transfer.transfer_timeout = nil
|
|
198
|
+
assert_raises(ArgumentError) { transfer.io_timeout = 0 }
|
|
199
|
+
assert_raises(ArgumentError) { transfer.transfer_timeout = Float::INFINITY }
|
|
200
|
+
calls = 0
|
|
201
|
+
IO.stub(:select, ->(*_) { calls += 1; raise Errno::EINTR if calls == 1; nil }) do
|
|
202
|
+
transfer.send(:wait_for_io, Object.new, Dnsruby::TransportClock.now + 1, true)
|
|
203
|
+
end
|
|
204
|
+
assert_equal(2, calls)
|
|
205
|
+
assert_raises(Dnsruby::ResolvTimeout) do
|
|
206
|
+
transfer.tcp_read(TransferSocket.new, 2, Dnsruby::TransportClock.now - 1)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def test_zone_transfer_matches_the_name_sent_in_the_question
|
|
211
|
+
zones = ['example', 'example.', 'EXAMPLE', Dnsruby::Name.create('example').freeze,
|
|
212
|
+
Dnsruby::Name.create('example.').freeze, '192.0.2.1']
|
|
213
|
+
['AXFR', 'IXFR'].each do |type|
|
|
214
|
+
zones.each do |zone|
|
|
215
|
+
owner = zone == '192.0.2.1' ? '1.2.0.192.in-addr.arpa.' : 'example.'
|
|
216
|
+
soa = Dnsruby::RR.create("#{owner} 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60")
|
|
217
|
+
address = Dnsruby::RR.create("www.#{owner} 300 IN A 192.0.2.1")
|
|
218
|
+
original_absolute = zone.absolute? if zone.is_a?(Dnsruby::Name)
|
|
219
|
+
answers = if type == 'AXFR'
|
|
220
|
+
[soa, address, soa.clone]
|
|
221
|
+
else
|
|
222
|
+
[soa, soa.clone.tap { |rr| rr.serial = 2 }, soa.clone, address, soa.clone]
|
|
223
|
+
end
|
|
224
|
+
result = transfer_with_answers(zone, type, owner, answers)
|
|
225
|
+
if type == 'AXFR'
|
|
226
|
+
assert_equal([soa, address], result, zone.inspect)
|
|
227
|
+
else
|
|
228
|
+
assert_equal([[2, 4]], result.map { |delta| [delta.start, delta.end] }, zone.inspect)
|
|
229
|
+
assert_equal([soa, address], result.first.adds)
|
|
230
|
+
end
|
|
231
|
+
assert_equal(original_absolute, zone.absolute?) if zone.is_a?(Dnsruby::Name)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def test_zone_transfer_still_rejects_an_initial_soa_for_another_zone_or_class
|
|
237
|
+
['AXFR', 'IXFR'].each do |type|
|
|
238
|
+
['example', 'example.'].each do |zone|
|
|
239
|
+
['other. 300 IN', 'example. 300 CH'].each do |owner_and_class|
|
|
240
|
+
soa = Dnsruby::RR.create("#{owner_and_class} SOA ns.example. hostmaster.example. 4 60 60 3600 60")
|
|
241
|
+
error = assert_raises(Dnsruby::ResolvError) do
|
|
242
|
+
transfer_with_answers(zone, type, 'example.', [soa])
|
|
243
|
+
end
|
|
244
|
+
assert_equal('initial SOA does not match transfer zone', error.message)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def test_ixfr_terminal_soa_matches_the_opening_soa
|
|
251
|
+
opening = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 4 60 60 3600 60')
|
|
252
|
+
[opening.clone, opening.clone.tap { |rr| rr.refresh = 120 }].each do |closing|
|
|
253
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
254
|
+
transfer.instance_variable_set(:@zone_name, Dnsruby::Name.create('example.'))
|
|
255
|
+
transfer.instance_variable_set(:@transfer_type, Dnsruby::Types.IXFR)
|
|
256
|
+
transfer.instance_variable_set(:@serial, 2)
|
|
257
|
+
transfer.instance_variable_set(:@state, :InitialSoa)
|
|
258
|
+
# Two ordinary deltas, including their repeated intermediate SOA.
|
|
259
|
+
old = opening.clone.tap { |rr| rr.serial = 2 }
|
|
260
|
+
middle = opening.clone.tap { |rr| rr.serial = 3 }
|
|
261
|
+
[opening, old, middle, middle.clone, opening.clone].each { |rr| transfer.parseRR(rr) }
|
|
262
|
+
if closing.refresh == opening.refresh
|
|
263
|
+
transfer.parseRR(closing)
|
|
264
|
+
assert_equal(:End, transfer.instance_variable_get(:@state))
|
|
265
|
+
assert_equal([[2, 3], [3, 4]], transfer.instance_variable_get(:@ixfr).map { |delta| [delta.start, delta.end] })
|
|
266
|
+
else
|
|
267
|
+
assert_raises(Dnsruby::ResolvError) { transfer.parseRR(closing) }
|
|
268
|
+
assert_equal(:Ixfr_Add, transfer.instance_variable_get(:@state))
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
private
|
|
274
|
+
|
|
275
|
+
def transfer_with_answers(zone, type, owner, answers)
|
|
276
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
277
|
+
transfer.transfer_type = type
|
|
278
|
+
transfer.serial = 2
|
|
279
|
+
response = Dnsruby::Message.new(owner, type)
|
|
280
|
+
response.header.qr = true
|
|
281
|
+
answers.each { |rr| response.add_answer!(rr) }
|
|
282
|
+
socket = TransferSocket.new
|
|
283
|
+
transfer.define_singleton_method(:send_message) do |_, request|
|
|
284
|
+
response.header.id = request.header.id
|
|
285
|
+
end
|
|
286
|
+
transfer.define_singleton_method(:receive_message) { |_| Dnsruby::Message.decode(response.encode) }
|
|
287
|
+
Socket.stub(:tcp, socket) { transfer.do_transfer(zone, '192.0.2.53') }
|
|
288
|
+
ensure
|
|
289
|
+
assert(socket.closed?) if socket
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def frame(message)
|
|
293
|
+
wire = message.encode
|
|
294
|
+
[wire.bytesize].pack('n') + wire
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def isolated(script)
|
|
298
|
+
options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
|
|
299
|
+
stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
|
|
300
|
+
File.expand_path('../lib', __dir__), '-e', "#{TestPipelineSend::FIXTURE}\nTimeout.timeout(10) do\n#{script}\nend")
|
|
301
|
+
assert(status.success?, "#{stdout}\n#{stderr}")
|
|
302
|
+
assert_empty(stderr)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require_relative 'spec_helper'
|
|
2
|
+
require 'socket'
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
class V2ZoneTransferNativeTest < Minitest::Test
|
|
6
|
+
def test_axfr_over_tcp_with_and_without_tsig
|
|
7
|
+
[false, true].each do |signed|
|
|
8
|
+
listener = TCPServer.new('127.0.0.1', 0)
|
|
9
|
+
transfer = Dnsruby::ZoneTransfer.new
|
|
10
|
+
transfer.server = '127.0.0.1'
|
|
11
|
+
transfer.port = listener.addr[1]
|
|
12
|
+
transfer.io_timeout = 2
|
|
13
|
+
transfer.transfer_timeout = 3
|
|
14
|
+
transfer.tsig = ['key.example.', ['local-transfer-secret'].pack('m0')] if signed
|
|
15
|
+
soa = Dnsruby::RR.create('example. 300 IN SOA ns.example. hostmaster.example. 2 60 60 3600 60')
|
|
16
|
+
address = Dnsruby::RR.create('www.example. 300 IN A 192.0.2.1')
|
|
17
|
+
server = Thread.new do
|
|
18
|
+
socket = listener.accept
|
|
19
|
+
query = Dnsruby::Message.decode(socket.read(socket.read(2).unpack1('n')))
|
|
20
|
+
response = Dnsruby::Message.new('example.', 'AXFR')
|
|
21
|
+
response.header.id = query.header.id
|
|
22
|
+
response.header.qr = response.header.aa = true
|
|
23
|
+
response.add_answer(soa)
|
|
24
|
+
response.add_answer(address)
|
|
25
|
+
if signed
|
|
26
|
+
response.add_answer!(soa.clone)
|
|
27
|
+
key = Dnsruby::RR.create(name: 'key.example.', type: 'TSIG', klass: 'ANY',
|
|
28
|
+
key: ['local-transfer-secret'].pack('m0'))
|
|
29
|
+
key.apply(response, query)
|
|
30
|
+
responses = [response]
|
|
31
|
+
else
|
|
32
|
+
closing = Dnsruby::Message.new
|
|
33
|
+
closing.header.id = query.header.id
|
|
34
|
+
closing.header.qr = closing.header.aa = true
|
|
35
|
+
closing.add_answer(soa.clone)
|
|
36
|
+
responses = [response, closing]
|
|
37
|
+
end
|
|
38
|
+
responses.each do |message|
|
|
39
|
+
wire = message.encode
|
|
40
|
+
# TCP may coalesce these writes or deliver any combination of them.
|
|
41
|
+
socket.write([wire.bytesize].pack('n').byteslice(0, 1))
|
|
42
|
+
socket.write([wire.bytesize].pack('n').byteslice(1, 1))
|
|
43
|
+
socket.write(wire)
|
|
44
|
+
end
|
|
45
|
+
ensure
|
|
46
|
+
socket&.close
|
|
47
|
+
end
|
|
48
|
+
assert_equal([soa, address], Timeout.timeout(5) { transfer.transfer('example') })
|
|
49
|
+
assert_equal(:Verified, transfer.last_tsigstate) if signed
|
|
50
|
+
assert_nil(transfer.last_tsigstate) unless signed
|
|
51
|
+
Timeout.timeout(2) { server.value }
|
|
52
|
+
ensure
|
|
53
|
+
listener&.close
|
|
54
|
+
server&.kill
|
|
55
|
+
server&.join
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/test/tc_zone_reader.rb
CHANGED
|
@@ -71,5 +71,221 @@ ZONEDATA
|
|
|
71
71
|
assert_equal(false, stringio.closed?)
|
|
72
72
|
stringio.close
|
|
73
73
|
end
|
|
74
|
-
end
|
|
75
74
|
|
|
75
|
+
# The reader reattaches a quoted tail for every type, so a parenthesis right
|
|
76
|
+
# before a quote must separate it too: the parenthesis is stripped from the
|
|
77
|
+
# line before the quoted text goes back on.
|
|
78
|
+
PAREN_QUOTE_ZONE = <<~ZONEDATA
|
|
79
|
+
$TTL 3600
|
|
80
|
+
caa IN CAA 0 issue("ca.example.net")
|
|
81
|
+
hinfo IN HINFO ("cpu" "os")
|
|
82
|
+
txt IN TXT ("abc" "def")
|
|
83
|
+
ZONEDATA
|
|
84
|
+
|
|
85
|
+
def test_zone_parenthesis_before_quote
|
|
86
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
87
|
+
caa, hinfo, txt = reader.process_file(StringIO.new(PAREN_QUOTE_ZONE))
|
|
88
|
+
|
|
89
|
+
assert_equal('issue', caa.property_tag)
|
|
90
|
+
assert_equal('ca.example.net', caa.property_value)
|
|
91
|
+
|
|
92
|
+
assert_equal('cpu', hinfo.cpu)
|
|
93
|
+
assert_equal('os', hinfo.os)
|
|
94
|
+
|
|
95
|
+
assert_equal(['abc', 'def'], txt.strings)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A backslash escaping the quote abuts it too. RFC 1035 sec 5.1 makes \" a
|
|
99
|
+
# literal quote within an unquoted character-string, so each of these is one
|
|
100
|
+
# string, not two.
|
|
101
|
+
ESCAPED_QUOTE_ZONE = <<~'ZONEDATA'
|
|
102
|
+
$TTL 3600
|
|
103
|
+
lead IN TXT \"escaped
|
|
104
|
+
mid IN TXT abc\"def
|
|
105
|
+
ZONEDATA
|
|
106
|
+
|
|
107
|
+
def test_zone_escaped_quote_not_separated
|
|
108
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
109
|
+
lead, mid = reader.process_file(StringIO.new(ESCAPED_QUOTE_ZONE))
|
|
110
|
+
|
|
111
|
+
assert_equal(['"escaped'], lead.strings)
|
|
112
|
+
assert_equal(['abc"def'], mid.strings)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# SVCB/HTTPS records carry a TargetName in the middle of the RDATA. The zone
|
|
116
|
+
# reader must resolve relative TargetNames against the origin, while leaving
|
|
117
|
+
# the root (".") and already-absolute names untouched.
|
|
118
|
+
SVCB_ZONE = <<~ZONEDATA
|
|
119
|
+
$TTL 3600
|
|
120
|
+
$ORIGIN example.com.
|
|
121
|
+
@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5
|
|
122
|
+
@ IN HTTPS 1 . alpn=h2,h3 ipv4hint=192.0.2.1
|
|
123
|
+
alias IN SVCB 0 svc4.example.com.
|
|
124
|
+
rel IN SVCB 1 foo alpn=h2
|
|
125
|
+
svc IN HTTPS 1 svc.example.net. port=8443
|
|
126
|
+
ZONEDATA
|
|
127
|
+
|
|
128
|
+
def svcb_zone_records
|
|
129
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
130
|
+
records = reader.process_file(StringIO.new(SVCB_ZONE))
|
|
131
|
+
records.reject { |rr| rr.type == Types::SOA }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def test_zone_svcb_https_targets_resolved
|
|
135
|
+
https_root, svcb_alias, svcb_rel, https_svc = svcb_zone_records
|
|
136
|
+
|
|
137
|
+
# Root TargetName stays as the root.
|
|
138
|
+
assert_equal(Types::HTTPS, https_root.type)
|
|
139
|
+
assert_equal('.', https_root.target.to_s(true))
|
|
140
|
+
assert_equal('h2,h3', https_root.params_to_hash['alpn'])
|
|
141
|
+
|
|
142
|
+
# Absolute AliasMode TargetName is untouched.
|
|
143
|
+
assert_equal(0, svcb_alias.priority)
|
|
144
|
+
assert_equal('svc4.example.com.', svcb_alias.target.to_s(true))
|
|
145
|
+
|
|
146
|
+
# Relative TargetName gains the origin.
|
|
147
|
+
assert_equal('foo.example.com.', svcb_rel.target.to_s(true))
|
|
148
|
+
assert_equal('h2', svcb_rel.params_to_hash['alpn'])
|
|
149
|
+
|
|
150
|
+
# Absolute TargetName in another zone is untouched.
|
|
151
|
+
assert_equal('svc.example.net.', https_svc.target.to_s(true))
|
|
152
|
+
assert_equal('8443', https_svc.params_to_hash['port'])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_zone_svcb_https_escaped_final_dots_do_not_make_targets_absolute
|
|
156
|
+
%w[SVCB HTTPS].each do |type|
|
|
157
|
+
{
|
|
158
|
+
'foo\\.' => ['foo.', 'example'],
|
|
159
|
+
'foo\\046' => ['foo.', 'example'],
|
|
160
|
+
'foo\\\\.' => ['foo\\'],
|
|
161
|
+
'foo\\..' => ['foo.'],
|
|
162
|
+
'.' => [],
|
|
163
|
+
'foo.' => ['foo']
|
|
164
|
+
}.each do |target, labels|
|
|
165
|
+
text = "owner 300 IN #{type} 1 #{target}\n"
|
|
166
|
+
record = ZoneReader.new('example.').process_io(StringIO.new(text)).first
|
|
167
|
+
assert_equal(labels, record.target.labels.map(&:string), "#{type} #{target}")
|
|
168
|
+
assert(record.target.absolute?)
|
|
169
|
+
assert_equal(record, record.clone)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# A HTTPS record spread across several lines with parentheses is valid zone
|
|
175
|
+
# format and must be parsed as a single record.
|
|
176
|
+
MULTILINE_ZONE = <<~ZONEDATA
|
|
177
|
+
$TTL 3600
|
|
178
|
+
$ORIGIN example.com.
|
|
179
|
+
@ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5
|
|
180
|
+
@ IN HTTPS ( 1 . alpn=h2,h3
|
|
181
|
+
port=443
|
|
182
|
+
ipv4hint=192.0.2.1 )
|
|
183
|
+
ZONEDATA
|
|
184
|
+
|
|
185
|
+
def test_zone_multiline_https
|
|
186
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
187
|
+
records = reader.process_file(StringIO.new(MULTILINE_ZONE))
|
|
188
|
+
https = records.find { |rr| rr.type == Types::HTTPS }
|
|
189
|
+
refute_nil(https)
|
|
190
|
+
assert_equal(1, https.priority)
|
|
191
|
+
assert_equal('.', https.target.to_s(true))
|
|
192
|
+
assert_equal('h2,h3', https.params_to_hash['alpn'])
|
|
193
|
+
assert_equal('443', https.params_to_hash['port'])
|
|
194
|
+
assert_equal('192.0.2.1', https.params_to_hash['ipv4hint'])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# RFC 9460 Appendix A: a SvcParamValue may be quoted, and the quotes open
|
|
198
|
+
# mid-token, unlike the quoted RDATA of a TXT record. The ech value also puts
|
|
199
|
+
# base64 padding inside the quotes, and an unquoted param after them.
|
|
200
|
+
QUOTED_SVCB_ZONE = <<~ZONEDATA
|
|
201
|
+
$TTL 3600
|
|
202
|
+
alpn IN HTTPS 1 . alpn="h2,h3"
|
|
203
|
+
ech IN HTTPS 1 . ech="AEX+DQBBzQAgACD/AA==" port=8443
|
|
204
|
+
ZONEDATA
|
|
205
|
+
|
|
206
|
+
def test_zone_quoted_svcparam_values
|
|
207
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
208
|
+
alpn, ech = reader.process_file(StringIO.new(QUOTED_SVCB_ZONE))
|
|
209
|
+
|
|
210
|
+
assert_equal('h2,h3', alpn.params_to_hash['alpn'])
|
|
211
|
+
|
|
212
|
+
assert_equal('AEX+DQBBzQAgACD/AA==', ech.params_to_hash['ech'])
|
|
213
|
+
assert_equal('8443', ech.params_to_hash['port'])
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# A quoted SvcParamValue inside the parentheses that group a record across
|
|
217
|
+
# lines. The quote opens mid-token, so the closing parenthesis travels with
|
|
218
|
+
# the quoted tail rather than with the rest of the line. An ech value is what
|
|
219
|
+
# gets wrapped in practice, being long enough to want a second line.
|
|
220
|
+
PAREN_QUOTED_SVCB_ZONE = <<~ZONEDATA
|
|
221
|
+
$TTL 3600
|
|
222
|
+
one IN HTTPS ( 1 . alpn="h2,h3" )
|
|
223
|
+
two IN HTTPS ( 1 . ech="AEX+DQBBzQAgACD/AA=="
|
|
224
|
+
port=8443 )
|
|
225
|
+
ZONEDATA
|
|
226
|
+
|
|
227
|
+
def test_zone_parenthesised_quoted_svcparam_values
|
|
228
|
+
reader = Dnsruby::ZoneReader.new('example.com.')
|
|
229
|
+
one_line, wrapped = reader.process_file(StringIO.new(PAREN_QUOTED_SVCB_ZONE))
|
|
230
|
+
|
|
231
|
+
assert_equal('h2,h3', one_line.params_to_hash['alpn'])
|
|
232
|
+
|
|
233
|
+
assert_equal('AEX+DQBBzQAgACD/AA==', wrapped.params_to_hash['ech'])
|
|
234
|
+
assert_equal('8443', wrapped.params_to_hash['port'])
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def test_zone_literal_parentheses_do_not_continue_a_record
|
|
238
|
+
zone = <<~'ZONE'
|
|
239
|
+
quoted IN HTTPS 1 . key667="a(b)c"
|
|
240
|
+
escaped IN SVCB 1 . key667=a\(b\)c
|
|
241
|
+
decimal IN HTTPS 1 . key667=a\040b\041c
|
|
242
|
+
open IN HTTPS 1 . key667="a\("
|
|
243
|
+
close IN SVCB 1 . key667=a\)
|
|
244
|
+
quote IN HTTPS 1 . key667="a\"(b)c"
|
|
245
|
+
following IN A 192.0.2.1
|
|
246
|
+
ZONE
|
|
247
|
+
records = @reader.process_file(StringIO.new(zone))
|
|
248
|
+
assert_equal(7, records.size)
|
|
249
|
+
assert_equal(['a(b)c', 'a(b)c', 'a(b)c', 'a(', 'a)', 'a"(b)c'],
|
|
250
|
+
records.first(6).map { |rr| rr.params[667] })
|
|
251
|
+
assert_equal('192.0.2.1', records.last.address.to_s)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def test_zone_grouping_ignores_parentheses_in_values_and_comments
|
|
255
|
+
zone = <<~'ZONE'
|
|
256
|
+
wrapped IN HTTPS (1 . key667="a;(b)c" ; unmatched ) in comment
|
|
257
|
+
key668=a\) ; unmatched ( in comment
|
|
258
|
+
port=443) ; ignored (
|
|
259
|
+
following IN A 192.0.2.1
|
|
260
|
+
ZONE
|
|
261
|
+
wrapped, following = @reader.process_file(StringIO.new(zone))
|
|
262
|
+
assert_equal('a;(b)c', wrapped.params[667])
|
|
263
|
+
assert_equal('a)', wrapped.params[668])
|
|
264
|
+
assert_equal('443', wrapped.params_to_hash['port'])
|
|
265
|
+
assert_equal('192.0.2.1', following.address.to_s)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_normalise_line_handles_grouping_when_called_directly
|
|
269
|
+
line = @reader.normalise_line('svc IN HTTPS (1 . key667="a\(b\)c")')
|
|
270
|
+
rr = RR.create(line)
|
|
271
|
+
assert_equal('svc.example.com.', rr.name.to_s(true))
|
|
272
|
+
assert_equal('a(b)c', rr.params[667])
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def test_normalise_line_preserves_standalone_unmatched_grouping
|
|
276
|
+
['host IN A (192.0.2.7', 'host IN A 192.0.2.7)'].each do |input|
|
|
277
|
+
rr = RR.create(@reader.normalise_line(input))
|
|
278
|
+
assert_equal('host.example.com.', rr.name.to_s(true))
|
|
279
|
+
assert_equal('192.0.2.7', rr.address.to_s)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def test_unbalanced_grouping_is_rejected
|
|
284
|
+
assert_raises(Dnsruby::ZoneReader::ParseException) do
|
|
285
|
+
@reader.process_file(StringIO.new('bad IN HTTPS 1 .)'))
|
|
286
|
+
end
|
|
287
|
+
assert_raises(Dnsruby::ZoneReader::ParseException) do
|
|
288
|
+
@reader.process_file(StringIO.new('bad IN HTTPS (1 .'))
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|