This directory handles NAT traversal — how two peers behind NAT gateways establish a direct connection. Three strategies are used in order of preference:
- Introduction — ask a mutual peer to tell the other side your address
- Hole punching — both sides send probes simultaneously to punch through NAT
- Relay — if all else fails, route traffic through a mutual peer
| Section | Description |
|---|---|
| How It Works (ELI5) | NAT traversal in plain English |
| Introducer | Introduction request/offer protocol |
| Hole Punch | UDP hole punch probes |
| Relay | Traffic relay fallback |
Imagine Alice and Bob both live in gated apartment buildings. Neither can ring the other's doorbell because the gate only opens from inside. But they have a mutual friend Carol who lives in a building with no gate.
Introduction: Alice tells Carol, "Hey, can you let Bob know I want to talk? Here's my apartment number." Carol passes the message to Bob. Now Bob knows where to send his first letter.
Hole punching: Alice and Bob both open their gates at the same time by sending a letter to each other's building. Even though the first letters bounce off the closed gate, the act of sending opens a temporary slot in the gate (NAT mapping). The second round of letters gets through because the slots are now open.
Relay: If the gates are too clever (symmetric NAT) and hole punching fails, Carol agrees to forward letters between Alice and Bob. This is slower and costs Carol bandwidth, so she limits how many relay sessions she'll handle (default: 3).
┌──────────┐ ┌──────────┐
│ Alice │ │ Bob │
│ (behind │ │ (behind │
│ NAT) │ │ NAT) │
└────┬─────┘ └────┬─────┘
│ │
│ 1. INTRODUCTION_REQ │
│ "I want to talk to Bob" │
│ ─────────────► ┌──────────┐ │
│ │ Carol │ │
│ │ (public) │ │
│ └────┬─────┘ │
│ │ │
│ │ 2. INTRODUCTION_OFFER
│ │ "Alice is at 1.2.3.4:5678"
│ │ ─────────────────►│
│ │
│ 3. Hole punch probes (simultaneous) │
│ ◄══════════════════════════════════════►│
│ (both send, both punch NAT) │
│ │
│ 4. Direct encrypted connection │
│ ◄──────────────────────────────────────►│
Header: introducer.hpp
Namespace: sneaker::nat
The introduction protocol uses two control frames to coordinate a three-party introduction:
Alice ──INTRODUCTION_REQ──► Carol ──INTRODUCTION_OFFER──► Bob
(target = Bob) (from = Alice,
endpoint = Alice's
observed address)
Sent by Alice to Carol, asking Carol to introduce her to Bob:
┌─────────────────────────┐
│ target_id[32] │ "I want to talk to this peer"
└─────────────────────────┘
Sent by Carol to Bob, relaying Alice's contact information:
┌────────────────────────────────────────────────────────────┐
│ from_id[32] │ ip_type[1] │ ip[4 or 16] │ port[2] │
└────────────────────────────────────────────────────────────┘
| Field | Size | Description |
|---|---|---|
from_id |
32 | Alice's static public key |
ip_type |
1 | 4 = IPv4, 6 = IPv6 |
ip |
4 or 16 | Alice's observed IP address |
port |
2 | Alice's observed port |
Introduction requests are rate-limited to one per 10 seconds per peer
(INTRO_RATE_LIMIT_MS = 10000). This prevents an attacker from using a
relay node to flood introduction offers across the network.
Introducer intro;
// Alice builds a request to Carol, asking for Bob
size_t len = intro.build_request(bob_id, buffer, buf_len);
// Carol builds an offer to Bob, with Alice's address
size_t len = intro.build_offer(alice_id, alice_endpoint, buffer, buf_len);
// Parse incoming messages
uint8_t target[32];
intro.parse_request(payload, payload_len, target);
uint8_t from[32];
Endpoint ep;
intro.parse_offer(payload, payload_len, from, ep);
// Check rate limit before processing
bool allowed = intro.check_rate_limit(peer_id, now_ms);Header: hole_punch.hpp
Namespace: sneaker::nat
UDP hole punching exploits the fact that most NAT gateways create a temporary mapping when an outbound packet is sent. If both peers send packets to each other's observed address simultaneously, both NAT gateways create mappings, and subsequent packets flow through.
Hole punch probes use outer packet type 0x05 (not encrypted — they
happen before a Noise handshake):
┌──────────────────────────────────────────────────────┐
│ OuterHeader (6B) │ our_static_pub[32] │ nonce[8]│
└──────────────────────────────────────────────────────┘
The static public key lets the receiver identify which pending punch attempt this probe corresponds to. The nonce prevents replay.
| Constant | Value | Description |
|---|---|---|
PUNCH_TICK_INTERVAL_MS |
200 | Probe interval |
PUNCH_DEFAULT_TIMEOUT_MS |
5000 | Give up after 5 seconds |
MAX_PUNCH_ATTEMPTS |
16 | Maximum concurrent punch attempts |
PUNCH_PROBE_PAYLOAD_SIZE |
40 | 32 (pubkey) + 8 (nonce) |
FUNCTION hole_punch(alice_endpoint, bob_endpoint):
// Both sides were told about each other via introduction.
// Now they need to punch through their NAT gateways.
// ─── Tick 0 (T=0ms) ────────────────────────────────────
// Alice sends a probe to Bob's observed address.
// Bob sends a probe to Alice's observed address.
//
// Both probes likely hit a closed NAT mapping and are
// dropped. BUT: Alice's NAT now has a mapping for
// "Alice:random → Bob:observed", and Bob's NAT has
// "Bob:random → Alice:observed".
alice: send_probe(bob_endpoint) // dropped by Bob's NAT
bob: send_probe(alice_endpoint) // dropped by Alice's NAT
// ─── Tick 1 (T=200ms) ──────────────────────────────────
// Alice sends another probe. This time, Bob's NAT has a
// mapping from Bob's earlier outbound probe, so Alice's
// probe gets through. Bob matches the probe to his pending
// punch attempt via the static public key.
alice: send_probe(bob_endpoint) // gets through!
bob: match_probe(alice_probe) // matched!
// ─── Result ────────────────────────────────────────────
// Both sides now have a working UDP path. They proceed
// with a Noise XX handshake on this path.
HolePunchManager punch;
// Start a punch attempt to a target endpoint
punch.start_punch(target_endpoint, peer_id, timeout_ms);
// Timer tick (every 200ms) — generates probes to send
punch.tick(now_ms);
auto probes = punch.get_probes();
for (auto &[endpoint, payload] : probes)
udp_send_to(sock, payload.data(), payload.size(), endpoint);
// Match an incoming probe to a pending attempt
Endpoint matched;
if (punch.match_probe(probe_data, probe_len, source, matched))
{
// Hole punch succeeded — initiate Noise handshake
initiate_connect(matched);
}
// Clean up timed-out attempts
size_t expired = punch.prune_timed_out(now_ms);Header: relay.hpp
Namespace: sneaker::nat
When hole punching fails (typically with symmetric NAT), peers can fall back to routing traffic through a mutual relay node. The relay node forwards encrypted packets between two peers without being able to read them (the Noise tunnel is end-to-end between the actual peers, not the relay).
| Type | Value | Direction | Description |
|---|---|---|---|
RELAY_REQUEST |
0x17 |
Requester -> Relay | "Please relay my traffic to peer X" |
RELAY_ACCEPT |
0x18 |
Relay -> Requester | "OK, session established" |
RELAY_REJECT |
0x19 |
Relay -> Requester | "No, because: capacity / not connected / not allowed" |
RELAY_DATA |
0x1A |
Either -> Relay | Forwarded data frame |
RELAY_END |
0x1B |
Either -> Relay | End the relay session |
┌─────────────────────────┐
│ target_id[32] │ "Please relay to this peer"
└─────────────────────────┘
| Reason | Description |
|---|---|
AT_CAPACITY |
Relay node has reached max_relayed_connections (default 3) |
NOT_CONNECTED |
Relay node is not connected to the target peer |
NOT_ALLOWED |
Relay node has accept_relay_requests disabled |
| Constant | Value | Description |
|---|---|---|
RELAY_IDLE_TIMEOUT_MS |
120,000 | Sessions expire after 2 minutes of no traffic |
max_sessions |
3 | Maximum simultaneous relay sessions per node |
FUNCTION relay_fallback():
// Alice and Bob both tried hole punching for 5 seconds
// and failed (symmetric NAT on both sides). Carol is
// connected to both and has relay enabled.
// ─── Step 1: Alice requests relay ───────────────────────
// Alice sends RELAY_REQUEST to Carol with Bob's PeerId.
alice → carol: RELAY_REQUEST(target = bob_id)
// ─── Step 2: Carol checks capacity ──────────────────────
// Carol has 3 max sessions and currently 1 active.
// She's connected to Bob. She accepts.
carol → alice: RELAY_ACCEPT
// ─── Step 3: Data forwarding ────────────────────────────
// Alice sends Noise-encrypted packets to Carol as RELAY_DATA.
// Carol looks up the relay session, finds Bob as the partner,
// and forwards the RELAY_DATA to Bob. Bob decrypts with the
// Noise keys he shares with Alice (Carol cannot read them).
alice → carol: RELAY_DATA(encrypted_for_bob)
carol → bob: RELAY_DATA(encrypted_for_bob) // forwarded
bob → carol: RELAY_DATA(encrypted_for_alice)
carol → alice: RELAY_DATA(encrypted_for_alice) // forwarded
// ─── Step 4: Idle timeout ───────────────────────────────
// If neither side sends data for 120 seconds, Carol prunes
// the session automatically.
// ─── Step 5: Periodic direct retry ──────────────────────
// Every relay_retry_direct_interval (default 2 minutes),
// Alice and Bob try hole punching again. If network conditions
// change (e.g., one side moves to a less restrictive NAT),
// they establish a direct connection and end the relay.
RelayManager relay(3); // max 3 simultaneous sessions
// Handle incoming relay request
RelayResponse response = relay.handle_request(
requester_key, target_id, peer_manager,
accept_relay_requests, now_ms
);
// response.accepted == true/false
// response.reply_buf contains the RELAY_ACCEPT or RELAY_REJECT frame
// Forward data — find the relay partner for a sender
PeerIdKey *partner = relay.get_relay_partner(sender_key, now_ms);
if (partner)
forward_data_to(*partner, relay_data);
// End a session
relay.end_session(peer_key);
// Periodic cleanup (prune idle sessions)
relay.tick(now_ms);