How `ping` Works Inside: Building It in Rust
Implement a real ping utility from scratch using low-level networking.
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.