-
Notifications
You must be signed in to change notification settings - Fork 4
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.
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 |
// 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;
}| 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 |
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.
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.
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 isResult::PeerNotFound, the relay's answer when the asked-for name is not registered. The relay link stays up, soAskPeercan 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.
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.
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.
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.
The repo ships one:
cmake --build build --target rendezvous-server
./build/rendezvous-server/rendezvous-serverIt 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::server_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.
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.