dns-sd 0.1.0

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