Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions echo/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ var (
tlsMaxVersion string
// TLS cipher suites (comma-separated list of cipher suite names)
tlsCipherSuites string
// CA certificates used to verify the client certificates (mTLS)
clientCAFile string
)

func InitFlag() {
Expand All @@ -110,6 +112,7 @@ func InitFlag() {
flag.StringVar(&tlsMinVersion, "web.tls-min-version", "", "Minimum TLS version (e.g., \"1.2\", \"1.3\")")
flag.StringVar(&tlsMaxVersion, "web.tls-max-version", "", "Maximum TLS version (e.g., \"1.2\", \"1.3\")")
flag.StringVar(&tlsCipherSuites, "web.tls-cipher-suites", "", "Comma-separated list of TLS cipher suite names")
flag.StringVar(&clientCAFile, "web.tls-client-ca-file", "", "The path to the CA certificates (PEM) used to verify the client certificates. When set, every client must present a certificate signed by one of them (mTLS), including the health probes. Only these CAs are trusted, not the system ones. It is reloaded when the file changes, but the established connections are not verified again.")
}

// parseTLSVersion converts a version string (e.g., "1.2", "1.3") to a tls.Version constant.
Expand Down Expand Up @@ -345,6 +348,8 @@ func (b *Builder) build() (*server, error) {
return nil, errors.New("--web.tls-key-file must be set when --web.tls-cert-file is set")
case cert == "" && key != "":
return nil, errors.New("--web.tls-cert-file must be set when --web.tls-key-file is set")
case cert == "" && clientCAFile != "":
return nil, errors.New("--web.tls-cert-file and --web.tls-key-file must be set when --web.tls-client-ca-file is set")
}
if len(b.addr) == 0 {
b.addr = addr
Expand Down Expand Up @@ -434,6 +439,7 @@ func (b *Builder) build() (*server, error) {
tlsMinVersion: parsedMinVersion,
tlsMaxVersion: parsedMaxVersion,
tlsCipherSuites: parsedCipherSuites,
clientCAFile: clientCAFile,
mdws: mdws,
preMDWs: b.preMDWs,
shutdownTimeout: 30 * time.Second,
Expand Down Expand Up @@ -479,6 +485,7 @@ type server struct {
tlsMinVersion uint16
tlsMaxVersion uint16
tlsCipherSuites []uint16
clientCAFile string
mdws []echo.MiddlewareFunc
preMDWs []echo.MiddlewareFunc
shutdownTimeout time.Duration
Expand Down Expand Up @@ -568,23 +575,43 @@ func (s *server) configureHTTPServer(httpServer *http.Server) {
}

// tlsConfig builds the TLS configuration of the HTTPS server.
// The certificate is provided by a reloader, so a rotated certificate is used without restarting the server.
// The certificate and the client CA are provided by reloaders, so they are rotated without restarting the server.
func (s *server) tlsConfig() (*tls.Config, error) {
reloader, err := newCertificateReloader(s.cert, s.key)
certReloader, err := newCertificateReloader(s.cert, s.key)
if err != nil {
return nil, err
}
tlsConfig := &tls.Config{
MinVersion: s.tlsMinVersion,
MaxVersion: s.tlsMaxVersion,
CipherSuites: s.tlsCipherSuites,
GetCertificate: reloader.GetCertificate,
MinVersion: s.tlsMinVersion,
MaxVersion: s.tlsMaxVersion,
CipherSuites: s.tlsCipherSuites,
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
return certReloader.get(), nil
},
}
if isHTTP2Compatible(s.tlsMinVersion, s.tlsCipherSuites) {
tlsConfig.NextProtos = []string{"h2"}
} else {
logrus.Warn("HTTP/2 is disabled because --web.tls-cipher-suites contains neither TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 nor TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, one of which HTTP/2 requires with TLS 1.2 (RFC 7540, section 9.2.2)")
}
if s.clientCAFile != "" {
clientCAReloader, caErr := newClientCAReloader(s.clientCAFile)
if caErr != nil {
return nil, caErr
}
tlsConfig.ClientAuth = tls.RequireAndVerifyClientCert
Comment thread
Nexucis marked this conversation as resolved.
Resolved
// Without ClientCAs, the client certificates would be verified with the system CAs, so the base configuration
// has them as well in case it is used without GetConfigForClient.
tlsConfig.ClientCAs = clientCAReloader.get()
// tls.Config.ClientCAs cannot be changed once the server started, so each handshake uses a copy of the
// configuration with the current client CA. The resumed sessions are verified again with it as well.
Comment thread
Nexucis marked this conversation as resolved.
Resolved
tlsConfig.GetConfigForClient = func(_ *tls.ClientHelloInfo) (*tls.Config, error) {
config := tlsConfig.Clone()
config.GetConfigForClient = nil
config.ClientCAs = clientCAReloader.get()
return config, nil
}
}
return tlsConfig, nil
}

Expand Down
126 changes: 124 additions & 2 deletions echo/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"crypto/tls"
"net"
"net/http"
"path/filepath"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -524,9 +525,15 @@ func startTLSServer(t *testing.T, certFile, keyFile, minVersion, cipherSuites st
require.NoError(t, task.Finalize())
})

// echo wraps the listener with TLS before serving, so the server is ready once the port is open. A TLS handshake
// would not work when a client CA is set with TLS 1.2, as the server rejects a client without certificate.
require.Eventually(t, func() bool {
_, dialErr := dialTLSServer(address, &tls.Config{})
return dialErr == nil
conn, dialErr := net.Dial("tcp", address)
if dialErr != nil {
return false
}
_ = conn.Close()
return true
}, 5*time.Second, 10*time.Millisecond)
return address
}
Expand Down Expand Up @@ -585,6 +592,112 @@ func TestServer_TLSCipherSuitesWithoutHTTP2(t *testing.T) {
require.Error(t, err)
}

func TestServer_TLSClientCA(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := writeCertificate(t, dir, 1)
// The client certificates are self-signed, so they are their own CA.
clientPEM, clientKeyPEM := generateCertificate(t, 2)
clientCertificate, err := tls.X509KeyPair(clientPEM, clientKeyPEM)
require.NoError(t, err)
otherClientPEM, otherClientKeyPEM := generateCertificate(t, 3)
otherClientCertificate, err := tls.X509KeyPair(otherClientPEM, otherClientKeyPEM)
require.NoError(t, err)
caFile := filepath.Join(dir, "ca.crt")
replaceFile(t, caFile, clientPEM)

origClientCAFile := clientCAFile
clientCAFile = caFile
defer func() {
clientCAFile = origClientCAFile
}()
address := startTLSServer(t, certFile, keyFile, "", "")

// An HTTP request is sent, as with TLS 1.3, the client completes the handshake before the server verifies its
// certificate: a rejected certificate is only reported when the client reads the response.
get := func(certificates ...tls.Certificate) error {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, Certificates: certificates},
DisableKeepAlives: true,
},
}
resp, reqErr := client.Get("https://" + address)
if reqErr != nil {
return reqErr
}
return resp.Body.Close()
}

// Only the clients presenting a certificate signed by the CA are accepted.
require.NoError(t, get(clientCertificate))
assert.ErrorContains(t, get(), "certificate required")
assert.ErrorContains(t, get(otherClientCertificate), "unknown certificate authority")

// The CA is rotated: the new connections are verified with the new one.
replaceFile(t, caFile, otherClientPEM)
require.NoError(t, get(otherClientCertificate))
assert.ErrorContains(t, get(clientCertificate), "unknown certificate authority")

// The new file is invalid: the previous CA is kept.
replaceFile(t, caFile, []byte("not a certificate"))
require.NoError(t, get(otherClientCertificate))
assert.ErrorContains(t, get(clientCertificate), "unknown certificate authority")
}

// A resumed session must not bypass the rotation of the client CA.
func TestServer_TLSClientCAResumption(t *testing.T) {
for _, version := range []uint16{tls.VersionTLS12, tls.VersionTLS13} {
t.Run(tls.VersionName(version), func(t *testing.T) {
dir := t.TempDir()
certFile, keyFile := writeCertificate(t, dir, 1)
clientPEM, clientKeyPEM := generateCertificate(t, 2)
clientCertificate, err := tls.X509KeyPair(clientPEM, clientKeyPEM)
require.NoError(t, err)
otherClientPEM, _ := generateCertificate(t, 3)
caFile := filepath.Join(dir, "ca.crt")
replaceFile(t, caFile, clientPEM)

origClientCAFile := clientCAFile
clientCAFile = caFile
defer func() {
clientCAFile = origClientCAFile
}()
address := startTLSServer(t, certFile, keyFile, "", "")

// Every connection uses the same TLS configuration, so the same session cache.
clientConfig := &tls.Config{
InsecureSkipVerify: true,
Certificates: []tls.Certificate{clientCertificate},
ClientSessionCache: tls.NewLRUClientSessionCache(1),
MaxVersion: version,
}
get := func() (*tls.ConnectionState, error) {
client := &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{TLSClientConfig: clientConfig, DisableKeepAlives: true},
}
resp, reqErr := client.Get("https://" + address)
if reqErr != nil {
return nil, reqErr
}
return resp.TLS, resp.Body.Close()
}

_, err = get()
require.NoError(t, err)
state, err := get()
require.NoError(t, err)
require.True(t, state.DidResume)

// The CA is rotated: the session is not resumed, and the full handshake fails.
replaceFile(t, caFile, otherClientPEM)
_, err = get()
assert.ErrorContains(t, err, "unknown certificate authority")
})
}
}

func TestBuild_PartialTLSConfiguration(t *testing.T) {
origHooks := logrus.StandardLogger().ReplaceHooks(make(logrus.LevelHooks))
t.Cleanup(func() {
Expand All @@ -596,6 +709,7 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) {
name string
certFile string
keyFile string
clientCAFile string
minVersion string
expectedError string
expectedWarning bool
Expand All @@ -618,6 +732,11 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) {
keyFile: "tls.key",
expectedError: "--web.tls-cert-file must be set when --web.tls-key-file is set",
},
{
name: "only the client CA is set",
clientCAFile: "ca.crt",
expectedError: "--web.tls-cert-file and --web.tls-key-file must be set when --web.tls-client-ca-file is set",
},
{
name: "TLS option without HTTPS",
minVersion: "1.2",
Expand All @@ -634,13 +753,16 @@ func TestBuild_PartialTLSConfiguration(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
origCert := cert
origKey := key
origClientCAFile := clientCAFile
origMinVersion := tlsMinVersion
cert = tt.certFile
key = tt.keyFile
clientCAFile = tt.clientCAFile
tlsMinVersion = tt.minVersion
defer func() {
cert = origCert
key = origKey
clientCAFile = origClientCAFile
tlsMinVersion = origMinVersion
}()
hook.Reset()
Expand Down
Loading
Loading