Using wireguard-go
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