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',