Skip to content

Commit 0ae8504

Browse files
committed
Disable rustls TLS-1.3 session resumption for the child client
Signed-off-by: Erick Bourgeois <erick@jeb.ca>
1 parent 10efb01 commit 0ae8504

4 files changed

Lines changed: 87 additions & 6 deletions

File tree

.claude/CHANGELOG.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,36 @@ The format is based on the regulated environment requirements:
99

1010
---
1111

12-
## [2026-06-26 10:00] - Switch rustls crypto provider ring -> aws-lc-rs (fixes mTLS to k0smotron-hosted control planes)
12+
## [2026-06-27 10:00] - Disable rustls TLS-1.3 session resumption for child clients (the real mTLS-to-hosted-CP fix)
13+
14+
**Author:** Erick Bourgeois
15+
16+
### Changed
17+
- `src/reconcilers/child_client.rs`: build the child-cluster `kube::Client` by hand (instead of
18+
`Client::try_from`) so we can set `rustls::client::Resumption::disabled()` on its TLS config.
19+
Otherwise mirrors kube's own connector stack (TimeoutConnector with the wire timeouts, base-URI
20+
+ auth layers, `BoxError` mapping).
21+
- `Cargo.toml`: add direct deps `hyper-rustls`, `rustls`, `hyper-timeout` for the custom connector.
22+
- 682 lib tests pass (incl. the 17 child-client tests through the custom connector), fmt/clippy clean.
23+
24+
### Why
25+
A k0smotron-hosted control plane requires fresh **client-certificate (mTLS)** auth on every
26+
connection and — like Go's TLS server — will not complete a **resumed** TLS-1.3 session that skips
27+
client auth. kube-rs/rustls, however, offers a PSK (session ticket) on reconnect; the `rustls=trace`
28+
shows `Resuming using PSK` on every connection, the resumed handshake **stalls right after the
29+
server's EncryptedExtensions**, and times out (`client error (Connect) -> deadline has elapsed`) — so
30+
node taints/drains never happen on the hosted-CP tier. client-go and `curl` always do a *full* mTLS
31+
handshake (proven on the playground: both connect in ~10ms). Disabling resumption makes 5-Spot's
32+
child client do the same. NOTE: this is provider-independent — both `ring` and `aws-lc-rs` exhibit
33+
the stall, so the earlier provider swap was **not** the fix.
34+
35+
### Impact
36+
- [ ] Breaking change
37+
- [x] Requires cluster rollout
38+
- [ ] Config change only
39+
- [ ] Documentation only
40+
41+
## [2026-06-26 10:00] - Switch rustls crypto provider ring -> aws-lc-rs (NOT the fix — superseded by disabling resumption)
1342

1443
**Author:** Erick Bourgeois
1544

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,14 @@ http-body-util = "0.1"
8080
# API error statuses we branch on, instead of hardcoding 404/409/429).
8181
http = "1"
8282
tower = "0.5"
83+
# Child-cluster client: kube-rs/rustls keeps RESUMING the TLS-1.3 session (PSK) against
84+
# a k0smotron-hosted control plane that requires fresh client-cert (mTLS) auth, and the
85+
# resumed handshake stalls (client error (Connect) -> deadline elapsed). We build the
86+
# child client's connector by hand with rustls session resumption DISABLED so every
87+
# connection performs a full mTLS handshake (what client-go/curl do, which works).
88+
hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "aws-lc-rs"] }
89+
rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs"] }
90+
hyper-timeout = "0.5"
8391
regex = "1"
8492
lazy_static = "1.5"
8593
# warp 0.4 made every previously-default feature opt-in. We use

src/reconcilers/child_client.rs

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use std::sync::{Arc, RwLock};
3939
use std::time::Instant;
4040

4141
use k8s_openapi::api::core::v1::Secret;
42-
use kube::{config::Kubeconfig, Api, Client, ResourceExt};
42+
use kube::{client::ConfigExt, config::Kubeconfig, Api, Client, ResourceExt};
4343
use tracing::{debug, info, warn};
4444

4545
use crate::constants::{CHILD_CLIENT_CACHE_CAP, HTTP_NOT_FOUND, K8S_API_TIMEOUT_SECS};
@@ -453,12 +453,53 @@ impl ChildClientCache {
453453
// would stall reconciliation indefinitely.
454454
config.read_timeout = Some(std::time::Duration::from_secs(K8S_API_TIMEOUT_SECS));
455455
config.write_timeout = Some(std::time::Duration::from_secs(K8S_API_TIMEOUT_SECS));
456-
457-
let child = Client::try_from(config).map_err(|e| ReconcilerError::KubeconfigInvalid {
456+
config.connect_timeout = Some(std::time::Duration::from_secs(K8S_API_TIMEOUT_SECS));
457+
458+
// Build the child client BY HAND with rustls TLS-1.3 session resumption DISABLED.
459+
//
460+
// kube-rs's default `Client::try_from` resumes the TLS session (PSK) on reconnect.
461+
// A k0smotron-hosted control plane requires fresh client-certificate (mTLS) auth on
462+
// every connection and will not complete a resumed handshake that skips it — so the
463+
// connection stalls until the wire timeout ("client error (Connect) -> deadline has
464+
// elapsed") and the Node is never tainted/drained. Disabling resumption forces a full
465+
// mTLS handshake every time, which is exactly what client-go and curl do (both connect
466+
// in ~10ms). This otherwise mirrors kube's own connector stack (timeouts, base-URI and
467+
// auth layers).
468+
let make_err = |reason: String| ReconcilerError::KubeconfigInvalid {
458469
namespace: namespace.to_string(),
459470
name: secret_name.to_string(),
460-
reason: format!("could not build kube::Client: {e}"),
461-
})?;
471+
reason,
472+
};
473+
let mut tls = config
474+
.rustls_client_config()
475+
.map_err(|e| make_err(format!("could not build rustls config: {e}")))?;
476+
tls.resumption = rustls::client::Resumption::disabled();
477+
let mut http = hyper_util::client::legacy::connect::HttpConnector::new();
478+
http.enforce_http(false);
479+
let https = hyper_rustls::HttpsConnectorBuilder::new()
480+
.with_tls_config(tls)
481+
.https_or_http()
482+
.enable_http1()
483+
.enable_http2()
484+
.wrap_connector(http);
485+
let mut connector = hyper_timeout::TimeoutConnector::new(https);
486+
connector.set_connect_timeout(config.connect_timeout);
487+
connector.set_read_timeout(config.read_timeout);
488+
connector.set_write_timeout(config.write_timeout);
489+
let hyper_client: hyper_util::client::legacy::Client<_, kube::client::Body> =
490+
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
491+
.build(connector);
492+
let auth = config
493+
.auth_layer()
494+
.map_err(|e| make_err(format!("could not build auth layer: {e}")))?;
495+
let service = tower::ServiceBuilder::new()
496+
.layer(config.base_uri_layer())
497+
.option_layer(auth)
498+
// Box the hyper client's error so the auth / base-URI layers see a uniform
499+
// `BoxError` (mirrors kube's own `make_generic_builder`).
500+
.map_err(tower::BoxError::from)
501+
.service(hyper_client);
502+
let child = Client::new(service, config.default_namespace.clone());
462503

463504
// Insert (with LRU eviction if at cap) and return. Track whether
464505
// we're replacing an existing entry for the same key (RV change

0 commit comments

Comments
 (0)