Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**? <br>
Expand Down
22 changes: 22 additions & 0 deletions docs/samples/README.md
Original file line number Diff line number Diff line change
@@ -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`.
Binary file added docs/samples/dns_sample.pcap
Binary file not shown.
239 changes: 239 additions & 0 deletions src/gui/pages/dns_page.rs
Original file line number Diff line number Diff line change
@@ -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)))
}
1 change: 1 addition & 0 deletions src/gui/pages/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
21 changes: 15 additions & 6 deletions src/gui/pages/types/running_page.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -15,36 +15,42 @@ 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 {
match self {
RunningPage::Overview => overview_translation(language),
RunningPage::Inspect => inspect_translation(language),
RunningPage::Notifications => notifications_translation(language),
RunningPage::Dns => dns_translation(language),
}
}

pub fn next(self) -> Self {
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,
}
}

Expand All @@ -53,6 +59,7 @@ impl RunningPage {
RunningPage::Overview => Icon::Overview,
RunningPage::Inspect => Icon::Inspect,
RunningPage::Notifications => Icon::Notification,
RunningPage::Dns => Icon::Globe,
}
.to_text()
}
Expand All @@ -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);
}
Expand All @@ -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);
}
}
Loading