Skip to content

Commit d755511

Browse files
committed
HTTPCLIENT-2261 - Enable HTTP/2 CONNECT tunneling for HTTP/2 clients through HTTP/2 proxies
Wire HTTP/2 tunnel establishment into InternalH2ConnPool for tunneled routes by using H2OverH2TunnelSupport to convert an existing proxy HTTP/2 connection into a stream-backed tunnel session
1 parent 797374c commit d755511

25 files changed

Lines changed: 4070 additions & 62 deletions
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.testing.compatibility;
28+
29+
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
30+
import org.apache.hc.client5.testing.compatibility.async.H2OverH2TunnelCompatibilityTest;
31+
import org.apache.hc.core5.http.HttpHost;
32+
import org.apache.hc.core5.http.URIScheme;
33+
import org.junit.jupiter.api.AfterAll;
34+
import org.junit.jupiter.api.BeforeAll;
35+
import org.junit.jupiter.api.DisplayName;
36+
import org.junit.jupiter.api.Nested;
37+
import org.testcontainers.containers.GenericContainer;
38+
import org.testcontainers.containers.Network;
39+
import org.testcontainers.junit.jupiter.Testcontainers;
40+
41+
@Testcontainers(disabledWithoutDocker = true)
42+
class ApacheHTTPDCaddyH2CompatibilityIT {
43+
44+
private static final Network NETWORK = Network.newNetwork();
45+
static final GenericContainer<?> HTTPD_CONTAINER = ContainerImages.apacheHttpD(NETWORK);
46+
static final GenericContainer<?> CADDY = ContainerImages.caddyH2Proxy(NETWORK);
47+
48+
@BeforeAll
49+
static void startContainers() {
50+
HTTPD_CONTAINER.start();
51+
CADDY.start();
52+
}
53+
54+
static HttpHost targetInternalTlsHost() {
55+
return new HttpHost(URIScheme.HTTPS.id, ContainerImages.WEB_SERVER, ContainerImages.HTTPS_PORT);
56+
}
57+
58+
static HttpHost h2ProxyContainerHost() {
59+
return new HttpHost(URIScheme.HTTPS.id, CADDY.getHost(), CADDY.getMappedPort(ContainerImages.H2_PROXY_PORT));
60+
}
61+
62+
static HttpHost h2ProxyPwProtectedContainerHost() {
63+
return new HttpHost(URIScheme.HTTPS.id, CADDY.getHost(), CADDY.getMappedPort(ContainerImages.H2_PROXY_PW_PROTECTED_PORT));
64+
}
65+
66+
@AfterAll
67+
static void cleanup() {
68+
CADDY.close();
69+
HTTPD_CONTAINER.close();
70+
NETWORK.close();
71+
}
72+
73+
@Nested
74+
@DisplayName("H2 client: TLS, connection via H2 proxy (H2-over-H2 tunnel)")
75+
class AsyncViaH2Proxy extends H2OverH2TunnelCompatibilityTest {
76+
77+
public AsyncViaH2Proxy() throws Exception {
78+
super(targetInternalTlsHost(), h2ProxyContainerHost(), null);
79+
}
80+
81+
}
82+
83+
@Nested
84+
@DisplayName("H2 client: TLS, connection via password protected H2 proxy (H2-over-H2 tunnel)")
85+
class AsyncViaPwProtectedH2Proxy extends H2OverH2TunnelCompatibilityTest {
86+
87+
public AsyncViaPwProtectedH2Proxy() throws Exception {
88+
super(
89+
targetInternalTlsHost(),
90+
h2ProxyPwProtectedContainerHost(),
91+
new UsernamePasswordCredentials("caddy", "nopassword".toCharArray()));
92+
}
93+
94+
}
95+
96+
}

httpclient5-testing/src/test/java/org/apache/hc/client5/testing/compatibility/ContainerImages.java

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public final class ContainerImages {
4848
public final static String PROXY = "test-proxy";
4949
public final static int PROXY_PORT = 8888;
5050
public final static int PROXY_PW_PROTECTED_PORT = 8889;
51+
public final static String H2_PROXY = "test-h2-proxy";
52+
public final static int H2_PROXY_PORT = 8443;
53+
public final static int H2_PROXY_PW_PROTECTED_PORT = 8444;
5154

5255
static final byte[] BYTES = "0123456789ABCDEF".getBytes(StandardCharsets.UTF_8);
5356

@@ -116,4 +119,31 @@ public static GenericContainer<?> squid(final Network network) {
116119

117120
}
118121

122+
/**
123+
* Caddy 2 with the {@code forwardproxy} plugin. Provides a TLS-terminated HTTP/2
124+
* forward proxy that supports the CONNECT method on two ports: an open one and a
125+
* Basic-auth protected one (user {@code caddy}, password {@code nopassword}).
126+
*/
127+
public static GenericContainer<?> caddyH2Proxy(final Network network) {
128+
return new GenericContainer<>(new ImageFromDockerfile()
129+
.withFileFromClasspath("Caddyfile", "docker/caddy/Caddyfile")
130+
.withFileFromClasspath("server-cert.pem", "docker/server-cert.pem")
131+
.withFileFromClasspath("server-key.pem", "docker/server-key.pem")
132+
.withFileFromString("Dockerfile",
133+
"FROM caddy:2-builder AS builder\n"
134+
+ "RUN xcaddy build --with github.com/caddyserver/forwardproxy@caddy2\n"
135+
+ "\n"
136+
+ "FROM caddy:2\n"
137+
+ "COPY --from=builder /usr/bin/caddy /usr/bin/caddy\n"
138+
+ "COPY Caddyfile /etc/caddy/Caddyfile\n"
139+
+ "COPY server-cert.pem /etc/caddy/server-cert.pem\n"
140+
+ "COPY server-key.pem /etc/caddy/server-key.pem\n"
141+
+ "EXPOSE " + H2_PROXY_PORT + " " + H2_PROXY_PW_PROTECTED_PORT + "\n"
142+
+ "ENTRYPOINT [\"caddy\", \"run\", \"--config\", \"/etc/caddy/Caddyfile\", \"--adapter\", \"caddyfile\"]\n"))
143+
.withNetwork(network)
144+
.withNetworkAliases(H2_PROXY)
145+
.withLogConsumer(new Slf4jLogConsumer(LOG))
146+
.withExposedPorts(H2_PROXY_PORT, H2_PROXY_PW_PROTECTED_PORT);
147+
}
148+
119149
}
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
/*
2+
* ====================================================================
3+
* Licensed to the Apache Software Foundation (ASF) under one
4+
* or more contributor license agreements. See the NOTICE file
5+
* distributed with this work for additional information
6+
* regarding copyright ownership. The ASF licenses this file
7+
* to you under the Apache License, Version 2.0 (the
8+
* "License"); you may not use this file except in compliance
9+
* with the License. You may obtain a copy of the License at
10+
*
11+
* http://www.apache.org/licenses/LICENSE-2.0
12+
*
13+
* Unless required by applicable law or agreed to in writing,
14+
* software distributed under the License is distributed on an
15+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16+
* KIND, either express or implied. See the License for the
17+
* specific language governing permissions and limitations
18+
* under the License.
19+
* ====================================================================
20+
*
21+
* This software consists of voluntary contributions made by many
22+
* individuals on behalf of the Apache Software Foundation. For more
23+
* information on the Apache Software Foundation, please see
24+
* <http://www.apache.org/>.
25+
*
26+
*/
27+
package org.apache.hc.client5.testing.compatibility.async;
28+
29+
import java.util.Queue;
30+
import java.util.concurrent.ConcurrentLinkedQueue;
31+
import java.util.concurrent.CountDownLatch;
32+
import java.util.concurrent.Future;
33+
34+
import org.apache.hc.client5.http.ContextBuilder;
35+
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
36+
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
37+
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
38+
import org.apache.hc.client5.http.auth.AuthScope;
39+
import org.apache.hc.client5.http.auth.Credentials;
40+
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
41+
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
42+
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
43+
import org.apache.hc.client5.http.protocol.HttpClientContext;
44+
import org.apache.hc.client5.testing.Result;
45+
import org.apache.hc.client5.testing.extension.async.H2AsyncClientResource;
46+
import org.apache.hc.core5.concurrent.FutureCallback;
47+
import org.apache.hc.core5.http.HttpHost;
48+
import org.apache.hc.core5.http.HttpStatus;
49+
import org.apache.hc.core5.http.HttpVersion;
50+
import org.apache.hc.core5.http.RequestNotExecutedException;
51+
import org.apache.hc.core5.util.Timeout;
52+
import org.junit.jupiter.api.Assertions;
53+
import org.junit.jupiter.api.Test;
54+
import org.junit.jupiter.api.extension.RegisterExtension;
55+
56+
public abstract class H2OverH2TunnelCompatibilityTest {
57+
58+
static final Timeout TIMEOUT = Timeout.ofSeconds(30);
59+
static final Timeout LONG_TIMEOUT = Timeout.ofSeconds(60);
60+
61+
private final HttpHost target;
62+
@RegisterExtension
63+
private final H2AsyncClientResource clientResource;
64+
private final BasicCredentialsProvider credentialsProvider;
65+
66+
public H2OverH2TunnelCompatibilityTest(
67+
final HttpHost target,
68+
final HttpHost proxy,
69+
final Credentials proxyCreds) throws Exception {
70+
this.target = target;
71+
this.clientResource = new H2AsyncClientResource(proxy);
72+
this.credentialsProvider = new BasicCredentialsProvider();
73+
if (proxy != null && proxyCreds != null) {
74+
this.credentialsProvider.setCredentials(new AuthScope(proxy), proxyCreds);
75+
}
76+
this.clientResource.configure(builder -> builder.setDefaultCredentialsProvider(credentialsProvider));
77+
}
78+
79+
CloseableHttpAsyncClient client() {
80+
return clientResource.client();
81+
}
82+
83+
HttpClientContext context() {
84+
return ContextBuilder.create()
85+
.useCredentialsProvider(credentialsProvider)
86+
.build();
87+
}
88+
89+
void addCredentials(final AuthScope authScope, final Credentials credentials) {
90+
credentialsProvider.setCredentials(authScope, credentials);
91+
}
92+
93+
@Test
94+
void test_sequential_gets() throws Exception {
95+
final CloseableHttpAsyncClient client = client();
96+
final HttpClientContext context = context();
97+
98+
final String[] requestUris = new String[] {"/111", "/222", "/333"};
99+
for (final String requestUri : requestUris) {
100+
final SimpleHttpRequest httpGet = SimpleRequestBuilder.get()
101+
.setHttpHost(target)
102+
.setPath(requestUri)
103+
.build();
104+
final Future<SimpleHttpResponse> future = client.execute(httpGet, context, null);
105+
final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
106+
Assertions.assertEquals(HttpStatus.SC_OK, response.getCode());
107+
Assertions.assertEquals(HttpVersion.HTTP_2, context.getProtocolVersion());
108+
}
109+
}
110+
111+
@Test
112+
void test_concurrent_gets() throws Exception {
113+
final CloseableHttpAsyncClient client = client();
114+
115+
final String[] requestUris = new String[] {"/111", "/222", "/333"};
116+
final int n = 10;
117+
final Queue<Result<Void>> queue = new ConcurrentLinkedQueue<>();
118+
final CountDownLatch latch = new CountDownLatch(requestUris.length * n);
119+
120+
for (int i = 0; i < n; i++) {
121+
for (final String requestUri : requestUris) {
122+
final SimpleHttpRequest request = SimpleRequestBuilder.get()
123+
.setHttpHost(target)
124+
.setPath(requestUri)
125+
.build();
126+
final HttpClientContext context = context();
127+
client.execute(request, context, new FutureCallback<SimpleHttpResponse>() {
128+
129+
@Override
130+
public void completed(final SimpleHttpResponse response) {
131+
queue.add(new Result<>(request, response, null));
132+
latch.countDown();
133+
}
134+
135+
@Override
136+
public void failed(final Exception ex) {
137+
queue.add(new Result<>(request, ex));
138+
latch.countDown();
139+
}
140+
141+
@Override
142+
public void cancelled() {
143+
queue.add(new Result<>(request, new RequestNotExecutedException()));
144+
latch.countDown();
145+
}
146+
147+
});
148+
}
149+
}
150+
Assertions.assertTrue(latch.await(LONG_TIMEOUT.getDuration(), LONG_TIMEOUT.getTimeUnit()));
151+
Assertions.assertEquals(requestUris.length * n, queue.size());
152+
for (final Result<Void> result : queue) {
153+
if (result.isOK()) {
154+
Assertions.assertEquals(HttpStatus.SC_OK, result.response.getCode());
155+
}
156+
}
157+
}
158+
159+
@Test
160+
void test_auth_failure_wrong_credentials() throws Exception {
161+
addCredentials(
162+
new AuthScope(target),
163+
new UsernamePasswordCredentials("testuser", "wrong password".toCharArray()));
164+
final CloseableHttpAsyncClient client = client();
165+
final HttpClientContext context = context();
166+
167+
final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get()
168+
.setHttpHost(target)
169+
.setPath("/private/big-secret.txt")
170+
.build();
171+
final Future<SimpleHttpResponse> future = client.execute(httpGetSecret, context, null);
172+
final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
173+
Assertions.assertEquals(HttpStatus.SC_UNAUTHORIZED, response.getCode());
174+
Assertions.assertEquals(HttpVersion.HTTP_2, context.getProtocolVersion());
175+
}
176+
177+
@Test
178+
void test_auth_success() throws Exception {
179+
addCredentials(
180+
new AuthScope(target),
181+
new UsernamePasswordCredentials("testuser", "nopassword".toCharArray()));
182+
final CloseableHttpAsyncClient client = client();
183+
final HttpClientContext context = context();
184+
185+
final SimpleHttpRequest httpGetSecret = SimpleRequestBuilder.get()
186+
.setHttpHost(target)
187+
.setPath("/private/big-secret.txt")
188+
.build();
189+
final Future<SimpleHttpResponse> future = client.execute(httpGetSecret, context, null);
190+
final SimpleHttpResponse response = future.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
191+
Assertions.assertEquals(HttpStatus.SC_OK, response.getCode());
192+
Assertions.assertEquals(HttpVersion.HTTP_2, context.getProtocolVersion());
193+
}
194+
195+
}

0 commit comments

Comments
 (0)