#[cfg(not(feature = "http3"))]
pub struct Http3Client;
#[cfg(not(feature = "http3"))]
impl Http3Client {
pub fn new(_profile: crate::fingerprint::BrowserProfile) -> anyhow::Result<Self> {
Err(anyhow::anyhow!(
"HTTP/3 disabled in this build. Rebuild with default features."
))
}
pub async fn supports_h3(url: &str) -> bool {
let Ok(client) = reqwest::Client::builder().build() else {
return false;
};
let Ok(resp) = client.head(url).send().await else {
return false;
};
resp.headers()
.get("alt-svc")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("h3"))
}
}
#[cfg(feature = "http3")]
use std::sync::Arc;
#[cfg(feature = "http3")]
use std::time::Duration;
#[cfg(feature = "http3")]
use anyhow::{Context, Result};
#[cfg(feature = "http3")]
use bytes::Buf;
#[cfg(feature = "http3")]
use bytes::Bytes;
#[cfg(feature = "http3")]
use tracing::{debug, info};
#[cfg(feature = "http3")]
use crate::fingerprint::BrowserProfile;
#[cfg(feature = "http3")]
pub struct Http3Client {
endpoint: quinn::Endpoint,
profile: BrowserProfile,
}
#[cfg(feature = "http3")]
impl Http3Client {
pub fn new(profile: BrowserProfile) -> Result<Self> {
let _ = rustls::crypto::ring::default_provider().install_default();
let mut roots = rustls::RootCertStore::empty();
let certs = rustls_native_certs::load_native_certs();
for cert in certs.certs {
let _ = roots.add(cert);
}
let tls_config = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
let mut transport = quinn::TransportConfig::default();
transport.max_idle_timeout(Some(Duration::from_secs(30).try_into()?));
transport.keep_alive_interval(Some(Duration::from_secs(5)));
let mut client_config = quinn::ClientConfig::new(Arc::new(
quinn::crypto::rustls::QuicClientConfig::try_from(tls_config)?,
));
client_config.transport_config(Arc::new(transport));
let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse()?)?;
endpoint.set_default_client_config(client_config);
Ok(Self { endpoint, profile })
}
pub async fn fetch(&self, url: &str) -> Result<Http3Response> {
let uri: http::Uri = url.parse().context("Invalid URL")?;
let host = uri.host().context("No host in URL")?;
let port = uri.port_u16().unwrap_or(443);
info!("HTTP/3 connecting to {}:{}", host, port);
let addr = tokio::net::lookup_host(format!("{host}:{port}"))
.await
.context("DNS lookup failed for host")?
.next()
.context("DNS resolution returned no addresses")?;
let connection = self
.endpoint
.connect(addr, host)
.context("Failed to initiate QUIC connection")?
.await
.context("QUIC handshake failed")?;
debug!(
"QUIC connected, protocol: {:?}",
connection.handshake_data()
);
let (mut driver, mut send_request) = h3::client::new(h3_quinn::Connection::new(connection))
.await
.context("H3 connection setup failed")?;
tokio::spawn(async move {
let err = futures::future::poll_fn(|cx| driver.poll_close(cx)).await;
debug!("H3 driver closed: {:?}", err);
});
let request = http::Request::builder()
.method("GET")
.uri(url)
.header("Host", host)
.header("User-Agent", &self.profile.user_agent)
.header(
"Accept",
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
)
.header("Accept-Language", &self.profile.accept_language)
.header("Accept-Encoding", "gzip, deflate, br")
.body(())
.context("Failed to build HTTP/3 request")?;
let mut stream = send_request
.send_request(request)
.await
.context("Failed to send HTTP/3 request")?;
stream
.finish()
.await
.context("Failed to finish HTTP/3 request stream")?;
let response = stream
.recv_response()
.await
.context("Failed to receive HTTP/3 response")?;
let status = response.status();
let headers = response.headers().clone();
info!("HTTP/3 response: {} from {}", status, url);
let mut body = Vec::new();
while let Some(mut chunk) = stream
.recv_data()
.await
.context("Failed to read HTTP/3 response body")?
{
while chunk.has_remaining() {
body.extend_from_slice(chunk.chunk());
chunk.advance(chunk.chunk().len());
}
}
Ok(Http3Response {
status: status.as_u16(),
headers,
body: Bytes::from(body),
})
}
pub async fn supports_h3(url: &str) -> bool {
let Ok(client) = reqwest::Client::builder().build() else {
return false;
};
let Ok(resp) = client.head(url).send().await else {
return false;
};
resp.headers()
.get("alt-svc")
.and_then(|v| v.to_str().ok())
.is_some_and(|v| v.contains("h3"))
}
}
#[cfg(feature = "http3")]
#[derive(Debug)]
pub struct Http3Response {
pub status: u16,
pub headers: http::HeaderMap,
pub body: Bytes,
}
#[cfg(feature = "http3")]
impl Http3Response {
pub fn text(&self) -> Result<String> {
String::from_utf8(self.body.to_vec()).context("Response body is not valid UTF-8")
}
#[must_use]
pub fn is_success(&self) -> bool {
(200..300).contains(&self.status)
}
}
#[cfg(all(test, feature = "http3"))]
mod tests {
use super::*;
use crate::fingerprint::chrome_profile;
#[tokio::test]
async fn test_h3_detection() {
let supports = Http3Client::supports_h3("https://cloudflare.com").await;
println!("Cloudflare H3 support: {supports}");
}
#[tokio::test]
async fn test_h3_fetch() {
let profile = chrome_profile();
let client = Http3Client::new(profile).unwrap();
match client.fetch("https://cloudflare.com").await {
Ok(resp) => {
println!("H3 Status: {}", resp.status);
assert!(resp.is_success());
}
Err(e) => {
println!("H3 fetch failed (may be network): {e}");
}
}
}
#[test]
fn test_response_is_success_2xx() {
let resp = Http3Response {
status: 200,
headers: http::HeaderMap::new(),
body: Bytes::from("ok"),
};
assert!(resp.is_success());
let resp_204 = Http3Response {
status: 204,
headers: http::HeaderMap::new(),
body: Bytes::new(),
};
assert!(resp_204.is_success());
}
#[test]
fn test_response_is_success_non_2xx() {
let resp_404 = Http3Response {
status: 404,
headers: http::HeaderMap::new(),
body: Bytes::from("not found"),
};
assert!(!resp_404.is_success());
let resp_500 = Http3Response {
status: 500,
headers: http::HeaderMap::new(),
body: Bytes::new(),
};
assert!(!resp_500.is_success());
let resp_301 = Http3Response {
status: 301,
headers: http::HeaderMap::new(),
body: Bytes::new(),
};
assert!(!resp_301.is_success());
}
#[test]
fn test_response_text_valid_utf8() {
let resp = Http3Response {
status: 200,
headers: http::HeaderMap::new(),
body: Bytes::from("Hello, world!"),
};
assert_eq!(resp.text().unwrap(), "Hello, world!");
}
#[test]
fn test_response_text_invalid_utf8() {
let resp = Http3Response {
status: 200,
headers: http::HeaderMap::new(),
body: Bytes::from_static(&[0xff, 0xfe]),
};
assert!(resp.text().is_err());
}
#[tokio::test]
async fn test_client_creation() {
let profile = chrome_profile();
let client = Http3Client::new(profile);
assert!(
client.is_ok(),
"Http3Client::new failed: {:?}",
client.err()
);
}
}