zeroconf 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/CODE_OF_CONDUCT.md +77 -0
- data/Gemfile +3 -0
- data/LICENSE +201 -0
- data/README.md +129 -0
- data/Rakefile +9 -0
- data/lib/zeroconf/service.rb +399 -0
- data/lib/zeroconf/utils.rb +65 -0
- data/lib/zeroconf/version.rb +3 -0
- data/lib/zeroconf.rb +288 -0
- data/test/client_test.rb +139 -0
- data/test/helper.rb +42 -0
- data/test/service_test.rb +334 -0
- data/zeroconf.gemspec +18 -0
- metadata +100 -0
data/lib/zeroconf.rb
ADDED
@@ -0,0 +1,288 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "zeroconf/utils"
|
4
|
+
require "zeroconf/service"
|
5
|
+
|
6
|
+
module ZeroConf
|
7
|
+
MDNS_CACHE_FLUSH = 0x8000
|
8
|
+
|
9
|
+
extend Utils
|
10
|
+
include Utils
|
11
|
+
|
12
|
+
# :stopdoc:
|
13
|
+
class PTR < Resolv::DNS::Resource::IN::PTR
|
14
|
+
MDNS_UNICAST_RESPONSE = 0x8000
|
15
|
+
|
16
|
+
ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
|
17
|
+
ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
|
18
|
+
end
|
19
|
+
|
20
|
+
class ANY < Resolv::DNS::Resource::IN::ANY
|
21
|
+
MDNS_UNICAST_RESPONSE = 0x8000
|
22
|
+
|
23
|
+
ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
|
24
|
+
::Resolv::DNS::Resource::ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
|
25
|
+
end
|
26
|
+
|
27
|
+
class A < Resolv::DNS::Resource::IN::A
|
28
|
+
MDNS_UNICAST_RESPONSE = 0x8000
|
29
|
+
|
30
|
+
ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
|
31
|
+
ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
|
32
|
+
end
|
33
|
+
|
34
|
+
class SRV < Resolv::DNS::Resource::IN::SRV
|
35
|
+
MDNS_UNICAST_RESPONSE = 0x8000
|
36
|
+
|
37
|
+
ClassValue = Resolv::DNS::Resource::IN::ClassValue | MDNS_UNICAST_RESPONSE
|
38
|
+
ClassHash[[TypeValue, ClassValue]] = self # :nodoc:
|
39
|
+
end
|
40
|
+
|
41
|
+
module MDNS
|
42
|
+
module Announce
|
43
|
+
module IN
|
44
|
+
[:SRV, :A, :AAAA, :TXT].each do |name|
|
45
|
+
const_set(name, Class.new(Resolv::DNS::Resource::IN.const_get(name)) {
|
46
|
+
const_set(:ClassValue, superclass::ClassValue | MDNS_CACHE_FLUSH)
|
47
|
+
self::ClassHash[[self::TypeValue, self::ClassValue]] = self
|
48
|
+
})
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
DISCOVER_QUERY = Resolv::DNS::Message.new 0
|
55
|
+
DISCOVER_QUERY.add_question DISCOVERY_NAME, PTR
|
56
|
+
|
57
|
+
# :startdoc:
|
58
|
+
|
59
|
+
##
|
60
|
+
# ZeroConf.browse
|
61
|
+
#
|
62
|
+
# Call this method to find server information for a particular service.
|
63
|
+
# For example, to find server information for servers advertising
|
64
|
+
# `_elg._tcp.local`, do this:
|
65
|
+
#
|
66
|
+
# ZeroConf.browse("_elg._tcp.local") { |r| p r }
|
67
|
+
#
|
68
|
+
# Yields info it finds to the provided block as it is received.
|
69
|
+
# Pass a list of interfaces you want to use, or just use the default.
|
70
|
+
# Also takes a timeout parameter to specify the length of the timeout.
|
71
|
+
#
|
72
|
+
# @param [Array<Socket::Ifaddr>] interfaces list of interfaces to query
|
73
|
+
# @param [Numeric] timeout number of seconds before returning
|
74
|
+
def self.browse name, interfaces: self.interfaces, timeout: 3, &blk
|
75
|
+
port = 0
|
76
|
+
sockets = interfaces.map { |iface|
|
77
|
+
if iface.addr.ipv4?
|
78
|
+
open_ipv4 iface.addr, port
|
79
|
+
else
|
80
|
+
open_ipv6 iface.addr, port
|
81
|
+
end
|
82
|
+
}.compact
|
83
|
+
|
84
|
+
q = PTR.new(name)
|
85
|
+
|
86
|
+
sockets.each { |socket|
|
87
|
+
query = Resolv::DNS::Message.new 0
|
88
|
+
|
89
|
+
query.add_question q.name, q.class
|
90
|
+
|
91
|
+
multicast_send socket, query.encode
|
92
|
+
}
|
93
|
+
|
94
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
95
|
+
now = start
|
96
|
+
msgs = block_given? ? nil : []
|
97
|
+
|
98
|
+
loop do
|
99
|
+
readers, = IO.select(sockets, [], [], timeout - (now - start))
|
100
|
+
return msgs unless readers
|
101
|
+
readers.each do |reader|
|
102
|
+
buf, = reader.recvfrom 2048
|
103
|
+
msg = Resolv::DNS::Message.decode(buf)
|
104
|
+
if block_given?
|
105
|
+
return msg if :done == yield(msg)
|
106
|
+
else
|
107
|
+
msgs << msg
|
108
|
+
end
|
109
|
+
end
|
110
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
111
|
+
end
|
112
|
+
ensure
|
113
|
+
sockets.map(&:close) if sockets
|
114
|
+
end
|
115
|
+
|
116
|
+
###
|
117
|
+
# Get a list tuples with host name and Addrinfo objects for a particular
|
118
|
+
# service name.
|
119
|
+
#
|
120
|
+
# For example:
|
121
|
+
#
|
122
|
+
# pp ZeroConf.find_addrinfos("_elg._tcp.local")
|
123
|
+
# # [["elgato-key-light-2d93.local", #<Addrinfo: 10.0.1.249:9123 (elgato-key-light-2d93.local)>],
|
124
|
+
# # ["elgato-key-light-2d93.local", #<Addrinfo: [fe80::3e6a:9dff:fe19:b313]:9123 (elgato-key-light-2d93.local)>],
|
125
|
+
# # ["elgato-key-light-48c6.local", #<Addrinfo: 10.0.1.151:9123 (elgato-key-light-48c6.local)>],
|
126
|
+
# # ["elgato-key-light-48c6.local", #<Addrinfo: [fe80::3e6a:9dff:fe19:3a99]:9123 (elgato-key-light-48c6.local)>]]
|
127
|
+
#
|
128
|
+
def self.find_addrinfos name, interfaces: self.interfaces, timeout: 3
|
129
|
+
browse(name, interfaces:, timeout:).flat_map { |r|
|
130
|
+
host = nil
|
131
|
+
port = nil
|
132
|
+
ipv4 = []
|
133
|
+
ipv6 = []
|
134
|
+
pp r
|
135
|
+
r.additional.each { |name, ttl, data|
|
136
|
+
case data
|
137
|
+
when Resolv::DNS::Resource::IN::SRV
|
138
|
+
host = data.target.to_s
|
139
|
+
port = data.port
|
140
|
+
when Resolv::DNS::Resource::IN::A
|
141
|
+
ipv4 << data.address
|
142
|
+
when Resolv::DNS::Resource::IN::AAAA
|
143
|
+
ipv6 << data.address
|
144
|
+
end
|
145
|
+
}
|
146
|
+
ipv4.map { |x| [host, ["AF_INET", port, host, x.to_s]] } +
|
147
|
+
ipv6.map { |x| [host, ["AF_INET6", port, host, x.to_s]] }
|
148
|
+
}.uniq.map { |host, x| [host, Addrinfo.new(x)] }
|
149
|
+
end
|
150
|
+
|
151
|
+
def self.resolve name, interfaces: self.interfaces, timeout: 3, &blk
|
152
|
+
port = 0
|
153
|
+
sockets = interfaces.map { |iface|
|
154
|
+
if iface.addr.ipv4?
|
155
|
+
open_ipv4 iface.addr, port
|
156
|
+
else
|
157
|
+
open_ipv6 iface.addr, port
|
158
|
+
end
|
159
|
+
}.compact
|
160
|
+
|
161
|
+
query = Resolv::DNS::Message.new 0
|
162
|
+
query.add_question Resolv::DNS::Name.create(name), A
|
163
|
+
|
164
|
+
sockets.each do |sock|
|
165
|
+
multicast_send sock, query.encode
|
166
|
+
end
|
167
|
+
|
168
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
169
|
+
now = start
|
170
|
+
|
171
|
+
loop do
|
172
|
+
readers, = IO.select(sockets, [], [], timeout - (now - start))
|
173
|
+
return unless readers
|
174
|
+
readers.each do |reader|
|
175
|
+
buf, = reader.recvfrom 2048
|
176
|
+
msg = Resolv::DNS::Message.decode(buf)
|
177
|
+
return msg if :done == yield(msg)
|
178
|
+
end
|
179
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
180
|
+
end
|
181
|
+
ensure
|
182
|
+
sockets.map(&:close) if sockets
|
183
|
+
end
|
184
|
+
|
185
|
+
def self.service service, service_port, hostname = Socket.gethostname, service_interfaces: self.service_interfaces, text: [""]
|
186
|
+
s = Service.new(service, service_port, hostname, service_interfaces:, text:)
|
187
|
+
s.start
|
188
|
+
end
|
189
|
+
|
190
|
+
##
|
191
|
+
# ZeroConf.find_services
|
192
|
+
#
|
193
|
+
# Get a list of services being advertised on the network!
|
194
|
+
#
|
195
|
+
# This method will yield the services as it finds them, or it will
|
196
|
+
# return a list of unique service names if no block is given.
|
197
|
+
#
|
198
|
+
# @param [Array<Socket::Ifaddr>] interfaces list of interfaces to query
|
199
|
+
# @param [Numeric] timeout number of seconds before returning
|
200
|
+
def self.find_services interfaces: self.interfaces, timeout: 3
|
201
|
+
if block_given?
|
202
|
+
discover(interfaces:, timeout:) do |res|
|
203
|
+
res.answer.map(&:last).map(&:name).map(&:to_s).each { yield _1 }
|
204
|
+
end
|
205
|
+
else
|
206
|
+
discover(interfaces:, timeout:)
|
207
|
+
.flat_map(&:answer)
|
208
|
+
.map(&:last)
|
209
|
+
.map(&:name)
|
210
|
+
.map(&:to_s)
|
211
|
+
.uniq
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
##
|
216
|
+
# ZeroConf.discover
|
217
|
+
#
|
218
|
+
# Call this method to discover services on your network!
|
219
|
+
# Yields services it finds to the provided block as it finds them.
|
220
|
+
# Pass a list of interfaces you want to use, or just use the default.
|
221
|
+
# Also takes a timeout parameter to specify the length of the timeout.
|
222
|
+
#
|
223
|
+
# @param [Array<Socket::Ifaddr>] interfaces list of interfaces to query
|
224
|
+
# @param [Numeric] timeout number of seconds before returning
|
225
|
+
def self.discover interfaces: self.interfaces, timeout: 3
|
226
|
+
port = 0
|
227
|
+
|
228
|
+
sockets = interfaces.map { |iface|
|
229
|
+
if iface.addr.ipv4?
|
230
|
+
open_ipv4 iface.addr, port
|
231
|
+
else
|
232
|
+
open_ipv6 iface.addr, port
|
233
|
+
end
|
234
|
+
}.compact
|
235
|
+
|
236
|
+
discover_query = DISCOVER_QUERY
|
237
|
+
sockets.each { |socket| multicast_send(socket, discover_query.encode) }
|
238
|
+
|
239
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
240
|
+
now = start
|
241
|
+
msgs = nil
|
242
|
+
|
243
|
+
loop do
|
244
|
+
readers, = IO.select(sockets, [], [], timeout && (timeout - (now - start)))
|
245
|
+
return msgs unless readers
|
246
|
+
readers.each do |reader|
|
247
|
+
buf, _ = reader.recvfrom 2048
|
248
|
+
msg = Resolv::DNS::Message.decode(buf)
|
249
|
+
# only yield replies to this question
|
250
|
+
if msg.question.length > 0 && msg.question.first.last == PTR
|
251
|
+
if block_given?
|
252
|
+
if :done == yield(msg)
|
253
|
+
return msg
|
254
|
+
end
|
255
|
+
else
|
256
|
+
msgs ||= []
|
257
|
+
msgs << msg
|
258
|
+
end
|
259
|
+
end
|
260
|
+
end
|
261
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
262
|
+
end
|
263
|
+
ensure
|
264
|
+
sockets.each(&:close) if sockets
|
265
|
+
end
|
266
|
+
|
267
|
+
def self.interfaces
|
268
|
+
addrs = Socket.getifaddrs
|
269
|
+
addrs.select { |ifa|
|
270
|
+
addr = ifa.addr
|
271
|
+
addr &&
|
272
|
+
(ifa.flags & Socket::IFF_UP > 0) && # must be up
|
273
|
+
(ifa.flags & Socket::IFF_MULTICAST > 0) && # must have multicast
|
274
|
+
(ifa.flags & Socket::IFF_POINTOPOINT == 0) && # must not be pointopoint
|
275
|
+
(ifa.flags & Socket::IFF_LOOPBACK == 0) && # must not be loopback
|
276
|
+
(addr.ipv4? || # must be ipv4 *or*
|
277
|
+
(addr.ipv6? && !addr.ipv6_linklocal?)) # must be ipv6 and not link local
|
278
|
+
}
|
279
|
+
end
|
280
|
+
|
281
|
+
def self.service_interfaces
|
282
|
+
ipv4, ipv6 = interfaces.partition { |ifa| ifa.addr.ipv4? }
|
283
|
+
[ipv4.first, ipv6&.first].compact
|
284
|
+
end
|
285
|
+
|
286
|
+
private_class_method def self.multiquery_send sock, queries, query_id
|
287
|
+
end
|
288
|
+
end
|
data/test/client_test.rb
ADDED
@@ -0,0 +1,139 @@
|
|
1
|
+
require "helper"
|
2
|
+
|
3
|
+
module ZeroConf
|
4
|
+
class ClientTest < Test
|
5
|
+
attr_reader :iface
|
6
|
+
|
7
|
+
def setup
|
8
|
+
super
|
9
|
+
@iface = ZeroConf.interfaces.find_all { |x| x.addr.ipv4? }.first
|
10
|
+
end
|
11
|
+
|
12
|
+
def test_resolve
|
13
|
+
s = make_server iface, "coolhostname"
|
14
|
+
runner = Thread.new { s.start }
|
15
|
+
found = nil
|
16
|
+
|
17
|
+
name = "coolhostname.local"
|
18
|
+
|
19
|
+
took = time_it do
|
20
|
+
ZeroConf.resolve name do |msg|
|
21
|
+
if msg.answer.find { |d, _, _| d.to_s == name }
|
22
|
+
found = msg
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
s.stop
|
28
|
+
runner.join
|
29
|
+
|
30
|
+
assert found
|
31
|
+
assert_in_delta 3, took, 0.1
|
32
|
+
end
|
33
|
+
|
34
|
+
def test_resolve_returns_early
|
35
|
+
s = make_server iface, "coolhostname"
|
36
|
+
runner = Thread.new { s.start }
|
37
|
+
found = nil
|
38
|
+
|
39
|
+
name = "coolhostname.local"
|
40
|
+
|
41
|
+
took = time_it do
|
42
|
+
ZeroConf.resolve name do |msg|
|
43
|
+
if msg.answer.find { |d, _, _| d.to_s == name }
|
44
|
+
found = msg
|
45
|
+
:done
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
s.stop
|
51
|
+
runner.join
|
52
|
+
|
53
|
+
assert found
|
54
|
+
assert_operator took, :<, 2
|
55
|
+
end
|
56
|
+
|
57
|
+
def test_discover_works
|
58
|
+
s = make_server iface
|
59
|
+
runner = Thread.new { s.start }
|
60
|
+
found = nil
|
61
|
+
|
62
|
+
took = time_it do
|
63
|
+
ZeroConf.discover do |msg|
|
64
|
+
if msg.answer.find { |_, _, d| d.name.to_s == SERVICE }
|
65
|
+
found = msg
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
s.stop
|
71
|
+
runner.join
|
72
|
+
|
73
|
+
assert found
|
74
|
+
assert_in_delta 3, took, 0.1
|
75
|
+
end
|
76
|
+
|
77
|
+
def test_discover_return_early
|
78
|
+
s = make_server iface
|
79
|
+
runner = Thread.new { s.start }
|
80
|
+
found = nil
|
81
|
+
|
82
|
+
took = time_it do
|
83
|
+
found = ZeroConf.discover do |msg|
|
84
|
+
if msg.answer.find { |_, _, d| d.name.to_s == SERVICE }
|
85
|
+
:done
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
s.stop
|
91
|
+
runner.join
|
92
|
+
|
93
|
+
assert found
|
94
|
+
assert_operator took, :<, 2
|
95
|
+
end
|
96
|
+
|
97
|
+
def test_browse
|
98
|
+
s = make_server iface
|
99
|
+
runner = Thread.new { s.start }
|
100
|
+
found = nil
|
101
|
+
|
102
|
+
took = time_it do
|
103
|
+
ZeroConf.browse SERVICE do |msg|
|
104
|
+
if msg.question.find { |name, type| name.to_s == SERVICE && type == ZeroConf::PTR }
|
105
|
+
found = msg
|
106
|
+
end
|
107
|
+
end
|
108
|
+
end
|
109
|
+
|
110
|
+
s.stop
|
111
|
+
runner.join
|
112
|
+
|
113
|
+
assert found
|
114
|
+
assert_equal Resolv::DNS::Name.create(SERVICE_NAME + "."), found.answer.first.last.name
|
115
|
+
assert_in_delta 3, took, 0.1
|
116
|
+
end
|
117
|
+
|
118
|
+
def test_browse_returns_early
|
119
|
+
s = make_server iface
|
120
|
+
runner = Thread.new { s.start }
|
121
|
+
found = nil
|
122
|
+
|
123
|
+
took = time_it do
|
124
|
+
found = ZeroConf.browse SERVICE do |msg|
|
125
|
+
if msg.question.find { |name, type| name.to_s == SERVICE && type == ZeroConf::PTR }
|
126
|
+
:done
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
s.stop
|
132
|
+
runner.join
|
133
|
+
|
134
|
+
assert found
|
135
|
+
assert_equal Resolv::DNS::Name.create(SERVICE_NAME + "."), found.answer.first.last.name
|
136
|
+
assert_operator took, :<, 2
|
137
|
+
end
|
138
|
+
end
|
139
|
+
end
|
data/test/helper.rb
ADDED
@@ -0,0 +1,42 @@
|
|
1
|
+
ENV["MT_NO_PLUGINS"] = "1"
|
2
|
+
|
3
|
+
require "minitest/autorun"
|
4
|
+
require "zeroconf"
|
5
|
+
|
6
|
+
module ZeroConf
|
7
|
+
class Test < Minitest::Test
|
8
|
+
SERVICE = "_test-mdns._tcp.local"
|
9
|
+
HOST_NAME = "tc-lan-adapter"
|
10
|
+
SERVICE_NAME = "#{HOST_NAME}.#{SERVICE}"
|
11
|
+
|
12
|
+
def time_it
|
13
|
+
start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
14
|
+
yield
|
15
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
|
16
|
+
end
|
17
|
+
|
18
|
+
def make_server iface, host = HOST_NAME
|
19
|
+
Service.new SERVICE + ".",
|
20
|
+
42424,
|
21
|
+
host,
|
22
|
+
service_interfaces: [iface], text: ["test=1", "other=value"]
|
23
|
+
end
|
24
|
+
|
25
|
+
def make_listener rd, q
|
26
|
+
Thread.new do
|
27
|
+
sock = open_ipv4 Addrinfo.new(Socket.sockaddr_in(Resolv::MDNS::Port, Socket::INADDR_ANY)), Resolv::MDNS::Port
|
28
|
+
loop do
|
29
|
+
readers, = IO.select([sock, rd])
|
30
|
+
read = readers.first
|
31
|
+
if read == rd
|
32
|
+
rd.close
|
33
|
+
sock.close
|
34
|
+
break
|
35
|
+
end
|
36
|
+
buf, = read.recvfrom 2048
|
37
|
+
q << Resolv::DNS::Message.decode(buf)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|