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!