feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -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,6 +104,9 @@ 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());
|
||||||
|
if (!Http2Authority.isServed(request.header("host"), request.sslSession())) {
|
||||||
|
response.status(HttpStatus.MISDIRECTED_REQUEST);
|
||||||
|
} else {
|
||||||
RequestHandler handler = context.router().route(request, routeScratch);
|
RequestHandler handler = context.router().route(request, routeScratch);
|
||||||
if (handler == null) handler = context.router().getNotFoundHandler();
|
if (handler == null) handler = context.router().getNotFoundHandler();
|
||||||
try {
|
try {
|
||||||
@@ -115,6 +119,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
if (result instanceof Response returned) response = returned;
|
if (result instanceof Response returned) response = returned;
|
||||||
else if (result != null) response.setBody(result);
|
else if (result != null) response.setBody(result);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
boolean pushStreaming = response.isPushStreaming();
|
boolean pushStreaming = response.isPushStreaming();
|
||||||
if (!pushStreaming) request.drain();
|
if (!pushStreaming) request.drain();
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|
||||||
/**
|
|
||||||
* reconstructed per connection.
|
|
||||||
*/
|
|
||||||
private static final byte[] H2C_PREFACE =
|
private static final byte[] H2C_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 ProtocolNegotiator() {
|
private ProtocolNegotiator() {}
|
||||||
}
|
|
||||||
|
|
||||||
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException {
|
/**
|
||||||
|
* 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) {
|
if (socket instanceof SSLSocket ssl) {
|
||||||
String applicationProtocol = ssl.getApplicationProtocol();
|
return "h2".equals(ssl.getApplicationProtocol())
|
||||||
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];
|
byte[] probe = new byte[H2C_PREFACE.length];
|
||||||
int n = source.peek(probe, 0, probe.length);
|
int read = source.peek(probe, 0, probe.length);
|
||||||
if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
|
return read == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)
|
||||||
return NegotiatedProtocol.HTTP_2;
|
? NegotiatedProtocol.HTTP_2
|
||||||
}
|
: NegotiatedProtocol.HTTP_1_1;
|
||||||
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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user