ICMP Ping Tool in Rust

Network

⏱️ 2h 40min
📦 16 modules
Rust

Talking to the network below TCP: writing ping in Rust

Every developer types ping google.com a hundred times a year, but almost nobody has seen what happens underneath. The answer is surprisingly small: forge an ICMP Echo Request packet by hand, drop it onto a raw socket, and wait for the echo to bounce back. In a couple of hundred lines of Rust you'll touch DNS, raw sockets, hand-assembled binary packets, checksums, and round-trip timing. We'll build it one small piece at a time.

The idea, in one sentence

ICMP "ping" sends an Echo Request packet to a host and times how long the matching Echo Reply takes to come back. The whole tool is a loop around two operations — send and recv — wrapped in a struct that owns the socket and the packet.

Start with the two halves of the channel:

struct PingService {
    dest: IpAddr,
    tx: TransportSender,
    rx: TransportReceiver,
    // ...
}

dest is the address we're pinging, resolved once up front. tx and rx are the sending and receiving ends of a raw transport channel — the thing that actually puts bytes on the wire and pulls them back off. Holding both in the struct means every send and recv reuses the same open socket.

The rest of the struct is the packet and the two numbers that label it:

struct PingService {
    // …
    packet: MutableEchoRequestPacket<'static>,
    id: u16,       // identifies our packets
    attempt: u16,  // sequence number per ping
}

packet is a reusable, mutable Echo Request we build once and re-stamp on each send. id is fixed for the lifetime of the process and marks every packet as ours; attempt ticks up by one per ping so we can pair each request with its reply. Everything else in the program is plumbing around these fields.

From a name to an address

A user types google.com; the kernel speaks IP addresses. DNS bridges the gap, and one name can resolve to several addresses, so we resolve and take the first.

First, ask the resolver for every address behind the name:

let mut ips = lookup_host(&hostname)?;

lookup_host comes from the dns-lookup crate; it wraps the system resolver and hands back an iterator of IpAddr. The trailing ? and the catch-all Result it feeds into come from the anyhow crate, which lets failures bubble straight up to main without us defining error types. If the name doesn't resolve at all, this line is where the program stops.

Now pull the first address out of that iterator:

let dest = ips.next().ok_or_else(|| {
    anyhow!("no address found for {hostname}")
})?;
println!("PING {hostname} ({dest})");

.next() returns an Option, because the resolver might hand back an empty list, and ok_or_else turns that empty case into a readable error instead of a silent crash. The println! echoes the classic ping banner so you can see exactly which address we picked — useful when a name has both IPv4 and IPv6 records.

Opening a channel under TCP and UDP

ICMP lives at the same layer as TCP and UDP, not on top of them, so an ordinary socket won't reach it. The pnet crate gives us a raw transport channel: a sender/receiver pair bound to one protocol number.

Name the protocol we want to speak:

let proto =
    Layer4(Ipv4(IpNextHeaderProtocols::Icmp));

Layer4 tells pnet to build the IP header for us — we only ever deal with the ICMP payload, and the kernel wraps it in IP on the way out. The Ipv4(...) part pins us to IPv4 and the Icmp protocol number, which is the channel the replies will come back on too.

Then open the channel itself:

let (tx, rx) = transport_channel(256, proto)?;

This hands back the tx/rx pair the struct stores. The 256 is the receive buffer size in bytes; ICMP echo headers are tiny, so there's no reason to ask for more. One catch: opening a raw socket needs root or the CAP_NET_RAW capability — run the finished binary with sudo, or grant the capability once with setcap.

Forging the packet by hand

An Echo Request is eight bytes of header: a type, a code, a checksum, an identifier, and a sequence number. pnet gives us MutableEchoRequestPacket, a zero-copy view over a byte buffer that lets us set named fields instead of poking raw offsets.

Allocate a buffer exactly the right size:

const SIZE: usize =
    EchoRequestPacket::minimum_packet_size();
let buf = vec![0u8; SIZE];

minimum_packet_size() is a const fn, so SIZE is computed at compile time — the buffer is never a magic number we have to keep in sync with the header layout. A buffer full of zeros is a blank packet; the next steps stamp the meaningful fields into it.

Wrap the buffer in a mutable packet view:

let mut packet =
    MutableEchoRequestPacket::owned(buf)
        .ok_or_else(|| {
            anyhow!("buffer too small")
        })?;

::owned ties the buffer's lifetime to the packet — that's the <'static> in the struct — so we don't fight the borrow checker on every send. It returns an Option because it refuses to build a view over a buffer that's too small, and ok_or_else turns that refusal into a clear error.

Now stamp the fields that never change:

let id = process::id() as u16;
packet.set_identifier(id);
packet.set_icmp_type(IcmpTypes::EchoRequest);
packet.set_icmp_code(IcmpCode::new(0));

The identifier is our process ID — that's how we'll later tell our replies apart from those of another ping running on the same machine. The type is Echo Request (the value 8 on the wire), and the code is always 0 for an echo. These three fields are set once and reused, because they're identical on every ping we send.

Send: set the fields, then the checksum

Two fields change on every ping — the sequence number and, because of it, the checksum. The order is non-negotiable: set everything first, compute the checksum last, because the checksum covers the whole packet.

Bump the sequence number first:

fn send(&mut self) -> Result<()> {
    self.attempt += 1;
    self.packet.set_sequence_number(self.attempt);
    // ...
}

Each call increments attempt and writes it into the packet, so ping #1, #2, #3 carry sequence numbers 1, 2, 3. That number is what recv will match on later, and it's also what gets printed as icmp_seq — so it has to change before we touch the checksum.

Now recompute the checksum over the freshly-stamped bytes:

let view = IcmpPacket::new(self.packet.packet())
    .ok_or_else(|| anyhow!("bad packet"))?;
let sum = checksum(&view);
self.packet.set_checksum(sum);

self.packet.packet() returns the raw bytes, and we re-view them as a generic IcmpPacket because pnet's checksum works on that type. The Internet checksum (RFC 1071) is a 16-bit one's-complement sum over the bytes; skip it and you send a packet with a zero checksum, which essentially every host on Earth silently drops — leaving you debugging a "working" program that gets no replies.

Finally, put it on the wire:

fn send(&mut self) -> Result<()> {
    // …
    self.tx.send_to(&self.packet, self.dest)?;
    Ok(())
}

send_to hands the finished packet and the destination address to the raw socket, and pnet wraps it in an IP header before it leaves. The ? propagates any OS-level send error — a missing route, say — straight up to the caller.

Recv: don't grab someone else's reply

A machine sees ICMP traffic from many sources — other pings, routers, unrelated hosts. If we accept the first packet that lands, we might steal another process's reply or pair the wrong request with the wrong response. So recv loops and filters on three things: the source address, our identifier, and the current sequence number.

Set up an iterator over incoming ICMP packets:

fn recv(&mut self) -> Result<()> {
    let mut iter = icmp_packet_iter(&mut self.rx);
    loop {
        // ...
    }
}

icmp_packet_iter borrows the receiver and yields one ICMP packet at a time. We wrap it in a loop because the first packet we see is often not the one we want — we keep pulling until a packet passes all three filters.

Wait for the next packet, with a deadline:

let (pkt, addr) = iter
    .next_with_timeout(TIMEOUT)?
    .context("timeout")?;
if addr != self.dest { continue; }

next_with_timeout returns Ok(None) when the deadline (say, a 5-second TIMEOUT) expires, and .context("timeout") from anyhow turns that empty result into a clear error — without it, an unreachable host would hang the program forever. The address check is the cheapest filter: anything not from the host we pinged is discarded immediately with continue.

Then make sure it's an Echo Reply at all:

let Some(reply) =
    EchoReplyPacket::new(pkt.packet())
else {
    continue;
};

EchoReplyPacket::new returns None if the bytes don't parse as an echo reply — for instance a Time Exceeded or Destination Unreachable message that arrived from the same host. The let ... else form keeps the happy path flat: parse succeeds and we fall through, parse fails and we loop again.

Finally, confirm it's our reply to this ping:

if reply.get_identifier() == self.id
    && reply.get_sequence_number()
        == self.attempt
{
    return Ok(());
}

The identifier check rejects replies meant for another ping process; the sequence check rejects stale ones. That second test matters more than it looks: if ping #1's reply arrives late, while we're already waiting on ping #2, matching attempt ensures we keep waiting for the real #2 instead of accepting the straggler.

Looping like the real thing

ping sends several requests, spaced out, and reports each round-trip time. We wire that together in main and time it with Instant, a monotonic clock that won't jump if the system time changes.

Time a single ping:

for _ in 0..args.counter {
    let start = Instant::now();
    service.ping()?;             // send + recv
    let rtt = start.elapsed();
    // ...
}

ping() is the send-then-recv pair from above, so start.elapsed() measures the full round trip — out to the host and back. Instant is monotonic, which means an NTP adjustment or a daylight-saving change mid-flight can't produce a negative or wildly wrong RTT.

Print the result in the familiar format:

let seq = service.attempt;
println!(
    "reply from {dest}: \
     icmp_seq={seq} time={rtt:?}"
);

We read attempt back off the service to report which sequence number just completed, matching the icmp_seq field you see from the system tool. The {rtt:?} debug-prints the Duration with a human unit attached, so a fast reply shows as something like 12ms rather than a raw nanosecond count.

Then pause before the next round:

for _ in 0..args.counter {
    // …
    let millis = (args.interval * 1000.0) as u64;
    thread::sleep(Duration::from_millis(millis));
}

interval is a float in seconds from the CLI, so we multiply by 1000.0 and cast to whole milliseconds for sleep. The pause is polite: back-to-back pings look like a flood and invite rate limiting, which is exactly what would skew your timings. The counter and interval themselves come from clap flags with #[arg(short, default_value_t = 4)] and default_value_t = 1.0, so ping -c 10 -i 0.2 google.com behaves just like the system tool.

Where to go next

The tool is honest, but real ping does more:

  • IPv6 — we hard-coded Ipv4. ICMPv6 is a separate protocol number with its own packet types.
  • Statistics — track sent/received/lost and print min/avg/max RTT on exit, like the summary line you're used to.
  • Asyncthread::sleep blocks; swapping to tokio lets you fire requests without waiting for each reply and correlate them by sequence number.
  • TTL and traceroute — set the IP TTL field and read Time Exceeded replies, and you've basically written traceroute.

Why build it

Networking feels like a black box until you've assembled a packet byte by byte and watched a server on the other side of the world echo it back. Do that once and "raw socket", "checksum", and "ICMP" stop being words in a man page — they're things you've held in your hands. And it fit in an afternoon.

Practice