Skip to content

Peer to Peer

irrld edited this page Aug 4, 2026 · 4 revisions

Peer-to-Peer

Two peers behind NAT cannot dial each other directly. znet's P2P module solves this the usual way: both connect out to a rendezvous server, learn each other's external address, then punch simultaneously so each one's NAT sees the other's traffic as a reply to something it already sent.

The result is an ordinary PeerSession. Once it exists, everything else in this wiki applies unchanged.

The pieces

PeerLocator Talks to the rendezvous server, exchanges addresses, drives the punch
p2p::PunchSync The punch itself. Called by the locator; usable directly if you have addresses already
p2p::IsInitiator Decides which peer takes the server role
rendezvous-server A ready-to-run rendezvous server in the repo

Usage

// the locator's Wait() returns once punching resolves, so hold the session
// yourself if the connection should outlive it
std::shared_ptr<PeerSession> g_session;
std::unique_ptr<p2p::PeerLocator> g_locator;

bool OnPeerReady(p2p::PeerLocatorReadyEvent& event) {
  // registered with the rendezvous server under event.peer_name(); ask for
  // whoever you want to reach
  g_locator->AskPeer("the-other-peer");
  return false;
}

bool OnPeerConnected(p2p::PeerConnectedEvent& event) {
  g_session = event.session();
  g_session->SetCodec(std::make_shared<Codec>());

  // both peers run the same code, so one has to take the server role. this
  // derives it from the punch id so the two sides never both pick the same one.
  bool acts_as_server = p2p::IsInitiator(event.punch_id(), event.self_peer_name(),
                                         event.target_peer_name());
  return false;
}

void OnEvent(Event& event) {
  EventDispatcher dispatcher{event};
  dispatcher.Dispatch<p2p::PeerLocatorReadyEvent>(ZNET_BIND_GLOBAL_FN(OnPeerReady));
  dispatcher.Dispatch<p2p::PeerConnectedEvent>(ZNET_BIND_GLOBAL_FN(OnPeerConnected));
}

int RunPeer() {
  p2p::PeerLocatorConfig config{"rendezvous.example.com", 25000};
  g_locator.reset(new p2p::PeerLocator(config));
  g_locator->SetEventCallback(ZNET_BIND_GLOBAL_FN(OnEvent));
  if (g_locator->Connect() != Result::Success) {
    return 1;
  }
  g_locator->Wait();  // returns once punching resolves, either way
  return 0;
}

Events

Event When Accessors
PeerLocatorReadyEvent Registered with the rendezvous server peer_name(), endpoint()
PeerConnectedEvent The punch succeeded and a session exists session(), punch_id(), self_peer_name(), target_peer_name()
PeerLocatorCloseEvent The locator shut down None

Two things that catch people out

Wait() returns when punching resolves, not when you are done. The locator's job ends once it has produced a session. If you let the locator go out of scope without holding the session, the connection goes with it. Keep the shared_ptr somewhere that outlives the locator, as the example does.

To reuse a locator for another peer, call Connect() again and handle the events again.

Somebody has to be the server. Both peers run identical code, so without a tiebreak both would wait for the other to speak first. p2p::IsInitiator derives the answer from the punch id and the two names, so the two sides always disagree: exactly one gets true.

This also decides encryption: the initiator is the accepting side, so its options are what the session adopts. See Encryption and Compression.

Transport

The rendezvous server decides the transport of the punched connection, so the two peers can never disagree: -c tcp|zdt on the shipped binary, or RendezvousServer::Config::punch_connection_type when embedding it. The default is ZDT, which is the better fit for traversal: it is UDP underneath, and UDP hole punching works through more NATs than TCP's simultaneous open does. The relay's own listener stays TCP either way.

When it fails

Every failure surfaces as a PeerLocatorFailedEvent carrying the phase, a Result reason and the peer name being sought:

  • PeerLocatorPhase::Relay - the link to the rendezvous server died.
  • PeerLocatorPhase::Rendezvous - the exchange on it failed; today that is Result::PeerNotFound, the relay's answer when the asked-for name is not registered. The relay link stays up, so AskPeer can simply be called again.
  • PeerLocatorPhase::Punch - the hole punch itself failed, e.g. Result::Timeout.

Relay and Punch failures are followed by PeerLocatorCloseEvent, the terminal "no session is coming" signal; success is PeerConnectedEvent instead. The p2p example handles all of them.

Same-network peers

Each locator reports its own private address at identify, and the rendezvous relays it to the match as a second punch candidate whenever it differs from the observed public one. ZDT races every candidate from the one socket and the first answer wins, so two peers behind the same NAT connect over the LAN without relying on hairpinning; TCP cycles through candidates across connect attempts with a per-attempt cap, which converges but slower. The punched session's own handshake is what confirms the right host answered.

Punched sessions drive themselves on one thread each, and that thread dozes when idle rather than spinning, so holding a session open costs roughly nothing between messages.

Limits

Hole punching does not always work. Symmetric NATs assign a different external port per destination, so the address learned from the rendezvous server is not the one your peer must send to. There is no fallback relay in znet: if the punch fails, it fails, and your application decides what to do next.

Meshes: three or more players

p2p::MeshLocator is the many-peer flavor. It stays connected to the rendezvous, AskPeer may be called any number of times, and every punch runs asynchronously on one shared UDP socket owned by p2p::Host, because a NAT hands out one public mapping per socket and every peer must be reached through it. Each success fires its own PeerConnectedEvent, on the host's thread, which is where the session's codec and handler belong. ZDT only, which the rendezvous brokers by default; a relay running -c tcp cannot serve a mesh.

Losing the relay ends matchmaking, not the game: punched sessions live until Disconnect() or their own idle timers close them, and Connect() rejoins the relay with the mesh intact. tests/p2p_host.cc has a full three-player mesh as a working reference.

The one-shot PeerLocator remains the simple two-player path and the only one that can punch TCP.

Running a rendezvous server

The repo ships one:

cmake --build build --target rendezvous-server
./build/rendezvous-server/rendezvous-server

It needs a public address both peers can reach. It only brokers introductions, and no traffic flows through it once the punch succeeds.

The brokering logic itself is znet::p2p::RendezvousServer (znet/p2p/rendezvous_server.h), so it can also run inside a larger process: construct it with a Config, call Start(), and read bind_address() back if you bound port 0. The shipped binary is a thin wrapper over it.

A public broker is a spam target, so it defends itself on two levels. Config::options is a full ServerOptions, meaning the listener-level protections apply: allow/deny lists, the per-source connection throttle and max_connections (which the server now enforces for TCP listeners). On top of that, Config::max_requests_per_window (default 30 per 10 s) bounds the locator requests one connected client may make; identify and connect-peer both count, a client over the limit is dropped, and the connection throttle prices the reconnect. Identify is idempotent, so a client repeating it is handed the same name rather than minting fresh registry entries.

Status

The module is younger than the rest of the library, but the gaps previously listed here are closed: the punch transport survives the wire, the locator joins its worker and its cross-thread state is guarded, failures surface as events, an unknown peer name is answered rather than ignored, and the whole rendezvous-and-punch flow runs end to end in tests/locator.cc over both transports. The remaining known gap is architectural and tracked in TODO: the punch is a blocking call on the locator's worker rather than event-driven I/O.

Clone this wiki locally