wireguard-go — Interfaces#

Interface catalog#

tun.Device#

  • Package: golang.zx2c4.com/wireguard/tun
  • File: tun/tun.go
  • Methods:
    File() *os.File
    Read(bufs [][]byte, sizes []int, offset int) (n int, err error)
    Write(bufs [][]byte, offset int) (int, error)
    MTU() (int, error)
    Name() (string, error)
    Events() <-chan Event
    Close() error
    BatchSize() int
  • Purpose: Abstracts the kernel TUN network interface. Read and Write are batch-oriented: they accept slices of byte slices so that a single syscall can transfer multiple IP packets. The offset parameter lets the caller pre-allocate a header region in each buffer (used by the device package to place the WireGuard message header before the payload). Events() returns a channel that signals EventUp, EventDown, or EventMTUUpdate so the engine can react without polling. BatchSize() advertises the maximum batch size the implementation can handle efficiently, allowing the engine to size its work queues accordingly.
  • Implementations:
    • tun.NativeTun — per-platform concrete type (separate files for Linux, Darwin, FreeBSD, OpenBSD, Windows/Wintun)
    • netstack.netTun — in-process userspace network stack using gVisor (tun/netstack/tun.go)
    • tun.ChannelTUN — in-memory channel-based TUN for testing (tun/tuntest/tuntest.go)
  • Design quality: Tightly segregated. Every method is load-bearing; nothing is vestigial. BatchSize() is a deliberate performance contract rather than a hint — callers must honor it. The batch API ([][]byte) is more complex than a single-packet Read([]byte) interface would be, but the complexity is justified by the 1–2× throughput gain from reduced syscall overhead. Follows ISP well: no unrelated concerns are bundled in.

conn.Bind#

  • Package: golang.zx2c4.com/wireguard/conn
  • File: conn/conn.go
  • Methods:
    Open(port uint16) (fns []ReceiveFunc, actualPort uint16, err error)
    Close() error
    SetMark(mark uint32) error
    Send(bufs [][]byte, ep Endpoint) error
    ParseEndpoint(s string) (Endpoint, error)
    BatchSize() int
    Supporting type alias:
    type ReceiveFunc func(packets [][]byte, sizes []int, eps []Endpoint) (n int, err error)
  • Purpose: Abstracts the UDP socket layer. Open returns a slice of ReceiveFunc values — typically one per address family (IPv4 and IPv6) — rather than a single blocking call. Each ReceiveFunc is meant to be called from a dedicated goroutine (RoutineReceiveIncoming). Send is also batch-oriented. SetMark sets SO_MARK for policy routing and VPN split-tunneling. ParseEndpoint is a factory for Endpoint values appropriate to this bind type.
  • Implementations:
    • conn.StdNetBind — cross-platform implementation using golang.org/x/net/ipv4 and ipv6 for batch I/O with GSO/GRO on Linux (conn/bind_std.go)
    • conn.WinRingBind — Windows Registered I/O (RIO) implementation for high-performance I/O completion ports (conn/bind_windows.go)
    • conn.ChannelBind — in-memory channel-based bind for testing (conn/bindtest/bindtest.go)
  • Design quality: Excellent. The ReceiveFunc indirection (returning a slice of functions rather than a Receive method) is an uncommon pattern that enables per-address-family goroutines without exposing address-family logic in the interface. BatchSize() mirrors the same contract as tun.Device.BatchSize(), enabling the device package to coordinate batch sizes end-to-end. The interface is small (6 methods) relative to its responsibility.

conn.Endpoint#

  • Package: golang.zx2c4.com/wireguard/conn
  • File: conn/conn.go
  • Methods:
    ClearSrc()
    SrcToString() string
    DstToString() string
    DstToBytes() []byte
    DstIP() netip.Addr
    SrcIP() netip.Addr
  • Purpose: Encapsulates the source+destination address pairing for a peer’s UDP path. WireGuard caches the local source address (src) so that replies go out on the same interface the peer’s packet arrived on — critical for multi-homed hosts and containers. ClearSrc() is called when the source cache must be invalidated (e.g. after a route change). DstToBytes() is used specifically for cookie (MAC2) computation per the WireGuard spec. All addressing uses netip.Addr (the modern, allocation-free address type introduced in Go 1.18).
  • Implementations:
    • conn.StdNetEndpoint (Linux/macOS/BSD, embedded in StdNetBind)
    • Windows endpoint (embedded in WinRingBind)
    • conn.ChannelEndpoint (test)
  • Design quality: Well-segregated. The interface is provider-owned — Bind.ParseEndpoint is the factory, keeping construction and type knowledge co-located in the bind implementation. The ClearSrc + src caching design is specific to the WireGuard protocol’s source-IP stickiness requirement; a general-purpose endpoint interface would not need it, but this is not a general-purpose interface.

conn.BindSocketToInterface#

  • Package: golang.zx2c4.com/wireguard/conn
  • File: conn/conn.go
  • Methods:
    BindSocketToInterface4(interfaceIndex uint32, blackhole bool) error
    BindSocketToInterface6(interfaceIndex uint32, blackhole bool) error
  • Purpose: Optional capability extension for Windows. Allows the caller to bind the underlying socket to a specific network interface index. blackhole mode discards packets on that interface rather than sending, used to implement split-tunneling exclusion routes. wireguard-windows performs a runtime type assertion (if b, ok := bind.(BindSocketToInterface); ok) to use this when available.
  • Implementations: WinRingBind (Windows only)
  • Design quality: A classic capability interface — narrow, optional, platform-specific. Correct use of the extension interface pattern: the main Bind contract stays clean and cross-platform, while platform-specific capabilities are exposed via separate interfaces discovered via type assertion.

conn.PeekLookAtSocketFd#

  • Package: golang.zx2c4.com/wireguard/conn
  • File: conn/conn.go
  • Methods:
    PeekLookAtSocketFd4() (fd int, err error)
    PeekLookAtSocketFd6() (fd int, err error)
  • Purpose: Optional capability extension for Android. Android’s VPN service needs to “protect” (exclude from VPN routing) the WireGuard UDP sockets themselves to prevent a routing loop. This interface lets the Android wrapper obtain the raw file descriptor and pass it to VpnService.protect().
  • Implementations: StdNetBind (when GOOS=android)
  • Design quality: Same extension pattern as BindSocketToInterface. Extremely narrow (2 methods) and single-use. The name PeekLookAt is slightly awkward but the semantics are unambiguous.

conn.batchReader / conn.batchWriter (internal)#

  • Package: golang.zx2c4.com/wireguard/conn (unexported)
  • File: conn/bind_std.go
  • Methods:
    type batchReader interface {
        ReadBatch([]ipv6.Message, int) (int, error)
    }
    type batchWriter interface {
        WriteBatch([]ipv6.Message, int) (int, error)
    }
  • Purpose: Internal abstraction within StdNetBind over golang.org/x/net/ipv4.PacketConn and ipv6.PacketConn. Both types share the same underlying ipv6.Message slice type (verified by a compile-time assertion: _ ipv6.Message = ipv4.Message{}), so a single receiveIP function handles both address families via these interfaces.
  • Implementations: *ipv4.PacketConn, *ipv6.PacketConn
  • Design quality: Pragmatic internal deduplication rather than a public abstraction contract. Not visible outside the conn package.

Interface patterns#

  • Size distribution: Very lean. The two primary interfaces (tun.Device, conn.Bind) have 8 and 6 methods respectively. The supporting interfaces (Endpoint, BindSocketToInterface, PeekLookAtSocketFd) have 6, 2, and 2 methods. Average ~5 methods per interface. No God interfaces.

  • Embedding: Not used between the public interfaces. There is no interface embedding (e.g. Bind does not embed io.Closer even though it has a Close() method). This is a deliberate choice for clarity over structural conciseness.

  • Implicit satisfaction: Interfaces are defined by consumers (device package consumes tun.Device and conn.Bind), but the definitions live in provider packages (tun and conn). This is a design compromise: the interfaces are co-located with the default implementations to keep the package structure simple, but conceptually they represent the protocol engine’s requirements. The capability extension interfaces (BindSocketToInterface, PeekLookAtSocketFd) follow pure implicit satisfaction — platform-specific implementations satisfy them without the interface knowing.

  • stdlib interfaces used:

    • net.ListenerUAPIListener wraps it
    • net.Conn — UAPI connections
    • os.File — returned by tun.Device.File()
    • The io.Reader/io.Writer interfaces are intentionally not used for packet I/O; the batch-oriented [][]byte API is incompatible with single-read semantics.

Key abstractions#

1. tun.Device — the TUN seam
This is the most critical interface in the codebase. It completely insulates the protocol engine from the OS kernel’s TUN driver, enabling the same device package to run on Linux, macOS, Windows, and inside a userspace network stack (gVisor/netstack). Its batch-read design is architecturally significant: without it, the CPU overhead of per-packet syscalls would dominate at high throughput.

2. conn.Bind — the UDP socket seam
The second critical seam. The ReceiveFunc-slice return from Open is the most unusual design choice in the codebase. By returning functions rather than a Receive method, the interface cleanly exposes that there may be multiple independent receive loops (one per AF) without forcing the engine to understand address families. The Windows RIO implementation plugs in here without any changes to the engine.

3. conn.Endpoint — the address cache
Less visible but architecturally important. The source-address caching inside Endpoint implements WireGuard’s “roaming” behavior (a peer’s IP address can change, and the stack must both detect and adapt to this). The ClearSrc() method is the hook for route-change invalidation. Without this interface, the engine would need to know about OS-level routing events per platform.

4. conn.ReceiveFunc (function type)
Not an interface in the Go sense, but a named function type used as a first-class abstraction. Returning []ReceiveFunc from Bind.Open treats receive loops as composable values rather than requiring a multi-method interface or a callback registration mechanism. This is idiomatic Go: use a function type when the abstraction is a single behavior.

5. Capability extension interfaces (BindSocketToInterface, PeekLookAtSocketFd)
Exemplify the “optional interface” pattern: the main contract stays clean and cross-platform, while platform-specific features are discovered at runtime via type assertion. Used by wireguard-windows and wireguard-android respectively. This pattern avoids either polluting the main interface with no-op methods or fragmenting the type hierarchy.


Interface-driven extensibility#

wireguard-go’s extensibility is entirely interface-driven through the two primary seams:

Embedding as a library: The canonical extension point is device.NewDevice(tun tun.Device, bind conn.Bind, logger *Logger). Any project (Tailscale, wireguard-windows, mobile apps) can provide its own tun.Device and conn.Bind implementations and receive a fully functional WireGuard engine. No plugin registry, no hooks, no callbacks — the interface parameters are the extension mechanism.

Platform adaptation: Each supported OS provides its own tun.NativeTun and, for Windows, a WinRingBind. The GOOS-specific files are compiled in via build constraints. No runtime dispatch or factory registry is needed; the appropriate implementation is wired in at build time and passed to NewDevice at startup.

Test doubles: Both interfaces have complete in-memory implementations in tun/tuntest and conn/bindtest. ChannelTUN and ChannelBind use Go channels as the transport, enabling deterministic unit tests of the device engine without any OS network resources.

gVisor netstack integration: tun/netstack implements tun.Device on top of gVisor’s userspace TCP/IP stack. This enables wireguard-go to operate as a full userspace network stack (used by Tailscale for its “userspace networking” mode), again without any changes to the engine.

The overall interface design reflects a philosophy of minimum necessary abstraction: exactly two seams are created, each precisely sized to their responsibility. The result is a codebase that is simultaneously easy to embed, easy to port, and easy to test — with zero abstraction overhead in the steady-state data path.