5 new PRDs: - PRD-public-stun.md — RFC 5389 STUN client - PRD-portmap.md — NAT-PMP/PCP/UPnP port mapping - PRD-ice-regather.md — Mid-call ICE re-gathering - PRD-netcheck.md — Network diagnostic - PRD-relay-selection.md — Region-based relay selection Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
4.4 KiB
PRD: NAT Port Mapping (PCP/PMP/UPnP)
Phase: Implemented Status: Done (2026-04-14) Crate: wzp-client, wzp-proto, wzp-relay
Problem
WarzonePhone falls back to relay-only when the client is behind a symmetric NAT (different external port per destination). The STUN-discovered reflexive address won't match what a peer sees, so direct hole-punching fails. Tailscale reports ~70% of consumer routers support NAT-PMP, PCP, or UPnP — protocols that let clients request explicit port mappings, making symmetric NATs traversable.
Solution
Implement all three port mapping protocols, tried in sequence (NAT-PMP -> PCP -> UPnP). When a mapping is acquired, advertise the mapped address as a new candidate type alongside reflexive and host candidates. The relay cross-wires it into CallSetup.peer_mapped_addr so the peer can dial it.
Implementation
New Module: crates/wzp-client/src/portmap.rs
NAT-PMP (RFC 6886):
- UDP to gateway:5351
- External address request (opcode 0) -> returns router's public IP
- Map UDP request (opcode 1) -> returns mapped external port + lifetime
- 12-byte request, 16-byte response
PCP (RFC 6887):
- Same gateway:5351, version 2
- MAP opcode with client IP as IPv4-mapped IPv6
- 60-byte request/response with 12-byte nonce for anti-spoofing
- Superset of NAT-PMP, supports IPv6
UPnP IGD:
- SSDP M-SEARCH to 239.255.255.250:1900 for InternetGatewayDevice discovery
- Parse LOCATION header -> fetch device description XML -> find WANIPConnection controlURL
- SOAP
GetExternalIPAddress-> router's public IP - SOAP
AddPortMapping-> maps the QUIC port
Gateway discovery:
- macOS:
route -n get default(parsegateway:line) - Linux/Android:
/proc/net/route(parse hex gateway for 00000000 destination)
Public API:
acquire_port_mapping(internal_port, local_ip)-> tries all 3, first success winsrelease_port_mapping(mapping)-> best-effort cleanup (lifetime=0 for NAT-PMP)spawn_refresh(mapping)-> background task renewing at half-lifetimedefault_gateway()-> cross-platform gateway discovery
Signal Protocol Extensions
| Message | New Field | Purpose |
|---|---|---|
DirectCallOffer |
caller_mapped_addr: Option<String> |
Caller's port-mapped address |
DirectCallAnswer |
callee_mapped_addr: Option<String> |
Callee's port-mapped address |
CallSetup |
peer_mapped_addr: Option<String> |
Relay cross-wires peer's mapped addr |
All fields use #[serde(default, skip_serializing_if)] for backward compatibility.
Relay Cross-Wiring
CallRegistry extended with caller_mapped_addr / callee_mapped_addr fields + setter methods. The relay:
- Extracts
caller_mapped_addrfromDirectCallOffer, stores in registry - Extracts
callee_mapped_addrfromDirectCallAnswer, stores in registry - Cross-wires into
CallSetup: caller gets callee's mapped addr aspeer_mapped_addr, and vice versa
Candidate Priority
PeerCandidates.mapped added to dual_path.rs. Dial order:
- Host (LAN) candidates — fastest on same-LAN
- Port-mapped — stable even behind symmetric NATs
- Server-reflexive (STUN) — standard hole-punching
- Relay — always-available fallback
Desktop Integration
Both place_call() and answer_call() call acquire_port_mapping() using the signal endpoint's local port. Privacy-mode answers (AcceptGeneric) skip portmap to keep the address hidden.
Files
| File | Change |
|---|---|
crates/wzp-client/src/portmap.rs |
New — NAT-PMP/PCP/UPnP client |
crates/wzp-client/src/dual_path.rs |
PeerCandidates.mapped field + dial_order update |
crates/wzp-proto/src/packet.rs |
caller/callee_mapped_addr + peer_mapped_addr fields |
crates/wzp-relay/src/call_registry.rs |
caller/callee_mapped_addr fields + setters |
crates/wzp-relay/src/main.rs |
Extract, store, cross-wire mapped addrs |
desktop/src-tauri/src/lib.rs |
Call portmap in place_call/answer_call |
Testing
- 18 unit tests: NAT-PMP encoding, UPnP XML parsing (5 variants including real-world router XML), URL host extraction, error Display, protocol serde, PortMapping serialization, gateway detection, constants verification
- 2 integration tests (
#[ignore]): gateway discovery, acquire_mapping - 9 PeerCandidates tests: dial_order with all types, dedup, is_empty edge cases
- 12 protocol roundtrip tests: offer/answer/setup with mapped addr, backward compat without