Skip to content

No TURN relay: peers on symmetric NAT or CGNAT cannot connect #2

Description

@itsoumya-d

Summary

The ICE server configuration is STUN-only. No TURN relay is configured. Peers behind symmetric NAT (common on corporate networks) or carrier-grade NAT (common on mobile) will fail to establish a connection and no relay fallback exists.

This is a known, documented limitation. The README now discloses it. This issue tracks implementing an actual fallback.


STUN-only ICE configuration

In src/peer-mesh.ts, connectToPeer() at line 72 and handleOffer() at line 100, both peer connection creation sites use:

const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });

One STUN server. No TURN servers. The iceServers configuration is hardcoded and is not overridable by the caller.


Protocol-level failure mode

STUN resolves public IP/port pairs (server-reflexive candidates). For peers behind symmetric NAT, each outbound connection uses a different port mapping, so the STUN-discovered candidate is not reusable by the remote peer's inbound connection attempt. ICE cannot complete. No relay exists to carry the traffic.

This could not be tested live: the audit environment has HTTP/HTTPS-only egress; UDP is blocked, so STUN and TURN cannot run. The conclusion is protocol-level, not an observed test result.


ICE failure handling in this repo

Unlike some other repos in this project, peer-mesh.ts does register an oniceconnectionstatechange handler (line 206):

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
    this.removePeer(peerId);
    this.handleConnectionFailure(peerId);
  }
};

removePeer emits peer_disconnected. handleConnectionFailure schedules an exponential-backoff reconnect attempt (starting at 500 ms, doubling to a maximum of 30 seconds) that calls connectToPeer again.

This is better than silently losing the peer, but the reconnect will fail for the same reason as the original attempt — the ICE configuration does not change between attempts. Against symmetric NAT, the retry loop will consume resources and eventually converge on the 30-second backoff, never succeeding. The emitted peer_disconnected event carries no reason code, so callers still cannot distinguish a NAT-traversal failure from a voluntary departure.

Additionally, the ping/timeout logic in setupPeer (line 156) will fire handleConnectionFailure again after 30 seconds of no pong — this can cause duplicate events if oniceconnectionstatechange and the ping timeout both fire for the same peer.


Impact

  • Peers on symmetric NAT or CGNAT cannot communicate; they receive peer_disconnected followed by repeated peer_disconnected events from retry-and-fail cycles.
  • Callers cannot distinguish NAT traversal failure from normal departure.
  • Retry attempts against symmetric NAT will never succeed without a TURN relay.

Proposed fixes

  1. TURN relay: Accept an optional iceServers array in the PeerMesh constructor and merge it with the STUN server, so callers can supply TURN credentials.
  2. Distinguishable error reason: Emit an ice_failed event (or include a reason field on peer_disconnected) when the ICE state is specifically 'failed', separate from 'disconnected'.
  3. Deduplication of failure events: Guard handleConnectionFailure so the ping timeout and oniceconnectionstatechange paths for the same peer do not both fire (the removePeer guard partially handles this but the reconnect callback re-adds the peer before the ping timeout clears).

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions