From f2e589d087b1eff860070d5addf566ba57cf2d54 Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Thu, 11 Jun 2026 18:53:32 +0800 Subject: [PATCH 1/3] feat(server): support TLS certificate hot-reload Signed-off-by: Yuedong Wu --- Cargo.lock | 10 + crates/openshell-core/src/config.rs | 5 + crates/openshell-server/Cargo.toml | 1 + crates/openshell-server/src/cli.rs | 7 + crates/openshell-server/src/lib.rs | 19 +- .../openshell-server/src/service_routing.rs | 1 + crates/openshell-server/src/tls.rs | 714 ++++++++++++++++-- crates/openshell-server/tests/common/mod.rs | 2 +- 8 files changed, 708 insertions(+), 51 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0cd77f85..38c859e6b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -156,6 +156,15 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "argon2" version = "0.5.3" @@ -3695,6 +3704,7 @@ name = "openshell-server" version = "0.0.0" dependencies = [ "anyhow", + "arc-swap", "async-trait", "axum 0.8.9", "bytes", diff --git a/crates/openshell-core/src/config.rs b/crates/openshell-core/src/config.rs index 04d6928da..63b6ec1d1 100644 --- a/crates/openshell-core/src/config.rs +++ b/crates/openshell-core/src/config.rs @@ -413,6 +413,11 @@ pub struct TlsConfig { /// When `false`, client certificates are accepted but not required. #[serde(default)] pub require_client_auth: bool, + + /// Interval in seconds for polling TLS certificate files for changes. + /// When `0`, certificate reload is disabled (default). + #[serde(default)] + pub reload_interval_secs: u64, } /// OIDC (`OpenID` Connect) configuration for JWT-based authentication. diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index 0b7e3a97e..679e57f98 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -94,6 +94,7 @@ ipnet = "2" tempfile = "3" rustix = { workspace = true } x509-parser = "0.16" +arc-swap = "1" [features] bundled-z3 = ["openshell-prover/bundled-z3"] diff --git a/crates/openshell-server/src/cli.rs b/crates/openshell-server/src/cli.rs index 748cec264..e5e9eaf27 100644 --- a/crates/openshell-server/src/cli.rs +++ b/crates/openshell-server/src/cli.rs @@ -279,11 +279,18 @@ async fn run_from_args(mut args: RunArgs, matches: ArgMatches) -> Result<()> { let key_path = args.tls_key.clone().ok_or_else(|| { miette::miette!("--tls-key is required when TLS is enabled (use --disable-tls to skip)") })?; + + let reload_interval_secs = file + .as_ref() + .and_then(|f| f.openshell.gateway.tls.as_ref()) + .map_or(0, |t| t.reload_interval_secs); + Some(openshell_core::TlsConfig { cert_path, key_path, require_client_auth: has_client_ca && !has_oidc, client_ca_path: args.tls_client_ca.clone(), + reload_interval_secs, }) }; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 676e23071..e23bbbb34 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -413,20 +413,28 @@ pub async fn run_server( info!("Metrics server disabled"); } + let (shutdown_tx, shutdown_rx) = watch::channel(false); + // Build TLS acceptor when TLS is configured; otherwise serve plaintext. let tls_acceptor = if let Some(tls) = &config.tls { - Some(TlsAcceptor::from_files( + let acceptor = TlsAcceptor::from_files( &tls.cert_path, &tls.key_path, tls.client_ca_path.as_deref(), tls.require_client_auth, - )?) + )?; + + // Spawn TLS certificate reload worker if enabled + if tls.reload_interval_secs > 0 { + acceptor.spawn_reload_worker(Duration::from_secs(tls.reload_interval_secs), shutdown_rx.clone()); + } + + Some(acceptor) } else { info!("TLS disabled — accepting plaintext connections"); None }; - let (shutdown_tx, shutdown_rx) = watch::channel(false); let mut listener_tasks = Vec::with_capacity(gateway_listeners.len()); let enable_loopback_service_http = config.service_routing.enable_loopback_service_http; for (listener, listen_addr) in gateway_listeners { @@ -615,7 +623,10 @@ fn spawn_gateway_connection( warn!(client = %addr, listen = %listen_addr, "Rejected plaintext HTTP on non-loopback gateway listener"); } Ok(ConnectionProtocol::Tls | ConnectionProtocol::Unknown) => { - match acceptor.inner().accept(stream).await { + // acceptor.acceptor() snapshots the current TLS config; + // the returned acceptor owns an Arc that stays alive for + // the full duration of the handshake. + match acceptor.acceptor().accept(stream).await { Ok(tls_stream) => { let peer_identity = multiplex::extract_peer_identity(&tls_stream); if let Err(e) = service diff --git a/crates/openshell-server/src/service_routing.rs b/crates/openshell-server/src/service_routing.rs index 7ebd6dba9..e8af34981 100644 --- a/crates/openshell-server/src/service_routing.rs +++ b/crates/openshell-server/src/service_routing.rs @@ -829,6 +829,7 @@ mod tests { key_path: "server.key".into(), client_ca_path: Some("ca.crt".into()), require_client_auth: false, + reload_interval_secs: 0, } } diff --git a/crates/openshell-server/src/tls.rs b/crates/openshell-server/src/tls.rs index 1af1ce0cd..6d47db277 100644 --- a/crates/openshell-server/src/tls.rs +++ b/crates/openshell-server/src/tls.rs @@ -3,19 +3,35 @@ //! TLS support using tokio-rustls. -use openshell_core::{Error, Result}; -use rustls::ServerConfig; -use rustls::pki_types::{CertificateDer, PrivateKeyDer}; -use rustls::server::WebPkiClientVerifier; use std::fs::File; use std::io::BufReader; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::time::Duration; + +use arc_swap::ArcSwap; +use openshell_core::{Error, Result}; +use rustls::crypto::ring::sign; +use rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use rustls::server::WebPkiClientVerifier; +use rustls::ServerConfig; +use tokio::sync::watch; +use tracing::{debug, info, warn}; /// TLS acceptor for wrapping connections. +/// +/// Uses `ArcSwap` internally so the active `ServerConfig` can be atomically +/// swapped by a background reload worker without blocking TLS handshakes. +/// +/// Stores the cert/key/CA paths from construction so that `reload()` can +/// re-read from the same files without the caller tracking them separately. #[derive(Clone)] pub struct TlsAcceptor { - acceptor: tokio_rustls::TlsAcceptor, + config: Arc>, + cert_path: PathBuf, + key_path: PathBuf, + client_ca_path: Option, + require_client_auth: bool, } impl TlsAcceptor { @@ -42,53 +58,130 @@ impl TlsAcceptor { client_ca_path: Option<&Path>, require_client_auth: bool, ) -> Result { - let certs = load_certs(cert_path)?; - let key = load_key(key_path)?; - - let mut config = if let Some(ca_path) = client_ca_path { - let ca_certs = load_certs(ca_path)?; - let mut root_store = rustls::RootCertStore::empty(); - for cert in ca_certs { - root_store - .add(cert) - .map_err(|e| Error::tls(format!("failed to add CA certificate: {e}")))?; - } + let config = build_server_config(cert_path, key_path, client_ca_path, require_client_auth)?; + Ok(Self { + config: Arc::new(ArcSwap::from(config)), + cert_path: cert_path.to_path_buf(), + key_path: key_path.to_path_buf(), + client_ca_path: client_ca_path.map(Path::to_path_buf), + require_client_auth, + }) + } + + /// Re-read certificates from the same paths used at construction and + /// atomically swap the active config. + /// + /// Returns `Ok(())` when the new config was built and swapped successfully. + /// Returns `Err(...)` if cert/key loading fails — the old config is preserved. + pub fn reload(&self) -> Result<()> { + let new_config = build_server_config( + &self.cert_path, + &self.key_path, + self.client_ca_path.as_deref(), + self.require_client_auth, + )?; + self.config.store(new_config); + Ok(()) + } - let verifier_builder = WebPkiClientVerifier::builder(Arc::new(root_store)); - let verifier = if require_client_auth { - verifier_builder - } else { - verifier_builder.allow_unauthenticated() + /// Return a fresh `tokio_rustls::TlsAcceptor` backed by the current config + /// snapshot. Each call clones the active `Arc` so it remains + /// alive for the duration of the TLS handshake. + #[must_use] + pub fn acceptor(&self) -> tokio_rustls::TlsAcceptor { + tokio_rustls::TlsAcceptor::from(self.config.load_full()) + } + + /// Spawn a background worker that periodically calls [`reload`](Self::reload). + /// + /// On each tick, the worker re-reads the cert/key/CA files from disk and + /// atomically swaps the active config. If reload fails, the old config is + /// preserved and a warning is logged. + /// + /// The worker exits when the `shutdown` watch channel fires, allowing the + /// gateway to perform a clean graceful shutdown without orphaned reload + /// tasks. + pub fn spawn_reload_worker( + &self, + interval: Duration, + mut shutdown: watch::Receiver, + ) -> tokio::task::JoinHandle<()> { + let this = self.clone(); + info!( + interval_seconds = interval.as_secs(), + "TLS certificate reload worker started" + ); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(interval); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // Skip the immediate first tick + ticker.tick().await; + loop { + tokio::select! { + _ = ticker.tick() => { + if let Err(e) = this.reload() { + warn!(error = %e, "TLS certificate reload failed, keeping existing config"); + } + } + _ = shutdown.changed() => { + debug!("TLS certificate reload worker stopped"); + break; + } + } } - .build() - .map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?; + }) + } +} - ServerConfig::builder() - .with_client_cert_verifier(verifier) - .with_single_cert(certs, key) - .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? +/// Build a `ServerConfig` from certificate, key, and optional client CA files. +fn build_server_config( + cert_path: &Path, + key_path: &Path, + client_ca_path: Option<&Path>, + require_client_auth: bool, +) -> Result> { + let certs = load_certs(cert_path)?; + let key = load_key(key_path)?; + + // Validate the key type early — rustls defers this to handshake time, + // which produces a cryptic error. A bad key type surfaces clearly here. + sign::any_supported_type(&key) + .map_err(|e| Error::tls(format!("unsupported private key type: {e}")))?; + + let mut config = if let Some(ca_path) = client_ca_path { + let ca_certs = load_certs(ca_path)?; + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store + .add(cert) + .map_err(|e| Error::tls(format!("failed to add CA certificate: {e}")))?; + } + + let verifier_builder = WebPkiClientVerifier::builder(Arc::new(root_store)); + let verifier = if require_client_auth { + verifier_builder } else { - ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(certs, key) - .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? - }; + verifier_builder.allow_unauthenticated() + } + .build() + .map_err(|e| Error::tls(format!("failed to build client verifier: {e}")))?; - config - .alpn_protocols - .extend([b"h2".to_vec(), b"http/1.1".to_vec()]); + ServerConfig::builder() + .with_client_cert_verifier(verifier) + .with_single_cert(certs, key) + .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + } else { + ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .map_err(|e| Error::tls(format!("failed to create TLS config: {e}")))? + }; - Ok(Self { - acceptor: tokio_rustls::TlsAcceptor::from(Arc::new(config)), - }) - } + config + .alpn_protocols + .extend([b"h2".to_vec(), b"http/1.1".to_vec()]); - /// Get the inner tokio-rustls acceptor. - #[must_use] - #[allow(clippy::missing_const_for_fn)] - pub fn inner(&self) -> &tokio_rustls::TlsAcceptor { - &self.acceptor - } + Ok(Arc::new(config)) } /// Load certificates from a PEM file. @@ -128,3 +221,532 @@ fn load_key(path: &Path) -> Result> { Err(Error::tls("no private key found in file")) } + +#[cfg(test)] +mod tests { + use super::*; + use rcgen::{CertificateParams, IsCa, KeyPair, KeyUsagePurpose}; + use std::io::Write; + use tokio::net::{TcpListener, TcpStream}; + + fn install_rustls_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + /// Generate test CA + server certs in `dir`, returning the CA cert and + /// keypair so callers can sign additional server or client certificates. + fn generate_test_certs_with_ca(dir: &Path) -> (rcgen::Certificate, KeyPair) { + let mut ca_params = + CertificateParams::new(Vec::::new()).expect("failed to create CA params"); + ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "test-ca"); + let ca_key = KeyPair::generate().expect("failed to generate CA key"); + let ca_cert = ca_params + .self_signed(&ca_key) + .expect("failed to sign CA cert"); + + let server_params = CertificateParams::new(vec!["localhost".to_string()]) + .expect("failed to create server params"); + let server_key = KeyPair::generate().expect("failed to generate server key"); + let server_cert = server_params + .signed_by(&server_key, &ca_cert, &ca_key) + .expect("failed to sign server cert"); + + let write_file = |name: &str, data: &[u8]| { + let path = dir.join(name); + File::create(&path) + .and_then(|mut file| file.write_all(data)) + .expect("failed to write test file"); + }; + write_file("ca.pem", ca_cert.pem().as_bytes()); + write_file("server-cert.pem", server_cert.pem().as_bytes()); + write_file("server-key.pem", server_key.serialize_pem().as_bytes()); + + (ca_cert, ca_key) + } + + /// Generate a new server cert + key in `dir`, signed by the given CA. + /// Overwrites `server-cert.pem` and `server-key.pem`. + fn generate_server_cert(ca_cert: &rcgen::Certificate, ca_key: &KeyPair, dir: &Path) { + let server_params = CertificateParams::new(vec!["localhost".to_string()]) + .expect("failed to create server params"); + let server_key = KeyPair::generate().expect("failed to generate server key"); + let server_cert = server_params + .signed_by(&server_key, ca_cert, ca_key) + .expect("failed to sign server cert"); + + let write_file = |name: &str, data: &[u8]| { + let path = dir.join(name); + File::create(&path) + .and_then(|mut file| file.write_all(data)) + .expect("failed to write test file"); + }; + write_file("server-cert.pem", server_cert.pem().as_bytes()); + write_file("server-key.pem", server_key.serialize_pem().as_bytes()); + } + + fn build_test_client_config(ca_path: &Path) -> Arc { + let ca_certs = load_certs(ca_path).expect("failed to load CA certs"); + let mut root_store = rustls::RootCertStore::empty(); + for cert in ca_certs { + root_store + .add(cert) + .expect("failed to add CA to root store"); + } + Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_no_client_auth(), + ) + } + + #[test] + fn test_build_server_config() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let _ = generate_test_certs_with_ca(dir.path()); + + let config = build_server_config( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build server config"); + + assert!(config.alpn_protocols.contains(&b"h2".to_vec())); + assert!(config.alpn_protocols.contains(&b"http/1.1".to_vec())); + } + + #[test] + fn test_reload_success() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build acceptor"); + + generate_server_cert(&ca_cert, &ca_key, dir.path()); + acceptor.reload().expect("reload should succeed"); + } + + #[test] + fn test_reload_invalid_preserves_old() { + install_rustls_provider(); + let dir = tempfile::tempdir().expect("failed to create tempdir"); + generate_test_certs_with_ca(dir.path()); + + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build acceptor"); + + // Snapshot the current config before corrupting files. + let acceptor_before = acceptor.acceptor(); + + std::fs::write(dir.path().join("server-cert.pem"), b"garbage") + .expect("failed to write garbage"); + + assert!(acceptor.reload().is_err(), "reload with garbage cert should fail"); + + // Old config must still be accessible after a failed reload. + drop(acceptor_before); + let _ = acceptor.acceptor(); // does not panic + } + + #[tokio::test(flavor = "multi_thread")] + async fn test_concurrent_handshake_and_reload() { + install_rustls_provider(); + + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build acceptor"); + + let client_config = build_test_client_config(&dir.path().join("ca.pem")); + let connector = tokio_rustls::TlsConnector::from(client_config); + let server_name = + rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name"); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let listen_addr = listener.local_addr().expect("failed to get local addr"); + + let acceptor_for_server = acceptor.clone(); + let (server_done_tx, mut server_done_rx) = watch::channel(false); + let server_handle = tokio::spawn(async move { + loop { + tokio::select! { + result = listener.accept() => { + match result { + Ok((stream, _)) => { + let acc = acceptor_for_server.clone(); + tokio::spawn(async move { + let _ = acc.acceptor().accept(stream).await; + }); + } + Err(_) => break, + } + } + _ = server_done_rx.changed() => break, + } + } + }); + + let num_clients = 10; + let cycles_per_client = 5; + let mut client_handles = Vec::with_capacity(num_clients); + + for i in 0..num_clients { + let connector = connector.clone(); + let name = server_name.clone(); + let addr = listen_addr; + client_handles.push(tokio::spawn(async move { + for _ in 0..cycles_per_client { + let tcp = TcpStream::connect(addr) + .await + .expect("client connect failed"); + let _tls = connector + .connect(name.clone(), tcp) + .await + .expect("client TLS handshake failed"); + } + i + })); + } + + let reload_handle = tokio::spawn(async move { + for _ in 0..20 { + generate_server_cert(&ca_cert, &ca_key, dir.path()); + acceptor.reload().expect("reload with valid cert should succeed"); + tokio::time::sleep(Duration::from_millis(5)).await; + } + }); + + let mut client_failures = 0; + for handle in client_handles { + match handle.await { + Err(e) if e.is_panic() => client_failures += 1, + _ => {} + } + } + assert_eq!(client_failures, 0, "some client tasks panicked"); + + reload_handle.await.expect("reload task panicked"); + + let _ = server_done_tx.send(true); + let _ = tokio::time::timeout(Duration::from_secs(2), server_handle).await; + } + + #[tokio::test] + async fn test_reload_serves_new_cert() { + // Helper to extract the DER-encoded server certificate from a TLS session. + fn peer_cert_der(tls: &tokio_rustls::client::TlsStream) -> Vec { + tls.get_ref().1.peer_certificates().expect("no peer certs")[0] + .as_ref() + .to_vec() + } + + install_rustls_provider(); + + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (ca_cert, ca_key) = generate_test_certs_with_ca(dir.path()); + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build acceptor"); + + let client_config = build_test_client_config(&dir.path().join("ca.pem")); + let connector = tokio_rustls::TlsConnector::from(client_config); + let server_name = + rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name"); + + // Connection 1: original cert + let listener1 = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr1 = listener1.local_addr().expect("failed to get local addr"); + let acc1 = acceptor.clone(); + let server_task_1 = tokio::spawn(async move { + let (stream, _) = listener1.accept().await.expect("accept failed"); + acc1.acceptor() + .accept(stream) + .await + .expect("TLS accept failed"); + }); + let tcp_1 = TcpStream::connect(addr1) + .await + .expect("connect failed"); + let tls_1 = connector + .connect(server_name.clone(), tcp_1) + .await + .expect("TLS handshake failed"); + let server_cert_1 = peer_cert_der(&tls_1); + server_task_1.await.expect("server task 1 failed"); + assert!(!server_cert_1.is_empty()); + + // Generate new server cert + reload + generate_server_cert(&ca_cert, &ca_key, dir.path()); + acceptor.reload().expect("reload should succeed"); + + // Connection 2: new cert on a fresh listener + let listener2 = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let addr2 = listener2.local_addr().expect("failed to get local addr"); + let acc2 = acceptor.clone(); + let server_task_2 = tokio::spawn(async move { + let (stream, _) = listener2.accept().await.expect("accept failed"); + acc2.acceptor() + .accept(stream) + .await + .expect("TLS accept failed"); + }); + let tcp_2 = TcpStream::connect(addr2) + .await + .expect("connect failed"); + let tls_2 = connector + .connect(server_name.clone(), tcp_2) + .await + .expect("TLS handshake failed"); + let server_cert_2 = peer_cert_der(&tls_2); + server_task_2.await.expect("server task 2 failed"); + assert!(!server_cert_2.is_empty()); + + assert_ne!( + server_cert_1, server_cert_2, + "served cert should change after reload" + ); + } + + #[tokio::test] + async fn test_reload_worker_shutdown() { + install_rustls_provider(); + + let dir = tempfile::tempdir().expect("failed to create tempdir"); + generate_test_certs_with_ca(dir.path()); + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + false, + ) + .expect("failed to build acceptor"); + + let (shutdown_tx, shutdown_rx) = watch::channel(false); + let handle = acceptor.spawn_reload_worker(Duration::from_millis(10), shutdown_rx); + + // Let the worker tick at least once + tokio::time::sleep(Duration::from_millis(50)).await; + + // Send shutdown signal and verify the worker exits promptly + let _ = shutdown_tx.send(true); + tokio::time::timeout(Duration::from_secs(2), handle) + .await + .expect("reload worker should exit after shutdown signal") + .expect("reload worker should not panic"); + } + + #[tokio::test] + async fn test_reload_mtls_ca_rotation() { + install_rustls_provider(); + + let dir = tempfile::tempdir().expect("failed to create tempdir"); + let (initial_ca_cert, initial_ca_key) = generate_test_certs_with_ca(dir.path()); + + let acceptor = TlsAcceptor::from_files( + &dir.path().join("server-cert.pem"), + &dir.path().join("server-key.pem"), + Some(&dir.path().join("ca.pem")), + true, // require mTLS + ) + .expect("failed to build acceptor with mTLS"); + + // Generate new CA and overwrite ca.pem + let mut new_ca_params = + CertificateParams::new(Vec::::new()).expect("failed to create CA params"); + new_ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + new_ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "new-ca"); + let new_ca_key = KeyPair::generate().expect("failed to generate new CA key"); + let new_ca_cert = new_ca_params + .self_signed(&new_ca_key) + .expect("failed to sign new CA cert"); + std::fs::write( + dir.path().join("ca.pem"), + new_ca_cert.pem().as_bytes(), + ) + .expect("failed to write new CA"); + + // Generate new server cert signed by new CA + let server_params = CertificateParams::new(vec!["localhost".to_string()]) + .expect("failed to create server params"); + let server_key = KeyPair::generate().expect("failed to generate server key"); + let server_cert = server_params + .signed_by(&server_key, &new_ca_cert, &new_ca_key) + .expect("failed to sign server cert"); + std::fs::write( + dir.path().join("server-cert.pem"), + server_cert.pem().as_bytes(), + ) + .expect("failed to write server cert"); + std::fs::write( + dir.path().join("server-key.pem"), + server_key.serialize_pem().as_bytes(), + ) + .expect("failed to write server key"); + + acceptor.reload().expect("reload with new CA should succeed"); + + // Generate client cert signed by new CA, write to files + let client_key = KeyPair::generate().expect("failed to generate client key"); + let mut client_params = CertificateParams::new(Vec::::new()) + .expect("failed to create client params"); + client_params + .distinguished_name + .push(rcgen::DnType::CommonName, "test-client"); + client_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let client_cert = client_params + .signed_by(&client_key, &new_ca_cert, &new_ca_key) + .expect("failed to sign client cert"); + + // Write client cert + key as PEM files and load via load_certs/load_key + let client_cert_path = dir.path().join("client-cert.pem"); + let client_key_path = dir.path().join("client-key.pem"); + std::fs::write(&client_cert_path, client_cert.pem().as_bytes()) + .expect("failed to write client cert"); + std::fs::write(&client_key_path, client_key.serialize_pem().as_bytes()) + .expect("failed to write client key"); + + let client_cert_chain = load_certs(&client_cert_path).expect("failed to load client cert"); + let client_private_key = load_key(&client_key_path).expect("failed to load client key"); + + let mut root_store = rustls::RootCertStore::empty(); + root_store + .add(CertificateDer::from(new_ca_cert.der().to_vec())) + .expect("failed to add new CA to root store"); + let new_ca_client_config = Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(root_store) + .with_client_auth_cert(client_cert_chain, client_private_key) + .expect("failed to set client auth cert"), + ); + + // Verify a real handshake succeeds with the new CA + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let listen_addr = listener.local_addr().expect("failed to get local addr"); + + let acceptor_srv = acceptor.clone(); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept failed"); + acceptor_srv + .acceptor() + .accept(stream) + .await + .expect("TLS accept with new mTLS CA failed"); + }); + + let connector = tokio_rustls::TlsConnector::from(new_ca_client_config); + let server_name = + rustls::pki_types::ServerName::try_from("localhost").expect("invalid server name"); + let tcp = TcpStream::connect(listen_addr) + .await + .expect("connect failed"); + connector + .connect(server_name.clone(), tcp) + .await + .expect("mTLS handshake with new CA should succeed"); + + server_task.await.expect("server task failed"); + + // Verify old CA is no longer trusted: a client cert signed by the + // initial CA should be rejected after rotation. + let old_client_key = KeyPair::generate().expect("failed to generate old client key"); + let mut old_client_params = CertificateParams::new(Vec::::new()) + .expect("failed to create old client params"); + old_client_params + .distinguished_name + .push(rcgen::DnType::CommonName, "old-client"); + old_client_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let old_client_cert = old_client_params + .signed_by(&old_client_key, &initial_ca_cert, &initial_ca_key) + .expect("failed to sign old client cert"); + + let old_cert_path = dir.path().join("old-client-cert.pem"); + let old_key_path = dir.path().join("old-client-key.pem"); + std::fs::write(&old_cert_path, old_client_cert.pem().as_bytes()) + .expect("failed to write old client cert"); + std::fs::write(&old_key_path, old_client_key.serialize_pem().as_bytes()) + .expect("failed to write old client key"); + + let old_cert_chain = load_certs(&old_cert_path).expect("failed to load old client cert"); + let old_key_der = load_key(&old_key_path).expect("failed to load old client key"); + + let mut old_root_store = rustls::RootCertStore::empty(); + old_root_store + .add(CertificateDer::from(new_ca_cert.der().to_vec())) + .expect("failed to add new CA to root store"); + let old_ca_client_config = Arc::new( + rustls::ClientConfig::builder() + .with_root_certificates(old_root_store) + .with_client_auth_cert(old_cert_chain, old_key_der) + .expect("failed to set old client auth cert"), + ); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind"); + let listen_addr = listener.local_addr().expect("failed to get local addr"); + + let acceptor_srv = acceptor.clone(); + let server_task = tokio::spawn(async move { + let (stream, _) = listener.accept().await.expect("accept failed"); + let result = acceptor_srv.acceptor().accept(stream).await; + assert!( + result.is_err(), + "TLS accept should reject client cert signed by old CA" + ); + }); + + // Drive the client side so the server has something to accept. + // tokio_rustls::TlsConnector::connect() may return Ok even when the + // server rejects the client cert (TLS 1.3 post-handshake auth), + // so the authoritative check is the server-side accept result above. + let connector = tokio_rustls::TlsConnector::from(old_ca_client_config); + let tcp = TcpStream::connect(listen_addr) + .await + .expect("connect failed"); + let _ = connector.connect(server_name, tcp).await; + + server_task.await.expect("server task failed"); + } +} diff --git a/crates/openshell-server/tests/common/mod.rs b/crates/openshell-server/tests/common/mod.rs index 00228b043..3077cf4c9 100644 --- a/crates/openshell-server/tests/common/mod.rs +++ b/crates/openshell-server/tests/common/mod.rs @@ -566,7 +566,7 @@ pub async fn start_test_server( let svc = service.clone(); let tls = tls_acceptor.clone(); tokio::spawn(async move { - let Ok(tls_stream) = tls.inner().accept(stream).await else { + let Ok(tls_stream) = tls.acceptor().accept(stream).await else { return; }; let _ = Builder::new(TokioExecutor::new()) From 6beb3c34d5d125f35c5d16bc72ef3c3097d6194e Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Thu, 11 Jun 2026 19:29:51 +0800 Subject: [PATCH 2/3] refactor(server): extract shared TLS test utilities Signed-off-by: Yuedong Wu --- crates/openshell-server/src/lib.rs | 42 ++----- crates/openshell-server/src/tls.rs | 106 ++++-------------- crates/openshell-server/src/tls_test_utils.rs | 62 ++++++++++ 3 files changed, 95 insertions(+), 115 deletions(-) create mode 100644 crates/openshell-server/src/tls_test_utils.rs diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index e23bbbb34..5ad105f01 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -40,6 +40,8 @@ mod ssh_sessions; pub mod supervisor_session; mod telemetry; mod tls; +#[cfg(test)] +pub(crate) mod tls_test_utils; pub mod tracing_bus; mod ws_tunnel; @@ -426,7 +428,10 @@ pub async fn run_server( // Spawn TLS certificate reload worker if enabled if tls.reload_interval_secs > 0 { - acceptor.spawn_reload_worker(Duration::from_secs(tls.reload_interval_secs), shutdown_rx.clone()); + acceptor.spawn_reload_worker( + Duration::from_secs(tls.reload_interval_secs), + shutdown_rx.clone(), + ); } Some(acceptor) @@ -919,8 +924,7 @@ mod tests { ComputeDriverKind, Config, proto::{HealthRequest, open_shell_client::OpenShellClient}, }; - use rcgen::{CertificateParams, IsCa, KeyPair}; - use std::io::{Error, ErrorKind, Write}; + use std::io::{Error, ErrorKind}; use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; @@ -929,41 +933,13 @@ mod tests { use tokio::net::{TcpListener, TcpStream}; use tokio::sync::watch; - fn install_rustls_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); - } + use crate::tls_test_utils::{generate_test_certs_with_ca, install_rustls_provider}; fn test_tls_acceptor() -> (TempDir, TlsAcceptor) { install_rustls_provider(); - let mut ca_params = - CertificateParams::new(Vec::::new()).expect("failed to create CA params"); - ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, "test-ca"); - let ca_key = KeyPair::generate().expect("failed to generate CA key"); - let ca_cert = ca_params - .self_signed(&ca_key) - .expect("failed to sign CA cert"); - - let server_params = CertificateParams::new(vec!["localhost".to_string()]) - .expect("failed to create server params"); - let server_key = KeyPair::generate().expect("failed to generate server key"); - let server_cert = server_params - .signed_by(&server_key, &ca_cert, &ca_key) - .expect("failed to sign server cert"); - let dir = tempdir().expect("failed to create tempdir"); - let write_file = |name: &str, data: &[u8]| { - let path = dir.path().join(name); - std::fs::File::create(&path) - .and_then(|mut file| file.write_all(data)) - .expect("failed to write tls test file"); - }; - write_file("ca.pem", ca_cert.pem().as_bytes()); - write_file("server-cert.pem", server_cert.pem().as_bytes()); - write_file("server-key.pem", server_key.serialize_pem().as_bytes()); + generate_test_certs_with_ca(dir.path()); let acceptor = TlsAcceptor::from_files( &dir.path().join("server-cert.pem"), diff --git a/crates/openshell-server/src/tls.rs b/crates/openshell-server/src/tls.rs index 6d47db277..3177f3ddc 100644 --- a/crates/openshell-server/src/tls.rs +++ b/crates/openshell-server/src/tls.rs @@ -11,10 +11,10 @@ use std::time::Duration; use arc_swap::ArcSwap; use openshell_core::{Error, Result}; +use rustls::ServerConfig; use rustls::crypto::ring::sign; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::server::WebPkiClientVerifier; -use rustls::ServerConfig; use tokio::sync::watch; use tracing::{debug, info, warn}; @@ -225,48 +225,12 @@ fn load_key(path: &Path) -> Result> { #[cfg(test)] mod tests { use super::*; + use crate::tls_test_utils::{ + generate_test_certs_with_ca, install_rustls_provider, write_test_file, + }; use rcgen::{CertificateParams, IsCa, KeyPair, KeyUsagePurpose}; - use std::io::Write; use tokio::net::{TcpListener, TcpStream}; - fn install_rustls_provider() { - let _ = rustls::crypto::ring::default_provider().install_default(); - } - - /// Generate test CA + server certs in `dir`, returning the CA cert and - /// keypair so callers can sign additional server or client certificates. - fn generate_test_certs_with_ca(dir: &Path) -> (rcgen::Certificate, KeyPair) { - let mut ca_params = - CertificateParams::new(Vec::::new()).expect("failed to create CA params"); - ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); - ca_params - .distinguished_name - .push(rcgen::DnType::CommonName, "test-ca"); - let ca_key = KeyPair::generate().expect("failed to generate CA key"); - let ca_cert = ca_params - .self_signed(&ca_key) - .expect("failed to sign CA cert"); - - let server_params = CertificateParams::new(vec!["localhost".to_string()]) - .expect("failed to create server params"); - let server_key = KeyPair::generate().expect("failed to generate server key"); - let server_cert = server_params - .signed_by(&server_key, &ca_cert, &ca_key) - .expect("failed to sign server cert"); - - let write_file = |name: &str, data: &[u8]| { - let path = dir.join(name); - File::create(&path) - .and_then(|mut file| file.write_all(data)) - .expect("failed to write test file"); - }; - write_file("ca.pem", ca_cert.pem().as_bytes()); - write_file("server-cert.pem", server_cert.pem().as_bytes()); - write_file("server-key.pem", server_key.serialize_pem().as_bytes()); - - (ca_cert, ca_key) - } - /// Generate a new server cert + key in `dir`, signed by the given CA. /// Overwrites `server-cert.pem` and `server-key.pem`. fn generate_server_cert(ca_cert: &rcgen::Certificate, ca_key: &KeyPair, dir: &Path) { @@ -277,14 +241,8 @@ mod tests { .signed_by(&server_key, ca_cert, ca_key) .expect("failed to sign server cert"); - let write_file = |name: &str, data: &[u8]| { - let path = dir.join(name); - File::create(&path) - .and_then(|mut file| file.write_all(data)) - .expect("failed to write test file"); - }; - write_file("server-cert.pem", server_cert.pem().as_bytes()); - write_file("server-key.pem", server_key.serialize_pem().as_bytes()); + write_test_file(dir, "server-cert.pem", server_cert.pem().as_bytes()); + write_test_file(dir, "server-key.pem", server_key.serialize_pem().as_bytes()); } fn build_test_client_config(ca_path: &Path) -> Arc { @@ -358,7 +316,10 @@ mod tests { std::fs::write(dir.path().join("server-cert.pem"), b"garbage") .expect("failed to write garbage"); - assert!(acceptor.reload().is_err(), "reload with garbage cert should fail"); + assert!( + acceptor.reload().is_err(), + "reload with garbage cert should fail" + ); // Old config must still be accessible after a failed reload. drop(acceptor_before); @@ -435,7 +396,9 @@ mod tests { let reload_handle = tokio::spawn(async move { for _ in 0..20 { generate_server_cert(&ca_cert, &ca_key, dir.path()); - acceptor.reload().expect("reload with valid cert should succeed"); + acceptor + .reload() + .expect("reload with valid cert should succeed"); tokio::time::sleep(Duration::from_millis(5)).await; } }); @@ -494,9 +457,7 @@ mod tests { .await .expect("TLS accept failed"); }); - let tcp_1 = TcpStream::connect(addr1) - .await - .expect("connect failed"); + let tcp_1 = TcpStream::connect(addr1).await.expect("connect failed"); let tls_1 = connector .connect(server_name.clone(), tcp_1) .await @@ -522,9 +483,7 @@ mod tests { .await .expect("TLS accept failed"); }); - let tcp_2 = TcpStream::connect(addr2) - .await - .expect("connect failed"); + let tcp_2 = TcpStream::connect(addr2).await.expect("connect failed"); let tls_2 = connector .connect(server_name.clone(), tcp_2) .await @@ -593,36 +552,19 @@ mod tests { let new_ca_cert = new_ca_params .self_signed(&new_ca_key) .expect("failed to sign new CA cert"); - std::fs::write( - dir.path().join("ca.pem"), - new_ca_cert.pem().as_bytes(), - ) - .expect("failed to write new CA"); - - // Generate new server cert signed by new CA - let server_params = CertificateParams::new(vec!["localhost".to_string()]) - .expect("failed to create server params"); - let server_key = KeyPair::generate().expect("failed to generate server key"); - let server_cert = server_params - .signed_by(&server_key, &new_ca_cert, &new_ca_key) - .expect("failed to sign server cert"); - std::fs::write( - dir.path().join("server-cert.pem"), - server_cert.pem().as_bytes(), - ) - .expect("failed to write server cert"); - std::fs::write( - dir.path().join("server-key.pem"), - server_key.serialize_pem().as_bytes(), - ) - .expect("failed to write server key"); + std::fs::write(dir.path().join("ca.pem"), new_ca_cert.pem().as_bytes()) + .expect("failed to write new CA"); - acceptor.reload().expect("reload with new CA should succeed"); + // Generate new server cert signed by new CA and reload + generate_server_cert(&new_ca_cert, &new_ca_key, dir.path()); + acceptor + .reload() + .expect("reload with new CA should succeed"); // Generate client cert signed by new CA, write to files let client_key = KeyPair::generate().expect("failed to generate client key"); - let mut client_params = CertificateParams::new(Vec::::new()) - .expect("failed to create client params"); + let mut client_params = + CertificateParams::new(Vec::::new()).expect("failed to create client params"); client_params .distinguished_name .push(rcgen::DnType::CommonName, "test-client"); diff --git a/crates/openshell-server/src/tls_test_utils.rs b/crates/openshell-server/src/tls_test_utils.rs new file mode 100644 index 000000000..aee83c49e --- /dev/null +++ b/crates/openshell-server/src/tls_test_utils.rs @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Shared test helpers for TLS-related tests. + +use std::fs::File; +use std::io::Write; +use std::path::Path; + +use rcgen::{CertificateParams, IsCa, KeyPair}; + +/// Install the default rustls crypto provider. +/// +/// Must be called once at the start of any test that exercises TLS handshakes. +/// Multiple calls are harmless (subsequent calls return an error, ignored). +pub fn install_rustls_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +/// Write bytes to a file inside `dir`, panicking on failure. +pub fn write_test_file(dir: &Path, name: &str, data: &[u8]) { + let path = dir.join(name); + File::create(&path) + .and_then(|mut file| file.write_all(data)) + .expect("failed to write test file"); +} + +/// Generate a self-signed CA certificate and a `localhost` server certificate, +/// writing them as PEM files into `dir`. +/// +/// Returns the CA certificate and keypair so callers can sign additional +/// server or client certificates. +/// +/// Files written: +/// - `ca.pem` +/// - `server-cert.pem` +/// - `server-key.pem` +pub fn generate_test_certs_with_ca(dir: &Path) -> (rcgen::Certificate, KeyPair) { + let mut ca_params = + CertificateParams::new(Vec::::new()).expect("failed to create CA params"); + ca_params.is_ca = IsCa::Ca(rcgen::BasicConstraints::Unconstrained); + ca_params + .distinguished_name + .push(rcgen::DnType::CommonName, "test-ca"); + let ca_key = KeyPair::generate().expect("failed to generate CA key"); + let ca_cert = ca_params + .self_signed(&ca_key) + .expect("failed to sign CA cert"); + + let server_params = CertificateParams::new(vec!["localhost".to_string()]) + .expect("failed to create server params"); + let server_key = KeyPair::generate().expect("failed to generate server key"); + let server_cert = server_params + .signed_by(&server_key, &ca_cert, &ca_key) + .expect("failed to sign server cert"); + + write_test_file(dir, "ca.pem", ca_cert.pem().as_bytes()); + write_test_file(dir, "server-cert.pem", server_cert.pem().as_bytes()); + write_test_file(dir, "server-key.pem", server_key.serialize_pem().as_bytes()); + + (ca_cert, ca_key) +} From c30d30fb70c4e304651de6cc3fcd09d15844dedb Mon Sep 17 00:00:00 2001 From: Yuedong Wu Date: Thu, 11 Jun 2026 19:29:55 +0800 Subject: [PATCH 3/3] feat(helm): add TLS certificate reload interval config Signed-off-by: Yuedong Wu --- deploy/helm/openshell/README.md | 1 + deploy/helm/openshell/templates/gateway-config.yaml | 3 +++ deploy/helm/openshell/values.yaml | 6 ++++++ docs/reference/gateway-config.mdx | 4 ++++ 4 files changed, 14 insertions(+) diff --git a/deploy/helm/openshell/README.md b/deploy/helm/openshell/README.md index ab5b6eb45..3b020ba54 100644 --- a/deploy/helm/openshell/README.md +++ b/deploy/helm/openshell/README.md @@ -216,6 +216,7 @@ JWT signing Secret. | server.tls.certSecretName | string | `"openshell-server-tls"` | K8s secret (type kubernetes.io/tls) with tls.crt and tls.key for the server. | | server.tls.clientCaSecretName | string | `"openshell-server-client-ca"` | K8s secret with ca.crt for client certificate verification (mTLS). Set to "" to disable mTLS and run HTTPS-only (use OIDC for auth instead). | | server.tls.clientTlsSecretName | string | `"openshell-client-tls"` | K8s secret mounted into sandbox pods for mTLS to the server. | +| server.tls.reloadIntervalSecs | int | `0` | Interval in seconds between TLS certificate reload checks. Set to 0 to disable periodic reload (the default). Set to a positive value (e.g., 60 or 600) to enable polling-based hot-reload. The gateway re-reads cert/key/CA files on each tick and atomically swaps the active TLS config when they change. | | server.workspaceDefaultStorageSize | string | `""` | Default storage size for the workspace PVC in sandbox pods. Uses Kubernetes quantity syntax (e.g. "2Gi", "10Gi", "500Mi"). Empty = built-in default (2Gi). | | service.healthPort | int | `8081` | Gateway health service port. | | service.metricsPort | int | `9090` | Gateway metrics service port. | diff --git a/deploy/helm/openshell/templates/gateway-config.yaml b/deploy/helm/openshell/templates/gateway-config.yaml index f46547c3f..95e69318f 100644 --- a/deploy/helm/openshell/templates/gateway-config.yaml +++ b/deploy/helm/openshell/templates/gateway-config.yaml @@ -63,6 +63,9 @@ data: cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" + {{- if .Values.server.tls.reloadIntervalSecs }} + reload_interval_secs = {{ .Values.server.tls.reloadIntervalSecs }} + {{- end }} {{- end }} {{- if .Values.server.auth.allowUnauthenticatedUsers }} diff --git a/deploy/helm/openshell/values.yaml b/deploy/helm/openshell/values.yaml index f0cd43c73..61506d9df 100644 --- a/deploy/helm/openshell/values.yaml +++ b/deploy/helm/openshell/values.yaml @@ -208,6 +208,12 @@ server: clientCaSecretName: openshell-server-client-ca # -- K8s secret mounted into sandbox pods for mTLS to the server. clientTlsSecretName: openshell-client-tls + # -- Interval in seconds between TLS certificate reload checks. + # Set to 0 to disable periodic reload (the default). Set to a positive + # value (e.g., 60 or 600) to enable polling-based hot-reload. The gateway + # re-reads cert/key/CA files on each tick and atomically swaps the active + # TLS config when they change. + reloadIntervalSecs: 0 # Gateway-minted sandbox JWT signing keys. The certgen hook generates an # Ed25519 keypair and writes it to a secret containing signing.pem (PKCS#8), # public.pem (SPKI), and kid (plain text). The hook runs in full PKI mode when diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index c70d8acbd..40c96ce78 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -104,6 +104,9 @@ cert_path = "/etc/openshell/certs/gateway.pem" key_path = "/etc/openshell/certs/gateway-key.pem" client_ca_path = "/etc/openshell/certs/client-ca.pem" require_client_auth = false +# Polling interval in seconds for hot-reloading TLS certificates. +# Set to 0 to disable reload (the default). +# reload_interval_secs = 600 [openshell.gateway.gateway_jwt] signing_key_path = "/etc/openshell/jwt/signing.pem" @@ -160,6 +163,7 @@ compute_drivers = ["kubernetes"] cert_path = "/etc/openshell-tls/server/tls.crt" key_path = "/etc/openshell-tls/server/tls.key" client_ca_path = "/etc/openshell-tls/client-ca/ca.crt" +# reload_interval_secs = 600 # opt-in: polling interval for TLS cert hot-reload [openshell.drivers.kubernetes] namespace = "agents"