From 67c6930bcf34caadd9e9edbf06f0646e6b3a5ee6 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Mon, 15 Jun 2026 10:40:01 -0500 Subject: [PATCH 01/25] feat(eventhubs): support AMQP-over-WebSockets transport Add a transport-type option so AMQP can be tunneled over secure WebSockets (wss://, port 443) for networks that block the native AMQP ports (5671/5672), matching the .NET, Java, and Python Azure SDKs. - azure_core_amqp: add AmqpTransport enum and AmqpConnectionOptions::transport; the fe2o3 backend opens a WebSocketStream and uses open_with_stream for the WebSocket arm while keeping the amqps:// URL for AMQP link addressing. - azure_messaging_eventhubs: add models::TransportType and with_transport_type on the producer and consumer builders. Resolves #3601. --- Cargo.lock | 191 ++++++++++-------- Cargo.toml | 1 + sdk/core/azure_core_amqp/CHANGELOG.md | 2 + sdk/core/azure_core_amqp/Cargo.toml | 4 +- sdk/core/azure_core_amqp/src/connection.rs | 19 ++ .../azure_core_amqp/src/fe2o3/connection.rs | 103 +++++++++- sdk/core/azure_core_amqp/src/fe2o3/error.rs | 17 ++ sdk/core/azure_core_amqp/src/lib.rs | 2 +- .../azure_messaging_eventhubs/CHANGELOG.md | 1 + .../azure_messaging_eventhubs/Cargo.toml | 18 +- .../src/common/authorizer.rs | 5 + .../src/common/recoverable/connection.rs | 11 +- .../src/consumer/mod.rs | 23 ++- .../src/models/mod.rs | 41 ++++ .../src/producer/mod.rs | 25 +++ .../Cargo.toml | 15 +- 16 files changed, 364 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4914d55cbef..cc99d333f18 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "fe2o3-amqp-ext", "fe2o3-amqp-management", "fe2o3-amqp-types", + "fe2o3-amqp-ws", "serde", "serde_amqp", "serde_bytes", @@ -477,18 +478,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "azure_core_opentelemetry" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b71bd721089367eee3399ab7820052421ddb35916327d70d14667303617dfd21" -dependencies = [ - "azure_core 1.1.0", - "opentelemetry 0.31.0", - "opentelemetry_sdk 0.31.0", - "tracing", -] - [[package]] name = "azure_core_opentelemetry" version = "1.1.0-beta.1" @@ -497,8 +486,8 @@ dependencies = [ "azure_core_test 0.2.0", "azure_core_test_macros 0.2.0", "azure_identity 1.1.0-beta.1", - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry_sdk", "tokio", "tracing", "tracing-subscriber", @@ -591,8 +580,8 @@ dependencies = [ "base64", "clap", "futures", - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry_sdk", "pin-project", "reqwest", "serde", @@ -710,10 +699,10 @@ dependencies = [ "azure_identity 1.0.0", "clap", "futures", - "opentelemetry 0.32.0", + "opentelemetry", "opentelemetry-otlp", "opentelemetry-stdout", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "rand 0.10.2", "serde", "serde_json", @@ -799,13 +788,13 @@ dependencies = [ "async-lock", "async-stream", "async-trait", - "azure_core 1.1.0", - "azure_core_amqp 1.1.0", - "azure_core_test 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", - "azure_identity 1.0.0", + "azure_core 1.2.0-beta.1", + "azure_core_amqp 1.2.0-beta.1", + "azure_core_test 0.2.0", + "azure_identity 1.1.0-beta.1", "azure_messaging_eventhubs", "azure_messaging_eventhubs_checkpointstore_blob", - "azure_storage_blob 1.0.0", + "azure_storage_blob", "base64", "criterion", "fe2o3-amqp", @@ -827,17 +816,17 @@ name = "azure_messaging_eventhubs_checkpointstore_blob" version = "0.9.0" dependencies = [ "async-trait", - "azure_core 1.1.0", - "azure_core_opentelemetry 1.0.0", - "azure_core_test 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", - "azure_identity 1.0.0", + "azure_core 1.2.0-beta.1", + "azure_core_opentelemetry", + "azure_core_test 0.2.0", + "azure_identity 1.1.0-beta.1", "azure_messaging_eventhubs", - "azure_storage_blob 1.0.0", + "azure_storage_blob", "futures", - "opentelemetry 0.32.0", + "opentelemetry", "opentelemetry-appender-tracing", "opentelemetry-stdout", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "serde", "serde_json", "time", @@ -945,25 +934,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "azure_storage_blob" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1756febbcca86c862ef718b983b505d08bd65a9bc984a915b0a16af4a4c3fe5b" -dependencies = [ - "async-stream", - "async-trait", - "azure_core 1.1.0", - "bytes", - "futures", - "percent-encoding", - "pin-project", - "serde", - "serde_json", - "time", - "tokio", -] - [[package]] name = "azure_storage_blob" version = "1.1.0-beta.2" @@ -1539,6 +1509,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "der" version = "0.8.1" @@ -1720,6 +1696,26 @@ dependencies = [ "serde_repr", ] +[[package]] +name = "fe2o3-amqp-ws" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117053be08403ac3b36538bf5a4cf42d328cdcf09950142524dd9ca4b25121a" +dependencies = [ + "bytes", + "futures-util", + "getrandom 0.3.4", + "http", + "js-sys", + "pin-project-lite", + "thiserror", + "tokio", + "tokio-tungstenite", + "tungstenite", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "filetime" version = "0.2.29" @@ -2667,20 +2663,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "opentelemetry" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" -dependencies = [ - "futures-core", - "futures-sink", - "js-sys", - "pin-project-lite", - "thiserror", - "tracing", -] - [[package]] name = "opentelemetry" version = "0.32.0" @@ -2701,7 +2683,7 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c0080f0dc1d7c786f467cd85a4e395fcab11ee852004f39a29a18ab7c25d837" dependencies = [ - "opentelemetry 0.32.0", + "opentelemetry", "tracing", "tracing-core", "tracing-subscriber", @@ -2714,9 +2696,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" dependencies = [ "http", - "opentelemetry 0.32.0", + "opentelemetry", "opentelemetry-proto", - "opentelemetry_sdk 0.32.1", + "opentelemetry_sdk", "prost 0.14.4", "thiserror", "tokio", @@ -2730,8 +2712,8 @@ version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" dependencies = [ - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", + "opentelemetry", + "opentelemetry_sdk", "prost 0.14.4", "tonic 0.14.6", "tonic-prost", @@ -2744,23 +2726,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b1c6a247d79091f0062a5f4bd058589525cf987a8d4c169440d9c1be72f0ad" dependencies = [ "chrono", - "opentelemetry 0.32.0", - "opentelemetry_sdk 0.32.1", -] - -[[package]] -name = "opentelemetry_sdk" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" -dependencies = [ - "futures-channel", - "futures-executor", - "futures-util", - "opentelemetry 0.31.0", - "percent-encoding", - "rand 0.9.5", - "thiserror", + "opentelemetry", + "opentelemetry_sdk", ] [[package]] @@ -2772,7 +2739,7 @@ dependencies = [ "futures-channel", "futures-executor", "futures-util", - "opentelemetry 0.32.0", + "opentelemetry", "percent-encoding", "portable-atomic", "rand 0.9.5", @@ -3743,6 +3710,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3941,7 +3919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -4112,6 +4090,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "native-tls", + "tokio", + "tokio-native-tls", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -4418,6 +4410,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "native-tls", + "rand 0.9.5", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typed-path" version = "0.12.3" @@ -4439,7 +4449,6 @@ dependencies = [ "base64", "bytes", "futures", - "quick-xml", "serde", "serde_json", "url", @@ -4596,6 +4605,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8-zero" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 4a5b4892f2a..1e9f3f68fde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ crossbeam = { version = "0.8", default-features = false } crossbeam-epoch = { version = "0.9", default-features = false } dyn-clone = "1.0" fe2o3-amqp = { version = "0.14", features = ["uuid"] } +fe2o3-amqp-ws = { version = "0.14" } fe2o3-amqp-ext = { version = "0.14" } fe2o3-amqp-management = { version = "0.14" } fe2o3-amqp-cbs = { version = "0.14" } diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 5f4bba0d71d..d3fe23cc1f0 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index c50a8fc8428..1e3b6c69e3a 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -21,6 +21,7 @@ edition.workspace = true async-trait.workspace = true azure_core = { path = "../azure_core", version = "1.2.0-beta.1", default-features = false } fe2o3-amqp = { workspace = true, optional = true } +fe2o3-amqp-ws = { workspace = true, optional = true } fe2o3-amqp-cbs = { workspace = true, optional = true } fe2o3-amqp-ext = { workspace = true, optional = true } fe2o3-amqp-management = { workspace = true, optional = true } @@ -38,11 +39,12 @@ serde_json.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } [features] -default = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] +default = ["fe2o3_amqp", "fe2o3-amqp/native-tls", "fe2o3-amqp-ws/native-tls"] ffi = [] test = [] fe2o3_amqp = [ "dep:fe2o3-amqp", + "dep:fe2o3-amqp-ws", "fe2o3-amqp-types", "fe2o3-amqp-ext", "fe2o3-amqp-management", diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index ba15a6329c8..3f974cd9851 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -14,6 +14,21 @@ type ConnectionImplementation = super::fe2o3::connection::Fe2o3AmqpConnection; #[cfg(not(feature = "fe2o3_amqp"))] type ConnectionImplementation = super::noop::NoopAmqpConnection; +/// The transport used to carry the AMQP protocol. +/// +/// AMQP is normally framed directly over a TCP/TLS socket, but some network +/// environments (for example corporate firewalls) only permit outbound +/// connections on port 443. In those cases the AMQP frames can be tunneled +/// over a WebSocket connection instead. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AmqpTransport { + /// AMQP framing over a TCP/TLS socket (port 5671). This is the default. + #[default] + Tcp, + /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). + WebSocket, +} + /// Options for configuring an AMQP connection. #[derive(Debug, Default, Clone)] pub struct AmqpConnectionOptions { @@ -37,6 +52,8 @@ pub struct AmqpConnectionOptions { pub buffer_size: Option, /// Custom endpoint for the connection. Used to connect to a local AMQP proxy server. pub custom_endpoint: Option, + /// The transport used to carry the AMQP protocol. Defaults to [`AmqpTransport::Tcp`]. + pub transport: Option, } impl AmqpConnectionOptions {} @@ -250,6 +267,7 @@ mod tests { .collect(), ), buffer_size: Some(1024), + transport: Some(AmqpTransport::WebSocket), }; assert_eq!(connection_options.max_frame_size, Some(1024)); @@ -281,6 +299,7 @@ mod tests { connection_options.custom_endpoint, Some(Url::parse("http://localhost:8080").unwrap()) ); + assert_eq!(connection_options.transport, Some(AmqpTransport::WebSocket)); } // On macOS, there is a periodic issue where loopback TCP connections fail. diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index a4a71c59bcc..237c8ea2d1a 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -2,9 +2,11 @@ // Licensed under the MIT license. use crate::{ - connection::{AmqpConnectionApis, AmqpConnectionOptions}, + connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, error::{AmqpErrorKind, Result}, - fe2o3::error::{Fe2o3ConnectionError, Fe2o3ConnectionOpenError, Fe2o3TransportError}, + fe2o3::error::{ + Fe2o3ConnectionError, Fe2o3ConnectionOpenError, Fe2o3TransportError, Fe2o3WebSocketError, + }, value::{AmqpOrderedMap, AmqpSymbol, AmqpValue}, AmqpError, }; @@ -44,6 +46,29 @@ impl Drop for Fe2o3AmqpConnection { } } +// cspell:ignore servicebus + +/// The well-known path that Service Bus and Event Hubs expose for the AMQP +/// WebSocket binding. Matches the suffix used by the other Azure SDKs. +const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; + +/// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the +/// given connection target. The target is the AMQP service URL (or a custom +/// endpoint proxy). Its scheme and path are discarded: only the host and an +/// explicit port (if any) are carried over, since AMQP-over-WebSockets always +/// uses TLS and a fixed binding path. When no port is present the default +/// `wss` port (443) is used. +fn websocket_address(target: &Url) -> Result { + let host = target + .host_str() + .ok_or_else(|| AmqpError::with_message("AMQP connection URL is missing a host."))?; + let authority = match target.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + Ok(format!("wss://{authority}{WEBSOCKET_PATH}")) +} + #[async_trait::async_trait] impl AmqpConnectionApis for Fe2o3AmqpConnection { async fn open( @@ -111,18 +136,47 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { builder = builder.buffer_size(buffer_size); } - if let Some(custom_endpoint) = options.custom_endpoint { - endpoint = custom_endpoint; - builder = builder.hostname(url.host_str()); - } - - self.connection - .set(Mutex::new( + let handle = match options.transport.unwrap_or_default() { + AmqpTransport::Tcp => { + // `custom_endpoint` redirects the socket to a proxy while the + // AMQP `hostname` stays the real service host. + if let Some(custom_endpoint) = options.custom_endpoint { + endpoint = custom_endpoint; + builder = builder.hostname(url.host_str()); + } builder .open(endpoint) .await - .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))?, - )) + .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + } + AmqpTransport::WebSocket => { + // Tunnel AMQP over a secure WebSocket (port 443) for networks + // that block the native AMQP ports. The socket connects to the + // websocket address (or the custom endpoint proxy, if set), + // while the AMQP `hostname` remains the real service host. + // `open_with_stream` does not derive the hostname from a URL, + // so it must be set explicitly. + let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); + let ws_address = websocket_address(ws_target)?; + debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); + let ws_stream = fe2o3_amqp_ws::WebSocketStream::connect_tls_with_config( + &ws_address, + None, + false, + None, + ) + .await + .map_err(|e| AmqpError::from(Fe2o3WebSocketError(e)))?; + builder + .hostname(url.host_str()) + .open_with_stream(ws_stream) + .await + .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + } + }; + + self.connection + .set(Mutex::new(handle)) .map_err(|_| Self::connection_already_set())?; Ok(()) } @@ -221,3 +275,30 @@ impl From for AmqpError { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_address_uses_default_port_for_service_url() { + // The Event Hubs connection URL has no explicit port; the wss default + // (443) is implied and the binding path is appended. + let url = Url::parse("amqps://my-namespace.servicebus.windows.net/my-eventhub").unwrap(); + assert_eq!( + websocket_address(&url).unwrap(), + "wss://my-namespace.servicebus.windows.net/$servicebus/websocket/" + ); + } + + #[test] + fn websocket_address_preserves_explicit_port() { + // A custom endpoint (e.g. a local proxy) may carry an explicit port, + // which must be preserved in the websocket address. + let proxy = Url::parse("amqps://localhost:8081/").unwrap(); + assert_eq!( + websocket_address(&proxy).unwrap(), + "wss://localhost:8081/$servicebus/websocket/" + ); + } +} diff --git a/sdk/core/azure_core_amqp/src/fe2o3/error.rs b/sdk/core/azure_core_amqp/src/fe2o3/error.rs index a5a57524933..b6c297ea304 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/error.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/error.rs @@ -46,6 +46,13 @@ impl From for Fe2o3TransportError { } } +pub(crate) struct Fe2o3WebSocketError(pub fe2o3_amqp_ws::Error); +impl From for Fe2o3WebSocketError { + fn from(e: fe2o3_amqp_ws::Error) -> Self { + Fe2o3WebSocketError(e) + } +} + // Specializations of From for common AMQP types. impl From<&fe2o3_amqp_types::definitions::ErrorCondition> for AmqpErrorCondition { fn from(e: &fe2o3_amqp_types::definitions::ErrorCondition) -> Self { @@ -157,6 +164,16 @@ impl From for AmqpError { } } +impl From for AmqpError { + fn from(e: Fe2o3WebSocketError) -> Self { + // The websocket establishment error wraps the underlying WebSocket and + // I/O failures that occur before the AMQP protocol handshake begins. + // There is no AMQP error condition for these, so they map to a + // transport-layer error. + AmqpError::from(AmqpErrorKind::TransportImplementationError(Box::new(e.0))) + } +} + impl From for AmqpError { fn from(e: Fe2o3TransportError) -> Self { match e.0 { diff --git a/sdk/core/azure_core_amqp/src/lib.rs b/sdk/core/azure_core_amqp/src/lib.rs index e691eb96e72..391f0489188 100644 --- a/sdk/core/azure_core_amqp/src/lib.rs +++ b/sdk/core/azure_core_amqp/src/lib.rs @@ -24,7 +24,7 @@ mod simple_value; mod value; pub use cbs::{AmqpClaimsBasedSecurity, AmqpClaimsBasedSecurityApis}; -pub use connection::{AmqpConnection, AmqpConnectionApis, AmqpConnectionOptions}; +pub use connection::{AmqpConnection, AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}; pub use error::*; pub use management::{AmqpManagement, AmqpManagementApis}; pub use messaging::{AmqpDelivery, AmqpDeliveryApis, AmqpMessage, AmqpSource, AmqpTarget}; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index 8575377c254..09cd874ab4e 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -5,6 +5,7 @@ ### Features Added - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) +- Added `models::TransportType` and `with_transport_type` builder methods on `ProducerClient` and `ConsumerClient`. `TransportType::AmqpWebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml index 0d7ee4773e4..bfb3fde5c06 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml +++ b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml @@ -20,8 +20,10 @@ edition.workspace = true async-lock.workspace = true async-stream.workspace = true async-trait.workspace = true -azure_core = { workspace = true, default-features = false } -azure_core_amqp.workspace = true +azure_core = { path = "../../core/azure_core", version = "1.2.0-beta.1", default-features = false } +# Unreleased: AmqpTransport for AMQP-over-WebSockets (issue #3601). Path plus +# version per the dependency policy in AGENTS.md. +azure_core_amqp = { path = "../../core/azure_core_amqp", version = "1.2.0-beta.1" } base64.workspace = true futures.workspace = true hmac.workspace = true @@ -35,16 +37,20 @@ tracing.workspace = true rustc_version.workspace = true [dev-dependencies] -azure_core_amqp = { workspace = true, features = ["test"] } -azure_core_test = { workspace = true, features = ["tracing"] } -azure_identity.workspace = true +azure_core_amqp = { path = "../../core/azure_core_amqp", features = ["test"] } +azure_core_test = { path = "../../core/azure_core_test", features = [ + "tracing", +] } +azure_identity = { path = "../../identity/azure_identity" } azure_messaging_eventhubs = { path = ".", features = [ "in_memory_checkpoint_store", ] } # Path-only so `cargo package` works: this crate is a dependency of the # checkpoint store crate, and the migration guide's blob sample compiles here. azure_messaging_eventhubs_checkpointstore_blob = { path = "../azure_messaging_eventhubs_checkpointstore_blob" } -azure_storage_blob.workspace = true +# Path-only, for the same reason as the checkpoint store crate above: the +# migration guide's blob sample must resolve the same azure_core as this crate. +azure_storage_blob = { path = "../../storage/azure_storage_blob" } criterion.workspace = true fe2o3-amqp = { workspace = true, features = ["tracing"] } include-file.workspace = true diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs index ed9e6ad31c4..cb68bd21176 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs @@ -740,6 +740,7 @@ mod tests { url, None, None, + azure_core_amqp::AmqpTransport::default(), mock_credential.clone(), Default::default(), None, @@ -806,6 +807,7 @@ mod tests { url, None, None, + azure_core_amqp::AmqpTransport::default(), mock_credential.clone(), Default::default(), None, @@ -876,6 +878,7 @@ mod tests { host.clone(), None, None, + azure_core_amqp::AmqpTransport::default(), mock_credential.clone(), Default::default(), None, @@ -1007,6 +1010,7 @@ mod tests { url.clone(), None, None, + Default::default(), credential.clone(), Default::default(), None, @@ -1103,6 +1107,7 @@ mod tests { url.clone(), None, None, + azure_core_amqp::AmqpTransport::default(), credential.clone(), Default::default(), None, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index 678516feeb9..176c4270937 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -25,7 +25,7 @@ use azure_core_amqp::{ AmqpClaimsBasedSecurity, AmqpConnection, AmqpConnectionApis, AmqpConnectionOptions, AmqpError, AmqpManagement, AmqpManagementApis, AmqpReceiver, AmqpReceiverApis, AmqpReceiverOptions, AmqpSender, AmqpSenderApis, AmqpSession, AmqpSessionApis, AmqpSessionOptions, AmqpSource, - AmqpSymbol, + AmqpSymbol, AmqpTransport, }; #[cfg(test)] use std::sync::Mutex; @@ -79,6 +79,7 @@ pub(crate) struct RecoverableConnection { pub(super) url: Url, application_id: Option, custom_endpoint: Option, + transport: AmqpTransport, // The management client is a single cached instance, held in a `OnceCell` // for the same reason the per-path caches are: the expensive build (connect // + session begin + CBS authorize + link attach) must not run while a lock @@ -283,6 +284,7 @@ impl RecoverableConnection { url: Url, application_id: Option, custom_endpoint: Option, + transport: AmqpTransport, credential: Arc, retry_options: RetryOptions, cbs_token_type: Option<&'static str>, @@ -299,6 +301,7 @@ impl RecoverableConnection { application_id, connection_name, custom_endpoint, + transport, retry_options, cbs_lock: AsyncMutex::new(()), connections: AsyncMutex::new(None), @@ -843,6 +846,7 @@ impl RecoverableConnection { ), desired_capabilities: Some(vec![GEODR_REPLICATION_CAPABILITY.into()]), custom_endpoint: self.custom_endpoint.clone(), + transport: Some(self.transport), ..Default::default() }), ) @@ -1550,6 +1554,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1573,6 +1578,7 @@ mod tests { url, Some(app_id.clone()), None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1593,6 +1599,7 @@ mod tests { url.clone(), None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1613,6 +1620,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1992,6 +2000,7 @@ mod tests { url, None, Some(custom_endpoint.clone()), + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs index 2e89e763b42..ceb0dd17b4c 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs @@ -16,7 +16,7 @@ use azure_core::{credentials::TokenCredential, http::Url, time::Duration, Uuid}; use azure_core_amqp::AmqpError; use azure_core_amqp::{ message::AmqpSourceFilter, AmqpDescribed, AmqpOrderedMap, AmqpReceiverOptions, AmqpSource, - AmqpSymbol, AmqpValue, ReceiverCreditMode, + AmqpSymbol, AmqpTransport, AmqpValue, ReceiverCreditMode, }; pub use event_receiver::EventReceiver; use std::{ @@ -45,6 +45,7 @@ struct ConsumerClientOptions { retry_options: Option, custom_endpoint: Option, cbs_token_type: Option<&'static str>, + transport: AmqpTransport, } impl ConsumerClient { @@ -98,6 +99,7 @@ impl ConsumerClient { url.clone(), options.application_id, options.custom_endpoint, + options.transport, credential, retry_options, options.cbs_token_type, @@ -608,6 +610,7 @@ pub mod builders { sas_credential::SasCredential, SAS_TOKEN_TYPE, }, + models::TransportType, Result, }; use std::sync::Arc; @@ -637,6 +640,7 @@ pub mod builders { instance_id: Option, retry_options: Option, custom_endpoint: Option, + transport_type: Option, } impl ConsumerClientBuilder { @@ -709,6 +713,21 @@ pub mod builders { self } + /// Sets the transport used to communicate with the Event Hub. + /// + /// # Arguments + /// * `transport_type` - The transport to use. Defaults to + /// [`TransportType::AmqpTcp`]. Use [`TransportType::AmqpWebSocket`] + /// to tunnel AMQP over WebSockets (port 443) when the native AMQP + /// ports are blocked. + /// + /// # Returns + /// The updated [`ConsumerClientBuilder`]. + pub fn with_transport_type(mut self, transport_type: TransportType) -> Self { + self.transport_type = Some(transport_type); + self + } + /// Opens a connection to the Event Hub. /// /// This method establishes a connection to the Event Hubs instance associated @@ -766,6 +785,7 @@ pub mod builders { retry_options: self.retry_options, custom_endpoint, cbs_token_type: None, + transport: self.transport_type.unwrap_or_default().into(), }, )?; consumer.ensure_connection().await?; @@ -837,6 +857,7 @@ pub mod builders { retry_options: self.retry_options, custom_endpoint, cbs_token_type: Some(SAS_TOKEN_TYPE), + transport: self.transport_type.unwrap_or_default().into(), }, )?; consumer.ensure_connection().await?; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs index 92b28ad90c4..b50188a581b 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs @@ -131,6 +131,47 @@ pub struct EventHubPartitionProperties { pub is_empty: bool, } +/// The type of transport used to communicate with the Event Hubs service. +/// +/// Event Hubs is normally accessed using AMQP framed directly over a TCP/TLS +/// socket (port 5671). Some networks (for example corporate firewalls) only +/// permit outbound connections on port 443; in those environments AMQP can be +/// tunneled over WebSockets instead. +/// +/// # Examples +/// +/// ```no_run +/// use azure_messaging_eventhubs::{ProducerClient, models::TransportType}; +/// use azure_identity::DeveloperToolsCredential; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let credential = DeveloperToolsCredential::new(None)?; +/// let producer = ProducerClient::builder() +/// .with_transport_type(TransportType::AmqpWebSocket) +/// .open("my_namespace", "my_eventhub", credential) +/// .await?; +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum TransportType { + /// AMQP framing over a TCP/TLS socket (port 5671). This is the default. + #[default] + AmqpTcp, + /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). + AmqpWebSocket, +} + +impl From for azure_core_amqp::AmqpTransport { + fn from(value: TransportType) -> Self { + match value { + TransportType::AmqpTcp => azure_core_amqp::AmqpTransport::Tcp, + TransportType::AmqpWebSocket => azure_core_amqp::AmqpTransport::WebSocket, + } + } +} + /// Uniquely identifies a message. /// /// This type can be used to uniquely identify a message within a message broker or messaging system. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs index 7e8e14c8b9f..df111e9d12f 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs @@ -17,6 +17,7 @@ use azure_core::{ }; use azure_core_amqp::{ error::AmqpErrorKind, AmqpError, AmqpSendOptions, AmqpSendOutcome, AmqpSenderApis, + AmqpTransport, }; use batch::{EventDataBatch, EventDataBatchOptions}; use std::{fmt::Debug, sync::Arc}; @@ -92,6 +93,7 @@ impl From for SendMessageOptions { } impl ProducerClient { + #[allow(clippy::too_many_arguments, reason = "private API")] pub(crate) fn new( endpoint: Url, eventhub: String, @@ -100,12 +102,14 @@ impl ProducerClient { retry_options: RetryOptions, custom_endpoint: Option, cbs_token_type: Option<&'static str>, + transport: AmqpTransport, ) -> Self { Self { connection: RecoverableConnection::new( endpoint.clone(), application_id, custom_endpoint, + transport, credential, retry_options, cbs_token_type, @@ -558,6 +562,7 @@ pub mod builders { sas_credential::SasCredential, SAS_TOKEN_TYPE, }, + models::TransportType, Result, RetryOptions, }; use azure_core::{http::Url, Error}; @@ -590,6 +595,9 @@ pub mod builders { /// The custom endpoint for the Event Hub. custom_endpoint: Option, + + /// The transport used to communicate with the Event Hub. + transport_type: Option, } impl ProducerClientBuilder { @@ -645,6 +653,21 @@ pub mod builders { self } + /// Sets the transport used to communicate with the Event Hub. + /// + /// # Arguments + /// * `transport_type` - The transport to use. Defaults to + /// [`TransportType::AmqpTcp`]. Use [`TransportType::AmqpWebSocket`] + /// to tunnel AMQP over WebSockets (port 443) when the native AMQP + /// ports are blocked. + /// + /// # Returns + /// The updated [`ProducerClientBuilder`]. + pub fn with_transport_type(mut self, transport_type: TransportType) -> Self { + self.transport_type = Some(transport_type); + self + } + /// Opens the connection to the Event Hub. /// /// # Arguments @@ -677,6 +700,7 @@ pub mod builders { self.retry_options.unwrap_or_default(), custom_endpoint, None, + self.transport_type.unwrap_or_default().into(), ); // Open a connection to the Event Hub to ensure that the client is ready to send messages. @@ -752,6 +776,7 @@ pub mod builders { self.retry_options.unwrap_or_default(), custom_endpoint, Some(SAS_TOKEN_TYPE), + self.transport_type.unwrap_or_default().into(), ); client.ensure_connection().await?; diff --git a/sdk/eventhubs/azure_messaging_eventhubs_checkpointstore_blob/Cargo.toml b/sdk/eventhubs/azure_messaging_eventhubs_checkpointstore_blob/Cargo.toml index 881adf4414e..606c407ebef 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs_checkpointstore_blob/Cargo.toml +++ b/sdk/eventhubs/azure_messaging_eventhubs_checkpointstore_blob/Cargo.toml @@ -20,9 +20,12 @@ rust-version.workspace = true [dependencies] async-trait.workspace = true -azure_core.workspace = true +azure_core = { path = "../../core/azure_core", version = "1.2.0-beta.1", default-features = false } azure_messaging_eventhubs = { path = "../azure_messaging_eventhubs", version = "0.15.0" } -azure_storage_blob.workspace = true +# Unreleased: must resolve the same azure_core as azure_messaging_eventhubs, which +# needs AmqpTransport (issue #3601). The registry build of azure_storage_blob pins +# the previous azure_core, which puts two copies of the crate in the graph. +azure_storage_blob = { path = "../../storage/azure_storage_blob", version = "1.1.0-beta.2" } futures.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true @@ -30,9 +33,11 @@ time = { workspace = true, features = ["serde"] } tracing = { workspace = true } [dev-dependencies] -azure_core_opentelemetry.workspace = true -azure_core_test = { workspace = true, features = ["tracing"] } -azure_identity.workspace = true +azure_core_opentelemetry = { path = "../../core/azure_core_opentelemetry" } +azure_core_test = { path = "../../core/azure_core_test", features = [ + "tracing", +] } +azure_identity = { path = "../../identity/azure_identity" } opentelemetry.workspace = true opentelemetry-appender-tracing.workspace = true opentelemetry-stdout.workspace = true From 818bc2eca222989c87adb4b38f9a45dda9b12f92 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Mon, 15 Jun 2026 10:59:39 -0500 Subject: [PATCH 02/25] docs(eventhubs): document WebSocket transport for EventProcessor EventProcessor::builder().build() takes a caller-constructed ConsumerClient and reuses its connection for every partition receiver, so the transport is inherited from that client. Document this on build() with an AMQP-over-WebSockets example, and add a test asserting the transport reaches the RecoverableConnection. --- .../azure_messaging_eventhubs/CHANGELOG.md | 2 +- .../src/common/recoverable/connection.rs | 20 ++++++++++ .../src/event_processor/processor.rs | 39 +++++++++++++++++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index 09cd874ab4e..150aebda0c4 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) -- Added `models::TransportType` and `with_transport_type` builder methods on `ProducerClient` and `ConsumerClient`. `TransportType::AmqpWebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) +- Added `models::TransportType` and `with_transport_type` builder methods on `ProducerClient` and `ConsumerClient`. `TransportType::AmqpWebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index 176c4270937..6703cac1592 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -2009,6 +2009,26 @@ mod tests { assert_eq!(connection_manager.custom_endpoint, Some(custom_endpoint)); } + // The transport selected on a client builder (and, transitively, on an + // EventProcessor's injected ConsumerClient) must reach the connection so it + // is applied when the AMQP connection is opened. This verifies the field is + // stored on the RecoverableConnection. + #[test] + fn constructor_with_websocket_transport() { + let url = Url::parse("amqps://example.com").unwrap(); + let connection_manager = RecoverableConnection::new( + url, + None, + None, + AmqpTransport::WebSocket, + Arc::new(MockCredential), + Default::default(), + None, + ); + + assert_eq!(connection_manager.transport, AmqpTransport::WebSocket); + } + #[test] fn test_should_retry_amqp_error() { use azure_core_amqp::AmqpDescribedError; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs index 6e0a6ba00da..2a36c2e3f40 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs @@ -760,6 +760,45 @@ pub mod builders { /// Builds the event processor with the specified consumer client and checkpoint store. /// Returns a `Result` containing the constructed `EventProcessor`. + /// + /// # Connection options (including transport) + /// + /// The event processor does not open its own connection. It processes + /// partitions using the [`ConsumerClient`] passed here, and every + /// per-partition receiver reuses that client's connection. Connection-level + /// options, such as the transport, a custom endpoint, retry options, and the + /// application id, are therefore configured on the [`ConsumerClient`] before + /// it is passed to `build`. + /// + /// To run the processor over AMQP-over-WebSockets (port 443, useful when the + /// native AMQP ports are blocked), select the transport on the consumer + /// client with + /// [`ConsumerClientBuilder::with_transport_type`](crate::builders::ConsumerClientBuilder::with_transport_type): + /// + /// ```no_run + /// use azure_messaging_eventhubs::{EventProcessor, CheckpointStore, ConsumerClient}; + /// use azure_messaging_eventhubs::models::TransportType; + /// use std::sync::Arc; + /// + /// async fn create_processor(checkpoint_store: Arc) -> Result<(), Box> { + /// use azure_identity::DeveloperToolsCredential; + /// + /// let eventhub_namespace = std::env::var("EVENTHUBS_HOST")?; + /// let eventhub_name = std::env::var("EVENTHUB_NAME")?; + /// let consumer = ConsumerClient::builder() + /// .with_transport_type(TransportType::AmqpWebSocket) + /// .open( + /// &eventhub_namespace, + /// eventhub_name, + /// DeveloperToolsCredential::new(None)?.clone(), + /// ) + /// .await?; + /// let processor = EventProcessor::builder() + /// .build(consumer, checkpoint_store.clone()) + /// .await?; + /// Ok(()) + /// } + /// ``` pub async fn build( self, consumer_client: ConsumerClient, From 9a43d25f6685febcbe55ac8da61b0f0d24511c0d Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Mon, 20 Jul 2026 18:18:40 -0400 Subject: [PATCH 03/25] docs(eventhubs): add AMQP-over-WebSockets sample Add a runnable sample that opens a producer and a consumer with TransportType::AmqpWebSocket, sends a tagged event, and reads it back. It mirrors the connection-string sample, so the two can be run side by side to compare transports. --- .../examples/eventhubs_websocket_transport.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs diff --git a/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs new file mode 100644 index 00000000000..767c6dd3556 --- /dev/null +++ b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. + +//! This sample demonstrates AMQP-over-WebSockets transport. It opens both a +//! [`ProducerClient`] and a [`ConsumerClient`] with +//! [`TransportType::AmqpWebSocket`], sends a uniquely tagged event, and reads it +//! back. The clients talk to the broker over `wss://` on port 443 instead of +//! AMQP on port 5671, which is useful when a firewall blocks 5671. +//! +//! Environment: +//! EVENTHUBS_CONNECTION_STRING required, e.g. +//! `Endpoint=sb://.servicebus.windows.net/;SharedAccessKeyName=;SharedAccessKey=` +//! EVENTHUB_NAME required only if the connection string has no `EntityPath` + +use azure_core::{time::Duration, Uuid}; +use azure_messaging_eventhubs::{ + models::TransportType, ConsumerClient, OpenReceiverOptions, ProducerClient, SendEventOptions, + StartLocation, StartPosition, +}; +use futures::StreamExt; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let connection_string = std::env::var("EVENTHUBS_CONNECTION_STRING")?; + // `None` when the connection string already carries an `EntityPath`. + let eventhub_name = std::env::var("EVENTHUB_NAME").ok(); + + let producer = ProducerClient::builder() + .with_transport_type(TransportType::AmqpWebSocket) + .open_with_connection_string(&connection_string, eventhub_name.as_deref()) + .await?; + println!("Opened producer over WebSockets."); + + // Pick a partition and capture its current tail, so we read only events we + // enqueue after this point. + let properties = producer.get_eventhub_properties().await?; + let partition_id = properties.partition_ids[0].clone(); + let before = producer.get_partition_properties(&partition_id).await?; + let start_sequence = before.last_enqueued_sequence_number; + + let marker = Uuid::new_v4().to_string(); + producer + .send_event( + marker.clone(), + Some(SendEventOptions { + partition_id: Some(partition_id.clone()), + }), + ) + .await?; + println!("Sent event with marker {marker} to partition {partition_id}."); + + let consumer = ConsumerClient::builder() + .with_transport_type(TransportType::AmqpWebSocket) + .open_with_connection_string(&connection_string, eventhub_name.as_deref()) + .await?; + println!("Opened consumer over WebSockets."); + + let receiver = consumer + .open_receiver_on_partition( + partition_id.clone(), + Some(OpenReceiverOptions { + start_position: Some(StartPosition { + location: StartLocation::SequenceNumber(start_sequence), + inclusive: false, + }), + receive_timeout: Some(Duration::seconds(30)), + ..Default::default() + }), + ) + .await?; + + let mut found = false; + { + let mut stream = receiver.stream_events(); + while let Some(event) = stream.next().await { + let event = event?; + if event.event_data().body() == Some(marker.as_bytes()) { + found = true; + println!("Received the marker event back over WebSockets."); + break; + } + } + // `stream` borrows `receiver`; drop it (end of block) before closing. + } + + // Close in dependency order: the receiver and stream hold references to the + // consumer's connection, so they must be released first. + receiver.close().await?; + consumer.close().await?; + producer.close().await?; + + if found { + println!("PASS: AMQP-over-WebSockets validated end to end."); + Ok(()) + } else { + Err("FAIL: did not receive the marker event within the timeout".into()) + } +} From a882b2498ebf191294e0a5c778c2e038e7d8e493 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 12:32:04 -0400 Subject: [PATCH 04/25] fix(amqp): address review feedback on WebSocket transport Add an IPv6 regression test for the WebSocket address builder, register fe2o3-amqp-ws in the crate spelling dictionary in both the underscore and the hyphen form, and record the AmqpConnectionOptions field addition under Breaking Changes. --- eng/dict/crates.txt | 2 ++ sdk/core/azure_core_amqp/CHANGELOG.md | 2 ++ sdk/core/azure_core_amqp/src/fe2o3/connection.rs | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/eng/dict/crates.txt b/eng/dict/crates.txt index 53900c0621d..aa7b57a6300 100644 --- a/eng/dict/crates.txt +++ b/eng/dict/crates.txt @@ -54,11 +54,13 @@ fe2o3_amqp_cbs fe2o3_amqp_ext fe2o3_amqp_management fe2o3_amqp_types +fe2o3_amqp_ws fe2o3-amqp fe2o3-amqp-cbs fe2o3-amqp-ext fe2o3-amqp-management fe2o3-amqp-types +fe2o3-amqp-ws flate2 futures getrandom diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index d3fe23cc1f0..00afbe3868a 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -8,6 +8,8 @@ ### Breaking Changes +- `AmqpConnectionOptions` has a new `transport` field. The struct is not `#[non_exhaustive]`, so code that builds it with a struct literal that names every field must add the new field. Code that uses `..Default::default()` is not affected. + ### Bugs Fixed - Link properties set through `AmqpReceiverOptions::properties` and `AmqpSenderOptions::properties` now reach the Attach frame. They were discarded before the link attached. diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 237c8ea2d1a..c2d5e938016 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -301,4 +301,14 @@ mod tests { "wss://localhost:8081/$servicebus/websocket/" ); } + + #[test] + fn websocket_address_keeps_brackets_around_ipv6_host() { + // `Url::host_str` keeps the brackets around an IPv6 literal, so the + // authority stays valid when the host and the port are joined. + let proxy = Url::parse("amqps://[::1]:8081/").unwrap(); + let address = websocket_address(&proxy).unwrap(); + assert_eq!(address, "wss://[::1]:8081/$servicebus/websocket/"); + assert!(Url::parse(&address).is_ok()); + } } From 951f6e98b5e66dbee657009a88b19a3840247ea6 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 12:45:03 -0400 Subject: [PATCH 05/25] feat(amqp): model the TLS backend and seal AmqpConnectionOptions Add the native_tls and rustls features, which select the TLS backend for both fe2o3-amqp and fe2o3-amqp-ws. The default feature selects native_tls. The WebSocket transport needs one of them, because fe2o3-amqp-ws gates its TLS entry point behind its own features. Without a backend the crate now compiles and the transport returns an error at run time. Before this change, "--no-default-features --features fe2o3_amqp" did not compile. Mark AmqpConnectionOptions as non_exhaustive and add with_* methods for each field, so later fields are additive. Update the Event Hubs call site to use the new methods. --- sdk/core/azure_core_amqp/CHANGELOG.md | 3 +- sdk/core/azure_core_amqp/Cargo.toml | 7 +- sdk/core/azure_core_amqp/src/connection.rs | 82 ++++++++++++++++++- .../azure_core_amqp/src/fe2o3/connection.rs | 65 +++++++++------ .../src/common/recoverable/connection.rs | 36 ++++---- 5 files changed, 148 insertions(+), 45 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 00afbe3868a..b77ca0dd1cf 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,10 +5,11 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. +- Added the `native_tls` and the `rustls` features, which select the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects `native_tls`. To use `rustls`, build with `--no-default-features --features fe2o3_amqp,rustls`. The `AmqpTransport::WebSocket` transport needs one of these two features and returns an error at run time without them. ### Breaking Changes -- `AmqpConnectionOptions` has a new `transport` field. The struct is not `#[non_exhaustive]`, so code that builds it with a struct literal that names every field must add the new field. Code that uses `..Default::default()` is not affected. +- `AmqpConnectionOptions` is now `#[non_exhaustive]`. Build it from `Default` and set the fields you need, for example `AmqpConnectionOptions { transport: Some(AmqpTransport::WebSocket), ..Default::default() }`. A struct literal that names every field no longer compiles. This makes each later field addition additive. ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 1e3b6c69e3a..1679eb8ba28 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -39,7 +39,12 @@ serde_json.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } [features] -default = ["fe2o3_amqp", "fe2o3-amqp/native-tls", "fe2o3-amqp-ws/native-tls"] +default = ["fe2o3_amqp", "native_tls"] +# TLS backend selection. The fe2o3 crates gate their TLS entry points behind +# their own features, so this crate forwards the choice to both of them and +# gates the WebSocket transport on it. +native_tls = ["fe2o3-amqp/native-tls", "fe2o3-amqp-ws/native-tls"] +rustls = ["fe2o3-amqp/rustls", "fe2o3-amqp-ws/rustls-tls-webpki-roots"] ffi = [] test = [] fe2o3_amqp = [ diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 3f974cd9851..5b57069eddd 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -30,7 +30,20 @@ pub enum AmqpTransport { } /// Options for configuring an AMQP connection. +/// +/// This struct is `#[non_exhaustive]`, so a struct literal cannot build it from +/// outside this crate. Start from [`Default`] and set the fields you need with +/// the `with_` methods. +/// +/// ``` +/// use azure_core_amqp::{AmqpConnectionOptions, AmqpTransport}; +/// +/// let options = AmqpConnectionOptions::default() +/// .with_transport(AmqpTransport::WebSocket) +/// .with_max_frame_size(65536); +/// ``` #[derive(Debug, Default, Clone)] +#[non_exhaustive] pub struct AmqpConnectionOptions { /// Maximum frame size for the connection in bytes. pub max_frame_size: Option, @@ -56,7 +69,74 @@ pub struct AmqpConnectionOptions { pub transport: Option, } -impl AmqpConnectionOptions {} +impl AmqpConnectionOptions { + /// Sets the maximum frame size for the connection in bytes. + pub fn with_max_frame_size(mut self, max_frame_size: u32) -> Self { + self.max_frame_size = Some(max_frame_size); + self + } + + /// Sets the maximum number of channels for the connection. + pub fn with_channel_max(mut self, channel_max: u16) -> Self { + self.channel_max = Some(channel_max); + self + } + + /// Sets the idle timeout for the connection. + pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self { + self.idle_timeout = Some(idle_timeout); + self + } + + /// Sets the outgoing locales for the connection. + pub fn with_outgoing_locales(mut self, outgoing_locales: Vec) -> Self { + self.outgoing_locales = Some(outgoing_locales); + self + } + + /// Sets the incoming locales for the connection. + pub fn with_incoming_locales(mut self, incoming_locales: Vec) -> Self { + self.incoming_locales = Some(incoming_locales); + self + } + + /// Sets the offered capabilities for the connection. + pub fn with_offered_capabilities(mut self, offered_capabilities: Vec) -> Self { + self.offered_capabilities = Some(offered_capabilities); + self + } + + /// Sets the desired capabilities for the connection. + pub fn with_desired_capabilities(mut self, desired_capabilities: Vec) -> Self { + self.desired_capabilities = Some(desired_capabilities); + self + } + + /// Sets the properties for the connection. + pub fn with_properties(mut self, properties: AmqpOrderedMap) -> Self { + self.properties = Some(properties); + self + } + + /// Sets the buffer size for the connection. + pub fn with_buffer_size(mut self, buffer_size: usize) -> Self { + self.buffer_size = Some(buffer_size); + self + } + + /// Sets the custom endpoint for the connection. Use it to connect through a + /// local AMQP proxy server. + pub fn with_custom_endpoint(mut self, custom_endpoint: Url) -> Self { + self.custom_endpoint = Some(custom_endpoint); + self + } + + /// Sets the transport that carries the AMQP protocol. + pub fn with_transport(mut self, transport: AmqpTransport) -> Self { + self.transport = Some(transport); + self + } +} /// Trait defining the asynchronous APIs for AMQP connection operations. #[async_trait::async_trait] diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index c2d5e938016..88ace79b6db 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -1,12 +1,12 @@ // Copyright (c) Microsoft Corporation. All Rights reserved // Licensed under the MIT license. +#[cfg(any(feature = "native_tls", feature = "rustls"))] +use crate::fe2o3::error::Fe2o3WebSocketError; use crate::{ connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, error::{AmqpErrorKind, Result}, - fe2o3::error::{ - Fe2o3ConnectionError, Fe2o3ConnectionOpenError, Fe2o3TransportError, Fe2o3WebSocketError, - }, + fe2o3::error::{Fe2o3ConnectionError, Fe2o3ConnectionOpenError, Fe2o3TransportError}, value::{AmqpOrderedMap, AmqpSymbol, AmqpValue}, AmqpError, }; @@ -50,6 +50,7 @@ impl Drop for Fe2o3AmqpConnection { /// The well-known path that Service Bus and Event Hubs expose for the AMQP /// WebSocket binding. Matches the suffix used by the other Azure SDKs. +#[cfg(any(feature = "native_tls", feature = "rustls"))] const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the @@ -58,6 +59,7 @@ const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// explicit port (if any) are carried over, since AMQP-over-WebSockets always /// uses TLS and a fixed binding path. When no port is present the default /// `wss` port (443) is used. +#[cfg(any(feature = "native_tls", feature = "rustls"))] fn websocket_address(target: &Url) -> Result { let host = target .host_str() @@ -150,28 +152,41 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? } AmqpTransport::WebSocket => { - // Tunnel AMQP over a secure WebSocket (port 443) for networks - // that block the native AMQP ports. The socket connects to the - // websocket address (or the custom endpoint proxy, if set), - // while the AMQP `hostname` remains the real service host. - // `open_with_stream` does not derive the hostname from a URL, - // so it must be set explicitly. - let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); - let ws_address = websocket_address(ws_target)?; - debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); - let ws_stream = fe2o3_amqp_ws::WebSocketStream::connect_tls_with_config( - &ws_address, - None, - false, - None, - ) - .await - .map_err(|e| AmqpError::from(Fe2o3WebSocketError(e)))?; - builder - .hostname(url.host_str()) - .open_with_stream(ws_stream) + // The fe2o3-amqp-ws TLS entry point only exists when a TLS + // backend feature is on, so the transport needs one too. + #[cfg(not(any(feature = "native_tls", feature = "rustls")))] + { + Err(AmqpError::with_message( + "The WebSocket transport needs the `native_tls` or the `rustls` feature of azure_core_amqp.", + ))?; + unreachable!() + } + + #[cfg(any(feature = "native_tls", feature = "rustls"))] + { + // Tunnel AMQP over a secure WebSocket (port 443) for networks + // that block the native AMQP ports. The socket connects to the + // websocket address (or the custom endpoint proxy, if set), + // while the AMQP `hostname` remains the real service host. + // `open_with_stream` does not derive the hostname from a URL, + // so it must be set explicitly. + let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); + let ws_address = websocket_address(ws_target)?; + debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); + let ws_stream = fe2o3_amqp_ws::WebSocketStream::connect_tls_with_config( + &ws_address, + None, + false, + None, + ) .await - .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + .map_err(|e| AmqpError::from(Fe2o3WebSocketError(e)))?; + builder + .hostname(url.host_str()) + .open_with_stream(ws_stream) + .await + .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + } } }; @@ -276,7 +291,7 @@ impl From for AmqpError { } } -#[cfg(test)] +#[cfg(all(test, any(feature = "native_tls", feature = "rustls")))] mod tests { use super::*; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index 6703cac1592..2322477a0b8 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -828,27 +828,29 @@ impl RecoverableConnection { ); let connection = Arc::new(AmqpConnection::new()); + let mut options = AmqpConnectionOptions::default() + .with_properties( + vec![ + ("user-agent", get_user_agent(&self.application_id)), + ("version", get_package_version()), + ("platform", get_platform_info()), + ("product", get_package_name()), + ] + .into_iter() + .map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v))) + .collect(), + ) + .with_desired_capabilities(vec![GEODR_REPLICATION_CAPABILITY.into()]) + .with_transport(self.transport); + if let Some(custom_endpoint) = self.custom_endpoint.clone() { + options = options.with_custom_endpoint(custom_endpoint); + } + connection .open( self.connection_name.clone(), self.url.clone(), - Some(AmqpConnectionOptions { - properties: Some( - vec![ - ("user-agent", get_user_agent(&self.application_id)), - ("version", get_package_version()), - ("platform", get_platform_info()), - ("product", get_package_name()), - ] - .into_iter() - .map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v))) - .collect(), - ), - desired_capabilities: Some(vec![GEODR_REPLICATION_CAPABILITY.into()]), - custom_endpoint: self.custom_endpoint.clone(), - transport: Some(self.transport), - ..Default::default() - }), + Some(options), ) .await?; info!( From 5f71122d22821c86476b7199eb807ba83034ba71 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 13:17:13 -0400 Subject: [PATCH 06/25] fix(amqp): drop the rustls feature, which pulls in banned ring The rustls backend of fe2o3-amqp depends on ring, and deny.toml bans that crate, so "cargo deny --all-features check bans" failed. Keep native_tls as the only TLS backend and gate the WebSocket transport on it. See issue #4189 for the ring work. --- sdk/core/azure_core_amqp/CHANGELOG.md | 2 +- sdk/core/azure_core_amqp/Cargo.toml | 4 ++-- sdk/core/azure_core_amqp/src/fe2o3/connection.rs | 14 +++++++------- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index b77ca0dd1cf..bb65871117e 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `native_tls` and the `rustls` features, which select the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects `native_tls`. To use `rustls`, build with `--no-default-features --features fe2o3_amqp,rustls`. The `AmqpTransport::WebSocket` transport needs one of these two features and returns an error at run time without them. +- Added the `native_tls` feature, which selects the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects it. The `AmqpTransport::WebSocket` transport needs this feature and returns an error at run time without it. A `rustls` backend is not available yet, because the `rustls` feature of `fe2o3-amqp` pulls in `ring`, which `deny.toml` bans (issue #4189). ### Breaking Changes diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 1679eb8ba28..610bc860f52 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -42,9 +42,9 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } default = ["fe2o3_amqp", "native_tls"] # TLS backend selection. The fe2o3 crates gate their TLS entry points behind # their own features, so this crate forwards the choice to both of them and -# gates the WebSocket transport on it. +# gates the WebSocket transport on it. Only native-tls is available: the rustls +# backend of fe2o3-amqp pulls in `ring`, which deny.toml bans. See issue #4189. native_tls = ["fe2o3-amqp/native-tls", "fe2o3-amqp-ws/native-tls"] -rustls = ["fe2o3-amqp/rustls", "fe2o3-amqp-ws/rustls-tls-webpki-roots"] ffi = [] test = [] fe2o3_amqp = [ diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 88ace79b6db..bf8ba8c3380 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All Rights reserved // Licensed under the MIT license. -#[cfg(any(feature = "native_tls", feature = "rustls"))] +#[cfg(feature = "native_tls")] use crate::fe2o3::error::Fe2o3WebSocketError; use crate::{ connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, @@ -50,7 +50,7 @@ impl Drop for Fe2o3AmqpConnection { /// The well-known path that Service Bus and Event Hubs expose for the AMQP /// WebSocket binding. Matches the suffix used by the other Azure SDKs. -#[cfg(any(feature = "native_tls", feature = "rustls"))] +#[cfg(feature = "native_tls")] const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the @@ -59,7 +59,7 @@ const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// explicit port (if any) are carried over, since AMQP-over-WebSockets always /// uses TLS and a fixed binding path. When no port is present the default /// `wss` port (443) is used. -#[cfg(any(feature = "native_tls", feature = "rustls"))] +#[cfg(feature = "native_tls")] fn websocket_address(target: &Url) -> Result { let host = target .host_str() @@ -154,15 +154,15 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { AmqpTransport::WebSocket => { // The fe2o3-amqp-ws TLS entry point only exists when a TLS // backend feature is on, so the transport needs one too. - #[cfg(not(any(feature = "native_tls", feature = "rustls")))] + #[cfg(not(feature = "native_tls"))] { Err(AmqpError::with_message( - "The WebSocket transport needs the `native_tls` or the `rustls` feature of azure_core_amqp.", + "The WebSocket transport needs the `native_tls` feature of azure_core_amqp.", ))?; unreachable!() } - #[cfg(any(feature = "native_tls", feature = "rustls"))] + #[cfg(feature = "native_tls")] { // Tunnel AMQP over a secure WebSocket (port 443) for networks // that block the native AMQP ports. The socket connects to the @@ -291,7 +291,7 @@ impl From for AmqpError { } } -#[cfg(all(test, any(feature = "native_tls", feature = "rustls")))] +#[cfg(all(test, feature = "native_tls"))] mod tests { use super::*; From 4272a8d02f827b8f3716ceeb2d05bbc55b1255c3 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 13:49:14 -0400 Subject: [PATCH 07/25] test(eventhubs): cover the transport plumbing, fix the changelog example Extract RecoverableConnection::connection_options so a test can assert the transport reaches the options given to AmqpConnection::open. A test on the constructor alone passed even when create_connection dropped the call. Route each builder open path through one transport() helper and test it, which covers the connection-string path too. Correct the CHANGELOG example: "..Default::default()" does not build a non_exhaustive struct from another crate. Show the with_ method instead. --- sdk/core/azure_core_amqp/CHANGELOG.md | 2 +- .../src/common/recoverable/connection.rs | 68 +++++++++++++------ .../src/consumer/mod.rs | 35 +++++++++- .../src/producer/mod.rs | 36 +++++++++- 4 files changed, 115 insertions(+), 26 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index bb65871117e..baa9807f84b 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -9,7 +9,7 @@ ### Breaking Changes -- `AmqpConnectionOptions` is now `#[non_exhaustive]`. Build it from `Default` and set the fields you need, for example `AmqpConnectionOptions { transport: Some(AmqpTransport::WebSocket), ..Default::default() }`. A struct literal that names every field no longer compiles. This makes each later field addition additive. +- `AmqpConnectionOptions` is now `#[non_exhaustive]`, and it has a `with_` method for each field. A struct literal no longer builds it from another crate, and `..Default::default()` does not help, because functional update is also a struct expression. Start from `Default` and chain the methods, for example `AmqpConnectionOptions::default().with_transport(AmqpTransport::WebSocket)`. This makes each later field addition additive. ### Bugs Fixed diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index 2322477a0b8..a2d9666754e 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -811,6 +811,29 @@ impl RecoverableConnection { .cell } + /// Builds the options handed to [`AmqpConnection::open`]. Kept separate from + /// `create_connection` so the wiring can be asserted without a broker. + fn connection_options(&self) -> AmqpConnectionOptions { + let options = AmqpConnectionOptions::default() + .with_properties( + vec![ + ("user-agent", get_user_agent(&self.application_id)), + ("version", get_package_version()), + ("platform", get_platform_info()), + ("product", get_package_name()), + ] + .into_iter() + .map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v))) + .collect(), + ) + .with_desired_capabilities(vec![GEODR_REPLICATION_CAPABILITY.into()]) + .with_transport(self.transport); + match self.custom_endpoint.clone() { + Some(custom_endpoint) => options.with_custom_endpoint(custom_endpoint), + None => options, + } + } + #[instrument( level = "debug", skip_all, @@ -828,29 +851,11 @@ impl RecoverableConnection { ); let connection = Arc::new(AmqpConnection::new()); - let mut options = AmqpConnectionOptions::default() - .with_properties( - vec![ - ("user-agent", get_user_agent(&self.application_id)), - ("version", get_package_version()), - ("platform", get_platform_info()), - ("product", get_package_name()), - ] - .into_iter() - .map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v))) - .collect(), - ) - .with_desired_capabilities(vec![GEODR_REPLICATION_CAPABILITY.into()]) - .with_transport(self.transport); - if let Some(custom_endpoint) = self.custom_endpoint.clone() { - options = options.with_custom_endpoint(custom_endpoint); - } - connection .open( self.connection_name.clone(), self.url.clone(), - Some(options), + Some(self.connection_options()), ) .await?; info!( @@ -2031,6 +2036,31 @@ mod tests { assert_eq!(connection_manager.transport, AmqpTransport::WebSocket); } + // The stored transport must also reach the options handed to + // `AmqpConnection::open`. Asserting on the constructor alone would still + // pass if `create_connection` dropped the `with_transport` call. + #[test] + fn connection_options_carry_the_transport() { + let url = Url::parse("amqps://example.com").unwrap(); + let custom_endpoint = Url::parse("amqps://proxy.example.com:8081").unwrap(); + for transport in [AmqpTransport::Tcp, AmqpTransport::WebSocket] { + let connection_manager = RecoverableConnection::new( + url.clone(), + None, + Some(custom_endpoint.clone()), + transport, + Arc::new(MockCredential), + Default::default(), + None, + ); + + let options = connection_manager.connection_options(); + assert_eq!(options.transport, Some(transport)); + assert_eq!(options.custom_endpoint, Some(custom_endpoint.clone())); + assert!(options.properties.is_some()); + } + } + #[test] fn test_should_retry_amqp_error() { use azure_core_amqp::AmqpDescribedError; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs index ceb0dd17b4c..461d0b04925 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs @@ -728,6 +728,12 @@ pub mod builders { self } + /// Returns the AMQP transport this builder opens the connection with. + /// Shared by every `open` path so they cannot drift apart. + pub(crate) fn transport(&self) -> AmqpTransport { + self.transport_type.unwrap_or_default().into() + } + /// Opens a connection to the Event Hub. /// /// This method establishes a connection to the Event Hubs instance associated @@ -769,6 +775,7 @@ pub mod builders { eventhub_name: String, credential: Arc, ) -> Result { + let transport = self.transport(); let custom_endpoint = match self.custom_endpoint { Some(endpoint) => Some(Url::parse(&endpoint).map_err(azure_core::Error::from)?), None => None, @@ -785,7 +792,7 @@ pub mod builders { retry_options: self.retry_options, custom_endpoint, cbs_token_type: None, - transport: self.transport_type.unwrap_or_default().into(), + transport, }, )?; consumer.ensure_connection().await?; @@ -834,6 +841,7 @@ pub mod builders { connection_string: &str, eventhub: Option<&str>, ) -> Result { + let transport = self.transport(); let connection_string: ConnectionString = connection_string.parse()?; let eventhub = resolve_eventhub(&connection_string, eventhub)?; let credential = Arc::new(SasCredential::from_connection_string( @@ -857,7 +865,7 @@ pub mod builders { retry_options: self.retry_options, custom_endpoint, cbs_token_type: Some(SAS_TOKEN_TYPE), - transport: self.transport_type.unwrap_or_default().into(), + transport, }, )?; consumer.ensure_connection().await?; @@ -868,18 +876,39 @@ pub mod builders { #[cfg(test)] pub(crate) mod tests { + use crate::models::TransportType; use crate::{ common::tests::force_errors, models::EventData, ConsumerClient, EventDataBatchOptions, ProducerClient, Result, StartLocation, StartPosition, }; use azure_core::{sleep::sleep, time::Duration}; - use azure_core_amqp::{error::AmqpErrorKind, AmqpError}; + use azure_core_amqp::{error::AmqpErrorKind, AmqpError, AmqpTransport}; use azure_core_test::{recorded, TestContext}; use futures::stream::StreamExt; use std::{ sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; + + // Every `open` path on the builder reads the transport through one helper, + // so this covers the plumbing that the connection-string path shares. + #[test] + fn builder_maps_the_transport_type() { + assert_eq!( + ConsumerClient::builder() + .with_transport_type(TransportType::AmqpWebSocket) + .transport(), + AmqpTransport::WebSocket + ); + assert_eq!( + ConsumerClient::builder() + .with_transport_type(TransportType::AmqpTcp) + .transport(), + AmqpTransport::Tcp + ); + // An unset transport type keeps the TCP default. + assert_eq!(ConsumerClient::builder().transport(), AmqpTransport::Tcp); + } use tracing::info; // static INIT_LOGGING: std::sync::Once = std::sync::Once::new(); diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs index df111e9d12f..2fd843a98d4 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs @@ -566,6 +566,7 @@ pub mod builders { Result, RetryOptions, }; use azure_core::{http::Url, Error}; + use azure_core_amqp::AmqpTransport; use std::sync::Arc; /// A builder for creating a [`ProducerClient`]. @@ -668,6 +669,12 @@ pub mod builders { self } + /// Returns the AMQP transport this builder opens the connection with. + /// Shared by every `open` path so they cannot drift apart. + pub(crate) fn transport(&self) -> AmqpTransport { + self.transport_type.unwrap_or_default().into() + } + /// Opens the connection to the Event Hub. /// /// # Arguments @@ -684,6 +691,7 @@ pub mod builders { eventhub: &str, credential: Arc, ) -> Result { + let transport = self.transport(); let url = format!("amqps://{}/{}", fully_qualified_namespace, eventhub); let url = Url::parse(&url).map_err(azure_core::Error::from)?; @@ -700,7 +708,7 @@ pub mod builders { self.retry_options.unwrap_or_default(), custom_endpoint, None, - self.transport_type.unwrap_or_default().into(), + transport, ); // Open a connection to the Event Hub to ensure that the client is ready to send messages. @@ -750,6 +758,7 @@ pub mod builders { connection_string: &str, eventhub: Option<&str>, ) -> Result { + let transport = self.transport(); let connection_string: ConnectionString = connection_string.parse()?; let eventhub = resolve_eventhub(&connection_string, eventhub)?; let credential = Arc::new(SasCredential::from_connection_string( @@ -776,7 +785,7 @@ pub mod builders { self.retry_options.unwrap_or_default(), custom_endpoint, Some(SAS_TOKEN_TYPE), - self.transport_type.unwrap_or_default().into(), + transport, ); client.ensure_connection().await?; @@ -788,12 +797,33 @@ pub mod builders { #[cfg(test)] mod tests { use crate::common::tests::force_errors; + use crate::models::TransportType; use crate::{models::EventData, EventDataBatchOptions, ProducerClient, Result}; use azure_core::time::Duration; - use azure_core_amqp::error::AmqpErrorKind; + use azure_core_amqp::{error::AmqpErrorKind, AmqpTransport}; use azure_core_test::{recorded, TestContext}; use std::sync::Arc; + // Every `open` path on the builder reads the transport through one helper, + // so this covers the plumbing that the connection-string path shares. + #[test] + fn builder_maps_the_transport_type() { + assert_eq!( + ProducerClient::builder() + .with_transport_type(TransportType::AmqpWebSocket) + .transport(), + AmqpTransport::WebSocket + ); + assert_eq!( + ProducerClient::builder() + .with_transport_type(TransportType::AmqpTcp) + .transport(), + AmqpTransport::Tcp + ); + // An unset transport type keeps the TCP default. + assert_eq!(ProducerClient::builder().transport(), AmqpTransport::Tcp); + } + #[recorded::test(live)] async fn force_errors_send_batch_link_error(ctx: TestContext) -> Result<()> { const EVENTHUB_PARTITION: &str = "1"; From 1e677df34f4e7dde2b6b717aacbaa9254cfc0a03 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 14:13:24 -0400 Subject: [PATCH 08/25] docs(amqp): state the TLS feature that the WebSocket transport needs Document on AmqpTransport::WebSocket, on with_transport, and on the Event Hubs TransportType::AmqpWebSocket that the variant needs a TLS backend. A build with default-features = false can select the variant, but the connection then returns an error when it opens. --- sdk/core/azure_core_amqp/src/connection.rs | 8 ++++++++ sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs | 5 +++++ 2 files changed, 13 insertions(+) diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 5b57069eddd..151ed9bbe04 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -26,6 +26,11 @@ pub enum AmqpTransport { #[default] Tcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). + /// + /// This variant needs the `native_tls` feature, which the `default` feature + /// selects. A build with `default-features = false` and no `native_tls` can + /// still select this variant, but the connection then returns an error when + /// it opens, because the WebSocket transport has no TLS backend. WebSocket, } @@ -132,6 +137,9 @@ impl AmqpConnectionOptions { } /// Sets the transport that carries the AMQP protocol. + /// + /// [`AmqpTransport::WebSocket`] needs the `native_tls` feature. See that + /// variant for the behavior without it. pub fn with_transport(mut self, transport: AmqpTransport) -> Self { self.transport = Some(transport); self diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs index b50188a581b..3ae9d51580c 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs @@ -160,6 +160,11 @@ pub enum TransportType { #[default] AmqpTcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). + /// + /// This variant needs a TLS backend in `azure_core_amqp`, which the + /// `default` feature of this crate selects. A build with + /// `default-features = false` can still select this variant, but the client + /// then returns an error when it opens the connection. AmqpWebSocket, } From edbe57838eaa418a4d9230ad895fee3d565fc09e Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 14:48:22 -0400 Subject: [PATCH 09/25] docs(eventhubs): correct the TLS feature statement on the transport The Event Hubs crate depends on azure_core_amqp with its default features, so native_tls is always on and the WebSocket variant works in every build of this crate. The previous text described a configuration the manifest does not produce. Also qualify the azure_core_amqp text: the run-time error needs the fe2o3_amqp feature, since the no-op backend panics. --- sdk/core/azure_core_amqp/src/connection.rs | 6 +++--- sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 151ed9bbe04..e69a2ac68be 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -28,9 +28,9 @@ pub enum AmqpTransport { /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). /// /// This variant needs the `native_tls` feature, which the `default` feature - /// selects. A build with `default-features = false` and no `native_tls` can - /// still select this variant, but the connection then returns an error when - /// it opens, because the WebSocket transport has no TLS backend. + /// selects. A build with `fe2o3_amqp` but no `native_tls` can still select + /// this variant, but the connection then returns an error when it opens, + /// because the WebSocket transport has no TLS backend. WebSocket, } diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs index 3ae9d51580c..943cb5527e0 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs @@ -161,10 +161,9 @@ pub enum TransportType { AmqpTcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). /// - /// This variant needs a TLS backend in `azure_core_amqp`, which the - /// `default` feature of this crate selects. A build with - /// `default-features = false` can still select this variant, but the client - /// then returns an error when it opens the connection. + /// This variant needs the `native_tls` feature of `azure_core_amqp`. This + /// crate depends on `azure_core_amqp` with its default features, which + /// include `native_tls`, so the variant works in every build of this crate. AmqpWebSocket, } From d839b9fca4251b8deaf1192f2c466526df050ba4 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 15:14:19 -0400 Subject: [PATCH 10/25] fix(eventhubs): add the transport argument to a new test call site The receiver attach test that came in with #4807 calls RecoverableConnection::new with the previous six arguments, so it stopped compiling after this branch added the transport argument. Pass the default transport. --- .../azure_messaging_eventhubs/src/consumer/event_receiver.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/event_receiver.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/event_receiver.rs index c933e59ec51..1d7a3664df2 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/event_receiver.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/event_receiver.rs @@ -385,6 +385,7 @@ mod tests { Url::parse("amqps://example.servicebus.windows.net").unwrap(), None, None, + Default::default(), Arc::new(azure_core_test::credentials::MockCredential), Default::default(), None, From dd68885489c711198527c17b9fedf34a0d07c470 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 15:15:11 -0400 Subject: [PATCH 11/25] docs(eventhubs): use a fully qualified namespace in the transport example The open method takes the fully qualified namespace and uses it as the AMQP host, so the short name in the TransportType example does not resolve. --- sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs index 943cb5527e0..8529511fc14 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs @@ -149,7 +149,7 @@ pub struct EventHubPartitionProperties { /// let credential = DeveloperToolsCredential::new(None)?; /// let producer = ProducerClient::builder() /// .with_transport_type(TransportType::AmqpWebSocket) -/// .open("my_namespace", "my_eventhub", credential) +/// .open("my_namespace.servicebus.windows.net", "my_eventhub", credential) /// .await?; /// Ok(()) /// } From e57a5989cb8b6a8e42004b7350202c234a1d2ac6 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 21 Jul 2026 15:43:36 -0400 Subject: [PATCH 12/25] docs(amqp): qualify the WebSocket TLS note by the backend feature The run-time error needs the fe2o3_amqp feature. Without a backend feature the no-op connection takes over, and its open calls unimplemented!, so it panics. --- sdk/core/azure_core_amqp/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index baa9807f84b..5d3c329f90d 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `native_tls` feature, which selects the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects it. The `AmqpTransport::WebSocket` transport needs this feature and returns an error at run time without it. A `rustls` backend is not available yet, because the `rustls` feature of `fe2o3-amqp` pulls in `ring`, which `deny.toml` bans (issue #4189). +- Added the `native_tls` feature, which selects the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects it. The `AmqpTransport::WebSocket` transport needs this feature. A build with `fe2o3_amqp` but no `native_tls` compiles, and the connection then returns an error when it opens. A `rustls` backend is not available yet, because the `rustls` feature of `fe2o3-amqp` pulls in `ring`, which `deny.toml` bans (issue #4189). ### Breaking Changes From 13b4ba3b4a1a7a5ba909c12a78de7918b1d65b3d Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 29 Jul 2026 12:02:29 -0400 Subject: [PATCH 13/25] refactor(amqp): give WebSockets a feature and keep the options bag open `AmqpConnectionOptions` returns to a plain struct with public fields. The `#[non_exhaustive]` marker and the `with_` methods are gone, so a struct literal with `..Default::default()` builds it again. The repo allows the `constructible_struct_adds_field` semver lint for this reason. AMQP over WebSockets is now a feature of this crate, not a TLS selection. `websockets_rustls` and `websockets_native_tls` each turn on `fe2o3-amqp-ws` and forward one of its TLS backends. `default` selects `websockets_rustls`, which is rustls with the aws-lc-rs provider, the same stack `azure_core` selects for HTTP. The direct `rustls` dependency selects that provider, because `fe2o3-amqp-ws` takes rustls without default features. The `fe2o3_amqp` feature no longer pulls in `fe2o3-amqp-ws`, so a build with that feature alone compiles. The TCP transport keeps `fe2o3-amqp/native-tls`, which `default` selected before this change: the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (#4189). --- Cargo.lock | 7 ++ Cargo.toml | 1 + sdk/core/azure_core_amqp/CHANGELOG.md | 6 +- sdk/core/azure_core_amqp/Cargo.toml | 29 ++++-- sdk/core/azure_core_amqp/src/connection.rs | 94 +++---------------- .../azure_core_amqp/src/fe2o3/connection.rs | 25 +++-- sdk/core/azure_core_amqp/src/fe2o3/error.rs | 3 + .../src/common/recoverable/connection.rs | 15 ++- 8 files changed, 67 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cc99d333f18..00e78a54fe1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -431,6 +431,7 @@ dependencies = [ "fe2o3-amqp-management", "fe2o3-amqp-types", "fe2o3-amqp-ws", + "rustls", "serde", "serde_amqp", "serde_bytes", @@ -4099,8 +4100,12 @@ dependencies = [ "futures-util", "log", "native-tls", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", "tokio-native-tls", + "tokio-rustls", "tungstenite", ] @@ -4423,6 +4428,8 @@ dependencies = [ "log", "native-tls", "rand 0.9.5", + "rustls", + "rustls-pki-types", "sha1", "thiserror", "utf-8", diff --git a/Cargo.toml b/Cargo.toml index 1e9f3f68fde..25baec26365 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ reqwest = { version = "0.13.2", features = [ ], default-features = false } rust_decimal = "1.40.0" rustc_version = "0.4" +rustls = "0.23" serde = { version = "1.0", features = ["derive"] } serde_amqp = { version = "0.14", features = ["uuid"] } serde_bytes = { version = "0.11" } diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 5d3c329f90d..78bb389d033 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,11 +5,7 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `native_tls` feature, which selects the TLS backend for both `fe2o3-amqp` and `fe2o3-amqp-ws`. The `default` feature selects it. The `AmqpTransport::WebSocket` transport needs this feature. A build with `fe2o3_amqp` but no `native_tls` compiles, and the connection then returns an error when it opens. A `rustls` backend is not available yet, because the `rustls` feature of `fe2o3-amqp` pulls in `ring`, which `deny.toml` bans (issue #4189). - -### Breaking Changes - -- `AmqpConnectionOptions` is now `#[non_exhaustive]`, and it has a `with_` method for each field. A struct literal no longer builds it from another crate, and `..Default::default()` does not help, because functional update is also a struct expression. Start from `Default` and chain the methods, for example `AmqpConnectionOptions::default().with_transport(AmqpTransport::WebSocket)`. This makes each later field addition additive. +- Added the `websockets_rustls` and `websockets_native_tls` features, which turn on the WebSocket transport and pick its TLS stack. The `default` feature selects `websockets_rustls`, which is rustls with the aws-lc-rs provider, the same stack that `azure_core` selects for HTTP. A build with `fe2o3_amqp` and neither feature compiles, and `AmqpTransport::WebSocket` then returns an error when the connection opens. The TCP transport keeps the `fe2o3-amqp/native-tls` stack, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (issue #4189). ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 610bc860f52..525b9746afd 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -26,6 +26,7 @@ fe2o3-amqp-cbs = { workspace = true, optional = true } fe2o3-amqp-ext = { workspace = true, optional = true } fe2o3-amqp-management = { workspace = true, optional = true } fe2o3-amqp-types = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } serde.workspace = true serde_amqp = { workspace = true, optional = true } serde_bytes = { workspace = true, optional = true } @@ -39,17 +40,11 @@ serde_json.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } [features] -default = ["fe2o3_amqp", "native_tls"] -# TLS backend selection. The fe2o3 crates gate their TLS entry points behind -# their own features, so this crate forwards the choice to both of them and -# gates the WebSocket transport on it. Only native-tls is available: the rustls -# backend of fe2o3-amqp pulls in `ring`, which deny.toml bans. See issue #4189. -native_tls = ["fe2o3-amqp/native-tls", "fe2o3-amqp-ws/native-tls"] +default = ["fe2o3_amqp", "fe2o3-amqp/native-tls", "websockets_rustls"] ffi = [] test = [] fe2o3_amqp = [ "dep:fe2o3-amqp", - "dep:fe2o3-amqp-ws", "fe2o3-amqp-types", "fe2o3-amqp-ext", "fe2o3-amqp-management", @@ -58,9 +53,27 @@ fe2o3_amqp = [ "serde_bytes", "azure_core/tokio", ] +# AMQP over WebSockets, in two flavors that differ only in the TLS stack. +# `fe2o3-amqp-ws` puts its connect entry point behind its own TLS features, so +# this crate forwards one of them and offers both stacks. Enable one of these. +# `websockets_rustls` is the default and matches the TLS stack in `sdk/core`: +# rustls with the aws-lc-rs provider. The direct `rustls` dependency selects +# that provider, because `fe2o3-amqp-ws` takes rustls without default features. +# Enabling both flavors is allowed; `fe2o3-amqp-ws` then uses native-tls. +websockets_rustls = [ + "fe2o3_amqp", + "dep:fe2o3-amqp-ws", + "fe2o3-amqp-ws/rustls-tls-native-roots", + "dep:rustls", +] +websockets_native_tls = [ + "fe2o3_amqp", + "dep:fe2o3-amqp-ws", + "fe2o3-amqp-ws/native-tls", +] [lints] workspace = true [package.metadata.docs.rs] -features = ["fe2o3_amqp"] +features = ["fe2o3_amqp", "websockets_rustls"] diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index e69a2ac68be..3f7aea1302f 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -27,28 +27,28 @@ pub enum AmqpTransport { Tcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). /// - /// This variant needs the `native_tls` feature, which the `default` feature - /// selects. A build with `fe2o3_amqp` but no `native_tls` can still select - /// this variant, but the connection then returns an error when it opens, - /// because the WebSocket transport has no TLS backend. + /// This variant needs the `websockets_rustls` feature (which the `default` + /// feature selects) or the `websockets_native_tls` feature. A build with + /// `fe2o3_amqp` and neither of them can still select this variant, but the + /// connection then returns an error when it opens. WebSocket, } /// Options for configuring an AMQP connection. /// -/// This struct is `#[non_exhaustive]`, so a struct literal cannot build it from -/// outside this crate. Start from [`Default`] and set the fields you need with -/// the `with_` methods. +/// Build it from [`Default`] and set only the fields you need, so a later field +/// addition does not break the call site: /// /// ``` /// use azure_core_amqp::{AmqpConnectionOptions, AmqpTransport}; /// -/// let options = AmqpConnectionOptions::default() -/// .with_transport(AmqpTransport::WebSocket) -/// .with_max_frame_size(65536); +/// #[allow(clippy::needless_update)] +/// let options = AmqpConnectionOptions { +/// transport: Some(AmqpTransport::WebSocket), +/// ..Default::default() +/// }; /// ``` #[derive(Debug, Default, Clone)] -#[non_exhaustive] pub struct AmqpConnectionOptions { /// Maximum frame size for the connection in bytes. pub max_frame_size: Option, @@ -74,78 +74,6 @@ pub struct AmqpConnectionOptions { pub transport: Option, } -impl AmqpConnectionOptions { - /// Sets the maximum frame size for the connection in bytes. - pub fn with_max_frame_size(mut self, max_frame_size: u32) -> Self { - self.max_frame_size = Some(max_frame_size); - self - } - - /// Sets the maximum number of channels for the connection. - pub fn with_channel_max(mut self, channel_max: u16) -> Self { - self.channel_max = Some(channel_max); - self - } - - /// Sets the idle timeout for the connection. - pub fn with_idle_timeout(mut self, idle_timeout: Duration) -> Self { - self.idle_timeout = Some(idle_timeout); - self - } - - /// Sets the outgoing locales for the connection. - pub fn with_outgoing_locales(mut self, outgoing_locales: Vec) -> Self { - self.outgoing_locales = Some(outgoing_locales); - self - } - - /// Sets the incoming locales for the connection. - pub fn with_incoming_locales(mut self, incoming_locales: Vec) -> Self { - self.incoming_locales = Some(incoming_locales); - self - } - - /// Sets the offered capabilities for the connection. - pub fn with_offered_capabilities(mut self, offered_capabilities: Vec) -> Self { - self.offered_capabilities = Some(offered_capabilities); - self - } - - /// Sets the desired capabilities for the connection. - pub fn with_desired_capabilities(mut self, desired_capabilities: Vec) -> Self { - self.desired_capabilities = Some(desired_capabilities); - self - } - - /// Sets the properties for the connection. - pub fn with_properties(mut self, properties: AmqpOrderedMap) -> Self { - self.properties = Some(properties); - self - } - - /// Sets the buffer size for the connection. - pub fn with_buffer_size(mut self, buffer_size: usize) -> Self { - self.buffer_size = Some(buffer_size); - self - } - - /// Sets the custom endpoint for the connection. Use it to connect through a - /// local AMQP proxy server. - pub fn with_custom_endpoint(mut self, custom_endpoint: Url) -> Self { - self.custom_endpoint = Some(custom_endpoint); - self - } - - /// Sets the transport that carries the AMQP protocol. - /// - /// [`AmqpTransport::WebSocket`] needs the `native_tls` feature. See that - /// variant for the behavior without it. - pub fn with_transport(mut self, transport: AmqpTransport) -> Self { - self.transport = Some(transport); - self - } -} - /// Trait defining the asynchronous APIs for AMQP connection operations. #[async_trait::async_trait] pub trait AmqpConnectionApis { diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index bf8ba8c3380..0118a1eb90a 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All Rights reserved // Licensed under the MIT license. -#[cfg(feature = "native_tls")] +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] use crate::fe2o3::error::Fe2o3WebSocketError; use crate::{ connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, @@ -50,7 +50,7 @@ impl Drop for Fe2o3AmqpConnection { /// The well-known path that Service Bus and Event Hubs expose for the AMQP /// WebSocket binding. Matches the suffix used by the other Azure SDKs. -#[cfg(feature = "native_tls")] +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the @@ -59,7 +59,7 @@ const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// explicit port (if any) are carried over, since AMQP-over-WebSockets always /// uses TLS and a fixed binding path. When no port is present the default /// `wss` port (443) is used. -#[cfg(feature = "native_tls")] +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] fn websocket_address(target: &Url) -> Result { let host = target .host_str() @@ -152,17 +152,21 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? } AmqpTransport::WebSocket => { - // The fe2o3-amqp-ws TLS entry point only exists when a TLS - // backend feature is on, so the transport needs one too. - #[cfg(not(feature = "native_tls"))] + // The fe2o3-amqp-ws connect entry point only exists when one + // of its TLS backends is on, so the transport needs one too. + #[cfg(not(any( + feature = "websockets_rustls", + feature = "websockets_native_tls" + )))] { Err(AmqpError::with_message( - "The WebSocket transport needs the `native_tls` feature of azure_core_amqp.", + "The WebSocket transport needs the `websockets_rustls` or the \ + `websockets_native_tls` feature of azure_core_amqp.", ))?; unreachable!() } - #[cfg(feature = "native_tls")] + #[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] { // Tunnel AMQP over a secure WebSocket (port 443) for networks // that block the native AMQP ports. The socket connects to the @@ -291,7 +295,10 @@ impl From for AmqpError { } } -#[cfg(all(test, feature = "native_tls"))] +#[cfg(all( + test, + any(feature = "websockets_rustls", feature = "websockets_native_tls") +))] mod tests { use super::*; diff --git a/sdk/core/azure_core_amqp/src/fe2o3/error.rs b/sdk/core/azure_core_amqp/src/fe2o3/error.rs index b6c297ea304..703bc6a04a6 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/error.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/error.rs @@ -46,7 +46,9 @@ impl From for Fe2o3TransportError { } } +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] pub(crate) struct Fe2o3WebSocketError(pub fe2o3_amqp_ws::Error); +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] impl From for Fe2o3WebSocketError { fn from(e: fe2o3_amqp_ws::Error) -> Self { Fe2o3WebSocketError(e) @@ -164,6 +166,7 @@ impl From for AmqpError { } } +#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] impl From for AmqpError { fn from(e: Fe2o3WebSocketError) -> Self { // The websocket establishment error wraps the underlying WebSocket and diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index a2d9666754e..ab3ab9c3d32 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -814,8 +814,8 @@ impl RecoverableConnection { /// Builds the options handed to [`AmqpConnection::open`]. Kept separate from /// `create_connection` so the wiring can be asserted without a broker. fn connection_options(&self) -> AmqpConnectionOptions { - let options = AmqpConnectionOptions::default() - .with_properties( + AmqpConnectionOptions { + properties: Some( vec![ ("user-agent", get_user_agent(&self.application_id)), ("version", get_package_version()), @@ -825,12 +825,11 @@ impl RecoverableConnection { .into_iter() .map(|(k, v)| (AmqpSymbol::from(k), AmqpValue::from(v))) .collect(), - ) - .with_desired_capabilities(vec![GEODR_REPLICATION_CAPABILITY.into()]) - .with_transport(self.transport); - match self.custom_endpoint.clone() { - Some(custom_endpoint) => options.with_custom_endpoint(custom_endpoint), - None => options, + ), + desired_capabilities: Some(vec![GEODR_REPLICATION_CAPABILITY.into()]), + custom_endpoint: self.custom_endpoint.clone(), + transport: Some(self.transport), + ..Default::default() } } From bf0a5083567ad240338ead2fe79a4e5897fbf6fc Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 29 Jul 2026 12:02:36 -0400 Subject: [PATCH 14/25] refactor(eventhubs): select the transport with AmqpTransport `models::TransportType` duplicated `azure_core_amqp::AmqpTransport`, which this crate already re-exports types from. The enum and its `From` implementation are gone. `models` re-exports `AmqpTransport` beside `AmqpMessage` and `AmqpValue`, and the builder method is `with_transport`, which takes that type. --- .../azure_messaging_eventhubs/CHANGELOG.md | 2 +- .../examples/eventhubs_websocket_transport.rs | 11 ++- .../src/consumer/mod.rs | 25 ++++--- .../src/event_processor/processor.rs | 6 +- .../src/models/mod.rs | 71 +++++++------------ .../src/producer/mod.rs | 24 +++---- 6 files changed, 58 insertions(+), 81 deletions(-) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index 150aebda0c4..f6033fb0eb3 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) -- Added `models::TransportType` and `with_transport_type` builder methods on `ProducerClient` and `ConsumerClient`. `TransportType::AmqpWebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) +- Added a `with_transport` builder method on `ProducerClient` and `ConsumerClient`, which takes the `AmqpTransport` of `azure_core_amqp` (re-exported as `models::AmqpTransport`). `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs index 767c6dd3556..e9251434708 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs @@ -3,7 +3,7 @@ //! This sample demonstrates AMQP-over-WebSockets transport. It opens both a //! [`ProducerClient`] and a [`ConsumerClient`] with -//! [`TransportType::AmqpWebSocket`], sends a uniquely tagged event, and reads it +//! [`AmqpTransport::WebSocket`], sends a uniquely tagged event, and reads it //! back. The clients talk to the broker over `wss://` on port 443 instead of //! AMQP on port 5671, which is useful when a firewall blocks 5671. //! @@ -14,7 +14,7 @@ use azure_core::{time::Duration, Uuid}; use azure_messaging_eventhubs::{ - models::TransportType, ConsumerClient, OpenReceiverOptions, ProducerClient, SendEventOptions, + models::AmqpTransport, ConsumerClient, OpenReceiverOptions, ProducerClient, SendEventOptions, StartLocation, StartPosition, }; use futures::StreamExt; @@ -26,7 +26,7 @@ async fn main() -> Result<(), Box> { let eventhub_name = std::env::var("EVENTHUB_NAME").ok(); let producer = ProducerClient::builder() - .with_transport_type(TransportType::AmqpWebSocket) + .with_transport(AmqpTransport::WebSocket) .open_with_connection_string(&connection_string, eventhub_name.as_deref()) .await?; println!("Opened producer over WebSockets."); @@ -50,7 +50,7 @@ async fn main() -> Result<(), Box> { println!("Sent event with marker {marker} to partition {partition_id}."); let consumer = ConsumerClient::builder() - .with_transport_type(TransportType::AmqpWebSocket) + .with_transport(AmqpTransport::WebSocket) .open_with_connection_string(&connection_string, eventhub_name.as_deref()) .await?; println!("Opened consumer over WebSockets."); @@ -83,8 +83,7 @@ async fn main() -> Result<(), Box> { // `stream` borrows `receiver`; drop it (end of block) before closing. } - // Close in dependency order: the receiver and stream hold references to the - // consumer's connection, so they must be released first. + // Detach the receiver link before the connection that carries it closes. receiver.close().await?; consumer.close().await?; producer.close().await?; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs index 461d0b04925..1e05d56d8d2 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs @@ -610,9 +610,9 @@ pub mod builders { sas_credential::SasCredential, SAS_TOKEN_TYPE, }, - models::TransportType, Result, }; + use azure_core_amqp::AmqpTransport; use std::sync::Arc; /// A builder for creating a [`ConsumerClient`]. @@ -640,7 +640,7 @@ pub mod builders { instance_id: Option, retry_options: Option, custom_endpoint: Option, - transport_type: Option, + transport: Option, } impl ConsumerClientBuilder { @@ -716,22 +716,22 @@ pub mod builders { /// Sets the transport used to communicate with the Event Hub. /// /// # Arguments - /// * `transport_type` - The transport to use. Defaults to - /// [`TransportType::AmqpTcp`]. Use [`TransportType::AmqpWebSocket`] - /// to tunnel AMQP over WebSockets (port 443) when the native AMQP + /// * `transport` - The transport to use. Defaults to + /// [`AmqpTransport::Tcp`]. Use [`AmqpTransport::WebSocket`] to + /// tunnel AMQP over WebSockets (port 443) when the native AMQP /// ports are blocked. /// /// # Returns /// The updated [`ConsumerClientBuilder`]. - pub fn with_transport_type(mut self, transport_type: TransportType) -> Self { - self.transport_type = Some(transport_type); + pub fn with_transport(mut self, transport: AmqpTransport) -> Self { + self.transport = Some(transport); self } /// Returns the AMQP transport this builder opens the connection with. /// Shared by every `open` path so they cannot drift apart. pub(crate) fn transport(&self) -> AmqpTransport { - self.transport_type.unwrap_or_default().into() + self.transport.unwrap_or_default() } /// Opens a connection to the Event Hub. @@ -876,7 +876,6 @@ pub mod builders { #[cfg(test)] pub(crate) mod tests { - use crate::models::TransportType; use crate::{ common::tests::force_errors, models::EventData, ConsumerClient, EventDataBatchOptions, ProducerClient, Result, StartLocation, StartPosition, @@ -893,20 +892,20 @@ pub(crate) mod tests { // Every `open` path on the builder reads the transport through one helper, // so this covers the plumbing that the connection-string path shares. #[test] - fn builder_maps_the_transport_type() { + fn builder_reads_the_transport_through_one_helper() { assert_eq!( ConsumerClient::builder() - .with_transport_type(TransportType::AmqpWebSocket) + .with_transport(AmqpTransport::WebSocket) .transport(), AmqpTransport::WebSocket ); assert_eq!( ConsumerClient::builder() - .with_transport_type(TransportType::AmqpTcp) + .with_transport(AmqpTransport::Tcp) .transport(), AmqpTransport::Tcp ); - // An unset transport type keeps the TCP default. + // An unset transport keeps the TCP default. assert_eq!(ConsumerClient::builder().transport(), AmqpTransport::Tcp); } use tracing::info; diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs index 2a36c2e3f40..97102d90971 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/event_processor/processor.rs @@ -773,11 +773,11 @@ pub mod builders { /// To run the processor over AMQP-over-WebSockets (port 443, useful when the /// native AMQP ports are blocked), select the transport on the consumer /// client with - /// [`ConsumerClientBuilder::with_transport_type`](crate::builders::ConsumerClientBuilder::with_transport_type): + /// [`ConsumerClientBuilder::with_transport`](crate::builders::ConsumerClientBuilder::with_transport): /// /// ```no_run /// use azure_messaging_eventhubs::{EventProcessor, CheckpointStore, ConsumerClient}; - /// use azure_messaging_eventhubs::models::TransportType; + /// use azure_messaging_eventhubs::models::AmqpTransport; /// use std::sync::Arc; /// /// async fn create_processor(checkpoint_store: Arc) -> Result<(), Box> { @@ -786,7 +786,7 @@ pub mod builders { /// let eventhub_namespace = std::env::var("EVENTHUBS_HOST")?; /// let eventhub_name = std::env::var("EVENTHUB_NAME")?; /// let consumer = ConsumerClient::builder() - /// .with_transport_type(TransportType::AmqpWebSocket) + /// .with_transport(AmqpTransport::WebSocket) /// .open( /// &eventhub_namespace, /// eventhub_name, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs index 8529511fc14..319846fb0b1 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/models/mod.rs @@ -10,6 +10,32 @@ pub use azure_core_amqp::AmqpMessage; /// An AMQP Value. pub use azure_core_amqp::AmqpValue; +/// The transport that carries the AMQP protocol. +/// +/// Event Hubs is normally reached with AMQP framed directly over a TCP/TLS +/// socket ([`AmqpTransport::Tcp`], port 5671). Some networks (for example +/// corporate firewalls) only permit outbound connections on port 443; there, +/// [`AmqpTransport::WebSocket`] tunnels AMQP over secure WebSockets instead. +/// Select it with `with_transport` on a client builder. +/// +/// # Examples +/// +/// ```no_run +/// use azure_messaging_eventhubs::{ProducerClient, models::AmqpTransport}; +/// use azure_identity::DeveloperToolsCredential; +/// +/// #[tokio::main] +/// async fn main() -> Result<(), Box> { +/// let credential = DeveloperToolsCredential::new(None)?; +/// let producer = ProducerClient::builder() +/// .with_transport(AmqpTransport::WebSocket) +/// .open("my_namespace.servicebus.windows.net", "my_eventhub", credential) +/// .await?; +/// Ok(()) +/// } +/// ``` +pub use azure_core_amqp::AmqpTransport; + /// An AMQP Simple Value. /// /// An AMQP Simple Value is a primitive type in AMQP 1.0. @@ -131,51 +157,6 @@ pub struct EventHubPartitionProperties { pub is_empty: bool, } -/// The type of transport used to communicate with the Event Hubs service. -/// -/// Event Hubs is normally accessed using AMQP framed directly over a TCP/TLS -/// socket (port 5671). Some networks (for example corporate firewalls) only -/// permit outbound connections on port 443; in those environments AMQP can be -/// tunneled over WebSockets instead. -/// -/// # Examples -/// -/// ```no_run -/// use azure_messaging_eventhubs::{ProducerClient, models::TransportType}; -/// use azure_identity::DeveloperToolsCredential; -/// -/// #[tokio::main] -/// async fn main() -> Result<(), Box> { -/// let credential = DeveloperToolsCredential::new(None)?; -/// let producer = ProducerClient::builder() -/// .with_transport_type(TransportType::AmqpWebSocket) -/// .open("my_namespace.servicebus.windows.net", "my_eventhub", credential) -/// .await?; -/// Ok(()) -/// } -/// ``` -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] -pub enum TransportType { - /// AMQP framing over a TCP/TLS socket (port 5671). This is the default. - #[default] - AmqpTcp, - /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). - /// - /// This variant needs the `native_tls` feature of `azure_core_amqp`. This - /// crate depends on `azure_core_amqp` with its default features, which - /// include `native_tls`, so the variant works in every build of this crate. - AmqpWebSocket, -} - -impl From for azure_core_amqp::AmqpTransport { - fn from(value: TransportType) -> Self { - match value { - TransportType::AmqpTcp => azure_core_amqp::AmqpTransport::Tcp, - TransportType::AmqpWebSocket => azure_core_amqp::AmqpTransport::WebSocket, - } - } -} - /// Uniquely identifies a message. /// /// This type can be used to uniquely identify a message within a message broker or messaging system. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs index 2fd843a98d4..9c5ee4e828d 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs @@ -562,7 +562,6 @@ pub mod builders { sas_credential::SasCredential, SAS_TOKEN_TYPE, }, - models::TransportType, Result, RetryOptions, }; use azure_core::{http::Url, Error}; @@ -598,7 +597,7 @@ pub mod builders { custom_endpoint: Option, /// The transport used to communicate with the Event Hub. - transport_type: Option, + transport: Option, } impl ProducerClientBuilder { @@ -657,22 +656,22 @@ pub mod builders { /// Sets the transport used to communicate with the Event Hub. /// /// # Arguments - /// * `transport_type` - The transport to use. Defaults to - /// [`TransportType::AmqpTcp`]. Use [`TransportType::AmqpWebSocket`] - /// to tunnel AMQP over WebSockets (port 443) when the native AMQP + /// * `transport` - The transport to use. Defaults to + /// [`AmqpTransport::Tcp`]. Use [`AmqpTransport::WebSocket`] to + /// tunnel AMQP over WebSockets (port 443) when the native AMQP /// ports are blocked. /// /// # Returns /// The updated [`ProducerClientBuilder`]. - pub fn with_transport_type(mut self, transport_type: TransportType) -> Self { - self.transport_type = Some(transport_type); + pub fn with_transport(mut self, transport: AmqpTransport) -> Self { + self.transport = Some(transport); self } /// Returns the AMQP transport this builder opens the connection with. /// Shared by every `open` path so they cannot drift apart. pub(crate) fn transport(&self) -> AmqpTransport { - self.transport_type.unwrap_or_default().into() + self.transport.unwrap_or_default() } /// Opens the connection to the Event Hub. @@ -797,7 +796,6 @@ pub mod builders { #[cfg(test)] mod tests { use crate::common::tests::force_errors; - use crate::models::TransportType; use crate::{models::EventData, EventDataBatchOptions, ProducerClient, Result}; use azure_core::time::Duration; use azure_core_amqp::{error::AmqpErrorKind, AmqpTransport}; @@ -807,20 +805,20 @@ mod tests { // Every `open` path on the builder reads the transport through one helper, // so this covers the plumbing that the connection-string path shares. #[test] - fn builder_maps_the_transport_type() { + fn builder_reads_the_transport_through_one_helper() { assert_eq!( ProducerClient::builder() - .with_transport_type(TransportType::AmqpWebSocket) + .with_transport(AmqpTransport::WebSocket) .transport(), AmqpTransport::WebSocket ); assert_eq!( ProducerClient::builder() - .with_transport_type(TransportType::AmqpTcp) + .with_transport(AmqpTransport::Tcp) .transport(), AmqpTransport::Tcp ); - // An unset transport type keeps the TCP default. + // An unset transport keeps the TCP default. assert_eq!(ProducerClient::builder().transport(), AmqpTransport::Tcp); } From e58ab6751159abd3accd05e816dabd7c034713f9 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 29 Jul 2026 17:11:10 -0400 Subject: [PATCH 15/25] refactor(amqp): split the WebSocket features by TLS stack The WebSocket transport had two features, `websockets_rustls` and `websockets_native_tls`. Each one turned on the transport and one TLS stack in a single step, so a consumer could not select the TLS stack that the rest of the application uses. Give the transport the base plus modifier shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. Name the features after the backend crate `fe2o3-amqp-ws`, which is the rule that `reqwest` and the existing `fe2o3_amqp` both follow. `fe2o3_amqp_ws` turns on the transport code, and `fe2o3_amqp_ws_rustls` and `fe2o3_amqp_ws_native_tls` each add one stack on top of it. Add `fe2o3_amqp_native_tls` for the TLS stack of the TCP transport. `default` named `fe2o3-amqp/native-tls` inline before, and `fe2o3-amqp` is only a dev-dependency of `azure_messaging_eventhubs`, so that crate could not forward it. Without the named feature, an application that turned off the default features to select a WebSocket stack also lost the TLS stack of the TCP transport. Forward all five features from `azure_messaging_eventhubs`, in the same way that `azure_core` forwards the `reqwest` TLS features of `typespec_client_core`. That crate is the one an application depends on, so the split was not reachable without the forward. Keep the features additive, which the Cargo reference asks for. A build can hold both WebSocket stacks; `fe2o3-amqp-ws` then uses native-tls, and the connection now logs a warning that names the stack in use. --- sdk/core/azure_core_amqp/CHANGELOG.md | 3 +- sdk/core/azure_core_amqp/Cargo.toml | 38 ++++++++++++------- sdk/core/azure_core_amqp/src/connection.rs | 10 +++-- .../azure_core_amqp/src/fe2o3/connection.rs | 36 +++++++++++++----- sdk/core/azure_core_amqp/src/fe2o3/error.rs | 6 +-- .../azure_messaging_eventhubs/CHANGELOG.md | 1 + .../azure_messaging_eventhubs/Cargo.toml | 28 ++++++++++++++ 7 files changed, 92 insertions(+), 30 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 78bb389d033..927b73320de 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,7 +5,8 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `websockets_rustls` and `websockets_native_tls` features, which turn on the WebSocket transport and pick its TLS stack. The `default` feature selects `websockets_rustls`, which is rustls with the aws-lc-rs provider, the same stack that `azure_core` selects for HTTP. A build with `fe2o3_amqp` and neither feature compiles, and `AmqpTransport::WebSocket` then returns an error when the connection opens. The TCP transport keeps the `fe2o3-amqp/native-tls` stack, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (issue #4189). +- Added the `fe2o3_amqp_ws`, `fe2o3_amqp_ws_rustls`, and `fe2o3_amqp_ws_native_tls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and the two modifiers each add one TLS stack on top of it. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack, so a consumer can select the TLS stack that the rest of the application uses. The `default` feature selects `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls`, which is rustls with the aws-lc-rs provider, the same stack that `azure_core` selects for HTTP. A build with the base feature and no TLS modifier compiles, and `AmqpTransport::WebSocket` then returns an error when the connection opens. The features are additive, so a build can hold both stacks; `fe2o3-amqp-ws` then uses native-tls, and the connection logs a warning. +- Added the `fe2o3_amqp_native_tls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. The `default` feature selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so a consumer that turns off the default features can name the stack. native-tls is the only choice for this transport, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (issue #4189). ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 525b9746afd..d5b8fb176f3 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -40,7 +40,12 @@ serde_json.workspace = true tracing-subscriber = { workspace = true, features = ["env-filter"] } [features] -default = ["fe2o3_amqp", "fe2o3-amqp/native-tls", "websockets_rustls"] +default = [ + "fe2o3_amqp", + "fe2o3_amqp_native_tls", + "fe2o3_amqp_ws", + "fe2o3_amqp_ws_rustls", +] ffi = [] test = [] fe2o3_amqp = [ @@ -53,22 +58,29 @@ fe2o3_amqp = [ "serde_bytes", "azure_core/tokio", ] -# AMQP over WebSockets, in two flavors that differ only in the TLS stack. -# `fe2o3-amqp-ws` puts its connect entry point behind its own TLS features, so -# this crate forwards one of them and offers both stacks. Enable one of these. -# `websockets_rustls` is the default and matches the TLS stack in `sdk/core`: +# The TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671). +# `fe2o3_amqp` is the base, and this feature adds the stack, in the same shape as +# the WebSocket features below. native-tls is the only choice, because the rustls +# backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (#4189). +fe2o3_amqp_native_tls = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] +# AMQP over WebSockets. The base `fe2o3_amqp_ws` feature turns on the transport +# code and the `fe2o3-amqp-ws` dependency, and it does not select a TLS stack. +# Add one of the two modifier features to select a stack. This is the shape that +# `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` +# adds the TLS stack. +# `fe2o3_amqp_ws_rustls` is the default and matches the TLS stack in `sdk/core`: # rustls with the aws-lc-rs provider. The direct `rustls` dependency selects # that provider, because `fe2o3-amqp-ws` takes rustls without default features. -# Enabling both flavors is allowed; `fe2o3-amqp-ws` then uses native-tls. -websockets_rustls = [ - "fe2o3_amqp", - "dep:fe2o3-amqp-ws", +# The features are additive, so both modifiers can be on at the same time. +# `fe2o3-amqp-ws` then uses native-tls, and the connection logs a warning. +fe2o3_amqp_ws = ["fe2o3_amqp", "dep:fe2o3-amqp-ws"] +fe2o3_amqp_ws_rustls = [ + "fe2o3_amqp_ws", "fe2o3-amqp-ws/rustls-tls-native-roots", "dep:rustls", ] -websockets_native_tls = [ - "fe2o3_amqp", - "dep:fe2o3-amqp-ws", +fe2o3_amqp_ws_native_tls = [ + "fe2o3_amqp_ws", "fe2o3-amqp-ws/native-tls", ] @@ -76,4 +88,4 @@ websockets_native_tls = [ workspace = true [package.metadata.docs.rs] -features = ["fe2o3_amqp", "websockets_rustls"] +features = ["fe2o3_amqp", "fe2o3_amqp_ws", "fe2o3_amqp_ws_rustls"] diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 3f7aea1302f..dd810a41c42 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -27,10 +27,12 @@ pub enum AmqpTransport { Tcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). /// - /// This variant needs the `websockets_rustls` feature (which the `default` - /// feature selects) or the `websockets_native_tls` feature. A build with - /// `fe2o3_amqp` and neither of them can still select this variant, but the - /// connection then returns an error when it opens. + /// This variant needs the `fe2o3_amqp_ws_rustls` feature (which the + /// `default` feature selects) or the `fe2o3_amqp_ws_native_tls` feature. + /// The base `fe2o3_amqp_ws` feature turns on the transport code, and it + /// does not select a TLS stack. A build without one of the two TLS + /// features can still select this variant, but the connection then + /// returns an error when it opens. WebSocket, } diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 0118a1eb90a..0db40a54fec 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All Rights reserved // Licensed under the MIT license. -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] use crate::fe2o3::error::Fe2o3WebSocketError; use crate::{ connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, @@ -50,7 +50,7 @@ impl Drop for Fe2o3AmqpConnection { /// The well-known path that Service Bus and Event Hubs expose for the AMQP /// WebSocket binding. Matches the suffix used by the other Azure SDKs. -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the @@ -59,7 +59,7 @@ const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// explicit port (if any) are carried over, since AMQP-over-WebSockets always /// uses TLS and a fixed binding path. When no port is present the default /// `wss` port (443) is used. -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] fn websocket_address(target: &Url) -> Result { let host = target .host_str() @@ -155,18 +155,22 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { // The fe2o3-amqp-ws connect entry point only exists when one // of its TLS backends is on, so the transport needs one too. #[cfg(not(any( - feature = "websockets_rustls", - feature = "websockets_native_tls" + feature = "fe2o3_amqp_ws_rustls", + feature = "fe2o3_amqp_ws_native_tls" )))] { Err(AmqpError::with_message( - "The WebSocket transport needs the `websockets_rustls` or the \ - `websockets_native_tls` feature of azure_core_amqp.", + "The WebSocket transport needs the `fe2o3_amqp_ws_rustls` or the \ + `fe2o3_amqp_ws_native_tls` feature of azure_core_amqp. The base \ + `fe2o3_amqp_ws` feature does not select a TLS stack.", ))?; unreachable!() } - #[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] + #[cfg(any( + feature = "fe2o3_amqp_ws_rustls", + feature = "fe2o3_amqp_ws_native_tls" + ))] { // Tunnel AMQP over a secure WebSocket (port 443) for networks // that block the native AMQP ports. The socket connects to the @@ -174,6 +178,20 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { // while the AMQP `hostname` remains the real service host. // `open_with_stream` does not derive the hostname from a URL, // so it must be set explicitly. + // The features are additive, so a build can hold both TLS + // stacks. `fe2o3-amqp-ws` then takes the native-tls arm, + // because this call passes no explicit connector. + #[cfg(all( + feature = "fe2o3_amqp_ws_rustls", + feature = "fe2o3_amqp_ws_native_tls" + ))] + warn!( + "Both the `fe2o3_amqp_ws_rustls` and the \ + `fe2o3_amqp_ws_native_tls` feature are on. The WebSocket \ + connection uses native-tls. Enable only one feature to \ + select a stack." + ); + let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); let ws_address = websocket_address(ws_target)?; debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); @@ -297,7 +315,7 @@ impl From for AmqpError { #[cfg(all( test, - any(feature = "websockets_rustls", feature = "websockets_native_tls") + any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls") ))] mod tests { use super::*; diff --git a/sdk/core/azure_core_amqp/src/fe2o3/error.rs b/sdk/core/azure_core_amqp/src/fe2o3/error.rs index 703bc6a04a6..dafcabf97ff 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/error.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/error.rs @@ -46,9 +46,9 @@ impl From for Fe2o3TransportError { } } -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] pub(crate) struct Fe2o3WebSocketError(pub fe2o3_amqp_ws::Error); -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] impl From for Fe2o3WebSocketError { fn from(e: fe2o3_amqp_ws::Error) -> Self { Fe2o3WebSocketError(e) @@ -166,7 +166,7 @@ impl From for AmqpError { } } -#[cfg(any(feature = "websockets_rustls", feature = "websockets_native_tls"))] +#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] impl From for AmqpError { fn from(e: Fe2o3WebSocketError) -> Self { // The websocket establishment error wraps the underlying WebSocket and diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index f6033fb0eb3..afdb6259265 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -6,6 +6,7 @@ - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) - Added a `with_transport` builder method on `ProducerClient` and `ConsumerClient`, which takes the `AmqpTransport` of `azure_core_amqp` (re-exported as `models::AmqpTransport`). `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) +- Added the `fe2o3_amqp`, `fe2o3_amqp_native_tls`, `fe2o3_amqp_ws`, `fe2o3_amqp_ws_rustls`, and `fe2o3_amqp_ws_native_tls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend, the native-tls stack for the TCP transport, and the rustls stack for the WebSocket transport. To select the other WebSocket TLS stack, turn off the default features and name the stack you want, for example `default-features = false, features = ["fe2o3_amqp_native_tls", "fe2o3_amqp_ws_native_tls"]`. This lets an application build the WebSocket transport with the TLS stack it already uses, in the same way that `azure_core` forwards its `reqwest` TLS features. - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml index bfb3fde5c06..10f2758b211 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml +++ b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml @@ -60,6 +60,34 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [features] in_memory_checkpoint_store = [] default = ["azure_core_amqp/default"] +# The AMQP backend and the AMQP-over-WebSockets transport, forwarded from +# `azure_core_amqp`. The `default` feature selects the backend and the rustls +# WebSocket stack. To select the other TLS stack, turn off the default features +# and name the backend and the stack you want: +# +# azure_messaging_eventhubs = { version = "...", default-features = false, features = [ +# "fe2o3_amqp_native_tls", +# "fe2o3_amqp_ws_native_tls", +# ] } +# +# The base `fe2o3_amqp_ws` feature turns on the transport code and does not +# select a TLS stack. A build without one of the two TLS features can still +# select `AmqpTransport::WebSocket`, but the connection then returns an error +# when it opens. +fe2o3_amqp = ["azure_core_amqp/fe2o3_amqp"] +fe2o3_amqp_native_tls = [ + "fe2o3_amqp", + "azure_core_amqp/fe2o3_amqp_native_tls", +] +fe2o3_amqp_ws = ["fe2o3_amqp", "azure_core_amqp/fe2o3_amqp_ws"] +fe2o3_amqp_ws_rustls = [ + "fe2o3_amqp_ws", + "azure_core_amqp/fe2o3_amqp_ws_rustls", +] +fe2o3_amqp_ws_native_tls = [ + "fe2o3_amqp_ws", + "azure_core_amqp/fe2o3_amqp_ws_native_tls", +] [[bench]] name = "benchmarks" From ed660604338f6f3081a625ec6b6f74f550994156 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Fri, 31 Jul 2026 16:39:00 -0400 Subject: [PATCH 16/25] fix(eventhubs): add the transport argument to new test call sites The rebase onto main brought in new tests that build a `RecoverableConnection` or a `ProducerClient`. Both constructors take the transport, so each new call site needs the argument. The tests do not exercise the transport, so they pass the default, which is TCP. --- .../azure_messaging_eventhubs/src/common/authorizer.rs | 2 ++ .../src/common/recoverable/connection.rs | 9 +++++++++ .../azure_messaging_eventhubs/src/producer/batch.rs | 2 ++ 3 files changed, 13 insertions(+) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs index cb68bd21176..109fd463ee2 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/authorizer.rs @@ -1213,6 +1213,7 @@ mod tests { url.clone(), None, None, + azure_core_amqp::AmqpTransport::default(), credential.clone(), Default::default(), None, @@ -1345,6 +1346,7 @@ mod tests { url.clone(), None, None, + azure_core_amqp::AmqpTransport::default(), credential.clone(), Default::default(), None, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index ab3ab9c3d32..cbe9cc06a10 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -1681,6 +1681,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1710,6 +1711,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1754,6 +1756,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1799,6 +1802,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1854,6 +1858,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -1957,6 +1962,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -2423,6 +2429,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -2499,6 +2506,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, @@ -2548,6 +2556,7 @@ mod tests { url, None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/batch.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/batch.rs index a664749abfe..2d7bcdc4491 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/batch.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/batch.rs @@ -392,6 +392,7 @@ pub struct EventDataBatchOptions { mod tests { use super::*; use crate::RetryOptions; + use azure_core_amqp::AmqpTransport; use azure_core_test::credentials::MockCredential; use std::sync::Arc; @@ -428,6 +429,7 @@ mod tests { RetryOptions::default(), None, None, + AmqpTransport::default(), ) } From 761edfa74404f7e3811ac7f84aa015c13d931a75 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Fri, 31 Jul 2026 16:52:15 -0400 Subject: [PATCH 17/25] refactor(amqp): drop the parity WebSocket TLS feature `fe2o3_amqp_ws_native_tls` was a parity feature: it named a second TLS stack that this crate does not otherwise use. `azure_core` has no such feature. It offers `reqwest` as the base and `reqwest_rustls` for the stack it ships, and `samples/list_blobs_native_tls` selects another stack with a direct `reqwest` dependency and Cargo feature unification. Follow that pattern. Keep `fe2o3_amqp_ws` as the base and `fe2o3_amqp_ws_rustls` for the stack this repository ships. Gate the transport code on the base feature alone, which is what makes unification work: an application can now name `fe2o3_amqp_ws`, add `fe2o3-amqp-ws` with `native-tls`, and get the transport on that stack. The old gate read this crate's own TLS features, so the same manifest compiled the transport out and returned an error at run time. One stack must be selected somewhere in the graph, because `fe2o3-amqp-ws` puts `connect_tls_with_config` behind its own TLS features. `fe2o3_amqp_ws` on its own therefore does not build. `reqwest` differs here, since it compiles with no TLS feature at all. Add `tokio-tungstenite` to the crate dictionary, in both spellings, beside the other entries. --- Cargo.lock | 3 - eng/dict/crates.txt | 2 + sdk/core/azure_core_amqp/CHANGELOG.md | 2 +- sdk/core/azure_core_amqp/Cargo.toml | 49 ++++++++++----- sdk/core/azure_core_amqp/src/connection.rs | 15 +++-- .../azure_core_amqp/src/fe2o3/connection.rs | 62 +++++++------------ sdk/core/azure_core_amqp/src/fe2o3/error.rs | 6 +- .../azure_messaging_eventhubs/CHANGELOG.md | 2 +- .../azure_messaging_eventhubs/Cargo.toml | 22 +++---- 9 files changed, 79 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 00e78a54fe1..43a20996d20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4099,12 +4099,10 @@ checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" dependencies = [ "futures-util", "log", - "native-tls", "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-native-tls", "tokio-rustls", "tungstenite", ] @@ -4426,7 +4424,6 @@ dependencies = [ "http", "httparse", "log", - "native-tls", "rand 0.9.5", "rustls", "rustls-pki-types", diff --git a/eng/dict/crates.txt b/eng/dict/crates.txt index aa7b57a6300..5da45bf457b 100644 --- a/eng/dict/crates.txt +++ b/eng/dict/crates.txt @@ -115,6 +115,8 @@ time tokio tokio_metrics tokio-metrics +tokio_tungstenite +tokio-tungstenite tracing tracing_subscriber tracing-subscriber diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 927b73320de..d216a621133 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `fe2o3_amqp_ws`, `fe2o3_amqp_ws_rustls`, and `fe2o3_amqp_ws_native_tls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and the two modifiers each add one TLS stack on top of it. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack, so a consumer can select the TLS stack that the rest of the application uses. The `default` feature selects `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls`, which is rustls with the aws-lc-rs provider, the same stack that `azure_core` selects for HTTP. A build with the base feature and no TLS modifier compiles, and `AmqpTransport::WebSocket` then returns an error when the connection opens. The features are additive, so a build can hold both stacks; `fe2o3-amqp-ws` then uses native-tls, and the connection logs a warning. +- Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, because `fe2o3-amqp-ws` puts its connect entry point behind its own TLS features. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. - Added the `fe2o3_amqp_native_tls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. The `default` feature selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so a consumer that turns off the default features can name the stack. native-tls is the only choice for this transport, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (issue #4189). ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index d5b8fb176f3..a6864d7979b 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -59,30 +59,45 @@ fe2o3_amqp = [ "azure_core/tokio", ] # The TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671). -# `fe2o3_amqp` is the base, and this feature adds the stack, in the same shape as -# the WebSocket features below. native-tls is the only choice, because the rustls -# backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (#4189). +# `fe2o3_amqp` is the base, and this feature adds the stack, in the same shape +# that `azure_core` uses for HTTP, where `reqwest` is the base and +# `reqwest_rustls` adds the stack. native-tls is the only stack this crate can +# name, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which +# `deny.toml` bans (#4189). fe2o3_amqp_native_tls = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] -# AMQP over WebSockets. The base `fe2o3_amqp_ws` feature turns on the transport -# code and the `fe2o3-amqp-ws` dependency, and it does not select a TLS stack. -# Add one of the two modifier features to select a stack. This is the shape that -# `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` -# adds the TLS stack. -# `fe2o3_amqp_ws_rustls` is the default and matches the TLS stack in `sdk/core`: -# rustls with the aws-lc-rs provider. The direct `rustls` dependency selects -# that provider, because `fe2o3-amqp-ws` takes rustls without default features. -# The features are additive, so both modifiers can be on at the same time. -# `fe2o3-amqp-ws` then uses native-tls, and the connection logs a warning. +# AMQP over WebSockets. `fe2o3_amqp_ws` is the base feature: it turns on the +# transport code and the `fe2o3-amqp-ws` dependency, and it names no TLS stack. +# `fe2o3_amqp_ws_rustls` adds the stack that the rest of `sdk/core` uses, rustls +# with the aws-lc-rs provider, and `default` selects it. +# +# To build the transport on another TLS stack, turn off the default features, +# name `fe2o3_amqp_ws`, and take a direct dependency on the backend crate with +# the stack you want. Cargo unifies the features, so the transport then uses +# your stack and nothing pulls rustls in: +# +# azure_core_amqp = { version = "...", default-features = false, features = [ +# "fe2o3_amqp", "fe2o3_amqp_native_tls", "fe2o3_amqp_ws", +# ] } +# fe2o3-amqp-ws = { version = "0.14", features = ["native-tls"] } +# +# `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. +# +# One stack must be selected somewhere in the graph. `fe2o3-amqp-ws` puts +# `connect_tls_with_config` behind its own TLS features, so `fe2o3_amqp_ws` on +# its own does not build. `reqwest` differs here: it compiles with no TLS +# feature and fails at run time instead. fe2o3_amqp_ws = ["fe2o3_amqp", "dep:fe2o3-amqp-ws"] +# The direct `rustls` dependency is only here to select the crypto provider. +# `reqwest/rustls` selects aws-lc-rs itself, so `typespec_client_core` needs no +# such dependency; the `tokio-tungstenite` chain under `fe2o3-amqp-ws` takes +# rustls with `default-features = false` and names no provider, so +# `ClientConfig::builder()` would panic. Default features give aws-lc-rs, std, +# and tls12. This crate names no `rustls` type. fe2o3_amqp_ws_rustls = [ "fe2o3_amqp_ws", "fe2o3-amqp-ws/rustls-tls-native-roots", "dep:rustls", ] -fe2o3_amqp_ws_native_tls = [ - "fe2o3_amqp_ws", - "fe2o3-amqp-ws/native-tls", -] [lints] workspace = true diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index dd810a41c42..716100c9a05 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -27,12 +27,15 @@ pub enum AmqpTransport { Tcp, /// AMQP framing tunneled over secure WebSockets (`wss://`, port 443). /// - /// This variant needs the `fe2o3_amqp_ws_rustls` feature (which the - /// `default` feature selects) or the `fe2o3_amqp_ws_native_tls` feature. - /// The base `fe2o3_amqp_ws` feature turns on the transport code, and it - /// does not select a TLS stack. A build without one of the two TLS - /// features can still select this variant, but the connection then - /// returns an error when it opens. + /// This variant needs the `fe2o3_amqp_ws` feature, which the `default` + /// feature selects. A build without it can still select this variant, but + /// the connection then returns an error when it opens. + /// + /// `fe2o3_amqp_ws` names no TLS stack. `default` adds + /// `fe2o3_amqp_ws_rustls`, which selects rustls with the aws-lc-rs + /// provider. To use another stack, turn off the default features, name + /// `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with + /// the stack you want. WebSocket, } diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 0db40a54fec..2305e0caafd 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All Rights reserved // Licensed under the MIT license. -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] use crate::fe2o3::error::Fe2o3WebSocketError; use crate::{ connection::{AmqpConnectionApis, AmqpConnectionOptions, AmqpTransport}, @@ -50,7 +50,7 @@ impl Drop for Fe2o3AmqpConnection { /// The well-known path that Service Bus and Event Hubs expose for the AMQP /// WebSocket binding. Matches the suffix used by the other Azure SDKs. -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// Builds the secure WebSocket (`wss://`) address used to tunnel AMQP for the @@ -59,7 +59,7 @@ const WEBSOCKET_PATH: &str = "/$servicebus/websocket/"; /// explicit port (if any) are carried over, since AMQP-over-WebSockets always /// uses TLS and a fixed binding path. When no port is present the default /// `wss` port (443) is used. -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] fn websocket_address(target: &Url) -> Result { let host = target .host_str() @@ -152,46 +152,31 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? } AmqpTransport::WebSocket => { - // The fe2o3-amqp-ws connect entry point only exists when one - // of its TLS backends is on, so the transport needs one too. - #[cfg(not(any( - feature = "fe2o3_amqp_ws_rustls", - feature = "fe2o3_amqp_ws_native_tls" - )))] + // A build without the transport code still accepts the variant, + // so report the missing feature rather than fail to compile. + #[cfg(not(feature = "fe2o3_amqp_ws"))] { Err(AmqpError::with_message( - "The WebSocket transport needs the `fe2o3_amqp_ws_rustls` or the \ - `fe2o3_amqp_ws_native_tls` feature of azure_core_amqp. The base \ - `fe2o3_amqp_ws` feature does not select a TLS stack.", + "The WebSocket transport needs the `fe2o3_amqp_ws` feature of \ + azure_core_amqp.", ))?; unreachable!() } - #[cfg(any( - feature = "fe2o3_amqp_ws_rustls", - feature = "fe2o3_amqp_ws_native_tls" - ))] + // Tunnel AMQP over a secure WebSocket (port 443) for networks + // that block the native AMQP ports. The socket connects to the + // websocket address (or the custom endpoint proxy, if set), + // while the AMQP `hostname` remains the real service host. + // `open_with_stream` does not derive the hostname from a URL, + // so it must be set explicitly. + // + // The call passes no connector, so `fe2o3-amqp-ws` uses + // whichever TLS stack its own features select. The + // `fe2o3_amqp_ws_rustls` feature of this crate selects rustls, + // and a direct dependency on `fe2o3-amqp-ws` in the application + // can select another stack instead. + #[cfg(feature = "fe2o3_amqp_ws")] { - // Tunnel AMQP over a secure WebSocket (port 443) for networks - // that block the native AMQP ports. The socket connects to the - // websocket address (or the custom endpoint proxy, if set), - // while the AMQP `hostname` remains the real service host. - // `open_with_stream` does not derive the hostname from a URL, - // so it must be set explicitly. - // The features are additive, so a build can hold both TLS - // stacks. `fe2o3-amqp-ws` then takes the native-tls arm, - // because this call passes no explicit connector. - #[cfg(all( - feature = "fe2o3_amqp_ws_rustls", - feature = "fe2o3_amqp_ws_native_tls" - ))] - warn!( - "Both the `fe2o3_amqp_ws_rustls` and the \ - `fe2o3_amqp_ws_native_tls` feature are on. The WebSocket \ - connection uses native-tls. Enable only one feature to \ - select a stack." - ); - let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); let ws_address = websocket_address(ws_target)?; debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); @@ -313,10 +298,7 @@ impl From for AmqpError { } } -#[cfg(all( - test, - any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls") -))] +#[cfg(all(test, feature = "fe2o3_amqp_ws"))] mod tests { use super::*; diff --git a/sdk/core/azure_core_amqp/src/fe2o3/error.rs b/sdk/core/azure_core_amqp/src/fe2o3/error.rs index dafcabf97ff..0a74307d625 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/error.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/error.rs @@ -46,9 +46,9 @@ impl From for Fe2o3TransportError { } } -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] pub(crate) struct Fe2o3WebSocketError(pub fe2o3_amqp_ws::Error); -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] impl From for Fe2o3WebSocketError { fn from(e: fe2o3_amqp_ws::Error) -> Self { Fe2o3WebSocketError(e) @@ -166,7 +166,7 @@ impl From for AmqpError { } } -#[cfg(any(feature = "fe2o3_amqp_ws_rustls", feature = "fe2o3_amqp_ws_native_tls"))] +#[cfg(feature = "fe2o3_amqp_ws")] impl From for AmqpError { fn from(e: Fe2o3WebSocketError) -> Self { // The websocket establishment error wraps the underlying WebSocket and diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index afdb6259265..8bd28515261 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -6,7 +6,7 @@ - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) - Added a `with_transport` builder method on `ProducerClient` and `ConsumerClient`, which takes the `AmqpTransport` of `azure_core_amqp` (re-exported as `models::AmqpTransport`). `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) -- Added the `fe2o3_amqp`, `fe2o3_amqp_native_tls`, `fe2o3_amqp_ws`, `fe2o3_amqp_ws_rustls`, and `fe2o3_amqp_ws_native_tls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend, the native-tls stack for the TCP transport, and the rustls stack for the WebSocket transport. To select the other WebSocket TLS stack, turn off the default features and name the stack you want, for example `default-features = false, features = ["fe2o3_amqp_native_tls", "fe2o3_amqp_ws_native_tls"]`. This lets an application build the WebSocket transport with the TLS stack it already uses, in the same way that `azure_core` forwards its `reqwest` TLS features. +- Added the `fe2o3_amqp`, `fe2o3_amqp_native_tls`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend, the native-tls stack for the TCP transport, and the rustls stack for the WebSocket transport. To build the WebSocket transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml index 10f2758b211..fe343124f17 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml +++ b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml @@ -61,19 +61,19 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } in_memory_checkpoint_store = [] default = ["azure_core_amqp/default"] # The AMQP backend and the AMQP-over-WebSockets transport, forwarded from -# `azure_core_amqp`. The `default` feature selects the backend and the rustls -# WebSocket stack. To select the other TLS stack, turn off the default features -# and name the backend and the stack you want: +# `azure_core_amqp`. The `default` feature selects the backend, the native-tls +# stack for the TCP transport, and the rustls stack for the WebSocket transport. +# +# To build the WebSocket transport on another TLS stack, turn off the default +# features, name `fe2o3_amqp_ws`, and take a direct dependency on the backend +# crate with the stack you want. Cargo unifies the features: # # azure_messaging_eventhubs = { version = "...", default-features = false, features = [ -# "fe2o3_amqp_native_tls", -# "fe2o3_amqp_ws_native_tls", +# "fe2o3_amqp_native_tls", "fe2o3_amqp_ws", # ] } +# fe2o3-amqp-ws = { version = "0.14", features = ["native-tls"] } # -# The base `fe2o3_amqp_ws` feature turns on the transport code and does not -# select a TLS stack. A build without one of the two TLS features can still -# select `AmqpTransport::WebSocket`, but the connection then returns an error -# when it opens. +# `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. fe2o3_amqp = ["azure_core_amqp/fe2o3_amqp"] fe2o3_amqp_native_tls = [ "fe2o3_amqp", @@ -84,10 +84,6 @@ fe2o3_amqp_ws_rustls = [ "fe2o3_amqp_ws", "azure_core_amqp/fe2o3_amqp_ws_rustls", ] -fe2o3_amqp_ws_native_tls = [ - "fe2o3_amqp_ws", - "azure_core_amqp/fe2o3_amqp_ws_native_tls", -] [[bench]] name = "benchmarks" From 7c31ed3b42f0b757c8333703139b3d8a614229d8 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Sat, 1 Aug 2026 21:42:25 -0400 Subject: [PATCH 18/25] feat(amqp): move the TLS stack to rustls with aws-lc-rs `fe2o3-amqp` 0.16 builds its rustls backend on aws-lc-rs, where 0.14 built it on `ring`, which `deny.toml` bans. That was the only reason this crate named native-tls for AMQP framed on TCP. Update the `fe2o3-amqp` family from 0.14 to 0.16, which needs no source change, and replace `fe2o3_amqp_native_tls` with `fe2o3_amqp_rustls`. The `default` feature selects it, so both the TCP and the WebSocket transport now run on rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses. There is no native-tls feature. `fe2o3-amqp` accepts one TLS stack and reports `TlsConnectorNotFound` at run time when both are on, so a pair of features that cannot both be on would break `--all-features`. A consumer that wants another stack turns off the default features and takes a direct dependency on `fe2o3-amqp` with the stack they want, which is the pattern `samples/list_blobs_native_tls` shows for `reqwest`. The `azure_core_amqp` dependency of `azure_messaging_eventhubs` now sets `default-features = false`, in the same shape as `azure_core`, so that turning the default features off can drop the rustls stack. Closes #4189. --- Cargo.lock | 144 ++++++++++++++---- Cargo.toml | 14 +- sdk/core/azure_core_amqp/CHANGELOG.md | 4 +- sdk/core/azure_core_amqp/Cargo.toml | 48 ++++-- .../azure_messaging_eventhubs/CHANGELOG.md | 2 +- .../azure_messaging_eventhubs/Cargo.toml | 34 +++-- 6 files changed, 184 insertions(+), 62 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 43a20996d20..65de1c883d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,13 +405,13 @@ checksum = "f4d270c927b83dbee3c6f5fb5340b38c6019452eeb6ea097f40148ec164fafe1" dependencies = [ "async-trait", "azure_core 1.1.0", - "fe2o3-amqp", - "fe2o3-amqp-cbs", - "fe2o3-amqp-ext", - "fe2o3-amqp-management", - "fe2o3-amqp-types", + "fe2o3-amqp 0.14.0", + "fe2o3-amqp-cbs 0.14.0", + "fe2o3-amqp-ext 0.14.0", + "fe2o3-amqp-management 0.14.0", + "fe2o3-amqp-types 0.14.0", "serde", - "serde_amqp", + "serde_amqp 0.14.1", "serde_bytes", "tokio", "tracing", @@ -425,15 +425,15 @@ version = "1.2.0-beta.1" dependencies = [ "async-trait", "azure_core 1.2.0-beta.1", - "fe2o3-amqp", - "fe2o3-amqp-cbs", - "fe2o3-amqp-ext", - "fe2o3-amqp-management", - "fe2o3-amqp-types", + "fe2o3-amqp 0.16.0", + "fe2o3-amqp-cbs 0.16.0", + "fe2o3-amqp-ext 0.16.0", + "fe2o3-amqp-management 0.16.0", + "fe2o3-amqp-types 0.16.0", "fe2o3-amqp-ws", "rustls", "serde", - "serde_amqp", + "serde_amqp 0.16.0", "serde_bytes", "serde_json", "tokio", @@ -798,7 +798,7 @@ dependencies = [ "azure_storage_blob", "base64", "criterion", - "fe2o3-amqp", + "fe2o3-amqp 0.16.0", "futures", "hmac", "include-file", @@ -1630,14 +1630,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a579ef4f1fb186f04bcdc9caf0c335adedebe879227c96d56876d473aa3d20a" dependencies = [ "bytes", - "fe2o3-amqp-types", + "fe2o3-amqp-types 0.14.0", "futures-util", "getrandom 0.3.4", "native-tls", "parking_lot", "pin-project-lite", "serde", - "serde_amqp", + "serde_amqp 0.14.1", "serde_bytes", "slab", "thiserror", @@ -1645,10 +1645,38 @@ dependencies = [ "tokio-native-tls", "tokio-stream", "tokio-util", + "url", + "uuid", + "wasmtimer", +] + +[[package]] +name = "fe2o3-amqp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1bf4f8851ad8c71afaa1aa164e0caa46b9b9282c78d9ba690b3ac3fa02ec9c6" +dependencies = [ + "bytes", + "fe2o3-amqp-types 0.16.0", + "futures-util", + "getrandom 0.4.3", + "parking_lot", + "pin-project-lite", + "rustls", + "serde", + "serde_amqp 0.16.0", + "serde_bytes", + "slab", + "thiserror", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", "tracing", "url", "uuid", "wasmtimer", + "webpki-roots", ] [[package]] @@ -1657,8 +1685,19 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6cae904b214ffa3c9bae26e4129d300fe79189d2ef70503071fb25ff9127531e" dependencies = [ - "fe2o3-amqp", - "fe2o3-amqp-management", + "fe2o3-amqp 0.14.0", + "fe2o3-amqp-management 0.14.0", + "trait-variant", +] + +[[package]] +name = "fe2o3-amqp-cbs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7df6781f8e68441039978dfccfb207abcb21c12cecc06d7012944c98b5af50" +dependencies = [ + "fe2o3-amqp 0.16.0", + "fe2o3-amqp-management 0.16.0", "trait-variant", ] @@ -1668,8 +1707,18 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6362c13b91a80dca77360eecdfbe85425e6dff1523f16c3a79fac541c47cf27d" dependencies = [ - "fe2o3-amqp-types", - "serde_amqp", + "fe2o3-amqp-types 0.14.0", + "serde_amqp 0.14.1", +] + +[[package]] +name = "fe2o3-amqp-ext" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab270e0b2e2a991a7b5809df3d250f24066e3ad3b9f68c31c91a695c36b9435" +dependencies = [ + "fe2o3-amqp-types 0.16.0", + "serde_amqp 0.16.0", ] [[package]] @@ -1678,8 +1727,20 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0582084762bdf022540c37868a0808e9f54dbcc51fe56f6212da59c167569cda" dependencies = [ - "fe2o3-amqp", - "fe2o3-amqp-types", + "fe2o3-amqp 0.14.0", + "fe2o3-amqp-types 0.14.0", + "serde", + "thiserror", +] + +[[package]] +name = "fe2o3-amqp-management" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1fe67bcc1831af0046d2b56615845b887d3e89a5827990dceb259b82316385a" +dependencies = [ + "fe2o3-amqp 0.16.0", + "fe2o3-amqp-types 0.16.0", "serde", "thiserror", ] @@ -1692,20 +1753,33 @@ checksum = "8bcc8d13ed13fbb2fb664a6df114bcc32f8ca85c9cb6b89d4e7576c47f583706" dependencies = [ "ordered-float", "serde", - "serde_amqp", + "serde_amqp 0.14.1", + "serde_bytes", + "serde_repr", +] + +[[package]] +name = "fe2o3-amqp-types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a759b57e923ff56dde0935a479be557b8681095e190f0cdf2a2777e8060cfd" +dependencies = [ + "ordered-float", + "serde", + "serde_amqp 0.16.0", "serde_bytes", "serde_repr", ] [[package]] name = "fe2o3-amqp-ws" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9117053be08403ac3b36538bf5a4cf42d328cdcf09950142524dd9ca4b25121a" +checksum = "d97c7c11da9b00c1703fee21d5b1ca129ade1ae0d44d4b9f20eecdde29dbbe7e" dependencies = [ "bytes", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.3", "http", "js-sys", "pin-project-lite", @@ -3578,11 +3652,27 @@ dependencies = [ "uuid", ] +[[package]] +name = "serde_amqp" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "110918b753cd22eaeb30632c7b73cc548b2a6258ec07a2bd5ea8ebf6365f8d69" +dependencies = [ + "bytes", + "indexmap 2.14.0", + "ordered-float", + "serde", + "serde_amqp_derive", + "serde_bytes", + "thiserror", + "uuid", +] + [[package]] name = "serde_amqp_derive" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22da57ecf44834259b4416250608e11da620750be91305bf6ae5d398954ddc6d" +checksum = "b1e9b8826519d5a00c5de47e74ee76001a50276de6716a91fb40efc70a3c95fa" dependencies = [ "convert_case", "darling", diff --git a/Cargo.toml b/Cargo.toml index 25baec26365..2a56dd348a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -104,12 +104,12 @@ criterion = { version = "0.8", features = ["async_tokio"] } crossbeam = { version = "0.8", default-features = false } crossbeam-epoch = { version = "0.9", default-features = false } dyn-clone = "1.0" -fe2o3-amqp = { version = "0.14", features = ["uuid"] } -fe2o3-amqp-ws = { version = "0.14" } -fe2o3-amqp-ext = { version = "0.14" } -fe2o3-amqp-management = { version = "0.14" } -fe2o3-amqp-cbs = { version = "0.14" } -fe2o3-amqp-types = { version = "0.14" } +fe2o3-amqp = { version = "0.16", features = ["uuid"] } +fe2o3-amqp-ws = { version = "0.16" } +fe2o3-amqp-ext = { version = "0.16" } +fe2o3-amqp-management = { version = "0.16" } +fe2o3-amqp-cbs = { version = "0.16" } +fe2o3-amqp-types = { version = "0.16" } flate2 = "1.1.9" futures = "0.3" getrandom = { version = "0.4" } @@ -139,7 +139,7 @@ rust_decimal = "1.40.0" rustc_version = "0.4" rustls = "0.23" serde = { version = "1.0", features = ["derive"] } -serde_amqp = { version = "0.14", features = ["uuid"] } +serde_amqp = { version = "0.16", features = ["uuid"] } serde_bytes = { version = "0.11" } serde_json = "1.0.149" serde_test = "1" diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index d216a621133..2109b384e2d 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -6,7 +6,7 @@ - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. - Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, because `fe2o3-amqp-ws` puts its connect entry point behind its own TLS features. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. -- Added the `fe2o3_amqp_native_tls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. The `default` feature selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so a consumer that turns off the default features can name the stack. native-tls is the only choice for this transport, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which `deny.toml` bans (issue #4189). +- Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so this transport now runs on rustls where it ran on native-tls. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) ### Bugs Fixed @@ -15,6 +15,8 @@ ### Other Changes +- Updated the `fe2o3-amqp` family of dependencies from 0.14 to 0.16. The rustls backend of 0.16 is built on aws-lc-rs, where 0.14 was built on `ring`, which `deny.toml` bans. This is what makes `fe2o3_amqp_rustls` possible. The update needed no source change. + ## 1.1.0 (2026-07-09) ### Features Added diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index a6864d7979b..32ad8315e48 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -42,7 +42,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } [features] default = [ "fe2o3_amqp", - "fe2o3_amqp_native_tls", + "fe2o3_amqp_rustls", "fe2o3_amqp_ws", "fe2o3_amqp_ws_rustls", ] @@ -61,10 +61,28 @@ fe2o3_amqp = [ # The TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671). # `fe2o3_amqp` is the base, and this feature adds the stack, in the same shape # that `azure_core` uses for HTTP, where `reqwest` is the base and -# `reqwest_rustls` adds the stack. native-tls is the only stack this crate can -# name, because the rustls backend of `fe2o3-amqp` 0.14 pulls in `ring`, which -# `deny.toml` bans (#4189). -fe2o3_amqp_native_tls = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] +# `reqwest_rustls` adds the stack. It is rustls with the aws-lc-rs provider, the +# stack that the rest of `sdk/core` uses, and `default` selects it. `fe2o3-amqp` +# selects the provider itself, so this feature needs no direct `rustls` +# dependency. +# +# This feature needs `fe2o3-amqp` 0.16 or later. The rustls backend of 0.14 was +# built on `ring`, which `deny.toml` bans (#4189). +# +# There is no matching native-tls feature, because `fe2o3-amqp` accepts one TLS +# stack only and reports `TlsConnectorNotFound` at run time when both are on. +# Two features that cannot both be on would break `--all-features`. To use +# another stack, turn off the default features, name `fe2o3_amqp`, and take a +# direct dependency on `fe2o3-amqp` with the stack you want. Cargo unifies the +# features, and nothing pulls rustls in: +# +# azure_core_amqp = { version = "...", default-features = false, features = [ +# "fe2o3_amqp", +# ] } +# fe2o3-amqp = { version = "0.16", features = ["native-tls"] } +# +# `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. +fe2o3_amqp_rustls = ["fe2o3_amqp", "fe2o3-amqp/rustls"] # AMQP over WebSockets. `fe2o3_amqp_ws` is the base feature: it turns on the # transport code and the `fe2o3-amqp-ws` dependency, and it names no TLS stack. # `fe2o3_amqp_ws_rustls` adds the stack that the rest of `sdk/core` uses, rustls @@ -76,9 +94,10 @@ fe2o3_amqp_native_tls = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] # your stack and nothing pulls rustls in: # # azure_core_amqp = { version = "...", default-features = false, features = [ -# "fe2o3_amqp", "fe2o3_amqp_native_tls", "fe2o3_amqp_ws", +# "fe2o3_amqp", "fe2o3_amqp_ws", # ] } -# fe2o3-amqp-ws = { version = "0.14", features = ["native-tls"] } +# fe2o3-amqp = { version = "0.16", features = ["native-tls"] } +# fe2o3-amqp-ws = { version = "0.16", features = ["native-tls"] } # # `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. # @@ -88,11 +107,16 @@ fe2o3_amqp_native_tls = ["fe2o3_amqp", "fe2o3-amqp/native-tls"] # feature and fails at run time instead. fe2o3_amqp_ws = ["fe2o3_amqp", "dep:fe2o3-amqp-ws"] # The direct `rustls` dependency is only here to select the crypto provider. -# `reqwest/rustls` selects aws-lc-rs itself, so `typespec_client_core` needs no -# such dependency; the `tokio-tungstenite` chain under `fe2o3-amqp-ws` takes -# rustls with `default-features = false` and names no provider, so -# `ClientConfig::builder()` would panic. Default features give aws-lc-rs, std, -# and tls12. This crate names no `rustls` type. +# This crate names no `rustls` type. `reqwest/rustls` selects aws-lc-rs itself, +# so `typespec_client_core` needs no such dependency. The `tokio-tungstenite` +# chain under `fe2o3-amqp-ws` is different. It takes rustls with +# `default-features = false` and names no provider, so `ClientConfig::builder()` +# panics when no process-level default is installed. The default features of +# `rustls` give aws-lc-rs, std, and tls12. +# +# `fe2o3_amqp_rustls` selects the same provider, so the dependency is redundant +# when `default` is on. This feature has to stand on its own, because a consumer +# can turn the default features off and name only the WebSocket transport. fe2o3_amqp_ws_rustls = [ "fe2o3_amqp_ws", "fe2o3-amqp-ws/rustls-tls-native-roots", diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index 8bd28515261..a3fa4ee81be 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -6,7 +6,7 @@ - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) - Added a `with_transport` builder method on `ProducerClient` and `ConsumerClient`, which takes the `AmqpTransport` of `azure_core_amqp` (re-exported as `models::AmqpTransport`). `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) -- Added the `fe2o3_amqp`, `fe2o3_amqp_native_tls`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend, the native-tls stack for the TCP transport, and the rustls stack for the WebSocket transport. To build the WebSocket transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. +- Added the `fe2o3_amqp`, `fe2o3_amqp_rustls`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend and the rustls stack with the aws-lc-rs provider for both the TCP and the WebSocket transport. That is the stack that the rest of `sdk/core` uses, and the TCP transport ran on native-tls before. To build on another stack, turn off the default features, name the base features, and take a direct dependency on `fe2o3-amqp` and `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. diff --git a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml index fe343124f17..78cc24eca26 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml +++ b/sdk/eventhubs/azure_messaging_eventhubs/Cargo.toml @@ -23,7 +23,11 @@ async-trait.workspace = true azure_core = { path = "../../core/azure_core", version = "1.2.0-beta.1", default-features = false } # Unreleased: AmqpTransport for AMQP-over-WebSockets (issue #3601). Path plus # version per the dependency policy in AGENTS.md. -azure_core_amqp = { path = "../../core/azure_core_amqp", version = "1.2.0-beta.1" } +# `default-features = false` so that the `default` feature below is what selects +# the TLS stack, in the same shape as `azure_core` above. With the default +# features on here, a consumer that turns them off could not drop the rustls +# stack to bring their own. +azure_core_amqp = { path = "../../core/azure_core_amqp", version = "1.2.0-beta.1", default-features = false } base64.workspace = true futures.workspace = true hmac.workspace = true @@ -37,12 +41,14 @@ tracing.workspace = true rustc_version.workspace = true [dev-dependencies] -azure_core_amqp = { path = "../../core/azure_core_amqp", features = ["test"] } +azure_core_amqp = { path = "../../core/azure_core_amqp", default-features = false, features = [ + "test", +] } azure_core_test = { path = "../../core/azure_core_test", features = [ "tracing", ] } azure_identity = { path = "../../identity/azure_identity" } -azure_messaging_eventhubs = { path = ".", features = [ +azure_messaging_eventhubs = { path = ".", default-features = false, features = [ "in_memory_checkpoint_store", ] } # Path-only so `cargo package` works: this crate is a dependency of the @@ -61,24 +67,24 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } in_memory_checkpoint_store = [] default = ["azure_core_amqp/default"] # The AMQP backend and the AMQP-over-WebSockets transport, forwarded from -# `azure_core_amqp`. The `default` feature selects the backend, the native-tls -# stack for the TCP transport, and the rustls stack for the WebSocket transport. +# `azure_core_amqp`. The `default` feature selects the backend and the rustls +# stack with the aws-lc-rs provider for both the TCP and the WebSocket +# transport. That is the stack that the rest of `sdk/core` uses. # -# To build the WebSocket transport on another TLS stack, turn off the default -# features, name `fe2o3_amqp_ws`, and take a direct dependency on the backend -# crate with the stack you want. Cargo unifies the features: +# There is no native-tls feature, because `fe2o3-amqp` accepts one TLS stack +# only. To build on another stack, turn off the default features, name the base +# features, and take a direct dependency on the backend crates with the stack +# you want. Cargo unifies the features: # # azure_messaging_eventhubs = { version = "...", default-features = false, features = [ -# "fe2o3_amqp_native_tls", "fe2o3_amqp_ws", +# "fe2o3_amqp", "fe2o3_amqp_ws", # ] } -# fe2o3-amqp-ws = { version = "0.14", features = ["native-tls"] } +# fe2o3-amqp = { version = "0.16", features = ["native-tls"] } +# fe2o3-amqp-ws = { version = "0.16", features = ["native-tls"] } # # `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. fe2o3_amqp = ["azure_core_amqp/fe2o3_amqp"] -fe2o3_amqp_native_tls = [ - "fe2o3_amqp", - "azure_core_amqp/fe2o3_amqp_native_tls", -] +fe2o3_amqp_rustls = ["fe2o3_amqp", "azure_core_amqp/fe2o3_amqp_rustls"] fe2o3_amqp_ws = ["fe2o3_amqp", "azure_core_amqp/fe2o3_amqp_ws"] fe2o3_amqp_ws_rustls = [ "fe2o3_amqp_ws", From 63453f56c23278a062ca4bd3a225e033247219f5 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Mon, 3 Aug 2026 19:46:35 -0400 Subject: [PATCH 19/25] fix(amqp): open the WebSocket through the ungated connect entry point `fe2o3_amqp_ws` is the base feature and names no TLS stack, but the transport called `WebSocketStream::connect_tls_with_config`, which `fe2o3-amqp-ws` puts behind its own TLS features. A build that named `fe2o3_amqp_ws` alone therefore failed with E0599, so the advertised base feature did not compile. Call `connect_with_config` instead. It carries no feature gate, and it reaches the same `tokio_tungstenite` connect path with no connector, which is what the previous call passed. The behavior is unchanged when a TLS stack is selected, and a build that selects none now reports `TlsFeatureNotEnabled` when the connection opens. This matches `reqwest`, which also compiles with no TLS feature and fails at run time, and it needs no TLS modifier feature of its own. Update the feature comment, the `AmqpTransport::WebSocket` doc, and the changelog, which all said the base feature could not build. --- sdk/core/azure_core_amqp/CHANGELOG.md | 2 +- sdk/core/azure_core_amqp/Cargo.toml | 7 +++---- sdk/core/azure_core_amqp/src/connection.rs | 3 ++- sdk/core/azure_core_amqp/src/fe2o3/connection.rs | 13 +++++++++---- 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 2109b384e2d..1ad12d52e73 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -5,7 +5,7 @@ ### Features Added - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. -- Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, because `fe2o3-amqp-ws` puts its connect entry point behind its own TLS features. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. +- Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, and a build that selects none still compiles and reports the missing stack when the connection opens. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. - Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so this transport now runs on rustls where it ran on native-tls. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 32ad8315e48..74ef68a4ded 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -101,10 +101,9 @@ fe2o3_amqp_rustls = ["fe2o3_amqp", "fe2o3-amqp/rustls"] # # `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. # -# One stack must be selected somewhere in the graph. `fe2o3-amqp-ws` puts -# `connect_tls_with_config` behind its own TLS features, so `fe2o3_amqp_ws` on -# its own does not build. `reqwest` differs here: it compiles with no TLS -# feature and fails at run time instead. +# One stack must be selected somewhere in the graph. `fe2o3_amqp_ws` on its own +# builds, and the connection reports `TlsFeatureNotEnabled` when it opens. +# `reqwest` behaves the same way. fe2o3_amqp_ws = ["fe2o3_amqp", "dep:fe2o3-amqp-ws"] # The direct `rustls` dependency is only here to select the crypto provider. # This crate names no `rustls` type. `reqwest/rustls` selects aws-lc-rs itself, diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 716100c9a05..6c40a943a2c 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -35,7 +35,8 @@ pub enum AmqpTransport { /// `fe2o3_amqp_ws_rustls`, which selects rustls with the aws-lc-rs /// provider. To use another stack, turn off the default features, name /// `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with - /// the stack you want. + /// the stack you want. A build that selects no stack also compiles, and + /// the connection reports the missing stack when it opens. WebSocket, } diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 2305e0caafd..77435801bd1 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -170,21 +170,26 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { // `open_with_stream` does not derive the hostname from a URL, // so it must be set explicitly. // - // The call passes no connector, so `fe2o3-amqp-ws` uses - // whichever TLS stack its own features select. The + // `connect_with_config` takes no connector, so `fe2o3-amqp-ws` + // uses whichever TLS stack its own features select. The // `fe2o3_amqp_ws_rustls` feature of this crate selects rustls, // and a direct dependency on `fe2o3-amqp-ws` in the application // can select another stack instead. + // + // `connect_tls_with_config` does the same thing, but it sits + // behind the TLS features of `fe2o3-amqp-ws`, so a call to it + // would keep `fe2o3_amqp_ws` from building on its own. + // `connect_with_config` carries no such gate, and it reports + // `TlsFeatureNotEnabled` when no stack is selected. #[cfg(feature = "fe2o3_amqp_ws")] { let ws_target = options.custom_endpoint.as_ref().unwrap_or(&url); let ws_address = websocket_address(ws_target)?; debug!("Opening AMQP-over-WebSockets connection to {ws_address}."); - let ws_stream = fe2o3_amqp_ws::WebSocketStream::connect_tls_with_config( + let ws_stream = fe2o3_amqp_ws::WebSocketStream::connect_with_config( &ws_address, None, false, - None, ) .await .map_err(|e| AmqpError::from(Fe2o3WebSocketError(e)))?; From 42b5c7dcb00de223283aff16b72f70cced53aff9 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 4 Aug 2026 10:06:49 -0400 Subject: [PATCH 20/25] fix(eventhubs): pass the transport to the call sites added on main `RecoverableConnection::new` takes a `transport` argument on this branch, and `ConsumerClientOptions` carries a `transport` field. PR #4933 landed on main after this branch forked and added a unit test and a builder call that use the earlier shapes. Git merges the two changes without a textual conflict, so the branch built on its own while the pipeline, which builds the merge with main, failed with E0061, E0063, and E0277. Pass the default transport at both new call sites. --- .../src/common/recoverable/connection.rs | 1 + sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs index cbe9cc06a10..64760d7efa2 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/common/recoverable/connection.rs @@ -1525,6 +1525,7 @@ mod tests { Url::parse("amqps://example.com").unwrap(), None, None, + AmqpTransport::default(), Arc::new(MockCredential), Default::default(), None, diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs index 1e05d56d8d2..6f8ef6dc704 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs @@ -197,6 +197,7 @@ impl ConsumerClient { retry_options: None, custom_endpoint: None, cbs_token_type: None, + transport: AmqpTransport::default(), }, ) } From bdc7245a36ed834d95edbdd197982e13f6c9c2d5 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 4 Aug 2026 11:13:24 -0400 Subject: [PATCH 21/25] docs(amqp): document the TCP TLS feature on docs.rs The `docs.rs` metadata named `fe2o3_amqp`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls`, so docs.rs built the crate without the TLS stack for AMQP framed on TCP. Add `fe2o3_amqp_rustls`. The list now holds every feature that `default` selects. It leaves out `test`, which is an internal helper, and `ffi`, which does not build on its own (#4978). `azure_core` names its features the same way. --- sdk/core/azure_core_amqp/Cargo.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 74ef68a4ded..9aa260bec96 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -126,4 +126,9 @@ fe2o3_amqp_ws_rustls = [ workspace = true [package.metadata.docs.rs] -features = ["fe2o3_amqp", "fe2o3_amqp_ws", "fe2o3_amqp_ws_rustls"] +features = [ + "fe2o3_amqp", + "fe2o3_amqp_rustls", + "fe2o3_amqp_ws", + "fe2o3_amqp_ws_rustls", +] From 3d786e4127cb93b8c1dcb18b926087fdfdf23a4c Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Tue, 4 Aug 2026 15:03:34 -0400 Subject: [PATCH 22/25] docs(eventhubs): correct the close-order comment in the WebSocket sample The comment said the receiver and the stream hold references to the consumer's connection, so the caller must release them before the consumer closes. That overstates the requirement. `ConsumerClient::close` takes `&self` on the connection manager and drains the receiver cache, so it detaches the receiver link on its own. A receiver that outlives its client then reports the closed client instead of opening a second connection (#4931). The order of `receiver.close()` and `consumer.close()` is free. The one order the caller must keep is the stream before the receiver. `stream_events` borrows `&self` and `close` takes `self` by value, so the compiler enforces it. The block above already covers that. --- .../examples/eventhubs_websocket_transport.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs index e9251434708..e3b754226bd 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/examples/eventhubs_websocket_transport.rs @@ -83,7 +83,8 @@ async fn main() -> Result<(), Box> { // `stream` borrows `receiver`; drop it (end of block) before closing. } - // Detach the receiver link before the connection that carries it closes. + // `consumer.close()` detaches the receiver link too, so the order of these + // two calls is free (#4931). Close the receiver first to show the intent. receiver.close().await?; consumer.close().await?; producer.close().await?; From 0fe40222685214f17fa25d72d36d1ec2d3219608 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 5 Aug 2026 13:31:47 -0400 Subject: [PATCH 23/25] docs(amqp): record the breaking entries under Breaking Changes Two entries under Features Added are breaking. Restore the section and move them. The `AmqpConnectionOptions::transport` field lands on a struct that is not `#[non_exhaustive]`, so an existing struct literal that names every field no longer compiles. The `default` feature swap from `fe2o3-amqp/native-tls` to `fe2o3_amqp_rustls` changes the trust anchors. `fe2o3-amqp` builds its rustls root store from `webpki-roots`, and native-tls read the root store of the operating system. The same swap reaches `azure_messaging_eventhubs` through its forwarded features, so its changelog gets the matching entry. --- sdk/core/azure_core_amqp/CHANGELOG.md | 7 ++++++- sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index 1ad12d52e73..edae2b56e2d 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -6,7 +6,12 @@ - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. - Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, and a build that selects none still compiles and reports the missing stack when the connection opens. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. -- Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it. It replaces the `fe2o3-amqp/native-tls` entry that `default` named before, so this transport now runs on rustls where it ran on native-tls. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) +- Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it in place of the `fe2o3-amqp/native-tls` entry that `default` named before. See Breaking Changes for the effect of that swap. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) + +### Breaking Changes + +- Added the `transport` field to `AmqpConnectionOptions`. The struct is not `#[non_exhaustive]`, so an existing struct literal that names every field no longer compiles. Add `..Default::default()` to the initializer, and put `#[allow(clippy::needless_update)]` on it. The workspace allows the `constructible_struct_adds_field` semver lint for this reason, so `cargo semver-checks` does not report the addition. +- The `default` feature now selects `fe2o3_amqp_rustls`, so AMQP framed directly on TCP (`amqps://`, port 5671) runs on rustls with the aws-lc-rs provider where it ran on native-tls. The two stacks trust different root certificates. `fe2o3-amqp` builds its rustls root store from `webpki-roots`, which carries the Mozilla root set, and native-tls read the root store of the operating system. A broker behind a private or an enterprise certificate authority can fail the handshake after this change, even when the operating system trusts that authority. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) ### Bugs Fixed diff --git a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md index a3fa4ee81be..af383a77018 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md +++ b/sdk/eventhubs/azure_messaging_eventhubs/CHANGELOG.md @@ -6,13 +6,14 @@ - Added connection-string authentication. `ProducerClientBuilder` and `ConsumerClientBuilder` now have an `open_with_connection_string` method that authenticates with a Shared Access Signature parsed from an Event Hubs connection string (`Endpoint=sb://...;SharedAccessKeyName=...;SharedAccessKey=...`, optionally with `EntityPath`, or a pre-formed `SharedAccessSignature`). The connection-string parser is exposed publicly as `ConnectionString`. This reaches parity with the other Azure SDKs for development and test scenarios; Microsoft Entra ID via `open` with a `TokenCredential` remains the recommended path for production. The parser rejects empty required values and empty Event Hub names up front, and a pre-formed `SharedAccessSignature` reports its own `se` as the token expiry (rather than a rolling client-side window); because such a token cannot be renewed, the connection's token refresher detects the non-advancing expiry and leaves the broker to enforce it. ([#3459](https://github.com/Azure/azure-sdk-for-rust/issues/3459)) - Added a `with_transport` builder method on `ProducerClient` and `ConsumerClient`, which takes the `AmqpTransport` of `azure_core_amqp` (re-exported as `models::AmqpTransport`). `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443), allowing clients to connect from networks that block the native AMQP ports (5671/5672). This matches the transport option offered by the .NET, Java, and Python Azure SDKs. The `EventProcessor` inherits the transport from the `ConsumerClient` passed to `build`, so it runs over WebSockets when that client selects them. ([#3601](https://github.com/Azure/azure-sdk-for-rust/issues/3601)) -- Added the `fe2o3_amqp`, `fe2o3_amqp_rustls`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend and the rustls stack with the aws-lc-rs provider for both the TCP and the WebSocket transport. That is the stack that the rest of `sdk/core` uses, and the TCP transport ran on native-tls before. To build on another stack, turn off the default features, name the base features, and take a direct dependency on `fe2o3-amqp` and `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. +- Added the `fe2o3_amqp`, `fe2o3_amqp_rustls`, `fe2o3_amqp_ws`, and `fe2o3_amqp_ws_rustls` features, which forward the matching features of `azure_core_amqp`. The `default` feature selects the AMQP backend and the rustls stack with the aws-lc-rs provider for both the TCP and the WebSocket transport. That is the stack that the rest of `sdk/core` uses. See Breaking Changes for the effect on the TCP transport, which ran on native-tls before. To build on another stack, turn off the default features, name the base features, and take a direct dependency on `fe2o3-amqp` and `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. - The `EventProcessor` now opens every partition receiver with AMQP epoch (owner level) `0` and surfaces broker-initiated displacement as the new `EventHubsError::ConsumerDisconnected` error kind. When a second `EventProcessor` instance claims a partition this instance is currently holding, the broker disconnects this instance's receiver and the consumer's `stream_events()` resolves with `ConsumerDisconnected`. This matches the behavior of `EventProcessorClient` in the .NET and Java Azure SDKs. Consumers should pattern-match on `ErrorKind::ConsumerDisconnected` to detect a stolen partition and re-acquire a client via `next_partition_client()`. - Added `EventHubsError::ConsumerDisconnected(Option)` error variant. - Added the `ErrorKind::InvalidBatchSize { requested, max_allowed }` error variant. `create_batch` reports it when `EventDataBatchOptions::max_size_in_bytes` is zero or is larger than the maximum the sender link allows, so a caller can branch on the kind instead of the message. This matches the `ArgumentOutOfRangeException` that .NET raises and the typed error that Go returns for the same input. ### Breaking Changes +- The `default` feature now selects `fe2o3_amqp_rustls`, so AMQP framed directly on TCP (`amqps://`, port 5671) runs on rustls with the aws-lc-rs provider where it ran on native-tls. The two stacks trust different root certificates. `fe2o3-amqp` builds its rustls root store from `webpki-roots`, which carries the Mozilla root set, and native-tls read the root store of the operating system. A namespace behind a private or an enterprise certificate authority can fail the handshake after this change, even when the operating system trusts that authority. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) - On the receive path, the `amqp:link:stolen` AMQP condition is no longer auto-retried. A receiver displaced by a higher-or-equal-epoch attacher now surfaces the error (translated to `EventHubsError::ConsumerDisconnected` by `EventReceiver::stream_events`) instead of silently re-attaching. Sender, CBS, and management operations retain the historical retry-on-stolen behavior. ### Bugs Fixed From 284be2ce6f5d361d182cb5955182ff22b7e23413 Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 5 Aug 2026 13:56:33 -0400 Subject: [PATCH 24/25] docs(amqp): state how custom_endpoint treats the port on WebSockets An explicit port on `custom_endpoint` carries into the `wss://` address, so a proxy named as `amqps://proxy:5671` dials 5671 and not 443. The .NET Azure SDK does the same. `AmqpClient` builds its `ConnectionEndpoint` from the host and the explicit port of `CustomEndpointAddress`, and `CreateTransportSettingsForWebSockets` carries that port into the WebSocket URI. A proxy that accepts WebSockets on its own port stays reachable, so the behavior stands. Document the rule on `AmqpConnectionOptions::custom_endpoint` and on the `with_custom_endpoint` builders, and pin the AMQP-port case with a test. --- sdk/core/azure_core_amqp/src/connection.rs | 6 ++++++ sdk/core/azure_core_amqp/src/fe2o3/connection.rs | 13 +++++++++++++ .../azure_messaging_eventhubs/src/consumer/mod.rs | 5 +++++ .../azure_messaging_eventhubs/src/producer/mod.rs | 5 +++++ 4 files changed, 29 insertions(+) diff --git a/sdk/core/azure_core_amqp/src/connection.rs b/sdk/core/azure_core_amqp/src/connection.rs index 6c40a943a2c..d77674affc6 100644 --- a/sdk/core/azure_core_amqp/src/connection.rs +++ b/sdk/core/azure_core_amqp/src/connection.rs @@ -75,6 +75,12 @@ pub struct AmqpConnectionOptions { /// Buffer size for the connection. pub buffer_size: Option, /// Custom endpoint for the connection. Used to connect to a local AMQP proxy server. + /// + /// The host and an explicit port both carry into the address that the + /// connection dials. Under [`AmqpTransport::WebSocket`] the port carries into + /// the `wss://` address, so name the port that the proxy accepts WebSockets + /// on, and leave the port out to dial the default port 443. The .NET Azure SDK + /// treats `CustomEndpointAddress` the same way. pub custom_endpoint: Option, /// The transport used to carry the AMQP protocol. Defaults to [`AmqpTransport::Tcp`]. pub transport: Option, diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index 77435801bd1..b379e4a67a7 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -329,6 +329,19 @@ mod tests { ); } + #[test] + fn websocket_address_preserves_an_amqp_port() { + // A custom endpoint that names an AMQP port keeps it. The .NET Azure SDK + // carries `CustomEndpointAddress` into the WebSocket address the same way, + // so a proxy that accepts WebSockets on 5671 stays reachable. A caller who + // wants port 443 leaves the port out. + let proxy = Url::parse("amqps://proxy.example.com:5671/").unwrap(); + assert_eq!( + websocket_address(&proxy).unwrap(), + "wss://proxy.example.com:5671/$servicebus/websocket/" + ); + } + #[test] fn websocket_address_keeps_brackets_around_ipv6_host() { // `Url::host_str` keeps the brackets around an IPv6 literal, so the diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs index 6f8ef6dc704..205e7092e90 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/consumer/mod.rs @@ -709,6 +709,11 @@ pub mod builders { /// Note: The custom endpoint option allows a customer to specify an AMQP proxy /// which will be used to forward requests to the actual Event Hub instance. /// + /// An explicit port on the endpoint carries into the address that the client + /// dials. Under [`AmqpTransport::WebSocket`] that is the `wss://` address, so + /// name the port that the proxy accepts WebSockets on, and leave the port out + /// to dial the default port 443. + /// pub fn with_custom_endpoint(mut self, endpoint: String) -> Self { self.custom_endpoint = Some(endpoint); self diff --git a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs index 9c5ee4e828d..81f9f2a188d 100644 --- a/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs +++ b/sdk/eventhubs/azure_messaging_eventhubs/src/producer/mod.rs @@ -648,6 +648,11 @@ pub mod builders { /// Note: The custom endpoint option allows a customer to specify an AMQP proxy /// which will be used to forward requests to the actual Event Hub instance. /// + /// An explicit port on the endpoint carries into the address that the client + /// dials. Under [`AmqpTransport::WebSocket`] that is the `wss://` address, so + /// name the port that the proxy accepts WebSockets on, and leave the port out + /// to dial the default port 443. + /// pub fn with_custom_endpoint(mut self, endpoint: String) -> Self { self.custom_endpoint = Some(endpoint); self From ff0382092015b2f9e3a88325b654328a20da4f5c Mon Sep 17 00:00:00 2001 From: Johnathan W Date: Wed, 5 Aug 2026 16:44:33 -0400 Subject: [PATCH 25/25] fix(amqp): keep the AMQP TLS handshake on the OS trust store The `rustls` feature of `fe2o3-amqp` 0.16 fills its root store from `webpki-roots` only, which is a compiled-in copy of the Mozilla root set. The connection called `open()` with no connector, so AMQP framed directly on TCP took that default and stopped reading the trust store of the operating system. A broker behind a private or an enterprise certificate authority would then fail the handshake, even when the operating system trusts that authority. Supply a connector built on `rustls-platform-verifier` instead. The TCP transport now trusts the same roots as `reqwest/rustls` on the HTTP side, and it agrees with the WebSocket transport, which already reads the same store through native roots. Use `rustls_connector`, because it needs the `rustls` feature only, where `tls_connector` also needs `native-tls` to be off. The connector-typed builder hands `alt_tls_estab` to the same transport call as the default path, so `alt_tls_establishment(true)` keeps its behavior. Add a test that builds the connector, which catches a missing crypto provider at test time instead of at connect time. --- Cargo.lock | 2 + Cargo.toml | 2 + sdk/core/azure_core_amqp/CHANGELOG.md | 4 +- sdk/core/azure_core_amqp/Cargo.toml | 35 +++++++++-- .../azure_core_amqp/src/fe2o3/connection.rs | 61 +++++++++++++++++-- 5 files changed, 93 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65de1c883d6..3af73852114 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -432,11 +432,13 @@ dependencies = [ "fe2o3-amqp-types 0.16.0", "fe2o3-amqp-ws", "rustls", + "rustls-platform-verifier", "serde", "serde_amqp 0.16.0", "serde_bytes", "serde_json", "tokio", + "tokio-rustls", "tracing", "tracing-subscriber", "typespec 1.2.0-beta.1", diff --git a/Cargo.toml b/Cargo.toml index 2a56dd348a6..6600d3006c7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -138,6 +138,7 @@ reqwest = { version = "0.13.2", features = [ rust_decimal = "1.40.0" rustc_version = "0.4" rustls = "0.23" +rustls-platform-verifier = "0.7" serde = { version = "1.0", features = ["derive"] } serde_amqp = { version = "0.16", features = ["uuid"] } serde_bytes = { version = "0.11" } @@ -158,6 +159,7 @@ tokio = { version = "1.49", default-features = false, features = [ "time", ] } tokio-metrics = "0.4" +tokio-rustls = { version = "0.26", default-features = false } tracing = "0.1.44" tracing-subscriber = "0.3" url = "2.5" diff --git a/sdk/core/azure_core_amqp/CHANGELOG.md b/sdk/core/azure_core_amqp/CHANGELOG.md index edae2b56e2d..e8f6113858c 100644 --- a/sdk/core/azure_core_amqp/CHANGELOG.md +++ b/sdk/core/azure_core_amqp/CHANGELOG.md @@ -6,12 +6,12 @@ - Added `AmqpTransport` and an `AmqpConnectionOptions::transport` field to select the connection transport. `AmqpTransport::WebSocket` tunnels AMQP over secure WebSockets (`wss://`, port 443) for networks that block the native AMQP ports. - Added the `fe2o3_amqp_ws` and `fe2o3_amqp_ws_rustls` features. `fe2o3_amqp_ws` is the base feature and turns on the WebSocket transport code, and `fe2o3_amqp_ws_rustls` adds the TLS stack that the rest of `sdk/core` uses, rustls with the aws-lc-rs provider. This is the shape that `azure_core` uses for HTTP, where `reqwest` is the base and `reqwest_rustls` adds the stack. The `default` feature selects both. To build the transport on another TLS stack, turn off the default features, name `fe2o3_amqp_ws`, and take a direct dependency on `fe2o3-amqp-ws` with the stack you want; Cargo unifies the features. One stack must be selected somewhere in the graph, and a build that selects none still compiles and reports the missing stack when the connection opens. A build without `fe2o3_amqp_ws` still accepts `AmqpTransport::WebSocket`, and the connection then returns an error when it opens. -- Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it in place of the `fe2o3-amqp/native-tls` entry that `default` named before. See Breaking Changes for the effect of that swap. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) +- Added the `fe2o3_amqp_rustls` feature, which adds the TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671) on top of the `fe2o3_amqp` base feature. It is rustls with the aws-lc-rs provider, the stack that the rest of `sdk/core` uses, and `default` selects it in place of the `fe2o3-amqp/native-tls` entry that `default` named before. The connection supplies a TLS connector built on `rustls-platform-verifier`, so the handshake reads the trust store of the operating system and trusts the same roots as `reqwest/rustls` on the HTTP side. The default connector of `fe2o3-amqp` instead fills its root store from `webpki-roots`, a compiled-in copy of the Mozilla root set, which would drop the roots that an operator installs in the operating system. There is no native-tls feature on this crate, because `fe2o3-amqp` accepts one TLS stack only, and two features that cannot both be on would break `--all-features`. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) ### Breaking Changes - Added the `transport` field to `AmqpConnectionOptions`. The struct is not `#[non_exhaustive]`, so an existing struct literal that names every field no longer compiles. Add `..Default::default()` to the initializer, and put `#[allow(clippy::needless_update)]` on it. The workspace allows the `constructible_struct_adds_field` semver lint for this reason, so `cargo semver-checks` does not report the addition. -- The `default` feature now selects `fe2o3_amqp_rustls`, so AMQP framed directly on TCP (`amqps://`, port 5671) runs on rustls with the aws-lc-rs provider where it ran on native-tls. The two stacks trust different root certificates. `fe2o3-amqp` builds its rustls root store from `webpki-roots`, which carries the Mozilla root set, and native-tls read the root store of the operating system. A broker behind a private or an enterprise certificate authority can fail the handshake after this change, even when the operating system trusts that authority. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) +- The `default` feature now selects `fe2o3_amqp_rustls`, so AMQP framed directly on TCP (`amqps://`, port 5671) runs on rustls with the aws-lc-rs provider where it ran on native-tls. Both stacks read the trust store of the operating system, so a broker behind a private or an enterprise certificate authority keeps working. The stacks read that store through different platform APIs, and a deployment that tunes native-tls directly, such as one that sets OpenSSL environment variables, can still see a difference. To keep native-tls, turn off the default features, name `fe2o3_amqp`, and take a direct dependency on `fe2o3-amqp` with its `native-tls` feature. ([#4189](https://github.com/Azure/azure-sdk-for-rust/issues/4189)) ### Bugs Fixed diff --git a/sdk/core/azure_core_amqp/Cargo.toml b/sdk/core/azure_core_amqp/Cargo.toml index 9aa260bec96..86fcebd684b 100644 --- a/sdk/core/azure_core_amqp/Cargo.toml +++ b/sdk/core/azure_core_amqp/Cargo.toml @@ -27,10 +27,12 @@ fe2o3-amqp-ext = { workspace = true, optional = true } fe2o3-amqp-management = { workspace = true, optional = true } fe2o3-amqp-types = { workspace = true, optional = true } rustls = { workspace = true, optional = true } +rustls-platform-verifier = { workspace = true, optional = true } serde.workspace = true serde_amqp = { workspace = true, optional = true } serde_bytes = { workspace = true, optional = true } tokio.workspace = true +tokio-rustls = { workspace = true, optional = true } tracing.workspace = true typespec = { path = "../typespec", version = "1.2.0-beta.1" } typespec_macros = { path = "../typespec_macros", version = "1.1.0-beta.1" } @@ -61,10 +63,27 @@ fe2o3_amqp = [ # The TLS stack for AMQP framed directly on TCP (`amqps://`, port 5671). # `fe2o3_amqp` is the base, and this feature adds the stack, in the same shape # that `azure_core` uses for HTTP, where `reqwest` is the base and -# `reqwest_rustls` adds the stack. It is rustls with the aws-lc-rs provider, the -# stack that the rest of `sdk/core` uses, and `default` selects it. `fe2o3-amqp` -# selects the provider itself, so this feature needs no direct `rustls` -# dependency. +# `reqwest_rustls` adds the stack. It is rustls with the aws-lc-rs provider, and +# `default` selects it. +# +# The connection builds its own TLS connector on `rustls-platform-verifier`. +# The default connector of `fe2o3-amqp` fills its root store from +# `webpki-roots`, which holds a compiled-in copy of the Mozilla root set and +# ignores the trust store of the operating system. The platform verifier reads +# the trust store of the operating system, so this feature trusts the same roots +# as `reqwest/rustls` on the HTTP side, and a broker behind a private or an +# enterprise certificate authority keeps working. `webpki-roots` still arrives +# as a dependency, because the `rustls` feature of `fe2o3-amqp` always names it, +# but nothing reads it. +# +# The direct `rustls` dependency selects the crypto provider. +# `ClientConfig::builder()` panics when no process-level default is installed +# and the crate features name no single provider. The default features of +# `rustls` give aws-lc-rs, std, and tls12, and `deny.toml` bans `ring`, so this +# repository cannot reach that panic. An application that unifies a second +# provider into the graph makes the choice ambiguous again, and it must call +# `CryptoProvider::install_default()` before it opens a connection. The +# WebSocket feature below carries the same condition. # # This feature needs `fe2o3-amqp` 0.16 or later. The rustls backend of 0.14 was # built on `ring`, which `deny.toml` bans (#4189). @@ -82,7 +101,13 @@ fe2o3_amqp = [ # fe2o3-amqp = { version = "0.16", features = ["native-tls"] } # # `samples/list_blobs_native_tls` shows the same pattern for `reqwest`. -fe2o3_amqp_rustls = ["fe2o3_amqp", "fe2o3-amqp/rustls"] +fe2o3_amqp_rustls = [ + "fe2o3_amqp", + "fe2o3-amqp/rustls", + "dep:rustls", + "dep:rustls-platform-verifier", + "dep:tokio-rustls", +] # AMQP over WebSockets. `fe2o3_amqp_ws` is the base feature: it turns on the # transport code and the `fe2o3-amqp-ws` dependency, and it names no TLS stack. # `fe2o3_amqp_ws_rustls` adds the stack that the rest of `sdk/core` uses, rustls diff --git a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs index b379e4a67a7..67b545a1294 100644 --- a/sdk/core/azure_core_amqp/src/fe2o3/connection.rs +++ b/sdk/core/azure_core_amqp/src/fe2o3/connection.rs @@ -71,6 +71,26 @@ fn websocket_address(target: &Url) -> Result { Ok(format!("wss://{authority}{WEBSOCKET_PATH}")) } +/// Builds the TLS connector for AMQP framed directly on TCP. +/// +/// The default connector of `fe2o3-amqp` fills its root store from +/// `webpki-roots`, a compiled-in copy of the Mozilla root set, and it ignores +/// the trust store of the operating system. This connector uses the platform +/// verifier instead, so the TCP transport trusts the same roots as the HTTP +/// stack of `azure_core`, and a broker behind a private or an enterprise +/// certificate authority keeps working. +#[cfg(feature = "fe2o3_amqp_rustls")] +fn platform_verifier_connector() -> Result { + use rustls_platform_verifier::ConfigVerifierExt as _; + + let config = rustls::ClientConfig::with_platform_verifier().map_err(|e| { + AmqpError::with_message(format!("Could not build the AMQP TLS configuration: {e}")) + })?; + Ok(tokio_rustls::TlsConnector::from(std::sync::Arc::new( + config, + ))) +} + #[async_trait::async_trait] impl AmqpConnectionApis for Fe2o3AmqpConnection { async fn open( @@ -146,10 +166,29 @@ impl AmqpConnectionApis for Fe2o3AmqpConnection { endpoint = custom_endpoint; builder = builder.hostname(url.host_str()); } - builder - .open(endpoint) - .await - .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + + // Supply the connector so that the handshake uses the trust + // store of the operating system. See + // `platform_verifier_connector`. Without a connector, + // `fe2o3-amqp` falls back to its `webpki-roots` default. + #[cfg(feature = "fe2o3_amqp_rustls")] + { + builder + .rustls_connector(platform_verifier_connector()?) + .open(endpoint) + .await + .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + } + + // Another TLS stack, selected through a direct dependency on + // `fe2o3-amqp`, keeps the default connector of that stack. + #[cfg(not(feature = "fe2o3_amqp_rustls"))] + { + builder + .open(endpoint) + .await + .map_err(|e| AmqpError::from(Fe2o3ConnectionOpenError(e)))? + } } AmqpTransport::WebSocket => { // A build without the transport code still accepts the variant, @@ -303,6 +342,20 @@ impl From for AmqpError { } } +#[cfg(all(test, feature = "fe2o3_amqp_rustls"))] +mod rustls_tests { + use super::*; + + #[test] + fn platform_verifier_connector_builds() { + // `ClientConfig::builder()` panics when the process has no default + // crypto provider, and the platform verifier reports an error when it + // cannot read the trust store of the operating system. Both faults + // would otherwise appear only when a connection opens. + assert!(platform_verifier_connector().is_ok()); + } +} + #[cfg(all(test, feature = "fe2o3_amqp_ws"))] mod tests { use super::*;