feat: best-effort UPnP IGD port mapping#40
Merged
Conversation
Adds an optional UPnP IGD client that opportunistically asks the local router to forward the endpoint's UDP port and surfaces the resulting public address as a high-priority candidate alongside locally-discovered and peer-observed candidates. The integration is strictly additive: failure is silent (no router, UPnP disabled, refused mapping, untrusted external IP) and behaviour matches the pre-UPnP build exactly when the gateway is unavailable. The endpoint construction never blocks on the probe — discovery runs in a background task with a 2s deadline. Key design points: * Single-shot probe per session. A router that did not answer SSDP is left alone for the rest of the session — no aggressive re-probing. * Lease renewed at half the lease duration (default 1h, so refresh every 30 minutes). Crash-path safety: 1h lease bounds the worst-case leak. * Gateway-claimed external IPs are validated as plausibly public before use. RFC1918, CGNAT (100.64/10), loopback, link-local, documentation, broadcast and multicast all rejected so a misbehaving router cannot poison candidate discovery. * Endpoint owns the service exclusively; the discovery manager only holds a read-only UpnpStateRx (watch::Receiver). This lets the shutdown path move out the service and call DeletePortMapping directly with a 500ms budget. * PortMapped slots into priority calculation between Host (126) and ServerReflexive (100) at 110, and gets discovery priority 70_000 so it outranks the bound-address promotion (60_000). Drive-by: clippy caught a pre-existing `!is_some()` in the PUNCH_ME_NOW coordination path that was blocking warnings-as-errors after a clippy upgrade — replaced with `.is_none()`. Tests: 1468 lib tests pass with --features upnp. New unit tests cover config defaults, the disabled path, the IP plausibility classifier across IPv4/IPv6 special ranges, and the discovery integration with a pinned UpnpStateRx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The IPv4 plausibility check rejects RFC 5737 documentation space (192.0.2/24, 198.51.100/24, 203.0.113/24) so a misbehaving router cannot poison NAT traversal candidate discovery by returning a documentation address as its "external" IP. The IPv6 side of the classifier was missing the equivalent rejection of RFC 3849 `2001:db8::/32`, leaving a small but real asymmetry in the stated threat model. Split `is_plausibly_public` into symmetric v4/v6 helpers, add an `is_ipv6_documentation` check backed by named constants for the prefix bytes, and replace the hand-rolled `is_ipv6_link_local` with stdlib's `Ipv6Addr::is_unicast_link_local()` (stable since 1.76 — well under the project MSRV of 1.88). Fixes up the test that would have caught this: the positive assertion in `accepts_global_unicast_ipv6_and_rejects_link_local` was using `2001:db8::1` as its "global unicast" example, which accidentally hid the gap. Swap it for `2606:4700:4700::1111` (Cloudflare DNS), add `rejects_ipv6_documentation_range` with a neighbouring `/32` as a positive control, and add `rejects_ipv6_multicast_and_unspecified` for basic coverage completeness. Drive-bys from the deep review of PR #40: * `CandidateDiscoveryManager::set_upnp_state_rx` downgraded from `pub` to `pub(crate)` — it's an internal wiring hook used only by the endpoint constructor, not a public API. * Clarifying comment on `local_socket_for_mapping` explaining why the sync `std::net::UdpSocket::bind`/`connect` pair is safe to call inside the tokio task (pure kernel route lookup, no wire I/O, runs once per session). Tests: 1470/1470 lib tests pass with `--features upnp`, and the UPnP module now has 11 passing tests (up from 9) across both the real and stub backends. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
PR #41 removed the dead `NatTraversalEndpoint::attempt_hole_punching` chain (`get_candidate_pairs_for_addr`, `calculate_candidate_pair_priority`, `attempt_quic_hole_punching`, `create_path_challenge_packet`, `store_successful_candidate_pair`, `get_successful_candidate_address`) as unreachable code gated behind `#[allow(dead_code)]` that could not have worked in production. PR #40 had added `CandidateSource::PortMapped` handling inside two of those exact functions (`is_local_side` in `get_candidate_pairs_for_addr` and a type-preference slot in `calculate_candidate_pair_priority`). Those additions patched dead code and are safely dropped — the live production pairing path in `P2pEndpoint::connect_with_fallback_inner` drives the coordinator-mediated PUNCH_ME_NOW flow and consumes priorities from `crate::connection::nat_traversal::calculate_candidate_priority`, which works off `CandidateType`, not `CandidateSource`. The mapping `CandidateSource::PortMapped -> CandidateType::ServerReflexive` in `classify_candidate_type` at `src/connection/nat_traversal.rs:307` (introduced by PR #40 and untouched by PR #41) is what carries the PortMapped variant through the production pairing formula. No further plumbing is required. Extended PR #41's tombstone comment with a note explaining why PortMapped needs no replacement pairing logic at this site, so the next person grepping for `PortMapped` in `nat_traversal_api.rs` lands on the explanation directly. Validation: - cargo fmt --all -- --check clean - cargo clippy --features upnp --all-targets -- -D warnings clean - cargo clippy --no-default-features --features platform-verifier,network-discovery --all-targets -- -D warnings clean - cargo nextest run --features upnp --lib: 1470/1470 passed Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jacderida
approved these changes
Apr 6, 2026
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
Adds optional UPnP IGD port mapping that opportunistically asks the local router to forward the endpoint's UDP port and surfaces the resulting public address as a high-priority NAT traversal candidate. The integration is strictly additive — failure is silent and behaviour matches the pre-UPnP build exactly when no gateway is available.
When the gateway cooperates, peers can dial the port-mapped public address directly and skip hole punching entirely. When it doesn't (no router, UPnP disabled, refused, untrusted external IP), discovery proceeds exactly as before via OBSERVED_ADDRESS and PUNCH_ME_NOW.
What's in the box
Best-effort guarantees
Drive-by
Clippy caught a pre-existing `!target_peer_id.is_some()` at `nat_traversal_api.rs:6144` (introduced in f65af2e) that was blocking warnings-as-errors after a clippy upgrade. Replaced with `.is_none()`.
Test plan
What's deliberately NOT in this PR
Per the design discussion before implementation:
🤖 Generated with Claude Code
Greptile Summary
Adds an optional, default-on UPnP IGD port-mapping layer that surfaces the router-assigned public ip:port as a new
PortMappedcandidate at priority 70,000 / ICE type-preference 110. The integration is carefully best-effort:UpnpMappingService::startis infallible, all failure paths are silent, the endpoint holds exclusive ownership for graceful unmap on shutdown, and a--no-upnpCLI flag lets users opt out. One minor gap worth closing: the IPv6 branch ofis_plausibly_publicomitsv6.is_documentation()(stable since Rust 1.73), leaving2001:db8::/32accepted as plausibly public — and the accompanying unit test inadvertently uses that same documentation prefix as its "global unicast" example.Confidence Score: 5/5
Safe to merge; only P2 findings remain in a strictly additive best-effort feature
Both findings are P2: a missing is_documentation() guard for IPv6 (no real gateway returns 2001:db8:: as its external IP) and the corresponding misleading test address. Core logic — ownership model, async shutdown, dedup, priority constants, no-op stub path, graceful 500ms unmap — is sound. All 1468 tests pass.
src/upnp.rs — is_plausibly_public IPv6 branch and its unit test
Important Files Changed
Sequence Diagram
sequenceDiagram participant E as NatTraversalEndpoint participant U as UpnpMappingService participant G as IGD Gateway participant D as CandidateDiscoveryManager E->>U: start(local_port, config) U-->>E: service (state=Probing) E->>D: set_upnp_state_rx(rx) U->>G: search_gateway (SSDP, 2s timeout) G-->>U: gateway U->>G: get_external_ip() G-->>U: external_ip U->>U: is_plausibly_public(ip)? U->>G: add_port OR add_any_port G-->>U: mapped_port U-->>D: watch => Mapped {external, lease_expires_at} D->>D: upnp_candidate() snapshot D->>D: try_publish_upnp_candidate() D-->>E: LocalCandidateDiscovered(PortMapped) Note over U: Refresh loop at lease/2 U->>G: add_port(mapped_port, lease) G-->>U: renewed E->>U: shutdown() U->>U: notify + abort background task U->>G: remove_port (500ms budget) G-->>U: deletedPrompt To Fix All With AI
Reviews (1): Last reviewed commit: "feat: best-effort UPnP IGD port mapping ..." | Re-trigger Greptile