diff --git a/README.md b/README.md index 918416ee4..5a26951b5 100644 --- a/README.md +++ b/README.md @@ -115,11 +115,33 @@ 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 - ...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**?
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 000000000..b4bc30d3f Binary files /dev/null and b/docs/samples/dns_sample.pcap differ diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs new file mode 100644 index 000000000..57259fcab --- /dev/null +++ b/src/gui/pages/dns_page.rs @@ -0,0 +1,239 @@ +//! 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, PickList, 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, DnsRCodeFilter, DnsTypeFilter}; +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 = 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 = 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> { + 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 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(sniffer.dns_state.len(), matching.len(), filter.is_active())) + .push(ranking_section(sniffer)) + .push(Space::new().height(4)) + .push(filter_row(sniffer)) + .push(Space::new().height(4)) + .push(header_row()) + .push(RuleType::Standard.horizontal(5)); + + 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); + for entry in matching { + scroll = scroll.push(log_row(entry)); + } + + col.push( + Scrollable::with_direction(scroll, Direction::Vertical(ScrollbarType::properties())) + .height(Length::Fill) + .width(Length::Fill), + ) +} + +/// 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(label).class(TextType::Title)) +} + +/// 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 col = Column::new().padding([0, 2]).spacing(1).align_x(Alignment::Start); + if top.is_empty() { + return col; + } + col = col.push(Text::new("Top domains").size(FONT_SIZE_FOOTER).class(TextType::Subtitle)); + for (rank, (domain, count)) in top.into_iter().enumerate() { + col = col.push( + Text::new(format!("{}. {} ({})", rank + 1, clip_text(&domain, 60), count)) + .size(FONT_SIZE_FOOTER), + ); + } + col +} + +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), + ("Latency", W_LATENCY), + ("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" }; + + // 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), + ]; + + 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>(message: &'a str) -> 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(message)) + .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..bfd62416e 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::{DnsFilter, DnsState}; use crate::gui::types::favorite::FavoriteKey; use crate::gui::types::message::Message; use crate::gui::types::settings::Settings; @@ -142,6 +144,10 @@ 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, + /// Active filters on the DNS analyzer page + pub dns_filter: DnsFilter, } impl Sniffer { @@ -189,6 +195,8 @@ impl Sniffer { frozen: false, freeze_tx: None, program_lookup: None, + dns_state: DnsState::default(), + dns_filter: DnsFilter::default(), } } @@ -317,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(), @@ -391,6 +401,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 +918,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 +1077,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..7fb48f960 --- /dev/null +++ b/src/gui/types/dns_state.rs @@ -0,0 +1,461 @@ +//! 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, the per-domain +//! ranking, and the query/response latency correlation shown in the DNS page. + +use std::collections::{HashMap, 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; +/// 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(entry); + } + } + + /// Total number of logged DNS messages. + pub fn len(&self) -> usize { + self.log.len() + } + + 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) +} + +/// 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) && 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, + ]; + + /// 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, + 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, + }; + entry.qtype == Some(expected) || entry.answer_types.contains(&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 { + 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, + /// 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, + /// Resolution latency in milliseconds (set on responses that were matched + /// to a previously seen query). + pub latency_ms: Option, +} + +impl From<&DnsEvent> 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(), + answer_types: message.answers.iter().map(|r| r.rtype).collect(), + rcode: message.rcode, + 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::*; + 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)); + 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, + nscount: 0, + arcount: 0, + 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 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 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(); + 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)]); + } +} 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 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/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..71085b296 --- /dev/null +++ b/src/networking/dns/parser.rs @@ -0,0 +1,432 @@ +//! 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]]); + 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; + + // 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), + nscount, + arcount, + 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()); + 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 + /// 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..c77d6052a --- /dev/null +++ b/src/networking/dns/types.rs @@ -0,0 +1,263 @@ +//! 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, + /// `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. + 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) + } + + /// 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. + pub fn answers_summary(&self) -> String { + self.answers + .iter() + .map(|r| format!("{} {}", r.rtype, r.rdata)) + .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; diff --git a/src/networking/parse_packets.rs b/src/networking/parse_packets.rs index b18981d87..d74be240e 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 { @@ -553,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)) + ); + } +} 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 { 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',