use anyhow::Result; use log::{LevelFilter, error, info, warn}; use ratatui::prelude::CrosstermBackend; use rustnet_monitor::{app, cli, network, ui}; use simplelog::{ConfigBuilder, WriteLogger}; use std::fs; use std::io; use std::path::Path; use std::time::Duration; fn main() -> Result<()> { // Check for required dependencies on Windows #[cfg(target_os = "windows")] check_windows_dependencies()?; // Parse command line arguments let matches = cli::build_cli().get_matches(); // Set up logging only if log-level was provided if let Some(log_level_str) = matches.get_one::("log-level") { let log_level = log_level_str .parse::() .map_err(|_| anyhow::anyhow!("Invalid log level: {}", log_level_str))?; setup_logging(log_level)?; } // Check privileges BEFORE initializing TUI (so error messages are visible) check_privileges_early()?; // Build configuration from command line arguments let mut config = app::Config::default(); if let Some(interface) = matches.get_one::("interface") { config.interface = Some(interface.to_string()); info!("Using interface: {}", interface); } if matches.get_flag("no-localhost") { config.filter_localhost = true; info!("Filtering localhost connections"); } if matches.get_flag("show-localhost") { config.filter_localhost = false; info!("Showing localhost connections"); } if let Some(interval) = matches.get_one::("refresh-interval") { config.refresh_interval = *interval; info!("Using refresh interval: {}ms", interval); } if matches.get_flag("no-dpi") { config.enable_dpi = false; info!("Deep packet inspection disabled"); } if let Some(json_log_path) = matches.get_one::("json-log") { config.json_log_file = Some(json_log_path.to_string()); info!("JSON logging enabled: {}", json_log_path); } if let Some(pcap_path) = matches.get_one::("pcap-export") { config.pcap_export_file = Some(pcap_path.to_string()); info!("PCAP export enabled: {}", pcap_path); } if let Some(pcapng_path) = matches.get_one::("pcapng-export") { config.pcapng_export_file = Some(pcapng_path.to_string()); info!("PCAPNG export enabled: {}", pcapng_path); } if let Some(bpf_filter) = matches.get_one::("bpf-filter") { let filter = bpf_filter.trim(); if !filter.is_empty() { config.bpf_filter = Some(filter.to_string()); info!("Using BPF filter: {}", filter); } } if matches.get_flag("no-resolve-dns") { config.resolve_dns = false; info!("Reverse DNS resolution disabled"); } if matches.get_flag("show-ptr-lookups") { config.show_ptr_lookups = true; info!("PTR lookup connections will be shown in UI"); } // Check NO_COLOR environment variable and --no-color flag (https://no-color.org) let no_color = matches.get_flag("no-color") || std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()); if no_color { info!("Colors disabled (NO_COLOR)"); ui::set_no_color(true); } // Color theme preset let theme_preset = match matches.get_one::("theme").map(String::as_str) { Some("classic") => ui::ThemePreset::Classic, _ => ui::ThemePreset::Muted, }; info!("Using {theme_preset:?} color theme"); ui::set_theme_preset(theme_preset); // GeoIP configuration if matches.get_flag("no-geoip") { config.disable_geoip = true; info!("GeoIP lookups disabled"); } if let Some(country_path) = matches.get_one::("geoip-country") { config.geoip_country_path = Some(country_path.to_string()); info!("Using GeoIP Country database: {}", country_path); } if let Some(asn_path) = matches.get_one::("geoip-asn") { config.geoip_asn_path = Some(asn_path.to_string()); info!("Using GeoIP ASN database: {}", asn_path); } if let Some(city_path) = matches.get_one::("geoip-city") { config.geoip_city_path = Some(city_path.to_string()); info!("Using GeoIP City database: {}", city_path); } // Kubernetes pod/container attribution mode (values validated by clap) #[cfg(feature = "kubernetes")] if let Some(mode) = matches.get_one::("kubernetes") && let Some(parsed) = network::kubernetes::KubernetesMode::parse(mode) { config.kubernetes_mode = parsed; info!("Kubernetes attribution mode: {}", mode); } // Resolve the identity to drop root to after privileged init (Linux, // macOS, and FreeBSD): the invoking sudo user, or nobody when started as // plain root. // Resolved before the export files are pre-created so they can be chowned // to the target user: they are created root-owned 0600 and are reopened by // path at runtime (libpcap savefile, sidecar JSONL appends), which would // fail after the drop if they stayed owned by root. #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] let uid_drop_target = if matches.get_flag("no-uid-drop") { info!("Root uid drop disabled by --no-uid-drop"); None } else { network::platform::privdrop::resolve_drop_target() }; // Pre-create the PCAP export file and its sidecar JSONL (needed for Landlock // permissions). This must be done BEFORE the sandbox is applied so the files // exist when adding rules: Landlock requires an open FD to scope a rule to a // file, so a not-yet-existing path falls back to granting write on the whole // parent directory. Pre-creating keeps the write rule file-scoped. The PCAP // writer later reopens the path with truncation, so a zero-byte file is fine. // // Done before terminal setup: pre-creation can fail hard (see below), and we // want the error to print to a normal terminal rather than into the TUI // alt-screen (which would also leave the terminal in raw mode). if let Some(ref pcap_path) = config.pcap_export_file { let jsonl_path = format!("{}.connections.jsonl", pcap_path); for (label, path) in [("PCAP", pcap_path.as_str()), ("sidecar JSONL", &jsonl_path)] { // Fail hard rather than continue: if we can't safely create the file // (e.g. the path is a symlink, rejected by O_NOFOLLOW), aborting now // is the only way the protection is meaningful. The PCAP itself is // later written by libpcap's pcap_dump_open, which does NOT honor // O_NOFOLLOW, so a warn-and-continue here would let libpcap follow an // attacker-controlled symlink and write the capture there anyway. let file = precreate_private_file(path).map_err(|e| { anyhow::anyhow!("Failed to pre-create {} file '{}': {}", label, path, e) })?; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] chown_to_uid_drop_target(&file, uid_drop_target, label, path); #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "freebsd")))] let _ = file; } } let mut output_handles = app::AppOutputHandles::default(); if let Some(ref pcapng_path) = config.pcapng_export_file { let file = precreate_private_file(pcapng_path).map_err(|e| { anyhow::anyhow!("Failed to pre-create PCAPNG file '{}': {}", pcapng_path, e) })?; #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] chown_to_uid_drop_target(&file, uid_drop_target, "PCAPNG", pcapng_path); output_handles.pcapng_export = Some(file); } // Set up terminal let backend = CrosstermBackend::new(io::stdout()); let mut terminal = ui::setup_terminal(backend)?; info!("Terminal UI initialized"); // Create and start the application let mut app = app::App::new_with_output_handles(config.clone(), output_handles)?; let (process_ready_rx, capture_ready_rx) = app.start()?; info!("Application started"); // Wait for process detection (including eBPF loading) to complete before // applying the sandbox, which drops CAP_BPF and CAP_PERFMON. // Without this synchronization, the sandbox could drop these capabilities // before the background thread has finished loading eBPF programs. match process_ready_rx.recv_timeout(std::time::Duration::from_secs(10)) { Ok(()) => info!("Process detection initialized, safe to apply sandbox"), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { warn!("Timed out waiting for process detection init, applying sandbox anyway"); } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { warn!("Process detection thread exited early, applying sandbox anyway"); } } // Also wait for the capture thread to finish opening the capture device. // The open runs on a background thread and needs the startup privileges; // without this synchronization the uid drop (Linux/FreeBSD) or sandbox // could win the race and the open would fail with EPERM, leaving the UI // running with no traffic. match capture_ready_rx.recv_timeout(std::time::Duration::from_secs(10)) { Ok(()) => info!("Packet capture initialized, safe to apply sandbox"), Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { warn!("Timed out waiting for packet capture init, applying sandbox anyway"); } Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { warn!("Capture thread exited early, applying sandbox anyway"); } } // Apply Landlock sandbox (Linux only) // This must be done AFTER process detection is initialized because: // - eBPF programs need to be loaded first (requires CAP_BPF + CAP_PERFMON) // - Packet capture handles need to be opened first (access to /dev) // - Log files need to be created first #[cfg(target_os = "linux")] { use network::geoip::GeoIpResolver; use network::platform::sandbox::{ SandboxConfig, SandboxMode, SandboxStatus, apply_sandbox, }; use std::path::PathBuf; let sandbox_mode = if matches.get_flag("no-sandbox") { SandboxMode::Disabled } else if matches.get_flag("sandbox-strict") { SandboxMode::Strict } else { SandboxMode::BestEffort }; // Collect read paths (GeoIP databases). Exclude the bare current-directory // entry: a Landlock PathBeneath rule on "." grants recursive read access to // the entire CWD subtree (e.g. all of $HOME when rustnet is launched from // there), which defeats the point of the read-path whitelist. The concrete // GeoIP locations (resources/geoip2, XDG/system dirs) stay covered. #[cfg(not(feature = "kubernetes"))] let read_paths: Vec = GeoIpResolver::get_search_paths() .into_iter() .filter(|p| p.exists() && p.as_os_str() != ".") .collect(); // When Kubernetes attribution is enabled, the resolver also reads pod // and container names from the kubelet log directories. /proc is // already granted below for process lookup; these need explicit read // access or the periodic metadata refresh would be denied once Landlock // applies. #[cfg(feature = "kubernetes")] let read_paths: Vec = { let mut paths: Vec = GeoIpResolver::get_search_paths() .into_iter() .filter(|p| p.exists() && p.as_os_str() != ".") .collect(); if config.kubernetes_mode.enabled() { for dir in ["/var/log/containers", "/var/log/pods"] { let pb = PathBuf::from(dir); if pb.exists() { paths.push(pb); } } } paths }; let mut write_paths = Vec::new(); // Add logs directory if logging is enabled if matches.get_one::("log-level").is_some() { write_paths.push(PathBuf::from("logs")); } // Add JSON log path if specified if let Some(json_log_path) = &config.json_log_file { write_paths.push(PathBuf::from(json_log_path)); } // Add PCAP export paths if specified (both .pcap and .pcap.connections.jsonl) if let Some(pcap_path) = &config.pcap_export_file { write_paths.push(PathBuf::from(pcap_path)); write_paths.push(PathBuf::from(format!("{}.connections.jsonl", pcap_path))); } if let Some(pcapng_path) = &config.pcapng_export_file { write_paths.push(PathBuf::from(pcapng_path)); } let sandbox_config = SandboxConfig { mode: sandbox_mode, block_network: true, // RustNet is passive, doesn't need TCP read_paths, write_paths, drop_uid: uid_drop_target, }; match apply_sandbox(&sandbox_config) { Ok(result) => { // Update UI with sandbox status let status_str = match result.status { SandboxStatus::FullyEnforced => "Fully enforced", SandboxStatus::PartiallyEnforced => "Partially enforced", SandboxStatus::NotApplied => "Not applied", }; app.set_sandbox_info(app::SandboxInfo { status: status_str.to_string(), cap_dropped: result.cap_net_raw_dropped, ebpf_caps_dropped: result.ebpf_caps_dropped, uid_dropped: result.uid_dropped, landlock_available: result.landlock_available, fs_restricted: result.landlock_fs_applied, net_restricted: result.landlock_net_applied, scope_restricted: result.landlock_scope_applied, landlock_abi: result.landlock_effective_abi, no_new_privs: result.no_new_privs, }); } Err(e) => { if sandbox_mode == SandboxMode::Strict { return Err(e.context("Sandbox enforcement required but failed")); } warn!("Sandbox application error (non-strict mode): {}", e); app.set_sandbox_info(app::SandboxInfo { status: "Error".to_string(), cap_dropped: false, ebpf_caps_dropped: false, uid_dropped: false, landlock_available: false, fs_restricted: false, net_restricted: false, scope_restricted: false, landlock_abi: None, no_new_privs: false, }); } } } // Drop root privileges (macOS only). Done after process detection init // (capture fds are open, PKTAP is set up) and BEFORE Seatbelt, so the // profile does not need to allow the setuid/setgid syscalls. Compiled // without the macos-sandbox feature too; only the flag lookups depend on // the feature (the flags do not exist in non-sandbox builds). #[cfg(target_os = "macos")] let uid_dropped = { #[cfg(feature = "macos-sandbox")] let (skip, strict) = ( matches.get_flag("no-sandbox"), matches.get_flag("sandbox-strict"), ); #[cfg(not(feature = "macos-sandbox"))] let (skip, strict) = (false, false); match uid_drop_target { Some(target) if !skip => match network::platform::privdrop::drop_to(target) { Ok(()) => { info!( "Dropped root privileges to uid {} gid {} (verified); lsof-fallback \ process attribution is now limited to that user's processes (PKTAP \ attribution unaffected)", target.uid, target.gid ); true } Err(e) => { if strict { return Err(e.context("Strict mode requires the root uid drop to succeed")); } warn!("Failed to drop root uid/gid: {}", e); false } }, Some(_) => { info!("Root uid drop skipped (--no-sandbox)"); false } None => false, } }; #[cfg(all(target_os = "macos", not(feature = "macos-sandbox")))] let _ = uid_dropped; // Drop root privileges (FreeBSD only). Done after process detection init, // when the BPF capture fds are open and nothing needs root anymore. There // is no sandbox on FreeBSD yet (Capsicum is planned), so until then this // is the primary containment. #[cfg(target_os = "freebsd")] if let Some(target) = uid_drop_target { match network::platform::privdrop::drop_to(target) { Ok(()) => info!( "Dropped root privileges to uid {} gid {} (verified); sockstat process \ attribution is now limited to that user's processes", target.uid, target.gid ), Err(e) => warn!("Failed to drop root uid/gid: {}", e), } } // Apply Seatbelt sandbox (macOS only) // This must be done AFTER app.start() because: // - Packet capture handles need to be opened first (BPF/PKTAP fds survive the sandbox) // - Log files need to be created first #[cfg(all(target_os = "macos", feature = "macos-sandbox"))] { use network::platform::sandbox::{ SandboxConfig, SandboxMode, SandboxStatus, apply_sandbox, }; let sandbox_mode = if matches.get_flag("no-sandbox") { SandboxMode::Disabled } else if matches.get_flag("sandbox-strict") { SandboxMode::Strict } else { SandboxMode::BestEffort }; let log_dir = if matches.get_one::("log-level").is_some() { Some("logs".to_string()) } else { None }; // Collect GeoIP paths that may need read access through the sandbox. // User-specified paths take priority; otherwise include auto-discovery // search paths so the file-read deny on /Users doesn't block them. let geoip_paths: Vec = { use network::geoip::GeoIpResolver; let mut paths = Vec::new(); if let Some(ref p) = config.geoip_country_path { paths.push(p.clone()); } if let Some(ref p) = config.geoip_asn_path { paths.push(p.clone()); } if let Some(ref p) = config.geoip_city_path { paths.push(p.clone()); } if paths.is_empty() && !config.disable_geoip { // Use auto-discovery search paths (directories, not individual files) paths.extend( GeoIpResolver::get_search_paths() .into_iter() .filter(|p| p.exists()) .map(|p| p.to_string_lossy().into_owned()), ); } paths }; let sandbox_config = SandboxConfig { mode: sandbox_mode, block_network: true, // RustNet is passive, doesn't need TCP log_dir, json_log_path: config.json_log_file, pcap_export_path: config.pcap_export_file, pcapng_export_path: config.pcapng_export_file, geoip_paths, }; match apply_sandbox(&sandbox_config) { Ok(result) => { let status_str = match result.status { SandboxStatus::FullyEnforced => { info!("Seatbelt sandbox fully enforced: {}", result.message); "Fully enforced" } SandboxStatus::NotApplied => { warn!("Seatbelt sandbox not applied: {}", result.message); "Not applied" } }; app.set_sandbox_info(app::SandboxInfo { status: status_str.to_string(), seatbelt_applied: result.seatbelt_applied, fs_restricted: result.fs_restricted, net_restricted: result.net_blocked, uid_dropped, }); } Err(e) => { if sandbox_mode == SandboxMode::Strict { return Err(e.context("Seatbelt sandbox enforcement required but failed")); } info!("Seatbelt sandbox error (non-strict mode): {}", e); app.set_sandbox_info(app::SandboxInfo { status: "Error".to_string(), seatbelt_applied: false, fs_restricted: false, net_restricted: false, uid_dropped, }); } } } // Apply restricted token sandbox (Windows only) // This must be done AFTER app.start() because: // - Npcap handles need to be opened first // - Log files need to be created first #[cfg(target_os = "windows")] { use network::platform::sandbox::{ SandboxConfig, SandboxMode, SandboxStatus, apply_sandbox, }; let sandbox_mode = if matches.get_flag("no-sandbox") { SandboxMode::Disabled } else if matches.get_flag("sandbox-strict") { SandboxMode::Strict } else { SandboxMode::BestEffort }; let sandbox_config = SandboxConfig { mode: sandbox_mode }; match apply_sandbox(&sandbox_config) { Ok(result) => { let status_str = match result.status { SandboxStatus::FullyEnforced => { info!("Windows sandbox fully enforced: {}", result.message); "Fully enforced" } SandboxStatus::PartiallyEnforced => { warn!("Windows sandbox partially enforced: {}", result.message); "Partially enforced" } SandboxStatus::NotApplied => { warn!("Windows sandbox not applied: {}", result.message); "Not applied" } }; app.set_sandbox_info(app::SandboxInfo { status: status_str.to_string(), privileges_removed: result.privileges_removed, privileges_removed_count: result.privileges_removed_count, job_object_applied: result.job_object_applied, }); } Err(e) => { if sandbox_mode == SandboxMode::Strict { return Err(e.context("Windows sandbox enforcement required but failed")); } warn!("Windows sandbox error (non-strict mode): {}", e); app.set_sandbox_info(app::SandboxInfo { status: "Error".to_string(), privileges_removed: false, privileges_removed_count: 0, job_object_applied: false, }); } } } // Now that the sandbox has been applied on the main thread, start the worker // threads (DPI packet processors, enrichment, snapshot, cleanup, collectors). // On Linux these inherit the Landlock domain and the dropped capabilities, so // a compromise in a DPI parser is contained even when running as root. app.start_workers()?; // Run the UI loop let res = run_ui_loop(&mut terminal, &app); // Cleanup app.stop(); ui::restore_terminal(&mut terminal)?; // Return any error that occurred if let Err(err) = res { error!("Application error: {}", err); println!("Error: {}", err); } info!("RustNet Monitor shutting down"); Ok(()) } fn setup_logging(level: LevelFilter) -> Result<()> { // The log directory is resolved relative to the current working directory. // rustnet typically runs as root, so a pre-planted symlink at `logs/` (e.g. // `logs -> /etc`) would let an attacker who controls the launch directory // redirect root-owned writes to an arbitrary location. Refuse to use it if // it is a symlink (symlink_metadata does not follow the link). let log_dir = Path::new("logs"); #[cfg(unix)] if let Ok(meta) = fs::symlink_metadata(log_dir) && meta.file_type().is_symlink() { anyhow::bail!("refusing to use log directory 'logs': it is a symlink"); } if !log_dir.exists() { fs::create_dir_all(log_dir)?; // Restrict the directory to the owner: the diagnostic log can contain // connection metadata and (at debug/trace) DNS/SNI hostnames, and rustnet // typically runs as root, so it must not be world-readable. Mirrors the // 0o600 treatment of the JSON/PCAP outputs. #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; if let Err(e) = fs::set_permissions(log_dir, fs::Permissions::from_mode(0o700)) { warn!("Failed to set logs directory permissions: {}", e); } } } // Create timestamped log file name let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); let log_file_path = log_dir.join(format!("rustnet_{}.log", timestamp)); // On Unix, open with O_NOFOLLOW so a symlink pre-planted at the (predictable, // timestamped) path cannot redirect the write, and set the 0o600 mode at // creation time to avoid a create-then-chmod window where the file is briefly // world-readable. #[cfg(unix)] let log_file = { use std::os::unix::fs::OpenOptionsExt; fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .custom_flags(libc::O_NOFOLLOW) .mode(0o600) .open(&log_file_path)? }; #[cfg(not(unix))] let log_file = fs::File::create(&log_file_path)?; // Enable the `target` field on every log line so each entry carries // the originating module (e.g. `network::dpi::dns`). Combined with // the startup-banner lines below, this addresses #310 — users now // see both the program identity (name/version/pid) at the top of // the file and which subsystem emitted each subsequent line. let config = ConfigBuilder::new() .set_target_level(LevelFilter::Error) .build(); WriteLogger::init(level, config, log_file)?; // Startup banner — one identifying header so a user grepping a // long-lived log file can immediately see which binary, which // version, and which pid produced these lines. The `pkg_name` is // the cargo package name (`rustnet-monitor`), not `argv[0]`, so it // stays correct when the binary is renamed or symlinked. info!( "{} v{} starting (pid {})", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"), std::process::id() ); Ok(()) } /// Hand a pre-created export file over to the uid-drop target so it can still /// be reopened by path after root privileges are dropped. Best-effort: on /// failure the drop still happens and the affected export degrades with a /// runtime warning when the reopen fails. #[cfg(any(target_os = "linux", target_os = "macos", target_os = "freebsd"))] fn chown_to_uid_drop_target( file: &fs::File, target: Option, label: &str, path: &str, ) { if let Some(target) = target && let Err(e) = network::platform::privdrop::chown_to_target(file, target) { warn!( "Failed to chown {} file '{}' to uid {}: {} (the file may not be writable after the root uid drop)", label, path, target.uid, e ); } } fn precreate_private_file(path: &str) -> io::Result { #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .custom_flags(libc::O_NOFOLLOW) .mode(0o600) .open(path) } #[cfg(not(unix))] { fs::File::create(path) } } /// Sort connections based on the specified column and direction use ui::{clear_all_with_confirmation, copy_to_clipboard, sort_connections}; fn run_ui_loop( terminal: &mut ui::Terminal, app: &app::App, ) -> Result<()> where ::Error: Send + Sync + 'static, { let tick_rate = Duration::from_millis(200); // Idle redraw ceiling. Terminal emulators repaint whenever output // arrives (iTerm2's renderer repaints the window on any content // change), so the draw cadence directly sets the terminal's CPU // cost. Input and data changes redraw immediately; graph animation // and the live sidebar counters advance at this heartbeat. let redraw_interval = Duration::from_millis(500); let mut last_tick = std::time::Instant::now(); let mut last_draw = std::time::Instant::now(); let mut needs_redraw = true; // first frame let mut ui_state = ui::UIState::default(); let (has_country_db, _, _) = app.get_geoip_status(); ui_state.has_geoip = has_country_db; let mut click_regions = ui::ClickableRegions::default(); // Data state persists across loop iterations — only refreshed on timer tick // or when an event changes the underlying data (filter, sort, historic toggle, etc.) let mut connections: Vec = Vec::new(); let mut grouped_rows: Vec> = Vec::new(); let mut stats = app.get_stats(); let mut needs_data_refresh = true; let mut needs_regroup = false; let mut last_seen_generation = u64::MAX; // force the first refresh 'main: loop { // Refresh connection data only when needed: // - On timer tick (every 200ms), but only if the snapshot actually // changed since we last consumed it (it rebuilds every // refresh-interval ms, so most ticks would re-clone and re-sort // identical data) // - When an event changes filter, sort, or data source let tick_elapsed = last_tick.elapsed() >= tick_rate; let snapshot_generation = app.snapshot_generation(); if tick_elapsed { // Keep counters (packets processed/dropped, etc.) live on every // tick even when the connection list is unchanged. stats = app.get_stats(); last_tick = std::time::Instant::now(); } if needs_data_refresh || (tick_elapsed && snapshot_generation != last_seen_generation) { connections = if ui_state.filter_query.is_empty() && !ui_state.filter_mode { app.get_connections() } else { app.get_filtered_connections(&ui_state.filter_query) }; sort_connections( &mut connections, ui_state.sort_column, ui_state.sort_ascending, ); grouped_rows = if ui_state.grouping_enabled { ui::compute_grouped_rows(&connections, &ui_state.expanded_groups) } else { Vec::new() }; last_seen_generation = snapshot_generation; needs_data_refresh = false; needs_regroup = false; needs_redraw = true; } else if needs_regroup { // Only rebuild grouped rows from existing connections // (e.g., after expand/collapse or grouping toggle) grouped_rows = if ui_state.grouping_enabled { ui::compute_grouped_rows(&connections, &ui_state.expanded_groups) } else { Vec::new() }; needs_regroup = false; needs_redraw = true; } // Ensure we have a valid selection (handles connection removals) if ui_state.grouping_enabled { let selected_idx = ui_state .ensure_valid_grouped_selection(&grouped_rows) .unwrap_or(0); ui_state.grouped_scroll_offset = ui::compute_scroll_offset( selected_idx, ui_state.grouped_scroll_offset, ui_state.visible_rows, grouped_rows.len(), ); } else { let selected_idx = ui_state.ensure_valid_selection(&connections).unwrap_or(0); ui_state.scroll_offset = ui::compute_scroll_offset( selected_idx, ui_state.scroll_offset, ui_state.visible_rows, connections.len(), ); } // Draw the UI, but only when something warrants it: immediately // after input or a data change, otherwise at the idle heartbeat. // The sidebar counters are live atomics read at render time, so // an unconditional draw here would emit fresh cells (and force a // terminal repaint) on every 200ms tick even with nothing going on. // The startup splash animates faster than the idle heartbeat, so // it gets a shorter redraw interval for its ~1s lifetime. let idle_redraw = if app.is_loading() { Duration::from_millis(100) } else { redraw_interval }; if needs_redraw || last_draw.elapsed() >= idle_redraw { terminal.draw(|f| { let grouped = if ui_state.grouping_enabled { Some(grouped_rows.as_slice()) } else { None }; if let Err(err) = ui::draw( f, app, &ui_state, &connections, grouped, &stats, &mut click_regions, ) { error!("UI draw error: {}", err); } })?; last_draw = std::time::Instant::now(); needs_redraw = false; } // Update visible rows for page navigation based on terminal height. // Chrome rows: tab bar (2) + section title (1) + table header incl. // margin (2) + status bar (1) = 6, plus the filter line (1) when a // filter is being edited or active. if let Ok(size) = terminal.size() { let chrome = if ui_state.filter_mode || !ui_state.filter_query.is_empty() { 7 } else { 6 }; ui_state.visible_rows = (size.height as usize).saturating_sub(chrome); } // Sleep until the next data tick or redraw heartbeat, whichever // comes first, unless an event arrives earlier. let timeout = tick_rate .checked_sub(last_tick.elapsed()) .unwrap_or(Duration::from_secs(0)) .min(idle_redraw.saturating_sub(last_draw.elapsed())); // Clear clipboard message after timeout if let Some((_, time)) = &ui_state.clipboard_message && time.elapsed().as_secs() >= 3 { ui_state.clipboard_message = None; needs_redraw = true; } // Handle input events, draining any queued burst (mouse motion, // key auto-repeat) before the next iteration so a flood of // events costs one redraw instead of one redraw per event. let mut poll_timeout = timeout; 'events: while crossterm::event::poll(poll_timeout)? { poll_timeout = Duration::ZERO; let event = crossterm::event::read()?; match event { crossterm::event::Event::Mouse(mouse) => { use crossterm::event::{MouseButton, MouseEventKind}; // Active tab's Component gets first crack — currently // only OverviewTab claims (scroll wheel inside the // scroll area). Click events fall through to the // global ClickableRegions dispatch below. let grouped_opt = if ui_state.grouping_enabled { Some(grouped_rows.as_slice()) } else { None }; let mut hctx = ui::HandlerContext { app, ui_state: &mut ui_state, connections: &connections, grouped_rows: grouped_opt, click_regions: &click_regions, }; if let Some(effects) = ui::dispatch_mouse(hctx.ui_state.selected_tab, mouse, &mut hctx) { let outcome = ui::apply_effects(effects, &mut ui_state, app); if outcome.needs_data_refresh { needs_data_refresh = true; } if outcome.needs_regroup { needs_regroup = true; } needs_redraw = true; continue 'events; } if let MouseEventKind::Down(MouseButton::Left) = mouse.kind { { needs_redraw = true; ui_state.quit_confirmation = false; ui_state.clear_confirmation = false; // Detect double-click (two clicks within 400ms at the same row) let is_double_click = if let Some((_, prev_row, prev_time)) = ui_state.last_click { prev_row == mouse.row && prev_time.elapsed().as_millis() < 400 } else { false }; ui_state.last_click = Some((mouse.column, mouse.row, std::time::Instant::now())); if let Some(action) = click_regions.hit_test(mouse.column, mouse.row) { match action.clone() { ui::ClickAction::SwitchTab(tab_idx) => { ui_state.selected_tab = tab_idx; } ui::ClickAction::SelectConnection(conn_idx) => { if ui_state.grouping_enabled { ui_state.set_selected_grouped_by_index( &grouped_rows, conn_idx, ); if is_double_click && let Some(row) = grouped_rows.get(conn_idx) { match row { ui::GroupedRow::Group { .. } => { // Double-click group header: toggle expand/collapse ui_state.toggle_group_expansion(); needs_regroup = true; } ui::GroupedRow::Connection { .. } => { // Double-click connection: open Details tab ui_state.selected_tab = 1; } } } } else { ui_state.set_selected_by_index(&connections, conn_idx); if is_double_click { // Double-click connection in flat view: open Details tab ui_state.selected_tab = 1; } } } ui::ClickAction::SelectConnectionKey(key) => { // Keep the grouped selection coherent: adopt the // clicked connection's group when grouping is on. if ui_state.grouping_enabled { for row in &grouped_rows { if let ui::GroupedRow::Connection { process_name, connection, .. } = row && connection.key() == key { ui_state.selected_group = Some(process_name.clone()); break; } } } ui_state.set_connection_key(Some(key)); } ui::ClickAction::CopyField { label, value } => { copy_to_clipboard( &value, &format!("{}: {}", label, value), &mut ui_state, app, ); } } } } } // Scroll events are handled by OverviewTab::handle_mouse above. } crossterm::event::Event::Key(key) => { use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers}; // On Windows, crossterm reports both Press and Release events // On Linux/macOS, only Press events are reported // Filter to only handle Press events for consistent cross-platform behavior if key.kind != KeyEventKind::Press { continue 'events; } needs_redraw = true; // Give the active tab's Component first crack // at the key (including filter-mode input — OverviewTab // owns that). If it claims (returns Some), the loop // skips its fallback match. The per-key confirmation // reset happens here for both branches so q / x can // still set their own confirmations without the // catch-all clobbering them. match key.code { KeyCode::Char('q') => ui_state.clear_confirmation = false, KeyCode::Char('x') => ui_state.quit_confirmation = false, _ => { ui_state.quit_confirmation = false; ui_state.clear_confirmation = false; } } let grouped_opt = if ui_state.grouping_enabled { Some(grouped_rows.as_slice()) } else { None }; let mut hctx = ui::HandlerContext { app, ui_state: &mut ui_state, connections: &connections, grouped_rows: grouped_opt, click_regions: &click_regions, }; let claimed = if let Some(effects) = ui::dispatch_key(hctx.ui_state.selected_tab, key, &mut hctx) { let outcome = ui::apply_effects(effects, &mut ui_state, app); if outcome.needs_data_refresh { needs_data_refresh = true; } if outcome.needs_regroup { needs_regroup = true; } true } else { false }; if claimed { // Component handled the key end-to-end. } else { // Normal-mode fallback: keys that weren't claimed // by the active tab's Component. Global navigation // and quit/help/interface-toggle live here, plus // cross-tab fallbacks for x (clear) and Esc which // would otherwise stop working on non-Overview // tabs. Per-arm confirmation clearing is no longer // needed — the dispatcher above already applied // the per-key reset rule. match (key.code, key.modifiers) { // Quit with confirmation (KeyCode::Char('q'), _) => { if ui_state.quit_confirmation { info!("User confirmed application exit"); break 'main; } else { info!("User requested quit - showing confirmation"); ui_state.quit_confirmation = true; } } // Ctrl+C always quits immediately (KeyCode::Char('c'), KeyModifiers::CONTROL) => { info!("User requested immediate exit with Ctrl+C"); break 'main; } // Tab navigation (forward) (KeyCode::Tab, KeyModifiers::NONE) | (KeyCode::Char(']'), KeyModifiers::NONE) => { ui_state.next_tab(); } // Shift+Tab navigation (backward) (KeyCode::BackTab, _) | (KeyCode::Tab, KeyModifiers::SHIFT) | (KeyCode::Char('['), KeyModifiers::NONE) => { ui_state.prev_tab(); } // Direct-jump shortcuts to each tab (mirrors the // numeric-jump convention used by htop, tmux, etc.). // Tab indices match `TAB_TITLES` in // `ui::widgets::tabs_bar`: Overview, Details, // Interfaces, Graph, Help. (KeyCode::Char('1'), KeyModifiers::NONE) => ui_state.jump_to_tab(0), (KeyCode::Char('2'), KeyModifiers::NONE) => ui_state.jump_to_tab(1), (KeyCode::Char('3'), KeyModifiers::NONE) => ui_state.jump_to_tab(2), (KeyCode::Char('4'), KeyModifiers::NONE) => ui_state.jump_to_tab(3), (KeyCode::Char('5'), KeyModifiers::NONE) => ui_state.jump_to_tab(4), // Help toggle — kept because `h` is the universal // mnemonic for help across less / man / vim / tmux. (KeyCode::Char('h'), _) => { ui_state.show_help = !ui_state.show_help; if ui_state.show_help { ui_state.selected_tab = 4; // Switch to help tab } else { ui_state.selected_tab = 0; // Back to overview } } // x and Esc keep cross-tab fallbacks here so // clear / filter-clear / tab-back still work // from Details / Interfaces / Graph / Help // (OverviewTab only claims them on Overview). (KeyCode::Char('x'), _) if clear_all_with_confirmation(&mut ui_state, app) => { needs_data_refresh = true; } (KeyCode::Esc, _) => { if !ui_state.filter_query.is_empty() { ui_state.clear_filter(); needs_data_refresh = true; } else if ui_state.selected_tab != 0 { ui_state.selected_tab = 0; } } _ => {} } } } // end Event::Key crossterm::event::Event::Resize(..) => { needs_redraw = true; } _ => {} // ignore focus, paste, etc. } // end match event } // end event drain } // end loop Ok(()) } /// Check if we have privileges for packet capture before starting the TUI fn check_privileges_early() -> Result<()> { match network::privileges::check_packet_capture_privileges() { Ok(status) if !status.has_privileges => { // Print error to stderr before TUI starts eprintln!( "\n╔═══════════════════════════════════════════════════════════════════════════╗" ); eprintln!( "║ INSUFFICIENT PRIVILEGES ║" ); eprintln!( "╚═══════════════════════════════════════════════════════════════════════════╝" ); eprintln!(); eprintln!("{}", status.error_message()); return Err(anyhow::anyhow!( "Insufficient privileges for packet capture" )); } Err(e) => { // Privilege check failed - warn but continue eprintln!("Warning: Failed to check privileges: {}", e); eprintln!("Continuing anyway, but packet capture may fail...\n"); } _ => { // Privileges OK } } Ok(()) } #[cfg(target_os = "windows")] fn check_windows_dependencies() -> Result<()> { use anyhow::anyhow; // Check if Npcap/WinPcap DLLs are available // Try to load the DLLs to see if they're in the system path let wpcap_available = check_dll_available("wpcap.dll"); let packet_available = check_dll_available("Packet.dll"); if !wpcap_available || !packet_available { eprintln!( "\n╔═══════════════════════════════════════════════════════════════════════════╗" ); eprintln!("║ MISSING DEPENDENCY ║"); eprintln!("╚═══════════════════════════════════════════════════════════════════════════╝"); eprintln!(); eprintln!("RustNet requires Npcap for packet capture on Windows."); eprintln!(); if !wpcap_available { eprintln!(" ✗ wpcap.dll not found"); } if !packet_available { eprintln!(" ✗ Packet.dll not found"); } eprintln!(); eprintln!("To fix this:"); eprintln!(); eprintln!(" 1. Download Npcap from: https://npcap.com/dist/"); eprintln!(" 2. Run the installer"); eprintln!(" 3. IMPORTANT: Check \"Install Npcap in WinPcap API-compatible Mode\""); eprintln!(" 4. Complete the installation"); eprintln!(); eprintln!("After installation, restart your terminal and try again."); eprintln!(); return Err(anyhow!( "Npcap is not installed or not in WinPcap compatible mode" )); } Ok(()) } #[cfg(target_os = "windows")] fn check_dll_available(dll_name: &str) -> bool { use std::ffi::CString; use windows::Win32::Foundation::{FreeLibrary, HMODULE}; use windows::Win32::System::LibraryLoader::LoadLibraryA; use windows::core::PCSTR; // Try to load the DLL let dll_cstring = match CString::new(dll_name) { Ok(s) => s, Err(_) => return false, }; unsafe { // Use LoadLibraryA to check if the DLL can be loaded let handle = LoadLibraryA(PCSTR(dll_cstring.as_ptr() as *const u8)); if let Ok(h) = handle && h != HMODULE(std::ptr::null_mut()) { // Free the library if it was loaded let _ = FreeLibrary(h); true } else { false } } } /// Check if the current process is running with Administrator privileges (Windows only) #[cfg(target_os = "windows")] fn is_admin() -> bool { use windows::Win32::Foundation::HANDLE; use windows::Win32::Security::{ GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, }; use windows::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; unsafe { let mut token_handle = HANDLE::default(); // Open the process token if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token_handle).is_err() { return false; } let mut elevation = TOKEN_ELEVATION::default(); let mut return_length = 0u32; // Get the elevation information let result = GetTokenInformation( token_handle, TokenElevation, Some(&mut elevation as *mut _ as *mut _), std::mem::size_of::() as u32, &mut return_length, ); // Close the token handle let _ = windows::Win32::Foundation::CloseHandle(token_handle); if result.is_err() { return false; } elevation.TokenIsElevated != 0 } }