Skip to main content

netscli_core/
ping.rs

1use pnet_packet::icmp::{echo_reply, echo_request, IcmpTypes};
2use pnet_packet::ip::IpNextHeaderProtocols;
3use pnet_packet::util::checksum;
4use pnet_packet::Packet;
5use pnet_transport::{transport_channel, TransportChannelType, TransportReceiver};
6use serde::Serialize;
7use std::net::IpAddr;
8use std::sync::atomic::{AtomicU16, Ordering};
9use std::sync::{Arc, OnceLock};
10use std::time::Duration;
11use tokio::sync::Semaphore;
12
13/// Process-wide monotonic counter for ICMP echo sequence numbers.
14///
15/// Raw ICMP sockets on Linux see *every* ICMP packet destined for the host, so
16/// concurrent pings that share the same (identifier, sequence) cannot reliably
17/// match replies to the request that generated them. We use `pid` as the
18/// identifier and a per-process atomic for `seq`.
19static PING_SEQ: AtomicU16 = AtomicU16::new(1);
20
21fn next_seq() -> u16 {
22    // `fetch_add` wraps on u16 overflow; that's fine — after ~65k pings the seq
23    // space recycles, but each in-flight ping at a given instant still has a
24    // unique seq because we only compare against the few pings racing in the
25    // current timeout window.
26    PING_SEQ.fetch_add(1, Ordering::Relaxed)
27}
28
29/// Cache the raw-ICMP capability check — it opens and closes a real socket,
30/// which on Windows is non-trivial and not something we should repeat per
31/// scanner construction.
32static RAW_ICMP_OK: OnceLock<bool> = OnceLock::new();
33
34#[derive(Debug, Clone, Serialize)]
35pub struct PingResult {
36    pub ip: IpAddr,
37    pub rtt_ms: Option<u64>,
38    pub alive: bool,
39    /// Sequence number actually used for this ping (monotonic per process).
40    /// Useful for debugging concurrent scans against logging-enabled targets.
41    pub seq: u16,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub error: Option<String>,
44    /// Indicates which mechanism was used (e.g., `icmpv4`, `tcp-connect`).
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub method: Option<String>,
47}
48
49#[derive(Debug, Clone, Copy)]
50enum PingBackend {
51    /// Raw ICMPv4 via pnet (fast, but may require CAP_NET_RAW / admin privileges).
52    RawIcmpV4,
53    /// TCP connect probe (works without raw socket privileges, also supports IPv6 targets).
54    TcpConnect,
55}
56
57#[derive(Clone)]
58pub struct PingScanner {
59    semaphore: Arc<Semaphore>,
60    backend: PingBackend,
61}
62
63impl PingScanner {
64    pub fn new(concurrency: usize) -> Self {
65        let concurrency = concurrency.max(1);
66        Self {
67            semaphore: Arc::new(Semaphore::new(concurrency)),
68            backend: if *RAW_ICMP_OK.get_or_init(can_use_raw_icmpv4) {
69                PingBackend::RawIcmpV4
70            } else {
71                PingBackend::TcpConnect
72            },
73        }
74    }
75
76    pub async fn ping(&self, target: IpAddr, timeout_ms: u64) -> PingResult {
77        let seq = next_seq();
78        let _permit = match self.semaphore.acquire().await {
79            Ok(p) => p,
80            Err(_) => {
81                return PingResult {
82                    ip: target,
83                    rtt_ms: None,
84                    alive: false,
85                    seq,
86                    error: Some("ping concurrency semaphore closed".to_string()),
87                    method: None,
88                };
89            }
90        };
91
92        // Prefer raw ICMPv4 when available and target is IPv4. Otherwise fall back to TCP probe.
93        match (self.backend, target) {
94            (PingBackend::RawIcmpV4, IpAddr::V4(_)) => ping_icmpv4(target, timeout_ms, seq).await,
95            _ => ping_tcp_probe(target, timeout_ms, seq).await,
96        }
97    }
98}
99
100fn can_use_raw_icmpv4() -> bool {
101    // Best-effort capability check: if we can open an ICMPv4 layer3 channel, raw ping is usable.
102    // This avoids making discovery completely useless when running unprivileged.
103    // Called once via `RAW_ICMP_OK` OnceLock — construction of the transport
104    // channel is non-trivial on Windows so we don't repeat it per scanner.
105    let protocol = TransportChannelType::Layer3(IpNextHeaderProtocols::Icmp);
106    transport_channel(4096, protocol).is_ok()
107}
108
109async fn ping_icmpv4(target: IpAddr, timeout_ms: u64, seq: u16) -> PingResult {
110    // Use tokio::spawn_blocking for pnet operations since they are synchronous.
111    let handle = tokio::task::spawn_blocking(move || send_icmp_echo_v4(target, timeout_ms, seq));
112    match handle.await {
113        Ok(Ok(rtt)) => PingResult {
114            ip: target,
115            rtt_ms: Some(rtt),
116            alive: true,
117            seq,
118            error: None,
119            method: Some("icmpv4".to_string()),
120        },
121        Ok(Err(e)) => PingResult {
122            ip: target,
123            rtt_ms: None,
124            alive: false,
125            seq,
126            error: Some(e.to_string()),
127            method: Some("icmpv4".to_string()),
128        },
129        Err(e) => PingResult {
130            ip: target,
131            rtt_ms: None,
132            alive: false,
133            seq,
134            error: Some(format!("ping task failed: {e}")),
135            method: Some("icmpv4".to_string()),
136        },
137    }
138}
139
140async fn ping_tcp_probe(target: IpAddr, timeout_ms: u64, seq: u16) -> PingResult {
141    use std::net::SocketAddr;
142    use tokio::net::TcpStream;
143    use tokio::time::timeout;
144
145    // A small set of common ports that often yield quick, definitive responses.
146    // We treat both success and connection refused as "host is alive".
147    const PROBE_PORTS: &[u16] = &[80, 443, 22];
148
149    let start = std::time::Instant::now();
150    let mut last_err: Option<String> = None;
151
152    for &port in PROBE_PORTS {
153        let addr = SocketAddr::new(target, port);
154        let attempt = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await;
155        match attempt {
156            Ok(Ok(_stream)) => {
157                return PingResult {
158                    ip: target,
159                    rtt_ms: Some(start.elapsed().as_millis() as u64),
160                    alive: true,
161                    seq,
162                    error: None,
163                    method: Some("tcp-connect".to_string()),
164                };
165            }
166            Ok(Err(e)) => {
167                // Connection refused/reset still proves the host is reachable.
168                if matches!(
169                    e.kind(),
170                    std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset
171                ) {
172                    return PingResult {
173                        ip: target,
174                        rtt_ms: Some(start.elapsed().as_millis() as u64),
175                        alive: true,
176                        seq,
177                        error: None,
178                        method: Some("tcp-connect".to_string()),
179                    };
180                }
181                last_err = Some(format!("tcp probe port {port}: {e}"));
182            }
183            Err(_elapsed) => {
184                last_err = Some(format!("tcp probe port {port}: timeout"));
185            }
186        }
187    }
188
189    PingResult {
190        ip: target,
191        rtt_ms: None,
192        alive: false,
193        seq,
194        error: last_err,
195        method: Some("tcp-connect".to_string()),
196    }
197}
198
199/// Send a single ICMPv4 Echo Request and await a matching Echo Reply.
200///
201/// Filters replies by (address, identifier, sequence) so overlapping pings
202/// from other tasks on the same host don't steal each other's replies.
203fn send_icmp_echo_v4(target: IpAddr, timeout_ms: u64, seq: u16) -> anyhow::Result<u64> {
204    let protocol = TransportChannelType::Layer3(IpNextHeaderProtocols::Icmp);
205    let (mut tx, mut rx) = transport_channel(4096, protocol)?;
206    configure_read_timeout(&rx, Duration::from_millis(10))?;
207
208    let identifier = std::process::id() as u16;
209
210    let mut buffer = [0u8; 64];
211    let mut echo_packet = echo_request::MutableEchoRequestPacket::new(&mut buffer)
212        .ok_or_else(|| anyhow::anyhow!("failed to build ICMP echo request packet"))?;
213
214    echo_packet.set_icmp_type(IcmpTypes::EchoRequest);
215    echo_packet.set_identifier(identifier);
216    echo_packet.set_sequence_number(seq);
217
218    let checksum = checksum(echo_packet.packet(), 1);
219    echo_packet.set_checksum(checksum);
220
221    let start = std::time::Instant::now();
222    tx.send_to(echo_packet, target)?;
223
224    let mut iter = pnet_transport::icmp_packet_iter(&mut rx);
225    let end_time = start + Duration::from_millis(timeout_ms);
226
227    while std::time::Instant::now() < end_time {
228        match iter.next() {
229            Ok((packet, addr)) => {
230                if addr != target || packet.get_icmp_type() != IcmpTypes::EchoReply {
231                    continue;
232                }
233                // Parse the echo-reply body so we can confirm identifier
234                // and sequence match OUR request rather than another
235                // concurrent ping's. `EchoReplyPacket::new` accepts the
236                // full ICMP packet bytes (the type/code/checksum header
237                // plus identifier/seq fields) — use `packet.packet()`
238                // here, not `.payload()`.
239                if let Some(reply) = echo_reply::EchoReplyPacket::new(packet.packet()) {
240                    if reply.get_identifier() == identifier && reply.get_sequence_number() == seq {
241                        return Ok(start.elapsed().as_millis() as u64);
242                    }
243                    // Not ours — keep reading until the timeout.
244                }
245            }
246            Err(_) => {
247                // Read error — usually just the read-timeout firing. Yield
248                // briefly so we don't spin the CPU when there's no traffic.
249                std::thread::sleep(Duration::from_millis(1));
250            }
251        }
252    }
253
254    Err(anyhow::anyhow!("Timeout"))
255}
256
257fn configure_read_timeout(rx: &TransportReceiver, timeout: Duration) -> anyhow::Result<()> {
258    #[cfg(unix)]
259    {
260        pnet_sys::set_socket_receive_timeout(rx.socket.fd, timeout)
261            .map_err(|e| anyhow::anyhow!("failed to set read timeout: {e}"))?;
262    }
263
264    #[cfg(windows)]
265    {
266        use windows_sys::Win32::Networking::WinSock::{
267            setsockopt, SOCKET_ERROR, SOL_SOCKET, SO_RCVTIMEO,
268        };
269
270        let timeout_ms = timeout.as_millis().min(i32::MAX as u128) as i32;
271        let result = unsafe {
272            setsockopt(
273                rx.socket.fd,
274                SOL_SOCKET,
275                SO_RCVTIMEO,
276                (&timeout_ms as *const i32).cast(),
277                std::mem::size_of::<i32>() as i32,
278            )
279        };
280        if result == SOCKET_ERROR {
281            return Err(anyhow::anyhow!(
282                "failed to set read timeout: {}",
283                std::io::Error::last_os_error()
284            ));
285        }
286    }
287
288    Ok(())
289}