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