Windows Packet Filter provides official Rust bindings through the ndisapi crate (version 0.7). The crate communicates directly with the Windows Packet Filter driver; it does not require ndisapi.dll. It provides typed packet structures, batch I/O, and an asynchronous adapter wrapper that manages packet notifications and resource cleanup.
Installation
Install the Windows Packet Filter runtime on Windows, then create a Rust project:
cargo new winpkfilter-passthrough
cd winpkfilter-passthrough
Replace Cargo.toml with the following. Tokio supplies the asynchronous runtime and the shutdown channel.
[package]
name = "winpkfilter-passthrough"
version = "0.1.0"
edition = "2021"
[dependencies]
ndisapi = "0.7"
tokio = { version = "1", features = ["macros", "rt", "sync"] }
Key Features
- Direct driver access: Enumerate TCP/IP-bound adapters, query their properties, and configure packet filtering through
Ndisapi. - Typed filtering modes: Use
FilterFlagsto select listen mode or inline tunnel mode. - Batch I/O: Read and reinject multiple packets per driver call, including unsorted operations across adapters.
- Asynchronous capture:
AsyncNdisapiAdapterwaits for packet notifications and releases its event resources when dropped.
Getting Started Example
This example intercepts packets in both directions and forwards each frame unchanged. It prints the direction and frame length, demonstrating the full capture and reinjection loop without adding protocol parsing or filtering rules.
Save the following as src/main.rs. Running it without an argument lists adapters without enabling filtering. Supply a number from that list to start passthrough on the selected adapter.
Download main.rs · Download Cargo.toml
use ndisapi::{
AsyncNdisapiAdapter, DirectionFlags, FilterFlags,
IntermediateBuffer, Ndisapi,
};
use std::{error::Error, io, sync::Arc};
use tokio::sync::oneshot;
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<(), Box<dyn Error>> {
let index = std::env::args()
.nth(1)
.map(|s| s.parse::<usize>())
.transpose()?;
// The async adapter API shares the driver through Arc.
#[allow(clippy::arc_with_non_send_sync)]
let driver = Arc::new(Ndisapi::new("NDISRD")?);
println!("Driver version: {}", driver.get_version()?);
let adapters = driver.get_tcpip_bound_adapters_info()?;
for (i, adapter) in adapters.iter().enumerate() {
let name =
Ndisapi::get_friendly_adapter_name(adapter.get_name())
.unwrap_or_else(|_| adapter.get_name().to_owned());
println!("[{}] {name}", i + 1);
}
// With no argument, only list adapters; do not filter.
let Some(index) = index else {
println!("Choose an adapter: cargo run -- <number>");
return Ok(());
};
let selected = index
.checked_sub(1)
.and_then(|i| adapters.get(i))
.ok_or("Choose an adapter number from the list")?;
let handle = selected.get_handle();
if !driver.get_adapter_mode(handle)?.is_empty() {
return Err("Adapter is already being filtered".into());
}
// Dropping this wrapper resets mode and releases events.
let mut adapter = AsyncNdisapiAdapter::new(driver, handle)?;
let (tx, mut stop) = oneshot::channel();
std::thread::spawn(move || {
let mut line = String::new();
let result = io::stdin().read_line(&mut line).map(|_| ());
let _ = tx.send(result);
});
adapter.set_adapter_mode(
FilterFlags::MSTCP_FLAG_SENT_TUNNEL
| FilterFlags::MSTCP_FLAG_RECV_TUNNEL,
)?;
println!("Passing packets unchanged. Press Enter to stop.");
let mut packet = IntermediateBuffer::default();
let mut retrying_read = false;
loop {
tokio::select! {
// Check shutdown even when packets arrive continuously.
biased;
result = &mut stop => {
result??;
break;
}
result = adapter.read_packet(&mut packet) => {
// A notification may outlive a drained packet burst.
if let Err(error) = result {
if retrying_read {
return Err(error.into());
}
retrying_read = true;
continue;
}
retrying_read = false;
let outgoing = packet.get_device_flags().contains(
DirectionFlags::PACKET_FLAG_ON_SEND,
);
// Reinject before waiting for the next packet.
let direction = if outgoing {
adapter.send_packet_to_adapter(&mut packet)?;
"TCP/IP -> adapter"
} else {
adapter.send_packet_to_mstcp(&packet)?;
"adapter -> TCP/IP"
};
println!("{direction}: {} bytes", packet.get_length());
}
}
}
// Check normal shutdown; Drop also cleans up on errors.
adapter.set_adapter_mode(FilterFlags::default())?;
Ok(())
}
Run the Example
First list adapters:
cargo run
Then use the number of the adapter you want to inspect. For example, if it appears as [2]:
cargo run -- 2
Generate traffic on that adapter, for example by opening a web page. Output includes lines such as:
TCP/IP -> adapter: 74 bytes
adapter -> TCP/IP: 1514 bytes
Press Enter to stop, including when the adapter is idle. The example refuses an adapter that is already being filtered by another application.
How the Packet Loop Works
- Tunnel mode diverts the original frame. Every intercepted frame must be reinjected or intentionally dropped. Listen mode instead captures a copy while the original continues normally.
- Direction determines the destination. Outgoing frames go to
send_packet_to_adapter; incoming frames go tosend_packet_to_mstcp, which delivers them to the Windows TCP/IP stack. - Shutdown remains responsive. The shutdown channel is checked before the next read, and each successfully read frame is forwarded before the loop waits again.
- Notifications can be stale. After draining a burst, a notification may produce an empty read. The example retries once and exits on a second consecutive read error.
- Cleanup belongs to the adapter wrapper. Normal shutdown checks that filtering has been disabled. The wrapper also attempts to reset the mode and detach the packet event when dropped on an error.
This is a learning example that processes and prints one frame at a time. For throughput-sensitive applications, use batch operations and avoid per-packet console output. The official repository includes an asynchronous batch passthrough example.
Authoritative Resources
- crates.io: crates.io/crates/ndisapi
- API Reference: ndisapi 0.7.0
- GitHub Repository: github.com/wiresock/ndisapi-rs