diff --git a/rust/crates/rustproxy-http/src/proxy_service.rs b/rust/crates/rustproxy-http/src/proxy_service.rs index d8f9361..2cb8cce 100644 --- a/rust/crates/rustproxy-http/src/proxy_service.rs +++ b/rust/crates/rustproxy-http/src/proxy_service.rs @@ -244,7 +244,10 @@ impl HttpProxyService { .map(|h| { // Strip port from host header h.split(':').next().unwrap_or(h).to_string() - }); + }) + // HTTP/2 uses :authority pseudo-header instead of Host; + // hyper maps it to the URI authority component + .or_else(|| req.uri().host().map(|h| h.to_string())); let path = req.uri().path().to_string(); let method = req.method().clone(); @@ -397,11 +400,18 @@ impl HttpProxyService { } } + // Ensure Host header is set (HTTP/2 requests don't have Host; need it for h1 backends) + if !upstream_headers.contains_key("host") { + if let Some(ref h) = host { + if let Ok(val) = hyper::header::HeaderValue::from_str(h) { + upstream_headers.insert(hyper::header::HOST, val); + } + } + } + // Add standard reverse-proxy headers (X-Forwarded-*) { - let original_host = parts.headers.get("host") - .and_then(|h| h.to_str().ok()) - .unwrap_or(""); + let original_host = host.as_deref().unwrap_or(""); let forwarded_proto = if route_match.route.action.tls.as_ref() .map(|t| matches!(t.mode, rustproxy_config::TlsMode::Terminate @@ -574,10 +584,11 @@ impl HttpProxyService { source_ip: &str, pool_key: &crate::connection_pool::PoolKey, ) -> Result>, hyper::Error> { + // Always use HTTP/1.1 for h1 backend connections (h2 incoming requests have version HTTP/2.0) let mut upstream_req = Request::builder() .method(parts.method) .uri(upstream_path) - .version(parts.version); + .version(hyper::Version::HTTP_11); if let Some(headers) = upstream_req.headers_mut() { *headers = upstream_headers; @@ -848,6 +859,7 @@ impl HttpProxyService { // Copy all original headers (preserving the client's Host header). // Skip X-Forwarded-* since we set them ourselves below. + let mut has_host_header = false; for (name, value) in parts.headers.iter() { let name_str = name.as_str(); if name_str == "x-forwarded-for" @@ -856,13 +868,25 @@ impl HttpProxyService { { continue; } + if name_str == "host" { + has_host_header = true; + } raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); } + // HTTP/2 requests don't have Host header; add one from URI authority for h1 backends + let ws_host = parts.uri.host().map(|h| h.to_string()); + if !has_host_header { + if let Some(ref h) = ws_host { + raw_request.push_str(&format!("host: {}\r\n", h)); + } + } + // Add standard reverse-proxy headers (X-Forwarded-*) { let original_host = parts.headers.get("host") .and_then(|h| h.to_str().ok()) + .or(ws_host.as_deref()) .unwrap_or(""); let forwarded_proto = if route.action.tls.as_ref() .map(|t| matches!(t.mode, diff --git a/rust/crates/rustproxy-passthrough/src/sni_parser.rs b/rust/crates/rustproxy-passthrough/src/sni_parser.rs index 4fb5576..595cc71 100644 --- a/rust/crates/rustproxy-passthrough/src/sni_parser.rs +++ b/rust/crates/rustproxy-passthrough/src/sni_parser.rs @@ -196,6 +196,7 @@ pub fn is_http(data: &[u8]) -> bool { b"PATC", b"OPTI", b"CONN", + b"PRI ", // HTTP/2 connection preface ]; starts.iter().any(|s| data.starts_with(s)) } diff --git a/test/test.perf-improvements.ts b/test/test.perf-improvements.ts index 61b6bb3..e851c9e 100644 --- a/test/test.perf-improvements.ts +++ b/test/test.perf-improvements.ts @@ -255,81 +255,62 @@ tap.test('HTTPS with TLS termination: multiple requests through TLS', async (too // =========================================================================== // 5. TLS ALPN negotiation verification // =========================================================================== -tap.test('TLS ALPN negotiation: h2 advertised, h1.1 functional', async (tools) => { +tap.test('HTTP/2 end-to-end: ALPN h2 with multiplexed requests', async (tools) => { tools.timeout(15000); - // Verify the Rust TLS layer advertises h2 via ALPN by inspecting a raw TLS socket - const alpnProtocol = await new Promise((resolve, reject) => { - const socket = tls.connect( - { - host: 'localhost', - port: PROXY_HTTPS_PORT, - ALPNProtocols: ['h2', 'http/1.1'], - rejectUnauthorized: false, - servername: 'localhost', - }, - () => { - const proto = (socket as any).alpnProtocol || 'none'; - socket.destroy(); - resolve(proto); - }, - ); - socket.on('error', reject); - socket.setTimeout(5000, () => { - socket.destroy(new Error('timeout')); - }); + // Connect an HTTP/2 session over TLS + const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, { + rejectUnauthorized: false, }); + await new Promise((resolve, reject) => { + session.on('connect', () => resolve()); + session.on('error', reject); + setTimeout(() => reject(new Error('h2 connect timeout')), 5000); + }); + + // Verify ALPN negotiated h2 + const alpnProtocol = (session.socket as tls.TLSSocket).alpnProtocol; console.log(`TLS ALPN negotiated protocol: ${alpnProtocol}`); - // Rust advertises h2+http/1.1; the negotiated protocol should be one of them - expect(['h2', 'http/1.1'].includes(alpnProtocol)).toBeTrue(); + expect(alpnProtocol).toEqual('h2'); - // Now try an actual HTTP/2 session — Rust may or may not support h2 end-to-end - let h2Supported = false; - try { - const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, { - rejectUnauthorized: false, - }); + // Send 5 multiplexed POST requests on the same h2 session + const REQUEST_COUNT = 5; + const promises: Promise<{ status: number; body: string }>[] = []; - await new Promise((resolve, reject) => { - session.on('connect', () => resolve()); - session.on('error', reject); - setTimeout(() => reject(new Error('h2 connect timeout')), 5000); - }); + for (let i = 0; i < REQUEST_COUNT; i++) { + promises.push( + new Promise<{ status: number; body: string }>((resolve, reject) => { + const reqStream = session.request({ + ':method': 'POST', + ':path': '/echo', + 'content-type': 'text/plain', + }); - // If we get here, h2 session connected. Try a request. - const result = await new Promise<{ status: number; body: string }>((resolve, reject) => { - const reqStream = session.request({ - ':method': 'POST', - ':path': '/echo', - 'content-type': 'text/plain', - }); + let data = ''; + let status = 0; - let data = ''; - let status = 0; - - reqStream.on('response', (headers) => { - status = headers[':status'] as number; - }); - reqStream.on('data', (chunk: Buffer) => { - data += chunk.toString(); - }); - reqStream.on('end', () => resolve({ status, body: data })); - reqStream.on('error', reject); - reqStream.end('h2-test'); - }); - - expect(result.status).toEqual(200); - expect(result.body).toEqual('echo:h2-test'); - h2Supported = true; - - await new Promise((resolve) => session.close(() => resolve())); - } catch { - // h2 end-to-end not yet supported — that's OK, h1.1 over TLS is verified above - console.log('HTTP/2 end-to-end not yet supported by Rust engine (expected)'); + reqStream.on('response', (headers) => { + status = headers[':status'] as number; + }); + reqStream.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + reqStream.on('end', () => resolve({ status, body: data })); + reqStream.on('error', reject); + reqStream.end(`h2-msg-${i}`); + }), + ); } - console.log(`HTTP/2 full support: ${h2Supported ? 'yes' : 'no (ALPN advertised but h2 framing not handled)'}`); + const results = await Promise.all(promises); + for (let i = 0; i < REQUEST_COUNT; i++) { + expect(results[i].status).toEqual(200); + expect(results[i].body).toEqual(`echo:h2-msg-${i}`); + } + + await new Promise((resolve) => session.close(() => resolve())); + console.log(`HTTP/2 end-to-end: ${REQUEST_COUNT} multiplexed requests completed successfully`); }); // ===========================================================================