From 2d5431a970ddfa41013cd40447fbb266c47a87ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:21:32 -0300 Subject: [PATCH 01/13] feat(dns): add manual DNS protocol parser (RFC 1035) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/networking/dns/mod.rs | 8 + src/networking/dns/parser.rs | 413 +++++++++++++++++++++++++++++++++++ src/networking/dns/types.rs | 245 +++++++++++++++++++++ src/networking/mod.rs | 1 + 4 files changed, 667 insertions(+) create mode 100644 src/networking/dns/mod.rs create mode 100644 src/networking/dns/parser.rs create mode 100644 src/networking/dns/types.rs diff --git a/src/networking/dns/mod.rs b/src/networking/dns/mod.rs new file mode 100644 index 000000000..4e7200956 --- /dev/null +++ b/src/networking/dns/mod.rs @@ -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; diff --git a/src/networking/dns/parser.rs b/src/networking/dns/parser.rs new file mode 100644 index 000000000..04b3b1bcf --- /dev/null +++ b/src/networking/dns/parser.rs @@ -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 { + // 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 = 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 = 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 { + 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 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 { + Some(u16::from_be_bytes([*buf.get(pos)?, *buf.get(pos + 1)?])) +} + +fn read_u32(buf: &[u8], pos: usize) -> Option { + 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()); + } +} diff --git a/src/networking/dns/types.rs b/src/networking/dns/types.rs new file mode 100644 index 000000000..3445b6bc9 --- /dev/null +++ b/src/networking/dns/types.rs @@ -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, + /// Resource Records of the Answer section. + pub answers: Vec, +} + +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 { + 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::>() + .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), + /// 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), +} + +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()), + } + } +} diff --git a/src/networking/mod.rs b/src/networking/mod.rs index 4703d9e0b..332a4654a 100644 --- a/src/networking/mod.rs +++ b/src/networking/mod.rs @@ -1,3 +1,4 @@ +pub mod dns; pub mod manage_packets; pub mod parse_packets; pub mod traffic_preview; From 4695544de5bdd95e1eb599e114b34ef294f2dbff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:22:36 -0300 Subject: [PATCH 02/13] feat(dns): capture and parse DNS traffic in the packet pipeline Detect DNS traffic (UDP/TCP port 53) in the capture loop and parse the message from the transport payload, extracted from `headers.payload` before `analyze_headers` consumes the headers (the slice borrows `packet.data`, so it outlives the move). DNS over TCP's 2-byte length prefix is stripped before parsing. Parsed messages are pushed to a new `InfoTraffic::dns_events` vector, carried to the GUI on each tick and reset by `take_but_leave_something`; `refresh` intentionally ignores it (events are drained GUI-side). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/networking/parse_packets.rs | 36 +++++++++++++++++++++++++++- src/networking/types/info_traffic.rs | 4 ++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/networking/parse_packets.rs b/src/networking/parse_packets.rs index b18981d87..010e9d058 100644 --- a/src/networking/parse_packets.rs +++ b/src/networking/parse_packets.rs @@ -5,6 +5,8 @@ use crate::location; use crate::mmdb::asn::get_asn; use crate::mmdb::country::get_country; use crate::mmdb::types::mmdb_reader::MmdbReaders; +use crate::networking::dns::parser::parse_dns; +use crate::networking::dns::types::DnsEvent; use crate::networking::manage_packets::{ analyze_headers, get_address_to_lookup, get_traffic_type, is_local_connection, modify_or_insert_in_map, @@ -20,13 +22,14 @@ use crate::networking::types::icmp_type::IcmpType; use crate::networking::types::info_traffic::InfoTraffic; use crate::networking::types::ip_blacklist::IpBlacklist; use crate::networking::types::my_link_type::MyLinkType; +use crate::networking::types::protocol::Protocol; use crate::networking::types::traffic_direction::TrafficDirection; use crate::utils::error_logger::{ErrorLogger, Location}; use crate::utils::formatted_strings::get_domain_from_r_dns; use crate::utils::types::timestamp::Timestamp; use async_channel::Sender; use dns_lookup::lookup_addr; -use etherparse::{EtherType, LaxPacketHeaders}; +use etherparse::{EtherType, LaxPacketHeaders, LaxPayloadSlice}; use pcap::{Address, Packet, PacketHeader}; use std::collections::HashMap; use std::net::IpAddr; @@ -155,6 +158,15 @@ pub fn parse_packets( let mut icmp_type = IcmpType::default(); let mut arp_type = ArpType::default(); + // Capture the UDP/TCP payload before `analyze_headers` consumes + // `headers`. The returned slice borrows `packet.data`, not + // `headers`, so it remains valid after the move. + let transport_payload: Option<&[u8]> = match &headers.payload { + LaxPayloadSlice::Udp { payload, .. } + | LaxPayloadSlice::Tcp { payload, .. } => Some(payload), + _ => None, + }; + let key_option = analyze_headers( headers, &mut mac_addresses, @@ -167,6 +179,28 @@ pub fn parse_packets( continue; }; + // If this is DNS traffic (port 53), parse the message from the + // transport payload and record it as a DNS event. + if key.sport == Some(53) || key.dport == Some(53) { + if let Some(payload) = transport_payload { + // DNS over TCP is prefixed by a 2-byte length field. + let dns_bytes = if key.protocol == Protocol::TCP { + payload.get(2..) + } else { + Some(payload) + }; + if let Some(message) = dns_bytes.and_then(parse_dns) { + info_traffic_msg.dns_events.push(DnsEvent { + timestamp: next_packet_timestamp, + src: key.source, + dst: key.dest, + transport: key.protocol, + message, + }); + } + } + } + // save this packet to PCAP file if let Some(file) = savefile.as_mut() { file.write(&Packet { diff --git a/src/networking/types/info_traffic.rs b/src/networking/types/info_traffic.rs index 6a9b8150e..2fe096aac 100644 --- a/src/networking/types/info_traffic.rs +++ b/src/networking/types/info_traffic.rs @@ -1,4 +1,5 @@ use crate::Service; +use crate::networking::dns::types::DnsEvent; use crate::networking::manage_packets::get_local_port; use crate::networking::types::address_port_pair::AddressPortPair; use crate::networking::types::data_info::DataInfo; @@ -26,6 +27,9 @@ pub struct InfoTraffic { pub services: HashMap, /// Map of the hosts with their data info pub hosts: HashMap, + /// DNS messages parsed during the current interval (drained by the GUI each + /// tick; not accumulated in the global `InfoTraffic`). + pub dns_events: Vec, } impl InfoTraffic { From 8f8dd02e937a94bb130afe413baad62c986b2579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:22:57 -0300 Subject: [PATCH 03/13] feat(dns): add DNS analyzer page with live query/response log Add a dedicated "DNS" tab to the running pages: - `gui::types::dns_state`: DnsState holds a bounded live log (VecDeque, cap 2000) of DnsEntry rows folded from the backend's DnsEvents. - `gui::pages::dns_page`: renders a header + scrollable table (Time, Q/R, Domain, Type, RCODE, Answers), newest first, with an empty-state placeholder; mirrors the inspect_page layout/styles. - Wire DnsState into Sniffer (field, construction, capture reset) and drain `msg.dns_events` into it in `refresh_data`. - Register RunningPage::Dns (tab label, next/previous, Globe icon) and dispatch it in `view`; enable the previously-disabled Globe icon. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/pages/dns_page.rs | 160 ++++++++++++++++++++++++++++ src/gui/pages/mod.rs | 1 + src/gui/pages/types/running_page.rs | 21 ++-- src/gui/sniffer.rs | 10 ++ src/gui/types/dns_state.rs | 79 ++++++++++++++ src/gui/types/mod.rs | 1 + src/translations/translations_2.rs | 6 ++ src/utils/types/icon.rs | 4 +- 8 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 src/gui/pages/dns_page.rs create mode 100644 src/gui/types/dns_state.rs diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs new file mode 100644 index 000000000..6ed20ddcf --- /dev/null +++ b/src/gui/pages/dns_page.rs @@ -0,0 +1,160 @@ +//! The DNS analyzer page: a live log of the DNS messages parsed from captured +//! traffic (see [`crate::networking::dns`]). + +use iced::widget::scrollable::Direction; +use iced::widget::{Column, Container, Row, Scrollable, Space, Text}; +use iced::{Alignment, Length, Padding}; + +use crate::gui::components::tab::get_pages_tabs; +use crate::gui::styles::container::ContainerType; +use crate::gui::styles::rule::RuleType; +use crate::gui::styles::scrollbar::ScrollbarType; +use crate::gui::styles::style_constants::FONT_SIZE_FOOTER; +use crate::gui::styles::text::TextType; +use crate::gui::types::dns_state::DnsEntry; +use crate::gui::types::message::Message; +use crate::gui::types::settings::Settings; +use crate::utils::formatted_strings::{clip_text, get_formatted_timestamp}; +use crate::utils::types::icon::Icon; +use crate::{RunningPage, Sniffer, StyleType}; + +// Column widths (in pixels) for the DNS log table. +const W_TIME: f32 = 160.0; +const W_QR: f32 = 45.0; +const W_DOMAIN: f32 = 250.0; +const W_TYPE: f32 = 60.0; +const W_RCODE: f32 = 90.0; +const W_ANSWERS: f32 = 320.0; + +// Maximum characters displayed per cell before clipping. +const MAX_DOMAIN_CHARS: usize = 40; +const MAX_ANSWERS_CHARS: usize = 55; + +/// Builds the body of the DNS analyzer page. +pub fn dns_page(sniffer: &Sniffer) -> Container<'_, Message, StyleType> { + let Settings { language, .. } = sniffer.conf.settings; + + let tabs = get_pages_tabs(RunningPage::Dns, language, sniffer.unread_notifications); + + let body = Column::new() + .width(Length::Fill) + .padding(10) + .spacing(10) + .align_x(Alignment::Center) + .push( + Container::new(dns_log(sniffer)) + .align_x(Alignment::Center) + .padding(Padding::new(7.0).top(10).bottom(3)) + .width(947) + .height(Length::Fill) + .class(ContainerType::BorderedRound), + ); + + Container::new(Column::new().height(Length::Fill).push(tabs).push(body)) + .height(Length::Fill) +} + +/// The DNS log: a header row plus a scrollable list of entries (newest first), +/// or an empty-state placeholder when no DNS traffic has been seen yet. +fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { + let count = sniffer.dns_state.len(); + + let col = Column::new() + .width(Length::Fill) + .height(Length::Fill) + .align_x(Alignment::Start) + .push(summary_row(count)) + .push(Space::new().height(4)) + .push(header_row()) + .push(RuleType::Standard.horizontal(5)); + + if sniffer.dns_state.is_empty() { + return col.push(empty_state()); + } + + let mut scroll = Column::new().align_x(Alignment::Start); + // Newest entries first. + for entry in sniffer.dns_state.log.iter().rev() { + scroll = scroll.push(log_row(entry)); + } + + col.push( + Scrollable::with_direction(scroll, Direction::Vertical(ScrollbarType::properties())) + .height(Length::Fill) + .width(Length::Fill), + ) +} + +fn summary_row<'a>(count: usize) -> Row<'a, Message, StyleType> { + Row::new() + .padding([0, 2]) + .align_y(Alignment::Center) + .push(Text::new(format!("DNS messages captured: {count}")).class(TextType::Title)) +} + +fn header_row<'a>() -> Row<'a, Message, StyleType> { + let titles = [ + ("Time", W_TIME), + ("Q/R", W_QR), + ("Domain", W_DOMAIN), + ("Type", W_TYPE), + ("RCODE", W_RCODE), + ("Answer(s)", W_ANSWERS), + ]; + let mut row = Row::new().padding([0, 2]).align_y(Alignment::Center); + for (title, width) in titles { + row = row.push( + Container::new(Text::new(title).class(TextType::Title)) + .align_x(Alignment::Center) + .width(width), + ); + } + row +} + +fn log_row<'a>(entry: &DnsEntry) -> Row<'a, Message, StyleType> { + // Responses and queries are colored differently for quick scanning. + let text_type = if entry.is_response { + TextType::Incoming + } else { + TextType::Outgoing + }; + + let qtype = entry + .qtype + .map(|t| t.to_string()) + .unwrap_or_else(|| "-".to_string()); + let qr = if entry.is_response { "R" } else { "Q" }; + + let cells = [ + (get_formatted_timestamp(entry.timestamp), W_TIME), + (qr.to_string(), W_QR), + (clip_text(&entry.domain, MAX_DOMAIN_CHARS), W_DOMAIN), + (qtype, W_TYPE), + (entry.rcode.to_string(), W_RCODE), + (clip_text(&entry.answers, MAX_ANSWERS_CHARS), W_ANSWERS), + ]; + + let mut row = Row::new().padding([1, 2]).align_y(Alignment::Center); + for (value, width) in cells { + row = row.push( + Container::new(Text::new(value).size(FONT_SIZE_FOOTER).class(text_type)) + .align_x(Alignment::Center) + .width(width), + ); + } + row +} + +fn empty_state<'a>() -> Column<'a, Message, StyleType> { + Column::new() + .width(Length::Fill) + .height(Length::Fill) + .padding(20) + .align_x(Alignment::Center) + .push(Space::new().height(Length::Fill)) + .push(Icon::Globe.to_text().size(60)) + .push(Space::new().height(15)) + .push(Text::new("No DNS traffic captured yet")) + .push(Space::new().height(Length::FillPortion(2))) +} diff --git a/src/gui/pages/mod.rs b/src/gui/pages/mod.rs index 30af7dbaf..cd5ce7498 100644 --- a/src/gui/pages/mod.rs +++ b/src/gui/pages/mod.rs @@ -1,4 +1,5 @@ pub mod connection_details_page; +pub mod dns_page; pub mod initial_page; pub mod inspect_page; pub mod notifications_page; diff --git a/src/gui/pages/types/running_page.rs b/src/gui/pages/types/running_page.rs index f06939645..bca0afa51 100644 --- a/src/gui/pages/types/running_page.rs +++ b/src/gui/pages/types/running_page.rs @@ -1,6 +1,6 @@ use crate::gui::types::message::Message; use crate::translations::translations::{notifications_translation, overview_translation}; -use crate::translations::translations_2::inspect_translation; +use crate::translations::translations_2::{dns_translation, inspect_translation}; use crate::utils::types::icon::Icon; use crate::{Language, StyleType}; use serde::{Deserialize, Serialize}; @@ -15,13 +15,16 @@ pub enum RunningPage { Inspect, /// Notifications page. Notifications, + /// DNS analyzer page. + Dns, } impl RunningPage { - pub const ALL: [RunningPage; 3] = [ + pub const ALL: [RunningPage; 4] = [ RunningPage::Overview, RunningPage::Inspect, RunningPage::Notifications, + RunningPage::Dns, ]; pub fn get_tab_label(&self, language: Language) -> &str { @@ -29,6 +32,7 @@ impl RunningPage { RunningPage::Overview => overview_translation(language), RunningPage::Inspect => inspect_translation(language), RunningPage::Notifications => notifications_translation(language), + RunningPage::Dns => dns_translation(language), } } @@ -36,15 +40,17 @@ impl RunningPage { match self { RunningPage::Overview => RunningPage::Inspect, RunningPage::Inspect => RunningPage::Notifications, - RunningPage::Notifications => RunningPage::Overview, + RunningPage::Notifications => RunningPage::Dns, + RunningPage::Dns => RunningPage::Overview, } } pub fn previous(self) -> Self { match self { - RunningPage::Overview => RunningPage::Notifications, + RunningPage::Overview => RunningPage::Dns, RunningPage::Inspect => RunningPage::Overview, RunningPage::Notifications => RunningPage::Inspect, + RunningPage::Dns => RunningPage::Notifications, } } @@ -53,6 +59,7 @@ impl RunningPage { RunningPage::Overview => Icon::Overview, RunningPage::Inspect => Icon::Inspect, RunningPage::Notifications => Icon::Notification, + RunningPage::Dns => Icon::Globe, } .to_text() } @@ -68,7 +75,8 @@ mod tests { #[test] fn test_previous_running_page() { - assert_eq!(RunningPage::Overview.previous(), RunningPage::Notifications); + assert_eq!(RunningPage::Overview.previous(), RunningPage::Dns); + assert_eq!(RunningPage::Dns.previous(), RunningPage::Notifications); assert_eq!(RunningPage::Notifications.previous(), RunningPage::Inspect); assert_eq!(RunningPage::Inspect.previous(), RunningPage::Overview); } @@ -77,6 +85,7 @@ mod tests { fn test_next_running_page() { assert_eq!(RunningPage::Overview.next(), RunningPage::Inspect); assert_eq!(RunningPage::Inspect.next(), RunningPage::Notifications); - assert_eq!(RunningPage::Notifications.next(), RunningPage::Overview); + assert_eq!(RunningPage::Notifications.next(), RunningPage::Dns); + assert_eq!(RunningPage::Dns.next(), RunningPage::Overview); } } diff --git a/src/gui/sniffer.rs b/src/gui/sniffer.rs index 5b0696ec9..a04d0c27d 100644 --- a/src/gui/sniffer.rs +++ b/src/gui/sniffer.rs @@ -6,6 +6,7 @@ use crate::gui::components::header::header; use crate::gui::components::modal::{get_clear_all_overlay, get_exit_overlay, modal}; use crate::gui::components::types::my_modal::MyModal; use crate::gui::pages::connection_details_page::connection_details_page; +use crate::gui::pages::dns_page::dns_page; use crate::gui::pages::initial_page::initial_page; use crate::gui::pages::inspect_page::inspect_page; use crate::gui::pages::notifications_page::notifications_page; @@ -22,6 +23,7 @@ use crate::gui::styles::types::custom_palette::CustomPalette; use crate::gui::styles::types::gradient_type::GradientType; use crate::gui::styles::types::palette::Palette; use crate::gui::types::conf::Conf; +use crate::gui::types::dns_state::DnsState; use crate::gui::types::favorite::FavoriteKey; use crate::gui::types::message::Message; use crate::gui::types::settings::Settings; @@ -142,6 +144,8 @@ pub struct Sniffer { pub freeze_tx: Option>, /// State of the port to program lookups pub program_lookup: Option, + /// State backing the DNS analyzer page + pub dns_state: DnsState, } impl Sniffer { @@ -189,6 +193,7 @@ impl Sniffer { frozen: false, freeze_tx: None, program_lookup: None, + dns_state: DnsState::default(), } } @@ -391,6 +396,7 @@ impl Sniffer { RunningPage::Overview => overview_page(self), RunningPage::Inspect => inspect_page(self), RunningPage::Notifications => notifications_page(self), + RunningPage::Dns => dns_page(self), } } } @@ -907,6 +913,9 @@ impl Sniffer { } fn refresh_data(&mut self, mut msg: InfoTraffic, no_more_packets: bool) { + // Drain the DNS events accumulated during this interval into the DNS + // page state (they are not retained in the merged `info_traffic`). + self.dns_state.ingest(std::mem::take(&mut msg.dns_events)); self.info_traffic .refresh(&mut msg, &mut self.program_lookup); if self.info_traffic.tot_data_info.tot_data(DataRepr::Packets) == 0 { @@ -1063,6 +1072,7 @@ impl Sniffer { self.current_capture_rx = (self.current_capture_rx.0 + 1, None); self.info_traffic = InfoTraffic::default(); self.addresses_resolved = HashMap::new(); + self.dns_state = DnsState::default(); self.logged_notifications = LoggedNotifications::default(); self.pcap_error = None; self.traffic_chart = TrafficChart::new(style, language, self.conf.data_repr); diff --git a/src/gui/types/dns_state.rs b/src/gui/types/dns_state.rs new file mode 100644 index 000000000..4988d9e85 --- /dev/null +++ b/src/gui/types/dns_state.rs @@ -0,0 +1,79 @@ +//! GUI-side state for the DNS analyzer page. +//! +//! The capture backend emits [`DnsEvent`]s; the GUI drains them each tick and +//! folds them into this state, which backs the live DNS log shown in the DNS +//! page. + +use std::collections::VecDeque; +use std::net::IpAddr; + +use crate::networking::dns::types::{DnsEvent, DnsRCode, DnsRecordType}; +use crate::networking::types::protocol::Protocol; +use crate::utils::types::timestamp::Timestamp; + +/// Maximum number of entries kept in the live log (older ones are discarded). +const MAX_LOG_ENTRIES: usize = 2000; + +/// State backing the DNS page. +#[derive(Debug, Default, Clone)] +pub struct DnsState { + /// Live log of observed DNS messages, oldest first. + pub log: VecDeque, +} + +impl DnsState { + /// Folds a batch of freshly parsed DNS events into the state. + pub fn ingest(&mut self, events: Vec) { + for event in events { + if self.log.len() >= MAX_LOG_ENTRIES { + self.log.pop_front(); + } + self.log.push_back(DnsEntry::from(event)); + } + } + + /// Total number of logged DNS messages. + pub fn len(&self) -> usize { + self.log.len() + } + + pub fn is_empty(&self) -> bool { + self.log.is_empty() + } +} + +/// A single, display-ready row of the DNS log. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DnsEntry { + pub timestamp: Timestamp, + pub src: IpAddr, + pub dst: IpAddr, + pub transport: Protocol, + pub id: u16, + pub is_response: bool, + /// Queried domain name, or "-" if the message carried no question. + pub domain: String, + /// Queried record type, if a question was present. + pub qtype: Option, + pub rcode: DnsRCode, + /// Comma-separated summary of the answers (empty for queries). + pub answers: String, +} + +impl From for DnsEntry { + fn from(event: DnsEvent) -> Self { + let message = &event.message; + DnsEntry { + timestamp: event.timestamp, + src: event.src, + dst: event.dst, + transport: event.transport, + id: message.id, + is_response: message.is_response, + domain: message.query_name().unwrap_or("-").to_string(), + qtype: message.query_type(), + rcode: message.rcode, + answers: message.answers_summary(), + } + } +} diff --git a/src/gui/types/mod.rs b/src/gui/types/mod.rs index 743e119d0..094662e37 100644 --- a/src/gui/types/mod.rs +++ b/src/gui/types/mod.rs @@ -1,5 +1,6 @@ pub mod conf; pub mod config_window; +pub mod dns_state; pub mod export_pcap; pub mod favorite; pub mod filters; diff --git a/src/translations/translations_2.rs b/src/translations/translations_2.rs index 93ab85ee5..fe6965a1a 100644 --- a/src/translations/translations_2.rs +++ b/src/translations/translations_2.rs @@ -29,6 +29,12 @@ pub fn new_version_available_translation(language: Language) -> &'static str { } } +/// Tab label for the DNS analyzer page. "DNS" is a universal acronym, so the +/// same label is used across all languages. +pub fn dns_translation(_language: Language) -> &'static str { + "DNS" +} + pub fn inspect_translation(language: Language) -> &'static str { match language { Language::EN => "Inspect", diff --git a/src/utils/types/icon.rs b/src/utils/types/icon.rs index 083803eb7..9ce069d1e 100644 --- a/src/utils/types/icon.rs +++ b/src/utils/types/icon.rs @@ -26,7 +26,7 @@ pub enum Icon { FunnelX, FunnelStar, GitHub, - // Globe, + Globe, HalfSun, Hourglass1, Hourglass2, @@ -87,7 +87,7 @@ impl Icon { Icon::FunnelX => ';', Icon::FunnelStar => '6', Icon::GitHub => 'H', - // Icon::Globe => 'c', + Icon::Globe => 'c', Icon::HalfSun => 'K', Icon::Hourglass1 => '1', Icon::Hourglass2 => '2', From 1e292edafda1a0bfa6b15e57c5eae7e44c0f194a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:22:57 -0300 Subject: [PATCH 04/13] docs: mention DNS protocol analysis in the feature list Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 918416ee4..911e6ebfe 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ Not what you're looking for? Check out [alternative installation methods](https: - โญ save your **favorite** network hosts, services, and programs - ๐Ÿ“Œ keep an eye on your network even when the application is **minimized** - ๏ธ๐Ÿ”Ž search and **inspect** each of your network connections in real time +- ๐Ÿงฌ analyze the **DNS protocol** (RFC 1035) with a dedicated live log of queries and responses - ๐Ÿ”‰ set custom **notifications** to inform you when defined network events occur - ๐Ÿšซ import custom **IP blacklists** to highlight potentially dangerous connections - ๐ŸŽจ choose the **style** that fits you the most, including custom themes support From 761e76fba59cb6c2d68fa7f8b235a86356c6687c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:40:51 -0300 Subject: [PATCH 05/13] feat(dns): add query/response latency and most-queried ranking Correlate each response with its originating query by transaction id and (client, server) endpoint pair to compute resolution latency, shown in a new "Latency" column on response rows. Count queries per domain and surface the top 5 in a "Top domains" ranking line on the DNS page. In-flight queries are bounded (cap 10k) to limit memory when queries go unanswered. Adds unit tests for latency correlation, orphan responses, and per-domain ranking. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/pages/dns_page.rs | 49 ++++++++++-- src/gui/types/dns_state.rs | 153 +++++++++++++++++++++++++++++++++++-- 2 files changed, 189 insertions(+), 13 deletions(-) diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs index 6ed20ddcf..6c81c9a30 100644 --- a/src/gui/pages/dns_page.rs +++ b/src/gui/pages/dns_page.rs @@ -21,14 +21,18 @@ use crate::{RunningPage, Sniffer, StyleType}; // Column widths (in pixels) for the DNS log table. const W_TIME: f32 = 160.0; const W_QR: f32 = 45.0; -const W_DOMAIN: f32 = 250.0; -const W_TYPE: f32 = 60.0; -const W_RCODE: f32 = 90.0; -const W_ANSWERS: f32 = 320.0; +const W_DOMAIN: f32 = 240.0; +const W_TYPE: f32 = 55.0; +const W_RCODE: f32 = 85.0; +const W_LATENCY: f32 = 80.0; +const W_ANSWERS: f32 = 280.0; // Maximum characters displayed per cell before clipping. -const MAX_DOMAIN_CHARS: usize = 40; -const MAX_ANSWERS_CHARS: usize = 55; +const MAX_DOMAIN_CHARS: usize = 38; +const MAX_ANSWERS_CHARS: usize = 48; + +/// Number of domains shown in the "most queried" ranking. +const TOP_DOMAINS: usize = 5; /// Builds the body of the DNS analyzer page. pub fn dns_page(sniffer: &Sniffer) -> Container<'_, Message, StyleType> { @@ -64,6 +68,7 @@ fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { .height(Length::Fill) .align_x(Alignment::Start) .push(summary_row(count)) + .push(ranking_row(sniffer)) .push(Space::new().height(4)) .push(header_row()) .push(RuleType::Standard.horizontal(5)); @@ -92,6 +97,26 @@ fn summary_row<'a>(count: usize) -> Row<'a, Message, StyleType> { .push(Text::new(format!("DNS messages captured: {count}")).class(TextType::Title)) } +/// A compact "most queried domains" ranking line. +fn ranking_row<'a>(sniffer: &Sniffer) -> Row<'a, Message, StyleType> { + let top = sniffer.dns_state.top_domains(TOP_DOMAINS); + let mut row = Row::new() + .padding([0, 2]) + .spacing(12) + .align_y(Alignment::Center); + if top.is_empty() { + return row; + } + row = row.push(Text::new("Top domains:").size(FONT_SIZE_FOOTER).class(TextType::Subtitle)); + for (rank, (domain, count)) in top.into_iter().enumerate() { + row = row.push( + Text::new(format!("{}. {} ({})", rank + 1, clip_text(&domain, 30), count)) + .size(FONT_SIZE_FOOTER), + ); + } + row +} + fn header_row<'a>() -> Row<'a, Message, StyleType> { let titles = [ ("Time", W_TIME), @@ -99,6 +124,7 @@ fn header_row<'a>() -> Row<'a, Message, StyleType> { ("Domain", W_DOMAIN), ("Type", W_TYPE), ("RCODE", W_RCODE), + ("Latency", W_LATENCY), ("Answer(s)", W_ANSWERS), ]; let mut row = Row::new().padding([0, 2]).align_y(Alignment::Center); @@ -126,12 +152,23 @@ fn log_row<'a>(entry: &DnsEntry) -> Row<'a, Message, StyleType> { .unwrap_or_else(|| "-".to_string()); let qr = if entry.is_response { "R" } else { "Q" }; + // Latency only applies to responses matched to their query. + let latency = if entry.is_response { + entry + .latency_ms + .map(|ms| format!("{ms:.1} ms")) + .unwrap_or_else(|| "-".to_string()) + } else { + String::new() + }; + let cells = [ (get_formatted_timestamp(entry.timestamp), W_TIME), (qr.to_string(), W_QR), (clip_text(&entry.domain, MAX_DOMAIN_CHARS), W_DOMAIN), (qtype, W_TYPE), (entry.rcode.to_string(), W_RCODE), + (latency, W_LATENCY), (clip_text(&entry.answers, MAX_ANSWERS_CHARS), W_ANSWERS), ]; diff --git a/src/gui/types/dns_state.rs b/src/gui/types/dns_state.rs index 4988d9e85..c6b84da15 100644 --- a/src/gui/types/dns_state.rs +++ b/src/gui/types/dns_state.rs @@ -1,10 +1,10 @@ //! GUI-side state for the DNS analyzer page. //! //! The capture backend emits [`DnsEvent`]s; the GUI drains them each tick and -//! folds them into this state, which backs the live DNS log shown in the DNS -//! page. +//! folds them into this state, which backs the live DNS log, the per-domain +//! ranking, and the query/response latency correlation shown in the DNS page. -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::net::IpAddr; use crate::networking::dns::types::{DnsEvent, DnsRCode, DnsRecordType}; @@ -13,22 +13,54 @@ use crate::utils::types::timestamp::Timestamp; /// Maximum number of entries kept in the live log (older ones are discarded). const MAX_LOG_ENTRIES: usize = 2000; +/// Safety cap on the number of in-flight (unanswered) queries tracked for +/// latency correlation, to bound memory if many queries go unanswered. +const MAX_PENDING_QUERIES: usize = 10_000; + +/// Correlation key matching a query with its response: transaction id plus the +/// (client, server) endpoint pair. For a query the client is the source and the +/// server the destination; for a response the roles are reversed. +type CorrelationKey = (u16, IpAddr, IpAddr); /// State backing the DNS page. #[derive(Debug, Default, Clone)] pub struct DnsState { /// Live log of observed DNS messages, oldest first. pub log: VecDeque, + /// Number of queries seen per domain, for the "most queried" ranking. + pub ranking: HashMap, + /// Timestamp of in-flight queries awaiting a response, keyed for + /// correlation, used to compute resolution latency. + pending: HashMap, } impl DnsState { /// Folds a batch of freshly parsed DNS events into the state. pub fn ingest(&mut self, events: Vec) { for event in events { + let mut entry = DnsEntry::from(&event); + + if event.message.is_response { + // Match against the originating query: client = dst, server = src. + let key = (entry.id, event.dst, event.src); + if let Some(query_ts) = self.pending.remove(&key) { + entry.latency_ms = latency_ms(query_ts, event.timestamp); + } + } else { + // Record the query for later correlation and count it in the ranking. + if self.pending.len() < MAX_PENDING_QUERIES { + let key = (entry.id, event.src, event.dst); + self.pending.insert(key, event.timestamp); + } + if !entry.domain.is_empty() && entry.domain != "-" { + *self.ranking.entry(entry.domain.clone()).or_insert(0) += 1; + } + } + if self.log.len() >= MAX_LOG_ENTRIES { self.log.pop_front(); } - self.log.push_back(DnsEntry::from(event)); + self.log.push_back(entry); } } @@ -40,10 +72,34 @@ impl DnsState { pub fn is_empty(&self) -> bool { self.log.is_empty() } + + /// The `n` most queried domains, most frequent first. + pub fn top_domains(&self, n: usize) -> Vec<(String, u64)> { + let mut ranked: Vec<(String, u64)> = self + .ranking + .iter() + .map(|(d, c)| (d.clone(), *c)) + .collect(); + // Sort by descending count, then by domain name for a stable order. + ranked.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + ranked.truncate(n); + ranked + } +} + +/// Computes resolution latency in milliseconds between a query and its +/// response. Returns `None` if the timestamps are unusable or out of order. +fn latency_ms(query: Timestamp, response: Timestamp) -> Option { + let q = query.to_usecs()?; + let r = response.to_usecs()?; + if r < q { + return None; + } + Some((r - q) as f64 / 1000.0) } /// A single, display-ready row of the DNS log. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub struct DnsEntry { pub timestamp: Timestamp, pub src: IpAddr, @@ -58,10 +114,13 @@ pub struct DnsEntry { pub rcode: DnsRCode, /// Comma-separated summary of the answers (empty for queries). pub answers: String, + /// Resolution latency in milliseconds (set on responses that were matched + /// to a previously seen query). + pub latency_ms: Option, } -impl From for DnsEntry { - fn from(event: DnsEvent) -> Self { +impl From<&DnsEvent> for DnsEntry { + fn from(event: &DnsEvent) -> Self { let message = &event.message; DnsEntry { timestamp: event.timestamp, @@ -74,6 +133,86 @@ impl From for DnsEntry { qtype: message.query_type(), rcode: message.rcode, answers: message.answers_summary(), + latency_ms: None, } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::networking::dns::types::{DnsFlags, DnsMessage, DnsQuestion}; + use std::net::Ipv4Addr; + + const CLIENT: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 2)); + const SERVER: IpAddr = IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)); + + fn message(id: u16, is_response: bool, domain: &str) -> DnsMessage { + DnsMessage { + id, + is_response, + opcode: 0, + flags: DnsFlags::default(), + rcode: DnsRCode::NoError, + questions: vec![DnsQuestion { + name: domain.to_string(), + qtype: DnsRecordType::A, + qclass: 1, + }], + answers: Vec::new(), + } + } + + fn query(id: u16, domain: &str, secs: i64) -> DnsEvent { + DnsEvent { + timestamp: Timestamp::new(secs, 0), + src: CLIENT, + dst: SERVER, + transport: Protocol::UDP, + message: message(id, false, domain), + } + } + + fn response(id: u16, domain: &str, secs: i64, usecs: i64) -> DnsEvent { + DnsEvent { + timestamp: Timestamp::new(secs, usecs), + src: SERVER, + dst: CLIENT, + transport: Protocol::UDP, + message: message(id, true, domain), + } + } + + #[test] + fn correlates_response_to_query_and_computes_latency() { + let mut state = DnsState::default(); + state.ingest(vec![ + query(0x1234, "example.com", 10), + response(0x1234, "example.com", 10, 25_000), // 25 ms later + ]); + assert_eq!(state.len(), 2); + let resp = state.log.back().unwrap(); + assert!(resp.is_response); + assert_eq!(resp.latency_ms, Some(25.0)); + } + + #[test] + fn response_without_matching_query_has_no_latency() { + let mut state = DnsState::default(); + state.ingest(vec![response(0x9999, "orphan.com", 5, 0)]); + assert_eq!(state.log.back().unwrap().latency_ms, None); + } + + #[test] + fn ranking_counts_queries_per_domain() { + let mut state = DnsState::default(); + state.ingest(vec![ + query(1, "a.com", 1), + query(2, "a.com", 2), + query(3, "b.com", 3), + response(1, "a.com", 1, 1000), // responses don't add to ranking + ]); + let top = state.top_domains(5); + assert_eq!(top, vec![("a.com".to_string(), 2), ("b.com".to_string(), 1)]); + } +} From 3cf45910407d4ac000ed57ecc50fed0570509d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:47:04 -0300 Subject: [PATCH 06/13] feat(dns): filter the DNS log by record type and response code Add two dropdown filters to the DNS page: record type (A, AAAA, CNAME, MX, TXT, NS, PTR, SOA) and response code (NOERROR, NXDOMAIN, SERVFAIL, REFUSED, FORMERR, NOTIMP). The summary line shows the filtered count and an empty-state message distinguishes "no traffic yet" from "no matches". DnsFilter lives in gui state, is wired through new Message variants, and applied when rendering the log. Adds a unit test for the matching logic. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/pages/dns_page.rs | 68 ++++++++++++--- src/gui/sniffer.rs | 7 +- src/gui/types/dns_state.rs | 166 +++++++++++++++++++++++++++++++++++++ src/gui/types/message.rs | 5 ++ 4 files changed, 233 insertions(+), 13 deletions(-) diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs index 6c81c9a30..501f073e6 100644 --- a/src/gui/pages/dns_page.rs +++ b/src/gui/pages/dns_page.rs @@ -2,7 +2,7 @@ //! traffic (see [`crate::networking::dns`]). use iced::widget::scrollable::Direction; -use iced::widget::{Column, Container, Row, Scrollable, Space, Text}; +use iced::widget::{Column, Container, PickList, Row, Scrollable, Space, Text}; use iced::{Alignment, Length, Padding}; use crate::gui::components::tab::get_pages_tabs; @@ -11,7 +11,7 @@ use crate::gui::styles::rule::RuleType; use crate::gui::styles::scrollbar::ScrollbarType; use crate::gui::styles::style_constants::FONT_SIZE_FOOTER; use crate::gui::styles::text::TextType; -use crate::gui::types::dns_state::DnsEntry; +use crate::gui::types::dns_state::{DnsEntry, DnsRCodeFilter, DnsTypeFilter}; use crate::gui::types::message::Message; use crate::gui::types::settings::Settings; use crate::utils::formatted_strings::{clip_text, get_formatted_timestamp}; @@ -61,25 +61,39 @@ pub fn dns_page(sniffer: &Sniffer) -> Container<'_, Message, StyleType> { /// The DNS log: a header row plus a scrollable list of entries (newest first), /// or an empty-state placeholder when no DNS traffic has been seen yet. fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { - let count = sniffer.dns_state.len(); + let filter = sniffer.dns_filter; + // Newest first, with the active filters applied. + let matching: Vec<&DnsEntry> = sniffer + .dns_state + .log + .iter() + .rev() + .filter(|e| filter.matches(e)) + .collect(); let col = Column::new() .width(Length::Fill) .height(Length::Fill) .align_x(Alignment::Start) - .push(summary_row(count)) + .push(summary_row(sniffer.dns_state.len(), matching.len(), filter.is_active())) .push(ranking_row(sniffer)) .push(Space::new().height(4)) + .push(filter_row(sniffer)) + .push(Space::new().height(4)) .push(header_row()) .push(RuleType::Standard.horizontal(5)); - if sniffer.dns_state.is_empty() { - return col.push(empty_state()); + if matching.is_empty() { + let message = if sniffer.dns_state.is_empty() { + "No DNS traffic captured yet" + } else { + "No DNS messages match the current filter" + }; + return col.push(empty_state(message)); } let mut scroll = Column::new().align_x(Alignment::Start); - // Newest entries first. - for entry in sniffer.dns_state.log.iter().rev() { + for entry in matching { scroll = scroll.push(log_row(entry)); } @@ -90,11 +104,41 @@ fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { ) } -fn summary_row<'a>(count: usize) -> Row<'a, Message, StyleType> { +/// Record-type and response-code filter dropdowns. +fn filter_row<'a>(sniffer: &Sniffer) -> Row<'a, Message, StyleType> { + let type_pick = PickList::new( + &DnsTypeFilter::ALL[..], + Some(sniffer.dns_filter.record_type), + Message::DnsTypeFilterSelection, + ) + .padding([2, 7]); + + let rcode_pick = PickList::new( + &DnsRCodeFilter::ALL[..], + Some(sniffer.dns_filter.rcode), + Message::DnsRCodeFilterSelection, + ) + .padding([2, 7]); + + Row::new() + .padding([0, 2]) + .spacing(10) + .align_y(Alignment::Center) + .push(Text::new("Filter:").size(FONT_SIZE_FOOTER).class(TextType::Subtitle)) + .push(type_pick) + .push(rcode_pick) +} + +fn summary_row<'a>(total: usize, shown: usize, filter_active: bool) -> Row<'a, Message, StyleType> { + let label = if filter_active { + format!("DNS messages captured: {total} (showing {shown})") + } else { + format!("DNS messages captured: {total}") + }; Row::new() .padding([0, 2]) .align_y(Alignment::Center) - .push(Text::new(format!("DNS messages captured: {count}")).class(TextType::Title)) + .push(Text::new(label).class(TextType::Title)) } /// A compact "most queried domains" ranking line. @@ -183,7 +227,7 @@ fn log_row<'a>(entry: &DnsEntry) -> Row<'a, Message, StyleType> { row } -fn empty_state<'a>() -> Column<'a, Message, StyleType> { +fn empty_state<'a>(message: &'a str) -> Column<'a, Message, StyleType> { Column::new() .width(Length::Fill) .height(Length::Fill) @@ -192,6 +236,6 @@ fn empty_state<'a>() -> Column<'a, Message, StyleType> { .push(Space::new().height(Length::Fill)) .push(Icon::Globe.to_text().size(60)) .push(Space::new().height(15)) - .push(Text::new("No DNS traffic captured yet")) + .push(Text::new(message)) .push(Space::new().height(Length::FillPortion(2))) } diff --git a/src/gui/sniffer.rs b/src/gui/sniffer.rs index a04d0c27d..bfd62416e 100644 --- a/src/gui/sniffer.rs +++ b/src/gui/sniffer.rs @@ -23,7 +23,7 @@ use crate::gui::styles::types::custom_palette::CustomPalette; use crate::gui::styles::types::gradient_type::GradientType; use crate::gui::styles::types::palette::Palette; use crate::gui::types::conf::Conf; -use crate::gui::types::dns_state::DnsState; +use crate::gui::types::dns_state::{DnsFilter, DnsState}; use crate::gui::types::favorite::FavoriteKey; use crate::gui::types::message::Message; use crate::gui::types::settings::Settings; @@ -146,6 +146,8 @@ pub struct Sniffer { pub program_lookup: Option, /// State backing the DNS analyzer page pub dns_state: DnsState, + /// Active filters on the DNS analyzer page + pub dns_filter: DnsFilter, } impl Sniffer { @@ -194,6 +196,7 @@ impl Sniffer { freeze_tx: None, program_lookup: None, dns_state: DnsState::default(), + dns_filter: DnsFilter::default(), } } @@ -322,6 +325,8 @@ impl Sniffer { Message::ResetButtonPressed => return self.reset_button_pressed(), Message::CtrlDPressed => self.ctrl_d_pressed(), Message::Search(parameters) => self.search(parameters), + Message::DnsTypeFilterSelection(filter) => self.dns_filter.record_type = filter, + Message::DnsRCodeFilterSelection(filter) => self.dns_filter.rcode = filter, Message::UpdatePageNumber(increment) => self.update_page_number(increment), Message::ArrowPressed(increment) => self.arrow_pressed(increment), Message::WindowFocused => self.window_focused(), diff --git a/src/gui/types/dns_state.rs b/src/gui/types/dns_state.rs index c6b84da15..ba2110fd4 100644 --- a/src/gui/types/dns_state.rs +++ b/src/gui/types/dns_state.rs @@ -98,6 +98,138 @@ fn latency_ms(query: Timestamp, response: Timestamp) -> Option { Some((r - q) as f64 / 1000.0) } +/// Active filters on the DNS page. `All` variants disable the corresponding +/// filter. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DnsFilter { + pub record_type: DnsTypeFilter, + pub rcode: DnsRCodeFilter, +} + +impl DnsFilter { + /// Whether the given log entry passes the active filters. + pub fn matches(&self, entry: &DnsEntry) -> bool { + self.record_type.matches(entry.qtype) && self.rcode.matches(entry.rcode) + } + + /// Whether any filter is active. + pub fn is_active(&self) -> bool { + self.record_type != DnsTypeFilter::All || self.rcode != DnsRCodeFilter::All + } +} + +/// Record-type filter selectable on the DNS page. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DnsTypeFilter { + #[default] + All, + A, + Aaaa, + Cname, + Mx, + Txt, + Ns, + Ptr, + Soa, +} + +impl DnsTypeFilter { + pub const ALL: [DnsTypeFilter; 9] = [ + DnsTypeFilter::All, + DnsTypeFilter::A, + DnsTypeFilter::Aaaa, + DnsTypeFilter::Cname, + DnsTypeFilter::Mx, + DnsTypeFilter::Txt, + DnsTypeFilter::Ns, + DnsTypeFilter::Ptr, + DnsTypeFilter::Soa, + ]; + + fn matches(self, qtype: Option) -> bool { + let expected = match self { + DnsTypeFilter::All => return true, + DnsTypeFilter::A => DnsRecordType::A, + DnsTypeFilter::Aaaa => DnsRecordType::Aaaa, + DnsTypeFilter::Cname => DnsRecordType::Cname, + DnsTypeFilter::Mx => DnsRecordType::Mx, + DnsTypeFilter::Txt => DnsRecordType::Txt, + DnsTypeFilter::Ns => DnsRecordType::Ns, + DnsTypeFilter::Ptr => DnsRecordType::Ptr, + DnsTypeFilter::Soa => DnsRecordType::Soa, + }; + qtype == Some(expected) + } +} + +impl std::fmt::Display for DnsTypeFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DnsTypeFilter::All => write!(f, "Type: all"), + DnsTypeFilter::A => write!(f, "A"), + DnsTypeFilter::Aaaa => write!(f, "AAAA"), + DnsTypeFilter::Cname => write!(f, "CNAME"), + DnsTypeFilter::Mx => write!(f, "MX"), + DnsTypeFilter::Txt => write!(f, "TXT"), + DnsTypeFilter::Ns => write!(f, "NS"), + DnsTypeFilter::Ptr => write!(f, "PTR"), + DnsTypeFilter::Soa => write!(f, "SOA"), + } + } +} + +/// Response-code filter selectable on the DNS page. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum DnsRCodeFilter { + #[default] + All, + NoError, + NxDomain, + ServFail, + Refused, + FormErr, + NotImpl, +} + +impl DnsRCodeFilter { + pub const ALL: [DnsRCodeFilter; 7] = [ + DnsRCodeFilter::All, + DnsRCodeFilter::NoError, + DnsRCodeFilter::NxDomain, + DnsRCodeFilter::ServFail, + DnsRCodeFilter::Refused, + DnsRCodeFilter::FormErr, + DnsRCodeFilter::NotImpl, + ]; + + fn matches(self, rcode: DnsRCode) -> bool { + let expected = match self { + DnsRCodeFilter::All => return true, + DnsRCodeFilter::NoError => DnsRCode::NoError, + DnsRCodeFilter::NxDomain => DnsRCode::NxDomain, + DnsRCodeFilter::ServFail => DnsRCode::ServFail, + DnsRCodeFilter::Refused => DnsRCode::Refused, + DnsRCodeFilter::FormErr => DnsRCode::FormErr, + DnsRCodeFilter::NotImpl => DnsRCode::NotImpl, + }; + rcode == expected + } +} + +impl std::fmt::Display for DnsRCodeFilter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DnsRCodeFilter::All => write!(f, "RCODE: all"), + DnsRCodeFilter::NoError => write!(f, "NOERROR"), + DnsRCodeFilter::NxDomain => write!(f, "NXDOMAIN"), + DnsRCodeFilter::ServFail => write!(f, "SERVFAIL"), + DnsRCodeFilter::Refused => write!(f, "REFUSED"), + DnsRCodeFilter::FormErr => write!(f, "FORMERR"), + DnsRCodeFilter::NotImpl => write!(f, "NOTIMP"), + } + } +} + /// A single, display-ready row of the DNS log. #[derive(Debug, Clone, PartialEq)] pub struct DnsEntry { @@ -203,6 +335,40 @@ mod tests { assert_eq!(state.log.back().unwrap().latency_ms, None); } + #[test] + fn filter_matches_by_type_and_rcode() { + let mut state = DnsState::default(); + state.ingest(vec![ + query(1, "a.com", 1), + response(2, "b.com", 2, 0), + ]); + let a_query = state.log.front().unwrap(); + + // Default filter accepts everything. + assert!(DnsFilter::default().matches(a_query)); + assert!(!DnsFilter::default().is_active()); + + // Type filter: A matches the A query, AAAA does not. + let f_a = DnsFilter { + record_type: DnsTypeFilter::A, + rcode: DnsRCodeFilter::All, + }; + assert!(f_a.matches(a_query)); + assert!(f_a.is_active()); + let f_aaaa = DnsFilter { + record_type: DnsTypeFilter::Aaaa, + rcode: DnsRCodeFilter::All, + }; + assert!(!f_aaaa.matches(a_query)); + + // RCODE filter: NXDOMAIN should not match a NOERROR entry. + let f_nx = DnsFilter { + record_type: DnsTypeFilter::All, + rcode: DnsRCodeFilter::NxDomain, + }; + assert!(!f_nx.matches(a_query)); + } + #[test] fn ranking_counts_queries_per_domain() { let mut state = DnsState::default(); diff --git a/src/gui/types/message.rs b/src/gui/types/message.rs index e376eeca8..4842ef806 100644 --- a/src/gui/types/message.rs +++ b/src/gui/types/message.rs @@ -2,6 +2,7 @@ use crate::gui::components::types::my_modal::MyModal; use crate::gui::pages::types::running_page::RunningPage; use crate::gui::pages::types::settings_page::SettingsPage; use crate::gui::styles::types::gradient_type::GradientType; +use crate::gui::types::dns_state::{DnsRCodeFilter, DnsTypeFilter}; use crate::gui::types::favorite::FavoriteKey; use crate::networking::traffic_preview::TrafficPreview; use crate::networking::types::capture_context::CaptureSourcePicklist; @@ -95,6 +96,10 @@ pub enum Message { CtrlDPressed, /// Update search parameters of inspect page Search(SearchParameters), + /// Update the record-type filter of the DNS page + DnsTypeFilterSelection(DnsTypeFilter), + /// Update the response-code filter of the DNS page + DnsRCodeFilterSelection(DnsRCodeFilter), /// Update page result number in inspect UpdatePageNumber(bool), /// Left (false) or Right (true) arrow key has been pressed From 26bb685194de968d46997af356ad36dcd57888f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:52:17 -0300 Subject: [PATCH 07/13] test(dns): add sample DNS pcap for offline demonstration A 2-packet Ethernet/IPv4/UDP capture (query + compressed A response for google.com) to reproduce and validate the DNS analyzer offline, with a README describing the expected output and the Wireshark cross-check. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/samples/README.md | 22 ++++++++++++++++++++++ docs/samples/dns_sample.pcap | Bin 0 -> 212 bytes 2 files changed, 22 insertions(+) create mode 100644 docs/samples/README.md create mode 100644 docs/samples/dns_sample.pcap diff --git a/docs/samples/README.md b/docs/samples/README.md new file mode 100644 index 000000000..faae7ddf6 --- /dev/null +++ b/docs/samples/README.md @@ -0,0 +1,22 @@ +# DNS sample capture + +`dns_sample.pcap` is a tiny, self-contained capture used to demonstrate and +validate the DNS analyzer offline (no live traffic needed). + +It contains two Ethernet/IPv4/UDP frames on port 53: + +1. **Query** โ€” `google.com`, type A (transaction id `0x1234`). +2. **Response** โ€” `google.com` A โ†’ `8.8.8.8`, TTL 300, using a DNS + name-compression pointer; sent ~18 ms after the query. + +## How to use + +1. Open Sniffnet and import this file (capture from file). +2. Open the **DNS** tab: you should see one `Q` and one `R` row for + `google.com`, type `A`, RCODE `NOERROR`, the answer `8.8.8.8`, and a + resolution latency of ~18 ms. +3. Optionally open the same file in Wireshark and compare the dissected + fields (id, flags, QNAME, QTYPE, RCODE, RDATA) โ€” they must match. + +The same byte vectors are exercised by the unit tests in +`src/networking/dns/parser.rs`. diff --git a/docs/samples/dns_sample.pcap b/docs/samples/dns_sample.pcap new file mode 100644 index 0000000000000000000000000000000000000000..b4bc30d3fc83050468b0a51af3ff3cf32e6d1900 GIT binary patch literal 212 zcmca|c+)~A1{MYw`2U}Qff2;{7@P{C+@P2V#Q6s%895kS85k@C1sxb11S4J@Si!)= z0R-n;7)%*d7#M_1fcn8kFfg#C=jW&Aq%tSx=Yqr-L8b-(jSK@C2Eq{YAZCM2^-xj* mnd*KLWFp9922-H9CLmKA8^9(2b)lPjfCnPOsKdYl(gy&S#v#N2 literal 0 HcmV?d00001 From 0083129a1ccb6dcda707aba2bdadd38b41bd59ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:53:27 -0300 Subject: [PATCH 08/13] docs: add DNS analyzer usage section to README Document how to build, run and try the DNS tab, including offline reproduction via the sample pcap, satisfying the "installation / execution / usage example" requirement. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/README.md b/README.md index 911e6ebfe..5a26951b5 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,27 @@ Not what you're looking for? Check out [alternative installation methods](https: - ๐ŸŽจ choose the **style** that fits you the most, including custom themes support - ...and more! +## DNS analyzer (course project feature) + +This fork adds a dedicated **DNS** tab that parses the DNS protocol +(RFC 1035) directly from the bytes of UDP/TCP traffic on port 53 and shows, +in real time: a query/response log (domain, type, RCODE, resolved values), +per-query **resolution latency**, a **most-queried domains** ranking, and +**filters** by record type and response code. + +**Try it:** + +```sh +cargo build +sudo ./target/debug/sniffnet # capture needs privileges +``` + +Start a capture, open the **DNS** tab (globe icon), then generate traffic +(`nslookup example.com 8.8.8.8`). To reproduce offline without live traffic, +import [`docs/samples/dns_sample.pcap`](docs/samples/) โ€” see its README for +the expected output. Implementation lives in `src/networking/dns/` and +`src/gui/pages/dns_page.rs`. + ## User manual Do you want to **learn more**?
From c07931ec8f43feb4b1ebb7616a35557fb3d60414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 18:58:40 -0300 Subject: [PATCH 09/13] test(dns): add end-to-end pcap parsing test Read docs/samples/dns_sample.pcap and run every packet through the exact production path (get_sniffable_headers -> payload extraction -> analyze_headers -> parse_dns), asserting the recovered query/response for google.com (type A, answer 8.8.8.8). Validates the full capture-to-parse chain, not just the parser in isolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/networking/parse_packets.rs | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/networking/parse_packets.rs b/src/networking/parse_packets.rs index 010e9d058..d74be240e 100644 --- a/src/networking/parse_packets.rs +++ b/src/networking/parse_packets.rs @@ -587,3 +587,83 @@ struct PacketOwned { header: PacketHeader, data: Box<[u8]>, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::networking::dns::types::{DnsMessage, DnsRData, DnsRecordType}; + use crate::networking::types::my_link_type::MyLinkType; + use std::net::Ipv4Addr; + + /// End-to-end test of the capture-to-parse path: reads the sample pcap and + /// runs each packet through the exact production logic (sniffable headers -> + /// payload extraction -> header analysis -> DNS parsing), then checks the + /// recovered DNS messages. + #[test] + fn parses_dns_from_sample_pcap() { + let path = concat!(env!("CARGO_MANIFEST_DIR"), "/docs/samples/dns_sample.pcap"); + let mut cap = pcap::Capture::from_file(path).expect("open sample pcap"); + let my_link_type = MyLinkType::from_pcap_link_type(cap.get_datalink()); + + let mut messages: Vec = Vec::new(); + while let Ok(packet) = cap.next_packet() { + let Some(headers) = get_sniffable_headers(&packet.data, my_link_type) else { + continue; + }; + + // Same extraction as the live pipeline: grab the payload before + // `analyze_headers` consumes `headers`. + let transport_payload: Option<&[u8]> = match &headers.payload { + LaxPayloadSlice::Udp { payload, .. } | LaxPayloadSlice::Tcp { payload, .. } => { + Some(payload) + } + _ => None, + }; + + let mut exchanged_bytes = 0; + let mut mac_addresses = (None, None); + let mut icmp_type = IcmpType::default(); + let mut arp_type = ArpType::default(); + let Some(key) = analyze_headers( + headers, + &mut mac_addresses, + &mut exchanged_bytes, + &mut icmp_type, + &mut arp_type, + ) else { + continue; + }; + + if key.sport == Some(53) || key.dport == Some(53) { + if let Some(payload) = transport_payload { + let dns_bytes = if key.protocol == Protocol::TCP { + payload.get(2..) + } else { + Some(payload) + }; + if let Some(message) = dns_bytes.and_then(parse_dns) { + messages.push(message); + } + } + } + } + + // The sample carries exactly one query and one response for google.com. + assert_eq!(messages.len(), 2, "expected one query and one response"); + + let query = &messages[0]; + assert!(!query.is_response); + assert_eq!(query.query_name(), Some("google.com")); + assert_eq!(query.query_type(), Some(DnsRecordType::A)); + + let response = &messages[1]; + assert!(response.is_response); + assert_eq!(response.query_name(), Some("google.com")); + assert_eq!(response.answers.len(), 1); + assert_eq!(response.answers[0].rtype, DnsRecordType::A); + assert_eq!( + response.answers[0].rdata, + DnsRData::A(Ipv4Addr::new(8, 8, 8, 8)) + ); + } +} From 86228634d76984fe74cef5a8650c33e517690b28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 19:05:58 -0300 Subject: [PATCH 10/13] fix(dns): render the top-domains ranking as a vertical list The ranking was laid out on a single horizontal row, which overflowed the page width. Render it as a title followed by one domain per line. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/pages/dns_page.rs | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs index 501f073e6..57259fcab 100644 --- a/src/gui/pages/dns_page.rs +++ b/src/gui/pages/dns_page.rs @@ -76,7 +76,7 @@ fn dns_log<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { .height(Length::Fill) .align_x(Alignment::Start) .push(summary_row(sniffer.dns_state.len(), matching.len(), filter.is_active())) - .push(ranking_row(sniffer)) + .push(ranking_section(sniffer)) .push(Space::new().height(4)) .push(filter_row(sniffer)) .push(Space::new().height(4)) @@ -141,24 +141,22 @@ fn summary_row<'a>(total: usize, shown: usize, filter_active: bool) -> Row<'a, M .push(Text::new(label).class(TextType::Title)) } -/// A compact "most queried domains" ranking line. -fn ranking_row<'a>(sniffer: &Sniffer) -> Row<'a, Message, StyleType> { +/// A vertical "most queried domains" ranking: a title followed by one domain +/// per line, to avoid horizontal overflow. +fn ranking_section<'a>(sniffer: &Sniffer) -> Column<'a, Message, StyleType> { let top = sniffer.dns_state.top_domains(TOP_DOMAINS); - let mut row = Row::new() - .padding([0, 2]) - .spacing(12) - .align_y(Alignment::Center); + let mut col = Column::new().padding([0, 2]).spacing(1).align_x(Alignment::Start); if top.is_empty() { - return row; + return col; } - row = row.push(Text::new("Top domains:").size(FONT_SIZE_FOOTER).class(TextType::Subtitle)); + col = col.push(Text::new("Top domains").size(FONT_SIZE_FOOTER).class(TextType::Subtitle)); for (rank, (domain, count)) in top.into_iter().enumerate() { - row = row.push( - Text::new(format!("{}. {} ({})", rank + 1, clip_text(&domain, 30), count)) + col = col.push( + Text::new(format!("{}. {} ({})", rank + 1, clip_text(&domain, 60), count)) .size(FONT_SIZE_FOOTER), ); } - row + col } fn header_row<'a>() -> Row<'a, Message, StyleType> { From 1fe4b77e32cd92c3ac10d23e0bdcbdcf0c968dbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 19:25:52 -0300 Subject: [PATCH 11/13] fix(dns): type filter also matches answer record types The record-type filter compared only the query type (QTYPE), so filtering by e.g. CNAME hid responses to A queries whose answer chain contains a CNAME (common for CDN/Vercel-hosted domains). Track the answer section's record types per entry and match the filter against the query type OR any answer record type. Adds a unit test covering a CNAME-in-answer response. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/types/dns_state.rs | 70 +++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 4 deletions(-) diff --git a/src/gui/types/dns_state.rs b/src/gui/types/dns_state.rs index ba2110fd4..ff7f084cb 100644 --- a/src/gui/types/dns_state.rs +++ b/src/gui/types/dns_state.rs @@ -109,7 +109,7 @@ pub struct DnsFilter { impl DnsFilter { /// Whether the given log entry passes the active filters. pub fn matches(&self, entry: &DnsEntry) -> bool { - self.record_type.matches(entry.qtype) && self.rcode.matches(entry.rcode) + self.record_type.matches(entry) && self.rcode.matches(entry.rcode) } /// Whether any filter is active. @@ -146,7 +146,10 @@ impl DnsTypeFilter { DnsTypeFilter::Soa, ]; - fn matches(self, qtype: Option) -> bool { + /// Matches if the entry's query type **or** any of its answer record types + /// equals the selected type. This lets, e.g., a "CNAME" filter surface + /// responses to A queries whose answer chain contains a CNAME. + fn matches(self, entry: &DnsEntry) -> bool { let expected = match self { DnsTypeFilter::All => return true, DnsTypeFilter::A => DnsRecordType::A, @@ -158,7 +161,7 @@ impl DnsTypeFilter { DnsTypeFilter::Ptr => DnsRecordType::Ptr, DnsTypeFilter::Soa => DnsRecordType::Soa, }; - qtype == Some(expected) + entry.qtype == Some(expected) || entry.answer_types.contains(&expected) } } @@ -243,6 +246,8 @@ pub struct DnsEntry { pub domain: String, /// Queried record type, if a question was present. pub qtype: Option, + /// Record types present in the answer section (for filtering). + pub answer_types: Vec, pub rcode: DnsRCode, /// Comma-separated summary of the answers (empty for queries). pub answers: String, @@ -263,6 +268,7 @@ impl From<&DnsEvent> for DnsEntry { is_response: message.is_response, domain: message.query_name().unwrap_or("-").to_string(), qtype: message.query_type(), + answer_types: message.answers.iter().map(|r| r.rtype).collect(), rcode: message.rcode, answers: message.answers_summary(), latency_ms: None, @@ -273,7 +279,9 @@ impl From<&DnsEvent> for DnsEntry { #[cfg(test)] mod tests { use super::*; - use crate::networking::dns::types::{DnsFlags, DnsMessage, DnsQuestion}; + use crate::networking::dns::types::{ + DnsFlags, DnsMessage, DnsQuestion, DnsRData, DnsRecord, + }; use std::net::Ipv4Addr; const CLIENT: IpAddr = IpAddr::V4(Ipv4Addr::new(192, 168, 0, 2)); @@ -369,6 +377,60 @@ mod tests { assert!(!f_nx.matches(a_query)); } + #[test] + fn type_filter_matches_answer_records_not_just_query_type() { + // Response to an A query whose answer chain contains a CNAME (as in a + // real Vercel/CDN-hosted domain). + let mut msg = message(7, true, "tabnews.example.com"); + msg.answers = vec![ + DnsRecord { + name: "tabnews.example.com".to_string(), + rtype: DnsRecordType::Cname, + class: 1, + ttl: 300, + rdata: DnsRData::Name("cdn.example.net".to_string()), + }, + DnsRecord { + name: "cdn.example.net".to_string(), + rtype: DnsRecordType::A, + class: 1, + ttl: 60, + rdata: DnsRData::A(Ipv4Addr::new(64, 29, 17, 1)), + }, + ]; + let mut state = DnsState::default(); + state.ingest(vec![DnsEvent { + timestamp: Timestamp::new(1, 0), + src: SERVER, + dst: CLIENT, + transport: Protocol::UDP, + message: msg, + }]); + let entry = state.log.back().unwrap(); + + // The query type is A, but a CNAME filter must still match because the + // answer section contains a CNAME record. + let f_cname = DnsFilter { + record_type: DnsTypeFilter::Cname, + rcode: DnsRCodeFilter::All, + }; + assert!(f_cname.matches(entry)); + + // A also matches (query type and an answer A record). + let f_a = DnsFilter { + record_type: DnsTypeFilter::A, + rcode: DnsRCodeFilter::All, + }; + assert!(f_a.matches(entry)); + + // MX matches neither the query type nor any answer record. + let f_mx = DnsFilter { + record_type: DnsTypeFilter::Mx, + rcode: DnsRCodeFilter::All, + }; + assert!(!f_mx.matches(entry)); + } + #[test] fn ranking_counts_queries_per_domain() { let mut state = DnsState::default(); From c29a270d8a45e100946b27e8e82ec7d27cecf24f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 19:38:56 -0300 Subject: [PATCH 12/13] feat(dns): label each answer record with its type in the summary The Answer(s) column showed only RDATA values, so a response to an A query whose answer chain contains a CNAME looked inconsistent when filtered by CNAME (the Type column shows the QTYPE, A). Prefix each answer with its record type, e.g. "CNAME cdn.example.net, A 1.2.3.4", matching dig/tcpdump and making the answer types visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/networking/dns/types.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/networking/dns/types.rs b/src/networking/dns/types.rs index 3445b6bc9..84bc13c5b 100644 --- a/src/networking/dns/types.rs +++ b/src/networking/dns/types.rs @@ -59,12 +59,13 @@ impl DnsMessage { self.questions.first().map(|q| q.qtype) } - /// Human-readable, comma-separated summary of the answer records' data, + /// Human-readable, comma-separated summary of the answer records, each + /// prefixed by its record type (e.g. "CNAME cdn.example.net, A 1.2.3.4"), /// suitable for a single table cell. pub fn answers_summary(&self) -> String { self.answers .iter() - .map(|r| r.rdata.to_string()) + .map(|r| format!("{} {}", r.rtype, r.rdata)) .collect::>() .join(", ") } From 670c63ee14b89c2bdd7601ea641f15bdee5b1f4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Pedro?= Date: Sun, 21 Jun 2026 21:20:40 -0300 Subject: [PATCH 13/13] feat(dns): interpret NSCOUNT and ARCOUNT header counts Complete the 12-byte DNS header parsing: read NSCOUNT (Authority) and ARCOUNT (Additional) record counts. The sections themselves are not expanded, but their counts are surfaced in the Answer(s) cell as a note (e.g. "[+1 add'l]"), which also reveals EDNS OPT records. Adds parser tests for the counts and the note. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/gui/types/dns_state.rs | 17 ++++++++++++++++- src/networking/dns/parser.rs | 21 ++++++++++++++++++++- src/networking/dns/types.rs | 17 +++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/gui/types/dns_state.rs b/src/gui/types/dns_state.rs index ff7f084cb..7fb48f960 100644 --- a/src/gui/types/dns_state.rs +++ b/src/gui/types/dns_state.rs @@ -270,12 +270,25 @@ impl From<&DnsEvent> for DnsEntry { qtype: message.query_type(), answer_types: message.answers.iter().map(|r| r.rtype).collect(), rcode: message.rcode, - answers: message.answers_summary(), + answers: answers_with_counts(message), latency_ms: None, } } } +/// Builds the Answer(s) cell text: the answer records' summary, plus a note for +/// the Authority/Additional sections counted via NSCOUNT/ARCOUNT but not +/// expanded (e.g. EDNS OPT records). +fn answers_with_counts(message: &crate::networking::dns::types::DnsMessage) -> String { + let summary = message.answers_summary(); + let note = message.extra_sections_note(); + match (summary.is_empty(), note.is_empty()) { + (_, true) => summary, + (true, false) => format!("[{note}]"), + (false, false) => format!("{summary} [{note}]"), + } +} + #[cfg(test)] mod tests { use super::*; @@ -294,6 +307,8 @@ mod tests { opcode: 0, flags: DnsFlags::default(), rcode: DnsRCode::NoError, + nscount: 0, + arcount: 0, questions: vec![DnsQuestion { name: domain.to_string(), qtype: DnsRecordType::A, diff --git a/src/networking/dns/parser.rs b/src/networking/dns/parser.rs index 04b3b1bcf..71085b296 100644 --- a/src/networking/dns/parser.rs +++ b/src/networking/dns/parser.rs @@ -37,7 +37,10 @@ pub fn parse_dns(buf: &[u8]) -> Option { 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 nscount = u16::from_be_bytes([buf[8], buf[9]]); + let arcount = u16::from_be_bytes([buf[10], buf[11]]); + // The Authority and Additional sections themselves are not expanded; only + // their record counts (NSCOUNT/ARCOUNT) are interpreted. let mut pos = 12; @@ -76,6 +79,8 @@ pub fn parse_dns(buf: &[u8]) -> Option { opcode, flags: DnsFlags { aa, tc, rd, ra }, rcode: DnsRCode::from_u8(rcode), + nscount, + arcount, questions, answers, }) @@ -277,6 +282,20 @@ mod tests { assert_eq!(msg.query_type(), Some(DnsRecordType::A)); assert_eq!(msg.questions[0].qclass, 1); assert!(msg.answers.is_empty()); + assert_eq!(msg.nscount, 0); + assert_eq!(msg.arcount, 0); + } + + #[test] + fn parses_authority_and_additional_counts() { + // Header only: QDCOUNT=0, ANCOUNT=0, NSCOUNT=2, ARCOUNT=1. + let buf = [ + 0x00, 0x05, 0x81, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, + ]; + let msg = parse_dns(&buf).expect("should parse header"); + assert_eq!(msg.nscount, 2); + assert_eq!(msg.arcount, 1); + assert_eq!(msg.extra_sections_note(), "+2 auth +1 add'l"); } /// Response for `google.com` A, using a compression pointer (0xC00C) in the diff --git a/src/networking/dns/types.rs b/src/networking/dns/types.rs index 84bc13c5b..c77d6052a 100644 --- a/src/networking/dns/types.rs +++ b/src/networking/dns/types.rs @@ -42,6 +42,10 @@ pub struct DnsMessage { pub flags: DnsFlags, /// `RCODE` field (response code). pub rcode: DnsRCode, + /// `NSCOUNT`: number of Resource Records in the Authority section. + pub nscount: u16, + /// `ARCOUNT`: number of Resource Records in the Additional section. + pub arcount: u16, /// Entries of the Question section. pub questions: Vec, /// Resource Records of the Answer section. @@ -59,6 +63,19 @@ impl DnsMessage { self.questions.first().map(|q| q.qtype) } + /// Short note about the Authority/Additional sections, which are counted + /// (NSCOUNT/ARCOUNT) but not expanded. Empty when both counts are zero. + pub fn extra_sections_note(&self) -> String { + let mut parts = Vec::new(); + if self.nscount > 0 { + parts.push(format!("+{} auth", self.nscount)); + } + if self.arcount > 0 { + parts.push(format!("+{} add'l", self.arcount)); + } + parts.join(" ") + } + /// Human-readable, comma-separated summary of the answer records, each /// prefixed by its record type (e.g. "CNAME cdn.example.net, A 1.2.3.4"), /// suitable for a single table cell.