feat(core): ALPN configuration and TLS visibility on Request/WebSocketSession
- 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:
co-authored by
Claude Sonnet 5
parent
7cd8b3869c
commit
1b48d14b4f
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user