Code lab 02 · 15 min

A guided read of magicsock

A file by file tour of wgengine/magicsock, the packet conn that hides path selection from WireGuard, with the disco, DERP, and peer relay machinery traced through current source.

Position in the code lab track 01 Reading Go for i. 02 A guided read of. 03 The pprof field . 04 The packet captu.

What magicsock is, in one sentence

The package comment at the top of wgengine/magicsock/magicsock.go says it plainly: magicsock “implements a socket that can change its communication path while in use, actively searching for the best way to communicate” (magicsock-go). Everything else in this module is elaboration on that sentence.

Here is the trick that makes Tailscale feel like magic. WireGuard, as a protocol and as the wireguard-go implementation Tailscale embeds, believes each peer lives at exactly one UDP address. It has no concept of “this peer might be reachable at five addresses, let’s race them.” Tailscale wants exactly that racing behavior, described end to end in the NAT traversal blog post: probe every candidate path, fall back to a relay instantly, upgrade to direct when a hole punch lands (nat-blog). The resolution is that magicsock implements wireguard-go’s conn.Bind interface, the abstraction wireguard-go uses to send and receive UDP. WireGuard hands magicsock a packet addressed to what it believes is a single stable endpoint, and magicsock decides, per send, whether those bytes leave via an IPv4 socket, an IPv6 socket, a DERP relay connection, or (since late 2025) a Geneve encapsulated peer relay path. Path selection is invisible to WireGuard by construction.

This guide walks the package as it exists on the main branch checked 2026-08-10. The file layout matters because it has drifted from what older writeups describe. The two canonical blog posts predate the current tree by years: how-blog is dated 2020-03-20 and nat-blog 2020-08-21, and neither one names a single source file or even uses the word magicsock. Meanwhile the code that once lived in one giant magicsock.go was broken apart in a run of commits on 2023-07-26 titled “factor out endpoint into its own file,” “factor out peerMap into separate file,” and “factor out more separable parts.” Today it reads as magicsock.go (the Conn, send and receive paths, disco dispatch), endpoint.go (per peer state and path selection), derp.go (relay integration), peermap.go (index structures), and relaymanager.go (peer relay path discovery, first landed in May 2025 ahead of the Peer Relays beta) (magicsock-pkg). If you are cross reading an old blog post against the source, expect names and locations to have moved; the mechanisms survived, the file boundaries and line numbers did not.

The Conn: one socket, many paths

Conn is the star. The comment above it reads “A Conn routes UDP packets and actively manages a list of its endpoints” (magicsock-go). Skim the struct definition and you can reconstruct the whole architecture from field names alone:

The conn.Bind surface is implemented by a small wrapper type connBind, and Conn.Send carries the comment “Send implements conn.Bind” (magicsock-go). On the receive side, connBind registers multiple receive functions with wireguard-go: the IPv4 socket, the IPv6 socket, and receiveDERP, which drains a channel fed by DERP reader goroutines (magicsock-go, derp-go). To wireguard-go these are all just packet sources; it neither knows nor cares that one of them is a TCP stream to a relay.

Where magicsock sits: one Bind above, many paths below wireguard-go device one peer = one conn.Endpoint (a fiction) conn.Bind: Send / receive funcs magicsock.Conn peerMap endpoint (per peer) netcheck + STUN relayManager disco: ping / pong / call-me-maybe (never shown to WireGuard) pconn4 / pconn6 direct UDP sockets activeDerp DERP over HTTPS/TCP peer relay UDP + Geneve VNI node-a picks among these per send; wireguard-go never sees the choice

The endpoint type: one peer, many candidate addresses

Move to endpoint.go. The doc comment on endpoint states the core inversion: “In wireguard-go and kernel WireGuard there is only one endpoint for a peer, but in Tailscale we distribute a number of possible endpoints for a peer” (endpoint-go). One endpoint object exists per peer, and it is simultaneously the conn.Endpoint handed to wireguard-go and the state machine that races paths. Its important fields, from current source:

The decision every data packet flows through is addrForSendLocked. Its logic, compressed from source: if bestAddr is set and trustBestAddrUntil has not passed, return the direct address alone. If the peer is WireGuard only, pick the lowest latency known candidate. Otherwise return both the (expired or missing) UDP address and derpAddr, meaning: send via DERP so the packet definitely arrives, optionally also via the stale direct path, and let discovery repair things (endpoint-go). One layer up, endpoint.send adds the kicker: whenever the chosen path is not a trusted direct one, it calls sendDiscoPingsLocked and, if the peer is relay capable, starts peer relay path discovery (endpoint-go). Discovery is not a background daemon that happens to exist; it is triggered by the very act of sending through a bad path. Traffic heals itself.

Per packet path choice in endpoint.addrForSendLocked and endpoint.send WireGuard calls Send bestAddr set AND now before trustBestAddrUntil ? yes send direct only (UDP, maybe via peer relay) no peer is WireGuard only ? yes pick lowest latency candidate, short one second trust window no send to derpAddr (always lands) plus stale bestAddr if any packet loss avoided, latency worse side effect in send: fire disco pings to all candidates + start peer relay discovery

Disco: how pings and pongs elect a path

Disco is Tailscale’s discovery protocol, a small message family living in the disco package. Three of its messages carry the classic path discovery: Ping, Pong, and CallMeMaybe. Current source defines nine message types in total, the other six (BindUDPRelayEndpoint, BindUDPRelayEndpointChallenge, BindUDPRelayEndpointAnswer, CallMeMaybeVia, AllocateUDPRelayEndpointRequest, AllocateUDPRelayEndpointResponse) all belonging to the peer relay machinery, so a writeup that lists only three is describing the pre relay world (disco-go). Disco messages ride the same UDP sockets and DERP connections as data, but they are consumed inside magicsock and never surface to wireguard-go. In receiveIP, every incoming datagram is classified by packetLooksLike: disco packets route to handleDiscoMessage, STUN responses go to netcheck, and everything else is presumed WireGuard and passed up (magicsock-go).

The election cycle works like this, matching what the NAT traversal post describes at design level (nat-blog) but with the current function names:

  1. Sending through an untrusted path triggers sendDiscoPingsLocked, which pings every plausible candidate in endpointState and records each ping in sentPing keyed by transaction ID (endpoint-go).
  2. If that round actually sent at least one ping and the peer has a DERP home, the same function queues a CallMeMaybe through DERP, the “I am about to ping you, ping me back at these addresses” message. The source states its purpose directly: inform the peer “that we’ve sent so our firewall ports are probably open and now would be a good time for them to connect.” It is not gated on whether the pair has ever talked directly before (endpoint-go). handleCallMeMaybe on the receiving side merges the advertised endpoints into endpointState and zeroes their lastPing times “to force sendPingsLocked to send new ones” (endpoint-go). This mutual, near simultaneous pinging is the hole punch: both NATs see outbound traffic first and therefore allow the inbound reply (nat-blog).
  3. A peer receiving a ping answers in Conn.handlePingLocked with a Pong echoing the transaction ID and, crucially, the source ip:port it observed, and it opportunistically records the sender’s address mapping in peerMap (magicsock-go).
  4. The original sender’s handlePongConnLocked matches the pong to its sentPing, computes latency, and then decides promotion: the pong’s path becomes bestAddr if betterAddr says it wins, or unconditionally if the current best is untrusted. When the pong confirms the existing best path, trustBestAddrUntil is refreshed (endpoint-go).

betterAddr is worth reading in full because it encodes Tailscale’s path taste as arithmetic. Direct paths always beat peer relay paths (any address with a Geneve VNI set loses to one without). Then latency is scored as a percentage advantage, with bonus points layered on: loopback addresses get 50 points, link local 30, private addresses 20 (cheaper and more local than public), and IPv6 a 10 point nudge (endpoint-go).

Once a direct path is elected, a heartbeat keeps it warm: every heartbeatInterval (3 seconds) endpoint.heartbeat pings the current preferred path, and each pong on that same address slides trustBestAddrUntil forward by trustUDPAddrDuration (6.5 seconds). The heartbeat also retries the full candidate set, but wantFullPingLocked suppresses that once the current path is at or under goodEnoughLatency (5 milliseconds), and otherwise rate limits it to upgradeUDPDirectInterval (1 minute); below the good enough line, magicsock stops shopping. After sessionActiveTimeout (45 seconds) with no externally triggered send the heartbeat stops and the peering goes quiet (endpoint-go, magicsock-go: the four constants all live in magicsock.go, the logic in endpoint.go).

DERP inside magicsock

The DERP design story is in the blog: relays “blindly forward already-encrypted traffic,” private keys never leave the nodes, and the control plane stays out of the data path entirely (how-blog). Operationally, DERP servers are both the fallback data path and the signaling channel through which disco bootstraps direct connections (derp-kb, nat-blog). The implementation is derp.go.

The addressing trick is the part to internalize: a DERP destination is encoded as an ordinary netip.AddrPort whose IP is tailcfg.DerpMagicIPAddr and whose port is the region ID (derp-go). That is how one code path can hold “this peer’s fallback is DERP region 17” in the same field shape as “this peer is at an ip:port,” and why isDERP := addr.Addr() == tailcfg.DerpMagicIPAddr checks appear throughout magicsock.go. The magic IP never touches a wire; Conn.sendAddr intercepts it and hands the packet to derpWriteChanForRegion instead of a UDP socket.

Each active region connection gets two goroutines: runDerpReader, which receives frames and forwards them as derpReadResult values into derpRecvCh, and runDerpWriter, which drains a write channel into the HTTPS/TCP connection (derp-go). On the receive side, connBind.receiveDERP is registered with wireguard-go as just another receive function; processDERPReadResult labels each incoming frame with a synthetic source of DERP magic IP plus region, looks up the sending peer by the node key the relay authenticated, and hands the still encrypted WireGuard payload up the stack (derp-go). WireGuard decrypts as usual; end to end secrecy never depended on the relay.

Two refinements matter for field debugging. First, Conn.myDerp is your home region, chosen by netcheck latency measurements in maybeSetNearestDERP, and you keep a persistent connection to it so peers can always reach you there. Second, derpRoute records that a given peer recently reached us via some other region’s connection; fallbackDERPRegionForPeer uses it as a last resort so that even a peer whose netmap entry lacks endpoints can be answered over the DERP path they used to reach us (derp-go, magicsock-go). You can see that last ditch logic directly in endpoint.send: no UDP address and no DERP home means try fallbackDERPRegionForPeer before giving up with errNoUDPOrDERP (endpoint-go).

Peer relays and the relayManager (2025 and later)

Everything above existed in some form for years. The newest organ in magicsock is the peer relay path, announced as a public beta on 2025-10-29: Customer deployed relay nodes built into the ordinary Tailscale client, preferred above DERP but below direct, with measured throughput the announcement puts at “often multiple orders of magnitude higher than Tailscale’s managed DERP fleet” (peer-relays-blog, derp-kb). In the source, this is relaymanager.go plus threading through endpoint.go, present and active in the code as of the 2026-08-10 checkout; older descriptions of magicsock predate it entirely.

The relayManager doc comment defines its job: it “manages allocation, handshaking, and initial probing (disco ping/pong)” of relay server endpoints, running everything in a single runLoop goroutine fed by channels so it can be safely invoked while Conn.mu or endpoint.mu are held (relaymanager-go). Peer relay packets are ordinary UDP prefixed with an 8 byte Geneve header (RFC 8926) carrying a 3 byte virtual network identifier; that is why the address type throughout modern magicsock is epAddr, a netip.AddrPort plus optional VNI (endpoint-go). receiveIP decodes and strips the Geneve header before handing payloads to wireguard-go. Disco messages that arrive Geneve encapsulated get split two ways: handlePingLocked bails out early on any ping whose VNI is set and hands it to the relayManager, which is “always responsible for handling (replying) to Geneve-encapsulated [disco.Ping] messages,” while a Geneve encapsulated pong is first offered to the endpoints and only forwarded to the relayManager if no endpoint recognizes its transaction ID (magicsock-go).

Path discovery over relays deliberately reuses the disco election: the relayManager allocates a session on a candidate relay, handshakes, then probes it with disco pings, and only when a pong proves the path does endpoint.udpRelayEndpointReady consider installing it as bestAddr (endpoint-go, relaymanager-go). Because betterAddr ranks any VNI bearing path below any direct path, a working peer relay never blocks a later direct upgrade. Relay epAddr values are also kept out of endpointState on purpose: they are either the current best address or forgotten, with peerMap.relayEpAddrByNodeKey capping bookkeeping at one relay address per peer (endpoint-go, peermap-go).

Where “direct” versus “relay” is actually decided

When someone runs tailscale status and asks why a peer shows a relay code instead of an address, the answer is computed in one small function: endpoint.populatePeerStatus in endpoint.go. Read it and the semantics of the status line stop being folklore (endpoint-go):

So “relayed” in status is not a stored flag anywhere. It is the live output of the same per packet decision function the data path uses: no trusted bestAddr right now means sends are going to DERP, and status shows only the relay. The status line is a window into addrForSendLocked.

A reading order that works

The package is about 17,700 lines including tests (roughly 11,000 without them, counted on the 2026-08-10 checkout), so read it with a goal. This sequence front loads the concepts each later file assumes:

  1. magicsock.go: package comment, Conn struct, then Send and receiveIP. You now know the boundary with wireguard-go and the packet classification on receive (magicsock-go).
  2. endpoint.go: the endpoint struct comment, addrForSendLocked, send, then handlePongConnLocked and betterAddr, then the constants block back in magicsock.go (trustUDPAddrDuration, heartbeatInterval, goodEnoughLatency, sessionActiveTimeout). Those four functions come to roughly 300 lines between them, and they are the entire path election (endpoint-go, magicsock-go).
  3. derp.go: derpWriteChanForRegion, runDerpReader, processDERPReadResult, and the DERP magic IP encoding (derp-go).
  4. peermap.go: small, and it explains every c.peerMap.endpointFor... lookup you saw earlier (peermap-go).
  5. relaymanager.go: read the type comment and runLoop channel list first; the state maps make sense once you accept the single goroutine ownership rule (relaymanager-go).
  6. The disco package: disco.go is one file of message marshaling (the package holds only it, a fuzzer, a pcap helper, and tests), ten minutes, and wire captures become readable (disco-go).

A concrete lab to cement it: bring up two machines, lab-vm-1 behind a deliberately strict NAT and cloud-1 with an open firewall, run traffic, and watch the client logs for the disco lines while toggling the firewall. You will see the exact sequence this guide traced: DERP first, call-me-maybe, ping storms, a now using promotion, and, if you break the direct path, a quiet fall back to the relay within one trust window.

Cross references

Sources

  1. tailscale/tailscale wgengine/magicsock package checked 2026-08-10
  2. wgengine/magicsock/magicsock.go checked 2026-08-10
  3. wgengine/magicsock/endpoint.go checked 2026-08-10
  4. wgengine/magicsock/derp.go checked 2026-08-10
  5. wgengine/magicsock/relaymanager.go checked 2026-08-10
  6. wgengine/magicsock/peermap.go checked 2026-08-10
  7. tailscale.com/disco package source checked 2026-08-10
  8. How NAT traversal works (Tailscale blog) checked 2026-08-10
  9. How Tailscale works (Tailscale blog) checked 2026-08-10
  10. Introducing Tailscale Peer Relays (Tailscale blog, 2025-10-29) checked 2026-08-10
  11. DERP servers (Tailscale KB) checked 2026-08-10

All code lab guides