diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java index c05f7f3ca8..77229b1146 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/HttpProxySocketFactory.java @@ -24,8 +24,19 @@ public class HttpProxySocketFactory extends SSLSocketFactory @Override public Socket createSocket() throws IOException { - Socket proxySocket = new Socket(proxySettings.getHostname(), proxySettings.getPort()); - return new ProxiedSSLSocket(delegate, proxySocket, proxySettings.getUsername(), proxySettings.getPassword()); + // The socket to the proxy is deliberately left unconnected here. Connecting it in this factory method would put + // the connection attempt outside of the connect timeout that the caller later passes to + // Socket.connect(SocketAddress, int), so an unreachable or overloaded proxy would block the calling thread for + // as long as the operating system's default TCP connect timeout allows. That surfaces as an unexplained hang + // with no error for the transport layer to retry on. ProxiedSSLSocket connects it instead, so that the caller's + // timeout covers connecting to the proxy as well as tunnelling through it. + return new ProxiedSSLSocket( + delegate, + new Socket(), + proxySettings.getHostname(), + proxySettings.getPort(), + proxySettings.getUsername(), + proxySettings.getPassword()); } @SuppressWarnings("unused") // Seems as if it's used in the Lombok delegate diff --git a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java index 2c47dd0906..2ec7bb5961 100644 --- a/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java +++ b/iothub/device/iot-device-client/src/main/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocket.java @@ -6,7 +6,6 @@ package com.microsoft.azure.sdk.iot.device.transport; import lombok.NonNull; -import lombok.RequiredArgsConstructor; import lombok.experimental.Delegate; import lombok.extern.slf4j.Slf4j; import org.apache.commons.codec.binary.Base64; @@ -20,10 +19,12 @@ import java.net.InetSocketAddress; import java.net.Socket; import java.net.SocketAddress; +import java.net.SocketTimeoutException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.LinkedList; import java.util.List; +import java.util.concurrent.TimeUnit; /** * Extension of an SSLSocket that sends an HTTP CONNECT packet to the proxy socket before sending the SSL handshake upstream. @@ -40,18 +41,46 @@ class ProxiedSSLSocket extends SSLSocket @Delegate(excludes = ProxiedSSLSocketNonDelegatedFunctions.class) private SSLSocket sslSocket; + //Address of the HTTP proxy. Null when the proxy socket was handed over already connected. + private final String proxyHostname; + private final int proxyPort; + private final String proxyUsername; private final char[] proxyPassword; private static final String HTTP = "HTTP/"; private static final String HTTP_VERSION_1_1 = HTTP + "1.1"; - + /** + * Create a socket that tunnels through an HTTP proxy that has already been connected to. + * @param socketFactory The factory used to layer SSL over the tunnel once it has been established + * @param proxySocket An already connected socket to the HTTP proxy + * @param proxyUsername The username to authenticate to the proxy with, or null if the proxy needs no authentication + * @param proxyPassword The password to authenticate to the proxy with, or null if the proxy needs no authentication + */ ProxiedSSLSocket(SSLSocketFactory socketFactory, Socket proxySocket, String proxyUsername, char[] proxyPassword) + { + this(socketFactory, proxySocket, null, -1, proxyUsername, proxyPassword); + } + + /** + * Create a socket that connects to an HTTP proxy and then tunnels through it. + * @param socketFactory The factory used to layer SSL over the tunnel once it has been established + * @param proxySocket An unconnected socket that will be connected to the proxy by + * {@link #connect(SocketAddress, int)} so that the caller's connect timeout applies to it + * @param proxyHostname The hostname of the HTTP proxy + * @param proxyPort The port of the HTTP proxy + * @param proxyUsername The username to authenticate to the proxy with, or null if the proxy needs no authentication + * @param proxyPassword The password to authenticate to the proxy with, or null if the proxy needs no authentication + */ + ProxiedSSLSocket(SSLSocketFactory socketFactory, Socket proxySocket, String proxyHostname, int proxyPort, String proxyUsername, char[] proxyPassword) { this.socketFactory = socketFactory; this.proxySocket = proxySocket; + this.proxyHostname = proxyHostname; + this.proxyPort = proxyPort; + this.proxyUsername = proxyUsername; this.proxyPassword = proxyPassword; } @@ -65,18 +94,118 @@ public void connect(SocketAddress socketAddress) throws IOException @Override public void connect(SocketAddress socketAddress, int timeout) throws IOException { - log.debug("Sending tunnel handshake to HTTP proxy"); - doTunnelHandshake(proxySocket, ((InetSocketAddress) socketAddress).getHostName(), ((InetSocketAddress) socketAddress).getPort()); + InetSocketAddress inetSocketAddress = (InetSocketAddress) socketAddress; + + // The proxy's response to the CONNECT request is read from a blocking stream, so without a timeout an + // unresponsive proxy would block this thread indefinitely rather than surfacing an error that the layers above + // can retry. The read timeout is applied as a deadline across the whole CONNECT response rather than per read, + // because the response is consumed a byte at a time and a proxy that trickles out one byte per interval would + // otherwise keep this call blocked forever. The previous value is restored once the tunnel is established so + // that it does not affect traffic sent through it. A timeout of 0 means "no timeout", which matches the + // behaviour of Socket.connect(SocketAddress, int). + int previousSoTimeout = this.proxySocket.getSoTimeout(); + + try + { + // Connecting to the proxy and tunnelling through it share the caller's timeout budget, so that this call + // cannot take up to twice the requested timeout. + int remainingTimeout = connectToProxy(timeout); + + log.debug("Sending tunnel handshake to HTTP proxy"); + + doTunnelHandshake(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), remainingTimeout); + } + catch (IOException | RuntimeException e) + { + // Don't leak the socket if the tunnel could not be established. A malformed proxy response can surface as + // an unchecked exception rather than an IOException, so those have to be cleaned up after as well. + closeProxySocketQuietly(e); + throw e; + } + + this.proxySocket.setSoTimeout(previousSoTimeout); + log.debug("Handshake to HTTP proxy succeeded"); //Wrap the proxy socket into the new SSLSocket so all further communication gets forwarded through the proxy - this.sslSocket = (SSLSocket) socketFactory.createSocket(proxySocket, ((InetSocketAddress) socketAddress).getHostName(), ((InetSocketAddress) socketAddress).getPort(), true); + this.sslSocket = (SSLSocket) socketFactory.createSocket(this.proxySocket, inetSocketAddress.getHostName(), inetSocketAddress.getPort(), true); + } + + /** + * Establish the TCP connection to the HTTP proxy if it has not been established already. + * + *

This deliberately happens here rather than when this socket is created. Creating a connected socket up front + * would put the connection attempt outside of any timeout the caller supplies to + * {@link #connect(SocketAddress, int)}, so an unreachable or overloaded proxy would block the calling thread for as + * long as the operating system's default TCP connect timeout allows, without any error for the layers above to + * retry on.

+ * + * @param timeoutMillis The total time available for connecting to the proxy and tunnelling through it, where 0 + * means wait indefinitely + * @return How much of the timeout is left for the tunnel handshake, where 0 means wait indefinitely + * @throws IOException If the proxy could not be connected to in time + */ + private int connectToProxy(int timeoutMillis) throws IOException + { + if (this.proxySocket.isConnected()) + { + //The socket was handed over already connected, so the whole timeout is available to the tunnel handshake + return timeoutMillis; + } + + if (this.proxyHostname == null) + { + throw new IOException("Cannot connect to the HTTP proxy because no proxy address was provided"); + } + + log.debug("Connecting to HTTP proxy {}:{}", this.proxyHostname, this.proxyPort); + + long startNanos = System.nanoTime(); + + this.proxySocket.connect(new InetSocketAddress(this.proxyHostname, this.proxyPort), timeoutMillis); + + // 0 means "no timeout", so there is no budget to track + if (timeoutMillis == 0) + { + return 0; + } + + long remainingMillis = timeoutMillis - TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos); + + if (remainingMillis <= 0) + { + throw new SocketTimeoutException(String.format("Timed out after %d milliseconds while connecting to the HTTP proxy %s:%d", timeoutMillis, this.proxyHostname, this.proxyPort)); + } + + return (int) remainingMillis; + } + + /** + * Close the proxy socket while propagating the failure that caused the tunnel to be abandoned. Any problem closing + * the socket is attached to that failure rather than replacing it. + * @param cause The failure that caused the tunnel handshake to be abandoned + */ + private void closeProxySocketQuietly(Throwable cause) + { + try + { + this.proxySocket.close(); + } + catch (IOException closeException) + { + cause.addSuppressed(closeException); + } } @Override public void close() throws IOException { this.proxySocket.close(); - this.sslSocket.close(); + + // May be null if the tunnel handshake never completed, so this socket was never fully connected + if (this.sslSocket != null) + { + this.sslSocket.close(); + } } /** @@ -84,9 +213,10 @@ public void close() throws IOException { * @param tunnel The socket to communicate to the HTTP proxy through * @param host The destination host the proxy will forward communication to * @param port The destination port the proxy will forward communication to - * @throws IOException If unable to read or send to the HTTP proxy + * @param timeoutMillis How long to wait for the proxy's complete response, where 0 means wait indefinitely + * @throws IOException If unable to read or send to the HTTP proxy, or if the proxy did not respond in time */ - private void doTunnelHandshake(Socket tunnel, String host, int port) throws IOException + private void doTunnelHandshake(Socket tunnel, String host, int port, int timeoutMillis) throws IOException { Charset byteEncoding = StandardCharsets.UTF_8; OutputStream out = tunnel.getOutputStream(); @@ -107,18 +237,26 @@ private void doTunnelHandshake(Socket tunnel, String host, int port) throws IOEx out.flush(); //Cannot do any buffering while reading, only read what is relevant to the connect response - HttpConnectResponseReader in = new HttpConnectResponseReader(tunnel.getInputStream(), byteEncoding); + HttpConnectResponseReader in = new HttpConnectResponseReader(tunnel.getInputStream(), byteEncoding, tunnel, timeoutMillis); String connectResponse = in.readHttpConnectResponse(); String[] connectResponseLines = connectResponse.split("\r\n"); int connectResponseStart = 0; - while (connectResponseLines[connectResponseStart].isEmpty()) + while (connectResponseStart < connectResponseLines.length && connectResponseLines[connectResponseStart].isEmpty()) { connectResponseStart++; } + // A response made up entirely of blank lines has no status line to inspect. Split discards trailing empty + // strings, so a response of just "\r\n\r\n" produces an empty array rather than a set of empty lines. + if (connectResponseStart == connectResponseLines.length) + { + tunnel.close(); + throw new IOException(String.format("Unable to tunnel through %s:%d. Proxy response to CONNECT did not contain a status line", host, port)); + } + //Expects the same http version in the response as the request String firstLine = connectResponseLines[connectResponseStart]; if (!firstLine.startsWith(HTTP)) @@ -162,13 +300,49 @@ private interface ProxiedSSLSocketNonDelegatedFunctions void close(); } - @RequiredArgsConstructor static class HttpConnectResponseReader { private boolean alreadyRead = false; @NonNull private final InputStream inputStream; @NonNull private final Charset byteEncoding; + // Socket whose read timeout is narrowed as the deadline approaches. Null when no deadline is being enforced. + private final Socket socket; + private final int timeoutMillis; + private final long deadlineNanos; + + HttpConnectResponseReader(@NonNull InputStream inputStream, @NonNull Charset byteEncoding, Socket socket, int timeoutMillis) + { + this.inputStream = inputStream; + this.byteEncoding = byteEncoding; + this.socket = socket; + this.timeoutMillis = timeoutMillis; + this.deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(Math.max(timeoutMillis, 0)); + } + + /** + * Narrow the socket's read timeout to whatever is left of the caller's timeout, so that the total time spent + * reading the proxy's response is bounded even though it is read one byte at a time. + * @throws SocketTimeoutException If the deadline has already passed + * @throws IOException If the socket's timeout could not be updated + */ + private void applyRemainingTimeout() throws IOException + { + // 0 means "no timeout", matching the behaviour of Socket.connect(SocketAddress, int) + if (this.socket == null || this.timeoutMillis <= 0) + { + return; + } + + long remainingMillis = TimeUnit.NANOSECONDS.toMillis(this.deadlineNanos - System.nanoTime()); + if (remainingMillis <= 0) + { + throw new SocketTimeoutException("Timed out waiting for the HTTP proxy to respond to the CONNECT request"); + } + + this.socket.setSoTimeout((int) Math.min(remainingMillis, Integer.MAX_VALUE)); + } + String readHttpConnectResponse() throws IOException { if (alreadyRead) @@ -182,6 +356,8 @@ String readHttpConnectResponse() throws IOException //until the 4 most recently read characters were \r\n\r\n while (!isCRLF(mostRecentFourCharacters)) { + applyRemainingTimeout(); + int i = inputStream.read(); if (i == -1) { diff --git a/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java new file mode 100644 index 0000000000..cbe627b30d --- /dev/null +++ b/iothub/device/iot-device-client/src/test/java/com/microsoft/azure/sdk/iot/device/transport/ProxiedSSLSocketTest.java @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +package com.microsoft.azure.sdk.iot.device.transport; + +import com.microsoft.azure.sdk.iot.device.ProxySettings; +import org.junit.Test; + +import javax.net.ssl.SSLSocketFactory; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * Unit tests for ProxiedSSLSocket. + */ +public class ProxiedSSLSocketTest +{ + // Deliberately unresolved so that these tests never depend on DNS. ProxiedSSLSocket only reads the hostname and + // port out of the address in order to build the CONNECT request. + private static final InetSocketAddress DESTINATION = + InetSocketAddress.createUnresolved("some-iot-hub.azure-devices.net", 443); + + private static final String CONNECT_ESTABLISHED_RESPONSE = "HTTP/1.1 200 Connection established\r\n\r\n"; + + /** + * Starts a local server that accepts a single connection and then optionally writes the given response. If the + * response is null, the server accepts the connection but never answers, simulating an unresponsive proxy. + */ + private static Thread startFakeProxy(final ServerSocket serverSocket, final String response) + { + Thread thread = new Thread(() -> + { + try (Socket accepted = serverSocket.accept()) + { + if (response == null) + { + // Hold the connection open without responding until the test closes the client side + InputStream inputStream = accepted.getInputStream(); + while (inputStream.read() != -1) + { + // discard the CONNECT request and never reply + } + } + else + { + OutputStream outputStream = accepted.getOutputStream(); + outputStream.write(response.getBytes(StandardCharsets.UTF_8)); + outputStream.flush(); + + // Keep the socket open so that the client can finish wrapping it + Thread.sleep(2000); + } + } + catch (IOException | InterruptedException e) + { + // Expected once the test closes its end of the connection + } + }); + + thread.setDaemon(true); + thread.start(); + return thread; + } + + // Without a read timeout applied to the proxy socket, an unresponsive proxy blocks the connecting thread forever, + // which prevents the layers above from ever retrying the connection. The JUnit timeout below fails the test rather + // than hanging the build if that regresses. + @Test(timeout = 30000) + public void connectTimesOutWhenProxyDoesNotRespondToConnectRequest() throws Exception + { + try (ServerSocket unresponsiveProxy = new ServerSocket(0)) + { + startFakeProxy(unresponsiveProxy, null); + + try (Socket proxySocket = new Socket("127.0.0.1", unresponsiveProxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 500); + fail("Expected connect to time out instead of blocking indefinitely"); + } + catch (SocketTimeoutException expected) + { + // Expected + } + + // Asserted here rather than relying on try-with-resources, which would close the socket regardless and + // would let this test pass even if connect() stopped cleaning up after a failed handshake. + assertTrue( + "Expected connect to close the proxy socket after the handshake timed out", + proxySocket.isClosed()); + } + } + } + + // The timeout only applies while the tunnel is being established. Once the proxy has accepted the CONNECT request, + // the socket must go back to its previous timeout so that tunneled traffic isn't cut short. + @Test(timeout = 30000) + public void connectRestoresPreviousSoTimeoutAfterTunnelIsEstablished() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, CONNECT_ESTABLISHED_RESPONSE); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + proxySocket.setSoTimeout(1234); + + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + proxiedSSLSocket.connect(DESTINATION, 5000); + + assertEquals( + "Expected the socket's original read timeout to be restored once the tunnel was established", + 1234, + proxySocket.getSoTimeout()); + } + } + } + + // A proxy that rejects the CONNECT request must surface an error rather than leaving a connected socket behind. + @Test(timeout = 30000) + public void connectThrowsWhenProxyRejectsConnectRequest() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, "HTTP/1.1 407 Proxy Authentication Required\r\n\r\n"); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 5000); + fail("Expected connect to throw when the proxy rejects the CONNECT request"); + } + catch (IOException expected) + { + // Expected + } + + // The failed tunnel must not leave a connected socket behind + proxiedSSLSocket.close(); + } + } + } + + /** + * Starts a local server that answers the CONNECT request one byte at a time, pausing between each byte. Used to + * show that the caller's timeout bounds the whole exchange rather than each individual read. + */ + private static void startTricklingProxy(final ServerSocket serverSocket, final String response, final long millisBetweenBytes) + { + Thread thread = new Thread(() -> + { + try (Socket accepted = serverSocket.accept()) + { + OutputStream outputStream = accepted.getOutputStream(); + for (byte b : response.getBytes(StandardCharsets.UTF_8)) + { + outputStream.write(b); + outputStream.flush(); + Thread.sleep(millisBetweenBytes); + } + + Thread.sleep(2000); + } + catch (IOException | InterruptedException e) + { + // Expected once the test closes its end of the connection + } + }); + + thread.setDaemon(true); + thread.start(); + } + + // A read timeout on its own only bounds each individual read, and the proxy's response is consumed one byte at a + // time. A proxy that sends a byte just before each interval expires would keep connect() blocked forever, so the + // timeout has to be enforced as a deadline across the whole response. + @Test(timeout = 30000) + public void connectTimesOutWhenProxyTricklesResponseSlowerThanTimeout() throws Exception + { + try (ServerSocket tricklingProxy = new ServerSocket(0)) + { + // Each byte arrives well within the 500ms timeout, but the full response would take far longer than it + startTricklingProxy(tricklingProxy, CONNECT_ESTABLISHED_RESPONSE, 200); + + try (Socket proxySocket = new Socket("127.0.0.1", tricklingProxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + long startMillis = System.currentTimeMillis(); + + try + { + proxiedSSLSocket.connect(DESTINATION, 500); + fail("Expected connect to time out rather than following the proxy's pace indefinitely"); + } + catch (SocketTimeoutException expected) + { + // Expected + } + + long elapsedMillis = System.currentTimeMillis() - startMillis; + + // Generous upper bound; without a deadline this would run until the whole response had trickled in + assertTrue( + "Expected connect to give up near its 500ms timeout, but it took " + elapsedMillis + "ms", + elapsedMillis < 5000); + + assertTrue( + "Expected connect to close the proxy socket after the handshake timed out", + proxySocket.isClosed()); + } + } + } + + // "\r\n\r\n".split("\r\n") discards trailing empty strings and yields an empty array, so a response with no status + // line used to fail with an ArrayIndexOutOfBoundsException that escaped the IOException cleanup path. + @Test(timeout = 30000) + public void connectThrowsIOExceptionAndClosesSocketWhenProxyResponseHasNoStatusLine() throws Exception + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, "\r\n\r\n"); + + try (Socket proxySocket = new Socket("127.0.0.1", proxy.getLocalPort())) + { + ProxiedSSLSocket proxiedSSLSocket = new ProxiedSSLSocket( + (SSLSocketFactory) SSLSocketFactory.getDefault(), proxySocket, null, null); + + try + { + proxiedSSLSocket.connect(DESTINATION, 5000); + fail("Expected connect to throw when the proxy response contains no status line"); + } + catch (IOException expected) + { + // Expected. An unchecked exception here would mean malformed responses bypass the cleanup path. + } + + assertTrue( + "Expected connect to close the proxy socket after a malformed proxy response", + proxySocket.isClosed()); + } + } + } + + /** + * Connecting to the proxy has to happen while handling connect(), not while creating the socket, because only then + * is a connect timeout available. If the socket returned by the factory were already connected, an unreachable + * proxy would block the calling thread for as long as the operating system's default TCP connect timeout allows, + * with no error for the transport layer to retry on. + */ + @Test + public void createSocketDoesNotConnectToTheProxy() throws IOException + { + int closedPort; + try (ServerSocket temporaryServer = new ServerSocket(0)) + { + closedPort = temporaryServer.getLocalPort(); + } + + //Nothing is listening on that port now, so any attempt to connect to it fails rather than hangs + HttpProxySocketFactory socketFactory = new HttpProxySocketFactory( + (SSLSocketFactory) SSLSocketFactory.getDefault(), + new ProxySettings(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", closedPort)))); + + // Creating the socket must not attempt to reach the proxy at all + Socket socket = socketFactory.createSocket(); + + try + { + socket.connect(DESTINATION, 5000); + fail("Expected connect to fail because nothing is listening on the proxy's port"); + } + catch (IOException expected) + { + // Expected. The connection to the proxy is attempted while handling connect, where the timeout applies. + } + } + + /** + * Guards the case above from being satisfied by a socket that never connects to the proxy at all. + */ + @Test + public void connectEstablishesTheTunnelWhenTheFactoryReturnsAnUnconnectedSocket() throws IOException + { + try (ServerSocket proxy = new ServerSocket(0)) + { + startFakeProxy(proxy, CONNECT_ESTABLISHED_RESPONSE); + + HttpProxySocketFactory socketFactory = new HttpProxySocketFactory( + (SSLSocketFactory) SSLSocketFactory.getDefault(), + new ProxySettings(new Proxy(Proxy.Type.HTTP, new InetSocketAddress("127.0.0.1", proxy.getLocalPort())))); + + Socket socket = socketFactory.createSocket(); + + socket.connect(DESTINATION, 5000); + + assertTrue("Expected the tunnel to be established through the proxy", socket.isConnected()); + } + } +}