Skip to content

use WebSocketConfig's TLS configuration for wss:// connections (#190) - #191

Merged
fabracht merged 11 commits into
LabOverWire:mainfrom
bryan-ashby:fix-wss-tls-config
Oct 9, 2026
Merged

fabracht merged 11 commits into
LabOverWire:mainfrom
bryan-ashby:fix-wss-tls-config

Conversation

@bryan-ashby

@bryan-ashby bryan-ashby commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #190. Builds on #189; until that merges, this diff includes #189's commits (merged in, not rebased). The changes here are this PR's three non-merge commits.

wss:// connections ignored WebSocketConfig::tls_config. With verification on, connect used tokio-tungstenite's own rustls config built from the platform roots; with verification off, it built a bare config with no client certificate or ALPN. This PR uses the configured TLS settings in both cases, keeping the platform roots for use_system_roots.

Changes

  • TlsConfig::client_config() (new, public) builds the rustls ClientConfig a TlsConfig describes, with the bundled webpki-roots for use_system_roots. It is the body of TlsTransport::build_tls_config, moved; TlsTransport now calls it. The private key is copied with clone_key() (here and in TlsConfig's Clone), so there's no expect, and roots are only loaded when the server certificate is verified.
  • Platform roots for WSS. WSS calls the crate-private client_config_with(SystemRoots::Native), which takes use_system_roots from the platform store via rustls-native-certs, loaded the way tokio-tungstenite loads it without a tls_config: load errors are logged, and only finding no certificate at all, with no custom roots, is an error. rustls-native-certs was already in the build through tokio-tungstenite's rustls-tls-native-roots; it's now a direct optional dependency of transport-websocket. TCP+TLS is unchanged.
  • WebSocketTransport::connect now has one path. For wss:// with a tls_config, it passes a Connector::Rustls built from it to connect_async_tls_with_config. Without a tls_config, it passes no connector, so tokio-tungstenite keeps verifying against the platform's native roots exactly as before.
  • The WSS module's duplicate NoVerifier is removed. Verification-off goes through crate::crypto::NoVerification, like TlsTransport.
  • with_tls_auto accepts a host name. It parsed "{host}:{port}" as a SocketAddr, so it failed for any non-IP URL, and so did the with_ca_cert_* and with_client_auth_* methods that call it. Since WSS no longer uses TlsConfig::addr, a host name now gets the unspecified address 0.0.0.0 with the URL's port, the same placeholder MqttClient::set_tls_config already uses. TlsConfig::addr and with_tls_auto document that such a config needs a real address before use with TlsTransport, which would otherwise dial the local host.
  • MqttClient uses its stored TLS config for wss://. connect_websocket_secure ignored the config from set_tls_config, and with set_insecure_tls(true) it replaced it with a bare verification-off config. It now applies the stored config as connect_tls does: same address and hostname override, verification from insecure_tls. Its ALPN protocols are left out: they select MQTT directly over TLS (e.g. x-amzn-mqtt-ca after connect_with_tls), and a WebSocket server negotiates HTTP; this crate's own wss:// listener advertises only http/1.1 and would reject them with NoApplicationProtocol.
  • with_tls_config documents which fields WSS uses:
    • used: roots, use_system_roots (platform roots), verification, client certificate, ALPN. An explicitly configured MQTT ALPN is offered as given, so the docs and CHANGELOG note the NoApplicationProtocol a server advertising only http/1.1 returns;
    • not used: addr, hostname and connect_timeout, which come from the URL and with_timeout.

Tests

tests/websocket_tls_integration.rs runs a loopback WSS server (tokio-rustls plus accept_hdr_async) with the certificates from scripts/generate_test_certs.sh, and checks what the server observed:

Test Before this PR After
custom CA from with_ca_cert_from_file is trusted UnknownIssuer connects
client certificate from with_client_auth_from_files is presented to a server requiring one UnknownIssuer connects, server saw the cert
client certificate is presented with verification disabled server: CertificateRequired connects, server saw the cert
configured ALPN is offered (server negotiates mqtt-test) UnknownIssuer negotiated mqtt-test
certificate from a different CA is rejected rejected rejected
with no tls_config, a private-CA server is not trusted rejected rejected
with_ca_cert_from_file on a wss://localhost:… URL connects config error: Invalid host/port combination connects
the certificate is checked against the URL host, not TlsConfig::hostname (set to wrong.example) UnknownIssuer connects
MqttClient with set_tls_config presents its client certificate over wss:// server never completes the handshake server saw the cert
MqttClient doesn't offer the stored config's ALPN (mqtt, from connect_with_tls) to a server advertising only http/1.1 — no ALPN offered, handshake completes

tests/websocket_tls_native_roots.rs is its own binary because it sets SSL_CERT_FILE, which rustls-native-certs loads instead of the platform store, to stand in for a platform-installed CA:

Test With webpki-roots for WSS After
ALPN-only TlsConfig (no CA) trusts the platform CA UnknownIssuer connects
MqttClient::set_tls_config(cert, key, None) to a server requiring a client certificate handshake fails server saw the cert
with_system_roots(false) doesn't trust the platform CA UnknownIssuer UnknownIssuer

The wss test server moved to tests/wss_test_server/mod.rs so both binaries share it.

The rejection tests are guards: they pass before and after and assert UnknownIssuer exactly. Each other test fails as shown without the fix.

Locally, on the pinned 1.98.0 toolchain:

  • clippy is clean, both workspace and wasm, and cargo doc -D warnings passes;
  • the existing TLS tests pass, including wss_alpn_integration and the transport:: unit tests;
  • clippy also passes for the lib with no features and with only tokio, transport-websocket or broker;
  • the full mqtt5 suite passes, apart from tests that fail on this machine only, identically on main: the first TestBroker in some binaries (another service holds 127.0.0.1:20000) and the external-broker tests (a local broker holds 127.0.0.1:1883).

Upstream's Rust CI and Conformance Suite run on the fork: bryan-ashby#2.

🤖 Generated with Claude Code

bryan-ashby and others added 4 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>

@fabracht fabracht left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The root store change is the one thing I'd change. With a tls_config set and use_system_roots true, wss now uses webpki-roots instead of the platform store, so anything signed by a CA installed on the machine stops working. A TlsConfig that only sets ALPN, or MqttClient::set_tls_config(cert, key, None), connects to such a server on main and on #189, and gets UnknownIssuer here. The CLI's --cert/--key without --cafile goes through the same path.

rustls-native-certs is already compiled in through tokio-tungstenite's rustls-tls-native-roots, so using it directly doesn't add anything to the build. Please load the native roots for wss when use_system_roots is set. TCP+TLS can stay as it is for now.

Minor:

  • Our own wss listener only advertises http/1.1, so a stored config with MQTT ALPN (e.g. after connect_with_tls) now fails over wss:// with NoApplicationProtocol. Please mention it in the CHANGELOG.
  • For a host-name URL, with_tls_auto puts 0.0.0.0:port in TlsConfig::addr. If someone takes that config and uses it with TlsTransport, it connects to localhost instead of failing. A doc note on addr is enough.
  • client_config is public now and still has the expect on the key clone. PrivateKeyDer::clone_key() gets rid of it.

These came from throwaway tests on our side, so please double check before changing anything:

  • Root store: SSL_CERT_FILE stands in for a platform-installed CA, since rustls-native-certs loads that file instead of the OS store. Run your wss_server from websocket_tls_integration.rs with SSL_CERT_FILE=test_certs/ca.pem and connect with only TlsConfig::new(addr, "localhost").with_alpn_protocols(&["mqtt-test"]). That connects on main and #189 and fails with UnknownIssuer here. With no tls_config it connects on all three, which shows the variable is being picked up.
  • ALPN: start an MqttBroker with with_websocket_tls and connect with with_verify_server_cert(false) plus with_alpn_protocols(&["mqtt"]). Main and #189 connect, this branch fails with NoApplicationProtocol.
  • addr: WebSocketConfig::new("wss://broker.does-not-exist.invalid:8883")?.with_tls_auto()?, then take_tls_config() has addr set to 0.0.0.0:8883.

bryan-ashby and others added 4 commits October 8, 2026 16:11
…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>
@bryan-ashby

Copy link
Copy Markdown
Contributor Author

Thanks. I reproduced each point first: the SSL_CERT_FILE root-store case, the NoApplicationProtocol case against with_websocket_tls, and the 0.0.0.0:8883 address all matched. Changes are in 4fbeb27, after merging the updated #189:

  • Platform roots for wss. With a tls_config and use_system_roots, wss now loads the platform roots through rustls-native-certs, the same way tokio-tungstenite does without a tls_config. TCP+TLS keeps webpki-roots. The public TlsConfig::client_config() still means the TCP+TLS config; wss uses a crate-private variant. A new test binary, websocket_tls_native_roots.rs, uses SSL_CERT_FILE as you suggested, and it's separate because the variable is process-wide. It covers an ALPN-only TlsConfig, set_tls_config(cert, key, None), and with_system_roots(false) as a guard. The webpki-roots "Changed" entry is gone.
  • ALPN. I went one step further than the CHANGELOG note: MqttClient's wss path no longer offers the stored config's ALPN. That config is shared with mqtts://, where the ALPN selects MQTT over TLS (for example x-amzn-mqtt-ca after connect_with_tls), so it shouldn't be offered to a WebSocket server. An ALPN set on a WebSocketConfig's own TLS config is still offered as given, and the docs and CHANGELOG note the NoApplicationProtocol you hit. If you'd rather keep the stored ALPN on wss and only document it, I can revert that part.
  • addr. TlsConfig::addr and with_tls_auto now document the 0.0.0.0 placeholder.
  • expect. Both key copies (client_config and TlsConfig's Clone) use clone_key().

Fork CI: bryan-ashby#2.

@fabracht fabracht left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Bryan. Keep the ALPN change, dropping the stored ALPN on wss is the right call. I tried it against our own wss listener with connect_with_tls storing mqtt as the ALPN: it connects on this branch and fails with NoApplicationProtocol if I put the stored ALPN back.

The platform roots work too. With SSL_CERT_FILE pointing at the test CA, your native roots tests pass, and switching wss back to SystemRoots::Bundled makes the two that rely on the platform CA fail while the with_system_roots(false) one still passes.

#189 is in, so I merged main into your branch to sort out the conflicts it left in websocket.rs and the CHANGELOG. Nothing changed in your code, and your entries stay under [Unreleased]. Nothing left for you to do here, merging now.

@fabracht
fabracht merged commit bcdb834 into LabOverWire:main Oct 9, 2026
11 checks passed
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.

WSS transport ignores WebSocketConfig::tls_config (custom CA, client certificate, ALPN)

2 participants