serve.rs raw

//! Listening: plain HTTP for development or behind a proxy, or HTTPS with
//! a Let's Encrypt certificate that the server obtains and renews itself,
//! with the plain listener reduced to redirecting.

use std::{sync::Arc, time::Duration};

use anyhow::{Context, Error};
use poem::{
    Endpoint, EndpointExt, IntoResponse, Request, Response, Server,
    listener::{
        Acceptor, Listener, TcpListener,
        acme::{AutoCert, ChallengeType, LETS_ENCRYPT_PRODUCTION, LETS_ENCRYPT_STAGING},
    },
    web::Redirect,
};

use crate::{App, Config};

const SHUTDOWN_GRACE: Duration = Duration::from_secs(10);

/// Applies the connection settings every mugit listener shares. Chiefly
/// it drops connections that fall silent, so clients which open one and
/// then abandon it cannot accumulate until the process runs out of file
/// descriptors; poem imposes no such limit of its own. Both the listeners
/// below and the tests build their servers through here, so that what the
/// tests exercise is configured the way the deployed server is.
pub fn configure<L, A>(server: Server<L, A>, config: &Config) -> Server<L, A>
where
    L: Listener,
    L::Acceptor: 'static,
    A: Acceptor + 'static,
{
    server.idle_timeout(Duration::from_secs(config.http_idle_timeout))
}

/// Serves the application until SIGINT or SIGTERM.
pub async fn run(app: Arc<App>) -> Result<(), Error> {
    let routes = app.routes();
    let config = &app.config;

    let Some(domain) = config.tls_domain.clone() else {
        tracing::info!(listen = %config.listen, "serving HTTP");
        return configure(Server::new(TcpListener::bind(&config.listen)), config)
            .run_with_graceful_shutdown(routes, shutdown_signal(), Some(SHUTDOWN_GRACE))
            .await
            .context("HTTP server");
    };

    // The dependency graph enables more than one rustls crypto backend, so
    // rustls cannot pick one on its own; a second install attempt (in
    // tests, say) is harmless and reports the provider already present.
    let _ = rustls::crypto::ring::default_provider().install_default();

    let mut cert = AutoCert::builder()
        .directory_url(if config.acme_staging {
            LETS_ENCRYPT_STAGING
        } else {
            LETS_ENCRYPT_PRODUCTION
        })
        .domain(&domain)
        .challenge_type(ChallengeType::TlsAlpn01)
        .cache_path(&config.acme_cache);
    if let Some(contact) = &config.acme_contact {
        cert = cert.contact(contact);
    }
    let cert = cert.build().context("configuring ACME")?;

    tracing::info!(listen = %config.tls_listen, %domain, "serving HTTPS");
    tracing::info!(listen = %config.listen, "redirecting HTTP to HTTPS");

    let https = configure(
        Server::new(TcpListener::bind(&config.tls_listen).acme(cert)),
        config,
    )
    .run_with_graceful_shutdown(routes, shutdown_signal(), Some(SHUTDOWN_GRACE));
    let http = configure(Server::new(TcpListener::bind(&config.listen)), config)
        .run_with_graceful_shutdown(redirect_to(domain), shutdown_signal(), Some(SHUTDOWN_GRACE));

    tokio::try_join!(async { https.await.context("HTTPS server") }, async {
        http.await.context("HTTP redirect server")
    },)?;
    Ok(())
}

// An endpoint that sends every request to the same path on the HTTPS site.
fn redirect_to(domain: String) -> impl Endpoint {
    poem::endpoint::make(move |req: Request| {
        let domain = domain.clone();
        async move {
            let path = req
                .uri()
                .path_and_query()
                .map(|pq| pq.as_str())
                .unwrap_or("/");
            Ok::<Response, poem::Error>(
                Redirect::permanent(format!("https://{domain}{path}")).into_response(),
            )
        }
    })
    .boxed()
}

async fn shutdown_signal() {
    use tokio::signal::unix::{SignalKind, signal};
    let mut sigterm = match signal(SignalKind::terminate()) {
        Ok(sigterm) => sigterm,
        Err(err) => {
            tracing::warn!(error = %err, "cannot listen for SIGTERM; only SIGINT will stop the server");
            let _ = tokio::signal::ctrl_c().await;
            return;
        }
    };
    tokio::select! {
        _ = tokio::signal::ctrl_c() => tracing::info!("SIGINT received, shutting down"),
        _ = sigterm.recv() => tracing::info!("SIGTERM received, shutting down"),
    }
}