ferranet 0.1.0

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

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 veth integration tests, but nothing has been validated on real hardware yet (the throughput numbers are veth-only and the AF_XDP backend has never run end-to-end). Treat the API as unstable and expect breaking changes. Use at your own risk; review the unsafe yourself.

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.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. 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. Run the throughput benches and the hw test suite against actual NICs (FERRANET_BENCH_IFACE_* / FERRANET_HW_IFACE_*); the current numbers are veth-only and forwarding-bound. Capture real figures in BENCHMARKS.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. via aya/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_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 \
    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 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.