VIII. Multiple Listeners
So far the proxy only listens for incoming requests on port 80. That's dumb.
It would be way more useful if we could listen for both http://
and https://
(TLS) connections, or even listen on custom ports.
To do that, we need to create multiple listeners, where each listener is a network socket to use to listen for requests (such as 0.0.0.0:443
).
We'll run srv.Serve(listener)
once for each listener defined.
Expanding on Listeners
Previously we started an HTTP server by just passing it a string
address. This address is converted to a net.Listener
and used to listen for connections:
srv := &http.Server{Addr: ":80", Handler: r.proxy}
We want to be able to listen on multiple addresses of our choosing.
To accomplish this, we can build a Listener concept into the code.
Let's start by defining a new type: type Listener struct
. This will contain things we need to listen and serve http://
or https://
connections.
We can create a new file listener.go
for this - Here's the updated project layout:
.
├── go.mod
├── go.sum
├── main.go
└── reverseproxy
└── listener.go
└── reverseproxy.go
File listener.go
contains a new Listener
struct and some methods to make it convenient to use:
package reverseproxy
import "net"
type Listener struct {
Addr string
TLSCert string
TLSKey string
}
// Make creates a net.Listen object to be used
// when starting an http server
func (l *Listener) Make() (net.Listener, error) {
return net.Listen("tcp", l.Addr)
}
// ServeTLS tells us if we should be serving TLS
// connections instead of unsecured connections
func (l *Listener) ServesTLS() bool {
return len(l.TLSCert) > 0 && len(l.TLSKey) > 0
}
The Listener
struct contains an Address compatible with net.Listen()
, and optional fields for a TLS certificate/key.
Method Make()
generates a net.Listener
for us (or an error, if our address Addr
is invalid).
Method ServesTLS()
returns a boolean - essentially just saying this listener is meant to be used for TLS certificates if the certificate and key fields are used.
Now that we've abstracted the concept of a Listener, we can use it!
Implementing Multiple Listeners
We need to update our ReverseProxy
object to make use of multiple listeners.
Let's see what that looks like. Here are the changes within reverseproxy.go
:
type ReverseProxy struct {
listeners []Listener
proxy *httputil.ReverseProxy
servers []*http.Server
targets []*Target
}
// AddListener adds a listener for non-TLS connections on the given address
func (r *ReverseProxy) AddListener(address string) {
l := Listener{
Addr: address,
}
r.listeners = append(r.listeners, l)
}
// AddListenerTLS adds a listener for TLS connections on the given address
func (r *ReverseProxy) AddListenerTLS(address, tlsCert, tlsKey string) {
l := Listener{
Addr: address,
TLSCert: tlsCert,
TLSKey: tlsKey,
}
r.listeners = append(r.listeners, l)
}
// Start will listen on configured listeners
func (r *ReverseProxy) Start() error {
r.proxy = &httputil.ReverseProxy{
Director: r.Director(),
}
for _, l := range r.listeners {
listener, err := l.Make()
if err != nil {
// todo: Close any listeners that
// were created successfully
// before one returned error
return err
}
srv := &http.Server{Handler: r.proxy}
r.servers = append(r.servers, srv)
// TODO: Handle unexpected errors from our servers
if l.ServesTLS() {
go func() {
if err := srv.ServeTLS(listener, l.TLSCert, l.TLSKey); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Println(err)
}
}()
} else {
go func() {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Println(err)
}
}()
}
}
return nil
}
Let's review what's going on!
Our ReverseProxy
struct gets 2 new properties
- One is a slice of
Listener
objects. - The other is a slice of
*http.Server
objects.
Each listener needs its own HTTP server to accept and handle connections, so we need one of each per network socket we're listening on.
A network socket is a network interface (address) + port combo being bound to in order to listen for connections.
We add two methods (which is a bit more consistent with the stdlib's HTTP methods) - one for regular connections, and one for TLS connections. These just add to our slice of listeners.
The Start()
method used to run this:
srv := &http.Server{Addr: ":80", Handler: r.proxy}
srv.ListenAndServe()
But now we have multiple listener objects! We'll need a server for each listener, and we'll use the Serve
/ ServeTLS
methods instead of ListenAndServe
.
Additionally, ListenAndServe()
/ Serve()
/ ServeTLS()
are all blocking. That means these all need to run in a goroutine, so they can run concurrently.
For each listener, we create a http.Server
, passing it our http.ReverseProxy
handler. Each server listens on our given address defined by our Listener
objects. TLS listeners get the TLS treatment.
We also handle (which is to say, "log") errors here, where as before we just ignored them.
Here's a fun fact: the Serve
methods always return an error. If the error is ErrServerClosed
, it just means the server was shutdown properly and will no longer accept new connections. All other errors are "true" errors.
Our Start()
method now just returns an error if an unexpected error is returned. However, if the Serve
methods return an unexpected error, we just log them.
I didn't go through the hoops of using an error channel to handle those errors (yet?).
Running the Server
We can now update our main.go
file to try to run this.
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 "fid.dev/api" and "fid.dev/api/*"
r := mux.NewRouter()
r.Host("fid.dev").PathPrefix("/api")
proxy.AddTarget("http://localhost:8000", r)
// Catch-all for all other requests
proxy.AddTarget("http://localhost:8001", nil)
// Listen for http://
proxy.AddListener(":80")
// Listen for https://
proxy.AddListenerTLS(":443", "keys/fid.dev.pem", "keys/fid.dev-key.pem")
if err := proxy.Start(); err != nil {
log.Fatal(err)
}
}
Wait! TLS keys! I used mkcert to generate some TLS keys (and a local CA to authenticate them) for me. The domain I used is fid.dev
. I edited my /etc/hosts
file so fid.dev
pointed to 127.0.0.1
.
I should probably have just used something like fid.local
or the more grim fid.localhost
. Oh well!
In any case, mkcert
Just Worked™ for me as described in its readme (on MacOS Monteray). I placed the keys it generated in a new directory named keys
.
We gotta fix a thing
OK, if you try to run this....it will just exit immediately.
Remember how we ran our servers in goroutines? Yeah, there's nothing blocking anymore, so the program just ends (and goroutines get shut down).
We need a way to keep our program running. To do that, we can listen (and wait) for an interrupt signal (SIGINT
), which is roughly Windows/Mac/Linux compatible.
This gives us the ability to block (keep the servers running) until we hit ctrl+c
. Based on some random Stack Overflow answer, os.Interrupt
is the only signal that will work on Windows.
With some additions, main.go
now looks like this:
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)
// Listen for http://
proxy.AddListener(":80")
// Listen for https://
proxy.AddListenerTLS(":443", "keys/fid.dev.pem", "keys/fid.dev-key.pem")
if err := proxy.Start(); err != nil {
log.Fatal(err)
}
// Shutdown when we receive Ctrl+c (interrupt)
c := make(chan os.Signal, 1)
// We'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
signal.Notify(c, os.Interrupt)
// Block until we receive our signal.
<-c
}
Now our program will run indefinitely until we send it SIGINT
(interrupt).
When we hit ctrl+c
, the server will shut down. It will also cut off any connections! We'll next see how to do graceful shutdowns.