Build a mesh VPN · lesson 09

Hole punching

Both nodes know each other's public endpoint. A pings B. A sends a HANDSHAKE_INIT to B's public address every half second for ten seconds, and B's NAT drops every one of them. B, with nothing to say, never sends anything. The first version of this lesson's code deadlocked exactly like that.

in progress
20 min interactive

Ten seconds of INITs into a wall

Here is the rule the design started with, and it reads sensibly: the initiator is whoever has a packet to send and no session. A has a staged ping. A sends HANDSHAKE_INIT to each of B's candidates. B's LAN address is unreachable. B's public address, 10.0.0.3:51820 in the lab, is behind a router that has never seen B talk to A's public address, so there is no row, and the router drops the packet. Every 500 ms, another INIT, another drop. B has no packet for A, so by the rule B is not an initiator, and B's router never gets an outbound packet toward A that would create the row. After ten seconds A gives up. The lab printed path none on both sides, and the ping timed out.

You already know why from the NAT traversal lesson: a hole is punched from the inside. Each NAT opens only for the destination its own host has sent to. For a packet from A to reach B, B must have sent to A first, and for B's packet to reach A, A must have sent to B. Both sides have to send. Which side "wants" the session is irrelevant to the routers.

💡
Mental model: probing is not a request, it is a key turning in your own lock. An INIT that is dropped by the far NAT still did its job on the near one. The reply that eventually gets through is not a reply to "the" INIT; it is a packet that found the row one of your earlier INITs left behind.

The fix: every node probes every peer it knows

path.c runs a small state machine per peer, driven from tick() on every pass of the poll() loop. The rule became: if there is no usable session with a peer, probe it, staged packet or not. When PEER lines arrive from coordd, both nodes start probing each other within one tick, each one's INITs opening its own NAT toward the other, and whichever INIT arrives second finds a row waiting. The cost is one 117-byte datagram per candidate per 500 ms for ten seconds, then one every five seconds, for every peer the node cannot reach. For a mesh of tens of nodes that is less traffic than a single idle SSH session.

c
// src/path.h #define PUNCH_INTERVAL_MS 500 #define PUNCH_TIMEOUT_MS 10000 #define PROBE_INTERVAL_MS 5000 #define KEEPALIVE_MS 25000 #define DEAD_PATH_MS 90000 // src/path.c // One INIT to every candidate at once. Simultaneous sends are the whole // trick of hole punching: each one creates a NAT mapping that lets the // peer's reply through. static void send_init_burst(struct node *n, struct peer *p, int64_t now) { uint8_t init[HANDSHAKE_LEN]; handshake_build_init(n, p, init); // fresh timestamp, same attempt for (int i = 0; i < p->n_candidates; i++) udp_send(n->udp_fd, &p->candidates[i], init, sizeof init); // … relayed copy of the INIT: lesson 10 p->last_probe_ms = now; p->last_tx_ms = now; } // No session: punch, then slow down to a background probe. static void tick_punching(struct node *n, struct peer *p, int64_t now) { if (p->n_candidates == 0 && !n->have_relay) return; // nowhere to send if (!p->hs.active) handshake_begin(p); // ephemeral key + index (lesson 06) if (p->punching_since_ms == 0) p->punching_since_ms = now; int64_t punching_for = now - p->punching_since_ms; int64_t interval = PUNCH_INTERVAL_MS; if (punching_for >= PUNCH_TIMEOUT_MS) { interval = PROBE_INTERVAL_MS; // … switch to the relay: lesson 10 } if (now - p->last_probe_ms >= interval) send_init_burst(n, p, now); } void path_tick(struct node *n, struct peer *p, int64_t now) { if (session_usable(&p->cur, now)) tick_established(n, p, now); else tick_punching(n, p, now); }

The elided lines are the relay fallback, in src/path.c and in lesson 10. Three things to notice. The burst goes to every candidate: the LAN address and the public one from lesson 08, at the same instant. Whichever is reachable will answer, and a wrong one costs a dropped datagram. The INIT is the real handshake message from lesson 06, not a special probe packet: the first thing that gets through is also the first half of a key exchange, so there is no second round trip to "connect" after the hole is open. And because both sides probe, both sides may send INITs at once; that is the simultaneous initiation case lesson 06 settled with the smaller-key-initiates tie-break. You already paid for this.

The first authenticated reply wins

What makes a candidate the endpoint? Not that you sent to it. Not that something came back from it. That an authenticated packet came from it: a RESP whose box opened under the peer's static key, or a DATA packet whose tag verified. The UDP handler in meshd.c calls path_on_authenticated only after handshake_handle_init, handshake_handle_resp or session_decrypt succeeded, and passes the from address the kernel reported.

c
// src/path.c void path_on_authenticated(struct node *n, struct peer *p, const struct endpoint *from, bool via_relay, int64_t now) { p->last_rx_ms = now; char ip[32], ep[32]; format_ip(p->overlay_ip, ip, sizeof ip); if (via_relay) { // … lesson 10 return; } // Roaming: a direct, authenticated packet from a new address moves the // endpoint. Safe only because the packet was authenticated — a forged // packet cannot redirect our traffic. if (!p->endpoint_ok || !endpoint_equal(&p->endpoint, from)) log_info("peer %s now at %s%s", ip, format_endpoint(from, ep, sizeof ep), p->via_relay ? " (leaving relay)" : ""); p->endpoint = *from; p->endpoint_ok = true; p->via_relay = false; (void)n; } // Established path: keepalives, dead detection (rekey and relay probes elided). static void tick_established(struct node *n, struct peer *p, int64_t now) { if (now - p->last_rx_ms > DEAD_PATH_MS) { char ip[32]; log_warn("peer %s silent for %d s, dropping path", format_ip(p->overlay_ip, ip, sizeof ip), DEAD_PATH_MS / 1000); drop_path(p); // sessions zeroed, endpoint_ok = false → tick_punching return; } if (now - p->last_tx_ms >= KEEPALIVE_MS) { uint8_t out[MAX_PKT]; size_t len = session_encrypt(&p->cur, NULL, 0, out, now); // empty DATA path_send(n, p, out, len, now); } // … rekey (lesson 06) and background direct probes while relayed (lesson 10) }

path_on_authenticated does two jobs with one assignment. During punching it selects: the first candidate to produce an authenticated packet becomes endpoint, and path_send uses only that from then on. After that it roams: if an authenticated packet arrives from an address that is not the current endpoint, the endpoint moves. A laptop that walks from wifi to a phone hotspot gets a new public ip:port; its first keepalive or data packet from the new address arrives at the peer, decrypts, and the peer's next packet goes to the new place. No signaling, no reconnect. This is exactly how WireGuard behaves, and it is why a WireGuard tunnel survives a network change without the user noticing.

🚫
Security hole if you move the endpoint on an unauthenticated packet. Anyone who can send one UDP datagram to your port could then forge a packet with a peer's receiver index and redirect all your traffic for that peer to themselves. They could not read it, but they could black-hole it. The only code path into path_on_authenticated runs after a successful decrypt or box-open, so a forged packet counts as bad_auth and changes nothing. Keep it that way.

Keeping the hole open, and noticing when it closes

A NAT row that carries no traffic is aged out, often after 30 s. If both sides are quiet, both rows vanish, and the next real packet starts from zero. So tick_established sends an empty DATA packet whenever nothing has been sent for KEEPALIVE_MS, 25 s. It is a real session packet with a real counter, so the receiver authenticates it, calls path_on_authenticated, and then writes nothing to tun0 because the plaintext is zero bytes. It refreshes both NAT rows and both last_rx_ms stamps for the price of 29 bytes.

The converse: if nothing authenticated has arrived for DEAD_PATH_MS, 90 s, the path is declared dead. Three keepalive intervals and change. drop_path zeroes both sessions, clears endpoint_ok, and the next tick is back in tick_punching with the 500 ms burst, because whatever broke the path may have changed the addresses too. Nothing here consults the peer; it is a local decision based on local clocks, which is the only kind a node can make about a path.

You saw this sequence in the NAT traversal lesson with a "punch" string as the probe. Step through it again; now the probe is a 117-byte HANDSHAKE_INIT, the packet that counts as "connected" is an authenticated RESP, and the 25-second keepalive is an empty DATA packet.

hole punching — now with INITs for probes
Click next to start the walkthrough.

Timers without a timer thread

None of this uses sleep, alarm or a second thread. path_next_deadline computes, for one peer, the earliest moment it will need attention: the next burst, the next keepalive, the dead-path cutoff, or the rekey. next_timeout in meshd.c takes the minimum over every peer and the node's own timers and hands it to poll() as the timeout. The loop wakes exactly when something is due or when an fd is readable, whichever is first, then runs tick() for everyone. This is the event-loop lesson applied with a dozen timers instead of one.

⚠️
Common mistake: a lab NAT that is too polite. A plain MASQUERADE rule rewrites outbound traffic but lets unsolicited inbound packets reach the NAT box itself, where they occupy a conntrack entry on the very port the real flow wanted. The result is hole punching that fails for reasons that have nothing to do with your code. test/netns-up.sh therefore adds -m conntrack --ctstate NEW -j DROP on each NAT's external interface, which is what a home router does. If you build your own lab, do the same, or you will debug a ghost.

Run it

The lab from test/netns-up.sh: node A in its own namespace behind natA, node B behind natB, both NATs masquerading onto a bridge in the cloud namespace where coordd listens on 10.0.0.1. A and B cannot see each other's LAN addresses at all. On a Linux host run it as root; on a Mac, in the Docker image from test/Dockerfile. Leave --relay off this time: there is no relay yet, and you want to see the direct path win on its own.

sh
# on macOS: docker build -t mesh-lab test/ && docker run --rm -it --privileged -v "$PWD":/work -w /work mesh-lab bash make ./test/netns-up.sh ./meshd genkey /tmp/A.key; ./meshd genkey /tmp/B.key ip netns exec cloud ./coordd --listen 10.0.0.1:7000 --udp 10.0.0.1:7001 --authkey lab-secret --db /tmp/nodes.db & MESHD_SOCK=/tmp/A.sock ip netns exec A ./meshd up --key /tmp/A.key --coord 10.0.0.1:7000 --authkey lab-secret & MESHD_SOCK=/tmp/B.sock ip netns exec B ./meshd up --key /tmp/B.key --coord 10.0.0.1:7000 --authkey lab-secret & ip netns exec A ping -c 3 100.64.0.2 MESHD_SOCK=/tmp/A.sock ./meshd status # or let the test do all of it (section 1 is this lesson): ./test/run.sh ./test/netns-down.sh
sh
# meshd A's log: STUN, join, peer learned, probing, then the first authenticated packet 13:02:10.411 info stun: public endpoint 10.0.0.2:51820 13:02:10.415 info control: assigned overlay address 100.64.0.1 13:02:12.907 info control: peer 100.64.0.2 added (2 candidates, 1 allowed) 13:02:13.420 info peer 100.64.0.2 now at 10.0.0.3:51820 13:02:13.421 info session with 100.64.0.2 established (responder, direct) # ping from A's namespace, through natA, the bridge and natB 64 bytes from 100.64.0.2: icmp_seq=1 ttl=64 time=1.03 ms 64 bytes from 100.64.0.2: icmp_seq=2 ttl=64 time=0.41 ms 64 bytes from 100.64.0.2: icmp_seq=3 ttl=64 time=0.39 ms # meshd status on A node 100.64.0.1 pub mK3f9Qx2…Yk= udp 51820 public 10.0.0.2:51820 control connected peers 1 peer 100.64.0.2 Zr8pLw1T…Qc= path direct endpoint 10.0.0.3:51820 session 6s tx 3 rx 3 last_rx 0s allowed 100.64.0.2/32 candidates 10.0.2.2:51820,10.0.0.3:51820 drops no_peer 0 bad_src 0 bad_auth 0 no_session 0

Read the status line slowly. candidates lists B's LAN address and B's public address; endpoint is the public one, because the LAN one never answered. path direct means no third machine is carrying this traffic. session 6s with no_session 0 says the handshake finished before you typed ping: the probing started the moment the PEER line arrived, not when traffic did. Type the ping within that first second and the first echo request is staged instead, and no_session reads 1. A's log says responder: both probed, B's INIT found A's row first, and A answered. Run it again and it may say initiator. Either way, it took under a second, through two routers that each refuse unsolicited packets.

Now set --random-fully on natB's masquerade rule, as test/run.sh does in section 3, and run it again. The public port B reports to coordd is not the port natB uses toward A, A's INITs go to a port with no row, and path stays none. That is a symmetric NAT, and it is the next lesson.

one-line takeaway

Every node sends an INIT to every candidate of every peer it cannot yet reach, because a NAT opens only from the inside; the first authenticated reply fixes the endpoint, keepalives hold the row, and an authenticated packet from a new address moves it.