diff --git a/README.md b/README.md index 49b2846..10a3221 100644 --- a/README.md +++ b/README.md @@ -418,6 +418,26 @@ 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 that clients present a certificate, pass a PEM bundle of the +certificate authorities they must chain to with `--tls-client-ca-path`. +Connections that present no certificate, or one signed by any other authority, +are rejected during the TLS handshake: + + 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 + +The requirement is per-service and applies to the hosts that service serves, so +services on the same proxy can have different client certificate rules. This is +how you enable [Cloudflare Authenticated Origin +Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/), +ensuring only Cloudflare can reach your origin. + +Note that a service deployed without `--host` — one using `--tls-on-demand-url` +or `--tls-domains-source` — serves every hostname no other service claims, so its +client CA applies to all of them. + + ### SAN Certificate Batching When started with `--acme-email` (or the `ACME_EMAIL` environment variable), diff --git a/internal/cmd/deploy.go b/internal/cmd/deploy.go index ec12a9a..f9a1322 100644 --- a/internal/cmd/deploy.go +++ b/internal/cmd/deploy.go @@ -44,6 +44,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", "", "Require client certificates signed by this CA bundle (mTLS; PEM format)") 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.TLSDomainsSource, "tls-domains-source", "", "Fetch additional TLS domains from this endpoint (path resolved against the service, or absolute URL)") diff --git a/internal/server/client_ca.go b/internal/server/client_ca.go new file mode 100644 index 0000000..46ebbfb --- /dev/null +++ b/internal/server/client_ca.go @@ -0,0 +1,27 @@ +package server + +import ( + "crypto/x509" + "errors" + "fmt" + "os" +) + +var ErrorUnableToLoadClientCACertificate = errors.New("unable to load client CA certificate") + +// loadClientCAs reads a PEM bundle of certificate authorities used to verify +// client certificates (mTLS). The error names the path, because a deploy failure +// reaches the CLI as a bare string over net/rpc. +func loadClientCAs(path string) (*x509.CertPool, error) { + pemData, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w %q: %w", ErrorUnableToLoadClientCACertificate, path, err) + } + + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pemData) { + return nil, fmt.Errorf("%w %q: no PEM certificates found", ErrorUnableToLoadClientCACertificate, path) + } + + return pool, nil +} diff --git a/internal/server/client_ca_test.go b/internal/server/client_ca_test.go new file mode 100644 index 0000000..d8135e8 --- /dev/null +++ b/internal/server/client_ca_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type testCA struct { + certPath string + cert *x509.Certificate + key *ecdsa.PrivateKey +} + +// generateTestCA writes a self-signed CA to a temp file and returns it, so tests +// can both point --tls-client-ca-path at it and mint client certificates under +// it. Nothing here touches the network. +func generateTestCA(t *testing.T) *testCA { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{Organization: []string{"kamal-proxy test CA"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IsCA: true, + KeyUsage: x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + + certPath := filepath.Join(t.TempDir(), "ca.pem") + require.NoError(t, os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0644)) + + return &testCA{certPath: certPath, cert: cert, key: key} +} + +// issueClientCertificate mints a client certificate signed by the CA. +func (ca *testCA) issueClientCertificate(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{Organization: []string{"kamal-proxy test client"}}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, template, ca.cert, &key.PublicKey, ca.key) + require.NoError(t, err) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + clientCert, err := tls.X509KeyPair(certPEM, keyPEM) + require.NoError(t, err) + + return clientCert +} + +func TestLoadClientCAs(t *testing.T) { + ca := generateTestCA(t) + + t.Run("loads a PEM bundle", func(t *testing.T) { + pool, err := loadClientCAs(ca.certPath) + require.NoError(t, err) + assert.NotNil(t, pool) + }) + + t.Run("reports a missing file", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.pem") + + _, err := loadClientCAs(path) + require.ErrorIs(t, err, ErrorUnableToLoadClientCACertificate) + // The CLI only sees this string over RPC, so it has to name the file. + assert.Contains(t, err.Error(), path) + }) + + t.Run("reports a file holding no certificates", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "garbage.pem") + require.NoError(t, os.WriteFile(path, []byte("not a certificate"), 0644)) + + _, err := loadClientCAs(path) + require.ErrorIs(t, err, ErrorUnableToLoadClientCACertificate) + assert.Contains(t, err.Error(), path) + }) +} diff --git a/internal/server/mtls_test.go b/internal/server/mtls_test.go new file mode 100644 index 0000000..883acbe --- /dev/null +++ b/internal/server/mtls_test.go @@ -0,0 +1,261 @@ +package server + +import ( + "crypto/tls" + "crypto/x509" + "encoding/json" + "net/http" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testMutualTLSServer(t *testing.T, ca *testCA, hosts []string) *Server { + t.Helper() + + target := testTarget(t, func(w http.ResponseWriter, r *http.Request) {}) + server := testServer(t, false) + + certPath, keyPath := prepareTestCertificateFiles(t) + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = hosts + serviceOptions.TLSEnabled = true + serviceOptions.TLSCertificatePath = certPath + serviceOptions.TLSPrivateKeyPath = keyPath + serviceOptions.TLSClientCACertificatePath = ca.certPath + + testDeployTarget(t, target, server, serviceOptions) + return server +} + +func testRequestWithClientCertificate(tb testing.TB, server *Server, certs []tls.Certificate, forceHTTP2 bool) (*http.Response, error) { + tb.Helper() + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + Certificates: certs, + }, + ForceAttemptHTTP2: forceHTTP2, + } + + return testRequestUsingTransport(server, transport) +} + +func TestServer_MutualTLS(t *testing.T) { + ca := generateTestCA(t) + server := testMutualTLSServer(t, ca, []string{"localhost"}) + + t.Run("rejects a client that presents no certificate", func(t *testing.T) { + _, err := testRequestUsingHTTP11(t, server) + assert.Error(t, err) + }) + + t.Run("rejects a certificate signed by a different CA", func(t *testing.T) { + other := generateTestCA(t) + + _, err := testRequestWithClientCertificate(t, server, []tls.Certificate{other.issueClientCertificate(t)}, false) + assert.Error(t, err) + }) + + t.Run("accepts a certificate signed by the configured CA", func(t *testing.T) { + resp, err := testRequestWithClientCertificate(t, server, []tls.Certificate{ca.issueClientCertificate(t)}, false) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + }) + + // Requiring client certificates swaps in a different tls.Config for the + // connection. Building that config from scratch rather than cloning the + // listener's would silently drop ALPN, downgrading every mTLS host to + // HTTP/1.1 and breaking tls-alpn-01 challenges. + t.Run("still negotiates HTTP/2", func(t *testing.T) { + resp, err := testRequestWithClientCertificate(t, server, []tls.Certificate{ca.issueClientCertificate(t)}, true) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "HTTP/2.0", resp.Proto) + }) +} + +// Without --tls-client-ca-path nothing about the handshake changes. +func TestServer_WithoutMutualTLSClientCertificatesAreNotRequired(t *testing.T) { + target := testTarget(t, func(w http.ResponseWriter, r *http.Request) {}) + server := testServer(t, false) + + certPath, keyPath := prepareTestCertificateFiles(t) + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"localhost"} + serviceOptions.TLSEnabled = true + serviceOptions.TLSCertificatePath = certPath + serviceOptions.TLSPrivateKeyPath = keyPath + + testDeployTarget(t, target, server, serviceOptions) + + resp, err := testRequestUsingHTTP11(t, server) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestRouter_ClientCAsForHost(t *testing.T) { + ca := generateTestCA(t) + + t.Run("returns the pool for a host the service serves", func(t *testing.T) { + router := testRouter(t) + _, target := testBackend(t, "first", http.StatusOK) + + certPath, keyPath := prepareTestCertificateFiles(t) + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"app.example.com"} + serviceOptions.TLSEnabled = true + serviceOptions.TLSCertificatePath = certPath + serviceOptions.TLSPrivateKeyPath = keyPath + serviceOptions.TLSClientCACertificatePath = ca.certPath + + require.NoError(t, router.DeployService("mtls", []string{target}, defaultEmptyReaders, + serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + + assert.NotNil(t, router.clientCAsForHost("app.example.com")) + }) + + t.Run("returns nil for a service without a client CA", func(t *testing.T) { + router := testRouter(t) + _, target := testBackend(t, "first", http.StatusOK) + + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"plain.example.com"} + + require.NoError(t, router.DeployService("plain", []string{target}, defaultEmptyReaders, + serviceOptions, defaultTargetOptions, defaultDeploymentOptions)) + + assert.Nil(t, router.clientCAsForHost("plain.example.com")) + }) + + t.Run("returns nil when no service claims the host", func(t *testing.T) { + router := testRouter(t) + + assert.Nil(t, router.clientCAsForHost("nobody.example.com")) + }) +} + +// Only an on-demand (or dynamic-domains) service can hold the catch-all binding +// with TLS on -- validation demands a host otherwise. Combined with a client CA +// that is the Cloudflare origin pull shape: one origin, every host behind it. +// The consequence worth pinning is that such a service's client CA governs every +// hostname no other service claims, including ones never named on the command +// line. +func TestRouter_ClientCAsForHost_OnDemandCatchAllCoversUnclaimedHosts(t *testing.T) { + ca := generateTestCA(t) + + router := testRouter(t) + _, catchAllTarget := testBackend(t, "catch-all", http.StatusOK) + _, namedTarget := testBackend(t, "named", http.StatusOK) + + catchAllOptions := defaultServiceOptions + catchAllOptions.TLSEnabled = true + catchAllOptions.TLSOnDemandURL = "/ask" + catchAllOptions.ACMECachePath = t.TempDir() + catchAllOptions.TLSClientCACertificatePath = ca.certPath + + require.NoError(t, router.DeployService("catchall", []string{catchAllTarget}, defaultEmptyReaders, + catchAllOptions, defaultTargetOptions, defaultDeploymentOptions)) + + namedOptions := defaultServiceOptions + namedOptions.Hosts = []string{"named.example.com"} + + require.NoError(t, router.DeployService("named", []string{namedTarget}, defaultEmptyReaders, + namedOptions, defaultTargetOptions, defaultDeploymentOptions)) + + assert.NotNil(t, router.clientCAsForHost("anything.example.com"), + "the catch-all service's client CA governs hosts nobody else claims") + assert.Nil(t, router.clientCAsForHost("named.example.com"), + "a host-scoped service is unaffected by the catch-all's client CA") +} + +// GetConfigForClient runs on every TLS handshake now, not just mTLS ones, so the +// cost of the nil path matters as much as the clone. +func BenchmarkServer_ClientCertificateConfig(b *testing.B) { + router := NewRouter(filepath.Join(b.TempDir(), "state.json")) + router.services.Set(&Service{ + name: "plain", + options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"plain.example.com"}}), + }) + router.services.Set(&Service{ + name: "mtls", + options: normalizedServiceOptions(ServiceOptions{Hosts: []string{"mtls.example.com"}}), + clientCAs: x509.NewCertPool(), + }) + + server := &Server{router: router} + base := &tls.Config{NextProtos: []string{"h2", "http/1.1"}} + getConfig := server.clientCertificateConfig(base) + + b.Run("host without client CA", func(b *testing.B) { + hello := &tls.ClientHelloInfo{ServerName: "plain.example.com"} + + b.ReportAllocs() + for b.Loop() { + _, _ = getConfig(hello) + } + }) + + b.Run("host requiring client certificates", func(b *testing.B) { + hello := &tls.ClientHelloInfo{ServerName: "mtls.example.com"} + + b.ReportAllocs() + for b.Loop() { + _, _ = getConfig(hello) + } + }) +} + +// A client CA without --tls is a misconfiguration that would otherwise be +// silently ignored, leaving an origin the operator believes is locked down open. +func TestServiceOptions_ClientCARequiresTLS(t *testing.T) { + options := defaultServiceOptions + options.Hosts = []string{"app.example.com"} + options.TLSClientCACertificatePath = "/etc/ca.pem" + + err := options.Validate() + require.ErrorIs(t, err, ErrServiceOptionsInvalid) + assert.Contains(t, err.Error(), "TLS must be enabled to require client certificates") +} + +// A CA path that cannot be loaded must fail the deploy rather than bring the +// service up without the client verification it asked for. +func TestService_UnloadableClientCAFailsDeploy(t *testing.T) { + router := testRouter(t) + _, target := testBackend(t, "first", http.StatusOK) + + certPath, keyPath := prepareTestCertificateFiles(t) + serviceOptions := defaultServiceOptions + serviceOptions.Hosts = []string{"app.example.com"} + serviceOptions.TLSEnabled = true + serviceOptions.TLSCertificatePath = certPath + serviceOptions.TLSPrivateKeyPath = keyPath + serviceOptions.TLSClientCACertificatePath = filepath.Join(t.TempDir(), "missing.pem") + + err := router.DeployService("mtls", []string{target}, defaultEmptyReaders, + serviceOptions, defaultTargetOptions, defaultDeploymentOptions) + require.ErrorIs(t, err, ErrorUnableToLoadClientCACertificate) +} + +// Old state files predate the field; they must restore with mTLS simply off. +func TestService_ClientCASurvivesStateRoundTrip(t *testing.T) { + ca := generateTestCA(t) + + options := defaultServiceOptions + options.Hosts = []string{"app.example.com"} + options.TLSClientCACertificatePath = ca.certPath + + encoded, err := json.Marshal(options) + require.NoError(t, err) + + var decoded ServiceOptions + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, ca.certPath, decoded.TLSClientCACertificatePath) + + var legacy ServiceOptions + require.NoError(t, json.Unmarshal([]byte(`{"hosts":["app.example.com"],"tls_enabled":true}`), &legacy)) + assert.Empty(t, legacy.TLSClientCACertificatePath) +} diff --git a/internal/server/router.go b/internal/server/router.go index 2adb016..134be8d 100644 --- a/internal/server/router.go +++ b/internal/server/router.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "crypto/tls" + "crypto/x509" "encoding/json" "errors" "log/slog" @@ -512,6 +513,18 @@ func (r *Router) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, e return service.certManager.GetCertificate(hello) } +// clientCAsForHost returns the certificate authorities that client certificates +// must chain to for the given host, or nil when the host's service does not +// require them. +func (r *Router) clientCAsForHost(host string) *x509.CertPool { + service := r.serviceForHost(host) + if service == nil { + return nil + } + + return service.clientCAs +} + // 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 27d398f..1942cdf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -208,15 +208,18 @@ func (s *Server) startHTTP3Server(handler http.Handler, httpsAddr string) error return err } + http3Config := &tls.Config{ + MinVersion: tls.VersionTLS13, + NextProtos: []string{"h3"}, + GetCertificate: s.router.GetCertificate, + } + http3Config.GetConfigForClient = s.clientCertificateConfig(http3Config) + s.http3Listener = http3Listener s.http3Server = &http3.Server{ Handler: handler, IdleTimeout: s.config.IdleTimeout, - TLSConfig: &tls.Config{ - MinVersion: tls.VersionTLS13, - NextProtos: []string{"h3"}, - GetCertificate: s.router.GetCertificate, - }, + TLSConfig: http3Config, } go s.http3Server.Serve(s.http3Listener) @@ -261,10 +264,12 @@ func (s *Server) startHTTPServers() error { handler.ServeHTTP(w, r) })) - s.httpsServer.TLSConfig = &tls.Config{ + httpsConfig := &tls.Config{ NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, GetCertificate: s.router.GetCertificate, } + httpsConfig.GetConfigForClient = s.clientCertificateConfig(httpsConfig) + s.httpsServer.TLSConfig = httpsConfig if s.config.ProxyProtocol { slog.Info("PROXY protocol enabled", "allow", s.config.ProxyProtocolAllowIPs) @@ -291,6 +296,34 @@ func (s *Server) startHTTPServers() error { return nil } +// clientCertificateConfig requires and verifies a client certificate for hosts +// whose service was deployed with --tls-client-ca-path, leaving every other host +// on the listener's own config. +// +// The returned config REPLACES the listener's for the connection, so it has to +// be a clone: building a fresh one carrying only the client-auth fields would +// drop ALPN and the minimum version, downgrading mTLS hosts to HTTP/1.1 and +// breaking tls-alpn-01 challenges. The clone is per-handshake, but only for +// hosts that actually require client certificates. +func (s *Server) clientCertificateConfig(base *tls.Config) func(*tls.ClientHelloInfo) (*tls.Config, error) { + return func(hello *tls.ClientHelloInfo) (*tls.Config, error) { + clientCAs := s.router.clientCAsForHost(hello.ServerName) + if clientCAs == nil { + return nil, nil + } + + config := base.Clone() + config.ClientAuth = tls.RequireAndVerifyClientCert + config.ClientCAs = clientCAs + // Already resolved for this connection; Go does not consult it again on + // the replacement config, and leaving it set invites a self-referential + // clone if that ever changes. + config.GetConfigForClient = nil + + return config, nil + } +} + func (s *Server) startMetricsServer() error { // Parsed before the disabled check, so a typo fails the boot rather than // waiting until someone turns metrics on. diff --git a/internal/server/service.go b/internal/server/service.go index 155bc40..968f692 100644 --- a/internal/server/service.go +++ b/internal/server/service.go @@ -3,6 +3,7 @@ package server import ( "context" "crypto/sha256" + "crypto/x509" "encoding/hex" "encoding/json" "errors" @@ -111,9 +112,13 @@ type ServiceOptions struct { ReadTargetsAcceptWebsockets bool `json:"read_targets_accept_websockets"` ExcludeMetricsPaths []string `json:"exclude_metrics_paths"` ClientIPHeader string `json:"client_ip_header"` - TLSDomainsSource string `json:"tls_domains_source,omitempty"` - TLSDomainsInterval time.Duration `json:"tls_domains_interval,omitempty"` - TLSDomainsBatchSize int `json:"tls_domains_batch_size,omitempty"` + // TLSClientCACertificatePath is a PEM bundle of certificate authorities that + // client certificates must chain to. Set, it turns on mTLS for the hosts this + // service serves; empty (the default) leaves the handshake untouched. + TLSClientCACertificatePath string `json:"tls_client_ca_certificate_path,omitempty"` + TLSDomainsSource string `json:"tls_domains_source,omitempty"` + TLSDomainsInterval time.Duration `json:"tls_domains_interval,omitempty"` + TLSDomainsBatchSize int `json:"tls_domains_batch_size,omitempty"` // InterceptErrorStatuses lists the response statuses the target itself may // return that should be replaced with the proxy's error pages. Empty (the @@ -172,6 +177,10 @@ func (so ServiceOptions) Validate() error { return fmt.Errorf("%w: TLS must be enabled to use a TLS on-demand URL", ErrServiceOptionsInvalid) } + if so.TLSClientCACertificatePath != "" && !so.TLSEnabled { + return fmt.Errorf("%w: TLS must be enabled to require client certificates", ErrServiceOptionsInvalid) + } + if so.TLSEnabled { if so.TLSOnDemandURL != "" { if so.HasConfiguredHosts() { @@ -270,6 +279,7 @@ type Service struct { sanCertManager *SANCertManager certManager CertManager + clientCAs *x509.CertPool middleware http.Handler basicAuth *basicAuthCredential allowedIPs *ipAllowList @@ -506,6 +516,13 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions return err } + // Loaded here rather than at handshake time so a bad path fails the deploy. + // Silently serving without client verification would be a security hole. + clientCAs, err := s.createClientCAs(options) + if err != nil { + return err + } + middleware, err := s.createMiddleware(options, targetOptions, certManager) if err != nil { return err @@ -514,6 +531,7 @@ func (s *Service) initialize(options ServiceOptions, targetOptions TargetOptions s.options = options s.targetOptions = targetOptions s.certManager = certManager + s.clientCAs = clientCAs s.middleware = middleware s.basicAuth = s.resolveBasicAuth(options) s.allowedIPs = s.resolveIPAllowList(options) @@ -614,6 +632,14 @@ func (s *Service) createCertManager(options ServiceOptions) (CertManager, error) }, nil } +func (s *Service) createClientCAs(options ServiceOptions) (*x509.CertPool, error) { + if options.TLSClientCACertificatePath == "" { + return nil, nil + } + + return loadClientCAs(options.TLSClientCACertificatePath) +} + func (s *Service) createHostPolicy(options ServiceOptions, certCache autocert.Cache) (autocert.HostPolicy, error) { if options.TLSOnDemandURL != "" { checker, err := newTLSOnDemandChecker(s, options.TLSOnDemandURL, certCache)