As a project to improve my computer networking skills and continue my Go learning journey, I decided to peek behind the curtain of a technology that has been around for a long time: Virtual Private Networks (VPNs).
These overlay networks, which provide a tunnel from server A to server B, are a great way to hide your internet traffic from prying eyes and to ensure encryption.
Go is a language perfectly suited for networking development, making it an ideal candidate for this project. Before diving into the actual implementation, however, I feel it’s necessary to explain what VPNs are and what different kinds of protocols exist. Afterwards, we’ll build our own minimal VPN implementation using only Go’s standard library plus the songgao/water package for working with TUN devices.
As a project to improve my computer networking skills and continue my Go learning journey, I decided to peek behind the curtain of a technology that has been around for a long time: Virtual Private Networks (VPNs).
These overlay networks, which provide a tunnel from server A to server B, are a great way to hide your internet traffic from prying eyes and to ensure encryption.
Go is a language perfectly suited for networking development, making it an ideal candidate for this project. Before diving into the actual implementation, however, I feel it’s necessary to explain what VPNs are and what different kinds of protocols exist. Afterwards, we’ll build our own minimal VPN implementation using only Go’s standard library plus the songgao/water package for working with TUN devices.
Of course, the explanation above is a gross simplification and omits many important details. Let’s look one level deeper at how different VPN protocols handle packet routing (we’ll leave encryption aside for now to keep the scope manageable).
There are numerous VPN protocols, but the most popular and widely used ones are OpenVPN, IPSec, and **WireGuard **. In this article, I’ll focus exclusively on OpenVPN and WireGuard, as they are built quite differently and help illustrate the fundamentals of tunneling protocols.
The key distinction between them lies in which OSI layer they operate on and how they process packets.
- OpenVPN supports both Layer 2 (L2) and Layer 3 (L3) tunneling.
- WireGuard is more opinionated and supports only Layer 3 tunneling.
OpenVPN uses TUN/TAP devices (explained below), while WireGuard operates within the kernel itself.
TUN/TAP devices are virtual networking interfaces that allow you to handle low-level networking operations in user space, providing access to raw IP packets.
- TUN devices operate at Layer 3 (Network Layer) — they handle IP packets.
- TAP devices operate at Layer 2 (Data Link Layer) — they handle Ethernet frames.
In this article, we’ll focus only on TUN devices.
OpenVPN can use either TUN or TAP devices. WireGuard, on the other hand, is implemented as a kernel module and does not
use user-space TUN devices by default. Its implementation can be found here:
https://github.com/torvalds/linux/tree/master/drivers/net/wireguard.
When you set up a WireGuard interface, it registers a new kernel-level network device that behaves like any other (e.g., eth0, lo). This approach offers major performance advantages, since packets never have to leave the kernel to be processed in user space.
There’s also a Go-based user-space implementation of WireGuard for non-Linux platforms:
https://github.com/WireGuard/wireguard-go.
For a detailed visualization of OpenVPN’s packet flow, see this excellent diagram:
https://community.openvpn.net/Pages/HowPacketsFlow.
Since our implementation will also use TUN devices, let’s walk through an example packet flow from client to destination, using real IPs and device names to keep it tangible.
Client:
Server:
On the client, we first define routing rules so all outgoing packets are routed via tun5:
With this setup, all traffic is directed to the TUN interface. From there, in user space, we can access the raw TCP/IP packets. We establish a UDP connection from the client to the VPN server, and for every packet received on tun5, we send it through this UDP socket to the remote server. Thus, the original packet becomes the payload of a UDP/IP packet.
The server listens on the same UDP socket. When it receives a packet, the OS has already processed the UDP/IP layer, leaving us with the original IP packet, which we write to tun6. The kernel then routes it out through eth0 to its final destination (e.g., domain-name-resolver.fly.dev).
Responses from domain-name-resolver.fly.dev travel back to the server, which routes packets destined for 10.0.5.1 to the client’s tunnel interface. Here we need to define another important routing rule. Below script ensures that all packets coming to our server that have 10.0.5.1 as the destination address will be sent to the tun6 interface.
We can visualize this with logs. Suppose the client makes a request to https://domain-name-resolver.fly.dev.
Client logs:
Server logs:
On the way back, the client reads the UDP data, writes it to tun5, and the kernel routes it back to the original TCP socket connection — completing the round trip.
Figure 2: Detailed flow of a packet in a VPN tunnel with TUN devicesGo is an excellent language for networking — it strikes a great balance between simplicity and low-level control. The client and server code are nearly identical; for brevity, we’ll look only at the client.
Create the TUN interface:
Set up the UDP socket and manage packet flow:
Forward packets from TUN → UDP:
Forward packets from UDP → TUN:
With just these few functions, we have a minimal working VPN client. The server implementation mirrors this closely.
From the project root directory, run the following commands:
From the project root directory, run the following commands:
This is, of course, a very naive VPN implementation — it lacks encryption, authentication, and connection management. The next logical step would be to add encryption, one of the core features that makes VPNs secure and private.
.png)
