Skip to content

NAT Traversal

Most nodes sit behind NATs. Two peers behind separate NATs cannot connect directly without coordination. mycellm solves this with a STUN + hole-punch + QUIC pipeline, falling back to relay when direct paths fail.

On startup, the node contacts public STUN servers to classify its NAT type:

stun.l.google.com:19302
stun.cloudflare.com:3478
stun.stunprotocol.org:3478

These are free, reliable public STUN servers. Custom servers can be configured via MYCELLM_STUN_SERVERS.

The classification determines strategy:

NAT TypeHole PunchNotes
Full ConeAlways worksSingle mapped port
Address-RestrictedUsually worksSame external port for same dest IP
Port-RestrictedSometimes worksRequires port prediction
SymmetricRarely worksFalls back to relay

The result is cached for 5 minutes and re-probed on network change.

When peer A wants to connect to peer B, the bootstrap node coordinates the punch:

A ──PUNCH_REQUEST──▶ Bootstrap
│
▼
B ◀──PUNCH_INITIATE──
│
A ◀──────────────────── │ ──────────────────▶ B
(both send UDP packets to each other's mapped address)
│
A ──PUNCH_RESPONSE──▶ Bootstrap
B ──PUNCH_RESPONSE──▶ Bootstrap
│
PUNCH_RESULT (success/fail)
  1. PUNCH_REQUEST — A asks bootstrap to coordinate a punch with B
  2. PUNCH_INITIATE — Bootstrap tells both peers each other’s public ip:port
  3. Both peers send UDP packets to the other’s mapped address simultaneously
  4. PUNCH_RESPONSE — Each peer reports success or timeout to bootstrap
  5. PUNCH_RESULT — Bootstrap confirms the outcome to both

The punch attempt times out after 5 seconds. Both peers send 10 UDP probes at 500ms intervals.

Once a UDP hole is established, the initiating peer upgrades the punched socket to a QUIC connection with mutual TLS:

A ──QUIC ClientHello──▶ B (over punched UDP hole)
A ◀──ServerHello + Cert── B
A ──Cert + Finished──▶ B
│
Authenticated QUIC stream

The TLS certificates are the same device certs used for bootstrap connections. Both peers verify the other’s cert chain back to their account key.

When hole punching fails (symmetric NAT, firewall, timeout), traffic routes through the bootstrap node as a relay:

Terminal window
# The relay path is transparent to the application layer
A ──QUIC──▶ Bootstrap ──QUIC──▶ B

Relayed connections incur a 2x credit cost — the relay node earns credits for bandwidth. The PUNCH_RESULT message includes a relay: true flag so both peers know the path.

Uses aioquic for QUIC transport and raw UDP sockets for hole punching:

# Hole punch initiated by PeerManager
await peer_manager.punch_through(target_peer_id)

Uses NWConnectionGroup from Network.framework for UDP hole punching. This integrates with iOS power management and avoids raw socket restrictions:

let group = NWConnectionGroup(of: .unicast, using: .udp)
group.stateUpdateHandler = { state in
// Handle punch coordination
}

After the punch succeeds, the connection upgrades to QUIC via NWConnection with custom TLS options carrying the device certificate.

Terminal window
# Check NAT type
mycellm status --nat
# Force re-probe
mycellm nat-probe
# View punch history
mycellm peers --punches