leyline-http 0.1.5

HTTP client that impersonates Chrome, Firefox, and Safari on the wire: TLS (JA3/JA4), HTTP/2, and HTTP/3 fingerprints
Documentation
use leyline_quiche as quiche;

use crate::tls::{
    FingerprintConnector, HelloOptions, Resolver, TlsMinVersion, TlsTrustConfig,
    apply_tls_with_trust,
};

use crate::quic::config::H3Config;
use crate::quic::transport::DatagramTransport;
use crate::quic::wire;

const DGRAM_QUEUE_LEN: usize = 16;

#[derive(Debug)]
pub struct H3Response {
    pub status: u16,
    pub headers: Vec<(String, String)>,
    pub body: Vec<u8>,
    pub trailers: Vec<(String, String)>,
}

pub(crate) struct EstablishedH3 {
    pub(crate) socket: DatagramTransport,
    pub(crate) conn: Box<quiche::Connection>,
    pub(crate) h3: quiche::h3::Connection,
    pub(crate) peer_addr: std::net::SocketAddr,
    pub(crate) local_addr: std::net::SocketAddr,
    pub(crate) max_udp_payload: usize,
    pub(crate) max_response_body_bytes: u64,
    pub(crate) priority_update: bool,
    pub(crate) tls: crate::pool::TlsInfo,
}

impl H3Config {
    fn build_quic_config(
        &self,
        trust: &TlsTrustConfig,
        host: &str,
    ) -> Result<quiche::Config, String> {
        let mut ssl_builder =
            leyline_bssl::ssl::SslContextBuilder::new(leyline_bssl::ssl::SslMethod::tls())
                .map_err(|e| format!("quic ssl ctx: {e}"))?;
        apply_tls_with_trust(&mut ssl_builder, &self.tls, TlsMinVersion::Tls13, trust)
            .map_err(|e| format!("quic ssl ctx: {e}"))?;

        let pins = trust.pinned_leaf_sha256();
        let ip_host = host.parse::<std::net::IpAddr>().is_ok();
        if !pins.is_empty() || ip_host || cfg!(target_os = "macos") && trust.uses_system_roots() {
            crate::tls::install_verifier_ctx(
                &mut ssl_builder,
                pins,
                Some(host),
                trust.uses_system_roots(),
            );
        }

        let mut config =
            quiche::Config::with_boring_ssl_ctx_builder(quiche::PROTOCOL_VERSION, ssl_builder)
                .map_err(|e| format!("quic config: {e}"))?;
        config.verify_peer(true);
        config
            .set_application_protos(quiche::h3::APPLICATION_PROTOCOL)
            .map_err(|e| format!("quic alpn: {e}"))?;
        config.set_max_idle_timeout(self.max_idle_timeout.as_millis() as u64);
        config.set_max_recv_udp_payload_size(self.max_udp_payload_size as usize);
        config.set_max_send_udp_payload_size(self.max_udp_payload_size as usize);
        config.set_initial_max_data(self.initial_max_data);
        config.set_initial_max_stream_data_bidi_local(self.initial_max_stream_data_bidi_local);
        config.set_initial_max_stream_data_bidi_remote(self.initial_max_stream_data_bidi_remote);
        config.set_initial_max_stream_data_uni(self.initial_max_stream_data_uni);
        config.set_initial_max_streams_bidi(self.initial_max_streams_bidi);
        config.set_initial_max_streams_uni(self.initial_max_streams_uni);
        config.set_active_connection_id_limit(self.active_connection_id_limit);
        config.set_disable_active_migration(true);
        let wire = &self.wire;
        if let Some(ms) = wire.max_ack_delay_ms {
            config.set_max_ack_delay(ms);
        }
        if wire.sends_datagrams() {
            config.enable_dgram(true, DGRAM_QUEUE_LEN, DGRAM_QUEUE_LEN);
        }
        if let Some(plan) = wire.transport_plan()? {
            config.set_transport_params_plan(plan);
        }
        config.set_compatible_versions(&wire.compatible_versions());
        config.set_initial_crypto_split(wire.crypto_split());
        if let Some(size) = wire.initial_datagram_size {
            config.set_initial_datagram_size(usize::from(size));
        }
        if wire.settings.is_some() {
            config.grease(false);
        }
        Ok(config)
    }

    fn build_h3_config(&self) -> Result<quiche::h3::Config, String> {
        let wire = &self.wire;
        let mut h3_config = quiche::h3::Config::new().map_err(|e| format!("h3 config: {e}"))?;
        h3_config.set_field_section_limit(self.max_header_list_bytes);
        match &wire.settings {
            Some(settings) => {
                h3_config.set_settings_plan(wire::settings_plan(settings));
                h3_config
                    .set_control_frames(wire::control_frames(wire.control_grease_frame.as_ref()));
            }
            None => {
                h3_config.set_qpack_max_table_capacity(wire.qpack_max_table_capacity.unwrap_or(0));
                h3_config.set_qpack_blocked_streams(wire.qpack_blocked_streams.unwrap_or(0));
                if let Some(size) = wire.max_field_section_size {
                    h3_config.set_max_field_section_size(size);
                }
            }
        }
        Ok(h3_config)
    }
}

pub(crate) async fn connect_and_handshake(
    h3_cfg: &H3Config,
    trust: &TlsTrustConfig,
    connector: &FingerprintConnector,
    host: &str,
    port: u16,
    proxy: Option<&str>,
) -> Result<EstablishedH3, String> {
    let dcid_len = wire::connection_id_len(&h3_cfg.wire.dcid_length);
    validate_connection_id_len(dcid_len)?;
    let scid_len = h3_cfg.wire.scid_length.unwrap_or(dcid_len);
    if scid_len > quiche::MAX_CONN_ID_LEN {
        return Err(format!(
            "h3: scid_length must be 0..={} bytes, got {scid_len}",
            quiche::MAX_CONN_ID_LEN
        ));
    }
    let host = crate::util::bare_host(host);

    let mut config = h3_cfg.build_quic_config(trust, host)?;
    let hello = HelloOptions::from_tls(&h3_cfg.tls).map_err(|e| format!("quic hello: {e}"))?;
    let (socket, peer_addr) = DatagramTransport::open(connector, host, port, proxy).await?;
    let local_addr = socket
        .local_addr()
        .map_err(|e| format!("local addr: {e}"))?;

    let scid_bytes = wire::random_bytes(scid_len);
    let dcid_bytes = wire::random_bytes(dcid_len);
    let scid = quiche::ConnectionId::from_ref(&scid_bytes);
    let dcid = quiche::ConnectionId::from_ref(&dcid_bytes);

    let server_name = host.parse::<std::net::IpAddr>().is_err().then_some(host);
    let mut conn = Box::new(
        quiche::connect_with_dcid(
            server_name,
            &scid,
            &dcid,
            local_addr,
            peer_addr,
            &mut config,
        )
        .map_err(|e| format!("quic connect: {e}"))?,
    );
    hello
        .apply(conn.ssl_mut(), true)
        .map_err(|e| format!("quic hello: {e}"))?;

    let h3_config = h3_cfg.build_h3_config()?;

    let mut out = vec![0u8; h3_cfg.max_udp_payload_size as usize];
    let mut buf = vec![0u8; 65_535];
    let mut h3: Option<quiche::h3::Connection> = None;
    let mut iter = 0u32;

    loop {
        iter += 1;
        if iter > 10_000 {
            return Err(format!(
                "h3 handshake: CPU-spin guard tripped at {iter} iters: closed={} established={} h3={}",
                conn.is_closed(),
                conn.is_established(),
                h3.is_some()
            ));
        }

        flush_egress(&socket, &mut conn, &mut out).await?;

        if conn.is_closed() {
            return Err(close_reason("h3 handshake", iter, &conn));
        }

        if conn.is_established() && h3.is_none() {
            let h3_conn = quiche::h3::Connection::with_transport(&mut conn, &h3_config)
                .map_err(|e| format!("h3 with_transport: {e}"))?;
            h3 = Some(h3_conn);
            flush_egress(&socket, &mut conn, &mut out).await?;
        }

        if let Some(h3) = h3.take() {
            let tls = crate::pool::TlsInfo {
                peer_cert_der: conn.peer_cert().map(<[u8]>::to_vec),
                version: Some("TLSv1.3".to_string()),
                cipher: conn.cipher().map(str::to_string),
            };
            return Ok(EstablishedH3 {
                socket,
                conn,
                h3,
                peer_addr,
                local_addr,
                max_udp_payload: h3_cfg.max_udp_payload_size as usize,
                max_response_body_bytes: h3_cfg.max_response_body_bytes,
                priority_update: h3_cfg.wire.priority_update,
                tls,
            });
        }

        let timeout = conn.timeout().unwrap_or(std::time::Duration::from_secs(5));
        match tokio::time::timeout(timeout, socket.recv(&mut buf)).await {
            Ok(Ok(len)) => {
                let recv_info = quiche::RecvInfo {
                    from: peer_addr,
                    to: local_addr,
                };
                conn.recv(&mut buf[..len], recv_info)
                    .map_err(|e| format!("quic recv: {e}"))?;
            }
            Ok(Err(e)) => return Err(format!("udp recv: {e}")),
            Err(_) => conn.on_timeout(),
        }
    }
}

pub(crate) async fn flush_egress(
    socket: &DatagramTransport,
    conn: &mut quiche::Connection,
    out: &mut [u8],
) -> Result<(), String> {
    loop {
        match conn.send(out) {
            Ok((n, _send_info)) => {
                socket
                    .send(&out[..n])
                    .await
                    .map_err(|e| format!("udp send: {e}"))?;
            }
            Err(quiche::Error::Done) => return Ok(()),
            Err(e) => return Err(format!("quic send: {e}")),
        }
    }
}

pub(crate) fn close_reason(ctx: &str, iter: u32, conn: &quiche::Connection) -> String {
    let peer_err = conn.peer_error().map(|e| {
        format!(
            "code={} reason={:?}",
            e.error_code,
            String::from_utf8_lossy(&e.reason)
        )
    });
    let local_err = conn.local_error().map(|e| {
        format!(
            "code={} reason={:?}",
            e.error_code,
            String::from_utf8_lossy(&e.reason)
        )
    });
    format!(
        "{ctx}: conn closed (iter={iter} established={}) peer_err={peer_err:?} local_err={local_err:?}",
        conn.is_established()
    )
}

pub(super) async fn resolve_peer(
    resolver: &dyn Resolver,
    host: &str,
    port: u16,
) -> Result<std::net::SocketAddr, String> {
    let addrs = resolver
        .resolve(host, port)
        .await
        .map_err(|e| format!("dns: {e}"))?;
    addrs
        .iter()
        .find(|a| a.is_ipv4())
        .or_else(|| addrs.first())
        .copied()
        .ok_or_else(|| "no address resolved".to_string())
}

fn validate_connection_id_len(len: usize) -> Result<(), String> {
    if len == 0 || len > quiche::MAX_CONN_ID_LEN {
        return Err(format!(
            "h3: dcid_length must be 1..={} bytes, got {len}",
            quiche::MAX_CONN_ID_LEN
        ));
    }

    Ok(())
}

pub(crate) fn check_body_budget(already: usize, n: usize, max: u64) -> Result<(), u64> {
    let new_total = already.saturating_add(n) as u64;
    if new_total > max {
        Err(new_total)
    } else {
        Ok(())
    }
}

#[cfg(test)]
mod tests;