//! TCP service discovery for a device. //! //! Connect-scans a curated set of OT/ICS and insecure-management ports and reports //! the ones that are open. The deep protocol probes own Modbus (502), OPC UA //! (4840) and EtherNet/IP (44818); this surfaces the *rest* of the industrial and //! cleartext-management surface (Siemens S7, DNP3, CODESYS programming, Telnet, …). use std::time::Duration; use futures_util::future::join_all; use tokio::net::TcpStream; use tokio::time::timeout; /// Whether an open port is an industrial protocol or an insecure management service. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PortKind { /// An industrial control protocol (typically unauthenticated). Ics, /// A cleartext management service (credentials/data in the clear). InsecureMgmt, } /// A well-known port worth flagging when open. #[derive(Debug, Clone, Copy)] pub struct KnownPort { pub port: u16, pub service: &'static str, pub kind: PortKind, pub note: &'static str, } /// The curated scan list. Excludes 502 / 4840 / 44818 — those have dedicated deep /// probes (Modbus, OPC UA, EtherNet/IP) that report richer findings. pub const KNOWN_PORTS: &[KnownPort] = &[ KnownPort { port: 102, service: "S7comm / ISO-TSAP", kind: PortKind::Ics, note: "Siemens S7 PLC communication", }, KnownPort { port: 20000, service: "DNP3", kind: PortKind::Ics, note: "SCADA / DNP3", }, KnownPort { port: 1911, service: "Niagara Fox", kind: PortKind::Ics, note: "Tridium Niagara building automation", }, KnownPort { port: 11740, service: "CODESYS", kind: PortKind::Ics, note: "CODESYS programming protocol", }, KnownPort { port: 1962, service: "PCWorx", kind: PortKind::Ics, note: "Phoenix Contact PCWorx", }, KnownPort { port: 9600, service: "OMRON FINS", kind: PortKind::Ics, note: "Omron FINS", }, KnownPort { port: 789, service: "Red Lion Crimson", kind: PortKind::Ics, note: "Red Lion controllers", }, KnownPort { port: 23, service: "Telnet", kind: PortKind::InsecureMgmt, note: "cleartext remote shell", }, KnownPort { port: 21, service: "FTP", kind: PortKind::InsecureMgmt, note: "cleartext file transfer", }, ]; /// Connect-scan `ports` on `host` (concurrently) and return those that accept a /// TCP connection. pub async fn scan<'a>(host: &str, ports: &'a [KnownPort], budget: Duration) -> Vec<&'a KnownPort> { let checks = ports.iter().map(|kp| async move { let open = timeout(budget, TcpStream::connect((host, kp.port))) .await .map(|r| r.is_ok()) .unwrap_or(false); (kp, open) }); join_all(checks) .await .into_iter() .filter_map(|(kp, open)| open.then_some(kp)) .collect() } #[cfg(test)] mod tests { use super::*; use tokio::net::TcpListener; #[tokio::test] async fn scan_reports_only_open_ports() { // Bind one port (open) and pick another that is closed. let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let open_port = listener.local_addr().expect("addr").port(); let ports = [ KnownPort { port: open_port, service: "test-open", kind: PortKind::Ics, note: "", }, KnownPort { port: 1, service: "test-closed", kind: PortKind::InsecureMgmt, note: "", }, ]; let found = scan("127.0.0.1", &ports, Duration::from_millis(500)).await; let services: Vec<&str> = found.iter().map(|p| p.service).collect(); assert_eq!(services, vec!["test-open"]); } }