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..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 100} / {@code 103-106} 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
@@ -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,27 @@ 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();
+
+ /**
+ * 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..5d22e23a
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStage.java
@@ -0,0 +1,100 @@
+/*
+ * 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/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..69089a2c
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParser.java
@@ -0,0 +1,318 @@
+/*
+ * 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.io.Serial;
+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:
+ *
+ * - {@link Result#needMoreData()} — the ClientHello is not yet complete; the caller keeps
+ * buffering. Bounded by {@link #MAX_CLIENT_HELLO_BYTES}: a client that never completes a valid
+ * ClientHello within the bound is treated as complete-with-no-SNI, so buffering can never grow
+ * unbounded.
+ * - {@link Result#parsed(Optional)} — a complete ClientHello was seen. The
+ * {@link Result#serverName()} is present when a usable SNI {@code host_name} was extracted, and
+ * empty for a ClientHello that carries no SNI, is malformed, is not a TLS handshake, or
+ * exceeds the size bound. An empty result is the caller's fail-closed signal: it takes the
+ * terminated-strict path, never a passthrough.
+ *
+ * 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 {
+
+ @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) {
+
+ /**
+ * 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/MtlsServerCustomizer.java b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java
new file mode 100644
index 00000000..f8b3604e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizer.java
@@ -0,0 +1,86 @@
+/*
+ * 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/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..545ed0e1
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/PassthroughRelay.java
@@ -0,0 +1,196 @@
+/*
+ * 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..be67a5dc
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/SniFrontListener.java
@@ -0,0 +1,200 @@
+/*
+ * 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;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * 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 @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..267add0e
--- /dev/null
+++ b/api-sheriff/src/main/java/de/cuioss/sheriff/gateway/tls/TlsEdgeProducer.java
@@ -0,0 +1,161 @@
+/*
+ * 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..1aa86925 100644
--- a/api-sheriff/src/main/resources/application.properties
+++ b/api-sheriff/src/main/resources/application.properties
@@ -15,6 +15,25 @@ 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
+
+# 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/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..b01535b5
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/pipeline/PassthroughHostGuardStageTest.java
@@ -0,0 +1,218 @@
+/*
+ * 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));
+ }
+
+ @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
+ @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/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..b2ae5ad9
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/ClientHelloSniParserTest.java
@@ -0,0 +1,481 @@
+/*
+ * 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());
+ }
+ }
+
+ @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");
+ }
+ }
+
+ /**
+ * 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 static final int EXTENSION_TYPE_SERVER_NAME = 0x0000;
+
+ 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 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[] 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, 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}. */
+ 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;
+ 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[] clientHelloBodyRaw(byte[] extensions, int declaredExtensionsLength) {
+ 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((declaredExtensionsLength >> 8) & 0xFF);
+ out.write(declaredExtensionsLength & 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/MtlsServerCustomizerTest.java b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java
new file mode 100644
index 00000000..40695e5a
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/MtlsServerCustomizerTest.java
@@ -0,0 +1,117 @@
+/*
+ * 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);
+ }
+}
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..b6e5d55d
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/PassthroughRelayTest.java
@@ -0,0 +1,184 @@
+/*
+ * 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.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;
+
+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(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(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..f740ecce
--- /dev/null
+++ b/api-sheriff/src/test/java/de/cuioss/sheriff/gateway/tls/SniFrontListenerTest.java
@@ -0,0 +1,174 @@
+/*
+ * 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("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 {
+ 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/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");
+ }
+ }
+}
diff --git a/benchmarks/README.adoc b/benchmarks/README.adoc
index 294cb39e..ce2c22fd 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,25 @@ 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. 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
+`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..b2caada7 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
+
+
+
+
@@ -578,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/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..5a9f3fab
--- /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..2fe55358
--- /dev/null
+++ b/benchmarks/src/test/java/de/cuioss/sheriff/gateway/k6/benchmark/PassthroughBaselineComparatorTest.java
@@ -0,0 +1,295 @@
+/*
+ * 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.*;
+
+/**
+ * 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);
+ }
+ }
+}
diff --git a/doc/LogMessages.adoc b/doc/LogMessages.adoc
index cfd7ff0c..db408b94 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,8 @@ 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
+|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)
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/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.
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
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..582b9af3 100644
--- a/doc/configuration.adoc
+++ b/doc/configuration.adoc
@@ -765,6 +765,24 @@ 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. 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
+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..e4420b73
--- /dev/null
+++ b/doc/user/tls-edge.adoc
@@ -0,0 +1,82 @@
+= 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. 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
+ 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. 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)
+
+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.
diff --git a/integration-tests/docker-compose.yml b/integration-tests/docker-compose.yml
index 709f2fb0..a0b961b5 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
@@ -102,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
@@ -174,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 00000000..4d1a6ec0
Binary files /dev/null and b/integration-tests/src/main/docker/certificates/mtls-client.p12 differ
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 00000000..847af6ad
Binary files /dev/null and b/integration-tests/src/main/docker/certificates/mtls-wrong.p12 differ
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/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/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..3553708c
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/MtlsHandshakeIT.java
@@ -0,0 +1,152 @@
+/*
+ * 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 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.
+ */
+ 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..1dddc411
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/PassthroughFaultIT.java
@@ -0,0 +1,171 @@
+/*
+ * 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.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 de.cuioss.sheriff.gateway.integration.MtlsHandshakeIT.TrustAllManager;
+
+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.
+ }
+ }
+}
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;
+ }
+}
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..fdb3109d
--- /dev/null
+++ b/integration-tests/src/test/java/de/cuioss/sheriff/gateway/integration/TlsPassthroughIT.java
@@ -0,0 +1,111 @@
+/*
+ * 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 de.cuioss.sheriff.gateway.integration.MtlsHandshakeIT.TrustAllManager;
+
+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];
+ }
+ }
+}
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;