This is a continuation of my previous post. The goal of this part is to understand in a simple way how Tailscale works behind the scenes on Windows using a L3 adapter called Wintun.

How Tailscale Peer-to-Peer Works#

Majority of Tailscale’s code is open source, except the DERP server which acts as a relay for peers when direct connection fails.

Since Tailscale’s codebase is pretty large, I won’t go deep into all their services. But if you want to understand how peer-to-peer works from scratch and eventually build your own, there are some good resources out there. I recommend starting with these two papers:

What is Peer-to-Peer in This Context?#

Peer-to-peer here simply means two devices behind NAT establishing a direct UDP connection by both registering with a STUN server. This technique is called UDP hole punching.

The basic flow is:

  1. Both peers connect to the STUN server
  2. The server records each peer’s public IP:port
  3. The server shares each peer’s endpoint with the other
  4. Both peers start sending UDP packets directly to each other simultaneously

To keep the UDP hole alive, peers must send keepalive packets periodically. In Tailscale this is handled by Magicsock, which sends keepalives every 3 seconds using small discovery packets — also called magic packets. Without these, the NAT mapping expires and the tunnel dies silently.

The Source Port Problem#

One important thing I noticed while building this: both peers must maintain the same source port for proper NAT mapping. The flow looks like this:

Device → Router → STUN Server

What I observed is that on a small SOHO router like Mikrotik, when your device uses source port 6666, the router preserves that same port in its NAT table when the packet reaches the STUN server. So the public endpoint the server records matches the actual port your device is using.

However, the problem I ran into is that when two peers behind different NATs reconnect, sometimes their source ports change between sessions. When this happens, one peer is still trying to reach the old registerd publicIP:port of the other, which breaks connectivity. This is why binding to a fixed source port and never closing the socket is critical, the NAT mapping must stay consistent across restarts.

How the Tunnel Works on Windows with Wintun#

On Windows I used Wintun to create a virtual L3 TUN adapter. The packet flow looks like this:

write(TX): App → TUN adapter → UDP encapsulation → IP → NIC → (wire) → NIC → IP → UDP → TUN adapter → App

When your application sends a packet, it goes into the TUN adapter as a raw IP packet. Your code reads it, wraps it inside a UDP packet, and sends it to the peer. On the receiving side, the UDP payload (which is the raw IP packet) is injected back into the TUN adapter and the OS delivers it to the application as if it came from a normal network interface.

MTU Calculation#

Since you are encapsulating IP packets inside UDP, you need to account for the extra headers:

Header Size
Outer IP header 20 bytes
Outer UDP header 8 bytes
Total overhead 28 bytes

So your TUN MTU should be:

TUN MTU = Physical MTU - overhead
TUN MTU = 1500 - 28 = 1472 bytes

In my code I set it to 1400 to give a safe margin. If you don’t do this, packets will either get fragmented or dropped silently, which is hard to debug.

The Code#

The code I implemented has two files — server.go and peer.go.

  • On one Windows PC set the TUN IP to 100.64.1.1
  • On the other set it to 100.64.1.2

These are CGNAT addresses from the 100.64.0.0/10 range defined in RFC 6598, the same range Tailscale uses. The advantage is these IPs don’t clash with typical private address ranges like 192.168.x.x or 10.x.x.x.

I wrote this by referencing:

Known Limitations#

Same public IP (Hairpin NAT): P2P does not work when both peers are behind the same public IP. This is the hairpin NAT problem. If you’re on a Mikrotik router, you can solve this by configuring hairpin NAT rules directly on the router.

No TURN relay: I haven’t implemented a TURN server. TURN is a fallback where all traffic flows through a relay server when direct connection is impossible. For now if hole punching fails, there’s no fallback.


:wq for now until next time.

References#

Papers#

Tailscale Source Code#

WireGuard#

Libraries and Tools#

Wiki#

My Code#