use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::time::Duration;
use tokio::net::TcpStream;
use tokio::time::timeout;
pub const TOP_PORTS: &[u16] = &[
80, 443, 22, 21, 25, 53, 8080, 8443, 3306, 5432, 6379, 27017, 9200, 11211, 2375, 2379, 5000,
8000, 8888, 9000,
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortProbe {
pub ip: IpAddr,
pub port: u16,
pub state: PortState,
pub banner: Option<String>,
pub service: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortState {
Open,
Closed,
Filtered,
}
impl PortState {
pub fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
Self::Filtered => "filtered",
}
}
}
pub async fn reverse_dns(ip: IpAddr) -> Option<String> {
use hickory_resolver::proto::rr::RData;
use hickory_resolver::TokioResolver;
let builder = TokioResolver::builder_tokio().ok()?;
let resolver = builder.build().ok()?;
let name = match ip {
IpAddr::V4(v4) => {
let o = v4.octets();
format!("{}.{}.{}.{}.in-addr.arpa.", o[3], o[2], o[1], o[0])
}
IpAddr::V6(v6) => {
let mut s = String::with_capacity(73);
for byte in v6.octets().iter().rev() {
s.push_str(&format!("{:x}.{:x}.", byte & 0x0f, byte >> 4));
}
s.push_str("ip6.arpa.");
s
}
};
let lookup = resolver.reverse_lookup(name).await.ok()?;
for record in lookup.answers() {
if let RData::PTR(ptr) = &record.data {
return Some(ptr.0.to_utf8().trim_end_matches('.').to_string());
}
}
None
}
pub async fn tcp_probe(ip: IpAddr, port: u16, connect_timeout: Duration) -> PortProbe {
let sock = SocketAddr::new(ip, port);
match timeout(connect_timeout, TcpStream::connect(sock)).await {
Ok(Ok(stream)) => {
let banner = read_banner(stream).await;
let service = banner
.as_deref()
.and_then(classify_banner)
.map(String::from);
PortProbe {
ip,
port,
state: PortState::Open,
banner,
service,
}
}
Ok(Err(e)) => {
if e.kind() == std::io::ErrorKind::ConnectionRefused {
PortProbe {
ip,
port,
state: PortState::Closed,
banner: None,
service: None,
}
} else {
PortProbe {
ip,
port,
state: PortState::Filtered,
banner: None,
service: None,
}
}
}
Err(_) => PortProbe {
ip,
port,
state: PortState::Filtered,
banner: None,
service: None,
},
}
}
pub async fn tcp_probe_ports(
ip: IpAddr,
ports: &[u16],
connect_timeout: Duration,
) -> Vec<PortProbe> {
use futures::stream::{FuturesUnordered, StreamExt};
let mut set: FuturesUnordered<_> = ports
.iter()
.copied()
.map(|p| tcp_probe(ip, p, connect_timeout))
.collect();
let mut out = Vec::with_capacity(ports.len());
while let Some(r) = set.next().await {
out.push(r);
}
out.sort_by_key(|p| p.port);
out
}
async fn read_banner(mut stream: TcpStream) -> Option<String> {
use tokio::io::AsyncReadExt;
let mut buf = [0u8; 256];
let n = match timeout(Duration::from_millis(400), stream.read(&mut buf)).await {
Ok(Ok(n)) if n > 0 => n,
_ => return None,
};
let text = String::from_utf8_lossy(&buf[..n]).into_owned();
let cleaned: String = text
.chars()
.map(|c| {
if c.is_control() && c != '\n' && c != '\r' && c != '\t' {
' '
} else {
c
}
})
.collect();
let trimmed = cleaned.trim().to_string();
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
}
fn classify_banner(banner: &str) -> Option<&'static str> {
let lower = banner.to_ascii_lowercase();
if lower.starts_with("ssh-") {
return Some("ssh");
}
if lower.starts_with("220 ") && (lower.contains("ftp") || lower.contains("proftpd")) {
return Some("ftp");
}
if lower.starts_with("220 ") && (lower.contains("smtp") || lower.contains("esmtp")) {
return Some("smtp");
}
if lower.starts_with("+ok") || lower.starts_with("* ok") {
return Some("imap/pop");
}
if lower.contains("mysql") {
return Some("mysql");
}
if lower.starts_with("http/") || lower.contains("server: ") {
return Some("http");
}
None
}
pub fn cloud_lookup(ip: IpAddr) -> Option<CloudTag> {
let v4 = match ip {
IpAddr::V4(v) => v,
IpAddr::V6(v) => return cloud_lookup_v6(v),
};
for (cidr, tag) in CLOUD_V4 {
let net: Ipv4Addr = cidr.0.parse().expect("static CIDR parse");
let bits = cidr.1;
if in_v4(v4, net, bits) {
return Some(tag.clone());
}
}
None
}
fn cloud_lookup_v6(ip: Ipv6Addr) -> Option<CloudTag> {
for (cidr, tag) in CLOUD_V6 {
let net: Ipv6Addr = cidr.0.parse().expect("static CIDR parse");
let bits = cidr.1;
if in_v6(ip, net, bits) {
return Some(tag.clone());
}
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CloudTag {
pub provider: &'static str,
pub service: Option<&'static str>,
}
#[rustfmt::skip]
static CLOUD_V4: &[((&str, u8), CloudTag)] = &[
(("173.245.48.0", 20), CloudTag { provider: "cloudflare", service: None }),
(("103.21.244.0", 22), CloudTag { provider: "cloudflare", service: None }),
(("103.22.200.0", 22), CloudTag { provider: "cloudflare", service: None }),
(("103.31.4.0", 22), CloudTag { provider: "cloudflare", service: None }),
(("141.101.64.0", 18), CloudTag { provider: "cloudflare", service: None }),
(("108.162.192.0",18), CloudTag { provider: "cloudflare", service: None }),
(("190.93.240.0", 20), CloudTag { provider: "cloudflare", service: None }),
(("188.114.96.0", 20), CloudTag { provider: "cloudflare", service: None }),
(("197.234.240.0",22), CloudTag { provider: "cloudflare", service: None }),
(("198.41.128.0", 17), CloudTag { provider: "cloudflare", service: None }),
(("162.158.0.0", 15), CloudTag { provider: "cloudflare", service: None }),
(("104.16.0.0", 13), CloudTag { provider: "cloudflare", service: None }),
(("104.24.0.0", 14), CloudTag { provider: "cloudflare", service: None }),
(("172.64.0.0", 13), CloudTag { provider: "cloudflare", service: None }),
(("131.0.72.0", 22), CloudTag { provider: "cloudflare", service: None }),
(("52.84.0.0", 15), CloudTag { provider: "aws", service: Some("cloudfront") }),
(("54.192.0.0", 16), CloudTag { provider: "aws", service: Some("cloudfront") }),
(("99.86.0.0", 16), CloudTag { provider: "aws", service: Some("cloudfront") }),
(("13.224.0.0", 14), CloudTag { provider: "aws", service: Some("cloudfront") }),
(("13.248.0.0", 14), CloudTag { provider: "aws", service: Some("global-accelerator") }),
(("76.223.0.0", 16), CloudTag { provider: "aws", service: Some("global-accelerator") }),
(("3.0.0.0", 8), CloudTag { provider: "aws", service: None }),
(("151.101.0.0", 16), CloudTag { provider: "fastly", service: None }),
(("199.232.0.0", 16), CloudTag { provider: "fastly", service: None }),
(("35.190.0.0", 15), CloudTag { provider: "gcp", service: None }),
(("34.64.0.0", 10), CloudTag { provider: "gcp", service: None }),
(("13.64.0.0", 11), CloudTag { provider: "azure", service: None }),
(("20.0.0.0", 8), CloudTag { provider: "azure", service: None }),
];
#[rustfmt::skip]
static CLOUD_V6: &[((&str, u8), CloudTag)] = &[
(("2400:cb00::", 32), CloudTag { provider: "cloudflare", service: None }),
(("2606:4700::", 32), CloudTag { provider: "cloudflare", service: None }),
(("2803:f800::", 32), CloudTag { provider: "cloudflare", service: None }),
(("2a06:98c0::", 29), CloudTag { provider: "cloudflare", service: None }),
(("2c0f:f248::", 32), CloudTag { provider: "cloudflare", service: None }),
(("2600:9000::", 28), CloudTag { provider: "aws", service: Some("cloudfront") }),
];
fn in_v4(ip: Ipv4Addr, net: Ipv4Addr, bits: u8) -> bool {
if bits == 0 {
return true;
}
let mask: u32 = if bits >= 32 {
u32::MAX
} else {
!((1u32 << (32 - bits)) - 1)
};
(u32::from(ip) & mask) == (u32::from(net) & mask)
}
fn in_v6(ip: Ipv6Addr, net: Ipv6Addr, bits: u8) -> bool {
if bits == 0 {
return true;
}
let ip_bits = u128::from(ip);
let net_bits = u128::from(net);
let mask: u128 = if bits >= 128 {
u128::MAX
} else {
!((1u128 << (128 - bits)) - 1)
};
(ip_bits & mask) == (net_bits & mask)
}
pub fn cloud_rollup(ips: &[IpAddr]) -> HashMap<&'static str, usize> {
let mut m: HashMap<&'static str, usize> = HashMap::new();
for ip in ips {
if let Some(tag) = cloud_lookup(*ip) {
*m.entry(tag.provider).or_insert(0) += 1;
}
}
m
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn port_state_stringify() {
assert_eq!(PortState::Open.as_str(), "open");
assert_eq!(PortState::Closed.as_str(), "closed");
assert_eq!(PortState::Filtered.as_str(), "filtered");
}
#[test]
fn cloud_match_cloudflare_v4() {
let ip = IpAddr::V4(Ipv4Addr::from_str("104.16.132.229").unwrap());
let tag = cloud_lookup(ip).expect("cf range match");
assert_eq!(tag.provider, "cloudflare");
}
#[test]
fn cloud_match_aws_global_accelerator() {
let ip = IpAddr::V4(Ipv4Addr::from_str("13.248.237.249").unwrap());
let tag = cloud_lookup(ip).expect("aws GA match");
assert_eq!(tag.provider, "aws");
assert_eq!(tag.service, Some("global-accelerator"));
}
#[test]
fn cloud_match_cloudflare_v6() {
let ip = IpAddr::V6(Ipv6Addr::from_str("2606:4700:4700::1111").unwrap());
let tag = cloud_lookup(ip).expect("cf v6 match");
assert_eq!(tag.provider, "cloudflare");
}
#[test]
fn cloud_non_match_returns_none() {
let ip = IpAddr::V4(Ipv4Addr::from_str("1.1.1.1").unwrap());
let _ = cloud_lookup(ip);
}
#[test]
fn classify_banner_ssh() {
assert_eq!(classify_banner("SSH-2.0-OpenSSH_8.9p1"), Some("ssh"));
}
#[test]
fn classify_banner_smtp() {
assert_eq!(
classify_banner("220 mx.google.com ESMTP abc123 - gsmtp"),
Some("smtp")
);
}
#[test]
fn cloud_rollup_counts_per_provider() {
let ips = [
IpAddr::V4(Ipv4Addr::from_str("104.16.1.1").unwrap()),
IpAddr::V4(Ipv4Addr::from_str("104.17.1.1").unwrap()),
IpAddr::V4(Ipv4Addr::from_str("13.248.0.1").unwrap()),
IpAddr::V4(Ipv4Addr::from_str("8.8.8.8").unwrap()),
];
let rollup = cloud_rollup(&ips);
assert_eq!(rollup.get("cloudflare"), Some(&2));
assert_eq!(rollup.get("aws"), Some(&1));
assert!(!rollup.contains_key("gcp"));
}
}