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}