mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-01-05 13:29:55 -06:00
initial rustnet app
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
1364
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
Normal file
21
Cargo.toml
Normal 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
130
README.md
@@ -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
62
i18n/en.yml
Normal 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
62
i18n/fr.yml
Normal 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
237
src/app.rs
Normal 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
203
src/config.rs
Normal 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
297
src/i18n.rs
Normal 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
176
src/main.rs
Normal 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
562
src/network/linux.rs
Normal 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
306
src/network/macos.rs
Normal 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
361
src/network/mod.rs
Normal 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
242
src/network/windows.rs
Normal 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
695
src/ui.rs
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user