feat!: rework NAT taxonomy with behavior-only variants + ADF filtering - #37
Merged
Conversation
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>
- Re-validate NatConfig cross-field invariants on apply. NatConfig has public fields, so mutating a built config into EDM + hairpin or EDM + ADF was possible and reached an unreachable!() in rule generation. Extract the builder's checks into NatConfig::validate and run them again in apply_nat_config, so a bad combination surfaces as a NatConfigError instead of a panic. - Fix a garbled doc comment on generate_nat_rules: EDM always uses masquerade random. - Tie the ADF @contacted set timeout to the config's udp_stream timeout instead of a fixed 300s, so a shorter per-config timeout closes the address filter in step with conntrack. - Start preset ConntrackTimeouts from the default and override only the differing fields. - Add a compile-only guard that nat(None) and set_nat(None) still infer Option<NatConfig>, and a regression test that validate rejects a mutated EDM + hairpin config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rename the difficulty-gradient variants after the widely-recognized console NAT-type scale: Easiest -> Open (full cone, EIM+EIF) Easy -> Moderate (port-restricted cone, EIM+APDF) Hard -> Strict (symmetric, EDM+APDF, random ports) The enum-level doc now maps each variant to its RFC 3489 cone type and its RFC 4787 mapping-and-filtering pair, keeping the per-variant RFC references intact. Updates every call site and test, the TOML string values (open/moderate/strict) including the deserialized lifecycle fixture, the preset/holepunching/limitations tables, and the migration notes. Also corrects the READMEs, which still described a stale five-tier taxonomy with a since-dropped port-preservation tier. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the variant rename: the nat_easiest/nat_easy/nat_hard test functions were the last identifiers carrying the old names. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in the merged PRs #43 (LinkLimits folded into LinkCondition with preset constructors and setters) and #44 (Lab test-setup helpers). Conflicts resolved: - patchbay/src/lib.rs: keep NatConfigError (from this branch) in the re-export list, drop LinkLimits (removed upstream by #43). - docs/reference/patterns.md: keep Nat::Moderate (this branch's rename) and the new LinkCondition::new().rate_mbit(20) API (from #43).
6 tasks
meefs
pushed a commit
to meefs/iroh
that referenced
this pull request
Jul 27, 2026
## Description I merged a couple of breaking changes to patchbay (restructured the NAT presets n0-computer/patchbay#37 and improved the link condition APIs including now modeling more realistic link conditions n0-computer/patchbay#43). This PR updates iroh to this newly released patchbay 0.7.0. ## Breaking Changes None ## Notes & open questions This PR does not adjust the degrade tests to make use of the more realistic loss distribution now available in patchbay, that'll be a separate PR. ## Change checklist <!-- Remove any that are not relevant. --> - [x] Self-review. - [x] Documentation updates following the [style guide](https://rust-lang.github.io/rfcs/1574-more-api-documentation-conventions.html#appendix-a-full-conventions-text), if relevant. - [x] Tests if relevant. - [x] All breaking changes documented. - [x] This PR was created by a human that thought critically about the proposed change and wrote an as clear and concise description as they could. - [x] This PR isn't slop, and is carefully crafted to do have the intented effect. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rework the
Natenum from deployment-flavored variants (Home,Corporate,Cgnat,CloudNat,FullCone) into a three-tier behavior gradient (Open,Moderate,Strict) ordered by hole-punching difficulty. The tiers are named after the console NAT-type scale (Open / Moderate / Strict); each maps to a classic RFC 3489 cone type and a precise RFC 4787 mapping-and-filtering pair, documented per variant. Move deployment context intoRouterPreset, which now covers nine real-world shapes including the newIspCgnatSymmetricpreset. AddNatFiltering::AddressDependent(RFC 4787 ADF, RFC 3489 "Restricted Cone") to close a real gap in the filtering model.NatConfigBuilder::buildreturnsResult<NatConfig, NatConfigError>and rejectsEDM + ADFandEDM + hairpinat 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.mddocumenting what patchbay does NOT simulate faithfully (notably port-preserving symmetric NAT, SYMPP) and how a future backend could close each gap.OpenModerateStrictWhy the gradient has three tiers
Nat::Open,Nat::Moderate, andNat::Strictcover 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 Linuxnftablescannot produce SYMPP behavior distinguishably from EIM:masqueradewithout 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::Stricttherefore usesmasquerade randomand 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. Seedocs/reference/nat-limitations.mdfor the full rationale and three plausible future backends (custom kernel module, nftables numgen, userspace NAT).Breaking Changes
Nat::Home→Nat::Moderate,Nat::Corporate→Nat::Strict,Nat::Cgnat→Nat::Open,Nat::CloudNat→Nat::Strict,Nat::FullCone→Nat::Open(plushairpin(true)if hairpin was required). Variants now describe hole-punching difficulty, not deployment.NatFiltering::AddressDependentfor RFC 4787 ADF. Only supported withNatMapping::EndpointIndependent; rejected at build time otherwise.NatConfigBuilder::buildreturnsResult<NatConfig, NatConfigError>. RejectsEndpointDependent + AddressDependentfiltering andEndpointDependent + hairpin. Both combinations previously compiled and produced subtly wrong nftables rules.NatConfigErrorenum (#[non_exhaustive]) with variantsAdfRequiresEimandHairpinRequiresEim.NatConfig::validate(), which re-checks the builder's cross-field invariants. The apply path runs it so a config mutated through the public fields into an unexpressible combination surfaces as an error instead of a panic.NatConfigandConntrackTimeoutsare now#[non_exhaustive]. Construction is throughNatConfig::builder()only. Fields remainpubfor read access and pattern matching.Nat::to_config(self) -> Option<NatConfig>for expanding a preset to its config.RouterPreset::IspCgnatis now EIM + APDF (was EIM + EIF). Published measurement data shows most RFC 6888 compliant CGNATs use APDF.RouterPreset::IspCgnatfirewall is nowBlockInbound(wasNone). Fixed-line CGN deployments block unsolicited v6 inbound.RouterPreset::IspCgnatSymmetric. Resolves toNat::Strictwith a 180-second UDP stream timeout andBlockInboundfirewall. 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.Router::nat_mode→Router::nat_config. Return type changed fromOption<Nat>toOption<NatConfig>and flattened (the previous nesting conflated "router removed" with "NAT disabled"; neither case now returns a separate outerNone).Router::set_nat_mode(Nat)→Router::set_nat<T: Into<Option<NatConfig>>>. MatchesRouter::set_firewallnaming.RouterBuilder::natacceptsimpl Into<Option<NatConfig>>. WasNat.Nat::None,None,Nat::Moderate, and an ownedNatConfigall compile.RouterState.natandLabEventKind::NatChanged.natwire format. Was a kebab-case preset string ("home","corporate"); is now the fullOption<NatConfig>object (ornullwhen NAT is disabled).[[router]] nat = "..."acceptsnone,open,moderate,strict,custom. Old strings (home,corporate,cgnat,cloud-nat,full-cone) fail to parse.ui/src/devtools-types.ts:Nat = NatConfig | null;NatMappingis a two-member union of unit string literals;NatFilteringgained"address_dependent".New documentation
docs/reference/nat-limitations.mdis the honest account of what patchbay does and does not simulate:nftablescannot produce it distinguishably, three plausible future backends.Each gap lists why it is missing and what would be required to close it.
Tests
adf_allows_different_port_from_contacted_host(positive) andadf_drops_from_uncontacted_host(negative): validateNatFiltering::AddressDependentadmits 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, andhairpin = falsefor everyRouterPreset. 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.validate_catches_mutated_edm_hairpin: a config mutated through the public fields is rejected byNatConfig::validate.nat_open_is_eim_eif,nat_moderate_is_eim_apdf,nat_strict_is_edm_apdf: pin theNat::*conversions.port_mapping_eim_stableandport_mapping_edm_changes: validate the port-stability distinction between EIM and EDM end-to-end.nat(None)/set_nat(None)still inferOption<NatConfig>without a turbofish.cargo clippy --workspace --all-targetsclean.