mirror of
https://github.com/GyulyVGC/sniffnet.git
synced 2026-10-20 14:13:37 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
4695544de5
commit
8f8dd02e93
8 changed files with 274 additions and 8 deletions
160
src/gui/pages/dns_page.rs
Normal file
160
src/gui/pages/dns_page.rs
Normal file
|
|
@ -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)))
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<tokio::sync::broadcast::Sender<()>>,
|
||||
/// State of the port to program lookups
|
||||
pub program_lookup: Option<ProgramLookup>,
|
||||
/// 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);
|
||||
|
|
|
|||
79
src/gui/types/dns_state.rs
Normal file
79
src/gui/types/dns_state.rs
Normal file
|
|
@ -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<DnsEntry>,
|
||||
}
|
||||
|
||||
impl DnsState {
|
||||
/// Folds a batch of freshly parsed DNS events into the state.
|
||||
pub fn ingest(&mut self, events: Vec<DnsEvent>) {
|
||||
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<DnsRecordType>,
|
||||
pub rcode: DnsRCode,
|
||||
/// Comma-separated summary of the answers (empty for queries).
|
||||
pub answers: String,
|
||||
}
|
||||
|
||||
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(),
|
||||
rcode: message.rcode,
|
||||
answers: message.answers_summary(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue