zeroconf 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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