-
Notifications
You must be signed in to change notification settings - Fork 87
Add mutual TLS (mTLS) support #204
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -120,9 +120,10 @@ func (s *Server) startHTTP3Server(handler http.Handler, httpsAddr string) error | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.http3Server = &http3.Server{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Handler: handler, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TLSConfig: &tls.Config{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MinVersion: tls.VersionTLS13, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NextProtos: []string{"h3"}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetCertificate: s.router.GetCertificate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| MinVersion: tls.VersionTLS13, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NextProtos: []string{"h3"}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetCertificate: s.router.GetCertificate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetConfigForClient: s.createGetConfigForClient(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -149,6 +150,7 @@ func (s *Server) startHTTPServers() error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return err | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.httpsListener = httpsListener | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| s.httpsServer = &http.Server{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -159,9 +161,10 @@ func (s *Server) startHTTPServers() error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| handler.ServeHTTP(w, r) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| TLSConfig: &tls.Config{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetCertificate: s.router.GetCertificate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetCertificate: s.router.GetCertificate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetConfigForClient: s.createGetConfigForClient(), | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| go s.httpServer.Serve(s.httpListener) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -211,6 +214,21 @@ func (s *Server) startCommandHandler() error { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return s.commandHandler.Start(s.config.SocketPath()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (s *Server) createGetConfigForClient() func(*tls.ClientHelloInfo) (*tls.Config, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return func(hello *tls.ClientHelloInfo) (*tls.Config, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if hello.ServerName != "" { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if pool := s.router.clientCACertPool(hello.ServerName); pool != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return &tls.Config{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| GetCertificate: s.router.GetCertificate, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ClientAuth: tls.RequireAndVerifyClientCert, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ClientCAs: pool, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+219
to
+228
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if hello.ServerName != "" { | |
| if pool := s.router.clientCACertPool(hello.ServerName); pool != nil { | |
| return &tls.Config{ | |
| GetCertificate: s.router.GetCertificate, | |
| ClientAuth: tls.RequireAndVerifyClientCert, | |
| ClientCAs: pool, | |
| }, nil | |
| } | |
| } | |
| return nil, nil | |
| if hello.ServerName == "" { | |
| return nil, nil | |
| } | |
| pool := s.router.clientCACertPool(hello.ServerName) | |
| if pool == nil { | |
| return nil, nil | |
| } | |
| if s.httpsServer == nil || s.httpsServer.TLSConfig == nil { | |
| return nil, nil | |
| } | |
| config := s.httpsServer.TLSConfig.Clone() | |
| config.GetConfigForClient = nil | |
| config.ClientAuth = tls.RequireAndVerifyClientCert | |
| config.ClientCAs = pool | |
| return config, nil |
Copilot
AI
Apr 17, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
mTLS is only applied when hello.ServerName != "". However Router.GetCertificate already falls back to defaultTLSHostname() when SNI is missing; with the current logic, a client can omit SNI and bypass client-cert verification while still being served a certificate/route via the default hostname. Consider applying the same default-hostname fallback (or otherwise enforcing a safe default) when selecting the client CA pool.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,21 @@ | ||
| package server | ||
|
|
||
| import ( | ||
| "crypto/ecdsa" | ||
| "crypto/elliptic" | ||
| "crypto/rand" | ||
| "crypto/tls" | ||
| "crypto/x509" | ||
| "crypto/x509/pkix" | ||
| "encoding/pem" | ||
| "fmt" | ||
| "math/big" | ||
| "net" | ||
| "net/http" | ||
| "os" | ||
| "path/filepath" | ||
| "testing" | ||
| "time" | ||
|
|
||
| "github.com/quic-go/quic-go/http3" | ||
| "github.com/stretchr/testify/assert" | ||
|
|
@@ -103,6 +113,52 @@ func TestServer_DeployingHTTPS(t *testing.T) { | |
| }) | ||
| } | ||
|
|
||
| func TestServer_DeployingHTTPSWithClientCA(t *testing.T) { | ||
| ca := generateTestCA(t) | ||
| target := testTarget(t, func(w http.ResponseWriter, r *http.Request) {}) | ||
| server := testServer(t, false) | ||
|
|
||
| certPath, keyPath := prepareTestCertificateFiles(t) | ||
| serviceOptions := defaultServiceOptions | ||
| serviceOptions.TLSEnabled = true | ||
| serviceOptions.TLSCertificatePath = certPath | ||
| serviceOptions.TLSPrivateKeyPath = keyPath | ||
| serviceOptions.Hosts = []string{"localhost"} | ||
| serviceOptions.TLSClientCACertificatePath = ca.certPath | ||
|
|
||
| testDeployTarget(t, target, server, serviceOptions) | ||
|
|
||
| t.Run("rejects request without client certificate", func(t *testing.T) { | ||
| transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} | ||
| _, err := (&http.Client{Transport: transport}).Get(fmt.Sprintf("https://localhost:%d/", server.HttpsPort())) | ||
| assert.Error(t, err) | ||
| }) | ||
|
|
||
| t.Run("rejects client certificate from unknown CA", func(t *testing.T) { | ||
| wrongCA := generateTestCA(t) | ||
| transport := &http.Transport{ | ||
| TLSClientConfig: &tls.Config{ | ||
| InsecureSkipVerify: true, | ||
| Certificates: []tls.Certificate{wrongCA.clientCert}, | ||
| }, | ||
| } | ||
| _, err := (&http.Client{Transport: transport}).Get(fmt.Sprintf("https://localhost:%d/", server.HttpsPort())) | ||
| assert.Error(t, err) | ||
| }) | ||
|
|
||
| t.Run("accepts client certificate from trusted CA", func(t *testing.T) { | ||
| transport := &http.Transport{ | ||
| TLSClientConfig: &tls.Config{ | ||
| InsecureSkipVerify: true, | ||
| Certificates: []tls.Certificate{ca.clientCert}, | ||
| }, | ||
| } | ||
| resp, err := (&http.Client{Transport: transport}).Get(fmt.Sprintf("https://localhost:%d/", server.HttpsPort())) | ||
| require.NoError(t, err) | ||
| assert.Equal(t, http.StatusOK, resp.StatusCode) | ||
| }) | ||
|
Comment on lines
+156
to
+159
|
||
| } | ||
|
|
||
| // Helpers | ||
|
|
||
| func testDeployTarget(tb testing.TB, target *Target, server *Server, serviceOptions ServiceOptions) { | ||
|
|
@@ -162,3 +218,61 @@ func testRequestUsingTransport(server *Server, transport http.RoundTripper) (*ht | |
| uri := fmt.Sprintf("https://localhost:%d/", server.HttpsPort()) | ||
| return client.Get(uri) | ||
| } | ||
|
|
||
| type testCAFixture struct { | ||
| certPath string | ||
| clientCert tls.Certificate | ||
| } | ||
|
|
||
| func generateTestCA(t *testing.T) testCAFixture { | ||
| t.Helper() | ||
|
|
||
| caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| require.NoError(t, err) | ||
|
|
||
| caTemplate := &x509.Certificate{ | ||
| SerialNumber: big.NewInt(1), | ||
| Subject: pkix.Name{Organization: []string{"Test CA"}}, | ||
| NotBefore: time.Now().Add(-time.Hour), | ||
| NotAfter: time.Now().Add(time.Hour), | ||
| IsCA: true, | ||
| KeyUsage: x509.KeyUsageCertSign, | ||
| BasicConstraintsValid: true, | ||
| } | ||
|
|
||
| caDER, err := x509.CreateCertificate(rand.Reader, caTemplate, caTemplate, &caKey.PublicKey, caKey) | ||
| require.NoError(t, err) | ||
|
|
||
| caCert, err := x509.ParseCertificate(caDER) | ||
| require.NoError(t, err) | ||
|
|
||
| caPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}) | ||
| caPath := filepath.Join(t.TempDir(), "ca.pem") | ||
| require.NoError(t, os.WriteFile(caPath, caPEM, 0644)) | ||
|
|
||
| clientKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) | ||
| require.NoError(t, err) | ||
|
|
||
| clientTemplate := &x509.Certificate{ | ||
| SerialNumber: big.NewInt(2), | ||
| Subject: pkix.Name{Organization: []string{"Test Client"}}, | ||
| NotBefore: time.Now().Add(-time.Hour), | ||
| NotAfter: time.Now().Add(time.Hour), | ||
| KeyUsage: x509.KeyUsageDigitalSignature, | ||
| ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, | ||
| } | ||
|
|
||
| clientDER, err := x509.CreateCertificate(rand.Reader, clientTemplate, caCert, &clientKey.PublicKey, caKey) | ||
| require.NoError(t, err) | ||
|
|
||
| clientKeyDER, err := x509.MarshalECPrivateKey(clientKey) | ||
| require.NoError(t, err) | ||
|
|
||
| clientCertPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: clientDER}) | ||
| clientKeyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: clientKeyDER}) | ||
|
|
||
| clientTLSCert, err := tls.X509KeyPair(clientCertPEM, clientKeyPEM) | ||
| require.NoError(t, err) | ||
|
|
||
| return testCAFixture{certPath: caPath, clientCert: clientTLSCert} | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ package server | |
|
|
||
| import ( | ||
| "crypto/sha256" | ||
| "crypto/x509" | ||
| "encoding/hex" | ||
| "encoding/json" | ||
| "errors" | ||
|
|
@@ -83,6 +84,7 @@ type ServiceOptions struct { | |
| TLSEnabled bool `json:"tls_enabled"` | ||
| TLSCertificatePath string `json:"tls_certificate_path"` | ||
| TLSPrivateKeyPath string `json:"tls_private_key_path"` | ||
| TLSClientCACertificatePath string `json:"tls_client_ca_certificate_path"` | ||
| TLSRedirect bool `json:"tls_redirect"` | ||
| CanonicalHost string `json:"canonical_host"` | ||
| ACMEDirectory string `json:"acme_directory"` | ||
|
|
@@ -135,8 +137,9 @@ type Service struct { | |
| pauseController *PauseController | ||
| rolloutController *RolloutController | ||
|
|
||
| certManager CertManager | ||
| middleware http.Handler | ||
| certManager CertManager | ||
| clientCACertPool *x509.CertPool | ||
| middleware http.Handler | ||
| } | ||
|
|
||
| func NewService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { | ||
|
|
@@ -335,6 +338,11 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions | |
| return err | ||
| } | ||
|
|
||
| caPool, err := s.createClientCACertPool(options) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| middleware, err := s.createMiddleware(options, certManager) | ||
| if err != nil { | ||
| return err | ||
|
|
@@ -343,6 +351,7 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions | |
| s.options = options | ||
| s.targetOptions = targetOptions | ||
| s.certManager = certManager | ||
| s.clientCACertPool = caPool | ||
| s.middleware = middleware | ||
|
|
||
| return nil | ||
|
|
@@ -400,6 +409,14 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) | |
| }, nil | ||
| } | ||
|
|
||
| func (s *Service) createClientCACertPool(options ServiceOptions) (*x509.CertPool, error) { | ||
| if !options.TLSEnabled || options.TLSClientCACertificatePath == "" { | ||
| return nil, nil | ||
| } | ||
|
|
||
| return loadCACertPool(options.TLSClientCACertificatePath) | ||
| } | ||
|
Comment on lines
+412
to
+418
|
||
|
|
||
| func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) { | ||
| var err error | ||
| var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
TLSConfigliteral instartHTTPServersis indented with spaces (looks like it may have skippedgofmt). Please rungofmt/fix indentation to keep the file consistent and avoid noisy diffs in future changes.