pktkit
Zero-copy L2/L3 packet handling toolkit for Rust.
pktkit is a feature-gated multi-tool for building virtual network topologies:
devices, hubs, adapters, NAT, and tunnels that move Ethernet frames and IP
packets without copying buffers on the hot path.
It is a Rust port of the Go pktkit library, re-cast into idiomatic Rust:
FrameandPacketare#[repr(transparent)]newtypes around[u8]. You hold them as&Frame/&mut Frame, exactly like Go's[]bytealias, with no per-call allocation.- Forwarding uses synchronous callbacks (
Arc<dyn Fn(&Frame) -> io::Result<()>>), not channels or async — matching the Go API one-for-one and keeping the hot path zero-cost. - Everything beyond the core (
Frame,Packet,L2Hub,L3Hub,Pipe, …) lives behind a Cargo feature so a user pulling only the core types pays nothing for crypto, OS FFI, or protocol stacks they don't use.
Features
Core (always on)
- Zero-copy types:
Frame(L2),Packet(L3), and thel4viewsTcpSegment,UdpDatagram,IcmpMessage— plusTcpFlags,FiveTuple,EtherType,Protocol,MacAddr,IpPrefix - Traits:
L2Device,L3Device,L2Acceptor,L2Connector,L3Connector - L2Hub: MAC-learning switch with VLAN access/trunk ports, per-port learning limits, and bounded forwarding for looped topologies
- L3Hub: prefix-routing hub with default-route fallback
- PipeL2 / PipeL3: in-memory devices for testing
- connect_l2 / connect_l3: point-to-point wiring
- serve: accept loop, with auto-cleanup on
Done - build: constructors that fill in lengths and checksums, plus VLAN push/pop
- icmp: error generation with the RFC rules on when a reply is forbidden
- fragment: IPv4 fragmentation for the send path
- checksum: RFC 1071, pseudo-header, and RFC 1624 incremental update
- DeviceStats / HubStats: rx/tx/drop counters, for when a packet vanishes
Opt-in cargo features
| Feature | What you get |
|---|---|
l2adapter |
ARP, NDP, gateway routing, L2Adapter bridging an L3 device onto an L2 net |
dhcp |
DHCP client codec + DHCPServer (DISCOVER/OFFER/REQUEST/ACK/…) |
qemu |
QEMU userspace network socket protocol (listener + dialer) |
pcap |
Mirror a device's traffic to a .pcap file (TapL2 / TapL3) |
impair |
Delay, jitter, loss, duplication, corruption and rate limits on a link |
tuntap |
TUN/TAP devices on Linux and macOS |
afpacket |
Bind an L2 device to an existing interface (Linux AF_PACKET) |
xdp |
Linux XDP: load/attach eBPF on a device's RX path, capture chosen IP prefixes |
afxdp |
Linux AF_XDP zero-copy sockets (builds on xdp) |
vtcp |
Pure-Rust TCP engine (congestion, SACK, timestamps, window scaling, SYN cookies) |
slirp |
Userspace NAT stack routing virtual traffic to real sockets |
vclient |
High-level virtual client: dial, listen, DNS, minimal HTTP |
nat |
Packet-level IPv4 NAT + NAT64 + ALGs (FTP, SIP, H.323, PPTP, TFTP, IRC) |
wg |
WireGuard tunnel (Noise IK + transport) |
ovpn |
OpenVPN server (TLS control + AES-CBC/GCM data) |
full |
All of the above |
full builds on every platform. xdp and afxdp are Linux kernel interfaces
with no analogue elsewhere, so those two modules are simply absent off Linux;
tuntap and afpacket keep their types everywhere and report
ErrorKind::Unsupported when opened on a platform that has no such device.
Dependency policy
pktkit depends on:
- the Rust standard library
libc(only whentuntap,afpacket,xdporafxdpis enabled)purecryptofor every piece of cryptography — primitives, X.509 and the TLS 1.2 control channel — only whenwgorovpnis enabled. We do not roll our own crypto.
That is the entire tree. Two direct dependencies, no transitive ones, no vendored C or assembly, and no build scripts.
No async runtime. No framework. No native code beyond libc. The default
build pulls in zero dependencies, and so do the pcap and impair features.
The policy is enforced in CI by cargo-deny rather than just stated: ring,
aws-lc-rs, openssl-sys and rustls are banned outright, so a second crypto
implementation cannot slip back in behind a default feature.
Requirements
Rust 1.88 or newer, edition 2024.
Usage
Point-to-point L3
Devices are shared as Arcs (the Arc<T>: L3Device blanket impl makes this
ergonomic), and connect_l3 cross-wires their handlers:
use Ipv4Addr;
use Arc;
use ;
let a = new;
let b = new;
connect_l3;
Virtual LAN with DHCP and NAT
// requires: --features "l2adapter dhcp slirp"
use Ipv4Addr;
use Arc;
use ;
use ;
use Stack;
let hub = new;
// DHCP server handing out 192.168.0.10–100.
let mut dcfg = new;
dcfg.router = Some;
dcfg.dns = vec!;
let _dhcp_handle = hub.connect;
// NAT gateway: a slirp stack routing to the real network, bridged onto L2.
let stack = new;
stack.set_addr.unwrap;
let gw = new_arc;
let _gw_handle = hub.connect_arc;
Virtual client over the tunnel (DNS + TCP + HTTP)
// requires: --features "vclient"
use Ipv4Addr;
use IpPrefix;
use ;
let client = new;
// Wire `client` into an L3 network (slirp, wg, hub) via its L3Device impl,
// then:
let resp = client.http_get?;
println!;
WireGuard server with per-peer isolation
// requires: --features "wg slirp"
use ;
use Arc;
use ;
use ;
use Stack;
let stack = new;
stack.set_addr.unwrap;
let adapter = new?;
adapter.add_peer;
let udp = bind?;
adapter.serve?;
QEMU VM networking
// requires: --features "qemu"
use Arc;
use ;
use qemu;
let listener = bind_unix?;
let hub = new;
serve?; // accept loop: each VM joins the hub
Reading and building packets
Typed views go all the way to L4, and the builders fill in every length and checksum, so tests and protocol code never assemble headers by hand:
use ;
use ;
use Ipv4Addr;
let = ;
let udp = build_udp;
let buf = build_ipv4;
let pkt = from_slice;
assert!;
assert_eq!;
assert_eq!;
IPv6 extension headers are walked for you: ip_protocol() and payload()
report the real upper-layer protocol and where it starts, rather than whatever
the next-header field happens to say. ipv6_next_header() still gives you the
literal field when you want it.
Forwarding a packet correctly
The pieces a forwarder owes its senders — expiry, MTU, and the ICMP that explains both — are in the box:
use ;
use ;
Seeing what happened
Devices and hubs keep counters, and any device can be wrapped in a tap that
writes a .pcap Wireshark opens:
// requires: --features "pcap"
use TapL2;
let tap = to_file?;
// use `tap` in place of `device`; both directions are written as they pass
let s = hub.stats;
println!;
Testing against a bad link
// requires: --features "impair"
use ;
use Duration;
let link = new;
Capturing specific addresses with XDP
xdp attaches an eBPF program that redirects only the traffic belonging to a
set of IP prefixes and returns XDP_PASS for everything else, so a capture
device shares a live NIC with the host stack instead of black-holing it.
use ;
use ;
use Ipv4Addr;
use Arc;
// One AF_XDP socket per RX queue, native-mode attach, zero-copy if the
// driver supports it.
let dev = open?;
dev.set_handler;
// Nothing is diverted until an address is named. Takes effect immediately:
// the prefix goes into a map the running program reads.
dev.capture_add?;
println!;
Matching is longest-prefix, so a /24 captures a whole subnet. ARP for a
captured IPv4 address is captured too (otherwise nothing could resolve it), and
adding an IPv6 /128 also captures its solicited-node multicast address so
neighbour discovery arrives.
A capture can never widen into the whole interface. capture_add refuses a
/0 outright, refuses anything shorter than the configured per-family floor
(min_prefix_v4 / min_prefix_v6), and refuses any addition that would leave
the set covering an entire address family — two /1s clear the floor
individually but not together. Both checks run before anything reaches the
kernel, so a refused call leaves the set unchanged. Traffic that matches nothing
returns XDP_PASS and goes to the host stack as usual.
Status
Active development; the API is not yet stable. Most features are functionally
complete and tested; a few have documented // TODO(<feature>) gaps:
- ovpn: tls-crypt/tls-auth, control-packet retransmit timers, and fuller PUSH_REPLY negotiation are not yet implemented.
- xdp / afxdp: program codegen, map key layout and ring math are
unit-tested, and
tests/xdp_kernel.rscovers verifier acceptance and the veth datapath — but those are#[ignore]d because they need root, so the kernel-facing paths stay marked// TODO(afxdp)until CI runs them. Zero-copy additionally needs a driver that supports it;Device::zerocopy()reports what was actually negotiated. - tuntap: macOS
utunis type-checked against the Apple target but not yet exercised on a macOS host. - slirp: inbound virtual TCP accept is IPv4-only (IPv6 accept is a TODO).
- nat: SIP/H.323/PPTP ALGs rewrite payloads; UPnP's live TCP control endpoint awaits wiring through the virtual TCP listener.
License
MIT — see LICENSE.