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
@@ -35,15 +35,31 @@ module Dnsruby
35
35
  attr_reader :last_tsigstate
36
36
  # Sets the connect timeout in seconds
37
37
  attr_accessor :connect_timeout
38
+ # Maximum seconds for one complete DNS frame to be read or written.
39
+ attr_reader :io_timeout
40
+ # Optional maximum seconds per server, including connect and IXFR fallback.
41
+ attr_reader :transfer_timeout
42
+
43
+ def io_timeout=(seconds)
44
+ @io_timeout = positive_timeout(seconds)
45
+ end
46
+
47
+ def transfer_timeout=(seconds)
48
+ @transfer_timeout = seconds.nil? ? nil : positive_timeout(seconds)
49
+ end
38
50
 
39
51
  # Sets the TSIG to sign the zone transfer with.
40
52
  # Pass in either a Dnsruby::RR::TSIG, or a key_name and key (or just a key)
41
53
  # Pass in nil to stop tsig signing.
54
+ # Defaults to hmac-sha256; the algorithm must match the server's key.
55
+ # You can select hmac-md5 for legacy servers, which logs a warning.
42
56
  # * res.tsig=(tsig_rr)
43
57
  # * res.tsig=(key_name, key)
44
58
  # * res.tsig=nil # Don't sign the transfer
45
59
  def tsig=(*args)
46
60
  @tsig = Resolver.get_tsig(args)
61
+ @tsig_configured_time = @tsig&.time_signed
62
+ @last_tsig_time = nil
47
63
  end
48
64
 
49
65
 
@@ -57,6 +73,8 @@ module Dnsruby
57
73
  @axfr = nil
58
74
  @src_address = nil
59
75
  @connect_timeout = 5
76
+ @io_timeout = 30
77
+ @transfer_timeout = nil
60
78
  end
61
79
 
62
80
  # Perform a zone transfer (RFC1995)
@@ -110,19 +128,32 @@ module Dnsruby
110
128
  def do_transfer(zone, server) #:nodoc: all
111
129
  @transfer_type = Types.new(@transfer_type)
112
130
  @state = :InitialSoa
113
- socket = Socket.tcp(server, @port, @src_address, connect_timeout: @connect_timeout)
114
- # socket = TCPSocket.new(server, @port, @src_address)
131
+ @axfr = @ixfr = nil
132
+ @initialsoa = @end_serial = @current_serial = @rtype = nil
133
+ @last_tsigstate = nil
134
+ @transfer_deadline = @transfer_timeout && TransportClock.now + @transfer_timeout
135
+ socket = nil
115
136
  begin
137
+ connect_limit = [@connect_timeout, @transfer_timeout].compact.min
138
+ socket = Socket.tcp(server, @port, @src_address, connect_timeout: connect_limit)
116
139
  # Send an initial query
117
140
  msg = Message.new(zone, @transfer_type, @klass)
141
+ # Compare SOA owners with the absolute name sent in the question.
142
+ @zone_name = msg.question.first.qname
118
143
  if @transfer_type == Types.IXFR
119
- rr = RR.create("#{zone} 0 IN SOA" + '0 0 %u 0 0 0 0' % @serial)
144
+ rr = RR.create("#{zone} 0 #{@klass} SOA " + '0 0 %u 0 0 0 0' % @serial)
120
145
  msg.add_authority(rr)
121
146
  end
147
+ @request = msg
122
148
  send_message(socket, msg)
123
149
 
124
150
  while (@state != :End)
125
151
  response = receive_message(socket)
152
+ validate_response(response)
153
+ # An error can terminate a transfer after earlier successful frames.
154
+ if @state != :InitialSoa && response.rcode != RCode.NOERROR
155
+ raise ResolvError, response.rcode.string
156
+ end
126
157
 
127
158
  if (@state == :InitialSoa)
128
159
  rcode = response.rcode
@@ -135,16 +166,13 @@ module Dnsruby
135
166
  @transfer_type=Types.AXFR
136
167
  # Send an initial AXFR query
137
168
  msg = Message.new(zone, @transfer_type, @klass)
169
+ @request = msg
138
170
  send_message(socket, msg)
139
171
  next
140
172
  end
141
173
  raise ResolvError.new(rcode.string);
142
174
  end
143
175
 
144
- if (response.question[0].qtype != @transfer_type)
145
- raise ResolvError.new("invalid question section")
146
- end
147
-
148
176
  if (response.header.ancount == 0 && @transfer_type == Types.IXFR)
149
177
  Dnsruby.log.debug("IXFR DID NOT WORK (ancount = 0) - TRYING AXFR!!")
150
178
  # IXFR didn't work - let's try AXFR
@@ -152,6 +180,7 @@ module Dnsruby
152
180
  # Send an initial AXFR query
153
181
  @state = :InitialSoa
154
182
  msg = Message.new(zone, @transfer_type, @klass)
183
+ @request = msg
155
184
  send_message(socket, msg)
156
185
  next
157
186
  end
@@ -185,14 +214,17 @@ module Dnsruby
185
214
  # serial change) consisting of deleted RRs and added RRs. The first RR
186
215
  # of the deleted RRs is the older SOA RR and the first RR of the added
187
216
  # RRs is the newer SOA RR.
188
- socket.close
189
217
  if (@axfr!=nil)
190
- return @axfr
218
+ return @axfr.uniq
191
219
  end
192
220
  return @ixfr
193
- rescue Exception => e
194
- socket.close
195
- raise e
221
+ ensure
222
+ begin
223
+ socket.close if socket && !socket.closed?
224
+ rescue IOError, SystemCallError
225
+ # Preserve the transfer's result or original failure if close races.
226
+ end
227
+ @transfer_deadline = nil
196
228
  end
197
229
  end
198
230
 
@@ -245,13 +277,15 @@ module Dnsruby
245
277
 
246
278
  def parseRR(rec) #:nodoc: all
247
279
  type = rec.type
248
- delta = Delta.new
249
280
 
250
281
  case @state
251
282
  when :InitialSoa
252
283
  if (type != Types.SOA)
253
284
  raise ResolvError.new("missing initial SOA")
254
285
  end
286
+ unless rec.name == @zone_name && rec.klass == Classes.new(@klass)
287
+ raise ResolvError, "initial SOA does not match transfer zone"
288
+ end
255
289
  @initialsoa = rec
256
290
  # Remember the serial number in the initial SOA; we need it
257
291
  # to recognize the end of an IXFR.
@@ -312,6 +346,9 @@ module Dnsruby
312
346
  if (type == Types.SOA)
313
347
  soa_serial = rec.serial
314
348
  if (soa_serial == @end_serial)
349
+ unless same_soa?(rec, @initialsoa)
350
+ raise ResolvError, "closing SOA does not match initial SOA"
351
+ end
315
352
  @state = :End
316
353
  return
317
354
  elsif (soa_serial != @current_serial)
@@ -331,6 +368,9 @@ module Dnsruby
331
368
  if (type == Types.A && rec.klass() != @klass)
332
369
  else
333
370
  if (type == Types.SOA)
371
+ unless same_soa?(rec, @initialsoa)
372
+ raise ResolvError, "closing SOA does not match initial SOA"
373
+ end
334
374
  @state = :End
335
375
  else
336
376
  @axfr << rec
@@ -346,39 +386,131 @@ module Dnsruby
346
386
 
347
387
 
348
388
  def send_message(socket, msg) #:nodoc: all
389
+ msg.tsig.warn_if_legacy_algorithm if msg.tsig
349
390
  if (@tsig)
350
- @tsig.apply(msg)
391
+ # Keep the last query's TSIG available to callers. Generate a fresh
392
+ # timestamp for each transfer or retry unless the caller set one.
393
+ if @last_tsig_time.nil? || @tsig.time_signed != @last_tsig_time
394
+ @tsig_configured_time = @tsig.time_signed
395
+ end
396
+ signing_key = @tsig.dup
397
+ signing_key.time_signed = @tsig_configured_time
398
+ signing_key.apply(msg)
351
399
  @tsig = msg.tsig
400
+ @last_tsig_time = @tsig.time_signed
352
401
  end
353
402
  query_packet = msg.encode
354
- lenmsg = [query_packet.length].pack('n')
355
- socket.send(lenmsg, 0)
356
- socket.send(query_packet, 0)
403
+ raise EncodeError, "DNS message exceeds the 65535-byte wire limit" if query_packet.bytesize > 65_535
404
+ frame = [query_packet.bytesize].pack('n') + query_packet
405
+ deadline = io_deadline
406
+ offset = 0
407
+ while offset < frame.bytesize
408
+ check_deadline(deadline)
409
+ begin
410
+ written = socket.write_nonblock(frame.byteslice(offset, frame.bytesize - offset))
411
+ raise IOError, "zone transfer write made no progress" unless written && written > 0
412
+ offset += written
413
+ rescue IO::WaitWritable
414
+ wait_for_io(socket, deadline, false)
415
+ rescue IO::WaitReadable
416
+ wait_for_io(socket, deadline, true)
417
+ rescue Errno::EINTR
418
+ next
419
+ end
420
+ end
357
421
  end
358
422
 
359
- def tcp_read(socket, len) #:nodoc: all
360
- buf=""
361
- while (buf.length < len) and not socket.eof? do
362
- buf += socket.read(len-buf.length)
423
+ def tcp_read(socket, len, deadline = io_deadline) #:nodoc: all
424
+ buf = +"".b
425
+ while buf.bytesize < len
426
+ check_deadline(deadline)
427
+ begin
428
+ part = socket.read_nonblock(len - buf.bytesize)
429
+ raise ResolvError, "Server closed an incomplete zone transfer" if part.nil? || part.empty?
430
+ buf << part
431
+ rescue IO::WaitReadable
432
+ wait_for_io(socket, deadline, true)
433
+ rescue IO::WaitWritable
434
+ wait_for_io(socket, deadline, false)
435
+ rescue Errno::EINTR
436
+ next
437
+ rescue EOFError
438
+ raise ResolvError, "Server closed an incomplete zone transfer"
439
+ end
363
440
  end
364
- return buf
441
+ buf
365
442
  end
366
443
 
367
444
  def receive_message(socket) #:nodoc: all
368
- buf = tcp_read(socket, 2)
445
+ deadline = io_deadline
446
+ buf = tcp_read(socket, 2, deadline)
369
447
  answersize = buf.unpack('n')[0]
370
448
  # Some servers (e.g. dnscache) apparently hang up on some connections.
371
449
  # Thanks to Matt Palmer for the fix.
372
450
  raise ResolvError.new("Server did not send a valid answer") if answersize.nil?
373
- buf = tcp_read(socket, answersize)
451
+ buf = tcp_read(socket, answersize, deadline)
374
452
  msg = Message.decode(buf)
375
453
  if (@tsig)
376
- if !@tsig.verify_envelope(msg, buf)
454
+ signature = msg.tsig
455
+ verified = @tsig.verify_envelope(msg, buf)
456
+ @last_tsigstate = msg.tsigstate
457
+ if !verified
458
+ authenticated_error = RR::TSIG.authenticated_error(msg, signature)
459
+ raise authenticated_error if authenticated_error
377
460
  Dnsruby.log.error("Bad signature on zone transfer - closing connection")
378
461
  raise ResolvError.new("Bad signature on zone transfer")
379
462
  end
380
463
  end
381
464
  return msg
382
465
  end
466
+
467
+ private
468
+
469
+ def positive_timeout(seconds)
470
+ value = Float(seconds)
471
+ raise ArgumentError, "timeout must be a positive finite number" unless value.finite? && value > 0
472
+ value
473
+ end
474
+
475
+ def io_deadline
476
+ [TransportClock.now + @io_timeout, @transfer_deadline].compact.min
477
+ end
478
+
479
+ def check_deadline(deadline)
480
+ raise ResolvTimeout, "Zone transfer I/O timed out" if TransportClock.now >= deadline
481
+ end
482
+
483
+ def wait_for_io(socket, deadline, readable)
484
+ check_deadline(deadline)
485
+ remaining = deadline - TransportClock.now
486
+ IO.select(readable ? [socket] : nil, readable ? nil : [socket], nil, [remaining, 0].max)
487
+ rescue Errno::EINTR
488
+ retry
489
+ end
490
+
491
+ def validate_response(response)
492
+ unless response.header.id == @request.header.id && response.header.qr &&
493
+ response.header.opcode == @request.header.opcode
494
+ raise ResolvError, "invalid zone transfer response header"
495
+ end
496
+ required_question = @state == :InitialSoa || response.rcode != RCode.NOERROR
497
+ questions = response.question
498
+ if questions.empty? && !required_question
499
+ return
500
+ end
501
+ request_question = @request.question.first
502
+ unless questions.length == 1 && questions.first.qname == request_question.qname &&
503
+ questions.first.qtype == request_question.qtype && questions.first.qclass == request_question.qclass
504
+ raise ResolvError, "invalid question section"
505
+ end
506
+ end
507
+
508
+ def same_soa?(left, right)
509
+ left.name == right.name && left.klass == right.klass && left.ttl == right.ttl &&
510
+ [:mname, :rname, :serial, :refresh, :retry, :expire, :minimum].all? do |field|
511
+ left.public_send(field) == right.public_send(field)
512
+ end
513
+ end
514
+
383
515
  end
384
516
  end
data/lib/dnsruby.rb CHANGED
@@ -102,7 +102,9 @@ require 'dnsruby/zone_transfer'
102
102
  #
103
103
  # == DNSSEC
104
104
  # Dnsruby supports DNSSEC and NSEC(3).
105
- # DNSSEC support is on by default - but no trust anchors are configured by default.
105
+ # Resolver#do_validation defaults to false; set it to true for local validation.
106
+ # Built-in root DS trust anchors are configured. The dnssec flag controls requesting
107
+ # DNSSEC records and is separate from validation.
106
108
  # See Dnsruby::Dnssec for more details.
107
109
  #
108
110
  # == Codes
@@ -122,7 +124,6 @@ require 'dnsruby/zone_transfer'
122
124
  # == Bugs
123
125
  # * NIS is not supported.
124
126
  # * /etc/nsswitch.conf is not supported.
125
- # * NSEC3 validation still TBD
126
127
  module Dnsruby
127
128
 
128
129
  def Dnsruby.version
@@ -157,6 +158,10 @@ module Dnsruby
157
158
  attr_accessor :response
158
159
  end
159
160
 
161
+ # A recursive lookup exceeded its aggregate work or query limit.
162
+ class RecursionLimitError < ResolvError
163
+ end
164
+
160
165
  # A timeout error raised while querying for a resource
161
166
  class ResolvTimeout < Timeout::Error
162
167
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CiWatchdog
4
+ TIMEOUT = Float(ENV.fetch('DNSRUBY_TEST_TIMEOUT'))
5
+ raise ArgumentError, 'DNSRUBY_TEST_TIMEOUT must be positive and finite' unless TIMEOUT.positive? && TIMEOUT.finite?
6
+
7
+ def run
8
+ label = "#{self.class}##{name}"
9
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
10
+ $stderr.puts("START #{label}")
11
+ $stderr.flush
12
+ watchdog = Thread.new do
13
+ sleep TIMEOUT
14
+ $stderr.puts("\nTIMEOUT after #{TIMEOUT}s: #{label}")
15
+ Thread.list.each do |thread|
16
+ $stderr.puts("#{thread.inspect}\n#{Array(thread.backtrace).join("\n")}")
17
+ end
18
+ $stderr.flush
19
+ # Raising in the test thread can get stuck again in teardown or while
20
+ # Open3 waits for a child. End this failed run after preserving its stacks.
21
+ exit! 124
22
+ end
23
+ super
24
+ ensure
25
+ watchdog&.kill
26
+ watchdog&.join
27
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
28
+ $stderr.puts("DONE #{label} (#{format('%.3f', elapsed)}s)")
29
+ $stderr.flush
30
+ end
31
+ end
32
+
33
+ Minitest::Test.prepend(CiWatchdog)
@@ -0,0 +1,5 @@
1
+ ; Captured 2026-11-05 from https://dns.google/resolve?name=org&type=DS&do=true
2
+ ; Original answer RDATA and TTLs; no signatures or keys synthesized.
3
+ ; Tests use Time.utc(2026, 11, 5, 12) within both signature validity periods.
4
+ org. 8211 IN DS 26974 8 2 4FEDE294C53F438A158C41D39489CD78A86BEB0D8A0AEAFF14745C0D16E1DE32
5
+ org. 8211 IN RRSIG ds 8 1 86400 1794891600 1793764800 8763 . b4V+K2vEqTwsd5SLh5TOrGHF1HVKHsP292XLRV1mmufaG8JO6f6oI/leUNvOy/hm3yPPYShuJvhAQCtcnxe+q+odD6/e8PZ8bW6QlgzcltauMVrphV4rXsxI8XgxLQv/mQDHaBVY/3smBijb96QxnBNfL83MNnYg+n/BR1wckIVMz5u4EKaVTat+MYI/2iffqkKbcQX3mWo/Fm3+69bkJr7fCUxGXu7EAvjOvuAM73SQRsaDnfoaUIkfGsjPSv2YEdYg6jgoYxC8AVdtS8OW2vd/XUoeKEpjUnC/jZ0b1fZFkVT4vK+hw1bb/z0VD9bI6O7LZGgKntz6KXO57HY4Cg==
@@ -0,0 +1,7 @@
1
+ ; Captured 2026-11-05 from https://dns.google/resolve?name=.&type=DNSKEY&do=true
2
+ ; Original answer RDATA and TTLs; no signatures or keys synthesized.
3
+ ; Tests use Time.utc(2026, 11, 5, 12) within both signature validity periods.
4
+ . 24999 IN DNSKEY 257 3 8 AwEAAa96jeuknZlaeSrvyAJj6ZHv28hhOKkx3rLGXVaC6rXTsDc449/cidltpkyGwCJNnOAlFNKF2jBosZBU5eeHspaQWOmOElZsjICMQMC3aeHbGiShvZsx4wMYSjH8e7Vrhbu6irwCzVBApESjbUdpWWmEnhathWu1jo+siFUiRAAxm9qyJNg/wOZqqzL/dL/q8PkcRU5oUKEpUge71M3ej2/7CPqpdVwuMoTvoB+ZOT4YeGyxMvHmbrxlFzGOHOijtzN+u1TQNatX2XBuzZNQ1K+s2CXkPIZo7s6JgZyvaBevYtxPvYLw4z9mR7K2vaF18UYH9Z9GNUUeayffKC73PYc=
5
+ . 24999 IN DNSKEY 256 3 8 AwEAAcorNxNc9t0ue1ZenPjVMRujlQ2YLBJ0pR/4XMjHby9vr03cRxFSKnWpxeg5PL384D9VeqdS1bjrGyTsHtoagzI3NiXFd8sjHMOnZxu5pp/+UBIg7tasYgVMMdUiM1Yu/c+t30d1Qw/v6NYLNnOnywTu6HuUh7Y1sZRq/GZcrxLENXHQoyKpOrOr8R3+qd6wK69IKB80ZBZtkX9rdEqKJk+yYXjEZzZ0zkAFthmNgCreAoU9PVlU66dyOsaLjf3wKUlUm9SNOKs+tK6LuSmRbK6+1T/tPaRaMBSEuv2G6vXfnJ1uyMbGvtpRn/YDcvTjrp6DzE6XyF44QdwJ3oKx4ek=
6
+ . 24999 IN DNSKEY 257 3 8 AwEAAaz/tAm8yTn4Mfeh5eyI96WSVexTBAvkMgJzkKTOiW1vkIbzxeF3+/4RgWOq7HrxRixHlFlExOLAJr5emLvN7SWXgnLh4+B5xQlNVz8Og8kvArMtNROxVQuCaSnIDdD5LKyWbRd2n9WGe2R8PzgCmr3EgVLrjyBxWezF0jLHwVN8efS3rCj/EWgvIWgb9tarpVUDK/b58Da+sqqls3eNbuv7pr+eoZG+SrDK6nWeL3c6H5Apxz7LjVc1uTIdsIXxuOLYA4/ilBmSVIzuDWfdRUfhHdY6+cn8HFRm+2hM8AnXGXws9555KrUB5qihylGa8subX2Nn6UwNR1AkUTV74bU=
7
+ . 24999 IN RRSIG dnskey 8 0 172800 1795219200 1793404800 38696 . UFkJYhexq5ZgfvP+oIz2WUJQgF8bhW8st3LnXtIBP0eRcSQXrYg7+luxwQYg+3iiZV2+BJPcNmf/pDu+uNPaplkHPJLXh9WS0oEVZKUNk/0wk3A0XL5UOSTdPWn/IoTWWxyGxun+udV7eRQqVpRvSdlBFaqPWSDIDkX0QQIfye72TB4tsPBnA2iWDye6U157EcPBdNeKv4cfh1cAOlePIaxLs1NoNHIu/ZrqEY5zM+GNJ4Z72Tba09jYjz3ayBSPVinI64OYOORqZsYtuLXUrBdD6UWfV8Ljf3b0MGfBs7BoZYAJxVyNr5CQF13CLw+4xrbMsmQWdTBqzp5iEtB9VA==
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module ForkTestHelper
6
+ # A timeout started before fork cannot protect the child: its timer thread
7
+ # exists only in the parent. Bound both processes and always reap the child,
8
+ # including when an outer test timeout interrupts the parent's wait.
9
+ def self.run(timeout: 5)
10
+ pid = nil
11
+ Thread.handle_interrupt(Exception => :never) do
12
+ pid = fork do
13
+ Thread.handle_interrupt(Exception => :immediate) do
14
+ begin
15
+ Timeout.timeout(timeout) { yield }
16
+ exit! 0
17
+ rescue Exception => error
18
+ warn error.full_message
19
+ exit! 1
20
+ end
21
+ end
22
+ end
23
+ end
24
+ Timeout.timeout(timeout + 2) { Process.wait2(pid).last }
25
+ ensure
26
+ Thread.handle_interrupt(Exception => :never) do
27
+ begin
28
+ if pid && !Process.waitpid(pid, Process::WNOHANG)
29
+ Process.kill('KILL', pid)
30
+ Process.waitpid(pid)
31
+ end
32
+ rescue Errno::ECHILD, Errno::ESRCH
33
+ # A successful wait already reaped this child.
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,57 @@
1
+ require 'socket'
2
+ require 'timeout'
3
+
4
+ # A single DNS exchange over a real socket, independent of system DNS and caches.
5
+ module LocalDnsServer
6
+ def with_local_dns_server(transport, rcode: Dnsruby::RCode.NOERROR, answer: nil)
7
+ socket = if transport == :tcp
8
+ TCPServer.new('127.0.0.1', 0)
9
+ else
10
+ UDPSocket.new
11
+ end
12
+ socket.bind('127.0.0.1', 0) if transport == :udp
13
+
14
+ server = Thread.new do
15
+ begin
16
+ if transport == :tcp
17
+ client = socket.accept
18
+ length = client.read(2).unpack('n').first
19
+ packet = client.read(length)
20
+ else
21
+ packet, sender = socket.recvfrom(65_535)
22
+ end
23
+ response = Dnsruby::Message.decode(packet)
24
+ response.header.qr = true
25
+ response.header.ra = true
26
+ response.header.rcode = rcode
27
+ response.add_answer(Dnsruby::RR.create(answer)) if answer
28
+ wire = response.encode
29
+ if transport == :tcp
30
+ client.write([wire.bytesize].pack('n') + wire)
31
+ else
32
+ socket.send(wire, 0, sender[3], sender[1])
33
+ end
34
+ rescue StandardError => error
35
+ # Some tests enable Thread.abort_on_exception globally. Report fixture
36
+ # errors in the test thread instead of aborting the whole suite.
37
+ error
38
+ ensure
39
+ client.close if client && !client.closed?
40
+ end
41
+ end
42
+
43
+ resolver = Dnsruby::Resolver.new(
44
+ nameserver: '127.0.0.1', port: socket.addr[1], use_tcp: transport == :tcp,
45
+ do_caching: false, dnssec: false, query_timeout: 2, retry_times: 1
46
+ )
47
+ Timeout.timeout(5) do
48
+ yield resolver
49
+ result = server.value
50
+ raise result if result.is_a?(StandardError)
51
+ end
52
+ ensure
53
+ resolver.close if resolver
54
+ server.kill.join if server
55
+ socket.close if socket && !socket.closed?
56
+ end
57
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'socket'
4
+ require 'resolv'
5
+ require 'thread'
6
+ require 'timeout'
7
+ require_relative 'socket_helpers'
8
+
9
+ # Local, independent DNS peer. No public DNS traffic, fixed port, privileged
10
+ # service, RubyDNS API or subprocess is required.
11
+ class SoakServer
12
+ IP = '127.0.0.1'
13
+ ADDRESS = '192.0.2.155'
14
+ EXCHANGE = 'mail.soak.test.'
15
+ ALIAS = 'alias.soak.test.'
16
+ TEXT = 'dnsruby-soak'
17
+ TYPES = %w[A MX CNAME TXT].freeze
18
+
19
+ attr_reader :port
20
+
21
+ def initialize
22
+ @udp, @listener = TestSockets.udp_tcp_pair(IP)
23
+ @port = @listener.addr[1]
24
+ @wake_read, @wake_write = IO.pipe
25
+ @clients = {}
26
+ ready = Queue.new
27
+ @worker = Thread.new do
28
+ Thread.current.abort_on_exception = false
29
+ Thread.current.report_on_exception = false
30
+ begin
31
+ ready << true
32
+ run
33
+ rescue StandardError => error
34
+ @error = error
35
+ end
36
+ end
37
+ Timeout.timeout(5) { ready.pop }
38
+ check!
39
+ rescue Exception
40
+ if @worker
41
+ @worker.kill
42
+ @worker.join
43
+ end
44
+ [@listener, @udp, @wake_read, @wake_write].compact.each { |io| io.close unless io.closed? }
45
+ raise
46
+ end
47
+
48
+ def check!
49
+ raise @error if @error
50
+ end
51
+
52
+ def stop
53
+ @wake_write.close unless @wake_write.closed?
54
+ unless @worker.join(5)
55
+ @worker.kill
56
+ @worker.join
57
+ raise 'Soak server did not stop within five seconds'
58
+ end
59
+ check!
60
+ end
61
+
62
+ private
63
+
64
+ def run
65
+ loop do
66
+ writers = @clients.select { |_, buffers| !buffers[:output].empty? }.keys
67
+ readable, writable = IO.select([@wake_read, @listener, @udp] + @clients.keys, writers)
68
+ break if readable.include?(@wake_read)
69
+ readable.each do |socket|
70
+ if socket == @listener
71
+ accept
72
+ elsif socket == @udp
73
+ receive_udp
74
+ else
75
+ receive_tcp(socket)
76
+ end
77
+ end
78
+ writable.each { |socket| write_tcp(socket) if @clients.key?(socket) }
79
+ end
80
+ ensure
81
+ @clients.keys.each { |socket| disconnect(socket) }
82
+ [@listener, @udp, @wake_read].each { |io| io.close unless io.closed? }
83
+ end
84
+
85
+ def accept
86
+ socket = @listener.accept_nonblock(exception: false)
87
+ return if socket == :wait_readable
88
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
89
+ @clients[socket] = { input: ''.b, output: ''.b }
90
+ end
91
+
92
+ def receive_udp
93
+ packet = @udp.recvfrom_nonblock(65_535, exception: false)
94
+ return if packet == :wait_readable
95
+ bytes, peer = packet
96
+ @udp.send(answer(bytes), 0, peer[3], peer[1])
97
+ end
98
+
99
+ def receive_tcp(socket)
100
+ bytes = socket.read_nonblock(65_535, exception: false)
101
+ return if bytes == :wait_readable
102
+ return disconnect(socket) if bytes.nil?
103
+ buffers = @clients.fetch(socket)
104
+ buffers[:input] << bytes
105
+ while buffers[:input].bytesize >= 2
106
+ length = buffers[:input].unpack1('n')
107
+ break if buffers[:input].bytesize < length + 2
108
+ buffers[:input].slice!(0, 2)
109
+ response = answer(buffers[:input].slice!(0, length))
110
+ buffers[:output] << [response.bytesize].pack('n') << response
111
+ end
112
+ write_tcp(socket)
113
+ rescue Errno::ECONNRESET, Errno::EPIPE
114
+ disconnect(socket)
115
+ end
116
+
117
+ def write_tcp(socket)
118
+ output = @clients.fetch(socket)[:output]
119
+ return if output.empty?
120
+ written = socket.write_nonblock(output, exception: false)
121
+ output.slice!(0, written) if written.is_a?(Integer)
122
+ rescue Errno::ECONNRESET, Errno::EPIPE
123
+ disconnect(socket)
124
+ end
125
+
126
+ def disconnect(socket)
127
+ @clients.delete(socket)
128
+ socket.close unless socket.closed?
129
+ end
130
+
131
+ def answer(bytes)
132
+ query = ::Resolv::DNS::Message.decode(bytes)
133
+ response = ::Resolv::DNS::Message.new(query.id)
134
+ response.qr = 1
135
+ response.aa = 1
136
+ response.rd = query.rd
137
+ response.ra = 1
138
+ query.each_question do |name, type|
139
+ raise "Unsupported soak class: #{type}" unless type::ClassValue == 1
140
+ resource = case type::TypeValue
141
+ when 1 then type.new(ADDRESS)
142
+ when 15 then type.new(10, ::Resolv::DNS::Name.create(EXCHANGE))
143
+ when 5 then type.new(::Resolv::DNS::Name.create(ALIAS))
144
+ when 16 then type.new(TEXT)
145
+ else raise "Unsupported soak question: #{type}"
146
+ end
147
+ response.add_question(name, type)
148
+ response.add_answer(name, 60, resource)
149
+ end
150
+ response.encode
151
+ end
152
+ end
@@ -0,0 +1,26 @@
1
+ require 'socket'
2
+
3
+ module TestSockets
4
+ # UDP and TCP have separate port namespaces. Keep both sockets reserved
5
+ # before starting a peer, and retry only collisions during allocation.
6
+ def self.udp_tcp_pair(host = '127.0.0.1')
7
+ 10.times do |attempt|
8
+ udp = tcp = nil
9
+ reserved = false
10
+ begin
11
+ tcp = TCPServer.new(host, 0)
12
+ udp = UDPSocket.new
13
+ udp.bind(host, tcp.addr[1])
14
+ reserved = true
15
+ return udp, tcp
16
+ rescue Errno::EADDRINUSE
17
+ raise if attempt == 9
18
+ ensure
19
+ unless reserved
20
+ udp.close if udp && !udp.closed?
21
+ tcp.close if tcp && !tcp.closed?
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end