Skip to content

Latest commit

 

History

3 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tcptrace

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.

Requirements

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

Quick start

make build
sudo ./bin/tcptrace run -i eth0 --out ./captures

Startup 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

Architecture

 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

One connection, one entry

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 dissector is a subscription

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=off removes the entry; the tail call falls through and tracking continues untouched.
  • --http-emit=off keeps 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.

TCP state machine

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_WAITCLOSINGCLOSED
RST RESET
data with no handshake ESTABLISHED, flagged MIDSTREAM

What the HTTP dissector handles

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

Caps

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.

Output format

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.

Usage

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.

Testing

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-all

make 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.

Notes and non-goals

  • 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, or tcptrace dump) report ring buffer drops, desyncs, retransmissions and truncations, so degraded capture is visible rather than silent.

On the shape of the BPF code

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 memcpy from a variable offset expands into hundreds of single-byte loads.
  • The parse window is large and singular. The verifier re-checks a bpf_loop callback once per distinct call-site state, so each extra outer iteration multiplies the cost of the whole parser.
  • The emit helpers are __noinline and 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.

License

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 by bpf2go on every build, so any header added to it would be overwritten.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages