TLS via Let's Encrypt + Rocket + Rust ๏ธ๏ธ

After building this entire dang website with axum, I had the sudden realization that I wasn't particularly fond of using it. Like any self-respecting developer, I decided to stay up "late" (like 9PM) and port everything to Rocket.


The migration was surprisingly smooth sailing() that is until I started setting up https. The axum server used rustls-acme and Let's Encrypt to automatically renew and verify my SSL certificates.


Table of Contents:

How Let's Encrypt Works ๏ธ๏ธ

Let's Encrypt is a non-profit certificate authority run by the Internet Security Research Group. They provide freeeeeee certificates to anyone who can prove they control a domain. This works by presenting the server with a challenge; if the server succeeds, it is given a key that can be used to create, renew, and revoke certificates for that domain.


The rustls-acme crate handles this process automatically using the TLS-ALPN-01 challenge, which occurs during the TLS handshake on the same port as your https traffic. You can set it up with axum like so:

use rustls_acme::{caches::DirCache, AcmeConfig};
use tokio_stream::StreamExt;

let acceptor = {
    let mut state = AcmeConfig::new(vec!["auxv.org"])
        .contact(vec!["mailto:5-pebble@protonmail.com"])
        .cache_option(Some(DirCache::new("lets_encrypt_cache")))
        .directory_lets_encrypt(true)
        .state();

    let tmp = state.axum_acceptor(state.default_rustls_config());
    tokio::spawn(async move {
        loop {
            match state.next().await.unwrap() {
                Ok(ok) => println!("Acme Event: {:?}", ok),
                Err(err) => println!("Acme Error: {:?}", err),
            }
        }
    });

    tmp
};

axum_server::bind(address)
    .acceptor(acceptor)
    .serve(app.into_make_service())
    .await?;

And that's it; you have https all setup.


but...


There is no support for Rocket nor has there been for the last 8 years. Thus, I found myself digging through the Rocket source code for the second time this week.

The Discoveries

I found three undocumented and unreleased traits in addition to two methods.

Important Note: As of January 2025 (Rocket 0.5.1), these features aren't part of the stable release. You'll need to import Rocket directly from the repository:

# Cargo.toml
[dependencies]
rocket = { git = "https://github.com/rwf2/Rocket.git", features = ["tls"] }

Traits:

pub trait Bind: Listener + 'static {
    type Error: Error + Send + 'static;

    #[crate::async_bound(Send)]
    async fn bind(rocket: &Rocket<Ignite>) -> Result<Self, Self::Error>;

    fn bind_endpoint(to: &Rocket<Ignite>) -> Result<Endpoint, Self::Error>;
}

pub trait Listener: Sized + Send + Sync {
    type Accept: Send;

    type Connection: Connection;

    #[crate::async_bound(Send)]
    async fn accept(&self) -> io::Result<Self::Accept>;

    #[crate::async_bound(Send)]
    async fn connect(&self, accept: Self::Accept) -> io::Result<Self::Connection>;

    fn endpoint(&self) -> io::Result<Endpoint>;
}

pub trait Connection: AsyncRead + AsyncWrite + Send + Unpin {
    fn endpoint(&self) -> io::Result<Endpoint>;

    /// DER-encoded X.509 certificate chain presented by the client, if any.
    ///
    /// The certificate order must be as it appears in the TLS protocol: the
    /// first certificate relates to the peer, the second certifies the first,
    /// the third certifies the second, and so on.
    ///
    /// Defaults to an empty vector to indicate that no certificates were
    /// presented.
    fn certificates(&self) -> Option<Certificates<'_>> { None }
}

Yes, Connection is half documented, but it's the half we aren't using.

Methods:

pub async fn launch_with<B: Bind>(self) -> Result<Rocket<Ignite>, Error> {
    let rocket = self.into_ignite().await?;
    let bind_endpoint = B::bind_endpoint(&rocket).ok();
    let listener: B = B::bind(&rocket).await
        .map_err(|e| ErrorKind::Bind(bind_endpoint, Box::new(e)))?;
    let any: Box<dyn Any + Send + Sync> = Box::new(listener);
    match any.downcast::<DefaultListener>() {
        Ok(listener) => {
            let listener = *listener;
            crate::util::for_both!(listener, listener => {
                crate::util::for_both!(listener, listener => {
                    rocket._launch(listener).await
                })
            })
        }
        Err(any) => {
            let listener = *any.downcast::<B>().unwrap();
            rocket._launch(listener).await
        }
    }
}

pub async fn launch_on<L>(self, listener: L) -> Result<Rocket<Ignite>, Error>
    where L: Listener + 'static,
{
    self.into_ignite().await?._launch(listener).await
}

Here's how (I think) these traits operate:


With that, we can write a simple LetsEncryptListener to automate certificate creation and renewal.

Copy and Past(e | a)

I will update this code as needed and plan to submit a PR to rustls-acme once these features stabilize in Rocket:

use std::{
    fmt::Debug,
    io::{Error, Result},
    net::SocketAddr,
    pin::Pin,
    task::{Context, Poll},
};

use rocket::listener::{Connection, Endpoint, Listener};
use rustls_acme::{
    AcmeConfig,
    futures_rustls::server::TlsStream,
    tokio::{TokioIncoming, TokioIncomingTcpWrapper},
};
use tokio::{
    io::{AsyncRead, AsyncWrite, ReadBuf},
    net::{TcpListener, TcpStream},
    sync::Mutex,
};
use tokio_stream::{StreamExt, wrappers::TcpListenerStream};
use tokio_util::compat::Compat;

///  A Rocket-compatible HTTPS listener that handles Let's Encrypt certificate automation.
///
/// This listener wraps a TcpListener and manages automatic TLS certificate provisioning
/// and renewal through Let's Encrypt's ACME protocol. It implements Rocket's `Listener`
/// trait to provide HTTPS connections while simultaneously completing TLS-ALPN-01 challenges.
///
/// # Example
/// ```rust
/// let tcp_listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 443)).await?;
/// let acme_config = AcmeConfig::new(["example.com"])
///     .contact(["mailto:admin@example.com"])
///     .directory_lets_encrypt(true);
///
/// let https_listener = LetsEncryptListener::new(acme_config, tcp_listener).await;
/// rocket.launch_on(https_listener).await?;
/// ```
pub struct LetsEncryptListener<T: Debug + 'static>(
    Mutex<
        TokioIncoming<
            Compat<TcpStream>,
            Error,
            TokioIncomingTcpWrapper<TcpStream, Error, TcpListenerStream>,
            T,
            T,
        >,
    >,
    SocketAddr,
);

impl<T: Debug + 'static> LetsEncryptListener<T> {
    /// Makes a new `LetEncryptListener` from the given ACME configuration and TCP listener.
    pub async fn new(acme_config: AcmeConfig<T, T>, tcp_listener: TcpListener) -> Self {
        let local_address = tcp_listener.local_addr().unwrap();
        let tcp_listener_stream = TcpListenerStream::new(tcp_listener);

        Self(
            Mutex::new(acme_config.tokio_incoming(tcp_listener_stream, Vec::new())),
            local_address,
        )
    }
}

impl<T: Debug + 'static> Listener for LetsEncryptListener<T> {
    type Accept = LetsEncryptConnection;

    type Connection = Self::Accept;

    async fn accept(&self) -> Result<Self::Accept> {
        self.0
            .lock()
            .await
            .next()
            .await
            .unwrap()
            .map(|tls_stream| LetsEncryptConnection(tls_stream, self.1))
    }

    async fn connect(&self, accept: Self::Accept) -> Result<Self::Connection> {
        Ok(accept)
    }

    fn endpoint(&self) -> Result<Endpoint> {
        Ok(Endpoint::Tcp(self.1))
    }
}

/// ๏ธ๏ธ A connection established through the Let's Encrypt listener.
pub struct LetsEncryptConnection(Compat<TlsStream<Compat<TcpStream>>>, SocketAddr);

impl AsyncWrite for LetsEncryptConnection {
    fn poll_write(self: Pin<&mut Self>, cx: &mut Context<'_>, buf: &[u8]) -> Poll<Result<usize>> {
        Pin::new(&mut self.get_mut().0).poll_write(cx, buf)
    }
    fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
        std::pin::Pin::new(&mut self.get_mut().0).poll_flush(cx)
    }
    fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
        Pin::new(&mut self.get_mut().0).poll_shutdown(cx)
    }
}

impl AsyncRead for LetsEncryptConnection {
    fn poll_read(
        self: Pin<&mut Self>,
        cx: &mut Context<'_>,
        buf: &mut ReadBuf<'_>,
    ) -> Poll<Result<()>> {
        Pin::new(&mut self.get_mut().0).poll_read(cx, buf)
    }
}

impl Connection for LetsEncryptConnection {
    fn endpoint(&self) -> Result<Endpoint> {
        Ok(Endpoint::Tcp(self.1))
    }
}

The End...