ferranet
⚠️ Beta — use with care. The core paths are exercised by unit, property, fuzz, Miri, and CI-run
vethintegration tests, and theAF_PACKETbackends (ring and basic) have been validated on real hardware — a Raspberry Pi Zero 2 W over a direct ethernet link, under thepibenchcapture and flood workloads. Known gaps: theAF_XDPbackend runs end-to-end overveth(generic/copy mode) but is unvalidated on a real NIC (zero-copy, multi-queue), the throughput numbers inBENCHMARKS.mdare stillveth-only, and the API may see further breaking changes before 1.0. Review theunsafeyourself before relying on it.
A modern, async-first, zero-copy datalink-layer (Layer 2) networking library for Rust.
ferranet lets you open a network interface and send/receive raw Ethernet frames with batching
and zero-copy receive. It is a spiritual successor to pnet's
datalink module, rebuilt around the lessons its original author shared in a
retrospective:
- Async first. A Tokio-based API is the primary surface, with a blocking API for maximum-throughput use.
- Zero-copy receive. Received frames borrow directly from a kernel-mapped
PACKET_MMAPring and are valid "until the end of the block" — no copying. - Batching. A
TPACKET_V3RX ring (block-based) andTPACKET_V2TX ring amortize syscalls. - Justified
unsafe. Allunsafeis concentrated in one module, each block carrying aSAFETYargument resting on the kernel'sTP_STATUS_*ownership protocol. - The fd is yours.
as_fd()exposes the underlying descriptor for interop.
Status
v0.2, Linux only, behind a sys::RawChannel abstraction boundary so other platforms
(BSD/macOS, Windows) can be added later without API churn. Two backends ship today:
AF_PACKET/PACKET_MMAP(default) — the zero-copy TPACKET_V3 RX ring / TPACKET_V2 TX ring.AF_XDP(optional,--features xdp) — the kernel-bypass tier.XdpSocketdrives the UMEM and fill/completion/RX/TX rings; steering traffic in still requires an XDP redirect program (see below).
Packet parsing/construction is intentionally out of scope (a separate concern).
The default ring backend needs Linux ≥ 3.2; the AF_XDP backend needs ≥ 4.18. See
docs/kernel-compat.md for the per-feature kernel matrix and required
capabilities.
Interface enumeration includes per-interface IP addresses: ferranet::interfaces() returns
Interface { name, index, mac, ips, flags, mtu }, where each IfAddr carries the address, its
netmask, and a prefix_len().
Roadmap / TODO
Before a 1.0 this needs, roughly in priority order:
- Real-hardware validation. The
AF_PACKETbackends are validated on real hardware (Raspberry Pi Zero 2 W,pibenchcapture/flood). Still to do: the throughput benches and thehwtest suite against cabled NICs (FERRANET_BENCH_IFACE_*/FERRANET_HW_IFACE_*) — theBENCHMARKS.mdnumbers areveth-only and forwarding-bound. - Finish AF_XDP. The receive/transmit path is covered end-to-end over
vethin generic mode (tests/xdp_veth.rs, redirect program loaded viaaya). Still to do: a multi-queue NIC, zero-copy mode, andneed_wakeup; integrate loading of the XDP redirect program so the backend is turnkey instead of bring-your-own. - API stabilisation. Audit and settle the public surface; today it's unstable.
- cBPF socket filters (
SO_ATTACH_FILTER) — optional in-kernel capture filtering. - Zero-copy transmit — a
send_with(len, |slot| …)builder to drop the user→ring copy. - Hardware timestamps (
SO_TIMESTAMPING/PHC) — only software RX timestamps today. - More
PACKET_FANOUTcontrol — flags (defrag, rollover) and BPF-steered fanout. - Exact snap length —
snaplencurrently rounds up to a power-of-two frame size. - Other platforms — BSD/macOS (BPF) and Windows behind the existing
RawChannelboundary. - Continuous fuzzing and a checked-in corpus; widen CI (MSRV, feature matrix).
Packet parsing/construction stays out of scope by design — pair ferranet with
etherparse for dissection.
AF_XDP (xdp feature)
#
#
Unlike AF_PACKET, an AF_XDP socket only receives frames that an XDP program redirects into its
XSKMAP for the bound (interface, queue) — the kernel ships no default. ferranet builds and
drives the socket and rings; attaching the redirect program is left to the operator or a higher
layer (libxdp/aya/xdp-loader), exactly as libxdp and xsk-rs separate the socket from the
program. The data-path math is unit-tested; end-to-end use needs a real NIC/queue (or veth in
generic XDP mode) with a redirect program attached.
Example
use Channel;
async
A blocking equivalent is available via Channel::builder(..).build_sync().
Multi-core receive (PACKET_FANOUT)
Open a group of receivers and let the kernel load-balance frames across them — one per core:
use ;
#
build_fanout_rx_async returns AsyncReceivers for the same pattern on Tokio.
Permissions
Opening an AF_PACKET socket requires the CAP_NET_RAW capability. For local use:
# Grant the capability to a built binary:
# Or run inside an unprivileged user + network namespace:
Testing
Unit tests need no privileges:
Property tests (proptest) cover the pure parsing/validation surfaces — MAC parsing round-trips,
ring-geometry invariants, snap-length derivation, and the TPACKET_V3 block parser's correctness —
and run as part of cargo test.
Fuzzing (cargo-fuzz/libFuzzer) hammers the block parser — the one place ferranet does pointer
reads with bounds checks over variable input — with coverage-guided garbage, including out-of-range
offsets and adversarial frame counts:
Miri checks the pure-memory unsafe for undefined behaviour, out-of-bounds, and provenance
violations — chiefly the block parser's read_unaligned/offset math and frame construction, driven
over plain heap buffers:
Miri cannot interpret syscalls, so the FFI paths (sockets, mmap, PACKET_*, AF_XDP, interface
enumeration) are #[ignore]d under Miri and covered by the veth/dummy/hw tests instead.
For deterministic, privilege-free testing of packet handling, the dummy backend gives you a real
Sender/Receiver pair wired to in-memory FIFO queues instead of a NIC (a parity to libpnet's
dummy backend, and usable by downstream code the same way):
let mut net = channel?;
net.inject.inject; // queue a frame to be received
let block = net.rx.recv_block?; // ... and receive it
net.tx.send?; // send a frame ...
assert_eq!; // ... and read what was sent
# Ok::
It is backed by a pipe for readiness, so it works with the blocking API, poll, and the async API
alike. See tests/dummy.rs for the full suite (send/receive ordering, idle-blocking, injected
errors, end-of-stream).
End-to-end tests use a veth pair and are #[ignore]d by default. Run them inside an unprivileged
namespace, which provides CAP_NET_RAW without root. Build first (the namespace has no network, so
compilation must happen outside it), then run:
To exercise real hardware, the hw suite (also #[ignore]d) runs roundtrip, VLAN, RX-timestamp,
and fanout-distribution checks against two cabled ports you name via the environment:
It skips cleanly when those vars are unset, so cargo test stays green everywhere.
Low-power / embedded hardware
On constrained devices (small ARM SoCs, little RAM, small caches, single-queue NICs at low packet rates) the goal is less memory moved, smaller footprint, fewer wakeups, less CPU per frame — not peak Mpps. ferranet has several knobs for this:
use ;
let = builder
// Small rings (~256 KiB vs the 8 MiB default) stay cache-resident and cut RAM ~32×.
.rx_ring // capture only headers
.build_sync?;
# Ok::
RingConfig::small()— a ~256 KiB RX ring instead of 8 MiB. Ample at low rates and keeps the ring hot in L2.RingConfig::snaplen(n)— for header-only monitoring, captures roughly the firstnbytes per frame. The kernel truncates its copy into the ring, so far less memory/bandwidth is used and many more frames pack per block;Frame::wire_len()still reports the true length. The single biggest lever for header-parsing agents.- Lazy frame metadata —
timestamp()/vlan()/packet_type()are decoded on access for ring-captured frames, so a loop that only readsframe.data()pays nothing for them. - Sync-only build — depend with
default-features = falseto drop Tokio: smaller binary, no async reactor, fewer threads. The blockingbuild_sync()API is all you need for a capture loop. - Wakeup vs latency — raise
RingConfig { retire_blk_tov_ms, .. }(default 60 ms) to wake less often under light traffic (lower power), or lower it for snappier delivery. - One core, pinned — on a single-queue NIC use a single channel (no
PACKET_FANOUT) and pin the capture thread to a core; build with-C target-cpu=<soc>for the target.
Detecting dropped packets
For all-packets capture, the thing that loses data is the ring overflowing — so drop detection is first-class:
let block = rx.recv_block?;
if block.is_losing
# Ok::
Block::is_losing()— reads theTP_STATUS_LOSINGbit the kernel sets on a block when it has dropped packets. No syscall, checkable every receive.Receiver::stats()— monotonic cumulativereceived/dropped/freezessince the channel opened (ferranet accumulates the kernel's reset-on-read counter for you). Reading it also clears the losing flag, so the pattern is:is_losing()tells you that you're dropping,stats()tells you how much.
Benchmarking
Performance is a primary goal, so the crate ships Criterion
benchmarks that produce statistically-sampled results plus an HTML report and SVG plots under
target/criterion/. Run everything with scripts/bench.sh, or individually:
# open target/criterion/report/index.html
Headline numbers on a veth pair, including a same-harness comparison against libpnet's real
datalink channel (pnet_datalink 0.35). Full results, methodology, and honest caveats in
BENCHMARKS.md:
- RX: ferranet's zero-copy ring is ~3× libpnet (1.90 vs 0.61 Mpps at 64 B), drops nothing under overload, and clears 20+ Gbit/s single-core at MTU (libpnet ~7). Even ferranet's basic backend beats libpnet ~1.8× (libpnet polls per packet).
- TX: ferranet ~1.45× libpnet (0.68 vs 0.47 Mpps); over
vethall backends are forwarding-bound, so the ring's batched-flush win is partly masked and widens on a real NIC. PACKET_FANOUTdistributes perfectly evenly across N receivers (e.g.[942, 940, 942, 945]k for N=4), parallelizing RX across cores. Aggregate scaling needs a multi-queue NIC; a single-queuevethcaps the demo.- Next tier is AF_XDP (~10–20× over
AF_PACKET), a planned backend behind the same boundary.
License
MIT OR Apache-2.0.