use futures::stream::{self, StreamExt};
use serde::Serialize;
use std::net::{IpAddr, SocketAddr};
use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::sync::Semaphore;
use tokio::time::timeout;
#[derive(Debug, Clone, Serialize)]
pub struct PortResult {
pub port: u16,
pub open: bool,
pub service: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub struct PortScanner {
semaphore: Arc<Semaphore>,
concurrency: usize,
}
#[derive(Debug, Clone)]
pub struct PortScanProgress {
pub completed: usize,
pub total: usize,
pub port: u16,
pub open: bool,
pub open_found: usize,
}
impl PortScanner {
pub fn new(concurrency: usize) -> Self {
let concurrency = concurrency.max(1);
Self {
semaphore: Arc::new(Semaphore::new(concurrency)),
concurrency,
}
}
pub async fn scan_host(
&self,
target: IpAddr,
ports: Vec<u16>,
timeout_ms: u64,
) -> Vec<PortResult> {
self.scan_host_with_progress(target, ports, timeout_ms, None)
.await
}
pub async fn scan_host_with_progress(
&self,
target: IpAddr,
ports: Vec<u16>,
timeout_ms: u64,
progress: Option<Arc<dyn Fn(PortScanProgress) + Send + Sync>>,
) -> Vec<PortResult> {
if ports.is_empty() {
return Vec::new();
}
let total = ports.len();
let completed = Arc::new(AtomicUsize::new(0));
let open_found = Arc::new(AtomicUsize::new(0));
stream::iter(ports)
.map(|port| {
let scanner = self.clone();
let completed = completed.clone();
let open_found = open_found.clone();
let progress = progress.clone();
async move {
let res = scanner.check_port(target, port, timeout_ms).await;
let done = completed.fetch_add(1, Ordering::SeqCst) + 1;
let open_count = if res.open {
open_found.fetch_add(1, Ordering::SeqCst) + 1
} else {
open_found.load(Ordering::SeqCst)
};
if let Some(cb) = &progress {
cb(PortScanProgress {
completed: done,
total,
port,
open: res.open,
open_found: open_count,
});
}
res
}
})
.buffer_unordered(self.concurrency)
.collect::<Vec<PortResult>>()
.await
}
async fn check_port(&self, target: IpAddr, port: u16, timeout_ms: u64) -> PortResult {
let _permit = match self.semaphore.acquire().await {
Ok(p) => p,
Err(_) => {
return PortResult {
port,
open: false,
service: None,
error: Some("scanner shut down (semaphore closed)".to_string()),
};
}
};
let addr = SocketAddr::new(target, port);
let result = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await;
match result {
Ok(Ok(_)) => PortResult {
port,
open: true,
service: Self::guess_service(port),
error: None,
},
Ok(Err(_)) | Err(_) => PortResult {
port,
open: false,
service: None,
error: None,
},
}
}
fn guess_service(port: u16) -> Option<String> {
let name = match port {
20 => "ftp-data",
21 => "ftp",
22 => "ssh",
23 => "telnet",
25 => "smtp",
53 => "dns",
67 => "dhcp-server",
68 => "dhcp-client",
69 => "tftp",
80 => "http",
110 => "pop3",
111 => "rpcbind",
123 => "ntp",
135 => "msrpc",
137 => "netbios-ns",
138 => "netbios-dgm",
139 => "netbios-ssn",
143 => "imap",
161 => "snmp",
162 => "snmp-trap",
389 => "ldap",
443 => "https",
445 => "smb",
465 => "smtps",
514 => "syslog",
587 => "smtp-submission",
636 => "ldaps",
873 => "rsync",
993 => "imaps",
995 => "pop3s",
1080 => "socks",
1433 => "mssql",
1521 => "oracle",
1723 => "pptp",
1883 => "mqtt",
2049 => "nfs",
2375 => "docker",
2376 => "docker-tls",
3000 => "dev-http",
3306 => "mysql",
3389 => "rdp",
4369 => "epmd",
5000 => "upnp",
5060 => "sip",
5061 => "sips",
5222 => "xmpp-client",
5432 => "postgresql",
5601 => "kibana",
5672 => "amqp",
5900 => "vnc",
5984 => "couchdb",
6379 => "redis",
6443 => "kubernetes-api",
6667 => "irc",
7000 => "cassandra",
8000 => "dev-http",
8008 => "http-alt",
8080 => "http-alt",
8081 => "http-alt",
8086 => "influxdb",
8443 => "https-alt",
8888 => "http-alt",
9000 => "http-alt",
9042 => "cassandra",
9090 => "prometheus",
9092 => "kafka",
9200 => "elasticsearch",
9418 => "git",
11211 => "memcached",
15672 => "rabbitmq-mgmt",
27017 => "mongodb",
27018 => "mongodb",
50000 => "sap",
_ => return None,
};
Some(name.to_string())
}
}
impl Clone for PortScanner {
fn clone(&self) -> Self {
Self {
semaphore: self.semaphore.clone(),
concurrency: self.concurrency,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_guess_service() {
assert_eq!(PortScanner::guess_service(22), Some("ssh".to_string()));
assert_eq!(PortScanner::guess_service(80), Some("http".to_string()));
assert_eq!(PortScanner::guess_service(443), Some("https".to_string()));
assert_eq!(PortScanner::guess_service(9999), None);
}
#[test]
fn test_port_scanner_creation() {
let scanner = PortScanner::new(256);
assert_eq!(scanner.concurrency, 256);
}
#[tokio::test]
async fn test_scan_localhost_common_ports() {
let scanner = PortScanner::new(10);
let localhost: IpAddr = "127.0.0.1".parse().unwrap();
let ports = vec![22, 80, 443, 8080];
let results = scanner.scan_host(localhost, ports.clone(), 1000).await;
assert_eq!(results.len(), ports.len());
for result in results {
assert!(ports.contains(&result.port));
if result.open {
if result.service.is_some() {
match result.port {
22 => assert_eq!(result.service, Some("ssh".to_string())),
80 => assert_eq!(result.service, Some("http".to_string())),
443 => assert_eq!(result.service, Some("https".to_string())),
8080 => assert_eq!(result.service, Some("http-alt".to_string())),
_ => {}
}
}
} else {
assert_eq!(result.service, None);
}
}
}
}