HTTP2 Support (#2363)

Adds HttpProtocol.H2 if server.http2.enabled=true.

Deprecates defaultConfigurationType as it is no longer used.

Updates to use new HttpClient ProtocolSslContextSpec for configuring ssl.

Fixes gh-7
Fixes gh-2206
This commit is contained in:
Spencer Gibb
2021-09-21 18:17:31 -04:00
committed by GitHub
parent e553925df2
commit 9d178e2217
12 changed files with 455 additions and 46 deletions

View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>http2</artifactId>
<packaging>jar</packaging>
<name>Spring Cloud Gateway HTTP2 Integration Test</name>
<description>Spring Cloud Gateway HTTP2 Integration Test</description>
<properties>
</properties>
<parent>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-integration-tests</artifactId>
<version>3.1.0-SNAPSHOT</version>
<relativePath>..</relativePath> <!-- lookup parent from repository -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-tcnative-boringssl-static</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<artifactId>maven-deploy-plugin</artifactId>
<configuration>
<skip>true</skip>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,86 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.gateway.tests.http2;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClient;
import org.springframework.cloud.loadbalancer.annotation.LoadBalancerClients;
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import org.springframework.cloud.loadbalancer.support.ServiceInstanceListSuppliers;
import org.springframework.context.annotation.Bean;
import org.springframework.core.env.Environment;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Spencer Gibb
*/
// curl -i --insecure https://localhost:8443/hello
@SpringBootConfiguration
@EnableAutoConfiguration
@RestController
@LoadBalancerClients({
@LoadBalancerClient(name = "myservice", configuration = Http2Application.MyServiceConf.class),
@LoadBalancerClient(name = "nossl", configuration = Http2Application.NosslServiceConf.class)
})
public class Http2Application {
@GetMapping("hello")
public String hello() {
return "Hello";
}
@Bean
public RouteLocator myRouteLocator(RouteLocatorBuilder builder) {
return builder.routes().route(r -> r.path("/myprefix/**").filters(f -> f.stripPrefix(1)).uri("lb://myservice"))
.route(r -> r.path("/nossl/**").filters(f -> f.stripPrefix(1)).uri("lb://nossl"))
.route(r -> r.path("/neverssl/**").filters(f -> f.stripPrefix(1)).uri("http://neverssl.com"))
.route(r -> r.path("/httpbin/**").uri("https://nghttp2.org")).build();
}
public static void main(String[] args) {
SpringApplication.run(Http2Application.class, args);
}
static class MyServiceConf {
@Bean
public ServiceInstanceListSupplier staticServiceInstanceListSupplier(Environment env) {
Integer port = env.getProperty("local.server.port", Integer.class, 8443);
return ServiceInstanceListSuppliers.from("myservice",
new DefaultServiceInstance("myservice-1", "myservice", "localhost", port, true));
}
}
static class NosslServiceConf {
@Bean
public ServiceInstanceListSupplier noSslStaticServiceInstanceListSupplier() {
int port = Integer.parseInt(System.getProperty("nossl.port", "8080"));
return ServiceInstanceListSuppliers.from("nossl",
new DefaultServiceInstance("nossl-1", "nossl", "localhost", port, false));
}
}
}

View File

@@ -0,0 +1,22 @@
logging:
level:
org.springframework.cloud.gateway: TRACE
reactor.netty.http.client: DEBUG
server:
ssl:
key-store: classpath:sample.jks
key-store-password: secret
key-password: password
http2:
enabled: true
spring:
cloud:
gateway:
# httpserver:
# wiretap: true
httpclient:
wiretap: true
ssl:
use-insecure-trust-manager: true

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.gateway.tests.http2;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import io.netty.buffer.ByteBufAllocator;
import io.netty.handler.codec.http.HttpMethod;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.netty.http.Http2SslContextSpec;
import reactor.netty.http.HttpProtocol;
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.HttpClientResponse;
import reactor.netty.resources.ConnectionProvider;
import reactor.test.StepVerifier;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.NettyDataBufferFactory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
/**
* @author Spencer Gibb
*/
@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
public class Http2ApplicationTests {
@LocalServerPort
int port;
@Test
public void http2Works(CapturedOutput output) {
String uri = "https://localhost:" + port + "/myprefix/hello";
String expected = "Hello";
assertResponse(uri, expected);
Assertions.assertThat(output).contains("Negotiated application-level protocol [h2]", "PRI * HTTP/2.0");
}
public static void assertResponse(String uri, String expected ) {
Flux<HttpClientResponse> responseFlux = getHttpClient().request(HttpMethod.GET)
.uri(uri)
.send(Mono.empty())
.response((res, byteBufFlux) -> {
assertThat(res.status()).isEqualTo(HttpResponseStatus.OK);
NettyDataBufferFactory bufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT);
return DataBufferUtils.join(byteBufFlux.map(bufferFactory::wrap))
.map(dataBuffer -> dataBuffer.toString(StandardCharsets.UTF_8))
.map(s -> {
assertThat(s).isEqualTo(expected);
return res;
});
});
StepVerifier.create(responseFlux).expectNextCount(1).expectComplete().verify();
}
static HttpClient getHttpClient() {
return HttpClient.create(ConnectionProvider.builder("test").maxConnections(100)
.pendingAcquireTimeout(Duration.ofMillis(0))
.pendingAcquireMaxCount(-1).build())
.protocol(HttpProtocol.HTTP11, HttpProtocol.H2)
.secure(sslContextSpec -> {
Http2SslContextSpec clientSslCtxt =
Http2SslContextSpec.forClient()
.configure(builder -> builder.trustManager(InsecureTrustManagerFactory.INSTANCE));
sslContextSpec.sslContext(clientSslCtxt);
});
}
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.gateway.tests.http2.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Configuration
@EnableAutoConfiguration
public class NosslConfiguration {
@GetMapping
public String home() {
return "nossl";
}
}

View File

@@ -0,0 +1,65 @@
/*
* Copyright 2013-2021 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.gateway.tests.http2.config;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.system.CapturedOutput;
import org.springframework.boot.test.system.OutputCaptureExtension;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.cloud.gateway.tests.http2.Http2Application;
import org.springframework.util.SocketUtils;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import static org.springframework.cloud.gateway.tests.http2.Http2ApplicationTests.assertResponse;
/**
* @author Spencer Gibb
*/
@ExtendWith(OutputCaptureExtension.class)
@SpringBootTest(classes = Http2Application.class, webEnvironment = WebEnvironment.RANDOM_PORT)
public class NosslTests {
@LocalServerPort
int port;
@BeforeAll
static void beforeAll() {
int noSslPort = SocketUtils.findAvailableTcpPort();
System.setProperty("nossl.port", String.valueOf(noSslPort));
}
@AfterAll
static void afterAll() {
System.clearProperty("nossl.port");
}
@Test
public void http2Works(CapturedOutput output) {
String uri = "https://localhost:" + port + "/myprefix/hello";
String expected = "Hello";
assertResponse(uri, expected);
Assertions.assertThat(output).contains("Negotiated application-level protocol [h2]", "PRI * HTTP/2.0");
}
}

View File

@@ -0,0 +1,13 @@
server:
ssl:
enabled: false
key-store: ~
key-store-password: ~
key-password: ~
http2:
enabled: false
spring:
cloud:
gateway:
enabled: false

View File

@@ -21,6 +21,7 @@
</parent>
<modules>
<module>http2</module>
<module>mvc-failure-analyzer</module>
</modules>

View File

@@ -23,15 +23,18 @@ import java.util.Set;
import java.util.function.Supplier;
import io.netty.channel.ChannelOption;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import reactor.core.publisher.Flux;
import reactor.netty.http.Http11SslContextSpec;
import reactor.netty.http.Http2SslContextSpec;
import reactor.netty.http.HttpProtocol;
import reactor.netty.http.client.HttpClient;
import reactor.netty.http.client.WebsocketClientSpec;
import reactor.netty.http.server.WebsocketServerSpec;
import reactor.netty.resources.ConnectionProvider;
import reactor.netty.tcp.SslProvider.ProtocolSslContextSpec;
import reactor.netty.transport.ProxyProvider;
import org.springframework.beans.factory.BeanFactory;
@@ -641,7 +644,8 @@ public class GatewayAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public HttpClient gatewayHttpClient(HttpClientProperties properties, List<HttpClientCustomizer> customizers) {
public HttpClient gatewayHttpClient(HttpClientProperties properties, ServerProperties serverProperties,
List<HttpClientCustomizer> customizers) {
// configure pool resources
ConnectionProvider connectionProvider = buildConnectionProvider(properties);
@@ -658,57 +662,66 @@ public class GatewayAutoConfiguration {
spec.maxInitialLineLength((int) properties.getMaxInitialLineLength().toBytes());
}
return spec;
}).tcpConfiguration(tcpClient -> {
if (properties.getConnectTimeout() != null) {
tcpClient = tcpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS,
properties.getConnectTimeout());
}
// configure proxy if proxy host is set.
HttpClientProperties.Proxy proxy = properties.getProxy();
if (StringUtils.hasText(proxy.getHost())) {
tcpClient = tcpClient.proxy(proxySpec -> {
ProxyProvider.Builder builder = proxySpec.type(proxy.getType()).host(proxy.getHost());
PropertyMapper map = PropertyMapper.get();
map.from(proxy::getPort).whenNonNull().to(builder::port);
map.from(proxy::getUsername).whenHasText().to(builder::username);
map.from(proxy::getPassword).whenHasText()
.to(password -> builder.password(s -> password));
map.from(proxy::getNonProxyHostsPattern).whenHasText().to(builder::nonProxyHosts);
});
}
return tcpClient;
});
if (serverProperties.getHttp2().isEnabled()) {
httpClient = httpClient.protocol(HttpProtocol.HTTP11, HttpProtocol.H2);
}
if (properties.getConnectTimeout() != null) {
httpClient = httpClient.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, properties.getConnectTimeout());
}
// configure proxy if proxy host is set.
if (StringUtils.hasText(properties.getProxy().getHost())) {
HttpClientProperties.Proxy proxy = properties.getProxy();
httpClient = httpClient.proxy(proxySpec -> {
ProxyProvider.Builder builder = proxySpec.type(proxy.getType()).host(proxy.getHost());
PropertyMapper map = PropertyMapper.get();
map.from(proxy::getPort).whenNonNull().to(builder::port);
map.from(proxy::getUsername).whenHasText().to(builder::username);
map.from(proxy::getPassword).whenHasText().to(password -> builder.password(s -> password));
map.from(proxy::getNonProxyHostsPattern).whenHasText().to(builder::nonProxyHosts);
});
}
HttpClientProperties.Ssl ssl = properties.getSsl();
if ((ssl.getKeyStore() != null && ssl.getKeyStore().length() > 0)
|| ssl.getTrustedX509CertificatesForTrustManager().length > 0 || ssl.isUseInsecureTrustManager()) {
httpClient = httpClient.secure(sslContextSpec -> {
// configure ssl
SslContextBuilder sslContextBuilder = SslContextBuilder.forClient();
ProtocolSslContextSpec clientSslContext = (serverProperties.getHttp2().isEnabled())
? Http2SslContextSpec.forClient() : Http11SslContextSpec.forClient();
clientSslContext.configure(sslContextBuilder -> {
X509Certificate[] trustedX509Certificates = ssl.getTrustedX509CertificatesForTrustManager();
if (trustedX509Certificates.length > 0) {
sslContextBuilder.trustManager(trustedX509Certificates);
}
else if (ssl.isUseInsecureTrustManager()) {
sslContextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE);
}
X509Certificate[] trustedX509Certificates = ssl.getTrustedX509CertificatesForTrustManager();
if (trustedX509Certificates.length > 0) {
sslContextBuilder = sslContextBuilder.trustManager(trustedX509Certificates);
}
else if (ssl.isUseInsecureTrustManager()) {
sslContextBuilder = sslContextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE);
}
try {
sslContextBuilder.keyManager(ssl.getKeyManagerFactory());
}
catch (Exception e) {
logger.error(e);
}
});
try {
sslContextBuilder = sslContextBuilder.keyManager(ssl.getKeyManagerFactory());
}
catch (Exception e) {
logger.error(e);
}
sslContextSpec.sslContext(sslContextBuilder).defaultConfiguration(ssl.getDefaultConfigurationType())
.handshakeTimeout(ssl.getHandshakeTimeout())
sslContextSpec.sslContext(clientSslContext).handshakeTimeout(ssl.getHandshakeTimeout())
.closeNotifyFlushTimeout(ssl.getCloseNotifyFlushTimeout())
.closeNotifyReadTimeout(ssl.getCloseNotifyReadTimeout());
});
}
else if (serverProperties.getHttp2().isEnabled()) {
httpClient = httpClient.secure(sslContextSpec -> {
Http2SslContextSpec clientSslCtxt = Http2SslContextSpec.forClient()
.configure(builder -> builder.trustManager(InsecureTrustManagerFactory.INSTANCE));
sslContextSpec.sslContext(clientSslCtxt).handshakeTimeout(ssl.getHandshakeTimeout())
.closeNotifyFlushTimeout(ssl.getCloseNotifyFlushTimeout())
.closeNotifyReadTimeout(ssl.getCloseNotifyReadTimeout());
});

View File

@@ -403,6 +403,7 @@ public class HttpClientProperties {
private Duration closeNotifyReadTimeout = Duration.ZERO;
/** The default ssl configuration type. Defaults to TCP. */
@Deprecated
private SslProvider.DefaultConfigurationType defaultConfigurationType = SslProvider.DefaultConfigurationType.TCP;
/** Keystore path for Netty HttpClient. */
@@ -568,10 +569,12 @@ public class HttpClientProperties {
this.closeNotifyReadTimeout = closeNotifyReadTimeout;
}
@Deprecated
public SslProvider.DefaultConfigurationType getDefaultConfigurationType() {
return defaultConfigurationType;
}
@Deprecated
public void setDefaultConfigurationType(SslProvider.DefaultConfigurationType defaultConfigurationType) {
this.defaultConfigurationType = defaultConfigurationType;
}

View File

@@ -33,7 +33,9 @@ import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.security.oauth2.client.reactive.ReactiveOAuth2ClientAutoConfiguration;
import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.cloud.gateway.actuate.GatewayControllerEndpoint;
import org.springframework.cloud.gateway.actuate.GatewayLegacyControllerEndpoint;
@@ -68,7 +70,8 @@ public class GatewayAutoConfigurationTests {
public void nettyHttpClientDefaults() {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class,
SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class))
SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class,
ServerPropertiesConfig.class))
.withPropertyValues("debug=true").run(context -> {
assertThat(context).hasSingleBean(HttpClient.class);
assertThat(context).hasBean("gatewayHttpClient");
@@ -94,7 +97,7 @@ public class GatewayAutoConfigurationTests {
new ReactiveWebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, MetricsAutoConfiguration.class,
SimpleMetricsExportAutoConfiguration.class, GatewayAutoConfiguration.class,
HttpClientCustomizedConfig.class))
HttpClientCustomizedConfig.class, ServerPropertiesConfig.class))
.withPropertyValues("spring.cloud.gateway.httpclient.ssl.use-insecure-trust-manager=true",
"spring.cloud.gateway.httpclient.connect-timeout=10",
"spring.cloud.gateway.httpclient.response-timeout=10s",
@@ -228,6 +231,12 @@ public class GatewayAutoConfigurationTests {
assertThat(spec2.protocols()).isNull();
}
@Configuration
@EnableConfigurationProperties(ServerProperties.class)
protected static class ServerPropertiesConfig {
}
@EnableAutoConfiguration
@SpringBootConfiguration
protected static class Config {