cool.io 1.9.4 → 1.10.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/test.yaml +1 -0
- data/.github/workflows/valgrind.yaml +41 -0
- data/Rakefile +7 -19
- data/cool.io.gemspec +1 -3
- data/ext/cool.io/buffer.c +77 -52
- data/ext/cool.io/cool.io.h +6 -0
- data/ext/cool.io/ev_wrap.h +1 -0
- data/ext/cool.io/extconf.rb +0 -1
- data/ext/cool.io/iowatcher.c +18 -1
- data/ext/cool.io/loop.c +21 -19
- data/ext/cool.io/stat_watcher.c +44 -19
- data/ext/cool.io/timer_watcher.c +1 -1
- data/ext/cool.io/watcher.c +13 -1
- data/ext/cool.io/watcher.h +0 -17
- data/ext/libev/ev.c +80 -12
- data/ext/libev/ev_select.c +10 -0
- data/ext/libev/ev_vars.h +4 -0
- data/ext/libev/ev_win32_stat.c +851 -0
- data/ext/libev/ev_wrap.h +4 -0
- data/gems.rb +4 -0
- data/lib/cool.io/dns_resolver.rb +53 -8
- data/lib/cool.io/loop.rb +5 -1
- data/lib/cool.io/version.rb +2 -2
- data/libev_win_select.diff +25 -9
- data/spec/async_watcher_spec.rb +3 -1
- data/spec/detach_race_condition_spec.rb +59 -25
- data/spec/dns_spec.rb +197 -0
- data/spec/fixtures/windows_stat_native.c +1305 -0
- data/spec/iobuffer_spec.rb +88 -1
- data/spec/loop_spec.rb +31 -0
- data/spec/stat_watcher_spec.rb +86 -1
- data/spec/tcp_server_spec.rb +34 -10
- data/spec/watcher_reattach_spec.rb +54 -0
- data/spec/windows_stat_native_spec.rb +193 -0
- data/spec/windows_stat_watcher_spec.rb +363 -0
- metadata +14 -2
data/ext/libev/ev_wrap.h
CHANGED
|
@@ -98,6 +98,8 @@
|
|
|
98
98
|
#define vec_ro ((loop)->vec_ro)
|
|
99
99
|
#define vec_wi ((loop)->vec_wi)
|
|
100
100
|
#define vec_wo ((loop)->vec_wo)
|
|
101
|
+
#define win32_fs ((loop)->win32_fs)
|
|
102
|
+
#define win32_fs_disabled ((loop)->win32_fs_disabled)
|
|
101
103
|
#else
|
|
102
104
|
#undef EV_WRAP_H
|
|
103
105
|
#undef acquire_cb
|
|
@@ -197,4 +199,6 @@
|
|
|
197
199
|
#undef vec_ro
|
|
198
200
|
#undef vec_wi
|
|
199
201
|
#undef vec_wo
|
|
202
|
+
#undef win32_fs
|
|
203
|
+
#undef win32_fs_disabled
|
|
200
204
|
#endif
|
data/gems.rb
CHANGED
data/lib/cool.io/dns_resolver.rb
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
#++
|
|
19
19
|
|
|
20
20
|
require 'resolv'
|
|
21
|
+
require 'securerandom'
|
|
22
|
+
require 'socket'
|
|
21
23
|
|
|
22
24
|
module Coolio
|
|
23
25
|
# A non-blocking DNS resolver. It provides interfaces for querying both
|
|
@@ -67,14 +69,22 @@ module Coolio
|
|
|
67
69
|
# list of nameservers to query. By default the resolver will
|
|
68
70
|
# use nameservers listed in /etc/resolv.conf
|
|
69
71
|
def initialize(hostname, *nameservers)
|
|
72
|
+
nameservers = reject_ipv6_nameservers(nameservers)
|
|
70
73
|
if nameservers.empty?
|
|
71
|
-
nameservers = Resolv::DNS::Config.default_config_hash[:nameserver]
|
|
74
|
+
nameservers = reject_ipv6_nameservers(Resolv::DNS::Config.default_config_hash[:nameserver])
|
|
72
75
|
raise RuntimeError, "no nameservers found" if nameservers.empty? # TODO just call resolve_failed, not raise [also handle Errno::ENOENT)]
|
|
73
76
|
end
|
|
74
77
|
|
|
75
78
|
@nameservers = nameservers.dup
|
|
76
79
|
@question = request_question hostname
|
|
77
80
|
|
|
81
|
+
# A guessable ID would let an off-path attacker forge a response
|
|
82
|
+
@request_id = SecureRandom.random_number(1 << 16)
|
|
83
|
+
|
|
84
|
+
# Numeric addresses this query was sent to, and the lookups behind them
|
|
85
|
+
@queried_addresses = []
|
|
86
|
+
@numeric_addresses = {}
|
|
87
|
+
|
|
78
88
|
@socket = UDPSocket.new
|
|
79
89
|
@timer = Timeout.new(self)
|
|
80
90
|
|
|
@@ -113,27 +123,44 @@ module Coolio
|
|
|
113
123
|
|
|
114
124
|
# Send a request to the DNS server
|
|
115
125
|
def send_request
|
|
116
|
-
|
|
117
|
-
|
|
126
|
+
@nameservers.rotate!
|
|
127
|
+
|
|
128
|
+
# Send to the numeric address, so we know where a response must come from
|
|
129
|
+
address = numeric_address(@nameservers.first)
|
|
130
|
+
@queried_addresses << address unless @queried_addresses.include?(address)
|
|
131
|
+
|
|
118
132
|
begin
|
|
119
|
-
@socket.send request_message, 0,
|
|
133
|
+
@socket.send request_message, 0, address, DNS_PORT
|
|
120
134
|
rescue Errno::EHOSTUNREACH # TODO figure out why it has to be wrapper here, when the other wrapper should be wrapping this one!
|
|
121
135
|
end
|
|
122
136
|
end
|
|
123
137
|
|
|
124
138
|
# Called by the subclass when the DNS response is available
|
|
125
139
|
def on_readable
|
|
126
|
-
datagram = nil
|
|
140
|
+
datagram = sender = nil
|
|
127
141
|
begin
|
|
128
|
-
datagram = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
|
|
142
|
+
datagram, sender = @socket.recvfrom_nonblock(DATAGRAM_SIZE)
|
|
129
143
|
rescue Errno::ECONNREFUSED
|
|
130
144
|
end
|
|
131
145
|
|
|
146
|
+
# Ignore anything we didn't ask for, rather than resolving or failing on it.
|
|
147
|
+
# The query stays outstanding, so the retry timer still bounds us.
|
|
148
|
+
return if datagram and not solicited_response?(datagram, sender)
|
|
149
|
+
|
|
132
150
|
address = response_address datagram rescue nil
|
|
133
151
|
address ? on_success(address) : on_failure
|
|
134
152
|
detach
|
|
135
153
|
end
|
|
136
154
|
|
|
155
|
+
# Is this a reply to our query, from an address we sent it to?
|
|
156
|
+
# Retries rotate through @nameservers, so any address already queried counts.
|
|
157
|
+
def solicited_response?(datagram, sender)
|
|
158
|
+
return false unless datagram.size >= 12
|
|
159
|
+
return false unless sender and sender[1] == DNS_PORT and @queried_addresses.include?(sender[3])
|
|
160
|
+
|
|
161
|
+
datagram[0..1].unpack('n').first.to_i == @request_id
|
|
162
|
+
end
|
|
163
|
+
|
|
137
164
|
def request_question(hostname)
|
|
138
165
|
raise ArgumentError, "hostname cannot be nil" if hostname.nil?
|
|
139
166
|
|
|
@@ -151,7 +178,7 @@ module Coolio
|
|
|
151
178
|
|
|
152
179
|
def request_message
|
|
153
180
|
# Standard query header
|
|
154
|
-
message = [
|
|
181
|
+
message = [@request_id, 1, 0].pack('nCC')
|
|
155
182
|
|
|
156
183
|
# One entry
|
|
157
184
|
qdcount = 1
|
|
@@ -166,7 +193,7 @@ module Coolio
|
|
|
166
193
|
def response_address(message)
|
|
167
194
|
# Confirm the ID field
|
|
168
195
|
id = message[0..1].unpack('n').first.to_i
|
|
169
|
-
return unless id ==
|
|
196
|
+
return unless id == @request_id
|
|
170
197
|
|
|
171
198
|
# Check the QR value and confirm this message is a response
|
|
172
199
|
qr = message[2..2].unpack('B1').first.to_i
|
|
@@ -191,6 +218,7 @@ module Coolio
|
|
|
191
218
|
while not message.empty?
|
|
192
219
|
type = message[2..3].unpack('n').first.to_i
|
|
193
220
|
rdlength = message[10..11].unpack('n').first.to_i
|
|
221
|
+
return if message.bytesize < 12 + rdlength
|
|
194
222
|
rdata = message[12..(12 + rdlength - 1)]
|
|
195
223
|
message.slice!(0, 12 + rdlength)
|
|
196
224
|
|
|
@@ -204,6 +232,23 @@ module Coolio
|
|
|
204
232
|
nil
|
|
205
233
|
end
|
|
206
234
|
|
|
235
|
+
private
|
|
236
|
+
|
|
237
|
+
def reject_ipv6_nameservers(nameservers)
|
|
238
|
+
nameservers.reject { |ns| ns.include?(':') }
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# The address of a nameserver, which may be given as a hostname.
|
|
242
|
+
# Only successful lookups are cached, so a transient failure is looked up again.
|
|
243
|
+
def numeric_address(nameserver)
|
|
244
|
+
@numeric_addresses[nameserver] ||= begin
|
|
245
|
+
addrinfo = Addrinfo.getaddrinfo(nameserver, nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first
|
|
246
|
+
raise SocketError, "getaddrinfo: no IPv4 address for #{nameserver}" if addrinfo.nil?
|
|
247
|
+
|
|
248
|
+
addrinfo.ip_address
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
207
252
|
class Timeout < TimerWatcher
|
|
208
253
|
def initialize(resolver)
|
|
209
254
|
@resolver = resolver
|
data/lib/cool.io/loop.rb
CHANGED
|
@@ -38,6 +38,10 @@ module Coolio
|
|
|
38
38
|
# :kqueue (BSD/Mac OS X)
|
|
39
39
|
# :port (Solaris 10)
|
|
40
40
|
#
|
|
41
|
+
# Raises RuntimeError if none of the selected backends is usable.
|
|
42
|
+
# Unless :skip_environment is true, $LIBEV_FLAGS can override the
|
|
43
|
+
# backend selection and cause the same error.
|
|
44
|
+
#
|
|
41
45
|
def initialize(options = {})
|
|
42
46
|
@watchers = {}
|
|
43
47
|
@active_watchers = 0
|
|
@@ -47,7 +51,7 @@ module Coolio
|
|
|
47
51
|
options.each do |option, value|
|
|
48
52
|
case option
|
|
49
53
|
when :skip_environment
|
|
50
|
-
flags |=
|
|
54
|
+
flags |= EVFLAG_NOENV if value
|
|
51
55
|
when :fork_check
|
|
52
56
|
flags |= EVFLAG_FORKCHECK if value
|
|
53
57
|
when :backend
|
data/lib/cool.io/version.rb
CHANGED
data/libev_win_select.diff
CHANGED
|
@@ -1,20 +1,26 @@
|
|
|
1
1
|
diff --git a/ext/libev/ev.c b/ext/libev/ev.c
|
|
2
|
-
index
|
|
2
|
+
index a59efb2..5c18fd6 100644
|
|
3
3
|
--- a/ext/libev/ev.c
|
|
4
4
|
+++ b/ext/libev/ev.c
|
|
5
|
-
@@ -
|
|
5
|
+
@@ -210,6 +210,13 @@
|
|
6
6
|
#else
|
|
7
7
|
# include <io.h>
|
|
8
8
|
# define WIN32_LEAN_AND_MEAN
|
|
9
|
-
|
|
9
|
+
+/* ruby.h above already pulled in winsock2.h, so fd_set may be dimensioned
|
|
10
|
+
+ * already. Defining FD_SETSIZE unconditionally here would only move the bound
|
|
11
|
+
+ * used by EV_WIN_FD_SET, not the array it indexes. Take whatever is in effect
|
|
12
|
+
+ * and only supply a default when nothing has been decided yet. */
|
|
13
|
+
+# ifndef FD_SETSIZE
|
|
14
|
+
+# define FD_SETSIZE 1024
|
|
15
|
+
+# endif
|
|
10
16
|
# include <winsock2.h>
|
|
11
17
|
# include <windows.h>
|
|
12
18
|
# ifndef EV_SELECT_IS_WINSOCKET
|
|
13
19
|
diff --git a/ext/libev/ev_select.c b/ext/libev/ev_select.c
|
|
14
|
-
index
|
|
20
|
+
index ed1fc7a..eccff2b 100644
|
|
15
21
|
--- a/ext/libev/ev_select.c
|
|
16
22
|
+++ b/ext/libev/ev_select.c
|
|
17
|
-
@@ -67,6 +67,
|
|
23
|
+
@@ -67,6 +67,64 @@
|
|
18
24
|
|
|
19
25
|
#include <string.h>
|
|
20
26
|
|
|
@@ -58,6 +64,16 @@ index f38d6ca..7050778 100644
|
|
|
58
64
|
+#define EV_WIN_FD_ZERO(set) (((fd_set *)(set))->fd_count=0)
|
|
59
65
|
+#define EV_WIN_FD_ISSET(fd, set) __WSAFDIsSet((SOCKET)(fd), (fd_set *)(set))
|
|
60
66
|
+#define EV_WIN_FD_COUNT(set) (((fd_set *)(set))->fd_count)
|
|
67
|
+
+
|
|
68
|
+
+/*
|
|
69
|
+
+fd_set is dimensioned by whatever FD_SETSIZE was in effect when winsock2.h was
|
|
70
|
+
+first pulled in, but EV_WIN_FD_SET and select_modify bound-check against the
|
|
71
|
+
+FD_SETSIZE visible here. The two are decided by separate paths, so if the bound
|
|
72
|
+
+ever exceeds the declared array we would write past the end of the allocation in
|
|
73
|
+
+select_init. Catch that at build time instead.
|
|
74
|
+
+*/
|
|
75
|
+
+typedef char coolio_fd_setsize_matches_fd_set[
|
|
76
|
+
+ (sizeof (((fd_set *)0)->fd_array) / sizeof (SOCKET) >= (size_t)FD_SETSIZE) ? 1 : -1];
|
|
61
77
|
+/* ######################################## */
|
|
62
78
|
+#else
|
|
63
79
|
+#define EV_WIN_FD_CLR FD_CLR
|
|
@@ -69,7 +85,7 @@ index f38d6ca..7050778 100644
|
|
|
69
85
|
static void
|
|
70
86
|
select_modify (EV_P_ int fd, int oev, int nev)
|
|
71
87
|
{
|
|
72
|
-
@@ -91,17 +
|
|
88
|
+
@@ -91,17 +149,17 @@ select_modify (EV_P_ int fd, int oev, int nev)
|
|
73
89
|
if ((oev ^ nev) & EV_READ)
|
|
74
90
|
#endif
|
|
75
91
|
if (nev & EV_READ)
|
|
@@ -91,7 +107,7 @@ index f38d6ca..7050778 100644
|
|
|
91
107
|
|
|
92
108
|
#else
|
|
93
109
|
|
|
94
|
-
@@ -197,8 +
|
|
110
|
+
@@ -197,8 +255,8 @@ select_poll (EV_P_ ev_tstamp timeout)
|
|
95
111
|
{
|
|
96
112
|
if (timeout)
|
|
97
113
|
{
|
|
@@ -102,7 +118,7 @@ index f38d6ca..7050778 100644
|
|
|
102
118
|
}
|
|
103
119
|
|
|
104
120
|
return;
|
|
105
|
-
@@ -230,10 +
|
|
121
|
+
@@ -230,10 +288,10 @@ select_poll (EV_P_ ev_tstamp timeout)
|
|
106
122
|
int handle = fd;
|
|
107
123
|
#endif
|
|
108
124
|
|
|
@@ -116,7 +132,7 @@ index f38d6ca..7050778 100644
|
|
|
116
132
|
#endif
|
|
117
133
|
|
|
118
134
|
if (expect_true (events))
|
|
119
|
-
@@ -
|
|
135
|
+
@@ -280,9 +338,9 @@ select_init (EV_P_ int flags)
|
|
120
136
|
backend_poll = select_poll;
|
|
121
137
|
|
|
122
138
|
#if EV_SELECT_USE_FD_SET
|
data/spec/async_watcher_spec.rb
CHANGED
|
@@ -30,7 +30,9 @@ describe Cool.io::AsyncWatcher, :env => :exclude_win do
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# ensure children are ready
|
|
33
|
-
|
|
33
|
+
# rd may be O_NONBLOCK on macOS Ruby 3.2+ (IO.pipe sets O_NONBLOCK); use read
|
|
34
|
+
# instead of sysread so EAGAIN on an empty pipe is retried transparently.
|
|
35
|
+
nr_fork.times { expect(rd.read(1)).to eq('.') }
|
|
34
36
|
|
|
35
37
|
# send our signals
|
|
36
38
|
nr_signal.times { aw.signal }
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
3
|
describe Cool.io::Loop do
|
|
4
|
+
# An IOWatcher that drains its socket and then runs a user-supplied block,
|
|
5
|
+
# receiving itself as the argument.
|
|
4
6
|
class Victim < Cool.io::IOWatcher
|
|
5
|
-
def initialize(io)
|
|
6
|
-
super
|
|
7
|
+
def initialize(io, &on_readable)
|
|
8
|
+
super(io)
|
|
7
9
|
@io = io
|
|
10
|
+
@on_readable = on_readable
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def on_readable
|
|
@@ -12,40 +15,71 @@ describe Cool.io::Loop do
|
|
|
12
15
|
@io.read_nonblock(1024)
|
|
13
16
|
rescue IO::WaitReadable, EOFError
|
|
14
17
|
end
|
|
18
|
+
@on_readable.call(self) if @on_readable
|
|
15
19
|
end
|
|
16
20
|
end
|
|
17
21
|
|
|
18
22
|
# https://github.com/socketry/cool.io/issues/87
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
#
|
|
24
|
+
# Several watchers have an event pending in the same loop iteration. When the
|
|
25
|
+
# first one dispatched detaches the others, the loop must skip their now-stale
|
|
26
|
+
# pending events instead of dispatching them to a detached watcher. Before the
|
|
27
|
+
# fix that raised "TypeError: wrong argument type nil (expected Coolio::Loop)"
|
|
28
|
+
# and could crash the VM.
|
|
29
|
+
#
|
|
30
|
+
# This is exercised deterministically within a single thread (a preceding
|
|
31
|
+
# callback detaching another watcher in the same loop cycle). The original
|
|
32
|
+
# reproduction detached from a separate thread while the loop was polling,
|
|
33
|
+
# which is an unsupported concurrent mutation of libev (not thread-safe) and
|
|
34
|
+
# crashed intermittently on macOS.
|
|
35
|
+
it "does not raise when a watcher with a pending event is detached during dispatch" do
|
|
22
36
|
iterations = 200
|
|
37
|
+
# Winsock select monitors sockets, not IO.pipe handles. TCP connections
|
|
38
|
+
# exercise the same queued-event scenario on every supported platform.
|
|
39
|
+
listener = TCPServer.new('127.0.0.1', 0)
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
victim_watcher.attach(loop)
|
|
41
|
+
iterations.times do
|
|
42
|
+
coolio_loop = Cool.io::Loop.new
|
|
43
|
+
connections = []
|
|
44
|
+
watchers = []
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
begin
|
|
47
|
+
5.times do
|
|
48
|
+
w = TCPSocket.new('127.0.0.1', listener.addr[1])
|
|
49
|
+
connections << [nil, w]
|
|
50
|
+
r = listener.accept
|
|
51
|
+
connections.last[0] = r
|
|
52
|
+
|
|
53
|
+
watcher = Victim.new(r) do |fired|
|
|
54
|
+
# Detach every other watcher whose event is already queued for this
|
|
55
|
+
# same loop iteration.
|
|
56
|
+
watchers.each do |other|
|
|
57
|
+
other.detach if !other.equal?(fired) && other.attached?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
watcher.attach(coolio_loop)
|
|
61
|
+
watchers << watcher
|
|
62
|
+
|
|
63
|
+
w.write("dummy\n") # make the read end readable so an event is pending
|
|
33
64
|
end
|
|
34
65
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
66
|
+
# TCP delivery is asynchronous. Ensure all five read ends are ready
|
|
67
|
+
# before libev collects the events, without consuming their data.
|
|
68
|
+
connections.each do |r, _|
|
|
69
|
+
expect(IO.select([r], nil, nil, 2)).not_to be_nil
|
|
38
70
|
end
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
71
|
+
coolio_loop.run_once
|
|
72
|
+
|
|
73
|
+
# Only the first dispatched watcher runs; it detaches the other four,
|
|
74
|
+
# whose pending events are then skipped.
|
|
75
|
+
expect(watchers.count(&:attached?)).to eq(1)
|
|
76
|
+
ensure
|
|
77
|
+
watchers.each { |watcher| watcher.detach if watcher.attached? }
|
|
78
|
+
connections.each { |r, w| r.close if r; w.close }
|
|
47
79
|
end
|
|
48
|
-
|
|
80
|
+
end
|
|
81
|
+
ensure
|
|
82
|
+
listener.close if listener
|
|
49
83
|
end
|
|
50
84
|
|
|
51
85
|
class HttpHandler < Coolio::IO
|
data/spec/dns_spec.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require File.expand_path('../spec_helper', __FILE__)
|
|
2
|
+
require 'tempfile'
|
|
2
3
|
|
|
3
4
|
VALID_DOMAIN = "google.com"
|
|
4
5
|
INVALID_DOMAIN = "gibidigibigididibitidibigitibidigitidididi.com"
|
|
@@ -55,4 +56,200 @@ describe "DNS" do
|
|
|
55
56
|
expect( Coolio::DNSResolver.hosts("localhost", file.path)).to eq @preferred_localhost_address
|
|
56
57
|
end
|
|
57
58
|
end
|
|
59
|
+
|
|
60
|
+
describe "IPv6 nameserver filtering" do
|
|
61
|
+
it "ignores IPv6 nameservers provided in arguments" do
|
|
62
|
+
resolver = Coolio::DNSResolver.new("example.com", "8.8.8.8", "2001:4860:4860::8888", "1.1.1.1")
|
|
63
|
+
|
|
64
|
+
nameservers = resolver.instance_variable_get(:@nameservers)
|
|
65
|
+
expect(nameservers).to eq(["8.8.8.8", "1.1.1.1"])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "falls back to default IPv4 config if only IPv6 addresses are provided" do
|
|
69
|
+
allow(Resolv::DNS::Config).to receive(:default_config_hash).and_return({
|
|
70
|
+
nameserver: ["8.8.4.4", "2001:4860:4860::8844"]
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
resolver = Coolio::DNSResolver.new("example.com", "2001:4860:4860::8888")
|
|
74
|
+
|
|
75
|
+
nameservers = resolver.instance_variable_get(:@nameservers)
|
|
76
|
+
expect(nameservers).to eq(["8.8.4.4"])
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe "nameserver normalization" do
|
|
81
|
+
let(:localhost_address) do
|
|
82
|
+
Addrinfo.getaddrinfo("localhost", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM).first.ip_address
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it "keeps the nameserver list as given" do
|
|
86
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
87
|
+
|
|
88
|
+
expect(resolver.instance_variable_get(:@nameservers)).to eq(["localhost"])
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it "queries the numeric address of a nameserver given as a hostname" do
|
|
92
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
93
|
+
resolver.__send__(:send_request)
|
|
94
|
+
|
|
95
|
+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq([localhost_address])
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it "accepts responses from a nameserver which was given as a hostname" do
|
|
99
|
+
resolver = Coolio::DNSResolver.new("example.com", "localhost")
|
|
100
|
+
resolver.__send__(:send_request)
|
|
101
|
+
response = dns_response_for(resolver)
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, localhost_address, localhost_address])
|
|
105
|
+
).to be true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "looks a nameserver up once and reuses the result on retries" do
|
|
109
|
+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
|
|
110
|
+
resolver = Coolio::DNSResolver.new("example.com", "127.0.0.1")
|
|
111
|
+
|
|
112
|
+
expect(Addrinfo).to receive(:getaddrinfo).once.and_return(resolved)
|
|
113
|
+
|
|
114
|
+
3.times { resolver.__send__(:send_request) }
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it "does not reject an unresolvable nameserver at construction" do
|
|
118
|
+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
|
|
119
|
+
|
|
120
|
+
expect do
|
|
121
|
+
Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
|
|
122
|
+
end.to_not raise_error
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it "surfaces an unresolvable nameserver as a SocketError from the request" do
|
|
126
|
+
allow(Addrinfo).to receive(:getaddrinfo).and_raise(SocketError, "getaddrinfo: Name or service not known")
|
|
127
|
+
resolver = Coolio::DNSResolver.new("example.com", "no-such-nameserver.invalid")
|
|
128
|
+
|
|
129
|
+
expect { resolver.attach(@loop) }.to raise_error(SocketError)
|
|
130
|
+
expect(@loop.watchers).to be_empty
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it "recovers when a nameserver is only transiently unresolvable" do
|
|
134
|
+
resolved = Addrinfo.getaddrinfo("127.0.0.1", nil, ::Socket::AF_INET, ::Socket::SOCK_DGRAM)
|
|
135
|
+
resolver = Coolio::DNSResolver.new("example.com", "ns.example.test")
|
|
136
|
+
|
|
137
|
+
attempts = 0
|
|
138
|
+
allow(Addrinfo).to receive(:getaddrinfo) do
|
|
139
|
+
attempts += 1
|
|
140
|
+
raise SocketError, "getaddrinfo: Name or service not known" if attempts == 1
|
|
141
|
+
|
|
142
|
+
resolved
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
expect { resolver.__send__(:send_request) }.to raise_error(SocketError)
|
|
146
|
+
expect { resolver.__send__(:send_request) }.to_not raise_error
|
|
147
|
+
expect(resolver.instance_variable_get(:@queried_addresses)).to eq(["127.0.0.1"])
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe "response validation" do
|
|
152
|
+
let(:nameserver) { "127.0.0.1" }
|
|
153
|
+
let(:sender) { ["AF_INET", 53, nameserver, nameserver] }
|
|
154
|
+
let(:resolver) do
|
|
155
|
+
Coolio::DNSResolver.new("example.com", nameserver).tap { |r| r.__send__(:send_request) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it "uses an unpredictable transaction ID for each query" do
|
|
159
|
+
ids = 10.times.map do
|
|
160
|
+
request_id_of(Coolio::DNSResolver.new("example.com", nameserver))
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
expect(ids.uniq.size).to be > 1
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it "accepts a response carrying our transaction ID from the queried nameserver" do
|
|
167
|
+
expect(
|
|
168
|
+
resolver.__send__(:solicited_response?, dns_response_for(resolver), sender)
|
|
169
|
+
).to be true
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
it "rejects a response carrying a different transaction ID" do
|
|
173
|
+
forged = dns_response_for(resolver, id: (request_id_of(resolver) + 1) % 65536)
|
|
174
|
+
|
|
175
|
+
expect(resolver.__send__(:solicited_response?, forged, sender)).to be false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it "rejects a response from a source address we did not query" do
|
|
179
|
+
response = dns_response_for(resolver)
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"])
|
|
183
|
+
).to be false
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it "rejects a response arriving before the request was sent" do
|
|
187
|
+
unsent = Coolio::DNSResolver.new("example.com", nameserver)
|
|
188
|
+
|
|
189
|
+
expect(unsent.__send__(:solicited_response?, dns_response_for(unsent), sender)).to be false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it "rejects a response from a source port other than the DNS port" do
|
|
193
|
+
response = dns_response_for(resolver)
|
|
194
|
+
|
|
195
|
+
expect(
|
|
196
|
+
resolver.__send__(:solicited_response?, response, ["AF_INET", 4444, nameserver, nameserver])
|
|
197
|
+
).to be false
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it "rejects a truncated datagram" do
|
|
201
|
+
expect(resolver.__send__(:solicited_response?, "\0\0", sender)).to be false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
it "resolves from a response sent by the queried nameserver" do
|
|
205
|
+
response = dns_response_for(resolver, address: "1.2.3.4")
|
|
206
|
+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([response, sender])
|
|
207
|
+
|
|
208
|
+
expect(resolver).to receive(:on_success).with("1.2.3.4")
|
|
209
|
+
expect(resolver).to receive(:detach)
|
|
210
|
+
|
|
211
|
+
resolver.__send__(:on_readable)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it "fails a response whose A record is shorter than its RDLENGTH instead of resolving a bogus address" do
|
|
215
|
+
truncated = dns_response_for(resolver, address: "1.2.3.4")[0...-4]
|
|
216
|
+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock).and_return([truncated, sender])
|
|
217
|
+
|
|
218
|
+
expect(resolver).to_not receive(:on_success)
|
|
219
|
+
expect(resolver).to receive(:on_failure)
|
|
220
|
+
expect(resolver).to receive(:detach)
|
|
221
|
+
|
|
222
|
+
resolver.__send__(:on_readable)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
it "ignores a spoofed response instead of resolving or failing it" do
|
|
226
|
+
forged = dns_response_for(resolver, address: "6.6.6.6")
|
|
227
|
+
allow(resolver.instance_variable_get(:@socket)).to receive(:recvfrom_nonblock)
|
|
228
|
+
.and_return([forged, ["AF_INET", 53, "10.11.12.13", "10.11.12.13"]])
|
|
229
|
+
|
|
230
|
+
expect(resolver).to_not receive(:on_success)
|
|
231
|
+
expect(resolver).to_not receive(:on_failure)
|
|
232
|
+
expect(resolver).to_not receive(:detach)
|
|
233
|
+
|
|
234
|
+
resolver.__send__(:on_readable)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def request_id_of(resolver)
|
|
239
|
+
resolver.__send__(:request_message)[0..1].unpack('n').first
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# A response to the resolver's own query: header plus the echoed question,
|
|
243
|
+
# and an A record when an address is given.
|
|
244
|
+
def dns_response_for(resolver, id: request_id_of(resolver), address: nil)
|
|
245
|
+
question = resolver.instance_variable_get(:@question)
|
|
246
|
+
answer = if address
|
|
247
|
+
# Compressed name pointer, type A, class IN, TTL, RDLENGTH, RDATA
|
|
248
|
+
[0xc00c, 1, 1, 60, 4].pack('nnnNn') + address.split('.').map(&:to_i).pack('CCCC')
|
|
249
|
+
else
|
|
250
|
+
""
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
[id, 0x81, 0x80, 1, answer.empty? ? 0 : 1, 0, 0].pack('nCCnnnn') + question + answer
|
|
254
|
+
end
|
|
58
255
|
end
|