turnstile
Turnstile is a very simple gateway / ingres component which provides the following functionality
- transparent proxy for HTTP/HTTPS/REST microservices
- transparent proxy for gRPC microservices including grpc-web
- transparent proxy for TCP microservices
- central authentication / authorisation handling for downstream HTTP, HTTPS and GPRC-WEB microservices
- automatic generation of SSL certificates
- automatic management of TLS (using let's encrypt)
- allows custom routing to allow further customisation
- turnstile supports consuming of the proxy protocol. It is then terminated and removed from connections to upstream consumers
- supports compressing the content (credit to https://github.com/nytimes/gziphandler)
Getting started (using HTTP)
func main() {
// Define basic config
config := turnstile.Config{
CompressResponse: true // Gzip data coming out (http proxies)
ListenerHTTP: ":8080", // set http to listen on port 8080
PublicURLs: []string{"/healthz"}, // exclude healthz handler from authentication
PublicDomains: []string{"public.domain.com", "*.public.com"}, // exclude public domain from authorisation
Proxies: turnstile.ProxyList{
&turnstile.HTTPProxy{
Domain: "protected.domain.com", // requests to this domain will be routed
Target: "https://httpbin.org", // .. to this server
Authoriser: nil, // you can provide custom authoriser
},
&turnstile.HTTPProxy{
Domain: "public.domain.com", // requests to this domain will be routed
Target: "https://httpbin.org", // .. to this server
Authoriser: nil // you can provide custom authoriser
},
&turnstile.HTTPProxy{
Path: "/http-bin", // requests starting with this path will be routed
Target: "https://httpbin.org", // .. to this server
StripPath: true, // strip path before relaying downstream
Authoriser: nil // you can provide custom authoriser
},
&turnstile.HTTPProxy{
Domain: "public.domain.com", // requests to this domain
Path: "/http-bin-status-500", // .. and this path will be routed
Target: "https://httpbin.org/status/500", // .. to this server, this path
StripPath: true, // strip path before relaying downstream
Authoriser: nil // you can provide custom authoriser
},
},
}
// Define some keys for authorisation
apiKeyMap := map[string]string{
"api-key-1": "client-id-1",
"api-key-2": "client-id-2",
}
// Set up API key authoriser, expecting in requests to send key in my-api-key header
// ... downstream microservices expecting to receive client id in out-client-id header
auth := authoriser.NewAPIKeyAuthoriser("my-api-key", "out-client-id", apiKeyMap)
// Initialise turnstile server
srv := server.New(conf, nil)
srv.AddAuthoriser(auth)
// Start the server
srv.Start()
select {}
}
Using HTTPS
When it comes to HTTPS, turnstile offers multiple ways of how to obtain the certificate. Currently, turnstile supports following modes.
Auto-generated, self signed certificate
This is the simplest and fastest way to get started. Turnstile automatically generates both key and certificate which are then used to serve over HTTPS
func main() {
config := turnstile.Config{
ListenerHTTPS: ":8443", // port where HTTPS will run
PublicURLs: []string{"/"}, // just for this example - disable authentication
}
// create, provide name of the organization
// ... and list of host names / ip addresses written to the certificate (can be empty)
certProvider := certprovider.GenerateSelfSigned("myorganization", []{"my.organization.com"})
srv := server.New(config, certProvider)
run.IfErrorThenExit(srv.Start(), "error when starting turnstile")
}
Certificate and key loaded from files
If you need to provide certificate and key from a file, use this cert provider. Example:
func main() {
config := turnstile.Config{
ListenerHTTPS: ":8443", // port where HTTPS will run
PublicURLs: []string{"/"}, // just for this example - disable authentication
}
certProvider := certprovider.FromFiles("cert.pem", "key.pem")
srv := server.New(config, certProvider)
run.IfErrorThenExit(srv.Start(), "error when starting turnstile")
}
Externally provided certificate and key as bytes
Use this for all other cases. The following example loads certificates from environment variables.
func main() {
config := turnstile.Config{
ListenerHTTPS: ":8443", // port where HTTPS will run
PublicURLs: []string{"/"}, // just for this example - disable authentication
}
certProvider := certprovider.Provide([]byte(os.Getenv("CERT")), []byte(os.Getenv("KEY")))
srv := server.New(config, certProvider)
run.IfErrorThenExit(srv.Start(), "error when starting turnstile")
}
Let's encrypt, auto requested, auto renewed
The certificate and key is stored on local HD. If the certificate does not exist, turnstile automatically starts HTTP server (along with the HTTPS one) and reuqests let's encrypt for a certificate. Once it receives valid certificate, it is stored on local HD.
There are pre requisites needed to be able to run turnstile in this mode:
- the box must be accessible from the internet
- server must be listening on http(80) and https(443) ports
- both http and https must be accessible from outside (no firewall)
- the DNS you use must be pointing to that box - validation of the certificate is using web ACME challenge, so let's encrypt need to be able to reach your server
To run turnstile in this mode, you need to provide certManager object: golang.org/x/crypto/acme/autocert
func main() {
config := turnstile.Config{
ListenerHTTPS: ":443",
PublicURLs: []string{"/"},
}
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
Cache: autocert.DirCache("/tmp/"),
}
certprovider.LetsEncryptCertProvider(certManager)
srv := server.New(config, certprovider.LetsEncryptCertProvider(certManager))
run.IfErrorThenExit(srv.Start(), "error when starting turnstile")
select {}
}
Assuming that your domain is ts.mydomain.com
, then once the server starts (you may need root permissions to expose things on port 80 and 443), navigate to the following URL:
https://ts.mydomain.com/healthz
If everything worked well, you should see Status 200 - healthy
message after some time on page which is served over https. The certificates are stored in ./stored-certs/
or in a directory you specified in tls config.
Custom 401, 403, 404 handlers
If you want the server to return customised response (or redirect), turnstile exposes 401, 403 and 404 handlers for overriding. \
Example:
// ...
srv := server.New(conf, nil)
// Custom 404 handler
srv.Handle404(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte("Custom 404 message"))
})
// Custom 401 handler
srv.Handle401(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusUnauthorised)
_, _ = w.Write([]byte("Custom 401 message"))
})
// Custom 403 handler
srv.Handle404(func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("Custom 403 message"))
})
// ...
Custom authorisers
Aim is to leave bespoke authorisation mechanisms to the developer. To implement your bespoke logic, simply extend Authoriser interface.
Authoriser interface has only one method which is returning 2 parameters
- authorised (bool): return true if the server should pass request downstream, return false if you want to return 403 - forbidden
- error: if it returns error, then the server returns 401 - unauthorised (or runs srv.Handle401() if provided)
Example
type MyAuthoriser struct {}
func (a *MyAuthoriser) Authorise(r *http.Request) (authorised bool, resultErr error) {
if r.Header.Get("my-header") == "" {
return false, fmt.Errorf("my header is missing") // server returns 401 - unauthorised
}
if r.Header.Get("my-header") != "SecretPassword" {
return false, nil // server returns 403 - forbidden
}
return true, nil // server passes request to the next handler and returns what that handler return
}
To use the authoriser, use the example above and just change authoriser
...
auth := MyAuthoriser{}
...
Using multiple authorisers
If there is a situation you need to use multiple authorisers for the server or and individual end point, use authoriser.UseAny(auth1, auth2, ...). For example:
apiKeyAuth := authoriser.NewAPIKeyAuthoriser("my-api-key", "out-client-id", map[string]string{
"api-key-1": "client-id-1",
"api-key-2": "client-id-2",
})
myAuth := MyAuthoriser{}
auth := authoriser.UseAny(apiKeyAuth, myAuth)
...
Custom routes
Server provides AddRoutes() callback. If the route provided already exist in the server, the provided is used instead of existing. This is also true for proxied paths (i. e. if your router maps to matching proxied path, your router is used)
AddRoutes callback provides router parameter, which is a subrouter created from the main router. This subrouter is mapped to root ("/") of the router.
Turnstile is using gorilla mux router under the hood - https://github.com/gorilla/mux
Example
(extending the example above)
...
srv := server.New(conf, nil)
srv.AddRoutes(func(router *mux.Router) {
router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("this is from the custom router"))
})
router.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("this is the custom http://my-server:8080/healthz handler"))
})
)
...
gRPC Proxy
If you need to proxy GRPC services, there is a gRPC proxy server built in. Underlying service utilizes https://github.com/mwitkow/grpc-proxy.
To make things even simpler, the exposed grpc services can be wrapped so they work according to the grpc-web spec,
so they can be called by javascript from the web browsers. The wrappers are utilizing https://github.com/improbable-eng/grpc-web/tree/master/go/grpcweb
If there is a certProvider loaded in turnstile, gRPC proxy will use the same certificates for securing gRPC proxies.
To proxy gRPC services, you need to provide gRPCProxyConfig object to the constructor function.
Example:
...
conf.GRPCConfig = &turnstile.GrpcConfig{
Listener: ":3009", // gRPC port which will the gRPC be listening on
Targets: map[string]string{
"exposed1.host.com": "internal-host-1:3009", // route requests for exposed1.host.com to internal-host-1:3009
"exposed2.host.com": "internal-host-2:3009", // route requests for exposed2.host.com to internal-host-2:3009
},
EnableGrpcWeb: true, // whether to create grpc-web wrapper
DisableGrpcListener: false, // if set to true, no standalone grpc is started. Still may be used with EnableGrpcWeb
}
srv := server.New(conf, nil)
...
TCP Proxy
If you need to proxy any other TCP service, there is a TCP proxy built in. To do this, simply add TCPProxy definition into the config.Proxies list
Example:
...
config.Proxies = turnstile.ProxyList{
&turnstile.TCPProxy{Listener: ":3000", Target: "internal-host-1:1234"},
&turnstile.TCPProxy{Listener: ":3010", Target: "internal-host-2:4567", ID: "proxy2"},
}
srv := server.New(conf, nil)
...
TCP proxies support authorisers as well. These are provided in form of a method as seen in the following example:
tcpProxy := &turnstile.TCPProxy{
Listener: ":3000"
Target: "internal-host-1:1234",
Authoriser: func(conn net.Conn) {
// ... do your logic, return true if should be authorised, false if rejected
}
}
If you want to get proxy in the later stage, you must set ID property to it (as seen for proxy2). Then you can call the following to get it
if proxy2 := srv.GetTCPProxy("proxy2"); proxy2 != nil {
// do something with proxy2
}
There are two prebuilt Conn authorisers. IPWhiteListAuthoriser and IPBlackListAuthoriser, both can consume IP addresses and CIDR blocks.
whiteListedProxy := &turnstile.TCPProxy{
Listener: ":3000"
Target: "internal-host-1:1234",
Authoriser: authoriser.IPWhiteList("192.168.1.1", "10.0.0.0/16")
}
blackListedProxy := &turnstile.TCPProxy{
Listener: ":3000"
Target: "internal-host-1:1234",
Authoriser: authoriser.IPBlackList("192.168.1.2", "88.1.1.1/24")
}
Shutter functionality
Sometimes, there is a need to 'shut' all communication and respond with a static message (when the site is under maintenance for example). This functionality can be achieved using shutter handler.
Shutter is currently supported only for http trafic, both on the top turnstile level or in the individual HTTP proxies.
Turnstile level shutter handler example:
// ...
srv := server.New(conf, nil)
srv.ShutterHandler(func(w http.ResponseWriter, req *http.Request) {
_, _ = w.Write([]byte("Turnstile is under maintenance, please come back later"))
})
To remove top level shutter, call
srv.ShutterHandler(nil)
Proxy level shutter handler example:
proxyShutterHandler := func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("proxy destination is under maintenance, please come back later"))
}
conf := turnstile.Config{
// ...
&turnstile.HTTPProxy{
ID: "proxy1", // this must be set if you want to dynamically set shutter page
Domain: "public.domain.com", // requests to this domain will be routed
Target: "https://httpbin.org", // .. to this server
ShutterHandler: proxyShutterHandler,
},
// ....
},
srv := server.New(conf, nil)
// ...
To add or remove proxy level shutter dynamically, proxy must have ID set, do
// Add shutter
if proxy1 := srv.GetHTTPProxy("proxy1"); proxy1 != nil {
proxy1.ShutterHandler = proxyShutterHandler
}
// Remove shutter
if proxy1 := srv.GetHTTPProxy("proxy1"); proxy1 != nil {
proxy1.ShutterHandler = nil
}
Proxy Protocol
Proxy protocol is partially implemented thanks to go-proxyproto library.
- Turnstile does consume proxy protocol from downstream load balancers.
- Turnstile does not send proxy protocol information to upstream service consumers
CORS configuration
CORS handler is implemented thanks to rs/cors library.
Configuration can be provided in either top level turnstile config, or in the individual proxy configurations.
If you want CORS to be delegated to underlying service, do not set CORS on neither of the turnstile config nor proxy config, otherwise you get CORSMultipleAllowOriginNotAllowed error in the browser.
Evaluation flow
The following stands for http and https proxies. GRPC and TCP proxies start their own listeners
- shutter
- authorisation
- grpc-web wrapper (if enabled for grpc services)
- custom routes
- proxies
TODO
- provide parsers for environment variables (to be able to define path proxies in plain text - useful for docker, kubernetes)
- implement set of common authorisers, API key, user / password from file, user / password from env. variables, user / password from db, etc.
- implement session handling and session sharing across multiple instances
- implement sso, oauth, etc.