Skip to content

cluster: encrypt and authenticate controller to replica connections - #37997

Draft
jasonhernandez wants to merge 8 commits into
MaterializeInc:mainfrom
jasonhernandez:jason/ctp-tls-wiring
Draft

cluster: encrypt and authenticate controller to replica connections#37997
jasonhernandez wants to merge 8 commits into
MaterializeInc:mainfrom
jasonhernandez:jason/ctp-tls-wiring

Conversation

@jasonhernandez

Copy link
Copy Markdown
Contributor

Motivation

PR 3 of 3. Turns on what #37995 (CTP mutual TLS support) and #37996 (internal secrets) built: controller↔replica traffic is encrypted and both ends authenticate each other.

Today CTP is plaintext bincode with no authentication. It carries user rows (peek results, SUBSCRIBE batches), and because a CTP server serves one client and cancels the incumbent on any new connect, anyone who can reach a replica's *ctl port can evict the controller and take over the command stream. The trust model is "the pod network is isolated," which stops holding once an operator can mirror ENI traffic.

Stacked on #37996 (which is stacked on #37995). Review those first; this branch merges both.

How it works

  • environmentd, behind --cluster-transport-tls, bootstraps a per-environment CA into an internal secret at startup and mints its own controller certificate.
  • The cluster controller mints a certificate per replica at ensure_replica_location time, writes it to the replica's internal secret, and passes --ctp-tls-secret=<name> to clusterd.
  • clusterd reads the credentials through the SecretsReader it already links and requires mutual TLS on both controller listeners.
  • Dropping a replica deletes its secret.

Identity is the replica's service name in the certificate SAN. The controller verifies it reached the intended replica; the replica verifies the peer holds a controller certificate from the same environment. Distribution rides the machinery that already exists for user secrets: no cert-manager, no operator changes, no new RBAC.

No shell required, by construction

This is meant to hold up the other half of the distroless bargain, so the credential lifecycle never needs an operator inside a container:

  • Nothing out of process. CA generation, issuance, and loading are all in-process (rcgen + the secrets reader). No init containers, no sidecars, no openssl CLI, no scripts — including for rotation.
  • Rotation rides process lifecycle. Replica certificates are re-minted on every replica process creation, so there is no fleet-wide renewal operation. The 5-year validity is a backstop, not the rotation mechanism.
  • Self-healing. A missing or corrupt credential secret is repaired by the controller re-ensuring it; clusterd retries the read at boot rather than crash-looping, since the write races process creation.
  • Diagnosable from outside. A per-server mz_cluster_server_tls_handshake_failures_total counter, an mz_ctp_tls_cert_not_after gauge, and handshake-failure logs naming the peer and reason. kubectl logs plus scraping is enough — no openssl s_client needed.

Behavior changes

  • Displacement now happens after authentication. A new client displaces the established one only once it has passed transport security, so with TLS on, an unauthenticated peer reaching the port cannot interrupt the live connection. The handshake runs in its own task bounded at 30s, so a stalled peer blocks neither the accept loop nor the live connection. Without TLS, displacement is unchanged (accept-time). A turmoil test covers the new property.
  • CTP now flushes after each message. A raw socket write reaches the peer on its own, but a TLS writer buffers plaintext into records and need not emit them until flushed. Without this, messages (including the CTP handshake) strand indefinitely. Found by running the real process orchestrator; the turmoil socket masks it.
  • Unmanaged replicas connect in plaintext. They run outside the orchestrator, so there is no channel for distributing credentials.
  • Enabled by default in the Rust test harness, sqllogictest, and mzcompose (version-gated for older images); production opts in explicitly.

Testing

  • New turmoil test asserting an unauthenticated peer cannot displace an authenticated connection, on top of the nine TLS tests from service: add mutual TLS support to the cluster transport protocol #37995 (23 tests total in the CTP suite, run 15+ times with random seeds, no flakes).
  • Verified end to end against the real process orchestrator: all 8 CTP connections (4 replicas × storage/compute) completed mutual TLS handshakes and builtin catalog dataflows were created over them — this is what surfaced the missing flush.
  • Caveat, stated plainly: I could not get a full mz-environmentd integration test to pass locally — test_empty_subscribe_notice times out at 240s. I A/B'd it with cluster_tls forced to None and it times out identically, so the timeout is environmental (local debug build against a containerized CockroachDB) and not caused by this change. CI is the real signal for the integration suites; worth a careful look at the first run.

🤖 Generated with Claude Code

jasonhernandez and others added 8 commits August 1, 2026 12:51
Add optional mutual TLS to CTP connections. When configured, both
endpoints present an X.509 certificate signed by a deployment-internal
CA and verify the peer's certificate chain and identity (a DNS-shaped
name in the SAN) before any CTP bytes are exchanged.

Nothing enables TLS yet. All callers pass None, so behavior is
unchanged. Wiring the configuration through environmentd, the
controllers, and clusterd is follow-up work.

The new transport::tls module provides:

* ClientTlsConfig / ServerTlsConfig: rustls-based endpoint configs.
  TLS 1.3 only, explicit aws-lc-rs provider. The server requires and
  verifies client certificates, then checks the client's identity
  against the expected name. The client verifies the server's identity
  through standard server-name verification.
* CertificateAuthority: rcgen-based issuance of a path-length-zero CA
  and per-endpoint leaf certificates, with PEM persistence and
  reconstruction, for the controller to mint replica credentials.

Connection::start now takes pre-split stream halves so it can run on
either a plain stream or a TLS stream wrapped around one. The CTP
handshake is unchanged and runs inside the TLS channel. The server
bounds the TLS handshake with a 30s timeout so a stalled or non-TLS
peer cannot occupy the single connection slot indefinitely.

Private key material is wrapped in mz_ore::secure::SecureString, so it
is zeroed on drop and redacted from debug output.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend SecretsController and SecretsReader with internal secrets,
keyed by name rather than by CatalogItemId. These hold
system-generated credentials, such as the upcoming CTP transport
keys, that have no corresponding catalog item.

The two namespaces are disjoint in every backend: Kubernetes uses an
"internal-" infix next to the existing "user-managed-" one, the
process orchestrator prefixes secret files with "internal-", and AWS
Secrets Manager gets an "internal-" infix after the deployment
prefix. Internal secrets are excluded from list(), whose parsers only
recognize user secret names. Names are validated against a
conservative alphabet (lowercase alphanumerics and dashes) that is
valid in all backends and cannot traverse paths.

The process orchestrator's list() previously errored on any file in
the secrets directory whose name did not parse as a CatalogItemId. It
now skips such files, matching the documented behavior of the other
backends.

The caching reader deliberately does not cache internal secrets. They
are read rarely, typically once at process startup, so caching would
only extend the lifetime of key material in memory.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…agement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Callers bootstrapping credentials must distinguish a secret that does
not exist yet (generate and persist a new one) from a transient read
failure (fail and retry). Conflating the two could cause a caller to
regenerate and overwrite live key material. Return Option instead of
an untyped error.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wire the CTP mutual TLS support end to end, so controller and replica
authenticate each other and their traffic is encrypted.

environmentd, behind the new --cluster-transport-tls flag, bootstraps a
per-environment certificate authority into an internal secret at
startup and mints its own controller certificate. The cluster
controller then mints a certificate per replica when it provisions the
replica, writes it to the replica's internal secret, and passes the
secret name to clusterd. clusterd reads the credentials through the
secrets reader it already has and requires mutual TLS on both
controller listeners. Dropping a replica deletes its secret.

Peer identity is the replica's service name, carried in the
certificate SAN. The controller verifies it connected to the intended
replica, and the replica verifies the peer holds a controller
certificate from the same environment. Credential distribution rides
the machinery that already exists for user secrets, so this needs no
cert-manager, no operator changes, and no new RBAC.

The credential lifecycle needs no operator intervention and no shell
in either image. Replica certificates are re-minted on every replica
process creation, so rotation rides process lifecycle rather than
expiry, and a missing or corrupt credential secret is repaired by the
controller re-ensuring it. Handshake failures are diagnosable from
logs and metrics alone: a per-server failure counter and a
certificate-expiry gauge, plus logs naming the peer and the reason.

A new client now displaces the established one only after passing
transport security, so with TLS enabled an unauthenticated peer that
reaches the listen port can no longer evict the controller. The
handshake runs in its own task, bounded by a 30s timeout, so a stalled
peer blocks neither the accept loop nor the live connection.

Enabled by default in the Rust test harness, sqllogictest, and
mzcompose, so CI exercises the TLS path. Production deployments opt in
explicitly.

Also flush after writing each CTP message. A raw socket write reaches
the peer on its own, but a TLS writer buffers plaintext into records
and need not emit them until flushed, which would strand messages
indefinitely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant