Add: H3 support - incomplete
This commit is contained in:
@@ -5,8 +5,21 @@ edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.11", features = ["rustls-tls"] }
|
||||
rustls = "0.21"
|
||||
rustls-native-certs = "0.6"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["net", "time"] }
|
||||
tokio-rustls = "0.24"
|
||||
tokio-socks = "0.5"
|
||||
url = "2"
|
||||
tracing = "0.1"
|
||||
h3 = { version = "0.0.8", optional = true }
|
||||
h3-quinn = { version = "0.0.10", optional = true }
|
||||
quinn = { version = "0.11", optional = true }
|
||||
http = "1"
|
||||
webpki-roots = "1"
|
||||
bytes = "1"
|
||||
|
||||
[features]
|
||||
http3 = ["dep:h3", "dep:h3-quinn", "dep:quinn"]
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
use reqwest::{Client, Method, Proxy, StatusCode};
|
||||
use rustls::{Certificate, ClientConfig, RootCertStore, ServerName};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::lookup_host;
|
||||
use thiserror::Error;
|
||||
use tokio::time::timeout;
|
||||
use tokio_rustls::TlsConnector;
|
||||
use tokio_socks::tcp::Socks5Stream;
|
||||
use tracing::debug;
|
||||
use url::Url;
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
use bytes::Buf;
|
||||
#[cfg(feature = "http3")]
|
||||
use http::Request;
|
||||
#[cfg(feature = "http3")]
|
||||
use quinn::ClientConfig as QuinnClientConfig;
|
||||
#[cfg(feature = "http3")]
|
||||
use quinn::Endpoint;
|
||||
#[cfg(feature = "http3")]
|
||||
use webpki_roots::TLS_SERVER_ROOTS;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum HttpError {
|
||||
#[error("invalid url: {0}")]
|
||||
@@ -36,6 +52,7 @@ pub struct HttpReport {
|
||||
pub resolved_ips: Vec<String>,
|
||||
pub headers: Vec<(String, String)>,
|
||||
pub body: Option<String>,
|
||||
pub warnings: Vec<String>,
|
||||
pub timing: HttpTiming,
|
||||
}
|
||||
|
||||
@@ -64,6 +81,8 @@ pub struct HttpRequestOptions {
|
||||
pub show_body: bool,
|
||||
pub http1_only: bool,
|
||||
pub http2_only: bool,
|
||||
pub http3: bool,
|
||||
pub http3_only: bool,
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
|
||||
@@ -105,6 +124,39 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
}
|
||||
let dns_ms = dns_start.elapsed().as_millis();
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if opts.http3 || opts.http3_only {
|
||||
if !cfg!(feature = "http3") {
|
||||
warnings.push("http3 feature not enabled in build".to_string());
|
||||
if opts.http3_only {
|
||||
return Err(HttpError::Request(
|
||||
"http3-only requested but feature is not enabled".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
{
|
||||
if opts.http3 || opts.http3_only {
|
||||
match http3_request(url, &opts, &resolved_ips, dns_ms).await {
|
||||
Ok((report, mut h3_warnings)) => {
|
||||
warnings.append(&mut h3_warnings);
|
||||
return Ok(HttpReport {
|
||||
warnings,
|
||||
..report
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("http3 failed: {err}"));
|
||||
if opts.http3_only {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut builder = Client::builder().timeout(Duration::from_millis(opts.timeout_ms));
|
||||
builder = if let Some(max) = opts.follow_redirects {
|
||||
builder.redirect(reqwest::redirect::Policy::limited(max as usize))
|
||||
@@ -132,6 +184,16 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
}
|
||||
|
||||
let client = builder.build().map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let (connect_ms, tls_ms, timing_warnings) = measure_connect_tls(
|
||||
&parsed,
|
||||
host,
|
||||
port,
|
||||
&resolved_ips,
|
||||
opts.proxy.as_deref(),
|
||||
opts.timeout_ms,
|
||||
)
|
||||
.await;
|
||||
warnings.extend(timing_warnings);
|
||||
let start = Instant::now();
|
||||
let response = client
|
||||
.request(opts.method.to_reqwest(), parsed.clone())
|
||||
@@ -184,11 +246,12 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
resolved_ips,
|
||||
headers,
|
||||
body,
|
||||
warnings,
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: None,
|
||||
tls_ms: None,
|
||||
connect_ms,
|
||||
tls_ms,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
})
|
||||
@@ -197,3 +260,311 @@ pub async fn request(url: &str, opts: HttpRequestOptions) -> Result<HttpReport,
|
||||
fn status_code(status: StatusCode) -> Option<u16> {
|
||||
Some(status.as_u16())
|
||||
}
|
||||
|
||||
struct Socks5Proxy {
|
||||
addr: String,
|
||||
remote_dns: bool,
|
||||
}
|
||||
|
||||
fn parse_socks5_proxy(value: &str) -> Result<Socks5Proxy, HttpError> {
|
||||
let url = Url::parse(value).map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let scheme = url.scheme();
|
||||
let remote_dns = match scheme {
|
||||
"socks5" => false,
|
||||
"socks5h" => true,
|
||||
_ => {
|
||||
return Err(HttpError::Request(format!(
|
||||
"unsupported proxy scheme: {scheme}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| HttpError::Request("invalid proxy host".to_string()))?;
|
||||
let port = url
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| HttpError::Request("invalid proxy port".to_string()))?;
|
||||
Ok(Socks5Proxy {
|
||||
addr: format!("{host}:{port}"),
|
||||
remote_dns,
|
||||
})
|
||||
}
|
||||
|
||||
async fn measure_connect_tls(
|
||||
parsed: &Url,
|
||||
host: &str,
|
||||
port: u16,
|
||||
resolved_ips: &[String],
|
||||
proxy: Option<&str>,
|
||||
timeout_ms: u64,
|
||||
) -> (Option<u128>, Option<u128>, Vec<String>) {
|
||||
let mut warnings = Vec::new();
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
warnings.push(format!("timing unavailable for scheme: {scheme}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
|
||||
let timeout_dur = Duration::from_millis(timeout_ms);
|
||||
let connect_start = Instant::now();
|
||||
let tcp = if let Some(proxy) = proxy {
|
||||
match parse_socks5_proxy(proxy) {
|
||||
Ok(proxy) => {
|
||||
let target = if proxy.remote_dns {
|
||||
(host, port)
|
||||
} else if let Some(ip) = resolved_ips.first() {
|
||||
(ip.as_str(), port)
|
||||
} else {
|
||||
warnings.push("no resolved IPs for proxy connect".to_string());
|
||||
return (None, None, warnings);
|
||||
};
|
||||
match timeout(timeout_dur, Socks5Stream::connect(proxy.addr.as_str(), target))
|
||||
.await
|
||||
{
|
||||
Ok(Ok(stream)) => stream.into_inner(),
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("proxy connect failed: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("proxy connect timed out".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warnings.push(format!("proxy timing skipped: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let addr = match resolved_ips.first().and_then(|ip| ip.parse::<IpAddr>().ok()) {
|
||||
Some(ip) => SocketAddr::new(ip, port),
|
||||
None => {
|
||||
warnings.push("no resolved IPs for connect timing".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
};
|
||||
match timeout(timeout_dur, tokio::net::TcpStream::connect(addr)).await {
|
||||
Ok(Ok(stream)) => stream,
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("connect failed: {err}"));
|
||||
return (None, None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("connect timed out".to_string());
|
||||
return (None, None, warnings);
|
||||
}
|
||||
}
|
||||
};
|
||||
let connect_ms = connect_start.elapsed().as_millis();
|
||||
|
||||
if scheme == "http" {
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
|
||||
let tls_start = Instant::now();
|
||||
let tls = match build_tls_connector() {
|
||||
Ok(connector) => connector,
|
||||
Err(err) => {
|
||||
warnings.push(format!("tls timing skipped: {err}"));
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
};
|
||||
let server_name = match ServerName::try_from(host) {
|
||||
Ok(name) => name,
|
||||
Err(_) => {
|
||||
warnings.push("invalid tls server name".to_string());
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
};
|
||||
match timeout(timeout_dur, tls.connect(server_name, tcp)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(err)) => {
|
||||
warnings.push(format!("tls handshake failed: {err}"));
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
Err(_) => {
|
||||
warnings.push("tls handshake timed out".to_string());
|
||||
return (Some(connect_ms), None, warnings);
|
||||
}
|
||||
}
|
||||
let tls_ms = tls_start.elapsed().as_millis();
|
||||
|
||||
(Some(connect_ms), Some(tls_ms), warnings)
|
||||
}
|
||||
|
||||
fn build_tls_connector() -> Result<TlsConnector, HttpError> {
|
||||
let mut roots = RootCertStore::empty();
|
||||
let store = rustls_native_certs::load_native_certs()
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
for cert in store {
|
||||
roots
|
||||
.add(&Certificate(cert.0))
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
}
|
||||
let config = ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_root_certificates(roots)
|
||||
.with_no_client_auth();
|
||||
Ok(TlsConnector::from(Arc::new(config)))
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
async fn http3_request(
|
||||
url: &str,
|
||||
opts: &HttpRequestOptions,
|
||||
resolved_ips: &[String],
|
||||
dns_ms: u128,
|
||||
) -> Result<(HttpReport, Vec<String>), HttpError> {
|
||||
let mut warnings = Vec::new();
|
||||
let parsed = Url::parse(url).map_err(|err| HttpError::Url(err.to_string()))?;
|
||||
if parsed.scheme() != "https" {
|
||||
return Err(HttpError::Request("http3 requires https scheme".to_string()));
|
||||
}
|
||||
if opts.proxy.is_some() {
|
||||
return Err(HttpError::Request(
|
||||
"http3 proxying is not supported".to_string(),
|
||||
));
|
||||
}
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| HttpError::Url("missing host".to_string()))?;
|
||||
let port = parsed
|
||||
.port_or_known_default()
|
||||
.ok_or_else(|| HttpError::Url("missing port".to_string()))?;
|
||||
let ip = resolved_ips
|
||||
.first()
|
||||
.and_then(|value| value.parse::<IpAddr>().ok())
|
||||
.ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?;
|
||||
|
||||
let quinn_config = build_quinn_config()?;
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
endpoint.set_default_client_config(quinn_config);
|
||||
|
||||
let connect_start = Instant::now();
|
||||
let connecting = endpoint
|
||||
.connect(SocketAddr::new(ip, port), host)
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let connection = timeout(Duration::from_millis(opts.timeout_ms), connecting)
|
||||
.await
|
||||
.map_err(|_| HttpError::Request("http3 connect timed out".to_string()))?
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let connect_ms = connect_start.elapsed().as_millis();
|
||||
|
||||
let conn = h3_quinn::Connection::new(connection);
|
||||
let (mut driver, mut send_request) = h3::client::new(conn)
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
tokio::spawn(async move {
|
||||
let _ = driver.wait_idle().await;
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let method = match opts.method {
|
||||
HttpMethod::Head => http::Method::HEAD,
|
||||
HttpMethod::Get => http::Method::GET,
|
||||
};
|
||||
let request = Request::builder()
|
||||
.method(method)
|
||||
.uri(parsed.as_str())
|
||||
.header("user-agent", "wtfnet")
|
||||
.body(())
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
let mut stream = send_request
|
||||
.send_request(request)
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
stream
|
||||
.finish()
|
||||
.await
|
||||
.map_err(|err| HttpError::Request(err.to_string()))?;
|
||||
|
||||
let response = stream
|
||||
.recv_response()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?;
|
||||
let ttfb_ms = start.elapsed().as_millis();
|
||||
|
||||
let status = response.status();
|
||||
let final_url = parsed.to_string();
|
||||
let headers = if opts.show_headers {
|
||||
response
|
||||
.headers()
|
||||
.iter()
|
||||
.map(|(name, value)| {
|
||||
let value = value.to_str().unwrap_or("-").to_string();
|
||||
(name.to_string(), value)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let body = if opts.show_body {
|
||||
let mut buf = Vec::new();
|
||||
while let Some(chunk) = stream
|
||||
.recv_data()
|
||||
.await
|
||||
.map_err(|err| HttpError::Response(err.to_string()))?
|
||||
{
|
||||
let mut chunk = chunk;
|
||||
while chunk.has_remaining() {
|
||||
let bytes = chunk.copy_to_bytes(chunk.remaining());
|
||||
buf.extend_from_slice(&bytes);
|
||||
}
|
||||
if buf.len() >= opts.max_body_bytes {
|
||||
buf.truncate(opts.max_body_bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(String::from_utf8_lossy(&buf).to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let total_ms = start.elapsed().as_millis();
|
||||
|
||||
warnings.push("http3 timing for tls/connect is best-effort".to_string());
|
||||
|
||||
Ok((
|
||||
HttpReport {
|
||||
url: url.to_string(),
|
||||
final_url: Some(final_url),
|
||||
method: match opts.method {
|
||||
HttpMethod::Head => "HEAD".to_string(),
|
||||
HttpMethod::Get => "GET".to_string(),
|
||||
},
|
||||
status: Some(status.as_u16()),
|
||||
http_version: Some("HTTP/3".to_string()),
|
||||
resolved_ips: resolved_ips.to_vec(),
|
||||
headers,
|
||||
body,
|
||||
warnings: Vec::new(),
|
||||
timing: HttpTiming {
|
||||
total_ms,
|
||||
dns_ms: Some(dns_ms),
|
||||
connect_ms: Some(connect_ms),
|
||||
tls_ms: None,
|
||||
ttfb_ms: Some(ttfb_ms),
|
||||
},
|
||||
},
|
||||
warnings,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "http3")]
|
||||
fn build_quinn_config() -> Result<QuinnClientConfig, HttpError> {
|
||||
let mut roots = quinn::rustls::RootCertStore::empty();
|
||||
roots.extend(TLS_SERVER_ROOTS.iter().cloned());
|
||||
|
||||
let mut client_config =
|
||||
QuinnClientConfig::with_root_certificates(Arc::new(roots)).map_err(|err| {
|
||||
HttpError::Request(format!("quinn config error: {err}"))
|
||||
})?;
|
||||
let mut transport = quinn::TransportConfig::default();
|
||||
transport.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
client_config.transport_config(Arc::new(transport));
|
||||
Ok(client_config)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user