Patchbay
Rust library for realistic network simulation via Linux namespaces
Install / Use
/learn @n0-computer/PatchbayREADME
patchbay
patchbay lets you build realistic network topologies out of Linux network
namespaces and run real code against them. You define routers, devices, NAT
policies, and link conditions through a Rust builder API, and the library
wires everything up with veth pairs, nftables rules, and tc qdisc
scheduling. Each node gets its own namespace with a private network stack,
so processes running inside see what they would see on a separate machine.
Everything runs unprivileged and cleans up when the Lab is dropped.
Quick example
See the simple.rs example for the runnable version.
// Enter a user namespace before any threads are spawned.
patchbay::init_userns().expect("failed to enter user namespace");
// Create a lab (async - sets up the root namespace and IX bridge).
let lab = Lab::new().await?;
// A public router: downstream devices get globally routable IPs.
let dc = lab
.add_router("dc")
.preset(RouterPreset::Public)
.build()
.await?;
// A home router: downstream devices get private IPs behind NAT.
let home = lab
.add_router("home")
.preset(RouterPreset::Home)
.build()
.await?;
// A device behind the home router, with a lossy WiFi link.
let dev = lab
.add_device("laptop")
.iface("eth0", home.id(), Some(LinkCondition::Wifi))
.build()
.await?;
// A server in the datacenter.
let server = lab
.add_device("server")
.iface("eth0", dc.id(), None)
.build()
.await?;
// Run an OS command inside a device's network namespace.
let mut child = dev.spawn_command({
let mut cmd = tokio::process::Command::new("ping");
cmd.args(["-c1", &server.ip().unwrap().to_string()]);
cmd
})?;
child.wait().await?;
// Spawn an async task on the device's per-namespace tokio runtime.
let client_task = dev.spawn(async move |_dev| {
let mut stream = tokio::net::TcpStream::connect(addr).await?;
println!("local addr: {}", stream.local_addr()?);
stream.write_all(b"hello server").await?;
anyhow::Ok(())
})?;
Requirements
-
Linux (bare-metal, VM, or CI container).
-
tcandnftin PATH (for link conditions and NAT rules). -
Unprivileged user namespaces enabled (default on most distros):
sysctl kernel.unprivileged_userns_clone # check sudo sysctl -w kernel.unprivileged_userns_clone=1 # enableOn Ubuntu 24.04+ with AppArmor:
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
No sudo is needed at runtime. The library bootstraps into an unprivileged
user namespace where it has full networking capabilities.
Architecture
Every node (router or device) gets its own network namespace. A lab-scoped root namespace hosts the IX bridge that interconnects all top-level routers. Veth pairs connect namespaces across the topology.
Each namespace has a lazy async worker (single-threaded tokio runtime) and a
lazy sync worker. device.spawn(...) runs async tasks on the namespace's
tokio runtime; device.run_sync(...) dispatches closures to the sync
worker. Callers never need to worry about setns; the workers handle
namespace entry.
Multi-region routing
Routers can be assigned to regions, and regions can be linked with simulated latency. When two routers live in different regions, traffic between them flows through per-region router namespaces with configurable impairment, giving you realistic cross-continent delays on top of the per-link conditions.
let eu = lab.add_region("eu").await?;
let us = lab.add_region("us").await?;
lab.link_regions(&eu, &us, RegionLink::good(80)).await?;
let dc_eu = lab.add_router("dc-eu").region(&eu).build().await?;
let dc_us = lab.add_router("dc-us").region(&us).build().await?;
// Traffic between dc-eu and dc-us now carries 80ms of added latency.
You can also tear down and restore region links at runtime with
lab.break_region_link() and lab.restore_region_link() for fault
injection scenarios.
Router presets
RouterPreset configures NAT, firewall, IP support, and address pool in
one call to match real-world deployment patterns:
let home = lab.add_router("home").preset(RouterPreset::Home).build().await?;
let dc = lab.add_router("dc").preset(RouterPreset::Public).build().await?;
let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?;
Available presets: Home, Public, PublicV4, IspCgnat, IspV6,
Corporate, Hotel, Cloud. Individual methods called after
preset() override preset values. See
docs/reference/ipv6.md for the full reference
table.
NAT
Routers support six IPv4 NAT presets (None, Home, Corporate,
CloudNat, FullCone, Cgnat) and four IPv6 modes (None, Nptv6,
Masquerade, Nat64), all configured via nftables rules. You can also
build custom NAT configs from mapping + filtering + timeout parameters.
NAT64 provides IPv4 access for IPv6-only devices via the well-known
prefix 64:ff9b::/96. A userspace SIIT translator on the router converts
between IPv6 and IPv4 headers; nftables masquerade handles port mapping.
Use RouterPreset::IspV6 or .nat_v6(NatV6Mode::Nat64) directly.
See docs/reference/ipv6.md for details.
IPv6 link-local and provisioning modes
Every IPv6-capable device/router interface exposes a link-local address through the handle snapshots:
Device::default_iface().and_then(|i| i.ll6())Device::interfaces().iter().filter_map(|i| i.ll6())router.iface("ix").or_else(|| router.iface("wan")).and_then(|i| i.ll6())router.interfaces().iter().filter_map(|i| i.ll6())
Patchbay also supports explicit IPv6 provisioning and DAD modes via LabOpts:
let lab = Lab::with_opts(
LabOpts::default()
.ipv6_provisioning_mode(Ipv6ProvisioningMode::Static)
.ipv6_dad_mode(Ipv6DadMode::Enabled),
).await?;
Ipv6ProvisioningMode::Static keeps route wiring deterministic.
Ipv6ProvisioningMode::RaDriven enables patchbay's RA/RS-driven path.
Ipv6DadMode::Disabled is the default for deterministic test setup.
In RaDriven mode, patchbay models Router Advertisement and Router
Solicitation behavior through structured events and route updates. It does
not emit raw ICMPv6 RA or RS packets on the virtual links.
For full scope and known gaps, see Book limitations and IPv6 reference.
Firewalls
Firewall presets control both inbound and outbound traffic:
BlockInbound (RFC 6092 CE router), Corporate (TCP 80,443 + UDP 53),
and CaptivePortal (block non-web UDP). All presets expand to a
FirewallConfig which can also be built from scratch via the builder API.
Link conditions
tc netem and tc tbf provide packet loss, latency, jitter, and rate
limiting. Apply presets (LinkCondition::Wifi, LinkCondition::Mobile4G)
or custom values at build time or dynamically.
Cleanup
Namespace file descriptors are held in-process. When the Lab is dropped,
workers are shut down and namespaces disappear automatically.
API overview
Building a topology
let lab = Lab::new().await?;
// Regions (optional)
let eu = lab.add_region("eu").await?;
let us = lab.add_region("us").await?;
lab.link_regions(&eu, &us, RegionLink::good(80)).await?;
// Routers
let dc = lab.add_router("dc")
.preset(RouterPreset::Public)
.region(&eu)
.build().await?;
let home = lab.add_router("home")
.preset(RouterPreset::Home)
.upstream(dc.id()) // chain behind dc
.nat_v6(NatV6Mode::Nptv6)
.build().await?;
// Devices
let dev = lab.add_device("phone")
.iface("wlan0", home.id(), Some(LinkCondition::Wifi))
.iface("eth0", dc.id(), None)
.default_via("wlan0")
.build().await?;
Running code in namespaces
// Async task on the device's tokio runtime
let jh = dev.spawn(async move |_dev| {
let stream = tokio::net::TcpStream::connect("203.0.113.10:80").await?;
Ok::<_, anyhow::Error>(())
})?;
// Blocking closure on the sync worker
let local_addr = dev.run_sync(|| {
let sock = std::net::UdpSocket::bind("0.0.0.0:0")?;
Ok(sock.local_addr()?)
})?;
// Spawn an OS command (sync, returns std::process::Child)
let child = dev.spawn_command({
let mut cmd = tokio::process::Command::new("curl");
cmd.arg("http://203.0.113.10");
cmd
})?;
// Spawn an OS command (sync, returns std::process::Child)
let child = dev.spawn_command_sync({
let mut cmd = std::process::Command::new("curl");
cmd.arg("http://203.0.113.10");
cmd
})?;
// Dedicated OS thread in the namespace
let handle = dev.spawn_thread(|| {
// long-running work
Ok(())
})?;
Dynamic operations
// Switch a device's uplink to a different router at runtime
dev.replug_iface("wlan0", other_router.id()).await?;
// Switch default route between interfaces
dev.set_default_route("eth0").await?;
// Link down / up
dev.link_down("wlan0").await?;
dev.link_up("wlan0").await?;
// Change link condition dynamically
dev.set_link_condition("wlan0", Some(LinkCondition::Manual(LinkLimits {
rate_kbit: 1000,
loss_pct: 5.0,
latency_ms: 100,
..Default::default()
}))).await?;
// Change NAT mode at runtime
router.set_nat_mode(Nat::Corporate).await?;
router.flush_nat_state().await?;
Handles
Device, Router, and Ix are lightweight, cloneable handles. All three
provide spawn, run_sync, spawn_thread, spawn_command,
spawn_command_sync, and spawn_reflector for running code in their
namespace. Handle methods return Result or Option when the underlying
node has been removed from the lab.
For IPv6 diagnostics, use per-interface snapshots instead of only ip6():
DeviceIface::ip6()for global/ULA address.DeviceIface::ll6()forfe80::/10link-local address.RouterIface::ip6()andRouterIface::ll6()for router-side interface state.
TOML configuration
You can a
