diff --git a/Cargo.lock b/Cargo.lock index 2e62010..7c12e9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3235,7 +3235,7 @@ dependencies = [ [[package]] name = "wtfnet-cli" -version = "0.1.0" +version = "0.4.0" dependencies = [ "clap", "serde", diff --git a/README.md b/README.md index e1ac393..1b8e90d 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,8 @@ cmake --build build --target install ``` ## HTTP/3 (experimental) -HTTP/3 support is feature-gated and incomplete. Do not enable it in production builds yet. +HTTP/3 support is feature-gated and best-effort. Enable it only when you want to test QUIC +connectivity. To enable locally for testing: ```bash diff --git a/crates/wtfnet-http/src/lib.rs b/crates/wtfnet-http/src/lib.rs index 44752d0..43e7645 100644 --- a/crates/wtfnet-http/src/lib.rs +++ b/crates/wtfnet-http/src/lib.rs @@ -21,6 +21,8 @@ use quinn::ClientConfig as QuinnClientConfig; #[cfg(feature = "http3")] use quinn::Endpoint; #[cfg(feature = "http3")] +use quinn::crypto::rustls::QuicClientConfig; +#[cfg(feature = "http3")] use webpki_roots::TLS_SERVER_ROOTS; #[derive(Debug, Error)] @@ -468,26 +470,54 @@ async fn http3_request( 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::().ok()) - .ok_or_else(|| HttpError::Request("no resolved IPs for http3".to_string()))?; - let quinn_config = build_quinn_config()?; + let candidates = resolved_ips + .iter() + .filter_map(|value| value.parse::().ok()) + .collect::>(); + if candidates.is_empty() { + return Err(HttpError::Request("no resolved IPs for http3".to_string())); + } - 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 mut endpoint_guard = None; + let mut connection = None; + let mut connect_ms = None; + for ip in candidates { + let bind_addr = match ip { + IpAddr::V4(_) => "0.0.0.0:0", + IpAddr::V6(_) => "[::]:0", + }; + let mut endpoint = Endpoint::client(bind_addr.parse().unwrap()) + .map_err(|err| HttpError::Request(err.to_string()))?; + endpoint.set_default_client_config(quinn_config.clone()); + let connect_start = Instant::now(); + let connecting = match endpoint.connect(SocketAddr::new(ip, port), host) { + Ok(connecting) => connecting, + Err(err) => { + warnings.push(format!("http3 connect failed to {ip}: {err}")); + continue; + } + }; + match timeout(Duration::from_millis(opts.timeout_ms), connecting).await { + Ok(Ok(conn)) => { + connect_ms = Some(connect_start.elapsed().as_millis()); + connection = Some(conn); + endpoint_guard = Some(endpoint); + break; + } + Ok(Err(err)) => { + warnings.push(format!("http3 connect failed to {ip}: {err}")); + } + Err(_) => { + warnings.push(format!("http3 connect to {ip} timed out")); + } + } + } - 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 connection = connection.ok_or_else(|| { + HttpError::Request("http3 connect failed for all resolved IPs".to_string()) + })?; + let connect_ms = connect_ms.unwrap_or_default(); let conn = h3_quinn::Connection::new(connection); let (mut driver, mut send_request) = h3::client::new(conn) @@ -563,30 +593,30 @@ async fn http3_request( 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), - }, + let _endpoint_guard = endpoint_guard; + let report = HttpReport { + url: url.to_string(), + final_url: Some(final_url), + method: match opts.method { + HttpMethod::Head => "HEAD".to_string(), + HttpMethod::Get => "GET".to_string(), }, - warnings, - )) + 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), + }, + }; + + Ok((report, warnings)) } #[cfg(feature = "http3")] @@ -594,10 +624,14 @@ fn build_quinn_config() -> Result { 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 crypto = quinn::rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth(); + crypto.alpn_protocols = vec![b"h3".to_vec()]; + let mut client_config = QuinnClientConfig::new(Arc::new( + QuicClientConfig::try_from(crypto) + .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)); diff --git a/docs/implementation_status.md b/docs/implementation_status.md index 0741e3c..b0bf69d 100644 --- a/docs/implementation_status.md +++ b/docs/implementation_status.md @@ -22,7 +22,7 @@ This document tracks current implementation status against the original design i - DNS watch uses `pnet` and is feature-gated as best-effort. ## Gaps vs design (as of now) -- HTTP/3 is feature-gated and incomplete; not enabled in default builds. +- HTTP/3 is feature-gated and best-effort; not enabled in default builds. - TLS verification is rustls-based (no OS-native verifier). - DNS leak DoH detection is heuristic and currently optional.