VII. Multi Host Reverse Proxy

Golang comes with a SingleHostReverseProxy. Let's do the obvious thing and give it the ability to handle multiple hosts.

It would be neat if we could configure the proxy to match some request parameters in order to decide which target (upstream/backend server - I need to decide on a vocabulary) to send the request to.

I noticed that Traefik leveraged gorilla/mux for this. Let's do some theft and "pull a Traefik" (only worse, because I'm not a team of experts).

Matching Routes

Digging into the Gorilla Mux package, we find the Match() method.

That Match() method matches a given request to a registered route. Perfect!

Gorilla/Mux wants us to pass a handler to use when a route is matched, but we'll ignore the Handler stuff. Traefik doesn't ignore that feature but our reverse proxy is different and we do. We just want the matching for now!

We can do something like this:

import "github.com/gorilla/mux"

// Register a route to match a request where
// hostname is "localhost" and path is "/foo"
a := mux.NewRouter()
a.Host("localhost").Path("/foo")

// Match an *http.Request to
// my Mux's registered route
match := &mux.RouteMatch{}
if a.Match(req, match) {
    // Do something if we have a match
}

Our goal is to add some possible "targets" (see, I decided on a word!) to our ReverseProxy and have the Director match an incoming request to the desired upstream target.

Implementing Gorilla Mux

First, we grab gorilla/mux for our project via go get -u github.com/gorilla/mux.

Then, back in reverseproxy.go, we edit some stuff:

type ReverseProxy struct {
    proxy  *httputil.ReverseProxy
    targets []*Target
}

type Target struct {
    router   *mux.Router
    upstream *url.URL
}

The ReverseProxy's Target has become targets (lower case, no longer exported). We won't add targets ourselves directly, but instead use a new AddTarget() method:

// AddTarget adds an upstream server to use for a request that matches
// a given gorilla/mux Router. These are matched via Director function.
func (r *ReverseProxy) AddTarget(upstream string, router *mux.Router) error {
    url, err := url.Parse(upstream)

    if err != nil {
        return err
    }

    if router == nil {
        router = mux.NewRouter()
        router.PathPrefix("/")
    }

    r.targets = append(r.targets, &Target{
        router:   router,
        upstream: url,
    })

    return nil
}

The method AddTarget() is added to the ReverseProxy struct.

One notable bit of logic is that if we pass nil for the router parameter, we create a catch-all router via router.PathPrefix("/").

After we register some targets, we need our Director function to be able to spin through the registered targets, and match any. If they are matched, it sends to that upstream target.

The gorilla/mux lib has the matching function, we just (ab)use it.

// Director returns a function for use in http.ReverseProxy.Director.
// The function matches the incoming request to a specific target and
// sets the request object to be sent to the matched upstream server.
func (r *ReverseProxy) Director() func(req *http.Request) {
    return func(req *http.Request) {
        // Check each target for a match
        for _, t := range r.targets {
            // We don't actually use the match variable
            // but we need to make it to satisfy the
            // Gorilla/Mux Match() method
            match := &mux.RouteMatch{}
            // The magic is here ✨
            if t.router.Match(req, match) {

                // This is all stdlib Director method stuff.
                // We adjusted it to use our matched target.
                targetQuery := t.upstream.RawQuery

                req.URL.Scheme = t.upstream.Scheme
                req.URL.Host = t.upstream.Host
                req.URL.Path, req.URL.RawPath = joinURLPath(t.upstream, req.URL)
                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", "")
                }
                break // First match wins
            }
        }
    }
}

As the comments suggest, the Director function now spins through the registered Targets, and uses Gorilla/Mux to match them. It directs the proxy to the first matched upstream Target.

We don't handle the case of a catch-all fallback here (if no matches are found). We'll assume we're responsible developers for now and define the fallback in our main.go file.

Tying it Together

Since that's all setup, let's go back to that main.go file and use our new feature!

package main

import (
    "github.com/fideloper/someproxy/reverseproxy"
    "github.com/gorilla/mux"
)

func main() {
    proxy := &reverseproxy.ReverseProxy{}

    // Let's assume we have 2 backends to proxy to
    // localhost:8000 (for our fun API requests)
    // and localhost:8001 (for everything else)

    // Match requests to "localhost/api"
    // and "localhost/api/*"
    r := mux.NewRouter()
    r.Host("localhost").PathPrefix("/api")
    proxy.AddTarget("http://localhost:8000", r)

    // Catch-all for all other requests
    proxy.AddTarget("http://localhost:8001", nil)

    proxy.Start()
}

Here we register 2 targets into our reverse proxy. One captures anything starting (or equal to) /api, as long as the request was made to hostname localhost. These requests will go to upstream server localhost:8000.

The 2nd target is a catch-all route (see, we're responsible!) that sends anything else to localhost:8001.

Note that proxy.AddTarget() can return an error, but I'm ignoring those for brevity.

If we start this up, we'll see it works! You don't even need to have backend servers to test this - we'll see some logging for failed requests.

# Assuming our proxy server is running...

curl localhost/api/foo

curl localhost/whatever

I didn't have any upstream servers running, so these curl requests receive a 502 Bad Gateway response. But, we'll see the following logged from our reverse proxy:

2022/10/07 21:32:09 http: proxy error: dial tcp [::1]:8000: connect: connection refused
2022/10/07 21:32:13 http: proxy error: dial tcp [::1]:8001: connect: connection refused

They're attempting to send to the correct locations! Port 8000 for our request to localhost/api/foo and port 8001 for any other request (localhost/whatever in our case).

Now we can target multiple backend hosts, using parameters of our choosing!