L7 tracing: HTTP
Parse application-layer HTTP out of raw packets with a socket-filter program — walking Ethernet, IPv4, and TCP headers to the payload — and weigh it against the syscall/uprobe approach for encrypted or buffer-level L7.
Connection-level tools (Ch 27–28) tell you that a connection happened; L7 tracing tells you what was said on it — the HTTP method and path, the response status. This chapter does it with a socket filter, the first program type that inspects packet contents, and then weighs it against the syscall/uprobe route for the cases a socket filter can’t reach.
The code is in examples/29-http-l7/. ./demo.sh there builds, deploys, and runs it; its README.md covers what it does and how to drive it.
A socket filter reads the wire
A socket_filter program is attached to a socket and runs on every
packet delivered to it. Point it at a raw AF_PACKET socket bound to an
interface and it sees all traffic on that interface — at the byte
level. The work is parsing down to the payload and recognizing HTTP:
let ethertype = u16::from_be(ctx.load::<u16>(12)?); // IPv4?
let verihl = ctx.load::<u8>(ETH_HLEN)?; // IHL == 5 (no options)
let proto = ctx.load::<u8>(ETH_HLEN + 9)?; // TCP?
let doff = ctx.load::<u8>(ETH_HLEN + 20 + 12)?; // TCP data offset
let payload = ETH_HLEN + 20 + ((doff >> 4) as usize) * 4;
if looks_http(&ctx.load::<[u8;5]>(payload)?) { /* capture first line */ }
Two simplifications keep the verifier happy and the code readable: we handle IPv4 with no options (IHL == 5, so the IP header is exactly 20 bytes) and we parse the TCP data offset to skip TCP options. Both are flagged — real-world parsers handle options and IPv6.
Each ctx.load::<T>(offset) is a bounded read into the packet — the
socket-filter analogue of read_at, with the kernel checking the offset
is in range. That bounds requirement is also why we special-case
IPv4-without-options: a variable-length IP header would make the payload
offset unprovable to the verifier, so we assert IHL == 5 and compute
from a fixed 20-byte header. When the first five payload bytes look like
HTTP, we copy the 4-tuple and the first line and emit:
let tcp_off = ETH_HLEN + 20;
if looks_http(&ctx.load::<[u8; 5]>(payload)?) {
if let Some(mut slot) = EVENTS.reserve::<HttpEvent>(0) {
let ev = slot.as_mut_ptr();
unsafe {
(*ev).saddr = ctx.load::<u32>(ETH_HLEN + 12)?;
(*ev).daddr = ctx.load::<u32>(ETH_HLEN + 16)?;
(*ev).sport = ctx.load::<u16>(tcp_off)?;
(*ev).dport = ctx.load::<u16>(tcp_off + 2)?;
let _ = ctx.load_bytes(payload, &mut (*ev).line); // first ~80 bytes
}
slot.submit(0);
}
}
Attaching to a raw socket
Here’s what makes a socket filter different from every program so far:
it’s not attached to a named hook — it’s attached to an actual socket
file descriptor. So user space first opens a raw AF_PACKET socket
bound to the interface, then hands its fd to SocketFilter::attach:
let sock = open_packet_socket(&ifname)?; // AF_PACKET/SOCK_RAW bound to the NIC (libc)
let prog: &mut SocketFilter = ebpf.program_mut("http_filter").unwrap().try_into()?;
prog.load()?;
prog.attach(sock.as_fd())?; // the filter now sees every frame on that socket
AF_PACKET + SOCK_RAW is what delivers raw frames including the
Ethernet header to the socket — which is why the parser starts at
offset 0 with a 14-byte ETH_HLEN. Binding to one interface scopes the
capture to that NIC. After that it’s the familiar RingBuf drain:
decode each HttpEvent, print the flow and request line, and count by
method (ebpf_http_lines_total{method}).
The complement: syscalls and uprobes
A socket filter sees the wire, which has one hard limit: HTTPS is
ciphertext. You will never parse an HTTP line out of an encrypted
packet. The complementary L7 technique traces the syscalls that move
the data — sys_enter_write/sys_enter_read, or sendto/recvfrom —
and inspects the buffer, which holds plaintext regardless of whether
TLS encrypts it on the way out. (That’s the same boundary sslsniff
tapped in Chapter 17, just at the syscall rather than the library.)
So the choice:
- Socket filter (this chapter) — cleartext L7 across all connections on an interface, with full packet context. Blind to TLS.
- Syscall tracepoint / uprobe — buffer-level L7 that survives encryption, but you see every read/write and must attribute it to a connection yourself.
Build, deploy, observe
scripts/lab/provision-vm.sh ebpf-peer # if needed
cd examples/29-http-l7 && ./demo.sh
The demo runs an HTTP server on the peer and drives GET/POST from the target while the filter watches the target’s interface:
FLOW REQUEST / RESPONSE LINE
10.0.0.21:51122 → 10.0.0.32:8000 GET / HTTP/1.1
10.0.0.21:51124 → 10.0.0.32:8000 POST /submit HTTP/1.1
10.0.0.32:8000 → 10.0.0.21:51122 HTTP/1.0 200 OK
ebpf_http_lines_total{method} in Grafana breaks requests down by
method — the start of an L7 dashboard.
In Grafana (127.0.0.1:3000 → Explore), graph rate(ebpf_http_lines_total[1m]) — HTTP request lines per second.
Cross-check
[vm]$ sudo tcpdump -i any -A 'tcp port 8000' | grep -E 'GET|POST|HTTP/'
tcpdump’s view of the same cleartext should match the filter’s.
What you learned
- A
socket_filterinspects packet contents; bind a rawAF_PACKETsocket and attach the filter to it. - Walk Ethernet → IPv4 → TCP (parsing the data offset) to reach the payload, then recognize the HTTP line.
- Socket filters are blind to TLS; syscall/uprobe L7 is the encrypted-traffic complement.
Next: sockops — reacting to TCP connection lifecycle from a
cgroup.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): the socket filter loads and parses cross-host HTTP request/response lines. Confirmed on this kernel.