# 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`](https://github.com/libpnet/libpnet)'s
datalink module, rebuilt around the lessons its original author shared in a
[retrospective](https://github.com/rustasync/team/issues/15#issuecomment-408595008):
- **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`](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 length** — `snaplen` 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`](https://crates.io/crates/etherparse) for dissection.
### AF_XDP (`xdp` feature)
```rust,no_run
# #[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
```rust,no_run
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:
```rust,no_run
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 `AsyncReceiver`s for the same pattern on Tokio.
## Permissions
Opening an `AF_PACKET` socket requires the `CAP_NET_RAW` capability. For local use:
```sh
# 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:
```sh
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:
```sh
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:
```sh
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):
```rust,no_run
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:
```sh
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:
```sh
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:
```rust
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 metadata** — `timestamp()`/`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:
```rust
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](https://github.com/bheisler/criterion.rs)
benchmarks that produce statistically-sampled results plus an HTML report and SVG plots under
`target/criterion/`. Run everything with `scripts/bench.sh`, or individually:
```sh
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`](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.