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
data/test/spec_helper.rb CHANGED
@@ -5,27 +5,22 @@ if Warning.respond_to?(:[]=)
5
5
  end
6
6
 
7
7
  if ENV['RUN_EXTRA_TASK'] == 'TRUE'
8
- unless "test".frozen?
9
- # Coverall setup term-ansi-color which isn't yet frozen string literal compatible
10
- # Ref: https://github.com/flori/term-ansicolor/pull/38
11
- require 'coveralls'
12
- Coveralls.wear!
13
- end
14
-
15
8
  require 'simplecov'
16
9
 
17
- # # SimpleCov.formatter = SimpleCov::Formatter::MultiFormatter.new(
18
- # # [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter])
19
- # SimpleCov.formatter = Coveralls::SimpleCov::Formatter
20
- # SimpleCov.start do
21
- # add_filter 'test/'
22
- # end
10
+ # Generate reports locally; CI uploads coverage after the tests complete.
11
+ SimpleCov.formatter = SimpleCov::Formatter::HTMLFormatter
12
+ SimpleCov.start do
13
+ add_filter '/test/'
14
+ end
23
15
  end
24
16
 
25
17
  require 'minitest'
26
18
  require 'minitest/autorun'
27
19
  require 'minitest/display'
28
20
 
21
+ # CI prints the active test and fails with thread backtraces if it stalls.
22
+ require_relative 'ci_watchdog' if ENV.key?('DNSRUBY_TEST_TIMEOUT')
23
+
29
24
  Minitest::Display.options = {
30
25
  suite_names: true,
31
26
  color: true,
@@ -0,0 +1,137 @@
1
+ require_relative 'tc_pipeline_send'
2
+ require 'minitest/mock'
3
+
4
+ class AttemptConfigurationTest < Minitest::Test
5
+ def in_memory(code)
6
+ out, err, status = Open3.capture3(RbConfig.ruby, '-Ilib', '-e', TestPipelineSend::FIXTURE + code,
7
+ chdir: File.expand_path('..', __dir__))
8
+ assert(status.success?, "#{out}\n#{err}")
9
+ end
10
+
11
+ def test_attempt_configuration_survives_tcp_connect_reconfiguration
12
+ in_memory(<<~'RUBY')
13
+ [false, true].each do |pipeline|
14
+ resolver = Dnsruby::Resolver.new(nameserver: '127.0.0.1', port: 5301,
15
+ use_tcp: true, tcp_pipelining: pipeline, tcp_pipelining_max_queries: :infinite,
16
+ tcp_idle_timeout: 10, dnssec: false)
17
+ writer = resolver.single_resolvers.first
18
+ connections, sockets = [], []
19
+ writer.define_singleton_method(:connect_tcp) do |server, port, source, source_port, *_|
20
+ connections << [server, port, source, source_port]
21
+ if connections.length == 1
22
+ resolver.port = 5302
23
+ resolver.src_address = '127.0.0.2'
24
+ resolver.src_port = 55001
25
+ resolver.ignore_truncation = true
26
+ resolver.no_tcp = true
27
+ resolver.tcp_idle_timeout = 20
28
+ resolver.tcp_pipelining_max_queries = 3
29
+ end
30
+ FrameSocket.new.tap do |socket|
31
+ socket.define_singleton_method(:connect) { |_| raise Errno::EISCONN }
32
+ sockets << socket
33
+ end
34
+ end
35
+ message = query
36
+ writer.send_async(message, Queue.new, :first)
37
+ first = SELECTOR.query_settings_for_id(:first)
38
+ raise 'connected endpoint changed' unless connections.first[0, 3] == ['127.0.0.1', 5301, '0.0.0.0']
39
+ raise 'registered peer changed' unless [first.dest_server, first.dest_port] == connections.first[0, 2]
40
+ raise 'response policy changed' if first.ignore_truncation || first.no_tcp
41
+ raise 'idle policy changed' unless first.tcp_idle_timeout == 10
42
+ raise 'cache route changed' unless first.cache_scope[0, 3] == ['127.0.0.1', 5301, '0.0.0.0']
43
+ raise 'mutable attempt configuration' unless first.transport_configuration.frozen? && first.transport_configuration[:src_port].frozen?
44
+ if pipeline
45
+ raise 'pipeline capacity changed' unless first.tcp_pipelining_max_queries == :infinite
46
+ end
47
+ writer.send_async(query('next.example.'), Queue.new, :next)
48
+ following = SELECTOR.query_settings_for_id(:next)
49
+ raise 'later send missed new settings' unless connections.last == ['127.0.0.1', 5302, '127.0.0.2', 55001]
50
+ raise 'later policy missed new settings' unless following.ignore_truncation && following.no_tcp && following.tcp_idle_timeout == 20
51
+ SELECTOR.remove_id(:first)
52
+ SELECTOR.remove_id(:next)
53
+ resolver.close
54
+ sockets.each { |socket| assert_clean(socket) }
55
+ end
56
+ RUBY
57
+ end
58
+
59
+ def test_attempt_configuration_survives_udp_bind_reconfiguration
60
+ in_memory(<<~'RUBY')
61
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', port: 5301, src_port: 55000, dnssec: false)
62
+ socket = FrameSocket.new
63
+ connected = nil
64
+ socket.define_singleton_method(:getsockopt) { |*| Struct.new(:data).new([Socket::SOCK_DGRAM].pack('i')) }
65
+ socket.define_singleton_method(:bind) do |address, port|
66
+ raise 'wrong source' unless [address, port] == ['0.0.0.0', 55000]
67
+ writer.server = '127.0.0.2'
68
+ writer.port = 5302
69
+ end
70
+ socket.define_singleton_method(:connect) { |*endpoint| connected = endpoint }
71
+ UDPSocket.define_singleton_method(:new) { |*| socket }
72
+ writer.send_async(query, Queue.new, :udp)
73
+ pending = SELECTOR.query_settings_for_id(:udp)
74
+ raise 'UDP connected to new endpoint' unless connected == ['127.0.0.1', 5301]
75
+ raise 'UDP registration changed endpoint' unless [pending.dest_server, pending.dest_port] == connected
76
+ SELECTOR.remove_id(:udp)
77
+ writer.close
78
+ assert_clean(socket)
79
+ RUBY
80
+ end
81
+
82
+ def test_attempt_configuration_preserves_truncation_policy_until_response
83
+ in_memory(<<~'RUBY')
84
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', no_tcp: true, dnssec: false)
85
+ socket = FrameSocket.new
86
+ socket.define_singleton_method(:bind) { |*| }
87
+ socket.define_singleton_method(:connect) { |*| }
88
+ socket.define_singleton_method(:getsockopt) { |*| Struct.new(:data).new([Socket::SOCK_DGRAM].pack('i')) }
89
+ UDPSocket.define_singleton_method(:new) { |*| socket }
90
+ message = query
91
+ queue = Queue.new
92
+ writer.send_async(message, queue, :policy)
93
+ pending = SELECTOR.query_settings_for_id(:policy)
94
+ writer.no_tcp = false
95
+ writer.define_singleton_method(:send_async) { |*| raise 'response used changed no_tcp policy' }
96
+ response = message.clone
97
+ response.header.qr = response.header.tc = true
98
+ Thread.current[:dnsruby_response_query] = pending
99
+ raise 'ordinary truncated response rejected' unless writer.check_response(response, response.encode, message, queue, :policy, false)
100
+ Thread.current[:dnsruby_response_query] = nil
101
+ SELECTOR.remove_id(:policy)
102
+ writer.close
103
+ assert_clean(socket)
104
+ RUBY
105
+ end
106
+
107
+ def test_attempt_configuration_explicit_ports_replace_default_random_pool
108
+ [Dnsruby::Resolver, Dnsruby::SingleResolver, Dnsruby::PacketSender].each do |klass|
109
+ resolver = klass.new(server: '127.0.0.1')
110
+ resolver.add_src_port(55000)
111
+ assert_equal(55000, resolver.src_port, klass)
112
+ resolver.add_src_port(55001..55002)
113
+ assert_equal([55000, 55001, 55002], resolver.src_port, klass)
114
+ [[0, 55003], [55003, 0]].each do |ports|
115
+ assert_raises(ArgumentError) { resolver.src_port = ports }
116
+ assert_equal([55000, 55001, 55002], resolver.src_port, klass)
117
+ end
118
+ assert_raises(ArgumentError) { resolver.add_src_port(0) }
119
+ assert_equal([55000, 55001, 55002], resolver.src_port, klass)
120
+ if resolver.respond_to?(:single_resolvers)
121
+ assert_equal(resolver.src_port, resolver.single_resolvers.first.src_port)
122
+ end
123
+ resolver.close
124
+ end
125
+ end
126
+
127
+ def test_attempt_configuration_disabled_cache_skips_response_classification
128
+ request = Dnsruby::Message.new('cache.example.')
129
+ request.do_caching = false
130
+ response = request.clone
131
+ response.header.qr = true
132
+ response.add_answer(Dnsruby::RR.create('cache.example. 60 IN A 192.0.2.1'))
133
+ Dnsruby::Cache.stub(:negative_soa, ->(*) { raise 'disabled cache examined records' }) do
134
+ assert_nil(Dnsruby::PacketSender.cache(request, response))
135
+ end
136
+ end
137
+ end
data/test/tc_caa.rb CHANGED
@@ -27,6 +27,7 @@ class TestCAA < Minitest::Test
27
27
  'foo.com. IN CAA 0 issuewild "ca.example.net"' => [0, 'issuewild', 'ca.example.net'],
28
28
  'foo.com. IN CAA 0 issuemail "ca.example.net"' => [0, 'issuemail', 'ca.example.net'],
29
29
  'foo.com. IN CAA 0 issuevmc "ca.example.net"' => [0, 'issuevmc', 'ca.example.net'],
30
+ 'foo.com. IN CAA 0 Issue "ca.example.net"' => [0, 'Issue', 'ca.example.net'],
30
31
  'foo.com. IN CAA 0 iodef "mailto:security@example.com"' => [0, 'iodef', 'mailto:security@example.com'],
31
32
  'foo.com. IN CAA 0 issue "ca.example.net; account=230123"' => [0, 'issue', 'ca.example.net; account=230123']
32
33
  }.each do |text, data|
@@ -48,8 +49,7 @@ class TestCAA < Minitest::Test
48
49
 
49
50
  def test_caa_error
50
51
  {
51
- 'foo.com. IN CAA 0 ca.example.net "issue"' => [0, 'ca.example.net', 'issue'],
52
- 'foo.com. IN CAA 0 Issue "ca.example.net"' => [0, 'Issue', 'ca.example.net']
52
+ 'foo.com. IN CAA 0 ca.example.net "issue"' => [0, 'ca.example.net', 'issue']
53
53
  }.each do |text, data|
54
54
  assert_raises DecodeError do
55
55
  RR.create(text)
@@ -0,0 +1,36 @@
1
+ require_relative 'spec_helper'
2
+
3
+ class TestCacheKeys < Minitest::Test
4
+ include Dnsruby
5
+
6
+ def test_numeric_query_spellings_find_the_decoded_response
7
+ cache = Cache.new
8
+ query = Message.new('cache.example.', 'TYPE0001', 'CLASS0001')
9
+ response = Message.decode(query.encode)
10
+ response.header.qr = true
11
+ response.add_answer(RR.create('cache.example. 300 IN A 192.0.2.1'))
12
+ cache.add(response)
13
+
14
+ ['A', 'TYPE1', 'TYPE0001', Types.new('TYPE01'), 1].each do |type|
15
+ ['IN', 'CLASS1', 'CLASS0001', Classes.new('CLASS01'), 1].each do |klass|
16
+ found = cache.find('CACHE.example', type, klass)
17
+ refute_nil(found, "#{type}/#{klass}")
18
+ assert(found.cached)
19
+ assert_equal('192.0.2.1', found.answer.first.address.to_s)
20
+ end
21
+ end
22
+ assert_nil(cache.find('cache.example.', 'AAAA', 'IN'))
23
+ assert_nil(cache.find('cache.example.', 'A', 'CH'))
24
+ end
25
+
26
+ def test_unknown_numeric_aliases_share_one_cache_entry
27
+ cache = Cache.new
28
+ [['TYPE65001', 'CLASS65000'], ['TYPE065001', 'CLASS065000']].each do |type, klass|
29
+ response = Message.new('cache.example.', type, klass)
30
+ response.add_answer(RR.create('cache.example. 300 CLASS65000 TYPE65001 \\# 1 01'))
31
+ cache.add(response)
32
+ end
33
+ assert_equal(1, cache.length)
34
+ assert_equal("\x01".b, cache.find('cache.example.', 'TYPE0065001', 'CLASS0065000').answer.first.data)
35
+ end
36
+ end
@@ -0,0 +1,158 @@
1
+ require_relative 'tc_pipeline_send'
2
+
3
+ class CancelledSendCapacityTest < Minitest::Test
4
+ def test_cancelled_udp_writes_release_all_workers_and_unregistered_sockets
5
+ isolated(<<~'RUBY')
6
+ sockets = []
7
+ UDPSocket.define_singleton_method(:new) do |*|
8
+ socket = FrameSocket.new
9
+ socket.define_singleton_method(:bind) { |*| }
10
+ socket.define_singleton_method(:connect) { |*| }
11
+ socket.define_singleton_method(:write_nonblock) { |*| raise IO::EAGAINWaitWritable }
12
+ sockets << socket
13
+ socket
14
+ end
15
+ tasks, queues = [], []
16
+ 4.times do |id|
17
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false, packet_timeout: 60)
18
+ q, queue = query, Queue.new
19
+ queues << queue
20
+ tasks << writer.send_dnsruby(q.encode, q, id, queue, false)
21
+ end
22
+ 4.times { WAITING.pop }
23
+ 4.times { |id| SELECTOR.remove_id(id) }
24
+ healthy = Queue.new
25
+ next_task = SELECTOR.defer_send { healthy << true }
26
+ Timeout.timeout(2) do
27
+ healthy.pop
28
+ (tasks + [next_task]).each(&:value)
29
+ end
30
+ drain_errors
31
+ raise 'cancelled UDP socket retained' unless sockets.all?(&:closed?)
32
+ raise 'cancelled attempt still registered' unless K.class_variable_get(:@@query_hash).empty?
33
+ raise 'cancelled UDP wrote bytes' unless sockets.all? { |socket| socket.written.empty? }
34
+ raise 'cancelled owner received another completion' unless queues.all?(&:empty?)
35
+ RUBY
36
+ end
37
+
38
+ def test_cancelled_unwritten_tcp_frames_release_workers_without_failing_peers
39
+ tcp_cancellation_scenario(false)
40
+ end
41
+
42
+ def test_cancelled_partial_tcp_frames_release_workers_and_retire_corrupt_streams
43
+ tcp_cancellation_scenario(true)
44
+ end
45
+
46
+ def test_writable_wait_slices_retain_the_original_tcp_and_udp_deadline
47
+ isolated(<<~'RUBY')
48
+ [:tcp, :udp].each do |transport|
49
+ writer = Dnsruby::PacketSender.new(server: '127.0.0.1', dnssec: false)
50
+ socket = FrameSocket.new
51
+ socket.define_singleton_method(:write_nonblock) { |*| raise IO::EAGAINWaitWritable }
52
+ token = K::SendToken.new
53
+ started = Dnsruby::TransportClock.now
54
+ begin
55
+ if transport == :tcp
56
+ writer.write_tcp_frame(socket, query.encode, started + 0.15, token)
57
+ else
58
+ writer.write_udp_packet(socket, query.encode, started + 0.15, token, true)
59
+ end
60
+ raise 'congested write did not time out'
61
+ rescue Dnsruby::ResolvTimeout
62
+ elapsed = Dnsruby::TransportClock.now - started
63
+ raise 'select slice expired the entire attempt early' if elapsed < 0.14
64
+ raise 'wait extended the packet deadline' if elapsed > 2
65
+ end
66
+ end
67
+ RUBY
68
+ end
69
+
70
+ private
71
+
72
+ def tcp_cancellation_scenario(partial)
73
+ isolated("partial = #{partial}\n" + <<~'RUBY')
74
+ sockets, tasks, peers, queues = [], [], [], []
75
+ 4.times do |id|
76
+ socket = FrameSocket.new
77
+ writer = sender(socket)
78
+ writer.packet_timeout = 60
79
+ peer = settings(socket, [:peer, id], query("peer-#{id}.example."), writer)
80
+ SELECTOR.add_to_select(peer)
81
+ calls = 0
82
+ native_write = socket.method(:write_nonblock)
83
+ socket.define_singleton_method(:write_nonblock) do |bytes|
84
+ calls += 1
85
+ if partial && calls == 1
86
+ native_write.call(bytes.byteslice(0, 2))
87
+ else
88
+ raise IO::EAGAINWaitWritable
89
+ end
90
+ end
91
+ q, queue = query("cancelled-#{id}.example."), Queue.new
92
+ sockets << socket
93
+ peers << peer
94
+ queues << queue
95
+ tasks << writer.send_dnsruby(q.encode, q, id, queue, true)
96
+ end
97
+ 4.times { WAITING.pop }
98
+ 4.times { |id| SELECTOR.remove_id(id) }
99
+ healthy = Queue.new
100
+ next_task = SELECTOR.defer_send { healthy << true }
101
+ Timeout.timeout(2) do
102
+ healthy.pop
103
+ (tasks + [next_task]).each(&:value)
104
+ end
105
+ drain_errors
106
+ raise 'cancelled owner received another completion' unless queues.all?(&:empty?)
107
+ sockets.zip(peers).each do |socket, peer|
108
+ raise 'canceled frame continued writing' unless socket.written.bytesize == (partial ? 2 : 0)
109
+ if partial
110
+ raise 'corrupt pipeline remained open' unless socket.closed?
111
+ raise 'corrupt stream peer not failed exactly once' unless peer.client_queue.length == 1 &&
112
+ peer.client_queue.pop(true).last.is_a?(Dnsruby::SocketEofResolvError)
113
+ else
114
+ raise 'unwritten cancellation closed peer socket' if socket.closed?
115
+ raise 'unwritten cancellation lost peer' unless SELECTOR.query_settings_for_id(peer.client_query_id).equal?(peer)
116
+ raise 'unwritten cancellation failed peer' unless peer.client_queue.empty?
117
+ response = peer.query.clone
118
+ response.header.qr = true
119
+ SELECTOR.process_message(response, response.encode, socket)
120
+ SELECTOR.send_queued_validation_responses
121
+ events = 2.times.map { peer.client_queue.pop(true) }
122
+ raise 'peer did not complete once' unless peer.client_queue.empty? && events.all? { |event| event.last.nil? }
123
+ raise 'retiring pipeline retained after peer finished' unless socket.closed?
124
+ end
125
+ assert_clean(socket)
126
+ end
127
+ raise 'cancelled TCP attempt retained' unless K.class_variable_get(:@@query_hash).empty?
128
+ RUBY
129
+ end
130
+
131
+ WAIT_FIXTURE = <<~'RUBY'
132
+ WAITING = Queue.new
133
+ waiters, waiter_mutex = {}, Mutex.new
134
+ IO.define_singleton_method(:select) do |readers, writers, _, timeout|
135
+ raise 'unexpected writable wait' unless readers.nil? && writers.length == 1
136
+ waiter_mutex.synchronize do
137
+ unless waiters[Thread.current]
138
+ waiters[Thread.current] = true
139
+ WAITING << Thread.current
140
+ end
141
+ end
142
+ # Model a socket that stays congested: honor the supplied timeout rather
143
+ # than artificially waking a canceled writer, which masked the leak.
144
+ sleep(timeout)
145
+ nil
146
+ end
147
+ K.class_variable_set(:@@select_thread, Thread.current)
148
+ RUBY
149
+
150
+ def isolated(body)
151
+ options = ''.frozen? ? ['--enable-frozen-string-literal'] : []
152
+ source = "#{TestPipelineSend::FIXTURE}\n#{WAIT_FIXTURE}\nTimeout.timeout(10) do\n#{body}\nend"
153
+ stdout, stderr, status = Open3.capture3(RbConfig.ruby, *options, '-I',
154
+ File.expand_path('../lib', __dir__), '-e', source)
155
+ assert(status.success?, "#{stdout}\n#{stderr}")
156
+ assert_empty(stderr)
157
+ end
158
+ end
@@ -0,0 +1,289 @@
1
+ require_relative 'tc_key_cache_identity'
2
+
3
+ class DnssecAliasValidationTest < Minitest::Test
4
+ def setup
5
+ @now = Time.now.to_i
6
+ @private_keys = KeyCacheIdentityTest::PRIVATE_KEYS.map { |pem| OpenSSL::PKey::EC.new(pem) }
7
+ @keys = %w[. left. right.].each_with_index.to_h do |zone, index|
8
+ private_key = @private_keys[index % @private_keys.length]
9
+ bytes = private_key.public_key.to_octet_string(:uncompressed).byteslice(1, 64)
10
+ [zone, [Dnsruby::RR.create("#{zone} 300 IN DNSKEY 257 3 13 #{Base64.strict_encode64(bytes)}"), private_key]]
11
+ end
12
+ @verifier = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
13
+ @verifier.add_trust_anchor(@keys.fetch('.').first)
14
+ end
15
+
16
+ def test_cold_cross_zone_cname_discovers_target_signer_with_the_same_budget
17
+ message = cross_zone_message
18
+ budget = Dnsruby::ValidationBudget.new
19
+ with_chain_resolver do |queries, budgets|
20
+ Dnsruby::ValidationBudget.with(budget) do
21
+ assert(@verifier.validate(message, Dnsruby::Message.new('alias.left.', 'A')))
22
+ end
23
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, message.security_level)
24
+ assert_equal(1, queries.count(['right.', 'DS']))
25
+ assert_equal(1, queries.count(['right.', 'DNSKEY']))
26
+ refute_includes(queries, ['target.right.', 'DS'])
27
+ assert(budgets.all? { |used| used.equal?(budget) })
28
+ end
29
+ end
30
+
31
+ def test_cross_zone_negative_answer_discovers_authority_signer
32
+ message = message_with('alias.left.', 'A', [signed('alias.left. 300 IN CNAME target.right.', 'left.')])
33
+ signed('right. 300 IN SOA ns.right. hostmaster.right. 1 3600 600 86400 300', 'right.').
34
+ each { |rr| message.add_authority(rr) }
35
+ signed('target.right. 300 IN NSEC z.right. TXT NSEC RRSIG', 'right.').
36
+ each { |rr| message.add_authority(rr) }
37
+ with_chain_resolver do |queries, _budgets|
38
+ assert(@verifier.validate(message, Dnsruby::Message.new('alias.left.', 'A')))
39
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, message.security_level)
40
+ assert_equal(1, queries.count(['right.', 'DNSKEY']))
41
+ end
42
+ end
43
+
44
+ def test_cached_target_key_avoids_repeating_signer_discovery
45
+ @verifier.add_trust_anchor(@keys.fetch('right.').first)
46
+ message = cross_zone_message
47
+ with_chain_resolver do |queries, _budgets|
48
+ assert(@verifier.validate(message, nil))
49
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, message.security_level)
50
+ assert_empty(queries.select { |name, _type| name == 'right.' })
51
+ end
52
+ end
53
+
54
+ def test_overlapping_rollover_refreshes_a_cached_key_with_the_same_tag
55
+ new_key = @keys.fetch('right.').first
56
+ old_private = @private_keys.fetch(1)
57
+ old_bytes = old_private.public_key.to_octet_string(:uncompressed).byteslice(1, 64)
58
+ old_key = Dnsruby::RR.create("right. 300 IN DNSKEY 257 3 13 #{Base64.strict_encode64(old_bytes)}")
59
+ refute_equal(old_key.key, new_key.key)
60
+ assert_equal(old_key.key_tag, new_key.key_tag)
61
+ @verifier.add_trust_anchor(old_key)
62
+
63
+ rollover = Dnsruby::RRSet.new([old_key, new_key])
64
+ signature = Dnsruby::RR.create(name: 'right.', type: 'RRSIG', ttl: 300,
65
+ type_covered: Dnsruby::Types.DNSKEY, algorithm: 13, labels: 1,
66
+ original_ttl: 300, expiration: @now + 3600, inception: @now - 60,
67
+ key_tag: old_key.key_tag, signers_name: 'right.')
68
+ bytes = @verifier.send(:canonical_rrset_data, rollover, signature, {})
69
+ der = old_private.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + bytes))
70
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
71
+ rollover.add(signature)
72
+ # Authenticate the ordinary two-key rollover independently, without
73
+ # teaching its new key to the verifier whose discovery is under test.
74
+ check = Dnsruby::SingleVerifier.new(Dnsruby::SingleVerifier::VerifierType::ANCHOR)
75
+ assert(check.verify(rollover, old_key))
76
+
77
+ responses = {['right.', 'DNSKEY'] => message_with('right.', 'DNSKEY', [rollover])}
78
+ message = cross_zone_message
79
+ with_chain_resolver(responses: responses) do |queries, _budgets|
80
+ assert(@verifier.validate(message, nil))
81
+ assert_equal(Dnsruby::Message::SecurityLevel.SECURE, message.security_level)
82
+ assert_equal(1, queries.count(['right.', 'DNSKEY']))
83
+ end
84
+ end
85
+
86
+ def test_signer_discovery_does_not_reset_the_shared_work_limit
87
+ budget = Dnsruby::ValidationBudget.new
88
+ before_response = lambda do |name, type|
89
+ if name.to_s(true) == 'right.' && type == Dnsruby::Types.DS
90
+ assert_same(budget, Dnsruby::ValidationBudget.current)
91
+ budget.check!(Dnsruby::Dnssec.validation_limits[:max_work] + 1)
92
+ end
93
+ end
94
+ with_chain_resolver(before_response) do |_queries, _budgets|
95
+ assert_raises(Dnsruby::ValidationLimitError) do
96
+ Dnsruby::ValidationBudget.with(budget) { @verifier.validate(cross_zone_message, nil) }
97
+ end
98
+ end
99
+ assert_nil(Dnsruby::ValidationBudget.current)
100
+ end
101
+
102
+ def test_additional_data_does_not_trigger_signer_discovery
103
+ message = cross_zone_message
104
+ signed('extra.right. 300 IN A 192.0.2.2', 'right.').each { |rr| message.add_additional(rr) }
105
+ requested = []
106
+ @verifier.stub(:find_closest_anchor_for, ->(name) { requested << name.to_s(true); false }) do
107
+ @verifier.send(:discover_signer_keys, message_with('left.', 'A', [], message.additional))
108
+ end
109
+ assert_empty(requested)
110
+ end
111
+
112
+ def test_optional_foreign_signed_data_is_removed_without_rejecting_the_answer
113
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
114
+ answer = signed('left. 300 IN MX 10 mail.right.', 'left.')
115
+ extra = signed('mail.right. 300 IN A 192.0.2.2', 'right.')
116
+ message = message_with('left.', 'MX', [answer], extra.rrs + extra.sigs)
117
+ assert(@verifier.verify(message))
118
+ assert_empty(message.additional)
119
+ assert_equal(0, message.header.arcount)
120
+ assert(message.answer.all? { |rr| rr.instance_variable_get(:@dnsruby_authenticated_expiration) })
121
+ refute(@verifier.get_keys_to_check.any? { |key| key.name == @keys.fetch('right.').first.name })
122
+ end
123
+
124
+ def test_optional_foreign_dnskey_and_ds_learning_is_opportunistic
125
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
126
+ answer = signed('left. 300 IN MX 10 mail.right.', 'left.')
127
+ key = @keys.fetch('right.').first
128
+ [signed(key, 'right.'), signed(Dnsruby::RR::DS.from_key(key, 2), 'right.')].each do |extra|
129
+ message = message_with('left.', 'MX', [answer], extra.rrs + extra.sigs)
130
+ assert(@verifier.verify(message))
131
+ assert_empty(message.additional)
132
+ refute(@verifier.get_keys_to_check.any? { |known| known.name == key.name })
133
+ end
134
+ end
135
+
136
+ def test_verified_optional_data_is_retained_and_unsigned_glue_stays_unauthenticated
137
+ %w[left. right.].each { |zone| @verifier.add_trust_anchor(@keys.fetch(zone).first) }
138
+ extra = signed('mail.right. 300 IN A 192.0.2.2', 'right.')
139
+ glue = Dnsruby::RR.create('ns.right. 300 IN A 192.0.2.3')
140
+ message = message_with('left.', 'MX', [signed('left. 300 IN MX 10 mail.right.', 'left.')],
141
+ extra.rrs + extra.sigs + [glue])
142
+ assert(@verifier.verify(message))
143
+ assert_equal(3, message.additional.length)
144
+ message.additional.each do |record|
145
+ if record.name == glue.name
146
+ assert_nil(record.instance_variable_get(:@dnsruby_authenticated_expiration))
147
+ else
148
+ refute_nil(record.instance_variable_get(:@dnsruby_authenticated_expiration))
149
+ end
150
+ end
151
+ end
152
+
153
+ def test_unsigned_additional_copy_does_not_inherit_answer_authentication
154
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
155
+ answer = signed('mail.left. 300 IN A 192.0.2.2', 'left.')
156
+ extra = answer.rrs.first.clone
157
+ message = message_with('mail.left.', 'A', [answer], [extra])
158
+ assert(@verifier.verify(message))
159
+ refute_nil(message.answer.first.instance_variable_get(:@dnsruby_authenticated_expiration))
160
+ assert_nil(message.additional.first.instance_variable_get(:@dnsruby_authenticated_expiration))
161
+ end
162
+
163
+ def test_optional_verification_preserves_the_shared_signature_limit
164
+ %w[left. right.].each { |zone| @verifier.add_trust_anchor(@keys.fetch(zone).first) }
165
+ extra = signed('mail.right. 300 IN A 192.0.2.2', 'right.')
166
+ message = message_with('left.', 'MX', [signed('left. 300 IN MX 10 mail.right.', 'left.')], extra.rrs + extra.sigs)
167
+ limits = Dnsruby::Dnssec.validation_limits.merge(max_signature_verifications: 1)
168
+ Dnsruby::Dnssec.stub(:validation_limits, limits) do
169
+ assert_raises(Dnsruby::ValidationLimitError) { @verifier.verify(message) }
170
+ end
171
+ assert_nil(Dnsruby::ValidationBudget.current)
172
+ end
173
+
174
+ def test_dname_authenticates_synthesized_cname_in_either_record_order
175
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
176
+ [false, true].each do |cname_first|
177
+ message = dname_message(cname_first: cname_first)
178
+ assert(@verifier.verify(message))
179
+ assert_equal('www.new.left', message.answer.find { |rr| rr.type == Dnsruby::Types.CNAME }.domainname.to_s)
180
+ end
181
+ end
182
+
183
+ def test_dname_redirects_without_a_server_synthesized_cname
184
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
185
+ message = dname_message
186
+ message.answer.delete_if { |rr| rr.type == Dnsruby::Types.CNAME }
187
+ assert(@verifier.verify(message))
188
+ end
189
+
190
+ def test_synthesized_cname_inherits_dname_expiration_and_preserves_zero_ttl
191
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
192
+ Time.stub(:now, Time.at(@now)) do
193
+ [:expiration, :signature_ttl].each do |bound|
194
+ [0, 300].each do |ttl|
195
+ message = dname_message(cname_ttl: ttl, dname_lifetime: bound == :expiration ? 7 : 3600)
196
+ if bound == :signature_ttl
197
+ message.answer.find { |rr| rr.type == Dnsruby::Types.RRSIG && rr.type_covered == Dnsruby::Types.DNAME }.ttl = 7
198
+ end
199
+ assert(@verifier.verify(message))
200
+ cname = message.answer.find { |rr| rr.type == Dnsruby::Types.CNAME }
201
+ assert_equal([ttl, 7].min, cname.ttl)
202
+ assert_equal(@now + [ttl, 7].min, cname.instance_variable_get(:@dnsruby_authenticated_expiration))
203
+ end
204
+ end
205
+ end
206
+ end
207
+
208
+ def test_synthesized_cname_uses_labels_and_case_insensitive_names
209
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
210
+ message = message_with('www\\.part.old.left.', 'A', [
211
+ signed('old.left. 300 IN DNAME new.left.', 'left.'),
212
+ Dnsruby::RRSet.new([Dnsruby::RR.create('www\\.part.OLD.LEFT. 300 IN CNAME www\\.part.NEW.LEFT.')]),
213
+ signed('www\\.part.new.left. 300 IN A 192.0.2.1', 'left.')])
214
+ assert(@verifier.verify(message))
215
+ end
216
+
217
+ def test_an_ordinary_unsigned_cname_still_requires_authentication
218
+ @verifier.add_trust_anchor(@keys.fetch('left.').first)
219
+ message = message_with('alias.left.', 'A', [
220
+ Dnsruby::RRSet.new([Dnsruby::RR.create('alias.left. 300 IN CNAME target.left.')]),
221
+ signed('target.left. 300 IN A 192.0.2.1', 'left.')])
222
+ assert_raises(Dnsruby::VerifyError) { @verifier.verify(message) }
223
+ end
224
+
225
+ private
226
+
227
+ def signed(record, zone, lifetime: 3600)
228
+ record = Dnsruby::RR.create(record) if record.is_a?(String)
229
+ key, private_key = @keys.fetch(zone)
230
+ signature = Dnsruby::RR.create(name: record.name, type: 'RRSIG', ttl: record.ttl,
231
+ type_covered: record.type, algorithm: 13, labels: record.name.labels.length,
232
+ original_ttl: record.ttl, expiration: @now + lifetime, inception: @now - 60,
233
+ key_tag: key.key_tag, signers_name: key.name)
234
+ bytes = Dnsruby::MessageEncoder.new { |encoder| encoder.put_rr(record, true) }.to_s
235
+ der = private_key.dsa_sign_asn1(OpenSSL::Digest::SHA256.digest(signature.sig_data + bytes))
236
+ signature.signature = OpenSSL::ASN1.decode(der).value.map { |n| n.value.to_s(2).rjust(32, "\0") }.join
237
+ Dnsruby::RRSet.new([record, signature])
238
+ end
239
+
240
+ def message_with(name, type, answer, additional = [])
241
+ Dnsruby::Message.new(name, type).tap do |message|
242
+ answer.each { |set| set.each { |rr| message.add_answer(rr) } }
243
+ additional.each { |rr| message.add_additional(rr) }
244
+ end
245
+ end
246
+
247
+ def cross_zone_message
248
+ message_with('alias.left.', 'A', [signed('alias.left. 300 IN CNAME target.right.', 'left.'),
249
+ signed('target.right. 300 IN A 192.0.2.1', 'right.')])
250
+ end
251
+
252
+ def dname_message(cname_first: false, cname_ttl: 300, dname_lifetime: 3600)
253
+ dname = signed('old.left. 300 IN DNAME new.left.', 'left.', lifetime: dname_lifetime)
254
+ cname = Dnsruby::RRSet.new([Dnsruby::RR.create("www.old.left. #{cname_ttl} IN CNAME www.new.left.")])
255
+ answer = cname_first ? [cname, dname] : [dname, cname]
256
+ answer << signed('www.new.left. 300 IN A 192.0.2.1', 'left.')
257
+ message_with('www.old.left.', 'A', answer)
258
+ end
259
+
260
+ def with_chain_resolver(before_response = nil, responses: {})
261
+ lookup = {}
262
+ @keys.each do |zone, (key, _)|
263
+ lookup[[zone, 'DNSKEY']] = message_with(zone, 'DNSKEY', [signed(key, zone)])
264
+ end
265
+ %w[left. right.].each do |zone|
266
+ ds = Dnsruby::RR::DS.from_key(@keys.fetch(zone).first, 2)
267
+ lookup[[zone, 'DS']] = message_with(zone, 'DS', [signed(ds, '.')])
268
+ end
269
+ lookup[['alias.left.', 'DS']] = message_with('alias.left.', 'DS', [
270
+ signed('alias.left. 300 IN CNAME target.right.', 'left.')])
271
+ lookup.merge!(responses)
272
+ queries, budgets = [], []
273
+ resolver = Object.new
274
+ resolver.define_singleton_method(:query_no_validation_or_recursion) do |name, type|
275
+ before_response&.call(name, type)
276
+ identity = [name.to_s(true), type.to_s]
277
+ queries << identity
278
+ budgets << Dnsruby::ValidationBudget.current
279
+ lookup.fetch(identity)
280
+ end
281
+ Dnsruby::Dnssec.stub(:do_validation_with_recursor?, false) do
282
+ Dnsruby::Dnssec.stub(:default_resolver, resolver) do
283
+ @verifier.stub(:get_nameservers_for, resolver) do
284
+ @verifier.stub(:get_nameservers_from_message, resolver) { yield queries, budgets }
285
+ end
286
+ end
287
+ end
288
+ end
289
+ end