//! Minimal EtherNet/IP (CIP) reachability probe. //! //! Sends an EtherNet/IP encapsulation **ListIdentity** command (0x0063) over TCP //! 44818 and checks for a valid encapsulation reply — confirming a CIP device //! without opening a session or writing anything. use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::time::timeout; /// Outcome of an EtherNet/IP handshake probe. #[derive(Debug, Default, PartialEq, Eq)] pub struct EnipProbe { /// A TCP connection to the port was established. pub reachable: bool, /// The endpoint returned a valid EtherNet/IP encapsulation reply. pub is_enip: bool, } /// Probe an EtherNet/IP endpoint with a ListIdentity request. Read-only. pub async fn probe(host: &str, port: u16, budget: Duration) -> EnipProbe { let mut out = EnipProbe::default(); let Ok(Ok(mut stream)) = timeout(budget, TcpStream::connect((host, port))).await else { return out; }; out.reachable = true; // Encapsulation header (24 bytes): command(2) length(2) session(4) status(4) // context(8) options(4). ListIdentity = command 0x0063, everything else zero. let mut req = vec![0u8; 24]; req[0..2].copy_from_slice(&0x0063u16.to_le_bytes()); if timeout(budget, stream.write_all(&req)) .await .ok() .and_then(Result::ok) .is_none() { return out; } let mut hdr = [0u8; 24]; if timeout(budget, stream.read_exact(&mut hdr)) .await .ok() .and_then(Result::ok) .is_none() { return out; } let command = u16::from_le_bytes([hdr[0], hdr[1]]); let status = u32::from_le_bytes([hdr[8], hdr[9], hdr[10], hdr[11]]); // Echoed command + success status = a valid EtherNet/IP encapsulation reply. if command == 0x0063 && status == 0 { out.is_enip = true; } out } #[cfg(test)] mod tests { use super::*; use tokio::net::TcpListener; async fn mock_server() -> std::net::SocketAddr { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let addr = listener.local_addr().expect("addr"); tokio::spawn(async move { let (mut sock, _) = listener.accept().await.expect("accept"); let mut req = [0u8; 24]; if sock.read_exact(&mut req).await.is_err() { return; } // Reply: echo command 0x0063, status 0, no data. let mut hdr = vec![0u8; 24]; hdr[0..2].copy_from_slice(&0x0063u16.to_le_bytes()); let _ = sock.write_all(&hdr).await; }); addr } #[tokio::test] async fn probe_detects_an_ethernetip_device() { let addr = mock_server().await; let p = probe(&addr.ip().to_string(), addr.port(), Duration::from_secs(2)).await; assert!(p.reachable && p.is_enip); } #[tokio::test] async fn probe_reports_unreachable_for_a_closed_port() { let p = probe("127.0.0.1", 1, Duration::from_millis(500)).await; assert!(!p.reachable && !p.is_enip); } }