feat: enhance HTTP/2 support by ensuring Host header is set and adding multiplexed request tests

This commit is contained in:
2026-02-20 18:30:57 +00:00
parent 9521f2e044
commit d4739045cd
3 changed files with 75 additions and 69 deletions

View File

@@ -244,7 +244,10 @@ impl HttpProxyService {
.map(|h| { .map(|h| {
// Strip port from host header // Strip port from host header
h.split(':').next().unwrap_or(h).to_string() 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 path = req.uri().path().to_string();
let method = req.method().clone(); 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-*) // Add standard reverse-proxy headers (X-Forwarded-*)
{ {
let original_host = parts.headers.get("host") let original_host = host.as_deref().unwrap_or("");
.and_then(|h| h.to_str().ok())
.unwrap_or("");
let forwarded_proto = if route_match.route.action.tls.as_ref() let forwarded_proto = if route_match.route.action.tls.as_ref()
.map(|t| matches!(t.mode, .map(|t| matches!(t.mode,
rustproxy_config::TlsMode::Terminate rustproxy_config::TlsMode::Terminate
@@ -574,10 +584,11 @@ impl HttpProxyService {
source_ip: &str, source_ip: &str,
pool_key: &crate::connection_pool::PoolKey, pool_key: &crate::connection_pool::PoolKey,
) -> Result<Response<BoxBody<Bytes, hyper::Error>>, hyper::Error> { ) -> Result<Response<BoxBody<Bytes, hyper::Error>>, 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() let mut upstream_req = Request::builder()
.method(parts.method) .method(parts.method)
.uri(upstream_path) .uri(upstream_path)
.version(parts.version); .version(hyper::Version::HTTP_11);
if let Some(headers) = upstream_req.headers_mut() { if let Some(headers) = upstream_req.headers_mut() {
*headers = upstream_headers; *headers = upstream_headers;
@@ -848,6 +859,7 @@ impl HttpProxyService {
// Copy all original headers (preserving the client's Host header). // Copy all original headers (preserving the client's Host header).
// Skip X-Forwarded-* since we set them ourselves below. // Skip X-Forwarded-* since we set them ourselves below.
let mut has_host_header = false;
for (name, value) in parts.headers.iter() { for (name, value) in parts.headers.iter() {
let name_str = name.as_str(); let name_str = name.as_str();
if name_str == "x-forwarded-for" if name_str == "x-forwarded-for"
@@ -856,13 +868,25 @@ impl HttpProxyService {
{ {
continue; continue;
} }
if name_str == "host" {
has_host_header = true;
}
raw_request.push_str(&format!("{}: {}\r\n", name, value.to_str().unwrap_or(""))); 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-*) // Add standard reverse-proxy headers (X-Forwarded-*)
{ {
let original_host = parts.headers.get("host") let original_host = parts.headers.get("host")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.or(ws_host.as_deref())
.unwrap_or(""); .unwrap_or("");
let forwarded_proto = if route.action.tls.as_ref() let forwarded_proto = if route.action.tls.as_ref()
.map(|t| matches!(t.mode, .map(|t| matches!(t.mode,

View File

@@ -196,6 +196,7 @@ pub fn is_http(data: &[u8]) -> bool {
b"PATC", b"PATC",
b"OPTI", b"OPTI",
b"CONN", b"CONN",
b"PRI ", // HTTP/2 connection preface
]; ];
starts.iter().any(|s| data.starts_with(s)) starts.iter().any(|s| data.starts_with(s))
} }

View File

@@ -255,81 +255,62 @@ tap.test('HTTPS with TLS termination: multiple requests through TLS', async (too
// =========================================================================== // ===========================================================================
// 5. TLS ALPN negotiation verification // 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); tools.timeout(15000);
// Verify the Rust TLS layer advertises h2 via ALPN by inspecting a raw TLS socket // Connect an HTTP/2 session over TLS
const alpnProtocol = await new Promise<string>((resolve, reject) => { const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, {
const socket = tls.connect( rejectUnauthorized: false,
{
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'));
});
}); });
await new Promise<void>((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}`); console.log(`TLS ALPN negotiated protocol: ${alpnProtocol}`);
// Rust advertises h2+http/1.1; the negotiated protocol should be one of them expect(alpnProtocol).toEqual('h2');
expect(['h2', 'http/1.1'].includes(alpnProtocol)).toBeTrue();
// Now try an actual HTTP/2 session — Rust may or may not support h2 end-to-end // Send 5 multiplexed POST requests on the same h2 session
let h2Supported = false; const REQUEST_COUNT = 5;
try { const promises: Promise<{ status: number; body: string }>[] = [];
const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, {
rejectUnauthorized: false,
});
await new Promise<void>((resolve, reject) => { for (let i = 0; i < REQUEST_COUNT; i++) {
session.on('connect', () => resolve()); promises.push(
session.on('error', reject); new Promise<{ status: number; body: string }>((resolve, reject) => {
setTimeout(() => reject(new Error('h2 connect timeout')), 5000); const reqStream = session.request({
}); ':method': 'POST',
':path': '/echo',
'content-type': 'text/plain',
});
// If we get here, h2 session connected. Try a request. let data = '';
const result = await new Promise<{ status: number; body: string }>((resolve, reject) => { let status = 0;
const reqStream = session.request({
':method': 'POST',
':path': '/echo',
'content-type': 'text/plain',
});
let data = ''; reqStream.on('response', (headers) => {
let status = 0; status = headers[':status'] as number;
});
reqStream.on('response', (headers) => { reqStream.on('data', (chunk: Buffer) => {
status = headers[':status'] as number; data += chunk.toString();
}); });
reqStream.on('data', (chunk: Buffer) => { reqStream.on('end', () => resolve({ status, body: data }));
data += chunk.toString(); reqStream.on('error', reject);
}); reqStream.end(`h2-msg-${i}`);
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<void>((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)');
} }
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<void>((resolve) => session.close(() => resolve()));
console.log(`HTTP/2 end-to-end: ${REQUEST_COUNT} multiplexed requests completed successfully`);
}); });
// =========================================================================== // ===========================================================================