Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- **`wss://` connections now use the TLS configuration on `WebSocketConfig`** (#190). With server-certificate verification on, `WebSocketTransport::connect` used tokio-tungstenite's own client configuration built from the platform's root certificates, so a CA set with `with_ca_cert_from_file` or `with_ca_cert_from_bytes` was never trusted (a server it issued failed with `UnknownIssuer`), a client certificate set with `with_client_auth_from_files` or `with_client_auth_from_bytes` was never presented, and `use_system_roots` and ALPN protocols had no effect. With verification off, the client certificate and ALPN protocols were dropped. The handshake now uses the rustls configuration `TlsTransport` builds from a `TlsConfig`, in both cases, except that `use_system_roots` adds the platform's root certificates, as a `wss://` connection without a TLS configuration already did, rather than the bundled `webpki-roots`.
- **`WebSocketConfig::with_tls_auto`, and the `with_ca_cert_*` and `with_client_auth_*` methods that call it, now accept a host name URL** (#190). They parsed the URL's host and port as a socket address, so they failed with "Invalid host/port combination" for anything but an IP address. A wss:// connection takes its server and certificate name from the URL and does not use `TlsConfig::addr`, so for a host name that is now the unspecified address `0.0.0.0` with the URL's port; set a real address before using such a config with `TlsTransport`.
- **`MqttClient` now uses its stored TLS configuration for `wss://` connections** (#190), as it already did for `mqtts://`. A CA, client certificate or key set with `set_tls_config` was ignored over WebSocket, and `set_insecure_tls(true)` replaced the stored configuration with a bare one that disables verification.
- **The WebSocket client transport now sends its configured headers, subprotocols and user agent** (#165). `WebSocketTransport::connect` built the upgrade request from a fixed set of headers and always offered `Sec-WebSocket-Protocol: mqtt`, so anything set with `WebSocketConfig::with_header`, `with_subprotocol`, `with_subprotocols` or `with_user_agent` never reached the server, and brokers that authenticate the upgrade request through custom headers rejected the connection. The request now carries every custom header, the configured subprotocols in order, followed by `mqtt` when the list does not include it (MQTT-6.0.0-3), and the user agent when one is set. The default user agent is now `mqtt5/<crate version>` instead of `mqtt-v5/0.4.0`.
- **The upgrade request's `Host` header now includes a non-default port**, so `ws://broker:8080/mqtt` sends `Host: broker:8080` rather than `Host: broker`.

### Changed

- **ALPN protocols on a `WebSocketConfig`'s TLS configuration are now offered on `wss://`.** They were ignored before. A WebSocket server negotiates HTTP, so one that advertises only `http/1.1`, as this crate's `wss://` listener does, now rejects a handshake that offers only an MQTT protocol such as `mqtt` with `NoApplicationProtocol`. `MqttClient` does not offer its stored configuration's ALPN protocols on `wss://` (for example those set with `connect_with_tls`, such as AWS IoT's `x-amzn-mqtt-ca`), since they select MQTT directly over TLS.
- **WebSocket configurations that relied on the old behavior may connect differently or fail to connect.** Because the configuration now reaches the wire:
- A custom header that is invalid or reserved (see the next entry) used to be silently dropped. It now makes `connect` fail.
- Every WebSocket connection, including those made by `MqttClient`, now sends a `User-Agent` header.
Expand All @@ -28,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- `TlsConfig::client_config`, which returns the rustls client configuration a `TlsConfig` describes.
- `WebSocketConfig::build_handshake_request`, which returns the upgrade request the configuration produces.

## [mqtt5 0.47.3] - 2026-10-09
Expand Down
3 changes: 2 additions & 1 deletion crates/mqtt5/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ opentelemetry_sdk = ["dep:opentelemetry_sdk"]
opentelemetry-otlp = ["dep:opentelemetry-otlp"]
tracing-opentelemetry = ["dep:tracing-opentelemetry"]
tokio = ["dep:tokio"]
transport-websocket = ["dep:tokio-tungstenite", "dep:futures-util"]
transport-websocket = ["dep:tokio-tungstenite", "dep:futures-util", "dep:rustls-native-certs"]
transport-quic = ["dep:quinn"]
codec-gzip = ["dep:flate2"]
codec-deflate = ["dep:flate2"]
Expand Down Expand Up @@ -177,6 +177,7 @@ rustls-pki-types = "1.15.0"
tokio = { version = "1.47", features = ["full"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect", "rustls-tls-native-roots"], optional = true }
rustls-native-certs = { version = "0.8", optional = true }
quinn = { version = "0.11.11", default-features = false, features = ["rustls-ring", "runtime-tokio", "platform-verifier"], optional = true }
webpki-roots = "1.0.8"

Expand Down
21 changes: 19 additions & 2 deletions crates/mqtt5/src/client/inner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,8 +282,25 @@ impl MqttClient {
let mut config = WebSocketConfig::new(url)
.map_err(|e| MqttError::ConnectionError(format!("Invalid WebSocket URL: {e}")))?;

if insecure {
let tls_config = TlsConfig::new(addr, host).with_verify_server_cert(false);
// As in connect_tls: a stored TLS config applies to wss:// too. With
// none, an insecure connection still needs one to disable
// verification; a secure one keeps the WebSocket transport's default.
// The stored ALPN protocols are left out: they name the protocol for
// MQTT directly over TLS (mqtts://, e.g. AWS IoT's x-amzn-mqtt-ca),
// while a WebSocket server negotiates HTTP.
let stored = self.tls_config.read().await.clone();
let tls_config = match stored {
Some(mut cfg) => {
cfg.addr = addr;
cfg.hostname = host.to_string();
cfg.verify_server_cert = !insecure;
cfg.alpn_protocols = None;
Some(cfg)
}
None if insecure => Some(TlsConfig::new(addr, host).with_verify_server_cert(false)),
None => None,
};
if let Some(tls_config) = tls_config {
config = config.with_tls_config(tls_config);
}

Expand Down
176 changes: 124 additions & 52 deletions crates/mqtt5/src/transport/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ pub type TlsWriteHalf = WriteHalf<TlsStream<TcpStream>>;
#[derive(Debug)]
pub struct TlsConfig {
/// Server address
///
/// [`TlsTransport`] dials it. A wss:// connection does not use it: the
/// WebSocket URL selects the server, so a config made by
/// [`WebSocketConfig::with_tls_auto`](crate::transport::websocket::WebSocketConfig::with_tls_auto)
/// for a host name URL holds the unspecified address `0.0.0.0` with the
/// URL's port, and must be given a real address before use with
/// [`TlsTransport`].
pub addr: SocketAddr,
/// Server hostname for SNI
pub hostname: String,
Expand All @@ -47,10 +54,7 @@ impl Clone for TlsConfig {
hostname: self.hostname.clone(),
connect_timeout: self.connect_timeout,
client_cert: self.client_cert.clone(),
client_key: self.client_key.as_ref().map(|key| {
PrivateKeyDer::try_from(key.secret_der().to_vec())
.expect("Failed to clone private key")
}),
client_key: self.client_key.as_ref().map(PrivateKeyDer::clone_key),
root_certs: self.root_certs.clone(),
use_system_roots: self.use_system_roots,
verify_server_cert: self.verify_server_cert,
Expand Down Expand Up @@ -307,6 +311,120 @@ impl TlsConfig {
self.root_certs = Some(vec![ca_cert]);
Ok(())
}

/// Builds the rustls client configuration this TLS configuration describes
///
/// The `addr` and `hostname` fields are not part of it: they select the
/// server to dial and the name to verify, which the caller supplies when
/// connecting.
///
/// With `use_system_roots`, the system roots are the bundled
/// `webpki-roots`, as [`TlsTransport`] uses them.
///
/// # Errors
///
/// Returns an error if a root certificate cannot be added or the client
/// certificate and key cannot be used for client authentication
pub fn client_config(&self) -> Result<ClientConfig> {
self.client_config_with(SystemRoots::Bundled)
}

/// Builds the rustls client configuration, taking the system roots that
/// `use_system_roots` adds from `system_roots`
///
/// Roots are only loaded when the server certificate is verified.
pub(crate) fn client_config_with(&self, system_roots: SystemRoots) -> Result<ClientConfig> {
let config_builder = if self.verify_server_cert {
let mut root_store = RootCertStore::empty();

// Add custom root certificates
if let Some(ref root_certs) = self.root_certs {
for cert in root_certs {
root_store.add(cert.clone()).map_err(|e| {
MqttError::ProtocolError(format!("Failed to add root cert: {e}"))
})?;
}
}

// Add system roots if requested
if self.use_system_roots {
system_roots.add_to(&mut root_store)?;
}

ClientConfig::builder().with_root_certificates(root_store)
} else {
// Disable certificate verification for testing with self-signed certs
ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerification))
};

let mut config = if let (Some(cert), Some(key)) = (
self.client_cert.clone(),
self.client_key.as_ref().map(PrivateKeyDer::clone_key),
) {
config_builder
.with_client_auth_cert(cert, key)
.map_err(|e| {
MqttError::ProtocolError(format!("Failed to configure client auth: {e}"))
})?
} else {
config_builder.with_no_client_auth()
};

// Configure ALPN protocols if provided
if let Some(ref protocols) = self.alpn_protocols {
config.alpn_protocols.clone_from(protocols);
}

Ok(config)
}
}

/// Where the roots added by [`TlsConfig::use_system_roots`] come from
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SystemRoots {
/// The bundled `webpki-roots`
Bundled,
/// The platform's certificate store, loaded with `rustls-native-certs`
#[cfg(feature = "transport-websocket")]
Native,
}

impl SystemRoots {
// Only loading native roots can fail.
#[cfg_attr(not(feature = "transport-websocket"), allow(clippy::unnecessary_wraps))]
fn add_to(self, root_store: &mut RootCertStore) -> Result<()> {
match self {
Self::Bundled => {
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
Ok(())
}
#[cfg(feature = "transport-websocket")]
Self::Native => add_native_roots(root_store),
}
}
}

/// Adds the platform's root certificates, as tokio-tungstenite does for a
/// wss:// connection without a TLS configuration: load errors are logged, and
/// only finding no usable certificate at all is an error, unless custom roots
/// were already added.
#[cfg(feature = "transport-websocket")]
fn add_native_roots(root_store: &mut RootCertStore) -> Result<()> {
let loaded = rustls_native_certs::load_native_certs();
if !loaded.errors.is_empty() {
tracing::warn!(errors = ?loaded.errors, "errors loading native root certificates");
}
let (added, ignored) = root_store.add_parsable_certificates(loaded.certs);
debug!(added, ignored, "loaded native root certificates");
if root_store.is_empty() {
return Err(MqttError::ConnectionError(format!(
"no native root certificates found (errors: {:?})",
loaded.errors
)));
}
Ok(())
}

/// TLS transport implementation
Expand Down Expand Up @@ -349,54 +467,8 @@ impl TlsTransport {
/// # Errors
///
/// Returns an error if the operation fails
fn build_tls_config(&mut self) -> Result<ClientConfig> {
let mut root_store = RootCertStore::empty();

// Add system roots if requested
if self.config.use_system_roots {
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.to_vec());
}

// Add custom root certificates
if let Some(ref root_certs) = self.config.root_certs {
for cert in root_certs {
root_store.add(cert.clone()).map_err(|e| {
MqttError::ProtocolError(format!("Failed to add root cert: {e}"))
})?;
}
}

let config_builder = if self.config.verify_server_cert {
ClientConfig::builder().with_root_certificates(root_store)
} else {
// Disable certificate verification for testing with self-signed certs
ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(Arc::new(NoVerification))
};

let mut config = if let (Some(cert), Some(key)) = (
self.config.client_cert.clone(),
self.config.client_key.as_ref().map(|k| {
PrivateKeyDer::try_from(k.secret_der().to_vec())
.expect("Failed to clone private key")
}),
) {
config_builder
.with_client_auth_cert(cert, key)
.map_err(|e| {
MqttError::ProtocolError(format!("Failed to configure client auth: {e}"))
})?
} else {
config_builder.with_no_client_auth()
};

// Configure ALPN protocols if provided
if let Some(ref protocols) = self.config.alpn_protocols {
config.alpn_protocols.clone_from(protocols);
}

Ok(config)
fn build_tls_config(&self) -> Result<ClientConfig> {
self.config.client_config()
}
}

Expand Down
Loading
Loading