Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 606c620849 | |||
| 4ae09ac6ae | |||
| 2fce910795 | |||
| ff09cef350 | |||
| d0148b2ac3 | |||
| 7217e15649 | |||
| bfcf92a855 | |||
| 8e0804cd20 |
26
changelog.md
26
changelog.md
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-03-20 - 25.17.8 - fix(rustproxy)
|
||||
use SNI-based certificate resolution for QUIC TLS connections
|
||||
|
||||
- Replaces static first-certificate selection with the shared CertResolver used by the TCP/TLS path.
|
||||
- Ensures QUIC connections can present the correct certificate per requested domain.
|
||||
- Keeps HTTP/3 ALPN configuration while improving multi-domain TLS handling.
|
||||
|
||||
## 2026-03-20 - 25.17.7 - fix(readme)
|
||||
document QUIC and HTTP/3 compatibility caveats
|
||||
|
||||
- Add notes explaining that GREASE frames are disabled on both server and client HTTP/3 paths to avoid interoperability issues
|
||||
- Document that the current HTTP/3 stack depends on pre-1.0 h3 ecosystem components and may still have rough edges
|
||||
|
||||
## 2026-03-20 - 25.17.6 - fix(rustproxy-http)
|
||||
disable HTTP/3 GREASE for client and server connections
|
||||
|
||||
- Switch the HTTP/3 server connection setup to use the builder API with send_grease(false)
|
||||
- Switch the HTTP/3 client handshake to use the builder API with send_grease(false) to improve compatibility
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@push.rocks/smartproxy",
|
||||
"version": "25.17.4",
|
||||
"version": "25.17.8",
|
||||
"private": false,
|
||||
"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.",
|
||||
"main": "dist_ts/index.js",
|
||||
|
||||
@@ -1111,6 +1111,10 @@ SmartProxy searches for the Rust binary in this order:
|
||||
5. Local dev build (`./rust/target/release/rustproxy`)
|
||||
6. System PATH (`rustproxy`)
|
||||
|
||||
### QUIC / HTTP3 Caveats
|
||||
- **GREASE frames are disabled.** The underlying h3 crate sends [GREASE frames](https://www.rfc-editor.org/rfc/rfc9114.html#frame-reserved) by default to test protocol extensibility. However, some HTTP/3 clients and servers don't properly ignore unknown frame types, causing 400/500 errors or stream hangs ([h3#206](https://github.com/hyperium/h3/issues/206)). SmartProxy disables GREASE on both the server side (for incoming H3 requests) and the client side (for H3 backend connections) to maximize compatibility.
|
||||
- **HTTP/3 is pre-release.** The h3 ecosystem (h3 0.0.8, h3-quinn 0.0.10, quinn 0.11) is still pre-1.0. Expect rough edges.
|
||||
|
||||
### Performance Tuning
|
||||
- ✅ Use NFTables forwarding for high-traffic routes (Linux only)
|
||||
- ✅ Enable connection keep-alive where appropriate
|
||||
|
||||
4
rust/Cargo.lock
generated
4
rust/Cargo.lock
generated
@@ -1224,10 +1224,14 @@ dependencies = [
|
||||
"bytes",
|
||||
"clap",
|
||||
"dashmap",
|
||||
"h3",
|
||||
"h3-quinn",
|
||||
"http",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"mimalloc",
|
||||
"quinn",
|
||||
"rcgen",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
|
||||
@@ -75,7 +75,9 @@ impl H3ProxyService {
|
||||
debug!("HTTP/3 connection from {} on port {}", remote_addr, port);
|
||||
|
||||
let mut h3_conn: h3::server::Connection<h3_quinn::Connection, Bytes> =
|
||||
h3::server::Connection::new(h3_quinn::Connection::new(connection))
|
||||
h3::server::builder()
|
||||
.send_grease(false)
|
||||
.build(h3_quinn::Connection::new(connection))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("H3 connection setup failed: {}", e))?;
|
||||
|
||||
|
||||
@@ -2550,7 +2550,11 @@ impl HttpProxyService {
|
||||
backend_key: &str,
|
||||
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> {
|
||||
let h3_quinn_conn = h3_quinn::Connection::new(quic_conn.clone());
|
||||
let (mut driver, mut send_request) = match h3::client::new(h3_quinn_conn).await {
|
||||
let (mut driver, mut send_request) = match h3::client::builder()
|
||||
.send_grease(false)
|
||||
.build(h3_quinn_conn)
|
||||
.await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(e) => {
|
||||
error!(backend = %backend_key, domain = %domain, error = %e, "H3 client handshake failed");
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1003,44 +1003,25 @@ impl RustProxy {
|
||||
fn build_quic_tls_config(
|
||||
tls_configs: &HashMap<String, TlsCertConfig>,
|
||||
) -> Option<Arc<rustls::ServerConfig>> {
|
||||
// Find the first available cert (prefer wildcard, then any)
|
||||
let cert_config = tls_configs.get("*")
|
||||
.or_else(|| tls_configs.values().next());
|
||||
|
||||
let cert_config = match cert_config {
|
||||
Some(c) => c,
|
||||
None => return None,
|
||||
};
|
||||
|
||||
// Parse cert chain from PEM
|
||||
let mut cert_reader = std::io::BufReader::new(cert_config.cert_pem.as_bytes());
|
||||
let certs: Vec<rustls::pki_types::CertificateDer<'static>> =
|
||||
rustls_pemfile::certs(&mut cert_reader)
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
if certs.is_empty() {
|
||||
if tls_configs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Parse private key from PEM
|
||||
let mut key_reader = std::io::BufReader::new(cert_config.key_pem.as_bytes());
|
||||
let key = match rustls_pemfile::private_key(&mut key_reader) {
|
||||
Ok(Some(key)) => key,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
let mut tls_config = match rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(certs, key)
|
||||
{
|
||||
Ok(c) => c,
|
||||
// Reuse CertResolver for SNI-based cert selection (same as TCP/TLS path).
|
||||
// This ensures QUIC connections get the correct certificate for each domain
|
||||
// instead of a single static cert.
|
||||
let resolver = match rustproxy_passthrough::tls_handler::CertResolver::new(tls_configs) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
warn!("Failed to build QUIC TLS config: {}", e);
|
||||
warn!("Failed to build QUIC cert resolver: {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let mut tls_config = rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_cert_resolver(Arc::new(resolver));
|
||||
|
||||
// QUIC requires h3 ALPN
|
||||
tls_config.alpn_protocols = vec![b"h3".to_vec()];
|
||||
|
||||
|
||||
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 = {
|
||||
name: '@push.rocks/smartproxy',
|
||||
version: '25.17.4',
|
||||
version: '25.17.8',
|
||||
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