diff --git a/src/gui/pages/dns_page.rs b/src/gui/pages/dns_page.rs new file mode 100644 index 00000000..6ed20ddc --- /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 30af7dba..cd5ce749 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 f0693964..bca0afa5 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 5b0696ec..a04d0c27 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 00000000..4988d9e8 --- /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 743e119d..094662e3 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 93ab85ee..fe6965a1 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 083803eb..9ce069d1 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',