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