⚠️ Before you read:
Before diving into this article, make sure you understand two key things: first, what UDP hole punching actually is, and second, have the code repository downloaded for this experiment: https://github.com/MohamedAYassin/OnePunchHole.
The part of UDP hole punching people usually skip is not the phrase “send packets at the same time.” The real problem is preserving the same socket, the same local port, and the same NAT mapping from the first discovery packet until the direct path is actually alive.
That was the constraint behind OnePunchHole: two machines behind NAT, direct UDP, no custom server in the middle, no TURN relay, and no networking library hiding the mechanics. The result is a 317-line Linux implementation and a 312-line Windows implementation, both written around raw POSIX sockets or Winsock and speaking the same wire protocol.
STUN Discovery & RFC 5389 by Hand
The first obstacle is that the peer cannot see the address the process bound locally. The program may be listening on 192.168.1.20:50000, while the router exposes that socket as something like 198.51.100.20:41222. Before either peer can punch, it needs to discover that public tuple.
That is why the code starts with five Google STUN hosts in STUNS (OnePunchHole_linux.cpp:28-29, Windows: 24-25). resolve turns those hostnames into UDP endpoints on port 3478 (linux:63-69, Windows: 55-60), and stun_query builds the RFC 5389 Binding Request by hand (linux:70-92, Windows: 62-84). The request is a 20-byte message with type 0x0001, the magic cookie 0x2112A442, and a 96-bit transaction ID. The response is checked against that transaction ID, then parsed for the IPv4 XOR-MAPPED-ADDRESS attribute. The returned port is XOR-decoded with 0x2112, and the address with 0x2112A442. That is the reflex ip:port printed to the terminal.
The Linux implementation makes the request directly in stun_query:
unsigned char req[20] = {0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xA4, 0x42};
for (int i = 8; i < 20; i++) req[i] = (unsigned char)rand();
if (sendto(s, req, sizeof req, 0, (const sockaddr*)&srv, sizeof srv) < 0) return false;
Local Port Binding & Fallbacks
The important detail is where that request comes from. udp_sock (linux:37-44, Windows: 30-37) binds the fixed local port supplied by the user:
static int udp_sock(uint16_t port) {
int s = socket(AF_INET, SOCK_DGRAM, 0);
if (!port || s < 0) return s;
sockaddr_in a{};
a.sin_family = AF_INET;
a.sin_addr.s_addr = htonl(INADDR_ANY);
a.sin_port = htons(port);
if (!bind(s, (sockaddr*)&a, sizeof a)) return s;
close(s);
fprintf(stderr, "port %u in use, using a random one\n", (unsigned)port);
return socket(AF_INET, SOCK_DGRAM, 0);
}
If that port is already occupied, the program falls back to a random OS-assigned port and says so. The fallback is useful for keeping the process alive, but it weakens the whole plan: the peer can no longer predict the mapping the user intended to share.
Preserving Socket State Across Discovery and Traversal
So the punch path keeps one socket alive from start to finish (linux:248-253, Windows: 242-247). It binds the socket, attempts router mapping, performs STUN discovery, prints the reflex endpoint, sends the punch packets, and then receives chat and keepalives on that same descriptor. Reopening the socket between discovery and punching could create a different NAT mapping and make the endpoint printed to the other peer stale.
Manual Signalling: The Copy-Paste Exchange
There is also no hidden signalling service. Both peers can run ./OnePunchHole punch 50000, read their own reflex endpoints, swap them manually, and paste the other endpoint into the prompt. parse_ep handles the ip:port input (linux:51-55, Windows: 43-47), or the peer can be supplied directly as ./OnePunchHole punch 203.0.113.7:41222 50000. That copy-paste step is not just an unfinished interface; it is the signalling half that this PoC deliberately leaves manual. STUN can tell each client what it looks like from outside, but it cannot introduce two clients to each other.
Router Assistance: UPnP IGD and NAT-PMP
Before the actual punch, the program tries to make the mapping more predictable. port_map (linux:204, Windows: 196) first attempts UPnP IGD: SSDP discovery on 239.255.255.255:1900, an HTTP request for the router description, extraction of the control URL, and a SOAP AddPortMapping request for a permanent UDP lease (linux:124-175, Windows: 116-167). If that fails, natpmp_map probes four common gateways on UDP 5351 and requests a NAT-PMP mapping (linux:176-203, Windows: 168-195). These are not relays; they are router configuration paths. If the router allows them, they can turn a symmetric-NAT problem into a fixed-port mapping. If they do not, the code still attempts ordinary hole punching.
Diagnosing NAT Mappings with the Check Command
The check command makes that behavior measurable instead of leaving it as guesswork. It queries two STUN servers through the same socket (linux:227-238, Windows: 221-232) and compares the observed endpoints with same_ep (linux:57-59, Windows: 49-51). If both observations match, the program reports an endpoint-independent mapping and says punching can work. If they differ, it reports a symmetric mapping and points toward UPnP, NAT-PMP, or a real relay. It is not a complete NAT classifier; it answers one practical question about this specific socket.
The Punch Loop & Keepalives
Then comes the part people usually call “the punch.” The constants are deliberately visible: MAX_ATTEMPTS = 200 and PEER_TIMEOUT = 5 (linux:30, Windows: 26). Before a connection is detected, the loop sends PUNCH n every 250 milliseconds, giving the two peers roughly 50 seconds to create overlapping NAT state. The send decision is concentrated in the loop (linux:302-309, Windows: 296-303):
put(connected
? "KEEP " + std::to_string(++seq)
: "PUNCH " + std::to_string(attempt),
s, peer);
When a datagram arrives from the exact endpoint supplied by the user, same_ep(from, peer) accepts it, the program prints HOLE PUNCHED, and the loop switches from PUNCH to one KEEP n per second (linux:284-295, Windows: 278-289). The receive-side check is also literal code:
if (n > 0 && same_ep(from, peer)) {
b[n] = 0; last = std::chrono::steady_clock::now();
if (!connected) { connected = true; puts("HOLE PUNCHED"); }
if (!strcmp(b, "BYE")) { puts("peer disconnected"); break; }
if (strncmp(b, "KEEP ", 5)) printf("peer> %s\n", b);
}
A clean quit sends BYE from the stdin thread (linux:265-276, Windows: 259-270); a crashed peer, a router reboot, or a dead path becomes peer disconnected (no packets) after five seconds (linux:297-300, Windows: 291-294).
Scope, Security, and Edge Cases
That is also where the scope of the implementation becomes important. The data path is raw: the stdin thread reads a line and passes it directly to sendto through put (linux:60-61, Windows: 52-53). There is no key exchange, encryption, authentication, or cryptographic identity. UDP does not provide delivery guarantees, ordering, deduplication, congestion control, or retransmission here, so this is a direct UDP chat path with NAT traversal, not a file-transfer protocol. A real file-transfer layer would still need chunking, sequence numbers, acknowledgements, retransmission, integrity checks, flow control, and resume support.
The failure cases are part of the experiment too. The path can stop because both peers share one public IP, because a symmetric NAT refuses the mapping, because the requested local port was already taken, because DNS or outbound UDP 3478 is blocked, because a firewall stops the binary, or because the router drops the mapping mid-session. The point is not that UDP magically defeats NAT. The point is that with one stable socket, hand-built STUN discovery, optional router mapping, synchronized outbound packets, and explicit timeout behavior, you can see exactly how a direct path is created — and exactly why it fails.
Key Takeaways
Port forwarding creates the inbound rule manually.
UDP hole punching makes both peers create outbound mappings at roughly the same time.
There is no literal hole in the router. There is only NAT state that now considers the other peer related to traffic initiated from inside the network.

