cdfad420bb
Extract the TLS-without-CA guard into a shared `build_tls_config` helper in options.rs so both GatewayClient and GalaxyClient use identical logic. GalaxyClient previously had no guard, so TLS-without-CA produced a cryptic tonic handshake failure; it now returns the same actionable InvalidEndpoint error. The guard message notes that a server-name override affects SNI but does not pin trust. Add --require-certificate-validation to ConnectionArgs in the CLI binary. Add a mirror test for GalaxyClient in tests/tls.rs.
283 lines
11 KiB
Rust
283 lines
11 KiB
Rust
//! 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<ApiKey>,
|
|
plaintext: bool,
|
|
ca_file: Option<PathBuf>,
|
|
require_certificate_validation: bool,
|
|
server_name_override: Option<String>,
|
|
connect_timeout: Duration,
|
|
call_timeout: Duration,
|
|
stream_timeout: Option<Duration>,
|
|
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<String>) -> 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<PathBuf>) -> Self {
|
|
self.ca_file = Some(ca_file.into());
|
|
self
|
|
}
|
|
|
|
/// Require TLS certificate verification even without a pinned CA. Default
|
|
/// false: the gateway's self-signed certificate is accepted (internal-tool
|
|
/// posture). Setting a CA file always verifies.
|
|
///
|
|
/// 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. Either pin a CA via
|
|
/// [`ClientOptions::with_ca_file`] (the supported lenient path on Rust) or
|
|
/// set this `true` to verify against the system trust roots.
|
|
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<String>) -> 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<Duration> {
|
|
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
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
/// Returns `Err(Error::InvalidEndpoint)` when TLS is requested but no pinned
|
|
/// CA was provided and `require_certificate_validation` is `false`.
|
|
///
|
|
/// # Why this 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 system-root verification (which always fails
|
|
/// against a self-signed gateway certificate), we reject the configuration
|
|
/// early with an actionable error.
|
|
pub(crate) fn build_tls_config(options: &ClientOptions) -> Result<Option<ClientTlsConfig>, Error> {
|
|
if options.plaintext() {
|
|
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());
|
|
}
|
|
if let Some(ca_file) = options.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));
|
|
} else if !options.require_certificate_validation() {
|
|
// Lenient-default fallback (Rust pin-only exception): tonic
|
|
// 0.13's `ClientTlsConfig` builds its rustls verifier inside a
|
|
// crate-private connector and exposes no hook for a custom
|
|
// `ServerCertVerifier`, so — unlike the other clients — the
|
|
// Rust client cannot accept an arbitrary self-signed cert. Pin
|
|
// the gateway's CA instead, or opt into strict verification
|
|
// against the system trust roots. We reject here rather than
|
|
// silently verifying against system roots (which would fail a
|
|
// self-signed gateway with a confusing handshake error).
|
|
//
|
|
// Note: a server-name override affects SNI (the hostname sent
|
|
// in the TLS ClientHello) but does NOT pin trust. Overriding
|
|
// the server name alone does not bypass certificate validation.
|
|
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(),
|
|
});
|
|
}
|
|
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(|_| "<redacted>"))
|
|
.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;
|
|
|
|
#[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("<redacted>"));
|
|
assert!(!debug.contains("mxgw_secret"));
|
|
}
|
|
}
|