138 lines
3.9 KiB
Rust
138 lines
3.9 KiB
Rust
//! 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"]);
|
|
}
|
|
}
|