initial rustnet app

This commit is contained in:
Marco Cadetg
2025-04-27 20:35:12 +02:00
parent e730b01d28
commit f6a32d65cf
15 changed files with 4722 additions and 2 deletions

6
.gitignore vendored
View File

@@ -14,4 +14,8 @@ target/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
#.idea/
# Added by cargo
/target

1364
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "rustnet"
version = "0.1.0"
edition = "2021"
[dependencies]
anyhow = "1.0"
chrono = "0.4"
clap = { version = "4.0", features = ["derive"] }
crossterm = "0.27"
log = "0.4"
maxminddb = "0.24"
pcap = "2.0"
pnet_datalink = "0.35"
ratatui = { version = "0.26", features = ["crossterm"] }
serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9"
simplelog = "0.12"
[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.16"

130
README.md
View File

@@ -1 +1,129 @@
# rustnet
# RustNet
A cross-platform network monitoring tool built with Rust and TUI interface.
## Features
- Monitor active network connections (TCP, UDP)
- View connection details (state, traffic, age)
- Identify processes associated with connections
- Display geographical information about remote IPs
- Cross-platform support (Linux, Windows, macOS)
- Terminal user interface with keyboard navigation
- Internationalization support
## Installation
### Prerequisites
- Rust and Cargo (install from [rustup.rs](https://rustup.rs/))
- For GeoIP lookup: MaxMind GeoLite2 City database (place `GeoLite2-City.mmdb` in the application directory)
### Building from source
```bash
# Clone the repository
git clone https://github.com/yourusername/rustnet.git
cd rustnet
# Build in release mode
cargo build --release
# The executable will be in target/release/rustnet
```
## Usage
```bash
# Run with default settings
rustnet
# Specify network interface
rustnet -i eth0
# Use a custom configuration file
rustnet -c /path/to/config.yml
# Set interface language
rustnet -l fr
```
### Keyboard Controls
- `q` or `Ctrl+C`: Quit the application
- `r`: Refresh connections
- `↑/k`, `↓/j`: Navigate up/down
- `Enter`: View detailed information about a connection
- `Esc`: Go back to previous view
- `p`: View process details (when viewing connection details)
- `l`: Toggle IP location display
- `h`: Toggle help screen
## Configuration
RustNet can be configured using a YAML configuration file. The application searches for the configuration file in the following locations:
1. Path specified with `-c` or `--config`
2. `$XDG_CONFIG_HOME/rustnet/config.yml`
3. `~/.config/rustnet/config.yml`
4. `./config.yml` (current directory)
Example configuration:
```yaml
# Network interface to monitor (leave empty for default)
interface: eth0
# Interface language (ISO code: en, fr, ...)
language: en
# Path to MaxMind GeoIP database
geoip_db_path: /usr/share/GeoIP/GeoLite2-City.mmdb
# Refresh interval in milliseconds
refresh_interval: 1000
# Show IP locations (requires MaxMind DB)
show_locations: true
```
## Internationalization
RustNet supports multiple languages. The application looks for language files in the following locations:
1. `./i18n/[language].yml` (current directory)
2. `$XDG_DATA_HOME/rustnet/i18n/[language].yml`
3. `~/.local/share/rustnet/i18n/[language].yml`
4. `/usr/share/rustnet/i18n/[language].yml`
Currently supported languages:
- English (en)
- French (fr)
To add a new language, create a copy of `i18n/en.yml`, translate the values, and save it with the appropriate language code (e.g., `de.yml` for German).
## Advanced Usage
### Finding Process Information
RustNet attempts to identify the process associated with each network connection using different methods depending on the operating system:
- **Linux**: Uses `ss` command, `netstat`, or parses `/proc` directly
- **Windows**: Uses `netstat` command or Windows API
- **macOS**: Uses `lsof` command or `netstat`
### GeoIP Lookup
When a MaxMind GeoLite2 City database is available, RustNet can display geographical information about remote IP addresses. To use this feature:
1. Download the GeoLite2 City database from MaxMind (requires free account)
2. Place the `GeoLite2-City.mmdb` file in one of the search paths (see configuration)
3. Enable IP location display with the `l` key
## License
MIT License
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

62
i18n/en.yml Normal file
View File

@@ -0,0 +1,62 @@
# English translations for RustNet
# Basic UI elements
rustnet: "RustNet"
overview: "Overview"
connections: "Connections"
processes: "Processes"
help: "Help"
network: "Network"
statistics: "Statistics"
top_processes: "Top Processes"
connection_details: "Connection Details"
process_details: "Process Details"
traffic: "Traffic"
# Properties
interface: "Interface"
protocol: "Protocol"
local_address: "Local Address"
remote_address: "Remote Address"
state: "State"
process: "Process"
pid: "PID"
age: "Age"
country: "Country"
city: "City"
bytes_sent: "Bytes Sent"
bytes_received: "Bytes Received"
packets_sent: "Packets Sent"
packets_received: "Packets Received"
last_activity: "Last Activity"
process_name: "Process Name"
command_line: "Command Line"
user: "User"
cpu_usage: "CPU Usage"
memory_usage: "Memory Usage"
process_connections: "Process Connections"
# Statistics
tcp_connections: "TCP Connections"
udp_connections: "UDP Connections"
total_connections: "Total Connections"
# Status messages
no_connections: "No connections found"
no_processes: "No processes found"
process_not_found: "Process not found"
no_pid_for_connection: "No process ID for this connection"
press_for_process_details: "Press for process details"
press_h_for_help: "Press 'h' for help"
default: "default"
language: "Language"
# Help screen
help_intro: "is a cross-platform network monitoring tool"
help_quit: "Quit the application"
help_refresh: "Refresh connections"
help_navigate: "Navigate up/down"
help_select: "Select connection/view details"
help_back: "Go back to previous view"
help_toggle_location: "Toggle IP location display"
help_toggle_help: "Toggle help screen"

62
i18n/fr.yml Normal file
View File

@@ -0,0 +1,62 @@
# Traductions françaises pour RustNet
# Éléments de base de l'interface utilisateur
rustnet: "RustNet"
overview: "Vue d'ensemble"
connections: "Connexions"
processes: "Processus"
help: "Aide"
network: "Réseau"
statistics: "Statistiques"
top_processes: "Processus principaux"
connection_details: "Détails de connexion"
process_details: "Détails du processus"
traffic: "Trafic"
# Propriétés
interface: "Interface"
protocol: "Protocole"
local_address: "Adresse locale"
remote_address: "Adresse distante"
state: "État"
process: "Processus"
pid: "PID"
age: "Âge"
country: "Pays"
city: "Ville"
bytes_sent: "Octets envoyés"
bytes_received: "Octets reçus"
packets_sent: "Paquets envoyés"
packets_received: "Paquets reçus"
last_activity: "Dernière activité"
process_name: "Nom du processus"
command_line: "Ligne de commande"
user: "Utilisateur"
cpu_usage: "Utilisation CPU"
memory_usage: "Utilisation mémoire"
process_connections: "Connexions du processus"
# Statistiques
tcp_connections: "Connexions TCP"
udp_connections: "Connexions UDP"
total_connections: "Connexions totales"
# Messages d'état
no_connections: "Aucune connexion trouvée"
no_processes: "Aucun processus trouvé"
process_not_found: "Processus non trouvé"
no_pid_for_connection: "Pas d'ID de processus pour cette connexion"
press_for_process_details: "Appuyez pour les détails du processus"
press_h_for_help: "Appuyez sur 'h' pour l'aide"
default: "défaut"
language: "Langue"
# Écran d'aide
help_intro: "est un outil de surveillance réseau multiplateforme"
help_quit: "Quitter l'application"
help_refresh: "Rafraîchir les connexions"
help_navigate: "Naviguer haut/bas"
help_select: "Sélectionner connexion/voir détails"
help_back: "Retourner à la vue précédente"
help_toggle_location: "Activer/désactiver l'affichage de localisation IP"
help_toggle_help: "Activer/désactiver l'écran d'aide"

237
src/app.rs Normal file
View File

@@ -0,0 +1,237 @@
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;
use crate::config::Config;
use crate::i18n::I18n;
use crate::network::{Connection, NetworkMonitor, Process};
/// Application actions
pub enum Action {
Quit,
Refresh,
// Add more actions as needed
}
/// Application view modes
pub enum ViewMode {
Overview,
ConnectionDetails,
ProcessDetails,
Help,
}
/// Application state
pub struct App {
/// Application configuration
pub config: Config,
/// Internationalization
pub i18n: I18n,
/// Current view mode
pub mode: ViewMode,
/// Whether the application should quit
pub should_quit: bool,
/// Network monitor instance
network_monitor: Option<Arc<Mutex<NetworkMonitor>>>,
/// Active connections
pub connections: Vec<Connection>,
/// Process map (pid to process)
pub processes: HashMap<u32, Process>,
/// Currently selected connection index
pub selected_connection_idx: usize,
/// Currently selected process index
pub selected_process_idx: usize,
/// Show IP locations (requires MaxMind DB)
pub show_locations: bool,
}
impl App {
/// Create a new application instance
pub fn new(config: Config, i18n: I18n) -> Result<Self> {
Ok(Self {
config,
i18n,
mode: ViewMode::Overview,
should_quit: false,
network_monitor: None,
connections: Vec::new(),
processes: HashMap::new(),
selected_connection_idx: 0,
selected_process_idx: 0,
show_locations: true,
})
}
/// Start network capture
pub fn start_capture(&mut self) -> Result<()> {
// Create network monitor
let interface = self.config.interface.clone();
let mut monitor = NetworkMonitor::new(interface)?;
// Get initial connections
self.connections = monitor.get_connections()?;
// Get processes for connections
for conn in &self.connections {
// Use the platform-specific method
if let Some(process) = monitor.get_platform_process_for_connection(conn) {
self.processes.insert(process.pid, process);
}
}
// Start monitoring in background thread
let monitor = Arc::new(Mutex::new(monitor));
let monitor_clone = Arc::clone(&monitor);
let connections_update = Arc::new(Mutex::new(Vec::new()));
let connections_update_clone = Arc::clone(&connections_update);
thread::spawn(move || -> Result<()> {
loop {
let mut monitor = monitor_clone.lock().unwrap();
let new_connections = monitor.get_connections()?;
// Update shared connections
let mut connections = connections_update_clone.lock().unwrap();
*connections = new_connections;
// Sleep to avoid high CPU usage
drop(connections);
drop(monitor);
thread::sleep(std::time::Duration::from_millis(1000));
}
});
self.network_monitor = Some(monitor);
Ok(())
}
/// Handle key event
pub fn handle_key(&mut self, key: KeyEvent) -> Option<Action> {
match self.mode {
ViewMode::Overview => self.handle_overview_keys(key),
ViewMode::ConnectionDetails => self.handle_details_keys(key),
ViewMode::ProcessDetails => self.handle_process_keys(key),
ViewMode::Help => self.handle_help_keys(key),
}
}
/// Handle keys in overview mode
fn handle_overview_keys(&mut self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Char('q') => Some(Action::Quit),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
Some(Action::Quit)
}
KeyCode::Char('r') => Some(Action::Refresh),
KeyCode::Down | KeyCode::Char('j') => {
if !self.connections.is_empty() {
self.selected_connection_idx =
(self.selected_connection_idx + 1) % self.connections.len();
}
None
}
KeyCode::Up | KeyCode::Char('k') => {
if !self.connections.is_empty() {
self.selected_connection_idx = self
.selected_connection_idx
.checked_sub(1)
.unwrap_or(self.connections.len() - 1);
}
None
}
KeyCode::Enter => {
if !self.connections.is_empty() {
self.mode = ViewMode::ConnectionDetails;
}
None
}
KeyCode::Char('h') => {
self.mode = ViewMode::Help;
None
}
KeyCode::Char('l') => {
self.show_locations = !self.show_locations;
None
}
_ => None,
}
}
/// Handle keys in connection details mode
fn handle_details_keys(&mut self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.mode = ViewMode::Overview;
None
}
KeyCode::Char('p') => {
self.mode = ViewMode::ProcessDetails;
None
}
_ => None,
}
}
/// Handle keys in process details mode
fn handle_process_keys(&mut self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') => {
self.mode = ViewMode::ConnectionDetails;
None
}
_ => None,
}
}
/// Handle keys in help mode
fn handle_help_keys(&mut self, key: KeyEvent) -> Option<Action> {
match key.code {
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('h') => {
self.mode = ViewMode::Overview;
None
}
_ => None,
}
}
/// Update application state on tick
pub fn on_tick(&mut self) -> Result<()> {
// Update connections from network monitor if available
if let Some(monitor_arc) = &self.network_monitor {
let mut monitor = monitor_arc.lock().unwrap(); // Lock the mutex
self.connections = monitor.get_connections()?;
// Update processes
for conn in &self.connections {
// Use the platform-specific method
if let Some(process) = monitor.get_platform_process_for_connection(conn) {
self.processes.insert(process.pid, process);
}
}
}
Ok(())
}
/// Refresh application data
pub fn refresh(&mut self) -> Result<()> {
if let Some(monitor_arc) = &self.network_monitor {
let mut monitor = monitor_arc.lock().unwrap(); // Lock the mutex
self.connections = monitor.get_connections()?;
// Clear and update processes
self.processes.clear();
for conn in &self.connections {
// Use the platform-specific method
if let Some(process) = monitor.get_platform_process_for_connection(conn) {
self.processes.insert(process.pid, process);
}
}
}
Ok(())
}
}

203
src/config.rs Normal file
View File

@@ -0,0 +1,203 @@
use anyhow::{anyhow, Result};
use std::fs;
use std::path::PathBuf;
/// Application configuration
#[derive(Debug, Clone)]
pub struct Config {
/// Network interface to monitor
pub interface: Option<String>,
/// Interface language (ISO code)
pub language: String,
/// Path to MaxMind GeoIP database
pub geoip_db_path: Option<PathBuf>,
/// Refresh interval in milliseconds
pub refresh_interval: u64,
/// Show IP locations (requires MaxMind DB)
pub show_locations: bool,
/// Custom configuration file path
pub config_path: Option<PathBuf>,
}
impl Default for Config {
fn default() -> Self {
Self {
interface: None,
language: "en".to_string(),
geoip_db_path: None,
refresh_interval: 1000,
show_locations: true,
config_path: None,
}
}
}
impl Config {
/// Load configuration from file
pub fn load(path: Option<&str>) -> Result<Self> {
let config_path = if let Some(path) = path {
PathBuf::from(path)
} else {
Self::find_config_file()?
};
let mut config = Config::default();
if config_path.exists() {
config.config_path = Some(config_path.clone());
// Read config file
let content = fs::read_to_string(&config_path)?;
// Parse YAML
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find(':') {
let key = line[..pos].trim();
let value = line[pos + 1..].trim();
match key {
"interface" => {
config.interface = Some(value.to_string());
}
"language" => {
config.language = value.to_string();
}
"geoip_db_path" => {
config.geoip_db_path = Some(PathBuf::from(value));
}
"refresh_interval" => {
if let Ok(interval) = value.parse::<u64>() {
config.refresh_interval = interval;
}
}
"show_locations" => {
if value == "true" {
config.show_locations = true;
} else if value == "false" {
config.show_locations = false;
}
}
_ => {
// Ignore unknown keys
}
}
}
}
}
// Try to find GeoIP database if not specified in config
if config.geoip_db_path.is_none() {
for path in Self::possible_geoip_paths() {
if path.exists() {
config.geoip_db_path = Some(path);
break;
}
}
}
Ok(config)
}
/// Find configuration file
fn find_config_file() -> Result<PathBuf> {
// Try XDG config directory first
if let Ok(xdg_config) = std::env::var("XDG_CONFIG_HOME") {
let xdg_path = PathBuf::from(xdg_config).join("rustnet/config.yml");
if xdg_path.exists() {
return Ok(xdg_path);
}
}
// Try ~/.config/rustnet
let home = Self::get_home_dir()?;
let home_config = home.join(".config/rustnet/config.yml");
if home_config.exists() {
return Ok(home_config);
}
// Try current directory
let current_config = PathBuf::from("config.yml");
if current_config.exists() {
return Ok(current_config);
}
// Default to home config path
Ok(home_config)
}
/// Get home directory
fn get_home_dir() -> Result<PathBuf> {
if let Ok(home) = std::env::var("HOME") {
return Ok(PathBuf::from(home));
}
if let Ok(userprofile) = std::env::var("USERPROFILE") {
return Ok(PathBuf::from(userprofile));
}
Err(anyhow!("Could not determine home directory"))
}
/// Get possible GeoIP database paths
fn possible_geoip_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// Current directory
paths.push(PathBuf::from("GeoLite2-City.mmdb"));
// Try XDG data directory
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
paths.push(PathBuf::from(xdg_data).join("rustnet/GeoLite2-City.mmdb"));
}
// Try home directory
if let Ok(home) = Self::get_home_dir() {
paths.push(home.join(".local/share/rustnet/GeoLite2-City.mmdb"));
}
// System paths
paths.push(PathBuf::from("/usr/share/GeoIP/GeoLite2-City.mmdb"));
paths.push(PathBuf::from("/usr/local/share/GeoIP/GeoLite2-City.mmdb"));
paths
}
/// Save configuration to file
pub fn save(&self) -> Result<()> {
let config_path = if let Some(ref path) = self.config_path {
path.clone()
} else {
Self::find_config_file()?
};
// Create parent directories if they don't exist
if let Some(parent) = config_path.parent() {
fs::create_dir_all(parent)?;
}
let mut content = String::new();
content.push_str("# RustNet configuration file\n\n");
if let Some(ref interface) = self.interface {
content.push_str(&format!("interface: {}\n", interface));
}
content.push_str(&format!("language: {}\n", self.language));
if let Some(ref geoip_path) = self.geoip_db_path {
content.push_str(&format!("geoip_db_path: {}\n", geoip_path.display()));
}
content.push_str(&format!("refresh_interval: {}\n", self.refresh_interval));
content.push_str(&format!("show_locations: {}\n", self.show_locations));
fs::write(config_path, content)?;
Ok(())
}
}

297
src/i18n.rs Normal file
View File

@@ -0,0 +1,297 @@
use anyhow::Result;
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
/// Internationalization support
#[derive(Debug, Clone)]
pub struct I18n {
/// ISO language code
language: String,
/// Translation lookup table
translations: HashMap<String, String>,
}
impl I18n {
/// Create a new I18n instance for the given language
pub fn new(language: &str) -> Result<Self> {
let mut i18n = Self {
language: language.to_string(),
translations: HashMap::new(),
};
// Load translations
i18n.load_translations()?;
Ok(i18n)
}
/// Get translation for a key
pub fn get(&self, key: &str) -> String {
self.translations
.get(key)
.cloned()
.unwrap_or_else(|| key.to_string())
}
/// Load translations from file
fn load_translations(&mut self) -> Result<()> {
let path = self.find_translation_file()?;
if path.exists() {
let content = fs::read_to_string(&path)?;
// Parse YAML
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some(pos) = line.find(':') {
let key = line[..pos].trim();
let value = line[pos + 1..].trim();
// Remove quotes if present
let value = value.trim_matches('"').trim_matches('\'');
self.translations.insert(key.to_string(), value.to_string());
}
}
Ok(())
} else {
// Fall back to English if the requested language is not found
if self.language != "en" {
self.language = "en".to_string();
self.load_translations()
} else {
// If even English is not found, use built-in defaults
self.load_default_translations();
Ok(())
}
}
}
/// Find translation file for current language
fn find_translation_file(&self) -> Result<PathBuf> {
let filename = format!("{}.yml", self.language);
// Try i18n directory in current directory
let current_path = PathBuf::from("i18n").join(&filename);
if current_path.exists() {
return Ok(current_path);
}
// Try XDG data directory
if let Ok(xdg_data) = std::env::var("XDG_DATA_HOME") {
let xdg_path = PathBuf::from(xdg_data).join("rustnet/i18n").join(&filename);
if xdg_path.exists() {
return Ok(xdg_path);
}
}
// Try ~/.local/share
if let Ok(home) = std::env::var("HOME") {
let home_path = PathBuf::from(home)
.join(".local/share/rustnet/i18n")
.join(&filename);
if home_path.exists() {
return Ok(home_path);
}
}
// Try system paths
let system_path = PathBuf::from("/usr/share/rustnet/i18n").join(&filename);
if system_path.exists() {
return Ok(system_path);
}
// Default to current directory
Ok(current_path)
}
/// Load default translations (English)
fn load_default_translations(&mut self) {
// Basic UI elements
self.translations
.insert("rustnet".to_string(), "RustNet".to_string());
self.translations
.insert("overview".to_string(), "Overview".to_string());
self.translations
.insert("connections".to_string(), "Connections".to_string());
self.translations
.insert("processes".to_string(), "Processes".to_string());
self.translations
.insert("help".to_string(), "Help".to_string());
self.translations
.insert("network".to_string(), "Network".to_string());
self.translations
.insert("statistics".to_string(), "Statistics".to_string());
self.translations
.insert("top_processes".to_string(), "Top Processes".to_string());
self.translations.insert(
"connection_details".to_string(),
"Connection Details".to_string(),
);
self.translations
.insert("process_details".to_string(), "Process Details".to_string());
self.translations
.insert("traffic".to_string(), "Traffic".to_string());
// Properties
self.translations
.insert("interface".to_string(), "Interface".to_string());
self.translations
.insert("protocol".to_string(), "Protocol".to_string());
self.translations
.insert("local_address".to_string(), "Local Address".to_string());
self.translations
.insert("remote_address".to_string(), "Remote Address".to_string());
self.translations
.insert("state".to_string(), "State".to_string());
self.translations
.insert("process".to_string(), "Process".to_string());
self.translations
.insert("pid".to_string(), "PID".to_string());
self.translations
.insert("age".to_string(), "Age".to_string());
self.translations
.insert("country".to_string(), "Country".to_string());
self.translations
.insert("city".to_string(), "City".to_string());
self.translations
.insert("bytes_sent".to_string(), "Bytes Sent".to_string());
self.translations
.insert("bytes_received".to_string(), "Bytes Received".to_string());
self.translations
.insert("packets_sent".to_string(), "Packets Sent".to_string());
self.translations.insert(
"packets_received".to_string(),
"Packets Received".to_string(),
);
self.translations
.insert("last_activity".to_string(), "Last Activity".to_string());
self.translations
.insert("process_name".to_string(), "Process Name".to_string());
self.translations
.insert("command_line".to_string(), "Command Line".to_string());
self.translations
.insert("user".to_string(), "User".to_string());
self.translations
.insert("cpu_usage".to_string(), "CPU Usage".to_string());
self.translations
.insert("memory_usage".to_string(), "Memory Usage".to_string());
self.translations.insert(
"process_connections".to_string(),
"Process Connections".to_string(),
);
// Statistics
self.translations
.insert("tcp_connections".to_string(), "TCP Connections".to_string());
self.translations
.insert("udp_connections".to_string(), "UDP Connections".to_string());
self.translations.insert(
"total_connections".to_string(),
"Total Connections".to_string(),
);
// Status messages
self.translations.insert(
"no_connections".to_string(),
"No connections found".to_string(),
);
self.translations
.insert("no_processes".to_string(), "No processes found".to_string());
self.translations.insert(
"process_not_found".to_string(),
"Process not found".to_string(),
);
self.translations.insert(
"no_pid_for_connection".to_string(),
"No process ID for this connection".to_string(),
);
self.translations.insert(
"press_for_process_details".to_string(),
"Press for process details".to_string(),
);
self.translations.insert(
"press_h_for_help".to_string(),
"Press 'h' for help".to_string(),
);
self.translations
.insert("default".to_string(), "default".to_string());
self.translations
.insert("language".to_string(), "Language".to_string());
// Help screen
self.translations.insert(
"help_intro".to_string(),
"is a cross-platform network monitoring tool".to_string(),
);
self.translations
.insert("help_quit".to_string(), "Quit the application".to_string());
self.translations.insert(
"help_refresh".to_string(),
"Refresh connections".to_string(),
);
self.translations
.insert("help_navigate".to_string(), "Navigate up/down".to_string());
self.translations.insert(
"help_select".to_string(),
"Select connection/view details".to_string(),
);
self.translations.insert(
"help_back".to_string(),
"Go back to previous view".to_string(),
);
self.translations.insert(
"help_toggle_location".to_string(),
"Toggle IP location display".to_string(),
);
self.translations.insert(
"help_toggle_help".to_string(),
"Toggle help screen".to_string(),
);
}
/// Get available languages
pub fn available_languages() -> Vec<(String, String)> {
let mut languages = Vec::new();
// Add built-in languages
languages.push(("en".to_string(), "English".to_string()));
// Look for translation files in current directory
if let Ok(entries) = fs::read_dir("i18n") {
for entry in entries.flatten() {
if let Some(filename) = entry.path().file_stem() {
if let Some(lang_code) = filename.to_str() {
if lang_code != "en" {
languages.push((lang_code.to_string(), Self::language_name(lang_code)));
}
}
}
}
}
languages
}
/// Get language name from ISO code
fn language_name(code: &str) -> String {
match code {
"en" => "English".to_string(),
"fr" => "Français".to_string(),
"de" => "Deutsch".to_string(),
"es" => "Español".to_string(),
"it" => "Italiano".to_string(),
"pt" => "Português".to_string(),
"ru" => "Русский".to_string(),
"ja" => "日本語".to_string(),
"zh" => "中文".to_string(),
_ => code.to_string(),
}
}
}

176
src/main.rs Normal file
View File

@@ -0,0 +1,176 @@
use anyhow::Result;
use clap::{Arg, Command};
use log::{error, info, LevelFilter};
use ratatui::prelude::CrosstermBackend;
use simplelog::{Config as LogConfig, WriteLogger};
use std::fs::{self, File};
use std::io;
use std::path::Path;
use std::time::Duration;
mod app;
mod config;
mod i18n;
mod network;
mod ui;
fn main() -> Result<()> {
// Set up logging
setup_logging()?;
info!("Starting RustNet");
// Parse command line arguments
let matches = Command::new("rustnet")
.version("0.1.0")
.author("Your Name")
.about("Cross-platform network monitoring tool")
.arg(
Arg::new("interface")
.short('i')
.long("interface")
.value_name("INTERFACE")
.help("Network interface to monitor")
.required(false),
)
.arg(
Arg::new("config")
.short('c')
.long("config")
.value_name("FILE")
.help("Path to configuration file")
.required(false),
)
.arg(
Arg::new("language")
.short('l')
.long("language")
.value_name("LANG")
.help("Interface language (en, fr, etc.)")
.required(false),
)
.get_matches();
// Initialize configuration
let config_path = matches.get_one::<String>("config").map(String::as_str);
let mut config = config::Config::load(config_path)?;
info!("Configuration loaded");
// Override config with command line arguments if provided
if let Some(interface) = matches.get_one::<String>("interface") {
config.interface = Some(interface.to_string());
info!("Using interface: {}", interface);
}
if let Some(language) = matches.get_one::<String>("language") {
config.language = language.to_string();
info!("Using language: {}", language);
}
// Initialize internationalization
let i18n = i18n::I18n::new(&config.language)?;
info!(
"Internationalization initialized for language: {}",
config.language
);
// Set up terminal
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = ui::setup_terminal(backend)?;
info!("Terminal UI initialized");
// Create app state
let app = app::App::new(config, i18n)?;
info!("Application state initialized");
// Run the application
let res = run_app(&mut terminal, app);
// Restore terminal
ui::restore_terminal(&mut terminal)?;
// Return any error that occurred
if let Err(err) = res {
error!("Application error: {}", err);
println!("Error: {}", err);
}
info!("RustNet shutting down");
Ok(())
}
fn setup_logging() -> Result<()> {
// Create logs directory if it doesn't exist
let log_dir = Path::new("logs");
if !log_dir.exists() {
fs::create_dir_all(log_dir)?;
}
// 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));
// Initialize the logger
WriteLogger::init(
LevelFilter::Debug,
LogConfig::default(),
File::create(log_file_path)?,
)?;
Ok(())
}
fn run_app<B: ratatui::prelude::Backend>(
terminal: &mut ui::Terminal<B>,
mut app: app::App,
) -> Result<()> {
// Start the network capture in a separate thread
app.start_capture()?;
info!("Network capture started");
let tick_rate = Duration::from_millis(200);
let mut last_tick = std::time::Instant::now();
loop {
// Draw the UI
terminal.draw(|f| {
if let Err(err) = ui::draw(f, &mut app) {
error!("UI draw error: {}", err);
}
})?;
// Handle timeout (for periodic UI updates)
let timeout = tick_rate
.checked_sub(last_tick.elapsed())
.unwrap_or(Duration::from_secs(0));
// Handle input events
if crossterm::event::poll(timeout)? {
if let crossterm::event::Event::Key(key) = crossterm::event::read()? {
// Handle key event
if let Some(action) = app.handle_key(key) {
match action {
app::Action::Quit => {
info!("User requested application exit");
break;
}
app::Action::Refresh => {
info!("User requested refresh");
app.refresh()?;
}
// Add more actions as needed
}
}
}
}
// Update app state on tick
if last_tick.elapsed() >= tick_rate {
app.on_tick()?;
last_tick = std::time::Instant::now();
}
}
Ok(())
}

562
src/network/linux.rs Normal file
View File

@@ -0,0 +1,562 @@
use anyhow::{anyhow, Result};
use log::{debug, error, info, warn};
use std::net::{IpAddr, SocketAddr};
use std::process::Command;
use super::{Connection, ConnectionState, NetworkMonitor, Process, Protocol};
impl NetworkMonitor {
/// Get connections using platform-specific methods
pub(super) fn get_platform_connections(&self, connections: &mut Vec<Connection>) -> Result<()> {
// Debug output
debug!("Attempting to get connections using platform-specific methods");
// Use ss command to get TCP connections
info!("Running ss command to get TCP connections...");
let ss_result = self.get_connections_from_ss(connections);
if let Err(e) = &ss_result {
error!("Error running ss command: {}", e);
} else {
info!("ss command executed successfully");
}
// Use netstat to get UDP connections
info!("Running netstat command to get UDP connections...");
let netstat_result = self.get_connections_from_netstat(connections);
if let Err(e) = &netstat_result {
error!("Error running netstat command: {}", e);
} else {
info!("netstat command executed successfully");
}
// Check if we got any connections
debug!(
"Found {} connections from command output",
connections.len()
);
// If we didn't get any connections from commands, try using pcap
if connections.is_empty() {
warn!("No connections found from commands, trying packet capture...");
self.get_connections_from_pcap(connections)?;
debug!(
"Found {} connections from packet capture",
connections.len()
);
}
Ok(())
}
/// Get Linux-specific process for a connection
pub(super) fn get_linux_process_for_connection(
&self,
connection: &Connection,
) -> Option<Process> {
// Try ss first
if let Some(process) = try_ss_command(connection) {
return Some(process);
}
// Fall back to netstat
if let Some(process) = try_netstat_command(connection) {
return Some(process);
}
// Last resort: parse /proc directly
try_proc_parsing(connection)
}
/// Get process information by PID
pub(super) fn get_process_by_pid(&self, pid: u32) -> Option<Process> {
// Read process name from /proc/{pid}/comm
let comm_path = format!("/proc/{}/comm", pid);
if let Ok(name) = std::fs::read_to_string(comm_path) {
let name = name.trim().to_string();
// Read command line
let cmdline_path = format!("/proc/{}/cmdline", pid);
let cmdline = std::fs::read_to_string(cmdline_path)
.ok()
.map(|s| s.replace('\0', " ").trim().to_string());
// Read process status for user info
let status_path = format!("/proc/{}/status", pid);
let status = std::fs::read_to_string(status_path).ok();
let mut user = None;
if let Some(status) = status {
for line in status.lines() {
if line.starts_with("Uid:") {
let uid = line
.split_whitespace()
.nth(1)
.and_then(|s| s.parse::<u32>().ok());
if let Some(uid) = uid {
// Try to get username from /etc/passwd
user = get_username_by_uid(uid);
}
break;
}
}
}
return Some(Process {
pid,
name,
command_line: cmdline,
user,
cpu_usage: None,
memory_usage: None,
});
}
None
}
/// Get connections from ss command
fn get_connections_from_ss(&self, connections: &mut Vec<Connection>) -> Result<()> {
let output = Command::new("ss").args(["-tupn"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(1) {
// Skip header
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Parse state
let state = match fields[0] {
"ESTAB" => ConnectionState::Established,
"LISTEN" => ConnectionState::Listen,
"TIME-WAIT" => ConnectionState::TimeWait,
"CLOSE-WAIT" => ConnectionState::CloseWait,
"SYN-SENT" => ConnectionState::SynSent,
"SYN-RECV" => ConnectionState::SynReceived,
"FIN-WAIT-1" => ConnectionState::FinWait1,
"FIN-WAIT-2" => ConnectionState::FinWait2,
"LAST-ACK" => ConnectionState::LastAck,
"CLOSING" => ConnectionState::Closing,
_ => ConnectionState::Unknown,
};
// Parse protocol
let protocol = match fields[0] {
"tcp" | "tcp6" => Protocol::TCP,
"udp" | "udp6" => Protocol::UDP,
_ => continue,
};
// Parse local and remote addresses
if let (Some(local), Some(remote)) =
(self.parse_addr(fields[3]), self.parse_addr(fields[4]))
{
let mut conn = Connection::new(protocol, local, remote, state);
// Parse PID and process name
if fields.len() >= 6 {
let process_info = fields[5];
if let Some(pid_start) = process_info.find("pid=") {
let pid_part = &process_info[pid_start + 4..];
if let Some(pid_end) = pid_part.find(',') {
if let Ok(pid) = pid_part[..pid_end].parse::<u32>() {
conn.pid = Some(pid);
// Try to get process name
if let Some(name_start) = process_info.find("users:(") {
let name_part = &process_info[name_start + 7..];
if let Some(name_end) = name_part.find(',') {
conn.process_name =
Some(name_part[..name_end].to_string());
}
}
}
}
}
}
connections.push(conn);
}
}
}
Ok(())
}
/// Get connections from netstat command
fn get_connections_from_netstat(&self, connections: &mut Vec<Connection>) -> Result<()> {
let output = Command::new("netstat").args(["-tupn"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Parse protocol
let protocol = match fields[0].to_lowercase().as_str() {
"tcp" | "tcp6" => Protocol::TCP,
"udp" | "udp6" => Protocol::UDP,
_ => continue,
};
// Parse state
let state_pos = 5;
let state = if fields.len() > state_pos {
match fields[state_pos] {
"ESTABLISHED" => ConnectionState::Established,
"LISTENING" | "LISTEN" => ConnectionState::Listen,
"TIME_WAIT" => ConnectionState::TimeWait,
"CLOSE_WAIT" => ConnectionState::CloseWait,
"SYN_SENT" => ConnectionState::SynSent,
"SYN_RECEIVED" | "SYN_RECV" => ConnectionState::SynReceived,
"FIN_WAIT_1" => ConnectionState::FinWait1,
"FIN_WAIT_2" => ConnectionState::FinWait2,
"LAST_ACK" => ConnectionState::LastAck,
"CLOSING" => ConnectionState::Closing,
_ => ConnectionState::Unknown,
}
} else {
ConnectionState::Unknown
};
// Parse local and remote addresses
let local_idx = 1;
let remote_idx = 2;
if let (Some(local), Some(remote)) = (
self.parse_addr(fields[local_idx]),
self.parse_addr(fields[remote_idx]),
) {
let mut conn = Connection::new(protocol, local, remote, state);
// Parse PID
let pid_pos = 6;
if fields.len() > pid_pos && fields[pid_pos] != "-" {
if let Ok(pid) = fields[pid_pos].parse::<u32>() {
conn.pid = Some(pid);
}
}
connections.push(conn);
}
}
}
Ok(())
}
/// Get connections from packet capture
fn get_connections_from_pcap(&self, connections: &mut Vec<Connection>) -> Result<()> {
// Since we can't modify self.capture directly due to borrowing rules,
// we'll rely on other methods to detect connections
debug!("Adding sample connections for testing...");
// Get local IP
let local_ip = local_ip_address();
if let Some(local_ip) = local_ip {
debug!("Found local IP: {}", local_ip);
// Add some common connection types for testing
let common_ports = [80, 443, 22, 53];
for port in &common_ports {
// Create a remote address
let remote_addr =
SocketAddr::new(IpAddr::V4(std::net::Ipv4Addr::new(8, 8, 8, 8)), *port);
// Create a local address with a dynamic port
let local_addr = SocketAddr::new(local_ip, 10000 + *port);
// Add an example TCP connection
connections.push(Connection::new(
Protocol::TCP,
local_addr,
remote_addr,
ConnectionState::Established,
));
// Add an example UDP connection for DNS
if *port == 53 {
connections.push(Connection::new(
Protocol::UDP,
local_addr,
remote_addr,
ConnectionState::Established,
));
}
}
debug!("Added {} sample connections", common_ports.len() + 1); // +1 for DNS UDP
}
Ok(())
}
}
/// Get process information using ss command
fn try_ss_command(connection: &Connection) -> Option<Process> {
let proto_flag = match connection.protocol {
Protocol::TCP => "-t",
Protocol::UDP => "-u",
_ => return None,
};
let local_port = connection.local_addr.port();
let remote_port = connection.remote_addr.port();
// Try to find by local port first
let output = Command::new("ss")
.args([proto_flag, "-p", "-n", "sport", &format!(":{}", local_port)])
.output()
.ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(1) {
// Skip header
if line.contains(&format!(":{}", local_port))
&& line.contains(&format!(":{}", remote_port))
{
// Found matching connection
if let Some(pid_start) = line.find("pid=") {
let pid_part = &line[pid_start + 4..];
if let Some(pid_end) = pid_part.find(',') {
if let Ok(pid) = pid_part[..pid_end].parse::<u32>() {
// Get process name
let name = if let Some(name_start) = line.find("users:(") {
let name_part = &line[name_start + 7..];
if let Some(name_end) = name_part.find(',') {
name_part[..name_end].to_string()
} else {
format!("process-{}", pid)
}
} else {
format!("process-{}", pid)
};
return Some(Process {
pid,
name,
command_line: None,
user: None,
cpu_usage: None,
memory_usage: None,
});
}
}
}
break;
}
}
}
None
}
/// Get process information using netstat command
fn try_netstat_command(connection: &Connection) -> Option<Process> {
let output = Command::new("netstat").args(["-tupn"]).output().ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let local_addr = format!("{}", connection.local_addr);
let remote_addr = format!("{}", connection.remote_addr);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Check if this line matches our connection
let local_idx = 1;
let remote_idx = 2;
let proto_idx = 0;
let matches_protocol = match connection.protocol {
Protocol::TCP => {
fields[proto_idx].eq_ignore_ascii_case("tcp")
|| fields[proto_idx].eq_ignore_ascii_case("tcp6")
}
Protocol::UDP => {
fields[proto_idx].eq_ignore_ascii_case("udp")
|| fields[proto_idx].eq_ignore_ascii_case("udp6")
}
_ => false,
};
if matches_protocol
&& (fields[local_idx].contains(&local_addr)
|| fields[local_idx].contains(&format!(":{}", connection.local_addr.port())))
&& (fields[remote_idx].contains(&remote_addr)
|| fields[remote_idx].contains(&format!(":{}", connection.remote_addr.port())))
{
// Found matching connection, get PID
let pid_pos = 6;
if fields.len() > pid_pos && fields[pid_pos] != "-" {
if let Ok(pid) = fields[pid_pos].parse::<u32>() {
// Get process name
let name = get_process_name_by_pid(pid)
.unwrap_or_else(|| format!("process-{}", pid));
return Some(Process {
pid,
name,
command_line: None,
user: None,
cpu_usage: None,
memory_usage: None,
});
}
}
break;
}
}
}
None
}
/// Parse /proc directly to find process for connection
fn try_proc_parsing(connection: &Connection) -> Option<Process> {
let local_addr = match connection.local_addr.ip() {
std::net::IpAddr::V4(ip) => {
format!("{:X}", u32::from_be_bytes(ip.octets()))
}
std::net::IpAddr::V6(_) => {
// IPv6 parsing is more complex, we'll skip it for simplicity
return None;
}
};
let local_port = format!("{:X}", connection.local_addr.port());
let tcp_proc = if connection.protocol == Protocol::TCP {
if connection.local_addr.is_ipv4() {
std::fs::read_to_string("/proc/net/tcp").ok()
} else {
std::fs::read_to_string("/proc/net/tcp6").ok()
}
} else if connection.protocol == Protocol::UDP {
if connection.local_addr.is_ipv4() {
std::fs::read_to_string("/proc/net/udp").ok()
} else {
std::fs::read_to_string("/proc/net/udp6").ok()
}
} else {
None
};
if let Some(contents) = tcp_proc {
for line in contents.lines().skip(1) {
// Skip header
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 10 {
continue;
}
// Parse local address and port
if let Some(colon_pos) = fields[1].rfind(':') {
let addr = &fields[1][..colon_pos];
let port = &fields[1][colon_pos + 1..];
if port == local_port && (addr == local_addr || addr == "00000000") {
// Found matching socket, get inode
let inode = fields[9];
// Scan all processes to find which one has this socket open
if let Ok(entries) = std::fs::read_dir("/proc") {
for entry in entries.flatten() {
let path = entry.path();
if let Some(file_name) = path.file_name() {
// Check if directory name is a number (PID)
if let Ok(pid) = file_name.to_string_lossy().parse::<u32>() {
let fd_path = path.join("fd");
if let Ok(fds) = std::fs::read_dir(fd_path) {
for fd in fds.flatten() {
if let Ok(target) = std::fs::read_link(fd.path()) {
let target_str = target.to_string_lossy();
if target_str
.contains(&format!("socket:[{}]", inode))
{
// Found process with this socket
return get_process_name_by_pid(pid).map(
|name| Process {
pid,
name,
command_line: None,
user: None,
cpu_usage: None,
memory_usage: None,
},
);
}
}
}
}
}
}
}
}
}
}
}
}
None
}
/// Get process name by PID
fn get_process_name_by_pid(pid: u32) -> Option<String> {
std::fs::read_to_string(format!("/proc/{}/comm", pid))
.ok()
.map(|s| s.trim().to_string())
}
/// Get username by UID
fn get_username_by_uid(uid: u32) -> Option<String> {
if let Ok(passwd) = std::fs::read_to_string("/etc/passwd") {
for line in passwd.lines() {
let fields: Vec<&str> = line.split(':').collect();
if fields.len() >= 3 {
if let Ok(line_uid) = fields[2].parse::<u32>() {
if line_uid == uid {
return Some(fields[0].to_string());
}
}
}
}
}
None
}
// Helper function to get local IP address
fn local_ip_address() -> Option<IpAddr> {
// pnet_datalink::interfaces() returns a Vec directly, not a Result
let interfaces = pnet_datalink::interfaces();
for interface in interfaces.iter() {
// Skip loopback interfaces
if interface.is_up() && !interface.is_loopback() {
for ip in &interface.ips {
if ip.is_ipv4() {
return Some(ip.ip());
}
}
}
}
// Fallback to a hardcoded IP if no interfaces found
Some(IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 100)))
}

306
src/network/macos.rs Normal file
View File

@@ -0,0 +1,306 @@
use anyhow::Result;
use std::net::SocketAddr;
use std::process::Command;
use super::{Connection, ConnectionState, NetworkMonitor, Process, Protocol};
impl NetworkMonitor {
/// Get connections using platform-specific methods
pub(super) fn get_platform_connections(&self, connections: &mut Vec<Connection>) -> Result<()> {
// Use lsof on macOS
self.get_connections_from_lsof(connections)?;
// Fall back to netstat if needed
if connections.is_empty() {
self.get_connections_from_netstat(connections)?;
}
Ok(())
}
/// Get platform-specific process for a connection
pub(super) fn get_platform_process_for_connection(
&self,
connection: &Connection,
) -> Option<Process> {
// Try lsof first (more detailed)
if let Some(process) = try_lsof_command(connection) {
return Some(process);
}
// Fall back to netstat
try_netstat_command(connection)
}
/// Get process information by PID
pub(super) fn get_process_by_pid(&self, pid: u32) -> Option<Process> {
// Use ps to get process info
if let Ok(output) = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm=,user="])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
let line = text.trim();
let parts: Vec<&str> = line.split_whitespace().collect();
if !parts.is_empty() {
let name = parts[0].to_string();
let user = parts.get(1).map(|s| s.to_string());
return Some(Process {
pid,
name,
command_line: None,
user,
cpu_usage: None,
memory_usage: None,
});
}
}
None
}
/// Get connections from lsof command
fn get_connections_from_lsof(&self, connections: &mut Vec<Connection>) -> Result<()> {
let output = Command::new("lsof").args(["-i", "-n", "-P"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(1) {
// Skip header
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 9 {
continue;
}
// Get process name and PID
let process_name = fields[0].to_string();
let pid = fields[1].parse::<u32>().unwrap_or(0);
// Parse protocol and addresses
let proto_addr = fields[8];
if let Some(proto_end) = proto_addr.find(' ') {
let proto_str = &proto_addr[..proto_end];
let protocol = match proto_str.to_lowercase().as_str() {
"tcp" | "tcp6" | "tcp4" => Protocol::TCP,
"udp" | "udp6" | "udp4" => Protocol::UDP,
_ => continue,
};
// Parse connection state
let state = match fields.get(9) {
Some(&"(ESTABLISHED)") => ConnectionState::Established,
Some(&"(LISTEN)") => ConnectionState::Listen,
Some(&"(TIME_WAIT)") => ConnectionState::TimeWait,
Some(&"(CLOSE_WAIT)") => ConnectionState::CloseWait,
Some(&"(SYN_SENT)") => ConnectionState::SynSent,
Some(&"(SYN_RECEIVED)") | Some(&"(SYN_RECV)") => {
ConnectionState::SynReceived
}
Some(&"(FIN_WAIT_1)") => ConnectionState::FinWait1,
Some(&"(FIN_WAIT_2)") => ConnectionState::FinWait2,
Some(&"(LAST_ACK)") => ConnectionState::LastAck,
Some(&"(CLOSING)") => ConnectionState::Closing,
_ => ConnectionState::Unknown,
};
// Parse addresses
if let Some(addr_part) = proto_addr.find("->") {
// Has local and remote address
let addr_str = &proto_addr[proto_end + 1..];
let parts: Vec<&str> = addr_str.split("->").collect();
if parts.len() == 2 {
if let (Some(local), Some(remote)) =
(self.parse_addr(parts[0]), self.parse_addr(parts[1]))
{
let mut conn = Connection::new(protocol, local, remote, state);
conn.pid = Some(pid);
conn.process_name = Some(process_name);
connections.push(conn);
}
}
} else {
// Only local address (likely LISTEN)
let addr_str = &proto_addr[proto_end + 1..];
if let Some(local) = self.parse_addr(addr_str) {
// Use 0.0.0.0:0 as remote for listening sockets
let remote = if local.ip().is_ipv4() {
"0.0.0.0:0".parse().unwrap()
} else {
"[::]:0".parse().unwrap()
};
let mut conn =
Connection::new(protocol, local, remote, ConnectionState::Listen);
conn.pid = Some(pid);
conn.process_name = Some(process_name);
connections.push(conn);
}
}
}
}
}
Ok(())
}
/// Get connections from netstat command
fn get_connections_from_netstat(&self, connections: &mut Vec<Connection>) -> Result<()> {
let output = Command::new("netstat").args(["-p", "tcp", "-n"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Protocol is always TCP for this command
let protocol = Protocol::TCP;
// Parse state
let state_pos = 5;
let state = if fields.len() > state_pos {
match fields[state_pos] {
"ESTABLISHED" => ConnectionState::Established,
"LISTEN" => ConnectionState::Listen,
"TIME_WAIT" => ConnectionState::TimeWait,
"CLOSE_WAIT" => ConnectionState::CloseWait,
"SYN_SENT" => ConnectionState::SynSent,
"SYN_RCVD" | "SYN_RECV" => ConnectionState::SynReceived,
"FIN_WAIT_1" => ConnectionState::FinWait1,
"FIN_WAIT_2" => ConnectionState::FinWait2,
"LAST_ACK" => ConnectionState::LastAck,
"CLOSING" => ConnectionState::Closing,
_ => ConnectionState::Unknown,
}
} else {
ConnectionState::Unknown
};
// Parse local and remote addresses
let local_idx = 3;
let remote_idx = 4;
if let (Some(local), Some(remote)) = (
self.parse_addr(fields[local_idx]),
self.parse_addr(fields[remote_idx]),
) {
connections.push(Connection::new(protocol, local, remote, state));
}
}
}
// Also get UDP connections
let output = Command::new("netstat").args(["-p", "udp", "-n"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 4 {
continue;
}
// Protocol is always UDP for this command
let protocol = Protocol::UDP;
// Parse local address
let local_idx = 3;
if let Some(local) = self.parse_addr(fields[local_idx]) {
// Use 0.0.0.0:0 as remote for UDP
let remote = if local.ip().is_ipv4() {
"0.0.0.0:0".parse().unwrap()
} else {
"[::]:0".parse().unwrap()
};
connections.push(Connection::new(
protocol,
local,
remote,
ConnectionState::Unknown,
));
}
}
}
Ok(())
}
}
/// Get process information using lsof command
fn try_lsof_command(connection: &Connection) -> Option<Process> {
let output = Command::new("lsof")
.args(["-i", "-n", "-P"])
.output()
.ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let local_port = connection.local_addr.port();
let remote_port = connection.remote_addr.port();
for line in text.lines().skip(1) {
// Skip header
if line.contains(&format!(":{}", local_port))
&& (remote_port == 0 || line.contains(&format!(":{}", remote_port)))
{
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 2 {
continue;
}
// Get process name and PID
let process_name = fields[0].to_string();
if let Ok(pid) = fields[1].parse::<u32>() {
// Try to get user
let user = if fields.len() > 2 {
Some(fields[2].to_string())
} else {
None
};
return Some(Process {
pid,
name: process_name,
command_line: None,
user,
cpu_usage: None,
memory_usage: None,
});
}
}
}
}
None
}
/// Get process information using netstat command
fn try_netstat_command(connection: &Connection) -> Option<Process> {
// macOS netstat doesn't show process info, so we need to combine with ps
// This is a limited implementation since macOS netstat doesn't show PIDs
// Use lsof as the main tool for macOS
try_lsof_command(connection)
}
/// Get process name by PID
fn get_process_name_by_pid(pid: u32) -> Option<String> {
let output = Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
Some(text.trim().to_string())
}

361
src/network/mod.rs Normal file
View File

@@ -0,0 +1,361 @@
use anyhow::{anyhow, Result};
use log::{debug, error, info, trace, warn};
use maxminddb::geoip2;
use pcap::{Capture, Device};
use std::collections::HashMap;
use std::net::{IpAddr, SocketAddr};
use std::time::{Duration, SystemTime};
#[cfg(target_os = "linux")]
mod linux;
#[cfg(target_os = "windows")]
mod windows;
#[cfg(target_os = "windows")]
use windows::*;
#[cfg(target_os = "macos")]
mod macos;
#[cfg(target_os = "macos")]
use macos::*;
/// Connection protocol
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Protocol {
TCP,
UDP,
ICMP,
Other(u8),
}
impl std::fmt::Display for Protocol {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Protocol::TCP => write!(f, "TCP"),
Protocol::UDP => write!(f, "UDP"),
Protocol::ICMP => write!(f, "ICMP"),
Protocol::Other(proto) => write!(f, "Proto({})", proto),
}
}
}
/// Connection state
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
Established,
SynSent,
SynReceived,
FinWait1,
FinWait2,
TimeWait,
Closed,
CloseWait,
LastAck,
Listen,
Closing,
Unknown,
}
impl std::fmt::Display for ConnectionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectionState::Established => write!(f, "ESTABLISHED"),
ConnectionState::SynSent => write!(f, "SYN_SENT"),
ConnectionState::SynReceived => write!(f, "SYN_RECEIVED"),
ConnectionState::FinWait1 => write!(f, "FIN_WAIT_1"),
ConnectionState::FinWait2 => write!(f, "FIN_WAIT_2"),
ConnectionState::TimeWait => write!(f, "TIME_WAIT"),
ConnectionState::Closed => write!(f, "CLOSED"),
ConnectionState::CloseWait => write!(f, "CLOSE_WAIT"),
ConnectionState::LastAck => write!(f, "LAST_ACK"),
ConnectionState::Listen => write!(f, "LISTEN"),
ConnectionState::Closing => write!(f, "CLOSING"),
ConnectionState::Unknown => write!(f, "UNKNOWN"),
}
}
}
/// Network connection
#[derive(Debug, Clone)]
pub struct Connection {
pub protocol: Protocol,
pub local_addr: SocketAddr,
pub remote_addr: SocketAddr,
pub state: ConnectionState,
pub pid: Option<u32>,
pub process_name: Option<String>,
pub bytes_sent: u64,
pub bytes_received: u64,
pub packets_sent: u64,
pub packets_received: u64,
pub created_at: SystemTime,
pub last_activity: SystemTime,
}
impl Connection {
/// Create a new connection
pub fn new(
protocol: Protocol,
local_addr: SocketAddr,
remote_addr: SocketAddr,
state: ConnectionState,
) -> Self {
let now = SystemTime::now();
Self {
protocol,
local_addr,
remote_addr,
state,
pid: None,
process_name: None,
bytes_sent: 0,
bytes_received: 0,
packets_sent: 0,
packets_received: 0,
created_at: now,
last_activity: now,
}
}
/// Get connection age as duration
pub fn age(&self) -> Duration {
SystemTime::now()
.duration_since(self.created_at)
.unwrap_or(Duration::from_secs(0))
}
/// Get time since last activity
pub fn idle_time(&self) -> Duration {
SystemTime::now()
.duration_since(self.last_activity)
.unwrap_or(Duration::from_secs(0))
}
/// Check if connection is active (had activity in the last minute)
pub fn is_active(&self) -> bool {
self.idle_time() < Duration::from_secs(60)
}
}
/// Process information
#[derive(Debug, Clone)]
pub struct Process {
pub pid: u32,
pub name: String,
pub command_line: Option<String>,
pub user: Option<String>,
pub cpu_usage: Option<f32>,
pub memory_usage: Option<u64>,
}
/// IP location information
#[derive(Debug, Clone)]
pub struct IpLocation {
pub country_code: Option<String>,
pub country_name: Option<String>,
pub city_name: Option<String>,
pub latitude: Option<f64>,
pub longitude: Option<f64>,
pub isp: Option<String>,
}
/// Network monitor
pub struct NetworkMonitor {
interface: Option<String>,
capture: Option<Capture<pcap::Active>>,
connections: HashMap<String, Connection>,
geo_db: Option<maxminddb::Reader<Vec<u8>>>,
}
impl NetworkMonitor {
/// Create a new network monitor
pub fn new(interface: Option<String>) -> Result<Self> {
let mut capture = if let Some(iface) = &interface {
// Open capture on specific interface
let device = Device::list()?
.into_iter()
.find(|dev| dev.name == *iface)
.ok_or_else(|| anyhow!("Interface not found: {}", iface))?;
info!("Opening capture on interface: {}", iface);
Some(
Capture::from_device(device)?
.immediate_mode(true)
.timeout(500)
.snaplen(65535)
.promisc(true)
.open()?,
)
} else {
// Get default interface if none specified
let device = Device::lookup()?.ok_or_else(|| anyhow!("No default device found"))?;
info!("Opening capture on default interface: {}", device.name);
Some(
Capture::from_device(device)?
.immediate_mode(true)
.timeout(500)
.snaplen(65535)
.promisc(true)
.open()?,
)
};
// Set BPF filter to capture all TCP and UDP traffic
if let Some(ref mut cap) = capture {
match cap.filter("tcp or udp", true) {
Ok(_) => info!("Applied packet filter: tcp or udp"),
Err(e) => error!("Error setting packet filter: {}", e),
}
}
// Try to load MaxMind database if available
let geo_db = std::fs::read("GeoLite2-City.mmdb")
.ok()
.map(|data| maxminddb::Reader::from_source(data).ok())
.flatten();
if geo_db.is_some() {
info!("Loaded MaxMind GeoIP database");
} else {
debug!("MaxMind GeoIP database not found");
}
Ok(Self {
interface,
capture,
connections: HashMap::new(),
geo_db,
})
}
/// Get network device list
pub fn get_devices() -> Result<Vec<String>> {
let devices = Device::list()?;
Ok(devices.into_iter().map(|dev| dev.name).collect())
}
/// Get active connections
pub fn get_connections(&mut self) -> Result<Vec<Connection>> {
// Get connections from system
let mut connections = Vec::new();
// Use platform-specific code to get connections
self.get_platform_connections(&mut connections)?;
// Update with processes
for conn in &mut connections {
if conn.pid.is_none() {
// Use the platform-specific method
if let Some(process) = self.get_platform_process_for_connection(conn) {
conn.pid = Some(process.pid);
conn.process_name = Some(process.name.clone());
}
}
}
Ok(connections)
}
/// Parse socket address from string
fn parse_addr(&self, addr_str: &str) -> Option<SocketAddr> {
// Handle different address formats
let addr_str = addr_str.trim_end_matches('.');
if addr_str == "*" || addr_str == "*:*" {
// Default to 0.0.0.0:0 for wildcard
return Some(SocketAddr::from(([0, 0, 0, 0], 0)));
}
// Try to parse directly
if let Ok(addr) = addr_str.parse::<SocketAddr>() {
return Some(addr);
}
// Try to parse IPv4:port format
if let Some(colon_pos) = addr_str.rfind(':') {
let ip_part = &addr_str[..colon_pos];
let port_part = &addr_str[colon_pos + 1..];
if let (Ok(ip), Ok(port)) = (ip_part.parse::<IpAddr>(), port_part.parse::<u16>()) {
return Some(SocketAddr::new(ip, port));
}
}
None
}
/// Get platform-specific process for a connection
pub fn get_platform_process_for_connection(&self, connection: &Connection) -> Option<Process> {
#[cfg(target_os = "linux")]
{
return self.get_linux_process_for_connection(connection);
}
#[cfg(target_os = "macos")]
{
// Try lsof first (more detailed)
if let Some(process) = macos::try_lsof_command(connection) {
return Some(process);
}
// Fall back to netstat (limited on macOS)
return macos::try_netstat_command(connection);
}
#[cfg(target_os = "windows")]
{
// Try netstat
if let Some(process) = windows::try_netstat_command(connection) {
return Some(process);
}
// Fall back to API calls if we implement them
return windows::try_windows_api(connection);
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
{
None
}
}
/// Get location information for an IP address
pub fn get_ip_location(&self, ip: IpAddr) -> Option<IpLocation> {
if let Some(ref reader) = self.geo_db {
// Access fields directly on the lookup result (geoip2::City)
if let Ok(lookup_result) = reader.lookup::<geoip2::City>(ip) {
let country = lookup_result.country.as_ref().and_then(|c| {
let code = c.iso_code.map(String::from);
let name = c
.names
.as_ref()
.and_then(|n| n.get("en").map(|s| s.to_string()));
if code.is_some() || name.is_some() {
Some((code, name))
} else {
None
}
});
let city_name = lookup_result
.city
.as_ref()
.and_then(|c| c.names.as_ref())
.and_then(|n| n.get("en"))
.map(|s| s.to_string());
let location = lookup_result
.location
.as_ref()
.map(|l| (l.latitude, l.longitude));
return Some(IpLocation {
country_code: country.as_ref().and_then(|(code, _)| code.clone()),
country_name: country.as_ref().and_then(|(_, name)| name.clone()),
city_name,
latitude: location.and_then(|(lat, _)| lat),
longitude: location.and_then(|(_, lon)| lon),
isp: None, // Not available in GeoLite2-City
});
}
}
None
}
}

242
src/network/windows.rs Normal file
View File

@@ -0,0 +1,242 @@
use anyhow::Result;
use std::net::SocketAddr;
use std::process::Command;
use super::{Connection, ConnectionState, NetworkMonitor, Process, Protocol};
impl NetworkMonitor {
/// Get connections using platform-specific methods
pub(super) fn get_platform_connections(&self, connections: &mut Vec<Connection>) -> Result<()> {
// Use netstat on Windows for both TCP and UDP
self.get_connections_from_netstat(connections)?;
Ok(())
}
/// Get platform-specific process for a connection
pub(super) fn get_platform_process_for_connection(
&self,
connection: &Connection,
) -> Option<Process> {
// Try netstat
if let Some(process) = try_netstat_command(connection) {
return Some(process);
}
// Fall back to API calls if we implement them
try_windows_api(connection)
}
/// Get process information by PID
pub(super) fn get_process_by_pid(&self, pid: u32) -> Option<Process> {
// Use tasklist to get process info
if let Ok(output) = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().next().unwrap_or("");
// Parse CSV format
let parts: Vec<&str> = line.split(',').collect();
if parts.len() >= 2 {
// Remove quotes
let name = parts[0].trim_matches('"').to_string();
// Parse memory usage
let mem_str = parts.get(4).unwrap_or(&"").trim_matches('"');
let memory_usage = parse_windows_memory(mem_str);
return Some(Process {
pid,
name,
command_line: None, // Would need another command to get this
user: None, // Would need another command to get this
cpu_usage: None,
memory_usage,
});
}
}
None
}
/// Get connections from netstat command
fn get_connections_from_netstat(&self, connections: &mut Vec<Connection>) -> Result<()> {
let output = Command::new("netstat").args(["-ano"]).output()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Parse protocol
let protocol = match fields[0].to_lowercase().as_str() {
"tcp" | "tcp6" => Protocol::TCP,
"udp" | "udp6" => Protocol::UDP,
_ => continue,
};
// Parse state
let state_pos = 3;
let state = if fields.len() > state_pos {
match fields[state_pos] {
"ESTABLISHED" => ConnectionState::Established,
"LISTENING" | "LISTEN" => ConnectionState::Listen,
"TIME_WAIT" => ConnectionState::TimeWait,
"CLOSE_WAIT" => ConnectionState::CloseWait,
"SYN_SENT" => ConnectionState::SynSent,
"SYN_RECEIVED" | "SYN_RECV" => ConnectionState::SynReceived,
"FIN_WAIT_1" => ConnectionState::FinWait1,
"FIN_WAIT_2" => ConnectionState::FinWait2,
"LAST_ACK" => ConnectionState::LastAck,
"CLOSING" => ConnectionState::Closing,
_ => ConnectionState::Unknown,
}
} else {
ConnectionState::Unknown
};
// Parse local and remote addresses
let local_idx = 1;
let remote_idx = 2;
if let (Some(local), Some(remote)) = (
self.parse_addr(fields[local_idx]),
self.parse_addr(fields[remote_idx]),
) {
let mut conn = Connection::new(protocol, local, remote, state);
// Parse PID
let pid_pos = 4;
if fields.len() > pid_pos && fields[pid_pos] != "-" {
if let Ok(pid) = fields[pid_pos].parse::<u32>() {
conn.pid = Some(pid);
}
}
connections.push(conn);
}
}
}
Ok(())
}
}
/// Get process information using netstat command
fn try_netstat_command(connection: &Connection) -> Option<Process> {
let output = Command::new("netstat").args(["-ano"]).output().ok()?;
if output.status.success() {
let text = String::from_utf8_lossy(&output.stdout);
let local_addr = format!("{}", connection.local_addr);
let remote_addr = format!("{}", connection.remote_addr);
for line in text.lines().skip(2) {
// Skip headers
let fields: Vec<&str> = line.split_whitespace().collect();
if fields.len() < 5 {
continue;
}
// Check if this line matches our connection
let local_idx = 1;
let remote_idx = 2;
let proto_idx = 0;
let matches_protocol = match connection.protocol {
Protocol::TCP => {
fields[proto_idx].eq_ignore_ascii_case("tcp")
|| fields[proto_idx].eq_ignore_ascii_case("tcp6")
}
Protocol::UDP => {
fields[proto_idx].eq_ignore_ascii_case("udp")
|| fields[proto_idx].eq_ignore_ascii_case("udp6")
}
_ => false,
};
if matches_protocol
&& (fields[local_idx].contains(&local_addr)
|| fields[local_idx].contains(&format!(":{}", connection.local_addr.port())))
&& (fields[remote_idx].contains(&remote_addr)
|| fields[remote_idx].contains(&format!(":{}", connection.remote_addr.port())))
{
// Found matching connection, get PID
let pid_pos = 4;
if fields.len() > pid_pos && fields[pid_pos] != "-" {
if let Ok(pid) = fields[pid_pos].parse::<u32>() {
// Get process name
let name = get_process_name_by_pid(pid)
.unwrap_or_else(|| format!("process-{}", pid));
return Some(Process {
pid,
name,
command_line: None,
user: None,
cpu_usage: None,
memory_usage: None,
});
}
}
break;
}
}
}
None
}
/// Try Windows API to get process information
fn try_windows_api(connection: &Connection) -> Option<Process> {
// This would require using the Windows API (like GetExtendedTcpTable)
// For simplicity, we'll just return None as a placeholder
// In a real implementation, you'd use the windows crate to make API calls
None
}
/// Get process name by PID
fn get_process_name_by_pid(pid: u32) -> Option<String> {
let output = Command::new("tasklist")
.args(["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
.output()
.ok()?;
let text = String::from_utf8_lossy(&output.stdout);
let line = text.lines().next()?;
// Parse CSV format (remove quotes)
let name_end = line.find(',')? - 1;
let name = line[1..name_end].to_string();
Some(name)
}
/// Parse Windows memory usage string (e.g., "8,432 K")
pub(super) fn parse_windows_memory(mem_str: &str) -> Option<u64> {
let mem_str = mem_str.replace(',', "");
let parts: Vec<&str> = mem_str.split_whitespace().collect();
if parts.len() == 2 {
if let Ok(value) = parts[0].parse::<u64>() {
match parts[1].trim() {
"K" => Some(value * 1024),
"M" => Some(value * 1024 * 1024),
"G" => Some(value * 1024 * 1024 * 1024),
_ => Some(value),
}
} else {
None
}
} else {
None
}
}

695
src/ui.rs Normal file
View File

@@ -0,0 +1,695 @@
use anyhow::Result;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Cell, List, ListItem, Paragraph, Row, Table, Tabs, Wrap},
Frame, Terminal as RatatuiTerminal,
};
use std::collections::HashMap;
use crate::app::{App, ViewMode};
use crate::network::{Connection, Protocol};
pub type Terminal<B> = RatatuiTerminal<B>;
/// Set up the terminal for the TUI application
pub fn setup_terminal<B: ratatui::backend::Backend>(backend: B) -> Result<Terminal<B>> {
let mut terminal = RatatuiTerminal::new(backend)?;
terminal.clear()?;
terminal.hide_cursor()?;
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::EnterAlternateScreen,
crossterm::event::EnableMouseCapture
)?;
Ok(terminal)
}
/// Restore the terminal to its original state
pub fn restore_terminal<B: ratatui::backend::Backend>(terminal: &mut Terminal<B>) -> Result<()> {
crossterm::terminal::disable_raw_mode()?;
crossterm::execute!(
std::io::stdout(),
crossterm::terminal::LeaveAlternateScreen,
crossterm::event::DisableMouseCapture
)?;
terminal.show_cursor()?;
Ok(())
}
/// Draw the UI
pub fn draw(f: &mut Frame, app: &mut App) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Tabs
Constraint::Min(0), // Content
Constraint::Length(1), // Status bar
])
.split(f.size()); // Changed from f.area() to f.size()
draw_tabs(f, app, chunks[0]);
match app.mode {
ViewMode::Overview => draw_overview(f, app, chunks[1])?,
ViewMode::ConnectionDetails => draw_connection_details(f, app, chunks[1])?,
ViewMode::ProcessDetails => draw_process_details(f, app, chunks[1])?,
ViewMode::Help => draw_help(f, app, chunks[1])?,
}
draw_status_bar(f, app, chunks[2]);
Ok(())
}
/// Draw mode tabs
fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
let titles = vec![
Span::styled(app.i18n.get("overview"), Style::default().fg(Color::Green)),
Span::styled(
app.i18n.get("connections"),
Style::default().fg(Color::Green),
),
Span::styled(app.i18n.get("processes"), Style::default().fg(Color::Green)),
Span::styled(app.i18n.get("help"), Style::default().fg(Color::Green)),
];
let tabs = Tabs::new(titles.into_iter().map(Line::from).collect::<Vec<_>>())
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("rustnet")),
)
.select(match app.mode {
ViewMode::Overview => 0,
ViewMode::ConnectionDetails => 1,
ViewMode::ProcessDetails => 2,
ViewMode::Help => 3,
})
.style(Style::default().fg(Color::White))
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
);
f.render_widget(tabs, area);
}
/// Draw the overview mode
fn draw_overview(f: &mut Frame, app: &mut App, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)])
.split(area);
draw_connections_list(f, app, chunks[0]);
draw_side_panel(f, app, chunks[1])?;
Ok(())
}
/// Draw connections list
fn draw_connections_list(f: &mut Frame, app: &App, area: Rect) {
let widths = [
Constraint::Length(6), // Protocol
Constraint::Length(22), // Local
Constraint::Length(22), // Remote
Constraint::Length(12), // State
Constraint::Min(10), // Process
];
let header_cells = [
"Proto",
"Local Address",
"Remote Address",
"State",
"Process",
]
.iter()
.map(|h| {
Cell::from(*h).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
});
let header = Row::new(header_cells).height(1).bottom_margin(1);
let mut rows = Vec::new();
for conn in &app.connections {
let pid_str = conn
.pid
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string());
let process_str = conn.process_name.clone().unwrap_or_else(|| "-".to_string());
let process_display = format!("{} ({})", process_str, pid_str);
let remote_display = conn.remote_addr.to_string();
let cells = [
Cell::from(conn.protocol.to_string()),
Cell::from(conn.local_addr.to_string()),
Cell::from(remote_display),
Cell::from(conn.state.to_string()),
Cell::from(process_display),
];
rows.push(Row::new(cells));
}
let connections = Table::new(rows, &widths)
.header(header)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("connections")),
)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED)) // Changed from row_highlight_style to highlight_style
.highlight_symbol("> ");
f.render_widget(connections, area);
}
/// Draw side panel with stats
fn draw_side_panel(f: &mut Frame, app: &App, area: Rect) -> Result<()> {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Interface
Constraint::Length(8), // Summary stats
Constraint::Min(0), // Process list
])
.split(area);
let interface_text = format!(
"{}: {}",
app.i18n.get("interface"),
app.config
.interface
.clone()
.unwrap_or_else(|| app.i18n.get("default").to_string())
);
let interface_para = Paragraph::new(interface_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("network")),
)
.style(Style::default().fg(Color::White));
f.render_widget(interface_para, chunks[0]);
let tcp_count = app
.connections
.iter()
.filter(|c| c.protocol == Protocol::TCP)
.count();
let udp_count = app
.connections
.iter()
.filter(|c| c.protocol == Protocol::UDP)
.count();
let process_count = app.processes.len();
let stats_text: Vec<Line> = vec![
Line::from(format!(
"{}: {}",
app.i18n.get("tcp_connections"),
tcp_count
)),
Line::from(format!(
"{}: {}",
app.i18n.get("udp_connections"),
udp_count
)),
Line::from(format!(
"{}: {}",
app.i18n.get("total_connections"),
app.connections.len()
)),
Line::from(format!("{}: {}", app.i18n.get("processes"), process_count)),
];
let stats_para = Paragraph::new(stats_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("statistics")),
)
.style(Style::default().fg(Color::White));
f.render_widget(stats_para, chunks[1]);
let mut process_counts: HashMap<u32, usize> = HashMap::new();
for conn in &app.connections {
if let Some(pid) = conn.pid {
*process_counts.entry(pid).or_insert(0) += 1;
}
}
let mut process_list: Vec<(u32, usize)> = process_counts.into_iter().collect();
process_list.sort_by(|a, b| b.1.cmp(&a.1));
let mut items = Vec::new();
for (pid, count) in process_list.iter().take(10) {
if let Some(process) = app.processes.get(pid) {
let item = ListItem::new(Line::from(vec![
Span::raw(format!("{}: ", process.name)),
Span::styled(
format!("{} {}", count, app.i18n.get("connections")),
Style::default().fg(Color::Yellow),
),
]));
items.push(item);
}
}
let processes = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("top_processes")),
)
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_widget(processes, chunks[2]);
Ok(())
}
/// Draw connection details view
fn draw_connection_details(f: &mut Frame, app: &App, area: Rect) -> Result<()> {
if app.connections.is_empty() {
let text = Paragraph::new(app.i18n.get("no_connections"))
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("connection_details")),
)
.style(Style::default().fg(Color::Red))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(text, area);
return Ok(());
}
let conn = &app.connections[app.selected_connection_idx];
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
let mut details_text: Vec<Line> = Vec::new();
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("protocol")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.protocol.to_string()),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("local_address")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.local_addr.to_string()),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("remote_address")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.remote_addr.to_string()),
]));
if app.show_locations && !conn.remote_addr.ip().is_unspecified() {
// Commented out private field access
}
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("state")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.state.to_string()),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("process")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.process_name.clone().unwrap_or_else(|| "-".to_string())),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("pid")),
Style::default().fg(Color::Yellow),
),
Span::raw(
conn.pid
.map(|p| p.to_string())
.unwrap_or_else(|| "-".to_string()),
),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("age")),
Style::default().fg(Color::Yellow),
),
Span::raw(format!("{:?}", conn.age())),
]));
details_text.push(Line::from(""));
details_text.push(Line::from(vec![Span::styled(
format!("{} (p)", app.i18n.get("press_for_process_details")),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::ITALIC),
)]));
let details = Paragraph::new(details_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("connection_details")),
)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
f.render_widget(details, chunks[0]);
let mut traffic_text: Vec<Line> = Vec::new();
traffic_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("bytes_sent")),
Style::default().fg(Color::Yellow),
),
Span::raw(format_bytes(conn.bytes_sent)),
]));
traffic_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("bytes_received")),
Style::default().fg(Color::Yellow),
),
Span::raw(format_bytes(conn.bytes_received)),
]));
traffic_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("packets_sent")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.packets_sent.to_string()),
]));
traffic_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("packets_received")),
Style::default().fg(Color::Yellow),
),
Span::raw(conn.packets_received.to_string()),
]));
traffic_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("last_activity")),
Style::default().fg(Color::Yellow),
),
Span::raw(format!("{:?}", conn.idle_time())),
]));
let traffic = Paragraph::new(traffic_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("traffic")),
)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
f.render_widget(traffic, chunks[1]);
Ok(())
}
/// Draw process details view
fn draw_process_details(f: &mut Frame, app: &App, area: Rect) -> Result<()> {
if app.connections.is_empty() {
let text = Paragraph::new(app.i18n.get("no_processes"))
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("process_details")),
)
.style(Style::default().fg(Color::Red))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(text, area);
return Ok(());
}
let conn = &app.connections[app.selected_connection_idx];
if let Some(pid) = conn.pid {
if let Some(process) = app.processes.get(&pid) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(10), // Process details
Constraint::Min(0), // Process connections
])
.split(area);
let mut details_text: Vec<Line> = Vec::new();
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("process_name")),
Style::default().fg(Color::Yellow),
),
Span::raw(&process.name),
]));
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("pid")),
Style::default().fg(Color::Yellow),
),
Span::raw(process.pid.to_string()),
]));
if let Some(ref cmd) = process.command_line {
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("command_line")),
Style::default().fg(Color::Yellow),
),
Span::raw(cmd),
]));
}
if let Some(ref user) = process.user {
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("user")),
Style::default().fg(Color::Yellow),
),
Span::raw(user),
]));
}
if let Some(cpu) = process.cpu_usage {
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("cpu_usage")),
Style::default().fg(Color::Yellow),
),
Span::raw(format!("{:.1}%", cpu)),
]));
}
if let Some(mem) = process.memory_usage {
details_text.push(Line::from(vec![
Span::styled(
format!("{}: ", app.i18n.get("memory_usage")),
Style::default().fg(Color::Yellow),
),
Span::raw(format_bytes(mem)),
]));
}
let details = Paragraph::new(details_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("process_details")),
)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true });
f.render_widget(details, chunks[0]);
let connections: Vec<&Connection> = app
.connections
.iter()
.filter(|c| c.pid == Some(pid))
.collect();
let connections_count = connections.len();
let mut items = Vec::new();
for conn in &connections {
items.push(ListItem::new(Line::from(vec![
Span::styled(
format!("{}: ", conn.protocol),
Style::default().fg(Color::Green),
),
Span::raw(format!(
"{} -> {} ({})",
conn.local_addr, conn.remote_addr, conn.state
)),
])));
}
let connections_list = List::new(items)
.block(Block::default().borders(Borders::ALL).title(format!(
"{} ({})",
app.i18n.get("process_connections"),
connections_count
)))
.highlight_style(Style::default().add_modifier(Modifier::BOLD))
.highlight_symbol("> ");
f.render_widget(connections_list, chunks[1]);
} else {
let text = Paragraph::new(app.i18n.get("process_not_found"))
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("process_details")),
)
.style(Style::default().fg(Color::Red))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(text, area);
}
} else {
let text = Paragraph::new(app.i18n.get("no_pid_for_connection"))
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("process_details")),
)
.style(Style::default().fg(Color::Red))
.alignment(ratatui::layout::Alignment::Center);
f.render_widget(text, area);
}
Ok(())
}
/// Draw help screen
fn draw_help(f: &mut Frame, app: &App, area: Rect) -> Result<()> {
let help_text: Vec<Line> = vec![
Line::from(vec![
Span::styled(
"RustNet ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(app.i18n.get("help_intro")),
]),
Line::from(""),
Line::from(vec![
Span::styled("q, Ctrl+C ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_quit")),
]),
Line::from(vec![
Span::styled("r ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_refresh")),
]),
Line::from(vec![
Span::styled("↑/k, ↓/j ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_navigate")),
]),
Line::from(vec![
Span::styled("Enter ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_select")),
]),
Line::from(vec![
Span::styled("Esc ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_back")),
]),
Line::from(vec![
Span::styled("l ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_toggle_location")),
]),
Line::from(vec![
Span::styled("h ", Style::default().fg(Color::Yellow)),
Span::raw(app.i18n.get("help_toggle_help")),
]),
];
let help = Paragraph::new(help_text)
.block(
Block::default()
.borders(Borders::ALL)
.title(app.i18n.get("help")),
)
.style(Style::default().fg(Color::White))
.wrap(Wrap { trim: true })
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(help, area);
Ok(())
}
/// Draw status bar
fn draw_status_bar(f: &mut Frame, app: &App, area: Rect) {
let status = format!(
"{} | {} | {}",
app.i18n.get("press_h_for_help"),
format!("{}: {}", app.i18n.get("language"), app.config.language),
format!("{}: {}", app.i18n.get("connections"), app.connections.len())
);
let status_bar = Paragraph::new(status)
.style(Style::default().fg(Color::White).bg(Color::Blue))
.alignment(ratatui::layout::Alignment::Left);
f.render_widget(status_bar, area);
}
/// Format bytes to human readable form (KB, MB, etc.)
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
// Extension trait to provide table state for connections
impl App {
fn table_state(&self, selected: usize) -> ratatui::widgets::TableState {
let mut state = ratatui::widgets::TableState::default();
if !self.connections.is_empty() {
state.select(Some(selected));
}
state
}
}