diff --git a/README.md b/README.md index 1b5b706..b0aa61f 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ The following is a (non-exhaustive) list of features : - Implemented RRs : A, AAAA, AFSDB, ANY, CAA, CERT, CNAME, DNAME, GPOS, HINFO, ISDN, LOC, MB, MG, MINFO, MR, MX, NAPTR, NS, NSAP, - NXT, OPT, PTR, PX, RP, RT, SOA, SPF, SRV, TKEY, TSIG, TXT, - WKS, X25, DNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, DS, DLV + NXT, OPT, PTR, PX, RP, RT, SOA, SPF, SRV, SVCB, HTTPS, TKEY, + TSIG, TXT, WKS, X25, DNSKEY, RRSIG, NSEC, NSEC3, NSEC3PARAM, DS, DLV - Generic RR types supported (RFC3597) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a2f44c0..865d6c7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,5 +1,14 @@ # Release Notes +## Unreleased + +* Add support for SVCB (type 64) and HTTPS (type 65) resource records - RFC 9460, including zone file parsing with TargetName origin resolution + * Recognises the registered SvcParamKey mnemonics `mandatory`, `alpn`, `no-default-alpn`, `port`, `ipv4hint`, `ipv6hint` (RFC 9460), `ech` (RFC 9848), `dohpath` (RFC 9461), `ohttp` (RFC 9540) and `docpath` (RFC 9953). Any other key reads and writes as `keyNNNNN` +* **Breaking (text output only)** - `Dnsruby::IPv6#to_s` and `#inspect` now emit the RFC 5952 canonical form: lowercase hex, no leading zeros, and `::` for the longest run of all-zero fields (never a single field) + * Affects `AAAA`, `IPSECKEY` and `APL` records and log output; code that string-compares the old uppercase form needs updating. Parsing and wire format are unchanged + * `::ffff:0:0/96` now uses the mixed notation (`::ffff:192.0.2.1`); the deprecated IPv4-compatible `::/96` range keeps the hexadecimal form, unlike `IPAddr#to_s` and `inet_ntop` +* Fix `Dnsruby::ZoneReader` splitting an unquoted character-string in two at a backslash-escaped quote - RFC 1035 Section 5.1 makes `\"` a literal quote, so `TXT \"escaped` is one string and was previously read as `"", "\"escaped"` + ## v1.74.0 * Resolve all configured nameservers in parallel diff --git a/lib/dnsruby/code_mappers.rb b/lib/dnsruby/code_mappers.rb index 03b255c..6e03d41 100644 --- a/lib/dnsruby/code_mappers.rb +++ b/lib/dnsruby/code_mappers.rb @@ -165,6 +165,8 @@ class Types < CodeMapper HIP = 55 # RFC 5205 CDS = 59 # RFC 7344 CDNSKEY = 60 # RFC 7344 + SVCB = 64 # RFC 9460 + HTTPS = 65 # RFC 9460 SPF = 99 # RFC 4408 UINFO = 100 # non-standard UID = 101 # non-standard diff --git a/lib/dnsruby/ipv6.rb b/lib/dnsruby/ipv6.rb index 966b5d3..03f7b88 100644 --- a/lib/dnsruby/ipv6.rb +++ b/lib/dnsruby/ipv6.rb @@ -108,12 +108,40 @@ def initialize(address) #:nodoc: # The raw IPv6 address as a String attr_reader :address + # The RFC 5952 canonical text representation of the address def to_s - address = sprintf("%X:%X:%X:%X:%X:%X:%X:%X", *@address.unpack("nnnnnnnn")) - unless address.sub!(/(^|:)0(:0)+(:|$)/, '::') - address.sub!(/(^|:)0(:|$)/, '::') + fields = @address.unpack("n8") + + # RFC 5952 section 5: an address carrying an embedded IPv4 address + # under the well-known ::ffff:0:0/96 prefix is written in the mixed + # hexadecimal / dotted decimal notation. + if fields[0, 5].all?(&:zero?) && fields[5] == 0xffff + return "::ffff:" + fields[6, 2].pack("n2").unpack("C4").join(".") + end + + # RFC 5952 section 4.2.3: "::" replaces the longest run of consecutive + # all-zero fields, and the first such run when several are equally long. + # Section 4.2.2: a run of a single all-zero field is never replaced. + run_start = nil + best_start, best_length = nil, 1 + fields.each_with_index do |field, i| + if field == 0 + run_start ||= i + length = i - run_start + 1 + best_start, best_length = run_start, length if length > best_length + else + run_start = nil + end + end + + # RFC 5952 sections 4.1 and 4.3: no leading zeros, and lowercase hex + strings = fields.map {|field| field.to_s(16)} + if best_start + strings[best_start, best_length] = [''] + strings.unshift('') if best_start == 0 + strings.push('') if best_start + best_length == fields.length end - return address + return strings.join(':') end def inspect #:nodoc: @@ -141,4 +169,4 @@ def hash return @address.hash end end -end \ No newline at end of file +end diff --git a/lib/dnsruby/resource/HTTPS.rb b/lib/dnsruby/resource/HTTPS.rb new file mode 100644 index 0000000..13dd707 --- /dev/null +++ b/lib/dnsruby/resource/HTTPS.rb @@ -0,0 +1,16 @@ +require 'dnsruby/resource/SVCB' + +module Dnsruby + class RR + # Class for DNS HTTPS resource records. + # + # RFC 9460 sec 9 + # + # The HTTPS-specific instantiation of the SVCB record, sharing its wire and + # presentation formats, for the "https" and "http" URI schemes. + class HTTPS < SVCB + ClassValue = nil #:nodoc: all + TypeValue = Types::HTTPS #:nodoc: all + end + end +end diff --git a/lib/dnsruby/resource/IN.rb b/lib/dnsruby/resource/IN.rb index 16c6947..c3b9c17 100644 --- a/lib/dnsruby/resource/IN.rb +++ b/lib/dnsruby/resource/IN.rb @@ -59,6 +59,8 @@ class RR Types::GPOS => GPOS, Types::NXT => NXT, Types::CAA => CAA, + Types::SVCB => SVCB, + Types::HTTPS => HTTPS, } #:nodoc: all # module IN contains ARPA Internet specific RRs diff --git a/lib/dnsruby/resource/SVCB.rb b/lib/dnsruby/resource/SVCB.rb new file mode 100644 index 0000000..37acef0 --- /dev/null +++ b/lib/dnsruby/resource/SVCB.rb @@ -0,0 +1,597 @@ +require 'base64' + +module Dnsruby + class RR + # Class for DNS Service Binding (SVCB) resource records. + # + # RFC 9460 + # + # The presentation format is: + # Name TTL Class SVCB SvcPriority TargetName SvcParams + # + # A SvcPriority of 0 is AliasMode, where TargetName aliases the service to + # another name; non-zero is ServiceMode, where the SvcParams describe the + # endpoint. + # + # RR.create also takes a Hash, whose :params values are in presentation + # format -- the text a zone file carries after the "=". #params_to_hash is + # the inverse of that Hash. + class SVCB < RR + ClassValue = nil #:nodoc: all + TypeValue = Types::SVCB #:nodoc: all + + # The registered SvcParamKey mnemonics: 0-4 and 6 from RFC 9460, 5 (ech) + # from RFC 9848, 7 (dohpath) from RFC 9461, 8 (ohttp) from RFC 9540 sec 4 + # and 10 (docpath) from RFC 9953 sec 3. Keys whose value format is not + # yet published as an RFC are left out, and read and write as keyNNNNN. + KEY_NAME_TO_NUM = { + 'mandatory' => 0, + 'alpn' => 1, + 'no-default-alpn' => 2, + 'port' => 3, + 'ipv4hint' => 4, + 'ech' => 5, + 'ipv6hint' => 6, + 'dohpath' => 7, + 'ohttp' => 8, + 'docpath' => 10, + }.freeze + + # The same table by number, for naming a key in presentation format and + # in error messages. A number with no mnemonic here becomes keyNNNNN. + KEY_NUM_TO_NAME = KEY_NAME_TO_NUM.invert.freeze + + # Keys whose presentation format requires a SvcParamValue. An empty + # docpath means the root path, and an unknown key may stand alone. + KEYS_REQUIRING_VALUE = [0, 1, 3, 4, 5, 6, 7].freeze + + # Keys defined to carry no value at all: no-default-alpn and ohttp. + KEYS_FORBIDDING_VALUE = [2, 8].freeze + + # RFC 9460 sec 14.3.2 reserves 65535 as an invalid SvcParamKey. + INVALID_KEY = 0xffff + + # The SvcPriority field (0-65535). Zero indicates AliasMode. + attr_reader :priority + + # Sets the SvcPriority, raising DecodeError unless the value is an + # integer in 0..65535. + def priority=(value) + @priority = to_uint16(value, 'SvcPriority') + end + + # The TargetName field, a Dnsruby::Name. + attr_accessor :target + + # The SvcParams, an ordered Hash mapping the numeric SvcParamKey to the + # SvcParamValue in wire format. See #params_to_hash for a readable view. + attr_accessor :params + + def from_hash(hash) #:nodoc: all + self.priority = hash[:priority] if hash[:priority] + @target = Name.create(hash[:target]) if hash[:target] + @params = {} + if hash[:params] + hash[:params].each do |key, value| + store_param(key_to_num(key.to_s), + value.nil? ? nil : decode_presentation_value(value.to_s)) + end + end + validate_svcparams(@priority, @params) + check_complete_pair + end + + def from_data(data) #:nodoc: all + @priority, @target, @params = data + end + + def from_string(input) #:nodoc: all + @params = {} + return if input.nil? || input.strip.empty? + + tokens = split_svcparams(input.strip) + self.priority = tokens.shift + target = tokens.shift + # Name.create(nil) would raise ArgumentError, not DecodeError. + raise DecodeError.new('SVCB record expects a TargetName after the SvcPriority') if target.nil? + + @target = Name.create(target) + + tokens.each do |token| + key, eq, value = token.partition('=') + num = key_to_num(key) + store_param(num, eq.empty? ? nil : decode_presentation_value(value)) + end + validate_svcparams(@priority, @params) + end + + def rdata_to_string #:nodoc: all + return '' if @priority.nil? || @target.nil? + + params_to_hash.inject(+"#{@priority} #{@target.to_s(true)}") do |s, (name, value)| + s << (value.nil? ? " #{name}" : " #{name}=#{value}") + end + end + + def encode_rdata(msg, canonical=false) #:nodoc: all + # An RRset delete (RFC 2136 sec 2.5.2) names the type with no RDATA at + # all, which is what Update#delete builds; domain_name.rb does the same. + if @priority.nil? && @target.nil? + return if [Classes::NONE, Classes::ANY].include?(klass) + end + + if @priority.nil? || @target.nil? + raise EncodeError.new('SVCB record needs both a SvcPriority and a ' \ + "TargetName, got #{@priority.inspect} and #{@target.inspect}") + end + + msg.put_pack('n', @priority) + # RFC 9460 sec 2.2: the TargetName MUST NOT be compressed. RFC 6840 sec + # 5.1: nor downcased for DNSSEC, SVCB post-dating RFC 4034. + msg.put_name(@target, true, false) + sorted_params.each do |num, value| + msg.put_pack('n', num) + msg.put_pack('n', value.bytesize) + msg.put_bytes(value) + end + end + + def self.decode_rdata(msg) #:nodoc: all + # The reading half of the empty record above. + return new([nil, nil, {}]) unless msg.has_remaining? + + priority, = msg.get_unpack('n') + target = msg.get_name + params = {} + last_key = nil + while msg.has_remaining? + key, length = msg.get_unpack('nn') + if last_key && key <= last_key + raise DecodeError.new('SvcParams must be in strictly increasing key order without duplicates') + end + last_key = key + # get_bytes returns what is there, so a length past the end would end + # this loop short. Message.decode catches that at the RDLENGTH + # boundary; RR.new_from_data, decoding RDATA alone, has none. + value = msg.get_bytes(length).to_s # nil past the end of the buffer + if value.bytesize != length + raise DecodeError.new("SvcParamValue for #{num_to_key(key)} is truncated: " \ + "#{length} octets declared, #{value.bytesize} present") + end + validate_param_value(key, value) + params[key] = value + end + validate_svcparams(priority, params) + new([priority, target, params]) + end + + # Returns the SvcParams as a Hash mapping the mnemonic (or "keyNNNNN") to + # its presentation-format value, nil for a value-less key. + def params_to_hash + sorted_params.each_with_object({}) do |(num, value), result| + result[num_to_key(num)] = decode_param_value(num, value) + end + end + + private + + # RFC 9460 sec 2.2: the SvcParams are written in increasing key order. + def sorted_params + (@params || {}).sort_by(&:first) + end + + # Neither field at all is the empty record above, which stays allowed. + def check_complete_pair + return if @priority.nil? == @target.nil? + + given, missing = @priority.nil? ? %w[target priority] : %w[priority target] + raise DecodeError.new("SVCB record given a #{given} but no #{missing}; both are mandatory") + end + + # RFC 9460 sec 2.1 forbids a duplicate key. + def store_param(num, value) + if @params.key?(num) + raise DecodeError.new("duplicate SvcParamKey: #{num_to_key(num)}") + end + @params[num] = encode_param_value(num, value) + end + + # The presentation <-> wire codec for the SvcParams, needed by + # .decode_rdata and by the instance methods alike. Extended and included + # so that the one `private` above covers both scopes. + module Codec #:nodoc: all + private + + # Translates a mnemonic or "keyNNNNN" token to its number. RFC 9460 sec + # 2.1 writes that number without leading zeros, so key01 is not alpn. + def key_to_num(key) + key = key.downcase + return KEY_NAME_TO_NUM[key] if KEY_NAME_TO_NUM.key?(key) + if (m = /\Akey(0|[1-9]\d*)\z/.match(key)) + num = m[1].to_i + raise DecodeError.new("SvcParamKey out of range: #{key}") if num > 0xffff + if num == INVALID_KEY + raise DecodeError.new("SvcParamKey #{INVALID_KEY} is reserved as invalid") + end + return num + end + raise DecodeError.new("Unknown SvcParamKey: #{key.inspect}") + end + + def num_to_key(num) + KEY_NUM_TO_NAME[num] || "key#{num}" + end + + # String#to_i and pack('n') fail silently: "99999" becomes 34463. + def to_uint16(value, field) + text = value.to_s.strip + unless /\A\d+\z/.match?(text) && text.to_i <= 0xffff + raise DecodeError.new("#{field} must be an integer in 0..65535, got #{value.inspect}") + end + text.to_i + end + + # Splits the SvcParams into whitespace-separated tokens, keeping + # double-quoted sections (which may contain spaces) intact and treating + # a backslash as escaping the octet after it. + def split_svcparams(str) + # Checked first: on an unterminated quote the scan below silently + # splits the value at its spaces instead. + if unterminated_quote?(str) + raise DecodeError.new("unterminated quoted SvcParamValue in #{str.inspect}") + end + + str.scan(/(?:"(?:\\.|[^"\\])*"|\\.|[^\s"])+/m) + end + + # Drops the escaped octets, then an odd number of quotes is left open. + def unterminated_quote?(str) + str.gsub(/\\./m, '').count('"').odd? + end + + # Runs before the per-key encoding -- see the value-list note below. + def decode_presentation_value(value) + unescape_char_string(unquote(value)) + end + + def unquote(value) + if value.length >= 2 && value.start_with?('"') && value.end_with?('"') + value[1...-1] + else + value + end + end + + # Resolves the backslash escapes of a character-string (\X and \DDD + # decimal), in octets, so a multibyte character becomes the octets it + # stands for. + def unescape_char_string(str) + str = str.b + result = +''.b + i = 0 + while i < str.length + c = str[i] + if c == '\\' + # RFC 1035 sec 5.1: the backslash escapes the octet after it. + raise DecodeError.new("escape at end of #{str.inspect} has nothing to escape") if + i + 1 >= str.length + + nxt = str[i + 1] + if nxt =~ /\d/ + # \DDD is exactly three digits, and stands for one octet. + unless str[i + 1, 3] =~ /\A\d{3}\z/ + raise DecodeError.new("escape #{str[i, 4].inspect} must be three digits") + end + octet = str[i + 1, 3].to_i + if octet > 0xff + raise DecodeError.new("escape \\#{str[i + 1, 3]} is not an octet value") + end + result << octet.chr + i += 4 + else + result << nxt + i += 2 + end + elsif c == '"' + # The surrounding quotes came off in unquote, so this one is data. + raise DecodeError.new("unescaped double quote in SvcParamValue #{str.inspect}") + else + result << c + i += 1 + end + end + result + end + + # Escapes raw octets into a presentation character-string. Four printable + # characters need the numeric form because each is consumed before + # escapes resolve: a token splits on the quote, a semicolon starts a + # comment, and RR.create strips parentheses even inside a quoted value. + def escape_char_string(str) + result = +'' + str.each_byte do |b| + if b == 0x5c # backslash + result << '\\\\' + elsif b <= 0x20 || b > 0x7e || # non-printable or space + b == 0x22 || b == 0x3b || # " and ; + b == 0x28 || b == 0x29 # ( and ) + result << format('\\%03d', b) + else + result << b.chr + end + end + result + end + + # RFC 9460 Appendix A: "Decoding of value-lists happens after + # character-string decoding", so the only escapes reaching the three + # methods below are the "\," and "\\" that layer leaves behind. + + def split_value_list(str) + str = str.b + items = [] + current = +''.b + i = 0 + while i < str.length + c = str[i] + if c == '\\' && i + 1 < str.length + current << str[i + 1] + i += 2 + elsif c == ',' + items << current + current = +''.b + i += 1 + else + current << c + i += 1 + end + end + items << current + items + end + + # Splits a value-list, rejecting the empty item a leading, trailing or + # doubled comma leaves behind. No SvcParamValue list admits one. + def value_list_items(num, value) + items = split_value_list(value) + if items.any?(&:empty?) + raise DecodeError.new( + "SvcParamValue for #{num_to_key(num)} must not contain an empty item") + end + items + end + + def join_value_list(items) + items.map do |item| + escaped = +''.b + item.to_s.each_byte do |b| + escaped << '\\' if b == 0x5c || b == 0x2c # backslash or comma + escaped << b.chr + end + escaped + end.join(',') + end + + # Packs a value-list of address text into an address hint. IPv4.create + # and IPv6.create raise ArgumentError rather than DecodeError, and the + # key already names the family their message would. + def join_addresses(num, value, klass) + value_list_items(num, value).map do |text| + klass.create(text).address + rescue ArgumentError + raise DecodeError.new( + "SvcParamValue for #{num_to_key(num)} is not an address: #{text.inspect}") + end.join + end + + # True when single-octet-length-prefixed items exactly tile the value, + # none of them empty -- the format shared by alpn and docpath. An empty + # value tiles trivially. + def length_prefixed_tile?(bytes) + i = 0 + while i < bytes.bytesize + len = bytes.getbyte(i) + return false if len.zero? || i + 1 + len > bytes.bytesize + i += 1 + len + end + true + end + + def pack_length_prefixed(num, items) + items.map do |item| + # pack('C') would truncate a longer item to a zero-length one and + # shift every item after it. + if item.bytesize > 0xff + raise DecodeError.new("#{num_to_key(num)} item must be at most 255 " \ + "octets, got #{item.bytesize}") + end + [item.bytesize].pack('C') + item + end.join + end + + # Counts in octets: see #decode_param_value. + def unpack_length_prefixed(bytes) + items = [] + i = 0 + while i < bytes.bytesize + len = bytes.getbyte(i) + items << bytes.byteslice(i + 1, len) + i += 1 + len + end + items + end + + # Rejects a value whose length does not fit the shape its key defines, + # which the decoders below would truncate or pad. Unlisted keys take + # any octets. + def validate_param_value(num, bytes) + if num == INVALID_KEY + raise DecodeError.new("SvcParamKey #{INVALID_KEY} is reserved as invalid") + end + + size = bytes.bytesize + expected = + case num + when 0 # mandatory + 'a non-empty, even number of octets' unless size.positive? && size.even? + when 1, 10 # alpn, docpath + # Zero docpath segments is the root path (RFC 9953 sec 3). + unless length_prefixed_tile?(bytes) && (num == 10 || size.positive?) + 'a sequence of non-empty length-prefixed items' + end + when *KEYS_FORBIDDING_VALUE + 'empty' unless size.zero? + when 3 # port + 'exactly 2 octets' unless size == 2 + when 4, 6 # ipv4hint, ipv6hint + width = num == 4 ? 4 : 16 + "a non-empty multiple of #{width} octets" unless size.positive? && (size % width).zero? + when 5, 7 # ech, dohpath + 'non-empty' unless size.positive? + end + return if expected.nil? + + raise DecodeError.new( + "SvcParamValue for #{num_to_key(num)} must be #{expected}, got #{size} octets") + end + + def validate_svcparams(priority, params) + validate_mandatory_value(params) + validate_self_consistency(priority, params) + end + + # RFC 9460 sec 8 on the mandatory list itself: it names "valid" + # SvcParamKeys, must not name itself, must not repeat a key, and must be + # in strictly increasing wire order. These describe one SvcParamValue, + # so they hold in either mode. + def validate_mandatory_value(params) + bytes = params[0] + return if bytes.nil? + + keys = bytes.unpack('n*') + raise DecodeError.new('mandatory must not list itself') if keys.include?(0) + if keys.include?(INVALID_KEY) + raise DecodeError.new("mandatory must not list key #{INVALID_KEY}, " \ + 'which sec 14.3.2 reserves as invalid') + end + unless keys.uniq.length == keys.length + raise DecodeError.new('mandatory must not list a key more than once') + end + # Presentation format is unordered and the encoder sorts it, so this + # only ever catches wire input. + unless keys == keys.sort + raise DecodeError.new('mandatory keys must be in strictly increasing order, got ' \ + "#{keys.map { |k| num_to_key(k) }.join(', ')}") + end + end + + # RFC 9460 sec 2.4.3: a ServiceMode RR is self-consistent when its + # SvcParams meet each other's requirements. Sec 8 gives mandatory one, + # and sec 7.1.1 gives no-default-alpn another. Sec 2.4.2 has recipients + # ignore an AliasMode RR's SvcParams, so neither applies at priority 0. + def validate_self_consistency(priority, params) + return if priority.nil? || priority.zero? + + if params.key?(2) && !params.key?(1) + raise DecodeError.new('no-default-alpn requires alpn') + end + + keys = params[0]&.unpack('n*') || [] + missing = keys.reject { |k| params.key?(k) } + return if missing.empty? + + raise DecodeError.new("mandatory lists #{missing.map { |k| num_to_key(k) }.join(', ')}, " \ + 'which the record does not contain') + end + + # Converts a SvcParamValue whose character-string escapes are already + # resolved (a String, or nil for a value-less key) into wire-format + # octets. + def encode_param_value(num, value) + if KEYS_FORBIDDING_VALUE.include?(num) + # An explicitly empty value encodes to the same nothing. + unless value.nil? || value.empty? + raise DecodeError.new("SvcParamValue for #{num_to_key(num)} must be empty") + end + return ''.b + end + if value.nil? && KEYS_REQUIRING_VALUE.include?(num) + raise DecodeError.new("#{num_to_key(num)} requires a SvcParamValue") + end + + wire = + case num + when 0 # mandatory + value_list_items(num, value).map { |k| key_to_num(k) }.sort. + map { |k| [k].pack('n') }.join + when 1, 10 # alpn, docpath + # The root path must not reach value_list_items, which rejects an + # empty item. + items = value.nil? || value.empty? ? [] : value_list_items(num, value) + pack_length_prefixed(num, items) + when 3 # port + [to_uint16(value, 'port')].pack('n') + when 4 # ipv4hint + join_addresses(num, value, IPv4) + when 5 # ech + # Base64.decode64 discards anything outside the alphabet. + begin + Base64.strict_decode64(value) + rescue ArgumentError + raise DecodeError.new( + "SvcParamValue for ech must be base64, got #{value.inspect}") + end + when 6 # ipv6hint + join_addresses(num, value, IPv6) + else # dohpath and unknown keys + value.to_s + end + + # dohpath and unknown keys hand the value straight back, and from_hash + # can pass in UTF-8, which MessageEncoder#put_bytes would reject. + wire = wire.b + + # Applied here too, so a zone file cannot build a record that + # .decode_rdata would refuse. + validate_param_value(num, wire) + wire + end + + # Converts a SvcParamValue into presentation format, or nil for a key + # written without one. The value normally arrives from the wire, but + # #params is public, so it may be any String the caller assigned, which + # is why the branches below work in octets rather than characters. + def decode_param_value(num, bytes) + case num + when 0 # mandatory + join_value_list(bytes.unpack('n*').map { |k| num_to_key(k) }) + when 1, 10 # alpn, docpath + # Alone among the value-lists, these items are arbitrary octets, so + # they need the character-string layer. Empty is the docpath root + # path, and RFC 9460 Appendix A has no empty char-string, so it + # stands alone. alpn is never empty. + unless bytes.empty? + escape_char_string(join_value_list(unpack_length_prefixed(bytes))) + end + when *KEYS_FORBIDDING_VALUE + nil + when 3 # port + bytes.unpack1('n').to_s + when 4 # ipv4hint + # .b: String#scan and IPv4/IPv6::new count characters. + join_value_list(bytes.b.scan(/.{4}/m).map { |a| IPv4.new(a).to_s }) + when 5 # ech + Base64.strict_encode64(bytes) + when 6 # ipv6hint + join_value_list(bytes.b.scan(/.{16}/m).map { |a| IPv6.new(a).to_s }) + else # dohpath and unknown keys + bytes.empty? ? nil : escape_char_string(bytes) + end + end + end + private_constant :Codec + extend Codec + include Codec + end + end +end diff --git a/lib/dnsruby/resource/generic.rb b/lib/dnsruby/resource/generic.rb index f46b8fe..308e548 100644 --- a/lib/dnsruby/resource/generic.rb +++ b/lib/dnsruby/resource/generic.rb @@ -169,3 +169,5 @@ def from_data(data) require 'dnsruby/resource/GPOS' require 'dnsruby/resource/NXT' require 'dnsruby/resource/CAA' +require 'dnsruby/resource/SVCB' +require 'dnsruby/resource/HTTPS' diff --git a/lib/dnsruby/zone_reader.rb b/lib/dnsruby/zone_reader.rb index 976c4c3..1ea8de8 100644 --- a/lib/dnsruby/zone_reader.rb +++ b/lib/dnsruby/zone_reader.rb @@ -215,9 +215,15 @@ def normalise_line(line, do_prefix_hack = false) # If we have text in the record, then ignore that in the parsing, and stick it on again at the end stored_line = ""; + # TXT quotes a whole token, but SVCB and HTTPS quote mid-token + # (alpn="h2,h3"), so remember which to put the text back as it came. + # A parenthesis separates too, and the strip below may remove it first. + stored_line_was_separate = true if (line.index('"') != nil) - stored_line = line[line.index('"'), line.length]; - line = line [0, line.index('"')] + quote_index = line.index('"') + stored_line_was_separate = (quote_index == 0) || !(/[\s()]/ =~ line[quote_index - 1]).nil? + stored_line = line[quote_index, line.length]; + line = line [0, quote_index] end if ((line[0,1] == " ") || (line[0,1] == "\t")) line = @last_name + " " + line @@ -343,7 +349,8 @@ def normalise_line(line, do_prefix_hack = false) line = line.strip if (stored_line && stored_line != "") - line += " " + stored_line.strip + line += " " if stored_line_was_separate + line += stored_line.strip end # We need to fix up any non-absolute names in the RR @@ -387,6 +394,17 @@ def normalise_line(line, do_prefix_hack = false) end line = parsed_rr.to_s end + # SVCB and HTTPS carry their TargetName in the middle of the RDATA + # (before the SvcParams), so the trailing-name logic above cannot reach + # it. Parse the record and qualify a relative TargetName against the + # origin; the root (".") is absolute, so the absolute? guard skips it. + if ([Types::SVCB, Types::HTTPS].include?type_was) + parsed_rr = Dnsruby::RR.create(line) + if (parsed_rr.target && !parsed_rr.target.absolute?) + parsed_rr.target = Name.create(parsed_rr.target.to_s + "." + @origin.to_s) + end + line = parsed_rr.to_s + end if (do_prefix_hack) return line + "\n", type_string, @last_name end diff --git a/test/tc_ipseckey.rb b/test/tc_ipseckey.rb index 785e184..6456083 100644 --- a/test/tc_ipseckey.rb +++ b/test/tc_ipseckey.rb @@ -43,7 +43,7 @@ def test_ipseckey {"0.d.4.0.3.0.e.f.f.f.3.f.0.1.2.01.0.0.0.0.0.2.8.B.D.0.1.0.0.2.ip6.arpa. 7200 IN IPSECKEY ( 10 2 2 2001:0DB8:0:8002::2000:1 AQNRU3mG7TVTO2BkR47usntb102uFJtugbo6BSGvgqt4AQ== )" => - ["2001:DB8:0:8002::2000:1", "AQNRU3mG7TVTO2BkR47usntb102uFJtugbo6BSGvgqt4AQ==", + ["2001:db8:0:8002::2000:1", "AQNRU3mG7TVTO2BkR47usntb102uFJtugbo6BSGvgqt4AQ==", 10, 2, 2]} ].each {|hash| hash.each {|txt, data| diff --git a/test/tc_ipv6.rb b/test/tc_ipv6.rb new file mode 100644 index 0000000..bf7ba53 --- /dev/null +++ b/test/tc_ipv6.rb @@ -0,0 +1,89 @@ +require_relative 'spec_helper' + +# Tests for the RFC 5952 text representation of IPv6 addresses +class TestIPv6 < Minitest::Test + + include Dnsruby + + # RFC 5952 section 4.1: leading zeros in a field are suppressed + def test_leading_zeros_suppressed + assert_equal('2001:db8::1', IPv6.create('2001:0db8:0000:0000:0000:0000:0000:0001').to_s) + assert_equal('2001:db8:0:1:1:1:1:1', IPv6.create('2001:0db8:0000:0001:0001:0001:0001:0001').to_s) + end + + # RFC 5952 section 4.2.1: a run of all-zero fields is replaced by '::' + def test_zero_run_shortened + assert_equal('::', IPv6.create('0:0:0:0:0:0:0:0').to_s) + assert_equal('::1', IPv6.create('0:0:0:0:0:0:0:1').to_s) + assert_equal('1::', IPv6.create('1:0:0:0:0:0:0:0').to_s) + assert_equal('2001:db8::1', IPv6.create('2001:db8:0:0:0:0:0:1').to_s) + end + + # RFC 5952 section 4.2.2: '::' must not shorten a single all-zero field + def test_single_zero_field_not_shortened + assert_equal('2001:db8:0:1:1:1:1:1', IPv6.create('2001:db8:0:1:1:1:1:1').to_s) + assert_equal('1:2:3:4:5:6:0:8', IPv6.create('1:2:3:4:5:6:0:8').to_s) + assert_equal('0:1:2:3:4:5:6:7', IPv6.create('0:1:2:3:4:5:6:7').to_s) + assert_equal('1:2:3:4:5:6:7:0', IPv6.create('1:2:3:4:5:6:7:0').to_s) + end + + # RFC 5952 section 4.2.3: the longest run is shortened. If there's a tie, + # shorten the first run. + def test_longest_zero_run_shortened + assert_equal('1:0:0:1::1', IPv6.create('1:0:0:1:0:0:0:1').to_s) + assert_equal('1::1:0:0:1:1', IPv6.create('1:0:0:1:0:0:1:1').to_s) + assert_equal('2001:db8::1:0:0:1', IPv6.create('2001:db8:0:0:1:0:0:1').to_s) + end + + # RFC 5952 section 4.3: downcase hexadecimal digits + def test_lowercase_hex + assert_equal('2001:db8::ab', IPv6.create('2001:0DB8::AB').to_s) + assert_equal('fe80::abcd:ef01', IPv6.create('FE80::ABCD:EF01').to_s) + assert_equal('2606:4700:20::681a:205', IPv6.create('2606:4700:20::681A:205').to_s) + end + + # RFC 5952 section 5: an embedded IPv4 address under the well-known + # ::ffff:0:0/96 prefix uses mixed notation + def test_ipv4_mapped_mixed_notation + assert_equal('::ffff:192.0.2.1', IPv6.create('::ffff:192.0.2.1').to_s) + assert_equal('::ffff:192.0.2.1', IPv6.create('0:0:0:0:0:ffff:c000:201').to_s) + assert_equal('::ffff:0.0.0.0', IPv6.create('::ffff:0:0').to_s) + end + + # Only ::ffff:0:0/96 gets the mixed notation. IPv4-compatible addresses + # (::/96) are deprecated by RFC 4291 section 2.5.5.1, so a dotted quad there + # would imply a semantic that no longer exists - they keep the hexadecimal + # form. Note this is a deliberate departure from IPAddr and inet_ntop, which + # write ::a:b as ::0.10.0.11. + def test_non_mapped_addresses_stay_hexadecimal + assert_equal('::', IPv6.create('::').to_s) + assert_equal('::1', IPv6.create('::1').to_s) + assert_equal('::102:304', IPv6.create('::1.2.3.4').to_s) + assert_equal('64:ff9b::102:304', IPv6.create('64:ff9b::1.2.3.4').to_s) + end + + # The mixed notation requires the whole ::ffff:0:0/96 prefix, not just 0xffff + # in the sixth field + def test_mixed_notation_requires_the_full_prefix + assert_equal('1::ffff:102:304', IPv6.create('1::ffff:1.2.3.4').to_s) + assert_equal('::1:ffff:102:304', IPv6.create('::1:ffff:1.2.3.4').to_s) + assert_equal('::fffe:102:304', IPv6.create('::fffe:1.2.3.4').to_s) + end + + # Whatever we emit must be readable back as the same address + def test_output_round_trips_through_create + [ + '2001:0DB8:0:8002::2000:1', + '2001:db8:0:1:1:1:1:1', + '1:0:0:1:0:0:0:1', + '::ffff:192.0.2.1', + '::', + '::1', + '1::', + 'fe80::1', + ].each do |text| + address = IPv6.create(text) + assert_equal(address, IPv6.create(address.to_s), "#{text} did not survive to_s / create") + end + end +end diff --git a/test/tc_svcb.rb b/test/tc_svcb.rb new file mode 100644 index 0000000..12d850b --- /dev/null +++ b/test/tc_svcb.rb @@ -0,0 +1,659 @@ +require_relative 'spec_helper' + +# Tests for the SVCB (type 64) and HTTPS (type 65) resource records, RFC 9460. +class TestSVCB < Minitest::Test + + include Dnsruby + + # Encodes just the RDATA of an RR to a hex string. + def rdata_hex(rr) + enc = Dnsruby::MessageEncoder.new + rr.encode_rdata(enc) + enc.to_s.unpack1('H*') + end + + # Builds an RR from the RDATA half of its presentation format. + def svcb(rdata, type = 'SVCB') + RR.create("example.com. 3600 IN #{type} #{rdata}") + end + + # Builds an RR from its RDATA in wire-format hex. + def decode(hex, type = RR::IN::SVCB) + type.decode_rdata(Dnsruby::MessageDecoder.new([hex].pack('H*'))) + end + + # Asserts every case raises DecodeError. A Hash labels its cases for the + # failure message; an Array labels each case with itself. + def assert_all_rejected(cases) + cases = cases.to_h { |input| [input, input] } unless cases.is_a?(Hash) + cases.each { |label, input| assert_raises(Dnsruby::DecodeError, label.to_s) { yield(input) } } + end + + # Round-trips an RR through a Message and asserts wire and presentation equality. + def assert_roundtrip(rr) + m = Dnsruby::Message.new + m.add_additional(rr) + m2 = Dnsruby::Message.decode(m.encode) + rr2 = m2.additional[0] + assert_equal(rr, rr2, 'record should survive encode/decode') + assert_equal(rr.rdata_to_string, rr2.rdata_to_string, 'presentation should survive encode/decode') + rr2 + end + + # HTTPS shares SVCB's whole implementation by subclassing it. + def test_types_registered + assert_equal(64, Types::SVCB) + assert_equal(65, Types::HTTPS) + assert_equal('SVCB', Types.new(64).string) + assert_equal('HTTPS', Types.new(65).string) + assert(RR::IN::HTTPS < RR::SVCB) + assert_equal(RR::IN::HTTPS, svcb('1 .', 'HTTPS').class) + end + + def test_basic_parse + rr = RR.create('crypto.cloudflare.com. 300 IN HTTPS 1 . alpn="h2,h3" ipv4hint=162.159.135.79') + assert_instance_of(RR::IN::HTTPS, rr) + assert_equal(Types::HTTPS, rr.type) + assert_equal(1, rr.priority) + assert_equal('.', rr.target.to_s(true)) + assert_equal(%w[h2 h3].join(','), rr.params_to_hash['alpn']) + assert_equal('162.159.135.79', rr.params_to_hash['ipv4hint']) + assert_roundtrip(rr) + end + + def test_alias_mode + rr = svcb('0 foo.example.com.') + assert_equal(Types::SVCB, rr.type) + assert_equal(0, rr.priority) + assert_equal('foo.example.com.', rr.target.to_s(true)) + assert_empty(rr.params) + assert_roundtrip(rr) + + # RFC 9460 sec 2.5.1: a "." TargetName in AliasMode means the service does + # not exist, which is a meaningful record rather than one to reject. + root = svcb('0 .', 'HTTPS') + assert_equal('0 .', root.rdata_to_string) + assert_roundtrip(root) + end + + # RFC 9460 sec 2.4.3 calls a ServiceMode RR self-consistent when its SvcParams + # meet each other's requirements. Sec 7.1.1 gives one of the two: alpn must + # accompany no-default-alpn. The other, that mandatory name only keys the + # record carries (sec 8), is the "mandatory key absent" case in the two + # rejection tables below. + def test_no_default_alpn_requires_alpn + assert_raises(Dnsruby::DecodeError) { svcb('1 . no-default-alpn') } + # And on the wire: key 2 present, with no alpn. + assert_raises(Dnsruby::DecodeError) { decode('000100' '0002' '0000') } + + rr = svcb('1 . alpn=h2 no-default-alpn') + assert_equal('1 . alpn=h2 no-default-alpn', rr.rdata_to_string) + assert_roundtrip(rr) + end + + # sec 2.4.2: "In AliasMode, recipients MUST ignore any SvcParams that are + # present" -- ignore, so they stay on the record rather than being dropped or + # rejected, and the sec 2.4.3 rules above do not apply at priority 0. The + # sec 8 rules about the mandatory value itself still do. + def test_alias_mode_ignores_svcparams + ['0 foo.example.com. alpn=h2', + '0 foo.example.com. mandatory=alpn', + '0 foo.example.com. no-default-alpn'].each do |rdata| + rr = svcb(rdata) + assert_equal(rdata, rr.rdata_to_string) + assert_roundtrip(rr) + end + + assert_all_rejected(['0 foo.example.com. mandatory=mandatory', + '0 foo.example.com. mandatory=alpn,alpn alpn=h2']) { |r| svcb(r) } + end + + # RFC 6840 sec 5.1: a TargetName is not downcased for DNSSEC. Decoded from the + # wire, because Name.create lowercases a presentation string. + def test_targetname_case_preserved_in_canonical_encoding + # priority 0, TargetName "Foo.Example.COM." with mixed case. + mixed_case_rdata = '000003466f6f074578616d706c6503434f4d00' + rr = decode(mixed_case_rdata) + assert_equal(%w[Foo Example COM], rr.target.to_a.map(&:to_s)) + + enc = Dnsruby::MessageEncoder.new + rr.encode_rdata(enc, true) # canonical + assert_equal(mixed_case_rdata, enc.to_s.unpack1('H*')) + end + + # RFC 9460 sec 2.2: the TargetName MUST NOT be compressed. The owner name here + # ends in the same two labels, so a compressing encoder would write a pointer + # to it and still decode correctly, which is why this counts octets rather + # than round-tripping. + def test_targetname_not_compressed + m = Dnsruby::Message.new + m.add_answer(svcb('0 foo.example.com.')) + # RDLENGTH=19, SvcPriority=0, then the TargetName in full. Compressed, the + # tail would be RDLENGTH=8 ... 03666f6f c00c. + assert_equal('0013' '0000' '03666f6f076578616d706c6503636f6d00', + m.encode[-21..-1].unpack1('H*')) + end + + # RFC 9460 Appendix D wire-format test vectors, in the appendix's own order and + # labelled with its figure numbers: Figure 2 is the whole of D.1 and Figures 3 + # to 9 are D.2. Figure 10 prints one record in two presentation formats, so it + # needs a comparison rather than a table entry: test_escaped_alpn_value has it. + def test_rfc9460_wire_vectors + { + # Figure 2: AliasMode + 'example.com. 3600 IN HTTPS 0 foo.example.com.' => + '000003666f6f076578616d706c6503636f6d00', + # Figure 3: TargetName is "." + 'example.com. 3600 IN SVCB 1 .' => + '000100', + # Figure 4: specifies a port + 'example.com. 3600 IN SVCB 16 foo.example.com. port=53' => + '001003666f6f076578616d706c6503636f6d00000300020035', + # Figure 5: a generic key and unquoted value + 'example.com. 7200 IN SVCB 1 foo.example.com. key667=hello' => + '0001' '03666f6f076578616d706c6503636f6d00' '029b' '0005' '68656c6c6f', + # Figure 6: a generic key and quoted value with a decimal escape + 'example.com. 7200 IN SVCB 1 foo.example.com. key667="hello\210qoo"' => + '0001' '03666f6f076578616d706c6503636f6d00' '029b' '0009' '68656c6c6fd2716f6f', + # Figure 7: two quoted IPv6 hints + 'example.com. 7200 IN SVCB 1 foo.example.com. ipv6hint="2001:db8::1,2001:db8::53:1"' => + '0001' '03666f6f076578616d706c6503636f6d00' '0006' '0020' \ + '20010db8000000000000000000000001' '20010db8000000000000000000530001', + # Figure 8: an IPv6 hint using the embedded IPv4 syntax + 'example.com. 7200 IN SVCB 1 example.com. ipv6hint="2001:db8:122:344::192.0.2.33"' => + '0001' '076578616d706c6503636f6d00' '0006' '0010' \ + '20010db80122034400000000c0000221', + # Figure 9: SvcParamKey ordering is arbitrary in presentation format but + # sorted in wire format + 'example.com. 7200 IN SVCB 16 foo.example.org. alpn=h2,h3-19 mandatory=ipv4hint,alpn ipv4hint=192.0.2.1' => + '001003666f6f076578616d706c65036f7267000000000400010004000100090268320568332d313900040004c0000201', + }.each do |text, expected_hex| + rr = RR.create(text) + assert_equal(expected_hex, rdata_hex(rr), "wire format mismatch for: #{text}") + assert_roundtrip(rr) + end + end + + def test_all_known_params + rr = RR.create('example.com. 3600 IN SVCB 1 svc.example.net. ' \ + 'mandatory=alpn,ipv4hint alpn=h2,h3 no-default-alpn port=8443 ' \ + 'ipv4hint=192.0.2.1,192.0.2.2 ipv6hint=2001:db8::1 ech=Zm9vYmFy') + params = rr.params_to_hash + assert_equal('alpn,ipv4hint', params['mandatory']) + assert_equal('h2,h3', params['alpn']) + assert_equal('', rr.params[2], 'no-default-alpn must be stored, value-less') + assert_nil(params['no-default-alpn']) + assert_equal('8443', params['port']) + assert_equal('192.0.2.1,192.0.2.2', params['ipv4hint']) + assert_equal('Zm9vYmFy', params['ech']) + assert_roundtrip(rr) + end + + # RFC 9460 sec 2.2: SvcParams are written in increasing key order, which is + # numeric, so mandatory(0) alpn(1) port(3) rather than anything alphabetical. + def test_params_sorted_on_output + rr = svcb('1 . port=443 alpn=h2 mandatory=alpn', 'HTTPS') + assert_equal('1 . mandatory=alpn alpn=h2 port=443', rr.rdata_to_string) + end + + # A literal comma and backslash within an ALPN id must survive; both reach the + # zone file doubled. RFC 9460 Appendix D.2 Figure 10 prints the record twice, + # quoted and unquoted, so both spellings must give the same wire value. + def test_escaped_alpn_value + rr, unquoted = ['alpn="f\\\\\\\\oo\\\\,bar,h2"', + 'alpn=f\\\\\\092oo\\092,bar,h2'].map do |param| + RR.create("example.com. 7200 IN SVCB 16 foo.example.org. #{param}") + end + # Wire: [8]"f\oo,bar" [2]"h2" + assert_equal('08665c6f6f2c626172026832', rr.params[1].unpack1('H*')) + assert_equal(rr.params, unquoted.params, 'the RFC prints these as one record') + rr2 = assert_roundtrip(rr) + # The presentation of rr2 must re-parse to the same wire value. + rr3 = RR.create("example.com. IN SVCB #{rr2.rdata_to_string}") + assert_equal(rr.params, rr3.params) + end + + # The RFC 9460 Appendix A example of "decoding of value-lists happens after + # character-string decoding": two spellings it states are equivalent. + def test_value_list_decoded_after_char_string + quoted, unquoted = ['alpn="part1,part2,part3\\\\,part4\\\\\\\\"', + 'alpn=part1\\,\\p\\a\\r\\t2\\044part3\\092,part4\\092\\\\'].map do |param| + RR.create("example.com. 7200 IN SVCB 1 . #{param}") + end + assert_equal('0570617274310570617274320c70617274332c70617274345c', + quoted.params[1].unpack1('H*')) + assert_equal(quoted.params, unquoted.params, 'the RFC states these spellings are equivalent') + # The emitter must write back at the same depth it read. + assert_equal('1 . alpn=part1,part2,part3\\\\,part4\\\\\\\\', quoted.rdata_to_string) + end + + # RFC 9461 sec 5 makes dohpath a UTF-8 URI Template, so a value can be + # multibyte, and every wire length is an octet count. + def test_multibyte_values_encode_as_octets + { 'dohpath=/é{?dns}' => [7, '2fc3a97b3f646e737d'], + 'alpn=hé2' => [1, '0468c3a932'], # [4]"h\xC3\xA92" + 'key667="é\210q"' => [667, 'c3a9d271'], + }.each do |param, (num, hex)| + rr = svcb("1 . #{param}") + assert_equal(hex, rr.params[num].unpack1('H*'), param) + assert_roundtrip(rr) + end + end + + # Four printable octets are consumed before escapes resolve, so the emitter + # must write them numerically to survive a re-parse: space, semicolon, quote + # and the parentheses RR.create strips even inside a quoted value. + def test_presentation_escaping + { # dohpath = /a()o + '00010000070005' + '2f6128296f' => '1 . dohpath=/a\040\041o', + # dohpath = /a" né + '00010000070007' + '2f6122206ec3a9' => '1 . dohpath=/a\034\032n\195\169', + # alpn = "a b", key667 = "x;y" + '000100' + '0001' + '0004' + '03612062' + '029b' + '0003' + '783b79' => + '1 . alpn=a\032b key667=x\059y', + }.each do |hex, presentation| + rr = decode(hex, RR::IN::HTTPS) + assert_equal(presentation, rr.rdata_to_string) + + rr2 = svcb(rr.rdata_to_string, 'HTTPS') + assert_equal(rr.params, rr2.params, presentation) + end + end + + # The literal escape another implementation may emit is equally valid, and + # SvcParamKeys are case-insensitive in both forms. + def test_alternative_presentation_spellings + { '1 . alpn=a\ b key667=x\"y' => '1 . alpn=a\032b key667=x\034y', + '1 . ALPN=h2 KEY667=hi' => '1 . alpn=h2 key667=hi', + }.each do |rdata, expected| + assert_equal(expected, svcb(rdata).rdata_to_string) + end + end + + # A \DDD escape stands for a single octet, so 255 is as high as it can reach. + def test_ddd_escape_octet_boundary + rr = svcb('1 . key667="\000\255"') + assert_equal('00ff', rr.params[667].unpack1('H*')) + assert_equal('1 . key667=\000\255', rr.rdata_to_string) + assert_roundtrip(rr) + end + + # A SvcParamValue must be stored as octets, or a multibyte one raises + # Encoding::CompatibilityError out of Message#encode. + def test_from_hash_stores_octets + rr = RR.create(name: 'example.com.', type: Types::HTTPS, ttl: 3600, priority: 1, target: '.', + params: { alpn: 'hé2', dohpath: '/é{?dns}', key667: 'é' }) + rr.params.each do |num, value| + assert_equal(Encoding::BINARY, value.encoding, "key #{num} must hold octets") + end + assert_equal(9, rr.params[7].bytesize) + assert_roundtrip(rr) + end + + # A hash value is presentation format, the same text a zone file carries after + # the "=", so the two constructors must agree. + def test_from_hash_matches_from_string + { 'alpn=h2,h3 port=443' => { alpn: 'h2,h3', port: 443 }, + 'dohpath=/é{?dns}' => { dohpath: '/é{?dns}' }, + 'key667=é' => { key667: 'é' }, + 'key667="a\032b" alpn=h2,h\,3' => { key667: '"a\032b"', alpn: 'h2,h\,3' }, + }.each do |rdata, params| + from_string = svcb("1 . #{rdata}", 'HTTPS') + from_hash = RR.create(name: 'example.com.', type: Types::HTTPS, ttl: 3600, + priority: 1, target: '.', params: params) + assert_equal(from_string, from_hash, rdata) + assert_equal(from_string.rdata_to_string, from_hash.rdata_to_string, rdata) + end + end + + # params_to_hash emits presentation format, which is exactly what from_hash + # accepts. + def test_params_to_hash_round_trips_through_from_hash + # key667 holds one of every octet the emitter escapes: space, semicolon, + # quote, both parens, backslash, comma, a multibyte pair and NUL. + nasty = [0x61, 0x20, 0x3b, 0x22, 0x28, 0x29, 0x5c, 0x2c, 0xc3, 0xa9, 0x00].pack('C*') + from_wire = RR::IN::SVCB.decode_rdata(Dnsruby::MessageDecoder.new( + ['0001' '00' '029b'].pack('H*') + [nasty.bytesize].pack('n') + nasty)) + assert_equal(nasty, from_wire.params[667]) + + all_keys = RR.create('example.com. 3600 IN SVCB 1 svc.example.net. ' \ + 'mandatory=alpn,port alpn=h2,h3 no-default-alpn port=8443 ' \ + 'ipv4hint=192.0.2.1,192.0.2.2 ipv6hint=2001:db8::1 ' \ + 'ech=Zm9vYmFy dohpath=/dns{?dns}') + + [from_wire, all_keys].each do |orig| + rebuilt = RR.create(name: 'example.com.', type: Types::SVCB, ttl: 3600, + priority: orig.priority, target: orig.target.to_s(true), + params: orig.params_to_hash) + # from_wire has no name/type/class, so compare the RDATA only. + assert_equal(orig.params, rebuilt.params, orig.rdata_to_string) + assert_equal(orig.rdata_to_string, rebuilt.rdata_to_string) + end + end + + # ech carries a base64 ECHConfigList (RFC 9460 sec 5), and Base64.decode64 + # discards anything outside the alphabet rather than failing. + def test_ech_requires_valid_base64 + assert_all_rejected( + ['1 . ech=!!!not-base64!!!', # decoded to 6 octets, emitted as "ech=notbase6" + '1 . ech=Zm9vYmF@', # single stray character + '1 . ech=aGk', # unpadded + '1 . ech='] # an ECHConfigList is length-prefixed, never empty + ) { |r| svcb(r) } + + rr = svcb('1 . ech=Zm9vYmFy') + assert_equal('foobar', rr.params[5]) + assert_equal('1 . ech=Zm9vYmFy', rr.rdata_to_string) + assert_roundtrip(rr) + end + + # RFC 9461 sec 5 requires a URI Template containing the "dns" variable, so + # there is no empty dohpath, in any of its three spellings. + def test_dohpath_requires_a_value + assert_all_rejected(['1 . dohpath', '1 . dohpath=', '1 . dohpath=""']) { |r| svcb(r, 'HTTPS') } + + rr = svcb('1 . dohpath=/dns{?dns}', 'HTTPS') + assert_equal('1 . dohpath=/dns{?dns}', rr.rdata_to_string) + assert_roundtrip(rr) + end + + # RFC 9540 sec 4: "Both the presentation and wire-format values for the + # 'ohttp' parameter MUST be empty", so it behaves like no-default-alpn. + def test_ohttp + ['1 . ohttp', '1 . ohttp=""'].each do |rdata| + rr = svcb(rdata, 'HTTPS') + assert_equal('', rr.params[8]) + assert_nil(rr.params_to_hash['ohttp']) + assert_equal('1 . ohttp', rr.rdata_to_string) + assert_roundtrip(rr) + end + + assert_raises(Dnsruby::DecodeError) { svcb('1 . ohttp=x', 'HTTPS') } + # And the same on the wire: key 8 with a one-octet value. + assert_raises(Dnsruby::DecodeError) { decode('000100' '0008' '0001' '61') } + end + + # RFC 9953 sec 3: length-prefixed segments like alpn, except that zero + # segments is legal and means the root path. + def test_docpath + rr = svcb('1 . docpath=.well-known,core', 'HTTPS') + assert_equal(['0b2e77656c6c2d6b6e6f776e04636f7265'].pack('H*'), rr.params[10]) + assert_equal('.well-known,core', rr.params_to_hash['docpath']) + assert_roundtrip(rr) + + # The root path: an empty value, however it is written, goes back out as + # the standalone key RFC 9953 sec 3.2.1 spells it with. + ['1 . docpath', '1 . docpath=""', '1 . docpath='].each do |rdata| + root = svcb(rdata, 'HTTPS') + assert_equal('', root.params[10], rdata) + assert_nil(root.params_to_hash['docpath'], rdata) + assert_equal('1 . docpath', root.rdata_to_string, rdata) + assert_roundtrip(root) + end + + # An empty *segment* is still an error -- only the whole list may be empty. + assert_raises(Dnsruby::DecodeError) { svcb('1 . docpath=a,,b', 'HTTPS') } + # A zero-length segment on the wire is the same error. + assert_raises(Dnsruby::DecodeError) { decode('000100' '000a' '0001' '00') } + end + + # RFC 9460 sec 14.3.2 reserves 65535 as an invalid SvcParamKey. 65534 is + # the top of the private-use range and stays usable. + def test_key_65535_reserved + assert_raises(Dnsruby::DecodeError) { svcb('1 . key65535=a') } + assert_raises(Dnsruby::DecodeError) { decode('000100' 'ffff' '0001' '61') } + + # A mandatory list naming it. AliasMode has no self-consistency rule to + # catch the dangling key, so validity is checked on the value itself. + assert_raises(Dnsruby::DecodeError) { decode('000100' '0000' '0002' 'ffff') } + assert_raises(Dnsruby::DecodeError) { decode('000000' '0000' '0002' 'ffff') } + + rr = svcb('1 . key65534=a') + assert_equal('1 . key65534=a', rr.rdata_to_string) + assert_roundtrip(rr) + end + + # RFC 9460 sec 2.1 writes the number of an unknown key "without leading + # zeros", so a padded spelling is not that key by another name. Left accepted, + # key01 read as alpn and its value was parsed as an ALPN list. + def test_key_leading_zeros_rejected + assert_all_rejected(['key01=h2', 'mandatory=key01,alpn alpn=h2']) { |p| svcb("1 . #{p}") } + + # key0 is the unpadded spelling of mandatory, so the zero itself stays legal. + assert_equal('1 . mandatory=alpn alpn=h2', + svcb('1 . key0=alpn alpn=h2').rdata_to_string) + end + + # RFC 9460 sec 8 puts the mandatory keys in "strictly increasing numeric + # order" on the wire. Presentation format has no such rule and the encoder + # sorts, so an unsorted zone-file list is fine. + def test_mandatory_wire_order + tail = '0001' '0003' '026832' '0003' '0002' '01bb' # alpn=h2 port=443 + ascending = '000100' + '0000' + '0004' + '00010003' + tail + descending = '000100' + '0000' + '0004' + '00030001' + tail + + rr = decode(ascending) + assert_equal('1 . mandatory=alpn,port alpn=h2 port=443', rr.rdata_to_string) + + assert_raises(Dnsruby::DecodeError) { decode(descending) } + + from_zone = svcb('1 . mandatory=port,alpn alpn=h2 port=443') + assert_equal(['00010003'].pack('H*'), from_zone.params[0], 'the encoder must sort') + assert_equal(rr.params, from_zone.params) + end + + # priority= is a real setter, so it range-checks like the presentation format. + def test_priority_setter_validates + rr = svcb('1 .') + rr.priority = 65535 + assert_equal(65535, rr.priority) + assert_equal('65535 .', rr.rdata_to_string) + + assert_all_rejected(['65536', 65536, -1, 'abc', nil]) { |bad| rr.priority = bad } + assert_equal(65535, rr.priority, 'a rejected assignment must not change the record') + end + + # String#to_i and pack('n') fail silently, turning a typo into a wrong number. + def test_out_of_range_numeric_fields_rejected + assert_all_rejected(['1 . port=65536', '1 . port=-1', '1 . port=abc', + '65536 .', 'foo.example. 1']) { |r| svcb(r) } + # 65535 is in range -- the check must not be off by one. + rr = svcb('65535 . port=65535') + assert_equal('65535 . port=65535', rr.rdata_to_string) + end + + # Presentation-format records a parser must reject: all ten of RFC 9460 + # Appendix D.3 (Figure 12 alone lists five value-less keys), empty value-list + # items, and addresses and escapes that fail underneath as ArgumentError or + # RangeError. All must surface as DecodeError, or a zone loader rescuing one + # line at a time dies on the whole file. + def test_presentation_failures_rejected + assert_all_rejected( + 'duplicate key' => '1 foo.example.com. key123=abc key123=def', + 'mandatory with no value' => '1 foo.example.com. mandatory', + 'alpn with no value' => '1 foo.example.com. alpn', + 'port with no value' => '1 foo.example.com. port', + 'ipv4hint with no value' => '1 foo.example.com. ipv4hint', + 'ipv6hint with no value' => '1 foo.example.com. ipv6hint', + 'port is not a number' => '1 foo.example.com. port=NaN', + 'no-default-alpn has value' => '1 foo.example.com. no-default-alpn=abc', + 'mandatory key absent' => '1 foo.example.com. mandatory=key123', + 'mandatory lists itself' => '1 foo.example.com. mandatory=mandatory', + 'mandatory lists a dup' => '1 foo.example.com. mandatory=key123,key123 key123=abc', + 'empty alpn' => '1 . alpn=', + 'alpn trailing comma' => '1 . alpn=h2,', + 'alpn leading comma' => '1 . alpn=,h2', + 'empty mandatory' => '1 . mandatory=', + 'empty ipv4hint' => '1 . ipv4hint=', + 'ipv4hint trailing comma' => '1 . ipv4hint=192.0.2.1,', + 'empty ipv6hint' => '1 . ipv6hint=', + 'ipv4 octet out of range' => '1 . ipv4hint=999.1.1.1', + 'ipv4 too short' => '1 . ipv4hint=1.2.3', + 'second ipv4 invalid' => '1 . ipv4hint=192.0.2.1,not-an-address', + 'ipv6 is not hex' => '1 . ipv6hint=zz::1', + 'escape above 255' => '1 . key667="\256"', + 'escape far above 255' => '1 . key667="\999"' + ) { |r| svcb(r) } + end + + # RFC 1035 sec 5.1 escapes: a backslash must escape something, \DDD is exactly + # three digits, and an unescaped quote is not character-string data. An + # unterminated quote must be reported as one, not as an unknown key. + def test_invalid_escapes_and_quotes_rejected + assert_all_rejected( + # Unquoted: inside quotes this escapes the closing quote instead. + 'trailing backslash' => '1 . key667=abc\\', + 'one-digit escape' => '1 . key667="\\1"', + 'two-digit escape' => '1 . key667="\\12"', + 'digits then non-digit' => '1 . key667="\\12x"', + 'unescaped quote mid-token' => '1 . dohpath=/a"b"c' + ) { |r| svcb(r) } + + ['1 . key667="abc\\"', '1 . key667="abc', '1 . alpn="h2,h3 port=443'].each do |rdata| + e = assert_raises(Dnsruby::DecodeError, rdata) { svcb(rdata) } + assert_match(/unterminated quoted SvcParamValue/, e.message, rdata) + end + + # The valid neighbours of each case must still work. + assert_equal('1 . key667=a\\\\\\\\b', + svcb('1 . key667="a\\\\\\\\b"').rdata_to_string) + assert_equal('1 . key667=\\012', + svcb('1 . key667="\\012"').rdata_to_string) + assert_equal('1 . key667=\\034', + svcb('1 . key667="\\034"').rdata_to_string) + end + + # The length prefix is a single octet, so a longer id has no representation. + def test_oversized_alpn_id_rejected + assert_raises(Dnsruby::DecodeError) { svcb("1 . alpn=#{'a' * 256}") } + # 255 octets fits -- the check must not be off by one. + rr = svcb("1 . alpn=#{'a' * 255}") + assert_equal(256, rr.params[1].bytesize) + assert_roundtrip(rr) + end + + # RFC 2136 sec 2.5.2: "delete an RRset" is the type with class ANY and no + # RDATA, which is what Update#delete builds, and must encode to a zero RDLENGTH. + def test_empty_rdata + rr = RR.create('example.com. 3600 IN SVCB') + assert_nil(rr.priority) + assert_empty(rr.params) + assert_equal('', rr.rdata_to_string) + # The same record built from a Hash, where the two fields are set separately. + assert_equal('', RR.create(name: 'example.com.', type: Types::SVCB).rdata_to_string) + + update = Dnsruby::Update.new('example.com.') + deleted = update.delete('www.example.com.', 'SVCB') + assert_equal(Dnsruby::Classes.ANY, deleted.klass) + encoder = Dnsruby::MessageEncoder.new + encoder.put_rr(deleted) + # ... TYPE=SVCB(64) CLASS=ANY(255) TTL=0 RDLENGTH=0, and nothing after it. + assert_equal('0040' '00ff' '00000000' '0000', encoder.to_s[-10..-1].unpack1('H*')) + + # And the whole message must survive the round trip a resolver puts it through. + decoded = Dnsruby::Message.decode(update.encode).authority.first + assert_equal(Types::SVCB, decoded.type) + assert_nil(decoded.target) + assert_empty(decoded.params) + end + + # Outside that dynamic-update case an incomplete record is a bug, and a Hash + # sets the two mandatory fields independently, so half a record is buildable. + def test_incomplete_record_rejected + [{ priority: 1 }, { target: 'foo.example.com.' }].each do |fields| + assert_raises(Dnsruby::DecodeError, fields.inspect) do + RR.create({ name: 'example.com.', type: Types::SVCB }.merge(fields)) + end + end + + ['1', '0', '1 '].each do |rdata| + assert_raises(Dnsruby::DecodeError, rdata.inspect) do + svcb(rdata) + end + end + + # An empty record is legal to hold, but only the update classes may encode it. + ['example.com. 3600 IN SVCB', 'example.com. 3600 CH SVCB'].each do |str| + rr = RR.create(str) + assert_raises(Dnsruby::EncodeError, str) { rr.encode_rdata(Dnsruby::MessageEncoder.new) } + end + + rr = svcb('1 . alpn=h2') + rr.target = nil + assert_raises(Dnsruby::EncodeError) { rr.encode_rdata(Dnsruby::MessageEncoder.new) } + # And it has no presentation format either, rather than raising ArgumentError. + assert_equal('', rr.rdata_to_string) + end + + # A malformed value off the wire would be truncated or padded and re-encode to + # the same bad octets, so it is rejected on the way in. + def test_decode_rejects_malformed_rdata + { + 'ech, empty' => '000100' + '0005' + '0000', + 'dohpath, empty' => '000100' + '0007' + '0000', + 'port, 4 octets' => '000100' + '0003' + '0004' + '000001bb', + 'ipv4hint, 3 octets' => '000100' + '0004' + '0003' + 'c00002', + 'ipv6hint, 15 octets' => '000100' + '0006' + '000f' + '20010db80000000000000000000000', + 'alpn id overruns' => '000100' + '0001' + '0003' + '036831', + 'alpn zero-length id' => '000100' + '0001' + '0001' + '00', + 'mandatory odd length' => '000100' + '0000' + '0003' + '000101', + 'no-default-alpn, value' => '000100' + '0002' + '0003' + '616263', + 'mandatory lists itself' => '000100' + '0000' + '0002' + '0000', + 'mandatory key absent' => '000100' + '0000' + '0002' + '0003', + # mandatory=[port,port], with a port param present + 'mandatory lists a dup' => '000100' + '0000' + '0004' + '00030003' + + '0003' + '0002' + '01bb', + # key 3 (port) before key 1 (alpn): SvcParams must strictly increase + 'params out of order' => '000100' + '0003' + '0002' + '01bb' + + '0001' + '0003' + '026832', + }.each do |label, hex| + assert_raises(Dnsruby::DecodeError, label) do + decode(hex) + end + end + end + + # Message.decode rejects a SvcParamValue length past the end of the RDATA at + # the RDLENGTH boundary; the RFC 3597 "\# len hex" form decodes RDATA alone. + def test_decode_rejects_truncated_svcparamvalue + { + 'dohpath, 3 of 10 octets' => '000100' + '0007' + '000a' + '616263', + 'ech, 3 of 4 octets' => '000100' + '0005' + '0004' + 'abcdef', + 'alpn, no value at all' => '000100' + '0001' + '0003', + }.each do |label, hex| + assert_raises(Dnsruby::DecodeError, label) { decode(hex) } + assert_raises(Dnsruby::DecodeError, label) do + RR.create("example.com. 3600 IN SVCB \\# #{hex.length / 2} #{hex}") + end + end + end + + # params is public, so a value can reach the decoder without the .b every + # constructor applies, which is why the length-prefixed decoders count + # octets. With String#[] this decoded to ["\xC3\xA9\x02", "2"]. + def test_length_prefixed_decode_counts_octets + rr = svcb('1 .') + # [2]"\xC3\xA9" [2]"h2" + rr.params[1] = "\x02\xc3\xa9\x02h2" + assert_equal(6, rr.params[1].bytesize) + assert_equal(5, rr.params[1].length, 'the point of the test is that these differ') + assert_equal('1 . alpn=\195\169,h2', rr.rdata_to_string) + end + + # Same trigger as above, for the fixed-width address hints. Splitting these in + # characters dropped the trailing address of each pair. + def test_address_hint_decode_counts_octets + rr = svcb('1 .') + + rr.params[4] = "\xc3\xa9\x01\x02\x03\x04\x05\x06" + assert_equal(8, rr.params[4].bytesize) + assert_equal(7, rr.params[4].length, 'the point of the test is that these differ') + assert_equal('1 . ipv4hint=195.169.1.2,3.4.5.6', rr.rdata_to_string) + + rr.params.delete(4) + rr.params[6] = "\xc3\xa9" + "\x00" * 14 + "\x20\x01\x0d\xb8" + "\x00" * 12 + assert_equal(32, rr.params[6].bytesize) + assert_equal(31, rr.params[6].length, 'the point of the test is that these differ') + assert_equal('1 . ipv6hint=c3a9::,2001:db8::', rr.rdata_to_string) + end +end diff --git a/test/tc_zone_reader.rb b/test/tc_zone_reader.rb index 8a5ac19..44dc7d8 100644 --- a/test/tc_zone_reader.rb +++ b/test/tc_zone_reader.rb @@ -71,5 +71,148 @@ def test_process_file_with_stringio_object assert_equal(false, stringio.closed?) stringio.close end + + # The reader reattaches a quoted tail for every type, so a parenthesis right + # before a quote must separate it too: the parenthesis is stripped from the + # line before the quoted text goes back on. + PAREN_QUOTE_ZONE = <<~ZONEDATA + $TTL 3600 + caa IN CAA 0 issue("ca.example.net") + hinfo IN HINFO ("cpu" "os") + txt IN TXT ("abc" "def") + ZONEDATA + + def test_zone_parenthesis_before_quote + reader = Dnsruby::ZoneReader.new('example.com.') + caa, hinfo, txt = reader.process_file(StringIO.new(PAREN_QUOTE_ZONE)) + + assert_equal('issue', caa.property_tag) + assert_equal('ca.example.net', caa.property_value) + + assert_equal('cpu', hinfo.cpu) + assert_equal('os', hinfo.os) + + assert_equal(['abc', 'def'], txt.strings) + end + + # A backslash escaping the quote abuts it too. RFC 1035 sec 5.1 makes \" a + # literal quote within an unquoted character-string, so each of these is one + # string, not two. + ESCAPED_QUOTE_ZONE = <<~'ZONEDATA' + $TTL 3600 + lead IN TXT \"escaped + mid IN TXT abc\"def + ZONEDATA + + def test_zone_escaped_quote_not_separated + reader = Dnsruby::ZoneReader.new('example.com.') + lead, mid = reader.process_file(StringIO.new(ESCAPED_QUOTE_ZONE)) + + assert_equal(['"escaped'], lead.strings) + assert_equal(['abc"def'], mid.strings) + end + + # SVCB/HTTPS records carry a TargetName in the middle of the RDATA. The zone + # reader must resolve relative TargetNames against the origin, while leaving + # the root (".") and already-absolute names untouched. + SVCB_ZONE = <<~ZONEDATA + $TTL 3600 + $ORIGIN example.com. + @ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5 + @ IN HTTPS 1 . alpn=h2,h3 ipv4hint=192.0.2.1 + alias IN SVCB 0 svc4.example.com. + rel IN SVCB 1 foo alpn=h2 + svc IN HTTPS 1 svc.example.net. port=8443 + ZONEDATA + + def svcb_zone_records + reader = Dnsruby::ZoneReader.new('example.com.') + records = reader.process_file(StringIO.new(SVCB_ZONE)) + records.reject { |rr| rr.type == Types::SOA } + end + + def test_zone_svcb_https_targets_resolved + https_root, svcb_alias, svcb_rel, https_svc = svcb_zone_records + + # Root TargetName stays as the root. + assert_equal(Types::HTTPS, https_root.type) + assert_equal('.', https_root.target.to_s(true)) + assert_equal('h2,h3', https_root.params_to_hash['alpn']) + + # Absolute AliasMode TargetName is untouched. + assert_equal(0, svcb_alias.priority) + assert_equal('svc4.example.com.', svcb_alias.target.to_s(true)) + + # Relative TargetName gains the origin. + assert_equal('foo.example.com.', svcb_rel.target.to_s(true)) + assert_equal('h2', svcb_rel.params_to_hash['alpn']) + + # Absolute TargetName in another zone is untouched. + assert_equal('svc.example.net.', https_svc.target.to_s(true)) + assert_equal('8443', https_svc.params_to_hash['port']) + end + + # A HTTPS record spread across several lines with parentheses is valid zone + # format and must be parsed as a single record. + MULTILINE_ZONE = <<~ZONEDATA + $TTL 3600 + $ORIGIN example.com. + @ IN SOA ns1.example.com. hostmaster.example.com. 1 2 3 4 5 + @ IN HTTPS ( 1 . alpn=h2,h3 + port=443 + ipv4hint=192.0.2.1 ) + ZONEDATA + + def test_zone_multiline_https + reader = Dnsruby::ZoneReader.new('example.com.') + records = reader.process_file(StringIO.new(MULTILINE_ZONE)) + https = records.find { |rr| rr.type == Types::HTTPS } + refute_nil(https) + assert_equal(1, https.priority) + assert_equal('.', https.target.to_s(true)) + assert_equal('h2,h3', https.params_to_hash['alpn']) + assert_equal('443', https.params_to_hash['port']) + assert_equal('192.0.2.1', https.params_to_hash['ipv4hint']) + end + + # RFC 9460 Appendix A: a SvcParamValue may be quoted, and the quotes open + # mid-token, unlike the quoted RDATA of a TXT record. The ech value also puts + # base64 padding inside the quotes, and an unquoted param after them. + QUOTED_SVCB_ZONE = <<~ZONEDATA + $TTL 3600 + alpn IN HTTPS 1 . alpn="h2,h3" + ech IN HTTPS 1 . ech="AEX+DQBBzQAgACD/AA==" port=8443 + ZONEDATA + + def test_zone_quoted_svcparam_values + reader = Dnsruby::ZoneReader.new('example.com.') + alpn, ech = reader.process_file(StringIO.new(QUOTED_SVCB_ZONE)) + + assert_equal('h2,h3', alpn.params_to_hash['alpn']) + + assert_equal('AEX+DQBBzQAgACD/AA==', ech.params_to_hash['ech']) + assert_equal('8443', ech.params_to_hash['port']) + end + + # A quoted SvcParamValue inside the parentheses that group a record across + # lines. The quote opens mid-token, so the closing parenthesis travels with + # the quoted tail rather than with the rest of the line. An ech value is what + # gets wrapped in practice, being long enough to want a second line. + PAREN_QUOTED_SVCB_ZONE = <<~ZONEDATA + $TTL 3600 + one IN HTTPS ( 1 . alpn="h2,h3" ) + two IN HTTPS ( 1 . ech="AEX+DQBBzQAgACD/AA==" + port=8443 ) + ZONEDATA + + def test_zone_parenthesised_quoted_svcparam_values + reader = Dnsruby::ZoneReader.new('example.com.') + one_line, wrapped = reader.process_file(StringIO.new(PAREN_QUOTED_SVCB_ZONE)) + + assert_equal('h2,h3', one_line.params_to_hash['alpn']) + + assert_equal('AEX+DQBBzQAgACD/AA==', wrapped.params_to_hash['ech']) + assert_equal('8443', wrapped.params_to_hash['port']) + end end diff --git a/test/ts_offline.rb b/test/ts_offline.rb index 5640973..2e3eebd 100644 --- a/test/ts_offline.rb +++ b/test/ts_offline.rb @@ -26,6 +26,7 @@ hash header ipseckey + ipv6 message misc name @@ -47,6 +48,7 @@ rr-unknown rrset rrsig + svcb tkey update zone_reader