Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?;
```

Expand Down Expand Up @@ -319,7 +321,7 @@ region = "eu"

[[router]]
name = "home"
nat = "home"
nat = "easy"

[device.laptop.eth0]
gateway = "home"
Expand Down
4 changes: 2 additions & 2 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
64 changes: 40 additions & 24 deletions docs/guide/nat-and-firewalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<NatConfig, NatConfigError>`.
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
Expand All @@ -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?;
```

Expand Down Expand Up @@ -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?;

Expand All @@ -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?;
```

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/running-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
```

Expand Down
2 changes: 1 addition & 1 deletion docs/guide/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?;

Expand Down
23 changes: 12 additions & 11 deletions docs/guide/topology.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?;
```
Expand Down Expand Up @@ -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

Expand Down
85 changes: 50 additions & 35 deletions docs/reference/holepunching.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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<NatConfig>` 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

Expand Down Expand Up @@ -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.
Loading
Loading