wireguard-go — Architecture#
Architectural style#
Layered Protocol Engine with Interface-Isolated Platform Adapters
wireguard-go is a single-binary network daemon (also embeddable as a library) organized as a strict layered stack around a central protocol engine. The device package is the monolithic core: it owns all protocol state, all goroutines, and all data-path logic. Two thin abstraction layers — tun.Device (kernel network interface) and conn.Bind (UDP socket) — insulate the engine from platform specifics. Configuration flows in via the UAPI protocol (ipc + device/uapi.go).
The design is emphatically not a microkernel or plugin architecture. There are no registries, no callbacks, no event buses. The engine is assembled once at startup by manual wiring and then operates as a self-contained concurrent machine driven entirely by goroutines and channels.
Evidence from the code:
device.NewDevice(tunDevice tun.Device, bind conn.Bind, logger *Logger)— three explicit constructor parameters, no DI framework- All routing, crypto, and timer logic lives in the
devicepackage (~20 files) tun.Deviceandconn.Bindare the only two abstraction seams; both defined as Go interfaces in their respective packages
Component diagram (textual)#
┌──────────────────────────────────────────────────────────────┐
│ main.go / embedding app │
│ 1. tun.CreateTUN(name) → tun.Device │
│ 2. conn.NewDefaultBind() → conn.Bind │
│ 3. device.NewDevice(tun, bind, logger) → *device.Device │
│ 4. ipc.UAPIOpen / UAPIListen → UAPI socket │
└────────┬──────────────────────────────────────────────────────┘
│ wires together
▼
┌────────────────────────────────────────────────────────────────┐
│ device.Device (central engine) │
│ │
│ state machine: down ↔ up → closed │
│ peer map: NoisePublicKey → *Peer │
│ AllowedIPs trie: net.IP → *Peer (routing table) │
│ IndexTable: session index → *Keypair (session demux) │
│ CookieChecker: DoS mitigation │
│ │
│ Queues: │
│ handshakeQueue → [RoutineHandshake × N] │
│ outboundQueue → [RoutineEncryption × N] │
│ inboundQueue → [RoutineDecryption × N] │
│ │
│ Goroutines (started in NewDevice): │
│ RoutineReadFromTUN — reads IP packets from tun.Device │
│ RoutineTUNEventReader — handles MTU/up/down events │
│ RoutineReceiveIncoming — reads UDP datagrams (per AF) │
│ RoutineHandshake × N — Noise IKpsk2 handshakes │
│ RoutineEncryption × N — ChaCha20-Poly1305 encryption │
│ RoutineDecryption × N — ChaCha20-Poly1305 decryption │
│ (N = runtime.NumCPU()) │
└───────────┬──────────────────────────┬────────────────────────┘
│ │
┌──────▼──────┐ ┌──────▼──────┐
│ tun.Device │ │ conn.Bind │
│ (interface) │ │ (interface) │
└──────┬──────┘ └──────┬──────┘
│ │
┌────────▼──────┐ ┌────────▼──────────────┐
│ OS kernel TUN │ │ UDP sockets (v4 + v6) │
│ or gVisor │ │ platform-specific │
│ netstack │ │ (GSO, sticky, RIO) │
└───────────────┘ └───────────────────────┘
Configuration path:
UAPI socket (Unix domain / named pipe)
→ ipc.UAPIListen → device.IpcHandle(conn)
→ device.IpcSetOperation / IpcGetOperation
→ device.SetPrivateKey / NewPeer / peer configCore components#
device.Device#
- Package:
golang.zx2c4.com/wireguard/device - Responsibility: Central orchestrator. Owns the complete WireGuard data plane: peer registry, routing table, session index, handshake coordination, packet encrypt/decrypt pipelines, UAPI command processing, and device lifecycle (up/down/close).
- Key types:
Device(struct, ~90 fields),Peer,Keypair,Handshake,AllowedIPs,IndexTable,CookieChecker - Dependencies:
conn.Bind,tun.Device,ratelimiter.Ratelimiter,rwcancel.RWCancel
device.Peer#
- Package:
golang.zx2c4.com/wireguard/device - Responsibility: Per-peer state machine. Holds the active keypairs, handshake state, endpoint (remote address + source cache), per-peer staged/outbound/inbound queues, five protocol timers (retransmit-handshake, send-keepalive, new-handshake, zero-key-material, persistent-keepalive), and byte counters.
- Key types:
Peer,Keypairs,Handshake,Timer - Dependencies:
conn.Endpoint, device-level queues and pools
conn.Bind (interface)#
- Package:
golang.zx2c4.com/wireguard/conn - Responsibility: UDP socket abstraction. Opens listening sockets, returns
[]ReceiveFunc(one per address family), sends batches of datagrams. Hides all platform socket options: GSO, GRO, sticky source IPs, socket marks, Windows Registered I/O. - Key types:
Bindinterface,Endpointinterface,ReceiveFunc(function type),StdBind(Linux/macOS/BSD), Windows RIO bind - Dependencies:
net/netip,rwcancel(Linux)
tun.Device (interface)#
- Package:
golang.zx2c4.com/wireguard/tun - Responsibility: Kernel TUN device abstraction. Reads/writes batched IP packets, reports MTU, signals up/down/MTU-change events. Platform implementations: Linux (
tun_linux.go), macOS (tun_darwin.go), Windows (wintun driver), FreeBSD, OpenBSD. Optional gVisor netstack TUN (tun/netstack). - Key types:
Deviceinterface,Event(int bitmask),NativeTun(concrete per-OS) - Dependencies: OS-specific:
unix.Syscall,wintun,gvisor.dev/gvisor/pkg/tcpip
ipc (UAPI channel)#
- Package:
golang.zx2c4.com/wireguard/ipc - Responsibility: Opens the WireGuard userspace API socket (Unix domain socket on Linux/BSD, named pipe on Windows). Provides
UAPIOpen(creates the socket file) andUAPIListen(wraps it as anet.Listener). The actual protocol parsing (get/setkey-value pairs) is indevice/uapi.go. - Key types:
UAPIListener(wrapsnet.UnixListener),namedpipe.Listener(Windows) - Dependencies:
net,os,golang.org/x/sys/unix
Security utilities#
- ratelimiter: Token-bucket rate limiter for handshake packets; prevents amplification attacks. Self-contained; no intra-project imports.
- replay: Anti-replay sliding window (RFC-style 64-bit counter, 2048-bit window); applied to inbound data packets. Self-contained.
- tai64n: TAI64N timestamp encoder/decoder; used in Noise handshake messages to prevent replay of handshake initiation. Self-contained.
Data flow#
Outbound (plaintext IP → encrypted UDP)#
IP packet arrives from OS kernel via TUN
↓
RoutineReadFromTUN (device/send.go)
- Reads a batch from tun.Device.Read()
- Looks up destination peer via AllowedIPs trie
- If no handshake: stage packet in peer.queue.staged; trigger handshake
- If handshake active: create QueueOutboundElement, assign sequential nonce
↓
peer.queue.outbound (per-peer sequential queue)
↓ (fan-out to device-wide encryption pool)
device.queue.encryption (device-wide)
↓
RoutineEncryption × N (parallel, one per CPU)
- ChaCha20-Poly1305 AEAD encryption
- Signal element "done" via mutex unlock (preserves ordering)
↓
peer.queue.outbound consumer (sequential, per peer)
- Waits for encryption to complete (locks element mutex)
- Sends via conn.Bind.Send(bufs, peer.endpoint.val)Ordering is maintained by a lock-per-element approach: each QueueOutboundElement carries a mutex that is locked before encryption and unlocked when encryption finishes. The sequential consumer walks the queue in order, blocking on each element’s mutex until encryption completes, then transmits.
Inbound (encrypted UDP → plaintext IP)#
UDP datagram arrives on socket
↓
RoutineReceiveIncoming (per address family, device/receive.go)
- Reads a batch via conn.ReceiveFunc
- Inspects first 4 bytes (message type):
type 1 (initiation) → handshakeQueue
type 2 (response) → handshakeQueue
type 3 (cookie) → processed inline
type 4 (data) → decryptionQueue
↓
[Handshake path]
device.queue.handshake
↓
RoutineHandshake × N (parallel)
- Runs Noise IKpsk2 initiation/response
- Checks cookie/ratelimiter
- Derives session keypairs
- Sends handshake response or completes handshake
- Drains peer.queue.staged → outbound path
[Data path]
device.queue.decryption
↓
RoutineDecryption × N (parallel)
- ChaCha20-Poly1305 AEAD decryption
- Anti-replay check via replay.Filter
- Signal element "done"
↓
peer.queue.inbound consumer (sequential, per peer)
- Waits for decryption (same lock-per-element ordering mechanism)
- Writes plaintext packets to tun.Device.Write()Initialization / Bootstrap#
main() — manual wiring, no DI framework:
1. Parse CLI args (-f/--foreground, interface name)
2. Read env vars: LOG_LEVEL, WG_TUN_FD, WG_UAPI_FD, WG_PROCESS_FOREGROUND
3. Open TUN: tun.CreateTUN(name, DefaultMTU)
OR: adopt pre-opened fd via tun.CreateTUNFromFile()
4. Create logger: device.NewLogger(level, prefix)
5. Open UAPI socket: ipc.UAPIOpen(interfaceName)
6. Daemonize (if not foreground):
- os.StartProcess(self, args, attr{files: [stdin,stdout,stderr,tunFd,uapiFd]})
- Pass TUN+UAPI fds as inherited FDs (FD 3 and 4)
- Set WG_PROCESS_FOREGROUND=1 in child env
- Parent exits; child re-enters main() and continues
7. device.NewDevice(tdev, conn.NewDefaultBind(), logger):
- Initialize all queues (handshake, encryption, decryption)
- Populate sync.Pool-based buffer pools (PopulatePools)
- Start N goroutines: RoutineEncryption, RoutineDecryption, RoutineHandshake
- Start RoutineReadFromTUN, RoutineTUNEventReader
8. ipc.UAPIListen(name, fileUAPI) → net.Listener
9. Accept loop: go device.IpcHandle(conn) for each UAPI connection
10. Wait on: signal channel (SIGTERM/SIGINT) | UAPI error | device.Wait() (closed chan)
11. Shutdown: uapi.Close(), device.Close()device.Close() follows a careful ordering: TUN close → bind close → all peers stopped → queue WaitGroups drained → rate limiter closed → device.closed channel closed (signals device.Wait()).
Dependency injection: Purely manual. NewDevice takes tun.Device and conn.Bind as constructor parameters. No wire, dig, or fx. This keeps the bootstrap transparent and makes the library trivially embeddable — Tailscale and wireguard-windows call NewDevice directly with their own tun.Device and conn.Bind implementations.
Configuration#
Two surfaces:
Environment variables (process startup only):
LOG_LEVEL—verbose/debug,error,silentWG_TUN_FD— adopt a pre-opened TUN fd (used by Android/iOS wrappers)WG_UAPI_FD— adopt a pre-opened UAPI socket fdWG_PROCESS_FOREGROUND— suppress daemonization (used by the self-re-exec child)
WireGuard UAPI protocol (runtime, per the xplatform spec):
- Text-based key=value protocol over the UAPI Unix socket or named pipe
- Commands:
get=1(dump config) andset=1(apply config) - Configures: private key, listen port, fwmark, peer public keys, preshared keys, allowed IPs, persistent keepalive, endpoints
- Implemented in
device/uapi.go:IpcGetOperation/IpcSetOperation - Protected by
device.ipcMutex(RWMutex) to serialize config changes against data-path operations - On
set, callingdevice.Up()is triggered automatically when the device transitions from configured-but-down to ready
No Viper, no YAML files, no etcd. The daemon is intentionally configuration-minimal: the UAPI protocol is the single runtime configuration surface, mirroring the kernel WireGuard’s wg(8) tool interface.
Key design decisions#
1. Pipelined parallel encryption with sequential ordering via per-element mutexes#
The outbound pipeline parallelizes ChaCha20-Poly1305 encryption across NumCPU goroutines while guaranteeing in-order delivery. Each QueueOutboundElement carries an embedded sync.Mutex that is locked before encryption begins and unlocked when it completes. The sequential sender walks the per-peer queue in FIFO order, blocking on each element’s mutex. This avoids both a global lock and explicit synchronization channels — the work queue itself is the ordering mechanism. The same pattern applies to inbound decryption.
2. Two abstraction seams — nothing more#
The entire platform-portability story is encapsulated in exactly two interfaces: tun.Device and conn.Bind. Everything above them (the Noise protocol, routing, timers, UAPI) is pure Go with zero platform conditionality. This allowed the codebase to support Linux, macOS, Windows, FreeBSD, OpenBSD, Android, iOS, and WASM without touching the protocol engine.
3. Pool-based zero-allocation packet processing#
All packet buffers ([MaxMessageSize]byte) and element structs are recycled via WaitPool (a bounded pool that blocks rather than allocating). Combined with batch read/write on both tun.Device and conn.Bind, this keeps per-packet allocations near zero in steady state — critical for a high-throughput crypto daemon.
4. Daemonization via self-re-exec with FD inheritance#
Rather than calling C.fork() or C.daemon(), the Unix daemon case uses os.StartProcess(self) with the pre-opened TUN and UAPI file descriptors inherited as FDs 3 and 4 in the child process. The child detects this via WG_PROCESS_FOREGROUND=1 and skips re-daemonizing. This pure-Go approach avoids any CGO dependency for daemon semantics, which matters for cross-compilation and static linking.
5. No internal/ — library embedding is first-class#
All packages are exported with no internal/ barrier. This is an explicit design choice: wireguard-go is the reference library used by Tailscale (tailscale.com/wgengine), the official Windows client (wireguard-windows), and Android/iOS apps. The library contract is the public device.NewDevice constructor and the tun.Device / conn.Bind interfaces. API stability is accepted as a responsibility in exchange for frictionless embedding.