Files
wz-phone/vault/PRDs/PRD-portmap.md
Siavash Sameni ed8a7ae5aa docs: protocol audit 2026-05-25, update architecture + Obsidian vault
Audit:
- docs/AUDIT-2026-05-25.md: full protocol audit covering 8 findings
  (4 critical, 2 high, 5 medium, 4 low) with code references and fix
  effort estimates
- vault/Audit/Tasks.md: Obsidian Tasks plugin file tracking all audit
  items with priorities, due dates, and per-step checklists

Architecture docs updated for Wire format v2 and Wave 5/6 features:
- ARCHITECTURE.md: adds wzp-video to dependency graph and project
  structure; wire format updated to v2 (16B header, 5B MiniHeader);
  relay concurrency section corrected (DashMap+RwLock is current, not
  a future optimization); test count 571→702; Android note
- PROGRESS.md: Wave 5 and Wave 6 sections appended; test count 372→702;
  current status and open blockers as of 2026-05-25
- ROAD-TO-VIDEO.md: implementation status table inserted (/🟡/🔴/🔲
  per phase); 6-step critical path to first video call
- WZP-SPEC.md: MediaHeader updated to v2 (16B byte-aligned); MiniHeader
  updated to 5B with seq_delta; codec IDs 9-12 added (H.264/H.265/AV1);
  version negotiation section added

Obsidian vault (vault/):
- 114 files across Architecture/, PRDs/, Reports/, Android/,
  Reference/, Audit/ with YAML frontmatter
- 00 - Home.md index note with wiki links
- .obsidian/app.json config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 06:00:17 +04:00

4.4 KiB

tags, type
tags type
prd
wzp
prd

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