pq-map 0.2.3

Domain service and cryptography discovery from Certificate Transparency and DNS
Documentation
use std::net::{IpAddr, SocketAddr};
use std::time::Duration;

use chrono::Utc;

use crate::config::Config;
use crate::model::{PortScan, ProbeStatus, ScanState};

use super::probe_one;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortKind {
    DirectTls,
    StartTls,
}

const GENERIC_WEB: &[(u16, PortKind)] = &[
    (8443, PortKind::DirectTls),
    (9443, PortKind::DirectTls),
    (4443, PortKind::DirectTls),
    (8843, PortKind::DirectTls),
    (10000, PortKind::DirectTls),
];

pub const PORT_CAP: usize = 5;

pub fn ports_for_host(name: &str, generic_override: &[u16], is_mx: bool) -> Vec<(u16, PortKind)> {
    let label = name.split('.').next().unwrap_or("").to_ascii_lowercase();
    let table: &[(u16, PortKind)] =
        if is_mx || starts_any(&label, &["smtp", "mx", "mailrelay", "mailout", "post"]) {
            &[
                (465, PortKind::DirectTls),
                (587, PortKind::StartTls),
                (25, PortKind::StartTls),
            ]
        } else if starts_any(&label, &["imap", "webmail", "mail"]) {
            &[
                (993, PortKind::DirectTls),
                (995, PortKind::DirectTls),
                (465, PortKind::DirectTls),
                (587, PortKind::StartTls),
            ]
        } else if label.starts_with("pop") {
            &[(995, PortKind::DirectTls)]
        } else if starts_any(&label, &["sip", "sips"]) {
            &[(5061, PortKind::DirectTls)]
        } else if starts_any(&label, &["ldap", "ldaps", "directory"]) {
            &[(636, PortKind::DirectTls)]
        } else if starts_any(&label, &["mqtt", "broker"]) {
            &[(8883, PortKind::DirectTls)]
        } else if starts_any(&label, &["xmpp", "jabber", "conference", "chat"]) {
            &[(5222, PortKind::StartTls), (5223, PortKind::DirectTls)]
        } else if generic_override.is_empty() {
            GENERIC_WEB
        } else {
            return generic_override
                .iter()
                .take(PORT_CAP)
                .map(|p| (*p, PortKind::DirectTls))
                .collect();
        };
    table.iter().take(PORT_CAP).copied().collect()
}

fn starts_any(label: &str, prefixes: &[&str]) -> bool {
    prefixes.iter().any(|p| label.starts_with(p))
}

async fn tcp_connect(ip: IpAddr, port: u16, timeout: Duration) -> ScanState {
    let fut = async {
        match tokio::net::TcpStream::connect(SocketAddr::new(ip, port)).await {
            Ok(_) => ScanState::Open,
            Err(e) => match super::classify_io(&e) {
                super::Failure::TcpRefused => ScanState::Closed,
                _ => ScanState::Filtered,
            },
        }
    };
    match tokio::time::timeout(timeout, fut).await {
        Err(_) => ScanState::Filtered,
        Ok(state) => state,
    }
}

pub async fn sweep_host(
    host: &str,
    ip: IpAddr,
    ports: &[(u16, PortKind)],
    cfg: &Config,
) -> Vec<PortScan> {
    let mut out = Vec::new();
    for (port, kind) in ports {
        let port = *port;
        let mut scan = PortScan {
            port,
            state: tcp_connect(ip, port, cfg.sweep_connect_timeout).await,
            served: None,
            negotiated: None,
            observed_at: Some(Utc::now()),
        };
        if scan.state == ScanState::Open && *kind == PortKind::DirectTls {
            let ep = probe_one(host, Some(ip), port, cfg).await;
            if ep.status == ProbeStatus::Ok {
                scan.served = ep.served;
                scan.negotiated = ep.negotiated;
            }
        } else if scan.state == ScanState::Open
            && *kind == PortKind::StartTls
            && matches!(port, 25 | 587)
        {
            let owned = host.to_string();
            let timeout = cfg.probe_timeout;
            let attempt = tokio::time::timeout(
                timeout,
                tokio::task::spawn_blocking(move || {
                    super::tls::openssl_starttls_smtp(&owned, Some(ip), port, timeout)
                }),
            )
            .await;
            if let Ok(Ok(Ok((served, negotiated)))) = attempt {
                scan.served = Some(served);
                scan.negotiated = Some(negotiated);
            }
        }
        out.push(scan);
        tokio::time::sleep(pacing(cfg.sweep_delay_ms)).await;
    }
    out
}

fn pacing(base_ms: u64) -> Duration {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|t| t.subsec_nanos() as u64)
        .unwrap_or(0);
    let jitter = base_ms * 3 / 4 + (nanos % (base_ms / 2).max(1));
    Duration::from_millis(jitter)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn family_matching_is_ordered() {
        let smtp = ports_for_host("mx1c.example.com", &[], false);
        assert_eq!(smtp.len(), 3);
        assert_eq!(smtp[0].0, 465);
        assert!(matches!(smtp[1].1, PortKind::StartTls));

        let mail = ports_for_host("webmail.example.pl", &[], false);
        assert_eq!(mail[0].0, 993);
        assert_eq!(mail.len(), 4);

        let xmpp = ports_for_host("conference.examplex.com", &[], false);
        assert_eq!(
            xmpp.iter().map(|p| p.0).collect::<Vec<_>>(),
            vec![5222, 5223]
        );

        let web = ports_for_host("vpn.example.pl", &[], false);
        assert_eq!(
            web.iter().map(|p| p.0).collect::<Vec<_>>(),
            vec![8443, 9443, 4443, 8843, 10000]
        );
        assert_eq!(web.len(), PORT_CAP);
    }

    #[test]
    fn generic_override_replaces_web_list() {
        let ports = ports_for_host("vpn.example.pl", &[7000, 7001, 7002], false);
        assert_eq!(
            ports.iter().map(|p| p.0).collect::<Vec<_>>(),
            vec![7000, 7001, 7002]
        );
        let smtp = ports_for_host("mx.example.pl", &[7000], false);
        assert_eq!(smtp[0].0, 465);
    }

    #[test]
    fn port_cap_holds_for_every_family() {
        for name in [
            "mx.a.pl",
            "mail.a.pl",
            "pop.a.pl",
            "sip.a.pl",
            "ldap.a.pl",
            "mqtt.a.pl",
            "xmpp.a.pl",
            "web.a.pl",
        ] {
            assert!(ports_for_host(name, &[], false).len() <= PORT_CAP, "{name}");
        }
    }
}