Controlling Client SNI with Hyper

20200927
~05 mins
tl;dr: There is at least one way to take control of client SNI in Rust.

I recently revisited Rust after a few years hiatus and in one project I found myself needing to provide a different Server Name Indicator (SNI) when initiating a TLS connection to a remote host.

In Go this is as simple as setting the ServerName field on the standard library’s TLS configuration struct.

(&http.Client{
    Transport: &http.Transport{
        TLSClientConfig: &tls.Config{
            ServerName: "somewhere.com",
        },
    },
}).Get("https://somewhere-else.com")

And is also what you can achieve with the openssl and curl CLIs for example.

; openssl s_client -connect somewhere-else.com:443 -servername somewhere.com
; curl --resolve somewhere.com:443:<somewhere-else.com IP> https://somewhere.com

However, to my surprise, getting the equivalent in Rust was quite awkward. My search for copypasta-able prior art failed to uncover anything usable and so I thought I would document at least one way of doing it, with Hyper, in case it helps a future weary traveler.

My project actually started out higher up the abstraction stack with Reqwest but at this level there’s little in the way of control provided over the underlying TLS settings. This forces dipping into the likes of Hyper.

But why?

In my experience passing a different SNI is a somewhat typical requirement for proxies doing virtual hosting or gateways doing domain fronting.

In my particular case, I needed to send an HTTPS request to an AWS PrivateLink address but present a different SNI such that the application layer load balancer on the other side of the link knew which certificate to present and how to route the requests. I should note that I did not have the environmental permissions to create a private DNS zone to CNAME or Alias the true hostname to the PrivateLink one.

Hyper

You can achieve this feat with Hyper (and by extension Tokio) but I found I needed to switch from the default TLS implementation to the Rust’s native OpenSSL bindings so I could link against SSL_set_tlsext_host_name in the FFI of the build system’s OpenSSL install.

In the rust-openssl bindings library this corresponds to openssl::ssl:SslRef::set_hostname().

In addition to the bindings, you will also need to switch Hyper to use hyper-openssl crate.

In your Cargo.toml this looks something like:

[dependencies]
hyper = "0.13.8"
hyper-openssl = "0.8.0"
openssl = "0.10.30"

Then take control of the connector and set a callback as you construct your Hyper client:

let mut conn = HttpsConnector::new()?;
conn.set_callback(move |c, _| {
    // Prevent native TLS lib from inferring and verifying a default SNI.
    c.set_use_server_name_indication(false);
    c.set_verify_hostname(false);

    // And set a custom SNI instead.
    c.set_hostname("somewhere.com")
});
Client::builder()
    .build::<_, Body>(conn)
    .request(Request::get("somewhere-else.com").body(())?)
    .await?;

That’s it! If you capture the TLS ClientHello packet you can confirm the SNI has changed:

WireShark TLS ClientHello

Cross-platform builds

Using native OpenSSL is not without its pitfalls. YMMV with this but with my attempts at statically linking for each target platform, even when I could get the right incantations of the OPENSSL_STATIC, OPENSSL_LIB_DIR and OPENSSL_INCLUDE_DIR variables to produce a true static binary from a ldd perspective, I still found the bindings reaching for a system-provided OpenSSL at runtime and subsequently segfaulting on Linux/amd64.

I eventually gave up with glibc and opted to use the musl libc counterparts. However compiling musl versions of OpenSSL, zlib and friends is itself a rabbit hole I did not have time for. Fortunately someone did and I can highly recommend clux/muslrust container image for getting this task done.

I also had some issues with the binary being unable to find the CA certificates on the host system. Solving this was easy thanks to the handy openssl-probe crate.