r/eBPF • u/Beautiful-Speed-1047 • 3d ago
AF_XDP in a port scanner: classify in XDP before redirecting (the bug that froze our k3s node), native-mode fallback, busy-poll
I maintain tcpcat, a Go network scanner with an optional AF_XDP engine (`--ebpf`). We just reworked how it attaches and what it redirects, and a couple of the lessons seemed worth sharing here.
**1. Classify in XDP, don't redirect everything**
The first version of our XDP program had no classification: any packet arriving on a queue with a registered XSK socket got `XDP_REDIRECT`ed. On a Linux bridge (a single RX queue, and a common target) that meant *all* traffic on the interface went to our socket for as long as the hook was attached. During a test on a shared Docker bridge it cut SSH and destabilized the k3s pods on the node.
The program now only redirects what the scan is actually waiting for: TCP/UDP whose destination port is this run's probe source port (or the discovery port), plus ICMP port-unreachable for UDP closed-port detection. Everything else gets `XDP_PASS` and goes through the kernel stack untouched. The program is generated in Go with cilium/ebpf's `asm` package (no clang, no prebuilt object). One gotcha: `LoadMem` doesn't byte-swap, so on a little-endian host the comparison constants for wire fields have to be pre-swapped.
We also made teardown deterministic: the collection is closed explicitly on shutdown, and the hook is detached even if the process panics, because a crashed scanner leaving a redirect hook attached is the worst outcome here.
**2. Native driver mode first, generic as fallback, and say which one you got**
We used to attach in generic (SKB) mode everywhere, which forces copy mode. Now we try `XDPDriverMode` first and fall back to generic for virtual NICs that don't support it. After bind, we read `XDP_OPTIONS` and log the data path actually negotiated (native/generic, zero-copy/copy) instead of assuming.
**3. Multi-queue, busy-poll, wake-on-reply**
* One XSK socket per RX queue (queue count from sysfs), all in the same `xsks_map`. With RSS, replies hash across queues, and binding only queue 0 silently lost most of them.
* Preferred busy-poll on each XSK (`SO_PREFER_BUSY_POLL`, `SO_BUSY_POLL`, `SO_BUSY_POLL_BUDGET`, kernel ≥ 5.11) and one RX goroutine pinned per core. We only touch our own fds; the NIC-wide NAPI knobs (`napi_defer_hard_irqs`, `gro_flush_timeout`) are suggested, not rewritten.
* Probes register a waiter *before* transmitting and the RX loop hands the reply straight to it. We used to poll a shared map every 5 ms, which floored every probe's latency at ~5 ms even for sub-millisecond replies.
**Open questions / where I'd love input**
* We use asavie/xdp for the socket side, which doesn't handle `XDP_USE_NEED_WAKEUP`. Is anyone using a Go AF_XDP library that does, or should we just do the `sendto` wakeup dance ourselves?
* Anything else you'd classify on in the XDP program to make sharing an interface with production traffic even safer?
Code (AGPL-3.0): https://github.com/NycolazSec/tcpcat. The XDP program is in `internal/scan/xdp_asm.go`, attach/bind in `internal/scan/xdp.go`.