Skip to content

feat(service): require client certificates with --tls-client-ca-path - #53

Merged
mhenrixon merged 1 commit into
dashfrom
feature/mtls-client-certs
Jul 29, 2026
Merged

feat(service): require client certificates with --tls-client-ca-path#53
mhenrixon merged 1 commit into
dashfrom
feature/mtls-client-certs

Conversation

@mhenrixon

Copy link
Copy Markdown
Collaborator

Summary

Ports basecamp#204 (open upstream since April 2026, unmerged — there is no ClientCAs/ClientAuth anywhere in upstream/main). Enables Cloudflare Authenticated Origin Pulls: with a CA bundle configured, only clients holding a certificate that chains to it complete the handshake, so nobody reaches the origin by pointing at its IP.

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, resolved per-connection via GetConfigForClient, so services on one proxy can have different client certificate rules.

Wiring: --tls-client-ca-path (internal/cmd/deploy.go) → ServiceOptions.TLSClientCACertificatePathService.clientCAs loaded in initialize (internal/server/service.go) → Router.clientCAsForHost (internal/server/router.go) → Server.clientCertificateConfig on both the HTTPS and HTTP/3 listeners (internal/server/server.go). New internal/server/client_ca.go holds the PEM loading, keeping the fork-only surface localized.

Two departures from PR basecamp#204, both load-bearing

1. The upstream PR would have broken HTTP/2 on every mTLS host. Its createGetConfigForClient returns a fresh tls.Config carrying only GetCertificate, ClientAuth and ClientCAs. Go replaces the connection's config with whatever that returns, so an mTLS host loses NextProtos (h2, http/1.1, acme-tls/1) and MinVersion — silently downgrading to HTTP/1.1 and breaking tls-alpn-01 challenges. This clones the listener's config and sets only the two client-auth fields. TestServer_MutualTLS/still_negotiates_HTTP/2 is the regression guard.

2. Dropped its hello.ServerName != "" guard. An empty SNI resolves through serviceForHost("") to the catch-all service — the service that would actually serve that connection — so its client CA should apply.

Also: the CA bundle is read during deploy, not at handshake time. A bad path fails the deploy rather than bringing up a service that serves without the client verification the operator asked for.

Test plan

  • TestServer_MutualTLS — end-to-end through a real TLS listener: rejects no certificate, rejects a certificate from a different CA, accepts one from the configured CA, and still negotiates HTTP/2
  • TestServer_WithoutMutualTLSClientCertificatesAreNotRequired — no --tls-client-ca-path means nothing about the handshake changes
  • TestRouter_ClientCAsForHost — pool for an mTLS host, nil for a plain service, nil for an unclaimed host
  • TestRouter_ClientCAsForHost_OnDemandCatchAllCoversUnclaimedHosts — pins the catch-all consequence (see below)
  • TestServiceOptions_ClientCARequiresTLS — a CA path without --tls is rejected rather than silently ignored
  • TestService_UnloadableClientCAFailsDeploy — a missing bundle fails the deploy
  • TestService_ClientCASurvivesStateRoundTrip — new field round-trips; state files predating it restore with mTLS off
  • TestLoadClientCAs — missing file and no-PEM-found both name the path (the CLI only sees this string over net/rpc)
  • make test green; go test -race ./internal/server/ ./internal/cmd/ clean (902 tests); gofmt -l internal/ cmd/ and go vet ./... clean

Performance

GetConfigForClient is now invoked on every TLS handshake, not only mTLS ones, so the nil path matters as much as the clone. BenchmarkServer_ClientCertificateConfig is added; M2 Max, -count 3:

Path ns/op B/op allocs/op
host without a client CA (the new cost on existing handshakes) 24.3 0 0
host requiring client certificates (the config clone) ~219 480 1

No baseline column: neither path exists on dash, so these are the costs added, not a delta. Both are proxy-level and negligible against the handshake they sit inside — a TLS handshake is microseconds to milliseconds of asymmetric crypto and a round trip. The 480 B clone is paid only by hosts that actually require client certificates.

Refs #12

Deviations & judgment calls

  • Did not port createGetConfigForClient verbatim. See departure 1 above — it drops ALPN and MinVersion. This is the single most important thing to check in review; if you disagree with the clone approach, the alternative is enumerating the fields to copy, which rots the moment anyone adds one to the listener config.
  • Dropped the hello.ServerName != "" guard. See departure 2. Behavior difference from Add mutual TLS (mTLS) support basecamp/kamal-proxy#204: a no-SNI connection to a catch-all mTLS service now requires a client certificate. I believe that is correct, but it is a deliberate divergence.
  • A TLS catch-all turned out to be unreachable, which killed my first test. I wrote a test deploying a TLS service with no --host and a client CA; Validate() rejects it with "host must be set when using TLS". Only --tls-on-demand-url or --tls-domains-source can hold the catch-all binding with TLS on. Rewrote the test against the on-demand shape — the only reachable mTLS catch-all — and documented the consequence in the README: such a service's client CA governs every hostname no other service claims, including ones never named on the command line. Worth a second opinion; it is the surprising part of this change.
  • CA loading goes through initialize's error path, not the resolve* group. Service already has a resolveBasicAuth/resolveIPAllowList/resolveRateLimiter pattern for derived per-service state, which would have been the natural home — but those cannot fail. Silently skipping an unloadable CA would leave an origin open that the operator believes is locked down, so it sits next to createCertManager where it can fail the deploy.
  • Field named TLSClientCACertificatePath, matching Add mutual TLS (mTLS) support basecamp/kamal-proxy#204 exactly even though it is long, so that if upstream ever merges Add mutual TLS (mTLS) support basecamp/kamal-proxy#204 the conflict is as small as possible. The flag is --tls-client-ca-path, which matches both Add mutual TLS (mTLS) support basecamp/kamal-proxy#204 and issue R3: mTLS client certs (--tls-client-ca-path) #12 — the gem-facing contract is identical either way.
  • HTTP/3 gets GetConfigForClient too, as Add mutual TLS (mTLS) support basecamp/kamal-proxy#204 did, but is untested here. An mTLS-over-QUIC test needs an h3 client that presents certificates, and HTTP/3 is off by default. Flagging rather than silently skipping: if you want mTLS over h3 to be a supported claim, it needs a test I have not written.
  • No gem-side plumbing. Issue R3: mTLS client certs (--tls-client-ca-path) #12 says "pairs with gem-side plumbing" — that is a separate change in ../kamal, not attempted here.

Ports basecamp#204, which has been open upstream since April.
Enables Cloudflare Authenticated Origin Pulls: with a CA bundle configured, only
clients holding a certificate that chains to it can complete the handshake, so
nobody can reach the origin by pointing at its IP directly.

The requirement is per-service and resolved per-connection through
GetConfigForClient, so services on one proxy can have different client
certificate rules.

Two departures from basecamp#204, both load-bearing:

Its GetConfigForClient builds a fresh tls.Config holding only GetCertificate,
ClientAuth and ClientCAs. Go replaces the connection's config with whatever that
returns, so an mTLS host would lose NextProtos and MinVersion -- silently
downgrading to HTTP/1.1 and breaking tls-alpn-01 challenges. Clone the
listener's config and set only the two client-auth fields instead.

Its lookup is guarded by hello.ServerName != "". Dropped: an empty SNI resolves
to the catch-all service, which is the service that would serve the connection,
so its client CA should apply.

The CA bundle is read during deploy rather than at handshake time, so a bad path
fails the deploy instead of bringing up a service that serves without the client
verification it asked for.

Refs #12
@mhenrixon mhenrixon self-assigned this Jul 29, 2026
@mhenrixon mhenrixon added the enhancement New feature or request label Jul 29, 2026
@mhenrixon
mhenrixon merged commit 87e1a09 into dash Jul 29, 2026
2 checks passed
@mhenrixon
mhenrixon deleted the feature/mtls-client-certs branch July 29, 2026 13:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant