TLS
App.Run serves TLS whenever ServerOptions supplies a certificate pair or a
*tls.Config. ServerOptions is embedded in AppOptions, so both are set inline.
From a certificate on disk
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Version: "1.0.0",
Addr: ":8443",
CertFile: settings.TLSCertFile,
KeyFile: settings.TLSKeyFile,
})
log.Fatal(app.RunSignals())
Leaving both empty serves plain HTTP, which is what a development run and a deployment behind a TLS-terminating proxy both want.
Set both or neither. A certificate without a key, or a key without a certificate, is a build
error. With only one of CertFile and KeyFile set, the server used to come up in plaintext on
the port its operator believed was HTTPS, with nothing said:
muzak: ServerOptions.CertFile is set without ServerOptions.KeyFile; set both to serve HTTPS, or neither
From a tls.Config
A *tls.Config covers everything a certificate pair cannot: several certificates, client
certificates, a pinned minimum version, or a certificate that comes from somewhere other
than a file.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Addr: ":8443",
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{cert},
},
})
Mutual TLS
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
app := muzak.New(muzak.AppOptions{
Title: "Internal API",
Addr: ":8443",
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
Certificates: []tls.Certificate{serverCert},
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
},
})
The verified client certificate is on the request, so a provider can turn it into an identity like any other credential:
func GetClientIdentity(ctx *muzak.Context) (ClientIdentity, error) {
state := ctx.Request().TLS
if state == nil || len(state.PeerCertificates) == 0 {
return ClientIdentity{}, muzak.Unauthorized("a client certificate is required")
}
return ClientIdentity{CommonName: state.PeerCertificates[0].Subject.CommonName}, nil
}
A certificate that renews itself
GetCertificate is consulted per handshake, which is what an ACME client or a certificate
that rotates on disk needs.
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
Addr: ":8443",
TLSConfig: &tls.Config{
MinVersion: tls.VersionTLS13,
GetCertificate: certificates.Current,
},
})
Whatever keeps certificates up to date is a resource with a start and a stop, so give it
a muzak.Lifecycle and publish it. See
Lifecycle.
What changes once you serve HTTPS
Cookies. Set Secure: true on every cookie, so a browser refuses to send it over plain
HTTP.
ctx.SetCookie(&http.Cookie{
Name: "theme",
Value: theme,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
MaxAge: 3600,
})
A session needs no cookie of your own: AppOptions.Sessions writes one that is Secure,
HttpOnly and SameSite=Lax by default, under a __Host- name a browser accepts only from a
secure origin. See Sessions.
Origins. The https and http spellings of a host are different origins. Update
CORSOptions.AllowedOrigins and WSOptions.AllowedOrigins accordingly.
WebSocket URLs. A page served over https must dial wss, not ws.
The Servers list. Point the generated document at the address clients actually use:
Servers: []muzak.Server{
{URL: "https://api.example.com", Description: "production"},
},
Terminating TLS somewhere else
A load balancer, an ingress controller or a reverse proxy commonly holds the certificate and speaks plain HTTP to the service behind it. Muzak then serves HTTP, and three things need saying explicitly:
- Name the proxy in
ClientIPOptions.TrustedProxies, or every request is attributed to the proxy's address rather than the client's. See Behind a Proxy. - Keep cookies
Secureanyway. The browser leg of the connection is the one that matters, and it is HTTPS. - Redirect plain HTTP with
AppOptions.RedirectHTTPS, if the proxy forwards it rather than redirecting itself. It believes the scheme a named proxy reports inX-Forwarded-Proto, answers301for GET and HEAD and308otherwise, and only ever names a host thatAppOptions.AllowedHostsadmitted orRedirectHTTPSOptions.Hostgives. See Allowed Hosts and HTTPS.
HTTP/2 without TLS
Over TLS, HTTP/2 is negotiated as usual. A platform that speaks HTTP/2 to the container in the
clear, such as Cloud Run with an h2c container port, needs ServerOptions.UnencryptedHTTP2,
which accepts HTTP/2 without TLS, sent with prior knowledge, on the same port as HTTP/1:
app := muzak.New(muzak.AppOptions{
Title: "Awesome API",
ServerOptions: muzak.ServerOptions{UnencryptedHTTP2: true},
})
Leave it off anywhere the proxy speaks HTTP/1, and never for a server reachable directly from an
untrusted network. A WebSocket handshake is not carried over HTTP/2 and is answered 426. What
was measured on Cloud Run, and why a request is cancelled the moment its client leaves over h2c
but not over HTTP/1, is on Running on Cloud Run.
Strict transport security
Muzak's SecurityHeaders middleware sets X-Content-Type-Options, X-Frame-Options and a
referrer policy on every response. A response to a request that arrived over TLS also gets
Strict-Transport-Security: max-age=31536000, which tells the browser to use HTTPS for this
host for a year, so that a visitor's next request cannot be downgraded to plain HTTP on the way.
It commits only the host that has just answered over TLS. It leaves out includeSubDomains and
preload, which commit hosts this application may not serve and are hard to take back, and it is
not sent for localhost, a .localhost name or an IP address, so a development server with a
local certificate does not pin every other server on the developer's machine to HTTPS. A browser
ignores it over plain HTTP, so it is not sent there, and behind a proxy that terminates TLS the
request Muzak sees is plain, so the proxy is the place to set it. A value the handler sets
replaces it.
To add includeSubDomains once every subdomain is on HTTPS, set the header yourself. Set
replaces the default:
func HSTS(maxAge time.Duration) muzak.Middleware {
value := "max-age=" + strconv.Itoa(int(maxAge.Seconds())) + "; includeSubDomains"
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil {
w.Header().Set("Strict-Transport-Security", value)
}
next.ServeHTTP(w, r)
})
}
}
app.Use(HSTS(365 * 24 * time.Hour))
The r.TLS != nil check keeps the header off a plain-HTTP response, where it means nothing
and where a redirect, which AppOptions.RedirectHTTPS sends, is the more useful answer. Behind
a terminating proxy the check has to read whatever header the proxy sets instead.
A host that also serves plain HTTP on another port, or may stop serving HTTPS, takes the header
off instead, because a browser pins the whole host along with it. Remove it in middleware
installed with App.Use, which runs after SecurityHeaders has set it, or send max-age=0:
app.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// This host also serves plain HTTP on another port, which a pinned
// browser could no longer reach.
w.Header().Del("Strict-Transport-Security")
next.ServeHTTP(w, r)
})
})
Development certificates
go run filippo.io/mkcert@latest -install
go run filippo.io/mkcert@latest localhost 127.0.0.1
ADDR=:8443
TLS_CERT_FILE=./localhost+1.pem
TLS_KEY_FILE=./localhost+1-key.pem
Tests need none of this. The test client serves the application on a loopback port in plain HTTP, whatever TLS is configured, so there is nothing to encrypt. See Testing.
Where to go next
Behind a Proxy covers running with TLS terminated in front of you, and Server Configuration covers the rest of the listener.