From 75edf2e8cb505402c6179ae58944d50867eadfe7 Mon Sep 17 00:00:00 2001 From: pasta Date: Sat, 26 Sep 2026 19:53:44 -0500 Subject: [PATCH] fix(sdk): pass an unbracketed host as the TLS server name so IPv6 endpoints do not panic create_channel let tonic take the TLS server name from Uri::host, which keeps the brackets of an IPv6 literal ([2001:db8::1]). rustls rejects that as a server name, so tls_config(..).expect(..) panicked for every IPv6 evonode endpoint, and the explicit-CA path did the same. The host is now always passed without brackets through domain_name, and a TLS configuration error is returned as an InvalidArgument transport error instead of panicking. The error keeps tonic's cause (for example the invalid DNS name) in its message and source chain. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/transport/tonic_channel.rs | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/packages/rs-dapi-client/src/transport/tonic_channel.rs b/packages/rs-dapi-client/src/transport/tonic_channel.rs index 779bdb33f52..217ebdecf0e 100644 --- a/packages/rs-dapi-client/src/transport/tonic_channel.rs +++ b/packages/rs-dapi-client/src/transport/tonic_channel.rs @@ -3,6 +3,8 @@ use crate::{request_settings::AppliedRequestSettings, Uri}; use dapi_grpc::core::v0::core_client::CoreClient; use dapi_grpc::platform::v0::platform_client::PlatformClient; use dapi_grpc::tonic::transport::{Certificate, Channel, ClientTlsConfig}; +use std::error::Error as _; +use std::sync::Arc; use std::time::Duration; /// Platform Client using gRPC transport. @@ -24,7 +26,7 @@ pub fn create_channel( uri: Uri, settings: Option<&AppliedRequestSettings>, ) -> Result { - let host = uri.host().expect("Failed to get host from URI").to_string(); + let host = unbracketed(uri.host().expect("Failed to get host from URI")).to_string(); let mut builder = Channel::builder(uri); @@ -32,7 +34,10 @@ pub fn create_channel( // Try to add native roots only on platforms where they're available (not iOS) let mut tls_config = ClientTlsConfig::new() .with_webpki_roots() - .assume_http2(true); + .assume_http2(true) + // Without an explicit name tonic takes `Uri::host`, which keeps the + // brackets of an IPv6 literal and is not a valid TLS server name. + .domain_name(host); // Try to add native roots - this may fail on iOS/Android, which is fine since we have webpki roots #[cfg(not(any( @@ -52,7 +57,7 @@ pub fn create_channel( if let Some(pem) = settings.ca_certificate.as_ref() { let cert = Certificate::from_pem(pem); - tls_config = tls_config.ca_certificate(cert).domain_name(host); + tls_config = tls_config.ca_certificate(cert); }; } @@ -65,9 +70,75 @@ pub fn create_channel( .keep_alive_timeout(HTTP2_KEEP_ALIVE_TIMEOUT) .keep_alive_while_idle(false); - builder = builder - .tls_config(tls_config) - .expect("Failed to set TLS config"); + builder = builder.tls_config(tls_config).map_err(invalid_tls_config)?; Ok(builder.connect_lazy()) } + +/// A TLS configuration tonic rejects, as an `InvalidArgument` status that +/// keeps the cause: tonic's own message names only the error kind, the cause +/// (an invalid server name, a malformed certificate) is in its source chain. +fn invalid_tls_config(error: dapi_grpc::tonic::transport::Error) -> TransportError { + let mut message = format!("invalid TLS configuration: {error}"); + let mut cause = error.source(); + while let Some(current) = cause { + message.push_str(&format!(": {current}")); + cause = current.source(); + } + let mut status = dapi_grpc::tonic::Status::invalid_argument(message); + status.set_source(Arc::new(error)); + TransportError::Grpc(status) +} + +/// The host of a URI without the brackets an IPv6 literal carries in it +/// (`[2001:db8::1]` becomes `2001:db8::1`); any other host is unchanged. +pub(crate) fn unbracketed(host: &str) -> &str { + host.strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(host) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RequestSettings; + + #[test] + fn should_strip_only_ipv6_brackets() { + assert_eq!(unbracketed("[2001:db8::1]"), "2001:db8::1"); + assert_eq!(unbracketed("127.0.0.1"), "127.0.0.1"); + assert_eq!(unbracketed("evo.example"), "evo.example"); + } + + /// `create_channel` used to panic for every IPv6 endpoint: the bracketed + /// host is not a valid TLS server name. + #[tokio::test] + async fn should_create_a_channel_for_an_ipv6_endpoint() { + let uri: Uri = "https://[2001:db8::1]:443".parse().expect("uri"); + create_channel(uri.clone(), None).expect("default roots"); + + let settings = RequestSettings::default() + .finalize() + .with_ca_certificate(Some(Certificate::from_pem("fake-pem-data"))); + create_channel(uri, Some(&settings)).expect("explicit CA"); + } + + #[test] + fn should_keep_the_cause_of_an_invalid_tls_configuration() { + // Not a valid TLS server name. + let uri: Uri = "https://foo..bar:1443".parse().expect("uri"); + let Err(TransportError::Grpc(status)) = create_channel(uri, None) else { + panic!("an invalid server name must be rejected"); + }; + assert_eq!(status.code(), dapi_grpc::tonic::Code::InvalidArgument); + assert!( + status.message().contains("invalid dns name"), + "{}", + status.message() + ); + assert!( + status.source().is_some(), + "the tonic error stays in the source chain" + ); + } +}