From 47e23a35fcaf5498075b1b9bd58a11f50827857f Mon Sep 17 00:00:00 2001 From: Jorge Bescos Gascon Date: Mon, 3 Jul 2023 16:33:48 +0200 Subject: [PATCH] Nima: WebClient Proxy Support (#6441) Nima: WebClient Proxy Support #6006 Signed-off-by: Jorge Bescos Gascon --- .../java/io/helidon/common/http/Http.java | 5 + .../helidon/common/socket/SocketWriter.java | 12 +- .../java/io/helidon/nima/common/tls/Tls.java | 22 + .../http2/webclient/ClientRequestImpl.java | 6 + nima/tests/integration/webclient/pom.xml | 7 +- .../integration/webclient/webclient/pom.xml | 11 +- .../tests/integration/client/HttpProxy.java | 296 +++++++++ .../integration/client/HttpProxyTest.java | 143 +++++ .../integration/client/HttpsProxyTest.java | 166 +++++ .../http1/ClientRequestImplTest.java | 4 + .../webclient/src/test/resources/server.p12 | Bin 0 -> 4133 bytes .../webclient/etc/spotbugs/exclude.xml | 10 +- nima/webclient/webclient/pom.xml | 4 + .../helidon/nima/webclient/ClientRequest.java | 8 + .../java/io/helidon/nima/webclient/Proxy.java | 574 ++++++++++++++++++ .../io/helidon/nima/webclient/UriHelper.java | 3 +- .../webclient/http1/ClientRequestImpl.java | 11 + .../nima/webclient/http1/ConnectionCache.java | 25 +- .../nima/webclient/http1/ConnectionKey.java | 5 +- .../http1/Http1ClientConnection.java | 171 +++++- .../webclient/http1/HttpCallChainBase.java | 5 + .../webclient/http1/HttpCallEntityChain.java | 4 +- .../http1/HttpCallOutputStreamChain.java | 4 +- .../webclient/src/main/java/module-info.java | 8 +- .../nima/webclient/HttpClientTest.java | 5 + .../io/helidon/nima/webclient/ProxyTest.java | 70 +++ 26 files changed, 1520 insertions(+), 59 deletions(-) create mode 100644 nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxy.java create mode 100644 nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxyTest.java create mode 100644 nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpsProxyTest.java create mode 100644 nima/tests/integration/webclient/webclient/src/test/resources/server.p12 create mode 100644 nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/Proxy.java create mode 100644 nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/ProxyTest.java diff --git a/common/http/src/main/java/io/helidon/common/http/Http.java b/common/http/src/main/java/io/helidon/common/http/Http.java index 55024e1c95a..ee931aef813 100644 --- a/common/http/src/main/java/io/helidon/common/http/Http.java +++ b/common/http/src/main/java/io/helidon/common/http/Http.java @@ -191,6 +191,11 @@ public static final class Method { */ public static final Method PATCH = new Method("PATCH", true); + /** + * The HTTP CONNECT method starts two-way communications with the requested resource. It can be used to open a tunnel. + */ + public static final Method CONNECT = new Method("CONNECT", true); + static { // THIS MUST BE AFTER THE LAST CONSTANT MethodHelper.methodsDone(); diff --git a/common/socket/src/main/java/io/helidon/common/socket/SocketWriter.java b/common/socket/src/main/java/io/helidon/common/socket/SocketWriter.java index 100fca6ed82..7d2480c02e3 100644 --- a/common/socket/src/main/java/io/helidon/common/socket/SocketWriter.java +++ b/common/socket/src/main/java/io/helidon/common/socket/SocketWriter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -56,6 +56,16 @@ public static SocketWriter create(ExecutorService executor, } } + /** + * Create a new socket writer. + * + * @param socket socket to write to + * @return a new socket writer + */ + public static SocketWriter create(HelidonSocket socket) { + return new SocketWriterDirect(socket); + } + @Override public void writeNow(BufferData... buffers) { BufferData composite = BufferData.create(buffers); diff --git a/nima/common/tls/src/main/java/io/helidon/nima/common/tls/Tls.java b/nima/common/tls/src/main/java/io/helidon/nima/common/tls/Tls.java index 576a4af78e4..98695ba7360 100644 --- a/nima/common/tls/src/main/java/io/helidon/nima/common/tls/Tls.java +++ b/nima/common/tls/src/main/java/io/helidon/nima/common/tls/Tls.java @@ -18,6 +18,8 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.net.InetSocketAddress; +import java.net.Socket; import java.security.PrivateKey; import java.security.SecureRandom; import java.security.cert.X509Certificate; @@ -196,6 +198,26 @@ public SSLSocket createSocket(String alpnProtocol) { } } + /** + * Create a SSLSocket for the chosen protocol and the given socket. + * + * @param alpnProtocol protocol to use + * @param socket existing socket + * @param address where SSL socket will connect + * @return a new socket ready for TLS communication + */ + public SSLSocket createSocket(String alpnProtocol, Socket socket, InetSocketAddress address) { + try { + SSLSocket sslSocket = (SSLSocket) sslSocketFactory + .createSocket(socket, address.getHostName(), address.getPort(), true); + sslParameters.setApplicationProtocols(new String[] {alpnProtocol}); + sslSocket.setSSLParameters(sslParameters); + return sslSocket; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + /** * SSL context based on the configured values. * diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java index cd8a489ef4b..e0642b37fed 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java @@ -42,6 +42,7 @@ import io.helidon.nima.http2.Http2Headers; import io.helidon.nima.webclient.ClientConnection; import io.helidon.nima.webclient.ClientRequest; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.UriHelper; class ClientRequestImpl implements Http2ClientRequest { @@ -371,4 +372,9 @@ public void accept(HeaderValue httpHeader) { } } } + + @Override + public Http2ClientRequest proxy(Proxy proxy) { + throw new UnsupportedOperationException("Proxy is not supported in HTTP2"); + } } diff --git a/nima/tests/integration/webclient/pom.xml b/nima/tests/integration/webclient/pom.xml index a60f214e68a..bd30275be43 100644 --- a/nima/tests/integration/webclient/pom.xml +++ b/nima/tests/integration/webclient/pom.xml @@ -1,6 +1,5 @@ - + --> 4.0.0 - io.helidon.nima.tests.integration helidon-nima-tests-integration-project @@ -31,7 +27,6 @@ io.helidon.nima.tests.integration.webclient helidon-nima-tests-integration-webclient-project Helidon Níma Tests Integration WebClient Project - pom diff --git a/nima/tests/integration/webclient/webclient/pom.xml b/nima/tests/integration/webclient/webclient/pom.xml index 887b7ac3dcd..f1fbce70400 100644 --- a/nima/tests/integration/webclient/webclient/pom.xml +++ b/nima/tests/integration/webclient/webclient/pom.xml @@ -25,11 +25,7 @@ helidon-nima-tests-integration-webclient - Helidon Níma Tests Integration WebClient - - - etc/spotbugs/exclude.xml - + Helidon Níma Tests Integration Webclient @@ -46,11 +42,6 @@ helidon-nima-testing-junit5-webserver test - - io.helidon.common.testing - helidon-common-testing-http-junit5 - test - org.junit.jupiter junit-jupiter-api diff --git a/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxy.java b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxy.java new file mode 100644 index 00000000000..cb38658b6e0 --- /dev/null +++ b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxy.java @@ -0,0 +1,296 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.nima.tests.integration.client; + +import java.io.IOException; +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.net.MalformedURLException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Arrays; +import java.util.Base64; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class HttpProxy { + + private static final System.Logger LOGGER = System.getLogger(HttpProxy.class.getName()); + // 1 minute + private static final int TIMEOUT = 60000; + private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + private volatile boolean stop = false; + private final int port; + private final String user; + private final String password; + // Starts with -1 because there is one first test connection to verify the HttpProxy is available + private final AtomicInteger counter = new AtomicInteger(-1); + private int connectedPort; + + HttpProxy(int port, String user, String password) { + this.port = port; + this.user = user; + this.password = password; + } + + HttpProxy(int port) { + this(port, null, null); + } + + boolean start() { + executor.submit(() -> { + try (ServerSocket server = new ServerSocket(port)) { + this.connectedPort = server.getLocalPort(); + LOGGER.log(Level.INFO, "Listening connections in port: " + connectedPort); + while (!stop) { + Socket origin = server.accept(); + LOGGER.log(Level.DEBUG, "Open: " + origin); + counter.incrementAndGet(); + origin.setSoTimeout(TIMEOUT); + Socket remote = new Socket(); + remote.setSoTimeout(TIMEOUT); + MiddleCommunicator remoteToOrigin = new MiddleCommunicator(executor, remote, origin, null); + MiddleCommunicator originToRemote = new MiddleCommunicator(executor, origin, remote, remoteToOrigin); + originToRemote.start(); + } + LOGGER.log(Level.INFO, "Shutting down HTTP Proxy server"); + executor.shutdownNow(); + } catch (IOException e) { + LOGGER.log(Level.ERROR, "Error in HTTP Proxy", e); + stop(); + } + }); + // Makes sure that HttpProxy is ready + boolean responding = false; + while (!responding) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(connectedPort), 10000); + responding = true; + } catch (IOException e) {} + } + return responding; + } + + int counter() { + return counter.get(); + } + + boolean stop() { + stop = true; + try { + // Make the server to check stop boolean + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(connectedPort), 10000); + } catch (IOException e) {} + return executor.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + return false; + } + } + + int connectedPort() { + return connectedPort; + } + + private class MiddleCommunicator { + + private static final System.Logger LOGGER = System.getLogger(MiddleCommunicator.class.getName()); + private static final int BUFFER_SIZE = 1024 * 1024; + private static final String HOST = "HOST: "; + private final ExecutorService executor; + private final Socket readerSocket; + private final Socket writerSocket; + private final boolean originToRemote; + private final Reader reader; + private final MiddleCommunicator callback; + + private MiddleCommunicator(ExecutorService executor, Socket readerSocket, Socket writerSocket, MiddleCommunicator callback) { + this.executor = executor; + this.readerSocket = readerSocket; + this.writerSocket = writerSocket; + this.originToRemote = callback != null; + this.reader = originToRemote ? new OriginToRemoteReader() : new RemoteToOriginReader(); + this.callback = callback; + } + + private void start() { + executor.submit(reader); + } + + private void stop(Socket socket, Exception exception) { + if (!socket.isClosed()) { + try { + socket.close(); + if (exception == null) { + LOGGER.log(Level.DEBUG, "Close: " + socket); + } else { + LOGGER.log(Level.DEBUG, "Close: " + socket + ". Reason: " + exception); + } + } catch (IOException e) { + LOGGER.log(Level.ERROR, "Cannot close " + socket + ": " + e.getMessage()); + } + } + } + + private abstract class Reader implements Runnable { + + @Override + public void run() { + // 1 MB + byte[] buffer = new byte[BUFFER_SIZE]; + Exception exception = null; + try { + int read; + OriginInfo originInfo = null; + while ((read = readerSocket.getInputStream().read(buffer)) != -1) { + final int readB = read; + LOGGER.log(Level.DEBUG, readerSocket + " read " + readB + " bytes"); + LOGGER.log(Level.DEBUG, new String(buffer, 0, readB)); + if (originToRemote) { + if (originInfo == null) { + originInfo = getOriginInfo(buffer, read); + LOGGER.log(Level.DEBUG, "Incoming request: " + originInfo); + if (originInfo.respondOrigin()) { + if (authenticate(originInfo)) { + // Respond origin + String response = "HTTP/1.1 200 Connection established\r\n\r\n"; + writerSocket.connect(new InetSocketAddress(originInfo.host, originInfo.port)); + LOGGER.log(Level.DEBUG, "Open: " + writerSocket); + readerSocket.getOutputStream() + .write(response.getBytes()); + // Start listening from origin + callback.start(); + readerSocket.getOutputStream().flush(); + } else { + LOGGER.log(Level.WARNING, "Invalid " + originInfo.user + ":" + originInfo.password); + originInfo = null; + String response = "HTTP/1.1 401 Unauthorized\r\n\r\n"; + readerSocket.getOutputStream().write(response.getBytes()); + readerSocket.getOutputStream().flush(); + readerSocket.close(); + } + } + } else { + writerSocket.getOutputStream().write(buffer, 0, read); + writerSocket.getOutputStream().flush(); + } + } else { + writerSocket.getOutputStream().write(buffer, 0, read); + writerSocket.getOutputStream().flush(); + } + } + } catch (IOException e) { + exception = e; +// LOGGER.log(Level.SEVERE, e.getMessage(), e); + } finally { + stop(readerSocket, exception); + stop(writerSocket, exception); + } + } + } + + private boolean authenticate(OriginInfo originInfo) { + if (HttpProxy.this.user == null) { + return true; + } else { + return HttpProxy.this.user.equals(originInfo.user) + && HttpProxy.this.password.equals(originInfo.password); + } + } + + private OriginInfo getOriginInfo(byte[] buffer, int read) throws MalformedURLException { + byte[] content = Arrays.copyOf(buffer, read); + String req = new String(content); + String[] lines = req.split("\r\n"); + OriginInfo request = new OriginInfo(); + for (String line : lines) { + if (line.startsWith(OriginInfo.CONNECT)) { + request.parseFirstLine(line); + } else if (line.toUpperCase().startsWith(HOST)) { + request.parseHost(line); + } else if (line.toUpperCase().startsWith(OriginInfo.AUTHORIZATION)) { + request.parseAuthorization(line); + } + } + return request; + } + + // Make it easy to understand stacktraces + private class OriginToRemoteReader extends Reader { + @Override + public void run() { + super.run(); + } + } + + private class RemoteToOriginReader extends Reader { + @Override + public void run() { + super.run(); + } + } + + private static class OriginInfo { + private static final String CONNECT = "CONNECT"; + private static final String AUTHORIZATION = "AUTHORIZATION:"; + private String host; + private int port = 80; + private String protocol; + private String method; + private String user; + private String password; + + // CONNECT host:port HTTP/1.1 + private void parseFirstLine(String line) { + String[] parts = line.split(" "); + this.method = parts[0].trim(); + this.protocol = parts[2].trim(); + } + + // Host: host:port + private void parseHost(String line) { + line = line.substring(HOST.length()).trim(); + String[] hostPort = line.split(":"); + this.host = hostPort[0]; + if (hostPort.length > 1) { + this.port = Integer.parseInt(hostPort[1]); + } + } + + // Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + private void parseAuthorization(String line) { + String[] parts = line.split(" "); + String base64 = parts[2]; + String[] userPass = new String(Base64.getDecoder().decode(base64)).split(":"); + user = userPass[0]; + password = userPass[1]; + } + + private boolean respondOrigin() { + return CONNECT.equals(method); + } + + @Override + public String toString() { + return "OriginInfo [host=" + host + ", port=" + port + ", protocol=" + protocol + ", method=" + method + + ", user=" + user + ", password=" + password + "]"; + } + } + } +} diff --git a/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxyTest.java b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxyTest.java new file mode 100644 index 00000000000..e1f05ca74d3 --- /dev/null +++ b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpProxyTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.nima.tests.integration.client; + +import static io.helidon.common.http.Http.Method.GET; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.net.InetSocketAddress; +import java.net.ProxySelector; + +import io.helidon.common.http.Http; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.testing.junit5.webserver.SetUpRoute; +import io.helidon.nima.webclient.Proxy; +import io.helidon.nima.webclient.Proxy.ProxyType; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.webclient.http1.Http1ClientResponse; +import io.helidon.nima.webserver.http.HttpRouting; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@ServerTest +class HttpProxyTest { + + private static final String PROXY_HOST = "localhost"; + private int proxyPort; + private HttpProxy httpProxy; + + private final Http1Client client; + + @SetUpRoute + static void routing(HttpRouting.Builder router) { + router.route(GET, "/get", Routes::get); + } + + @BeforeEach + public void before() { + httpProxy = new HttpProxy(0); + httpProxy.start(); + proxyPort = httpProxy.connectedPort(); + } + + @AfterEach + public void after() { + httpProxy.stop(); + } + + HttpProxyTest(Http1Client client) { + this.client = client; + } + + @Test + void testNoProxy() { + noProxyChecks(); + } + + @Test + void testNoProxyTypeDefaultsToNone() { + noProxyChecks(); + } + + @Test + void testNoHosts() { + Proxy proxy = Proxy.builder().host(PROXY_HOST).port(proxyPort).addNoProxy(PROXY_HOST).build(); + try (Http1ClientResponse response = client.get("/get").proxy(proxy).request()) { + assertThat(response.status(), is(Http.Status.OK_200)); + String entity = response.entity().as(String.class); + assertThat(entity, is("Hello")); + } + assertThat(httpProxy.counter(), is(0)); + } + + @Test + void testNoProxyTypeButHasHost() { + Proxy proxy = Proxy.builder().host(PROXY_HOST).port(proxyPort).build(); + successVerify(proxy); + } + + @Test + void testProxyNoneTypeButHasHost() { + Proxy proxy = Proxy.builder().type(ProxyType.NONE).host(PROXY_HOST).port(proxyPort).build(); + successVerify(proxy); + } + + @Test + void testSimpleProxy() { + Proxy proxy = Proxy.builder().type(ProxyType.HTTP).host(PROXY_HOST).port(proxyPort).build(); + successVerify(proxy); + } + + @Test + void testSystemProxy() { + ProxySelector original = ProxySelector.getDefault(); + try { + ProxySelector.setDefault(ProxySelector.of(new InetSocketAddress(PROXY_HOST, proxyPort))); + Proxy proxy = Proxy.create(); + successVerify(proxy); + } finally { + ProxySelector.setDefault(original); + } + } + + private void successVerify(Proxy proxy) { + try (Http1ClientResponse response = client.get("/get").proxy(proxy).request()) { + assertThat(response.status(), is(Http.Status.OK_200)); + String entity = response.entity().as(String.class); + assertThat(entity, is("Hello")); + } + assertThat(httpProxy.counter(), is(1)); + } + + private void noProxyChecks() { + try (Http1ClientResponse response = client.get("/get").request()) { + assertThat(response.status(), is(Http.Status.OK_200)); + String entity = response.entity().as(String.class); + assertThat(entity, is("Hello")); + } + assertThat(httpProxy.counter(), is(0)); + } + + private static class Routes { + private static String get() { + return "Hello"; + } + } +} diff --git a/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpsProxyTest.java b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpsProxyTest.java new file mode 100644 index 00000000000..bcc2ec8ec2d --- /dev/null +++ b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/tests/integration/client/HttpsProxyTest.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.nima.tests.integration.client; + +import static io.helidon.common.http.Http.Method.GET; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ProxySelector; + +import io.helidon.common.configurable.Resource; +import io.helidon.common.http.Http; +import io.helidon.common.pki.Keys; +import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.testing.junit5.webserver.SetUpRoute; +import io.helidon.nima.testing.junit5.webserver.SetUpServer; +import io.helidon.nima.webclient.Proxy; +import io.helidon.nima.webclient.Proxy.ProxyType; +import io.helidon.nima.webclient.WebClient; +import io.helidon.nima.webclient.http1.Http1Client; +import io.helidon.nima.webclient.http1.Http1ClientResponse; +import io.helidon.nima.webserver.WebServer; +import io.helidon.nima.webserver.WebServerConfig.Builder; +import io.helidon.nima.webserver.http.HttpRouting; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +@ServerTest +class HttpsProxyTest { + + private static final String PROXY_HOST = "localhost"; + private static int PROXY_PORT; + private static HttpProxy httpProxy; + + private final Http1Client client; + + @SetUpServer + static void server(Builder builder) { + Keys privateKeyConfig = Keys.builder() + .keystore(keystore -> keystore + .keystore(Resource.create("server.p12")) + .passphrase("password")) + .build(); + + Tls tls = Tls.builder() + .privateKey(privateKeyConfig.privateKey().get()) + .privateKeyCertChain(privateKeyConfig.certChain()) + .trustAll(true) + .endpointIdentificationAlgorithm(Tls.ENDPOINT_IDENTIFICATION_NONE) + .build(); + + builder.tls(tls); + } + + @SetUpRoute + static void routing(HttpRouting.Builder router) { + router.route(GET, "/get", Routes::get); + } + + @BeforeAll + public static void beforeAll() throws IOException { + httpProxy = new HttpProxy(0); + httpProxy.start(); + PROXY_PORT = httpProxy.connectedPort(); + } + + @AfterAll + public static void afterAll() { + httpProxy.stop(); + } + + HttpsProxyTest(WebServer server) { + int port = server.port(); + + Tls tls = Tls.builder() + .trustAll(true) + .endpointIdentificationAlgorithm(Tls.ENDPOINT_IDENTIFICATION_NONE) + .build(); + + client = WebClient.builder() + .baseUri("https://localhost:" + port) + .tls(tls) + .build(); + } + + @Test + void testNoProxy() { + noProxyChecks(); + } + + @Test + void testNoProxyTypeDefaultsToNone() { + noProxyChecks(); + } + + @Test + void testNoProxyTypeButHasHost() { + Proxy proxy = Proxy.builder().host(PROXY_HOST).port(PROXY_PORT).build(); + successVerify(proxy); + } + + @Test + void testProxyNoneTypeButHasHost() { + Proxy proxy = Proxy.builder().type(ProxyType.NONE).host(PROXY_HOST).port(PROXY_PORT).build(); + successVerify(proxy); + } + + @Test + void testSimpleProxy() { + Proxy proxy = Proxy.builder().type(ProxyType.HTTP).host(PROXY_HOST).port(PROXY_PORT).build(); + successVerify(proxy); + } + + @Test + void testSystemProxy() { + ProxySelector original = ProxySelector.getDefault(); + try { + ProxySelector.setDefault(ProxySelector.of(new InetSocketAddress(PROXY_HOST, PROXY_PORT))); + Proxy proxy = Proxy.create(); + successVerify(proxy); + } finally { + ProxySelector.setDefault(original); + } + } + + private void successVerify(Proxy proxy) { + try (Http1ClientResponse response = client.get("/get").proxy(proxy).request()) { + assertThat(response.status(), is(Http.Status.OK_200)); + String entity = response.entity().as(String.class); + assertThat(entity, is("Hello")); + } + } + + private void noProxyChecks() { + try (Http1ClientResponse response = client.get("/get").request()) { + assertThat(response.status(), is(Http.Status.OK_200)); + String entity = response.entity().as(String.class); + assertThat(entity, is("Hello")); + } + } + + private static class Routes { + private static String get() { + return "Hello"; + } + } +} diff --git a/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/webclient/http1/ClientRequestImplTest.java b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/webclient/http1/ClientRequestImplTest.java index 8781f6fa375..3141f443d30 100644 --- a/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/webclient/http1/ClientRequestImplTest.java +++ b/nima/tests/integration/webclient/webclient/src/test/java/io/helidon/nima/webclient/http1/ClientRequestImplTest.java @@ -223,6 +223,7 @@ void testConnectionQueueDequeue() { // connection will be dequeued if queue is not empty ClientRequestImpl requestImpl = (ClientRequestImpl) request; connectionNow = ConnectionCache.connection(requestImpl.clientConfig(), + null, null, requestImpl.uri(), requestImpl.headers(), @@ -249,6 +250,7 @@ void testConnectionQueueSizeLimit() { Http1ClientRequest request = injectedHttp1client.put("/test"); ClientRequestImpl requestImpl = (ClientRequestImpl) request; connectionList.add(ConnectionCache.connection(requestImpl.clientConfig(), + null, null, requestImpl.uri(), requestImpl.headers(), @@ -269,6 +271,7 @@ void testConnectionQueueSizeLimit() { Http1ClientRequest request = injectedHttp1client.put("/test"); ClientRequestImpl requestImpl = (ClientRequestImpl) request; connection = ConnectionCache.connection(requestImpl.clientConfig(), + null, null, requestImpl.uri(), requestImpl.headers(), @@ -289,6 +292,7 @@ void testConnectionQueueSizeLimit() { Http1ClientRequest request = injectedHttp1client.put("/test"); ClientRequestImpl requestImpl = (ClientRequestImpl) request; ClientConnection connectionNow = ConnectionCache.connection(requestImpl.clientConfig(), + null, null, requestImpl.uri(), requestImpl.headers(), diff --git a/nima/tests/integration/webclient/webclient/src/test/resources/server.p12 b/nima/tests/integration/webclient/webclient/src/test/resources/server.p12 new file mode 100644 index 0000000000000000000000000000000000000000..ff8e4ddfc7fc9907376df9a9fb5d453a466c6196 GIT binary patch literal 4133 zcmY+EcQhLg*T)kwY+9>k(HgCm*wo&uHc@KVUNx#p)sG-brDlzk#;QGPN23U_sZnaC zR#j1}wMX*web4(o&w2j1=brQV-h2PM9~gp(p8`k;Lom)ksBa_nkjL~uC@>$vI0Qm4 z_Wi}(Fa+4~zap?H2m!wT7wi2kEeP%ZZPC#HDe@7Za~J}247&xP`G5TPI2VXTfN24L z92dsPc*Zj?PJcd1XKPtOK?&&odkfTG#4NWoXi#X?tUX=j%hxaKOs%{99O_IITApR-b2_+u8z$_({pQY9E^_?lJ%Lxw)um4Y z-}4W>TyaKDjqM}}SAe&dg!VeC))bT0_r9A1KjddPnUuKfB_uzsCr>@`W|jzm8wBi2 zg=(C()RGx{{q}{Q*NNO`NfbhXNhfvNgjG)=UuV6*rfXjdScE%>O$|(JLRo~IFRRW% zr}yaEYS^Tuo`*AtuhWGtJ#HnT)zEYf^}pIGyRIgjErR?Qfu^(1j~q`k#9&0P1J9@@ z+hmK$nBT1C7pq7p0U(d2`Hy$5*ci2TO??+zigDpIlV(MW*HSbDr(BCjwn^iy=3YG%{5?~1 z^mA6vDK5$6&1ZM_34*mMsFP65k~#!S6(tAfkBZePTezHVEr=amkhsr5i)<11Gae3Bk^T$|$j+p(xtwof(Xr?DzJhNeGCK{Hl z1;xDO3K9fydzRy!0vKMWfQzli*-XXh7!?(~db&$SCNz7Ysi~}Qj>6u>BRGBE>hk>o zA>Jeq>JuvwDWlVb9h`TLxyGaxC1jpc*-a$F+u$wXlPRVh_fcdip7d$I-q~^ z?fYu@fv%z5#p2d!aotq_y`$v^(6ArP zx-9SL&%-b}Ql1h~x=oQw%-#G`i&4h+`%Xd3!zCj-x1a|9^prRoHXtI#m=xW(OERBL zPv=BdZqLjFI*yH_`x?H@HNwpT#K%fevou_WFK>Pj?dlMLK!hm&>@$W4iaf^kdd3ln z?~(?y6qsAWGL3ZOUFMY7e|57l6z|<0bDrk2YA*2%RRS1jTRdRBQy6KvKJ{y9>L#>k zVe_R@KT!{ryp3h5xQ>lIdY#8u9#k!MVfN=?UW`dCQiT`e^eqL1Ly=0~E&(%-jkf`* zUXcv0b@fFxl|xAUNCvd*Wyio#b%{!vFXYU1>%`BTJbW=#*C`yJ%E6JaE`_==&c81! zAq%_mZ&gyWNPu`L01`0%{~%Tg7SL5oFF!Xv2^j?md3k9WSxH%WSr~%G?%zXDNIrtb z=r7Wz00RCtjsGOT|MDv2f4qtj_c{!@r!sE|pJP>kj#x?dQuh9@SECR#ROR4)eD!xU zmF_n!<@W?u(uurVa|N;uO#2=|H+w7BytQ>HQ|Mn<(PdtX=Jo9<$0|6?qa0t^Ef+Jv?(Axl<-+C456ved zR14F?S`FFst?(9SG0(4*s|4aeA0jKukSBwW*0|8l)UfI!+RNx&_J zz2Rb1E<0TAmx|C@^AwfqZOo=Ow|tLZlYCH(Ocy@07fV=?AX1A}sM@So2n-ejNh{za zNdeN7VkVA2ca!ndk`Nr)fR*pgHp!TEt=18Ra$N`q_!d6t(!Q;-S+@@qXrBcC2tP?I zcY78bkD5Y@aZ1tLOoOFg-2?z!&6as`)H5}FfQNWrff#?8`c6OXBkQtzsrFbxc-u{- z%U+3{K=xegE17<_a`svc_Br0Q(KMUyFw++~>qr~xdrcfON?KX5x-cb6)3L#_umt``m+@GX3x#xk76gtdGdFU@;+FUCW*8N4U|VRut3 zA#=yPdh=+=7pIitM}mgP?uxw7ewn7|6JDSl!$h?+v>F>AwbFSj@tS#X>b0uvS&i`> zRpqQ1^oAmfL)E4L73r+7qcU7ZAyF^eu{3YtpzQ}?P0hSy(nYM5bv|d zeoZ{s{UUAEwI7b9?@KIaaU|3O6Vua-BOI@k`u(oWI=vV^_gyq;eTP&q=f+3M<=1>) z<9+}S?FkPM$UL!}Cr=b%*f^6DX=n;l{kcm46ETz;kNr49ySXy)KFaNWo|Z8RT6iL|fI z7`o{Dxz^=Sa&4|ubn)RcGwn_YvMkf^L=<7&BhwDD-e~*xnXSP&1=wZUN_E@ci=C}iX+uXZheqa; zrwmARHL0=K%8NS=#f9TIi)~Lv51*%d^L=Jf>=GesJIa&_E2Q9eBk<_PP1Y~ySWT8I z{wR{5Cya~H{jSg;2CZ3YrxWV!V9n!;K+IVS@^A(OaqCk_=VgCOpTZ*{+$0M4c)d*R z17QO=_jXw$^$k@Dg>iilz)Edw)<*ksVmjSDA2V`XzciGfm>$i$GizT{zNDr)J=`I5 z#%>GrHxH%9-Bfy~U*f9^ojKA_W`lF`m zT2u5w7e3aU^rp||+QhMau98H14r5+6*Osvvh4DLWDgOQ5pK9);@O|+8H5oP5y)pVD zzRAPucT{}O9n#W6MlQLf+^J8b;Uboj#x8!3>AitD!nA{4Du_;@X2;Vb6*Rm z!)>z%a9Jt8`d=pZ!qw>vk=f>*R$nWtiQhBE3q1C6pTsk7q~SLcDXMz#S zCu|Q=1c6!Lf+=@2BLh2p`S_U6-8{}i)<-G}Le|LP7EJN(M1qF&j~M2EtZf(<`pl^T z0_lAsz4NN&03~9IKSaS;4CboG(4!m!uBpn)OL^TI!xOe#=E@!XKtS)j#Z#_chPypbCeM1p|8}V{N)?udQ~g4u2_y-OtCbBlEm@ z=B;BQ+MZi03LuiTU^n4fx8UVJgNEJq#)AdR;# zD=Yn+s18l|A;ir*P@;xz)FW-Gdl^sZ22MO-{>mhE_L-O>4+rNC-f$eHCyQd-c_2E8 z56)%*A|^cJWLli}5?N^X8T`8uw&}pDC9eDaI<%jCPM4S>{iUUk0ko-xJ2Z)Pmu_;p zDDVG>csN>oSMEWhNN58}CFopuPuu+ZK)g*N6rTN%m_mgdGU=Y5?7$hZ6S4&Vh^6ij zKZ;Te%gjZdvA2~6Oesu`GqY^i2fQY~0y6b569&#KZcq);iCGs_V5I{m011&}CL>Zt zN54kE>8k|OvjU3oxNBqD(6ivJ%H|W6OsvIP*MtcheTyTG3RDmtjPN@k9+R<92h1Rz zNp7nYRyVxyT`f_o!13 zROazdppozUl0TfnRtGJb8jqP!CA%c{d7Lx%wJt1$Wt6(`*O6Uw z_nExHrAOA#ou8}BLOi9h953j3I}f>3qvRgmMsij4`!FIr2(?hnL#MT#aD}l()@Wn! zIwrc8$%!QS>Q$&Dz)wKXW#RH&;ku6d;0EE$O!32LHt(}9(#subeacy90=p%`om%M# zPP3|&r?iqDXM@vUA8^OpE_`UQ99#&UG_X1cG@ksC!23|we$FP^QMU0&O-M_XjY|3N z$0!;s)c!te%hfR7Aft2m5GJ$5Bj~y>j6iarDu!pGb7O`+;=N&JtE1I7ot?2ah@z=h zW2K$ZPl4%!zuON?jp_ux)7X81p9@7<=7o)muQ!`VU*(U_GMZtu2O?fDpn4;&s;Q(e zRGDC(^{49^Z4ZPC^eY6YNJjucoEOc70koBr&K)(r+s?@g^Se%SH`l9uWoVU9k8~!7 zSm(Da`qf`K>*l$GR3fX#>*?3KSLGipLS>5eZQT02fellR^9>>##$oNHE}<85p%~Q7 z%_(}1jZoEc^nl$(=_+s4AiZQK^f5uJuh zyx{G`iKeIyY%fRaL#e4KJl~qBe?H-g4xGk|wmuFpt&b6U)UaK7xbX7Trn9x!O|I0d zM0C&$Crky#52J>FMHwh5IKcoA3);!)%GXyMs9+@{1qF^WmEsdjJ%}afv*WKBoMu}7 U@+;F6i_J(6+nRj}N+2@-U-0Veh5!Hn literal 0 HcmV?d00001 diff --git a/nima/webclient/webclient/etc/spotbugs/exclude.xml b/nima/webclient/webclient/etc/spotbugs/exclude.xml index 24d5ddc5ee2..51d89dbeec8 100644 --- a/nima/webclient/webclient/etc/spotbugs/exclude.xml +++ b/nima/webclient/webclient/etc/spotbugs/exclude.xml @@ -1,7 +1,7 @@ + + + diff --git a/nima/webclient/webclient/pom.xml b/nima/webclient/webclient/pom.xml index 2a495cc911f..f34de9090a8 100644 --- a/nima/webclient/webclient/pom.xml +++ b/nima/webclient/webclient/pom.xml @@ -48,6 +48,10 @@ io.helidon.nima.common helidon-nima-common-tls + + io.helidon.common + helidon-common-config + io.helidon.nima.http.media helidon-nima-http-media diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ClientRequest.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ClientRequest.java index 0d7074b31a5..06b8b260864 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ClientRequest.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ClientRequest.java @@ -67,6 +67,14 @@ default B path(String uri) { */ B tls(Tls tls); + /** + * Proxy configuration for this specific request. + * + * @param proxy proxy configuration + * @return updated request + */ + B proxy(Proxy proxy); + /** * Configure URI. * diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/Proxy.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/Proxy.java new file mode 100644 index 00000000000..9df20ee4e62 --- /dev/null +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/Proxy.java @@ -0,0 +1,574 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.nima.webclient; + +import java.lang.System.Logger.Level; +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedList; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.common.config.Config; +import io.helidon.common.configurable.LruCache; +import io.helidon.config.metadata.Configured; +import io.helidon.config.metadata.ConfiguredOption; + +/** + * A definition of a proxy server to use for outgoing requests. + */ +public class Proxy { + private static final System.Logger LOGGER = System.getLogger(Proxy.class.getName()); + + /** + * No proxy instance. + */ + private static final Proxy NO_PROXY = new Proxy(builder().type(ProxyType.NONE)); + private static final Proxy SYSTEM_PROXY = new Proxy(builder().type(ProxyType.SYSTEM)); + + private static final Pattern PORT_PATTERN = Pattern.compile(".*:(\\d+)"); + private static final Pattern IP_V4 = Pattern.compile("^(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\." + + "(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}$"); + private static final Pattern IP_V6_IDENTIFIER = Pattern.compile("^\\[(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}]$"); + private static final Pattern IP_V6_HEX_IDENTIFIER = Pattern + .compile("^\\[((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)]$"); + private static final Pattern IP_V6_HOST = Pattern.compile("^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$"); + private static final Pattern IP_V6_HEX_HOST = Pattern + .compile("^((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)::((?:[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4})*)?)$"); + + private static final LruCache IVP6_HOST_MATCH_RESULTS = LruCache.builder() + .capacity(100) + .build(); + private static final LruCache IVP6_IDENTIFIER_MATCH_RESULTS = LruCache.builder() + .capacity(100) + .build(); + + private final ProxyType type; + private final String host; + private final int port; + private final Function noProxy; + private final Optional username; + private final Optional password; + + private Proxy(Proxy.Builder builder) { + this.host = builder.host(); + if (this.host != null) { + this.type = ProxyType.HTTP; + } else { + this.type = builder.type(); + } + + this.port = builder.port(); + this.username = builder.username(); + this.password = builder.password(); + + if (type == ProxyType.SYSTEM) { + this.noProxy = inetSocketAddress -> true; + } else { + this.noProxy = prepareNoProxy(builder.noProxyHosts()); + } + } + + /** + * Fluent API builder for new instances. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * A Proxy instance that does not proxy requests. + * + * @return a new instance with no proxy definition + */ + public static Proxy noProxy() { + return NO_PROXY; + } + + /** + * Create a new proxy instance from configuration. + * {@code + * proxy: + * http: + * uri: https://www.example.org + * https: + * uri: https://www.example.org + * no-proxy: ["*.example.org", "localhost"] + * } + * + * @param config configuration, should be located on a key that has proxy as a subkey + * @return proxy instance + */ + public static Proxy create(Config config) { + return builder() + .config(config) + .build(); + } + + /** + * Create from environment and system properties. + * + * @return a proxy instance configured based on this system settings + */ + public static Proxy create() { + return SYSTEM_PROXY; + } + + static Function prepareNoProxy(Set noProxyHosts) { + if (noProxyHosts.isEmpty()) { + // if no exceptions, then simple + return address -> false; + } + + boolean simple = true; + for (String noProxyHost : noProxyHosts) { + // go through all - if none start with *. then simple contains is sufficient + if (noProxyHost.startsWith(".")) { + simple = false; + break; + } + } + + if (simple) { + return address -> noProxyHosts.contains(address.host()) + || noProxyHosts.contains(address.host() + ":" + address.port()); + } + + List> hostMatchers = new LinkedList<>(); + List> ipMatchers = new LinkedList<>(); + + for (String noProxyHost : noProxyHosts) { + String hostPart = noProxyHost; + Integer portPart = null; + Matcher portMatcher = PORT_PATTERN.matcher(noProxyHost); + if (portMatcher.matches()) { + // we have a port + portPart = Integer.parseInt(portMatcher.group(1)); + int index = noProxyHost.lastIndexOf(':'); + hostPart = noProxyHost.substring(0, index); + } + + if (isIpV4(hostPart)) { + //this is going to be an IP matcher - IP matchers only support full IP addresses + exactMatch(ipMatchers, hostPart, portPart); + } else if (isIpV6Identifier(hostPart)) { + if ("[::1]".equals(hostPart)) { + exactMatch(ipMatchers, "0:0:0:0:0:0:0:1", portPart); + } + + exactMatch(ipMatchers, hostPart.substring(1, hostPart.length() - 1), portPart); + } else { + // for host names, we must honor . prefix to handle all sub-domains + if (hostPart.charAt(0) == '.') { + prefixedMatch(hostMatchers, hostPart, portPart); + } else { + // exact match + exactMatch(hostMatchers, hostPart, portPart); + } + } + } + + // complicated - must check for . prefixes + return address -> { + String host = resolveHost(address.host()); + int port = address.port(); + + // first need to make sure whether I have an IP address or a hostname + if (isIpV4(host) || isIpV6Host(host)) { + // we have an IP address + for (BiFunction ipMatcher : ipMatchers) { + if (ipMatcher.apply(host, port)) { + LOGGER.log(Level.TRACE, () -> "IP Address " + host + " bypasses proxy"); + return true; + } + } + LOGGER.log(Level.TRACE, () -> "IP Address " + host + " uses proxy"); + } else { + // we have a host name + for (BiFunction hostMatcher : hostMatchers) { + if (hostMatcher.apply(host, port)) { + LOGGER.log(Level.TRACE, () -> "Host " + host + " bypasses proxy"); + return true; + } + } + LOGGER.log(Level.TRACE, () -> "Host " + host + " uses proxy"); + } + + return false; + }; + } + + private static String resolveHost(String host) { + if (host != null && isIpV6Identifier(host)) { + return host.substring(1, host.length() - 1); + } + return host; + } + + private static void prefixedMatch(List> matchers, + String hostPart, + Integer portPart) { + if (null == portPart) { + matchers.add((host, port) -> prefixHostMatch(hostPart, host)); + } else { + matchers.add((host, port) -> portPart.equals(port) && prefixHostMatch(hostPart, host)); + } + } + + private static boolean prefixHostMatch(String hostPart, String host) { + if (host.endsWith(hostPart)) { + return true; + } + return host.equals(hostPart.substring(1)); + } + + private static void exactMatch(List> matchers, + String hostPart, + Integer portPart) { + if (null == portPart) { + matchers.add((host, port) -> hostPart.equals(host)); + } else { + matchers.add((host, port) -> portPart.equals(port) && hostPart.equals(host)); + } + } + + private static boolean isIpV4(String host) { + return IP_V4.matcher(host).matches(); + + } + + private static boolean isIpV6Identifier(String host) { + return IVP6_IDENTIFIER_MATCH_RESULTS.computeValue(host, () -> isIpV6IdentifierRegExp(host)).orElse(false); + } + + private static Optional isIpV6IdentifierRegExp(String host) { + return Optional.of(IP_V6_IDENTIFIER.matcher(host).matches() || IP_V6_HEX_IDENTIFIER.matcher(host).matches()); + } + + private static boolean isIpV6Host(String host) { + return IVP6_HOST_MATCH_RESULTS.computeValue(host, () -> isIpV6HostRegExp(host)).orElse(false); + } + + private static Optional isIpV6HostRegExp(String host) { + return Optional.of(IP_V6_HOST.matcher(host).matches() || IP_V6_HEX_HOST.matcher(host).matches()); + } + + /** + * Get proxy type. + * + * @return the proxy type + */ + public ProxyType type() { + return type; + } + + Function noProxyPredicate() { + return noProxy; + } + + /** + * Verifies whether the current host is inside noHosts. + * + * @param uri the uri + * @return true if it is in no hosts, otherwise false + */ + public boolean isNoHosts(UriHelper uri) { + return noProxy.apply(uri); + } + + /** + * Creates an Optional with the InetSocketAddress of the server proxy for the specified uri. + * @param uri the uri + * @return the InetSocketAddress + */ + public Optional address(UriHelper uri) { + if (type == null || type == ProxyType.NONE) { + return Optional.empty(); + } else if (type == ProxyType.SYSTEM) { + for (java.net.Proxy netProxy : ProxySelector.getDefault().select(uri.toUri())) { + if (netProxy.type() == java.net.Proxy.Type.HTTP) { + return Optional.of((InetSocketAddress) netProxy.address()); + } + } + return Optional.empty(); + } else { + return Optional.of(new InetSocketAddress(host, port)); + } + } + + /** + * Returns an Optional with the username. + * @return the username + */ + public Optional username() { + return username; + } + + /** + * Returns an Optional with the password. + * @return the password + */ + public Optional password() { + return password; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Proxy proxy = (Proxy) o; + return port == proxy.port + && type == proxy.type + && Objects.equals(host, proxy.host) + && Objects.equals(noProxy, proxy.noProxy) + && Objects.equals(username, proxy.username) + && Objects.equals(password, proxy.password); + } + + @Override + public int hashCode() { + return Objects.hash(type, host, port, noProxy, username, password); + } + + /** + * Fluent API builder for {@link Proxy}. + */ + @Configured + public static class Builder implements io.helidon.common.Builder { + private final Set noProxyHosts = new HashSet<>(); + + // Defaults to system + private ProxyType type; + private String host; + private int port = 80; + private String username; + private char[] password; + + private Builder() { + } + + @Override + public Proxy build() { + if ((null == host) || (host.isEmpty())) { + return NO_PROXY; + } else if (type == ProxyType.SYSTEM) { + return SYSTEM_PROXY; + } + return new Proxy(this); + } + + /** + * Configure a metric from configuration. + * The following configuration key are used: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Client Metric configuration options
keydefaultdescription
type{@code no default}Sets which type is this proxy. See {@link Proxy.ProxyType}
host{@code no default}Host of the proxy
port{@code 80}Port of the proxy
username{@code no default}Proxy username
password{@code no default}Proxy password
no-proxy{@code no default}Contains list of the hosts which should be excluded from using proxy
+ * + * @param config configuration to configure this proxy + * @return updated builder instance + */ + public Builder config(Config config) { + if (this.type != ProxyType.SYSTEM) { + config.get("type").asString().map(ProxyType::valueOf).ifPresentOrElse(this::type, () -> type(ProxyType.HTTP)); + config.get("host").asString().ifPresent(this::host); + config.get("port").asInt().ifPresent(this::port); + config.get("username").asString().ifPresent(this::username); + config.get("password").asString().map(String::toCharArray).ifPresent(this::password); + config.get("no-proxy").asList(String.class).ifPresent(hosts -> hosts.forEach(this::addNoProxy)); + } + return this; + } + + /** + * Sets a new proxy type. + * + * @param type proxy type + * @return updated builder instance + * @throws NullPointerException when type is null + */ + @ConfiguredOption("HTTP") + public Builder type(ProxyType type) { + this.type = Objects.requireNonNull(type); + return this; + } + + /** + * Sets a new host value. + * + * @param host host + * @return updated builder instance + * @throws NullPointerException when host is null + */ + @ConfiguredOption + public Builder host(String host) { + this.host = Objects.requireNonNull(host); + return this; + } + + /** + * Sets a port value. + * + * @param port port + * @return updated builder instance + */ + @ConfiguredOption + public Builder port(int port) { + this.port = port; + return this; + } + + /** + * Sets a new username for the proxy. + * + * @param username proxy username + * @return updated builder instance + */ + @ConfiguredOption + public Builder username(String username) { + this.username = username; + return this; + } + + /** + * Sets a new password for the proxy. + * + * @param password proxy password + * @return updated builder instance + */ + @ConfiguredOption(type = String.class) + public Builder password(char[] password) { + this.password = Arrays.copyOf(password, password.length); + return this; + } + + /** + * Configure a host pattern that is not going through a proxy. + *

+ * Options are: + *

    + *
  • IP Address, such as {@code 192.168.1.1}
  • + *
  • IP V6 Address, such as {@code [2001:db8:85a3:8d3:1319:8a2e:370:7348]}
  • + *
  • Hostname, such as {@code localhost}
  • + *
  • Domain name, such as {@code helidon.io}
  • + *
  • Domain name and all sub-domains, such as {@code .helidon.io} (leading dot)
  • + *
  • Combination of all options from above with a port, such as {@code .helidon.io:80}
  • + *
+ * + * @param noProxyHost to exclude from proxying + * @return updated builder instance + */ + @ConfiguredOption(key = "no-proxy", kind = ConfiguredOption.Kind.LIST) + public Builder addNoProxy(String noProxyHost) { + noProxyHosts.add(noProxyHost); + return this; + } + + ProxyType type() { + return type; + } + + String host() { + return host; + } + + int port() { + return port; + } + + Set noProxyHosts() { + return new HashSet<>(noProxyHosts); + } + + Optional username() { + return Optional.ofNullable(username); + } + + Optional password() { + return Optional.ofNullable(password); + } + } + + /** + * Type of the proxy. + */ + public enum ProxyType { + + /** + * No proxy. + */ + NONE, + + /** + * Proxy obtained from system. + */ + SYSTEM, + + /** + * HTTP proxy. + */ + HTTP; + } +} diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java index bf9bd81d4e6..d3dfe1f4ee2 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java @@ -33,6 +33,7 @@ public class UriHelper { "http", 80, "https", 443 ); + private static final String EMPTY_STRING = ""; private final String baseScheme; private final String baseAuthority; private final String basePath; @@ -41,7 +42,7 @@ public class UriHelper { private String scheme; private String authority; - private String path; + private String path = EMPTY_STRING; private String host; private int port; private boolean skipUriEncoding = false; diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java index 9edc95c6173..db061b95928 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java @@ -22,6 +22,7 @@ import java.util.List; import java.util.ListIterator; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; @@ -42,6 +43,7 @@ import io.helidon.nima.common.tls.Tls; import io.helidon.nima.http.media.MediaContext; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.UriHelper; import io.helidon.nima.webclient.WebClientServiceRequest; import io.helidon.nima.webclient.WebClientServiceResponse; @@ -67,6 +69,7 @@ class ClientRequestImpl implements Http1ClientRequest { private String uriTemplate; private ClientConnection connection; private UriFragment fragment = UriFragment.empty(); + private Proxy proxy; private boolean skipUriEncoding = false; private boolean keepAlive; @@ -208,6 +211,7 @@ public Http1ClientResponse outputStream(OutputStreamHandler streamHandler) { clientConfig, connection, tls, + proxy, whenSent, whenComplete, streamHandler); @@ -337,6 +341,7 @@ private ClientResponseImpl invokeRequestWithEntity(Object entity) { clientConfig, connection, tls, + proxy, whenSent, whenComplete, entity); @@ -359,6 +364,12 @@ public UriQuery uriQuery() { return UriQuery.create(resolvedUri()); } + @Override + public Http1ClientRequest proxy(Proxy proxy) { + this.proxy = Objects.requireNonNull(proxy); + return this; + } + Duration readTimeout() { return readTimeout; } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionCache.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionCache.java index 818e92b15de..a687d96db26 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionCache.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionCache.java @@ -26,6 +26,7 @@ import io.helidon.common.http.WritableHeaders; import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.UriHelper; import static java.lang.System.Logger.Level.DEBUG; @@ -43,15 +44,16 @@ private ConnectionCache() { static ClientConnection connection(Http1ClientConfig clientConfig, Tls tls, + Proxy proxy, UriHelper uri, ClientRequestHeaders headers, boolean defaultKeepAlive) { boolean keepAlive = handleKeepAlive(defaultKeepAlive, headers); Tls effectiveTls = HTTPS.equals(uri.scheme()) ? tls : null; if (keepAlive) { - return keepAliveConnection(clientConfig, effectiveTls, uri); + return keepAliveConnection(clientConfig, effectiveTls, uri, proxy); } else { - return oneOffConnection(clientConfig, effectiveTls, uri); + return oneOffConnection(clientConfig, effectiveTls, uri, proxy); } } @@ -72,13 +74,14 @@ private static boolean handleKeepAlive(boolean defaultKeepAlive, WritableHeaders private static ClientConnection keepAliveConnection(Http1ClientConfig clientConfig, Tls tls, - UriHelper uri) { - // todo add proxy to the key + UriHelper uri, + Proxy proxy) { KeepAliveKey keepAliveKey = new KeepAliveKey(uri.scheme(), uri.authority(), tls, clientConfig.socketOptions().connectTimeout(), - clientConfig.socketOptions().readTimeout()); + clientConfig.socketOptions().readTimeout(), + proxy); var connectionQueue = CHANNEL_CACHE.computeIfAbsent(keepAliveKey, it -> new LinkedBlockingDeque<>(clientConfig.connectionQueueSize())); @@ -95,7 +98,8 @@ private static ClientConnection keepAliveConnection(Http1ClientConfig clientConf uri.port(), tls, clientConfig.dnsResolver(), - clientConfig.dnsAddressLookup())) + clientConfig.dnsAddressLookup(), + proxy)) .connect(); } else { if (LOGGER.isLoggable(DEBUG)) { @@ -109,16 +113,19 @@ private static ClientConnection keepAliveConnection(Http1ClientConfig clientConf private static ClientConnection oneOffConnection(Http1ClientConfig clientConfig, Tls tls, - UriHelper uri) { + UriHelper uri, + Proxy proxy) { return new Http1ClientConnection(clientConfig.socketOptions(), new ConnectionKey(uri.scheme(), uri.host(), uri.port(), tls, clientConfig.dnsResolver(), - clientConfig.dnsAddressLookup())) + clientConfig.dnsAddressLookup(), + proxy)) .connect(); } - private record KeepAliveKey(String scheme, String authority, Tls tlsConfig, Duration connectTimeout, Duration readTimeout) { + private record KeepAliveKey(String scheme, String authority, Tls tlsConfig, Duration connectTimeout, + Duration readTimeout, Proxy proxy) { } } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java index dff31fb59d3..b5d43c03e1b 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java @@ -18,6 +18,7 @@ import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.DnsAddressLookup; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.spi.DnsResolver; /** @@ -29,10 +30,12 @@ * @param tls TLS to be used in connection * @param dnsResolver DNS resolver to be used * @param dnsAddressLookup DNS address lookup strategy + * @param proxy Proxy server to use for outgoing requests */ record ConnectionKey(String scheme, String host, int port, Tls tls, DnsResolver dnsResolver, - DnsAddressLookup dnsAddressLookup) { } + DnsAddressLookup dnsAddressLookup, + Proxy proxy) { } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java index f4f514fc44c..885b1961be8 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java @@ -25,7 +25,9 @@ import java.security.cert.Certificate; import java.security.cert.X509Certificate; import java.time.Duration; +import java.util.Collections; import java.util.HexFormat; +import java.util.Optional; import java.util.concurrent.LinkedBlockingDeque; import java.util.concurrent.TimeUnit; @@ -36,11 +38,20 @@ import io.helidon.common.buffers.BufferData; import io.helidon.common.buffers.DataReader; import io.helidon.common.buffers.DataWriter; +import io.helidon.common.http.Http.Header; +import io.helidon.common.http.Http.Method; +import io.helidon.common.http.Http.Status; +import io.helidon.common.http.WritableHeaders; import io.helidon.common.socket.HelidonSocket; import io.helidon.common.socket.PlainSocket; import io.helidon.common.socket.SocketOptions; import io.helidon.common.socket.TlsSocket; +import io.helidon.common.uri.UriQueryWriteable; +import io.helidon.nima.http.media.MediaContext; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.Proxy; +import io.helidon.nima.webclient.Proxy.ProxyType; +import io.helidon.nima.webclient.UriHelper; import io.helidon.nima.webclient.spi.DnsResolver; import static java.lang.System.Logger.Level.DEBUG; @@ -119,33 +130,62 @@ boolean isConnected() { return socket != null && socket.isConnected(); } + private InetSocketAddress inetSocketAddress() { + DnsResolver dnsResolver = connectionKey.dnsResolver(); + if (dnsResolver.useDefaultJavaResolver()) { + return new InetSocketAddress(connectionKey.host(), connectionKey.port()); + } else { + InetAddress address = dnsResolver.resolveAddress(connectionKey.host(), connectionKey.dnsAddressLookup()); + return new InetSocketAddress(address, connectionKey.port()); + } + } + + private Http1ClientConnection proxyConnection(InetSocketAddress proxyAddress) { + return new Http1ClientConnection(options, new ConnectionKey("http", + proxyAddress.getHostName(), + proxyAddress.getPort(), + null, + connectionKey.dnsResolver(), + connectionKey.dnsAddressLookup(), + null)).connect(); + } + + private int connectToProxy(InetSocketAddress remoteAddress, InetSocketAddress proxyAddress) { + String hostPort = remoteAddress.getHostName() + ":" + remoteAddress.getPort(); + Http1ClientConnection proxyConnection = proxyConnection(proxyAddress); + UriHelper uriHelper = UriHelper.create(); + uriHelper.scheme("http"); + uriHelper.host(proxyAddress.getHostName()); + uriHelper.port(proxyAddress.getPort()); + Http1ClientConfig clientConfig = Http1ClientConfig.builder().mediaContext(MediaContext.create()) + .socketOptions(options).dnsResolver(connectionKey.dnsResolver()) + .dnsAddressLookup(connectionKey.dnsAddressLookup()).defaultHeaders(WritableHeaders.create()).build(); + ClientRequestImpl httpClient = new ClientRequestImpl(clientConfig, + Method.CONNECT, uriHelper, UriQueryWriteable.create(), Collections.emptyMap()); + httpClient.connection(proxyConnection); + httpClient.header(Header.HOST, hostPort); + httpClient.header(Header.ACCEPT, "*/*"); + Http1ClientResponse response = httpClient.request(); + + // Re-use socket + this.socket = proxyConnection.socket; + // Note that Http1StatusParser fails parsing HTTP/1.0 and some proxies will return that. + return response.status().code(); + } + + private String createChannelId(Socket socket) { + return "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); + } + Http1ClientConnection connect() { + ConnectionStrategy strategy; try { - SSLSocket sslSocket = connectionKey.tls() == null ? null : connectionKey.tls().createSocket("http/1.1"); - - socket = sslSocket == null ? new Socket() : sslSocket; + socket = new Socket(); socket.setSoTimeout((int) options.readTimeout().toMillis()); options.configureSocket(socket); - DnsResolver dnsResolver = connectionKey.dnsResolver(); - if (dnsResolver.useDefaultJavaResolver()) { - socket.connect(new InetSocketAddress(connectionKey.host(), connectionKey.port()), - (int) options.connectTimeout().toMillis()); - } else { - InetAddress address = dnsResolver.resolveAddress(connectionKey.host(), connectionKey.dnsAddressLookup()); - socket.connect(new InetSocketAddress(address, connectionKey.port()), (int) options.connectTimeout().toMillis()); - } - - channelId = "0x" + HexFormat.of().toHexDigits(System.identityHashCode(socket)); - - if (sslSocket == null) { - helidonSocket = PlainSocket.client(socket, channelId); - } else { - sslSocket.startHandshake(); - helidonSocket = TlsSocket.client(sslSocket, channelId); - if (LOGGER.isLoggable(TRACE)) { - debugTls(sslSocket); - } - } + InetSocketAddress remoteAddress = inetSocketAddress(); + strategy = ConnectionStrategy.get(this, remoteAddress); + strategy.connect(this, remoteAddress); } catch (IOException e) { throw new UncheckedIOException("Could not connect to " + connectionKey.host() + ":" + connectionKey.port(), e); } @@ -156,7 +196,7 @@ Http1ClientConnection connect() { socket.getLocalAddress(), Thread.currentThread().getName())); } - + this.channelId = createChannelId(socket); this.reader = new DataReader(helidonSocket); this.writer = new DataWriter() { @Override @@ -258,4 +298,87 @@ private String certsToString(Certificate[] peerCertificates) { return String.join(", ", certs); } + + private enum ConnectionStrategy { + PLAIN { + @Override + protected void connect(Http1ClientConnection connection, InetSocketAddress remoteAddress) throws IOException { + connection.socket.connect(remoteAddress, (int) connection.options.connectTimeout().toMillis()); + connection.helidonSocket = PlainSocket.client(connection.socket, connection.channelId); + } + }, + HTTPS { + @Override + protected void connect(Http1ClientConnection connection, InetSocketAddress remoteAddress) throws IOException { + SSLSocket sslSocket = connection.connectionKey.tls().createSocket("http/1.1"); + connection.socket = sslSocket; + connection.socket.connect(remoteAddress, (int) connection.options.connectTimeout().toMillis()); + sslSocket.startHandshake(); + connection.helidonSocket = TlsSocket.client(sslSocket, connection.channelId); + if (LOGGER.isLoggable(TRACE)) { + connection.debugTls(sslSocket); + } + } + }, + PROXY_PLAIN { + @Override + protected void connect(Http1ClientConnection connection, InetSocketAddress remoteAddress) throws IOException { + UriHelper uri = UriHelper.create(); + uri.scheme("http"); + uri.host(remoteAddress.getHostName()); + uri.port(remoteAddress.getPort()); + InetSocketAddress proxyAddress = connection.connectionKey.proxy().address(uri).get(); + int responseCode = connection.connectToProxy(remoteAddress, proxyAddress); + if (responseCode != Status.OK_200.code()) { + throw new IllegalStateException("Proxy sent wrong HTTP response code: " + responseCode); + } + connection.helidonSocket = PlainSocket.client(connection.socket, connection.channelId); + } + }, + PROXY_HTTPS { + @Override + protected void connect(Http1ClientConnection connection, InetSocketAddress remoteAddress) throws IOException { + UriHelper uri = UriHelper.create(); + uri.scheme("http"); + uri.host(remoteAddress.getHostName()); + uri.port(remoteAddress.getPort()); + InetSocketAddress proxyAddress = connection.connectionKey.proxy().address(uri).get(); + int responseCode = connection.connectToProxy(remoteAddress, proxyAddress); + if (responseCode != Status.OK_200.code()) { + throw new IllegalStateException("Proxy sent wrong HTTP response code: " + responseCode); + } + SSLSocket sslSocket = connection.connectionKey.tls().createSocket("http/1.1", connection.socket, remoteAddress); + connection.socket = sslSocket; + sslSocket.startHandshake(); + connection.helidonSocket = TlsSocket.client(sslSocket, connection.channelId); + if (LOGGER.isLoggable(TRACE)) { + connection.debugTls(sslSocket); + } + } + }; + + protected abstract void connect(Http1ClientConnection connection, InetSocketAddress remoteAddress) throws IOException; + + static ConnectionStrategy get(Http1ClientConnection connection, InetSocketAddress remoteAddress) { + Proxy proxy = connection.connectionKey.proxy(); + boolean useProxy = false; + if (proxy != null && proxy.type() != ProxyType.NONE) { + UriHelper uri = UriHelper.create(); + uri.scheme("http"); + uri.host(remoteAddress.getHostName()); + uri.port(remoteAddress.getPort()); + if (proxy.type() == ProxyType.SYSTEM) { + Optional optional = proxy.address(uri); + useProxy = optional.isPresent(); + } else if (!proxy.isNoHosts(uri)) { + useProxy = true; + } + } + if (useProxy) { + return connection.connectionKey.tls() != null ? ConnectionStrategy.PROXY_HTTPS : ConnectionStrategy.PROXY_PLAIN; + } else { + return connection.connectionKey.tls() != null ? ConnectionStrategy.HTTPS : ConnectionStrategy.PLAIN; + } + } + } } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallChainBase.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallChainBase.java index 30872a14fb8..be6d3b5d2bc 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallChainBase.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallChainBase.java @@ -28,6 +28,7 @@ import io.helidon.common.http.WritableHeaders; import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.UriHelper; import io.helidon.nima.webclient.WebClientServiceRequest; import io.helidon.nima.webclient.WebClientServiceResponse; @@ -38,15 +39,18 @@ abstract class HttpCallChainBase implements WebClientService.Chain { private final Http1ClientConfig clientConfig; private final ClientConnection connection; private final Tls tls; + private final Proxy proxy; private final boolean keepAlive; HttpCallChainBase(Http1ClientConfig clientConfig, ClientConnection connection, Tls tls, + Proxy proxy, boolean keepAlive) { this.clientConfig = clientConfig; this.connection = connection; this.tls = tls; + this.proxy = proxy; this.keepAlive = keepAlive; } @@ -107,6 +111,7 @@ ClientResponseHeaders readHeaders(DataReader reader) { private ClientConnection obtainConnection(WebClientServiceRequest request) { return ConnectionCache.connection(clientConfig, tls, + proxy, request.uri(), request.headers(), keepAlive); diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallEntityChain.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallEntityChain.java index edd592b40f8..958fcdd4295 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallEntityChain.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallEntityChain.java @@ -29,6 +29,7 @@ import io.helidon.nima.common.tls.Tls; import io.helidon.nima.http.media.EntityWriter; import io.helidon.nima.webclient.ClientConnection; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.WebClientServiceRequest; import io.helidon.nima.webclient.WebClientServiceResponse; @@ -44,10 +45,11 @@ class HttpCallEntityChain extends HttpCallChainBase { Http1ClientConfig clientConfig, ClientConnection connection, Tls tls, + Proxy proxy, CompletableFuture whenSent, CompletableFuture whenComplete, Object entity) { - super(clientConfig, connection, tls, request.keepAlive()); + super(clientConfig, connection, tls, proxy, request.keepAlive()); this.request = request; this.clientConfig = clientConfig; this.whenSent = whenSent; diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallOutputStreamChain.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallOutputStreamChain.java index b96dc292f2c..7b3ec72f177 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallOutputStreamChain.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/HttpCallOutputStreamChain.java @@ -33,6 +33,7 @@ import io.helidon.nima.common.tls.Tls; import io.helidon.nima.webclient.ClientConnection; import io.helidon.nima.webclient.ClientRequest; +import io.helidon.nima.webclient.Proxy; import io.helidon.nima.webclient.WebClientServiceRequest; import io.helidon.nima.webclient.WebClientServiceResponse; @@ -46,10 +47,11 @@ class HttpCallOutputStreamChain extends HttpCallChainBase { Http1ClientConfig clientConfig, ClientConnection connection, Tls tls, + Proxy proxy, CompletableFuture whenSent, CompletableFuture whenComplete, ClientRequest.OutputStreamHandler osHandler) { - super(clientConfig, connection, tls, clientRequest.keepAlive()); + super(clientConfig, connection, tls, proxy, clientRequest.keepAlive()); this.clientConfig = clientConfig; this.whenSent = whenSent; this.whenComplete = whenComplete; diff --git a/nima/webclient/webclient/src/main/java/module-info.java b/nima/webclient/webclient/src/main/java/module-info.java index 7bd0cdf7c24..0ba62f6fb52 100644 --- a/nima/webclient/webclient/src/main/java/module-info.java +++ b/nima/webclient/webclient/src/main/java/module-info.java @@ -42,13 +42,15 @@ // @Builder - validator is a runtime dependency requires io.helidon.builder.api; + requires transitive io.helidon.common; + requires transitive io.helidon.common.context; + requires transitive io.helidon.common.http; + requires transitive io.helidon.common.configurable; + requires transitive io.helidon.common.socket; requires transitive io.helidon.common.uri; requires transitive io.helidon.nima.common.tls; - requires transitive io.helidon.common.socket; - requires transitive io.helidon.common.http; requires transitive io.helidon.nima.http.encoding; requires transitive io.helidon.nima.http.media; - requires transitive io.helidon.common.context; requires transitive io.helidon.common.config; exports io.helidon.nima.webclient; diff --git a/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/HttpClientTest.java b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/HttpClientTest.java index d705ff48ce8..6f125733787 100644 --- a/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/HttpClientTest.java +++ b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/HttpClientTest.java @@ -179,6 +179,11 @@ public FakeHttpClientRequest connection(ClientConnection connection) { return null; } + @Override + public FakeHttpClientRequest proxy(Proxy proxy) { + return null; + } + @Override public FakeHttpClientRequest skipUriEncoding() { return null; diff --git a/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/ProxyTest.java b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/ProxyTest.java new file mode 100644 index 00000000000..86056da344d --- /dev/null +++ b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/ProxyTest.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2023 Oracle and/or its affiliates. + * + * 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 + * + * http://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 io.helidon.nima.webclient; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.util.Set; +import java.util.function.Function; + +import org.junit.jupiter.api.Test; + +class ProxyTest { + + @Test + void testNoProxyHandling() { + Set noProxy = Set.of("localhost:8080", + ".helidon.io", + "www.oracle.com", + "192.168.1.1", + "[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443", + ".0.0.1", + "[::1]"); + + Function fun = Proxy.prepareNoProxy(noProxy); + + assertThat("[::1]:80", fun.apply(address("[::1]", 80)), is(true)); + assertThat("localhost:8080", fun.apply(address("localhost", 8080)), is(true)); + assertThat("localhost:8081", fun.apply(address("localhost", 8081)), is(false)); + assertThat("helidon.io:80", fun.apply(address("helidon.io", 80)), is(true)); + assertThat("docs.helidon.io:80", fun.apply(address("docs.helidon.io", 80)), is(true)); + assertThat("www.oracle.com:443", fun.apply(address("www.oracle.com", 443)), is(true)); + assertThat("docs.oracle.com:443", fun.apply(address("docs.oracle.com", 443)), is(false)); + assertThat("192.168.1.1:8081", fun.apply(address("192.168.1.1", 8081)), is(true)); + assertThat("192.168.1.2:8081", fun.apply(address("192.168.1.2", 8081)), is(false)); + assertThat("127.0.0.1:80", fun.apply(address("127.0.0.1", 80)), is(false)); + assertThat("[2001:db8:85a3:8d3:1319:8a2e:370:7348]:443", + fun.apply(address("[2001:db8:85a3:8d3:1319:8a2e:370:7348]", 443)), + is(true)); + assertThat("[2001:db8:85a3:8d3:1319:8a2e:370:7349]:443", + fun.apply(address("[2001:db8:85a3:8d3:1319:8a2e:370:7349]", 443)), + is(false)); + assertThat("[2001:db8:85a3:8d3:1319:8a2e:370:7348]:445", + fun.apply(address("[2001:db8:85a3:8d3:1319:8a2e:370:7348]", 445)), + is(false)); + + } + + private UriHelper address(String host, int port) { + UriHelper uri = UriHelper.create(); + uri.scheme("http"); + uri.host(host); + uri.port(port); + return uri; + } +}