ping looks trivial: type an address, read back a response time. But a whole protocol hides behind that one line. There is no TCP connection and no ports here — there is a raw socket, an ICMP packet assembled by hand, and a checksum you have to compute yourself. Let's build a real, working utility in Rust on top of the pnet crate, and along the way see exactly what goes out onto the wire and how we recognize "our" reply.

A Socket That Knows Nothing About Ports

Rust's standard library speaks TCP and UDP, but not ICMP: that protocol lives at the network layer and has no ports. So we reach for the low-level pnet crate, which gives access to raw sockets. Such a channel is opened by the transport_channel function from the pnet::transport module.

let icmp = IpNextHeaderProtocols::Icmp;
let protocol = TransportProtocol::Ipv4(icmp);
let channel_type =
    TransportChannelType::Layer4(protocol);
let (tx, rx) = transport_channel(256, channel_type)?;

Line by line. IpNextHeaderProtocols::Icmp is a ready-made constant for protocol number 1; no need to spell out the raw value. We wrap it in TransportProtocol::Ipv4 and ask for Layer4 rather than Layer3: that way pnet handles the IP header for us, leaving only the ICMP payload. The 256 is the receive buffer size in bytes; echo messages are usually under 100 bytes, but a margin never hurts.

The function returns a pair (tx, rx) — a sender and a receiver — inside a Result. Opening the socket can fail, so the ? operator propagates the error upward. To avoid hand- rolled error types, we keep the error as Error from the anyhow crate — a generic container for any error.

An Echo Request, Byte by Byte

An ICMP packet in pnet is a wrapper over a byte array that lays fields out at the right offsets. To build a request, we take MutableEchoRequestPacket and reserve a buffer of exactly the minimum packet size up front.

const SIZE: usize =
    EchoRequestPacket::minimum_packet_size();
let buf = vec![0u8; SIZE];
let mut packet = MutableEchoRequestPacket::owned(buf)
    .ok_or(PingError::InsufficentBuffer)?;

minimum_packet_size() is an associated function that tells how many bytes an echo request needs. The wrapper cannot grow, so we reserve the space ourselves: if you want a payload, enlarge the buffer by its length. The owned() method returns an Option, since the buffer could in theory be too small. Instead of unwrap(), we turn None into our own InsufficentBuffer error via ok_or — a panic inside a library helps no one.

Next we fill the fields that stay the same from ping to ping.

packet.set_identifier(id);
packet.set_icmp_type(IcmpTypes::EchoRequest);
packet.set_icmp_code(IcmpCode::new(0));

set_icmp_type(IcmpTypes::EchoRequest) sets type 8 — that is what marks the message as a request. A subtype code is unused in a request, hence IcmpCode::new(0). And set_identifier(id) is the crucial bit: the server echoes back the same identifier, and by it we tell our exchange from someone else's. A handy id is the process identifier:

let id = process::id() as u16;

process::id() returns a u32, but ICMP reserves only two bytes for the identifier, so we truncate to u16 with the as operator. That way two separately launched pings won't mix up replies: the kernel delivers an echo reply to every open ICMP socket, and without the identifier we'd react to foreign traffic. The packet field itself lives right inside the service struct with a 'static lifetime — we assemble the buffer once and reuse it for every ping, changing only the sequence number and the checksum.

The Checksum — A Final Touch

Once every field is in place, we compute the checksum. The key point is that this is a separate step on top of the already-filled packet: the sum depends on the whole contents, so it cannot be computed ahead of time.

self.counter += 1;
self.packet.set_sequence_number(self.counter);
let packet = IcmpPacket::new(self.packet.packet())
    .ok_or(PingError::InvalidPacket)?;
let echo_checksum = checksum(&packet);
self.packet.set_checksum(echo_checksum);

Before each send we bump counter and write it into the sequence-number field — this lets us later confirm which request a reply belongs to. Then we wrap the raw bytes in an IcmpPacket (the packet() method from the Packet trait yields a slice), compute checksum(), and write the result back via set_checksum. IcmpPacket::new also returns an Option, and again we prefer an explicit InvalidPacket error over a call to unwrap().

Send It, and Catch Exactly Your Reply

The finished packet goes out via the sender's send_to method — it needs the data and the recipient's address.

self.tx.send_to(&self.packet, self.dest)?;

On failure send_to returns a std::io::Error inside a Result; the ? operator raises it, and anyhow coerces it to our common error type. Now the subtle part — receiving. A raw socket hears every bit of ICMP traffic on the host, so we must read it in a loop and filter out everything foreign.

let mut packets = icmp_packet_iter(&mut self.rx);
loop {
    let (packet, addr) = packets
        .next_with_timeout(Duration::from_secs(5))?
        .ok_or(PingError::Timeout)?;
    if addr == self.dest {
        // filter further
    }
}

icmp_packet_iter gives an "iterator", but really it's a stream: each call to next_with_timeout may return a packet, or it may hit the timeout. So the value arrives as an Option inside a Result: ? catches the error, and an empty None (time ran out) we turn into a Timeout error. The first filter is by address: we only care about packets from the host we sent to.

Then we check that this really is an echo reply, and to our own request.

if let Some(repl) = EchoReplyPacket::new(packet.packet()) {
    if repl.get_identifier() == self.id
        && repl.get_sequence_number() == self.counter
    {
        break;
    }
}

EchoReplyPacket::new tries to read the bytes as an echo reply. If the identifier (our process) and the sequence number (our specific request) both match, this is exactly the reply we awaited, and we can leave the loop. The sequence check is not redundant: under network delay it's easy to receive a stale reply to a previous ping.

Round-Trip Time

The whole point of ping is not just "is the host alive" but "how fast does it answer". We measure with the monotonic Instant clock: it doesn't jump when system time is corrected.

let start = Instant::now();
self.send()?;
self.recv()?;
let end = Instant::now();
let elapsed = end - start;
println!(
    "ping from {}: icmp_seq={} time={elapsed:?}",
    self.dest, self.counter
);

We take Instant::now() before sending and after receiving, and the difference yields a Duration — that is the round- trip time. Using SystemTime here would be wrong: it can jump backward on an NTP sync and give a negative or absurd interval. Instant, by contrast, is monotonic and only counts forward. There is a gap between send() and recv(), but no race: the socket is the same one, and if the reply arrived early it simply waits in the buffer until we read it.

From Address to Name: Adding DNS

Pinging by bare IP is inconvenient. Let's give the tool hostnames, plus the -c (ping count) and -i (interval) options — just like the system command. We parse arguments with the clap crate via derive.

#[derive(Parser)]
struct Opts {
    hostname: String,
    #[arg(short, default_value_t = 4)]
    counter: u32,
    #[arg(short, default_value_t = 1.0)]
    interval: f32,
}

#[arg(short)] turns a field name into a short flag by its first letter: counter-c, interval-i. default_value_t sets a default already in the right type — 4 pings and a 1.0-second pause, fractional so the interval can be finer than a second. The hostname field, with no attributes, is a required positional argument.

We turn the name into an address with the lookup_host function from the dns-lookup crate. The standard library's ToSocketAddrs trait doesn't fit here: it demands a port, and ICMP has none.

let ips = lookup_host(hostname)?;
let resolved = ips.first().ok_or_else(|| {
    anyhow!("Can't find any ip for: {hostname}")
})?;

lookup_host makes a system call and returns a Vec<IpAddr> — a name may have several addresses, so we take the first with first(). It returns an Option, because the list can be empty; in that case we build a clear error with the anyhow! macro, substituting the hostname itself. Then we dereference *resolved and pass it into PingService::open — and the loop starts sending requests to the resolved address.

Where to Go From Here

Running it runs into privileges: raw sockets usually require root (which is why the system ping has a sticky bit set). In development it's easiest to launch through sudo. From there, plenty remains: support IPv6 (we hard-wired Ipv4), compute packet-loss percentage and jitter, and print separate lines for timeouts, not only for successful replies. You could also add a payload with a timestamp inside the packet — then RTT can be computed straight from the reply body.

Why build ping by hand at all? Because it's the shortest path to touching the network at the protocol level: assembling a packet byte by byte, the checksum, filtering replies by identifier — the same techniques underpin any network tool. A hundred lines on pnet, and the textbook's abstract "ICMP" becomes a utility you can watch at work.