From 33b71435adefbf387275773aaa2dc99a8d23207d Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 11:03:19 +0200 Subject: [PATCH 1/7] feat!: restructure Nat taxonomy with behavior-only variants Replace deployment-flavored Nat variants (Home/Corporate/Cgnat/CloudNat/ FullCone) with a behavior gradient (Easiest/Easy/Hard/Hardest) and move deployment context into RouterPreset. Add NatFiltering::AddressDependent (RFC 4787 ADF) and PortPreservation axis. Add IspCgnatHard and MobileCarrier presets. This commit captures the intermediate state for review; follow-up work will tighten type-system invariants, add empirical tests for the port-preservation distinction, and realign presets with published deployment data. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/guide/getting-started.md | 4 +- docs/guide/nat-and-firewalls.md | 52 ++--- docs/guide/running-code.md | 2 +- docs/guide/testing.md | 2 +- docs/guide/topology.md | 6 +- docs/reference/holepunching.md | 72 ++++--- docs/reference/ipv6.md | 4 +- docs/reference/patterns.md | 30 +-- patchbay/src/core.rs | 19 +- patchbay/src/event.rs | 16 +- patchbay/src/lab.rs | 6 +- patchbay/src/lib.rs | 2 +- patchbay/src/nat.rs | 295 ++++++++++++++------------- patchbay/src/nft.rs | 77 +++++-- patchbay/src/router.rs | 178 +++++++++++++--- patchbay/src/tests/devtools.rs | 2 +- patchbay/src/tests/dns.rs | 4 +- patchbay/src/tests/firewall.rs | 10 +- patchbay/src/tests/hairpin.rs | 21 +- patchbay/src/tests/holepunch.rs | 6 +- patchbay/src/tests/iface.rs | 4 +- patchbay/src/tests/ipv6.rs | 2 +- patchbay/src/tests/lifecycle.rs | 4 +- patchbay/src/tests/link_condition.rs | 4 +- patchbay/src/tests/mod.rs | 4 +- patchbay/src/tests/nat.rs | 62 +++--- patchbay/src/tests/nat64.rs | 6 +- patchbay/src/tests/nat_rebind.rs | 10 +- patchbay/src/tests/preset.rs | 8 +- patchbay/src/tests/region.rs | 2 +- patchbay/src/tests/route.rs | 16 +- patchbay/src/tests/smoke.rs | 14 +- patchbay/tests/vm_smoke.rs | 2 +- 34 files changed, 573 insertions(+), 375 deletions(-) diff --git a/README.md b/README.md index 2db2bea..4a8068e 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ dev.iface("wlan0").unwrap().set_condition(LinkCondition::Manual(LinkLimits { }), LinkDirection::Both).await?; // Change NAT mode at runtime. -router.set_nat_mode(Nat::Corporate).await?; +router.set_nat_mode(Nat::Hardest).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 01860e2..d31ebb7 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -107,7 +107,7 @@ let lab = patchbay::Lab::new().await?; 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 +IP addresses, like a datacenter. Adding `.nat(Nat::Easy)` places a NAT in front of the router's downstream, assigning devices private addresses and masquerading their traffic, like a typical home WiFi router. @@ -118,7 +118,7 @@ use patchbay::{Nat, LinkCondition, LinkDirection}; 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?; +let home = lab.add_router("home").nat(Nat::Easy).build().await?; ``` Devices attach to routers through named network interfaces. Each interface diff --git a/docs/guide/nat-and-firewalls.md b/docs/guide/nat-and-firewalls.md index ab25c10..e40f3c5 100644 --- a/docs/guide/nat-and-firewalls.md +++ b/docs/guide/nat-and-firewalls.md @@ -25,31 +25,33 @@ gets a different external port, so the address learned from STUN is useless for other peers. *Filtering* determines which inbound packets the router forwards. -Endpoint-independent filtering (fullcone) accepts packets from any -external host, as long as a mapping exists. Endpoint-dependent filtering -only forwards packets from hosts the internal device has already -contacted — unsolicited packets from unknown hosts are dropped even if -the port is mapped. +Endpoint-independent filtering (full cone) accepts packets from any +external host, as long as a mapping exists. Address-dependent filtering +accepts packets from any port on an address the internal device has +already contacted. Address-and-port-dependent filtering only forwards +packets from the exact address and port the internal device has +already contacted; unsolicited packets are dropped even if the port is +mapped. You configure NAT on the router builder with `.nat()`: ```rust use patchbay::Nat; -let home = lab.add_router("home").nat(Nat::Home).build().await?; +let home = lab.add_router("home").nat(Nat::Easy).build().await?; ``` -Each preset combines a mapping and filtering mode to match a real-world -device class: +The `Nat` enum forms a gradient of hole-punching difficulty. Each variant +combines a mapping, filtering, and port-preservation choice to match a +real-world device class: -| Mode | Mapping | Filtering | Real-world model | -|------|---------|-----------|------------------| -| `None` | n/a | n/a | Datacenter, public IPs | -| `Home` | Endpoint-independent | Endpoint-dependent | Home WiFi router | -| `Corporate` | Endpoint-independent | Endpoint-dependent | Enterprise gateway | -| `FullCone` | Endpoint-independent | Endpoint-independent | Gaming router, fullcone VPN | -| `CloudNat` | Endpoint-dependent | Endpoint-dependent | AWS/GCP cloud NAT | -| `Cgnat` | Endpoint-dependent | Endpoint-dependent | Carrier-grade NAT at the ISP | +| Mode | Mapping | Filtering | Port allocation | Real-world model | +|------|---------|-----------|-----------------|------------------| +| `None` | n/a | n/a | n/a | Datacenter, public IPs | +| `Easiest` | Endpoint-independent | Endpoint-independent | Preserve | Full-cone router, RFC 6888 compliant CGNAT | +| `Easy` | Endpoint-independent | Address-and-port-dependent | Preserve | Standard home WiFi router | +| `Hard` | Endpoint-dependent | Address-and-port-dependent | Preserve | PBA CGNAT, mobile carrier, port-preserving symmetric NAT | +| `Hardest` | Endpoint-dependent | Address-and-port-dependent | Random | Enterprise gateway, AWS/Azure/GCP NAT Gateway | For a deep dive into how these modes are implemented in nftables and how hole-punching works across them, see the @@ -64,11 +66,11 @@ independently: ```rust use patchbay::nat::{NatConfig, NatMapping, NatFiltering}; -let custom = Nat::Custom(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::EndpointIndependent, - ..Default::default() -}); +let custom = NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .filtering(NatFiltering::EndpointIndependent) + .hairpin(true) + .build(); let router = lab.add_router("custom").nat(custom).build().await?; ``` @@ -82,7 +84,7 @@ changes mid-session, for example simulating a network migration. Call new connections use the updated rules: ```rust -router.set_nat_mode(Nat::Corporate).await?; +router.set_nat_mode(Nat::Hardest).await?; router.flush_nat_state().await?; ``` @@ -219,7 +221,7 @@ typical compositions: ```rust // Home router: NAT + inbound firewall. The most common residential setup. let home = lab.add_router("home") - .nat(Nat::Home) + .nat(Nat::Easy) .firewall(Firewall::BlockInbound) .build().await?; @@ -229,10 +231,10 @@ let dc = lab.add_router("dc") .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 isp = lab.add_router("isp").nat(Nat::Easiest).build().await?; let home = lab.add_router("home") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build().await?; ``` diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index 57d1d01..bb21f2c 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -193,7 +193,7 @@ This is covered in more detail in the [NAT and Firewalls](nat-and-firewalls.md) chapter: ```rust -router.set_nat_mode(Nat::Corporate).await?; +router.set_nat_mode(Nat::Hardest).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/testing.md b/docs/guide/testing.md index 775c1c2..7d5ef21 100644 --- a/docs/guide/testing.md +++ b/docs/guide/testing.md @@ -50,7 +50,7 @@ async fn tcp_through_nat() -> Result<()> { let dc = lab.add_router("dc").build().await?; let home = lab .add_router("home") - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 2427390..06a830d 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -31,11 +31,11 @@ multi-layer topologies like ISP + home or corporate gateway + branch office: ```rust -let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?; +let isp = lab.add_router("isp").nat(Nat::Easiest).build().await?; let home = lab .add_router("home") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; ``` @@ -77,7 +77,7 @@ the preset configures: 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 +For example, `RouterPreset::Home` with `.nat(Nat::Easiest)` gives you a home-style topology with fullcone NAT instead of the default endpoint-dependent filtering. diff --git a/docs/reference/holepunching.md b/docs/reference/holepunching.md index 17de0c5..4db06de 100644 --- a/docs/reference/holepunching.md +++ b/docs/reference/holepunching.md @@ -24,13 +24,12 @@ the internal client has already contacted. Combined, these axes produce the real-world NAT profiles that patchbay simulates: -| Preset | Mapping | Filtering | Hole-punch? | Real-world examples | -|--------|---------|-----------|-------------|---------------------| -| `Nat::Home` | EIM | APDF | Yes, simultaneous open | FritzBox, Unifi, TP-Link, ASUS RT, OpenWRT | -| `Nat::FullCone` | EIM | EIF | Always | Old FritzBox firmware, some CGNAT | -| `Nat::Corporate` | EDM | APDF | Never (need relay) | Cisco ASA, Palo Alto, Fortinet, Juniper SRX | -| `Nat::CloudNat` | EDM | APDF | Never (need relay) | AWS/Azure/GCP NAT Gateway | -| `Nat::Cgnat` | -- | -- | Varies | ISP-level, stacks with home NAT | +| Preset | Mapping | Filtering | Port preservation | Hole-punch? | Real-world examples | +|--------|---------|-----------|-------------------|-------------|---------------------| +| `Nat::Easiest` | EIM | EIF | Preserve | Always | Older consumer routers, RFC 6888 compliant fiber CGNAT | +| `Nat::Easy` | EIM | APDF | Preserve | Yes, simultaneous open | FritzBox, Unifi, TP-Link, ASUS RT, OpenWRT | +| `Nat::Hard` | EDM | APDF | Preserve | Sometimes, with port prediction | PBA CGNAT, mobile carriers, some stateful firewalls | +| `Nat::Hardest` | EDM | APDF | Random | Practically never | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway | ## The fullcone dynamic map @@ -79,17 +78,25 @@ by outbound traffic. ## Filtering modes -### Endpoint-independent filtering (fullcone) +### Endpoint-independent filtering (full cone) -`Nat::FullCone` uses the fullcone map above with no additional filtering. +`Nat::Easiest` 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) +### Address-dependent filtering (restricted cone) -`Nat::Home` uses the same fullcone map for endpoint-independent mapping, +`Nat::Custom` with `NatFiltering::AddressDependent` uses the fullcone map +for mapping, plus a `@contacted` dynamic set of external IPs the internal +side has sent to. The forward chain accepts inbound packets from those +addresses regardless of source port. This is less common in the wild than +APDF but documented on some consumer routers. + +### Address-and-port-dependent filtering (port-restricted cone) + +`Nat::Easy` uses the same fullcone map for endpoint-independent mapping, plus a forward filter that restricts inbound traffic to established connections: @@ -120,23 +127,26 @@ 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) +### Endpoint-dependent mapping (symmetric NAT) -`Nat::Corporate` and `Nat::CloudNat` use plain `masquerade random` -without a fullcone map: +`Nat::Hard` and `Nat::Hardest` use plain `masquerade` without a fullcone +map. The distinction is the port allocation flag: ```nft table ip nat { chain postrouting { type nat hook postrouting priority 100; - oif "ix" masquerade random + oif "ix" masquerade # Nat::Hard (port-preserving symmetric) + # or: + oif "ix" masquerade random # Nat::Hardest (random per flow) } } ``` -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 +With `Nat::Hard`, the kernel keeps the internal source port when it is +free, which makes hole-punching succeed when peers exchange the observed +port. With `Nat::Hardest`, the `random` flag assigns a fresh port per +conntrack entry, so the peer cannot predict the mapped port from a STUN probe. ## nftables pitfalls @@ -198,26 +208,24 @@ directions. ## NatConfig architecture -The `Nat` enum provides named presets. Each preset expands via -`Nat::to_config()` to a `NatConfig` struct that drives rule generation: +The `Nat` enum provides named presets. Each preset converts into an +`Option` via `impl From` that drives rule generation: ```rust pub struct NatConfig { - pub mapping: NatMapping, // EIM or EDM - pub filtering: NatFiltering, // EIF or APDF - pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established + pub mapping: NatMapping, // EIM or EDM + pub filtering: NatFiltering, // EIF, ADF, or APDF + pub port_preservation: PortPreservation, // Preserve or Random + pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established + pub hairpin: bool, // loopback NAT } ``` -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. +The `generate_nat_rules()` function in `nft.rs` builds nftables rules +from `NatConfig` alone, without matching on `Nat` variants. Users can +either use the named presets (`router.nat(Nat::Easy)`) or build custom +configurations with arbitrary mapping, filtering, and port-preservation +combinations. ## NPTv6 implementation notes diff --git a/docs/reference/ipv6.md b/docs/reference/ipv6.md index 9fe73d8..692d851 100644 --- a/docs/reference/ipv6.md +++ b/docs/reference/ipv6.md @@ -141,7 +141,7 @@ 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 + .nat(Nat::Easiest) // swap NAT type, keep everything else .build().await?; ``` @@ -258,7 +258,7 @@ connectivity regressions that single-topology tests miss. ```rust let home = lab.add_router("home") .preset(RouterPreset::Home) - .nat(Nat::FullCone) + .nat(Nat::Easiest) .build().await?; let alice = lab.add_device("alice").uplink(home.id()).build().await?; diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index a681993..b868684 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -37,12 +37,12 @@ two VPN hops. ```rust // VPN exit node (NATs all clients behind server IP) let vpn_exit = lab.add_router("vpn-exit") - .nat(Nat::Home) + .nat(Nat::Easy) .mtu(1420) // WireGuard overhead .build().await?; // Before VPN: device on home network -let home = lab.add_router("home").nat(Nat::Home).build().await?; +let home = lab.add_router("home").nat(Nat::Easy).build().await?; let device = lab.add_device("client").uplink(home.id()).build().await?; // Connect VPN: device moves to VPN router, gets new IP @@ -121,13 +121,13 @@ NAT mapping that the peer's probe can traverse. ```rust // 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?; +let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; +let nat_b = lab.add_router("nat-b").nat(Nat::Easy).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?; +let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; +let nat_b = lab.add_router("nat-b").nat(Nat::Hardest).build().await?; // Assert: falls back to relay (TURN/DERP) ``` @@ -138,10 +138,10 @@ Port forwarding (UPnP) only works on the home router, not the CGNAT. Hole punching is more timing-sensitive. ```rust -let cgnat = lab.add_router("cgnat").nat(Nat::Cgnat).build().await?; +let cgnat = lab.add_router("cgnat").nat(Nat::Easiest).build().await?; let home = lab.add_router("home") .upstream(cgnat.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build().await?; let device = lab.add_device("client").uplink(home.id()).build().await?; ``` @@ -183,8 +183,8 @@ during the transition while the cellular radio attaches and the new address is assigned. ```rust -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 wifi_router = lab.add_router("wifi").nat(Nat::Easy).build().await?; +let cell_router = lab.add_router("cell").nat(Nat::Easiest).build().await?; let device = lab.add_device("phone") .iface("eth0", wifi_router.id()) @@ -210,7 +210,7 @@ through: UDP direct -> UDP relay (TURN) -> TCP relay -> TLS/TCP relay on 443. ```rust let corp = lab.add_router("corp") - .nat(Nat::Corporate) + .nat(Nat::Hardest) .firewall(Firewall::Corporate) // TCP 80,443 + UDP 53 only .build().await?; @@ -235,7 +235,7 @@ calls, each direction is limited by the sender's upload. ```rust // 20 Mbps down, 2 Mbps up (10:1 ratio) let router = lab.add_router("isp") - .nat(Nat::Home) + .nat(Nat::Easy) .downlink_condition(LinkCondition::Manual(LinkLimits { rate_kbit: 20_000, ..Default::default() @@ -265,7 +265,7 @@ connections skip NAT traversal entirely if both peers have public v6 addresses. ```rust let router = lab.add_router("dual") .ip_support(IpSupport::DualStack) - .nat(Nat::Home) + .nat(Nat::Easy) .build().await?; ``` @@ -370,8 +370,8 @@ for _ in 0..3 { | VPN split tunnel | Two interfaces on different routers + `set_default_route` | | WiFi to cellular | `iface.replug()` + `iface.set_condition()` | | Network goes down briefly | `iface.link_down()`, sleep, `iface.link_up()` | -| Cone NAT | `Nat::Home` | -| Symmetric NAT | `Nat::Corporate` | +| Cone NAT | `Nat::Easy` | +| Symmetric NAT | `Nat::Hardest` | | Double NAT / CGNAT | Chain routers: `home.upstream(cgnat.id())` | | Corporate UDP block | `Firewall::Corporate` on router | | Captive portal | Router with no upstream | diff --git a/patchbay/src/core.rs b/patchbay/src/core.rs index 479e504..05ed022 100644 --- a/patchbay/src/core.rs +++ b/patchbay/src/core.rs @@ -14,7 +14,7 @@ use ipnet::{Ipv4Net, Ipv6Net}; use crate::{ nft::nptv6_wan_prefix, wiring::{add_host, link_local_from_seed, seed2, seed3}, - Firewall, IpSupport, Ipv6ProvisioningMode, LinkCondition, Nat, NatConfig, NatV6Mode, + Firewall, IpSupport, Ipv6ProvisioningMode, LinkCondition, NatConfig, NatV6Mode, }; pub(crate) const RA_DEFAULT_ENABLED: bool = true; @@ -75,8 +75,8 @@ pub(crate) enum DownstreamPool { /// Configures per-router NAT and downstream behavior. #[derive(Clone, Debug)] pub(crate) struct RouterConfig { - /// Selects router NAT behavior. Use [`Nat::Custom`] for a custom config. - pub nat: Nat, + /// NAT configuration. `None` disables NAT entirely. + pub nat: Option, /// Selects which pool to allocate downstream subnets from. pub downstream_pool: DownstreamPool, /// Selects router IPv6 NAT behavior. @@ -98,10 +98,9 @@ pub(crate) struct RouterConfig { } impl RouterConfig { - /// Returns the effective NAT config by expanding the preset (or returning - /// the custom config). Returns `None` for `Nat::None`. + /// Returns the effective NAT config, or `None` if NAT is disabled. pub(crate) fn effective_nat_config(&self) -> Option { - self.nat.to_config() + self.nat } } @@ -707,7 +706,11 @@ impl NetworkCore { } /// Stores an updated NAT mode on the router record. - pub(crate) fn set_router_nat_mode(&mut self, id: NodeId, mode: Nat) -> Result<()> { + pub(crate) fn set_router_nat_mode( + &mut self, + id: NodeId, + mode: Option, + ) -> Result<()> { let router = self.routers.get_mut(&id).context("unknown router id")?; router.cfg.nat = mode; Ok(()) @@ -756,7 +759,7 @@ impl NetworkCore { pub(crate) fn add_router( &mut self, name: &str, - nat: Nat, + nat: Option, downstream_pool: DownstreamPool, region: Option>, ip_support: IpSupport, diff --git a/patchbay/src/event.rs b/patchbay/src/event.rs index 08b9771..1d8596f 100644 --- a/patchbay/src/event.rs +++ b/patchbay/src/event.rs @@ -13,7 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{ firewall::Firewall, lab::LinkCondition, - nat::{IpSupport, Nat, NatV6Mode}, + nat::{IpSupport, NatConfig, NatV6Mode}, }; // ───────────────────────────────────────────── @@ -129,8 +129,8 @@ pub enum LabEventKind { NatChanged { /// Router name. router: String, - /// New NAT configuration. - nat: Nat, + /// New NAT configuration. `None` means NAT is disabled. + nat: Option, }, /// Router IPv6 NAT mode changed. NatV6Changed { @@ -349,8 +349,8 @@ pub struct RouterState { pub ns: String, /// Region tag. pub region: Option, - /// NAT configuration. - pub nat: Nat, + /// NAT configuration. `None` means NAT is disabled. + pub nat: Option, /// IPv6 NAT mode. pub nat_v6: NatV6Mode, /// Firewall configuration. @@ -798,7 +798,7 @@ mod tests { state: Box::new(RouterState { ns: "ns-r1".into(), region: None, - nat: Nat::Home, + nat: crate::Nat::Easy.into(), nat_v6: NatV6Mode::None, firewall: Firewall::None, ip_support: IpSupport::V4Only, @@ -838,7 +838,7 @@ mod tests { }, LabEventKind::NatChanged { router: "r1".into(), - nat: Nat::Corporate, + nat: crate::Nat::Hardest.into(), }, LabEventKind::DeviceRemoved { name: "d1".into() }, LabEventKind::RouterRemoved { name: "r1".into() }, @@ -888,7 +888,7 @@ mod tests { state: Box::new(RouterState { ns: "ns-r1".into(), region: None, - nat: Nat::Home, + nat: crate::Nat::Easy.into(), nat_v6: NatV6Mode::None, firewall: Firewall::None, ip_support: IpSupport::V4Only, diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 3a2a4f5..17e0682 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -1073,7 +1073,7 @@ impl Lab { name: name.to_string(), region: None, upstream: None, - nat: Nat::None, + nat: None, ip_support: IpSupport::V4Only, nat_v6: NatV6Mode::None, downstream_pool: None, @@ -1169,11 +1169,11 @@ impl Lab { } let idx = inner.alloc_region_idx()?; - // Region router: Nat::None, public downstream, no region tag (it IS the region). + // Region router: no NAT, public downstream, no region tag (it IS the region). // DualStack so it can forward v6 traffic from sub-routers. let id = inner.add_router( ®ion_router_name, - Nat::None, + None, DownstreamPool::Public, None, IpSupport::DualStack, diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index 45ec98d..5865cf0 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -177,7 +177,7 @@ //! dev.set_default_route("eth0").await?; //! dev.iface("wlan0").unwrap().link_down().await?; //! dev.iface("wlan0").unwrap().link_up().await?; -//! router.set_nat_mode(Nat::Corporate).await?; +//! router.set_nat_mode(Nat::Hardest).await?; //! router.flush_nat_state().await?; //! # Ok(()) //! # } diff --git a/patchbay/src/nat.rs b/patchbay/src/nat.rs index 65364ae..6a6a318 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -2,13 +2,19 @@ use serde::{Deserialize, Serialize}; -/// NAT behavior preset for common real-world equipment. +/// NAT behavior preset for common deployment shapes. /// -/// Abbreviations used in variant docs: -/// - EIM: Endpoint-Independent Mapping (same external port for all destinations) -/// - EDM: Endpoint-Dependent Mapping (different port per destination, "symmetric") -/// - EIF: Endpoint-Independent Filtering (any host can reach the mapped port) -/// - APDF: Address-and-Port-Dependent Filtering (only contacted host:port can reply) +/// The variants form a gradient of hole-punching difficulty. `Easiest` +/// and `Easy` are hole-punchable with standard UDP hole-punching. +/// `Hard` is hole-punchable with port prediction. `Hardest` practically +/// requires a relay like TURN. +/// +/// Abbreviations in the doc comments: +/// - EIM: Endpoint-Independent Mapping (RFC 4787 §4.1). +/// - EDM: Endpoint-Dependent Mapping (RFC 4787 §4.1, "symmetric"). +/// - EIF: Endpoint-Independent Filtering (RFC 4787 §5). +/// - ADF: Address-Dependent Filtering (RFC 4787 §5). +/// - APDF: Address-and-Port-Dependent Filtering (RFC 4787 §5). #[derive( Clone, Copy, @@ -23,70 +29,61 @@ use serde::{Deserialize, Serialize}; )] #[serde(rename_all = "kebab-case")] pub enum Nat { - /// No NAT - addresses are publicly routable. + /// No NAT. Addresses are publicly routable. /// - /// Use for datacenter racks, cloud VMs with elastic IPs, - /// or any host that needs a stable public address. + /// Use for datacenter racks, cloud VMs with elastic IPs, or any host + /// that needs a stable public address. #[default] None, - /// Home router - the most common consumer NAT. - /// - /// EIM + APDF. Port-preserving. No hairpin. UDP timeout 300s. - /// This is what Linux `snat to ` produces. - /// - /// Observed on: FritzBox, Unifi (default), TP-Link Archer, ASUS RT-AX, - /// OpenWRT default masquerade. + /// Full cone NAT. Once an internal endpoint has sent one outbound + /// packet, any external host can reach the mapped external port. /// - /// Hole-punching works with simultaneous open (both sides must send). - /// This is the RFC 4787 REQ-1 compliant "port-restricted cone" NAT. - Home, + /// RFC 4787: EIM plus EIF. Port-preserving. RFC 3489 calls this + /// "Full Cone". Deployed on older consumer routers, RFC 6888 + /// compliant fiber ISP CGNAT, and routers running the + /// `netfilter-full-cone-nat` kernel module. Hole-punching is not + /// needed because peers can reach the mapped port directly. + Easiest, - /// Corporate firewall - symmetric NAT. - /// - /// EDM + APDF. Random ports. No hairpin. UDP timeout 120s. - /// Produces a different external port per (dst_ip, dst_port) 4-tuple. - /// - /// Observed on: Cisco ASA (PAT), Palo Alto NGFW (DIPP), Fortinet - /// FortiGate, Juniper SRX. AWS/Azure/GCP NAT gateways behave identically. + /// Port-restricted cone NAT. The external port stays the same for + /// all destinations, but only peers the internal endpoint has + /// contacted can reply. /// - /// Hole-punching is impossible without relay (TURN/DERP). - Corporate, + /// RFC 4787: EIM plus APDF. Port-preserving. RFC 3489 calls this + /// "Port-Restricted Cone". This is the default behavior of almost + /// every home router: FritzBox, Unifi, ASUS, TP-Link, OpenWRT, and + /// Linux masquerade without the `random` flag. Standard UDP + /// hole-punching succeeds here. + Easy, - /// Carrier-grade NAT per RFC 6888. - /// - /// EIM + EIF. Port-preserving. No hairpin. UDP timeout 300s. - /// Applied on the ISP/IX-facing interface (stacks with home NAT). - /// - /// Observed on: A10 Thunder CGN, Cisco ASR CGNAT, Juniper MX MS-MPC. - /// Mobile carriers (T-Mobile, Vodafone, O2) use this for LTE/5G subscribers. - /// RFC 6888 mandates EIM to preserve P2P traversal at the ISP layer. - Cgnat, - - /// Cloud NAT gateway - symmetric NAT with randomized ports. - /// - /// EDM + APDF. Random ports. No hairpin. UDP timeout 350s. + /// Symmetric NAT with port preservation. /// - /// Observed on: AWS NAT Gateway, Azure NAT Gateway, GCP Cloud NAT - /// (default dynamic port allocation mode). + /// RFC 4787: EDM plus APDF, with port preservation. Each + /// destination sees a different mapping, but the external port + /// still matches the internal source port when the port is free. + /// RFC 3489 has no term for this because it lumps all EDM as + /// "Symmetric". The peer-to-peer literature calls this SYMPP. /// - /// Functionally identical to Corporate but with longer timeouts - /// matching documented cloud provider behavior. - CloudNat, + /// Deployed on some stateful firewalls, CGNAT that uses Port Block + /// Allocation (RFC 7753), and many mobile carriers. Hole-punching + /// succeeds when the port-prediction technique applies. + Hard, - /// Full cone - most permissive NAT for testing. + /// Symmetric NAT with random ports. /// - /// EIM + EIF. Port-preserving. Hairpin on. UDP timeout 300s. - /// Any external host can send to the mapped port after first outbound packet. + /// RFC 4787: EDM plus APDF, with random port allocation. Each + /// destination sees a fresh, unpredictable external port. RFC 3489 + /// calls this "Symmetric". /// - /// Observed on: older FritzBox firmware, some CGNAT with full-cone policy. - /// Hole-punching always succeeds. - FullCone, + /// Deployed on enterprise firewalls (Cisco ASA, Palo Alto, Fortinet, + /// Juniper), AWS, Azure, and GCP NAT gateways, and hardened mobile + /// CGNAT. Hole-punching practically fails: peers need a relay. + Hardest, - /// Custom NAT configuration built from [`NatConfig`]. + /// Fully custom NAT configuration. /// - /// Use this when the named presets don't match your scenario. - /// The router gets private addressing (like [`Nat::Home`]). + /// Use this when the named presets do not cover the scenario. /// /// # Example /// ```no_run @@ -95,7 +92,7 @@ pub enum Nat { /// NatConfig::builder() /// .mapping(NatMapping::EndpointIndependent) /// .filtering(NatFiltering::EndpointIndependent) - /// .udp_stream_timeout(120) + /// .hairpin(true) /// .build(), /// ); /// ``` @@ -109,39 +106,112 @@ impl From for Nat { } } -/// NAT mapping behavior per RFC 4787 Section 4.1. +impl From for Option { + fn from(nat: Nat) -> Self { + match nat { + Nat::None => None, + Nat::Easiest => Some(NatConfig { + mapping: NatMapping::EndpointIndependent, + filtering: NatFiltering::EndpointIndependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }), + Nat::Easy => Some(NatConfig { + mapping: NatMapping::EndpointIndependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }), + Nat::Hard => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }), + Nat::Hardest => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Random, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }), + Nat::Custom(config) => Some(config), + } + } +} + +/// NAT mapping behavior per RFC 4787 §4.1. /// -/// Controls how the NAT assigns external ports when translating outbound packets. +/// Controls how the NAT assigns external ports when translating outbound +/// packets. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NatMapping { /// Same external port for all destinations (EIM). /// - /// Port-preserving: the NAT reuses the internal source port when free. - /// nftables: `snat to `. + /// The NAT reuses one external port for every destination from a given + /// internal source. nftables: `snat to ` with a fullcone map. EndpointIndependent, - /// Different external port per destination IP+port (symmetric/EDM). + /// Different external port per destination IP and port (EDM, "symmetric"). /// - /// Port randomized per 4-tuple. nftables: `masquerade random,fully-random`. + /// The NAT assigns a fresh mapping per 4-tuple. Whether the new port + /// is predictable depends on [`PortPreservation`]. EndpointDependent, } -/// NAT filtering behavior per RFC 4787 Section 5. +/// NAT filtering behavior per RFC 4787 §5. /// -/// Controls which inbound packets the NAT allows through to the internal host. +/// Controls which inbound packets the NAT allows through to the internal +/// host after an outbound mapping has been established. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NatFiltering { - /// Any external host can send to the mapped port (full cone). + /// Any external host can send to the mapped port (EIF, "full cone"). /// /// nftables: fullcone DNAT map in prerouting. EndpointIndependent, - /// Only the exact (IP, port) the internal endpoint contacted can reply. + /// Only external hosts the internal endpoint has contacted can reply, + /// but any port on those hosts is allowed (ADF, "restricted cone"). + /// + /// RFC 3489 calls this "Restricted Cone". Less common than APDF but + /// documented on some consumer routers. + AddressDependent, + /// Only the exact (IP, port) the internal endpoint contacted can reply + /// (APDF, "port-restricted cone"). /// /// nftables: conntrack-only (no prerouting DNAT). AddressAndPortDependent, } +/// Port allocation behavior for the NAT. +/// +/// Orthogonal to mapping and filtering per RFC 4787 §4.2 (port preservation). +// +// Gap: sequential port allocation (deployed in some older SOHO boxes and in +// Port-Block-Allocated CGNAT per RFC 7753) is not modeled. Hole-punching +// literature treats it as a distinct class from `Preserve` because ports +// are predictable across flows in a deterministic way. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PortPreservation { + /// Reuse the internal source port for the external port when the port + /// is free. Fall back to a fresh port on collision. + /// + /// This is the Linux conntrack default for `masquerade` without flags. + /// Combined with `EndpointDependent` mapping this produces a symmetric + /// NAT that port-prediction techniques can still traverse. + #[default] + Preserve, + /// Allocate a random external port. + /// + /// Combined with `EndpointDependent` mapping this makes hole-punching + /// practically impossible. + Random, +} + /// Conntrack timeout configuration for a NAT profile. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConntrackTimeouts { @@ -163,17 +233,16 @@ impl Default for ConntrackTimeouts { } } -/// Expanded NAT configuration produced from a [`Nat`] preset or the builder API. +/// Expanded NAT configuration produced from a [`Nat`] preset or the builder. /// -/// This carries all parameters needed to generate nftables rules and -/// conntrack settings for a router's NAT. The presets ([`Nat::Home`], -/// [`Nat::Corporate`], etc.) each expand to a specific `NatConfig` via -/// [`Nat::to_config`]. Custom configurations can be built directly. +/// Carries all parameters needed to generate nftables rules and conntrack +/// settings for a router's NAT. Each preset ([`Nat::Easy`], [`Nat::Hard`], +/// etc.) expands to a specific `NatConfig` via [`From`] for +/// `Option`. /// /// # Example /// ``` /// # use patchbay::{NatConfig, NatMapping, NatFiltering}; -/// // A home router with shorter UDP timeouts: /// let cfg = NatConfig::builder() /// .mapping(NatMapping::EndpointIndependent) /// .filtering(NatFiltering::AddressAndPortDependent) @@ -186,6 +255,9 @@ pub struct NatConfig { pub mapping: NatMapping, /// Which inbound packets are forwarded. pub filtering: NatFiltering, + /// How external ports are allocated. + #[serde(default)] + pub port_preservation: PortPreservation, /// Conntrack timeout settings. pub timeouts: ConntrackTimeouts, /// Whether LAN devices can reach each other via the router's public IP. @@ -201,11 +273,13 @@ impl NatConfig { /// Builder for [`NatConfig`]. /// -/// Defaults to EIM + APDF with standard home-router timeouts. +/// Defaults to EIM + APDF with port preservation and standard home-router +/// timeouts. #[derive(Clone, Debug)] pub struct NatConfigBuilder { mapping: NatMapping, filtering: NatFiltering, + port_preservation: PortPreservation, timeouts: ConntrackTimeouts, hairpin: bool, } @@ -215,6 +289,7 @@ impl Default for NatConfigBuilder { Self { mapping: NatMapping::EndpointIndependent, filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, timeouts: ConntrackTimeouts::default(), hairpin: false, } @@ -234,6 +309,12 @@ impl NatConfigBuilder { self } + /// Sets the port allocation behavior. + pub fn port_preservation(mut self, pp: PortPreservation) -> Self { + self.port_preservation = pp; + self + } + /// Sets the UDP single-packet timeout (seconds). Default: 30. pub fn udp_timeout(mut self, secs: u32) -> Self { self.timeouts.udp = secs; @@ -266,79 +347,13 @@ impl NatConfigBuilder { NatConfig { mapping: self.mapping, filtering: self.filtering, + port_preservation: self.port_preservation, timeouts: self.timeouts, hairpin: self.hairpin, } } } -impl Nat { - /// Expands a preset into its full [`NatConfig`]. - /// - /// Returns `None` for [`Nat::None`] (no NAT applied). - pub fn to_config(self) -> Option { - match self { - Nat::None => None, - Nat::Cgnat => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::EndpointIndependent, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 300, - tcp_established: 7200, - }, - hairpin: false, - }), - Nat::Home => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::AddressAndPortDependent, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 300, - tcp_established: 7200, - }, - hairpin: false, - }), - Nat::FullCone => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::EndpointIndependent, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 300, - tcp_established: 7200, - }, - hairpin: true, - }), - Nat::Corporate => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 120, - tcp_established: 3600, - }, - hairpin: false, - }), - // CloudNat and Corporate use the same nftables rules (masquerade random) - // and the same mapping/filtering (EDM + APDF). The only difference is - // timeout tuning: cloud NAT gateways (AWS, Azure, GCP) use longer UDP - // stream timeouts (~350s) than corporate firewalls (~120s). - // See: https://tailscale.com/blog/nat-traversal-improvements-pt-2-cloud-environments - Nat::CloudNat => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 350, - tcp_established: 3600, - }, - hairpin: false, - }), - Nat::Custom(config) => Some(config), - } - } -} - /// IPv6 NAT mode for a router. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] diff --git a/patchbay/src/nft.rs b/patchbay/src/nft.rs index 8938859..303915b 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -7,8 +7,8 @@ use ipnet::Ipv6Net; use tracing::{debug, trace}; use crate::{ - core::RouterConfig, netns, qdisc, wiring::set_sysctl_root, ConntrackTimeouts, LinkCondition, - NatConfig, NatFiltering, NatMapping, NatV6Mode, + core::RouterConfig, nat::PortPreservation, netns, qdisc, wiring::set_sysctl_root, + ConntrackTimeouts, LinkCondition, NatConfig, NatFiltering, NatMapping, NatV6Mode, }; /// Applies nftables rules (assumes caller is already in the target namespace). @@ -54,9 +54,11 @@ pub(crate) async fn run_nft_in(netns: &netns::NetnsManager, ns: &str, rules: &st /// Generates nftables rules for a [`NatConfig`]. /// /// EIM uses a dynamic fullcone map to preserve source ports across destinations. -/// EDM uses `masquerade random` for per-flow port randomization. +/// EDM uses `snat` with the port-preservation flag set by [`PortPreservation`]. /// EIF adds unconditional fullcone DNAT in prerouting. -/// APDF adds a forward filter that only allows established/related flows. +/// ADF and APDF add a forward filter that only allows established/related flows. +/// ADF additionally permits packets from any port on a previously-contacted +/// external address. fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String { let use_fullcone_map = cfg.mapping == NatMapping::EndpointIndependent; let hairpin = cfg.hairpin; @@ -73,7 +75,7 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String }; // Prerouting: for EIM, DNAT via fullcone map so inbound UDP reaches - // the correct internal host. For EDM, an empty prerouting chain is + // the correct internal host. For EDM, an empty prerouting chain is // still needed for conntrack reverse-NAT on reply packets. // // With hairpin: match on `ip daddr ` instead of `iif ""` so @@ -98,9 +100,10 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String String::new() }; - // Postrouting: EIM uses snat + fullcone map update. EDM uses masquerade random. - // With hairpin: masquerade DNAT'd packets so the return path goes through - // the router (otherwise the LAN peer replies directly, confusing conntrack). + // Postrouting: EIM uses snat + fullcone map update. EDM uses snat with a + // per-preservation flag. With hairpin: masquerade DNAT'd packets so the + // return path goes through the router (otherwise the LAN peer replies + // directly, confusing conntrack). let hairpin_masq = if hairpin { " ct status dnat masquerade\n".to_string() } else { @@ -116,18 +119,38 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String ip = wan_ip, ) } else { + // EDM with Preserve: masquerade (Linux keeps the source port when free). + // EDM with Random: masquerade random (fresh port per flow). + let masq_flag = match cfg.port_preservation { + PortPreservation::Preserve => "", + PortPreservation::Random => " random", + }; format!( - r#"{hairpin} oif "{wan}" masquerade random"#, + r#"{hairpin} oif "{wan}" masquerade{flag}"#, hairpin = hairpin_masq, wan = wan_if, + flag = masq_flag, ) }; let postrouting_priority = if use_fullcone_map { "srcnat" } else { "100" }; - // APDF filter: only forward inbound packets matching existing conntrack flows. - let filter_table = if cfg.filtering == NatFiltering::AddressAndPortDependent { - format!( + // Filter table implements the filtering behavior on the WAN ingress path. + // + // APDF: `ct state established,related accept` is enough because conntrack + // only matches when source IP and source port exactly match the outbound + // flow. + // + // ADF: same conntrack accept plus a dynamic set of external IPs the + // internal side has contacted. Packets from any port on those IPs are + // allowed. The set is populated on the postrouting path; the forward + // chain consults it on inbound. + // + // EIF: no filter table; the prerouting fullcone DNAT delivers packets + // regardless of source. + let filter_table = match cfg.filtering { + NatFiltering::EndpointIndependent => String::new(), + NatFiltering::AddressAndPortDependent => format!( r#" table ip filter {{ chain forward {{ @@ -137,9 +160,29 @@ table ip filter {{ }} }}"#, wan = wan_if - ) - } else { - String::new() + ), + NatFiltering::AddressDependent => format!( + r#" +table ip filter {{ + set contacted {{ + type ipv4_addr + flags dynamic,timeout + timeout 300s + size 8192 + }} + chain forward {{ + type filter hook forward priority 0; policy accept; + iif "{wan}" ct state established,related accept + iif "{wan}" ip saddr @contacted accept + iif "{wan}" drop + }} + chain postrouting {{ + type filter hook postrouting priority 0; policy accept; + oif "{wan}" update @contacted {{ ip daddr }} + }} +}}"#, + wan = wan_if + ), }; format!( @@ -196,9 +239,7 @@ fn apply_conntrack_timeouts_from_config( /// Applies router NAT rules for the configured mode. /// -/// Uses the effective NAT config from the router's [`Nat`] variant. -/// Otherwise expands the [`Nat`] preset via [`Nat::to_config`]. -/// CGNAT and None are handled separately. +/// Uses the stored `Option` directly; `None` disables NAT. pub(crate) async fn apply_nat_for_router( netns: &netns::NetnsManager, ns: &str, diff --git a/patchbay/src/router.rs b/patchbay/src/router.rs index c3b9a99..5212adb 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -21,7 +21,10 @@ use crate::{ event::{LabEventKind, RouterState}, firewall::{Firewall, FirewallConfigBuilder}, lab::{Ipv6ProvisioningMode, LabInner, LinkCondition}, - nat::{IpSupport, Nat, NatV6Mode}, + nat::{ + ConntrackTimeouts, IpSupport, NatConfig, NatFiltering, NatMapping, NatV6Mode, + PortPreservation, + }, netlink::Netlink, nft::{ apply_firewall, apply_nat_for_router, apply_nat_v6, apply_or_remove_impair, @@ -241,8 +244,11 @@ impl Router { .flatten() } - /// Returns the NAT mode, or `None` if the router has been removed. - pub fn nat_mode(&self) -> Option { + /// Returns the effective NAT configuration for this router. + /// + /// Returns `None` if the router has been removed. The inner `Option` is + /// `None` when NAT is disabled (routes are passed through unmodified). + pub fn nat_config(&self) -> Option> { self.lab.with_router(self.id, |r| r.cfg.nat) } @@ -428,7 +434,8 @@ impl Router { /// # Errors /// /// Returns an error if the router has been removed or nftables commands fail. - pub async fn set_nat_mode(&self, mode: Nat) -> Result<()> { + pub async fn set_nat_mode>>(&self, mode: T) -> Result<()> { + let mode = mode.into(); let op = self .lab .with_router(self.id, |r| Arc::clone(&r.op)) @@ -692,11 +699,10 @@ impl Router { /// on the [`RouterBuilder`] override the preset's defaults, so you can start /// from a known configuration and adjust only what your test needs. /// -/// The ISP presets (`IspCgnat`, `IspV6`) cover both fixed-line and mobile -/// carriers. Most mobile networks (T-Mobile, Vodafone, AT&T) use the same -/// CGNAT or NAT64 infrastructure as their fixed-line counterparts, and -/// real-world measurements confirm that hole-punching succeeds on the -/// majority of them. +/// The ISP presets cover different shapes of carrier-grade NAT. `IspCgnat` +/// is the RFC 6888 compliant fixed-line case. `IspCgnatHard` covers +/// hardened symmetric CGNAT. `MobileCarrier` covers typical cellular +/// deployments. `IspV6` is the IPv6-only case with NAT64. /// /// # Example /// @@ -708,7 +714,21 @@ impl Router { /// // Override NAT while keeping the rest of the Home preset: /// let home = lab.add_router("home") /// .preset(RouterPreset::Home) -/// .nat(Nat::FullCone) +/// .nat(Nat::Easiest) +/// .build().await?; +/// ``` +/// +/// # Double NAT +/// +/// Double NAT (a home router behind ISP CGNAT, or a phone hotspot) is not +/// a single preset; it is a topology composed from two routers. Chain them +/// with [`upstream`](RouterBuilder::upstream): +/// +/// ```ignore +/// let isp = lab.add_router("isp").preset(RouterPreset::IspCgnat).build().await?; +/// let home = lab.add_router("home") +/// .preset(RouterPreset::Home) +/// .upstream(isp.id()) /// .build().await?; /// ``` #[derive(Clone, Copy, Debug)] @@ -744,19 +764,38 @@ pub enum RouterPreset { /// V4-only, public downstream pool. PublicV4, - /// ISP or mobile carrier with carrier-grade NAT. + /// ISP with RFC 6888 compliant carrier-grade NAT. /// - /// Models any provider that shares a pool of public IPv4 addresses - /// across subscribers via CGNAT: budget fiber, fixed-wireless, - /// satellite (Starlink), and dual-stack mobile carriers (Vodafone, O2, - /// AT&T). The CGNAT uses endpoint-independent mapping and filtering - /// per RFC 6888, so hole-punching works — inbound packets reach - /// mapped ports. No additional firewall beyond the NAT. IPv6 addresses - /// are globally routable. + /// Models a provider that shares a pool of public IPv4 addresses across + /// subscribers via CGNAT with endpoint-independent mapping and filtering + /// per RFC 6888. Typical of fixed-line fiber CGNAT, Starlink, and some + /// compliant mobile deployments. Standard hole-punching works. IPv6 + /// addresses are globally routable. /// /// Dual-stack, private downstream pool. IspCgnat, + /// ISP with hardened symmetric CGNAT. + /// + /// Models carrier-grade NAT that deploys symmetric (endpoint-dependent) + /// mapping with port preservation, common in Port-Block-Allocated CGNAT + /// (RFC 7753) and in some older fixed-line deployments. Hole-punching + /// succeeds only with port prediction. + /// + /// Dual-stack, private downstream pool. + IspCgnatHard, + + /// Mobile carrier on LTE or 5G. + /// + /// Models typical cellular deployments: symmetric NAT with port + /// preservation, aggressive UDP timeouts (around 60 seconds), and a + /// stateful firewall that blocks unsolicited inbound on both address + /// families. Matches the observed behavior of most European and North + /// American mobile networks in 2026. + /// + /// Dual-stack, private downstream pool. + MobileCarrier, + /// IPv6-only ISP or mobile carrier with NAT64. /// /// Models T-Mobile US, Jio, NTT Docomo, and other providers that run @@ -807,13 +846,75 @@ pub enum RouterPreset { } impl RouterPreset { - fn nat(self) -> Nat { + fn nat(self) -> Option { match self { - Self::Home => Nat::Home, - Self::Public | Self::PublicV4 | Self::IspV6 => Nat::None, - Self::IspCgnat => Nat::Cgnat, - Self::Corporate | Self::Hotel => Nat::Corporate, - Self::Cloud => Nat::CloudNat, + Self::Public | Self::PublicV4 | Self::IspV6 => None, + Self::Home => Some(NatConfig { + mapping: NatMapping::EndpointIndependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 300, + tcp_established: 7200, + }, + hairpin: false, + }), + Self::IspCgnat => Some(NatConfig { + mapping: NatMapping::EndpointIndependent, + filtering: NatFiltering::EndpointIndependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 300, + tcp_established: 7200, + }, + hairpin: false, + }), + Self::IspCgnatHard => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 180, + tcp_established: 3600, + }, + hairpin: false, + }), + Self::MobileCarrier => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Preserve, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 60, + tcp_established: 3600, + }, + hairpin: false, + }), + Self::Corporate | Self::Hotel => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Random, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 120, + tcp_established: 3600, + }, + hairpin: false, + }), + Self::Cloud => Some(NatConfig { + mapping: NatMapping::EndpointDependent, + filtering: NatFiltering::AddressAndPortDependent, + port_preservation: PortPreservation::Random, + timeouts: ConntrackTimeouts { + udp: 30, + udp_stream: 350, + tcp_established: 3600, + }, + hairpin: false, + }), } } @@ -826,8 +927,10 @@ impl RouterPreset { fn firewall(self) -> Firewall { match self { - Self::Home | Self::IspV6 => Firewall::BlockInbound, - Self::Public | Self::PublicV4 | Self::IspCgnat | Self::Cloud => Firewall::None, + Self::Home | Self::IspV6 | Self::MobileCarrier => Firewall::BlockInbound, + Self::Public | Self::PublicV4 | Self::IspCgnat | Self::IspCgnatHard | Self::Cloud => { + Firewall::None + } Self::Corporate => Firewall::Corporate, Self::Hotel => Firewall::CaptivePortal, } @@ -869,7 +972,7 @@ pub struct RouterBuilder { pub(crate) name: String, pub(crate) region: Option>, pub(crate) upstream: Option, - pub(crate) nat: Nat, + pub(crate) nat: Option, pub(crate) ip_support: IpSupport, pub(crate) nat_v6: NatV6Mode, pub(crate) downstream_pool: Option, @@ -898,7 +1001,7 @@ impl RouterBuilder { name: name.to_string(), region: None, upstream: None, - nat: Nat::None, + nat: None, ip_support: IpSupport::V4Only, nat_v6: NatV6Mode::None, downstream_pool: None, @@ -947,7 +1050,7 @@ impl RouterBuilder { /// // Home router with full-cone NAT instead of default port-restricted: /// lab.add_router("home") /// .preset(RouterPreset::Home) - /// .nat(Nat::FullCone) + /// .nat(Nat::Easiest) /// .build().await?; /// ``` pub fn preset(mut self, p: RouterPreset) -> Self { @@ -961,10 +1064,19 @@ impl RouterBuilder { self } - /// Sets the NAT mode. Defaults to [`Nat::None`] (no NAT, public addressing). - pub fn nat(mut self, mode: Nat) -> Self { + /// Sets the NAT mode. + /// + /// Accepts [`Nat`] presets, a [`NatConfig`], or `None` to disable NAT. + /// + /// # Example + /// ```ignore + /// lab.add_router("home").nat(Nat::Easy).build().await?; + /// lab.add_router("cfg").nat(NatConfig::builder().build()).build().await?; + /// lab.add_router("direct").nat(None).build().await?; + /// ``` + pub fn nat>>(mut self, mode: T) -> Self { if self.result.is_ok() { - self.nat = mode; + self.nat = mode.into(); } self } @@ -1097,7 +1209,7 @@ impl RouterBuilder { let (id, setup_data) = { let mut inner = self.inner.core.lock().unwrap(); let nat = self.nat; - let downstream_pool = self.downstream_pool.unwrap_or(if nat == Nat::None { + let downstream_pool = self.downstream_pool.unwrap_or(if nat.is_none() { DownstreamPool::Public } else { DownstreamPool::Private diff --git a/patchbay/src/tests/devtools.rs b/patchbay/src/tests/devtools.rs index f35070f..663bc2b 100644 --- a/patchbay/src/tests/devtools.rs +++ b/patchbay/src/tests/devtools.rs @@ -33,7 +33,7 @@ async fn simple_lab_for_e2e() -> Result<()> { let home = lab .add_router("home") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; diff --git a/patchbay/src/tests/dns.rs b/patchbay/src/tests/dns.rs index ded589e..ae23b6b 100644 --- a/patchbay/src/tests/dns.rs +++ b/patchbay/src/tests/dns.rs @@ -482,8 +482,8 @@ async fn hickory_resolve_relay_setup() -> Result<()> { dns.set_host("relay.test.", IpAddr::V4(relay_v4))?; dns.set_host("relay.test.", IpAddr::V6(relay_v6))?; - let nat1 = lab.add_router("nat1").nat(Nat::Home).build().await?; - let nat2 = lab.add_router("nat2").nat(Nat::Home).build().await?; + let nat1 = lab.add_router("nat1").nat(Nat::Easy).build().await?; + let nat2 = lab.add_router("nat2").nat(Nat::Easy).build().await?; let server = lab.add_device("server").uplink(nat1.id()).build().await?; let client = lab.add_device("client").uplink(nat2.id()).build().await?; diff --git a/patchbay/src/tests/firewall.rs b/patchbay/src/tests/firewall.rs index 31b9fbd..366d5e0 100644 --- a/patchbay/src/tests/firewall.rs +++ b/patchbay/src/tests/firewall.rs @@ -14,7 +14,7 @@ async fn corporate_blocks_udp() -> Result<()> { let corp = lab .add_router("corp") - .nat(Nat::Home) + .nat(Nat::Easy) .firewall(Firewall::Corporate) .build() .await?; @@ -56,7 +56,7 @@ async fn captive_portal_blocks_udp() -> Result<()> { let portal = lab .add_router("portal") - .nat(Nat::Home) + .nat(Nat::Easy) .firewall(Firewall::CaptivePortal) .build() .await?; @@ -96,7 +96,7 @@ async fn none_allows_all() -> Result<()> { let dc = lab.add_router("dc").build().await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; - let home = lab.add_router("home").nat(Nat::Home).build().await?; + let home = lab.add_router("home").nat(Nat::Easy).build().await?; let dev = lab .add_device("laptop") @@ -128,7 +128,7 @@ async fn custom_selective() -> Result<()> { let fw = lab .add_router("fw") - .nat(Nat::Home) + .nat(Nat::Easy) .firewall_custom(|f| f.allow_udp(&[5000]).block_tcp()) .build() .await?; @@ -276,7 +276,7 @@ async fn runtime_change() -> Result<()> { let dc = lab.add_router("dc").build().await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; - let home = lab.add_router("home").nat(Nat::Home).build().await?; + let home = lab.add_router("home").nat(Nat::Easy).build().await?; let dev = lab .add_device("laptop") diff --git a/patchbay/src/tests/hairpin.rs b/patchbay/src/tests/hairpin.rs index b1fb3ae..0dc741a 100644 --- a/patchbay/src/tests/hairpin.rs +++ b/patchbay/src/tests/hairpin.rs @@ -6,15 +6,26 @@ use super::*; -/// Two devices behind a FullCone router (hairpin=true). Device A creates a -/// mapping via a DC reflector, then device B sends to A's external addr:port. -/// With hairpin, the router DNAT's the packet to A and masquerades the return. +/// Two devices behind a full-cone router with hairpin enabled. Device A +/// creates a mapping via a DC reflector, then device B sends to A's external +/// addr:port. With hairpin, the router DNAT's the packet to A and masquerades +/// the return. #[tokio::test(flavor = "current_thread")] #[traced_test] async fn fullcone_allows() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let r = lab.add_router("r").nat(Nat::FullCone).build().await?; + let r = lab + .add_router("r") + .nat( + NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .filtering(NatFiltering::EndpointIndependent) + .hairpin(true) + .build(), + ) + .build() + .await?; let a = lab.add_device("a").iface("eth0", r.id()).build().await?; let b = lab.add_device("b").iface("eth0", r.id()).build().await?; @@ -56,7 +67,7 @@ async fn fullcone_allows() -> Result<()> { async fn home_nat_blocks() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let r = lab.add_router("r").nat(Nat::Home).build().await?; + let r = lab.add_router("r").nat(Nat::Easy).build().await?; let a = lab.add_device("a").iface("eth0", r.id()).build().await?; let b = lab.add_device("b").iface("eth0", r.id()).build().await?; diff --git a/patchbay/src/tests/holepunch.rs b/patchbay/src/tests/holepunch.rs index 2a32abf..0608fd8 100644 --- a/patchbay/src/tests/holepunch.rs +++ b/patchbay/src/tests/holepunch.rs @@ -13,7 +13,7 @@ use super::*; #[traced_test] async fn fullcone_holepunch() -> Result<()> { let lab = Lab::new().await?; - let nat_mode = Nat::FullCone; + let nat_mode = Nat::Easiest; let dc = lab.add_router("dc").build().await?; let nat1 = lab.add_router("nat1").nat(nat_mode).build().await?; let nat2 = lab.add_router("nat2").nat(nat_mode).build().await?; @@ -95,8 +95,8 @@ async fn home_nat_holepunch() -> Result<()> { info!("--- {label} ---"); let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let nat1 = lab.add_router("nat1").nat(Nat::Home).build().await?; - let nat2 = lab.add_router("nat2").nat(Nat::Home).build().await?; + let nat1 = lab.add_router("nat1").nat(Nat::Easy).build().await?; + let nat2 = lab.add_router("nat2").nat(Nat::Easy).build().await?; let stun = lab.add_device("stun").uplink(dc.id()).build().await?; let dev1 = lab.add_device("dev1").uplink(nat1.id()).build().await?; let dev2 = lab.add_device("dev2").uplink(nat2.id()).build().await?; diff --git a/patchbay/src/tests/iface.rs b/patchbay/src/tests/iface.rs index b8db791..dc18f6e 100644 --- a/patchbay/src/tests/iface.rs +++ b/patchbay/src/tests/iface.rs @@ -15,7 +15,7 @@ use super::*; async fn add_remove_runtime() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let home = lab.add_router("home").nat(Nat::Home).build().await?; + let home = lab.add_router("home").nat(Nat::Easy).build().await?; let dev = lab .add_device("dev") .iface("eth0", home.id()) @@ -309,7 +309,7 @@ async fn replug_to_different_subnet() -> Result<()> { let dc_b = lab .add_router("dc-b") .downstream_cidr("172.20.0.0/24".parse()?) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; diff --git a/patchbay/src/tests/ipv6.rs b/patchbay/src/tests/ipv6.rs index daecf20..1d65847 100644 --- a/patchbay/src/tests/ipv6.rs +++ b/patchbay/src/tests/ipv6.rs @@ -198,7 +198,7 @@ async fn dual_stack_home_nat_udp() -> Result<()> { // Home NAT router: dual-stack with NPTv6. let nat = lab .add_router("nat") - .nat(Nat::Home) + .nat(Nat::Easy) .nat_v6(NatV6Mode::Nptv6) .ip_support(IpSupport::DualStack) .build() diff --git a/patchbay/src/tests/lifecycle.rs b/patchbay/src/tests/lifecycle.rs index ef8d759..bd0af26 100644 --- a/patchbay/src/tests/lifecycle.rs +++ b/patchbay/src/tests/lifecycle.rs @@ -25,7 +25,7 @@ region = "eu" [[router]] name = "lan1" upstream = "isp1" -nat = "home" +nat = "easy" [device.dev1.eth0] gateway = "lan1" @@ -68,7 +68,7 @@ async fn custom_downstream_cidr() -> Result<()> { let dc = lab.add_router("dc").build().await?; let custom = lab .add_router("custom") - .nat(Nat::Home) + .nat(Nat::Easy) .downstream_cidr("172.30.99.0/24".parse()?) .build() .await?; diff --git a/patchbay/src/tests/link_condition.rs b/patchbay/src/tests/link_condition.rs index 72fdf3b..61b2c6c 100644 --- a/patchbay/src/tests/link_condition.rs +++ b/patchbay/src/tests/link_condition.rs @@ -330,7 +330,7 @@ async fn rate_multihop_bottleneck() -> Result<()> { let nat = lab .add_router("nat") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab @@ -651,7 +651,7 @@ async fn latency_multihop_chain() -> Result<()> { let nat = lab .add_router("nat") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab diff --git a/patchbay/src/tests/mod.rs b/patchbay/src/tests/mod.rs index e7431b7..5707d24 100644 --- a/patchbay/src/tests/mod.rs +++ b/patchbay/src/tests/mod.rs @@ -329,7 +329,7 @@ async fn build_nat_case( let upstream = match wiring { UplinkWiring::DirectIx => None, UplinkWiring::ViaPublicIsp => Some(lab.add_router("isp").build().await?), - UplinkWiring::ViaCgnatIsp => Some(lab.add_router("isp").nat(Nat::Cgnat).build().await?), + UplinkWiring::ViaCgnatIsp => Some(lab.add_router("isp").nat(Nat::Easiest).build().await?), }; let nat = { let mut rb = lab.add_router("nat").nat(nat_mode); @@ -431,7 +431,7 @@ async fn build_single_nat_case( let upstream = match wiring { UplinkWiring::DirectIx => None, UplinkWiring::ViaPublicIsp => Some(lab.add_router("isp").build().await?), - UplinkWiring::ViaCgnatIsp => Some(lab.add_router("isp").nat(Nat::Cgnat).build().await?), + UplinkWiring::ViaCgnatIsp => Some(lab.add_router("isp").nat(Nat::Easiest).build().await?), }; let nat = { let mut rb = lab.add_router("nat").nat(nat_mode); diff --git a/patchbay/src/tests/nat.rs b/patchbay/src/tests/nat.rs index 1f86bb8..c65bb20 100644 --- a/patchbay/src/tests/nat.rs +++ b/patchbay/src/tests/nat.rs @@ -8,12 +8,12 @@ use super::*; async fn cgnat_reflexive_ip() -> Result<()> { check_caps()?; let lab = Lab::new().await?; - let isp = lab.add_router("isp1").nat(Nat::Cgnat).build().await?; + let isp = lab.add_router("isp1").nat(Nat::Easiest).build().await?; let dc = lab.add_router("dc1").build().await?; let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; lab.add_device("dev1") @@ -45,17 +45,17 @@ async fn cgnat_shared_reflexive_ip() -> Result<()> { check_caps()?; let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?; + let isp = lab.add_router("isp").nat(Nat::Easiest).build().await?; let lan_provider = lab .add_router("lan-provider") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let lan_fetcher = lab .add_router("lan-fetcher") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; lab.add_device("provider") @@ -127,13 +127,13 @@ async fn matrix_connectivity_and_reflexive_ip() -> Result<()> { check_caps()?; let cases = [ (Nat::None, UplinkWiring::DirectIx), - (Nat::Cgnat, UplinkWiring::DirectIx), - (Nat::Home, UplinkWiring::DirectIx), - (Nat::Home, UplinkWiring::ViaPublicIsp), - (Nat::Home, UplinkWiring::ViaCgnatIsp), - (Nat::Corporate, UplinkWiring::DirectIx), - (Nat::Corporate, UplinkWiring::ViaPublicIsp), - (Nat::Corporate, UplinkWiring::ViaCgnatIsp), + (Nat::Easiest, UplinkWiring::DirectIx), + (Nat::Easy, UplinkWiring::DirectIx), + (Nat::Easy, UplinkWiring::ViaPublicIsp), + (Nat::Easy, UplinkWiring::ViaCgnatIsp), + (Nat::Hardest, UplinkWiring::DirectIx), + (Nat::Hardest, UplinkWiring::ViaPublicIsp), + (Nat::Hardest, UplinkWiring::ViaCgnatIsp), ]; let mut case_idx = 0u16; @@ -165,8 +165,8 @@ async fn private_isolation_public_reachable() -> Result<()> { check_caps()?; let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - 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?; + let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; + let nat_b = lab.add_router("nat-b").nat(Nat::Easy).build().await?; let relay = lab .add_device("relay") @@ -235,17 +235,21 @@ async fn wan_pool_selection() -> Result<()> { check_caps()?; let lab = Lab::new().await?; let isp_public = lab.add_router("isp-public").build().await?; - let isp_cgnat = lab.add_router("isp-cgnat").nat(Nat::Cgnat).build().await?; + let isp_cgnat = lab + .add_router("isp-cgnat") + .nat(Nat::Easiest) + .build() + .await?; let home_public = lab .add_router("home-public") .upstream(isp_public.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let home_cgnat = lab .add_router("home-cgnat") .upstream(isp_cgnat.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; @@ -328,7 +332,7 @@ async fn port_mapping_eim_stable() -> Result<()> { let mut failures = Vec::new(); for wiring in UplinkWiring::iter() { let result: Result<()> = async { - let (lab, ctx) = build_nat_case(Nat::Home, wiring, port_base).await?; + let (lab, ctx) = build_nat_case(Nat::Easy, wiring, port_base).await?; let dev = lab.device_by_name("dev").unwrap(); let o1 = dev.probe_udp_mapping(ctx.r_dc)?; let o2 = dev.probe_udp_mapping(ctx.r_ix)?; @@ -362,7 +366,7 @@ async fn port_mapping_edm_changes() -> Result<()> { let mut failures = Vec::new(); for wiring in UplinkWiring::iter() { let result: Result<()> = async { - let (lab, ctx) = build_nat_case(Nat::Corporate, wiring, port_base).await?; + let (lab, ctx) = build_nat_case(Nat::Hardest, wiring, port_base).await?; let dev = lab.device_by_name("dev").unwrap(); let o1 = dev.probe_udp_mapping(ctx.r_dc)?; let o2 = dev.probe_udp_mapping(ctx.r_ix)?; @@ -393,7 +397,7 @@ async fn port_mapping_edm_changes() -> Result<()> { async fn same_nat_shared_ip() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let nat = lab.add_router("nat").nat(Nat::Home).build().await?; + let nat = lab.add_router("nat").nat(Nat::Easy).build().await?; let dev_a = lab .add_device("dev-a") .iface("eth0", nat.id()) @@ -426,8 +430,8 @@ async fn same_nat_shared_ip() -> Result<()> { async fn different_nat_isolation() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - 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?; + let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; + let nat_b = lab.add_router("nat-b").nat(Nat::Easy).build().await?; let dev_a = lab .add_device("dev-a") .iface("eth0", nat_a.id()) @@ -484,7 +488,7 @@ async fn v6_masquerade() -> Result<()> { let home = lab .add_router("nat") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .ip_support(IpSupport::DualStack) .nat_v6(NatV6Mode::Masquerade) .build() @@ -528,7 +532,7 @@ async fn v6_no_translation() -> Result<()> { let home = lab .add_router("home") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .ip_support(IpSupport::DualStack) .nat_v6(NatV6Mode::None) .build() @@ -562,7 +566,7 @@ async fn fullcone_external_reachable() -> Result<()> { check_caps()?; let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let fc = lab.add_router("fc").nat(Nat::FullCone).build().await?; + let fc = lab.add_router("fc").nat(Nat::Easiest).build().await?; let dev = lab.add_device("dev").iface("eth0", fc.id()).build().await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -606,7 +610,7 @@ async fn cgnat_external_reachable() -> Result<()> { check_caps()?; let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?; + let isp = lab.add_router("isp").nat(Nat::Easiest).build().await?; // Device directly behind CGNAT (no Home NAT layer) to test CGNAT EIF in isolation. let dev = lab .add_device("dev") @@ -663,11 +667,11 @@ async fn cgnat_port_mapping_eim() -> Result<()> { let lab = Lab::new().await?; let dc1 = lab.add_router("dc1").build().await?; let dc2 = lab.add_router("dc2").build().await?; - let isp = lab.add_router("isp").nat(Nat::Cgnat).build().await?; + let isp = lab.add_router("isp").nat(Nat::Easiest).build().await?; let home = lab .add_router("home") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab @@ -710,7 +714,7 @@ async fn v6_masquerade_port_mapping() -> Result<()> { let nat = lab .add_router("nat") .ip_support(IpSupport::DualStack) - .nat(Nat::Home) + .nat(Nat::Easy) .nat_v6(NatV6Mode::Masquerade) .build() .await?; diff --git a/patchbay/src/tests/nat64.rs b/patchbay/src/tests/nat64.rs index 902d79d..3c9857f 100644 --- a/patchbay/src/tests/nat64.rs +++ b/patchbay/src/tests/nat64.rs @@ -18,7 +18,7 @@ async fn nat64_udp_v6_to_v4() -> Result<()> { let carrier = lab .add_router("carrier") .ip_support(IpSupport::DualStack) - .nat(Nat::Home) + .nat(Nat::Easy) .nat_v6(NatV6Mode::Nat64) .build() .await?; @@ -67,7 +67,7 @@ async fn nat64_tcp_v6_to_v4() -> Result<()> { let carrier = lab .add_router("carrier") .ip_support(IpSupport::DualStack) - .nat(Nat::Home) + .nat(Nat::Easy) .nat_v6(NatV6Mode::Nat64) .build() .await?; @@ -112,7 +112,7 @@ async fn nat64_preserves_native_v6() -> Result<()> { let carrier = lab .add_router("carrier") .ip_support(IpSupport::DualStack) - .nat(Nat::Home) + .nat(Nat::Easy) .nat_v6(NatV6Mode::Nat64) .build() .await?; diff --git a/patchbay/src/tests/nat_rebind.rs b/patchbay/src/tests/nat_rebind.rs index dd699e1..14f6ed0 100644 --- a/patchbay/src/tests/nat_rebind.rs +++ b/patchbay/src/tests/nat_rebind.rs @@ -15,8 +15,8 @@ use super::*; async fn mode_port_change() -> Result<()> { // Home→Corporate: port changes (EIM→EDM); Corporate→Home: port stabilises. let cases: &[(Nat, Nat, bool)] = &[ - (Nat::Home, Nat::Corporate, false), - (Nat::Corporate, Nat::Home, true), + (Nat::Easy, Nat::Hardest, false), + (Nat::Hardest, Nat::Easy, true), ]; let mut port_base = 16_800u16; let mut failures = Vec::new(); @@ -60,7 +60,7 @@ async fn mode_ip_change() -> Result<()> { // Home→None is omitted: with NAT=None, the device's private IP appears // as the packet source; the DC has no return route, so the UDP probe // times out rather than completing. - let cases: &[(Nat, Nat)] = &[(Nat::None, Nat::Home)]; + let cases: &[(Nat, Nat)] = &[(Nat::None, Nat::Easy)]; let mut port_base = 16_900u16; let mut failures = Vec::new(); for &(from, to) in cases { @@ -76,7 +76,7 @@ async fn mode_ip_change() -> Result<()> { test_utils::probe_udp(r_dc, Duration::from_millis(500), Some(bind)) })?; let expected = match to { - Nat::Home => IpAddr::V4(wan_ip), + Nat::Easy => IpAddr::V4(wan_ip), Nat::None => IpAddr::V4(ctx.dev_ip), _ => unreachable!(), }; @@ -111,7 +111,7 @@ async fn conntrack_flush() -> Result<()> { eprintln!("skipping nat_rebind_conntrack_flush: conntrack not found"); return Ok(()); } - let (lab, ctx) = build_nat_case(Nat::Corporate, UplinkWiring::DirectIx, 17_000).await?; + let (lab, ctx) = build_nat_case(Nat::Hardest, UplinkWiring::DirectIx, 17_000).await?; let nat_handle = lab.router_by_name("nat").context("missing nat")?; let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); let r_dc = ctx.r_dc; diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index fec75ca..899c98b 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -126,7 +126,7 @@ async fn preset_corporate_blocks_udp() -> Result<()> { Ok(()) } -/// Preset with override: Home preset with Nat::FullCone overrides only NAT. +/// Preset with override: Home preset with Nat::Easiest overrides only NAT. #[tokio::test(flavor = "current_thread")] #[traced_test] async fn preset_override() -> Result<()> { @@ -140,11 +140,11 @@ async fn preset_override() -> Result<()> { .await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; - // Home preset + FullCone NAT override. + // Home preset with full-cone NAT override. let home = lab .add_router("home") .preset(RouterPreset::Home) - .nat(Nat::FullCone) + .nat(Nat::Easiest) .build() .await?; @@ -176,6 +176,8 @@ async fn preset_recommended_ipv6_profiles() -> Result<()> { RouterPreset::Public, RouterPreset::PublicV4, RouterPreset::IspCgnat, + RouterPreset::IspCgnatHard, + RouterPreset::MobileCarrier, RouterPreset::IspV6, RouterPreset::Corporate, RouterPreset::Hotel, diff --git a/patchbay/src/tests/region.rs b/patchbay/src/tests/region.rs index 4ef6e6e..83e76b3 100644 --- a/patchbay/src/tests/region.rs +++ b/patchbay/src/tests/region.rs @@ -281,7 +281,7 @@ async fn mixed_nat_region() -> Result<()> { let home_eu = lab .add_router("home-eu") .region(&eu) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; diff --git a/patchbay/src/tests/route.rs b/patchbay/src/tests/route.rs index db08225..039c628 100644 --- a/patchbay/src/tests/route.rs +++ b/patchbay/src/tests/route.rs @@ -20,7 +20,7 @@ async fn switch_default_reflexive_ip() -> Result<()> { reflector, dc: _, _reflector_guard, - } = build_dual_nat_lab(Nat::Home, Nat::Corporate, 16_200).await?; + } = build_dual_nat_lab(Nat::Easy, Nat::Hardest, 16_200).await?; let wan_a = nat_a.uplink_ip().context("no uplink ip")?; let wan_b = nat_b.uplink_ip().context("no uplink ip")?; @@ -77,7 +77,7 @@ async fn switch_default_multiple_times() -> Result<()> { nat_b, reflector, _reflector_guard, - } = build_dual_nat_lab(Nat::Home, Nat::Home, 16_300).await?; + } = build_dual_nat_lab(Nat::Easy, Nat::Easy, 16_300).await?; let wan_a = nat_a.uplink_ip().context("no uplink ip")?; let wan_b = nat_b.uplink_ip().context("no uplink ip")?; @@ -115,7 +115,7 @@ async fn switch_default_tcp_roundtrip() -> Result<()> { nat_b: _, reflector: _, _reflector_guard, - } = build_dual_nat_lab(Nat::Home, Nat::Corporate, 16_400).await?; + } = build_dual_nat_lab(Nat::Easy, Nat::Hardest, 16_400).await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; @@ -143,8 +143,8 @@ async fn switch_default_tcp_roundtrip() -> Result<()> { async fn replug_iface_udp() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - 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?; + let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; + let nat_b = lab.add_router("nat-b").nat(Nat::Easy).build().await?; let dev = lab .add_device("dev") .iface("eth0", nat_a.id()) @@ -176,8 +176,8 @@ async fn replug_iface_udp() -> Result<()> { async fn replug_iface_reflexive_ip() -> Result<()> { let lab = Lab::new().await?; let dc = lab.add_router("dc").build().await?; - 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?; + let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; + let nat_b = lab.add_router("nat-b").nat(Nat::Easy).build().await?; let dev = lab .add_device("dev") .iface("eth0", nat_a.id()) @@ -228,7 +228,7 @@ async fn proc_net_route_shows_namespace_routes() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab diff --git a/patchbay/src/tests/smoke.rs b/patchbay/src/tests/smoke.rs index 7b68a11..67d0bc1 100644 --- a/patchbay/src/tests/smoke.rs +++ b/patchbay/src/tests/smoke.rs @@ -12,7 +12,7 @@ async fn ping_gateway() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab @@ -38,7 +38,7 @@ async fn udp_roundtrip() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab @@ -66,7 +66,7 @@ async fn tcp_roundtrip() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev = lab @@ -99,7 +99,7 @@ async fn ping_router_to_isp() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; @@ -136,10 +136,10 @@ async fn ping_through_nat_to_relay() -> Result<()> { let dc = lab.add_router("dc").build().await?; let lan_provider = lab .add_router("lan-provider") - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; - let lan_fetcher = lab.add_router("lan-fetcher").nat(Nat::Home).build().await?; + let lan_fetcher = lab.add_router("lan-fetcher").nat(Nat::Easy).build().await?; let relay = lab .add_device("relay") @@ -175,7 +175,7 @@ async fn ping_same_lan() -> Result<()> { let home = lab .add_router("home1") .upstream(isp.id()) - .nat(Nat::Home) + .nat(Nat::Easy) .build() .await?; let dev1 = lab diff --git a/patchbay/tests/vm_smoke.rs b/patchbay/tests/vm_smoke.rs index 8cac8ac..c7fc966 100644 --- a/patchbay/tests/vm_smoke.rs +++ b/patchbay/tests/vm_smoke.rs @@ -25,7 +25,7 @@ async fn tcp_through_nat() -> Result<()> { .await?; let dc = lab.add_router("dc").build().await?; - let home = lab.add_router("home").nat(Nat::Home).build().await?; + let home = lab.add_router("home").nat(Nat::Easy).build().await?; let server = lab .add_device("server") From 9644198c6140f4dea687012b95906fba4d3549b1 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 11:20:06 +0200 Subject: [PATCH 2/7] refactor: validate Nat invariants, fix presets, streamline docs Address review findings from the networking and Rust code-quality review passes: Type-system safety (builder-time invariants) - Move PortPreservation into NatMapping::EndpointDependent(_) so EIM + Random is structurally unrepresentable. - NatConfigBuilder::build() returns Result and rejects EDM + AddressDependent filtering and EDM + hairpin. Both combinations previously compiled and produced subtly wrong nftables. - NatConfig and ConntrackTimeouts are #[non_exhaustive]. Preset realism - RouterPreset::IspCgnat: EIM + EIF to EIM + APDF (S1). Published measurement data shows most RFC 6888 compliant CGNATs use APDF, not EIF. Nat::Easiest remains available for textbook full-cone tests. - RouterPreset::IspCgnat, IspCgnatSymmetric firewall changed from None to BlockInbound (S5). Matches Swisscom, Deutsche Telekom, Starlink. - Rename IspCgnatHard to IspCgnatSymmetric and drop the incorrect RFC 7753 citation (S2); RFC 7753 is a PCP extension, and the preset does not model Port Block Allocation. Empirical validation - port_mapping_edm_preserve_stable: confirms `masquerade` without the random flag preserves the internal source port across destinations on this kernel. Pins the Nat::Hard vs Nat::Hardest distinction against future kernel changes. - adf_allows_different_port_from_contacted_host and adf_drops_from_uncontacted_host: positive and negative coverage for the new ADF filtering branch. - preset_nat_snapshots: pins every RouterPreset's NAT mapping, filtering, port preservation, and UDP stream timeout. API polish - Router::nat_mode to Router::nat_config, return flattened to Option (B2); matches peer accessors. - Router::set_nat_mode to Router::set_nat (I4); matches set_firewall. - RouterPreset::nat() reuses Nat::*.to_config() and only overrides timeouts (I1), removing preset-literal duplication. - Nat::to_config() alias restored (I2) for discoverability. - effective_nat_config() removed (I6); callers read the field directly. - PortPreservation and NatConfigError re-exported from the crate root (B1). Wire format and runtime - RouterState.nat and LabEventKind::NatChanged.nat now carry Option; breaking change. - Runtime set_nat now deletes and re-creates the nat and filter tables instead of flushing (M9) so named sets from the previous mode do not bleed through. Docs and examples - Streamline the Nat enum doc comments: lead with a one-line summary, describe behavior and deployment, cite RFC 3489 and RFC 4787 without overloading on abbreviations. - Update README.md and docs/reference/toml-reference.md TOML examples and the NAT-modes table (B3). - Remove the now-implemented "Address-restricted cone" and "Hairpin" entries from the holepunching.md "Future work" list (M6). All 222 tests pass; cargo clippy --workspace is clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- docs/reference/holepunching.md | 8 +- docs/reference/toml-reference.md | 21 +- patchbay/src/core.rs | 7 - patchbay/src/lab.rs | 4 +- patchbay/src/lib.rs | 6 +- patchbay/src/nat.rs | 472 ++++++++++++++++++++----------- patchbay/src/nft.rs | 38 ++- patchbay/src/router.rs | 193 ++++++------- patchbay/src/tests/hairpin.rs | 11 +- patchbay/src/tests/nat.rs | 157 ++++++++++ patchbay/src/tests/nat_rebind.rs | 4 +- patchbay/src/tests/preset.rs | 64 ++++- plans/nat-breaking-changes.md | 190 +++++++++++++ 14 files changed, 862 insertions(+), 315 deletions(-) create mode 100644 plans/nat-breaking-changes.md diff --git a/README.md b/README.md index 4a8068e..c9c10db 100644 --- a/README.md +++ b/README.md @@ -319,7 +319,7 @@ region = "eu" [[router]] name = "home" -nat = "home" +nat = "easy" [device.laptop.eth0] gateway = "home" diff --git a/docs/reference/holepunching.md b/docs/reference/holepunching.md index 4db06de..2913479 100644 --- a/docs/reference/holepunching.md +++ b/docs/reference/holepunching.md @@ -27,7 +27,7 @@ simulates: | Preset | Mapping | Filtering | Port preservation | Hole-punch? | Real-world examples | |--------|---------|-----------|-------------------|-------------|---------------------| | `Nat::Easiest` | EIM | EIF | Preserve | Always | Older consumer routers, RFC 6888 compliant fiber CGNAT | -| `Nat::Easy` | EIM | APDF | Preserve | Yes, simultaneous open | FritzBox, Unifi, TP-Link, ASUS RT, OpenWRT | +| `Nat::Easy` | EIM | APDF | Preserve | Yes, via UDP hole-punching with simultaneous send | FritzBox, Unifi, TP-Link, ASUS RT, OpenWRT | | `Nat::Hard` | EDM | APDF | Preserve | Sometimes, with port prediction | PBA CGNAT, mobile carriers, some stateful firewalls | | `Nat::Hardest` | EDM | APDF | Random | Practically never | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway | @@ -267,10 +267,8 @@ 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. +- **Sequential port allocation** for Port-Block-Allocated CGNAT + simulation. diff --git a/docs/reference/toml-reference.md b/docs/reference/toml-reference.md index 8df9c35..2b83397 100644 --- a/docs/reference/toml-reference.md +++ b/docs/reference/toml-reference.md @@ -670,7 +670,7 @@ name = "dc" # A home NAT router (endpoint-independent mapping, port-restricted filtering) [[router]] name = "lan-client" -nat = "home" +nat = "easy" # A device with one interface behind the DC router [device.server.eth0] @@ -708,14 +708,17 @@ gateway = "dc" **NAT modes:** -| Value | Behavior | -|----------------|----------| -| (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. | +Each variant is a behavior preset. See the [`Nat`] documentation for the +RFC 3489 and RFC 4787 labels and the real-world deployments each models. + +| Value | Behavior | +|-------------|----------| +| (absent) | No NAT; device has a public IP on the upstream network. | +| `"none"` | Same as absent. | +| `"easiest"` | EIM+EIF: any external host can reach the mapped port (full cone). | +| `"easy"` | EIM+APDF: same external port for all destinations (port-restricted cone). Default of most home routers. | +| `"hard"` | EDM+APDF with port preservation: port-predictable symmetric NAT. | +| `"hardest"` | EDM+APDF with random ports: symmetric NAT that requires a relay. | **Region latency** can be added to introduce inter-router delays: diff --git a/patchbay/src/core.rs b/patchbay/src/core.rs index 05ed022..c1e7485 100644 --- a/patchbay/src/core.rs +++ b/patchbay/src/core.rs @@ -97,13 +97,6 @@ pub(crate) struct RouterConfig { pub ra_lifetime_secs: u64, } -impl RouterConfig { - /// Returns the effective NAT config, or `None` if NAT is disabled. - pub(crate) fn effective_nat_config(&self) -> Option { - self.nat - } -} - /// Parameters needed to (re-)configure NAT on a router. #[allow(dead_code)] pub(crate) struct RouterNatParams { diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 17e0682..1fad22c 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -60,8 +60,8 @@ fn region_base(idx: u8) -> Ipv4Addr { pub use crate::{ firewall::{Firewall, FirewallConfig, FirewallConfigBuilder}, nat::{ - ConntrackTimeouts, IpSupport, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, - NatV6Mode, + ConntrackTimeouts, IpSupport, Nat, NatConfig, NatConfigBuilder, NatConfigError, + NatFiltering, NatMapping, NatV6Mode, PortPreservation, }, }; diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index 5865cf0..935d499 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -177,7 +177,7 @@ //! dev.set_default_route("eth0").await?; //! dev.iface("wlan0").unwrap().link_down().await?; //! dev.iface("wlan0").unwrap().link_up().await?; -//! router.set_nat_mode(Nat::Hardest).await?; +//! router.set_nat(Nat::Hardest).await?; //! router.flush_nat_state().await?; //! # Ok(()) //! # } @@ -242,8 +242,8 @@ pub use ipnet::Ipv4Net; pub use lab::{ ConntrackTimeouts, DefaultRegions, Firewall, FirewallConfig, FirewallConfigBuilder, IpSupport, Ipv6DadMode, Ipv6Profile, Ipv6ProvisioningMode, Ix, Lab, LabBuilder, LinkCondition, - LinkDirection, LinkLimits, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, - NatV6Mode, OutDir, Region, RegionLink, TestGuard, + LinkDirection, LinkLimits, Nat, NatConfig, NatConfigBuilder, NatConfigError, NatFiltering, + NatMapping, NatV6Mode, OutDir, PortPreservation, Region, RegionLink, TestGuard, }; pub use metrics::MetricsBuilder; pub use router::{Router, RouterBuilder, RouterIface, RouterPreset}; diff --git a/patchbay/src/nat.rs b/patchbay/src/nat.rs index 6a6a318..3c4f7ad 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -2,19 +2,16 @@ use serde::{Deserialize, Serialize}; -/// NAT behavior preset for common deployment shapes. +/// NAT behavior for IPv4. /// -/// The variants form a gradient of hole-punching difficulty. `Easiest` -/// and `Easy` are hole-punchable with standard UDP hole-punching. -/// `Hard` is hole-punchable with port prediction. `Hardest` practically -/// requires a relay like TURN. +/// The variants form a gradient of hole-punching difficulty. `Easiest` and +/// `Easy` are reachable with standard UDP hole-punching. `Hard` is reachable +/// with port prediction. `Hardest` requires a relay like TURN. /// -/// Abbreviations in the doc comments: -/// - EIM: Endpoint-Independent Mapping (RFC 4787 §4.1). -/// - EDM: Endpoint-Dependent Mapping (RFC 4787 §4.1, "symmetric"). -/// - EIF: Endpoint-Independent Filtering (RFC 4787 §5). -/// - ADF: Address-Dependent Filtering (RFC 4787 §5). -/// - APDF: Address-and-Port-Dependent Filtering (RFC 4787 §5). +/// Each preset expands to a [`NatConfig`] via [`Nat::to_config`]. Timeouts +/// and hairpin behavior come from [`ConntrackTimeouts::default`] and +/// `hairpin: false`; for deployment-specific timeouts use +/// [`RouterPreset`](crate::RouterPreset) instead. #[derive( Clone, Copy, @@ -29,77 +26,74 @@ use serde::{Deserialize, Serialize}; )] #[serde(rename_all = "kebab-case")] pub enum Nat { - /// No NAT. Addresses are publicly routable. - /// - /// Use for datacenter racks, cloud VMs with elastic IPs, or any host - /// that needs a stable public address. + /// No NAT. The device has a publicly routable address. #[default] None, - /// Full cone NAT. Once an internal endpoint has sent one outbound - /// packet, any external host can reach the mapped external port. + /// Most permissive NAT. + /// + /// Any external host can reach the mapped port after the first outbound + /// packet. Typical of consumer routers with UPnP or static port + /// forwarding, older consumer NAT boxes, and the output of + /// `netfilter-full-cone-nat` style kernel modules. /// - /// RFC 4787: EIM plus EIF. Port-preserving. RFC 3489 calls this - /// "Full Cone". Deployed on older consumer routers, RFC 6888 - /// compliant fiber ISP CGNAT, and routers running the - /// `netfilter-full-cone-nat` kernel module. Hole-punching is not - /// needed because peers can reach the mapped port directly. + /// RFC 3489: Full Cone. + /// RFC 4787: Endpoint-Independent Mapping, Endpoint-Independent + /// Filtering. Easiest, - /// Port-restricted cone NAT. The external port stays the same for - /// all destinations, but only peers the internal endpoint has - /// contacted can reply. + /// Moderately restrictive NAT. + /// + /// The external mapping is stable across destinations, but inbound + /// packets are accepted only from the exact addresses and ports the + /// internal side has contacted. Standard UDP hole-punching succeeds. + /// Default behavior of every major home router (FritzBox, Unifi, ASUS, + /// TP-Link, OpenWRT, Linux masquerade) and of most RFC 6888 compliant + /// CGNAT. /// - /// RFC 4787: EIM plus APDF. Port-preserving. RFC 3489 calls this - /// "Port-Restricted Cone". This is the default behavior of almost - /// every home router: FritzBox, Unifi, ASUS, TP-Link, OpenWRT, and - /// Linux masquerade without the `random` flag. Standard UDP - /// hole-punching succeeds here. + /// RFC 3489: Port Restricted Cone. + /// RFC 4787: Endpoint-Independent Mapping, Address-and-Port-Dependent + /// Filtering. Easy, - /// Symmetric NAT with port preservation. + /// Restrictive NAT with a port-predictable mapping. /// - /// RFC 4787: EDM plus APDF, with port preservation. Each - /// destination sees a different mapping, but the external port + /// Each destination gets its own external mapping, but the external port /// still matches the internal source port when the port is free. - /// RFC 3489 has no term for this because it lumps all EDM as - /// "Symmetric". The peer-to-peer literature calls this SYMPP. + /// Hole-punching succeeds with port prediction. Typical of some stateful + /// firewalls, some CGNAT variants, and most mobile carriers. /// - /// Deployed on some stateful firewalls, CGNAT that uses Port Block - /// Allocation (RFC 7753), and many mobile carriers. Hole-punching - /// succeeds when the port-prediction technique applies. + /// RFC 4787: Endpoint-Dependent Mapping with port preservation, + /// Address-and-Port-Dependent Filtering. Hard, - /// Symmetric NAT with random ports. + /// Most restrictive NAT. /// - /// RFC 4787: EDM plus APDF, with random port allocation. Each - /// destination sees a fresh, unpredictable external port. RFC 3489 - /// calls this "Symmetric". + /// Each destination gets a fresh, random external port. Hole-punching + /// fails; peers need a relay. Typical of enterprise firewalls + /// (Cisco ASA, Palo Alto, Fortinet) and cloud NAT gateways (AWS, Azure, + /// GCP). /// - /// Deployed on enterprise firewalls (Cisco ASA, Palo Alto, Fortinet, - /// Juniper), AWS, Azure, and GCP NAT gateways, and hardened mobile - /// CGNAT. Hole-punching practically fails: peers need a relay. + /// RFC 3489: Symmetric. + /// RFC 4787: Endpoint-Dependent Mapping with random ports, + /// Address-and-Port-Dependent Filtering. Hardest, /// Fully custom NAT configuration. /// - /// Use this when the named presets do not cover the scenario. - /// - /// # Example - /// ```no_run - /// # use patchbay::*; - /// let custom = Nat::Custom( - /// NatConfig::builder() - /// .mapping(NatMapping::EndpointIndependent) - /// .filtering(NatFiltering::EndpointIndependent) - /// .hairpin(true) - /// .build(), - /// ); - /// ``` + /// Use this when the named presets do not cover the scenario. Construct + /// the config with [`NatConfig::builder`]. #[strum(disabled)] Custom(NatConfig), } +impl Nat { + /// Expands this preset into a [`NatConfig`], or `None` for [`Nat::None`]. + pub fn to_config(self) -> Option { + self.into() + } +} + impl From for Nat { fn from(config: NatConfig) -> Self { Nat::Custom(config) @@ -108,38 +102,32 @@ impl From for Nat { impl From for Option { fn from(nat: Nat) -> Self { - match nat { - Nat::None => None, - Nat::Easiest => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::EndpointIndependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts::default(), - hairpin: false, - }), - Nat::Easy => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts::default(), - hairpin: false, - }), - Nat::Hard => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts::default(), - hairpin: false, - }), - Nat::Hardest => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Random, - timeouts: ConntrackTimeouts::default(), - hairpin: false, - }), - Nat::Custom(config) => Some(config), - } + let (mapping, filtering) = match nat { + Nat::None => return None, + Nat::Easiest => ( + NatMapping::EndpointIndependent, + NatFiltering::EndpointIndependent, + ), + Nat::Easy => ( + NatMapping::EndpointIndependent, + NatFiltering::AddressAndPortDependent, + ), + Nat::Hard => ( + NatMapping::EndpointDependent(PortPreservation::Preserve), + NatFiltering::AddressAndPortDependent, + ), + Nat::Hardest => ( + NatMapping::EndpointDependent(PortPreservation::Random), + NatFiltering::AddressAndPortDependent, + ), + Nat::Custom(config) => return Some(config), + }; + Some(NatConfig { + mapping, + filtering, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }) } } @@ -150,70 +138,83 @@ impl From for Option { #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum NatMapping { - /// Same external port for all destinations (EIM). + /// Same external port for all destinations. /// - /// The NAT reuses one external port for every destination from a given - /// internal source. nftables: `snat to ` with a fullcone map. + /// RFC 4787: Endpoint-Independent Mapping (EIM). The NAT reuses one + /// external port for every destination from a given internal source + /// address and port. Port preservation is implicit and always enabled. EndpointIndependent, - /// Different external port per destination IP and port (EDM, "symmetric"). - /// - /// The NAT assigns a fresh mapping per 4-tuple. Whether the new port - /// is predictable depends on [`PortPreservation`]. - EndpointDependent, -} -/// NAT filtering behavior per RFC 4787 §5. -/// -/// Controls which inbound packets the NAT allows through to the internal -/// host after an outbound mapping has been established. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum NatFiltering { - /// Any external host can send to the mapped port (EIF, "full cone"). - /// - /// nftables: fullcone DNAT map in prerouting. - EndpointIndependent, - /// Only external hosts the internal endpoint has contacted can reply, - /// but any port on those hosts is allowed (ADF, "restricted cone"). + /// Different external port per destination. /// - /// RFC 3489 calls this "Restricted Cone". Less common than APDF but - /// documented on some consumer routers. - AddressDependent, - /// Only the exact (IP, port) the internal endpoint contacted can reply - /// (APDF, "port-restricted cone"). - /// - /// nftables: conntrack-only (no prerouting DNAT). - AddressAndPortDependent, + /// RFC 4787: Endpoint-Dependent Mapping (EDM), also called "symmetric". + /// Each unique destination 4-tuple gets a fresh external port. The + /// [`PortPreservation`] value selects how the fresh port is chosen. + EndpointDependent(PortPreservation), } -/// Port allocation behavior for the NAT. +/// Port allocation policy for [`NatMapping::EndpointDependent`]. /// -/// Orthogonal to mapping and filtering per RFC 4787 §4.2 (port preservation). +/// Only applies to EDM: [`NatMapping::EndpointIndependent`] always +/// preserves the source port, and the variant has no configuration. // // Gap: sequential port allocation (deployed in some older SOHO boxes and in -// Port-Block-Allocated CGNAT per RFC 7753) is not modeled. Hole-punching -// literature treats it as a distinct class from `Preserve` because ports -// are predictable across flows in a deterministic way. +// Port-Block-Allocated CGNAT) is not modeled. Hole-punching literature +// treats it as a distinct class from `Preserve` because ports are +// predictable across flows in a deterministic way. If a future test needs +// it, implement as a new variant. #[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PortPreservation { - /// Reuse the internal source port for the external port when the port - /// is free. Fall back to a fresh port on collision. + /// Reuse the internal source port when the external port is free. /// - /// This is the Linux conntrack default for `masquerade` without flags. - /// Combined with `EndpointDependent` mapping this produces a symmetric - /// NAT that port-prediction techniques can still traverse. + /// Linux `masquerade` without flags. Combined with EDM this produces a + /// symmetric NAT that port-prediction techniques can traverse. #[default] Preserve, - /// Allocate a random external port. + /// Allocate a random external port for each flow. /// - /// Combined with `EndpointDependent` mapping this makes hole-punching + /// Linux `masquerade random`. Combined with EDM this makes hole-punching /// practically impossible. Random, } +/// NAT filtering behavior per RFC 4787 §5. +/// +/// Controls which inbound packets the NAT allows through to the internal +/// host after an outbound mapping has been established. Not every filtering +/// mode is compatible with every mapping mode: [`NatMapping::EndpointDependent`] +/// paired with `AddressDependent` is rejected at build time because the +/// implementation has no way to deliver inbound packets from unsolicited +/// ports without a fullcone map. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum NatFiltering { + /// Any external host can send to the mapped port. + /// + /// RFC 4787: Endpoint-Independent Filtering (EIF), also called + /// "full cone". nftables implementation: fullcone DNAT map in + /// prerouting. + EndpointIndependent, + /// Only external hosts the internal endpoint has contacted can reply. + /// Any source port on those hosts is allowed. + /// + /// RFC 3489: Restricted Cone. RFC 4787: Address-Dependent Filtering + /// (ADF). Less common than APDF but documented on some consumer + /// routers. Only supported with [`NatMapping::EndpointIndependent`]. + AddressDependent, + /// Only the exact address and port the internal endpoint contacted can + /// reply. + /// + /// RFC 3489: Port-Restricted Cone. RFC 4787: Address-and-Port-Dependent + /// Filtering (APDF). nftables implementation: conntrack match only, + /// no prerouting DNAT. + AddressAndPortDependent, +} + /// Conntrack timeout configuration for a NAT profile. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct ConntrackTimeouts { /// Timeout for a single unreplied UDP packet (seconds). pub udp: u32, @@ -233,31 +234,56 @@ impl Default for ConntrackTimeouts { } } +/// Errors returned by [`NatConfigBuilder::build`] when the requested +/// combination cannot be expressed in nftables. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NatConfigError { + /// Address-Dependent filtering requires Endpoint-Independent mapping. + /// + /// The nftables rules for ADF rely on the fullcone DNAT map to deliver + /// inbound packets from any port on a contacted address. That map only + /// exists when mapping is [`NatMapping::EndpointIndependent`]. + AdfRequiresEim, + /// Hairpin requires Endpoint-Independent mapping. + /// + /// The hairpin DNAT rule rewrites traffic destined for the router's + /// WAN IP back into the LAN using the fullcone map. Endpoint-Dependent + /// mapping has no such map, and the current implementation would route + /// hairpin traffic to the router itself, not to the LAN peer. + HairpinRequiresEim, +} + +impl std::fmt::Display for NatConfigError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::AdfRequiresEim => f.write_str( + "NatFiltering::AddressDependent requires \ + NatMapping::EndpointIndependent; Endpoint-Dependent mapping \ + has no way to deliver inbound packets from unsolicited ports", + ), + Self::HairpinRequiresEim => f.write_str( + "hairpin requires NatMapping::EndpointIndependent; \ + Endpoint-Dependent mapping has no fullcone map to DNAT \ + hairpin traffic", + ), + } + } +} + +impl std::error::Error for NatConfigError {} + /// Expanded NAT configuration produced from a [`Nat`] preset or the builder. /// -/// Carries all parameters needed to generate nftables rules and conntrack -/// settings for a router's NAT. Each preset ([`Nat::Easy`], [`Nat::Hard`], -/// etc.) expands to a specific `NatConfig` via [`From`] for -/// `Option`. -/// -/// # Example -/// ``` -/// # use patchbay::{NatConfig, NatMapping, NatFiltering}; -/// let cfg = NatConfig::builder() -/// .mapping(NatMapping::EndpointIndependent) -/// .filtering(NatFiltering::AddressAndPortDependent) -/// .udp_stream_timeout(120) -/// .build(); -/// ``` +/// Each preset ([`Nat::Easy`], [`Nat::Hard`], etc.) expands via +/// [`Nat::to_config`]. Custom configurations are built with +/// [`NatConfig::builder`], which validates cross-field invariants. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] pub struct NatConfig { /// How outbound port mapping works. pub mapping: NatMapping, /// Which inbound packets are forwarded. pub filtering: NatFiltering, - /// How external ports are allocated. - #[serde(default)] - pub port_preservation: PortPreservation, /// Conntrack timeout settings. pub timeouts: ConntrackTimeouts, /// Whether LAN devices can reach each other via the router's public IP. @@ -273,13 +299,13 @@ impl NatConfig { /// Builder for [`NatConfig`]. /// -/// Defaults to EIM + APDF with port preservation and standard home-router -/// timeouts. +/// Defaults to EIM + APDF with standard home-router timeouts. Call +/// [`NatConfigBuilder::build`] to validate cross-field invariants and +/// produce the final [`NatConfig`]. #[derive(Clone, Debug)] pub struct NatConfigBuilder { mapping: NatMapping, filtering: NatFiltering, - port_preservation: PortPreservation, timeouts: ConntrackTimeouts, hairpin: bool, } @@ -289,7 +315,6 @@ impl Default for NatConfigBuilder { Self { mapping: NatMapping::EndpointIndependent, filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, timeouts: ConntrackTimeouts::default(), hairpin: false, } @@ -309,12 +334,6 @@ impl NatConfigBuilder { self } - /// Sets the port allocation behavior. - pub fn port_preservation(mut self, pp: PortPreservation) -> Self { - self.port_preservation = pp; - self - } - /// Sets the UDP single-packet timeout (seconds). Default: 30. pub fn udp_timeout(mut self, secs: u32) -> Self { self.timeouts.udp = secs; @@ -336,21 +355,31 @@ impl NatConfigBuilder { /// Enables or disables NAT hairpinning. Default: false. /// /// When enabled, LAN devices can reach each other via the router's - /// public IP (e.g. using a reflexive address learned via STUN). + /// public IP. Only supported with [`NatMapping::EndpointIndependent`]. pub fn hairpin(mut self, enabled: bool) -> Self { self.hairpin = enabled; self } - /// Builds the [`NatConfig`]. - pub fn build(self) -> NatConfig { - NatConfig { + /// Validates the configuration and returns the [`NatConfig`]. + /// + /// Returns an error when the combination of mapping, filtering, and + /// hairpin cannot be expressed in nftables. See [`NatConfigError`] for + /// the specific rules. + pub fn build(self) -> Result { + let eim = matches!(self.mapping, NatMapping::EndpointIndependent); + if !eim && matches!(self.filtering, NatFiltering::AddressDependent) { + return Err(NatConfigError::AdfRequiresEim); + } + if !eim && self.hairpin { + return Err(NatConfigError::HairpinRequiresEim); + } + Ok(NatConfig { mapping: self.mapping, filtering: self.filtering, - port_preservation: self.port_preservation, timeouts: self.timeouts, hairpin: self.hairpin, - } + }) } } @@ -399,3 +428,116 @@ impl IpSupport { matches!(self, IpSupport::V6Only | IpSupport::DualStack) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builder_rejects_edm_with_adf() { + let err = NatConfig::builder() + .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) + .filtering(NatFiltering::AddressDependent) + .build() + .unwrap_err(); + assert_eq!(err, NatConfigError::AdfRequiresEim); + } + + #[test] + fn builder_rejects_edm_with_hairpin() { + let err = NatConfig::builder() + .mapping(NatMapping::EndpointDependent(PortPreservation::Random)) + .hairpin(true) + .build() + .unwrap_err(); + assert_eq!(err, NatConfigError::HairpinRequiresEim); + } + + #[test] + fn builder_accepts_eim_with_adf() { + let cfg = NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .filtering(NatFiltering::AddressDependent) + .build() + .unwrap(); + assert_eq!(cfg.filtering, NatFiltering::AddressDependent); + } + + #[test] + fn builder_accepts_eim_with_hairpin() { + let cfg = NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .hairpin(true) + .build() + .unwrap(); + assert!(cfg.hairpin); + } + + #[test] + fn builder_accepts_edm_preserve() { + let cfg = NatConfig::builder() + .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) + .filtering(NatFiltering::AddressAndPortDependent) + .build() + .unwrap(); + assert!(matches!( + cfg.mapping, + NatMapping::EndpointDependent(PortPreservation::Preserve) + )); + } + + #[test] + fn builder_accepts_edm_random() { + let cfg = NatConfig::builder() + .mapping(NatMapping::EndpointDependent(PortPreservation::Random)) + .filtering(NatFiltering::AddressAndPortDependent) + .build() + .unwrap(); + assert!(matches!( + cfg.mapping, + NatMapping::EndpointDependent(PortPreservation::Random) + )); + } + + #[test] + fn nat_to_config_covers_every_preset() { + for nat in [Nat::None, Nat::Easiest, Nat::Easy, Nat::Hard, Nat::Hardest] { + let cfg = nat.to_config(); + assert_eq!(cfg.is_none(), nat == Nat::None); + } + } + + #[test] + fn nat_easiest_is_eim_eif() { + let cfg = Nat::Easiest.to_config().unwrap(); + assert_eq!(cfg.mapping, NatMapping::EndpointIndependent); + assert_eq!(cfg.filtering, NatFiltering::EndpointIndependent); + } + + #[test] + fn nat_easy_is_eim_apdf() { + let cfg = Nat::Easy.to_config().unwrap(); + assert_eq!(cfg.mapping, NatMapping::EndpointIndependent); + assert_eq!(cfg.filtering, NatFiltering::AddressAndPortDependent); + } + + #[test] + fn nat_hard_is_edm_preserve_apdf() { + let cfg = Nat::Hard.to_config().unwrap(); + assert_eq!( + cfg.mapping, + NatMapping::EndpointDependent(PortPreservation::Preserve) + ); + assert_eq!(cfg.filtering, NatFiltering::AddressAndPortDependent); + } + + #[test] + fn nat_hardest_is_edm_random_apdf() { + let cfg = Nat::Hardest.to_config().unwrap(); + assert_eq!( + cfg.mapping, + NatMapping::EndpointDependent(PortPreservation::Random) + ); + assert_eq!(cfg.filtering, NatFiltering::AddressAndPortDependent); + } +} diff --git a/patchbay/src/nft.rs b/patchbay/src/nft.rs index 303915b..b7cc7ea 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -11,6 +11,13 @@ use crate::{ ConntrackTimeouts, LinkCondition, NatConfig, NatFiltering, NatMapping, NatV6Mode, }; +/// Returns `true` when the NAT uses the fullcone DNAT map (Endpoint-Independent +/// Mapping). This is the single check that both postrouting and prerouting +/// chain generation branch on. +fn uses_fullcone_map(cfg: &NatConfig) -> bool { + matches!(cfg.mapping, NatMapping::EndpointIndependent) +} + /// Applies nftables rules (assumes caller is already in the target namespace). async fn run_nft(rules: &str) -> Result<()> { use tokio::io::AsyncWriteExt; @@ -54,13 +61,14 @@ pub(crate) async fn run_nft_in(netns: &netns::NetnsManager, ns: &str, rules: &st /// Generates nftables rules for a [`NatConfig`]. /// /// EIM uses a dynamic fullcone map to preserve source ports across destinations. -/// EDM uses `snat` with the port-preservation flag set by [`PortPreservation`]. -/// EIF adds unconditional fullcone DNAT in prerouting. -/// ADF and APDF add a forward filter that only allows established/related flows. -/// ADF additionally permits packets from any port on a previously-contacted -/// external address. +/// EDM uses `masquerade` with or without the `random` flag depending on the +/// [`PortPreservation`] carried by the mapping variant. +/// EIF produces an unconditional fullcone DNAT in prerouting. +/// ADF and APDF add a forward filter that only allows established/related +/// flows. ADF additionally permits packets from any port on a +/// previously-contacted external address. fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String { - let use_fullcone_map = cfg.mapping == NatMapping::EndpointIndependent; + let use_fullcone_map = uses_fullcone_map(cfg); let hairpin = cfg.hairpin; let map_decl = if use_fullcone_map { @@ -119,17 +127,19 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String ip = wan_ip, ) } else { - // EDM with Preserve: masquerade (Linux keeps the source port when free). - // EDM with Random: masquerade random (fresh port per flow). - let masq_flag = match cfg.port_preservation { - PortPreservation::Preserve => "", - PortPreservation::Random => " random", + // EDM with Preserve: `masquerade`; Linux keeps the source port when + // free (see C1 empirical test `port_mapping_edm_preserve_stable`). + // EDM with Random: `masquerade random`; fresh port per flow. + let masq_stmt = match cfg.mapping { + NatMapping::EndpointDependent(PortPreservation::Preserve) => "masquerade", + NatMapping::EndpointDependent(PortPreservation::Random) => "masquerade random", + NatMapping::EndpointIndependent => unreachable!("EIM took the fullcone branch"), }; format!( - r#"{hairpin} oif "{wan}" masquerade{flag}"#, + r#"{hairpin} oif "{wan}" {stmt}"#, hairpin = hairpin_masq, wan = wan_if, - flag = masq_flag, + stmt = masq_stmt, ) }; @@ -247,7 +257,7 @@ pub(crate) async fn apply_nat_for_router( wan_if: &str, wan_ip: Ipv4Addr, ) -> Result<()> { - match router_cfg.effective_nat_config() { + match router_cfg.nat { None => Ok(()), Some(cfg) => apply_nat_config(netns, ns, &cfg, wan_if, wan_ip).await, } diff --git a/patchbay/src/router.rs b/patchbay/src/router.rs index 5212adb..189ad7a 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -21,10 +21,7 @@ use crate::{ event::{LabEventKind, RouterState}, firewall::{Firewall, FirewallConfigBuilder}, lab::{Ipv6ProvisioningMode, LabInner, LinkCondition}, - nat::{ - ConntrackTimeouts, IpSupport, NatConfig, NatFiltering, NatMapping, NatV6Mode, - PortPreservation, - }, + nat::{ConntrackTimeouts, IpSupport, Nat, NatConfig, NatV6Mode}, netlink::Netlink, nft::{ apply_firewall, apply_nat_for_router, apply_nat_v6, apply_or_remove_impair, @@ -246,10 +243,12 @@ impl Router { /// Returns the effective NAT configuration for this router. /// - /// Returns `None` if the router has been removed. The inner `Option` is - /// `None` when NAT is disabled (routes are passed through unmodified). - pub fn nat_config(&self) -> Option> { - self.lab.with_router(self.id, |r| r.cfg.nat) + /// Returns `None` when NAT is disabled, when the router has been + /// removed, or when no config is set. Matches the pattern used by + /// other router accessors ([`mtu`](Self::mtu), + /// [`uplink_ip`](Self::uplink_ip), etc.). + pub fn nat_config(&self) -> Option { + self.lab.with_router(self.id, |r| r.cfg.nat).flatten() } /// Returns the configured MTU, if set. @@ -434,7 +433,7 @@ impl Router { /// # Errors /// /// Returns an error if the router has been removed or nftables commands fail. - pub async fn set_nat_mode>>(&self, mode: T) -> Result<()> { + pub async fn set_nat>>(&self, mode: T) -> Result<()> { let mode = mode.into(); let op = self .lab @@ -448,10 +447,13 @@ impl Router { let nat_params = inner.router_nat_params(self.id)?; (nat_params, cfg) }; - run_nft_in(&self.lab.netns, &nat_params.ns, "flush table ip nat") + // Delete the whole tables rather than flushing: `flush` leaves named + // sets (`@contacted` for ADF) in place across transitions, which + // would bleed previous ADF state into the new config. + run_nft_in(&self.lab.netns, &nat_params.ns, "delete table ip nat") .await .ok(); - run_nft_in(&self.lab.netns, &nat_params.ns, "flush table ip filter") + run_nft_in(&self.lab.netns, &nat_params.ns, "delete table ip filter") .await .ok(); apply_nat_for_router( @@ -519,7 +521,7 @@ impl Router { /// Flushes the conntrack table, forcing all active NAT mappings to expire. /// /// Subsequent flows get new external port assignments. Pair with - /// [`set_nat_mode`](Self::set_nat_mode) when testing mode transitions. + /// [`set_nat`](Self::set_nat) when testing mode transitions. /// /// # Errors /// @@ -700,9 +702,9 @@ impl Router { /// from a known configuration and adjust only what your test needs. /// /// The ISP presets cover different shapes of carrier-grade NAT. `IspCgnat` -/// is the RFC 6888 compliant fixed-line case. `IspCgnatHard` covers -/// hardened symmetric CGNAT. `MobileCarrier` covers typical cellular -/// deployments. `IspV6` is the IPv6-only case with NAT64. +/// is the RFC 6888 compliant fixed-line case. `IspCgnatSymmetric` covers +/// symmetric CGNAT. `MobileCarrier` covers typical cellular deployments. +/// `IspV6` is the IPv6-only case with NAT64. /// /// # Example /// @@ -767,31 +769,35 @@ pub enum RouterPreset { /// ISP with RFC 6888 compliant carrier-grade NAT. /// /// Models a provider that shares a pool of public IPv4 addresses across - /// subscribers via CGNAT with endpoint-independent mapping and filtering - /// per RFC 6888. Typical of fixed-line fiber CGNAT, Starlink, and some - /// compliant mobile deployments. Standard hole-punching works. IPv6 - /// addresses are globally routable. + /// subscribers via CGNAT. RFC 6888 mandates endpoint-independent mapping + /// (EIM). In practice most compliant deployments pair EIM with + /// address-and-port-dependent filtering (APDF), matching + /// [`Nat::Easy`](crate::Nat::Easy), rather than the more permissive EIF + /// that the original "full cone CGNAT" description implied. Standard + /// UDP hole-punching succeeds. IPv6 addresses are globally routable but + /// the firewall blocks unsolicited inbound. /// /// Dual-stack, private downstream pool. IspCgnat, - /// ISP with hardened symmetric CGNAT. + /// ISP with symmetric CGNAT. /// - /// Models carrier-grade NAT that deploys symmetric (endpoint-dependent) - /// mapping with port preservation, common in Port-Block-Allocated CGNAT - /// (RFC 7753) and in some older fixed-line deployments. Hole-punching - /// succeeds only with port prediction. + /// Models carrier-grade NAT that uses endpoint-dependent mapping with + /// port preservation. Common in some older fixed-line deployments and + /// in CGNAT hardware where administrators disable EIM. Hole-punching + /// succeeds only with port prediction ([`Nat::Hard`](crate::Nat::Hard) + /// semantics). IPv6 inbound is blocked by a stateful firewall. /// /// Dual-stack, private downstream pool. - IspCgnatHard, + IspCgnatSymmetric, /// Mobile carrier on LTE or 5G. /// /// Models typical cellular deployments: symmetric NAT with port - /// preservation, aggressive UDP timeouts (around 60 seconds), and a + /// preservation, a 60-second UDP stream timeout (matching the cellular + /// median reported in published CGN measurement studies), and a /// stateful firewall that blocks unsolicited inbound on both address - /// families. Matches the observed behavior of most European and North - /// American mobile networks in 2026. + /// families. /// /// Dual-stack, private downstream pool. MobileCarrier, @@ -846,76 +852,56 @@ pub enum RouterPreset { } impl RouterPreset { + #[cfg(test)] + pub(crate) fn nat_config(self) -> Option { + self.nat() + } + fn nat(self) -> Option { - match self { - Self::Public | Self::PublicV4 | Self::IspV6 => None, - Self::Home => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 300, - tcp_established: 7200, - }, - hairpin: false, - }), - Self::IspCgnat => Some(NatConfig { - mapping: NatMapping::EndpointIndependent, - filtering: NatFiltering::EndpointIndependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 300, - tcp_established: 7200, - }, - hairpin: false, - }), - Self::IspCgnatHard => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 180, - tcp_established: 3600, - }, - hairpin: false, - }), - Self::MobileCarrier => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Preserve, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 60, - tcp_established: 3600, - }, - hairpin: false, - }), - Self::Corporate | Self::Hotel => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Random, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 120, - tcp_established: 3600, - }, - hairpin: false, - }), - Self::Cloud => Some(NatConfig { - mapping: NatMapping::EndpointDependent, - filtering: NatFiltering::AddressAndPortDependent, - port_preservation: PortPreservation::Random, - timeouts: ConntrackTimeouts { - udp: 30, - udp_stream: 350, - tcp_established: 3600, - }, - hairpin: false, - }), - } + // Start from the matching Nat preset and then override timeouts per + // deployment. `Nat::*.to_config()` supplies the correct mapping, + // filtering, and port-preservation combination for each tier. + let base = match self { + Self::Public | Self::PublicV4 | Self::IspV6 => Nat::None, + // S1: RFC 6888 mandates EIM but most compliant deployments pair + // it with APDF (matching Nat::Easy), not the "full cone" EIF + // assumed by earlier versions of this preset. + Self::Home | Self::IspCgnat => Nat::Easy, + Self::IspCgnatSymmetric | Self::MobileCarrier => Nat::Hard, + Self::Corporate | Self::Hotel | Self::Cloud => Nat::Hardest, + }; + let mut cfg = base.to_config()?; + cfg.timeouts = match self { + Self::Home | Self::IspCgnat => ConntrackTimeouts { + udp: 30, + udp_stream: 300, + tcp_established: 7200, + }, + Self::IspCgnatSymmetric => ConntrackTimeouts { + udp: 30, + udp_stream: 180, + tcp_established: 3600, + }, + Self::MobileCarrier => ConntrackTimeouts { + udp: 30, + udp_stream: 60, + tcp_established: 3600, + }, + Self::Corporate | Self::Hotel => ConntrackTimeouts { + udp: 30, + udp_stream: 120, + tcp_established: 3600, + }, + Self::Cloud => ConntrackTimeouts { + udp: 30, + udp_stream: 350, + tcp_established: 3600, + }, + Self::Public | Self::PublicV4 | Self::IspV6 => { + unreachable!("presets without NAT returned via Nat::None above") + } + }; + Some(cfg) } fn nat_v6(self) -> NatV6Mode { @@ -926,11 +912,16 @@ impl RouterPreset { } fn firewall(self) -> Firewall { + // S5: fixed-line CGNAT deployments block unsolicited inbound on v6 + // (Swisscom, Deutsche Telekom, Starlink), matching cellular. Only + // the explicitly-public presets get Firewall::None. match self { - Self::Home | Self::IspV6 | Self::MobileCarrier => Firewall::BlockInbound, - Self::Public | Self::PublicV4 | Self::IspCgnat | Self::IspCgnatHard | Self::Cloud => { - Firewall::None - } + Self::Home + | Self::IspV6 + | Self::IspCgnat + | Self::IspCgnatSymmetric + | Self::MobileCarrier => Firewall::BlockInbound, + Self::Public | Self::PublicV4 | Self::Cloud => Firewall::None, Self::Corporate => Firewall::Corporate, Self::Hotel => Firewall::CaptivePortal, } diff --git a/patchbay/src/tests/hairpin.rs b/patchbay/src/tests/hairpin.rs index 0dc741a..0eb266e 100644 --- a/patchbay/src/tests/hairpin.rs +++ b/patchbay/src/tests/hairpin.rs @@ -2,7 +2,8 @@ //! //! Hairpinning lets a LAN device reach a peer on the same router via //! the router's public IP+port, rather than requiring a direct LAN -//! connection. FullCone enables it; Home NAT disables it. +//! connection. `Nat::Easy` leaves it disabled; opt in with a custom +//! `NatConfig` setting `hairpin(true)`. use super::*; @@ -22,7 +23,7 @@ async fn fullcone_allows() -> Result<()> { .mapping(NatMapping::EndpointIndependent) .filtering(NatFiltering::EndpointIndependent) .hairpin(true) - .build(), + .build()?, ) .build() .await?; @@ -108,13 +109,13 @@ async fn custom_allows() -> Result<()> { let dc = lab.add_router("dc").build().await?; let r = lab .add_router("r") - .nat(Nat::Custom( + .nat( NatConfig::builder() .mapping(NatMapping::EndpointIndependent) .filtering(NatFiltering::AddressAndPortDependent) .hairpin(true) - .build(), - )) + .build()?, + ) .build() .await?; let a = lab.add_device("a").iface("eth0", r.id()).build().await?; diff --git a/patchbay/src/tests/nat.rs b/patchbay/src/tests/nat.rs index c65bb20..a5f06a9 100644 --- a/patchbay/src/tests/nat.rs +++ b/patchbay/src/tests/nat.rs @@ -357,6 +357,45 @@ async fn port_mapping_eim_stable() -> Result<()> { Ok(()) } +/// EDM with port preservation (Nat::Hard): external port stays stable across +/// destinations when the internal source port is free. Validates the +/// "symmetric but port-predictable" claim behind `Nat::Hard`. If this test +/// fails, `Nat::Hard` is empirically equivalent to `Nat::Hardest` on this +/// kernel and the two variants must be reconciled. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn port_mapping_edm_preserve_stable() -> Result<()> { + use strum::IntoEnumIterator; + let mut port_base = 16_050u16; + let mut failures = Vec::new(); + for wiring in UplinkWiring::iter() { + let result: Result<()> = async { + let (lab, ctx) = build_nat_case(Nat::Hard, wiring, port_base).await?; + let dev = lab.device_by_name("dev").unwrap(); + let o1 = dev.probe_udp_mapping(ctx.r_dc)?; + let o2 = dev.probe_udp_mapping(ctx.r_ix)?; + if o1.port() != o2.port() { + bail!( + "EDM Preserve: external port changed across destinations: \ + r_dc={} r_ix={}", + o1.port(), + o2.port() + ); + } + Ok(()) + } + .await; + if let Err(e) = result { + failures.push(format!("DestDep+Preserve/{wiring}: {e:#}")); + } + port_base += 10; + } + if !failures.is_empty() { + bail!("{} combos failed:\n{}", failures.len(), failures.join("\n")); + } + Ok(()) +} + /// EDM (Corporate NAT): external port differs between two reflectors. #[tokio::test(flavor = "current_thread")] #[traced_test] @@ -558,6 +597,124 @@ async fn v6_no_translation() -> Result<()> { Ok(()) } +/// Address-Dependent Filtering (RFC 4787 ADF, "Restricted Cone") admits +/// packets from any port on a previously-contacted external address. +/// +/// After the device contacts the DC on one port, the DC sends from a +/// different source port to the mapping. ADF permits the packet because the +/// source IP is in the `@contacted` set. APDF (the default for `Nat::Easy`) +/// would drop it because the source port does not match the outbound flow. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn adf_allows_different_port_from_contacted_host() -> Result<()> { + check_caps()?; + let lab = Lab::new().await?; + let dc = lab.add_router("dc").build().await?; + let router = lab + .add_router("r") + .nat( + NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .filtering(NatFiltering::AddressDependent) + .build()?, + ) + .build() + .await?; + let dev = lab + .add_device("dev") + .iface("eth0", router.id()) + .build() + .await?; + + let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; + let reflector = SocketAddr::new(IpAddr::V4(dc_ip), 21_000); + let _r = dc.spawn_reflector(reflector).await?; + + // Device contacts DC, creating a mapping and populating @contacted. + let mapped = dev.probe_udp_mapping(reflector)?; + + // Device listens on the internal port matching the mapping. + let dev_listen = SocketAddr::new(IpAddr::V4(dev.ip().unwrap()), mapped.port()); + let _d = dev.spawn_reflector(dev_listen).await?; + + // DC sends from a *different* source port to the mapped address. + let reply = dc.run_sync(move || { + let sock = std::net::UdpSocket::bind("0.0.0.0:0").context("bind")?; + sock.set_read_timeout(Some(Duration::from_secs(2)))?; + sock.send_to(b"HELLO", mapped)?; + let mut buf = [0u8; 512]; + let (n, _) = sock.recv_from(&mut buf)?; + Ok(String::from_utf8_lossy(&buf[..n]).to_string()) + })?; + assert!( + reply.starts_with("OBSERVED "), + "ADF must accept inbound from a contacted IP on any source port; got: {reply:?}" + ); + Ok(()) +} + +/// Address-Dependent Filtering drops packets from uncontacted external +/// addresses. +/// +/// A second external host that the device never contacted sends to the +/// mapped address. ADF must drop the packet because the source IP is not in +/// `@contacted`. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn adf_drops_from_uncontacted_host() -> Result<()> { + check_caps()?; + let lab = Lab::new().await?; + let dc_a = lab.add_router("dc-a").build().await?; + let dc_b = lab.add_router("dc-b").build().await?; + let router = lab + .add_router("r") + .nat( + NatConfig::builder() + .mapping(NatMapping::EndpointIndependent) + .filtering(NatFiltering::AddressDependent) + .build()?, + ) + .build() + .await?; + let dev = lab + .add_device("dev") + .iface("eth0", router.id()) + .build() + .await?; + + let dc_a_ip = dc_a.uplink_ip().context("no dc-a uplink ip")?; + let dc_b_ip = dc_b.uplink_ip().context("no dc-b uplink ip")?; + let reflector = SocketAddr::new(IpAddr::V4(dc_a_ip), 21_100); + let _r = dc_a.spawn_reflector(reflector).await?; + + // Device contacts dc-a only. dc-b is not in @contacted. + let mapped = dev.probe_udp_mapping(reflector)?; + + let dev_listen = SocketAddr::new(IpAddr::V4(dev.ip().unwrap()), mapped.port()); + let _d = dev.spawn_reflector(dev_listen).await?; + + // dc-b sends unsolicited to the mapped address. + let _ = dc_b_ip; + let result = dc_b.run_sync(move || { + let sock = std::net::UdpSocket::bind("0.0.0.0:0").context("bind")?; + sock.set_read_timeout(Some(Duration::from_millis(500)))?; + sock.send_to(b"UNSOLICITED", mapped)?; + let mut buf = [0u8; 512]; + match sock.recv_from(&mut buf) { + Ok(_) => bail!("ADF must drop packets from uncontacted source IP"), + Err(e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + Ok(()) + } + Err(e) => Err(e.into()), + } + }); + assert!(result.is_ok(), "expected timeout (drop), got: {result:?}"); + Ok(()) +} + /// FullCone NAT allows unsolicited inbound: external host sends to mapped /// address and device receives it without prior outbound to that sender. #[tokio::test(flavor = "current_thread")] diff --git a/patchbay/src/tests/nat_rebind.rs b/patchbay/src/tests/nat_rebind.rs index 14f6ed0..3d7d3ac 100644 --- a/patchbay/src/tests/nat_rebind.rs +++ b/patchbay/src/tests/nat_rebind.rs @@ -24,7 +24,7 @@ async fn mode_port_change() -> Result<()> { let result: Result<()> = async { let (lab, ctx) = build_nat_case(from, UplinkWiring::DirectIx, port_base).await?; let nat_handle = lab.router_by_name("nat").context("missing nat")?; - nat_handle.set_nat_mode(to).await?; + nat_handle.set_nat(to).await?; tokio::time::sleep(Duration::from_millis(50)).await; let dev = lab.device_by_name("dev").unwrap(); let o1 = dev.probe_udp_mapping(ctx.r_dc)?; @@ -68,7 +68,7 @@ async fn mode_ip_change() -> Result<()> { let (lab, ctx) = build_nat_case(from, UplinkWiring::DirectIx, port_base).await?; let nat_handle = lab.router_by_name("nat").context("missing nat")?; let wan_ip = nat_handle.uplink_ip().context("no uplink ip")?; - nat_handle.set_nat_mode(to).await?; + nat_handle.set_nat(to).await?; tokio::time::sleep(Duration::from_millis(50)).await; let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); let r_dc = ctx.r_dc; diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index 899c98b..7aa2ef5 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -167,6 +167,68 @@ async fn preset_override() -> Result<()> { Ok(()) } +/// Snapshot of each preset's NAT config. Changes here are intentional and +/// should be matched by a corresponding update in the docs +/// (`docs/guide/nat-and-firewalls.md` preset table) and in +/// `plans/nat-breaking-changes.md`. +#[test] +fn preset_nat_snapshots() { + use crate::{Nat, NatMapping, PortPreservation}; + + // (preset, expected_nat_kind_or_none, udp_stream_seconds_or_none) + let cases = [ + (RouterPreset::Home, Some(Nat::Easy), Some(300u32)), + (RouterPreset::IspCgnat, Some(Nat::Easy), Some(300)), + (RouterPreset::IspCgnatSymmetric, Some(Nat::Hard), Some(180)), + (RouterPreset::MobileCarrier, Some(Nat::Hard), Some(60)), + (RouterPreset::Corporate, Some(Nat::Hardest), Some(120)), + (RouterPreset::Hotel, Some(Nat::Hardest), Some(120)), + (RouterPreset::Cloud, Some(Nat::Hardest), Some(350)), + (RouterPreset::Public, None, None), + (RouterPreset::PublicV4, None, None), + (RouterPreset::IspV6, None, None), + ]; + for (preset, expected_kind, expected_udp_stream) in cases { + let cfg = preset.nat_config(); + match (cfg, expected_kind) { + (None, None) => {} + (Some(actual), Some(kind)) => { + let expected_cfg = kind.to_config().expect("kind is not Nat::None"); + assert_eq!( + actual.mapping, expected_cfg.mapping, + "{preset:?}: mapping mismatch" + ); + assert_eq!( + actual.filtering, expected_cfg.filtering, + "{preset:?}: filtering mismatch" + ); + let udp_stream = expected_udp_stream.expect("set when kind is Some"); + assert_eq!( + actual.timeouts.udp_stream, udp_stream, + "{preset:?}: udp_stream mismatch" + ); + } + (actual, expected) => { + panic!( + "{preset:?}: config mismatch: actual={actual:?}, expected_kind={expected:?}" + ); + } + } + // Cross-check that EDM presets carry the correct port-preservation + // tag: Hard → Preserve, Hardest → Random. + if let Some(actual) = cfg { + if let NatMapping::EndpointDependent(pp) = actual.mapping { + let expected_pp = match expected_kind { + Some(Nat::Hard) => PortPreservation::Preserve, + Some(Nat::Hardest) => PortPreservation::Random, + _ => unreachable!("EDM presets map to Hard or Hardest only"), + }; + assert_eq!(pp, expected_pp, "{preset:?}: port preservation mismatch"); + } + } + } +} + /// All presets recommend Ipv6Profile::Realistic. #[tokio::test(flavor = "current_thread")] #[traced_test] @@ -176,7 +238,7 @@ async fn preset_recommended_ipv6_profiles() -> Result<()> { RouterPreset::Public, RouterPreset::PublicV4, RouterPreset::IspCgnat, - RouterPreset::IspCgnatHard, + RouterPreset::IspCgnatSymmetric, RouterPreset::MobileCarrier, RouterPreset::IspV6, RouterPreset::Corporate, diff --git a/plans/nat-breaking-changes.md b/plans/nat-breaking-changes.md new file mode 100644 index 0000000..426d82a --- /dev/null +++ b/plans/nat-breaking-changes.md @@ -0,0 +1,190 @@ +# NAT refactor: breaking changes + +This file tracks every breaking change introduced by the NAT taxonomy +redesign so the final PR description can cite them accurately. + +## Public API + +### `Nat` enum variants renamed + +Replaced deployment-flavored variants with a behavior gradient: + +| Old | New | +|-----|-----| +| `Nat::Home` | `Nat::Easy` | +| `Nat::Corporate` | `Nat::Hardest` | +| `Nat::Cgnat` | `Nat::Easiest` | +| `Nat::CloudNat` | `Nat::Hardest` | +| `Nat::FullCone` | `Nat::Easiest` plus `hairpin(true)` if hairpin is needed | +| `Nat::None`, `Nat::Custom` | unchanged | + +### `Nat::to_config()` returns `Option` + +Restored as a thin alias for `impl From for Option`. +Timeouts come from `ConntrackTimeouts::default()`; per-deployment +timeouts come from the matching `RouterPreset`. + +### `NatMapping` enum shape changed + +`EndpointDependent` now carries a `PortPreservation` payload. The +previous unit variant no longer exists. + +```rust +// before +NatMapping::EndpointDependent +// after +NatMapping::EndpointDependent(PortPreservation::Preserve) +NatMapping::EndpointDependent(PortPreservation::Random) +``` + +This makes EIM+Random structurally unrepresentable, replacing the old +silently-ignored combination. + +### `NatFiltering` gained `AddressDependent` + +Third variant for RFC 4787 Address-Dependent Filtering (RFC 3489 +"Restricted Cone"). nftables backend uses a dynamic `@contacted` set of +external IPs the internal side has reached and a matching rule in the +forward chain. + +### `PortPreservation` added + +Two variants: `Preserve` and `Random`. Only reachable via +`NatMapping::EndpointDependent(...)`. EIM always preserves ports and +offers no configuration. + +### `NatConfigBuilder::build()` returns `Result` + +The builder now validates cross-field invariants and returns an error +for: + +- `NatMapping::EndpointDependent(_)` with `NatFiltering::AddressDependent`. + The ADF implementation requires the fullcone map to deliver inbound + packets from any port on a contacted address. +- `NatMapping::EndpointDependent(_)` with `hairpin = true`. The hairpin + DNAT rule relies on the fullcone map. + +Previously both combinations compiled and produced subtly wrong +nftables rules. + +### `NatConfig` is `#[non_exhaustive]` + +Construction through `NatConfig::builder()` is the stable path. Direct +struct literals outside the crate no longer compile. `ConntrackTimeouts` +is also `#[non_exhaustive]`. + +### `RouterPreset` changes + +- Added `IspCgnatSymmetric`: EDM + APDF + Preserve, 180-second UDP + stream timeout, `BlockInbound` firewall. +- Added `MobileCarrier`: EDM + APDF + Preserve, 60-second UDP stream + timeout, `BlockInbound` firewall. +- `IspCgnat` changed from EIM + EIF to EIM + APDF with `BlockInbound` + firewall. Published measurement data (ipSpace, IMC'16, and observed + behavior on Swisscom, Deutsche Telekom, Starlink) supports EIM+APDF + as the typical RFC 6888 compliant deployment; EIF is rare. +- `IspCgnat` and `IspCgnatSymmetric` firewalls changed from + `Firewall::None` to `Firewall::BlockInbound` so IPv6 inbound is + blocked by default, matching Swisscom, Deutsche Telekom, Starlink. +- No variant named `IspCgnatHard`; the earlier draft used that name and + cited RFC 7753. The preset does not model Port Block Allocation, so + the name and citation were dropped. + +### `Router::nat_mode` renamed to `Router::nat_config` + +Returns `Option` (flat), matching the pattern of peer +accessors like `mtu()`, `uplink_ip()`, and `downstream_cidr()`. `None` +covers both "router removed" and "NAT disabled". Use `exists` / +`uplink_ip` checks if you need to distinguish. + +### `Router::set_nat_mode` renamed to `Router::set_nat` + +Matches the naming of `Router::set_firewall`. Accepts +`impl Into>`; `Nat`, `NatConfig`, `None`, and +`Some(config)` all compile. + +### `RouterBuilder::nat` signature widened + +Accepts `impl Into>`. `Nat::None` and `None` both +disable NAT. + +### `PortPreservation` and `NatConfigError` re-exported from the crate +root + +Previously unreachable for downstream users even though they appeared in +public function signatures. + +## Internal changes + +### `RouterConfig.nat` stores `Option` + +Was `Nat`. Call sites that set or read the field use the expanded +config directly. `RouterConfig::effective_nat_config()` was deleted; +call sites read `router_cfg.nat` directly. + +### `set_nat` deletes and re-creates tables instead of flushing + +Dynamic nftables sets (`@contacted` for ADF) survive `flush table`. The +runtime path now does `delete table` before reapplying, so mode +transitions start from a clean state. + +## Wire format changes + +### `RouterState.nat` + +JSON shape changed from a kebab-case preset string to a config object +or `null`: + +``` +// before +"nat": "home" + +// after +"nat": { + "mapping": { "endpoint_dependent": "preserve" }, + "filtering": "address_and_port_dependent", + "timeouts": { "udp": 30, "udp_stream": 300, "tcp_established": 7200 }, + "hairpin": false +} + +// or when NAT is disabled +"nat": null +``` + +The `mapping` field is either the string `"endpoint_independent"` or an +object like `{ "endpoint_dependent": "preserve" }` because +`NatMapping::EndpointDependent` carries `PortPreservation`. + +### `LabEventKind::NatChanged.nat` + +Same change as `RouterState.nat`. + +### TOML config + +The `[[router]] nat = "..."` field still accepts a `Nat` enum but the +accepted strings are now `none`, `easiest`, `easy`, `hard`, `hardest`, +`custom`. Old strings (`home`, `corporate`, `cgnat`, `cloud-nat`, +`full-cone`) fail to parse. + +## Test coverage added + +- `port_mapping_edm_preserve_stable`: validates that `Nat::Hard` + (`masquerade` without the `random` flag) preserves the internal source + port across destinations. If this test ever fails on a new kernel, + the `Nat::Hard`/`Nat::Hardest` distinction must be re-examined. +- `adf_allows_different_port_from_contacted_host`: positive path for + `NatFiltering::AddressDependent`. The contacted peer sends from a + different source port and the packet reaches the device. +- `adf_drops_from_uncontacted_host`: negative path for ADF. An + uncontacted external host sends to the mapped address and the packet + is dropped. +- `preset_nat_snapshots`: validates mapping, filtering, port + preservation, and UDP stream timeout for every `RouterPreset`. Changes + here must match the docs in `docs/guide/nat-and-firewalls.md` and the + TOML reference. +- `builder_rejects_edm_with_adf`, `builder_rejects_edm_with_hairpin`, + plus the matching positive cases: validate that the builder's + cross-field invariants hold. +- `nat_easiest_is_eim_eif`, `nat_easy_is_eim_apdf`, + `nat_hard_is_edm_preserve_apdf`, `nat_hardest_is_edm_random_apdf`: + pins the `Nat::*` preset expansions. From e9c56c6d63f80415605ccdc4eb01be7c25263da5 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 11:35:06 +0200 Subject: [PATCH 3/7] Address remaining review nits from second review pass - Clarify Nat::Custom docstring: prefer passing NatConfig directly to RouterBuilder::nat; reach for Custom when you need a Nat value (TOML, pattern matching). - Replace weasel wording ("practically impossible") in PortPreservation docs with a concrete statement ("hole-punching fails without a relay"). - Add a NatConfig doc example that exercises the new builder Result return, demonstrates EDM+Preserve, and imports PortPreservation. - Refresh the nat_rebind::mode_port_change test doc comment to name Nat::Easy and Nat::Hardest instead of the removed Home/Corporate variants. - Docs: nat-and-firewalls.md table now describes Easiest as a full-cone router (UPnP or static forwarding), not a CGNAT preset; the double-NAT example uses RouterPreset::IspCgnat for realistic CGNAT semantics. - TypeScript types: NatPreset union removed; Nat is now NatConfig | null; NatMapping mirrors the new Rust enum shape ("endpoint_independent" | { endpoint_dependent: PortPreservation }); NatFiltering gained "address_dependent". TopologyGraph.natLabel rewritten to render EIM/EDM + EIF/ADF/APDF from the structured value. All 223 tests pass; cargo clippy --workspace clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guide/nat-and-firewalls.md | 13 ++++++++----- patchbay/src/nat.rs | 27 ++++++++++++++++++++++---- patchbay/src/tests/nat_rebind.rs | 10 ++++++---- ui/src/components/TopologyGraph.tsx | 12 +++++++++--- ui/src/devtools-types.ts | 30 +++++++++++++++++++++-------- 5 files changed, 68 insertions(+), 24 deletions(-) diff --git a/docs/guide/nat-and-firewalls.md b/docs/guide/nat-and-firewalls.md index e40f3c5..6fea659 100644 --- a/docs/guide/nat-and-firewalls.md +++ b/docs/guide/nat-and-firewalls.md @@ -48,9 +48,9 @@ real-world device class: | Mode | Mapping | Filtering | Port allocation | Real-world model | |------|---------|-----------|-----------------|------------------| | `None` | n/a | n/a | n/a | Datacenter, public IPs | -| `Easiest` | Endpoint-independent | Endpoint-independent | Preserve | Full-cone router, RFC 6888 compliant CGNAT | +| `Easiest` | Endpoint-independent | Endpoint-independent | Preserve | Full-cone router with UPnP or static forwarding | | `Easy` | Endpoint-independent | Address-and-port-dependent | Preserve | Standard home WiFi router | -| `Hard` | Endpoint-dependent | Address-and-port-dependent | Preserve | PBA CGNAT, mobile carrier, port-preserving symmetric NAT | +| `Hard` | Endpoint-dependent | Address-and-port-dependent | Preserve | Mobile carrier, symmetric CGNAT, port-predictable symmetric NAT | | `Hardest` | Endpoint-dependent | Address-and-port-dependent | Random | Enterprise gateway, AWS/Azure/GCP NAT Gateway | For a deep dive into how these modes are implemented in nftables and how @@ -230,11 +230,14 @@ 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::Easiest).build().await?; +// Double NAT: ISP carrier-grade NAT in front of a home router. The +// `IspCgnat` preset gives realistic defaults (EIM + APDF, v6 inbound +// blocked); use `.nat(Nat::Easiest)` if you specifically want to model +// a full-cone CGNAT. +let isp = lab.add_router("isp").preset(RouterPreset::IspCgnat).build().await?; let home = lab.add_router("home") + .preset(RouterPreset::Home) .upstream(isp.id()) - .nat(Nat::Easy) .build().await?; ``` diff --git a/patchbay/src/nat.rs b/patchbay/src/nat.rs index 3c4f7ad..551c1ab 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -81,8 +81,12 @@ pub enum Nat { /// Fully custom NAT configuration. /// - /// Use this when the named presets do not cover the scenario. Construct - /// the config with [`NatConfig::builder`]. + /// [`RouterBuilder::nat`](crate::RouterBuilder::nat) and + /// [`Router::set_nat`](crate::Router::set_nat) both accept a + /// [`NatConfig`] directly, so `Nat::Custom` is rarely needed in + /// application code. Reach for it when you need a `Nat` value: for + /// TOML deserialization or when pattern-matching on a stored + /// preset. #[strum(disabled)] Custom(NatConfig), } @@ -174,8 +178,9 @@ pub enum PortPreservation { Preserve, /// Allocate a random external port for each flow. /// - /// Linux `masquerade random`. Combined with EDM this makes hole-punching - /// practically impossible. + /// Linux `masquerade random`. Combined with EDM this produces a + /// fresh, unpredictable external port per flow; hole-punching fails + /// without a relay. Random, } @@ -277,6 +282,20 @@ impl std::error::Error for NatConfigError {} /// Each preset ([`Nat::Easy`], [`Nat::Hard`], etc.) expands via /// [`Nat::to_config`]. Custom configurations are built with /// [`NatConfig::builder`], which validates cross-field invariants. +/// +/// # Example +/// ``` +/// # use patchbay::{NatConfig, NatMapping, NatFiltering, PortPreservation}; +/// // Port-preserving symmetric NAT with a short UDP timeout, matching a +/// // mobile carrier CGN. +/// let cfg = NatConfig::builder() +/// .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) +/// .filtering(NatFiltering::AddressAndPortDependent) +/// .udp_stream_timeout(60) +/// .build() +/// .expect("valid combination"); +/// assert_eq!(cfg.timeouts.udp_stream, 60); +/// ``` #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub struct NatConfig { diff --git a/patchbay/src/tests/nat_rebind.rs b/patchbay/src/tests/nat_rebind.rs index 3d7d3ac..4686d3d 100644 --- a/patchbay/src/tests/nat_rebind.rs +++ b/patchbay/src/tests/nat_rebind.rs @@ -6,14 +6,16 @@ use super::*; -/// Changing NAT mode between Home (EIM) and Corporate (EDM) flips port stability. +/// Switching between `Nat::Easy` (EIM, stable port) and `Nat::Hardest` +/// (EDM with random port allocation) flips observed port stability. /// -/// Home → Corporate: previously stable external port now varies per destination. -/// Corporate → Home: previously varying port now stays stable. +/// Easy to Hardest: the previously stable external port now varies per +/// destination because random port allocation kicks in. +/// Hardest to Easy: the previously varying port stabilises because EIM +/// assigns the same external port for all destinations. #[tokio::test(flavor = "current_thread")] #[traced_test] async fn mode_port_change() -> Result<()> { - // Home→Corporate: port changes (EIM→EDM); Corporate→Home: port stabilises. let cases: &[(Nat, Nat, bool)] = &[ (Nat::Easy, Nat::Hardest, false), (Nat::Hardest, Nat::Easy, true), diff --git a/ui/src/components/TopologyGraph.tsx b/ui/src/components/TopologyGraph.tsx index 1d1901b..b226ccd 100644 --- a/ui/src/components/TopologyGraph.tsx +++ b/ui/src/components/TopologyGraph.tsx @@ -44,9 +44,15 @@ function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { } function natLabel(nat: Nat): string { - if (typeof nat === 'string') return nat - if ('custom' in nat) return 'custom' - return '?' + if (nat === null) return 'none' + const mapping = typeof nat.mapping === 'string' ? 'EIM' : 'EDM' + const filtering = + nat.filtering === 'endpoint_independent' + ? 'EIF' + : nat.filtering === 'address_dependent' + ? 'ADF' + : 'APDF' + return `${mapping}+${filtering}` } function selCls(nodeId: string, sel: string | null | undefined): string { diff --git a/ui/src/devtools-types.ts b/ui/src/devtools-types.ts index 757b014..826e568 100644 --- a/ui/src/devtools-types.ts +++ b/ui/src/devtools-types.ts @@ -1,18 +1,32 @@ // ── NAT types ── -/** NAT behavior preset (kebab-case from Rust `Nat` enum). */ -export type NatPreset = 'none' | 'home' | 'corporate' | 'cgnat' | 'cloud-nat' | 'full-cone' - -/** Custom NAT configuration (Rust `Nat::Custom(NatConfig)`). */ +/** Port allocation for EDM. Matches Rust `PortPreservation`. */ +export type PortPreservation = 'preserve' | 'random' + +/** Matches Rust `NatMapping`. EIM has no payload; EDM carries + * a `PortPreservation` tag. */ +export type NatMapping = + | 'endpoint_independent' + | { endpoint_dependent: PortPreservation } + +/** Matches Rust `NatFiltering`. */ +export type NatFiltering = + | 'endpoint_independent' + | 'address_dependent' + | 'address_and_port_dependent' + +/** Matches Rust `NatConfig`. Serialized directly as the value of + * `RouterState.nat` (or `null` when NAT is disabled). */ export interface NatConfig { - mapping: 'endpoint_independent' | 'endpoint_dependent' - filtering: 'endpoint_independent' | 'address_and_port_dependent' + mapping: NatMapping + filtering: NatFiltering timeouts: { udp: number; udp_stream: number; tcp_established: number } hairpin: boolean } -/** Matches Rust `Nat` — either a preset string or `{ custom: NatConfig }`. */ -export type Nat = NatPreset | { custom: NatConfig } +/** Serialized form of `Option` in `RouterState.nat` and + * `LabEventKind::NatChanged`. `null` means NAT is disabled. */ +export type Nat = NatConfig | null /** Matches Rust `NatV6Mode`. */ export type NatV6Mode = 'none' | 'nptv6' | 'masquerade' | 'nat64' From c337f085fa768701a8176be4eb45cfed4c87f02a Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 11:52:06 +0200 Subject: [PATCH 4/7] Resolve remaining review nits from second re-review pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs: replace stale `set_nat_mode` with `set_nat` in README and two guide files. The rename from the previous commit missed these prose-only markdown blocks. - docs: `holepunching.md` table no longer claims `Nat::Easiest` models RFC 6888 compliant CGNAT; the `IspCgnat` preset row now maps to `Nat::Easy` per the refactor's own thesis. - docs: `NatConfig` struct snippet in `holepunching.md` reflects the actual field list (no `port_preservation` field; noting it lives inside `NatMapping::EndpointDependent(_)`), and mentions the builder Result and its rejected combinations. - docs: `NatConfig::builder().build()` examples in `nat-and-firewalls.md` and `patterns.md` now handle the builder's `Result` return. - docs: crate-level preset table in `lib.rs` and `topology.md` preset table list all ten presets including the new `IspCgnatSymmetric` and `MobileCarrier`, and name the underlying `Nat` variants accurately. - docs: README NAT section names the current `Nat` variants (None/Easiest/Easy/Hard/Hardest/Custom), drops the six-variant claim from before the refactor. - docs: `patterns.md` "cell_router" example uses `RouterPreset::MobileCarrier` instead of `Nat::Easiest`. The mechanical `Cgnat → Easiest` rename in the earlier commit preserved the previous (also-wrong) semantics; mobile is Hard, not Easiest. - nft.rs: EDM + hairpin branch is now `unreachable!()` guarded by the builder invariant `NatConfigError::HairpinRequiresEim`, with a clear panic message if the invariant is ever violated. Removes the latent `redirect` bug the reviewer flagged as pre-existing. - nft.rs: inline comment at the postrouting rules correctly says `masquerade`, not `snat`. - nat.rs: `NatConfigError` is `#[non_exhaustive]`. - nat.rs: `NatConfig` doc comment explicitly documents that post-build field mutation bypasses cross-field validation, so callers know the invariants are enforced at `build()` time only. - tests: `preset_nat_snapshots` now pins firewall, ip_support, nat_v6, and `hairpin = false` in addition to mapping/filtering/preservation/ udp_stream, preventing future silent drift on any preset dimension. All 223 tests pass; `cargo clippy --workspace --all-targets` clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 22 ++-- docs/guide/nat-and-firewalls.md | 13 ++- docs/guide/running-code.md | 2 +- docs/guide/topology.md | 18 +-- docs/reference/holepunching.md | 35 +++--- docs/reference/patterns.md | 13 ++- patchbay/src/lib.rs | 17 +-- patchbay/src/nat.rs | 15 ++- patchbay/src/nft.rs | 16 ++- patchbay/src/router.rs | 15 +++ patchbay/src/tests/preset.rs | 189 +++++++++++++++++++++++++------- 11 files changed, 257 insertions(+), 98 deletions(-) diff --git a/README.md b/README.md index c9c10db..3491716 100644 --- a/README.md +++ b/README.md @@ -131,18 +131,20 @@ let dc = lab.add_router("dc").preset(RouterPreset::Public).build().await?; let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?; ``` -Available presets: `Home`, `Public`, `PublicV4`, `IspCgnat`, `IspV6`, -`Corporate`, `Hotel`, `Cloud`. Individual methods called after -`preset()` override preset values. See -[docs/reference/ipv6.md](docs/reference/ipv6.md) for the full reference -table. +Available presets: `Home`, `Public`, `PublicV4`, `IspCgnat`, +`IspCgnatSymmetric`, `MobileCarrier`, `IspV6`, `Corporate`, `Hotel`, +`Cloud`. Individual methods called after `preset()` override preset +values. See [docs/reference/ipv6.md](docs/reference/ipv6.md) for the +full reference table. ### NAT -Routers support six IPv4 NAT presets (`None`, `Home`, `Corporate`, -`CloudNat`, `FullCone`, `Cgnat`) and four IPv6 modes (`None`, `Nptv6`, -`Masquerade`, `Nat64`), all configured via nftables rules. You can also -build custom NAT configs from mapping + filtering + timeout parameters. +The `Nat` enum describes IPv4 NAT behavior on a difficulty gradient: +`None`, `Easiest` (EIM + EIF), `Easy` (EIM + APDF), `Hard` (EDM with +port preservation), `Hardest` (EDM with random ports), and `Custom`. +IPv6 has four modes: `None`, `Nptv6`, `Masquerade`, `Nat64`. Both are +implemented with nftables. Build custom NAT configs from mapping plus +filtering plus timeout parameters with `NatConfig::builder()`. **NAT64** provides IPv4 access for IPv6-only devices via the well-known prefix `64:ff9b::/96`. A userspace SIIT translator on the router converts @@ -290,7 +292,7 @@ dev.iface("wlan0").unwrap().set_condition(LinkCondition::Manual(LinkLimits { }), LinkDirection::Both).await?; // Change NAT mode at runtime. -router.set_nat_mode(Nat::Hardest).await?; +router.set_nat(Nat::Hardest).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/nat-and-firewalls.md b/docs/guide/nat-and-firewalls.md index 6fea659..d6296ce 100644 --- a/docs/guide/nat-and-firewalls.md +++ b/docs/guide/nat-and-firewalls.md @@ -64,17 +64,24 @@ directly and choose the mapping, filtering, and timeout behavior independently: ```rust -use patchbay::nat::{NatConfig, NatMapping, NatFiltering}; +use patchbay::{NatConfig, NatFiltering, NatMapping}; let custom = NatConfig::builder() .mapping(NatMapping::EndpointIndependent) .filtering(NatFiltering::EndpointIndependent) .hairpin(true) - .build(); + .build()?; let router = lab.add_router("custom").nat(custom).build().await?; ``` +`NatConfigBuilder::build` returns `Result`. +The builder rejects unsupported combinations up front: `EndpointDependent` +mapping paired with `AddressDependent` filtering or with +`hairpin = true` both return an error because the backend cannot +express them. The textbook NAT shapes (EIM with any filtering, EDM with +APDF) all succeed. + ### Changing NAT at runtime You can switch a router's NAT mode after the topology is built. This is @@ -84,7 +91,7 @@ changes mid-session, for example simulating a network migration. Call new connections use the updated rules: ```rust -router.set_nat_mode(Nat::Hardest).await?; +router.set_nat(Nat::Hardest).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index bb21f2c..45cfcd9 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -193,7 +193,7 @@ This is covered in more detail in the [NAT and Firewalls](nat-and-firewalls.md) chapter: ```rust -router.set_nat_mode(Nat::Hardest).await?; +router.set_nat(Nat::Hardest).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 06a830d..637d8a1 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -66,20 +66,22 @@ the preset configures: | Preset | NAT | Firewall | IP support | Pool | |--------|-----|----------|------------|------| -| `Home` | Home (EIM+APDF) | BlockInbound | DualStack | Private | +| `Home` | `Easy` (EIM+APDF, preserve) | BlockInbound | DualStack | Private | | `Public` | None | None | DualStack | Public | | `PublicV4` | None | None | V4Only | Public | -| `IspCgnat` | Cgnat (EIM+EIF) | None | DualStack | Private | -| `IspV6` | None (v4) / Nat64 (v6) | BlockInbound | V6Only | Public | -| `Corporate` | Corporate (sym) | Corporate | DualStack | Private | -| `Hotel` | Corporate (sym) | CaptivePortal | V4Only | Private | -| `Cloud` | CloudNat (sym) | None | DualStack | Private | +| `IspCgnat` | `Easy` (EIM+APDF, preserve) | BlockInbound | DualStack | Private | +| `IspCgnatSymmetric` | `Hard` (EDM+APDF, preserve) | BlockInbound | DualStack | Private | +| `MobileCarrier` | `Hard` (EDM+APDF, preserve) | BlockInbound | DualStack | Private | +| `IspV6` | None (v4), NAT64 (v6) | BlockInbound | V6Only | Public | +| `Corporate` | `Hardest` (EDM+APDF, random) | Corporate | DualStack | Private | +| `Hotel` | `Hardest` (EDM+APDF, random) | CaptivePortal | V4Only | Private | +| `Cloud` | `Hardest` (EDM+APDF, random) | None | DualStack | Private | 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::Easiest)` gives you a -home-style topology with fullcone NAT instead of the default -endpoint-dependent filtering. +home topology with full-cone NAT instead of the default +port-restricted filtering. ### Address families diff --git a/docs/reference/holepunching.md b/docs/reference/holepunching.md index 2913479..c563d14 100644 --- a/docs/reference/holepunching.md +++ b/docs/reference/holepunching.md @@ -26,10 +26,10 @@ simulates: | Preset | Mapping | Filtering | Port preservation | Hole-punch? | Real-world examples | |--------|---------|-----------|-------------------|-------------|---------------------| -| `Nat::Easiest` | EIM | EIF | Preserve | Always | Older consumer routers, RFC 6888 compliant fiber CGNAT | -| `Nat::Easy` | EIM | APDF | Preserve | Yes, via UDP hole-punching with simultaneous send | FritzBox, Unifi, TP-Link, ASUS RT, OpenWRT | -| `Nat::Hard` | EDM | APDF | Preserve | Sometimes, with port prediction | PBA CGNAT, mobile carriers, some stateful firewalls | -| `Nat::Hardest` | EDM | APDF | Random | Practically never | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway | +| `Nat::Easiest` | EIM | EIF | Preserve | Always | Older consumer routers, routers with UPnP or static forwarding | +| `Nat::Easy` | EIM | APDF | Preserve | Yes, via UDP hole-punching with simultaneous send | Most home routers (FritzBox, Unifi, TP-Link, ASUS, OpenWRT); typical RFC 6888 compliant CGNAT | +| `Nat::Hard` | EDM | APDF | Preserve | Sometimes, with port prediction | Mobile carriers, symmetric CGNAT, some stateful firewalls | +| `Nat::Hardest` | EDM | APDF | Random | No, requires a relay | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway | ## The fullcone dynamic map @@ -209,23 +209,28 @@ directions. ## NatConfig architecture The `Nat` enum provides named presets. Each preset converts into an -`Option` via `impl From` that drives rule generation: +`Option` via `Nat::to_config` that drives rule generation: ```rust pub struct NatConfig { - pub mapping: NatMapping, // EIM or EDM - pub filtering: NatFiltering, // EIF, ADF, or APDF - pub port_preservation: PortPreservation, // Preserve or Random - pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established - pub hairpin: bool, // loopback NAT + pub mapping: NatMapping, // EndpointIndependent + // | EndpointDependent(PortPreservation) + pub filtering: NatFiltering, // EIF, ADF, or APDF + pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established + pub hairpin: bool, // loopback NAT; EIM only } ``` -The `generate_nat_rules()` function in `nft.rs` builds nftables rules -from `NatConfig` alone, without matching on `Nat` variants. Users can -either use the named presets (`router.nat(Nat::Easy)`) or build custom -configurations with arbitrary mapping, filtering, and port-preservation -combinations. +Port preservation lives inside `NatMapping::EndpointDependent(_)` because +EIM always preserves the source port via the fullcone map and has no +configuration. `NatConfigBuilder::build` returns a `Result` and rejects +combinations the backend cannot express: `EndpointDependent` mapping paired +with `AddressDependent` filtering or with `hairpin = true`. + +The `generate_nat_rules` function in `nft.rs` builds nftables rules from +`NatConfig` alone, without matching on `Nat` variants. Users can either +use the named presets (`router.nat(Nat::Easy)`) or build custom +configurations via `NatConfig::builder`. ## NPTv6 implementation notes diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index b868684..fe5da2f 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -155,13 +155,13 @@ Test by waiting beyond the timeout period then verifying connectivity. ```rust // Custom short timeout for fast testing let nat = lab.add_router("nat") - .nat(Nat::Custom( + .nat( NatConfig::builder() .mapping(NatMapping::EndpointIndependent) .filtering(NatFiltering::AddressAndPortDependent) - .udp_timeout(5) // seconds, short for testing - .build(), - )) + .udp_timeout(5) // seconds, short for testing + .build()?, + ) .build().await?; // Wait for timeout, verify mapping expired @@ -184,7 +184,10 @@ address is assigned. ```rust let wifi_router = lab.add_router("wifi").nat(Nat::Easy).build().await?; -let cell_router = lab.add_router("cell").nat(Nat::Easiest).build().await?; +let cell_router = lab + .add_router("cell") + .preset(RouterPreset::MobileCarrier) + .build().await?; let device = lab.add_device("phone") .iface("eth0", wifi_router.id()) diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index 935d499..e9bdf3e 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -98,15 +98,18 @@ //! builder methods called after [`RouterBuilder::preset`] override preset //! values. //! -//! | Preset | NAT | Firewall | IP | Use case | -//! |--------|-----|----------|----|----------| -//! | [`Home`](RouterPreset::Home) | EIM + APDF | Block inbound | Dual | Consumer router (FritzBox, UniFi) | +//! | Preset | NAT (kind, port alloc.) | Firewall | IP | Use case | +//! |--------|-------------------------|----------|----|----------| +//! | [`Home`](RouterPreset::Home) | `Easy` (EIM+APDF, preserve) | Block inbound | Dual | Consumer router (FritzBox, UniFi) | //! | [`Public`](RouterPreset::Public) | None | None | Dual | Datacenter switch, ISP handoff | -//! | [`IspCgnat`](RouterPreset::IspCgnat) | CGNAT (EIM) | None | Dual | Carrier with shared IPv4 | +//! | [`PublicV4`](RouterPreset::PublicV4) | None | None | V4 | Legacy v4-only hosting | +//! | [`IspCgnat`](RouterPreset::IspCgnat) | `Easy` (EIM+APDF, preserve) | Block inbound | Dual | RFC 6888 compliant CGNAT | +//! | [`IspCgnatSymmetric`](RouterPreset::IspCgnatSymmetric) | `Hard` (EDM+APDF, preserve) | Block inbound | Dual | Symmetric CGNAT | +//! | [`MobileCarrier`](RouterPreset::MobileCarrier) | `Hard` (EDM+APDF, preserve) | Block inbound | Dual | Typical LTE/5G carrier | //! | [`IspV6`](RouterPreset::IspV6) | NAT64 | Block inbound | V6 | T-Mobile, Jio-style IPv6-only | -//! | [`Corporate`](RouterPreset::Corporate) | Symmetric | TCP 80/443 only | Dual | Enterprise firewall | -//! | [`Hotel`](RouterPreset::Hotel) | Symmetric | No UDP | V4 | Guest WiFi | -//! | [`Cloud`](RouterPreset::Cloud) | Symmetric | None | Dual | AWS/GCP NAT gateway | +//! | [`Corporate`](RouterPreset::Corporate) | `Hardest` (EDM+APDF, random) | TCP 80/443, UDP 53 only | Dual | Enterprise firewall | +//! | [`Hotel`](RouterPreset::Hotel) | `Hardest` (EDM+APDF, random) | Captive portal (no UDP) | V4 | Guest WiFi | +//! | [`Cloud`](RouterPreset::Cloud) | `Hardest` (EDM+APDF, random) | None | Dual | AWS/GCP/Azure NAT gateway | //! //! # Link conditions //! diff --git a/patchbay/src/nat.rs b/patchbay/src/nat.rs index 551c1ab..c803f54 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -242,6 +242,7 @@ impl Default for ConntrackTimeouts { /// Errors returned by [`NatConfigBuilder::build`] when the requested /// combination cannot be expressed in nftables. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] pub enum NatConfigError { /// Address-Dependent filtering requires Endpoint-Independent mapping. /// @@ -281,7 +282,19 @@ impl std::error::Error for NatConfigError {} /// /// Each preset ([`Nat::Easy`], [`Nat::Hard`], etc.) expands via /// [`Nat::to_config`]. Custom configurations are built with -/// [`NatConfig::builder`], which validates cross-field invariants. +/// [`NatConfig::builder`], which validates cross-field invariants at +/// construction time. +/// +/// The struct is `#[non_exhaustive]` so only the builder and the `Nat` +/// conversion can produce values; callers cannot write a struct literal. +/// Fields remain `pub` for ergonomic read access and pattern matching. +/// Mutating a field on an existing `NatConfig` bypasses the builder's +/// cross-field validation: for example, setting +/// `cfg.mapping = NatMapping::EndpointDependent(_)` on a config with +/// `AddressDependent` filtering or `hairpin = true` produces a combination +/// the nftables backend cannot express. Callers that mutate fields are +/// responsible for maintaining these invariants; the library does not +/// re-validate on apply. /// /// # Example /// ``` diff --git a/patchbay/src/nft.rs b/patchbay/src/nft.rs index b7cc7ea..1f94369 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -102,16 +102,20 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String ) } } else if hairpin { - // EDM + hairpin: redirect traffic destined for the WAN IP back. - format!(r#" ip daddr {ip} redirect"#, ip = wan_ip,) + unreachable!( + "NatConfigBuilder rejects EndpointDependent + hairpin \ + (NatConfigError::HairpinRequiresEim); reaching this branch \ + would mean an invalid NatConfig slipped past validation" + ) } else { String::new() }; - // Postrouting: EIM uses snat + fullcone map update. EDM uses snat with a - // per-preservation flag. With hairpin: masquerade DNAT'd packets so the - // return path goes through the router (otherwise the LAN peer replies - // directly, confusing conntrack). + // Postrouting: EIM uses snat + fullcone map update. EDM uses masquerade + // with or without the `random` flag based on port preservation. With + // hairpin (EIM only): masquerade DNAT'd packets so the return path goes + // through the router; otherwise the LAN peer replies directly and + // confuses conntrack. let hairpin_masq = if hairpin { " ct status dnat masquerade\n".to_string() } else { diff --git a/patchbay/src/router.rs b/patchbay/src/router.rs index 189ad7a..e114991 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -857,6 +857,21 @@ impl RouterPreset { self.nat() } + #[cfg(test)] + pub(crate) fn firewall_for_tests(self) -> Firewall { + self.firewall() + } + + #[cfg(test)] + pub(crate) fn nat_v6_for_tests(self) -> NatV6Mode { + self.nat_v6() + } + + #[cfg(test)] + pub(crate) fn ip_support_for_tests(self) -> IpSupport { + self.ip_support() + } + fn nat(self) -> Option { // Start from the matching Nat preset and then override timeouts per // deployment. `Nat::*.to_config()` supplies the correct mapping, diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index 7aa2ef5..a395456 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -167,65 +167,170 @@ async fn preset_override() -> Result<()> { Ok(()) } -/// Snapshot of each preset's NAT config. Changes here are intentional and -/// should be matched by a corresponding update in the docs -/// (`docs/guide/nat-and-firewalls.md` preset table) and in -/// `plans/nat-breaking-changes.md`. +/// Snapshot of each preset's NAT config, firewall, IP support, and IPv6 +/// NAT mode. Changes here are intentional and should be matched by a +/// corresponding update in the docs (`docs/guide/nat-and-firewalls.md` +/// preset table) and in `plans/nat-breaking-changes.md`. #[test] fn preset_nat_snapshots() { - use crate::{Nat, NatMapping, PortPreservation}; - - // (preset, expected_nat_kind_or_none, udp_stream_seconds_or_none) - let cases = [ - (RouterPreset::Home, Some(Nat::Easy), Some(300u32)), - (RouterPreset::IspCgnat, Some(Nat::Easy), Some(300)), - (RouterPreset::IspCgnatSymmetric, Some(Nat::Hard), Some(180)), - (RouterPreset::MobileCarrier, Some(Nat::Hard), Some(60)), - (RouterPreset::Corporate, Some(Nat::Hardest), Some(120)), - (RouterPreset::Hotel, Some(Nat::Hardest), Some(120)), - (RouterPreset::Cloud, Some(Nat::Hardest), Some(350)), - (RouterPreset::Public, None, None), - (RouterPreset::PublicV4, None, None), - (RouterPreset::IspV6, None, None), + use crate::{Firewall, IpSupport, Nat, NatMapping, NatV6Mode, PortPreservation}; + + struct Expect { + nat_kind: Option, + udp_stream: Option, + firewall: Firewall, + ip_support: IpSupport, + nat_v6: NatV6Mode, + } + + let cases: &[(RouterPreset, Expect)] = &[ + ( + RouterPreset::Home, + Expect { + nat_kind: Some(Nat::Easy), + udp_stream: Some(300), + firewall: Firewall::BlockInbound, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::IspCgnat, + Expect { + nat_kind: Some(Nat::Easy), + udp_stream: Some(300), + firewall: Firewall::BlockInbound, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::IspCgnatSymmetric, + Expect { + nat_kind: Some(Nat::Hard), + udp_stream: Some(180), + firewall: Firewall::BlockInbound, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::MobileCarrier, + Expect { + nat_kind: Some(Nat::Hard), + udp_stream: Some(60), + firewall: Firewall::BlockInbound, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Corporate, + Expect { + nat_kind: Some(Nat::Hardest), + udp_stream: Some(120), + firewall: Firewall::Corporate, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Hotel, + Expect { + nat_kind: Some(Nat::Hardest), + udp_stream: Some(120), + firewall: Firewall::CaptivePortal, + ip_support: IpSupport::V4Only, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Cloud, + Expect { + nat_kind: Some(Nat::Hardest), + udp_stream: Some(350), + firewall: Firewall::None, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Public, + Expect { + nat_kind: None, + udp_stream: None, + firewall: Firewall::None, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::PublicV4, + Expect { + nat_kind: None, + udp_stream: None, + firewall: Firewall::None, + ip_support: IpSupport::V4Only, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::IspV6, + Expect { + nat_kind: None, + udp_stream: None, + firewall: Firewall::BlockInbound, + ip_support: IpSupport::V6Only, + nat_v6: NatV6Mode::Nat64, + }, + ), ]; - for (preset, expected_kind, expected_udp_stream) in cases { + for (preset, expect) in cases { + let preset = *preset; let cfg = preset.nat_config(); - match (cfg, expected_kind) { + match (cfg, expect.nat_kind) { (None, None) => {} (Some(actual), Some(kind)) => { let expected_cfg = kind.to_config().expect("kind is not Nat::None"); - assert_eq!( - actual.mapping, expected_cfg.mapping, - "{preset:?}: mapping mismatch" - ); + assert_eq!(actual.mapping, expected_cfg.mapping, "{preset:?}: mapping"); assert_eq!( actual.filtering, expected_cfg.filtering, - "{preset:?}: filtering mismatch" + "{preset:?}: filtering" ); - let udp_stream = expected_udp_stream.expect("set when kind is Some"); + assert!(!actual.hairpin, "{preset:?}: hairpin must default to false"); + let udp_stream = expect.udp_stream.expect("set when kind is Some"); assert_eq!( actual.timeouts.udp_stream, udp_stream, - "{preset:?}: udp_stream mismatch" + "{preset:?}: udp_stream" ); + if let NatMapping::EndpointDependent(pp) = actual.mapping { + let expected_pp = match kind { + Nat::Hard => PortPreservation::Preserve, + Nat::Hardest => PortPreservation::Random, + _ => unreachable!("EDM presets map to Hard or Hardest only"), + }; + assert_eq!(pp, expected_pp, "{preset:?}: port preservation"); + } } (actual, expected) => { - panic!( - "{preset:?}: config mismatch: actual={actual:?}, expected_kind={expected:?}" - ); - } - } - // Cross-check that EDM presets carry the correct port-preservation - // tag: Hard → Preserve, Hardest → Random. - if let Some(actual) = cfg { - if let NatMapping::EndpointDependent(pp) = actual.mapping { - let expected_pp = match expected_kind { - Some(Nat::Hard) => PortPreservation::Preserve, - Some(Nat::Hardest) => PortPreservation::Random, - _ => unreachable!("EDM presets map to Hard or Hardest only"), - }; - assert_eq!(pp, expected_pp, "{preset:?}: port preservation mismatch"); + panic!("{preset:?}: nat mismatch: actual={actual:?}, expected={expected:?}"); } } + assert_eq!( + preset.firewall_for_tests(), + expect.firewall, + "{preset:?}: firewall" + ); + assert_eq!( + preset.ip_support_for_tests(), + expect.ip_support, + "{preset:?}: ip_support" + ); + assert_eq!( + preset.nat_v6_for_tests(), + expect.nat_v6, + "{preset:?}: nat_v6" + ); } } From 8d2be0e3a2fd2b6ac4246e32f98a4f9a76bc4321 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 12:18:36 +0200 Subject: [PATCH 5/7] Collapse Nat to 3 tiers, make EDM always random, doc the limits Drop the four-tier Easiest/Easy/Hard/Hardest gradient in favor of the three-tier Easiest/Easy/Hard the initial design sketch proposed. Drop PortPreservation entirely and make NatMapping unit variants only. Why: Linux nftables cannot produce port-preserving symmetric NAT (SYMPP) distinguishably from EIM-under-light-load. `masquerade` without flags preserves the source port whenever the 4-tuple is free, which for a single internal source across multiple destinations converges on EIM-looking behavior. Shipping a `Nat::Hard` preset that promised SYMPP semantics while producing what was observationally EIM+APDF misled users running hole-punching test suites. Collapsing to three tiers keeps the API honest and matches real deployments: modern enterprise firewalls and cloud NAT gateways (the bulk of symmetric-NAT deployments in 2026) already use random port allocation by default. Applications that work against `Nat::Hard` with random ports work against every symmetric NAT they will encounter in the wild; the pessimistic model is the right default. Changes: - `Nat` drops `Hardest`; `Hard` is now EDM + APDF with random ports. - `NatMapping::EndpointDependent` is a unit variant again (no `PortPreservation` payload). The `PortPreservation` type is removed. - `nft.rs` emits `masquerade random` for every EDM config. The `masquerade` (no flag) branch is gone. - Presets `MobileCarrier` and `IspCgnatSymmetric` both map to `Nat::Hard` with their deployment-specific timeouts. Their docstrings explicitly flag that real-world SYMPP behavior is not simulated distinctly. - New `docs/reference/nat-limitations.md` documents what classes of NAT patchbay does not simulate faithfully and how a future backend could close each gap (custom kernel module, dynamic sets plus numgen, userspace NAT). - The `port_mapping_edm_preserve_stable` test is removed; it was validating kernel behavior we no longer rely on. The builder-level validation tests and the ADF positive/negative tests remain. - Preset snapshot test simplified: no more port-preservation cross- check because there is nothing to check. - TypeScript bindings: `NatMapping` simplifies to a two-member union; `PortPreservation` type removed. All 220 tests pass; cargo clippy --workspace --all-targets clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/guide/nat-and-firewalls.md | 28 ++-- docs/guide/running-code.md | 2 +- docs/guide/topology.md | 14 +- docs/reference/holepunching.md | 52 +++---- docs/reference/nat-limitations.md | 200 ++++++++++++++++++++++++++ docs/reference/patterns.md | 6 +- docs/reference/toml-reference.md | 3 +- patchbay/src/event.rs | 2 +- patchbay/src/lab.rs | 2 +- patchbay/src/lib.rs | 22 +-- patchbay/src/nat.rs | 136 ++++++------------ patchbay/src/nft.rs | 24 ++-- patchbay/src/router.rs | 51 ++++--- patchbay/src/tests/nat.rs | 52 ++----- patchbay/src/tests/nat_rebind.rs | 9 +- patchbay/src/tests/preset.rs | 16 +-- patchbay/src/tests/route.rs | 4 +- plans/nat-breaking-changes.md | 211 ++++++++++------------------ ui/src/components/TopologyGraph.tsx | 2 +- ui/src/devtools-types.ts | 10 +- 20 files changed, 450 insertions(+), 396 deletions(-) create mode 100644 docs/reference/nat-limitations.md diff --git a/docs/guide/nat-and-firewalls.md b/docs/guide/nat-and-firewalls.md index d6296ce..284321c 100644 --- a/docs/guide/nat-and-firewalls.md +++ b/docs/guide/nat-and-firewalls.md @@ -41,17 +41,21 @@ use patchbay::Nat; let home = lab.add_router("home").nat(Nat::Easy).build().await?; ``` -The `Nat` enum forms a gradient of hole-punching difficulty. Each variant -combines a mapping, filtering, and port-preservation choice to match a -real-world device class: - -| Mode | Mapping | Filtering | Port allocation | Real-world model | -|------|---------|-----------|-----------------|------------------| -| `None` | n/a | n/a | n/a | Datacenter, public IPs | -| `Easiest` | Endpoint-independent | Endpoint-independent | Preserve | Full-cone router with UPnP or static forwarding | -| `Easy` | Endpoint-independent | Address-and-port-dependent | Preserve | Standard home WiFi router | -| `Hard` | Endpoint-dependent | Address-and-port-dependent | Preserve | Mobile carrier, symmetric CGNAT, port-predictable symmetric NAT | -| `Hardest` | Endpoint-dependent | Address-and-port-dependent | Random | Enterprise gateway, AWS/Azure/GCP NAT Gateway | +The `Nat` enum forms a three-tier gradient of hole-punching difficulty: + +| Mode | Mapping | Filtering | Real-world model | +|------|---------|-----------|------------------| +| `None` | n/a | n/a | Datacenter, public IPs | +| `Easiest` | Endpoint-independent | Endpoint-independent | Full-cone router with UPnP or static forwarding | +| `Easy` | Endpoint-independent | Address-and-port-dependent | Standard home WiFi router, RFC 6888 compliant CGNAT | +| `Hard` | Endpoint-dependent | Address-and-port-dependent | Enterprise gateway, cloud NAT, mobile carrier, symmetric CGNAT | + +`Hard` covers every symmetric NAT deployment: each destination gets a +fresh, random external port. Port-preserving symmetric NAT (SYMPP) is a +real middle tier in the wild (punchable through port prediction) but +patchbay does not model it distinctly. See +[NAT simulation limits](../reference/nat-limitations.md) for the +reason. For a deep dive into how these modes are implemented in nftables and how hole-punching works across them, see the @@ -91,7 +95,7 @@ changes mid-session, for example simulating a network migration. Call new connections use the updated rules: ```rust -router.set_nat(Nat::Hardest).await?; +router.set_nat(Nat::Hard).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index 45cfcd9..923d121 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -193,7 +193,7 @@ This is covered in more detail in the [NAT and Firewalls](nat-and-firewalls.md) chapter: ```rust -router.set_nat(Nat::Hardest).await?; +router.set_nat(Nat::Hard).await?; router.flush_nat_state().await?; ``` diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 637d8a1..8897cfb 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -66,16 +66,16 @@ the preset configures: | Preset | NAT | Firewall | IP support | Pool | |--------|-----|----------|------------|------| -| `Home` | `Easy` (EIM+APDF, preserve) | BlockInbound | DualStack | Private | +| `Home` | `Easy` (EIM+APDF) | BlockInbound | DualStack | Private | | `Public` | None | None | DualStack | Public | | `PublicV4` | None | None | V4Only | Public | -| `IspCgnat` | `Easy` (EIM+APDF, preserve) | BlockInbound | DualStack | Private | -| `IspCgnatSymmetric` | `Hard` (EDM+APDF, preserve) | BlockInbound | DualStack | Private | -| `MobileCarrier` | `Hard` (EDM+APDF, preserve) | BlockInbound | DualStack | Private | +| `IspCgnat` | `Easy` (EIM+APDF) | BlockInbound | DualStack | Private | +| `IspCgnatSymmetric` | `Hard` (EDM+APDF, random) | BlockInbound | DualStack | Private | +| `MobileCarrier` | `Hard` (EDM+APDF, random) | BlockInbound | DualStack | Private | | `IspV6` | None (v4), NAT64 (v6) | BlockInbound | V6Only | Public | -| `Corporate` | `Hardest` (EDM+APDF, random) | Corporate | DualStack | Private | -| `Hotel` | `Hardest` (EDM+APDF, random) | CaptivePortal | V4Only | Private | -| `Cloud` | `Hardest` (EDM+APDF, random) | None | DualStack | Private | +| `Corporate` | `Hard` (EDM+APDF, random) | Corporate | DualStack | Private | +| `Hotel` | `Hard` (EDM+APDF, random) | CaptivePortal | V4Only | Private | +| `Cloud` | `Hard` (EDM+APDF, random) | None | DualStack | Private | Methods called after `.preset()` override the preset's defaults, so you can use a preset as a starting point and customize individual settings. diff --git a/docs/reference/holepunching.md b/docs/reference/holepunching.md index c563d14..2d99f4e 100644 --- a/docs/reference/holepunching.md +++ b/docs/reference/holepunching.md @@ -26,10 +26,13 @@ simulates: | Preset | Mapping | Filtering | Port preservation | Hole-punch? | Real-world examples | |--------|---------|-----------|-------------------|-------------|---------------------| -| `Nat::Easiest` | EIM | EIF | Preserve | Always | Older consumer routers, routers with UPnP or static forwarding | -| `Nat::Easy` | EIM | APDF | Preserve | Yes, via UDP hole-punching with simultaneous send | Most home routers (FritzBox, Unifi, TP-Link, ASUS, OpenWRT); typical RFC 6888 compliant CGNAT | -| `Nat::Hard` | EDM | APDF | Preserve | Sometimes, with port prediction | Mobile carriers, symmetric CGNAT, some stateful firewalls | -| `Nat::Hardest` | EDM | APDF | Random | No, requires a relay | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway | +| `Nat::Easiest` | EIM | EIF | Always | Older consumer routers, routers with UPnP or static forwarding | +| `Nat::Easy` | EIM | APDF | Yes, via UDP hole-punching with simultaneous send | Most home routers (FritzBox, Unifi, TP-Link, ASUS, OpenWRT); typical RFC 6888 compliant CGNAT | +| `Nat::Hard` | EDM | APDF | No, requires a relay | Cisco ASA, Palo Alto, Fortinet, AWS/Azure/GCP NAT Gateway, mobile carriers, symmetric CGNAT | + +A fourth class exists in the wild — port-preserving symmetric NAT +(SYMPP) — but patchbay models it as `Hard`. See +[NAT simulation limits](nat-limitations.md) for the rationale. ## The fullcone dynamic map @@ -129,25 +132,26 @@ no matching outbound conntrack entry exists, so the packet arrives with ### Endpoint-dependent mapping (symmetric NAT) -`Nat::Hard` and `Nat::Hardest` use plain `masquerade` without a fullcone -map. The distinction is the port allocation flag: +`Nat::Hard` uses `masquerade random` without a fullcone map: ```nft table ip nat { chain postrouting { type nat hook postrouting priority 100; - oif "ix" masquerade # Nat::Hard (port-preserving symmetric) - # or: - oif "ix" masquerade random # Nat::Hardest (random per flow) + oif "ix" masquerade random } } ``` -With `Nat::Hard`, the kernel keeps the internal source port when it is -free, which makes hole-punching succeed when peers exchange the observed -port. With `Nat::Hardest`, the `random` flag assigns a fresh port per -conntrack entry, so the peer cannot predict the mapped port from a STUN -probe. +The `random` flag assigns a fresh, unpredictable external port per +conntrack entry, so the peer cannot predict the mapped port from a +STUN probe. Hole-punching fails without a relay. + +Port-preserving symmetric NAT (SYMPP) exists in real hardware and is +punchable through port prediction; patchbay does not model it as a +distinct tier because Linux `nftables` cannot produce SYMPP-like +behavior distinguishably from EIM. See +[NAT simulation limits](nat-limitations.md). ## nftables pitfalls @@ -213,19 +217,19 @@ The `Nat` enum provides named presets. Each preset converts into an ```rust pub struct NatConfig { - pub mapping: NatMapping, // EndpointIndependent - // | EndpointDependent(PortPreservation) - pub filtering: NatFiltering, // EIF, ADF, or APDF - pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established - pub hairpin: bool, // loopback NAT; EIM only + pub mapping: NatMapping, // EndpointIndependent | EndpointDependent + pub filtering: NatFiltering, // EIF, ADF, or APDF + pub timeouts: ConntrackTimeouts, // udp, udp_stream, tcp_established + pub hairpin: bool, // loopback NAT; EIM only } ``` -Port preservation lives inside `NatMapping::EndpointDependent(_)` because -EIM always preserves the source port via the fullcone map and has no -configuration. `NatConfigBuilder::build` returns a `Result` and rejects -combinations the backend cannot express: `EndpointDependent` mapping paired -with `AddressDependent` filtering or with `hairpin = true`. +EIM always preserves the source port via the fullcone map. EDM always +allocates a random port via `masquerade random`. Port-preserving EDM +(SYMPP) is not modeled; see [NAT simulation limits](nat-limitations.md). +`NatConfigBuilder::build` returns a `Result` and rejects combinations the +backend cannot express: `EndpointDependent` mapping paired with +`AddressDependent` filtering or with `hairpin = true`. The `generate_nat_rules` function in `nft.rs` builds nftables rules from `NatConfig` alone, without matching on `Nat` variants. Users can either diff --git a/docs/reference/nat-limitations.md b/docs/reference/nat-limitations.md new file mode 100644 index 0000000..c17aa03 --- /dev/null +++ b/docs/reference/nat-limitations.md @@ -0,0 +1,200 @@ +# NAT simulation limits + +patchbay implements NAT through Linux nftables rules injected into router +namespaces. The nftables rule machinery is real kernel code, so simulated +NATs behave like their real-world counterparts for most properties: +mapping type, filtering rules, conntrack timeouts, hairpin, and the +rewrite pipeline. This document lists the classes of NAT where the +simulation diverges from published taxonomies, why, and what would be +required to close each gap. + +## What is and is not modeled + +The [`Nat`](../rust-doc) enum forms a deliberately coarse three-step +gradient: + +| Preset | RFC 3489 | RFC 4787 | Backend | +|--------|----------|----------|---------| +| `Easiest` | Full Cone | EIM + EIF | `@fullcone` map, no filter | +| `Easy` | Port Restricted Cone | EIM + APDF | `@fullcone` map, APDF filter | +| `Hard` | Symmetric | EDM + APDF (random ports) | `masquerade random` | + +RFC 4787's third mapping class (`Address-Dependent Mapping`) is not +modeled: no real-world deployment we are aware of uses it, and dropping +it keeps the gradient honest. RFC 4787's third filtering class +(`Address-Dependent Filtering`) is modeled through +`NatFiltering::AddressDependent`, reachable via [`NatConfig::builder`]. + +## Gap 1: port-preserving symmetric NAT (SYMPP) + +### What it is + +Some real-world hardware sits between `Easy` and `Hard`: endpoint- +dependent mapping (fresh mapping per destination, so STUN's reflexive +address does not generalize) combined with port preservation (the +external port matches the internal source port when free). The hole- +punching literature calls this class SYMPP. + +Examples in the wild: + +- Some mobile carriers that allocate a deterministic external port range + per subscriber and allocate sequentially within the range. +- Port Block Allocated CGNAT deployments (A+P / RFC 6346 family) where a + subscriber's external port range is fixed and allocation within the + range is deterministic. +- Older enterprise firewalls before vendors moved to random port + allocation (circa 2013 for Cisco ASA PAT). + +Hole-punching against SYMPP can succeed through port prediction: the +peer observes the reflexive port for one flow and predicts the port for +the next. + +### Why patchbay does not simulate SYMPP distinctly + +Linux `nftables` does not offer a NAT statement that produces SYMPP +behavior: + +- `masquerade` without flags tries to preserve the source port. For a + single internal source sending to several destinations, the kernel + checks 4-tuple uniqueness `(ext_ip, ext_port, dst_ip, dst_port)`. + Different destinations occupy different tuples even with the same + `ext_port`, so preservation succeeds and every flow lands on the same + external port. The observable behavior is EIM, not EDM. +- `masquerade random` allocates a random port per flow. True EDM, no + preservation. +- `masquerade fully-random` is the same class as `random` for our + purposes. + +An earlier iteration of this library tried to simulate SYMPP with +`masquerade` (no flag) and presented it as a separate `Nat::Hard` tier +distinct from random symmetric NAT. The empirical test confirmed that +in single-flow tests the two are indistinguishable. Shipping a preset +that promises a distinction the backend cannot produce would mislead +users writing hole-punching test suites; the library now models SYMPP +as `Hard` (random) to keep simulation pessimistic and honest. + +### Impact + +Presets `MobileCarrier` and `IspCgnatSymmetric` resolve to `Nat::Hard` +(random symmetric NAT). Hole-punching tests against these presets will +fail, as they would against any symmetric NAT without port prediction. +Applications that rely on port-prediction-based traversal to reach +peers behind real SYMPP hardware will NOT see that path exercised in +patchbay tests. The pessimistic model is the right default for +"does my app work?" testing; it is wrong for "does my app exploit +SYMPP optimistically?" testing. + +### How a future backend could add it + +Three plausible approaches: + +1. **Custom kernel module.** Fork or extend `netfilter-full-cone-nat` to + expose an EDM variant that allocates external ports deterministically + per destination while preserving the source port when possible. This + matches how some hardware NATs are actually built, but requires an + out-of-tree kernel module and platform-specific packaging. + +2. **Dynamic sets plus nftables numgen.** Use `numgen inc mod N` or a + destination-IP hash to produce a per-destination external port + offset, combined with a static SNAT port range. Produces a SYMPP-like + port allocation that is deterministic but distinct per destination. + Does not preserve the source port exactly, so it is closer to + "predictable symmetric" than to SYMPP. + +3. **Userspace NAT.** Run a userspace process (TUN or eBPF) that + implements custom NAT semantics. Maximum flexibility, worst + performance, significant engineering investment. + +The path we would pursue first is (2): it is the smallest change with +the largest coverage improvement and fits inside the existing nftables +pipeline. + +## Gap 2: sequential and deterministic port allocation + +Related to Gap 1. Port-Block-Allocated CGNAT allocates a contiguous +port range to each subscriber and assigns ports within the range +sequentially. This matters for: + +- Per-subscriber port predictability (peer can guess a subscriber's next + external port from a prior observation). +- Logging simplicity (one log line per subscriber rather than per flow). + +Neither property is modeled. The `@fullcone` map and `masquerade random` +allocate from the full ephemeral range without per-subscriber carving. +Addressing this requires the same numgen-based nftables work from Gap 1. + +## Gap 3: TCP NAT behavior + +patchbay's NAT is specified in UDP-centric terms (hole-punching, +conntrack UDP timeouts, fullcone UDP map). TCP goes through conntrack +and SNAT normally, so outbound TCP works, but: + +- There is no TCP equivalent of the `@fullcone` map, so TCP "fullcone" + semantics are not distinctly modeled. TCP through `Nat::Easiest` + behaves as whatever conntrack does for TCP, which in practice is + closer to APDF. +- TCP-specific NAT behaviors such as RST-on-expired-flow or SYN-cookies + are not modeled. + +Adding TCP fullcone would extend `@fullcone` to `inet_service` keys for +both protocols. Low-effort; we have not prioritized it. + +## Gap 4: vendor quirks + +Real NAT hardware has vendor-specific idiosyncrasies that patchbay does +not reproduce: + +- Cisco ASA default TCP idle timeout is 3600 seconds; Palo Alto is 3600 + but with different post-close handling; Fortinet is 180s for UDP by + default; Juniper SRX has per-service timeouts. +- Some vendors perform application-layer gateway (ALG) fixups for + protocols like SIP, FTP, and H.323. patchbay does not. +- Some hardware drops packets during the first ~50ms after a conntrack + entry is created (ASIC warm-up). +- Cisco ASA post-8.4 randomizes PAT ports by default; pre-8.4 did not. + +The `RouterPreset` timeouts are rough approximations. Override them +explicitly with `NatConfig::builder` when your test needs a specific +vendor shape. + +## Gap 5: NAT behavior under load + +Real NATs behave differently when many concurrent flows exist from the +same internal source: + +- Port pool exhaustion forces fallback allocation strategies that vary + by vendor. +- Some hardware changes mapping behavior above a per-subscriber flow + threshold (for example switching from EIM to effectively EDM under + load to prevent port exhaustion). +- Conntrack tables in patchbay simulate a single subscriber, so + multi-subscriber contention on a CGN is not modeled. + +This is fundamentally a simulation-scale question rather than a missing +feature. Addressing it requires running many device namespaces behind +one CGN router, which patchbay supports topologically, but the CGN +hardware behaviors above require vendor-specific rule sets that we do +not ship. + +## Gap 6: IPv6 NAT edge cases + +patchbay models NPTv6, masquerade, and NAT64 on the IPv6 side. Not +modeled: + +- SIIT-DC and stateful NAT64 edge behaviors (DNS64 timing, prefix + selection from multiple `64:ff9b::` variants). +- NPTv6 checksum-neutral translation corner cases for ICMPv6 error + messages. +- MAP-T and MAP-E (RFC 7597 / 7599) used by some ISPs for deterministic + v4-over-v6 translation. + +## Summary + +patchbay's NAT model is deliberately coarser than the full RFC 4787 +cross product. The biggest missing class is port-preserving symmetric +NAT (SYMPP); the others are either niche, protocol-specific, or require +multi-subscriber simulation scale. The coarse model is honest about +what it simulates and pessimistic where it deliberately omits detail. +For tests whose correctness depends on the gaps above, the fix is to +run against the real hardware; patchbay will not pretend to reproduce +behavior it cannot. diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index fe5da2f..78c08b5 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -127,7 +127,7 @@ let nat_b = lab.add_router("nat-b").nat(Nat::Easy).build().await?; // One side symmetric: hole punching fails, relay needed let nat_a = lab.add_router("nat-a").nat(Nat::Easy).build().await?; -let nat_b = lab.add_router("nat-b").nat(Nat::Hardest).build().await?; +let nat_b = lab.add_router("nat-b").nat(Nat::Hard).build().await?; // Assert: falls back to relay (TURN/DERP) ``` @@ -213,7 +213,7 @@ through: UDP direct -> UDP relay (TURN) -> TCP relay -> TLS/TCP relay on 443. ```rust let corp = lab.add_router("corp") - .nat(Nat::Hardest) + .nat(Nat::Hard) .firewall(Firewall::Corporate) // TCP 80,443 + UDP 53 only .build().await?; @@ -374,7 +374,7 @@ for _ in 0..3 { | WiFi to cellular | `iface.replug()` + `iface.set_condition()` | | Network goes down briefly | `iface.link_down()`, sleep, `iface.link_up()` | | Cone NAT | `Nat::Easy` | -| Symmetric NAT | `Nat::Hardest` | +| Symmetric NAT | `Nat::Hard` | | Double NAT / CGNAT | Chain routers: `home.upstream(cgnat.id())` | | Corporate UDP block | `Firewall::Corporate` on router | | Captive portal | Router with no upstream | diff --git a/docs/reference/toml-reference.md b/docs/reference/toml-reference.md index 2b83397..416870a 100644 --- a/docs/reference/toml-reference.md +++ b/docs/reference/toml-reference.md @@ -717,8 +717,7 @@ RFC 3489 and RFC 4787 labels and the real-world deployments each models. | `"none"` | Same as absent. | | `"easiest"` | EIM+EIF: any external host can reach the mapped port (full cone). | | `"easy"` | EIM+APDF: same external port for all destinations (port-restricted cone). Default of most home routers. | -| `"hard"` | EDM+APDF with port preservation: port-predictable symmetric NAT. | -| `"hardest"` | EDM+APDF with random ports: symmetric NAT that requires a relay. | +| `"hard"` | EDM+APDF with random ports: symmetric NAT that requires a relay. | **Region latency** can be added to introduce inter-router delays: diff --git a/patchbay/src/event.rs b/patchbay/src/event.rs index 1d8596f..0fca55f 100644 --- a/patchbay/src/event.rs +++ b/patchbay/src/event.rs @@ -838,7 +838,7 @@ mod tests { }, LabEventKind::NatChanged { router: "r1".into(), - nat: crate::Nat::Hardest.into(), + nat: crate::Nat::Hard.into(), }, LabEventKind::DeviceRemoved { name: "d1".into() }, LabEventKind::RouterRemoved { name: "r1".into() }, diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index 1fad22c..f8103d0 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -61,7 +61,7 @@ pub use crate::{ firewall::{Firewall, FirewallConfig, FirewallConfigBuilder}, nat::{ ConntrackTimeouts, IpSupport, Nat, NatConfig, NatConfigBuilder, NatConfigError, - NatFiltering, NatMapping, NatV6Mode, PortPreservation, + NatFiltering, NatMapping, NatV6Mode, }, }; diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index e9bdf3e..c8a2678 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -98,18 +98,18 @@ //! builder methods called after [`RouterBuilder::preset`] override preset //! values. //! -//! | Preset | NAT (kind, port alloc.) | Firewall | IP | Use case | -//! |--------|-------------------------|----------|----|----------| -//! | [`Home`](RouterPreset::Home) | `Easy` (EIM+APDF, preserve) | Block inbound | Dual | Consumer router (FritzBox, UniFi) | +//! | Preset | NAT | Firewall | IP | Use case | +//! |--------|-----|----------|----|----------| +//! | [`Home`](RouterPreset::Home) | `Easy` (EIM+APDF) | Block inbound | Dual | Consumer router (FritzBox, UniFi) | //! | [`Public`](RouterPreset::Public) | None | None | Dual | Datacenter switch, ISP handoff | //! | [`PublicV4`](RouterPreset::PublicV4) | None | None | V4 | Legacy v4-only hosting | -//! | [`IspCgnat`](RouterPreset::IspCgnat) | `Easy` (EIM+APDF, preserve) | Block inbound | Dual | RFC 6888 compliant CGNAT | -//! | [`IspCgnatSymmetric`](RouterPreset::IspCgnatSymmetric) | `Hard` (EDM+APDF, preserve) | Block inbound | Dual | Symmetric CGNAT | -//! | [`MobileCarrier`](RouterPreset::MobileCarrier) | `Hard` (EDM+APDF, preserve) | Block inbound | Dual | Typical LTE/5G carrier | +//! | [`IspCgnat`](RouterPreset::IspCgnat) | `Easy` (EIM+APDF) | Block inbound | Dual | RFC 6888 compliant CGNAT | +//! | [`IspCgnatSymmetric`](RouterPreset::IspCgnatSymmetric) | `Hard` (EDM+APDF, random) | Block inbound | Dual | Symmetric CGNAT | +//! | [`MobileCarrier`](RouterPreset::MobileCarrier) | `Hard` (EDM+APDF, random) | Block inbound | Dual | Typical LTE/5G carrier | //! | [`IspV6`](RouterPreset::IspV6) | NAT64 | Block inbound | V6 | T-Mobile, Jio-style IPv6-only | -//! | [`Corporate`](RouterPreset::Corporate) | `Hardest` (EDM+APDF, random) | TCP 80/443, UDP 53 only | Dual | Enterprise firewall | -//! | [`Hotel`](RouterPreset::Hotel) | `Hardest` (EDM+APDF, random) | Captive portal (no UDP) | V4 | Guest WiFi | -//! | [`Cloud`](RouterPreset::Cloud) | `Hardest` (EDM+APDF, random) | None | Dual | AWS/GCP/Azure NAT gateway | +//! | [`Corporate`](RouterPreset::Corporate) | `Hard` (EDM+APDF, random) | TCP 80/443, UDP 53 only | Dual | Enterprise firewall | +//! | [`Hotel`](RouterPreset::Hotel) | `Hard` (EDM+APDF, random) | Captive portal (no UDP) | V4 | Guest WiFi | +//! | [`Cloud`](RouterPreset::Cloud) | `Hard` (EDM+APDF, random) | None | Dual | AWS/GCP/Azure NAT gateway | //! //! # Link conditions //! @@ -180,7 +180,7 @@ //! dev.set_default_route("eth0").await?; //! dev.iface("wlan0").unwrap().link_down().await?; //! dev.iface("wlan0").unwrap().link_up().await?; -//! router.set_nat(Nat::Hardest).await?; +//! router.set_nat(Nat::Hard).await?; //! router.flush_nat_state().await?; //! # Ok(()) //! # } @@ -246,7 +246,7 @@ pub use lab::{ ConntrackTimeouts, DefaultRegions, Firewall, FirewallConfig, FirewallConfigBuilder, IpSupport, Ipv6DadMode, Ipv6Profile, Ipv6ProvisioningMode, Ix, Lab, LabBuilder, LinkCondition, LinkDirection, LinkLimits, Nat, NatConfig, NatConfigBuilder, NatConfigError, NatFiltering, - NatMapping, NatV6Mode, OutDir, PortPreservation, Region, RegionLink, TestGuard, + NatMapping, NatV6Mode, OutDir, Region, RegionLink, TestGuard, }; pub use metrics::MetricsBuilder; pub use router::{Router, RouterBuilder, RouterIface, RouterPreset}; diff --git a/patchbay/src/nat.rs b/patchbay/src/nat.rs index c803f54..224a24b 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -5,13 +5,24 @@ use serde::{Deserialize, Serialize}; /// NAT behavior for IPv4. /// /// The variants form a gradient of hole-punching difficulty. `Easiest` and -/// `Easy` are reachable with standard UDP hole-punching. `Hard` is reachable -/// with port prediction. `Hardest` requires a relay like TURN. +/// `Easy` are reachable with standard UDP hole-punching. `Hard` requires a +/// relay like TURN. /// /// Each preset expands to a [`NatConfig`] via [`Nat::to_config`]. Timeouts /// and hairpin behavior come from [`ConntrackTimeouts::default`] and /// `hairpin: false`; for deployment-specific timeouts use /// [`RouterPreset`](crate::RouterPreset) instead. +/// +/// # Port-preserving symmetric NAT +/// +/// Real-world "port-preserving symmetric" (SYMPP) hardware exists and is +/// hole-punchable with port prediction, forming a middle tier between +/// `Easy` and `Hard`. patchbay does not model it distinctly: Linux +/// `nftables` cannot produce true per-destination port allocation that +/// preserves the source port, and simulating SYMPP through plain +/// `masquerade` converges on EIM-like behavior under light load. See +/// [`docs/reference/nat-limitations.md`](https://github.com/n0-computer/patchbay/blob/main/docs/reference/nat-limitations.md) +/// for the full rationale and how a future backend could add it. #[derive( Clone, Copy, @@ -34,7 +45,7 @@ pub enum Nat { /// /// Any external host can reach the mapped port after the first outbound /// packet. Typical of consumer routers with UPnP or static port - /// forwarding, older consumer NAT boxes, and the output of + /// forwarding, older consumer NAT boxes, and routers running /// `netfilter-full-cone-nat` style kernel modules. /// /// RFC 3489: Full Cone. @@ -56,28 +67,17 @@ pub enum Nat { /// Filtering. Easy, - /// Restrictive NAT with a port-predictable mapping. - /// - /// Each destination gets its own external mapping, but the external port - /// still matches the internal source port when the port is free. - /// Hole-punching succeeds with port prediction. Typical of some stateful - /// firewalls, some CGNAT variants, and most mobile carriers. - /// - /// RFC 4787: Endpoint-Dependent Mapping with port preservation, - /// Address-and-Port-Dependent Filtering. - Hard, - /// Most restrictive NAT. /// /// Each destination gets a fresh, random external port. Hole-punching - /// fails; peers need a relay. Typical of enterprise firewalls - /// (Cisco ASA, Palo Alto, Fortinet) and cloud NAT gateways (AWS, Azure, - /// GCP). + /// fails without a relay. Typical of enterprise firewalls (Cisco ASA, + /// Palo Alto, Fortinet), cloud NAT gateways (AWS, Azure, GCP), mobile + /// carriers, and most symmetric CGNAT. /// /// RFC 3489: Symmetric. /// RFC 4787: Endpoint-Dependent Mapping with random ports, /// Address-and-Port-Dependent Filtering. - Hardest, + Hard, /// Fully custom NAT configuration. /// @@ -117,11 +117,7 @@ impl From for Option { NatFiltering::AddressAndPortDependent, ), Nat::Hard => ( - NatMapping::EndpointDependent(PortPreservation::Preserve), - NatFiltering::AddressAndPortDependent, - ), - Nat::Hardest => ( - NatMapping::EndpointDependent(PortPreservation::Random), + NatMapping::EndpointDependent, NatFiltering::AddressAndPortDependent, ), Nat::Custom(config) => return Some(config), @@ -146,42 +142,20 @@ pub enum NatMapping { /// /// RFC 4787: Endpoint-Independent Mapping (EIM). The NAT reuses one /// external port for every destination from a given internal source - /// address and port. Port preservation is implicit and always enabled. + /// address and port. Port preservation is implicit and always enabled: + /// the nftables backend records the pre-SNAT source tuple in a + /// `@fullcone` map so inbound packets reach the correct internal host. EndpointIndependent, /// Different external port per destination. /// /// RFC 4787: Endpoint-Dependent Mapping (EDM), also called "symmetric". - /// Each unique destination 4-tuple gets a fresh external port. The - /// [`PortPreservation`] value selects how the fresh port is chosen. - EndpointDependent(PortPreservation), -} - -/// Port allocation policy for [`NatMapping::EndpointDependent`]. -/// -/// Only applies to EDM: [`NatMapping::EndpointIndependent`] always -/// preserves the source port, and the variant has no configuration. -// -// Gap: sequential port allocation (deployed in some older SOHO boxes and in -// Port-Block-Allocated CGNAT) is not modeled. Hole-punching literature -// treats it as a distinct class from `Preserve` because ports are -// predictable across flows in a deterministic way. If a future test needs -// it, implement as a new variant. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum PortPreservation { - /// Reuse the internal source port when the external port is free. - /// - /// Linux `masquerade` without flags. Combined with EDM this produces a - /// symmetric NAT that port-prediction techniques can traverse. - #[default] - Preserve, - /// Allocate a random external port for each flow. - /// - /// Linux `masquerade random`. Combined with EDM this produces a - /// fresh, unpredictable external port per flow; hole-punching fails - /// without a relay. - Random, + /// Each unique destination 4-tuple gets a fresh, random external port + /// (Linux `masquerade random`). Hole-punching requires a relay. + /// Port-preserving EDM ("SYMPP") exists in the wild but is not + /// modeled distinctly; see the crate-level note on [`Nat`] for the + /// reason. + EndpointDependent, } /// NAT filtering behavior per RFC 4787 §5. @@ -290,7 +264,7 @@ impl std::error::Error for NatConfigError {} /// Fields remain `pub` for ergonomic read access and pattern matching. /// Mutating a field on an existing `NatConfig` bypasses the builder's /// cross-field validation: for example, setting -/// `cfg.mapping = NatMapping::EndpointDependent(_)` on a config with +/// `cfg.mapping = NatMapping::EndpointDependent` on a config with /// `AddressDependent` filtering or `hairpin = true` produces a combination /// the nftables backend cannot express. Callers that mutate fields are /// responsible for maintaining these invariants; the library does not @@ -298,11 +272,10 @@ impl std::error::Error for NatConfigError {} /// /// # Example /// ``` -/// # use patchbay::{NatConfig, NatMapping, NatFiltering, PortPreservation}; -/// // Port-preserving symmetric NAT with a short UDP timeout, matching a -/// // mobile carrier CGN. +/// # use patchbay::{NatConfig, NatFiltering, NatMapping}; +/// // Symmetric NAT with a short UDP timeout, for fast timeout tests. /// let cfg = NatConfig::builder() -/// .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) +/// .mapping(NatMapping::EndpointDependent) /// .filtering(NatFiltering::AddressAndPortDependent) /// .udp_stream_timeout(60) /// .build() @@ -468,7 +441,7 @@ mod tests { #[test] fn builder_rejects_edm_with_adf() { let err = NatConfig::builder() - .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) + .mapping(NatMapping::EndpointDependent) .filtering(NatFiltering::AddressDependent) .build() .unwrap_err(); @@ -478,7 +451,7 @@ mod tests { #[test] fn builder_rejects_edm_with_hairpin() { let err = NatConfig::builder() - .mapping(NatMapping::EndpointDependent(PortPreservation::Random)) + .mapping(NatMapping::EndpointDependent) .hairpin(true) .build() .unwrap_err(); @@ -506,34 +479,18 @@ mod tests { } #[test] - fn builder_accepts_edm_preserve() { - let cfg = NatConfig::builder() - .mapping(NatMapping::EndpointDependent(PortPreservation::Preserve)) - .filtering(NatFiltering::AddressAndPortDependent) - .build() - .unwrap(); - assert!(matches!( - cfg.mapping, - NatMapping::EndpointDependent(PortPreservation::Preserve) - )); - } - - #[test] - fn builder_accepts_edm_random() { + fn builder_accepts_edm_apdf() { let cfg = NatConfig::builder() - .mapping(NatMapping::EndpointDependent(PortPreservation::Random)) + .mapping(NatMapping::EndpointDependent) .filtering(NatFiltering::AddressAndPortDependent) .build() .unwrap(); - assert!(matches!( - cfg.mapping, - NatMapping::EndpointDependent(PortPreservation::Random) - )); + assert_eq!(cfg.mapping, NatMapping::EndpointDependent); } #[test] fn nat_to_config_covers_every_preset() { - for nat in [Nat::None, Nat::Easiest, Nat::Easy, Nat::Hard, Nat::Hardest] { + for nat in [Nat::None, Nat::Easiest, Nat::Easy, Nat::Hard] { let cfg = nat.to_config(); assert_eq!(cfg.is_none(), nat == Nat::None); } @@ -554,22 +511,9 @@ mod tests { } #[test] - fn nat_hard_is_edm_preserve_apdf() { + fn nat_hard_is_edm_apdf() { let cfg = Nat::Hard.to_config().unwrap(); - assert_eq!( - cfg.mapping, - NatMapping::EndpointDependent(PortPreservation::Preserve) - ); - assert_eq!(cfg.filtering, NatFiltering::AddressAndPortDependent); - } - - #[test] - fn nat_hardest_is_edm_random_apdf() { - let cfg = Nat::Hardest.to_config().unwrap(); - assert_eq!( - cfg.mapping, - NatMapping::EndpointDependent(PortPreservation::Random) - ); + assert_eq!(cfg.mapping, NatMapping::EndpointDependent); assert_eq!(cfg.filtering, NatFiltering::AddressAndPortDependent); } } diff --git a/patchbay/src/nft.rs b/patchbay/src/nft.rs index 1f94369..d2f16b2 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -7,8 +7,8 @@ use ipnet::Ipv6Net; use tracing::{debug, trace}; use crate::{ - core::RouterConfig, nat::PortPreservation, netns, qdisc, wiring::set_sysctl_root, - ConntrackTimeouts, LinkCondition, NatConfig, NatFiltering, NatMapping, NatV6Mode, + core::RouterConfig, netns, qdisc, wiring::set_sysctl_root, ConntrackTimeouts, LinkCondition, + NatConfig, NatFiltering, NatMapping, NatV6Mode, }; /// Returns `true` when the NAT uses the fullcone DNAT map (Endpoint-Independent @@ -62,7 +62,7 @@ pub(crate) async fn run_nft_in(netns: &netns::NetnsManager, ns: &str, rules: &st /// /// EIM uses a dynamic fullcone map to preserve source ports across destinations. /// EDM uses `masquerade` with or without the `random` flag depending on the -/// [`PortPreservation`] carried by the mapping variant. +/// always `masquerade random` (true symmetric NAT). /// EIF produces an unconditional fullcone DNAT in prerouting. /// ADF and APDF add a forward filter that only allows established/related /// flows. ADF additionally permits packets from any port on a @@ -131,19 +131,17 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String ip = wan_ip, ) } else { - // EDM with Preserve: `masquerade`; Linux keeps the source port when - // free (see C1 empirical test `port_mapping_edm_preserve_stable`). - // EDM with Random: `masquerade random`; fresh port per flow. - let masq_stmt = match cfg.mapping { - NatMapping::EndpointDependent(PortPreservation::Preserve) => "masquerade", - NatMapping::EndpointDependent(PortPreservation::Random) => "masquerade random", - NatMapping::EndpointIndependent => unreachable!("EIM took the fullcone branch"), - }; + // EDM: always `masquerade random` to allocate a fresh, random + // external port per destination 4-tuple. Plain `masquerade` (no + // flag) would try to preserve the source port, which conntrack + // grants whenever the (ext_ip, ext_port, dst_ip, dst_port) tuple + // is free. For a single internal source sending to multiple + // destinations that converges on EIM-looking behavior, so it is + // not a faithful symmetric NAT. See docs/reference/nat-limitations.md. format!( - r#"{hairpin} oif "{wan}" {stmt}"#, + r#"{hairpin} oif "{wan}" masquerade random"#, hairpin = hairpin_masq, wan = wan_if, - stmt = masq_stmt, ) }; diff --git a/patchbay/src/router.rs b/patchbay/src/router.rs index e114991..8916424 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -782,23 +782,34 @@ pub enum RouterPreset { /// ISP with symmetric CGNAT. /// - /// Models carrier-grade NAT that uses endpoint-dependent mapping with - /// port preservation. Common in some older fixed-line deployments and - /// in CGNAT hardware where administrators disable EIM. Hole-punching - /// succeeds only with port prediction ([`Nat::Hard`](crate::Nat::Hard) - /// semantics). IPv6 inbound is blocked by a stateful firewall. + /// Models carrier-grade NAT that uses endpoint-dependent mapping. + /// Common in older fixed-line deployments and in CGNAT hardware + /// where administrators disable EIM. Hole-punching requires a relay. + /// IPv6 inbound is blocked by a stateful firewall. + /// + /// patchbay simulates this as [`Nat::Hard`](crate::Nat::Hard) with + /// random port allocation. Real port-preserving symmetric CGNAT + /// (SYMPP, punchable through port prediction) is not modeled + /// distinctly; see [the NAT limits + /// reference](https://github.com/n0-computer/patchbay/blob/main/docs/reference/nat-limitations.md). /// /// Dual-stack, private downstream pool. IspCgnatSymmetric, /// Mobile carrier on LTE or 5G. /// - /// Models typical cellular deployments: symmetric NAT with port - /// preservation, a 60-second UDP stream timeout (matching the cellular - /// median reported in published CGN measurement studies), and a - /// stateful firewall that blocks unsolicited inbound on both address + /// Models typical cellular deployments: symmetric NAT with a + /// 60-second UDP stream timeout (matching the cellular median + /// reported in published CGN measurement studies) and a stateful + /// firewall that blocks unsolicited inbound on both address /// families. /// + /// patchbay simulates this as [`Nat::Hard`](crate::Nat::Hard) with + /// random port allocation. Real mobile CGNAT is often + /// port-preserving symmetric NAT (SYMPP, punchable through port + /// prediction); that class is not modeled distinctly. See [the NAT + /// limits reference](https://github.com/n0-computer/patchbay/blob/main/docs/reference/nat-limitations.md). + /// /// Dual-stack, private downstream pool. MobileCarrier, @@ -873,17 +884,23 @@ impl RouterPreset { } fn nat(self) -> Option { - // Start from the matching Nat preset and then override timeouts per - // deployment. `Nat::*.to_config()` supplies the correct mapping, - // filtering, and port-preservation combination for each tier. + // Each preset starts from a `Nat::*` expansion and overrides only + // timeouts. `Nat::*.to_config()` supplies the mapping and filtering. + // + // `Nat::Hard` now covers every symmetric-NAT preset. See + // docs/reference/nat-limitations.md for why `MobileCarrier` and + // `IspCgnatSymmetric` do not simulate port-preserving symmetric NAT + // distinctly from random-port symmetric NAT. let base = match self { Self::Public | Self::PublicV4 | Self::IspV6 => Nat::None, - // S1: RFC 6888 mandates EIM but most compliant deployments pair - // it with APDF (matching Nat::Easy), not the "full cone" EIF - // assumed by earlier versions of this preset. + // RFC 6888 mandates EIM; most compliant deployments pair it + // with APDF rather than EIF, matching Nat::Easy. Self::Home | Self::IspCgnat => Nat::Easy, - Self::IspCgnatSymmetric | Self::MobileCarrier => Nat::Hard, - Self::Corporate | Self::Hotel | Self::Cloud => Nat::Hardest, + Self::IspCgnatSymmetric + | Self::MobileCarrier + | Self::Corporate + | Self::Hotel + | Self::Cloud => Nat::Hard, }; let mut cfg = base.to_config()?; cfg.timeouts = match self { diff --git a/patchbay/src/tests/nat.rs b/patchbay/src/tests/nat.rs index a5f06a9..267b77c 100644 --- a/patchbay/src/tests/nat.rs +++ b/patchbay/src/tests/nat.rs @@ -131,9 +131,9 @@ async fn matrix_connectivity_and_reflexive_ip() -> Result<()> { (Nat::Easy, UplinkWiring::DirectIx), (Nat::Easy, UplinkWiring::ViaPublicIsp), (Nat::Easy, UplinkWiring::ViaCgnatIsp), - (Nat::Hardest, UplinkWiring::DirectIx), - (Nat::Hardest, UplinkWiring::ViaPublicIsp), - (Nat::Hardest, UplinkWiring::ViaCgnatIsp), + (Nat::Hard, UplinkWiring::DirectIx), + (Nat::Hard, UplinkWiring::ViaPublicIsp), + (Nat::Hard, UplinkWiring::ViaCgnatIsp), ]; let mut case_idx = 0u16; @@ -357,46 +357,10 @@ async fn port_mapping_eim_stable() -> Result<()> { Ok(()) } -/// EDM with port preservation (Nat::Hard): external port stays stable across -/// destinations when the internal source port is free. Validates the -/// "symmetric but port-predictable" claim behind `Nat::Hard`. If this test -/// fails, `Nat::Hard` is empirically equivalent to `Nat::Hardest` on this -/// kernel and the two variants must be reconciled. -#[tokio::test(flavor = "current_thread")] -#[traced_test] -async fn port_mapping_edm_preserve_stable() -> Result<()> { - use strum::IntoEnumIterator; - let mut port_base = 16_050u16; - let mut failures = Vec::new(); - for wiring in UplinkWiring::iter() { - let result: Result<()> = async { - let (lab, ctx) = build_nat_case(Nat::Hard, wiring, port_base).await?; - let dev = lab.device_by_name("dev").unwrap(); - let o1 = dev.probe_udp_mapping(ctx.r_dc)?; - let o2 = dev.probe_udp_mapping(ctx.r_ix)?; - if o1.port() != o2.port() { - bail!( - "EDM Preserve: external port changed across destinations: \ - r_dc={} r_ix={}", - o1.port(), - o2.port() - ); - } - Ok(()) - } - .await; - if let Err(e) = result { - failures.push(format!("DestDep+Preserve/{wiring}: {e:#}")); - } - port_base += 10; - } - if !failures.is_empty() { - bail!("{} combos failed:\n{}", failures.len(), failures.join("\n")); - } - Ok(()) -} - -/// EDM (Corporate NAT): external port differs between two reflectors. +/// EDM (Nat::Hard, symmetric NAT): external port differs between two +/// reflectors. The nftables backend uses `masquerade random`, which +/// assigns a fresh, random port per flow regardless of source-port +/// availability. #[tokio::test(flavor = "current_thread")] #[traced_test] async fn port_mapping_edm_changes() -> Result<()> { @@ -405,7 +369,7 @@ async fn port_mapping_edm_changes() -> Result<()> { let mut failures = Vec::new(); for wiring in UplinkWiring::iter() { let result: Result<()> = async { - let (lab, ctx) = build_nat_case(Nat::Hardest, wiring, port_base).await?; + let (lab, ctx) = build_nat_case(Nat::Hard, wiring, port_base).await?; let dev = lab.device_by_name("dev").unwrap(); let o1 = dev.probe_udp_mapping(ctx.r_dc)?; let o2 = dev.probe_udp_mapping(ctx.r_ix)?; diff --git a/patchbay/src/tests/nat_rebind.rs b/patchbay/src/tests/nat_rebind.rs index 4686d3d..f608b7d 100644 --- a/patchbay/src/tests/nat_rebind.rs +++ b/patchbay/src/tests/nat_rebind.rs @@ -6,7 +6,7 @@ use super::*; -/// Switching between `Nat::Easy` (EIM, stable port) and `Nat::Hardest` +/// Switching between `Nat::Easy` (EIM, stable port) and `Nat::Hard` /// (EDM with random port allocation) flips observed port stability. /// /// Easy to Hardest: the previously stable external port now varies per @@ -16,10 +16,7 @@ use super::*; #[tokio::test(flavor = "current_thread")] #[traced_test] async fn mode_port_change() -> Result<()> { - let cases: &[(Nat, Nat, bool)] = &[ - (Nat::Easy, Nat::Hardest, false), - (Nat::Hardest, Nat::Easy, true), - ]; + let cases: &[(Nat, Nat, bool)] = &[(Nat::Easy, Nat::Hard, false), (Nat::Hard, Nat::Easy, true)]; let mut port_base = 16_800u16; let mut failures = Vec::new(); for &(from, to, expect_stable) in cases { @@ -113,7 +110,7 @@ async fn conntrack_flush() -> Result<()> { eprintln!("skipping nat_rebind_conntrack_flush: conntrack not found"); return Ok(()); } - let (lab, ctx) = build_nat_case(Nat::Hardest, UplinkWiring::DirectIx, 17_000).await?; + let (lab, ctx) = build_nat_case(Nat::Hard, UplinkWiring::DirectIx, 17_000).await?; let nat_handle = lab.router_by_name("nat").context("missing nat")?; let bind = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0); let r_dc = ctx.r_dc; diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index a395456..0bd4cad 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -173,7 +173,7 @@ async fn preset_override() -> Result<()> { /// preset table) and in `plans/nat-breaking-changes.md`. #[test] fn preset_nat_snapshots() { - use crate::{Firewall, IpSupport, Nat, NatMapping, NatV6Mode, PortPreservation}; + use crate::{Firewall, IpSupport, Nat, NatV6Mode}; struct Expect { nat_kind: Option, @@ -227,7 +227,7 @@ fn preset_nat_snapshots() { ( RouterPreset::Corporate, Expect { - nat_kind: Some(Nat::Hardest), + nat_kind: Some(Nat::Hard), udp_stream: Some(120), firewall: Firewall::Corporate, ip_support: IpSupport::DualStack, @@ -237,7 +237,7 @@ fn preset_nat_snapshots() { ( RouterPreset::Hotel, Expect { - nat_kind: Some(Nat::Hardest), + nat_kind: Some(Nat::Hard), udp_stream: Some(120), firewall: Firewall::CaptivePortal, ip_support: IpSupport::V4Only, @@ -247,7 +247,7 @@ fn preset_nat_snapshots() { ( RouterPreset::Cloud, Expect { - nat_kind: Some(Nat::Hardest), + nat_kind: Some(Nat::Hard), udp_stream: Some(350), firewall: Firewall::None, ip_support: IpSupport::DualStack, @@ -303,14 +303,6 @@ fn preset_nat_snapshots() { actual.timeouts.udp_stream, udp_stream, "{preset:?}: udp_stream" ); - if let NatMapping::EndpointDependent(pp) = actual.mapping { - let expected_pp = match kind { - Nat::Hard => PortPreservation::Preserve, - Nat::Hardest => PortPreservation::Random, - _ => unreachable!("EDM presets map to Hard or Hardest only"), - }; - assert_eq!(pp, expected_pp, "{preset:?}: port preservation"); - } } (actual, expected) => { panic!("{preset:?}: nat mismatch: actual={actual:?}, expected={expected:?}"); diff --git a/patchbay/src/tests/route.rs b/patchbay/src/tests/route.rs index 039c628..e7cbd4e 100644 --- a/patchbay/src/tests/route.rs +++ b/patchbay/src/tests/route.rs @@ -20,7 +20,7 @@ async fn switch_default_reflexive_ip() -> Result<()> { reflector, dc: _, _reflector_guard, - } = build_dual_nat_lab(Nat::Easy, Nat::Hardest, 16_200).await?; + } = build_dual_nat_lab(Nat::Easy, Nat::Hard, 16_200).await?; let wan_a = nat_a.uplink_ip().context("no uplink ip")?; let wan_b = nat_b.uplink_ip().context("no uplink ip")?; @@ -115,7 +115,7 @@ async fn switch_default_tcp_roundtrip() -> Result<()> { nat_b: _, reflector: _, _reflector_guard, - } = build_dual_nat_lab(Nat::Easy, Nat::Hardest, 16_400).await?; + } = build_dual_nat_lab(Nat::Easy, Nat::Hard, 16_400).await?; let dc_ip = dc.uplink_ip().context("no dc uplink ip")?; diff --git a/plans/nat-breaking-changes.md b/plans/nat-breaking-changes.md index 426d82a..e20c44d 100644 --- a/plans/nat-breaking-changes.md +++ b/plans/nat-breaking-changes.md @@ -1,190 +1,131 @@ # NAT refactor: breaking changes -This file tracks every breaking change introduced by the NAT taxonomy -redesign so the final PR description can cite them accurately. +Tracks every breaking change for the final PR description. -## Public API +## `Nat` enum -### `Nat` enum variants renamed - -Replaced deployment-flavored variants with a behavior gradient: +Replaced deployment-flavored variants with a three-tier behavior +gradient: | Old | New | |-----|-----| | `Nat::Home` | `Nat::Easy` | -| `Nat::Corporate` | `Nat::Hardest` | | `Nat::Cgnat` | `Nat::Easiest` | -| `Nat::CloudNat` | `Nat::Hardest` | -| `Nat::FullCone` | `Nat::Easiest` plus `hairpin(true)` if hairpin is needed | +| `Nat::Corporate`, `Nat::CloudNat` | `Nat::Hard` | +| `Nat::FullCone` | `Nat::Easiest` plus `hairpin(true)` if needed | | `Nat::None`, `Nat::Custom` | unchanged | -### `Nat::to_config()` returns `Option` - -Restored as a thin alias for `impl From for Option`. -Timeouts come from `ConntrackTimeouts::default()`; per-deployment -timeouts come from the matching `RouterPreset`. - -### `NatMapping` enum shape changed - -`EndpointDependent` now carries a `PortPreservation` payload. The -previous unit variant no longer exists. +The new variants: `None`, `Easiest` (EIM+EIF), `Easy` (EIM+APDF), `Hard` +(EDM+APDF with random ports), and `Custom(NatConfig)`. -```rust -// before -NatMapping::EndpointDependent -// after -NatMapping::EndpointDependent(PortPreservation::Preserve) -NatMapping::EndpointDependent(PortPreservation::Random) -``` +## `NatMapping` -This makes EIM+Random structurally unrepresentable, replacing the old -silently-ignored combination. +`EndpointIndependent` and `EndpointDependent`, both unit variants. An +earlier draft of this PR carried a `PortPreservation` payload on +`EndpointDependent` to distinguish port-preserving symmetric NAT from +random symmetric NAT. That distinction was dropped because Linux +`nftables` cannot produce port-preserving symmetric NAT distinguishably +from EIM-under-light-load. See `docs/reference/nat-limitations.md`. -### `NatFiltering` gained `AddressDependent` +## `NatFiltering` -Third variant for RFC 4787 Address-Dependent Filtering (RFC 3489 -"Restricted Cone"). nftables backend uses a dynamic `@contacted` set of -external IPs the internal side has reached and a matching rule in the -forward chain. +Gained `AddressDependent` variant for RFC 4787 ADF (RFC 3489 "Restricted +Cone"). Only supported with `NatMapping::EndpointIndependent`; the +builder rejects `EDM + AddressDependent`. -### `PortPreservation` added +## `NatConfigBuilder::build()` -Two variants: `Preserve` and `Random`. Only reachable via -`NatMapping::EndpointDependent(...)`. EIM always preserves ports and -offers no configuration. +Now returns `Result`. Rejects: -### `NatConfigBuilder::build()` returns `Result` - -The builder now validates cross-field invariants and returns an error -for: - -- `NatMapping::EndpointDependent(_)` with `NatFiltering::AddressDependent`. - The ADF implementation requires the fullcone map to deliver inbound - packets from any port on a contacted address. -- `NatMapping::EndpointDependent(_)` with `hairpin = true`. The hairpin - DNAT rule relies on the fullcone map. +- `EndpointDependent` mapping with `AddressDependent` filtering +- `EndpointDependent` mapping with `hairpin = true` Previously both combinations compiled and produced subtly wrong nftables rules. -### `NatConfig` is `#[non_exhaustive]` - -Construction through `NatConfig::builder()` is the stable path. Direct -struct literals outside the crate no longer compile. `ConntrackTimeouts` -is also `#[non_exhaustive]`. +## `NatConfig` and `ConntrackTimeouts` -### `RouterPreset` changes +Both are `#[non_exhaustive]`. Construction is through +`NatConfig::builder()` only. `NatConfig` has public fields for ergonomic +read access and pattern matching; mutating fields post-build bypasses +builder validation and is documented as caller responsibility. -- Added `IspCgnatSymmetric`: EDM + APDF + Preserve, 180-second UDP - stream timeout, `BlockInbound` firewall. -- Added `MobileCarrier`: EDM + APDF + Preserve, 60-second UDP stream - timeout, `BlockInbound` firewall. -- `IspCgnat` changed from EIM + EIF to EIM + APDF with `BlockInbound` - firewall. Published measurement data (ipSpace, IMC'16, and observed - behavior on Swisscom, Deutsche Telekom, Starlink) supports EIM+APDF - as the typical RFC 6888 compliant deployment; EIF is rare. -- `IspCgnat` and `IspCgnatSymmetric` firewalls changed from - `Firewall::None` to `Firewall::BlockInbound` so IPv6 inbound is - blocked by default, matching Swisscom, Deutsche Telekom, Starlink. -- No variant named `IspCgnatHard`; the earlier draft used that name and - cited RFC 7753. The preset does not model Port Block Allocation, so - the name and citation were dropped. +## `RouterPreset` -### `Router::nat_mode` renamed to `Router::nat_config` +- `IspCgnat` changed from EIM+EIF to EIM+APDF (most RFC 6888 compliant + CGNATs use APDF, not EIF), firewall `None` to `BlockInbound`. +- `IspCgnatHard` renamed to `IspCgnatSymmetric`, RFC 7753 citation + dropped (that RFC is a PCP extension, not a PBA RFC; the preset does + not model Port Block Allocation). Firewall `None` to `BlockInbound`. +- `MobileCarrier` (new): EDM+APDF, 60-second UDP stream timeout, + `BlockInbound` firewall. +- `IspCgnatSymmetric` and `MobileCarrier` both map to `Nat::Hard` + (symmetric NAT with random ports). An earlier draft simulated these + as port-preserving symmetric NAT; patchbay does not do that + distinctly. -Returns `Option` (flat), matching the pattern of peer -accessors like `mtu()`, `uplink_ip()`, and `downstream_cidr()`. `None` -covers both "router removed" and "NAT disabled". Use `exists` / -`uplink_ip` checks if you need to distinguish. +## `Router` API -### `Router::set_nat_mode` renamed to `Router::set_nat` +- `Router::nat_mode` renamed to `Router::nat_config`, returns + `Option` (flattened). Matches peer accessors. +- `Router::set_nat_mode` renamed to `Router::set_nat`. Matches + `Router::set_firewall`. Accepts `impl Into>`. -Matches the naming of `Router::set_firewall`. Accepts -`impl Into>`; `Nat`, `NatConfig`, `None`, and -`Some(config)` all compile. - -### `RouterBuilder::nat` signature widened +## `RouterBuilder::nat` Accepts `impl Into>`. `Nat::None` and `None` both disable NAT. -### `PortPreservation` and `NatConfigError` re-exported from the crate -root - -Previously unreachable for downstream users even though they appeared in -public function signatures. - -## Internal changes +## Wire format -### `RouterConfig.nat` stores `Option` - -Was `Nat`. Call sites that set or read the field use the expanded -config directly. `RouterConfig::effective_nat_config()` was deleted; -call sites read `router_cfg.nat` directly. - -### `set_nat` deletes and re-creates tables instead of flushing - -Dynamic nftables sets (`@contacted` for ADF) survive `flush table`. The -runtime path now does `delete table` before reapplying, so mode -transitions start from a clean state. - -## Wire format changes - -### `RouterState.nat` - -JSON shape changed from a kebab-case preset string to a config object -or `null`: +`RouterState.nat` and `LabEventKind::NatChanged.nat` serialize as +`Option`: ``` // before "nat": "home" - // after "nat": { - "mapping": { "endpoint_dependent": "preserve" }, + "mapping": "endpoint_dependent", "filtering": "address_and_port_dependent", "timeouts": { "udp": 30, "udp_stream": 300, "tcp_established": 7200 }, "hairpin": false } - // or when NAT is disabled "nat": null ``` -The `mapping` field is either the string `"endpoint_independent"` or an -object like `{ "endpoint_dependent": "preserve" }` because -`NatMapping::EndpointDependent` carries `PortPreservation`. - -### `LabEventKind::NatChanged.nat` +## TOML -Same change as `RouterState.nat`. +`[[router]] nat = "..."` accepts `none`, `easiest`, `easy`, `hard`, +`custom`. Old strings (`home`, `corporate`, `cgnat`, `cloud-nat`, +`full-cone`, `hardest`) fail to parse. -### TOML config +## TypeScript devtools bindings -The `[[router]] nat = "..."` field still accepts a `Nat` enum but the -accepted strings are now `none`, `easiest`, `easy`, `hard`, `hardest`, -`custom`. Old strings (`home`, `corporate`, `cgnat`, `cloud-nat`, -`full-cone`) fail to parse. +`ui/src/devtools-types.ts`: `Nat = NatConfig | null`; `NatMapping` is a +union of unit string literals; `NatFiltering` gained +`"address_dependent"`. ## Test coverage added -- `port_mapping_edm_preserve_stable`: validates that `Nat::Hard` - (`masquerade` without the `random` flag) preserves the internal source - port across destinations. If this test ever fails on a new kernel, - the `Nat::Hard`/`Nat::Hardest` distinction must be re-examined. -- `adf_allows_different_port_from_contacted_host`: positive path for - `NatFiltering::AddressDependent`. The contacted peer sends from a - different source port and the packet reaches the device. -- `adf_drops_from_uncontacted_host`: negative path for ADF. An - uncontacted external host sends to the mapped address and the packet - is dropped. -- `preset_nat_snapshots`: validates mapping, filtering, port - preservation, and UDP stream timeout for every `RouterPreset`. Changes - here must match the docs in `docs/guide/nat-and-firewalls.md` and the - TOML reference. +- `adf_allows_different_port_from_contacted_host` (positive) and + `adf_drops_from_uncontacted_host` (negative): validate ADF + semantics. +- `preset_nat_snapshots`: pins mapping, filtering, UDP stream timeout, + firewall, IP support, v6 NAT mode, and `hairpin = false` for every + preset. - `builder_rejects_edm_with_adf`, `builder_rejects_edm_with_hairpin`, - plus the matching positive cases: validate that the builder's - cross-field invariants hold. + plus matching positive cases. - `nat_easiest_is_eim_eif`, `nat_easy_is_eim_apdf`, - `nat_hard_is_edm_preserve_apdf`, `nat_hardest_is_edm_random_apdf`: - pins the `Nat::*` preset expansions. + `nat_hard_is_edm_apdf`: pin the `Nat::*` conversions. + +## Documentation + +- New `docs/reference/nat-limitations.md` explaining what classes of + NAT patchbay does not simulate faithfully (SYMPP, sequential port + allocation, vendor quirks, behavior under load, IPv6 corner cases) + and how a future backend could close each gap. +- Preset tables updated in `docs/guide/topology.md`, `lib.rs`, + `docs/guide/nat-and-firewalls.md`, `docs/reference/holepunching.md`, + and `docs/reference/toml-reference.md`. diff --git a/ui/src/components/TopologyGraph.tsx b/ui/src/components/TopologyGraph.tsx index b226ccd..7a53810 100644 --- a/ui/src/components/TopologyGraph.tsx +++ b/ui/src/components/TopologyGraph.tsx @@ -45,7 +45,7 @@ function layoutGraph(nodes: Node[], edges: Edge[]): Node[] { function natLabel(nat: Nat): string { if (nat === null) return 'none' - const mapping = typeof nat.mapping === 'string' ? 'EIM' : 'EDM' + const mapping = nat.mapping === 'endpoint_independent' ? 'EIM' : 'EDM' const filtering = nat.filtering === 'endpoint_independent' ? 'EIF' diff --git a/ui/src/devtools-types.ts b/ui/src/devtools-types.ts index 826e568..e30b1b6 100644 --- a/ui/src/devtools-types.ts +++ b/ui/src/devtools-types.ts @@ -1,13 +1,7 @@ // ── NAT types ── -/** Port allocation for EDM. Matches Rust `PortPreservation`. */ -export type PortPreservation = 'preserve' | 'random' - -/** Matches Rust `NatMapping`. EIM has no payload; EDM carries - * a `PortPreservation` tag. */ -export type NatMapping = - | 'endpoint_independent' - | { endpoint_dependent: PortPreservation } +/** Matches Rust `NatMapping`. Unit variants only. */ +export type NatMapping = 'endpoint_independent' | 'endpoint_dependent' /** Matches Rust `NatFiltering`. */ export type NatFiltering = From eb8365bf2df4a591e5aaa985bceb2c9a7cf6399c Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 12:32:32 +0200 Subject: [PATCH 6/7] chore: collapse nested if into match guards in parse_step_failure Fix `clippy::collapsible_match` errors reported by the Rust 1.95 CI toolchain on the nat-refactor branch. Unrelated to the NAT changes; the older local clippy did not flag this. Uses match-arm guards instead of an inner `if let`. Co-Authored-By: Claude Opus 4.7 (1M context) --- patchbay-runner/src/sim/runner.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/patchbay-runner/src/sim/runner.rs b/patchbay-runner/src/sim/runner.rs index 5eefc02..e823d0b 100644 --- a/patchbay-runner/src/sim/runner.rs +++ b/patchbay-runner/src/sim/runner.rs @@ -1039,16 +1039,8 @@ fn parse_step_failure(raw: &str) -> Option { match k { "index" => index = v.parse::().ok(), "action" => action = Some(v.to_string()), - "id" => { - if !v.is_empty() { - id = Some(v.to_string()); - } - } - "device" => { - if !v.is_empty() { - device = Some(v.to_string()); - } - } + "id" if !v.is_empty() => id = Some(v.to_string()), + "device" if !v.is_empty() => device = Some(v.to_string()), _ => {} } } From 26c738d8f1f4cf8fdf86bb3d0bb519bcdb6ac080 Mon Sep 17 00:00:00 2001 From: Frando Date: Fri, 17 Apr 2026 12:54:38 +0200 Subject: [PATCH 7/7] Drop MobileCarrier preset, document cellular in IspCgnatSymmetric IspCgnatSymmetric and MobileCarrier differed only in the UDP stream timeout (180s vs 60s). The two preset shapes were otherwise identical: same Nat::Hard, same BlockInbound firewall, same dual-stack, same private downstream pool. Shipping both added a knob without adding a meaningfully distinct preset. Consolidate into IspCgnatSymmetric and describe both fixed-line symmetric CGN and cellular carriers in its docstring. Cite Richter et al. IMC 2016 ("A Multi-Perspective Analysis of Carrier-Grade NAT Deployment") for the measurement observation that real-world UDP timeouts are much shorter than vendor defaults: cellular median 65s, non-cellular 35s. Users testing keep-alive behavior for a specific carrier override with `.udp_stream_timeout()`. Source: https://www.prichter.com/imc16_richter_cgn.pdf Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 8 ++-- docs/guide/topology.md | 1 - docs/reference/nat-limitations.md | 17 +++++---- docs/reference/patterns.md | 2 +- patchbay/src/lib.rs | 3 +- patchbay/src/router.rs | 62 +++++++++++-------------------- patchbay/src/tests/preset.rs | 11 ------ plans/nat-breaking-changes.md | 13 ++++--- 8 files changed, 44 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 3491716..9dc0b0e 100644 --- a/README.md +++ b/README.md @@ -132,10 +132,10 @@ let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await? ``` Available presets: `Home`, `Public`, `PublicV4`, `IspCgnat`, -`IspCgnatSymmetric`, `MobileCarrier`, `IspV6`, `Corporate`, `Hotel`, -`Cloud`. Individual methods called after `preset()` override preset -values. See [docs/reference/ipv6.md](docs/reference/ipv6.md) for the -full reference table. +`IspCgnatSymmetric`, `IspV6`, `Corporate`, `Hotel`, `Cloud`. Individual +methods called after `preset()` override preset values. See +[docs/reference/ipv6.md](docs/reference/ipv6.md) for the full reference +table. ### NAT diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 8897cfb..49d869e 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -71,7 +71,6 @@ the preset configures: | `PublicV4` | None | None | V4Only | Public | | `IspCgnat` | `Easy` (EIM+APDF) | BlockInbound | DualStack | Private | | `IspCgnatSymmetric` | `Hard` (EDM+APDF, random) | BlockInbound | DualStack | Private | -| `MobileCarrier` | `Hard` (EDM+APDF, random) | BlockInbound | DualStack | Private | | `IspV6` | None (v4), NAT64 (v6) | BlockInbound | V6Only | Public | | `Corporate` | `Hard` (EDM+APDF, random) | Corporate | DualStack | Private | | `Hotel` | `Hard` (EDM+APDF, random) | CaptivePortal | V4Only | Private | diff --git a/docs/reference/nat-limitations.md b/docs/reference/nat-limitations.md index c17aa03..db38773 100644 --- a/docs/reference/nat-limitations.md +++ b/docs/reference/nat-limitations.md @@ -75,14 +75,15 @@ as `Hard` (random) to keep simulation pessimistic and honest. ### Impact -Presets `MobileCarrier` and `IspCgnatSymmetric` resolve to `Nat::Hard` -(random symmetric NAT). Hole-punching tests against these presets will -fail, as they would against any symmetric NAT without port prediction. -Applications that rely on port-prediction-based traversal to reach -peers behind real SYMPP hardware will NOT see that path exercised in -patchbay tests. The pessimistic model is the right default for -"does my app work?" testing; it is wrong for "does my app exploit -SYMPP optimistically?" testing. +`RouterPreset::IspCgnatSymmetric` (which also represents the cellular +CGNAT case) resolves to `Nat::Hard` (random symmetric NAT). +Hole-punching tests against this preset will fail, as they would +against any symmetric NAT without port prediction. Applications that +rely on port-prediction-based traversal to reach peers behind real +SYMPP hardware will NOT see that path exercised in patchbay tests. +The pessimistic model is the right default for "does my app work?" +testing; it is wrong for "does my app exploit SYMPP optimistically?" +testing. ### How a future backend could add it diff --git a/docs/reference/patterns.md b/docs/reference/patterns.md index 78c08b5..5ceded5 100644 --- a/docs/reference/patterns.md +++ b/docs/reference/patterns.md @@ -186,7 +186,7 @@ address is assigned. let wifi_router = lab.add_router("wifi").nat(Nat::Easy).build().await?; let cell_router = lab .add_router("cell") - .preset(RouterPreset::MobileCarrier) + .preset(RouterPreset::IspCgnatSymmetric) .build().await?; let device = lab.add_device("phone") diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index c8a2678..e6b174c 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -104,8 +104,7 @@ //! | [`Public`](RouterPreset::Public) | None | None | Dual | Datacenter switch, ISP handoff | //! | [`PublicV4`](RouterPreset::PublicV4) | None | None | V4 | Legacy v4-only hosting | //! | [`IspCgnat`](RouterPreset::IspCgnat) | `Easy` (EIM+APDF) | Block inbound | Dual | RFC 6888 compliant CGNAT | -//! | [`IspCgnatSymmetric`](RouterPreset::IspCgnatSymmetric) | `Hard` (EDM+APDF, random) | Block inbound | Dual | Symmetric CGNAT | -//! | [`MobileCarrier`](RouterPreset::MobileCarrier) | `Hard` (EDM+APDF, random) | Block inbound | Dual | Typical LTE/5G carrier | +//! | [`IspCgnatSymmetric`](RouterPreset::IspCgnatSymmetric) | `Hard` (EDM+APDF, random) | Block inbound | Dual | Symmetric CGNAT, including cellular | //! | [`IspV6`](RouterPreset::IspV6) | NAT64 | Block inbound | V6 | T-Mobile, Jio-style IPv6-only | //! | [`Corporate`](RouterPreset::Corporate) | `Hard` (EDM+APDF, random) | TCP 80/443, UDP 53 only | Dual | Enterprise firewall | //! | [`Hotel`](RouterPreset::Hotel) | `Hard` (EDM+APDF, random) | Captive portal (no UDP) | V4 | Guest WiFi | diff --git a/patchbay/src/router.rs b/patchbay/src/router.rs index 8916424..648974b 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -703,8 +703,8 @@ impl Router { /// /// The ISP presets cover different shapes of carrier-grade NAT. `IspCgnat` /// is the RFC 6888 compliant fixed-line case. `IspCgnatSymmetric` covers -/// symmetric CGNAT. `MobileCarrier` covers typical cellular deployments. -/// `IspV6` is the IPv6-only case with NAT64. +/// symmetric CGNAT, including typical cellular carriers. `IspV6` is the +/// IPv6-only case with NAT64. /// /// # Example /// @@ -783,9 +783,19 @@ pub enum RouterPreset { /// ISP with symmetric CGNAT. /// /// Models carrier-grade NAT that uses endpoint-dependent mapping. - /// Common in older fixed-line deployments and in CGNAT hardware - /// where administrators disable EIM. Hole-punching requires a relay. - /// IPv6 inbound is blocked by a stateful firewall. + /// Covers both fixed-line symmetric CGN deployments (where operators + /// disable EIM) and cellular carriers on LTE or 5G, which in + /// published measurement studies overwhelmingly deploy symmetric + /// NAT. Hole-punching requires a relay. IPv6 inbound is blocked by + /// a stateful firewall. + /// + /// The UDP stream timeout defaults to 180 seconds, which is close + /// to common vendor defaults (Cisco ASR CGNAT, A10 Thunder CGN, + /// Fortinet FortiGate CGNAT). Real deployments often run lower: + /// Richter et al. (IMC 2016) report a median mapping timeout of + /// 65 seconds on cellular networks and 35 seconds on non-cellular + /// CGN. When testing keep-alive behavior for a specific carrier, + /// override with `.udp_stream_timeout(secs)`. /// /// patchbay simulates this as [`Nat::Hard`](crate::Nat::Hard) with /// random port allocation. Real port-preserving symmetric CGNAT @@ -796,23 +806,6 @@ pub enum RouterPreset { /// Dual-stack, private downstream pool. IspCgnatSymmetric, - /// Mobile carrier on LTE or 5G. - /// - /// Models typical cellular deployments: symmetric NAT with a - /// 60-second UDP stream timeout (matching the cellular median - /// reported in published CGN measurement studies) and a stateful - /// firewall that blocks unsolicited inbound on both address - /// families. - /// - /// patchbay simulates this as [`Nat::Hard`](crate::Nat::Hard) with - /// random port allocation. Real mobile CGNAT is often - /// port-preserving symmetric NAT (SYMPP, punchable through port - /// prediction); that class is not modeled distinctly. See [the NAT - /// limits reference](https://github.com/n0-computer/patchbay/blob/main/docs/reference/nat-limitations.md). - /// - /// Dual-stack, private downstream pool. - MobileCarrier, - /// IPv6-only ISP or mobile carrier with NAT64. /// /// Models T-Mobile US, Jio, NTT Docomo, and other providers that run @@ -887,20 +880,16 @@ impl RouterPreset { // Each preset starts from a `Nat::*` expansion and overrides only // timeouts. `Nat::*.to_config()` supplies the mapping and filtering. // - // `Nat::Hard` now covers every symmetric-NAT preset. See - // docs/reference/nat-limitations.md for why `MobileCarrier` and - // `IspCgnatSymmetric` do not simulate port-preserving symmetric NAT + // `Nat::Hard` covers every symmetric-NAT preset. See + // docs/reference/nat-limitations.md for why `IspCgnatSymmetric` + // does not simulate port-preserving symmetric NAT (SYMPP) // distinctly from random-port symmetric NAT. let base = match self { Self::Public | Self::PublicV4 | Self::IspV6 => Nat::None, // RFC 6888 mandates EIM; most compliant deployments pair it // with APDF rather than EIF, matching Nat::Easy. Self::Home | Self::IspCgnat => Nat::Easy, - Self::IspCgnatSymmetric - | Self::MobileCarrier - | Self::Corporate - | Self::Hotel - | Self::Cloud => Nat::Hard, + Self::IspCgnatSymmetric | Self::Corporate | Self::Hotel | Self::Cloud => Nat::Hard, }; let mut cfg = base.to_config()?; cfg.timeouts = match self { @@ -914,11 +903,6 @@ impl RouterPreset { udp_stream: 180, tcp_established: 3600, }, - Self::MobileCarrier => ConntrackTimeouts { - udp: 30, - udp_stream: 60, - tcp_established: 3600, - }, Self::Corporate | Self::Hotel => ConntrackTimeouts { udp: 30, udp_stream: 120, @@ -948,11 +932,9 @@ impl RouterPreset { // (Swisscom, Deutsche Telekom, Starlink), matching cellular. Only // the explicitly-public presets get Firewall::None. match self { - Self::Home - | Self::IspV6 - | Self::IspCgnat - | Self::IspCgnatSymmetric - | Self::MobileCarrier => Firewall::BlockInbound, + Self::Home | Self::IspV6 | Self::IspCgnat | Self::IspCgnatSymmetric => { + Firewall::BlockInbound + } Self::Public | Self::PublicV4 | Self::Cloud => Firewall::None, Self::Corporate => Firewall::Corporate, Self::Hotel => Firewall::CaptivePortal, diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index 0bd4cad..586a5d4 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -214,16 +214,6 @@ fn preset_nat_snapshots() { nat_v6: NatV6Mode::None, }, ), - ( - RouterPreset::MobileCarrier, - Expect { - nat_kind: Some(Nat::Hard), - udp_stream: Some(60), - firewall: Firewall::BlockInbound, - ip_support: IpSupport::DualStack, - nat_v6: NatV6Mode::None, - }, - ), ( RouterPreset::Corporate, Expect { @@ -336,7 +326,6 @@ async fn preset_recommended_ipv6_profiles() -> Result<()> { RouterPreset::PublicV4, RouterPreset::IspCgnat, RouterPreset::IspCgnatSymmetric, - RouterPreset::MobileCarrier, RouterPreset::IspV6, RouterPreset::Corporate, RouterPreset::Hotel, diff --git a/plans/nat-breaking-changes.md b/plans/nat-breaking-changes.md index e20c44d..9b2f5cb 100644 --- a/plans/nat-breaking-changes.md +++ b/plans/nat-breaking-changes.md @@ -57,12 +57,13 @@ builder validation and is documented as caller responsibility. - `IspCgnatHard` renamed to `IspCgnatSymmetric`, RFC 7753 citation dropped (that RFC is a PCP extension, not a PBA RFC; the preset does not model Port Block Allocation). Firewall `None` to `BlockInbound`. -- `MobileCarrier` (new): EDM+APDF, 60-second UDP stream timeout, - `BlockInbound` firewall. -- `IspCgnatSymmetric` and `MobileCarrier` both map to `Nat::Hard` - (symmetric NAT with random ports). An earlier draft simulated these - as port-preserving symmetric NAT; patchbay does not do that - distinctly. +- `IspCgnatSymmetric` resolves to `Nat::Hard` (symmetric NAT with + random ports) and covers both fixed-line symmetric CGN and cellular + carriers. The docstring notes that published measurement data + (Richter et al. IMC 2016) reports much shorter real-world UDP + timeouts (cellular median 65s, non-cellular 35s) than the 180s + vendor-default the preset uses; tune with `.udp_stream_timeout()` + for specific carriers. ## `Router` API