Skip to content

Add mutual TLS (mTLS) support#204

Open
darenas31415 wants to merge 1 commit intobasecamp:mainfrom
darenas31415:mtls-support
Open

Add mutual TLS (mTLS) support#204
darenas31415 wants to merge 1 commit intobasecamp:mainfrom
darenas31415:mtls-support

Conversation

@darenas31415
Copy link
Copy Markdown

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 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.yml with a couple of customisations to mount the certs and change ports:

services:
  proxy:
    build: ../
    ports:
      - "80:80"
      - "443:443"
    restart: unless-stopped
    command: [ "kamal-proxy", "run", "--debug" ]
    volumes:
      - ./certs:/certs:ro

Download the Cloudflare authenticated origin pull certificate and also copy your TLS certificate to the certs folder.

So your certs folder looks like:

~/kamal-proxy/example$ ls certs
authenticated_origin_pull_ca.pem  cert.pem  key.pem

Then run docker composer up --build

Deploy a new service with new --tls-client-ca-path argument

docker compose exec proxy kamal-proxy deploy service1 \
 --target example-web-1 \
 --host app.example.com \
 --tls \
 --tls-certificate-path /certs/cert.pem \
 --tls-private-key-path /certs/key.pem \
 --tls-client-ca-path /certs/authenticated_origin_pull_ca.pem

If you try to bypass Cloudflare by pointing to the server IP directly, the request fails

curl -vk --resolve app.example.com:443:203.0.113.1 https://app.example.com/
* Added app.example.com:443:203.0.113.1 to DNS cache
* Hostname app.example.com was found in DNS cache
*   Trying 203.0.113.1:443...
* Connected to app.example.com (203.0.113.1) port 443
* ALPN: curl offers h2,http/1.1
* (304) (OUT), TLS handshake, Client hello (1):
* (304) (IN), TLS handshake, Server hello (2):
* (304) (IN), TLS handshake, Unknown (8):
* (304) (IN), TLS handshake, Request CERT (13):
* (304) (IN), TLS handshake, Certificate (11):
* (304) (IN), TLS handshake, CERT verify (15):
* (304) (IN), TLS handshake, Finished (20):
* (304) (OUT), TLS handshake, Certificate (11):
* (304) (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / AEAD-CHACHA20-POLY1305-SHA256 / [blank] / UNDEF
* ALPN: server accepted h2
* Server certificate:
*  subject: O=CloudFlare, Inc.; OU=CloudFlare Origin CA; CN=CloudFlare Origin Certificate
*  start date: Jan  1 00:00:00 2024 GMT
*  expire date: Jan  1 00:00:00 2039 GMT
*  issuer: C=US; O=CloudFlare, Inc.; OU=CloudFlare Origin SSL Certificate Authority; L=San Francisco; ST=California
*  SSL certificate verify result: unable to get local issuer certificate (20), continuing anyway.
* using HTTP/2
...
* LibreSSL SSL_read: error:1404C45C:SSL routines:ST_OK:reason(1116), errno 0
* Failed receiving HTTP2 data: 56(Failure when receiving data from the peer)
curl: (56) LibreSSL SSL_read: error:1404C45C:SSL routines:ST_OK:reason(1116), errno 0

If this change is accepted, I can work on integrating this into https://github.com/basecamp/kamal

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.
Copilot AI review requested due to automatic review settings April 17, 2026 21:10
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 TLSClientCACertificatePath in ServiceOptions and loads a per-service client CA pool.
  • Uses tls.Config.GetConfigForClient to 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 run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +412 to +418
func (s *Service) createClientCACertPool(options ServiceOptions) (*x509.CertPool, error) {
if !options.TLSEnabled || options.TLSClientCACertificatePath == "" {
return nil, nil
}

return loadCACertPool(options.TLSClientCACertificatePath)
}
Copy link

Copilot AI Apr 17, 2026

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.
Comment on lines +156 to +159
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)
})
Copy link

Copilot AI Apr 17, 2026

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.
Comment thread internal/server/server.go
Comment on lines +219 to +228
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
Copy link

Copilot AI Apr 17, 2026

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 thread internal/server/server.go
Comment on lines +218 to +229
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
}
Copy link

Copilot AI Apr 17, 2026

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.
Comment thread internal/server/server.go
Comment on lines +164 to +167
NextProtos: []string{"h2", "http/1.1", acme.ALPNProto},
GetCertificate: s.router.GetCertificate,
GetConfigForClient: s.createGetConfigForClient(),
},
Copy link

Copilot AI Apr 17, 2026

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants