dnsruby 1.73.1 → 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 +314 -1
  7. data/Rakefile +0 -3
  8. data/demo/axfr.rb +24 -6
  9. data/dnsruby.gemspec +6 -4
  10. data/lib/dnsruby/cache.rb +279 -49
  11. data/lib/dnsruby/code_mapper.rb +35 -8
  12. data/lib/dnsruby/code_mappers.rb +63 -49
  13. data/lib/dnsruby/config.rb +77 -4
  14. data/lib/dnsruby/deadline_queue.rb +75 -0
  15. data/lib/dnsruby/dnssec.rb +148 -45
  16. data/lib/dnsruby/ipv6.rb +45 -5
  17. data/lib/dnsruby/key_cache.rb +202 -35
  18. data/lib/dnsruby/message/decoder.rb +85 -19
  19. data/lib/dnsruby/message/encoder.rb +27 -16
  20. data/lib/dnsruby/message/header.rb +8 -12
  21. data/lib/dnsruby/message/message.rb +53 -22
  22. data/lib/dnsruby/message/section.rb +12 -34
  23. data/lib/dnsruby/name.rb +5 -2
  24. data/lib/dnsruby/packet_sender.rb +664 -207
  25. data/lib/dnsruby/presentation.rb +147 -0
  26. data/lib/dnsruby/query_control.rb +132 -0
  27. data/lib/dnsruby/recursor.rb +798 -341
  28. data/lib/dnsruby/resolver.rb +472 -176
  29. data/lib/dnsruby/resource/CAA.rb +67 -16
  30. data/lib/dnsruby/resource/DLV.rb +6 -0
  31. data/lib/dnsruby/resource/DNSKEY.rb +108 -16
  32. data/lib/dnsruby/resource/DS.rb +27 -28
  33. data/lib/dnsruby/resource/HTTPS.rb +16 -0
  34. data/lib/dnsruby/resource/IN.rb +21 -2
  35. data/lib/dnsruby/resource/MX.rb +2 -2
  36. data/lib/dnsruby/resource/NSEC.rb +6 -10
  37. data/lib/dnsruby/resource/NSEC3.rb +55 -19
  38. data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
  39. data/lib/dnsruby/resource/OPT.rb +2 -3
  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 +986 -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 +53 -2
  122. data/test/tc_res_opt.rb +21 -1
  123. data/test/tc_resolver.rb +34 -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 +127 -14
  167. data/.coveralls.yml +0 -2
@@ -0,0 +1,157 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'timeout'
4
+
5
+ class TestScopedIPv6Select < Minitest::Test
6
+ def test_scoped_response_with_interface_punctuation_is_delivered
7
+ with_registered_query('fe80::1%7') do |selector, socket, _client_id, message|
8
+ message.answerip = 'fe80::1%eth-test.100'
9
+ message.answerfrom = message.answerip
10
+ bytes = 'response bytes'
11
+ delivered = []
12
+
13
+ selector.stub(:send_response_to_client, ->(*args) { delivered << args }) do
14
+ selector.process_message(message, bytes, socket)
15
+ end
16
+
17
+ assert_equal([[message, bytes, socket]], delivered)
18
+ end
19
+ end
20
+
21
+ def test_decode_error_attribution_accepts_numeric_and_named_zones
22
+ [
23
+ ['FE80:0000:0000:0000:0000:0000:0000:0001%7', 'fe80::1%eth-test.100'],
24
+ ['fe80::1%eth-test.100', 'FE80:0:0:0:0:0:0:1%7'],
25
+ ].each do |destination, source|
26
+ with_registered_query(destination) do |selector, socket, client_id, _message|
27
+ assert_equal(client_id, selector.get_client_id_from_answerfrom(socket, source, 53))
28
+ end
29
+ end
30
+ end
31
+
32
+ def test_decode_error_attribution_requires_matching_address_and_port
33
+ with_registered_query('fe80::1%7') do |selector, socket, _client_id, _message|
34
+ assert_nil(selector.get_client_id_from_answerfrom(socket, 'fe80::2%eth-test.100', 53))
35
+ assert_nil(selector.get_client_id_from_answerfrom(socket, 'fe80::1%eth-test.100', 54))
36
+ assert_nil(selector.get_client_id_from_answerfrom(socket, '192.0.2.1', 53))
37
+ end
38
+ end
39
+
40
+ def test_decode_error_attribution_stays_on_the_receiving_socket
41
+ with_registered_query('fe80::1%7') do |selector, first_socket, first_id, _message|
42
+ with_registered_query('fe80::1%8') do |_other_selector, second_socket, second_id, _other_message|
43
+ assert_equal(first_id, selector.get_client_id_from_answerfrom(first_socket, 'fe80::1%eth0', 53))
44
+ assert_equal(second_id, selector.get_client_id_from_answerfrom(second_socket, 'fe80::1%eth1', 53))
45
+ end
46
+ end
47
+ end
48
+
49
+ def test_malformed_truncated_udp_response_retries_over_tcp
50
+ with_registered_query('fe80::1%7') do |selector, socket, client_id, query|
51
+ query.header.id = 12_345
52
+ settings = Dnsruby::SelectThread.class_variable_get(:@@query_hash)[client_id]
53
+ settings.client_queue = Queue.new
54
+ sent = Queue.new
55
+ sender = Object.new
56
+ sender.define_singleton_method(:send_async) do |*args|
57
+ raise 'retry lost original attempt identity' unless Thread.current[:dnsruby_response_query].equal?(settings)
58
+ sent << args
59
+ end
60
+ sender.define_singleton_method(:no_tcp) { false }
61
+ settings.single_resolver = sender
62
+ # Keep the matching question complete, then advertise a missing answer.
63
+ # Only an attributable truncated response may trigger TCP fallback.
64
+ response = Dnsruby::Message.decode(query.encode)
65
+ response.header.qr = true
66
+ response.header.tc = true
67
+ packet = response.encode
68
+ packet[6, 2] = [1].pack('n')
69
+ socket.define_singleton_method(:recvfrom_nonblock) { |_| [packet, ['AF_INET6', 53, 'fe80::1%7', 'fe80::1%7']] }
70
+ removed = []
71
+ selector.stub(:tcp?, false) do
72
+ selector.stub(:remove_id_from_mutex_synchronized_block, ->(id) { removed << id }) do
73
+ assert_nil(selector.get_incoming_data(socket, 512))
74
+ assert_equal([query, settings.client_queue, client_id, true], Timeout.timeout(2) { sent.pop })
75
+ end
76
+ end
77
+ assert_empty(removed) # Keep the attempt cancellable until the retry replaces it.
78
+ assert_nil(Thread.current[:dnsruby_response_query])
79
+ end
80
+ end
81
+
82
+ def test_tcp_decode_error_uses_partial_header_on_receiving_pipeline_socket
83
+ with_registered_query('fe80::1%7') do |selector, socket, first_id, query|
84
+ query.header.id = 12_346
85
+ second_id = Object.new
86
+ second = Dnsruby::SelectThread::QuerySettings.new
87
+ second.query = Dnsruby::Message.new('other.example')
88
+ second.query.header.id = 12_347
89
+ queries = Dnsruby::SelectThread.class_variable_get(:@@query_hash)
90
+ sockets = Dnsruby::SelectThread.class_variable_get(:@@socket_hash)
91
+ queries[second_id] = second
92
+ sockets[socket] << second_id
93
+ assert_equal(second_id, selector.get_client_id_from_answerfrom(socket, nil, nil, 12_347))
94
+ assert_nil(selector.get_client_id_from_answerfrom(socket, nil, nil, 12_348))
95
+ assert_nil(selector.get_client_id_from_answerfrom(socket, nil, nil))
96
+ packet = [query.header.id, 0x8000, 1, 0, 0, 0].pack('n6')
97
+ errors = []
98
+ selector.stub(:tcp?, true) do
99
+ selector.stub(:tcp_read, packet) do
100
+ selector.stub(:send_exception_to_client, ->(*args) { errors << args }) do
101
+ assert_nil(selector.get_incoming_data(socket, 0))
102
+ end
103
+ end
104
+ end
105
+ assert_equal(1, errors.length)
106
+ assert_kind_of(Dnsruby::DecodeError, errors[0][0])
107
+ assert_equal([socket, first_id], errors[0][1..])
108
+ ensure
109
+ queries.delete(second_id) if queries
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ def with_registered_query(destination)
116
+ # Use existing tables when a resolver is active. Otherwise provide only the
117
+ # tables these methods need, without starting a select loop or binding TCP.
118
+ # Stubs live on a separate object and tokens never enter the IO.select set.
119
+ selector_class = Dnsruby::SelectThread
120
+ temporary_variables = []
121
+ { :@@mutex => Mutex.new, :@@query_hash => {}, :@@socket_hash => {} }.each do |name, value|
122
+ next if selector_class.class_variable_defined?(name)
123
+ selector_class.class_variable_set(name, value)
124
+ temporary_variables << name
125
+ end
126
+ selector = selector_class.send(:allocate)
127
+ socket = Object.new
128
+ message = Dnsruby::Message.new('scoped.example')
129
+ # A negative ID cannot collide with a response arriving on a live socket.
130
+ message.header.id = -message.object_id
131
+ client_id = [Object.new, message]
132
+ settings = Dnsruby::SelectThread::QuerySettings.new
133
+ settings.query = message
134
+ settings.socket = socket
135
+ settings.client_query_id = client_id
136
+ settings.dest_server = destination
137
+ settings.dest_port = 53
138
+
139
+ mutex = selector_class.class_variable_get(:@@mutex)
140
+ queries = selector_class.class_variable_get(:@@query_hash)
141
+ sockets = selector_class.class_variable_get(:@@socket_hash)
142
+ mutex.synchronize do
143
+ queries[client_id] = settings
144
+ sockets[socket] = [client_id]
145
+ end
146
+
147
+ begin
148
+ yield selector, socket, client_id, message
149
+ ensure
150
+ mutex.synchronize do
151
+ queries.delete(client_id)
152
+ sockets.delete(socket)
153
+ end
154
+ temporary_variables.each { |name| selector_class.remove_class_variable(name) }
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,329 @@
1
+ require_relative 'spec_helper'
2
+ require 'minitest/mock'
3
+ require 'socket'
4
+ require 'timeout'
5
+
6
+ class TestScopedIPv6Transport < Minitest::Test
7
+ SCOPED_SERVERS = ['fe80::1%eth0.100', 'fe80::1%4'].freeze
8
+ ANSWER = '192.0.2.184'.freeze
9
+
10
+ # These socket-boundary tests run even on hosts without an IPv6 interface.
11
+ # They verify that the transport receives the zone, not just the IPv6 address.
12
+ def test_udp_preserves_scope_and_uses_ipv6
13
+ SCOPED_SERVERS.each do |server|
14
+ sender = Dnsruby::PacketSender.new(server: server, port: 5300, src_port: 55000)
15
+ message = Dnsruby::Message.new('scoped.example.invalid')
16
+ bytes = message.encode
17
+ socket = Minitest::Mock.new(Object.new)
18
+ socket.expect(:bind, 0, ['::', 55000])
19
+ socket.expect(:connect, 0, [server, 5300])
20
+ socket.expect(:write_nonblock, bytes.bytesize, [bytes])
21
+ selector = capture_selector do |settings|
22
+ assert_equal server, settings.dest_server
23
+ assert_equal socket.object_id, settings.socket.object_id
24
+ true
25
+ end
26
+
27
+ constructor = lambda do |family|
28
+ assert_equal Socket::AF_INET6, family
29
+ socket
30
+ end
31
+ Dnsruby::SelectThread.stub(:instance, selector) do
32
+ UDPSocket.stub(:new, constructor) do
33
+ sender.send_dnsruby(bytes, message, :udp_scope, Queue.new, false)
34
+ end
35
+ end
36
+ socket.verify
37
+ end
38
+ end
39
+
40
+ def test_tcp_preserves_scope_and_uses_ipv6_source
41
+ SCOPED_SERVERS.each do |server|
42
+ sender = Dnsruby::PacketSender.new(server: server, port: 5300, src_port: 55000)
43
+ message = Dnsruby::Message.new('scoped.example.invalid')
44
+ bytes = message.encode
45
+ frame = [bytes.bytesize].pack('n') + bytes
46
+ socket = Minitest::Mock.new(Object.new)
47
+ socket.expect(:write_nonblock, frame.bytesize, [frame])
48
+ selector = capture_selector do |settings|
49
+ assert_equal server, settings.dest_server
50
+ assert_equal socket.object_id, settings.socket.object_id
51
+ true
52
+ end
53
+
54
+ connector = lambda do |host, port, source, source_port, deadline, token|
55
+ assert_equal [server, 5300, '::', 55000], [host, port, source, source_port]
56
+ assert_operator deadline - Dnsruby::TransportClock.now, :>, 0
57
+ refute token.cancelled?
58
+ assert_operator deadline - Dnsruby::TransportClock.now, :<=, sender.packet_timeout
59
+ socket
60
+ end
61
+ Dnsruby::SelectThread.stub(:instance, selector) do
62
+ sender.stub(:connect_tcp, connector) do
63
+ sender.send_dnsruby(bytes, message, :tcp_scope, Queue.new, true)
64
+ end
65
+ end
66
+ socket.verify
67
+ end
68
+ end
69
+
70
+ def test_tcp_pipeline_preserves_scope_and_selects_matching_source_address
71
+ (SCOPED_SERVERS + ['127.0.0.1']).each do |server|
72
+ ipv6 = server.include?(':')
73
+ sender = Dnsruby::PacketSender.new(server: server, port: 5300)
74
+ source = ipv6 ? '::' : '0.0.0.0'
75
+ target = 'packed destination including scope'
76
+ socket = Minitest::Mock.new(Object.new)
77
+
78
+ address_builder = lambda do |port, host|
79
+ assert_equal [5300, server], [port, host]
80
+ target
81
+ end
82
+ connector = lambda do |host, port, local_address, local_port, deadline, token|
83
+ assert_equal [server, 5300, source, 0], [host, port, local_address, local_port]
84
+ assert_operator deadline - Dnsruby::TransportClock.now, :>, 0
85
+ refute token.cancelled?
86
+ assert_operator deadline - Dnsruby::TransportClock.now, :<=, sender.packet_timeout
87
+ socket
88
+ end
89
+ Socket.stub(:sockaddr_in, address_builder) do
90
+ sender.stub(:connect_tcp, connector) do
91
+ connected, new_socket = sender.tcp_pipeline_socket(0)
92
+ assert_equal socket.object_id, connected.object_id
93
+ assert new_socket
94
+ end
95
+ end
96
+ socket.verify
97
+ end
98
+ end
99
+
100
+ def test_tcp_pipeline_does_not_retain_a_failed_scoped_connection
101
+ SCOPED_SERVERS.each do |server|
102
+ [Errno::EADDRNOTAVAIL, Errno::ENETUNREACH].each do |error_class|
103
+ sender = Dnsruby::PacketSender.new(server: server, port: 5300, src_port: 55000)
104
+ error = error_class.new('unusable scoped source or destination')
105
+ connector = lambda do |host, port, source, source_port, deadline, token|
106
+ assert_equal [server, 5300, '::', 55000], [host, port, source, source_port]
107
+ assert_operator deadline - Dnsruby::TransportClock.now, :>, 0
108
+ refute token.cancelled?
109
+ assert_operator deadline - Dnsruby::TransportClock.now, :<=, sender.packet_timeout
110
+ raise error
111
+ end
112
+
113
+ # The connector closes the socket if bind or connect fails.
114
+ # The sender must also clear its pipeline and usage count.
115
+ Socket.stub(:sockaddr_in, 'scoped destination') do
116
+ sender.stub(:connect_tcp, connector) do
117
+ assert_same error, assert_raises(error_class) { sender.tcp_pipeline_socket(55000) }
118
+ end
119
+ end
120
+ assert_nil sender.instance_variable_get(:@pipeline_socket)
121
+ assert_empty sender.instance_variable_get(:@use_counts)
122
+ end
123
+ end
124
+ end
125
+
126
+ def test_ipv6_loopback_udp_roundtrip
127
+ assert_local_roundtrip(:udp, scoped: false)
128
+ end
129
+
130
+ def test_ipv6_loopback_tcp_roundtrip
131
+ assert_local_roundtrip(:tcp, scoped: false)
132
+ end
133
+
134
+ def test_ipv6_loopback_tcp_pipeline_has_multiple_outstanding_queries
135
+ assert_local_roundtrip(:pipeline, 3, scoped: false)
136
+ end
137
+
138
+ def test_ipv6_loopback_truncated_udp_retries_over_tcp
139
+ assert_local_roundtrip(:fallback, scoped: false)
140
+ end
141
+
142
+ def test_scoped_ipv6_udp_roundtrip
143
+ assert_local_roundtrip(:udp)
144
+ end
145
+
146
+ def test_scoped_ipv6_numeric_zone_udp_roundtrip
147
+ assert_local_roundtrip(:udp, 1, true)
148
+ end
149
+
150
+ def test_scoped_ipv6_tcp_roundtrip
151
+ assert_local_roundtrip(:tcp)
152
+ end
153
+
154
+ def test_scoped_ipv6_numeric_zone_tcp_roundtrip
155
+ assert_local_roundtrip(:tcp, 1, true)
156
+ end
157
+
158
+ def test_scoped_ipv6_tcp_pipeline_has_multiple_outstanding_queries
159
+ assert_local_roundtrip(:pipeline, 2)
160
+ end
161
+
162
+ def test_scoped_ipv6_numeric_zone_tcp_pipeline_has_multiple_outstanding_queries
163
+ assert_local_roundtrip(:pipeline, 2, true)
164
+ end
165
+
166
+ def test_scoped_ipv6_truncated_udp_retries_over_tcp
167
+ assert_local_roundtrip(:fallback)
168
+ end
169
+
170
+ private
171
+
172
+ def capture_selector(&registered)
173
+ current = nil
174
+ Object.new.tap do |selector|
175
+ selector.define_singleton_method(:track_socket) { |socket| socket }
176
+ selector.define_singleton_method(:reserve_send) { |settings, _previous| current = settings; true }
177
+ selector.define_singleton_method(:query_settings_for_id) { |_| current }
178
+ selector.define_singleton_method(:selector_thread?) { false }
179
+ selector.define_singleton_method(:register_query_if_current) do |settings, previous|
180
+ raise 'wrong attempt replaced' unless current.equal?(previous)
181
+ current = settings
182
+ registered.call(settings)
183
+ end
184
+ end
185
+ end
186
+
187
+ def assert_local_roundtrip(transport, count = 1, numeric_scope = false, scoped: true)
188
+ server, udp, tcp = bind_local_servers(transport, numeric_scope, scoped)
189
+ port = (udp || tcp).local_address.ip_port
190
+ received = []
191
+ server_errors = Queue.new
192
+ worker = Thread.new do
193
+ Thread.current.abort_on_exception = false
194
+ Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
195
+ connection = nil
196
+ begin
197
+ if udp
198
+ wait_readable(udp)
199
+ bytes, peer = udp.recvfrom(65_535)
200
+ query = Dnsruby::Message.decode(bytes)
201
+ received << [:udp, query.question.first.qname.to_s]
202
+ response = answer_for(query, transport == :fallback)
203
+ udp.send(response.encode, 0, peer[3], peer[1])
204
+ end
205
+ if tcp
206
+ wait_readable(tcp)
207
+ connection, = tcp.accept
208
+ queries = Array.new(count) do
209
+ length = read_exactly(connection, 2).unpack1('n')
210
+ query = Dnsruby::Message.decode(read_exactly(connection, length))
211
+ received << [:tcp, query.question.first.qname.to_s]
212
+ query
213
+ end
214
+ # Read every request before replying, then reverse the responses.
215
+ # This requires concurrent outstanding queries on one connection.
216
+ queries.reverse_each do |query|
217
+ response = answer_for(query).encode
218
+ connection.write([response.bytesize].pack('n') + response)
219
+ end
220
+ end
221
+ rescue StandardError => error
222
+ server_errors << error
223
+ ensure
224
+ connection.close if connection && !connection.closed?
225
+ end
226
+ end
227
+
228
+ resolver = Dnsruby::Resolver.new(
229
+ nameserver: server, port: port, src_port: 0,
230
+ dnssec: false, do_caching: false, retry_times: 1,
231
+ packet_timeout: 2, query_timeout: 3,
232
+ use_tcp: [:tcp, :pipeline].include?(transport),
233
+ tcp_pipelining: transport == :pipeline
234
+ )
235
+ names = Array.new(count) { |index| "scoped-#{transport}-#{index}.example.invalid" }
236
+ if transport == :pipeline
237
+ queue = Queue.new
238
+ names.each_with_index do |name, index|
239
+ message = Dnsruby::Message.new(name)
240
+ # Keep outstanding queries distinct without relying on random DNS IDs.
241
+ message.header.id = index
242
+ message.do_caching = false
243
+ message.do_validation = false
244
+ resolver.send_async(message, queue, index)
245
+ end
246
+ responses = Timeout.timeout(5) { Array.new(count) { queue.pop } }
247
+ assert_equal (0...count).to_a, responses.map(&:first).sort
248
+ responses.each do |index, response, error|
249
+ assert_nil error
250
+ assert_equal ANSWER, response.answer.first.address.to_s
251
+ assert_equal names[index], response.question.first.qname.to_s
252
+ end
253
+ else
254
+ name = names.first
255
+ response = Timeout.timeout(5) { resolver.query(name) }
256
+ assert_equal ANSWER, response.answer.first.address.to_s
257
+ assert_equal name, response.question.first.qname.to_s
258
+ end
259
+ assert worker.join(3), 'local DNS server did not finish'
260
+ raise server_errors.pop unless server_errors.empty?
261
+ expected = transport == :fallback ? [:udp, :tcp] : Array.new(count, transport == :udp ? :udp : :tcp)
262
+ assert_equal expected, received.map(&:first)
263
+ ensure
264
+ resolver.close if resolver
265
+ if worker
266
+ worker.kill if worker.alive?
267
+ worker.join
268
+ end
269
+ [udp, tcp].compact.each { |socket| socket.close unless socket.closed? }
270
+ end
271
+
272
+ def answer_for(query, truncated = false)
273
+ query.header.qr = true
274
+ query.header.ra = true
275
+ query.header.tc = truncated
276
+ unless truncated
277
+ query.add_answer(Dnsruby::RR.create("#{query.question.first.qname} 60 IN A #{ANSWER}"))
278
+ end
279
+ query
280
+ end
281
+
282
+ def wait_readable(socket)
283
+ raise IOError, 'local DNS server timed out' unless IO.select([socket], nil, nil, 3)
284
+ end
285
+
286
+ def read_exactly(socket, length)
287
+ bytes = ''.b
288
+ while bytes.bytesize < length
289
+ wait_readable(socket)
290
+ bytes << socket.readpartial(length - bytes.bytesize)
291
+ end
292
+ bytes
293
+ end
294
+
295
+ def bind_local_servers(transport, numeric_scope, scoped)
296
+ servers = ['::1']
297
+ if scoped
298
+ unless Socket.respond_to?(:getifaddrs)
299
+ skip 'Socket.getifaddrs is unavailable; socket-boundary tests cover scoped transport'
300
+ end
301
+ interfaces = Socket.getifaddrs.select { |entry| entry.addr && entry.addr.ipv6? && entry.addr.ipv6_linklocal? }
302
+ interfaces.sort_by! { |entry| (entry.flags & Socket::IFF_LOOPBACK).zero? ? 1 : 0 }
303
+ servers = interfaces.filter_map do |entry|
304
+ next if numeric_scope && !entry.respond_to?(:ifindex)
305
+ zone = numeric_scope ? entry.ifindex : entry.name
306
+ "#{entry.addr.ip_address.split('%').first}%#{zone}"
307
+ end
308
+ end
309
+ servers.each do |server|
310
+ udp = tcp = nil
311
+ begin
312
+ if [:udp, :fallback].include?(transport)
313
+ udp = UDPSocket.new(Socket::AF_INET6)
314
+ udp.bind(server, 0)
315
+ end
316
+ if [:tcp, :pipeline, :fallback].include?(transport)
317
+ tcp = Socket.new(Socket::AF_INET6, Socket::SOCK_STREAM, 0)
318
+ tcp.bind(Socket.sockaddr_in(udp ? udp.local_address.ip_port : 0, server))
319
+ tcp.listen(1)
320
+ end
321
+ return [server, udp, tcp]
322
+ rescue SocketError, SystemCallError
323
+ [udp, tcp].compact.each { |socket| socket.close unless socket.closed? }
324
+ end
325
+ end
326
+ skip(scoped ? 'No bindable local IPv6 link-local address; socket-boundary tests cover scoped transport' :
327
+ 'IPv6 loopback is unavailable; socket-boundary tests cover IPv6 transport')
328
+ end
329
+ end
@@ -0,0 +1,42 @@
1
+ require_relative 'spec_helper'
2
+ require 'open3'
3
+ require 'rbconfig'
4
+
5
+ class TestSelectorWakeup < Minitest::Test
6
+ def test_a_saturated_native_notification_socket_cannot_block_the_selector
7
+ script = <<~'RUBY'
8
+ require 'dnsruby'
9
+ require 'fcntl'
10
+ require 'timeout'
11
+ selector = Dnsruby::SelectThread.send(:allocate)
12
+ writer, reader = selector.get_socket_pair
13
+ Dnsruby::SelectThread.class_variable_set(:@@wakeup_sockets, [writer, reader])
14
+ writer.setsockopt(Socket::SOL_SOCKET, Socket::SO_SNDBUF, 1024)
15
+ reader.setsockopt(Socket::SOL_SOCKET, Socket::SO_RCVBUF, 1024)
16
+ flags = writer.fcntl(Fcntl::F_GETFL, 0)
17
+ begin
18
+ # Fill both kernel buffers; allow already-enqueued data to move across
19
+ # the connection, then refill until no further transfer creates space.
20
+ 20.times do
21
+ begin
22
+ loop { writer.write_nonblock('x' * 4096) }
23
+ rescue IO::WaitWritable
24
+ end
25
+ sleep 0.005
26
+ end
27
+ # Filling through write_nonblock can change the descriptor flags. Restore
28
+ # blocking mode so the old Socket#send implementation really would block.
29
+ writer.fcntl(Fcntl::F_SETFL, flags & ~Fcntl::O_NONBLOCK)
30
+ Timeout.timeout(1) { 1000.times { selector.wake_up_select_thread } }
31
+ raise 'notification disappeared' unless IO.select([reader], nil, nil, 0)
32
+ ensure
33
+ writer.close
34
+ reader.close
35
+ end
36
+ RUBY
37
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, '-I',
38
+ File.expand_path('../lib', __dir__), '-e', script)
39
+ assert(status.success?, "#{stdout}\n#{stderr}")
40
+ assert_empty(stderr)
41
+ end
42
+ end