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

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.