P2P Tunnels in Rust: The Peer-to-Peer Tunnel Architecture Without an Exit Node
How peer-to-peer tunnels actually work without a third-party exit node — NAT types, simultaneous-open hole-punching, and how rustunnel's peer-to-peer tunnel in Rust keeps the relay out of the data path entirely. A complete explainer for engineers who've heard 'P2P tunnel' a hundred times but never seen it drawn.
Most "P2P tunnel" tools aren't actually P2P. The data still flows through someone else's relay — they just rebrand the relay as a "rendezvous server" or "STUN coordinator" and call it a day. rustunnel's peer-to-peer tunnel is built in Rust and is the real thing: once both peers connect to the relay long enough to exchange addresses, the relay drops out and the two peers talk directly. No exit node. No middlebox reading your bytes. This post is the 30-minute explainer for engineers who keep hearing "P2P tunnel" without ever seeing the wire actually drawn.
The lie of "P2P" tunnels
Tailscale Funnel, ngrok edge, cloudflared, frp's HTTP mode — all of them route data through a relay. Tailscale is upfront about it; the others bury it in marketing.
The thing that makes a tunnel P2P is whether the data path routes peer-to-peer after handshake, not whether the control plane needs a relay. (It almost always does — that's how the peers find each other.)
Relay-only tunnel (ngrok, cloudflared, rustunnel default):
Browser ── HTTPS ──▶ Relay ── HTTPS ──▶ Your laptop
(data flows through relay forever)
True P2P tunnel (rustunnel P2P mode, Tailscale's direct path, WebRTC):
Phase 1 — Handshake (relay-mediated):
Browser ── ?──▶ Relay ◀──? Your laptop
(peers exchange UDP candidates via relay)
Phase 2 — Direct (relay drops out):
Browser ──── UDP ────▶ Your laptop
(relay sees zero packets after this)
Why "without an exit node" is the harder problem
If you allow an exit node (Tailscale's term, or a TURN server in WebRTC parlance), the problem is easy: when direct fails, fall back to relay. That's a TURN server. rustunnel's relay can act as TURN — but the whole point of P2P mode is to avoid bandwidth bills on the relay for high-traffic peers (game session screen-sharing, big file transfers, video).
So we want to maximise the chance that the direct path actually works. That means NAT traversal.
NAT traversal, 5 minutes
Home routers and carrier networks sit your machine behind a network address translator (NAT). The NAT translates between your private 192.168.x.x address and the single public IP visible to the internet. There are four NAT behaviours that matter for P2P, and they're not equal.
Full-cone NAT is the friendliest. Once your machine sends a packet from internal port P through the NAT, the NAT punches a stable mapping: external port E → P. Any host on the internet can now send a packet to your external IP:E and it will reach you. STUN is all you need here — it tells peer B your public IP:E, and B's packets land directly.
Restricted-cone NAT is slightly stricter. The NAT only lets inbound packets through if your machine has previously sent a packet to that source IP. Peer B can't reach you until your machine has sent something toward B's IP. The fix is simple: both peers fire simultaneously. Peer A sends toward B's IP, B sends toward A's IP — both NATs see outbound traffic to the peer's IP and open the gate for inbound packets from that IP. This is called simultaneous-open hole-punching, and it's the core technique behind WebRTC, Tailscale, and rustunnel's P2P mode.
Port-restricted cone NAT tightens the restriction further: inbound packets must come from both the correct IP and the correct port your machine already sent to. Simultaneous-open still works, but the timing matters more — both peers must fire at each other's exact public IP:port pair (as reported by STUN), and neither side can start before both peers have their STUN-discovered addresses.
Symmetric NAT breaks the model entirely. With symmetric NAT, the external port the NAT assigns to a flow changes for every new destination. When your machine sends to the STUN server at stun.example.com:3478, the NAT picks external port E_stun. When it then sends to peer B at B_ip:B_port, it assigns a completely different external port, E_b. Peer B learned E_stun from the STUN exchange — but the hole-punch packets B sends will arrive at E_b, which it never knew. There's no reliable way to predict E_b without port-scanning, and port-scanning is slow, often rate-limited, and not guaranteed to work. The only reliable solution is a TURN relay sitting in the data path.
Where does symmetric NAT live in the wild? Mostly at mobile carriers. Carrier-grade NAT (CGNAT) — the NAT layer stacked between your phone and the public internet — is almost always symmetric or uses per-session port allocation. Home broadband routers sold since 2020 are mostly full-cone or restricted-cone. The practical result:
- Desktop-to-desktop (home routers on both sides): hole-punch succeeds roughly 85% of the time
- Mobile-to-residential (CGNAT on one side): success rate drops to roughly 50%
- Mobile-to-mobile (CGNAT on both sides): near-zero without a relay
The timing of simultaneous-open also matters. Both peers must fire their UDP packets at each other within a window of a few hundred milliseconds. If peer A fires first and its packet hits B's NAT before B has sent anything (so no outbound mapping exists on B's NAT for A's IP), B's NAT drops A's packet. When B fires a moment later, B's packet clears A's NAT because A's outbound mapping is already there. The relay's job is to synchronise the "fire" signal — both peers punch at the same moment, which is why the relay's signalling channel must stay open until hole-punch is confirmed.
STUN — what the relay actually does in P2P mode
Peer A rustunnel relay Peer B
│ │ │
│── STUN BINDING ─────────▶│ │
│◀───── 203.0.113.5:54321 ─│ (your public addr/port) │
│ │ │
│ │◀──── STUN BINDING ───────│
│ │── 198.51.100.7:38291 ───▶│
│ │ │
│ │── A: 203.0.113.5:54321 ─▶│
│ │── B: 198.51.100.7:38291 ▶│ (forwarded
│ │ │ addresses)
│ │ │
│── UDP punch ─────────────────────────────────────▶ │
│◀────────────────────── UDP punch ───────────────────│
│ │
│═══════ DIRECT PEER-TO-PEER FROM HERE ═══════════════│
The relay's job is exactly two things: (1) tell each peer their public IP:port as seen from the internet, and (2) forward each peer's address to the other so they can fire simultaneously. Once both peers have started sending UDP to each other's public address, the NAT mappings open and the path is direct. The relay sees zero data packets after this.
How rustunnel implements it
Control plane: the existing :4040 WebSocket. Every rustunnel client already holds a persistent WebSocket connection to the relay on port 4040 for control signalling — this is the same channel used for regular relay tunnels. When peer A runs rustunnel p2p ./shared, a CreateP2PTunnel message travels over that WebSocket. The relay mints a join code and responds. Peer B connects with rustunnel p2p-join <code> — the relay looks up the session and bridges the two WebSocket channels so A and B can exchange STUN candidates over the control plane, with no third-party signalling server required.
STUN built into the relay listener. The relay already knows the public IP:port of every connected peer — this is just the OS-level source address from the TCP accept on :4040. When a peer connects, the relay records its observed public endpoint. When the P2P handshake starts, the relay tells each peer "your public address as I see it is IP:port" and forwards each peer's observed address to the other. No external STUN server (stun.l.google.com, etc.) is needed because the relay is the STUN server.
Data plane: QUIC over UDP. After hole-punching succeeds, both peers establish a QUIC connection over the direct UDP path. QUIC gives the P2P data path properties that raw UDP or TCP-in-UDP can't match:
- Multiplexed streams over a single UDP 5-tuple, without head-of-line blocking. Multiple concurrent file transfers or channels share the hole-punched path without opening additional NAT mappings.
- Congestion control (BBR by default). A high-bandwidth file transfer adapts to the path's capacity rather than hammering a home network into packet-drop chaos.
- Connection migration. If your public IP:port changes mid-session — your phone switches from Wi-Fi to LTE, or a CGNAT rebinds your port — QUIC's connection ID lets the session survive without renegotiation, as long as both peers remain reachable. The relay plays no role in migration; it's handled entirely on the direct path.
Fallback: --p2p-attempt-timeout (default 8s). The timer starts when both peers fire their first hole-punch packets. If a QUIC connection isn't established within the timeout window, rustunnel falls back to relay mode silently — the user sees a slightly higher RTT but no error. You can lower the timeout (e.g. --p2p-attempt-timeout 3s) if your use case favors fast fallback over maximising P2P hit rate, or raise it (e.g. --p2p-attempt-timeout 20s) for stubborn CGNAT environments where hole-punch needs more attempts.
See docs/reference/p2p-tunnels for the full CLI surface and TOML config.
What you actually run
# Peer A — laptop wanting to share a folder
rustunnel p2p ./shared --token <token>
# → Join code: rt-p2p-2A91-7XKP-Q4L9
# Peer B — collaborator on the other side of the world
rustunnel p2p-join rt-p2p-2A91-7XKP-Q4L9 --token <token>Output on B:
✓ STUN: my public address is 198.51.100.7:38291
✓ Peer A is at 203.0.113.5:54321
✓ UDP hole-punch: succeeded (RTT 47ms)
✓ Mounted ./shared at /tmp/p2p-rt-p2p-2A91-7XKP-Q4L9
Now /tmp/p2p-rt-p2p-2A91-7XKP-Q4L9 on B is a FUSE mount of A's ./shared. Bytes flow direct A to B; the relay sees zero traffic after the handshake.
When P2P doesn't work — and what we do about it
rustunnel always tries P2P first. It falls back to relay when the direct path can't be established. The four common failure modes:
Symmetric NAT on either side. The most common cause of failure. Each new destination gets a different external port, so the port peer B learned from STUN is wrong by the time hole-punch fires. rustunnel detects this quickly: if the UDP packets received after hole-punch attempts carry mismatched source ports, the NAT is almost certainly symmetric. The fallback timer activates immediately rather than waiting the full timeout.
Mixed IPv4/IPv6. If peer A is IPv6-only and peer B is IPv4-only, there is no shared address family to punch through. IPv6 is actually better for P2P — most IPv6 deployments carry no NAT at all, so the direct path works without any hole-punching — but mixed stacks require the relay to act as a protocol translator.
Strict enterprise firewalls. Corporate networks that block all outbound UDP except DNS and QUIC-on-443 prevent hole-punch from ever receiving a response. The 8-second fallback timer catches this. Checking with rustunnel debug nat-type before relying on P2P in an enterprise environment will save that 8-second wait.
Stacked CGNAT. Mobile-to-mobile connections where both peers sit behind CGNAT are effectively always symmetric, compounded. The hit rate is near zero without relay assistance.
When fallback activates, the relay acts as a TURN server — it sits in the data path and relays QUIC/UDP packets between the two peers. This relay traffic is billed at $0.10/GB (the same rate as regular relay tunnel traffic — see pay-as-you-go pricing).
The dashboard shows a small banner in the active tunnel panel:
P2P unavailable, falling back to relay (billed at $0.10/GB)
This banner stays visible as long as the tunnel is up so you can always see whether you're on the free direct path or the metered relay path. You can also query path mode from the CLI:
rustunnel p2p status rt-p2p-2A91-7XKP-Q4L9
# → path: relay (symmetric NAT detected on peer B)
# → relay_bytes_transferred: 142 MBCaveats
P2P setup adds latency before the first byte. Hole-punching plus the QUIC handshake take 200–500 ms on a good path. The default 8-second fallback window means a worst-case 8-second delay before data flows via relay. For quick one-off operations — webhook tests, short-lived port shares — the regular relay tunnel connects faster end-to-end. P2P pays off when you're moving a lot of data or need sustained low latency.
No HTTPS URL. A P2P tunnel is point-to-point between two clients — there's no relay on a public IP to terminate TLS and mint an https://xyz.rustunnel.com subdomain. If the remote end is a browser or a webhook sender that needs an HTTPS URL, use a regular relay tunnel. The quickstart covers the one-command relay setup.
Both peers need UDP egress. Most home routers and developer laptops have outbound UDP open. Strict enterprise networks that permit only TCP-443 will fall back to relay every time. The rustunnel debug nat-type subcommand probes your NAT type and reports whether you're likely to succeed at hole-punching before you commit a workflow to P2P mode. The full compatibility matrix and NAT-type detection logic are documented in the architecture reference.
P2P only makes economic sense for high-volume or latency-sensitive flows. For anything under roughly 100 MB, relay traffic costs about $0.01 — the NAT-traversal complexity isn't worth it. File syncs, screen shares, game server traffic, and live video streams are the right workloads. Ephemeral webhook development is not.
How this compares
| Tool | True P2P data path? | Hole-punch hit rate | Fallback to relay |
|---|---|---|---|
| Tailscale | yes (DERP fallback) | ~85% (their own number) | yes (DERP) |
| WireGuard | yes (no relay at all) | NAT problem is yours | no |
frp xtcp | yes | varies | no |
| ngrok | no | n/a | n/a |
rustunnel p2p | yes | ~85% (desktop-to-desktop) | yes (relay-as-TURN) |
Next steps
- Read the P2P reference — full CLI surface and TOML config.
- Self-host the relay to keep the STUN coordinator on infrastructure you own.
- File NAT-traversal bugs — we maintain a corpus of known-tricky CGNAT configurations.