nautalid 0.1.0

Scratch container substrate — TLS 1.3 HTTP/2+3 kernel, LID/AetherDB, optional filter bus (GPL-3.0-or-later).
//! In-process HTTP/2 (and optional HTTP/3) probe for **`FROM scratch`** containers.
//!
//! Run **`/nautalid --probe`** while the server is up — checks **`GET /ready`** over **HTTPS + HTTP/2**.
//! Set **`NAUTALID_PROBE_H3=1`** to also verify **`/ready`** over HTTP/3.
//! When inbound mTLS is required, set **`NAUTALID_PROBE_CLIENT_CERT_*`** and **`NAUTALID_PROBE_CLIENT_KEY_*`**.

use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum ProbeError {
    #[error("HTTP probe failed: {0}")]
    Http(#[from] reqwest::Error),
    #[error("probe got HTTP {0}")]
    BadStatus(u16),
    #[error("inbound TLS not configured (probe requires NAUTALID_TLS_*)")]
    TlsRequired,
    #[error(
        "inbound mTLS requires probe client cert (NAUTALID_PROBE_CLIENT_CERT_* and NAUTALID_PROBE_CLIENT_KEY_*)"
    )]
    MtlsClientRequired,
    #[error("read probe client material {path}: {source}")]
    Io {
        path: String,
        source: std::io::Error,
    },
    #[error("H3 probe failed: {0}")]
    H3(String),
}

/// Probe **`GET /ready`** on loopback **`PORT`** (default 8080) via HTTPS + HTTP/2.
pub async fn probe_ready_from_env() -> Result<(), ProbeError> {
    if !crate::tls::configured_from_env() {
        return Err(ProbeError::TlsRequired);
    }
    let addr = crate::app::probe_addr_from_env();
    probe_url(&format!("https://{addr}/ready")).await?;
    if crate::surfaces::env_enabled("NAUTALID_PROBE_H3") {
        probe_h3_ready(addr).await?;
    }
    Ok(())
}

/// `GET` **`url`** over HTTP/2; succeed on 2xx. Accepts loopback self-signed certs for HEALTHCHECK.
pub async fn probe_url(url: &str) -> Result<(), ProbeError> {
    let client = probe_client_builder()?.build()?;
    let resp = client.get(url).send().await?;
    let status = resp.status().as_u16();
    if (200..300).contains(&status) {
        Ok(())
    } else {
        Err(ProbeError::BadStatus(status))
    }
}

fn probe_client_builder() -> Result<reqwest::ClientBuilder, ProbeError> {
    let mut builder = reqwest::Client::builder()
        .timeout(Duration::from_secs(2))
        .danger_accept_invalid_certs(true);

    if let Some(identity_pem) = probe_client_identity_pem_from_env()? {
        let identity = reqwest::Identity::from_pem(identity_pem.as_bytes())?;
        builder = builder.identity(identity);
    } else if crate::tls::client_auth_required_from_env() {
        return Err(ProbeError::MtlsClientRequired);
    }

    Ok(builder)
}

fn probe_client_identity_pem_from_env() -> Result<Option<String>, ProbeError> {
    let cert = match probe_pem_from_env(
        "NAUTALID_PROBE_CLIENT_CERT_PEM",
        "NAUTALID_PROBE_CLIENT_CERT_PATH",
    ) {
        Ok(pem) => pem,
        Err(ProbeError::MtlsClientRequired) => return Ok(None),
        Err(err) => return Err(err),
    };
    let key = probe_pem_from_env(
        "NAUTALID_PROBE_CLIENT_KEY_PEM",
        "NAUTALID_PROBE_CLIENT_KEY_PATH",
    )?;
    Ok(Some(format!("{cert}\n{key}")))
}

fn probe_pem_from_env(inline: &str, path_var: &str) -> Result<String, ProbeError> {
    if let Ok(pem) = std::env::var(inline) {
        if !pem.trim().is_empty() {
            return Ok(pem);
        }
    }
    if let Ok(path) = std::env::var(path_var) {
        if !path.is_empty() {
            return std::fs::read_to_string(&path).map_err(|source| ProbeError::Io {
                path: path.clone(),
                source,
            });
        }
    }
    Err(ProbeError::MtlsClientRequired)
}

async fn probe_h3_ready(addr: SocketAddr) -> Result<(), ProbeError> {
    use std::io::Cursor;

    use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier};
    use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime};

    #[derive(Debug)]
    struct SkipServerVerify;

    impl ServerCertVerifier for SkipServerVerify {
        fn verify_server_cert(
            &self,
            _end_entity: &CertificateDer<'_>,
            _intermediates: &[CertificateDer<'_>],
            _server_name: &ServerName<'_>,
            _ocsp_response: &[u8],
            _now: UnixTime,
        ) -> Result<ServerCertVerified, rustls::Error> {
            Ok(ServerCertVerified::assertion())
        }

        fn verify_tls13_signature(
            &self,
            _message: &[u8],
            _cert: &CertificateDer<'_>,
            _dss: &rustls::DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }

        fn verify_tls12_signature(
            &self,
            _message: &[u8],
            _cert: &CertificateDer<'_>,
            _dss: &rustls::DigitallySignedStruct,
        ) -> Result<HandshakeSignatureValid, rustls::Error> {
            Ok(HandshakeSignatureValid::assertion())
        }

        fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
            rustls::crypto::CryptoProvider::get_default()
                .map(|p| p.signature_verification_algorithms.supported_schemes())
                .unwrap_or_else(|| {
                    rustls::crypto::ring::default_provider()
                        .signature_verification_algorithms
                        .supported_schemes()
                })
        }
    }

    let provider = rustls::crypto::CryptoProvider::get_default()
        .ok_or_else(|| ProbeError::H3("rustls crypto provider not installed".into()))?;

    let builder = rustls::ClientConfig::builder_with_provider(provider.clone())
        .with_protocol_versions(&[&rustls::version::TLS13])
        .map_err(|e| ProbeError::H3(e.to_string()))?
        .dangerous()
        .with_custom_certificate_verifier(Arc::new(SkipServerVerify));

    let mut rustls_config = if let (Ok(cert_pem), Ok(key_pem)) = (
        probe_pem_from_env(
            "NAUTALID_PROBE_CLIENT_CERT_PEM",
            "NAUTALID_PROBE_CLIENT_CERT_PATH",
        ),
        probe_pem_from_env(
            "NAUTALID_PROBE_CLIENT_KEY_PEM",
            "NAUTALID_PROBE_CLIENT_KEY_PATH",
        ),
    ) {
        let mut cert_reader = Cursor::new(cert_pem.as_bytes());
        let certs: Vec<CertificateDer<'static>> = rustls_pemfile::certs(&mut cert_reader)
            .collect::<Result<Vec<_>, _>>()
            .map_err(|e| ProbeError::H3(e.to_string()))?;
        let mut key_reader = Cursor::new(key_pem.as_bytes());
        let key = rustls_pemfile::read_all(&mut key_reader)
            .find_map(|item| {
                item.ok().and_then(|i| match i {
                    rustls_pemfile::Item::Pkcs1Key(k) => Some(PrivateKeyDer::Pkcs1(k)),
                    rustls_pemfile::Item::Pkcs8Key(k) => Some(PrivateKeyDer::Pkcs8(k)),
                    rustls_pemfile::Item::Sec1Key(k) => Some(PrivateKeyDer::Sec1(k)),
                    _ => None,
                })
            })
            .ok_or_else(|| ProbeError::H3("probe client key PEM missing".into()))?;
        builder
            .with_client_auth_cert(certs, key)
            .map_err(|e| ProbeError::H3(e.to_string()))?
    } else if crate::tls::client_auth_required_from_env() {
        return Err(ProbeError::MtlsClientRequired);
    } else {
        builder.with_no_client_auth()
    };
    rustls_config.alpn_protocols = vec![b"h3".to_vec()];

    let quic = quinn::crypto::rustls::QuicClientConfig::try_from(rustls_config)
        .map_err(|e| ProbeError::H3(e.to_string()))?;
    let mut endpoint = quinn::Endpoint::client("[::]:0".parse().expect("client bind"))
        .map_err(|e| ProbeError::H3(e.to_string()))?;
    endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(quic)));

    let conn = endpoint
        .connect(addr, "localhost")
        .map_err(|e| ProbeError::H3(e.to_string()))?
        .await
        .map_err(|e| ProbeError::H3(e.to_string()))?;

    let quinn_conn = h3_quinn::Connection::new(conn);
    let (driver, mut client) = h3::client::builder()
        .build::<_, _, bytes::Bytes>(quinn_conn)
        .await
        .map_err(|e| ProbeError::H3(e.to_string()))?;

    let mut stream = client
        .send_request(
            http::Request::builder()
                .uri("http://localhost/ready")
                .body(())
                .expect("request"),
        )
        .await
        .map_err(|e| ProbeError::H3(e.to_string()))?;
    stream
        .finish()
        .await
        .map_err(|e| ProbeError::H3(e.to_string()))?;
    let response = stream
        .recv_response()
        .await
        .map_err(|e| ProbeError::H3(e.to_string()))?;
    drop(driver);

    let status = response.status().as_u16();
    if (200..300).contains(&status) {
        Ok(())
    } else {
        Err(ProbeError::BadStatus(status))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::http::router;
    use crate::state::AppState;

    fn install_crypto() {
        let _ = crate::platform::install_defaults();
    }

    fn test_pem_pair() -> (String, String) {
        let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
        (cert.cert.pem(), cert.signing_key.serialize_pem())
    }

    #[tokio::test]
    async fn probe_ready_against_h2_tls_server() {
        install_crypto();
        #[cfg(feature = "filter-control")]
        crate::readiness::set_bus_ready(true);

        let (cert, key) = test_pem_pair();
        // SAFETY: single-threaded test.
        unsafe {
            std::env::set_var("NAUTALID_TLS_CERT_PEM", &cert);
            std::env::set_var("NAUTALID_TLS_KEY_PEM", &key);
        }

        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
        let addr = listener.local_addr().unwrap();
        drop(listener);

        let tls = Arc::new(crate::tls::InboundTls::from_env().unwrap());
        let app = router(AppState::empty());
        let h2_config = tls.h2_config_store();
        let server = tokio::spawn(crate::http_transport::serve_h2_tls(
            addr,
            h2_config,
            None,
            app,
            false,
        ));

        for _ in 0..50 {
            if probe_url(&format!("https://{addr}/ready")).await.is_ok() {
                server.abort();
                unsafe {
                    std::env::remove_var("NAUTALID_TLS_CERT_PEM");
                    std::env::remove_var("NAUTALID_TLS_KEY_PEM");
                }
                return;
            }
            tokio::time::sleep(Duration::from_millis(20)).await;
        }
        server.abort();
        panic!("h2 probe never succeeded");
    }
}