Skip to content

add WebSocketConfig::with_request_modifier (#165) - #211

Merged
fabracht merged 16 commits into
LabOverWire:mainfrom
bryan-ashby:websocket-request-modifier
Oct 11, 2026
Merged

fabracht merged 16 commits into
LabOverWire:mainfrom
bryan-ashby:websocket-request-modifier

Conversation

@bryan-ashby

Copy link
Copy Markdown
Contributor

Part of #165: the request modifier, the second half of what we agreed there (the static config is #210).

Builds on #210, which builds on #189 and #191. Until those merge, the diff includes their commits; the change here is the last commit.

Changes

  • WebSocketConfig::with_request_modifier takes an async callback:
    F: Fn(Request<()>) -> Fut + Send + Sync + 'static
    Fut: Future<Output = Result<Request<()>, E>> + Send + 'static
    E: Into<Box<dyn Error + Send + Sync>>
    The callback receives the validated request from build_handshake_request and returns the request to send. Use cases: a short-lived signed token (e.g. an AWS IoT custom-authorizer signature), or a presigned URI.
  • It runs on every attempt. Set on the WebSocketConfig given to ConnectOptions::with_websocket_config, it runs before every MqttClient connection attempt, including automatic reconnects, because the client reads that config on each attempt (add ConnectOptions::with_websocket_config for MqttClient (#165) #210). So the values never go stale.
  • It's bounded and fails cleanly. The callback runs inside the existing connect timeout. An error fails the attempt with MqttError::ConnectionError("WebSocket request modifier failed: …") before anything is dialed, so a reconnecting client retries as it would after any connect failure.
  • What it returns is sent as is. The with_header checks don't cover headers the callback adds, and tungstenite still requires its handshake headers exactly once. The connection goes to the returned request's URI; the docs note that a callback pointing it at another host must also update Host.
  • Types: the callback is stored as a public, opaque RequestModifier (Clone, with a Debug that prints RequestModifier(..)). WebSocketConfig gains a request_modifier field, and RequestModifierError is the boxed error type.
  • The handshake future is boxed, so the connect futures that await it stay under clippy's large_futures limit.

Tests

tests/websocket_request_modifier.rs, against a loopback server:

Test Checks
modifier_adds_headers_computed_at_connect_time a header the callback computes arrives; the callback runs once; it sees the configured headers
modifier_can_replace_the_uri a presigned-style query added by the callback reaches the server
modifier_error_fails_connect_before_dialing ConnectionError with the callback's message; the server sees no connection
modifier_runs_within_the_connect_timeout a callback that never finishes ends in MqttError::Timeout

In tests/client_websocket_config.rs, request_modifier_runs_again_on_reconnect uses #210's server, which closes each connection after CONNACK. It checks that the first connect carries x-token: token-1 and the automatic reconnect carries token-2.

All five fail if connect ignores the modifier. The two #210 tests in that file still pass, as expected.

Locally, on the pinned 1.98.0 toolchain, fmt, clippy (workspace, wasm, and the lib with no features or a single feature), cargo doc -D warnings and the doctests pass. The full suite passes apart from the same machine-only failures noted on #189 and #191. Fork CI: bryan-ashby#4.

🤖 Generated with Claude Code

bryan-ashby and others added 16 commits October 2, 2026 10:01
…ade request (LabOverWire#165)

WebSocketTransport::connect built the upgrade request from a fixed set of
headers and always offered Sec-WebSocket-Protocol: mqtt, so with_header,
with_subprotocol(s) and with_user_agent never reached the server.

Request construction moves to WebSocketConfig::build_handshake_request,
which starts from tungstenite's into_client_request (fixing a Host header
that dropped non-default ports) and adds the configured subprotocols, user
agent and custom headers. Invalid, reserved or case-duplicated headers and
non-token subprotocols are rejected before dialing. WebSocketConfig's Debug
no longer prints header values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…LabOverWire#165)

Content-Length, Transfer-Encoding and Sec-WebSocket-Accept are now
rejected as custom handshake headers alongside the existing reserved
names. Custom headers are validated and sent in name order, so the
request and the reported error do not depend on HashMap iteration. The
integration test checks Host against the exact bound address, and the
CHANGELOG calls out the changes that can affect existing configurations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…erWire#190)

With server-certificate verification on, WebSocketTransport::connect used
tokio-tungstenite's own client config built from the platform roots, so a
configured CA, client certificate, use_system_roots and ALPN protocols
were ignored. With verification off, the client certificate and ALPN were
dropped.

The rustls client config construction moves from TlsTransport to a public
TlsConfig::client_config, and a wss:// connection with a tls_config now
passes that config to tokio-tungstenite in both cases. Without a
tls_config, the platform roots are still used. The WSS module's duplicate
NoVerifier is replaced by the shared NoVerification.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…S config over wss (LabOverWire#190)

with_tls_auto parsed the URL's host and port as a socket address, so it
and the with_ca_cert_* and with_client_auth_* methods that call it failed
for any host name. A wss:// connection takes its server and certificate
name from the URL, so a host name now gets the unspecified address with
the URL's port.

MqttClient's wss:// path built its WebSocketConfig from the URL alone and
ignored the TLS configuration stored by set_tls_config; it now applies it
as connect_tls does. New tests cover a host name URL, the URL host being
verified rather than TlsConfig::hostname, and MqttClient presenting its
stored client certificate, and two rejection tests now assert
UnknownIssuer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…headers (LabOverWire#165)

Review follow-up. The configured subprotocols are now followed by "mqtt"
when the list does not include it, as MQTT-6.0.0-3 requires: the broker
in this crate only selects a subprotocol the client offers, so a config
preferring mqttv5.0 failed with "Server sent no subprotocol". A
subprotocol listed twice is rejected (RFC 6455 §4.1), and TE, Trailer,
Keep-Alive, Proxy-Connection and Expect join the reserved headers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LabOverWire#190)

Review follow-up. With a tls_config and use_system_roots, wss:// now
loads the platform's root certificates through rustls-native-certs, as
tokio-tungstenite already did without a tls_config, instead of the
bundled webpki-roots, so a CA installed on the machine keeps working for
an ALPN-only config or MqttClient::set_tls_config(cert, key, None).
TlsTransport keeps webpki-roots; roots are only loaded when the server
certificate is verified.

MqttClient's wss:// path no longer offers the stored config's ALPN
protocols, which select MQTT directly over TLS; a WebSocketConfig's own
ALPN is still offered, and the CHANGELOG notes that a server advertising
only http/1.1 rejects an MQTT one. TlsConfig::addr and with_tls_auto
document the 0.0.0.0 placeholder, and the private key is copied with
clone_key() rather than an expect. The wss test server moves to a shared
module for the new SSL_CERT_FILE test binary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#165)

MqttClient built each connection's WebSocketConfig from the URL alone, so
its ws:// and wss:// connections could not send custom handshake headers,
subprotocols or a user agent. ConnectOptions::with_websocket_config stores
a WebSocketConfig that the client reads on every connection attempt,
including automatic reconnects, with the URL replaced by the address
passed to connect. For wss://, its TLS configuration, when set, is used in
place of the stored one; insecure_tls still disables verification.

WebSocketConfig now implements Clone, and is held behind an Arc so the
connect futures stay small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An async callback that receives the validated upgrade request and returns
the one to send, so values computed at connect time, such as a
short-lived signed token or a presigned URI, can be added to each
connection attempt. Through ConnectOptions::with_websocket_config it runs
before every MqttClient attempt, including automatic reconnects. It runs
within the connect timeout, and an error fails the attempt with
ConnectionError before dialing. The handshake future is boxed so the
connect futures that await it stay small.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fabracht
fabracht merged commit 619153d into LabOverWire:main Oct 11, 2026
11 checks passed
@fabracht

Copy link
Copy Markdown
Contributor

Thanks Bryan, this is merged now. The modifier is a good fit for the presigned URL and short-lived token cases.

We made a few changes before merging:

  • The request the modifier returns is checked before dialing. It has to keep the URI scheme, exactly one of each handshake header the transport sets, none of the other reserved headers, and a Sec-WebSocket-Protocol that still offers mqtt. Otherwise the attempt fails with MqttError::Configuration. Before, a modifier could add Content-Length, send a second Host, drop mqtt, or change the URI scheme, and the transport dialed anyway.
  • MqttClient no longer wraps WebSocket errors, so a modifier that hangs comes back as MqttError::Timeout, and a modifier error is reported once instead of nested inside "WebSocket connect failed".
  • A modifier failure is logged at error level, so a token service failing on reconnects shows up at the default log level.
  • MqttClient doesn't resolve the host of a ws:// or wss:// address anymore, since the handshake dials the request's host anyway. That means the address passed to connect can be a placeholder that the modifier replaces. Together with open one websocket connection per connection attempt #217, the modifier now runs once per attempt instead of once per resolved address.
  • RequestModifier and RequestModifierError are exported from mqtt5::transport, and the CHANGELOG lists the new request_modifier field under Breaking.

There are tests for each of these. It'll go out in the next release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants