feat(core): add HTTP/2 cleartext proxy support
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package dev.relism.flash.http;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.bytes.PooledSlice;
|
||||
import dev.relism.flash.http.HopByHopHeaders.Protocol;
|
||||
import dev.relism.flash.models.MutableHeaderMap;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HopByHopHeaderTest {
|
||||
@Test
|
||||
void sharedPolicyCoversAllFourProtocolConversions() {
|
||||
for (Protocol sourceProtocol : Protocol.values()) {
|
||||
for (Protocol targetProtocol : Protocol.values()) {
|
||||
MutableHeaderMap source = new MutableHeaderMap();
|
||||
add(source, "connection", "x-private, keep-alive");
|
||||
add(source, "x-private", "secret");
|
||||
add(source, "upgrade", "websocket");
|
||||
add(source, "te", "trailers");
|
||||
add(source, "x-end-to-end", "yes");
|
||||
|
||||
assertFalse(forward(source, "connection", "x-private", sourceProtocol, targetProtocol));
|
||||
assertFalse(forward(source, "x-private", "secret", sourceProtocol, targetProtocol));
|
||||
assertFalse(forward(source, "upgrade", "websocket", sourceProtocol, targetProtocol));
|
||||
assertTrue(forward(source, "x-end-to-end", "yes", sourceProtocol, targetProtocol));
|
||||
assertTrue(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_2));
|
||||
assertFalse(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_1_1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean forward(
|
||||
MutableHeaderMap source,
|
||||
String name,
|
||||
String value,
|
||||
Protocol sourceProtocol,
|
||||
Protocol targetProtocol) {
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
PooledSlice nameView = new PooledSlice();
|
||||
PooledSlice valueView = new PooledSlice();
|
||||
nameView.reset(nameBytes, 0, nameBytes.length);
|
||||
valueView.reset(valueBytes, 0, valueBytes.length);
|
||||
return HopByHopHeaders.shouldForward(
|
||||
source, nameView, valueView, sourceProtocol, targetProtocol);
|
||||
}
|
||||
|
||||
private static void add(MutableHeaderMap headers, String name, String value) {
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,13 @@ class GrpcInteropTest {
|
||||
@Test
|
||||
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.host("127.0.0.1").port(port).http2Enabled(true).build());
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.post("/flash.test.Echo/Unary", (request, response) ->
|
||||
response.type("application/grpc")
|
||||
.body(request.body().bytes())
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.client.Http2Client;
|
||||
import dev.relism.flash.http2.client.Http2ClientResponse;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class H2cPriorKnowledgeTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void priorKnowledgeRequiresItsIndependentOptIn() throws Exception {
|
||||
int disabledPort = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(disabledPort)
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
app.get("/", (request, response) -> "wrong protocol");
|
||||
app.start();
|
||||
|
||||
try (Socket socket = new Socket("127.0.0.1", disabledPort)) {
|
||||
socket.setSoTimeout(2_000);
|
||||
socket.getOutputStream().write(Http2Preface.clientPreface());
|
||||
byte[] prefix = socket.getInputStream().readNBytes(5);
|
||||
assertArrayEquals("HTTP/".getBytes(StandardCharsets.US_ASCII), prefix);
|
||||
}
|
||||
app.stop().join();
|
||||
|
||||
int enabledPort = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(enabledPort)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.get("/", (request, response) -> "h2c");
|
||||
app.start();
|
||||
|
||||
try (Http2Client client = new Http2Client()) {
|
||||
Http2ClientResponse response =
|
||||
client.get(URI.create("http://127.0.0.1:" + enabledPort + "/"));
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("h2c", new String(response.body(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -184,8 +184,14 @@ class Http2AbuseTest {
|
||||
@Test
|
||||
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
|
||||
int port = freePort();
|
||||
FlashApp app = FlashApp.create(FlashConfiguration.builder()
|
||||
.host("127.0.0.1").port(port).http2Enabled(true).h2StreamIdleTimeoutMs(20).build());
|
||||
FlashApp app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.h2StreamIdleTimeoutMs(20)
|
||||
.build());
|
||||
app.post("/idle", (request, response) -> request.body().bytes());
|
||||
app.start();
|
||||
ByteWriter headers = new ByteWriter(64);
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class Http2AuthorityTest {
|
||||
@Test
|
||||
void matchesExactIpPortAndSingleLabelWildcardAuthorities() {
|
||||
assertTrue(Http2Authority.matches("api.example.com:443", "api.example.com"));
|
||||
assertTrue(Http2Authority.matches("127.0.0.1:8443", "127.0.0.1"));
|
||||
assertTrue(Http2Authority.matches("one.example.com", "*.example.com"));
|
||||
assertFalse(Http2Authority.matches("example.com", "*.example.com"));
|
||||
assertFalse(Http2Authority.matches("two.one.example.com", "*.example.com"));
|
||||
assertFalse(Http2Authority.matches("other.example.net", "*.example.com"));
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,13 @@ class Http2ConnectTest {
|
||||
@Test
|
||||
void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.host("127.0.0.1").port(port).http2Enabled(true).build());
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.connect("tunnel", (request, response) ->
|
||||
response.type(ContentType.NONE).streaming(output -> {
|
||||
byte[] bytes = new byte[16];
|
||||
|
||||
@@ -253,7 +253,11 @@ class Http2ConnectionIntegrationTest {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.get("/api/ping", (request, response) -> "pong");
|
||||
app.start();
|
||||
|
||||
@@ -314,7 +318,11 @@ class Http2ConnectionIntegrationTest {
|
||||
AtomicInteger calls = new AtomicInteger();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.get(
|
||||
"/queued",
|
||||
(request, response) -> {
|
||||
@@ -380,7 +388,11 @@ class Http2ConnectionIntegrationTest {
|
||||
AtomicBoolean handlerEntered = new AtomicBoolean();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.get(
|
||||
"/",
|
||||
(request, response) -> {
|
||||
@@ -436,7 +448,7 @@ class Http2ConnectionIntegrationTest {
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.http2Enabled(true)
|
||||
.http2CleartextEnabled(true)
|
||||
.shutdownDrainTimeoutMs(5_000)
|
||||
.build());
|
||||
app.start();
|
||||
@@ -476,7 +488,11 @@ class Http2ConnectionIntegrationTest {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||
FlashConfiguration.builder()
|
||||
.port(port)
|
||||
.host("127.0.0.1")
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.start();
|
||||
|
||||
try (Socket first = new Socket("127.0.0.1", port)) {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http2.frame.FrameFlags;
|
||||
import dev.relism.flash.http2.frame.FrameType;
|
||||
import dev.relism.flash.http2.frame.FrameWriteBuffer;
|
||||
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.io.InputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.nio.file.Path;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class Http2MisdirectedRequestTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void authorityOutsideSelectedCertificateReceives421(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"misdirected.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
app.get("/", (request, response) -> "must not run");
|
||||
app.start();
|
||||
|
||||
try (SSLSocket socket =
|
||||
(SSLSocket)
|
||||
TestKeystores.trustAllClientContext()
|
||||
.getSocketFactory()
|
||||
.createSocket("localhost", port)) {
|
||||
SSLParameters parameters = socket.getSSLParameters();
|
||||
parameters.setApplicationProtocols(new String[] {"h2"});
|
||||
socket.setSSLParameters(parameters);
|
||||
socket.startHandshake();
|
||||
socket.getOutputStream().write(request("other.example"));
|
||||
assertEquals(421, readStatus(socket.getInputStream()));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] request(String authority) {
|
||||
ByteWriter bytes = new ByteWriter(128);
|
||||
bytes.writeBytes(Http2Preface.clientPreface());
|
||||
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
|
||||
frames.beginFrame(FrameType.SETTINGS, 0, 0);
|
||||
frames.endFrame();
|
||||
frames.beginFrame(
|
||||
FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1);
|
||||
HpackEncoder.writeIndexed(bytes, 2);
|
||||
HpackEncoder.writeIndexed(bytes, 7);
|
||||
HpackEncoder.writeLiteralWithNameIndex(
|
||||
bytes, 1, authority.getBytes(java.nio.charset.StandardCharsets.US_ASCII), false);
|
||||
HpackEncoder.writeIndexed(bytes, 4);
|
||||
frames.endFrame();
|
||||
byte[] result = new byte[bytes.length()];
|
||||
System.arraycopy(bytes.array(), 0, result, 0, result.length);
|
||||
return result;
|
||||
}
|
||||
|
||||
private static int readStatus(InputStream input) throws Exception {
|
||||
HpackDecoder decoder = new HpackDecoder();
|
||||
byte[] header = new byte[9];
|
||||
while (true) {
|
||||
input.readNBytes(header, 0, header.length);
|
||||
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||
int type = header[3] & 0xff;
|
||||
int streamId =
|
||||
((header[5] & 0x7f) << 24)
|
||||
| ((header[6] & 0xff) << 16)
|
||||
| ((header[7] & 0xff) << 8)
|
||||
| (header[8] & 0xff);
|
||||
byte[] payload = input.readNBytes(length);
|
||||
if (type != FrameType.HEADERS.code() || streamId != 1) continue;
|
||||
int[] status = {0};
|
||||
decoder.decode(
|
||||
payload,
|
||||
0,
|
||||
payload.length,
|
||||
(name, value, never) -> {
|
||||
if (name.length() == 7 && name.byteAt(0) == ':') {
|
||||
status[0] =
|
||||
(value.byteAt(0) - '0') * 100
|
||||
+ (value.byteAt(1) - '0') * 10
|
||||
+ value.byteAt(2)
|
||||
- '0';
|
||||
}
|
||||
});
|
||||
return status[0];
|
||||
}
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,13 @@ class Http2TrailersTest {
|
||||
@Test
|
||||
void requestTrailersReachHandlerAfterBodyEof() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.host("127.0.0.1").port(port).http2Enabled(true).build());
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.post("/trailers", (request, response) -> {
|
||||
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
|
||||
return request.trailers().first("grpc-status");
|
||||
@@ -95,8 +100,13 @@ class Http2TrailersTest {
|
||||
|
||||
private int startBlockingRoute() throws Exception {
|
||||
int port = freePort();
|
||||
app = FlashApp.create(FlashConfiguration.builder()
|
||||
.host("127.0.0.1").port(port).http2Enabled(true).build());
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.post("/trailers", (request, response) -> request.body().bytes());
|
||||
app.start();
|
||||
return port;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.proxy.HttpProxy;
|
||||
import dev.relism.flash.http2.client.Http2Client;
|
||||
import dev.relism.flash.http2.client.Http2ClientResponse;
|
||||
import dev.relism.flash.models.MutableHeaderMap;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProxyTrailerRelayTest {
|
||||
private FlashApp upstream;
|
||||
private FlashApp proxy;
|
||||
private Http2Client proxyUpstream;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (proxyUpstream != null) proxyUpstream.close();
|
||||
if (proxy != null) proxy.stop().join();
|
||||
if (upstream != null) upstream.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestAndResponseTrailersSurviveH2AndH1DownstreamProxyHops() throws Exception {
|
||||
int upstreamPort = freePort();
|
||||
upstream =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(upstreamPort)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
upstream.post(
|
||||
"/relay",
|
||||
(request, response) ->
|
||||
response
|
||||
.header("x-query", request.query("mode"))
|
||||
.header("x-private-seen", String.valueOf(request.header("x-private") != null))
|
||||
.body(request.body().bytes())
|
||||
.trailer("x-relayed-trailer", request.trailers().first("x-request-trailer")));
|
||||
upstream.start();
|
||||
|
||||
int proxyPort = freePort();
|
||||
proxyUpstream = new Http2Client();
|
||||
proxy =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(proxyPort)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
proxy.post(
|
||||
"/relay",
|
||||
HttpProxy.toHttp2(URI.create("http://127.0.0.1:" + upstreamPort), proxyUpstream));
|
||||
proxy.start();
|
||||
|
||||
MutableHeaderMap h2Headers = fields("connection", "x-private");
|
||||
add(h2Headers, "x-private", "must-not-cross");
|
||||
MutableHeaderMap h2Trailers = fields("x-request-trailer", "from-h2");
|
||||
try (Http2Client downstream = new Http2Client()) {
|
||||
Http2ClientResponse response =
|
||||
downstream.exchange(
|
||||
URI.create("http://127.0.0.1:" + proxyPort + "/relay?mode=h2"),
|
||||
HttpMethod.POST,
|
||||
h2Headers,
|
||||
"hello-h2".getBytes(StandardCharsets.UTF_8),
|
||||
h2Trailers);
|
||||
assertEquals("hello-h2", new String(response.body(), StandardCharsets.UTF_8));
|
||||
assertEquals("h2", response.headers().first("x-query"));
|
||||
assertEquals("false", response.headers().first("x-private-seen"));
|
||||
assertEquals("from-h2", response.trailers().first("x-relayed-trailer"));
|
||||
}
|
||||
|
||||
String h1 = h1Exchange(proxyPort);
|
||||
assertTrue(h1.contains("hello-h1"), h1);
|
||||
assertTrue(h1.toLowerCase().contains("x-query: h1"), h1);
|
||||
assertTrue(h1.toLowerCase().contains("x-private-seen: false"), h1);
|
||||
assertTrue(h1.toLowerCase().contains("x-relayed-trailer: from-h1"), h1);
|
||||
assertFalse(h1.contains("must-not-cross"), h1);
|
||||
}
|
||||
|
||||
private static String h1Exchange(int port) throws Exception {
|
||||
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||
socket.setSoTimeout(2_000);
|
||||
socket
|
||||
.getOutputStream()
|
||||
.write(
|
||||
("POST /relay?mode=h1 HTTP/1.1\r\n"
|
||||
+ "Host: 127.0.0.1\r\n"
|
||||
+ "Connection: x-private, close\r\n"
|
||||
+ "X-Private: must-not-cross\r\n"
|
||||
+ "Transfer-Encoding: chunked\r\n"
|
||||
+ "Trailer: x-request-trailer\r\n\r\n"
|
||||
+ "8\r\nhello-h1\r\n"
|
||||
+ "0\r\nX-Request-Trailer: from-h1\r\n\r\n")
|
||||
.getBytes(StandardCharsets.US_ASCII));
|
||||
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
|
||||
socket.getInputStream().transferTo(bytes);
|
||||
return bytes.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static MutableHeaderMap fields(String name, String value) {
|
||||
MutableHeaderMap headers = new MutableHeaderMap();
|
||||
add(headers, name, value);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static void add(MutableHeaderMap headers, String name, String value) {
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package dev.relism.flash.http2.client;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import dev.relism.flash.extension.FlashApp;
|
||||
import dev.relism.flash.extension.FlashConfiguration;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.MutableHeaderMap;
|
||||
import dev.relism.flash.tls.TestKeystores;
|
||||
import dev.relism.flash.tls.TlsConfig;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class Http2ClientTest {
|
||||
private FlashApp app;
|
||||
|
||||
@AfterEach
|
||||
void stop() {
|
||||
if (app != null) app.stop().join();
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusesOriginConnectionAndExchangesFlowControlledBodiesAndTrailers() throws Exception {
|
||||
int port = freePort();
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.http2CleartextEnabled(true)
|
||||
.build());
|
||||
app.post(
|
||||
"/relay",
|
||||
(request, response) -> {
|
||||
byte[] body = request.body().bytes();
|
||||
String checksum = request.trailers().first("x-request-checksum");
|
||||
return response
|
||||
.header("x-upstream", request.header("x-forwarded-test"))
|
||||
.body(body)
|
||||
.trailer("x-response-checksum", checksum);
|
||||
});
|
||||
app.start();
|
||||
|
||||
byte[] body = new byte[2 * 1024 * 1024 + 31];
|
||||
for (int i = 0; i < body.length; i++) body[i] = (byte) (i * 29);
|
||||
MutableHeaderMap requestHeaders = fields("x-forwarded-test", "yes");
|
||||
MutableHeaderMap requestTrailers = fields("x-request-checksum", "valid");
|
||||
|
||||
try (Http2Client client = new Http2Client()) {
|
||||
URI uri = URI.create("http://127.0.0.1:" + port + "/relay");
|
||||
Http2ClientResponse first =
|
||||
client.exchange(uri, HttpMethod.POST, requestHeaders, body, requestTrailers);
|
||||
Http2ClientResponse second =
|
||||
client.exchange(
|
||||
uri,
|
||||
HttpMethod.POST,
|
||||
requestHeaders,
|
||||
"again".getBytes(StandardCharsets.UTF_8),
|
||||
requestTrailers);
|
||||
|
||||
assertEquals(200, first.statusCode());
|
||||
assertEquals("yes", first.headers().first("x-upstream"));
|
||||
assertArrayEquals(body, first.body());
|
||||
assertEquals("valid", first.trailers().first("x-response-checksum"));
|
||||
assertArrayEquals("again".getBytes(StandardCharsets.UTF_8), second.body());
|
||||
assertEquals(1, client.pooledConnectionCount());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void negotiatesTlsAlpnAndVerifiesTheUpstreamHostname(@TempDir Path directory) throws Exception {
|
||||
int port = freePort();
|
||||
Path keystore =
|
||||
TestKeystores.build(
|
||||
directory,
|
||||
"http2-client.p12",
|
||||
"changeit",
|
||||
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||
app =
|
||||
FlashApp.create(
|
||||
FlashConfiguration.builder()
|
||||
.host("127.0.0.1")
|
||||
.port(port)
|
||||
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||
.http2Enabled(true)
|
||||
.build());
|
||||
app.get("/secure", (request, response) -> "tls-h2");
|
||||
app.start();
|
||||
|
||||
try (Http2Client client = new Http2Client(TestKeystores.trustAllClientContext())) {
|
||||
Http2ClientResponse response =
|
||||
client.get(URI.create("https://localhost:" + port + "/secure"));
|
||||
assertEquals(200, response.statusCode());
|
||||
assertEquals("tls-h2", new String(response.body(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
|
||||
private static MutableHeaderMap fields(String name, String value) {
|
||||
MutableHeaderMap headers = new MutableHeaderMap();
|
||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
|
||||
return headers;
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (ServerSocket socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc
|
||||
* for why it does not itself consult {@code FlashConfiguration.http2Enabled}) — every case here
|
||||
* for why it does not itself consult {@code FlashConfiguration}) — every case here
|
||||
* calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}.
|
||||
*/
|
||||
class ProtocolNegotiatorTest {
|
||||
|
||||
Reference in New Issue
Block a user