From 459504ebb69078e277fbd66dae67500168d0c06e Mon Sep 17 00:00:00 2001 From: Marco Cadetg Date: Thu, 11 Sep 2025 12:32:53 +0200 Subject: [PATCH] feat: ssh dpi --- README.md | 19 +- src/filter.rs | 27 ++- src/network/dpi/mod.rs | 7 +- src/network/dpi/ssh.rs | 483 +++++++++++++++++++++++++++++++++++++++++ src/network/merge.rs | 73 ++++++- src/network/types.rs | 44 +++- src/ui.rs | 37 +++- 7 files changed, 676 insertions(+), 14 deletions(-) create mode 100644 src/network/dpi/ssh.rs diff --git a/README.md b/README.md index 039754f..4a536bf 100644 --- a/README.md +++ b/README.md @@ -11,12 +11,13 @@ A cross-platform network monitoring tool built with Rust. RustNet provides real- - **TCP States**: `ESTABLISHED`, `SYN_SENT`, `TIME_WAIT`, `CLOSED`, etc. - **QUIC States**: `QUIC_INITIAL`, `QUIC_HANDSHAKE`, `QUIC_CONNECTED`, `QUIC_DRAINING` - **DNS States**: `DNS_QUERY`, `DNS_RESPONSE` + - **SSH States**: `BANNER`, `KEYEXCHANGE`, `AUTHENTICATION`, `ESTABLISHED` (for SSH protocol) - **Activity States**: `UDP_ACTIVE`, `UDP_IDLE`, `UDP_STALE` based on connection activity - **Deep Packet Inspection (DPI)**: Detect application protocols: - HTTP with host information - HTTPS/TLS with SNI (Server Name Indication) - DNS queries and responses - - TODO: SSH connections + - **SSH connections** with version detection, software identification, and connection state tracking - **QUIC protocol with CONNECTION_CLOSE frame detection** and RFC 9000 compliance - **Connection Lifecycle Management**: - Configurable timeouts based on protocol, state, and activity (TCP closed: 5s, QUIC draining: 10s, SSH: 30min) @@ -64,10 +65,17 @@ RustNet is available as a Docker container from GitHub Container Registry: # Pull the latest image docker pull ghcr.io/domcyrus/rustnet:latest -# Run with required network capabilities +# Or pull a specific version +docker pull ghcr.io/domcyrus/rustnet:0.7.0 + +# Run with required network capabilities (latest) docker run --rm -it --cap-add=NET_RAW --cap-add=NET_ADMIN --net=host \ ghcr.io/domcyrus/rustnet:latest +# Run with specific version +docker run --rm -it --cap-add=NET_RAW --cap-add=NET_ADMIN --net=host \ + ghcr.io/domcyrus/rustnet:0.7.0 + # Run with specific interface docker run --rm -it --cap-add=NET_RAW --cap-add=NET_ADMIN --net=host \ ghcr.io/domcyrus/rustnet:latest -i eth0 @@ -78,6 +86,9 @@ docker run --rm -it --privileged --net=host \ # View available options docker run --rm ghcr.io/domcyrus/rustnet:latest --help + +# Or with specific version +docker run --rm ghcr.io/domcyrus/rustnet:0.7.0 --help ``` **Note:** The container requires network capabilities (`NET_RAW` and `NET_ADMIN`) or privileged mode for packet capture. Host networking (`--net=host`) is recommended for monitoring all network interfaces. @@ -169,6 +180,7 @@ Press `/` to enter filter mode. Type to filter connections in real-time, navigat - `dst:github.com` - Destinations containing "github.com" - `process:ssh` - Process names containing "ssh" - `sni:api` - SNI hostnames containing "api" +- `ssh:openssh` - SSH connections using OpenSSH - `state:established` - Filter connections by protocol state **State filtering:** @@ -189,6 +201,7 @@ Filter connections by their current protocol state (case-insensitive): - **QUIC**: `QUIC_INITIAL`, `QUIC_HANDSHAKE`, `QUIC_CONNECTED`, `QUIC_DRAINING`, `QUIC_CLOSED` ⚠️ *Note: QUIC state tracking may be incomplete due to encrypted handshake packets and reassembly challenges* - **UDP**: `UDP_ACTIVE`, `UDP_IDLE`, `UDP_STALE` - **DNS**: `DNS_QUERY`, `DNS_RESPONSE` +- **SSH**: `BANNER`, `KEYEXCHANGE`, `AUTHENTICATION`, `ESTABLISHED` ⚠️ *Note: SSH state tracking is based on packet inspection and may not always reflect the true connection state* - **Other**: `ECHO_REQUEST`, `ECHO_REPLY`, `ARP_REQUEST`, `ARP_REPLY` **Examples:** @@ -198,6 +211,8 @@ Filter connections by their current protocol state (case-insensitive): - `sport:443 state:syn_recv` - Half-open connections to port 443 (SYN flood detection) - `proto:tcp state:established` - All established TCP connections - `process:firefox state:quic_connected` - Active QUIC connections from Firefox +- `dport:22 ssh:openssh` - SSH connections using OpenSSH +- `state:established ssh:openssh` - Established SSH connections using OpenSSH Press `Esc` to clear filter. diff --git a/src/filter.rs b/src/filter.rs index 3e7f9cc..bd10ddb 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -313,10 +313,35 @@ impl ConnectionFilter { } } } - ApplicationProtocol::Ssh => { + ApplicationProtocol::Ssh(info) => { if "ssh".contains(text) { return true; } + + // Check software names + if let Some(ref software) = info.server_software + && software.to_lowercase().contains(text) + { + return true; + } + if let Some(ref software) = info.client_software + && software.to_lowercase().contains(text) + { + return true; + } + + // Check connection state + let state_str = format!("{:?}", info.connection_state).to_lowercase(); + if state_str.contains(text) { + return true; + } + + // Check algorithms + for algo in &info.algorithms { + if algo.to_lowercase().contains(text) { + return true; + } + } } } diff --git a/src/network/dpi/mod.rs b/src/network/dpi/mod.rs index 0f561a4..492644f 100644 --- a/src/network/dpi/mod.rs +++ b/src/network/dpi/mod.rs @@ -6,6 +6,7 @@ mod dns; mod http; mod https; mod quic; +mod ssh; pub use cipher_suites::{format_cipher_suite, is_secure_cipher_suite}; @@ -45,9 +46,11 @@ pub fn analyze_tcp_packet( } // 3. Check for SSH (port 22 or SSH banner) - if local_port == 22 || remote_port == 22 || payload.starts_with(b"SSH-") { + if (local_port == 22 || remote_port == 22 || ssh::is_likely_ssh(payload)) + && let Some(ssh_result) = ssh::analyze_ssh(payload, _is_outgoing) + { return Some(DpiResult { - application: ApplicationProtocol::Ssh, + application: ApplicationProtocol::Ssh(ssh_result), }); } diff --git a/src/network/dpi/ssh.rs b/src/network/dpi/ssh.rs new file mode 100644 index 0000000..4680b60 --- /dev/null +++ b/src/network/dpi/ssh.rs @@ -0,0 +1,483 @@ +use crate::network::types::{SshConnectionState, SshInfo, SshVersion}; +use log::debug; + +/// Analyze payload for SSH protocol +/// is_outgoing: true if this packet is from client to server +pub fn analyze_ssh(payload: &[u8], is_outgoing: bool) -> Option { + if !is_likely_ssh(payload) { + return None; + } + + let mut info = SshInfo { + version: None, + client_software: None, + server_software: None, + connection_state: SshConnectionState::Banner, + algorithms: Vec::new(), + auth_method: None, + }; + + // Convert payload to string for banner analysis + let text = String::from_utf8_lossy(payload); + let lines: Vec<&str> = text.lines().collect(); + + if lines.is_empty() { + return None; + } + + // Parse SSH banner(s) and assign based on packet direction + for line in lines { + if let Some(banner_info) = parse_ssh_banner(line) { + // Use packet direction to distinguish client vs server + if is_outgoing { + // Outgoing packet: client to server, so this banner is from client + if info.client_software.is_none() { + info.client_software = Some(banner_info.software); + info.version = Some(banner_info.version); + } + } else { + // Incoming packet: server to client, so this banner is from server + if info.server_software.is_none() { + info.server_software = Some(banner_info.software); + info.version = Some(banner_info.version); + } + } + } + } + + // Detect SSH message types for connection state + // Look for SSH packet structures throughout the payload + let mut found_packet_state = false; + for i in 0..payload.len().saturating_sub(6) { + if payload.len() >= i + 6 { + // Validate this looks like a real SSH packet structure + if is_valid_ssh_packet_at_offset(payload, i) { + let msg_type = payload[i + 5]; + + match msg_type { + 20 => { + info.connection_state = SshConnectionState::KeyExchange; + debug!("SSH: Detected KEXINIT message at offset {}", i); + found_packet_state = true; + break; + } + 21 => { + info.connection_state = SshConnectionState::KeyExchange; + debug!("SSH: Detected NEWKEYS message at offset {}", i); + found_packet_state = true; + break; + } + 50 => { + info.connection_state = SshConnectionState::Authentication; + debug!("SSH: Detected USERAUTH_REQUEST message at offset {}", i); + found_packet_state = true; + break; + } + 51 => { + info.connection_state = SshConnectionState::Authentication; + debug!("SSH: Detected USERAUTH_FAILURE message at offset {}", i); + found_packet_state = true; + break; + } + 52 => { + info.connection_state = SshConnectionState::Established; + debug!("SSH: Detected USERAUTH_SUCCESS message at offset {}", i); + found_packet_state = true; + break; + } + 90..=127 => { + info.connection_state = SshConnectionState::Established; + debug!("SSH: Detected connection protocol message at offset {}", i); + found_packet_state = true; + break; + } + _ => { + // Continue searching + } + } + } + } + } + + // If we didn't find a packet state and we have banner info, default to Banner state + if !found_packet_state && (info.server_software.is_some() || info.client_software.is_some()) { + info.connection_state = SshConnectionState::Banner; + } + + // Try to extract algorithm information from KEXINIT messages or any payload + if payload.len() > 20 && payload[5] == 20 { + if let Some(algorithms) = parse_kexinit_algorithms(payload) { + info.algorithms = algorithms; + } + } else { + // Also try to extract algorithms from banner/text content + if let Some(algorithms) = parse_kexinit_algorithms(payload) { + info.algorithms = algorithms; + } + } + + debug!("SSH analysis result: {:?}", info); + Some(info) +} + +/// Check if payload might be SSH +pub fn is_likely_ssh(payload: &[u8]) -> bool { + if payload.len() < 4 { + return false; + } + + // SSH banner identification string + payload.starts_with(b"SSH-1.") || + payload.starts_with(b"SSH-2.") || + // Sometimes we might see SSH packets without banners + is_ssh_packet_structure(payload) +} + +/// Parse SSH banner line +fn parse_ssh_banner(line: &str) -> Option { + if !line.starts_with("SSH-") { + return None; + } + + let parts: Vec<&str> = line.splitn(3, '-').collect(); + if parts.len() < 2 { + return None; + } + + let version = match parts[1] { + "1.99" | "2.0" => SshVersion::V2, + v if v.starts_with("1.") => SshVersion::V1, + _ => SshVersion::V2, // Default to V2 for unknown versions + }; + + let software = if parts.len() >= 3 { + parts[2].trim().to_string() + } else { + "Unknown".to_string() + }; + + Some(BannerInfo { version, software }) +} + +/// Check if payload has SSH packet structure +fn is_ssh_packet_structure(payload: &[u8]) -> bool { + if payload.len() < 6 { + return false; + } + + is_valid_ssh_packet_at_offset(payload, 0) +} + +/// Check if there's a valid SSH packet structure at the given offset +fn is_valid_ssh_packet_at_offset(payload: &[u8], offset: usize) -> bool { + if payload.len() < offset + 6 { + return false; + } + + // SSH packet format: + // 4 bytes: packet length + // 1 byte: padding length + // 1+ bytes: payload (message type + data) + // N bytes: padding + + let packet_length = u32::from_be_bytes([ + payload[offset], + payload[offset + 1], + payload[offset + 2], + payload[offset + 3], + ]); + let padding_length = payload[offset + 4] as u32; + + // Basic sanity checks + if packet_length > 35000 || padding_length > 255 { + return false; + } + + // Message type should be in valid range + let msg_type = payload[offset + 5]; + matches!(msg_type, 1..=127) +} + +/// Parse algorithms from KEXINIT message +fn parse_kexinit_algorithms(payload: &[u8]) -> Option> { + // This is a simplified version - full KEXINIT parsing is quite complex + // We'll just try to extract some common algorithm names + let text = String::from_utf8_lossy(payload); + let mut algorithms = Vec::new(); + + // Look for common SSH algorithms + let common_algos = [ + "diffie-hellman-group14-sha256", + "ecdh-sha2-nistp256", + "aes128-ctr", + "aes256-ctr", + "aes128-gcm", + "aes256-gcm", + "ssh-rsa", + "ssh-ed25519", + "ecdsa-sha2-nistp256", + "hmac-sha2-256", + "hmac-sha2-512", + ]; + + for algo in &common_algos { + if text.contains(algo) { + algorithms.push(algo.to_string()); + } + } + + if algorithms.is_empty() { + None + } else { + Some(algorithms) + } +} + +/// Helper struct for banner parsing +struct BannerInfo { + version: SshVersion, + software: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_openssh_banner() { + let payload = b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.1\r\n"; + let info = analyze_ssh(payload, false).unwrap(); + + assert_eq!(info.version, Some(SshVersion::V2)); + assert_eq!( + info.server_software.as_deref(), + Some("OpenSSH_8.9p1 Ubuntu-3ubuntu0.1") + ); + assert_eq!(info.connection_state, SshConnectionState::Banner); + } + + #[test] + fn test_putty_banner() { + let payload = b"SSH-2.0-PuTTY_Release_0.76\r\n"; + let info = analyze_ssh(payload, false).unwrap(); + + assert_eq!(info.version, Some(SshVersion::V2)); + assert_eq!(info.server_software.as_deref(), Some("PuTTY_Release_0.76")); + } + + #[test] + fn test_ssh1_banner() { + let payload = b"SSH-1.99-libssh_0.8.9\r\n"; + let info = analyze_ssh(payload, false).unwrap(); + + assert_eq!(info.version, Some(SshVersion::V2)); // 1.99 maps to V2 + assert_eq!(info.server_software.as_deref(), Some("libssh_0.8.9")); + } + + #[test] + fn test_kexinit_detection() { + // Simplified KEXINIT packet structure + let mut payload = vec![0, 0, 0, 100]; // packet length + payload.push(10); // padding length + payload.push(20); // SSH_MSG_KEXINIT + payload.extend_from_slice(&[0; 94]); // rest of packet + + let info = analyze_ssh(&payload, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::KeyExchange); + } + + #[test] + fn test_userauth_success() { + let mut payload = vec![0, 0, 0, 20]; // packet length + payload.push(5); // padding length + payload.push(52); // SSH_MSG_USERAUTH_SUCCESS + payload.extend_from_slice(&[0; 14]); // padding + + let info = analyze_ssh(&payload, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::Established); + } + + #[test] + fn test_non_ssh_payload() { + let payload = b"HTTP/1.1 200 OK\r\n"; + assert!(analyze_ssh(payload, false).is_none()); + } + + #[test] + fn test_partial_ssh_banner() { + let payload = b"SSH-2.0-Open"; + let info = analyze_ssh(payload, false).unwrap(); + assert_eq!(info.version, Some(SshVersion::V2)); + } + + #[test] + fn test_ssh_banner_parsing() { + assert!(parse_ssh_banner("SSH-2.0-OpenSSH_8.9").is_some()); + assert!(parse_ssh_banner("SSH-1.5-oldversion").is_some()); + assert!(parse_ssh_banner("HTTP/1.1 200 OK").is_none()); + assert!(parse_ssh_banner("").is_none()); + } + + #[test] + fn test_is_likely_ssh() { + assert!(is_likely_ssh(b"SSH-2.0-OpenSSH")); + assert!(is_likely_ssh(b"SSH-1.99-libssh")); + assert!(!is_likely_ssh(b"HTTP/1.1")); + assert!(!is_likely_ssh(b"GET /")); + assert!(!is_likely_ssh(b"")); + } + + #[test] + fn test_ssh_packet_structure() { + // Valid SSH packet structure + let valid_packet = vec![0, 0, 0, 20, 5, 50, 0, 0, 0, 0]; // packet_len=20, padding_len=5, msg_type=50 + assert!(is_ssh_packet_structure(&valid_packet)); + + // Invalid packet structure + let invalid_packet = vec![255, 255, 255, 255, 255, 255]; // unrealistic lengths + assert!(!is_ssh_packet_structure(&invalid_packet)); + } + + #[test] + fn test_various_ssh_implementations() { + // Test different SSH software banners + let test_cases = vec![ + ("SSH-2.0-OpenSSH_7.4", SshVersion::V2, "OpenSSH_7.4"), + ("SSH-2.0-libssh2_1.9.0", SshVersion::V2, "libssh2_1.9.0"), + ( + "SSH-2.0-WinSCP_release_5.19.6", + SshVersion::V2, + "WinSCP_release_5.19.6", + ), + ("SSH-2.0-Paramiko_2.8.0", SshVersion::V2, "Paramiko_2.8.0"), + ("SSH-1.99-Cisco-1.25", SshVersion::V2, "Cisco-1.25"), // 1.99 maps to V2 + ("SSH-1.5-1.2.27", SshVersion::V1, "1.2.27"), + ]; + + for (banner, expected_version, expected_software) in test_cases { + let payload = format!("{}\r\n", banner).into_bytes(); + let info = analyze_ssh(&payload, false).unwrap(); + + assert_eq!( + info.version, + Some(expected_version), + "Failed for banner: {}", + banner + ); + assert_eq!( + info.server_software.as_deref(), + Some(expected_software), + "Failed for banner: {}", + banner + ); + assert_eq!(info.connection_state, SshConnectionState::Banner); + } + } + + #[test] + fn test_ssh_connection_states() { + // Test KEXINIT detection + let mut kexinit_packet = vec![0, 0, 0, 100, 10, 20]; // packet_len, padding_len, SSH_MSG_KEXINIT + kexinit_packet.extend(vec![0; 94]); // fill the packet + let info = analyze_ssh(&kexinit_packet, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::KeyExchange); + + // Test USERAUTH_REQUEST + let mut userauth_packet = vec![0, 0, 0, 50, 8, 50]; // SSH_MSG_USERAUTH_REQUEST + userauth_packet.extend(vec![0; 44]); + let info = analyze_ssh(&userauth_packet, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::Authentication); + + // Test USERAUTH_SUCCESS + let mut success_packet = vec![0, 0, 0, 20, 5, 52]; // SSH_MSG_USERAUTH_SUCCESS + success_packet.extend(vec![0; 14]); + let info = analyze_ssh(&success_packet, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::Established); + + // Test connection protocol message + let mut conn_packet = vec![0, 0, 0, 30, 6, 95]; // Some connection protocol message + conn_packet.extend(vec![0; 24]); + let info = analyze_ssh(&conn_packet, false).unwrap(); + assert_eq!(info.connection_state, SshConnectionState::Established); + } + + #[test] + fn test_malformed_ssh_packets() { + // Empty payload + assert!(analyze_ssh(&[], false).is_none()); + + // Too short payload + assert!(analyze_ssh(&[1, 2, 3], false).is_none()); + + // Invalid SSH banner + let invalid_banner = b"HTTP/1.1 200 OK\r\n"; + assert!(analyze_ssh(invalid_banner, false).is_none()); + + // Malformed SSH banner (missing parts) + let malformed_banner = b"SSH-\r\n"; + assert!(analyze_ssh(malformed_banner, false).is_none()); + } + + #[test] + fn test_algorithm_detection() { + // Create a payload that contains some SSH algorithms in the text + let payload_with_algos = + b"SSH-2.0-test\r\nsome data aes128-ctr ssh-ed25519 hmac-sha2-256 more data"; + let info = analyze_ssh(payload_with_algos, false).unwrap(); + + assert!(!info.algorithms.is_empty()); + // Should contain some of the algorithms we look for + assert!(info.algorithms.iter().any(|a| a.contains("aes128-ctr"))); + } + + #[test] + fn test_edge_cases() { + // Banner with no software info + let minimal_banner = b"SSH-2.0\r\n"; + let info = analyze_ssh(minimal_banner, false); + // Should still parse successfully but with minimal info + assert!(info.is_some()); + + // Very long banner (should still work) + let long_banner = format!("SSH-2.0-{}\r\n", "x".repeat(200)).into_bytes(); + let info = analyze_ssh(&long_banner, false); + assert!(info.is_some()); + + // Banner with special characters + let special_banner = b"SSH-2.0-OpenSSH_8.9p1-Ubuntu-3~20.04.3\r\n"; + let info = analyze_ssh(special_banner, false).unwrap(); + assert_eq!(info.version, Some(SshVersion::V2)); + } + + #[test] + fn test_client_server_software_distinction() { + // Test server banner (incoming packet) + let server_banner = b"SSH-2.0-OpenSSH_9.9\r\n"; + let server_info = analyze_ssh(server_banner, false).unwrap(); + assert!(server_info.server_software.is_some()); + assert!(server_info.client_software.is_none()); + assert_eq!(server_info.server_software.as_ref().unwrap(), "OpenSSH_9.9"); + + // Test client banner (outgoing packet) + let client_banner = b"SSH-2.0-OpenSSH_9.8\r\n"; + let client_info = analyze_ssh(client_banner, true).unwrap(); + assert!(client_info.client_software.is_some()); + assert!(client_info.server_software.is_none()); + assert_eq!(client_info.client_software.as_ref().unwrap(), "OpenSSH_9.8"); + } + + #[test] + fn test_mixed_content() { + // Test payload that has both banner and packet data + // Banner: "SSH-2.0-OpenSSH_8.9\r\n" (21 bytes) + // Packet: \x00\x00\x00\x14\x05\x32 (packet_len=20, padding_len=5, msg_type=50/0x32) + let mixed_payload = b"SSH-2.0-OpenSSH_8.9\r\n\x00\x00\x00\x14\x05\x32additional data here"; + let info = analyze_ssh(mixed_payload, false).unwrap(); + + assert_eq!(info.version, Some(SshVersion::V2)); + assert!(info.server_software.is_some()); + // The packet structure starts at offset 21, so message type is at offset 26 + // Should detect the SSH_MSG_USERAUTH_REQUEST (50/0x32) in the packet data + assert_eq!(info.connection_state, SshConnectionState::Authentication); + } +} diff --git a/src/network/merge.rs b/src/network/merge.rs index 4eb45dc..6018971 100644 --- a/src/network/merge.rs +++ b/src/network/merge.rs @@ -7,7 +7,7 @@ use crate::network::dpi::DpiResult; use crate::network::parser::{ParsedPacket, TcpFlags}; use crate::network::types::{ ApplicationProtocol, Connection, DnsInfo, DpiInfo, HttpInfo, HttpsInfo, ProtocolState, - QuicConnectionState, QuicInfo, TcpState, + QuicConnectionState, QuicInfo, SshInfo, TcpState, }; /// Update TCP connection state based on observed flags and current state @@ -321,9 +321,9 @@ fn merge_dpi_info(conn: &mut Connection, dpi_result: &DpiResult) { merge_dns_info(old_info, new_info); } - // SSH - no additional merging needed - (ApplicationProtocol::Ssh, ApplicationProtocol::Ssh) => { - // SSH doesn't have additional info to merge + // SSH - merge SSH info + (ApplicationProtocol::Ssh(old_info), ApplicationProtocol::Ssh(new_info)) => { + merge_ssh_info(old_info, new_info); } _ => { @@ -570,6 +570,71 @@ fn merge_dns_info(old_info: &mut DnsInfo, new_info: &DnsInfo) { } } +/// Merge SSH information +fn merge_ssh_info(old_info: &mut SshInfo, new_info: &SshInfo) { + // Update version if not set + if old_info.version.is_none() && new_info.version.is_some() { + old_info.version = new_info.version.clone(); + } + + // Update client software if not set + if old_info.client_software.is_none() && new_info.client_software.is_some() { + old_info.client_software = new_info.client_software.clone(); + } + + // Update server software if not set + if old_info.server_software.is_none() && new_info.server_software.is_some() { + old_info.server_software = new_info.server_software.clone(); + } + + // Update connection state to the more advanced state + use crate::network::types::SshConnectionState; + match (&old_info.connection_state, &new_info.connection_state) { + (SshConnectionState::Banner, _) => { + old_info.connection_state = new_info.connection_state.clone() + } + (SshConnectionState::KeyExchange, SshConnectionState::Authentication) => { + old_info.connection_state = new_info.connection_state.clone() + } + (SshConnectionState::KeyExchange, SshConnectionState::Established) => { + old_info.connection_state = new_info.connection_state.clone() + } + (SshConnectionState::Authentication, SshConnectionState::Established) => { + old_info.connection_state = new_info.connection_state.clone() + } + _ => {} // Keep existing state if it's more advanced + } + + // Merge algorithms - prioritize final negotiated algorithms over initial offers + match (&old_info.connection_state, &new_info.connection_state) { + // If we're moving to Established state and new info has algorithms, use those (final negotiated) + (_, SshConnectionState::Established) if !new_info.algorithms.is_empty() => { + old_info.algorithms = new_info.algorithms.clone(); + } + // If both are in Established state, merge unique algorithms + (SshConnectionState::Established, SshConnectionState::Established) => { + for algo in &new_info.algorithms { + if !old_info.algorithms.contains(algo) { + old_info.algorithms.push(algo.clone()); + } + } + } + // For earlier states, accumulate all seen algorithms + _ => { + for algo in &new_info.algorithms { + if !old_info.algorithms.contains(algo) { + old_info.algorithms.push(algo.clone()); + } + } + } + } + + // Update auth method if not set + if old_info.auth_method.is_none() && new_info.auth_method.is_some() { + old_info.auth_method = new_info.auth_method.clone(); + } +} + /// Update connection rate calculations using sliding window fn update_connection_rates(conn: &mut Connection) { // Use the new rate tracker with sliding window calculation diff --git a/src/network/types.rs b/src/network/types.rs index d33dbb5..63c7908 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -53,7 +53,19 @@ impl std::fmt::Display for ApplicationProtocol { write!(f, "DNS") } } - ApplicationProtocol::Ssh => write!(f, "SSH"), + ApplicationProtocol::Ssh(info) => { + if let Some(software) = info + .server_software + .as_ref() + .or(info.client_software.as_ref()) + { + // Extract just the software name without version details + let software_name = software.split('_').next().unwrap_or(software); + write!(f, "SSH ({})", software_name) + } else { + write!(f, "SSH") + } + } ApplicationProtocol::Quic(info) => { if let Some(tls_info) = &info.tls_info { if let Some(sni) = &tls_info.sni { @@ -110,12 +122,36 @@ pub enum ArpOperation { Reply, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshConnectionState { + Banner, + KeyExchange, + Authentication, + Established, +} + +#[derive(Debug, Clone)] +pub struct SshInfo { + pub version: Option, + pub client_software: Option, + pub server_software: Option, + pub connection_state: SshConnectionState, + pub algorithms: Vec, + pub auth_method: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SshVersion { + V1, + V2, +} + #[derive(Debug, Clone)] pub enum ApplicationProtocol { Http(HttpInfo), Https(HttpsInfo), Dns(DnsInfo), - Ssh, + Ssh(SshInfo), Quic(Box), } @@ -871,7 +907,7 @@ impl Connection { } ApplicationProtocol::Http(_) => "HTTP_UDP".to_string(), ApplicationProtocol::Https(_) => "HTTPS_UDP".to_string(), - ApplicationProtocol::Ssh => "SSH_UDP".to_string(), + ApplicationProtocol::Ssh(_) => "SSH_UDP".to_string(), } } else { // Regular UDP without DPI classification @@ -947,7 +983,7 @@ impl Connection { ApplicationProtocol::Dns(_) => Duration::from_secs(30), ApplicationProtocol::Http(_) => Duration::from_secs(180), ApplicationProtocol::Https(_) => Duration::from_secs(180), - ApplicationProtocol::Ssh => Duration::from_secs(1800), // SSH can be very long-lived + ApplicationProtocol::Ssh(_) => Duration::from_secs(1800), // SSH can be very long-lived } } else { // Regular UDP without DPI classification diff --git a/src/ui.rs b/src/ui.rs index 1b72c18..56e4ff1 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -738,7 +738,42 @@ fn draw_connection_details( Span::raw(connection_state), ])); } - _ => {} + crate::network::types::ApplicationProtocol::Ssh(info) => { + if let Some(version) = &info.version { + details_text.push(Line::from(vec![ + Span::styled(" SSH Version: ", Style::default().fg(Color::Cyan)), + Span::raw(format!("{:?}", version)), + ])); + } + if let Some(server_software) = &info.server_software { + details_text.push(Line::from(vec![ + Span::styled(" Server Software: ", Style::default().fg(Color::Cyan)), + Span::raw(server_software.clone()), + ])); + } + if let Some(client_software) = &info.client_software { + details_text.push(Line::from(vec![ + Span::styled(" Client Software: ", Style::default().fg(Color::Cyan)), + Span::raw(client_software.clone()), + ])); + } + details_text.push(Line::from(vec![ + Span::styled(" Connection State: ", Style::default().fg(Color::Cyan)), + Span::raw(format!("{:?}", info.connection_state)), + ])); + if !info.algorithms.is_empty() { + details_text.push(Line::from(vec![ + Span::styled(" Algorithms: ", Style::default().fg(Color::Cyan)), + Span::raw(info.algorithms.join(", ")), + ])); + } + if let Some(auth_method) = &info.auth_method { + details_text.push(Line::from(vec![ + Span::styled(" Auth Method: ", Style::default().fg(Color::Cyan)), + Span::raw(auth_method.clone()), + ])); + } + } } } None => {