From a7e25f405e1a36909fcde98b84fc0a4527a3144a Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:22:38 +0200 Subject: [PATCH 01/15] feat(tls-edge): accept-time SNI split with opaque L4 passthrough relay (D1) Add the Vert.x NetServer front listener that reassembles the full TLS ClientHello (RFC 6066), reads the SNI, and either opaquely L4-relays a passthrough_sni match to the topology-resolved backend (the gateway never handshakes) or hands the still-encrypted stream to the internal terminated Quarkus HTTPS listener. Fail-closed per GW-06: empty/unresolved/malformed SNI takes the terminated-strict path. Exposes the resolved topology as a CDI bean, adds relay-lifecycle INFO and fail-closed WARN LogRecords, and reserves the public TLS port. Unit tests cover ClientHello reassembly/SNI extraction, front-listener routing, and L4 relay byte-fidelity/backpressure/half-close/abort. --- .../gateway/ApiSheriffLogMessages.java | 27 +- .../gateway/quarkus/ConfigProducer.java | 22 ++ .../gateway/tls/ClientHelloSniParser.java | 322 ++++++++++++++++++ .../sheriff/gateway/tls/PassthroughRelay.java | 195 +++++++++++ .../sheriff/gateway/tls/SniFrontListener.java | 198 +++++++++++ .../sheriff/gateway/tls/TlsEdgeProducer.java | 160 +++++++++ .../sheriff/gateway/tls/package-info.java | 36 ++ .../src/main/resources/application.properties | 12 + .../gateway/tls/ClientHelloSniParserTest.java | 320 +++++++++++++++++ .../gateway/tls/PassthroughRelayTest.java | 181 ++++++++++ .../gateway/tls/SniFrontListenerTest.java | 162 +++++++++ doc/LogMessages.adoc | 3 + 12 files changed, 1637 insertions(+), 1 deletion(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/package-info.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java index 3732b4d7..b30f59d1 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java @@ -28,7 +28,7 @@ * assertable. This catalogue's identifier ranges are disjoint from * {@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}'s (the boot-time configuration * subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} / - * {@code 100} / {@code 103-106} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} + * {@code 6-7} / {@code 100} / {@code 103-107} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} * there — never renumber one catalogue without checking the other for a collision. * Security-relevant {@code WARN}s record only the failure type and route id — * never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger @@ -61,6 +61,20 @@ public static final class INFO { .identifier(4) .template("WebSocket relay established for route '%s'") .build(); + + /** An opaque L4 passthrough relay to the resolved backend was established for a mapped SNI. */ + public static final LogRecord PASSTHROUGH_RELAY_ESTABLISHED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(6) + .template("Passthrough relay established for SNI '%s' to upstream '%s'") + .build(); + + /** The accept-time SNI front listener bound the public TLS port at boot. */ + public static final LogRecord SNI_FRONT_LISTENER_STARTED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(7) + .template("Accept-time SNI front listener started on port %s (%s passthrough SNI mapping(s))") + .build(); } /** @@ -110,5 +124,16 @@ public static final class WARN { .identifier(106) .template("WebSocket relay on route '%s' reclaimed after idle timeout of %s seconds") .build(); + + /** + * A TLS ClientHello was failed closed to the terminated-strict path (GW-06): it carried no + * usable SNI, was malformed, or exceeded the reassembly bound. Records the disposition only — + * never the raw ClientHello bytes. + */ + public static final LogRecord CLIENT_HELLO_FAIL_CLOSED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(107) + .template("TLS ClientHello failed closed to terminated path: %s") + .build(); } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/ConfigProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/ConfigProducer.java index d1edf7ff..8acff3d2 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/ConfigProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/quarkus/ConfigProducer.java @@ -76,6 +76,7 @@ public class ConfigProducer { private GatewayConfig gateway; private RouteTable routeTable; + private ResolvedTopology resolvedTopology; private boolean built; /** @@ -122,6 +123,26 @@ public RouteTable routeTable() { return routeTable; } + /** + * Produces the immutable, fully-resolved topology assembled at boot: every topology alias + * referenced by an enabled endpoint or a {@code tls.passthrough_sni} target, decomposed into its + * upstream endpoint. Published (rather than recomputed) so {@code tls.TlsEdgeProducer} can build + * the accept-time SNI relay map from the same resolved data the validator already accepted. + *

+ * {@link Singleton} (a pseudo-scope, no client proxy) because {@link ResolvedTopology} is a + * {@code record}: ArC cannot subclass a final type to build the proxy a normal scope such as + * {@code @ApplicationScoped} would require. The bean is immutable and assembled once at boot, so + * a single instance is exact. + * + * @return the immutable {@link ResolvedTopology} + */ + @Produces + @Singleton + public ResolvedTopology resolvedTopology() { + buildOnce(); + return resolvedTopology; + } + private synchronized void buildOnce() { if (built) { return; @@ -138,6 +159,7 @@ private synchronized void buildOnce() { abort(violations); } this.gateway = loaded.gateway(); + this.resolvedTopology = topology; this.routeTable = new RouteTableBuilder().build(loaded.gateway(), enabled, topology); this.built = true; LOGGER.info(ConfigLogMessages.INFO.CONFIG_LOADED, configVersion(gateway)); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java new file mode 100644 index 00000000..eb79c0c4 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java @@ -0,0 +1,322 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; + +/** + * A framework-agnostic parser that reassembles the full TLS ClientHello (RFC 6066) across TCP + * fragments and extracts the SNI {@code host_name}, without any decryption. + *

+ * The accept-time SNI split (GW-06 fail-closed) must decide the route from bytes the client sends + * before any handshake completes. The parser is fed the connection bytes accumulated so + * far and returns one of two verdicts: + *

+ * The parser is pure (no I/O, no framework types) and therefore unit-testable byte-for-byte + * independently of the Vert.x front listener. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class ClientHelloSniParser { + + /** TLS {@code handshake} record content type (RFC 8446 §5.1). */ + private static final int RECORD_TYPE_HANDSHAKE = 0x16; + /** {@code client_hello} handshake message type (RFC 8446 §4). */ + private static final int HANDSHAKE_TYPE_CLIENT_HELLO = 0x01; + /** {@code server_name} extension type (RFC 6066 §3). */ + private static final int EXTENSION_TYPE_SERVER_NAME = 0x0000; + /** {@code host_name} name type inside the {@code server_name} list (RFC 6066 §3). */ + private static final int NAME_TYPE_HOST_NAME = 0x00; + + private static final int RECORD_HEADER_LENGTH = 5; + private static final int HANDSHAKE_HEADER_LENGTH = 4; + private static final int CLIENT_HELLO_FIXED_PREFIX = 2 + 32; // legacy_version + random + private static final int UINT8_MASK = 0xFF; + + /** + * The hard upper bound on the bytes buffered while reassembling a single ClientHello. A TLS + * record body is capped at 2^14 bytes; a legitimate ClientHello (even one spread across a few + * records with many extensions) stays comfortably inside this bound. Anything larger is treated + * as complete-with-no-SNI (fail closed) rather than buffered further. + */ + public static final int MAX_CLIENT_HELLO_BYTES = 32 * 1024; + + /** + * Parses the accumulated connection bytes. + * + * @param bytes the bytes received from the client so far, never {@code null} + * @return {@link Result#needMoreData()} when the ClientHello is still incomplete (and within the + * size bound), otherwise a {@link Result#parsed(Optional)} verdict whose + * {@link Result#serverName()} is present only when a usable SNI was extracted + */ + public Result parse(byte[] bytes) { + ByteArrayOutputStream handshake = new ByteArrayOutputStream(); + int pos = 0; + while (true) { + if (bytes.length - pos < RECORD_HEADER_LENGTH) { + return overBound(pos) ? Result.parsed(Optional.empty()) : Result.needMoreData(); + } + if ((bytes[pos] & UINT8_MASK) != RECORD_TYPE_HANDSHAKE) { + // Not a TLS handshake record — fail closed to the terminated-strict path. + return Result.parsed(Optional.empty()); + } + int recordLength = uint16(bytes, pos + 3); + int recordEnd = pos + RECORD_HEADER_LENGTH + recordLength; + if (recordEnd > MAX_CLIENT_HELLO_BYTES) { + return Result.parsed(Optional.empty()); + } + if (bytes.length < recordEnd) { + return Result.needMoreData(); + } + handshake.write(bytes, pos + RECORD_HEADER_LENGTH, recordLength); + pos = recordEnd; + + byte[] handshakeBytes = handshake.toByteArray(); + HandshakeSpan span = completeHandshake(handshakeBytes); + if (span == HandshakeSpan.MALFORMED) { + return Result.parsed(Optional.empty()); + } + if (span == HandshakeSpan.COMPLETE) { + return Result.parsed(extractServerName(handshakeBytes)); + } + // span == INCOMPLETE: keep reading records if any remain, else ask for more bytes. + if (bytes.length - pos < RECORD_HEADER_LENGTH) { + return overBound(pos) ? Result.parsed(Optional.empty()) : Result.needMoreData(); + } + } + } + + private static boolean overBound(int pos) { + return pos >= MAX_CLIENT_HELLO_BYTES; + } + + /** + * Classifies the reassembled handshake bytes against the fixed handshake header: complete once + * the full {@code client_hello} body is present, malformed when it is a different handshake type, + * incomplete while the body has not fully arrived. + */ + private static HandshakeSpan completeHandshake(byte[] handshake) { + if (handshake.length < HANDSHAKE_HEADER_LENGTH) { + return HandshakeSpan.INCOMPLETE; + } + if ((handshake[0] & UINT8_MASK) != HANDSHAKE_TYPE_CLIENT_HELLO) { + return HandshakeSpan.MALFORMED; + } + int bodyLength = uint24(handshake, 1); + int total = HANDSHAKE_HEADER_LENGTH + bodyLength; + if (total > MAX_CLIENT_HELLO_BYTES) { + return HandshakeSpan.MALFORMED; + } + return handshake.length >= total ? HandshakeSpan.COMPLETE : HandshakeSpan.INCOMPLETE; + } + + /** + * Walks the fully-reassembled {@code client_hello} body to the {@code server_name} extension and + * returns the first {@code host_name}. Any structural inconsistency (a declared length that runs + * past the buffer) yields an empty result so the caller fails closed. + */ + private static Optional extractServerName(byte[] handshake) { + Cursor cursor = new Cursor(handshake, HANDSHAKE_HEADER_LENGTH); + try { + cursor.skip(CLIENT_HELLO_FIXED_PREFIX); + cursor.skip(cursor.readUint8()); // session_id + cursor.skip(cursor.readUint16()); // cipher_suites + cursor.skip(cursor.readUint8()); // compression_methods + if (cursor.remaining() == 0) { + return Optional.empty(); // no extensions block → no SNI + } + int extensionsLength = cursor.readUint16(); + int extensionsEnd = cursor.position() + extensionsLength; + if (extensionsEnd > handshake.length) { + return Optional.empty(); + } + while (cursor.position() < extensionsEnd) { + int type = cursor.readUint16(); + int length = cursor.readUint16(); + int next = cursor.position() + length; + if (type == EXTENSION_TYPE_SERVER_NAME) { + return readServerNameExtension(new Cursor(handshake, cursor.position()), length); + } + cursor.seek(next); + } + return Optional.empty(); + } catch (MalformedHelloException e) { + return Optional.empty(); + } + } + + /** + * Reads the {@code server_name} extension body: a 2-byte list length, then entries each of + * {@code name_type(1)} + {@code length(2)} + name. Returns the first {@code host_name}. + */ + private static Optional readServerNameExtension(Cursor cursor, int extensionLength) { + int extensionEnd = cursor.position() + extensionLength; + if (extensionLength < 2) { + return Optional.empty(); + } + int listLength = cursor.readUint16(); + int listEnd = cursor.position() + listLength; + if (listEnd > extensionEnd) { + return Optional.empty(); + } + while (cursor.position() < listEnd) { + int nameType = cursor.readUint8(); + int nameLength = cursor.readUint16(); + if (cursor.position() + nameLength > listEnd) { + return Optional.empty(); + } + if (nameType == NAME_TYPE_HOST_NAME) { + String host = cursor.readString(nameLength); + return host.isEmpty() ? Optional.empty() : Optional.of(host); + } + cursor.skip(nameLength); + } + return Optional.empty(); + } + + private static int uint16(byte[] data, int offset) { + return ((data[offset] & UINT8_MASK) << 8) | (data[offset + 1] & UINT8_MASK); + } + + private static int uint24(byte[] data, int offset) { + return ((data[offset] & UINT8_MASK) << 16) + | ((data[offset + 1] & UINT8_MASK) << 8) + | (data[offset + 2] & UINT8_MASK); + } + + /** Whether the handshake body is complete, still arriving, or of the wrong handshake type. */ + private enum HandshakeSpan { + COMPLETE, INCOMPLETE, MALFORMED + } + + /** A bounds-checked forward cursor over the reassembled handshake bytes. */ + private static final class Cursor { + + private final byte[] data; + private int position; + + Cursor(byte[] data, int position) { + this.data = data; + this.position = position; + } + + int position() { + return position; + } + + int remaining() { + return data.length - position; + } + + void require(int count) { + if (count < 0 || position + count > data.length) { + throw new MalformedHelloException(); + } + } + + int readUint8() { + require(1); + return data[position++] & UINT8_MASK; + } + + int readUint16() { + require(2); + int value = uint16(data, position); + position += 2; + return value; + } + + String readString(int length) { + require(length); + String value = new String(data, position, length, StandardCharsets.US_ASCII); + position += length; + return value; + } + + void skip(int count) { + require(count); + position += count; + } + + void seek(int target) { + if (target < position || target > data.length) { + throw new MalformedHelloException(); + } + position = target; + } + } + + /** Raised internally when a declared length runs past the reassembled handshake buffer. */ + private static final class MalformedHelloException extends RuntimeException { + + @java.io.Serial + private static final long serialVersionUID = 1L; + + MalformedHelloException() { + super(null, null, false, false); + } + } + + /** + * The verdict of a single {@link ClientHelloSniParser#parse(byte[])} call. + * + * @param complete {@code true} once a full ClientHello has been observed; {@code false} means + * more bytes are needed + * @param serverName the extracted SNI {@code host_name} when {@code complete} and a usable SNI + * was present, otherwise empty + * @author API Sheriff Team + * @since 1.0 + */ + public record Result(boolean complete, Optional serverName) { + + /** Canonical constructor normalizing an absent {@code serverName} to {@link Optional#empty()}. */ + public Result { + serverName = serverName == null ? Optional.empty() : serverName; + } + + /** + * The "keep buffering" verdict: the ClientHello is not yet complete. + * + * @return an incomplete result carrying no server name + */ + public static Result needMoreData() { + return new Result(false, Optional.empty()); + } + + /** + * The "ClientHello complete" verdict. + * + * @param serverName the extracted SNI, or empty to fail closed to the terminated path + * @return a complete result carrying the supplied server name + */ + public static Result parsed(Optional serverName) { + return new Result(true, serverName); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java new file mode 100644 index 00000000..20ac1e84 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java @@ -0,0 +1,195 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import java.util.Objects; + +import de.cuioss.sheriff.gateway.ApiSheriffLogMessages; +import de.cuioss.tools.logging.CuiLogger; + +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetSocket; + +/** + * The opaque L4 TCP pipe: a {@link NetSocket} ↔ {@link NetSocket} relay, backpressured in both + * directions, with half-close and abort propagation. + *

+ * The relay dials the resolved backend over a plain TCP {@link NetClient} — it never speaks TLS — + * replays the already-buffered ClientHello bytes verbatim, and then pipes every subsequent byte + * transparently. The gateway therefore never participates in the handshake: the backend presents + * its own certificate directly to the client, end-to-end. Backpressure follows the Vert.x + * write-queue contract (pause the busy source until the target drains); a graceful {@code FIN} on + * either leg is propagated as a half-close, and an exception on either leg aborts both. + *

+ * The same pipe backs both branches of the {@link SniFrontListener}: a {@code passthrough_sni} match + * relays to the topology-resolved backend, and a terminated (non-passthrough) connection relays to + * the internal Quarkus HTTPS listener — the only difference is the target and the lifecycle log + * level. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class PassthroughRelay { + + private static final CuiLogger LOGGER = new CuiLogger(PassthroughRelay.class); + + private final NetClient netClient; + + /** + * @param netClient the shared plain-TCP client the relay dials the backend with (no TLS) + */ + public PassthroughRelay(NetClient netClient) { + this.netClient = Objects.requireNonNull(netClient, "netClient"); + } + + /** + * Dials the target and, on success, replays the buffered ClientHello and establishes the opaque + * bidirectional pipe. On a dial failure the accepted client connection is closed (abort, never a + * hang). + * + * @param client the accepted client connection, already paused by the caller + * @param clientHello the bytes buffered while the SNI was being read, replayed to the backend + * first so the handshake is byte-identical + * @param target the resolved backend endpoint to relay to + * @param kind whether the connection is a passthrough match or a terminated hand-off, + * controlling the lifecycle log level + * @param sniLabel the matched SNI hostname for a passthrough relay's audit log ({@code kind == + * PASSTHROUGH}); ignored for a terminated relay + */ + public void relay(NetSocket client, Buffer clientHello, RelayTarget target, RelayKind kind, String sniLabel) { + Objects.requireNonNull(client, "client"); + Objects.requireNonNull(clientHello, "clientHello"); + Objects.requireNonNull(target, "target"); + Objects.requireNonNull(kind, "kind"); + Objects.requireNonNull(sniLabel, "sniLabel"); + netClient.connect(target.port(), target.host()) + .onSuccess(backend -> establish(client, backend, clientHello, target, kind, sniLabel)) + .onFailure(failure -> { + LOGGER.debug(failure, "Relay backend dial to %s failed: %s", target, failure.getMessage()); + closeQuietly(client); + }); + } + + private static void establish(NetSocket client, NetSocket backend, Buffer clientHello, RelayTarget target, + RelayKind kind, String sniLabel) { + if (kind == RelayKind.PASSTHROUGH) { + LOGGER.info(ApiSheriffLogMessages.INFO.PASSTHROUGH_RELAY_ESTABLISHED, sniLabel, target.toString()); + } else { + LOGGER.debug("Terminated relay established to %s", target); + } + new RelaySession(client, backend).start(clientHello); + } + + private static void closeQuietly(NetSocket socket) { + socket.close().onFailure(failure -> + LOGGER.debug(failure, "Relay socket close failed: %s", failure.getMessage())); + } + + /** + * A resolved backend endpoint the relay dials at L4. + * + * @param host the backend host + * @param port the backend port + * @author API Sheriff Team + * @since 1.0 + */ + public record RelayTarget(String host, int port) { + + /** Canonical constructor requiring a non-null {@code host}. */ + public RelayTarget { + Objects.requireNonNull(host, "host"); + } + + @Override + public String toString() { + return host + ":" + port; + } + } + + /** Whether a relayed connection is a passthrough SNI match or a terminated hand-off. */ + public enum RelayKind { + /** The SNI matched {@code tls.passthrough_sni}; relayed opaquely to the resolved backend. */ + PASSTHROUGH, + /** No passthrough match; relayed to the internal terminated Quarkus HTTPS listener. */ + TERMINATED + } + + /** + * One established relay: the two legs and their bidirectional, backpressured wiring. Every + * callback runs on the accepted connection's Vert.x event loop, so the {@code closed} flag is + * single-threaded and needs no synchronization. + */ + private static final class RelaySession { + + private final NetSocket client; + private final NetSocket backend; + private boolean closed; + + RelaySession(NetSocket client, NetSocket backend) { + this.client = client; + this.backend = backend; + } + + void start(Buffer clientHello) { + wire(client, backend); + wire(backend, client); + client.closeHandler(v -> closeBoth()); + backend.closeHandler(v -> closeBoth()); + client.exceptionHandler(this::abort); + backend.exceptionHandler(this::abort); + if (clientHello.length() > 0) { + backend.write(clientHello); + } + client.resume(); + } + + private void wire(NetSocket source, NetSocket target) { + source.handler(buffer -> { + if (closed) { + return; + } + target.write(buffer); + if (target.writeQueueFull()) { + source.pause(); + target.drainHandler(v -> source.resume()); + } + }); + // A graceful FIN on the read side is propagated as a half-close on the peer's write side. + source.endHandler(v -> target.end() + .onFailure(failure -> LOGGER.debug(failure, "Relay half-close failed: %s", failure.getMessage()))); + } + + private void abort(Throwable failure) { + LOGGER.debug(failure, "Passthrough relay error: %s", failure.getMessage()); + closeBoth(); + } + + private void closeBoth() { + if (closed) { + return; + } + closed = true; + closeLeg(client); + closeLeg(backend); + } + + private static void closeLeg(NetSocket socket) { + socket.close().onFailure(failure -> + LOGGER.debug(failure, "Relay leg close failed: %s", failure.getMessage())); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java new file mode 100644 index 00000000..49a9577d --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java @@ -0,0 +1,198 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; + +import de.cuioss.sheriff.gateway.ApiSheriffLogMessages; +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayKind; +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; +import de.cuioss.tools.logging.CuiLogger; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetSocket; + +/** + * The Vert.x {@link NetServer} front, bound to the public TLS port, that performs the accept-time + * SNI split at L4. + *

+ * The front is a plain-TCP server: the TLS ClientHello is sent in cleartext at the record layer, so + * no decryption is needed to read the SNI. On each accepted connection the listener buffers bytes + * until the {@link ClientHelloSniParser} reports a complete ClientHello, then routes: + *

    + *
  • a case-insensitively matched {@code passthrough_sni} SNI hands the connection — buffered + * ClientHello included — to the opaque {@link PassthroughRelay} targeting the resolved backend; the + * gateway never handshakes;
  • + *
  • an SNI that is not in the passthrough set relays the still-encrypted stream to the internal + * terminated Quarkus HTTPS listener;
  • + *
  • a missing / malformed SNI fails closed (GW-06): it takes the same terminated-strict path and + * an audited {@code WARN} records the disposition only, never raw bytes.
  • + *
+ * The listener is constructed and started only when {@code tls.passthrough_sni} is non-empty (see + * {@link TlsEdgeProducer}); when passthrough is unconfigured the front is never created, so the + * default single-listener topology is unchanged. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class SniFrontListener { + + private static final CuiLogger LOGGER = new CuiLogger(SniFrontListener.class); + + private final Vertx vertx; + private final PassthroughRelay relay; + private final ClientHelloSniParser parser; + private final Map passthroughTargets; + private final RelayTarget terminatedTarget; + private final int publicPort; + + private @org.jspecify.annotations.Nullable NetServer server; + + /** + * @param vertx the managed Vert.x instance the front server is created on + * @param relay the opaque L4 relay both routing branches hand connections to + * @param passthroughTargets the immutable SNI (normalized, lower-cased) → backend map; a match + * relays opaquely to the backend + * @param terminatedTarget the internal terminated Quarkus HTTPS endpoint every non-passthrough + * connection is relayed to + * @param publicPort the public TLS port the front server binds + */ + public SniFrontListener(Vertx vertx, PassthroughRelay relay, Map passthroughTargets, + RelayTarget terminatedTarget, int publicPort) { + this.vertx = Objects.requireNonNull(vertx, "vertx"); + this.relay = Objects.requireNonNull(relay, "relay"); + this.parser = new ClientHelloSniParser(); + this.passthroughTargets = Map.copyOf(Objects.requireNonNull(passthroughTargets, "passthroughTargets")); + this.terminatedTarget = Objects.requireNonNull(terminatedTarget, "terminatedTarget"); + this.publicPort = publicPort; + } + + /** + * Creates the {@link NetServer}, wires the connect handler, and binds the public TLS port. + * + * @return a future completing when the front server is listening, or failing when the bind fails + */ + public Future start() { + NetServer netServer = vertx.createNetServer(); + netServer.connectHandler(this::onConnect); + this.server = netServer; + return netServer.listen(publicPort) + .onSuccess(bound -> LOGGER.info(ApiSheriffLogMessages.INFO.SNI_FRONT_LISTENER_STARTED, + Integer.toString(publicPort), Integer.toString(passthroughTargets.size()))) + .mapEmpty(); + } + + /** + * Closes the front server, if started. + * + * @return a future completing when the server is closed + */ + public Future stop() { + NetServer current = server; + return current == null ? Future.succeededFuture() : current.close(); + } + + /** + * The port the front server is actually bound to. When the configured port is {@code 0} the + * server binds an ephemeral port, and this returns the concrete port it resolved to. + * + * @return the bound port, or {@code -1} when the server has not been started + */ + public int actualPort() { + NetServer current = server; + return current == null ? -1 : current.actualPort(); + } + + private void onConnect(NetSocket socket) { + new Incoming(socket).begin(); + } + + /** + * Normalizes an SNI hostname for case-insensitive, root-dot-insensitive exact matching: lowered + * (root locale), stripped, and with a single trailing FQDN dot removed. Shared by + * {@link TlsEdgeProducer} when it builds the relay-map keys so lookup and insertion agree. + * + * @param host the raw SNI hostname + * @return the normalized matching key + */ + static String normalizeSni(String host) { + String lower = host.toLowerCase(Locale.ROOT).strip(); + return lower.endsWith(".") ? lower.substring(0, lower.length() - 1) : lower; + } + + /** + * One accepted connection while its ClientHello is being reassembled. All callbacks run on the + * connection's Vert.x event loop, so {@code buffer} and {@code decided} are single-threaded. + */ + private final class Incoming { + + private final NetSocket socket; + private final Buffer buffer = Buffer.buffer(); + private boolean decided; + + Incoming(NetSocket socket) { + this.socket = socket; + } + + void begin() { + socket.handler(this::onData); + socket.exceptionHandler(this::onError); + } + + private void onData(Buffer chunk) { + if (decided) { + return; + } + buffer.appendBuffer(chunk); + ClientHelloSniParser.Result result = parser.parse(buffer.getBytes()); + if (!result.complete()) { + return; + } + decided = true; + socket.pause(); + route(result.serverName()); + } + + private void onError(Throwable failure) { + if (decided) { + return; + } + LOGGER.debug(failure, "Front connection failed before ClientHello was read: %s", failure.getMessage()); + socket.close(); + } + + private void route(Optional serverName) { + if (serverName.isEmpty()) { + LOGGER.warn(ApiSheriffLogMessages.WARN.CLIENT_HELLO_FAIL_CLOSED, "no-usable-sni"); + relay.relay(socket, buffer, terminatedTarget, RelayKind.TERMINATED, ""); + return; + } + String sni = serverName.get(); + RelayTarget target = passthroughTargets.get(normalizeSni(sni)); + if (target != null) { + relay.relay(socket, buffer, target, RelayKind.PASSTHROUGH, sni); + } else { + relay.relay(socket, buffer, terminatedTarget, RelayKind.TERMINATED, ""); + } + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java new file mode 100644 index 00000000..061dbb62 --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java @@ -0,0 +1,160 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.ResolvedTopology; +import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; +import de.cuioss.tools.logging.CuiLogger; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.vertx.core.Vertx; +import io.vertx.core.net.NetClient; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jspecify.annotations.Nullable; + +/** + * The framework-bound boot wiring for the accept-time TLS edge: it builds the immutable + * SNI → {@code host:port} relay map from {@link GatewayConfig#tls()} and the resolved topology, then + * — only when {@code tls.passthrough_sni} is non-empty — creates and starts the + * {@link SniFrontListener} on the managed {@link Vertx}. + *

+ * Each {@code passthrough_sni} entry maps an SNI hostname to a topology alias; the alias is resolved + * through {@link ResolvedTopology} to a concrete backend endpoint. Per ADR-0009 the passthrough + * alias resolution is asymmetric — an alias absent from the resolved map means the + * {@link de.cuioss.sheriff.gateway.config.validation.ConfigValidator} already failed the boot — so a + * present entry that unexpectedly fails to resolve is skipped defensively rather than aborting here. + *

+ * When passthrough is unconfigured the front listener is never created: the default single-listener + * topology (terminated Quarkus HTTPS on the public port) is unchanged, at zero runtime overhead. A + * bind failure of the front listener fails the boot (fail fast), so the gateway never serves on a + * half-open TLS edge. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class TlsEdgeProducer { + + private static final CuiLogger LOGGER = new CuiLogger(TlsEdgeProducer.class); + private static final String TERMINATED_HOST = "localhost"; + private static final int BIND_TIMEOUT_SECONDS = 15; + + private final Vertx vertx; + private final GatewayConfig gatewayConfig; + private final ResolvedTopology topology; + private final int publicPort; + private final int internalHttpsPort; + + private @Nullable NetClient netClient; + private @Nullable SniFrontListener listener; + + /** + * @param vertx the managed Vert.x instance the front listener and backend client run on + * @param gatewayConfig the bound global gateway document (source of {@code tls.passthrough_sni}) + * @param topology the resolved topology the passthrough aliases are looked up against + * @param publicPort the public TLS port the front listener binds when passthrough is active + * @param internalHttpsPort the internal loopback port the terminated Quarkus HTTPS listener owns + */ + @Inject + public TlsEdgeProducer(Vertx vertx, GatewayConfig gatewayConfig, ResolvedTopology topology, + @ConfigProperty(name = "sheriff.tls.public-port", defaultValue = "8443") int publicPort, + @ConfigProperty(name = "sheriff.tls.internal-https-port", defaultValue = "8444") int internalHttpsPort) { + this.vertx = Objects.requireNonNull(vertx, "vertx"); + this.gatewayConfig = Objects.requireNonNull(gatewayConfig, "gatewayConfig"); + this.topology = Objects.requireNonNull(topology, "topology"); + this.publicPort = publicPort; + this.internalHttpsPort = internalHttpsPort; + } + + /** + * Builds the relay map and, when passthrough is configured, starts the front listener — blocking + * the boot until the public port is bound so a bind failure aborts startup. + * + * @param event the Quarkus startup event + */ + void onStartup(@Observes StartupEvent event) { + Map targets = buildRelayMap(); + if (targets.isEmpty()) { + LOGGER.debug("tls.passthrough_sni is empty — accept-time SNI front listener not started"); + return; + } + this.netClient = vertx.createNetClient(); + PassthroughRelay relay = new PassthroughRelay(netClient); + RelayTarget terminatedTarget = new RelayTarget(TERMINATED_HOST, internalHttpsPort); + SniFrontListener front = new SniFrontListener(vertx, relay, targets, terminatedTarget, publicPort); + this.listener = front; + awaitBind(front); + } + + /** + * Stops the front listener and closes the backend client on shutdown. + * + * @param event the Quarkus shutdown event + */ + void onShutdown(@Observes ShutdownEvent event) { + if (listener != null) { + listener.stop(); + } + if (netClient != null) { + netClient.close(); + } + } + + private Map buildRelayMap() { + Map passthrough = gatewayConfig.tls().map(TlsConfig::passthroughSni).orElse(Map.of()); + Map targets = new LinkedHashMap<>(); + for (Map.Entry entry : passthrough.entrySet()) { + String sni = entry.getKey(); + String alias = entry.getValue(); + Optional resolved = topology.lookup(alias); + if (resolved.isEmpty()) { + // ADR-0009: an unresolved passthrough alias means the validator already failed the + // boot; this defensive skip never fires in a validated configuration. + LOGGER.debug("Passthrough alias '%s' for SNI '%s' is unresolved — skipping", alias, sni); + continue; + } + ResolvedUpstream upstream = resolved.get(); + targets.put(SniFrontListener.normalizeSni(sni), new RelayTarget(upstream.host(), upstream.port())); + } + return Map.copyOf(targets); + } + + private static void awaitBind(SniFrontListener front) { + try { + front.start().toCompletionStage().toCompletableFuture().get(BIND_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while binding the TLS front listener", e); + } catch (ExecutionException | TimeoutException e) { + throw new IllegalStateException("Refusing to start — TLS front listener bind failed", e); + } + } +} diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/package-info.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/package-info.java new file mode 100644 index 00000000..fcd1603e --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/package-info.java @@ -0,0 +1,36 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The accept-time TLS edge: the Vert.x {@link io.vertx.core.net.NetServer} front listener that + * reassembles the full TLS ClientHello (RFC 6066), reads the SNI, and either opaquely L4-relays a + * {@code tls.passthrough_sni} match to the topology-resolved backend (the gateway never handshakes) + * or hands the still-encrypted stream to the internal terminated Quarkus HTTPS listener. + *

+ * Fail-closed (GW-06): the full ClientHello is reassembled before any decision, and an + * empty/unresolved/malformed SNI always takes the terminated-strict path — never a passthrough. When + * {@code tls.passthrough_sni} is empty the front listener is never started, so the default + * single-listener topology is unchanged (zero-overhead default). + *

+ * This package is framework-coupled (Vert.x) and is therefore outside the ADR-0005 + * framework-agnostic arch-gate rule set, like {@code edge} and {@code routing}. + * + * @author API Sheriff Team + * @since 1.0 + */ +@NullMarked +package de.cuioss.sheriff.gateway.tls; + +import org.jspecify.annotations.NullMarked; diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 668f0fdc..44abd590 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -15,6 +15,18 @@ quarkus.management.enabled=true # TLS Configuration quarkus.ssl.native=true quarkus.tls.protocols=TLSv1.3,TLSv1.2 + +# Accept-time TLS edge — SNI passthrough split (tls/SniFrontListener, tls/TlsEdgeProducer). +# The Vert.x front listener is started ONLY when gateway.yaml declares tls.passthrough_sni; when it +# is empty the front is never created and the terminated Quarkus HTTPS listener (quarkus.http.ssl-port +# above) keeps the public port — the default single-listener topology is unchanged (zero overhead). +# A deployment that enables tls.passthrough_sni gives the public TLS port to the front listener and +# moves the terminated Quarkus HTTPS listener to the internal loopback port by additionally setting +# quarkus.http.ssl-port=${sheriff.tls.internal-https-port} +# so the front owns sheriff.tls.public-port and opaquely L4-relays a matched SNI to the resolved +# backend, handing every other connection to the internal terminated listener. +sheriff.tls.public-port=8443 +sheriff.tls.internal-https-port=8444 quarkus.tls.cipher-suites=TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 quarkus.tls.alpn=true diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java new file mode 100644 index 00000000..ad00b734 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java @@ -0,0 +1,320 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link ClientHelloSniParser}: full ClientHello reassembly across TCP fragments and + * TLS records, SNI extraction, and the fragmented / empty / malformed fail-closed (GW-06) verdicts. + *

+ * The tests construct real TLS ClientHello byte layouts (RFC 8446 §4 / RFC 6066 §3) with the + * {@link ClientHelloFixture} helper, so the parser is exercised byte-for-byte with no framework + * dependency. + */ +@DisplayName("ClientHelloSniParser") +class ClientHelloSniParserTest { + + private final ClientHelloSniParser parser = new ClientHelloSniParser(); + + @Nested + @DisplayName("SNI extraction from a complete ClientHello") + class Complete { + + @Test + @DisplayName("extracts the SNI host_name from a single-record ClientHello") + void extractsServerName() { + // Arrange + byte[] hello = ClientHelloFixture.withSni("api.example.com"); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertTrue(result.complete(), "a full ClientHello is complete"); + assertEquals(Optional.of("api.example.com"), result.serverName()); + } + + @Test + @DisplayName("preserves SNI case verbatim (normalization is the listener's concern)") + void preservesCase() { + byte[] hello = ClientHelloFixture.withSni("API.Example.COM"); + + ClientHelloSniParser.Result result = parser.parse(hello); + + assertEquals(Optional.of("API.Example.COM"), result.serverName()); + } + + @Test + @DisplayName("returns an empty server name for a ClientHello with no SNI extension") + void noSniExtension() { + byte[] hello = ClientHelloFixture.withoutSni(); + + ClientHelloSniParser.Result result = parser.parse(hello); + + assertTrue(result.complete(), "a full ClientHello without SNI is still complete"); + assertTrue(result.serverName().isEmpty(), "no SNI extension → empty → fail closed"); + } + + @Test + @DisplayName("returns an empty server name for an empty host_name (fail closed)") + void emptyHostName() { + byte[] hello = ClientHelloFixture.withSni(""); + + ClientHelloSniParser.Result result = parser.parse(hello); + + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty()); + } + } + + @Nested + @DisplayName("reassembly across fragments") + class Reassembly { + + @Test + @DisplayName("asks for more data until the full ClientHello has arrived") + void needsMoreDataForAPrefix() { + // Arrange + byte[] hello = ClientHelloFixture.withSni("relay.internal"); + byte[] prefix = Arrays.copyOf(hello, hello.length - 4); + + // Act + ClientHelloSniParser.Result partial = parser.parse(prefix); + ClientHelloSniParser.Result full = parser.parse(hello); + + // Assert + assertFalse(partial.complete(), "a truncated ClientHello is incomplete"); + assertTrue(partial.serverName().isEmpty()); + assertTrue(full.complete()); + assertEquals(Optional.of("relay.internal"), full.serverName()); + } + + @Test + @DisplayName("asks for more data when only the record header has arrived") + void needsMoreDataForRecordHeaderOnly() { + byte[] hello = ClientHelloFixture.withSni("relay.internal"); + byte[] justHeader = Arrays.copyOf(hello, 3); + + ClientHelloSniParser.Result result = parser.parse(justHeader); + + assertFalse(result.complete()); + } + + @Test + @DisplayName("reassembles a ClientHello split across two TLS handshake records") + void reassemblesAcrossTwoRecords() { + // Arrange + byte[] twoRecords = ClientHelloFixture.withSniSplitAcrossRecords("split.example.org", 40); + + // Act + ClientHelloSniParser.Result result = parser.parse(twoRecords); + + // Assert + assertTrue(result.complete(), "two handshake records reassemble into one ClientHello"); + assertEquals(Optional.of("split.example.org"), result.serverName()); + } + } + + @Nested + @DisplayName("fail-closed verdicts (GW-06)") + class FailClosed { + + @Test + @DisplayName("treats a non-handshake first byte as complete-with-no-SNI") + void nonTlsFirstByte() { + byte[] httpRequest = "GET / HTTP/1.1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + + ClientHelloSniParser.Result result = parser.parse(httpRequest); + + assertTrue(result.complete(), "a non-TLS stream is decided immediately, never buffered"); + assertTrue(result.serverName().isEmpty(), "not a TLS handshake → fail closed"); + } + + @Test + @DisplayName("treats a non-ClientHello handshake type as complete-with-no-SNI") + void wrongHandshakeType() { + byte[] serverHello = ClientHelloFixture.handshakeRecord((byte) 0x02, new byte[16]); + + ClientHelloSniParser.Result result = parser.parse(serverHello); + + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty()); + } + + @Test + @DisplayName("fails closed on an extension length that runs past the buffer") + void malformedExtensionLength() { + byte[] hello = ClientHelloFixture.withCorruptExtensionLength(); + + ClientHelloSniParser.Result result = parser.parse(hello); + + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty(), "a structural inconsistency fails closed"); + } + + @Test + @DisplayName("fails closed rather than buffering unboundedly past the size bound") + void oversizeRecordFailsClosed() { + byte[] oversize = ClientHelloFixture.oversizeRecordHeader(); + + ClientHelloSniParser.Result result = parser.parse(oversize); + + assertTrue(result.complete(), "an oversize declared record is decided, never buffered"); + assertTrue(result.serverName().isEmpty()); + } + } + + /** + * Builds real TLS ClientHello byte layouts for the parser tests. Kept package-visible and static + * so both this test and {@code SniFrontListenerTest} / {@code PassthroughRelayTest} can craft the + * same fixtures. + */ + static final class ClientHelloFixture { + + private static final byte RECORD_HANDSHAKE = 0x16; + private static final byte HANDSHAKE_CLIENT_HELLO = 0x01; + + private ClientHelloFixture() { + } + + /** A complete single-record ClientHello carrying the given SNI {@code host_name}. */ + static byte[] withSni(String sni) { + return handshakeRecord(HANDSHAKE_CLIENT_HELLO, clientHelloBody(sni)); + } + + /** A complete single-record ClientHello with no {@code server_name} extension. */ + static byte[] withoutSni() { + return handshakeRecord(HANDSHAKE_CLIENT_HELLO, clientHelloBody(null)); + } + + /** A ClientHello whose handshake message is split across two handshake records. */ + static byte[] withSniSplitAcrossRecords(String sni, int firstRecordPayload) { + byte[] handshake = handshakeMessage(HANDSHAKE_CLIENT_HELLO, clientHelloBody(sni)); + byte[] first = Arrays.copyOfRange(handshake, 0, firstRecordPayload); + byte[] second = Arrays.copyOfRange(handshake, firstRecordPayload, handshake.length); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeRecord(out, first); + writeRecord(out, second); + return out.toByteArray(); + } + + /** A ClientHello whose SNI extension declares a length running past the buffer. */ + static byte[] withCorruptExtensionLength() { + byte[] hello = withSni("corrupt.example.com"); + // The extension length is the two bytes immediately after the server_name extension type + // (0x00 0x00). Locate the first 0x00 0x00 pair inside the extensions area and inflate the + // following length so it overruns the buffer. + for (int i = 5 + 4 + 34; i < hello.length - 4; i++) { + if (hello[i] == 0x00 && hello[i + 1] == 0x00) { + hello[i + 2] = (byte) 0x7F; + hello[i + 3] = (byte) 0xFF; + break; + } + } + return hello; + } + + /** A record header declaring a length beyond the parser's reassembly bound. */ + static byte[] oversizeRecordHeader() { + int declared = ClientHelloSniParser.MAX_CLIENT_HELLO_BYTES + 1; + return new byte[] { + RECORD_HANDSHAKE, 0x03, 0x01, + (byte) ((declared >> 8) & 0xFF), (byte) (declared & 0xFF) + }; + } + + /** Wraps a handshake message in a single TLS handshake record. */ + static byte[] handshakeRecord(byte handshakeType, byte[] body) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeRecord(out, handshakeMessage(handshakeType, body)); + return out.toByteArray(); + } + + private static byte[] handshakeMessage(byte handshakeType, byte[] body) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(handshakeType); + out.write((body.length >> 16) & 0xFF); + out.write((body.length >> 8) & 0xFF); + out.write(body.length & 0xFF); + out.writeBytes(body); + return out.toByteArray(); + } + + private static void writeRecord(ByteArrayOutputStream out, byte[] payload) { + out.write(RECORD_HANDSHAKE); + out.write(0x03); + out.write(0x01); + out.write((payload.length >> 8) & 0xFF); + out.write(payload.length & 0xFF); + out.writeBytes(payload); + } + + private static byte[] clientHelloBody(String sni) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x03); + out.write(0x03); // legacy_version TLS 1.2 + out.writeBytes(new byte[32]); // random + out.write(0x00); // session_id length 0 + out.write(0x00); + out.write(0x02); // cipher_suites length 2 + out.write(0x13); + out.write(0x01); // TLS_AES_128_GCM_SHA256 + out.write(0x01); // compression_methods length 1 + out.write(0x00); // null compression + byte[] extensions = sni == null ? new byte[0] : serverNameExtension(sni); + out.write((extensions.length >> 8) & 0xFF); + out.write(extensions.length & 0xFF); + out.writeBytes(extensions); + return out.toByteArray(); + } + + private static byte[] serverNameExtension(String sni) { + byte[] host = sni.getBytes(StandardCharsets.US_ASCII); + ByteArrayOutputStream entry = new ByteArrayOutputStream(); + entry.write(0x00); // name_type host_name + entry.write((host.length >> 8) & 0xFF); + entry.write(host.length & 0xFF); + entry.writeBytes(host); + byte[] list = entry.toByteArray(); + + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write((list.length >> 8) & 0xFF); + body.write(list.length & 0xFF); + body.writeBytes(list); + byte[] extensionBody = body.toByteArray(); + + ByteArrayOutputStream extension = new ByteArrayOutputStream(); + extension.write(0x00); + extension.write(0x00); // extension type server_name + extension.write((extensionBody.length >> 8) & 0xFF); + extension.write(extensionBody.length & 0xFF); + extension.writeBytes(extensionBody); + return extension.toByteArray(); + } + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java new file mode 100644 index 00000000..9a3b4e68 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java @@ -0,0 +1,181 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Random; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayKind; +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link PassthroughRelay}'s opaque L4 pipe: byte fidelity (including the replayed + * ClientHello prefix), integrity under backpressure with a large payload, half-close propagation on + * a graceful {@code FIN}, and abort propagation when a leg is closed. Exercised against real Vert.x + * sockets so the backpressure and close semantics are the production ones. + */ +@DisplayName("PassthroughRelay") +class PassthroughRelayTest { + + private static final long TIMEOUT_SECONDS = 10; + private static final String HOST = "localhost"; + + private Vertx vertx; + private NetClient dialClient; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + dialClient = vertx.createNetClient(); + } + + @AfterEach + void tearDown() throws Exception { + await(dialClient.close()); + await(vertx.close()); + } + + @Test + @DisplayName("replays the buffered ClientHello prefix then relays subsequent bytes, in order") + void relaysPrefixThenLiveBytes() throws Exception { + // Arrange + RelayTarget backend = startEchoBackend(); + int frontPort = startRelayHarness(backend, Buffer.buffer("PREFIX")); + byte[] expected = "PREFIXDATA".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + CompletableFuture echoed = new CompletableFuture<>(); + + // Act + NetSocket client = await(dialClient.connect(frontPort, HOST)); + accumulateUntil(client, expected.length, echoed); + await(client.write(Buffer.buffer("DATA"))); + + // Assert + assertEquals(Buffer.buffer(expected), echoed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "the buffered prefix is replayed before the live bytes, byte-for-byte"); + } + + @Test + @DisplayName("relays a large payload intact under write-queue backpressure") + void relaysLargePayloadIntactUnderBackpressure() throws Exception { + // Arrange + RelayTarget backend = startEchoBackend(); + int frontPort = startRelayHarness(backend, Buffer.buffer()); + byte[] payload = new byte[1024 * 1024]; + new Random(42).nextBytes(payload); + CompletableFuture echoed = new CompletableFuture<>(); + + // Act + NetSocket client = await(dialClient.connect(frontPort, HOST)); + accumulateUntil(client, payload.length, echoed); + await(client.write(Buffer.buffer(payload))); + + // Assert + assertEquals(Buffer.buffer(payload), echoed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "1 MiB round-trips intact, so pause/resume backpressure preserves every byte"); + } + + @Test + @DisplayName("propagates a graceful client FIN as a half-close to the backend") + void propagatesHalfCloseToBackend() throws Exception { + // Arrange + CompletableFuture backendEnded = new CompletableFuture<>(); + RelayTarget backend = startSignalBackend(socket -> socket.endHandler(v -> backendEnded.complete(null))); + int frontPort = startRelayHarness(backend, Buffer.buffer()); + + // Act + NetSocket client = await(dialClient.connect(frontPort, HOST)); + await(client.end()); + + // Assert + assertNull(backendEnded.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "the client FIN is propagated as a half-close to the backend"); + } + + @Test + @DisplayName("aborts the backend leg when the client connection is closed") + void propagatesAbortToBackend() throws Exception { + // Arrange + CompletableFuture backendClosed = new CompletableFuture<>(); + RelayTarget backend = startSignalBackend(socket -> socket.closeHandler(v -> backendClosed.complete(null))); + int frontPort = startRelayHarness(backend, Buffer.buffer()); + + // Act + NetSocket client = await(dialClient.connect(frontPort, HOST)); + await(client.write(Buffer.buffer("x"))); + await(client.close()); + + // Assert + assertNull(backendClosed.get(TIMEOUT_SECONDS, TimeUnit.SECONDS), + "closing the client leg aborts the backend leg"); + } + + /** + * Starts a harness front server whose accepted connection is handed to the relay, targeting the + * given backend with the given buffered prefix. Returns the front's bound port. + */ + private int startRelayHarness(RelayTarget backend, Buffer prefix) throws Exception { + PassthroughRelay relay = new PassthroughRelay(vertx.createNetClient()); + NetServer harness = vertx.createNetServer(); + harness.connectHandler(accepted -> { + accepted.pause(); + relay.relay(accepted, prefix, backend, RelayKind.TERMINATED, ""); + }); + return await(harness.listen(0)).actualPort(); + } + + private RelayTarget startEchoBackend() throws Exception { + NetServer server = vertx.createNetServer(); + server.connectHandler(socket -> socket.handler(socket::write)); + int port = await(server.listen(0)).actualPort(); + return new RelayTarget(HOST, port); + } + + private RelayTarget startSignalBackend(java.util.function.Consumer wiring) throws Exception { + NetServer server = vertx.createNetServer(); + server.connectHandler(wiring::accept); + int port = await(server.listen(0)).actualPort(); + return new RelayTarget(HOST, port); + } + + private static void accumulateUntil(NetSocket socket, int expectedBytes, CompletableFuture done) { + Buffer accumulator = Buffer.buffer(); + socket.handler(chunk -> { + accumulator.appendBuffer(chunk); + if (accumulator.length() >= expectedBytes && !done.isDone()) { + done.complete(accumulator.copy()); + } + }); + } + + private static T await(Future future) throws Exception { + return future.toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java new file mode 100644 index 00000000..77b83e0a --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java @@ -0,0 +1,162 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import de.cuioss.sheriff.gateway.tls.ClientHelloSniParserTest.ClientHelloFixture; +import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; + +import io.vertx.core.Future; +import io.vertx.core.Vertx; +import io.vertx.core.buffer.Buffer; +import io.vertx.core.net.NetClient; +import io.vertx.core.net.NetServer; +import io.vertx.core.net.NetSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link SniFrontListener}'s accept-time routing decision, exercised against real + * Vert.x sockets: a mapped SNI is relayed opaquely to the passthrough backend, and an empty / + * non-matching SNI takes the terminated-strict path. Byte fidelity is asserted alongside routing — + * the backend receives the exact ClientHello the client sent. + */ +@DisplayName("SniFrontListener") +class SniFrontListenerTest { + + private static final long TIMEOUT_SECONDS = 5; + private static final String HOST = "localhost"; + private static final String MAPPED_SNI = "api.example.com"; + + private Vertx vertx; + private NetClient dialClient; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + dialClient = vertx.createNetClient(); + } + + @AfterEach + void tearDown() throws Exception { + await(dialClient.close()); + await(vertx.close()); + } + + @Test + @DisplayName("relays a mapped SNI opaquely to the passthrough backend, byte-for-byte") + void mappedSniRelaysToPassthroughBackend() throws Exception { + // Arrange + byte[] hello = ClientHelloFixture.withSni(MAPPED_SNI); + Backend passthrough = startBackend(hello.length); + Backend terminated = startBackend(hello.length); + SniFrontListener front = startFront(Map.of(MAPPED_SNI, passthrough.target()), terminated.target()); + + // Act + sendToFront(front, hello); + + // Assert + Buffer received = passthrough.firstBytes.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(Buffer.buffer(hello), received, "the passthrough backend receives the exact ClientHello"); + await(front.stop()); + } + + @Test + @DisplayName("routes a non-matching SNI to the terminated-strict path") + void nonMatchingSniRelaysToTerminatedBackend() throws Exception { + byte[] hello = ClientHelloFixture.withSni("unmapped.example.net"); + Backend passthrough = startBackend(hello.length); + Backend terminated = startBackend(hello.length); + SniFrontListener front = startFront(Map.of(MAPPED_SNI, passthrough.target()), terminated.target()); + + sendToFront(front, hello); + + Buffer received = terminated.firstBytes.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(Buffer.buffer(hello), received, "a non-matching SNI is handed to the terminated listener"); + await(front.stop()); + } + + @Test + @DisplayName("fails an SNI-less ClientHello closed to the terminated-strict path") + void sniLessHelloFailsClosedToTerminated() throws Exception { + byte[] hello = ClientHelloFixture.withoutSni(); + Backend passthrough = startBackend(hello.length); + Backend terminated = startBackend(hello.length); + SniFrontListener front = startFront(Map.of(MAPPED_SNI, passthrough.target()), terminated.target()); + + sendToFront(front, hello); + + Buffer received = terminated.firstBytes.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(Buffer.buffer(hello), received, "a missing SNI fails closed to the terminated path (GW-06)"); + await(front.stop()); + } + + @Test + @DisplayName("routes every connection to the terminated path when no SNI is mapped") + void emptyPassthroughMapRelaysEverythingToTerminated() throws Exception { + byte[] hello = ClientHelloFixture.withSni(MAPPED_SNI); + Backend terminated = startBackend(hello.length); + SniFrontListener front = startFront(Map.of(), terminated.target()); + + sendToFront(front, hello); + + Buffer received = terminated.firstBytes.get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(Buffer.buffer(hello), received, "an empty passthrough map relays everything terminated"); + await(front.stop()); + } + + private SniFrontListener startFront(Map targets, RelayTarget terminatedTarget) + throws Exception { + PassthroughRelay relay = new PassthroughRelay(vertx.createNetClient()); + SniFrontListener front = new SniFrontListener(vertx, relay, targets, terminatedTarget, 0); + await(front.start()); + return front; + } + + private void sendToFront(SniFrontListener front, byte[] payload) throws Exception { + NetSocket client = await(dialClient.connect(front.actualPort(), HOST)); + await(client.write(Buffer.buffer(payload))); + } + + private Backend startBackend(int expectedBytes) throws Exception { + CompletableFuture firstBytes = new CompletableFuture<>(); + Buffer accumulator = Buffer.buffer(); + NetServer server = vertx.createNetServer(); + server.connectHandler(socket -> socket.handler(chunk -> { + accumulator.appendBuffer(chunk); + if (accumulator.length() >= expectedBytes && !firstBytes.isDone()) { + firstBytes.complete(accumulator.copy()); + } + })); + int port = await(server.listen(0)).actualPort(); + return new Backend(new RelayTarget(HOST, port), firstBytes); + } + + private static T await(Future future) throws Exception { + return future.toCompletionStage().toCompletableFuture().get(TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + + /** A capturing backend server: its endpoint plus the future completed with its first bytes. */ + private record Backend(RelayTarget target, CompletableFuture firstBytes) { + } +} diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index cfd7ff0c..b522c2a0 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -38,6 +38,8 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-3 |CONFIG |Route '%s' effective posture: anchor='%s', auth.require='%s', filter='%s' |Logged once per route during route-table assembly, reporting the materialized effective posture (resolving anchor, effective auth requirement, effective security-filter profile) — anchors vanish at runtime, so the boot log is the discoverability record (ADR-0007) |ApiSheriff-4 |EDGE |WebSocket relay established on route '%s' (upstream '%s') |Logged once when a WebSocket upgrade completes (101 Switching Protocols) and the opaque bidirectional relay to the upstream begins (Plan 05 WebSocket processor) |ApiSheriff-5 |EDGE |WebSocket relay on route '%s' reclaimed after idle timeout (%s seconds) |Logged when an established WebSocket relay is closed because no frame travelled in either direction for the per-route websocket.idle_timeout_seconds window; ping/pong counts as activity, so only genuinely dead sockets are reaped +|ApiSheriff-6 |EDGE |Passthrough relay established for SNI '%s' to upstream '%s' |Logged once per accepted connection whose SNI matched tls.passthrough_sni, when the opaque L4 relay to the resolved backend is established; the gateway never handshakes, so the backend certificate reaches the client end-to-end +|ApiSheriff-7 |EDGE |Accept-time SNI front listener started on port %s (%s passthrough SNI mapping(s)) |Logged once at startup when the Vert.x front listener binds the public TLS port; emitted only when tls.passthrough_sni is non-empty (the front listener is not started for the default single-listener topology) |=== == WARN Level (100-199) @@ -51,6 +53,7 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-103 |EDGE |Circuit breaker opened for upstream '%s' |Logged when a route's SmallRye Fault-Tolerance circuit breaker transitions to OPEN after its configured consecutive-failure threshold, so the breaker's protective posture is always an audited event |ApiSheriff-104 |EDGE |Circuit breaker closed for upstream '%s' |Logged when a route's circuit breaker transitions back to CLOSED after recovery |ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015) +|ApiSheriff-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes |=== == ERROR Level (200-299) From e8ed7f21ccf67bba6fc53a05fdb69907945d9c4b Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:28:34 +0200 Subject: [PATCH 02/15] feat(tls-edge): mTLS termination with client-CA verification (D2) Add MtlsServerCustomizer, a Quarkus HttpServerOptionsCustomizer that maps tls.mtls.enabled + client_ca onto the terminated HTTPS listener's client-auth: require-and-verify a client certificate against the configured CA trust anchor, with no HTTP fallback and no optional/want mode. Client-auth stays off when tls.mtls is absent or disabled; enabled without client_ca fails the boot. Passthrough connections are split off at L4 and never mTLS-checked. Unit tests cover the enabled/disabled/absent/misconfigured mapping. --- .../gateway/tls/MtlsServerCustomizer.java | 85 +++++++++++++ .../src/main/resources/application.properties | 7 ++ .../gateway/tls/MtlsServerCustomizerTest.java | 116 ++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java new file mode 100644 index 00000000..6d0bee0f --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java @@ -0,0 +1,85 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import java.util.Objects; +import java.util.Optional; + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; +import de.cuioss.tools.logging.CuiLogger; + +import io.quarkus.vertx.http.HttpServerOptionsCustomizer; +import io.vertx.core.http.ClientAuth; +import io.vertx.core.http.HttpServerOptions; +import io.vertx.core.net.PemTrustOptions; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Maps the resolved {@code tls.mtls} block onto the terminated Quarkus HTTPS listener's client-auth + * options: when {@code tls.mtls.enabled} is set, the terminated listener is switched to + * {@link ClientAuth#REQUIRED} and its trust material is bound from the configured {@code client_ca} + * CA anchor, so every terminated connection must present a certificate the CA signed. + *

+ * The mapping is strict by design (GW-06): there is no HTTP-level fallback and no + * optional / want-style mode — a client that fails verification is rejected at the handshake. When + * {@code tls.mtls} is absent or {@code enabled=false} the listener's client-auth is left untouched + * (off). {@code enabled} with a missing {@code client_ca} fails the boot fast rather than starting a + * require-client-auth listener with no trust anchor. + *

+ * Passthrough connections are never affected: they are split off at L4 by the + * {@link SniFrontListener} before any handshake, so the gateway never participates in a passthrough + * TLS handshake and this customizer only ever governs the terminated listener. + * + * @author API Sheriff Team + * @since 1.0 + */ +@ApplicationScoped +public class MtlsServerCustomizer implements HttpServerOptionsCustomizer { + + private static final CuiLogger LOGGER = new CuiLogger(MtlsServerCustomizer.class); + + private final GatewayConfig gatewayConfig; + + /** + * @param gatewayConfig the bound global gateway document (source of the {@code tls.mtls} block) + */ + @Inject + public MtlsServerCustomizer(GatewayConfig gatewayConfig) { + this.gatewayConfig = Objects.requireNonNull(gatewayConfig, "gatewayConfig"); + } + + /** + * Applies the mTLS client-auth requirement to the terminated HTTPS listener when + * {@code tls.mtls.enabled} is set; a no-op otherwise. + * + * @param options the HTTPS server options Quarkus is about to start the terminated listener with + */ + @Override + public void customizeHttpsServer(HttpServerOptions options) { + Optional mtls = gatewayConfig.tls().flatMap(TlsConfig::mtls); + if (mtls.isEmpty() || !mtls.get().enabled()) { + return; + } + String clientCa = mtls.get().clientCa().orElseThrow(() -> new IllegalStateException( + "Refusing to start — tls.mtls.enabled requires a client_ca trust anchor")); + options.setClientAuth(ClientAuth.REQUIRED); + options.setTrustOptions(new PemTrustOptions().addCertPath(clientCa)); + LOGGER.debug("mTLS enabled: terminated HTTPS listener requires a client certificate signed by '%s'", + clientCa); + } +} diff --git a/api-sheriff/src/main/resources/application.properties b/api-sheriff/src/main/resources/application.properties index 44abd590..1aa86925 100644 --- a/api-sheriff/src/main/resources/application.properties +++ b/api-sheriff/src/main/resources/application.properties @@ -27,6 +27,13 @@ quarkus.tls.protocols=TLSv1.3,TLSv1.2 # backend, handing every other connection to the internal terminated listener. sheriff.tls.public-port=8443 sheriff.tls.internal-https-port=8444 + +# mTLS on terminated connections (tls/MtlsServerCustomizer). Client-auth is bound PROGRAMMATICALLY +# from gateway.yaml's tls.mtls block: when tls.mtls.enabled is set the terminated HTTPS listener is +# switched to require-and-verify a client certificate against the configured client_ca trust anchor +# (no HTTP fallback, no optional/want mode). This explicit default keeps client-auth OFF when +# tls.mtls is absent or disabled; passthrough connections are split off at L4 and never mTLS-checked. +quarkus.http.ssl.client-auth=none quarkus.tls.cipher-suites=TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 quarkus.tls.alpn=true diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java new file mode 100644 index 00000000..5cf1f37f --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java @@ -0,0 +1,116 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Optional; + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; + +import io.vertx.core.http.ClientAuth; +import io.vertx.core.http.HttpServerOptions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link MtlsServerCustomizer}'s config → client-auth mapping: enabled mTLS switches + * the terminated HTTPS listener to {@link ClientAuth#REQUIRED} with a trust anchor, disabled / + * absent mTLS leaves client-auth off, and enabled-with-missing-{@code client_ca} fails the boot. + *

+ * The real accept / reject handshake behaviour (valid cert, wrong CA, no cert) is proven as an + * integration behaviour test in deliverable 4 (GW-06 behaviour, not a flag read); this test governs + * only the deterministic option mapping, so it needs no running TLS server. + */ +@DisplayName("MtlsServerCustomizer") +class MtlsServerCustomizerTest { + + private static final String CLIENT_CA_PATH = "/etc/api-sheriff/client-ca.crt"; + + @Test + @DisplayName("enabled mTLS requires client-auth and binds the client_ca trust anchor") + void enabledRequiresClientAuthWithTrustAnchor() { + // Arrange + MtlsServerCustomizer customizer = customizerFor(mtls(true, Optional.of(CLIENT_CA_PATH))); + HttpServerOptions options = new HttpServerOptions(); + + // Act + customizer.customizeHttpsServer(options); + + // Assert + assertEquals(ClientAuth.REQUIRED, options.getClientAuth(), "enabled mTLS requires a client certificate"); + assertNotNull(options.getTrustOptions(), "the client_ca trust anchor is bound as the listener trust store"); + } + + @Test + @DisplayName("disabled mTLS leaves client-auth off") + void disabledLeavesClientAuthOff() { + // Arrange + MtlsServerCustomizer customizer = customizerFor(mtls(false, Optional.of(CLIENT_CA_PATH))); + HttpServerOptions options = new HttpServerOptions(); + + // Act + customizer.customizeHttpsServer(options); + + // Assert + assertEquals(ClientAuth.NONE, options.getClientAuth(), "disabled mTLS never requires a client certificate"); + assertNull(options.getTrustOptions(), "disabled mTLS binds no trust store"); + } + + @Test + @DisplayName("an absent tls.mtls block is a no-op") + void absentMtlsIsNoOp() { + // Arrange + GatewayConfig gateway = GatewayConfig.builder().version(1) + .tls(Optional.of(TlsConfig.builder().build())).build(); + MtlsServerCustomizer customizer = new MtlsServerCustomizer(gateway); + HttpServerOptions options = new HttpServerOptions(); + + // Act + customizer.customizeHttpsServer(options); + + // Assert + assertEquals(ClientAuth.NONE, options.getClientAuth()); + assertNull(options.getTrustOptions()); + } + + @Test + @DisplayName("enabled mTLS with no client_ca fails the boot (fail-closed)") + void enabledWithoutClientCaFailsBoot() { + // Arrange + MtlsServerCustomizer customizer = customizerFor(mtls(true, Optional.empty())); + HttpServerOptions options = new HttpServerOptions(); + + // Act + Assert + assertThrows(IllegalStateException.class, () -> customizer.customizeHttpsServer(options), + "enabled mTLS without a client_ca trust anchor must not start a require-client-auth listener"); + } + + private static MtlsServerCustomizer customizerFor(TlsConfig.Mtls mtls) { + GatewayConfig gateway = GatewayConfig.builder().version(1) + .tls(Optional.of(TlsConfig.builder().mtls(Optional.of(mtls)).build())) + .build(); + return new MtlsServerCustomizer(gateway); + } + + private static TlsConfig.Mtls mtls(boolean enabled, Optional clientCa) { + return new TlsConfig.Mtls(enabled, clientCa); + } +} From d7e96a2ef298bfd58d647b7d7cf66ae474fca9c6 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:44:11 +0200 Subject: [PATCH 03/15] feat(tls-edge): runtime Host-vs-SNI smuggle 404 guard (D3) Add PassthroughHostGuardStage, a pipeline pre-check that rejects a terminated request whose Host header names a passthrough_sni hostname with 404 (PASSTHROUGH_HOST_SMUGGLED) before route selection, closing the host-confusion vector against the passthrough backend identity. Matching mirrors the front listener's case-insensitive, dot/port-normalized SNI handling; the stage is inert when passthrough_sni is empty. Cover the guard with a negative-test suite and extend the EventType catalogue test with the new PASSTHROUGH_HOST_SMUGGLED member. Co-Authored-By: Claude --- .../gateway/ApiSheriffLogMessages.java | 13 +- .../gateway/edge/GatewayEdgeRoute.java | 10 + .../sheriff/gateway/events/EventType.java | 7 + .../pipeline/PassthroughHostGuardStage.java | 99 ++++++++++ .../sheriff/gateway/events/EventTypeTest.java | 1 + .../PassthroughHostGuardStageTest.java | 186 ++++++++++++++++++ doc/LogMessages.adoc | 1 + 7 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java index b30f59d1..681bbcbb 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/ApiSheriffLogMessages.java @@ -28,7 +28,7 @@ * assertable. This catalogue's identifier ranges are disjoint from * {@link de.cuioss.sheriff.gateway.config.ConfigLogMessages}'s (the boot-time configuration * subsystem catalogue), which shares the same {@code ApiSheriff} prefix: {@code 1} / {@code 4} / - * {@code 6-7} / {@code 100} / {@code 103-107} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} + * {@code 6-7} / {@code 100} / {@code 103-108} here vs {@code 2-3} / {@code 101-102} / {@code 200-201} * there — never renumber one catalogue without checking the other for a collision. * Security-relevant {@code WARN}s record only the failure type and route id — * never the raw offending payload. {@code DEBUG} / {@code TRACE} diagnostics use the logger @@ -135,5 +135,16 @@ public static final class WARN { .identifier(107) .template("TLS ClientHello failed closed to terminated path: %s") .build(); + + /** + * A terminated request's {@code Host} header named a reserved passthrough SNI (Host-vs-SNI + * smuggle), rejected 404 before route selection. Records a fixed disposition only — never the + * raw {@code Host} value. + */ + public static final LogRecord PASSTHROUGH_HOST_SMUGGLED = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(108) + .template("Host-vs-SNI smuggle rejected before route selection: %s") + .build(); } } diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java index 51fae6bd..d7a43136 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/edge/GatewayEdgeRoute.java @@ -52,6 +52,7 @@ import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; import de.cuioss.sheriff.gateway.config.model.RouteTable; import de.cuioss.sheriff.gateway.config.model.SecurityFilterConfig; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; import de.cuioss.sheriff.gateway.events.EventCategory; import de.cuioss.sheriff.gateway.events.EventType; import de.cuioss.sheriff.gateway.events.GatewayEventCounter; @@ -62,6 +63,7 @@ import de.cuioss.sheriff.gateway.pipeline.CanonicalPathGuard; import de.cuioss.sheriff.gateway.pipeline.FramingGate; import de.cuioss.sheriff.gateway.pipeline.OriginValidationStage; +import de.cuioss.sheriff.gateway.pipeline.PassthroughHostGuardStage; import de.cuioss.sheriff.gateway.pipeline.PipelineRequest; import de.cuioss.sheriff.gateway.pipeline.RouteSelectionStage; import de.cuioss.sheriff.gateway.pipeline.SecurityHeadersStage; @@ -160,6 +162,7 @@ public class GatewayEdgeRoute { private final BasicChecksStage basicChecksStage; private final CanonicalPathGuard canonicalPathGuard; private final FramingGate framingGate; + private final PassthroughHostGuardStage passthroughHostGuardStage; private final RouteSelectionStage routeSelectionStage; private final VerbGateStage verbGateStage; private final ThoroughChecksStage thoroughChecksStage; @@ -230,6 +233,8 @@ public GatewayEdgeRoute(RouteTable routeTable, GatewayConfig gatewayConfig, this.basicChecksStage = new BasicChecksStage(defaultConfiguration, securityEventCounter); this.canonicalPathGuard = new CanonicalPathGuard(); this.framingGate = new FramingGate(); + this.passthroughHostGuardStage = new PassthroughHostGuardStage( + gatewayConfig.tls().map(TlsConfig::passthroughSni).map(Map::keySet).orElse(Set.of())); this.routeSelectionStage = new RouteSelectionStage(routes); this.verbGateStage = new VerbGateStage(); this.thoroughChecksStage = new ThoroughChecksStage(defaultConfiguration, securityEventCounter); @@ -382,6 +387,7 @@ private void process(RoutingContext ctx) { writeShortCircuit(ctx, request); return; } + passthroughHostGuardStage.process(request); routeSelectionStage.process(request); verbGateStage.process(request); RouteRuntime route = requireSelectedRoute(request); @@ -409,6 +415,10 @@ private void process(RoutingContext ctx) { // Security-relevant WARN (D4): the failure-type detail only, never the raw payload — // rejected.getMessage() already carries a sanitized description (see GatewayException). LOGGER.warn(ApiSheriffLogMessages.WARN.SECURITY_FILTER_VIOLATION, routeLabel(ctx), rejected.getMessage()); + } else if (rejected.getEventType() == EventType.PASSTHROUGH_HOST_SMUGGLED) { + // Security-relevant WARN: a terminated Host named a reserved passthrough SNI. The + // message is a fixed disposition (never the raw Host value). + LOGGER.warn(ApiSheriffLogMessages.WARN.PASSTHROUGH_HOST_SMUGGLED, rejected.getMessage()); } recordError(ctx, rejected.getEventType()); renderRejection(ctx, request, rejected.getEventType()); diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java index 753ebe88..aa45045a 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/events/EventType.java @@ -60,6 +60,13 @@ public enum EventType { PARAMETER_LIMIT_EXCEEDED(EventCategory.INPUT_VALIDATION, 400), /** No route matched the canonical path (deny-by-default routing). */ NO_ROUTE_MATCHED(EventCategory.INPUT_VALIDATION, 404), + /** + * A terminated request's {@code Host} header names a {@code passthrough_sni} hostname reserved + * for the accept-time L4 split (Host-vs-SNI smuggle); rejected {@code 404} before route + * selection so the passthrough backend identity cannot be reached through the terminated + * listener. + */ + PASSTHROUGH_HOST_SMUGGLED(EventCategory.INPUT_VALIDATION, 404), /** The request method is outside the route's effective {@code allowed_methods}. */ METHOD_NOT_ALLOWED(EventCategory.INPUT_VALIDATION, 405), diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java new file mode 100644 index 00000000..25520aab --- /dev/null +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java @@ -0,0 +1,99 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.pipeline; + +import java.util.Collection; +import java.util.HashSet; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; + +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; + +/** + * A pipeline pre-check that rejects a Host-vs-SNI smuggle before stage-2 route selection: a + * terminated request whose {@code Host} header names a {@code passthrough_sni} hostname is + * rejected {@code 404} ({@link EventType#PASSTHROUGH_HOST_SMUGGLED}) rather than being routed. + *

+ * A passthrough hostname is meant to be split off at L4 by the accept-time front listener and never + * terminated here; a terminated request carrying that hostname in its {@code Host} header is + * therefore attempting to reach the passthrough backend's identity through the terminated listener + * (a request-smuggling / host-confusion vector). Matching is case-insensitive and normalized + * (lower-cased, trailing FQDN dot and any {@code :port} suffix removed), mirroring the front + * listener's SNI normalization. + *

+ * This is the genuinely-new runtime half of the guard, distinct from the framing / anti-smuggling + * {@code FramingGate} and from the boot-time collision rule + * ({@code ConfigValidator.validatePassthroughHostCollision}). The stage is inert when + * {@code passthrough_sni} is empty, and framework-agnostic (ADR-0005) — it consumes only the + * agnostic {@link PipelineRequest}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class PassthroughHostGuardStage { + + private final Set passthroughHosts; + + /** + * @param passthroughSniHosts the {@code tls.passthrough_sni} hostnames (SNI keys); normalized + * into the reserved-host set the guard rejects a terminated + * {@code Host} match against + */ + public PassthroughHostGuardStage(Collection passthroughSniHosts) { + Objects.requireNonNull(passthroughSniHosts, "passthroughSniHosts"); + Set normalized = new HashSet<>(); + for (String host : passthroughSniHosts) { + normalized.add(normalize(host)); + } + this.passthroughHosts = Set.copyOf(normalized); + } + + /** + * Rejects the request when its {@code Host} names a passthrough SNI; a no-op otherwise. + * + * @param request the in-flight request context + * @throws GatewayException with {@link EventType#PASSTHROUGH_HOST_SMUGGLED} when the terminated + * request's {@code Host} names a reserved passthrough hostname + */ + public void process(PipelineRequest request) { + Objects.requireNonNull(request, "request"); + if (passthroughHosts.isEmpty()) { + return; + } + String host = request.host(); + if (host == null) { + return; + } + if (passthroughHosts.contains(normalize(host))) { + throw new GatewayException(EventType.PASSTHROUGH_HOST_SMUGGLED, + "Terminated request Host names a reserved passthrough SNI"); + } + } + + private static String normalize(String host) { + String lower = host.toLowerCase(Locale.ROOT).strip(); + int colon = lower.indexOf(':'); + if (colon > 0 && lower.lastIndexOf(':') == colon) { + String maybePort = lower.substring(colon + 1); + if (!maybePort.isEmpty() && maybePort.chars().allMatch(Character::isDigit)) { + lower = lower.substring(0, colon); + } + } + return lower.endsWith(".") ? lower.substring(0, lower.length() - 1) : lower; + } +} diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java index ed2e2812..c822141a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/events/EventTypeTest.java @@ -74,6 +74,7 @@ class FailureEvents { "PATH_NOT_ALLOWED, 400, INPUT_VALIDATION", "PARAMETER_LIMIT_EXCEEDED, 400, INPUT_VALIDATION", "NO_ROUTE_MATCHED, 404, INPUT_VALIDATION", + "PASSTHROUGH_HOST_SMUGGLED, 404, INPUT_VALIDATION", "METHOD_NOT_ALLOWED, 405, INPUT_VALIDATION", "TOKEN_MISSING, 401, AUTHENTICATION", "TOKEN_INVALID, 401, AUTHENTICATION", diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java new file mode 100644 index 00000000..75c2d843 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java @@ -0,0 +1,186 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.pipeline; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import java.util.Set; + +import de.cuioss.sheriff.gateway.config.model.HttpMethod; +import de.cuioss.sheriff.gateway.events.EventCategory; +import de.cuioss.sheriff.gateway.events.EventType; +import de.cuioss.sheriff.gateway.events.GatewayException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +@DisplayName("PassthroughHostGuardStage — runtime Host-vs-SNI smuggle 404 guard") +class PassthroughHostGuardStageTest { + + /** The reserved passthrough SNI hostname the guard protects. */ + private static final String PASSTHROUGH_SNI = "backend.internal.example"; + + private final PassthroughHostGuardStage guardedStage = + new PassthroughHostGuardStage(List.of(PASSTHROUGH_SNI)); + + @Nested + @DisplayName("Smuggled Host rejection (a terminated Host naming a passthrough SNI)") + class SmuggledHostRejection { + + @Test + @DisplayName("rejects a Host that names a passthrough SNI with 404 PASSTHROUGH_HOST_SMUGGLED") + void rejectsSmuggledHost() { + // Arrange + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> guardedStage.process(request)); + + // Assert — the guard's own event maps to a 404 input-validation rejection + assertAll("smuggle rejection", + () -> assertEquals(EventType.PASSTHROUGH_HOST_SMUGGLED, thrown.getEventType(), + "Rejection must carry the smuggle event"), + () -> assertEquals(404, thrown.getEventType().httpStatus(), + "The smuggle guard rejects with 404"), + () -> assertEquals(EventCategory.INPUT_VALIDATION, thrown.getEventType().category(), + "The smuggle guard is an input-validation rejection")); + } + + @ParameterizedTest(name = "rejects normalized smuggle variant \"{0}\"") + @ValueSource(strings = { + "BACKEND.INTERNAL.EXAMPLE", // case-insensitive match + "Backend.Internal.Example", // mixed case + "backend.internal.example.", // trailing FQDN dot stripped + "backend.internal.example:8443", // :port suffix stripped + " backend.internal.example " // surrounding whitespace stripped + }) + @DisplayName("rejects a smuggled Host after case/dot/port normalization") + void rejectsNormalizedSmuggleVariants(String smuggledHost) { + // Arrange + PipelineRequest request = requestWithHost(smuggledHost); + + // Act + GatewayException thrown = assertThrows(GatewayException.class, () -> guardedStage.process(request)); + + // Assert + assertEquals(EventType.PASSTHROUGH_HOST_SMUGGLED, thrown.getEventType(), + "Normalized variant must still be recognized as a smuggle"); + } + + @Test + @DisplayName("rejects the smuggle before route selection (no route is recorded on the request)") + void rejectsBeforeRouteSelection() { + // Arrange + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI); + + // Act + assertThrows(GatewayException.class, () -> guardedStage.process(request)); + + // Assert — the guard is a pre-check: it must not have advanced routing state + assertAll("pre-route rejection", + () -> assertNull(request.selectedRoute(), + "No route may be selected on a rejected smuggle"), + () -> assertNull(request.canonicalPath(), + "The guard must not canonicalize a rejected request")); + } + } + + @Nested + @DisplayName("Benign pass-through (a request the guard must let flow to route selection)") + class BenignPassThrough { + + @Test + @DisplayName("passes a benign Host through to route selection") + void passesBenignHost() { + // Arrange + PipelineRequest request = requestWithHost("edge.public.example"); + + // Act + Assert — a benign Host is a no-op (the guard never touches it) + assertDoesNotThrow(() -> guardedStage.process(request)); + } + + @Test + @DisplayName("treats an absent Host header as a no-op") + void passesNullHost() { + // Arrange — the edge may build a request without a Host authority + PipelineRequest request = requestWithHost(null); + + // Act + Assert + assertDoesNotThrow(() -> guardedStage.process(request)); + } + + @Test + @DisplayName("passes a Host that only shares a suffix with the passthrough SNI") + void passesSuffixLookalikeHost() { + // Arrange — "evil-backend.internal.example" must NOT match "backend.internal.example" + PipelineRequest request = requestWithHost("evil-backend.internal.example"); + + // Act + Assert + assertDoesNotThrow(() -> guardedStage.process(request)); + } + } + + @Nested + @DisplayName("Empty passthrough set (an inert guard)") + class EmptyPassthroughSet { + + private final PassthroughHostGuardStage inertStage = + new PassthroughHostGuardStage(Set.of()); + + @Test + @DisplayName("is a no-op even for a Host that would otherwise be a smuggle") + void inertWhenPassthroughSetEmpty() { + // Arrange — with no passthrough SNI configured there is no reserved identity to smuggle + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI); + + // Act + Assert + assertDoesNotThrow(() -> inertStage.process(request)); + } + } + + @Nested + @DisplayName("Contract guards") + class ContractGuards { + + @Test + @DisplayName("rejects a null passthrough-SNI collection at construction") + void rejectsNullConstructorArgument() { + assertThrows(NullPointerException.class, () -> new PassthroughHostGuardStage(null)); + } + + @Test + @DisplayName("rejects a null request at process time") + void rejectsNullRequest() { + assertThrows(NullPointerException.class, () -> guardedStage.process(null)); + } + } + + private static PipelineRequest requestWithHost(String host) { + return PipelineRequest.builder() + .method(HttpMethod.GET) + .requestPath("/api/v1/resource") + .host(host) + .build(); + } +} diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc index b522c2a0..db408b94 100644 --- a/doc/LogMessages.adoc +++ b/doc/LogMessages.adoc @@ -54,6 +54,7 @@ diagnostics use the logger directly and are not catalogued. |ApiSheriff-104 |EDGE |Circuit breaker closed for upstream '%s' |Logged when a route's circuit breaker transitions back to CLOSED after recovery |ApiSheriff-105 |EDGE |WebSocket upgrade rejected on route '%s': Origin '%s' is not in the allowed_origins allowlist |Logged when a bearer WebSocket handshake presents an Origin that does not exactly match the route's allowed_origins allowlist; the upgrade is refused before any upstream dial (fail-closed CSWSH defence, GW-09 / ADR-0015) |ApiSheriff-107 |EDGE |TLS ClientHello failed closed to terminated path: %s |Logged when an accept-time TLS ClientHello is failed closed to the terminated-strict path (GW-06) because it carried no usable SNI, was malformed, or exceeded the reassembly bound; records the disposition only — never the raw ClientHello bytes +|ApiSheriff-108 |EDGE |Host-vs-SNI smuggle rejected before route selection: %s |Logged when a terminated request's Host header names a reserved passthrough SNI hostname and is rejected 404 before route selection; records a fixed disposition only — never the raw Host value |=== == ERROR Level (200-299) From 599ef137f0f263961c31d0eb3f6ed3176dea601e Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 11:58:19 +0200 Subject: [PATCH 04/15] test(tls-edge): Toxiproxy + TLS backend compose and TLS/fault IT suites (D4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a pinned Toxiproxy service and a TLS-enabled passthrough backend (serving its own CN=passthrough-backend certificate) to the integration-tests compose stack as purely additive services — existing services are untouched. Add four black-box integration suites exercising the TLS edge against the running stack under -Pintegration-tests: - TlsPassthroughIT: the passthrough relay surfaces the backend's certificate identity (opaque relay proof); terminated traffic on the same listener still served. - MtlsHandshakeIT: mTLS accept/reject proven at the handshake level (trusted cert, no cert, wrong-CA cert). - HostSmuggleGuardIT: a terminated Host naming a passthrough SNI is rejected 404. - PassthroughFaultIT: a mid-stream Toxiproxy reset propagates as a connection abort, not a hang, driven via the Toxiproxy REST control API. Co-Authored-By: Claude --- integration-tests/docker-compose.yml | 66 ++++++ .../integration/HostSmuggleGuardIT.java | 76 +++++++ .../gateway/integration/MtlsHandshakeIT.java | 151 ++++++++++++++ .../integration/PassthroughFaultIT.java | 193 ++++++++++++++++++ .../gateway/integration/TlsPassthroughIT.java | 132 ++++++++++++ 5 files changed, 618 insertions(+) create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/HostSmuggleGuardIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index 709f2fb0..b5a8b0f6 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -33,6 +33,72 @@ services: networks: - api-sheriff + # Toxiproxy sits in front of the TLS passthrough backend so PassthroughFaultIT can inject a + # mid-stream reset and prove the opaque L4 relay surfaces the fault as a connection abort rather + # than a hang (GW passthrough resilience). Pinned image with a healthcheck; the admin API (8474) + # is published to the host so the Failsafe-side IT creates the proxy and adds the reset_peer toxic + # at test time — no static proxy config file is mounted. Added beside keycloak as a purely + # additive service; the existing services are untouched. + toxiproxy: + image: ghcr.io/shopify/toxiproxy:2.12.0 + ports: + - "8474:8474" # Toxiproxy admin/control API, driven by PassthroughFaultIT from the host + networks: + - api-sheriff + restart: unless-stopped + healthcheck: + test: ["CMD", "/toxiproxy-cli", "list"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s + + # TLS-enabled passthrough target backing the accept-time SNI split's opaque L4 relay. It serves + # HTTPS on 8443 with its OWN self-signed certificate (CN=passthrough-backend), so TlsPassthroughIT + # can assert the peer certificate identity it observes through the relay is the backend's — proof + # the gateway relayed the encrypted stream opaquely and never terminated it. Reached by the gateway + # on the internal network as passthrough-backend:8443 (direct path), or via toxiproxy for the + # mid-stream fault test. No host port is published — it is reached only inside the compose network. + # The cert is generated at container start (least infrastructure: no pre-provisioned key material), + # mirroring the self-signed convention the certificates/ scripts use for the terminated listener. + passthrough-backend: + image: nginx:1.27-alpine + entrypoint: + - /bin/sh + - -c + - | + set -e + apk add --no-cache openssl >/dev/null 2>&1 + openssl req -x509 -newkey rsa:2048 -nodes -days 730 \ + -keyout /etc/nginx/passthrough-backend.key \ + -out /etc/nginx/passthrough-backend.crt \ + -subj "/CN=passthrough-backend/OU=Integration Testing/O=API-Sheriff/L=Berlin/ST=Berlin/C=DE" \ + -addext "subjectAltName=DNS:passthrough-backend,DNS:passthrough.test.example,IP:127.0.0.1" + cat > /etc/nginx/conf.d/default.conf <<'NGINX' + server { + listen 8443 ssl; + server_name passthrough-backend; + ssl_certificate /etc/nginx/passthrough-backend.crt; + ssl_certificate_key /etc/nginx/passthrough-backend.key; + location / { + add_header Content-Type text/plain always; + return 200 "passthrough-backend\n"; + } + } + NGINX + exec nginx -g 'daemon off;' + networks: + - api-sheriff + restart: unless-stopped + healthcheck: + # /dev/tcp readiness probe on the TLS port; nginx:alpine ships /bin/sh but the ssl handshake + # is not exercised here — a TCP accept is sufficient to gate dependents on listener readiness. + test: ["CMD-SHELL", "test -f /etc/nginx/passthrough-backend.crt && nginx -t"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 15s + # Routable upstream backend for the proxy: /anything/* echoes the complete # received request as JSON, so integration tests can assert exactly what the # gateway forwarded. Reached by the gateway on the internal network as diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/HostSmuggleGuardIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/HostSmuggleGuardIT.java new file mode 100644 index 00000000..9d41c332 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/HostSmuggleGuardIT.java @@ -0,0 +1,76 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Proves the runtime Host-vs-SNI smuggle guard end-to-end against the running edge: a + * terminated request whose {@code Host} header names a {@code tls.passthrough_sni} hostname + * is rejected {@code 404} ({@code PASSTHROUGH_HOST_SMUGGLED}) before route selection, so the + * passthrough backend's reserved identity cannot be reached through the terminated listener. A benign + * {@code Host} is still routed normally, confirming the guard is a targeted pre-check, not a blanket + * denial. + *

+ * This is the running-edge complement to the unit-level {@code PassthroughHostGuardStageTest}: the + * unit test proves the stage logic in isolation, this IT proves the stage is actually wired into the + * terminated pipeline ahead of routing. + *

+ * Runtime precondition: the {@code -Pintegration-tests} stack configures a + * {@code passthrough_sni} entry for {@link #passthroughSni()}. + */ +class HostSmuggleGuardIT extends BaseIntegrationTest { + + private static String passthroughSni() { + return System.getProperty("test.passthrough.sni", "passthrough.test.example"); + } + + @Test + @DisplayName("a terminated request whose Host names a passthrough SNI is rejected 404, not forwarded") + void smuggledHostRejected() { + var response = given() + .header("Host", passthroughSni()) + .when() + .get("/proxy/get") + .then() + .statusCode(404) + .contentType("application/problem+json") + .extract(); + + assertTrue(response.path("type").toString().contains("urn:api-sheriff:problem:input-validation"), + "a Host-smuggle rejection must render the input-validation problem type"); + assertNull(response.path("method"), + "a smuggled request must be rejected before route selection — it must not reach the upstream"); + } + + @Test + @DisplayName("a benign Host on the same route is routed normally, proving a targeted guard") + void benignHostStillRouted() { + var response = given() + .when() + .get("/proxy/get") + .then() + .statusCode(200) + .extract(); + assertTrue(response.path("method") != null, + "a benign terminated request must still be forwarded to the upstream"); + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java new file mode 100644 index 00000000..4a3eec77 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java @@ -0,0 +1,151 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Proves {@code tls.mtls} client-certificate verification at the handshake level (GW-06 behaviour, + * not a flag-read): with {@code mtls.enabled} and a {@code client_ca} trust anchor, the terminated + * listener requires and verifies a client certificate. A client presenting a cert the {@code + * client_ca} trusts completes the handshake; a client presenting no certificate, or one signed by a + * foreign CA, is rejected at the TLS layer — the failure is a handshake abort, never an + * application-level {@code 4xx}, because verification happens before any HTTP is exchanged. + *

+ * This is the running-edge complement to the unit-level {@code MtlsServerCustomizerTest}, which + * proves the config→client-auth mapping in isolation; here the accept/reject decision is proven + * against the live TLS stack. + *

+ * Runtime preconditions (supplied by the {@code -Pintegration-tests} stack, not by + * this black-box client): the mTLS-terminated listener is reachable on {@link #mtlsPort()}, its + * {@code client_ca} trusts the identity in the PKCS#12 keystore named by + * {@code -Dtest.mtls.client.keystore} (password {@code -Dtest.mtls.client.password}), and the foreign + * identity in {@code -Dtest.mtls.wrong.keystore} is signed by a CA the {@code client_ca} does NOT + * trust. The keystores follow the same provisioning convention as {@code certificates/}. + */ +class MtlsHandshakeIT extends BaseIntegrationTest { + + private static int mtlsPort() { + return Integer.parseInt(System.getProperty("test.mtls.port", "10443")); + } + + @Test + @DisplayName("a client cert the client_ca trusts completes the mTLS handshake") + void trustedClientCertAccepted() throws Exception { + SSLContext context = clientContext( + System.getProperty("test.mtls.client.keystore"), + System.getProperty("test.mtls.client.password", "localhost-trust")); + assertDoesNotThrow(() -> handshake(context), + "a client certificate trusted by client_ca must complete the mTLS handshake"); + } + + @Test + @DisplayName("a client presenting no certificate is rejected at the handshake") + void noClientCertRejected() throws Exception { + // No KeyManager → the client offers no certificate; a require-and-verify listener aborts the + // handshake rather than serving the request. + SSLContext context = clientContext(null, null); + assertThrows(SSLException.class, () -> handshake(context), + "a missing client certificate must be rejected at the TLS handshake, not as an HTTP status"); + } + + @Test + @DisplayName("a client cert signed by a foreign CA is rejected at the handshake") + void wrongCaClientCertRejected() throws Exception { + SSLContext context = clientContext( + System.getProperty("test.mtls.wrong.keystore"), + System.getProperty("test.mtls.wrong.password", "wrong-trust")); + assertThrows(SSLException.class, () -> handshake(context), + "a client certificate signed by a CA the client_ca does not trust must be rejected"); + } + + /** + * Opens a TLS connection to the mTLS listener and drives the handshake to completion. The server + * certificate is trust-all (the stack's self-signed material); only the CLIENT-auth outcome is + * under test. + */ + private static void handshake(SSLContext context) throws IOException { + SSLSocketFactory factory = context.getSocketFactory(); + try (SSLSocket socket = (SSLSocket) factory.createSocket("localhost", mtlsPort())) { + socket.setSoTimeout(15_000); + socket.startHandshake(); + } + } + + /** + * Builds an SSL context whose client identity is loaded from {@code keystorePath} (a PKCS#12), or + * that offers no client certificate when {@code keystorePath} is {@code null}. The server trust is + * always trust-all — this suite asserts the client-auth decision, not server-cert validation. + */ + private static SSLContext clientContext(String keystorePath, String password) throws Exception { + KeyManager[] keyManagers = null; + if (keystorePath != null) { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + try (InputStream in = Files.newInputStream(Path.of(keystorePath))) { + keyStore.load(in, password == null ? new char[0] : password.toCharArray()); + } + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(keyStore, password == null ? new char[0] : password.toCharArray()); + keyManagers = kmf.getKeyManagers(); + } + SSLContext context = SSLContext.getInstance("TLS"); + context.init(keyManagers, new TrustManager[]{new TrustAllManager()}, new SecureRandom()); + return context; + } + + /** + * A trust-all {@link X509TrustManager} for the stack's self-signed server certificate. Scoped + * strictly to this black-box IT — never a production trust decision. Only the client-auth outcome + * is under test here. + */ + private static final class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: not exercised on the client side. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: the stack's self-signed server certificate is intentionally accepted. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java new file mode 100644 index 00000000..3db39676 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java @@ -0,0 +1,193 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Proves the opaque L4 passthrough relay's resilience contract under a mid-stream transport fault: a + * connection reset injected into the relayed byte stream by Toxiproxy (D4 adds it to the compose + * stack) propagates back to the client as a connection abort, not an indefinite hang. A relay + * that swallowed the peer reset and left the client leg open would surface as a read timeout; a + * correct relay tears the client leg down promptly, surfacing an {@link IOException} that is NOT a + * {@link SocketTimeoutException}. + *

+ * The fault is driven entirely through Toxiproxy's REST control API (JDK {@link HttpClient}, no + * Toxiproxy client dependency): the test creates a proxy in front of {@code passthrough-backend:8443} + * and adds a {@code reset_peer} toxic, then relays a passthrough connection through the gateway and + * observes the abort. + *

+ * Runtime preconditions (supplied by the {@code -Pintegration-tests} stack): the + * Toxiproxy control API is reachable at {@code -Dtest.toxiproxy.url} (default + * {@code http://localhost:8474}); the gateway maps the fault passthrough SNI {@link #faultSni()} to + * the Toxiproxy listen endpoint {@link #proxyListen()}, which forwards to + * {@code passthrough-backend:8443}. + */ +class PassthroughFaultIT extends BaseIntegrationTest { + + private static final String PROXY_NAME = "passthrough-fault"; + + private final HttpClient control = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)).build(); + + private static String toxiproxyUrl() { + return System.getProperty("test.toxiproxy.url", "http://localhost:8474"); + } + + private static String proxyListen() { + // The address the gateway's passthrough target resolves to (inside the compose network). + return System.getProperty("test.toxiproxy.listen", "0.0.0.0:8666"); + } + + private static String proxyUpstream() { + return System.getProperty("test.toxiproxy.upstream", "passthrough-backend:8443"); + } + + private static String faultSni() { + return System.getProperty("test.passthrough.fault.sni", "fault.test.example"); + } + + private static int httpsPort() { + return Integer.parseInt(System.getProperty("test.https.port", "10443")); + } + + @BeforeEach + void createFaultProxy() throws Exception { + deleteProxyQuietly(); + String body = "{\"name\":\"" + PROXY_NAME + "\",\"listen\":\"" + proxyListen() + + "\",\"upstream\":\"" + proxyUpstream() + "\",\"enabled\":true}"; + send("POST", "/proxies", body); + // A reset_peer toxic tears the connection down mid-stream after a short delay. + String toxic = "{\"type\":\"reset_peer\",\"attributes\":{\"timeout\":200}}"; + send("POST", "/proxies/" + PROXY_NAME + "/toxics", toxic); + } + + @AfterEach + void removeFaultProxy() { + deleteProxyQuietly(); + } + + @Test + @DisplayName("a mid-stream reset on the passthrough relay surfaces as an abort, not a hang") + void midStreamResetSurfacesAsAbort() { + // A generous read timeout distinguishes an abort (fast IOException) from a hang (which would + // instead trip the SocketTimeoutException at the timeout boundary). + IOException failure = assertThrows(IOException.class, () -> relayThroughFault(faultSni()), + "a mid-stream reset must propagate to the client as a connection abort"); + assertFalse(failure instanceof SocketTimeoutException, + "the relay must tear the client leg down promptly (abort), not leave it hanging until timeout"); + } + + /** + * Opens a passthrough TLS connection through the gateway (SNI = the fault hostname, relayed via + * Toxiproxy), writes a probe byte, and reads — the injected reset must surface as an + * {@link IOException} well within the socket read timeout. + */ + private static void relayThroughFault(String sni) throws Exception { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[]{new TrustAllManager()}, new SecureRandom()); + SSLSocketFactory factory = context.getSocketFactory(); + try (SSLSocket socket = (SSLSocket) factory.createSocket("localhost", httpsPort())) { + SSLParameters params = socket.getSSLParameters(); + params.setServerNames(List.of(new SNIHostName(sni))); + socket.setSSLParameters(params); + socket.setSoTimeout(10_000); + socket.startHandshake(); + socket.getOutputStream().write("GET / HTTP/1.1\r\nHost: passthrough-backend\r\n\r\n".getBytes()); + socket.getOutputStream().flush(); + // Drain until the reset arrives; the read throws the abort we assert on. + byte[] buffer = new byte[512]; + while (socket.getInputStream().read(buffer) != -1) { + // keep reading until the peer reset tears the stream down + } + } + } + + private void send(String method, String path, String body) throws Exception { + HttpRequest request = HttpRequest.newBuilder(URI.create(toxiproxyUrl() + path)) + .timeout(Duration.ofSeconds(10)) + .header("Content-Type", "application/json") + .method(method, HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = control.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() >= 300) { + throw new IOException("Toxiproxy " + method + " " + path + " failed: " + + response.statusCode() + " " + response.body()); + } + } + + private void deleteProxyQuietly() { + try { + HttpRequest request = HttpRequest.newBuilder(URI.create(toxiproxyUrl() + "/proxies/" + PROXY_NAME)) + .timeout(Duration.ofSeconds(10)) + .DELETE() + .build(); + control.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + // Best-effort teardown: a missing proxy (404) or transient control-plane error must not + // fail the test body — the assertion under test is the relay abort, not proxy bookkeeping. + } + } + + /** + * A trust-all {@link X509TrustManager} for the relayed self-signed backend certificate. Scoped + * strictly to this black-box IT — never a production trust decision. + */ + private static final class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: not exercised on the client side. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: the relayed backend's self-signed certificate is intentionally accepted. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java new file mode 100644 index 00000000..1013ce55 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java @@ -0,0 +1,132 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.List; +import javax.net.ssl.SNIHostName; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Proves the accept-time SNI split's opaque L4 passthrough relay end-to-end: a TLS ClientHello whose + * SNI matches a {@code tls.passthrough_sni} entry is split off at L4 and relayed, byte-for-byte, to + * the TLS-enabled {@code passthrough-backend} the compose stack adds (D4) — the gateway never + * terminates it. The observable proof is the peer certificate the client negotiates through the + * relay: it is the backend's ({@code CN=passthrough-backend}), not the gateway's terminated + * {@code CN=localhost} identity. A terminated request on the same public listener is still fully + * served, proving the split is per-connection and does not disturb the L7 path. + *

+ * Runtime preconditions (supplied by the {@code -Pintegration-tests} stack, not by + * this black-box client): the gateway is configured with a {@code passthrough_sni} entry for + * {@link #passthroughSni()} resolving to {@code passthrough-backend:8443}, and the terminated + * listener serves the stack's {@code CN=localhost} certificate. This suite holds no key material — + * it only observes the negotiated peer identity. + */ +class TlsPassthroughIT extends BaseIntegrationTest { + + /** The passthrough SNI the gateway splits off at L4; overridable for a differently-named stack. */ + private static String passthroughSni() { + return System.getProperty("test.passthrough.sni", "passthrough.test.example"); + } + + private static int httpsPort() { + return Integer.parseInt(System.getProperty("test.https.port", "10443")); + } + + @Test + @DisplayName("a passthrough-SNI connection negotiates the backend's certificate, proving opaque relay") + void passthroughNegotiatesBackendCertificate() throws Exception { + // Act — open a TLS connection to the public listener with SNI set to the passthrough hostname. + X509Certificate peerLeaf = handshakePeerLeaf("localhost", httpsPort(), passthroughSni()); + + // Assert — the negotiated identity is the BACKEND's, so the gateway relayed opaquely and never + // terminated: a terminated split would have surfaced the gateway's own CN=localhost identity. + String subject = peerLeaf.getSubjectX500Principal().getName(); + assertTrue(subject.contains("CN=passthrough-backend"), + "the passthrough relay must surface the backend's certificate identity, was: " + subject); + } + + @Test + @DisplayName("terminated traffic on the same public listener is still fully served") + void terminatedTrafficStillServed() { + // A terminated request (default SNI=localhost) flows through the full L7 pipeline to go-httpbin; + // the echoed method proves the request reached the upstream through the terminated path. + var response = given() + .when() + .get("/proxy/get") + .then() + .statusCode(200) + .extract(); + assertNotNull(response.path("method"), "terminated traffic must still reach the go-httpbin upstream"); + } + + /** + * Completes a TLS handshake to {@code host:port} with the given SNI (trust-all, mirroring + * {@link BaseIntegrationTest}'s relaxed validation for the stack's self-signed material) and + * returns the negotiated leaf certificate. + */ + private static X509Certificate handshakePeerLeaf(String host, int port, String sni) throws Exception { + SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, new TrustManager[]{new TrustAllManager()}, new SecureRandom()); + SSLSocketFactory factory = context.getSocketFactory(); + try (SSLSocket socket = (SSLSocket) factory.createSocket(host, port)) { + SSLParameters params = socket.getSSLParameters(); + params.setServerNames(List.of(new SNIHostName(sni))); + socket.setSSLParameters(params); + socket.setSoTimeout(15_000); + socket.startHandshake(); + Certificate[] chain = socket.getSession().getPeerCertificates(); + assertTrue(chain.length > 0, "the handshake must yield a peer certificate chain"); + return (X509Certificate) chain[0]; + } + } + + /** + * A trust-all {@link X509TrustManager} scoped strictly to this black-box IT — it inspects the + * negotiated identity itself, so it must accept whatever certificate the peer presents. + */ + private static final class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: the local stack's self-signed certificates are intentionally accepted. + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + // Trust-all test manager: the negotiated identity is asserted by the test body, not rejected here. + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + } +} From 719d27851b8b3f7b7cf7619f5c90976f4fc423ce Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:21:12 +0200 Subject: [PATCH 05/15] docs(tls-edge): author three-layer TLS-edge documentation (D5) Reference layer: add a TLS-active status section to doc/configuration.adoc documenting the now-enforced tls.passthrough_sni / tls.mtls behavior (accept-time SNI split, exact case-insensitive matching, no-base-path alias rule, the two guards, mTLS on terminated connections only, no wildcards) without touching the PLAN-06 reserved gateway-paths block. Operator layer: doc/user/tls-edge.adoc covers configuring passthrough SNI and mTLS plus the emergency trust-root-replacement blast radius (swapping client_ca invalidates the entire old trust root at once; no per-cert revocation, no CRL/OCSP). Developer layer: doc/development/tls-edge.adoc covers the Vert.x NetServer front, the accept-time L4/L7 split, the internal-handoff loopback relay and port ownership (8443 public / 8444 internal), the runtime Host-vs-SNI smuggle guard, and the fail-closed GW-06 rationale with the Traefik CVE-2026-32305 / CVE-2025-66491 and KrakenD CVE-2026-39821 references. Record the TLS edge in doc/architecture.adoc's component model and append-only index lines in doc/README.adoc, doc/user/README.adoc, and doc/development/README.adoc. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Pq3eQyg78SuTKrC8pLpLs --- doc/README.adoc | 7 ++ doc/architecture.adoc | 22 +++++ doc/configuration.adoc | 14 ++++ doc/development/README.adoc | 5 ++ doc/development/tls-edge.adoc | 146 ++++++++++++++++++++++++++++++++++ doc/user/README.adoc | 6 ++ doc/user/tls-edge.adoc | 78 ++++++++++++++++++ 7 files changed, 278 insertions(+) create mode 100644 doc/development/tls-edge.adoc create mode 100644 doc/user/tls-edge.adoc diff --git a/doc/README.adoc b/doc/README.adoc index d5a3e6de..8d7b9a44 100644 --- a/doc/README.adoc +++ b/doc/README.adoc @@ -70,6 +70,13 @@ built* and *how the parts fit together*. | Trust boundaries and STRIDE, plus a failure catalogue where every class that produced a real CVE in a comparable gateway/proxy or OAuth/OIDC stack is mapped to an API Sheriff control, a status, and a testable assertion. Doubles as the 1.0 security release gate. + +| TLS Edge (three-layer topic) +| The accept-time SNI split and mTLS edge, documented across all three layers: the + link:configuration.adoc#_tls[`tls` configuration reference], the link:user/tls-edge.adoc[operator + guide] (configuring passthrough SNI / mTLS and the trust-root-replacement blast radius), and the + link:development/tls-edge.adoc[developer topic] (front-listener architecture, the L4/L7 split, and + the fail-closed GW-06 rationale). |=== == Deployment Variants diff --git a/doc/architecture.adoc b/doc/architecture.adoc index 3d9d7861..d0edaa71 100644 --- a/doc/architecture.adoc +++ b/doc/architecture.adoc @@ -33,6 +33,28 @@ The authentication stage is where the three variants differ: offline bearer vali (link:variants/02-bff-session.adoc[Variant 2]), or OIDC encrypted-cookie mediation (link:variants/03-bff-cookie.adoc[Variant 3]). Everything else in the pipeline is shared. +=== TLS Edge (Accept-Time SNI Split) + +The leftmost component is a Vert.x `NetServer` *front listener* that owns the public TLS port. It +reassembles the full TLS ClientHello, reads the Server Name Indication (RFC 6066) in cleartext, and +splits each connection at accept time: + +* *SNI listed in `tls.passthrough_sni`* -> an *opaque L4 TCP relay*: the intact byte stream + (ClientHello included) is forwarded, undecrypted, to the topology-resolved backend, which + completes the handshake and presents *its own* certificate. The gateway never terminates and sees + no L7 detail. +* *any other SNI, or none* -> an *internal handoff to the terminated L7 listener*, which completes + the handshake with the gateway's own certificate (enforcing `min_version`, `alpn`, and `mtls` + client-certificate verification) and runs the full fixed pipeline. + +Two guards keep the split unambiguous: a boot-time check fails startup when a route's `match.host` +equals a passthrough SNI, and a runtime *Host-vs-SNI smuggle guard* rejects a terminated request +whose `Host` header names a passthrough SNI with `404` (`PASSTHROUGH_HOST_SMUGGLED`) *before route +selection* -- so the passthrough backend's reserved identity can never be reached through the +terminated listener. Selection is *fail-closed* (GW-06): anything not positively matched as +passthrough is terminated, never relayed. The front-listener internals and the fail-closed rationale +are documented in the link:development/tls-edge.adoc[developer TLS-edge topic]. + === Configuration Subsystem Configuration is loaded once at startup from static files (see link:configuration.adoc[Configuration Model]), diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 2dddaffb..22c19b19 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -765,6 +765,20 @@ so an ECH client would treat the handshake as ECH rejection (it authenticates th `public_name`, refuses to send the real request, and retries or aborts). Listing an *inner* hostname in `passthrough_sni` does nothing -- it never appears in the outer SNI. +[[_tls_active]] +*Status -- active.* The `tls.passthrough_sni` and `tls.mtls` blocks are *enforced at runtime*, not +merely parsed: an accept-time front listener reads the SNI from the reassembled ClientHello and +splits each connection at L4 (passthrough) or L7 (terminated); terminated connections apply +`mtls` client-certificate verification; and the runtime Host-vs-SNI smuggle guard rejects a +terminated request whose `Host` names a passthrough SNI with `404` before route selection. Three +constraints an operator must keep in mind, all stated above and restated here because they are the +common configuration mistakes: SNI matching is *exact* -- *no wildcard* entries; a passthrough +alias URL carries *no base path*; and `mtls` applies to *terminated connections only* (the gateway +never participates in a passthrough handshake). Operating these keys -- including the emergency +trust-root-replacement blast radius -- is covered in the link:user/tls-edge.adoc[operator TLS-edge +topic]; the front-listener architecture and the fail-closed (GW-06) rationale are covered in the +link:development/tls-edge.adoc[developer TLS-edge topic]. + [[_protocols]] === `protocol` diff --git a/doc/development/README.adoc b/doc/development/README.adoc index e0e72ae8..a9c170c1 100644 --- a/doc/development/README.adoc +++ b/doc/development/README.adoc @@ -48,6 +48,11 @@ This tree is seeded here and grows as contributor-facing material lands. the edge protocol-dispatch seam that routes WebSocket and gRPC down their specialised paths -- including where the handshake/relay and forced-h2/trailer code lives, and how to add a new processor. + +| link:tls-edge.adoc[TLS Edge -- Front Listener and the Accept-Time SNI Split] +| The Vert.x `NetServer` front architecture, the accept-time L4/L7 split, the internal-handoff + loopback relay and public/internal port ownership, the runtime Host-vs-SNI smuggle guard, and the + fail-closed (GW-06) rationale with the Traefik / KrakenD CVEs it answers. |=== == Scope of This Layer diff --git a/doc/development/tls-edge.adoc b/doc/development/tls-edge.adoc new file mode 100644 index 00000000..6acddc03 --- /dev/null +++ b/doc/development/tls-edge.adoc @@ -0,0 +1,146 @@ += API Sheriff -- TLS Edge: Front Listener and the Accept-Time SNI Split +:toc: +:toclevels: 2 +:sectnums: + +The *contributor* view of the TLS edge: how the accept-time SNI split is built, which component +owns which port, and why selection is *fail-closed*. Operators configuring these keys want the +link:../user/tls-edge.adoc[operator TLS-edge topic]; the field contract is the +link:../configuration.adoc#_tls[Configuration Reference `tls` section]; the threat this design +closes is link:../security-threat-model.adoc#gw-06[GW-06] in the threat model. + +The whole edge lives under `de.cuioss.sheriff.gateway.tls` plus one pipeline stage +(`de.cuioss.sheriff.gateway.pipeline.PassthroughHostGuardStage`). It is framework-agnostic at the +seam (link:../adr/0005-module-structure.adoc[ADR-0005]) apart from `TlsEdgeProducer`, which is the +single CDI-bound boot component. + +== Front Listener (Vert.x `NetServer`) + +The leftmost component is `SniFrontListener` -- a *plain-TCP* Vert.x `NetServer` that binds the +public TLS port. It is plain TCP on purpose: the TLS ClientHello is sent in cleartext at the record +layer, so the Server Name Indication (RFC 6066) can be read *without any decryption or private key*. +The front never holds the gateway's certificate; it only reads the SNI and forwards bytes. + +On each accepted connection an `Incoming` reassembles the ClientHello: + +* bytes are appended to a per-connection buffer as they arrive, and handed to + `ClientHelloSniParser` after every chunk; +* the parser returns `complete() == false` until the full ClientHello record is present -- the + listener *never routes on a partial record* (a fragmented-ClientHello defence, see + <<_fail_closed_gw_06>>); +* once the parser reports a complete ClientHello the socket is *paused* (`socket.pause()`), + `decided` latches true, and the connection is routed exactly once. + +All callbacks run on the connection's Vert.x event loop, so the reassembly buffer and the `decided` +latch are single-threaded and need no synchronization. + +The front listener is *conditional*: `TlsEdgeProducer` constructs and starts it only when +`tls.passthrough_sni` is non-empty. When passthrough is unconfigured the front is never created, the +terminated Quarkus HTTPS listener owns the public port directly, and the edge adds *zero* runtime +overhead -- the default single-listener topology is unchanged. + +== The Accept-Time L4/L7 Split + +`SniFrontListener.route(...)` makes a single decision per connection, keyed on the normalized SNI: + +* *SNI present in the passthrough map* -> the connection, *buffered ClientHello included*, is handed + to `PassthroughRelay` as an *opaque L4 TCP relay* (`RelayKind.PASSTHROUGH`). The intact, + still-encrypted byte stream is spliced to the resolved backend; the backend completes the + handshake and presents *its own* certificate. The gateway never terminates and sees no L7 detail. +* *any other SNI* -> the same still-encrypted stream is relayed (`RelayKind.TERMINATED`) to the + *internal* terminated Quarkus HTTPS listener, which completes the handshake with the gateway's own + certificate -- enforcing `min_version`, `alpn`, and `mtls` client-certificate verification -- and + runs the full fixed L7 pipeline. +* *missing / malformed SNI* -> the terminated-strict path, plus an audited `WARN` + (`CLIENT_HELLO_FAIL_CLOSED`) that records the disposition only, never raw bytes. This is the + fail-closed branch (<<_fail_closed_gw_06>>). + +SNI matching is *exact, case-insensitive, and root-dot-insensitive*. `normalizeSni(...)` lowers the +host in the root locale, strips it, and removes a single trailing FQDN dot; `TlsEdgeProducer` builds +the relay-map keys through the same routine so insertion and lookup always agree. There is *no +wildcard* matching -- every passthrough host is listed literally. + +== Internal-Handoff Mechanics and Port Ownership + +There are two ports, wired by `TlsEdgeProducer`: + +[cols="1,2,3"] +|=== +| Port | Config property (default) | Owner + +| Public TLS port +| `sheriff.tls.public-port` (`8443`) +| The `SniFrontListener` `NetServer` when passthrough is active; otherwise the terminated Quarkus + HTTPS listener directly. + +| Internal HTTPS port +| `sheriff.tls.internal-https-port` (`8444`) +| The terminated Quarkus HTTPS listener, bound to `localhost` only. Every non-passthrough connection + is relayed here for termination. +|=== + +The internal handoff is a *loopback relay*, not an in-process call: terminated connections are +spliced from the front listener to `localhost:8444`, where the ordinary Quarkus HTTPS listener does +the TLS termination and L7 processing it would do anyway. This keeps the terminated pipeline +identical whether or not the front listener is present -- the split is purely an accept-time +prefix, and the terminated listener is unaware it sits behind a relay. + +`TlsEdgeProducer` builds the immutable SNI -> `RelayTarget` map from `GatewayConfig.tls()` and the +resolved topology. Per link:../adr/0009-asymmetric-alias-resolution-failure.adoc[ADR-0009] an +unresolved passthrough alias means the `ConfigValidator` already failed the boot, so a present entry +that unexpectedly fails to resolve is skipped defensively rather than aborting a second time. Boot is +*fail-fast*: the front listener's port bind is awaited (15 s timeout) and a bind failure aborts +startup, so the gateway never serves on a half-open TLS edge. + +== Runtime Host-vs-SNI Smuggle Guard + +The L4 split routes on the *outer SNI*, but a terminated request also carries an L7 `Host` header, +and the two can disagree. `PassthroughHostGuardStage` is the runtime half of the guard: a *pre-check +before stage-2 route selection* that rejects a terminated request whose `Host` names a +`passthrough_sni` hostname with `404` (`EventType.PASSTHROUGH_HOST_SMUGGLED`). + +A passthrough hostname is meant to be split off at L4 and never terminated. A terminated request +carrying that hostname in its `Host` header is therefore attempting to reach the passthrough +backend's reserved identity *through* the terminated listener -- a host-confusion / request-smuggling +vector. The guard's `Host` normalization mirrors the front listener's SNI normalization (lower-cased, +trailing FQDN dot removed) and additionally strips a single `:port` suffix, so the two matchers agree +on what "the same host" means. + +This runtime guard is distinct from its two boot-time and framing siblings: + +* the *boot-time collision rule* (`ConfigValidator.validatePassthroughHostCollision`) fails startup + when a route's `match.host` equals a passthrough SNI; and +* the framing / anti-smuggling `FramingGate`, which is orthogonal request-framing hygiene. + +The stage is inert when `passthrough_sni` is empty and consumes only the agnostic `PipelineRequest`, +so it stays framework-agnostic (link:../adr/0005-module-structure.adoc[ADR-0005]). + +[#_fail_closed_gw_06] +== Fail-Closed Selection (GW-06) + +Selection is *fail-closed*: anything not *positively matched* as a passthrough SNI is terminated +under the strict policy, never relayed and never dropped to a permissive default. Two rules make +this concrete, and both are direct responses to real gateway CVEs catalogued under +link:../security-threat-model.adoc#gw-06[GW-06]: + +* *Full-ClientHello reassembly before routing.* The listener buffers until `ClientHelloSniParser` + reports a complete record and only then decides. This defeats the *fragmented-ClientHello* class: + *Traefik CVE-2026-32305*, where a fragmented ClientHello yielded an empty SNI that fell through to + a *permissive* default TLS config and bypassed mTLS. A partial record here yields no decision at + all -- the front waits for the rest. +* *Empty/unresolved SNI takes the strict path, never a permissive default.* A missing or malformed + SNI is routed to the terminated-strict listener (which still enforces `mtls`), not to a + passthrough or a no-client-cert fallback. This is the corrective for the *silent verify-inversion* + class -- *Traefik CVE-2025-66491*, where `proxy-ssl-verify: on` was mapped to Go's + `InsecureSkipVerify: true`, leaving verification *off* for months. The edge never expresses "verify + off" as a fallback; the strict listener is the only non-passthrough destination. + +Host-name normalization for the security decision is spec-conformant and tested, closing the +*normalization-bypass* class -- *KrakenD CVE-2026-39821*, where a punycode host (`xn--example-.com`) +normalized to `example.com` and bypassed a host-based rule. The passthrough collision rule and the +runtime `Host` guard share the same normalization routine, so a punycode host that normalizes onto a +passthrough SNI is caught by the same match rather than slipping between two disagreeing normalizers. + +The discipline the CVEs teach is *assert behaviour, not the flag*: the integration suites verify a +real no-cert handshake *fails* when mTLS is enabled and a fragmented/empty-SNI ClientHello never +reaches a passthrough or a no-client-cert default -- not merely that a boolean parsed. diff --git a/doc/user/README.adoc b/doc/user/README.adoc index 648d9b88..0e37018d 100644 --- a/doc/user/README.adoc +++ b/doc/user/README.adoc @@ -32,6 +32,12 @@ layer. setting `allowed_origins`, tuning `websocket.idle_timeout_seconds`, and authoring a gRPC route. Links to the Configuration Reference for the field contract and to the Architecture document for the gRPC error mapping. + +| link:tls-edge.adoc[TLS Edge -- Passthrough SNI and mTLS] +| Configuring `tls.passthrough_sni` (relay selected hostnames at L4 without terminating) and + `tls.mtls` (require-and-verify a client certificate on terminated connections), plus the + emergency trust-root-replacement blast radius every operator must understand before relying on + mTLS. Links to the Configuration Reference for the field contract. |=== == Scope of This Layer diff --git a/doc/user/tls-edge.adoc b/doc/user/tls-edge.adoc new file mode 100644 index 00000000..62ba45c8 --- /dev/null +++ b/doc/user/tls-edge.adoc @@ -0,0 +1,78 @@ += API Sheriff -- TLS Edge: Passthrough SNI and mTLS +:toc: +:toclevels: 2 +:sectnums: + +An operator guide to the two TLS-edge features: *SNI passthrough* (relay selected hostnames at L4 +without terminating) and *mTLS* (require and verify a client certificate on terminated +connections). This topic describes how to *configure and operate* these keys; the authoritative +field contract lives in the link:../configuration.adoc#_tls[Configuration Reference `tls` section], +and the design rationale in the link:../development/tls-edge.adoc[developer TLS-edge topic]. + +== Passthrough SNI + +A passthrough entry maps an *SNI hostname* to a *topology alias*; every connection whose TLS +ClientHello carries that SNI is relayed, undecrypted, to the alias's `host:port`. The gateway does +not terminate it, sees no L7 detail, and the *backend* presents its own certificate to the client. + +[source,yaml] +---- +tls: + passthrough_sni: + backend.example.com: PASSTHROUGH_BACKEND # SNI -> topology alias +---- + +The alias resolves via `topology.properties` / `TOPOLOGY_PASSTHROUGH_BACKEND`, exactly like a +route's `base_url`, so a passthrough target is relocated per environment by changing only the +topology. + +Operating rules to keep in mind: + +* *Exact match, case-insensitive, no wildcards.* Each hostname is listed literally; + `*.example.com` is not supported. List every host you intend to pass through. +* *No base path on the alias.* An L4 relay has no notion of a path -- the resolved URL must be a + bare `host:port`. A base-path'd alias fails the boot. +* *Declared here, per SNI -- never on a route.* Passthrough is a listener-level concern, not a + route property. +* *Terminated and passthrough coexist* on the same public port. Any SNI you do not list is + terminated and runs the full L7 pipeline. +* *A terminated request whose `Host` names a passthrough SNI is rejected `404`* before routing -- + this is the runtime Host-smuggle guard; it is automatic and needs no configuration. + +== mTLS (client-certificate verification) + +Enabling `mtls` makes the *terminated* listener require and verify a client certificate against a +configured CA trust anchor. Passthrough connections are unaffected -- the gateway never participates +in that handshake. + +[source,yaml] +---- +tls: + mtls: + enabled: true + client_ca: /etc/sheriff/client-ca.pem # the trust anchor client certs must chain to +---- + +A connection presenting no certificate, or one that does not chain to `client_ca`, is rejected at +the *TLS handshake* -- before any HTTP is exchanged. There is no application-level fallback and no +"optional/want" mode: mTLS is require-and-verify or off. + +== Operator Consideration: Emergency Trust-Root Replacement + +`client_ca` is a *single global trust anchor* for the whole terminated listener. Replacing it has a +*global blast radius* that every operator must understand before relying on mTLS: + +* *Swapping `client_ca` invalidates the entire old trust root at once.* Every client certificate + that chained to the old CA stops being accepted the moment the new `client_ca` takes effect -- + there is no gradual cutover and no per-client exemption. +* *There is no per-certificate revocation.* You cannot revoke one compromised client certificate + in isolation; the only lever is replacing the trust root, which rejects *all* certificates under + the old root. +* *No CRL or OCSP initially.* Certificate-revocation-list and OCSP checking are *not* performed. + Revocation is therefore an all-or-nothing trust-root rotation, not a targeted per-certificate + action. + +Plan trust-root rotation as a coordinated event: stage the new client certificates to every client +*before* switching `client_ca`, because the switch is atomic and unforgiving. For a single +compromised client where you cannot rotate the whole fleet immediately, the compensating control is +network-level (drop the client at the perimeter), not certificate-level. From 1d05c80344138ac9017f003cfa15527c23804442 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:22:16 +0200 Subject: [PATCH 06/15] docs(adr): correct ADR-0004 forward-URI reconstruction wording (D6) The Decision "Decompose-on-read" bullet described the forward URI as resolved(base_url) + route.upstream.path + , implying concatenation of three terms. The shipped semantics (RouteTableBuilder.applyRouteUpstreamPath and DispatchStage.upstreamRequestUri) are REPLACE: a declared route.upstream.path replaces the alias-derived base path, and the forward URI is stripTrailingSlash(effective base path) + . Doc-only; the decompose-on-read decision itself is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Pq3eQyg78SuTKrC8pLpLs --- doc/adr/0004-topology-indirection.adoc | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/doc/adr/0004-topology-indirection.adoc b/doc/adr/0004-topology-indirection.adoc index 87437753..d5c36210 100644 --- a/doc/adr/0004-topology-indirection.adoc +++ b/doc/adr/0004-topology-indirection.adoc @@ -51,8 +51,10 @@ researched what the majority of widely-used systems do for human-authored, env-o visible in-file placeholder instead of a convention-named lookup.)* * *Decompose-on-read (Kong model).* On load, each resolved URL is validated and decomposed once into `{scheme, host, port, base-path}`; the rest of the gateway works with the structured components. A - route's upstream is `resolved(base_url) + route.upstream.path + ` (see link:../configuration.adoc[Configuration -- `upstream`]). + declared `route.upstream.path` *replaces* the alias-derived base path (it does not append to it), + and the forward URI is `stripTrailingSlash(effective base path) + `, where the effective base path is the `route.upstream.path` when declared and + the alias-derived base path otherwise (see link:../configuration.adoc[Configuration -- `upstream`]). * *No component split initially.* Per-component overrides (e.g. `TOPOLOGY_APP1_PORT`) are *not* implemented now. They may be added later as a narrow escape hatch (mirroring Kong: full URL as the ergonomic default, components as fine-grained override) only if a real partial-override need appears. From 768d4255e9948b13b3bb3615130eebe6c7ea3992 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:56:31 +0200 Subject: [PATCH 07/15] feat(benchmarks): add passthrough-relay benchmark and empty-SNI no-regression comparator Add the k6 passthrough_relay.js aspect driving the opaque L4 TCP relay path (mapped SNI -> L4 relay -> TLS backend), plus an empty-passthrough_sni mode re-measuring the same proxied static route for a no-regression check against the PLAN-04 plain-proxy baseline. Add PassthroughBaselineComparator, a noise-band comparator asserting the empty-mode run does not regress beyond a percentile-band tolerance (n/a, never 0, for an unmeasured metric), with a JUnit 5 unit test using CUI Test Generator. Register the two -Pbenchmark k6 executions and the post-integration-test comparator, and document the new aspect and its two modes in the README aspect matrix and methodology. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Pq3eQyg78SuTKrC8pLpLs --- benchmarks/README.adoc | 41 ++- benchmarks/pom.xml | 87 +++++ .../k6/benchmark/K6BenchmarkLogMessages.java | 35 ++ .../PassthroughBaselineComparator.java | 324 ++++++++++++++++++ .../resources/k6-scripts/passthrough_relay.js | 75 ++++ .../PassthroughBaselineComparatorTest.java | 299 ++++++++++++++++ 6 files changed, 855 insertions(+), 6 deletions(-) create mode 100644 benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java create mode 100644 benchmarks/src/main/resources/k6-scripts/passthrough_relay.js create mode 100644 benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java diff --git a/benchmarks/README.adoc b/benchmarks/README.adoc index 294cb39e..e170cff4 100644 --- a/benchmarks/README.adoc +++ b/benchmarks/README.adoc @@ -24,9 +24,10 @@ The two lanes run deliberately different sets, so a number is always attributabl |Lane |Executions |Contents |CI baseline (`-Pbenchmark`) -|10 -|The eight matrix aspects *plus* the two retained non-matrix benchmarks `healthLiveCheck` and - `gatewayHealth`. +|12 +|The eight cross-gateway matrix aspects, the two retained non-matrix benchmarks `healthLiveCheck` + and `gatewayHealth`, *plus* the two API-Sheriff-only passthrough-relay executions (`mapped` relay + throughput and the `empty`-mode no-regression run). |On-demand comparison (`run-comparison.sh`) |8 @@ -41,9 +42,12 @@ closes its aspect list to the eight comparable aspects so that is unreachable. == The aspect matrix -These eight aspects exist route-for-route on both gateways and are the comparable set. The first -six ride the single static fairness backend and measure pure gateway overhead; the last two (`ws`, -`grpc`) ride protocol-appropriate backends instead — see the fairness caveat below the table. +The first eight aspects exist route-for-route on both gateways and are the *comparable set*: the +first six ride the single static fairness backend and measure pure gateway overhead, and the next +two (`ws`, `grpc`) ride protocol-appropriate backends instead — see the fairness caveat below the +table. The final `passthrough` aspect is *API-Sheriff-only* — the opaque L4 relay has no APISIX +route in this lane — so it runs in the CI baseline lane but never in the on-demand cross-gateway +comparison; see the passthrough methodology note under _Methodology_. [cols="1,1,3", options="header"] |=== @@ -90,6 +94,14 @@ six ride the single static fairness backend and measure pure gateway overhead; t |A unary gRPC call over the forced-HTTP/2 relay on the bare service path, against the in-repo `grpc-echo` service. Reports calls/second and call latency. (Per Clarification 1 this k6 gRPC benchmark supersedes the plan doc's `ghz` proposal, keeping the whole matrix on one load generator.) + +|`passthrough` +|`passthrough_relay.js` +|The opaque L4 TCP relay path (`mapped` mode): `k6 -> gateway public TLS port with a mapped SNI -> + L4 relay -> TLS-enabled backend`, measuring relay throughput/latency through the active passthrough + path. Its `empty` mode re-measures the same proxied static route with `passthrough_sni` empty (D1's + zero-overhead default) for a no-regression check against the `unauth` baseline. *API-Sheriff-only*; + like `ws`/`grpc` it rides a real backend — see the passthrough methodology note. |=== [NOTE] @@ -197,6 +209,23 @@ go-httpbin's `/websocket/echo` for `ws`, the in-repo `grpc-echo` service for `gr sides stay symmetric even though these rows are not backend-free. See the fairness caveat under _The aspect matrix_ for how to read the two rows. +Passthrough relay, two modes (API-Sheriff-only):: +The `passthrough` aspect drives the opaque L4 TCP relay and — unlike the six pure-overhead HTTP +aspects, and exactly like `ws` and `grpc` — rides a *TLS-enabled backend* that completes the +handshake and presents its own certificate. It therefore folds a real backend's cost into the +number and is a *protocol-relay measurement*, not a backend-free gateway-overhead figure: read it as +relay overhead versus the terminated baseline within one run, never as an absolute throughput claim. +It runs in two modes selected by `PASSTHROUGH_SNI`. In `mapped` mode the ClientHello names a +`tls.passthrough_sni` host, so the gateway relays the still-encrypted byte stream at L4 without +terminating and the run measures throughput/latency through the active relay path. In `empty` mode +`passthrough_sni` is empty — D1's zero-overhead default, where the accept-time front listener is +never created and the single terminated Quarkus HTTPS listener owns the public port directly — so +the run re-measures exactly the same proxied static route as the PLAN-04 `unauth` (`proxiedStatic`) +baseline. `PassthroughBaselineComparator` reads the empty-mode summary against that stored baseline +and fails the run if throughput dropped, or either latency percentile rose, beyond a fixed +percentile-band noise tolerance (k6 omits `latency_ms.stdev`, so a standard-deviation gate is not +available — see _Known fidelity limit_). A metric a run did not measure renders `n/a`, never `0`. + Readiness, not warm-up discard:: `pre-benchmark-health-check.sh` gates every run on the stack actually serving, so no run starts against a cold or half-started gateway. There is no separate warm-up phase whose samples are diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index cc6552ed..e5f90da1 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -89,6 +89,14 @@ awaitility test + + + + de.cuioss.test + cui-test-generator + test + @@ -319,6 +327,68 @@ + + + run-k6-passthrough-relay-benchmark + integration-test + + exec + + + docker + + compose + run + --rm + k6 + run + /scripts/passthrough_relay.js + + ${integration.compose.dir} + 240000 + + ${k6.vus} + ${k6.duration} + ${k6.output.dir} + mapped + + + + + + + run-k6-passthrough-relay-empty-benchmark + integration-test + + exec + + + docker + + compose + run + --rm + k6 + run + /scripts/passthrough_relay.js + + ${integration.compose.dir} + 240000 + + ${k6.vus} + ${k6.duration} + ${k6.output.dir} + empty + + + + @@ -543,6 +613,23 @@ + + + compare-passthrough-baseline + post-integration-test + + java + + + de.cuioss.sheriff.gateway.k6.benchmark.PassthroughBaselineComparator + + ${k6.output.dir} + + + + dump-keycloak-logs diff --git a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkLogMessages.java b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkLogMessages.java index 2593111b..a129d3e3 100644 --- a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkLogMessages.java +++ b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/K6BenchmarkLogMessages.java @@ -80,6 +80,20 @@ private INFO() { .identifier(4) .template("Wrote comparison summary covering %s aspect(s) to %s") .build(); + + /** Logged once when the empty-passthrough_sni no-regression comparison starts. */ + public static final LogRecord PASSTHROUGH_BASELINE_START = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(5) + .template("Comparing empty-passthrough_sni run '%s' against PLAN-04 baseline '%s'") + .build(); + + /** Logged once when the empty-passthrough_sni run stays within the no-regression noise band. */ + public static final LogRecord PASSTHROUGH_BASELINE_OK = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(6) + .template("Passthrough empty-mode no-regression check passed within the %s noise band") + .build(); } /** @@ -146,5 +160,26 @@ private ERROR() { .identifier(207) .template("No results directory for target '%s' under %s") .build(); + + /** Logged when the passthrough baseline comparator is invoked without the results directory. */ + public static final LogRecord PASSTHROUGH_BASELINE_USAGE_ERROR = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(208) + .template("Usage: PassthroughBaselineComparator ") + .build(); + + /** Logged when a summary the passthrough baseline comparison needs is absent. */ + public static final LogRecord PASSTHROUGH_BASELINE_SUMMARY_MISSING = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(209) + .template("Missing k6 summary '%s' under %s — cannot run the passthrough baseline comparison") + .build(); + + /** Logged when the empty-passthrough_sni run regressed beyond the no-regression noise band. */ + public static final LogRecord PASSTHROUGH_BASELINE_REGRESSION = LogRecordModel.builder() + .prefix(PREFIX) + .identifier(210) + .template("Passthrough empty-mode regressed beyond the %s noise band vs the PLAN-04 baseline: %s") + .build(); } } diff --git a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java new file mode 100644 index 00000000..d7b3c2f1 --- /dev/null +++ b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java @@ -0,0 +1,324 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.k6.benchmark; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.cuioss.tools.logging.CuiLogger; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +/** + * Verifies that the empty-{@code passthrough_sni} proxied route does not regress beyond the run's + * noise band against the stored PLAN-04 plain-proxy baseline. + *

+ * When {@code tls.passthrough_sni} is empty the accept-time front listener is never created (D1's + * zero-overhead default) and the terminated Quarkus HTTPS listener owns the public port directly, so + * the empty-mode run measures exactly the same proxied static route as the PLAN-04 {@code unauth} + * ({@code proxiedStatic}) aspect. This comparator reads the {@code passthroughRelayEmpty} summary + * against the {@code proxiedStatic} summary produced in the same lane and asserts no regression + * beyond a percentile-band tolerance. + *

+ * Noise band, not a point comparison. These are single-node, containerized, + * local-network measurements, so a small run-to-run delta is noise rather than signal. The band is a + * fixed fractional tolerance (default {@value #DEFAULT_TOLERANCE}, overridable via the + * {@code passthrough.baseline.tolerance} system property) rather than a standard-deviation gate, + * because k6 omits {@code latency_ms.stdev} (see the benchmark README): throughput (higher is + * better) may not fall below {@code baseline * (1 - tolerance)}, and each latency percentile (lower + * is better) may not exceed {@code baseline * (1 + tolerance)}. + *

+ * Absent metric is {@code n/a}, never {@code 0}. A metric a run did not measure is + * rendered {@code n/a} and classified {@link Verdict#NOT_MEASURED} — never a {@code 0} that would + * read as a measured collapse and never a false regression — mirroring {@link ComparisonSummaryWriter}. + * + * @author API Sheriff Team + * @since 1.0 + */ +public final class PassthroughBaselineComparator { + + private static final CuiLogger LOGGER = new CuiLogger(PassthroughBaselineComparator.class); + + /** The empty-mode candidate summary, produced by {@code passthrough_relay.js} with {@code PASSTHROUGH_SNI=empty}. */ + static final String CANDIDATE_SUMMARY = "passthroughRelayEmpty-summary.json"; + + /** The stored PLAN-04 plain-proxy baseline summary the candidate is read against. */ + static final String BASELINE_SUMMARY = "proxiedStatic-summary.json"; + + /** Name of the rendered artifact, written directly under the results directory. */ + static final String OUTPUT_FILE_NAME = "passthrough-baseline-comparison.md"; + + /** The fractional noise band applied when the {@code passthrough.baseline.tolerance} property is unset. */ + static final double DEFAULT_TOLERANCE = 0.15; + + /** System property overriding {@link #DEFAULT_TOLERANCE}. */ + static final String TOLERANCE_PROPERTY = "passthrough.baseline.tolerance"; + + private static final String FIELD_REQUESTS_PER_SECOND = "requests_per_second"; + private static final String FIELD_LATENCY_MS = "latency_ms"; + private static final String FIELD_P50 = "p50"; + private static final String FIELD_P99 = "p99"; + + private static final String NOT_AVAILABLE = "n/a"; + + private PassthroughBaselineComparator() { + // utility class + } + + /** Whether a single metric stayed within the band, regressed, or was not measured on either side. */ + enum Verdict { + PASS, REGRESSION, NOT_MEASURED + } + + /** One metric's candidate-vs-baseline comparison. */ + record MetricComparison(String label, String unit, Optional candidate, + Optional baseline, Verdict verdict) { + } + + /** The full comparison across the throughput and latency metrics. */ + record ComparisonResult(List metrics) { + + /** A regression on any measured metric fails the run; a not-measured metric never does. */ + boolean regressed() { + return metrics.stream().anyMatch(metric -> metric.verdict() == Verdict.REGRESSION); + } + } + + /** + * Renders the passthrough baseline comparison, writes it beside the summaries, and exits non-zero + * when the empty-mode run regressed beyond the noise band. + * + * @param args {@code } — the directory holding the k6 {@code *-summary.json} exports + * @throws IOException when a summary cannot be read or the artifact cannot be written + */ + public static void main(String[] args) throws IOException { + if (args.length != 1) { + LOGGER.error(K6BenchmarkLogMessages.ERROR.PASSTHROUGH_BASELINE_USAGE_ERROR); + throw new IllegalArgumentException("expected , got " + args.length + " argument(s)"); + } + Path resultsDir = Path.of(args[0]); + Path candidatePath = resultsDir.resolve(CANDIDATE_SUMMARY); + Path baselinePath = resultsDir.resolve(BASELINE_SUMMARY); + + Optional candidate = readSummary(candidatePath, resultsDir); + Optional baseline = readSummary(baselinePath, resultsDir); + if (candidate.isEmpty() || baseline.isEmpty()) { + throw new IllegalStateException("missing a summary the passthrough baseline comparison requires under " + + resultsDir); + } + + double tolerance = resolveTolerance(); + LOGGER.info(K6BenchmarkLogMessages.INFO.PASSTHROUGH_BASELINE_START, CANDIDATE_SUMMARY, BASELINE_SUMMARY); + + ComparisonResult result = compare(candidate.get(), baseline.get(), tolerance); + String rendered = render(result, tolerance); + Files.writeString(resultsDir.resolve(OUTPUT_FILE_NAME), rendered); + + if (result.regressed()) { + LOGGER.error(K6BenchmarkLogMessages.ERROR.PASSTHROUGH_BASELINE_REGRESSION, + formatTolerance(tolerance), regressionDetail(result)); + throw new IllegalStateException("empty-passthrough_sni run regressed beyond the noise band"); + } + LOGGER.info(K6BenchmarkLogMessages.INFO.PASSTHROUGH_BASELINE_OK, formatTolerance(tolerance)); + } + + /** + * Reads one summary, logging and returning empty when it is absent rather than throwing here. + * + * @param summaryPath the summary file + * @param resultsDir the directory it was expected under, for the diagnostic + * @return the parsed summary, or empty when the file does not exist + * @throws IOException when the present file cannot be read + */ + private static Optional readSummary(Path summaryPath, Path resultsDir) throws IOException { + if (!Files.isRegularFile(summaryPath)) { + LOGGER.error(K6BenchmarkLogMessages.ERROR.PASSTHROUGH_BASELINE_SUMMARY_MISSING, + summaryPath.getFileName(), resultsDir); + return Optional.empty(); + } + return Optional.of(JsonParser.parseString(Files.readString(summaryPath)).getAsJsonObject()); + } + + /** + * Compares the throughput and both latency percentiles of the empty-mode candidate against the + * baseline. + * + * @param candidate the empty-passthrough_sni summary + * @param baseline the PLAN-04 plain-proxy baseline summary + * @param tolerance the fractional noise band + * @return the per-metric comparison + */ + static ComparisonResult compare(JsonObject candidate, JsonObject baseline, double tolerance) { + MetricComparison rps = new MetricComparison("throughput", "RPS", + topLevelMetric(candidate, FIELD_REQUESTS_PER_SECOND), + topLevelMetric(baseline, FIELD_REQUESTS_PER_SECOND), + throughputVerdict(topLevelMetric(candidate, FIELD_REQUESTS_PER_SECOND), + topLevelMetric(baseline, FIELD_REQUESTS_PER_SECOND), tolerance)); + MetricComparison p50 = latencyComparison("latency p50", candidate, baseline, FIELD_P50, tolerance); + MetricComparison p99 = latencyComparison("latency p99", candidate, baseline, FIELD_P99, tolerance); + return new ComparisonResult(List.of(rps, p50, p99)); + } + + private static MetricComparison latencyComparison(String label, JsonObject candidate, JsonObject baseline, + String percentileField, double tolerance) { + Optional candidateValue = latencyMetric(candidate, percentileField); + Optional baselineValue = latencyMetric(baseline, percentileField); + return new MetricComparison(label, "ms", candidateValue, baselineValue, + latencyVerdict(candidateValue, baselineValue, tolerance)); + } + + /** + * The no-regression verdict for a higher-is-better metric (throughput): the candidate must stay at + * or above {@code baseline * (1 - tolerance)}. + * + * @param candidate the candidate value, when measured + * @param baseline the baseline value, when measured + * @param tolerance the fractional noise band + * @return {@link Verdict#NOT_MEASURED} when either side is absent, else PASS / REGRESSION + */ + static Verdict throughputVerdict(Optional candidate, Optional baseline, double tolerance) { + if (candidate.isEmpty() || baseline.isEmpty()) { + return Verdict.NOT_MEASURED; + } + double floor = baseline.get() * (1.0 - tolerance); + return candidate.get() >= floor ? Verdict.PASS : Verdict.REGRESSION; + } + + /** + * The no-regression verdict for a lower-is-better metric (a latency percentile): the candidate must + * stay at or below {@code baseline * (1 + tolerance)}. + * + * @param candidate the candidate value, when measured + * @param baseline the baseline value, when measured + * @param tolerance the fractional noise band + * @return {@link Verdict#NOT_MEASURED} when either side is absent, else PASS / REGRESSION + */ + static Verdict latencyVerdict(Optional candidate, Optional baseline, double tolerance) { + if (candidate.isEmpty() || baseline.isEmpty()) { + return Verdict.NOT_MEASURED; + } + double ceiling = baseline.get() * (1.0 + tolerance); + return candidate.get() <= ceiling ? Verdict.PASS : Verdict.REGRESSION; + } + + /** + * Reads a top-level numeric field, treating an absent or non-numeric field as not measured. + * + * @param summary the parsed summary + * @param field the field name + * @return the value, or empty when absent / non-numeric + */ + static Optional topLevelMetric(JsonObject summary, String field) { + return summary.has(field) && summary.get(field).isJsonPrimitive() + ? Optional.of(summary.get(field).getAsDouble()) : Optional.empty(); + } + + /** + * Reads a percentile from the nested {@code latency_ms} object, treating an absent object or field + * as not measured. + * + * @param summary the parsed summary + * @param percentileField the percentile field name (e.g. {@code p50}) + * @return the value, or empty when absent / non-numeric + */ + static Optional latencyMetric(JsonObject summary, String percentileField) { + if (!summary.has(FIELD_LATENCY_MS) || !summary.get(FIELD_LATENCY_MS).isJsonObject()) { + return Optional.empty(); + } + return topLevelMetric(summary.getAsJsonObject(FIELD_LATENCY_MS), percentileField); + } + + /** + * Renders a measured value to two decimals, or {@code n/a} when not measured. + * + * @param value the metric value, when measured + * @return the rendered value, or {@code n/a} + */ + static String render(Optional value) { + return value.map(measured -> String.format(Locale.ROOT, "%.2f", measured)).orElse(NOT_AVAILABLE); + } + + /** + * Renders the comparison as a markdown table. + * + * @param result the per-metric comparison + * @param tolerance the fractional noise band applied + * @return the rendered markdown document + */ + static String render(ComparisonResult result, double tolerance) { + StringBuilder out = new StringBuilder(512); + out.append("# Passthrough empty-mode baseline comparison\n\n") + .append("Empty-`passthrough_sni` proxied route (`").append(CANDIDATE_SUMMARY) + .append("`) vs the PLAN-04 plain-proxy baseline (`").append(BASELINE_SUMMARY).append("`). ") + .append("Noise band: ").append(formatTolerance(tolerance)) + .append(". `n/a` means the run did not measure the metric.\n\n") + .append("| Metric | Unit | Empty-mode | Baseline | Verdict |\n") + .append("|---|---|---|---|---|\n"); + for (MetricComparison metric : result.metrics()) { + out.append("| ").append(metric.label()) + .append(" | ").append(metric.unit()) + .append(" | ").append(render(metric.candidate())) + .append(" | ").append(render(metric.baseline())) + .append(" | ").append(metric.verdict()) + .append(" |\n"); + } + return out.toString(); + } + + /** Renders the regressed metrics for the failure diagnostic. */ + private static String regressionDetail(ComparisonResult result) { + return result.metrics().stream() + .filter(metric -> metric.verdict() == Verdict.REGRESSION) + .map(metric -> "%s (empty-mode %s vs baseline %s %s)".formatted(metric.label(), + render(metric.candidate()), render(metric.baseline()), metric.unit())) + .reduce((left, right) -> left + ", " + right) + .orElse("none"); + } + + private static String formatTolerance(double tolerance) { + return String.format(Locale.ROOT, "%.0f%%", tolerance * 100.0); + } + + /** + * Resolves the noise band from the {@code passthrough.baseline.tolerance} system property, falling + * back to {@link #DEFAULT_TOLERANCE}. An invalid override is fatal rather than silently defaulted: + * a negative value would invert the gate and a value above 1 would disable it. + * + * @return the fractional noise band in {@code [0, 1]} + */ + static double resolveTolerance() { + String raw = System.getProperty(TOLERANCE_PROPERTY); + if (raw == null || raw.isBlank()) { + return DEFAULT_TOLERANCE; + } + double value; + try { + value = Double.parseDouble(raw.strip()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + TOLERANCE_PROPERTY + " must be a fraction in [0, 1], got \"" + raw + "\"", e); + } + if (!Double.isFinite(value) || value < 0.0 || value > 1.0) { + throw new IllegalArgumentException( + TOLERANCE_PROPERTY + " must be a fraction in [0, 1], got \"" + raw + "\""); + } + return value; + } +} diff --git a/benchmarks/src/main/resources/k6-scripts/passthrough_relay.js b/benchmarks/src/main/resources/k6-scripts/passthrough_relay.js new file mode 100644 index 00000000..2fb2e405 --- /dev/null +++ b/benchmarks/src/main/resources/k6-scripts/passthrough_relay.js @@ -0,0 +1,75 @@ +/** + * @fileoverview Benchmark for the accept-time passthrough L4 relay and its empty-mode baseline. + * + * Two modes, selected by the `PASSTHROUGH_SNI` env var: + * + * mapped -> drive the *passthrough* path (k6 -> gateway public TLS port -> opaque L4 TCP relay + * -> TLS-enabled backend). The ClientHello SNI names a `tls.passthrough_sni` host, so + * the gateway relays the intact, still-encrypted byte stream at L4 without terminating; + * the backend completes the handshake and presents its *own* certificate. Measures + * relay throughput/latency through the active passthrough path. Emits `passthroughRelay`. + * + * empty -> drive the *same proxied static route* the PLAN-04 `unauth` baseline uses, but with + * `passthrough_sni` empty — D1's zero-overhead default, where the front listener is + * never created and the terminated Quarkus HTTPS listener owns the public port directly. + * This is the no-regression comparison side: `PassthroughBaselineComparator` reads its + * summary against the stored PLAN-04 `proxiedStatic` baseline. Emits `passthroughRelayEmpty`. + * + * One script body drives both modes so the two runs share identical VU/duration/threshold plumbing + * and only the SNI/route differs. Native k6 thresholds (`http_req_failed`, `checks`) gate the run, + * exactly as the other aspects: a run that starts rejecting every request exits non-zero rather than + * benchmarking as an improvement. An unknown `PASSTHROUGH_SNI` value is fatal rather than defaulted, + * mirroring `lib/target.js` — a mislabelled run is worse than a failed one. + */ +import http from 'k6/http'; +import { check } from 'k6'; +import { buildSummary, duration, maxErrorRate, SUMMARY_TREND_STATS, vus } from './lib/summary.js'; +import { targetUrl } from './lib/target.js'; + +/** The passthrough mode this run measures, defaulting to the active relay path. */ +const MODE = (__ENV.PASSTHROUGH_SNI || 'mapped').toLowerCase(); + +/** + * The mapped SNI host the passthrough run's ClientHello carries. It must both resolve to the gateway + * container and be listed in `tls.passthrough_sni`; the benchmark compose overlay (D4) provides that + * mapping and the TLS-enabled backend behind it. Overridable so a run can target a different edge. + */ +const PASSTHROUGH_TARGET_URL = __ENV.PASSTHROUGH_TARGET_URL || 'https://passthrough.api-sheriff:8443/get'; + +/** + * Resolves the (benchmarkName, url) pair for the selected mode. An unknown mode is fatal at module + * load, before any VU starts, so a typo can never silently fall through to one of the two paths and + * mislabel the summary. + */ +function resolveMode() { + switch (MODE) { + case 'mapped': + return { benchmarkName: 'passthroughRelay', url: __ENV.TARGET_URL || PASSTHROUGH_TARGET_URL }; + case 'empty': + return { benchmarkName: 'passthroughRelayEmpty', url: __ENV.TARGET_URL || targetUrl('/proxy/static') }; + default: + throw new Error(`PASSTHROUGH_SNI must be one of mapped, empty, got "${__ENV.PASSTHROUGH_SNI}"`); + } +} + +const { benchmarkName: BENCHMARK_NAME, url: TARGET_URL } = resolveMode(); + +export const options = { + vus: vus(50), + duration: duration(), + summaryTrendStats: SUMMARY_TREND_STATS, + insecureSkipTLSVerify: true, + thresholds: { + http_req_failed: [`rate<=${maxErrorRate()}`], + checks: [`rate>=${1 - maxErrorRate()}`], + }, +}; + +export default function () { + const response = http.get(TARGET_URL, { tags: { benchmark: BENCHMARK_NAME } }); + check(response, { 'status is 200': (r) => r.status === 200 }); +} + +export function handleSummary(data) { + return buildSummary(BENCHMARK_NAME, data); +} diff --git a/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java new file mode 100644 index 00000000..7e75a992 --- /dev/null +++ b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java @@ -0,0 +1,299 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.k6.benchmark; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import de.cuioss.sheriff.gateway.k6.benchmark.PassthroughBaselineComparator.ComparisonResult; +import de.cuioss.sheriff.gateway.k6.benchmark.PassthroughBaselineComparator.Verdict; +import de.cuioss.test.generator.Generators; +import de.cuioss.test.generator.junit.EnableGeneratorController; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; + +import java.util.Locale; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Unit tests for {@link PassthroughBaselineComparator}'s noise-band verdict logic. + *

+ * The three acceptance behaviours the empty-{@code passthrough_sni} no-regression gate turns on are + * asserted directly: a delta within the noise band passes, a drop (throughput) or rise (latency) + * beyond the band is flagged as a regression, and an absent metric renders {@code n/a} — classified + * {@link Verdict#NOT_MEASURED} — rather than collapsing to a {@code 0} that would read as a measured + * failure. The band tests are property-style: CUI Test Generator supplies fresh baselines and + * within-/beyond-band deltas each repetition, so the verdict is exercised across a range of + * magnitudes rather than a single hand-picked pair. + */ +@EnableGeneratorController +class PassthroughBaselineComparatorTest { + + /** The comparator's own default band, so the tests track it if the production default changes. */ + private static final double TOLERANCE = PassthroughBaselineComparator.DEFAULT_TOLERANCE; + + /** Builds a k6-shaped summary carrying a top-level throughput and both latency percentiles. */ + private static JsonObject summary(double rps, double p50, double p99) { + return JsonParser.parseString(""" + { + "requests_per_second": %s, + "error_rate": 0.0, + "latency_ms": { "avg": 4.0, "p50": %s, "p99": %s } + } + """.formatted(rps, p50, p99)).getAsJsonObject(); + } + + // ---- Throughput (higher-is-better) band ------------------------------------------------------ + + @RepeatedTest(25) + void throughputStayingWithinTheNoiseBandPasses() { + // Arrange -- a candidate whose throughput drop stays strictly inside the tolerance. + double baseline = Generators.doubles(50.0, 10_000.0).next(); + double dropFraction = Generators.doubles(0.0, TOLERANCE * 0.95).next(); + double candidate = baseline * (1.0 - dropFraction); + + // Act + Verdict verdict = PassthroughBaselineComparator.throughputVerdict( + Optional.of(candidate), Optional.of(baseline), TOLERANCE); + + // Assert -- a within-band throughput drop is noise, not a regression. + assertEquals(Verdict.PASS, verdict, + () -> "candidate %.4f vs baseline %.4f (drop %.4f) is within the %.2f band" + .formatted(candidate, baseline, dropFraction, TOLERANCE)); + } + + @RepeatedTest(25) + void throughputDroppingBeyondTheNoiseBandRegresses() { + // Arrange -- a candidate whose throughput drop exceeds the tolerance. + double baseline = Generators.doubles(50.0, 10_000.0).next(); + double dropFraction = Generators.doubles(TOLERANCE * 1.05, 0.9).next(); + double candidate = baseline * (1.0 - dropFraction); + + // Act + Verdict verdict = PassthroughBaselineComparator.throughputVerdict( + Optional.of(candidate), Optional.of(baseline), TOLERANCE); + + // Assert -- a beyond-band throughput drop is a real regression. + assertEquals(Verdict.REGRESSION, verdict, + () -> "candidate %.4f vs baseline %.4f (drop %.4f) exceeds the %.2f band" + .formatted(candidate, baseline, dropFraction, TOLERANCE)); + } + + // ---- Latency (lower-is-better) band ---------------------------------------------------------- + + @RepeatedTest(25) + void latencyRisingWithinTheNoiseBandPasses() { + // Arrange -- a candidate whose latency rise stays strictly inside the tolerance. + double baseline = Generators.doubles(0.5, 500.0).next(); + double riseFraction = Generators.doubles(0.0, TOLERANCE * 0.95).next(); + double candidate = baseline * (1.0 + riseFraction); + + // Act + Verdict verdict = PassthroughBaselineComparator.latencyVerdict( + Optional.of(candidate), Optional.of(baseline), TOLERANCE); + + // Assert -- a within-band latency rise is noise, not a regression. + assertEquals(Verdict.PASS, verdict, + () -> "candidate %.4f vs baseline %.4f (rise %.4f) is within the %.2f band" + .formatted(candidate, baseline, riseFraction, TOLERANCE)); + } + + @RepeatedTest(25) + void latencyRisingBeyondTheNoiseBandRegresses() { + // Arrange -- a candidate whose latency rise exceeds the tolerance. + double baseline = Generators.doubles(0.5, 500.0).next(); + double riseFraction = Generators.doubles(TOLERANCE * 1.05, 2.0).next(); + double candidate = baseline * (1.0 + riseFraction); + + // Act + Verdict verdict = PassthroughBaselineComparator.latencyVerdict( + Optional.of(candidate), Optional.of(baseline), TOLERANCE); + + // Assert -- a beyond-band latency rise is a real regression. + assertEquals(Verdict.REGRESSION, verdict, + () -> "candidate %.4f vs baseline %.4f (rise %.4f) exceeds the %.2f band" + .formatted(candidate, baseline, riseFraction, TOLERANCE)); + } + + // ---- Absent metric: NOT_MEASURED, never a false collapse ------------------------------------- + + @Test + void anAbsentMetricIsNotMeasuredOnEitherSideForBothDirections() { + // Arrange -- one measured value; the other side is absent. + double measured = Generators.doubles(1.0, 1_000.0).next(); + + // Act + Assert -- a missing candidate or baseline is never a regression, only NOT_MEASURED. + assertEquals(Verdict.NOT_MEASURED, PassthroughBaselineComparator.throughputVerdict( + Optional.empty(), Optional.of(measured), TOLERANCE)); + assertEquals(Verdict.NOT_MEASURED, PassthroughBaselineComparator.throughputVerdict( + Optional.of(measured), Optional.empty(), TOLERANCE)); + assertEquals(Verdict.NOT_MEASURED, PassthroughBaselineComparator.latencyVerdict( + Optional.empty(), Optional.of(measured), TOLERANCE)); + assertEquals(Verdict.NOT_MEASURED, PassthroughBaselineComparator.latencyVerdict( + Optional.of(measured), Optional.empty(), TOLERANCE)); + } + + @Test + void anAbsentValueRendersNotAvailableNeverZero() { + // Arrange + double value = Generators.doubles(0.01, 9_999.0).next(); + + // Act + String absent = PassthroughBaselineComparator.render(Optional.empty()); + String measured = PassthroughBaselineComparator.render(Optional.of(value)); + + // Assert -- absent is the n/a sentinel, never a 0.00 that reads as a measured collapse. + assertEquals("n/a", absent); + assertNotEquals("n/a", measured); + assertNotEquals("0.00", measured); + assertEquals(String.format(Locale.ROOT, "%.2f", value), measured); + } + + // ---- End-to-end compare + render over k6-shaped summaries ------------------------------------- + + @Test + void aWithinBandCandidateDoesNotRegressAcrossAllMetrics() { + // Arrange -- every metric moves half a band in the worse direction (throughput down, + // latencies up), all still inside the tolerance. + JsonObject baseline = summary(8_000.0, 2.0, 30.0); + JsonObject candidate = summary( + 8_000.0 * (1.0 - TOLERANCE * 0.5), + 2.0 * (1.0 + TOLERANCE * 0.5), + 30.0 * (1.0 + TOLERANCE * 0.5)); + + // Act + ComparisonResult result = PassthroughBaselineComparator.compare(candidate, baseline, TOLERANCE); + + // Assert + assertFalse(result.regressed(), "a within-band candidate must not be flagged as a regression"); + } + + @Test + void aThroughputCollapseRegressesTheComparison() { + // Arrange -- a 50% throughput drop, latencies unchanged. + JsonObject baseline = summary(8_000.0, 2.0, 30.0); + JsonObject candidate = summary(8_000.0 * 0.5, 2.0, 30.0); + + // Act + ComparisonResult result = PassthroughBaselineComparator.compare(candidate, baseline, TOLERANCE); + + // Assert + assertTrue(result.regressed(), "a throughput collapse must be flagged as a regression"); + } + + @Test + void anUnmeasuredLatencyRendersNotAvailableInTheMarkdownWithoutRegressing() { + // Arrange -- the candidate summary omits latency_ms entirely (an unmeasured run), while its + // throughput matches the baseline so throughput alone stays within band. + JsonObject candidate = JsonParser.parseString( + "{ \"requests_per_second\": 8000.0 }").getAsJsonObject(); + JsonObject baseline = summary(8_000.0, 2.0, 30.0); + + // Act + ComparisonResult result = PassthroughBaselineComparator.compare(candidate, baseline, TOLERANCE); + String rendered = PassthroughBaselineComparator.render(result, TOLERANCE); + + // Assert -- an absent latency metric is NOT_MEASURED, never a regression, and reads n/a — not 0. + assertFalse(result.regressed(), "an absent latency metric must not read as a regression"); + assertTrue(rendered.contains("n/a"), "an unmeasured metric must render n/a:\n" + rendered); + assertFalse(rendered.contains("| 0.00 |"), + "an absent measurement must never render as 0:\n" + rendered); + } + + // ---- Metric extraction from the summary shape ------------------------------------------------ + + @Test + void metricExtractionTreatsAbsentFieldsAsNotMeasured() { + // Arrange + JsonObject full = summary(1234.5, 2.0, 30.0); + JsonObject noLatency = JsonParser.parseString( + "{ \"requests_per_second\": 1.0 }").getAsJsonObject(); + + // Act + Assert -- present fields are read; absent top-level and nested fields are empty. + assertEquals(Optional.of(1234.5), + PassthroughBaselineComparator.topLevelMetric(full, "requests_per_second")); + assertEquals(Optional.of(2.0), PassthroughBaselineComparator.latencyMetric(full, "p50")); + assertTrue(PassthroughBaselineComparator.topLevelMetric(noLatency, "missing").isEmpty()); + assertTrue(PassthroughBaselineComparator.latencyMetric(noLatency, "p50").isEmpty()); + } + + // ---- Tolerance resolution from the system property ------------------------------------------- + + @Test + void resolveToleranceDefaultsWhenThePropertyIsUnset() { + // Arrange + String prior = System.getProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY); + System.clearProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY); + try { + // Act + Assert + assertEquals(PassthroughBaselineComparator.DEFAULT_TOLERANCE, + PassthroughBaselineComparator.resolveTolerance()); + } finally { + restoreToleranceProperty(prior); + } + } + + @Test + void resolveToleranceReadsAValidOverride() { + // Arrange + String prior = System.getProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY); + try { + System.setProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY, "0.25"); + + // Act + Assert + assertEquals(0.25, PassthroughBaselineComparator.resolveTolerance()); + } finally { + restoreToleranceProperty(prior); + } + } + + @Test + void resolveToleranceRejectsAnOutOfRangeOrMalformedOverride() { + // Arrange + String prior = System.getProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY); + try { + System.setProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY, "1.5"); + assertThrows(IllegalArgumentException.class, + PassthroughBaselineComparator::resolveTolerance, + "a value above 1 would disable the gate and must be rejected"); + + System.setProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY, "-0.1"); + assertThrows(IllegalArgumentException.class, + PassthroughBaselineComparator::resolveTolerance, + "a negative value would invert the gate and must be rejected"); + + System.setProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY, "not-a-number"); + assertThrows(IllegalArgumentException.class, + PassthroughBaselineComparator::resolveTolerance, + "a malformed value must be rejected rather than silently defaulted"); + } finally { + restoreToleranceProperty(prior); + } + } + + private static void restoreToleranceProperty(String prior) { + if (prior == null) { + System.clearProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY); + } else { + System.setProperty(PassthroughBaselineComparator.TOLERANCE_PROPERTY, prior); + } + } +} From 400c49e7c0d9d83da0a561179106bd29032fcb71 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:06:15 +0200 Subject: [PATCH 08/15] style: apply pre-commit OpenRewrite formatting normalizations The whole-tree pre-commit quality gate applied mechanical OpenRewrite normalizations across the plan's touched TLS-edge and benchmark sources: fully-qualified annotation/type references simplified to imports (@java.io.Serial, @org.jspecify.annotations.Nullable, StandardCharsets, Consumer), wildcard static-import collapse, import-group blank-line separators, and array-initializer whitespace. No semantic changes; the quality gate passes green with these applied. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Pq3eQyg78SuTKrC8pLpLs --- .../gateway/pipeline/PassthroughHostGuardStage.java | 1 + .../cuioss/sheriff/gateway/tls/ClientHelloSniParser.java | 3 ++- .../cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java | 1 + .../de/cuioss/sheriff/gateway/tls/PassthroughRelay.java | 1 + .../de/cuioss/sheriff/gateway/tls/SniFrontListener.java | 4 +++- .../de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java | 1 + .../gateway/pipeline/PassthroughHostGuardStageTest.java | 1 + .../sheriff/gateway/tls/ClientHelloSniParserTest.java | 2 +- .../sheriff/gateway/tls/MtlsServerCustomizerTest.java | 1 + .../cuioss/sheriff/gateway/tls/PassthroughRelayTest.java | 7 +++++-- .../cuioss/sheriff/gateway/tls/SniFrontListenerTest.java | 1 + .../k6/benchmark/PassthroughBaselineComparator.java | 2 +- .../k6/benchmark/PassthroughBaselineComparatorTest.java | 6 +----- 13 files changed, 20 insertions(+), 11 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java index 25520aab..5d22e23a 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java @@ -21,6 +21,7 @@ import java.util.Objects; import java.util.Set; + import de.cuioss.sheriff.gateway.events.EventType; import de.cuioss.sheriff.gateway.events.GatewayException; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java index eb79c0c4..8e3afe23 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java @@ -16,6 +16,7 @@ package de.cuioss.sheriff.gateway.tls; import java.io.ByteArrayOutputStream; +import java.io.Serial; import java.nio.charset.StandardCharsets; import java.util.Optional; @@ -275,7 +276,7 @@ void seek(int target) { /** Raised internally when a declared length runs past the reassembled handshake buffer. */ private static final class MalformedHelloException extends RuntimeException { - @java.io.Serial + @Serial private static final long serialVersionUID = 1L; MalformedHelloException() { diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java index 6d0bee0f..f8b3604e 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java @@ -18,6 +18,7 @@ import java.util.Objects; import java.util.Optional; + import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.TlsConfig; import de.cuioss.tools.logging.CuiLogger; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java index 20ac1e84..545ed0e1 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java @@ -17,6 +17,7 @@ import java.util.Objects; + import de.cuioss.sheriff.gateway.ApiSheriffLogMessages; import de.cuioss.tools.logging.CuiLogger; diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java index 49a9577d..be67a5dc 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java @@ -20,6 +20,7 @@ import java.util.Objects; import java.util.Optional; + import de.cuioss.sheriff.gateway.ApiSheriffLogMessages; import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayKind; import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; @@ -30,6 +31,7 @@ import io.vertx.core.buffer.Buffer; import io.vertx.core.net.NetServer; import io.vertx.core.net.NetSocket; +import org.jspecify.annotations.Nullable; /** * The Vert.x {@link NetServer} front, bound to the public TLS port, that performs the accept-time @@ -65,7 +67,7 @@ public final class SniFrontListener { private final RelayTarget terminatedTarget; private final int publicPort; - private @org.jspecify.annotations.Nullable NetServer server; + private @Nullable NetServer server; /** * @param vertx the managed Vert.x instance the front server is created on diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java index 061dbb62..267add0e 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java @@ -23,6 +23,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; + import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.ResolvedTopology; import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java index 75c2d843..2ee4dcac 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.Set; + import de.cuioss.sheriff.gateway.config.model.HttpMethod; import de.cuioss.sheriff.gateway.events.EventCategory; import de.cuioss.sheriff.gateway.events.EventType; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java index ad00b734..b5337729 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java @@ -242,7 +242,7 @@ static byte[] withCorruptExtensionLength() { /** A record header declaring a length beyond the parser's reassembly bound. */ static byte[] oversizeRecordHeader() { int declared = ClientHelloSniParser.MAX_CLIENT_HELLO_BYTES + 1; - return new byte[] { + return new byte[]{ RECORD_HANDSHAKE, 0x03, 0x01, (byte) ((declared >> 8) & 0xFF), (byte) (declared & 0xFF) }; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java index 5cf1f37f..40695e5a 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java @@ -22,6 +22,7 @@ import java.util.Optional; + import de.cuioss.sheriff.gateway.config.model.GatewayConfig; import de.cuioss.sheriff.gateway.config.model.TlsConfig; diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java index 9a3b4e68..b6e5d55d 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java @@ -18,9 +18,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import java.nio.charset.StandardCharsets; import java.util.Random; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayKind; import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; @@ -69,7 +72,7 @@ void relaysPrefixThenLiveBytes() throws Exception { // Arrange RelayTarget backend = startEchoBackend(); int frontPort = startRelayHarness(backend, Buffer.buffer("PREFIX")); - byte[] expected = "PREFIXDATA".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] expected = "PREFIXDATA".getBytes(StandardCharsets.US_ASCII); CompletableFuture echoed = new CompletableFuture<>(); // Act @@ -158,7 +161,7 @@ private RelayTarget startEchoBackend() throws Exception { return new RelayTarget(HOST, port); } - private RelayTarget startSignalBackend(java.util.function.Consumer wiring) throws Exception { + private RelayTarget startSignalBackend(Consumer wiring) throws Exception { NetServer server = vertx.createNetServer(); server.connectHandler(wiring::accept); int port = await(server.listen(0)).actualPort(); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java index 77b83e0a..8824ca19 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java @@ -21,6 +21,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; + import de.cuioss.sheriff.gateway.tls.ClientHelloSniParserTest.ClientHelloFixture; import de.cuioss.sheriff.gateway.tls.PassthroughRelay.RelayTarget; diff --git a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java index d7b3c2f1..5a9f3fab 100644 --- a/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java +++ b/benchmarks/src/main/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparator.java @@ -89,7 +89,7 @@ enum Verdict { /** One metric's candidate-vs-baseline comparison. */ record MetricComparison(String label, String unit, Optional candidate, - Optional baseline, Verdict verdict) { + Optional baseline, Verdict verdict) { } /** The full comparison across the throughput and latency metrics. */ diff --git a/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java index 7e75a992..2fe55358 100644 --- a/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java +++ b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java @@ -27,11 +27,7 @@ import java.util.Locale; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.*; /** * Unit tests for {@link PassthroughBaselineComparator}'s noise-band verdict logic. From 4437e6dec06fb2da28c67c99a5e5dfdd8eebdba8 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Thu, 23 Jul 2026 23:48:37 +0200 Subject: [PATCH 09/15] test(coverage): raise api-sheriff branch coverage to >=0.80 for TLS-edge classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves Q-Gate finding 765287 (jacoco-branch-min). The api-sheriff JaCoCo bundle branch ratio was 998/1262 = 0.7908, below the 0.80 minimum. Add branch-covering JUnit 5 tests (AAA, no Mockito) for the plan-touched TLS-edge classes, lifting the bundle to 1020/1262 = 0.8082: - ClientHelloSniParserTest: extension-walk / name-entry edge cases (seek past a leading non-SNI extension, non-host_name entry skip, truncated and overlong server_name list, name length past list end) and the Result null-normalization branch. - PassthroughHostGuardStageTest: normalize branches for non-numeric, empty, and multi-colon :port suffixes. - SniFrontListenerTest: normalizeSni trailing-dot / case / whitespace path. - TlsEdgeProducerTest (new): boot wiring — front listener start/stop with a resolved passthrough SNI, empty-passthrough short-circuit, and the unresolved-alias defensive skip (ADR-0009). api-sheriff jacoco check: "All coverage checks have been met." Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017Pq3eQyg78SuTKrC8pLpLs --- .../PassthroughHostGuardStageTest.java | 31 ++++ .../gateway/tls/ClientHelloSniParserTest.java | 169 ++++++++++++++++++ .../gateway/tls/SniFrontListenerTest.java | 11 ++ .../gateway/tls/TlsEdgeProducerTest.java | 138 ++++++++++++++ 4 files changed, 349 insertions(+) create mode 100644 api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducerTest.java diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java index 2ee4dcac..b01535b5 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java @@ -140,6 +140,37 @@ void passesSuffixLookalikeHost() { // Act + Assert assertDoesNotThrow(() -> guardedStage.process(request)); } + + @Test + @DisplayName("does not strip a non-numeric :suffix, so the Host no longer matches the SNI") + void passesHostWithNonNumericPort() { + // Arrange — only a purely-numeric :port is stripped; "notaport" is kept, so the normalized + // Host retains the colon suffix and can no longer match the reserved SNI. + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI + ":notaport"); + + // Act + Assert + assertDoesNotThrow(() -> guardedStage.process(request)); + } + + @Test + @DisplayName("does not strip an empty :suffix, so the Host no longer matches the SNI") + void passesHostWithEmptyPort() { + // Arrange — a trailing colon with no port digits is not a strippable :port suffix. + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI + ":"); + + // Act + Assert + assertDoesNotThrow(() -> guardedStage.process(request)); + } + + @Test + @DisplayName("does not strip a multi-colon suffix, so the Host no longer matches the SNI") + void passesHostWithMultipleColons() { + // Arrange — the :port strip only fires for a single colon; two colons leave the Host intact. + PipelineRequest request = requestWithHost(PASSTHROUGH_SNI + ":80:80"); + + // Act + Assert + assertDoesNotThrow(() -> guardedStage.process(request)); + } } @Nested diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java index b5337729..1c68d0c3 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java @@ -189,6 +189,112 @@ void oversizeRecordFailsClosed() { } } + @Nested + @DisplayName("extension-walk and name-entry edge cases") + class ExtensionWalk { + + @Test + @DisplayName("skips a preceding non-SNI extension and still extracts the SNI host_name") + void skipsPrecedingExtension() { + // Arrange — a benign supported_groups-shaped extension precedes the server_name extension, + // so the walk must seek past it before reaching the SNI. + byte[] preceding = ClientHelloFixture.rawExtension(0x000A, new byte[]{0x00, 0x02, 0x00, 0x17}); + byte[] sni = ClientHelloFixture.serverNameExtension("host.after.example"); + byte[] hello = ClientHelloFixture.withRawExtensions(ClientHelloFixture.concat(preceding, sni)); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertTrue(result.complete()); + assertEquals(Optional.of("host.after.example"), result.serverName(), + "the walk seeks past a leading non-SNI extension"); + } + + @Test + @DisplayName("skips a non-host_name server_name entry and returns a following host_name") + void skipsNonHostNameEntry() { + // Arrange — the server_name list carries a non-host_name entry (name_type 0x01) ahead of + // the real host_name entry. + byte[] entries = ClientHelloFixture.concat( + ClientHelloFixture.nameEntry(0x01, "ignored".getBytes(StandardCharsets.US_ASCII)), + ClientHelloFixture.nameEntry(0x00, "host.example".getBytes(StandardCharsets.US_ASCII))); + byte[] hello = ClientHelloFixture.withRawExtensions( + ClientHelloFixture.serverNameExtensionFromEntries(entries)); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertEquals(Optional.of("host.example"), result.serverName(), + "a non-host_name entry is skipped, the host_name entry wins"); + } + + @Test + @DisplayName("fails closed on a server_name extension body shorter than its list-length prefix") + void truncatedServerNameExtension() { + // Arrange — a server_name extension whose body is a single byte cannot hold the 2-byte + // list-length prefix. + byte[] sni = ClientHelloFixture.rawExtension(0x0000, new byte[]{0x00}); + byte[] hello = ClientHelloFixture.withRawExtensions(sni); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty(), "a body too short for the list prefix fails closed"); + } + + @Test + @DisplayName("fails closed when the server_name list length overruns the extension body") + void overlongServerNameList() { + // Arrange — the extension declares a 2-byte body carrying a list length (0x007F) that runs + // well past the extension end. + byte[] sni = ClientHelloFixture.rawExtension(0x0000, new byte[]{0x00, 0x7F}); + byte[] hello = ClientHelloFixture.withRawExtensions(sni); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty(), "a list length past the extension body fails closed"); + } + + @Test + @DisplayName("fails closed when a name entry declares a length past the list end") + void nameEntryLengthPastList() { + // Arrange — the single name entry declares a 0x007F-byte name but carries no name bytes. + byte[] entry = new byte[]{0x00, 0x00, 0x7F}; + byte[] hello = ClientHelloFixture.withRawExtensions( + ClientHelloFixture.serverNameExtensionFromEntries(entry)); + + // Act + ClientHelloSniParser.Result result = parser.parse(hello); + + // Assert + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty(), "a name length past the list end fails closed"); + } + } + + @Nested + @DisplayName("Result verdict factory") + class ResultVerdict { + + @Test + @DisplayName("normalizes a null server name to an empty Optional") + void normalizesNullServerName() { + // Act — the canonical constructor must defend against a null Optional argument. + ClientHelloSniParser.Result result = new ClientHelloSniParser.Result(true, null); + + // Assert + assertTrue(result.complete()); + assertTrue(result.serverName().isEmpty(), "a null server name normalizes to empty"); + } + } + /** * Builds real TLS ClientHello byte layouts for the parser tests. Kept package-visible and static * so both this test and {@code SniFrontListenerTest} / {@code PassthroughRelayTest} can craft the @@ -198,6 +304,7 @@ static final class ClientHelloFixture { private static final byte RECORD_HANDSHAKE = 0x16; private static final byte HANDSHAKE_CLIENT_HELLO = 0x01; + private static final int EXTENSION_TYPE_SERVER_NAME = 0x0000; private ClientHelloFixture() { } @@ -239,6 +346,50 @@ static byte[] withCorruptExtensionLength() { return hello; } + /** A complete single-record ClientHello whose extensions block is exactly the given bytes. */ + static byte[] withRawExtensions(byte[] extensionsBlock) { + return handshakeRecord(HANDSHAKE_CLIENT_HELLO, clientHelloBodyRaw(extensionsBlock)); + } + + /** A raw TLS extension: {@code type(2) + length(2) + body}. */ + static byte[] rawExtension(int type, byte[] body) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write((type >> 8) & 0xFF); + out.write(type & 0xFF); + out.write((body.length >> 8) & 0xFF); + out.write(body.length & 0xFF); + out.writeBytes(body); + return out.toByteArray(); + } + + /** A server_name extension whose {@code server_name_list} is exactly the given entry bytes. */ + static byte[] serverNameExtensionFromEntries(byte[] entries) { + ByteArrayOutputStream body = new ByteArrayOutputStream(); + body.write((entries.length >> 8) & 0xFF); + body.write(entries.length & 0xFF); + body.writeBytes(entries); + return rawExtension(EXTENSION_TYPE_SERVER_NAME, body.toByteArray()); + } + + /** A single {@code server_name} list entry: {@code name_type(1) + length(2) + name}. */ + static byte[] nameEntry(int nameType, byte[] name) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(nameType); + out.write((name.length >> 8) & 0xFF); + out.write(name.length & 0xFF); + out.writeBytes(name); + return out.toByteArray(); + } + + /** Concatenates the given byte arrays in order. */ + static byte[] concat(byte[]... arrays) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] array : arrays) { + out.writeBytes(array); + } + return out.toByteArray(); + } + /** A record header declaring a length beyond the parser's reassembly bound. */ static byte[] oversizeRecordHeader() { int declared = ClientHelloSniParser.MAX_CLIENT_HELLO_BYTES + 1; @@ -293,6 +444,24 @@ private static byte[] clientHelloBody(String sni) { return out.toByteArray(); } + private static byte[] clientHelloBodyRaw(byte[] extensions) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(0x03); + out.write(0x03); // legacy_version TLS 1.2 + out.writeBytes(new byte[32]); // random + out.write(0x00); // session_id length 0 + out.write(0x00); + out.write(0x02); // cipher_suites length 2 + out.write(0x13); + out.write(0x01); // TLS_AES_128_GCM_SHA256 + out.write(0x01); // compression_methods length 1 + out.write(0x00); // null compression + out.write((extensions.length >> 8) & 0xFF); + out.write(extensions.length & 0xFF); + out.writeBytes(extensions); + return out.toByteArray(); + } + private static byte[] serverNameExtension(String sni) { byte[] host = sni.getBytes(StandardCharsets.US_ASCII); ByteArrayOutputStream entry = new ByteArrayOutputStream(); diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java index 8824ca19..f740ecce 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java @@ -112,6 +112,17 @@ void sniLessHelloFailsClosedToTerminated() throws Exception { await(front.stop()); } + @Test + @DisplayName("normalizeSni lower-cases, strips whitespace, and removes a single trailing FQDN dot") + void normalizeSniLowersAndStripsTrailingDot() { + // Act + Assert — a trailing FQDN dot and case/whitespace are normalized away so lookup and + // insertion agree; a host without a trailing dot is only lower-cased. + assertEquals("api.example.com", SniFrontListener.normalizeSni(" API.Example.COM. "), + "trailing FQDN dot, case, and surrounding whitespace are normalized"); + assertEquals("api.example.com", SniFrontListener.normalizeSni("API.EXAMPLE.COM"), + "a host without a trailing dot is only lower-cased"); + } + @Test @DisplayName("routes every connection to the terminated path when no SNI is mapped") void emptyPassthroughMapRelaysEverythingToTerminated() throws Exception { diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducerTest.java new file mode 100644 index 00000000..65a5bfb9 --- /dev/null +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducerTest.java @@ -0,0 +1,138 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.tls; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import java.util.Map; +import java.util.Optional; + + +import de.cuioss.sheriff.gateway.config.model.GatewayConfig; +import de.cuioss.sheriff.gateway.config.model.ResolvedTopology; +import de.cuioss.sheriff.gateway.config.model.ResolvedUpstream; +import de.cuioss.sheriff.gateway.config.model.TlsConfig; + +import io.quarkus.runtime.ShutdownEvent; +import io.quarkus.runtime.StartupEvent; +import io.vertx.core.Vertx; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Boot-wiring contract of {@link TlsEdgeProducer}: the relay map is built from {@code + * tls.passthrough_sni} against the resolved topology, the accept-time front listener is started only + * when at least one passthrough SNI resolves, and shutdown is a clean no-op when nothing was started. + * An unresolved passthrough alias is defensively skipped rather than aborting boot (ADR-0009). The + * front binds an ephemeral port so the test never contends for a fixed public port. + */ +@DisplayName("TlsEdgeProducer — accept-time front listener boot wiring") +class TlsEdgeProducerTest { + + private static final int EPHEMERAL_PORT = 0; + private static final int INTERNAL_HTTPS_PORT = 8444; + private static final String RESOLVED_ALIAS = "backend-alias"; + private static final String UNRESOLVED_ALIAS = "missing-alias"; + + private Vertx vertx; + + @BeforeEach + void setUp() { + vertx = Vertx.vertx(); + } + + @AfterEach + void tearDown() { + vertx.close().toCompletionStage().toCompletableFuture().join(); + } + + @Nested + @DisplayName("Passthrough configured") + class PassthroughConfigured { + + @Test + @DisplayName("starts the front listener and shuts it down cleanly when a passthrough SNI resolves") + void startsAndStopsFrontListener() { + // Arrange — one SNI maps to a resolvable alias, one to an alias absent from the topology. + // The resolvable entry makes the relay map non-empty (front started); the unresolved entry + // exercises the defensive skip branch. + TlsConfig tls = TlsConfig.builder() + .passthroughSni(Map.of( + "sni.resolved.example", RESOLVED_ALIAS, + "sni.unresolved.example", UNRESOLVED_ALIAS)) + .build(); + GatewayConfig config = GatewayConfig.builder().version(1) + .tls(Optional.of(tls)).build(); + ResolvedTopology topology = new ResolvedTopology(Map.of( + RESOLVED_ALIAS, new ResolvedUpstream("https", "backend.local", 9443, ""))); + TlsEdgeProducer producer = new TlsEdgeProducer(vertx, config, topology, EPHEMERAL_PORT, + INTERNAL_HTTPS_PORT); + + // Act + Assert — the front binds the ephemeral port on startup, then shutdown stops the + // started listener and closes the backend client without error. + assertDoesNotThrow(() -> producer.onStartup(new StartupEvent()), + "a resolvable passthrough SNI starts the front listener on an ephemeral port"); + assertDoesNotThrow(() -> producer.onShutdown(new ShutdownEvent()), + "shutdown stops the started listener and closes the backend client"); + } + } + + @Nested + @DisplayName("Passthrough unconfigured") + class PassthroughUnconfigured { + + @Test + @DisplayName("never starts the front listener when passthrough_sni is empty") + void noFrontListenerWhenPassthroughEmpty() { + // Arrange — no tls block at all, so the relay map is empty. + GatewayConfig config = GatewayConfig.builder().version(1).build(); + ResolvedTopology topology = new ResolvedTopology(Map.of()); + TlsEdgeProducer producer = new TlsEdgeProducer(vertx, config, topology, EPHEMERAL_PORT, + INTERNAL_HTTPS_PORT); + + // Act + Assert — an empty relay map short-circuits startup; the later shutdown is a clean + // no-op because neither the listener nor the backend client was ever created. + assertDoesNotThrow(() -> producer.onStartup(new StartupEvent()), + "an empty passthrough map never starts the front listener"); + assertDoesNotThrow(() -> producer.onShutdown(new ShutdownEvent()), + "shutdown is a no-op when nothing was started"); + } + + @Test + @DisplayName("skips a passthrough SNI whose alias does not resolve, leaving the map empty") + void skipsUnresolvedAlias() { + // Arrange — the only passthrough SNI maps to an alias absent from the resolved topology, so + // the defensive skip leaves the relay map empty and no front listener is started. + TlsConfig tls = TlsConfig.builder() + .passthroughSni(Map.of("sni.unresolved.example", UNRESOLVED_ALIAS)) + .build(); + GatewayConfig config = GatewayConfig.builder().version(1) + .tls(Optional.of(tls)).build(); + ResolvedTopology topology = new ResolvedTopology(Map.of()); + TlsEdgeProducer producer = new TlsEdgeProducer(vertx, config, topology, EPHEMERAL_PORT, + INTERNAL_HTTPS_PORT); + + // Act + Assert + assertDoesNotThrow(() -> producer.onStartup(new StartupEvent()), + "an unresolved alias is skipped, so no front listener is started"); + assertDoesNotThrow(() -> producer.onShutdown(new ShutdownEvent()), + "shutdown is a no-op when the only alias was skipped"); + } + } +} From 4f9d4efc72687d8b5ecb9ce510a2ed71fae2e0fc Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:03:54 +0200 Subject: [PATCH 10/15] chore(simplify): collapse triplicated TrustAllManager test helper Consolidate the byte-identical TrustAllManager X509TrustManager defined in MtlsHandshakeIT, PassthroughFaultIT, and TlsPassthroughIT into a single package-visible class in MtlsHandshakeIT, imported by the other two, and drop the now-unused imports. Test-only, package-private; no API or behavior change. Co-Authored-By: Claude --- .../gateway/integration/MtlsHandshakeIT.java | 9 ++++--- .../integration/PassthroughFaultIT.java | 26 ++----------------- .../gateway/integration/TlsPassthroughIT.java | 25 ++---------------- 3 files changed, 9 insertions(+), 51 deletions(-) diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java index 4a3eec77..3553708c 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java @@ -127,11 +127,12 @@ private static SSLContext clientContext(String keystorePath, String password) th } /** - * A trust-all {@link X509TrustManager} for the stack's self-signed server certificate. Scoped - * strictly to this black-box IT — never a production trust decision. Only the client-auth outcome - * is under test here. + * A trust-all {@link X509TrustManager} for a stack's self-signed server certificate. Scoped + * strictly to black-box ITs — never a production trust decision. Package-visible and shared with + * {@code PassthroughFaultIT} and {@code TlsPassthroughIT} so the three TLS-edge ITs do not each + * carry their own copy. */ - private static final class TrustAllManager implements X509TrustManager { + static final class TrustAllManager implements X509TrustManager { @Override public void checkClientTrusted(X509Certificate[] chain, String authType) { diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java index 3db39676..1dddc411 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java @@ -25,7 +25,6 @@ import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.security.SecureRandom; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.List; import javax.net.ssl.SNIHostName; @@ -34,7 +33,8 @@ import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; + +import de.cuioss.sheriff.gateway.integration.MtlsHandshakeIT.TrustAllManager; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -168,26 +168,4 @@ private void deleteProxyQuietly() { // fail the test body — the assertion under test is the relay abort, not proxy bookkeeping. } } - - /** - * A trust-all {@link X509TrustManager} for the relayed self-signed backend certificate. Scoped - * strictly to this black-box IT — never a production trust decision. - */ - private static final class TrustAllManager implements X509TrustManager { - - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { - // Trust-all test manager: not exercised on the client side. - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { - // Trust-all test manager: the relayed backend's self-signed certificate is intentionally accepted. - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } } diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java index 1013ce55..fdb3109d 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java @@ -29,7 +29,8 @@ import javax.net.ssl.SSLSocket; import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; -import javax.net.ssl.X509TrustManager; + +import de.cuioss.sheriff.gateway.integration.MtlsHandshakeIT.TrustAllManager; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -107,26 +108,4 @@ private static X509Certificate handshakePeerLeaf(String host, int port, String s return (X509Certificate) chain[0]; } } - - /** - * A trust-all {@link X509TrustManager} scoped strictly to this black-box IT — it inspects the - * negotiated identity itself, so it must accept whatever certificate the peer presents. - */ - private static final class TrustAllManager implements X509TrustManager { - - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) { - // Trust-all test manager: the local stack's self-signed certificates are intentionally accepted. - } - - @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) { - // Trust-all test manager: the negotiated identity is asserted by the test body, not rejected here. - } - - @Override - public X509Certificate[] getAcceptedIssuers() { - return new X509Certificate[0]; - } - } } From 238569170950ef4a8a9bc3b026b39fec8db0a0ae Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:46:02 +0200 Subject: [PATCH 11/15] style(tests): apply OpenRewrite import-order normalization The pre-commit OpenRewrite recipe normalizes import ordering in two grpc integration-test files touched incidentally by the whole-tree gate. Committing the gate-mandated formatting so the working tree is OpenRewrite-clean. No behavioral change. Co-Authored-By: Claude --- .../gateway/integration/GrpcProxyIT.java | 20 +++++++++---------- .../integration/grpc/GrpcEchoServiceTest.java | 5 ++--- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/GrpcProxyIT.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/GrpcProxyIT.java index 6c99a8e4..131e2bc3 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/GrpcProxyIT.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/GrpcProxyIT.java @@ -19,17 +19,6 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.ArrayList; -import java.util.Iterator; -import java.util.List; -import java.util.concurrent.TimeUnit; - - -import de.cuioss.sheriff.gateway.integration.grpc.EchoGrpc; -import de.cuioss.sheriff.gateway.integration.grpc.EchoRequest; -import de.cuioss.sheriff.gateway.integration.grpc.EchoResponse; -import de.cuioss.sheriff.gateway.integration.grpc.SecureEchoGrpc; - import io.grpc.ManagedChannel; import io.grpc.Metadata; import io.grpc.Status; @@ -39,6 +28,15 @@ import io.grpc.stub.MetadataUtils; import io.netty.handler.ssl.SslContext; import io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import de.cuioss.sheriff.gateway.integration.grpc.EchoGrpc; +import de.cuioss.sheriff.gateway.integration.grpc.EchoRequest; +import de.cuioss.sheriff.gateway.integration.grpc.EchoResponse; +import de.cuioss.sheriff.gateway.integration.grpc.SecureEchoGrpc; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/grpc/GrpcEchoServiceTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/grpc/GrpcEchoServiceTest.java index c4f54d7e..cbe2a088 100644 --- a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/grpc/GrpcEchoServiceTest.java +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/grpc/GrpcEchoServiceTest.java @@ -18,11 +18,10 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import java.time.Duration; -import java.util.List; - import io.grpc.Status; import io.grpc.StatusRuntimeException; +import java.time.Duration; +import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; From 4ee9dba0c5e742bb87168685bf63f85f0652903e Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:13:05 +0200 Subject: [PATCH 12/15] fix(tls): drop null-check on Optional in ClientHelloSniParser.Result (S2789) Sonar new_reliability_rating flagged java:S2789 at ClientHelloSniParser.Result: the canonical constructor null-normalized the serverName Optional, but every construction site (needMoreData, parsed, and extractServerName's returns) already supplies a non-null Optional, so the guard was dead defensive code that an Optional must never carry. Remove the compact constructor and the test that exercised the now-absent null branch. No behavioral change; coverage stays green. Co-Authored-By: Claude --- .../gateway/tls/ClientHelloSniParser.java | 5 ----- .../gateway/tls/ClientHelloSniParserTest.java | 16 ---------------- 2 files changed, 21 deletions(-) diff --git a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java index 8e3afe23..69089a2c 100644 --- a/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java +++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java @@ -296,11 +296,6 @@ private static final class MalformedHelloException extends RuntimeException { */ public record Result(boolean complete, Optional serverName) { - /** Canonical constructor normalizing an absent {@code serverName} to {@link Optional#empty()}. */ - public Result { - serverName = serverName == null ? Optional.empty() : serverName; - } - /** * The "keep buffering" verdict: the ClientHello is not yet complete. * diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java index 1c68d0c3..26384d38 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java @@ -279,22 +279,6 @@ void nameEntryLengthPastList() { } } - @Nested - @DisplayName("Result verdict factory") - class ResultVerdict { - - @Test - @DisplayName("normalizes a null server name to an empty Optional") - void normalizesNullServerName() { - // Act — the canonical constructor must defend against a null Optional argument. - ClientHelloSniParser.Result result = new ClientHelloSniParser.Result(true, null); - - // Assert - assertTrue(result.complete()); - assertTrue(result.serverName().isEmpty(), "a null server name normalizes to empty"); - } - } - /** * Builds real TLS ClientHello byte layouts for the parser tests. Kept package-visible and static * so both this test and {@code SniFrontListenerTest} / {@code PassthroughRelayTest} can craft the From 984725dd0bb241c2cd2576898eea1780f73da63d Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:02:41 +0200 Subject: [PATCH 13/15] fix(integration-tests): activate the TLS edge in the IT deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The five new TLS ITs (TlsPassthroughIT, MtlsHandshakeIT x2, HostSmuggleGuardIT, PassthroughFaultIT) passed unit tests but failed in the Docker runtime: the integration-test deployment never turned the (opt-in) TLS edge on. Production wiring is correct and unchanged — the gap was purely in the IT descriptors. - gateway.yaml: declare tls.passthrough_sni (passthrough/fault test hosts → aliases); topology.properties: resolve those aliases base-path-free. - New sheriff-config-mtls/gateway.yaml (tls.mtls.enabled + client_ca) overlay, served by a new api-sheriff-mtls compose service; primary gateway gets the QUARKUS_HTTP_SSL_PORT=8444 internal-port split so the SNI front owns 8443. - generate-mtls-certificates.sh + committed client-CA / wrong-CA keystores; Failsafe wires test.mtls.* system properties; host-side mTLS readiness wait. - The mTLS test instance is pinned to TLS 1.2 so JSSE surfaces the client-auth rejection synchronously at handshake (observation-point only; mutual auth is identical on 1.2/1.3). - New TlsEdgeActivationWiringTest (fast, no-Docker surefire guard) asserts the descriptors actually activate the edge — red against the unwired config, green after this fix — closing the unit-green/IT-red blind spot. verify -Pintegration-tests: 50 completed, 0 failures/errors. No production code, no ConfigValidator change, no new dependency. Co-Authored-By: Claude --- integration-tests/docker-compose.yml | 72 ++++++ integration-tests/pom.xml | 23 ++ .../scripts/start-integration-container.sh | 23 ++ .../generate-mtls-certificates.sh | 88 +++++++ .../docker/certificates/mtls-client-ca.crt | 23 ++ .../main/docker/certificates/mtls-client.p12 | Bin 0 -> 3898 bytes .../main/docker/certificates/mtls-wrong.p12 | Bin 0 -> 3880 bytes .../docker/sheriff-config-mtls/gateway.yaml | 92 +++++++ .../main/docker/sheriff-config/gateway.yaml | 14 ++ .../docker/sheriff-config/topology.properties | 14 ++ .../TlsEdgeActivationWiringTest.java | 228 ++++++++++++++++++ 11 files changed, 577 insertions(+) create mode 100755 integration-tests/src/main/docker/certificates/generate-mtls-certificates.sh create mode 100644 integration-tests/src/main/docker/certificates/mtls-client-ca.crt create mode 100644 integration-tests/src/main/docker/certificates/mtls-client.p12 create mode 100644 integration-tests/src/main/docker/certificates/mtls-wrong.p12 create mode 100644 integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml create mode 100644 integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsEdgeActivationWiringTest.java diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml index b5a8b0f6..a0b961b5 100644 --- a/integration-tests/docker-compose.yml +++ b/integration-tests/docker-compose.yml @@ -168,6 +168,12 @@ services: - QUARKUS_PROFILE=it # File logging to mounted target directory - LOG_FILE_PATH=/logs/quarkus.log + # Accept-time SNI passthrough split: gateway.yaml declares tls.passthrough_sni, so the Vert.x + # SNI front listener owns the public TLS port (sheriff.tls.public-port=8443) and the terminated + # Quarkus HTTPS listener must move to the internal loopback port to avoid a bind conflict. This + # override moves it to sheriff.tls.internal-https-port (8444); the front L4-relays a matched + # passthrough SNI to the resolved backend and hands every other connection to localhost:8444. + - QUARKUS_HTTP_SSL_PORT=8444 # Certificate paths (runtime override) - QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key @@ -240,6 +246,72 @@ services: # Production restart policy restart: unless-stopped + # Dedicated mTLS-terminated gateway instance backing MtlsHandshakeIT (GW-06 / D2). mTLS is a + # property of the single terminated Quarkus HTTPS listener, so it cannot coexist on the primary + # instance (10443) whose terminated path serves the rest of the suite without a client cert. This + # instance reuses the SAME native image, overlays an mTLS-enabled gateway.yaml (require-and-verify + # against mtls-client-ca.crt, no passthrough → Quarkus terminates directly on the public port), + # and is published on 10444. TLS is pinned to 1.2 here so the JDK black-box client observes the + # client-auth rejection synchronously at startHandshake() — under TLS 1.3 JSSE surfaces a + # client-auth failure only on the first read, which MtlsHandshakeIT's read-less handshake would + # miss; the mutual-auth security check itself is identical on 1.2 and 1.3. + api-sheriff-mtls: + image: "api-sheriff:distroless" + ports: + - "10444:8443" # External test port for MtlsHandshakeIT (test.mtls.port) + - "19001:9000" # Management interface (health/metrics, plain HTTP) + environment: + - QUARKUS_PROFILE=it + - LOG_FILE_PATH=/logs/quarkus-mtls.log + # Terminate directly on the public port (no passthrough front on this instance). + - QUARKUS_HTTP_SSL_CERTIFICATE_FILES=/app/certificates/localhost.crt + - QUARKUS_HTTP_SSL_CERTIFICATE_KEY_FILES=/app/certificates/localhost.key + - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PATH=/app/certificates/localhost-truststore.p12 + - QUARKUS_TLS_DEFAULT_TRUST__STORE_P12_PASSWORD=localhost-trust + # Pin the terminated HTTPS listener to TLS 1.2 so the require-client-cert rejection is + # observable synchronously at startHandshake() (see the service comment). The controlling key + # is quarkus.http.ssl.protocols (the HTTPS listener's protocol list); quarkus.tls.protocols is + # not the listener's control and would leave TLS 1.3 offered. + - QUARKUS_HTTP_SSL_PROTOCOLS=TLSv1.2 + - SHERIFF_CONFIG_DIR=/app/sheriff-config + depends_on: + keycloak: + condition: service_started + go-httpbin: + condition: service_started + asset-origin: + condition: service_started + grpc-echo: + condition: service_healthy + volumes: + - ./src/main/docker/certificates:/app/certificates:ro + # Shared config dir, then overlay ONLY gateway.yaml with the mTLS-enabled variant. + - ./src/main/docker/sheriff-config:/app/sheriff-config:ro + - ./src/main/docker/sheriff-config-mtls/gateway.yaml:/app/sheriff-config/gateway.yaml:ro + - ./src/main/docker/assets:/app/assets:ro + - ${LOG_TARGET_DIR:-./target/quarkus-logs}:/logs:rw + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=100m + deploy: + resources: + limits: + memory: 512M + cpus: '4.0' + reservations: + memory: 256M + cpus: '1.0' + # No in-container healthcheck: the distroless image ships neither a shell nor curl. Readiness is + # gated host-side by start-integration-container.sh probing the published management port 19001, + # exactly as the primary instance is gated on 19000. + networks: + - api-sheriff + restart: unless-stopped + prometheus: image: prom/prometheus:v3.6.0 ports: diff --git a/integration-tests/pom.xml b/integration-tests/pom.xml index 9c5d5669..024445fd 100644 --- a/integration-tests/pom.xml +++ b/integration-tests/pom.xml @@ -201,6 +201,17 @@ ${test.https.port} + + 10444 + ${project.basedir}/src/main/docker/certificates/mtls-client.p12 + localhost-trust + ${project.basedir}/src/main/docker/certificates/mtls-wrong.p12 + wrong-trust org.jboss.logmanager.LogManager ${skipITs} @@ -214,6 +225,18 @@ org.codehaus.mojo exec-maven-plugin + + + generate-mtls-certificates + pre-integration-test + + exec + + + ./src/main/docker/certificates/generate-mtls-certificates.sh + ${project.basedir} + + build-native-if-needed diff --git a/integration-tests/scripts/start-integration-container.sh b/integration-tests/scripts/start-integration-container.sh index 6ae1f017..d43565cb 100755 --- a/integration-tests/scripts/start-integration-container.sh +++ b/integration-tests/scripts/start-integration-container.sh @@ -168,6 +168,29 @@ for i in {1..30}; do sleep 1 done +# Wait for the dedicated mTLS gateway instance (api-sheriff-mtls) to be ready. It reuses the same +# native image and reaches readiness offline (static-file JWKS), published on management port 19001. +# MtlsHandshakeIT connects to its TLS port 10444, so it must be up before the IT phase. +echo "⏳ Waiting for the mTLS gateway instance to be ready..." +for i in {1..30}; do + if curl -sf http://localhost:19001/q/health/live > /dev/null 2>&1; then + echo "✅ mTLS gateway instance is ready!" + break + fi + if [ $i -eq 30 ]; then + echo "❌ mTLS gateway instance failed to start within 30 seconds" + DIAG_DIR="target/failsafe-reports" + mkdir -p "$DIAG_DIR" + echo "----- $COMPOSE_BASE logs api-sheriff-mtls -----" + $COMPOSE_BASE logs --no-color api-sheriff-mtls 2>&1 | tee "$DIAG_DIR/api-sheriff-mtls-app.log" + curl -s http://localhost:19001/q/health 2>&1 | tee "$DIAG_DIR/api-sheriff-mtls-health.json" + echo "" + exit 1 + fi + echo "⏳ Waiting for mTLS gateway... (attempt $i/30)" + sleep 1 +done + # Extract native startup time from logs NATIVE_STARTUP=$($COMPOSE_BASE logs api-sheriff 2>/dev/null | grep "started in" | sed -n 's/.*started in \([0-9.]*\)s.*/\1/p' | tail -1) if [ ! -z "$NATIVE_STARTUP" ]; then diff --git a/integration-tests/src/main/docker/certificates/generate-mtls-certificates.sh b/integration-tests/src/main/docker/certificates/generate-mtls-certificates.sh new file mode 100755 index 00000000..aa993af4 --- /dev/null +++ b/integration-tests/src/main/docker/certificates/generate-mtls-certificates.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# Generate the client-certificate material for the mTLS integration-test instance (api-sheriff-mtls). +# +# Produces, next to the existing server material: +# - mtls-client-ca.crt : the TRUSTED client CA (self-signed). Mounted into api-sheriff-mtls as +# tls.mtls.client_ca — the terminated listener's client-auth trust anchor. +# - mtls-client.p12 : a client identity (leaf signed by the trusted CA, chain includes the CA), +# password 'localhost-trust'. MtlsHandshakeIT presents it and the handshake +# MUST complete. +# - mtls-wrong.p12 : a client identity signed by a DIFFERENT, untrusted CA, password +# 'wrong-trust'. MtlsHandshakeIT presents it and the handshake MUST be +# rejected, proving the trust anchor is enforced (not merely that a cert +# was presented). +# +# Test-only material: the shipped default profile trusts no such CA. Passwords are non-secret test +# fixtures matching the MtlsHandshakeIT / Failsafe defaults. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CERT_DIR="${SCRIPT_DIR}" +VALIDITY=730 +SUBJ_SUFFIX="/OU=Integration Testing/O=API-Sheriff/L=Berlin/ST=Berlin/C=DE" + +echo "Generating mTLS client certificate material in ${CERT_DIR}..." + +# Clean any prior material so regeneration is deterministic. +rm -f "${CERT_DIR}"/mtls-client-ca.crt "${CERT_DIR}"/mtls-client-ca.key \ + "${CERT_DIR}"/mtls-client.crt "${CERT_DIR}"/mtls-client.key "${CERT_DIR}"/mtls-client.csr \ + "${CERT_DIR}"/mtls-client.p12 \ + "${CERT_DIR}"/mtls-wrong-ca.crt "${CERT_DIR}"/mtls-wrong-ca.key \ + "${CERT_DIR}"/mtls-wrong.crt "${CERT_DIR}"/mtls-wrong.key "${CERT_DIR}"/mtls-wrong.csr \ + "${CERT_DIR}"/mtls-wrong.p12 \ + "${CERT_DIR}"/mtls-client-ca.srl "${CERT_DIR}"/mtls-wrong-ca.srl + +# --- Trusted client CA ------------------------------------------------------------------------- +openssl req -x509 -newkey rsa:2048 -nodes -days ${VALIDITY} \ + -keyout "${CERT_DIR}/mtls-client-ca.key" -out "${CERT_DIR}/mtls-client-ca.crt" \ + -subj "/CN=mtls-client-ca${SUBJ_SUFFIX}" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + +# --- Trusted client leaf (signed by the trusted CA) -------------------------------------------- +openssl req -newkey rsa:2048 -nodes \ + -keyout "${CERT_DIR}/mtls-client.key" -out "${CERT_DIR}/mtls-client.csr" \ + -subj "/CN=mtls-client${SUBJ_SUFFIX}" +openssl x509 -req -in "${CERT_DIR}/mtls-client.csr" -days ${VALIDITY} \ + -CA "${CERT_DIR}/mtls-client-ca.crt" -CAkey "${CERT_DIR}/mtls-client-ca.key" -CAcreateserial \ + -extfile <(printf "keyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n") \ + -out "${CERT_DIR}/mtls-client.crt" +openssl pkcs12 -export -name mtls-client \ + -inkey "${CERT_DIR}/mtls-client.key" -in "${CERT_DIR}/mtls-client.crt" \ + -certfile "${CERT_DIR}/mtls-client-ca.crt" \ + -out "${CERT_DIR}/mtls-client.p12" -passout pass:localhost-trust + +# --- Foreign (untrusted) CA + client leaf ------------------------------------------------------ +openssl req -x509 -newkey rsa:2048 -nodes -days ${VALIDITY} \ + -keyout "${CERT_DIR}/mtls-wrong-ca.key" -out "${CERT_DIR}/mtls-wrong-ca.crt" \ + -subj "/CN=mtls-wrong-ca${SUBJ_SUFFIX}" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" +openssl req -newkey rsa:2048 -nodes \ + -keyout "${CERT_DIR}/mtls-wrong.key" -out "${CERT_DIR}/mtls-wrong.csr" \ + -subj "/CN=mtls-wrong${SUBJ_SUFFIX}" +openssl x509 -req -in "${CERT_DIR}/mtls-wrong.csr" -days ${VALIDITY} \ + -CA "${CERT_DIR}/mtls-wrong-ca.crt" -CAkey "${CERT_DIR}/mtls-wrong-ca.key" -CAcreateserial \ + -extfile <(printf "keyUsage=critical,digitalSignature\nextendedKeyUsage=clientAuth\n") \ + -out "${CERT_DIR}/mtls-wrong.crt" +openssl pkcs12 -export -name mtls-wrong \ + -inkey "${CERT_DIR}/mtls-wrong.key" -in "${CERT_DIR}/mtls-wrong.crt" \ + -certfile "${CERT_DIR}/mtls-wrong-ca.crt" \ + -out "${CERT_DIR}/mtls-wrong.p12" -passout pass:wrong-trust + +# Retain only the runtime artifacts: the trusted CA anchor (mounted into the mTLS instance) and the +# two client keystores (read by the Failsafe-side MtlsHandshakeIT). Drop keys/CSRs/serials and the +# untrusted CA — none are needed at runtime. +rm -f "${CERT_DIR}"/mtls-client-ca.key "${CERT_DIR}"/mtls-client.key "${CERT_DIR}"/mtls-client.csr \ + "${CERT_DIR}"/mtls-client.crt \ + "${CERT_DIR}"/mtls-wrong-ca.key "${CERT_DIR}"/mtls-wrong-ca.crt \ + "${CERT_DIR}"/mtls-wrong.key "${CERT_DIR}"/mtls-wrong.csr "${CERT_DIR}"/mtls-wrong.crt \ + "${CERT_DIR}"/mtls-client-ca.srl "${CERT_DIR}"/mtls-wrong-ca.srl + +chmod 644 "${CERT_DIR}/mtls-client-ca.crt" "${CERT_DIR}/mtls-client.p12" "${CERT_DIR}/mtls-wrong.p12" + +echo "mTLS client material generated:" +echo " - mtls-client-ca.crt (trust anchor, mounted as tls.mtls.client_ca)" +echo " - mtls-client.p12 (trusted client identity, password localhost-trust)" +echo " - mtls-wrong.p12 (foreign-CA client identity, password wrong-trust)" diff --git a/integration-tests/src/main/docker/certificates/mtls-client-ca.crt b/integration-tests/src/main/docker/certificates/mtls-client-ca.crt new file mode 100644 index 00000000..e9e2bc58 --- /dev/null +++ b/integration-tests/src/main/docker/certificates/mtls-client-ca.crt @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIID6TCCAtGgAwIBAgIUIeJIxcdWl14iJcZLRou6S8taDgUwDQYJKoZIhvcNAQEL +BQAwfDEXMBUGA1UEAwwObXRscy1jbGllbnQtY2ExHDAaBgNVBAsME0ludGVncmF0 +aW9uIFRlc3RpbmcxFDASBgNVBAoMC0FQSS1TaGVyaWZmMQ8wDQYDVQQHDAZCZXJs +aW4xDzANBgNVBAgMBkJlcmxpbjELMAkGA1UEBhMCREUwHhcNMjYwNzI1MTI1NTQ0 +WhcNMjgwNzI0MTI1NTQ0WjB8MRcwFQYDVQQDDA5tdGxzLWNsaWVudC1jYTEcMBoG +A1UECwwTSW50ZWdyYXRpb24gVGVzdGluZzEUMBIGA1UECgwLQVBJLVNoZXJpZmYx +DzANBgNVBAcMBkJlcmxpbjEPMA0GA1UECAwGQmVybGluMQswCQYDVQQGEwJERTCC +ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAO2WYILN3rkb9zxz1z7qTfdr +h2M1Euuix5piZTzRIj58c+Tuxzmp9g2RxpCzh7PJ2rgLfuK/3uajM38Fl0bMBb3g +IQ4noqkiOjtMm/qxz6TIvRKMtzoEY67sPLv46CE7rYT/h5z52/j7aLGiOgfhYl40 +CGqBtRSfHArPt4b2+fDxN8j2NiSjrVNg1nSiLwpU/MggRMsRGcDuvwf5pLDFzgwY +Lpz5qZskPnXAplHsecUHWdLDbUmLTOCUICf4THXdr/Y8qUQQ68ZfAvbcMcEvQvlq +uneNZNgG3CpP2OZimYtC1jdpQIJXFLrdZE7weMIHysFaMYiGEPCWnsqLwGz0ussC +AwEAAaNjMGEwHQYDVR0OBBYEFABIuA6ZOpEAyGGdtAQv5/7YH2fPMB8GA1UdIwQY +MBaAFABIuA6ZOpEAyGGdtAQv5/7YH2fPMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P +AQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4IBAQBQl7yYQe4AkHBVKfe9gsPagZCr +3iHTnYLvKLNBNme9rM1UlmGg79oEuU58tX1hX1iPCBiobX+HAuE8Y3vHp02az1Fx +5DkaQSbi9owoopuQT5hYzp75LeLN5WSmRKDfTH4tBGIsQO86SgMAQdemIyRR8YnC +rP3Qm+YMgeFllx7Czi55fijQDJ99E/52wtgSjSLmSzkiyvgCqIzxNFJ1ZBOt0K4n +kVM0IabUpegZE67d/B1OQnvyXYwLmsXfHBx5F+BdHe8jtQoaRfQfUeA9CfDTxU/4 +sh1V6qq264fLXgecY5o/8cU5FnkbqqasvlUawVKOaz/Xy4tfASKOEB2GPPUS +-----END CERTIFICATE----- diff --git a/integration-tests/src/main/docker/certificates/mtls-client.p12 b/integration-tests/src/main/docker/certificates/mtls-client.p12 new file mode 100644 index 0000000000000000000000000000000000000000..4d1a6ec00e83ef953e70b6b76eb2072807387d61 GIT binary patch literal 3898 zcmai%S5Om*)`gQ0LPGCI7Xg(XAaqa(9fE)q5h>x&i`1YVsR;k=1DKspEn1;800(Eqy%g3$utf)E%w))44M0|rWhS?MS@xDh6w_M7-59$%L` zNQ}ZkQzN#)G3qA#3AMEyRU3>sjvKv)b%xosCv1Jb;`k<&nxr*(yGYDO z!V5CPZu;<`sv=4o<(sU}BfO`y_j{`|ua0MT-?Fb&!oa#S8Gq7-4limJ9DO9GQ%=ab zj$p9cY-tb|$wzxe-?Lc@4>;il?~of7ZOO8QR`bXwNt&*E`$zUoq`p&`_MJQ53#R;t z*tyduis$do=yzjZDmcB=3NcJ;yCk0dO6Jb=-|l(Ckx6rkbF$$QnGY8uH@+vs6-g=b zZ6<6e2_3?POmlkm@K~_=p7hW_3npvo#~K#hG4w!VK=WOg^yz_NcIw5Y$#He@luy(w zT{)zGukPyGWaFZ~`O!QH`PXbbPxtLRTj`qSzIDt;5-KiL9hg^Grswib+Am!`;2}EkF3=UiR3)}sd6yn?efjA z$^zFjw8lQ36!uHcXgOo_HvZ(NbTl_t z6>2pM8g5N;1>N8G<&moy$p?V;l3dyD<9dOZ(-Zm8<#_a>{ZAioKO9@BhhMiKlX0Kn=bE%$II;N58?+sSQCid zLBAIn)3r#5dady!uh^+cPV_Pm!@_6#awgOd#T7{BdX&toX&Hj7Y&h-a=zNk{C;LlS z4L0Asxn+Q6mmn|DKR}ym*eVFu+m|f}|Lh+7RIPEYhrF0T5wu%(dWL#1-xhJ&B~%}K*kG1c#P zMTnEdJ+(Yl$#-)5)QSu@-V~SZ&$~RPR3H?XexIzlZ}7>q8quyadqF;U_vKcP(p=(VE`tI^kJ~9-w6Cvy6oI{C=;rNDd~`osJR!>1 zRUl^{;jiu#$*Xf@JXy2ZB9VN?LKT$wR=P!V_+7cM2hT=COsu%$GckL4V#Q9|ftBJ_ z>jfcJgh~yoFG3)r^sW8_Fv#>qFkUYE{t4t+#y0>IzV0X+B8)(73_x}$nAGo~A;6hz zOOuqc54F>r)!}TCCAQ^`?SG{WddROm)8B=~-mJ`{>)j5I9saDNi{#h1HtTIxlX;z9 zRz~!rdG1tRVFaXI>yh4VkN8GlV==JdS~kh}y#gB8 zVFt7hIIo_)g8opy`aSmpbUy(acBx>_#nXXPgdDc$JKbK=FCilxlbyqoA!HnD3f&|G z1V6QYW6Q9X=QS;v?wAHd3POD{tVbGg}=z%x^SW@If z0lj=ui#=P$9b>x&{}7Hy^@oQYjg2p=`-Fl*I$qTLrY(?0T3ffcIx@pWQ1b$orE&X7 zS4(+ymFKtZf{ytr`Wj9*18OJIf6T#cVTP1q*w&jmYjKSn)=jC$^CEq38NZ#j6nNPP zdV1#8pU~NHufKX~)yJu9AJ@^8AYQ<0rGx)cs`zpw9K|wBUh@acZhpk5$Ymd7a`B^< ztq#5J=`*o=$)*HYCBLLwJju5 zZ!k@rwyh*$l#$vg*X;;Z^gcu|gX)BDH|sRKzFZPhH%YNac0OrTQW^ceY1Rpj{0V}$ z$F2vFzmm+(R6*hWIuXUbiW#EYHoW+m zV(bgIYcNjET=vsJEF4E$_Ydd%7o2E|!8lstA366=5g-iz&s2=`K+qpk?T@JRzd%V+ zL7kN@%DszBId6 z-NoTy!{Jj`@+tX(WV^sOg=l+rqnD?xZK)oL-u`UPX3>)yofkySLRDSnnC5dKR=w!- zn{lN#ea%}N?5|SxjjwcLm29>cBHwq4MqSG#4X{jbjuPm;nwi@9DX)oueI5`5mi^E1V6EHD7g>v|SRAY6KJ0^|>F~X9w z*>eu14w3hRHJ80Fe)M~E==7DUgeCGnvsT?(A!MDLyNPA@UjK;LDzHFKmHAgX(O+6H z=1Ey=DT@Iw!9??k#-pdroc-_ehhi3G?zONv)HaZW@};pfH{uBqJXd1&a<`u_j+(?S zRnAa9Zv&H>92#v9_Gs?fW#7_YJ?JVZ8md02SjpB7cB?g1f+#GuLE) z+LX!sMZ>&%3@C5fjx4ivC)ArJ$XR9~{G^_rhaeXRLhI?_Z|Z`6<@2VewpwDDm2EAg zuKwIoy3x>od5Qj4T%w94-!}al?}q@2sFj*0RFCQ}93_M+@xOp$f)wrPe@S_9C=e1+ zf~p%C*2`aa-+61qAv!7cbp|M?A?hyoiv%mn)r@wZatc^Edev@r8IM^!H;nBb`$~%& z&Rb0S@T;8LF44n7R>Gm)+vnEv*CV<%dY%olYc`Ko^OkAdzG;D6DU8_(cF>((<8DNz zYYx3amTcm+@v-pnG$2sVrRS_7K1{Bw{u;OvhOp+-#OvtUP_ z=UuW62d*J4D|%Wb@qn*gxnYY6fTSMvv<4C~Esh-8X5qfN=p}kkA5e!ptkshNemA+I zZF@rPv@CvJKmi`yN}Nr1-QJ0TlNZ=T4Qe;LA_Fe}*yW~#B(a5#G~k|Kpp$iNPaeB` zly;ez{QDKwf~~AZ^GKLWZZ~sgdLFxi>J)qSHo_YjaLeLzW>0}+6kq%R=F}s|b#B)t zp56Ry@R<=lKl`(^kWK2kT_PidUMp>PyDar}dseQdKi3?zf>*n1#sU!7_m%z0L6T90XD_$4c5E`Vk-HG4W*U+Yi6)wWPr!rsn87jwLr1BuUtS}XJ96Dk zIi+@9DdbFt;Lv3|pQRxo6ttWaOd9{LuuwUaON;l&aZ}S8OK+!%2nrKV;oe*9>)7yMc?A-vR|J=%%QTAMmC>JJex}kB&npU# zN}F2HrB5!Fn=%~pAk@9SFrrRuzmR*lJ~z#j?u>eEjB#T%sKGFPCo6iXvRx8>Z@)Zswjpi<1q(vT96^cJ8+hOEk3RDqM@;rCB+P?pM|Y zA#{j08J+c8qI4F#vkof*xyy3oQE<_Jf)>Ul&kg1T1$>TqJ#&qLA~RIC)qNUa;Y==q zi~qaCDSttT8*mSR0r&!f0WtuWKiv!92Ji!5;M#C`IMYAhCJ0Ci0AC!^Mwu=B3g*!E oKTW9};{U=EjkP#l7jCmSQ&`lbvO9vIuU7YH)5g_S{+r1E0sS&HoB#j- literal 0 HcmV?d00001 diff --git a/integration-tests/src/main/docker/certificates/mtls-wrong.p12 b/integration-tests/src/main/docker/certificates/mtls-wrong.p12 new file mode 100644 index 0000000000000000000000000000000000000000..847af6adaadf44d8002b26e8ab222cac50446d3e GIT binary patch literal 3880 zcmai%XEYm**T)lLhS;(9sNI+~s`hMS)0CRE8(UFoMQdwo6h(}pBGjnbd)Ev~j3Q=B z)hKGKr~mUj=l6Tw{a@U3&;5SyIrrW7d@c+{BR~WshM^$yq!gktdNC)|Kr&zf3Nj2r zK?eTHJunmn+rJiR0g3|rS0W<<0{%9@zYY*)_s>8=4KsyF{X0^@1VFS$<}1n#ZdGZa zbHV`^7a%DjB60wXij@3+uM$y^0AQS?6!tNCKxbkQP!vRqX+quEYjQ2{Td~2wm$q(t^woWFH!IM;g<-wmDQFGQ-!={ zm)M&ikVOCVDJQJZH-%9GGQ5vtxIVeQXC?P0 zV$e+Ptfcy=wt3$wI|Ho8TxRoT7JW-rRHHrak65#(KTmp+`tq9crHBqr6AEI|6ua4U zlL7&n$iv2M!C^Q*a{46WaF-G6pQaxC?YF_-syNESpPv)A>9Ozlx{0hjbe&B{SDgiK z-yH}8g!kEG82oVlwCe9pQ!sdlcz_A{F+JF&5pZ;Pf*s z`%lwa<57aD@$m<#ipLp&g=MKe%XWN96zj75x!C%hfMs?S#{!%Qsa$l% zX=G**bfgc*Xmr3QLx_LQzC+~k<-L2CV9>;XmAr>@uP?^ngRA%bnQ@cQdp0w795S6t z?x;#JM8j2WIh++8GcSdjt`A4Lf#W4Sy@!SZw{p;NQE7FVJ;! z{2j6Kh;yakZ!-INgRCe!42mBk3|s!; zT&-{^C4-VctGtfI>GDmsv(1;JK`ptCMT5Q7Q%VXEnz+s-)KHA~qZ)hhs7QKwcCq~v zcXR$Eb%VRHn`s5vWr$rlS*0hss9}6)*Db{B>E-I_w+^e4-=jZoLs&nA&I4{l;k`9~2_N)Fb z)2|{4^IoW2v7Egw%4Lf`pe(;WzXDo8mVp_?+u20VlwTx38o!UrpN?la1mk%rF^dtKbR5a;ifmU3d9VC;bDJWKD=v zVtT`3UquI6Tg2P3g`*o2l5{Y%xJR7duJ&NAv2f}4z^G`#cl?)=;G34A5UmM)YwCKI z&Tw;1bU2Ki$Shp7>RDrXZd4pwogATk>o@DQ){xTByWOZe6t_~kLU_q6aa<15(s}r# zXBuN6A7rAoO`D3#w)WIzcr%?P5$(|2lTU7bsLOuULCn_+i+4oyIW1XxSYy;n&oQA5 zmBS}Fe}3Gr0+D0yZdRvy1Mmv2;KDehg0}oXIF|M*D#T9C4g?ogWlI zm{omN<99tHTxyH6uM)&d+9$E>m(g1nE}x!mT#{a}S`SBaDkiN<_-?n2Qa zHkWtI<9FmN&AtOljk%QT6pd^6XpXSR0!`)nW3(|NcFJy+eP2+lipDrnO)AIgFTzK zV<++NFWBvqB$ISCpUq8Hv6UZ$_p?C|StY?7b11H7F}v?ta`q{1Upv$Y3*YP06GM1F zSiao48G^Q!e)Rmw%ONL*46*kCoBL34ZlH@H-6pj>H7z%I+nKdIDs z3KSJ(e?~80H;Hg2G`fX@1A@$4GW%;bmTm97-Zs=x&A9n!8%;G5NTnC+ccg!hCGEZe z58X;^pJ=A|GmgU>BdS>mS<`4mX^JKGtF_k5dz(g(s3niyYBmBH3(RQ!fJXA+9}9S6 zvpj_ig6TdLGIbq>A}RcbbN&NPB&i@2$;-cT!rzJ^1^*vYQBeYk{+c3xMZy0DO0A@k za_i;eRL_MdT?MjMXNZ)m!HmFs5GY zH0+Gn*0DG9_R--2U1!kw^?i9Wp87z2<90YJKK19a;Yl8Qa0Dxa0BBpZZaE3ZlP#rn ztsTv_256V_oX#x6Zr;SL?wcJY4y@cD?=<>o7(uV@&%JbbxsGi5K`O=-QO9EDy>DJ_ zm$llRrOog)&^fv`!&8yZFRR40PHBwdc1dGvAhv>_yF9`lx&_vlbUXvk|gS zEckD%IW_O|%TcT6s}gw5McJ4CunpZd&VODaOF0=jrQ;f zr02)78@9XBH+|Nc(m-9(kirYH_t}S6HF~lU5aZ{SUiu_5 z=GO*GNVwTS|3YIxQ_)*eq#Jx#O)2O!{)_H=!Vdb2A!|w)spYFjl)Ac6_?M>dO#KHz zE49zQ3Rl=yADc|+Osd;5<|`>j!zXPphB{nfoQn#Xu6B|Q+4W|sHwJX4SqSxIb52c( zYrV7I_OP)5+r@fNZw@O_pMfvqvu8&`jhqoNG$ zbGabAK#W~fq^`fe4pZVzSz}eJ6XSV`VD$7vg_DcIfbA-u@x~p=b*4$4eCO-YE$xjm z>AqR$oZ9s+;-jp7HNF`(-6vM0|>}_|9x-N#4Tu zs(|9~60FD6ec#1n4#KH`BVI<>ZKbSz<@-W2@|K7C1;BOkMFT5FP!;~;_?l44zZV== zYk}~Mr1D!1a*Z)H@yI6vXNS0>Mo-=bDv&z5d)1C^*3zDiwlfud@(LLBe3zco*nr1Q zj;Vh;uh-ajmrCduT3tw*6T^pmeVt!ck|0#{S#krRgy$7rQ8bku)B}a@-arKkWJb0d ztr#M&KdoB^6}-sh;;1%;p0>CMVU8-sjLE=BvrSE8a@8i-`(5rRD3? zADz}W47f}Cti=%)`>n)zCf#15TDc%da!)L1u`!V+xeoMQAk0a6Db22|3AS>!6*ggVgz^s0su&WKR^Ny1n>iR1H1sP|9^=|3JPLO$l*ahn(9?? zem^-+l~>7w$@D|Q)L~LE>VJMqA|MF>#4{X#jhWUlKjJsvf0S?}oywyTQL)}2lb}xj Xi-Df&BN9YDdL|Q6O4N$^PyYT73F0)A literal 0 HcmV?d00001 diff --git a/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml new file mode 100644 index 00000000..cc501cbe --- /dev/null +++ b/integration-tests/src/main/docker/sheriff-config-mtls/gateway.yaml @@ -0,0 +1,92 @@ +# yaml-language-server: $schema=../../../../../api-sheriff/src/main/resources/schema/gateway.schema.json +# Global gateway document for the DEDICATED mTLS integration-test instance (api-sheriff-mtls in +# docker-compose.yml). It is overlay-mounted on top of the shared sheriff-config directory so it +# replaces ONLY gateway.yaml — the endpoints/ subdirectory and topology.properties are shared with +# the primary instance, keeping the two documents in lockstep except for their tls block. +# +# This instance exists because mTLS is a property of the single terminated Quarkus HTTPS listener: +# switching it to require-and-verify would break every existing terminated IT on the primary +# instance (they make plain HTTPS calls with no client cert). So the require-client-cert behaviour +# (GW-06 / D2) is proven on this separate listener, published on host port 10444, while the primary +# instance (10443) stays terminated-without-mtls for the rest of the suite. +# +# The anchors and token_validation block are a faithful copy of the primary gateway.yaml so the +# shared endpoints/ resolve identically and the offline it-static JWKS issuer reaches readiness +# without Keycloak. The ONLY intentional difference is the tls block: no passthrough_sni here (the +# SNI front never starts, so Quarkus terminates directly on the public port), and tls.mtls enabled. +version: 1 +metadata: + config_version: "integration-test-mtls" +allowed_methods: ["GET", "POST", "PUT", "DELETE"] +tls: + alpn: ["h2", "http/1.1"] + # Require-and-verify client certificates on the terminated listener (tls/MtlsServerCustomizer). + # client_ca is the PEM trust anchor the client certificate must chain to; it is mounted read-only + # into the container at /app/certificates/mtls-client-ca.crt (see the certificates volume). A + # client presenting no certificate, or one signed by a CA this anchor does not trust, is rejected + # at the TLS handshake — never as an HTTP status. The anchor deliberately trusts ONLY the + # mtls-client CA, so MtlsHandshakeIT's wrong-CA identity (mtls-wrong.p12) is rejected. + mtls: + enabled: true + client_ca: /app/certificates/mtls-client-ca.crt +anchors: + api: + path_prefix: /proxy + type: proxy + access: public + bff: + path_prefix: /bff + type: proxy + access: public + secure: + path_prefix: /secure + type: proxy + access: authenticated + auth: + require: bearer + graphql: + path_prefix: /graphql + type: proxy + access: public + allowed_methods: ["POST"] + upload: + path_prefix: /upload + type: proxy + access: public + allowed_methods: ["POST"] + security_filter: + max_body_bytes: 67108864 + ws: + path_prefix: /ws + type: proxy + access: public + grpc: + path_prefix: /grpc + type: proxy + access: public + assets-public: + path_prefix: /assets + type: asset + access: public + allowed_methods: ["GET", "HEAD"] + assets-secure: + path_prefix: /secure-assets + type: asset + access: authenticated + allowed_methods: ["GET", "HEAD"] + auth: + require: bearer +token_validation: + issuers: + - name: it-static + issuer: https://api-sheriff.test/it + jwks: + source: file + file: /app/certificates/test-jwks.json + - name: benchmark-keycloak + issuer: https://keycloak:8443/realms/benchmark + jwks: + source: http + url: https://keycloak:8443/realms/benchmark/protocol/openid-connect/certs + allowed_egress_hosts: ["keycloak"] + tls_profile: benchmark-idp diff --git a/integration-tests/src/main/docker/sheriff-config/gateway.yaml b/integration-tests/src/main/docker/sheriff-config/gateway.yaml index 82cec277..356c3aa1 100644 --- a/integration-tests/src/main/docker/sheriff-config/gateway.yaml +++ b/integration-tests/src/main/docker/sheriff-config/gateway.yaml @@ -23,6 +23,20 @@ tls: # HTTP/2 at the edge instead of silently falling back to HTTP/1.1 — a fallback # would report an h2 number that was never measured over h2. alpn: ["h2", "http/1.1"] + # Accept-time SNI passthrough split (GW-06 / D1). Declaring a non-empty passthrough_sni + # is what STARTS the Vert.x SNI front listener (tls/SniFrontListener via tls/TlsEdgeProducer) + # on the public TLS port; the terminated Quarkus HTTPS listener is moved to the internal + # loopback port (QUARKUS_HTTP_SSL_PORT=8444, set on the api-sheriff service in + # docker-compose.yml) so the front owns 8443 without a bind conflict. Each SNI maps to a + # topology alias resolved (base-path-free) through topology.properties: + # - passthrough.test.example -> the TLS passthrough-backend (CN=passthrough-backend), so + # TlsPassthroughIT observes the BACKEND's certificate through the opaque L4 relay, and + # HostSmuggleGuardIT's runtime Host-vs-SNI guard has a reserved hostname to reject. + # - fault.test.example -> the same backend fronted by Toxiproxy, so PassthroughFaultIT + # can inject a mid-stream reset and prove the relay surfaces it as an abort, not a hang. + passthrough_sni: + passthrough.test.example: PASSTHROUGH_BACKEND + fault.test.example: PASSTHROUGH_FAULT anchors: api: path_prefix: /proxy diff --git a/integration-tests/src/main/docker/sheriff-config/topology.properties b/integration-tests/src/main/docker/sheriff-config/topology.properties index 43413d99..a3d20640 100644 --- a/integration-tests/src/main/docker/sheriff-config/topology.properties +++ b/integration-tests/src/main/docker/sheriff-config/topology.properties @@ -21,6 +21,20 @@ ASSET_ORIGIN=http://asset-origin:80 # the opaque relay (echo round-trip, Origin gate, idle reclaim) against it. WS_UPSTREAM=http://go-httpbin:8080/websocket/echo +# PASSTHROUGH_BACKEND / PASSTHROUGH_FAULT back the accept-time SNI passthrough split +# (tls.passthrough_sni in gateway.yaml). The opaque L4 relay dials these targets over plain +# TCP — it never speaks TLS itself — so only host:port is used; the https scheme keeps the +# URL well-formed and the origin base-path-free (ConfigValidator.validatePassthroughAliasResolvable +# rejects a passthrough alias that resolves to a non-root base path). +# - PASSTHROUGH_BACKEND: the TLS-enabled passthrough-backend (nginx, CN=passthrough-backend) +# reached directly on the compose network; TlsPassthroughIT asserts the peer certificate it +# negotiates through the relay is the backend's, proving the gateway never terminated it. +# - PASSTHROUGH_FAULT: the same backend reached via Toxiproxy's listen endpoint (toxiproxy:8666); +# PassthroughFaultIT creates that proxy at test time and injects a reset_peer toxic to prove +# the relay tears the client leg down promptly (abort) rather than hanging. +PASSTHROUGH_BACKEND=https://passthrough-backend:8443 +PASSTHROUGH_FAULT=https://toxiproxy:8666 + # GRPC_ECHO is the in-repo Quarkus gRPC echo upstream (grpc-echo:9000, plaintext h2c — # the gateway's forced-h2 client speaks prior-knowledge h2c to the http-scheme target). The # alias carries no base path: each gRPC route matches a bare service path diff --git a/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsEdgeActivationWiringTest.java b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsEdgeActivationWiringTest.java new file mode 100644 index 00000000..84ec7e71 --- /dev/null +++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsEdgeActivationWiringTest.java @@ -0,0 +1,228 @@ +/* + * Copyright © 2026 CUI-OpenSource-Software (info@cuioss.de) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.cuioss.sheriff.gateway.integration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Properties; +import org.yaml.snakeyaml.Yaml; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Fast, no-Docker surefire guard that the committed integration-test deployment descriptors + * actually activate the accept-time TLS edge and the mTLS listener — the exact wiring + * whose absence made the five TLS-edge ITs ({@code TlsPassthroughIT}, {@code MtlsHandshakeIT}, + * {@code HostSmuggleGuardIT}, {@code PassthroughFaultIT}) pass their black-box setup yet fail their + * assertions against the real Docker runtime. + *

+ * The production TLS components ({@code tls/SniFrontListener}, {@code tls/MtlsServerCustomizer}, + * {@code pipeline/PassthroughHostGuardStage}, …) are each unit-covered and correct in isolation; the + * whole edge is nonetheless opt-in, so a mounted {@code gateway.yaml} that omits + * {@code tls.passthrough_sni} / {@code tls.mtls}, a compose stack that never performs the + * public/internal port split, an absent mTLS gateway instance, or missing client-cert material leaves + * every component inert. A per-component unit test structurally cannot see that gap; this descriptor + * assertion is the lowest-level regression guard that can. + *

+ * It parses the committed descriptors only (YAML / properties / POM text) and asserts the activation + * is present — it starts no container and reaches no network. + * + * @author API Sheriff Team + * @since 1.0 + */ +class TlsEdgeActivationWiringTest { + + /** The module base directory (surefire runs with the module root as the working directory). */ + private static final Path MODULE = Path.of(System.getProperty("user.dir")); + private static final Path DOCKER = MODULE.resolve("src/main/docker"); + private static final Path CERTS = DOCKER.resolve("certificates"); + + private static final String PASSTHROUGH_SNI = "passthrough.test.example"; + private static final String FAULT_SNI = "fault.test.example"; + + @Test + @DisplayName("the mounted gateway.yaml declares a non-empty passthrough_sni mapping the test SNIs to aliases") + void primaryGatewayDeclaresPassthroughSni() throws Exception { + // Arrange + Map tls = tlsBlock(DOCKER.resolve("sheriff-config/gateway.yaml")); + + // Act + Object passthrough = tls.get("passthrough_sni"); + + // Assert — the front listener (and the runtime Host-smuggle guard) only activate on a + // non-empty passthrough_sni that names the test hostnames the ITs open connections to. + assertNotNull(passthrough, "tls.passthrough_sni must be present so the SNI front listener starts"); + assertInstanceOf(Map.class, passthrough, "tls.passthrough_sni must be a map of SNI -> alias"); + @SuppressWarnings("unchecked") + Map sniMap = (Map) passthrough; + assertFalse(sniMap.isEmpty(), "tls.passthrough_sni must be non-empty to activate the accept-time split"); + assertTrue(sniMap.containsKey(PASSTHROUGH_SNI), + "tls.passthrough_sni must map the passthrough SNI '" + PASSTHROUGH_SNI + "'"); + assertTrue(sniMap.containsKey(FAULT_SNI), + "tls.passthrough_sni must map the fault-injection SNI '" + FAULT_SNI + "'"); + } + + @Test + @DisplayName("every passthrough_sni alias resolves base-path-free in topology.properties") + void passthroughAliasesResolveBasePathFree() throws Exception { + // Arrange + Map tls = tlsBlock(DOCKER.resolve("sheriff-config/gateway.yaml")); + Object passthrough = tls.get("passthrough_sni"); + assertInstanceOf(Map.class, passthrough, "tls.passthrough_sni must be a map"); + @SuppressWarnings("unchecked") + Map sniMap = (Map) passthrough; + Properties topology = topology(); + + // Act + Assert — mirror ConfigValidator.validatePassthroughAliasResolvable: the alias must be + // present in topology.properties and resolve to an origin without a base path. + for (Map.Entry entry : sniMap.entrySet()) { + String alias = String.valueOf(entry.getValue()); + String url = topology.getProperty(alias); + assertNotNull(url, "passthrough_sni alias '" + alias + "' (SNI '" + entry.getKey() + + "') must resolve in topology.properties"); + String path = URI.create(url.trim()).getPath(); + assertTrue(path == null || path.isEmpty() || "/".equals(path), + "passthrough_sni alias '" + alias + "' must resolve to a base-path-free origin, was: " + url); + } + } + + @Test + @DisplayName("the mTLS instance gateway.yaml enables mtls with a client_ca trust anchor") + void mtlsGatewayEnablesClientAuth() throws Exception { + // Arrange + Map tls = tlsBlock(DOCKER.resolve("sheriff-config-mtls/gateway.yaml")); + + // Act + Object mtls = tls.get("mtls"); + + // Assert — MtlsServerCustomizer flips the terminated listener to require-and-verify only when + // mtls.enabled is set; a missing client_ca fails the boot fast, so both must be present. + assertNotNull(mtls, "the mTLS instance must declare tls.mtls to require client certificates"); + assertInstanceOf(Map.class, mtls, "tls.mtls must be a map"); + @SuppressWarnings("unchecked") + Map mtlsMap = (Map) mtls; + assertEquals(Boolean.TRUE, mtlsMap.get("enabled"), "tls.mtls.enabled must be true on the mTLS instance"); + assertNotNull(mtlsMap.get("client_ca"), "tls.mtls.client_ca trust anchor must be configured"); + } + + @Test + @DisplayName("docker-compose splits the primary listener to the internal port and publishes the mTLS instance") + void composePerformsPortSplitAndPublishesMtlsInstance() throws Exception { + // Arrange + Map services = composeServices(); + + // Act — the primary gateway must move its terminated Quarkus HTTPS listener to the internal + // port so the SNI front owns the public port without a bind conflict. + List primaryEnv = environment(services, "api-sheriff"); + + // Assert + assertTrue(primaryEnv.contains("QUARKUS_HTTP_SSL_PORT=8444"), + "the primary api-sheriff service must set QUARKUS_HTTP_SSL_PORT=8444 (internal-port split)"); + + Object mtlsService = services.get("api-sheriff-mtls"); + assertNotNull(mtlsService, "a dedicated api-sheriff-mtls gateway instance must be defined"); + @SuppressWarnings("unchecked") + Map mtls = (Map) mtlsService; + Object ports = mtls.get("ports"); + assertInstanceOf(List.class, ports, "the mTLS instance must publish ports"); + boolean publishesMtlsPort = ((List) ports).stream() + .map(String::valueOf) + .anyMatch(p -> p.startsWith("10444:")); + assertTrue(publishesMtlsPort, "the mTLS instance must publish host port 10444 for MtlsHandshakeIT"); + } + + @Test + @DisplayName("the client and wrong-CA mTLS keystores are provisioned") + void mtlsKeystoresExist() { + // Assert — MtlsHandshakeIT loads these PKCS#12 keystores from the host; they must be generated. + assertTrue(Files.isRegularFile(CERTS.resolve("mtls-client.p12")), + "the trusted client keystore mtls-client.p12 must exist"); + assertTrue(Files.isRegularFile(CERTS.resolve("mtls-wrong.p12")), + "the foreign-CA client keystore mtls-wrong.p12 must exist"); + assertTrue(Files.isRegularFile(CERTS.resolve("mtls-client-ca.crt")), + "the client CA trust anchor mtls-client-ca.crt must exist for the mTLS instance"); + } + + @Test + @DisplayName("the Failsafe configuration wires the test.mtls.* system properties") + void failsafeWiresMtlsSystemProperties() throws Exception { + // Arrange + String pom = Files.readString(MODULE.resolve("pom.xml")); + + // Assert — without these, MtlsHandshakeIT falls back to null keystores / port 10443 and cannot + // reach the mTLS instance with a client identity. + assertTrue(pom.contains(""), "pom must wire test.mtls.port to the mTLS instance port"); + assertTrue(pom.contains(""), + "pom must wire test.mtls.client.keystore to the trusted client keystore"); + assertTrue(pom.contains(""), + "pom must wire test.mtls.wrong.keystore to the foreign-CA client keystore"); + } + + @SuppressWarnings("unchecked") + private static Map tlsBlock(Path gatewayYaml) throws IOException { + Map doc = loadYaml(gatewayYaml); + Object tls = doc.get("tls"); + assertNotNull(tls, "gateway.yaml " + gatewayYaml + " must declare a tls block"); + assertInstanceOf(Map.class, tls, "the tls block must be a mapping"); + return (Map) tls; + } + + @SuppressWarnings("unchecked") + private static Map composeServices() throws IOException { + Map doc = loadYaml(MODULE.resolve("docker-compose.yml")); + Object services = doc.get("services"); + assertInstanceOf(Map.class, services, "docker-compose.yml must declare services"); + return (Map) services; + } + + @SuppressWarnings("unchecked") + private static List environment(Map services, String service) { + Object node = services.get(service); + assertNotNull(node, "docker-compose.yml must declare the '" + service + "' service"); + Map serviceMap = (Map) node; + Object env = serviceMap.get("environment"); + assertInstanceOf(List.class, env, "the '" + service + "' service environment must be a list"); + return ((List) env).stream().map(String::valueOf).toList(); + } + + @SuppressWarnings("unchecked") + private static Map loadYaml(Path path) throws IOException { + try (InputStream in = Files.newInputStream(path)) { + return new Yaml().loadAs(in, Map.class); + } + } + + private static Properties topology() throws IOException { + Properties properties = new Properties(); + try (Reader reader = Files.newBufferedReader(DOCKER.resolve("sheriff-config/topology.properties"))) { + properties.load(reader); + } + return properties; + } +} From ec0405d3846f93a87c4c2d79b81004ec971e4b67 Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:30:45 +0200 Subject: [PATCH 14/15] fix(tls-edge): address CodeRabbit review on PR #100 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - benchmarks/pom.xml: move compare-passthrough-baseline after stop-integration-containers so a regression (IllegalStateException) never aborts before container teardown, leaving orphaned Docker containers. - ClientHelloSniParserTest: rebuild withCorruptExtensionLength() to inflate the server_name extensions-block length directly (via withRawExtensionsDeclaring) so malformedExtensionLength genuinely trips the extensionsEnd bound check instead of an unintended cipher-suites overrun path. - benchmarks/README.adoc: correct the passthrough vs ws/grpc analogy — ws/grpc fold a real backend's cost into the number but the gateway still terminates their TLS; only passthrough's backend terminates its own TLS. - doc/configuration.adoc + doc/user/tls-edge.adoc: document the runtime SNI trailing-dot removal and Host :port stripping before the passthrough/smuggle decision, in both operator-facing locations. Co-Authored-By: Claude --- .../gateway/tls/ClientHelloSniParserTest.java | 42 +++++++++++-------- benchmarks/README.adoc | 12 +++--- benchmarks/pom.xml | 35 ++++++++-------- doc/configuration.adoc | 6 ++- doc/user/tls-edge.adoc | 8 +++- 5 files changed, 61 insertions(+), 42 deletions(-) diff --git a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java index 26384d38..b2ae5ad9 100644 --- a/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java +++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java @@ -314,25 +314,33 @@ static byte[] withSniSplitAcrossRecords(String sni, int firstRecordPayload) { return out.toByteArray(); } - /** A ClientHello whose SNI extension declares a length running past the buffer. */ + /** + * A ClientHello carrying a well-formed {@code server_name} extension whose declared + * extensions-block length is inflated past the actual bytes, so the extensions region + * overruns the reassembled handshake buffer. The {@code session_id} and {@code cipher_suites} + * length fields stay intact, so the parser reaches the extension walk and fails closed on the + * {@code extensionsEnd > handshake.length} bound check. + */ static byte[] withCorruptExtensionLength() { - byte[] hello = withSni("corrupt.example.com"); - // The extension length is the two bytes immediately after the server_name extension type - // (0x00 0x00). Locate the first 0x00 0x00 pair inside the extensions area and inflate the - // following length so it overruns the buffer. - for (int i = 5 + 4 + 34; i < hello.length - 4; i++) { - if (hello[i] == 0x00 && hello[i + 1] == 0x00) { - hello[i + 2] = (byte) 0x7F; - hello[i + 3] = (byte) 0xFF; - break; - } - } - return hello; + byte[] extensions = serverNameExtension("corrupt.example.com"); + return withRawExtensionsDeclaring(extensions, extensions.length + 0x0100); } /** A complete single-record ClientHello whose extensions block is exactly the given bytes. */ static byte[] withRawExtensions(byte[] extensionsBlock) { - return handshakeRecord(HANDSHAKE_CLIENT_HELLO, clientHelloBodyRaw(extensionsBlock)); + return handshakeRecord(HANDSHAKE_CLIENT_HELLO, + clientHelloBodyRaw(extensionsBlock, extensionsBlock.length)); + } + + /** + * A complete single-record ClientHello whose extensions block is {@code extensionsBlock} but + * whose declared extensions-block length is {@code declaredLength}. Passing a + * {@code declaredLength} larger than {@code extensionsBlock.length} inflates the extensions + * region past the buffer to exercise the parser's extensions-length overrun (fail-closed) path. + */ + static byte[] withRawExtensionsDeclaring(byte[] extensionsBlock, int declaredLength) { + return handshakeRecord(HANDSHAKE_CLIENT_HELLO, + clientHelloBodyRaw(extensionsBlock, declaredLength)); } /** A raw TLS extension: {@code type(2) + length(2) + body}. */ @@ -428,7 +436,7 @@ private static byte[] clientHelloBody(String sni) { return out.toByteArray(); } - private static byte[] clientHelloBodyRaw(byte[] extensions) { + private static byte[] clientHelloBodyRaw(byte[] extensions, int declaredExtensionsLength) { ByteArrayOutputStream out = new ByteArrayOutputStream(); out.write(0x03); out.write(0x03); // legacy_version TLS 1.2 @@ -440,8 +448,8 @@ private static byte[] clientHelloBodyRaw(byte[] extensions) { out.write(0x01); // TLS_AES_128_GCM_SHA256 out.write(0x01); // compression_methods length 1 out.write(0x00); // null compression - out.write((extensions.length >> 8) & 0xFF); - out.write(extensions.length & 0xFF); + out.write((declaredExtensionsLength >> 8) & 0xFF); + out.write(declaredExtensionsLength & 0xFF); out.writeBytes(extensions); return out.toByteArray(); } diff --git a/benchmarks/README.adoc b/benchmarks/README.adoc index e170cff4..ce2c22fd 100644 --- a/benchmarks/README.adoc +++ b/benchmarks/README.adoc @@ -210,11 +210,13 @@ sides stay symmetric even though these rows are not backend-free. See the fairne _The aspect matrix_ for how to read the two rows. Passthrough relay, two modes (API-Sheriff-only):: -The `passthrough` aspect drives the opaque L4 TCP relay and — unlike the six pure-overhead HTTP -aspects, and exactly like `ws` and `grpc` — rides a *TLS-enabled backend* that completes the -handshake and presents its own certificate. It therefore folds a real backend's cost into the -number and is a *protocol-relay measurement*, not a backend-free gateway-overhead figure: read it as -relay overhead versus the terminated baseline within one run, never as an absolute throughput claim. +The `passthrough` aspect drives the opaque L4 TCP relay. Like `ws` and `grpc`, it folds a real +backend's cost into the number rather than being backend-free — so it is a *protocol-relay +measurement*, not a pure gateway-overhead figure. It is *distinct* from `ws` and `grpc` in one way: +for those the gateway still owns TLS termination, whereas passthrough's backend is *TLS-enabled* and +terminates its own TLS — completing the handshake and presenting its own certificate — while the +gateway relays the still-encrypted stream at L4. Read it as relay overhead versus the terminated +baseline within one run, never as an absolute throughput claim. It runs in two modes selected by `PASSTHROUGH_SNI`. In `mapped` mode the ClientHello names a `tls.passthrough_sni` host, so the gateway relays the still-encrypted byte stream at L4 without terminating and the run measures throughput/latency through the active relay path. In `empty` mode diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml index e5f90da1..b2caada7 100644 --- a/benchmarks/pom.xml +++ b/benchmarks/pom.xml @@ -613,23 +613,6 @@ - - - compare-passthrough-baseline - post-integration-test - - java - - - de.cuioss.sheriff.gateway.k6.benchmark.PassthroughBaselineComparator - - ${k6.output.dir} - - - - dump-keycloak-logs @@ -665,6 +648,24 @@ + + + compare-passthrough-baseline + post-integration-test + + java + + + de.cuioss.sheriff.gateway.k6.benchmark.PassthroughBaselineComparator + + ${k6.output.dir} + + + + clean-integration-tests-for-rebuild diff --git a/doc/configuration.adoc b/doc/configuration.adoc index 22c19b19..582b9af3 100644 --- a/doc/configuration.adoc +++ b/doc/configuration.adoc @@ -770,7 +770,11 @@ hostname in `passthrough_sni` does nothing -- it never appears in the outer SNI. merely parsed: an accept-time front listener reads the SNI from the reassembled ClientHello and splits each connection at L4 (passthrough) or L7 (terminated); terminated connections apply `mtls` client-certificate verification; and the runtime Host-vs-SNI smuggle guard rejects a -terminated request whose `Host` names a passthrough SNI with `404` before route selection. Three +terminated request whose `Host` names a passthrough SNI with `404` before route selection. Before +each match the runtime normalizes both sides: the SNI is lower-cased and one trailing FQDN dot is +removed, and the terminated request's `Host` additionally has a single `:port` suffix stripped -- so +`Backend.Example.Com.`, `backend.example.com`, and `backend.example.com:8443` all resolve to the same +passthrough key. Three constraints an operator must keep in mind, all stated above and restated here because they are the common configuration mistakes: SNI matching is *exact* -- *no wildcard* entries; a passthrough alias URL carries *no base path*; and `mtls` applies to *terminated connections only* (the gateway diff --git a/doc/user/tls-edge.adoc b/doc/user/tls-edge.adoc index 62ba45c8..e4420b73 100644 --- a/doc/user/tls-edge.adoc +++ b/doc/user/tls-edge.adoc @@ -29,7 +29,9 @@ topology. Operating rules to keep in mind: * *Exact match, case-insensitive, no wildcards.* Each hostname is listed literally; - `*.example.com` is not supported. List every host you intend to pass through. + `*.example.com` is not supported. List every host you intend to pass through. Before matching, the + SNI is lower-cased and a single trailing FQDN dot is removed, so `backend.example.com.` matches the + `backend.example.com` you listed. * *No base path on the alias.* An L4 relay has no notion of a path -- the resolved URL must be a bare `host:port`. A base-path'd alias fails the boot. * *Declared here, per SNI -- never on a route.* Passthrough is a listener-level concern, not a @@ -37,7 +39,9 @@ Operating rules to keep in mind: * *Terminated and passthrough coexist* on the same public port. Any SNI you do not list is terminated and runs the full L7 pipeline. * *A terminated request whose `Host` names a passthrough SNI is rejected `404`* before routing -- - this is the runtime Host-smuggle guard; it is automatic and needs no configuration. + this is the runtime Host-smuggle guard; it is automatic and needs no configuration. The guard + normalizes the `Host` the same way (lower-case, one trailing dot removed) and additionally strips a + single `:port` suffix, so `backend.example.com:8443` cannot slip past it. == mTLS (client-certificate verification) From 127baa255c341ca8c8bf0364d9bdfa40e0a622ac Mon Sep 17 00:00:00 2001 From: cuioss oliver <23139298+cuioss@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:10:01 +0200 Subject: [PATCH 15/15] docs(adr): add ADR-0017 for the accept-time SNI split Record the decision to place a dedicated raw-TCP front listener on the public TLS port that reads SNI from the reassembled ClientHello and either opaquely L4-relays a passthrough match (backend keeps end-to-end cert identity) or hands the still-encrypted connection to an internal terminating HTTPS listener that owns TLS termination and mTLS. Documents the rejected alternatives (framework SNI seam; terminate-and-re-originate) and the fail-closed pre-handshake rationale. Co-Authored-By: Claude --- ...everything_else_terminates_internally.adoc | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 doc/adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc diff --git a/doc/adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc b/doc/adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc new file mode 100644 index 00000000..cb5f557a --- /dev/null +++ b/doc/adr/0017-Accept-time_SNI_split_at_a_dedicated_front_listener_passthrough_relays_opaquely_at_L4_everything_else_terminates_internally.adoc @@ -0,0 +1,115 @@ += ADR-0017: Accept-time SNI split at a dedicated front listener; passthrough relays opaquely at L4, everything else terminates internally +:toc: left +:toclevels: 2 +:sectnums: + +// adr-metadata +// Progressive-disclosure metadata block (see manage-adr SKILL.md → "ADR Template +// Structure"). Read by `manage-adr.py scan` so a caller can assess an ADR's +// relevance without reading the full file. List fields are comma-separated. +// summary: A dedicated raw-TCP front listener owns the public TLS port, reassembles the ClientHello, and by SNI either opaquely L4-relays a passthrough match to its resolved backend or hands the still-encrypted connection to an internal terminating HTTPS listener that solely owns TLS termination and mTLS. +// tags: tls, sni, edge, passthrough, mtls, fail-closed, listener-topology, framework-seam +// affects: api-sheriff +// supersedes: +// end-adr-metadata + +// Authoring discipline (see manage-adr SKILL.md → "Authoring Discipline"): +// ADRs are durable architectural statements, not incident write-ups. No PR numbers, +// commit SHAs, dates, or lesson IDs anywhere in the body. CLAUDE.md's "current state +// only" rule applies — describe the architecture, not the chronology. + +== Status + +Proposed + +== Context + +A security gateway must serve two mutually-exclusive dispositions for an inbound TLS +connection arriving on the same public port. Some hostnames must be relayed opaquely at +L4 -- the gateway never sees plaintext and the backend presents its own certificate +end-to-end. Every other hostname must be TLS-terminated at the gateway, with optional +client-certificate (mTLS) verification. + +The disposition is knowable only from the Server Name Indication carried in the TLS +ClientHello, which arrives before any handshake completes. A framework whose HTTPS +listener exposes SNI only at its own handshake layer has, by the time SNI is visible, +already committed to terminating the connection -- it offers no seam to divert a raw, +still-encrypted TCP stream by SNI before termination. The architecture must decide where +the accept-time SNI decision lives, how the two dispositions co-exist on one public port, +and how the fail-closed posture is preserved: an ambiguous, empty, or SNI-less hello must +never fall through to the more permissive path. + +== Decision + +* *A dedicated front listener owns the public TLS port.* It is a raw-TCP, non-terminating + listener that buffers bytes until the full ClientHello is reassembled, reads the SNI, + and routes on it: +** SNI matches a configured passthrough entry -> opaque L4 relay to the topology-resolved + backend. The gateway never handshakes, so the backend's own certificate reaches the + client end-to-end. +** SNI empty, unresolved, or non-matching -> the still-encrypted stream is handed to an + internal terminating HTTPS listener, which solely owns TLS termination and mTLS + client-certificate verification. +* *Port ownership is split.* The public port belongs to the front listener; the + terminating listener moves to an internal port. Termination and mTLS are exclusively the + terminating listener's responsibility -- the passthrough path never participates in a + handshake. +* *Fail-closed SNI.* The disposition is decided only after the full ClientHello is + reassembled. An empty or unresolved SNI takes the terminated-strict path -- never a + passthrough, never a no-client-cert default. +* *Zero-overhead default.* When no passthrough hostnames are configured, the front listener + is not started and the single-terminating-listener topology is unchanged. + +== Consequences + +=== Positive + +* One public port serves both the opaque-relay and terminated dispositions, with the + disposition decided at the earliest possible point -- accept time, before any handshake. +* End-to-end certificate identity is preserved for passthrough targets because the gateway + never terminates them. +* The fail-closed rule is structural: an ambiguous hello cannot reach the permissive path, + independent of any per-request check. + +=== Negative + +* The listener topology is now two-tier (front plus internal), a permanent increase in + moving parts and a port-ownership contract that deployments must respect. +* The gateway hand-rolls ClientHello reassembly and SNI extraction because the framework + exposes no pre-termination seam; that parser is a security-relevant surface the project + now owns and must test at handshake level, not by config assertion. + +=== Risks + +* A malformed or maximally-fragmented ClientHello is handled by failing closed to the + terminated-strict path. A passthrough client that emits a pathological hello loses + passthrough rather than risking a wrong disposition -- an accepted trade of availability + for a guaranteed-safe disposition. + +== Alternatives Considered + +* *Framework listener-level SNI seam.* Let the framework's own HTTPS listener read SNI + during its handshake and branch there. Rejected because the framework exposes SNI only + after it has committed to terminating the connection; there is no clean seam to divert a + raw, still-encrypted TCP stream by SNI before termination. Reading SNI at that layer + cannot yield an opaque L4 passthrough in which the backend presents its own certificate, + because the connection is already being terminated by the time SNI is visible. +* *Single terminating listener that re-originates TLS to passthrough backends.* Terminate + every connection at the gateway and open a fresh TLS connection to the backend for + passthrough hostnames. Rejected because it breaks end-to-end certificate identity -- the + client sees the gateway's certificate, not the backend's -- and places the gateway on the + plaintext path for traffic that is explicitly meant to remain opaque, enlarging the trust + boundary rather than preserving it. + +Both alternatives place the SNI decision at or after the framework's termination +commitment. The failure they share is that termination has already begun before the +disposition is known, which is incompatible with opaque L4 passthrough and with a +fail-closed, pre-handshake decision. + +== References + +* link:0005-module-structure.adoc[ADR-0005] -- the edge/core boundary the front listener respects +* link:0008-data-plane-transport.adoc[ADR-0008] -- the upstream Vert.x streaming leg this ingress feeds +* link:0009-asymmetric-alias-resolution-failure.adoc[ADR-0009] -- the resolved passthrough-alias map the front listener consumes +* `doc/development/tls-edge.adoc` -- developer topic: front-listener architecture, the L4/L7 split, port ownership, and the GW-06 fail-closed rationale +* `doc/security-threat-model.adoc` -- GW-06 fail-closed SNI