Using wireguard-go

  • 28th Jun 2025
  • 3 min read

Context

Quite a while ago, I started to have my mind fixed on tailscale. I was impressed by the genious of this technologie. Tailscale allows you to create a mesh VPN even when the peers are behind NAT and don’t have public IP addresses.

As I am often unsatisfied by everything, I found that tailscale was not minimalistic enough for my taste. So I began to wonder: how hard it could be to create something that provide Tailscale’s functionality, but with the simplicity and transparency of configuring WireGuard directly?

So, I started hacking on a prototype — first in Rust, using boringtun, Cloudflare’s userspace WireGuard implementation. But despite Rust’s strengths and my unwanted love for this language, I quickly hit friction. The language is verbose, the compiler is strict, and I spend more time debuging my code than planing my next functionnality. I eventually realized that boringtun didn’t offer some of the hooks I needed — like easily intercepting encrypted packets before they hit the network (e.g., to relay them elsewhere).

That is when I decided to go with go (pun intended). WireGuard’s official userspace implementation wireguard-go is writen is, so it provide all the necessary tool to interact with it.

The code

After a bit of vibe coding, a lot of hallucinations, and reading through wireguard-go code, I finally landed on this very simple main.go that:

  • Creates a TUN device
  • Configure Wireguard via IPC
  • Spins up the embedded WireGuard engine
  • Dumps decrypted packets to stdout
package main

import (
	"flag"
	"fmt"
	"io"
	"log"
	"os"

	"golang.zx2c4.com/wireguard/conn"
	"golang.zx2c4.com/wireguard/device"
	"golang.zx2c4.com/wireguard/tun"
)

func main() {
	ipcPath := flag.String("ipc", "", "Path to WireGuard ipc file")
	flag.Parse()

	if *ipcPath == "" {
		log.Fatal("Missing required --ipc argument")
	}

	// Read ipc file
	ipcBytes, err := os.ReadFile(*ipcPath)
	if err != nil {
		log.Fatalf("Failed to read config file: %v", err)
	}
	ipc := string(ipcBytes)

	// Open TUN device
	const name = "wg0"
	tunDev, err := tun.CreateTUN(name, 1420)
	if err != nil {
		log.Fatal("TUN open error:", err)
	}
	fmt.Println("Opened TUN:", name)

	// Start embedded WireGuard engine
	logger := device.NewLogger(device.LogLevelVerbose, fmt.Sprintf("[%s] ", name))
	dev := device.NewDevice(tunDev, conn.NewDefaultBind(), logger)
	defer dev.Close()

	// Configure wireguard interface
	if err := dev.IpcSet(ipc); err != nil {
		log.Fatal("WireGuard config failed:", err)
	}
	dev.Up()

	// Print configuration
	out, err := dev.IpcGet()
	if err != nil {
		log.Fatal("WireGuard config failed:", err)
	}
	log.Println(out)

	// Read decrypted traffic
	bufs := make([][]byte, 8)
	sizes := make([]int, 8)
	for i := range bufs {
		bufs[i] = make([]byte, 65535)
	}

	for {
		n, err := tunDev.Read(bufs, sizes, 0)
		if err == io.EOF {
			break
		} else if err != nil {
			log.Println("Read error:", err)
			continue
		}
		for i := range n {
			fmt.Printf("Packet %d (%d bytes): %x\n", i, sizes[i], bufs[i][:sizes[i]])
		}
	}
}

In order to deal interact with Wireguard, I used the ipc api descibed here: www.wireguard.com/xplatform/#configuration-protocol

In order to perform a test run, I used 2 docker on the same network. each of this docker container run tinescale and establish a tunnel between each other. You can find the code in the annex.

Encrypted packets interception

When the peers are not on the same network, my tailscale implementation needs to find a way to make them communicate anyway. It can be through nat traversal, or through a relay. In both of these case, The encrypted packet need to be intercepted and rerouted to another destination. This can be done by creating a wrapper around conn.Bind like this:

type InterceptingBind struct {
	inner conn.Bind
}

// ...

// in the main
bind := &InterceptingBind{inner: conn.NewDefaultBind()}
dev := device.NewDevice(tunDev, bind, logger)

For this to work, each method of the interface conn.Bind need to be overloaded. For instance Send can be written like this:

func (b *InterceptingBind) Send(bufs [][]byte, ep conn.Endpoint) error {
	for _, buf := range bufs {
		fmt.Printf("[Encrypted OUT] (%d bytes) to %s\n", len(buf), ep.DstToString())
	}
	return b.inner.Send(bufs, ep)
}

I also added an overload for packet Receive. And now we can see the encrypted packet leaving and entering the host!

peer2  | DEBUG: [wg0] 2025/06/28 18:07:08 peer(OQr/…zOSE) - Sending keepalive packet
peer2  | [Encrypted OUT] (32 bytes) to 10.10.0.11:51820
peer1  | [Encrypted IN] (32 bytes) from 10.10.0.12:51820: 0400000076698eaa0300000000000000941a731a7d7ae4bfccc67c86d3f5114a
peer2  | [Encrypted IN] (32 bytes) from 10.10.0.11:51820: 04000000253a2e34030000000000000079de92c05fc556531539b432f06df541
peer1  | DEBUG: [wg0] 2025/06/28 18:07:08 peer(ATYf…ExDg) - Receiving keepalive packet
peer2  | DEBUG: [wg0] 2025/06/28 18:07:08 peer(OQr/…zOSE) - Receiving keepalive packet

Annex

The following code create two docker container with tinescale creating a wireguard connection between them

tests/
├── Dockerfile
├── entrypoint.sh
└── same-net-1
    ├── docker-compose.yaml
    ├── peer1.ipc
    └── peer2.ipc
FROM golang:tip-bullseye

# Set up working directory
WORKDIR /app

# Install tool for internal debugging
RUN apt-get update && apt-get install -y --no-install-recommends \
    socat \
    iproute2 \
    ca-certificates \
    iputils-ping \
    traceroute \
    curl \
    wget \
    iproute2 \
    tcpdump \
    wireguard-tools \
    dnsutils \
    net-tools \
 && apt-get clean

# Copy Go project
COPY . .

# Build tinescale binary
RUN go build -o tinescale ./cmd/tinescale && cp tinescale /usr/local/bin/

COPY tests/entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
services:
  peer1:
    container_name: peer1
    build:
      context: ../../
      dockerfile: tests/Dockerfile
    cap_add:
      - NET_ADMIN
    privileged: true
    environment:
      - IFACE=wg0
      - ADDR=10.0.0.1/24
    networks:
      testnet:
        ipv4_address: 10.10.0.11
    volumes:
      - ./peer1.ipc:/app/config.ipc

  peer2:
    container_name: peer2
    build:
      context: ../../
      dockerfile: tests/Dockerfile
    cap_add:
      - NET_ADMIN
    privileged: true
    environment:
      - IFACE=wg0
      - ADDR=10.0.0.2/24
    networks:
      testnet:
        ipv4_address: 10.10.0.12
    volumes:
      - ./peer2.ipc:/app/config.ipc

networks:
  testnet:
    name: same-net-1-testnet
    driver: bridge
    ipam:
      config:
        - subnet: 10.10.0.0/24

then we can configuring the peers like this:

private_key=909d7dc175dff8461ac1e6bee76a2dd44982de2b1d3f0121450840eea22c6a58
listen_port=51820
replace_peers=true
public_key=01361f99041a1b5a39860182d292c9a8a4fdeeae7cef78867d2bcd63ef84c438
replace_allowed_ips=true
endpoint=10.10.0.12:51820
allowed_ip=10.0.0.2/32
persistent_keepalive_interval=15
private_key=80aad15bee3126936dd5784d704cea7110b52826ad9c4b0c2cbfa86c49f97470
listen_port=51820
replace_peers=true
public_key=390affb161f08eb900700065b69c2edb7aea5c83d6d7f45b936c6abbc2333921
replace_allowed_ips=true
endpoint=10.10.0.11:51820
allowed_ip=10.0.0.1/32
persistent_keepalive_interval=15

and finaly the entrypoint to docker, that will create the interface and assign an ip:

#!/bin/bash
set -euo pipefail

IPC_FILE="/app/config.ipc"

if [[ -z "$IFACE" ]]; then
    echo "[ERROR] IFACE not defined as an env variable"
    exit 1
fi

if [[ -z "$ADDR" ]]; then
    echo "[ERROR] ADDR not defined as an env variable"
    exit 1
fi

# Launch tinescale in the background
tinescale --ipc "$IPC_FILE" &
APP_PID=$!

# Wait for the TUN interface to appear
echo "[INFO] Waiting for interface $IFACE to be created by tinescale..."
for i in {1..10}; do
    if ip link show "$IFACE" >/dev/null 2>&1; then
        echo "[INFO] Interface $IFACE detected"
        break
    fi
    sleep 1
done

# Assign IP to the interface
echo "[INFO] Assigning $ADDR to $IFACE"
ip addr add "$ADDR" dev "$IFACE"
ip link set up dev "$IFACE"

# Wait for tinescale to exit
wait $APP_PID