Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

patchbay builds realistic network topologies out of Linux network namespaces and lets you run real code against them. You describe routers, devices, NAT policies, firewalls, and link conditions through a Rust builder API. The library creates a namespace per node, wires them with veth pairs, installs nftables rules for NAT and firewalling, and applies tc netem shaping for loss, latency, jitter, and rate limits. Each device gets its own kernel network stack, so code running inside a namespace sees exactly what it would see on a separate machine. Everything runs unprivileged and cleans up when the Lab is dropped.

How this book is organized

The Guide section walks through patchbay’s concepts in the order you are likely to need them. It starts with the motivation behind the project and progresses through setting up a lab, building topologies, configuring NAT and firewalls, running code inside namespaces, and running labs in a QEMU VM on non-Linux hosts. Each chapter builds on the previous one and includes runnable examples.

The Reference section covers specialized topics in depth. It documents real-world IPv6 deployment patterns and how to simulate them, recipes for common network scenarios like WiFi handoff and VPN tunnels, the internals of NAT traversal and hole-punching as implemented in nftables, and the TOML simulation file format used by the patchbay runner.

A built-in devtools server (patchbay serve) provides an interactive web UI for inspecting lab runs: topology graphs, event timelines, per-namespace structured logs, and performance results. Set PATCHBAY_OUTDIR when running tests or simulations to capture output, then serve it in the browser.

Motivation and Scope

The problem

Networking code is notoriously hard to test. Unit tests can verify serialization and state machines, but they cannot tell you whether your connection logic survives a home NAT, whether your hole-punching strategy works through carrier-grade NAT, or whether your reconnect path handles a WiFi-to-cellular handoff without dropping state. Those questions require actual network stacks with actual packet processing, and the only way most teams answer them today is by deploying to staging and hoping for the best.

Tools like Docker Compose, Mininet, and custom iptables scripts can help, but each comes with trade-offs around privilege requirements, cleanup reliability, and how easily you can parameterize topologies from a test harness. patchbay was built to make this kind of testing ergonomic for Rust projects: no root, no cleanup, and a builder API that fits naturally into #[tokio::test] functions.

What patchbay does

patchbay builds realistic network topologies out of Linux network namespaces and lets you run real code against them. You describe routers, devices, NAT policies, firewalls, and link conditions through a Rust builder API. The library creates a namespace per node, wires them together with veth pairs, installs nftables rules for NAT and firewalling, and applies tc netem/tbf shaping for loss, latency, jitter, and rate limits. Each device gets its own kernel network stack, so code running inside a namespace sees exactly what it would see on a separate machine.

Everything runs unprivileged. The library enters an unprivileged user namespace at startup, so no root access is needed at any point. When the Lab value is dropped, all namespaces, interfaces, and rules disappear automatically.

Where it fits

patchbay is a testing and development tool, designed for three primary use cases:

Integration tests. Write #[tokio::test] functions that build a topology, run your networking code inside it, and assert on outcomes. Each test gets an isolated lab with its own address space, so tests can run in parallel without interfering with each other or with the host.

Performance and regression testing. Apply link conditions to simulate constrained networks (3G, satellite, lossy WiFi) and measure throughput, latency, or reconnection time under controlled impairment. Because tc netem operates at the kernel level, the shaping is realistic enough for comparative benchmarks, though absolute numbers will differ from hardware links due to scheduling overhead and the absence of real radio or cable physics.

Interactive experimentation. Build a topology in a binary or script, attach to device namespaces with shell commands, and observe how traffic flows. This is useful for understanding NAT behavior, debugging connectivity issues, or validating protocol assumptions before writing tests.

patchbay operates at the kernel namespace level with real TCP/IP stacks, not at the packet simulation level. This means the fidelity is high (you are testing against real Linux networking), but the scale is limited to what a single machine can support (typically dozens of namespaces, not thousands).

Scope of this book

The Guide section walks through patchbay’s concepts in order: setting up a lab, building topologies, configuring NAT and firewalls, running code inside namespaces, and running labs in a VM on non-Linux hosts. Each chapter builds on the previous one and includes runnable examples.

The Reference section covers specialized topics in depth: real-world IPv6 deployment patterns, network event simulation recipes, NAT traversal and hole-punching internals, and the TOML simulation file format.

Getting Started

This chapter walks through building your first patchbay lab: a home router with NAT, a datacenter router, and two devices that communicate across them. By the end you will have a working topology with a ping traversing a NAT and an async TCP exchange between two isolated network stacks.

System requirements

patchbay needs a Linux environment. A bare-metal machine, a VM, or a CI container all work. You need two userspace tools in your PATH:

  • tc from the iproute2 package, used for link condition shaping.
  • nft from the nftables package, used for NAT and firewall rules.

You also need unprivileged user namespaces, which are enabled by default on most distributions. You can verify this with:

sysctl kernel.unprivileged_userns_clone

If the value is 0, enable it with sudo sysctl -w kernel.unprivileged_userns_clone=1. On Ubuntu 24.04 and later, AppArmor restricts unprivileged user namespaces separately:

sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0

No root access is needed at runtime. patchbay enters an unprivileged user namespace at startup that grants it the capabilities needed to create network namespaces, veth pairs, and nftables rules.

Adding patchbay to your project

Add patchbay and its runtime dependencies to your Cargo.toml. You need tokio with at least the rt and macros features, since patchbay is async internally:

[dependencies]
patchbay = "0.1"
tokio = { version = "1", features = ["rt", "macros"] }
anyhow = "1"

Entering the user namespace

Before any threads are spawned, your program must call init_userns() to enter the unprivileged user namespace. This has to happen before tokio starts its thread pool, because unshare(2) only works in a single-threaded process. The standard pattern splits main into a sync entry point and an async body:

fn main() -> anyhow::Result<()> {
    patchbay::init_userns().expect("failed to enter user namespace");
    async_main()
}

#[tokio::main]
async fn async_main() -> anyhow::Result<()> {
    // All lab code goes here.
    Ok(())
}

If you skip this call, Lab::new() will fail because the process lacks the network namespace capabilities it needs.

In integration tests, you can avoid the main / async_main split by using a #[ctor] initializer that runs before any test thread is spawned:

#![allow(unused)]
fn main() {
#[cfg(test)]
#[ctor::ctor]
fn init() {
    patchbay::init_userns().expect("failed to enter user namespace");
}

#[tokio::test]
async fn my_test() -> anyhow::Result<()> {
    let lab = patchbay::Lab::new().await?;
    // ...
    Ok(())
}
}

The ctor crate runs the function at load time, before main or the test harness starts. This keeps your test functions clean and avoids repeating the namespace setup in every binary.

Creating a lab

A Lab is the top-level container for a topology. When you create one, it sets up a root network namespace with an internet exchange (IX) bridge. Every top-level router connects to this bridge, which provides the backbone for inter-router connectivity.

#![allow(unused)]
fn main() {
let lab = patchbay::Lab::new().await?;
}

Adding routers and devices

Routers connect to the IX bridge and provide network access to downstream devices. A router without any NAT configuration gives its devices public IP addresses, like a datacenter. Adding .nat(Nat::Home) places a NAT in front of the router’s downstream, assigning devices private addresses and masquerading their traffic, like a typical home WiFi router.

#![allow(unused)]
fn main() {
use patchbay::{Nat, LinkCondition};

// A datacenter router whose devices get public IPs.
let dc = lab.add_router("dc").build().await?;

// A home router whose devices sit behind NAT.
let home = lab.add_router("home").nat(Nat::Home).build().await?;
}

Devices attach to routers through named network interfaces. Each interface is a veth pair connecting the device’s namespace to the router’s namespace. You can optionally apply a link condition to the interface to simulate real-world impairment like packet loss, latency, and jitter.

#![allow(unused)]
fn main() {
// A server in the datacenter, with a clean link.
let server = lab
    .add_device("server")
    .iface("eth0", dc.id(), None)
    .build()
    .await?;

// A laptop behind the home router, over a lossy WiFi link.
let laptop = lab
    .add_device("laptop")
    .iface("eth0", home.id(), Some(LinkCondition::Wifi))
    .build()
    .await?;
}

At this point you have four network namespaces (IX root, dc router, home router with NAT, server, laptop) wired together with veth pairs. The laptop has a private IP behind the home router’s NAT, and the server has a public IP on the datacenter router’s subnet.

Running a ping across the NAT

Every device handle can spawn OS commands inside its network namespace. To verify connectivity, ping the server from the laptop:

#![allow(unused)]
fn main() {
let mut child = laptop.spawn_command_sync({
    let mut cmd = std::process::Command::new("ping");
    cmd.args(["-c1", &server.ip().unwrap().to_string()]);
    cmd
})?;

let status = tokio::task::spawn_blocking(move || child.wait()).await??;
assert!(status.success());
}

The ICMP echo request travels from the laptop’s namespace through the home router, where nftables masquerade translates the source address. The packet then crosses the IX bridge, enters the datacenter router’s namespace, and arrives at the server. The reply follows the reverse path. All of this happens in real kernel network stacks, fully isolated from your host.

Running async code in a namespace

For anything beyond shell commands, you will want to run async Rust code inside a namespace. The spawn method runs an async closure on the device’s single-threaded tokio runtime, giving you access to the full tokio networking stack (TCP, UDP, listeners, timeouts) within that namespace’s isolated network:

#![allow(unused)]
fn main() {
use std::net::SocketAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};

let addr = SocketAddr::from((server.ip().unwrap(), 8080));

// Start a TCP listener on the server.
let server_task = server.spawn(async move |_dev| {
    let listener = tokio::net::TcpListener::bind(addr).await?;
    let (mut stream, _peer) = listener.accept().await?;
    let mut buf = vec![0u8; 64];
    let n = stream.read(&mut buf).await?;
    assert_eq!(&buf[..n], b"hello");
    anyhow::Ok(())
})?;

// Connect from the laptop. Traffic is NATed through the home router.
let client_task = laptop.spawn(async move |_dev| {
    let mut stream = tokio::net::TcpStream::connect(addr).await?;
    stream.write_all(b"hello").await?;
    anyhow::Ok(())
})?;

client_task.await??;
server_task.await??;
}

Both tasks run in separate network namespaces with completely isolated stacks. The tokio primitives behave exactly as they would in a normal application, but all traffic flows through the simulated topology. The Running Code in Namespaces chapter covers all execution methods in detail.

Cleanup

When the Lab goes out of scope, it shuts down all namespace workers and closes the namespace file descriptors. The kernel automatically removes veth pairs, routes, and nftables rules when the last reference to a namespace disappears. No cleanup code is needed and no leftover state pollutes the host.

Viewing results in the browser

patchbay can write structured output to disk — topology events, per-namespace tracing logs, and extracted custom events — and serve them in an interactive web UI. Set the PATCHBAY_OUTDIR environment variable to enable this:

PATCHBAY_OUTDIR=/tmp/pb cargo test my_test

Each Lab creates a timestamped subdirectory under the outdir. You can optionally label it for easier identification:

#![allow(unused)]
fn main() {
let lab = Lab::with_opts(LabOpts::default().label("my-test")).await?;
}

After the test completes, serve the output directory:

patchbay serve /tmp/pb --open

This opens the devtools UI in your browser with tabs for topology, events, logs, timeline, and performance results. Multiple runs accumulate in the same outdir and appear in the run selector dropdown.

You can also emit custom events to the timeline using the _events:: tracing target convention:

#![allow(unused)]
fn main() {
tracing::info!(target: "myapp::_events::PeerConnected", addr = %peer_addr);
}

The per-namespace tracing subscriber extracts these into .events.jsonl files, which the timeline tab renders automatically.

What comes next

The following chapters cover patchbay’s features in more depth:

  • Building Topologies explains router chains, multi-homed devices, regions with inter-region latency, link condition presets, and router presets.
  • NAT and Firewalls covers all NAT modes (including NAT64 for IPv6-only networks), firewall presets, custom configurations, and runtime changes.
  • Running Code in Namespaces describes the execution model, all the ways to run code inside a namespace, and dynamic topology operations like interface replug and link control.

Building Topologies

A patchbay topology is built from three kinds of objects: routers that provide network connectivity, devices that run your code, and regions that introduce latency between groups of routers. This chapter explains how to compose them into realistic network layouts.

Routers

Every router connects to the lab’s internet exchange (IX) bridge and receives a public IP address on that link. Downstream devices connect to the router through veth pairs and receive addresses from the router’s address pool.

#![allow(unused)]
fn main() {
let dc = lab.add_router("dc").build().await?;
}

A router with no additional configuration acts like a datacenter switch: devices behind it get public IPs, there is no NAT, and there is no firewall. To model different real-world environments, you configure NAT, firewalls, IP support, and address pools on the router builder. The NAT and Firewalls chapter covers those options in detail.

Chaining routers

Routers can be chained behind other routers using the .upstream() method. Instead of connecting directly to the IX, the downstream router receives its address from the parent router’s pool. This is how you build multi-layer topologies like ISP + home or corporate gateway + branch office:

#![allow(unused)]
fn main() {
let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?;
let home = lab
    .add_router("home")
    .upstream(isp.id())
    .nat(Nat::Home)
    .build()
    .await?;
}

In this example, the home router sits behind the ISP. Devices behind home are double-NATed: their traffic passes through home NAT first, then through carrier-grade NAT at the ISP. This is a common topology for testing P2P connectivity where both peers sit behind multiple layers of NAT.

Router presets

For common deployment patterns, RouterPreset configures NAT, firewall, IP support, and address pool in a single call. This avoids repeating the same combinations across tests:

#![allow(unused)]
fn main() {
use patchbay::RouterPreset;

let home = lab.add_router("home").preset(RouterPreset::Home).build().await?;
let dc   = lab.add_router("dc").preset(RouterPreset::Datacenter).build().await?;
let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?;
}

The following table lists all available presets. Each row shows the NAT mode, firewall policy, IP address family, and downstream address pool that the preset configures:

PresetNATFirewallIP supportPool
HomeHomeBlockInboundDualStackPrivate
DatacenterNoneNoneDualStackPublic
IspV4CgnatNoneV4OnlyCgnatShared
MobileHomeBlockInboundDualStackPrivate
MobileV6None (v4) / Nat64 (v6)BlockInboundV6OnlyPublic
CorporateCorporateCorporateDualStackPrivate
HotelHomeCaptivePortalDualStackPrivate
CloudNoneNoneDualStackPublic

Methods called after .preset() override the preset’s defaults, so you can use a preset as a starting point and customize individual settings. For example, RouterPreset::Home with .nat(Nat::FullCone) gives you a home-style topology with fullcone NAT instead of the default endpoint-dependent filtering.

Address families

By default, routers run dual-stack (both IPv4 and IPv6). You can restrict a router to a single address family with .ip_support():

#![allow(unused)]
fn main() {
use patchbay::IpSupport;

let v6_only = lab.add_router("carrier")
    .ip_support(IpSupport::V6Only)
    .build().await?;
}

The three options are V4Only, V6Only, and DualStack. Devices behind a V6Only router will only receive IPv6 addresses. If the router also has NAT64 enabled, those devices can still reach IPv4 destinations through the NAT64 prefix; see the NAT and Firewalls chapter for details.

Devices

Devices are the endpoints where your code runs. Each device gets its own network namespace with one or more interfaces, each connected to a router. IP addresses are assigned automatically from the router’s pool.

#![allow(unused)]
fn main() {
let server = lab
    .add_device("server")
    .iface("eth0", dc.id(), None)
    .build()
    .await?;
}

You can read a device’s assigned addresses through the handle:

#![allow(unused)]
fn main() {
let v4: Option<Ipv4Addr> = server.ip();
let v6: Option<Ipv6Addr> = server.ip6();
}

Multi-homed devices

A device can have multiple interfaces, each connected to a different router. This models machines with both WiFi and Ethernet, phones with WiFi and cellular, or VPN scenarios where a tunnel interface coexists with the physical link:

#![allow(unused)]
fn main() {
let phone = lab
    .add_device("phone")
    .iface("wlan0", home.id(), Some(LinkCondition::Wifi))
    .iface("cell0", carrier.id(), Some(LinkCondition::Mobile4G))
    .default_via("wlan0")
    .build()
    .await?;
}

The .default_via("wlan0") call sets which interface carries the default route. At runtime, you can switch the default route to a different interface to simulate a handoff:

#![allow(unused)]
fn main() {
phone.set_default_route("cell0").await?;
}

Link conditions simulate real-world network impairment. Under the hood, patchbay uses tc netem for loss, latency, and jitter, and tc tbf for rate limiting. You can apply conditions at build time through interface presets, through custom parameters, or dynamically at runtime.

Presets

The built-in presets model common access technologies:

PresetLossLatencyJitterRate
Wifi2%5 ms1 ms54 Mbit/s
Mobile4G1%30 ms10 ms50 Mbit/s
Mobile3G3%100 ms30 ms2 Mbit/s
Satellite0.5%600 ms50 ms10 Mbit/s

Apply a preset when building the interface:

#![allow(unused)]
fn main() {
let dev = lab.add_device("laptop")
    .iface("eth0", home.id(), Some(LinkCondition::Wifi))
    .build().await?;
}

Custom parameters

When the presets do not match your scenario, build a LinkLimits struct directly:

#![allow(unused)]
fn main() {
use patchbay::{LinkCondition, LinkLimits};

let degraded = LinkCondition::Manual(LinkLimits {
    rate_kbit: 1000,    // 1 Mbit/s
    loss_pct: 10.0,     // 10% packet loss
    latency_ms: 50,     // 50 ms one-way delay
    jitter_ms: 20,      // 20 ms jitter
    ..Default::default()
});

let dev = lab.add_device("laptop")
    .iface("eth0", home.id(), Some(degraded))
    .build().await?;
}

Runtime changes

You can change or remove link conditions at any point after the topology is built. This is useful for simulating network degradation during a test, for example switching from WiFi to a congested 3G link and verifying that your application adapts:

#![allow(unused)]
fn main() {
dev.set_link_condition("eth0", Some(LinkCondition::Mobile3G)).await?;

// Later, restore a clean link.
dev.set_link_condition("eth0", None).await?;
}

Regions

Regions model geographic distance between groups of routers. When you assign routers to different regions and link those regions, traffic between them passes through per-region router namespaces that apply configurable latency via tc netem. This gives you realistic cross-continent delays on top of any per-link conditions.

#![allow(unused)]
fn main() {
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?;
}

In this topology, traffic between dc_eu and dc_us carries 80 ms of added round-trip latency. Routers within the same region communicate without the region penalty.

You can break and restore region links at runtime to simulate network partitions. This is valuable for testing how your application handles split-brain scenarios, failover logic, and reconnection:

#![allow(unused)]
fn main() {
lab.break_region_link(&eu, &us).await?;
// All traffic between EU and US routers is now blackholed.

// ... run your partition test ...

lab.restore_region_link(&eu, &us).await?;
// Connectivity is restored.
}

The break is immediate: packets in flight are dropped, and no new packets can cross the link until it is restored.

NAT and Firewalls

patchbay implements NAT and firewalls using nftables rules injected into router namespaces. Because these are real kernel-level packet processing rules, they behave identically to their counterparts on physical hardware. This chapter covers all available NAT modes, firewall presets, custom configurations, and runtime mutation.

IPv4 NAT

NAT controls how a router translates addresses for traffic flowing between its downstream (private) and upstream (public) interfaces. You configure it on the router builder with .nat():

#![allow(unused)]
fn main() {
use patchbay::Nat;

let home = lab.add_router("home").nat(Nat::Home).build().await?;
}

Each NAT preset models a real-world device class by combining two independent axes from RFC 4787: mapping (how external ports are assigned) and filtering (which inbound packets are forwarded to a mapped port).

ModeMappingFilteringReal-world model
Nonen/an/aDatacenter, public IPs
HomeEndpoint-independentEndpoint-dependentHome WiFi router
CorporateEndpoint-independentEndpoint-dependentEnterprise gateway
FullConeEndpoint-independentEndpoint-independentGaming router, fullcone VPN
CloudNatEndpoint-dependentEndpoint-dependentAWS/GCP cloud NAT
CgnatEndpoint-dependentEndpoint-dependentCarrier-grade NAT at the ISP

Endpoint-independent mapping means the router reuses the same external port for all destinations. This is what makes UDP hole-punching possible: a peer can learn the mapped address via STUN and share it with another peer, and the mapping holds regardless of who sends to it. Endpoint-dependent mapping assigns a different external port per destination, which defeats naive hole-punching.

Filtering is the inbound side. Endpoint-independent filtering (fullcone) forwards packets from any external host to a mapped port. Endpoint-dependent filtering only forwards replies from hosts the internal client has already contacted. For a deep dive into how these modes are implemented in nftables and how hole-punching works across them, see the NAT Hole-Punching reference.

Custom NAT configurations

When the presets do not match your scenario, you can build a NatConfig directly and choose the mapping, filtering, and timeout behavior independently:

#![allow(unused)]
fn main() {
use patchbay::nat::{NatConfig, NatMapping, NatFiltering};

let custom = Nat::Custom(NatConfig {
    mapping: NatMapping::EndpointIndependent,
    filtering: NatFiltering::EndpointIndependent,
    ..Default::default()
});

let router = lab.add_router("custom").nat(custom).build().await?;
}

Changing NAT at runtime

You can switch a router’s NAT mode after the topology is built. This is useful for testing how your application reacts when the NAT environment changes mid-session, for example simulating a network migration. Call flush_nat_state() afterward to clear stale conntrack entries so that new connections use the updated rules:

#![allow(unused)]
fn main() {
router.set_nat_mode(Nat::Corporate).await?;
router.flush_nat_state().await?;
}

IPv6 NAT

IPv6 NAT is configured separately from IPv4 using .nat_v6(). In most real-world deployments, IPv6 does not use NAT at all: devices receive globally routable addresses and a stateful firewall handles inbound filtering. patchbay defaults to this behavior. For the scenarios where IPv6 NAT does exist in practice, four modes are available:

#![allow(unused)]
fn main() {
use patchbay::NatV6Mode;

let router = lab.add_router("r")
    .ip_support(IpSupport::DualStack)
    .nat_v6(NatV6Mode::Nptv6)
    .build().await?;
}
ModeDescription
NoneNo IPv6 NAT. Devices get globally routable addresses. This is the default and the most common real-world configuration.
Nat64Stateless IP/ICMP Translation (RFC 6145). Allows IPv6-only devices to reach IPv4 hosts through the well-known prefix 64:ff9b::/96. The most important v6 NAT mode in practice; used by major mobile carriers.
Nptv6Network Prefix Translation (RFC 6296). Performs stateless 1:1 prefix mapping at the border, preserving end-to-end connectivity while hiding internal prefixes.
MasqueradeIPv6 masquerade, analogous to IPv4 NAPT. Rare in production but useful for testing applications that must handle v6 address rewriting.

NAT64

NAT64 is the mechanism that lets IPv6-only mobile networks (T-Mobile US, Jio, NTT Docomo) provide IPv4 connectivity. The router runs a userspace SIIT translator that rewrites packet headers between IPv6 and IPv4. When an IPv6-only device sends a packet to an address in the 64:ff9b::/96 prefix, the translator extracts the embedded IPv4 address, rewrites the headers, and forwards the packet as IPv4. Return traffic is translated back to IPv6.

You can configure NAT64 explicitly or use the MobileV6 preset, which sets up a V6Only router with NAT64 and an inbound firewall, matching the configuration of a typical mobile carrier gateway:

#![allow(unused)]
fn main() {
use patchbay::{IpSupport, NatV6Mode, Nat, RouterPreset};

// Explicit configuration:
let carrier = lab
    .add_router("carrier")
    .ip_support(IpSupport::DualStack)
    .nat(Nat::Home)
    .nat_v6(NatV6Mode::Nat64)
    .build()
    .await?;

// Or equivalently, using the preset:
let carrier = lab
    .add_router("carrier")
    .preset(RouterPreset::MobileV6)
    .build()
    .await?;
}

To reach an IPv4 server from an IPv6-only device, embed the server’s IPv4 address in the NAT64 prefix using the embed_v4_in_nat64 helper:

#![allow(unused)]
fn main() {
use patchbay::nat64::embed_v4_in_nat64;

let server_v4: Ipv4Addr = dc.uplink_ip().unwrap();
let nat64_addr = embed_v4_in_nat64(server_v4);
// nat64_addr is 64:ff9b::<v4 octets>, e.g. 64:ff9b::cb00:710a

let target = SocketAddr::new(IpAddr::V6(nat64_addr), 8080);
// Connecting to this address goes through the NAT64 translator.
}

The IPv6 Deployments reference covers how real carriers deploy NAT64 and how to simulate each scenario in patchbay.

Firewalls

Firewall presets control which traffic a router allows in each direction. They are independent of NAT: a router can have a firewall without NAT (common for datacenter servers behind a stateful firewall), NAT without a firewall, or both.

#![allow(unused)]
fn main() {
use patchbay::Firewall;

let corp = lab.add_router("corp")
    .firewall(Firewall::Corporate)
    .build().await?;
}

The following presets are available:

PresetInbound policyOutbound policy
NoneAll traffic allowedAll traffic allowed
BlockInboundBlock unsolicited connections (RFC 6092 CE router behavior)All traffic allowed
CorporateBlock unsolicited connectionsAllow only TCP 80, 443 and UDP 53
CaptivePortalBlock unsolicited connectionsAllow only TCP 80, 443 and UDP 53; block all other UDP

The Corporate and CaptivePortal presets are particularly useful for testing P2P applications: corporate firewalls block STUN and direct UDP, forcing applications to fall back to TURN relaying over TLS on port 443. Captive portal firewalls additionally kill QUIC by blocking all non-DNS UDP.

Custom firewall rules

When the presets do not match your test scenario, build a FirewallConfig directly:

#![allow(unused)]
fn main() {
use patchbay::firewall::FirewallConfig;

let config = FirewallConfig::builder()
    .block_inbound(true)
    .allow_tcp_ports(&[80, 443, 8080])
    .allow_udp_ports(&[53, 443])
    .build();

let router = lab.add_router("strict")
    .firewall(Firewall::Custom(config))
    .build().await?;
}

Composing NAT and firewalls

NAT and firewalls are orthogonal. A router can have any combination of the two, and they operate at different points in the nftables pipeline. Some typical compositions:

#![allow(unused)]
fn main() {
// Home router: NAT + inbound firewall. The most common residential setup.
let home = lab.add_router("home")
    .nat(Nat::Home)
    .firewall(Firewall::BlockInbound)
    .build().await?;

// Datacenter with strict outbound rules but no NAT.
let dc = lab.add_router("dc")
    .firewall(Firewall::Corporate)
    .build().await?;

// Double NAT: ISP carrier-grade NAT in front of a home router.
let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?;
let home = lab.add_router("home")
    .upstream(isp.id())
    .nat(Nat::Home)
    .build().await?;
}

Router presets set both NAT and firewall to sensible defaults for each deployment pattern. Calling individual methods after .preset() overrides the preset’s defaults, so you can start from a known configuration and adjust only what your test needs.

Running Code in Namespaces

Every node in a patchbay topology, whether it is a device, a router, or the IX itself, has its own Linux network namespace. Each namespace comes with two workers: an async worker backed by a single-threaded tokio runtime, and a sync worker backed by a dedicated OS thread. You never interact with setns directly; the workers enter the correct namespace before executing your code.

This chapter describes all the execution methods available on node handles, when to use each one, and how to modify the topology at runtime.

Async tasks

The spawn method is the primary way to run networking code inside a namespace. It takes an async closure, dispatches it to the namespace’s tokio runtime, and returns a join handle that resolves when the task completes:

#![allow(unused)]
fn main() {
let handle = dev.spawn(async move |_dev| {
    let stream = tokio::net::TcpStream::connect("203.0.113.10:80").await?;
    let mut buf = vec![0u8; 1024];
    let n = stream.read(&mut buf).await?;
    anyhow::Ok(n)
})?;

let bytes_read = handle.await??;
}

The closure receives a clone of the device handle, which you can use to query addresses or spawn further tasks. All tokio networking primitives work inside spawn: TcpStream, TcpListener, UdpSocket, timeouts, intervals, and anything built on top of them. Because the runtime is single-threaded and pinned to the namespace, all socket operations happen against the namespace’s isolated network stack.

You should use spawn for any work that involves network I/O. The alternative, blocking I/O in a sync context, will stall the worker thread and can cause kernel-level timeouts for TCP (SYN retransmit takes roughly 127 seconds to exhaust). Always prefer async networking via spawn.

Sync closures

The run_sync method dispatches a closure to the namespace’s sync worker thread and blocks until it returns. It is intended for quick, non-I/O operations: reading a sysctl value, creating a socket to inspect its local address, or spawning an OS process.

#![allow(unused)]
fn main() {
let local_addr = dev.run_sync(|| {
    let sock = std::net::UdpSocket::bind("0.0.0.0:0")?;
    Ok(sock.local_addr()?)
})?;
}

Because run_sync blocks both the calling thread and the sync worker, avoid doing anything slow inside it. TCP connects, HTTP requests, and other blocking network I/O belong in spawn, not in run_sync.

OS commands

spawn_command runs an OS process inside the namespace and registers the child with the namespace’s tokio reactor, so .wait() and .wait_with_output() work as non-blocking futures. It takes a tokio::process::Command and returns a tokio::process::Child:

#![allow(unused)]
fn main() {
let mut child = dev.spawn_command({
    let mut cmd = tokio::process::Command::new("curl");
    cmd.arg("http://203.0.113.10");
    cmd
})?;

let status = child.wait().await?;
assert!(status.success());
}

When you need a synchronous std::process::Child instead (for example to pass to spawn_blocking or manage outside of an async context), use spawn_command_sync:

#![allow(unused)]
fn main() {
let mut child = dev.spawn_command_sync({
    let mut cmd = std::process::Command::new("curl");
    cmd.arg("http://203.0.113.10");
    cmd
})?;

let output = tokio::task::spawn_blocking(move || {
    child.wait_with_output()
}).await??;
assert!(output.status.success());
}

Dedicated threads

When you have long-running blocking work that would starve the sync worker, spawn_thread creates a dedicated OS thread inside the namespace. Unlike run_sync, this thread does not compete with other sync operations on the same namespace:

#![allow(unused)]
fn main() {
let handle = dev.spawn_thread(|| {
    // This thread runs in the device's namespace.
    // It can do blocking work for an extended period
    // without affecting run_sync calls.
    Ok(())
})?;
}

UDP reflectors

spawn_reflector starts a UDP echo server in the namespace. It is a convenience method for connectivity tests: send a datagram to the reflector and measure the round-trip time to verify that the path works.

#![allow(unused)]
fn main() {
let bind_addr = SocketAddr::new(IpAddr::V4(server_ip), 9000);
server.spawn_reflector(bind_addr)?;
}

The reflector runs on the namespace’s async worker and echoes every received datagram back to its sender.

Dynamic topology operations

A patchbay topology is not static. After building the initial layout, you can modify interfaces, routes, link conditions, and NAT configuration at runtime. These operations are useful for simulating network events during a test: a WiFi handoff, a link failure, or a NAT policy change.

Replugging interfaces

Move a device’s interface from one router to another. The interface receives a new IP address from the new router’s pool, and routes are updated automatically:

#![allow(unused)]
fn main() {
dev.replug_iface("wlan0", other_router.id()).await?;
}

This models scenarios like roaming between WiFi access points or switching between ISPs.

Switching the default route

For multi-homed devices, change which interface carries the default route. This simulates a WiFi-to-cellular handoff or a VPN tunnel activation:

#![allow(unused)]
fn main() {
dev.set_default_route("cell0").await?;
}

Bringing interfaces down and up

Simulate link failures by administratively disabling an interface. While the interface is down, packets sent to or from it are dropped:

#![allow(unused)]
fn main() {
dev.link_down("wlan0").await?;
// All traffic over wlan0 is now dropped.

dev.link_up("wlan0").await?;
// The interface is back and traffic flows again.
}

Modify link impairment on the fly to simulate degrading or improving network quality:

#![allow(unused)]
fn main() {
use patchbay::{LinkCondition, LinkLimits};

// Switch to a 3G-like link.
dev.set_link_condition("wlan0", Some(LinkCondition::Mobile3G)).await?;

// Apply custom impairment.
dev.set_link_condition("wlan0", Some(LinkCondition::Manual(LinkLimits {
    rate_kbit: 500,
    loss_pct: 15.0,
    latency_ms: 200,
    ..Default::default()
}))).await?;

// Remove all impairment and return to a clean link.
dev.set_link_condition("wlan0", None).await?;
}

Changing NAT at runtime

Switch a router’s NAT mode and flush stale connection tracking state. This is covered in more detail in the NAT and Firewalls chapter:

#![allow(unused)]
fn main() {
router.set_nat_mode(Nat::Corporate).await?;
router.flush_nat_state().await?;
}

Handles

Device, Router, and Ix are lightweight, cloneable handles. All three types support the same set of execution methods described above: spawn, run_sync, spawn_thread, spawn_command, spawn_command_sync, and spawn_reflector. Cloning a handle is cheap; it does not duplicate the underlying namespace or its workers.

Handle methods return Result or Option when the underlying node has been removed from the lab. If you hold a handle to a device that no longer exists, calls will return an error rather than panicking.

Cleanup

When the Lab is dropped, it shuts down all async and sync workers, then closes the namespace file descriptors. The kernel removes veth pairs, routes, and nftables rules when the last reference to a namespace disappears. No explicit cleanup is needed, and no state leaks onto the host between test runs.

Running in a VM

patchbay requires Linux network namespaces, which means it cannot run natively on macOS or Windows. The patchbay-vm crate solves this by wrapping your simulations and tests in a QEMU Linux VM, giving you the same experience on any development machine.

Installing patchbay-vm

cargo install --git https://github.com/n0-computer/patchbay patchbay-vm

Running simulations

The run command boots a VM (or reuses a running one), stages the simulation files and binaries, and executes them inside the guest:

patchbay-vm run ./sims/iperf-baseline.toml

Results and logs are written to the work directory (.patchbay-work/ by default). You can pass multiple simulation files, and they run sequentially in the same VM.

Controlling the patchbay version

By default, patchbay-vm downloads the latest release of the patchbay runner binary. You can pin a version, build from a Git ref, or point to a local binary:

patchbay-vm run sim.toml --patchbay-version v0.10.0
patchbay-vm run sim.toml --patchbay-version git:main
patchbay-vm run sim.toml --patchbay-version path:/usr/local/bin/patchbay

Binary overrides

If your simulation references custom binaries (test servers, protocol implementations), you can stage them into the VM:

patchbay-vm run sim.toml --binary myserver:path:./target/release/myserver

The binary is copied into the guest’s work directory and made available at the path the simulation expects.

Running tests

The test command cross-compiles your Rust tests for musl, stages the test binaries in the VM, and runs them:

patchbay-vm test
patchbay-vm test --package patchbay
patchbay-vm test -- --test-threads=4

This is the recommended way to run patchbay integration tests on macOS. The VM has all required tools pre-installed (nftables, iproute2, iperf3) and unprivileged user namespaces enabled.

VM lifecycle

The VM boots on first use and stays running between commands. Subsequent run or test calls reuse the existing VM, which avoids the 30-60 second boot time on repeated invocations.

patchbay-vm up        # Boot the VM (or verify it is running)
patchbay-vm status    # Show VM state, SSH port, mount paths
patchbay-vm down      # Shut down the VM
patchbay-vm cleanup   # Remove stale sockets and PID files

You can also SSH into the guest directly for debugging:

patchbay-vm ssh -- ip netns list
patchbay-vm ssh -- nft list ruleset

How it works

patchbay-vm downloads a Debian cloud image (cached in ~/.local/share/patchbay/qemu-images/), creates a COW disk backed by it, and boots QEMU with cloud-init for initial provisioning. The guest gets SSH access via a host-forwarded port (default 2222) and three shared mount points:

Guest pathHost pathAccessPurpose
/appWorkspace rootRead-onlySource code and simulation files
/targetCargo target dirRead-onlyBuild artifacts
/workWork directoryRead-writeSimulation output and logs

File sharing uses virtiofs when available (faster, requires virtiofsd on the host) and falls back to 9p. Hardware acceleration is auto-detected: KVM on Linux, HVF on macOS, TCG emulation as a last resort.

Configuration

All settings have sensible defaults. Override them through environment variables when needed:

VariableDefaultDescription
QEMU_VM_MEM_MB4096Guest RAM in megabytes
QEMU_VM_CPUS4Guest CPU count
QEMU_VM_SSH_PORT2222Host port forwarded to guest SSH
QEMU_VM_NAMEpatchbay-vmVM instance name
QEMU_VM_DISK_GB40Disk size in gigabytes

VM state lives in .qemu-vm/<name>/ in your project directory. The disk image uses COW backing, so it only consumes space for blocks that differ from the base image.

Real-World IPv6 Deployments

How IPv6 works in practice and how to simulate each scenario in patchbay.


How ISPs Actually Deploy IPv6

Residential (FTTH, Cable, DSL)

ISPs assign a globally routable prefix (typically /56 or /60) via DHCPv6-PD (Prefix Delegation). The CE (Customer Edge) router carves /64s from this prefix for each LAN segment. Devices get public GUA addresses — no NAT involved. The security boundary is a stateful firewall on the CE router that blocks unsolicited inbound connections (RFC 6092).

IPv4 access is provided in parallel via dual-stack (separate IPv4 address with NAT44) or via DS-Lite / MAP-E / MAP-T (IPv4-in-IPv6 tunneling to the ISP’s AFTR).

Key properties:

  • Devices have globally routable IPv6 addresses
  • No IPv6 NAT — the prefix is public
  • Stateful firewall blocks inbound, allows outbound + established
  • SLAAC for address assignment (not DHCPv6 address assignment)
  • Privacy extensions (RFC 4941) rotate source addresses

Carriers: Deutsche Telekom, Comcast, AT&T, Orange, BT, NTT.

Mobile (4G/5G)

Each device typically gets a single /64 via RA (Router Advertisement). The device is the only host on its /64. There is no home router — the carrier’s gateway acts as the first hop.

For IPv4 access, carriers use either:

  • 464XLAT (RFC 6877): CLAT on device + NAT64 on carrier gateway
  • NAT64 + DNS64: carrier synthesizes AAAA records from A records

Some carriers (T-Mobile US, Jio) are IPv6-only with NAT64. Others (Verizon, NTT Docomo) do dual-stack.

Key properties:

  • One /64 per device (not shared)
  • NAT64/DNS64 for IPv4 access (no real IPv4 address)
  • No firewall — carrier relies on per-device /64 isolation
  • 3GPP CGNAT for remaining IPv4 users

Enterprise / Corporate

Enterprises typically run dual-stack internally with PA (Provider Aggregatable) or PI (Provider Independent) space. Strict firewalls allow only TCP 80/443 and UDP 53 outbound. All other ports are blocked — STUN/TURN on non-standard ports fails, must use TURN-over-TLS on 443.

Some enterprises use ULA (fd00::/8) internally with NAT66 at the border, though this is discouraged by RFC 4864 and IETF best practices.

Hotel / Airport / Guest WiFi

After captive portal authentication, these networks typically allow:

  • TCP 80, 443 (HTTP/HTTPS)
  • TCP/UDP 53 (DNS)
  • All other UDP blocked (kills QUIC, STUN, direct P2P)
  • TCP to other ports sometimes allowed (unlike corporate)

Many guest networks are still IPv4-only. Those with IPv6 usually provide GUA addresses with a restrictive firewall.


ULA + NAT66: Mostly a Myth

RFC 4193 ULA (fd00::/8) was designed for stable internal addressing, not as an IPv6 equivalent of RFC 1918. In practice:

  • No major ISP deploys NAT66 — it defeats the end-to-end principle
  • Android does not support NAT66 (no DHCPv6 client, only SLAAC)
  • ULA is used alongside GUA for stable internal addressing, never alone
  • RFC 6296 NPTv6 (prefix translation) exists but is niche — mostly for multihoming, not general NAT

If you need to simulate “NATted IPv6”, use NPTv6 (NatV6Mode::Nptv6) which does stateless 1:1 prefix translation. But understand this is rare in the real world.


Simulating Real-World Scenarios in Patchbay

Using Router Presets

[RouterPreset] configures NAT, firewall, IP support, and address pool in one call. Individual methods override preset values when called after preset().

#![allow(unused)]
fn main() {
// One-liner for each common case:
let home = lab.add_router("home").preset(RouterPreset::Home).build().await?;
let dc   = lab.add_router("dc").preset(RouterPreset::Datacenter).build().await?;
let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?;

// Override one knob:
let home = lab.add_router("home")
    .preset(RouterPreset::Home)
    .nat(Nat::FullCone)   // swap NAT type, keep everything else
    .build().await?;
}
PresetNATNAT v6FirewallIPPool
HomeHome (EIM+APDF)NoneBlockInboundDualStackPrivate
DatacenterNoneNoneNoneDualStackPublic
IspV4NoneNoneNoneV4OnlyPublic
MobileCgnatNoneBlockInboundDualStackPublic
MobileV6NoneNat64BlockInboundV6OnlyPublic
CorporateCorporate (sym)NoneCorporateDualStackPublic
HotelCorporate (sym)NoneCaptivePortalV4OnlyPrivate
CloudCloudNatNoneNoneDualStackPublic

Scenario 1: Residential Dual-Stack (Most Common)

A home router with NATted IPv4 and public IPv6. The CE router firewall blocks unsolicited inbound on both families.

#![allow(unused)]
fn main() {
let home = lab.add_router("home").preset(RouterPreset::Home).build().await?;
let laptop = lab.add_device("laptop").uplink(home.id()).build().await?;
// laptop.ip()  → 10.0.x.x (private IPv4, NATted)
// laptop.ip6() → fd10:0:x::2 (ULA v6, firewalled)
}

Scenario 2: IPv6-Only Mobile with NAT64

A carrier network where devices only have IPv6. IPv4 destinations are reached via NAT64 — a userspace SIIT translator on the router converts between IPv6 and IPv4 headers using the well-known prefix 64:ff9b::/96.

#![allow(unused)]
fn main() {
let carrier = lab.add_router("carrier")
    .preset(RouterPreset::MobileV6)
    .build().await?;
let phone = lab.add_device("phone").uplink(carrier.id()).build().await?;
// phone.ip6() → 2001:db8:1:x::2 (public GUA)
// phone.ip()  → None (no IPv4 on the device)

// Reach an IPv4 server via NAT64:
use patchbay::nat64::embed_v4_in_nat64;
let nat64_addr = embed_v4_in_nat64(server_v4_ip);
// Connect to [64:ff9b::<server_v4>]:port — translated to IPv4 by the router
}

The MobileV6 preset configures: IpSupport::V6Only + NatV6Mode::Nat64 + Firewall::BlockInbound + public GUA pool. You can also configure NAT64 manually on any router:

#![allow(unused)]
fn main() {
let carrier = lab.add_router("carrier")
    .ip_support(IpSupport::DualStack)  // or V6Only
    .nat_v6(NatV6Mode::Nat64)
    .build().await?;
}

Scenario 3: Corporate Firewall (Restrictive)

Enterprise network that blocks everything except web traffic. STUN/ICE fails — P2P apps must fall back to TURN-over-TLS on port 443.

#![allow(unused)]
fn main() {
let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?;
let workstation = lab.add_device("ws").uplink(corp.id()).build().await?;
}

Scenario 4: Hotel / Captive Portal

Guest WiFi that allows web traffic but blocks most UDP.

#![allow(unused)]
fn main() {
let hotel = lab.add_router("hotel").preset(RouterPreset::Hotel).build().await?;
let guest = lab.add_device("guest").uplink(hotel.id()).build().await?;
}

Scenario 5: Mobile Carrier (CGNAT + Dual-Stack)

Multiple subscribers sharing a single public IPv4 address. Common on mobile and some fixed-line ISPs.

#![allow(unused)]
fn main() {
let carrier = lab.add_router("carrier").preset(RouterPreset::Mobile).build().await?;
let phone = lab.add_device("phone").uplink(carrier.id()).build().await?;
}

Scenario 6: Peer-to-Peer Connectivity Test Matrix

Test how two peers connect across different network types:

#![allow(unused)]
fn main() {
// Home user: easy NAT, firewalled
let home = lab.add_router("home")
    .preset(RouterPreset::Home)
    .nat(Nat::FullCone)
    .build().await?;
let alice = lab.add_device("alice").uplink(home.id()).build().await?;

// Mobile user: CGNAT
let mobile = lab.add_router("mobile").preset(RouterPreset::Mobile).build().await?;
let bob = lab.add_device("bob").uplink(mobile.id()).build().await?;

// Corporate user: strict firewall
let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?;
let charlie = lab.add_device("charlie").uplink(corp.id()).build().await?;

// Test: can alice reach bob? bob reach charlie? etc.
}

IPv6 Feature Reference

FeatureAPINotes
Dual-stackIpSupport::DualStackBoth v4 and v6
IPv6-onlyIpSupport::V6OnlyNo v4 routes
IPv4-onlyIpSupport::V4OnlyNo v6 routes (default)
NPTv6NatV6Mode::Nptv6Stateless 1:1 prefix translation
NAT66 (masquerade)NatV6Mode::MasqueradeLike NAT44 but for v6
Block inboundFirewall::BlockInboundRFC 6092 CE router
Corporate FWFirewall::CorporateBlock inbound + TCP 80,443 + UDP 53
Captive portal FWFirewall::CaptivePortalBlock inbound + block non-web UDP
Custom FWFirewall::Custom(cfg)Full control via FirewallConfig
NAT64NatV6Mode::Nat64Userspace SIIT + nftables masquerade
DHCPv6-PDnot plannedUse static /64 allocation

Common Pitfalls

NPTv6 and NDP

NPTv6 dnat prefix to rules must include address match clauses (e.g., ip6 daddr <wan_prefix>) to avoid translating NDP packets. Without this, neighbor discovery breaks and the router becomes unreachable.

IPv6 Firewall Is Not Optional

On IPv4, NAT implicitly blocks inbound connections (no port mapping = no access). On IPv6 with public GUA addresses, there is no NAT — devices are directly addressable. Without Firewall::BlockInbound, any host on the IX can connect to your devices. This matches reality: every CE router ships with an IPv6 stateful firewall enabled by default.

When IX-level routers share a /64 IX prefix, their WAN addresses are on-link with each other. Routing prefixes carved from the IX range can cause “on-link” confusion where packets are sent directly (ARP/NDP) rather than via the gateway. Use distinct prefixes for IX (/64) and downstream pools (/48 from a different range).

Real-World Network Patterns

Patterns for testing P2P applications against common real-world network conditions. Each section describes what happens from the application’s perspective and how to simulate it.


VPN Connect / Disconnect

What happens when a VPN connects

A VPN client performs three operations:

  1. IP change - A new tunnel interface (wg0, tun0) gets a VPN-assigned address. The device now has two IPs: physical and tunnel.

  2. Route change - For full-tunnel VPNs, a new default route via the tunnel is installed. All traffic exits through the VPN server. For split-tunnel, only specific CIDRs (corporate ranges) route through the tunnel.

  3. DNS change - VPN pushes its own DNS servers. Private hostnames become resolvable.

Impact on existing connections: Existing TCP connections do not automatically die but break in practice. The source IP that the remote knows is the old physical IP. After routing changes, outgoing packets exit via the tunnel with a different source IP. The remote sends responses to the old IP. Connections stall and eventually time out. QUIC connections can migrate if both sides support it.

Full-tunnel VPN

All traffic exits through the VPN server. STUN reports the VPN server’s public IP as the reflexive address. Direct connections between two VPN peers go through two VPN hops.

#![allow(unused)]
fn main() {
// VPN exit node (NATs all clients behind server IP)
let vpn_exit = lab.add_router("vpn-exit")
    .nat(Nat::Home)
    .mtu(1420)          // WireGuard overhead
    .build().await?;

// Before VPN: device on home network
let home = lab.add_router("home").nat(Nat::Home).build().await?;
let device = lab.add_device("client").uplink(home.id()).build().await?;

// Connect VPN: device moves to VPN router, gets new IP
device.replug_iface("eth0", vpn_exit.id()).await?;

// Disconnect VPN: device returns to home router
device.replug_iface("eth0", home.id()).await?;
}

Split-tunnel VPN

Some traffic goes through VPN, rest uses physical interface. Model with two interfaces on different routers:

#![allow(unused)]
fn main() {
let device = lab.add_device("client")
    .iface("eth0", home.id(), None)      // physical: internet traffic
    .iface("wg0", vpn_exit.id(), None)   // tunnel: corporate traffic
    .default_via("eth0")                  // default route on physical
    .build().await?;

// Corporate server only reachable via VPN
let corp_server = lab.add_device("server").uplink(vpn_exit.id()).build().await?;
// Internet server reachable via physical
let public_server = lab.add_device("relay").uplink(dc.id()).build().await?;

// Switch from split to full tunnel
device.set_default_route("wg0").await?;
// Switch back
device.set_default_route("eth0").await?;
}

VPN kill switch

A kill switch drops all non-tunnel traffic immediately:

#![allow(unused)]
fn main() {
device.link_down("eth0").await?;           // kill switch fires
device.replug_iface("eth0", vpn_exit.id()).await?;  // tunnel established
device.link_up("eth0").await?;
}

VPN MTU impact

VPN encapsulation reduces effective MTU. Common values:

ProtocolOverheadInner MTU
WireGuard60B (v4) / 80B (v6)1420 / 1400
OpenVPN UDP~50-60B~1400
IPsec ESP (NAT-T)52-72B~1400

If ICMP “fragmentation needed” is blocked (common in corporate/cloud), PMTUD fails silently. Small requests work, large transfers hang.

#![allow(unused)]
fn main() {
// Simulate VPN MTU + PMTUD blackhole
let vpn = lab.add_router("vpn")
    .mtu(1420)
    .block_icmp_frag_needed()  // PMTU blackhole
    .build().await?;
}

NAT Traversal

See NAT Hole-Punching for the full NAT implementation reference (nftables fullcone map, conntrack behavior, and debugging notes).

Hole punching (STUN + simultaneous open)

Both peers discover their reflexive address via STUN, exchange it through a signaling channel, then send UDP probes simultaneously. Each probe creates a NAT mapping that the peer’s probe can traverse.

#![allow(unused)]
fn main() {
// Both behind cone NATs: hole punching works
let nat_a = lab.add_router("nat-a").nat(Nat::Home).build().await?;
let nat_b = lab.add_router("nat-b").nat(Nat::Home).build().await?;
// Assert: direct connection established

// One side symmetric: hole punching fails, relay needed
let nat_a = lab.add_router("nat-a").nat(Nat::Home).build().await?;
let nat_b = lab.add_router("nat-b").nat(Nat::Corporate).build().await?;
// Assert: falls back to relay (TURN/DERP)
}

Double NAT (CGNAT + home router)

The device is behind two NAT layers. STUN returns the outermost public IP. Port forwarding (UPnP) only works on the home router, not the CGNAT. Hole punching is more timing-sensitive.

#![allow(unused)]
fn main() {
let cgnat = lab.add_router("cgnat").nat(Nat::Cgnat).build().await?;
let home = lab.add_router("home")
    .upstream(cgnat.id())
    .nat(Nat::Home)
    .build().await?;
let device = lab.add_device("client").uplink(home.id()).build().await?;
}

NAT mapping timeout

After a period of inactivity, NAT mappings expire. The application must send keepalives to prevent this. Default UDP timeouts vary by NAT type (120-350s). Test by waiting beyond the timeout period then verifying connectivity.

#![allow(unused)]
fn main() {
// Custom short timeout for fast testing
let nat = lab.add_router("nat")
    .nat(Nat::Custom(
        NatConfig::builder()
            .mapping(NatMapping::EndpointIndependent)
            .filtering(NatFiltering::AddressAndPortDependent)
            .udp_timeout(5)  // seconds, short for testing
            .build(),
    ))
    .build().await?;

// Wait for timeout, verify mapping expired
tokio::time::sleep(Duration::from_secs(6)).await;
router.flush_nat_state().await?;
// Assert: reflexive address changed (new mapping)
}

WiFi to Cellular Handoff

The device’s IP changes. Old connections are invalidated. There is typically a 0.5-5s gap with no connectivity during the transition.

#![allow(unused)]
fn main() {
let wifi_router = lab.add_router("wifi").nat(Nat::Home).build().await?;
let cell_router = lab.add_router("cell").nat(Nat::Cgnat).build().await?;

let device = lab.add_device("phone")
    .iface("eth0", wifi_router.id(), Some(LinkCondition::Wifi))
    .build().await?;

// Simulate handoff with connectivity gap
device.link_down("eth0").await?;
tokio::time::sleep(Duration::from_millis(500)).await;
device.replug_iface("eth0", cell_router.id()).await?;
device.set_link_condition("eth0", Some(LinkCondition::Mobile4G)).await?;
device.link_up("eth0").await?;

// Assert: application reconnects within X seconds
}

Corporate Firewall Blocking UDP

UDP packets are silently dropped. STUN requests time out. ICE falls back through: UDP direct -> UDP relay (TURN) -> TCP relay -> TLS/TCP relay on 443.

#![allow(unused)]
fn main() {
let corp = lab.add_router("corp")
    .nat(Nat::Corporate)
    .firewall(Firewall::Corporate)  // TCP 80,443 + UDP 53 only
    .build().await?;

let workstation = lab.add_device("ws").uplink(corp.id()).build().await?;
// Assert: connection type is Relay, not Direct
// Assert: relay uses TCP/TLS on port 443
}

Asymmetric Bandwidth

Upload and download speeds differ. Common ratios: residential cable 100/10 Mbps, cellular 50/10 Mbps, satellite 100/10 Mbps.

The bottleneck for P2P transfers is the uploader’s upload speed. For video calls, each direction is limited by the sender’s upload.

#![allow(unused)]
fn main() {
// 20 Mbps down, 2 Mbps up (10:1 ratio)
let router = lab.add_router("isp")
    .nat(Nat::Home)
    .downlink_condition(LinkCondition::Manual(LinkLimits {
        rate_kbit: 20_000,
        ..Default::default()
    }))
    .build().await?;

let device = lab.add_device("client").uplink(router.id()).build().await?;
device.set_link_condition("eth0", Some(LinkCondition::Manual(LinkLimits {
    rate_kbit: 2_000,
    ..Default::default()
})))?;
}

IPv6 Transition

See IPv6 Deployments for the full IPv6 deployment reference and router preset table.

Dual-stack

Device has both v4 and v6 addresses. Applications using Happy Eyeballs (RFC 8305) try v6 first. ICE collects both v4 and v6 candidates. Direct v6 connections skip NAT traversal entirely if both peers have public v6 addresses.

#![allow(unused)]
fn main() {
let router = lab.add_router("dual")
    .ip_support(IpSupport::DualStack)
    .nat(Nat::Home)
    .build().await?;
}

v6-only with NAT64

Device has only an IPv6 address. IPv4 destinations are reached via NAT64: the router translates packets between IPv6 and IPv4 using the well-known prefix 64:ff9b::/96. Applications connect to [64:ff9b::<ipv4>]:port and the router handles the rest. ICE candidates are v6 only; TURN must be dual-stack.

#![allow(unused)]
fn main() {
use patchbay::nat64::embed_v4_in_nat64;

// One-liner: MobileV6 preset = V6Only + NAT64 + BlockInbound
let carrier = lab.add_router("carrier")
    .preset(RouterPreset::MobileV6)
    .build().await?;
let phone = lab.add_device("phone").uplink(carrier.id()).build().await?;

// Reach an IPv4 server via NAT64:
let nat64_addr = embed_v4_in_nat64(server_v4_ip);
let target = SocketAddr::new(IpAddr::V6(nat64_addr), 443);
}

Captive Portal

The device has L3 connectivity but no internet access. HTTP requests redirect to the portal. HTTPS and UDP fail. All connection attempts time out.

#![allow(unused)]
fn main() {
// Isolated router with no upstream (simulates pre-auth portal)
let portal = lab.add_router("portal").build().await?;  // no upstream
let device = lab.add_device("victim").uplink(portal.id()).build().await?;

// Assert: all connections fail/timeout

// User "authenticates" - move to real router
device.replug_iface("eth0", real_router.id()).await?;

// Assert: connections now succeed
}

DHCP Renewal (IP Change on Same Network)

The device stays on the same network but its IP address changes. This happens during DHCP lease renewal, cloud instance metadata refresh, or ISP-side reassignment.

#![allow(unused)]
fn main() {
let old_ip = device.ip();
let new_ip = device.renew_ip("eth0").await?;
assert_ne!(old_ip, new_ip);

// Assert: application detects IP change and re-establishes connections
}

Degraded Network Conditions

Progressive degradation

Network conditions worsen over time (moving away from WiFi AP, entering tunnel on cellular, weather affecting satellite).

#![allow(unused)]
fn main() {
device.set_link_condition("eth0", Some(LinkCondition::Wifi)).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
device.set_link_condition("eth0", Some(LinkCondition::WifiBad)).await?;
tokio::time::sleep(Duration::from_secs(5)).await;
device.set_link_condition("eth0", None).await?;  // remove impairment
}

Intermittent connectivity

Network flaps briefly, simulating tunnels, elevators, or brief signal loss.

#![allow(unused)]
fn main() {
for _ in 0..3 {
    device.link_down("eth0").await?;
    tokio::time::sleep(Duration::from_millis(200)).await;
    device.link_up("eth0").await?;
    tokio::time::sleep(Duration::from_secs(2)).await;
}
// Assert: application recovers after each flap
}

Simulator Primitive Reference

Real-World EventSimulator Primitive
VPN connects (full tunnel)device.replug_iface("eth0", vpn_router)
VPN disconnectsdevice.replug_iface("eth0", original_router)
VPN kill switchlink_down then replug_iface
VPN split tunnelTwo interfaces on different routers + set_default_route
WiFi to cellularreplug_iface + change set_link_condition
Network goes down brieflylink_down, sleep, link_up
Cone NATNat::Home
Symmetric NATNat::Corporate
Double NAT / CGNATChain routers: home.upstream(cgnat.id())
Corporate UDP blockFirewall::Corporate on router
Captive portalRouter with no upstream
DHCP renewaldevice.renew_ip("eth0")
Asymmetric bandwidthdownlink_condition on router + set_link_condition on device
Degrading conditionsSequential set_link_condition calls
MTU reduction (VPN).mtu(1420) on router or device builder
PMTU blackhole.block_icmp_frag_needed() on router builder
IPv6 dual-stack.ip_support(IpSupport::DualStack)
IPv6 only.ip_support(IpSupport::V6Only)
IPv6-only + NAT64.preset(RouterPreset::MobileV6) or .nat_v6(NatV6Mode::Nat64)
Mobile carrier (CGNAT).preset(RouterPreset::Mobile)
Mobile carrier (v6-only).preset(RouterPreset::MobileV6)

NAT Hole-Punching

This is an advanced reference for readers who want to understand how patchbay implements NAT traversal at the nftables level. You do not need to read this to use patchbay; the NAT and Firewalls guide covers the user-facing API.

This document describes how patchbay implements NAT mapping and filtering using nftables, and what we learned getting UDP hole-punching to work across different NAT types in Linux network namespaces.

RFC 4787: mapping and filtering

Two independent axes define NAT behavior for UDP. Mapping controls how external ports are assigned: endpoint-independent mapping (EIM) reuses the same external port for all destinations, while endpoint-dependent mapping (EDM) assigns a different port per destination. Filtering controls which inbound packets are forwarded to a mapped port: endpoint-independent filtering (EIF) accepts packets from any external host, while endpoint-dependent filtering only forwards replies from hosts the internal client has already contacted.

Combined, these axes produce the real-world NAT profiles that patchbay simulates:

PresetMappingFilteringHole-punch?Real-world examples
Nat::HomeEIMAPDFYes, simultaneous openFritzBox, Unifi, TP-Link, ASUS RT, OpenWRT
Nat::FullConeEIMEIFAlwaysOld FritzBox firmware, some CGNAT
Nat::CorporateEDMAPDFNever (need relay)Cisco ASA, Palo Alto, Fortinet, Juniper SRX
Nat::CloudNatEDMAPDFNever (need relay)AWS/Azure/GCP NAT Gateway
Nat::CgnatVariesISP-level, stacks with home NAT

The fullcone dynamic map

The only reliable way to get endpoint-independent mapping in nftables is to explicitly track port mappings in a dynamic map. The kernel’s built-in snat and masquerade statements do not preserve ports across independent conntrack entries, even when there is no port conflict (see the pitfalls section below). patchbay works around this with an @fullcone map:

table ip nat {
    map fullcone {
        type inet_service : ipv4_addr . inet_service
        flags dynamic,timeout
        timeout 300s
        size 65536
    }
    chain prerouting {
        type nat hook prerouting priority dstnat; policy accept;
        iif "ix" meta l4proto udp dnat to udp dport map @fullcone
    }
    chain postrouting {
        type nat hook postrouting priority srcnat; policy accept;
        oif "ix" meta l4proto udp update @fullcone {
            udp sport timeout 300s : ip saddr . udp sport
        }
        oif "ix" snat to <wan_ip>
    }
}

The postrouting chain records the pre-SNAT source address and port in the map before the snat rule executes. The map key is the UDP source port and the value is internal_ip . internal_port. Even if snat later remaps the port, the map holds the correct mapping keyed by the original port. On the inbound side, the prerouting chain looks up incoming UDP packets by destination port in the map and DNATs them to the internal host, bypassing conntrack reverse-NAT entirely.

The update statement must come before snat in the postrouting chain. nftables NAT statements record the transformation, but the conntrack entry’s reply tuple is not yet available during the same chain evaluation. By recording udp sport and ip saddr before SNAT, we capture the original tuple. Map entries time out after 300 seconds and are refreshed by outbound traffic.

Filtering modes

Endpoint-independent filtering (fullcone)

Nat::FullCone uses the fullcone map above with no additional filtering. The prerouting DNAT fires for any inbound packet whose destination port appears in the map, regardless of source address. Once an internal device sends one outbound packet, any external host can reach it on the mapped port.

Address-and-port-dependent filtering (home NAT)

Nat::Home uses the same fullcone map for endpoint-independent mapping, plus a forward filter that restricts inbound traffic to established connections:

table ip filter {
    chain forward {
        type filter hook forward priority 0; policy accept;
        iif "ix" ct state established,related accept
        iif "ix" drop
    }
}

This combination is what makes hole-punching work with home NATs. The sequence is:

  1. The internal device sends a UDP packet to the peer. Postrouting SNAT creates a conntrack entry and the fullcone map records the port mapping.
  2. The peer sends a packet to the device’s mapped address. Prerouting DNAT via the fullcone map rewrites the destination from the router’s WAN IP to the device’s internal IP.
  3. After DNAT, the packet’s 5-tuple matches the reply direction of the outbound conntrack entry from step 1. Conntrack marks it as ct state established.
  4. The forward filter allows the packet through.

An unsolicited packet from an unknown host also gets DNATed in step 2, but no matching outbound conntrack entry exists, so the packet arrives with ct state new and the filter drops it.

Endpoint-dependent mapping (corporate and cloud NAT)

Nat::Corporate and Nat::CloudNat use plain masquerade random without a fullcone map:

table ip nat {
    chain postrouting {
        type nat hook postrouting priority 100;
        oif "ix" masquerade random
    }
}

The random flag randomizes the source port for each conntrack entry. Without a fullcone map and without a prerouting chain, hole-punching is impossible because the peer cannot predict the mapped port from a STUN probe.

nftables pitfalls

Port preservation is unreliable

The single biggest surprise during implementation. Conventional wisdom says snat to <ip> without a port range is “port-preserving”. In practice, Linux conntrack assigns different external ports for different conntrack entries from the same source socket, even when there is no port conflict.

For example: a device binds port 40000, sends to a STUN server (port preserved to 40000), then sends to a peer. Conntrack assigns port 27028 instead of 40000, despite the absence of any conflict on that port.

None of the following fix this:

oif "ix" snat to 203.0.113.11              # port NOT preserved across entries
oif "ix" snat to 203.0.113.11 persistent   # still remaps
oif "ix" masquerade persistent              # still remaps

The persistent flag is documented to “give a client the same source-ip,source-port”, but the kernel’s NAT tuple uniqueness check still triggers port reallocation across independent conntrack entries. This is why the fullcone dynamic map is necessary for endpoint-independent mapping.

A prerouting nat chain is required even if empty

Without a type nat hook prerouting chain registered in the nat table, the kernel does not perform conntrack reverse-NAT lookup on inbound packets. Packets destined for the router’s WAN IP that should be reverse-DNATed are delivered to the router’s INPUT chain instead of being forwarded to the internal device.

Conntrack reverse-NAT depends on port consistency

Even with a prerouting chain, conntrack reverse-NAT only works when the inbound packet’s 5-tuple matches the reply tuple of an existing conntrack entry. If SNAT changed the port (which it does, as described above), the peer sends to the wrong port and conntrack cannot match the entry.

Test helper subtlety

Both sides of a hole-punch test call holepunch_send_recv, which sends UDP probes every 200ms and checks for a response. There is a critical ordering issue: when one side receives a probe first, it must send a few more packets before returning. Otherwise, side A receives side B’s probe, returns success, and stops sending. But side B’s early probes may have arrived before side A created its outbound conntrack entry at side B’s NAT, so those probes were dropped by APDF filtering. With side A no longer sending, side B never receives a packet.

The fix is to send three additional “ack” packets after receiving, to ensure the peer’s NAT has an established conntrack entry in both directions.

NatConfig architecture

The Nat enum provides named presets. Each preset expands via Nat::to_config() to a NatConfig struct that drives rule generation:

#![allow(unused)]
fn main() {
pub struct NatConfig {
    pub mapping: NatMapping,           // EIM or EDM
    pub filtering: NatFiltering,       // EIF or APDF
    pub timeouts: ConntrackTimeouts,   // udp, udp_stream, tcp_established
}
}

The generate_nat_rules() function in core.rs builds nftables rules from NatConfig alone, without matching on Nat variants. This means users can either use the named presets (router.nat(Nat::Home)) or build custom configurations with arbitrary mapping and filtering combinations.

CGNAT is a special case: Nat::Cgnat is applied at the ISP router level via apply_isp_cgnat() rather than through NatConfig. It uses plain masquerade (without the random flag) on the IX-facing interface and stacks with the downstream home router’s NAT.

NPTv6 implementation notes

NPTv6 (Network Prefix Translation for IPv6) translates source and destination prefixes while preserving the host part, using nftables snat prefix to and dnat prefix to. Several issues were found during implementation:

  1. Prefix length mismatch breaks translation. NPTv6 requires matching prefix lengths on LAN and WAN sides. The nptv6_wan_prefix() function derives a unique /64 from the router’s IX address.

  2. Unrestricted dnat prefix breaks NDP. Without an address match clause, NDP and ICMPv6 packets get translated, making the router unreachable. The rules are restricted to ip6 saddr/daddr matching the WAN or LAN prefix.

  3. WAN prefix must be outside the IX on-link range. The IX CIDR was changed from /32 to /64 so WAN prefixes are off-link and routed via the gateway.

  4. Return routes needed for private v6 downstreams. IPv6 return routes are added for all IX-level routers regardless of downstream pool configuration.

See IPv6 Deployments for the full IPv6 deployment reference.

Limitations

The fullcone map tracks UDP only. TCP hole-punching (simultaneous SYN) relies on plain conntrack, which matches real-world behavior where TCP hole-punching is unreliable.

There is also a port preservation assumption in the map: if snat to <ip> remaps the source port, the fullcone map key (the original port) differs from the actual mapped port. In practice this does not happen in patchbay simulations because there are few concurrent flows relative to the 64k port space.

Future work

  • Address-restricted cone (EIM + address-dependent filtering): extend the fullcone map to track contacted remote IPs.
  • Hairpin NAT: add a prerouting rule for LAN packets addressed to the router’s own WAN IP.
  • TCP fullcone: extend @fullcone to TCP for a complete NAT model.
  • Port-conflict-safe fullcone: two-stage postrouting to read ct reply proto-dst after conntrack finalizes the mapping.

Sim TOML Reference

A simulation is defined by one TOML file. That file describes what topology to use, what binaries to run, and the sequence of steps to execute. This page covers every field.


File layout

[[extends]]          # optional: inherit from a shared defaults file
file = "..."

[sim]                # simulation metadata
name     = "..."
topology = "..."

[[binary]]           # optional: binary definitions (repeatable)
...

[[step-template]]    # optional: reusable single-step templates (repeatable)
...

[[step-group]]       # optional: reusable multi-step groups (repeatable)
...

[[step]]             # the actual steps to execute (repeatable)
...

Inline topology tables ([[router]], [device.*], [region.*]) can also appear directly in the sim file instead of referencing an external topology.


[[extends]]

Pulls in definitions from another TOML file. The loaded file can contribute [[binary]], [[step-template]], and [[step-group]] entries. The sim file’s own declarations always win on name collision. Multiple [[extends]] blocks are supported and processed in order.

KeyTypeDescription
filestringPath to the shared file. Searched relative to the sim file, then one directory up, then the working directory.

Example:

[[extends]]
file = "iroh-defaults.toml"

[sim]

KeyTypeDescription
namestringIdentifier used in output filenames and the report header.
topologystringName of a topology file to load from the topos/ directory next to the sim file. Overrides any topology from [[extends]].

[[binary]]

Declares a named binary that steps can reference as ${binary.<name>}. Exactly one source field is required.

KeyTypeDescription
namestringReference key. Used as ${binary.relay}, ${binary.transfer}, etc.
pathstringLocal path. Prefix target: to resolve relative to the Cargo target directory (e.g. target:examples/transfer).
urlstringDownload URL. Supports .tar.gz archives; the binary is extracted automatically.
repostringGit repository URL. Must pair with example or bin.
commitstringBranch, tag, or SHA for repo source. Defaults to "main".
examplestringBuild with cargo --example <name> from the repo.
binstringBuild with cargo --bin <name> from the repo.

[[step-template]]

A named, reusable step definition. Contains the same fields as a [[step]] plus a name. Referenced with use = "<name>" in a step; the call-site fields are merged on top before the step executes.

[[step-template]]
name   = "transfer-fetcher"
action = "spawn"
parser = "ndjson"
cmd    = ["${binary.transfer}", "--output", "json", "fetch"]
[step-template.captures.size]
match = { kind = "DownloadComplete" }
pick  = ".size"
[step-template.results]
down_bytes = ".size"

Call site:

[[step]]
use    = "transfer-fetcher"
id     = "fetcher"
device = "fetcher"
args   = ["${provider.endpoint_id}"]

The call site’s id, device, args, env, requires, captures, and results fields are merged into the template. args is appended to the template’s cmd. env is merged (call site wins on collision). captures is merged (call site wins). results replaces entirely if supplied.


[[step-group]]

A named sequence of steps that expands inline wherever use = "<group-name>" appears. Groups support variable substitution for parameterization.

KeyTypeDescription
namestringGroup identifier.
[[step-group.step]]arrayOrdered step definitions.

The call site uses a [[step]] with use and vars:

[[step]]
use  = "relay-setup"
vars = { device = "relay" }

Inside group steps, ${group.<key>} is substituted with the caller-supplied value before the steps execute. This substitution happens at expansion time (before runtime), so a two-stage pattern is used for nested references:

# In the group step:
content = "cert_path = \"${${group.device}-cert.cert_pem_path}\""
# After group expansion (e.g. device="relay"):
#   → cert_path = "${relay-cert.cert_pem_path}"
# Then resolved at runtime as a capture reference.

Group steps can themselves use use = "<step-template-name>" to inherit from a template. Groups cannot nest other groups.


[[step]]

Common fields

These fields apply to most or all step types.

KeyTypeDescription
actionstringStep type. See the sections below for valid values. Defaults to "run" when cmd is present.
idstringStep identifier. Required for spawn, gen-certs, gen-file. Referenced as ${id.capture_name} in later steps.
usestringTemplate or group name. When referencing a group, only vars is used from this entry; all other fields come from the group.
varstableGroup substitution variables. Only meaningful when use references a [[step-group]].
devicestringName of the network namespace to run the command in.
envtableExtra environment variables, merged with any template env.
requiresarray of stringsCapture keys to wait for before this step starts. Format: "step_id.capture_name". Blocks until all are resolved.

action = "run"

Runs a command and waits for it to exit before moving to the next step.

KeyTypeDefaultDescription
cmdarrayrequiredCommand and arguments. Supports ${binary.<n>}, $NETSIM_IP_<device>, ${id.capture}.
argsarrayAppended to the template’s cmd. Does not replace it.
parserstring"text"Output parser. See parsers.
capturestableNamed captures. See [captures].
resultstableNormalized result fields. See [results].

action = "spawn"

Starts a process in the background. A later wait-for step waits for it to exit.

KeyTypeDefaultDescription
cmdarrayrequiredCommand and arguments.
argsarrayAppended to the template’s cmd.
parserstring"text"Output parser. See parsers.
ready_afterdurationHow long to wait after spawning before the next step runs. Useful when a process needs startup time but doesn’t print a ready signal.
capturestableNamed captures. See [captures].
resultstableNormalized result fields. Collected when the process exits.

action = "wait-for"

Waits for a spawned process to exit. Collects its captures and results.

KeyTypeDefaultDescription
idstringrequiredID of a previously spawned step.
timeoutduration"300s"How long to wait before failing.

action = "wait"

Sleeps for a fixed duration.

KeyTypeDescription
durationdurationRequired. How long to sleep.

Applies link impairment (rate limit, loss, latency) to a device interface using tc netem and tc tbf.

KeyTypeDescription
devicestringTarget device.
interfacestringInterface name, e.g. "eth0".
link_conditionstring or tablePreset name ("wifi", "mobile4g", etc.) or a custom table: { rate = 10000, loss = 0.5, latency = 40 }. Rate in kbit/s, loss as percentage, latency in ms.

Brings a device interface up or down.

KeyTypeDescription
devicestringTarget device.
interfacestringInterface name.

action = "set-default-route"

Switches the default route on a device to a given interface. Useful for simulating path changes.

KeyTypeDescription
devicestringTarget device.
tostringInterface to set as the new default route.

action = "gen-certs"

Generates a self-signed TLS certificate and key using rcgen. The outputs are written to {work_dir}/certs/{id}/ and also stored as captures.

KeyTypeDefaultDescription
idstringrequiredStep ID, prefixes the output captures.
devicestringDevice whose IP is automatically added to the Subject Alternative Names.
cnstring"localhost"Certificate Common Name.
sanarray of strings[device_ip, "localhost"]SANs. $NETSIM_IP_<device> variables are expanded.

Output captures: {id}.cert_pem, {id}.key_pem, {id}.cert_pem_path, {id}.key_pem_path.


action = "gen-file"

Writes an interpolated string to disk and records the path as a capture. Useful for generating config files that reference captures from earlier steps.

KeyTypeDescription
idstringRequired.
contentstringRequired. ${...} tokens are interpolated; blocks on unresolved capture references.

Output capture: {id}.path.

The file is written to {work_dir}/files/{id}/content.


action = "assert"

Checks one or more assertion expressions. All must pass; the sim fails on the first that doesn’t.

KeyTypeDescription
checkstringSingle assertion expression.
checksarray of stringsMultiple expressions; equivalent to multiple check fields.

Expression syntax:

step_id.capture_name operator rhs

The LHS must be a capture key in the form step_id.capture_name. The value used is the most recent one recorded for that capture.

OperatorPasses when
== rhsExact string match.
!= rhsNot an exact match.
contains rhsrhs is a substring of the capture value.
matches rhsrhs is a Rust regex that matches the capture value.

Examples:

[[step]]
action = "assert"
checks = [
  "fetcher.conn_type contains Direct",
  "fetcher.size matches [0-9]+",
  "iperf-run.bps != 0",
]

Parsers

Set on run or spawn steps with parser = "...".

ValueWhen it firesWhat it can do
"text"Streaming, per lineregex captures only.
"ndjson"Streaming, per lineregex captures, plus match/pick on JSON lines.
"json"After process exitspick on the single JSON document. No per-line matching.

[captures]

Defined as sub-tables of a run or spawn step:

[[step]]
action = "run"
id     = "iperf"
parser = "json"
cmd    = ["iperf3", "-J", ...]
[step.captures.bytes]
pick = ".end.sum_received.bytes"
[step.captures.seconds]
pick = ".end.sum_received.seconds"

Or on a template:

[[step-template]]
name = "transfer-provider"
...
[step-template.captures.endpoint_id]
match = { kind = "EndpointBound" }
pick  = ".endpoint_id"
KeyTypeDefaultDescription
pipestring"stdout"Which output stream to read: "stdout" or "stderr".
regexstringRegex applied to the raw text line. Group 1 is captured if present, otherwise the full match. Works with all parsers. Cannot be combined with pick.
matchtableKey=value guards on a parsed JSON object. All keys must match. Requires pick. Only valid with "ndjson" or "json" parser.
pickstringDot-path into the parsed JSON value, e.g. ".endpoint_id" or ".end.sum_received.bytes". Requires "ndjson" or "json" parser. Cannot be combined with regex.

With "ndjson", every matching line appends to the capture’s history. With "json" or regex, the capture is set once from the final matched value.

The latest value is available for interpolation as ${step_id.capture_name}.


[results]

Maps well-known output fields to capture references, so the report can show normalized throughput comparisons across steps and runs.

[step.results]
duration   = "iperf-run.seconds"
down_bytes = "iperf-run.bytes"

Inside a [[step-template]], the shorthand .capture_name (leading dot, no step ID) refers to the template step’s own captures. It gets rewritten to {id}.capture_name when the template is expanded:

[step-template.results]
duration   = ".duration"    # becomes "fetcher.duration" when id="fetcher"
down_bytes = ".size"
FieldTypeDescription
durationfloat sDuration of the transfer or test.
up_bytesintegerBytes sent (upload).
down_bytesintegerBytes received (download).

Bandwidth (down_bytes / duration) is computed in the UI. Unset fields are omitted from the output.


Variable interpolation

Supported in cmd, args, env values, content (gen-file), and san (gen-certs).

PatternResolves to
${binary.<name>}Resolved filesystem path to the named binary.
$NETSIM_IP_<DEVICE>IP address of the device (name uppercased, non-alphanumeric characters replaced with _).
${step_id.capture_name}Latest value of the named capture. Blocks until the capture resolves.

Duration format

Durations are strings of the form "<n>s", "<n>ms", or "<n>m".

Examples: "30s", "500ms", "2m", "300s".


Output files

For each sim run, patchbay writes to a timestamped directory under the work root (default .patchbay-work/):

.patchbay-work/
  latest/                     # symlink to the most recent run
  <sim-name>-YYMMDD-HHMMSS/
    results.json              # captures and normalized results
    results.md                # human-readable summary table
    nodes/
      <device>/
        stdout.log
        stderr.log
    files/                    # gen-file outputs
      <id>/content
    certs/                    # gen-certs outputs
      <id>/cert.pem
      <id>/key.pem
  combined-results.json       # aggregated across all runs in the work root
  combined-results.md

results.json structure:

{
  "sim": "iroh-1to1-nat",
  "captures": {
    "fetcher.conn_type": { "value": "Direct", "history": ["Relay", "Direct"] },
    "fetcher.size":      { "value": "104857600", "history": ["104857600"] }
  },
  "results": [
    { "id": "fetcher", "duration": "12.3", "down_bytes": "104857600" }
  ]
}

Topology files

A topology file (in topos/) defines the network graph: routers with optional NAT, and devices with their interfaces and gateways.

# A datacenter router (no NAT)
[[router]]
name = "dc"

# A home NAT router (endpoint-independent mapping, port-restricted filtering)
[[router]]
name = "lan-client"
nat  = "home"

# A device with one interface behind the DC router
[device.server.eth0]
gateway = "dc"

# A device behind the NAT router
[device.client.eth0]
gateway = "lan-client"

# Multiple devices of the same name (count expansion)
[device.fetcher]
count = 10

[device.fetcher.eth0]
gateway = "dc"

NAT modes:

ValueBehavior
(absent)No NAT; device has a public IP on the upstream network.
"home"EIM+APDF: same external port for all destinations (port-restricted cone).
"corporate"EDM+APDF: different port per destination (symmetric NAT).
"cgnat"EIM+EIF: carrier-grade NAT, stacks with home NAT.
"cloud-nat"EDM+APDF: symmetric NAT with longer timeouts (AWS/Azure/GCP).
"full-cone"EIM+EIF: any host can reach the mapped port.

Region latency can be added to introduce inter-router delays:

[region.us-west]
latency = { us-east = "80ms", eu-central = "140ms" }

Attach a router to a region with region = "us-west" in the [[router]] table.


Example: minimal iperf sim

[sim]
name     = "iperf-baseline"
topology = "1to1-public"

[[step]]
action      = "spawn"
id          = "iperf-server"
device      = "provider"
cmd         = ["iperf3", "-s", "-1"]
ready_after = "1s"

[[step]]
action = "run"
id     = "iperf-run"
device = "fetcher"
parser = "json"
cmd    = ["iperf3", "-c", "$NETSIM_IP_provider", "-t", "10", "-J"]
[step.captures.bytes]
pick = ".end.sum_received.bytes"
[step.captures.seconds]
pick = ".end.sum_received.seconds"
[step.results]
duration   = "iperf-run.seconds"
down_bytes = "iperf-run.bytes"

[[step]]
action = "wait-for"
id     = "iperf-server"

[[step]]
action = "assert"
checks = [
  "iperf-run.bytes matches [0-9]+",
]

Example: iroh transfer with relay (NAT topology)

This uses templates and a step group defined in iroh-defaults.toml.

[[extends]]
file = "iroh-defaults.toml"

[sim]
name     = "iroh-1to1-nat"
topology = "1to1-nat"

# Expands to: gen-certs → gen-file (relay config) → spawn relay
[[step]]
use  = "relay-setup"
vars = { device = "relay" }

[[step]]
use      = "transfer-provider"
id       = "provider"
device   = "provider"
requires = ["relay.ready"]
args     = ["--relay-url", "https://$NETSIM_IP_relay:3340"]

[[step]]
use    = "transfer-fetcher"
id     = "fetcher"
device = "fetcher"
args   = ["${provider.endpoint_id}",
          "--relay-url",        "https://$NETSIM_IP_relay:3340",
          "--remote-relay-url", "https://$NETSIM_IP_relay:3340"]

[[step]]
action  = "wait-for"
id      = "fetcher"
timeout = "45s"

[[step]]
action = "assert"
checks = [
  "fetcher.size matches [0-9]+",
]

The relay-setup group (from iroh-defaults.toml) runs gen-certs, writes a relay config file with gen-file, and spawns the relay binary. The relay step captures a ready signal from stderr; provider uses requires = ["relay.ready"] to block until it fires.