I was watching a video on travel routers wanted to understand IP spoofing, so I made a simple VPN client/server in Golang.
What we’re building
VPN client
- Encrypts data and sends to server
- Receive data from server
- Decrypts data from server
VPN server
- Receives encrypted data a client
- Decrypts data
- Forward data to destination
- Receive data from destination
- Encrypt data
- Send to respective client
Prerequisites: Basic understanding of TCP/IP
Quick glossary
- TUN: A virtual network interface
- NAT Masquerade: Replaces a source IP of a Layer 3 header with its own source IP
Following one packet
Here is the whole journey of a single packet. Step through it, and click any box to jump to it.
// Nothing VPN-specific here: the app just uses a normal socket.
conn, _ := net.Dial("tcp", "example.com:80")
fmt.Fprint(conn, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")// createTun(): open the device and attach it to tun0
file, err := os.OpenFile("/dev/net/tun", os.O_RDWR, 0)
ifr, err := unix.NewIfreq(name)
ifr.SetUint16(unix.IFF_TUN | unix.IFF_NO_PI) // Layer 3, no extra header
if err := unix.IoctlIfreq(int(file.Fd()), unix.TUNSETIFF, ifr); err != nil {
// ...
}
// setupClientRoutes(): send all IPv4 traffic into tun0
// (two /1 routes beat the default /0)
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
_, dst, _ := net.ParseCIDR(cidr)
route := &netlink.Route{
LinkIndex: tunLink.Attrs().Index,
Dst: dst,
Gw: net.ParseIP("10.0.0.1"),
}
// ...netlink.RouteReplace(route)
}// tun is the tun0 file we opened in the previous step
buffer := make([]byte, 2048)
for {
n, err := tun.Read(buffer) // one call = one whole IP packet
if err != nil {
log.Printf("TUN Read Error: %v", err)
continue
}
rawPacket := buffer[:n]// error checks omitted
block, err := aes.NewCipher(key) // 32-byte key = AES-256
aesGCM, err := cipher.NewGCM(block)
// A fresh random nonce for every packet
nonce := make([]byte, aesGCM.NonceSize())
io.ReadFull(rand.Reader, nonce)
// Seal returns nonce || ciphertext || tag
encrypted := aesGCM.Seal(nonce, nonce, plaintext, nil)// Created once at startup
serverAddr, err := net.ResolveUDPAddr("udp", serverAddrStr)
conn, err := net.DialUDP("udp", nil, serverAddr)
// Bypass route: the tunnel's own UDP packets must leave via the real gateway,
// not tun0, or they would loop back into the tunnel forever
serverDst := &net.IPNet{IP: serverIP.To4(), Mask: net.CIDRMask(32, 32)}
bypassRoute = &netlink.Route{
LinkIndex: defaultLinkIndex,
Dst: serverDst,
Gw: defaultGw,
}
// ...netlink.RouteReplace(bypassRoute)
// For every packet: the kernel adds the outer IP + UDP headers
if _, err := conn.Write(encryptedPayload); err != nil {
log.Printf("UDP Write Error: %v", err)
}conn, err := createServerSocket(":7646")
// ...
buffer := make([]byte, 2048)
for {
n, addr, err := conn.ReadFromUDP(buffer)
if err != nil {
log.Printf("UDP Read Error: %v", err)
continue
}
addrMutex.Lock()
clientAddr = addr // remember where to send replies
addrMutex.Unlock()
payload := buffer[:n]
// ...decrypt next// decrypt(): same AES-GCM setup as encrypt(), then...
nonceSize := aesGCM.NonceSize()
if len(encrypted) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := encrypted[:nonceSize], encrypted[nonceSize:]
// Open checks the tag and decrypts in one step
plaintext, err := aesGCM.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, err // bad tag: forged or corrupted
}
// runServer(): a failed packet is dropped
rawPacket, err := decrypt(payload, key)
if err != nil {
log.Printf("Decrypt Error: %v", err)
continue
}// Inject the plaintext packet into the kernel via tun0
if _, err := tun.Write(rawPacket); err != nil {
log.Printf("TUN Write Error: %v", err)
}// Enable IP forwarding
if err := os.WriteFile("/proc/sys/net/ipv4/ip_forward", []byte("1\n"), 0644); err != nil {
return fmt.Errorf("failed to enable ip_forward: %w", err)
}
// ...outIfName = the interface that holds the default route
// Masquerade traffic from the VPN subnet as it leaves the server
addCmd := exec.Command("iptables", "-t", "nat", "-A", "POSTROUTING",
"-s", "10.0.0.0/24", "-o", outIfName, "-j", "MASQUERADE")HTTP/1.1 200 OK
Content-Type: text/html
<!doctype html>...$ conntrack -L -d 93.184.216.34
tcp ESTABLISHED src=10.0.0.2 dst=93.184.216.34 sport=51234 dport=80 \
src=93.184.216.34 dst=203.0.113.5 sport=80 dport=51234buffer := make([]byte, 2048)
for {
n, err := tun.Read(buffer)
if err != nil {
log.Printf("TUN Read Error: %v", err)
continue
}
rawPacket := buffer[:n]
// ...encrypt next// Same encrypt(): a brand-new random nonce for every packet
encryptedPayload, err := encrypt(rawPacket, key)
if err != nil {
log.Printf("Encrypt Error: %v", err)
continue
}addrMutex.RLock()
target := clientAddr // remembered from the client's last datagram
addrMutex.RUnlock()
if target != nil {
if _, err := conn.WriteToUDP(encryptedPayload, target); err != nil {
log.Printf("UDP Write Error: %v", err)
}
}buffer := make([]byte, 2048)
for {
n, err := conn.Read(buffer)
if err != nil {
log.Printf("UDP Read Error: %v", err)
continue
}
payload := buffer[:n]
// ...decrypt next// Same decrypt() as the server
rawPacket, err := decrypt(payload, key) // Open verifies the tag
if err != nil {
log.Printf("Decrypt Error: %v", err)
continue // bad tag: drop the packet
}
// rawPacket is the original reply packet// The kernel treats it as if it just arrived from the network
if _, err := tun.Write(rawPacket); err != nil {
log.Printf("TUN Write Error: %v", err)
}// Back in the app: an ordinary socket read
n, _ := conn.Read(reply)
fmt.Printf("%s", reply[:n]) // HTTP/1.1 200 OK ...Details the walkthrough skips
Why two /1 routes
The default route of 0.0.0.0/0 goes to the physical NIC. Having 0.0.0.0/1 (which points to tun0) essentially creates a new default route. I found this to be better because when a user exits the program, the 0.0.0.0/1 gets destroyed and the routing table is restored to what it normally was.
How the server finds the client
The server listens for UDP traffic on a specific port. I personally used Oracle Cloud, so I didn’t have to expose my home network. If you want to test this at home, you will have to do port forwarding.
Limitations
This project is a learning tool, not something you should trust with your real traffic. I cut a lot of corners on purpose to keep the code small enough to follow in one sitting. Here are the biggest ones.
- No handshake: Both sides read the same 32-byte key from the
KEYenvironment variable, so you have to share it ahead of time through some other channel. There is no key exchange, so if the key leaks, every packet ever sent with it can be decrypted. - No replay protection: AES-GCM proves that a packet wasn’t modified, but it doesn’t prove the packet is new. Nothing in my code remembers which packets it has already seen, so an attacker who captures a datagram can send it again and the server will happily decrypt it and inject it into
tun0. Real VPNs solve this with a packet counter and a sliding window of recently seen counters. - Single client: The server keeps one
clientAddr, and the client’s tunnel address (10.0.0.2) is hardcoded. A second client would collide with the first one. - IPv4 only: The routes I add only cover IPv4. If your machine also has IPv6 connectivity, that traffic skips the tunnel completely, and so can your DNS lookups. A real client would either tunnel IPv6 too or block it.
Wrapping up
Before I built this, a VPN felt like magic. It turns out to be a virtual network card, a routing table, some encryption, and a UDP socket. If you made it this far, I hope the walkthrough made that clearer. The full source is available at https://github.com/pacyluu/vpn.