diff --git a/README.md b/README.md index 2db2bea..9dc0b0e 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 +Available presets: `Home`, `Public`, `PublicV4`, `IspCgnat`, +`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 -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::Corporate).await?; +router.set_nat(Nat::Hardest).await?; router.flush_nat_state().await?; ``` @@ -319,7 +321,7 @@ region = "eu" [[router]] name = "home" -nat = "home" +nat = "easy" [device.laptop.eth0] gateway = "home" 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..284321c 100644 --- a/docs/guide/nat-and-firewalls.md +++ b/docs/guide/nat-and-firewalls.md @@ -25,31 +25,37 @@ 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 three-tier gradient of hole-punching difficulty: | 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 | +| `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 @@ -62,17 +68,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 = 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?; ``` +`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 @@ -82,7 +95,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(Nat::Hard).await?; router.flush_nat_state().await?; ``` @@ -219,7 +232,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?; @@ -228,11 +241,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::Cgnat).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::Home) .build().await?; ``` diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index 57d1d01..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_mode(Nat::Corporate).await?; +router.set_nat(Nat::Hard).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..49d869e 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?; ``` @@ -66,20 +66,21 @@ the preset configures: | Preset | NAT | Firewall | IP support | Pool | |--------|-----|----------|------------|------| -| `Home` | Home (EIM+APDF) | BlockInbound | DualStack | Private | +| `Home` | `Easy` (EIM+APDF) | 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) | BlockInbound | DualStack | Private | +| `IspCgnatSymmetric` | `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 | +| `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. -For example, `RouterPreset::Home` with `.nat(Nat::FullCone)` gives you a -home-style topology with fullcone NAT instead of the default -endpoint-dependent filtering. +For example, `RouterPreset::Home` with `.nat(Nat::Easiest)` gives you a +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 17de0c5..2d99f4e 100644 --- a/docs/reference/holepunching.md +++ b/docs/reference/holepunching.md @@ -24,13 +24,15 @@ 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 | 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 @@ -79,17 +81,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::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. -`Nat::Home` uses the same fullcone map for endpoint-independent mapping, +### 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,10 +130,9 @@ 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` uses `masquerade random` without a fullcone map: ```nft table ip nat { @@ -134,10 +143,15 @@ table ip nat { } ``` -The `random` flag randomizes the source port for each conntrack entry. -Without a fullcone map and without a prerouting chain, hole-punching is -impossible because the peer cannot predict the mapped port from a STUN -probe. +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 @@ -198,26 +212,29 @@ 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 `Nat::to_config` 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, // EndpointIndependent | EndpointDependent + 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 `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. +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`. -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 via `NatConfig::builder`. ## NPTv6 implementation notes @@ -259,10 +276,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/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/nat-limitations.md b/docs/reference/nat-limitations.md new file mode 100644 index 0000000..db38773 --- /dev/null +++ b/docs/reference/nat-limitations.md @@ -0,0 +1,201 @@ +# 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 + +`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 + +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 a681993..5ceded5 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::Hard).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?; ``` @@ -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 @@ -183,8 +183,11 @@ 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") + .preset(RouterPreset::IspCgnatSymmetric) + .build().await?; let device = lab.add_device("phone") .iface("eth0", wifi_router.id()) @@ -210,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::Corporate) + .nat(Nat::Hard) .firewall(Firewall::Corporate) // TCP 80,443 + UDP 53 only .build().await?; @@ -235,7 +238,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 +268,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 +373,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::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 8df9c35..416870a 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,16 @@ 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 random ports: symmetric NAT that requires a relay. | **Region latency** can be added to introduce inter-router delays: 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()), _ => {} } } diff --git a/patchbay/src/core.rs b/patchbay/src/core.rs index 479e504..c1e7485 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. @@ -97,14 +97,6 @@ pub(crate) struct RouterConfig { pub ra_lifetime_secs: u64, } -impl RouterConfig { - /// Returns the effective NAT config by expanding the preset (or returning - /// the custom config). Returns `None` for `Nat::None`. - pub(crate) fn effective_nat_config(&self) -> Option { - self.nat.to_config() - } -} - /// Parameters needed to (re-)configure NAT on a router. #[allow(dead_code)] pub(crate) struct RouterNatParams { @@ -707,7 +699,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 +752,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..0fca55f 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::Hard.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..f8103d0 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, }, }; @@ -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..e6b174c 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -100,13 +100,15 @@ //! //! | Preset | NAT | Firewall | IP | Use case | //! |--------|-----|----------|----|----------| -//! | [`Home`](RouterPreset::Home) | EIM + APDF | Block inbound | Dual | Consumer router (FritzBox, UniFi) | +//! | [`Home`](RouterPreset::Home) | `Easy` (EIM+APDF) | 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) | Block inbound | Dual | RFC 6888 compliant CGNAT | +//! | [`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) | 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) | `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 //! @@ -177,7 +179,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(Nat::Hard).await?; //! router.flush_nat_state().await?; //! # Ok(()) //! # } @@ -242,8 +244,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, 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 65364ae..224a24b 100644 --- a/patchbay/src/nat.rs +++ b/patchbay/src/nat.rs @@ -2,13 +2,27 @@ use serde::{Deserialize, Serialize}; -/// NAT behavior preset for common real-world equipment. +/// NAT behavior for IPv4. /// -/// 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 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, @@ -23,127 +37,163 @@ 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, - /// 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. - /// - /// Hole-punching works with simultaneous open (both sides must send). - /// This is the RFC 4787 REQ-1 compliant "port-restricted cone" NAT. - Home, - - /// 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. - /// - /// Hole-punching is impossible without relay (TURN/DERP). - Corporate, - - /// Carrier-grade NAT per RFC 6888. + /// Most permissive NAT. /// - /// EIM + EIF. Port-preserving. No hairpin. UDP timeout 300s. - /// Applied on the ISP/IX-facing interface (stacks with home 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 routers running + /// `netfilter-full-cone-nat` style kernel modules. /// - /// 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, + /// RFC 3489: Full Cone. + /// RFC 4787: Endpoint-Independent Mapping, Endpoint-Independent + /// Filtering. + Easiest, - /// Cloud NAT gateway - symmetric NAT with randomized ports. + /// Moderately restrictive NAT. /// - /// EDM + APDF. Random ports. No hairpin. UDP timeout 350s. + /// 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. /// - /// Observed on: AWS NAT Gateway, Azure NAT Gateway, GCP Cloud NAT - /// (default dynamic port allocation mode). - /// - /// Functionally identical to Corporate but with longer timeouts - /// matching documented cloud provider behavior. - CloudNat, + /// RFC 3489: Port Restricted Cone. + /// RFC 4787: Endpoint-Independent Mapping, Address-and-Port-Dependent + /// Filtering. + Easy, - /// Full cone - most permissive NAT for testing. + /// Most restrictive NAT. /// - /// EIM + EIF. Port-preserving. Hairpin on. UDP timeout 300s. - /// Any external host can send to the mapped port after first outbound packet. + /// Each destination gets a fresh, random external port. Hole-punching + /// 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. /// - /// Observed on: older FritzBox firmware, some CGNAT with full-cone policy. - /// Hole-punching always succeeds. - FullCone, + /// RFC 3489: Symmetric. + /// RFC 4787: Endpoint-Dependent Mapping with random ports, + /// Address-and-Port-Dependent Filtering. + Hard, - /// Custom NAT configuration built from [`NatConfig`]. - /// - /// Use this when the named presets don't match your scenario. - /// The router gets private addressing (like [`Nat::Home`]). + /// Fully custom NAT configuration. /// - /// # Example - /// ```no_run - /// # use patchbay::*; - /// let custom = Nat::Custom( - /// NatConfig::builder() - /// .mapping(NatMapping::EndpointIndependent) - /// .filtering(NatFiltering::EndpointIndependent) - /// .udp_stream_timeout(120) - /// .build(), - /// ); - /// ``` + /// [`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), } +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) } } -/// NAT mapping behavior per RFC 4787 Section 4.1. +impl From for Option { + fn from(nat: Nat) -> Self { + 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, + NatFiltering::AddressAndPortDependent, + ), + Nat::Custom(config) => return Some(config), + }; + Some(NatConfig { + mapping, + filtering, + timeouts: ConntrackTimeouts::default(), + hairpin: false, + }) + } +} + +/// 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). + /// Same external port for all destinations. /// - /// Port-preserving: the NAT reuses the internal source port when free. - /// nftables: `snat to `. + /// 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: + /// 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 IP+port (symmetric/EDM). + + /// Different external port per destination. /// - /// Port randomized per 4-tuple. nftables: `masquerade random,fully-random`. + /// RFC 4787: Endpoint-Dependent Mapping (EDM), also called "symmetric". + /// 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 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. 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 (full cone). + /// Any external host can send to the mapped port. /// - /// nftables: fullcone DNAT map in prerouting. + /// RFC 4787: Endpoint-Independent Filtering (EIF), also called + /// "full cone". nftables implementation: 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. + /// Any source port on those hosts is allowed. /// - /// nftables: conntrack-only (no prerouting DNAT). + /// 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, @@ -163,24 +213,77 @@ impl Default for ConntrackTimeouts { } } -/// Expanded NAT configuration produced from a [`Nat`] preset or the builder API. +/// 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. + /// + /// 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. +/// +/// Each preset ([`Nat::Easy`], [`Nat::Hard`], etc.) expands via +/// [`Nat::to_config`]. Custom configurations are built with +/// [`NatConfig::builder`], which validates cross-field invariants at +/// construction time. /// -/// 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. +/// 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 /// ``` -/// # use patchbay::{NatConfig, NatMapping, NatFiltering}; -/// // A home router with shorter UDP timeouts: +/// # use patchbay::{NatConfig, NatFiltering, NatMapping}; +/// // Symmetric NAT with a short UDP timeout, for fast timeout tests. /// let cfg = NatConfig::builder() -/// .mapping(NatMapping::EndpointIndependent) +/// .mapping(NatMapping::EndpointDependent) /// .filtering(NatFiltering::AddressAndPortDependent) -/// .udp_stream_timeout(120) -/// .build(); +/// .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 { /// How outbound port mapping works. pub mapping: NatMapping, @@ -201,7 +304,9 @@ impl NatConfig { /// Builder for [`NatConfig`]. /// -/// Defaults to EIM + APDF with 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, @@ -255,87 +360,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, 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), - } + }) } } @@ -384,3 +433,87 @@ 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) + .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) + .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_apdf() { + let cfg = NatConfig::builder() + .mapping(NatMapping::EndpointDependent) + .filtering(NatFiltering::AddressAndPortDependent) + .build() + .unwrap(); + 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] { + 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_apdf() { + let cfg = Nat::Hard.to_config().unwrap(); + 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 8938859..d2f16b2 100644 --- a/patchbay/src/nft.rs +++ b/patchbay/src/nft.rs @@ -11,6 +11,13 @@ use crate::{ 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,11 +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 `masquerade random` for per-flow port randomization. -/// EIF adds unconditional fullcone DNAT in prerouting. -/// APDF adds a forward filter that only allows established/related flows. +/// EDM uses `masquerade` with or without the `random` flag depending on the +/// 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 +/// 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 { @@ -73,7 +83,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 @@ -92,15 +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 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 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 { @@ -116,6 +131,13 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String ip = wan_ip, ) } else { + // 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}" masquerade random"#, hairpin = hairpin_masq, @@ -125,9 +147,22 @@ fn generate_nat_rules(cfg: &NatConfig, wan_if: &str, wan_ip: Ipv4Addr) -> String 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 +172,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 +251,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, @@ -206,7 +259,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 c3b9a99..648974b 100644 --- a/patchbay/src/router.rs +++ b/patchbay/src/router.rs @@ -21,7 +21,7 @@ use crate::{ event::{LabEventKind, RouterState}, firewall::{Firewall, FirewallConfigBuilder}, lab::{Ipv6ProvisioningMode, LabInner, LinkCondition}, - nat::{IpSupport, Nat, NatV6Mode}, + nat::{ConntrackTimeouts, IpSupport, Nat, NatConfig, NatV6Mode}, netlink::Netlink, nft::{ apply_firewall, apply_nat_for_router, apply_nat_v6, apply_or_remove_impair, @@ -241,9 +241,14 @@ impl Router { .flatten() } - /// Returns the NAT mode, or `None` if the router has been removed. - pub fn nat_mode(&self) -> Option { - self.lab.with_router(self.id, |r| r.cfg.nat) + /// Returns the effective NAT configuration for this router. + /// + /// 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. @@ -428,7 +433,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>>(&self, mode: T) -> Result<()> { + let mode = mode.into(); let op = self .lab .with_router(self.id, |r| Arc::clone(&r.op)) @@ -441,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( @@ -512,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 /// @@ -692,11 +701,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. `IspCgnatSymmetric` covers +/// symmetric CGNAT, including typical cellular carriers. `IspV6` is the +/// IPv6-only case with NAT64. /// /// # Example /// @@ -708,7 +716,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 +766,46 @@ 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. 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 symmetric CGNAT. + /// + /// Models carrier-grade NAT that uses endpoint-dependent mapping. + /// 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 + /// (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, + /// IPv6-only ISP or mobile carrier with NAT64. /// /// Models T-Mobile US, Jio, NTT Docomo, and other providers that run @@ -807,14 +856,68 @@ pub enum RouterPreset { } impl RouterPreset { - fn nat(self) -> Nat { - match self { - Self::Home => Nat::Home, + #[cfg(test)] + pub(crate) fn nat_config(self) -> Option { + 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 { + // Each preset starts from a `Nat::*` expansion and overrides only + // timeouts. `Nat::*.to_config()` supplies the mapping and filtering. + // + // `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, - Self::IspCgnat => Nat::Cgnat, - Self::Corporate | Self::Hotel => Nat::Corporate, - Self::Cloud => Nat::CloudNat, - } + // 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::Corporate | Self::Hotel | Self::Cloud => Nat::Hard, + }; + 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::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 { @@ -825,9 +928,14 @@ 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 => Firewall::BlockInbound, - Self::Public | Self::PublicV4 | Self::IspCgnat | Self::Cloud => Firewall::None, + 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, } @@ -869,7 +977,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 +1006,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 +1055,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 +1069,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 +1214,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..0eb266e 100644 --- a/patchbay/src/tests/hairpin.rs +++ b/patchbay/src/tests/hairpin.rs @@ -2,19 +2,31 @@ //! //! 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::*; -/// 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 +68,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?; @@ -97,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/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..267b77c 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::Hard, UplinkWiring::DirectIx), + (Nat::Hard, UplinkWiring::ViaPublicIsp), + (Nat::Hard, 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)?; @@ -353,7 +357,10 @@ async fn port_mapping_eim_stable() -> Result<()> { 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<()> { @@ -362,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::Corporate, 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)?; @@ -393,7 +400,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 +433,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 +491,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 +535,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() @@ -554,6 +561,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")] @@ -562,7 +687,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 +731,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 +788,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 +835,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..f608b7d 100644 --- a/patchbay/src/tests/nat_rebind.rs +++ b/patchbay/src/tests/nat_rebind.rs @@ -6,25 +6,24 @@ use super::*; -/// Changing NAT mode between Home (EIM) and Corporate (EDM) flips port stability. +/// Switching between `Nat::Easy` (EIM, stable port) and `Nat::Hard` +/// (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::Home, Nat::Corporate, false), - (Nat::Corporate, Nat::Home, 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 { 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)?; @@ -60,7 +59,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 { @@ -68,7 +67,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; @@ -76,7 +75,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 +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::Corporate, 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 fec75ca..586a5d4 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?; @@ -167,6 +167,155 @@ async fn preset_override() -> Result<()> { Ok(()) } +/// 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::{Firewall, IpSupport, Nat, NatV6Mode}; + + 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::Corporate, + Expect { + nat_kind: Some(Nat::Hard), + udp_stream: Some(120), + firewall: Firewall::Corporate, + ip_support: IpSupport::DualStack, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Hotel, + Expect { + nat_kind: Some(Nat::Hard), + udp_stream: Some(120), + firewall: Firewall::CaptivePortal, + ip_support: IpSupport::V4Only, + nat_v6: NatV6Mode::None, + }, + ), + ( + RouterPreset::Cloud, + Expect { + nat_kind: Some(Nat::Hard), + 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, expect) in cases { + let preset = *preset; + let cfg = preset.nat_config(); + 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"); + assert_eq!( + actual.filtering, expected_cfg.filtering, + "{preset:?}: filtering" + ); + 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" + ); + } + (actual, expected) => { + 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" + ); + } +} + /// All presets recommend Ipv6Profile::Realistic. #[tokio::test(flavor = "current_thread")] #[traced_test] @@ -176,6 +325,7 @@ async fn preset_recommended_ipv6_profiles() -> Result<()> { RouterPreset::Public, RouterPreset::PublicV4, RouterPreset::IspCgnat, + RouterPreset::IspCgnatSymmetric, 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..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::Home, Nat::Corporate, 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")?; @@ -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::Hard, 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") diff --git a/plans/nat-breaking-changes.md b/plans/nat-breaking-changes.md new file mode 100644 index 0000000..9b2f5cb --- /dev/null +++ b/plans/nat-breaking-changes.md @@ -0,0 +1,132 @@ +# NAT refactor: breaking changes + +Tracks every breaking change for the final PR description. + +## `Nat` enum + +Replaced deployment-flavored variants with a three-tier behavior +gradient: + +| Old | New | +|-----|-----| +| `Nat::Home` | `Nat::Easy` | +| `Nat::Cgnat` | `Nat::Easiest` | +| `Nat::Corporate`, `Nat::CloudNat` | `Nat::Hard` | +| `Nat::FullCone` | `Nat::Easiest` plus `hairpin(true)` if needed | +| `Nat::None`, `Nat::Custom` | unchanged | + +The new variants: `None`, `Easiest` (EIM+EIF), `Easy` (EIM+APDF), `Hard` +(EDM+APDF with random ports), and `Custom(NatConfig)`. + +## `NatMapping` + +`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` variant for RFC 4787 ADF (RFC 3489 "Restricted +Cone"). Only supported with `NatMapping::EndpointIndependent`; the +builder rejects `EDM + AddressDependent`. + +## `NatConfigBuilder::build()` + +Now returns `Result`. Rejects: + +- `EndpointDependent` mapping with `AddressDependent` filtering +- `EndpointDependent` mapping with `hairpin = true` + +Previously both combinations compiled and produced subtly wrong +nftables rules. + +## `NatConfig` and `ConntrackTimeouts` + +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. + +## `RouterPreset` + +- `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`. +- `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 + +- `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>`. + +## `RouterBuilder::nat` + +Accepts `impl Into>`. `Nat::None` and `None` both +disable NAT. + +## Wire format + +`RouterState.nat` and `LabEventKind::NatChanged.nat` serialize as +`Option`: + +``` +// before +"nat": "home" +// after +"nat": { + "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 +``` + +## TOML + +`[[router]] nat = "..."` accepts `none`, `easiest`, `easy`, `hard`, +`custom`. Old strings (`home`, `corporate`, `cgnat`, `cloud-nat`, +`full-cone`, `hardest`) fail to parse. + +## TypeScript devtools bindings + +`ui/src/devtools-types.ts`: `Nat = NatConfig | null`; `NatMapping` is a +union of unit string literals; `NatFiltering` gained +`"address_dependent"`. + +## Test coverage added + +- `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 matching positive cases. +- `nat_easiest_is_eim_eif`, `nat_easy_is_eim_apdf`, + `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 1d1901b..7a53810 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 = nat.mapping === 'endpoint_independent' ? '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..e30b1b6 100644 --- a/ui/src/devtools-types.ts +++ b/ui/src/devtools-types.ts @@ -1,18 +1,26 @@ // ── NAT types ── -/** NAT behavior preset (kebab-case from Rust `Nat` enum). */ -export type NatPreset = 'none' | 'home' | 'corporate' | 'cgnat' | 'cloud-nat' | 'full-cone' +/** Matches Rust `NatMapping`. Unit variants only. */ +export type NatMapping = 'endpoint_independent' | 'endpoint_dependent' -/** Custom NAT configuration (Rust `Nat::Custom(NatConfig)`). */ +/** 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'