Passive TCP session tracking and HTTP/1.1 dissection in eBPF.
tcptrace attaches to one or more network interfaces — XDP on ingress and
TCX on egress — and tracks every TCP connection crossing them. Connections
whose endpoints match a configured CIDR are handed to an in-kernel HTTP/1.1
dissector, which parses requests and responses and streams them to userspace over
a ring buffer. A Go agent reassembles each exchange and writes one file per HTTP
transaction, containing the request and the response with their methods, all
headers, and their bodies.
Nothing is modified: every program returns pass.
| Kernel | ≥ 6.6 (TCX links), with BTF at /sys/kernel/btf/vmlinux |
| Toolchain | clang ≥ 12, bpftool, Go ≥ 1.22 |
| Privileges | CAP_BPF + CAP_NET_ADMIN, in practice root |
make build
sudo ./bin/tcptrace run -i eth0 --out ./capturesStartup takes a few seconds: the kernel verifier has to check the dissector
before it can be attached. Wait for the msg=attached line before expecting
traffic to be captured — anything crossing the interface before then is simply
not seen.
Then, in another shell:
curl http://neverssl.com/
ls ./captures/
cat ./captures/*.http ingress frame ──▶ XDP prog ─┐
├─▶ parse L2/L3/L4 ─▶ canonical conn_key
egress skb ──▶ TCX prog ─┘ │
├─▶ conns (LRU_HASH): state machine, counters
├─▶ tcp_events ringbuf (open/estab/close/reset)
│
conn.DISSECT? (LPM trie match, cached at conn creation)
│ yes, and there is payload
▼
stage pkt_ctx (per-CPU)
bpf_tail_call(dissectors_{xdp,tc}[0])
▼
HTTP/1.1 dissector program
per-(conn,dir) http_streams (LRU_HASH)
│
▼
http_events ringbuf ─▶ assembler ─▶ .http files
Both directions of a flow produce the same map key. The two endpoints are
sorted byte-wise and the smaller becomes a, so an ingress frame seen by XDP and
the egress reply seen by TCX collapse into a single record with correctly
attributed per-direction counters — and one hash lookup, never two.
The tracker does not know what a dissector is. When a connection is marked for
dissection it tail-calls index 0 of a PROG_ARRAY. Userspace decides what lives
there:
--http=offremoves the entry; the tail call falls through and tracking continues untouched.--http-emit=offkeeps the parser running but suppresses ring buffer output, so the statistics stay meaningful at zero I/O cost.
Tail-call targets must share the caller's program type, so there is one prog array per hook and one thin entry program each, both wrapping the same parser.
Observational, not RFC 793 — we only ever see packets, never endpoint state:
| Observation | Result |
|---|---|
| bare SYN | SYN_SENT, and that side is the client |
| SYN+ACK | SYN_RECV |
| client ACK | ESTABLISHED |
| FIN per direction | FIN_WAIT → CLOSING → CLOSED |
| RST | RESET |
| data with no handshake | ESTABLISHED, flagged MIDSTREAM |
Message framing per RFC 7230 §3.3.3, in order: 1xx/204/304 and responses to
HEAD have no body; Transfer-Encoding: chunked is de-chunked in the kernel;
otherwise Content-Length; otherwise a response body runs until the connection
closes. Requests and responses may span any number of segments — a partial-line
buffer carries an unfinished start line or header across the boundary.
Pipelining and keep-alive fall out of the same state machine. CONNECT and
Upgrade switch the stream to tunnel mode and parsing stops.
Sequence numbers are checked before parsing:
| Condition | Action |
|---|---|
| in order | parse |
| fully retransmitted | dropped, no duplicate events |
| partial overlap | duplicated prefix skipped |
| gap | message flagged DESYNC + TRUNCATED, stream waits to resynchronise at the next plausible message start |
Everything in the kernel is bounded. Anything that hits a cap is flagged, never silently dropped.
| Request URI / reason phrase | 256 B |
| Header name / value | 64 B / 192 B |
| Headers per message | 64 |
| Body per message | --max-body-bytes, default 64 KiB |
| Body bytes per ring buffer event | 1 KiB (a longer run is split across events, not cut) |
| Payload parsed per segment | 32 KiB |
| Line buffer | 512 B |
The per-segment cap only matters for GSO super-frames on egress. A segment past it is skipped whole and reported as a desync — parsing just its prefix would leave the body length silently out of step with the stream.
The kernel does not spend budget closing out a message it abandons; the userspace assembler salvages the captured part when the next start line arrives and marks it truncated. Splitting the work this way keeps the dissector at roughly half the verifier ceiling instead of nine tenths of it.
One file per transaction, named so that a directory listing sorts chronologically:
20260728T101530.123-c000042-t000-GET-200.http
The file is the exchange behind a #-prefixed preamble. Strip the comment block
and what remains parses as HTTP — http.ReadRequest and http.ReadResponse read
it back directly, which the tests assert.
Header names, values and order are preserved exactly as they arrived, with one
deliberate exception: the framing headers are rewritten whenever they would
misdescribe the bytes in the file. Bodies reach userspace already de-chunked,
and they may have been capped or not captured at all, so keeping the sender's
Transfer-Encoding: chunked or its original Content-Length would produce
something no parser can read. In those cases Transfer-Encoding and
Content-Length are replaced by a single Content-Length counting the bytes
actually present, and the preamble records what was replaced:
# response-framing-rewritten: Transfer-Encoding: chunked -> Content-Length: 4096
# response-body-bytes: 204800 captured: 4096
Nothing is lost: the original framing, the true body size and the truncation
flags all stay in the preamble. A message that carried no body at all — a HEAD
response, or 204/304 — keeps its Content-Length untouched, because there it
describes the body the message would have had.
Interim 1xx responses appear in the response section ahead of the final one,
in the order the client saw them, and are counted in the preamble:
# informational-responses: 1 (100)
# tcptrace/1
# conn-id: 42
# transaction: 0
# client: 10.0.0.5:51234
# server: 10.0.0.9:8080
# started: 2026-07-28T10:15:30.123456789Z
# outcome: complete
# request-headers: 3 request-body-bytes: 0 captured: 0
# request-flags: none
# response-headers: 2 response-body-bytes: 12 captured: 12
# response-flags: none
>>> REQUEST
GET /index.html HTTP/1.1
Host: example.com
User-Agent: curl/8.5.0
<<< RESPONSE
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 12
Hello world!
Either half may be absent — outcome: says why (complete, no-response,
no-request), and request: absent / response: absent appears in its place.
An interim response never counts as the final one: 100 Continue does not
conclude a request, so it does not close the transaction. 101 Switching Protocols does, since it is the last HTTP response on that connection.
tcptrace run -i eth0 [-i eth1] [flags]
tcptrace dump -i eth0 [-for 3s]
tcptrace version
| Flag | Default | Meaning |
|---|---|---|
-i, -iface |
— | interface to attach to; repeatable or comma-separated |
--cidr |
all | only dissect connections with an endpoint in this prefix; repeatable, IPv4 and IPv6 |
--xdp-mode |
auto |
auto (native, falling back to generic), native, generic |
--http |
true |
install the HTTP dissector at all |
--http-emit |
true |
publish dissected HTTP to userspace |
--capture-bodies |
true |
capture bodies as well as headers |
--max-body-bytes |
65536 |
per-message body cap |
--tcp-events |
true |
stream connection lifecycle events |
--out |
./captures |
output directory |
--txn-timeout |
30s |
flush a request that never got a response |
--stats-interval |
off | periodically log BPF counters |
--conn-map-size |
65536 |
connection table capacity |
--stream-map-size |
16384 |
HTTP parser state entries (two per dissected connection) |
--cidr matches when either endpoint falls inside the prefix. With no
--cidr, every connection is dissected.
Three tiers:
make test # unprivileged unit tests
make test-bpf # BPF_PROG_TEST_RUN: crafted packets straight into the programs
make test-system # netns + veth, a real server and a real client
make test-allmake test-bpf feeds hand-built Ethernet frames directly into the XDP and TC
programs with no network involved, then asserts on map contents and drained ring
buffer events. It covers the handshake (v4 and v6), graceful close, RST,
mid-stream capture, both hooks collapsing to one connection, VLAN and QinQ, IPv4
options, IPv6 extension headers, Ethernet padding, non-TCP rejection, CIDR
selection, both disable switches, headers split at every awkward byte offset,
chunked bodies split mid-size-line and mid-data, pipelining, keep-alive,
retransmission, sequence gaps and resynchronisation, body truncation, and each
response-framing rule.
make test-system builds a veth pair into a private namespace, runs a real
net/http server and a real client, and asserts on the exported files: the full
GET flow, POST bodies, chunked responses, keep-alive, twenty concurrent
connections checked for cross-contamination, over-cap bodies, CIDR filtering in
both directions, the dissector switched off, IPv6 end to end, a response cut
short by an abort, repeated attach/detach cycles, and segmentation offloads
enabled so the egress hook sees GSO super-frames. Two further tests drive the
built binary as a subprocess, covering the command line, signal handling and
clean detach.
- TLS is out of scope. HTTPS is opaque on the wire.
- HTTP/2 and HTTP/3 are out of scope.
- No out-of-order reassembly. Gaps are detected and flagged, not repaired.
- IP fragments: the first fragment is tracked, the rest counted.
- Statistics (
--stats-interval, ortcptrace dump) report ring buffer drops, desyncs, retransmissions and truncations, so degraded capture is visible rather than silent.
A few constructs in bpf/ look roundabout and are load-bearing; each has a
comment saying so:
- Header offsets are recorded while the line is buffered, not found by scanning it afterwards. A scan forks one verifier state per candidate position, and those multiply with every other branch.
- Variable-length payloads are written with dynptrs. A byte loop is
verifier-hostile and a constant-size
memcpyfrom a variable offset expands into hundreds of single-byte loads. - The parse window is large and singular. The verifier re-checks a
bpf_loopcallback once per distinct call-site state, so each extra outer iteration multiplies the cost of the whole parser. - The emit helpers are
__noinlineand take their arguments through the map, which keeps them verified once rather than once per parse path.
Together these bring the dissector to roughly 477k verified instructions against
the one-million ceiling. make verifier-log prints the full log if you change
the parser and want to see where you stand.
GPL-2.0-only. See LICENSE. Every hand-written source file carries an SPDX identifier and a copyright line.
The version is not a matter of taste here. bpf/vmlinux.h is generated from the
running kernel's BTF, so it is derived from Linux kernel headers, and the kernel
is GPL-2.0 without the "or later" clause. The BPF object also declares
SEC("license") = "GPL", which the kernel reads as GPL-2 compatible, and that
object is embedded into the Go binary by go:embed — so the BPF and userspace
halves ship as a single work and cannot hold incompatible licenses. GPL-3.0 is
incompatible with GPL-2.0-only, which rules it out.
Two files carry no copyright header on purpose:
bpf/vmlinux.h— generated from kernel BTF. It is kernel-derived and covered by the kernel's own GPL-2.0; claiming copyright over it would be wrong.internal/bpfobj/tcptrace_bpfel.go— regenerated bybpf2goon every build, so any header added to it would be overwritten.