From b736ae8721847501f87edf466eb6591117b40eee Mon Sep 17 00:00:00 2001 From: 0xghost42 Date: Wed, 13 May 2026 16:29:26 +0530 Subject: [PATCH 1/2] feat(dpi): add FTP control-channel protocol detection and metadata extraction Adds best-effort Deep Packet Inspection for the plaintext FTP control channel (RFC 959, RFC 2389, RFC 2428, RFC 4217). The data channel is left untouched (arbitrary file bytes). Detection: port 21 / FTPS-implicit 990 hint plus a cheap signature (3-digit reply code or known command set) so non-standard ports are still classified. Metadata: request command + args (USER surfaced as username), response code + message, server software extracted from 220/215 greetings. Display formatter produces 'FTP RETR /pub/x.iso' for requests and 'FTP 220 (ProFTPD)' for greetings. Per-flow merge keeps identity fields first-wins and dialog state latest-wins. Tests: 12 unit tests covering request/response/continuation, USER capture, system-type software extraction, lowercase commands, HTTP/short/non-digit rejection, EPSV. cargo test --all-features: 336 passed. cargo clippy --all-features --all-targets -- -D warnings: clean. cargo fmt --all -- --check: clean. Tracks ROADMAP DPI Enhancements (FTP). --- src/filter.rs | 25 ++++ src/network/dpi/ftp.rs | 276 +++++++++++++++++++++++++++++++++++++++++ src/network/dpi/mod.rs | 18 +++ src/network/merge.rs | 40 +++++- src/network/types.rs | 56 ++++++++- src/ui.rs | 63 ++++++++++ 6 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/network/dpi/ftp.rs diff --git a/src/filter.rs b/src/filter.rs index 87388c04..d8723b9e 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -402,6 +402,31 @@ impl ConnectionFilter { return true; } } + ApplicationProtocol::Ftp(info) => { + if match_text("ftp", fv) { + return true; + } + if let Some(ref cmd) = info.command + && match_text(cmd, fv) + { + return true; + } + if let Some(ref user) = info.username + && match_text(user, fv) + { + return true; + } + if let Some(ref sw) = info.server_software + && match_text(sw, fv) + { + return true; + } + if let Some(code) = info.response_code + && match_text(&code.to_string(), fv) + { + return true; + } + } ApplicationProtocol::Mdns(info) => { if let Some(ref query_name) = info.query_name && match_text(query_name, fv) diff --git a/src/network/dpi/ftp.rs b/src/network/dpi/ftp.rs new file mode 100644 index 00000000..4526f8fc --- /dev/null +++ b/src/network/dpi/ftp.rs @@ -0,0 +1,276 @@ +//! FTP (File Transfer Protocol) Deep Packet Inspection +//! +//! Parses the plaintext FTP control channel (RFC 959, RFC 2389, RFC 2428). +//! Detection is keyed off port 21 plus a cheap start-line signature so non- +//! standard ports are still caught. The data channel (port 20 / passive) is +//! deliberately not inspected — payloads are arbitrary file bytes. + +use crate::network::types::{FtpInfo, FtpMessageType}; + +/// Maximum bytes we ever scan to decide whether a payload looks like FTP. +const MAX_SNIFF_BYTES: usize = 1024; + +/// Commands defined by RFC 959 / 2389 / 2428 / 4217. Matched case-insensitively +/// against the first whitespace-delimited token on the first line of the +/// payload. +const FTP_COMMANDS: &[&str] = &[ + // RFC 959 access control + "USER", "PASS", "ACCT", "CWD", "CDUP", "SMNT", "QUIT", "REIN", + // RFC 959 transfer parameters + "PORT", "PASV", "TYPE", "STRU", "MODE", // RFC 959 service + "RETR", "STOR", "STOU", "APPE", "ALLO", "REST", "RNFR", "RNTO", "ABOR", "DELE", "RMD", "MKD", + "PWD", "LIST", "NLST", "SITE", "SYST", "STAT", "HELP", "NOOP", + // RFC 2389 (FEAT/OPTS) + "FEAT", "OPTS", // RFC 2428 (extended passive / port for IPv6) + "EPSV", "EPRT", // RFC 4217 (FTP over TLS) + "AUTH", "PBSZ", "PROT", "CCC", // RFC 3659 (size/mdtm/mlsd) + "SIZE", "MDTM", "MLSD", "MLST", +]; + +/// Cheap heuristic that returns `true` when a payload's first line plausibly +/// belongs to the FTP control channel. Used so non-standard-port flows can +/// still be classified. +pub fn is_ftp(payload: &[u8]) -> bool { + let line = first_line(payload); + if line.is_empty() { + return false; + } + // Server response: 3-digit code, then space or '-' (continuation marker). + if line.len() >= 4 + && line[0].is_ascii_digit() + && line[1].is_ascii_digit() + && line[2].is_ascii_digit() + && (line[3] == b' ' || line[3] == b'-') + { + return true; + } + // Client request: known command (case-insensitive) followed by space, CRLF, + // or end-of-line. + let upper = first_token_upper(line); + if upper.is_empty() { + return false; + } + FTP_COMMANDS.iter().any(|cmd| cmd.as_bytes() == upper) +} + +/// Parse an FTP control-channel payload. Returns `None` when the payload does +/// not look like FTP. +pub fn analyze_ftp(payload: &[u8]) -> Option { + let line = first_line(payload); + if line.is_empty() { + return None; + } + + // Server response branch: `CCC ` or `CCC-` where CCC is a + // 3-digit reply code. + if line.len() >= 4 + && line[0].is_ascii_digit() + && line[1].is_ascii_digit() + && line[2].is_ascii_digit() + && (line[3] == b' ' || line[3] == b'-') + { + let code = std::str::from_utf8(&line[0..3]).ok()?.parse::().ok()?; + let message = std::str::from_utf8(&line[4..]) + .ok() + .map(|s| s.trim().to_string()); + // RFC 959 §5.4 / 4.2 — service-ready greetings (`220`) and system-type + // replies (`215`) typically embed the server software name; surface it + // so the connection-table column shows "FTP (vsftpd)" etc. + let server_software = if code == 220 || code == 215 { + message.as_deref().map(extract_software_token) + } else { + None + }; + return Some(FtpInfo { + message_type: FtpMessageType::Response, + command: None, + args: None, + response_code: Some(code), + response_message: message, + username: None, + server_software, + }); + } + + // Client request branch. + let upper = first_token_upper(line); + if upper.is_empty() { + return None; + } + let is_command = FTP_COMMANDS.iter().any(|cmd| cmd.as_bytes() == upper); + if !is_command { + return None; + } + let command = std::str::from_utf8(&upper).ok()?.to_string(); + // Trim leading command + whitespace to expose the argument. + let args = std::str::from_utf8(line) + .ok() + .map(|s| s.trim()) + .and_then(|s| { + s.split_once(char::is_whitespace) + .map(|(_, rest)| rest.trim()) + }) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + // RFC 959 §5.4: `USER` carries the login name, useful as a per-flow + // identity hint (plaintext anyway — FTP-AUTH/TLS encrypts later). + let username = if command == "USER" { + args.clone() + } else { + None + }; + + Some(FtpInfo { + message_type: FtpMessageType::Request, + command: Some(command), + args, + response_code: None, + response_message: None, + username, + server_software: None, + }) +} + +fn first_line(payload: &[u8]) -> &[u8] { + let sniff = &payload[..payload.len().min(MAX_SNIFF_BYTES)]; + match sniff.iter().position(|&b| b == b'\n') { + Some(end) => { + // Strip trailing CR if present. + if end > 0 && sniff[end - 1] == b'\r' { + &sniff[..end - 1] + } else { + &sniff[..end] + } + } + None => sniff, + } +} + +fn first_token_upper(line: &[u8]) -> Vec { + let token_end = line + .iter() + .position(|&b| b == b' ' || b == b'\t' || b == b'\r') + .unwrap_or(line.len()); + let token = &line[..token_end]; + if token.is_empty() || token.len() > 6 { + // FTP commands are 3-4 letters; cap at 6 to keep the cost of + // upper-casing tight on hostile traffic. + return Vec::new(); + } + if !token.iter().all(|b| b.is_ascii_alphabetic()) { + return Vec::new(); + } + token.iter().map(|b| b.to_ascii_uppercase()).collect() +} + +fn extract_software_token(message: &str) -> String { + // The greeting line is free-form. Heuristic: take the first whitespace- + // delimited token that contains a letter, strip surrounding punctuation. + // Falls back to the full message when no clean token is found. + for token in message.split_whitespace() { + let trimmed = token.trim_matches(|c: char| !c.is_ascii_alphanumeric()); + if trimmed.chars().any(|c| c.is_ascii_alphabetic()) { + return trimmed.to_string(); + } + } + message.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_server_greeting() { + let payload = b"220 ProFTPD 1.3.7 Server (Example) [::ffff:10.0.0.1]\r\n"; + assert!(is_ftp(payload)); + let info = analyze_ftp(payload).expect("should parse"); + assert!(matches!(info.message_type, FtpMessageType::Response)); + assert_eq!(info.response_code, Some(220)); + assert_eq!(info.server_software.as_deref(), Some("ProFTPD")); + } + + #[test] + fn detects_continuation_response() { + let payload = b"220-Welcome to the FTP service.\r\n220 Ready.\r\n"; + assert!(is_ftp(payload)); + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.response_code, Some(220)); + } + + #[test] + fn parses_user_request() { + let payload = b"USER alice\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert!(matches!(info.message_type, FtpMessageType::Request)); + assert_eq!(info.command.as_deref(), Some("USER")); + assert_eq!(info.username.as_deref(), Some("alice")); + assert_eq!(info.args.as_deref(), Some("alice")); + } + + #[test] + fn parses_retr_request() { + let payload = b"RETR /pub/file.iso\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("RETR")); + assert_eq!(info.args.as_deref(), Some("/pub/file.iso")); + assert!(info.username.is_none()); + } + + #[test] + fn parses_noop_without_args() { + let payload = b"NOOP\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("NOOP")); + assert!(info.args.is_none()); + } + + #[test] + fn parses_lowercase_command() { + let payload = b"quit\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("QUIT")); + } + + #[test] + fn parses_system_type_response() { + let payload = b"215 UNIX Type: L8\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.response_code, Some(215)); + assert_eq!(info.server_software.as_deref(), Some("UNIX")); + } + + #[test] + fn rejects_unknown_request() { + assert!(!is_ftp(b"FOOBAR baz\r\n")); + assert!(analyze_ftp(b"FOOBAR baz\r\n").is_none()); + } + + #[test] + fn rejects_http_payload() { + // OPTIONS is a real HTTP method; its argument must be a path, not a + // 3-digit token. The FTP detector should reject it. + let payload = b"GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert!(!is_ftp(payload)); + assert!(analyze_ftp(payload).is_none()); + } + + #[test] + fn rejects_short_response() { + assert!(!is_ftp(b"220")); + assert!(analyze_ftp(b"220").is_none()); + } + + #[test] + fn rejects_non_digit_prefix() { + // Looks like response prefix but first char is alphabetic. + assert!(!is_ftp(b"2X0 hello\r\n")); + } + + #[test] + fn parses_epsv_extended_passive() { + let payload = b"EPSV\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.command.as_deref(), Some("EPSV")); + } +} diff --git a/src/network/dpi/mod.rs b/src/network/dpi/mod.rs index d2eb422f..3ce6e956 100644 --- a/src/network/dpi/mod.rs +++ b/src/network/dpi/mod.rs @@ -5,6 +5,7 @@ mod bittorrent; mod cipher_suites; mod dhcp; mod dns; +mod ftp; mod http; mod https; mod llmnr; @@ -24,6 +25,8 @@ pub use quic::{is_partial_sni, try_extract_tls_from_reassembler}; // Well-known port numbers used for DPI protocol detection. const PORT_SSH: u16 = 22; const PORT_DNS: u16 = 53; +const PORT_FTP: u16 = 21; +const PORT_FTPS_IMPLICIT: u16 = 990; const PORT_DHCP_SERVER: u16 = 67; const PORT_DHCP_CLIENT: u16 = 68; const PORT_NTP: u16 = 123; @@ -101,6 +104,21 @@ pub fn analyze_tcp_packet( }); } + // 6. Check for FTP control channel (port 21, FTPS implicit 990, or signature). + // Runs before more generic protocols since the start line is unambiguous + // (3-digit reply code OR a small known command set). + if (local_port == PORT_FTP + || remote_port == PORT_FTP + || local_port == PORT_FTPS_IMPLICIT + || remote_port == PORT_FTPS_IMPLICIT + || ftp::is_ftp(payload)) + && let Some(ftp_result) = ftp::analyze_ftp(payload) + { + return Some(DpiResult { + application: ApplicationProtocol::Ftp(ftp_result), + }); + } + // More protocols here... None diff --git a/src/network/merge.rs b/src/network/merge.rs index 8b22fc03..791364a2 100644 --- a/src/network/merge.rs +++ b/src/network/merge.rs @@ -6,7 +6,7 @@ use std::time::{Instant, SystemTime}; use crate::network::dpi::{DpiResult, is_partial_sni, try_extract_tls_from_reassembler}; use crate::network::parser::{ParsedPacket, TcpFlags}; use crate::network::types::{ - ApplicationProtocol, Connection, DnsInfo, DpiInfo, HttpInfo, HttpsInfo, MqttInfo, + ApplicationProtocol, Connection, DnsInfo, DpiInfo, FtpInfo, HttpInfo, HttpsInfo, MqttInfo, ProtocolState, QuicConnectionState, QuicInfo, SshInfo, TcpState, }; @@ -494,6 +494,11 @@ fn merge_dpi_info(conn: &mut Connection, dpi_result: &DpiResult) { merge_mqtt_info(old_info, new_info); } + // FTP - dialog state evolves across requests/responses + (ApplicationProtocol::Ftp(old_info), ApplicationProtocol::Ftp(new_info)) => { + merge_ftp_info(old_info, new_info); + } + _ => { // Keep existing protocol } @@ -858,6 +863,39 @@ fn merge_ssh_info(old_info: &mut SshInfo, new_info: &SshInfo) { } } +/// Merge FTP information across packets in the same control connection. +/// +/// Identity-like fields (`username`, `server_software`) are first-wins so the +/// first observed value is preserved across long-lived sessions. Dialog state +/// (`message_type`, `command`, `args`, `response_code`, `response_message`) is +/// latest-wins so the connection-table column reflects the most recent +/// exchange. +fn merge_ftp_info(old_info: &mut FtpInfo, new_info: &FtpInfo) { + if old_info.username.is_none() && new_info.username.is_some() { + old_info.username.clone_from(&new_info.username); + } + if old_info.server_software.is_none() && new_info.server_software.is_some() { + old_info + .server_software + .clone_from(&new_info.server_software); + } + old_info.message_type = new_info.message_type; + if new_info.command.is_some() { + old_info.command.clone_from(&new_info.command); + } + if new_info.args.is_some() { + old_info.args.clone_from(&new_info.args); + } + if new_info.response_code.is_some() { + old_info.response_code = new_info.response_code; + } + if new_info.response_message.is_some() { + old_info + .response_message + .clone_from(&new_info.response_message); + } +} + /// Merge MQTT information fn merge_mqtt_info(old_info: &mut MqttInfo, new_info: &MqttInfo) { if old_info.version.is_none() && new_info.version.is_some() { diff --git a/src/network/types.rs b/src/network/types.rs index d943d226..dc71580d 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -160,6 +160,28 @@ impl std::fmt::Display for ApplicationProtocol { write!(f, "MQTT {}", info.packet_type) } } + ApplicationProtocol::Ftp(info) => match info.message_type { + FtpMessageType::Request => { + if let Some(cmd) = &info.command { + if let Some(args) = &info.args { + write!(f, "FTP {} {}", cmd, args) + } else { + write!(f, "FTP {}", cmd) + } + } else { + write!(f, "FTP") + } + } + FtpMessageType::Response => { + if let (Some(code), Some(sw)) = (info.response_code, &info.server_software) { + write!(f, "FTP {} ({})", code, sw) + } else if let Some(code) = info.response_code { + write!(f, "FTP {}", code) + } else { + write!(f, "FTP") + } + } + }, } } } @@ -358,6 +380,7 @@ pub enum ApplicationProtocol { BitTorrent(BitTorrentInfo), Stun(StunInfo), Mqtt(MqttInfo), + Ftp(FtpInfo), } impl ApplicationProtocol { @@ -367,6 +390,7 @@ impl ApplicationProtocol { ApplicationProtocol::BitTorrent(_) => "BitTorrent", ApplicationProtocol::Dhcp(_) => "DHCP", ApplicationProtocol::Dns(_) => "DNS", + ApplicationProtocol::Ftp(_) => "FTP", ApplicationProtocol::Http(_) => "HTTP", ApplicationProtocol::Https(_) => "HTTPS", ApplicationProtocol::Llmnr(_) => "LLMNR", @@ -383,6 +407,33 @@ impl ApplicationProtocol { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FtpMessageType { + Request, + Response, +} + +impl fmt::Display for FtpMessageType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + FtpMessageType::Request => "FTP_REQUEST", + FtpMessageType::Response => "FTP_RESPONSE", + }; + write!(f, "{}", name) + } +} + +#[derive(Debug, Clone)] +pub struct FtpInfo { + pub message_type: FtpMessageType, + pub command: Option, + pub args: Option, + pub response_code: Option, + pub response_message: Option, + pub username: Option, + pub server_software: Option, +} + #[derive(Debug, Clone)] pub struct HttpInfo { pub version: HttpVersion, @@ -1478,7 +1529,8 @@ impl AppProtocolDistribution { | ApplicationProtocol::NetBios(_) | ApplicationProtocol::BitTorrent(_) | ApplicationProtocol::Stun(_) - | ApplicationProtocol::Mqtt(_) => dist.other_count += 1, + | ApplicationProtocol::Mqtt(_) + | ApplicationProtocol::Ftp(_) => dist.other_count += 1, } } else { dist.other_count += 1; @@ -1995,6 +2047,7 @@ impl Connection { Cow::Owned(format!("STUN_{}", info.message_class)) } ApplicationProtocol::Mqtt(_) => Cow::Borrowed("MQTT_UDP"), + ApplicationProtocol::Ftp(_) => Cow::Borrowed("FTP_UDP"), } } else { // Regular UDP without DPI classification @@ -2113,6 +2166,7 @@ impl Connection { ApplicationProtocol::BitTorrent(_) => Duration::from_secs(60), ApplicationProtocol::Stun(_) => Duration::from_secs(30), ApplicationProtocol::Mqtt(_) => Duration::from_secs(120), + ApplicationProtocol::Ftp(_) => Duration::from_secs(60), } } else { // Regular UDP without DPI classification diff --git a/src/ui.rs b/src/ui.rs index caebf678..6ab9dea6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3922,6 +3922,69 @@ fn draw_connection_details( ); } } + crate::network::types::ApplicationProtocol::Ftp(info) => { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Message Type", + info.message_type.to_string(), + label_style, + ); + if let Some(cmd) = &info.command { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Command", + cmd.clone(), + label_style, + ); + } + if let Some(args) = &info.args { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Arguments", + args.clone(), + label_style, + ); + } + if let Some(code) = info.response_code { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Response Code", + code.to_string(), + label_style, + ); + } + if let Some(message) = &info.response_message { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Response", + message.clone(), + label_style, + ); + } + if let Some(user) = &info.username { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Username", + user.clone(), + label_style, + ); + } + if let Some(sw) = &info.server_software { + push_detail_field( + &mut details_text, + &mut detail_fields, + "Server Software", + sw.clone(), + label_style, + ); + } + } crate::network::types::ApplicationProtocol::Mqtt(info) => { push_detail_field( &mut details_text, From f537e6ddade63836037e047e350fea29c2a75f82 Mon Sep 17 00:00:00 2001 From: 0xghost42 Date: Thu, 14 May 2026 17:22:49 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat(dpi):=20address=20PR=20#266=20review?= =?UTF-8?q?=20=E2=80=94=20system=5Ftype=20field,=20continuation=20skip,=20?= =?UTF-8?q?drop=20FTPS-implicit,=20Display=20variants,=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per maintainer review on PR #266: 1. Code 215 (SYST) no longer populates server_software. RFC 959 §4.2 defines 215 as returning the OS / system type (UNIX, Windows_NT, ...), not the FTP server software. Added a dedicated FtpInfo.system_type field for the 215 case so the TUI can label it correctly (rendered as 'System Type' alongside 'Server Software'). Updated parses_system_type_response test to assert server_software is None and system_type == "UNIX". 2. extract_software_token no longer over-tags continuation lines. The default greeting on vsftpd, ProFTPD, and Pure-FTPd is multi-line: '220-Welcome to the FTP service.\\r\\n220 ProFTPD ...\\r\\n'. We only see the first line at the DPI layer, so we now skip software extraction when line[3] == b'-' (the RFC 959 §4.2 continuation marker). Added skips_software_extraction_on_220_continuation test that exercises the ProFTPD-style payload and asserts server_software stays None. 3. Dropped PORT_FTPS_IMPLICIT (port 990) from the FTP dispatch in src/network/dpi/mod.rs. Implicit FTPS is TLS from the very first byte; ftp::analyze_ftp always returned None for that traffic and it falls through to the HTTPS/TLS branch correctly. The port-990 check made it look like we were detecting implicit FTPS when we were not. AUTH TLS on port 21 is still handled via the plaintext AUTH command before the TLS upgrade. 4. FtpMessageType::Display now produces 'Request' / 'Response' instead of 'FTP_REQUEST' / 'FTP_RESPONSE'. The protocol-name prefix is already in the surrounding column / panel context, so the variant only needs to disambiguate request-vs-response — previously the details panel showed 'FTP / Message Type: FTP_REQUEST', which read as a duplicated prefix. 5. Added FTP to the README.md 'Deep packet inspection' bullet and to the DPI protocols list in ARCHITECTURE.md. Also threaded the new system_type field through merge_ftp_info (first-wins identity-like field), through the UI details renderer (new 'System Type' row), and through filter.rs so '\sys:UNIX' style queries match it. All 337 lib unit tests pass; cargo fmt / clippy clean. --- ARCHITECTURE.md | 1 + README.md | 2 +- src/filter.rs | 5 ++++ src/network/dpi/ftp.rs | 59 +++++++++++++++++++++++++++++++++++++----- src/network/dpi/mod.rs | 18 ++++++------- src/network/merge.rs | 13 ++++++---- src/network/types.rs | 12 +++++++-- src/ui.rs | 9 +++++++ 8 files changed, 96 insertions(+), 23 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4b459aa6..c7e1f8d3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -67,6 +67,7 @@ Multiple worker threads (up to 4 by default, based on CPU cores) that parse pack - HTTPS/TLS with SNI (Server Name Indication) - DNS queries and responses - SSH connections with version detection + - FTP control channel with commands, response codes, username, server software, and system type - QUIC protocol with CONNECTION_CLOSE frame detection - MQTT with packet types, version, and client identifier - BitTorrent handshakes and DHT messages diff --git a/README.md b/README.md index 12ac19ae..32bf996e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ ## Features - **Per-process attribution**: Every TCP, UDP, and QUIC connection mapped to its owning process, via eBPF on Linux, PKTAP on macOS, native APIs on Windows and FreeBSD. Wireshark and tcpdump can't do this; `netstat` / `ss` can't show live state. -- **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, QUIC, MQTT, BitTorrent, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors. +- **Deep packet inspection**: Identify HTTP, HTTPS/TLS with SNI, DNS, SSH, FTP, QUIC, MQTT, BitTorrent, STUN, NTP, mDNS, LLMNR, DHCP, SNMP, SSDP, and NetBIOS, without external dissectors. - **Security sandboxing**: Landlock (Linux 5.13+), Seatbelt (macOS), token privilege drop + job-object child-process block (Windows). Drops privileges immediately after libpcap initializes. See [SECURITY.md](SECURITY.md). - **TCP network analytics**: Real-time retransmissions, out-of-order packets, and fast-retransmit detection, per-connection and aggregate. - **Smart connection lifecycle**: Protocol-aware timeouts with white → yellow → red staleness indicators. Toggle `t` to keep historic (closed) connections visible for forensics. diff --git a/src/filter.rs b/src/filter.rs index d8723b9e..d8d77f67 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -421,6 +421,11 @@ impl ConnectionFilter { { return true; } + if let Some(ref sys) = info.system_type + && match_text(sys, fv) + { + return true; + } if let Some(code) = info.response_code && match_text(&code.to_string(), fv) { diff --git a/src/network/dpi/ftp.rs b/src/network/dpi/ftp.rs index 4526f8fc..7f906dcd 100644 --- a/src/network/dpi/ftp.rs +++ b/src/network/dpi/ftp.rs @@ -62,7 +62,8 @@ pub fn analyze_ftp(payload: &[u8]) -> Option { } // Server response branch: `CCC ` or `CCC-` where CCC is a - // 3-digit reply code. + // 3-digit reply code. A trailing `-` is the RFC 959 §4.2 continuation + // marker, signalling that the payload is multi-line. if line.len() >= 4 && line[0].is_ascii_digit() && line[1].is_ascii_digit() @@ -70,13 +71,30 @@ pub fn analyze_ftp(payload: &[u8]) -> Option { && (line[3] == b' ' || line[3] == b'-') { let code = std::str::from_utf8(&line[0..3]).ok()?.parse::().ok()?; + let is_continuation = line[3] == b'-'; let message = std::str::from_utf8(&line[4..]) .ok() .map(|s| s.trim().to_string()); - // RFC 959 §5.4 / 4.2 — service-ready greetings (`220`) and system-type - // replies (`215`) typically embed the server software name; surface it - // so the connection-table column shows "FTP (vsftpd)" etc. - let server_software = if code == 220 || code == 215 { + + // Software / system-type extraction is skipped on continuation lines + // (`220-Welcome to the FTP service.\r\n220 ProFTPD ...\r\n`) because + // the first line is human-greeting prose. vsftpd, ProFTPD, and + // Pure-FTPd all emit multi-line greetings by default, so honouring + // the continuation marker is critical — without it we tag + // `server_software = "Welcome"` on most real servers. + let server_software = if code == 220 && !is_continuation { + // RFC 959 §5.4 — service-ready greetings typically embed the + // FTP server software in the first whitespace-delimited token + // (`220 ProFTPD 1.3.7 ...`). + message.as_deref().map(extract_software_token) + } else { + None + }; + // RFC 959 §4.2 — code 215 carries the system / OS name (`UNIX`, + // `Windows_NT`), NOT the FTP server software. Keeping the two + // separate avoids labelling "UNIX" under "Server Software" in the + // TUI. + let system_type = if code == 215 && !is_continuation { message.as_deref().map(extract_software_token) } else { None @@ -89,6 +107,7 @@ pub fn analyze_ftp(payload: &[u8]) -> Option { response_message: message, username: None, server_software, + system_type, }); } @@ -128,6 +147,7 @@ pub fn analyze_ftp(payload: &[u8]) -> Option { response_message: None, username, server_software: None, + system_type: None, }) } @@ -198,6 +218,23 @@ mod tests { assert_eq!(info.response_code, Some(220)); } + #[test] + fn skips_software_extraction_on_220_continuation() { + // Default greeting on vsftpd / ProFTPD / Pure-FTPd is multi-line: + // the first line is `220-` continuation prose ("Welcome to..."), + // followed by `220 ` on a later line. We only see the + // first line at the DPI layer, so we must not pull "Welcome" out of + // it and label it as server software. + let payload = b"220-Welcome to the FTP service.\r\n220 ProFTPD 1.3.7\r\n"; + let info = analyze_ftp(payload).expect("should parse"); + assert_eq!(info.response_code, Some(220)); + assert!( + info.server_software.is_none(), + "continuation lines must not populate server_software, got {:?}", + info.server_software + ); + } + #[test] fn parses_user_request() { let payload = b"USER alice\r\n"; @@ -234,10 +271,20 @@ mod tests { #[test] fn parses_system_type_response() { + // RFC 959 §4.2: a `215` reply returns the OS / system type, NOT the + // FTP server software. Previously we tagged "UNIX" under + // `server_software`, which surfaced under the "Server Software" + // column in the TUI alongside greetings like "ProFTPD". They are + // different things; route 215 to a dedicated `system_type` field + // and confirm `server_software` stays unset for this reply. let payload = b"215 UNIX Type: L8\r\n"; let info = analyze_ftp(payload).expect("should parse"); assert_eq!(info.response_code, Some(215)); - assert_eq!(info.server_software.as_deref(), Some("UNIX")); + assert!( + info.server_software.is_none(), + "215 must not populate server_software" + ); + assert_eq!(info.system_type.as_deref(), Some("UNIX")); } #[test] diff --git a/src/network/dpi/mod.rs b/src/network/dpi/mod.rs index 3ce6e956..0e26d03c 100644 --- a/src/network/dpi/mod.rs +++ b/src/network/dpi/mod.rs @@ -26,7 +26,6 @@ pub use quic::{is_partial_sni, try_extract_tls_from_reassembler}; const PORT_SSH: u16 = 22; const PORT_DNS: u16 = 53; const PORT_FTP: u16 = 21; -const PORT_FTPS_IMPLICIT: u16 = 990; const PORT_DHCP_SERVER: u16 = 67; const PORT_DHCP_CLIENT: u16 = 68; const PORT_NTP: u16 = 123; @@ -104,14 +103,15 @@ pub fn analyze_tcp_packet( }); } - // 6. Check for FTP control channel (port 21, FTPS implicit 990, or signature). - // Runs before more generic protocols since the start line is unambiguous - // (3-digit reply code OR a small known command set). - if (local_port == PORT_FTP - || remote_port == PORT_FTP - || local_port == PORT_FTPS_IMPLICIT - || remote_port == PORT_FTPS_IMPLICIT - || ftp::is_ftp(payload)) + // 6. Check for FTP control channel (port 21 plaintext / AUTH TLS upgrade, + // or signature). Runs before more generic protocols since the start + // line is unambiguous (3-digit reply code OR a small known command set). + // + // Implicit FTPS on port 990 is intentionally NOT routed here: that + // flow is TLS from the very first byte and falls through to the + // HTTPS/TLS branch. AUTH TLS on port 21 is captured via the `AUTH` + // command in the plaintext control channel before the upgrade. + if (local_port == PORT_FTP || remote_port == PORT_FTP || ftp::is_ftp(payload)) && let Some(ftp_result) = ftp::analyze_ftp(payload) { return Some(DpiResult { diff --git a/src/network/merge.rs b/src/network/merge.rs index 791364a2..5ec8e9ce 100644 --- a/src/network/merge.rs +++ b/src/network/merge.rs @@ -865,11 +865,11 @@ fn merge_ssh_info(old_info: &mut SshInfo, new_info: &SshInfo) { /// Merge FTP information across packets in the same control connection. /// -/// Identity-like fields (`username`, `server_software`) are first-wins so the -/// first observed value is preserved across long-lived sessions. Dialog state -/// (`message_type`, `command`, `args`, `response_code`, `response_message`) is -/// latest-wins so the connection-table column reflects the most recent -/// exchange. +/// Identity-like fields (`username`, `server_software`, `system_type`) are +/// first-wins so the first observed value is preserved across long-lived +/// sessions. Dialog state (`message_type`, `command`, `args`, `response_code`, +/// `response_message`) is latest-wins so the connection-table column reflects +/// the most recent exchange. fn merge_ftp_info(old_info: &mut FtpInfo, new_info: &FtpInfo) { if old_info.username.is_none() && new_info.username.is_some() { old_info.username.clone_from(&new_info.username); @@ -879,6 +879,9 @@ fn merge_ftp_info(old_info: &mut FtpInfo, new_info: &FtpInfo) { .server_software .clone_from(&new_info.server_software); } + if old_info.system_type.is_none() && new_info.system_type.is_some() { + old_info.system_type.clone_from(&new_info.system_type); + } old_info.message_type = new_info.message_type; if new_info.command.is_some() { old_info.command.clone_from(&new_info.command); diff --git a/src/network/types.rs b/src/network/types.rs index dc71580d..07b97634 100644 --- a/src/network/types.rs +++ b/src/network/types.rs @@ -415,9 +415,13 @@ pub enum FtpMessageType { impl fmt::Display for FtpMessageType { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + // The protocol-name prefix is already in the surrounding column / + // panel context, so the variant only needs to disambiguate + // request-vs-response. Emitting "FTP_REQUEST" / "FTP_RESPONSE" here + // produced "FTP / Message Type: FTP_REQUEST" in the details panel. let name = match self { - FtpMessageType::Request => "FTP_REQUEST", - FtpMessageType::Response => "FTP_RESPONSE", + FtpMessageType::Request => "Request", + FtpMessageType::Response => "Response", }; write!(f, "{}", name) } @@ -432,6 +436,10 @@ pub struct FtpInfo { pub response_message: Option, pub username: Option, pub server_software: Option, + /// OS / system type from a `215` SYST reply (RFC 959 §4.2): `UNIX`, + /// `Windows_NT`, etc. Kept separate from `server_software` so the TUI + /// can label them distinctly — they describe different things. + pub system_type: Option, } #[derive(Debug, Clone)] diff --git a/src/ui.rs b/src/ui.rs index 6ab9dea6..e328f89a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3984,6 +3984,15 @@ fn draw_connection_details( label_style, ); } + if let Some(sys) = &info.system_type { + push_detail_field( + &mut details_text, + &mut detail_fields, + "System Type", + sys.clone(), + label_style, + ); + } } crate::network::types::ApplicationProtocol::Mqtt(info) => { push_detail_field(