diff --git a/.changesets/fix_caroline_rh_1314.md b/.changesets/fix_caroline_rh_1314.md new file mode 100644 index 0000000000..7f070c2dbf --- /dev/null +++ b/.changesets/fix_caroline_rh_1314.md @@ -0,0 +1,12 @@ +### Fix `http2only` not using h2c for cleartext connections ([PR #9018](https://github.com/apollographql/router/pull/9018)) + +`hyper` does not support [upgrading cleartext connections from HTTP/1.1 to HTTP/2](https://github.com/hyperium/hyper/issues/2411). To use HTTP/2 without TLS, clients must use 'prior knowledge' — connecting with the HTTP/2 preface directly. This is what `experimental_http2: http2only` is for, but previously HTTP/1 was always enabled in the connector, causing the client to fall back to HTTP/1.1 regardless. This fix applies to all outbound HTTP connections: subgraphs, connectors, and coprocessors. + +| `experimental_http2` | TLS | protocol used | +|----------------------|-----|-----------------------------------------------| +| `disable` | yes | HTTP/1.1 | +| `disable` | no | HTTP/1.1 | +| `enable` | yes | HTTP/2 (if server supports it), else HTTP/1.1 | +| `enable` | no | HTTP/1.1 | +| `http2only` | yes | HTTP/2 | +| `http2only` | no | HTTP/2 (h2c — cleartext prior knowledge) | diff --git a/apollo-router/src/services/http/service.rs b/apollo-router/src/services/http/service.rs index cb2c90db10..a901f1f819 100644 --- a/apollo-router/src/services/http/service.rs +++ b/apollo-router/src/services/http/service.rs @@ -136,16 +136,18 @@ impl HttpClientService { let builder = hyper_rustls::HttpsConnectorBuilder::new() .with_tls_config(tls_config) - .https_or_http() - .enable_http1(); + .https_or_http(); let pool_idle_timeout = client_config.pool_idle_timeout; let http2 = client_config.experimental_http2.unwrap_or_default(); - let connector = if http2 != Http2Config::Disable { - builder.enable_http2().wrap_connector(http_connector) - } else { - builder.wrap_connector(http_connector) + let connector = match http2 { + Http2Config::Enable => builder + .enable_http1() + .enable_http2() + .wrap_connector(http_connector), + Http2Config::Disable => builder.enable_http1().wrap_connector(http_connector), + Http2Config::Http2Only => builder.enable_http2().wrap_connector(http_connector), }; let http_client = @@ -309,6 +311,19 @@ pub(crate) fn generate_tls_client_config( }) } +#[cfg(test)] +impl HttpClientService { + pub(crate) fn from_client_config( + client_config: crate::configuration::shared::Client, + ) -> Result { + // No TLS config - provide empty root store + let tls_root_store = RootCertStore::empty(); + let tls_client_config = generate_tls_client_config(tls_root_store, None)?; + + HttpClientService::new("test".to_string(), tls_client_config, client_config) + } +} + impl tower::Service for HttpClientService { type Response = HttpResponse; type Error = BoxError; diff --git a/apollo-router/src/services/http/tests.rs b/apollo-router/src/services/http/tests.rs index 2b085c92c9..f4f4e7d00e 100644 --- a/apollo-router/src/services/http/tests.rs +++ b/apollo-router/src/services/http/tests.rs @@ -568,45 +568,47 @@ mod tls { mod h2c_cleartext { use super::*; + use crate::configuration::shared::Client; // Starts a local server that responds with a default GraphQL response over plain HTTP. async fn emulate_h2c_server(listener: TcpListener) { - async fn handle(_request: http::Request) -> Result, Infallible> { - Ok(http::Response::builder() - .header(CONTENT_TYPE, APPLICATION_JSON.essence_str()) - .status(StatusCode::OK) - .body( - serde_json::to_string(&Response { + async fn handle(request: http::Request) -> Result, Infallible> { + let response_builder = + http::Response::builder().header(CONTENT_TYPE, APPLICATION_JSON.essence_str()); + + let response = match request.version() { + Version::HTTP_2 => { + let response_body = serde_json::to_string(&Response { data: Some(Value::default()), ..Response::default() - }) - .expect("always valid") - .into(), - ) - .unwrap()) + }); + response_builder + .status(StatusCode::OK) + .body(response_body.unwrap().into()) + } + Version::HTTP_11 => response_builder + .status(StatusCode::HTTP_VERSION_NOT_SUPPORTED) + .body(Body::empty()), + version => panic!("unexpected version {version:?}"), + }; + + Ok(response.unwrap()) } - // XXX(@goto-bus-stop): ideally this server would *only* support HTTP 2 and not HTTP 1 serve(listener, handle).await.unwrap(); } #[tokio::test(flavor = "multi_thread")] - async fn test_subgraph_h2c() { + async fn test_subgraph_h2c_works_with_http2only() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let socket_addr = listener.local_addr().unwrap(); tokio::task::spawn(emulate_h2c_server(listener)); - let subgraph_service = HttpClientService::test_new( - "test", - rustls::ClientConfig::builder() - .with_native_roots() - .expect("read native TLS root certificates") - .with_no_client_auth(), - crate::configuration::shared::Client::builder() - .experimental_http2(Http2Config::Http2Only) - .build(), - ) - .expect("can create a HttpService"); + let client_config = Client::builder() + .experimental_http2(Http2Config::Http2Only) + .build(); + let subgraph_service = + HttpClientService::from_client_config(client_config).expect("can create a HttpService"); let url = Uri::from_str(&format!("http://{socket_addr}")).unwrap(); let response = send_request( @@ -615,8 +617,38 @@ mod h2c_cleartext { r#"{"query":"{ me { name username } }"#, ) .await; + assert_response_body(response, r#"{"data":null}"#).await; } + + #[tokio::test(flavor = "multi_thread")] + async fn test_subgraph_h2c_not_used_with_enable() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let socket_addr = listener.local_addr().unwrap(); + tokio::task::spawn(emulate_h2c_server(listener)); + + let client_config = Client::builder() + .experimental_http2(Http2Config::Enable) + .build(); + let subgraph_service = + HttpClientService::from_client_config(client_config).expect("can create a HttpService"); + + let url = Uri::from_str(&format!("http://{socket_addr}")).unwrap(); + let response = send_request( + subgraph_service, + url, + r#"{"query":"{ me { name username } }"#, + ) + .await; + + // h2c only works with `Http2Config::Http2Only` - hyper only supports HTTP/2 with TLS or + // with 'prior knowledge' + // https://github.com/hyperium/hyper/issues/2411 + assert_eq!( + response.http_response.status(), + StatusCode::HTTP_VERSION_NOT_SUPPORTED + ); + } } mod compressed_req_res {