feat(ics): EtherNet/IP probe + OT service-discovery port scan (#175)
This commit was merged in pull request #175.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user