dns-sd 0.1.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/.gitignore +6 -0
- data/.rubocop.yml +104 -0
- data/.travis.yml +10 -0
- data/.yardopts +1 -0
- data/CODE_OF_CONDUCT.md +49 -0
- data/CONTRIBUTING.md +13 -0
- data/LICENCE +674 -0
- data/README.md +130 -0
- data/dns-sd.gemspec +40 -0
- data/lib/dns-sd.rb +110 -0
- data/lib/dns-sd/resource_cache.rb +47 -0
- data/lib/dns-sd/service.rb +96 -0
- data/lib/dns-sd/service_instance.rb +154 -0
- data/lib/dns-sd/target.rb +7 -0
- metadata +213 -0
data/README.md
ADDED
@@ -0,0 +1,130 @@
|
|
1
|
+
[DNS-SD](https://tools.ietf.org/html/rfc6763) is a method of laying out
|
2
|
+
standard DNS records in such a way that permits service discovery and
|
3
|
+
enumeration, high availability, load balancing, and failover of arbitrary
|
4
|
+
services. Whilst it is often used in concert with [Multicast DNS
|
5
|
+
(mDNS)](https://tools.ietf.org/html/rfc67621), it works just as well with
|
6
|
+
regular DNS services, and that is what this package is focused on. If
|
7
|
+
you're interested in mDNS-based DNS-SD interaction, the [similarly-named
|
8
|
+
dnssd gem](https://rubygems.org/gems/dnssd) might be more to your liking.
|
9
|
+
|
10
|
+
|
11
|
+
# Installation
|
12
|
+
|
13
|
+
It's a gem:
|
14
|
+
|
15
|
+
gem install dns-sd
|
16
|
+
|
17
|
+
There's also the wonders of [the Gemfile](http://bundler.io):
|
18
|
+
|
19
|
+
gem 'dns-sd'
|
20
|
+
|
21
|
+
If you're the sturdy type that likes to run from git:
|
22
|
+
|
23
|
+
rake install
|
24
|
+
|
25
|
+
Or, if you've eschewed the convenience of Rubygems entirely, then you
|
26
|
+
presumably know what to do already.
|
27
|
+
|
28
|
+
|
29
|
+
# Usage
|
30
|
+
|
31
|
+
The basic class, DNSSD, is the usual entrypoint for everything:
|
32
|
+
|
33
|
+
require 'dns-sd'
|
34
|
+
|
35
|
+
dnssd = DNSSD.new("example.com")
|
36
|
+
|
37
|
+
From there, you can connect directly to a service instance, get a service
|
38
|
+
type so you can ask it for all available instances, or even ask for all
|
39
|
+
services in the domain.
|
40
|
+
|
41
|
+
The sub-sections below show some common usage patterns. If your specific
|
42
|
+
use case isn't covered, the per-class documentation may be more
|
43
|
+
enlightening.
|
44
|
+
|
45
|
+
|
46
|
+
## Connecting to a service instance
|
47
|
+
|
48
|
+
If you know what service and instance you're interested in, you can go
|
49
|
+
straight there:
|
50
|
+
|
51
|
+
my_printer = dnssd.service_instance("My Printer", "ipp", :TCP)
|
52
|
+
|
53
|
+
Then get the connection targets and even try connecting to them:
|
54
|
+
|
55
|
+
targets = my_printer.targets
|
56
|
+
|
57
|
+
sock = begin
|
58
|
+
break nil if targets.empty?
|
59
|
+
t = targets.shift
|
60
|
+
TCPSocket.new(t.hostname, t.port)
|
61
|
+
rescue SystemCallError
|
62
|
+
# Automatically go on to the next server
|
63
|
+
retry
|
64
|
+
end
|
65
|
+
|
66
|
+
if sock.nil?
|
67
|
+
$stderr.puts "Failed to connect to My Printer"
|
68
|
+
else
|
69
|
+
# Work with `sock` as required
|
70
|
+
end
|
71
|
+
|
72
|
+
If there's more than one target registered for a given service instance
|
73
|
+
(common in high-availability and load-balanced systems), every time you call
|
74
|
+
DNSSD::ServiceInstance#targets, you'll get the server list in a different
|
75
|
+
order, respecting the priorities and weights of the constituent SRV records.
|
76
|
+
|
77
|
+
The SRV record lookups (like all DNS records) are cached, so if you wish
|
78
|
+
to connect to a service instance repeatedly, you should call #targets on
|
79
|
+
your service instance object each time you want to make a connection, rather
|
80
|
+
than re-using the result of a single call to #target. As long as you're
|
81
|
+
operating against the same DNSSD::ServiceInstance object and the record TTLs
|
82
|
+
haven't expired, successive calls to #target should be very efficient.
|
83
|
+
|
84
|
+
|
85
|
+
## Enumerating all instances of a service
|
86
|
+
|
87
|
+
If you know you want a printer, but aren't sure which one, you can ask for
|
88
|
+
the IPP service, and then enumerate all the service instances:
|
89
|
+
|
90
|
+
dnssd.service("ipp", :TCP).each do |name, instance|
|
91
|
+
puts "I found a printer named #{name}"
|
92
|
+
puts "Its targets are #{instance.targets.map { |t| "#{t.hostname}:#{t.port}" }.join(", ")}"
|
93
|
+
end
|
94
|
+
|
95
|
+
## Enumerating all services
|
96
|
+
|
97
|
+
If you're just curious about what might be on a domain, you can try the
|
98
|
+
"Service Type Enumeration" endpoint:
|
99
|
+
|
100
|
+
dnssd.services.each do |name, svc|
|
101
|
+
puts "I found a service named #{name}"
|
102
|
+
puts "It has instances of #{svc.instances.map { |i| i.name }.join(", ")}"
|
103
|
+
end
|
104
|
+
|
105
|
+
|
106
|
+
# Contributing
|
107
|
+
|
108
|
+
Bug reports should be sent to the [Github issue
|
109
|
+
tracker](https://github.com/discourse/dns-sd/issues). Patches can be sent as a
|
110
|
+
[Github pull request](https://github.com/discourse/dns-sd/pulls).
|
111
|
+
|
112
|
+
|
113
|
+
# Licence
|
114
|
+
|
115
|
+
Unless otherwise stated, everything in this repo is covered by the following
|
116
|
+
copyright notice:
|
117
|
+
|
118
|
+
Copyright (C) 2017 Civilzed Discourse Construction Kit, Inc.
|
119
|
+
|
120
|
+
This program is free software: you can redistribute it and/or modify it
|
121
|
+
under the terms of the GNU General Public License version 3, as
|
122
|
+
published by the Free Software Foundation.
|
123
|
+
|
124
|
+
This program is distributed in the hope that it will be useful,
|
125
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
126
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
127
|
+
GNU General Public License for more details.
|
128
|
+
|
129
|
+
You should have received a copy of the GNU General Public License
|
130
|
+
along with this program. If not, see <http://www.gnu.org/licenses/>.
|
data/dns-sd.gemspec
ADDED
@@ -0,0 +1,40 @@
|
|
1
|
+
begin
|
2
|
+
require 'git-version-bump'
|
3
|
+
rescue LoadError
|
4
|
+
nil
|
5
|
+
end
|
6
|
+
|
7
|
+
Gem::Specification.new do |s|
|
8
|
+
s.name = "dns-sd"
|
9
|
+
|
10
|
+
s.version = GVB.version rescue "0.0.0.1.NOGVB"
|
11
|
+
s.date = GVB.date rescue Time.now.strftime("%Y-%m-%d")
|
12
|
+
|
13
|
+
s.platform = Gem::Platform::RUBY
|
14
|
+
|
15
|
+
s.summary = "Query RFC6763 DNS-SD records"
|
16
|
+
s.description = <<~EOF
|
17
|
+
If you need to retrieve and work with DNS-SD records, this is the library
|
18
|
+
you've been waiting for.
|
19
|
+
EOF
|
20
|
+
|
21
|
+
s.authors = ["Matt Palmer"]
|
22
|
+
s.email = ["matt.palmer@discourse.org"]
|
23
|
+
s.homepage = "https://github.com/discourse/dns-sd"
|
24
|
+
|
25
|
+
s.files = `git ls-files -z`.split("\0").reject { |f| f =~ /^(G|spec|Rakefile)/ }
|
26
|
+
|
27
|
+
s.required_ruby_version = ">= 2.3.0"
|
28
|
+
|
29
|
+
s.add_development_dependency 'bundler'
|
30
|
+
s.add_development_dependency 'github-release'
|
31
|
+
s.add_development_dependency 'git-version-bump'
|
32
|
+
s.add_development_dependency 'guard-rspec'
|
33
|
+
s.add_development_dependency 'guard-rubocop'
|
34
|
+
s.add_development_dependency 'rake', "~> 12.0"
|
35
|
+
s.add_development_dependency 'redcarpet'
|
36
|
+
s.add_development_dependency 'rspec'
|
37
|
+
s.add_development_dependency 'rubocop'
|
38
|
+
s.add_development_dependency 'simplecov'
|
39
|
+
s.add_development_dependency 'yard'
|
40
|
+
end
|
data/lib/dns-sd.rb
ADDED
@@ -0,0 +1,110 @@
|
|
1
|
+
require 'dns-sd/resource_cache'
|
2
|
+
require 'dns-sd/service'
|
3
|
+
|
4
|
+
require 'resolv'
|
5
|
+
|
6
|
+
# Interact with RFC6763 DNS-SD records.
|
7
|
+
#
|
8
|
+
# Given a domain to work in, instances of this class use the standard Ruby DNS
|
9
|
+
# resolution mechanisms to look up services, service instances, and the
|
10
|
+
# individual servers that make up service instances. You can also obtain the
|
11
|
+
# "metadata" associated with service instances.
|
12
|
+
#
|
13
|
+
# If you know the service, or even service instance, you wish to connect to,
|
14
|
+
# you can go straight there, using #service or #service_instance (as
|
15
|
+
# appropriate). Otherwise, if you're just curious about what is available, you
|
16
|
+
# can use #services to get a list of everything advertised under the "Service
|
17
|
+
# Type Enumeration" record for the domain.
|
18
|
+
#
|
19
|
+
class DNSSD
|
20
|
+
include DNSSD::ResourceCache
|
21
|
+
|
22
|
+
# Create a new DNS-SD instance.
|
23
|
+
#
|
24
|
+
# @param domain [String, Resolv::DNS::Name] Specify the base domain under
|
25
|
+
# which all the records we're interested in are registered.
|
26
|
+
#
|
27
|
+
def initialize(domain)
|
28
|
+
@domain = if domain.is_a?(Resolv::DNS::Name)
|
29
|
+
domain
|
30
|
+
else
|
31
|
+
Resolv::DNS::Name.create(domain)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# The current search domain.
|
36
|
+
#
|
37
|
+
# @return [String]
|
38
|
+
#
|
39
|
+
def domain
|
40
|
+
@domain.to_s
|
41
|
+
end
|
42
|
+
|
43
|
+
# Create a new instance of DNSSD::Service for this given name and protocol.
|
44
|
+
#
|
45
|
+
# If you know the name and protocol of the service you wish to query for,
|
46
|
+
# this is the method for you! Note that just calling this method doesn't
|
47
|
+
# make any DNS requests, so you may get a service that has no instances.
|
48
|
+
#
|
49
|
+
# @param name [String] the name of the service, *without* the leading
|
50
|
+
# underscore that goes into the DNS name.
|
51
|
+
#
|
52
|
+
# @param protocol [Symbol] One of `:TCP` or `:UDP`, to indicate that you want
|
53
|
+
# to talk to a TCP or non-TCP service, respectively. Yes, `:UDP` means
|
54
|
+
# "non-TCP"; for more laughs, read RFC6763 s. 7.
|
55
|
+
#
|
56
|
+
# @return [DNSSD::Service]
|
57
|
+
#
|
58
|
+
def service(name, protocol)
|
59
|
+
proto = case protocol
|
60
|
+
when :TCP
|
61
|
+
"_tcp"
|
62
|
+
when :UDP
|
63
|
+
"_udp"
|
64
|
+
else
|
65
|
+
raise ArgumentError,
|
66
|
+
"Invalid protocol (must be one of :TCP or :UDP)"
|
67
|
+
end
|
68
|
+
|
69
|
+
DNSSD::Service.new(Resolv::DNS::Name.new(["_#{name}", proto] + @domain.to_a))
|
70
|
+
end
|
71
|
+
|
72
|
+
# Create a new DNSSD::ServiceInstance.
|
73
|
+
#
|
74
|
+
# If you know everything about what you're trying to talk to except the
|
75
|
+
# server list, you can go straight to the boss level with this method.
|
76
|
+
#
|
77
|
+
# @param name [String] the name of the service instance.
|
78
|
+
#
|
79
|
+
# @param service_name [String] the generic name of the service which the
|
80
|
+
# desired instance implements, *without* the leading underscore that
|
81
|
+
# is in the DNS name.
|
82
|
+
#
|
83
|
+
# @param service_protocol [Symbol] one of `:TCP` or `:UDP`.
|
84
|
+
#
|
85
|
+
# @return [DNSSD::ServiceInstance]
|
86
|
+
#
|
87
|
+
def service_instance(name, service_name, service_protocol)
|
88
|
+
service(service_name, service_protocol).instance(name)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Enumerate all known services in the domain.
|
92
|
+
#
|
93
|
+
# RFC6763 s. 9 provides a special "Service Type Enumeration" DNS record,
|
94
|
+
# `_services._dns-sd._udp.<domain>`, which is a list of PTR records for
|
95
|
+
# the services available in the domain. If your DNS-SD registration system
|
96
|
+
# provisions names in there, you can use this to enumerate the available
|
97
|
+
# services.
|
98
|
+
#
|
99
|
+
# @return [Hash<String, DNSSD::Service>] the list of services, indexed by
|
100
|
+
# the service name.
|
101
|
+
#
|
102
|
+
def services
|
103
|
+
{}.tap do |services|
|
104
|
+
cached_resources(Resolv::DNS::Name.new(["_services", "_dns-sd", "_udp"] + @domain.to_a), Resolv::DNS::Resource::IN::PTR).each do |ptr|
|
105
|
+
svc = DNSSD::Service.new(ptr.name)
|
106
|
+
services[svc.name] = svc
|
107
|
+
end
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'resolv'
|
2
|
+
|
3
|
+
class DNSSD
|
4
|
+
# A mix-in to provide a TTL-respecting caching layer.
|
5
|
+
module ResourceCache
|
6
|
+
private
|
7
|
+
|
8
|
+
# Return a list of resource records.
|
9
|
+
#
|
10
|
+
# We'll ask the DNS, via `Resolv::DNS`, for DNS records matching the
|
11
|
+
# given FQDN and type, unless we've recently seen a response for the
|
12
|
+
# same query, in which case we'll just give that back instead.
|
13
|
+
#
|
14
|
+
# Note that we don't currently implement negative caching, but it's
|
15
|
+
# not a massive optimisation for our use-case, anyway.
|
16
|
+
#
|
17
|
+
# @param fqdn [Resolv::DNS::Name] the name to look up resources at.
|
18
|
+
#
|
19
|
+
# @param type [Resolv::DNS::Resource] the type of resource to request.
|
20
|
+
#
|
21
|
+
# @return [Array<Resolv::DNS::Resource>]
|
22
|
+
#
|
23
|
+
def cached_resources(fqdn, type)
|
24
|
+
@rrcache ||= {}
|
25
|
+
|
26
|
+
k = [fqdn, type]
|
27
|
+
|
28
|
+
if @rrcache[k] && @rrcache[k][:expiry] > Time.now
|
29
|
+
@rrcache[k][:records].dup
|
30
|
+
else
|
31
|
+
Resolv::DNS.new.getresources(fqdn, type).tap do |rrs|
|
32
|
+
@rrcache[k] = { records: rrs.dup, expiry: Time.now + rrs.map { |rr| rr.ttl }.min }
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
def entry_expiry_time(fqdn, type)
|
38
|
+
k = [fqdn, type]
|
39
|
+
|
40
|
+
if @rrcache && @rrcache[k]
|
41
|
+
@rrcache[k][:expiry].dup
|
42
|
+
else
|
43
|
+
nil
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,96 @@
|
|
1
|
+
require 'dns-sd/resource_cache'
|
2
|
+
require 'dns-sd/service_instance'
|
3
|
+
|
4
|
+
require 'resolv'
|
5
|
+
|
6
|
+
class DNSSD
|
7
|
+
# The generic service.
|
8
|
+
#
|
9
|
+
# Allows you to get the list of instances of a given service.
|
10
|
+
#
|
11
|
+
class Service
|
12
|
+
include DNSSD::ResourceCache
|
13
|
+
|
14
|
+
# The name of the service, without the leading underscore.
|
15
|
+
#
|
16
|
+
# @return [String]
|
17
|
+
#
|
18
|
+
attr_reader :name
|
19
|
+
|
20
|
+
# The protocol of the service.
|
21
|
+
#
|
22
|
+
# @return [Symbol] `:TCP` or `:UDP`.
|
23
|
+
#
|
24
|
+
attr_reader :protocol
|
25
|
+
|
26
|
+
# Create the service.
|
27
|
+
#
|
28
|
+
# @param fqdn [Resolv::DNS::Name]
|
29
|
+
#
|
30
|
+
def initialize(fqdn)
|
31
|
+
unless fqdn.is_a?(Resolv::DNS::Name)
|
32
|
+
raise ArgumentError,
|
33
|
+
"FQDN must be a Resolv::DNS::Name (got an instance of #{fqdn.class})"
|
34
|
+
end
|
35
|
+
|
36
|
+
@fqdn = fqdn
|
37
|
+
|
38
|
+
if fqdn[0].to_s =~ /\A_([A-Za-z0-9][A-Za-z0-9-]+)\z/
|
39
|
+
@name = $1
|
40
|
+
else
|
41
|
+
raise ArgumentError,
|
42
|
+
"Invalid service name #{fqdn[0].inspect}; see RFC6763 s. 7"
|
43
|
+
end
|
44
|
+
|
45
|
+
@protocol = case fqdn[1].to_s.downcase
|
46
|
+
when "_tcp"
|
47
|
+
:TCP
|
48
|
+
when "_udp"
|
49
|
+
:UDP
|
50
|
+
else
|
51
|
+
raise ArgumentError,
|
52
|
+
"Invalid service protocol #{@protocol}, must be '_tcp' or '_udp'"
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
# Create an object for a specific instance of this service.
|
57
|
+
#
|
58
|
+
# @param name [String] the name of the service instance.
|
59
|
+
#
|
60
|
+
# @return [DNSSD::ServiceInstance]
|
61
|
+
#
|
62
|
+
def instance(name)
|
63
|
+
DNSSD::ServiceInstance.new(Resolv::DNS::Name.new([name] + @fqdn.to_a))
|
64
|
+
end
|
65
|
+
|
66
|
+
# Enumerate all existing instances of this service.
|
67
|
+
#
|
68
|
+
# @return [Hash<String, DNSSD::ServiceInstance>] objects for all the
|
69
|
+
# service instances, indexed by their names.
|
70
|
+
#
|
71
|
+
def instances
|
72
|
+
{}.tap do |instances|
|
73
|
+
cached_resources(@fqdn, Resolv::DNS::Resource::IN::PTR).each do |rr|
|
74
|
+
i = DNSSD::ServiceInstance.new(rr.name)
|
75
|
+
instances[i.name] = i
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Let us know how long until the cache expires.
|
81
|
+
#
|
82
|
+
# This can be handy if you've got something that wants to poll the record
|
83
|
+
# repeatedly; this way we can be a bit more intelligent about when to
|
84
|
+
# retry, since if we re-poll too often, we'll just get the cached data back
|
85
|
+
# again anyway.
|
86
|
+
#
|
87
|
+
# @return [Time, nil] if the entry is currently cached, a Time instance
|
88
|
+
# will be returned indicating when the entry will expire (potentially
|
89
|
+
# this could be in the past, if the cache has expired). If we have no
|
90
|
+
# knowledge of the entry, `nil` will be returned.
|
91
|
+
#
|
92
|
+
def cached_until
|
93
|
+
entry_expiry_time(@fqdn, Resolv::DNS::Resource::IN::PTR)
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
@@ -0,0 +1,154 @@
|
|
1
|
+
require 'dns-sd/resource_cache'
|
2
|
+
require 'dns-sd/service'
|
3
|
+
require 'dns-sd/target'
|
4
|
+
|
5
|
+
require 'resolv'
|
6
|
+
|
7
|
+
class DNSSD
|
8
|
+
# A single instance of a service.
|
9
|
+
#
|
10
|
+
# This is where the rubber hits the road: servers to talk to, and instance
|
11
|
+
# metadata, is all under here.
|
12
|
+
#
|
13
|
+
class ServiceInstance
|
14
|
+
include DNSSD::ResourceCache
|
15
|
+
|
16
|
+
# The name of this service instance.
|
17
|
+
#
|
18
|
+
# This is just the left-most component of the service instance FQDN, and
|
19
|
+
# can, as such, contain practically anything at all.
|
20
|
+
#
|
21
|
+
# @return [String]
|
22
|
+
#
|
23
|
+
attr_reader :name
|
24
|
+
|
25
|
+
# The FQDN of this service instance.
|
26
|
+
#
|
27
|
+
# @return [Resolv::DNS::Name]
|
28
|
+
#
|
29
|
+
attr_reader :fqdn
|
30
|
+
|
31
|
+
# The generic service which this instance implements.
|
32
|
+
#
|
33
|
+
# If you happen to forget what protocol to use, this might come in
|
34
|
+
# handy.
|
35
|
+
#
|
36
|
+
# @return [DNSSD::Service]
|
37
|
+
#
|
38
|
+
attr_reader :service
|
39
|
+
|
40
|
+
# Create the service instance.
|
41
|
+
#
|
42
|
+
# @param fqdn [Resolv::DNS::Name]
|
43
|
+
#
|
44
|
+
def initialize(fqdn)
|
45
|
+
unless fqdn.is_a?(Resolv::DNS::Name)
|
46
|
+
raise ArgumentError,
|
47
|
+
"FQDN must be a Resolv::DNS::Name (got an instance of #{fqdn.class})"
|
48
|
+
end
|
49
|
+
|
50
|
+
@fqdn = fqdn
|
51
|
+
|
52
|
+
@name = fqdn[0].to_s
|
53
|
+
@service = DNSSD::Service.new(Resolv::DNS::Name.new(fqdn[1..-1]))
|
54
|
+
end
|
55
|
+
|
56
|
+
# Return the metadata for the service instance.
|
57
|
+
#
|
58
|
+
# RFC6763 s. 6 describes a means by which specially formatted TXT records
|
59
|
+
# can be used to provide metadata for a service instance. If your
|
60
|
+
# services populate such data, you can access it here.
|
61
|
+
#
|
62
|
+
# @return [Hash<String, String or nil>] the key-value metadata, presented
|
63
|
+
# as a nice hash for your looking-up convenience. If your metadata
|
64
|
+
# contains "Attribute present, with no value" tags, then the value of
|
65
|
+
# the associated key will be `nil`, whereas "Attribute present, with
|
66
|
+
# empty value" will have a value of the empty string (`""`).
|
67
|
+
#
|
68
|
+
def data
|
69
|
+
{}.tap do |data|
|
70
|
+
cached_resources(@fqdn, Resolv::DNS::Resource::IN::TXT).each do |rr|
|
71
|
+
rr.strings.each do |s|
|
72
|
+
s =~ /\A([^=]+)(=(.*))?$/
|
73
|
+
data[$1.to_sym] = $3
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# The things to connect to for this service instance.
|
80
|
+
#
|
81
|
+
# This is what you're here for, I'll bet. Everything comes down to this.
|
82
|
+
# Each DNSSD::Target object in this list contains an FQDN (`#hostname`) and
|
83
|
+
# port (`#port`) to connect to, which you can walk in order in order to
|
84
|
+
# get to something that will talk to you.
|
85
|
+
#
|
86
|
+
# Every time you call this method, even if the records are cached, you may
|
87
|
+
# get the targets in a different order. This is because we automatically
|
88
|
+
# sort the list of targets according to the rules for SRV record priority
|
89
|
+
# and weight. Thus, it is recommended that every time you want to make a
|
90
|
+
# connection to the service instance, you call `#targets` again, both
|
91
|
+
# because the DNS records may have expired (and thus will be re-queried),
|
92
|
+
# but also because it'll ensure that the weight-based randomisation of the
|
93
|
+
# server list is respected.
|
94
|
+
#
|
95
|
+
# @return [Array<DNSSD::Target>]
|
96
|
+
def targets
|
97
|
+
[].tap do |list|
|
98
|
+
left = cached_resources(@fqdn, Resolv::DNS::Resource::IN::SRV)
|
99
|
+
|
100
|
+
# Happily, this algorithm, whilst a bit involved, maps quite directly
|
101
|
+
# to the description from RFC2782, page 4, of which parts are quoted as
|
102
|
+
# appropriate below. A practical example of how this process runs is
|
103
|
+
# described in the test suite, also, which might help explain what's
|
104
|
+
# happening.
|
105
|
+
#
|
106
|
+
# > This process is repeated for each Priority.
|
107
|
+
until left.empty?
|
108
|
+
# > A client MUST attempt to contact the target host with the
|
109
|
+
# > lowest-numbered priority it can reach; target hosts with the
|
110
|
+
# > same priority SHOULD be tried in an order defined by the weight
|
111
|
+
# > field.
|
112
|
+
prio = left.map { |rr| rr.priority }.uniq.min
|
113
|
+
|
114
|
+
# > The following algorithm SHOULD be used to order the SRV RRs of the
|
115
|
+
# > same priority:
|
116
|
+
candidates = left.select { |rr| rr.priority == prio }
|
117
|
+
left -= candidates
|
118
|
+
|
119
|
+
# > arrange all SRV RRs (that have not been ordered yet) in any
|
120
|
+
# > order, except that all those with weight 0 are placed at the
|
121
|
+
# > beginning of the list.
|
122
|
+
#
|
123
|
+
# Because it makes it easier to test, I like to sort by weight and
|
124
|
+
# name (<lawyer>it counts as "any order"</lawyer>). This does mean
|
125
|
+
# that all the zero-weight entries come back in name order, but
|
126
|
+
# if you don't want that behaviour, it's easy enough to give
|
127
|
+
# everything weight=1 and they'll be properly randomised.
|
128
|
+
candidates.sort_by! { |rr| [rr.weight, rr.target.to_s] }
|
129
|
+
|
130
|
+
# > Continue the ordering process until there are no unordered SRV
|
131
|
+
# > RRs.
|
132
|
+
until candidates.empty?
|
133
|
+
# > Compute the sum of the weights of those RRs, and with each RR
|
134
|
+
# > associate the running sum in the selected order. Then choose a
|
135
|
+
# > uniform random number between 0 and the sum computed
|
136
|
+
# > (inclusive)
|
137
|
+
selector = rand(candidates.inject(1) { |n, rr| n + rr.weight })
|
138
|
+
|
139
|
+
# > select the RR whose running sum value is the first in the
|
140
|
+
# > selected order which is greater than or equal to the random
|
141
|
+
# > number selected
|
142
|
+
chosen = candidates.inject(0) do |n, rr|
|
143
|
+
break rr if n + rr.weight >= selector
|
144
|
+
n + rr.weight
|
145
|
+
end
|
146
|
+
# > Remove this SRV RR from the set of the unordered SRV RRs
|
147
|
+
candidates.delete(chosen)
|
148
|
+
list << DNSSD::Target.new(chosen.target.to_s, chosen.port)
|
149
|
+
end
|
150
|
+
end
|
151
|
+
end
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|