From 8e0804cd20ce2d7f7002b2a8337727414f8dfb8e Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Fri, 20 Mar 2026 07:43:32 +0000 Subject: [PATCH] fix(rustproxy): add HTTP/3 integration test for QUIC response stream FIN handling --- changelog.md | 7 + rust/Cargo.lock | 4 + rust/crates/rustproxy/Cargo.toml | 6 + .../rustproxy/tests/integration_h3_proxy.rs | 195 ++++++++++++++++++ ts/00_commitinfo_data.ts | 2 +- 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 rust/crates/rustproxy/tests/integration_h3_proxy.rs diff --git a/changelog.md b/changelog.md index e01a218..7cbd4d9 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # Changelog +## 2026-03-20 - 25.17.5 - fix(rustproxy) +add HTTP/3 integration test for QUIC response stream FIN handling + +- adds an integration test covering HTTP/3 proxying over QUIC with TLS termination +- verifies response bodies fully arrive and the client receives stream termination instead of hanging +- adds test-only dependencies for quinn, h3, h3-quinn, rustls, bytes, and http + ## 2026-03-20 - 25.17.4 - fix(rustproxy-http) prevent HTTP/3 response body streaming from hanging on backend completion diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 5d328d2..4bdeb1d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1224,10 +1224,14 @@ dependencies = [ "bytes", "clap", "dashmap", + "h3", + "h3-quinn", + "http", "http-body-util", "hyper", "hyper-util", "mimalloc", + "quinn", "rcgen", "rustls", "rustls-pemfile", diff --git a/rust/crates/rustproxy/Cargo.toml b/rust/crates/rustproxy/Cargo.toml index 211eb26..4a9694a 100644 --- a/rust/crates/rustproxy/Cargo.toml +++ b/rust/crates/rustproxy/Cargo.toml @@ -44,3 +44,9 @@ mimalloc = { workspace = true } [dev-dependencies] rcgen = { workspace = true } +quinn = { workspace = true } +h3 = { workspace = true } +h3-quinn = { workspace = true } +bytes = { workspace = true } +rustls = { workspace = true } +http = "1" diff --git a/rust/crates/rustproxy/tests/integration_h3_proxy.rs b/rust/crates/rustproxy/tests/integration_h3_proxy.rs new file mode 100644 index 0000000..e708b9f --- /dev/null +++ b/rust/crates/rustproxy/tests/integration_h3_proxy.rs @@ -0,0 +1,195 @@ +mod common; + +use common::*; +use rustproxy::RustProxy; +use rustproxy_config::{RustProxyOptions, TransportProtocol, RouteUdp, RouteQuic}; +use bytes::Buf; +use std::sync::Arc; + +/// Build a route that listens on UDP with HTTP/3 enabled and TLS terminate. +fn make_h3_route( + port: u16, + target_host: &str, + target_port: u16, + cert_pem: &str, + key_pem: &str, +) -> rustproxy_config::RouteConfig { + let mut route = make_tls_terminate_route(port, "localhost", target_host, target_port, cert_pem, key_pem); + route.route_match.transport = Some(TransportProtocol::Udp); + // Keep domain="localhost" from make_tls_terminate_route — needed for TLS cert extraction + route.action.udp = Some(RouteUdp { + session_timeout: None, + max_sessions_per_ip: None, + max_datagram_size: None, + quic: Some(RouteQuic { + max_idle_timeout: Some(30000), + max_concurrent_bidi_streams: None, + max_concurrent_uni_streams: None, + enable_http3: Some(true), + alt_svc_port: None, + alt_svc_max_age: None, + initial_congestion_window: None, + }), + }); + route +} + +/// Build a quinn client endpoint with insecure TLS for testing. +fn make_h3_client_endpoint() -> quinn::Endpoint { + let mut tls_config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(InsecureVerifier)) + .with_no_client_auth(); + tls_config.alpn_protocols = vec![b"h3".to_vec()]; + + let quic_client_config = quinn::crypto::rustls::QuicClientConfig::try_from(tls_config) + .expect("Failed to build QUIC client config"); + let client_config = quinn::ClientConfig::new(Arc::new(quic_client_config)); + + let mut endpoint = quinn::Endpoint::client("0.0.0.0:0".parse().unwrap()) + .expect("Failed to create QUIC client endpoint"); + endpoint.set_default_client_config(client_config); + endpoint +} + +/// Test that HTTP/3 response streams properly finish (FIN is received by client). +/// +/// This is the critical regression test for the FIN bug: the proxy must send +/// a QUIC stream FIN after the response body so the client's `recv_data()` +/// returns `None` instead of hanging forever. +#[tokio::test] +async fn test_h3_response_stream_finishes() { + let backend_port = next_port(); + let proxy_port = next_port(); + let body_text = "Hello from HTTP/3 backend! This body has a known length for testing."; + + // 1. Start plain HTTP backend with known body + content-length + let _backend = start_http_server(backend_port, 200, body_text).await; + + // 2. Generate self-signed cert and configure H3 route + let (cert_pem, key_pem) = generate_self_signed_cert("localhost"); + let route = make_h3_route(proxy_port, "127.0.0.1", backend_port, &cert_pem, &key_pem); + + let options = RustProxyOptions { + routes: vec![route], + ..Default::default() + }; + + // 3. Start proxy and wait for UDP bind + let mut proxy = RustProxy::new(options).unwrap(); + proxy.start().await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + + // 4. Connect QUIC/H3 client + let endpoint = make_h3_client_endpoint(); + let addr: std::net::SocketAddr = format!("127.0.0.1:{}", proxy_port).parse().unwrap(); + let connection = endpoint + .connect(addr, "localhost") + .expect("Failed to initiate QUIC connection") + .await + .expect("QUIC handshake failed"); + + let (mut driver, mut send_request) = h3::client::new( + h3_quinn::Connection::new(connection), + ) + .await + .expect("H3 connection setup failed"); + + // Drive the H3 connection in background + tokio::spawn(async move { + let _ = driver.wait_idle().await; + }); + + // 5. Send GET request + let req = http::Request::builder() + .method("GET") + .uri("https://localhost/") + .header("host", "localhost") + .body(()) + .unwrap(); + + let mut stream = send_request.send_request(req).await + .expect("Failed to send H3 request"); + stream.finish().await + .expect("Failed to finish sending H3 request body"); + + // 6. Read response headers + let resp = stream.recv_response().await + .expect("Failed to receive H3 response"); + assert_eq!(resp.status(), http::StatusCode::OK, + "Expected 200 OK, got {}", resp.status()); + + // 7. Read body and verify stream ends (FIN received) + // This is the critical assertion: recv_data() must return None (stream ended) + // within the timeout, NOT hang forever waiting for a FIN that never arrives. + let result = with_timeout(async { + let mut total = 0usize; + while let Some(chunk) = stream.recv_data().await.expect("H3 data receive error") { + total += chunk.remaining(); + } + // recv_data() returned None => stream ended (FIN received) + total + }, 10) + .await; + + let bytes_received = result.expect( + "TIMEOUT: H3 stream never ended (FIN not received by client). \ + The proxy sent all response data but failed to send the QUIC stream FIN." + ); + assert_eq!( + bytes_received, + body_text.len(), + "Expected {} bytes, got {}", + body_text.len(), + bytes_received + ); + + // 8. Cleanup + endpoint.close(quinn::VarInt::from_u32(0), b"test done"); + proxy.stop().await.unwrap(); +} + +/// Insecure TLS verifier that accepts any certificate (for tests only). +#[derive(Debug)] +struct InsecureVerifier; + +impl rustls::client::danger::ServerCertVerifier for InsecureVerifier { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::ED25519, + rustls::SignatureScheme::RSA_PSS_SHA256, + ] + } +} diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index e2a84ac..ab4fb51 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartproxy', - version: '25.17.4', + version: '25.17.5', description: 'A powerful proxy package with unified route-based configuration for high traffic management. Features include SSL/TLS support, flexible routing patterns, WebSocket handling, advanced security options, and automatic ACME certificate management.' }