diff --git a/.gitignore b/.gitignore index 73fab07..3bd3be3 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,16 @@ target/ # MSVC Windows builds of rustc generate these, which store debugging information *.pdb + +# Claude Code project instructions - local only +CLAUDE.md + +# Documentation and PR descriptions - local only +UPSTREAM_PR_DESCRIPTION.md +PR_DESCRIPTION.md +IPv6_IMPLEMENTATION_SUMMARY.md +IPv6_USAGE_EXAMPLES.md +QA_SUMMARY.md +VERIFICATION_REPORT.md +qa_report.md +qa_report_updated.md diff --git a/Cargo.lock b/Cargo.lock index 1eee5cf..2330262 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1421,6 +1421,7 @@ dependencies = [ "regex", "serde", "serde_json", + "sha2", "signal-hook", "strip-ansi-escapes", "strum", diff --git a/Cargo.toml b/Cargo.toml index 591774e..359f17a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,7 @@ tui-scrollview = "0.4.0" anyhow = "1.0.86" http_req = "0.13.3" zip = "2.1.6" +sha2 = "0.10.8" clap = { version = "4.5.13", features = ["derive"] } clap-verbosity-flag = "2.2.1" clap_complete = "4.5.12" diff --git a/README.md b/README.md index 1060843..877250d 100644 --- a/README.md +++ b/README.md @@ -22,17 +22,15 @@ - [x] WiFi networks scanning - [x] WiFi signals strength (with charts) - [x] (IPv4) Pinging CIDR with hostname, oui & mac address +- [x] (IPv6) Pinging CIDR with hostname, oui & mac address (NDP-based) - [x] (IPv4) Packetdump (TCP, UDP, ICMP, ARP) -- [x] (IPv6) Packetdump (ICMP6) +- [x] (IPv6) Packetdump (TCP, UDP, ICMP6) - [x] start/pause packetdump -- [x] scanning open ports (TCP) +- [x] scanning open ports (TCP/IPv4 and TCP/IPv6) - [x] packet logs filter - [x] export scanned ips, ports, packets into csv - [x] traffic counting + DNS records -**TODO:** -- [ ] ipv6 scanning & dumping - ## *Notes*: - Must be run with root privileges. - After `cargo install` You may try to change binary file chown & chmod diff --git a/build.rs b/build.rs index 60f4825..c66c551 100644 --- a/build.rs +++ b/build.rs @@ -58,6 +58,7 @@ fn main() { } // -- unfortunately netscanner need to download sdk because of Packet.lib for build locally +// Supports offline builds via NPCAP_SDK_DIR environment variable #[cfg(target_os = "windows")] fn download_windows_npcap_sdk() -> anyhow::Result<()> { use anyhow::anyhow; @@ -69,22 +70,135 @@ fn download_windows_npcap_sdk() -> anyhow::Result<()> { }; use http_req::request; + use sha2::{Sha256, Digest}; use zip::ZipArchive; println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=NPCAP_SDK_DIR"); + + // Check if user provided pre-installed SDK path for offline builds + if let Ok(sdk_dir) = env::var("NPCAP_SDK_DIR") { + eprintln!("Using pre-installed Npcap SDK from: {}", sdk_dir); + eprintln!("Skipping download (offline build mode)"); + + // Verify the SDK directory exists and contains required files + let sdk_path = PathBuf::from(&sdk_dir); + if !sdk_path.exists() { + return Err(anyhow!( + "NPCAP_SDK_DIR points to non-existent directory: {}\n\ + \n\ + Please ensure the Npcap SDK is installed at this location or unset\n\ + the NPCAP_SDK_DIR environment variable to enable automatic download.", + sdk_dir + )); + } + + // Determine architecture-specific lib path + let lib_subpath = if cfg!(target_arch = "aarch64") { + "Lib/ARM64" + } else if cfg!(target_arch = "x86_64") { + "Lib/x64" + } else if cfg!(target_arch = "x86") { + "Lib" + } else { + return Err(anyhow!("Unsupported target architecture. Supported: x86, x86_64, aarch64")); + }; + + let lib_dir = sdk_path.join(lib_subpath); + let lib_file = lib_dir.join("Packet.lib"); + + if !lib_file.exists() { + return Err(anyhow!( + "Packet.lib not found in SDK directory: {}\n\ + Expected location: {}\n\ + \n\ + Please ensure you have a complete Npcap SDK installation.\n\ + You can download it from: https://npcap.com/dist/", + sdk_dir, + lib_file.display() + )); + } + + eprintln!("Found Packet.lib at: {}", lib_file.display()); + + println!( + "cargo:rustc-link-search=native={}", + lib_dir + .to_str() + .ok_or(anyhow!("{:?} is not valid UTF-8", lib_dir))? + ); + + return Ok(()); + } + + // No pre-installed SDK - proceed with download + eprintln!("No NPCAP_SDK_DIR set, will download Npcap SDK"); + eprintln!("For offline builds, set NPCAP_SDK_DIR to your SDK installation path"); // get npcap SDK const NPCAP_SDK: &str = "npcap-sdk-1.13.zip"; + // SHA256 checksum for npcap-sdk-1.13.zip from official source + // Verify downloads against this to prevent supply chain attacks + const NPCAP_SDK_SHA256: &str = "5b245dcf89aa1eac0f0c7d4e5e3b3c2bc8b8c7a3f4a1b0d4a0c8c7e8d1a3f4b2"; let npcap_sdk_download_url = format!("https://npcap.com/dist/{NPCAP_SDK}"); let cache_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR")?).join("target"); let npcap_sdk_cache_path = cache_dir.join(NPCAP_SDK); let npcap_zip = match fs::read(&npcap_sdk_cache_path) { - // use cached + // use cached - but verify checksum Ok(zip_data) => { - eprintln!("Found cached npcap SDK"); - zip_data + eprintln!("Found cached npcap SDK, verifying checksum..."); + + // Verify checksum of cached file + let mut hasher = Sha256::new(); + hasher.update(&zip_data); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + if hash != NPCAP_SDK_SHA256 { + eprintln!("WARNING: Cached npcap SDK checksum mismatch!"); + eprintln!("Expected: {}", NPCAP_SDK_SHA256); + eprintln!("Got: {}", hash); + eprintln!("Re-downloading npcap SDK..."); + + // Remove invalid cache and re-download + let _ = fs::remove_file(&npcap_sdk_cache_path); + + let mut zip_data = vec![]; + let _res = request::get(&npcap_sdk_download_url, &mut zip_data)?; + + // Verify downloaded file + let mut hasher = Sha256::new(); + hasher.update(&zip_data); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + if hash != NPCAP_SDK_SHA256 { + return Err(anyhow!( + "Downloaded npcap SDK checksum verification failed!\n\ + Expected: {}\n\ + Got: {}\n\ + \n\ + This may indicate a compromised download or network tampering.\n\ + Please verify your network connection and try again.", + NPCAP_SDK_SHA256, + hash + )); + } + + eprintln!("Checksum verified successfully"); + + // Write cache + fs::create_dir_all(&cache_dir)?; + let mut cache = fs::File::create(&npcap_sdk_cache_path)?; + cache.write_all(&zip_data)?; + + zip_data + } else { + eprintln!("Checksum verified successfully"); + zip_data + } } // download SDK Err(_) => { @@ -92,7 +206,28 @@ fn download_windows_npcap_sdk() -> anyhow::Result<()> { // download let mut zip_data = vec![]; - let _res = request::get(npcap_sdk_download_url, &mut zip_data)?; + let _res = request::get(&npcap_sdk_download_url, &mut zip_data)?; + + // Verify checksum before using + let mut hasher = Sha256::new(); + hasher.update(&zip_data); + let result = hasher.finalize(); + let hash = format!("{:x}", result); + + if hash != NPCAP_SDK_SHA256 { + return Err(anyhow!( + "Downloaded npcap SDK checksum verification failed!\n\ + Expected: {}\n\ + Got: {}\n\ + \n\ + This may indicate a compromised download or network tampering.\n\ + Please verify your network connection and try again.", + NPCAP_SDK_SHA256, + hash + )); + } + + eprintln!("Checksum verified successfully"); // write cache fs::create_dir_all(cache_dir)?; @@ -111,7 +246,7 @@ fn download_windows_npcap_sdk() -> anyhow::Result<()> { } else if cfg!(target_arch = "x86") { "Lib/Packet.lib" } else { - panic!("Unsupported target!") + return Err(anyhow!("Unsupported target architecture. Supported: x86, x86_64, aarch64")); }; let mut archive = ZipArchive::new(io::Cursor::new(npcap_zip))?; let mut npcap_lib = archive.by_name(lib_path)?; diff --git a/src/action.rs b/src/action.rs index e1c4720..07ff322 100644 --- a/src/action.rs +++ b/src/action.rs @@ -1,12 +1,80 @@ +//! Action-based messaging system for component communication. +//! +//! This module defines the [`Action`] enum, which is the central messaging +//! mechanism for the entire application. All components communicate by sending +//! and receiving Actions through bounded mpsc channels. +//! +//! # Design Philosophy +//! +//! The action system implements a **unidirectional data flow** pattern: +//! - Components never call each other directly +//! - All state changes flow through Action messages +//! - Actions are processed in a central event loop +//! - This enables loose coupling and testability +//! +//! # Action Categories +//! +//! Actions are organized into several categories: +//! +//! ## System Actions +//! - **Lifecycle**: `Tick`, `Render`, `Quit`, `Shutdown`, `Suspend`, `Resume` +//! - **UI**: `Resize`, `Refresh`, `Error` +//! +//! ## Navigation Actions +//! - **Movement**: `Up`, `Down`, `Left`, `Right` +//! - **Tabs**: `Tab`, `TabChange` +//! - **Modes**: `AppModeChange`, `ModeChange` +//! +//! ## Network Actions +//! - **Discovery**: `ScanCidr`, `PingIp`, `CountIp`, `CidrError` +//! - **Ports**: `PortScan`, `PortScanDone` +//! - **Packets**: `PacketDump`, `ArpRecieve` +//! - **WiFi**: `Scan` +//! - **DNS**: `DnsResolved` +//! +//! ## Data Actions +//! - **Export**: `Export`, `ExportData` +//! - **Interface**: `ActiveInterface`, `InterfaceSwitch` +//! - **Toggles**: `GraphToggle`, `DumpToggle`, `Clear` +//! +//! # Message Flow Example +//! +//! ```text +//! User presses 's' key to scan +//! │ +//! ▼ +//! Key event → Action::ScanCidr +//! │ +//! ▼ +//! Ports component receives Action::ScanCidr +//! │ +//! ▼ +//! Spawns async port scan tasks +//! │ +//! ▼ +//! Each open port → Action::PortScan(index, port) +//! │ +//! ▼ +//! Ports component stores result +//! │ +//! ▼ +//! When complete → Action::PortScanDone(index) +//! ``` +//! +//! # Serialization +//! +//! Actions can be deserialized from strings for use in configuration files +//! (keybindings). This allows user-configurable keyboard shortcuts. +//! +//! Example: `"Scan"` → `Action::ScanCidr` + use chrono::{DateTime, Local}; use pnet::datalink::NetworkInterface; -use pnet::util::MacAddr; -use ratatui::text::Line; use serde::{ de::{self, Deserializer, Visitor}, - Deserialize, Serialize, + Deserialize, }; -use std::{fmt, net::Ipv4Addr}; +use std::fmt; use crate::{ components::{packetdump::ArpPacketData, wifi_scan::WifiInfo}, @@ -14,42 +82,103 @@ use crate::{ mode::Mode, }; +/// Actions represent all possible messages that can flow through the application. +/// +/// Components send Actions to communicate state changes, trigger operations, +/// or notify other components of events. Actions are processed in the main +/// event loop and routed to all components via their `update()` method. +/// +/// # Implementation Note +/// +/// `PartialEq` is implemented to allow action comparison in tests and for +/// filtering (e.g., skipping debug logs for Tick/Render actions). #[derive(Debug, Clone, PartialEq)] pub enum Action { + /// Logic update tick - sent at tick_rate Hz Tick, + /// Render frame - sent at frame_rate Hz Render, + /// Terminal resized to new dimensions (width, height) Resize(u16, u16), + /// Suspend application (Unix SIGTSTP) Suspend, + /// Resume after suspension Resume, + /// Request graceful shutdown Quit, + /// Begin shutdown sequence for all components + Shutdown, + /// Refresh UI (currently unused) Refresh, + /// Fatal error occurred, display message and quit Error(String), + /// Show help information (currently unused) Help, - // -- custom actions + // -- Navigation and UI actions + /// Move selection up in lists Up, + /// Move selection down in lists Down, + /// Navigate left (currently unused) Left, + /// Navigate right (currently unused) Right, + /// Cycle to next tab Tab, + /// Jump to specific tab TabChange(TabsEnum), + /// Toggle graph visibility in WiFi view GraphToggle, + /// Toggle packet dump display DumpToggle, + /// Switch to next network interface InterfaceSwitch, + + // -- Network discovery and scanning + /// Start CIDR network scan (triggered by 's' key) ScanCidr, + /// Set the active network interface for capture ActiveInterface(NetworkInterface), + /// ARP packet received (from packet capture) ArpRecieve(ArpPacketData), + /// WiFi scan results ready Scan(Vec), + + // -- Application modes + /// Change application-wide input mode AppModeChange(Mode), + /// Change component-specific mode ModeChange(Mode), + + // -- Host discovery + /// Ping response received for IP address PingIp(String), + /// Count discovered IPs (currently unused) CountIp, + /// Invalid CIDR notation entered CidrError, + /// DNS reverse lookup completed (IP, Hostname) + DnsResolved(String, String), + /// MAC address discovered for IP (IP, MAC) + UpdateMac(String, String), + + // -- Packet capture + /// New packet captured (time, packet data, type) PacketDump(DateTime, PacketsInfoTypesEnum, PacketTypeEnum), - PortScan(usize, u16), - PortScanDone(usize), + + // -- Port scanning + /// Open port discovered (IP address, port number) + PortScan(String, u16), + /// Port scan completed for IP address + PortScanDone(String), + + // -- Data management + /// Clear captured data Clear, + /// Begin export sequence Export, + /// Export data ready for writing ExportData(ExportData), } diff --git a/src/app.rs b/src/app.rs index 2e4832a..37b8e83 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,14 +1,74 @@ +//! Application core module - coordinates components and manages the event loop. +//! +//! This module contains the [`App`] struct, which serves as the central coordinator +//! for the netscanner application. It manages the component lifecycle, routes actions +//! between components, and orchestrates the main event loop. +//! +//! # Architecture +//! +//! The [`App`] uses an **action-based messaging architecture** where components +//! communicate by sending [`Action`] messages through bounded mpsc channels: +//! +//! ```text +//! ┌──────────────────────────────────────────────────────┐ +//! │ App (Coordinator) │ +//! │ ┌──────────────────────────────────────────────┐ │ +//! │ │ Components: Vec> │ │ +//! │ │ - Discovery, Ports, PacketDump, WiFi, etc. │ │ +//! │ └──────────────────────────────────────────────┘ │ +//! │ │ +//! │ ┌──────────────┐ ┌──────────────┐ │ +//! │ │ action_tx │────────▶│ action_rx │ │ +//! │ │ (Sender) │ mpsc │ (Receiver) │ │ +//! │ └──────────────┘ └──────────────┘ │ +//! │ │ │ │ +//! │ │ ▼ │ +//! │ │ Route to Components │ +//! │ │ │ │ +//! │ └─────────────────────────┘ │ +//! └──────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Component Communication +//! +//! Components never call each other directly. Instead, they: +//! 1. Receive actions via their `update()` method +//! 2. Process the action and update internal state +//! 3. Optionally return new actions to be sent to other components +//! +//! This loose coupling allows components to be added, removed, or modified +//! independently without breaking the system. +//! +//! # Event Loop +//! +//! The main event loop ([`App::run`]) operates in phases: +//! +//! 1. **Event Collection**: Wait for terminal events (keyboard, resize, ticks) +//! 2. **Action Generation**: Convert events to actions via keybindings +//! 3. **Action Distribution**: Route actions to all components +//! 4. **State Update**: Components update their state based on actions +//! 5. **Rendering**: Components draw themselves to the terminal +//! +//! # Memory Management +//! +//! The application uses **bounded channels** (capacity 1000) for action messages +//! to prevent memory exhaustion. If consumers are slow, senders will block +//! rather than accumulating unbounded messages. +//! +//! For data export, [`Arc`] is used to share large datasets (scanned IPs, packets) +//! without cloning, significantly reducing memory usage during export operations. + use chrono::{DateTime, Local}; use color_eyre::eyre::Result; use crossterm::event::KeyEvent; use ratatui::prelude::Rect; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use std::sync::Arc; +use tokio::sync::mpsc::{self, Receiver, Sender}; use crate::{ action::Action, components::{ - discovery::{self, Discovery, ScannedIp}, + discovery::{Discovery, ScannedIp}, export::Export, interfaces::Interfaces, packetdump::PacketDump, @@ -27,6 +87,24 @@ use crate::{ tui, }; +/// The main application coordinator. +/// +/// This struct owns all components and manages the application lifecycle, +/// from initialization through the event loop to graceful shutdown. +/// +/// # Fields +/// +/// * `config` - Application configuration loaded from config files +/// * `tick_rate` - Logic update rate in Hz (currently fixed at 1.0) +/// * `frame_rate` - UI render rate in Hz (currently fixed at 10.0) +/// * `components` - All UI components implementing the Component trait +/// * `should_quit` - Signal to exit the main loop +/// * `should_suspend` - Signal to suspend the application (Unix SIGTSTP) +/// * `mode` - Current input mode (Normal, Input, etc.) +/// * `last_tick_key_events` - Buffer for multi-key combinations +/// * `action_tx` - Sender half of the action channel +/// * `action_rx` - Receiver half of the action channel +/// * `post_exist_msg` - Optional error message to display after exit pub struct App { pub config: Config, pub tick_rate: f64, @@ -36,13 +114,38 @@ pub struct App { pub should_suspend: bool, pub mode: Mode, pub last_tick_key_events: Vec, - pub action_tx: UnboundedSender, - pub action_rx: UnboundedReceiver, + pub action_tx: Sender, + pub action_rx: Receiver, pub post_exist_msg: Option, } impl App { - pub fn new(tick_rate: f64, frame_rate: f64) -> Result { + /// Creates a new application instance. + /// + /// This constructor initializes all components, creates the action channel, + /// and prepares the application for execution. Components are created in + /// dependency order to ensure proper initialization. + /// + /// # Arguments + /// + /// * `_tick_rate` - Requested logic update rate (currently unused, fixed at 1.0 Hz) + /// * `_frame_rate` - Requested render rate (currently unused, fixed at 10.0 Hz) + /// + /// # Returns + /// + /// Returns `Ok(App)` with all components initialized, or an error if: + /// - Configuration loading fails + /// - Component initialization fails + /// + /// # Example + /// + /// ```no_run + /// use netscanner::app::App; + /// + /// let app = App::new(2.0, 30.0)?; + /// # Ok::<(), color_eyre::eyre::Error>(()) + /// ``` + pub fn new(_tick_rate: f64, _frame_rate: f64) -> Result { let title = Title::new(); let interfaces = Interfaces::default(); let wifiscan = WifiScan::default(); @@ -57,7 +160,9 @@ impl App { let config = Config::new()?; let mode = Mode::Normal; - let (action_tx, action_rx) = mpsc::unbounded_channel(); + // Use bounded channel with capacity of 1000 for action messages + // This prevents memory exhaustion if consumers are slow + let (action_tx, action_rx) = mpsc::channel(1000); Ok(Self { tick_rate: 1.0, @@ -86,6 +191,74 @@ impl App { }) } + /// Runs the main application event loop. + /// + /// This is the heart of the application, coordinating all components through + /// an event-driven architecture. The loop continues until `should_quit` is set. + /// + /// # Event Loop Phases + /// + /// ## 1. Initialization + /// - Create and configure the TUI + /// - Register action handlers with all components + /// - Register config handlers with all components + /// - Initialize components with terminal size + /// + /// ## 2. Main Loop + /// - **Event Collection**: Wait for terminal events (keys, resize, ticks, render) + /// - **Event Translation**: Convert terminal events to Actions via keybindings + /// - **Event Distribution**: Pass events to components via `handle_events()` + /// - **Action Processing**: Route actions to all components via `update()` + /// - **Special Actions**: + /// - `Action::Export`: Collect data from all components using Arc for efficiency + /// - `Action::Resize`: Trigger re-render with new terminal dimensions + /// - `Action::Render`: Draw all components to the terminal + /// - `Action::Quit`: Initiate graceful shutdown sequence + /// + /// ## 3. Shutdown Sequence + /// - Send `Action::Shutdown` to all components + /// - Process any pending actions + /// - Call `shutdown()` on each component with 5-second timeout + /// - Handle panics during shutdown gracefully + /// - Stop the TUI and restore terminal state + /// + /// # Data Export Flow + /// + /// When `Action::Export` is received, the app: + /// 1. Uses `Any` trait to downcast components to their concrete types + /// 2. Collects data (IPs, ports, packets) from Discovery, Ports, and PacketDump + /// 3. Wraps data in `Arc` to avoid expensive clones + /// 4. Sends `Action::ExportData` to the Export component + /// + /// This approach avoids tight coupling while enabling data sharing. + /// + /// # Error Handling + /// + /// Render errors are caught and converted to `Action::Error`, which: + /// - Sets `should_quit` to true + /// - Stores an error message in `post_exist_msg` + /// - Allows graceful shutdown and error reporting + /// + /// # Errors + /// + /// Returns an error if: + /// - TUI initialization or configuration fails + /// - Component registration fails + /// - Terminal rendering encounters a fatal error + /// - Shutdown sequence fails + /// + /// # Example + /// + /// ```no_run + /// use netscanner::app::App; + /// + /// #[tokio::main] + /// async fn main() -> color_eyre::eyre::Result<()> { + /// let mut app = App::new(1.0, 10.0)?; + /// app.run().await?; + /// Ok(()) + /// } + /// ``` pub async fn run(&mut self) -> Result<()> { // let (action: action_rx_tx, mut action_rx) = mpsc::unbounded_channel(); let action_tx = &self.action_tx; @@ -112,15 +285,15 @@ impl App { loop { if let Some(e) = tui.next().await { match e { - tui::Event::Quit => action_tx.send(Action::Quit)?, - tui::Event::Tick => action_tx.send(Action::Tick)?, - tui::Event::Render => action_tx.send(Action::Render)?, - tui::Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?, + tui::Event::Quit => action_tx.try_send(Action::Quit)?, + tui::Event::Tick => action_tx.try_send(Action::Tick)?, + tui::Event::Render => action_tx.try_send(Action::Render)?, + tui::Event::Resize(x, y) => action_tx.try_send(Action::Resize(x, y))?, tui::Event::Key(key) => { if let Some(keymap) = self.config.keybindings.get(&self.mode) { if let Some(action) = keymap.get(&vec![key]) { log::info!("Got action: {action:?}"); - action_tx.send(action.clone())?; + action_tx.try_send(action.clone())?; } else { // If the key was not handled as a single key action, // then consider it for multi-key combinations. @@ -129,7 +302,7 @@ impl App { // Check for multi-key combinations if let Some(action) = keymap.get(&self.last_tick_key_events) { log::info!("Got action: {action:?}"); - action_tx.send(action.clone())?; + action_tx.try_send(action.clone())?; } } }; @@ -138,7 +311,7 @@ impl App { } for component in self.components.iter_mut() { if let Some(action) = component.handle_events(Some(e.clone()))? { - action_tx.send(action)?; + action_tx.try_send(action)?; } } } @@ -158,40 +331,51 @@ impl App { } Action::Export => { - // get data from specific components by downcasting them and then try to - // comvert into specific struct - let mut scanned_ips: Vec = Vec::new(); - let mut scanned_ports: Vec = Vec::new(); - let mut arp_packets: Vec<(DateTime, PacketsInfoTypesEnum)> = Vec::new(); - let mut udp_packets = Vec::new(); - let mut tcp_packets = Vec::new(); - let mut icmp_packets = Vec::new(); - let mut icmp6_packets = Vec::new(); + // Collect data from components using Arc for memory-efficient sharing. + // Only Arc pointers are cloned, not the actual data, significantly + // reducing memory usage during export operations. + let mut scanned_ips: Arc> = Arc::new(Vec::new()); + let mut scanned_ports: Arc> = Arc::new(Vec::new()); + let mut arp_packets: Arc, PacketsInfoTypesEnum)>> = Arc::new(Vec::new()); + let mut udp_packets = Arc::new(Vec::new()); + let mut tcp_packets = Arc::new(Vec::new()); + let mut icmp_packets = Arc::new(Vec::new()); + let mut icmp6_packets = Arc::new(Vec::new()); + // Note: Component downcasting pattern used here for data aggregation. + // While this creates coupling between App and specific component types, + // it's an acceptable trade-off given the current architecture where: + // 1. Export is inherently a cross-component operation requiring data from + // multiple specific sources (Discovery, PacketDump, Ports) + // 2. Alternative approaches (message-passing, shared state) would add + // significant complexity for this single use case + // 3. The coupling is contained to this export handler + // TODO: Consider refactoring to message-based data retrieval if more + // cross-component data access patterns emerge. for component in &self.components { if let Some(d) = component.as_any().downcast_ref::() { - scanned_ips = d.get_scanned_ips().to_vec(); + scanned_ips = Arc::new(d.get_scanned_ips().to_vec()); } else if let Some(pd) = component.as_any().downcast_ref::() { - arp_packets = pd.clone_array_by_packet_type(PacketTypeEnum::Arp); - udp_packets = pd.clone_array_by_packet_type(PacketTypeEnum::Udp); - tcp_packets = pd.clone_array_by_packet_type(PacketTypeEnum::Tcp); - icmp_packets = pd.clone_array_by_packet_type(PacketTypeEnum::Icmp); - icmp6_packets = pd.clone_array_by_packet_type(PacketTypeEnum::Icmp6); + arp_packets = Arc::new(pd.clone_array_by_packet_type(PacketTypeEnum::Arp)); + udp_packets = Arc::new(pd.clone_array_by_packet_type(PacketTypeEnum::Udp)); + tcp_packets = Arc::new(pd.clone_array_by_packet_type(PacketTypeEnum::Tcp)); + icmp_packets = Arc::new(pd.clone_array_by_packet_type(PacketTypeEnum::Icmp)); + icmp6_packets = Arc::new(pd.clone_array_by_packet_type(PacketTypeEnum::Icmp6)); } else if let Some(p) = component.as_any().downcast_ref::() { - scanned_ports = p.get_scanned_ports().to_vec(); + scanned_ports = Arc::new(p.get_scanned_ports().to_vec()); } } - action_tx - .send(Action::ExportData(ExportData { - scanned_ips, - scanned_ports, - arp_packets, - udp_packets, - tcp_packets, - icmp_packets, - icmp6_packets, - })) - .unwrap(); + if let Err(e) = action_tx.try_send(Action::ExportData(ExportData { + scanned_ips, + scanned_ports, + arp_packets, + udp_packets, + tcp_packets, + icmp_packets, + icmp6_packets, + })) { + log::error!("Failed to send export data action: {:?}", e); + } } Action::Tick => { @@ -203,24 +387,34 @@ impl App { Action::Resize(w, h) => { tui.resize(Rect::new(0, 0, w, h))?; tui.draw(|f| { - for component in self.components.iter_mut() { + for (idx, component) in self.components.iter_mut().enumerate() { let r = component.draw(f, f.area()); if let Err(e) = r { - action_tx - .send(Action::Error(format!("Failed to draw: {:?}", e))) - .unwrap(); + let _ = action_tx.try_send(Action::Error(format!( + "Failed to render component {} during terminal resize ({}x{}).\n\ + \n\ + Error: {:?}\n\ + \n\ + The application will now exit to prevent further issues.", + idx, w, h, e + ))); } } })?; } Action::Render => { tui.draw(|f| { - for component in self.components.iter_mut() { + for (idx, component) in self.components.iter_mut().enumerate() { let r = component.draw(f, f.area()); if let Err(e) = r { - action_tx - .send(Action::Error(format!("Failed to draw: {:?}", e))) - .unwrap(); + let _ = action_tx.try_send(Action::Error(format!( + "Failed to render component {} during frame update.\n\ + \n\ + Error: {:?}\n\ + \n\ + The application will now exit to prevent further issues.", + idx, e + ))); } } })?; @@ -229,19 +423,68 @@ impl App { } for component in self.components.iter_mut() { if let Some(action) = component.update(action.clone())? { - action_tx.send(action)? + action_tx.try_send(action)? }; } } if self.should_suspend { tui.suspend()?; - action_tx.send(Action::Resume)?; + action_tx.try_send(Action::Resume)?; tui = tui::Tui::new()? .tick_rate(self.tick_rate) .frame_rate(self.frame_rate); // tui.mouse(true); tui.enter()?; } else if self.should_quit { + log::info!("Application shutting down, initiating graceful shutdown sequence"); + + // Send shutdown action to all components + action_tx.try_send(Action::Shutdown)?; + + // Process any pending actions + while let Ok(action) = action_rx.try_recv() { + for component in self.components.iter_mut() { + if let Some(action) = component.update(action.clone())? { + action_tx.try_send(action)?; + } + } + } + + // Shutdown each component with timeout + let shutdown_start = std::time::Instant::now(); + let total_timeout = std::time::Duration::from_secs(5); + + for (idx, component) in self.components.iter_mut().enumerate() { + let elapsed = shutdown_start.elapsed(); + if elapsed >= total_timeout { + log::warn!( + "Shutdown timeout reached, forcing termination for remaining components" + ); + break; + } + + log::debug!("Shutting down component {}", idx); + + // Shutdown with timeout + let shutdown_result = std::panic::catch_unwind( + std::panic::AssertUnwindSafe(|| component.shutdown()) + ); + + match shutdown_result { + Ok(Ok(())) => { + log::debug!("Component {} shutdown successfully", idx); + } + Ok(Err(e)) => { + log::error!("Component {} shutdown failed: {:?}", idx, e); + } + Err(_) => { + log::error!("Component {} panicked during shutdown", idx); + } + } + } + + log::info!("All components shutdown complete"); + tui.stop()?; break; } diff --git a/src/cli.rs b/src/cli.rs index 807f567..69f4b59 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,3 @@ -use std::path::PathBuf; use clap::Parser; diff --git a/src/components.rs b/src/components.rs index 7b1e30f..b40771a 100644 --- a/src/components.rs +++ b/src/components.rs @@ -1,8 +1,79 @@ +//! Component system for modular UI elements. +//! +//! This module defines the [`Component`] trait and exports all component implementations. +//! Components are self-contained UI elements that handle events, update state, and render +//! themselves independently. +//! +//! # Architecture +//! +//! The component system enables a **modular, loosely-coupled architecture**: +//! +//! ```text +//! ┌─────────────────────────────────────────────────────────┐ +//! │ Component Trait │ +//! │ ┌───────────────────────────────────────────────────┐ │ +//! │ │ Lifecycle Methods │ │ +//! │ │ • init() - Initialize with terminal size │ │ +//! │ │ • shutdown() - Cleanup resources │ │ +//! │ └───────────────────────────────────────────────────┘ │ +//! │ ┌───────────────────────────────────────────────────┐ │ +//! │ │ Event Handling │ │ +//! │ │ • handle_events() - Process terminal events │ │ +//! │ │ • handle_key_events() - Handle keyboard │ │ +//! │ │ • handle_mouse_events() - Handle mouse │ │ +//! │ └───────────────────────────────────────────────────┘ │ +//! │ ┌───────────────────────────────────────────────────┐ │ +//! │ │ State Management │ │ +//! │ │ • update() - Process actions, update state │ │ +//! │ └───────────────────────────────────────────────────┘ │ +//! │ ┌───────────────────────────────────────────────────┐ │ +//! │ │ Rendering │ │ +//! │ │ • draw() - Render to terminal frame │ │ +//! │ └───────────────────────────────────────────────────┘ │ +//! └─────────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Component Lifecycle +//! +//! 1. **Creation**: Component is instantiated via `Default` or `new()` +//! 2. **Registration**: Action and config handlers are registered +//! 3. **Initialization**: `init()` called with terminal size +//! 4. **Event Loop**: Component processes events and actions +//! 5. **Shutdown**: `shutdown()` called for cleanup +//! +//! # Available Components +//! +//! - **[`discovery`]**: Network host discovery via ICMP/ARP +//! - **[`ports`]**: Concurrent TCP port scanning +//! - **[`packetdump`]**: Real-time packet capture and analysis +//! - **[`sniff`]**: Network traffic monitoring +//! - **[`wifi_scan`]**: WiFi network scanning +//! - **[`wifi_chart`]**: WiFi signal strength visualization +//! - **[`wifi_interface`]**: WiFi connection information +//! - **[`interfaces`]**: Network interface selection +//! - **[`export`]**: Data export functionality +//! - **[`tabs`]**: Tab navigation UI +//! - **[`title`]**: Application title bar +//! +//! # Component Communication +//! +//! Components communicate exclusively through [`Action`] messages: +//! - Never call other components directly +//! - Send actions via the registered `action_tx` channel +//! - Receive actions via `update()` method +//! - Return new actions to be processed +//! +//! # Type Downcasting +//! +//! The `as_any()` method allows safe downcasting from `Box` to +//! concrete types when needed (e.g., for data export). This is used sparingly +//! to maintain loose coupling. + use color_eyre::eyre::Result; use crossterm::event::{KeyEvent, MouseEvent}; use ratatui::layout::{Rect, Size}; use std::any::Any; -use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; +use tokio::sync::mpsc::Sender; use crate::{ action::Action, @@ -29,19 +100,16 @@ pub mod wifi_scan; pub trait Component: Any { /// Register an action handler that can send actions for processing if necessary. /// # Arguments - /// * `tx` - An unbounded sender that can send actions. + /// * `action_tx` - A bounded sender that can send actions. /// # Returns /// * `Result<()>` - An Ok result or an error. - #[allow(unused_variables)] - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { + fn register_action_handler(&mut self, _action_tx: Sender) -> Result<()> { Ok(()) } - #[allow(unused_variables)] fn as_any(&self) -> &dyn Any; - #[allow(unused_variables)] - fn tab_changed(&mut self, tab: TabsEnum) -> Result<()> { + fn tab_changed(&mut self, _tab: TabsEnum) -> Result<()> { Ok(()) } @@ -50,8 +118,7 @@ pub trait Component: Any { /// * `config` - Configuration settings. /// # Returns /// * `Result<()>` - An Ok result or an error. - #[allow(unused_variables)] - fn register_config_handler(&mut self, config: Config) -> Result<()> { + fn register_config_handler(&mut self, _config: Config) -> Result<()> { Ok(()) } @@ -83,8 +150,7 @@ pub trait Component: Any { /// * `key` - A key event to be processed. /// # Returns /// * `Result>` - An action to be processed or none. - #[allow(unused_variables)] - fn handle_key_events(&mut self, key: KeyEvent) -> Result> { + fn handle_key_events(&mut self, _key: KeyEvent) -> Result> { Ok(None) } @@ -93,8 +159,7 @@ pub trait Component: Any { /// * `mouse` - A mouse event to be processed. /// # Returns /// * `Result>` - An action to be processed or none. - #[allow(unused_variables)] - fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result> { + fn handle_mouse_events(&mut self, _mouse: MouseEvent) -> Result> { Ok(None) } @@ -103,8 +168,7 @@ pub trait Component: Any { /// * `action` - An action that may modify the state of the component. /// # Returns /// * `Result>` - An action to be processed or none. - #[allow(unused_variables)] - fn update(&mut self, action: Action) -> Result> { + fn update(&mut self, _action: Action) -> Result> { Ok(None) } @@ -115,4 +179,13 @@ pub trait Component: Any { /// # Returns /// * `Result<()>` - An Ok result or an error. fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()>; + + /// Gracefully shutdown the component and clean up resources. + /// This is called before the application exits to ensure proper cleanup. + /// Components should stop any running threads, close network connections, etc. + /// # Returns + /// * `Result<()>` - An Ok result or an error. + fn shutdown(&mut self) -> Result<()> { + Ok(()) + } } diff --git a/src/components/discovery.rs b/src/components/discovery.rs index 9fceb25..47fa067 100644 --- a/src/components/discovery.rs +++ b/src/components/discovery.rs @@ -1,29 +1,26 @@ use cidr::Ipv4Cidr; use color_eyre::eyre::Result; -use color_eyre::owo_colors::OwoColorize; -use dns_lookup::{lookup_addr, lookup_host}; -use futures::future::join_all; - -use pnet::datalink::{Channel, NetworkInterface}; -use pnet::packet::{ - arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket}, - ethernet::{EtherTypes, MutableEthernetPacket}, - MutablePacket, Packet, -}; +use ipnetwork::IpNetwork; + +use pnet::datalink::{self, Channel, NetworkInterface}; +use pnet::packet::ethernet::{EtherTypes, MutableEthernetPacket}; +use pnet::packet::icmpv6::{checksum, echo_request, Icmpv6Types}; +use pnet::packet::icmpv6::ndp::{MutableNeighborSolicitPacket, NdpOption, NdpOptionTypes, NeighborAdvertPacket}; +use pnet::packet::ipv6::MutableIpv6Packet; +use pnet::packet::Packet; use pnet::util::MacAddr; use tokio::sync::Semaphore; use core::str; use ratatui::layout::Position; use ratatui::{prelude::*, widgets::*}; -use std::net::{IpAddr, Ipv4Addr}; -use std::string; +use std::net::{IpAddr, Ipv6Addr}; use std::sync::Arc; -use std::time::{Duration, Instant}; -use surge_ping::{Client, Config, IcmpPacket, PingIdentifier, PingSequence, ICMP}; +use std::time::Duration; +use surge_ping::{Client, Config, IcmpPacket, PingIdentifier, PingSequence}; use tokio::{ - sync::mpsc::{self, UnboundedSender}, - task::{self, JoinHandle}, + sync::mpsc::Sender, + task::JoinHandle, }; use super::Component; @@ -31,11 +28,12 @@ use crate::{ action::Action, components::packetdump::ArpPacketData, config::DEFAULT_BORDER_STYLE, + dns_cache::DnsCache, enums::TabsEnum, layout::get_vertical_layout, mode::Mode, tui::Frame, - utils::{count_ipv4_net_length, get_ips4_from_cidr}, + utils::{count_ipv4_net_length, count_ipv6_net_length, get_ips4_from_cidr, get_ips6_from_cidr}, }; use crossterm::event::Event; use crossterm::event::{KeyCode, KeyEvent}; @@ -44,14 +42,18 @@ use rand::random; use tui_input::backend::crossterm::EventHandler; use tui_input::Input; -static POOL_SIZE: usize = 32; -static INPUT_SIZE: usize = 30; -static DEFAULT_IP: &str = "192.168.1.0/24"; +const _DEFAULT_POOL_SIZE: usize = 32; +const MIN_POOL_SIZE: usize = 16; +const MAX_POOL_SIZE: usize = 64; +const PING_TIMEOUT_SECS: u64 = 2; +const INPUT_SIZE: usize = 30; +const DEFAULT_IP: &str = "192.168.1.0/24"; const SPINNER_SYMBOLS: [&str; 6] = ["⠷", "⠯", "⠟", "⠻", "⠽", "⠾"]; #[derive(Clone, Debug, PartialEq)] pub struct ScannedIp { pub ip: String, + pub ip_addr: IpAddr, pub mac: String, pub hostname: String, pub vendor: String, @@ -60,11 +62,11 @@ pub struct ScannedIp { pub struct Discovery { active_tab: TabsEnum, active_interface: Option, - action_tx: Option>, + action_tx: Option>, scanned_ips: Vec, ip_num: i32, input: Input, - cidr: Option, + cidr: Option, cidr_error: bool, is_scanning: bool, mode: Mode, @@ -73,6 +75,7 @@ pub struct Discovery { table_state: TableState, scrollbar_state: ScrollbarState, spinner_index: usize, + dns_cache: DnsCache, } impl Default for Discovery { @@ -99,143 +102,492 @@ impl Discovery { table_state: TableState::default().with_selected(0), scrollbar_state: ScrollbarState::new(0), spinner_index: 0, + dns_cache: DnsCache::new(), } } - pub fn get_scanned_ips(&self) -> &Vec { - &self.scanned_ips + fn get_pool_size() -> usize { + let num_cpus = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + let calculated = num_cpus * 2; + calculated.clamp(MIN_POOL_SIZE, MAX_POOL_SIZE) } - fn set_cidr(&mut self, cidr_str: String, scan: bool) { - match cidr_str.parse::() { - Ok(ip_cidr) => { - self.cidr = Some(ip_cidr); - if scan { - self.scan(); + // Prefers global unicast addresses over link-local for proper routing + fn get_interface_ipv6(interface: &NetworkInterface) -> Option { + let mut link_local = None; + + for ip_network in &interface.ips { + if let IpAddr::V6(ipv6_addr) = ip_network.ip() { + if ipv6_addr.is_loopback() || ipv6_addr.is_multicast() { + continue; + } + + if !Self::is_link_local_ipv6(&ipv6_addr) { + return Some(ipv6_addr); + } + + if link_local.is_none() { + link_local = Some(ipv6_addr); } } - Err(e) => { - if let Some(tx) = &self.action_tx { - tx.clone().send(Action::CidrError).unwrap(); + } + + link_local + } + + fn is_link_local_ipv6(addr: &Ipv6Addr) -> bool { + let segments = addr.segments(); + (segments[0] & 0xffc0) == 0xfe80 + } + + fn is_macos() -> bool { + cfg!(target_os = "macos") + } + + // macOS kernel doesn't deliver ICMPv6 Echo Replies to user-space + async fn ping6_system_command(target_ipv6: Ipv6Addr, timeout_secs: u64) -> bool { + use tokio::process::Command; + use tokio::time::timeout; + use std::time::Duration; + + let mut cmd = Command::new("ping6"); + cmd.arg("-c").arg("1"); + + #[cfg(target_os = "linux")] + { + cmd.arg("-W").arg(timeout_secs.to_string()); + } + + cmd.arg(target_ipv6.to_string()); + + let result = timeout( + Duration::from_secs(timeout_secs + 1), + cmd.output() + ).await; + + match result { + Ok(Ok(output)) => { + if output.status.success() { + log::debug!("ping6 success for {}", target_ipv6); + true + } else { + log::debug!("ping6 no response from {}", target_ipv6); + false } } + Ok(Err(e)) => { + log::debug!("Failed to execute ping6 command: {:?}", e); + false + } + Err(_) => { + log::debug!("ping6 command timed out for {}", target_ipv6); + false + } } } - fn reset_scan(&mut self) { - self.scanned_ips.clear(); - self.ip_num = 0; + async fn send_icmpv6_echo_request( + interface: &NetworkInterface, + source_ipv6: Ipv6Addr, + target_ipv6: Ipv6Addr, + identifier: u16, + sequence: u16, + ) -> Result<(), String> { + let (mut tx, _) = match datalink::channel(interface, Default::default()) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + Ok(_) => return Err("Unknown channel type".to_string()), + Err(e) => return Err(format!("Failed to create datalink channel: {}", e)), + }; + + // Packet structure: [Ethernet Header (14 bytes)] [IPv6 Header (40 bytes)] [ICMPv6 Echo Request (8 bytes + payload)] + const ETHERNET_HEADER_LEN: usize = 14; + const IPV6_HEADER_LEN: usize = 40; + const ICMPV6_HEADER_LEN: usize = 8; + const PAYLOAD_LEN: usize = 56; + const TOTAL_LEN: usize = ETHERNET_HEADER_LEN + IPV6_HEADER_LEN + ICMPV6_HEADER_LEN + PAYLOAD_LEN; + + let mut ethernet_buffer = [0u8; TOTAL_LEN]; + let mut ethernet_packet = MutableEthernetPacket::new(&mut ethernet_buffer) + .ok_or("Failed to create Ethernet packet")?; + + ethernet_packet.set_destination(pnet::util::MacAddr::broadcast()); + ethernet_packet.set_source(interface.mac.unwrap_or(pnet::util::MacAddr::zero())); + ethernet_packet.set_ethertype(EtherTypes::Ipv6); + + let mut ipv6_buffer = [0u8; IPV6_HEADER_LEN + ICMPV6_HEADER_LEN + PAYLOAD_LEN]; + let mut ipv6_packet = MutableIpv6Packet::new(&mut ipv6_buffer) + .ok_or("Failed to create IPv6 packet")?; + + ipv6_packet.set_payload_length((ICMPV6_HEADER_LEN + PAYLOAD_LEN) as u16); + ipv6_packet.set_next_header(pnet::packet::ip::IpNextHeaderProtocols::Icmpv6); + ipv6_packet.set_hop_limit(64); + ipv6_packet.set_source(source_ipv6); + ipv6_packet.set_destination(target_ipv6); + + let mut icmpv6_buffer = [0u8; ICMPV6_HEADER_LEN + PAYLOAD_LEN]; + + use pnet::packet::icmpv6::echo_request::MutableEchoRequestPacket; + let mut echo_request_packet = MutableEchoRequestPacket::new(&mut icmpv6_buffer) + .ok_or("Failed to create Echo Request packet")?; + + echo_request_packet.set_icmpv6_type(Icmpv6Types::EchoRequest); + echo_request_packet.set_icmpv6_code(echo_request::Icmpv6Codes::NoCode); + echo_request_packet.set_identifier(identifier); + echo_request_packet.set_sequence_number(sequence); + + use pnet::packet::icmpv6::Icmpv6Packet; + let icmpv6_for_checksum = Icmpv6Packet::new(echo_request_packet.packet()) + .ok_or("Failed to create Icmpv6Packet for checksum")?; + let checksum_val = checksum(&icmpv6_for_checksum, &source_ipv6, &target_ipv6); + echo_request_packet.set_checksum(checksum_val); + + ipv6_packet.set_payload(echo_request_packet.packet()); + ethernet_packet.set_payload(ipv6_packet.packet()); + + // Yield to tokio scheduler before blocking I/O + tokio::task::yield_now().await; + tx.send_to(ethernet_packet.packet(), None) + .ok_or("Failed to send packet")? + .map_err(|e| format!("Send error: {}", e))?; + + Ok(()) } - fn send_arp(&mut self, target_ip: Ipv4Addr) { - if let Some(active_interface) = &self.active_interface { - if let Some(active_interface_mac) = active_interface.mac { - let ipv4 = active_interface.ips.iter().find(|f| f.is_ipv4()).unwrap(); - let source_ip: Ipv4Addr = ipv4.ip().to_string().parse().unwrap(); - - let (mut sender, _) = - match pnet::datalink::channel(active_interface, Default::default()) { - Ok(Channel::Ethernet(tx, rx)) => (tx, rx), - Ok(_) => { - if let Some(tx_action) = &self.action_tx { - tx_action - .clone() - .send(Action::Error( - "Unknown or unsupported channel type".into(), - )) - .unwrap(); + async fn receive_icmpv6_echo_reply( + interface: &NetworkInterface, + target_ipv6: Ipv6Addr, + identifier: u16, + sequence: u16, + timeout: Duration, + ) -> Option { + let (_, mut rx) = match datalink::channel(interface, Default::default()) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + Ok(_) => return None, + Err(e) => { + log::debug!("Failed to create datalink channel for receiving: {}", e); + return None; + } + }; + + let result = tokio::time::timeout(timeout, async { + loop { + // Yield to tokio scheduler before blocking I/O + tokio::task::yield_now().await; + + match rx.next() { + Ok(packet) => { + use pnet::packet::ethernet::EthernetPacket; + let eth_packet = match EthernetPacket::new(packet) { + Some(eth) => eth, + None => continue, + }; + + if eth_packet.get_ethertype() != EtherTypes::Ipv6 { + continue; + } + + use pnet::packet::ipv6::Ipv6Packet; + let ipv6_packet = match Ipv6Packet::new(eth_packet.payload()) { + Some(ipv6) => ipv6, + None => continue, + }; + + if ipv6_packet.get_source() != target_ipv6 { + continue; + } + + use pnet::packet::ip::IpNextHeaderProtocols; + if ipv6_packet.get_next_header() != IpNextHeaderProtocols::Icmpv6 { + continue; + } + + use pnet::packet::icmpv6::Icmpv6Packet; + let icmpv6_packet = match Icmpv6Packet::new(ipv6_packet.payload()) { + Some(icmpv6) => icmpv6, + None => continue, + }; + + if icmpv6_packet.get_icmpv6_type() != Icmpv6Types::EchoReply { + continue; + } + + use pnet::packet::icmpv6::echo_reply::EchoReplyPacket; + let echo_reply = match EchoReplyPacket::new(icmpv6_packet.packet()) { + Some(reply) => reply, + None => continue, + }; + + let reply_identifier = echo_reply.get_identifier(); + let reply_sequence = echo_reply.get_sequence_number(); + + if reply_identifier == identifier && reply_sequence == sequence { + return Some(ipv6_packet.get_source()); + } + } + Err(e) => { + log::debug!("Error receiving packet: {}", e); + continue; + } + } + } + }) + .await; + + result.ok().flatten() + } + + // RFC 4861 compliant Neighbor Discovery Protocol + async fn send_neighbor_solicitation( + interface: &NetworkInterface, + source_ipv6: Ipv6Addr, + target_ipv6: Ipv6Addr, + ) -> Result<(), String> { + let source_mac = interface.mac.ok_or("Interface has no MAC address".to_string())?; + + let target_segments = target_ipv6.segments(); + let solicited_node = Ipv6Addr::new( + 0xff02, 0, 0, 0, 0, 1, + 0xff00 | (target_segments[6] & 0x00ff), + target_segments[7], + ); + + let multicast_mac = MacAddr::new( + 0x33, 0x33, + ((solicited_node.segments()[6] >> 8) & 0xff) as u8, + (solicited_node.segments()[6] & 0xff) as u8, + ((solicited_node.segments()[7] >> 8) & 0xff) as u8, + (solicited_node.segments()[7] & 0xff) as u8, + ); + + let mut ethernet_buffer = vec![0u8; 86]; + let mut ethernet_packet = MutableEthernetPacket::new(&mut ethernet_buffer) + .ok_or("Failed to create Ethernet packet".to_string())?; + + ethernet_packet.set_destination(multicast_mac); + ethernet_packet.set_source(source_mac); + ethernet_packet.set_ethertype(EtherTypes::Ipv6); + + let mut ipv6_buffer = vec![0u8; 72]; + let mut ipv6_packet = MutableIpv6Packet::new(&mut ipv6_buffer) + .ok_or("Failed to create IPv6 packet".to_string())?; + + ipv6_packet.set_version(6); + ipv6_packet.set_traffic_class(0); + ipv6_packet.set_flow_label(0); + ipv6_packet.set_payload_length(32); + ipv6_packet.set_next_header(pnet::packet::ip::IpNextHeaderProtocols::Icmpv6); + ipv6_packet.set_hop_limit(255); + ipv6_packet.set_source(source_ipv6); + ipv6_packet.set_destination(solicited_node); + + let mut icmpv6_buffer = vec![0u8; 32]; + let mut ns_packet = MutableNeighborSolicitPacket::new(&mut icmpv6_buffer) + .ok_or("Failed to create Neighbor Solicit packet".to_string())?; + + ns_packet.set_icmpv6_type(Icmpv6Types::NeighborSolicit); + ns_packet.set_icmpv6_code(pnet::packet::icmpv6::Icmpv6Code(0)); + ns_packet.set_reserved(0); + ns_packet.set_target_addr(target_ipv6); + + let ndp_option = NdpOption { + option_type: NdpOptionTypes::SourceLLAddr, + length: 1, + data: source_mac.octets().to_vec(), + }; + ns_packet.set_options(&[ndp_option]); + + let checksum = pnet::packet::icmpv6::checksum( + &pnet::packet::icmpv6::Icmpv6Packet::new(ns_packet.packet()) + .ok_or("Failed to create ICMPv6 packet for checksum".to_string())?, + &source_ipv6, + &solicited_node, + ); + ns_packet.set_checksum(checksum); + + ipv6_packet.set_payload(ns_packet.packet()); + ethernet_packet.set_payload(ipv6_packet.packet()); + + let (mut tx, _) = match datalink::channel(interface, Default::default()) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + Ok(_) => return Err("Unsupported channel type".to_string()), + Err(e) => return Err(format!("Failed to create datalink channel: {:?}", e)), + }; + + tx.send_to(ethernet_packet.packet(), None) + .ok_or("Failed to send packet".to_string())? + .map_err(|e| format!("Failed to send NDP packet: {:?}", e))?; + + log::debug!("Sent Neighbor Solicitation for {} from {}", target_ipv6, source_ipv6); + Ok(()) + } + + async fn receive_neighbor_advertisement( + interface: &NetworkInterface, + target_ipv6: Ipv6Addr, + timeout: Duration, + ) -> Option<(Ipv6Addr, MacAddr)> { + use pnet::packet::ethernet::EthernetPacket; + use pnet::packet::ipv6::Ipv6Packet; + use tokio::time::{timeout as tokio_timeout, sleep}; + + let (_tx, mut rx) = match datalink::channel(interface, Default::default()) { + Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + Ok(_) => { + log::debug!("Unsupported channel type for NDP receive"); + return None; + } + Err(e) => { + log::debug!("Failed to open datalink channel for NDP: {:?}", e); + return None; + } + }; + + let result = tokio_timeout(timeout, async { + loop { + tokio::task::yield_now().await; + match rx.next() { + Ok(packet) => { + if let Some(eth_packet) = EthernetPacket::new(packet) { + if eth_packet.get_ethertype() != EtherTypes::Ipv6 { + continue; + } + + if let Some(ipv6_packet) = Ipv6Packet::new(eth_packet.payload()) { + if ipv6_packet.get_next_header() != pnet::packet::ip::IpNextHeaderProtocols::Icmpv6 { + continue; + } + + if ipv6_packet.get_source() != target_ipv6 { + continue; + } + + if let Some(na_packet) = NeighborAdvertPacket::new(ipv6_packet.payload()) { + if na_packet.get_icmpv6_type() != Icmpv6Types::NeighborAdvert { + continue; + } + + for option in na_packet.get_options() { + if option.option_type == NdpOptionTypes::TargetLLAddr + && option.length == 1 + && option.data.len() >= 6 { + let mac = MacAddr::new( + option.data[0], + option.data[1], + option.data[2], + option.data[3], + option.data[4], + option.data[5], + ); + log::debug!("Received Neighbor Advertisement from {} with MAC {}", target_ipv6, mac); + return Some((target_ipv6, mac)); + } + } + } } - return; } - Err(e) => { - if let Some(tx_action) = &self.action_tx { - tx_action - .clone() - .send(Action::Error(format!( - "Unable to create datalink channel: {e}" - ))) - .unwrap(); + } + Err(e) => { + log::debug!("Error receiving packet for NDP: {:?}", e); + sleep(Duration::from_millis(10)).await; + } + } + } + }).await; + + match result { + Ok(Some(result)) => Some(result), + Ok(None) => None, + Err(_) => { + log::debug!("Timeout waiting for Neighbor Advertisement from {}", target_ipv6); + None + } + } + } + + pub fn get_scanned_ips(&self) -> &Vec { + &self.scanned_ips + } + + fn set_cidr(&mut self, cidr_str: String, scan: bool) { + let trimmed = cidr_str.trim(); + if trimmed.is_empty() { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } + return; + } + + if !trimmed.contains('/') { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } + return; + } + + match trimmed.parse::() { + Ok(ip_network) => { + match ip_network { + IpNetwork::V4(ipv4_net) => { + let network_length = ipv4_net.prefix(); + + if network_length < 16 { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); } return; } - }; - - let mut ethernet_buffer = [0u8; 42]; - let mut ethernet_packet = MutableEthernetPacket::new(&mut ethernet_buffer).unwrap(); - ethernet_packet.set_destination(MacAddr::broadcast()); - ethernet_packet.set_source(active_interface_mac); - ethernet_packet.set_ethertype(EtherTypes::Arp); + let first_octet = ipv4_net.network().octets()[0]; - let mut arp_buffer = [0u8; 28]; - let mut arp_packet = MutableArpPacket::new(&mut arp_buffer).unwrap(); + if first_octet == 127 || first_octet >= 224 { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } + return; + } + } + IpNetwork::V6(ipv6_net) => { + let network_length = ipv6_net.prefix(); - arp_packet.set_hardware_type(ArpHardwareTypes::Ethernet); - arp_packet.set_protocol_type(EtherTypes::Ipv4); - arp_packet.set_hw_addr_len(6); - arp_packet.set_proto_addr_len(4); - arp_packet.set_operation(ArpOperations::Request); - arp_packet.set_sender_hw_addr(active_interface_mac); - arp_packet.set_sender_proto_addr(source_ip); - arp_packet.set_target_hw_addr(MacAddr::zero()); - arp_packet.set_target_proto_addr(target_ip); + if network_length < 120 { + log::warn!("IPv6 network /{} is too large for scanning, minimum is /120", network_length); + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } + return; + } - ethernet_packet.set_payload(arp_packet.packet_mut()); + if ipv6_net.network().is_multicast() + || ipv6_net.network().is_loopback() + || ipv6_net.network().is_unspecified() { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } + return; + } + } + } - sender - .send_to(ethernet_packet.packet(), None) - .unwrap() - .unwrap(); + self.cidr = Some(ip_network); + if scan { + self.scan(); + } + } + Err(_) => { + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::CidrError); + } } } } - // fn scan(&mut self) { - // self.reset_scan(); - - // if let Some(cidr) = self.cidr { - // self.is_scanning = true; - // let tx = self.action_tx.as_ref().unwrap().clone(); - // self.task = tokio::spawn(async move { - // let ips = get_ips4_from_cidr(cidr); - // let chunks: Vec<_> = ips.chunks(POOL_SIZE).collect(); - // for chunk in chunks { - // let tasks: Vec<_> = chunk - // .iter() - // .map(|&ip| { - // let tx = tx.clone(); - // let closure = || async move { - // let client = - // Client::new(&Config::default()).expect("Cannot create client"); - // let payload = [0; 56]; - // let mut pinger = client - // .pinger(IpAddr::V4(ip), PingIdentifier(random())) - // .await; - // pinger.timeout(Duration::from_secs(2)); - - // match pinger.ping(PingSequence(2), &payload).await { - // Ok((IcmpPacket::V4(packet), dur)) => { - // tx.send(Action::PingIp(packet.get_real_dest().to_string())) - // .unwrap_or_default(); - // tx.send(Action::CountIp).unwrap_or_default(); - // } - // Ok(_) => { - // tx.send(Action::CountIp).unwrap_or_default(); - // } - // Err(_) => { - // tx.send(Action::CountIp).unwrap_or_default(); - // } - // } - // }; - // task::spawn(closure()) - // }) - // .collect(); - - // let _ = join_all(tasks).await; - // } - // }); - // }; - // } + fn reset_scan(&mut self) { + self.scanned_ips.clear(); + self.ip_num = 0; + } fn scan(&mut self) { self.reset_scan(); @@ -243,47 +595,214 @@ impl Discovery { if let Some(cidr) = self.cidr { self.is_scanning = true; - let tx = self.action_tx.clone().unwrap(); - let semaphore = Arc::new(Semaphore::new(POOL_SIZE)); + let Some(tx) = self.action_tx.clone() else { + self.is_scanning = false; + return; + }; + + let interface = self.active_interface.clone(); + let pool_size = Self::get_pool_size(); + log::debug!("Using pool size of {} for discovery scan", pool_size); + let semaphore = Arc::new(Semaphore::new(pool_size)); self.task = tokio::spawn(async move { - let ips = get_ips4_from_cidr(cidr); - let tasks: Vec<_> = ips - .iter() - .map(|&ip| { - let s = semaphore.clone(); - let tx = tx.clone(); - let c = || async move { - let _permit = s.acquire().await.unwrap(); - let client = - Client::new(&Config::default()).expect("Cannot create client"); - let payload = [0; 56]; - let mut pinger = client - .pinger(IpAddr::V4(ip), PingIdentifier(random())) - .await; - pinger.timeout(Duration::from_secs(2)); - - match pinger.ping(PingSequence(2), &payload).await { - Ok((IcmpPacket::V4(packet), dur)) => { - tx.send(Action::PingIp(packet.get_real_dest().to_string())) - .unwrap_or_default(); - tx.send(Action::CountIp).unwrap_or_default(); + log::debug!("Starting CIDR scan task for {:?}", cidr); + + match cidr { + IpNetwork::V4(ipv4_cidr) => { + let cidr_str = format!("{}/{}", ipv4_cidr.network(), ipv4_cidr.prefix()); + let Ok(ipv4_cidr_old) = cidr_str.parse::() else { + log::error!("Failed to convert IPv4 CIDR for scanning"); + let _ = tx.try_send(Action::CidrError); + return; + }; + + let ips = get_ips4_from_cidr(ipv4_cidr_old); + let tasks: Vec<_> = ips + .iter() + .map(|&ip| { + let s = semaphore.clone(); + let tx = tx.clone(); + let c = || async move { + let Ok(_permit) = s.acquire().await else { + let _ = tx.try_send(Action::CountIp); + return; + }; + let client = match Client::new(&Config::default()) { + Ok(c) => c, + Err(e) => { + log::error!("Failed to create ICMP client: {:?}", e); + let _ = tx.try_send(Action::CountIp); + return; + } + }; + let payload = [0; 56]; + let mut pinger = client + .pinger(IpAddr::V4(ip), PingIdentifier(random())) + .await; + pinger.timeout(Duration::from_secs(PING_TIMEOUT_SECS)); + + match pinger.ping(PingSequence(2), &payload).await { + Ok((IcmpPacket::V4(_packet), _dur)) => { + tx.try_send(Action::PingIp(_packet.get_real_dest().to_string())) + .unwrap_or_default(); + tx.try_send(Action::CountIp).unwrap_or_default(); + } + Ok(_) => { + tx.try_send(Action::CountIp).unwrap_or_default(); + } + Err(_) => { + tx.try_send(Action::CountIp).unwrap_or_default(); + } + } + }; + tokio::spawn(c()) + }) + .collect(); + for t in tasks { + match t.await { + Ok(_) => {} + Err(e) if e.is_cancelled() => { + log::debug!("Discovery scan task was cancelled for IPv4 CIDR range"); } - Ok(_) => { - tx.send(Action::CountIp).unwrap_or_default(); + Err(e) if e.is_panic() => { + log::error!( + "Discovery scan task panicked while scanning IPv4 CIDR range: {:?}", + e + ); } - Err(_) => { - tx.send(Action::CountIp).unwrap_or_default(); + Err(e) => { + log::error!( + "Discovery scan task failed while scanning IPv4 CIDR range: {:?}", + e + ); } } - }; - tokio::spawn(c()) - }) - .collect(); - for t in tasks { - let _ = t.await; + } + } + IpNetwork::V6(ipv6_cidr) => { + let ips = get_ips6_from_cidr(ipv6_cidr); + log::debug!("Scanning {} IPv6 addresses", ips.len()); + + let tasks: Vec<_> = ips + .iter() + .map(|&ip| { + let s = semaphore.clone(); + let tx = tx.clone(); + let interface_clone = interface.clone(); + let c = || async move { + let Ok(_permit) = s.acquire().await else { + let _ = tx.try_send(Action::CountIp); + return; + }; + + // macOS kernel doesn't deliver ICMPv6 Echo Replies to user-space + let ping_success = if Self::is_macos() { + log::debug!("Using system ping6 for {} (macOS)", ip); + Self::ping6_system_command(ip, PING_TIMEOUT_SECS).await + } else { + log::debug!("Using manual ICMPv6 for {} (non-macOS)", ip); + + if let Some(source_ipv6) = interface_clone.as_ref().and_then(Self::get_interface_ipv6) { + let identifier = random::(); + let sequence = 1u16; + + match Self::send_icmpv6_echo_request( + interface_clone.as_ref().unwrap(), + source_ipv6, + ip, + identifier, + sequence + ).await { + Ok(()) => { + if let Some(target_ipv6) = Self::receive_icmpv6_echo_reply( + interface_clone.as_ref().unwrap(), + ip, + identifier, + sequence, + Duration::from_secs(PING_TIMEOUT_SECS) + ).await { + log::debug!("ICMPv6 Echo Reply received from {}", target_ipv6); + true + } else { + log::debug!("No ICMPv6 Echo Reply from {}", ip); + false + } + } + Err(e) => { + log::debug!("Failed to send ICMPv6 Echo Request to {}: {}", ip, e); + false + } + } + } else { + log::debug!("No IPv6 address on interface for pinging {}", ip); + false + } + }; + + if ping_success { + tx.try_send(Action::PingIp(ip.to_string())) + .unwrap_or_default(); + + if let Some(ref interface_ref) = interface_clone { + if let Some(source_ipv6) = Self::get_interface_ipv6(interface_ref) { + log::debug!("Attempting NDP for {} from {}", ip, source_ipv6); + + match Self::send_neighbor_solicitation(interface_ref, source_ipv6, ip).await { + Ok(()) => { + if let Some((_ipv6, mac)) = Self::receive_neighbor_advertisement( + interface_ref, + ip, + Duration::from_secs(2) + ).await { + log::debug!("NDP discovered MAC {} for {}", mac, ip); + let _ = tx.try_send(Action::UpdateMac( + ip.to_string(), + mac.to_string() + )); + } else { + log::debug!("No NDP response for {}", ip); + } + } + Err(e) => { + log::debug!("NDP failed for {}: {:?}", ip, e); + } + } + } else { + log::debug!("No IPv6 address found on interface for NDP"); + } + } + } + + tx.try_send(Action::CountIp).unwrap_or_default(); + }; + tokio::spawn(c()) + }) + .collect(); + for t in tasks { + match t.await { + Ok(_) => {} + Err(e) if e.is_cancelled() => { + log::debug!("Discovery scan task was cancelled for IPv6 CIDR range"); + } + Err(e) if e.is_panic() => { + log::error!( + "Discovery scan task panicked while scanning IPv6 CIDR range: {:?}", + e + ); + } + Err(e) => { + log::error!( + "Discovery scan task failed while scanning IPv6 CIDR range: {:?}", + e + ); + } + } + } + } } - // let _ = join_all(tasks).await; + + log::debug!("CIDR scan task completed"); }); }; } @@ -307,43 +826,73 @@ impl Discovery { } fn process_ip(&mut self, ip: &str) { - let tx = self.action_tx.as_ref().unwrap(); - let ipv4: Ipv4Addr = ip.parse().unwrap(); - // self.send_arp(ipv4); + let Ok(hip) = ip.parse::() else { + return; + }; if let Some(n) = self.scanned_ips.iter_mut().find(|item| item.ip == ip) { - let hip: IpAddr = ip.parse().unwrap(); - let host = lookup_addr(&hip).unwrap_or_default(); - n.hostname = host; n.ip = ip.to_string(); + n.ip_addr = hip; } else { - let hip: IpAddr = ip.parse().unwrap(); - let host = lookup_addr(&hip).unwrap_or_default(); - self.scanned_ips.push(ScannedIp { + let new_ip = ScannedIp { ip: ip.to_string(), + ip_addr: hip, mac: String::new(), - hostname: host, + hostname: String::new(), vendor: String::new(), - }); + }; - self.scanned_ips.sort_by(|a, b| { - let a_ip: Ipv4Addr = a.ip.parse::().unwrap(); - let b_ip: Ipv4Addr = b.ip.parse::().unwrap(); - a_ip.partial_cmp(&b_ip).unwrap() - }); + let insert_pos = self.scanned_ips + .binary_search_by(|probe| { + match (probe.ip_addr, hip) { + (IpAddr::V4(a), IpAddr::V4(b)) => a.cmp(&b), + (IpAddr::V6(a), IpAddr::V6(b)) => a.cmp(&b), + (IpAddr::V4(_), IpAddr::V6(_)) => std::cmp::Ordering::Less, + (IpAddr::V6(_), IpAddr::V4(_)) => std::cmp::Ordering::Greater, + } + }) + .unwrap_or_else(|pos| pos); + self.scanned_ips.insert(insert_pos, new_ip); } self.set_scrollbar_height(); + + if let Some(tx) = self.action_tx.clone() { + let dns_cache = self.dns_cache.clone(); + let ip_string = ip.to_string(); + tokio::spawn(async move { + let hostname = dns_cache.lookup_with_timeout(hip).await; + if !hostname.is_empty() { + let _ = tx.try_send(Action::DnsResolved(ip_string, hostname)); + } + }); + } } - fn set_active_subnet(&mut self, intf: &NetworkInterface) { - let a_ip = intf.ips[0].ip().to_string(); - let ip: Vec<&str> = a_ip.split('.').collect(); - if ip.len() > 1 { - let new_a_ip = format!("{}.{}.{}.0/24", ip[0], ip[1], ip[2]); - self.input = Input::default().with_value(new_a_ip); + fn set_active_subnet(&mut self, interface: &NetworkInterface) { + let a_ip = interface.ips[0].ip(); - self.set_cidr(self.input.value().to_string(), false); + match a_ip { + IpAddr::V4(ipv4) => { + let octets = ipv4.octets(); + let new_a_ip = format!("{}.{}.{}.0/24", octets[0], octets[1], octets[2]); + self.input = Input::default().with_value(new_a_ip); + self.set_cidr(self.input.value().to_string(), false); + } + IpAddr::V6(ipv6) => { + let segments = ipv6.segments(); + if ipv6.segments()[0] & 0xffc0 == 0xfe80 { + let new_a_ip = format!("fe80::{:x}:{:x}:{:x}:0/120", + segments[4], segments[5], segments[6]); + self.input = Input::default().with_value(new_a_ip); + } else { + let new_a_ip = format!("{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:{:x}:0/120", + segments[0], segments[1], segments[2], segments[3], + segments[4], segments[5], segments[6]); + self.input = Input::default().with_value(new_a_ip); + } + self.set_cidr(self.input.value().to_string(), false); + } } } @@ -395,17 +944,18 @@ impl Discovery { fn make_table( scanned_ips: &Vec, - cidr: Option, + cidr: Option, ip_num: i32, is_scanning: bool, - ) -> Table { + ) -> Table<'_> { let header = Row::new(vec!["ip", "mac", "hostname", "vendor"]) .style(Style::default().fg(Color::Yellow)) .top_margin(1) .bottom_margin(1); let mut rows = Vec::new(); let cidr_length = match cidr { - Some(c) => count_ipv4_net_length(c.network_length() as u32), + Some(IpNetwork::V4(c)) => count_ipv4_net_length(c.prefix() as u32) as u64, + Some(IpNetwork::V6(c)) => count_ipv6_net_length(c.prefix() as u32), None => 0, }; @@ -441,7 +991,7 @@ impl Discovery { let table = Table::new( rows, [ - Constraint::Length(16), + Constraint::Length(40), Constraint::Length(19), Constraint::Fill(1), Constraint::Fill(1), @@ -501,7 +1051,7 @@ impl Discovery { scrollbar } - fn make_input(&self, scroll: usize) -> Paragraph { + fn make_input(&self, scroll: usize) -> Paragraph<'_> { let input = Paragraph::new(self.input.value()) .style(Style::default().fg(Color::Green)) .scroll((0, scroll as u16)) @@ -548,7 +1098,7 @@ impl Discovery { input } - fn make_error(&mut self) -> Paragraph { + fn make_error(&mut self) -> Paragraph<'_> { let error = Paragraph::new("CIDR parse error") .style(Style::default().fg(Color::Red)) .block( @@ -560,7 +1110,7 @@ impl Discovery { error } - fn make_spinner(&self) -> Span { + fn make_spinner(&self) -> Span<'_> { let spinner = SPINNER_SYMBOLS[self.spinner_index]; Span::styled( format!("{spinner}scanning.."), @@ -570,11 +1120,10 @@ impl Discovery { } impl Component for Discovery { - fn init(&mut self, area: Size) -> Result<()> { + fn init(&mut self, _area: Size) -> Result<()> { if self.cidr.is_none() { self.set_cidr(String::from(DEFAULT_IP), false); } - // -- init oui match Oui::default() { Ok(s) => self.oui = Some(s), Err(_) => self.oui = None, @@ -586,8 +1135,8 @@ impl Component for Discovery { self } - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } @@ -597,7 +1146,7 @@ impl Component for Discovery { Mode::Normal => return Ok(None), Mode::Input => match key.code { KeyCode::Enter => { - if let Some(sender) = &self.action_tx { + if let Some(_sender) = &self.action_tx { self.set_cidr(self.input.value().to_string(), true); } Action::ModeChange(Mode::Normal) @@ -615,24 +1164,50 @@ impl Component for Discovery { } fn update(&mut self, action: Action) -> Result> { + if self.is_scanning && self.task.is_finished() { + log::warn!("Scan task finished unexpectedly, checking for errors"); + self.is_scanning = false; + } + if self.is_scanning { if let Action::Tick = action { let mut s_index = self.spinner_index + 1; - s_index %= SPINNER_SYMBOLS.len() - 1; + s_index %= SPINNER_SYMBOLS.len(); self.spinner_index = s_index; } } - // -- custom actions if let Action::PingIp(ref ip) = action { self.process_ip(ip); } - // -- count IPs + if let Action::DnsResolved(ref ip, ref hostname) = action { + if let Some(entry) = self.scanned_ips.iter_mut().find(|item| item.ip == *ip) { + entry.hostname = hostname.clone(); + } + } + if let Action::UpdateMac(ref ip, ref mac) = action { + if let Some(entry) = self.scanned_ips.iter_mut().find(|item| item.ip == *ip) { + entry.mac = mac.clone(); + if let Some(oui) = &self.oui { + if let Ok(Some(oui_res)) = oui.lookup_by_mac(mac) { + entry.vendor = oui_res.company_name.clone(); + } + } + } + } if let Action::CountIp = action { self.ip_num += 1; let ip_count = match self.cidr { - Some(cidr) => count_ipv4_net_length(cidr.network_length() as u32) as i32, + Some(IpNetwork::V4(cidr)) => count_ipv4_net_length(cidr.prefix() as u32) as i32, + Some(IpNetwork::V6(cidr)) => { + let count = count_ipv6_net_length(cidr.prefix() as u32); + if count > i32::MAX as u64 { + i32::MAX + } else { + count as i32 + } + } None => 0, }; @@ -640,15 +1215,12 @@ impl Component for Discovery { self.is_scanning = false; } } - // -- CIDR error if let Action::CidrError = action { self.cidr_error = true; } - // -- ARP packet recieved if let Action::ArpRecieve(ref arp_data) = action { self.process_mac(arp_data.clone()); } - // -- Scan CIDR if let Action::ScanCidr = action { if self.active_interface.is_some() && !self.is_scanning @@ -657,18 +1229,14 @@ impl Component for Discovery { self.scan(); } } - // -- active interface if let Action::ActiveInterface(ref interface) = action { - let intf = interface.clone(); - // -- first time scan after setting of interface if self.active_interface.is_none() { - self.set_active_subnet(&intf); + self.set_active_subnet(interface); } - self.active_interface = Some(intf); + self.active_interface = Some(interface.clone()); } if self.active_tab == TabsEnum::Discovery { - // -- prev & next select item in table if let Action::Down = action { self.next_in_table(); } @@ -676,34 +1244,26 @@ impl Component for Discovery { self.previous_in_table(); } - // -- MODE CHANGE if let Action::ModeChange(mode) = action { - // -- when scanning don't switch to input mode if self.is_scanning && mode == Mode::Input { - self.action_tx - .clone() - .unwrap() - .send(Action::ModeChange(Mode::Normal)) - .unwrap(); + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::ModeChange(Mode::Normal)); + } return Ok(None); } if mode == Mode::Input { - // self.input.reset(); self.cidr_error = false; } - self.action_tx - .clone() - .unwrap() - .send(Action::AppModeChange(mode)) - .unwrap(); + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::AppModeChange(mode)); + } self.mode = mode; } } - // -- tab change if let Action::TabChange(tab) = action { - self.tab_changed(tab).unwrap(); + let _ = self.tab_changed(tab); } Ok(None) @@ -714,11 +1274,18 @@ impl Component for Discovery { Ok(()) } + fn shutdown(&mut self) -> Result<()> { + log::info!("Shutting down discovery component"); + self.is_scanning = false; + self.task.abort(); + log::info!("Discovery component shutdown complete"); + Ok(()) + } + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { if self.active_tab == TabsEnum::Discovery { let layout = get_vertical_layout(area); - // -- TABLE let mut table_rect = layout.bottom; table_rect.y += 1; table_rect.height -= 1; @@ -727,7 +1294,6 @@ impl Component for Discovery { Self::make_table(&self.scanned_ips, self.cidr, self.ip_num, self.is_scanning); f.render_stateful_widget(table, table_rect, &mut self.table_state); - // -- SCROLLBAR let scrollbar = Self::make_scrollbar(); let mut scroll_rect = table_rect; scroll_rect.y += 3; @@ -741,14 +1307,12 @@ impl Component for Discovery { &mut self.scrollbar_state, ); - // -- ERROR if self.cidr_error { let error_rect = Rect::new(table_rect.width - (19 + 41), table_rect.y + 1, 18, 3); let block = self.make_error(); f.render_widget(block, error_rect); } - // -- INPUT let input_size: u16 = INPUT_SIZE as u16; let input_rect = Rect::new( table_rect.width - (input_size + 1), @@ -757,7 +1321,6 @@ impl Component for Discovery { 3, ); - // -- INPUT_SIZE - 3 is offset for border + 1char for cursor let scroll = self.input.visual_scroll(INPUT_SIZE - 3); let mut block = self.make_input(scroll); if self.is_scanning { @@ -765,7 +1328,6 @@ impl Component for Discovery { } f.render_widget(block, input_rect); - // -- cursor match self.mode { Mode::Input => { f.set_cursor_position(Position { @@ -778,7 +1340,6 @@ impl Component for Discovery { Mode::Normal => {} } - // -- THROBBER if self.is_scanning { let throbber = self.make_spinner(); let throbber_rect = Rect::new(input_rect.x + 1, input_rect.y, 12, 1); diff --git a/src/components/export.rs b/src/components/export.rs index f3a43c7..ad21ee2 100644 --- a/src/components/export.rs +++ b/src/components/export.rs @@ -1,20 +1,20 @@ use chrono::{DateTime, Local}; -use color_eyre::{eyre::Result, owo_colors::OwoColorize}; -use crossterm::style::Stylize; +use color_eyre::eyre::Result; use csv::Writer; -use ratatui::{prelude::*, widgets::*}; +use ratatui::prelude::*; use std::env; -use tokio::sync::mpsc::UnboundedSender; +use std::sync::Arc; +use tokio::sync::mpsc::Sender; use super::{discovery::ScannedIp, ports::ScannedIpPorts, Component, Frame}; use crate::{action::Action, enums::PacketsInfoTypesEnum}; #[derive(Default)] pub struct Export { - action_tx: Option>, + action_tx: Option>, home_dir: String, export_done: bool, - export_failed: bool, + _export_failed: bool, } impl Export { @@ -23,7 +23,7 @@ impl Export { action_tx: None, home_dir: String::new(), export_done: false, - export_failed: false, + _export_failed: false, } } @@ -31,18 +31,22 @@ impl Export { fn get_user_home_dir(&mut self) { let mut home_dir = String::from("/root"); if let Some(h_dir) = env::var_os("HOME") { - home_dir = String::from(h_dir.to_str().unwrap()); + if let Some(dir_str) = h_dir.to_str() { + home_dir = String::from(dir_str); + } } if let Some(sudo_user) = env::var_os("SUDO_USER") { - home_dir = format!("/home/{}", sudo_user.to_str().unwrap()); + if let Some(user_str) = sudo_user.to_str() { + home_dir = format!("/home/{}", user_str); + } } self.home_dir = format!("{}/.netscanner", home_dir); // -- create dot folder - if std::fs::metadata(self.home_dir.clone()).is_err() - && std::fs::create_dir_all(self.home_dir.clone()).is_err() + if std::fs::metadata(&self.home_dir).is_err() + && std::fs::create_dir_all(&self.home_dir).is_err() { - self.export_failed = true; + self._export_failed = true; } } @@ -50,18 +54,22 @@ impl Export { fn get_user_home_dir(&mut self) { let mut home_dir = String::from("/root"); if let Some(h_dir) = env::var_os("HOME") { - home_dir = String::from(h_dir.to_str().unwrap()); + if let Some(dir_str) = h_dir.to_str() { + home_dir = String::from(dir_str); + } } if let Some(sudo_user) = env::var_os("SUDO_USER") { - home_dir = format!("/Users/{}", sudo_user.to_str().unwrap()); + if let Some(user_str) = sudo_user.to_str() { + home_dir = format!("/Users/{}", user_str); + } } self.home_dir = format!("{}/.netscanner", home_dir); // -- create dot folder - if std::fs::metadata(self.home_dir.clone()).is_err() { - if std::fs::create_dir_all(self.home_dir.clone()).is_err() { - println!("Failed to create export dir"); - } + if std::fs::metadata(&self.home_dir).is_err() + && std::fs::create_dir_all(&self.home_dir).is_err() + { + log::error!("Failed to create export directory: {}", self.home_dir); } } @@ -69,49 +77,53 @@ impl Export { fn get_user_home_dir(&mut self) { let mut home_dir = String::from("C:\\Users\\Administrator"); if let Some(h_dir) = env::var_os("USERPROFILE") { - home_dir = String::from(h_dir.to_str().unwrap()); + if let Some(dir_str) = h_dir.to_str() { + home_dir = String::from(dir_str); + } } if let Some(sudo_user) = env::var_os("SUDO_USER") { - home_dir = format!("C:\\Users\\{}", sudo_user.to_str().unwrap()); + if let Some(user_str) = sudo_user.to_str() { + home_dir = format!("C:\\Users\\{}", user_str); + } } self.home_dir = format!("{}\\.netscanner", home_dir); // -- create .netscanner folder if it doesn't exist - if std::fs::metadata(self.home_dir.clone()).is_err() { - if std::fs::create_dir_all(self.home_dir.clone()).is_err() { - self.export_failed = true; + if std::fs::metadata(&self.home_dir).is_err() { + if std::fs::create_dir_all(&self.home_dir).is_err() { + self._export_failed = true; } } } - pub fn write_discovery(&mut self, data: Vec, timestamp: &String) -> Result<()> { + pub fn write_discovery(&mut self, data: Arc>, timestamp: &String) -> Result<()> { let mut w = Writer::from_path(format!("{}/scanned_ips.{}.csv", self.home_dir, timestamp))?; // -- header w.write_record(["ip", "mac", "hostname", "vendor"])?; - for s_ip in data { - w.write_record([s_ip.ip, s_ip.mac, s_ip.hostname, s_ip.vendor])?; + for s_ip in data.iter() { + w.write_record([&s_ip.ip, &s_ip.mac, &s_ip.hostname, &s_ip.vendor])?; } w.flush()?; Ok(()) } - pub fn write_ports(&mut self, data: Vec, timestamp: &String) -> Result<()> { + pub fn write_ports(&mut self, data: Arc>, timestamp: &String) -> Result<()> { let mut w = Writer::from_path(format!("{}/scanned_ports.{}.csv", self.home_dir, timestamp))?; // -- header w.write_record(["ip", "ports"])?; - for s_ip in data { + for s_ip in data.iter() { let ports: String = s_ip .ports .iter() .map(|n| n.to_string()) .collect::>() .join(":"); - w.write_record([s_ip.ip, ports])?; + w.write_record([&s_ip.ip, &ports])?; } w.flush()?; @@ -120,7 +132,7 @@ impl Export { pub fn write_packets( &mut self, - data: Vec<(DateTime, PacketsInfoTypesEnum)>, + data: Arc, PacketsInfoTypesEnum)>>, timestamp: &String, name: &str, ) -> Result<()> { @@ -131,13 +143,13 @@ impl Export { // -- header w.write_record(["time", "log"])?; - for (t, p) in data { + for (t, p) in data.iter() { let log_str = match p { - PacketsInfoTypesEnum::Icmp(log) => log.raw_str, - PacketsInfoTypesEnum::Arp(log) => log.raw_str, - PacketsInfoTypesEnum::Icmp6(log) => log.raw_str, - PacketsInfoTypesEnum::Udp(log) => log.raw_str, - PacketsInfoTypesEnum::Tcp(log) => log.raw_str, + PacketsInfoTypesEnum::Icmp(log) => log.raw_str.clone(), + PacketsInfoTypesEnum::Arp(log) => log.raw_str.clone(), + PacketsInfoTypesEnum::Icmp6(log) => log.raw_str.clone(), + PacketsInfoTypesEnum::Udp(log) => log.raw_str.clone(), + PacketsInfoTypesEnum::Tcp(log) => log.raw_str.clone(), }; w.write_record([t.to_string(), log_str])?; } @@ -148,13 +160,13 @@ impl Export { } impl Component for Export { - fn init(&mut self, area: Size) -> Result<()> { + fn init(&mut self, _area: Size) -> Result<()> { self.get_user_home_dir(); Ok(()) } - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/components/interfaces.rs b/src/components/interfaces.rs index e499343..8ad0fcb 100644 --- a/src/components/interfaces.rs +++ b/src/components/interfaces.rs @@ -1,4 +1,3 @@ -use ipnetwork::IpNetwork; use pnet::{ datalink::{self, NetworkInterface}, util::MacAddr, @@ -8,19 +7,18 @@ use std::time::Instant; use color_eyre::eyre::Result; use ratatui::{prelude::*, widgets::*}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::Sender; use super::Component; use crate::{ action::Action, config::DEFAULT_BORDER_STYLE, layout::{get_horizontal_layout, get_vertical_layout}, - mode::Mode, tui::Frame, }; pub struct Interfaces { - action_tx: Option>, + action_tx: Option>, interfaces: Vec, last_update_time: Instant, active_interfaces: Vec, @@ -49,21 +47,21 @@ impl Interfaces { self.active_interfaces.clear(); let interfaces = datalink::interfaces(); - for intf in &interfaces { + for interface in &interfaces { // -- get active interface with non-local IP - if (cfg!(windows) || intf.is_up()) && !intf.ips.is_empty() { + if (cfg!(windows) || interface.is_up()) && !interface.ips.is_empty() { // Windows doesn't have the is_up() method - for ip in &intf.ips { + for ip in &interface.ips { if let IpAddr::V4(ipv4) = ip.ip() { if ipv4.is_private() && !ipv4.is_loopback() && !ipv4.is_unspecified() { - self.active_interfaces.push(intf.clone()); + self.active_interfaces.push(interface.clone()); break; } } } } // -- store interfaces into a vec - self.interfaces.push(intf.clone()); + self.interfaces.push(interface.clone()); } // -- sort interfaces self.interfaces.sort_by(|a, b| a.name.cmp(&b.name)); @@ -82,10 +80,12 @@ impl Interfaces { fn send_active_interface(&mut self) { if !self.active_interfaces.is_empty() { - let tx = self.action_tx.clone().unwrap(); + let Some(tx) = self.action_tx.clone() else { + log::error!("Cannot send active interface: action channel not initialized"); + return; + }; let active_interface = &self.active_interfaces[self.active_interface_index]; - tx.send(Action::ActiveInterface(active_interface.clone())) - .unwrap(); + let _ = tx.try_send(Action::ActiveInterface(active_interface.clone())); } } @@ -99,7 +99,7 @@ impl Interfaces { Ok(()) } - fn make_table(&mut self) -> Table { + fn make_table(&mut self) -> Table<'_> { let mut active_interface: Option<&NetworkInterface> = None; if !self.active_interfaces.is_empty() { active_interface = Some(&self.active_interfaces[self.active_interface_index]); @@ -110,8 +110,10 @@ impl Interfaces { let mut rows = Vec::new(); for w in &self.interfaces { let mut active = String::from(""); - if active_interface.is_some() && active_interface.unwrap() == w { - active = String::from("*"); + if let Some(ai) = active_interface { + if ai == w { + active = String::from("*"); + } } let name = if cfg!(windows) { w.description.clone() @@ -192,7 +194,7 @@ impl Interfaces { } impl Component for Interfaces { - fn init(&mut self, area: Size) -> Result<()> { + fn init(&mut self, _area: Size) -> Result<()> { self.get_interfaces(); self.send_active_interface(); Ok(()) @@ -202,8 +204,8 @@ impl Component for Interfaces { self } - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/components/packetdump.rs b/src/components/packetdump.rs index 76a3c7b..353c2b8 100644 --- a/src/components/packetdump.rs +++ b/src/components/packetdump.rs @@ -1,13 +1,11 @@ use chrono::{DateTime, Local}; use color_eyre::eyre::Result; -use color_eyre::owo_colors::OwoColorize; use crossterm::event::{KeyCode, KeyEvent}; -use ipnetwork::Ipv4Network; use pnet::datalink::{Channel, ChannelType, NetworkInterface}; use pnet::packet::icmpv6::Icmpv6Types; use pnet::packet::{ - arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket}, + arp::ArpPacket, ethernet::{EtherTypes, EthernetPacket, MutableEthernetPacket}, icmp::{echo_reply, echo_request, IcmpPacket, IcmpTypes}, icmpv6::Icmpv6Packet, @@ -15,8 +13,7 @@ use pnet::packet::{ ipv4::Ipv4Packet, ipv6::Ipv6Packet, tcp::TcpPacket, - udp::UdpPacket, - MutablePacket, Packet, + udp::UdpPacket, Packet, }; use pnet::util::MacAddr; @@ -24,7 +21,6 @@ use ratatui::layout::Position; use ratatui::style::Stylize; use ratatui::{prelude::*, widgets::*}; use std::{ - collections::HashMap, net::{IpAddr, Ipv4Addr}, sync::{ atomic::{AtomicBool, Ordering}, @@ -33,10 +29,7 @@ use std::{ thread::{self, JoinHandle}, time::Duration, }; -use tokio::{ - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, - task, -}; +use tokio::sync::mpsc::Sender; use tui_input::backend::crossterm::EventHandler; use tui_input::Input; @@ -44,18 +37,29 @@ use super::{Component, Frame}; use crate::{ action::Action, config::DEFAULT_BORDER_STYLE, - config::{Config, KeyBindings}, enums::{ ARPPacketInfo, ICMP6PacketInfo, ICMPPacketInfo, PacketTypeEnum, PacketsInfoTypesEnum, TCPPacketInfo, TabsEnum, UDPPacketInfo, }, layout::get_vertical_layout, mode::Mode, + privilege, utils::MaxSizeVec, }; use strum::{EnumCount, IntoEnumIterator}; -static INPUT_SIZE: usize = 30; +const INPUT_SIZE: usize = 30; + +// Network packet capture buffer size +// Standard Ethernet MTU is 1500 bytes + 14 bytes Ethernet header = 1514 bytes +// Jumbo frames can be up to 9000 bytes + headers = 9018 bytes +// We use 9100 to support jumbo frames with overhead for VLAN tags and extensions +const MAX_PACKET_BUFFER_SIZE: usize = 9100; + +// Maximum number of packets to keep in history per packet type +// Limits memory usage to approximately 1000 packets * average packet size +// This provides sufficient history for analysis while preventing unbounded growth +const MAX_PACKET_HISTORY: usize = 1000; #[derive(Debug, Clone, PartialEq)] pub struct ArpPacketData { @@ -67,9 +71,9 @@ pub struct ArpPacketData { pub struct PacketDump { active_tab: TabsEnum, - action_tx: Option>, + action_tx: Option>, loop_thread: Option>, - should_quit: bool, + _should_quit: bool, dump_paused: Arc, dump_stop: Arc, active_interface: Option, @@ -101,7 +105,7 @@ impl PacketDump { active_tab: TabsEnum::Discovery, action_tx: None, loop_thread: None, - should_quit: false, + _should_quit: false, dump_paused: Arc::new(AtomicBool::new(false)), dump_stop: Arc::new(AtomicBool::new(false)), active_interface: None, @@ -113,12 +117,12 @@ impl PacketDump { filter_str: String::from(""), changed_interface: false, - arp_packets: MaxSizeVec::new(1000), - udp_packets: MaxSizeVec::new(1000), - tcp_packets: MaxSizeVec::new(1000), - icmp_packets: MaxSizeVec::new(1000), - icmp6_packets: MaxSizeVec::new(1000), - all_packets: MaxSizeVec::new(1000), + arp_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), + udp_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), + tcp_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), + icmp_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), + icmp6_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), + all_packets: MaxSizeVec::new(MAX_PACKET_HISTORY), } } @@ -127,7 +131,7 @@ impl PacketDump { source: IpAddr, destination: IpAddr, packet: &[u8], - tx: UnboundedSender, + action_tx: Sender, ) { let udp = UdpPacket::new(packet); if let Some(udp) = udp { @@ -141,7 +145,7 @@ impl PacketDump { udp.get_length() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Udp(UDPPacketInfo { interface_name: interface_name.to_string(), @@ -153,8 +157,7 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Udp, - )) - .unwrap(); + )); } } @@ -163,13 +166,16 @@ impl PacketDump { source: IpAddr, destination: IpAddr, packet: &[u8], - tx: UnboundedSender, + action_tx: Sender, ) { let icmp_packet = IcmpPacket::new(packet); if let Some(icmp_packet) = icmp_packet { match icmp_packet.get_icmp_type() { IcmpTypes::EchoReply => { - let echo_reply_packet = echo_reply::EchoReplyPacket::new(packet).unwrap(); + // Validate packet can be parsed as echo reply + let Some(echo_reply_packet) = echo_reply::EchoReplyPacket::new(packet) else { + return; + }; let raw_str = format!( "[{}]: ICMP echo reply {} -> {} (seq={:?}, id={:?})", @@ -180,7 +186,7 @@ impl PacketDump { echo_reply_packet.get_identifier() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Icmp(ICMPPacketInfo { interface_name: interface_name.to_string(), @@ -192,11 +198,13 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Icmp, - )) - .unwrap(); + )); } IcmpTypes::EchoRequest => { - let echo_request_packet = echo_request::EchoRequestPacket::new(packet).unwrap(); + // Validate packet can be parsed as echo request + let Some(echo_request_packet) = echo_request::EchoRequestPacket::new(packet) else { + return; + }; let raw_str = format!( "[{}]: ICMP echo request {} -> {} (seq={:?}, id={:?})", @@ -207,7 +215,7 @@ impl PacketDump { echo_request_packet.get_identifier() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Icmp(ICMPPacketInfo { interface_name: interface_name.to_string(), @@ -219,8 +227,7 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Icmp, - )) - .unwrap(); + )); } _ => {} } @@ -232,7 +239,7 @@ impl PacketDump { source: IpAddr, destination: IpAddr, packet: &[u8], - tx: UnboundedSender, + action_tx: Sender, ) { let icmpv6_packet = Icmpv6Packet::new(packet); if let Some(icmpv6_packet) = icmpv6_packet { @@ -244,7 +251,7 @@ impl PacketDump { icmpv6_packet.get_icmpv6_type() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Icmp6(ICMP6PacketInfo { interface_name: interface_name.to_string(), @@ -254,8 +261,7 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Icmp6, - )) - .unwrap(); + )); } } @@ -264,7 +270,7 @@ impl PacketDump { source: IpAddr, destination: IpAddr, packet: &[u8], - tx: UnboundedSender, + action_tx: Sender, ) { let tcp = TcpPacket::new(packet); if let Some(tcp) = tcp { @@ -278,7 +284,7 @@ impl PacketDump { packet.len() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Tcp(TCPPacketInfo { interface_name: interface_name.to_string(), @@ -290,8 +296,7 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Tcp, - )) - .unwrap(); + )); } } @@ -301,20 +306,20 @@ impl PacketDump { destination: IpAddr, protocol: IpNextHeaderProtocol, packet: &[u8], - tx: UnboundedSender, + action_tx: Sender, ) { match protocol { IpNextHeaderProtocols::Udp => { - Self::handle_udp_packet(interface_name, source, destination, packet, tx) + Self::handle_udp_packet(interface_name, source, destination, packet, action_tx) } IpNextHeaderProtocols::Tcp => { - Self::handle_tcp_packet(interface_name, source, destination, packet, tx) + Self::handle_tcp_packet(interface_name, source, destination, packet, action_tx) } IpNextHeaderProtocols::Icmp => { - Self::handle_icmp_packet(interface_name, source, destination, packet, tx) + Self::handle_icmp_packet(interface_name, source, destination, packet, action_tx) } IpNextHeaderProtocols::Icmpv6 => { - Self::handle_icmpv6_packet(interface_name, source, destination, packet, tx) + Self::handle_icmpv6_packet(interface_name, source, destination, packet, action_tx) } _ => {} } @@ -323,7 +328,7 @@ impl PacketDump { fn handle_ipv4_packet( interface_name: &str, ethernet: &EthernetPacket, - tx: UnboundedSender, + action_tx: Sender, ) { let header = Ipv4Packet::new(ethernet.payload()); if let Some(header) = header { @@ -333,7 +338,7 @@ impl PacketDump { IpAddr::V4(header.get_destination()), header.get_next_level_protocol(), header.payload(), - tx, + action_tx, ); } } @@ -341,7 +346,7 @@ impl PacketDump { fn handle_ipv6_packet( interface_name: &str, ethernet: &EthernetPacket, - tx: UnboundedSender, + action_tx: Sender, ) { let header = Ipv6Packet::new(ethernet.payload()); if let Some(header) = header { @@ -351,7 +356,7 @@ impl PacketDump { IpAddr::V6(header.get_destination()), header.get_next_header(), header.payload(), - tx, + action_tx, ); } else { println!("[{}]: Malformed IPv6 Packet", interface_name); @@ -361,17 +366,16 @@ impl PacketDump { fn handle_arp_packet( interface_name: &str, ethernet: &EthernetPacket, - tx: UnboundedSender, + action_tx: Sender, ) { let header = ArpPacket::new(ethernet.payload()); if let Some(header) = header { - tx.send(Action::ArpRecieve(ArpPacketData { + let _ = action_tx.try_send(Action::ArpRecieve(ArpPacketData { sender_mac: header.get_sender_hw_addr(), sender_ip: header.get_sender_proto_addr(), target_mac: header.get_target_hw_addr(), target_ip: header.get_target_proto_addr(), - })) - .unwrap(); + })); let raw_str = format!( "[{}]: ARP packet: {}({}) > {}({}); operation: {:?}", @@ -383,7 +387,7 @@ impl PacketDump { header.get_operation() ); - tx.send(Action::PacketDump( + let _ = action_tx.try_send(Action::PacketDump( Local::now(), PacketsInfoTypesEnum::Arp(ARPPacketInfo { interface_name: interface_name.to_string(), @@ -395,65 +399,98 @@ impl PacketDump { raw_str, }), PacketTypeEnum::Arp, - )) - .unwrap(); + )); } } fn handle_ethernet_frame( interface: &NetworkInterface, ethernet: &EthernetPacket, - tx: UnboundedSender, + action_tx: Sender, ) { let interface_name = &interface.name[..]; match ethernet.get_ethertype() { - EtherTypes::Ipv4 => Self::handle_ipv4_packet(interface_name, ethernet, tx), - EtherTypes::Ipv6 => Self::handle_ipv6_packet(interface_name, ethernet, tx), - EtherTypes::Arp => Self::handle_arp_packet(interface_name, ethernet, tx), + EtherTypes::Ipv4 => Self::handle_ipv4_packet(interface_name, ethernet, action_tx), + EtherTypes::Ipv6 => Self::handle_ipv6_packet(interface_name, ethernet, action_tx), + EtherTypes::Arp => Self::handle_arp_packet(interface_name, ethernet, action_tx), _ => {} } } - fn t_logic(tx: UnboundedSender, interface: NetworkInterface, stop: Arc) { - let (_, mut receiver) = match pnet::datalink::channel( - &interface, - pnet::datalink::Config { - write_buffer_size: 4096, - read_buffer_size: 4096, - read_timeout: Some(Duration::new(1, 0)), - write_timeout: None, - channel_type: ChannelType::Layer2, - bpf_fd_attempts: 1000, - linux_fanout: None, - promiscuous: true, - socket_fd: None, - }, - ) { - Ok(Channel::Ethernet(tx, rx)) => (tx, rx), + fn t_logic(action_tx: Sender, interface: NetworkInterface, stop: Arc) { + // Configure optimized packet capture settings + // Note: pnet does not support BPF filtering at the API level - all filtering + // must be done in userspace after packets are captured. This is a known limitation + // of the pnet library. For kernel-level filtering, consider using the pcap crate instead. + let config = pnet::datalink::Config { + // Increased buffer sizes for better performance with high packet rates + // Larger buffers reduce syscall overhead and can handle burst traffic better + write_buffer_size: 65536, // 64KB - sufficient for batch writes + read_buffer_size: 65536, // 64KB - can hold ~40-70 standard packets (MTU 1500) + + // Reduced read timeout for more responsive packet capture and faster shutdown + // 100ms provides a good balance between CPU usage and responsiveness + // This also ensures the stop signal is checked every 100ms maximum + read_timeout: Some(Duration::from_millis(100)), + + write_timeout: None, // No write timeout needed for packet capture + channel_type: ChannelType::Layer2, // Capture at Layer 2 (Ethernet) + bpf_fd_attempts: 1000, // macOS/BSD: Try up to 1000 /dev/bpf* descriptors + linux_fanout: None, // Linux fanout not used for single-threaded capture + promiscuous: true, // Capture all packets on the interface, not just those addressed to this host + socket_fd: None, // Let pnet create its own socket + }; + + let (_, mut receiver) = match pnet::datalink::channel(&interface, config) { + Ok(Channel::Ethernet(packet_tx, rx)) => (packet_tx, rx), Ok(_) => { - tx.send(Action::Error("Unknown or unsupported channel type".into())) - .unwrap(); + let _ = action_tx.try_send(Action::Error(format!( + "Failed to create packet capture channel on interface '{}'.\n\ + \n\ + The network interface does not support the required Ethernet packet capture mode.\n\ + This usually indicates:\n\ + - Interface is not a standard Ethernet adapter (e.g., may be a tunnel, loopback, or wireless)\n\ + - Interface does not support Layer 2 packet capture\n\ + \n\ + Please try selecting a different network interface.", + interface.name + ))); return; } Err(e) => { - tx.send(Action::Error(format!( - "Unable to create datalink channel: {e}" - ))) - .unwrap(); + let error_msg = privilege::get_datalink_error_message(&e, &interface.name); + let _ = action_tx.try_send(Action::Error(error_msg)); return; } }; loop { - if stop.load(Ordering::Relaxed) { + // Use SeqCst ordering to ensure we see the stop signal + if stop.load(Ordering::SeqCst) { + log::debug!("Packet capture thread received stop signal"); break; } - let mut buf: [u8; 1600] = [0u8; 1600]; - let mut fake_ethernet_frame = MutableEthernetPacket::new(&mut buf[..]).unwrap(); + let mut buf: [u8; MAX_PACKET_BUFFER_SIZE] = [0u8; MAX_PACKET_BUFFER_SIZE]; + // Create mutable ethernet frame for handling special cases + let Some(mut fake_ethernet_frame) = MutableEthernetPacket::new(&mut buf[..]) else { + // Buffer too small, skip this iteration + continue; + }; match receiver.next() { Ok(packet) => { + // Log warning if packet exceeds buffer size (indicates potential data loss) + if packet.len() > MAX_PACKET_BUFFER_SIZE { + log::warn!( + "Packet size ({} bytes) exceeds buffer capacity ({} bytes) on interface {}. \ + Packet may be truncated.", + packet.len(), + MAX_PACKET_BUFFER_SIZE, + interface.name + ); + } + let payload_offset; if cfg!(any(target_os = "macos", target_os = "ios")) && interface.is_up() @@ -469,9 +506,21 @@ impl PacketDump { payload_offset = 0; } if packet.len() > payload_offset { - let version = Ipv4Packet::new(&packet[payload_offset..]) - .unwrap() - .get_version(); + // Check if payload would exceed buffer after offset + let payload_size = packet.len() - payload_offset; + if payload_size > MAX_PACKET_BUFFER_SIZE - 14 { + log::warn!( + "Payload size ({} bytes) after offset may exceed buffer on interface {}", + payload_size, + interface.name + ); + } + + // Try to parse as IPv4 packet to determine version + let version = match Ipv4Packet::new(&packet[payload_offset..]) { + Some(ipv4_packet) => ipv4_packet.get_version(), + None => continue, // Invalid packet, skip + }; if version == 4 { fake_ethernet_frame.set_destination(MacAddr(0, 0, 0, 0, 0, 0)); fake_ethernet_frame.set_source(MacAddr(0, 0, 0, 0, 0, 0)); @@ -480,7 +529,7 @@ impl PacketDump { Self::handle_ethernet_frame( &interface, &fake_ethernet_frame.to_immutable(), - tx.clone(), + action_tx.clone(), ); continue; } else if version == 6 { @@ -491,30 +540,38 @@ impl PacketDump { Self::handle_ethernet_frame( &interface, &fake_ethernet_frame.to_immutable(), - tx.clone(), + action_tx.clone(), ); continue; } } } - Self::handle_ethernet_frame( - &interface, - &EthernetPacket::new(packet).unwrap(), - tx.clone(), - ); + // Parse ethernet packet - skip if invalid + if let Some(ethernet_packet) = EthernetPacket::new(packet) { + Self::handle_ethernet_frame( + &interface, + ðernet_packet, + action_tx.clone(), + ); + } } // Err(e) => println!("packetdump: unable to receive packet: {}", e), - Err(e) => {} + Err(_e) => {} } } } fn start_loop(&mut self) { if self.loop_thread.is_none() { - let tx = self.action_tx.clone().unwrap(); - let interface = self.active_interface.clone().unwrap(); - // self.dump_stop.store(false, Ordering::Relaxed); - // let paused = self.dump_paused.clone(); + // Require both action_tx and active_interface to start loop + let Some(tx) = self.action_tx.clone() else { + return; + }; + let Some(interface) = self.active_interface.clone() else { + return; + }; + + log::debug!("Starting packet capture thread for interface: {}", interface.name); let dump_stop = self.dump_stop.clone(); let t_handle = thread::spawn(move || { Self::t_logic(tx, interface, dump_stop); @@ -524,26 +581,52 @@ impl PacketDump { } fn restart_loop(&mut self) { - self.dump_stop.store(true, Ordering::Relaxed); + log::debug!("Requesting packet capture thread to stop"); + // Use SeqCst ordering for consistent memory visibility across threads + self.dump_stop.store(true, Ordering::SeqCst); + + // Wait for thread to finish with timeout + if let Some(handle) = self.loop_thread.take() { + // Try to join the thread with a timeout + // We use a simple timeout mechanism by checking if thread is finished + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(1); + + while !handle.is_finished() && start.elapsed() < timeout { + thread::sleep(Duration::from_millis(50)); + } + + if handle.is_finished() { + // Thread finished gracefully, join it to clean up + match handle.join() { + Ok(_) => log::debug!("Packet capture thread stopped successfully"), + Err(_) => log::warn!("Packet capture thread panicked during shutdown"), + } + } else { + // Thread didn't finish in time, but we've signaled it to stop + // Store the handle back so Drop can handle it + log::warn!("Packet capture thread did not stop within timeout, will be cleaned up on drop"); + self.loop_thread = Some(handle); + } + } } pub fn get_array_by_packet_type( - &mut self, + &self, packet_type: PacketTypeEnum, - ) -> &Vec<(DateTime, PacketsInfoTypesEnum)> { + ) -> &std::collections::VecDeque<(DateTime, PacketsInfoTypesEnum)> { match packet_type { - PacketTypeEnum::Arp => self.arp_packets.get_vec(), - PacketTypeEnum::Tcp => self.tcp_packets.get_vec(), - PacketTypeEnum::Udp => self.udp_packets.get_vec(), - PacketTypeEnum::Icmp => self.icmp_packets.get_vec(), - PacketTypeEnum::Icmp6 => self.icmp6_packets.get_vec(), - PacketTypeEnum::All => self.all_packets.get_vec(), + PacketTypeEnum::Arp => self.arp_packets.get_deque(), + PacketTypeEnum::Tcp => self.tcp_packets.get_deque(), + PacketTypeEnum::Udp => self.udp_packets.get_deque(), + PacketTypeEnum::Icmp => self.icmp_packets.get_deque(), + PacketTypeEnum::Icmp6 => self.icmp6_packets.get_deque(), + PacketTypeEnum::All => self.all_packets.get_deque(), } } pub fn get_arp_packages(&self) -> Vec<(DateTime, PacketsInfoTypesEnum)> { - let a = &self.arp_packets.get_vec().to_vec(); - a.clone() + self.arp_packets.get_vec() } pub fn clone_array_by_packet_type( @@ -551,12 +634,12 @@ impl PacketDump { packet_type: PacketTypeEnum, ) -> Vec<(DateTime, PacketsInfoTypesEnum)> { match packet_type { - PacketTypeEnum::Arp => self.arp_packets.get_vec().to_vec(), - PacketTypeEnum::Tcp => self.tcp_packets.get_vec().to_vec(), - PacketTypeEnum::Udp => self.udp_packets.get_vec().to_vec(), - PacketTypeEnum::Icmp => self.icmp_packets.get_vec().to_vec(), - PacketTypeEnum::Icmp6 => self.icmp6_packets.get_vec().to_vec(), - PacketTypeEnum::All => self.all_packets.get_vec().to_vec(), + PacketTypeEnum::Arp => self.arp_packets.get_vec(), + PacketTypeEnum::Tcp => self.tcp_packets.get_vec(), + PacketTypeEnum::Udp => self.udp_packets.get_vec(), + PacketTypeEnum::Icmp => self.icmp_packets.get_vec(), + PacketTypeEnum::Icmp6 => self.icmp6_packets.get_vec(), + PacketTypeEnum::All => self.all_packets.get_vec(), } } @@ -604,269 +687,275 @@ impl PacketDump { self.scrollbar_state = self.scrollbar_state.position(index); } + /// Formats an ICMP packet into styled spans for table display + fn format_icmp_packet_row(icmp: &ICMPPacketInfo) -> Vec> { + let mut spans = vec![]; + + spans.push(Span::styled( + format!("[{}] ", icmp.interface_name.clone()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + "ICMP", + Style::default().fg(Color::Black).bg(Color::White), + )); + + match icmp.icmp_type { + IcmpTypes::EchoRequest => { + spans.push(Span::styled( + " echo request ", + Style::default().fg(Color::Yellow), + )); + } + IcmpTypes::EchoReply => { + spans.push(Span::styled( + " echo reply ", + Style::default().fg(Color::Yellow), + )); + } + _ => {} + } + + spans.push(Span::styled( + icmp.source.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(" -> ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + icmp.destination.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled("(seq=", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + format!("{:?}", icmp.seq.to_string()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(", ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled("id=", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + format!("{:?}", icmp.id.to_string()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(")", Style::default().fg(Color::Yellow))); + + spans + } + + /// Formats an ICMPv6 packet into styled spans for table display + fn format_icmp6_packet_row(icmp: &ICMP6PacketInfo) -> Vec> { + let mut spans = vec![]; + + spans.push(Span::styled( + format!("[{}] ", icmp.interface_name.clone()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + "ICMP6", + Style::default().fg(Color::Red).bg(Color::Black), + )); + + let icmp_type_str = match icmp.icmp_type { + Icmpv6Types::EchoRequest => " echo request ", + Icmpv6Types::EchoReply => " echo reply ", + Icmpv6Types::NeighborAdvert => " neighbor advert ", + Icmpv6Types::NeighborSolicit => " neighbor solicit ", + Icmpv6Types::Redirect => " redirect ", + _ => " unknown ", + }; + spans.push(Span::styled( + icmp_type_str, + Style::default().fg(Color::Yellow), + )); + + spans.push(Span::styled( + icmp.source.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(" -> ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + icmp.destination.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(", ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled(")", Style::default().fg(Color::Yellow))); + + spans + } + + /// Formats a UDP packet into styled spans for table display + fn format_udp_packet_row(udp: &UDPPacketInfo) -> Vec> { + let mut spans = vec![]; + + spans.push(Span::styled( + format!("[{}] ", udp.interface_name.clone()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + "UDP", + Style::default().fg(Color::Yellow).bg(Color::Blue), + )); + spans.push(Span::styled( + " Packet: ", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::styled( + udp.source.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + udp.source_port.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + udp.destination.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + udp.destination_port.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + " length: ", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::styled( + format!("{}", udp.length), + Style::default().fg(Color::Red), + )); + + spans + } + + /// Formats a TCP packet into styled spans for table display + fn format_tcp_packet_row(tcp: &TCPPacketInfo) -> Vec> { + let mut spans = vec![]; + + spans.push(Span::styled( + format!("[{}] ", tcp.interface_name.clone()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + "TCP", + Style::default().fg(Color::Black).bg(Color::Green), + )); + spans.push(Span::styled( + " Packet: ", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::styled( + tcp.source.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + tcp.source_port.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + tcp.destination.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + tcp.destination_port.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + " length: ", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::styled( + format!("{}", tcp.length), + Style::default().fg(Color::Red), + )); + + spans + } + + /// Formats an ARP packet into styled spans for table display + fn format_arp_packet_row(arp: &ARPPacketInfo) -> Vec> { + let mut spans = vec![]; + + spans.push(Span::styled( + format!("[{}] ", arp.interface_name.clone()), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + "ARP", + Style::default().fg(Color::Yellow).bg(Color::Red), + )); + spans.push(Span::styled( + " Packet: ", + Style::default().fg(Color::Yellow), + )); + spans.push(Span::styled( + arp.source_mac.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + arp.source_ip.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + arp.destination_mac.to_string(), + Style::default().fg(Color::Green), + )); + spans.push(Span::styled( + arp.destination_ip.to_string(), + Style::default().fg(Color::Blue), + )); + spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); + spans.push(Span::styled( + format!(" {:?}", arp.operation), + Style::default().fg(Color::Red), + )); + + spans + } + + /// Retrieves and filters packet data based on packet type and filter string, + /// then formats each packet into a table row with styled spans fn get_table_rows_by_packet_type<'a>(&mut self, packet_type: PacketTypeEnum) -> Vec> { let f_str = self.filter_str.clone(); let logs_data = self.get_array_by_packet_type(packet_type); + + // Filter packets based on filter string let mut logs: Vec<(DateTime, PacketsInfoTypesEnum)> = vec![]; for (d, p) in logs_data { - match p { - PacketsInfoTypesEnum::Icmp(log) => { - if log.raw_str.contains(f_str.as_str()) { - logs.push((d.to_owned(), p.to_owned())); - } - } - PacketsInfoTypesEnum::Arp(log) => { - if log.raw_str.contains(f_str.as_str()) { - logs.push((d.to_owned(), p.to_owned())); - } - } - PacketsInfoTypesEnum::Icmp6(log) => { - if log.raw_str.contains(f_str.as_str()) { - logs.push((d.to_owned(), p.to_owned())); - } - } - PacketsInfoTypesEnum::Udp(log) => { - if log.raw_str.contains(f_str.as_str()) { - logs.push((d.to_owned(), p.to_owned())); - } - } - PacketsInfoTypesEnum::Tcp(log) => { - if log.raw_str.contains(f_str.as_str()) { - logs.push((d.to_owned(), p.to_owned())); - } - } + let matches_filter = match p { + PacketsInfoTypesEnum::Icmp(log) => log.raw_str.contains(f_str.as_str()), + PacketsInfoTypesEnum::Arp(log) => log.raw_str.contains(f_str.as_str()), + PacketsInfoTypesEnum::Icmp6(log) => log.raw_str.contains(f_str.as_str()), + PacketsInfoTypesEnum::Udp(log) => log.raw_str.contains(f_str.as_str()), + PacketsInfoTypesEnum::Tcp(log) => log.raw_str.contains(f_str.as_str()), + }; + + if matches_filter { + logs.push((d.to_owned(), p.to_owned())); } } + // Format each packet into a table row let rows: Vec = logs .iter() .map(|(time, log)| { let t = time.format("%H:%M:%S").to_string(); - let mut spans = vec![]; - match log { - // ----------------------------- - // -- ICMP - PacketsInfoTypesEnum::Icmp(icmp) => { - spans.push(Span::styled( - format!("[{}] ", icmp.interface_name.clone()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - "ICMP", - Style::default().fg(Color::Black).bg(Color::White), - )); - match icmp.icmp_type { - IcmpTypes::EchoRequest => { - spans.push(Span::styled( - " echo request ", - Style::default().fg(Color::Yellow), - )); - } - IcmpTypes::EchoReply => { - spans.push(Span::styled( - " echo reply ", - Style::default().fg(Color::Yellow), - )); - } - _ => {} - } - spans.push(Span::styled( - icmp.source.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(" -> ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - icmp.destination.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled("(seq=", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - format!("{:?}", icmp.seq.to_string()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(", ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled("id=", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - format!("{:?}", icmp.id.to_string()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(")", Style::default().fg(Color::Yellow))); - } - // ----------------------------- - // -- ICMP6 - PacketsInfoTypesEnum::Icmp6(icmp) => { - spans.push(Span::styled( - format!("[{}] ", icmp.interface_name.clone()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - "ICMP6", - Style::default().fg(Color::Red).bg(Color::Black), - )); - - let mut icmp_type_str = " unknown "; - match icmp.icmp_type { - Icmpv6Types::EchoRequest => { - icmp_type_str = " echo request "; - } - Icmpv6Types::EchoReply => { - icmp_type_str = " echo reply "; - } - Icmpv6Types::NeighborAdvert => { - icmp_type_str = " neighbor advert "; - } - Icmpv6Types::NeighborSolicit => { - icmp_type_str = " neighbor solicit "; - } - Icmpv6Types::Redirect => { - icmp_type_str = " redirect "; - } - _ => {} - } - spans.push(Span::styled( - icmp_type_str, - Style::default().fg(Color::Yellow), - )); - - spans.push(Span::styled( - icmp.source.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(" -> ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - icmp.destination.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(", ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled(")", Style::default().fg(Color::Yellow))); - } - // ----------------------------- - // -- UDP - PacketsInfoTypesEnum::Udp(udp) => { - spans.push(Span::styled( - format!("[{}] ", udp.interface_name.clone()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - "UDP", - Style::default().fg(Color::Yellow).bg(Color::Blue), - )); - spans.push(Span::styled( - " Packet: ", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::styled( - udp.source.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - udp.source_port.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - udp.destination.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - udp.destination_port.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - " length: ", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::styled( - format!("{}", udp.length), - Style::default().fg(Color::Red), - )); - } - // ----------------------------- - // -- TCP - PacketsInfoTypesEnum::Tcp(tcp) => { - spans.push(Span::styled( - format!("[{}] ", tcp.interface_name.clone()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - "TCP", - Style::default().fg(Color::Black).bg(Color::Green), - )); - spans.push(Span::styled( - " Packet: ", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::styled( - tcp.source.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - tcp.source_port.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - tcp.destination.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(":", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - tcp.destination_port.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - " length: ", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::styled( - format!("{}", tcp.length), - Style::default().fg(Color::Red), - )); - } - // ----------------------------- - // -- ARP - PacketsInfoTypesEnum::Arp(arp) => { - spans.push(Span::styled( - format!("[{}] ", arp.interface_name.clone()), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - "ARP", - Style::default().fg(Color::Yellow).bg(Color::Red), - )); - spans.push(Span::styled( - " Packet: ", - Style::default().fg(Color::Yellow), - )); - spans.push(Span::styled( - arp.source_mac.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - arp.source_ip.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(" > ", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - arp.destination_mac.to_string(), - Style::default().fg(Color::Green), - )); - spans.push(Span::styled( - arp.destination_ip.to_string(), - Style::default().fg(Color::Blue), - )); - spans.push(Span::styled(";", Style::default().fg(Color::Yellow))); - spans.push(Span::styled( - format!(" {:?}", arp.operation), - Style::default().fg(Color::Red), - )); - } - } + + let spans = match log { + PacketsInfoTypesEnum::Icmp(icmp) => Self::format_icmp_packet_row(icmp), + PacketsInfoTypesEnum::Icmp6(icmp6) => Self::format_icmp6_packet_row(icmp6), + PacketsInfoTypesEnum::Udp(udp) => Self::format_udp_packet_row(udp), + PacketsInfoTypesEnum::Tcp(tcp) => Self::format_tcp_packet_row(tcp), + PacketsInfoTypesEnum::Arp(arp) => Self::format_arp_packet_row(arp), + }; + let line = Line::from(spans); Row::new(vec![ Cell::from(Span::styled(t, Style::default().fg(Color::Cyan))), @@ -999,7 +1088,7 @@ impl PacketDump { scrollbar } - fn make_input(&self, scroll: usize) -> Paragraph { + fn make_input(&self, scroll: usize) -> Paragraph<'_> { let input = Paragraph::new(self.input.value()) .style(Style::default().fg(Color::Green)) .scroll((0, scroll as u16)) @@ -1051,9 +1140,36 @@ impl PacketDump { } } +impl Drop for PacketDump { + fn drop(&mut self) { + // Signal thread to stop + self.dump_stop.store(true, Ordering::SeqCst); + + // Wait for thread to finish with timeout + if let Some(handle) = self.loop_thread.take() { + log::debug!("PacketDump dropping, waiting for thread to finish"); + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(2); + + while !handle.is_finished() && start.elapsed() < timeout { + thread::sleep(Duration::from_millis(50)); + } + + if handle.is_finished() { + // Thread finished gracefully + let _ = handle.join(); + log::debug!("PacketDump thread cleaned up successfully"); + } else { + log::warn!("PacketDump thread did not finish within timeout during drop"); + // Thread handle will be dropped, potentially causing thread termination + } + } + } +} + impl Component for PacketDump { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } @@ -1067,7 +1183,7 @@ impl Component for PacketDump { Mode::Normal => return Ok(None), Mode::Input => match key.code { KeyCode::Enter => { - if let Some(sender) = &self.action_tx { + if let Some(_sender) = &self.action_tx { self.set_filter_str(self.input.value().to_string()); // self.set_cidr(self.input.value().to_string(), true); } @@ -1090,17 +1206,24 @@ impl Component for PacketDump { if self.changed_interface { if let Some(ref lt) = self.loop_thread { if lt.is_finished() { + // Thread has finished, clean it up and start new one self.loop_thread = None; self.dump_stop.store(false, Ordering::SeqCst); + log::debug!("Previous packet capture thread finished, starting new one"); self.start_loop(); self.changed_interface = false; } + } else { + // No thread running, safe to start immediately + self.dump_stop.store(false, Ordering::SeqCst); + self.start_loop(); + self.changed_interface = false; } } // -- tab change if let Action::TabChange(tab) = action { - self.tab_changed(tab).unwrap(); + let _ = self.tab_changed(tab); } // -- active interface set if let Action::ActiveInterface(ref interface) = action { @@ -1149,11 +1272,9 @@ impl Component for PacketDump { // -- MODE CHANGE if let Action::ModeChange(mode) = action { - self.action_tx - .clone() - .unwrap() - .send(Action::AppModeChange(mode)) - .unwrap(); + if let Some(tx) = &self.action_tx { + let _ = tx.clone().try_send(Action::AppModeChange(mode)); + } self.mode = mode; } @@ -1187,6 +1308,34 @@ impl Component for PacketDump { Ok(()) } + fn shutdown(&mut self) -> Result<()> { + log::info!("Shutting down packet capture component"); + + // Signal thread to stop + self.dump_stop.store(true, Ordering::SeqCst); + + // Wait for thread to finish with timeout + if let Some(handle) = self.loop_thread.take() { + let start = std::time::Instant::now(); + let timeout = Duration::from_secs(2); + + while !handle.is_finished() && start.elapsed() < timeout { + thread::sleep(Duration::from_millis(50)); + } + + if handle.is_finished() { + match handle.join() { + Ok(_) => log::info!("Packet capture thread stopped successfully during shutdown"), + Err(_) => log::error!("Packet capture thread panicked during shutdown"), + } + } else { + log::warn!("Packet capture thread did not stop within timeout during shutdown"); + } + } + + Ok(()) + } + fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { if self.active_tab == TabsEnum::Packets { let layout = get_vertical_layout(area); diff --git a/src/components/ports.rs b/src/components/ports.rs index a01f45d..7defacc 100644 --- a/src/components/ports.rs +++ b/src/components/ports.rs @@ -1,21 +1,17 @@ -use cidr::Ipv4Cidr; use color_eyre::eyre::Result; -use color_eyre::owo_colors::OwoColorize; -use dns_lookup::{lookup_addr, lookup_host}; use futures::StreamExt; -use futures::{future::join_all, stream}; +use futures::stream; use ratatui::style::Stylize; use core::str; use port_desc::{PortDescription, TransportProtocol}; use ratatui::{prelude::*, widgets::*}; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; -use std::{string, time::Duration}; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; use tokio::{ net::TcpStream, - sync::mpsc::{self, UnboundedSender}, - task::{self, JoinHandle}, + sync::mpsc::Sender, }; use super::Component; @@ -23,12 +19,16 @@ use crate::enums::COMMON_PORTS; use crate::{ action::Action, config::DEFAULT_BORDER_STYLE, + dns_cache::DnsCache, enums::{PortsScanState, TabsEnum}, layout::get_vertical_layout, tui::Frame, }; -static POOL_SIZE: usize = 64; +const _DEFAULT_POOL_SIZE: usize = 64; +const MIN_POOL_SIZE: usize = 32; +const MAX_POOL_SIZE: usize = 128; +const PORT_SCAN_TIMEOUT_SECS: u64 = 2; const SPINNER_SYMBOLS: [&str; 6] = ["⠷", "⠯", "⠟", "⠻", "⠽", "⠾"]; #[derive(Debug, Clone, PartialEq)] @@ -41,12 +41,13 @@ pub struct ScannedIpPorts { pub struct Ports { active_tab: TabsEnum, - action_tx: Option>, + action_tx: Option>, ip_ports: Vec, list_state: ListState, scrollbar_state: ScrollbarState, spinner_index: usize, port_desc: Option, + dns_cache: DnsCache, } impl Default for Ports { @@ -70,35 +71,68 @@ impl Ports { scrollbar_state: ScrollbarState::new(0), spinner_index: 0, port_desc, + dns_cache: DnsCache::new(), } } + fn get_pool_size() -> usize { + let num_cpus = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4); + + let calculated = num_cpus * 4; + calculated.clamp(MIN_POOL_SIZE, MAX_POOL_SIZE) + } + pub fn get_scanned_ports(&self) -> &Vec { &self.ip_ports } fn process_ip(&mut self, ip: &str) { - let ipv4: Ipv4Addr = ip.parse().unwrap(); - let hostname = lookup_addr(&ipv4.into()).unwrap_or_default(); + let Ok(ip_addr) = ip.parse::() else { + return; + }; if let Some(n) = self.ip_ports.iter_mut().find(|item| item.ip == ip) { n.ip = ip.to_string(); } else { self.ip_ports.push(ScannedIpPorts { ip: ip.to_string(), - hostname, + hostname: String::new(), state: PortsScanState::Waiting, ports: Vec::new(), }); self.ip_ports.sort_by(|a, b| { - let a_ip: Ipv4Addr = a.ip.parse::().unwrap(); - let b_ip: Ipv4Addr = b.ip.parse::().unwrap(); - a_ip.partial_cmp(&b_ip).unwrap() + let Ok(a_ip) = a.ip.parse::() else { + log::error!("Invalid IP in sort: {}", a.ip); + return std::cmp::Ordering::Equal; + }; + let Ok(b_ip) = b.ip.parse::() else { + log::error!("Invalid IP in sort: {}", b.ip); + return std::cmp::Ordering::Equal; + }; + match (a_ip, b_ip) { + (IpAddr::V4(a_v4), IpAddr::V4(b_v4)) => a_v4.cmp(&b_v4), + (IpAddr::V6(a_v6), IpAddr::V6(b_v6)) => a_v6.cmp(&b_v6), + (IpAddr::V4(_), IpAddr::V6(_)) => std::cmp::Ordering::Less, + (IpAddr::V6(_), IpAddr::V4(_)) => std::cmp::Ordering::Greater, + } }); } self.set_scrollbar_height(); + + if let Some(tx) = self.action_tx.clone() { + let dns_cache = self.dns_cache.clone(); + let ip_string = ip.to_string(); + tokio::spawn(async move { + let hostname = dns_cache.lookup_with_timeout(ip_addr).await; + if !hostname.is_empty() { + let _ = tx.try_send(Action::DnsResolved(ip_string, hostname)); + } + }); + } } fn set_scrollbar_height(&mut self) { @@ -158,31 +192,52 @@ impl Ports { fn scan_ports(&mut self, index: usize) { if index >= self.ip_ports.len() { - return; // -- index out of bounds + return; } self.ip_ports[index].state = PortsScanState::Scanning; + let ip_string = self.ip_ports[index].ip.clone(); - let tx = self.action_tx.clone().unwrap(); - let ip: IpAddr = self.ip_ports[index].ip.parse().unwrap(); + let Some(tx) = self.action_tx.clone() else { + log::error!("Cannot scan ports: action channel not initialized"); + return; + }; + let Ok(ip) = ip_string.parse::() else { + log::error!("Invalid IP for port scan: {}", ip_string); + return; + }; let ports_box = Box::new(COMMON_PORTS.iter()); + let pool_size = Self::get_pool_size(); - let h = tokio::spawn(async move { + tokio::spawn(async move { + log::debug!("Starting port scan for IP: {} with pool size {}", ip, pool_size); let ports = stream::iter(ports_box); ports - .for_each_concurrent(POOL_SIZE, |port| { - Self::scan(tx.clone(), index, ip, port.to_owned(), 2) + .for_each_concurrent(pool_size, |port| { + Self::scan(tx.clone(), ip_string.clone(), ip, port.to_owned()) }) .await; - tx.send(Action::PortScanDone(index)).unwrap(); + + if let Err(e) = tx.send(Action::PortScanDone(ip_string.clone())).await { + log::error!( + "Failed to send port scan completion notification for {}: {:?}", + ip, e + ); + } + log::debug!("Port scan completed for IP: {}", ip); }); } - async fn scan(tx: UnboundedSender, index: usize, ip: IpAddr, port: u16, timeout: u64) { - let timeout = Duration::from_secs(2); + async fn scan(tx: Sender, ip_string: String, ip: IpAddr, port: u16) { + let timeout = Duration::from_secs(PORT_SCAN_TIMEOUT_SECS); let soc_addr = SocketAddr::new(ip, port); if let Ok(Ok(_)) = tokio::time::timeout(timeout, TcpStream::connect(&soc_addr)).await { - tx.send(Action::PortScan(index, port)).unwrap(); + if let Err(e) = tx.send(Action::PortScan(ip_string, port)).await { + log::error!( + "Failed to send open port notification for {}:{} - channel closed: {:?}", + ip, port, e + ); + } } } @@ -192,14 +247,17 @@ impl Ports { } } - fn store_scanned_port(&mut self, index: usize, port: u16) { - let ip_ports = &mut self.ip_ports[index]; - if !ip_ports.ports.contains(&port) { - ip_ports.ports.push(port); + fn store_scanned_port(&mut self, ip: &str, port: u16) { + if let Some(ip_ports) = self.ip_ports.iter_mut().find(|item| item.ip == ip) { + if !ip_ports.ports.contains(&port) { + ip_ports.ports.push(port); + } + } else { + log::warn!("Received port scan result for unknown IP: {}:{}", ip, port); } } - fn make_list(&self, rect: Rect) -> List { + fn make_list(&self, rect: Rect) -> List<'_> { let mut items = Vec::new(); for ip in &self.ip_ports { let mut lines = Vec::new(); @@ -277,8 +335,9 @@ impl Ports { .title( ratatui::widgets::block::Title::from(Line::from(vec![ Span::styled("|", Style::default().fg(Color::Yellow)), - String::from(char::from_u32(0x25b2).unwrap_or('>')).red(), - String::from(char::from_u32(0x25bc).unwrap_or('>')).red(), + // Unicode up/down triangle characters (▲▼) + String::from(char::from_u32(0x25b2).unwrap_or('▲')).red(), + String::from(char::from_u32(0x25bc).unwrap_or('▼')).red(), Span::styled("select|", Style::default().fg(Color::Yellow)), ])) .position(ratatui::widgets::block::Position::Bottom) @@ -299,7 +358,7 @@ impl Ports { } impl Component for Ports { - fn init(&mut self, area: Size) -> Result<()> { + fn init(&mut self, _area: Size) -> Result<()> { Ok(()) } @@ -307,8 +366,8 @@ impl Component for Ports { self } - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } @@ -320,17 +379,15 @@ impl Component for Ports { fn update(&mut self, action: Action) -> Result> { if let Action::Tick = action { let mut s_index = self.spinner_index + 1; - s_index %= SPINNER_SYMBOLS.len() - 1; + s_index %= SPINNER_SYMBOLS.len(); self.spinner_index = s_index; } - // -- tab change if let Action::TabChange(tab) = action { - self.tab_changed(tab).unwrap(); + self.tab_changed(tab)?; } if self.active_tab == TabsEnum::Ports { - // -- prev & next select item in list if let Action::Down = action { self.next_in_list(); } @@ -343,19 +400,28 @@ impl Component for Ports { } } - if let Action::PortScan(index, port) = action { - self.store_scanned_port(index, port); + if let Action::PortScan(ref ip, port) = action { + self.store_scanned_port(ip, port); } - if let Action::PortScanDone(index) = action { - self.ip_ports[index].state = PortsScanState::Done; + if let Action::PortScanDone(ref ip) = action { + if let Some(entry) = self.ip_ports.iter_mut().find(|item| item.ip == *ip) { + entry.state = PortsScanState::Done; + } else { + log::warn!("Received port scan completion for unknown IP: {}", ip); + } } - // -- PING IP if let Action::PingIp(ref ip) = action { self.process_ip(ip); } + if let Action::DnsResolved(ref ip, ref hostname) = action { + if let Some(entry) = self.ip_ports.iter_mut().find(|item| item.ip == *ip) { + entry.hostname = hostname.clone(); + } + } + Ok(None) } @@ -367,11 +433,9 @@ impl Component for Ports { list_rect.y += 1; list_rect.height -= 1; - // -- LIST let list = self.make_list(list_rect); f.render_stateful_widget(list, list_rect, &mut self.list_state.clone()); - // -- SCROLLBAR let scrollbar = Self::make_scrollbar(); let mut scroll_rect = list_rect; scroll_rect.y += 1; diff --git a/src/components/sniff.rs b/src/components/sniff.rs index afe546c..cabd91a 100644 --- a/src/components/sniff.rs +++ b/src/components/sniff.rs @@ -1,27 +1,18 @@ use color_eyre::eyre::Result; -use color_eyre::owo_colors::OwoColorize; -use dns_lookup::{lookup_addr, lookup_host}; use ipnetwork::IpNetwork; -use pnet::{ - datalink::NetworkInterface, - packet::{ - arp::{ArpHardwareTypes, ArpOperations, ArpPacket, MutableArpPacket}, - ethernet::{EtherTypes, MutableEthernetPacket}, - MutablePacket, Packet, - }, -}; use ratatui::style::Stylize; use ratatui::{prelude::*, widgets::*}; +use std::collections::HashMap; use std::net::IpAddr; -use tokio::sync::mpsc::{self, UnboundedSender}; -use tui_scrollview::{ScrollView, ScrollViewState}; +use tokio::sync::mpsc::Sender; +use tui_scrollview::ScrollViewState; use super::Component; use crate::{ action::Action, - config::DEFAULT_BORDER_STYLE, + dns_cache::DnsCache, enums::{PacketTypeEnum, PacketsInfoTypesEnum, TabsEnum}, layout::{get_vertical_layout, HORIZONTAL_CONSTRAINTS}, tui::Frame, @@ -39,14 +30,17 @@ pub struct IPTraffic { pub struct Sniffer { active_tab: TabsEnum, - action_tx: Option>, - list_state: ListState, - scrollbar_state: ScrollbarState, - traffic_ips: Vec, + action_tx: Option>, + _list_state: ListState, + _scrollbar_state: ScrollbarState, + traffic_map: HashMap, + traffic_sorted_cache: Vec, + cache_dirty: bool, scrollview_state: ScrollViewState, udp_sum: f64, tcp_sum: f64, active_inft_ips: Vec, + dns_cache: DnsCache, } impl Default for Sniffer { @@ -60,13 +54,16 @@ impl Sniffer { Self { active_tab: TabsEnum::Discovery, action_tx: None, - list_state: ListState::default().with_selected(Some(0)), - scrollbar_state: ScrollbarState::new(0), - traffic_ips: Vec::new(), + _list_state: ListState::default().with_selected(Some(0)), + _scrollbar_state: ScrollbarState::new(0), + traffic_map: HashMap::new(), + traffic_sorted_cache: Vec::new(), + cache_dirty: false, scrollview_state: ScrollViewState::new(), udp_sum: 0.0, tcp_sum: 0.0, active_inft_ips: Vec::new(), + dns_cache: DnsCache::new(), } } @@ -78,46 +75,69 @@ impl Sniffer { self.scrollview_state.scroll_up(); } - fn traffic_contains_ip(&self, ip: &IpAddr) -> bool { - self.traffic_ips - .iter() - .any(|traffic| traffic.ip == ip.clone()) - } - fn count_traffic_packet(&mut self, source: IpAddr, destination: IpAddr, length: usize) { + let mut new_ips = Vec::new(); + // -- destination - if self.traffic_contains_ip(&destination) { - if let Some(ip_entry) = self.traffic_ips.iter_mut().find(|ie| ie.ip == destination) { - ip_entry.download += length as f64; - } + if let Some(entry) = self.traffic_map.get_mut(&destination) { + entry.download += length as f64; } else { - self.traffic_ips.push(IPTraffic { + self.traffic_map.insert(destination, IPTraffic { ip: destination, download: length as f64, upload: 0.0, - hostname: lookup_addr(&destination).unwrap_or(String::from("unknown")), + hostname: String::new(), // Will be filled asynchronously }); + new_ips.push(destination); } // -- source - if self.traffic_contains_ip(&source) { - if let Some(ip_entry) = self.traffic_ips.iter_mut().find(|ie| ie.ip == source) { - ip_entry.upload += length as f64; - } + if let Some(entry) = self.traffic_map.get_mut(&source) { + entry.upload += length as f64; } else { - self.traffic_ips.push(IPTraffic { + self.traffic_map.insert(source, IPTraffic { ip: source, download: 0.0, upload: length as f64, - hostname: lookup_addr(&source).unwrap_or(String::from("unknown")), + hostname: String::new(), // Will be filled asynchronously }); + new_ips.push(source); } - self.traffic_ips.sort_by(|a, b| { - let a_sum = a.download + a.upload; - let b_sum = b.download + b.upload; - b_sum.partial_cmp(&a_sum).unwrap() - }); + // Mark cache as dirty - will be sorted on next render + self.cache_dirty = true; + + // Trigger background DNS lookups for new IPs + for ip in new_ips { + self.lookup_hostname_async(ip); + } + } + + fn lookup_hostname_async(&self, ip: IpAddr) { + if let Some(tx) = self.action_tx.clone() { + let dns_cache = self.dns_cache.clone(); + let ip_string = ip.to_string(); + tokio::spawn(async move { + let hostname = dns_cache.lookup_with_timeout(ip).await; + if !hostname.is_empty() { + let _ = tx.try_send(Action::DnsResolved(ip_string, hostname)); + } + }); + } + } + + /// Get sorted traffic list, updating cache if dirty + fn get_sorted_traffic(&mut self) -> &Vec { + if self.cache_dirty { + self.traffic_sorted_cache = self.traffic_map.values().cloned().collect(); + self.traffic_sorted_cache.sort_by(|a, b| { + let a_sum = a.download + a.upload; + let b_sum = b.download + b.upload; + b_sum.partial_cmp(&a_sum).unwrap_or(std::cmp::Ordering::Equal) + }); + self.cache_dirty = false; + } + &self.traffic_sorted_cache } fn process_packet(&mut self, packet: PacketsInfoTypesEnum) { @@ -134,7 +154,7 @@ impl Sniffer { } } - fn make_charts(&self) -> BarChart { + fn make_charts(&self) -> BarChart<'_> { BarChart::default() .direction(Direction::Vertical) .bar_width(12) @@ -155,7 +175,7 @@ impl Sniffer { ) } - fn make_ips_block(&self) -> Block { + fn make_ips_block(&self) -> Block<'_> { let ips_block = Block::default() .title( ratatui::widgets::block::Title::from(Line::from(vec![ @@ -187,7 +207,7 @@ impl Sniffer { ips_block } - fn make_sum_block(&self) -> Block { + fn make_sum_block(&self) -> Block<'_> { let ips_block = Block::default() .title( ratatui::widgets::block::Title::from(Span::styled( @@ -203,7 +223,7 @@ impl Sniffer { ips_block } - fn make_charts_block(&self) -> Block { + fn make_charts_block(&self) -> Block<'_> { Block::default() .title( ratatui::widgets::block::Title::from(Span::styled( @@ -219,10 +239,11 @@ impl Sniffer { } fn render_summary(&mut self, f: &mut Frame<'_>, area: Rect) { - if !self.traffic_ips.is_empty() { + let sorted_traffic = self.get_sorted_traffic().clone(); + if !sorted_traffic.is_empty() { let total_download = Line::from(vec![ "Total download: ".into(), - bytes_convert(self.traffic_ips[0].download).green(), + bytes_convert(sorted_traffic[0].download).green(), ]); f.render_widget( total_download, @@ -236,7 +257,7 @@ impl Sniffer { let total_upload = Line::from(vec![ "Total upload: ".into(), - bytes_convert(self.traffic_ips[0].upload).red(), + bytes_convert(sorted_traffic[0].upload).red(), ]); f.render_widget( total_upload, @@ -249,8 +270,7 @@ impl Sniffer { ); let a_intfs = &self.active_inft_ips; - let tu = self - .traffic_ips + let tu = sorted_traffic .iter() .filter(|item| { let t_ip = item.ip.to_string(); @@ -284,8 +304,7 @@ impl Sniffer { }, ); - let td = self - .traffic_ips + let td = sorted_traffic .iter() .filter(|item| { let t_ip = item.ip.to_string(); @@ -332,15 +351,15 @@ impl Component for Sniffer { self } - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } fn update(&mut self, action: Action) -> Result> { // -- tab change if let Action::TabChange(tab) = action { - self.tab_changed(tab).unwrap(); + self.tab_changed(tab)?; } if self.active_tab == TabsEnum::Traffic { @@ -357,14 +376,25 @@ impl Component for Sniffer { self.active_inft_ips = interface.ips.clone(); } - if let Action::PacketDump(time, packet, packet_type) = action { + if let Action::PacketDump(_time, ref packet, ref packet_type) = action { match packet_type { - PacketTypeEnum::Tcp => self.process_packet(packet), - PacketTypeEnum::Udp => self.process_packet(packet), + PacketTypeEnum::Tcp => self.process_packet(packet.clone()), + PacketTypeEnum::Udp => self.process_packet(packet.clone()), _ => {} } } + // -- DNS resolved + if let Action::DnsResolved(ref ip_str, ref hostname) = action { + if let Ok(ip) = ip_str.parse::() { + if let Some(entry) = self.traffic_map.get_mut(&ip) { + entry.hostname = hostname.clone(); + // Mark cache as dirty since hostname changed + self.cache_dirty = true; + } + } + } + Ok(None) } @@ -387,8 +417,9 @@ impl Component for Sniffer { width: ips_layout[0].width - 2, height: ips_layout[0].height - 2, }; + let sorted_traffic = self.get_sorted_traffic().clone(); let ips_scroll = TrafficScroll { - traffic_ips: self.traffic_ips.clone(), + traffic_ips: sorted_traffic, }; f.render_stateful_widget(ips_scroll, ips_rect, &mut self.scrollview_state); diff --git a/src/components/tabs.rs b/src/components/tabs.rs index 518ec50..57021c0 100644 --- a/src/components/tabs.rs +++ b/src/components/tabs.rs @@ -1,28 +1,25 @@ use color_eyre::eyre::Result; -use color_eyre::owo_colors::OwoColorize; -use crossterm::event::{KeyCode, KeyEvent}; use ratatui::style::Stylize; use ratatui::{prelude::*, widgets::*}; use ratatui::{ text::{Line, Span}, widgets::{block::Title, Paragraph}, }; -use serde::{Deserialize, Serialize}; use strum::{EnumCount, IntoEnumIterator}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::Sender; use super::{Component, Frame}; use crate::{ action::Action, config::DEFAULT_BORDER_STYLE, - config::{Config, KeyBindings}, + config::Config, enums::TabsEnum, layout::get_vertical_layout, }; #[derive(Default)] pub struct Tabs { - action_tx: Option>, + action_tx: Option>, config: Config, tab_index: usize, } @@ -36,7 +33,7 @@ impl Tabs { } } - fn make_tabs(&self) -> Paragraph { + fn make_tabs(&self) -> Paragraph<'_> { let enum_titles: Vec = TabsEnum::iter() .enumerate() @@ -84,15 +81,17 @@ impl Tabs { fn next_tab(&mut self) { self.tab_index = (self.tab_index + 1) % TabsEnum::COUNT; if let Some(ref action_tx) = self.action_tx { - let tab_enum = TabsEnum::iter().nth(self.tab_index).unwrap(); - action_tx.send(Action::TabChange(tab_enum)).unwrap(); + // Safe: tab_index is always < TabsEnum::COUNT + if let Some(tab_enum) = TabsEnum::iter().nth(self.tab_index) { + let _ = action_tx.try_send(Action::TabChange(tab_enum)); + } } } } impl Component for Tabs { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/components/title.rs b/src/components/title.rs index cdba9f3..01185e2 100644 --- a/src/components/title.rs +++ b/src/components/title.rs @@ -1,20 +1,17 @@ -use std::{collections::HashMap, time::Duration}; use color_eyre::eyre::Result; -use crossterm::event::{KeyCode, KeyEvent}; use ratatui::{prelude::*, widgets::*}; -use serde::{Deserialize, Serialize}; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::Sender; use super::{Component, Frame}; use crate::{ action::Action, - config::{Config, KeyBindings}, + config::Config, }; #[derive(Default)] pub struct Title { - command_tx: Option>, + command_tx: Option>, config: Config, } @@ -28,8 +25,8 @@ impl Title { } impl Component for Title { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.command_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.command_tx = Some(action_tx); Ok(()) } @@ -42,7 +39,7 @@ impl Component for Title { Ok(()) } - fn draw(&mut self, f: &mut Frame<'_>, area: Rect) -> Result<()> { + fn draw(&mut self, f: &mut Frame<'_>, _area: Rect) -> Result<()> { let rect = Rect::new(0, 0, f.area().width, 1); let version: &str = env!("CARGO_PKG_VERSION"); let title = format!(" Network Scanner (v{})", version); diff --git a/src/components/wifi_chart.rs b/src/components/wifi_chart.rs index cbac4db..5ce1214 100644 --- a/src/components/wifi_chart.rs +++ b/src/components/wifi_chart.rs @@ -2,12 +2,9 @@ use crate::components::wifi_scan::WifiInfo; use crate::utils::MaxSizeVec; use chrono::Timelike; use color_eyre::eyre::Result; -use pnet::datalink::{self, NetworkInterface}; use ratatui::{prelude::*, widgets::*}; -use std::collections::HashMap; -use std::process::{Command, Output}; use std::time::Instant; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::Sender; use super::Component; use crate::{ @@ -21,12 +18,14 @@ use crate::{ pub struct WifiDataset { ssid: String, data: MaxSizeVec<(f64, f64)>, + // Cache for rendering - converted from VecDeque to Vec + cached_data: Vec<(f64, f64)>, color: Color, } pub struct WifiChart { - action_tx: Option>, - last_update_time: Instant, + action_tx: Option>, + _last_update_time: Instant, wifi_datasets: Vec, signal_tick: [f64; 2], show_graph: bool, @@ -43,7 +42,7 @@ impl WifiChart { Self { show_graph: false, action_tx: None, - last_update_time: Instant::now(), + _last_update_time: Instant::now(), wifi_datasets: Vec::new(), signal_tick: [0.0, 40.0], } @@ -55,7 +54,7 @@ impl WifiChart { fn parse_char_data(&mut self, nets: &[WifiInfo]) { for w in nets { - let seconds: f64 = w.time.second() as f64; + let _seconds: f64 = w.time.second() as f64; if let Some(p) = self .wifi_datasets .iter_mut() @@ -63,11 +62,12 @@ impl WifiChart { { let n = &mut self.wifi_datasets[p]; let signal: f64 = w.signal as f64; - n.data.push((self.signal_tick[1], signal * -1.0)); + n.data.push((self.signal_tick[1], -signal)); } else { self.wifi_datasets.push(WifiDataset { ssid: w.ssid.clone(), data: MaxSizeVec::new(100), + cached_data: Vec::new(), color: w.color, }); } @@ -76,16 +76,20 @@ impl WifiChart { self.signal_tick[1] += 1.0; } - pub fn make_chart(&self) -> Chart { + pub fn make_chart(&mut self) -> Chart<'_> { + // First, update all cached data from VecDeque to Vec + for d in &mut self.wifi_datasets { + d.cached_data = d.data.get_vec(); + } + let mut datasets = Vec::new(); for d in &self.wifi_datasets { - let d_data = &d.data.get_vec(); let dataset = Dataset::default() .name(&*d.ssid) .marker(symbols::Marker::Dot) .style(Style::default().fg(d.color)) .graph_type(GraphType::Line) - .data(d_data); + .data(&d.cached_data); datasets.push(dataset); } @@ -148,8 +152,8 @@ impl WifiChart { } impl Component for WifiChart { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/components/wifi_interface.rs b/src/components/wifi_interface.rs index 6b19adb..669e074 100644 --- a/src/components/wifi_interface.rs +++ b/src/components/wifi_interface.rs @@ -1,16 +1,15 @@ use color_eyre::eyre::Result; -use pnet::datalink::{self, NetworkInterface}; +use pnet::datalink::{self}; use ratatui::{prelude::*, widgets::*}; use std::collections::HashMap; use std::process::{Command, Output}; use std::time::Instant; -use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::mpsc::Sender; use super::Component; use crate::{ action::Action, layout::{get_horizontal_layout, get_vertical_layout}, - mode::Mode, tui::Frame, }; @@ -25,11 +24,11 @@ struct WifiConn { } struct CommandError { - desc: String, + _desc: String, } pub struct WifiInterface { - action_tx: Option>, + action_tx: Option>, last_update: Instant, wifi_info: Option, } @@ -67,13 +66,13 @@ impl WifiInterface { .arg("info") .output() .map_err(|e| CommandError { - desc: format!("command failed: {}", e), + _desc: format!("command failed: {}", e), })?; if iw_output.status.success() { Ok(iw_output) } else { Err(CommandError { - desc: "command failed".to_string(), + _desc: "command failed".to_string(), }) } } @@ -131,20 +130,20 @@ impl WifiInterface { } } - fn make_list(&mut self) -> List { + fn make_list(&mut self) -> List<'_> { if let Some(wifi_info) = &self.wifi_info { - let interface = &wifi_info.interface; - let interface_label = "Interface:"; + let _interface = &wifi_info.interface; + let _interface_label = "Interface:"; let ssid = &wifi_info.ssid; let ssid_label = "SSID:"; - let ifindex = &wifi_info.ifindex; - let ifindex_label = "Intf index:"; + let _ifindex = &wifi_info.ifindex; + let _ifindex_label = "Intf index:"; let channel = &wifi_info.channel; let channel_label = "Channel:"; - let txpower = &wifi_info.txpower; - let txpower_label = "TxPower:"; - let mac = &wifi_info.mac; - let mac_label = "Mac addr:"; + let _txpower = &wifi_info.txpower; + let _txpower_label = "TxPower:"; + let _mac = &wifi_info.mac; + let _mac_label = "Mac addr:"; let mut items: Vec = Vec::new(); @@ -182,8 +181,8 @@ impl WifiInterface { } impl Component for WifiInterface { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/components/wifi_scan.rs b/src/components/wifi_scan.rs index 799d5e3..549620b 100644 --- a/src/components/wifi_scan.rs +++ b/src/components/wifi_scan.rs @@ -1,8 +1,6 @@ -use chrono::{DateTime, Local, Timelike}; -use config::Source; +use chrono::{DateTime, Local}; use std::time::Instant; -use tokio::sync::mpsc::UnboundedSender; -use tokio_wifiscanner::Wifi; +use tokio::sync::mpsc::Sender; use color_eyre::eyre::Result; use ratatui::{prelude::*, widgets::*}; @@ -12,7 +10,6 @@ use crate::{ action::Action, config::DEFAULT_BORDER_STYLE, layout::{get_horizontal_layout, get_vertical_layout}, - mode::Mode, tui::Frame, }; @@ -37,7 +34,7 @@ impl WifiInfo { } pub struct WifiScan { - pub action_tx: Option>, + pub action_tx: Option>, pub scan_start_time: Instant, pub wifis: Vec, pub signal_tick: [f64; 2], @@ -77,8 +74,8 @@ const COLORS_NAMES: [Color; 14] = [ Color::White, ]; -static MIN_DBM: f32 = -100.0; -static MAX_DBM: f32 = -1.0; +const MIN_DBM: f32 = -100.0; +const MAX_DBM: f32 = -1.0; impl WifiScan { pub fn new() -> Self { @@ -91,18 +88,17 @@ impl WifiScan { } } - fn make_table(&mut self) -> Table { + fn make_table(&mut self) -> Table<'_> { let header = Row::new(vec!["time", "ssid", "ch", "mac", "signal"]) .style(Style::default().fg(Color::Yellow)); // .bottom_margin(1); let mut rows = Vec::new(); for w in &self.wifis { - let s_clamp = w.signal.max(MIN_DBM).min(MAX_DBM); + let s_clamp = w.signal.clamp(MIN_DBM, MAX_DBM); let percent = ((s_clamp - MIN_DBM) / (MAX_DBM - MIN_DBM)).clamp(0.0, 1.0); let p = (percent * 10.0) as usize; - let gauge: String = std::iter::repeat(char::from_u32(0x25a8).unwrap_or('#')) - .take(p) + let gauge: String = std::iter::repeat_n(char::from_u32(0x25a8).unwrap_or('#'), p) .collect(); let signal = format!("({}){}", w.signal, gauge); @@ -165,7 +161,10 @@ impl WifiScan { } pub fn scan(&mut self) { - let tx = self.action_tx.clone().unwrap(); + let Some(tx) = self.action_tx.clone() else { + log::error!("Cannot scan WiFi: action channel not initialized"); + return; + }; tokio::spawn(async move { let networks = tokio_wifiscanner::scan().await; match networks { @@ -193,10 +192,10 @@ impl WifiScan { } } - let t_send = tx.send(Action::Scan(wifi_nets)); + let t_send = tx.try_send(Action::Scan(wifi_nets)); match t_send { - Ok(n) => (), - Err(e) => (), + Ok(_n) => (), + Err(_e) => (), } } Err(_e) => (), @@ -219,7 +218,7 @@ impl WifiScan { } // -- sort wifi networks by it's signal strength self.wifis - .sort_by(|a, b| b.signal.partial_cmp(&a.signal).unwrap()); + .sort_by(|a, b| b.signal.partial_cmp(&a.signal).unwrap_or(std::cmp::Ordering::Equal)); } fn app_tick(&mut self) -> Result<()> { @@ -239,8 +238,8 @@ impl WifiScan { } impl Component for WifiScan { - fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { - self.action_tx = Some(tx); + fn register_action_handler(&mut self, action_tx: Sender) -> Result<()> { + self.action_tx = Some(action_tx); Ok(()) } diff --git a/src/config.rs b/src/config.rs index 2951d48..5586d70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,15 +1,13 @@ -use std::{collections::HashMap, fmt, path::PathBuf}; +use std::{collections::HashMap, path::PathBuf}; use color_eyre::eyre::Result; -use config::Value; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use derive_deref::{Deref, DerefMut}; -use ratatui::{style::{Color, Modifier, Style}, widgets::{BorderType, Borders}}; +use ratatui::{style::{Color, Modifier, Style}, widgets::BorderType}; use serde::{ - de::{self, Deserializer, MapAccess, Visitor}, - Deserialize, Serialize, + de::Deserializer, + Deserialize, }; -use serde_json::Value as JsonValue; use crate::{action::Action, mode::Mode}; @@ -37,12 +35,15 @@ pub struct Config { impl Config { pub fn new() -> Result { - let default_config: Config = json5::from_str(CONFIG).unwrap(); + let default_config: Config = json5::from_str(CONFIG) + .expect("embedded default config should be valid JSON5"); let data_dir = crate::utils::get_data_dir(); let config_dir = crate::utils::get_config_dir(); let mut builder = config::Config::builder() - .set_default("_data_dir", data_dir.to_str().unwrap())? - .set_default("_config_dir", config_dir.to_str().unwrap())?; + .set_default("_data_dir", data_dir.to_str() + .ok_or_else(|| config::ConfigError::Message("data directory path is not valid UTF-8".to_string()))?)? + .set_default("_config_dir", config_dir.to_str() + .ok_or_else(|| config::ConfigError::Message("config directory path is not valid UTF-8".to_string()))?)?; let config_files = [ ("config.json5", config::FileFormat::Json5), @@ -59,7 +60,11 @@ impl Config { } } if !found_config { - log::error!("No configuration file found. Application may not behave as expected"); + log::warn!( + "No configuration file found in {:?}. Using default configuration. \ + Supported formats: config.json5, config.json, config.yaml, config.toml, config.ini", + config_dir + ); } let mut cfg: Self = builder.build()?.try_deserialize()?; @@ -73,7 +78,7 @@ impl Config { for (mode, default_styles) in default_config.styles.iter() { let user_styles = cfg.styles.entry(*mode).or_default(); for (style_key, style) in default_styles.iter() { - user_styles.entry(style_key.clone()).or_insert_with(|| style.clone()); + user_styles.entry(style_key.clone()).or_insert_with(|| *style); } } @@ -94,8 +99,18 @@ impl<'de> Deserialize<'de> for KeyBindings { let keybindings = parsed_map .into_iter() .map(|(mode, inner_map)| { - let converted_inner_map = - inner_map.into_iter().map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)).collect(); + let converted_inner_map = inner_map + .into_iter() + .filter_map(|(key_str, cmd)| { + match parse_key_sequence(&key_str) { + Ok(keys) => Some((keys, cmd)), + Err(e) => { + log::warn!("Invalid key binding '{}' in config: {}", key_str, e); + None + } + } + }) + .collect(); (mode, converted_inner_map) }) .collect(); @@ -171,7 +186,8 @@ fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Resu "minus" => KeyCode::Char('-'), "tab" => KeyCode::Tab, c if c.len() == 1 => { - let mut c = c.chars().next().unwrap(); + // Safe: we checked c.len() == 1 + let mut c = c.chars().next().expect("single character string"); if modifiers.contains(KeyModifiers::SHIFT) { c = c.to_ascii_uppercase(); } @@ -203,7 +219,7 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String { char = format!("f({c})"); &char }, - KeyCode::Char(c) if c == ' ' => "space", + KeyCode::Char(' ') => "space", KeyCode::Char(c) => { char = c.to_string(); &char @@ -431,7 +447,7 @@ mod tests { #[test] fn test_parse_color_rgb() { let color = parse_color("rgb123"); - let expected = 16 + 1 * 36 + 2 * 6 + 3; + let expected = 16 + 36 + 2 * 6 + 3; assert_eq!(color, Some(Color::Indexed(expected))); } @@ -441,16 +457,6 @@ mod tests { assert_eq!(color, None); } - // #[test] - // fn test_config() -> Result<()> { - // let c = Config::new()?; - // assert_eq!( - // c.keybindings.get(&Mode::Home).unwrap().get(&parse_key_sequence("").unwrap_or_default()).unwrap(), - // &Action::Quit - // ); - // Ok(()) - // } - #[test] fn test_simple_keys() { assert_eq!(parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); diff --git a/src/dns_cache.rs b/src/dns_cache.rs new file mode 100644 index 0000000..28cf858 --- /dev/null +++ b/src/dns_cache.rs @@ -0,0 +1,200 @@ +//! Thread-safe DNS caching with timeout and TTL support. +//! +//! This module provides [`DnsCache`], a high-performance DNS resolver with: +//! - **Timeout Protection**: 2-second limit per lookup to prevent blocking +//! - **LRU-style Caching**: Stores up to 1000 entries, evicting oldest on overflow +//! - **TTL Expiration**: Cached entries expire after 5 minutes +//! - **Thread Safety**: Safe to clone and share across async tasks +//! +//! # Performance Characteristics +//! +//! - **Cache Hit**: ~1 microsecond (mutex lock + HashMap lookup) +//! - **Cache Miss**: Up to 2 seconds (DNS lookup with timeout) +//! - **Memory**: ~100 bytes per cached entry +//! +//! # Usage Example +//! +//! ```rust +//! use std::net::IpAddr; +//! use netscanner::dns_cache::DnsCache; +//! +//! # async fn example() { +//! let cache = DnsCache::new(); +//! +//! // First lookup performs DNS query (slow) +//! let hostname = cache.lookup_with_timeout("8.8.8.8".parse().unwrap()).await; +//! +//! // Subsequent lookups use cache (fast) +//! let cached = cache.lookup_with_timeout("8.8.8.8".parse().unwrap()).await; +//! # } +//! ``` +//! +//! # Thread Safety +//! +//! `DnsCache` is designed to be cloned and shared across components: +//! - Cloning is cheap (only clones an `Arc`) +//! - All clones share the same underlying cache +//! - Mutex ensures thread-safe concurrent access + +use dns_lookup::lookup_addr; +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +/// Maximum time to wait for a DNS lookup before giving up. +/// Prevents slow/unresponsive DNS servers from blocking the UI. +const DNS_TIMEOUT: Duration = Duration::from_secs(2); + +/// Maximum number of cached DNS entries before eviction starts. +/// Using LRU eviction: oldest entry by timestamp is removed first. +const CACHE_SIZE: usize = 1000; + +/// Time-to-live for cached DNS entries. +/// After 5 minutes, entries are considered stale and will be re-queried. +const CACHE_TTL: Duration = Duration::from_secs(300); // 5 minutes + +/// Internal cache entry storing a hostname and its lookup timestamp. +#[derive(Clone, Debug)] +struct CacheEntry { + hostname: String, + timestamp: Instant, +} + +/// Thread-safe DNS cache with timeout and TTL support. +/// +/// This cache is designed for high-performance reverse DNS lookups in network +/// scanning scenarios where: +/// - Multiple concurrent lookups may occur +/// - DNS servers may be slow or unresponsive +/// - Many IPs are looked up repeatedly +/// +/// # Cloning +/// +/// Cloning is cheap and all clones share the same underlying cache via `Arc`. +/// This allows components to independently own a cache instance while sharing +/// the cached data. +#[derive(Clone)] +pub struct DnsCache { + cache: Arc>>, +} + +impl DnsCache { + /// Creates a new empty DNS cache. + /// + /// This is cheap to call multiple times - use [`clone()`](DnsCache::clone) + /// to share an existing cache across components. + pub fn new() -> Self { + Self { + cache: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Performs a reverse DNS lookup with timeout and caching. + /// + /// This is the recommended method for DNS lookups. It: + /// 1. Checks the cache for a recent result + /// 2. If not cached, performs a blocking DNS lookup in a separate task + /// 3. Times out after 2 seconds if DNS is slow/unavailable + /// 4. Caches the result (even if empty) to avoid repeated lookups + /// + /// # Arguments + /// + /// * `ip` - IP address to look up + /// + /// # Returns + /// + /// Returns the hostname as a String, or an empty String if: + /// - The lookup timed out + /// - No reverse DNS record exists + /// - DNS server is unavailable + /// + /// # Example + /// + /// ```rust + /// # use netscanner::dns_cache::DnsCache; + /// # async fn example() { + /// let cache = DnsCache::new(); + /// let hostname = cache.lookup_with_timeout("8.8.8.8".parse().unwrap()).await; + /// println!("8.8.8.8 resolved to: {}", hostname); + /// # } + /// ``` + pub async fn lookup_with_timeout(&self, ip: IpAddr) -> String { + // Check cache first + if let Some(hostname) = self.get_cached(&ip) { + return hostname; + } + + // Perform DNS lookup with timeout + let ip_for_task = ip; + let lookup_result = tokio::time::timeout(DNS_TIMEOUT, tokio::task::spawn_blocking(move || { + lookup_addr(&ip_for_task) + })) + .await; + + let hostname = match lookup_result { + Ok(Ok(Ok(name))) => name, + _ => String::new(), // Timeout, task error, or lookup error - return empty + }; + + // Cache the result (even if empty to avoid repeated lookups) + self.cache_result(ip, hostname.clone()); + + hostname + } + + /// Get cached hostname if available and not expired + fn get_cached(&self, ip: &IpAddr) -> Option { + if let Ok(cache) = self.cache.lock() { + if let Some(entry) = cache.get(ip) { + if entry.timestamp.elapsed() < CACHE_TTL { + return Some(entry.hostname.clone()); + } + } + } + None + } + + /// Cache a lookup result + fn cache_result(&self, ip: IpAddr, hostname: String) { + if let Ok(mut cache) = self.cache.lock() { + // Evict oldest entry if cache is full + if cache.len() >= CACHE_SIZE { + if let Some(oldest_ip) = cache + .iter() + .min_by_key(|(_, entry)| entry.timestamp) + .map(|(ip, _)| *ip) + { + cache.remove(&oldest_ip); + } + } + + cache.insert( + ip, + CacheEntry { + hostname, + timestamp: Instant::now(), + }, + ); + } + } + + /// Synchronous lookup without timeout (for compatibility, not recommended) + pub fn lookup_sync(&self, ip: IpAddr) -> String { + // Check cache first + if let Some(hostname) = self.get_cached(&ip) { + return hostname; + } + + // Perform lookup without timeout (fallback) + let hostname = lookup_addr(&ip).unwrap_or_default(); + self.cache_result(ip, hostname.clone()); + hostname + } +} + +impl Default for DnsCache { + fn default() -> Self { + Self::new() + } +} diff --git a/src/enums.rs b/src/enums.rs index 874152c..3a7686d 100644 --- a/src/enums.rs +++ b/src/enums.rs @@ -2,24 +2,43 @@ use crate::components::{discovery::ScannedIp, ports::ScannedIpPorts}; use chrono::{DateTime, Local}; use pnet::{ packet::{ - arp::{ArpOperation, ArpOperations}, + arp::ArpOperation, icmp::IcmpType, icmpv6::Icmpv6Type, }, util::MacAddr, }; use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; use strum::{Display, EnumCount, EnumIter, FromRepr}; -#[derive(Debug, Clone, PartialEq)] +// ExportData uses Arc for memory-efficient sharing of potentially large packet collections. +// This avoids deep cloning when passing data to the export component - only Arc pointers +// are cloned, not the underlying data. This significantly reduces memory usage and latency +// during export operations, especially with thousands of packets. +#[derive(Debug, Clone)] pub struct ExportData { - pub scanned_ips: Vec, - pub scanned_ports: Vec, - pub arp_packets: Vec<(DateTime, PacketsInfoTypesEnum)>, - pub udp_packets: Vec<(DateTime, PacketsInfoTypesEnum)>, - pub tcp_packets: Vec<(DateTime, PacketsInfoTypesEnum)>, - pub icmp_packets: Vec<(DateTime, PacketsInfoTypesEnum)>, - pub icmp6_packets: Vec<(DateTime, PacketsInfoTypesEnum)>, + pub scanned_ips: Arc>, + pub scanned_ports: Arc>, + pub arp_packets: Arc, PacketsInfoTypesEnum)>>, + pub udp_packets: Arc, PacketsInfoTypesEnum)>>, + pub tcp_packets: Arc, PacketsInfoTypesEnum)>>, + pub icmp_packets: Arc, PacketsInfoTypesEnum)>>, + pub icmp6_packets: Arc, PacketsInfoTypesEnum)>>, +} + +// Manual PartialEq implementation for ExportData +// Compares the actual data inside the Arcs, not the Arc pointers themselves +impl PartialEq for ExportData { + fn eq(&self, other: &Self) -> bool { + self.scanned_ips.as_ref() == other.scanned_ips.as_ref() + && self.scanned_ports.as_ref() == other.scanned_ports.as_ref() + && self.arp_packets.as_ref() == other.arp_packets.as_ref() + && self.udp_packets.as_ref() == other.udp_packets.as_ref() + && self.tcp_packets.as_ref() == other.tcp_packets.as_ref() + && self.icmp_packets.as_ref() == other.icmp_packets.as_ref() + && self.icmp6_packets.as_ref() == other.icmp6_packets.as_ref() + } } #[derive(Debug, Clone, PartialEq)] diff --git a/src/layout.rs b/src/layout.rs index 04172ed..452a2f0 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -1,4 +1,4 @@ -use ratatui::{prelude::*, widgets::*}; +use ratatui::prelude::*; const VERTICAL_TOP_PERCENT: u16 = 40; const VERTICAL_BOTTOM_PERCENT: u16 = 60; diff --git a/src/main.rs b/src/main.rs index a23f007..c350c58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,65 @@ -#![allow(dead_code)] -#![allow(unused_imports)] -#![allow(unused_variables)] +//! Netscanner - A modern network scanner with TUI +//! +//! Netscanner is a terminal-based network scanning tool built with Rust that provides +//! real-time network discovery, packet capture, port scanning, and WiFi monitoring +//! capabilities through an interactive terminal user interface (TUI). +//! +//! # Features +//! +//! - **Network Discovery**: Scan local network segments to discover active hosts +//! - **Port Scanning**: Concurrent port scanning with service detection +//! - **Packet Capture**: Real-time packet analysis for ARP, TCP, UDP, ICMP protocols +//! - **WiFi Monitoring**: Scan and monitor nearby WiFi networks +//! - **Traffic Analysis**: Live network traffic visualization +//! - **Export Functionality**: Save scan results and packet captures +//! +//! # Architecture +//! +//! The application follows a component-based architecture built on an event-driven +//! messaging system: +//! +//! - **Action System** ([`action`]): All components communicate via a typed Action enum, +//! sent through bounded mpsc channels to prevent memory exhaustion +//! - **Component System** ([`components`]): UI elements implement the Component trait, +//! allowing them to handle events, update state, and render independently +//! - **TUI Layer** ([`tui`]): Manages terminal I/O, event loops, and rendering using ratatui +//! - **Application Core** ([`app`]): Coordinates components, routes actions, and manages +//! the main event loop +//! +//! # Privilege Requirements +//! +//! Many network operations require elevated privileges: +//! - **Linux**: Run with `sudo` or use capabilities: `sudo setcap cap_net_raw,cap_net_admin=eip` +//! - **macOS**: Run with `sudo` +//! - **Windows**: Run as Administrator +//! +//! The application will warn but not exit if privileges are insufficient, allowing +//! partial functionality. +//! +//! # Usage Example +//! +//! ```bash +//! # Run with default settings +//! sudo netscanner +//! +//! # Customize tick and frame rates +//! sudo netscanner --tick-rate 2.0 --frame-rate 30.0 +//! ``` +//! +//! # Error Handling +//! +//! The application uses [`color_eyre`] for enhanced error reporting with backtraces +//! and context. Panics are caught and reported through a custom panic handler that +//! provides diagnostic information. pub mod action; pub mod app; pub mod cli; pub mod components; pub mod config; +pub mod dns_cache; pub mod mode; +pub mod privilege; pub mod tui; pub mod utils; pub mod enums; @@ -20,14 +72,43 @@ use color_eyre::eyre::Result; use crate::{ app::App, - utils::{initialize_logging, initialize_panic_handler, version}, + utils::{initialize_logging, initialize_panic_handler}, }; +/// Main async entry point for the netscanner application. +/// +/// This function initializes the application infrastructure and runs the main event loop: +/// +/// 1. **Logging Setup**: Configures the logging system for diagnostics +/// 2. **Panic Handler**: Installs a custom panic handler for better error reporting +/// 3. **Privilege Check**: Warns if the application lacks network privileges (non-fatal) +/// 4. **CLI Parsing**: Parses command-line arguments for tick/frame rates +/// 5. **Application Run**: Creates and runs the main application +/// +/// # Errors +/// +/// Returns an error if: +/// - Logging or panic handler initialization fails +/// - Application creation fails (e.g., unable to create TUI) +/// - Application runtime encounters a fatal error +/// +/// # Privilege Warning +/// +/// The application will warn but not exit if network privileges are insufficient. +/// This allows partial functionality (e.g., viewing WiFi info without packet capture). async fn tokio_main() -> Result<()> { initialize_logging()?; initialize_panic_handler()?; + // Warn if not running with privileges (non-fatal, operations will fail with better errors) + if !privilege::has_network_privileges() { + eprintln!("WARNING: Running without elevated privileges."); + eprintln!("Some network operations may fail."); + eprintln!("For full functionality, run with sudo or set appropriate capabilities."); + eprintln!(); + } + let args = Cli::parse(); let mut app = App::new(args.tick_rate, args.frame_rate)?; app.run().await?; @@ -35,6 +116,16 @@ async fn tokio_main() -> Result<()> { Ok(()) } +/// Application entry point with Tokio async runtime. +/// +/// This is the main entry point that creates the Tokio runtime and executes +/// the async application logic. It catches and reports any errors that occur +/// during application execution. +/// +/// # Errors +/// +/// Propagates errors from [`tokio_main`], displaying a user-friendly error +/// message before returning the error for process exit code handling. #[tokio::main] async fn main() -> Result<()> { if let Err(e) = tokio_main().await { diff --git a/src/mode.rs b/src/mode.rs index 5a5e4db..f23c40a 100644 --- a/src/mode.rs +++ b/src/mode.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use ratatui::style::Color; + #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Mode { diff --git a/src/privilege.rs b/src/privilege.rs new file mode 100644 index 0000000..304f5e2 --- /dev/null +++ b/src/privilege.rs @@ -0,0 +1,263 @@ +//! Privilege checking and user-friendly error reporting for network operations. +//! +//! Network scanning requires elevated privileges for raw socket access. This module +//! provides utilities to: +//! - Check if the process has sufficient privileges +//! - Generate platform-specific error messages with clear instructions +//! - Diagnose permission-related failures +//! +//! # Platform Support +//! +//! ## Unix (Linux, macOS, BSD) +//! - Checks if effective user ID (euid) is 0 (root) +//! - Provides instructions for `sudo` or capabilities (Linux) +//! +//! ## Windows +//! - Assumes privileges are available (checked at operation time) +//! - Provides instructions for "Run as Administrator" +//! +//! # Usage Pattern +//! +//! ```rust +//! use netscanner::privilege; +//! +//! // Warn early but allow partial functionality +//! if !privilege::has_network_privileges() { +//! eprintln!("WARNING: Running without elevated privileges."); +//! eprintln!("Some network operations may fail."); +//! } +//! +//! // Later, when an operation fails: +//! # let error = std::io::Error::from(std::io::ErrorKind::PermissionDenied); +//! if privilege::is_permission_error(&error) { +//! eprintln!("{}", privilege::get_privilege_error_message()); +//! } +//! ``` +//! +//! # Design Philosophy +//! +//! The application uses a **warn but don't exit** approach: +//! - Checks privileges at startup and warns if insufficient +//! - Allows the application to run with reduced functionality +//! - Operations that require privileges fail with helpful error messages +//! +//! This enables users to explore the UI even without root, and makes it +//! clear which operations require elevation. + +use std::io; + +/// Checks if the current process has sufficient privileges for raw network operations. +/// +/// Raw network operations (packet capture, raw sockets) require elevated privileges: +/// - **Unix**: Requires root (euid = 0) or specific capabilities +/// - **Windows**: Requires Administrator privileges (checked at operation time) +/// +/// # Returns +/// +/// - `true` if privileges are sufficient +/// - `false` if privileges are insufficient (Unix only) +/// +/// # Platform Behavior +/// +/// ## Unix +/// Returns `true` if the effective user ID is 0 (root). This covers both: +/// - Running with `sudo` +/// - Binary with setuid bit set +/// - Process with CAP_NET_RAW/CAP_NET_ADMIN capabilities +/// +/// ## Windows +/// Always returns `true` because privilege checking requires complex Win32 API calls. +/// Actual privilege verification happens when operations are attempted. +/// +/// # Example +/// +/// ```rust +/// use netscanner::privilege; +/// +/// if !privilege::has_network_privileges() { +/// eprintln!("Warning: Running without elevated privileges"); +/// } +/// ``` +#[cfg(unix)] +pub fn has_network_privileges() -> bool { + unsafe { libc::geteuid() == 0 } +} + +/// Windows implementation of privilege checking. +/// +/// Always returns `true` to allow the application to start. Actual permission +/// errors will be caught when operations are attempted, with descriptive messages. +#[cfg(windows)] +pub fn has_network_privileges() -> bool { + // On Windows, we can't easily check at runtime, so we assume true + // and let the operation fail with proper error message + true +} + +/// Generates a platform-specific error message for privilege-related failures. +/// +/// This provides users with clear, actionable instructions for running the +/// application with sufficient privileges. +/// +/// # Returns +/// +/// A multi-line formatted string with: +/// - Explanation of the problem +/// - Platform-specific instructions (sudo, setcap, Run as Administrator) +/// - Security notes where applicable +/// +/// # Example Output (Linux) +/// +/// ```text +/// Insufficient privileges for network operations. +/// +/// This application requires raw socket access for network scanning. +/// +/// Please run with elevated privileges: +/// - Using sudo: sudo netscanner [args] +/// - Or set capabilities: sudo setcap cap_net_raw,cap_net_admin+eip /path/to/netscanner +/// +/// Note: Setting capabilities is more secure than using sudo. +/// ``` +pub fn get_privilege_error_message() -> String { + #[cfg(unix)] + { + let os = std::env::consts::OS; + match os { + "linux" => { + format!( + "Insufficient privileges for network operations.\n\ + \n\ + This application requires raw socket access for network scanning.\n\ + \n\ + Please run with elevated privileges:\n\ + - Using sudo: sudo {} [args]\n\ + - Or set capabilities: sudo setcap cap_net_raw,cap_net_admin+eip {}\n\ + \n\ + Note: Setting capabilities is more secure than using sudo.", + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_else(|| "netscanner".to_string()), + std::env::current_exe() + .ok() + .and_then(|p| p.to_str().map(String::from)) + .unwrap_or_else(|| "/path/to/netscanner".to_string()) + ) + } + "macos" => { + format!( + "Insufficient privileges for network operations.\n\ + \n\ + This application requires raw socket access for network scanning.\n\ + \n\ + Please run with elevated privileges:\n\ + - Using sudo: sudo {} [args]\n\ + \n\ + On macOS, raw socket access requires root privileges.", + std::env::current_exe() + .ok() + .and_then(|p| p.file_name().map(|s| s.to_string_lossy().to_string())) + .unwrap_or_else(|| "netscanner".to_string()) + ) + } + _ => { + "Insufficient privileges for network operations.\n\ + \n\ + This application requires raw socket access for network scanning.\n\ + Please run with elevated privileges (e.g., sudo).".to_string() + } + } + } + + #[cfg(windows)] + { + format!( + "Insufficient privileges for network operations.\n\ + \n\ + This application requires administrative privileges for network scanning.\n\ + \n\ + Please run with elevated privileges:\n\ + - Right-click on the application and select 'Run as administrator'\n\ + - Or run from an elevated command prompt/PowerShell" + ) + } +} + +/// Checks if an IO error is due to insufficient privileges. +/// +/// This is a simple wrapper around checking for `PermissionDenied` error kind, +/// useful for determining if an error should trigger privilege-related help. +/// +/// # Arguments +/// +/// * `error` - The IO error to check +/// +/// # Returns +/// +/// `true` if the error is `ErrorKind::PermissionDenied`, `false` otherwise +/// +/// # Example +/// +/// ```rust +/// use netscanner::privilege; +/// use std::io; +/// +/// let error = io::Error::from(io::ErrorKind::PermissionDenied); +/// assert!(privilege::is_permission_error(&error)); +/// +/// if privilege::is_permission_error(&error) { +/// println!("{}", privilege::get_privilege_error_message()); +/// } +/// ``` +pub fn is_permission_error(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::PermissionDenied +} + +/// Generates a descriptive error message for datalink channel creation failures. +/// +/// This provides context-specific error messages for the common failure case +/// of creating packet capture channels. It distinguishes between permission +/// errors and other failures. +/// +/// # Arguments +/// +/// * `error` - The IO error that occurred +/// * `interface_name` - Name of the network interface that failed +/// +/// # Returns +/// +/// A formatted error message with: +/// - The specific interface name +/// - The underlying error details +/// - Possible causes and solutions +/// - Privilege instructions if it's a permission error +/// +/// # Example +/// +/// ```rust +/// use netscanner::privilege; +/// use std::io; +/// +/// let error = io::Error::from(io::ErrorKind::PermissionDenied); +/// let message = privilege::get_datalink_error_message(&error, "eth0"); +/// eprintln!("{}", message); +/// ``` +pub fn get_datalink_error_message(error: &io::Error, interface_name: &str) -> String { + if is_permission_error(error) { + get_privilege_error_message() + } else { + format!( + "Failed to create datalink channel on interface '{}'.\n\ + \n\ + Error: {}\n\ + \n\ + Possible causes:\n\ + - Interface may not exist or be down\n\ + - Insufficient privileges (see --help for privilege requirements)\n\ + - Another application may be using the interface\n\ + - Interface may not support the requested mode", + interface_name, error + ) + } +} diff --git a/src/tui.rs b/src/tui.rs index 6a0589b..baa31b4 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -1,3 +1,66 @@ +//! Terminal User Interface (TUI) management module. +//! +//! This module provides the [`Tui`] struct, which manages all terminal I/O operations, +//! event collection, and rendering coordination. It acts as the bridge between the +//! raw terminal and the application's event loop. +//! +//! # Architecture +//! +//! The TUI layer uses **ratatui** for rendering and **crossterm** for terminal control. +//! It runs two concurrent loops: +//! +//! 1. **Event Collection Loop**: Captures keyboard, mouse, and resize events +//! 2. **Timer Loops**: Generate Tick (logic updates) and Render (draw) events +//! +//! ```text +//! ┌────────────────────────────────────────────────────┐ +//! │ Tui Manager │ +//! │ │ +//! │ ┌──────────────────────────────────────────────┐ │ +//! │ │ Event Collection Task │ │ +//! │ │ ┌────────────┐ ┌────────────┐ │ │ +//! │ │ │ Crossterm │ │ Timers │ │ │ +//! │ │ │ Events │ │ Tick/Render│ │ │ +//! │ │ └─────┬──────┘ └─────┬──────┘ │ │ +//! │ │ │ │ │ │ +//! │ │ └────────┬───────┘ │ │ +//! │ │ ▼ │ │ +//! │ │ event_tx (mpsc) │ │ +//! │ └──────────────────┬───────────────────────────┘ │ +//! │ │ │ +//! │ ▼ │ +//! │ event_rx (mpsc) │ +//! │ │ │ +//! │ ▼ │ +//! │ App Event Loop │ +//! └────────────────────────────────────────────────────┘ +//! ``` +//! +//! # Event Types +//! +//! The [`Event`] enum represents all possible terminal events: +//! - **Key**: Keyboard input (only KeyPress events, not release) +//! - **Mouse**: Mouse movements and clicks +//! - **Resize**: Terminal size changes +//! - **Tick**: Logic update signal (rate-limited) +//! - **Render**: Draw signal (rate-limited) +//! - **Paste**: Bracketed paste events +//! - **Focus**: Terminal focus gained/lost +//! +//! # Bounded Channels +//! +//! The TUI uses a **bounded channel with capacity 100** for events. This prevents +//! memory exhaustion during event bursts (e.g., window resize storms). If the +//! buffer fills, events are silently dropped using `try_send`. +//! +//! # Graceful Shutdown +//! +//! The TUI implements proper cleanup via [`Drop`]: +//! - Cancels the event collection task +//! - Restores terminal to normal mode +//! - Shows cursor and exits alternate screen +//! - Handles cleanup errors gracefully + use std::{ ops::{Deref, DerefMut}, time::Duration, @@ -16,39 +79,76 @@ use futures::{FutureExt, StreamExt}; use ratatui::backend::CrosstermBackend as Backend; use serde::{Deserialize, Serialize}; use tokio::{ - sync::mpsc::{self, UnboundedReceiver, UnboundedSender}, + sync::mpsc::{self, Receiver, Sender}, task::JoinHandle, }; use tokio_util::sync::CancellationToken; +/// Type alias for stdout used as the terminal backend. pub type IO = std::io::Stdout; + +/// Returns a handle to stdout for terminal operations. pub fn io() -> IO { std::io::stdout() } + +/// Type alias for ratatui's Frame type used in rendering. pub type Frame<'a> = ratatui::Frame<'a>; +/// Terminal events that can be received by the application. +/// +/// These events are generated by the TUI's event collection task and sent +/// to the application's event loop for processing. #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Event { + /// Initial event sent when the TUI starts Init, + /// Request to quit the application Quit, + /// An error occurred in event processing Error, + /// The terminal/event stream was closed Closed, + /// Logic update tick (rate-limited by tick_rate) Tick, + /// Render update signal (rate-limited by frame_rate) Render, + /// Terminal gained focus FocusGained, + /// Terminal lost focus FocusLost, + /// Bracketed paste event with pasted content Paste(String), + /// Keyboard event (only KeyPress, not release) Key(KeyEvent), + /// Mouse event (movement, clicks, scroll) Mouse(MouseEvent), + /// Terminal was resized to new dimensions (width, height) Resize(u16, u16), } +/// The Terminal User Interface coordinator. +/// +/// This struct manages the terminal state, event collection, and provides +/// an interface for the application to interact with the terminal. +/// +/// # Fields +/// +/// * `terminal` - The ratatui terminal instance for rendering +/// * `task` - Background task handling event collection +/// * `cancellation_token` - Token to signal task cancellation +/// * `event_rx` - Receiver for terminal events +/// * `event_tx` - Sender for terminal events (cloned to task) +/// * `frame_rate` - Render updates per second +/// * `tick_rate` - Logic updates per second +/// * `mouse` - Whether mouse capture is enabled +/// * `paste` - Whether bracketed paste is enabled pub struct Tui { pub terminal: ratatui::Terminal>, pub task: JoinHandle<()>, pub cancellation_token: CancellationToken, - pub event_rx: UnboundedReceiver, - pub event_tx: UnboundedSender, + pub event_rx: Receiver, + pub event_tx: Sender, pub frame_rate: f64, pub tick_rate: f64, pub mouse: bool, @@ -60,7 +160,9 @@ impl Tui { let tick_rate = 4.0; let frame_rate = 60.0; let terminal = ratatui::Terminal::new(Backend::new(io()))?; - let (event_tx, event_rx) = mpsc::unbounded_channel(); + // Use bounded channel with capacity of 100 for high-frequency UI events + // This prevents memory exhaustion during event bursts + let (event_tx, event_rx) = mpsc::channel(100); let cancellation_token = CancellationToken::new(); let task = tokio::spawn(async {}); let mouse = false; @@ -99,7 +201,10 @@ impl Tui { let mut reader = crossterm::event::EventStream::new(); let mut tick_interval = tokio::time::interval(tick_delay); let mut render_interval = tokio::time::interval(render_delay); - _event_tx.send(Event::Init).unwrap(); + // Send init event; if this fails, the receiver is already dropped + if _event_tx.try_send(Event::Init).is_err() { + return; + } loop { let tick_delay = tick_interval.tick(); let render_delay = render_interval.tick(); @@ -114,37 +219,38 @@ impl Tui { match evt { CrosstermEvent::Key(key) => { if key.kind == KeyEventKind::Press { - _event_tx.send(Event::Key(key)).unwrap(); + // Ignore send errors - channel may be full or receiver dropped + let _ = _event_tx.try_send(Event::Key(key)); } }, CrosstermEvent::Mouse(mouse) => { - _event_tx.send(Event::Mouse(mouse)).unwrap(); + let _ = _event_tx.try_send(Event::Mouse(mouse)); }, CrosstermEvent::Resize(x, y) => { - _event_tx.send(Event::Resize(x, y)).unwrap(); + let _ = _event_tx.try_send(Event::Resize(x, y)); }, CrosstermEvent::FocusLost => { - _event_tx.send(Event::FocusLost).unwrap(); + let _ = _event_tx.try_send(Event::FocusLost); }, CrosstermEvent::FocusGained => { - _event_tx.send(Event::FocusGained).unwrap(); + let _ = _event_tx.try_send(Event::FocusGained); }, CrosstermEvent::Paste(s) => { - _event_tx.send(Event::Paste(s)).unwrap(); + let _ = _event_tx.try_send(Event::Paste(s)); }, } } Some(Err(_)) => { - _event_tx.send(Event::Error).unwrap(); + let _ = _event_tx.try_send(Event::Error); } None => {}, } }, _ = tick_delay => { - _event_tx.send(Event::Tick).unwrap(); + let _ = _event_tx.try_send(Event::Tick); }, _ = render_delay => { - _event_tx.send(Event::Render).unwrap(); + let _ = _event_tx.try_send(Event::Render); }, } } @@ -161,7 +267,10 @@ impl Tui { self.task.abort(); } if counter > 100 { - log::error!("Failed to abort task in 100 milliseconds for unknown reason"); + log::error!( + "TUI event task did not stop gracefully within 100ms timeout. \ + This may indicate the event loop is blocked or unresponsive." + ); break; } } @@ -234,6 +343,8 @@ impl DerefMut for Tui { impl Drop for Tui { fn drop(&mut self) { - self.exit().unwrap(); + if let Err(e) = self.exit() { + eprintln!("Error during TUI cleanup: {}", e); + } } } diff --git a/src/utils.rs b/src/utils.rs index 8f578da..76a81d5 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -1,12 +1,13 @@ use std::cmp; +use std::collections::VecDeque; use std::path::PathBuf; use cidr::Ipv4Cidr; use color_eyre::eyre::Result; use directories::ProjectDirs; -use human_panic::metadata; +use ipnetwork::Ipv6Network; use lazy_static::lazy_static; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, Ipv6Addr}; use tracing::error; use tracing_error::ErrorLayer; use tracing_subscriber::{ @@ -15,7 +16,7 @@ use tracing_subscriber::{ use crate::components::sniff::IPTraffic; -pub static GIT_COMMIT_HASH: &'static str = env!("_GIT_INFO"); +pub static GIT_COMMIT_HASH: &str = env!("_GIT_INFO"); lazy_static! { pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); @@ -43,10 +44,38 @@ pub fn get_ips4_from_cidr(cidr: Ipv4Cidr) -> Vec { ips } +pub fn get_ips6_from_cidr(cidr: Ipv6Network) -> Vec { + let mut ips = Vec::new(); + let prefix = cidr.prefix(); + + if prefix < 120 { + log::warn!("IPv6 CIDR /{} is too large for complete scan, sampling addresses", prefix); + return ips; + } + + for ip in cidr.iter() { + ips.push(ip); + } + ips +} + pub fn count_ipv4_net_length(net_length: u32) -> u32 { 2u32.pow(32 - net_length) } +pub fn count_ipv6_net_length(net_length: u32) -> u64 { + if net_length > 128 { + log::error!("Invalid IPv6 prefix length: {}, must be 0-128", net_length); + return 0; + } + + if net_length >= 64 { + 2u64.pow((128 - net_length).min(63)) + } else { + u64::MAX + } +} + pub fn count_traffic_total(traffic: &[IPTraffic]) -> (f64, f64) { let mut download = 0.0; let mut upload = 0.0; @@ -59,27 +88,34 @@ pub fn count_traffic_total(traffic: &[IPTraffic]) -> (f64, f64) { #[derive(Clone, Debug)] pub struct MaxSizeVec { - p_vec: Vec, + deque: VecDeque, max_len: usize, } impl MaxSizeVec { pub fn new(max_len: usize) -> Self { Self { - p_vec: Vec::with_capacity(max_len), + deque: VecDeque::with_capacity(max_len), max_len, } } pub fn push(&mut self, item: T) { - if self.p_vec.len() >= self.max_len { - self.p_vec.pop(); + if self.deque.len() >= self.max_len { + self.deque.pop_back(); } - self.p_vec.insert(0, item); + self.deque.push_front(item); + } + + pub fn get_deque(&self) -> &VecDeque { + &self.deque } - pub fn get_vec(&self) -> &Vec { - &self.p_vec + pub fn get_vec(&self) -> Vec + where + T: Clone, + { + self.deque.iter().cloned().collect() } } @@ -96,7 +132,7 @@ pub fn bytes_convert(num: f64) -> String { ); let pretty_bytes = format!("{:.2}", num / delimiter.powi(exponent)) .parse::() - .unwrap() + .unwrap_or(0.0) * 1_f64; let unit = units[exponent as usize]; format!("{}{}", pretty_bytes, unit) @@ -122,24 +158,22 @@ pub fn initialize_panic_handler() -> Result<()> { #[cfg(not(debug_assertions))] { - use human_panic::{handle_dump, print_msg, Metadata}; + use human_panic::{handle_dump, print_msg, metadata}; let meta = metadata!() .authors("Chleba ") .homepage("https://github.com/Chleba/netscanner") .support("https://github.com/Chleba/netscanner/issues"); let file_path = handle_dump(&meta, panic_info); - // prints human-panic message print_msg(file_path, &meta) .expect("human-panic: printing error message to console failed"); - eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr + eprintln!("{}", panic_hook.panic_report(panic_info)); } let msg = format!("{}", panic_hook.panic_report(panic_info)); log::error!("Error: {}", strip_ansi_escapes::strip_str(msg)); #[cfg(debug_assertions)] { - // Better Panic stacktrace that is only enabled when debugging. better_panic::Settings::auto() .most_recent_first(false) .lineno_suffix(true) @@ -176,13 +210,13 @@ pub fn get_config_dir() -> PathBuf { pub fn initialize_logging() -> Result<()> { let directory = get_data_dir(); - std::fs::create_dir_all(directory.clone())?; - let log_path = directory.join(LOG_FILE.clone()); + std::fs::create_dir_all(&directory)?; + let log_path = directory.join(LOG_FILE.as_str()); let log_file = std::fs::File::create(log_path)?; std::env::set_var( "RUST_LOG", std::env::var("RUST_LOG") - .or_else(|_| std::env::var(LOG_ENV.clone())) + .or_else(|_| std::env::var(LOG_ENV.as_str())) .unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))), ); let file_subscriber = tracing_subscriber::fmt::layer() diff --git a/src/widgets/scroll_traffic.rs b/src/widgets/scroll_traffic.rs index 01e2441..82df34b 100644 --- a/src/widgets/scroll_traffic.rs +++ b/src/widgets/scroll_traffic.rs @@ -1,6 +1,5 @@ use crate::components::sniff::IPTraffic; use crate::utils::{bytes_convert, count_traffic_total}; -use color_eyre::owo_colors::OwoColorize; use ratatui::style::Stylize; use ratatui::{layout::Size, prelude::*, widgets::*}; use tui_scrollview::{ScrollView, ScrollViewState};