Files
wz-phone/docs/PRD-portmap.md
Siavash Sameni f83361895e
Some checks failed
Mirror to GitHub / mirror (push) Failing after 23s
Build Release Binaries / build-amd64 (push) Failing after 3m35s
docs: add PRDs for Phase 8 Tailscale-inspired features
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>
2026-04-14 11:08:46 +04:00

93 lines
4.4 KiB
Markdown

# 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` (parse `gateway:` 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 wins
- `release_port_mapping(mapping)` -> best-effort cleanup (lifetime=0 for NAT-PMP)
- `spawn_refresh(mapping)` -> background task renewing at half-lifetime
- `default_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:
1. Extracts `caller_mapped_addr` from `DirectCallOffer`, stores in registry
2. Extracts `callee_mapped_addr` from `DirectCallAnswer`, stores in registry
3. Cross-wires into `CallSetup`: caller gets callee's mapped addr as `peer_mapped_addr`, and vice versa
### Candidate Priority
`PeerCandidates.mapped` added to `dual_path.rs`. Dial order:
1. Host (LAN) candidates — fastest on same-LAN
2. **Port-mapped** — stable even behind symmetric NATs
3. Server-reflexive (STUN) — standard hole-punching
4. 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