Skip to content
Merged
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
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down
27 changes: 27 additions & 0 deletions internal/server/client_ca.go
Original file line number Diff line number Diff line change
@@ -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
}
115 changes: 115 additions & 0 deletions internal/server/client_ca_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading