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.
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
@@ -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