Skip to content

feat!: rework NAT taxonomy with behavior-only variants + ADF filtering#37

Open
Frando wants to merge 7 commits intomainfrom
nat-refactor
Open

feat!: rework NAT taxonomy with behavior-only variants + ADF filtering#37
Frando wants to merge 7 commits intomainfrom
nat-refactor

Conversation

@Frando
Copy link
Copy Markdown
Member

@Frando Frando commented Apr 17, 2026

Summary

Rework the Nat enum from deployment-flavored variants (Home, Corporate, Cgnat, CloudNat, FullCone) into a three-tier behavior gradient (Easiest, Easy, Hard) ordered by hole-punching difficulty. Move deployment context into RouterPreset, which now covers nine real-world shapes including the new IspCgnatSymmetric preset. Add NatFiltering::AddressDependent (RFC 4787 ADF, RFC 3489 "Restricted Cone") to close a real gap in the filtering model. NatConfigBuilder::build returns Result<NatConfig, NatConfigError> and rejects EDM + ADF and EDM + hairpin at construction time. Realign the three CGNAT presets with published measurement data and align firewall defaults with the observed behavior of Swisscom, Deutsche Telekom, and Starlink.

Ship docs/reference/nat-limitations.md documenting what patchbay does NOT simulate faithfully (notably port-preserving symmetric NAT, SYMPP) and how a future backend could close each gap.

Why the gradient has three tiers

Nat::Easiest, Nat::Easy, and Nat::Hard cover full cone, port-restricted cone, and symmetric NAT respectively. Port-preserving symmetric NAT (SYMPP), a fourth real-world class punchable through port prediction, is not modeled as a distinct tier because Linux nftables cannot produce SYMPP behavior distinguishably from EIM: masquerade without flags preserves the source port whenever the 4-tuple is free, which for a single internal source across multiple destinations converges on EIM-looking observable behavior. Nat::Hard therefore uses masquerade random and simulates symmetric NAT pessimistically: each destination gets a fresh, random external port and hole-punching requires a relay. The pessimistic model is the right default for "does my application work?" testing; optimistic SYMPP exploitation belongs against real hardware. See docs/reference/nat-limitations.md for the full rationale and three plausible future backends (custom kernel module, nftables numgen, userspace NAT).

Breaking Changes

  • renamed: Nat::HomeNat::Easy, Nat::CorporateNat::Hard, Nat::CgnatNat::Easiest, Nat::CloudNatNat::Hard, Nat::FullConeNat::Easiest (plus hairpin(true) if hairpin was required). Variants now describe hole-punching difficulty, not deployment.
  • added: NatFiltering::AddressDependent for RFC 4787 ADF. Only supported with NatMapping::EndpointIndependent; rejected at build time otherwise.
  • changed: NatConfigBuilder::build returns Result<NatConfig, NatConfigError>. Rejects EndpointDependent + AddressDependent filtering and EndpointDependent + hairpin. Both combinations previously compiled and produced subtly wrong nftables rules.
  • added: NatConfigError enum (#[non_exhaustive]) with variants AdfRequiresEim and HairpinRequiresEim.
  • changed: NatConfig and ConntrackTimeouts are now #[non_exhaustive]. Construction is through NatConfig::builder() only. Fields remain pub for read access and pattern matching.
  • added: Nat::to_config(self) -> Option<NatConfig> for expanding a preset to its config.
  • changed: RouterPreset::IspCgnat is now EIM + APDF (was EIM + EIF). Published measurement data shows most RFC 6888 compliant CGNATs use APDF.
  • changed: RouterPreset::IspCgnat firewall is now BlockInbound (was None). Fixed-line CGN deployments block unsolicited v6 inbound.
  • added: RouterPreset::IspCgnatSymmetric. Resolves to Nat::Hard with a 180-second UDP stream timeout and BlockInbound firewall. Covers both fixed-line symmetric CGN and cellular carriers; the docstring cites Richter et al. IMC 2016 for the observation that real-world UDP timeouts often run much shorter (cellular median 65s, non-cellular 35s) and points users at .udp_stream_timeout() for carrier-specific tuning.
  • renamed: Router::nat_modeRouter::nat_config. Return type changed from Option<Nat> to Option<NatConfig> and flattened (the previous nesting conflated "router removed" with "NAT disabled"; neither case now returns a separate outer None).
  • renamed: Router::set_nat_mode(Nat)Router::set_nat<T: Into<Option<NatConfig>>>. Matches Router::set_firewall naming.
  • changed: RouterBuilder::nat accepts impl Into<Option<NatConfig>>. Was Nat. Nat::None, None, Nat::Easy, and an owned NatConfig all compile.
  • changed: RouterState.nat and LabEventKind::NatChanged.nat wire format. Was a kebab-case preset string ("home", "corporate"); is now the full Option<NatConfig> object (or null when NAT is disabled).
  • changed: TOML [[router]] nat = "..." accepts none, easiest, easy, hard, custom. Old strings (home, corporate, cgnat, cloud-nat, full-cone) fail to parse.
  • changed: TypeScript bindings in ui/src/devtools-types.ts: Nat = NatConfig | null; NatMapping is a two-member union of unit string literals; NatFiltering gained "address_dependent".

New documentation

docs/reference/nat-limitations.md is the honest account of what patchbay does and does not simulate:

  • SYMPP (port-preserving symmetric NAT): why nftables cannot produce it distinguishably, three plausible future backends.
  • Sequential port allocation for PBA CGNAT.
  • TCP NAT behavior.
  • Vendor quirks (Cisco ASA PAT timing, Palo Alto idle handling, Fortinet defaults, ALG fixups).
  • NAT behavior under load.
  • IPv6 NAT edge cases (SIIT-DC, NPTv6 checksum-neutral ICMPv6 corner cases, MAP-T/MAP-E).

Each gap lists why it is missing and what would be required to close it.

Tests

  • adf_allows_different_port_from_contacted_host (positive) and adf_drops_from_uncontacted_host (negative): validate NatFiltering::AddressDependent admits packets from any port on a contacted address and drops packets from any other address.
  • preset_nat_snapshots: pins mapping, filtering, UDP stream timeout, firewall, IP support, v6 NAT mode, and hairpin = false for every RouterPreset. Intentional future changes must update this test.
  • builder_rejects_edm_with_adf, builder_rejects_edm_with_hairpin, plus matching positive cases: validate the builder's cross-field invariants.
  • nat_easiest_is_eim_eif, nat_easy_is_eim_apdf, nat_hard_is_edm_apdf: pin the Nat::* conversions.
  • port_mapping_eim_stable and port_mapping_edm_changes: validate the port-stability distinction between EIM and EDM end-to-end.
  • Full suite: 219 tests pass; cargo clippy --workspace --all-targets -- -D warnings clean.
Frando and others added 7 commits April 17, 2026 11:03
Replace deployment-flavored Nat variants (Home/Corporate/Cgnat/CloudNat/
FullCone) with a behavior gradient (Easiest/Easy/Hard/Hardest) and move
deployment context into RouterPreset. Add NatFiltering::AddressDependent
(RFC 4787 ADF) and PortPreservation axis. Add IspCgnatHard and
MobileCarrier presets.

This commit captures the intermediate state for review; follow-up work
will tighten type-system invariants, add empirical tests for the
port-preservation distinction, and realign presets with published
deployment data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review findings from the networking and Rust code-quality
review passes:

Type-system safety (builder-time invariants)
- Move PortPreservation into NatMapping::EndpointDependent(_) so EIM +
  Random is structurally unrepresentable.
- NatConfigBuilder::build() returns Result<NatConfig, NatConfigError>
  and rejects EDM + AddressDependent filtering and EDM + hairpin. Both
  combinations previously compiled and produced subtly wrong nftables.
- NatConfig and ConntrackTimeouts are #[non_exhaustive].

Preset realism
- RouterPreset::IspCgnat: EIM + EIF to EIM + APDF (S1). Published
  measurement data shows most RFC 6888 compliant CGNATs use APDF, not
  EIF. Nat::Easiest remains available for textbook full-cone tests.
- RouterPreset::IspCgnat, IspCgnatSymmetric firewall changed from None
  to BlockInbound (S5). Matches Swisscom, Deutsche Telekom, Starlink.
- Rename IspCgnatHard to IspCgnatSymmetric and drop the incorrect
  RFC 7753 citation (S2); RFC 7753 is a PCP extension, and the preset
  does not model Port Block Allocation.

Empirical validation
- port_mapping_edm_preserve_stable: confirms `masquerade` without the
  random flag preserves the internal source port across destinations
  on this kernel. Pins the Nat::Hard vs Nat::Hardest distinction
  against future kernel changes.
- adf_allows_different_port_from_contacted_host and
  adf_drops_from_uncontacted_host: positive and negative coverage for
  the new ADF filtering branch.
- preset_nat_snapshots: pins every RouterPreset's NAT mapping,
  filtering, port preservation, and UDP stream timeout.

API polish
- Router::nat_mode to Router::nat_config, return flattened to
  Option<NatConfig> (B2); matches peer accessors.
- Router::set_nat_mode to Router::set_nat (I4); matches set_firewall.
- RouterPreset::nat() reuses Nat::*.to_config() and only overrides
  timeouts (I1), removing preset-literal duplication.
- Nat::to_config() alias restored (I2) for discoverability.
- effective_nat_config() removed (I6); callers read the field directly.
- PortPreservation and NatConfigError re-exported from the crate root
  (B1).

Wire format and runtime
- RouterState.nat and LabEventKind::NatChanged.nat now carry
  Option<NatConfig>; breaking change.
- Runtime set_nat now deletes and re-creates the nat and filter tables
  instead of flushing (M9) so named sets from the previous mode do not
  bleed through.

Docs and examples
- Streamline the Nat enum doc comments: lead with a one-line summary,
  describe behavior and deployment, cite RFC 3489 and RFC 4787 without
  overloading on abbreviations.
- Update README.md and docs/reference/toml-reference.md TOML examples
  and the NAT-modes table (B3).
- Remove the now-implemented "Address-restricted cone" and "Hairpin"
  entries from the holepunching.md "Future work" list (M6).

All 222 tests pass; cargo clippy --workspace is clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Clarify Nat::Custom docstring: prefer passing NatConfig directly to
  RouterBuilder::nat; reach for Custom when you need a Nat value (TOML,
  pattern matching).
- Replace weasel wording ("practically impossible") in PortPreservation
  docs with a concrete statement ("hole-punching fails without a relay").
- Add a NatConfig doc example that exercises the new builder Result
  return, demonstrates EDM+Preserve, and imports PortPreservation.
- Refresh the nat_rebind::mode_port_change test doc comment to name
  Nat::Easy and Nat::Hardest instead of the removed Home/Corporate
  variants.
- Docs: nat-and-firewalls.md table now describes Easiest as a full-cone
  router (UPnP or static forwarding), not a CGNAT preset; the double-NAT
  example uses RouterPreset::IspCgnat for realistic CGNAT semantics.
- TypeScript types: NatPreset union removed; Nat is now
  NatConfig | null; NatMapping mirrors the new Rust enum shape
  ("endpoint_independent" | { endpoint_dependent: PortPreservation });
  NatFiltering gained "address_dependent". TopologyGraph.natLabel
  rewritten to render EIM/EDM + EIF/ADF/APDF from the structured value.

All 223 tests pass; cargo clippy --workspace clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- docs: replace stale `set_nat_mode` with `set_nat` in README and two
  guide files. The rename from the previous commit missed these
  prose-only markdown blocks.
- docs: `holepunching.md` table no longer claims `Nat::Easiest` models
  RFC 6888 compliant CGNAT; the `IspCgnat` preset row now maps to
  `Nat::Easy` per the refactor's own thesis.
- docs: `NatConfig` struct snippet in `holepunching.md` reflects the
  actual field list (no `port_preservation` field; noting it lives
  inside `NatMapping::EndpointDependent(_)`), and mentions the builder
  Result and its rejected combinations.
- docs: `NatConfig::builder().build()` examples in `nat-and-firewalls.md`
  and `patterns.md` now handle the builder's `Result` return.
- docs: crate-level preset table in `lib.rs` and `topology.md` preset
  table list all ten presets including the new `IspCgnatSymmetric` and
  `MobileCarrier`, and name the underlying `Nat` variants accurately.
- docs: README NAT section names the current `Nat` variants
  (None/Easiest/Easy/Hard/Hardest/Custom), drops the six-variant claim
  from before the refactor.
- docs: `patterns.md` "cell_router" example uses
  `RouterPreset::MobileCarrier` instead of `Nat::Easiest`. The
  mechanical `Cgnat → Easiest` rename in the earlier commit preserved
  the previous (also-wrong) semantics; mobile is Hard, not Easiest.
- nft.rs: EDM + hairpin branch is now `unreachable!()` guarded by the
  builder invariant `NatConfigError::HairpinRequiresEim`, with a clear
  panic message if the invariant is ever violated. Removes the latent
  `redirect` bug the reviewer flagged as pre-existing.
- nft.rs: inline comment at the postrouting rules correctly says
  `masquerade`, not `snat`.
- nat.rs: `NatConfigError` is `#[non_exhaustive]`.
- nat.rs: `NatConfig` doc comment explicitly documents that post-build
  field mutation bypasses cross-field validation, so callers know the
  invariants are enforced at `build()` time only.
- tests: `preset_nat_snapshots` now pins firewall, ip_support, nat_v6,
  and `hairpin = false` in addition to mapping/filtering/preservation/
  udp_stream, preventing future silent drift on any preset dimension.

All 223 tests pass; `cargo clippy --workspace --all-targets` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop the four-tier Easiest/Easy/Hard/Hardest gradient in favor of the
three-tier Easiest/Easy/Hard the initial design sketch proposed. Drop
PortPreservation entirely and make NatMapping unit variants only.

Why: Linux nftables cannot produce port-preserving symmetric NAT
(SYMPP) distinguishably from EIM-under-light-load. `masquerade`
without flags preserves the source port whenever the 4-tuple is free,
which for a single internal source across multiple destinations
converges on EIM-looking behavior. Shipping a `Nat::Hard` preset that
promised SYMPP semantics while producing what was observationally
EIM+APDF misled users running hole-punching test suites.

Collapsing to three tiers keeps the API honest and matches real
deployments: modern enterprise firewalls and cloud NAT gateways (the
bulk of symmetric-NAT deployments in 2026) already use random port
allocation by default. Applications that work against `Nat::Hard` with
random ports work against every symmetric NAT they will encounter in
the wild; the pessimistic model is the right default.

Changes:
- `Nat` drops `Hardest`; `Hard` is now EDM + APDF with random ports.
- `NatMapping::EndpointDependent` is a unit variant again (no
  `PortPreservation` payload). The `PortPreservation` type is removed.
- `nft.rs` emits `masquerade random` for every EDM config. The
  `masquerade` (no flag) branch is gone.
- Presets `MobileCarrier` and `IspCgnatSymmetric` both map to
  `Nat::Hard` with their deployment-specific timeouts. Their docstrings
  explicitly flag that real-world SYMPP behavior is not simulated
  distinctly.
- New `docs/reference/nat-limitations.md` documents what classes of NAT
  patchbay does not simulate faithfully and how a future backend could
  close each gap (custom kernel module, dynamic sets plus numgen,
  userspace NAT).
- The `port_mapping_edm_preserve_stable` test is removed; it was
  validating kernel behavior we no longer rely on. The builder-level
  validation tests and the ADF positive/negative tests remain.
- Preset snapshot test simplified: no more port-preservation cross-
  check because there is nothing to check.
- TypeScript bindings: `NatMapping` simplifies to a two-member union;
  `PortPreservation` type removed.

All 220 tests pass; cargo clippy --workspace --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fix `clippy::collapsible_match` errors reported by the Rust 1.95 CI
toolchain on the nat-refactor branch. Unrelated to the NAT changes;
the older local clippy did not flag this. Uses match-arm guards
instead of an inner `if let`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IspCgnatSymmetric and MobileCarrier differed only in the UDP stream
timeout (180s vs 60s). The two preset shapes were otherwise identical:
same Nat::Hard, same BlockInbound firewall, same dual-stack, same
private downstream pool. Shipping both added a knob without adding a
meaningfully distinct preset.

Consolidate into IspCgnatSymmetric and describe both fixed-line
symmetric CGN and cellular carriers in its docstring. Cite Richter et
al. IMC 2016 ("A Multi-Perspective Analysis of Carrier-Grade NAT
Deployment") for the measurement observation that real-world UDP
timeouts are much shorter than vendor defaults: cellular median 65s,
non-cellular 35s. Users testing keep-alive behavior for a specific
carrier override with `.udp_stream_timeout()`.

Source: https://www.prichter.com/imc16_richter_cgn.pdf

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant