Date: 2026-07-23 Source: web research (general-purpose agent), consolidated and verified against docs.rs / GitHub.
- Current crates:
aya(userspace) andaya-ebpf(kernel-side, formerlyaya-bpf), plusaya-log/aya-log-ebpffor logging. - As of Aya 0.13,
Bpfwas renamed toEbpfandBpfLoadertoEbpfLoader. This is now the canonical API (aya::Ebpf,aya::EbpfLoader, confirmed live on docs.rs). Old names are kept as type aliases for a transition period but are deprecated; new code should useEbpf/EbpfLoaderdirectly. - Related 0.13 breaking change:
ProbeContext(kprobe/uprobe) split fromRetProbeContext(kretprobe/uretprobe). Return-value access on a kretprobe must go throughRetProbeContext, not a unified context, relevant since our design uses kretprobes to read the connect() result. - Other 0.13 changes to be aware of if consulting older tutorials: Maps API rework, removal of
aya::ProgramFd, changedEbpfLoader::set_globalsignature, BTF types moved into a separateaya-objcrate. Any pre-0.13 example code needs re-verification against current docs.rs.
Compared three options for observing outbound TCP connects:
| Option | Verdict |
|---|---|
kprobe+kretprobe on tcp_v4_connect / tcp_v6_connect |
Chosen |
tracepoint syscalls:sys_enter_connect / sys_exit_connect |
Rejected |
tracepoint sock:inet_sock_set_state |
Rejected |
Reasoning:
- The syscall tracepoint only exposes the raw
sockaddrargument passed by userspace: awkward to parse for IPv6/mapped-address cases, and gives no access to the kernelstruct sockinternals we want (state, established addresses). sock:inet_sock_set_staterequires filtering for theTCP_SYN_SENTtransition and separately correlating process context; less direct than hooking the connect call itself.- The kprobe/kretprobe pair on
tcp_v4_connect/tcp_v6_connectis the de facto standard used by bcc/libbpf-toolstcpconnect.bpf.c, bpftrace'stcpconnect, and Aya-based prior art (e.g.mfontanini/sockwho). Pattern: entry kprobe stashes thestruct sock *in a hash map keyed bypid_tgid; kretprobe checks the return value (0 == success), reads connection details from the cachedsock*, then deletes the map entry. - IPv4 and IPv6 are handled by separate kernel functions; both need their own probe attachments; no single unified hook covers both address families.
Explicit tradeoff (documented for the design doc and README): kernel-internal functions like
tcp_v4_connect are not part of a stable kernel ABI: names/signatures can change across kernel
versions or config. The C/libbpf ecosystem mitigates the field-layout half of this risk with
BTF/CO-RE relocations; rustc/bpf-linker emit no CO-RE field relocations, so this project
instead performs the equivalent resolution in userspace at startup, parsing the running
kernel's BTF and injecting the resolved sock_common offsets into eBPF globals before load
(see design.md §5, addendum §7 below, and the README's Known Limitations). This is a
deliberate deviation from the contract's general "prefer stable tracepoints"
guidance, justified because it's the only practical way to get correct PID + real destination
IP/port + correct v4/v6 disambiguation at the connect() call site.
aya_ebpf::helpers::bpf_get_current_pid_tgid()returns au64: TGID (process id, matchesps//procsemantics) in the high 32 bits, thread id in the low 32 bits. Use the high bits as "pid" for display.aya_ebpf::helpers::bpf_get_current_comm()returns[u8; 16]: kernel'sTASK_COMM_LENis 16 bytes (including NUL), unchanged historically. All target browser process names fit:firefox(7),chrome(6),chromium(8),brave(5),brave-browser(13), all ≤ 15 chars + NUL. Still worth a code comment since TASK_COMM_LEN silently truncates anything ≥ 16 bytes (relevant if the browser list is ever extended).
aya::maps::RingBuf(requires kernel ≥ 5.8) is current best practice overPerfEventArray: single shared ring buffer across CPUs (vs per-CPU), strict event ordering, event-driven wakeup, reserve/commit direct writes, in-kernel drop reporting.- Userspace polling pattern: either wrap the ring buffer fd in
tokio::io::unix::AsyncFdfor async polling, or use a blocking loop with epoll. For this minimal, non-concurrent CLI, a blocking poll loop is sufficient and keeps the dependency tree smaller (no tokio needed).
- Address/port fields read from
struct sock.__sk_common:- v4:
skc_daddr(dest addr),skc_rcv_saddr(src addr) - v6:
skc_v6_daddr.in6_u.u6_addr32/skc_v6_rcv_saddr.in6_u.u6_addr32 - ports:
skc_dport(dest port),skc_num(local/src port)
- v4:
- Byte order gotcha:
skc_dportis in network byte order and must be byte-swapped (u16::from_be/bpf_ntohsequivalent) before display.skc_num(local port) is typically already host byte order in modern kernels: worth a runtime sanity check/comment rather than a blind assumption, since this asymmetry is a common source of bugs in tcpconnect-style tools.
Not covered by the original web research, discovered only through empirical testing against
the running kernel: bpf_get_current_comm() reports the calling thread's name, and for
Chromium-based browsers (Brave, Chrome) the connect() syscall is issued from a dedicated IO
thread whose kernel comm is "Chrome_ChildIOThread" (truncated to "Chrome_ChildIOT"),
never the browser's own process name. An in-eBPF comm-based filter therefore silently drops
every real Chromium event.
This was diagnosed with a standalone bpftrace one-liner run directly against the kernel,
independent of this project's code:
sudo bpftrace -e 'kprobe:tcp_v4_connect { printf("%s pid=%d\n", comm, pid); }'which printed Chrome_ChildIOT pid=6464 while visiting example.com in Brave, confirming the
kprobe fires correctly and the bug was isolated to the userspace/eBPF filtering assumption, not
probe attachment or the kernel hook choice. Fix: match on /proc/<pid>/comm (the thread-group
leader's name) using the already-captured pid, instead of the in-kernel thread comm. See
design.md section 3 for the applied fix.
Follow-up to the tradeoff note in §2. Since rustc/bpf-linker cannot emit CO-RE field relocations, the fixed-offset fragility was addressed in userspace instead:
- At startup the CLI loads
/sys/kernel/btf/vmlinuxviaaya::Btf::from_sys_fs()and resolves the byte offsets ofskc_daddr,skc_dport,skc_num, andskc_v6_daddrinstruct sock_common, then injects them into#[unsafe(no_mangle)]globals in the eBPF program withEbpfLoader::set_global(..., must_exist = true)before load. The globals are read withcore::ptr::read_volatilein the probe so LLVM cannot constant-fold the fallback initializers into the instruction stream (verified: the compiled object retainsR_BPF_64_64relocations against all four symbols). - aya-obj 0.3 parses BTF but keeps member fields of its types private, so the resolver walks
the raw type section of
Btf::to_bytes()directly. Two layout subtleties matter:sock_commonpromotes several fields out of C anonymous structs/unions, which must be followed recursively while accumulating bit offsets, and structs containing bitfields use thekind_flagmember encoding (bit offset in the low 24 bits, bitfield width in the high 8), which must be decoded before converting to byte offsets. - Failure policy: no BTF on the kernel → warn and fall back to the built-in offsets (the
pre-existing behavior); BTF present but
sock_commonunresolvable → hard error, since the built-in offsets are then equally suspect. - A host-side unit test (no root needed,
/sys/kernel/btf/vmlinuxis world-readable) runs the resolver against the build machine's kernel and assertsskc_daddr == 0,skc_dport == 12,skc_num == 14,skc_v6_daddr ∈ {48, 56}(56 observed on the Fedora x86_64 development machine, matching the previously hand-computed values).
All findings below came from bpftrace runs against the real kernel (Fedora, Linux 7.1.3)
with real browser traffic, not from documentation:
- The unprefixed
ip4_datagram_connectwrapper never fires on this kernel. It exists in kallsyms, but tracing it during heavy UDP activity (curl DNS lookups, systemd-resolved, browsers) produced zero hits, while__ip4_datagram_connectcaught everything. The original probe attempt on the wrapper looked like "browsers don't use UDP connect": the same class of false negative as the earlierChrome_ChildIOThreadcomm bug (§6): always verify which symbol actually fires before concluding the event doesn't happen.__ip6_datagram_connectlikewise covers v6, including theip6_datagram_connect_v6_onlywrapper's path. - Chromium/Brave QUIC uses connected UDP sockets. With the correct inner symbols traced,
Chrome_ChildIOTthreads showed steady v4 and v6 UDP connects to port 443 (QUIC) while browsing, plus UDP connects to port 53 from the browser's async DNS resolver. - Two distinct classes of dport=0 UDP connect() exist, needing two filters. First:
connect(AF_UNSPEC)disconnects (ChromiumThreadPoolForeg/libuv threads). They return 0 ("success") and are filtered at the entry probe by readinguaddr->sa_family. Second, discovered only during the live smoke test because it survives the first filter: Chromium's address sorter connects UDP sockets withsin_port = 0to each DNS candidate IP (observed as batches of four GitHub Pages addresses) purely so the kernel's route lookup reveals source-address selection, RFC 6724 style. These carry a real destination address, so they pass the AF_UNSPEC check and briefly printed as bogusUDP -> IP:0events. Since no packet can ever flow to port 0, the exit probe drops UDP events whose resolvedskc_dportis 0. http3.is's on-page verdict is unreliable for this purpose: it reported "HTTP/3 was not used" (it checks for stale h3-29/h3-27 drafts, and first visits negotiate over TCP before Alt-Svc is learned) during the same session in which the kernel trace showed QUIC connects. The kernel-side evidence is authoritative.
- https://docs.rs/aya/latest/aya/struct.Ebpf.html
- https://docs.rs/aya/latest/aya/struct.EbpfLoader.html
- https://github.com/aya-rs/aya
- https://aya-rs.dev/book/
- https://docs.rs/crate/aya/0.13.1/source/BREAKING-CHANGES.md
- https://github.com/iovisor/bcc/blob/master/libbpf-tools/tcpconnect.bpf.c
- https://github.com/mfontanini/sockwho
- https://docs.rs/aya/latest/aya/maps/ring_buf/struct.RingBuf.html
- https://docs.rs/aya-ebpf/latest/aya_ebpf/helpers/fn.bpf_get_current_comm.html
- https://docs.ebpf.io/linux/helper-function/bpf_get_current_pid_tgid/
- https://www.brendangregg.com/blog/2018-03-22/tcp-tracepoints.html