Golang WireGuard Console Client from scratch

Have ended up with following

In previous note I had real fun with building custom web UI for WireGuard being deployed to Kubernetes with help of FastCGI and plain shell scripts

The idea was to protect access to UI with help of OAuth2 proxy configured against GitHub

But the problem overall is that created keys will be valid forever and need to managed somehow

Instead of dealing with that we are going to do everything in reverse, so keys will be ephemeral

Imagine that whenever our WireGuard server starts it does generate its own pair of keys, there is no peers yet and alongside there is an small API which we will talk about later

On a client side we will have really small console application which will act as an VPN client

The flow will be following:

  • whenever console app starts - we are going to authenticate user with help of openid connect device flow (here we will have fancy 2FA if needed and may use any provider we want like Google, Microsoft, ...)
  • after successfull registration we are going to create our own key pair
  • ther we are going to send access key whih we received after registration and our public key to the server
  • server will check access token against user info endpoint to check if it is walid
  • then server may check if user should have access and if so add its public key as peer so user may proceed and connect
  • after successfull communication with our api console app will create tunnel and configure it
  • if everything succeed we will configure interface ip address, routing table and dns

Azure Device Flow Authentication in Go - really awesome article with details and sample of how can it be done

Also here at bottom you may found curl examples for device flow which is pretty straight forward

For the console app itself here is an starting point example:

package main

import (


func main() {
	errs := make(chan error)
	term := make(chan os.Signal, 1)

	// if runtime.GOOS != "" { // "darwin", "windows"
	// 	fmt.Println(runtime.GOOS)
	// 	return
	// }

	// for macos interface name must be utun0..9
	interfaceName, err := getNextInterfaceName()
	if err != nil {
		log.Fatalf("Failed to get next interface name: %v", err)

	logger := device.NewLogger(device.LogLevelVerbose, fmt.Sprintf("(%s) ", interfaceName))

	// create utun tunnel and device, after this two commands ifconfig will show our interface
	t, err := tun.CreateTUN(interfaceName, device.DefaultMTU)
	if err != nil {
		log.Fatalf("Failed to create TUN: %v", err)
	defer t.Close()

	dev := device.NewDevice(t, conn.NewDefaultBind(), logger)

	// talk to interface, we are sending commands to configure it, note that it is little bit different than configs we are used to
	// private_key=$(cat peer1.privatekey | base64 -d | xxd -p -c32)
	// public_key=$(cat server.publickey | base64 -d | xxd -p -c32)
	err = dev.Up() // gets up automatically in macos, but not in windows
	if err != nil {
		log.Fatalf("Failed to up: %v", err)
	defer dev.Close()
	// at this point connection is established and because of keepalive we will see in the logs that packets are sending

	// TODO: for windows something like:
	// netsh interface ipv4 show config
	// netsh interface ipv4 set address name="utun1" static
	// route print
	// route -p add MASK

	// additional changes we need: configure ip address of created utun interface, add row to an routing table, configure search names and dns

	// configure IP address
	_, err = exec.Command("ifconfig", interfaceName, "inet", "", "", "alias").Output()
	if err != nil {
		log.Fatalf("Failed to set IP address: %v", err)

	// add routes
	_, err = exec.Command("route", "-q", "-n", "add", "-inet", "", "-interface", interfaceName).Output()
	if err != nil {
		log.Fatalf("Failed to add route: %v", err)

	// configure DNS
	_, err = exec.Command("networksetup", "-setdnsservers", "Wi-Fi", "").Output()
	if err != nil {
		log.Fatalf("Failed to add route: %v", err)

	// configure search domains
	_, err = exec.Command("networksetup", "-setsearchdomains", "Wi-Fi", "dev.svc.cluster.local svc.cluster.local cluster.local").Output()
	if err != nil {
		log.Fatalf("Failed to add route: %v", err)

	// wait for program to terminate

	signal.Notify(term, syscall.SIGTERM)
	signal.Notify(term, os.Interrupt)

	select {
	case <-term:
	case <-errs:
	case <-dev.Wait():

	logger.Verbosef("\nShutting down")

	// ip address and routes are removed automatically

	// remove dns
	_, err = exec.Command("networksetup", "-setdnsservers", "Wi-Fi", "empty").Output()
	if err != nil {
		log.Fatalf("Failed to add route: %v", err)

	// remove searchdomains
	_, err = exec.Command("networksetup", "-setsearchdomains", "Wi-Fi", "empty").Output()
	if err != nil {
		log.Fatalf("Failed to add route: %v", err)

// instead of this function you may just hardcode interface name
func getNextInterfaceName() (string, error) {
	ifaces, err := net.Interfaces()
	if err != nil {
		return "", err

	num := 0
	for _, iface := range ifaces {
		if strings.HasPrefix(iface.Name, "utun") {
			idx, err := strconv.Atoi(strings.ReplaceAll(iface.Name, "utun", ""))
			if err != nil {
			if idx > num {
				num = idx

	return fmt.Sprintf("utun%d", num+1), nil


  • ofcourse it is not completed client but it is an starting point, it is possible to build anything with it
  • for windows to work i needed to download wintun.dll and put it somewhere where it will be accessible (for example to system32) after that it did worked out as well
  • because of how everything configured keys are ephemeral and will be created each time
  • to cleanup old keys, on server side, by schedule we will just remove keys that has big last handshake time
  • we do not care about revoking keys anymore