diff --git a/lib/resolv.rb b/lib/resolv.rb index 7c05540..c69b035 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -84,8 +84,8 @@ def self.each_name(address, &proc) ## # Creates a new Resolv using +resolvers+. - def initialize(resolvers=[Hosts.new, DNS.new]) - @resolvers = resolvers + def initialize(resolvers=nil, use_ipv6: nil) + @resolvers = resolvers || [Hosts.new, DNS.new(DNS::Config.default_config_hash.merge(use_ipv6: use_ipv6))] end ## @@ -314,6 +314,8 @@ def self.open(*args) # String:: Path to a file using /etc/resolv.conf's format. # Hash:: Must contain :nameserver, :search and :ndots keys. # :nameserver_port can be used to specify port number of nameserver address. + # :raise_timeout_errors can be used to raise timeout errors + # as exceptions instead of treating the same as an NXDOMAIN response. # # The value of :nameserver should be an address string or # an array of address strings. @@ -410,6 +412,11 @@ def each_address(name) end def use_ipv6? # :nodoc: + use_ipv6 = @config.use_ipv6? + unless use_ipv6.nil? + return use_ipv6 + end + begin list = Socket.ip_address_list rescue NotImplementedError @@ -754,7 +761,7 @@ def lazy_initialize next if @socks_hash[bind_host] begin sock = UDPSocket.new(af) - rescue Errno::EAFNOSUPPORT + rescue Errno::EAFNOSUPPORT, Errno::EPROTONOSUPPORT next # The kernel doesn't support the address family. end @socks << sock @@ -1010,6 +1017,7 @@ def lazy_initialize @mutex.synchronize { unless @initialized @nameserver_port = [] + @use_ipv6 = nil @search = nil @ndots = 1 case @config_info @@ -1034,8 +1042,12 @@ def lazy_initialize if config_hash.include? :nameserver_port @nameserver_port = config_hash[:nameserver_port].map {|ns, port| [ns, (port || Port)] } end + if config_hash.include? :use_ipv6 + @use_ipv6 = config_hash[:use_ipv6] + end @search = config_hash[:search] if config_hash.include? :search @ndots = config_hash[:ndots] if config_hash.include? :ndots + @raise_timeout_errors = config_hash[:raise_timeout_errors] if @nameserver_port.empty? @nameserver_port << ['0.0.0.0', Port] @@ -1089,6 +1101,10 @@ def nameserver_port @nameserver_port end + def use_ipv6? + @use_ipv6 + end + def generate_candidates(name) candidates = nil name = Name.create(name) @@ -1122,6 +1138,7 @@ def generate_timeouts def resolv(name) candidates = generate_candidates(name) timeouts = @timeouts || generate_timeouts + timeout_error = false begin candidates.each {|candidate| begin @@ -1133,11 +1150,13 @@ def resolv(name) end } } + timeout_error = true raise ResolvError.new("DNS resolv timeout: #{name}") rescue NXDomain end } rescue ResolvError + raise if @raise_timeout_errors && timeout_error end end @@ -1524,13 +1543,15 @@ def Message.decode(m) id, flag, qdcount, ancount, nscount, arcount = msg.get_unpack('nnnnnn') o.id = id + o.tc = (flag >> 9) & 1 + o.rcode = flag & 15 + return o unless o.tc.zero? + o.qr = (flag >> 15) & 1 o.opcode = (flag >> 11) & 15 o.aa = (flag >> 10) & 1 - o.tc = (flag >> 9) & 1 o.rd = (flag >> 8) & 1 o.ra = (flag >> 7) & 1 - o.rcode = flag & 15 (1..qdcount).each { name, typeclass = msg.get_question o.add_question(name, typeclass) @@ -1622,6 +1643,14 @@ def get_string_list strings end + def get_list + [].tap do |values| + while @index < @limit + values << yield + end + end + end + def get_name return Name.new(self.get_labels) end @@ -1682,6 +1711,378 @@ def get_rr end end + ## + # SvcParams for service binding RRs. [RFC9460] + + class SvcParams + include Enumerable + + ## + # Create a list of SvcParams with the given initial content. + # + # +params+ has to be an enumerable of +SvcParam+s. + # If its content has +SvcParam+s with the duplicate key, + # the one appears last takes precedence. + + def initialize(params = []) + @params = {} + + params.each do |param| + add param + end + end + + ## + # Get SvcParam for the given +key+ in this list. + + def [](key) + @params[canonical_key(key)] + end + + ## + # Get the number of SvcParams in this list. + + def count + @params.count + end + + ## + # Get whether this list is empty. + + def empty? + @params.empty? + end + + ## + # Add the SvcParam +param+ to this list, overwriting the existing one with the same key. + + def add(param) + @params[param.class.key_number] = param + end + + ## + # Remove the +SvcParam+ with the given +key+ and return it. + + def delete(key) + @params.delete(canonical_key(key)) + end + + ## + # Enumerate the +SvcParam+s in this list. + + def each(&block) + return enum_for(:each) unless block + @params.each_value(&block) + end + + def encode(msg) # :nodoc: + @params.keys.sort.each do |key| + msg.put_pack('n', key) + msg.put_length16 do + @params.fetch(key).encode(msg) + end + end + end + + def self.decode(msg) # :nodoc: + params = msg.get_list do + key, = msg.get_unpack('n') + msg.get_length16 do + SvcParam::ClassHash[key].decode(msg) + end + end + + return self.new(params) + end + + private + + def canonical_key(key) # :nodoc: + case key + when Integer + key + when /\Akey(\d+)\z/ + Integer($1) + when Symbol + SvcParam::ClassHash[key].key_number + else + raise TypeError, 'key must be either String or Symbol' + end + end + end + + + ## + # Base class for SvcParam. [RFC9460] + + class SvcParam + + ## + # Get the presentation name of the SvcParamKey. + + def self.key_name + const_get(:KeyName) + end + + ## + # Get the registered number of the SvcParamKey. + + def self.key_number + const_get(:KeyNumber) + end + + ClassHash = Hash.new do |h, key| # :nodoc: + case key + when Integer + Generic.create(key) + when /\Akey(?\d+)\z/ + Generic.create(key.to_int) + when Symbol + raise KeyError, "unknown key #{key}" + else + raise TypeError, 'key must be either String or Symbol' + end + end + + ## + # Generic SvcParam abstract class. + + class Generic < SvcParam + + ## + # SvcParamValue in wire-format byte string. + + attr_reader :value + + ## + # Create generic SvcParam + + def initialize(value) + @value = value + end + + def encode(msg) # :nodoc: + msg.put_bytes(@value) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_bytes) + end + + def self.create(key_number) + c = Class.new(Generic) + key_name = :"key#{key_number}" + c.const_set(:KeyName, key_name) + c.const_set(:KeyNumber, key_number) + self.const_set(:"Key#{key_number}", c) + ClassHash[key_name] = ClassHash[key_number] = c + return c + end + end + + ## + # "mandatory" SvcParam -- Mandatory keys in service binding RR + + class Mandatory < SvcParam + KeyName = :mandatory + KeyNumber = 0 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Mandatory keys. + + attr_reader :keys + + ## + # Initialize "mandatory" ScvParam. + + def initialize(keys) + @keys = keys.map(&:to_int) + end + + def encode(msg) # :nodoc: + @keys.sort.each do |key| + msg.put_pack('n', key) + end + end + + def self.decode(msg) # :nodoc: + keys = msg.get_list { msg.get_unpack('n')[0] } + return self.new(keys) + end + end + + ## + # "alpn" SvcParam -- Additional supported protocols + + class ALPN < SvcParam + KeyName = :alpn + KeyNumber = 1 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Supported protocol IDs. + + attr_reader :protocol_ids + + ## + # Initialize "alpn" ScvParam. + + def initialize(protocol_ids) + @protocol_ids = protocol_ids.map(&:to_str) + end + + def encode(msg) # :nodoc: + msg.put_string_list(@protocol_ids) + end + + def self.decode(msg) # :nodoc: + return self.new(msg.get_string_list) + end + end + + ## + # "no-default-alpn" SvcParam -- No support for default protocol + + class NoDefaultALPN < SvcParam + KeyName = :'no-default-alpn' + KeyNumber = 2 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + def encode(msg) # :nodoc: + # no payload + end + + def self.decode(msg) # :nodoc: + return self.new + end + end + + ## + # "port" SvcParam -- Port for alternative endpoint + + class Port < SvcParam + KeyName = :port + KeyNumber = 3 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Port number. + + attr_reader :port + + ## + # Initialize "port" ScvParam. + + def initialize(port) + @port = port.to_int + end + + def encode(msg) # :nodoc: + msg.put_pack('n', @port) + end + + def self.decode(msg) # :nodoc: + port, = msg.get_unpack('n') + return self.new(port) + end + end + + ## + # "ipv4hint" SvcParam -- IPv4 address hints + + class IPv4Hint < SvcParam + KeyName = :ipv4hint + KeyNumber = 4 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv4 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv4hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv4.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv4.new(msg.get_bytes(4)) } + return self.new(addresses) + end + end + + ## + # "ipv6hint" SvcParam -- IPv6 address hints + + class IPv6Hint < SvcParam + KeyName = :ipv6hint + KeyNumber = 6 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # Set of IPv6 addresses. + + attr_reader :addresses + + ## + # Initialize "ipv6hint" ScvParam. + + def initialize(addresses) + @addresses = addresses.map {|address| IPv6.create(address) } + end + + def encode(msg) # :nodoc: + @addresses.each do |address| + msg.put_bytes(address.address) + end + end + + def self.decode(msg) # :nodoc: + addresses = msg.get_list { IPv6.new(msg.get_bytes(16)) } + return self.new(addresses) + end + end + + ## + # "dohpath" SvcParam -- DNS over HTTPS path template [RFC9461] + + class DoHPath < SvcParam + KeyName = :dohpath + KeyNumber = 7 + ClassHash[KeyName] = ClassHash[KeyNumber] = self # :nodoc: + + ## + # URI template for DoH queries. + + attr_reader :template + + ## + # Initialize "dohpath" ScvParam. + + def initialize(template) + @template = template.encode('utf-8') + end + + def encode(msg) # :nodoc: + msg.put_bytes(@template) + end + + def self.decode(msg) # :nodoc: + template = msg.get_bytes.force_encoding('utf-8') + return self.new(template) + end + end + end + ## # A DNS query abstract class. @@ -2345,6 +2746,84 @@ def self.decode_rdata(msg) # :nodoc: return self.new(priority, weight, port, target) end end + + ## + # Common implementation for SVCB-compatible resource records. + + class ServiceBinding + + ## + # Create a service binding resource record. + + def initialize(priority, target, params = []) + @priority = priority.to_int + @target = Name.create(target) + @params = SvcParams.new(params) + end + + ## + # The priority of this target host. + # + # The range is 0-65535. + # If set to 0, this RR is in AliasMode. Otherwise, it is in ServiceMode. + + attr_reader :priority + + ## + # The domain name of the target host. + + attr_reader :target + + ## + # The service paramters for the target host. + + attr_reader :params + + ## + # Whether this RR is in AliasMode. + + def alias_mode? + self.priority == 0 + end + + ## + # Whether this RR is in ServiceMode. + + def service_mode? + !alias_mode? + end + + def encode_rdata(msg) # :nodoc: + msg.put_pack("n", @priority) + msg.put_name(@target, compress: false) + @params.encode(msg) + end + + def self.decode_rdata(msg) # :nodoc: + priority, = msg.get_unpack("n") + target = msg.get_name + params = SvcParams.decode(msg) + return self.new(priority, target, params) + end + end + + ## + # SVCB resource record [RFC9460] + + class SVCB < ServiceBinding + TypeValue = 64 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end + + ## + # HTTPS resource record [RFC9460] + + class HTTPS < ServiceBinding + TypeValue = 65 + ClassValue = IN::ClassValue + ClassHash[[TypeValue, ClassValue]] = self # :nodoc: + end end end end @@ -2564,11 +3043,7 @@ def initialize(address) # :nodoc: attr_reader :address def to_s # :nodoc: - address = sprintf("%x:%x:%x:%x:%x:%x:%x:%x", *@address.unpack("nnnnnnnn")) - unless address.sub!(/(^|:)0(:0)+(:|$)/, '::') - address.sub!(/(^|:)0(:|$)/, '::') - end - return address + sprintf("%x:%x:%x:%x:%x:%x:%x:%x", *@address.unpack("nnnnnnnn")).sub(/(^|:)0(:0)+(:|$)/, '::') end def inspect # :nodoc: diff --git a/test/resolv/test_dns.rb b/test/resolv/test_dns.rb index 0c3128b..bf21f93 100644 --- a/test/resolv/test_dns.rb +++ b/test/resolv/test_dns.rb @@ -44,6 +44,16 @@ def teardown BasicSocket.do_not_reverse_lookup = @save_do_not_reverse_lookup end + def with_tcp(host, port) + t = TCPServer.new(host, port) + begin + t.listen(1) + yield t + ensure + t.close + end + end + def with_udp(host, port) u = UDPSocket.new begin @@ -157,6 +167,168 @@ def test_query_ipv4_address } end + def test_query_ipv4_address_truncated_tcp_fallback + begin + OpenSSL + rescue LoadError + skip 'autoload problem. see [ruby-dev:45021][Bug #5786]' + end if defined?(OpenSSL) + + num_records = 50 + + with_udp('127.0.0.1', 0) {|u| + _, server_port, _, server_address = u.addr + with_tcp('127.0.0.1', server_port) {|t| + client_thread = Thread.new { + Resolv::DNS.open(:nameserver_port => [[server_address, server_port]]) {|dns| + dns.getresources("foo.example.org", Resolv::DNS::Resource::IN::A) + } + } + udp_server_thread = Thread.new { + msg, (_, client_port, _, client_address) = Timeout.timeout(5) {u.recvfrom(4096)} + id, word2, qdcount, ancount, nscount, arcount = msg.unpack("nnnnnn") + qr = (word2 & 0x8000) >> 15 + opcode = (word2 & 0x7800) >> 11 + aa = (word2 & 0x0400) >> 10 + tc = (word2 & 0x0200) >> 9 + rd = (word2 & 0x0100) >> 8 + ra = (word2 & 0x0080) >> 7 + z = (word2 & 0x0070) >> 4 + rcode = word2 & 0x000f + rest = msg[12..-1] + assert_equal(0, qr) # 0:query 1:response + assert_equal(0, opcode) # 0:QUERY 1:IQUERY 2:STATUS + assert_equal(0, aa) # Authoritative Answer + assert_equal(0, tc) # TrunCation + assert_equal(1, rd) # Recursion Desired + assert_equal(0, ra) # Recursion Available + assert_equal(0, z) # Reserved for future use + assert_equal(0, rcode) # 0:No-error 1:Format-error 2:Server-failure 3:Name-Error 4:Not-Implemented 5:Refused + assert_equal(1, qdcount) # number of entries in the question section. + assert_equal(0, ancount) # number of entries in the answer section. + assert_equal(0, nscount) # number of entries in the authority records section. + assert_equal(0, arcount) # number of entries in the additional records section. + name = [3, "foo", 7, "example", 3, "org", 0].pack("Ca*Ca*Ca*C") + assert_operator(rest, :start_with?, name) + rest = rest[name.length..-1] + assert_equal(4, rest.length) + qtype, _ = rest.unpack("nn") + assert_equal(1, qtype) # A + assert_equal(1, qtype) # IN + id = id + qr = 1 + opcode = opcode + aa = 0 + tc = 1 + rd = rd + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack("nnnnnn") + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192,0,2,i].pack("CCCC") # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack("a*nnNna*") + msg << rr + end + u.send(msg[0...512], 0, client_address, client_port) + } + tcp_server_thread = Thread.new { + ct = t.accept + msg = ct.recv(512) + msg.slice!(0..1) # Size (only for TCP) + id, word2, qdcount, ancount, nscount, arcount = msg.unpack("nnnnnn") + qr = (word2 & 0x8000) >> 15 + opcode = (word2 & 0x7800) >> 11 + aa = (word2 & 0x0400) >> 10 + tc = (word2 & 0x0200) >> 9 + rd = (word2 & 0x0100) >> 8 + ra = (word2 & 0x0080) >> 7 + z = (word2 & 0x0070) >> 4 + rcode = word2 & 0x000f + rest = msg[12..-1] + assert_equal(0, qr) # 0:query 1:response + assert_equal(0, opcode) # 0:QUERY 1:IQUERY 2:STATUS + assert_equal(0, aa) # Authoritative Answer + assert_equal(0, tc) # TrunCation + assert_equal(1, rd) # Recursion Desired + assert_equal(0, ra) # Recursion Available + assert_equal(0, z) # Reserved for future use + assert_equal(0, rcode) # 0:No-error 1:Format-error 2:Server-failure 3:Name-Error 4:Not-Implemented 5:Refused + assert_equal(1, qdcount) # number of entries in the question section. + assert_equal(0, ancount) # number of entries in the answer section. + assert_equal(0, nscount) # number of entries in the authority records section. + assert_equal(0, arcount) # number of entries in the additional records section. + name = [3, "foo", 7, "example", 3, "org", 0].pack("Ca*Ca*Ca*C") + assert_operator(rest, :start_with?, name) + rest = rest[name.length..-1] + assert_equal(4, rest.length) + qtype, _ = rest.unpack("nn") + assert_equal(1, qtype) # A + assert_equal(1, qtype) # IN + id = id + qr = 1 + opcode = opcode + aa = 0 + tc = 0 + rd = rd + ra = 1 + z = 0 + rcode = 0 + qdcount = 0 + ancount = num_records + nscount = 0 + arcount = 0 + word2 = (qr << 15) | + (opcode << 11) | + (aa << 10) | + (tc << 9) | + (rd << 8) | + (ra << 7) | + (z << 4) | + rcode + msg = [id, word2, qdcount, ancount, nscount, arcount].pack("nnnnnn") + type = 1 + klass = 1 + ttl = 3600 + rdlength = 4 + num_records.times do |i| + rdata = [192,0,2,i].pack("CCCC") # 192.0.2.x (TEST-NET address) RFC 3330 + rr = [name, type, klass, ttl, rdlength, rdata].pack("a*nnNna*") + msg << rr + end + msg = "#{[msg.bytesize].pack("n")}#{msg}" # Prefix with size + ct.send(msg, 0) + ct.close + } + result, _ = assert_join_threads([client_thread, udp_server_thread, tcp_server_thread]) + assert_instance_of(Array, result) + assert_equal(50, result.length) + result.each_with_index do |rr, i| + assert_instance_of(Resolv::DNS::Resource::IN::A, rr) + assert_instance_of(Resolv::IPv4, rr.address) + assert_equal("192.0.2.#{i}", rr.address.to_s) + assert_equal(3600, rr.ttl) + end + } + } + end + def test_query_ipv4_duplicate_responses begin OpenSSL @@ -374,7 +546,8 @@ def test_ipv6_to_s ["2001:db8::1", "2001:db8::0:1"], ["::", "0:0:0:0:0:0:0:0"], ["2001::", "2001::0"], - ["2001:db8::1:1:1:1:1", "2001:db8:0:1:1:1:1:1"], + ["2001:db8:0:1:1:1:1:1", "2001:db8:0:1:1:1:1:1"], # RFC 5952 Section 4.2.2. + ["2001:db8::1:1:1:1", "2001:db8:0:0:1:1:1:1"], ["1::1:0:0:0:1", "1:0:0:1:0:0:0:1"], ["1::1:0:0:1", "1:0:0:0:1:0:0:1"], ] @@ -457,7 +630,33 @@ def dns.each_resource(name, typeclass) end assert_raise(Resolv::ResolvError) { dns.each_name('example.com') } end + + def test_unreachable_server + unreachable_ip = '127.0.0.1' + sock = UDPSocket.new + sock.connect(unreachable_ip, 53) + begin + sock.send('1', 0) + rescue Errno::ENETUNREACH, Errno::EHOSTUNREACH + else + omit('cannot test unreachable server, as IP used is reachable') + end + + config = { + :nameserver => [unreachable_ip], + :search => ['lan'], + :ndots => 1 + } + r = Resolv.new([Resolv::DNS.new(config)]) + assert_equal([], r.getaddresses('www.google.com')) + config[:raise_timeout_errors] = true + r = Resolv.new([Resolv::DNS.new(config)]) + assert_raise(Resolv::ResolvError) { r.getaddresses('www.google.com') } + ensure + sock&.close + end + def test_query_ipv4_try_next_dns_if_first_answers_with_servfail begin OpenSSL @@ -571,4 +770,4 @@ def test_query_ipv4_try_next_dns_if_first_answers_with_servfail } } end -end \ No newline at end of file +end diff --git a/test/resolv/test_svcb_https.rb b/test/resolv/test_svcb_https.rb new file mode 100644 index 0000000..5dc3163 --- /dev/null +++ b/test/resolv/test_svcb_https.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: false +require 'test/unit' +require 'resolv' + +class TestResolvSvcbHttps < Test::Unit::TestCase + # Wraps a RR in answer section + def wrap_rdata(rrtype, rrclass, rdata) + [ + "\x00\x00\x00\x00", # ID/FLAGS + [0, 1, 0, 0].pack('nnnn'), # QDCOUNT/ANCOUNT/NSCOUNT/ARCOUNT + "\x07example\x03com\x00", # NAME + [rrtype, rrclass, 0, rdata.bytesize].pack('nnNn'), # TYPE/CLASS/TTL/RDLENGTH + rdata, + ].join.b + end + + def test_svcparams + params = Resolv::DNS::SvcParams.new([Resolv::DNS::SvcParam::Mandatory.new([1])]) + + assert_equal 1, params.count + + params.add Resolv::DNS::SvcParam::NoDefaultALPN.new + params.add Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3]) + + assert_equal 3, params.count + + assert_equal [1], params[:mandatory].keys + assert_equal [1], params[0].keys + + assert_equal %w[h2 h3], params[:alpn].protocol_ids + assert_equal %w[h2 h3], params[1].protocol_ids + + params.delete :mandatory + params.delete :alpn + + assert_equal 1, params.count + + assert_nil params[:mandatory] + assert_nil params[1] + + ary = params.each.to_a + + assert_instance_of Resolv::DNS::SvcParam::NoDefaultALPN, ary.first + end + + def test_svcb + rr = Resolv::DNS::Resource::IN::SVCB.new(0, 'example.com.') + + assert_equal 0, rr.priority + assert rr.alias_mode? + assert !rr.service_mode? + assert_equal Resolv::DNS::Name.create('example.com.'), rr.target + assert rr.params.empty? + + rr = Resolv::DNS::Resource::IN::SVCB.new(16, 'example.com.', [ + Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3]), + ]) + + assert_equal 16, rr.priority + assert !rr.alias_mode? + assert rr.service_mode? + + assert_equal 1, rr.params.count + assert_instance_of Resolv::DNS::SvcParam::ALPN, rr.params[:alpn] + end + + def test_svcb_encode_order + msg = Resolv::DNS::Message.new(0) + msg.add_answer( + 'example.com.', 0, + Resolv::DNS::Resource::IN::SVCB.new(16, 'foo.example.org.', [ + Resolv::DNS::SvcParam::ALPN.new(%w[h2 h3-19]), + Resolv::DNS::SvcParam::Mandatory.new([4, 1]), + Resolv::DNS::SvcParam::IPv4Hint.new(['192.0.2.1']), + ]) + ) + + expected = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x00\x00\x04\x00\x01\x00\x04" + + "\x00\x01\x00\x09\x02h2\x05h3-19" + + "\x00\x04\x00\x04\xc0\x00\x02\x01" + + assert_equal expected, msg.encode + end + + ## Test vectors from [RFC9460] + + def test_alias_mode + wire = wrap_rdata 65, 1, "\x00\x00\x03foo\x07example\x03com\x00" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 0, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 0, rr.params.count + + assert_equal wire, msg.encode + end + + def test_target_name_is_root + wire = wrap_rdata 64, 1, "\x00\x01\x00" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('.'), rr.target + assert_equal 0, rr.params.count + + assert_equal wire, msg.encode + end + + def test_specifies_port + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03com\x00" + + "\x00\x03\x00\x02\x00\x35" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal 53, rr.params[:port].port + + assert_equal wire, msg.encode + end + + def test_generic_key + wire = wrap_rdata 64, 1, "\x00\x01\x03foo\x07example\x03com\x00" + + "\x02\x9b\x00\x05hello" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal 'hello', rr.params[:key667].value + + assert_equal wire, msg.encode + end + + def test_two_ipv6hints + wire = wrap_rdata 64, 1, "\x00\x01\x03foo\x07example\x03com\x00" + + "\x00\x06\x00\x20" + + ("\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01" + + "\x20\x01\x0d\xb8\x00\x00\x00\x00\x00\x00\x00\x00\x00\x53\x00\x01") + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal [Resolv::IPv6.create('2001:db8::1'), Resolv::IPv6.create('2001:db8::53:1')], + rr.params[:ipv6hint].addresses + + assert_equal wire, msg.encode + end + + def test_ipv6hint_embedded_ipv4 + wire = wrap_rdata 64, 1, "\x00\x01\x07example\x03com\x00" + + "\x00\x06\x00\x10\x20\x01\x0d\xb8\x01\x22\x03\x44\x00\x00\x00\x00\xc0\x00\x02\x21" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('example.com.'), rr.target + assert_equal 1, rr.params.count + assert_equal [Resolv::IPv6.create('2001:db8:122:344::192.0.2.33')], + rr.params[:ipv6hint].addresses + + assert_equal wire, msg.encode + end + + def test_mandatory_alpn_ipv4hint + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x00\x00\x04\x00\x01\x00\x04" + + "\x00\x01\x00\x09\x02h2\x05h3-19" + + "\x00\x04\x00\x04\xc0\x00\x02\x01" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.org.'), rr.target + assert_equal 3, rr.params.count + assert_equal [1, 4], rr.params[:mandatory].keys + assert_equal ['h2', 'h3-19'], rr.params[:alpn].protocol_ids + assert_equal [Resolv::IPv4.create('192.0.2.1')], rr.params[:ipv4hint].addresses + + assert_equal wire, msg.encode + end + + def test_alpn_comma_backslash + wire = wrap_rdata 64, 1, "\x00\x10\x03foo\x07example\x03org\x00" + + "\x00\x01\x00\x0c\x08f\\oo,bar\x02h2" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 16, rr.priority + assert_equal Resolv::DNS::Name.create('foo.example.org.'), rr.target + assert_equal 1, rr.params.count + assert_equal ['f\oo,bar', 'h2'], rr.params[:alpn].protocol_ids + + assert_equal wire, msg.encode + end + + ## For [RFC9461] + + def test_dohpath + wire = wrap_rdata 64, 1, "\x00\x01\x03one\x03one\x03one\x03one\x00" + + "\x00\x01\x00\x03\x02h2" + + "\x00\x03\x00\x02\x01\xbb" + + "\x00\x04\x00\x08\x01\x01\x01\x01\x01\x00\x00\x01" + + "\x00\x06\x00\x20" + + ("\x26\x06\x47\x00\x47\x00\x00\x00\x00\x00\x00\x00\x00\x00\x11\x11" + + "\x26\x06\x47\x00\x47\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x01") + + "\x00\x07\x00\x10/dns-query{?dns}" + msg = Resolv::DNS::Message.decode(wire) + _, _, rr = msg.answer.first + + assert_equal 1, rr.priority + assert_equal Resolv::DNS::Name.create('one.one.one.one.'), rr.target + assert_equal 5, rr.params.count + assert_equal ['h2'], rr.params[:alpn].protocol_ids + assert_equal 443, rr.params[:port].port + assert_equal [Resolv::IPv4.create('1.1.1.1'), Resolv::IPv4.create('1.0.0.1')], + rr.params[:ipv4hint].addresses + assert_equal [Resolv::IPv6.create('2606:4700:4700::1111'), Resolv::IPv6.create('2606:4700:4700::1001')], + rr.params[:ipv6hint].addresses + assert_equal '/dns-query{?dns}', rr.params[:dohpath].template + + assert_equal wire, msg.encode + end +end