//! Connection options shared by [`crate::client::GatewayClient`] and //! [`crate::galaxy::GalaxyClient`]. Build with [`ClientOptions::new`] and a //! chain of `with_*` setters; the `Debug` impl redacts the API key. use std::fmt; use std::fs; use std::path::PathBuf; use std::time::Duration; use tonic::transport::{Certificate, ClientTlsConfig}; use crate::auth::ApiKey; use crate::error::Error; const DEFAULT_MAX_GRPC_MESSAGE_BYTES: usize = 16 * 1024 * 1024; /// Configuration for connecting to a gateway endpoint. /// /// Defaults are 10s connect timeout, 30s call timeout, no streaming timeout, /// and plaintext (h2c) transport. Set [`ClientOptions::with_plaintext`] to /// `false` and supply [`ClientOptions::with_ca_file`] / a server-name override /// for TLS deployments. #[derive(Clone)] pub struct ClientOptions { endpoint: String, api_key: Option, plaintext: bool, ca_file: Option, require_certificate_validation: bool, server_name_override: Option, connect_timeout: Duration, call_timeout: Duration, stream_timeout: Option, max_grpc_message_bytes: usize, } impl ClientOptions { /// Build options for the supplied gateway endpoint URL (for example, /// `http://127.0.0.1:5000`). Other settings take their defaults. pub fn new(endpoint: impl Into) -> Self { Self { endpoint: endpoint.into(), api_key: None, plaintext: true, ca_file: None, require_certificate_validation: false, server_name_override: None, connect_timeout: Duration::from_secs(10), call_timeout: Duration::from_secs(30), stream_timeout: None, max_grpc_message_bytes: DEFAULT_MAX_GRPC_MESSAGE_BYTES, } } /// Attach an API key. The key flows through [`crate::auth::AuthInterceptor`] /// as the Bearer token on every request. pub fn with_api_key(mut self, api_key: ApiKey) -> Self { self.api_key = Some(api_key); self } /// Toggle h2c (plaintext) vs TLS. `true` (the default) skips the TLS /// handshake and is suitable for loopback development. pub fn with_plaintext(mut self, plaintext: bool) -> Self { self.plaintext = plaintext; self } /// Trust roots PEM bundle for TLS connections. Ignored when /// `plaintext` is `true`. pub fn with_ca_file(mut self, ca_file: impl Into) -> Self { self.ca_file = Some(ca_file.into()); self } /// Require TLS certificate verification even without a pinned CA. Default /// false. Setting a CA file always verifies against that CA. /// /// Note for Rust: tonic 0.13's `ClientTlsConfig` exposes no hook for a /// custom rustls verifier, so the Rust client cannot accept an *arbitrary* /// self-signed certificate the way the other clients do. With the default /// (false) and no pinned CA, [`crate::client::GatewayClient::connect`] /// rejects the TLS connection and asks for a CA file. There are two /// supported TLS paths: /// /// - Pin the gateway certificate with [`ClientOptions::with_ca_file`] (the /// lenient pin-only path; works for a self-signed gateway cert). /// - Set this `true` to verify against the operating system's trust roots /// (`tls-native-roots`). This only succeeds for a certificate that chains /// to a root the host already trusts, so it is for gateways fronted by a /// publicly- or enterprise-CA-issued certificate, not a bare self-signed /// one. pub fn with_require_certificate_validation(mut self, require: bool) -> Self { self.require_certificate_validation = require; self } /// Override the SNI/server name used during the TLS handshake. Useful /// when the dial-target host name does not match the certificate. pub fn with_server_name_override(mut self, server_name_override: impl Into) -> Self { self.server_name_override = Some(server_name_override.into()); self } /// Maximum time the transport waits for the initial TCP/TLS connection. pub fn with_connect_timeout(mut self, connect_timeout: Duration) -> Self { self.connect_timeout = connect_timeout; self } /// Per-call deadline applied to every unary RPC. Streaming RPCs use /// [`ClientOptions::with_stream_timeout`] instead. pub fn with_call_timeout(mut self, call_timeout: Duration) -> Self { self.call_timeout = call_timeout; self } /// Optional deadline applied to streaming RPCs (for example, /// `StreamEvents`). Without a stream timeout the stream lives until the /// caller drops it or the server closes it. pub fn with_stream_timeout(mut self, stream_timeout: Duration) -> Self { self.stream_timeout = Some(stream_timeout); self } /// Maximum encoded/decoded gRPC message size in bytes (default 16 MiB). pub fn with_max_grpc_message_bytes(mut self, max_grpc_message_bytes: usize) -> Self { self.max_grpc_message_bytes = max_grpc_message_bytes; self } /// Configured endpoint URL. pub fn endpoint(&self) -> &str { &self.endpoint } /// Configured API key, if any. pub fn api_key(&self) -> Option<&ApiKey> { self.api_key.as_ref() } /// Whether the transport runs in plaintext (h2c) mode. pub fn plaintext(&self) -> bool { self.plaintext } /// Optional CA bundle path used to validate the server certificate. pub fn ca_file(&self) -> Option<&PathBuf> { self.ca_file.as_ref() } /// Whether TLS certificate verification is required even without a pinned /// CA. See [`ClientOptions::with_require_certificate_validation`]. pub fn require_certificate_validation(&self) -> bool { self.require_certificate_validation } /// Optional SNI / server-name override for TLS handshakes. pub fn server_name_override(&self) -> Option<&str> { self.server_name_override.as_deref() } /// Connect timeout used during transport setup. pub fn connect_timeout(&self) -> Duration { self.connect_timeout } /// Per-call timeout for unary RPCs. pub fn call_timeout(&self) -> Duration { self.call_timeout } /// Optional per-call timeout for streaming RPCs. pub fn stream_timeout(&self) -> Option { self.stream_timeout } /// Configured maximum encoded/decoded gRPC message size in bytes. pub fn max_grpc_message_bytes(&self) -> usize { self.max_grpc_message_bytes } } /// Where the TLS handshake gets its trust anchors for a given set of options. /// Computed by [`tls_trust_decision`] and applied by [`build_tls_config`]; /// split out so the trust posture is unit-testable without a live handshake. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TlsTrustDecision { /// Plaintext transport — no TLS, no trust anchors. None, /// Validate against the CA pinned with [`ClientOptions::with_ca_file`]. PinnedCa, /// Validate against the operating system's trust roots /// (`require_certificate_validation == true`, no pinned CA). SystemRoots, /// Reject up front: TLS requested with neither a pinned CA nor strict /// verification (the Rust pin-only lenient default). RejectNoCa, } /// Decide the TLS trust posture from `options` without touching the filesystem /// or the network. pub(crate) fn tls_trust_decision(options: &ClientOptions) -> TlsTrustDecision { if options.plaintext() { TlsTrustDecision::None } else if options.ca_file().is_some() { TlsTrustDecision::PinnedCa } else if options.require_certificate_validation() { TlsTrustDecision::SystemRoots } else { TlsTrustDecision::RejectNoCa } } /// Build the [`ClientTlsConfig`] for a non-plaintext connection described by /// `options`, applying the lenient-default guard that is the **Rust /// pin-only exception**. /// /// Returns `Ok(None)` when `options.plaintext()` is `true` (no TLS needed). /// Returns `Ok(Some(tls))` when a valid TLS config can be assembled — either /// pinned to the CA from [`ClientOptions::with_ca_file`], or, when /// `require_certificate_validation` is set with no pinned CA, verifying against /// the operating system's trust roots (`tls-native-roots`). /// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned /// CA was provided and `require_certificate_validation` is `false`. /// /// # Why the no-CA guard exists /// /// `tonic` 0.13's `ClientTlsConfig` builds its rustls verifier inside a /// crate-private connector and exposes no hook for a custom /// `ServerCertVerifier`. The Rust client therefore cannot accept an *arbitrary* /// self-signed certificate the way the other language clients do. Rather than /// silently falling back to a verifier with no trust anchors (which rejects /// every certificate with a confusing handshake error), the lenient default /// rejects the configuration early with an actionable error. The strict opt-in /// instead loads the system trust roots so a certificate chaining to an /// already-trusted root validates. pub(crate) fn build_tls_config(options: &ClientOptions) -> Result, Error> { let decision = tls_trust_decision(options); if decision == TlsTrustDecision::None { return Ok(None); } let mut tls = ClientTlsConfig::new(); if let Some(server_name) = options.server_name_override() { tls = tls.domain_name(server_name.to_owned()); } match decision { TlsTrustDecision::PinnedCa => { let ca_file = options.ca_file().expect("PinnedCa implies a CA file"); let certificate = fs::read(ca_file).map_err(|source| Error::InvalidEndpoint { endpoint: options.endpoint().to_owned(), detail: format!("failed to read CA file {}: {source}", ca_file.display()), })?; tls = tls.ca_certificate(Certificate::from_pem(certificate)); } TlsTrustDecision::SystemRoots => { // Strict opt-in with no pinned CA: verify against the OS trust // store. Without this the bare `ClientTlsConfig` carries zero // trust anchors and rejects every certificate, so the documented // "verify against the system trust roots" behaviour would be // unreachable. Only a certificate chaining to an already-trusted // root validates — a bare self-signed gateway cert still needs // `with_ca_file`. tls = tls.with_native_roots(); } TlsTrustDecision::RejectNoCa => { // Lenient-default fallback (Rust pin-only exception): the Rust // client cannot accept an arbitrary self-signed cert. Pin the // gateway's CA, or opt into strict verification against the // system trust roots. // // Note: a server-name override affects SNI (the hostname sent in // the TLS ClientHello) but does NOT pin trust. return Err(Error::InvalidEndpoint { endpoint: options.endpoint().to_owned(), detail: "TLS requested without a pinned CA. The Rust client cannot accept an \ arbitrary self-signed certificate (tonic 0.13 exposes no custom \ rustls verifier). Pin the gateway certificate with \ ClientOptions::with_ca_file, or call \ ClientOptions::with_require_certificate_validation(true) to verify \ against the system trust roots. Note: a server-name override \ affects SNI but does not pin trust." .to_owned(), }); } TlsTrustDecision::None => unreachable!("handled above"), } Ok(Some(tls)) } impl Default for ClientOptions { fn default() -> Self { Self::new("http://127.0.0.1:5000") } } impl fmt::Debug for ClientOptions { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { formatter .debug_struct("ClientOptions") .field("endpoint", &self.endpoint) .field("api_key", &self.api_key.as_ref().map(|_| "")) .field("plaintext", &self.plaintext) .field("ca_file", &self.ca_file) .field( "require_certificate_validation", &self.require_certificate_validation, ) .field("server_name_override", &self.server_name_override) .field("connect_timeout", &self.connect_timeout) .field("call_timeout", &self.call_timeout) .field("stream_timeout", &self.stream_timeout) .field("max_grpc_message_bytes", &self.max_grpc_message_bytes) .finish() } } #[cfg(test)] mod tests { use super::ClientOptions; use crate::auth::ApiKey; use super::{build_tls_config, tls_trust_decision, TlsTrustDecision}; #[test] fn debug_redacts_api_key() { let options = ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new("mxgw_secret")); let debug = format!("{options:?}"); assert!(debug.contains("")); assert!(!debug.contains("mxgw_secret")); } #[test] fn plaintext_needs_no_tls() { let options = ClientOptions::new("http://127.0.0.1:5000").with_plaintext(true); assert_eq!(tls_trust_decision(&options), TlsTrustDecision::None); assert!(build_tls_config(&options).unwrap().is_none()); } #[test] fn pinned_ca_uses_pinned_trust() { let options = ClientOptions::new("https://127.0.0.1:5000") .with_plaintext(false) .with_ca_file("/some/ca.pem"); assert_eq!(tls_trust_decision(&options), TlsTrustDecision::PinnedCa); } #[test] fn strict_without_ca_uses_system_roots() { // Regression for Client.Rust-031: strict verification with no pinned CA // must verify against the system trust roots, not produce a config with // zero trust anchors. The trust decision proves roots are consulted; the // build then succeeds (no no-CA guard error) and emits a config. let options = ClientOptions::new("https://127.0.0.1:5000") .with_plaintext(false) .with_require_certificate_validation(true); assert_eq!( tls_trust_decision(&options), TlsTrustDecision::SystemRoots, "strict-no-CA must request the system trust roots" ); assert!( build_tls_config(&options).unwrap().is_some(), "strict-no-CA must build a usable TLS config" ); } #[test] fn lenient_without_ca_is_rejected() { let options = ClientOptions::new("https://127.0.0.1:5000").with_plaintext(false); assert_eq!(tls_trust_decision(&options), TlsTrustDecision::RejectNoCa); assert!(build_tls_config(&options).is_err()); } }