ferranet
⚠️ Alpha — not production-ready. ferranet is early-stage and needs significant testing before it should be relied on. The core paths are exercised by unit, property, fuzz, Miri, and
vethintegration tests, but nothing has been validated on real hardware yet (the throughput numbers areveth-only and theAF_XDPbackend has never run end-to-end). Treat the API as unstable and expect breaking changes. Use at your own risk; review theunsafeyourself.
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.1, 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. Run the throughput benches and the
hwtest suite against actual NICs (FERRANET_BENCH_IFACE_*/FERRANET_HW_IFACE_*); the current numbers areveth-only and forwarding-bound. Capture real figures inBENCHMARKS.md. - Finish AF_XDP. Validate the receive/transmit path end-to-end on a multi-queue NIC,
including zero-copy mode and
need_wakeup; integrate loading of the XDP redirect program (e.g. viaaya/libxdp) 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.