Skip to content

Latest commit

 

History

History
351 lines (265 loc) · 13.4 KB

File metadata and controls

351 lines (265 loc) · 13.4 KB

NAT Module

This directory handles NAT traversal — how two peers behind NAT gateways establish a direct connection. Three strategies are used in order of preference:

  1. Introduction — ask a mutual peer to tell the other side your address
  2. Hole punching — both sides send probes simultaneously to punch through NAT
  3. Relay — if all else fails, route traffic through a mutual peer

Table of Contents

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

How It Works (ELI5)

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         │
       │ ◄──────────────────────────────────────►│

Introducer

Header: introducer.hpp Namespace: sneaker::nat

The introduction protocol uses two control frames to coordinate a three-party introduction:

Message Flow

Alice ──INTRODUCTION_REQ──► Carol ──INTRODUCTION_OFFER──► Bob
         (target = Bob)              (from = Alice,
                                      endpoint = Alice's
                                      observed address)

INTRODUCTION_REQ (control frame type 0x15)

Sent by Alice to Carol, asking Carol to introduce her to Bob:

┌─────────────────────────┐
│  target_id[32]          │   "I want to talk to this peer"
└─────────────────────────┘

INTRODUCTION_OFFER (control frame type 0x16)

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

Rate Limiting

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.

API

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);

Hole Punch

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.

Probe Format

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.

Constants

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)

Scenario: Hole Punch Between Alice and Bob

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.

API

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);

Relay

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).

Control Frame Types

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

Relay Request

┌─────────────────────────┐
│  target_id[32]          │   "Please relay to this peer"
└─────────────────────────┘

Rejection Reasons

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

Constants

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

Scenario: Relay Fallback

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.

API

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);