Golang provides a simple HTTP Utility called ReverseProxy which makes it dead simple to create a ReverseProxy as such:

package main

import (
	"fmt"
	"io/ioutil"
	"log"
	"net/http"
	"net/http/httptest"
	"net/http/httputil"
	"net/url"
)

func main() {
	backendServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "this call was relayed by the reverse proxy")
	}))
	defer backendServer.Close()

	rpURL, err := url.Parse(backendServer.URL)
	if err != nil {
		log.Fatal(err)
	}
	frontendProxy := httptest.NewServer(httputil.NewSingleHostReverseProxy(rpURL))
	defer frontendProxy.Close()

	resp, err := http.Get(frontendProxy.URL)
	if err != nil {
		log.Fatal(err)
	}

	b, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}

	fmt.Printf("%s", b)
}

This is all well and good, but maybe you want to use multiple backend servers. That is exactly what I wanted to accomplish! To make things easy we are just going to assume that: * backend servers are healthy * backend servers have same priority weight (round robin) * all traffic is HTTP (not SSL/TLS/HTTPS)

To accomplish this I need to be able to identify multiple backend servers, and rotate between them. So the first thing I want to do is look at what exactly httputil.NewSingleHostReverseProxy() is doing.

Looking at https://golang.org/pkg/net/http/httputil/#NewSingleHostReverseProxy we see a director is created, which manipulates the URL to conform to what the backend server is expecting. This directory is set into a ReverseProxy struct, and sent back to the caller.

So what exactly is ReverseProxy? ReverseProxy is an HTTP Handler, which is an interface that dictates the existence of the method ServeHTTP(ResponseWriter, *http.Request). So we know that ServeHTTP is the meat to look at there, and I have trimmed it down to the relevant parts for us

func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
    ...
	p.Director(outreq)
	...
}

As you can see the Director is being used, so all we need to do is have multiple Directors, and swap them out each time ServerHTTP runs. Let’s do it!

Extend

So the first thing we need to do is extend ReverseProxy so we do that as such:

type ReverseProxy struct {
	httputil.ReverseProxy
}

Next we need to have a way to store the state of which Director is being used. So lets add a simple integer; we also need a simple mechanism for storing the directors, so we’ll add a slice of functions!

type ReverseProxy struct {
	httputil.ReverseProxy
	
	currentDirector int
	Directors []func(req *http.Request)
}

The original NewSingleHostReverseProxy() takes a single url.URL as its argument, but we’d like to be able to give a bunch if we have them ready when we create the ReverseProxy, so we create a new function to create our new ReverseProxy with multiple Director support:

func NewMultiHostReverseProxy(targets ...*url.URL) (reverseProxy *ReverseProxy) {
	// grab a new ReverseProxy and set the currentDirector to 0
	reverseProxy := &ReverseProxy{currentDirector: 0}

    // loop through given targets and add as Directors
	for _, target := range targets {
		reverseProxy.AddHost(target)
	}

    // send back the 
	return reverseProxy
}

func singleJoiningSlash(a, b string) string {
	aslash := strings.HasSuffix(a, "/")
	bslash := strings.HasPrefix(b, "/")
	switch {
	case aslash && bslash:
		return a + b[1:]
	case !aslash && !bslash:
		return a + "/" + b
	}
	return a + b
}

func (p *ReverseProxy) AddHost(target *url.URL) {
	targetQuery := target.RawQuery
	p.Directors = append(p.Directors, func(req *http.Request) {
		req.URL.Scheme = target.Scheme
		req.URL.Host = target.Host
		req.URL.Path = singleJoiningSlash(target.Path, req.URL.Path)
		if targetQuery == "" || req.URL.RawQuery == "" {
			req.URL.RawQuery = targetQuery + req.URL.RawQuery
		} else {
			req.URL.RawQuery = targetQuery + "&" + req.URL.RawQuery
		}
		if _, ok := req.Header["User-Agent"]; !ok {
			// explicitly disable User-Agent so it's not set to default value
			req.Header.Set("User-Agent", "")
		}
	})
}

Above you can see the AddHost method added which borrows the logic from NewSingleHostReverseProxy() but places the newly generated director into our new Directors slice. We also had to take singleJoiningSlash() verbatim from reverseproxy.go as it was created as a private function.

Lastly we need to rotate between the backend servers, so we add a new function called NextDirector:

func (p *ReverseProxy) NextDirector() func(*http.Request) {
	log.Printf("Current Director: %d", p.currentDirector)
	p.currentDirector += 1
	log.Printf("Next Director: %d", p.currentDirector)
	if p.currentDirector >= len(p.Directors) {
		p.currentDirector = 0
		log.Printf("Too High; Resetting Director: %d", p.currentDirector)
	}

	log.Printf("Returning Next Director: %d", p.currentDirector)
	return p.Directors[p.currentDirector]
}

and we need to call that new function when ServeHTTP is called, but we don’t want to take over all the function of the original ServeHTTP, and so we have:

func (p *ReverseProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
	// Rotate Director
	p.Director = p.NextDirector()
	
	// Call the upstream ServeHTTP
	p.ReverseProxy.ServeHTTP(rw, req)
}

and now we can use our new Multi Host Reverse Proxy as follows:

package main

import (
	"github.com/utahcon/reverse-proxy/util"
	"log"
	"net/http"
	"net/url"
)

func main() {
	
	var targets []*url.URL

	for _, host := range []string{"http://127.0.0.1:9000", "http://127.0.0.1:9500"} {
		rpURL, err := url.Parse(host)
		if err != nil {
			log.Fatalf("Error parsing URL: %v", err)
		}
		targets = append(targets, rpURL)
	}

	rp := util.NewMultiHostReverseProxy(targets...)

	feProxy := &http.Server{
		Addr:    ":80",
		Handler: rp,
	}
	defer func() {
		err := feProxy.Close()
		if err != nil {
			log.Fatalf("Error closing proxy: %v", err)
		}
	}()

	err := feProxy.ListenAndServe()
	if err != nil {
		log.Fatal("Error Listening: ", err)
	}
}

What’s Next?

Next up I want to add ACME support so that SSL/TLS/HTTPS is a trivial add-on.