ITNEXT

ITNEXT is a platform for IT developers & software engineers to share knowledge, connect…

Follow publication

Proxying HTTP and gRPC requests in Go

golang gopher eating popcorn

At Host Factor all incoming traffic passes through our proxy layer and all outgoing TCP traffic is redirected to our application proxy. Capturing outgoing TCP traffic is important since all of our internal services use either HTTP or gRPC (HTTP/2) and we want to prevent any user servers from talking to our internal services. After doing some investigation, we couldn’t find any simple libraries in Go that could proxy both HTTP/1 and HTTP/2 requests in a single server so we decided to create one.

For this example, let’s assume we want to block all requests to blocked.hostfactor.io

TCP server

Because both HTTP/1 and HTTP/2 use TCP under the hood, we can start there.

addr, _ := net.ResolveTCPAddr("tcp", ":8080")

list, err := net.ListenTCP("tcp", addr)
if err != nil {
return err
}

go func() {
for {
conn, err := list.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) || errors.Is(err, io.EOF) {
return
}
continue
}

go handleConn(conn.(*net.TCPConn))
}
}()

Here we start a simple TCP server on port 8080 and pass the connection to the handleConn func. The nice part about handling HTTP/1/2 as TCP is we can have a single server listen on a single port to handle both cases. Let’s dig into handleConn

func handleConn(conn *net.TCPConn) {
defer func() {
_ = conn.Close()
}()

buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
return
}

payload := buf[:n]
req, err := http.ReadRequest(bufio.NewReader(bytes.NewBuffer(payload)))
if err != nil {
return
}
...
}

http.ReadRequest allows you to convert raw []byte into a *http.Request which allows us to parse the payload.

if req.ProtoMajor >= 2 {
err = l.handleHttp2(bytes.NewBuffer(payload), conn)
} else {
err = l.handleHttpReq(req, conn)
}

req.ProtoMajor allows us to distinguish if the request is HTTP/2 (gRPC) or HTTP/1

Handling HTTP/1

HTTP/1 is the most familiar case that has the most support in Go so we’ll start there. First we have to check if the host is equal to our banned blocked.hostfactor.io host

// Check the host of the request
if req.Host == "banned.hostfactor.io" {
resp.StatusCode = http.StatusNotFound
}

You can also check the path, method, headers, etc. After the check is passed, we dispatch the request on behalf of the original requester

// Required to forward the request
req.RequestURI = ""
var err error
resp, err = http.DefaultClient.Do(req)
if err != nil {
return err
}

And finally we write the response to the conn.

return resp.Write(w)

Altogether it looks like this

func handleHttpReq(req *http.Request, w net.Conn) error {
resp := &http.Response{}
// Check the host of the request
if req.Host == "banned.hostfactor.io" {
resp.StatusCode = http.StatusNotFound
} else {
// Required to forward the request
req.RequestURI = ""
var err error
resp, err = http.DefaultClient.Do(req)
if err != nil {
return err
}
}
return resp.Write(w)
}

Handling HTTP/2 (gRPC)

HTTP/2 is pretty different from HTTP/1. In HTTP/1 the smallest unit of data is a request while in HTTP/2 messages for requests and responses are broken into frames. HTTP/1 also uses plain text strings while HTTP/2 uses binary encoding which causes some headaches when working with a raw TCP connection.

The Go http package does not have an equivalent http.ReadRequest function for HTTP/2. Instead, we can leverage the golang.org/x/net/http2 package to parse out these frames as the come in through our TCP connection.

First thing we need is a Framer

f := http2.NewFramer(conn, conn)

The first parameter is an io.Writer and the second is an io.Reader. A net.Conn implements both so we can read and write using the same TCP connection.

HTTP/2 requires that a settings ACK is sent when receiving the initial frame in RFC 7540 so let’s get that done before the connection is dropped

err := f.WriteSettingsAck()
if err != nil {
return err
}

Now that we’ve established the connection, we can utilize the Framer to read the frames of data from the connection. Perfect! The only problem is that the Framer consumes the []byte from the TCP connection which we need to forward along if the request is valid 🤔. So if the Framer is consuming the bytes, how do we forward that data to the original host without reimplementing HTTP/2?

This is where io.TeeReader came to the rescue. We simply duplicate all bytes that are read from the connection into a buffer and dispatch them to the original host (if the request is valid)

dataBuffer := bytes.NewBuffer(make([]byte, 0))
reader := io.TeeReader(conn, dataBuffer)
f = http2.NewFramer(io.Discard, reader)
decoder := hpack.NewDecoder(1024, nil)

Now whenever the Framer reads from the connection, the bytes will also be written into the dataBuffer 🎉

The decoder will be used next to actually decode the headers via the golang.org/x/net/http2/hpack package

Next, we need to read frames from the Framer and check out the HeadersFrame to see the original host (and any other metadata we care about in the request)

auth := ""
for auth == "" {
frame, err := f.ReadFrame()
if err != nil {
return err
}

switch t := frame.(type) {
case *http2.HeadersFrame:
out, err := decoder.DecodeFull(t.HeaderBlockFragment())
if err != nil {
return err
}

for _, v := range out {
if v.Name == ":authority" {
auth = v.Value
}
}
}
}

The above loops through the frames until we find the HeadersFrame which holds the :authority (the HTTP/1 equivalent of the host). Once found, we stop reading the frames.

Cool! Now we’ve read through the HTTP/2 frames and we know the original host to check if it’s valid or not

if auth == "blocked.hostfactor.io" {
return nil
}

But what if the :authority is valid? How do we send all the data we’ve read from the Framer to the original host? Well first we’re going to need a new TCP connection to our original host

dialer, err := net.Dial("tcp", auth)
if err != nil {
return err
}

Now since we’re using a raw TCP connection for the client, we’re going to have to handle both reading and writing from the connection. Let’s start with the reading side

// The WaitGroup ensures that every byte is read before exiting
wg := sync.WaitGroup{}
wg.Add(1)
dataSent := int64(0)
go func() {
// Copy any data we receive from the host into the original connection
dataSent, err = io.Copy(conn, dialer)
wg.Done()
}()

Now we can handle writing to the original host

_, err = io.Copy(dialer, io.MultiReader(initial, dataBuffer, conn))
wg.Wait()

initial are the initial bytes read from the requester, dataBuffer are the bytes we read from the connection to inspect the frames and conn is the original. A MultiReader simply chains all of these together so they’re read in order and sent to the original host. This guarantees that all data that we’ve read so far will be sent to the original host. Putting it all together looks like

func handleHttp2(initial io.Reader, conn net.Conn) error {
defer func() {
_ = conn.Close()
}()
dataBuffer := bytes.NewBuffer(make([]byte, 0))
reader := io.TeeReader(conn, dataBuffer)
f := http2.NewFramer(conn, conn)
err := f.WriteSettingsAck()
if err != nil {
return err
}

f = http2.NewFramer(io.Discard, reader)
decoder := hpack.NewDecoder(1024, nil)

auth := ""
for auth == "" {
frame, err := f.ReadFrame()
if err != nil {
return err
}

switch t := frame.(type) {
case *http2.HeadersFrame:
out, err := decoder.DecodeFull(t.HeaderBlockFragment())
if err != nil {
return err
}

for _, v := range out {
if v.Name == ":authority" {
auth = v.Value
}
}
}
}

if auth == "blocked.hostfactor.io" {
return nil
}

dialer, err := net.Dial("tcp", auth)
if err != nil {
return err
}

_ = dialer.SetReadDeadline(time.Now().Add(5 * time.Second))

wg := sync.WaitGroup{}
wg.Add(1)
dataSent := int64(0)
go func() {
// Copy any data we receive from the host into the original connection
dataSent, err = io.Copy(conn, dialer)
wg.Done()
}()

_, err = io.Copy(dialer, io.MultiReader(initial, dataBuffer, conn))
wg.Wait()

if errors.Is(err, os.ErrDeadlineExceeded) && dataSent > 0 {
return nil
}
return err
}

And there you have it! A proxy server that is able to block any received traffic via HTTP/1 or HTTP/2.

We leverage a lot of Go and several other modern technologies over at Host Factor to ensure our customers have the best experience hosting their applications. Please check it out!

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

Published in ITNEXT

ITNEXT is a platform for IT developers & software engineers to share knowledge, connect, collaborate, learn and experience next-gen technologies.

No responses yet

Write a response