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