use std::io::{Read, Write};
use std::net::{IpAddr, SocketAddr, TcpStream};
use std::time::Duration;
use crate::iface;
const BANNER_LIMIT: usize = 512;
const HTTP_PROBE: &[u8] = b"GET / HTTP/1.0\r\n\r\n";
pub fn detect(ip: &str, port: u16, timeout: Duration) -> (Option<String>, Option<String>) {
let banner = ip
.parse::<IpAddr>()
.ok()
.and_then(|addr| grab_banner(SocketAddr::new(addr, port), timeout));
let service = banner
.as_deref()
.and_then(match_service)
.map(str::to_string)
.or_else(|| service_by_port(port).map(str::to_string));
(service, banner)
}
fn grab_banner(addr: SocketAddr, timeout: Duration) -> Option<String> {
let mut stream = iface::tcp_connect_timeout(addr, timeout).ok()?;
let read_timeout = timeout.min(Duration::from_secs(3));
stream.set_read_timeout(Some(read_timeout)).ok()?;
if let Some(banner) = read_banner(&mut stream) {
return Some(banner);
}
stream.write_all(HTTP_PROBE).ok()?;
read_banner(&mut stream)
}
fn read_banner(stream: &mut TcpStream) -> Option<String> {
let mut buf = [0u8; BANNER_LIMIT];
let n = stream.read(&mut buf).ok()?;
if n == 0 {
return None;
}
let banner = sanitize(&buf[..n]);
if banner.is_empty() {
None
} else {
Some(banner)
}
}
fn sanitize(bytes: &[u8]) -> String {
let text = String::from_utf8_lossy(bytes);
let first_line = text.lines().next().unwrap_or("");
first_line
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.trim()
.to_string()
}
pub fn match_service(banner: &str) -> Option<&'static str> {
let lower = banner.to_ascii_lowercase();
if banner.starts_with("SSH-") {
return Some("ssh");
}
if banner.starts_with("HTTP/") || lower.contains("server:") {
return Some("http");
}
if banner.starts_with("220") && (lower.contains("ftp")) {
return Some("ftp");
}
if banner.starts_with("220") && (lower.contains("smtp") || lower.contains("esmtp")) {
return Some("smtp");
}
if banner.starts_with("+OK") {
return Some("pop3");
}
if banner.starts_with("* OK") {
return Some("imap");
}
if lower.contains("mysql") || lower.contains("mariadb") {
return Some("mysql");
}
if lower.contains("-err") || lower.contains("+pong") || lower.contains("redis") {
return Some("redis");
}
None
}
pub fn service_by_port(port: u16) -> Option<&'static str> {
let name = match port {
21 => "ftp",
22 => "ssh",
23 => "telnet",
25 | 587 => "smtp",
53 => "domain",
80 | 8080 | 8000 | 8008 => "http",
110 => "pop3",
143 => "imap",
443 | 8443 => "https",
3306 => "mysql",
3389 => "rdp",
5432 => "postgresql",
6379 => "redis",
_ => return None,
};
Some(name)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_ssh_banner() {
assert_eq!(match_service("SSH-2.0-OpenSSH_8.9p1 Ubuntu"), Some("ssh"));
}
#[test]
fn matches_http_by_status_line_and_server_header() {
assert_eq!(match_service("HTTP/1.1 200 OK"), Some("http"));
assert_eq!(match_service("Server: nginx/1.25.3"), Some("http"));
}
#[test]
fn matches_mail_and_ftp_greetings() {
assert_eq!(
match_service("220 mail.example.com ESMTP Postfix"),
Some("smtp")
);
assert_eq!(match_service("220 (vsFTPd 3.0.5)"), Some("ftp"));
assert_eq!(match_service("+OK POP3 ready"), Some("pop3"));
}
#[test]
fn unknown_banner_matches_nothing() {
assert_eq!(match_service("random gibberish"), None);
}
#[test]
fn falls_back_to_well_known_port_names() {
assert_eq!(service_by_port(22), Some("ssh"));
assert_eq!(service_by_port(443), Some("https"));
assert_eq!(service_by_port(9999), None);
}
#[test]
fn sanitize_takes_the_first_printable_line() {
assert_eq!(sanitize(b"SSH-2.0-OpenSSH\r\nextra\r\n"), "SSH-2.0-OpenSSH");
assert_eq!(sanitize(b" \x01\x02hello\x03 \n"), "hello");
assert_eq!(sanitize(b""), "");
}
#[test]
fn detect_reads_a_banner_from_a_local_server() {
use std::io::Write;
use std::net::TcpListener;
use std::thread;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let port = listener.local_addr().unwrap().port();
let handle = thread::spawn(move || {
if let Ok((mut sock, _)) = listener.accept() {
let _ = sock.write_all(b"SSH-2.0-OpenSSH_9.6\r\n");
}
});
let (service, banner) = detect("127.0.0.1", port, Duration::from_millis(500));
assert_eq!(service.as_deref(), Some("ssh"));
assert_eq!(banner.as_deref(), Some("SSH-2.0-OpenSSH_9.6"));
let _ = handle.join();
}
}