Skip to content

Commit 8c323ba

Browse files
committed
Rework Resolver to follow middleware pattern for name lookup.
1 parent 725ca62 commit 8c323ba

File tree

18 files changed

+439
-375
lines changed

18 files changed

+439
-375
lines changed

async-dns.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Gem::Specification.new do |spec|
66
spec.name = "async-dns"
77
spec.version = Async::DNS::VERSION
88

9-
spec.summary = "An easy to use DNS client resolver and server for Ruby."
9+
spec.summary = "An easy to use DNS client and server for Ruby."
1010
spec.authors = ["Samuel Williams", "Tony Arcieri", "Olle Jonsson", "Greg Thornton", "Hal Brodigan", "Hendrik Beskow", "Mike Perham", "Sean Dilda", "Stefan Wrobel"]
1111
spec.license = "MIT"
1212

fixtures/async/dns/server_context.rb

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
require 'sus/fixtures/async/reactor_context'
77

88
require 'async/dns/server'
9-
require 'async/dns/resolver'
9+
require 'async/dns/client'
1010

1111
require 'io/endpoint'
1212

@@ -26,19 +26,19 @@ def make_server(endpoint)
2626
Async::DNS::Server.new(endpoint)
2727
end
2828

29-
def make_resolver(endpoint)
30-
Async::DNS::Resolver.new(endpoint)
29+
def make_client(endpoint)
30+
Async::DNS::Client.new(endpoint: endpoint)
3131
end
3232

33-
def resolver
34-
@resolver ||= make_resolver(@resolver_endpoint)
33+
def client
34+
@client ||= make_client(@client_endpoint)
3535
end
3636

3737
def before
3838
super
3939

4040
@bound_endpoint = endpoint.bound
41-
@resolver_endpoint = @bound_endpoint.local_address_endpoint
41+
@client_endpoint = @bound_endpoint.local_address_endpoint
4242

4343
@server = make_server(@bound_endpoint)
4444
@server_task = @server.run

fixtures/async/dns/test_server.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
module Async
77
module DNS
88
class TestServer < Server
9-
def initialize(endpoint = DEFAULT_ENDPOINT, resolver: Resolver.new, **options)
9+
def initialize(endpoint = DEFAULT_ENDPOINT, client: Client.new, **options)
1010
super(endpoint, **options)
1111

12-
@resolver = resolver
12+
@client = client
1313
end
1414

1515
def process(name, resource_class, transaction)
1616
# This is a simple example of how to pass the query to an upstream server:
17-
transaction.passthrough!(@resolver)
17+
transaction.passthrough!(@client)
1818
end
1919
end
2020
end

guides/getting-started/readme.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Getting Started
2+
3+
This guide explains how to get started with the `async-dns` gem.
4+
5+
## Installation
6+
7+
Add the gem to your project:
8+
9+
~~~ bash
10+
$ bundle add async-dns
11+
~~~
12+
13+
## Usage
14+
15+
### Client
16+

lib/async/dns.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
require_relative 'dns/version'
99

1010
require_relative 'dns/server'
11-
require_relative 'dns/resolver'
11+
require_relative 'dns/client'
1212
require_relative 'dns/handler'
1313

1414
# @namespace

lib/async/dns/client.rb

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# frozen_string_literal: true
2+
3+
# Released under the MIT License.
4+
# Copyright, 2015-2024, by Samuel Williams.
5+
# Copyright, 2017, by Olle Jonsson.
6+
# Copyright, 2024, by Sean Dilda.
7+
8+
require_relative 'resolver'
9+
require_relative 'handler'
10+
require_relative 'system'
11+
require_relative 'cache'
12+
13+
require 'securerandom'
14+
require 'async'
15+
16+
require 'io/endpoint/composite_endpoint'
17+
require 'io/endpoint/host_endpoint'
18+
19+
module Async::DNS
20+
# Represents a DNS connection which we don't know how to use.
21+
class InvalidProtocolError < StandardError
22+
end
23+
24+
# Resolve names to addresses using the DNS protocol.
25+
class Client < Resolver
26+
# 10ms wait between making requests. Override with `options[:delay]`
27+
DEFAULT_DELAY = 0.01
28+
29+
# Try a given request 10 times before failing. Override with `options[:retries]`.
30+
DEFAULT_RETRIES = 10
31+
32+
# Servers are specified in the same manor as options[:listen], e.g.
33+
# [:tcp/:udp, address, port]
34+
# In the case of multiple servers, they will be checked in sequence.
35+
def initialize(*arguments, endpoint: nil, origin: nil, cache: Cache.new)
36+
super(*arguments)
37+
38+
@endpoint = endpoint || System.default_nameservers
39+
40+
# Legacy support for multiple endpoints:
41+
if @endpoint.is_a?(Array)
42+
endpoints = @endpoint.map do |specification|
43+
::IO::Endpoint.public_send(specification[0], *specification[1..-1])
44+
end
45+
46+
@endpoint = ::IO::Endpoint.composite(*endpoints)
47+
end
48+
49+
@origin = origin
50+
@cache = cache
51+
@count = 0
52+
end
53+
54+
attr_accessor :origin
55+
56+
# Generates a fully qualified name from a given name.
57+
#
58+
# @parameter name [String | Resolv::DNS::Name] The name to fully qualify.
59+
def fully_qualified_name(name)
60+
# If we are passed an existing deconstructed name:
61+
if Resolv::DNS::Name === name
62+
if name.absolute?
63+
return name
64+
else
65+
return name.with_origin(@origin)
66+
end
67+
end
68+
69+
# ..else if we have a string, we need to do some basic processing:
70+
if name.end_with? '.'
71+
return Resolv::DNS::Name.create(name)
72+
else
73+
return Resolv::DNS::Name.create(name).with_origin(@origin)
74+
end
75+
end
76+
77+
# Provides the next sequence identification number which is used to keep track of DNS messages.
78+
def next_id!
79+
# Using sequential numbers for the query ID is generally a bad thing because over UDP they can be spoofed. 16-bits isn't hard to guess either, but over UDP we also use a random port, so this makes effectively 32-bits of entropy to guess per request.
80+
SecureRandom.random_number(2**16)
81+
end
82+
83+
# Query a named resource and return the response.
84+
#
85+
# Bypasses the cache and always makes a new request.
86+
#
87+
# @returns [Resolv::DNS::Message] The response from the server.
88+
def query(name, resource_class = Resolv::DNS::Resource::IN::A)
89+
self.dispatch_query(self.fully_qualified_name(name), resource_class)
90+
end
91+
92+
# Look up a named resource of the given resource_class.
93+
def records_for(name, resource_classes)
94+
Console.debug(self) {"Looking up records for #{name.inspect} with #{resource_classes.inspect}."}
95+
name = self.fully_qualified_name(name)
96+
resource_classes = Array(resource_classes)
97+
98+
@cache.fetch(name, resource_classes) do |name, resource_class|
99+
if response = self.dispatch_query(name, resource_class)
100+
response.answer.each do |name, ttl, record|
101+
Console.debug(self) {"Caching record for #{name.inspect} with #{record.class} and TTL #{ttl}."}
102+
@cache.store(name, resource_class, record)
103+
end
104+
end
105+
end
106+
end
107+
108+
if System.ipv6?
109+
ADDRESS_RESOURCE_CLASSES = [Resolv::DNS::Resource::IN::A, Resolv::DNS::Resource::IN::AAAA]
110+
else
111+
ADDRESS_RESOURCE_CLASSES = [Resolv::DNS::Resource::IN::A]
112+
end
113+
114+
# Yields a list of `Resolv::IPv4` and `Resolv::IPv6` addresses for the given `name` and `resource_class`. Raises a ResolutionFailure if no severs respond.
115+
def addresses_for(name, resource_classes = ADDRESS_RESOURCE_CLASSES)
116+
records = self.records_for(name, resource_classes)
117+
118+
if records.empty?
119+
raise ResolutionFailure.new("Could not find any records for #{name.inspect}!")
120+
end
121+
122+
if records
123+
addresses = []
124+
125+
records.each do |record|
126+
if record.respond_to? :address
127+
addresses << record.address
128+
else
129+
# The most common case here is that record.class is IN::CNAME and we need to figure out the address. Usually the upstream DNS server would have replied with this too, and this will be loaded from the response if possible without requesting additional information:
130+
addresses += addresses_for(record.name, resource_classes)
131+
end
132+
end
133+
134+
if addresses.empty?
135+
addresses = nil
136+
end
137+
end
138+
139+
return addresses
140+
end
141+
142+
def call(name)
143+
if addresses = self.addresses_for(name)
144+
return addresses
145+
else
146+
return super
147+
end
148+
end
149+
150+
private
151+
152+
# In general, DNS servers are only able to handle a single question at a time. This method is used to dispatch a single query to the server and wait for a response.
153+
def dispatch_query(name, resource_class)
154+
message = Resolv::DNS::Message.new(self.next_id!)
155+
message.rd = 1
156+
157+
message.add_question(name, resource_class)
158+
159+
return dispatch_request(message)
160+
end
161+
162+
# Send the message to available servers. If no servers respond correctly, nil is returned. This result indicates a failure of the Client to correctly contact any server and get a valid response.
163+
def dispatch_request(message, parent: Async::Task.current)
164+
request = Request.new(message, @endpoint)
165+
error = nil
166+
167+
request.each do |endpoint|
168+
Console.debug "[#{message.id}] Sending request #{message.question.inspect} to address #{endpoint.inspect}"
169+
170+
begin
171+
response = try_server(request, endpoint)
172+
173+
if valid_response(message, response)
174+
return response
175+
end
176+
rescue => error
177+
# Try the next server.
178+
end
179+
end
180+
181+
if error
182+
raise error
183+
end
184+
185+
return nil
186+
end
187+
188+
def try_server(request, endpoint)
189+
endpoint.connect do |socket|
190+
case socket.local_address.socktype
191+
when Socket::SOCK_DGRAM
192+
try_datagram_server(request, socket)
193+
when Socket::SOCK_STREAM
194+
try_stream_server(request, socket)
195+
else
196+
raise InvalidProtocolError.new(endpoint)
197+
end
198+
end
199+
end
200+
201+
def valid_response(message, response)
202+
if response.tc != 0
203+
Console.warn "Received truncated response!", message_id: message.id
204+
elsif response.id != message.id
205+
Console.warn "Received response with incorrect message id: #{response.id}!", message_id: message.id
206+
else
207+
Console.debug "Received valid response with #{response.answer.size} answer(s).", message_id: message.id
208+
209+
return true
210+
end
211+
212+
return false
213+
end
214+
215+
def try_datagram_server(request, socket)
216+
socket.sendmsg(request.packet, 0)
217+
218+
data, peer = socket.recvfrom(UDP_MAXIMUM_SIZE)
219+
220+
return ::Resolv::DNS::Message.decode(data)
221+
end
222+
223+
def try_stream_server(request, socket)
224+
transport = Transport.new(socket)
225+
226+
transport.write_chunk(request.packet)
227+
228+
data = transport.read_chunk
229+
230+
return ::Resolv::DNS::Message.decode(data)
231+
end
232+
233+
# Manages a single DNS question message across one or more servers.
234+
class Request
235+
# Create a new request for the given message and endpoint.
236+
#
237+
# Encodes the message and stores it for later use.
238+
#
239+
# @parameter message [Resolv::DNS::Message] The message to send.
240+
# @parameter endpoint [IO::Endpoint::Generic] The endpoint to send the message to.
241+
def initialize(message, endpoint)
242+
@message = message
243+
@packet = message.encode
244+
245+
@endpoint = endpoint
246+
end
247+
248+
# @attribute [Resolv::DNS::Message] The message to send.
249+
attr :message
250+
251+
# @attribute [String] The encoded message to send.
252+
attr :packet
253+
254+
def each(&block)
255+
@endpoint.each(&block)
256+
end
257+
258+
def update_id!(id)
259+
@message.id = id
260+
@packet = @message.encode
261+
end
262+
end
263+
264+
private_constant :Request
265+
end
266+
end

0 commit comments

Comments
 (0)