Skip to main content

ts_netstack_smoltcp_socket/
ping.rs

1//! Overlay ICMPv4 echo (`ping`) over a netstack [`Channel`][netcore::Channel].
2//!
3//! This is the raw-socket analogue of tsnet's `LocalClient.Ping`: it opens a raw ICMP socket
4//! on the overlay netstack, emits an ICMPv4 Echo Request to a peer's tailnet IP, and waits for
5//! the matching Echo Reply, returning the round-trip time.
6//!
7//! Anti-leak: this rides the overlay netstack only (via the [`CreateSocket`] channel); it never
8//! touches a host socket. ICMPv4 only — IPv6 is rejected (IPv6-off posture).
9
10// The echo-build/match helpers (and their smoltcp wire imports + `Ipv4Addr` + `alloc::vec`) are used
11// only by the `tokio`-gated `ping` entry point and the tests; `IpAddr`/`Duration`/`CreateSocket`
12// only by `ping` itself. Gating the imports to match their users keeps the default (no-`tokio`) lib
13// build free of unused-import warnings under `-D warnings`.
14#[cfg(any(feature = "tokio", test))]
15use alloc::vec;
16#[cfg(any(feature = "tokio", test))]
17use core::net::Ipv4Addr;
18#[cfg(feature = "tokio")]
19use core::{net::IpAddr, time::Duration};
20
21#[cfg(any(feature = "tokio", test))]
22use netcore::smoltcp::{
23    phy::ChecksumCapabilities,
24    wire::{IPV4_HEADER_LEN, Icmpv4Packet, Icmpv4Repr, IpProtocol, Ipv4Packet, Ipv4Repr},
25};
26
27#[cfg(feature = "tokio")]
28use crate::CreateSocket;
29
30/// Errors returned by [`ping`].
31#[derive(Debug)]
32pub enum PingError {
33    /// No matching Echo Reply arrived before the timeout elapsed.
34    Timeout,
35    /// The destination was an IPv6 address; only ICMPv4 is supported.
36    Ipv6Unsupported,
37    /// An underlying netstack error occurred.
38    Net(netcore::Error),
39}
40
41impl core::fmt::Display for PingError {
42    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
43        match self {
44            Self::Timeout => f.write_str("ping timed out"),
45            Self::Ipv6Unsupported => f.write_str("ICMPv6 ping is unsupported (IPv6 is off)"),
46            Self::Net(e) => write!(f, "netstack error: {e}"),
47        }
48    }
49}
50
51impl core::error::Error for PingError {
52    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
53        match self {
54            Self::Net(e) => Some(e),
55            _ => None,
56        }
57    }
58}
59
60impl From<netcore::Error> for PingError {
61    fn from(e: netcore::Error) -> Self {
62        Self::Net(e)
63    }
64}
65
66// Only the `tokio`-gated `ping` path (and its tests) build/scan ICMP echo packets; gate these so
67// the lib builds clean without the `tokio` feature (they'd otherwise be dead code under `-D warnings`).
68#[cfg(any(feature = "tokio", test))]
69const PING_PAYLOAD: &[u8] = b"ts_netstack_smoltcp ping";
70
71/// Per-call counter mixed into the ICMP `ident`.
72///
73/// The raw ICMP socket each `ping` call opens intercepts *all* ICMPv4, so two concurrent calls
74/// on the same netstack would otherwise share a fixed ident+seq and cross-match each other's
75/// replies (resolving the wrong awaiter with the wrong RTT). To avoid same-process collisions we
76/// derive a unique `ident` per call: the low byte of `std::process::id()` (non-deterministic
77/// across runs, no crate dependency) combined with a monotonically incrementing `AtomicU16`. Two
78/// in-flight pings in the same process get distinct idents (the counter differs), and pings from
79/// different processes are unlikely to share the high byte. We do not depend on the `rand` crate
80/// because it is not a dependency of this `no-std`-flavored crate (see `Cargo.toml`); the
81/// `tokio`-gated `ping` path enables `std`, so `std::process::id()` is available.
82#[cfg(feature = "tokio")]
83static PING_IDENT_COUNTER: core::sync::atomic::AtomicU16 = core::sync::atomic::AtomicU16::new(0);
84
85/// Produce a per-call ICMP `ident` that does not collide with other concurrent `ping` calls in
86/// this process. See [`PING_IDENT_COUNTER`] for the rationale and source of (weak) randomness.
87#[cfg(feature = "tokio")]
88fn next_ident() -> u16 {
89    let counter = PING_IDENT_COUNTER.fetch_add(1, core::sync::atomic::Ordering::Relaxed);
90    // High byte: process-randomized seed; low byte: per-call counter. The counter guarantees
91    // distinct idents for concurrent in-process calls regardless of the seed.
92    let seed = (std::process::id() as u16) & 0xFF00;
93    seed ^ counter
94}
95
96/// Send an ICMPv4 Echo Request from `src` to `dst` over the overlay netstack `chan` and wait
97/// for the matching Echo Reply, returning the round-trip time.
98///
99/// `src` must be a tailnet IPv4 address owned by this netstack (the raw send path emits a full
100/// IPv4 packet, so the source address goes on the wire verbatim). `dst` must be IPv4; an IPv6
101/// `dst` returns [`PingError::Ipv6Unsupported`].
102///
103/// Non-matching ICMP traffic (wrong ident/seq, or non-EchoReply messages) is ignored. If no
104/// reply arrives within `timeout`, returns [`PingError::Timeout`].
105#[cfg(feature = "tokio")]
106pub async fn ping<C: CreateSocket + Sync>(
107    chan: &C,
108    src: Ipv4Addr,
109    dst: IpAddr,
110    timeout: Duration,
111) -> Result<Duration, PingError> {
112    let dst = match dst {
113        IpAddr::V4(v4) => v4,
114        IpAddr::V6(_) => return Err(PingError::Ipv6Unsupported),
115    };
116
117    // The raw socket intercepts *all* ICMPv4, so a fixed ident/seq would cross-match concurrent
118    // pings on the same netstack. Use a per-call unique ident (see `next_ident`) and match strictly
119    // on both ident and seq, so concurrent calls never resolve each other's replies.
120    let ident: u16 = next_ident();
121    let seq_no: u16 = 1;
122
123    let sock = chan.raw_open(true, IpProtocol::Icmp).await?;
124
125    let request = build_echo_request(src, dst, ident, seq_no, PING_PAYLOAD);
126
127    let start = tokio::time::Instant::now();
128    sock.send(&request).await?;
129
130    let deadline = start + timeout;
131
132    loop {
133        let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
134        if remaining.is_zero() {
135            return Err(PingError::Timeout);
136        }
137
138        let recv = tokio::time::timeout(remaining, sock.recv_bytes()).await;
139        let bytes = match recv {
140            Err(_elapsed) => return Err(PingError::Timeout),
141            Ok(Ok(b)) => b,
142            Ok(Err(e)) => return Err(PingError::Net(e)),
143        };
144
145        if matches_reply(&bytes, src, dst, ident, seq_no) {
146            return Ok(start.elapsed());
147        }
148        // Not our reply (or not an EchoReply) -- keep waiting.
149    }
150}
151
152/// Build a complete ICMPv4 Echo Request IPv4 datagram (IP header + ICMP message).
153///
154/// The raw socket send path parses the buffer as a full IPv4 packet and fills the IPv4 header
155/// checksum on dispatch, but the ICMP checksum and the rest of the IPv4 header must be valid
156/// here.
157#[cfg(any(feature = "tokio", test))]
158fn build_echo_request(
159    src: Ipv4Addr,
160    dst: Ipv4Addr,
161    ident: u16,
162    seq_no: u16,
163    payload: &[u8],
164) -> vec::Vec<u8> {
165    let checksum_caps = ChecksumCapabilities::default();
166
167    let icmp_repr = Icmpv4Repr::EchoRequest {
168        ident,
169        seq_no,
170        data: payload,
171    };
172
173    let ipv4_repr = Ipv4Repr {
174        src_addr: src,
175        dst_addr: dst,
176        next_header: IpProtocol::Icmp,
177        payload_len: icmp_repr.buffer_len(),
178        hop_limit: 64,
179    };
180
181    let total = IPV4_HEADER_LEN + icmp_repr.buffer_len();
182    let mut buf = vec![0u8; total];
183
184    {
185        let mut ip_packet = Ipv4Packet::new_unchecked(&mut buf[..]);
186        ipv4_repr.emit(&mut ip_packet, &checksum_caps);
187    }
188
189    {
190        let mut icmp_packet = Icmpv4Packet::new_unchecked(&mut buf[IPV4_HEADER_LEN..]);
191        icmp_repr.emit(&mut icmp_packet, &checksum_caps);
192    }
193
194    buf
195}
196
197/// Parse a received raw IPv4 datagram and check whether it is the Echo Reply we are waiting for.
198#[cfg(any(feature = "tokio", test))]
199fn matches_reply(
200    bytes: &[u8],
201    expect_src: Ipv4Addr,
202    expect_dst: Ipv4Addr,
203    ident: u16,
204    seq_no: u16,
205) -> bool {
206    let checksum_caps = ChecksumCapabilities::default();
207
208    let Ok(ip_packet) = Ipv4Packet::new_checked(bytes) else {
209        return false;
210    };
211    let Ok(ipv4_repr) = Ipv4Repr::parse(&ip_packet, &checksum_caps) else {
212        return false;
213    };
214    if ipv4_repr.next_header != IpProtocol::Icmp {
215        return false;
216    }
217    // Reply travels dst -> src.
218    if ipv4_repr.src_addr != expect_dst || ipv4_repr.dst_addr != expect_src {
219        return false;
220    }
221
222    let Ok(icmp_packet) = Icmpv4Packet::new_checked(ip_packet.payload()) else {
223        return false;
224    };
225    let Ok(icmp_repr) = Icmpv4Repr::parse(&icmp_packet, &checksum_caps) else {
226        return false;
227    };
228
229    matches!(
230        icmp_repr,
231        Icmpv4Repr::EchoReply { ident: i, seq_no: s, .. } if i == ident && s == seq_no
232    )
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    const SRC: Ipv4Addr = Ipv4Addr::new(100, 64, 0, 1);
240    const DST: Ipv4Addr = Ipv4Addr::new(100, 64, 0, 2);
241
242    /// Build a full IPv4 Echo *Reply* datagram travelling `from` -> `to` with the given
243    /// ident/seq, mirroring what a peer responder emits.
244    fn build_echo_reply(from: Ipv4Addr, to: Ipv4Addr, ident: u16, seq_no: u16) -> vec::Vec<u8> {
245        let checksum_caps = ChecksumCapabilities::default();
246        let icmp_repr = Icmpv4Repr::EchoReply {
247            ident,
248            seq_no,
249            data: PING_PAYLOAD,
250        };
251        let ipv4_repr = Ipv4Repr {
252            src_addr: from,
253            dst_addr: to,
254            next_header: IpProtocol::Icmp,
255            payload_len: icmp_repr.buffer_len(),
256            hop_limit: 64,
257        };
258        let mut buf = vec![0u8; IPV4_HEADER_LEN + icmp_repr.buffer_len()];
259        {
260            let mut p = Ipv4Packet::new_unchecked(&mut buf[..]);
261            ipv4_repr.emit(&mut p, &checksum_caps);
262        }
263        {
264            let mut p = Icmpv4Packet::new_unchecked(&mut buf[IPV4_HEADER_LEN..]);
265            icmp_repr.emit(&mut p, &checksum_caps);
266        }
267        buf
268    }
269
270    #[test]
271    fn matches_reply_accepts_matching_ident_and_seq() {
272        let reply = build_echo_reply(DST, SRC, 0xABCD, 7);
273        assert!(matches_reply(&reply, SRC, DST, 0xABCD, 7));
274    }
275
276    #[test]
277    fn matches_reply_rejects_foreign_ident() {
278        // A concurrent ping's reply: correct addressing and seq, but a different ident. It must
279        // NOT satisfy this call (otherwise concurrent pings cross-match).
280        let foreign = build_echo_reply(DST, SRC, 0x1111, 7);
281        assert!(!matches_reply(&foreign, SRC, DST, 0xABCD, 7));
282    }
283
284    #[test]
285    fn matches_reply_rejects_foreign_seq() {
286        let foreign = build_echo_reply(DST, SRC, 0xABCD, 99);
287        assert!(!matches_reply(&foreign, SRC, DST, 0xABCD, 7));
288    }
289
290    #[test]
291    fn matches_reply_rejects_non_echo_reply() {
292        // An Echo *Request* with our exact ident/seq must not be accepted as our reply.
293        let request = build_echo_request(DST, SRC, 0xABCD, 7, PING_PAYLOAD);
294        assert!(!matches_reply(&request, SRC, DST, 0xABCD, 7));
295    }
296
297    #[cfg(feature = "tokio")]
298    #[test]
299    fn next_ident_is_unique_for_concurrent_calls() {
300        // Distinct calls get distinct idents (the per-call counter differs), so concurrent pings
301        // never share an ident and thus never cross-match.
302        let a = next_ident();
303        let b = next_ident();
304        let c = next_ident();
305        assert_ne!(a, b);
306        assert_ne!(b, c);
307        assert_ne!(a, c);
308    }
309}