feat(dns): add manual DNS protocol parser (RFC 1035)

Add a new `networking::dns` module that parses DNS messages directly
from raw UDP/TCP payload bytes, with no external DNS crate:

- `types.rs`: DnsMessage, DnsEvent, DnsQuestion, DnsRecord and the
  DnsRecordType / DnsRCode / DnsRData / DnsFlags enums, with Display impls.
- `parser.rs`: `parse_dns(&[u8])` decoding the 12-byte header (bitwise
  flags), the Question section and Answer resource records, with
  compression-pointer support (RFC 1035 §4.1.4), RDATA interpreted per
  type (A, AAAA, CNAME/NS/PTR, MX, TXT, SOA) and defensive handling of
  malformed/truncated input (no panics).

Includes 9 unit tests over fixed byte vectors (A query, compressed A
response, AAAA, MX, NXDOMAIN, too-short, truncated, pointer loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
João Pedro 2026-06-21 18:21:32 -03:00
parent ab4340abe8
commit 2d5431a970
4 changed files with 667 additions and 0 deletions

View file

@ -0,0 +1,8 @@
//! Application-layer DNS protocol analysis (RFC 1035).
//!
//! [`parser`] turns the raw bytes of a UDP/TCP payload into the strongly-typed
//! [`types::DnsMessage`]; [`types`] defines those data types together with the
//! [`types::DnsEvent`] capture wrapper.
pub mod parser;
pub mod types;

View file

@ -0,0 +1,413 @@
//! Manual parser for the DNS message format (RFC 1035), operating directly on
//! the raw bytes of a UDP/TCP payload.
//!
//! No external DNS parsing crate is used: every field is read by hand from the
//! byte slice. The parser is defensive — any malformed or truncated input
//! yields `None` (or a partial result) instead of panicking.
use std::net::{Ipv4Addr, Ipv6Addr};
use crate::networking::dns::types::{
DnsFlags, DnsMessage, DnsQuestion, DnsRCode, DnsRData, DnsRecord, DnsRecordType,
};
/// Maximum number of compression-pointer jumps allowed while decoding a single
/// name, to guard against maliciously crafted pointer loops.
const MAX_NAME_JUMPS: usize = 64;
/// Parses a DNS message from the bytes of a transport payload.
///
/// `buf` must start at the DNS header (for TCP, strip the 2-byte length prefix
/// beforehand). Returns `None` if the input is too short or the header/question
/// section is malformed.
pub fn parse_dns(buf: &[u8]) -> Option<DnsMessage> {
// The header is a fixed 12 bytes.
if buf.len() < 12 {
return None;
}
let id = u16::from_be_bytes([buf[0], buf[1]]);
let flags = u16::from_be_bytes([buf[2], buf[3]]);
let qr = (flags >> 15) & 1;
let opcode = ((flags >> 11) & 0x0F) as u8;
let aa = (flags >> 10) & 1 == 1;
let tc = (flags >> 9) & 1 == 1;
let rd = (flags >> 8) & 1 == 1;
let ra = (flags >> 7) & 1 == 1;
let rcode = (flags & 0x0F) as u8;
let qdcount = u16::from_be_bytes([buf[4], buf[5]]);
let ancount = u16::from_be_bytes([buf[6], buf[7]]);
// NSCOUNT (buf[8..10]) and ARCOUNT (buf[10..12]) are not parsed.
let mut pos = 12;
// Question section.
let mut questions = Vec::with_capacity(qdcount as usize);
for _ in 0..qdcount {
let (name, next) = read_name(buf, pos)?;
pos = next;
let qtype = read_u16(buf, pos)?;
pos += 2;
let qclass = read_u16(buf, pos)?;
pos += 2;
questions.push(DnsQuestion {
name,
qtype: DnsRecordType::from_u16(qtype),
qclass,
});
}
// Answer section. Tolerate truncation: stop at the first record we cannot
// fully read, keeping whatever we already decoded.
let mut answers = Vec::with_capacity(ancount as usize);
for _ in 0..ancount {
match read_record(buf, pos) {
Some((record, next)) => {
pos = next;
answers.push(record);
}
None => break,
}
}
Some(DnsMessage {
id,
is_response: qr == 1,
opcode,
flags: DnsFlags { aa, tc, rd, ra },
rcode: DnsRCode::from_u8(rcode),
questions,
answers,
})
}
/// Decodes a DNS name starting at `start`, following compression pointers
/// (RFC 1035 §4.1.4). Returns the decoded name and the position immediately
/// after the name in the byte stream (i.e. after the first pointer, if any).
fn read_name(buf: &[u8], start: usize) -> Option<(String, usize)> {
let mut labels: Vec<String> = Vec::new();
let mut pos = start;
// Position to resume reading the outer stream once the name is decoded.
// Set when the first pointer is encountered; otherwise it is the byte after
// the terminating zero length.
let mut next_pos: Option<usize> = None;
let mut jumps = 0;
loop {
let len = *buf.get(pos)?;
match len & 0xC0 {
// Regular label: top two bits are 00.
0x00 => {
if len == 0 {
pos += 1;
next_pos.get_or_insert(pos);
break;
}
let label_len = len as usize;
let label = buf.get(pos + 1..pos + 1 + label_len)?;
labels.push(String::from_utf8_lossy(label).into_owned());
pos += 1 + label_len;
}
// Compression pointer: top two bits are 11.
0xC0 => {
let second = *buf.get(pos + 1)?;
let offset = (((len & 0x3F) as usize) << 8) | second as usize;
next_pos.get_or_insert(pos + 2);
jumps += 1;
if jumps > MAX_NAME_JUMPS || offset >= buf.len() {
return None;
}
pos = offset;
}
// 0x40 and 0x80 are reserved and must not appear.
_ => return None,
}
}
let name = if labels.is_empty() {
".".to_string()
} else {
labels.join(".")
};
Some((name, next_pos?))
}
/// Reads a single Resource Record starting at `start`. Returns the record and
/// the position immediately after it.
fn read_record(buf: &[u8], start: usize) -> Option<(DnsRecord, usize)> {
let (name, mut pos) = read_name(buf, start)?;
let rtype_raw = read_u16(buf, pos)?;
pos += 2;
let class = read_u16(buf, pos)?;
pos += 2;
let ttl = read_u32(buf, pos)?;
pos += 4;
let rdlength = read_u16(buf, pos)? as usize;
pos += 2;
let rdata_start = pos;
let rdata_end = rdata_start.checked_add(rdlength)?;
if rdata_end > buf.len() {
return None;
}
let rtype = DnsRecordType::from_u16(rtype_raw);
let rdata = parse_rdata(buf, rtype, rdata_start, rdlength)?;
Some((
DnsRecord {
name,
rtype,
class,
ttl,
rdata,
},
rdata_end,
))
}
/// Interprets the RDATA of a record according to its type. `start`/`len`
/// delimit the RDATA inside `buf`; names inside RDATA may use compression and
/// therefore are resolved against the whole message.
fn parse_rdata(buf: &[u8], rtype: DnsRecordType, start: usize, len: usize) -> Option<DnsRData> {
let end = start.checked_add(len)?;
let data = buf.get(start..end)?;
let rdata = match rtype {
DnsRecordType::A => {
let octets: [u8; 4] = data.try_into().ok()?;
DnsRData::A(Ipv4Addr::from(octets))
}
DnsRecordType::Aaaa => {
let octets: [u8; 16] = data.try_into().ok()?;
DnsRData::Aaaa(Ipv6Addr::from(octets))
}
DnsRecordType::Cname | DnsRecordType::Ns | DnsRecordType::Ptr => {
let (name, _) = read_name(buf, start)?;
DnsRData::Name(name)
}
DnsRecordType::Mx => {
let preference = read_u16(buf, start)?;
let (exchange, _) = read_name(buf, start + 2)?;
DnsRData::Mx {
preference,
exchange,
}
}
DnsRecordType::Txt => {
// RDATA is one or more length-prefixed <character-string>s.
let mut strings = Vec::new();
let mut p = start;
while p < end {
let str_len = *buf.get(p)? as usize;
p += 1;
let bytes = buf.get(p..p + str_len)?;
strings.push(String::from_utf8_lossy(bytes).into_owned());
p += str_len;
}
DnsRData::Txt(strings)
}
DnsRecordType::Soa => {
let (mname, p) = read_name(buf, start)?;
let (rname, p) = read_name(buf, p)?;
let serial = read_u32(buf, p)?;
let refresh = read_u32(buf, p + 4)?;
let retry = read_u32(buf, p + 8)?;
let expire = read_u32(buf, p + 12)?;
let minimum = read_u32(buf, p + 16)?;
DnsRData::Soa {
mname,
rname,
serial,
refresh,
retry,
expire,
minimum,
}
}
DnsRecordType::Srv | DnsRecordType::Other(_) => DnsRData::Other(data.to_vec()),
};
Some(rdata)
}
fn read_u16(buf: &[u8], pos: usize) -> Option<u16> {
Some(u16::from_be_bytes([*buf.get(pos)?, *buf.get(pos + 1)?]))
}
fn read_u32(buf: &[u8], pos: usize) -> Option<u32> {
Some(u32::from_be_bytes([
*buf.get(pos)?,
*buf.get(pos + 1)?,
*buf.get(pos + 2)?,
*buf.get(pos + 3)?,
]))
}
#[cfg(test)]
mod tests {
use super::*;
/// Query for `google.com`, type A, class IN. Header: id=0x1234, RD set,
/// QDCOUNT=1.
const QUERY_A: &[u8] = &[
0x12, 0x34, // ID
0x01, 0x00, // flags: RD=1
0x00, 0x01, // QDCOUNT
0x00, 0x00, // ANCOUNT
0x00, 0x00, // NSCOUNT
0x00, 0x00, // ARCOUNT
0x06, b'g', b'o', b'o', b'g', b'l', b'e', // "google"
0x03, b'c', b'o', b'm', // "com"
0x00, // end of name
0x00, 0x01, // QTYPE = A
0x00, 0x01, // QCLASS = IN
];
#[test]
fn parses_query_a() {
let msg = parse_dns(QUERY_A).expect("should parse");
assert_eq!(msg.id, 0x1234);
assert!(!msg.is_response);
assert_eq!(msg.opcode, 0);
assert!(msg.flags.rd);
assert_eq!(msg.rcode, DnsRCode::NoError);
assert_eq!(msg.questions.len(), 1);
assert_eq!(msg.query_name(), Some("google.com"));
assert_eq!(msg.query_type(), Some(DnsRecordType::A));
assert_eq!(msg.questions[0].qclass, 1);
assert!(msg.answers.is_empty());
}
/// Response for `google.com` A, using a compression pointer (0xC00C) in the
/// answer's NAME field to reference the question's name at offset 12.
const RESPONSE_A_COMPRESSED: &[u8] = &[
0x12, 0x34, // ID
0x81, 0x80, // flags: QR=1, RD=1, RA=1, RCODE=0
0x00, 0x01, // QDCOUNT
0x00, 0x01, // ANCOUNT
0x00, 0x00, // NSCOUNT
0x00, 0x00, // ARCOUNT
// Question (name starts at offset 12)
0x06, b'g', b'o', b'o', b'g', b'l', b'e', 0x03, b'c', b'o', b'm', 0x00, 0x00, 0x01,
0x00, 0x01, // QTYPE=A, QCLASS=IN
// Answer
0xC0, 0x0C, // NAME -> pointer to offset 12
0x00, 0x01, // TYPE=A
0x00, 0x01, // CLASS=IN
0x00, 0x00, 0x01, 0x2C, // TTL=300
0x00, 0x04, // RDLENGTH=4
0x08, 0x08, 0x08, 0x08, // RDATA = 8.8.8.8
];
#[test]
fn parses_response_a_with_compression() {
let msg = parse_dns(RESPONSE_A_COMPRESSED).expect("should parse");
assert!(msg.is_response);
assert!(msg.flags.ra);
assert_eq!(msg.rcode, DnsRCode::NoError);
assert_eq!(msg.query_name(), Some("google.com"));
assert_eq!(msg.answers.len(), 1);
let answer = &msg.answers[0];
assert_eq!(answer.name, "google.com");
assert_eq!(answer.rtype, DnsRecordType::A);
assert_eq!(answer.ttl, 300);
assert_eq!(answer.rdata, DnsRData::A(Ipv4Addr::new(8, 8, 8, 8)));
}
/// AAAA response (::1) with a compression pointer.
const RESPONSE_AAAA: &[u8] = &[
0x00, 0x01, 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, //
0x04, b'i', b'p', b'v', b'6', 0x00, // name "ipv6"
0x00, 0x1C, 0x00, 0x01, // QTYPE=AAAA, QCLASS=IN
0xC0, 0x0C, // pointer to "ipv6"
0x00, 0x1C, // TYPE=AAAA
0x00, 0x01, // CLASS=IN
0x00, 0x00, 0x00, 0x3C, // TTL=60
0x00, 0x10, // RDLENGTH=16
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x01, // ::1
];
#[test]
fn parses_aaaa() {
let msg = parse_dns(RESPONSE_AAAA).expect("should parse");
assert_eq!(msg.answers.len(), 1);
assert_eq!(
msg.answers[0].rdata,
DnsRData::Aaaa(Ipv6Addr::LOCALHOST)
);
}
/// MX response: preference 10, exchange "mail" (compressed back to "ex").
const RESPONSE_MX: &[u8] = &[
0x00, 0x02, 0x81, 0x80, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, //
0x02, b'e', b'x', 0x00, // name "ex"
0x00, 0x0F, 0x00, 0x01, // QTYPE=MX, QCLASS=IN
0xC0, 0x0C, // pointer to "ex"
0x00, 0x0F, // TYPE=MX
0x00, 0x01, // CLASS=IN
0x00, 0x00, 0x00, 0x3C, // TTL=60
0x00, 0x09, // RDLENGTH=9
0x00, 0x0A, // preference=10
0x04, b'm', b'a', b'i', b'l', 0xC0, 0x0C, // "mail" + pointer to "ex"
];
#[test]
fn parses_mx() {
let msg = parse_dns(RESPONSE_MX).expect("should parse");
assert_eq!(msg.answers.len(), 1);
assert_eq!(
msg.answers[0].rdata,
DnsRData::Mx {
preference: 10,
exchange: "mail.ex".to_string(),
}
);
}
/// NXDOMAIN response (RCODE=3) with no answers.
const RESPONSE_NXDOMAIN: &[u8] = &[
0x00, 0x03, 0x81, 0x83, // QR=1, RD=1, RA=1, RCODE=3
0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x07, b'm', b'i', b's', b's', b'i', b'n', b'g', 0x03, b'c', b'o', b'm', 0x00, //
0x00, 0x01, 0x00, 0x01, // QTYPE=A, QCLASS=IN
];
#[test]
fn parses_nxdomain() {
let msg = parse_dns(RESPONSE_NXDOMAIN).expect("should parse");
assert!(msg.is_response);
assert_eq!(msg.rcode, DnsRCode::NxDomain);
assert_eq!(msg.query_name(), Some("missing.com"));
assert!(msg.answers.is_empty());
}
#[test]
fn rejects_too_short() {
assert!(parse_dns(&[0x00, 0x01, 0x02]).is_none());
assert!(parse_dns(&[]).is_none());
}
#[test]
fn truncated_question_returns_none() {
// Header claims one question but the name never terminates.
let buf = [
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0x06, b'g', b'o', b'o', // truncated label, no terminator
];
assert!(parse_dns(&buf).is_none());
}
#[test]
fn pointer_loop_does_not_hang() {
// A name at offset 12 that points to itself.
let buf = [
0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, //
0xC0, 0x0C, // pointer -> offset 12 (itself)
0x00, 0x01, 0x00, 0x01,
];
// Must terminate (return None) rather than loop forever.
assert!(parse_dns(&buf).is_none());
}
}

245
src/networking/dns/types.rs Normal file
View file

@ -0,0 +1,245 @@
//! Data types representing a parsed DNS message (RFC 1035) and the capture
//! metadata associated with it.
//!
//! These types are intentionally independent of any external DNS parsing
//! crate: the parsing is performed manually over the raw bytes (see
//! [`super::parser`]), so every field here maps directly to a field of the
//! on-the-wire DNS format.
use std::fmt::{Display, Formatter};
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::networking::types::protocol::Protocol;
use crate::utils::types::timestamp::Timestamp;
/// A DNS message captured from the network, enriched with the capture context
/// (when it was seen and between which endpoints).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DnsEvent {
/// Timestamp of the packet carrying this DNS message.
pub timestamp: Timestamp,
/// Source IP address of the packet.
pub src: IpAddr,
/// Destination IP address of the packet (the DNS server, for a query).
pub dst: IpAddr,
/// Transport protocol carrying the DNS message (UDP or TCP).
pub transport: Protocol,
/// The parsed DNS message itself.
pub message: DnsMessage,
}
/// A fully parsed DNS message: the 12-byte header plus the Question and Answer
/// sections. The Authority and Additional sections are not retained.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DnsMessage {
/// Transaction ID, used to correlate a query with its response.
pub id: u16,
/// `QR` bit: `false` = query, `true` = response.
pub is_response: bool,
/// `OPCODE` field (0 = standard QUERY, 1 = IQUERY, 2 = STATUS, ...).
pub opcode: u8,
/// Header flags (AA, TC, RD, RA).
pub flags: DnsFlags,
/// `RCODE` field (response code).
pub rcode: DnsRCode,
/// Entries of the Question section.
pub questions: Vec<DnsQuestion>,
/// Resource Records of the Answer section.
pub answers: Vec<DnsRecord>,
}
impl DnsMessage {
/// Name of the first question, if any (the domain being looked up).
pub fn query_name(&self) -> Option<&str> {
self.questions.first().map(|q| q.name.as_str())
}
/// Type of the first question, if any.
pub fn query_type(&self) -> Option<DnsRecordType> {
self.questions.first().map(|q| q.qtype)
}
/// Human-readable, comma-separated summary of the answer records' data,
/// suitable for a single table cell.
pub fn answers_summary(&self) -> String {
self.answers
.iter()
.map(|r| r.rdata.to_string())
.collect::<Vec<_>>()
.join(", ")
}
}
/// Header flags retained from the DNS header.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct DnsFlags {
/// Authoritative Answer.
pub aa: bool,
/// TrunCation: the message was truncated.
pub tc: bool,
/// Recursion Desired.
pub rd: bool,
/// Recursion Available.
pub ra: bool,
}
/// An entry of the Question section.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DnsQuestion {
/// Queried domain name (already decoded, dots between labels).
pub name: String,
/// Query type (QTYPE).
pub qtype: DnsRecordType,
/// Query class (QCLASS); 1 = IN (Internet).
pub qclass: u16,
}
/// A Resource Record (used here for the Answer section).
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DnsRecord {
/// Owner name of the record.
pub name: String,
/// Record type.
pub rtype: DnsRecordType,
/// Record class; 1 = IN (Internet).
pub class: u16,
/// Time to live, in seconds.
pub ttl: u32,
/// Interpreted record data.
pub rdata: DnsRData,
}
/// DNS record/query type (TYPE / QTYPE field).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DnsRecordType {
A,
Ns,
Cname,
Soa,
Ptr,
Mx,
Txt,
Aaaa,
Srv,
/// Any other type, keeping its numeric value.
Other(u16),
}
impl DnsRecordType {
pub fn from_u16(value: u16) -> Self {
match value {
1 => Self::A,
2 => Self::Ns,
5 => Self::Cname,
6 => Self::Soa,
12 => Self::Ptr,
15 => Self::Mx,
16 => Self::Txt,
28 => Self::Aaaa,
33 => Self::Srv,
other => Self::Other(other),
}
}
}
impl Display for DnsRecordType {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::A => write!(f, "A"),
Self::Ns => write!(f, "NS"),
Self::Cname => write!(f, "CNAME"),
Self::Soa => write!(f, "SOA"),
Self::Ptr => write!(f, "PTR"),
Self::Mx => write!(f, "MX"),
Self::Txt => write!(f, "TXT"),
Self::Aaaa => write!(f, "AAAA"),
Self::Srv => write!(f, "SRV"),
Self::Other(n) => write!(f, "TYPE{n}"),
}
}
}
/// DNS response code (RCODE field).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum DnsRCode {
NoError,
FormErr,
ServFail,
NxDomain,
NotImpl,
Refused,
/// Any other code, keeping its numeric value.
Other(u8),
}
impl DnsRCode {
pub fn from_u8(value: u8) -> Self {
match value {
0 => Self::NoError,
1 => Self::FormErr,
2 => Self::ServFail,
3 => Self::NxDomain,
4 => Self::NotImpl,
5 => Self::Refused,
other => Self::Other(other),
}
}
}
impl Display for DnsRCode {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoError => write!(f, "NOERROR"),
Self::FormErr => write!(f, "FORMERR"),
Self::ServFail => write!(f, "SERVFAIL"),
Self::NxDomain => write!(f, "NXDOMAIN"),
Self::NotImpl => write!(f, "NOTIMP"),
Self::Refused => write!(f, "REFUSED"),
Self::Other(n) => write!(f, "RCODE{n}"),
}
}
}
/// Interpreted record data (RDATA), by record type.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum DnsRData {
/// IPv4 address (type A).
A(Ipv4Addr),
/// IPv6 address (type AAAA).
Aaaa(Ipv6Addr),
/// A domain name (types CNAME, NS, PTR).
Name(String),
/// Mail exchange (type MX): preference and mail server name.
Mx { preference: u16, exchange: String },
/// Text strings (type TXT).
Txt(Vec<String>),
/// Start of authority (type SOA).
Soa {
mname: String,
rname: String,
serial: u32,
refresh: u32,
retry: u32,
expire: u32,
minimum: u32,
},
/// Raw, uninterpreted data for unsupported types.
Other(Vec<u8>),
}
impl Display for DnsRData {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::A(ip) => write!(f, "{ip}"),
Self::Aaaa(ip) => write!(f, "{ip}"),
Self::Name(name) => write!(f, "{name}"),
Self::Mx {
preference,
exchange,
} => write!(f, "{preference} {exchange}"),
Self::Txt(strings) => write!(f, "{}", strings.join(" ")),
Self::Soa { mname, rname, .. } => write!(f, "{mname} {rname}"),
Self::Other(bytes) => write!(f, "{} bytes", bytes.len()),
}
}
}

View file

@ -1,3 +1,4 @@
pub mod dns;
pub mod manage_packets;
pub mod parse_packets;
pub mod traffic_preview;