mirror of
https://github.com/domcyrus/rustnet.git
synced 2026-01-04 13:00:04 -06:00
feat: ssh dpi
This commit is contained in:
19
README.md
19
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.
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
483
src/network/dpi/ssh.rs
Normal file
483
src/network/dpi/ssh.rs
Normal file
@@ -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<SshInfo> {
|
||||
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<BannerInfo> {
|
||||
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<Vec<String>> {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<SshVersion>,
|
||||
pub client_software: Option<String>,
|
||||
pub server_software: Option<String>,
|
||||
pub connection_state: SshConnectionState,
|
||||
pub algorithms: Vec<String>,
|
||||
pub auth_method: Option<String>,
|
||||
}
|
||||
|
||||
#[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<QuicInfo>),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
37
src/ui.rs
37
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 => {
|
||||
|
||||
Reference in New Issue
Block a user