From c9c0c55bd28b56f37a41bcd5d30a99f04f8c4063 Mon Sep 17 00:00:00 2001 From: Eric Hodel Date: Sun, 7 Jun 2020 16:40:14 -0700 Subject: [PATCH] Add DNS over HTTP resolver This implements RFC 8484 using the existing Resolv library and Net::HTTP. --- lib/resolv.rb | 142 ++++++++++++++++++++ test/resolv/test_doh.rb | 279 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 421 insertions(+) create mode 100644 test/resolv/test_doh.rb diff --git a/lib/resolv.rb b/lib/resolv.rb index 2b81c05a2e..5bb87ef7f1 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -2642,6 +2642,148 @@ def make_udp_requester # :nodoc: end + ## + # A DNS over HTTP resolver which follows RFC 8484 + # + # This implementation uses HTTP POST with the application/dns-message. + + class DoH < DNS + + ## + # Creates a new DNS over HTTP resolver with the +doh_uri+ of the resolver. + # + # +no_cache+ adds a Cache-Control header of "no-cache" to disable caching + # of HTTP responses. + # + # The three timeouts set their respective Net::HTTP timeouts. + + def initialize(doh_uri, + no_cache: false, + open_timeout: 2, + write_timeout: 1, + read_timeout: 1) + @doh_uri = doh_uri + @no_cache = no_cache + @open_timeout = open_timeout + @write_timeout = write_timeout + @read_timeout = read_timeout + + @mutex = Thread::Mutex.new + @initialized = nil + @http = nil + end + + def lazy_initialize # :nodoc: + @mutex.synchronize { + next if @initialized + + require "net/http" + require "uri" + + @doh_uri = URI @doh_uri + @doh_uri += "/dns-query" + + @http = Net::HTTP.new @doh_uri.hostname, @doh_uri.port + @http.use_ssl = "https" == @doh_uri.scheme.downcase + @http.verify_mode = OpenSSL::SSL::VERIFY_PEER + @http.open_timeout = @open_timeout + @http.write_timeout = @write_timeout + @http.read_timeout = @read_timeout + + @initialized = true + } + end + + ## + # Looks up the first IP address for +name+. + + def getaddress(name) + each_address(name) {|address| return address} + raise ResolvError.new("DNS over HTTP result has no information for #{name}") + end + + ## + # Looks up the hostname of +address+ + + def getname(address) + each_name(address) {|name| return name} + raise ResolvError.new("DNS over HTTP result has no information for #{address}") + end + + def fetch_http(name, typeclass) # :nodoc: + lazy_initialize + + req = request(name, typeclass) + + res = @mutex.synchronize { + @http.start unless @http.started? + + @http.request req + } + + case res + when Net::HTTPSuccess + age = Integer res["Age"], exception: false + age = 0 unless age + age = 0 if age.negative? + + reply = Resolv::DNS::Message.decode res.body + reply.answer.map! { |name, ttl, data| + new_data_ttl = data.ttl - age + + data.instance_variable_set(:@ttl, new_data_ttl) + + [name, ttl - age, data] + } + reply + when Net::HTTPGatewayTimeout + raise ResolvTimeout + else + raise ResolvError, "DNS over HTTP error #{res.message} (#{res.code}) for #{name}" + end + end + + private :fetch_http + + def fetch_resource(name, typeclass) # :nodoc: + reply = fetch_http(name, typeclass) + + case reply.rcode + when RCode::NoError + if reply.tc == 1 + # how? + raise ResolvError, "Truncated DNS over HTTP reply" + else + yield(reply, name) + end + return + when RCode::NXDomain + raise Config::NXDomain.new(name.to_s) + else + raise Config::OtherResolvError.new(name.to_s) + end + end + + def request(name, typeclass) # :nodoc: + name = Name.create(name) + name = Name.create("#{name}.") unless name.absolute? + + msg = Message.new + msg.rd = 1 + msg.add_question(name, typeclass) + + req = Net::HTTP::Post.new @doh_uri.path + req["Content-Type"] = "application/dns-message" + req["Accept"] = "application/dns-message" + req["Cache-Control"] = "no-cache" if @no_cache + req.body = msg.encode + + req + end + + private :request + end + module LOC ## diff --git a/test/resolv/test_doh.rb b/test/resolv/test_doh.rb new file mode 100644 index 0000000000..91cdcfbbff --- /dev/null +++ b/test/resolv/test_doh.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: false +require 'test/unit' +require 'resolv' + +class TestResolvDoH < Test::Unit::TestCase + def setup + @uri = "https://dns.example:853" + @doh = Resolv::DoH.new @uri + @doh.lazy_initialize + + Resolv::DNS::Message.class_variable_set :@@identifier, -1 + end + + def stub_http + http = @doh.instance_variable_get(:@http) + + def http.res=(res) + @res = res + end + + def http.started? + true + end + + def http.request(req) + @res + end + + http + end + + def fake_http_response(response_class, body, header = {}) + res = response_class.allocate + res.initialize_http_header header + res.instance_variable_set(:@read, true) + res.body = body + res + end + + def test_fetch_http_ok + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.add_question(name, typeclass) + reply.add_answer(name, 30, resource) + + header = { "Age" => "5" } + + http = stub_http + res = fake_http_response Net::HTTPOK, reply.encode, header + http.res = res + + reply = @doh.send(:fetch_http, name, typeclass) + + _, _, res = reply.answer.first + + assert_equal "192.0.2.1", res.address.to_s + assert_equal 25, res.ttl + end + + def test_fetch_http_adjust_ttl + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.add_question(name, typeclass) + reply.add_answer(name, 30, resource) + + http = stub_http + http.res = fake_http_response Net::HTTPOK, reply.encode + + reply = @doh.send(:fetch_http, name, typeclass) + + _, _, res = reply.answer.first + + assert_equal "192.0.2.1", res.address.to_s + end + + def test_fetch_http_non_200 + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + + res = fake_http_response Net::HTTPBadRequest, "" + res.instance_variable_set(:@code, "400") + res.instance_variable_set(:@message, "BAD REQUEST") + + http = stub_http + http.res = res + + e = assert_raise Resolv::ResolvError do + @doh.send(:fetch_http, name, typeclass) + end + + assert_equal "DNS over HTTP error BAD REQUEST (400) for www.example.com", + e.message + end + + def test_fetch_http_gateway_timeout + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + + http = stub_http + http.res = fake_http_response Net::HTTPGatewayTimeout, "" + + assert_raise Resolv::ResolvTimeout do + @doh.send(:fetch_http, name, typeclass) + end + end + + def test_fetch_resource + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.add_question(name, typeclass) + reply.add_answer(name, 30, resource) + + http = stub_http + http.res = fake_http_response Net::HTTPOK, reply.encode + + yielded = false + + @doh.fetch_resource(name, typeclass) do |rep, name| + yielded = true + + _, _, res = rep.answer.first + + assert_equal "192.0.2.1", res.address.to_s + end + + assert yielded, "#fetch_resource did not yield to the block" + end + + def test_fetch_resource_truncated + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.tc = 1 + reply.add_question(name, typeclass) + reply.add_answer(name, 30, resource) + + http = stub_http + http.res = fake_http_response Net::HTTPOK, reply.encode + + yielded = false + + e = assert_raise Resolv::ResolvError do + @doh.fetch_resource(name, typeclass) + end + + assert_equal "Truncated DNS over HTTP reply", e.message + end + + def test_fetch_resource_nxdomain + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.tc = 1 + reply.add_question(name, typeclass) + reply.rcode = Resolv::DNS::RCode::NXDomain + + http = stub_http + http.res = fake_http_response Net::HTTPOK, reply.encode + + yielded = false + + e = assert_raise Resolv::DNS::Config::NXDomain do + @doh.fetch_resource(name, typeclass) + end + + assert_equal "www.example.com", e.message + end + + def test_fetch_resource_other_error + name = Resolv::DNS::Name.create("www.example.com.") + typeclass = Resolv::DNS::Resource::IN::A + resource = Resolv::DNS::Resource::IN::A.new("192.0.2.1") + + reply = Resolv::DNS::Message.new 1 + reply.rd = 1 + reply.ra = 1 + reply.tc = 1 + reply.add_question(name, typeclass) + reply.rcode = Resolv::DNS::RCode::ServFail + + http = stub_http + http.res = fake_http_response Net::HTTPOK, reply.encode + + yielded = false + + e = assert_raise Resolv::DNS::Config::OtherResolvError do + @doh.fetch_resource(name, typeclass) + end + + assert_equal "www.example.com", e.message + end + + def test_lazy_initialize + http = @doh.instance_variable_get(:@http) + + assert_equal 2, http.open_timeout + assert_equal 1, http.write_timeout + assert_equal 1, http.read_timeout + end + + def test_lazy_initialize_timeouts + @doh = + Resolv::DoH.new @uri, open_timeout: 3, write_timeout: 4, read_timeout: 5 + + @doh.lazy_initialize + + http = @doh.instance_variable_get(:@http) + + assert_equal 3, http.open_timeout + assert_equal 4, http.write_timeout + assert_equal 5, http.read_timeout + end + + def test_request + name = "www.example.com" + typeclass = Resolv::DNS::Resource::IN::A + + req = @doh.send(:request, name, typeclass) + + assert_kind_of Net::HTTP::Post, req + assert_equal "/dns-query", req.path + assert_equal "application/dns-message", req["Content-Type"] + assert_equal "application/dns-message", req["Accept"] + + message = Resolv::DNS::Message.new 0 + message.rd = 1 + message.add_question(Resolv::DNS::Name.create("#{name}."), typeclass) + expected = message.encode + + assert_equal expected, req.body + end + + def test_request_no_cache + @doh = Resolv::DoH.new @uri, no_cache: true + @doh.lazy_initialize + + name = "www.example.com" + typeclass = Resolv::DNS::Resource::IN::A + + req = @doh.send(:request, name, typeclass) + + assert_kind_of Net::HTTP::Post, req + assert_equal "/dns-query", req.path + assert_equal "application/dns-message", req["Content-Type"] + assert_equal "application/dns-message", req["Accept"] + assert_equal "no-cache", req["Cache-Control"] + + message = Resolv::DNS::Message.new 0 + message.rd = 1 + message.add_question(Resolv::DNS::Name.create("#{name}."), typeclass) + expected = message.encode + + assert_equal expected, req.body + end + +end -- 2.24.1