feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession
CI / Build & Test (push) Failing after 5m3s
CI / Build & Test (pull_request) Failing after 4m53s

- TlsConfig.applicationProtocols(String...) sets the listener's negotiable
  ALPN protocol list via SSLParameters, inherited by every accepted socket
  like clientAuth — works on both keystore() and ofContext(), untouched
  unless called. Enables TLS-ALPN-01 (RFC 8737) style on-demand cert
  issuance: a custom KeyManager can read the already-resolved protocol via
  engine/socket getHandshakeApplicationProtocol() inside
  chooseEngineServerAlias/chooseServerAlias, since ALPN is resolved during
  ClientHello/ServerHello, always before Certificate production.
- Request gains isSecure()/sslSession(), threaded through RequestParser from
  the accepted SSLSocket exactly like remoteAddress() — reference-only,
  zero per-request allocation. sslSession() defers to SSLSocket#getSession()
  lazily, so it's a cached-field read (handshake already completed by the
  time a handler can call it), never a forced handshake.
- WebSocketSession.isSecure()/sslSession() delegate to the upgrading
  Request rather than tracking the socket a second time.
- Documents TLS end-to-end in README.md (listeners, TlsConfig, SNI, ALPN,
  mTLS, Request/WebSocketSession accessors).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-09 23:34:22 +00:00
co-authored by Claude Sonnet 5
parent 7cd8b3869c
commit 1b48d14b4f
7 changed files with 468 additions and 27 deletions
@@ -19,6 +19,7 @@ import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import java.io.*;
import java.net.InetSocketAddress;
@@ -296,7 +297,8 @@ class HttpServer implements ServerHandle {
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress());
(InetSocketAddress) socket.getRemoteSocketAddress(),
socket instanceof SSLSocket sslSocket ? sslSocket : null);
while (!stopped) {
Request request = parser.parse(in);
@@ -8,6 +8,8 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLSocket;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
@@ -40,6 +42,7 @@ public class RequestParser {
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final HeaderMap headerMap = new HeaderMap();
private byte[] buffer;
@@ -48,11 +51,17 @@ public class RequestParser {
private int bufBase = 0;
private int bufLen = 0;
public RequestParser() { this(64 * 1024, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null); }
public RequestParser() { this(64 * 1024, null, null); }
public RequestParser(int maxHeaderBufferSize) { this(maxHeaderBufferSize, null, null); }
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress) {
this(maxHeaderBufferSize, remoteAddress, null);
}
/** {@code sslSocket} is {@code null} for a plain connection — see {@link Request#isSecure()}. */
public RequestParser(int maxHeaderBufferSize, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.maxHeaderBufferSize = maxHeaderBufferSize;
this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
}
@@ -177,9 +186,9 @@ public class RequestParser {
if (isChunked) {
return Request.forParsed(requestLine,
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
-1L, null, 0, 0, remoteAddress);
-1L, null, 0, 0, remoteAddress, sslSocket);
}
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress);
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
}
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
@@ -9,6 +9,9 @@ import lombok.ToString;
import lombok.Value;
import lombok.experimental.NonFinal;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
@@ -60,12 +63,30 @@ public class Request {
@ToString.Exclude
InetSocketAddress remoteAddress;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress) {
/**
* The accepted socket for this connection, or {@code null} if plain HTTP — set once per
* connection by {@link RequestParser}, same lifetime and reference-only cost as
* {@link #remoteAddress}. Every request on the same keep-alive connection shares the
* identical instance.
*
* <p>Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public
* surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()}
* rather than called here — by the time a handler can call it, the handshake this connection
* needed to reach the handler has already completed, so it is a cached-field read, never a
* forced handshake.
*/
@Getter(lombok.AccessLevel.NONE)
@EqualsAndHashCode.Exclude
@ToString.Exclude
SSLSocket sslSocket;
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.requestLine = requestLine;
this.body = body;
this.pathParams = null;
this.queryParams = null;
this.remoteAddress = remoteAddress;
this.sslSocket = sslSocket;
}
/**
@@ -75,19 +96,19 @@ public class Request {
*/
void setPathParams(PathParams p) { this.pathParams = p; }
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}. */
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
public Request(RequestLine requestLine, byte[] body) {
this(requestLine, RequestBody.of(body), null);
this(requestLine, RequestBody.of(body), null, null);
}
public static Request forParsed(RequestLine requestLine, InputStream stream,
long contentLength, byte[] headerBuf,
int bodyStart, int preBufLen,
InetSocketAddress remoteAddress) {
InetSocketAddress remoteAddress, SSLSocket sslSocket) {
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
: contentLength == 0 ? RequestBody.empty()
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
return new Request(requestLine, rb, remoteAddress);
return new Request(requestLine, rb, remoteAddress, sslSocket);
}
// ── Request line ──────────────────────────────────────────────────────────
@@ -170,6 +191,21 @@ public class Request {
*/
public InetSocketAddress remoteAddress() { return remoteAddress; }
// ── TLS ───────────────────────────────────────────────────────────────────
/** Whether this request arrived over TLS (HTTPS). */
public boolean isSecure() { return sslSocket != null; }
/**
* Returns the TLS session for this connection, or {@code null} for plain HTTP.
* Gives access to {@link SSLSession#getPeerCertificates()} (mTLS — the caller's certificate
* chain, if {@code TlsConfig.clientAuth} required or requested one), and to
* {@link SSLSession#getCipherSuite()} / {@link SSLSession#getProtocol()} for logging and
* diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} —
* check that first, or just null-check the result.
*/
public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; }
// ── Body ──────────────────────────────────────────────────────────────────
/**
@@ -27,14 +27,24 @@ import java.security.KeyStore;
* JDK's own curated default, which each JDK security release keeps current — Flash does
* not maintain its own suite allow-list.</li>
* <li>{@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used
* exactly as built: Flash never calls {@code setSSLParameters} on this path, so protocols,
* cipher suites, and ALPN (e.g. {@code acme-tls/1} for TLS-ALPN-01) you configured on it
* are 100% authoritative.</li>
* exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
* explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
* anything else you configured on it is 100% authoritative.</li>
* </ul>
*
* <p>{@link #clientAuth(ClientAuth)} applies on either path — it is an explicit instruction
* through this API, not a Flash-chosen default, so it is only ever applied when called. The
* {@link ClientAuth#NONE} default makes no client-auth call at all, on either path.
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
* either path — they are explicit instructions through this API, not Flash-chosen defaults, so
* each is only ever applied when called. Neither has a value by default, on either path.
*
* <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 ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello}
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
* — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve
* can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()}
* (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside
* {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
*/
public final class TlsConfig {
@@ -43,11 +53,13 @@ public final class TlsConfig {
private final SSLContext context;
private final boolean hardenDefaults;
private final ClientAuth clientAuth;
private final String[] applicationProtocols;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
this.applicationProtocols = applicationProtocols;
}
/**
@@ -73,20 +85,32 @@ public final class TlsConfig {
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE);
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 {@link #clientAuth}. */
/**
* Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
*/
public static TlsConfig ofContext(SSLContext context) {
return new TlsConfig(context, false, ClientAuth.NONE);
return new TlsConfig(context, false, ClientAuth.NONE, null);
}
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) {
return new TlsConfig(context, hardenDefaults, mode);
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
}
/**
* ALPN protocols this listener negotiates, in preference order (e.g.
* {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc
* for how a custom {@code KeyManager} observes the negotiated value.
*/
public TlsConfig applicationProtocols(String... protocols) {
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
}
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
@@ -96,9 +120,10 @@ public final class TlsConfig {
}
public void applyTo(SSLServerSocket socket) {
if (hardenDefaults) {
if (hardenDefaults || applicationProtocols != null) {
SSLParameters params = socket.getSSLParameters();
params.setProtocols(SECURE_PROTOCOLS);
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
socket.setSSLParameters(params);
}
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
@@ -2,6 +2,8 @@ package dev.relism.flash.websocket;
import dev.relism.flash.models.Request;
import javax.net.ssl.SSLSession;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
@@ -78,6 +80,17 @@ public final class WebSocketSession {
/** The request that upgraded this connection, or {@code null} — see the 4-arg constructor. */
public Request request() { return request; }
/**
* Whether this connection is TLS (WSS). Delegates to the upgrading {@link #request}'s
* {@link Request#isSecure()} rather than tracking the socket a second time — the request
* already carries it. {@code false} if this session has no backing request (e.g. one opened
* in WS *client* mode via the 5-arg constructor with {@code request == null}).
*/
public boolean isSecure() { return request != null && request.isSecure(); }
/** TLS session for this connection, or {@code null} for plain WS or no backing request. */
public SSLSession sslSession() { return request != null ? request.sslSession() : null; }
// ── Public send API ────────────────────────────────────────────────────
public void sendText(byte[] utf8, int off, int len) throws IOException {