Add mutual TLS (mTLS) support#204
Conversation
Adds a new --tls-client-ca-path flag to the deploy command that enables mutual TLS (mTLS). When set, clients must present a certificate signed by the specified CA, otherwise the connection is rejected at the TLS layer. Certificate verification is applied per-connection using GetConfigForClient, so different services on the same host can have independent mTLS requirements. Motivated by kamal/issues/1628 — enables Cloudflare Authenticated Origin Pull, ensuring only Cloudflare can reach kamal proxy.
There was a problem hiding this comment.
Pull request overview
Adds optional mutual TLS (mTLS) enforcement per service by introducing a new deploy-time flag (--tls-client-ca-path) that configures a client CA used to verify incoming client certificates during the TLS handshake.
Changes:
- Introduces
TLSClientCACertificatePathinServiceOptionsand loads a per-service client CA pool. - Uses
tls.Config.GetConfigForClientto enforce mTLS on a per-host (SNI) basis for both HTTPS and HTTP/3 listeners. - Adds documentation and a server test covering mTLS accept/reject behavior.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/server/service.go | Stores a per-service client CA pool derived from the new option. |
| internal/server/server.go | Adds GetConfigForClient to dynamically enable client cert verification per connection. |
| internal/server/router.go | Exposes the per-service client CA pool lookup by hostname. |
| internal/server/cert.go | Adds helper to load a CA cert pool from PEM. |
| internal/cmd/deploy.go | Adds --tls-client-ca-path flag wiring to ServiceOptions. |
| internal/server/server_test.go | Adds an mTLS-focused integration test and CA fixture generation. |
| README.md | Documents mTLS usage and Cloudflare Authenticated Origin Pull example. |
[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or rungh pr ready --undo.
Click "Ready for review" or rungh pr readyto reengage.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| func (s *Service) createClientCACertPool(options ServiceOptions) (*x509.CertPool, error) { | ||
| if !options.TLSEnabled || options.TLSClientCACertificatePath == "" { | ||
| return nil, nil | ||
| } | ||
|
|
||
| return loadCACertPool(options.TLSClientCACertificatePath) | ||
| } |
There was a problem hiding this comment.
Enabling TLSClientCACertificatePath while using the automatic ACME cert manager will require a client certificate for the TLS-ALPN-01 validation handshake, which Let's Encrypt (and similar ACME CAs) won't present. This likely makes initial issuance/renewal fail. Either validate and reject this configuration (require a static cert when mTLS is enabled) or explicitly bypass client-cert requirements for acme.ALPNProto handshakes.
| 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) | ||
| }) |
There was a problem hiding this comment.
The successful mTLS test case doesn't close resp.Body. Please defer resp.Body.Close() (and ideally drain the body) to avoid leaking resources in tests, especially when running many tests in parallel.
| 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 |
There was a problem hiding this comment.
GetConfigForClient returns a brand-new tls.Config that only sets GetCertificate/ClientAuth/ClientCAs. This replaces (not merges with) the base config, so it drops critical fields like NextProtos and MinVersion (HTTP/3 requires TLS 1.3 + ALPN "h3"; HTTPS currently also advertises h2 + ACME ALPN). Please clone/derive from the existing TLSConfig and only mutate the client-auth-related fields so protocol negotiation and ACME continue to work.
| 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 |
| 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 | ||
| } |
There was a problem hiding this comment.
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.
| NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, | ||
| GetCertificate: s.router.GetCertificate, | ||
| GetConfigForClient: s.createGetConfigForClient(), | ||
| }, |
There was a problem hiding this comment.
The TLSConfig literal in startHTTPServers is indented with spaces (looks like it may have skipped gofmt). Please run gofmt/fix indentation to keep the file consistent and avoid noisy diffs in future changes.
| NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, | |
| GetCertificate: s.router.GetCertificate, | |
| GetConfigForClient: s.createGetConfigForClient(), | |
| }, | |
| NextProtos: []string{"h2", "http/1.1", acme.ALPNProto}, | |
| GetCertificate: s.router.GetCertificate, | |
| GetConfigForClient: s.createGetConfigForClient(), | |
| }, |
Adds a new
--tls-client-ca-pathflag to the deploy command that enables mutual TLS (mTLS).When set, clients must present a certificate signed by the specified CA, otherwise the connection is rejected at the TLS layer.
Certificate verification is applied per-connection using GetConfigForClient, so different services on the same host can have independent mTLS requirements.
Motivated by basecamp/kamal#1628 this feature enables Cloudflare Authenticated Origin Pull, ensuring only Cloudflare can reach kamal proxy.
How to test?
Clone the project in your server and checkout this branch.
I have used the
example/docker-compose.ymlwith a couple of customisations to mount the certs and change ports:Download the Cloudflare authenticated origin pull certificate and also copy your TLS certificate to the certs folder.
So your certs folder looks like:
Then run
docker composer up --buildDeploy a new service with new
--tls-client-ca-pathargumentIf you try to bypass Cloudflare by pointing to the server IP directly, the request fails
If this change is accepted, I can work on integrating this into https://github.com/basecamp/kamal