zeroconf 1.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 +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
|