GeezerVisonThe World Wide Web, restored to 800 pixels of glory. | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Home|About|Gaming cabinet|Archives|How it works|Starter sites | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
GeezerVison is showing github.com — [dbsp] Exchange data between hosts over TLS with client certificates by blp · Pull Request #7713 · feldera/feldera Conversation
|
| Check | Why |
|---|---|
| The peer presents a certificate | Both sides authenticate |
| The chain ends at the private CA, the first certificate after the leaf | Same rule as the existing client certificate verifier: other CAs under the same root are not trusted |
| The peer's pipeline URI equals our own | Refuses other pipelines, the control plane and external clients |
Hosts connect by pod IP address, which no certificate lists, so the client cannot use rustls's built-in server verifier, which checks the DNS name or IP address. It uses a custom verifier, which rustls allows only through ClientConfig::dangerous(). The custom verifier does what the built-in one does without CRLs (chain, validity period, server auth EKU, handshake signatures), except that it checks the pipeline URI instead of the name. The first commit message explains why the alternatives that avoid dangerous() are worse.
| Commit | Change |
|---|---|
| [feldera-mtls] | PipelinePeerTls::from_chain(chain, key) builds TLS 1.3 client and server configurations with the checks above |
| [dbsp] | CircuitConfig::exchange_tls. The server now sends the exchange magic back, and the client waits for it while it connects. In TLS 1.3 the client finishes its handshake before the server checks the client certificate, so this reply is how the client learns that the server accepted its certificate. A refused certificate in either direction then fails connect and waits for the backoff, instead of reconnecting at once. The server does the handshake in the task for the connection, not in the accept loop. On both sides, the handshake and the magic exchange each time out after 10 s. Flushes after each message, ping, magic and acknowledgement, because tokio-rustls can hold back encrypted bytes until a flush. Plain TCP still uses into_split. The dev tweak tls_exchange, on by default, turns exchange TLS off without turning off mTLS elsewhere |
| [connectors] | With client certificates required, the pipeline builds PipelinePeerTls from --https-tls-cert-path and --https-tls-key-path and passes it through the new CircuitParams::exchange_tls to the static engine. The three ControllerBuilder::open_* methods now share ControllerBuilder::finish, so that they pass on the same settings. Docs: HTTPS guide, Helm chart reference, changelog |
| [feldera-mtls] (review) | Both verifiers refuse TLS 1.2 signatures, although the configurations already allow only TLS 1.3. Setup errors name the cause: a key that does not match the certificate, or a missing CA chain from caSecretRef |
| [connectors] (review) | The panic for a bad exchange TLS setup names the certificate and key paths and where they come from |
Each new check was disabled once to confirm that its test fails.
| Test | Fails when |
|---|---|
feldera-mtls peer_tls_admits_only_own_pipeline: other pipeline, control plane, sibling CA, root CA, no certificate, with the client and the server side each checked alone |
the server identity check, the client identity check, the trust of only the first CA, or the required client certificate is removed |
feldera-mtls peer_tls_refuses_tls12: TLS 1.2 as client and as server |
the configurations allow TLS 1.2 |
feldera-mtls peer_tls_needs_pipeline_chain, key case: a key from another certificate |
the error does not say that the key does not match |
dbsp multihost_tls, multihost_mixed_size_tls, with fault injection, so reconnections go through the handshake. The servers count handshakes |
ExchangeClients ignores the configuration |
dbsp tls_client_starts_with_handshake |
the client ignores TLS |
dbsp tls_server_refuses_plaintext |
the server ignores TLS |
dbsp client_connect_fails_when_server_refuses_certificate: a client without a certificate against a TLS server |
the client does not wait for the server's magic |
dbsp client_connect_waits_for_server_magic |
the server does not send its magic back |
dbsp server_closes_connection_without_magic (tests use a 2 s handshake timeout) |
the server reads the magic without a timeout |
dbsp multihost_tls_exchange_off |
the runtime ignores tls_exchange |
connectors open_passes_exchange_tls |
ControllerBuilder::finish drops the exchange TLS |
All 37 dbsp operator::communication and multihost tests pass. Clippy is clean on the changed crates, and scripts/validate-crypto-deps.sh passes: tokio-rustls is added with default-features = false.
Not tested: a FIPS build (the configurations use the same crypto_provider() as the existing verifier, which the FIPS tests cover), an end-to-end run on Kubernetes (the cloud CI fresh install has mtls.enabled and runs 2-host pipelines, so the submodule bump will cover it).
Release build, 2 hosts of 4 workers on localhost, fault injection off, 5 runs of each mode:
| Workload | Plaintext | TLS | Change |
|---|---|---|---|
| 20,000 rounds of small messages | 6,050 to 6,910 rounds/s | 6,050 to 7,770 rounds/s | none measurable |
| 200 rounds of 1 MiB messages | 1,107 to 1,193 MB/s | 805 to 841 MB/s | about 29% lower |
The flushes add no measurable latency. Encrypting bulk data costs about 29% of throughput, which shows only when the network is faster than about 800 MB/s. Set dev_tweaks.tls_exchange to false to exchange data in plaintext.
dev_tweaks.tls_exchange is false. The cloud Helm chart already sets the variable and mounts the pipeline certificate and key, so it needs no change.feldera-xchg-v2. All hosts of a pipeline run the same image, so they agree on the protocol and on TLS.CircuitParams has a new field. An engine that destructures it must name the field.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| let description = format!("{remote_address} for exchange with workers {remote_workers:?}"); | ||
| loop { | ||
| let mut n_failures = 0u64; | ||
| let stream = loop { |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
bug (medium): a refused client certificate skips the backoff. In TLS 1.3 the client finishes its handshake before the server checks the client certificate. So when the server refuses our cert, connect() still returns Ok. The server's alert only shows up later, as an error from run_connection (a few lines below), and the outer loop then reconnects at once with no sleep(backoff_time()). That produces a hot loop of full handshakes and connection … dropped warnings, and the InvalidData warn branch here never fires for this case.
I confirmed this with a scratch test: a client with with_no_client_auth() against test_exchange_tls().0.server gives connect() = Ok, while the server gets Err(InvalidData, NoCertificatesPresented).
Suggested fix: back off when run_connection fails before anything has been received, or always sleep backoff_time() after a dropped connection. Please also add a test where the server refuses the client cert.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Thanks, you're right. The backoff only covered the client refusing the server's certificate, not the other way around.
Fixed in ab24b66. The server now sends the exchange magic back after it reads the client's, and ExchangeClient::connect waits for that reply, under the same 10 s timeout as the TLS handshake. With a refused client certificate, the server's alert arrives while the client waits for the reply, so connect fails with InvalidData. It then goes through the existing backoff and the warn branch, instead of the immediate reconnect after a dropped connection. The reply is also sent without TLS, so the magic is now feldera-xchg-v2.
New tests:
client_connect_fails_when_server_refuses_certificate: a client without a certificate against a TLS listener. connect fails with InvalidData, and a client with a certificate connects. The test fails if the client does not wait for the server's magic.client_connect_waits_for_server_magic: fails if the server does not send the magic back.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Review: TLS for multihost exchange What I ran
Gates: tests, docs, crypto/FIPS ( Test cases the PR does not cover
Pitfalls
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| .flatten() | ||
| .collect(); | ||
| let key = PrivateKeyDer::from_pem_file(key_path).expect("HTTPS TLS key should be read"); | ||
| let tls = PipelinePeerTls::from_chain(chain, key) |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): the chain and key are read once at startup, and the peer verifier checks the validity period on every handshake. When cert-manager renews the pipeline certificate, the running process keeps the old one. Once it expires, every exchange reconnect (a network blip, or a peer pod restart) fails the handshake on both sides. The pipeline then stalls in a backoff loop and only logs warnings. For HTTPS this only breaks the API, but here it stops the data plane. Can you either reload the cert files per handshake, with a ResolvesServerCert/ResolvesClientCert that rereads them on change, or confirm that pipeline certificates always outlive a deployment? A test with an expired peer certificate would also help.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
cert-manager does not issue pipeline certificates, only the control-plane client certificate. The runner issues a pipeline's certificate once, when the pipeline starts (cloud crates/cluster-control-plane/src/kubernetes_runner/runner.rs, construct_secret). The certificate is valid for 355 days (objects/secret.rs), and nothing renews it while the pipeline runs. So the files under /https-config do not change during a pipeline's life, and rereading them per handshake would get the same expired certificate.
The pipeline's HTTPS server and the coordinator already load the same certificate once, at startup. A pipeline that runs for 355 days without a restart therefore already loses its API when the certificate expires, and with this PR exchange also fails on its next reconnect. Renewing certificates of running pipelines would need the runner to reissue them and all three users to reload them. That is a separate change, so I left it out of this PR.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Ran (debug, locked)
The gates pass: there are tests for the changed behaviour, docs and changelog are updated, there are no Missing (medium): uncovered cases
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
Thanks for the rereview. Pushed 769bbee:
|
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| .flatten() | ||
| .collect(); | ||
| let key = PrivateKeyDer::from_pem_file(key_path).expect("HTTPS TLS key should be read"); | ||
| let tls = PipelinePeerTls::from_chain(chain, key) |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): the chain and key are read once at startup. The control plane rereads its renewed client certificate every 60 s (CLIENT_CERTIFICATE_RELOAD_INTERVAL in pipeline-manager config.rs), but this config never picks up the renewal. Once the pipeline certificate expires, both verifiers refuse every new handshake, so the next exchange reconnect (network blip, pod reschedule) retries forever and the multihost pipeline stalls. The pipeline HTTPS server also loads its certificate only once, but exchange makes it worse because exchange needs reconnects to recover. Could this use a reloading ResolvesServerCert/ResolvesClientCert, or can you confirm that pipeline certificates outlive any deployment? Then a test for an expired peer certificate on reconnect would be good too.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Same point as the earlier thread, so see #7713 (comment). The 60-second reload in pipeline-manager is for the control plane's own client certificate, which cert-manager issues and renews. Pipeline certificates are different: the runner issues one when the pipeline starts, and nothing renews it while the pipeline runs. A reload would read the same file again, so a reloading resolver would not help. Renewing the certificates of running pipelines is a separate change, and it would cover the HTTPS server and the coordinator too.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| IoSlice::advance_slices(&mut bufs, n); | ||
| } | ||
|
|
||
| // TLS can hold back encrypted data until a flush, which would |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
pitfall (medium): this puts TLS (encrypt plus a flush per message, ping and ack) on the hot data path of every multihost pipeline with mtls.enabled, and the PR says throughput has not been measured. There is also no way to turn exchange TLS off without turning off mTLS everywhere. Can you gate it behind a dev_tweaks opt-out (or opt-in at first) and run a quick plaintext vs. TLS exchange benchmark before this becomes the default for mTLS deployments?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Added in 6267b2b: the dev tweak tls_exchange, on by default. Setting it to false makes exchange use plaintext even when the pipeline has client certificates, and leaves mTLS on for everything else. multihost_tls_exchange_off checks that no handshake happens with the tweak off, and it fails if the runtime ignores the tweak. The HTTPS guide and the changelog describe the tweak.
I measured it with a release build, 2 hosts of 4 workers on localhost, fault injection off, and 5 runs of each mode:
| Workload | Plaintext | TLS | Change |
|---|---|---|---|
| 20,000 rounds of small messages | 6,050 to 6,910 rounds/s | 6,050 to 7,770 rounds/s | none measurable |
| 200 rounds of 1 MiB messages | 1,107 to 1,193 MB/s | 805 to 841 MB/s | about 29% lower |
So the flushes do not add measurable latency. The cost is the encryption of bulk data, and it shows only when the network is faster than about 800 MB/s. The handshake count stayed the same in every TLS run, so the connections were reused for the whole run.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
|
What I ran (debug, merge commit):
Gates: tests, docs, changelog, commit messages, deps ( Not covered by tests (medium):
The design looks sound to me: the custom verifier checks only the CA and the URI SAN, and the server sends the magic back so a refused certificate fails |
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| let provider = verifier.provider.clone(); | ||
| let server = ServerConfig::builder_with_provider(provider.clone()) | ||
| .with_protocol_versions(&[&rustls::version::TLS13]) | ||
| .map_err(|e| format!("peer TLS server: {e}"))? |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
maybe we could make these more actionable, I wonder if they can fail for some setup problem (helm) etc. we can tell people where to look
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Done in 2b9fff0 and 832af79. The errors that a setup problem can cause now say what is wrong: a private key that does not match the pipeline certificate, or a certificate file without the CA chain, which the runner appends from the private CA in the Helm value caSecretRef. The panic in server.rs now names both file paths and says that the runner writes them from the pipeline certificate, which it signs with that CA. The TLS 1.3 error can only come from a code bug, so it just says that the crypto provider does not support TLS 1.3.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| cert: &CertificateDer<'_>, | ||
| dss: &DigitallySignedStruct, | ||
| ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| verify_tls12_signature( |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
maybe we can just block 1.2?
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Done in 2b9fff0. The configurations already negotiate only TLS 1.3, but the rustls traits require verify_tls12_signature. Both verifiers now refuse in it instead of checking the signature, so TLS 1.2 is blocked twice. peer_tls_refuses_tls12 checks that a host refuses TLS 1.2 as client and as server. When I let the configurations allow TLS 1.2, the test failed, and the handshake stopped at the new refusal in the verifier.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
| cert: &CertificateDer<'_>, | ||
| dss: &DigitallySignedStruct, | ||
| ) -> Result<HandshakeSignatureValid, rustls::Error> { | ||
| self.client_verifier |
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
same q
Sorry, something went wrong.
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
The reason will be displayed to describe this comment to others. Learn more.
Same change, in 2b9fff0: the client certificate verifier refuses TLS 1.2 signatures too.
Sorry, something went wrong.
There was an error while loading. Please reload this page.
832af79 to
90f6ee6
Compare
…peline. The hosts of a multihost pipeline exchange data over raw TCP. To move that to TLS, each host needs a client and a server configuration that authenticate the other host as a host of the same pipeline. The identity to check is already in each pipeline certificate. An X.509 certificate lists the names that it is valid for in its "subject alternative name" (SAN) extension. Most SAN entries are DNS names or IP addresses, which name a network location, but an entry can also be a URI. The runner gives each pipeline certificate the URI entry `urn:feldera:pipeline:<pipeline id>`. Because the private CA signs it, a peer that completes a TLS handshake with that certificate proves that it belongs to that pipeline, wherever it runs. The control plane's certificate has `urn:feldera:control-plane` instead. `Caller` reads these URIs, and the compiler, runner, pipeline and coordinator servers already use it to admit clients. `PipelinePeerTls::from_chain` builds both configurations from the pipeline certificate chain and its key. Both ends present the pipeline certificate. Both trust only the private CA, the first certificate after the leaf, as the existing client certificate verifier does. Both admit only a peer whose certificate has the same pipeline URI, so the control plane, external clients and other pipelines are refused. The server requires a client certificate. The client needs a custom server certificate verifier, which rustls allows only through `ClientConfig::dangerous()`. The built-in verifier checks that a server's DNS name or IP address entries include the name that the client connected to. Hosts connect by pod IP address, which no certificate lists, and the pipeline URI is the identity that matters. The custom verifier does what the built-in one does without CRLs (chain to the private CA, validity period, server auth extended key usage, handshake signatures), except that it checks the pipeline URI instead of the name. Two alternatives avoid `dangerous()`, but each is worse: - Verify against a DNS name that matches the wildcard DNS entry of our own certificate, `*.<pipeline service>.<namespace>.svc.cluster.local`. This makes the runner's DNS naming the identity, separate from the pipeline URI that the servers check. - Verify against a name that every pipeline certificate has, such as `localhost`, and check the peer's pipeline URI after the handshake. This splits verification in two, and the connection is only safe if every caller remembers the second check. The tests run in-memory handshakes. The client and the server side are each checked on their own, against another pipeline, the control plane, a sibling CA, the root CA, and no certificate. Each test fails if the check it covers is removed. Signed-off-by: Ben Pfaff <[email protected]>
`CircuitConfig::exchange_tls` holds rustls client and server configurations. When it is set, every exchange connection between hosts starts with a TLS handshake, and the exchange magic and messages go inside TLS. Without it, there is no TLS. The server now sends the exchange magic back after it reads the client's, and the client waits for it before it uses the connection. This is how the client learns that the server accepted its certificate: in TLS 1.3, the client finishes its handshake before the server checks the client certificate, so a refused certificate shows up only when the client next reads. Without the reply, `connect` succeeded, the refusal arrived later as a dropped connection, and the client reconnected at once, in a loop of full handshakes. With it, a refused certificate in either direction fails `connect`, which waits for the backoff before it tries again and logs a warning. The reply is also sent without TLS, so the magic's version becomes v2. On both sides, the TLS handshake and the magic exchange each time out after 10 seconds, so a peer that stalls, or a stray client that connects and sends nothing, does not keep a task forever. The server does the handshake in the task for the connection, not in the accept loop, so a slow client cannot hold up other connections. It logs refused and timed-out handshakes with the peer address, as it does for a bad exchange magic. tokio-rustls can return from a write before the encrypted bytes reach the socket, so both sides flush after the magic, the client after each message and each ping, and the server after each acknowledgement. Without TLS, a flush does nothing. Plain TCP connections still use `into_split`. The new dev tweak `tls_exchange`, on by default, turns exchange TLS off, so that data goes in plaintext even when `exchange_tls` is set. On localhost, with 2 hosts of 4 workers, TLS did not change the rate of rounds with small messages, and it lowered the throughput of 1 MiB messages by about 29%, from about 1,150 MB/s to about 820 MB/s. The tests run the multihost exchange tests over TLS with fault injection, so reconnections go through the handshake. A handshake counter shows that TLS was used. Other tests check that a TLS client does not send the magic in plaintext, that a TLS server closes a plaintext connection, that `connect` fails when the server refuses the client's certificate, that `connect` succeeds once the server answers the magic, and that the server closes a connection that sends no magic, and that exchange does not use TLS with the dev tweak off. Tests use a 2-second timeout. Each of these tests fails if the behavior it checks is removed. Signed-off-by: Ben Pfaff <[email protected]>
With `--require-client-certificates` (`FELDERA_REQUIRE_CLIENT_CERTIFICATES`, Helm `mtls.enabled`), the pipeline loads its certificate and key from `--https-tls-cert-path` and `--https-tls-key-path` and builds `PipelinePeerTls`. The controller passes it to the engine in the new `CircuitParams::exchange_tls`, and the static engine sets it in dbsp's `CircuitConfig`. So the hosts of a multihost pipeline exchange data over TLS and admit only hosts of the same pipeline. Without client certificates, exchange stays plaintext. `CircuitParams` carries the `feldera-mtls` type, not the dbsp one, because the engine interface does not depend on dbsp. An engine that destructures `CircuitParams` must name the new field. The three `ControllerBuilder::open_*` methods each passed the builder's settings on to `ControllerInit` with the same chain of calls. They now share `ControllerBuilder::finish`, so that a setting cannot reach some of them and not others. A new test checks that opening a controller passes the exchange TLS on to the circuit, and fails if `finish` drops it. Document it in the HTTPS guide, the Helm chart reference and the changelog. Signed-off-by: Ben Pfaff <[email protected]>
The peer configurations negotiate only TLS 1.3, but the rustls verifier traits still require `verify_tls12_signature`, and both verifiers implemented it by checking the signature. Now both refuse, so a TLS 1.2 handshake fails even if the protocol versions change. `PipelinePeerTls::from_chain` errors now say what is wrong in the setup: a private key that does not match the pipeline certificate, or a certificate file without the CA chain, which the runner appends from the private CA in the Helm value `caSecretRef`. `peer_tls_refuses_tls12` checks that a host refuses TLS 1.2 as client and as server. It fails if the configurations allow TLS 1.2. `peer_tls_needs_pipeline_chain` now also checks the error for a key that does not match the certificate. Signed-off-by: Ben Pfaff <[email protected]>
When the exchange TLS configuration cannot be built, the panic now names the certificate and key paths, and says that the runner writes them from the pipeline certificate, which it signs with the private CA in the Helm value `caSecretRef`. Signed-off-by: Ben Pfaff <[email protected]>
There was an error while loading. Please reload this page.
There was an error while loading. Please reload this page.
Successfully merging this pull request may close these issues.
There was an error while loading. Please reload this page.