[ruby/resolv] Implement SVCB and HTTPS RRs

(https://github.com/ruby/resolv/pull/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/

https://github.com/ruby/resolv/commit/b3ced7f039
This commit is contained in:
Kasumi Hanazuki 2023-11-24 10:35:26 +09:00 committed by git
parent 1ffaff884e
commit 608a518b42
2 changed files with 633 additions and 0 deletions

View File

@ -1618,6 +1618,14 @@ class Resolv
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 @@ class Resolv
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(?<key>\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 @@ class Resolv
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

View File

@ -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