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.
Files changed (167) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -4
  3. data/DNSSEC +3 -2
  4. data/EXAMPLES +1 -1
  5. data/README.md +370 -64
  6. data/RELEASE_NOTES.md +302 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/demo/rubydig.rb +0 -2
  10. data/dnsruby.gemspec +5 -4
  11. data/lib/dnsruby/cache.rb +279 -47
  12. data/lib/dnsruby/code_mapper.rb +35 -8
  13. data/lib/dnsruby/code_mappers.rb +63 -49
  14. data/lib/dnsruby/config.rb +75 -4
  15. data/lib/dnsruby/deadline_queue.rb +75 -0
  16. data/lib/dnsruby/dnssec.rb +148 -45
  17. data/lib/dnsruby/ipv6.rb +45 -5
  18. data/lib/dnsruby/key_cache.rb +202 -35
  19. data/lib/dnsruby/message/decoder.rb +85 -19
  20. data/lib/dnsruby/message/encoder.rb +27 -16
  21. data/lib/dnsruby/message/header.rb +8 -12
  22. data/lib/dnsruby/message/message.rb +53 -22
  23. data/lib/dnsruby/message/section.rb +12 -34
  24. data/lib/dnsruby/name.rb +5 -2
  25. data/lib/dnsruby/packet_sender.rb +645 -203
  26. data/lib/dnsruby/presentation.rb +147 -0
  27. data/lib/dnsruby/query_control.rb +132 -0
  28. data/lib/dnsruby/recursor.rb +795 -339
  29. data/lib/dnsruby/resolver.rb +468 -186
  30. data/lib/dnsruby/resource/CAA.rb +67 -16
  31. data/lib/dnsruby/resource/DLV.rb +6 -0
  32. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  33. data/lib/dnsruby/resource/DS.rb +27 -28
  34. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  35. data/lib/dnsruby/resource/IN.rb +19 -0
  36. data/lib/dnsruby/resource/MX.rb +2 -2
  37. data/lib/dnsruby/resource/NSEC.rb +6 -10
  38. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  39. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  40. data/lib/dnsruby/resource/RR.rb +108 -65
  41. data/lib/dnsruby/resource/RRSIG.rb +5 -5
  42. data/lib/dnsruby/resource/RRSet.rb +38 -22
  43. data/lib/dnsruby/resource/SRV.rb +2 -2
  44. data/lib/dnsruby/resource/SVCB.rb +775 -0
  45. data/lib/dnsruby/resource/TSIG.rb +266 -231
  46. data/lib/dnsruby/resource/TXT.rb +34 -42
  47. data/lib/dnsruby/resource/domain_name.rb +1 -1
  48. data/lib/dnsruby/resource/generic.rb +31 -0
  49. data/lib/dnsruby/resource/generic_cache.rb +384 -0
  50. data/lib/dnsruby/resource/generic_constants.rb +79 -0
  51. data/lib/dnsruby/select_thread.rb +979 -291
  52. data/lib/dnsruby/serial_time.rb +25 -0
  53. data/lib/dnsruby/single_resolver.rb +25 -6
  54. data/lib/dnsruby/single_verifier.rb +1620 -530
  55. data/lib/dnsruby/transport_clock.rb +9 -0
  56. data/lib/dnsruby/update.rb +8 -6
  57. data/lib/dnsruby/validation_budget.rb +143 -0
  58. data/lib/dnsruby/validator_thread.rb +139 -58
  59. data/lib/dnsruby/version.rb +1 -1
  60. data/lib/dnsruby/zone_reader.rb +196 -129
  61. data/lib/dnsruby/zone_transfer.rb +157 -25
  62. data/lib/dnsruby.rb +7 -2
  63. data/test/ci_watchdog.rb +33 -0
  64. data/test/fixtures/org-ds-2026-11-05.zone +5 -0
  65. data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
  66. data/test/fork_test_helper.rb +37 -0
  67. data/test/local_dns_server.rb +57 -0
  68. data/test/soak_server.rb +152 -0
  69. data/test/socket_helpers.rb +26 -0
  70. data/test/spec_helper.rb +8 -13
  71. data/test/tc_attempt_configuration.rb +137 -0
  72. data/test/tc_caa.rb +2 -2
  73. data/test/tc_cache_keys.rb +36 -0
  74. data/test/tc_cancelled_send_capacity.rb +158 -0
  75. data/test/tc_dnssec_alias_validation.rb +289 -0
  76. data/test/tc_dnssec_budget.rb +489 -0
  77. data/test/tc_dnssec_cache_bounds.rb +197 -0
  78. data/test/tc_dnssec_cache_lifetime.rb +615 -0
  79. data/test/tc_dnssec_denial_proofs.rb +285 -0
  80. data/test/tc_dnssec_recursion_limits.rb +186 -0
  81. data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
  82. data/test/tc_dnssec_trust_anchors.rb +286 -0
  83. data/test/tc_dnssec_trust_races.rb +147 -0
  84. data/test/tc_dnssec_wildcard_proofs.rb +249 -0
  85. data/test/tc_ds.rb +5 -5
  86. data/test/tc_edns_retry_regressions.rb +255 -0
  87. data/test/tc_escapedchars.rb +8 -0
  88. data/test/tc_final_review_fixes.rb +167 -0
  89. data/test/tc_final_transport_contracts.rb +104 -0
  90. data/test/tc_generic_cache.rb +852 -0
  91. data/test/tc_header.rb +26 -0
  92. data/test/tc_ipv6.rb +120 -0
  93. data/test/tc_key_cache_identity.rb +382 -0
  94. data/test/tc_malformed_tcp.rb +712 -0
  95. data/test/tc_name.rb +99 -5
  96. data/test/tc_nameserver_bootstrap.rb +322 -0
  97. data/test/tc_nsec.rb +4 -45
  98. data/test/tc_nsec3.rb +17 -1
  99. data/test/tc_pipeline_retirement.rb +358 -0
  100. data/test/tc_pipeline_retry.rb +462 -0
  101. data/test/tc_pipeline_send.rb +663 -0
  102. data/test/tc_protocol_review_fixes.rb +95 -0
  103. data/test/tc_query_control.rb +90 -0
  104. data/test/tc_record_codec_contracts.rb +107 -0
  105. data/test/tc_record_regressions.rb +225 -0
  106. data/test/tc_recur.rb +1 -0
  107. data/test/tc_recursor_address_fallback.rb +169 -0
  108. data/test/tc_recursor_limits.rb +139 -0
  109. data/test/tc_release_auxiliary_fixes.rb +288 -0
  110. data/test/tc_release_configuration_fixes.rb +612 -0
  111. data/test/tc_release_dnssec_completion.rb +405 -0
  112. data/test/tc_release_dnssec_fixes.rb +305 -0
  113. data/test/tc_release_negative_cache.rb +327 -0
  114. data/test/tc_release_recursor_cache.rb +212 -0
  115. data/test/tc_release_review_transport.rb +372 -0
  116. data/test/tc_release_review_transport_native.rb +56 -0
  117. data/test/tc_release_transport.rb +906 -0
  118. data/test/tc_release_transport_fixes.rb +196 -0
  119. data/test/tc_release_zone_defaults.rb +157 -0
  120. data/test/tc_release_zone_ttls.rb +39 -0
  121. data/test/tc_res_config.rb +42 -20
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +21 -19
  124. data/test/tc_resolver_concurrency.rb +383 -0
  125. data/test/tc_resolver_configuration_contracts.rb +31 -0
  126. data/test/tc_resolver_validation.rb +211 -0
  127. data/test/tc_review_cache.rb +163 -0
  128. data/test/tc_review_code_mapper.rb +279 -0
  129. data/test/tc_review_control_deadlines.rb +169 -0
  130. data/test/tc_review_followup_transport.rb +583 -0
  131. data/test/tc_review_packet_contracts.rb +369 -0
  132. data/test/tc_review_presentation.rb +213 -0
  133. data/test/tc_review_record_compatibility.rb +101 -0
  134. data/test/tc_review_transport_fixes.rb +195 -0
  135. data/test/tc_review_transport_native.rb +415 -0
  136. data/test/tc_review_validation_priming.rb +178 -0
  137. data/test/tc_rr-opt.rb +5 -2
  138. data/test/tc_rr-unknown.rb +19 -0
  139. data/test/tc_rr.rb +14 -0
  140. data/test/tc_rrset_grouping.rb +50 -0
  141. data/test/tc_rrset_review_fixes.rb +58 -0
  142. data/test/tc_rrsig.rb +21 -1
  143. data/test/tc_scoped_ipv6.rb +153 -0
  144. data/test/tc_scoped_ipv6_select.rb +157 -0
  145. data/test/tc_scoped_ipv6_transport.rb +329 -0
  146. data/test/tc_selector_wakeup.rb +42 -0
  147. data/test/tc_soak.rb +118 -196
  148. data/test/tc_socket_helpers.rb +90 -0
  149. data/test/tc_supporting_lookup_limits.rb +252 -0
  150. data/test/tc_svcb.rb +1043 -0
  151. data/test/tc_svcb_signed_target.rb +145 -0
  152. data/test/tc_svcb_transport.rb +120 -0
  153. data/test/tc_tcp.rb +69 -43
  154. data/test/tc_tcp_pipelining.rb +95 -204
  155. data/test/tc_transport_fork.rb +179 -0
  156. data/test/tc_tsig.rb +2 -2
  157. data/test/tc_tsig_api.rb +251 -0
  158. data/test/tc_tsig_rfc8945.rb +357 -0
  159. data/test/tc_v2_release_dnssec.rb +442 -0
  160. data/test/tc_v2_release_transport.rb +304 -0
  161. data/test/tc_v2_zone_transfer_native.rb +58 -0
  162. data/test/tc_zone_reader.rb +217 -1
  163. data/test/tcp_pipelining_server.rb +150 -0
  164. data/test/ts_offline.rb +77 -6
  165. data/test/ts_online.rb +2 -1
  166. metadata +105 -10
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,612 @@
1
+ require_relative 'tc_release_transport'
2
+ require 'minitest/mock'
3
+
4
+ class ReleaseConfigurationFixesTest < Minitest::Test
5
+ def test_rejected_port_pool_preserves_parent_and_sender
6
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', src_port: [60_001, 60_002])
7
+ assert_raises(ArgumentError) { resolver.src_port = [] }
8
+ assert_equal([60_001, 60_002], resolver.src_port)
9
+ assert_equal([60_001, 60_002], resolver.single_resolvers.first.src_port)
10
+ resolver.port = 1053
11
+ assert_equal(1053, resolver.single_resolvers.first.port)
12
+ ensure
13
+ resolver&.close
14
+ end
15
+
16
+ def test_zero_delay_keeps_and_dispatches_all_servers_and_retries
17
+ isolated(<<~'SCRIPT')
18
+ resolver = Dnsruby::Resolver.new(nameserver: ['127.0.0.1', '127.0.0.2'],
19
+ retry_delay: 0, retry_times: 3, query_timeout: 1)
20
+ plan = resolver.generate_timeouts(100.0)
21
+ raise 'different deadlines' unless plan.keys == [100.0]
22
+ raise 'attempt lost' unless plan[100.0].length == 6
23
+ sent = []
24
+ resolver.single_resolvers.each do |sender|
25
+ sender.define_singleton_method(:prepare_send_async) do |_message, _queue, id, *_|
26
+ -> { sent << [server, id.last] }
27
+ end
28
+ end
29
+ resolver.send_async(query, Queue.new, :zero_delay)
30
+ expected = ['127.0.0.1', '127.0.0.2'].product([0, 1, 2])
31
+ raise 'simultaneous attempt overwritten' unless sent.sort == expected.sort
32
+ resolver.close
33
+ SCRIPT
34
+ end
35
+
36
+ def test_configured_pool_tries_each_port_and_reports_exhaustion_once
37
+ isolated(<<~'SCRIPT')
38
+ SecureRandom.define_singleton_method(:random_number) { |_| 0 }
39
+ [false, true].product([false, true]).each do |tcp, all_busy|
40
+ attempts, sockets = [], []
41
+ if tcp
42
+ Dnsruby::PacketSender.define_method(:connect_tcp) do |_server, _port, _address, source_port, *_|
43
+ attempts << source_port
44
+ raise Errno::EADDRINUSE if all_busy || source_port == 60_001
45
+ FrameSocket.new.tap { |socket| sockets << socket }
46
+ end
47
+ else
48
+ UDPSocket.define_singleton_method(:new) do |*|
49
+ FrameSocket.new.tap do |socket|
50
+ socket.define_singleton_method(:bind) do |_address, source_port|
51
+ attempts << source_port
52
+ raise Errno::EADDRINUSE if all_busy || source_port == 60_001
53
+ end
54
+ socket.define_singleton_method(:connect) { |*| }
55
+ sockets << socket
56
+ end
57
+ end
58
+ end
59
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', use_tcp: tcp,
60
+ src_port: [60_001, 60_002], dnssec: false)
61
+ queue = Queue.new
62
+ writer.send_async(query, queue, :pool)
63
+ drain_errors
64
+ raise 'pool did not try distinct ports' unless attempts == [60_001, 60_002]
65
+ if all_busy
66
+ raise 'missing or duplicate failure' unless queue.length == 1 && queue.pop.last.is_a?(IOError)
67
+ else
68
+ raise 'free candidate did not send' if sockets.last.written.empty?
69
+ raise 'collision completed successful query' unless queue.empty?
70
+ SELECTOR.remove_id(:pool)
71
+ end
72
+ writer.close
73
+ sockets.each { |socket| raise 'socket retained' unless socket.closed?; assert_clean(socket) }
74
+ end
75
+ SCRIPT
76
+ end
77
+
78
+ def test_random_port_retry_keeps_its_original_mode_after_configuration_changes
79
+ isolated(<<~'SCRIPT')
80
+ SecureRandom.define_singleton_method(:random_number) { |_| 7 }
81
+ attempts = []
82
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', use_tcp: true, dnssec: false)
83
+ socket = FrameSocket.new
84
+ writer.define_singleton_method(:connect_tcp) do |_server, _port, _address, source_port, *_|
85
+ attempts << source_port
86
+ if attempts.length == 1
87
+ self.src_port = 60_001
88
+ raise Errno::EADDRINUSE
89
+ end
90
+ socket
91
+ end
92
+ writer.send_async(query, Queue.new, :snapshot)
93
+ raise 'retry adopted a different port pool' unless attempts == [50_007, 50_007]
94
+ SELECTOR.remove_id(:snapshot)
95
+ writer.close
96
+ SCRIPT
97
+ end
98
+
99
+ def test_pipeline_port_metadata_tracks_the_complete_captured_pool
100
+ isolated(<<~'SCRIPT')
101
+ SecureRandom.define_singleton_method(:random_number) { |limit| limit == 15_536 ? 7 : 0 }
102
+ [0, [60_001, 60_002]].product([false, true]).each do |ports, replace|
103
+ attempts, sockets = [], []
104
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', use_tcp: true,
105
+ tcp_pipelining: true, src_port: ports, dnssec: false)
106
+ writer.define_singleton_method(:connect_tcp) do |_server, _port, _address, source_port, *_|
107
+ attempts << source_port
108
+ if attempts.length == 1
109
+ self.src_port = 60_003 if replace
110
+ raise Errno::EADDRINUSE
111
+ end
112
+ FrameSocket.new.tap do |socket|
113
+ socket.define_singleton_method(:connect) { |_| raise Errno::EISCONN }
114
+ sockets << socket
115
+ end
116
+ end
117
+ 3.times do |id|
118
+ message = query("snapshot#{id}.example.")
119
+ message.header.id = id
120
+ writer.send_async(message, Queue.new, id)
121
+ if id == 0
122
+ expected = ports == 0 ? [50_007, 50_007] : [60_001, 60_002]
123
+ raise 'retry changed its captured pool' unless attempts == expected
124
+ end
125
+ end
126
+ if replace
127
+ raise 'new query reused the old source binding' unless attempts.last == 60_003 && attempts.length == 3
128
+ raise 'replacement pipeline was not reused' unless sockets.length == 2
129
+ else
130
+ raise 'depleted candidates changed pipeline identity' unless attempts.length == 2 && sockets.length == 1
131
+ end
132
+ 3.times { |id| SELECTOR.remove_id(id) }
133
+ writer.close
134
+ sockets.each { |socket| raise 'socket retained' unless socket.closed?; assert_clean(socket) }
135
+ end
136
+ SCRIPT
137
+ end
138
+
139
+ def test_recursive_hops_inherit_transport_settings_but_not_upstream_tsig
140
+ with_recursion_state do
141
+ parent = Dnsruby::Resolver.new(nameserver: '127.0.0.1', port: 1053, use_tcp: true,
142
+ query_timeout: 0.25, packet_timeout: 0.1, retry_times: 2, retry_delay: 0.02,
143
+ src_address: '127.0.0.1', src_port: [60_001, 60_002], udp_size: 1400)
144
+ parent.tsig = ['client-only.example.', 'c2VjcmV0', 'hmac-sha256']
145
+ recursor = Dnsruby::Recursor.new(parent)
146
+ response = Dnsruby::Message.new('www.example.', 'A')
147
+ response.header.aa = true
148
+ hop = Struct.new(:dnssec, :recurse, :closed, :response) do
149
+ def send_message(_message); response; end
150
+ def close; self.closed = true; end
151
+ end.new(nil, nil, false, response)
152
+ options = nil
153
+ Dnsruby::Resolver.stub(:new, ->(settings) { options = settings; hop }) do
154
+ assert_same(response, recursor._dorecursion('www.example.', 'A', 'IN', '.', root_authorities, 0, true))
155
+ end
156
+ {port: 1053, use_tcp: true, query_timeout: 0.25, packet_timeout: 0.1,
157
+ retry_times: 2, retry_delay: 0.02, src_address: '127.0.0.1',
158
+ src_port: [60_001, 60_002], udp_size: 1400}.each { |key, value| assert_equal(value, options[key]) }
159
+ refute_same(parent.src_port, options[:src_port])
160
+ refute(options.key?(:tsig))
161
+ assert_equal(true, hop.closed)
162
+ ensure
163
+ parent&.close
164
+ end
165
+ end
166
+
167
+ def test_recursive_deadline_covers_bootstrap_and_preserves_inherited_deadline
168
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', query_timeout: 0.02)
169
+ recursor = Dnsruby::Recursor.new(resolver)
170
+ wait = ->(*) { Dnsruby::QueryControl.current.pop(Dnsruby::QueryControl::Queue.new) }
171
+ Dnsruby::Recursor.stub(:ensure_hints, wait) do
172
+ assert_raises(Dnsruby::ResolvTimeout) { recursor.query('www.example.', 'A', 'IN', true) }
173
+ resolver.query_timeout = 1
174
+ parent = Dnsruby::QueryControl.new(deadline: Dnsruby::TransportClock.now + 0.02)
175
+ assert_raises(Dnsruby::QueryControl::DeadlineExceeded) do
176
+ Dnsruby::QueryControl.with(parent) { recursor.query('www.example.', 'A', 'IN', true) }
177
+ end
178
+ end
179
+ assert_nil(Dnsruby::QueryControl.current)
180
+ ensure
181
+ resolver&.close
182
+ end
183
+
184
+ def test_no_glue_referral_keeps_address_discovery_in_the_recursive_query
185
+ with_no_glue_referral do |run, calls, hops|
186
+ response = run.call
187
+ assert_equal('192.0.2.1', response.answer.first.address.to_s)
188
+ assert_equal(['www.child.example.', 'ns.external.', 'www.child.example.'], calls.map(&:first))
189
+ assert_equal(1, calls.map { |_, _, control, _| control.deadline }.uniq.length)
190
+ assert_equal(1, calls.map { |_, _, _, budget| budget.object_id }.uniq.length)
191
+ calls.each do |_, settings, control, budget|
192
+ refute_nil(control)
193
+ refute_nil(budget)
194
+ assert_equal(1053, settings[:port])
195
+ assert_equal(true, settings[:use_tcp])
196
+ assert_equal('127.0.0.1', settings[:src_address])
197
+ assert_equal(60_001, settings[:src_port])
198
+ assert(settings[:nameserver].all? { |server| server.start_with?('192.0.2.') })
199
+ end
200
+ assert(hops.all?(&:closed))
201
+ end
202
+ end
203
+
204
+ def test_no_glue_address_discovery_obeys_the_overall_deadline
205
+ with_no_glue_referral(wait_for_deadline: true) do |run, calls, hops|
206
+ assert_raises(Dnsruby::ResolvTimeout) { run.call }
207
+ assert_equal(['www.child.example.', 'ns.external.'], calls.map(&:first))
208
+ assert(hops.all?(&:closed))
209
+ assert_nil(Dnsruby::QueryControl.current)
210
+ end
211
+ end
212
+
213
+ def test_mixed_referral_discovers_missing_addresses_when_glue_servers_fail
214
+ [Dnsruby::ResolvTimeout, IOError, Dnsruby::ServFail, Dnsruby::Refused,
215
+ Dnsruby::NotImp, Dnsruby::FormErr, Dnsruby::SocketEofResolvError].each do |error|
216
+ with_no_glue_referral(glue_error: error.new) do |run, calls, hops|
217
+ response = run.call
218
+ assert_equal('192.0.2.1', response.answer.first.address.to_s)
219
+ assert_equal([['192.0.2.53'], ['192.0.2.55'], ['192.0.2.53'], ['192.0.2.54']],
220
+ calls.map { |_, settings, _, _| settings[:nameserver] })
221
+ assert(hops.all?(&:closed))
222
+ end
223
+ end
224
+ end
225
+
226
+ def test_mixed_referral_retains_original_error_when_missing_addresses_are_unavailable
227
+ original = Dnsruby::Refused.new('addressed server refused')
228
+ with_no_glue_referral(glue_error: original, address_error: Dnsruby::ServFail.new) do |run, calls, hops|
229
+ assert_same(original, assert_raises(Dnsruby::Refused) { run.call })
230
+ assert_equal(['www.child.example.', 'www.child.example.', 'ns.external.'], calls.map(&:first))
231
+ assert(hops.all?(&:closed))
232
+ end
233
+ end
234
+
235
+ def test_mixed_referral_does_not_hide_validation_failure_during_address_discovery
236
+ failure = Dnsruby::VerifyError.new('address validation failed')
237
+ with_no_glue_referral(glue_error: Dnsruby::Refused.new, address_error: failure) do |run, calls, hops|
238
+ assert_same(failure, assert_raises(Dnsruby::VerifyError) { run.call })
239
+ assert_equal(['www.child.example.', 'www.child.example.', 'ns.external.'], calls.map(&:first))
240
+ assert(hops.all?(&:closed))
241
+ end
242
+ end
243
+
244
+ def test_mixed_referral_keeps_terminal_errors_terminal
245
+ [Dnsruby::NXDomain, Dnsruby::VerifyError, Dnsruby::ValidationLimitError,
246
+ Dnsruby::QueryControl::Cancelled, RuntimeError].each do |error|
247
+ with_no_glue_referral(glue_error: error.new) do |run, calls, hops|
248
+ assert_raises(error) { run.call }
249
+ assert_equal(['www.child.example.', 'www.child.example.'], calls.map(&:first))
250
+ assert(hops.all?(&:closed))
251
+ end
252
+ end
253
+ end
254
+
255
+ def test_early_recursive_cancellation_preserves_outer_cache_generation
256
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1')
257
+ recursor = Dnsruby::Recursor.new(resolver)
258
+ previous = Thread.current[:dnsruby_recursor_generation]
259
+ Thread.current[:dnsruby_recursor_generation] = :outer_query
260
+ control = Dnsruby::QueryControl.new
261
+ control.cancel
262
+ Thread.current[:dnsruby_query_control] = control
263
+ assert_raises(Dnsruby::QueryControl::Cancelled) { recursor.query('www.example.') }
264
+ assert_equal(:outer_query, Thread.current[:dnsruby_recursor_generation])
265
+ ensure
266
+ Thread.current[:dnsruby_recursor_generation] = previous
267
+ Thread.current[:dnsruby_query_control] = nil
268
+ resolver&.close
269
+ end
270
+
271
+ def test_hints_reset_keeps_an_active_query_usable_without_repopulating_old_cache
272
+ with_recursion_state do
273
+ klass = Dnsruby::Recursor
274
+ root = root_authorities
275
+ klass.class_variable_set(:@@hints, root)
276
+ klass.class_variable_set(:@@zones_cache, {'.' => root})
277
+ klass.class_variable_set(:@@stale_hints, nil)
278
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', do_caching: false)
279
+ recursor = klass.new(resolver)
280
+ original = recursor.method(:get_closest_known_zone_authorities_for)
281
+ entered, resume = Queue.new, Queue.new
282
+ recursor.define_singleton_method(:get_closest_known_zone_authorities_for) do |name|
283
+ entered << true
284
+ resume.pop
285
+ original.call(name)
286
+ end
287
+ recursor.define_singleton_method(:_dorecursion) do |*|
288
+ klass.cache_zone('old.example.', root, 60)
289
+ klass.cache_authority('old.example.', root.values.first)
290
+ Dnsruby::Message.new('www.example.')
291
+ end
292
+ worker = Thread.new { recursor.query('www.example.', 'A', 'IN', true) }
293
+ entered.pop
294
+ klass.reset_hints
295
+ resume << true
296
+ assert_instance_of(Dnsruby::Message, worker.value)
297
+ refute(klass.class_variable_get(:@@zones_cache).key?('old.example.'))
298
+ refute(klass.class_variable_get(:@@authority_cache).key?('old.example.'))
299
+ ensure
300
+ resume << true if resume
301
+ worker&.join
302
+ resolver&.close
303
+ end
304
+ end
305
+
306
+ def test_long_lived_recursor_refreshes_expired_root_addresses
307
+ with_recursion_state do
308
+ klass = Dnsruby::Recursor
309
+ clock = 1_800_000_000
310
+ Time.stub(:now, -> { Time.at(clock) }) do
311
+ addresses = klass::AddressCache.new
312
+ addresses.push(['192.0.2.53', 1])
313
+ root = {'ns.root' => addresses}
314
+ klass.class_variable_set(:@@hints, root)
315
+ klass.class_variable_set(:@@zones_cache, {'.' => root})
316
+ klass.class_variable_set(:@@stale_hints, nil)
317
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', query_timeout: 1, do_caching: false)
318
+ recursor = klass.new(resolver)
319
+ used_addresses = []
320
+ recursor.define_singleton_method(:_dorecursion) do |name, type, _klass, _zone, authorities, *_|
321
+ used_addresses << authorities.values.flat_map(&:values)
322
+ Dnsruby::Message.new(name, type)
323
+ end
324
+ lookups = 0
325
+ bootstrap = lambda do |*|
326
+ lookups += 1
327
+ assert_instance_of(Dnsruby::QueryControl, Dnsruby::QueryControl.current)
328
+ refute(klass.class_variable_get(:@@mutex).owned?)
329
+ packet = Dnsruby::Message.new('.', 'NS')
330
+ packet.add_answer(Dnsruby::RR.create('. 300 IN NS ns.root.'))
331
+ packet.add_additional(Dnsruby::RR.create('ns.root. 300 IN A 192.0.2.54'))
332
+ packet
333
+ end
334
+ resolver.stub(:query_no_validation_or_recursion, bootstrap) do
335
+ recursor.query('www.example.', 'A', 'IN', true)
336
+ clock += 1
337
+ recursor.query('www.example.', 'A', 'IN', true)
338
+ recursor.query('www.example.', 'A', 'IN', true)
339
+ end
340
+ assert_equal(1, lookups)
341
+ assert_equal([['192.0.2.53'], ['192.0.2.54'], ['192.0.2.54']], used_addresses)
342
+ ensure
343
+ resolver&.close
344
+ end
345
+ end
346
+ end
347
+
348
+ def test_default_recursor_uses_current_hint_addresses_without_resolving_owner_names
349
+ with_recursion_state do
350
+ klass = Dnsruby::Recursor
351
+ root = root_authorities
352
+ klass.class_variable_set(:@@hints, root)
353
+ klass.class_variable_set(:@@stale_hints, nil)
354
+ klass.class_variable_set(:@@nameservers, root.keys)
355
+ configured = []
356
+ factory = ->(*args) { configured << args; Struct.new(:dnssec).new }
357
+ Dnsruby::Resolver.stub(:new, factory) do
358
+ klass.new
359
+ assert_equal([[{nameserver: ['192.0.2.53']}]], configured)
360
+ klass.reset_hints
361
+ klass.new
362
+ assert_equal([], configured.last)
363
+ # Expired hints also leave the normal Resolver bootstrap in place.
364
+ root.values.first.push(['192.0.2.53', 0])
365
+ klass.class_variable_set(:@@stale_hints, nil)
366
+ klass.new
367
+ assert_equal([], configured.last)
368
+ end
369
+ end
370
+ end
371
+
372
+ def test_expired_root_addresses_refresh_during_delegated_server_discovery
373
+ [false, true].each do |reset|
374
+ with_root_expiry_during_lookup(reset: reset) do |run, trace|
375
+ response = run.call
376
+ assert_equal('192.0.2.1', response.answer.first.address.to_s)
377
+ assert_equal(['www.child.example.', 'ns.external.', 'www.child.example.'],
378
+ trace[:calls].map(&:first))
379
+ assert_equal([['192.0.2.53'], ['192.0.2.54'], ['192.0.2.55']],
380
+ trace[:calls].map { |_, settings, *_| settings[:nameserver] })
381
+ assert_equal(1, trace[:bootstraps].length)
382
+ operations = trace[:calls] + trace[:bootstraps]
383
+ assert_equal(1, operations.map { |_, _, control, _| control.deadline }.uniq.length)
384
+ assert_equal(1, operations.map { |_, _, _, budget| budget.object_id }.uniq.length)
385
+ assert_equal([trace[:generation]], operations.map { |_, _, _, _, generation| generation }.uniq)
386
+ trace[:calls].each do |_, settings, *_|
387
+ assert_equal(1053, settings[:port])
388
+ assert_equal(true, settings[:use_tcp])
389
+ assert_equal('127.0.0.1', settings[:src_address])
390
+ end
391
+ assert(trace[:hops].all?(&:closed))
392
+ assert_equal(['.'], Dnsruby::Recursor.class_variable_get(:@@zones_cache).keys)
393
+ assert_empty(Dnsruby::Recursor.class_variable_get(:@@authority_cache))
394
+ end
395
+ end
396
+ end
397
+
398
+ def test_root_refresh_failure_stops_address_discovery_without_repeated_bootstrap
399
+ [{bootstrap_error: Dnsruby::Refused.new}, {bootstrap_error: IOError.new},
400
+ {bootstrap_ttl: nil}].each do |options|
401
+ with_root_expiry_during_lookup(**options) do |run, trace|
402
+ assert_nil(run.call)
403
+ assert_equal(1, trace[:bootstraps].length)
404
+ assert_equal(['www.child.example.'], trace[:calls].map(&:first))
405
+ assert(trace[:hops].all?(&:closed))
406
+ refute(Dnsruby::Recursor.class_variable_get(:@@hints_mutex).locked?)
407
+ end
408
+ end
409
+ end
410
+
411
+ def test_zero_ttl_root_refresh_completes_the_current_lookup
412
+ with_root_expiry_during_lookup(bootstrap_ttl: 0) do |run, trace|
413
+ assert_equal('192.0.2.1', run.call.answer.first.address.to_s)
414
+ assert_equal(1, trace[:bootstraps].length)
415
+ assert_equal(['www.child.example.', 'ns.external.', 'www.child.example.'], trace[:calls].map(&:first))
416
+ assert_empty(Dnsruby::Recursor.class_variable_get(:@@hints).values.first.values)
417
+ assert(trace[:hops].all?(&:closed))
418
+ end
419
+ end
420
+
421
+ def test_root_refresh_preserves_cancellation_and_unexpected_errors
422
+ [Dnsruby::QueryControl::Cancelled.new, Dnsruby::VerifyError.new, RuntimeError.new].each do |error|
423
+ with_root_expiry_during_lookup(bootstrap_error: error) do |run, trace|
424
+ assert_same(error, assert_raises(error.class) { run.call })
425
+ assert_equal(1, trace[:bootstraps].length)
426
+ assert(trace[:hops].all?(&:closed))
427
+ refute(Dnsruby::Recursor.class_variable_get(:@@hints_mutex).locked?)
428
+ assert_nil(Dnsruby::QueryControl.current)
429
+ end
430
+ end
431
+ end
432
+
433
+ def test_root_refresh_keeps_the_original_query_deadline
434
+ with_root_expiry_during_lookup(wait_for_deadline: true) do |run, trace|
435
+ assert_raises(Dnsruby::ResolvTimeout) { run.call }
436
+ assert_equal(1, trace[:bootstraps].length)
437
+ assert(trace[:hops].all?(&:closed))
438
+ refute(Dnsruby::Recursor.class_variable_get(:@@hints_mutex).locked?)
439
+ assert_nil(Dnsruby::QueryControl.current)
440
+ end
441
+ end
442
+
443
+ def test_hints_replacement_publishes_only_a_complete_bootstrap
444
+ with_recursion_state do
445
+ klass = Dnsruby::Recursor
446
+ root = root_authorities
447
+ klass.class_variable_set(:@@hints, root)
448
+ klass.class_variable_set(:@@zones_cache, {'.' => root, 'example.' => root})
449
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1')
450
+ resolver.stub(:query_no_validation_or_recursion, ->(*) { raise Dnsruby::ResolvTimeout }) do
451
+ assert_raises(Dnsruby::ResolvTimeout) { klass.set_hints(['192.0.2.54'], resolver) }
452
+ end
453
+ assert_same(root, klass.class_variable_get(:@@hints))
454
+ assert(klass.class_variable_get(:@@zones_cache).key?('example.'))
455
+ packet = Dnsruby::Message.new('.', 'NS')
456
+ packet.add_answer(Dnsruby::RR.create('. 300 IN NS ns.root.'))
457
+ packet.add_additional(Dnsruby::RR.create('ns.root. 300 IN A 192.0.2.54'))
458
+ resolver.stub(:query_no_validation_or_recursion, packet) do
459
+ assert_equal(['ns.root'], klass.set_hints([], resolver))
460
+ end
461
+ assert_equal(['192.0.2.54'], klass.class_variable_get(:@@hints)['ns.root'].values)
462
+ assert_equal(['.'], klass.class_variable_get(:@@zones_cache).keys)
463
+ assert_equal(false, resolver.recurse)
464
+ klass.reset_hints
465
+ default = klass.new
466
+ assert_instance_of(Dnsruby::Resolver, default.resolver)
467
+ ensure
468
+ default&.resolver&.close
469
+ resolver&.close
470
+ end
471
+ end
472
+
473
+ private
474
+
475
+ def with_root_expiry_during_lookup(reset: false, bootstrap_error: nil,
476
+ bootstrap_ttl: 300, wait_for_deadline: false)
477
+ with_recursion_state do
478
+ klass = Dnsruby::Recursor
479
+ clock = 1_800_000_000
480
+ Time.stub(:now, -> { Time.at(clock) }) do
481
+ addresses = klass::AddressCache.new
482
+ addresses.push(['192.0.2.53', 1])
483
+ root = {'ns.root.' => addresses}
484
+ klass.class_variable_set(:@@hints, root)
485
+ klass.class_variable_set(:@@zones_cache, {'.' => root})
486
+ klass.class_variable_set(:@@stale_hints, nil)
487
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', do_caching: false,
488
+ query_timeout: wait_for_deadline ? 0.05 : 1, port: 1053, use_tcp: true,
489
+ src_address: '127.0.0.1')
490
+ recursor = klass.new(resolver)
491
+ trace = {calls: [], bootstraps: [], hops: [], generation: klass.class_variable_get(:@@hints_generation)}
492
+ context = -> { [Dnsruby::QueryControl.current, Dnsruby::SelectThread::TransmissionBudget.current,
493
+ Thread.current[:dnsruby_recursor_generation]] }
494
+ bootstrap = lambda do |*|
495
+ trace[:bootstraps] << ['.', nil, *context.call]
496
+ raise bootstrap_error if bootstrap_error
497
+ if wait_for_deadline
498
+ Dnsruby::QueryControl.current.pop(Dnsruby::QueryControl::Queue.new)
499
+ end
500
+ packet = Dnsruby::Message.new('.', 'NS')
501
+ if bootstrap_ttl
502
+ packet.add_answer(Dnsruby::RR.create('. 300 IN NS ns.root.'))
503
+ packet.add_additional(Dnsruby::RR.create("ns.root. #{bootstrap_ttl} IN A 192.0.2.54"))
504
+ end
505
+ packet
506
+ end
507
+ handler = lambda do |settings, message|
508
+ name = message.question.first.qname.to_s(true)
509
+ trace[:calls] << [name, settings, *context.call]
510
+ packet = Dnsruby::Message.new(name, 'A')
511
+ if settings[:nameserver] == ['192.0.2.53']
512
+ packet.add_authority(Dnsruby::RR.create('child.example. 300 IN NS ns.external.'))
513
+ clock += 1
514
+ klass.reset_hints if reset
515
+ elsif name == 'ns.external.'
516
+ packet.add_answer(Dnsruby::RR.create('ns.external. 300 IN A 192.0.2.55'))
517
+ else
518
+ packet.add_answer(Dnsruby::RR.create('www.child.example. 300 IN A 192.0.2.1'))
519
+ end
520
+ packet
521
+ end
522
+ factory = lambda do |settings|
523
+ hop = Struct.new(:dnssec, :recurse, :closed).new
524
+ hop.define_singleton_method(:send_message) { |message| handler.call(settings, message) }
525
+ hop.define_singleton_method(:close) { self.closed = true }
526
+ trace[:hops] << hop
527
+ hop
528
+ end
529
+ run = -> { recursor.query('www.child.example.', 'A', 'IN', true) }
530
+ resolver.stub(:query_no_validation_or_recursion, bootstrap) do
531
+ Dnsruby::Resolver.stub(:new, factory) { yield run, trace }
532
+ end
533
+ ensure
534
+ resolver&.close
535
+ end
536
+ end
537
+ end
538
+
539
+ def with_no_glue_referral(glue_error: nil, address_error: nil, wait_for_deadline: false)
540
+ with_recursion_state do
541
+ klass = Dnsruby::Recursor
542
+ root = root_authorities
543
+ klass.class_variable_set(:@@hints, root)
544
+ klass.class_variable_set(:@@zones_cache, {'.' => root})
545
+ klass.class_variable_set(:@@stale_hints, nil)
546
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', do_caching: false,
547
+ query_timeout: wait_for_deadline ? 0.05 : 1, port: 1053, use_tcp: true,
548
+ src_address: '127.0.0.1', src_port: [60_001])
549
+ recursor = klass.new(resolver)
550
+ calls, hops = [], []
551
+ handler = lambda do |settings, message|
552
+ name = message.question.first.qname.to_s(true)
553
+ calls << [name, settings, Dnsruby::QueryControl.current, Dnsruby::SelectThread::TransmissionBudget.current]
554
+ raise glue_error if settings[:nameserver] == ['192.0.2.55']
555
+ if name == 'ns.external.'
556
+ raise address_error if address_error
557
+ if wait_for_deadline
558
+ Dnsruby::QueryControl.current.pop(Dnsruby::QueryControl::Queue.new)
559
+ end
560
+ answer = Dnsruby::Message.new(name, 'A')
561
+ answer.add_answer(Dnsruby::RR.create('ns.external. 300 IN A 192.0.2.54'))
562
+ elsif settings[:nameserver] == ['192.0.2.53']
563
+ answer = Dnsruby::Message.new(name, 'A')
564
+ answer.add_authority(Dnsruby::RR.create('child.example. 300 IN NS ns.external.'))
565
+ if glue_error
566
+ answer.add_authority(Dnsruby::RR.create('child.example. 300 IN NS ns.child.example.'))
567
+ answer.add_additional(Dnsruby::RR.create('ns.child.example. 300 IN A 192.0.2.55'))
568
+ end
569
+ else
570
+ answer = Dnsruby::Message.new(name, 'A')
571
+ answer.add_answer(Dnsruby::RR.create('www.child.example. 300 IN A 192.0.2.1'))
572
+ end
573
+ answer
574
+ end
575
+ factory = lambda do |settings|
576
+ hop = Struct.new(:dnssec, :recurse, :closed).new
577
+ hop.define_singleton_method(:send_message) { |message| handler.call(settings, message) }
578
+ hop.define_singleton_method(:close) { self.closed = true }
579
+ hops << hop
580
+ hop
581
+ end
582
+ run = -> { recursor.query('www.child.example.', 'A', 'IN', true) }
583
+ Dnsruby::Resolver.stub(:new, factory) { yield run, calls, hops }
584
+ ensure
585
+ resolver&.close
586
+ end
587
+ end
588
+
589
+ def root_authorities
590
+ addresses = Dnsruby::Recursor::AddressCache.new
591
+ addresses.push(['192.0.2.53', 300])
592
+ {'ns.root.' => addresses}
593
+ end
594
+
595
+ def with_recursion_state
596
+ klass = Dnsruby::Recursor
597
+ previous = %i[@@hints @@zones_cache @@zone_expirations @@authority_cache @@nameservers
598
+ @@stale_hints @@hints_generation].to_h { |key| [key, klass.class_variable_get(key)] }
599
+ klass.class_variable_set(:@@authority_cache, {})
600
+ yield
601
+ ensure
602
+ previous.each { |key, value| klass.class_variable_set(key, value) }
603
+ end
604
+
605
+ def isolated(body)
606
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
607
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
608
+ File.expand_path('../lib', __dir__), '-e', "#{TestPipelineSend::FIXTURE}\nTimeout.timeout(10) do\n#{body}\nend")
609
+ assert(status.success?, "#{stdout}\n#{stderr}")
610
+ assert_empty(stderr)
611
+ end
612
+ end