firegex/backend/proxysrc/tests/h3.rs
2026-09-18 09:34:44 +02:00

704 lines
30 KiB
Rust

//! HTTP/3 carried by the engine, and shown to the filters as HTTP/1.1.
//!
//! The point of the translation is that a filter written once works on both: a pattern
//! that blocks a path over TCP has to block the same path over QUIC, where on the wire
//! that path was a QPACK-compressed header block. These tests put a real HTTP/3 client and
//! a real HTTP/3 service either side of the relay and check what the chain was shown.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use bytes::{Buf, Bytes};
use fgex_proxy::filter::{ChainHandle, FilterChain};
use fgex_proxy::proxy::ProxyStats;
use fgex_proxy::proxy::Onward;
use fgex_proxy::quic::{QuicConfig, QuicRelay, QuicSetup};
use fgex_proxy::spec::parse_filters;
use fgex_proxy::tls;
use quinn::crypto::rustls::{QuicClientConfig, QuicServerConfig};
fn self_signed() -> (String, String) {
let cert =
rcgen::generate_simple_self_signed(vec!["localhost".to_string(), "127.0.0.1".to_string()])
.unwrap();
(cert.cert.pem(), cert.key_pair.serialize_pem())
}
/// An HTTP/3 service that answers with what it was asked, body and all.
async fn spawn_h3_echo(cert: &str, key: &str) -> SocketAddr {
let mut config = (*tls::server_config(cert, key).unwrap()).clone();
config.alpn_protocols = vec![b"h3".to_vec()];
let endpoint = quinn::Endpoint::server(
quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())),
"127.0.0.1:0".parse().unwrap(),
)
.unwrap();
let addr = endpoint.local_addr().unwrap();
tokio::spawn(async move {
while let Some(incoming) = endpoint.accept().await {
tokio::spawn(async move {
let Ok(connection) = incoming.await else {
return;
};
let Ok(mut h3) = h3::server::builder()
.build(h3_quinn::Connection::new(connection))
.await
else {
return;
};
while let Ok(Some(resolver)) = h3.accept().await {
tokio::spawn(async move {
let Ok((request, mut stream)) = resolver.resolve_request().await else {
return;
};
let mut body = Vec::new();
while let Ok(Some(mut chunk)) = stream.recv_data().await {
let len = chunk.remaining();
body.extend_from_slice(&chunk.copy_to_bytes(len));
}
// One path answers with something a rule is looking for, and
// says it in bytes that appear nowhere in the request: that is
// what makes a test of the *answer* a test of the answer.
let answer = if request.uri().path() == "/secret" {
"here is FLAG{only-in-the-answer}".to_string()
} else {
format!(
"you asked for {} and sent {} bytes: {}",
request.uri().path(),
body.len(),
String::from_utf8_lossy(&body)
)
};
let response = http::Response::builder()
.status(http::StatusCode::OK)
.header("content-length", answer.len().to_string())
.body(())
.unwrap();
let _ = stream.send_response(response).await;
let _ = stream.send_data(Bytes::from(answer)).await;
let _ = stream.finish().await;
});
}
});
}
});
addr
}
/// A service that speaks HTTP/1.1 and nothing else, which is what most of the web is.
///
/// Deliberately hand-written rather than a crate: what these tests are about is what
/// leaves this engine, so the far end has to be something that reads exactly what an
/// HTTP/1.1 service reads and nothing more forgiving.
async fn spawn_h1_echo() -> SocketAddr {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
let Ok((mut socket, _)) = listener.accept().await else {
return;
};
tokio::spawn(async move {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let mut buf = Vec::new();
let mut chunk = [0u8; 4096];
// The head, then whatever the framing says the body is.
let head_end = loop {
let n = match socket.read(&mut chunk).await {
Ok(0) | Err(_) => return,
Ok(n) => n,
};
buf.extend_from_slice(&chunk[..n]);
if let Some(at) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
break at + 4;
}
};
let head = String::from_utf8_lossy(&buf[..head_end]).to_lowercase();
let path = head.split_whitespace().nth(1).unwrap_or("/").to_string();
let declared = head
.split("content-length:")
.nth(1)
.and_then(|rest| rest.split("\r\n").next())
.and_then(|v| v.trim().parse::<usize>().ok());
let mut body = buf[head_end..].to_vec();
match declared {
Some(want) => {
while body.len() < want {
let n = match socket.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
body.extend_from_slice(&chunk[..n]);
}
body.truncate(want.min(body.len()));
}
// Chunked, which is what an undeclared body is in HTTP/1.1 — and what
// the rendering shows a filter, so it is what has to arrive here.
None if head.contains("transfer-encoding: chunked") => {
let mut decoded = Vec::new();
loop {
while !body.windows(2).any(|w| w == b"\r\n") {
let n = match socket.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
body.extend_from_slice(&chunk[..n]);
}
let Some(at) = body.windows(2).position(|w| w == b"\r\n") else {
break;
};
let size = usize::from_str_radix(
String::from_utf8_lossy(&body[..at]).trim(),
16,
)
.unwrap_or(0);
if size == 0 {
break;
}
body.drain(..at + 2);
while body.len() < size + 2 {
let n = match socket.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => n,
};
body.extend_from_slice(&chunk[..n]);
}
decoded.extend_from_slice(&body[..size.min(body.len())]);
body.drain(..(size + 2).min(body.len()));
}
body = decoded;
}
None => body.clear(),
}
let answer = format!(
"you asked for {path} and sent {} bytes: {}",
body.len(),
String::from_utf8_lossy(&body)
);
let _ = socket
.write_all(
format!(
"HTTP/1.1 200 OK\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{answer}",
answer.len()
)
.as_bytes(),
)
.await;
let _ = socket.flush().await;
});
}
});
addr
}
async fn spawn_relay(upstream: SocketAddr, filters: &str) -> SocketAddr {
spawn_relay_to(upstream, filters, Onward::Same).await
}
async fn spawn_relay_to(
upstream: SocketAddr,
filters: &str,
protocol: Onward,
) -> SocketAddr {
let (cert, key) = self_signed();
let chain = ChainHandle::new(FilterChain::new(
parse_filters(filters).unwrap(),
Duration::from_millis(500),
));
let cfg = QuicConfig {
setup: Arc::new(QuicSetup::build(&cert, &key, vec![b"h3".to_vec()]).unwrap()),
self_mark: None,
spoof_source: false,
max_connections: 0,
over_limit_forwards: false,
first_byte_timeout: None,
connect_timeout: Duration::from_secs(5),
upstream: protocol,
// No interface to reconstruct anything onto, which is every host without the
// capture device — what it would carry is pinned by the integration suite, which
// has a real one.
capture: None,
};
let relay = QuicRelay::bind(
"127.0.0.1:0".parse().unwrap(),
upstream,
chain,
cfg,
Arc::new(ProxyStats::default()),
)
.unwrap();
let addr = relay.local_addr().unwrap();
tokio::spawn(Arc::new(relay).serve());
addr
}
/// One request through the relay, and what came back.
async fn request(
addr: SocketAddr,
method: &str,
path: &str,
body: Option<&[u8]>,
declare_length: bool,
) -> Result<String, String> {
let mut config = tls::quic_client_config().unwrap();
config.alpn_protocols = vec![b"h3".to_vec()];
let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(
QuicClientConfig::try_from(config).unwrap(),
)));
let connection = endpoint
.connect(addr, "localhost")
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection))
.await
.map_err(|e| e.to_string())?;
let driving = tokio::spawn(async move {
let _ = driver.wait_idle().await;
});
let answer = async {
let mut builder = http::Request::builder()
.method(method)
.uri(format!("https://localhost{path}"));
if declare_length {
builder = builder.header("content-length", body.map(|b| b.len()).unwrap_or(0).to_string());
}
let outgoing = builder.body(()).map_err(|e| e.to_string())?;
let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?;
if let Some(body) = body {
stream
.send_data(Bytes::copy_from_slice(body))
.await
.map_err(|e| e.to_string())?;
}
stream.finish().await.map_err(|e| e.to_string())?;
stream.recv_response().await.map_err(|e| e.to_string())?;
let mut got = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? {
let len = chunk.remaining();
got.extend_from_slice(&chunk.copy_to_bytes(len));
}
Ok(String::from_utf8_lossy(&got).into_owned())
}
.await;
driving.abort();
answer
}
#[tokio::test]
async fn an_exchange_goes_through() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "").await;
let answer = request(addr, "GET", "/hello", None, false).await.unwrap();
assert_eq!(answer, "you asked for /hello and sent 0 bytes: ");
}
/// The whole reason for rendering the exchange: a pattern written against an HTTP
/// request matches one that arrived as a QPACK header block.
#[tokio::test]
async fn a_pattern_matches_the_request_line() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:GET /admin").await;
let allowed = request(addr, "GET", "/public", None, false).await.unwrap();
assert!(allowed.contains("/public"), "an unrelated request was affected: {allowed}");
let refused = request(addr, "GET", "/admin", None, false).await;
assert!(
refused.is_err(),
"a request the chain refused was answered anyway: {refused:?}"
);
}
/// A header the request did not carry must not appear in what the filters are shown.
///
/// This is what the head is held back for. A bodiless request rendered as
/// `transfer-encoding: chunked` would be a lie in front of every filter looking for that
/// header — and a filter looking for it is usually looking for request smuggling, which
/// makes a fabricated one the worst possible answer.
#[tokio::test]
async fn a_request_with_no_body_is_shown_no_framing_header() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:transfer-encoding").await;
let answer = request(addr, "GET", "/plain", None, false).await.unwrap();
assert!(answer.contains("/plain"), "a framing header was invented: {answer}");
}
/// And a body whose length was never declared is shown chunked, because that is what
/// that message is in HTTP/1.1 — so a parser reading the rendering finds a body.
#[tokio::test]
async fn a_body_of_unknown_length_is_shown_chunked() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:transfer-encoding").await;
let refused = request(addr, "POST", "/upload", Some(b"anything"), false).await;
assert!(
refused.is_err(),
"a body with no declared length was shown unframed: {refused:?}"
);
}
/// A declared length is passed through as it was, and the body shown raw beneath it.
#[tokio::test]
async fn a_declared_length_is_left_alone() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:transfer-encoding").await;
let answer = request(addr, "POST", "/upload", Some(b"declared"), true)
.await
.unwrap();
assert!(answer.contains("sent 8 bytes"), "the body did not arrive: {answer}");
}
/// Both directions are inspected, and a refused answer does not reach the client.
#[tokio::test]
async fn the_answer_is_inspected_too() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:FLAG{").await;
let answer = request(addr, "GET", "/harmless", None, false).await;
assert!(answer.is_ok(), "an unrelated exchange was affected: {answer:?}");
// Nothing in this request carries the needle; everything that does is in the reply.
let refused = request(addr, "GET", "/secret", None, false).await;
match refused {
Err(_) => {}
Ok(body) => panic!("the answer reached the client anyway: {body}"),
}
}
/// A service that answers a fixed body and remembers the trailer section it was sent.
///
/// Separate from the echo above on purpose: what these tests are asking is what reached
/// the *service*, which an answer that reflects the request cannot tell them apart from
/// what reached the client.
async fn spawn_h3_recorder(
cert: &str,
key: &str,
) -> (SocketAddr, Arc<std::sync::Mutex<Vec<String>>>) {
let seen: Arc<std::sync::Mutex<Vec<String>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let mut config = (*tls::server_config(cert, key).unwrap()).clone();
config.alpn_protocols = vec![b"h3".to_vec()];
let endpoint = quinn::Endpoint::server(
quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())),
"127.0.0.1:0".parse().unwrap(),
)
.unwrap();
let addr = endpoint.local_addr().unwrap();
let recorded = Arc::clone(&seen);
tokio::spawn(async move {
while let Some(incoming) = endpoint.accept().await {
let recorded = Arc::clone(&recorded);
tokio::spawn(async move {
let Ok(connection) = incoming.await else { return };
let Ok(mut h3) = h3::server::builder()
.build(h3_quinn::Connection::new(connection))
.await
else {
return;
};
while let Ok(Some(resolver)) = h3.accept().await {
let recorded = Arc::clone(&recorded);
tokio::spawn(async move {
let Ok((_request, mut stream)) = resolver.resolve_request().await else {
return;
};
while let Ok(Some(_)) = stream.recv_data().await {}
if let Ok(Some(trailers)) = stream.recv_trailers().await {
for (name, value) in trailers.iter() {
recorded.lock().unwrap().push(format!(
"{name}={}",
value.to_str().unwrap_or("?")
));
}
}
let answer = "recorded";
let response = http::Response::builder()
.status(http::StatusCode::OK)
.header("content-length", answer.len().to_string())
.body(())
.unwrap();
let _ = stream.send_response(response).await;
let _ = stream.send_data(Bytes::from(answer)).await;
let _ = stream.finish().await;
});
}
});
}
});
(addr, seen)
}
/// A service that answers the moment the head arrives, without waiting for a body.
///
/// The shape of a bidirectional RPC and of every service that replies before an upload
/// finishes — a `413`, a redirect, a refusal.
async fn spawn_h3_eager(cert: &str, key: &str) -> SocketAddr {
let mut config = (*tls::server_config(cert, key).unwrap()).clone();
config.alpn_protocols = vec![b"h3".to_vec()];
let endpoint = quinn::Endpoint::server(
quinn::ServerConfig::with_crypto(Arc::new(QuicServerConfig::try_from(config).unwrap())),
"127.0.0.1:0".parse().unwrap(),
)
.unwrap();
let addr = endpoint.local_addr().unwrap();
tokio::spawn(async move {
while let Some(incoming) = endpoint.accept().await {
tokio::spawn(async move {
let Ok(connection) = incoming.await else { return };
let Ok(mut h3) = h3::server::builder()
.build(h3_quinn::Connection::new(connection))
.await
else {
return;
};
while let Ok(Some(resolver)) = h3.accept().await {
tokio::spawn(async move {
let Ok((_request, mut stream)) = resolver.resolve_request().await else {
return;
};
let answer = "the service spoke first";
let response = http::Response::builder()
.status(http::StatusCode::OK)
.header("content-length", answer.len().to_string())
.body(())
.unwrap();
let _ = stream.send_response(response).await;
let _ = stream.send_data(Bytes::from(answer)).await;
let _ = stream.finish().await;
});
}
});
}
});
addr
}
/// One request that ends with a trailer section, and what came back.
async fn request_with_trailer(
addr: SocketAddr,
body: &[u8],
declare_length: bool,
trailer: &str,
) -> Result<String, String> {
let mut config = tls::quic_client_config().unwrap();
config.alpn_protocols = vec![b"h3".to_vec()];
let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(
QuicClientConfig::try_from(config).unwrap(),
)));
let connection = endpoint
.connect(addr, "localhost")
.map_err(|e| e.to_string())?
.await
.map_err(|e| e.to_string())?;
let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection))
.await
.map_err(|e| e.to_string())?;
let driving = tokio::spawn(async move {
let _ = driver.wait_idle().await;
});
let answer = async {
let mut builder = http::Request::builder()
.method("POST")
.uri("https://localhost/up");
if declare_length {
builder = builder.header("content-length", body.len().to_string());
}
let outgoing = builder.body(()).map_err(|e| e.to_string())?;
let mut stream = sender.send_request(outgoing).await.map_err(|e| e.to_string())?;
if !body.is_empty() {
stream
.send_data(Bytes::copy_from_slice(body))
.await
.map_err(|e| e.to_string())?;
}
let mut trailers = http::HeaderMap::new();
trailers.insert("x-note", http::HeaderValue::from_str(trailer).unwrap());
stream.send_trailers(trailers).await.map_err(|e| e.to_string())?;
let _ = stream.finish().await;
stream.recv_response().await.map_err(|e| e.to_string())?;
let mut got = Vec::new();
while let Some(mut chunk) = stream.recv_data().await.map_err(|e| e.to_string())? {
let len = chunk.remaining();
got.extend_from_slice(&chunk.copy_to_bytes(len));
}
Ok(String::from_utf8_lossy(&got).into_owned())
}
.await;
driving.abort();
answer
}
/// A trailer section on a message whose body was never framed by a declared length is
/// shown to the chain, which is the only reason it may then be forwarded.
///
/// The bodiless case is the one that matters most and the one that used to slip past: a
/// message of HEADERS and TRAILERS and nothing in between — which is exactly a gRPC
/// trailers-only answer — had no chunked body to hang a trailer section off, so the
/// rendering ended at the head and the trailers travelled to the far side unread. They
/// decide the framing now: in HTTP/1.1 a message with trailers *is* a chunked one.
#[tokio::test]
async fn a_trailer_section_is_shown_to_the_chain() {
let (cert, key) = self_signed();
let upstream = spawn_h3_echo(&cert, &key).await;
let addr = spawn_relay(upstream, "block:SNEAKY").await;
for (body, declared, what) in [
(&b""[..], false, "no body at all"),
(&b"harmless"[..], false, "a body of undeclared length"),
] {
let refused = request_with_trailer(addr, body, declared, "SNEAKY").await;
assert!(
refused.is_err(),
"a needle in the trailers of a message with {what} was not seen: {refused:?}"
);
}
}
/// And where the rendering cannot carry one, it is dropped rather than forwarded unread.
///
/// A declared length means the HTTP/1.1 view of the message ends where the header said it
/// would, so there is nowhere to put a trailer section — and forwarding a piece of a
/// message no filter was shown is the one thing this engine does not do anywhere else.
/// Dropping is what an intermediary is allowed to do with a trailer section it cannot
/// carry; the exchange itself is untouched.
#[tokio::test]
async fn a_trailer_the_rendering_cannot_carry_is_not_forwarded() {
let (cert, key) = self_signed();
let (upstream, seen) = spawn_h3_recorder(&cert, &key).await;
let addr = spawn_relay(upstream, "").await;
let answer = request_with_trailer(addr, b"harmless", true, "SNEAKY").await;
assert_eq!(answer.as_deref(), Ok("recorded"), "the exchange was disturbed");
assert!(
seen.lock().unwrap().is_empty(),
"a trailer section no filter was shown reached the service: {:?}",
seen.lock().unwrap()
);
// Without the declared length the same trailers are renderable, so they are shown and
// then carried: the rule is what the chain saw, not a dislike of trailers.
let (upstream, seen) = spawn_h3_recorder(&cert, &key).await;
let addr = spawn_relay(upstream, "").await;
let answer = request_with_trailer(addr, b"harmless", false, "SNEAKY").await;
assert_eq!(answer.as_deref(), Ok("recorded"));
assert_eq!(
seen.lock().unwrap().as_slice(),
["x-note=SNEAKY"],
"a trailer section the chain accepted was dropped anyway"
);
}
/// An exchange the service is supposed to speak first in is not held waiting for a body.
///
/// The head is held only until the framing is known, and a client that sends a head and
/// then waits to be answered — a bidirectional RPC, gRPC's among them — never sends one.
/// Holding it there deadlocked the exchange: the proxy waiting for a body, the client
/// waiting for the answer that would tell it what to send.
#[tokio::test]
async fn a_service_that_speaks_first_is_not_waited_out() {
let (cert, key) = self_signed();
let upstream = spawn_h3_eager(&cert, &key).await;
let addr = spawn_relay(upstream, "").await;
let mut config = tls::quic_client_config().unwrap();
config.alpn_protocols = vec![b"h3".to_vec()];
let mut endpoint = quinn::Endpoint::client("127.0.0.1:0".parse().unwrap()).unwrap();
endpoint.set_default_client_config(quinn::ClientConfig::new(Arc::new(
QuicClientConfig::try_from(config).unwrap(),
)));
let connection = endpoint.connect(addr, "localhost").unwrap().await.unwrap();
let (mut driver, mut sender) = h3::client::new(h3_quinn::Connection::new(connection))
.await
.unwrap();
let driving = tokio::spawn(async move {
let _ = driver.wait_idle().await;
});
let outgoing = http::Request::builder()
.method("POST")
.uri("https://localhost/stream")
.body(())
.unwrap();
let mut stream = sender.send_request(outgoing).await.unwrap();
// Deliberately no body and no finish: this client is waiting to be spoken to.
let answered = tokio::time::timeout(Duration::from_secs(5), stream.recv_response()).await;
driving.abort();
assert!(
answered.is_ok(),
"the head never reached a service that answers without waiting for a body"
);
assert_eq!(answered.unwrap().unwrap().status(), http::StatusCode::OK);
}
// --- a service that speaks HTTP/1.1, behind an HTTP/3 edge ----------------------------
//
// The one place in this engine where what leaves is not the version that arrived. It is
// the operator's choice (`FGEX_PROXY_UPSTREAM`) and it exists because most of the web is
// an HTTP/1.1 service that will never speak QUIC: firegex terminates HTTP/3 in front of
// it and sends on the rendering the filters were already being shown.
#[tokio::test]
async fn an_http1_service_answers_an_http3_client() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let answer = request(addr, "GET", "/hello", None, false).await.unwrap();
assert_eq!(answer, "you asked for /hello and sent 0 bytes: ");
}
/// A body, declared — which is the framing that travels as a `content-length` and is
/// read back by the far end as one.
#[tokio::test]
async fn a_declared_body_reaches_an_http1_service() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), true)
.await
.unwrap();
assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes");
}
/// And one that declared nothing, which is chunked in HTTP/1.1 — the framing the chain
/// is shown, so it had better be the framing the service is sent.
#[tokio::test]
async fn an_undeclared_body_reaches_an_http1_service_chunked() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "", Onward::Plain).await;
let answer = request(addr, "POST", "/upload", Some(b"twelve bytes"), false)
.await
.unwrap();
assert_eq!(answer, "you asked for /upload and sent 12 bytes: twelve bytes");
}
/// The promise that makes the whole thing worth having: one filter file, and it does not
/// care which version either end speaks. The same pattern that blocks this request
/// against an HTTP/3 service blocks it against an HTTP/1.1 one.
#[tokio::test]
async fn a_pattern_matches_with_an_http1_service_behind() {
let upstream = spawn_h1_echo().await;
let addr = spawn_relay_to(upstream, "block:GET /admin", Onward::Plain).await;
assert!(
request(addr, "GET", "/admin", None, false).await.is_err(),
"the request reached an HTTP/1.1 service that a pattern should have refused"
);
let answer = request(addr, "GET", "/public", None, false).await.unwrap();
assert_eq!(answer, "you asked for /public and sent 0 bytes: ");
}