feat: enhance HTTP/2 support by ensuring Host header is set and adding multiplexed request tests
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,38 +255,10 @@ 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 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'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// 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}`, {
|
const session = http2.connect(`https://localhost:${PROXY_HTTPS_PORT}`, {
|
||||||
rejectUnauthorized: false,
|
rejectUnauthorized: false,
|
||||||
});
|
});
|
||||||
@@ -297,8 +269,18 @@ tap.test('TLS ALPN negotiation: h2 advertised, h1.1 functional', async (tools) =
|
|||||||
setTimeout(() => reject(new Error('h2 connect timeout')), 5000);
|
setTimeout(() => reject(new Error('h2 connect timeout')), 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we get here, h2 session connected. Try a request.
|
// Verify ALPN negotiated h2
|
||||||
const result = await new Promise<{ status: number; body: string }>((resolve, reject) => {
|
const alpnProtocol = (session.socket as tls.TLSSocket).alpnProtocol;
|
||||||
|
console.log(`TLS ALPN negotiated protocol: ${alpnProtocol}`);
|
||||||
|
expect(alpnProtocol).toEqual('h2');
|
||||||
|
|
||||||
|
// Send 5 multiplexed POST requests on the same h2 session
|
||||||
|
const REQUEST_COUNT = 5;
|
||||||
|
const promises: Promise<{ status: number; body: string }>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < REQUEST_COUNT; i++) {
|
||||||
|
promises.push(
|
||||||
|
new Promise<{ status: number; body: string }>((resolve, reject) => {
|
||||||
const reqStream = session.request({
|
const reqStream = session.request({
|
||||||
':method': 'POST',
|
':method': 'POST',
|
||||||
':path': '/echo',
|
':path': '/echo',
|
||||||
@@ -316,20 +298,19 @@ tap.test('TLS ALPN negotiation: h2 advertised, h1.1 functional', async (tools) =
|
|||||||
});
|
});
|
||||||
reqStream.on('end', () => resolve({ status, body: data }));
|
reqStream.on('end', () => resolve({ status, body: data }));
|
||||||
reqStream.on('error', reject);
|
reqStream.on('error', reject);
|
||||||
reqStream.end('h2-test');
|
reqStream.end(`h2-msg-${i}`);
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
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`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user