diff --git a/README.md b/README.md index 03d34de3..2768e868 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,18 @@ your certificate file and the corresponding private key: kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem +### Mutual TLS (mTLS) + +To require clients to present a certificate signed by a trusted CA, pass the CA +certificate via `--tls-client-ca-path`. Connections from clients without a valid +certificate are rejected at the TLS layer. + + kamal-proxy deploy service1 --target web-1:3000 --host app1.example.com --tls --tls-certificate-path cert.pem --tls-private-key-path key.pem --tls-client-ca-path ca.pem + +This can be used to implement [Cloudflare Authenticated Origin Pull](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/), +ensuring only Cloudflare can reach your origin. + + ## Specifying `run` options with environment variables In some environments, like when running a Docker container, it can be convenient diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index 01010c89..6f3b9b99 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -37,6 +37,7 @@ func newDeployCommand() *deployCommand { deployCommand.cmd.Flags().BoolVar(&deployCommand.tlsStaging, "tls-staging", false, "Use Let's Encrypt staging environment for certificate provisioning") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSCertificatePath, "tls-certificate-path", "", "Configure custom TLS certificate path (PEM format)") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSPrivateKeyPath, "tls-private-key-path", "", "Configure custom TLS private key path (PEM format)") + deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.TLSClientCACertificatePath, "tls-client-ca-path", "", "Path to CA certificate used to verify client certificates (mTLS, requires --tls)") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.ACMECachePath, "tls-acme-cache-path", globalConfig.CertificatePath(), "Location to store ACME assets") deployCommand.cmd.Flags().BoolVar(&deployCommand.args.ServiceOptions.TLSRedirect, "tls-redirect", true, "Redirect HTTP traffic to HTTPS") deployCommand.cmd.Flags().StringVar(&deployCommand.args.ServiceOptions.CanonicalHost, "canonical-host", "", "Redirect all requests to this host (e.g., force root or www)") diff --git a/internal/server/cert.go b/internal/server/cert.go index b762daee..3d1244bb 100644 --- a/internal/server/cert.go +++ b/internal/server/cert.go @@ -2,12 +2,17 @@ package server import ( "crypto/tls" + "crypto/x509" "errors" "log/slog" "net/http" + "os" ) -var ErrorUnableToLoadCertificate = errors.New("unable to load certificate") +var ( + ErrorUnableToLoadCertificate = errors.New("unable to load certificate") + ErrorUnableToLoadClientCACertificate = errors.New("unable to load client CA certificate") +) type CertManager interface { GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) @@ -38,3 +43,17 @@ func (m *StaticCertManager) GetCertificate(*tls.ClientHelloInfo) (*tls.Certifica func (m *StaticCertManager) HTTPHandler(handler http.Handler) http.Handler { return handler } + +func loadCACertPool(tlsClientCACertificateFilePath string) (*x509.CertPool, error) { + pemData, err := os.ReadFile(tlsClientCACertificateFilePath) + if err != nil { + slog.Error("Error loading client CA certificate", "path", tlsClientCACertificateFilePath, "error", err) + return nil, ErrorUnableToLoadClientCACertificate + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + slog.Error("Error parsing client CA certificate", "path", tlsClientCACertificateFilePath) + return nil, ErrorUnableToLoadClientCACertificate + } + return pool, nil +} diff --git a/internal/server/router.go b/internal/server/router.go index 051f0be8..eefaab87 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -3,6 +3,7 @@ package server import ( "context" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "log/slog" @@ -285,6 +286,14 @@ func (r *Router) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, e return service.certManager.GetCertificate(hello) } +func (r *Router) clientCACertPool(hostname string) *x509.CertPool { + service := r.serviceForHost(hostname) + if service == nil { + return nil + } + return service.clientCACertPool +} + // Private func (r *Router) createOrUpdateService(name string, options ServiceOptions, targetOptions TargetOptions) (*Service, error) { diff --git a/internal/server/server.go b/internal/server/server.go index 4b336de3..2bbdafa3 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 + } +} + func (s *Server) buildHandler() http.Handler { var handler http.Handler diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 4792d3de..2cb84dc7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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) + }) +} + // 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} +} diff --git a/internal/server/service.go b/internal/server/service.go index 56953137..109a6956 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -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) +} + func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) { var err error var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget)