feat(core): add TLS/mTLS support with multi-listener and SNI
CI / Build & Test (push) Failing after 4m54s
CI / Build & Test (pull_request) Failing after 4m54s

Flash can now serve HTTPS and WSS, on one or many listeners per app:

- FlashConfiguration gains an optional `tls` field for the single default
  listener, and a `listeners` list for apps that bind multiple ports (each
  independently plain or TLS).
- New dev.relism.flash.tls package: TlsConfig.keystore(path, password) builds
  an SSLContext from a PKCS12/JKS keystore, with SNI-based certificate
  selection for free when the keystore holds more than one alias (matched by
  SAN/CN, pure JDK APIs). TlsConfig.ofContext(sslContext) is a full escape
  hatch — Flash never calls setSSLParameters on that path, so caller-set
  protocols/cipher suites/ALPN survive untouched. TlsConfig.clientAuth(...)
  adds optional/required mTLS on either path.
- HttpServer moves from a single ServerSocket to a list of bound listeners;
  the per-request hot path (RequestParser, routing, response writing) is
  untouched — TLS only changes which bytes come out of accept(), so WSS needs
  no separate code path from WS.
- process()'s catch is widened to log non-IOException failures (e.g. a
  misbehaving custom KeyManager/TrustManager on the ofContext path) instead
  of swallowing them silently; the failure was already isolated to the one
  connection via the existing try-with-resources/executor-submission
  boundary — this only fixes the missing log line.
- Fixes a pre-existing gap where FlashConfiguration#host was accepted but
  never used to bind (listeners always bound to the wildcard address).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-09 21:41:57 +00:00
co-authored by Claude Sonnet 5
parent 524bdeb28b
commit 9f6808e90c
9 changed files with 855 additions and 28 deletions
@@ -10,6 +10,7 @@ import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.tls.TlsConfig;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
@@ -17,6 +18,8 @@ import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLServerSocket;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
@@ -24,15 +27,23 @@ import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Pure I/O transport layer. Owns the {@link ServerSocket}, the virtual-thread
* executor, and the keep-alive accept loop. Routing is delegated to HTTP and WS routers.
* Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or
* TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
* HTTP and WS routers — identically, regardless of which listener accepted the connection.
*
* <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
* {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
*
* <h3>Allocation model (unchanged)</h3>
* <ul>
@@ -77,15 +88,18 @@ class HttpServer implements ServerHandle {
// ── Instance fields ───────────────────────────────────────────────────────
private final FlashConfiguration configuration;
private final ServerSocket serverSocket;
private final List<BoundListener> boundListeners;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
/** Latch that reaches 0 when all accept threads have exited. */
private final CountDownLatch acceptLatch = new CountDownLatch(ACCEPT_THREADS);
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
private final CountDownLatch acceptLatch;
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
private record BoundListener(ServerSocket socket, boolean secure) {}
// ── Static byte constants (written once, read-only on hot path) ──────────
@@ -132,27 +146,60 @@ class HttpServer implements ServerHandle {
this.router = router;
this.wsRouter = wsRouter;
// Explicit bind with raised backlog.
// setReuseAddress(true) must be called BEFORE bind().
this.serverSocket = new ServerSocket();
this.serverSocket.setReuseAddress(true);
this.serverSocket.setReceiveBufferSize(SOCKET_BUF_SIZE);
this.serverSocket.bind(new InetSocketAddress(configuration.getPort()), ACCEPT_BACKLOG);
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
? List.of(new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
log.info("HttpServer bound on port {} (backlog={}, acceptThreads={})",
configuration.getPort(), ACCEPT_BACKLOG, ACCEPT_THREADS);
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
this.boundListeners = List.copyOf(bound);
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
for (BoundListener bl : boundListeners) {
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
ACCEPT_BACKLOG, ACCEPT_THREADS);
}
}
/**
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
* anything downstream of it, treats them.
*/
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
TlsConfig tls = spec.tls();
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
// setReuseAddress(true) must be called BEFORE bind().
socket.setReuseAddress(true);
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
if (tls != null) tls.applyTo((SSLServerSocket) socket);
InetSocketAddress addr = spec.host() != null
? new InetSocketAddress(spec.host(), spec.port())
: new InetSocketAddress(spec.port());
socket.bind(addr, ACCEPT_BACKLOG);
return new BoundListener(socket, tls != null);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
public void start() {
for (int i = 0; i < ACCEPT_THREADS; i++) {
final int idx = i;
Thread.ofPlatform()
.name("flash-accept-" + idx)
.daemon(false)
.start(this::acceptLoop);
for (int li = 0; li < boundListeners.size(); li++) {
BoundListener listener = boundListeners.get(li);
for (int i = 0; i < ACCEPT_THREADS; i++) {
Thread.ofPlatform()
.name("flash-accept-" + li + "-" + i)
.daemon(false)
.start(() -> acceptLoop(listener));
}
}
}
@@ -165,14 +212,15 @@ class HttpServer implements ServerHandle {
/**
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
* platform threads. All threads block on the same {@link ServerSocket};
* the JVM ensures only one wakes per incoming connection (no thundering herd).
* platform threads bound to one {@code listener}. All threads for that listener block on
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
* (no thundering herd). Other listeners' accept threads are entirely independent.
*/
private void acceptLoop() {
private void acceptLoop(BoundListener listener) {
try {
while (!stopped) {
try {
process(serverSocket.accept());
process(listener.socket().accept());
} catch (IOException e) {
if (!stopped) log.error("Accept error", e);
}
@@ -186,7 +234,9 @@ class HttpServer implements ServerHandle {
public CompletableFuture<Void> stop() {
return CompletableFuture.runAsync(() -> {
stopped = true;
try { serverSocket.close(); } catch (IOException e) { log.error("Error closing server socket", e); }
for (BoundListener bl : boundListeners) {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown();
try {
@@ -281,6 +331,14 @@ class HttpServer implements ServerHandle {
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 (e.g. a failed DB lookup or on-demand cert issuance).
// That failure is isolated to this one virtual thread/connection: the
// try-with-resources above still closes the socket, the finally below still
// runs, and the accept loop (a different thread entirely) never sees this.
if (!stopped) log.error("Unexpected error handling connection", e);
} finally {
activeSockets.remove(socket);
}
@@ -65,14 +65,12 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
private final List<Middleware> globalMiddlewares = new ArrayList<>();
private final List<RouteDefinition> deferredRoutes = new ArrayList<>();
private final List<WsRouteDefinition> deferredWsRoutes = new ArrayList<>();
private int port;
private record WsRouteDefinition(String path, WebSocketEndpoint endpoint) {}
private FlashApp(FlashConfiguration config) {
try {
this.server = ServerHandle.create(config, router, wsRouter);
this.port = config.getPort();
}
catch (IOException e) { throw new InitializationException("Failed to bind on port " + config.getPort(), e); }
}
@@ -157,7 +155,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
public FlashApp start() {
boot();
server.start();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
return this;
}
@@ -167,7 +165,7 @@ public final class FlashApp extends FlashRegistrar<FlashApp> {
*/
public void startAndBlock() {
boot();
if (Flash.DEV) log.info("[Flash] Started (dev mode): listening on port " + port);
if (Flash.DEV) log.info("[Flash] Started (dev mode) — see HttpServer bind logs above for listener details");
server.startAndBlock();
}
@@ -1,8 +1,13 @@
package dev.relism.flash.extension;
import dev.relism.flash.tls.TlsConfig;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
* Configuration for a {@link FlashApp} instance.
*
@@ -16,6 +21,18 @@ import lombok.Value;
* .host("127.0.0.1")
* .maxHeaderBufferSize(128 * 1024)
* .build());
*
* // TLS on the single default listener
* FlashApp.create(FlashConfiguration.builder()
* .port(443)
* .tls(TlsConfig.keystore(Path.of("cert.p12"), "changeit"))
* .build());
*
* // Multiple listeners on one app — takes precedence over port/host/tls above
* FlashApp.create(FlashConfiguration.builder()
* .listener(new FlashConfiguration.Listener(80))
* .listener(new FlashConfiguration.Listener(443, TlsConfig.keystore(Path.of("cert.p12"), "changeit")))
* .build());
* }</pre>
*/
@Value
@@ -25,6 +42,13 @@ public class FlashConfiguration {
int port;
String host;
/** 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
List<Listener> listeners;
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default
int maxHeaderBufferSize = 64 * 1024;
@@ -32,4 +56,10 @@ public class FlashConfiguration {
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default
int wsFrameBufferSize = 64 * 1024;
/** 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) {
public Listener(int port) { this(port, null, null); }
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
}
}
@@ -0,0 +1,14 @@
package dev.relism.flash.tls;
/**
* Client-certificate requirement for a TLS listener, applied via
* {@link TlsConfig#clientAuth(ClientAuth)}.
*/
public enum ClientAuth {
/** No client certificate requested. Default — Flash makes no client-auth call at all. */
NONE,
/** Client certificate requested; handshake still succeeds if the client presents none. */
OPTIONAL,
/** Handshake fails unless the client presents a certificate trusted by this listener. */
REQUIRE
}
@@ -0,0 +1,126 @@
package dev.relism.flash.tls;
import javax.net.ssl.ExtendedSSLSession;
import javax.net.ssl.SNIHostName;
import javax.net.ssl.SNIServerName;
import javax.net.ssl.SSLEngine;
import javax.net.ssl.SSLSession;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.X509ExtendedKeyManager;
import java.net.Socket;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.Principal;
import java.security.PrivateKey;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
/**
* Wraps a keystore's default {@link X509ExtendedKeyManager} with SNI-based alias selection:
* every alias's certificate is inspected for its Subject Alternative Names (Common Name as
* fallback) so a keystore holding one entry per domain serves the right certificate per
* {@code ClientHello} — no explicit hostname-to-alias mapping needed from the caller.
*
* <p>{@link #defaultAlias} is simply the keystore's first key entry — same convention as
* nginx/HAProxy's {@code default_server}: used when the client sends no SNI, or an SNI name
* that matches nothing here.
*
* <p>Only server-alias selection is overridden; every other {@link X509ExtendedKeyManager}
* method (client aliases, certificate chains, private keys) delegates unchanged.
*/
final class SniKeyManager extends X509ExtendedKeyManager {
private final X509ExtendedKeyManager delegate;
private final Map<String, String> aliasByHostname;
private final String defaultAlias;
SniKeyManager(X509ExtendedKeyManager delegate, KeyStore keyStore) throws KeyStoreException {
this.delegate = delegate;
Map<String, String> byHostname = new HashMap<>();
String first = null;
for (String alias : Collections.list(keyStore.aliases())) {
if (!keyStore.isKeyEntry(alias)) continue;
if (first == null) first = alias;
Certificate cert = keyStore.getCertificate(alias);
if (cert instanceof X509Certificate x509) {
for (String host : hostnamesOf(x509)) byHostname.putIfAbsent(host, alias);
}
}
this.aliasByHostname = byHostname;
this.defaultAlias = first;
}
/** DNS SANs (preferred) or, failing that, the certificate's CN — all lower-cased for matching. */
private static List<String> hostnamesOf(X509Certificate cert) {
List<String> names = new ArrayList<>();
try {
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
if (sans != null) {
for (List<?> san : sans) {
if (san.get(0).equals(2)) // dNSName, see X509Certificate#getSubjectAlternativeNames
names.add(san.get(1).toString().toLowerCase(Locale.ROOT));
}
}
} catch (CertificateParsingException ignored) {
// Fall through to the CN below.
}
if (names.isEmpty()) {
for (String part : cert.getSubjectX500Principal().getName().split(",")) {
part = part.trim();
if (part.regionMatches(true, 0, "CN=", 0, 3)) {
names.add(part.substring(3).toLowerCase(Locale.ROOT));
break;
}
}
}
return names;
}
@Override
public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) {
return resolve(engine.getHandshakeSession());
}
@Override
public String chooseServerAlias(String keyType, Principal[] issuers, Socket socket) {
return resolve(socket instanceof SSLSocket ssl ? ssl.getHandshakeSession() : null);
}
/**
* Every TLS implementation in practice sends at most one {@code server_name} entry (RFC 6066
* permits a list, but only the {@code host_name} type exists and clients send zero or one of
* it), so indexing {@code names.get(0)} directly — rather than a for-each, which would
* allocate an {@link java.util.Iterator} per handshake — is both correct and allocation-free
* on the JDK's own {@code List.copyOf}-backed {@link SSLSession#getRequestedServerNames()},
* deterministically rather than relying on the JIT to prove the iterator never escapes.
*/
private String resolve(SSLSession session) {
if (session instanceof ExtendedSSLSession ext) {
List<SNIServerName> names = ext.getRequestedServerNames();
if (!names.isEmpty() && names.get(0) instanceof SNIHostName host) {
String alias = aliasByHostname.get(host.getAsciiName().toLowerCase(Locale.ROOT));
if (alias != null) return alias;
}
}
return defaultAlias;
}
// ── Delegated — this class only changes server-alias selection ─────────────
@Override public String[] getClientAliases(String keyType, Principal[] issuers) { return delegate.getClientAliases(keyType, issuers); }
@Override public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { return delegate.chooseClientAlias(keyType, issuers, socket); }
@Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { return delegate.chooseEngineClientAlias(keyType, issuers, engine); }
@Override public String[] getServerAliases(String keyType, Principal[] issuers) { return delegate.getServerAliases(keyType, issuers); }
@Override public X509Certificate[] getCertificateChain(String alias) { return delegate.getCertificateChain(alias); }
@Override public PrivateKey getPrivateKey(String alias) { return delegate.getPrivateKey(alias); }
}
@@ -0,0 +1,107 @@
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.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
/**
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
*
* <h3>Two ways in</h3>
* <ul>
* <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
* for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
* 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>
* </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.
*/
public final class TlsConfig {
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
private final SSLContext context;
private final boolean hardenDefaults;
private final ClientAuth clientAuth;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
}
/**
* 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
* assumed equal to the store password, the common case for PKCS12.
*/
public static TlsConfig keystore(Path path, String password) {
try {
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
try (InputStream in = Files.newInputStream(path)) {
store.load(in, password.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, password.toCharArray());
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) {
managers[i] = new SniKeyManager(x509, store);
}
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE);
} 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}. */
public static TlsConfig ofContext(SSLContext context) {
return new TlsConfig(context, false, ClientAuth.NONE);
}
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) {
return new TlsConfig(context, hardenDefaults, mode);
}
// ── Consumed by HttpServer at bind time — not meant for direct use ──────────
public SSLServerSocketFactory serverSocketFactory() {
return context.getServerSocketFactory();
}
public void applyTo(SSLServerSocket socket) {
if (hardenDefaults) {
SSLParameters params = socket.getSSLParameters();
params.setProtocols(SECURE_PROTOCOLS);
socket.setSSLParameters(params);
}
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
}
}