defter 0.4.2

Self-hosted Zotero sync and file storage server, with metadata in SQLite and attachments in S3.
//! Serve the router over plain HTTP or, with a certificate, over HTTPS.
//!
//! HTTPS lets defter sit behind something that passes TLS through rather than
//! terminating it — such as dragoman's `--sni-route`, which hands connections
//! for defter's hostname, still encrypted, to defter on a loopback port. The
//! certificate is then one the CDN in front trusts, e.g. a Cloudflare origin
//! certificate covering defter's hostname, or, for a hostname no CDN is in front
//! of, one from Let's Encrypt.

use std::future::Future;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use axum::Router;
use axum_server::Handle;
use axum_server::tls_rustls::RustlsConfig;
use futures::StreamExt;
use rustls::ServerConfig;
use rustls_acme::caches::DirCache;
use rustls_acme::{AcmeConfig, EventOk};

/// Where the HTTPS certificate comes from.
#[derive(Debug, Clone)]
pub enum Tls {
    /// A PEM certificate (chain) and its private key.
    Files { cert: PathBuf, key: PathBuf },
    /// Let's Encrypt, which is asked for one and for renewals through the
    /// TLS-ALPN-01 challenge. That needs only connections for `domain` on port
    /// 443 to reach this server, as they do through dragoman's `--sni-route`.
    Acme {
        domain: String,
        email: String,
        /// Keeps the account and certificates between restarts.
        cache: PathBuf,
        /// Let's Encrypt's staging environment, whose certificates browsers
        /// do not trust.
        staging: bool,
    },
}

/// How long open connections get to finish when shutting down.
const DRAIN: Duration = Duration::from_secs(30);

/// How long a certificate may take before saying so: an order takes well under
/// a minute when nothing is wrong.
const CERT_OVERDUE: Duration = Duration::from_secs(5 * 60);

/// How long to wait for a connection when checking that Let's Encrypt is
/// reachable.
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);

/// `config` offering HTTP/2 and HTTP/1.1, as a certificate from files does.
/// rustls-acme's default offers neither, which leaves clients on HTTP/1.1. Its
/// own challenge connections are answered apart from this.
fn with_http2(config: &ServerConfig) -> Arc<ServerConfig> {
    let mut config = config.clone();
    config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
    Arc::new(config)
}

/// Whether a TCP connection to `addr` opens in time.
async fn reachable(addr: SocketAddr, timeout: Duration) -> bool {
    matches!(
        tokio::time::timeout(timeout, tokio::net::TcpStream::connect(addr)).await,
        Ok(Ok(_))
    )
}

/// What is wrong with how Let's Encrypt can be reached, given its addresses in
/// the order the system returned them. The ACME client tries them in that order
/// and has no timeout of its own, so an unreachable address it tries first costs
/// every request the kernel's connect timeout, minutes, without any error.
fn reachability_problem(addrs: &[(SocketAddr, bool)]) -> Option<String> {
    let first = addrs.first()?;
    if first.1 {
        return None;
    }
    if addrs.iter().all(|(_, ok)| !ok) {
        return Some(
            "Let's Encrypt cannot be reached, so no certificate can be ordered; check this \
             server's outgoing connections on port 443"
                .to_string(),
        );
    }
    let family = if first.0.is_ipv6() { "IPv6" } else { "IPv4" };
    let remedy = if first.0.is_ipv6() {
        "fix IPv6 on this server, or prefer IPv4 with `precedence ::ffff:0:0/96 100` in /etc/gai.conf"
    } else {
        "fix IPv4 on this server"
    };
    Some(format!(
        "Let's Encrypt cannot be reached over {family}, which is tried first, so a certificate \
         order will take very long or stall without an error; {remedy}"
    ))
}

/// Check at startup that Let's Encrypt can be reached the way the ACME client
/// will try to, and log what is wrong if not.
async fn check_acme_reachable(staging: bool) {
    let host = if staging {
        "acme-staging-v02.api.letsencrypt.org"
    } else {
        "acme-v02.api.letsencrypt.org"
    };
    let addrs: Vec<SocketAddr> = match tokio::net::lookup_host((host, 443)).await {
        Ok(addrs) => addrs.collect(),
        Err(error) => {
            tracing::error!(host, %error, "cannot resolve Let's Encrypt, so no certificate can be ordered");
            return;
        }
    };
    let probes = addrs.iter().map(|addr| reachable(*addr, PROBE_TIMEOUT));
    let results = futures::future::join_all(probes).await;
    let addrs: Vec<(SocketAddr, bool)> = addrs.into_iter().zip(results).collect();
    if let Some(problem) = reachability_problem(&addrs) {
        tracing::warn!(host, "{problem}");
    }
}

/// Serve `app` on `addr` until `shutdown` completes. `ready` receives the
/// bound address (useful with port 0).
pub async fn serve(
    addr: SocketAddr,
    app: Router,
    tls: Option<Tls>,
    shutdown: impl Future<Output = ()> + Send + 'static,
    ready: impl FnOnce(SocketAddr) + Send + 'static,
) -> Result<(), String> {
    let service = app.into_make_service();
    let handle: Handle<SocketAddr> = Handle::new();
    let on_shutdown = handle.clone();
    tokio::spawn(async move {
        shutdown.await;
        on_shutdown.graceful_shutdown(Some(DRAIN));
    });
    let on_ready = handle.clone();
    tokio::spawn(async move {
        if let Some(addr) = on_ready.listening().await {
            ready(addr);
        }
    });

    match tls {
        None => axum_server::bind(addr)
            .handle(handle)
            .serve(service)
            .await
            .map_err(|e| format!("serving on {addr}: {e}")),
        Some(Tls::Acme {
            domain,
            email,
            cache,
            staging,
        }) => {
            std::fs::create_dir_all(&cache)
                .map_err(|e| format!("creating the certificate cache {}: {e}", cache.display()))?;
            let mut acme = AcmeConfig::new([domain.as_str()])
                .contact([format!("mailto:{email}").as_str()])
                .cache(DirCache::new(cache))
                .directory_lets_encrypt(!staging)
                .state();
            let acceptor = acme.axum_acceptor(with_http2(&acme.default_rustls_config()));
            tokio::spawn(check_acme_reachable(staging));
            // Orders and renews for as long as the server runs.
            tokio::spawn(async move {
                let overdue = tokio::time::sleep(CERT_OVERDUE);
                tokio::pin!(overdue);
                let mut waiting = true;
                loop {
                    tokio::select! {
                        event = acme.next() => match event {
                            Some(Ok(ok)) => {
                                if matches!(ok, EventOk::DeployedNewCert | EventOk::DeployedCachedCert) {
                                    waiting = false;
                                }
                                tracing::info!(event = ?ok, "ACME");
                            }
                            Some(Err(error)) => tracing::error!(%error, "ACME"),
                            None => break,
                        },
                        () = &mut overdue, if waiting => {
                            waiting = false;
                            tracing::warn!(
                                %domain,
                                minutes = CERT_OVERDUE.as_secs() / 60,
                                "no certificate from Let's Encrypt yet, and HTTPS fails until there is \
                                 one; check that this hostname's DNS and port 443 reach this server and \
                                 that this server can reach Let's Encrypt, IPv6 included"
                            );
                        }
                    }
                }
            });
            axum_server::bind(addr)
                .acceptor(acceptor)
                .handle(handle)
                .serve(service)
                .await
                .map_err(|e| format!("serving HTTPS on {addr}: {e}"))
        }
        Some(Tls::Files { cert, key }) => {
            let config = RustlsConfig::from_pem_file(&cert, &key)
                .await
                .map_err(|e| {
                    format!(
                        "loading the certificate {} and key {}: {e}",
                        cert.display(),
                        key.display()
                    )
                })?;
            axum_server::bind_rustls(addr, config)
                .handle(handle)
                .serve(service)
                .await
                .map_err(|e| format!("serving HTTPS on {addr}: {e}"))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use axum::routing::get;

    #[test]
    fn lets_encrypt_certificates_offer_http2() {
        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
        let dir = tempfile::tempdir().unwrap();
        // Nothing reaches the network until the state is polled.
        let acme = AcmeConfig::new(["defter.example.org"])
            .contact(["mailto:me@example.org"])
            .cache(DirCache::new(dir.path().to_path_buf()))
            .state();
        let config = with_http2(&acme.default_rustls_config());
        assert_eq!(config.alpn_protocols[0], b"h2");
        assert_eq!(config.alpn_protocols[1], b"http/1.1");
    }

    #[test]
    fn says_what_is_wrong_with_reaching_lets_encrypt() {
        let v4: SocketAddr = "192.0.2.1:443".parse().unwrap();
        let v6: SocketAddr = "[2001:db8::1]:443".parse().unwrap();
        // Fine as long as the address tried first answers, whatever comes after.
        assert_eq!(reachability_problem(&[(v4, true), (v6, false)]), None);
        assert_eq!(reachability_problem(&[(v6, true), (v4, true)]), None);
        assert_eq!(reachability_problem(&[]), None);
        // IPv6 tried first and broken, the case that stalls without an error.
        let problem = reachability_problem(&[(v6, false), (v4, true)]).unwrap();
        assert!(problem.contains("over IPv6"), "{problem}");
        assert!(problem.contains("/etc/gai.conf"), "{problem}");
        let problem = reachability_problem(&[(v4, false), (v6, true)]).unwrap();
        assert!(problem.contains("over IPv4"), "{problem}");
        assert!(!problem.contains("gai.conf"), "{problem}");
        // Nothing works.
        let problem = reachability_problem(&[(v6, false), (v4, false)]).unwrap();
        assert!(problem.contains("cannot be reached, so"), "{problem}");
    }

    #[tokio::test]
    async fn tells_reachable_addresses_from_others() {
        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let open = listener.local_addr().unwrap();
        assert!(reachable(open, PROBE_TIMEOUT).await);
        drop(listener);
        assert!(!reachable(open, PROBE_TIMEOUT).await);
    }

    /// Start `app` on a free port; returns its address and a stopper.
    async fn start(
        app: Router,
        tls: Option<Tls>,
    ) -> (SocketAddr, tokio::sync::oneshot::Sender<()>) {
        let (stop, stopped) = tokio::sync::oneshot::channel::<()>();
        let (bound, address) = tokio::sync::oneshot::channel();
        tokio::spawn(async move {
            let addr = "127.0.0.1:0".parse().unwrap();
            serve(
                addr,
                app,
                tls,
                async {
                    let _ = stopped.await;
                },
                move |addr| {
                    let _ = bound.send(addr);
                },
            )
            .await
            .unwrap();
        });
        (address.await.unwrap(), stop)
    }

    fn app() -> Router {
        Router::new().route("/", get(|| async { "defter" }))
    }

    fn client() -> reqwest::ClientBuilder {
        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
        reqwest::Client::builder().no_proxy()
    }

    #[tokio::test]
    async fn serves_plain_http() {
        let (addr, _stop) = start(app(), None).await;
        let body = client()
            .build()
            .unwrap()
            .get(format!("http://{addr}/"))
            .send()
            .await
            .unwrap()
            .text()
            .await
            .unwrap();
        assert_eq!(body, "defter");
    }

    #[tokio::test]
    async fn serves_https_with_the_given_certificate() {
        let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
        let dir = tempfile::tempdir().unwrap();
        let cert = rcgen::generate_simple_self_signed(vec!["defter.test".into()]).unwrap();
        let (cert_path, key_path) = (dir.path().join("cert.pem"), dir.path().join("key.pem"));
        let tls = Tls::Files {
            cert: cert_path.clone(),
            key: key_path.clone(),
        };
        std::fs::write(&cert_path, cert.cert.pem()).unwrap();
        std::fs::write(&key_path, cert.signing_key.serialize_pem()).unwrap();

        let (addr, _stop) = start(app(), Some(tls)).await;
        let trusting = client()
            .tls_certs_only([reqwest::Certificate::from_pem(cert.cert.pem().as_bytes()).unwrap()])
            .resolve("defter.test", addr)
            .build()
            .unwrap();
        let response = trusting
            .get(format!("https://defter.test:{}/", addr.port()))
            .send()
            .await
            .unwrap();
        assert_eq!(response.version(), reqwest::Version::HTTP_2);
        assert_eq!(response.text().await.unwrap(), "defter");

        // A client that does not trust the certificate gets no answer.
        let untrusting = client().resolve("defter.test", addr).build().unwrap();
        assert!(
            untrusting
                .get(format!("https://defter.test:{}/", addr.port()))
                .send()
                .await
                .is_err()
        );
    }

    #[tokio::test]
    async fn a_missing_certificate_is_an_error() {
        let tls = Tls::Files {
            cert: "/nonexistent/cert.pem".into(),
            key: "/nonexistent/key.pem".into(),
        };
        let addr = "127.0.0.1:0".parse().unwrap();
        let error = serve(addr, app(), Some(tls), std::future::pending(), |_| {})
            .await
            .unwrap_err();
        assert!(error.contains("/nonexistent/cert.pem"), "{error}");
    }
}