Skip to content

Commit

Permalink
HTTP 1 parser, CONNECT host:port fix, HTTP2 support tests
Browse files Browse the repository at this point in the history
Signed-off-by: Jorge Bescos Gascon <jorge.bescos.gascon@oracle.com>
  • Loading branch information
jbescos committed Aug 2, 2023
1 parent 39b644b commit 1a54714
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 119 deletions.
4 changes: 4 additions & 0 deletions nima/tests/integration/webclient/webclient/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@
<groupId>io.helidon.nima.webclient</groupId>
<artifactId>helidon-nima-webclient</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.nima.http2</groupId>
<artifactId>helidon-nima-http2-webclient</artifactId>
</dependency>
<dependency>
<groupId>io.helidon.nima.webserver</groupId>
<artifactId>helidon-nima-webserver</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,22 @@
import java.net.Socket;
import java.util.Arrays;
import java.util.Base64;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/**
* HttpProxy implementation. It has to handle two sockets per each connection to it:
* 1. The socket that starts the connection to the HTTP Proxy, known as origin.
* 2. The socket that will connect from the HTTP Proxy to the desired remote host, known as remote.
*
* An HTTP Proxy has to primarily pass data from both sides, origin to remote and remote to origin.
* Before doing this, it has to handle a first request from the origin to know where is the remote host.
*
* An instance of HttpProxy can not be re-used after stopping it.
*/
class HttpProxy {

private static final System.Logger LOGGER = System.getLogger(HttpProxy.class.getName());
Expand All @@ -53,16 +64,21 @@ class HttpProxy {
this(port, null, null);
}

boolean start() {
void start() {
CountDownLatch ready = new CountDownLatch(1);
executor.submit(() -> {
try (ServerSocket server = new ServerSocket(port)) {
this.connectedPort = server.getLocalPort();
LOGGER.log(Level.INFO, "Listening connections in port: " + connectedPort);
while (!stop) {
// Origin is the socket that starts the connection
Socket origin = server.accept();
LOGGER.log(Level.DEBUG, "Open: " + origin);
counter.incrementAndGet();
ready.countDown();
origin.setSoTimeout(TIMEOUT);
// Remote is the socket that will connect to the desired host, for example www.google.com
// It is not connected yet because we need to wait for the HTTP CONNECT to know where.
Socket remote = new Socket();
remote.setSoTimeout(TIMEOUT);
MiddleCommunicator remoteToOrigin = new MiddleCommunicator(executor, remote, origin, null);
Expand All @@ -82,9 +98,10 @@ boolean start() {
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(connectedPort), 10000);
responding = true;
} catch (IOException e) {}
// Wait for counter is set to 0
ready.await(5, TimeUnit.SECONDS);
} catch (IOException | InterruptedException e) {}
}
return responding;
}

int counter() {
Expand All @@ -108,11 +125,23 @@ int connectedPort() {
return connectedPort;
}

/**
* This is the core of the HTTP Proxy. One HTTP Proxy must run 2 instances of this, as it will be explained here.
*
* Its goal is to forward what arrives. There are two workflows:
* 1. Listen from origin to forward it to remote.
* 2. Listen from remote to forward it to origin.
*
* One instance of MiddleCommunicator can only handle 1 workflow, then you need 2 instances of it because
* it is needed to send data to remote, and also read data from it.
*
* The workflow number 1 (originToRemote) also requires one additional step. It has to handle
* an HTTP CONNECT request before start forwarding. This is needed to know where is the remote and to authenticate.
*/
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;
Expand All @@ -125,6 +154,7 @@ private MiddleCommunicator(ExecutorService executor, Socket readerSocket, Socket
this.readerSocket = readerSocket;
this.writerSocket = writerSocket;
this.originToRemote = callback != null;
// Both are the same thing with different name. The only purpose of this is to understand better stack traces.
this.reader = originToRemote ? new OriginToRemoteReader() : new RemoteToOriginReader();
this.callback = callback;
}
Expand Down Expand Up @@ -156,46 +186,41 @@ public void run() {
byte[] buffer = new byte[BUFFER_SIZE];
Exception exception = null;
try {
boolean handleFirstRequest = true;
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();
}
}
final int readb = read;
LOGGER.log(Level.DEBUG, () -> readerSocket + " read " + readb + " bytes\n" + new String(buffer, 0, readb));
// Handling workflow number 1
if (originToRemote && handleFirstRequest) {
handleFirstRequest = false;
// It is expected the first request is HTTP CONNECT
OriginInfo originInfo = getOriginInfo(buffer, readb);
LOGGER.log(Level.DEBUG, "Incoming request: " + originInfo);
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());
// Now we know where to connect, so we can connect the socket to the remote.
callback.start();
readerSocket.getOutputStream().flush();
} else {
writerSocket.getOutputStream().write(buffer, 0, read);
writerSocket.getOutputStream().flush();
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().write(buffer, 0, readb);
writerSocket.getOutputStream().flush();
}
}
} catch (IOException e) {
} catch (Exception e) {
exception = e;
// LOGGER.log(Level.SEVERE, e.getMessage(), e);
} finally {
Expand All @@ -222,16 +247,14 @@ private OriginInfo getOriginInfo(byte[] buffer, int read) throws MalformedURLExc
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
// Make it easy to understand stack traces
private class OriginToRemoteReader extends Reader {
@Override
public void run() {
Expand Down Expand Up @@ -261,12 +284,7 @@ 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(":");
String[] hostPort = parts[1].split(":");
this.host = hostPort[0];
if (hostPort.length > 1) {
this.port = Integer.parseInt(hostPort[1]);
Expand All @@ -282,10 +300,6 @@ private void parseAuthorization(String line) {
password = userPass[1];
}

private boolean respondOrigin() {
return CONNECT.equals(method);
}

@Override
public String toString() {
return "OriginInfo [host=" + host + ", port=" + port + ", protocol=" + protocol + ", method=" + method
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,15 @@
import java.net.ProxySelector;

import io.helidon.common.http.Http;
import io.helidon.nima.http2.webclient.Http2Client;
import io.helidon.nima.testing.junit5.webserver.ServerTest;
import io.helidon.nima.testing.junit5.webserver.SetUpRoute;
import io.helidon.nima.webclient.api.HttpClient;
import io.helidon.nima.webclient.api.HttpClientResponse;
import io.helidon.nima.webclient.api.Proxy;
import io.helidon.nima.webclient.api.Proxy.ProxyType;
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.http.HttpRouting;

import org.junit.jupiter.api.AfterEach;
Expand All @@ -43,7 +46,8 @@ class HttpProxyTest {
private int proxyPort;
private HttpProxy httpProxy;

private final Http1Client client;
private final HttpClient<?> clientHttp1;
private final HttpClient<?> clientHttp2;

@SetUpRoute
static void routing(HttpRouting.Builder router) {
Expand All @@ -55,79 +59,121 @@ public void before() {
httpProxy = new HttpProxy(0);
httpProxy.start();
proxyPort = httpProxy.connectedPort();
assertThat(httpProxy.counter(), is(0));
}

@AfterEach
public void after() {
httpProxy.stop();
}

HttpProxyTest(Http1Client client) {
this.client = client;
HttpProxyTest(WebServer server) {
String uri = "http://localhost:" + server.port();
this.clientHttp1 = Http1Client.builder().baseUri(uri).build();
this.clientHttp2 = Http2Client.builder().baseUri(uri).build();
}

@Test
void testNoProxy() {
noProxyChecks();
void testNoProxy1() {
noProxyChecks(clientHttp1);
}

@Test
void testNoProxyTypeDefaultsToNone() {
noProxyChecks();
void testNoProxy2() {
noProxyChecks(clientHttp2);
}

@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));
void testNoHosts1() {
noHosts(clientHttp1);
}

@Test
void testNoHosts2() {
noHosts(clientHttp2);
}

@Test
void testNoProxyTypeButHasHost() {
void testNoProxyTypeButHasHost1() {
Proxy proxy = Proxy.builder().host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy);
successVerify(proxy, clientHttp1);
}

@Test
void testProxyNoneTypeButHasHost() {
void testNoProxyTypeButHasHost2() {
Proxy proxy = Proxy.builder().host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy, clientHttp2);
}

@Test
void testProxyNoneTypeButHasHost1() {
Proxy proxy = Proxy.builder().type(ProxyType.NONE).host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy, clientHttp1);
}

@Test
void testProxyNoneTypeButHasHost2() {
Proxy proxy = Proxy.builder().type(ProxyType.NONE).host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy);
successVerify(proxy, clientHttp2);
}

@Test
void testSimpleProxy1() {
Proxy proxy = Proxy.builder().type(ProxyType.HTTP).host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy, clientHttp1);
}

@Test
void testSimpleProxy() {
void testSimpleProxy2() {
Proxy proxy = Proxy.builder().type(ProxyType.HTTP).host(PROXY_HOST).port(proxyPort).build();
successVerify(proxy);
successVerify(proxy, clientHttp2);
}

@Test
void testSystemProxy1() {
ProxySelector original = ProxySelector.getDefault();
try {
ProxySelector.setDefault(ProxySelector.of(new InetSocketAddress(PROXY_HOST, proxyPort)));
Proxy proxy = Proxy.create();
successVerify(proxy, clientHttp1);
} finally {
ProxySelector.setDefault(original);
}
}

@Test
void testSystemProxy() {
void testSystemProxy2() {
ProxySelector original = ProxySelector.getDefault();
try {
ProxySelector.setDefault(ProxySelector.of(new InetSocketAddress(PROXY_HOST, proxyPort)));
Proxy proxy = Proxy.create();
successVerify(proxy);
successVerify(proxy, clientHttp2);
} finally {
ProxySelector.setDefault(original);
}
}

private void successVerify(Proxy proxy) {
try (Http1ClientResponse response = client.get("/get").proxy(proxy).request()) {
private void noHosts(HttpClient<?> client) {
Proxy proxy = Proxy.builder().host(PROXY_HOST).port(proxyPort).addNoProxy(PROXY_HOST).build();
try (HttpClientResponse 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));
}

private void successVerify(Proxy proxy, HttpClient<?> client) {
try (HttpClientResponse 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()) {
private void noProxyChecks(HttpClient<?> client) {
try (HttpClientResponse response = client.get("/get").request()) {
assertThat(response.status(), is(Http.Status.OK_200));
String entity = response.entity().as(String.class);
assertThat(entity, is("Hello"));
Expand Down

0 comments on commit 1a54714

Please sign in to comment.