feat(core): add HTTP/2 cleartext proxy support

This commit is contained in:
Zakaria El Orche
2026-08-13 20:00:59 +00:00
parent 5755ef77fe
commit 3c1eb0d0df
27 changed files with 1593 additions and 109 deletions
+21 -10
View File
@@ -172,7 +172,8 @@ app.onException((ex, req, res) -> {
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | | `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | | `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | | `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `http2Enabled` | `false` | Whether the server negotiates HTTP/2 through ALPN or accepts h2c prior knowledge. The conservative default keeps protocol rollout explicit. | | `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. | | `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. | | `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. | | `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
@@ -349,7 +350,21 @@ return res.streaming(stream -> {
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
separate extension. future `flash-ext-grpc` extension.
### HTTP/2 upstream proxy
The core includes a deliberately small, proxy-oriented HTTP/2 client and a protocol-neutral relay:
```java
Http2Client upstream = new Http2Client();
app.post("/service/{path}",
HttpProxy.toHttp2(URI.create("http://service.internal:8080"), upstream));
```
The relay preserves the path, query, body and trailers and applies one shared hop-by-hop field
policy for HTTP/1.1 and HTTP/2. Close the client when the application stops. Cleartext upstreams
use prior knowledge; Flash never implements the obsolete `Upgrade: h2c` mechanism.
## Architecture ## Architecture
@@ -358,13 +373,9 @@ TransportFactory.create() # binds every listener, wires the connection
→ AcceptLoop # one per listener × accept thread; hands sockets off → AcceptLoop # one per listener × accept thread; hands sockets off
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation → ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once → ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later ├─ Http1Connection.run() # request parser, router, handler, h1 response writer
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive └─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl → RequestHandler.handle() # the same protocol-neutral request/response API
→ RequestHandler.handle() # user handler; return value sets body
→ Request.drain() # consume unread body for keep-alive
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header, or ServerLifecycle draining
``` ```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required. - **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
@@ -372,7 +383,7 @@ TransportFactory.create() # binds every listener, wires the connection
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection. - **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported. - **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which. - **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown. - **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
## Build & test ## Build & test
+42
View File
@@ -0,0 +1,42 @@
# HTTP/2 cleartext and proxying
TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls:
- `http2Enabled` advertises `h2` through TLS ALPN.
- `http2CleartextEnabled` accepts the HTTP/2 prior-knowledge preface on plaintext listeners.
Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete
HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported.
## Upstream client
`Http2Client` is a synchronous, pooled client for reverse-proxy handlers. It supports TLS ALPN and
h2c prior knowledge, request and response bodies, flow control, response status, trailers,
SETTINGS, PING, GOAWAY and RST_STREAM. Connections are pooled by origin and reused across
sequential exchanges. A connection serializes its exchanges deliberately; this keeps ownership
and HPACK state explicit and bounded while virtual threads allow independent origins to progress.
It is not intended to replace a general-purpose HTTP client.
`HttpProxy.toHttp2(origin, client)` adapts Flash's shared `Request` and `Response` models to that
client. It preserves the incoming raw path and query, body, end-to-end fields and trailers.
## Header conversion
`HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by
`Connection`, the standard hop-by-hop set, HTTP/2-forbidden fields and pseudo-fields. `TE` is
forwarded only as `trailers` when the target is HTTP/2. Tests execute the same policy for all four
HTTP/1.1 and HTTP/2 source/target combinations.
## Authority and 421
On TLS HTTP/2 connections, Flash checks `:authority` against the selected certificate's DNS/IP
subject alternative names. An authority outside that served set receives `421 Misdirected
Request`, allowing a coalescing client to retry on a different connection. Exact names and
single-label wildcards are supported; h2c has no certificate identity and is unaffected.
## Trailer guarantee
The proxy copies request trailers only after the incoming body reaches EOF and emits upstream
trailers as a trailing HEADERS block. Response trailers follow the reverse path and remain
trailers on both HTTP/2 and HTTP/1.1 chunked downstream connections. The live relay tests cover
both downstream protocols.
+27 -7
View File
@@ -982,23 +982,22 @@ window and pool byte capacity together; never raise credit independently of boun
--- ---
## DEC-29 — Keep HTTP/2 opt-in through the cleartext rollout boundary ## DEC-29 — Keep TLS HTTP/2 opt-in until the compliance gate
**Context.** Trailers and push streaming make the protocol feature-complete for ordinary and gRPC- **Context.** Trailers and push streaming make the protocol feature-complete for ordinary and gRPC-
shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned
by the following security phase. by the following security phase.
**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false`. Applications can **Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false`. Phase 14 separates
enable the complete path explicitly. The Phase 13 hostile-peer suite is now green, but the same cleartext behind its own `http2CleartextEnabled` opt-in, also defaulting to `false`. Passing the
flag currently also admits cleartext prior-knowledge traffic; Phase 14 owns splitting that into a hostile-peer gate removes the security blocker, but changing the TLS default remains deferred
separate `http2CleartextEnabled` opt-in before the general protocol default can change safely. until the complete external conformance gate is green.
**Consequence.** Existing deployments do not silently expose a newly completed protocol before its **Consequence.** Existing deployments do not silently expose a newly completed protocol before its
adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use
the same public request/response, header, trailer and streaming APIs. the same public request/response, header, trailer and streaming APIs.
**Revisit when.** At Phase 14 closure, after TLS HTTP/2 and cleartext h2c have independent rollout **Revisit when.** At Phase 16 closure, after the external compliance matrix is green.
controls.
--- ---
@@ -1022,3 +1021,24 @@ thread. A fixed control-intent pool and one-in-flight intent per live stream bou
aggregate default; tune the threshold from evidence without splitting the defence by frame type. aggregate default; tune the threshold from evidence without splitting the defence by frame type.
--- ---
## DEC-31 — Keep the upstream HTTP/2 client proxy-oriented and single-owner
**Context.** A general-purpose HTTP client would introduce a second large public API, redirect,
cookie, authentication and retry policy, while the immediate requirement is a reliable Flash
reverse-proxy hop with trailers.
**Decision.** Pool one reusable connection per origin and serialize exchanges on that connection.
Reuse the core frame reader/writer and HPACK codec, but keep response assembly and ownership inside
the client connection. Expose `HttpProxy.toHttp2` as the protocol-neutral adapter and one shared
`HopByHopHeaders` policy for every conversion direction.
**Consequence.** HPACK and socket state have one clear owner, upstream connections are reused, and
trailer semantics cannot diverge by downstream protocol. Concurrent calls to one origin queue
behind its active exchange rather than pretending this minimal client is a fully multiplexed
general-purpose stack.
**Revisit when.** Proxy production traces show per-origin serialization is a bottleneck; add a
bounded pool or client-side multiplexing without changing the proxy-facing API.
---
+3 -3
View File
@@ -75,7 +75,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. | | 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. |
| 12 — Trailers, half-close, gRPC | done | `feature/core/http2` | Protocol-neutral request/response trailers, bounded push streaming, four half-close orderings and authority-form CONNECT tunnels complete. Real grpcurl 1.9.3 unary/server-streaming/error interop passes. EX-48/49 fixed. HTTP/2 remains opt-in until the Phase 13 hostile-peer gate (DEC-29). 649/649 tests green from a clean `-Pjmh` build. | | 12 — Trailers, half-close, gRPC | done | `feature/core/http2` | Protocol-neutral request/response trailers, bounded push streaming, four half-close orderings and authority-form CONNECT tunnels complete. Real grpcurl 1.9.3 unary/server-streaming/error interop passes. EX-48/49 fixed. HTTP/2 remains opt-in until the Phase 13 hostile-peer gate (DEC-29). 649/649 tests green from a clean `-Pjmh` build. |
| 13 — Security hardening & abuse resistance | done | `feature/core/http2` | Two-bucket Rapid Reset/stream/settings/ping/aggregate counters, control/write queue bounds, optional stream/byte/lifetime budgets, absolute header and idle-stream deadlines, and hostile-peer suite complete. Security review found+fixed EX-50/51. JMH counter: 38.083 ns/op, ~10^-4 B/op, no GC. 100k-CONTINUATION attack terminates in under 2 s with bounded retained heap. 663/663 tests green from a clean `-Pjmh` build. | | 13 — Security hardening & abuse resistance | done | `feature/core/http2` | Two-bucket Rapid Reset/stream/settings/ping/aggregate counters, control/write queue bounds, optional stream/byte/lifetime budgets, absolute header and idle-stream deadlines, and hostile-peer suite complete. Security review found+fixed EX-50/51. JMH counter: 38.083 ns/op, ~10^-4 B/op, no GC. 100k-CONTINUATION attack terminates in under 2 s with bounded retained heap. 663/663 tests green from a clean `-Pjmh` build. |
| 14 — h2c prior knowledge + proxy support | not started | — | — | | 14 — h2c prior knowledge + proxy support | done | `feature/core/http2` | Independent TLS/h2c gates, pooled proxy-oriented h2 client with TLS ALPN and h2c, bidirectional h1/h2 trailer relay, shared four-direction hop-by-hop policy and certificate-backed 421 handling complete. Real grpcurl h2c interop passes. 670/670 tests green from a clean `-Pjmh` build. |
| 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — | | 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — |
| 16 — Compliance test suite | not started | — | — | | 16 — Compliance test suite | not started | — | — |
| 17 — Benchmarks, allocation gates, tuning | not started | — | — | | 17 — Benchmarks, allocation gates, tuning | not started | — | — |
@@ -2906,8 +2906,8 @@ speak h2 as a **client** so Pathway can proxy.
`flash/docs/http2/CLEARTEXT-AND-PROXY.md`. `flash/docs/http2/CLEARTEXT-AND-PROXY.md`.
### DoD ### DoD
- [ ] gRPC over h2c works end to end. - [x] gRPC over h2c works end to end.
- [ ] Trailers survive a Flash→Flash proxy hop in both directions. - [x] Trailers survive a Flash→Flash proxy hop in both directions.
--- ---
@@ -88,13 +88,15 @@ public class FlashConfiguration {
*/ */
@Builder.Default int shutdownDrainTimeoutMs = 15_000; @Builder.Default int shutdownDrainTimeoutMs = 15_000;
/** /** Whether TLS listeners advertise HTTP/2 through ALPN. */
* Whether this server negotiates HTTP/2. When enabled, plaintext listeners recognize h2c prior
* knowledge and TLS listeners advertise {@code h2} followed by HTTP/1.1 through ALPN. Disabled by
* default until the HTTP/2 request/response path is complete.
*/
@Builder.Default boolean http2Enabled = false; @Builder.Default boolean http2Enabled = false;
/**
* Whether plaintext listeners accept the HTTP/2 prior-knowledge preface. This is independent
* from TLS HTTP/2 and deliberately disabled by default.
*/
@Builder.Default boolean http2CleartextEnabled = false;
/** /**
* Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always * Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always
* compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses. * compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses.
@@ -0,0 +1,87 @@
package dev.relism.flash.http;
import dev.relism.flash.models.HeaderView;
import dev.relism.fpr.core.ByteView;
/** Shared proxy policy for fields that must not cross an HTTP connection boundary. */
public final class HopByHopHeaders {
public enum Protocol {
HTTP_1_1,
HTTP_2
}
private HopByHopHeaders() {}
/** Returns whether a field may be copied to a new downstream connection. */
public static boolean shouldForward(
HeaderView source,
ByteView name,
ByteView value,
Protocol sourceProtocol,
Protocol targetProtocol) {
if (name.length() == 0 || name.byteAt(0) == ':') return false;
if (is(name, "connection")
|| is(name, "keep-alive")
|| is(name, "proxy-connection")
|| is(name, "proxy-authenticate")
|| is(name, "proxy-authorization")
|| is(name, "trailer")
|| is(name, "transfer-encoding")
|| is(name, "upgrade")) {
return false;
}
if (isConnectionListed(source, name)) return false;
if (is(name, "te")) {
return targetProtocol == Protocol.HTTP_2 && isTrimmed(value, "trailers");
}
return true;
}
private static boolean isConnectionListed(HeaderView source, ByteView fieldName) {
for (String value : source.all("connection")) {
int start = 0;
while (start < value.length()) {
int comma = value.indexOf(',', start);
int end = comma < 0 ? value.length() : comma;
while (start < end && isWhitespace(value.charAt(start))) start++;
while (end > start && isWhitespace(value.charAt(end - 1))) end--;
if (equalsAsciiIgnoreCase(fieldName, value, start, end)) return true;
start = comma < 0 ? value.length() : comma + 1;
}
}
return false;
}
private static boolean is(ByteView bytes, String expected) {
return equalsAsciiIgnoreCase(bytes, expected, 0, expected.length());
}
private static boolean isTrimmed(ByteView bytes, String expected) {
int start = 0;
int end = bytes.length();
while (start < end && isWhitespace((char) bytes.byteAt(start))) start++;
while (end > start && isWhitespace((char) bytes.byteAt(end - 1))) end--;
if (end - start != expected.length()) return false;
for (int i = 0; i < expected.length(); i++) {
if (lower(bytes.byteAt(start + i) & 0xff) != lower(expected.charAt(i))) return false;
}
return true;
}
private static boolean equalsAsciiIgnoreCase(
ByteView bytes, String expected, int expectedStart, int expectedEnd) {
if (bytes.length() != expectedEnd - expectedStart) return false;
for (int i = 0; i < bytes.length(); i++) {
if (lower(bytes.byteAt(i) & 0xff) != lower(expected.charAt(expectedStart + i))) return false;
}
return true;
}
private static int lower(int value) {
return value >= 'A' && value <= 'Z' ? value + ('a' - 'A') : value;
}
private static boolean isWhitespace(char value) {
return value == ' ' || value == '\t';
}
}
@@ -0,0 +1,92 @@
package dev.relism.flash.http.proxy;
import dev.relism.flash.http.HopByHopHeaders;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.http2.client.Http2Client;
import dev.relism.flash.http2.client.Http2ClientResponse;
import dev.relism.flash.models.HeaderView;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.fpr.core.ByteView;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Protocol-neutral reverse-proxy adapter backed by Flash's HTTP/2 upstream client. */
public final class HttpProxy {
private HttpProxy() {}
/** Creates a handler that preserves the incoming path, query, fields, body and trailers. */
public static SimpleHandler.FunctionalHandler toHttp2(URI upstreamOrigin, Http2Client client) {
Objects.requireNonNull(upstreamOrigin, "upstreamOrigin");
Objects.requireNonNull(client, "client");
return (request, response) -> relay(upstreamOrigin, client, request, response);
}
private static Response relay(
URI upstreamOrigin, Http2Client client, Request request, Response response) throws Exception {
byte[] body = request.body().bytes();
Protocol downstream =
request.getRequestLine().getProtocol() == null ? Protocol.HTTP_2 : Protocol.HTTP_1_1;
URI target = upstreamOrigin.resolve(rawTarget(request));
Http2ClientResponse upstream =
client.exchange(
target,
request.method(),
request.getRequestLine().getHeaders(),
body,
request.trailers());
response.status(upstream.statusCode()).body(upstream.body());
copyHeaders(upstream.headers(), Protocol.HTTP_2, downstream, response, false);
copyHeaders(upstream.trailers(), Protocol.HTTP_2, downstream, response, true);
return response;
}
private static String rawTarget(Request request) {
String path = request.path();
ByteView query = request.getRequestLine().getQuery();
if (query == null || query.length() == 0) return path;
byte[] bytes = new byte[query.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = query.byteAt(i);
return path + "?" + new String(bytes, StandardCharsets.US_ASCII);
}
private static void copyHeaders(
HeaderView source,
Protocol sourceProtocol,
Protocol targetProtocol,
Response response,
boolean trailers) {
source.forEach(
(name, value) -> {
if (!HopByHopHeaders.shouldForward(
source, name, value, sourceProtocol, targetProtocol)) return;
if (!trailers && (equalsAscii(name, "content-length") || equalsAscii(name, "content-type"))) {
if (equalsAscii(name, "content-type")) response.type(string(value));
return;
}
if (trailers) response.trailer(string(name), string(value));
else response.header(string(name), string(value));
});
}
private static String string(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.UTF_8);
}
private static boolean equalsAscii(ByteView bytes, String value) {
if (bytes.length() != value.length()) return false;
for (int i = 0; i < bytes.length(); i++) {
int left = bytes.byteAt(i) & 0xff;
int right = value.charAt(i);
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash.http2;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLSession;
/** Validates a coalesced request authority against the certificate selected for its connection. */
final class Http2Authority {
private Http2Authority() {}
static boolean isServed(String authority, SSLSession session) {
if (session == null || authority == null) return true;
String host = host(authority);
try {
Certificate[] certificates = session.getLocalCertificates();
if (certificates == null || certificates.length == 0
|| !(certificates[0] instanceof X509Certificate certificate)) {
return true;
}
Collection<List<?>> names = certificate.getSubjectAlternativeNames();
if (names == null) return true;
for (List<?> name : names) {
int type = (Integer) name.get(0);
if ((type == 2 || type == 7) && matches(host, name.get(1).toString())) return true;
}
return false;
} catch (CertificateParsingException failure) {
return true;
}
}
static boolean matches(String authority, String certificateName) {
String host = host(authority).toLowerCase(Locale.ROOT);
String name = certificateName.toLowerCase(Locale.ROOT);
if (!name.startsWith("*.")) return host.equals(name);
String suffix = name.substring(1);
if (!host.endsWith(suffix)) return false;
int prefixLength = host.length() - suffix.length();
return prefixLength > 0 && host.indexOf('.') == prefixLength;
}
private static String host(String authority) {
if (authority.startsWith("[")) {
int closing = authority.indexOf(']');
return closing < 0 ? authority : authority.substring(1, closing);
}
int colon = authority.lastIndexOf(':');
return colon > 0 && authority.indexOf(':') == colon ? authority.substring(0, colon) : authority;
}
}
@@ -7,7 +7,7 @@ import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */ /** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
final class Http2Preface { public final class Http2Preface {
private static final byte[] CLIENT_PREFACE = private static final byte[] CLIENT_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII); "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER_SETTINGS = buildServerSettings(); private static final byte[] SERVER_SETTINGS = buildServerSettings();
@@ -16,6 +16,11 @@ final class Http2Preface {
private Http2Preface() {} private Http2Preface() {}
/** Immutable client connection preface bytes. Callers must not modify the returned array. */
public static byte[] clientPreface() {
return CLIENT_PREFACE;
}
static int clientPrefaceLength() { static int clientPrefaceLength() {
return CLIENT_PREFACE.length; return CLIENT_PREFACE.length;
} }
@@ -1,6 +1,7 @@
package dev.relism.flash.http2; package dev.relism.flash.http2;
import dev.relism.flash.http.HttpMethod; import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.http2.frame.Http2FrameWriter; import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.message.Http2ResponseWriter; import dev.relism.flash.http2.message.Http2ResponseWriter;
import dev.relism.flash.http2.stream.Http2FlowController; import dev.relism.flash.http2.stream.Http2FlowController;
@@ -103,17 +104,21 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
Response pooled = stream.resetResponse(); Response pooled = stream.resetResponse();
Response response = pooled; Response response = pooled;
Object routeScratch = stream.routeScratch(context.router()); Object routeScratch = stream.routeScratch(context.router());
RequestHandler handler = context.router().route(request, routeScratch); if (!Http2Authority.isServed(request.header("host"), request.sslSession())) {
if (handler == null) handler = context.router().getNotFoundHandler(); response.status(HttpStatus.MISDIRECTED_REQUEST);
try { } else {
Object result = handler.handle(request, response); RequestHandler handler = context.router().route(request, routeScratch);
if (result instanceof Response returned) response = returned; if (handler == null) handler = context.router().getNotFoundHandler();
else if (result != null) response.setBody(result); try {
} catch (Exception handlerFailure) { Object result = handler.handle(request, response);
Object result = if (result instanceof Response returned) response = returned;
context.router().getExceptionHandler().handle(handlerFailure, request, response); else if (result != null) response.setBody(result);
if (result instanceof Response returned) response = returned; } catch (Exception handlerFailure) {
else if (result != null) response.setBody(result); Object result =
context.router().getExceptionHandler().handle(handlerFailure, request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
}
} }
boolean pushStreaming = response.isPushStreaming(); boolean pushStreaming = response.isPushStreaming();
@@ -0,0 +1,621 @@
package dev.relism.flash.http2.client;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.http.HopByHopHeaders;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2Preface;
import dev.relism.flash.http2.Http2Settings;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.frame.Padding;
import dev.relism.flash.http2.frame.WriteIntent;
import dev.relism.flash.http2.hpack.ContinuationAssembler;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.models.EmptyHeaderView;
import dev.relism.flash.models.HeaderView;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.fpr.core.ByteView;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
/**
* Small pooled HTTP/2 client for Flash proxy handlers. It intentionally exposes synchronous
* request/response exchange rather than trying to be a general-purpose client API.
*/
public final class Http2Client implements Closeable {
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int MAX_RESPONSE_BODY_SIZE = Http2Limits.MAX_REQUEST_BODY_SIZE;
private final ConcurrentHashMap<Origin, Connection> connections = new ConcurrentHashMap<>();
private final SSLContext sslContext;
public Http2Client() {
this(null);
}
public Http2Client(SSLContext sslContext) {
this.sslContext = sslContext;
}
public Http2ClientResponse get(URI uri) throws IOException {
return exchange(
uri,
HttpMethod.GET,
EmptyHeaderView.INSTANCE,
new byte[0],
EmptyHeaderView.INSTANCE);
}
public Http2ClientResponse exchange(
URI uri, HttpMethod method, HeaderView headers, byte[] body, HeaderView trailers)
throws IOException {
Objects.requireNonNull(uri, "uri");
Objects.requireNonNull(method, "method");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(trailers, "trailers");
Origin origin = Origin.from(uri);
Connection connection;
try {
connection = connections.computeIfAbsent(origin, this::openUnchecked);
} catch (OpenFailure failure) {
throw failure.io;
}
try {
return connection.exchange(uri, method, headers, body, trailers);
} catch (IOException | RuntimeException failure) {
connections.remove(origin, connection);
connection.close();
throw failure;
}
}
@Override
public void close() {
for (Connection connection : connections.values()) connection.close();
connections.clear();
}
/** Number of currently pooled origin connections. */
public int pooledConnectionCount() {
return connections.size();
}
private Connection openUnchecked(Origin origin) {
try {
return new Connection(origin, sslContext);
} catch (IOException failure) {
throw new OpenFailure(failure);
}
}
private static final class Connection implements Closeable {
private final Socket socket;
private final OutputStream output;
private final Http2FrameReader reader;
private final Http2FrameWriter writer;
private final Http2Settings peerSettings = new Http2Settings();
private final HpackDecoder decoder = new HpackDecoder();
private final ContinuationAssembler headers = new ContinuationAssembler();
private final ByteWriter outgoing = new ByteWriter(16 * 1024);
private final FrameWriteBuffer frames = new FrameWriteBuffer(outgoing);
private final BufferIntent intent = new BufferIntent();
private int nextStreamId = 1;
private int connectionSendWindow = Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE;
private int streamSendWindow;
private boolean headerEndStream;
private boolean closed;
Connection(Origin origin, SSLContext sslContext) throws IOException {
socket = connect(origin, sslContext);
output = socket.getOutputStream();
reader =
new Http2FrameReader(new BufferedByteSource(socket.getInputStream(), socket));
writer = new Http2FrameWriter(output::write);
writePreface();
awaitServerSettings();
}
synchronized Http2ClientResponse exchange(
URI uri, HttpMethod method, HeaderView requestHeaders, byte[] body, HeaderView trailers)
throws IOException {
if (closed) throw new IOException("HTTP/2 connection is closed");
if (nextStreamId <= 0) throw new IOException("HTTP/2 stream id space exhausted");
int streamId = nextStreamId;
nextStreamId += 2;
streamSendWindow = peerSettings.initialWindowSize();
Exchange exchange = new Exchange(streamId);
writeRequestHeaders(uri, method, requestHeaders, body.length == 0 && trailers.count() == 0,
streamId);
if (body.length != 0) writeRequestBody(exchange, body, trailers.count() == 0);
if (trailers.count() != 0) writeRequestTrailers(trailers, streamId);
while (!exchange.complete) readFrame(exchange);
return exchange.response();
}
private void writePreface() throws IOException {
output.write(Http2Preface.clientPreface());
outgoing.reset();
frames.beginFrame(FrameType.SETTINGS, 0, 0);
outgoing.writeUInt16(Http2Settings.ENABLE_PUSH);
outgoing.writeUInt32(0);
outgoing.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE);
outgoing.writeUInt32(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
frames.endFrame();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
outgoing.writeUInt31(
Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE);
frames.endFrame();
writeOutgoing();
}
private void awaitServerSettings() throws IOException {
boolean received = false;
while (!received) {
FrameHeader frame = reader.readFrame();
if (frame == null) throw new IOException("server closed before SETTINGS");
try {
if (frame.type() == FrameType.SETTINGS && !FrameFlags.isAck(frame.flags())) {
applySettings(frame);
sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
received = true;
} else if (frame.type() == FrameType.WINDOW_UPDATE) {
applyWindowUpdate(frame, 0);
} else if (frame.type() == FrameType.GOAWAY) {
throw new IOException("server sent GOAWAY during HTTP/2 setup");
}
} finally {
reader.consumeFrame();
}
}
}
private void writeRequestHeaders(
URI uri, HttpMethod method, HeaderView source, boolean endStream, int streamId)
throws IOException {
outgoing.reset();
frames.beginFrame(
FrameType.HEADERS,
FrameFlags.END_HEADERS | (endStream ? FrameFlags.END_STREAM : 0),
streamId);
writeMethod(method);
HpackEncoder.writeIndexed(outgoing, "https".equalsIgnoreCase(uri.getScheme()) ? 7 : 6);
writeAuthority(uri);
writePath(uri);
source.forEach(
(name, value) -> {
if (HopByHopHeaders.shouldForward(
source, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2)
&& !equalsAscii(name, "host")) {
HpackEncoder.writeLiteral(outgoing, name, value);
}
});
frames.endFrame();
writeOutgoing();
}
private void writeRequestBody(Exchange exchange, byte[] body, boolean endStream)
throws IOException {
int offset = 0;
while (offset < body.length) {
while (connectionSendWindow <= 0 || streamSendWindow <= 0) readFrame(exchange);
int count =
Math.min(
body.length - offset,
Math.min(
peerSettings.maxFrameSize(),
Math.min(connectionSendWindow, streamSendWindow)));
outgoing.reset();
frames.beginFrame(
FrameType.DATA,
endStream && offset + count == body.length ? FrameFlags.END_STREAM : 0,
exchange.streamId);
outgoing.writeBytes(body, offset, count);
frames.endFrame();
writeOutgoing();
connectionSendWindow -= count;
streamSendWindow -= count;
offset += count;
}
}
private void writeRequestTrailers(HeaderView trailers, int streamId) throws IOException {
outgoing.reset();
frames.beginFrame(
FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, streamId);
trailers.forEach(
(name, value) -> {
if (HopByHopHeaders.shouldForward(
trailers, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2)) {
HpackEncoder.writeLiteral(outgoing, name, value);
}
});
frames.endFrame();
writeOutgoing();
}
private void readFrame(Exchange exchange) throws IOException {
FrameHeader frame = reader.readFrame();
if (frame == null) throw new IOException("server closed an active HTTP/2 exchange");
try {
FrameType type = frame.type();
if (type == null) return;
switch (type) {
case SETTINGS -> {
if (!FrameFlags.isAck(frame.flags())) {
applySettings(frame);
sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
}
}
case WINDOW_UPDATE -> applyWindowUpdate(frame, exchange.streamId);
case PING -> {
if (!FrameFlags.isAck(frame.flags())) sendPingAck(frame);
}
case HEADERS, CONTINUATION -> receiveHeaders(frame, exchange);
case DATA -> receiveData(frame, exchange);
case RST_STREAM -> receiveReset(frame, exchange);
case GOAWAY -> throw receiveGoAway(frame);
case PUSH_PROMISE -> throw new IOException("server sent PUSH_PROMISE after ENABLE_PUSH=0");
default -> {
// PRIORITY and unknown extension semantics do not affect this single exchange.
}
}
} finally {
reader.consumeFrame();
}
}
private void receiveHeaders(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId) {
throw new IOException("unexpected response stream " + frame.streamId());
}
if (frame.type() == FrameType.HEADERS) {
if (headers.isActive()) throw new IOException("interleaved response header block");
headerEndStream = FrameFlags.isEndStream(frame.flags());
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int offset = Pairs.hi(unpadded);
int length = Pairs.lo(unpadded);
if (FrameFlags.hasPriority(frame.flags())) {
if (length < 5) throw new IOException("truncated response priority fields");
offset += 5;
length -= 5;
}
headers.begin(
frame.streamId(),
frame.buffer(),
offset,
length,
FrameFlags.isEndHeaders(frame.flags()));
} else {
headers.continuation(
frame.streamId(),
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isEndHeaders(frame.flags()));
}
if (!headers.isComplete()) return;
boolean trailers = exchange.statusCode != 0;
ResponseHeaderSink sink = new ResponseHeaderSink(exchange, trailers);
decoder.decode(headers.buffer(), 0, headers.length(), sink);
headers.reset();
sink.validate();
if (!trailers && exchange.statusCode >= 100 && exchange.statusCode < 200) {
if (headerEndStream) throw new IOException("informational response ended the stream");
exchange.statusCode = 0;
exchange.headers.reset();
return;
}
if (trailers && !headerEndStream) {
throw new IOException("response trailers did not end the stream");
}
if (headerEndStream) exchange.complete = true;
}
private void receiveData(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId || exchange.statusCode == 0) {
throw new IOException("DATA received before response headers");
}
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int dataOffset = Pairs.hi(unpadded);
int dataLength = Pairs.lo(unpadded);
if (exchange.body.size() > MAX_RESPONSE_BODY_SIZE - dataLength) {
throw new IOException("proxied HTTP/2 response body exceeds limit");
}
exchange.body.write(frame.buffer(), dataOffset, dataLength);
if (frame.length() != 0) {
sendWindowUpdate(0, frame.length());
sendWindowUpdate(exchange.streamId, frame.length());
}
if (FrameFlags.isEndStream(frame.flags())) exchange.complete = true;
}
private void receiveReset(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId || frame.length() != 4) return;
int code = readInt(frame.buffer(), frame.payloadOffset());
throw new IOException("upstream reset HTTP/2 stream with error " + code);
}
private IOException receiveGoAway(FrameHeader frame) {
closed = true;
int code = frame.length() >= 8 ? readInt(frame.buffer(), frame.payloadOffset() + 4) : -1;
return new IOException("upstream sent GOAWAY with error " + code);
}
private void applySettings(FrameHeader frame) {
int oldWindow = peerSettings.initialWindowSize();
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), delta -> {});
streamSendWindow += peerSettings.initialWindowSize() - oldWindow;
}
private void applyWindowUpdate(FrameHeader frame, int activeStreamId) throws IOException {
if (frame.length() != 4) throw new IOException("invalid WINDOW_UPDATE length");
int increment = readInt(frame.buffer(), frame.payloadOffset()) & 0x7fff_ffff;
if (increment == 0) throw new IOException("zero WINDOW_UPDATE increment");
if (frame.streamId() == 0) connectionSendWindow = addWindow(connectionSendWindow, increment);
else if (frame.streamId() == activeStreamId) streamSendWindow = addWindow(streamSendWindow, increment);
}
private void sendPingAck(FrameHeader frame) throws IOException {
outgoing.reset();
frames.beginFrame(FrameType.PING, FrameFlags.ACK, 0);
outgoing.writeBytes(frame.buffer(), frame.payloadOffset(), frame.length());
frames.endFrame();
writeOutgoing();
}
private void sendWindowUpdate(int streamId, int increment) throws IOException {
outgoing.reset();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, streamId);
outgoing.writeUInt31(increment);
frames.endFrame();
writeOutgoing();
}
private void sendEmpty(FrameType type, int flags, int streamId) throws IOException {
outgoing.reset();
frames.beginFrame(type, flags, streamId);
frames.endFrame();
writeOutgoing();
}
private void writeOutgoing() throws IOException {
intent.reset(outgoing.array(), outgoing.length());
writer.write(intent);
}
private void writeMethod(HttpMethod method) {
if (method == HttpMethod.GET) HpackEncoder.writeIndexed(outgoing, 2);
else if (method == HttpMethod.POST) HpackEncoder.writeIndexed(outgoing, 3);
else {
byte[] value = method.name().getBytes(StandardCharsets.US_ASCII);
HpackEncoder.writeLiteralWithNameIndex(outgoing, 2, value, false);
}
}
private void writeAuthority(URI uri) {
String authority = uri.getRawAuthority();
if (authority == null || authority.isEmpty()) {
throw new IllegalArgumentException("HTTP/2 URI requires an authority");
}
HpackEncoder.writeLiteralWithNameIndex(
outgoing, 1, authority.getBytes(StandardCharsets.US_ASCII), false);
}
private void writePath(URI uri) {
String path = uri.getRawPath();
if (path == null || path.isEmpty()) path = "/";
if (uri.getRawQuery() != null) path += "?" + uri.getRawQuery();
if ("/".equals(path)) HpackEncoder.writeIndexed(outgoing, 4);
else if ("/index.html".equals(path)) HpackEncoder.writeIndexed(outgoing, 5);
else {
HpackEncoder.writeLiteralWithNameIndex(
outgoing, 4, path.getBytes(StandardCharsets.US_ASCII), false);
}
}
@Override
public synchronized void close() {
if (closed) return;
closed = true;
writer.close();
try {
socket.close();
} catch (IOException ignored) {
// Closing a broken pooled connection is best-effort.
}
}
private static Socket connect(Origin origin, SSLContext sslContext) throws IOException {
if (!origin.secure) {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS);
return socket;
}
SSLContext context;
try {
context = sslContext == null ? SSLContext.getDefault() : sslContext;
} catch (Exception failure) {
throw new IOException("cannot initialize TLS context", failure);
}
SSLSocket socket =
(SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port);
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2"});
parameters.setEndpointIdentificationAlgorithm("HTTPS");
socket.setSSLParameters(parameters);
socket.startHandshake();
if (!"h2".equals(socket.getApplicationProtocol())) {
socket.close();
throw new IOException("upstream did not negotiate HTTP/2 through ALPN");
}
return socket;
}
}
private static final class Exchange {
private final int streamId;
private final MutableHeaderMap headers = new MutableHeaderMap();
private final MutableHeaderMap trailers = new MutableHeaderMap();
private final ByteArrayOutputStream body = new ByteArrayOutputStream();
private int statusCode;
private boolean complete;
private Exchange(int streamId) {
this.streamId = streamId;
}
private Http2ClientResponse response() {
return new Http2ClientResponse(statusCode, headers, body.toByteArray(), trailers);
}
}
private static final class ResponseHeaderSink
implements dev.relism.flash.http2.hpack.HeaderSink {
private final Exchange exchange;
private final boolean trailers;
private boolean regular;
private boolean status;
private ResponseHeaderSink(Exchange exchange, boolean trailers) {
this.exchange = exchange;
this.trailers = trailers;
}
@Override
public void accept(ByteView name, ByteView value, boolean neverIndexed) {
if (name.length() != 0 && name.byteAt(0) == ':') {
if (trailers || regular || status || !equalsAscii(name, ":status")) {
throw Http2Exception.PROTOCOL_ERROR;
}
exchange.statusCode = parseStatus(value);
status = true;
return;
}
regular = true;
MutableHeaderMap target = trailers ? exchange.trailers : exchange.headers;
byte[] nameBytes = copy(name);
byte[] valueBytes = copy(value);
target.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
private void validate() throws IOException {
if (!trailers && !status) throw new IOException("HTTP/2 response omitted :status");
}
private static int parseStatus(ByteView value) {
if (value.length() != 3) throw Http2Exception.PROTOCOL_ERROR;
int code = 0;
for (int i = 0; i < 3; i++) {
int digit = (value.byteAt(i) & 0xff) - '0';
if (digit < 0 || digit > 9) throw Http2Exception.PROTOCOL_ERROR;
code = code * 10 + digit;
}
return code;
}
}
private static final class BufferIntent implements WriteIntent {
private byte[] bytes;
private int length;
private WriteIntent next;
private void reset(byte[] bytes, int length) {
this.bytes = bytes;
this.length = length;
this.next = null;
}
@Override public byte[] buffer() { return bytes; }
@Override public int offset() { return 0; }
@Override public int length() { return length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
private record Origin(String scheme, String host, int port, boolean secure) {
private static Origin from(URI uri) {
String scheme = uri.getScheme();
boolean secure;
if ("https".equalsIgnoreCase(scheme)) secure = true;
else if ("http".equalsIgnoreCase(scheme)) secure = false;
else throw new IllegalArgumentException("HTTP/2 URI scheme must be http or https");
if (uri.getHost() == null) throw new IllegalArgumentException("HTTP/2 URI requires a host");
int port = uri.getPort() >= 0 ? uri.getPort() : secure ? 443 : 80;
return new Origin(scheme.toLowerCase(), uri.getHost(), port, secure);
}
}
private static final class OpenFailure extends RuntimeException {
private final IOException io;
private OpenFailure(IOException io) {
super(io);
this.io = io;
}
}
private static boolean equalsAscii(ByteView bytes, String value) {
if (bytes.length() != value.length()) return false;
for (int i = 0; i < bytes.length(); i++) {
int left = bytes.byteAt(i) & 0xff;
int right = value.charAt(i);
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
private static byte[] copy(ByteView view) {
byte[] result = new byte[view.length()];
for (int i = 0; i < result.length; i++) result[i] = view.byteAt(i);
return result;
}
private static int addWindow(int current, int increment) throws IOException {
long next = (long) current + increment;
if (next > Integer.MAX_VALUE) throw new IOException("HTTP/2 flow-control window overflow");
return (int) next;
}
private static int readInt(byte[] bytes, int offset) {
return ((bytes[offset] & 0xff) << 24)
| ((bytes[offset + 1] & 0xff) << 16)
| ((bytes[offset + 2] & 0xff) << 8)
| (bytes[offset + 3] & 0xff);
}
}
@@ -0,0 +1,7 @@
package dev.relism.flash.http2.client;
import dev.relism.flash.models.HeaderView;
/** Complete response returned by Flash's proxy-oriented HTTP/2 client. */
public record Http2ClientResponse(
int statusCode, HeaderView headers, byte[] body, HeaderView trailers) {}
@@ -1,6 +1,7 @@
package dev.relism.flash.http2.hpack; package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ByteWriter; import dev.relism.flash.bytes.ByteWriter;
import dev.relism.fpr.core.ByteView;
/** /**
* Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal * Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal
@@ -25,6 +26,19 @@ public final class HpackEncoder {
writeLiteral(out, name, 0, name.length, value, 0, value.length, false); writeLiteral(out, name, 0, name.length, value, 0, value.length, false);
} }
/** Writes a non-indexed literal directly from protocol-neutral byte views. */
public static void writeLiteral(ByteWriter out, ByteView name, ByteView value) {
HpackIntegers.encode(out, 0, 4, 0);
HpackIntegers.encode(out, 0, 7, name.length());
for (int i = 0; i < name.length(); i++) {
int octet = name.byteAt(i) & 0xff;
if (octet >= 'A' && octet <= 'Z') octet += 'a' - 'A';
out.writeByte((byte) octet);
}
HpackIntegers.encode(out, 0, 7, value.length());
for (int i = 0; i < value.length(); i++) out.writeByte(value.byteAt(i));
}
public static void writeLiteral( public static void writeLiteral(
ByteWriter out, ByteWriter out,
byte[] name, byte[] name,
@@ -134,17 +134,13 @@ public final class ConnectionRunner {
} }
} }
/** /** Decides h1 vs h2 while keeping the TLS and cleartext rollout gates independent. */
* Decides h1 vs h2 for this connection, applying {@link FlashConfiguration#isHttp2Enabled()} to
* the plaintext (h2c) path see {@link ProtocolNegotiator}'s Javadoc for why the flag is applied
* here rather than inside the negotiator itself.
*/
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in) private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in)
throws IOException { throws IOException {
if (socket instanceof SSLSocket) { if (socket instanceof SSLSocket) {
return ProtocolNegotiator.negotiate(socket, in); // ALPN already resolved, no I/O return ProtocolNegotiator.negotiate(socket, in); // ALPN already resolved, no I/O
} }
if (!configuration.isHttp2Enabled()) { if (!configuration.isHttp2CleartextEnabled()) {
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
} }
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L); in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
@@ -1,63 +1,35 @@
package dev.relism.flash.transport; package dev.relism.flash.transport;
import javax.net.ssl.SSLSocket;
import java.io.IOException; import java.io.IOException;
import java.net.Socket; import java.net.Socket;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Arrays; import java.util.Arrays;
import javax.net.ssl.SSLSocket;
/** /** Detects HTTP/1.1 or HTTP/2 once, before the connection parser is selected. */
* Decides, once per connection and before any request is parsed, whether the connection speaks
* immediately after ALPN/preface detection").
*
* <p>Two independent signals, in order:
* <ol>
* <li><b>ALPN</b> (TLS connections). If the socket is an {@link SSLSocket} and the TLS
* handshake already resolved {@code "h2"} as the application protocol, this connection is
* {@link NegotiatedProtocol#HTTP_2}. Anything else negotiated {@code "http/1.1"}, no
* protocol at all (a peer that doesn't speak ALPN), or an empty string is
* {@link NegotiatedProtocol#HTTP_1_1}. This costs nothing beyond a field read: ALPN is
* resolved during the handshake, which must already have completed (see
* {@code TlsConfig}'s Javadoc on why {@code startHandshake()} must be called explicitly
* <li><b>h2c prior knowledge</b> (plaintext connections, RFC 9113 §3.4). The first 24 bytes of
* the connection are compared, without being consumed, against the client connection
* preface {@code "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"}. A match is
* {@link NegotiatedProtocol#HTTP_2}; anything else including a partial match followed by
* EOF, or a preface look-alike that diverges partway through is
* {@link NegotiatedProtocol#HTTP_1_1}. This is why {@link BufferedByteSource#peek} exists:
* the bytes must remain available for {@code RequestParser} if they turn out not to be an
* h2 preface after all.</li>
* </ol>
*
* <p>This method reports the protocol accurately and unconditionally it does not consult
* {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#HTTP_2}
* {@code Http2Connection} yet) and whether the h2c peek is even attempted for plaintext
* connections are both the caller's responsibility, so that this class stays a pure,
* directly-testable detector (see {@code ProtocolNegotiatorTest}).
*/
public final class ProtocolNegotiator { public final class ProtocolNegotiator {
private static final byte[] H2C_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
/** private ProtocolNegotiator() {}
* reconstructed per connection.
*/
private static final byte[] H2C_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private ProtocolNegotiator() { /**
* Uses the completed TLS ALPN result for secure sockets and a non-consuming prior-knowledge
* preface probe for plaintext sockets. Configuration gates remain the caller's responsibility,
* which keeps detection deterministic and independently testable.
*/
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source)
throws IOException {
if (socket instanceof SSLSocket ssl) {
return "h2".equals(ssl.getApplicationProtocol())
? NegotiatedProtocol.HTTP_2
: NegotiatedProtocol.HTTP_1_1;
} }
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException { byte[] probe = new byte[H2C_PREFACE.length];
if (socket instanceof SSLSocket ssl) { int read = source.peek(probe, 0, probe.length);
String applicationProtocol = ssl.getApplicationProtocol(); return read == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)
return "h2".equals(applicationProtocol) ? NegotiatedProtocol.HTTP_2 : NegotiatedProtocol.HTTP_1_1; ? NegotiatedProtocol.HTTP_2
} : NegotiatedProtocol.HTTP_1_1;
}
byte[] probe = new byte[H2C_PREFACE.length];
int n = source.peek(probe, 0, probe.length);
if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
return NegotiatedProtocol.HTTP_2;
}
return NegotiatedProtocol.HTTP_1_1;
}
} }
@@ -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 @Test
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception { void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
int port = freePort(); int port = freePort();
app = FlashApp.create(FlashConfiguration.builder() app =
.host("127.0.0.1").port(port).http2Enabled(true).build()); FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/flash.test.Echo/Unary", (request, response) -> app.post("/flash.test.Echo/Unary", (request, response) ->
response.type("application/grpc") response.type("application/grpc")
.body(request.body().bytes()) .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 @Test
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception { void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
int port = freePort(); int port = freePort();
FlashApp app = FlashApp.create(FlashConfiguration.builder() FlashApp app =
.host("127.0.0.1").port(port).http2Enabled(true).h2StreamIdleTimeoutMs(20).build()); 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.post("/idle", (request, response) -> request.body().bytes());
app.start(); app.start();
ByteWriter headers = new ByteWriter(64); 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 @Test
void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception { void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception {
int port = freePort(); int port = freePort();
app = FlashApp.create(FlashConfiguration.builder() app =
.host("127.0.0.1").port(port).http2Enabled(true).build()); FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.connect("tunnel", (request, response) -> app.connect("tunnel", (request, response) ->
response.type(ContentType.NONE).streaming(output -> { response.type(ContentType.NONE).streaming(output -> {
byte[] bytes = new byte[16]; byte[] bytes = new byte[16];
@@ -253,7 +253,11 @@ class Http2ConnectionIntegrationTest {
int port = freePort(); int port = freePort();
app = app =
FlashApp.create( 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.get("/api/ping", (request, response) -> "pong");
app.start(); app.start();
@@ -314,7 +318,11 @@ class Http2ConnectionIntegrationTest {
AtomicInteger calls = new AtomicInteger(); AtomicInteger calls = new AtomicInteger();
app = app =
FlashApp.create( 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( app.get(
"/queued", "/queued",
(request, response) -> { (request, response) -> {
@@ -380,7 +388,11 @@ class Http2ConnectionIntegrationTest {
AtomicBoolean handlerEntered = new AtomicBoolean(); AtomicBoolean handlerEntered = new AtomicBoolean();
app = app =
FlashApp.create( 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( app.get(
"/", "/",
(request, response) -> { (request, response) -> {
@@ -436,7 +448,7 @@ class Http2ConnectionIntegrationTest {
FlashConfiguration.builder() FlashConfiguration.builder()
.port(port) .port(port)
.host("127.0.0.1") .host("127.0.0.1")
.http2Enabled(true) .http2CleartextEnabled(true)
.shutdownDrainTimeoutMs(5_000) .shutdownDrainTimeoutMs(5_000)
.build()); .build());
app.start(); app.start();
@@ -476,7 +488,11 @@ class Http2ConnectionIntegrationTest {
int port = freePort(); int port = freePort();
app = app =
FlashApp.create( 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(); app.start();
try (Socket first = new Socket("127.0.0.1", port)) { 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 @Test
void requestTrailersReachHandlerAfterBodyEof() throws Exception { void requestTrailersReachHandlerAfterBodyEof() throws Exception {
int port = freePort(); int port = freePort();
app = FlashApp.create(FlashConfiguration.builder() app =
.host("127.0.0.1").port(port).http2Enabled(true).build()); FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/trailers", (request, response) -> { app.post("/trailers", (request, response) -> {
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII)); assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
return request.trailers().first("grpc-status"); return request.trailers().first("grpc-status");
@@ -95,8 +100,13 @@ class Http2TrailersTest {
private int startBlockingRoute() throws Exception { private int startBlockingRoute() throws Exception {
int port = freePort(); int port = freePort();
app = FlashApp.create(FlashConfiguration.builder() app =
.host("127.0.0.1").port(port).http2Enabled(true).build()); FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/trailers", (request, response) -> request.body().bytes()); app.post("/trailers", (request, response) -> request.body().bytes());
app.start(); app.start();
return port; 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 * {@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}. * calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}.
*/ */
class ProtocolNegotiatorTest { class ProtocolNegotiatorTest {