fix(rustproxy): add HTTP/3 integration test for QUIC response stream FIN handling
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# 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)
|
## 2026-03-20 - 25.17.4 - fix(rustproxy-http)
|
||||||
prevent HTTP/3 response body streaming from hanging on backend completion
|
prevent HTTP/3 response body streaming from hanging on backend completion
|
||||||
|
|
||||||
|
|||||||
4
rust/Cargo.lock
generated
4
rust/Cargo.lock
generated
@@ -1224,10 +1224,14 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
"dashmap",
|
"dashmap",
|
||||||
|
"h3",
|
||||||
|
"h3-quinn",
|
||||||
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"mimalloc",
|
"mimalloc",
|
||||||
|
"quinn",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pemfile",
|
"rustls-pemfile",
|
||||||
|
|||||||
@@ -44,3 +44,9 @@ mimalloc = { workspace = true }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
rcgen = { workspace = true }
|
rcgen = { workspace = true }
|
||||||
|
quinn = { workspace = true }
|
||||||
|
h3 = { workspace = true }
|
||||||
|
h3-quinn = { workspace = true }
|
||||||
|
bytes = { workspace = true }
|
||||||
|
rustls = { workspace = true }
|
||||||
|
http = "1"
|
||||||
|
|||||||
195
rust/crates/rustproxy/tests/integration_h3_proxy.rs
Normal file
195
rust/crates/rustproxy/tests/integration_h3_proxy.rs
Normal file
@@ -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<rustls::client::danger::ServerCertVerified, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::ServerCertVerified::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls12_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_tls13_signature(
|
||||||
|
&self,
|
||||||
|
_message: &[u8],
|
||||||
|
_cert: &rustls::pki_types::CertificateDer<'_>,
|
||||||
|
_dss: &rustls::DigitallySignedStruct,
|
||||||
|
) -> Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
|
||||||
|
Ok(rustls::client::danger::HandshakeSignatureValid::assertion())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
|
||||||
|
vec![
|
||||||
|
rustls::SignatureScheme::RSA_PKCS1_SHA256,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP256_SHA256,
|
||||||
|
rustls::SignatureScheme::ECDSA_NISTP384_SHA384,
|
||||||
|
rustls::SignatureScheme::ED25519,
|
||||||
|
rustls::SignatureScheme::RSA_PSS_SHA256,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@push.rocks/smartproxy',
|
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.'
|
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.'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user