Skip to main content

netscli_core/
ping.rs

1use serde::Serialize;
2use std::net::IpAddr;
3use std::sync::atomic::{AtomicU16, Ordering};
4use std::sync::{Arc, OnceLock};
5use std::time::Duration;
6use tokio::sync::Semaphore;
7
8/// One ICMP backend per platform. They are not interchangeable: see the note
9/// on `send_icmp_echo_v4` below for why Windows cannot use the raw socket.
10#[cfg(not(windows))]
11mod raw_icmp;
12#[cfg(windows)]
13mod windows_icmp;
14
15#[cfg(not(windows))]
16use raw_icmp::send_icmp_echo_v4;
17
18/// Process-wide monotonic counter for ICMP echo sequence numbers.
19///
20/// Raw ICMP sockets on Linux see *every* ICMP packet destined for the host, so
21/// concurrent pings that share the same (identifier, sequence) cannot reliably
22/// match replies to the request that generated them. We use `pid` as the
23/// identifier and a per-process atomic for `seq`.
24static PING_SEQ: AtomicU16 = AtomicU16::new(1);
25
26fn next_seq() -> u16 {
27    // `fetch_add` wraps on u16 overflow; that's fine — after ~65k pings the seq
28    // space recycles, but each in-flight ping at a given instant still has a
29    // unique seq because we only compare against the few pings racing in the
30    // current timeout window.
31    PING_SEQ.fetch_add(1, Ordering::Relaxed)
32}
33
34/// Cache the raw-ICMP capability check — it opens and closes a real socket,
35/// which on Windows is non-trivial and not something we should repeat per
36/// scanner construction.
37static RAW_ICMP_OK: OnceLock<bool> = OnceLock::new();
38
39#[derive(Debug, Clone, Serialize)]
40pub struct PingResult {
41    pub ip: IpAddr,
42    pub rtt_ms: Option<u64>,
43    /// The reply's IP time-to-live, where the platform exposes it: Windows'
44    /// ICMP API does, the raw-socket path on Unix strips the IP header before
45    /// the reply is parsed, and the TCP fallback has no reply packet at all.
46    /// A starting TTL of 64, 128 or 255 hints at the OS family; see
47    /// `os_hint`.
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub ttl: Option<u8>,
50    pub alive: bool,
51    /// Sequence number actually used for this ping (monotonic per process).
52    /// Useful for debugging concurrent scans against logging-enabled targets.
53    pub seq: u16,
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub error: Option<String>,
56    /// Indicates which mechanism was used (e.g., `icmpv4`, `tcp-connect`).
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub method: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy)]
62enum PingBackend {
63    /// Raw ICMPv4 via pnet (fast, but may require CAP_NET_RAW / admin privileges).
64    RawIcmpV4,
65    /// TCP connect probe (works without raw socket privileges, also supports IPv6 targets).
66    TcpConnect,
67}
68
69#[derive(Clone)]
70pub struct PingScanner {
71    semaphore: Arc<Semaphore>,
72    backend: PingBackend,
73}
74
75impl PingScanner {
76    pub fn new(concurrency: usize) -> Self {
77        let concurrency = concurrency.clamp(1, crate::MAX_CONCURRENCY);
78        Self {
79            semaphore: Arc::new(Semaphore::new(concurrency)),
80            backend: if *RAW_ICMP_OK.get_or_init(can_use_raw_icmpv4) {
81                PingBackend::RawIcmpV4
82            } else {
83                PingBackend::TcpConnect
84            },
85        }
86    }
87
88    pub async fn ping(&self, target: IpAddr, timeout_ms: u64) -> PingResult {
89        let seq = next_seq();
90        let _permit = match self.semaphore.acquire().await {
91            Ok(p) => p,
92            Err(_) => {
93                return PingResult {
94                    ip: target,
95                    rtt_ms: None,
96                    ttl: None,
97                    alive: false,
98                    seq,
99                    error: Some("ping concurrency semaphore closed".to_string()),
100                    method: None,
101                };
102            }
103        };
104
105        // Prefer ICMP when the platform offers it for this address family.
106        // Otherwise fall back to the TCP probe, which is a weaker signal: it
107        // can only prove a host is up by getting something back from a port.
108        match (self.backend, target) {
109            (PingBackend::RawIcmpV4, IpAddr::V4(_)) => ping_icmpv4(target, timeout_ms, seq).await,
110            // Unix has no ICMPv6 path here, so v6 keeps falling back there.
111            #[cfg(windows)]
112            (_, IpAddr::V6(v6)) => ping_icmpv6(v6, timeout_ms, seq).await,
113            _ => ping_tcp_probe(target, timeout_ms, seq).await,
114        }
115    }
116}
117
118#[cfg(windows)]
119fn can_use_raw_icmpv4() -> bool {
120    // Windows pings through the IP Helper API rather than a raw socket, and
121    // `IcmpCreateFile` needs no privileges, so ICMP is always available.
122    true
123}
124
125#[cfg(not(windows))]
126use raw_icmp::can_use_raw_icmpv4;
127
128async fn ping_icmpv4(target: IpAddr, timeout_ms: u64, seq: u16) -> PingResult {
129    // Use tokio::spawn_blocking for pnet operations since they are synchronous.
130    let handle = tokio::task::spawn_blocking(move || send_icmp_echo_v4(target, timeout_ms, seq));
131    match handle.await {
132        Ok(Ok((rtt, ttl))) => PingResult {
133            ip: target,
134            rtt_ms: Some(rtt),
135            ttl,
136            alive: true,
137            seq,
138            error: None,
139            method: Some("icmpv4".to_string()),
140        },
141        Ok(Err(e)) => PingResult {
142            ip: target,
143            rtt_ms: None,
144            ttl: None,
145            alive: false,
146            seq,
147            error: Some(e.to_string()),
148            method: Some("icmpv4".to_string()),
149        },
150        Err(e) => PingResult {
151            ip: target,
152            rtt_ms: None,
153            ttl: None,
154            alive: false,
155            seq,
156            error: Some(format!("ping task failed: {e}")),
157            method: Some("icmpv4".to_string()),
158        },
159    }
160}
161
162#[cfg(windows)]
163async fn ping_icmpv6(target: std::net::Ipv6Addr, timeout_ms: u64, seq: u16) -> PingResult {
164    let outcome =
165        tokio::task::spawn_blocking(move || windows_icmp::send_echo6(target, timeout_ms)).await;
166    let (rtt_ms, error) = match outcome {
167        Ok(Ok(rtt)) => (Some(rtt), None),
168        Ok(Err(e)) => (None, Some(e.to_string())),
169        Err(e) => (None, Some(format!("ping task failed: {e}"))),
170    };
171    PingResult {
172        ip: IpAddr::V6(target),
173        alive: rtt_ms.is_some(),
174        rtt_ms,
175        ttl: None,
176        seq,
177        error,
178        method: Some("icmpv6".to_string()),
179    }
180}
181
182async fn ping_tcp_probe(target: IpAddr, timeout_ms: u64, seq: u16) -> PingResult {
183    use std::net::SocketAddr;
184    use tokio::net::TcpStream;
185    use tokio::time::timeout;
186
187    // A small set of common ports that often yield quick, definitive responses.
188    // We treat both success and connection refused as "host is alive".
189    const PROBE_PORTS: &[u16] = &[80, 443, 22];
190
191    let start = std::time::Instant::now();
192    let mut last_err: Option<String> = None;
193
194    for &port in PROBE_PORTS {
195        let addr = SocketAddr::new(target, port);
196        let attempt = timeout(Duration::from_millis(timeout_ms), TcpStream::connect(addr)).await;
197        match attempt {
198            Ok(Ok(_stream)) => {
199                return PingResult {
200                    ip: target,
201                    rtt_ms: Some(start.elapsed().as_millis() as u64),
202                    ttl: None,
203                    alive: true,
204                    seq,
205                    error: None,
206                    method: Some("tcp-connect".to_string()),
207                };
208            }
209            Ok(Err(e)) => {
210                // Connection refused/reset still proves the host is reachable.
211                if matches!(
212                    e.kind(),
213                    std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::ConnectionReset
214                ) {
215                    return PingResult {
216                        ip: target,
217                        rtt_ms: Some(start.elapsed().as_millis() as u64),
218                        ttl: None,
219                        alive: true,
220                        seq,
221                        error: None,
222                        method: Some("tcp-connect".to_string()),
223                    };
224                }
225                last_err = Some(format!("tcp probe port {port}: {e}"));
226            }
227            Err(_elapsed) => {
228                last_err = Some(format!("tcp probe port {port}: timeout"));
229            }
230        }
231    }
232
233    PingResult {
234        ip: target,
235        rtt_ms: None,
236        ttl: None,
237        alive: false,
238        seq,
239        error: last_err,
240        method: Some("tcp-connect".to_string()),
241    }
242}
243
244/// Send a single ICMPv4 Echo Request and await a matching Echo Reply.
245///
246/// Windows and Unix take different routes here, and the reason is a measured
247/// bug rather than portability pedantry. Pinging `127.0.0.1` on Windows --
248/// or the machine's own LAN address -- reported 100% loss while the system
249/// `ping` reported none.
250///
251/// Two things stacked up. Opening a raw ICMP socket needs administrator
252/// rights, so an ordinary run never reached the ICMP path at all: it fell
253/// back to the TCP probe below, which asks ports 80/443/22 and concludes a
254/// host is down when nothing answers -- true of most machines asked about
255/// themselves. Elevated runs have the separate, documented problem that a
256/// Windows raw socket does not observe traffic to an address the host owns.
257///
258/// The IP Helper API sidesteps both: it needs no privileges and it reaches
259/// local addresses. Unix keeps the raw socket, where loopback ICMP is
260/// observable and this dependency would buy nothing.
261#[cfg(windows)]
262fn send_icmp_echo_v4(
263    target: IpAddr,
264    timeout_ms: u64,
265    seq: u16,
266) -> anyhow::Result<(u64, Option<u8>)> {
267    // `seq` is unused here: IcmpSendEcho owns its own request/reply matching
268    // per handle, which is the job the identifier and sequence do on the raw
269    // path. `PingResult` still reports the process-wide counter so the field
270    // means the same thing on both platforms.
271    let _ = seq;
272    windows_icmp::send_echo(target, timeout_ms)
273}