From b3ced7f0392da5897aac4f9b198bf9aa4d432735 Mon Sep 17 00:00:00 2001 From: Kasumi Hanazuki Date: Fri, 24 Nov 2023 10:35:26 +0900 Subject: [PATCH] Implement SVCB and HTTPS RRs (#32) * Add MessageDecoder#get_list This method repeats yielding until all the data upto the current limit is consumed, and then returns an Array containig the block results. * Implement SVCB and HTTPS RRs [RFC 9460] > This patch implements SVCB and HTTPS resource record types defined in > [RFC 9460]. > > The RR types are now supported by many server implementations including > BIND, unbound, PowerDNS, and Knot DNS. Major browsers such as Chrome, > Edge, and Safari have started to query HTTPS records, with the records > gradually adopted by websites. Also, SVCB is actually deployed in the > public DNS resolvers such as Cloudflare DNS and Google Public DNS for > [DDR]. > > With such wide adoption, we have plenty of real-world use cases, and > it is unlikely the wire format will change further in an incompatible > way. It is time to implement them in the client libraries! > > # Rationale for proposed API > > ## `Resolv::DNS::Resource::IN::ServiceBinding` > > This is an abstract class for SVCB-compatible RR types. > SVCB-compatible RR types, as defined in the Draft, shares the wire > format and the semantics of their RDATA fields with SVCB to allow > implementations to share the processing of these RR types. So we do > so. > > The interface of this class is straightforward: It has three > attributes `priority`, `target`, and `params`, which correspond the > RDATA fields SvcPriority, TargetName, and SvcParams, resp. > > SVCB RR type is defined specifically within IN class. Thus, this > class is placed in the `Resolv::DNS::Resource::IN` namespace. > > ## `Resolv::DNS::Resource::IN::SVCB`, `Resolv::DNS::Resource::IN::HTTPS` > > Just inherits ServiceBinding class. > > ## `Resolv::DNS::SvcParam` > > This class represents a pair of a SvcParamKey and a SvcParamValue. > Aligned with the design of `Resolv::DNS::Resource`, each SvcParamKey > has its own subclass of `Resolv::DNS::SvcParam`. > > ## `Resolv::DNS::SvcParam::Generic` > > This is an abstract class representing a SvcParamKey that is unknown > to this library. `Generic.create(key)` dynamically defines its > subclass for specific `key`. E.g., `Generic.create(667)` will define > `Generic::Key667`. > > This class holds SvcParamValue in its wire format. > > SvcParam with an unknown SvcParamKey will be decoded as a subclass of > this class. Also, users of this library can generate a non-supported > SvcParam if they know its wire format. > > ## `Resolv::DNS::SvcParams` > > This is conceptually a set of `SvcParam`s, whose elements have the > unique SvcParamKeys. It behaves like a set, and for convenience > provides indexing by SvcParamKey. > > - `#initialize(params)` takes an Enumerable of `SvcParam`s as the > initial content. If it contains `SvcParam`s with the duplicate key, > the one that appears last takes precedence. > - `#[](key)` fetches the `SvcParam` with the given key. The key can be > specified by its name (e.g., `:alpn`) or number (e.g., `1`). > - `#add(param)` adds a `SvcParam` to the set. If the set already has a > `SvcParam` with the same key, it will be replaced. > - `#delete(key)` deletes a `SvcParam` by its key and returns it. The key > can be specified by its name or number. * Update comments referring to draft-ietf-dnsop-svcb-https-12 Published as RFC 9460. https://datatracker.ietf.org/doc/rfc9460/ [draft-ietf-dnsop-svcb-https-12]: https://datatracker.ietf.org/doc/draft-ietf-dnsop-svcb-https/12/ [RFC 9460]: https://datatracker.ietf.org/doc/rfc9460/ [DDR]: https://datatracker.ietf.org/doc/draft-ietf-add-ddr/ --- lib/resolv.rb | 429 +++++++++++++++++++++++++++++++++ test/resolv/test_svcb_https.rb | 204 ++++++++++++++++ 2 files changed, 633 insertions(+) create mode 100644 test/resolv/test_svcb_https.rb diff --git a/lib/resolv.rb b/lib/resolv.rb index 0db6cc5..6fe9822 100644 --- a/lib/resolv.rb +++ b/lib/resolv.rb @@ -1618,6 +1618,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 @@ -1678,6 +1686,349 @@ 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 + + end + ## # A DNS query abstract class. @@ -2341,6 +2692,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 diff --git a/test/resolv/test_svcb_https.rb b/test/resolv/test_svcb_https.rb new file mode 100644 index 0000000..9b8b576 --- /dev/null +++ b/test/resolv/test_svcb_https.rb @@ -0,0 +1,204 @@ +# 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 +end