Skip to content
Open
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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
21 changes: 20 additions & 1 deletion internal/server/cert.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
9 changes: 9 additions & 0 deletions internal/server/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package server
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"log/slog"
Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 24 additions & 6 deletions internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}

Expand All @@ -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) {
Expand All @@ -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(),
},
Comment on lines +164 to +167

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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(),
},

Copilot uses AI. Check for mistakes.
}

go s.httpServer.Serve(s.httpListener)
Expand Down Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 uses AI. Check for mistakes.
}
Comment on lines +218 to +229

Copilot AI Apr 17, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.
}

func (s *Server) buildHandler() http.Handler {
var handler http.Handler

Expand Down
114 changes: 114 additions & 0 deletions internal/server/server_test.go
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"
Expand Down Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
}

// Helpers

func testDeployTarget(tb testing.TB, target *Target, server *Server, serviceOptions ServiceOptions) {
Expand Down Expand Up @@ -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}
}
21 changes: 19 additions & 2 deletions internal/server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package server

import (
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Copilot AI Apr 17, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.

func (s *Service) createMiddleware(options ServiceOptions, certManager CertManager) (http.Handler, error) {
var err error
var handler http.Handler = http.HandlerFunc(s.serviceRequestWithTarget)
Expand Down
Loading