diff --git a/README.md b/README.md index e082edc..39d5762 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A cross-platform network monitoring tool built with Rust. RustNet provides real- - **Smart Connection Lifecycle**: Protocol-aware timeouts with visual staleness indicators (white → yellow → red) before cleanup - **Process Identification**: Associate network connections with running processes - **Service Name Resolution**: Identify well-known services using port numbers +- **Reverse DNS Lookups**: Resolve IP addresses to hostnames with background async resolution and caching - **Cross-platform Support**: Works on Linux, macOS, Windows, and FreeBSD - **Advanced Filtering**: Real-time vim/fzf-style filtering with keyword support (`port:`, `src:`, `dst:`, `sni:`, `process:`, `state:`) - **Terminal User Interface**: Beautiful TUI built with ratatui with adjustable column widths @@ -133,6 +134,7 @@ rustnet ```bash rustnet -i eth0 # Specify network interface rustnet --show-localhost # Show localhost connections +rustnet --resolve-dns # Enable reverse DNS lookups rustnet -r 500 # Set refresh interval (ms) ``` @@ -152,6 +154,7 @@ See [INSTALL.md](INSTALL.md) for detailed permission setup and [USAGE.md](USAGE. | `Esc` | Go back or clear filter | | `c` | Copy remote address | | `p` | Toggle service names/ports | +| `d` | Toggle hostnames/IPs (with `--resolve-dns`) | | `s` `S` | Cycle sort columns / toggle direction | | `/` | Enter filter mode | | `h` | Toggle help | diff --git a/ROADMAP.md b/ROADMAP.md index ff4ca97..5885051 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,7 +66,7 @@ The experimental eBPF support provides efficient process identification but has - [x] **Process Identification**: Associate network connections with running processes (with experimental eBPF support on Linux) - [x] **Service Name Resolution**: Identify well-known services using port numbers - [x] **Cross-platform Support**: Works on Linux, macOS, Windows, and FreeBSD -- [ ] **DNS Reverse Lookup**: Add optional hostname resolution (toggle between IP and hostname display) +- [x] **DNS Reverse Lookup**: Add optional hostname resolution (toggle between IP and hostname display) - `--resolve-dns` flag with `d` key toggle - [ ] **IPv6 Support**: Full IPv6 connection tracking and display, including DNS resolution (needs testing) ### Filtering & Search diff --git a/USAGE.md b/USAGE.md index 7d415f8..d9d7bce 100644 --- a/USAGE.md +++ b/USAGE.md @@ -58,6 +58,9 @@ rustnet --refresh-interval 2000 # Disable deep packet inspection rustnet --no-dpi +# Enable reverse DNS lookups to show hostnames +rustnet --resolve-dns + # Enable logging with specific level (options: error, warn, info, debug, trace) rustnet -l debug rustnet --log-level info @@ -77,6 +80,8 @@ Options: --show-localhost Show localhost connections (overrides default filtering) -r, --refresh-interval UI refresh interval in milliseconds [default: 1000] --no-dpi Disable deep packet inspection + --resolve-dns Enable reverse DNS lookups to show hostnames + --show-ptr-lookups Show PTR lookup connections (hidden by default with --resolve-dns) -l, --log-level Set the log level (if not provided, no logging will be enabled) --json-log Enable JSON logging of connection events to specified file -f, --bpf-filter BPF filter expression for packet capture @@ -170,6 +175,15 @@ Disable Deep Packet Inspection (DPI). This reduces CPU usage by 20-40% on high-t Useful for performance-constrained environments or when application-level details aren't needed. +#### `--resolve-dns` / `--show-ptr-lookups` + +Enable reverse DNS lookups to display hostnames instead of IP addresses. + +- **`--resolve-dns`**: Resolves IP addresses to hostnames in the background. Hostnames appear in the connection list (toggle with `d` key) and in the Details tab. +- **`--show-ptr-lookups`**: By default, PTR lookup traffic is hidden when `--resolve-dns` is enabled. Use this flag to show the DNS PTR queries. + +**Note**: Resolved hostnames are also included in JSON logs (`destination_hostname`, `source_hostname` fields). + #### `-f, --bpf-filter ` Apply a BPF (Berkeley Packet Filter) expression to filter packets at capture time. This is more efficient than application-level filtering as packets are filtered in the kernel before reaching RustNet. @@ -243,6 +257,7 @@ Log files are created in the `logs/` directory with timestamp: `rustnet_YYYY-MM- - `c` - Copy remote address to clipboard - `p` - Toggle between service names and port numbers +- `d` - Toggle between hostnames and IP addresses (requires `--resolve-dns`) - `/` - Enter filter mode (vim-style search with real-time results) ### Sorting diff --git a/src/app.rs b/src/app.rs index aecf5ce..e077d58 100644 --- a/src/app.rs +++ b/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, + 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, /// JSON log file path for connection events pub json_log_file: Option, + /// 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>, + /// DNS resolver for reverse DNS lookups + dns_resolver: Option>, + /// Sandbox status (Linux Landlock) #[cfg(target_os = "linux")] sandbox_info: Arc>, @@ -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>) -> 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 { - 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 { 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 = 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> { + 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, rtt_tracker: &Arc>, + 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 diff --git a/src/cli.rs b/src/cli.rs index 5d718bc..ec73596 100644 --- a/src/cli.rs +++ b/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")] diff --git a/src/main.rs b/src/main.rs index 0e4105f..c023e8c 100644 --- a/src/main.rs +++ b/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( ); } + // 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; diff --git a/src/network/dns.rs b/src/network/dns.rs new file mode 100644 index 0000000..41d8bb4 --- /dev/null +++ b/src/network/dns.rs @@ -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, + /// 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>, + /// Channel to send IPs for resolution + request_tx: Sender, + /// Control flag for shutdown + should_stop: Arc, + /// 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, 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 { + // 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); + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index c273a74..7d24650 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -1,4 +1,5 @@ pub mod capture; +pub mod dns; pub mod dpi; pub mod interface_stats; pub mod link_layer; diff --git a/src/ui.rs b/src/ui.rs index db1a20f..249ef37 100644 --- a/src/ui.rs +++ b/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.)"),