XDP capture: a tcpdump in eBPF
Use XDP to observe rather than drop: parse and filter packets in the kernel, and ship only the interesting ones (TCP SYN/FIN/RST) to user space through a ring buffer for a live tcpdump-style view of connection activity.
Chapter 32 used XDP to drop. The same hook is just as good at watching —
and watching at XDP is how high-rate packet capture is built, because you
can decide in the kernel which packets are worth user space’s attention and
discard the rest for free. This chapter builds a miniature tcpdump: an XDP
program that parses each frame, keeps only TCP control packets
(SYN / FIN / RST — the start and end of connections), and ships a one-line
record for each to user space. Every other packet is passed untouched.
The code is in examples/33-xdp-capture/. ./demo.sh there builds,
deploys, and runs it; its README.md covers what it does and how to drive
it.
The discipline: filter first, copy little
Chapters 31 and 32 hammered one rule: on a data path you don’t ship a
record per packet, you aggregate. Capture looks like the exception — its
whole job is to emit records — but the rule still holds, just shifted: you
filter in the kernel so that only a tiny fraction of packets ever
become a record, and you copy only what you need (a few header fields),
never the whole frame. A SYN-only filter on a busy host emits a handful of
records per second, not millions. That’s what makes a RingBuf the right
tool here where it was the wrong one for a packet counter.
So this program returns XDP_PASS for everything — it changes nothing — and
its only side effect is dropping a small FlowRecord into a ring when a
packet matches.
How the code works
The record and the maps
// in -common, shared with user space
#[repr(C)]
#[derive(Clone, Copy)]
pub struct FlowRecord {
pub saddr: u32, pub daddr: u32, // network byte order
pub sport: u16, pub dport: u16,
pub flags: u8, // the TCP flags byte
pub len: u16, // total IP length
}
// in -ebpf
#[map] static EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
#[map] static SEEN: HashMap<u32, u64> = HashMap::with_max_entries(4, 0); // per-proto totals
One ring for the matched records, and a tiny SEEN map so we can also
report how many packets went by — the denominator that makes the capture
count meaningful (captured 12 of 40,000 seen).
Parsing and filtering at XDP
const TCP_FIN: u8 = 0x01;
const TCP_SYN: u8 = 0x02;
const TCP_RST: u8 = 0x04;
fn try_capture(ctx: &XdpContext) -> Result<u32, ()> {
let eth: *const EthHdr = unsafe { ptr_at(ctx, 0)? };
if unsafe { (*eth).ether_type } != EtherType::Ipv4 { return Ok(xdp_action::XDP_PASS); }
let ip: *const Ipv4Hdr = unsafe { ptr_at(ctx, EthHdr::LEN)? };
let proto = unsafe { (*ip).proto };
bump(&SEEN, proto as u32, 1);
if proto != IpProto::Tcp { return Ok(xdp_action::XDP_PASS); }
let tcp_off = EthHdr::LEN + Ipv4Hdr::LEN;
let tcp: *const TcpHdr = unsafe { ptr_at(ctx, tcp_off)? };
let flags: u8 = unsafe { *ptr_at::<u8>(ctx, tcp_off + 13)? }; // flags byte
if flags & (TCP_SYN | TCP_FIN | TCP_RST) != 0 {
if let Some(mut slot) = EVENTS.reserve::<FlowRecord>(0) {
let rec = FlowRecord {
saddr: unsafe { (*ip).src_addr }, daddr: unsafe { (*ip).dst_addr },
sport: unsafe { u16::from_be((*tcp).source) },
dport: unsafe { u16::from_be((*tcp).dest) },
flags,
len: unsafe { u16::from_be((*ip).tot_len) },
};
unsafe { *slot.as_mut_ptr() = rec; }
slot.submit(0);
}
}
Ok(xdp_action::XDP_PASS)
}
Reading it the way you’d write it:
- Every header is fetched through the
ptr_atbounds check from Chapter 32 — capture is still raw-pointer XDP, so each access is proven in-window before the dereference. - We count every IPv4 packet by protocol in
SEEN(the denominator), then narrow to TCP. - The flags byte lives at offset 13 of the TCP header. Rather than lean
on a bitfield accessor, we read the byte directly with
ptr_at::<u8>(ctx, tcp_off + 13)— the same offset-reading habit from the socket chapters, and unambiguous.SYN | FIN | RSTis our filter: connection setup and teardown, which is what you usually want to see. - Only on a match do we touch the ring:
reserveaFlowRecord, fill it (addresses kept in network order; ports and length byte-swapped to host order), andsubmit. A non-match costs one comparison and a counter bump. - The verdict is always
XDP_PASS— we are a tap, not a filter.
User side: drain into tcpdump-style lines
let mut ring = RingBuf::try_from(ebpf.map_mut("EVENTS").unwrap())?;
// … on a short timer …
while let Some(item) = ring.next() {
let r: FlowRecord = unsafe { core::ptr::read_unaligned(item.as_ptr() as *const _) };
let names = flag_names(r.flags); // e.g. "SYN", "FIN ACK", "RST"
println!("{:<10} {}:{} > {}:{} len {}",
names,
Ipv4Addr::from(u32::from_be(r.saddr)), r.sport,
Ipv4Addr::from(u32::from_be(r.daddr)), r.dport,
r.len);
captured.add(1, &[KeyValue::new("flag", primary_flag(r.flags))]);
}
The drain is the same RingBuf loop you’ve used since Chapter 9, with a
read_unaligned because the record is packed. The output reads like
tcpdump: SYN 10.0.0.5:54312 > 10.0.0.9:443 len 60. We also export
ebpf_xdp_captured_total{flag} (and a gauge of SEEN totals), so Grafana
shows the capture rate against the total packet rate.
Build, deploy, observe
cd examples/33-xdp-capture && ./demo.sh
The demo attaches the capture program to the target’s interface, then opens
and closes connections from the peer (a loop of short curls). You’ll see a
SYN line as each connection starts and a FIN/RST as it ends — a live
feed of connection activity — while ebpf_xdp_captured_total rises in
Grafana far more slowly than the underlying packet count.
In Grafana (127.0.0.1:3000 → Explore), graph rate(ebpf_xdp_seen_total[1m]) — packets seen, against rate(ebpf_xdp_captured_total[1m]) for how many matched the filter.
Cross-check
On the target ([vm]$ — ssh fedora@$(scripts/lab/vm-ip.sh ebpf-target)),
find the interface (usually enp1s0) and run real tcpdump with the same
filter beside your program:
[vm]$ ip -br link # find the NIC (e.g. enp1s0)
[vm]$ sudo tcpdump -ni enp1s0 'tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0'
tcpdump’s tcp[tcpflags] & (…) != 0 filter is exactly the SYN/FIN/RST
test your XDP program does — its lines should match yours packet for packet.
That’s the most direct “is my capture correct?” check there is.
What you learned
- XDP is as useful for observing as for dropping: parse and filter in
the kernel, copy only the fields you need, and
XDP_PASSeverything so you change nothing. - Reading the TCP flags byte by offset, and using a
RingBufcorrectly on a data path — legitimate precisely because the in-kernel filter keeps the record rate low. - A SYN/FIN/RST feed is a connection-activity view that mirrors
tcpdump 'tcp[tcpflags] & … != 0'.
Next, Chapter 34 makes XDP forward: a small load balancer that rewrites
packets and fans them across backends with XDP_TX/XDP_PASS.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.