mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-01-05 21:40:01 -06:00
Add reverse DNS hostnames to Details tab and filter PTR traffic (#104)
* feat: add reverse DNS resolution for IP addresses - Add --resolve-dns flag to enable background DNS resolution - Add --show-ptr-lookups flag to show/hide PTR lookup connections - Create dns.rs module with async resolver and LRU cache - Display hostnames in UI with 'd' key toggle - Include hostname fields in JSON log output when resolved Closes #97
This commit is contained in:
79
src/app.rs
79
src/app.rs
@@ -15,12 +15,13 @@ use crate::filter::ConnectionFilter;
|
||||
|
||||
use crate::network::{
|
||||
capture::{CaptureConfig, PacketReader, setup_packet_capture},
|
||||
dns::DnsResolver,
|
||||
interface_stats::{InterfaceRates, InterfaceStats, InterfaceStatsProvider},
|
||||
merge::{create_connection_from_packet, merge_packet_into_connection},
|
||||
parser::{PacketParser, ParsedPacket, ParserConfig},
|
||||
platform::create_process_lookup,
|
||||
services::ServiceLookup,
|
||||
types::{ApplicationProtocol, Connection, ConnectionKey, Protocol, RttTracker, TrafficHistory},
|
||||
types::{ApplicationProtocol, Connection, ConnectionKey, DnsQueryType, Protocol, RttTracker, TrafficHistory},
|
||||
};
|
||||
|
||||
// Platform-specific interface stats provider
|
||||
@@ -63,6 +64,7 @@ fn log_connection_event(
|
||||
event_type: &str,
|
||||
conn: &Connection,
|
||||
duration_secs: Option<u64>,
|
||||
dns_resolver: Option<&DnsResolver>,
|
||||
) {
|
||||
// Build JSON object based on event type
|
||||
let mut event = json!({
|
||||
@@ -75,6 +77,16 @@ fn log_connection_event(
|
||||
"destination_port": conn.remote_addr.port(),
|
||||
});
|
||||
|
||||
// Add hostname fields if DNS resolution is enabled and hostnames are resolved
|
||||
if let Some(resolver) = dns_resolver {
|
||||
if let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) {
|
||||
event["destination_hostname"] = json!(hostname);
|
||||
}
|
||||
if let Some(hostname) = resolver.get_hostname(&conn.local_addr.ip()) {
|
||||
event["source_hostname"] = json!(hostname);
|
||||
}
|
||||
}
|
||||
|
||||
// Add process information if available
|
||||
if let Some(pid) = conn.pid {
|
||||
event["pid"] = json!(pid);
|
||||
@@ -162,6 +174,10 @@ pub struct Config {
|
||||
pub bpf_filter: Option<String>,
|
||||
/// JSON log file path for connection events
|
||||
pub json_log_file: Option<String>,
|
||||
/// Enable reverse DNS resolution for IP addresses
|
||||
pub resolve_dns: bool,
|
||||
/// Show PTR lookup connections in UI (when DNS resolution is enabled)
|
||||
pub show_ptr_lookups: bool,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
@@ -173,6 +189,8 @@ impl Default for Config {
|
||||
enable_dpi: true,
|
||||
bpf_filter: None, // No filter by default to see all packets
|
||||
json_log_file: None,
|
||||
resolve_dns: false,
|
||||
show_ptr_lookups: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -248,6 +266,9 @@ pub struct App {
|
||||
/// RTT tracker for latency measurement
|
||||
rtt_tracker: Arc<Mutex<RttTracker>>,
|
||||
|
||||
/// DNS resolver for reverse DNS lookups
|
||||
dns_resolver: Option<Arc<DnsResolver>>,
|
||||
|
||||
/// Sandbox status (Linux Landlock)
|
||||
#[cfg(target_os = "linux")]
|
||||
sandbox_info: Arc<RwLock<SandboxInfo>>,
|
||||
@@ -262,6 +283,14 @@ impl App {
|
||||
ServiceLookup::with_defaults()
|
||||
});
|
||||
|
||||
// Initialize DNS resolver if enabled
|
||||
let dns_resolver = if config.resolve_dns {
|
||||
info!("DNS resolution enabled - starting background resolver");
|
||||
Some(Arc::new(DnsResolver::with_defaults()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
config,
|
||||
should_stop: Arc::new(AtomicBool::new(false)),
|
||||
@@ -277,6 +306,7 @@ impl App {
|
||||
interface_rates: Arc::new(DashMap::new()),
|
||||
traffic_history: Arc::new(RwLock::new(TrafficHistory::new(60))), // 60 seconds of history
|
||||
rtt_tracker: Arc::new(Mutex::new(RttTracker::new())),
|
||||
dns_resolver,
|
||||
#[cfg(target_os = "linux")]
|
||||
sandbox_info: Arc::new(RwLock::new(SandboxInfo::default())),
|
||||
})
|
||||
@@ -481,6 +511,7 @@ impl App {
|
||||
let linktype_storage = Arc::clone(&self.linktype);
|
||||
let json_log_path = self.config.json_log_file.clone();
|
||||
let rtt_tracker = Arc::clone(&self.rtt_tracker);
|
||||
let dns_resolver = self.dns_resolver.clone();
|
||||
let parser_config = ParserConfig {
|
||||
enable_dpi: self.config.enable_dpi,
|
||||
..Default::default()
|
||||
@@ -527,6 +558,7 @@ impl App {
|
||||
&stats,
|
||||
&json_log_path,
|
||||
&rtt_tracker,
|
||||
dns_resolver.as_deref(),
|
||||
);
|
||||
parsed_count += 1;
|
||||
}
|
||||
@@ -978,6 +1010,7 @@ impl App {
|
||||
fn start_cleanup_thread(&self, connections: Arc<DashMap<String, Connection>>) -> Result<()> {
|
||||
let should_stop = Arc::clone(&self.should_stop);
|
||||
let json_log_path = self.config.json_log_file.clone();
|
||||
let dns_resolver = self.dns_resolver.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
info!("Cleanup thread started");
|
||||
@@ -1011,7 +1044,13 @@ impl App {
|
||||
|
||||
// Log connection_closed event if JSON logging is enabled
|
||||
if let Some(log_path) = &json_log_path {
|
||||
log_connection_event(log_path, "connection_closed", conn, duration_secs);
|
||||
log_connection_event(
|
||||
log_path,
|
||||
"connection_closed",
|
||||
conn,
|
||||
duration_secs,
|
||||
dns_resolver.as_deref(),
|
||||
);
|
||||
}
|
||||
|
||||
// Log cleanup reason for debugging
|
||||
@@ -1057,13 +1096,34 @@ impl App {
|
||||
|
||||
/// Get current connections for UI display
|
||||
pub fn get_connections(&self) -> Vec<Connection> {
|
||||
self.connections_snapshot.read().unwrap().clone()
|
||||
self.get_filtered_connections("")
|
||||
}
|
||||
|
||||
/// Get filtered connections for UI display
|
||||
pub fn get_filtered_connections(&self, filter_query: &str) -> Vec<Connection> {
|
||||
let connections = self.connections_snapshot.read().unwrap().clone();
|
||||
|
||||
// Filter out DNS PTR queries/responses when reverse DNS is enabled
|
||||
let hide_ptr_lookups = self.dns_resolver.is_some() && !self.config.show_ptr_lookups;
|
||||
|
||||
let connections: Vec<Connection> = if hide_ptr_lookups {
|
||||
connections
|
||||
.into_iter()
|
||||
.filter(|conn| {
|
||||
// Hide DNS PTR queries/responses (used for reverse DNS lookups)
|
||||
if let Some(ref dpi) = conn.dpi_info
|
||||
&& let ApplicationProtocol::Dns(ref dns_info) = dpi.application
|
||||
&& dns_info.query_type == Some(DnsQueryType::PTR)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
connections
|
||||
};
|
||||
|
||||
if filter_query.trim().is_empty() {
|
||||
return connections;
|
||||
}
|
||||
@@ -1181,6 +1241,16 @@ impl App {
|
||||
(String::from("Unknown"), false)
|
||||
}
|
||||
|
||||
/// Get the DNS resolver if enabled
|
||||
pub fn get_dns_resolver(&self) -> Option<Arc<DnsResolver>> {
|
||||
self.dns_resolver.clone()
|
||||
}
|
||||
|
||||
/// Check if DNS resolution is enabled
|
||||
pub fn is_dns_resolution_enabled(&self) -> bool {
|
||||
self.dns_resolver.is_some()
|
||||
}
|
||||
|
||||
/// Stop all threads gracefully
|
||||
pub fn stop(&self) {
|
||||
info!("Stopping application");
|
||||
@@ -1195,6 +1265,7 @@ fn update_connection(
|
||||
_stats: &AppStats,
|
||||
json_log_path: &Option<String>,
|
||||
rtt_tracker: &Arc<Mutex<RttTracker>>,
|
||||
dns_resolver: Option<&DnsResolver>,
|
||||
) {
|
||||
let mut key = parsed.connection_key.clone();
|
||||
let now = SystemTime::now();
|
||||
@@ -1286,7 +1357,7 @@ fn update_connection(
|
||||
|
||||
// Log new connection event if JSON logging is enabled
|
||||
if let Some(log_path) = json_log_path {
|
||||
log_connection_event(log_path, "new_connection", &conn, None);
|
||||
log_connection_event(log_path, "new_connection", &conn, None, dns_resolver);
|
||||
}
|
||||
|
||||
conn
|
||||
|
||||
12
src/cli.rs
12
src/cli.rs
@@ -76,6 +76,18 @@ pub fn build_cli() -> Command {
|
||||
.value_name("FILTER")
|
||||
.help(BPF_HELP)
|
||||
.required(false),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("resolve-dns")
|
||||
.long("resolve-dns")
|
||||
.help("Enable reverse DNS resolution for IP addresses (shows hostnames instead of IPs)")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("show-ptr-lookups")
|
||||
.long("show-ptr-lookups")
|
||||
.help("Show PTR lookup connections in UI (hidden by default when --resolve-dns is enabled)")
|
||||
.action(clap::ArgAction::SetTrue),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
26
src/main.rs
26
src/main.rs
@@ -75,6 +75,16 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
if matches.get_flag("resolve-dns") {
|
||||
config.resolve_dns = true;
|
||||
info!("Reverse DNS resolution enabled");
|
||||
}
|
||||
|
||||
if matches.get_flag("show-ptr-lookups") {
|
||||
config.show_ptr_lookups = true;
|
||||
info!("PTR lookup connections will be shown in UI");
|
||||
}
|
||||
|
||||
// Set up terminal
|
||||
let backend = CrosstermBackend::new(io::stdout());
|
||||
let mut terminal = ui::setup_terminal(backend)?;
|
||||
@@ -556,6 +566,22 @@ fn run_ui_loop<B: ratatui::prelude::Backend>(
|
||||
);
|
||||
}
|
||||
|
||||
// Toggle hostname display (when DNS resolution is enabled)
|
||||
(KeyCode::Char('d'), _) => {
|
||||
if app.is_dns_resolution_enabled() {
|
||||
ui_state.quit_confirmation = false;
|
||||
ui_state.show_hostnames = !ui_state.show_hostnames;
|
||||
info!(
|
||||
"Toggled hostname display: {}",
|
||||
if ui_state.show_hostnames {
|
||||
"showing hostnames"
|
||||
} else {
|
||||
"showing IP addresses"
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cycle sort column with 's'
|
||||
(KeyCode::Char('s'), KeyModifiers::NONE) => {
|
||||
ui_state.quit_confirmation = false;
|
||||
|
||||
352
src/network/dns.rs
Normal file
352
src/network/dns.rs
Normal file
@@ -0,0 +1,352 @@
|
||||
//! DNS resolver with background async resolution and caching.
|
||||
//!
|
||||
//! Provides non-blocking reverse DNS lookups with an LRU cache to avoid
|
||||
//! repeated lookups for the same IP address.
|
||||
|
||||
use crossbeam::channel::{self, Receiver, Sender};
|
||||
use dashmap::DashMap;
|
||||
use dns_lookup::lookup_addr;
|
||||
use log::debug;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Resolution state for a cached entry
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ResolutionState {
|
||||
/// Resolution is in progress
|
||||
Pending,
|
||||
/// Resolution succeeded
|
||||
Resolved,
|
||||
/// Resolution failed
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Cached hostname entry
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedHostname {
|
||||
/// The resolved hostname, if successful
|
||||
pub hostname: Option<String>,
|
||||
/// When this entry was resolved
|
||||
pub resolved_at: Instant,
|
||||
/// Current resolution state
|
||||
pub state: ResolutionState,
|
||||
}
|
||||
|
||||
impl CachedHostname {
|
||||
fn pending() -> Self {
|
||||
Self {
|
||||
hostname: None,
|
||||
resolved_at: Instant::now(),
|
||||
state: ResolutionState::Pending,
|
||||
}
|
||||
}
|
||||
|
||||
fn resolved(hostname: String) -> Self {
|
||||
Self {
|
||||
hostname: Some(hostname),
|
||||
resolved_at: Instant::now(),
|
||||
state: ResolutionState::Resolved,
|
||||
}
|
||||
}
|
||||
|
||||
fn failed() -> Self {
|
||||
Self {
|
||||
hostname: None,
|
||||
resolved_at: Instant::now(),
|
||||
state: ResolutionState::Failed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for DNS resolver
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DnsResolverConfig {
|
||||
/// Cache TTL for resolved hostnames (default: 5 minutes)
|
||||
pub cache_ttl: Duration,
|
||||
/// Cache TTL for failed lookups (default: 1 minute)
|
||||
pub negative_cache_ttl: Duration,
|
||||
/// Maximum cache size (default: 10000 entries)
|
||||
pub max_cache_size: usize,
|
||||
/// Number of resolver threads (default: 4)
|
||||
pub resolver_threads: usize,
|
||||
}
|
||||
|
||||
impl Default for DnsResolverConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_ttl: Duration::from_secs(300), // 5 minutes
|
||||
negative_cache_ttl: Duration::from_secs(60), // 1 minute
|
||||
max_cache_size: 10000,
|
||||
resolver_threads: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Background DNS resolver with caching
|
||||
pub struct DnsResolver {
|
||||
/// Hostname cache: IP -> CachedHostname
|
||||
cache: Arc<DashMap<IpAddr, CachedHostname>>,
|
||||
/// Channel to send IPs for resolution
|
||||
request_tx: Sender<IpAddr>,
|
||||
/// Control flag for shutdown
|
||||
should_stop: Arc<AtomicBool>,
|
||||
/// Configuration
|
||||
config: DnsResolverConfig,
|
||||
}
|
||||
|
||||
impl DnsResolver {
|
||||
/// Create a new DNS resolver with the given configuration
|
||||
pub fn new(config: DnsResolverConfig) -> Self {
|
||||
let cache = Arc::new(DashMap::new());
|
||||
let (request_tx, request_rx) = channel::unbounded();
|
||||
let should_stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
let resolver = Self {
|
||||
cache: Arc::clone(&cache),
|
||||
request_tx,
|
||||
should_stop: Arc::clone(&should_stop),
|
||||
config: config.clone(),
|
||||
};
|
||||
|
||||
// Start resolver threads
|
||||
resolver.start_resolver_threads(request_rx, &config);
|
||||
|
||||
// Start cache cleanup thread
|
||||
resolver.start_cache_cleanup_thread();
|
||||
|
||||
resolver
|
||||
}
|
||||
|
||||
/// Create a new DNS resolver with default configuration
|
||||
pub fn with_defaults() -> Self {
|
||||
Self::new(DnsResolverConfig::default())
|
||||
}
|
||||
|
||||
/// Start background resolver threads
|
||||
fn start_resolver_threads(&self, request_rx: Receiver<IpAddr>, config: &DnsResolverConfig) {
|
||||
let num_threads = config.resolver_threads;
|
||||
|
||||
for i in 0..num_threads {
|
||||
let rx = request_rx.clone();
|
||||
let cache = Arc::clone(&self.cache);
|
||||
let should_stop = Arc::clone(&self.should_stop);
|
||||
let cache_ttl = config.cache_ttl;
|
||||
let negative_cache_ttl = config.negative_cache_ttl;
|
||||
|
||||
thread::Builder::new()
|
||||
.name(format!("dns-resolver-{}", i))
|
||||
.spawn(move || {
|
||||
debug!("DNS resolver thread {} started", i);
|
||||
|
||||
while !should_stop.load(Ordering::Relaxed) {
|
||||
match rx.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(ip) => {
|
||||
// Skip if already resolved or pending
|
||||
if let Some(entry) = cache.get(&ip) {
|
||||
let age = entry.resolved_at.elapsed();
|
||||
match entry.state {
|
||||
ResolutionState::Pending => continue,
|
||||
ResolutionState::Resolved if age < cache_ttl => continue,
|
||||
ResolutionState::Failed if age < negative_cache_ttl => {
|
||||
continue
|
||||
}
|
||||
_ => {} // Expired, re-resolve
|
||||
}
|
||||
}
|
||||
|
||||
// Mark as pending
|
||||
cache.insert(ip, CachedHostname::pending());
|
||||
|
||||
// Perform DNS lookup
|
||||
match lookup_addr(&ip) {
|
||||
Ok(hostname) => {
|
||||
debug!("Resolved {} -> {}", ip, hostname);
|
||||
cache.insert(ip, CachedHostname::resolved(hostname));
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to resolve {}: {}", ip, e);
|
||||
cache.insert(ip, CachedHostname::failed());
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(crossbeam::channel::RecvTimeoutError::Timeout) => continue,
|
||||
Err(crossbeam::channel::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
|
||||
debug!("DNS resolver thread {} stopping", i);
|
||||
})
|
||||
.expect("Failed to spawn DNS resolver thread");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start cache cleanup thread to evict expired entries
|
||||
fn start_cache_cleanup_thread(&self) {
|
||||
let cache = Arc::clone(&self.cache);
|
||||
let should_stop = Arc::clone(&self.should_stop);
|
||||
let cache_ttl = self.config.cache_ttl;
|
||||
let negative_cache_ttl = self.config.negative_cache_ttl;
|
||||
let max_cache_size = self.config.max_cache_size;
|
||||
|
||||
thread::Builder::new()
|
||||
.name("dns-cache-cleanup".to_string())
|
||||
.spawn(move || {
|
||||
debug!("DNS cache cleanup thread started");
|
||||
|
||||
while !should_stop.load(Ordering::Relaxed) {
|
||||
thread::sleep(Duration::from_secs(30)); // Cleanup every 30 seconds
|
||||
|
||||
if should_stop.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Remove expired entries
|
||||
cache.retain(|_, entry| {
|
||||
let age = entry.resolved_at.elapsed();
|
||||
match entry.state {
|
||||
ResolutionState::Resolved => age < cache_ttl,
|
||||
ResolutionState::Failed => age < negative_cache_ttl,
|
||||
ResolutionState::Pending => age < Duration::from_secs(30), // Timeout pending
|
||||
}
|
||||
});
|
||||
|
||||
// If cache is too large, remove oldest entries
|
||||
if cache.len() > max_cache_size {
|
||||
let mut entries: Vec<_> = cache
|
||||
.iter()
|
||||
.map(|e| (*e.key(), e.resolved_at))
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, time)| *time);
|
||||
|
||||
let to_remove = cache.len() - max_cache_size;
|
||||
for (ip, _) in entries.into_iter().take(to_remove) {
|
||||
cache.remove(&ip);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("DNS cache size: {}", cache.len());
|
||||
}
|
||||
|
||||
debug!("DNS cache cleanup thread stopping");
|
||||
})
|
||||
.expect("Failed to spawn DNS cache cleanup thread");
|
||||
}
|
||||
|
||||
/// Request resolution for an IP address (non-blocking)
|
||||
pub fn request_resolution(&self, ip: IpAddr) {
|
||||
// Don't resolve localhost or link-local
|
||||
if ip.is_loopback() || is_link_local(&ip) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already in cache and not expired
|
||||
if let Some(entry) = self.cache.get(&ip) {
|
||||
let age = entry.resolved_at.elapsed();
|
||||
match entry.state {
|
||||
ResolutionState::Pending => return,
|
||||
ResolutionState::Resolved if age < self.config.cache_ttl => return,
|
||||
ResolutionState::Failed if age < self.config.negative_cache_ttl => return,
|
||||
_ => {} // Expired
|
||||
}
|
||||
}
|
||||
|
||||
// Queue for resolution (ignore send errors - channel is unbounded)
|
||||
let _ = self.request_tx.send(ip);
|
||||
}
|
||||
|
||||
/// Get hostname for IP if resolved, otherwise return None
|
||||
pub fn get_hostname(&self, ip: &IpAddr) -> Option<String> {
|
||||
// Request resolution if not in cache
|
||||
self.request_resolution(*ip);
|
||||
|
||||
// Return cached hostname if available
|
||||
self.cache.get(ip).and_then(|entry| {
|
||||
if entry.state == ResolutionState::Resolved {
|
||||
entry.hostname.clone()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Stop the resolver
|
||||
pub fn stop(&self) {
|
||||
self.should_stop.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DnsResolver {
|
||||
fn drop(&mut self) {
|
||||
self.stop();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if IP is link-local
|
||||
fn is_link_local(ip: &IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_link_local(),
|
||||
IpAddr::V6(v6) => {
|
||||
// fe80::/10
|
||||
let segments = v6.segments();
|
||||
(segments[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_cached_hostname_states() {
|
||||
let pending = CachedHostname::pending();
|
||||
assert_eq!(pending.state, ResolutionState::Pending);
|
||||
assert!(pending.hostname.is_none());
|
||||
|
||||
let resolved = CachedHostname::resolved("example.com".to_string());
|
||||
assert_eq!(resolved.state, ResolutionState::Resolved);
|
||||
assert_eq!(resolved.hostname, Some("example.com".to_string()));
|
||||
|
||||
let failed = CachedHostname::failed();
|
||||
assert_eq!(failed.state, ResolutionState::Failed);
|
||||
assert!(failed.hostname.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_local_detection() {
|
||||
// IPv4 link-local
|
||||
assert!(is_link_local(&"169.254.1.1".parse().unwrap()));
|
||||
assert!(!is_link_local(&"192.168.1.1".parse().unwrap()));
|
||||
|
||||
// IPv6 link-local
|
||||
assert!(is_link_local(&"fe80::1".parse().unwrap()));
|
||||
assert!(!is_link_local(&"2001:db8::1".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_loopback_skip() {
|
||||
let config = DnsResolverConfig {
|
||||
resolver_threads: 1,
|
||||
..Default::default()
|
||||
};
|
||||
let resolver = DnsResolver::new(config);
|
||||
|
||||
// Loopback should not be queued
|
||||
resolver.request_resolution("127.0.0.1".parse().unwrap());
|
||||
assert!(resolver.get_hostname(&"127.0.0.1".parse().unwrap()).is_none());
|
||||
|
||||
resolver.stop();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = DnsResolverConfig::default();
|
||||
assert_eq!(config.cache_ttl, Duration::from_secs(300));
|
||||
assert_eq!(config.negative_cache_ttl, Duration::from_secs(60));
|
||||
assert_eq!(config.max_cache_size, 10000);
|
||||
assert_eq!(config.resolver_threads, 4);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod capture;
|
||||
pub mod dns;
|
||||
pub mod dpi;
|
||||
pub mod interface_stats;
|
||||
pub mod link_layer;
|
||||
|
||||
69
src/ui.rs
69
src/ui.rs
@@ -12,6 +12,7 @@ use ratatui::{
|
||||
};
|
||||
|
||||
use crate::app::{App, AppStats};
|
||||
use crate::network::dns::DnsResolver;
|
||||
use crate::network::types::{
|
||||
AppProtocolDistribution, Connection, Protocol, ProtocolState, TcpState, TrafficHistory,
|
||||
};
|
||||
@@ -122,6 +123,8 @@ pub struct UIState {
|
||||
pub show_port_numbers: bool,
|
||||
pub sort_column: SortColumn,
|
||||
pub sort_ascending: bool,
|
||||
/// Show hostnames instead of IP addresses (when DNS resolution is enabled)
|
||||
pub show_hostnames: bool,
|
||||
}
|
||||
|
||||
impl Default for UIState {
|
||||
@@ -138,6 +141,7 @@ impl Default for UIState {
|
||||
show_port_numbers: false,
|
||||
sort_column: SortColumn::default(),
|
||||
sort_ascending: true, // Default to ascending
|
||||
show_hostnames: true, // Show hostnames by default when DNS resolution is enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -410,7 +414,10 @@ pub fn draw(
|
||||
|
||||
match ui_state.selected_tab {
|
||||
0 => draw_overview(f, ui_state, connections, stats, app, content_area)?,
|
||||
1 => draw_connection_details(f, ui_state, connections, content_area)?,
|
||||
1 => {
|
||||
let dns_resolver = app.get_dns_resolver();
|
||||
draw_connection_details(f, ui_state, connections, content_area, dns_resolver.as_deref())?
|
||||
}
|
||||
2 => draw_interface_stats(f, app, content_area)?,
|
||||
3 => draw_graph_tab(f, app, connections, content_area)?,
|
||||
4 => draw_help(f, content_area)?,
|
||||
@@ -467,7 +474,9 @@ fn draw_overview(
|
||||
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
|
||||
.split(area);
|
||||
|
||||
draw_connections_list(f, ui_state, connections, chunks[0]);
|
||||
// Get DNS resolver from app if enabled
|
||||
let dns_resolver = app.get_dns_resolver();
|
||||
draw_connections_list(f, ui_state, connections, chunks[0], dns_resolver.as_deref());
|
||||
draw_stats_panel(f, connections, stats, app, chunks[1])?;
|
||||
|
||||
Ok(())
|
||||
@@ -479,11 +488,15 @@ fn draw_connections_list(
|
||||
ui_state: &UIState,
|
||||
connections: &[Connection],
|
||||
area: Rect,
|
||||
dns_resolver: Option<&DnsResolver>,
|
||||
) {
|
||||
// When DNS resolution is enabled, we need more space for hostnames
|
||||
let remote_addr_width = if dns_resolver.is_some() && ui_state.show_hostnames { 30 } else { 21 };
|
||||
|
||||
let widths = [
|
||||
Constraint::Length(6), // Protocol (TCP/UDP + arrow = "Pro ↑" = 5 chars, give 6 for padding)
|
||||
Constraint::Length(17), // Local Address (13 + arrow = 15, fits in 17)
|
||||
Constraint::Length(21), // Remote Address (14 + arrow = 16, fits in 21)
|
||||
Constraint::Length(remote_addr_width), // Remote Address - wider when showing hostnames
|
||||
Constraint::Length(16), // State (5 + arrow = 7, fits in 16)
|
||||
Constraint::Length(10), // Service (7 + arrow = 9, need at least 10 for padding)
|
||||
Constraint::Length(24), // DPI/Application (18 + arrow = 20, fits in 24)
|
||||
@@ -659,10 +672,33 @@ fn draw_connections_list(
|
||||
Style::default()
|
||||
};
|
||||
|
||||
// Format addresses - use hostnames when DNS resolution is enabled and show_hostnames is true
|
||||
let local_addr_display = conn.local_addr.to_string();
|
||||
let remote_addr_display = if ui_state.show_hostnames {
|
||||
if let Some(resolver) = dns_resolver {
|
||||
if let Some(hostname) = resolver.get_hostname(&conn.remote_addr.ip()) {
|
||||
// Truncate hostname if too long, but always show port
|
||||
let port = conn.remote_addr.port();
|
||||
let max_hostname_len = (remote_addr_width as usize).saturating_sub(7); // Leave room for :port
|
||||
if hostname.len() > max_hostname_len {
|
||||
format!("{}...:{}", &hostname[..max_hostname_len.saturating_sub(3)], port)
|
||||
} else {
|
||||
format!("{}:{}", hostname, port)
|
||||
}
|
||||
} else {
|
||||
conn.remote_addr.to_string()
|
||||
}
|
||||
} else {
|
||||
conn.remote_addr.to_string()
|
||||
}
|
||||
} else {
|
||||
conn.remote_addr.to_string()
|
||||
};
|
||||
|
||||
let cells = [
|
||||
Cell::from(conn.protocol.to_string()),
|
||||
Cell::from(conn.local_addr.to_string()),
|
||||
Cell::from(conn.remote_addr.to_string()),
|
||||
Cell::from(local_addr_display),
|
||||
Cell::from(remote_addr_display),
|
||||
Cell::from(conn.state()),
|
||||
Cell::from(service_display),
|
||||
Cell::from(dpi_display),
|
||||
@@ -1664,6 +1700,7 @@ fn draw_connection_details(
|
||||
ui_state: &UIState,
|
||||
connections: &[Connection],
|
||||
area: Rect,
|
||||
dns_resolver: Option<&DnsResolver>,
|
||||
) -> Result<()> {
|
||||
if connections.is_empty() {
|
||||
let text = Paragraph::new("No connections available")
|
||||
@@ -1722,6 +1759,24 @@ fn draw_connection_details(
|
||||
]),
|
||||
];
|
||||
|
||||
// Add reverse DNS hostnames if available
|
||||
if let Some(resolver) = dns_resolver {
|
||||
let local_hostname = resolver.get_hostname(&conn.local_addr.ip());
|
||||
let remote_hostname = resolver.get_hostname(&conn.remote_addr.ip());
|
||||
|
||||
if local_hostname.is_some() || remote_hostname.is_some() {
|
||||
details_text.push(Line::from("")); // Empty line separator
|
||||
details_text.push(Line::from(vec![
|
||||
Span::styled("Local Hostname: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(local_hostname.unwrap_or_else(|| "-".to_string())),
|
||||
]));
|
||||
details_text.push(Line::from(vec![
|
||||
Span::styled("Remote Hostname: ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw(remote_hostname.unwrap_or_else(|| "-".to_string())),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
// Add DPI information
|
||||
match &conn.dpi_info {
|
||||
Some(dpi) => {
|
||||
@@ -2022,6 +2077,10 @@ fn draw_help(f: &mut Frame, area: Rect) -> Result<()> {
|
||||
Span::styled("p ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("Toggle between service names and port numbers"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("d ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("Toggle between hostnames and IP addresses (when --resolve-dns)"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("s ", Style::default().fg(Color::Yellow)),
|
||||
Span::raw("Cycle through sort columns (Bandwidth, Process, etc.)"),
|
||||
|
||||
Reference in New Issue
Block a user