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