qrpc 0.1.2

qrpc is a small QUIC + mTLS messaging library
Documentation
use std::fs::File;
use std::io::BufReader;
use std::sync::Arc;
use std::time::Duration;

use quinn::{ClientConfig, ServerConfig, TransportConfig};
use rustls::pki_types::{CertificateDer, PrivateKeyDer};
use rustls::RootCertStore;
use tracing::{debug, info};

use crate::error::{QrpcError, QrpcResult};

/// ALPN value used by this crate.
pub(crate) const ALPN_QRPC: &[u8] = b"qrpc-v1";
/// Default keepalive interval used by qrpc.
pub(crate) const DEFAULT_KEEP_ALIVE_INTERVAL: Duration = Duration::from_secs(10);
/// Default max idle timeout used by qrpc.
pub(crate) const DEFAULT_MAX_IDLE_TIMEOUT: Duration = Duration::from_secs(600);

/// QUIC transport tuning options shared by server/client config.
#[derive(Clone, Copy, Debug)]
pub(crate) struct TransportOptions {
    pub(crate) keep_alive_interval: Option<Duration>,
    pub(crate) max_idle_timeout: Option<Duration>,
}

impl Default for TransportOptions {
    fn default() -> Self {
        Self {
            keep_alive_interval: Some(DEFAULT_KEEP_ALIVE_INTERVAL),
            max_idle_timeout: Some(DEFAULT_MAX_IDLE_TIMEOUT),
        }
    }
}

/// Ensures rustls crypto provider is installed.
pub(crate) fn ensure_rustls_provider() -> QrpcResult<()> {
    let _ = rustls::crypto::ring::default_provider().install_default();
    debug!("rustls provider ensured");
    Ok(())
}

/// Builds QUIC server config with mTLS enabled.
pub(crate) fn build_server_config(
    ca_cert_path: &str,
    cert_path: &str,
    key_path: &str,
    transport_options: TransportOptions,
) -> QrpcResult<ServerConfig> {
    info!(
        ca_cert_path = ca_cert_path,
        cert_path = cert_path,
        "building server tls config"
    );
    let ca_der = load_cert_der(ca_cert_path)?;
    let cert_chain = load_cert_der(cert_path)?;
    let key = load_key_der(key_path)?;

    let mut trusted_client_ca = RootCertStore::empty();
    for cert in ca_der {
        trusted_client_ca.add(cert)?;
    }

    let client_verifier =
        rustls::server::WebPkiClientVerifier::builder(Arc::new(trusted_client_ca))
            .build()
            .map_err(|e| {
                QrpcError::MessageDecode(format!("failed to build client verifier: {e}"))
            })?;

    let mut tls = rustls::ServerConfig::builder()
        .with_client_cert_verifier(client_verifier)
        .with_single_cert(cert_chain, key)
        .map_err(|e| QrpcError::MessageDecode(format!("failed to build server tls config: {e}")))?;
    tls.alpn_protocols = vec![ALPN_QRPC.to_vec()];

    let crypto = quinn::crypto::rustls::QuicServerConfig::try_from(tls).map_err(|e| {
        QrpcError::MessageDecode(format!("failed to convert server tls to quic: {e}"))
    })?;
    let mut transport = TransportConfig::default();
    transport.keep_alive_interval(transport_options.keep_alive_interval);
    transport.max_idle_timeout(to_idle_timeout(transport_options.max_idle_timeout)?);
    let transport = Arc::new(transport);

    let mut server = ServerConfig::with_crypto(Arc::new(crypto));
    server.transport_config(transport);
    debug!("server tls config built");
    Ok(server)
}

/// Builds QUIC client config with mTLS enabled.
pub(crate) fn build_client_config(
    ca_cert_path: &str,
    cert_path: &str,
    key_path: &str,
    transport_options: TransportOptions,
) -> QrpcResult<ClientConfig> {
    info!(
        ca_cert_path = ca_cert_path,
        cert_path = cert_path,
        "building client tls config"
    );
    let ca_der = load_cert_der(ca_cert_path)?;
    let cert_chain = load_cert_der(cert_path)?;
    let key = load_key_der(key_path)?;

    let mut trusted_server_ca = RootCertStore::empty();
    for cert in ca_der {
        trusted_server_ca.add(cert)?;
    }

    let mut tls = rustls::ClientConfig::builder()
        .with_root_certificates(trusted_server_ca)
        .with_client_auth_cert(cert_chain, key)
        .map_err(|e| QrpcError::MessageDecode(format!("failed to build client tls config: {e}")))?;
    tls.alpn_protocols = vec![ALPN_QRPC.to_vec()];

    let crypto = quinn::crypto::rustls::QuicClientConfig::try_from(tls).map_err(|e| {
        QrpcError::MessageDecode(format!("failed to convert client tls to quic: {e}"))
    })?;
    let mut transport = TransportConfig::default();
    transport.keep_alive_interval(transport_options.keep_alive_interval);
    transport.max_idle_timeout(to_idle_timeout(transport_options.max_idle_timeout)?);
    let transport = Arc::new(transport);

    let mut client = ClientConfig::new(Arc::new(crypto));
    client.transport_config(transport);
    debug!("client tls config built");
    Ok(client)
}

fn to_idle_timeout(timeout: Option<Duration>) -> QrpcResult<Option<quinn::IdleTimeout>> {
    timeout
        .map(|d| {
            d.try_into().map_err(|e| {
                QrpcError::MessageDecode(format!("invalid max idle timeout {d:?}: {e}"))
            })
        })
        .transpose()
}

/// Loads one or more PEM certificates from disk.
pub(crate) fn load_cert_der(path: &str) -> QrpcResult<Vec<CertificateDer<'static>>> {
    debug!(path = path, "loading certificate file");
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);
    let certs = rustls_pemfile::certs(&mut reader).collect::<std::io::Result<Vec<_>>>()?;
    if certs.is_empty() {
        return Err(QrpcError::MessageDecode(format!(
            "certificate file is empty: {path}"
        )));
    }
    debug!(path = path, count = certs.len(), "certificate file loaded");
    Ok(certs)
}

/// Loads one PEM private key from disk.
pub(crate) fn load_key_der(path: &str) -> QrpcResult<PrivateKeyDer<'static>> {
    debug!(path = path, "loading private key file");
    let file = File::open(path)?;
    let mut reader = BufReader::new(file);
    let key = rustls_pemfile::private_key(&mut reader)?
        .ok_or_else(|| QrpcError::MessageDecode(format!("private key file is empty: {path}")))?;
    debug!(path = path, "private key loaded");
    Ok(key)
}

#[cfg(test)]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::time::{SystemTime, UNIX_EPOCH};

    use super::*;

    fn tmp_file(name: &str, content: &[u8]) -> String {
        let ts = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("system time")
            .as_nanos();
        let path = PathBuf::from(std::env::temp_dir()).join(format!("qrpc_{name}_{ts}.pem"));
        fs::write(&path, content).expect("write temp file");
        path.to_string_lossy().to_string()
    }

    #[test]
    fn load_cert_missing_fails() {
        assert!(load_cert_der("/tmp/qrpc_not_exists.pem").is_err());
    }

    #[test]
    fn load_key_empty_fails() {
        let p = tmp_file("empty_key", b"");
        assert!(load_key_der(&p).is_err());
        let _ = fs::remove_file(p);
    }

    #[test]
    fn build_quic_configs_from_test_certs() {
        ensure_rustls_provider().expect("provider init");
        let ca = "tests/certs/ca.crt";
        let cert = "tests/certs/server.crt";
        let key = "tests/certs/server.key";

        let transport_options = TransportOptions::default();
        let server_cfg = build_server_config(ca, cert, key, transport_options).expect("server cfg");
        let client_cfg = build_client_config(ca, cert, key, transport_options).expect("client cfg");

        let _ = server_cfg;
        let _ = client_cfg;
    }
}