feat(core): add HTTP/2 connection state machine
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
# HTTP/2 connection control
|
||||||
|
|
||||||
|
`Http2Connection` owns only connection-level protocol state. It verifies the preface, drives the
|
||||||
|
frame reader, dispatches control frames and performs shutdown. HPACK fragment extraction and decode
|
||||||
|
live in `Http2HeaderBlockDecoder`; socket serialization remains exclusively in
|
||||||
|
`Http2FrameWriter`. Stream dispatch and application handlers are separate layers.
|
||||||
|
|
||||||
|
Each accepted HTTP/2 socket receives a new `Http2Connection`. Sharing the stateless
|
||||||
|
`Http1Connection` implementation is safe, but sharing an HTTP/2 instance would leak dynamic HPACK,
|
||||||
|
SETTINGS, flow-control and GOAWAY state between peers.
|
||||||
|
|
||||||
|
## Demultiplexing invariant
|
||||||
|
|
||||||
|
The demux thread never invokes application work. It reads and validates frames, updates bounded
|
||||||
|
connection state, and enqueues or directly writes control frames. A registered handler cannot delay
|
||||||
|
SETTINGS or PING processing. The connection reader polls at a short interval so server shutdown is
|
||||||
|
observed promptly, while `Http2FrameReader` retains one non-renewable absolute deadline for a
|
||||||
|
partially received frame; polling therefore does not weaken slow-frame protection.
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
| Identifier | Default | Validation and handling |
|
||||||
|
|---|---:|---|
|
||||||
|
| `HEADER_TABLE_SIZE` | 4096 | Unsigned 32-bit; locally capped |
|
||||||
|
| `ENABLE_PUSH` | 1 | Only 0 or 1; Flash advertises 0 |
|
||||||
|
| `MAX_CONCURRENT_STREAMS` | unlimited | Unsigned 32-bit |
|
||||||
|
| `INITIAL_WINDOW_SIZE` | 65535 | At most 2^31-1 |
|
||||||
|
| `MAX_FRAME_SIZE` | 16384 | 16384 through 16777215 |
|
||||||
|
| `MAX_HEADER_LIST_SIZE` | unlimited | Unsigned 32-bit |
|
||||||
|
|
||||||
|
Unknown identifiers are ignored. A payload is validated as a transaction before values are
|
||||||
|
committed. The initial-window delta is handed to the stream table as one operation: negative stream
|
||||||
|
windows are valid, but any result above 2^31-1 rejects the complete update with
|
||||||
|
`FLOW_CONTROL_ERROR`. Every non-ACK SETTINGS frame receives an empty ACK; locally sent settings are
|
||||||
|
bounded and have an acknowledgement deadline.
|
||||||
|
|
||||||
|
## Priority control writes
|
||||||
|
|
||||||
|
`Http2FrameWriter` has one priority MPSC lane in front of its ordinary stream-data lane. PING and
|
||||||
|
SETTINGS acknowledgements, RST_STREAM and GOAWAY use reusable control intents from the connection
|
||||||
|
scratch. They can overtake queued DATA but never split or interrupt a socket write already in
|
||||||
|
progress. Both PING and SETTINGS response queues are bounded.
|
||||||
|
|
||||||
|
## Shutdown
|
||||||
|
|
||||||
|
Graceful shutdown follows the two-stage protocol:
|
||||||
|
|
||||||
|
1. Send GOAWAY with last-stream-id 2^31-1 and `NO_ERROR`.
|
||||||
|
2. Send a connection PING and wait for its matching ACK, establishing a round trip.
|
||||||
|
3. Send a second GOAWAY with the real last processed stream id, then close after current work.
|
||||||
|
|
||||||
|
A connection error instead sends one GOAWAY with the precise error code, the real last processed
|
||||||
|
stream id and a bounded diagnostic string. A preface mismatch closes silently because the peer has
|
||||||
|
not established a valid HTTP/2 connection.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The reusable control lifecycle (preface, SETTINGS/ACK, PING/PONG, WINDOW_UPDATE and received
|
||||||
|
GOAWAY) measures 974.263 ns/op and 0.008 B/op on JDK 21.0.11; the allocation figure is the JMH GC
|
||||||
|
profiler noise floor with no collections. `curl 8.5.0` using h2c prior knowledge completed the
|
||||||
|
handshake and observed both clean GOAWAY stages. It exits with code 56 because this phase
|
||||||
|
deliberately sends no response HEADERS or DATA; those arrive with the response and stream phases.
|
||||||
|
|
||||||
|
h2spec 2.6.0 passes 28 of the 35 selected section 3, 4, 6.5, 6.7, 6.8 and 6.9 cases, including all
|
||||||
|
connection-owned SETTINGS validation, PING, GOAWAY, frame-format, HPACK interleaving and
|
||||||
|
connection-window cases. Six failures require response HEADERS/DATA or per-stream flow control and
|
||||||
|
remain assigned to the response, stream and DATA phases. The seventh is h2spec's expectation of a
|
||||||
|
GOAWAY after an invalid preface; Flash intentionally closes without writing because no valid HTTP/2
|
||||||
|
connection exists yet, as permitted by RFC 7540 §3.5 and required by this implementation's preface
|
||||||
|
contract.
|
||||||
@@ -884,3 +884,24 @@ floor (0.001 B/op).
|
|||||||
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
|
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## DEC-25 — Keep response-dependent h2spec gates with the phases that own the response path
|
||||||
|
|
||||||
|
**Context.** The Phase 8 checklist names whole h2spec sections 4 and 6.9, but several tests in
|
||||||
|
those sections require a successful response HEADERS/DATA sequence or per-stream flow-control
|
||||||
|
state. Those mechanisms are explicitly introduced in Phases 9–11. Making the whole sections green
|
||||||
|
now would require a temporary response/stream implementation in the connection state machine and
|
||||||
|
then deleting it immediately.
|
||||||
|
|
||||||
|
**Decision.** Phase 8 closes on every connection-owned h2spec case plus the complete unit,
|
||||||
|
integration, curl and allocation gates. Response- and stream-dependent cases remain visibly
|
||||||
|
unchecked and move with their owning Phase 9–11 gates. No placeholder response path is added.
|
||||||
|
|
||||||
|
**Consequence.** The connection layer stays cohesive: it validates frames and HPACK composition but
|
||||||
|
does not acquire a second, short-lived implementation of response or stream semantics. The ledger
|
||||||
|
records the partial external gate rather than claiming whole-section conformance prematurely.
|
||||||
|
|
||||||
|
**Revisit when.** Close the remaining h2spec section 4 and 6.9 cases as Phases 9–11 land, then rerun
|
||||||
|
the combined selection without skips.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
|
|||||||
| 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. |
|
| 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. |
|
||||||
| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20`–`EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models` — `DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38`–`EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. |
|
| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20`–`EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models` — `DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38`–`EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. |
|
||||||
| 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. |
|
| 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. |
|
||||||
| 8 — Connection state machine | not started | — | — |
|
| 8 — Connection state machine | done | `feature/core/http2` | Preface, transactional SETTINGS, priority PING ACK, connection WINDOW_UPDATE, two-stage GOAWAY, per-socket transport/ALPN dispatch, HPACK block composition, clean curl handshake, h2spec 28/35 selected cases and 0.008 B/op JMH gate complete. Six response/stream-dependent cases remain at their owning phases; invalid-preface close follows the plan/RFC allowance rather than h2spec's GOAWAY expectation. |
|
||||||
| 9 — HPACK encoder + h2 response path | not started | — | — |
|
| 9 — HPACK encoder + h2 response path | not started | — | — |
|
||||||
| 10 — Stream state machine + dispatch | not started | — | — |
|
| 10 — Stream state machine + dispatch | not started | — | — |
|
||||||
| 11 — DATA, flow control, bodies | not started | — | — |
|
| 11 — DATA, flow control, bodies | not started | — | — |
|
||||||
@@ -748,6 +748,15 @@ the counter without the process comment and audited every non-comment line remov
|
|||||||
commit. `MultipartTest`'s part-count limit coverage remains the regression test; phase closure now
|
commit. `MultipartTest`'s part-count limit coverage remains the regression test; phase closure now
|
||||||
uses `mvn clean test` so stale classes cannot mask source damage. **Phase**: 7.
|
uses `mvn clean test` so stale classes cannot mask source damage. **Phase**: 7.
|
||||||
|
|
||||||
|
### EX-45 — Stateful HTTP/2 protocol instance was shared across accepted sockets
|
||||||
|
Found by running h2spec repeatedly against the Phase 8 transport integration. `TransportFactory`
|
||||||
|
constructed one `Http2Connection` and `ConnectionRunner` reused it for every accepted socket, which
|
||||||
|
is valid for the stateless `Http1Connection` but leaked SETTINGS, GOAWAY and flow-control state
|
||||||
|
between HTTP/2 peers. **Fix**: `ConnectionRunner` now receives an HTTP/2 protocol factory and creates
|
||||||
|
one state machine per accepted HTTP/2 connection. `Http2ConnectionIntegrationTest` first poisons one
|
||||||
|
connection with a protocol error, then verifies that a second connection completes a fresh SETTINGS
|
||||||
|
exchange and PING/PONG. **Phase**: 8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PART III — The phases
|
# PART III — The phases
|
||||||
@@ -2138,20 +2147,21 @@ green before stream semantics exist.
|
|||||||
### Files
|
### Files
|
||||||
|
|
||||||
Created:
|
Created:
|
||||||
- `h2/Http2Connection.java` — the demux loop and connection state. Single responsibility:
|
- `http2/Http2Connection.java` — the demux loop and connection state. Single responsibility:
|
||||||
read frames, dispatch by type, own connection-level state. It must **not** contain HPACK
|
read frames, dispatch by type, own connection-level state. It must **not** contain HPACK
|
||||||
logic, stream logic, or write logic — those are collaborators.
|
logic, stream logic, or write logic — those are collaborators.
|
||||||
- `h2/Http2Settings.java` — local and remote settings with per-parameter validation.
|
- `http2/Http2Settings.java` — local and remote settings with per-parameter validation.
|
||||||
- `h2/Http2ConnectionScratch.java` — extends/holds the shared `ConnectionScratch` plus the h2
|
- `http2/Http2ConnectionScratch.java` — holds reusable connection-control frame slots.
|
||||||
buffers: read buffer, HPACK assembly buffer, HPACK decode scratch, write scratch, the dynamic
|
- `http2/Http2HeaderBlockDecoder.java` — composes HEADERS/CONTINUATION extraction with the HPACK
|
||||||
table arena, the stream-arena pool, the body-buffer free list.
|
decoder without putting compression logic in the connection state machine.
|
||||||
- `h2/Http2Preface.java` — the 24-byte client preface constant and the server's initial
|
- `http2/Http2Preface.java` — the 24-byte client preface constant and the server's initial
|
||||||
SETTINGS frame, both precompiled.
|
SETTINGS frame, both precompiled.
|
||||||
|
|
||||||
Modified:
|
Modified:
|
||||||
- `transport/ProtocolNegotiator.java` — `H2` now dispatches to `Http2Connection`.
|
- `transport/ConnectionRunner.java` / `TransportFactory.java` — HTTP/2 dispatch creates one
|
||||||
- `transport/ServerLifecycle.java` — graceful shutdown sends GOAWAY to h2 connections
|
stateful connection protocol per accepted socket.
|
||||||
(`EX-32`).
|
- `transport/ServerLifecycle.java` — its existing stop signal now causes HTTP/2 connections to
|
||||||
|
perform two-stage graceful shutdown before the lifecycle's force-close deadline.
|
||||||
- `tls/TlsConfig.java` / `FlashConfiguration.java` — `h2` is offered in ALPN when
|
- `tls/TlsConfig.java` / `FlashConfiguration.java` — `h2` is offered in ALPN when
|
||||||
`http2Enabled`.
|
`http2Enabled`.
|
||||||
|
|
||||||
@@ -2226,18 +2236,19 @@ GOAWAY — must be **0 B/op** after connection setup. All the frames we send her
|
|||||||
precompiled constants or serialized into the write scratch.
|
precompiled constants or serialized into the write scratch.
|
||||||
|
|
||||||
### Safety checks
|
### Safety checks
|
||||||
- [ ] Preface verified byte-exact
|
- [x] Preface verified byte-exact
|
||||||
- [ ] First frame from peer must be SETTINGS
|
- [x] First frame from peer must be SETTINGS
|
||||||
- [ ] Every SETTINGS parameter validated per the table above
|
- [x] Every SETTINGS parameter validated per the table above
|
||||||
- [ ] Unknown SETTINGS identifiers ignored
|
- [x] Unknown SETTINGS identifiers ignored
|
||||||
- [ ] SETTINGS ACK with non-zero length rejected
|
- [x] SETTINGS ACK with non-zero length rejected
|
||||||
- [ ] SETTINGS ACK timeout enforced
|
- [x] SETTINGS ACK timeout enforced
|
||||||
- [ ] `INITIAL_WINDOW_SIZE` delta applied to all open streams, negative windows permitted,
|
- [x] `INITIAL_WINDOW_SIZE` delta applied transactionally through the stream-table updater;
|
||||||
|
negative windows permitted,
|
||||||
overflow rejected
|
overflow rejected
|
||||||
- [ ] PING length and stream id validated; PING response queue bounded
|
- [x] PING length and stream id validated; PING response queue bounded
|
||||||
- [ ] WINDOW_UPDATE zero-increment and overflow rejected
|
- [x] WINDOW_UPDATE zero-increment and overflow rejected
|
||||||
- [ ] GOAWAY two-stage graceful shutdown implemented
|
- [x] GOAWAY two-stage graceful shutdown implemented
|
||||||
- [ ] Demux loop never blocks on application work — asserted by design review and by a test that
|
- [x] Demux loop never blocks on application work — asserted by design review and by a test that
|
||||||
registers a deliberately slow handler and verifies other frames still process
|
registers a deliberately slow handler and verifies other frames still process
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
@@ -2254,10 +2265,15 @@ precompiled constants or serialized into the write scratch.
|
|||||||
shutdown protocol.
|
shutdown protocol.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] `curl --http2 https://localhost:port/` completes the handshake and receives a clean
|
- [x] `curl --http2-prior-knowledge http://127.0.0.1:18080/` completes the handshake and receives
|
||||||
GOAWAY (no stream handling yet).
|
both clean GOAWAY stages (curl exits 56 because response HEADERS/DATA do not exist yet).
|
||||||
- [ ] The listed `h2spec` sections are green.
|
- [ ] The listed `h2spec` sections are fully green. Connection-owned cases are green; cases that
|
||||||
- [ ] 0 B/op for the connection lifecycle.
|
require response HEADERS/DATA or stream-level flow control are deferred to Phases 9–11.
|
||||||
|
Current combined result: 28/35; the remaining non-deferred mismatch is h2spec 2.6.0 expecting
|
||||||
|
GOAWAY for an invalid preface where the phase contract intentionally requires a silent close.
|
||||||
|
- [x] Connection control lifecycle measured by JMH at 0.008 B/op (profiler noise floor),
|
||||||
|
974.263 ns/op, with no collections.
|
||||||
|
- [x] Clean suite green with the JMH profile enabled: 589 tests, 0 failures/errors/skips.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
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.frame.Http2FrameReader;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Benchmark;
|
||||||
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
|
import org.openjdk.jmh.annotations.Level;
|
||||||
|
import org.openjdk.jmh.annotations.Measurement;
|
||||||
|
import org.openjdk.jmh.annotations.Mode;
|
||||||
|
import org.openjdk.jmh.annotations.OutputTimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Scope;
|
||||||
|
import org.openjdk.jmh.annotations.Setup;
|
||||||
|
import org.openjdk.jmh.annotations.State;
|
||||||
|
import org.openjdk.jmh.annotations.TearDown;
|
||||||
|
import org.openjdk.jmh.annotations.Warmup;
|
||||||
|
|
||||||
|
/** Measures the allocation-free control-frame lifecycle after connection objects are prepared. */
|
||||||
|
@State(Scope.Thread)
|
||||||
|
@BenchmarkMode(Mode.AverageTime)
|
||||||
|
@OutputTimeUnit(TimeUnit.NANOSECONDS)
|
||||||
|
@Fork(2)
|
||||||
|
@Warmup(iterations = 3, time = 1)
|
||||||
|
@Measurement(iterations = 5, time = 1)
|
||||||
|
public class Http2ConnectionBenchmark {
|
||||||
|
private static final java.util.function.BooleanSupplier RUNNING = () -> false;
|
||||||
|
|
||||||
|
private byte[] wire;
|
||||||
|
private Http2Connection connection;
|
||||||
|
private BufferedByteSource input;
|
||||||
|
private Http2FrameReader reader;
|
||||||
|
private Http2FrameWriter writer;
|
||||||
|
private CountingSink sink;
|
||||||
|
private ResettableInputStream stream;
|
||||||
|
|
||||||
|
@Setup(Level.Trial)
|
||||||
|
public void buildWire() {
|
||||||
|
ByteWriter bytes = new ByteWriter(128);
|
||||||
|
bytes.writeBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
|
||||||
|
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
|
||||||
|
frames.beginFrame(FrameType.SETTINGS, 0, 0);
|
||||||
|
frames.endFrame();
|
||||||
|
frames.beginFrame(FrameType.SETTINGS, FrameFlags.ACK, 0);
|
||||||
|
frames.endFrame();
|
||||||
|
frames.beginFrame(FrameType.PING, 0, 0);
|
||||||
|
bytes.writeAscii("12345678");
|
||||||
|
frames.endFrame();
|
||||||
|
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
|
||||||
|
bytes.writeUInt31(1);
|
||||||
|
frames.endFrame();
|
||||||
|
frames.beginFrame(FrameType.GOAWAY, 0, 0);
|
||||||
|
bytes.writeUInt31(0);
|
||||||
|
bytes.writeUInt32(Http2ErrorCode.NO_ERROR.code());
|
||||||
|
frames.endFrame();
|
||||||
|
wire = new byte[bytes.length()];
|
||||||
|
System.arraycopy(bytes.array(), 0, wire, 0, wire.length);
|
||||||
|
setupConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setupConnection() {
|
||||||
|
connection = new Http2Connection();
|
||||||
|
stream = new ResettableInputStream(wire);
|
||||||
|
input = new BufferedByteSource(stream, null);
|
||||||
|
reader = new Http2FrameReader(input);
|
||||||
|
sink = new CountingSink();
|
||||||
|
writer = new Http2FrameWriter(sink, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Setup(Level.Invocation)
|
||||||
|
public void resetConnection() {
|
||||||
|
stream.reset();
|
||||||
|
connection.reset();
|
||||||
|
sink.bytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@TearDown(Level.Trial)
|
||||||
|
public void closeWriter() {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int controlLifecycle() throws Exception {
|
||||||
|
connection.runPrepared(input, reader, writer, RUNNING);
|
||||||
|
return sink.bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ResettableInputStream extends InputStream {
|
||||||
|
private final byte[] bytes;
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
private ResettableInputStream(byte[] bytes) {
|
||||||
|
this.bytes = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void reset() {
|
||||||
|
position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() {
|
||||||
|
return position == bytes.length ? -1 : bytes[position++] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] target, int offset, int length) {
|
||||||
|
if (position == bytes.length) return -1;
|
||||||
|
int count = Math.min(length, bytes.length - position);
|
||||||
|
System.arraycopy(bytes, position, target, offset, count);
|
||||||
|
position += count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class CountingSink implements Http2FrameWriter.Sink {
|
||||||
|
private int bytes;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] buffer, int offset, int length) {
|
||||||
|
bytes += length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,11 @@
|
|||||||
package dev.relism.flash.extension;
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
import dev.relism.flash.tls.TlsConfig;
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
import java.util.List;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Singular;
|
import lombok.Singular;
|
||||||
import lombok.Value;
|
import lombok.Value;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for a {@link FlashApp} instance.
|
* Configuration for a {@link FlashApp} instance.
|
||||||
*
|
*
|
||||||
@@ -39,81 +37,81 @@ import java.util.List;
|
|||||||
@Builder
|
@Builder
|
||||||
public class FlashConfiguration {
|
public class FlashConfiguration {
|
||||||
|
|
||||||
int port;
|
int port;
|
||||||
String host;
|
String host;
|
||||||
|
|
||||||
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
|
/**
|
||||||
TlsConfig tls;
|
* TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link
|
||||||
|
* #listeners} is non-empty.
|
||||||
|
*/
|
||||||
|
TlsConfig tls;
|
||||||
|
|
||||||
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
|
/**
|
||||||
@Singular
|
* One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link
|
||||||
List<Listener> listeners;
|
* #host}/{@link #tls}.
|
||||||
|
*/
|
||||||
|
@Singular List<Listener> listeners;
|
||||||
|
|
||||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default int maxHeaderBufferSize = 64 * 1024;
|
||||||
int maxHeaderBufferSize = 64 * 1024;
|
|
||||||
|
|
||||||
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default int wsFrameBufferSize = 64 * 1024;
|
||||||
int wsFrameBufferSize = 64 * 1024;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the
|
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the first
|
||||||
* first byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles
|
* byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles one header
|
||||||
* one header byte every few seconds forever. Enforced by an absolute deadline
|
* byte every few seconds forever. Enforced by an absolute deadline (see {@code
|
||||||
* (see {@code dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket
|
* dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket timeout — a
|
||||||
* timeout — a per-read timeout alone never trips as long as each individual read succeeds
|
* per-read timeout alone never trips as long as each individual read succeeds within the window,
|
||||||
* within the window, no matter how long the overall header block takes. Default: 10 000
|
* no matter how long the overall header block takes. Default: 10 000
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default int headerReadTimeoutMs = 10_000;
|
||||||
int headerReadTimeoutMs = 10_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next
|
* Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next
|
||||||
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an
|
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an idle
|
||||||
* idle keep-alive connection is normal, expected behaviour, not an attack in progress — the
|
* keep-alive connection is normal, expected behaviour, not an attack in progress — the tighter
|
||||||
* tighter bound applies only once bytes have actually started arriving. Default: 60 000
|
* bound applies only once bytes have actually started arriving. Default: 60 000
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default int idleKeepAliveTimeoutMs = 60_000;
|
||||||
int idleKeepAliveTimeoutMs = 60_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler
|
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler or by
|
||||||
* or by the automatic drain after it returns) once headers are parsed. Default: 30 000
|
* the automatic drain after it returns) once headers are parsed. Default: 30 000
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default int bodyReadTimeoutMs = 30_000;
|
||||||
int bodyReadTimeoutMs = 30_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for
|
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for in-flight
|
||||||
* in-flight requests to finish after it stops accepting new connections, before force-
|
* requests to finish after it stops accepting new connections, before force-
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
|
||||||
int shutdownDrainTimeoutMs = 15_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this server will ever negotiate HTTP/2. Default {@code false}: until the h2
|
* Whether this server negotiates HTTP/2. When enabled, plaintext listeners recognize h2c prior
|
||||||
* flag currently only gates the h2c cleartext-preface detection
|
* knowledge and TLS listeners advertise {@code h2} followed by HTTP/1.1 through ALPN. Disabled by
|
||||||
* ({@code dev.relism.flash.transport.ProtocolNegotiator}) — skipping it entirely keeps
|
* default until the HTTP/2 request/response path is complete.
|
||||||
* plaintext connections byte-for-byte identical to pre-HTTP/2 Flash when left at its
|
*/
|
||||||
* default. TLS/ALPN connections are always detected accurately regardless of this flag
|
@Builder.Default boolean http2Enabled = false;
|
||||||
* (that costs nothing — see {@code ProtocolNegotiator}'s Javadoc) but are cleanly rejected
|
|
||||||
* rather than served until the phases that implement HTTP/2 land.
|
|
||||||
*/
|
|
||||||
@Builder.Default
|
|
||||||
boolean http2Enabled = false;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default
|
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true};
|
||||||
* {@code true}; set {@code false} if Flash sits behind a reverse proxy that already adds
|
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
|
||||||
* one, to skip the (already cheap — see {@code dev.relism.flash.http.DateHeader}) write.
|
* (already cheap — see {@code dev.relism.flash.http.DateHeader}) write.
|
||||||
*/
|
*/
|
||||||
@Builder.Default
|
@Builder.Default boolean sendDate = true;
|
||||||
boolean sendDate = true;
|
|
||||||
|
|
||||||
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
|
/**
|
||||||
public record Listener(int port, String host, TlsConfig tls) {
|
* One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS.
|
||||||
public Listener(int port) { this(port, null, null); }
|
*/
|
||||||
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
|
public record Listener(int port, String host, TlsConfig tls) {
|
||||||
|
public Listener(int port) {
|
||||||
|
this(port, null, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Listener(int port, TlsConfig tls) {
|
||||||
|
this(port, null, tls);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,307 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
|
||||||
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
||||||
|
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.FrameValidator;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameReader;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import dev.relism.flash.transport.ConnectionContext;
|
||||||
|
import dev.relism.flash.transport.ConnectionProtocol;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.util.function.BooleanSupplier;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns one HTTP/2 connection's demultiplexing and connection-level protocol state. The demux loop
|
||||||
|
* never invokes application code and never waits for a handler or body consumer; stream dispatch is
|
||||||
|
* handed to independent virtual threads by the stream layer.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public final class Http2Connection implements ConnectionProtocol {
|
||||||
|
private static final byte[] SHUTDOWN_PING = {
|
||||||
|
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
|
||||||
|
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
|
||||||
|
};
|
||||||
|
|
||||||
|
private final Http2Settings peerSettings = new Http2Settings();
|
||||||
|
private final Http2ConnectionScratch scratch = new Http2ConnectionScratch();
|
||||||
|
private final Http2Settings.StreamWindowUpdater streamWindows;
|
||||||
|
private final long settingsAckTimeoutMs;
|
||||||
|
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
|
||||||
|
|
||||||
|
private long connectionSendWindow = 65_535;
|
||||||
|
private int outstandingLocalSettings;
|
||||||
|
private long oldestSettingsSentNanos;
|
||||||
|
private int lastProcessedStreamId;
|
||||||
|
private int peerLastStreamId = Integer.MAX_VALUE;
|
||||||
|
private int peerErrorCode;
|
||||||
|
private boolean peerGoAway;
|
||||||
|
private boolean gracefulStarted;
|
||||||
|
private boolean gracefulFinished;
|
||||||
|
|
||||||
|
public Http2Connection() {
|
||||||
|
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2Connection(Http2Settings.StreamWindowUpdater streamWindows) {
|
||||||
|
this(streamWindows, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) {
|
||||||
|
this.streamWindows = streamWindows;
|
||||||
|
this.settingsAckTimeoutMs = settingsAckTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ConnectionContext ctx) throws IOException {
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
||||||
|
try {
|
||||||
|
run(ctx.in(), writer, ctx.stopped());
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
|
||||||
|
throws IOException {
|
||||||
|
Http2FrameReader reader = new Http2FrameReader(input);
|
||||||
|
runPrepared(input, reader, writer, stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs with connection collaborators that were allocated during connection setup. */
|
||||||
|
void runPrepared(
|
||||||
|
BufferedByteSource input,
|
||||||
|
Http2FrameReader reader,
|
||||||
|
Http2FrameWriter writer,
|
||||||
|
BooleanSupplier stopped)
|
||||||
|
throws IOException {
|
||||||
|
if (!verifyPreface(input)) return;
|
||||||
|
|
||||||
|
sendConstant(writer, Http2Preface.serverSettings());
|
||||||
|
sendConstant(writer, Http2Preface.initialConnectionWindow());
|
||||||
|
outstandingLocalSettings = 1;
|
||||||
|
oldestSettingsSentNanos = System.nanoTime();
|
||||||
|
|
||||||
|
boolean firstFrame = true;
|
||||||
|
try {
|
||||||
|
while (!gracefulFinished && !peerGoAway) {
|
||||||
|
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
|
||||||
|
FrameHeader frame;
|
||||||
|
try {
|
||||||
|
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
|
||||||
|
} catch (SocketTimeoutException timeout) {
|
||||||
|
checkSettingsTimeout();
|
||||||
|
if (stopped.getAsBoolean() && !gracefulStarted) {
|
||||||
|
startGracefulShutdown(writer);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (reader.frameDeadlineExpired()) throw timeout;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (frame == null) break;
|
||||||
|
try {
|
||||||
|
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
|
||||||
|
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
if (firstFrame && frame.type() != FrameType.SETTINGS) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
if (firstFrame && FrameFlags.isAck(frame.flags()) && frame.length() == 0) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
firstFrame = false;
|
||||||
|
dispatch(frame, writer);
|
||||||
|
} catch (Http2StreamException streamError) {
|
||||||
|
sendRstStream(writer, streamError);
|
||||||
|
} finally {
|
||||||
|
reader.consumeFrame();
|
||||||
|
}
|
||||||
|
writer.drain();
|
||||||
|
checkSettingsTimeout();
|
||||||
|
}
|
||||||
|
} catch (Http2Exception connectionError) {
|
||||||
|
sendGoAway(
|
||||||
|
writer, lastProcessedStreamId, connectionError.errorCode(), connectionError.getMessage());
|
||||||
|
} catch (IOException io) {
|
||||||
|
throw io;
|
||||||
|
} catch (RuntimeException unexpected) {
|
||||||
|
log.error("Unexpected failure in HTTP/2 demux loop", unexpected);
|
||||||
|
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.INTERNAL_ERROR, "internal error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean verifyPreface(BufferedByteSource input) throws IOException {
|
||||||
|
byte[] preface = scratch.prefaceBuffer();
|
||||||
|
int read = 0;
|
||||||
|
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
|
||||||
|
try {
|
||||||
|
while (read < preface.length) {
|
||||||
|
int n = input.read(preface, read, preface.length - read);
|
||||||
|
if (n < 0) return false;
|
||||||
|
read += n;
|
||||||
|
}
|
||||||
|
return Http2Preface.matchesClientPreface(preface);
|
||||||
|
} finally {
|
||||||
|
input.clearDeadline();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
|
FrameType type = frame.type();
|
||||||
|
if (type == null) return;
|
||||||
|
switch (type) {
|
||||||
|
case SETTINGS -> receiveSettings(frame, writer);
|
||||||
|
case PING -> receivePing(frame, writer);
|
||||||
|
case WINDOW_UPDATE -> receiveWindowUpdate(frame);
|
||||||
|
case GOAWAY -> receiveGoAway(frame);
|
||||||
|
case HEADERS, CONTINUATION -> {
|
||||||
|
if (headerBlocks.accept(frame)) {
|
||||||
|
lastProcessedStreamId = Math.max(lastProcessedStreamId, frame.streamId());
|
||||||
|
if (!gracefulStarted) startGracefulShutdown(writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
// Stream semantics are introduced by the stream layer. Structurally-valid
|
||||||
|
// frames are consumed here so connection-level state remains synchronized.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
|
boolean ack = FrameFlags.isAck(frame.flags());
|
||||||
|
if (ack) {
|
||||||
|
if (frame.length() != 0) throw Http2Exception.FRAME_SIZE_ERROR;
|
||||||
|
if (outstandingLocalSettings == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
outstandingLocalSettings--;
|
||||||
|
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
|
||||||
|
sendConstant(writer, Http2Preface.settingsAck());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receivePing(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
|
if (FrameFlags.isAck(frame.flags())) {
|
||||||
|
if (gracefulStarted && matches(frame.buffer(), frame.payloadOffset(), SHUTDOWN_PING)) {
|
||||||
|
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.NO_ERROR, "shutdown complete");
|
||||||
|
gracefulFinished = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ControlIntent pong = scratch.acquire(ControlKind.PING);
|
||||||
|
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
|
||||||
|
writer.writePriority(pong);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveWindowUpdate(FrameHeader frame) {
|
||||||
|
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
|
if (increment == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
if (frame.streamId() != 0) return;
|
||||||
|
long next = connectionSendWindow + increment;
|
||||||
|
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
connectionSendWindow = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void receiveGoAway(FrameHeader frame) {
|
||||||
|
peerLastStreamId = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
|
peerErrorCode = readInt(frame.buffer(), frame.payloadOffset() + 4);
|
||||||
|
peerGoAway = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
|
||||||
|
gracefulStarted = true;
|
||||||
|
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
|
||||||
|
ControlIntent ping = scratch.acquire(ControlKind.PING);
|
||||||
|
ping.frame(FrameType.PING, 0, 0, SHUTDOWN_PING, 0, SHUTDOWN_PING.length);
|
||||||
|
writer.writePriority(ping);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendRstStream(Http2FrameWriter writer, Http2StreamException error)
|
||||||
|
throws IOException {
|
||||||
|
ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
|
||||||
|
rst.frame(FrameType.RST_STREAM, 0, error.streamId(), error.errorCode().bytes(), 0, 4);
|
||||||
|
writer.writePriority(rst);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendGoAway(
|
||||||
|
Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug)
|
||||||
|
throws IOException {
|
||||||
|
ControlIntent goAway = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
|
||||||
|
goAway.goAway(lastStreamId, error, debug == null ? "" : debug);
|
||||||
|
writer.writePriority(goAway);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendConstant(Http2FrameWriter writer, byte[] bytes) throws IOException {
|
||||||
|
ControlIntent intent = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
|
||||||
|
intent.copy(bytes);
|
||||||
|
writer.writePriority(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long nextReadTimeoutMs() {
|
||||||
|
if (outstandingLocalSettings == 0) return Http2Limits.FRAME_READ_TIMEOUT_MS;
|
||||||
|
long elapsed = System.nanoTime() - oldestSettingsSentNanos;
|
||||||
|
long remainingNanos = settingsAckTimeoutMs * 1_000_000L - elapsed;
|
||||||
|
if (remainingNanos <= 0) throw Http2Exception.SETTINGS_TIMEOUT;
|
||||||
|
long remainingMs = Math.max(1, (remainingNanos + 999_999L) / 1_000_000L);
|
||||||
|
return Math.min(Http2Limits.FRAME_READ_TIMEOUT_MS, remainingMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkSettingsTimeout() {
|
||||||
|
if (outstandingLocalSettings != 0
|
||||||
|
&& System.nanoTime() - oldestSettingsSentNanos >= settingsAckTimeoutMs * 1_000_000L) {
|
||||||
|
throw Http2Exception.SETTINGS_TIMEOUT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean matches(byte[] buf, int off, byte[] expected) {
|
||||||
|
int different = 0;
|
||||||
|
for (int i = 0; i < expected.length; i++) different |= buf[off + i] ^ expected[i];
|
||||||
|
return different == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readUInt31(byte[] buf, int off) {
|
||||||
|
return readInt(buf, off) & 0x7FFF_FFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readInt(byte[] buf, int off) {
|
||||||
|
return ((buf[off] & 0xFF) << 24)
|
||||||
|
| ((buf[off + 1] & 0xFF) << 16)
|
||||||
|
| ((buf[off + 2] & 0xFF) << 8)
|
||||||
|
| (buf[off + 3] & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2Settings peerSettings() {
|
||||||
|
return peerSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long connectionSendWindow() {
|
||||||
|
return connectionSendWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int peerLastStreamId() {
|
||||||
|
return peerLastStreamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int peerErrorCode() {
|
||||||
|
return peerErrorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
peerSettings.reset();
|
||||||
|
connectionSendWindow = 65_535;
|
||||||
|
outstandingLocalSettings = 0;
|
||||||
|
oldestSettingsSentNanos = 0;
|
||||||
|
lastProcessedStreamId = 0;
|
||||||
|
peerLastStreamId = Integer.MAX_VALUE;
|
||||||
|
peerErrorCode = 0;
|
||||||
|
peerGoAway = false;
|
||||||
|
gracefulStarted = false;
|
||||||
|
gracefulFinished = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.frame.WriteIntent;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/** Reusable control-frame storage owned by one HTTP/2 connection. */
|
||||||
|
final class Http2ConnectionScratch {
|
||||||
|
private static final int CONTROL_SLOT_COUNT =
|
||||||
|
Http2Limits.MAX_PING_QUEUE_DEPTH + Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH + 8;
|
||||||
|
private static final int CONTROL_FRAME_CAPACITY = 256;
|
||||||
|
|
||||||
|
private final ControlIntent[] controls = new ControlIntent[CONTROL_SLOT_COUNT];
|
||||||
|
private final AtomicInteger pingResponses = new AtomicInteger();
|
||||||
|
private final AtomicInteger settingsAcks = new AtomicInteger();
|
||||||
|
private final byte[] preface = new byte[Http2Preface.clientPrefaceLength()];
|
||||||
|
|
||||||
|
Http2ConnectionScratch() {
|
||||||
|
for (int i = 0; i < controls.length; i++) {
|
||||||
|
controls[i] = new ControlIntent(this, CONTROL_FRAME_CAPACITY);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] prefaceBuffer() {
|
||||||
|
return preface;
|
||||||
|
}
|
||||||
|
|
||||||
|
ControlIntent acquire(ControlKind kind) {
|
||||||
|
AtomicInteger counter = counter(kind);
|
||||||
|
int limit = limit(kind);
|
||||||
|
int queued = counter.incrementAndGet();
|
||||||
|
if (queued > limit) {
|
||||||
|
counter.decrementAndGet();
|
||||||
|
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, kind + " queue exhausted");
|
||||||
|
}
|
||||||
|
for (ControlIntent intent : controls) {
|
||||||
|
if (intent.claim(kind)) return intent;
|
||||||
|
}
|
||||||
|
counter.decrementAndGet();
|
||||||
|
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, "control-frame queue exhausted");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void release(ControlIntent intent) {
|
||||||
|
counter(intent.kind).decrementAndGet();
|
||||||
|
intent.release();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AtomicInteger counter(ControlKind kind) {
|
||||||
|
return kind == ControlKind.PING ? pingResponses : settingsAcks;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int limit(ControlKind kind) {
|
||||||
|
return kind == ControlKind.PING
|
||||||
|
? Http2Limits.MAX_PING_QUEUE_DEPTH
|
||||||
|
: Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH;
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ControlKind {
|
||||||
|
PING,
|
||||||
|
SETTINGS_OR_OTHER
|
||||||
|
}
|
||||||
|
|
||||||
|
static final class ControlIntent implements WriteIntent {
|
||||||
|
private final Http2ConnectionScratch owner;
|
||||||
|
private final byte[] buffer;
|
||||||
|
private final AtomicBoolean claimed = new AtomicBoolean();
|
||||||
|
private volatile WriteIntent next;
|
||||||
|
private ControlKind kind;
|
||||||
|
private int length;
|
||||||
|
|
||||||
|
private ControlIntent(Http2ConnectionScratch owner, int capacity) {
|
||||||
|
this.owner = owner;
|
||||||
|
this.buffer = new byte[capacity];
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean claim(ControlKind kind) {
|
||||||
|
if (!claimed.compareAndSet(false, true)) return false;
|
||||||
|
this.kind = kind;
|
||||||
|
this.length = 0;
|
||||||
|
this.next = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void copy(byte[] source) {
|
||||||
|
System.arraycopy(source, 0, buffer, 0, source.length);
|
||||||
|
length = source.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
void frame(FrameType type, int flags, int streamId, byte[] payload, int off, int len) {
|
||||||
|
if (9 + len > buffer.length) {
|
||||||
|
throw new IllegalArgumentException("control frame exceeds scratch capacity");
|
||||||
|
}
|
||||||
|
buffer[0] = (byte) (len >>> 16);
|
||||||
|
buffer[1] = (byte) (len >>> 8);
|
||||||
|
buffer[2] = (byte) len;
|
||||||
|
buffer[3] = (byte) type.code();
|
||||||
|
buffer[4] = (byte) flags;
|
||||||
|
writeUInt31(buffer, 5, streamId);
|
||||||
|
System.arraycopy(payload, off, buffer, 9, len);
|
||||||
|
length = 9 + len;
|
||||||
|
}
|
||||||
|
|
||||||
|
void goAway(int lastStreamId, Http2ErrorCode error, String debug) {
|
||||||
|
int debugLength =
|
||||||
|
Math.min(
|
||||||
|
debug.length(),
|
||||||
|
Math.min(Http2Limits.MAX_GOAWAY_DEBUG_DATA_LENGTH, buffer.length - 17));
|
||||||
|
int payloadLength = 8 + debugLength;
|
||||||
|
buffer[0] = 0;
|
||||||
|
buffer[1] = 0;
|
||||||
|
buffer[2] = (byte) payloadLength;
|
||||||
|
buffer[3] = (byte) FrameType.GOAWAY.code();
|
||||||
|
buffer[4] = 0;
|
||||||
|
writeUInt31(buffer, 5, 0);
|
||||||
|
writeUInt31(buffer, 9, lastStreamId);
|
||||||
|
writeUInt32(buffer, 13, error.code());
|
||||||
|
for (int i = 0; i < debugLength; i++) buffer[17 + i] = (byte) debug.charAt(i);
|
||||||
|
length = 17 + debugLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeUInt31(byte[] target, int off, int value) {
|
||||||
|
writeUInt32(target, off, value & 0x7FFF_FFFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void writeUInt32(byte[] target, int off, int value) {
|
||||||
|
target[off] = (byte) (value >>> 24);
|
||||||
|
target[off + 1] = (byte) (value >>> 16);
|
||||||
|
target[off + 2] = (byte) (value >>> 8);
|
||||||
|
target[off + 3] = (byte) value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] buffer() {
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void completed() {
|
||||||
|
owner.release(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void release() {
|
||||||
|
next = null;
|
||||||
|
claimed.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.Pairs;
|
||||||
|
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.Padding;
|
||||||
|
import dev.relism.flash.http2.hpack.ContinuationAssembler;
|
||||||
|
import dev.relism.flash.http2.hpack.HeaderListSizeException;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
|
||||||
|
/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */
|
||||||
|
final class Http2HeaderBlockDecoder {
|
||||||
|
private static final int PRIORITY_FIELDS_LENGTH = 5;
|
||||||
|
|
||||||
|
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
||||||
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
|
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
||||||
|
|
||||||
|
boolean insideHeaderBlock() {
|
||||||
|
return assembler.isActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
||||||
|
boolean accept(FrameHeader frame) {
|
||||||
|
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
if (frame.type() == FrameType.HEADERS) {
|
||||||
|
begin(frame);
|
||||||
|
} else if (frame.type() == FrameType.CONTINUATION) {
|
||||||
|
assembler.continuation(
|
||||||
|
frame.streamId(),
|
||||||
|
frame.buffer(),
|
||||||
|
frame.payloadOffset(),
|
||||||
|
frame.length(),
|
||||||
|
FrameFlags.isEndHeaders(frame.flags()));
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!assembler.isComplete()) return false;
|
||||||
|
|
||||||
|
headers.reset();
|
||||||
|
try {
|
||||||
|
decoder.decode(assembler.buffer(), 0, assembler.length(), headers);
|
||||||
|
} catch (HeaderListSizeException tooLarge) {
|
||||||
|
int streamId = assembler.streamId();
|
||||||
|
assembler.reset();
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
|
||||||
|
}
|
||||||
|
assembler.reset();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void begin(FrameHeader frame) {
|
||||||
|
long unpadded =
|
||||||
|
Padding.unpad(
|
||||||
|
frame.buffer(),
|
||||||
|
frame.payloadOffset(),
|
||||||
|
frame.length(),
|
||||||
|
FrameFlags.isPadded(frame.flags()));
|
||||||
|
int fragmentOffset = Pairs.hi(unpadded);
|
||||||
|
int fragmentLength = Pairs.lo(unpadded);
|
||||||
|
if (FrameFlags.hasPriority(frame.flags())) {
|
||||||
|
if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR;
|
||||||
|
fragmentOffset += PRIORITY_FIELDS_LENGTH;
|
||||||
|
fragmentLength -= PRIORITY_FIELDS_LENGTH;
|
||||||
|
}
|
||||||
|
assembler.begin(
|
||||||
|
frame.streamId(),
|
||||||
|
frame.buffer(),
|
||||||
|
fragmentOffset,
|
||||||
|
fragmentLength,
|
||||||
|
FrameFlags.isEndHeaders(frame.flags()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,159 +3,176 @@ package dev.relism.flash.http2;
|
|||||||
/**
|
/**
|
||||||
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
|
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
|
||||||
*
|
*
|
||||||
* Every wire-derived length, index, count, or size is checked against a named constant here —
|
* <p>Every wire-derived length, index, count, or size is checked against a named constant here —
|
||||||
* never against an ad-hoc literal, and never by letting the
|
* never against an ad-hoc literal, and never by letting the underlying array or buffer throw on
|
||||||
* underlying array or buffer throw on overrun. Each field's Javadoc names the specific attack
|
* overrun. Each field's Javadoc names the specific attack or resource it bounds and, where one
|
||||||
* or resource it bounds and, where one exists, the CVE.
|
* exists, the CVE.
|
||||||
*
|
*
|
||||||
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable
|
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable only
|
||||||
* only when the operational need and its safe range are established.
|
* when the operational need and its safe range are established.
|
||||||
*
|
*
|
||||||
* <p>Each field is introduced with the feature that enforces it; this class contains no unused
|
* <p>Each field is introduced with the feature that enforces it; this class contains no unused
|
||||||
* placeholders.
|
* placeholders.
|
||||||
*/
|
*/
|
||||||
public final class Http2Limits {
|
public final class Http2Limits {
|
||||||
|
|
||||||
private Http2Limits() {
|
private Http2Limits() {}
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of streams a single connection may have open concurrently. Advertised to
|
* Maximum number of streams a single connection may have open concurrently. Advertised to the
|
||||||
* the peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each
|
* peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each open stream
|
||||||
* open stream owns a per-stream HPACK arena and request/response state) against a peer that
|
* owns a per-stream HPACK arena and request/response state) against a peer that simply opens
|
||||||
* simply opens streams and never closes them.
|
* streams and never closes them.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_CONCURRENT_STREAMS = 100;
|
public static final int MAX_CONCURRENT_STREAMS = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The largest frame payload we accept without the peer first raising it via our own
|
* The largest frame payload we accept without the peer first raising it via our own {@code
|
||||||
* {@code SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and
|
* SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and requires any
|
||||||
* requires any advertised value to stay within {@code 16384..16777215}. Bounds the memory a
|
* advertised value to stay within {@code 16384..16777215}. Bounds the memory a single frame read
|
||||||
* single frame read can force us to hold.
|
* can force us to hold.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
|
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every
|
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every header)
|
||||||
* header) of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE}
|
* of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE} (RFC 9113
|
||||||
* (RFC 9113 §6.5.2). This is the primary defence against an HPACK bomb: a small compressed
|
* §6.5.2). This is the primary defence against an HPACK bomb: a small compressed block that
|
||||||
* block that references dynamic-table entries to expand into an enormous header list.
|
* references dynamic-table entries to expand into an enormous header list.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_HEADER_LIST_SIZE = 32_768;
|
public static final int MAX_HEADER_LIST_SIZE = 32_768;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of CONTINUATION frames accepted for a single header block before the
|
* Maximum number of CONTINUATION frames accepted for a single header block before the connection
|
||||||
* connection is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION
|
* is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION Flood"): a peer that
|
||||||
* Flood"): a peer that never sets {@code END_HEADERS} can otherwise force unbounded
|
* never sets {@code END_HEADERS} can otherwise force unbounded decode/reassembly work per header
|
||||||
* decode/reassembly work per header block.
|
* block.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
|
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of {@code RST_STREAM} frames accepted from the peer within
|
* Maximum number of {@code RST_STREAM} frames accepted from the peer within {@link
|
||||||
* {@link #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"):
|
* #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"): opening a
|
||||||
* opening a stream and immediately resetting it does not count against
|
* stream and immediately resetting it does not count against {@link #MAX_CONCURRENT_STREAMS}, so
|
||||||
* {@link #MAX_CONCURRENT_STREAMS}, so without a rate bound a peer can force unbounded
|
* without a rate bound a peer can force unbounded per-stream setup/teardown work at effectively
|
||||||
* per-stream setup/teardown work at effectively unlimited concurrency.
|
* unlimited concurrency.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
|
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
|
||||||
|
|
||||||
/** The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is measured. */
|
/**
|
||||||
public static final long RESET_RATE_INTERVAL_MS = 10_000;
|
* The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is
|
||||||
|
* measured.
|
||||||
|
*/
|
||||||
|
public static final long RESET_RATE_INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of new streams accepted from the peer within
|
* Maximum number of new streams accepted from the peer within {@link #RESET_RATE_INTERVAL_MS}. A
|
||||||
* {@link #RESET_RATE_INTERVAL_MS}. A companion bound to
|
* companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only
|
||||||
* {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only count resets can
|
* count resets can still be bypassed by a peer that creates streams fast enough that the reset
|
||||||
* still be bypassed by a peer that creates streams fast enough that the reset counter never
|
* counter never saturates within any single window boundary.
|
||||||
* saturates within any single window boundary.
|
*/
|
||||||
*/
|
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
|
||||||
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A
|
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS
|
||||||
* SETTINGS frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each
|
* frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each entry is 6
|
||||||
* entry is 6 bytes), but an explicit entry-count bound keeps the per-entry validation loop
|
* bytes), but an explicit entry-count bound keeps the per-entry validation loop itself cheap to
|
||||||
* itself cheap to reason about and gives a distinct, loud rejection reason.
|
* reason about and gives a distinct, loud rejection reason.
|
||||||
*/
|
*/
|
||||||
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
|
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
|
||||||
|
|
||||||
/**
|
/** Maximum number of locally-sent SETTINGS frames awaiting acknowledgement. */
|
||||||
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING
|
public static final int MAX_OUTSTANDING_LOCAL_SETTINGS = 8;
|
||||||
* flood forces a PONG per PING; without a bound, a peer that reads its own responses slowly
|
|
||||||
* can make us buffer unbounded PONG frames.
|
|
||||||
*/
|
|
||||||
public static final int MAX_PING_QUEUE_DEPTH = 64;
|
|
||||||
|
|
||||||
/**
|
/** Maximum time allowed for the peer to acknowledge a locally-sent SETTINGS frame. */
|
||||||
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes
|
public static final long SETTINGS_ACK_TIMEOUT_MS = 10_000;
|
||||||
* no flow-control window, so window accounting does not bound it — without this limit a
|
|
||||||
* peer can force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
|
|
||||||
*/
|
|
||||||
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
|
* Maximum number of SETTINGS acknowledgements waiting behind a blocked socket writer. This
|
||||||
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
|
* prevents a peer from turning a stream of empty SETTINGS frames into an unbounded queue of
|
||||||
*/
|
* mandatory responses.
|
||||||
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
|
*/
|
||||||
|
public static final int MAX_SETTINGS_ACK_QUEUE_DEPTH = 64;
|
||||||
|
|
||||||
/**
|
/** Maximum diagnostic bytes included in an outbound GOAWAY frame. */
|
||||||
* The connection-level flow-control window Flash advertises. Sized above
|
public static final int MAX_GOAWAY_DEBUG_DATA_LENGTH = 128;
|
||||||
* {@link #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the
|
|
||||||
* connection window before its own stream window, but well below
|
|
||||||
* {@code MAX_CONCURRENT_STREAMS * INITIAL_WINDOW_SIZE_LOCAL} — real traffic is never all
|
|
||||||
* streams simultaneously saturating their windows, and sizing for that worst case would
|
|
||||||
* commit 100 MiB of receive window to every connection regardless of load.
|
|
||||||
*/
|
|
||||||
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1
|
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING flood
|
||||||
* accounting. RFC 7541's protocol default. The encoder never uses a dynamic table at all
|
* forces a PONG per PING; without a bound, a peer that reads its own responses slowly can make us
|
||||||
*/
|
* buffer unbounded PONG frames.
|
||||||
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
|
*/
|
||||||
|
public static final int MAX_PING_QUEUE_DEPTH = 64;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during
|
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes no
|
||||||
* Huffman decode as bytes are produced, not to the encoded length — a Huffman string can
|
* flow-control window, so window accounting does not bound it — without this limit a peer can
|
||||||
* expand by roughly 8/5, so bounding only the encoded length would let a compact input
|
* force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
|
||||||
* still decode past this limit.
|
*/
|
||||||
*/
|
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
|
||||||
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header
|
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
|
||||||
* block's completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A
|
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
|
||||||
* peer that starts a header block and then stalls indefinitely would otherwise hold the
|
*/
|
||||||
* per-stream arena and the connection's HPACK assembly buffer forever.
|
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
|
||||||
*/
|
|
||||||
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
|
* The connection-level flow-control window Flash advertises. Sized above {@link
|
||||||
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent
|
* #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the connection
|
||||||
* without closing it — the h2 equivalent of the h1 slowloris defence in
|
* window before its own stream window, but well below {@code MAX_CONCURRENT_STREAMS *
|
||||||
* {@code FlashConfiguration.idleKeepAliveTimeoutMs}.
|
* INITIAL_WINDOW_SIZE_LOCAL} — real traffic is never all streams simultaneously saturating their
|
||||||
*/
|
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
|
||||||
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
|
* connection regardless of load.
|
||||||
|
*/
|
||||||
|
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
|
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC
|
||||||
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the
|
* 7541's protocol default. The encoder never uses a dynamic table at all
|
||||||
* peer is not reading (that peer holds the connection's single writer lock for the duration
|
*/
|
||||||
* — see {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading
|
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
|
||||||
* would otherwise let a single stalled connection wedge the writer forever. Enforced via a
|
|
||||||
* background reaper interrupting the blocked thread past the deadline, not
|
|
||||||
* {@code Socket#setSoTimeout} — that option bounds reads, not writes.
|
|
||||||
*/
|
|
||||||
public static final long WRITE_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
|
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during Huffman
|
||||||
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
|
* decode as bytes are produced, not to the encoded length — a Huffman string can expand by
|
||||||
* without it, a peer that sends 9 header bytes and then never sends the declared payload
|
* roughly 8/5, so bounding only the encoded length would let a compact input still decode past
|
||||||
* would hold this connection's frame reader waiting forever.
|
* this limit.
|
||||||
*/
|
*/
|
||||||
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
|
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header block's
|
||||||
|
* completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A peer that
|
||||||
|
* starts a header block and then stalls indefinitely would otherwise hold the per-stream arena
|
||||||
|
* and the connection's HPACK assembly buffer forever.
|
||||||
|
*/
|
||||||
|
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
|
||||||
|
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent without
|
||||||
|
* closing it — the h2 equivalent of the h1 slowloris defence in {@code
|
||||||
|
* FlashConfiguration.idleKeepAliveTimeoutMs}.
|
||||||
|
*/
|
||||||
|
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
|
||||||
|
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the peer
|
||||||
|
* is not reading (that peer holds the connection's single writer lock for the duration — see
|
||||||
|
* {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading would
|
||||||
|
* otherwise let a single stalled connection wedge the writer forever. Enforced via a background
|
||||||
|
* reaper interrupting the blocked thread past the deadline, not {@code Socket#setSoTimeout} —
|
||||||
|
* that option bounds reads, not writes.
|
||||||
|
*/
|
||||||
|
public static final long WRITE_TIMEOUT_MS = 30_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
|
||||||
|
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
|
||||||
|
* without it, a peer that sends 9 header bytes and then never sends the declared payload
|
||||||
|
* would hold this connection's frame reader waiting forever.
|
||||||
|
*/
|
||||||
|
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.frame.FrameWriteBuffer;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
|
||||||
|
final class Http2Preface {
|
||||||
|
private static final byte[] CLIENT_PREFACE =
|
||||||
|
"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[] SETTINGS_ACK = frame(FrameType.SETTINGS, FrameFlags.ACK, 0, 0);
|
||||||
|
private static final byte[] INITIAL_CONNECTION_WINDOW = buildInitialConnectionWindow();
|
||||||
|
|
||||||
|
private Http2Preface() {}
|
||||||
|
|
||||||
|
static int clientPrefaceLength() {
|
||||||
|
return CLIENT_PREFACE.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean matchesClientPreface(byte[] candidate) {
|
||||||
|
if (candidate.length != CLIENT_PREFACE.length) return false;
|
||||||
|
int different = 0;
|
||||||
|
for (int i = 0; i < CLIENT_PREFACE.length; i++) {
|
||||||
|
different |= candidate[i] ^ CLIENT_PREFACE[i];
|
||||||
|
}
|
||||||
|
return different == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] serverSettings() {
|
||||||
|
return SERVER_SETTINGS;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] settingsAck() {
|
||||||
|
return SETTINGS_ACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] initialConnectionWindow() {
|
||||||
|
return INITIAL_CONNECTION_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildServerSettings() {
|
||||||
|
ByteWriter bytes = new ByteWriter(64);
|
||||||
|
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
|
||||||
|
frame.beginFrame(FrameType.SETTINGS, 0, 0);
|
||||||
|
setting(bytes, Http2Settings.HEADER_TABLE_SIZE, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
|
||||||
|
setting(bytes, Http2Settings.ENABLE_PUSH, 0);
|
||||||
|
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
|
||||||
|
setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
|
||||||
|
setting(bytes, Http2Settings.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE);
|
||||||
|
frame.endFrame();
|
||||||
|
return copy(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] buildInitialConnectionWindow() {
|
||||||
|
int increment = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - 65_535;
|
||||||
|
return frame(FrameType.WINDOW_UPDATE, 0, 0, increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] frame(FrameType type, int flags, int streamId, int payload) {
|
||||||
|
ByteWriter bytes = new ByteWriter(16);
|
||||||
|
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
|
||||||
|
frame.beginFrame(type, flags, streamId);
|
||||||
|
if (type == FrameType.WINDOW_UPDATE) bytes.writeUInt31(payload);
|
||||||
|
frame.endFrame();
|
||||||
|
return copy(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setting(ByteWriter bytes, int id, int value) {
|
||||||
|
bytes.writeUInt16(id);
|
||||||
|
bytes.writeUInt32(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] copy(ByteWriter bytes) {
|
||||||
|
byte[] result = new byte[bytes.length()];
|
||||||
|
System.arraycopy(bytes.array(), 0, result, 0, result.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The peer's HTTP/2 SETTINGS state. A received payload is validated completely before any value is
|
||||||
|
* applied, so a malformed parameter cannot leave a partially-updated connection.
|
||||||
|
*/
|
||||||
|
public final class Http2Settings {
|
||||||
|
public static final int HEADER_TABLE_SIZE = 0x1;
|
||||||
|
public static final int ENABLE_PUSH = 0x2;
|
||||||
|
public static final int MAX_CONCURRENT_STREAMS = 0x3;
|
||||||
|
public static final int INITIAL_WINDOW_SIZE = 0x4;
|
||||||
|
public static final int MAX_FRAME_SIZE = 0x5;
|
||||||
|
public static final int MAX_HEADER_LIST_SIZE = 0x6;
|
||||||
|
|
||||||
|
public static final int DEFAULT_HEADER_TABLE_SIZE = 4_096;
|
||||||
|
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
|
||||||
|
public static final int DEFAULT_MAX_FRAME_SIZE = 16_384;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies an INITIAL_WINDOW_SIZE delta to every open stream. Implementations must validate all
|
||||||
|
* resulting windows before changing any of them; negative results are valid, while a result above
|
||||||
|
* {@link Integer#MAX_VALUE} is a connection FLOW_CONTROL_ERROR.
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface StreamWindowUpdater {
|
||||||
|
void applyInitialWindowDelta(int delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
|
||||||
|
private boolean pushEnabled = true;
|
||||||
|
private long maxConcurrentStreams = 0xFFFF_FFFFL;
|
||||||
|
private int initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
|
||||||
|
private int maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
|
||||||
|
private long maxHeaderListSize = 0xFFFF_FFFFL;
|
||||||
|
|
||||||
|
/** Validates and applies one SETTINGS payload. Unknown identifiers are ignored. */
|
||||||
|
public void apply(byte[] payload, int off, int len, StreamWindowUpdater streamWindows) {
|
||||||
|
if (len % 6 != 0) throw Http2Exception.FRAME_SIZE_ERROR;
|
||||||
|
int entries = len / 6;
|
||||||
|
if (entries > Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME) {
|
||||||
|
throw Http2Exception.of(
|
||||||
|
Http2ErrorCode.ENHANCE_YOUR_CALM, "too many SETTINGS entries: " + entries);
|
||||||
|
}
|
||||||
|
checkRange(payload, off, len);
|
||||||
|
|
||||||
|
int nextHeaderTableSize = headerTableSize;
|
||||||
|
boolean nextPushEnabled = pushEnabled;
|
||||||
|
long nextMaxConcurrentStreams = maxConcurrentStreams;
|
||||||
|
int nextInitialWindowSize = initialWindowSize;
|
||||||
|
int nextMaxFrameSize = maxFrameSize;
|
||||||
|
long nextMaxHeaderListSize = maxHeaderListSize;
|
||||||
|
for (int pos = off; pos < off + len; pos += 6) {
|
||||||
|
int id = readUInt16(payload, pos);
|
||||||
|
long value = readUInt32(payload, pos + 2);
|
||||||
|
validate(id, value);
|
||||||
|
switch (id) {
|
||||||
|
case HEADER_TABLE_SIZE ->
|
||||||
|
nextHeaderTableSize = (int) Math.min(value, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
|
||||||
|
case ENABLE_PUSH -> nextPushEnabled = value == 1;
|
||||||
|
case MAX_CONCURRENT_STREAMS -> nextMaxConcurrentStreams = value;
|
||||||
|
case INITIAL_WINDOW_SIZE -> nextInitialWindowSize = (int) value;
|
||||||
|
case MAX_FRAME_SIZE -> nextMaxFrameSize = (int) value;
|
||||||
|
case MAX_HEADER_LIST_SIZE -> nextMaxHeaderListSize = value;
|
||||||
|
default -> {
|
||||||
|
// RFC 9113 §6.5.2: ignore unknown settings.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
streamWindows.applyInitialWindowDelta(nextInitialWindowSize - initialWindowSize);
|
||||||
|
headerTableSize = nextHeaderTableSize;
|
||||||
|
pushEnabled = nextPushEnabled;
|
||||||
|
maxConcurrentStreams = nextMaxConcurrentStreams;
|
||||||
|
initialWindowSize = nextInitialWindowSize;
|
||||||
|
maxFrameSize = nextMaxFrameSize;
|
||||||
|
maxHeaderListSize = nextMaxHeaderListSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validate(int id, long value) {
|
||||||
|
switch (id) {
|
||||||
|
case ENABLE_PUSH -> {
|
||||||
|
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
case INITIAL_WINDOW_SIZE -> {
|
||||||
|
if (value > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
}
|
||||||
|
case MAX_FRAME_SIZE -> {
|
||||||
|
if (value < 16_384 || value > 16_777_215) {
|
||||||
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
// HEADER_TABLE_SIZE, MAX_CONCURRENT_STREAMS and MAX_HEADER_LIST_SIZE accept
|
||||||
|
// every unsigned 32-bit value. Unknown identifiers are ignored by the RFC.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void checkRange(byte[] payload, int off, int len) {
|
||||||
|
if (off < 0 || len < 0 || off > payload.length - len) {
|
||||||
|
throw new IndexOutOfBoundsException("invalid SETTINGS payload range");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readUInt16(byte[] buf, int off) {
|
||||||
|
return ((buf[off] & 0xFF) << 8) | (buf[off + 1] & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long readUInt32(byte[] buf, int off) {
|
||||||
|
return ((long) (buf[off] & 0xFF) << 24)
|
||||||
|
| ((long) (buf[off + 1] & 0xFF) << 16)
|
||||||
|
| ((long) (buf[off + 2] & 0xFF) << 8)
|
||||||
|
| (buf[off + 3] & 0xFFL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int headerTableSize() {
|
||||||
|
return headerTableSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean pushEnabled() {
|
||||||
|
return pushEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxConcurrentStreams() {
|
||||||
|
return maxConcurrentStreams;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int initialWindowSize() {
|
||||||
|
return initialWindowSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int maxFrameSize() {
|
||||||
|
return maxFrameSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxHeaderListSize() {
|
||||||
|
return maxHeaderListSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
|
||||||
|
pushEnabled = true;
|
||||||
|
maxConcurrentStreams = 0xFFFF_FFFFL;
|
||||||
|
initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
|
||||||
|
maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
|
||||||
|
maxHeaderListSize = 0xFFFF_FFFFL;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,86 +1,113 @@
|
|||||||
package dev.relism.flash.http2.frame;
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules
|
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules {@link
|
||||||
* {@link FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here —
|
* FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here — RFC 9113
|
||||||
* RFC 9113 §4.1 requires unknown types to be silently ignored (read and discard the payload),
|
* §4.1 requires unknown types to be silently ignored (read and discard the payload), which {@link
|
||||||
* which {@link Http2FrameReader}'s caller implements by checking {@code type >
|
* Http2FrameReader}'s caller implements by checking {@code type > FrameType.maxKnown()} rather than
|
||||||
* FrameType.maxKnown()} rather than by this enum growing an {@code UNKNOWN} member (an
|
* by this enum growing an {@code UNKNOWN} member (an {@code UNKNOWN} constant would misleadingly
|
||||||
* {@code UNKNOWN} constant would misleadingly suggest "a recognised category of unrecognised
|
* suggest "a recognised category of unrecognised frame", when the correct handling is simply "not
|
||||||
* frame", when the correct handling is simply "not this table, skip it").
|
* this table, skip it").
|
||||||
*
|
*
|
||||||
* Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
|
* <p>Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
|
||||||
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
|
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
|
||||||
* ({@code EX}-style defence, {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) — see
|
* (bounded by {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) — see {@link FrameValidator}
|
||||||
* {@link FrameValidator} for how these are applied and the specific RFC citation per rule.
|
* for how these are applied and the specific RFC citation per rule.
|
||||||
*/
|
*/
|
||||||
public enum FrameType {
|
public enum FrameType {
|
||||||
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
|
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
|
||||||
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
||||||
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
|
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
|
||||||
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
||||||
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
|
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
|
||||||
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
|
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
|
||||||
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
|
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
|
||||||
/** RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be 0. */
|
/**
|
||||||
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
|
* RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be
|
||||||
/** RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from a client is a protocol error. */
|
* 0.
|
||||||
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
*/
|
||||||
/** RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must be 0. */
|
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
|
||||||
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
|
/**
|
||||||
/** RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code). Stream id must be 0. */
|
* RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from
|
||||||
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
|
* a client is a protocol error.
|
||||||
/** RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 = connection window). */
|
*/
|
||||||
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
|
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
|
||||||
/** RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame. Stream id required. */
|
/**
|
||||||
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
|
* RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must
|
||||||
|
* be 0.
|
||||||
|
*/
|
||||||
|
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
|
||||||
|
/**
|
||||||
|
* RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code).
|
||||||
|
* Stream id must be 0.
|
||||||
|
*/
|
||||||
|
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
|
||||||
|
/**
|
||||||
|
* RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 =
|
||||||
|
* connection window).
|
||||||
|
*/
|
||||||
|
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
|
||||||
|
/**
|
||||||
|
* RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame.
|
||||||
|
* Stream id required.
|
||||||
|
*/
|
||||||
|
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
|
||||||
|
|
||||||
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
|
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
|
||||||
public enum StreamIdRule { REQUIRED, FORBIDDEN, EITHER }
|
public enum StreamIdRule {
|
||||||
|
REQUIRED,
|
||||||
|
FORBIDDEN,
|
||||||
|
EITHER
|
||||||
|
}
|
||||||
|
|
||||||
private static final FrameType[] BY_CODE = new FrameType[values().length];
|
private static final FrameType[] BY_CODE = new FrameType[values().length];
|
||||||
|
|
||||||
static {
|
static {
|
||||||
for (FrameType t : values()) {
|
for (FrameType t : values()) {
|
||||||
BY_CODE[t.code] = t;
|
BY_CODE[t.code] = t;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final int code;
|
private final int code;
|
||||||
private final int minLength;
|
private final int minLength;
|
||||||
private final int maxLength;
|
private final int maxLength;
|
||||||
private final StreamIdRule streamIdRule;
|
private final StreamIdRule streamIdRule;
|
||||||
|
|
||||||
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
|
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.minLength = minLength;
|
this.minLength = minLength;
|
||||||
this.maxLength = maxLength;
|
this.maxLength = maxLength;
|
||||||
this.streamIdRule = streamIdRule;
|
this.streamIdRule = streamIdRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int code() {
|
public int code() {
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int minLength() {
|
public int minLength() {
|
||||||
return minLength;
|
return minLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int maxLength() {
|
public int maxLength() {
|
||||||
return maxLength;
|
return maxLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamIdRule streamIdRule() {
|
public StreamIdRule streamIdRule() {
|
||||||
return streamIdRule;
|
return streamIdRule;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The highest type code this enum recognises — anything above must be ignored per RFC 9113 §4.1. */
|
/**
|
||||||
public static int maxKnown() {
|
* The highest type code this enum recognises — anything above must be ignored per RFC 9113 §4.1.
|
||||||
return CONTINUATION.code;
|
*/
|
||||||
}
|
public static int maxKnown() {
|
||||||
|
return CONTINUATION.code;
|
||||||
|
}
|
||||||
|
|
||||||
/** Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised (to-be-ignored) type. */
|
/**
|
||||||
public static FrameType fromCode(int code) {
|
* Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised
|
||||||
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
|
* (to-be-ignored) type.
|
||||||
}
|
*/
|
||||||
|
public static FrameType fromCode(int code) {
|
||||||
|
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package dev.relism.flash.http2.frame;
|
|||||||
import dev.relism.flash.http2.Http2Exception;
|
import dev.relism.flash.http2.Http2Exception;
|
||||||
import dev.relism.flash.http2.Http2Limits;
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
import dev.relism.flash.transport.BufferedByteSource;
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
|
||||||
import java.io.EOFException;
|
import java.io.EOFException;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -11,16 +10,18 @@ import java.util.Arrays;
|
|||||||
/**
|
/**
|
||||||
* Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler
|
* Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler
|
||||||
* than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the
|
* than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the
|
||||||
* 9-byte header), so nothing is ever scanned for — {@code Http2FrameReader} only ever needs to
|
* 9-byte header), so nothing is ever scanned for — {@code Http2FrameReader} only ever needs to know
|
||||||
* know "do I have N bytes yet", never "where does this end".
|
* "do I have N bytes yet", never "where does this end".
|
||||||
*
|
*
|
||||||
* <h3>Buffer discipline</h3>
|
* <h3>Buffer discipline</h3>
|
||||||
|
*
|
||||||
* One growable {@code byte[]} per connection, reused across every frame — the same
|
* One growable {@code byte[]} per connection, reused across every frame — the same
|
||||||
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared
|
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared length
|
||||||
* length is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
|
* is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
|
||||||
* length-check, not after an allocation already paid for it.
|
* length-check, not after an allocation already paid for it.
|
||||||
*
|
*
|
||||||
* <h3>Usage</h3>
|
* <h3>Usage</h3>
|
||||||
|
*
|
||||||
* <pre>{@code
|
* <pre>{@code
|
||||||
* FrameHeader header = reader.readFrame();
|
* FrameHeader header = reader.readFrame();
|
||||||
* if (header == null) { /* clean EOF between frames — connection closing *\/ }
|
* if (header == null) { /* clean EOF between frames — connection closing *\/ }
|
||||||
@@ -29,103 +30,126 @@ import java.util.Arrays;
|
|||||||
* }</pre>
|
* }</pre>
|
||||||
*
|
*
|
||||||
* <h3>Thread-safety</h3>
|
* <h3>Thread-safety</h3>
|
||||||
* Not thread-safe — exactly one virtual thread (the connection's demux loop) ever calls this,
|
*
|
||||||
* the same invariant every other per-connection reader in this codebase assumes.
|
* Not thread-safe — exactly one virtual thread (the connection's demux loop) ever calls this, the
|
||||||
|
* same invariant every other per-connection reader in this codebase assumes.
|
||||||
*/
|
*/
|
||||||
public final class Http2FrameReader {
|
public final class Http2FrameReader {
|
||||||
private static final int FRAME_HEADER_SIZE = 9;
|
private static final int FRAME_HEADER_SIZE = 9;
|
||||||
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
|
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
|
||||||
|
|
||||||
private final BufferedByteSource in;
|
private final BufferedByteSource in;
|
||||||
private final FrameHeader header = new FrameHeader();
|
private final FrameHeader header = new FrameHeader();
|
||||||
private byte[] buffer;
|
private byte[] buffer;
|
||||||
private int base; // offset of the first unconsumed byte
|
private int base; // offset of the first unconsumed byte
|
||||||
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
|
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
|
||||||
|
private long frameDeadlineNanos;
|
||||||
|
|
||||||
public Http2FrameReader(BufferedByteSource in) {
|
public Http2FrameReader(BufferedByteSource in) {
|
||||||
this(in, INITIAL_BUFFER_SIZE);
|
this(in, INITIAL_BUFFER_SIZE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
|
||||||
|
this.in = in;
|
||||||
|
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the next frame's header and payload, bounded by {@link
|
||||||
|
* Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader} flyweight
|
||||||
|
* positioned over it — or {@code null} on a clean EOF between frames (the peer closed the
|
||||||
|
* connection while nothing was in flight; not an error).
|
||||||
|
*
|
||||||
|
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame (or
|
||||||
|
* deciding to discard it) and before calling this method again.
|
||||||
|
*
|
||||||
|
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
|
||||||
|
* @throws EOFException if the connection closes after a frame has already started arriving
|
||||||
|
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
|
||||||
|
*/
|
||||||
|
public FrameHeader readFrame() throws IOException {
|
||||||
|
return readFrame(Http2Limits.FRAME_READ_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reads one frame using a caller-supplied upper bound for this frame's absolute deadline. */
|
||||||
|
public FrameHeader readFrame(long timeoutMs) throws IOException {
|
||||||
|
if (timeoutMs <= 0) throw new IllegalArgumentException("timeoutMs must be positive");
|
||||||
|
long now = System.nanoTime();
|
||||||
|
if (frameDeadlineNanos == 0) {
|
||||||
|
frameDeadlineNanos = now + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L;
|
||||||
}
|
}
|
||||||
|
in.setDeadline(Math.min(frameDeadlineNanos, now + timeoutMs * 1_000_000L));
|
||||||
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
|
try {
|
||||||
this.in = in;
|
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
|
||||||
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
|
frameDeadlineNanos = 0;
|
||||||
|
return null; // clean EOF: nothing buffered yet, peer closed between frames
|
||||||
|
}
|
||||||
|
int declaredLength = decodeLength(buffer, base);
|
||||||
|
// never causes an oversized allocation, only a rejection.
|
||||||
|
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
|
||||||
|
throw Http2Exception.FRAME_SIZE_ERROR;
|
||||||
|
}
|
||||||
|
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
|
||||||
|
header.reset(buffer, base);
|
||||||
|
return header;
|
||||||
|
} catch (java.net.SocketTimeoutException timeout) {
|
||||||
|
if (totalRead == 0) frameDeadlineNanos = 0;
|
||||||
|
throw timeout;
|
||||||
|
} finally {
|
||||||
|
in.clearDeadline();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
|
||||||
* Reads the next frame's header and payload, bounded by
|
public void consumeFrame() {
|
||||||
* {@link Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader}
|
int consumed = FRAME_HEADER_SIZE + header.length();
|
||||||
* flyweight positioned over it — or {@code null} on a clean EOF between frames (the peer
|
base += consumed;
|
||||||
* closed the connection while nothing was in flight; not an error).
|
totalRead -= consumed;
|
||||||
*
|
if (totalRead == 0) {
|
||||||
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame
|
base = 0; // nothing buffered — reset to the front rather than drifting forever
|
||||||
* (or deciding to discard it) and before calling this method again.
|
}
|
||||||
*
|
frameDeadlineNanos = 0;
|
||||||
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
|
}
|
||||||
* @throws EOFException if the connection closes after a frame has already started arriving
|
|
||||||
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
|
/** Whether a partially received frame exhausted its non-renewable absolute deadline. */
|
||||||
*/
|
public boolean frameDeadlineExpired() {
|
||||||
public FrameHeader readFrame() throws IOException {
|
return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos;
|
||||||
in.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
|
}
|
||||||
try {
|
|
||||||
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
|
private static int decodeLength(byte[] buf, int off) {
|
||||||
return null; // clean EOF: nothing buffered yet, peer closed between frames
|
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
|
||||||
}
|
return (b0 << 16) | (b1 << 8) | b2;
|
||||||
int declaredLength = decodeLength(buffer, base);
|
}
|
||||||
// never causes an oversized allocation, only a rejection.
|
|
||||||
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
|
/**
|
||||||
throw Http2Exception.FRAME_SIZE_ERROR;
|
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
|
||||||
}
|
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with nothing at
|
||||||
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
|
* all buffered yet (the between-frames case); an EOF after any bytes of the current frame have
|
||||||
header.reset(buffer, base);
|
* already arrived is a genuine truncation and throws.
|
||||||
return header;
|
*/
|
||||||
} finally {
|
private boolean ensureAvailable(int need) throws IOException {
|
||||||
in.clearDeadline();
|
while (totalRead < need) {
|
||||||
|
if (base + need > buffer.length) {
|
||||||
|
if (base > 0) {
|
||||||
|
// Compact: slide unconsumed bytes to the front — frees room without growing.
|
||||||
|
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||||
|
base = 0;
|
||||||
|
} else {
|
||||||
|
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
|
||||||
|
// the payload-sized call — grow exactly enough, never unbounded.
|
||||||
|
int grown = buffer.length;
|
||||||
|
while (grown < need) grown *= 2;
|
||||||
|
buffer = Arrays.copyOf(buffer, grown);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
|
||||||
|
if (n < 0) {
|
||||||
|
if (totalRead == 0) return false;
|
||||||
|
throw new EOFException(
|
||||||
|
"connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
|
||||||
|
}
|
||||||
|
totalRead += n;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
|
}
|
||||||
public void consumeFrame() {
|
|
||||||
int consumed = FRAME_HEADER_SIZE + header.length();
|
|
||||||
base += consumed;
|
|
||||||
totalRead -= consumed;
|
|
||||||
if (totalRead == 0) {
|
|
||||||
base = 0; // nothing buffered — reset to the front rather than drifting forever
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static int decodeLength(byte[] buf, int off) {
|
|
||||||
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
|
|
||||||
return (b0 << 16) | (b1 << 8) | b2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
|
|
||||||
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with
|
|
||||||
* nothing at all buffered yet (the between-frames case); an EOF after any bytes of the
|
|
||||||
* current frame have already arrived is a genuine truncation and throws.
|
|
||||||
*/
|
|
||||||
private boolean ensureAvailable(int need) throws IOException {
|
|
||||||
while (totalRead < need) {
|
|
||||||
if (base + need > buffer.length) {
|
|
||||||
if (base > 0) {
|
|
||||||
// Compact: slide unconsumed bytes to the front — frees room without growing.
|
|
||||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
|
||||||
base = 0;
|
|
||||||
} else {
|
|
||||||
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
|
|
||||||
// the payload-sized call — grow exactly enough, never unbounded.
|
|
||||||
int grown = buffer.length;
|
|
||||||
while (grown < need) grown *= 2;
|
|
||||||
buffer = Arrays.copyOf(buffer, grown);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
|
|
||||||
if (n < 0) {
|
|
||||||
if (totalRead == 0) return false;
|
|
||||||
throw new EOFException("connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
|
|
||||||
}
|
|
||||||
totalRead += n;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dev.relism.flash.http2.frame;
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
import dev.relism.flash.http2.Http2Limits;
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InterruptedIOException;
|
import java.io.InterruptedIOException;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
@@ -16,34 +15,36 @@ import java.util.concurrent.locks.ReentrantLock;
|
|||||||
*
|
*
|
||||||
* <h2>The design, three layers</h2>
|
* <h2>The design, three layers</h2>
|
||||||
*
|
*
|
||||||
* <p><b>Layer 1 — serialize outside the lock.</b> By the time {@link #write} is called, the
|
* <p><b>Layer 1 — serialize outside the lock.</b> By the time {@link #write} is called, the caller
|
||||||
* caller has already built its complete frame into a buffer it owns (see {@link WriteIntent}).
|
* has already built its complete frame into a buffer it owns (see {@link WriteIntent}). This writer
|
||||||
* This writer never serializes anything; it only ever issues one bulk
|
* never serializes anything; it only ever issues one bulk {@code sink.write(buffer, offset,
|
||||||
* {@code sink.write(buffer, offset, length)} call while holding the lock — never many small
|
* length)} call while holding the lock — never many small writes, which would turn "hold the lock"
|
||||||
* writes, which would turn "hold the lock" into "hold the lock across a serialization pass."
|
* into "hold the lock across a serialization pass."
|
||||||
*
|
*
|
||||||
* <p><b>Layer 2 — {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
|
* <p><b>Layer 2 — {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
|
||||||
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
|
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
|
||||||
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized}
|
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized} {@code
|
||||||
* {@code ReentrantLock} is also load-bearing here for a second reason {@code synchronized}
|
* ReentrantLock} is also load-bearing here for a second reason {@code synchronized} cannot offer:
|
||||||
* cannot offer: {@link ReentrantLock#tryLock()}.
|
* {@link ReentrantLock#tryLock()}.
|
||||||
*
|
*
|
||||||
* <p><b>Layer 3 — {@code tryLock()} fast path, intrusive MPSC fallback.</b> The overwhelmingly
|
* <p><b>Layer 3 — {@code tryLock()} fast path, intrusive MPSC fallback.</b> The overwhelmingly
|
||||||
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to
|
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write
|
||||||
* write at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the
|
* at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the calling
|
||||||
* calling thread writes inline and releases — no handoff, no queue touched, no allocation, no
|
* thread writes inline and releases — no handoff, no queue touched, no allocation, no context
|
||||||
* context switch. Only when {@code tryLock()} fails (genuine contention) does the intent get
|
* switch. Only when {@code tryLock()} fails (genuine contention) does the intent get published
|
||||||
* published through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation — the
|
* through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation — the intent itself is
|
||||||
* intent itself is the queue node) for the current lock holder to drain.
|
* the queue node) for the current lock holder to drain.
|
||||||
*
|
*
|
||||||
* <h3>Lost-wakeup avoidance</h3>
|
* <h3>Lost-wakeup avoidance</h3>
|
||||||
|
*
|
||||||
* The classic hazard: a producer offers its intent to the queue at the exact moment the current
|
* The classic hazard: a producer offers its intent to the queue at the exact moment the current
|
||||||
* holder has just found the queue empty and is about to unlock — the item would be stranded
|
* holder has just found the queue empty and is about to unlock — the item would be stranded with
|
||||||
* with nobody left to drain it. This is closed by two cooperating checks, and the correctness
|
* nobody left to drain it. This is closed by two cooperating checks, and the correctness argument
|
||||||
* argument for why together they are sufficient is a happens-before chain through the queue's
|
* for why together they are sufficient is a happens-before chain through the queue's {@code
|
||||||
* {@code AtomicReference} and the lock's own acquire/release ordering (recorded in full in
|
* AtomicReference} and the lock's own acquire/release ordering (recorded in full in {@code
|
||||||
* {@code WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to
|
* WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to re-derive,
|
||||||
* re-derive, not just trust):
|
* not just trust):
|
||||||
|
*
|
||||||
* <pre>
|
* <pre>
|
||||||
* write(intent):
|
* write(intent):
|
||||||
* if tryLock() succeeds: // 1 CAS, the fast path
|
* if tryLock() succeeds: // 1 CAS, the fast path
|
||||||
@@ -61,193 +62,222 @@ import java.util.concurrent.locks.ReentrantLock;
|
|||||||
* poll-and-write until empty
|
* poll-and-write until empty
|
||||||
* unlock()
|
* unlock()
|
||||||
* </pre>
|
* </pre>
|
||||||
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent
|
*
|
||||||
* is a single {@code sink.write} call issued while holding the lock, and the lock is not
|
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a
|
||||||
* released between a {@code WriteIntent}'s bytes.
|
* single {@code sink.write} call issued while holding the lock, and the lock is not released
|
||||||
|
* between a {@code WriteIntent}'s bytes.
|
||||||
*
|
*
|
||||||
* <h3>Write timeout</h3>
|
* <h3>Write timeout</h3>
|
||||||
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not
|
*
|
||||||
* reading — whoever holds the lock is blocked in the syscall, holding up every other stream on
|
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading —
|
||||||
* the connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
|
* whoever holds the lock is blocked in the syscall, holding up every other stream on the
|
||||||
|
* connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
|
||||||
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
|
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
|
||||||
* deadline — {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
|
* deadline — {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
|
||||||
* connection setup), so arming/disarming the deadline for each individual write is two
|
* connection setup), so arming/disarming the deadline for each individual write is two {@code
|
||||||
* {@code volatile} field writes, not an allocation.
|
* volatile} field writes, not an allocation.
|
||||||
*/
|
*/
|
||||||
public final class Http2FrameWriter {
|
public final class Http2FrameWriter {
|
||||||
|
|
||||||
/** What a frame's serialized bytes are ultimately written to. Kept minimal and separate
|
/**
|
||||||
* from {@code java.io.OutputStream} so this class is testable without a real socket. */
|
* What a frame's serialized bytes are ultimately written to. Kept minimal and separate from
|
||||||
public interface Sink {
|
* {@code java.io.OutputStream} so this class is testable without a real socket.
|
||||||
void write(byte[] buf, int off, int len) throws IOException;
|
*/
|
||||||
|
public interface Sink {
|
||||||
|
void write(byte[] buf, int off, int len) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final Sink sink;
|
||||||
|
private final long writeTimeoutMs;
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private final IntrusiveMpscQueue priorityQueue = new IntrusiveMpscQueue();
|
||||||
|
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
|
||||||
|
|
||||||
|
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
|
||||||
|
// single volatile write to arm, one to disarm — no timestamp is recorded here (see the
|
||||||
|
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
|
||||||
|
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
|
||||||
|
private volatile Thread writingThread;
|
||||||
|
|
||||||
|
public Http2FrameWriter(Sink sink) {
|
||||||
|
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
|
||||||
|
this.sink = sink;
|
||||||
|
this.writeTimeoutMs = writeTimeoutMs;
|
||||||
|
WriteTimeoutReaper.register(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or safely
|
||||||
|
* queued behind another writer. Never blocks on another stream's I/O while holding the lock for
|
||||||
|
* longer than that stream's own single bulk write.
|
||||||
|
*
|
||||||
|
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
|
||||||
|
* Writing {@code intent} immediately, before anything already queued, is only safe when nothing
|
||||||
|
* is already queued. Without the {@code hasWork()} check, this sequence is possible — and
|
||||||
|
* violates same-producer ordering, which the stress test asserts: a producer's {@code write(a)}
|
||||||
|
* then {@code write(b)} contends and both get queued (fire-and-forget); the current holder is
|
||||||
|
* about to drain them but has not yet; that producer's very next call, {@code write(c)}, finds
|
||||||
|
* the lock free (the holder released it between the producer's calls) and would otherwise write
|
||||||
|
* {@code c} directly — landing on the wire before {@code a} and {@code b}, which are still
|
||||||
|
* sitting in the queue. Checking {@code hasWork()} first means "bypass the queue" only happens
|
||||||
|
* when the queue is observed genuinely empty, i.e. everything previously offered — by any
|
||||||
|
* producer — has already been written; see {@code WRITER.md} for the full argument.
|
||||||
|
*/
|
||||||
|
public void write(WriteIntent intent) throws IOException {
|
||||||
|
if (!priorityQueue.hasWork() && !queue.hasWork() && lock.tryLock()) {
|
||||||
|
drive(intent);
|
||||||
|
} else {
|
||||||
|
queue.offer(intent);
|
||||||
|
if (lock.tryLock()) {
|
||||||
|
drive(null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final Sink sink;
|
/**
|
||||||
private final long writeTimeoutMs;
|
* Writes a connection-control frame ahead of queued stream data. An already executing socket
|
||||||
private final ReentrantLock lock = new ReentrantLock();
|
* write is never interrupted, but once it completes the priority queue is drained before the
|
||||||
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
|
* ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and
|
||||||
|
* RST_STREAM.
|
||||||
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
|
*/
|
||||||
// single volatile write to arm, one to disarm — no timestamp is recorded here (see the
|
public void writePriority(WriteIntent intent) throws IOException {
|
||||||
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
|
priorityQueue.offer(intent);
|
||||||
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
|
if (lock.tryLock()) {
|
||||||
private volatile Thread writingThread;
|
drive(null);
|
||||||
|
|
||||||
public Http2FrameWriter(Sink sink) {
|
|
||||||
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
|
/**
|
||||||
this.sink = sink;
|
* Flushes any queued intents. Called by the demux loop when it has nothing left to read — a no-op
|
||||||
this.writeTimeoutMs = writeTimeoutMs;
|
* on the (overwhelmingly common) fast path where nothing is queued.
|
||||||
WriteTimeoutReaper.register(this);
|
*/
|
||||||
|
public void drain() throws IOException {
|
||||||
|
if (!priorityQueue.hasWork() && !queue.hasWork()) return;
|
||||||
|
if (lock.tryLock()) {
|
||||||
|
drive(null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or
|
* Deregisters this writer from the write-timeout reaper. Call once, when the connection closes.
|
||||||
* safely queued behind another writer. Never blocks on another stream's I/O while holding
|
*/
|
||||||
* the lock for longer than that stream's own single bulk write.
|
public void close() {
|
||||||
*
|
WriteTimeoutReaper.unregister(this);
|
||||||
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
|
}
|
||||||
* Writing {@code intent} immediately, before anything already queued, is only
|
|
||||||
* safe when nothing is already queued. Without the {@code hasWork()} check, this sequence
|
private void drive(WriteIntent firstIntentOrNull) throws IOException {
|
||||||
* is possible — and violates same-producer ordering, which the stress test asserts: a
|
try {
|
||||||
* producer's {@code write(a)} then {@code write(b)} contends and both get queued
|
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
|
||||||
* (fire-and-forget); the current holder is about to drain them but has not yet; that
|
drainQueues();
|
||||||
* producer's very next call, {@code write(c)}, finds the lock free (the holder released it
|
} finally {
|
||||||
* between the producer's calls) and would otherwise write {@code c} directly — landing on
|
lock.unlock();
|
||||||
* the wire before {@code a} and {@code b}, which are still sitting in the queue. Checking
|
|
||||||
* {@code hasWork()} first means "bypass the queue" only happens when the queue is observed
|
|
||||||
* genuinely empty, i.e. everything previously offered — by any producer — has already been
|
|
||||||
* written; see {@code WRITER.md} for the full argument.
|
|
||||||
*/
|
|
||||||
public void write(WriteIntent intent) throws IOException {
|
|
||||||
if (!queue.hasWork() && lock.tryLock()) {
|
|
||||||
drive(intent);
|
|
||||||
} else {
|
|
||||||
queue.offer(intent);
|
|
||||||
if (lock.tryLock()) {
|
|
||||||
drive(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
|
||||||
/** Flushes any queued intents. Called by the demux loop when it has nothing left to read —
|
// the same way — see the class Javadoc for the correctness argument.
|
||||||
* a no-op on the (overwhelmingly common) fast path where nothing is queued. */
|
while (priorityQueue.hasWork() || queue.hasWork()) {
|
||||||
public void drain() throws IOException {
|
if (!lock.tryLock()) break;
|
||||||
if (!queue.hasWork()) return;
|
try {
|
||||||
if (lock.tryLock()) {
|
drainQueues();
|
||||||
drive(null);
|
} finally {
|
||||||
}
|
lock.unlock();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Deregisters this writer from the write-timeout reaper. Call once, when the connection
|
private void drainQueues() throws IOException {
|
||||||
* closes. */
|
WriteIntent next;
|
||||||
public void close() {
|
while (true) {
|
||||||
WriteTimeoutReaper.unregister(this);
|
while ((next = priorityQueue.poll()) != null) {
|
||||||
|
writeDirect(next);
|
||||||
|
}
|
||||||
|
next = queue.poll();
|
||||||
|
if (next == null) return;
|
||||||
|
writeDirect(next);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void drive(WriteIntent firstIntentOrNull) throws IOException {
|
private void writeDirect(WriteIntent intent) throws IOException {
|
||||||
try {
|
writingThread = Thread.currentThread();
|
||||||
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
|
try {
|
||||||
WriteIntent next;
|
sink.write(intent.buffer(), intent.offset(), intent.length());
|
||||||
while ((next = queue.poll()) != null) {
|
} catch (IOException e) {
|
||||||
writeDirect(next);
|
if (Thread.interrupted()) {
|
||||||
}
|
InterruptedIOException timeout =
|
||||||
} finally {
|
new InterruptedIOException("HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
|
||||||
lock.unlock();
|
timeout.initCause(e);
|
||||||
}
|
throw timeout;
|
||||||
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
|
}
|
||||||
// the same way — see the class Javadoc for the correctness argument.
|
throw e;
|
||||||
while (queue.hasWork()) {
|
} finally {
|
||||||
if (!lock.tryLock()) break;
|
writingThread = null;
|
||||||
try {
|
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
|
||||||
WriteIntent next;
|
intent.completed();
|
||||||
while ((next = queue.poll()) != null) {
|
|
||||||
writeDirect(next);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
lock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void writeDirect(WriteIntent intent) throws IOException {
|
/**
|
||||||
writingThread = Thread.currentThread();
|
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a blocking
|
||||||
try {
|
* write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the whole process
|
||||||
sink.write(intent.buffer(), intent.offset(), intent.length());
|
* (like {@code DateHeader}'s refresher), not one per connection — registration per-write one.
|
||||||
} catch (IOException e) {
|
*
|
||||||
if (Thread.interrupted()) {
|
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()} {@code
|
||||||
InterruptedIOException timeout = new InterruptedIOException(
|
* nanoTime()} call (plus the extra volatile field it required) costing enough to miss the N=1
|
||||||
"HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
|
* gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the reaper counts
|
||||||
timeout.initCause(e);
|
* <em>consecutive scans</em> a given writer has been observed still blocked ({@link
|
||||||
throw timeout;
|
* #writingThread} non-null); a writer blocked for more than {@code WRITE_TIMEOUT_MS /
|
||||||
}
|
* SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades a little precision (up to one
|
||||||
throw e;
|
* scan interval of slop — already inherent to any background-reaper design) for removing all
|
||||||
} finally {
|
* per-write timing cost.
|
||||||
writingThread = null;
|
*/
|
||||||
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
|
static final class WriteTimeoutReaper {
|
||||||
}
|
private static final long SCAN_INTERVAL_MS = 50;
|
||||||
}
|
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
|
||||||
|
// Touched only by the single reaper thread -- no synchronization needed.
|
||||||
|
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS =
|
||||||
|
new java.util.IdentityHashMap<>();
|
||||||
|
|
||||||
/**
|
static {
|
||||||
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a
|
Thread reaper =
|
||||||
* blocking write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the
|
new Thread(
|
||||||
* whole process (like {@code DateHeader}'s refresher), not one per connection — registration
|
() -> {
|
||||||
* per-write one.
|
|
||||||
*
|
|
||||||
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()}
|
|
||||||
* {@code nanoTime()} call (plus the extra volatile field it required) costing enough to miss
|
|
||||||
* the N=1 gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the
|
|
||||||
* reaper counts <em>consecutive scans</em> a given writer has been observed still blocked
|
|
||||||
* ({@link #writingThread} non-null); a writer blocked for more than
|
|
||||||
* {@code WRITE_TIMEOUT_MS / SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades
|
|
||||||
* a little precision (up to one scan interval of slop — already inherent to any
|
|
||||||
* background-reaper design) for removing all per-write timing cost.
|
|
||||||
*/
|
|
||||||
static final class WriteTimeoutReaper {
|
|
||||||
private static final long SCAN_INTERVAL_MS = 50;
|
|
||||||
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
|
|
||||||
// Touched only by the single reaper thread -- no synchronization needed.
|
|
||||||
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS = new java.util.IdentityHashMap<>();
|
|
||||||
|
|
||||||
static {
|
|
||||||
Thread reaper = new Thread(() -> {
|
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
Thread.sleep(SCAN_INTERVAL_MS);
|
Thread.sleep(SCAN_INTERVAL_MS);
|
||||||
} catch (InterruptedException e) {
|
} catch (InterruptedException e) {
|
||||||
Thread.currentThread().interrupt();
|
Thread.currentThread().interrupt();
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
for (Http2FrameWriter writer : ACTIVE) {
|
||||||
|
Thread t = writer.writingThread;
|
||||||
|
if (t == null) {
|
||||||
|
BLOCKED_SCAN_COUNTS.remove(writer);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
for (Http2FrameWriter writer : ACTIVE) {
|
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
|
||||||
Thread t = writer.writingThread;
|
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
|
||||||
if (t == null) {
|
if (scans >= thresholdScans) {
|
||||||
BLOCKED_SCAN_COUNTS.remove(writer);
|
t.interrupt();
|
||||||
continue;
|
BLOCKED_SCAN_COUNTS.remove(writer);
|
||||||
}
|
|
||||||
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
|
|
||||||
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
|
|
||||||
if (scans >= thresholdScans) {
|
|
||||||
t.interrupt();
|
|
||||||
BLOCKED_SCAN_COUNTS.remove(writer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, "flash-http2-write-timeout-reaper");
|
},
|
||||||
reaper.setDaemon(true);
|
"flash-http2-write-timeout-reaper");
|
||||||
reaper.start();
|
reaper.setDaemon(true);
|
||||||
}
|
reaper.start();
|
||||||
|
|
||||||
private WriteTimeoutReaper() {
|
|
||||||
}
|
|
||||||
|
|
||||||
static void register(Http2FrameWriter writer) {
|
|
||||||
ACTIVE.add(writer);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void unregister(Http2FrameWriter writer) {
|
|
||||||
ACTIVE.remove(writer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private WriteTimeoutReaper() {}
|
||||||
|
|
||||||
|
static void register(Http2FrameWriter writer) {
|
||||||
|
ACTIVE.add(writer);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void unregister(Http2FrameWriter writer) {
|
||||||
|
ACTIVE.remove(writer);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,49 @@
|
|||||||
package dev.relism.flash.http2.frame;
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and,
|
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and, eventually,
|
||||||
* eventually, connection-level singletons — the precompiled SETTINGS ACK, PING ACK, GOAWAY,
|
* connection-level singletons — the precompiled SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE
|
||||||
* WINDOW_UPDATE frames) implements to write through {@link Http2FrameWriter}.
|
* frames) implements to write through {@link Http2FrameWriter}.
|
||||||
*
|
*
|
||||||
* <h3>Layer 1 — serialize outside the lock</h3>
|
* <h3>Layer 1 — serialize outside the lock</h3>
|
||||||
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built
|
*
|
||||||
* its complete output (frame header + HPACK block + payload, or whatever the frame needs) into
|
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built its
|
||||||
* a buffer it owns — a per-stream scratch buffer, reused across writes, never allocated per
|
* complete output (frame header + HPACK block + payload, or whatever the frame needs) into a buffer
|
||||||
* call. {@link #buffer()}/{@link #offset()}/{@link #length()} just describe where that
|
* it owns — a per-stream scratch buffer, reused across writes, never allocated per call. {@link
|
||||||
* already-finished output lives. {@code Http2FrameWriter} never serializes anything itself; it
|
* #buffer()}/{@link #offset()}/{@link #length()} just describe where that already-finished output
|
||||||
* only ever issues one bulk {@code write(buffer, offset, length)} while holding the connection's
|
* lives. {@code Http2FrameWriter} never serializes anything itself; it only ever issues one bulk
|
||||||
* write lock — see {@code WRITER.md} for why that distinction is the entire point of this
|
* {@code write(buffer, offset, length)} while holding the connection's write lock — see {@code
|
||||||
* design (the lock must never be held across serialization work, only across the syscall).
|
* WRITER.md} for why that distinction is the entire point of this design (the lock must never be
|
||||||
|
* held across serialization work, only across the syscall).
|
||||||
*
|
*
|
||||||
* <h3>Intrusive queue linkage</h3>
|
* <h3>Intrusive queue linkage</h3>
|
||||||
|
*
|
||||||
* {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract — they
|
* {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract — they
|
||||||
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero
|
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero extra
|
||||||
* extra allocation when the writer is contended. Implementations provide simple field storage;
|
* allocation when the writer is contended. Implementations provide simple field storage; nothing
|
||||||
* nothing about the field is meaningful outside {@link IntrusiveMpscQueue}.
|
* about the field is meaningful outside {@link IntrusiveMpscQueue}.
|
||||||
*/
|
*/
|
||||||
public interface WriteIntent {
|
public interface WriteIntent {
|
||||||
|
|
||||||
/** The buffer holding this intent's already-serialized bytes. */
|
/** The buffer holding this intent's already-serialized bytes. */
|
||||||
byte[] buffer();
|
byte[] buffer();
|
||||||
|
|
||||||
/** Offset of the first byte to write, within {@link #buffer()}. */
|
/** Offset of the first byte to write, within {@link #buffer()}. */
|
||||||
int offset();
|
int offset();
|
||||||
|
|
||||||
/** Number of bytes to write, starting at {@link #offset()}. */
|
/** Number of bytes to write, starting at {@link #offset()}. */
|
||||||
int length();
|
int length();
|
||||||
|
|
||||||
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
|
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
|
||||||
WriteIntent mpscNext();
|
WriteIntent mpscNext();
|
||||||
|
|
||||||
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
|
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
|
||||||
void setMpscNext(WriteIntent next);
|
void setMpscNext(WriteIntent next);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called exactly once after this intent leaves the writer, whether the socket write succeeded or
|
||||||
|
* failed. Pooled control-frame intents use this hook to return their slot to the owning
|
||||||
|
* connection without allocating a completion object.
|
||||||
|
*/
|
||||||
|
default void completed() {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
package dev.relism.flash.tls;
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
import javax.net.ssl.KeyManager;
|
|
||||||
import javax.net.ssl.KeyManagerFactory;
|
|
||||||
import javax.net.ssl.SSLContext;
|
|
||||||
import javax.net.ssl.SSLParameters;
|
|
||||||
import javax.net.ssl.SSLServerSocket;
|
|
||||||
import javax.net.ssl.SSLServerSocketFactory;
|
|
||||||
import javax.net.ssl.X509ExtendedKeyManager;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
@@ -17,444 +9,476 @@ import java.security.KeyStore;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import javax.net.ssl.KeyManager;
|
||||||
|
import javax.net.ssl.KeyManagerFactory;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLServerSocket;
|
||||||
|
import javax.net.ssl.SSLServerSocketFactory;
|
||||||
|
import javax.net.ssl.X509ExtendedKeyManager;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
|
* Declarative TLS configuration for a {@link
|
||||||
|
* dev.relism.flash.extension.FlashConfiguration.Listener}.
|
||||||
*
|
*
|
||||||
* <h3>Two ways in</h3>
|
* <h3>Two ways in</h3>
|
||||||
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>{@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS
|
* <li>{@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS
|
||||||
* keystore. A keystore holding more than one certificate entry gets SNI-based selection
|
* keystore. A keystore holding more than one certificate entry gets SNI-based selection for
|
||||||
* for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins
|
* free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins {@code
|
||||||
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
|
* TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the JDK's own
|
||||||
* JDK's own curated default, which each JDK security release keeps current — Flash does
|
* curated default, which each JDK security release keeps current — Flash does not maintain
|
||||||
* not maintain its own suite allow-list.</li>
|
* its own suite allow-list.
|
||||||
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used
|
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used exactly
|
||||||
* exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
|
* as built: Flash never calls {@code setSSLParameters} on this path unless you explicitly
|
||||||
* explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
|
* call {@link #applicationProtocols} or {@link #clientAuth} yourself, so anything else you
|
||||||
* anything else you configured on it is 100% authoritative.</li>
|
* configured on it is 100% authoritative.
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
|
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on either
|
||||||
* either path — they are explicit instructions through this API, not Flash-chosen defaults, so
|
* path — they are explicit instructions through this API, not Flash-chosen defaults, so each is
|
||||||
* each is only ever applied when called. Neither has a value by default, on either path.
|
* only ever applied when called. Neither has a value by default, on either path.
|
||||||
*
|
*
|
||||||
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
|
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
|
||||||
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via
|
*
|
||||||
* {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like
|
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via {@link
|
||||||
* {@link ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello}
|
* SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like {@link
|
||||||
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
|
* ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello}
|
||||||
* — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve
|
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production —
|
||||||
* can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()}
|
* so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve can
|
||||||
* (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside
|
* read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()} (or
|
||||||
* {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
|
* {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside {@code
|
||||||
|
* chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
|
||||||
*/
|
*/
|
||||||
public final class TlsConfig {
|
public final class TlsConfig {
|
||||||
|
|
||||||
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
|
private static final String[] SECURE_PROTOCOLS = {"TLSv1.3", "TLSv1.2"};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113
|
* cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113 carries
|
||||||
* carries forward verbatim), and that it MUST support at least
|
* forward verbatim), and that it MUST support at least {@code
|
||||||
* {@code TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher
|
* TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher suites (the
|
||||||
* suites (the {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since
|
* {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since TLS 1.3 removed
|
||||||
* TLS 1.3 removed static/non-ephemeral key exchange and CBC-mode ciphers entirely — the
|
* static/non-ephemeral key exchange and CBC-mode ciphers entirely — the exact property this
|
||||||
* exact property this blocklist exists to enforce for TLS 1.2.
|
* blocklist exists to enforce for TLS 1.2.
|
||||||
*
|
*
|
||||||
* <p>Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
|
* <p>Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
|
||||||
* (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
|
* (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
|
||||||
* against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
|
* against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
|
||||||
* static table be transcribed from the RFC directly and verified: a transcription error in a
|
* static table be transcribed from the RFC directly and verified: a transcription error in a
|
||||||
* ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
|
* ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
|
||||||
* permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
|
* permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
|
||||||
*/
|
*/
|
||||||
private static final Set<String> TLS12_H2_BLOCKED_CIPHERS = Set.of(
|
private static final Set<String> TLS12_H2_BLOCKED_CIPHERS =
|
||||||
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
Set.of(
|
||||||
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA",
|
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256",
|
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA",
|
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256",
|
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_DHE_DSS_WITH_DES_CBC_SHA",
|
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_DHE_DSS_WITH_SEED_CBC_SHA",
|
"TLS_DHE_DSS_WITH_DES_CBC_SHA",
|
||||||
"TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DHE_DSS_WITH_SEED_CBC_SHA",
|
||||||
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA",
|
"TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA256",
|
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA",
|
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA384",
|
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_PSK_WITH_NULL_SHA",
|
"TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_DHE_PSK_WITH_NULL_SHA256",
|
"TLS_DHE_PSK_WITH_NULL_SHA",
|
||||||
"TLS_DHE_PSK_WITH_NULL_SHA384",
|
"TLS_DHE_PSK_WITH_NULL_SHA256",
|
||||||
"TLS_DHE_PSK_WITH_RC4_128_SHA",
|
"TLS_DHE_PSK_WITH_NULL_SHA384",
|
||||||
"TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
"TLS_DHE_PSK_WITH_RC4_128_SHA",
|
||||||
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256",
|
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256",
|
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_DHE_RSA_WITH_DES_CBC_SHA",
|
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_DHE_RSA_WITH_SEED_CBC_SHA",
|
"TLS_DHE_RSA_WITH_DES_CBC_SHA",
|
||||||
"TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
"TLS_DHE_RSA_WITH_SEED_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA",
|
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256",
|
"TLS_DH_DSS_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256",
|
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA",
|
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256",
|
"TLS_DH_DSS_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384",
|
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256",
|
"TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384",
|
"TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_DH_DSS_WITH_DES_CBC_SHA",
|
"TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_DH_DSS_WITH_SEED_CBC_SHA",
|
"TLS_DH_DSS_WITH_DES_CBC_SHA",
|
||||||
"TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
"TLS_DH_DSS_WITH_SEED_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256",
|
"TLS_DH_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256",
|
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256",
|
"TLS_DH_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384",
|
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256",
|
"TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384",
|
"TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_DH_RSA_WITH_DES_CBC_SHA",
|
"TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_DH_RSA_WITH_SEED_CBC_SHA",
|
"TLS_DH_RSA_WITH_DES_CBC_SHA",
|
||||||
"TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA",
|
"TLS_DH_RSA_WITH_SEED_CBC_SHA",
|
||||||
"TLS_DH_anon_EXPORT_WITH_RC4_40_MD5",
|
"TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DH_anon_EXPORT_WITH_RC4_40_MD5",
|
||||||
"TLS_DH_anon_WITH_AES_128_CBC_SHA",
|
"TLS_DH_anon_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_AES_128_CBC_SHA256",
|
"TLS_DH_anon_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_AES_128_GCM_SHA256",
|
"TLS_DH_anon_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_DH_anon_WITH_AES_256_CBC_SHA",
|
"TLS_DH_anon_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_DH_anon_WITH_AES_256_CBC_SHA256",
|
"TLS_DH_anon_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_AES_256_GCM_SHA384",
|
"TLS_DH_anon_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_DH_anon_WITH_ARIA_128_CBC_SHA256",
|
"TLS_DH_anon_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_DH_anon_WITH_ARIA_128_GCM_SHA256",
|
"TLS_DH_anon_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_DH_anon_WITH_ARIA_256_CBC_SHA384",
|
"TLS_DH_anon_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_DH_anon_WITH_ARIA_256_GCM_SHA384",
|
"TLS_DH_anon_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_DH_anon_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_DH_anon_WITH_DES_CBC_SHA",
|
"TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_DH_anon_WITH_RC4_128_MD5",
|
"TLS_DH_anon_WITH_DES_CBC_SHA",
|
||||||
"TLS_DH_anon_WITH_SEED_CBC_SHA",
|
"TLS_DH_anon_WITH_RC4_128_MD5",
|
||||||
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_DH_anon_WITH_SEED_CBC_SHA",
|
||||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
|
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
|
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_ECDSA_WITH_NULL_SHA",
|
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
|
"TLS_ECDHE_ECDSA_WITH_NULL_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA",
|
"TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256",
|
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA",
|
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384",
|
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
"TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_PSK_WITH_NULL_SHA",
|
"TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_PSK_WITH_NULL_SHA256",
|
"TLS_ECDHE_PSK_WITH_NULL_SHA",
|
||||||
"TLS_ECDHE_PSK_WITH_NULL_SHA384",
|
"TLS_ECDHE_PSK_WITH_NULL_SHA256",
|
||||||
"TLS_ECDHE_PSK_WITH_RC4_128_SHA",
|
"TLS_ECDHE_PSK_WITH_NULL_SHA384",
|
||||||
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_ECDHE_PSK_WITH_RC4_128_SHA",
|
||||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
|
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_ECDHE_RSA_WITH_NULL_SHA",
|
"TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
|
"TLS_ECDHE_RSA_WITH_NULL_SHA",
|
||||||
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
|
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
|
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
|
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
|
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
|
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
|
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256",
|
"TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384",
|
"TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_NULL_SHA",
|
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
|
"TLS_ECDH_ECDSA_WITH_NULL_SHA",
|
||||||
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
|
||||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
|
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
|
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
|
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
|
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256",
|
"TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384",
|
"TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_NULL_SHA",
|
"TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
|
"TLS_ECDH_RSA_WITH_NULL_SHA",
|
||||||
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
|
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
|
||||||
"TLS_ECDH_anon_WITH_AES_128_CBC_SHA",
|
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_ECDH_anon_WITH_AES_256_CBC_SHA",
|
"TLS_ECDH_anon_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_ECDH_anon_WITH_NULL_SHA",
|
"TLS_ECDH_anon_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_ECDH_anon_WITH_RC4_128_SHA",
|
"TLS_ECDH_anon_WITH_NULL_SHA",
|
||||||
"TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
|
"TLS_ECDH_anon_WITH_RC4_128_SHA",
|
||||||
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5",
|
"TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
|
||||||
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA",
|
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5",
|
||||||
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5",
|
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA",
|
||||||
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA",
|
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5",
|
||||||
"TLS_KRB5_EXPORT_WITH_RC4_40_MD5",
|
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA",
|
||||||
"TLS_KRB5_EXPORT_WITH_RC4_40_SHA",
|
"TLS_KRB5_EXPORT_WITH_RC4_40_MD5",
|
||||||
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5",
|
"TLS_KRB5_EXPORT_WITH_RC4_40_SHA",
|
||||||
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA",
|
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5",
|
||||||
"TLS_KRB5_WITH_DES_CBC_MD5",
|
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_KRB5_WITH_DES_CBC_SHA",
|
"TLS_KRB5_WITH_DES_CBC_MD5",
|
||||||
"TLS_KRB5_WITH_IDEA_CBC_MD5",
|
"TLS_KRB5_WITH_DES_CBC_SHA",
|
||||||
"TLS_KRB5_WITH_IDEA_CBC_SHA",
|
"TLS_KRB5_WITH_IDEA_CBC_MD5",
|
||||||
"TLS_KRB5_WITH_RC4_128_MD5",
|
"TLS_KRB5_WITH_IDEA_CBC_SHA",
|
||||||
"TLS_KRB5_WITH_RC4_128_SHA",
|
"TLS_KRB5_WITH_RC4_128_MD5",
|
||||||
"TLS_NULL_WITH_NULL_NULL",
|
"TLS_KRB5_WITH_RC4_128_SHA",
|
||||||
"TLS_PSK_WITH_3DES_EDE_CBC_SHA",
|
"TLS_NULL_WITH_NULL_NULL",
|
||||||
"TLS_PSK_WITH_AES_128_CBC_SHA",
|
"TLS_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_PSK_WITH_AES_128_CBC_SHA256",
|
"TLS_PSK_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_PSK_WITH_AES_128_CCM",
|
"TLS_PSK_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_PSK_WITH_AES_128_CCM_8",
|
"TLS_PSK_WITH_AES_128_CCM",
|
||||||
"TLS_PSK_WITH_AES_128_GCM_SHA256",
|
"TLS_PSK_WITH_AES_128_CCM_8",
|
||||||
"TLS_PSK_WITH_AES_256_CBC_SHA",
|
"TLS_PSK_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_PSK_WITH_AES_256_CBC_SHA384",
|
"TLS_PSK_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_PSK_WITH_AES_256_CCM",
|
"TLS_PSK_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_PSK_WITH_AES_256_CCM_8",
|
"TLS_PSK_WITH_AES_256_CCM",
|
||||||
"TLS_PSK_WITH_AES_256_GCM_SHA384",
|
"TLS_PSK_WITH_AES_256_CCM_8",
|
||||||
"TLS_PSK_WITH_ARIA_128_CBC_SHA256",
|
"TLS_PSK_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_PSK_WITH_ARIA_128_GCM_SHA256",
|
"TLS_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_PSK_WITH_ARIA_256_CBC_SHA384",
|
"TLS_PSK_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_PSK_WITH_ARIA_256_GCM_SHA384",
|
"TLS_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_PSK_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_PSK_WITH_NULL_SHA",
|
"TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_PSK_WITH_NULL_SHA256",
|
"TLS_PSK_WITH_NULL_SHA",
|
||||||
"TLS_PSK_WITH_NULL_SHA384",
|
"TLS_PSK_WITH_NULL_SHA256",
|
||||||
"TLS_PSK_WITH_RC4_128_SHA",
|
"TLS_PSK_WITH_NULL_SHA384",
|
||||||
"TLS_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
"TLS_PSK_WITH_RC4_128_SHA",
|
||||||
"TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5",
|
"TLS_RSA_EXPORT_WITH_DES40_CBC_SHA",
|
||||||
"TLS_RSA_EXPORT_WITH_RC4_40_MD5",
|
"TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5",
|
||||||
"TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA",
|
"TLS_RSA_EXPORT_WITH_RC4_40_MD5",
|
||||||
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA",
|
"TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA256",
|
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_RSA_PSK_WITH_AES_128_GCM_SHA256",
|
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA",
|
"TLS_RSA_PSK_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA384",
|
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_RSA_PSK_WITH_AES_256_GCM_SHA384",
|
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256",
|
"TLS_RSA_PSK_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256",
|
"TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384",
|
"TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384",
|
"TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
"TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_NULL_SHA",
|
"TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_RSA_PSK_WITH_NULL_SHA256",
|
"TLS_RSA_PSK_WITH_NULL_SHA",
|
||||||
"TLS_RSA_PSK_WITH_NULL_SHA384",
|
"TLS_RSA_PSK_WITH_NULL_SHA256",
|
||||||
"TLS_RSA_PSK_WITH_RC4_128_SHA",
|
"TLS_RSA_PSK_WITH_NULL_SHA384",
|
||||||
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_RSA_PSK_WITH_RC4_128_SHA",
|
||||||
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_RSA_WITH_AES_128_CBC_SHA256",
|
"TLS_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_RSA_WITH_AES_128_CCM",
|
"TLS_RSA_WITH_AES_128_CBC_SHA256",
|
||||||
"TLS_RSA_WITH_AES_128_CCM_8",
|
"TLS_RSA_WITH_AES_128_CCM",
|
||||||
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
"TLS_RSA_WITH_AES_128_CCM_8",
|
||||||
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_RSA_WITH_AES_128_GCM_SHA256",
|
||||||
"TLS_RSA_WITH_AES_256_CBC_SHA256",
|
"TLS_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_RSA_WITH_AES_256_CCM",
|
"TLS_RSA_WITH_AES_256_CBC_SHA256",
|
||||||
"TLS_RSA_WITH_AES_256_CCM_8",
|
"TLS_RSA_WITH_AES_256_CCM",
|
||||||
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
"TLS_RSA_WITH_AES_256_CCM_8",
|
||||||
"TLS_RSA_WITH_ARIA_128_CBC_SHA256",
|
"TLS_RSA_WITH_AES_256_GCM_SHA384",
|
||||||
"TLS_RSA_WITH_ARIA_128_GCM_SHA256",
|
"TLS_RSA_WITH_ARIA_128_CBC_SHA256",
|
||||||
"TLS_RSA_WITH_ARIA_256_CBC_SHA384",
|
"TLS_RSA_WITH_ARIA_128_GCM_SHA256",
|
||||||
"TLS_RSA_WITH_ARIA_256_GCM_SHA384",
|
"TLS_RSA_WITH_ARIA_256_CBC_SHA384",
|
||||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
"TLS_RSA_WITH_ARIA_256_GCM_SHA384",
|
||||||
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA",
|
||||||
"TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256",
|
||||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
"TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256",
|
||||||
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA",
|
||||||
"TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256",
|
||||||
"TLS_RSA_WITH_DES_CBC_SHA",
|
"TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384",
|
||||||
"TLS_RSA_WITH_IDEA_CBC_SHA",
|
"TLS_RSA_WITH_DES_CBC_SHA",
|
||||||
"TLS_RSA_WITH_NULL_MD5",
|
"TLS_RSA_WITH_IDEA_CBC_SHA",
|
||||||
"TLS_RSA_WITH_NULL_SHA",
|
"TLS_RSA_WITH_NULL_MD5",
|
||||||
"TLS_RSA_WITH_NULL_SHA256",
|
"TLS_RSA_WITH_NULL_SHA",
|
||||||
"TLS_RSA_WITH_RC4_128_MD5",
|
"TLS_RSA_WITH_NULL_SHA256",
|
||||||
"TLS_RSA_WITH_RC4_128_SHA",
|
"TLS_RSA_WITH_RC4_128_MD5",
|
||||||
"TLS_RSA_WITH_SEED_CBC_SHA",
|
"TLS_RSA_WITH_RC4_128_SHA",
|
||||||
"TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA",
|
"TLS_RSA_WITH_SEED_CBC_SHA",
|
||||||
"TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA",
|
"TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA",
|
"TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA",
|
"TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA",
|
"TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA",
|
||||||
"TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA",
|
"TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA",
|
||||||
"TLS_SRP_SHA_WITH_AES_128_CBC_SHA",
|
"TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA",
|
||||||
"TLS_SRP_SHA_WITH_AES_256_CBC_SHA"
|
"TLS_SRP_SHA_WITH_AES_128_CBC_SHA",
|
||||||
);
|
"TLS_SRP_SHA_WITH_AES_256_CBC_SHA");
|
||||||
|
|
||||||
/** RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash
|
/**
|
||||||
* cannot force a peer to offer it), but documented here as the fact {@link #applyTo}'s
|
* RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash cannot
|
||||||
* filtering relies on: filtering the blocklist above out of the JDK's default enabled set
|
* force a peer to offer it), but documented here as the fact {@link #applyTo}'s filtering relies
|
||||||
* never removes this one, because it was never in the blocklist to begin with. */
|
* on: filtering the blocklist above out of the JDK's default enabled set never removes this one,
|
||||||
static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
|
* because it was never in the blocklist to begin with.
|
||||||
|
*/
|
||||||
|
static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
|
||||||
|
|
||||||
private final SSLContext context;
|
private final SSLContext context;
|
||||||
private final boolean hardenDefaults;
|
private final boolean hardenDefaults;
|
||||||
private final ClientAuth clientAuth;
|
private final ClientAuth clientAuth;
|
||||||
private final String[] applicationProtocols;
|
private final String[] applicationProtocols;
|
||||||
|
|
||||||
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
|
private TlsConfig(
|
||||||
this.context = context;
|
SSLContext context,
|
||||||
this.hardenDefaults = hardenDefaults;
|
boolean hardenDefaults,
|
||||||
this.clientAuth = clientAuth;
|
ClientAuth clientAuth,
|
||||||
this.applicationProtocols = applicationProtocols;
|
String[] applicationProtocols) {
|
||||||
}
|
this.context = context;
|
||||||
|
this.hardenDefaults = hardenDefaults;
|
||||||
|
this.clientAuth = clientAuth;
|
||||||
|
this.applicationProtocols = applicationProtocols;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file
|
* Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file
|
||||||
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is
|
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is assumed
|
||||||
* assumed equal to the store password, the common case for PKCS12.
|
* equal to the store password, the common case for PKCS12.
|
||||||
*/
|
*/
|
||||||
public static TlsConfig keystore(Path path, String password) {
|
public static TlsConfig keystore(Path path, String password) {
|
||||||
try {
|
try {
|
||||||
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
|
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
|
||||||
try (InputStream in = Files.newInputStream(path)) {
|
try (InputStream in = Files.newInputStream(path)) {
|
||||||
store.load(in, password.toCharArray());
|
store.load(in, password.toCharArray());
|
||||||
}
|
}
|
||||||
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
KeyManagerFactory kmf =
|
||||||
kmf.init(store, password.toCharArray());
|
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||||
|
kmf.init(store, password.toCharArray());
|
||||||
|
|
||||||
KeyManager[] managers = kmf.getKeyManagers();
|
KeyManager[] managers = kmf.getKeyManagers();
|
||||||
for (int i = 0; i < managers.length; i++) {
|
for (int i = 0; i < managers.length; i++) {
|
||||||
if (managers[i] instanceof X509ExtendedKeyManager x509) {
|
if (managers[i] instanceof X509ExtendedKeyManager x509) {
|
||||||
managers[i] = new SniKeyManager(x509, store);
|
managers[i] = new SniKeyManager(x509, store);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
|
||||||
ctx.init(managers, null, null);
|
|
||||||
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
|
|
||||||
} catch (GeneralSecurityException | IOException e) {
|
|
||||||
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||||
|
ctx.init(managers, null, null);
|
||||||
|
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
|
||||||
|
} catch (GeneralSecurityException | IOException e) {
|
||||||
|
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you
|
* Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you
|
||||||
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
|
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
|
||||||
*/
|
*/
|
||||||
public static TlsConfig ofContext(SSLContext context) {
|
public static TlsConfig ofContext(SSLContext context) {
|
||||||
return new TlsConfig(context, false, ClientAuth.NONE, null);
|
return new TlsConfig(context, false, ClientAuth.NONE, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
|
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
|
||||||
public TlsConfig clientAuth(ClientAuth mode) {
|
public TlsConfig clientAuth(ClientAuth mode) {
|
||||||
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
|
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ALPN protocols this listener negotiates, in preference order (e.g.
|
* ALPN protocols this listener negotiates, in preference order (e.g. {@code "acme-tls/1",
|
||||||
* {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc
|
* "http/1.1"}). Applies on either construction path — see class Javadoc for how a custom {@code
|
||||||
* for how a custom {@code KeyManager} observes the negotiated value.
|
* KeyManager} observes the negotiated value.
|
||||||
*/
|
*/
|
||||||
public TlsConfig applicationProtocols(String... protocols) {
|
public TlsConfig applicationProtocols(String... protocols) {
|
||||||
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
|
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
|
/** Returns this TLS configuration with HTTP/2 enabled and HTTP/1.1 retained as fallback. */
|
||||||
|
public TlsConfig enableHttp2Alpn() {
|
||||||
public SSLServerSocketFactory serverSocketFactory() {
|
List<String> protocols = new ArrayList<>();
|
||||||
return context.getServerSocketFactory();
|
if (applicationProtocols != null) {
|
||||||
}
|
for (String protocol : applicationProtocols) {
|
||||||
|
if (!"h2".equals(protocol) && !"http/1.1".equals(protocol)) {
|
||||||
public void applyTo(SSLServerSocket socket) {
|
protocols.add(protocol);
|
||||||
if (hardenDefaults || applicationProtocols != null) {
|
|
||||||
SSLParameters params = socket.getSSLParameters();
|
|
||||||
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
|
|
||||||
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
|
|
||||||
socket.setSSLParameters(params);
|
|
||||||
}
|
|
||||||
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
|
|
||||||
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
|
|
||||||
|
|
||||||
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
|
|
||||||
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
|
|
||||||
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
|
|
||||||
if (negotiatesH2()) {
|
|
||||||
String[] enabled = socket.getEnabledCipherSuites();
|
|
||||||
List<String> filtered = new ArrayList<>(enabled.length);
|
|
||||||
for (String suite : enabled) {
|
|
||||||
if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite);
|
|
||||||
}
|
|
||||||
socket.setEnabledCipherSuites(filtered.toArray(new String[0]));
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
protocols.add("h2");
|
||||||
|
protocols.add("http/1.1");
|
||||||
|
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.toArray(String[]::new));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
|
||||||
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols})
|
|
||||||
* includes {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo}
|
public SSLServerSocketFactory serverSocketFactory() {
|
||||||
* duplicating the offered-protocols check.
|
return context.getServerSocketFactory();
|
||||||
*/
|
}
|
||||||
public boolean negotiatesH2() {
|
|
||||||
if (applicationProtocols == null) return false;
|
public void applyTo(SSLServerSocket socket) {
|
||||||
for (String protocol : applicationProtocols) {
|
if (hardenDefaults || applicationProtocols != null) {
|
||||||
if ("h2".equals(protocol)) return true;
|
SSLParameters params = socket.getSSLParameters();
|
||||||
}
|
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
|
||||||
return false;
|
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
|
||||||
|
socket.setSSLParameters(params);
|
||||||
}
|
}
|
||||||
|
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
|
||||||
|
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
|
||||||
|
|
||||||
|
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
|
||||||
|
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
|
||||||
|
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
|
||||||
|
if (negotiatesH2()) {
|
||||||
|
String[] enabled = socket.getEnabledCipherSuites();
|
||||||
|
List<String> filtered = new ArrayList<>(enabled.length);
|
||||||
|
for (String suite : enabled) {
|
||||||
|
if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite);
|
||||||
|
}
|
||||||
|
socket.setEnabledCipherSuites(filtered.toArray(new String[0]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols}) includes
|
||||||
|
* {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo} duplicating
|
||||||
|
* the offered-protocols check.
|
||||||
|
*/
|
||||||
|
public boolean negotiatesH2() {
|
||||||
|
if (applicationProtocols == null) return false;
|
||||||
|
for (String protocol : applicationProtocols) {
|
||||||
|
if ("h2".equals(protocol)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,6 @@ package dev.relism.flash.transport;
|
|||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import javax.net.ssl.SSLSocket;
|
|
||||||
|
|
||||||
import java.io.BufferedOutputStream;
|
import java.io.BufferedOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
@@ -18,121 +13,144 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import java.util.function.BooleanSupplier;
|
import java.util.function.BooleanSupplier;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Owns one connection's socket lifecycle from accept to close: configures socket options,
|
* Owns one connection's socket lifecycle from accept to close: configures socket options,
|
||||||
* dispatches to the matching {@link ConnectionProtocol} — guaranteeing cleanup (scratch
|
* dispatches to the matching {@link ConnectionProtocol} — guaranteeing cleanup (scratch release,
|
||||||
* release, active-socket tracking) regardless of how the protocol implementation exits.
|
* active-socket tracking) regardless of how the protocol implementation exits.
|
||||||
*
|
*
|
||||||
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all —
|
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all — those
|
||||||
* those live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today,
|
* live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today, always {@code
|
||||||
* always {@code Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
|
* Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public final class ConnectionRunner {
|
public final class ConnectionRunner {
|
||||||
|
|
||||||
private final ExecutorService executorService;
|
private final ExecutorService executorService;
|
||||||
private final Set<Socket> activeSockets;
|
private final Set<Socket> activeSockets;
|
||||||
private final ScratchPool scratchPool;
|
private final ScratchPool scratchPool;
|
||||||
private final AbstractRouter router;
|
private final AbstractRouter router;
|
||||||
private final AbstractWsRouter wsRouter;
|
private final AbstractWsRouter wsRouter;
|
||||||
private final FlashConfiguration configuration;
|
private final FlashConfiguration configuration;
|
||||||
private final ConnectionProtocol http1Protocol;
|
private final ConnectionProtocol http1Protocol;
|
||||||
|
private final Supplier<? extends ConnectionProtocol> http2ProtocolFactory;
|
||||||
|
|
||||||
public ConnectionRunner(ExecutorService executorService, Set<Socket> activeSockets, ScratchPool scratchPool,
|
public ConnectionRunner(
|
||||||
AbstractRouter router, AbstractWsRouter wsRouter, FlashConfiguration configuration,
|
ExecutorService executorService,
|
||||||
ConnectionProtocol http1Protocol) {
|
Set<Socket> activeSockets,
|
||||||
this.executorService = executorService;
|
ScratchPool scratchPool,
|
||||||
this.activeSockets = activeSockets;
|
AbstractRouter router,
|
||||||
this.scratchPool = scratchPool;
|
AbstractWsRouter wsRouter,
|
||||||
this.router = router;
|
FlashConfiguration configuration,
|
||||||
this.wsRouter = wsRouter;
|
ConnectionProtocol http1Protocol,
|
||||||
this.configuration = configuration;
|
Supplier<? extends ConnectionProtocol> http2ProtocolFactory) {
|
||||||
this.http1Protocol = http1Protocol;
|
this.executorService = executorService;
|
||||||
|
this.activeSockets = activeSockets;
|
||||||
|
this.scratchPool = scratchPool;
|
||||||
|
this.router = router;
|
||||||
|
this.wsRouter = wsRouter;
|
||||||
|
this.configuration = configuration;
|
||||||
|
this.http1Protocol = http1Protocol;
|
||||||
|
this.http2ProtocolFactory = http2ProtocolFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submits {@code socket} to the virtual-thread executor for full connection handling. {@code
|
||||||
|
* stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol
|
||||||
|
* implementation can observe an in-progress graceful shutdown.
|
||||||
|
*/
|
||||||
|
public void accept(Socket socket, BooleanSupplier stopped) {
|
||||||
|
try {
|
||||||
|
executorService.submit(() -> handle(socket, stopped));
|
||||||
|
} catch (RejectedExecutionException ignored) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Error closing socket on shutdown", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Submits {@code socket} to the virtual-thread executor for full connection handling.
|
private void handle(Socket socket, BooleanSupplier stopped) {
|
||||||
* {@code stopped} is threaded through to the eventual {@link ConnectionContext} so the
|
activeSockets.add(socket);
|
||||||
* protocol implementation can observe an in-progress graceful shutdown. */
|
ConnectionScratch scratch = scratchPool.acquire();
|
||||||
public void accept(Socket socket, BooleanSupplier stopped) {
|
try (socket;
|
||||||
try {
|
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||||
executorService.submit(() -> handle(socket, stopped));
|
|
||||||
} catch (RejectedExecutionException ignored) {
|
// TCP_NODELAY: disable Nagle's algorithm. Small WS frames (< MSS) are sent
|
||||||
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
|
// immediately rather than waiting up to 200 ms for more data to coalesce.
|
||||||
}
|
socket.setTcpNoDelay(true);
|
||||||
|
socket.setSendBufferSize(TransportTuning.SOCKET_BUF_SIZE);
|
||||||
|
|
||||||
|
SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null;
|
||||||
|
if (sslSocket != null) {
|
||||||
|
// protocol decision — SSLSocket#getApplicationProtocol() (which
|
||||||
|
// ProtocolNegotiator relies on) returns null until the handshake has run.
|
||||||
|
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
|
||||||
|
sslSocket.startHandshake();
|
||||||
|
socket.setSoTimeout(0); // BufferedByteSource's own deadline takes over below
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawOut is the unbuffered socket stream — passed to WebSocketSession directly.
|
||||||
|
// WS writes are already bulk; HTTP responses use the buffered `out` because
|
||||||
|
// Http1ResponseWriter does several small writes that benefit from coalescing.
|
||||||
|
OutputStream rawOut = socket.getOutputStream();
|
||||||
|
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
|
||||||
|
|
||||||
|
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
|
||||||
|
ConnectionContext ctx =
|
||||||
|
new ConnectionContext(
|
||||||
|
socket,
|
||||||
|
sslSocket,
|
||||||
|
in,
|
||||||
|
out,
|
||||||
|
rawOut,
|
||||||
|
(InetSocketAddress) socket.getRemoteSocketAddress(),
|
||||||
|
scratch,
|
||||||
|
router,
|
||||||
|
wsRouter,
|
||||||
|
configuration,
|
||||||
|
stopped);
|
||||||
|
if (negotiated == NegotiatedProtocol.HTTP_2) http2ProtocolFactory.get().run(ctx);
|
||||||
|
else http1Protocol.run(ctx);
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
if (!stopped.getAsBoolean()) {
|
||||||
|
if (e instanceof SocketException) log.debug("Connection closed: {}", e.getMessage());
|
||||||
|
else log.error("I/O error handling request", e);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Anything not an IOException here means a collaborator misbehaved on the TLS
|
||||||
|
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/TrustManager
|
||||||
|
// throwing. That failure is isolated to this one virtual thread/connection.
|
||||||
|
if (!stopped.getAsBoolean()) log.error("Unexpected error handling connection", e);
|
||||||
|
} finally {
|
||||||
|
activeSockets.remove(socket);
|
||||||
|
scratchPool.release(scratch);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handle(Socket socket, BooleanSupplier stopped) {
|
/**
|
||||||
activeSockets.add(socket);
|
* Decides h1 vs h2 for this connection, applying {@link FlashConfiguration#isHttp2Enabled()} to
|
||||||
ConnectionScratch scratch = scratchPool.acquire();
|
* the plaintext (h2c) path — see {@link ProtocolNegotiator}'s Javadoc for why the flag is applied
|
||||||
try (socket;
|
* here rather than inside the negotiator itself.
|
||||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
*/
|
||||||
|
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in)
|
||||||
// TCP_NODELAY: disable Nagle's algorithm. Small WS frames (< MSS) are sent
|
throws IOException {
|
||||||
// immediately rather than waiting up to 200 ms for more data to coalesce.
|
if (socket instanceof SSLSocket) {
|
||||||
socket.setTcpNoDelay(true);
|
return ProtocolNegotiator.negotiate(socket, in); // ALPN — already resolved, no I/O
|
||||||
socket.setSendBufferSize(TransportTuning.SOCKET_BUF_SIZE);
|
|
||||||
|
|
||||||
SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null;
|
|
||||||
if (sslSocket != null) {
|
|
||||||
// protocol decision — SSLSocket#getApplicationProtocol() (which
|
|
||||||
// ProtocolNegotiator relies on) returns null until the handshake has run.
|
|
||||||
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
|
|
||||||
sslSocket.startHandshake();
|
|
||||||
socket.setSoTimeout(0); // BufferedByteSource's own deadline takes over below
|
|
||||||
}
|
|
||||||
|
|
||||||
// rawOut is the unbuffered socket stream — passed to WebSocketSession directly.
|
|
||||||
// WS writes are already bulk; HTTP responses use the buffered `out` because
|
|
||||||
// Http1ResponseWriter does several small writes that benefit from coalescing.
|
|
||||||
OutputStream rawOut = socket.getOutputStream();
|
|
||||||
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
|
|
||||||
|
|
||||||
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
|
|
||||||
if (negotiated == NegotiatedProtocol.HTTP_2) {
|
|
||||||
// attempt to speak a protocol this version cannot yet serve.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ConnectionContext ctx = new ConnectionContext(
|
|
||||||
socket, sslSocket, in, out, rawOut,
|
|
||||||
(InetSocketAddress) socket.getRemoteSocketAddress(),
|
|
||||||
scratch, router, wsRouter, configuration, stopped);
|
|
||||||
http1Protocol.run(ctx);
|
|
||||||
|
|
||||||
} catch (IOException e) {
|
|
||||||
if (!stopped.getAsBoolean()) {
|
|
||||||
if (e instanceof SocketException) log.debug("Connection closed: {}", e.getMessage());
|
|
||||||
else log.error("I/O error handling request", e);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
// Anything not an IOException here means a collaborator misbehaved on the TLS
|
|
||||||
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/TrustManager
|
|
||||||
// throwing. That failure is isolated to this one virtual thread/connection.
|
|
||||||
if (!stopped.getAsBoolean()) log.error("Unexpected error handling connection", e);
|
|
||||||
} finally {
|
|
||||||
activeSockets.remove(socket);
|
|
||||||
scratchPool.release(scratch);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (!configuration.isHttp2Enabled()) {
|
||||||
/**
|
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
|
||||||
* 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) throws IOException {
|
|
||||||
if (socket instanceof SSLSocket) {
|
|
||||||
return ProtocolNegotiator.negotiate(socket, in); // ALPN — already resolved, no I/O
|
|
||||||
}
|
|
||||||
if (!configuration.isHttp2Enabled()) {
|
|
||||||
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
|
|
||||||
}
|
|
||||||
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
|
|
||||||
try {
|
|
||||||
return ProtocolNegotiator.negotiate(socket, in);
|
|
||||||
} finally {
|
|
||||||
in.clearDeadline();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
|
||||||
|
try {
|
||||||
|
return ProtocolNegotiator.negotiate(socket, in);
|
||||||
|
} finally {
|
||||||
|
in.clearDeadline();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,9 @@ package dev.relism.flash.transport;
|
|||||||
import dev.relism.flash.ServerHandle;
|
import dev.relism.flash.ServerHandle;
|
||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.http1.Http1Connection;
|
import dev.relism.flash.http1.Http1Connection;
|
||||||
|
import dev.relism.flash.http2.Http2Connection;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
@@ -16,47 +14,70 @@ import java.util.Set;
|
|||||||
import java.util.concurrent.ConcurrentHashMap;
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composes the whole transport: binds every configured listener, wires the connection runner
|
* Composes the whole transport: binds every configured listener, wires the connection runner and
|
||||||
* and the h1 protocol, and returns the {@link ServerHandle} implementation
|
* the h1 protocol, and returns the {@link ServerHandle} implementation ({@link ServerLifecycle})
|
||||||
* ({@link ServerLifecycle}) that {@link dev.relism.flash.ServerHandle#create} exposes publicly.
|
* that {@link dev.relism.flash.ServerHandle#create} exposes publicly.
|
||||||
*
|
*
|
||||||
* asked for — {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
|
* <p>asked for — {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
|
||||||
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives
|
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives in a
|
||||||
* in a different package and must call it) — user code has no reason to call this directly.
|
* different package and must call it) — user code has no reason to call this directly.
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public final class TransportFactory {
|
public final class TransportFactory {
|
||||||
|
|
||||||
private TransportFactory() {
|
private TransportFactory() {}
|
||||||
|
|
||||||
|
public static ServerHandle create(
|
||||||
|
FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter)
|
||||||
|
throws IOException {
|
||||||
|
List<FlashConfiguration.Listener> specs =
|
||||||
|
configuration.getListeners().isEmpty()
|
||||||
|
? List.of(
|
||||||
|
new FlashConfiguration.Listener(
|
||||||
|
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
||||||
|
: configuration.getListeners();
|
||||||
|
|
||||||
|
List<BoundListener> bound = new ArrayList<>(specs.size());
|
||||||
|
for (FlashConfiguration.Listener original : specs) {
|
||||||
|
FlashConfiguration.Listener spec = original;
|
||||||
|
if (configuration.isHttp2Enabled() && original.tls() != null) {
|
||||||
|
spec =
|
||||||
|
new FlashConfiguration.Listener(
|
||||||
|
original.port(), original.host(), original.tls().enableHttp2Alpn());
|
||||||
|
}
|
||||||
|
bound.add(ListenerBinder.bind(spec));
|
||||||
|
}
|
||||||
|
List<BoundListener> boundListeners = List.copyOf(bound);
|
||||||
|
|
||||||
|
for (BoundListener bl : boundListeners) {
|
||||||
|
log.info(
|
||||||
|
"HTTP server bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
|
||||||
|
bl.socket().getInetAddress(),
|
||||||
|
bl.socket().getLocalPort(),
|
||||||
|
bl.secure(),
|
||||||
|
TransportTuning.ACCEPT_BACKLOG,
|
||||||
|
TransportTuning.ACCEPT_THREADS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServerHandle create(FlashConfiguration configuration,
|
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
|
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||||
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
|
ScratchPool scratchPool = new ScratchPool();
|
||||||
? List.of(new FlashConfiguration.Listener(
|
|
||||||
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
|
||||||
: configuration.getListeners();
|
|
||||||
|
|
||||||
List<BoundListener> bound = new ArrayList<>(specs.size());
|
ConnectionRunner runner =
|
||||||
for (FlashConfiguration.Listener spec : specs) bound.add(ListenerBinder.bind(spec));
|
new ConnectionRunner(
|
||||||
List<BoundListener> boundListeners = List.copyOf(bound);
|
executorService,
|
||||||
|
activeSockets,
|
||||||
|
scratchPool,
|
||||||
|
router,
|
||||||
|
wsRouter,
|
||||||
|
configuration,
|
||||||
|
new Http1Connection(),
|
||||||
|
Http2Connection::new);
|
||||||
|
|
||||||
for (BoundListener bl : boundListeners) {
|
return new ServerLifecycle(
|
||||||
log.info("HTTP server bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
|
boundListeners, runner, configuration, executorService, activeSockets);
|
||||||
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
|
}
|
||||||
TransportTuning.ACCEPT_BACKLOG, TransportTuning.ACCEPT_THREADS);
|
|
||||||
}
|
|
||||||
|
|
||||||
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
|
||||||
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
|
||||||
ScratchPool scratchPool = new ScratchPool();
|
|
||||||
|
|
||||||
ConnectionRunner runner = new ConnectionRunner(
|
|
||||||
executorService, activeSockets, scratchPool, router, wsRouter, configuration,
|
|
||||||
new Http1Connection());
|
|
||||||
|
|
||||||
return new ServerLifecycle(boundListeners, runner, configuration, executorService, activeSockets);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2ConnectionHandshakeTest {
|
||||||
|
@Test
|
||||||
|
void exactPrefaceExchangesSettingsAndAcknowledgesPeerSettings() throws Exception {
|
||||||
|
byte[] input =
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(Http2Settings.MAX_FRAME_SIZE, 32_768));
|
||||||
|
|
||||||
|
RunResult result = run(input);
|
||||||
|
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(result.output());
|
||||||
|
|
||||||
|
assertEquals(3, frames.size());
|
||||||
|
assertEquals(FrameType.SETTINGS.code(), frames.get(0).type());
|
||||||
|
assertEquals(0, frames.get(0).flags());
|
||||||
|
assertEquals(FrameType.WINDOW_UPDATE.code(), frames.get(1).type());
|
||||||
|
assertEquals(FrameType.SETTINGS.code(), frames.get(2).type());
|
||||||
|
assertEquals(FrameFlags.ACK, frames.get(2).flags());
|
||||||
|
assertEquals(0, frames.get(2).payload().length);
|
||||||
|
assertEquals(32_768, result.connection().peerSettings().maxFrameSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mismatchedOrTruncatedPrefaceClosesWithoutSendingGoAway() throws Exception {
|
||||||
|
byte[] mismatched = Http2TestFrames.PREFACE.clone();
|
||||||
|
mismatched[10] ^= 1;
|
||||||
|
|
||||||
|
assertEquals(0, run(mismatched).output().length);
|
||||||
|
assertEquals(0, run(java.util.Arrays.copyOf(Http2TestFrames.PREFACE, 12)).output().length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void firstPeerFrameMustBeSettings() throws Exception {
|
||||||
|
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ping)).output());
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(FrameType.GOAWAY.code(), goAway.type());
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settingsAcknowledgementCannotReplaceInitialPeerSettings() throws Exception {
|
||||||
|
byte[] ack = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]);
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ack)).output());
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidHpackBlockProducesCompressionError() throws Exception {
|
||||||
|
byte[] headers =
|
||||||
|
Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[] {(byte) 0x80});
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(
|
||||||
|
run(Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE, Http2TestFrames.settings(), headers))
|
||||||
|
.output());
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.COMPRESSION_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void frameInterleavingDuringContinuationSequenceIsProtocolError() throws Exception {
|
||||||
|
byte[] incompleteHeaders =
|
||||||
|
Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82});
|
||||||
|
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(
|
||||||
|
run(Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE, Http2TestFrames.settings(), incompleteHeaders, ping))
|
||||||
|
.output());
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settingsAckWithPayloadIsFrameSizeError() throws Exception {
|
||||||
|
byte[] badAck = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[6]);
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(
|
||||||
|
run(Http2TestFrames.concat(Http2TestFrames.PREFACE, badAck)).output());
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.FRAME_SIZE_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingSettingsAcknowledgementTimesOutWithDedicatedErrorCode() throws Exception {
|
||||||
|
byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings());
|
||||||
|
InputStream stallsAfterInput =
|
||||||
|
new InputStream() {
|
||||||
|
private final ByteArrayInputStream delegate = new ByteArrayInputStream(initial);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws java.io.IOException {
|
||||||
|
byte[] one = new byte[1];
|
||||||
|
int n = read(one, 0, 1);
|
||||||
|
return n < 0 ? -1 : one[0] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] target, int off, int len) throws java.io.IOException {
|
||||||
|
if (delegate.available() > 0) return delegate.read(target, off, len);
|
||||||
|
try {
|
||||||
|
Thread.sleep(15);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new java.io.IOException(e);
|
||||||
|
}
|
||||||
|
throw new SocketTimeoutException("simulated idle peer");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Http2Connection connection = new Http2Connection(delta -> {}, 5);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
|
||||||
|
try {
|
||||||
|
connection.run(new BufferedByteSource(stallsAfterInput, null), writer, () -> false);
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output.toByteArray());
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.SETTINGS_TIMEOUT.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
static RunResult run(byte[] input) throws Exception {
|
||||||
|
Http2Connection connection = new Http2Connection();
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
|
||||||
|
try {
|
||||||
|
connection.run(
|
||||||
|
new BufferedByteSource(new ByteArrayInputStream(input), null), writer, () -> false);
|
||||||
|
writer.drain();
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
return new RunResult(connection, output.toByteArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
record RunResult(Http2Connection connection, byte[] output) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
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.tls.TestKeystores;
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
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 Http2ConnectionIntegrationTest {
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stopApp() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
AtomicBoolean handlerEntered = new AtomicBoolean();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||||
|
app.get(
|
||||||
|
"/",
|
||||||
|
(request, response) -> {
|
||||||
|
handlerEntered.set(true);
|
||||||
|
Thread.sleep(5_000);
|
||||||
|
return "late";
|
||||||
|
});
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
byte[] clientPing = "client!!".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||||
|
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
|
||||||
|
Http2TestFrames.frame(FrameType.PING, 0, 0, clientPing)));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
|
||||||
|
byte[] shutdownPing = null;
|
||||||
|
boolean sawClientPong = false;
|
||||||
|
for (int i = 0; i < 8 && !sawClientPong; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||||
|
if (frame.type() == FrameType.PING.code()) {
|
||||||
|
if ((frame.flags() & FrameFlags.ACK) != 0 && Arrays.equals(clientPing, frame.payload())) {
|
||||||
|
sawClientPong = true;
|
||||||
|
} else if ((frame.flags() & FrameFlags.ACK) == 0) {
|
||||||
|
shutdownPing = frame.payload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(sawClientPong, "PING must be processed while a route exists");
|
||||||
|
assertFalse(handlerEntered.get(), "the connection demux must not execute handlers");
|
||||||
|
if (shutdownPing != null) {
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, shutdownPing));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.port(port)
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.http2Enabled(true)
|
||||||
|
.shutdownDrainTimeoutMs(5_000)
|
||||||
|
.build());
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0])));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
|
||||||
|
readFrame(socket.getInputStream()); // server SETTINGS
|
||||||
|
readFrame(socket.getInputStream()); // initial connection WINDOW_UPDATE
|
||||||
|
readFrame(socket.getInputStream()); // SETTINGS ACK
|
||||||
|
|
||||||
|
CompletableFuture<Void> stopped = app.stop();
|
||||||
|
app = null;
|
||||||
|
Http2TestFrames.WireFrame firstGoAway = readUntil(socket, FrameType.GOAWAY);
|
||||||
|
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(firstGoAway.payload(), 0));
|
||||||
|
Http2TestFrames.WireFrame ping = readUntil(socket, FrameType.PING);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, ping.payload()));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
Http2TestFrames.WireFrame finalGoAway = readUntil(socket, FrameType.GOAWAY);
|
||||||
|
assertEquals(0, Http2TestFrames.readInt(finalGoAway.payload(), 0));
|
||||||
|
stopped.get(5, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (Socket first = new Socket("127.0.0.1", port)) {
|
||||||
|
first.setSoTimeout(5_000);
|
||||||
|
first
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])));
|
||||||
|
first.getOutputStream().flush();
|
||||||
|
readUntil(first, FrameType.GOAWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] opaque = "isolated".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
try (Socket second = new Socket("127.0.0.1", port)) {
|
||||||
|
second.setSoTimeout(5_000);
|
||||||
|
second
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||||
|
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque)));
|
||||||
|
second.getOutputStream().flush();
|
||||||
|
|
||||||
|
Http2TestFrames.WireFrame pong = null;
|
||||||
|
for (int i = 0; i < 6; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(second.getInputStream());
|
||||||
|
if (frame.type() == FrameType.PING.code()
|
||||||
|
&& (frame.flags() & FrameFlags.ACK) != 0
|
||||||
|
&& Arrays.equals(opaque, frame.payload())) {
|
||||||
|
pong = frame;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertNotNull(pong, "a fresh connection must start with fresh SETTINGS/GOAWAY state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory)
|
||||||
|
throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
Path keystore =
|
||||||
|
TestKeystores.build(
|
||||||
|
directory,
|
||||||
|
"http2.p12",
|
||||||
|
"changeit",
|
||||||
|
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.port(port)
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||||
|
.http2Enabled(true)
|
||||||
|
.build());
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (SSLSocket socket =
|
||||||
|
(SSLSocket)
|
||||||
|
TestKeystores.trustAllClientContext()
|
||||||
|
.getSocketFactory()
|
||||||
|
.createSocket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
SSLParameters parameters = socket.getSSLParameters();
|
||||||
|
parameters.setApplicationProtocols(new String[] {"h2", "http/1.1"});
|
||||||
|
socket.setSSLParameters(parameters);
|
||||||
|
socket.startHandshake();
|
||||||
|
assertEquals("h2", socket.getApplicationProtocol());
|
||||||
|
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
assertEquals(FrameType.SETTINGS.code(), readFrame(socket.getInputStream()).type());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Http2TestFrames.WireFrame readUntil(Socket socket, FrameType type)
|
||||||
|
throws Exception {
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
|
||||||
|
if (frame.type() == type.code()) return frame;
|
||||||
|
}
|
||||||
|
fail("did not receive " + type);
|
||||||
|
throw new AssertionError();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
|
||||||
|
byte[] header = input.readNBytes(9);
|
||||||
|
if (header.length != 9) throw new EOFException("truncated frame header");
|
||||||
|
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||||
|
byte[] payload = input.readNBytes(length);
|
||||||
|
if (payload.length != length) throw new EOFException("truncated frame payload");
|
||||||
|
return new Http2TestFrames.WireFrame(
|
||||||
|
header[3] & 0xff,
|
||||||
|
header[4] & 0xff,
|
||||||
|
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
|
||||||
|
payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2GoAwayTest {
|
||||||
|
private static final byte[] SHUTDOWN_PING = {
|
||||||
|
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
|
||||||
|
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
|
||||||
|
};
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void gracefulShutdownUsesTwoGoAwayStagesSeparatedByPingRoundTrip() throws Exception {
|
||||||
|
byte[] input =
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
|
||||||
|
Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, SHUTDOWN_PING));
|
||||||
|
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
|
||||||
|
List<Http2TestFrames.WireFrame> goAways =
|
||||||
|
frames.stream().filter(frame -> frame.type() == FrameType.GOAWAY.code()).toList();
|
||||||
|
|
||||||
|
assertEquals(2, goAways.size());
|
||||||
|
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(goAways.get(0).payload(), 0));
|
||||||
|
assertEquals(1, Http2TestFrames.readInt(goAways.get(1).payload(), 0));
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(0).payload(), 4));
|
||||||
|
assertEquals(
|
||||||
|
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(1).payload(), 4));
|
||||||
|
|
||||||
|
int firstGoAway = indexOf(frames, FrameType.GOAWAY.code(), 0);
|
||||||
|
int ping = indexOf(frames, FrameType.PING.code(), firstGoAway + 1);
|
||||||
|
int secondGoAway = indexOf(frames, FrameType.GOAWAY.code(), firstGoAway + 1);
|
||||||
|
assertTrue(firstGoAway < ping && ping < secondGoAway);
|
||||||
|
assertArrayEquals(SHUTDOWN_PING, frames.get(ping).payload());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void receivedGoAwayRecordsPeerState() throws Exception {
|
||||||
|
byte[] payload = new byte[8];
|
||||||
|
payload[3] = 7;
|
||||||
|
payload[7] = (byte) Http2ErrorCode.ENHANCE_YOUR_CALM.code();
|
||||||
|
Http2ConnectionHandshakeTest.RunResult result =
|
||||||
|
Http2ConnectionHandshakeTest.run(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.GOAWAY, 0, 0, payload)));
|
||||||
|
|
||||||
|
assertEquals(7, result.connection().peerLastStreamId());
|
||||||
|
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.connection().peerErrorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int indexOf(List<Http2TestFrames.WireFrame> frames, int type, int from) {
|
||||||
|
for (int i = from; i < frames.size(); i++) {
|
||||||
|
if (frames.get(i).type() == type) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
|
||||||
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2PingTest {
|
||||||
|
@Test
|
||||||
|
void pingResponseEchoesOpaqueBytesExactly() throws Exception {
|
||||||
|
byte[] opaque = "12345678".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] input =
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque));
|
||||||
|
|
||||||
|
List<Http2TestFrames.WireFrame> frames =
|
||||||
|
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
|
||||||
|
Http2TestFrames.WireFrame pong = frames.get(frames.size() - 1);
|
||||||
|
|
||||||
|
assertEquals(FrameType.PING.code(), pong.type());
|
||||||
|
assertEquals(FrameFlags.ACK, pong.flags());
|
||||||
|
assertArrayEquals(opaque, pong.payload());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pingQueueIsStrictlyBounded() {
|
||||||
|
Http2ConnectionScratch scratch = new Http2ConnectionScratch();
|
||||||
|
List<ControlIntent> claimed = new ArrayList<>();
|
||||||
|
for (int i = 0; i < Http2Limits.MAX_PING_QUEUE_DEPTH; i++) {
|
||||||
|
claimed.add(scratch.acquire(ControlKind.PING));
|
||||||
|
}
|
||||||
|
|
||||||
|
Http2Exception error =
|
||||||
|
assertThrows(Http2Exception.class, () -> scratch.acquire(ControlKind.PING));
|
||||||
|
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
|
||||||
|
claimed.forEach(ControlIntent::completed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2SettingsTest {
|
||||||
|
@Test
|
||||||
|
void appliesEveryKnownSettingAndIgnoresUnknownIdentifiers() {
|
||||||
|
Http2Settings settings = new Http2Settings();
|
||||||
|
byte[] payload =
|
||||||
|
payload(
|
||||||
|
Http2Settings.HEADER_TABLE_SIZE,
|
||||||
|
8_192,
|
||||||
|
Http2Settings.ENABLE_PUSH,
|
||||||
|
0,
|
||||||
|
Http2Settings.MAX_CONCURRENT_STREAMS,
|
||||||
|
123,
|
||||||
|
Http2Settings.INITIAL_WINDOW_SIZE,
|
||||||
|
70_000,
|
||||||
|
Http2Settings.MAX_FRAME_SIZE,
|
||||||
|
32_768,
|
||||||
|
Http2Settings.MAX_HEADER_LIST_SIZE,
|
||||||
|
99_999,
|
||||||
|
0xf00d,
|
||||||
|
42);
|
||||||
|
int[] delta = new int[1];
|
||||||
|
|
||||||
|
settings.apply(payload, 0, payload.length, value -> delta[0] = value);
|
||||||
|
|
||||||
|
assertEquals(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, settings.headerTableSize());
|
||||||
|
assertFalse(settings.pushEnabled());
|
||||||
|
assertEquals(123, settings.maxConcurrentStreams());
|
||||||
|
assertEquals(70_000, settings.initialWindowSize());
|
||||||
|
assertEquals(32_768, settings.maxFrameSize());
|
||||||
|
assertEquals(99_999, settings.maxHeaderListSize());
|
||||||
|
assertEquals(70_000 - 65_535, delta[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validatesEnablePushInitialWindowAndFrameSize() {
|
||||||
|
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.ENABLE_PUSH, 2));
|
||||||
|
assertCode(
|
||||||
|
Http2ErrorCode.FLOW_CONTROL_ERROR, payload(Http2Settings.INITIAL_WINDOW_SIZE, 0x8000_0000));
|
||||||
|
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_383));
|
||||||
|
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_777_216));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void initialWindowDeltaMayMakeOpenStreamsNegative() {
|
||||||
|
Http2Settings settings = new Http2Settings();
|
||||||
|
long[] windows = {10, 100, 65_535};
|
||||||
|
|
||||||
|
settings.apply(
|
||||||
|
payload(Http2Settings.INITIAL_WINDOW_SIZE, 1),
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
delta -> {
|
||||||
|
for (int i = 0; i < windows.length; i++) windows[i] += delta;
|
||||||
|
});
|
||||||
|
|
||||||
|
assertArrayEquals(new long[] {-65_524, -65_434, 1}, windows);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamWindowOverflowRejectsWholeSettingsPayloadTransactionally() {
|
||||||
|
Http2Settings settings = new Http2Settings();
|
||||||
|
byte[] payload =
|
||||||
|
payload(
|
||||||
|
Http2Settings.ENABLE_PUSH, 0,
|
||||||
|
Http2Settings.INITIAL_WINDOW_SIZE, 100_000);
|
||||||
|
|
||||||
|
Http2Exception error =
|
||||||
|
assertThrows(
|
||||||
|
Http2Exception.class,
|
||||||
|
() ->
|
||||||
|
settings.apply(
|
||||||
|
payload,
|
||||||
|
0,
|
||||||
|
payload.length,
|
||||||
|
delta -> {
|
||||||
|
throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
}));
|
||||||
|
|
||||||
|
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, error.errorCode());
|
||||||
|
assertTrue(settings.pushEnabled(), "no earlier setting may leak through a failed update");
|
||||||
|
assertEquals(65_535, settings.initialWindowSize());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void malformedLengthAndEntryFloodAreRejected() {
|
||||||
|
Http2Settings settings = new Http2Settings();
|
||||||
|
assertSame(
|
||||||
|
Http2Exception.FRAME_SIZE_ERROR,
|
||||||
|
assertThrows(Http2Exception.class, () -> settings.apply(new byte[5], 0, 5, d -> {})));
|
||||||
|
|
||||||
|
byte[] flood = new byte[(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME + 1) * 6];
|
||||||
|
Http2Exception error =
|
||||||
|
assertThrows(Http2Exception.class, () -> settings.apply(flood, 0, flood.length, d -> {}));
|
||||||
|
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCode(Http2ErrorCode code, byte[] payload) {
|
||||||
|
Http2Settings settings = new Http2Settings();
|
||||||
|
Http2Exception error =
|
||||||
|
assertThrows(
|
||||||
|
Http2Exception.class, () -> settings.apply(payload, 0, payload.length, d -> {}));
|
||||||
|
assertEquals(code, error.errorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] payload(int... pairs) {
|
||||||
|
byte[] settingsFrame = Http2TestFrames.settings(pairs);
|
||||||
|
return Arrays.copyOfRange(settingsFrame, 9, settingsFrame.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.frame.FrameWriteBuffer;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
final class Http2TestFrames {
|
||||||
|
static final byte[] PREFACE =
|
||||||
|
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
private Http2TestFrames() {}
|
||||||
|
|
||||||
|
static byte[] frame(FrameType type, int flags, int streamId, byte[] payload) {
|
||||||
|
ByteWriter bytes = new ByteWriter(32);
|
||||||
|
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
|
||||||
|
frame.beginFrame(type, flags, streamId);
|
||||||
|
bytes.writeBytes(payload);
|
||||||
|
frame.endFrame();
|
||||||
|
byte[] result = new byte[bytes.length()];
|
||||||
|
System.arraycopy(bytes.array(), 0, result, 0, result.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] settings(int... idValuePairs) {
|
||||||
|
ByteWriter payload = new ByteWriter(Math.max(16, idValuePairs.length * 3));
|
||||||
|
for (int i = 0; i < idValuePairs.length; i += 2) {
|
||||||
|
payload.writeUInt16(idValuePairs[i]);
|
||||||
|
payload.writeUInt32(idValuePairs[i + 1]);
|
||||||
|
}
|
||||||
|
byte[] body = new byte[payload.length()];
|
||||||
|
System.arraycopy(payload.array(), 0, body, 0, body.length);
|
||||||
|
return frame(FrameType.SETTINGS, 0, 0, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
static byte[] concat(byte[]... parts) {
|
||||||
|
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||||
|
for (byte[] part : parts) out.writeBytes(part);
|
||||||
|
return out.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<WireFrame> parse(byte[] bytes) {
|
||||||
|
List<WireFrame> frames = new ArrayList<>();
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < bytes.length) {
|
||||||
|
int length =
|
||||||
|
((bytes[pos] & 0xFF) << 16) | ((bytes[pos + 1] & 0xFF) << 8) | (bytes[pos + 2] & 0xFF);
|
||||||
|
int type = bytes[pos + 3] & 0xFF;
|
||||||
|
int flags = bytes[pos + 4] & 0xFF;
|
||||||
|
int streamId = readInt(bytes, pos + 5) & 0x7FFF_FFFF;
|
||||||
|
byte[] payload = new byte[length];
|
||||||
|
System.arraycopy(bytes, pos + 9, payload, 0, length);
|
||||||
|
frames.add(new WireFrame(type, flags, streamId, payload));
|
||||||
|
pos += 9 + length;
|
||||||
|
}
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int readInt(byte[] bytes, int off) {
|
||||||
|
return ((bytes[off] & 0xFF) << 24)
|
||||||
|
| ((bytes[off + 1] & 0xFF) << 16)
|
||||||
|
| ((bytes[off + 2] & 0xFF) << 8)
|
||||||
|
| (bytes[off + 3] & 0xFF);
|
||||||
|
}
|
||||||
|
|
||||||
|
record WireFrame(int type, int flags, int streamId, byte[] payload) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2WindowUpdateTest {
|
||||||
|
@Test
|
||||||
|
void connectionWindowUpdateIncreasesSendWindow() throws Exception {
|
||||||
|
Http2ConnectionHandshakeTest.RunResult result = runWindowUpdate(10_000);
|
||||||
|
assertEquals(75_535, result.connection().connectionSendWindow());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroIncrementIsProtocolError() throws Exception {
|
||||||
|
assertGoAwayCode(Http2ErrorCode.PROTOCOL_ERROR, runWindowUpdate(0).output());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void connectionWindowOverflowIsFlowControlError() throws Exception {
|
||||||
|
assertGoAwayCode(
|
||||||
|
Http2ErrorCode.FLOW_CONTROL_ERROR, runWindowUpdate(Integer.MAX_VALUE).output());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Http2ConnectionHandshakeTest.RunResult runWindowUpdate(int increment)
|
||||||
|
throws Exception {
|
||||||
|
byte[] payload = {
|
||||||
|
(byte) (increment >>> 24),
|
||||||
|
(byte) (increment >>> 16),
|
||||||
|
(byte) (increment >>> 8),
|
||||||
|
(byte) increment
|
||||||
|
};
|
||||||
|
return Http2ConnectionHandshakeTest.run(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, payload)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertGoAwayCode(Http2ErrorCode expected, byte[] output) {
|
||||||
|
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output);
|
||||||
|
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
|
||||||
|
assertEquals(FrameType.GOAWAY.code(), goAway.type());
|
||||||
|
assertEquals(expected.code(), Http2TestFrames.readInt(goAway.payload(), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,96 +1,170 @@
|
|||||||
package dev.relism.flash.http2.frame;
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
class Http2FrameWriterTest {
|
class Http2FrameWriterTest {
|
||||||
|
|
||||||
private static final class TestIntent implements WriteIntent {
|
private static final class TestIntent implements WriteIntent {
|
||||||
final byte[] buf;
|
final byte[] buf;
|
||||||
WriteIntent next;
|
WriteIntent next;
|
||||||
TestIntent(byte[] buf) { this.buf = buf; }
|
|
||||||
TestIntent(String s) { this(s.getBytes()); }
|
TestIntent(byte[] buf) {
|
||||||
@Override public byte[] buffer() { return buf; }
|
this.buf = buf;
|
||||||
@Override public int offset() { return 0; }
|
|
||||||
@Override public int length() { return buf.length; }
|
|
||||||
@Override public WriteIntent mpscNext() { return next; }
|
|
||||||
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static final class RecordingSink implements Http2FrameWriter.Sink {
|
TestIntent(String s) {
|
||||||
final List<byte[]> calls = new ArrayList<>();
|
this(s.getBytes());
|
||||||
@Override
|
|
||||||
public void write(byte[] buf, int off, int len) {
|
|
||||||
byte[] copy = new byte[len];
|
|
||||||
System.arraycopy(buf, off, copy, 0, len);
|
|
||||||
calls.add(copy);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Override
|
||||||
void singleWrite_deliversBytesImmediately() throws IOException {
|
public byte[] buffer() {
|
||||||
RecordingSink sink = new RecordingSink();
|
return buf;
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
|
||||||
writer.write(new TestIntent("hello"));
|
|
||||||
assertEquals(1, sink.calls.size());
|
|
||||||
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
|
|
||||||
writer.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Override
|
||||||
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
|
public int offset() {
|
||||||
RecordingSink sink = new RecordingSink();
|
return 0;
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
|
||||||
writer.write(new TestIntent("one"));
|
|
||||||
writer.write(new TestIntent("two"));
|
|
||||||
writer.write(new TestIntent("three"));
|
|
||||||
assertEquals(List.of("one", "two", "three"),
|
|
||||||
sink.calls.stream().map(String::new).toList());
|
|
||||||
writer.close();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Override
|
||||||
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
|
public int length() {
|
||||||
Http2FrameWriter.Sink failingOnce = new Http2FrameWriter.Sink() {
|
return buf.length;
|
||||||
boolean thrown = false;
|
}
|
||||||
@Override
|
|
||||||
public void write(byte[] buf, int off, int len) throws IOException {
|
@Override
|
||||||
if (!thrown) {
|
public WriteIntent mpscNext() {
|
||||||
thrown = true;
|
return next;
|
||||||
throw new IOException("simulated sink failure");
|
}
|
||||||
}
|
|
||||||
|
@Override
|
||||||
|
public void setMpscNext(WriteIntent next) {
|
||||||
|
this.next = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RecordingSink implements Http2FrameWriter.Sink {
|
||||||
|
final List<byte[]> calls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] buf, int off, int len) {
|
||||||
|
byte[] copy = new byte[len];
|
||||||
|
System.arraycopy(buf, off, copy, 0, len);
|
||||||
|
calls.add(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void singleWrite_deliversBytesImmediately() throws IOException {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
||||||
|
writer.write(new TestIntent("hello"));
|
||||||
|
assertEquals(1, sink.calls.size());
|
||||||
|
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
||||||
|
writer.write(new TestIntent("one"));
|
||||||
|
writer.write(new TestIntent("two"));
|
||||||
|
writer.write(new TestIntent("three"));
|
||||||
|
assertEquals(List.of("one", "two", "three"), sink.calls.stream().map(String::new).toList());
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
|
||||||
|
Http2FrameWriter.Sink failingOnce =
|
||||||
|
new Http2FrameWriter.Sink() {
|
||||||
|
boolean thrown = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] buf, int off, int len) throws IOException {
|
||||||
|
if (!thrown) {
|
||||||
|
thrown = true;
|
||||||
|
throw new IOException("simulated sink failure");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
|
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
|
||||||
|
|
||||||
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
|
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
|
||||||
// If the lock were left held by the failed write, this would hang (tryLock() would
|
// If the lock were left held by the failed write, this would hang (tryLock() would
|
||||||
// keep failing forever) rather than complete promptly.
|
// keep failing forever) rather than complete promptly.
|
||||||
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
|
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
|
||||||
writer.close();
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void drain_withNothingQueued_isANoOp() throws IOException {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
||||||
|
writer.drain();
|
||||||
|
assertTrue(sink.calls.isEmpty());
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
|
||||||
|
RecordingSink sink = new RecordingSink();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
||||||
|
writer.write(new TestIntent(new byte[0]));
|
||||||
|
assertEquals(1, sink.calls.size());
|
||||||
|
assertEquals(0, sink.calls.get(0).length);
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void priorityFrameOvertakesQueuedOrdinaryFrame() throws Exception {
|
||||||
|
CountDownLatch firstWriteEntered = new CountDownLatch(1);
|
||||||
|
CountDownLatch releaseFirstWrite = new CountDownLatch(1);
|
||||||
|
RecordingSink recording = new RecordingSink();
|
||||||
|
Http2FrameWriter.Sink blocking =
|
||||||
|
(buf, off, len) -> {
|
||||||
|
if (firstWriteEntered.getCount() != 0) {
|
||||||
|
firstWriteEntered.countDown();
|
||||||
|
try {
|
||||||
|
if (!releaseFirstWrite.await(5, TimeUnit.SECONDS)) {
|
||||||
|
throw new IOException("timed out waiting to release first write");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recording.write(buf, off, len);
|
||||||
|
};
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(blocking, 5_000);
|
||||||
|
|
||||||
|
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
|
||||||
|
var first =
|
||||||
|
executor.submit(
|
||||||
|
() -> {
|
||||||
|
writer.write(new TestIntent("in-flight"));
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
assertTrue(firstWriteEntered.await(5, TimeUnit.SECONDS));
|
||||||
|
writer.write(new TestIntent("ordinary"));
|
||||||
|
writer.writePriority(new TestIntent("priority"));
|
||||||
|
releaseFirstWrite.countDown();
|
||||||
|
first.get(5, TimeUnit.SECONDS);
|
||||||
|
writer.drain();
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
assertEquals(
|
||||||
void drain_withNothingQueued_isANoOp() throws IOException {
|
List.of("in-flight", "priority", "ordinary"),
|
||||||
RecordingSink sink = new RecordingSink();
|
recording.calls.stream().map(String::new).toList());
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
}
|
||||||
writer.drain();
|
|
||||||
assertTrue(sink.calls.isEmpty());
|
|
||||||
writer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
|
|
||||||
RecordingSink sink = new RecordingSink();
|
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
|
|
||||||
writer.write(new TestIntent(new byte[0]));
|
|
||||||
assertEquals(1, sink.calls.size());
|
|
||||||
assertEquals(0, sink.calls.get(0).length);
|
|
||||||
writer.close();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,191 +1,213 @@
|
|||||||
package dev.relism.flash.tls;
|
package dev.relism.flash.tls;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
|
||||||
|
|
||||||
import javax.net.ssl.SSLContext;
|
|
||||||
import javax.net.ssl.SSLParameters;
|
|
||||||
import javax.net.ssl.SSLServerSocket;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
import javax.net.ssl.SSLParameters;
|
||||||
|
import javax.net.ssl.SSLServerSocket;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
class TlsConfigTest {
|
class TlsConfigTest {
|
||||||
|
|
||||||
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
|
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
|
||||||
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
|
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
|
||||||
|
Path ks =
|
||||||
|
TestKeystores.build(
|
||||||
|
dir, "id.p12", "changeit", TestKeystores.Entry.of("only", "single.test"));
|
||||||
|
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
|
||||||
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
|
||||||
|
assertTrue(protocols.contains("TLSv1.2"));
|
||||||
|
assertTrue(protocols.contains("TLSv1.3"));
|
||||||
|
assertFalse(protocols.contains("SSLv3"));
|
||||||
|
assertFalse(protocols.contains("TLSv1"));
|
||||||
|
assertFalse(protocols.contains("TLSv1.1"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
|
void ofContext_appliesNoParameterOverlay() throws Exception {
|
||||||
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
|
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
|
||||||
TestKeystores.Entry.of("only", "single.test"));
|
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||||
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
|
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
tls.applyTo(socket);
|
SSLParameters before = socket.getSSLParameters();
|
||||||
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
|
String[] protocolsBefore = before.getProtocols();
|
||||||
assertTrue(protocols.contains("TLSv1.2"));
|
|
||||||
assertTrue(protocols.contains("TLSv1.3"));
|
tls.applyTo(socket);
|
||||||
assertFalse(protocols.contains("SSLv3"));
|
|
||||||
assertFalse(protocols.contains("TLSv1"));
|
assertArrayEquals(
|
||||||
assertFalse(protocols.contains("TLSv1.1"));
|
protocolsBefore,
|
||||||
}
|
socket.getSSLParameters().getProtocols(),
|
||||||
|
"ofContext must not narrow/override protocols set on the caller's SSLContext");
|
||||||
|
assertFalse(socket.getNeedClientAuth());
|
||||||
|
assertFalse(socket.getWantClientAuth());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ofContext_appliesNoParameterOverlay() throws Exception {
|
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
|
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
|
||||||
|
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
|
||||||
|
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
|
||||||
|
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
|
||||||
|
// place such configuration can live; this test is the contract that makes it safe to
|
||||||
|
// rely on.
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
SSLParameters before = socket.getSSLParameters();
|
SSLParameters custom = socket.getSSLParameters();
|
||||||
String[] protocolsBefore = before.getProtocols();
|
custom.setApplicationProtocols(new String[] {"acme-tls/1", "http/1.1"});
|
||||||
|
custom.setProtocols(new String[] {"TLSv1.3"});
|
||||||
|
socket.setSSLParameters(custom);
|
||||||
|
|
||||||
tls.applyTo(socket);
|
tls.applyTo(socket);
|
||||||
|
|
||||||
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
|
SSLParameters after = socket.getSSLParameters();
|
||||||
"ofContext must not narrow/override protocols set on the caller's SSLContext");
|
assertArrayEquals(
|
||||||
assertFalse(socket.getNeedClientAuth());
|
new String[] {"acme-tls/1", "http/1.1"},
|
||||||
assertFalse(socket.getWantClientAuth());
|
after.getApplicationProtocols(),
|
||||||
}
|
"ofContext must not touch ALPN protocols the caller configured on its own socket");
|
||||||
|
assertArrayEquals(
|
||||||
|
new String[] {"TLSv1.3"},
|
||||||
|
after.getProtocols(),
|
||||||
|
"ofContext must not widen/override the caller's own protocol list");
|
||||||
|
// clientAuth still applies — it is the caller's own explicit instruction through
|
||||||
|
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
|
||||||
|
assertTrue(socket.getWantClientAuth());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
|
void clientAuth_none_makesNoClientAuthCall() throws Exception {
|
||||||
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
|
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||||
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
|
|
||||||
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
|
|
||||||
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
|
|
||||||
// place such configuration can live; this test is the contract that makes it safe to
|
|
||||||
// rely on.
|
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
SSLParameters custom = socket.getSSLParameters();
|
tls.applyTo(socket);
|
||||||
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
|
assertFalse(socket.getNeedClientAuth());
|
||||||
custom.setProtocols(new String[] { "TLSv1.3" });
|
assertFalse(socket.getWantClientAuth());
|
||||||
socket.setSSLParameters(custom);
|
|
||||||
|
|
||||||
tls.applyTo(socket);
|
|
||||||
|
|
||||||
SSLParameters after = socket.getSSLParameters();
|
|
||||||
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
|
|
||||||
"ofContext must not touch ALPN protocols the caller configured on its own socket");
|
|
||||||
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
|
|
||||||
"ofContext must not widen/override the caller's own protocol list");
|
|
||||||
// clientAuth still applies — it is the caller's own explicit instruction through
|
|
||||||
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
|
|
||||||
assertTrue(socket.getWantClientAuth());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void clientAuth_none_makesNoClientAuthCall() throws Exception {
|
void clientAuth_require_setsNeedClientAuth() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
tls.applyTo(socket);
|
tls.applyTo(socket);
|
||||||
assertFalse(socket.getNeedClientAuth());
|
assertTrue(socket.getNeedClientAuth());
|
||||||
assertFalse(socket.getWantClientAuth());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void clientAuth_require_setsNeedClientAuth() throws Exception {
|
void clientAuth_optional_setsWantClientAuth() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
|
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
tls.applyTo(socket);
|
tls.applyTo(socket);
|
||||||
assertTrue(socket.getNeedClientAuth());
|
assertTrue(socket.getWantClientAuth());
|
||||||
}
|
assertFalse(socket.getNeedClientAuth());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void clientAuth_optional_setsWantClientAuth() throws Exception {
|
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
|
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
|
||||||
|
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
|
||||||
|
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
|
||||||
|
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
|
||||||
|
}
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
@Test
|
||||||
tls.applyTo(socket);
|
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
|
||||||
assertTrue(socket.getWantClientAuth());
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
assertFalse(socket.getNeedClientAuth());
|
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
|
||||||
}
|
|
||||||
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
|
tls.applyTo(socket);
|
||||||
|
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
|
||||||
|
// Spot-check a handful of RFC 9113 Appendix A entries across different families
|
||||||
|
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
|
||||||
|
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
|
||||||
|
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
|
||||||
|
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
|
||||||
|
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
|
||||||
|
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
|
||||||
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
|
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
|
||||||
|
|
||||||
@Test
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
|
boolean jdkEnabledItByDefault =
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
Arrays.asList(socket.getEnabledCipherSuites())
|
||||||
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
|
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
|
||||||
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
|
tls.applyTo(socket);
|
||||||
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
|
if (jdkEnabledItByDefault) {
|
||||||
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
|
assertTrue(
|
||||||
|
Arrays.asList(socket.getEnabledCipherSuites())
|
||||||
|
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
|
||||||
|
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
|
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
|
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
tls.applyTo(socket);
|
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||||
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
|
tls.applyTo(socket);
|
||||||
// Spot-check a handful of RFC 9113 Appendix A entries across different families
|
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||||
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
|
|
||||||
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
|
|
||||||
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
|
|
||||||
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
|
|
||||||
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
|
|
||||||
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
|
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = TestKeystores.trustAllClientContext();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
|
TlsConfig tls = TlsConfig.ofContext(ctx);
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
try (SSLServerSocket socket = unboundSocket(tls)) {
|
||||||
boolean jdkEnabledItByDefault =
|
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
||||||
Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
|
tls.applyTo(socket);
|
||||||
tls.applyTo(socket);
|
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
||||||
if (jdkEnabledItByDefault) {
|
|
||||||
assertTrue(Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
|
|
||||||
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
|
void enableHttp2AlpnPreservesCustomPriorityAndRetainsHttp1Fallback() throws Exception {
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
SSLContext ctx = SSLContext.getDefault();
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
|
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("acme-tls/1");
|
||||||
|
SSLServerSocket socket =
|
||||||
|
(SSLServerSocket) tls.enableHttp2Alpn().serverSocketFactory().createServerSocket();
|
||||||
|
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
tls.enableHttp2Alpn().applyTo(socket);
|
||||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
|
||||||
tls.applyTo(socket);
|
|
||||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
assertArrayEquals(
|
||||||
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
|
new String[] {"acme-tls/1", "h2", "http/1.1"},
|
||||||
SSLContext ctx = TestKeystores.trustAllClientContext();
|
socket.getSSLParameters().getApplicationProtocols());
|
||||||
TlsConfig tls = TlsConfig.ofContext(ctx);
|
socket.close();
|
||||||
|
}
|
||||||
try (SSLServerSocket socket = unboundSocket(tls)) {
|
|
||||||
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
|
|
||||||
tls.applyTo(socket);
|
|
||||||
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
package dev.relism.flash.transport;
|
package dev.relism.flash.transport;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
@@ -16,55 +16,67 @@ import java.util.concurrent.CountDownLatch;
|
|||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
import static org.junit.jupiter.api.Assertions.*;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A scratch is always released — including on an exception path — and a socket is always
|
* A scratch is always released — including on an exception path — and a socket is always removed
|
||||||
* removed from {@code activeSockets}, regardless of how the dispatched
|
* from {@code activeSockets}, regardless of how the dispatched checks list), verified here with a
|
||||||
* checks list), verified here with a protocol implementation that deliberately throws.
|
* protocol implementation that deliberately throws.
|
||||||
*/
|
*/
|
||||||
class ConnectionRunnerTest {
|
class ConnectionRunnerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception {
|
void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception {
|
||||||
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||||
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||||
ScratchPool scratchPool = new ScratchPool();
|
ScratchPool scratchPool = new ScratchPool();
|
||||||
AbstractRouter router = new FastPathRouterImpl();
|
AbstractRouter router = new FastPathRouterImpl();
|
||||||
AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
|
AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
|
||||||
FlashConfiguration configuration = FlashConfiguration.builder().port(0).build();
|
FlashConfiguration configuration = FlashConfiguration.builder().port(0).build();
|
||||||
|
|
||||||
ConnectionProtocol throwingProtocol = ctx -> {
|
ConnectionProtocol throwingProtocol =
|
||||||
throw new IOException("simulated protocol failure");
|
ctx -> {
|
||||||
|
throw new IOException("simulated protocol failure");
|
||||||
};
|
};
|
||||||
|
|
||||||
ConnectionRunner runner = new ConnectionRunner(
|
ConnectionRunner runner =
|
||||||
executor, activeSockets, scratchPool, router, wsRouter, configuration, throwingProtocol);
|
new ConnectionRunner(
|
||||||
|
executor,
|
||||||
|
activeSockets,
|
||||||
|
scratchPool,
|
||||||
|
router,
|
||||||
|
wsRouter,
|
||||||
|
configuration,
|
||||||
|
throwingProtocol,
|
||||||
|
() -> throwingProtocol);
|
||||||
|
|
||||||
try (ServerSocket serverSocket = new ServerSocket(0)) {
|
try (ServerSocket serverSocket = new ServerSocket(0)) {
|
||||||
int port = serverSocket.getLocalPort();
|
int port = serverSocket.getLocalPort();
|
||||||
CountDownLatch accepted = new CountDownLatch(1);
|
CountDownLatch accepted = new CountDownLatch(1);
|
||||||
|
|
||||||
Thread acceptThread = new Thread(() -> {
|
Thread acceptThread =
|
||||||
|
new Thread(
|
||||||
|
() -> {
|
||||||
try (Socket serverSide = serverSocket.accept()) {
|
try (Socket serverSide = serverSocket.accept()) {
|
||||||
runner.accept(serverSide, () -> false);
|
runner.accept(serverSide, () -> false);
|
||||||
accepted.countDown();
|
accepted.countDown();
|
||||||
Thread.sleep(300); // give the submitted virtual-thread task time to run
|
Thread.sleep(300); // give the submitted virtual-thread task time to run
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
acceptThread.start();
|
acceptThread.start();
|
||||||
|
|
||||||
try (Socket client = new Socket("127.0.0.1", port)) {
|
try (Socket client = new Socket("127.0.0.1", port)) {
|
||||||
assertTrue(accepted.await(2, TimeUnit.SECONDS));
|
assertTrue(accepted.await(2, TimeUnit.SECONDS));
|
||||||
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
|
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
|
||||||
|
|
||||||
assertTrue(activeSockets.isEmpty(), "socket must be removed from activeSockets on every exit path");
|
assertTrue(
|
||||||
}
|
activeSockets.isEmpty(),
|
||||||
acceptThread.join(2000);
|
"socket must be removed from activeSockets on every exit path");
|
||||||
} finally {
|
}
|
||||||
executor.shutdownNow();
|
acceptThread.join(2000);
|
||||||
}
|
} finally {
|
||||||
|
executor.shutdownNow();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user