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

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.