Numa becomes a true DNS resolver — resolves from root nameservers with complete DNSSEC chain-of-trust verification. Recursive resolution: - Iterative RFC 1034 from configurable root hints (13 default) - CNAME chasing (depth 8), referral following (depth 10) - A+AAAA glue extraction, IPv6 nameserver support - TLD priming: NS + DS + DNSKEY for 34 gTLDs + EU ccTLDs - Config: mode = "recursive" in [upstream], root_hints, prime_tlds DNSSEC (all 4 phases): - EDNS0 OPT pseudo-record (DO bit, 1232 payload per DNS Flag Day 2020) - DNSKEY, DS, RRSIG, NSEC, NSEC3 record types with wire read/write - Signature verification via ring: RSA/SHA-256, ECDSA P-256, Ed25519 - Chain-of-trust: zone DNSKEY → parent DS → root KSK (key tag 20326) - DNSKEY RRset self-signature verification (RRSIG(DNSKEY) by KSK) - RRSIG expiration/inception time validation - NSEC: NXDOMAIN gap proofs, NODATA type absence, wildcard denial - NSEC3: SHA-1 iterated hashing, closest encloser proof, hash range - Authority RRSIG verification for denial proofs - Config: [dnssec] enabled/strict (default false, opt-in) - AD bit on Secure, SERVFAIL on Bogus+strict - DnssecStatus cached per entry, ValidationStats logging Performance: - TLD chain pre-warmed on startup (root DNSKEY + TLD DS/DNSKEY) - Referral DS piggybacking from authority sections - DNSKEY prefetch before validation loop - Cold-cache validation: ~1 DNSKEY fetch (down from 5) - Benchmarks: RSA 10.9µs, ECDSA 174ns, DS verify 257ns Also: - write_qname fix for root domain "." (was producing malformed queries) - write_record_header() dedup, write_bytes() bulk writes - DnsRecord::domain() + query_type() accessors - UpstreamMode enum, DEFAULT_EDNS_PAYLOAD const - Real glue TTL (was hardcoded 3600) - DNSSEC restricted to recursive mode only Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
586 lines
18 KiB
Rust
586 lines
18 KiB
Rust
use crate::buffer::BytePacketBuffer;
|
|
use crate::header::DnsHeader;
|
|
use crate::question::{DnsQuestion, QueryType};
|
|
use crate::record::DnsRecord;
|
|
use crate::Result;
|
|
|
|
/// Recommended EDNS0 UDP payload size (DNS Flag Day 2020) — avoids IP fragmentation.
|
|
pub const DEFAULT_EDNS_PAYLOAD: u16 = 1232;
|
|
|
|
/// EDNS0 OPT pseudo-record (RFC 6891)
|
|
#[derive(Clone, Debug)]
|
|
pub struct EdnsOpt {
|
|
pub udp_payload_size: u16,
|
|
pub extended_rcode: u8,
|
|
pub version: u8,
|
|
pub do_bit: bool,
|
|
pub options: Vec<u8>,
|
|
}
|
|
|
|
impl Default for EdnsOpt {
|
|
fn default() -> Self {
|
|
EdnsOpt {
|
|
udp_payload_size: DEFAULT_EDNS_PAYLOAD,
|
|
extended_rcode: 0,
|
|
version: 0,
|
|
do_bit: false,
|
|
options: Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct DnsPacket {
|
|
pub header: DnsHeader,
|
|
pub questions: Vec<DnsQuestion>,
|
|
pub answers: Vec<DnsRecord>,
|
|
pub authorities: Vec<DnsRecord>,
|
|
pub resources: Vec<DnsRecord>,
|
|
pub edns: Option<EdnsOpt>,
|
|
}
|
|
|
|
impl Default for DnsPacket {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl DnsPacket {
|
|
pub fn new() -> DnsPacket {
|
|
DnsPacket {
|
|
header: DnsHeader::new(),
|
|
questions: Vec::new(),
|
|
answers: Vec::new(),
|
|
authorities: Vec::new(),
|
|
resources: Vec::new(),
|
|
edns: None,
|
|
}
|
|
}
|
|
|
|
pub fn response_from(query: &DnsPacket, rescode: crate::header::ResultCode) -> DnsPacket {
|
|
let mut resp = DnsPacket::new();
|
|
resp.header.id = query.header.id;
|
|
resp.header.response = true;
|
|
resp.header.recursion_desired = query.header.recursion_desired;
|
|
resp.header.recursion_available = true;
|
|
resp.header.rescode = rescode;
|
|
resp.questions = query.questions.clone();
|
|
resp
|
|
}
|
|
|
|
pub fn from_buffer(buffer: &mut BytePacketBuffer) -> Result<DnsPacket> {
|
|
let mut result = DnsPacket::new();
|
|
result.header.read(buffer)?;
|
|
|
|
for _ in 0..result.header.questions {
|
|
let mut question = DnsQuestion::new(String::with_capacity(64), QueryType::UNKNOWN(0));
|
|
question.read(buffer)?;
|
|
result.questions.push(question);
|
|
}
|
|
|
|
for _ in 0..result.header.answers {
|
|
let rec = DnsRecord::read(buffer)?;
|
|
result.answers.push(rec);
|
|
}
|
|
for _ in 0..result.header.authoritative_entries {
|
|
let rec = DnsRecord::read(buffer)?;
|
|
result.authorities.push(rec);
|
|
}
|
|
for _ in 0..result.header.resource_entries {
|
|
// Peek at type field to detect OPT pseudo-records.
|
|
// OPT name is always root (0x00), so name byte + type field starts at pos+1.
|
|
let peek_pos = buffer.pos();
|
|
let name_byte = buffer.get(peek_pos)?;
|
|
let is_opt = if name_byte == 0 {
|
|
// Root name (single zero byte) — peek at type
|
|
let type_hi = buffer.get(peek_pos + 1)?;
|
|
let type_lo = buffer.get(peek_pos + 2)?;
|
|
u16::from_be_bytes([type_hi, type_lo]) == 41
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if is_opt {
|
|
// Parse OPT manually to capture the class field (= UDP payload size)
|
|
buffer.step(1)?; // skip root name (0x00)
|
|
let _ = buffer.read_u16()?; // type (41)
|
|
let udp_payload_size = buffer.read_u16()?; // class = UDP payload size
|
|
let ttl_field = buffer.read_u32()?; // packed flags
|
|
let rdlength = buffer.read_u16()?;
|
|
let options = buffer.get_range(buffer.pos(), rdlength as usize)?.to_vec();
|
|
buffer.step(rdlength as usize)?;
|
|
|
|
result.edns = Some(EdnsOpt {
|
|
udp_payload_size,
|
|
extended_rcode: ((ttl_field >> 24) & 0xFF) as u8,
|
|
version: ((ttl_field >> 16) & 0xFF) as u8,
|
|
do_bit: (ttl_field >> 15) & 1 == 1,
|
|
options,
|
|
});
|
|
} else {
|
|
let rec = DnsRecord::read(buffer)?;
|
|
result.resources.push(rec);
|
|
}
|
|
}
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
pub fn write(&self, buffer: &mut BytePacketBuffer) -> Result<()> {
|
|
let edns_count = if self.edns.is_some() { 1u16 } else { 0 };
|
|
|
|
let mut header = self.header.clone();
|
|
header.questions = self.questions.len() as u16;
|
|
header.answers = self.answers.len() as u16;
|
|
header.authoritative_entries = self.authorities.len() as u16;
|
|
header.resource_entries = self.resources.len() as u16 + edns_count;
|
|
|
|
header.write(buffer)?;
|
|
|
|
for question in &self.questions {
|
|
question.write(buffer)?;
|
|
}
|
|
for rec in &self.answers {
|
|
rec.write(buffer)?;
|
|
}
|
|
for rec in &self.authorities {
|
|
rec.write(buffer)?;
|
|
}
|
|
for rec in &self.resources {
|
|
rec.write(buffer)?;
|
|
}
|
|
|
|
// Write EDNS0 OPT pseudo-record
|
|
if let Some(ref edns) = self.edns {
|
|
buffer.write_u8(0)?; // root name
|
|
buffer.write_u16(QueryType::OPT.to_num())?; // type 41
|
|
buffer.write_u16(edns.udp_payload_size)?; // class = UDP payload size
|
|
// TTL = extended_rcode(8) | version(8) | DO(1) | Z(15)
|
|
let ttl_field = ((edns.extended_rcode as u32) << 24)
|
|
| ((edns.version as u32) << 16)
|
|
| (if edns.do_bit { 1u32 << 15 } else { 0 });
|
|
buffer.write_u32(ttl_field)?;
|
|
buffer.write_u16(edns.options.len() as u16)?; // RDLENGTH
|
|
buffer.write_bytes(&edns.options)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn display(&self) {
|
|
println!("{:#?}", self.header);
|
|
|
|
for q in &self.questions {
|
|
println!("{:#?}", q);
|
|
}
|
|
for rec in &self.answers {
|
|
println!("{:#?}", rec);
|
|
}
|
|
for rec in &self.authorities {
|
|
println!("{:#?}", rec);
|
|
}
|
|
for rec in &self.resources {
|
|
println!("{:#?}", rec);
|
|
}
|
|
if let Some(ref edns) = self.edns {
|
|
println!("EDNS: {:?}", edns);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::header::ResultCode;
|
|
|
|
#[test]
|
|
fn edns_round_trip() {
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0x1234;
|
|
pkt.header.response = true;
|
|
pkt.header.rescode = ResultCode::NOERROR;
|
|
pkt.edns = Some(EdnsOpt {
|
|
do_bit: true,
|
|
..Default::default()
|
|
});
|
|
|
|
let mut buf = BytePacketBuffer::new();
|
|
pkt.write(&mut buf).unwrap();
|
|
buf.seek(0).unwrap();
|
|
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
|
|
|
let edns = parsed.edns.expect("EDNS should be present");
|
|
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
|
assert!(edns.do_bit);
|
|
assert_eq!(edns.version, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn edns_do_bit_false() {
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0x5678;
|
|
pkt.header.response = true;
|
|
pkt.edns = Some(EdnsOpt {
|
|
udp_payload_size: 1232,
|
|
do_bit: false,
|
|
..Default::default()
|
|
});
|
|
|
|
let mut buf = BytePacketBuffer::new();
|
|
pkt.write(&mut buf).unwrap();
|
|
buf.seek(0).unwrap();
|
|
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
|
|
|
let edns = parsed.edns.expect("EDNS should be present");
|
|
assert_eq!(edns.udp_payload_size, DEFAULT_EDNS_PAYLOAD);
|
|
assert!(!edns.do_bit);
|
|
}
|
|
|
|
#[test]
|
|
fn no_edns_by_default() {
|
|
let pkt = DnsPacket::new();
|
|
assert!(pkt.edns.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn packet_without_edns_round_trips() {
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0xABCD;
|
|
pkt.header.response = true;
|
|
pkt.header.rescode = ResultCode::NOERROR;
|
|
pkt.answers.push(crate::record::DnsRecord::A {
|
|
domain: "example.com".into(),
|
|
addr: "1.2.3.4".parse().unwrap(),
|
|
ttl: 300,
|
|
});
|
|
|
|
let parsed = packet_round_trip(&pkt);
|
|
assert!(parsed.edns.is_none());
|
|
assert_eq!(parsed.answers.len(), 1);
|
|
}
|
|
|
|
fn packet_round_trip(pkt: &DnsPacket) -> DnsPacket {
|
|
let mut buf = BytePacketBuffer::new();
|
|
pkt.write(&mut buf).unwrap();
|
|
let wire_len = buf.pos();
|
|
buf.seek(0).unwrap();
|
|
let parsed = DnsPacket::from_buffer(&mut buf).unwrap();
|
|
// Verify we consumed exactly what was written
|
|
assert_eq!(
|
|
buf.pos(),
|
|
wire_len,
|
|
"parse did not consume all written bytes"
|
|
);
|
|
parsed
|
|
}
|
|
|
|
#[test]
|
|
fn nxdomain_with_nsec_authority_round_trips() {
|
|
use crate::question::DnsQuestion;
|
|
use crate::record::DnsRecord;
|
|
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0x1111;
|
|
pkt.header.response = true;
|
|
pkt.header.rescode = ResultCode::NXDOMAIN;
|
|
pkt.questions.push(DnsQuestion::new(
|
|
"nonexistent.example.com".into(),
|
|
QueryType::A,
|
|
));
|
|
|
|
pkt.authorities.push(DnsRecord::NSEC {
|
|
domain: "alpha.example.com".into(),
|
|
next_domain: "gamma.example.com".into(),
|
|
type_bitmap: vec![0, 2, 0x40, 0x01], // A + MX
|
|
ttl: 3600,
|
|
});
|
|
pkt.authorities.push(DnsRecord::RRSIG {
|
|
domain: "alpha.example.com".into(),
|
|
type_covered: QueryType::NSEC.to_num(),
|
|
algorithm: 13,
|
|
labels: 3,
|
|
original_ttl: 3600,
|
|
expiration: 1700000000,
|
|
inception: 1690000000,
|
|
key_tag: 12345,
|
|
signer_name: "example.com".into(),
|
|
signature: vec![0xAA; 64],
|
|
ttl: 3600,
|
|
});
|
|
|
|
// Wildcard denial NSEC
|
|
pkt.authorities.push(DnsRecord::NSEC {
|
|
domain: "example.com".into(),
|
|
next_domain: "alpha.example.com".into(),
|
|
type_bitmap: vec![0, 3, 0x62, 0x01, 0x80], // A, NS, SOA, MX, RRSIG
|
|
ttl: 3600,
|
|
});
|
|
|
|
pkt.edns = Some(EdnsOpt {
|
|
do_bit: true,
|
|
..Default::default()
|
|
});
|
|
|
|
let parsed = packet_round_trip(&pkt);
|
|
|
|
assert_eq!(parsed.header.id, 0x1111);
|
|
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
|
assert_eq!(parsed.questions.len(), 1);
|
|
assert_eq!(parsed.questions[0].name, "nonexistent.example.com");
|
|
assert_eq!(parsed.authorities.len(), 3);
|
|
|
|
// Verify NSEC records survived
|
|
if let DnsRecord::NSEC {
|
|
domain,
|
|
next_domain,
|
|
type_bitmap,
|
|
..
|
|
} = &parsed.authorities[0]
|
|
{
|
|
assert_eq!(domain, "alpha.example.com");
|
|
assert_eq!(next_domain, "gamma.example.com");
|
|
assert_eq!(type_bitmap, &[0, 2, 0x40, 0x01]);
|
|
} else {
|
|
panic!("expected NSEC, got {:?}", parsed.authorities[0]);
|
|
}
|
|
|
|
// Verify RRSIG survived
|
|
if let DnsRecord::RRSIG {
|
|
type_covered,
|
|
signer_name,
|
|
signature,
|
|
..
|
|
} = &parsed.authorities[1]
|
|
{
|
|
assert_eq!(*type_covered, QueryType::NSEC.to_num());
|
|
assert_eq!(signer_name, "example.com");
|
|
assert_eq!(signature.len(), 64);
|
|
} else {
|
|
panic!("expected RRSIG, got {:?}", parsed.authorities[1]);
|
|
}
|
|
|
|
// Verify EDNS survived
|
|
assert!(parsed.edns.as_ref().unwrap().do_bit);
|
|
}
|
|
|
|
#[test]
|
|
fn nxdomain_with_nsec3_authority_round_trips() {
|
|
use crate::question::DnsQuestion;
|
|
use crate::record::DnsRecord;
|
|
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0x2222;
|
|
pkt.header.response = true;
|
|
pkt.header.rescode = ResultCode::NXDOMAIN;
|
|
pkt.questions
|
|
.push(DnsQuestion::new("no.example.com".into(), QueryType::AAAA));
|
|
|
|
// Three NSEC3 records (closest encloser, next closer, wildcard)
|
|
let salt = vec![0xAB, 0xCD];
|
|
pkt.authorities.push(DnsRecord::NSEC3 {
|
|
domain: "ABC123.example.com".into(),
|
|
hash_algorithm: 1,
|
|
flags: 0,
|
|
iterations: 5,
|
|
salt: salt.clone(),
|
|
next_hashed_owner: vec![
|
|
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
|
|
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
|
|
],
|
|
type_bitmap: vec![0, 2, 0x60, 0x01], // NS, SOA, MX
|
|
ttl: 300,
|
|
});
|
|
pkt.authorities.push(DnsRecord::NSEC3 {
|
|
domain: "DEF456.example.com".into(),
|
|
hash_algorithm: 1,
|
|
flags: 0,
|
|
iterations: 5,
|
|
salt: salt.clone(),
|
|
next_hashed_owner: vec![0x20; 20],
|
|
type_bitmap: vec![0, 1, 0x40], // A
|
|
ttl: 300,
|
|
});
|
|
pkt.authorities.push(DnsRecord::RRSIG {
|
|
domain: "ABC123.example.com".into(),
|
|
type_covered: QueryType::NSEC3.to_num(),
|
|
algorithm: 8,
|
|
labels: 3,
|
|
original_ttl: 300,
|
|
expiration: 2000000000,
|
|
inception: 1600000000,
|
|
key_tag: 54321,
|
|
signer_name: "example.com".into(),
|
|
signature: vec![0xBB; 128],
|
|
ttl: 300,
|
|
});
|
|
|
|
pkt.edns = Some(EdnsOpt {
|
|
do_bit: true,
|
|
..Default::default()
|
|
});
|
|
|
|
let parsed = packet_round_trip(&pkt);
|
|
|
|
assert_eq!(parsed.header.rescode, ResultCode::NXDOMAIN);
|
|
assert_eq!(parsed.authorities.len(), 3);
|
|
|
|
// Verify first NSEC3 survived with all fields intact
|
|
if let DnsRecord::NSEC3 {
|
|
domain,
|
|
hash_algorithm,
|
|
flags,
|
|
iterations,
|
|
salt: parsed_salt,
|
|
next_hashed_owner,
|
|
type_bitmap,
|
|
..
|
|
} = &parsed.authorities[0]
|
|
{
|
|
assert_eq!(domain, "abc123.example.com");
|
|
assert_eq!(*hash_algorithm, 1);
|
|
assert_eq!(*flags, 0);
|
|
assert_eq!(*iterations, 5);
|
|
assert_eq!(parsed_salt, &salt);
|
|
assert_eq!(next_hashed_owner.len(), 20);
|
|
assert_eq!(type_bitmap, &[0, 2, 0x60, 0x01]);
|
|
} else {
|
|
panic!("expected NSEC3, got {:?}", parsed.authorities[0]);
|
|
}
|
|
|
|
// Verify RRSIG covering NSEC3
|
|
if let DnsRecord::RRSIG {
|
|
type_covered,
|
|
algorithm,
|
|
signature,
|
|
..
|
|
} = &parsed.authorities[2]
|
|
{
|
|
assert_eq!(*type_covered, QueryType::NSEC3.to_num());
|
|
assert_eq!(*algorithm, 8);
|
|
assert_eq!(signature.len(), 128);
|
|
} else {
|
|
panic!("expected RRSIG, got {:?}", parsed.authorities[2]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn dnssec_answer_with_rrsig_round_trips() {
|
|
use crate::question::DnsQuestion;
|
|
use crate::record::DnsRecord;
|
|
|
|
let mut pkt = DnsPacket::new();
|
|
pkt.header.id = 0x3333;
|
|
pkt.header.response = true;
|
|
pkt.header.rescode = ResultCode::NOERROR;
|
|
pkt.header.authed_data = true;
|
|
pkt.questions
|
|
.push(DnsQuestion::new("example.com".into(), QueryType::A));
|
|
|
|
pkt.answers.push(DnsRecord::A {
|
|
domain: "example.com".into(),
|
|
addr: "93.184.216.34".parse().unwrap(),
|
|
ttl: 300,
|
|
});
|
|
pkt.answers.push(DnsRecord::RRSIG {
|
|
domain: "example.com".into(),
|
|
type_covered: QueryType::A.to_num(),
|
|
algorithm: 13,
|
|
labels: 2,
|
|
original_ttl: 300,
|
|
expiration: 1700000000,
|
|
inception: 1690000000,
|
|
key_tag: 11111,
|
|
signer_name: "example.com".into(),
|
|
signature: vec![0xCC; 64],
|
|
ttl: 300,
|
|
});
|
|
|
|
// Authority: NS + DS
|
|
pkt.authorities.push(DnsRecord::NS {
|
|
domain: "example.com".into(),
|
|
host: "ns1.example.com".into(),
|
|
ttl: 3600,
|
|
});
|
|
pkt.authorities.push(DnsRecord::DS {
|
|
domain: "example.com".into(),
|
|
key_tag: 22222,
|
|
algorithm: 8,
|
|
digest_type: 2,
|
|
digest: vec![0xDD; 32],
|
|
ttl: 86400,
|
|
});
|
|
|
|
// Additional: glue A + DNSKEY
|
|
pkt.resources.push(DnsRecord::A {
|
|
domain: "ns1.example.com".into(),
|
|
addr: "198.51.100.1".parse().unwrap(),
|
|
ttl: 3600,
|
|
});
|
|
pkt.resources.push(DnsRecord::DNSKEY {
|
|
domain: "example.com".into(),
|
|
flags: 257,
|
|
protocol: 3,
|
|
algorithm: 13,
|
|
public_key: vec![0xEE; 64],
|
|
ttl: 3600,
|
|
});
|
|
|
|
pkt.edns = Some(EdnsOpt {
|
|
do_bit: true,
|
|
..Default::default()
|
|
});
|
|
|
|
let parsed = packet_round_trip(&pkt);
|
|
|
|
assert_eq!(parsed.header.id, 0x3333);
|
|
assert!(parsed.header.authed_data);
|
|
assert_eq!(parsed.answers.len(), 2);
|
|
assert_eq!(parsed.authorities.len(), 2);
|
|
assert_eq!(parsed.resources.len(), 2);
|
|
|
|
// Verify A record
|
|
if let DnsRecord::A { addr, .. } = &parsed.answers[0] {
|
|
assert_eq!(addr.to_string(), "93.184.216.34");
|
|
} else {
|
|
panic!("expected A");
|
|
}
|
|
|
|
// Verify RRSIG in answers
|
|
if let DnsRecord::RRSIG {
|
|
type_covered,
|
|
key_tag,
|
|
signer_name,
|
|
..
|
|
} = &parsed.answers[1]
|
|
{
|
|
assert_eq!(*type_covered, 1); // A
|
|
assert_eq!(*key_tag, 11111);
|
|
assert_eq!(signer_name, "example.com");
|
|
} else {
|
|
panic!("expected RRSIG");
|
|
}
|
|
|
|
// Verify DS in authority
|
|
if let DnsRecord::DS {
|
|
key_tag, digest, ..
|
|
} = &parsed.authorities[1]
|
|
{
|
|
assert_eq!(*key_tag, 22222);
|
|
assert_eq!(digest.len(), 32);
|
|
} else {
|
|
panic!("expected DS");
|
|
}
|
|
|
|
// Verify DNSKEY in additional
|
|
if let DnsRecord::DNSKEY {
|
|
flags, public_key, ..
|
|
} = &parsed.resources[1]
|
|
{
|
|
assert_eq!(*flags, 257);
|
|
assert_eq!(public_key.len(), 64);
|
|
} else {
|
|
panic!("expected DNSKEY");
|
|
}
|
|
}
|
|
}
|