Simple VPN in Golang

2023-01-29 ⏳3.7 min(1.5k words)

In this article, I will describe how to implement one simple VPN system, using the Golang.

The VPN is not a new concept, by which you can transfer inner traffic over the public network. One key problem of it is how to capture and re-inject network packets from and into the local network. Luckily, this hard job could be done by the TUN device, which is supported by almost all major operating system, including Linux, Mac, Windows, BSD, and so on.

The other problem is how to transfer the inner packets using the public network. It is quite easy, because we can treat them as normal application data, and transfer using any existing protocol. In this essay, I will demonstrate how to use the HTTP/HTTPS to transfer the network data.

Talk is cheap, let’s dig the code 😄

Although the TUN device is widely supported, operating them is by no means an easy task. Fortunately, again, Song Gao has open-sourced the water1 Go library, by which we can maintain the TUN device easily. And here is one simple example. The code creates one TUN device and dump all packet from it.

import "github.com/songgao/water"

func main() {
  ifce, err := water.New(water.Config{ DeviceType: water.TUN })
  if err != nil {
    log.Fatal(err)
  }

  buf := make([]byte, 1500)
  for {
    n, err := ifce.Read(buf)
    if err != nil {
      log.Fatal(err)
    }
    log.Printf("packet: % x\n", buf[:n])
  }
}

First we should call the water.New() function to create a new TUN device. It need one parameter of type water.Config, by which we can indicate the device type. It is possible to set the name of the created TUN device. However, different operating system has vary limits. For simplicity, I just use the default name. In Linux, the name seems like tun\d+, and in Mac, utun\d+.

Once the water.New() function returned without error, we are ready to capture or re-inject network packets from or into the system network stack. The operating system will create a new virtual network device (aka TUN device). In order to use them, we need to let them up and set some IP address.

In Linux, we can use the iproute2 to do this job:

ip link set up dev tun0
ip addr add 10.0.0.1/24 dev tun0

Then, we try ping the TUN device:

ping -c 1 10.0.0.2

The go program , running under the root privileges, will display (the long line has been wraped):

2023/01/31 10:48:55 packet: 45 00 00 54 5d 05 00 00 40 01 09 a2 0a 00 00 01 0a
00 00 02 08 00 e3 c1 27 66 00 00 63 d8 81 97 00 01 1c 64 08 09 0a 0b 0c 0d 0e
0f 10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f 20 21 22 23 24 25 26 27 28
29 2a 2b 2c 2d 2e 2f 30 31 32 33 34 35 36 37

This is how to capture the IP datagram.

In the Golang code, the ifce we gained is also writable. However, it isn’t a normal file. We should write one complete IP packet at once. And all incomplete data will be dropped by the kernel.

Once kernel receive one complete IP packet, it will inject it into the network stack, and makes it looks like the packet coming from the TUN device. This is how to re-inject the network packets.

So far we have solved the first key problem. Then we need to solve the other one.

The easiest way is dialing a TCP connection and use it to send or receive the packets coming from the TUN device. However, transferring the inner network this way may leak information, so every serious VPN system should encrypt the network data before transferring them. So we could dial a TLS connection.

But using the raw TLS connection still has some issues. For VPN system usually transmit some meta information like IP address, gateway, route rules, and so on. Using the raw TLS connection means we need to design some new protocol to exchange the meta information, which is not an easy task.

In light of this, I choose the HTTPS to transmit the packets. But how can this be possible? The HTTP/HTTPS protocol uses the classic request-and-response, and only the client can send request actively. How can we use it to do the bi-directional communication? This magic is buried in the stand http library of Golang.

In the server side, the net/http offers the Hijacker2 interface. If the ResponseWriters implements the Hijacker interface, we can use it’s Hijack() function to take over the connection, by which we can do the bi-way communication. The default ResponseWriter for HTTP/1.x connections supports Hijacker.

In the client side, however, there is no such hijacking way. But since Go 1.12, if the client send request using the CONNECT method or with the Connection: upgrade header, then the Body in the response will implement the io.Writer interface, and by which we can do the bi-direction communication3.

So the server side code may look like this:

func main() {
  http.HandleFunc("/vpn", func(w http.ResponseWriter, r *http.Request) {
    // do authn and authz
    u, p, ok := req.BasicAuth()

    if req.Header.Get("Connection") != "" {
      w.WriteHeader(http.StatusUpgradeRequired)
      return
    }

    hj, ok := w.(http.Hijacker)
    if !ok {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte("Cannot Hijack!"))
            return
    }
    conn, _, err := hj.Hijack()
    if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            w.Write([]byte("Hijack error " + err.Error()))
    }

    // clear connection timeout
    conn.SetDeadline(time.Time{})
    // the client
    conn.Write([]byte("HTTP/1.1 101 Switching Protocols\r\n" +
                "Connection: Upgrade\r\n" +
                "Upgrade: Simple-VPN\r\n" +
		"Simple-VPN-Address: 10.0.0.2/24\r\n" +
		"Simple-VPN-Gateway: 10.0.0.1\r\n" +
                "\r\n"))

    // creating TUN device and proxy ...
  })
  err := http.ListenAndServeTLS(":443", "server.crt", "server.key", nil)
  if err != nil {
    log.Fatal("ListenAndServe: ", err)
  }
}

Using the HTTP protocol, we could do some authorization and authentication firstly. Then we hijack the http session to TLS session. Once hijacked, we are unable to use the ResponseWriter to send header and data. Instead, we need to send the “raw” data by the hijacked conn. This is why I concatenate strings to send the 101 response.

And the client side code:

req, err := http.NewRequest(http.MethodGet, "https://example.com/vpn", nil)

req.SetBasicAuth(username, password)

req.Header.Set("Connection", "upgrade")
req.Header.Set("Upgrade", "Simple-VPN")

resp, err := http.DefaultClient.Do(req)

if resp.StatusCode != http.StatusSwitchingProtocols {
  panic("upgrade failed " + resp.Status)
}

addr := resp.Header.Get("Simple-VPN-Address")
gate := resp.Header.Get("Simple-VPN-Gateway")

conn, ok := resp.Body.(io.ReadWriteCloser)
if !ok {
  panic("cannot upgrade to vpn")
}

// creating TUN device and proxy ...

The client side code is far more clear. We make a req, set the auth information and essential header. Then we send the request, and finally convert the resp.Body to the io.ReadWriteCloser interface, which supports read and write operation.

The finally issue is how to “proxy” the network packets. There are two directions. One is reading from tun and sending to conn, and the other is reading from conn and sending to tun.

The first direction is simple, because every time we read the TUN device, we get a complete IP packet, so just writing it in to conn is enough. But the second is not that simple. For every time we read from the conn, we may get one packet, multiple packets, or even incomplete packet. This is how TCP, which is the underlying protocol of TLS, works. So we need to be careful with this problem.

First, we need a reading buffer, which should be large enough to save the data length of MTU. And then every time we read from the TLS session, we should check if there is new complete packet have been received. But how can we know the packet received is complete? We need to know its length. But how can we know its length? Many VPN protocol design their own private protocol to store such information. For example, Cisco’s anyconnect uses a fixed 8-bytes header4 to save such information. In my opinion, a more natural way is just parsing the IP packet and extract the length from it.

If we got one complete packet and there is remaining data in the buffer, we need to move them to the beginning and read new data again.

Here is the real code:

func ConnToTun(t io.WriteCloser, c io.ReadCloser) error {
  off := 0
  buf := make([]byte, 1500)
  for {
    n, err := c.Read(buf[off:])

    if n > 0 {
      off += n
    } else {
      // remote peer close the connection
      return nil
    }

    tl := 0
    switch uint8(buf[0]) >> 4 {
    case 6: // ipv6
      if off >= 6 {
        tl = int(binary.BigEndian.Uint16(buf[4:6]))
        tl += 40 // fixed header length
      }
    case 4: // ipv4
      if off >= 4 {
        tl = int(binary.BigEndian.Uint16(buf[2:4]))
      }
    default:
      // skip
    }

    if tl > 0 && off >= tl {
      _, err := t.Write(buf[:tl])
      if err != nil {
        return err
      }
      if off > tl {
        copy(buf, buf[tl:off])
        off -= tl
      } else /* if off == tl */ {
        off = 0
      }
    }

    if err != nil {
      return err
    }
  }
}

If the buffer contains multiple small packet, the code aforementioned is not optimized, because it will copy the following data after parsing the first packet. But the optimize it will make the code too complicated to understand. So I use the unoptimized version.

Finally, we have implemented a simple VPN. It works. But it have drawbacks. One of the most is it use the TCP to transmit the VPN packets, which is not a good idea5. Another idea is using the UDP, so I designed the dtun6. However, many ISP will limit the UDP traffic’s speed. Maybe the ultimate solution is use the UDP protocol but masquerade them into fake TCP. But it beyond the scope of this article.


  1. https://github.com/songgao/water↩︎

  2. https://pkg.go.dev/net/http#Hijacker↩︎

  3. https://github.com/golang/go/issues/28030↩︎

  4. https://datatracker.ietf.org/doc/html/draft-mavrogiannopoulos-openconnect-03↩︎

  5. http://sites.inka.de/sites/bigred/devel/tcp-tcp.html↩︎

  6. https://github.com/taoso/dtun↩︎