Back to Site
Loading...
Searching...
No Matches
hole_punch.h File Reference

UDP hole punching: two nodes behind NATs dial each other at the same instant, arranged through a peer they both already have. More...

#include "librats/util/rats_export.h"
#include "librats/core/address.h"
#include "librats/core/types.h"
#include "librats/node/nat_status.h"
#include "librats/node/peer_network.h"
#include "librats/peer/peer.h"
#include "librats/peer/peer_id.h"
#include "librats/subsystems/hole_punch_service.h"
#include "librats/subsystems/relay_service.h"
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <mutex>
#include <thread>
#include <unordered_map>
#include <vector>
Include dependency graph for hole_punch.h:

Go to the source code of this file.

Classes

class  librats::HolePunch
 Published as HolePunchService, so a module that discovers a peer it cannot dial (PeerExchange) can hand the id over without depending on this class. More...
 
struct  librats::HolePunch::Config
 

Namespaces

namespace  librats
 

Detailed Description

UDP hole punching: two nodes behind NATs dial each other at the same instant, arranged through a peer they both already have.

── Why this can work at all ──────────────────────────────────────────────── A NAT drops an inbound datagram unless something went out to that peer first. So neither side can be dialed — but if BOTH dial at once, each one's outbound Syn opens the mapping and the filter its peer's Syn needs, and the two cross mid-path. Whichever direction survives becomes an ordinary connection.

Everything below that is already in place and is deliberately left alone:

  • The punch packet is the Syn. A datagram dial sends one immediately (UdpMux::connect) and retries it, so a punch needs no new packet type on the wire — only a different retry shape (DialProfile::punch(): dense probes with no backoff, because the first Syns are expected to die on the peer's NAT).
  • One socket, one mapping. Every UDP peer shares the node's single datagram socket, so the port a peer sees us send from is the port anyone can aim at. That is what identify now reports on datagram links, and what nat_status.h turns into "is this mapping stable enough to punch through at all".
  • Both sides succeeding is fine. A punch normally lands in both directions, leaving two connections; PeerTable::add resolves that pair identically at both ends (the link from the smaller PeerId survives), exactly as it does for any simultaneous cross-connect.

── The only genuinely hard part: agreeing on "now" ───────────────────────── Punching works when the two bursts overlap, and the peers have no shared clock. The fix (as in libp2p's DCUtR) is to derive the instant from the round trip itself rather than from any clock. With A the initiator, B the target and R a peer both are connected to:

  1. A →R→ B Connect{addrs of A} A notes the time it sent this
  2. B →R→ A Connect{addrs of B} B now waits
  3. A →R→ B Sync A starts dialing AT ONCE
  4. B receives Sync B starts dialing AT ONCE

A starts half a relayed round trip early and B starts on arrival, so the two bursts meet in the middle of the path. No clock synchronisation, and the accuracy degrades gracefully — a slow relay widens the window rather than breaking it, which is why the punch profile fires several probes instead of one.

── The relay ─────────────────────────────────────────────────────────────── R is an ordinary node running this same subsystem. It forwards ONLY control messages, ONLY to a peer it is already connected to, and ONLY within a per-peer budget. It never opens a connection, never resolves an address and never carries application data — so it cannot be turned into an open reflector, and the traffic it does relay is a few dozen bytes per punch.

── Wire format (MessageType::Punch), big-endian ──────────────────────────── envelope : [u8 ver=1][u8 op] op=0 Relay : [32B dst_id][inner…] sender → relay op=1 Relayed : [32B src_id][inner…] relay → destination inner : [u8 kind] kind=0 Connect : [u8 role][u8 n][ n × { u8 ip_len, ip bytes, u16 port } ] kind=1 Sync : —

role says whether a Connect opens a rendezvous (0) or answers one (1). Without it the two are indistinguishable, and an initiator would read the answer it asked for as a competing rendezvous — hand the round to the peer, and the exchange would ping-pong Connects forever with neither side ever sending Sync. It is also what makes the collision case (both ends opening at once) recognisable, and therefore settleable by the same symmetric PeerId rule the peer table uses.

The inner message is end-to-end between the two punching peers; the relay copies it across without looking inside. Every length is bounds-checked and every count capped, so a malformed or hostile payload is dropped rather than acted on.

── What this does NOT do ─────────────────────────────────────────────────── A symmetric NAT (a fresh mapping per destination) cannot be punched through by any endpoint we can advertise, and this subsystem does not pretend otherwise: it checks NatMapping first and declines rather than firing a burst that cannot land. There is no TCP punching (the datagram wire is the one that fits), no port prediction (it would need more than the one socket the design is built on) and no relaying of data (a punch that fails, fails).

Trust: the addresses a peer advertises for itself are its own unverified word, and a relay could forge a Connect naming somebody else's endpoint. Acting on that means sending a handful of 16-byte Syns to an address of another node's choosing — the same exposure PeerExchange already accepts when it dials what a peer told it about, and bounded here by the per-session address cap and the retry limits.

Threading: message handlers run on reactor threads; one worker thread drives session timeouts and retries. All session state is behind one mutex, and no blocking work happens on a reactor thread.

Definition in file hole_punch.h.