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 87388c04..d8d77f67 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -402,6 +402,36 @@ 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(ref sys) = info.system_type + && match_text(sys, 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..7f906dcd --- /dev/null +++ b/src/network/dpi/ftp.rs @@ -0,0 +1,323 @@ +//! 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. 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() + && line[2].is_ascii_digit() + && (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()); + + // 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 + }; + return Some(FtpInfo { + message_type: FtpMessageType::Response, + command: None, + args: None, + response_code: Some(code), + response_message: message, + username: None, + server_software, + system_type, + }); + } + + // 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, + system_type: 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 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"; + 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() { + // 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!( + info.server_software.is_none(), + "215 must not populate server_software" + ); + assert_eq!(info.system_type.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..0e26d03c 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,7 @@ 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_DHCP_SERVER: u16 = 67; const PORT_DHCP_CLIENT: u16 = 68; const PORT_NTP: u16 = 123; @@ -101,6 +103,22 @@ pub fn analyze_tcp_packet( }); } + // 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 { + application: ApplicationProtocol::Ftp(ftp_result), + }); + } + // More protocols here... None diff --git a/src/network/merge.rs b/src/network/merge.rs index 8b22fc03..5ec8e9ce 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,42 @@ 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`, `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); + } + if old_info.server_software.is_none() && new_info.server_software.is_some() { + old_info + .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); + } + 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..07b97634 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,41 @@ 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 { + // 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 => "Request", + FtpMessageType::Response => "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, + /// 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)] pub struct HttpInfo { pub version: HttpVersion, @@ -1478,7 +1537,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 +2055,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 +2174,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..e328f89a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -3922,6 +3922,78 @@ 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, + ); + } + 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( &mut details_text,