ferranet 0.2.0

A modern, async-first, zero-copy datalink-layer (L2) networking library
Documentation

ferranet

⚠️ Beta — use with care. The core paths are exercised by unit, property, fuzz, Miri, and CI-run veth integration tests, and the AF_PACKET backends (ring and basic) have been validated on real hardware — a Raspberry Pi Zero 2 W over a direct ethernet link, under the pibench capture and flood workloads. Known gaps: the AF_XDP backend runs end-to-end over veth (generic/copy mode) but is unvalidated on a real NIC (zero-copy, multi-queue), the throughput numbers in BENCHMARKS.md are still veth-only, and the API may see further breaking changes before 1.0. Review the unsafe yourself 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_MMAP ring and are valid "until the end of the block" — no copying.
  • Batching. A TPACKET_V3 RX ring (block-based) and TPACKET_V2 TX ring amortize syscalls.
  • Justified unsafe. All unsafe is concentrated in one module, each block carrying a SAFETY argument resting on the kernel's TP_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. XdpSocket drives 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_PACKET backends are validated on real hardware (Raspberry Pi Zero 2 W, pibench capture/flood). Still to do: the throughput benches and the hw test suite against cabled NICs (FERRANET_BENCH_IFACE_* / FERRANET_HW_IFACE_*) — the BENCHMARKS.md numbers are veth-only and forwarding-bound.
  • Finish AF_XDP. The receive/transmit path is covered end-to-end over veth in generic mode (tests/xdp_veth.rs, redirect program loaded via aya). Still to do: a multi-queue NIC, zero-copy mode, and need_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_FANOUT control — flags (defrag, rollover) and BPF-steered fanout.
  • Exact snap lengthsnaplen currently rounds up to a power-of-two frame size.
  • Other platforms — BSD/macOS (BPF) and Windows behind the existing RawChannel boundary.
  • 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)

# #[cfg(feature = "xdp")]
# fn run() -> ferranet::Result<()> {
use ferranet::{XdpConfig, XdpSocket, interfaces};

let idx = interfaces()?.into_iter().find(|i| i.name == "eth0").unwrap().index;
let mut sock = XdpSocket::open(idx, XdpConfig { queue_id: 0, ..XdpConfig::default() })?;
let block = sock.recv()?;                 // zero-copy frames from the UMEM
for frame in block.frames() { /* ... */ }
# Ok(())
# }

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 ferranet::Channel;

#[tokio::main]
async fn main() -> ferranet::Result<()> {
    let (mut tx, mut rx) = Channel::builder("eth0").promiscuous(true).build_async()?;

    // Zero-copy, batched receive.
    let block = rx.recv_block().await?;
    for frame in block.frames() {
        println!("{} bytes, type {:?}", frame.data().len(), frame.packet_type());
    }

    // Send a raw frame (must include the Ethernet header for SOCK_RAW).
    tx.send(b"\xff\xff\xff\xff\xff\xff\x02\x00\x00\x00\x00\x01\x88\xb5hi").await?;
    Ok(())
}

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 ferranet::{Channel, FanoutMode};

# fn main() -> ferranet::Result<()> {
let receivers = Channel::builder("eth0")
    .fanout(FanoutMode::Hash)        // flow-consistent steering
    .build_fanout_rx(4)?;            // 4 sockets in one fanout group

for mut rx in receivers {
    std::thread::spawn(move || {
        loop {
            let block = rx.recv_block()?;
            for frame in block.frames() { /* ... */ }
        }
        # #[allow(unreachable_code)] ferranet::Result::Ok(())
    });
}
# Ok(())
# }

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:
sudo setcap cap_net_raw+ep ./target/debug/examples/sniff

# Or run inside an unprivileged user + network namespace:
unshare --user --map-root-user --net ./your-program

Testing

Unit tests need no privileges:

cargo test

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:

cargo install cargo-fuzz
cargo +nightly fuzz run parse_block          # see fuzz/

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:

cargo +nightly miri test --lib

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 = ferranet::dummy::channel()?;
net.inject.inject(frame_bytes);              // queue a frame to be received
let block = net.rx.recv_block()?;            // ... and receive it
net.tx.send(other_frame)?;                   // send a frame ...
assert_eq!(net.drain.try_recv().unwrap(), other_frame); // ... and read what was sent
# Ok::<(), ferranet::Error>(())

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:

cargo test --test veth --no-run
unshare --user --map-root-user --net \
    env RUSTC_WRAPPER= cargo test --test veth -- --ignored --test-threads=1

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:

cargo test --test hw --no-run
sudo FERRANET_HW_IFACE_A=enp1s0f0 FERRANET_HW_IFACE_B=enp1s0f1 \
    ./target/debug/deps/hw-<hash> --ignored --test-threads=1

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 ferranet::{Channel, RingConfig};

let (mut tx, mut rx) = Channel::builder("eth0")
    // Small rings (~256 KiB vs the 8 MiB default) stay cache-resident and cut RAM ~32×.
    .rx_ring(RingConfig::small().snaplen(128))   // capture only headers
    .build_sync()?;
# Ok::<(), ferranet::Error>(())
  • 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 first n bytes 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 metadatatimestamp()/vlan()/packet_type() are decoded on access for ring-captured frames, so a loop that only reads frame.data() pays nothing for them.
  • Sync-only build — depend with default-features = false to drop Tokio: smaller binary, no async reactor, fewer threads. The blocking build_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() {                 // zero-syscall, inline: the kernel flagged drops
    let s = rx.stats()?;               // exact, cumulative counts (and clears the flag)
    eprintln!("dropped {} packets, {} ring freezes", s.dropped, s.freezes);
}
# Ok::<(), ferranet::Error>(())
  • Block::is_losing() — reads the TP_STATUS_LOSING bit the kernel sets on a block when it has dropped packets. No syscall, checkable every receive.
  • Receiver::stats() — monotonic cumulative received / dropped / freezes since 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:

cargo bench --bench parse                                   # privilege-free parse hot loop
cargo bench --bench throughput --no-run                     # build outside the namespace…
unshare --user --map-root-user --net env RUSTC_WRAPPER= cargo bench --bench throughput   # …run inside it
# 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 veth all backends are forwarding-bound, so the ring's batched-flush win is partly masked and widens on a real NIC.
  • PACKET_FANOUT distributes 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-queue veth caps 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.