dnsruby 1.74.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yml +16 -4
- data/DNSSEC +3 -2
- data/EXAMPLES +1 -1
- data/README.md +370 -64
- data/RELEASE_NOTES.md +302 -1
- data/Rakefile +0 -3
- data/demo/axfr.rb +24 -6
- data/demo/rubydig.rb +0 -2
- data/dnsruby.gemspec +5 -4
- data/lib/dnsruby/cache.rb +279 -47
- data/lib/dnsruby/code_mapper.rb +35 -8
- data/lib/dnsruby/code_mappers.rb +63 -49
- data/lib/dnsruby/config.rb +75 -4
- data/lib/dnsruby/deadline_queue.rb +75 -0
- data/lib/dnsruby/dnssec.rb +148 -45
- data/lib/dnsruby/ipv6.rb +45 -5
- data/lib/dnsruby/key_cache.rb +202 -35
- data/lib/dnsruby/message/decoder.rb +85 -19
- data/lib/dnsruby/message/encoder.rb +27 -16
- data/lib/dnsruby/message/header.rb +8 -12
- data/lib/dnsruby/message/message.rb +53 -22
- data/lib/dnsruby/message/section.rb +12 -34
- data/lib/dnsruby/name.rb +5 -2
- data/lib/dnsruby/packet_sender.rb +645 -203
- data/lib/dnsruby/presentation.rb +147 -0
- data/lib/dnsruby/query_control.rb +132 -0
- data/lib/dnsruby/recursor.rb +795 -339
- data/lib/dnsruby/resolver.rb +468 -186
- data/lib/dnsruby/resource/CAA.rb +67 -16
- data/lib/dnsruby/resource/DLV.rb +6 -0
- data/lib/dnsruby/resource/DNSKEY.rb +108 -16
- data/lib/dnsruby/resource/DS.rb +27 -28
- data/lib/dnsruby/resource/HTTPS.rb +16 -0
- data/lib/dnsruby/resource/IN.rb +19 -0
- data/lib/dnsruby/resource/MX.rb +2 -2
- data/lib/dnsruby/resource/NSEC.rb +6 -10
- data/lib/dnsruby/resource/NSEC3.rb +55 -19
- data/lib/dnsruby/resource/NSEC3PARAM.rb +4 -6
- data/lib/dnsruby/resource/RR.rb +108 -65
- data/lib/dnsruby/resource/RRSIG.rb +5 -5
- data/lib/dnsruby/resource/RRSet.rb +38 -22
- data/lib/dnsruby/resource/SRV.rb +2 -2
- data/lib/dnsruby/resource/SVCB.rb +775 -0
- data/lib/dnsruby/resource/TSIG.rb +266 -231
- data/lib/dnsruby/resource/TXT.rb +34 -42
- data/lib/dnsruby/resource/domain_name.rb +1 -1
- data/lib/dnsruby/resource/generic.rb +31 -0
- data/lib/dnsruby/resource/generic_cache.rb +384 -0
- data/lib/dnsruby/resource/generic_constants.rb +79 -0
- data/lib/dnsruby/select_thread.rb +979 -291
- data/lib/dnsruby/serial_time.rb +25 -0
- data/lib/dnsruby/single_resolver.rb +25 -6
- data/lib/dnsruby/single_verifier.rb +1620 -530
- data/lib/dnsruby/transport_clock.rb +9 -0
- data/lib/dnsruby/update.rb +8 -6
- data/lib/dnsruby/validation_budget.rb +143 -0
- data/lib/dnsruby/validator_thread.rb +139 -58
- data/lib/dnsruby/version.rb +1 -1
- data/lib/dnsruby/zone_reader.rb +196 -129
- data/lib/dnsruby/zone_transfer.rb +157 -25
- data/lib/dnsruby.rb +7 -2
- data/test/ci_watchdog.rb +33 -0
- data/test/fixtures/org-ds-2026-11-05.zone +5 -0
- data/test/fixtures/root-dnskey-2026-11-05.zone +7 -0
- data/test/fork_test_helper.rb +37 -0
- data/test/local_dns_server.rb +57 -0
- data/test/soak_server.rb +152 -0
- data/test/socket_helpers.rb +26 -0
- data/test/spec_helper.rb +8 -13
- data/test/tc_attempt_configuration.rb +137 -0
- data/test/tc_caa.rb +2 -2
- data/test/tc_cache_keys.rb +36 -0
- data/test/tc_cancelled_send_capacity.rb +158 -0
- data/test/tc_dnssec_alias_validation.rb +289 -0
- data/test/tc_dnssec_budget.rb +489 -0
- data/test/tc_dnssec_cache_bounds.rb +197 -0
- data/test/tc_dnssec_cache_lifetime.rb +615 -0
- data/test/tc_dnssec_denial_proofs.rb +285 -0
- data/test/tc_dnssec_recursion_limits.rb +186 -0
- data/test/tc_dnssec_recursor_review_fixes.rb +351 -0
- data/test/tc_dnssec_trust_anchors.rb +286 -0
- data/test/tc_dnssec_trust_races.rb +147 -0
- data/test/tc_dnssec_wildcard_proofs.rb +249 -0
- data/test/tc_ds.rb +5 -5
- data/test/tc_edns_retry_regressions.rb +255 -0
- data/test/tc_escapedchars.rb +8 -0
- data/test/tc_final_review_fixes.rb +167 -0
- data/test/tc_final_transport_contracts.rb +104 -0
- data/test/tc_generic_cache.rb +852 -0
- data/test/tc_header.rb +26 -0
- data/test/tc_ipv6.rb +120 -0
- data/test/tc_key_cache_identity.rb +382 -0
- data/test/tc_malformed_tcp.rb +712 -0
- data/test/tc_name.rb +99 -5
- data/test/tc_nameserver_bootstrap.rb +322 -0
- data/test/tc_nsec.rb +4 -45
- data/test/tc_nsec3.rb +17 -1
- data/test/tc_pipeline_retirement.rb +358 -0
- data/test/tc_pipeline_retry.rb +462 -0
- data/test/tc_pipeline_send.rb +663 -0
- data/test/tc_protocol_review_fixes.rb +95 -0
- data/test/tc_query_control.rb +90 -0
- data/test/tc_record_codec_contracts.rb +107 -0
- data/test/tc_record_regressions.rb +225 -0
- data/test/tc_recur.rb +1 -0
- data/test/tc_recursor_address_fallback.rb +169 -0
- data/test/tc_recursor_limits.rb +139 -0
- data/test/tc_release_auxiliary_fixes.rb +288 -0
- data/test/tc_release_configuration_fixes.rb +612 -0
- data/test/tc_release_dnssec_completion.rb +405 -0
- data/test/tc_release_dnssec_fixes.rb +305 -0
- data/test/tc_release_negative_cache.rb +327 -0
- data/test/tc_release_recursor_cache.rb +212 -0
- data/test/tc_release_review_transport.rb +372 -0
- data/test/tc_release_review_transport_native.rb +56 -0
- data/test/tc_release_transport.rb +906 -0
- data/test/tc_release_transport_fixes.rb +196 -0
- data/test/tc_release_zone_defaults.rb +157 -0
- data/test/tc_release_zone_ttls.rb +39 -0
- data/test/tc_res_config.rb +42 -20
- data/test/tc_res_opt.rb +21 -1
- data/test/tc_resolver.rb +21 -19
- data/test/tc_resolver_concurrency.rb +383 -0
- data/test/tc_resolver_configuration_contracts.rb +31 -0
- data/test/tc_resolver_validation.rb +211 -0
- data/test/tc_review_cache.rb +163 -0
- data/test/tc_review_code_mapper.rb +279 -0
- data/test/tc_review_control_deadlines.rb +169 -0
- data/test/tc_review_followup_transport.rb +583 -0
- data/test/tc_review_packet_contracts.rb +369 -0
- data/test/tc_review_presentation.rb +213 -0
- data/test/tc_review_record_compatibility.rb +101 -0
- data/test/tc_review_transport_fixes.rb +195 -0
- data/test/tc_review_transport_native.rb +415 -0
- data/test/tc_review_validation_priming.rb +178 -0
- data/test/tc_rr-opt.rb +5 -2
- data/test/tc_rr-unknown.rb +19 -0
- data/test/tc_rr.rb +14 -0
- data/test/tc_rrset_grouping.rb +50 -0
- data/test/tc_rrset_review_fixes.rb +58 -0
- data/test/tc_rrsig.rb +21 -1
- data/test/tc_scoped_ipv6.rb +153 -0
- data/test/tc_scoped_ipv6_select.rb +157 -0
- data/test/tc_scoped_ipv6_transport.rb +329 -0
- data/test/tc_selector_wakeup.rb +42 -0
- data/test/tc_soak.rb +118 -196
- data/test/tc_socket_helpers.rb +90 -0
- data/test/tc_supporting_lookup_limits.rb +252 -0
- data/test/tc_svcb.rb +1043 -0
- data/test/tc_svcb_signed_target.rb +145 -0
- data/test/tc_svcb_transport.rb +120 -0
- data/test/tc_tcp.rb +69 -43
- data/test/tc_tcp_pipelining.rb +95 -204
- data/test/tc_transport_fork.rb +179 -0
- data/test/tc_tsig.rb +2 -2
- data/test/tc_tsig_api.rb +251 -0
- data/test/tc_tsig_rfc8945.rb +357 -0
- data/test/tc_v2_release_dnssec.rb +442 -0
- data/test/tc_v2_release_transport.rb +304 -0
- data/test/tc_v2_zone_transfer_native.rb +58 -0
- data/test/tc_zone_reader.rb +217 -1
- data/test/tcp_pipelining_server.rb +150 -0
- data/test/ts_offline.rb +77 -6
- data/test/ts_online.rb +2 -1
- metadata +105 -10
- data/.coveralls.yml +0 -2
|
@@ -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
|
-
|
|
114
|
-
|
|
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
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
362
|
-
|
|
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
|
-
|
|
441
|
+
buf
|
|
365
442
|
end
|
|
366
443
|
|
|
367
444
|
def receive_message(socket) #:nodoc: all
|
|
368
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
data/test/ci_watchdog.rb
ADDED
|
@@ -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
|
data/test/soak_server.rb
ADDED
|
@@ -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
|